# SPDX-License-Identifier: GPL-3.0-or-later """Outbound notifications: desktop + phone push (ntfy / Pushover / webhook). A backend is *enabled* when its required config keys are present, so turning one on is purely a matter of configuration. Phone pushes are gated by ``notify_min_severity``. Everything sends on a worker thread and swallows its own errors — a flaky notifier must never stall or crash detection. """ from __future__ import annotations import threading from dataclasses import dataclass, field from datetime import datetime from ..alert import Alert, Severity from ..config import Config from . import backends _SEV = {"MEDIUM": Severity.MEDIUM, "HIGH": Severity.HIGH, "CRITICAL": Severity.CRITICAL} _PUSH_BACKENDS = (backends.Ntfy, backends.Pushover, backends.Webhook) @dataclass(frozen=True) class Notification: severity: Severity host: str signatures: tuple[str, ...] sids: tuple[int, ...] snapshot_name: str count: int time: str = field(default_factory=lambda: datetime.now().astimezone().isoformat()) dashboard_url: str = "" @classmethod def from_alerts(cls, alerts: list[Alert], host: str, snapshot_name: str, dashboard_url: str = "") -> "Notification": sev = max((a.severity for a in alerts), default=Severity.HIGH) sigs = tuple(dict.fromkeys(a.signature for a in alerts)) sids = tuple(dict.fromkeys(a.sid for a in alerts if a.sid)) return cls(severity=sev, host=host, signatures=sigs, sids=sids, snapshot_name=snapshot_name, count=len(alerts), dashboard_url=dashboard_url) def title(self) -> str: return f"Enodia: {self.severity} on {self.host}" def message(self) -> str: lines = [f"{self.count} detection(s): {', '.join(self.signatures)}"] if self.sids: lines.append("sids: " + ", ".join(str(s) for s in self.sids)) lines.append(f"snapshot: {self.snapshot_name}") if self.dashboard_url: lines.append(self.dashboard_url.rstrip("/") + "/") return "\n".join(lines) def to_dict(self) -> dict: return { "time": self.time, "host": self.host, "severity": str(self.severity), "signatures": list(self.signatures), "sids": list(self.sids), "snapshot": self.snapshot_name, "count": self.count, } def min_severity(cfg: Config) -> Severity: return _SEV.get(cfg.notify_min_severity.upper(), Severity.HIGH) def enabled_backends(cfg: Config) -> list: return [b for b in _PUSH_BACKENDS if b.enabled(cfg)] def dispatch(cfg: Config, notif: Notification) -> None: """Fire all enabled phone-push backends (on worker threads, best-effort).""" if notif.severity < min_severity(cfg): return for backend in enabled_backends(cfg): threading.Thread(target=_safe_send, args=(backend, cfg, notif), daemon=True).start() def _safe_send(backend, cfg: Config, notif: Notification) -> None: try: backend.send(cfg, notif) except Exception: pass