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

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