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)[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''
+ 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: