Files
Claude Agent ed7eca4fc7 fix(discord): watchdog de gateway — procesul viu pe un gateway mort nu mai trece neobservat
Puntea a tacut ~7 ore fara ca nimic sa semnaleze: discord.py memoreaza
`resume_gateway_url` primit la ultimul READY si il refoloseste la fiecare
reconectare. Cand gatewayul regional (gateway-us-east-1a) a inceput sa dea
503 la handshake, botul a reincercat la infinit acelasi host mort, cu backoff
pana la ~15 minute. Procesul era viu, deci nici systemd nici dashboardul nu
aveau ce vedea, iar `gateway.discord.gg` raspundea normal tot timpul.

GatewayWatchdog numara de cat timp e legatura jos (on_connect / on_resumed /
on_ready o ridica, on_disconnect o coboara, iar reincercarile esuate nu
reseteaza ceasul). Peste `GATEWAY_WATCHDOG_S` (implicit 300s) alerteaza, iese
cu codul 3 si lasa systemd sa reporneasca — restartul e singurul lucru care
forteaza un IDENTIFY nou pe gateway.discord.gg. Un prag <= 0 il dezactiveaza.

O iesire la 300s ramane sub StartLimitBurst=5/5min, deci bucla de restart nu
poate ajunge sa lase unitul `failed`. `_hard_exit_after` acopera cazul in care
`close()` se blocheaza pe socketul mort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
2026-09-01 07:37:38 +00:00

475 lines
17 KiB
Python

"""Adaptorul Discord: falsuri pentru discord.py, zero retea, zero API.
Fisierul contine si falsurile folosite de test_allowlist.py si test_commands.py.
"""
from __future__ import annotations
import asyncio
import logging
import pytest
import bot
import limits as limits_mod
import runner as runner_mod
GUILD, CHANNEL, USER = "100", "200", "300"
# ------------------------------------------------------------------ falsuri
class FakeUser:
def __init__(self, uid=USER, is_bot=False):
self.id = uid
self.bot = is_bot
self.display_name = f"user-{uid}"
class FakeSent:
"""Mesajul returnat de channel.send(), editabil ca in discord.py."""
def __init__(self, channel, content, **kw):
self.channel = channel
self.content = content
self.kwargs = kw
self.edits: list[str] = []
self.id = f"sent-{id(self)}"
async def edit(self, content=None, **kw):
if content is not None:
self.content = content
self.edits.append(content)
return self
class FakeChannel:
def __init__(self, cid=CHANNEL, parent_id=None):
self.id = cid
self.parent_id = parent_id
self.sent: list[FakeSent] = []
async def send(self, content=None, **kw):
msg = FakeSent(self, content, **kw)
self.sent.append(msg)
return msg
@property
def texts(self) -> list[str]:
return [m.content or "" for m in self.sent]
@property
def all_text(self) -> str:
return "\n".join(self.texts + [e for m in self.sent for e in m.edits])
class FakeGuild:
def __init__(self, gid=GUILD):
self.id = gid
class FakeAttachment:
"""Minimul din `discord.Attachment` pe care il atinge puntea."""
def __init__(self, filename, data=b"", content_type="", size=None):
self.filename = filename
self.content_type = content_type
self.size = len(data) if size is None else size
self._data = data
self.reads = 0
async def read(self):
self.reads += 1
return self._data
class FakeBrokenAttachment(FakeAttachment):
async def read(self):
raise RuntimeError("retea picata")
class FakeMessage:
def __init__(self, content="salut", *, author=None, channel=None, guild=None,
webhook_id=None, mid="m1", attachments=None):
self.attachments = list(attachments or [])
self.content = content
self.author = author if author is not None else FakeUser()
self.channel = channel if channel is not None else FakeChannel()
self.guild = guild if guild is not None else FakeGuild()
self.webhook_id = webhook_id
self.id = mid
self.reactions: list[str] = []
async def add_reaction(self, emoji):
self.reactions.append(emoji)
class FakeResponse:
"""`interaction.response`: se poate folosi o singura data, ca la Discord."""
def __init__(self):
self.deferred = 0
self.messages: list[FakeSent] = []
async def defer(self, **kw):
self.deferred += 1
async def send_message(self, content=None, **kw):
msg = FakeSent(None, content, **kw)
self.messages.append(msg)
return msg
class FakeFollowup:
def __init__(self, channel):
self.channel = channel
async def send(self, content=None, **kw):
return await self.channel.send(content, **kw)
class FakeInteraction:
"""Minimul din `discord.Interaction` pe care il atinge puntea."""
def __init__(self, *, channel=None, user=None, guild_id=GUILD,
channel_id=None, iid="i1"):
self.channel = channel if channel is not None else FakeChannel()
self.channel_id = channel_id if channel_id is not None else self.channel.id
self.guild_id = guild_id
self.user = user if user is not None else FakeUser()
self.id = iid
self.response = FakeResponse()
self.followup = FakeFollowup(self.channel)
@property
def ephemeral(self) -> str:
"""Textul raspunsurilor efemere (refuzuri)."""
return "\n".join(
(m.content or "") for m in self.response.messages if m.kwargs.get("ephemeral")
)
# ------------------------------------------------------------------ fixturi
@pytest.fixture
def allowed(monkeypatch):
"""Allowlist completa in mediu (config.get cade pe os.environ)."""
monkeypatch.setenv("DISCORD_GUILD_IDS", GUILD)
monkeypatch.setenv("DISCORD_CHANNEL_IDS", CHANNEL)
monkeypatch.setenv("DISCORD_USER_IDS", USER)
return {"guild": GUILD, "channel": CHANNEL, "user": USER}
@pytest.fixture
async def bridge(store, fake_bin, allowed, monkeypatch):
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "normal")
monkeypatch.setenv("FAKE_CLAUDE_COST", "0.0123")
mgr = runner_mod.RunnerManager(
store, claude_bin=fake_bin, is_inflight=store.is_inflight, poll_s=3600
)
lim = limits_mod.Limits(
store, cost_cap=10.0, rate_per_min=100, max_procs=2,
alerter=lambda *a, **k: None,
)
br = bot.Bridge(store, mgr, lim, self_id="999")
br.render.kw = {"min_interval": 0.05, "max_interval": 0.2} # teste rapide
yield br
await br.shutdown()
# -------------------------------------------------------------------- teste
async def test_tur_normal_are_subsol_cu_model_durata_si_cost(bridge):
msg = FakeMessage("cat fac 2+2?")
assert await bridge.handle_message(msg) == "ok"
final = msg.channel.sent[0].content
assert "ecou: cat fac 2+2?" in final
assert "sonnet" in final and "$0.0123 tur" in final and "$0.0123 fir" in final
async def test_costul_se_acumuleaza_pe_fir_si_pe_zi(bridge, store):
ch = FakeChannel()
for i in range(2):
await bridge.handle_message(FakeMessage(f"mesaj {i}", channel=ch, mid=f"m{i}"))
tid = str(ch.id)
assert store.thread(tid)["cost_usd_total"] == pytest.approx(0.0246)
assert store.cost_today() == pytest.approx(0.0246)
assert "$0.0246 fir" in ch.sent[1].content
async def test_inflight_curatat_si_sid_persistat(bridge, store):
msg = FakeMessage("salut")
await bridge.handle_message(msg)
tid = str(msg.channel.id)
assert store.is_inflight(tid) is False
assert store.thread(tid)["sid"] == "sid-fake-0001"
async def test_mesaj_in_timpul_turului_e_steering_nu_tur_nou(bridge, monkeypatch):
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "slow")
monkeypatch.setenv("FAKE_CLAUDE_DELAY", "1.5")
ch = FakeChannel()
first = FakeMessage("prima", channel=ch, mid="m1")
task = asyncio.create_task(bridge.handle_message(first))
tid = str(ch.id)
for _ in range(200): # asteptam sa intre turul in zbor
proc = bridge.runner.procs.get(tid)
if proc is not None and proc.alive and proc.inflight:
break
await asyncio.sleep(0.02)
else: # pragma: no cover
pytest.fail("turul nu a pornit")
await asyncio.sleep(0.25) # lasam CLI-ul fals sa consume primul mesaj
second = FakeMessage("steering", channel=ch, mid="m2")
assert await bridge.handle_message(second) == "steered"
assert second.reactions == ["➡️"]
assert bridge.steered == 1
assert await task == "ok"
# mesajul de steering a ajuns in acelasi tur, nu a deschis unul nou
assert "prima | steering" in bridge.last_result.text
assert len(bridge.runner.procs) == 1
async def test_plafonul_de_cost_opreste_botul_si_o_spune_in_fir(bridge, store):
bridge.limits.cost_cap = 0.001
store.add_cost(None, 0.5)
msg = FakeMessage("mai fa ceva")
assert await bridge.handle_message(msg) == "cost-cap"
assert "plafon" in msg.channel.all_text.lower()
assert bridge.runner.procs == {}
async def test_plafonul_atins_dupa_tur_e_anuntat(bridge):
bridge.limits.cost_cap = 0.005 # sub costul unui tur fals (0.0123)
msg = FakeMessage("un tur scump")
assert await bridge.handle_message(msg) == "ok"
assert "Ma opresc" in msg.channel.all_text
async def test_rate_limit_per_utilizator(bridge):
bridge.limits.rate_per_min = 1
ch = FakeChannel()
assert await bridge.handle_message(FakeMessage("unu", channel=ch, mid="1")) == "ok"
assert await bridge.handle_message(FakeMessage("doi", channel=ch, mid="2")) == "rate-limited"
assert "prea multe mesaje" in ch.all_text
async def test_tur_esuat_raspunde_in_fir(bridge, monkeypatch):
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "eof")
msg = FakeMessage("ceva")
assert await bridge.handle_message(msg) == "failed"
assert "esuat" in msg.channel.all_text
assert bridge.store.is_inflight(str(msg.channel.id)) is False
async def test_sweep_la_pornire_anunta_turul_pierdut_fara_reluare(bridge, store, monkeypatch):
ch = FakeChannel(cid="200")
store.update_thread("200", pid=999999, pid_start_time=1.0)
store.set_inflight("200", "t1", USER, "m1")
bridge.get_channel = lambda cid: ch if str(cid) == "200" else None
lost = await bridge.startup()
assert [x["thread_id"] for x in lost] == ["200"]
assert "pierdut" in ch.all_text
assert store.is_inflight("200") is False
assert bridge.runner.procs == {} # niciun tur nu a fost repornit
async def test_raspuns_lung_devine_atasament(bridge, store, monkeypatch):
lung = "x" * 7000
async def fake_turn(prompt, on_event=None, timeout=None):
await on_event(bot.stream_mod.AssistantText(text=lung))
return runner_mod.TurnOutcome(
result=bot.stream_mod.Result(0.01, 100, False, 1, text=lung)
)
proc = bridge.runner.get("200")
monkeypatch.setattr(proc, "run_turn", fake_turn)
msg = FakeMessage("da-mi mult text")
assert await bridge.handle_message(msg) == "ok"
assert "raspuns lung" in msg.channel.sent[0].content # previzualizarea
if bot.discord is not None: # atasamentul propriu-zis
assert any(m.kwargs.get("file") is not None for m in msg.channel.sent)
# ------------------------------------------------- prefixul vechi nu mai executa
async def test_prefixul_vechi_da_doar_un_indiciu(bridge):
ch = FakeChannel()
assert await bridge.handle_message(FakeMessage("!status", channel=ch)) == "legacy-prefix"
assert "/status" in ch.all_text
assert bridge.runner.procs == {} # niciun tur pornit, niciun cost
async def test_text_care_incepe_cu_exclamare_dar_nu_e_comanda_merge_la_claude(bridge):
ch = FakeChannel()
assert await bridge.handle_message(FakeMessage("!!! ce faci?", channel=ch)) == "ok"
# ------------------------------------------------ zgomot in log si utilizare
async def test_ecoul_propriilor_mesaje_nu_umple_logul_cu_warning(bridge, caplog):
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
msg = FakeMessage("salut", author=FakeUser("999", is_bot=True))
assert await bridge.handle_message(msg) == "rejected"
assert "mesaj respins" not in caplog.text
assert bridge.rejected[-1] == "propriul mesaj" # dar tot se contorizeaza
async def test_utilizatorul_strain_ramane_warning(bridge, caplog):
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
assert await bridge.handle_message(FakeMessage("salut", author=FakeUser("42"))) == "rejected"
assert "mesaj respins (utilizator neautorizat 42)" in caplog.text
def test_status_arata_fereastra_de_utilizare(bridge):
assert "utilizare : in regula" in bridge.status_text("200")
bridge.note_rate_limit(bot.stream_mod.RateLimit(status="allowed", limit_type="five_hour"))
assert "utilizare : in regula" in bridge.status_text("200")
bridge.note_rate_limit(
bot.stream_mod.RateLimit(status="rejected", limit_type="five_hour", resets_at=1788171600)
)
txt = bridge.status_text("200")
assert "⚠️ rejected (five_hour)" in txt and "reset " in txt
def test_limita_atinsa_se_avertizeaza_o_singura_data(bridge, caplog):
ev = bot.stream_mod.RateLimit(status="rejected", limit_type="five_hour")
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
bridge.note_rate_limit(ev)
bridge.note_rate_limit(ev)
assert caplog.text.count("limita de utilizare") == 1
# revenirea la normal rearmeaza avertismentul
bridge.note_rate_limit(bot.stream_mod.RateLimit(status="allowed"))
with caplog.at_level(logging.WARNING, logger="discord-bridge.bot"):
bridge.note_rate_limit(ev)
assert caplog.text.count("limita de utilizare") == 2
def test_progresul_uneltei_arata_ca_turul_traieste():
ev = bot.stream_mod.ToolProgress(tool_name="Bash", tool_id="t1", elapsed_s=30, heartbeat=True)
assert bot._progress_line(ev) == "⏳ ruleaza de 30s"
assert bot._progress_line(
bot.stream_mod.ToolProgress(tool_name="Bash", tool_id="t1", elapsed_s=150)
) == "⏳ ruleaza de 2m30s"
# ------------------------------------------------------------------ aprobari
def test_decizia_cere_allowlist(bridge, allowed):
assert "allowlist" in bridge.decide("777", "req-1", "allow")
def test_decizia_ajunge_la_lane_b(bridge, allowed, monkeypatch):
calls = []
monkeypatch.setattr(
bot.approvals, "submit_decision",
lambda rid, dec: calls.append((rid, dec)) or True,
)
out = bridge.decide(USER, "req-1", "allow")
assert calls == [("req-1", "allow")]
assert "Permis" in out
def test_allow_pe_fir_spune_ca_nu_mai_intreaba(bridge, allowed, monkeypatch):
monkeypatch.setattr(bot.approvals, "submit_decision", lambda rid, dec: True)
out = bridge.decide(USER, "req-1", "allow_session")
assert "nu se mai" in out and "/permisiuni" in out
def test_permisiuni_listeaza_si_revoca(bridge, allowed):
assert "Nicio aprobare" in bridge.permissions_text("200")
bot.approvals.add_grant("200", "host_productie", "comanda catre hostul pvemini")
assert "host_productie" in bridge.permissions_text("200")
assert "Am revocat 1" in bridge.permissions_text("200", revoke=True)
assert "Nicio aprobare" in bridge.permissions_text("200")
def test_permisiuni_fara_lane_b_nu_arunca(bridge, monkeypatch):
monkeypatch.setattr(bot, "approvals", None)
assert "nu e disponibil" in bridge.permissions_text("200")
async def test_sesiune_noua_revoca_aprobarile_firului(bridge, allowed):
bot.approvals.add_grant("200", "rm_recursiv", "stergere recursiva (rm -r)")
ch = FakeChannel()
await bridge.handle_slash(FakeInteraction(channel=ch), "new")
assert "Sesiune noua" in ch.all_text and "revocat si 1" in ch.all_text
assert bot.approvals.list_grants("200") == []
def test_cerere_inexistenta_nu_arunca(bridge, allowed):
assert "nu mai exista" in bridge.decide(USER, "req-inexistent", "deny")
async def test_botul_porneste_si_fara_modulele_lui_b_si_c(bridge, monkeypatch):
"""Lane B/C absente: pornire normala, doar fara aprobari si fara !cleanup."""
monkeypatch.setattr(bot, "approvals", None)
monkeypatch.setattr(bot, "cleanup", None)
assert bridge.wire_approvals() is False
ch = FakeChannel()
await bridge.handle_slash(FakeInteraction(channel=ch), "cleanup")
assert "nu e disponibil" in ch.all_text
assert bridge.approval_view("req-1") is None
assert await bridge.handle_message(FakeMessage("salut", channel=ch)) == "ok"
async def test_cererea_de_aprobare_posteaza_butoane_in_fir(bridge, allowed):
ch = FakeChannel()
bridge.get_channel = lambda cid: ch
await bridge.on_approval_request(
{"request_id": "r1", "thread_id": "200", "tool_name": "Bash", "command": "rm -rf /tmp/x"}
)
assert "Confirmare ceruta" in ch.texts[0]
assert "rm -rf /tmp/x" in ch.texts[0]
if bot.discord is not None:
assert ch.sent[0].kwargs.get("view") is not None
# ---------------------------------------------------------------- watchdog
class FakeClock:
def __init__(self):
self.t = 0.0
def __call__(self):
return self.t
def test_watchdogul_tace_cat_timp_gatewayul_e_sus():
clock = FakeClock()
wd = bot.GatewayWatchdog(300.0, now=clock)
wd.on_up()
clock.t = 10_000.0 # bot linistit ore intregi: niciun eveniment intre timp
assert wd.downtime() == 0.0
assert not wd.expired()
def test_watchdogul_iarta_o_reconectare_scurta():
clock = FakeClock()
wd = bot.GatewayWatchdog(300.0, now=clock)
wd.on_down()
clock.t = 4.0
wd.on_up() # RESUMED
clock.t = 900.0
assert not wd.expired()
def test_watchdogul_expira_dupa_prag_iar_reincercarile_nu_reseteaza_ceasul():
clock = FakeClock()
wd = bot.GatewayWatchdog(300.0, now=clock)
wd.on_down()
for t in (60.0, 120.0, 240.0): # fiecare handshake 503 mai da un on_disconnect
clock.t = t
wd.on_down()
assert not wd.expired()
clock.t = 300.0
assert wd.expired()
assert wd.downtime() == 300.0
def test_watchdogul_se_poate_dezactiva():
clock = FakeClock()
wd = bot.GatewayWatchdog(0.0, now=clock)
wd.on_down()
clock.t = 100_000.0
assert not wd.expired()