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:
@@ -29,7 +29,7 @@ bot.py -- allowlist (guild / canal / utilizator) [doar adaptor Discord]
|
|||||||
+-- limits.py max procese, timeout tur, rate limit, plafon de cost
|
+-- limits.py max procese, timeout tur, rate limit, plafon de cost
|
||||||
+-- security/ hook PreToolUse (confirmari) + wrapper `infra`
|
+-- security/ hook PreToolUse (confirmari) + wrapper `infra`
|
||||||
+-- alerts.py alerte email [ops]
|
+-- alerts.py alerte email [ops]
|
||||||
+-- cleanup.py procese lasate in urma (`!cleanup`) [ops]
|
+-- cleanup.py procese lasate in urma (`/cleanup`) [ops]
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
claude -p --input-format stream-json --output-format stream-json --verbose
|
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;
|
(verificat: mesaj la 8s intr-un tur de 34.5s). Reaper la 20 min de inactivitate;
|
||||||
repornirea se face cu `--resume <sid>`, deci firul nu-si pierde contextul.
|
repornirea se face cu `--resume <sid>`, deci firul nu-si pierde contextul.
|
||||||
- **Un proces `claude` = ~406 MB RSS** (masurat). De aici toate limitele: maxim 4 procese
|
- **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
|
- **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 |
|
| Comanda | Ce face |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `!new` | Sesiune noua, curata, in firul curent |
|
| `/new` | Sesiune noua, curata, in firul curent |
|
||||||
| `!new --fork` | Sesiune noua care porneste din contextul celei curente |
|
| `/new fork:True` | Sesiune noua care porneste din contextul celei curente |
|
||||||
| `!cd <cale>` | Schimba directorul de lucru al firului (ex. `!cd /workspace/romfastsql`) |
|
| `/cd cale:<cale>` | Schimba directorul de lucru al firului (ex. `/cd cale:/workspace/romfastsql`) |
|
||||||
| `!model <sonnet\|opus>` | Schimba modelul pentru firul curent |
|
| `/model model:<sonnet\|opus>` | Schimba modelul pentru firul curent |
|
||||||
| `!status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr |
|
| `/status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr |
|
||||||
| `!stop` | Opreste turul in desfasurare din firul curent |
|
| `/stop` | Opreste turul in desfasurare din firul curent |
|
||||||
| `!cleanup` | Listeaza procesele lasate in urma (rulare seaca). `!cleanup --force` le opreste |
|
| `/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
|
Un fir de Discord = o sesiune Claude. Canalul principal are si el sesiunea lui, cea
|
||||||
implicita. Subsolul fiecarui raspuns arata modelul, durata si costul.
|
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
|
`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
|
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.
|
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
|
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
|
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
|
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.
|
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.)
|
(*Server Members* si *Presence* nu sunt necesare — lasa-le oprite.)
|
||||||
5. **Invita botul intr-un guild PRIVAT** al tau. *OAuth2* -> *URL Generator* ->
|
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*,
|
*Create Public Threads*, *Send Messages in Threads*, *Attach Files*,
|
||||||
*Embed Links*, *Add Reactions*. Deschide URL-ul generat si alege serverul.
|
*Embed Links*, *Add Reactions*. Deschide URL-ul generat si alege serverul.
|
||||||
Bifele sunt greu de nimerit pe telefon; linkul echivalent, gata calculat
|
Bifele sunt greu de nimerit pe telefon; linkul echivalent, gata calculat
|
||||||
(`APPLICATION_ID` e in *General Information*):
|
(`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
|
`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
|
**Nu-l invita intr-un server cu alti oameni** — cine scrie in canalul permis
|
||||||
comanda direct containerul.
|
comanda direct containerul.
|
||||||
6. **Ia ID-urile pentru allowlist.** In Discord: *Settings* -> *Advanced* ->
|
6. **Ia ID-urile pentru allowlist.** In Discord: *Settings* -> *Advanced* ->
|
||||||
@@ -178,7 +187,7 @@ systemctl --user restart claude-discord # repornire
|
|||||||
### Cost
|
### Cost
|
||||||
|
|
||||||
Costul se vede in trei locuri: in subsolul fiecarui raspuns (turul curent + cumulat pe
|
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
|
(`COST_CAP_USD_DAY`) botul nu mai accepta tururi noi si trimite email. Plafonul se
|
||||||
reseteaza la schimbarea zilei.
|
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 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. |
|
| 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`. |
|
| 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. |
|
| 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. |
|
| „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. |
|
| 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`. |
|
| 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
|
(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
|
incet. E singura cale fara test din analiza modurilor de esec — acceptata, fiindca
|
||||||
esecul e intarziere, nu pierdere.
|
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
|
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.
|
a `claude` (un `python -m http.server` desprins complet, de exemplu) ii scapa.
|
||||||
Lista `NEVER_KILL` din `cleanup.py` protejeaza infrastructura sesiunii (systemd, sshd,
|
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 |
|
| `stream.py` | parser tolerant de stream JSONL | A |
|
||||||
| `render.py` | chunker + loop de editare per canal | A |
|
| `render.py` | chunker + loop de editare per canal | A |
|
||||||
| `limits.py` | max procese, timeout, rate limit, plafon de cost | 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 |
|
| `config.py` | citeste `~/.claude-discord/env` | A |
|
||||||
| `security/confirm_hook.py` | hook `PreToolUse`, fail-closed | B |
|
| `security/confirm_hook.py` | hook `PreToolUse`, fail-closed | B |
|
||||||
| `security/approvals.py` | canal de aprobari hook <-> bot | B |
|
| `security/approvals.py` | canal de aprobari hook <-> bot | B |
|
||||||
| `security/infra` | wrapper cu lista de hosturi permise | B |
|
| `security/infra` | wrapper cu lista de hosturi permise | B |
|
||||||
| `alerts.py` | alerte email, dedup 1h, nu arunca niciodata | C |
|
| `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/claude-discord.service` | unit systemd de utilizator | C |
|
||||||
| `ops/install.sh` | instalare idempotenta | C |
|
| `ops/install.sh` | instalare idempotenta | C |
|
||||||
| `ops/env.example` | sablon de configurare | C |
|
| `ops/env.example` | sablon de configurare | C |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import pathlib
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import commands_slash
|
||||||
import config
|
import config
|
||||||
import limits as limits_mod
|
import limits as limits_mod
|
||||||
import render
|
import render
|
||||||
@@ -58,7 +59,7 @@ except Exception: # pragma: no cover - lipsa modulului NU are voie sa opreasca
|
|||||||
|
|
||||||
|
|
||||||
MODELS = ("sonnet", "opus")
|
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
|
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):
|
if self_id is not None and uid == str(self_id):
|
||||||
return Decision(False, "propriul mesaj")
|
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()
|
guilds, channels, users = guild_ids(), channel_ids(), user_ids()
|
||||||
if not guilds or not channels or not users:
|
if not guilds or not channels or not users:
|
||||||
return Decision(False, "allowlist goala (fail-closed)")
|
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:
|
if gid is None:
|
||||||
return Decision(False, "mesaj privat (fara guild)")
|
return Decision(False, "mesaj privat (fara guild)")
|
||||||
if str(gid) not in guilds:
|
if str(gid) not in guilds:
|
||||||
return Decision(False, f"guild neautorizat {gid}")
|
return Decision(False, f"guild neautorizat {gid}")
|
||||||
|
|
||||||
if not (set(channel_candidates(message)) & channels):
|
if not (set(channel_candidates(source)) & channels):
|
||||||
return Decision(False, f"canal neautorizat {thread_key(message)}")
|
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 Decision(False, f"utilizator neautorizat {uid}")
|
||||||
|
|
||||||
return ACCEPTED
|
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
|
# -------------------------------------------------------------------- comenzi
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Command:
|
class Command:
|
||||||
@@ -177,15 +206,64 @@ class Command:
|
|||||||
return " ".join(self.args)
|
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()
|
text = (content or "").strip()
|
||||||
if not text.startswith(PREFIX):
|
if not text.startswith(LEGACY_PREFIX):
|
||||||
return None
|
return None
|
||||||
body = text[len(PREFIX):].strip()
|
body = text[len(LEGACY_PREFIX):].strip()
|
||||||
if not body:
|
if not body:
|
||||||
return None
|
return None
|
||||||
parts = body.split()
|
name = body.split()[0].lower()
|
||||||
return Command(parts[0].lower(), parts[1:])
|
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
|
# ------------------------------------------------------------------ utilitare
|
||||||
@@ -380,9 +458,10 @@ class Bridge:
|
|||||||
return "empty"
|
return "empty"
|
||||||
|
|
||||||
tid = thread_key(message)
|
tid = thread_key(message)
|
||||||
command = parse_command(content)
|
hint = legacy_prefix_hint(content)
|
||||||
if command is not None:
|
if hint is not None:
|
||||||
return await self.handle_command(message, tid, command)
|
await self.say(message.channel, hint)
|
||||||
|
return "legacy-prefix"
|
||||||
|
|
||||||
# Steering: un mesaj sosit in timpul unui tur NU deschide un tur nou,
|
# Steering: un mesaj sosit in timpul unui tur NU deschide un tur nou,
|
||||||
# ci intra pe stdin-ul procesului viu. Asta e functionalitatea centrala.
|
# 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)
|
return await self.run_turn(message, tid, content)
|
||||||
|
|
||||||
# ------------------------------------------------------------- comenzi
|
# ------------------------------------------------------------- comenzi
|
||||||
async def handle_command(self, message, tid: str, cmd: Command) -> str:
|
async def handle_slash(self, interaction, name: str, **opts) -> str:
|
||||||
handler = getattr(self, f"cmd_{cmd.name}", None)
|
"""Punctul de intrare al comenzilor slash. Aceleasi cmd_* ca inainte.
|
||||||
if handler is None:
|
|
||||||
await self.say(message.channel, f"Comanda `!{cmd.name}` nu exista. `!help` le listeaza.")
|
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"
|
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:
|
async def cmd_help(self, message, tid: str, cmd: Command) -> None:
|
||||||
await self.say(
|
await self.say(
|
||||||
message.channel,
|
message.channel,
|
||||||
"**Comenzi**\n"
|
"**Comenzi**\n"
|
||||||
"`!new` sesiune noua · `!new --fork` sesiune noua din contextul curent\n"
|
"`/new` sesiune noua · `/new fork:True` sesiune noua din contextul curent\n"
|
||||||
"`!cd <cale>` schimba directorul de lucru\n"
|
"`/cd cale:<cale>` schimba directorul de lucru\n"
|
||||||
"`!model <sonnet|opus>` schimba modelul firului\n"
|
"`/model model:<sonnet|opus>` schimba modelul firului\n"
|
||||||
"`!status` starea firului · `!stop` opreste turul curent\n"
|
"`/status` starea firului · `/stop` opreste turul curent\n"
|
||||||
"`!cleanup [--force]` procese lasate in urma",
|
"`/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:
|
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:
|
async def cmd_cd(self, message, tid: str, cmd: Command) -> None:
|
||||||
if not cmd.args:
|
if not cmd.args:
|
||||||
await self.say(message.channel, "Foloseste `!cd <cale>`.")
|
await self.say(message.channel, "Foloseste `/cd cale:<cale>`.")
|
||||||
return
|
return
|
||||||
# Fara allowlist de proiecte: accesul larg la /workspace e decizie ferma din plan.
|
# Fara allowlist de proiecte: accesul larg la /workspace e decizie ferma din plan.
|
||||||
path = os.path.abspath(os.path.expanduser(cmd.rest))
|
path = os.path.abspath(os.path.expanduser(cmd.rest))
|
||||||
@@ -453,7 +574,7 @@ class Bridge:
|
|||||||
await self.say(
|
await self.say(
|
||||||
message.channel,
|
message.channel,
|
||||||
f"Model curent: `{rec.get('model') or default_model()}`. "
|
f"Model curent: `{rec.get('model') or default_model()}`. "
|
||||||
f"Foloseste `!model <{'|'.join(MODELS)}>`.",
|
f"Foloseste `/model model:<{'|'.join(MODELS)}>`.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
model = cmd.args[0].strip().lower()
|
model = cmd.args[0].strip().lower()
|
||||||
@@ -518,7 +639,7 @@ class Bridge:
|
|||||||
results = await asyncio.to_thread(cleanup.kill_orphans, orphans, not force)
|
results = await asyncio.to_thread(cleanup.kill_orphans, orphans, not force)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.exception("cleanup a esuat")
|
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
|
return
|
||||||
await self.say(message.channel, cleanup.format_report(orphans, results if force else None))
|
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)
|
super().__init__(intents=intents)
|
||||||
self.bridge = bridge or Bridge()
|
self.bridge = bridge or Bridge()
|
||||||
self.bridge.get_channel = self.get_channel
|
self.bridge.get_channel = self.get_channel
|
||||||
|
self.tree = commands_slash.build_tree(self, self.bridge)
|
||||||
self._started = False
|
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):
|
async def on_ready(self):
|
||||||
self.bridge.self_id = str(self.user.id) if self.user else None
|
self.bridge.self_id = str(self.user.id) if self.user else None
|
||||||
if not self._started:
|
if not self._started:
|
||||||
|
|||||||
116
proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py
Normal file
116
proxmox/lxc171-claude-agent/discord-bridge/commands_slash.py
Normal file
@@ -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
|
||||||
@@ -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.
|
# 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
|
# 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
|
# containerul. Nu prinde totusi ce s-a desprins din cgroup — pentru aia exista
|
||||||
# comanda `!cleanup` (cleanup.py).
|
# comanda `/cleanup` (cleanup.py).
|
||||||
KillMode=control-group
|
KillMode=control-group
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ DISCORD_USER_IDS=
|
|||||||
|
|
||||||
# Aplicatia Discord a puntii (creata 2026-08-30). Link de reinvitare, daca botul
|
# Aplicatia Discord a puntii (creata 2026-08-30). Link de reinvitare, daca botul
|
||||||
# a fost scos din server sau ii lipsesc permisiuni:
|
# 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,
|
# permissions=309237763136 -> View Channel, Send Messages, Embed Links, Attach Files,
|
||||||
# Read Message History, Add Reactions, Create/Send in Public Threads.
|
# Read Message History, Add Reactions, Create/Send in Public Threads.
|
||||||
|
|
||||||
# --- Model si cost ---------------------------------------------------------
|
# --- 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
|
CLAUDE_MODEL=sonnet
|
||||||
# Plafon de cost pe zi, in USD. La atingere botul refuza tururi noi si alerteaza.
|
# 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.
|
# Reper masurat: un tur banal pe opus a costat $0.1547.
|
||||||
@@ -49,6 +49,6 @@ ALERT_RECIPIENT=root
|
|||||||
|
|
||||||
# --- Directorul de lucru implicit -----------------------------------------
|
# --- Directorul de lucru implicit -----------------------------------------
|
||||||
# Directorul in care porneste un fir nou. Spatiu dedicat, cu git propriu, ca ce se
|
# 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 <cale>` il
|
# face din Discord sa aiba istoric separat de proiectele reale. `/cd <cale>` il
|
||||||
# schimba per fir, oriunde in /workspace.
|
# schimba per fir, oriunde in /workspace.
|
||||||
DEFAULT_CWD=/workspace/claude-agent
|
DEFAULT_CWD=/workspace/claude-agent
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import bot
|
|||||||
|
|
||||||
try: # rulat ca pachet `tests` sau ca module libere
|
try: # rulat ca pachet `tests` sau ca module libere
|
||||||
from tests.test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild,
|
from tests.test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild,
|
||||||
FakeMessage, FakeUser, allowed, bridge)
|
FakeInteraction, FakeMessage, FakeUser, allowed, bridge)
|
||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
from test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild,
|
from test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild,
|
||||||
FakeMessage, FakeUser, allowed, bridge)
|
FakeInteraction, FakeMessage, FakeUser, allowed, bridge)
|
||||||
|
|
||||||
__all__ = ["allowed", "bridge"] # fixturi reexportate
|
__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 await bridge.handle_message(msg) == "rejected"
|
||||||
assert ch.sent == []
|
assert ch.sent == []
|
||||||
assert bridge.runner.procs == {}
|
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
|
||||||
|
|||||||
@@ -81,6 +81,51 @@ class FakeMessage:
|
|||||||
self.reactions.append(emoji)
|
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
|
# ------------------------------------------------------------------ fixturi
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def allowed(monkeypatch):
|
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)
|
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
|
# ------------------------------------------------------------------ aprobari
|
||||||
def test_decizia_cere_allowlist(bridge, allowed):
|
def test_decizia_cere_allowlist(bridge, allowed):
|
||||||
assert "allowlist" in bridge.decide("777", "req-1", "allow")
|
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)
|
monkeypatch.setattr(bot, "cleanup", None)
|
||||||
assert bridge.wire_approvals() is False
|
assert bridge.wire_approvals() is False
|
||||||
ch = FakeChannel()
|
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 "nu e disponibil" in ch.all_text
|
||||||
assert bridge.approval_view("req-1") is None
|
assert bridge.approval_view("req-1") is None
|
||||||
assert await bridge.handle_message(FakeMessage("salut", channel=ch)) == "ok"
|
assert await bridge.handle_message(FakeMessage("salut", channel=ch)) == "ok"
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,48 +7,77 @@ import pytest
|
|||||||
import bot
|
import bot
|
||||||
|
|
||||||
try:
|
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
|
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"]
|
__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()
|
ch = ch or FakeChannel()
|
||||||
msg = FakeMessage(text, channel=ch)
|
it = FakeInteraction(channel=ch)
|
||||||
label = await bridge.handle_message(msg)
|
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
|
return label, ch
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- parser
|
# ------------------------------------------------- optiuni slash -> argumente
|
||||||
def test_parse_command():
|
def test_slash_args():
|
||||||
cmd = bot.parse_command("!cd /workspace/romfastsql")
|
assert bot.slash_args("new", {"fork": True}) == ["--fork"]
|
||||||
assert cmd.name == "cd" and cmd.rest == "/workspace/romfastsql"
|
assert bot.slash_args("new", {"fork": False}) == []
|
||||||
assert bot.parse_command("!MODEL opus").name == "model"
|
assert bot.slash_args("cleanup", {"force": True}) == ["--force"]
|
||||||
assert bot.parse_command("salut") is None
|
assert bot.slash_args("cd", {"cale": " /workspace/romfastsql "}) == ["/workspace/romfastsql"]
|
||||||
assert bot.parse_command("!") is None
|
assert bot.slash_args("model", {"model": "opus"}) == ["opus"]
|
||||||
|
assert bot.slash_args("status", {}) == []
|
||||||
|
|
||||||
|
|
||||||
async def test_comanda_necunoscuta(bridge):
|
def test_prefixul_vechi_nu_mai_e_parsat():
|
||||||
label, ch = await run(bridge, "!inexistenta")
|
"""`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 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):
|
async def test_help(bridge):
|
||||||
label, ch = await run(bridge, "!help")
|
label, ch, _ = await slash(bridge, "help")
|
||||||
assert label == "cmd: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 c in ch.all_text
|
||||||
|
assert "!" not in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- !new
|
# ---------------------------------------------------------------------- /new
|
||||||
async def test_new_sterge_sesiunea(bridge, store):
|
async def test_new_sterge_sesiunea(bridge, store):
|
||||||
ch = FakeChannel()
|
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"
|
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 label == "cmd:new"
|
||||||
assert store.thread(str(ch.id))["sid"] is None
|
assert store.thread(str(ch.id))["sid"] is None
|
||||||
assert bridge.runner.procs[str(ch.id)].alive is False
|
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):
|
async def test_new_fork_pastreaza_contextul(bridge, store):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
await run(bridge, "salut", ch)
|
await prompt(bridge, "salut", ch)
|
||||||
await run(bridge, "!new --fork", ch)
|
await slash(bridge, "new", ch=ch, fork=True)
|
||||||
assert store.thread(str(ch.id))["sid"] == "sid-fake-0001"
|
assert store.thread(str(ch.id))["sid"] == "sid-fake-0001"
|
||||||
assert "fork" in ch.all_text
|
assert "fork" in ch.all_text
|
||||||
assert bridge.runner.procs[str(ch.id)].sid == "sid-fake-0001"
|
assert bridge.runner.procs[str(ch.id)].sid == "sid-fake-0001"
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- !cd
|
# ----------------------------------------------------------------------- /cd
|
||||||
async def test_cd_oriunde_in_workspace(bridge, store, tmp_path):
|
async def test_cd_oriunde_in_workspace(bridge, store, tmp_path):
|
||||||
target = tmp_path / "proiect-nou"
|
target = tmp_path / "proiect-nou"
|
||||||
target.mkdir()
|
target.mkdir()
|
||||||
label, ch = await run(bridge, f"!cd {target}")
|
label, ch, _ = await slash(bridge, "cd", cale=str(target))
|
||||||
assert label == "cmd:cd"
|
assert label == "cmd:cd"
|
||||||
assert store.thread(str(ch.id))["cwd"] == str(target)
|
assert store.thread(str(ch.id))["cwd"] == str(target)
|
||||||
assert str(target) in ch.all_text
|
assert str(target) in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
async def test_cd_refuza_ce_nu_e_director(bridge, store):
|
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 "nu e un director" in ch.all_text
|
||||||
assert store.thread(str(ch.id))["cwd"] != "/nu/exista/nicaieri"
|
assert store.thread(str(ch.id))["cwd"] != "/nu/exista/nicaieri"
|
||||||
|
|
||||||
|
|
||||||
async def test_cd_fara_argument(bridge):
|
async def test_cd_fara_argument(bridge):
|
||||||
_, ch = await run(bridge, "!cd")
|
_, ch, _ = await slash(bridge, "cd", cale="")
|
||||||
assert "Foloseste" in ch.all_text
|
assert "Foloseste" in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
async def test_cd_se_aplica_procesului_urmator(bridge, tmp_path):
|
async def test_cd_se_aplica_procesului_urmator(bridge, tmp_path):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
await run(bridge, "salut", ch)
|
await prompt(bridge, "salut", ch)
|
||||||
target = tmp_path / "alt-proiect"
|
target = tmp_path / "alt-proiect"
|
||||||
target.mkdir()
|
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)].cwd == str(target)
|
||||||
assert bridge.runner.procs[str(ch.id)].alive is False # se reia cu --resume
|
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):
|
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 store.thread(str(ch.id))["model"] == "sonnet"
|
||||||
assert "sonnet" in ch.all_text
|
assert "sonnet" in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
async def test_model_opus_per_fir(bridge, store):
|
async def test_model_opus_per_fir(bridge, store):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
label, _ = await run(bridge, "!model opus", ch)
|
label, _, _ = await slash(bridge, "model", ch=ch, model="opus")
|
||||||
assert label == "cmd:model"
|
assert label == "cmd:model"
|
||||||
assert store.thread(str(ch.id))["model"] == "opus"
|
assert store.thread(str(ch.id))["model"] == "opus"
|
||||||
# firul vecin (alt thread, acelasi canal-parinte) ramane pe sonnet
|
# firul vecin (alt thread, acelasi canal-parinte) ramane pe sonnet
|
||||||
ch2 = FakeChannel("201", parent_id=CHANNEL)
|
ch2 = FakeChannel("201", parent_id=CHANNEL)
|
||||||
await run(bridge, "!status", ch2)
|
await slash(bridge, "status", ch=ch2)
|
||||||
assert "model : sonnet" in ch2.all_text
|
assert "model : sonnet" in ch2.all_text
|
||||||
|
|
||||||
|
|
||||||
async def test_modelul_ales_ajunge_in_subsol(bridge):
|
async def test_modelul_ales_ajunge_in_subsol(bridge):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
await run(bridge, "!model opus", ch)
|
await slash(bridge, "model", ch=ch, model="opus")
|
||||||
await run(bridge, "salut", ch)
|
await prompt(bridge, "salut", ch)
|
||||||
assert "opus" in ch.sent[-1].content
|
assert "opus" in ch.sent[-1].content
|
||||||
|
|
||||||
|
|
||||||
async def test_model_necunoscut_refuzat(bridge, store):
|
async def test_model_necunoscut_refuzat(bridge, store):
|
||||||
|
"""Discord filtreaza prin Choice, dar centura ramane si in cod."""
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
await run(bridge, "!model gpt", ch)
|
await slash(bridge, "model", ch=ch, model="gpt")
|
||||||
assert "necunoscut" in ch.all_text
|
assert "necunoscut" in ch.all_text
|
||||||
assert store.thread(str(ch.id))["model"] == "sonnet"
|
assert store.thread(str(ch.id))["model"] == "sonnet"
|
||||||
|
|
||||||
|
|
||||||
async def test_model_fara_argument_arata_curentul(bridge):
|
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
|
assert "Model curent" in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- !status
|
# ------------------------------------------------------------------- /status
|
||||||
async def test_status_arata_campurile_cerute(bridge):
|
async def test_status_arata_campurile_cerute(bridge):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
await run(bridge, "salut", ch)
|
await prompt(bridge, "salut", ch)
|
||||||
_, _ = await run(bridge, "!status", ch)
|
await slash(bridge, "status", ch=ch)
|
||||||
text = ch.sent[-1].content
|
text = ch.sent[-1].content
|
||||||
for camp in ("fir", "sesiune", "director", "model", "proces", "tur in zbor",
|
for camp in ("fir", "sesiune", "director", "model", "proces", "tur in zbor",
|
||||||
"in coada", "cost fir", "cost azi"):
|
"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):
|
async def test_status_arata_stderr(bridge, monkeypatch):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "crash")
|
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 = bridge.runner.procs[str(ch.id)]
|
||||||
proc.stderr_buf.append("fake_claude: boom")
|
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
|
assert "boom" in ch.sent[-1].content
|
||||||
|
|
||||||
|
|
||||||
async def test_status_pe_fir_fara_proces(bridge):
|
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 "proces : oprit" in ch.all_text
|
||||||
assert "(noua)" in ch.all_text
|
assert "(noua)" in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- !stop
|
# --------------------------------------------------------------------- /stop
|
||||||
async def test_stop_opreste_procesul(bridge, store):
|
async def test_stop_opreste_procesul(bridge, store):
|
||||||
ch = FakeChannel()
|
ch = FakeChannel()
|
||||||
await run(bridge, "salut", ch)
|
await prompt(bridge, "salut", ch)
|
||||||
proc = bridge.runner.procs[str(ch.id)]
|
proc = bridge.runner.procs[str(ch.id)]
|
||||||
assert proc.alive is True # procesul ramane viu intre tururi
|
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 "oprit" in ch.all_text
|
||||||
assert proc.alive is False
|
assert proc.alive is False
|
||||||
|
|
||||||
|
|
||||||
async def test_stop_fara_proces(bridge):
|
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
|
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()
|
ch = FakeChannel()
|
||||||
store.set_inflight(str(ch.id), "t1", USER, "m1")
|
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
|
assert store.is_inflight(str(ch.id)) is False
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ !cleanup
|
# ------------------------------------------------------------------ /cleanup
|
||||||
async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch):
|
async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch):
|
||||||
calls = {}
|
calls = {}
|
||||||
|
|
||||||
@@ -204,12 +234,12 @@ async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch):
|
|||||||
return f"{len(orphans)} orfani"
|
return f"{len(orphans)} orfani"
|
||||||
|
|
||||||
monkeypatch.setattr(bot, "cleanup", FakeCleanup)
|
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 "1 orfani" in ch.all_text
|
||||||
assert calls["dry_run"] is True and calls["results"] is None
|
assert calls["dry_run"] is True and calls["results"] is None
|
||||||
assert "threads" in calls["state"]
|
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
|
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")
|
raise RuntimeError("proc ilizibil")
|
||||||
|
|
||||||
monkeypatch.setattr(bot, "cleanup", Boom)
|
monkeypatch.setattr(bot, "cleanup", Boom)
|
||||||
_, ch = await run(bridge, "!cleanup")
|
_, ch, _ = await slash(bridge, "cleanup")
|
||||||
assert "a esuat" in ch.all_text
|
assert "a esuat" in ch.all_text
|
||||||
|
|
||||||
|
|
||||||
async def test_cleanup_real_ruleaza_in_dry_run(bridge):
|
async def test_cleanup_real_ruleaza_in_dry_run(bridge):
|
||||||
"""Modulul real al lui Lane C: nu omoara nimic implicit."""
|
"""Modulul real al lui Lane C: nu omoara nimic implicit."""
|
||||||
cleanup = pytest.importorskip("cleanup")
|
pytest.importorskip("cleanup")
|
||||||
_, ch = await run(bridge, "!cleanup")
|
_, ch, _ = await slash(bridge, "cleanup")
|
||||||
assert ch.all_text.strip() != ""
|
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(), []) == {}
|
||||||
|
|||||||
Reference in New Issue
Block a user