From 1816a2673446f7d6b3833d900cd55372f49ac111 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 10:17:49 +0200 Subject: [PATCH] feat: Notruf-Eskalation per SMS/Anruf (provider-agnostischer Webhook) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zweiter Benachrichtigungskanal neben der E-Mail: bei einem Notruf geht zusätzlich ein JSON-POST an EMERGENCY_WEBHOOK_URL mit Kanälen (sms,call), Kontakt-Telefonnummern und fertigen SMS-/Anruf-Texten. Den konkreten Gateway (Twilio, seven.io, sipgate, eigener Dienst) verdrahtet der Empfänger dahinter — kein Provider-Lock-in im Code. - config: EMERGENCY_WEBHOOK_URL aktiviert + _TOKEN (Bearer), _CONTACT_PHONE (CSV/E.164), _WEBHOOK_CHANNELS (default sms,call) - emergency.py: async httpx-POST (best effort), Telefon-Auflösung analog zur E-Mail (Pref emergency_phones > Default), notified = email OR webhook; Hinweise kanal-neutral formuliert; Response um webhook_sent + channels ergänzt - Tests: Webhook-Versand inkl. Payload/Bearer-Header + "ohne URL kein POST" - Doku: §10 + Env-Var-Tabelle aktualisiert (zwei Kanäle, Payload-Format) Co-Authored-By: Claude Opus 4.8 --- BEDIENUNGSANLEITUNG.md | 50 ++++++++++---- app/config.py | 13 +++- app/safety/emergency.py | 129 ++++++++++++++++++++++++++++++------- tests/test_quota_safety.py | 55 ++++++++++++++++ 4 files changed, 209 insertions(+), 38 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 9897198..a703ed3 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -2011,31 +2011,56 @@ unzuverlässig (Fehlalarme **wie** verpasste Notfälle) und intransparent. Ein N - Der Notruf wird protokolliert (Admin → System → **Notrufe**); der Nutzer erhält einen klaren Hinweis in seiner Sprache. -**Benachrichtigung (Testphase):** Ist SMTP konfiguriert, geht beim Notruf eine **E-Mail** -an die Kontaktperson(en) (`EMERGENCY_CONTACT_EMAIL`). Ohne SMTP wird nichts versendet und -der Nutzer sieht „ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.". SMS/Anruf folgen -später. +**Benachrichtigung — zwei unabhängige Kanäle (best effort):** + +1. **E-Mail (SMTP):** Ist `SMTP_HOST` gesetzt, geht eine E-Mail an die Kontaktperson(en) + (`EMERGENCY_CONTACT_EMAIL`; pro Nutzer übersteuerbar via Pref `emergency_contacts`). +2. **SMS/Anruf (provider-agnostischer Webhook):** Ist `EMERGENCY_WEBHOOK_URL` gesetzt, geht + zusätzlich ein JSON-POST dorthin — mit den angefragten Kanälen, den Telefonnummern + (`EMERGENCY_CONTACT_PHONE`; pro Nutzer via Pref `emergency_phones`) und **fertigen + SMS-/Anruf-Texten**. Den konkreten Gateway (Twilio, seven.io, sipgate, eigener Dienst …) + verdrahtet man hinter dem Webhook; `EMERGENCY_WEBHOOK_TOKEN` schützt ihn optional per + `Authorization: Bearer `. + +Geht über **mindestens einen** Kanal etwas raus, sieht der Nutzer „Hilfe wurde verständigt …", +sonst „ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.". ```bash -EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de # Default-Kontaktperson -SMTP_HOST= # ohne Host kein Versand — KEINE Inline-Kommentare hinter Werten! +EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de # Default-Kontaktperson (E-Mail) +SMTP_HOST= # ohne Host kein E-Mail-Versand — KEINE Inline-Kommentare hinter Werten! SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SMTP_FROM= # muss dem SMTP_USER gehören (sonst 553 Sender rejected) SMTP_STARTTLS=true -EMERGENCY_WEBHOOK_URL= # optional, für spätere Eskalation +EMERGENCY_WEBHOOK_URL= # SMS/Anruf-Eskalation: JSON-POST hierhin (leer = aus) +EMERGENCY_WEBHOOK_TOKEN= # optional -> Authorization: Bearer +EMERGENCY_CONTACT_PHONE= # Default-Telefonnummer(n), CSV, E.164 (+49…) +EMERGENCY_WEBHOOK_CHANNELS=sms,call # angefragte Kanäle im Payload +``` + +Das POST-Payload, das der Gateway-Empfänger bekommt: +```json +{ + "event": "emergency", "category": "manual", "source": "voice-assistant", + "timestamp": "2026-06-25T…Z", "language": "de", + "user": {"id": "…", "name": "…"}, + "channels": ["sms", "call"], + "phones": ["+4915112345678"], + "message": {"sms": "NOTRUF: …", "call": "Achtung. …"} +} ``` Auslösen per API (z. B. zum Testen): ```bash curl -s -X POST $URL/api/emergency -H 'Content-Type: application/json' \ -d '{"language":"de"}' | jq -# → {"category":"manual","notice":"…","email_sent":true|false} +# → {"category":"manual","notice":"…","email_sent":…,"webhook_sent":…,"channels":[…]} ``` -> ⚠️ **Wichtig:** Der Notruf ist **kein Ersatz** für einen echten Rettungsdienst. In der -> Testphase ist die Zustellung nur E-Mail (best effort, abhängig vom Mailserver). +> ⚠️ **Wichtig:** Der Notruf ist **kein Ersatz** für einen echten Rettungsdienst. Die +> Zustellung ist best effort und hängt vom Mailserver bzw. dem hinter dem Webhook +> verdrahteten SMS/Anruf-Gateway ab. --- @@ -2295,7 +2320,10 @@ Logs: Terminal von `make run`. Mehr Details: `LOG_LEVEL=debug` in `.env`. | `SMTP_USER` / `SMTP_PASSWORD` | (leer) | SMTP-Zugangsdaten | | `SMTP_FROM` | (leer → `SMTP_USER`) | Absender (muss dem SMTP_USER gehören) | | `SMTP_STARTTLS` | `true` | STARTTLS verwenden | -| `EMERGENCY_WEBHOOK_URL` | (leer) | optionaler Webhook (spätere Eskalation) | +| `EMERGENCY_WEBHOOK_URL` | (leer) | SMS/Anruf-Eskalation: JSON-POST hierhin (leer = aus) | +| `EMERGENCY_WEBHOOK_TOKEN` | (leer) | optional → `Authorization: Bearer ` für den Webhook | +| `EMERGENCY_CONTACT_PHONE` | (leer) | Default-Telefonnummer(n) für SMS/Anruf, CSV, E.164 | +| `EMERGENCY_WEBHOOK_CHANNELS` | `sms,call` | im Payload angefragte Kanäle (CSV) | --- diff --git a/app/config.py b/app/config.py index 7d1291b..e3ff763 100644 --- a/app/config.py +++ b/app/config.py @@ -188,9 +188,7 @@ class Settings(BaseSettings): llm_fallback: str = "" tts_fallback: str = "" daily_request_limit: int = 0 # 0 = unbegrenzt; Anfragen pro Nutzer pro Tag - emergency_webhook_url: str = "" # optionaler Eskalations-Webhook (später) - - # Notruf-Benachrichtigung per E-Mail (Testphase, bis SMS/Anruf gebaut sind). + # Notruf-Benachrichtigung per E-Mail. emergency_contact_email: str = "dieter.schlueter@linix.de" # Default-Kontaktperson smtp_host: str = "" # ohne Host wird KEINE E-Mail versendet smtp_port: int = 587 @@ -198,6 +196,15 @@ class Settings(BaseSettings): smtp_password: str = "" smtp_from: str = "" # Absenderadresse; leer -> smtp_user smtp_starttls: bool = True + + # Notruf-Eskalation per SMS/Anruf: provider-agnostischer Webhook. Bei einem Notruf + # geht ein JSON-POST an emergency_webhook_url (Kanäle, Telefonnummern, fertige + # SMS-/Anruf-Texte). Den konkreten Gateway (Twilio, seven.io, …) verdrahtet der + # Empfänger dahinter. Ohne URL wird nichts gesendet. + emergency_webhook_url: str = "" + emergency_webhook_token: str = "" # optional -> Authorization: Bearer + emergency_contact_phone: str = "" # Default-Telefonnummer(n), CSV, E.164 (+49…) + emergency_webhook_channels: str = "sms,call" # angefragte Kanäle (CSV) model_config = SettingsConfigDict( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 6a89bec..7d1fc81 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -3,17 +3,24 @@ KEINE automatische Erkennung (zu unzuverlässig/intransparent). Ein Notruf entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung). -Testphase: Bei einem Notruf wird eine E-Mail an die Kontaktperson(en) des -Nutzers geschickt (Default: settings.emergency_contact_email). SMS/Anruf- -Bridges folgen später. Ohne konfigurierten SMTP-Host wird nichts versendet -und der Nutzer erhält einen klaren Hinweis darauf. +Benachrichtigung von Kontaktpersonen über zwei unabhängige Kanäle (best effort): +- E-Mail per SMTP (settings.smtp_*, Empfänger settings.emergency_contact_email). +- SMS/Anruf per provider-agnostischem Webhook (settings.emergency_webhook_url): + ein JSON-POST mit Kanälen, Telefonnummern und fertigen SMS-/Anruf-Texten; den + konkreten Gateway (Twilio, seven.io, …) verdrahtet der Empfänger dahinter. + +Ohne SMTP-Host und ohne Webhook-URL wird nichts versendet und der Nutzer erhält +einen klaren Hinweis darauf. """ import asyncio import logging import smtplib +from datetime import datetime, timezone from email.message import EmailMessage +import httpx + from app.config import settings from app.metrics import metrics @@ -33,34 +40,44 @@ _NO_NOTIFY_NOTICE: dict[str, str] = { "zh": "注意:尚未设置任何通知。", } -# Hinweis je Sprache, wenn die E-Mail rausging. +# Hinweis je Sprache, wenn mindestens ein Kanal die Meldung rausgeschickt hat. _SENT_NOTICE: dict[str, str] = { - "de": "Hilfe wurde verständigt. Eine Kontaktperson wurde per E-Mail benachrichtigt.", - "en": "Help has been alerted. A contact person was notified by email.", - "fr": "Les secours ont été alertés. Un proche a été prévenu par e-mail.", - "es": "Se ha avisado a ayuda. Se notificó a un contacto por correo electrónico.", - "it": "L'aiuto è stato allertato. Una persona di contatto è stata avvisata via e-mail.", - "pt": "A ajuda foi avisada. Uma pessoa de contato foi notificada por e-mail.", - "pl": "Pomoc została powiadomiona. Osoba kontaktowa otrzymała e-mail.", - "ar": "تم تنبيه المساعدة. تم إخطار شخص الاتصال عبر البريد الإلكتروني.", - "ru": "Помощь оповещена. Контактное лицо уведомлено по электронной почте.", - "zh": "已通知求助。已通过电子邮件通知联系人。", + "de": "Hilfe wurde verständigt. Eine Kontaktperson wurde benachrichtigt.", + "en": "Help has been alerted. A contact person was notified.", + "fr": "Les secours ont été alertés. Un proche a été prévenu.", + "es": "Se ha avisado a ayuda. Se notificó a un contacto.", + "it": "L'aiuto è stato allertato. Una persona di contatto è stata avvisata.", + "pt": "A ajuda foi avisada. Uma pessoa de contato foi notificada.", + "pl": "Pomoc została powiadomiona. Osoba kontaktowa została powiadomiona.", + "ar": "تم تنبيه المساعدة. تم إخطار شخص الاتصال.", + "ru": "Помощь оповещена. Контактное лицо уведомлено.", + "zh": "已通知求助。已通知联系人。", } def notify_notice(language: str | None, sent: bool) -> str: - """Hinweis in Nutzersprache (Fallback Deutsch), je nachdem ob eine Mail rausging.""" + """Hinweis in Nutzersprache (Fallback Deutsch), je nachdem ob etwas rausging.""" table = _SENT_NOTICE if sent else _NO_NOTIFY_NOTICE return table.get((language or "de").lower(), table["de"]) +def _csv(raw: str | None) -> list[str]: + """Trennt eine CSV-/Semikolon-Liste in bereinigte Einzelwerte.""" + return [v.strip() for v in (raw or "").replace(";", ",").split(",") if v.strip()] + + def _contacts(user, cfg) -> list[str]: """Kontakt-E-Mail(s): pro-Nutzer-Pref (CSV) falls vorhanden, sonst Default aus der Config.""" prefs = getattr(user, "prefs", None) or {} raw = (prefs.get("emergency_contacts") or "").strip() if isinstance(prefs, dict) else "" - if not raw: - raw = cfg.emergency_contact_email or "" - return [a.strip() for a in raw.replace(";", ",").split(",") if a.strip()] + return _csv(raw) or _csv(cfg.emergency_contact_email) + + +def _phones(user, cfg) -> list[str]: + """Kontakt-Telefonnummer(n): pro-Nutzer-Pref (CSV) falls vorhanden, sonst Default aus der Config.""" + prefs = getattr(user, "prefs", None) or {} + raw = (prefs.get("emergency_phones") or "").strip() if isinstance(prefs, dict) else "" + return _csv(raw) or _csv(cfg.emergency_contact_phone) def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool: @@ -85,20 +102,84 @@ def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool: return False +def _emergency_texts(name: str) -> dict[str, str]: + """Fertige Texte für die SMS- und Sprachanruf-Kanäle (vom Webhook-Empfänger genutzt).""" + return { + "sms": ( + f"NOTRUF: {name} hat in der Voice-Assistant-App Hilfe angefordert. " + f"Bitte sofort Kontakt aufnehmen." + ), + "call": ( + f"Achtung. {name} hat über die Voice Assistant App einen Notruf ausgelöst. " + f"Bitte nehmen Sie umgehend Kontakt auf. " + f"Ich wiederhole: {name} braucht dringend Hilfe." + ), + } + + +def _webhook_payload(user, language: str | None, phones: list[str], channels: list[str], name: str) -> dict: + """Provider-agnostisches Notruf-Payload für den Eskalations-Webhook.""" + return { + "event": "emergency", + "category": "manual", + "source": "voice-assistant", + "timestamp": datetime.now(timezone.utc).isoformat(), + "language": (language or "de"), + "user": {"id": getattr(user, "id", None), "name": name}, + "channels": channels, + "phones": phones, + "message": _emergency_texts(name), + } + + +async def _post_webhook(url: str, payload: dict, cfg) -> bool: + """Sendet das Notruf-Payload per HTTP-POST an den Eskalations-Webhook (best effort).""" + if not url: + return False + headers = {"Content-Type": "application/json"} + if cfg.emergency_webhook_token: + headers["Authorization"] = f"Bearer {cfg.emergency_webhook_token}" + try: + async with httpx.AsyncClient(timeout=10) as c: + resp = await c.post(url, json=payload, headers=headers) + return resp.is_success + except Exception: + logger.exception("Notruf-Webhook (SMS/Anruf) konnte nicht ausgelöst werden") + return False + + async def record_manual_emergency(user, store, language: str | None = None, cfg=settings) -> dict: """Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis.""" store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf") metrics.inc("emergency_total", {"category": "manual", "source": "user"}) - to = _contacts(user, cfg) name = getattr(user, "display_name", None) or getattr(user, "id", "?") + + # Kanal 1: E-Mail (SMTP) + to = _contacts(user, cfg) subject = f"🆘 Notruf von {name}" body = ( f"{name} hat in der Voice-Assistant-App einen Notruf ausgelöst.\n\n" f"Bitte umgehend Kontakt aufnehmen.\n\n" - f"(Testphase: automatische Eskalation per SMS/Anruf ist noch nicht aktiv.)" + f"(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)" ) - sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg) - if sent: + email_sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg) + if email_sent: metrics.inc("emergency_notify_total", {"channel": "email"}) - return {"category": "manual", "notice": notify_notice(language, sent), "email_sent": sent} + + # Kanal 2: SMS/Anruf (provider-agnostischer Webhook) + phones = _phones(user, cfg) + channels = _csv(cfg.emergency_webhook_channels) + payload = _webhook_payload(user, language, phones, channels, name) + webhook_sent = await _post_webhook(cfg.emergency_webhook_url, payload, cfg) + if webhook_sent: + metrics.inc("emergency_notify_total", {"channel": "webhook"}) + + notified = email_sent or webhook_sent + return { + "category": "manual", + "notice": notify_notice(language, notified), + "email_sent": email_sent, + "webhook_sent": webhook_sent, + "channels": channels, + } diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 856af03..88bd0c9 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -94,3 +94,58 @@ def test_manual_emergency_sends_email_when_smtp_configured(monkeypatch): assert "benachrichtigt" in data["notice"].lower() assert captured["to"] == "kontakt@example.com" assert "Notruf" in captured["subject"] + + +def test_manual_emergency_posts_webhook_when_configured(monkeypatch): + import app.safety.emergency as em + captured = {} + + class FakeResp: + is_success = True + status_code = 200 + + class FakeClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, json=None, headers=None): + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return FakeResp() + + monkeypatch.setattr(em.httpx, "AsyncClient", FakeClient) + monkeypatch.setattr(settings, "emergency_webhook_url", "https://hook.example/notify") + monkeypatch.setattr(settings, "emergency_webhook_token", "s3cret") + monkeypatch.setattr(settings, "emergency_contact_phone", "+4915112345678") + + data = client.post("/api/emergency", json={"language": "de"}).json() + assert data["webhook_sent"] is True + assert data["email_sent"] is False # kein SMTP -> Webhook trägt allein + assert "benachrichtigt" in data["notice"].lower() # ein Kanal reicht für "gesendet"-Hinweis + + assert captured["url"] == "https://hook.example/notify" + assert captured["headers"]["Authorization"] == "Bearer s3cret" + payload = captured["json"] + assert payload["category"] == "manual" + assert payload["phones"] == ["+4915112345678"] + assert set(payload["channels"]) >= {"sms", "call"} + assert "sms" in payload["message"] and "call" in payload["message"] + + +def test_manual_emergency_no_webhook_without_url(monkeypatch): + import app.safety.emergency as em + + def _boom(*a, **k): # AsyncClient darf ohne URL gar nicht erst gebaut werden + raise AssertionError("Webhook ohne URL nicht aufrufen") + + monkeypatch.setattr(em.httpx, "AsyncClient", _boom) + monkeypatch.setattr(settings, "emergency_webhook_url", "") + data = client.post("/api/emergency", json={"language": "de"}).json() + assert data["webhook_sent"] is False