feat: Notruf — Telefon in SMS, Geo-Diagnose + Geräteerkennung; Sprach-UI-Fix

Notruf (Punkte 1+2):
- SMS enthält jetzt die Telefonnummer(n) der Person (Mobil/Festnetz).
- Geo-Diagnose (P1): captureGeo liefert bei Fehlschlag einen Grund
  (Permission/insecure/Timeout/Code) statt still null; geht als geo_error an
  /api/emergency, landet in E-Mail ("Standort nicht verfügbar (<Grund>)") und
  im Server-Log (logger.info) — Fehlversuche sind jetzt erklärbar.
- Geräteerkennung (P2): device {mobile, platform} via User-Agent Client Hints
  (Fallback UA-String); E-Mail zeigt "Auslösegerät: Mobilgerät/Festgerät (…)".
- schemas: DeviceInfo + EmergencyRequest.geo_error/device; chat reicht durch.
- Tests: Telefon in SMS, geo_error + Geräte-Zeile in E-Mail (240 grün).

Sprach-Bug (Punkt 3): allowed_languages wurde im Menü nur via <option hidden>
gefiltert, was manche Browser bei <select> ignorieren -> Sprachen blieben
wählbar. Jetzt zusätzlich opt.disabled (robust, nicht wählbar); Backend klemmt
ohnehin serverseitig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 17:24:36 +02:00
commit 5d05e081d4
5 changed files with 104 additions and 34 deletions

View file

@ -5,18 +5,14 @@ entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
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.
- SMS/Anruf per provider-agnostischem Webhook (settings.emergency_webhook_url).
Die Meldungen werden mit den Profildaten der auslösenden Person (Name, Adresse,
Telefon, med. Hinweise) und falls vom Gerät mitgeschickt der Geoposition
angereichert. Kanal-Rollen: die E-Mail trägt das Voll-Dossier, die SMS den
Karten-Link, der Anruf nennt Name + Wohnadresse gesprochen und verweist für den
genauen Standort auf SMS/E-Mail (Koordinaten werden NICHT vorgelesen).
Ohne SMTP-Host und ohne Webhook-URL wird nichts versendet und der Nutzer erhält
einen klaren Hinweis darauf.
genauen Standort auf SMS/E-Mail (Koordinaten werden NICHT vorgelesen). Schlägt die
Geo-Erfassung fehl, wird der Grund (geo_error) in E-Mail und Log ausgewiesen.
"""
import asyncio
@ -72,7 +68,7 @@ def _csv(raw: str | None) -> list[str]:
return [v.strip() for v in (raw or "").replace(";", ",").split(",") if v.strip()]
# --- Profil & Standort -----------------------------------------------------
# --- Profil, Standort, Gerät ----------------------------------------------
def _profile(user) -> dict:
"""Liest die Notfall-Profildaten der Person aus user.prefs (mit Fallbacks)."""
@ -101,10 +97,7 @@ def _profile(user) -> dict:
def _geo_fields(geo) -> dict | None:
"""Normalisiert die Geoposition (pydantic-Model ODER dict) auf {lat,lon,accuracy,ts}.
Liefert None, wenn keine brauchbaren Koordinaten vorliegen.
"""
"""Normalisiert die Geoposition (pydantic-Model ODER dict) auf {lat,lon,accuracy,ts}."""
if geo is None:
return None
if isinstance(geo, dict):
@ -120,11 +113,29 @@ def _maps_link(lat, lon) -> str:
return f"https://www.google.com/maps?q={lat},{lon}"
def _location_block(geo) -> str:
"""Mehrzeiliger Standort-Block für die E-Mail (oder klarer 'nicht verfügbar'-Hinweis)."""
def _person_phones(profile: dict) -> str:
"""Telefonnummer(n) der Person für die Meldung (Mobil / Festnetz)."""
return " / ".join(t for t in (profile["phone_mobile"], profile["phone_landline"]) if t)
def _device_line(device) -> str | None:
"""Beschriftet das auslösende Gerät (Mobilgerät/Festgerät + Plattform), falls bekannt."""
if not device:
return None
if isinstance(device, dict):
mobile, platform = device.get("mobile"), device.get("platform")
else:
mobile, platform = getattr(device, "mobile", None), getattr(device, "platform", None)
kind = "Mobilgerät" if mobile else ("Festgerät" if mobile is False else "Gerät")
return f"Auslösegerät: {kind}" + (f" ({platform})" if platform else "")
def _location_block(geo, geo_error: str | None = None) -> str:
"""Standort-Block für die E-Mail (Koordinaten + Karte, oder Grund der Nicht-Verfügbarkeit)."""
g = _geo_fields(geo)
if not g:
return "Standort (Gerät): nicht verfügbar (kein GPS / keine Freigabe)."
reason = geo_error or "kein GPS / keine Freigabe"
return f"Standort (Gerät): nicht verfügbar ({reason})."
lines = [
f"Standort (Gerät): {g['lat']:.6f}, {g['lon']:.6f}",
f"Karte: {_maps_link(g['lat'], g['lon'])}",
@ -140,12 +151,15 @@ def _emergency_texts(profile: dict, geo) -> dict[str, str]:
"""Fertige Texte für SMS und Sprachanruf (vom Webhook-Empfänger genutzt)."""
name = profile["name"]
addr = profile["address_short"]
tel = _person_phones(profile)
g = _geo_fields(geo)
# SMS: kompakt, mit Karten-Link (trägt den genauen Standort)
# SMS: kompakt — Name, Adresse, Telefon, Karten-Link (trägt den genauen Standort)
sms = f"NOTRUF: {name} braucht Hilfe."
if addr:
sms += f" Adresse: {addr}."
if tel:
sms += f" Tel: {tel}."
if g:
sms += f" Standort: {_maps_link(g['lat'], g['lon'])}"
@ -158,15 +172,18 @@ def _emergency_texts(profile: dict, geo) -> dict[str, str]:
return {"sms": sms, "call": call}
def _email_body(profile: dict, geo) -> str:
"""Voll-Dossier für die E-Mail: Angaben zur Person + Standort."""
def _email_body(profile: dict, geo, geo_error: str | None = None, device=None) -> str:
"""Voll-Dossier für die E-Mail: Gerät + Angaben zur Person + Standort."""
lines = [
f"{profile['name']} hat in der Voice-Assistant-App einen Notruf ausgelöst.",
"",
"Bitte umgehend Kontakt aufnehmen.",
"",
"— Angaben zur Person —",
]
dev = _device_line(device)
if dev:
lines += [dev, ""]
lines.append("— Angaben zur Person —")
fields = [
("Adresse", profile["address_short"]),
("Mobil", profile["phone_mobile"]),
@ -176,7 +193,7 @@ def _email_body(profile: dict, geo) -> str:
]
info = [f"{label}: {value}" for label, value in fields if value]
lines.extend(info or ["(keine Profilangaben hinterlegt)"])
lines += ["", "— Standort —", _location_block(geo), "",
lines += ["", "— Standort —", _location_block(geo, geo_error), "",
"(Diese Meldung wurde automatisch von der Voice-Assistant-App gesendet.)"]
return "\n".join(lines)
@ -219,7 +236,7 @@ def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool:
return False
def _webhook_payload(user, language, phones, channels, profile, geo) -> dict:
def _webhook_payload(user, language, phones, channels, profile, geo, geo_error=None, device=None) -> dict:
"""Provider-agnostisches Notruf-Payload für den Eskalations-Webhook."""
return {
"event": "emergency",
@ -231,6 +248,8 @@ def _webhook_payload(user, language, phones, channels, profile, geo) -> dict:
"channels": channels,
"phones": phones,
"geo": _geo_fields(geo),
"geo_error": geo_error,
"device": device if isinstance(device, dict) else None,
"message": _emergency_texts(profile, geo),
}
@ -251,18 +270,23 @@ async def _post_webhook(url: str, payload: dict, cfg) -> bool:
return False
async def record_manual_emergency(user, store, language: str | None = None, geo=None, cfg=settings) -> dict:
async def record_manual_emergency(user, store, language: str | None = None,
geo=None, geo_error: str | None = None, device=None,
cfg=settings) -> dict:
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis."""
# Bewusst minimaler Log-Eintrag — KEINE Adresse/Koordinaten (Admin-Log bleibt unkritisch).
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
profile = _profile(user)
# Diagnose-Zeile im Server-Log: erklärt fehlenden Standort (geo_error) + Geräteart.
logger.info("Notruf von %s: geo=%s geo_error=%r device=%r",
profile["name"], bool(_geo_fields(geo)), geo_error, device)
# Kanal 1: E-Mail (SMTP) — Voll-Dossier
to = _contacts(user, cfg)
subject = f"🆘 Notruf von {profile['name']}"
body = _email_body(profile, geo)
body = _email_body(profile, geo, geo_error, device)
email_sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg)
if email_sent:
metrics.inc("emergency_notify_total", {"channel": "email"})
@ -270,7 +294,7 @@ async def record_manual_emergency(user, store, language: str | None = None, geo=
# Kanal 2: SMS/Anruf (provider-agnostischer Webhook)
phones = _phones(user, cfg)
channels = _csv(cfg.emergency_webhook_channels)
payload = _webhook_payload(user, language, phones, channels, profile, geo)
payload = _webhook_payload(user, language, phones, channels, profile, geo, geo_error, device)
webhook_sent = await _post_webhook(cfg.emergency_webhook_url, payload, cfg)
if webhook_sent:
metrics.inc("emergency_notify_total", {"channel": "webhook"})