feat: Kontakt-Abgleich gegen Nextcloud-Adressbuch bei eingehenden Anrufen

pipe/kontakte.py: CardDAV-Abgleich (stdlib urllib, gleicher Stil wie
store.py) gegen ein konfigurierbares Adressbuch (NEXTCLOUD_ADRESSBUCH,
Default "contacts"). Rufnummer wird normalisiert (+49/0049/0-Schreibweisen,
Leerzeichen/Bindestriche), Adressbuch 5 Minuten gecacht statt pro Anruf neu
geladen. Ohne Nextcloud-Konfiguration oder bei Ausfall: sauber None, kein
Anruf bricht deswegen ab.

process_call.py speichert den Treffer als "bekannter_kontakt" in meta.json;
server.py zeigt ihn als gruenen Badge neben der Rufnummer im Board - fuer den
Abgleich "Anrufer sagt X, Nummer gehoert laut Adressbuch Y" auf einen Blick.

Nextcloud-Zugangsdaten aktivierten bisher automatisch auch den Datei-Upload
(store.hochladen) - fuer den reinen Kontakt-Abgleich waere das ein unge-
wollter Nebeneffekt, der das "Vollstaendig on-prem"-Versprechen des Projekts
bricht. Neuer Schalter NEXTCLOUD_UPLOAD (Default an, bestehende Installa-
tionen unveraendert) trennt beides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuP3zfgYqhnrwyEne2ejG7
This commit is contained in:
Jeuner 2026-08-23 02:18:51 +02:00
parent f77bc6e558
commit 08ca806cda
6 changed files with 159 additions and 3 deletions

View file

@ -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 {

129
pipe/kontakte.py Normal file
View file

@ -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)</[a-z]+:href>", 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'<?xml version="1.0"?><a:propfind xmlns:a="DAV:">'
b'<a:prop><a:getetag/></a:prop></a:propfind>',
)
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

View file

@ -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 …")

View file

@ -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

View file

@ -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}
</style></head><body><div class="wrap">
<header><h1>Anruf-Leitstand</h1>
<button class="archiv-btn" id="archivBtn" title="Erledigte Anrufe vom Board raeumen (simuliert - nichts wird geloescht oder exportiert)">🗄 Tag archivieren</button>
@ -454,7 +458,7 @@ function karte(d){
return `<article class="karte" draggable="true" data-id="${esc(d.id)}" style="--akzent:${f}">
<div class="kopf"><span class="titel">${esc(KAT[d.kategorie]||d.kategorie)} · ${esc(d.name||'unbekannt')}</span>
<span class="dring" style="background:${f}">${esc(d.kategorie==='notfall'?'notfall':d.dringlichkeit)}</span></div>
<div class="meta">${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}</div>
<div class="meta">${esc(d.empfangen.replace('T',' '))} · ${nummerLink(d)}${d.rueckruf?' · 📞 Rückruf':''}${d.kontakt?`<span class="kontakt-badge"> ${esc(d.kontakt)}</span>`:''}</div>
<p class="anliegen">${esc(d.anliegen)}</p>
<div>${(d.stichworte||[]).map(s=>`<span class="tag">${esc(s)}</span>`).join('')}</div>
${d.audio?`<audio controls preload="none" src="${d.audio}"></audio>`:''}

View file

@ -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: