Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/alerts.py
Claude Agent d466f358ce feat(discord-bridge): punte Discord -> Claude Code pe LXC 171
Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri,
3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu
proces persistent per fir alimentat pe stdin cu --input-format stream-json.

Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream
(parser tolerant), session_store (scriere atomica, lock per fir, detectare PID
reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost
pe zi), render (un loop de editare per canal, interval adaptiv).

Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor,
comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul
fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou.

Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru
operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny
rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa).

Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd
user cu KillMode=control-group si limite de memorie, install.sh idempotent.

Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara
discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s
intr-un tool call de 25s schimba raspunsul final).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-30 10:44:39 +00:00

301 lines
10 KiB
Python

#!/usr/bin/env python3
"""alerts.py — alerte pe email pentru puntea Discord -> Claude Code (T12).
Urmeaza tiparul de alertare deja folosit in repo (vezi
proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh):
mail -s "[LEVEL] subiect" "$ALERT_RECIPIENT"
Contract (INTERFACES.md, granita A <-> C):
alert(level, subject, body, dedup_key=None) -> None
Reguli dure:
* functia NU arunca NICIODATA exceptii — o alerta esuata nu are voie sa doboare botul;
orice eroare e prinsa, logata local si ignorata;
* dedup pe `dedup_key` cu fereastra de 1h, persistat pe disc, ca sa nu se trimita
acelasi email de o suta de ori intr-un crash loop;
* daca binarul `mail` lipseste, se degradeaza la scriere in log (nu e eroare fatala).
Conditiile pe care le semnaleaza Lane A: proces mort neasteptat, crash loop,
plafon de cost atins, state.json corupt, orfani detectati la sweep.
"""
from __future__ import annotations
import json
import os
import pathlib
import shutil
import subprocess
import sys
import time
# --- configurare -----------------------------------------------------------
# config.py apartine Lane A si poate lipsi cand rulam izolat; importul e tolerant
# si NU cream o varianta proprie de config (vezi INTERFACES.md).
try: # pragma: no cover - depinde de ordinea de merge intre lane-uri
import config as _config # type: ignore
except Exception: # pragma: no cover
_config = None
def _default_state_dir() -> pathlib.Path:
if _config is not None and getattr(_config, "STATE_DIR", None):
return pathlib.Path(_config.STATE_DIR)
return pathlib.Path(os.path.expanduser("~/.claude-discord"))
def _default_log_dir() -> pathlib.Path:
if _config is not None and getattr(_config, "LOG_DIR", None):
return pathlib.Path(_config.LOG_DIR)
return _default_state_dir() / "logs"
# Variabile de modul, ca testele sa le poata rescrie fara sa atinga HOME-ul real.
STATE_DIR = _default_state_dir()
LOG_DIR = _default_log_dir()
DEDUP_FILE = STATE_DIR / "alerts-dedup.json"
LOG_FILE = LOG_DIR / "alerts.log"
DEDUP_WINDOW_S = 3600.0 # 1h, cerut de contract
MAIL_TIMEOUT_S = 20.0 # daca MTA-ul atarna, nu blocam botul
MAX_BODY_CHARS = 60000 # un corp urias nu are ce cauta intr-un email de alerta
LEVELS = ("INFO", "WARN", "CRITICAL")
def _recipient() -> str:
"""Destinatarul, cu acelasi default ca scripturile bash din repo: root."""
val = None
if _config is not None and hasattr(_config, "get"):
try:
val = _config.get("ALERT_RECIPIENT")
except Exception:
val = None
if not val:
val = os.environ.get("ALERT_RECIPIENT")
return val or "root"
def _mail_binary() -> str | None:
"""Calea catre `mail`, sau None daca lipseste (atunci degradam la log)."""
return shutil.which("mail") or shutil.which("mailx")
# --- log local -------------------------------------------------------------
def _log(line: str) -> None:
"""Scrie o linie in ~/.claude-discord/logs/alerts.log. Nu arunca niciodata."""
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
text = f"[{stamp}] {line}\n"
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as fh:
fh.write(text)
except Exception:
# Ultima plasa de siguranta: stderr, care ajunge in journald prin unit.
try:
sys.stderr.write(text)
except Exception:
pass
# --- dedup pe disc ---------------------------------------------------------
def _load_dedup() -> dict:
try:
with open(DEDUP_FILE, "r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return data
except FileNotFoundError:
pass
except Exception as exc:
_log(f"dedup: fisier ilizibil, se reia de la zero ({exc})")
return {}
def _save_dedup(data: dict) -> None:
"""Scriere atomica; un fisier de dedup corupt ar strica alertele urmatoare."""
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = DEDUP_FILE.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, DEDUP_FILE)
def _dedup_should_skip(key: str, now: float) -> bool:
"""True daca aceeasi cheie a fost deja trimisa in ultima ora.
Marcheaza cheia la fiecare incercare (si cand emailul esueaza), tocmai ca un
esec repetat sa nu devina el insusi sursa de spam.
"""
data = _load_dedup()
# curata intrarile expirate ca fisierul sa nu creasca la nesfarsit
fresh = {}
for k, ts in data.items():
try:
ts = float(ts)
except Exception:
continue
if now - ts < DEDUP_WINDOW_S:
fresh[k] = ts
last = fresh.get(key)
if last is not None:
return True
fresh[key] = now
_save_dedup(fresh)
return False
# --- trimiterea propriu-zisa ----------------------------------------------
def _send_mail(subject_line: str, body: str) -> tuple[bool, str]:
"""Ruleaza `mail -s "<subject>" <recipient>`. Intoarce (ok, detaliu)."""
binary = _mail_binary()
if not binary:
return False, "binarul `mail` lipseste (instaleaza bsd-mailx)"
recipient = _recipient()
try:
proc = subprocess.run(
[binary, "-s", subject_line, recipient],
input=body.encode("utf-8", "replace"),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=MAIL_TIMEOUT_S,
)
except subprocess.TimeoutExpired:
return False, f"`mail` a depasit {MAIL_TIMEOUT_S:.0f}s si a fost abandonat"
except Exception as exc:
return False, f"`mail` nu a putut fi lansat: {exc}"
if proc.returncode != 0:
out = (proc.stdout or b"").decode("utf-8", "replace").strip()
return False, f"`mail` a iesit cu cod {proc.returncode}: {out[:400]}"
return True, f"trimis catre {recipient}"
def _format_body(level: str, subject: str, body: str) -> str:
"""Corpul emailului, cu context de host si sursa — ca in scripturile bash."""
host = ""
try:
host = os.uname().nodename
except Exception:
pass
body = (body or "").strip()
if len(body) > MAX_BODY_CHARS:
body = body[:MAX_BODY_CHARS] + "\n\n[... corp trunchiat ...]"
return (
f"{body}\n"
"\n"
"-----------------------------------------------------------\n"
f" Nivel: {level}\n"
f" Subiect: {subject}\n"
f" Host: {host}\n"
f" Sursa: punte Discord -> Claude Code (claude-discord.service)\n"
f" Moment: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f" Loguri: {LOG_DIR}\n"
)
def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None:
"""Trimite o alerta pe email. NU arunca niciodata exceptii.
level: "INFO" | "WARN" | "CRITICAL" (orice altceva e normalizat la "WARN")
dedup_key: aceeasi cheie nu se retrimite in fereastra de 1h
"""
try:
lvl = str(level or "").strip().upper()
if lvl not in LEVELS:
lvl = "WARN"
subj = " ".join(str(subject or "(fara subiect)").split())[:200]
if dedup_key:
try:
if _dedup_should_skip(str(dedup_key), time.time()):
_log(f"{lvl} SKIP(dedup={dedup_key}) {subj}")
return
except Exception as exc:
# Dedup-ul e o optimizare, nu o bariera: daca pica, tot trimitem.
_log(f"dedup indisponibil ({exc}), se trimite oricum")
subject_line = f"[{lvl}] {subj}"
ok, detail = _send_mail(subject_line, _format_body(lvl, subj, str(body or "")))
if ok:
_log(f"{lvl} SENT {subj} :: {detail}")
else:
# Degradare: alerta ramane macar in log, cu tot cu corp.
_log(f"{lvl} NESENT {subj} :: {detail}")
_log(f" corp: {' | '.join(str(body or '').splitlines())[:2000]}")
except Exception as exc: # plasa finala — contractul spune "niciodata exceptii"
try:
_log(f"alert() a esuat complet: {exc!r}")
except Exception:
pass
# --- ajutoare pentru conditiile din Lane A --------------------------------
# Nu sunt in contract, dar tin textele alertelor intr-un singur loc.
def alert_process_died(thread_id: str, pid: int, detail: str = "") -> None:
alert(
"WARN",
f"Proces claude mort neasteptat (fir {thread_id})",
f"Procesul claude pid={pid} al firului {thread_id} a murit fara `result`.\n{detail}",
dedup_key=f"proc-died:{thread_id}",
)
def alert_crash_loop(thread_id: str, count: int, window_s: float) -> None:
alert(
"CRITICAL",
f"Crash loop pe firul {thread_id}",
f"{count} porniri esuate in {window_s:.0f}s. Firul a fost oprit.\n"
"Verifica `journalctl --user -u claude-discord -n 200`.",
dedup_key=f"crash-loop:{thread_id}",
)
def alert_cost_cap(usd: float, cap: float) -> None:
alert(
"CRITICAL",
f"Plafon de cost atins: ${usd:.4f} / ${cap:.2f}",
f"Botul s-a oprit din a accepta tururi noi pentru azi.\n"
f"Cost cumulat: ${usd:.4f}. Plafon: ${cap:.2f} (COST_CAP_USD_DAY).",
dedup_key="cost-cap",
)
def alert_state_corrupt(path: str, detail: str = "") -> None:
alert(
"CRITICAL",
"state.json corupt",
f"Fisierul de stare {path} nu a putut fi citit si a fost recuperat.\n{detail}",
dedup_key="state-corrupt",
)
def alert_orphans(orphans: list) -> None:
lines = [
f" pid={o.get('pid')} varsta={o.get('age_s')}s rss={o.get('rss_mb')}MB "
f":: {str(o.get('cmdline'))[:120]}"
for o in (orphans or [])
]
alert(
"WARN",
f"{len(orphans or [])} procese orfane detectate la sweep",
"Procese ramase din tururi anterioare (KillMode nu le prinde pe toate).\n"
"Ruleaza `!cleanup` in Discord ca sa le vezi si sa le opresti.\n\n"
+ "\n".join(lines),
dedup_key="orphans",
)
if __name__ == "__main__": # test manual: python3 alerts.py INFO "subiect" "corp"
lvl = sys.argv[1] if len(sys.argv) > 1 else "INFO"
sub = sys.argv[2] if len(sys.argv) > 2 else "test punte Discord"
bod = sys.argv[3] if len(sys.argv) > 3 else "Alerta de test, se poate ignora."
alert(lvl, sub, bod)
print(f"gata; vezi {LOG_FILE}")