feat(discord-bridge): punte Discord -> Claude Code pe LXC 171

Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri,
3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu
proces persistent per fir alimentat pe stdin cu --input-format stream-json.

Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream
(parser tolerant), session_store (scriere atomica, lock per fir, detectare PID
reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost
pe zi), render (un loop de editare per canal, interval adaptiv).

Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor,
comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul
fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou.

Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru
operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny
rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa).

Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd
user cu KillMode=control-group si limite de memorie, install.sh idempotent.

Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara
discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s
intr-un tool call de 25s schimba raspunsul final).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
Claude Agent
2026-08-30 10:44:39 +00:00
parent d7b4007af8
commit d466f358ce
43 changed files with 7767 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
"""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 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)
# ------------------------------------------------------------------ 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)
# ------------------------------------------------------------------ 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_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_message(FakeMessage("!cleanup", channel=ch))
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