feat(P2): Notruf verschickt E-Mail an Kontaktperson (Testphase)
Beim Notruf-Knopf wird – sofern SMTP konfiguriert ist – eine E-Mail an die Kontaktperson(en) des Nutzers geschickt (Default: dieter.schlueter@linix.de). Der Nutzer-Hinweis spiegelt ehrlich wider, ob wirklich etwas rausging: "Kontaktperson per E-Mail benachrichtigt" vs. "noch keine Benachrichtigung". - config.py: EMERGENCY_CONTACT_EMAIL + SMTP_* (ohne SMTP_HOST kein Versand); veralteten LLM-Klassifikator-Kommentar entfernt. - emergency.py: SMTP-Versand (best effort, im Thread), lokalisierte Hinweise für "gesendet"/"nicht gesendet" (10 Sprachen). Pro-Nutzer-Kontakte via prefs.emergency_contacts vorbereitet, Fallback auf Config-Default. - Test mit gemocktem smtplib. env.example + DEPLOYMENT.md 2.5 dokumentiert. SMS/Anruf-Eskalation folgt später (eigene Planung). 210 passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a7f718192f
commit
c35fe6e187
6 changed files with 141 additions and 20 deletions
|
|
@ -845,6 +845,16 @@ ADMIN_USERS=dschlueter
|
|||
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
|
||||
HF_HOME=/var/lib/voice-assistant/hf-cache
|
||||
SSO_LOGOUT_URL=https://auth.jamulix.de/?action=logout
|
||||
|
||||
# Notruf-Benachrichtigung per E-Mail (Testphase). OHNE SMTP_HOST kein Versand;
|
||||
# der Nutzer sieht dann "noch keine Benachrichtigung eingebaut". SMS/Anruf folgen.
|
||||
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de
|
||||
SMTP_HOST= # z. B. smtp.linix.de — leer = kein Versand
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM= # leer -> SMTP_USER
|
||||
SMTP_STARTTLS=true
|
||||
```
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -150,4 +150,4 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ
|
|||
Die eigentliche Benachrichtigung von Angehörigen folgt später.
|
||||
"""
|
||||
store = get_store()
|
||||
return record_manual_emergency(user, store, payload.language)
|
||||
return await record_manual_emergency(user, store, payload.language)
|
||||
|
|
|
|||
|
|
@ -188,10 +188,16 @@ 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
|
||||
# LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen).
|
||||
# Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine
|
||||
# zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider.
|
||||
emergency_webhook_url: str = "" # optionaler Eskalations-Webhook (später)
|
||||
|
||||
# Notruf-Benachrichtigung per E-Mail (Testphase, bis SMS/Anruf gebaut sind).
|
||||
emergency_contact_email: str = "dieter.schlueter@linix.de" # Default-Kontaktperson
|
||||
smtp_host: str = "" # ohne Host wird KEINE E-Mail versendet
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = "" # Absenderadresse; leer -> smtp_user
|
||||
smtp_starttls: bool = True
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
"""Manuell ausgelöster Notruf — vom Nutzer bewusst bestätigt.
|
||||
|
||||
Es gibt KEINE automatische Notfallerkennung (keine Stichwörter, kein LLM): die
|
||||
wäre unzuverlässig (Fehlalarme wie verpasste Notfälle) und intransparent. Ein
|
||||
Notruf entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
|
||||
KEINE automatische Erkennung (zu unzuverlässig/intransparent). Ein Notruf
|
||||
entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
|
||||
|
||||
Die tatsächliche Benachrichtigung von Angehörigen (SMS/Anruf/Webhook) ist noch
|
||||
NICHT implementiert. Bis dahin wird der Notruf protokolliert und der Nutzer
|
||||
erhält einen klaren Hinweis in seiner Sprache.
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import settings
|
||||
from app.metrics import metrics
|
||||
|
||||
# Platzhalter-Hinweis je Sprache, solange die Eskalation noch nicht gebaut ist.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hinweis je Sprache, wenn (noch) KEINE Benachrichtigung rausging.
|
||||
_NO_NOTIFY_NOTICE: dict[str, str] = {
|
||||
"de": "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.",
|
||||
"en": "ATTENTION: No notification has been set up yet.",
|
||||
|
|
@ -25,17 +33,72 @@ _NO_NOTIFY_NOTICE: dict[str, str] = {
|
|||
"zh": "注意:尚未设置任何通知。",
|
||||
}
|
||||
|
||||
|
||||
def notify_notice(language: str | None) -> str:
|
||||
"""Liefert den Platzhalter-Hinweis in der Nutzersprache (Fallback Deutsch)."""
|
||||
return _NO_NOTIFY_NOTICE.get((language or "de").lower(), _NO_NOTIFY_NOTICE["de"])
|
||||
# Hinweis je Sprache, wenn die E-Mail rausging.
|
||||
_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": "已通知求助。已通过电子邮件通知联系人。",
|
||||
}
|
||||
|
||||
|
||||
def record_manual_emergency(user, store, language: str | None = None) -> dict:
|
||||
"""Protokolliert einen vom Nutzer bestätigten Notruf und liefert den Hinweis.
|
||||
def notify_notice(language: str | None, sent: bool) -> str:
|
||||
"""Hinweis in Nutzersprache (Fallback Deutsch), je nachdem ob eine Mail rausging."""
|
||||
table = _SENT_NOTICE if sent else _NO_NOTIFY_NOTICE
|
||||
return table.get((language or "de").lower(), table["de"])
|
||||
|
||||
Eskalation (Benachrichtigung) folgt später; hier wird nur protokolliert.
|
||||
"""
|
||||
|
||||
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()]
|
||||
|
||||
|
||||
def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool:
|
||||
"""Versendet eine E-Mail per SMTP. Liefert True bei Erfolg, sonst False (best effort)."""
|
||||
if not cfg.smtp_host or not to:
|
||||
return False
|
||||
msg = EmailMessage()
|
||||
msg["From"] = cfg.smtp_from or cfg.smtp_user or "voice-assistant@localhost"
|
||||
msg["To"] = ", ".join(to)
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
try:
|
||||
with smtplib.SMTP(cfg.smtp_host, int(cfg.smtp_port), timeout=10) as s:
|
||||
if cfg.smtp_starttls:
|
||||
s.starttls()
|
||||
if cfg.smtp_user:
|
||||
s.login(cfg.smtp_user, cfg.smtp_password)
|
||||
s.send_message(msg)
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Notruf-E-Mail konnte nicht versendet 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"})
|
||||
return {"category": "manual", "notice": notify_notice(language)}
|
||||
|
||||
to = _contacts(user, cfg)
|
||||
name = getattr(user, "display_name", None) or getattr(user, "id", "?")
|
||||
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.)"
|
||||
)
|
||||
sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg)
|
||||
if sent:
|
||||
metrics.inc("emergency_notify_total", {"channel": "email"})
|
||||
return {"category": "manual", "notice": notify_notice(language, sent), "email_sent": sent}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,15 @@ SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
|
|||
|
||||
# Persistente Datenbank (Pfad auf dem Host)
|
||||
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
|
||||
|
||||
# --- Notruf-Benachrichtigung per E-Mail (Testphase) --------------------------
|
||||
# Beim Notruf-Knopf wird (sofern SMTP gesetzt) eine E-Mail an die Kontaktperson
|
||||
# geschickt. OHNE SMTP_HOST wird NICHTS versendet und der Nutzer sieht den
|
||||
# Hinweis "noch keine Benachrichtigung eingebaut". SMS/Anruf folgen später.
|
||||
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de
|
||||
SMTP_HOST= # z. B. smtp.linix.de — leer = kein Versand
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM= # Absender; leer -> SMTP_USER
|
||||
SMTP_STARTTLS=true
|
||||
|
|
|
|||
|
|
@ -64,3 +64,33 @@ def test_manual_emergency_does_not_count_against_quota(monkeypatch):
|
|||
monkeypatch.setattr(settings, "daily_request_limit", 1)
|
||||
for _ in range(3):
|
||||
assert client.post("/api/emergency", json={"language": "de"}).status_code == 200
|
||||
|
||||
|
||||
def test_manual_emergency_sends_email_when_smtp_configured(monkeypatch):
|
||||
import app.safety.emergency as em
|
||||
captured = {}
|
||||
|
||||
class FakeSMTP:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
captured["host"] = host
|
||||
def __enter__(self):
|
||||
return self
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
def starttls(self):
|
||||
captured["tls"] = True
|
||||
def login(self, u, p):
|
||||
captured["login"] = u
|
||||
def send_message(self, msg):
|
||||
captured["to"] = msg["To"]
|
||||
captured["subject"] = msg["Subject"]
|
||||
|
||||
monkeypatch.setattr(em.smtplib, "SMTP", FakeSMTP)
|
||||
monkeypatch.setattr(settings, "smtp_host", "mail.example.com")
|
||||
monkeypatch.setattr(settings, "emergency_contact_email", "kontakt@example.com")
|
||||
|
||||
data = client.post("/api/emergency", json={"language": "de"}).json()
|
||||
assert data["email_sent"] is True
|
||||
assert "benachrichtigt" in data["notice"].lower()
|
||||
assert captured["to"] == "kontakt@example.com"
|
||||
assert "Notruf" in captured["subject"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue