"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch). Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende (fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint. Das Modul existiert bewusst identisch in API und Worker (docker/api/notify.py) — es gibt kein geteiltes Paket zwischen den Containern. Wer es ändert, ändert BEIDE Dateien. Payload-Formate (dokumentiert, nicht geraten — AGENTS Regel D): - Discord: POST JSON {"content": "..."} — discord.com/developers/docs/resources/webhook - Slack: POST JSON {"text": "..."} — api.slack.com/messaging/webhooks - ntfy: POST Roh-Text an https://ntfy.sh/, Titel via ?title= — docs.ntfy.sh/publish - Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger (Home Assistant, n8n, eigene Skripte). """ import requests TIMEOUT_SEKUNDEN = 10 def erkenne_webhook_typ(url: str) -> str: """Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren.""" u = url.lower() if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u: return "discord" if "hooks.slack.com" in u: return "slack" if "ntfy" in u: return "ntfy" return "generisch" def baue_payload(url: str, titel: str, text: str, level: str = "info"): """Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text.""" typ = erkenne_webhook_typ(url) if typ == "discord": return typ, {"content": f"**{titel}**\n{text}"} if typ == "slack": return typ, {"text": f"*{titel}*\n{text}"} if typ == "ntfy": return typ, None return typ, {"title": titel, "message": text, "level": level} def sende(url: str, titel: str, text: str, level: str = "info") -> None: """Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern.""" typ, payload = baue_payload(url, titel, text, level) try: if typ == "ntfy": antwort = requests.post( url, data=text.encode("utf-8"), params={"title": titel}, timeout=TIMEOUT_SEKUNDEN, ) else: antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN) except requests.RequestException as e: raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e if antwort.status_code >= 300: raise RuntimeError( f"Webhook antwortete mit HTTP {antwort.status_code}: " f"{(antwort.text or '')[:200]}" )