diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index e543df3..838c441 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -29,7 +29,7 @@ bot.py -- allowlist (guild / canal / utilizator) [doar adaptor Discord] +-- limits.py max procese, timeout tur, rate limit, plafon de cost +-- security/ hook PreToolUse (confirmari) + wrapper `infra` +-- alerts.py alerte email [ops] - +-- cleanup.py procese lasate in urma (`!cleanup`) [ops] + +-- cleanup.py procese lasate in urma (`/cleanup`) [ops] | v claude -p --input-format stream-json --output-format stream-json --verbose @@ -44,9 +44,9 @@ Cateva alegeri care nu se vad din diagrama: (verificat: mesaj la 8s intr-un tur de 34.5s). Reaper la 20 min de inactivitate; repornirea se face cu `--resume `, deci firul nu-si pierde contextul. - **Un proces `claude` = ~406 MB RSS** (masurat). De aici toate limitele: maxim 4 procese - vii, `MemoryMax=6G` pe unit, si comanda `!cleanup`. + vii, `MemoryMax=6G` pe unit, si comanda `/cleanup`. - **Model implicit `sonnet`.** Un tur banal pe opus a costat $0.1547 (masurat), deci - opus e optional, per fir, prin `!model opus`. + opus e optional, per fir, prin `/model model:opus`. --- @@ -54,24 +54,30 @@ Cateva alegeri care nu se vad din diagrama: | Comanda | Ce face | |---------|---------| -| `!new` | Sesiune noua, curata, in firul curent | -| `!new --fork` | Sesiune noua care porneste din contextul celei curente | -| `!cd ` | Schimba directorul de lucru al firului (ex. `!cd /workspace/romfastsql`) | -| `!model ` | Schimba modelul pentru firul curent | -| `!status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr | -| `!stop` | Opreste turul in desfasurare din firul curent | -| `!cleanup` | Listeaza procesele lasate in urma (rulare seaca). `!cleanup --force` le opreste | +| `/new` | Sesiune noua, curata, in firul curent | +| `/new fork:True` | Sesiune noua care porneste din contextul celei curente | +| `/cd cale:` | Schimba directorul de lucru al firului (ex. `/cd cale:/workspace/romfastsql`) | +| `/model model:` | Schimba modelul pentru firul curent | +| `/status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr | +| `/stop` | Opreste turul in desfasurare din firul curent | +| `/cleanup` | Listeaza procesele lasate in urma (rulare seaca). `/cleanup force:True` le opreste | +| `/help` | Lista de mai sus, in fir | + +Comenzile sunt **application commands** (`/`), inregistrate pe guild-urile din +`DISCORD_GUILD_IDS` la pornirea botului, deci apar in lista de comenzi a Discord. Vechiul +prefix `!` nu mai executa nimic: botul raspunde doar cu indiciul catre comanda `/` +echivalenta. Orice alt mesaj din canal pleaca la Claude ca prompt, ca inainte. Un fir de Discord = o sesiune Claude. Canalul principal are si el sesiunea lui, cea implicita. Subsolul fiecarui raspuns arata modelul, durata si costul. -### Despre `!cleanup` +### Despre `/cleanup` `KillMode=control-group` opreste arborele serviciului la restart, dar **nu prinde ce s-a desprins**: un server pornit cu `&` intr-un tur, un `nohup`, un job lung reparentat la init. Alea raman si se aduna — 406 MB bucata, pe un container cu istoric de OOM. -`!cleanup` cauta doua feluri de resturi: procese `claude` care nu apar in `state.json`, +`/cleanup` cauta doua feluri de resturi: procese `claude` care nu apar in `state.json`, si copii reparentati la init ramasi in cgroup-ul serviciului. **Ruleaza sec (dry-run) in mod implicit** — intai vezi lista, apoi decizi. Ce e inregistrat in `state.json` si toti descendentii acelor procese (adica turul care ruleaza chiar acum) nu sunt niciodata @@ -127,18 +133,21 @@ Nu se poate automatiza: cere un om logat in Discord. Nu ai nevoie de aprobare de la Discord: verificarea e ceruta abia de la 100 de servere. (*Server Members* si *Presence* nu sunt necesare — lasa-le oprite.) 5. **Invita botul intr-un guild PRIVAT** al tau. *OAuth2* -> *URL Generator* -> - scopes: `bot` -> permisiuni: *View Channel*, *Send Messages*, *Read Message History*, + scopes: `bot` **si** `applications.commands` -> permisiuni: *View Channel*, *Send Messages*, *Read Message History*, *Create Public Threads*, *Send Messages in Threads*, *Attach Files*, *Embed Links*, *Add Reactions*. Deschide URL-ul generat si alege serverul. Bifele sunt greu de nimerit pe telefon; linkul echivalent, gata calculat (`APPLICATION_ID` e in *General Information*): ``` - https://discord.com/oauth2/authorize?client_id=APPLICATION_ID&scope=bot&permissions=309237763136 + https://discord.com/oauth2/authorize?client_id=APPLICATION_ID&scope=bot%20applications.commands&permissions=309237763136 ``` `309237763136` = exact permisiunile de mai sus. Fara *View Channel* botul nu vede - canalul deloc, oricat de permis ar fi in allowlist. + canalul deloc, oricat de permis ar fi in allowlist. Fara scope-ul + `applications.commands` botul merge, dar inregistrarea comenzilor `/` esueaza cu + *403 Missing Access* (scrie in log linkul de reinvitare) si comenzile nu apar in lista. + Un bot deja invitat se re-invita cu acelasi link: se adauga doar scope-ul lipsa. **Nu-l invita intr-un server cu alti oameni** — cine scrie in canalul permis comanda direct containerul. 6. **Ia ID-urile pentru allowlist.** In Discord: *Settings* -> *Advanced* -> @@ -178,7 +187,7 @@ systemctl --user restart claude-discord # repornire ### Cost Costul se vede in trei locuri: in subsolul fiecarui raspuns (turul curent + cumulat pe -fir), in `!status`, si in `state.json` la cheia `cost`. La atingerea plafonului zilnic +fir), in `/status`, si in `state.json` la cheia `cost`. La atingerea plafonului zilnic (`COST_CAP_USD_DAY`) botul nu mai accepta tururi noi si trimite email. Plafonul se reseteaza la schimbarea zilei. @@ -227,9 +236,10 @@ tail -2 ~/.claude-discord/logs/alerts.log |---------|---------| | Botul nu raspunde deloc in Discord | `systemctl --user status claude-discord`. Daca e `failed`, `journalctl --user -u claude-discord -n 100`. Cauza #1: token invalid sau **MESSAGE CONTENT INTENT** oprit. | | Botul e viu dar ignora mesajele | Allowlist. Verifica `DISCORD_GUILD_IDS` / `DISCORD_CHANNEL_IDS` / `DISCORD_USER_IDS` din env. Respingerea e **tacuta**, intentionat. | +| Comenzile `/` nu apar in lista din Discord | Botul a fost invitat fara scope-ul `applications.commands`. In log: `sync de comenzi slash esuat` + linkul de reinvitare. Reinvita botul, apoi `systemctl --user restart claude-discord`. | | Unitul se invarte in restart | Dupa 5 porniri esuate in 300s systemd renunta si lasa unitul `failed` (e voit). Repara, apoi `systemctl --user reset-failed claude-discord && systemctl --user start claude-discord`. | | Firul e blocat pe hourglass | Botul a fost restartat la mijlocul unui tur. Turul **nu** se reia automat (risc de dubla executie sub `bypassPermissions`); sweep-ul de la pornire pune un avertisment in fir. Trimite mesajul din nou. | -| Memoria containerului creste | `!cleanup` (sec), apoi `!cleanup --force`. Vezi si `systemctl --user show claude-discord -p MemoryCurrent`. | +| Memoria containerului creste | `/cleanup` (sec), apoi `/cleanup force:True`. Vezi si `systemctl --user show claude-discord -p MemoryCurrent`. | | „Plafon de cost atins" | E limita zilnica, nu o eroare. Ridica `COST_CAP_USD_DAY` in env si reporneste, sau asteapta ziua urmatoare. | | Nu vin emailuri de alerta | `command -v mail`; `mailq`; `tail ~/.claude-discord/logs/alerts.log`. Un `NESENT` in log iti spune exact de ce. | | Dupa reboot serviciul nu porneste | `loginctl show-user claude -p Linger` trebuie sa fie `yes`. Daca nu: `sudo loginctl enable-linger claude`. | @@ -289,7 +299,7 @@ baza pe ele ca pe o bariera. (1s -> 5s), dar cand Discord franeaza nu apare niciun mesaj: raspunsul doar apare mai incet. E singura cale fara test din analiza modurilor de esec — acceptata, fiindca esecul e intarziere, nu pierdere. -- **`!cleanup` nu e infailibil.** Prinde procese `claude` neinregistrate si copii +- **`/cleanup` nu e infailibil.** Prinde procese `claude` neinregistrate si copii reparentati la init ramasi in cgroup. Un proces care a iesit din cgroup *si* nu arata a `claude` (un `python -m http.server` desprins complet, de exemplu) ii scapa. Lista `NEVER_KILL` din `cleanup.py` protejeaza infrastructura sesiunii (systemd, sshd, @@ -312,12 +322,13 @@ baza pe ele ca pe o bariera. | `stream.py` | parser tolerant de stream JSONL | A | | `render.py` | chunker + loop de editare per canal | A | | `limits.py` | max procese, timeout, rate limit, plafon de cost | A | +| `commands_slash.py` | declararea si inregistrarea comenzilor `/` | A | | `config.py` | citeste `~/.claude-discord/env` | A | | `security/confirm_hook.py` | hook `PreToolUse`, fail-closed | B | | `security/approvals.py` | canal de aprobari hook <-> bot | B | | `security/infra` | wrapper cu lista de hosturi permise | B | | `alerts.py` | alerte email, dedup 1h, nu arunca niciodata | C | -| `cleanup.py` | `!cleanup`: orfani, dry-run implicit | C | +| `cleanup.py` | `/cleanup`: orfani, dry-run implicit | C | | `ops/claude-discord.service` | unit systemd de utilizator | C | | `ops/install.sh` | instalare idempotenta | C | | `ops/env.example` | sablon de configurare | C | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/bot.py b/proxmox/lxc171-claude-agent/discord-bridge/bot.py index baf8f00..8e3c1c2 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/bot.py @@ -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 ` schimba directorul de lucru\n" - "`!model ` 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:` schimba directorul de lucru\n" + "`/model model:` 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 `.") + await self.say(message.channel, "Foloseste `/cd 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: diff --git a/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py b/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py new file mode 100644 index 0000000..0871696 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py @@ -0,0 +1,116 @@ +"""Comenzile slash ale puntii: declararea si inregistrarea lor in Discord. + +Tot ce depinde de `discord.app_commands` sta aici. Logica ramane in `bot.Bridge` +(`cmd_new`, `cmd_cd`, ...), deci comenzile slash si allowlist-ul nu au a doua +implementare care sa divergheze. + +Modulul se importa si FARA discord.py instalat (ca `bot.py`): atunci `build_tree` +returneaza None si `sync_guilds` nu face nimic. +""" + +from __future__ import annotations + +import logging + +log = logging.getLogger("discord-bridge.slash") + +try: # pragma: no cover - depinde de mediu + import discord # type: ignore + from discord import app_commands # type: ignore +except ImportError: # pragma: no cover + discord = None # type: ignore + app_commands = None # type: ignore + +# Permisiunile din README (309237763136) + scope-ul de comenzi. Fara +# `applications.commands` in invitatie, sync-ul da 403 Missing Access. +INVITE_TEMPLATE = ( + "https://discord.com/oauth2/authorize?client_id={app_id}" + "&scope=bot%20applications.commands&permissions=309237763136" +) + + +def invite_url(app_id) -> str: + return INVITE_TEMPLATE.format(app_id=app_id or "APPLICATION_ID") + + +def build_tree(client, bridge): + """Construieste arborele de comenzi. Fara discord.py: None.""" + if app_commands is None: # pragma: no cover - mediu fara discord.py + log.warning("discord.py lipseste: nu declar comenzi slash") + return None + + tree = app_commands.CommandTree(client) + + @tree.command(name="new", description="Sesiune Claude noua in firul curent") + @app_commands.describe(fork="Porneste noua sesiune din contextul celei curente") + async def _new(interaction, fork: bool = False): + await bridge.handle_slash(interaction, "new", fork=fork) + + @tree.command(name="cd", description="Schimba directorul de lucru al firului") + @app_commands.describe(cale="Cale absoluta, oriunde in /workspace") + async def _cd(interaction, cale: str): + await bridge.handle_slash(interaction, "cd", cale=cale) + + @tree.command(name="model", description="Schimba modelul pentru firul curent") + @app_commands.describe(model="sonnet (implicit) sau opus") + @app_commands.choices(model=[ + app_commands.Choice(name="sonnet", value="sonnet"), + app_commands.Choice(name="opus", value="opus"), + ]) + async def _model(interaction, model: app_commands.Choice[str]): + await bridge.handle_slash(interaction, "model", model=model.value) + + @tree.command(name="status", description="Sesiune, director, model, cost, proces") + async def _status(interaction): + await bridge.handle_slash(interaction, "status") + + @tree.command(name="stop", description="Opreste turul in desfasurare din firul curent") + async def _stop(interaction): + await bridge.handle_slash(interaction, "stop") + + @tree.command(name="cleanup", description="Procese lasate in urma (implicit: rulare seaca)") + @app_commands.describe(force="Opreste efectiv procesele gasite (implicit: doar lista)") + async def _cleanup(interaction, force: bool = False): + await bridge.handle_slash(interaction, "cleanup", force=force) + + @tree.command(name="help", description="Lista comenzilor puntii") + async def _help(interaction): + await bridge.handle_slash(interaction, "help") + + return tree + + +async def sync_guilds(tree, guild_ids) -> dict[str, str]: + """Inregistreaza comenzile pe fiecare guild din allowlist. + + Sync-ul pe guild e instantaneu; cel global se propaga in ~1h, deci nu-l folosim. + NU arunca niciodata: daca invitatia botului nu are scope-ul `applications.commands`, + Discord raspunde 403 Missing Access — logam linkul de reinvitare si mergem mai + departe. Botul ramane functional pentru mesajele obisnuite. + """ + results: dict[str, str] = {} + if tree is None or discord is None: + log.warning("fara arbore de comenzi: sar peste inregistrarea comenzilor slash") + return results + gids = [str(g).strip() for g in (guild_ids or []) if str(g).strip()] + if not gids: + log.warning("DISCORD_GUILD_IDS gol: nu inregistrez comenzi slash nicaieri") + return results + + app_id = getattr(getattr(tree, "client", None), "application_id", None) + for gid in gids: + try: + guild = discord.Object(id=int(gid)) + tree.copy_global_to(guild=guild) + cmds = await tree.sync(guild=guild) + results[gid] = f"ok ({len(cmds)} comenzi)" + log.info("comenzi slash inregistrate pe guild %s: %d", gid, len(cmds)) + except Exception as exc: + results[gid] = f"esuat: {exc}" + log.error("sync de comenzi slash esuat pe guild %s: %s", gid, exc) + log.error( + "daca e 403 Missing Access, botul a fost invitat fara scope-ul " + "`applications.commands`. Reinvita-l cu: %s", + invite_url(app_id), + ) + return results diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service b/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service index 80e64a5..9d3293a 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service @@ -37,7 +37,7 @@ ExecStart=%h/.claude-discord/venv/bin/python /workspace/romfastsql/proxmox/lxc17 # ESENTIAL: la stop/restart omoara TOT cgroup-ul, nu doar procesul principal. # Fara asta procesele `claude` (406 MB RSS masurat) raman in urma si umplu # containerul. Nu prinde totusi ce s-a desprins din cgroup — pentru aia exista -# comanda `!cleanup` (cleanup.py). +# comanda `/cleanup` (cleanup.py). KillMode=control-group KillSignal=SIGTERM TimeoutStopSec=30 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example index 5a6d40b..cdb82f6 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example @@ -19,12 +19,12 @@ DISCORD_USER_IDS= # Aplicatia Discord a puntii (creata 2026-08-30). Link de reinvitare, daca botul # a fost scos din server sau ii lipsesc permisiuni: -# https://discord.com/oauth2/authorize?client_id=1543576449624186880&scope=bot&permissions=309237763136 +# https://discord.com/oauth2/authorize?client_id=1543576449624186880&scope=bot%20applications.commands&permissions=309237763136 # permissions=309237763136 -> View Channel, Send Messages, Embed Links, Attach Files, # Read Message History, Add Reactions, Create/Send in Public Threads. # --- Model si cost --------------------------------------------------------- -# Modelul implicit al firelor noi. `!model opus` il schimba per fir. +# Modelul implicit al firelor noi. `/model opus` il schimba per fir. CLAUDE_MODEL=sonnet # Plafon de cost pe zi, in USD. La atingere botul refuza tururi noi si alerteaza. # Reper masurat: un tur banal pe opus a costat $0.1547. @@ -49,6 +49,6 @@ ALERT_RECIPIENT=root # --- Directorul de lucru implicit ----------------------------------------- # Directorul in care porneste un fir nou. Spatiu dedicat, cu git propriu, ca ce se -# face din Discord sa aiba istoric separat de proiectele reale. `!cd ` il +# face din Discord sa aiba istoric separat de proiectele reale. `/cd ` il # schimba per fir, oriunde in /workspace. DEFAULT_CWD=/workspace/claude-agent diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py index 12e451c..b9bfdd5 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py @@ -12,10 +12,10 @@ import bot try: # rulat ca pachet `tests` sau ca module libere from tests.test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild, - FakeMessage, FakeUser, allowed, bridge) + FakeInteraction, FakeMessage, FakeUser, allowed, bridge) except ImportError: # pragma: no cover from test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild, - FakeMessage, FakeUser, allowed, bridge) + FakeInteraction, FakeMessage, FakeUser, allowed, bridge) __all__ = ["allowed", "bridge"] # fixturi reexportate @@ -133,3 +133,66 @@ async def test_webhookul_nu_declanseaza_comenzi(bridge): assert await bridge.handle_message(msg) == "rejected" assert ch.sent == [] assert bridge.runner.procs == {} + + +# ------------------------------------------- acelasi filtru la comenzile slash +def iok(interaction) -> bool: + return bot.check_interaction(interaction, self_id="999").ok + + +def iwhy(interaction) -> str: + return bot.check_interaction(interaction, self_id="999").reason + + +def test_interactiune_permisa(allowed): + assert iok(FakeInteraction()) + + +def test_interactiune_din_fir_permis(allowed): + thread = FakeChannel(cid="99999", parent_id=CHANNEL) + assert iok(FakeInteraction(channel=thread)) + + +def test_interactiune_din_guild_strain_refuzata(allowed): + assert "guild" in iwhy(FakeInteraction(guild_id="777")) + + +def test_interactiune_in_mesaj_privat_refuzata(allowed): + assert "guild" in iwhy(FakeInteraction(guild_id=None)) + + +def test_interactiune_din_canal_strain_refuzata(allowed): + assert "canal" in iwhy(FakeInteraction(channel=FakeChannel("777"))) + + +def test_interactiune_de_la_utilizator_strain_refuzata(allowed): + assert "utilizator" in iwhy(FakeInteraction(user=FakeUser("777"))) + + +def test_interactiune_de_la_bot_refuzata(allowed): + assert iwhy(FakeInteraction(user=FakeUser("40", is_bot=True))) == "bot" + + +@pytest.mark.parametrize("lipsa", ["DISCORD_GUILD_IDS", "DISCORD_CHANNEL_IDS", "DISCORD_USER_IDS"]) +def test_interactiunea_e_fail_closed(allowed, monkeypatch, lipsa): + monkeypatch.setenv(lipsa, "") + assert "fail-closed" in iwhy(FakeInteraction()) + + +async def test_comanda_slash_neautorizata_nu_executa_nimic(bridge, caplog): + """Refuz efemer, fara defer, fara efect: `/cleanup force:True` de la un strain.""" + ch = FakeChannel() + it = FakeInteraction(channel=ch, user=FakeUser("777")) + with caplog.at_level("WARNING"): + assert await bridge.handle_slash(it, "cleanup", force=True) == "rejected" + assert ch.sent == [] # nimic vizibil in canal + assert it.response.deferred == 0 # nici macar nu amanam + assert "Nu esti autorizat" in it.ephemeral + assert bridge.rejected and "utilizator" in bridge.rejected[-1] + assert "interactiune respinsa" in caplog.text + + +async def test_comanda_slash_din_alt_canal_refuzata(bridge): + it = FakeInteraction(channel=FakeChannel("777")) + assert await bridge.handle_slash(it, "status") == "rejected" + assert "Nu esti autorizat" in it.ephemeral diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py index 8be3982..8517aed 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py @@ -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" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py index 05ca041..0662757 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py @@ -1,4 +1,4 @@ -"""Comenzile puntii: !new (+ --fork), !cd, !model, !status, !stop, !cleanup, !help.""" +"""Comenzile slash: /new (+ fork), /cd, /model, /status, /stop, /cleanup, /help.""" from __future__ import annotations @@ -7,48 +7,77 @@ import pytest import bot try: - from tests.test_bot import (CHANNEL, USER, FakeChannel, FakeMessage, allowed, bridge) + from tests.test_bot import (CHANNEL, USER, FakeChannel, FakeInteraction, + FakeMessage, FakeUser, allowed, bridge) except ImportError: # pragma: no cover - from test_bot import (CHANNEL, USER, FakeChannel, FakeMessage, allowed, bridge) + from test_bot import (CHANNEL, USER, FakeChannel, FakeInteraction, + FakeMessage, FakeUser, allowed, bridge) __all__ = ["allowed", "bridge"] -async def run(bridge, text, ch=None): +async def slash(bridge, name, ch=None, **opts): + """Ruleaza o comanda slash pe un canal, ca in Discord.""" ch = ch or FakeChannel() - msg = FakeMessage(text, channel=ch) - label = await bridge.handle_message(msg) + it = FakeInteraction(channel=ch) + label = await bridge.handle_slash(it, name, **opts) + return label, ch, it + + +async def prompt(bridge, text, ch=None): + """Un mesaj obisnuit: pleaca la Claude ca prompt.""" + ch = ch or FakeChannel() + label = await bridge.handle_message(FakeMessage(text, channel=ch)) return label, ch -# ------------------------------------------------------------------- parser -def test_parse_command(): - cmd = bot.parse_command("!cd /workspace/romfastsql") - assert cmd.name == "cd" and cmd.rest == "/workspace/romfastsql" - assert bot.parse_command("!MODEL opus").name == "model" - assert bot.parse_command("salut") is None - assert bot.parse_command("!") is None +# ------------------------------------------------- optiuni slash -> argumente +def test_slash_args(): + assert bot.slash_args("new", {"fork": True}) == ["--fork"] + assert bot.slash_args("new", {"fork": False}) == [] + assert bot.slash_args("cleanup", {"force": True}) == ["--force"] + assert bot.slash_args("cd", {"cale": " /workspace/romfastsql "}) == ["/workspace/romfastsql"] + assert bot.slash_args("model", {"model": "opus"}) == ["opus"] + assert bot.slash_args("status", {}) == [] -async def test_comanda_necunoscuta(bridge): - label, ch = await run(bridge, "!inexistenta") +def test_prefixul_vechi_nu_mai_e_parsat(): + """`parse_command` a disparut: `!` nu mai executa nimic.""" + assert not hasattr(bot, "parse_command") + assert bot.legacy_prefix_hint("!cd /x").startswith("Comenzile sunt acum slash") + assert bot.legacy_prefix_hint("salut") is None + assert bot.legacy_prefix_hint("!") is None + assert bot.legacy_prefix_hint("!ceva-inexistent") is None + + +async def test_comanda_necunoscuta_e_refuzata_efemer(bridge): + label, ch, it = await slash(bridge, "inexistenta") assert label == "unknown-command" - assert "nu exista" in ch.all_text + assert ch.sent == [] + assert "nu exista" in it.ephemeral + + +async def test_orice_comanda_confirma_in_3_secunde(bridge): + """Fara `defer()`, Discord marcheaza interactiunea ca esuata.""" + for name in bot.COMMANDS: + _, _, it = await slash(bridge, name, cale="/tmp", model="sonnet") + assert it.response.deferred == 1, name async def test_help(bridge): - label, ch = await run(bridge, "!help") + label, ch, _ = await slash(bridge, "help") assert label == "cmd:help" - for c in ("!new", "!cd", "!model", "!status", "!stop", "!cleanup"): + for c in ("/new", "/cd", "/model", "/status", "/stop", "/cleanup"): assert c in ch.all_text + assert "!" not in ch.all_text -# ---------------------------------------------------------------------- !new +# ---------------------------------------------------------------------- /new async def test_new_sterge_sesiunea(bridge, store): ch = FakeChannel() - await run(bridge, "salut", ch) # creeaza sesiunea + await prompt(bridge, "salut", ch) # creeaza sesiunea assert store.thread(str(ch.id))["sid"] == "sid-fake-0001" - label, _ = await run(bridge, "!new", ch) + label, _, _ = await slash(bridge, "new", ch=ch, fork=False) assert label == "cmd:new" assert store.thread(str(ch.id))["sid"] is None assert bridge.runner.procs[str(ch.id)].alive is False @@ -56,86 +85,87 @@ async def test_new_sterge_sesiunea(bridge, store): async def test_new_fork_pastreaza_contextul(bridge, store): ch = FakeChannel() - await run(bridge, "salut", ch) - await run(bridge, "!new --fork", ch) + await prompt(bridge, "salut", ch) + await slash(bridge, "new", ch=ch, fork=True) assert store.thread(str(ch.id))["sid"] == "sid-fake-0001" assert "fork" in ch.all_text assert bridge.runner.procs[str(ch.id)].sid == "sid-fake-0001" -# ----------------------------------------------------------------------- !cd +# ----------------------------------------------------------------------- /cd async def test_cd_oriunde_in_workspace(bridge, store, tmp_path): target = tmp_path / "proiect-nou" target.mkdir() - label, ch = await run(bridge, f"!cd {target}") + label, ch, _ = await slash(bridge, "cd", cale=str(target)) assert label == "cmd:cd" assert store.thread(str(ch.id))["cwd"] == str(target) assert str(target) in ch.all_text async def test_cd_refuza_ce_nu_e_director(bridge, store): - label, ch = await run(bridge, "!cd /nu/exista/nicaieri") + _, ch, _ = await slash(bridge, "cd", cale="/nu/exista/nicaieri") assert "nu e un director" in ch.all_text assert store.thread(str(ch.id))["cwd"] != "/nu/exista/nicaieri" async def test_cd_fara_argument(bridge): - _, ch = await run(bridge, "!cd") + _, ch, _ = await slash(bridge, "cd", cale="") assert "Foloseste" in ch.all_text async def test_cd_se_aplica_procesului_urmator(bridge, tmp_path): ch = FakeChannel() - await run(bridge, "salut", ch) + await prompt(bridge, "salut", ch) target = tmp_path / "alt-proiect" target.mkdir() - await run(bridge, f"!cd {target}", ch) + await slash(bridge, "cd", ch=ch, cale=str(target)) assert bridge.runner.procs[str(ch.id)].cwd == str(target) assert bridge.runner.procs[str(ch.id)].alive is False # se reia cu --resume -# -------------------------------------------------------------------- !model +# -------------------------------------------------------------------- /model async def test_model_implicit_e_sonnet(bridge, store): - _, ch = await run(bridge, "salut") + _, ch = await prompt(bridge, "salut") assert store.thread(str(ch.id))["model"] == "sonnet" assert "sonnet" in ch.all_text async def test_model_opus_per_fir(bridge, store): ch = FakeChannel() - label, _ = await run(bridge, "!model opus", ch) + label, _, _ = await slash(bridge, "model", ch=ch, model="opus") assert label == "cmd:model" assert store.thread(str(ch.id))["model"] == "opus" # firul vecin (alt thread, acelasi canal-parinte) ramane pe sonnet ch2 = FakeChannel("201", parent_id=CHANNEL) - await run(bridge, "!status", ch2) + await slash(bridge, "status", ch=ch2) assert "model : sonnet" in ch2.all_text async def test_modelul_ales_ajunge_in_subsol(bridge): ch = FakeChannel() - await run(bridge, "!model opus", ch) - await run(bridge, "salut", ch) + await slash(bridge, "model", ch=ch, model="opus") + await prompt(bridge, "salut", ch) assert "opus" in ch.sent[-1].content async def test_model_necunoscut_refuzat(bridge, store): + """Discord filtreaza prin Choice, dar centura ramane si in cod.""" ch = FakeChannel() - await run(bridge, "!model gpt", ch) + await slash(bridge, "model", ch=ch, model="gpt") assert "necunoscut" in ch.all_text assert store.thread(str(ch.id))["model"] == "sonnet" async def test_model_fara_argument_arata_curentul(bridge): - _, ch = await run(bridge, "!model") + _, ch, _ = await slash(bridge, "model", model="") assert "Model curent" in ch.all_text -# ------------------------------------------------------------------- !status +# ------------------------------------------------------------------- /status async def test_status_arata_campurile_cerute(bridge): ch = FakeChannel() - await run(bridge, "salut", ch) - _, _ = await run(bridge, "!status", ch) + await prompt(bridge, "salut", ch) + await slash(bridge, "status", ch=ch) text = ch.sent[-1].content for camp in ("fir", "sesiune", "director", "model", "proces", "tur in zbor", "in coada", "cost fir", "cost azi"): @@ -147,43 +177,43 @@ async def test_status_arata_campurile_cerute(bridge): async def test_status_arata_stderr(bridge, monkeypatch): ch = FakeChannel() monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "crash") - await run(bridge, "salut", ch) # esueaza si lasa stderr in buffer + await prompt(bridge, "salut", ch) # esueaza si lasa stderr in buffer proc = bridge.runner.procs[str(ch.id)] proc.stderr_buf.append("fake_claude: boom") - _, _ = await run(bridge, "!status", ch) + await slash(bridge, "status", ch=ch) assert "boom" in ch.sent[-1].content async def test_status_pe_fir_fara_proces(bridge): - _, ch = await run(bridge, "!status") + _, ch, _ = await slash(bridge, "status") assert "proces : oprit" in ch.all_text assert "(noua)" in ch.all_text -# --------------------------------------------------------------------- !stop +# --------------------------------------------------------------------- /stop async def test_stop_opreste_procesul(bridge, store): ch = FakeChannel() - await run(bridge, "salut", ch) + await prompt(bridge, "salut", ch) proc = bridge.runner.procs[str(ch.id)] assert proc.alive is True # procesul ramane viu intre tururi - await run(bridge, "!stop", ch) + await slash(bridge, "stop", ch=ch) assert "oprit" in ch.all_text assert proc.alive is False async def test_stop_fara_proces(bridge): - _, ch = await run(bridge, "!stop") + _, ch, _ = await slash(bridge, "stop") assert "Nu ruleaza nimic" in ch.all_text -async def test_stop_curata_inflight(bridge, store, monkeypatch): +async def test_stop_curata_inflight(bridge, store): ch = FakeChannel() store.set_inflight(str(ch.id), "t1", USER, "m1") - await run(bridge, "!stop", ch) + await slash(bridge, "stop", ch=ch) assert store.is_inflight(str(ch.id)) is False -# ------------------------------------------------------------------ !cleanup +# ------------------------------------------------------------------ /cleanup async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch): calls = {} @@ -204,12 +234,12 @@ async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch): return f"{len(orphans)} orfani" monkeypatch.setattr(bot, "cleanup", FakeCleanup) - _, ch = await run(bridge, "!cleanup") + _, ch, _ = await slash(bridge, "cleanup", force=False) assert "1 orfani" in ch.all_text assert calls["dry_run"] is True and calls["results"] is None assert "threads" in calls["state"] - _, ch2 = await run(bridge, "!cleanup --force") + _, ch2, _ = await slash(bridge, "cleanup", force=True) assert calls["dry_run"] is False and calls["results"] is not None @@ -220,12 +250,98 @@ async def test_cleanup_care_crapa_nu_doboara_botul(bridge, monkeypatch): raise RuntimeError("proc ilizibil") monkeypatch.setattr(bot, "cleanup", Boom) - _, ch = await run(bridge, "!cleanup") + _, ch, _ = await slash(bridge, "cleanup") assert "a esuat" in ch.all_text async def test_cleanup_real_ruleaza_in_dry_run(bridge): """Modulul real al lui Lane C: nu omoara nimic implicit.""" - cleanup = pytest.importorskip("cleanup") - _, ch = await run(bridge, "!cleanup") + pytest.importorskip("cleanup") + _, ch, _ = await slash(bridge, "cleanup") assert ch.all_text.strip() != "" + + +# ------------------------------------------------ declararea comenzilor slash +def test_toate_comenzile_au_handler(bridge): + for name in bot.COMMANDS: + assert callable(getattr(bridge, f"cmd_{name}", None)), name + + +def test_arborele_declara_exact_comenzile_puntii(bridge): + """Cu discord.py instalat: semnaturile chiar ajung in arbore.""" + pytest.importorskip("discord") + import commands_slash + + tree = commands_slash.build_tree(_FakeClient(), bridge) + cmds = {c.name: c for c in tree.get_commands()} + assert set(cmds) == set(bot.COMMANDS) + assert {p.name for p in cmds["new"].parameters} == {"fork"} + assert {p.name for p in cmds["cd"].parameters} == {"cale"} + assert {p.name for p in cmds["cleanup"].parameters} == {"force"} + assert cmds["cd"].parameters[0].required is True + assert cmds["new"].parameters[0].required is False + choices = [c.value for c in cmds["model"].parameters[0].choices] + assert choices == ["sonnet", "opus"] + + +class _FakeState: + _command_tree = None + + +class _FakeClient: + """`CommandTree` cere doar http + _connection; nu se deschide nicio conexiune.""" + + application_id = 42 + http = None + + def __init__(self): + self._connection = _FakeState() + + +class _FakeTree: + """Arbore fals pentru testarea sync-ului, fara retea.""" + + def __init__(self, client=None, fail=None): + self.client = client or _FakeClient() + self.fail = fail + self.synced: list[int] = [] + self.copied: list[int] = [] + + def copy_global_to(self, guild): + self.copied.append(guild.id) + + async def sync(self, guild=None): + if self.fail is not None: + raise self.fail + self.synced.append(guild.id) + return ["/new", "/cd"] + + +# ------------------------------------------------- inregistrarea in Discord +async def test_sync_se_face_pe_guild(bridge): + pytest.importorskip("discord") + import commands_slash + + tree = _FakeTree() + out = await commands_slash.sync_guilds(tree, ["100", "101"]) + assert tree.synced == [100, 101] and tree.copied == [100, 101] + assert out == {"100": "ok (2 comenzi)", "101": "ok (2 comenzi)"} + + +async def test_sync_esuat_nu_doboara_botul_si_spune_linkul(bridge, caplog): + pytest.importorskip("discord") + import commands_slash + + tree = _FakeTree(fail=RuntimeError("403 Forbidden (Missing Access)")) + with caplog.at_level("ERROR"): + out = await commands_slash.sync_guilds(tree, ["100"]) + assert out["100"].startswith("esuat") + assert "applications.commands" in caplog.text + assert "client_id=42" in caplog.text + + +async def test_sync_fara_arbore_sau_fara_guilduri_nu_arunca(): + import commands_slash + + assert await commands_slash.sync_guilds(None, ["100"]) == {} + assert await commands_slash.sync_guilds(_FakeTree(), []) == {}