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

@ -150,4 +150,7 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ
Die eigentliche Benachrichtigung von Angehörigen folgt später.
"""
store = get_store()
return await record_manual_emergency(user, store, payload.language, geo=payload.geo)
return await record_manual_emergency(
user, store, payload.language,
geo=payload.geo, geo_error=payload.geo_error, device=payload.device,
)

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"})

View file

@ -113,10 +113,18 @@ class GeoFix(BaseModel):
ts: str | None = None # ISO-Zeitstempel des Fixes
class DeviceInfo(BaseModel):
"""Geräteart/Plattform des auslösenden Geräts (aus Browser-Daten)."""
mobile: bool | None = None
platform: str | None = None
class EmergencyRequest(BaseModel):
"""Vom Nutzer ausgelöster Notruf (Knopf). language für den Hinweistext."""
language: str | None = None
geo: GeoFix | None = None
geo_error: str | None = None # Grund, falls die Geo-Erfassung scheiterte
device: DeviceInfo | None = None
class AdminUserPrefsUpdate(BaseModel):

View file

@ -337,7 +337,9 @@ function applyAllowedLanguages(prefs) {
const allowed = String((prefs || {}).allowed_languages || "")
.split(",").map((s) => s.trim()).filter(Boolean);
Array.from(langSel.options).forEach((opt) => {
opt.hidden = allowed.length > 0 && !allowed.includes(opt.value);
const blocked = allowed.length > 0 && !allowed.includes(opt.value);
opt.hidden = blocked; // versteckt (moderne Browser)
opt.disabled = blocked; // robust: auch wo <option hidden> ignoriert wird, nicht wählbar
});
let val = (prefs || {}).language || "de";
if (allowed.length && !allowed.includes(val)) val = allowed[0];
@ -2091,39 +2093,54 @@ $("#load-settings").addEventListener("click", loadSettings);
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
const emergencyBtn = $("#emergency-btn");
// Standort (One-Shot) für den Notruf erfassen — nur mit Freigabe (location_consent)
// und mit Timeout, damit ein hängendes GPS den Alarm nicht blockiert. null = kein Fix.
// Standort (One-Shot) für den Notruf erfassen — nur mit Freigabe (location_consent),
// mit Timeout (hängendes GPS blockiert den Alarm nie). Liefert {ok:true,…} oder
// {ok:false, error:"…"} mit erklärbarem Grund (für Diagnose in Meldung/Log).
function captureGeo(timeoutMs = 8000) {
return new Promise((resolve) => {
if (!myPrefs.location_consent || !navigator.geolocation) return resolve(null);
if (!myPrefs.location_consent) return resolve({ ok: false, error: "Standort-Freigabe aus" });
if (!window.isSecureContext) return resolve({ ok: false, error: "kein HTTPS (insecure context)" });
if (!navigator.geolocation) return resolve({ ok: false, error: "Browser ohne Geolocation" });
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
const timer = setTimeout(() => finish(null), timeoutMs);
const timer = setTimeout(() => finish({ ok: false, error: "Timeout" }), timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => {
clearTimeout(timer);
finish({
ok: true,
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy: pos.coords.accuracy,
ts: new Date(pos.timestamp).toISOString(),
});
},
() => { clearTimeout(timer); finish(null); },
(err) => { clearTimeout(timer); finish({ ok: false, error: "code " + err.code + ": " + err.message }); },
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 60000 }
);
});
}
// Geräteart/Plattform aus Browser-Daten (User-Agent Client Hints, Fallback UA-String).
function deviceInfo() {
try {
const uad = navigator.userAgentData;
if (uad) return { mobile: !!uad.mobile, platform: uad.platform || "" };
} catch (e) { /* ignore */ }
const ua = navigator.userAgent || "";
return { mobile: /Mobi|Android|iPhone|iPad|iPod/i.test(ua), platform: ua.slice(0, 120) };
}
if (emergencyBtn) {
emergencyBtn.addEventListener("click", async () => {
if (!confirm("Wirklich Hilfe rufen?")) return;
const lang = (langSel && langSel.value) || "de";
emergencyBtn.disabled = true;
try {
const geo = await captureGeo(); // null, wenn keine Freigabe/kein Fix
const body = { language: lang };
if (geo) body.geo = geo;
const g = await captureGeo();
const body = { language: lang, device: deviceInfo() };
if (g.ok) body.geo = { lat: g.lat, lon: g.lon, accuracy: g.accuracy, ts: g.ts };
else body.geo_error = g.error;
const res = await fetch("/api/emergency", {
method: "POST",
headers: { "Content-Type": "application/json" },

View file

@ -106,3 +106,21 @@ def test_record_manual_emergency_uses_profile(monkeypatch):
assert "Frieda Schlüter" in captured["subject"]
assert "Diabetes" in captured["body"]
assert MAPS in captured["body"]
def test_sms_includes_phone():
p = em._profile(FakeUser(FULL_PREFS))
sms = em._emergency_texts(p, GEO)["sms"]
assert "Tel: +4915100000000" in sms
def test_email_geo_error_shown():
p = em._profile(FakeUser(FULL_PREFS))
body = em._email_body(p, None, "code 2: POSITION_UNAVAILABLE", None)
assert "nicht verfügbar" in body and "code 2" in body
def test_email_device_line_mobile_and_desktop():
p = em._profile(FakeUser(FULL_PREFS))
assert "Festgerät" in em._email_body(p, GEO, None, {"mobile": False, "platform": "Linux"})
assert "Mobilgerät" in em._email_body(p, GEO, None, {"mobile": True, "platform": "Android"})