integrity_scanner_fuer_stat.../scanner/alerter.py

174 lines
6.2 KiB
Python
Raw Permalink Normal View History

"""
Alert dispatch: e-mail (smtplib) and HTTP webhook.
Alerts are only sent when the assessment level meets or exceeds min_level.
Duplicate alerts (identical reasons since last send) are suppressed.
"""
import hashlib
import json
import logging
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path
import requests
from .plain import ampel
from .report import render_markdown
logger = logging.getLogger(__name__)
_LEVEL_ORDER = {"green": 0, "yellow": 1, "red": 2}
_ALERT_STATE_FILE = "last_alert.json"
def send_alert(report: dict, cfg: dict) -> None:
"""Send alert if the report's level is at or above min_level.
Duplicate alerts (identical assessment reasons since last send) are suppressed.
The dedup state is persisted in data_dir/last_alert.json per site.
"""
alerting = cfg.get("alerting", {})
level = report.get("assessment", {}).get("level", "green")
min_level = alerting.get("min_level", "yellow")
if _LEVEL_ORDER.get(level, 0) < _LEVEL_ORDER.get(min_level, 1):
logger.debug("Alert suppressed: level=%s < min_level=%s", level, min_level)
return
# --- Deduplizierung: nur senden wenn sich die Alarm-Gründe geändert haben ---
alert_key = _compute_alert_key(report)
data_dir = cfg.get("data_dir")
if data_dir:
last = _load_last_alert(Path(data_dir))
if last and last.get("key") == alert_key:
logger.info("Alert suppressed (duplicate, reasons unchanged since %s)",
last.get("sent_at", "unknown"))
return
subject = _make_subject(report)
body = _format_body(report)
email_cfg = alerting.get("email", {})
if email_cfg.get("enabled"):
_send_email(subject, body, email_cfg)
webhook_cfg = alerting.get("webhook", {})
if webhook_cfg.get("enabled") and webhook_cfg.get("url"):
_send_webhook(webhook_cfg["url"], report, subject)
# Zustand speichern — für nächste Dedup-Runde
if data_dir:
_save_last_alert(Path(data_dir), alert_key)
# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------
def _make_subject(report: dict) -> str:
level = report.get("assessment", {}).get("level", "green")
target = report.get("target", "?")
symbol, _ = ampel(level)
if level == "red":
return f"{symbol} Ihre Website {target} braucht Aufmerksamkeit"
if level == "yellow":
return f"{symbol} Bitte einmal auf Ihrer Website {target} nachschauen"
return f"{symbol} Ihre Website {target}: alles in Ordnung"
def _format_body(report: dict) -> str:
"""E-Mail-Body = derselbe Renderer wie der Dateibericht (eine Quelle der
Wahrheit). So enthalten Report und Mail garantiert denselben Inhalt."""
return render_markdown(report)
# ---------------------------------------------------------------------------
# Transport
# ---------------------------------------------------------------------------
def _send_email(subject: str, body: str, cfg: dict) -> None:
password = os.environ.get(cfg.get("smtp_password_env", "SCANNER_SMTP_PASSWORD"), "")
recipients = cfg.get("to", [])
if not recipients:
logger.warning("E-Mail alert enabled but no recipients configured.")
return
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = cfg.get("from", "scanner@localhost")
msg["To"] = ", ".join(recipients)
msg.set_content(body)
host = cfg.get("smtp_host", "localhost")
port = cfg.get("smtp_port", 587)
use_tls = cfg.get("smtp_tls", True)
try:
with smtplib.SMTP(host, port) as server:
if use_tls:
server.starttls()
user = cfg.get("smtp_user", "")
if user and password:
server.login(user, password)
server.send_message(msg)
logger.info("Alert e-mail sent to %s", recipients)
except Exception as exc:
logger.error("E-mail alert failed: %s", exc)
def _send_webhook(url: str, report: dict, subject: str) -> None:
payload = {
"text": subject,
"target": report.get("target"),
"level": report.get("assessment", {}).get("level"),
"score": report.get("assessment", {}).get("score"),
"reasons": report.get("assessment", {}).get("reasons", []),
"new_external_domains": report.get("diff", {}).get("new_external_domains", []),
"new_internal_urls": report.get("diff", {}).get("new_internal_urls", []),
"missing_internal_urls": report.get("diff", {}).get("missing_internal_urls", []),
"changed_pages": report.get("diff", {}).get("changed_pages", 0),
}
try:
resp = requests.post(url, json=payload, timeout=10)
logger.info("Webhook delivered: HTTP %s", resp.status_code)
except Exception as exc:
logger.error("Webhook alert failed: %s", exc)
# ---------------------------------------------------------------------------
# Deduplication state (last_alert.json in data_dir)
# ---------------------------------------------------------------------------
def _compute_alert_key(report: dict) -> str:
"""Stable hash of the assessment reasons + level for deduplication."""
payload = {
"level": report.get("assessment", {}).get("level", "green"),
"score": report.get("assessment", {}).get("score", 0),
"reasons": sorted(report.get("assessment", {}).get("reasons", [])),
}
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
def _load_last_alert(data_dir: Path) -> dict:
"""Load previous alert state. Returns {} on missing/corrupt file."""
path = data_dir / _ALERT_STATE_FILE
try:
return json.loads(path.read_text())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _save_last_alert(data_dir: Path, alert_key: str) -> None:
"""Persist current alert state for next dedup round."""
from datetime import datetime, timezone
path = data_dir / _ALERT_STATE_FILE
state = {
"key": alert_key,
"sent_at": datetime.now(timezone.utc).isoformat(),
}
try:
path.write_text(json.dumps(state))
except OSError as exc:
logger.debug("Alert state write failed: %s", exc)