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,137 @@
"""T10: chunker si loop de editare per canal."""
from __future__ import annotations
import asyncio
import pytest
import render
def test_text_scurt_ramane_intr_o_bucata():
r = render.split_message("salut")
assert r.kind == "chunks" and r.parts == ["salut"]
def test_sparge_la_2000_de_caractere():
text = "\n".join(f"linia {i} " + "x" * 50 for i in range(60))
r = render.split_message(text)
assert r.kind == "chunks" and len(r.parts) > 1
assert all(len(p) <= render.MAX_MSG for p in r.parts)
assert "linia 0" in r.parts[0] and "linia 59" in r.parts[-1]
def test_fence_nu_se_rupe_peste_granita():
corp = "\n".join(f"cod linia {i}" for i in range(300))
r = render.split_message(f"intro\n```python\n{corp}\n```\ngata")
assert len(r.parts) >= 2
for p in r.parts:
assert p.count("```") % 2 == 0 # fiecare bucata are fence-uri echilibrate
assert r.parts[0].endswith("```")
assert r.parts[1].startswith("```python")
assert all(len(p) <= render.MAX_MSG for p in r.parts)
def test_fence_fara_limbaj():
corp = "\n".join(f"linia {i}" for i in range(400))
r = render.split_message(f"```\n{corp}\n```")
assert r.parts[1].startswith("```\n")
def test_linie_unica_uriasa_se_taie():
r = render.split_message("y" * 5000)
assert r.kind == "chunks" and all(len(p) <= render.MAX_MSG for p in r.parts)
def test_peste_6000_devine_atasament():
r = render.split_message("z" * 6001)
assert r.kind == "attachment"
assert isinstance(r, render.Attachment)
assert r.content == "z" * 6001 and r.filename.endswith(".md")
assert len(r.preview) <= render.MAX_MSG
def test_text_gol():
assert render.split_message("").parts == [""]
def test_footer():
s = render.footer("sonnet", 34500, 0.1547, 1.2)
assert "sonnet" in s and "34.5s" in s and "0.1547" in s and "1.2000" in s
# ------------------------------------------------------------- edit loop
async def test_loop_coalesceaza_actualizarile():
edits = []
async def edit(target, text):
edits.append((target, text))
loop = render.ChannelEditLoop("c1", edit, min_interval=0.05, max_interval=0.2)
loop.start()
for i in range(20):
loop.queue("msg1", f"text {i}")
await asyncio.sleep(0.15)
await loop.stop()
assert len(edits) < 20 # nu s-a trimis fiecare actualizare
assert edits[-1] == ("msg1", "text 19") # ultima versiune ajunge
async def test_flush_trimite_tot_ce_a_ramas():
edits = []
loop = render.ChannelEditLoop("c1", lambda t, x: _append(edits, t, x),
min_interval=10, max_interval=10)
loop.queue("m1", "a")
loop.queue("m2", "b")
await loop.flush()
assert sorted(edits) == [("m1", "a"), ("m2", "b")]
async def _append(lst, t, x):
lst.append((t, x))
async def test_intervalul_creste_sub_presiune_si_scade_la_liniste():
loop = render.ChannelEditLoop("c1", lambda t, x: _append([], t, x),
min_interval=0.01, max_interval=0.2)
loop.start()
for i in range(10):
loop.queue(f"m{i}", "x")
await asyncio.sleep(0.1)
crescut = loop.interval
assert crescut > 0.01
loop.note_rate_limited()
assert loop.interval == 0.2
await asyncio.sleep(0.5)
await loop.stop()
assert loop.interval < 0.2 # linistea coboara intervalul
async def test_editarea_esuata_nu_doboara_loop_ul():
async def edit(target, text):
raise RuntimeError("429 sau altceva")
loop = render.ChannelEditLoop("c1", edit, min_interval=0.01, max_interval=0.05)
loop.start()
loop.queue("m1", "a")
await asyncio.sleep(0.1)
assert loop.errors >= 1
loop.queue("m1", "b")
await asyncio.sleep(0.1)
assert loop.errors >= 2 # loop-ul inca traieste
await loop.stop()
async def test_un_singur_loop_per_canal():
edits = []
mgr = render.RenderManager(lambda t, x: _append(edits, t, x),
min_interval=0.01, max_interval=0.05)
l1 = mgr.loop_for("canal-1")
assert mgr.loop_for("canal-1") is l1
assert mgr.loop_for("canal-2") is not l1
mgr.queue("canal-1", "m1", "salut")
await asyncio.sleep(0.08)
assert ("m1", "salut") in edits
await mgr.stop_all()
assert mgr.loops == {}