Initial build: Asterisk voicemail transcription + portal

- mailcmd replacement (vm_mailcmd.py): faster-whisper transcription (CPU int8),
  extractive summary + intent tags + spoken-digit number extraction,
  multipart/alternative HTML email, fail-safe relay of original message
- Telegram DM delivery (vm_telegram.py) with per-mailbox routing
- Caller-ID -> name (vm_contacts.py): file / google / carddav backends
- SQLite store (vm_store.py) with content-addressed audio
- FastAPI portal (vm_web.py): PIN login, list/play/delete, per-user settings,
  zero JS, loopback-only behind Apache TLS
- Backfill importer (vm_import.py) for existing spool recordings
- systemd unit, Apache vhost + certbot TLS, install.sh
- Docs: INSTALL, CONFIGURATION, ARCHITECTURE, OPERATIONS, SECURITY, TESTING

Verified end-to-end on mail.txt3.net: 157 historical messages backfilled,
live voicemail -> transcribed -> stored -> visible at https://vm.txt3.net.
This commit is contained in:
jp
2026-08-13 09:31:41 +01:00
commit 857284abbf
28 changed files with 3793 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
Caller-ID -> contact name resolution for Asterisk voicemail notifications.
Backends (tried in the order given by contacts.conf 'backends='):
file - local vCard (.vcf) or CSV export; no auth, offline, fast.
google - Google People API via the Hermes OAuth token.
carddav - generic CardDAV with username + app password.
Everything here is best-effort: a lookup failure returns None and the caller
falls back to the raw caller-ID string. Results (hits AND misses) are cached
to avoid per-call API traffic.
NOTE: app-password / basic auth does NOT work against Google - Google disabled
it for CardDAV/CalDAV/IMAP on 2024-09-30 and now requires OAuth for CardDAV.
The carddav backend is for Nextcloud / Fastmail / iCloud / Radicale etc.
"""
import base64
import configparser
import json
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
CONF_PATH = os.environ.get("VM_CONTACTS_CONF", "/opt/vm-transcribe/contacts.conf")
# ------------------------------------------------------------------- helpers
def digits_of(s):
return re.sub(r"\D", "", s or "")
def extract_number(caller):
"""Pull a dialable number out of a CallerID string like 'Dave <079...>'."""
if not caller:
return ""
m = re.search(r"<([^>]+)>", caller)
cand = m.group(1) if m else caller
return digits_of(cand)
def _key(num_digits, n):
d = digits_of(num_digits)
return d[-n:] if len(d) >= n else d
class Cache:
def __init__(self, path, ttl):
self.path, self.ttl, self.data = path, ttl, {}
try:
with open(path) as fh:
self.data = json.load(fh)
except Exception:
self.data = {}
def get(self, key):
e = self.data.get(key)
if not e:
return None # unknown -> caller should look up
if self.ttl and time.time() - e.get("t", 0) > self.ttl:
return None
return e # {"t":..., "name": <str or None>}
def put(self, key, name):
self.data[key] = {"t": time.time(), "name": name}
try:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
tmp = self.path + ".tmp"
with open(tmp, "w") as fh:
json.dump(self.data, fh)
os.replace(tmp, self.path)
except Exception:
pass
# ------------------------------------------------------------- file backend
def _parse_vcf(text):
"""Return list of (name, [numbers])."""
out = []
name, nums = None, []
for raw in text.splitlines():
line = raw.strip()
u = line.upper()
if u == "BEGIN:VCARD":
name, nums = None, []
elif u.startswith("FN"):
name = line.split(":", 1)[1].strip() if ":" in line else None
elif u.startswith("TEL"):
if ":" in line:
nums.append(line.split(":", 1)[1].strip())
elif u == "END:VCARD":
if name and nums:
out.append((name, nums))
return out
def _parse_csv(path):
import csv
out = []
with open(path, newline="", encoding="utf-8", errors="replace") as fh:
r = csv.DictReader(fh)
cols = r.fieldnames or []
name_cols = [c for c in cols if c and ("Name" == c or c.endswith("Name"))]
phone_cols = [c for c in cols if c and "Phone" in c and "Value" in c] or \
[c for c in cols if c and "Phone" in c]
for row in r:
name = ""
if "Name" in row and row["Name"]:
name = row["Name"]
else:
parts = [row.get(c, "") for c in ("Given Name", "Family Name") if row.get(c)]
name = " ".join(parts) or (row.get(name_cols[0], "") if name_cols else "")
nums = []
for c in phone_cols:
v = row.get(c, "")
if v:
nums.extend(re.split(r"\s*:::\s*|\s*;\s*", v))
if name and nums:
out.append((name.strip(), nums))
return out
def _index(entries, n):
idx = {}
for name, nums in entries:
for num in nums:
k = _key(num, n)
if k:
idx.setdefault(k, name)
return idx
def lookup_file(cfg, num_digits, n, log):
path = cfg.get("path", "").strip()
if not path or not os.path.exists(path):
return None
try:
if path.lower().endswith(".csv"):
entries = _parse_csv(path)
else:
with open(path, encoding="utf-8", errors="replace") as fh:
entries = _parse_vcf(fh.read())
return _index(entries, n).get(_key(num_digits, n))
except Exception as e:
log("contacts file backend error: %s" % e)
return None
# ----------------------------------------------------------- google backend
def _google_access_token(token_path, log):
try:
with open(token_path) as fh:
tok = json.load(fh)
except Exception as e:
log("google token unreadable (%s): %s" % (token_path, e))
return None
# try existing token first; refresh if People API 401s
at = tok.get("token") or tok.get("access_token")
refresh = tok.get("refresh_token")
cid = tok.get("client_id")
secret = tok.get("client_secret")
if at:
return at, (refresh, cid, secret, token_path, tok)
return _google_refresh((refresh, cid, secret, token_path, tok), log)
def _google_refresh(ctx, log):
refresh, cid, secret, token_path, tok = ctx
if not (refresh and cid and secret):
log("google token missing refresh_token/client_id/client_secret")
return None
try:
data = urllib.parse.urlencode({
"client_id": cid, "client_secret": secret,
"refresh_token": refresh, "grant_type": "refresh_token",
}).encode()
req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data)
with urllib.request.urlopen(req, timeout=20) as r:
new = json.loads(r.read().decode())
at = new.get("access_token")
if at:
tok["token"] = at
try:
with open(token_path, "w") as fh:
json.dump(tok, fh)
except Exception:
pass
return at, ctx
except Exception as e:
log("google token refresh failed: %s" % e)
return None
def lookup_google(cfg, num_digits, n, log):
token_path = cfg.get("token_path", "").strip()
if not token_path or not os.path.exists(token_path):
return None
got = _google_access_token(token_path, log)
if not got:
return None
at, ctx = got
want = _key(num_digits, n)
def query(access):
url = ("https://people.googleapis.com/v1/people:searchContacts"
"?query=%s&readMask=names,phoneNumbers"
% urllib.parse.quote(num_digits[-7:] or num_digits))
req = urllib.request.Request(url, headers={"Authorization": "Bearer %s" % access})
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode())
try:
try:
d = query(at)
except urllib.error.HTTPError as he:
if he.code == 401: # refresh once
got = _google_refresh(ctx if isinstance(ctx, tuple) and len(ctx) == 5
else ctx, log)
if not got:
return None
d = query(got[0])
else:
raise
for res in d.get("results", []):
person = res.get("person", {})
for ph in person.get("phoneNumbers", []):
if _key(ph.get("value", ""), n) == want:
names = person.get("names", [])
if names:
return names[0].get("displayName")
except Exception as e:
log("google People API error: %s" % e)
return None
# ---------------------------------------------------------- carddav backend
def lookup_carddav(cfg, num_digits, n, log):
import ssl
url = cfg.get("url", "").strip()
user = cfg.get("username", "").strip()
pw = cfg.get("app_password", "").strip()
if not (url and user and pw):
return None
if "google.com" in url:
log("carddav backend points at Google, which rejects app passwords "
"since 2024-09-30; use the 'google' or 'file' backend instead")
return None
path = cfg.get("addressbook_path", "").strip()
base = url.rstrip("/") + (path if path.startswith("/") else "/" + path if path else "")
ctx = None
if cfg.get("verify_tls", "yes").lower() in ("no", "false", "0"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
timeout = int(cfg.get("timeout", "20") or 20)
auth = base64.b64encode(("%s:%s" % (user, pw)).encode()).decode()
body = ('<?xml version="1.0"?>'
'<C:addressbook-query xmlns:D="DAV:" '
'xmlns:C="urn:ietf:params:xml:ns:carddav">'
'<D:prop><C:address-data/></D:prop></C:addressbook-query>')
try:
req = urllib.request.Request(
base, data=body.encode(), method="REPORT",
headers={"Authorization": "Basic %s" % auth, "Depth": "1",
"Content-Type": "application/xml; charset=utf-8"})
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
xml = r.read().decode("utf-8", "replace")
vcards = re.findall(r"BEGIN:VCARD.*?END:VCARD", xml, re.S | re.I)
entries = []
for v in vcards:
entries.extend(_parse_vcf(v.replace("&#13;", "").replace("\r", "")))
return _index(entries, n).get(_key(num_digits, n))
except Exception as e:
log("carddav backend error: %s" % e)
return None
# ---------------------------------------------------------------- entrypoint
_BACKENDS = {"file": lookup_file, "google": lookup_google, "carddav": lookup_carddav}
def resolve(caller_id, log=print):
"""Return a contact name for a CallerID string, or None."""
num = extract_number(caller_id)
if not num or not os.path.exists(CONF_PATH):
return None
try:
cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";"))
cp.read(CONF_PATH)
if not cp.has_section("contacts") or not cp["contacts"].getboolean("enabled", False):
return None
g = cp["contacts"]
n = int(g.get("match_digits", "9") or 9)
cache = Cache(g.get("cache_path", "/var/lib/vm-transcribe/contacts_cache.json"),
int(g.get("cache_ttl", "86400") or 86400))
key = _key(num, n)
cached = cache.get(key)
if cached is not None:
return cached.get("name")
order = [b.strip() for b in g.get("backends", "file").split(",") if b.strip()]
name = None
for b in order:
fn = _BACKENDS.get(b)
if not fn:
log("unknown contacts backend: %s" % b)
continue
sect = cp[b] if cp.has_section(b) else {}
try:
name = fn(sect, num, n, log)
except Exception as e:
log("contacts backend %s crashed: %s" % (b, e))
name = None
if name:
log("contacts: %s -> %s (via %s)" % (num, name, b))
break
cache.put(key, name) # cache misses too (name=None)
return name
except Exception as e:
log("contacts resolve error: %s" % e)
return None
if __name__ == "__main__":
import sys
print(resolve(sys.argv[1] if len(sys.argv) > 1 else "", log=lambda m: print("[log]", m)))