Add MySQL contacts table + mysql backend for vm_contacts

- CREATE TABLE contacts (number_e164 UNIQUE, name, email) in asterisk DB
- new lookup_mysql() backend, registered as first in _BACKENDS
- vm_import_contacts.py: import VCF into MySQL (527 added)
- contacts.conf: backends=mysql,file
- live: 16 of 130 messages resolved via MySQL contacts
This commit is contained in:
jp
2026-08-13 13:16:13 +01:00
parent 05c5dbe45a
commit d2f1ca8f9d
2 changed files with 122 additions and 1 deletions

View File

@ -3,6 +3,7 @@
Caller-ID -> contact name resolution for Asterisk voicemail notifications. Caller-ID -> contact name resolution for Asterisk voicemail notifications.
Backends (tried in the order given by contacts.conf 'backends='): Backends (tried in the order given by contacts.conf 'backends='):
mysql - MySQL contacts table on the asterisk DB (primary, fast, offline).
file - local vCard (.vcf) or CSV export; no auth, offline, fast. file - local vCard (.vcf) or CSV export; no auth, offline, fast.
google - Google People API via the Hermes OAuth token. google - Google People API via the Hermes OAuth token.
carddav - generic CardDAV with username + app password. carddav - generic CardDAV with username + app password.
@ -21,6 +22,7 @@ import configparser
import json import json
import os import os
import re import re
import sys
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
@ -311,6 +313,41 @@ def lookup_google(cfg, num_digits, n, log):
return None return None
# ----------------------------------------------------------- mysql backend
def lookup_mysql(cfg, num_digits, n, log):
"""Look up a contact in the asterisk MySQL DB contacts table.
Uses vm_store.connect() so it shares the same asterisk DB config and
PyMySQL dependency already required by the portal. If the table does
not not exist yet, run vm_import_contacts.py to create and populate it.
"""
try:
sys_path = os.path.dirname(os.path.abspath(__file__))
if sys_path not in sys.path:
sys.path.insert(0, sys_path)
import vm_store # noqa: E402 (local import to avoid hard dep at import time)
except Exception as e:
log("contacts mysql backend: vm_store unavailable (%s)" % e)
return None
num = extract_number(num_digits)
if not num:
return None
try:
con = vm_store.connect()
row = con.execute(
"SELECT name, email FROM contacts "
"WHERE number_e164 = ? AND name IS NOT NULL AND name != '' "
"ORDER BY id LIMIT 1",
(num,)).fetchone()
con.close()
if row:
return (row["name"], row.get("email") or None)
except Exception as e:
log("contacts mysql backend error: %s" % e)
return None
# ---------------------------------------------------------- carddav backend # ---------------------------------------------------------- carddav backend
def lookup_carddav(cfg, num_digits, n, log): def lookup_carddav(cfg, num_digits, n, log):
import ssl import ssl
@ -356,7 +393,7 @@ def lookup_carddav(cfg, num_digits, n, log):
# ---------------------------------------------------------------- entrypoint # ---------------------------------------------------------------- entrypoint
_BACKENDS = {"file": lookup_file, "google": lookup_google, "carddav": lookup_carddav} _BACKENDS = {"mysql": lookup_mysql, "file": lookup_file, "google": lookup_google, "carddav": lookup_carddav}
def resolve(caller_id, log=print): def resolve(caller_id, log=print):

84
src/vm_import_contacts.py Normal file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Import /var/lib/vm-transcribe/contacts.vcf into the MySQL contacts table.
Idempotent: re-runs skip rows whose number_e164 already exists (UNIQUE key).
Caller-visible numbers are stored as the canonical E.164 form produced by
vm_contacts.normalize_uk, matching what Asterisk stores in callerid.
Run as the asterisk user:
sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_import_contacts.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_contacts
import vm_store
VCF_PATH = "/var/lib/vm-transcribe/contacts.vcf"
DB_TABLE = "contacts"
def main():
total = added = skipped = 0
con = vm_store.connect()
# Ensure table exists.
con.execute(
"CREATE TABLE IF NOT EXISTS %s ("
"id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, "
"number_e164 VARCHAR(20) NOT NULL UNIQUE, "
"name VARCHAR(255) NOT NULL, "
"email VARCHAR(255), "
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "
"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP "
"ON UPDATE CURRENT_TIMESTAMP, "
"INDEX idx_number (number_e164), "
"INDEX idx_name (name)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" % DB_TABLE)
con.commit()
if not os.path.exists(VCF_PATH):
print("VCF not found:", VCF_PATH)
return
with open(VCF_PATH, encoding="utf-8", errors="replace") as fh:
entries = vm_contacts._parse_vcf(fh.read())
# Load existing numbers so we can skip idempotently without relying on
# INSERT IGNORE swallowing other constraint failures.
existing = {
r["number_e164"] for r in con.execute(
"SELECT number_e164 FROM %s" % DB_TABLE).fetchall()
}
for name, nums, emails in entries:
if vm_contacts._is_junk_name(name):
continue
email = emails[0] if emails else None
for raw_num in nums:
e164 = vm_contacts.normalize_uk(vm_contacts.digits_of(raw_num))
if not e164:
continue
total += 1
if e164 in existing:
skipped += 1
continue
try:
con.execute(
"INSERT INTO %s (number_e164, name, email) VALUES (?, ?, ?)"
% DB_TABLE, (e164, name, email))
existing.add(e164)
added += 1
except Exception as e:
print("insert error for %s (%s): %s" % (e164, name, e))
con.commit()
con.close()
print("vcf entries: %d, added: %d, skipped (already known): %d" % (
total, added, skipped))
if __name__ == "__main__":
main()