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

@@ -20,6 +20,7 @@ import pathlib
import time
from dataclasses import dataclass, field
import commands_slash
import config
import limits as limits_mod
import render
@@ -58,7 +59,7 @@ except Exception: # pragma: no cover - lipsa modulului NU are voie sa opreasca
MODELS = ("sonnet", "opus")
PREFIX = "!"
LEGACY_PREFIX = "!" # nu mai executa nimic: doar indiciul de migrare catre `/`
LIVE_TAIL = 1500 # cate caractere aratam in mesajul care se editeaza in timp real
@@ -146,26 +147,54 @@ def check_message(message, self_id: str | None = None) -> Decision:
if self_id is not None and uid == str(self_id):
return Decision(False, "propriul mesaj")
guild = _attr(message, "guild")
gid = _attr(guild, "id") if guild is not None else _attr(message, "guild_id")
return check_ids(gid, message, uid)
def check_ids(gid, source, uid: str) -> Decision:
"""Miezul allowlist-ului: acelasi cod pentru mesaje si pentru interactiuni slash.
`source` e orice obiect care expune `channel` / `channel_id` (mesaj sau interactiune).
"""
guilds, channels, users = guild_ids(), channel_ids(), user_ids()
if not guilds or not channels or not users:
return Decision(False, "allowlist goala (fail-closed)")
guild = _attr(message, "guild")
gid = _attr(guild, "id") if guild is not None else _attr(message, "guild_id")
if gid is None:
return Decision(False, "mesaj privat (fara guild)")
if str(gid) not in guilds:
return Decision(False, f"guild neautorizat {gid}")
if not (set(channel_candidates(message)) & channels):
return Decision(False, f"canal neautorizat {thread_key(message)}")
if not (set(channel_candidates(source)) & channels):
return Decision(False, f"canal neautorizat {thread_key(source)}")
if uid not in users:
if str(uid) not in users:
return Decision(False, f"utilizator neautorizat {uid}")
return ACCEPTED
def check_interaction(interaction, self_id: str | None = None) -> Decision:
"""T1 pentru comenzile slash. Aceleasi reguli, acelasi fail-closed ca la mesaje.
O interactiune nu poate veni de la un webhook, deci verificarea aceea lipseste;
restul (bot, propriul bot, guild, canal, utilizator) e identic.
"""
user = _attr(interaction, "user")
if user is None:
return Decision(False, "fara utilizator")
if bool(_attr(user, "bot", False)):
return Decision(False, "bot")
uid = str(_attr(user, "id", ""))
if self_id is not None and uid == str(self_id):
return Decision(False, "propriul mesaj")
guild = _attr(interaction, "guild")
gid = _attr(interaction, "guild_id") or (_attr(guild, "id") if guild is not None else None)
return check_ids(gid, interaction, uid)
# -------------------------------------------------------------------- comenzi
@dataclass(frozen=True)
class Command:
@@ -177,15 +206,64 @@ class Command:
return " ".join(self.args)
def parse_command(content: str) -> Command | None:
COMMANDS = ("new", "cd", "model", "status", "stop", "cleanup", "help")
# Optiunile slash traduse in argumentele pe care cmd_* le stiu deja.
SLASH_FLAGS = {"new": ("fork", "--fork"), "cleanup": ("force", "--force")}
def slash_args(name: str, opts: dict) -> list[str]:
"""`/new fork:True` -> `["--fork"]`, `/cd cale:/x` -> `["/x"]`."""
flag = SLASH_FLAGS.get(name)
if flag is not None:
return [flag[1]] if opts.get(flag[0]) else []
for key in ("cale", "model"):
val = opts.get(key)
if val not in (None, ""):
return [str(val).strip()]
return []
def legacy_prefix_hint(content: str) -> str | None:
"""Vechiul `!cmd` nu mai executa nimic; spunem doar ca a devenit `/cmd`.
Fara asta, `!status` ar pleca la Claude ca prompt si ar costa un tur degeaba.
"""
text = (content or "").strip()
if not text.startswith(PREFIX):
if not text.startswith(LEGACY_PREFIX):
return None
body = text[len(PREFIX):].strip()
body = text[len(LEGACY_PREFIX):].strip()
if not body:
return None
parts = body.split()
return Command(parts[0].lower(), parts[1:])
name = body.split()[0].lower()
if name not in COMMANDS:
return None
return f"Comenzile sunt acum slash: foloseste `/{name}` in loc de `!{name}`."
class FollowupChannel:
"""Canal duck-typed peste `interaction.followup`.
Dupa `defer()` raspunsul se trimite doar prin followup. Invelisul asta lasa
`cmd_*` si `Bridge.say` neschimbate — ele vad tot un obiect cu `.send()`.
"""
def __init__(self, interaction):
self.interaction = interaction
self.id = _attr(interaction, "channel_id")
async def send(self, content=None, **kw):
return await self.interaction.followup.send(content, **kw)
class SlashMessage:
"""Ce vad `cmd_*` in locul unui mesaj: canal + autor. Nimic altceva nu folosesc."""
def __init__(self, interaction, channel):
self.interaction = interaction
self.channel = channel
self.author = _attr(interaction, "user")
self.id = _attr(interaction, "id", "")
# ------------------------------------------------------------------ utilitare
@@ -380,9 +458,10 @@ class Bridge:
return "empty"
tid = thread_key(message)
command = parse_command(content)
if command is not None:
return await self.handle_command(message, tid, command)
hint = legacy_prefix_hint(content)
if hint is not None:
await self.say(message.channel, hint)
return "legacy-prefix"
# Steering: un mesaj sosit in timpul unui tur NU deschide un tur nou,
# ci intra pe stdin-ul procesului viu. Asta e functionalitatea centrala.
@@ -400,23 +479,65 @@ class Bridge:
return await self.run_turn(message, tid, content)
# ------------------------------------------------------------- comenzi
async def handle_command(self, message, tid: str, cmd: Command) -> str:
handler = getattr(self, f"cmd_{cmd.name}", None)
if handler is None:
await self.say(message.channel, f"Comanda `!{cmd.name}` nu exista. `!help` le listeaza.")
async def handle_slash(self, interaction, name: str, **opts) -> str:
"""Punctul de intrare al comenzilor slash. Aceleasi cmd_* ca inainte.
Refuzul e EFEMER (`ephemeral=True`), nu tacut ca la mesaje: cine a apucat sa
vada comanda in lista stie deja ca botul exista, iar o interactiune fara raspuns
apare in Discord ca "application did not respond" — confuz degeaba. Nu se
executa nimic si motivul exact ramane doar in log.
"""
decision = check_interaction(interaction, self.self_id)
if not decision.ok:
self.rejected.append(decision.reason)
log.warning(
"interactiune respinsa (%s): guild=%s canal=%s user=%s comanda=%s",
decision.reason,
_attr(interaction, "guild_id"),
_attr(interaction, "channel_id"),
_attr(_attr(interaction, "user"), "id"),
name,
)
with contextlib.suppress(Exception):
await interaction.response.send_message("⛔ Nu esti autorizat.", ephemeral=True)
return "rejected"
handler = getattr(self, f"cmd_{name}", None)
if handler is None: # nu se poate intampla: comenzile sunt declarate static
with contextlib.suppress(Exception):
await interaction.response.send_message(
f"Comanda `/{name}` nu exista.", ephemeral=True
)
return "unknown-command"
await handler(message, tid, cmd)
return f"cmd:{cmd.name}"
# Discord asteapta confirmarea in 3 secunde. Amanam INTOTDEAUNA si raspundem
# prin followup: `/status` citeste din /proc, `/cleanup` scaneaza cgroup-ul,
# `/new` opreste un proces — oricare poate depasi fereastra.
with contextlib.suppress(Exception):
await interaction.response.defer()
tid = thread_key(interaction)
channel = FollowupChannel(interaction)
proxy = SlashMessage(interaction, channel)
try:
await handler(proxy, tid, Command(name, slash_args(name, opts)))
except Exception as exc: # pragma: no cover - plasa de siguranta
log.exception("comanda /%s a esuat", name)
with contextlib.suppress(Exception):
await self.say(channel, f"⛔ `/{name}` a esuat: {exc}")
return "error"
return f"cmd:{name}"
async def cmd_help(self, message, tid: str, cmd: Command) -> None:
await self.say(
message.channel,
"**Comenzi**\n"
"`!new` sesiune noua · `!new --fork` sesiune noua din contextul curent\n"
"`!cd <cale>` schimba directorul de lucru\n"
"`!model <sonnet|opus>` schimba modelul firului\n"
"`!status` starea firului · `!stop` opreste turul curent\n"
"`!cleanup [--force]` procese lasate in urma",
"`/new` sesiune noua · `/new fork:True` sesiune noua din contextul curent\n"
"`/cd cale:<cale>` schimba directorul de lucru\n"
"`/model model:<sonnet|opus>` schimba modelul firului\n"
"`/status` starea firului · `/stop` opreste turul curent\n"
"`/cleanup [force:True]` procese lasate in urma\n"
"Orice alt mesaj din canal pleaca la Claude ca prompt.",
)
async def cmd_new(self, message, tid: str, cmd: Command) -> None:
@@ -435,7 +556,7 @@ class Bridge:
async def cmd_cd(self, message, tid: str, cmd: Command) -> None:
if not cmd.args:
await self.say(message.channel, "Foloseste `!cd <cale>`.")
await self.say(message.channel, "Foloseste `/cd cale:<cale>`.")
return
# Fara allowlist de proiecte: accesul larg la /workspace e decizie ferma din plan.
path = os.path.abspath(os.path.expanduser(cmd.rest))
@@ -453,7 +574,7 @@ class Bridge:
await self.say(
message.channel,
f"Model curent: `{rec.get('model') or default_model()}`. "
f"Foloseste `!model <{'|'.join(MODELS)}>`.",
f"Foloseste `/model model:<{'|'.join(MODELS)}>`.",
)
return
model = cmd.args[0].strip().lower()
@@ -518,7 +639,7 @@ class Bridge:
results = await asyncio.to_thread(cleanup.kill_orphans, orphans, not force)
except Exception as exc:
log.exception("cleanup a esuat")
await self.say(message.channel, f"⛔ `!cleanup` a esuat: {exc}")
await self.say(message.channel, f"⛔ `/cleanup` a esuat: {exc}")
return
await self.say(message.channel, cleanup.format_report(orphans, results if force else None))
@@ -654,8 +775,14 @@ def make_client(bridge: Bridge | None = None): # pragma: no cover - are nevoie
super().__init__(intents=intents)
self.bridge = bridge or Bridge()
self.bridge.get_channel = self.get_channel
self.tree = commands_slash.build_tree(self, self.bridge)
self._started = False
async def setup_hook(self):
# Sync PE GUILD: e instantaneu, spre deosebire de cel global (~1h).
# Esecul nu doboara botul — mesajele obisnuite merg mai departe.
await commands_slash.sync_guilds(self.tree, guild_ids())
async def on_ready(self):
self.bridge.self_id = str(self.user.id) if self.user else None
if not self._started: