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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user