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
301 lines
10 KiB
Python
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}")
|