Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py
Claude Agent a89cf201ab fix(discord-bridge): zgomot in bot.log si doua tipuri de stream necunoscute
Trei lucruri observate in logurile de productie dupa restartul precedent.

1. Ecoul propriilor mesaje umplea bot.log cu WARNING. Propriile mesaje au si
   ele `author.bot == True`, iar verificarea generica de bot venea INAINTEA
   celei pe `self_id` — deci raspunsurile botului se jurnalizau ca "bot
   strain", la fiecare mesaj. Verificarea pe `self_id` trece prima (motivul e
   acum precis), iar refuzurile de rutina — propriile mesaje si ceilalti boti —
   merg la DEBUG. Guild / canal / utilizator strain si webhook raman WARNING:
   alea chiar sunt semnal de securitate si erau inecate in zgomot.

2. `tool_progress` (heartbeat la 30s cat timp o unealta ruleaza) devine
   eveniment `ToolProgress`. Mesajul live arata acum "⏳ ruleaza de 2m30s" sub
   unealta curenta — singurul semn ca un tur lung lucreaza si nu a inghetat.

3. `rate_limit_event` devine eveniment `RateLimit` si apare in `/status` la
   randul `utilizare`. Cum nu exista plafon de cost (abonament, nu API),
   fereastra de utilizare e singura limita reala; se avertizeaza in log o data
   per schimbare de stare, nu la fiecare eveniment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-31 07:46:51 +00:00

403 lines
15 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 FakeMessage:
def __init__(self, content="salut", *, author=None, channel=None, guild=None,
webhook_id=None, mid="m1"):
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