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:
parent
2a013ae9fe
commit
5d05e081d4
5 changed files with 104 additions and 34 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue