diff --git a/pipe/config.py b/pipe/config.py index a17c062..77351b9 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -117,6 +117,18 @@ NEXTCLOUD_PASS = os.environ.get("NEXTCLOUD_PASS", "") # App-Passwort # WebDAV-Pfad braucht die interne User-ID (weicht bei E-Mail-Login ab). NEXTCLOUD_USERID = os.environ.get("NEXTCLOUD_USERID", "") or NEXTCLOUD_USER NEXTCLOUD_ORDNER = os.environ.get("NEXTCLOUD_ORDNER", "Anrufe").strip("/") +# Adressbuch (CardDAV) fuer den Kontakt-Abgleich bei eingehenden Anrufen. +# Nextclouds Standard-Adressbuch heisst "contacts"; eigene Adressbuecher +# tragen ihre URI (z. B. "hgod"). +NEXTCLOUD_ADRESSBUCH = os.environ.get("NEXTCLOUD_ADRESSBUCH", "contacts") +# Getrennter Schalter fuer den Datei-Upload: Zugangsdaten allein reichen fuer +# den Kontakt-Abgleich (kontakte.py) - wer nur das will, ohne dass Aufnahmen +# und Transkripte zusaetzlich nach Nextcloud hochgeladen werden, setzt hier 0. +# Default an, damit sich am bisherigen Verhalten (Zugangsdaten -> Upload) fuer +# bestehende Installationen nichts aendert. +NEXTCLOUD_UPLOAD = os.environ.get("NEXTCLOUD_UPLOAD", "1").strip().lower() in { + "1", "true", "yes", "ja", "on" +} # Deck-Triage-Board (optional): pro Anruf eine Karte, Stapel = Kategorie. NEXTCLOUD_DECK = os.environ.get("NEXTCLOUD_DECK", "").strip().lower() in { diff --git a/pipe/kontakte.py b/pipe/kontakte.py new file mode 100644 index 0000000..c057720 --- /dev/null +++ b/pipe/kontakte.py @@ -0,0 +1,129 @@ +"""Kontakt-Abgleich: bekannte Anrufer per Nextcloud-Adressbuch (CardDAV). + +Steckbar wie die Nextcloud-Ablage in store.py - ohne Zugangsdaten passiert +nichts, ein Anruf bleibt dann einfach ohne Kontakt-Treffer. Das Adressbuch wird +komplett geladen und kurz zwischengespeichert (siehe CACHE_S); für eine +Arztpraxis mit ein paar hundert Kontakten ist das schneller und robuster als +eine serverseitige Suche pro Anruf. +""" +from __future__ import annotations + +import base64 +import re +import ssl +import time +import urllib.error +import urllib.request +from functools import lru_cache + +from . import config + +CACHE_S = 300 # Adressbuch-Cache - neue Kontakte brauchen bis zu 5 Min. +TIMEOUT = 15 + + +@lru_cache(maxsize=1) +def _ssl_kontext() -> ssl.SSLContext: + try: + import certifi + return ssl.create_default_context(cafile=certifi.where()) + except Exception: + return ssl.create_default_context() + + +def _basis() -> str: + return (f"{config.NEXTCLOUD_URL}/remote.php/dav/addressbooks/users/" + f"{config.NEXTCLOUD_USERID}/{config.NEXTCLOUD_ADRESSBUCH}/") + + +def _auth_header() -> dict: + roh = f"{config.NEXTCLOUD_USER}:{config.NEXTCLOUD_PASS}".encode("utf-8") + return { + "Authorization": "Basic " + base64.b64encode(roh).decode("ascii"), + "User-Agent": "praxis-telefon-agent/1.0", + "Content-Type": "application/xml; charset=utf-8", + "Depth": "1", + } + + +def _normalisiere(nummer: str | None) -> str: + """Rein numerische Kennung fuer den Vergleich - Schreibweise darf abweichen. + + +49 152 230-62462, 0049152230622462, 0152 23062462 sollen alle auf dieselbe + Kennung fuehren. Deutschland-Annahme (0 -> 49), wie der Rest des Projekts. + """ + z = re.sub(r"[^0-9+]", "", (nummer or "")) + if z.startswith("00"): + z = z[2:] + elif z.startswith("+"): + z = z[1:] + elif z.startswith("0"): + z = "49" + z[1:] + return z + + +_HREF = re.compile(r"<[a-z]+:href>([^<]+\.vcf)", re.I) + + +def _vcf_hrefs() -> list[str]: + """Listet alle .vcf-Pfade im Adressbuch (PROPFIND, Depth 1).""" + req = urllib.request.Request( + _basis(), method="PROPFIND", headers=_auth_header(), + data=b'' + b'', + ) + with urllib.request.urlopen(req, timeout=TIMEOUT, context=_ssl_kontext()) as r: + rumpf = r.read().decode("utf-8", errors="replace") + return [h for h in _HREF.findall(rumpf) if not h.rstrip("/").endswith(config.NEXTCLOUD_ADRESSBUCH)] + + +_FN = re.compile(r"^FN:(.*)$", re.M) +_TEL = re.compile(r"^TEL[^:]*:(.*)$", re.M) + + +def _vcards_laden() -> list[tuple[str, list[str]]]: + """[(Name, [normalisierte Rufnummern]), ...] fuer jede Karte im Adressbuch.""" + ergebnis = [] + for href in _vcf_hrefs(): + url = href if href.startswith("http") else f"{config.NEXTCLOUD_URL}{href}" + req = urllib.request.Request(url, headers=_auth_header()) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT, context=_ssl_kontext()) as r: + karte = r.read().decode("utf-8", errors="replace") + except (urllib.error.URLError, OSError): + continue + namen = _FN.findall(karte) + if not namen: + continue + nummern = [_normalisiere(n) for n in _TEL.findall(karte)] + ergebnis.append((namen[0].strip(), [n for n in nummern if n])) + return ergebnis + + +_cache: list[tuple[str, list[str]]] | None = None +_cache_zeit = 0.0 + + +def _adressbuch() -> list[tuple[str, list[str]]]: + global _cache, _cache_zeit + if _cache is None or time.time() - _cache_zeit > CACHE_S: + try: + _cache = _vcards_laden() + except Exception: + return _cache or [] # letzten guten Stand behalten statt leer + _cache_zeit = time.time() + return _cache + + +def nachschlagen(nummer: str | None) -> dict | None: + """Bekannter Kontakt zu einer Rufnummer, oder None (auch bei fehlender + Konfiguration, unbekannter Nummer oder nicht erreichbarem Nextcloud).""" + if not config.nextcloud_aktiv() or not nummer: + return None + ziel = _normalisiere(nummer) + if not ziel: + return None + for name, nummern in _adressbuch(): + if ziel in nummern: + return {"name": name, "quelle": "nextcloud", "adressbuch": config.NEXTCLOUD_ADRESSBUCH} + return None diff --git a/pipe/process_call.py b/pipe/process_call.py index 33e2514..735cbef 100644 --- a/pipe/process_call.py +++ b/pipe/process_call.py @@ -12,7 +12,7 @@ import argparse import sys from datetime import datetime -from . import categorize, protokoll, store, transcribe +from . import categorize, kontakte, protokoll, store, transcribe def verarbeite(audio: str, anrufer_nummer: str | None = None, @@ -37,6 +37,13 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None, nummer=anrufer_nummer, kategorie=auswertung["kategorie"], dringlichkeit=auswertung["dringlichkeit"]) + bekannter_kontakt = kontakte.nachschlagen(anrufer_nummer) + if bekannter_kontakt: + print(f" Kontakt: bekannt als »{bekannter_kontakt['name']}« " + f"({bekannter_kontakt['adressbuch']})") + protokoll.schreibe("kontakt", f"bekannter Anrufer: {bekannter_kontakt['name']}", + nummer=anrufer_nummer) + datensatz = { "empfangen": empfangen.isoformat(timespec="seconds"), "anrufer_nummer": anrufer_nummer, @@ -44,6 +51,7 @@ def verarbeite(audio: str, anrufer_nummer: str | None = None, "erkannte_sprache": t.sprache, "audio_dauer_s": t.dauer, "auswertung": auswertung, + "bekannter_kontakt": bekannter_kontakt, } print("▸ Lege ab …") diff --git a/pipe/resync.py b/pipe/resync.py index a73ea05..32c46bf 100644 --- a/pipe/resync.py +++ b/pipe/resync.py @@ -44,6 +44,9 @@ def main() -> int: if not config.nextcloud_aktiv(): print("Nextcloud ist nicht konfiguriert — nichts zu tun.", file=sys.stderr) return 1 + if not config.NEXTCLOUD_UPLOAD: + print("NEXTCLOUD_UPLOAD=0 — Datei-Upload ist bewusst ausgeschaltet.", file=sys.stderr) + return 1 hoch, schon, fehler = nachsync() print(f"\nNachsync fertig: {hoch} hochgeladen, {schon} bereits oben, {fehler} fehlgeschlagen.") return 0 if fehler == 0 else 2 diff --git a/pipe/server.py b/pipe/server.py index 0d87e77..8b5fb5b 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -145,6 +145,7 @@ def anrufe() -> list[dict]: "stichworte": e.get("stichworte") or [], "transkript": d.get("transkript") or "", "audio": audio, + "kontakt": (d.get("bekannter_kontakt") or {}).get("name"), }) return liste @@ -403,6 +404,9 @@ height:180px;overflow-y:auto;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,mo .a-anruf{color:#3b86d8}.a-eingang{color:#1f9d5c}.a-fertig{color:#1f9d5c} .a-fehler{color:var(--schlecht)}.a-notfall{color:var(--schlecht);font-weight:700} .a-system{color:var(--muted)}.a-stapel{color:#9b6bd8}.a-archiv{color:#9b6bd8} +.a-kontakt{color:var(--gut)} +.kontakt-badge{display:inline-block;background:color-mix(in srgb,var(--gut) 16%,transparent); +color:var(--gut);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:600;margin-left:6px}

Anruf-Leitstand

@@ -454,7 +458,7 @@ function karte(d){ return `
${esc(KAT[d.kategorie]||d.kategorie)} · ${esc(d.name||'unbekannt')} ${esc(d.kategorie==='notfall'?'notfall':d.dringlichkeit)}
-
${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}
+
${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}${d.kontakt?`✓ ${esc(d.kontakt)}`:''}

${esc(d.anliegen)}

${(d.stichworte||[]).map(s=>`${esc(s)}`).join('')}
${d.audio?``:''} diff --git a/pipe/store.py b/pipe/store.py index b2a8e05..fe08bff 100644 --- a/pipe/store.py +++ b/pipe/store.py @@ -96,7 +96,7 @@ def speichere(datensatz: dict, audio: str | Path | None) -> Path: audio_ziel = ziel / f"aufnahme{Path(audio).suffix or '.wav'}" shutil.copy2(audio, audio_ziel) - if config.nextcloud_aktiv(): + if config.nextcloud_aktiv() and config.NEXTCLOUD_UPLOAD: if hochladen(ziel): print(f" → Nextcloud: hochgeladen ({config.NEXTCLOUD_ORDNER}/{zeit:%Y-%m-%d}/{ziel.name})") else: