#!/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 "" `. 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}")