"""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()