feat: Notruf-Eskalation per SMS/Anruf (provider-agnostischer Webhook)

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 10:17:49 +02:00
commit 1816a26734
4 changed files with 209 additions and 38 deletions

View file

@ -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 <token>`.
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 <token>
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 <token>` 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) |
---

View file

@ -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 <token>
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"
)

View file

@ -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,
}

View file

@ -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