diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 774a560..9c7f83a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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 diff --git a/app/api/chat.py b/app/api/chat.py index c592fc2..8e1c1e0 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -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) diff --git a/app/config.py b/app/config.py index 35b73c8..7d1291b 100644 --- a/app/config.py +++ b/app/config.py @@ -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" ) diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 0ef65ba..6a89bec 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -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} diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example index 21341af..55e5a07 100644 --- a/deploy/voice-assistant.env.example +++ b/deploy/voice-assistant.env.example @@ -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 diff --git a/tests/test_quota_safety.py b/tests/test_quota_safety.py index 0bc2ed5..856af03 100644 --- a/tests/test_quota_safety.py +++ b/tests/test_quota_safety.py @@ -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"]