mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-09 15:02:33 +02:00
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:
parent
f77bc6e558
commit
08ca806cda
6 changed files with 159 additions and 3 deletions
|
|
@ -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
129
pipe/kontakte.py
Normal 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
|
||||
|
|
@ -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 …")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>`:''}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue