Plugin assistenza server: monitoraggio Sentinella via SSH e bot sysadmin via Telegram con approvazioni

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-25 15:09:10 +02:00
1 parent 1e372d068c
commit d2f2e93b21
5 files changed
+411

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ def claude_env(config_dir: str) -> dict:
env.pop("CLAUDECODE", None)
if config_dir.strip():
env["CLAUDE_CONFIG_DIR"] = config_dir.strip()
env.setdefault("MCP_TOOL_TIMEOUT", "900000") # strumenti lenti (es. assistenza server): fino a 15 minuti
return env
+6
View File
@@ -31,3 +31,9 @@ Sei **Ava**, l'assistente personale di chi ti parla. Vivi in un'app sul suo Mac
- Puoi guardare con la webcam e vedere lo schermo del Mac tramite gli strumenti di visione. "Cosa vedi?", "guardami", "come sto?" si riferiscono alla webcam; lo schermo solo quando viene nominato.
- Descrivi ciò che vedi in modo naturale e breve, come farebbe una persona; non elencare dettagli tecnici dell'immagine.
## Server
- Con gli strumenti "server_…" tieni d'occhio i server Linux dell'utente: lo stato della flotta, gli allarmi e i backup li leggi subito dal monitoraggio; per diagnosi e interventi ("perché nginx dà 502?", "riavvia php-fpm su web1") inoltri la richiesta all'assistente sysadmin con server_chiedi e riferisci la sua risposta in breve.
- Quando l'assistente sysadmin chiede l'approvazione di un comando, leggi all'utente cosa vuole fare e attendi la sua decisione prima di usare server_approva. Non approvare mai di tua iniziativa.
- Se una risposta tarda, dillo e continua con server_attendi invece di inventare l'esito.
+3
View File
@@ -46,6 +46,9 @@ DEFAULTS: dict[str, Any] = {
"monitor_intervallo": 60, # secondi tra un controllo e l'altro
"monitor_regole": "", # cosa merita attenzione (vuoto = regole predefinite)
"monitor_escludi": "", # parole/frasi (separate da virgola) che escludono un messaggio dagli avvisi
"server_assist_ssh": "", # utente@host del server ponte (assistenza server), es. root@1.2.3.4
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
}
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key")
+14
View File
@@ -204,6 +204,17 @@ class SettingsDialog(QDialog):
add(pg_msg, form4)
threading.Thread(target=lambda: self._async.emit("wa", (self._wa_status(), None)), daemon=True).start()
# ── Assistenza server (bot sysadmin + monitoraggio Sentinella) ────
form_srv = QFormLayout()
form_srv.addRow(QLabel("Assistenza server: bot Telegram sysadmin e monitoraggio Sentinella sul server ponte (accesso SSH con chiave)"))
self.srv_ssh = QLineEdit(str(s.get("server_assist_ssh") or "")); self.srv_ssh.setPlaceholderText("root@host del server ponte")
form_srv.addRow("SSH server ponte", self.srv_ssh)
self.srv_dir = QLineEdit(str(s.get("server_assist_dir") or "")); self.srv_dir.setPlaceholderText("/opt/aiserverassistance")
form_srv.addRow("Cartella del bot sul server", self.srv_dir)
self.srv_bot = QLineEdit(str(s.get("server_assist_bot") or "")); self.srv_bot.setPlaceholderText("luzaserver_bot")
form_srv.addRow("Username del bot Telegram", self.srv_bot)
add(pg_msg, form_srv)
# ── Monitor avvisi ────────────────────────────────────────────────
form5 = QFormLayout()
row = QHBoxLayout()
@@ -452,6 +463,9 @@ class SettingsDialog(QDialog):
"monitor_intervallo": int(self.mon_int.currentData() or 60),
"monitor_regole": self.mon_rules.text().strip(),
"monitor_escludi": self.mon_excl.text().strip(),
"server_assist_ssh": self.srv_ssh.text().strip(),
"server_assist_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance",
"server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot",
}
if self.tg_hash.text().strip():
values["telegram_api_hash"] = self.tg_hash.text().strip()
+387
View File
@@ -0,0 +1,387 @@
"""Assistenza server: parla con il bot sysadmin (Claude Code sul server ponte) e legge il monitoraggio Sentinella.
Due canali, nessuna shell diretta da qui:
- Telegram (account personale, Telethon): la richiesta viene inviata al bot @luzaserver_bot, che indaga via SSH
e chiede approvazione con i pulsanti per ogni comando che modifica qualcosa. Da qui si può approvare o negare.
- SSH verso il server ponte: interroga l'API locale di Sentinella (flotta, allarmi, backup, azioni proposte)
con uno script eseguito sul server; la password dell'amministratore non lascia mai il server.
"""
from __future__ import annotations
import asyncio
import json
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from avatar.macos import confirm_or_param, CONFERMATO # noqa: E402
from avatar.settings import Settings # noqa: E402
from avatar import telegram_client as tg # noqa: E402
_state: dict = {"since": 0} # ultimo id di messaggio già consegnato all'assistente
QUIET_SECONDS = 5 # silenzio del bot dopo l'ultima risposta = risposta completa
def _cfg() -> tuple[str, str, str]:
s = Settings()
ssh = str(s.get("server_assist_ssh") or "").strip()
d = str(s.get("server_assist_dir") or "/opt/aiserverassistance").strip()
bot = str(s.get("server_assist_bot") or "luzaserver_bot").strip().lstrip("@")
return ssh, d, bot
# ── Sentinella via SSH ───────────────────────────────────────────────────────
_REMOTE = r'''
import json, sys, urllib.request, urllib.parse
env = {}
for line in open(sys.argv[1] + "/.env"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1); env[k.strip()] = v.strip().strip('"').strip("'")
base = env.get("SENTINELLA_API_URL", "http://127.0.0.1:8000")
req = json.loads(sys.stdin.read())
data = urllib.parse.urlencode({"username": env.get("SENTINELLA_ADMIN_USER", "admin"), "password": env.get("SENTINELLA_ADMIN_PASS", "")}).encode()
tok = json.load(urllib.request.urlopen(urllib.request.Request(base + "/api/auth/login", data=data), timeout=15))["access_token"]
out = {}
for item in req:
path, method, body = item["path"], item.get("method", "GET"), item.get("json")
r = urllib.request.Request(base + path, method=method, headers={"authorization": "Bearer " + tok, "content-type": "application/json"},
data=json.dumps(body).encode() if body is not None else None)
try:
with urllib.request.urlopen(r, timeout=20) as resp:
raw = resp.read()
out[path] = json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
out[path] = {"errore": "HTTP %s: %s" % (e.code, e.read()[:200].decode(errors="replace"))}
print(json.dumps(out, default=str))
'''
def _sentinella(requests: list[dict]) -> dict:
"""Esegue sul server ponte uno script Python (via stdin) che interroga l'API Sentinella con le credenziali locali."""
ssh, d, _ = _cfg()
if not ssh:
raise RuntimeError("Assistenza server non configurata: imposta l'accesso SSH al server ponte (utente@host) nella scheda Messaggistica.")
# Il caricatore non contiene apici: sopravvive alla shell remota. Legge tutto lo stdin, separa script e richiesta
# sul marcatore "\n=====\n", rimette la richiesta su stdin ed esegue lo script.
loader = "import sys,io;s=sys.stdin.read();i=s.index(chr(10)+chr(61)*5+chr(10));sys.stdin=io.StringIO(s[i+7:]);exec(s[:i])"
cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", ssh, "python3", "-c", f"'{loader}'", d]
script = _REMOTE.replace("req = json.loads(sys.stdin.read())", "req = json.loads(sys.stdin.readline())")
proc = subprocess.run(cmd, input=script + "\n=====\n" + json.dumps(requests) + "\n", capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout).strip().splitlines()
raise RuntimeError("server ponte non raggiungibile o script fallito: " + (err[-1] if err else f"codice {proc.returncode}"))
try:
return json.loads(proc.stdout.strip().splitlines()[-1])
except Exception:
raise RuntimeError("risposta inattesa dal server ponte: " + proc.stdout[-300:])
def _pct(v) -> str:
return "?" if v is None else f"{round(float(v))}%"
def _icon(status: str) -> str:
return {"online": "🟢", "warning": "🟡", "critical": "🔴", "offline": "⚫"}.get(str(status or "").lower(), "⚪")
def _sev(s: str) -> str:
return {"critical": "🔴", "warning": "🟡", "info": "🔵"}.get(str(s or "").lower(), "⚪")
def flotta(params: dict, ctx: dict) -> str:
res = _sentinella([{"path": "/api/servers"}, {"path": "/api/alerts?status_filter=firing"}])
servers, alerts = res.get("/api/servers") or [], res.get("/api/alerts?status_filter=firing") or []
if isinstance(servers, dict):
return f"Errore dal monitoraggio: {servers.get('errore')}"
if not servers:
return "Nessun server monitorato."
n_al = {}
for a in alerts if isinstance(alerts, list) else []:
n_al[a.get("server_id")] = n_al.get(a.get("server_id"), 0) + 1
lines = []
for s in servers:
m = s.get("latest") or {}
met = f" cpu {_pct(m.get('cpu_percent'))}, ram {_pct(m.get('mem_percent'))}, disco {_pct(m.get('disk_percent'))}" if m else " (nessuna metrica)"
al = f", {n_al[s['id']]} allarm{'e' if n_al[s['id']] == 1 else 'i'}" if s.get("id") in n_al else ""
lines.append(f"{_icon(s.get('status'))} {s.get('name')} [{s.get('status')}]{met}{al}")
return f"Flotta ({len(servers)} server):\n" + "\n".join(lines)
def dettaglio(params: dict, ctx: dict) -> str:
nome = str(params.get("nome", "")).strip().lower()
if not nome:
return "Errore: serve il nome del server (vedi server_flotta)."
res = _sentinella([{"path": "/api/servers"}, {"path": "/api/alerts?status_filter=firing"}])
servers = res.get("/api/servers") or []
s = next((x for x in servers if str(x.get("name", "")).lower() == nome), None) or next((x for x in servers if nome in str(x.get("name", "")).lower()), None)
if not s:
return f"Server '{nome}' non trovato. Server disponibili: " + ", ".join(x.get("name", "?") for x in servers)
m = s.get("latest") or {}
os_info = s.get("os_info") or {}
al = [a for a in (res.get("/api/alerts?status_filter=firing") or []) if a.get("server_id") == s.get("id")]
out = [f"{_icon(s.get('status'))} {s.get('name')} [{s.get('status')}] {s.get('hostname') or ''} {os_info.get('distro') or ''}".strip()]
if m:
out.append(f"CPU {_pct(m.get('cpu_percent'))}, RAM {_pct(m.get('mem_percent'))}, disco {_pct(m.get('disk_percent'))}, load {m.get('load1', '?')}")
out.append(f"Ultimo heartbeat: {str(s.get('last_seen') or '?').replace('T', ' ')[:19]}")
if al:
out.append("Allarmi attivi:")
out += [f" {_sev(a.get('severity'))} {a.get('title')}: {' '.join(str(a.get('message') or '').split())[:160]}" for a in al[:8]]
else:
out.append("Nessun allarme attivo.")
return "\n".join(out)
def allarmi(params: dict, ctx: dict) -> str:
res = _sentinella([{"path": "/api/servers"}, {"path": "/api/alerts?status_filter=firing"}])
alerts = res.get("/api/alerts?status_filter=firing") or []
if isinstance(alerts, dict):
return f"Errore dal monitoraggio: {alerts.get('errore')}"
if not alerts:
return "Nessun allarme attivo sui server."
names = {s.get("id"): s.get("name") for s in (res.get("/api/servers") or [])}
lines = [f"{_sev(a.get('severity'))} [{names.get(a.get('server_id'), 'srv' + str(a.get('server_id')))}] {a.get('title')}: {' '.join(str(a.get('message') or '').split())[:160]} (dal {str(a.get('created_at') or '')[:16].replace('T', ' ')})" for a in alerts[:20]]
more = f"\n… e altri {len(alerts) - 20}" if len(alerts) > 20 else ""
return f"Allarmi attivi ({len(alerts)}):\n" + "\n".join(lines) + more
def backup(params: dict, ctx: dict) -> str:
azione = str(params.get("azione", "elenco")).strip().lower()
if azione == "storico":
runs = _sentinella([{"path": "/api/backups/runs"}]).get("/api/backups/runs") or []
if not runs:
return "Nessun backup eseguito finora."
lines = [f"- job {r.get('job_id')} {str(r.get('started_at') or r.get('created_at') or '')[:16].replace('T', ' ')}: {r.get('status')} {r.get('size_bytes') or ''} {r.get('message') or r.get('error') or ''}".rstrip() for r in runs[:15]]
return "Ultimi backup:\n" + "\n".join(lines)
res = _sentinella([{"path": "/api/backups/jobs"}, {"path": "/api/servers"}])
jobs = res.get("/api/backups/jobs") or []
names = {s.get("id"): s.get("name") for s in (res.get("/api/servers") or [])}
if azione == "esegui":
rif = str(params.get("job", "")).strip().lower()
job = next((j for j in jobs if str(j.get("id")) == rif or str(j.get("name", "")).lower() == rif), None) or next((j for j in jobs if rif and rif in str(j.get("name", "")).lower()), None)
if not job:
return "Job di backup non trovato. Disponibili: " + ", ".join(f"{j.get('id')} {j.get('name')}" for j in jobs)
def do() -> str:
r = _sentinella([{"path": f"/api/backups/jobs/{job['id']}/run", "method": "POST"}])
v = r.get(f"/api/backups/jobs/{job['id']}/run")
if isinstance(v, dict) and v.get("errore"):
return f"Errore: {v['errore']}"
return f"Backup '{job.get('name')}' avviato su {names.get(job.get('server_id'), '?')}: l'agent lo eseguirà al prossimo heartbeat."
return confirm_or_param(ctx, params, "server_backup", "Avviare il backup?", f"{job.get('name')} su {names.get(job.get('server_id'), '?')}\n{job.get('paths') or ''}", do)
if not jobs:
return "Nessun job di backup configurato."
lines = [f"- {j.get('id')} {j.get('name')} su {names.get(j.get('server_id'), '?')}: {j.get('paths') or ''} {('cron ' + str(j.get('cron'))) if j.get('cron') else ''} {'(disattivo)' if j.get('enabled') is False else ''}".rstrip() for j in jobs]
return "Job di backup:\n" + "\n".join(lines) + "\nPer lanciarne uno: server_backup con azione=esegui e job=<id o nome>."
def azioni(params: dict, ctx: dict) -> str:
azione = str(params.get("azione", "elenco")).strip().lower()
res = _sentinella([{"path": "/api/actions"}, {"path": "/api/servers"}])
actions = res.get("/api/actions") or []
if isinstance(actions, dict):
return f"Errore dal monitoraggio: {actions.get('errore')}"
names = {s.get("id"): s.get("name") for s in (res.get("/api/servers") or [])}
def fmt(a: dict) -> str:
return f"[{a.get('id')}] {names.get(a.get('server_id'), '?')} ({a.get('status')}, rischio {a.get('risk') or a.get('risk_level') or '?'}): {a.get('command') or ''} — {a.get('reason') or a.get('explanation') or ''}".strip()
if azione in ("approva", "rifiuta"):
aid = str(params.get("id", "")).strip()
a = next((x for x in actions if str(x.get("id")) == aid), None)
if not a:
pend = [x for x in actions if str(x.get("status", "")).lower() == "pending"]
return "Azione non trovata. In attesa: " + ("\n".join(fmt(x) for x in pend) if pend else "nessuna")
verb = "approve" if azione == "approva" else "reject"
def do() -> str:
r = _sentinella([{"path": f"/api/actions/{a['id']}/{verb}", "method": "POST"}])
v = r.get(f"/api/actions/{a['id']}/{verb}")
if isinstance(v, dict) and v.get("errore"):
return f"Errore: {v['errore']}"
return ("Azione approvata: verrà eseguita al prossimo heartbeat dell'agent." if verb == "approve" else "Azione rifiutata.")
return confirm_or_param(ctx, params, "server_azione", "Approvare l'intervento sul server?" if verb == "approve" else "Rifiutare l'intervento?", fmt(a), do)
pend = [x for x in actions if str(x.get("status", "")).lower() == "pending"]
if not pend:
recent = actions[-5:] if isinstance(actions, list) else []
return "Nessun intervento in attesa di approvazione." + ("\nUltimi: \n" + "\n".join(fmt(x) for x in recent) if recent else "")
return f"Interventi proposti in attesa ({len(pend)}):\n" + "\n".join(fmt(x) for x in pend) + "\nPer decidere: server_azioni con azione=approva|rifiuta e id."
# ── Assistente sysadmin via Telegram ─────────────────────────────────────────
def _buttons(msg) -> list:
out = []
for row in (msg.buttons or []):
for b in row:
data = getattr(b.button, "data", None)
if isinstance(data, (bytes, bytearray)):
out.append((data.decode(errors="ignore"), b))
return out
def _pending(msg) -> bool:
return any(d.startswith(("ok:", "approve:")) for d, _ in _buttons(msg))
def _text(m) -> str:
t = (m.message or "").strip()
if not t and getattr(m, "media", None):
t = "[allegato]"
return t
async def _collect(client, bot, since: int, wait: float) -> tuple[list, bool, bool]:
"""Raccoglie le risposte del bot dopo `since`. Ritorna (messaggi, completo, approvazione_richiesta)."""
start = time.monotonic()
seen: dict[int, object] = {}
last_change = None
while True:
await asyncio.sleep(2)
msgs = await client.get_messages(bot, min_id=since, limit=40)
incoming = sorted((m for m in msgs if not m.out), key=lambda m: m.id)
if [m.id for m in incoming] != list(seen):
seen = {m.id: m for m in incoming}
last_change = time.monotonic()
if any(_pending(m) for m in incoming):
return incoming, False, True
now = time.monotonic()
if incoming and last_change is not None and now - last_change >= QUIET_SECONDS:
return incoming, True, False
if now - start >= wait:
return incoming, False, False
def _report(msgs: list, done: bool, approval: bool) -> str:
if msgs:
_state["since"] = max(m.id for m in msgs)
parts = [_text(m) for m in msgs if _text(m)]
if approval:
req = next(m for m in reversed(msgs) if _pending(m))
return ("APPROVAZIONE RICHIESTA: il bot vuole eseguire un comando che modifica il server e attende una decisione.\n"
+ _text(req) + "\nChiedi all'utente se approvare, poi usa server_approva con decisione=approva oppure nega.")
if not parts:
return "Il bot sta ancora lavorando (nessuna risposta finora). Richiama server_attendi tra poco per avere il risultato."
body = "\n\n".join(parts)
if not done:
return body + "\n\n(il bot potrebbe non aver finito: se manca una conclusione, richiama server_attendi)"
return body
def chiedi(params: dict, ctx: dict) -> str:
testo = str(params.get("richiesta", "")).strip()
if not testo:
return "Errore: serve la richiesta da inoltrare al bot dei server."
wait = max(20, min(int(params.get("attesa") or 150), 300))
_, _, botname = _cfg()
async def go(client):
bot = await client.get_entity(botname)
sent = await client.send_message(bot, testo)
r = await _collect(client, bot, sent.id, wait)
try:
await client.send_read_acknowledge(bot)
except Exception:
pass
return r
if ctx.get("log"):
ctx["log"](f"[server] → @{botname}: {testo[:80]}")
msgs, done, approval = tg.run(go)
return _report(msgs, done, approval)
def attendi(params: dict, ctx: dict) -> str:
wait = max(10, min(int(params.get("attesa") or 120), 300))
_, _, botname = _cfg()
async def go(client):
bot = await client.get_entity(botname)
since = _state["since"]
if not since:
last = await client.get_messages(bot, limit=1)
since = last[0].id if last else 0
# niente di nuovo da consegnare: aspetta i messaggi successivi all'ultimo visto
r = await _collect(client, bot, since, wait)
try:
await client.send_read_acknowledge(bot)
except Exception:
pass
return r
msgs, done, approval = tg.run(go)
return _report(msgs, done, approval)
def approva(params: dict, ctx: dict) -> str:
dec = str(params.get("decisione", "")).strip().lower()
if dec not in ("approva", "nega"):
return "Errore: decisione deve essere 'approva' o 'nega'."
_, _, botname = _cfg()
async def find(client):
bot = await client.get_entity(botname)
msgs = await client.get_messages(bot, limit=30)
for m in msgs:
if not m.out and _pending(m):
return m.id, _text(m)
return None, None
mid, txt = tg.run(find)
if mid is None:
return "Nessuna richiesta di approvazione in sospeso dal bot dei server."
def do() -> str:
async def click(client):
bot = await client.get_entity(botname)
m = (await client.get_messages(bot, ids=[mid]))[0]
if m is None or not _pending(m):
return "La richiesta non è più in sospeso (già decisa o scaduta).", False
for data, b in _buttons(m):
if (dec == "approva" and data.startswith(("ok:", "approve:"))) or (dec == "nega" and data.startswith(("no:", "reject:"))):
await b.click()
return "", True
return "Pulsante non trovato nel messaggio.", False
msg, ok = tg.run(click)
if not ok:
return msg
if dec == "nega":
return "Comando negato al bot.\n" + _report(*tg.run(lambda c: _collect_after(c, botname, mid, 60)))
wait = max(20, min(int(params.get("attesa") or 150), 300))
return "Comando approvato, il bot lo esegue.\n" + _report(*tg.run(lambda c: _collect_after(c, botname, mid, wait)))
if dec == "nega":
return do()
return confirm_or_param(ctx, params, "server_approva", "Approvare il comando sul server?", txt[:400], do)
async def _collect_after(client, botname: str, since: int, wait: float):
bot = await client.get_entity(botname)
r = await _collect(client, bot, since, wait)
try:
await client.send_read_acknowledge(bot)
except Exception:
pass
return r
TOOLS = [
{"name": "server_flotta", "description": "Stato di tutti i server monitorati (Sentinella): stato, CPU, RAM, disco e numero di allarmi. Usalo per 'come stanno i server?'.",
"parameters": {"type": "object", "properties": {}}, "run": flotta},
{"name": "server_dettaglio", "description": "Dettaglio di un server monitorato per nome: metriche, sistema, ultimo contatto e allarmi attivi.",
"parameters": {"type": "object", "properties": {"nome": {"type": "string"}}, "required": ["nome"]}, "run": dettaglio},
{"name": "server_allarmi", "description": "Allarmi attivi sui server (soglie superate, servizi giù, errori, certificati).",
"parameters": {"type": "object", "properties": {}}, "run": allarmi},
{"name": "server_backup", "description": "Backup dei server: azione=elenco (job configurati), storico (ultime esecuzioni) o esegui (lancia un job per id o nome, con conferma).",
"parameters": {"type": "object", "properties": {"azione": {"type": "string", "enum": ["elenco", "storico", "esegui"]}, "job": {"type": "string"}, "confermato": CONFERMATO}}, "run": backup},
{"name": "server_azioni", "description": "Interventi di riparazione proposti dall'AI di monitoraggio in attesa di decisione: azione=elenco, approva o rifiuta (con id). Approvare richiede conferma sullo schermo.",
"parameters": {"type": "object", "properties": {"azione": {"type": "string", "enum": ["elenco", "approva", "rifiuta"]}, "id": {"type": "string"}, "confermato": CONFERMATO}}, "run": azioni},
{"name": "server_chiedi", "description": "Inoltra una richiesta in linguaggio naturale all'assistente sysadmin dei server (bot Telegram con Claude Code sul server ponte): diagnosi, log, spazio disco, riavvii, modifiche. Il bot indaga via SSH e risponde; se vuole eseguire un comando che modifica qualcosa, il risultato è APPROVAZIONE RICHIESTA: chiedi all'utente e usa server_approva. Accetta anche i comandi /progetti, /progetto <nome> (lavorare sul codice di un progetto sul server), /sys (torna ai server), /reset. Può richiedere fino a qualche minuto.",
"parameters": {"type": "object", "properties": {"richiesta": {"type": "string"}, "attesa": {"type": "integer", "description": "secondi massimi di attesa (default 150)"}}, "required": ["richiesta"]}, "run": chiedi},
{"name": "server_attendi", "description": "Continua ad aspettare la risposta del bot dei server dopo server_chiedi o server_approva, quando il lavoro non era ancora concluso.",
"parameters": {"type": "object", "properties": {"attesa": {"type": "integer"}}}, "run": attendi},
{"name": "server_approva", "description": "Risponde alla richiesta di approvazione in sospeso del bot dei server: decisione=approva (con conferma sullo schermo; con [CONFIRMATION_PENDING] di' all'utente di confermare) oppure nega. Poi attende l'esito.",
"parameters": {"type": "object", "properties": {"decisione": {"type": "string", "enum": ["approva", "nega"]}, "attesa": {"type": "integer"}, "confermato": CONFERMATO}, "required": ["decisione"]}, "run": approva},
]