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:
parent
9cfaaab7b0
commit
1816a26734
4 changed files with 209 additions and 38 deletions
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue