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
403 lines
15 KiB
Python
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
|