feat(discord-bridge): comenzi slash in loc de prefixul !

Comenzile devin application commands inregistrate pe guild (sync instantaneu,
spre deosebire de cel global care dureaza ~1h): /new [fork], /cd <cale>,
/model <sonnet|opus> cu Choice, /status, /stop, /cleanup [force], /help.

- allowlist-ul se aplica identic la interactiuni (check_ids comun, ca sa nu
  existe a doua implementare care diverge); refuz efemer, fara executie
- fiecare comanda face defer() inainte de lucru — altfel Discord marcheaza
  interactiunea esuata dupa 3s desi comanda a rulat
- sync tolerant: la esec (lipsa scope applications.commands) botul porneste
  normal si logheaza linkul de reinvitare necesar
- mesajele obisnuite raman neschimbate, inclusiv steering-ul mid-tur
- linkul de invitatie primeste scope=bot%20applications.commands; referintele
  la ! din ops/ si documentatie trecute pe /

Verificat in productie: 7 comenzi inregistrate pe guild, citite inapoi din API.
Suita: 296 passed cu discord.py, 293 passed + 3 skipped fara.

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 12:23:33 +00:00
parent c9b9e2e5da
commit 5f34320e3e
8 changed files with 600 additions and 109 deletions

View File

@@ -81,6 +81,51 @@ class FakeMessage:
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):
@@ -225,6 +270,19 @@ async def test_raspuns_lung_devine_atasament(bridge, store, monkeypatch):
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"
# ------------------------------------------------------------------ aprobari
def test_decizia_cere_allowlist(bridge, allowed):
assert "allowlist" in bridge.decide("777", "req-1", "allow")
@@ -251,7 +309,7 @@ async def test_botul_porneste_si_fara_modulele_lui_b_si_c(bridge, monkeypatch):
monkeypatch.setattr(bot, "cleanup", None)
assert bridge.wire_approvals() is False
ch = FakeChannel()
await bridge.handle_message(FakeMessage("!cleanup", channel=ch))
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"