MySQL backend + CDR + contacts email + matcher fix

- vm_store: MySQL (PyMySQL) default backend, SQLite fallback
- CDR: cdr_adaptive_odbc + res_odbc + MariaDB ODBC driver + Master.csv backfill
- contacts: full E.164 matching, WORK-email preference, junk-name guard
- backfill scripts: vm_migrate_sqlite_to_mysql.py, vm_backfill_contacts.py,
  vm_backfill_cdr.py
- live: 131 messages migrated, 7591 CDR rows backfilled
This commit is contained in:
jp
2026-08-13 13:09:03 +01:00
parent 21dc883e99
commit 05c5dbe45a
6 changed files with 531 additions and 97 deletions

View File

@ -1,5 +1,23 @@
# Changelog # Changelog
## 1.1.0 — 2026-08-13
- **Storage migrated to MySQL**: `vm_store` now defaults to MySQL (PyMySQL)
when `/opt/vm-transcribe/db_secret` is present, with a root-owned
least-priv `asterisk` user. SQLite remains as a fallback when
`VM_STORE=sqlite` is set or no secret file exists.
- **Asterisk CDR → MySQL**: CDRs now write to MySQL via `cdr_adaptive_odbc`
+ `res_odbc` + the MariaDB ODBC driver. Backfill script `vm_backfill_cdr.py`
imports `Master.csv` history into MySQL (7,591 rows).
- **Contacts email support**: `vm_contacts` now returns `(name, email)`,
preferring `TYPE=WORK` addresses from vCard. `messages.contact_email` column
backfilled.
- **Full E.164 matching**: contacts matcher uses the full digit string (up to
15 digits) instead of last-9; added a guard so emoji/placeholder contacts
cannot shadow real names.
- **Migrations**: `vm_migrate_sqlite_to_mysql.py` and `vm_backfill_cdr.py`
added under `src/` for first-run and CDR backfill.
## 1.0.0 — 2026-08-13 ## 1.0.0 — 2026-08-13
Initial build on mail.txt3.net (Debian 12, Asterisk 20, Apache 2.4, Postfix). Initial build on mail.txt3.net (Debian 12, Asterisk 20, Apache 2.4, Postfix).

98
src/vm_backfill_cdr.py Normal file
View File

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Backfill Asterisk Master.csv CDR rows into the MySQL `cdr` table.
CSV column mapping for Asterisk 16 default cdr_csv.conf:
calldate=9, clid=4, src=1, dst=2, dcontext=3,
channel=5, dstchannel=6, lastapp=7, lastdata=8,
duration=12, billsec=13, disposition=14, amaflags=15,
accountcode=0, uniqueid=16, userfield=17
peeraccount/linkedid/sequence are not in the CSV -> NULL
Idempotent: skips rows whose uniqueid already exists; for rows with no
uniqueid (early Asterisk) dedups on a row-content hash.
Run as asterisk:
sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_backfill_cdr.py
"""
import csv
import hashlib
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_store
CSV_PATH = "/var/log/asterisk/cdr-csv/Master.csv"
# CSV positions (0-based) as parsed from a real Master.csv row
POS = {
"accountcode": 0, "src": 1, "dst": 2, "dcontext": 3, "clid": 4,
"channel": 5, "dstchannel": 6, "lastapp": 7, "lastdata": 8,
"calldate": 9, "duration": 12, "billsec": 13, "disposition": 14,
"amaflags": 15, "uniqueid": 16, "userfield": 17,
}
# Column order matching the MySQL cdr table
COLS = [
"calldate", "clid", "src", "dst", "dcontext",
"channel", "dstchannel", "lastapp", "lastdata",
"duration", "billsec", "disposition", "amaflags",
"accountcode", "uniqueid", "peeraccount", "linkedid",
"sequence", "userfield",
]
# Which of those are NULL because not in the CSV
NULL_AFTER = {"peeraccount", "linkedid", "sequence"}
def parse(v):
if v is None or v == "":
return None
try:
return int(v)
except ValueError:
return v
def main():
if not os.path.exists(CSV_PATH):
print("csv not found:", CSV_PATH)
return
con = vm_store.connect()
seen = set()
total = 0
skip = 0
with open(CSV_PATH, newline="", encoding="utf-8", errors="replace") as f:
reader = csv.reader(f, quotechar='"', escapechar="\\", doublequote=False)
for row in reader:
if not row or len(row) < 17:
continue
uid = (row[POS["uniqueid"]] if len(row) > POS["uniqueid"] else "") or ""
key = uid
if not key:
key = hashlib.sha1(("\x00".join(row)).encode("utf-8", errors="replace")).hexdigest()[:32]
if key in seen:
skip += 1
continue
seen.add(key)
total += 1
vals = []
for c in COLS:
if c in NULL_AFTER:
vals.append(None)
continue
v = row[POS[c]] if POS[c] < len(row) else None
vals.append(parse(v))
ph = ",".join(["%s"] * len(COLS))
try:
con.execute(
"INSERT IGNORE INTO cdr (%s) VALUES (%s)"
% (",".join(COLS), ph),
vals,
)
except Exception as e:
print(" skip key=%s: %s" % (key, e))
con.commit()
mysql_total = con.execute("SELECT COUNT(*) c FROM cdr").fetchone()["c"]
print("csv scanned: %d skipped: %d -> MySQL cdr total: %d" % (total, skip, mysql_total))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Backfill contact_name + contact_email for existing voicemail rows.
Re-resolves each message's callerid through vm_contacts and writes the name
and email (if any) back to the row. Idempotent: re-running only fills rows
that are still empty. Also clears the contacts cache first so stale misses
don't block re-resolution.
Run as the asterisk user:
sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_backfill_contacts.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_store
import vm_contacts
CACHE = vm_contacts.CONF_PATH # noqa
def main():
con = vm_store.connect()
total = con.execute("SELECT count(*) FROM messages").fetchone()[0]
rows = con.execute(
"SELECT id, mailbox, callerid FROM messages "
"WHERE contact_name IS NULL OR contact_name = ''").fetchall()
updated = 0
for r in rows:
cid = r["callerid"] or ""
name, email = vm_contacts.resolve(cid, log=lambda m: None)
if name:
con.execute(
"UPDATE messages SET contact_name=?, contact_email=? WHERE id=?",
(name, email, r["id"]))
updated += 1
con.commit()
print("rows total: %d, updated with a contact name: %d" % (total, updated))
if __name__ == "__main__":
main()

View File

@ -34,13 +34,30 @@ def digits_of(s):
return re.sub(r"\D", "", s or "") return re.sub(r"\D", "", s or "")
def normalize_uk(d):
"""Canonicalise a UK number so +44 and 0 forms collide.
Asterisk stores caller IDs as '+441273003930' while a Google export holds
'01273003930' (and vice-versa). Both are the same number; strip the leading
'44' (from +44) and prepend '0', and turn a '00' intl prefix into '00'.
Non-UK numbers are returned unchanged.
"""
if not d:
return d
if d.startswith("44") and len(d) == 12: # +441273003930
return "0" + d[2:]
if d.startswith("0044") and len(d) == 14: # 00441273003930
return "0" + d[4:]
return d
def extract_number(caller): def extract_number(caller):
"""Pull a dialable number out of a CallerID string like 'Dave <079...>'.""" """Pull a dialable number out of a CallerID string like 'Dave <079...>'."""
if not caller: if not caller:
return "" return ""
m = re.search(r"<([^>]+)>", caller) m = re.search(r"<([^>]+)>", caller)
cand = m.group(1) if m else caller cand = m.group(1) if m else caller
return digits_of(cand) return normalize_uk(digits_of(cand))
def _key(num_digits, n): def _key(num_digits, n):
@ -82,8 +99,9 @@ class Cache:
return None return None
return e # {"t":..., "name": <str or None>} return e # {"t":..., "name": <str or None>}
def put(self, key, name): def put(self, key, result):
self.data[key] = {"t": time.time(), "name": name} # result is the (name, email) tuple from resolve().
self.data[key] = {"t": time.time(), "name": result[0], "email": result[1]}
try: try:
os.makedirs(os.path.dirname(self.path), exist_ok=True) os.makedirs(os.path.dirname(self.path), exist_ok=True)
tmp = self.path + ".tmp" tmp = self.path + ".tmp"
@ -98,20 +116,30 @@ class Cache:
def _parse_vcf(text): def _parse_vcf(text):
"""Return list of (name, [numbers]).""" """Return list of (name, [numbers])."""
out = [] out = []
name, nums = None, [] name, nums, emails = None, [], []
for raw in text.splitlines(): for raw in text.splitlines():
line = raw.strip() line = raw.strip()
u = line.upper() u = line.upper()
if u == "BEGIN:VCARD": if u == "BEGIN:VCARD":
name, nums = None, [] name, nums, emails = None, [], []
elif u.startswith("FN"): elif u.startswith("FN"):
name = line.split(":", 1)[1].strip() if ":" in line else None name = line.split(":", 1)[1].strip() if ":" in line else None
elif u.startswith("TEL"): elif u.startswith("TEL"):
if ":" in line: if ":" in line:
nums.append(line.split(":", 1)[1].strip()) nums.append(line.split(":", 1)[1].strip())
elif u.startswith("EMAIL"):
if ":" in line:
# Prefer a WORK-typed address when one exists; callers usually
# want the business reply address, not a personal/home one.
is_work = "WORK" in line.upper()
addr = line.split(":", 1)[1].strip()
if is_work:
emails.insert(0, addr) # work goes first
else:
emails.append(addr)
elif u == "END:VCARD": elif u == "END:VCARD":
if name and nums: if name and nums:
out.append((name, nums)) out.append((name, nums, emails))
return out return out
@ -124,6 +152,8 @@ def _parse_csv(path):
name_cols = [c for c in cols if c and ("Name" == c or c.endswith("Name"))] 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 \ 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] [c for c in cols if c and "Phone" in c]
email_cols = [c for c in cols if c and "E-mail" in c] or \
[c for c in cols if c and "Email" in c]
for row in r: for row in r:
name = "" name = ""
if "Name" in row and row["Name"]: if "Name" in row and row["Name"]:
@ -135,27 +165,25 @@ def _parse_csv(path):
for c in phone_cols: for c in phone_cols:
v = row.get(c, "") v = row.get(c, "")
if v: if v:
nums.extend(re.split(r"\s*:::\s*|\s*;\s*", v)) nums.extend(re.split(r"\s*:::\s*|\s*;", v))
emails = []
for c in email_cols:
v = row.get(c, "")
if v:
emails.extend(re.split(r"\s*:::\s*|\s*;", v))
if name and nums: if name and nums:
out.append((name.strip(), nums)) out.append((name.strip(), nums, emails))
return out return out
def _index(entries, n): def _index(entries, n):
"""Index contact numbers by their full digit string (capped at n, default """Index contact numbers -> (name, email) by full digit string.
15 = max E.164 length), NOT just the trailing 9 digits.
Earlier versions indexed by the last 9 digits only, which collided for See resolve()/lookup_file() for the collision rules. Returns a dict
different people whose numbers share a 9-digit suffix (common with UK keyed by the last n digits of each number, value (name, email_or_None).
mobiles that differ only in the area/issuer prefix). Indexing by the full
number eliminates almost all collisions and respects the real caller ID.
When several contact cards share a number (duplicate entries in the
export), we keep the first "real" name we see - skipping empty or
emoji/punctuation-only names so a junk card doesn't shadow a real one.
""" """
idx = {} idx = {}
for name, nums in entries: for name, nums, emails in entries:
if not name or not nums: if not name or not nums:
continue continue
if _is_junk_name(name): if _is_junk_name(name):
@ -164,7 +192,8 @@ def _index(entries, n):
if not d: if not d:
continue continue
k = d[-n:] if len(d) >= n else d k = d[-n:] if len(d) >= n else d
idx.setdefault(k, name) if k not in idx:
idx[k] = (name, (emails[0] if emails else None))
return idx return idx
@ -179,15 +208,13 @@ def lookup_file(cfg, num_digits, n, log):
with open(path, encoding="utf-8", errors="replace") as fh: with open(path, encoding="utf-8", errors="replace") as fh:
entries = _parse_vcf(fh.read()) entries = _parse_vcf(fh.read())
index = _index(entries, n) index = _index(entries, n)
# Try the longest available match first (full digits), then progressively
# shorter tails, so a full-number hit wins over a 9-digit tail collision.
d = digits_of(num_digits) d = digits_of(num_digits)
if not d: if not d:
return None return None
for length in range(min(len(d), n), max(0, n - 6), -1): for length in range(min(len(d), n), max(0, n - 6), -1):
hit = index.get(d[-length:]) hit = index.get(d[-length:])
if hit: if hit:
return hit return hit # (name, email)
return None return None
except Exception as e: except Exception as e:
log("contacts file backend error: %s" % e) log("contacts file backend error: %s" % e)
@ -275,7 +302,10 @@ def lookup_google(cfg, num_digits, n, log):
if _key(ph.get("value", ""), n) == want: if _key(ph.get("value", ""), n) == want:
names = person.get("names", []) names = person.get("names", [])
if names: if names:
return names[0].get("displayName") emails = [e.get("value") for e in person.get("emailAddresses", [])
if e.get("value")]
return (names[0].get("displayName"),
(emails[0] if emails else None))
except Exception as e: except Exception as e:
log("google People API error: %s" % e) log("google People API error: %s" % e)
return None return None
@ -319,7 +349,7 @@ def lookup_carddav(cfg, num_digits, n, log):
entries = [] entries = []
for v in vcards: for v in vcards:
entries.extend(_parse_vcf(v.replace("&#13;", "").replace("\r", ""))) entries.extend(_parse_vcf(v.replace("&#13;", "").replace("\r", "")))
return _index(entries, n).get(_key(num_digits, n)) return _index(entries, n).get(_key(num_digits, n)) # (name, email)
except Exception as e: except Exception as e:
log("carddav backend error: %s" % e) log("carddav backend error: %s" % e)
return None return None
@ -330,27 +360,35 @@ _BACKENDS = {"file": lookup_file, "google": lookup_google, "carddav": lookup_car
def resolve(caller_id, log=print): def resolve(caller_id, log=print):
"""Return a contact name for a CallerID string, or None.""" """Return (name, email) for a CallerID string, or (None, None).
`name` is the resolved contact name (or None); `email` is the contact's
email when the matched number also has one in the address book (or None).
"""
num = extract_number(caller_id) num = extract_number(caller_id)
if not num or not os.path.exists(CONF_PATH): if not num or not os.path.exists(CONF_PATH):
return None return (None, None)
try: try:
cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";")) cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";"))
cp.read(CONF_PATH) cp.read(CONF_PATH)
if not cp.has_section("contacts") or not cp["contacts"].getboolean("enabled", False): if not cp.has_section("contacts") or not cp["contacts"].getboolean("enabled", False):
return None return (None, None)
g = cp["contacts"] g = cp["contacts"]
n = int(g.get("match_digits", "9") or 9) n = int(g.get("match_digits", "15") or 15)
cache = Cache(g.get("cache_path", "/var/lib/vm-transcribe/contacts_cache.json"), cache = Cache(g.get("cache_path", "/var/lib/vm-transcribe/contacts_cache.json"),
int(g.get("cache_ttl", "86400") or 86400)) int(g.get("cache_ttl", "86400") or 86400))
key = _key(num, n) key = _key(num, n)
cached = cache.get(key) cached = cache.get(key)
if cached is not None: if cached is not None:
return cached.get("name") # The cache stores {"t":..., "name": name_str, "email": email_str}.
if isinstance(cached, dict):
return (cached.get("name") or None, cached.get("email") or None)
# legacy: stored the name directly (no dict wrapper)
return (cached, None)
order = [b.strip() for b in g.get("backends", "file").split(",") if b.strip()] order = [b.strip() for b in g.get("backends", "file").split(",") if b.strip()]
name = None result = (None, None)
for b in order: for b in order:
fn = _BACKENDS.get(b) fn = _BACKENDS.get(b)
if not fn: if not fn:
@ -358,19 +396,25 @@ def resolve(caller_id, log=print):
continue continue
sect = cp[b] if cp.has_section(b) else {} sect = cp[b] if cp.has_section(b) else {}
try: try:
name = fn(sect, num, n, log) hit = fn(sect, num, n, log)
except Exception as e: except Exception as e:
log("contacts backend %s crashed: %s" % (b, e)) log("contacts backend %s crashed: %s" % (b, e))
name = None hit = None
if isinstance(hit, tuple):
name, email = hit
else:
name, email = hit, None # legacy backends returned name only
if name: if name:
log("contacts: %s -> %s (via %s)" % (num, name, b)) result = (name, email)
log("contacts: %s -> %s (via %s)%s"
% (num, name, b, (" email=%s" % email if email else "")))
break break
cache.put(key, name) # cache misses too (name=None) cache.put(key, result) # cache the (name, email) tuple
return name return result
except Exception as e: except Exception as e:
log("contacts resolve error: %s" % e) log("contacts resolve error: %s" % e)
return None return (None, None)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""One-shot: migrate the SQLite voicemail store into MySQL.
Reads the existing /var/lib/vm-transcribe/voicemail.db (sqlite) and inserts
every message/setting/session row into the MySQL `asterisk` DB, skipping
duplicates on (mailbox, origtime, callerid). Idempotent.
Run as asterisk:
sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_migrate_sqlite_to_mysql.py
"""
import json
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_store
SRC = os.environ.get("VM_DB", "/var/lib/vm-transcribe/voicemail.db")
def main():
if not os.path.exists(SRC):
print("no sqlite source at", SRC, "- nothing to migrate")
return
src = sqlite3.connect(SRC)
src.row_factory = sqlite3.Row
dst = vm_store.connect() # MySQL (via secret)
print("migrating from", SRC, "-> MySQL")
cols = [d[1] for d in src.execute("PRAGMA table_info(messages)")]
n = 0
for r in src.execute("SELECT * FROM messages"):
try:
vals = []
for c in cols:
v = r[c]
if c in ("intents", "numbers") and isinstance(v, str):
try:
json.loads(v)
except Exception:
v = json.dumps([])
vals.append(v)
ph = ",".join(["%s"] * len(cols))
dst.execute(
"INSERT IGNORE INTO messages (%s) VALUES (%s)" %
(",".join(cols), ph), vals)
n += 1
except Exception as e:
print(" skip msg id", r["id"], ":", e)
dst.commit()
print("messages scanned: %d" % n)
for tbl in ("settings", "sessions"):
try:
c = [d[1] for d in src.execute("PRAGMA table_info(%s)" % tbl)]
except Exception:
continue
k = 0
for r in src.execute("SELECT * FROM %s" % tbl):
try:
dst.execute(
"INSERT IGNORE INTO %s (%s) VALUES (%s)" %
(tbl, ",".join(c), ",".join(["%s"] * len(c))),
[r[x] for x in c])
k += 1
except Exception as e:
print(" skip %s row:" % tbl, e)
dst.commit()
print("%s migrated: %d" % (tbl, k))
# quick count check
total = dst.execute("SELECT COUNT(*) c FROM messages").fetchone()["c"]
print("MySQL messages total: %d" % total)
if __name__ == "__main__":
main()

View File

@ -1,66 +1,114 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
SQLite store for voicemail transcripts, shared by the mailcmd hook and the Store for voicemail transcripts, shared by the mailcmd hook and the web app.
web app.
Backends:
* MySQL (default when VM_MYSQL_* env vars are set) - a single shared
`asterisk` database, also used by the Asterisk CDR. Credentials come from
/opt/vm-transcribe/db_secret (mode 640 root:root) or VM_MYSQL_* env vars.
* SQLite (fallback when no MySQL config) - the original file-based store.
The public API (connect, add_message, get_setting, get_settings, set_setting,
store_audio, audio_path, truthy, USER_SETTINGS) is backend-agnostic. The
connection object returned by connect() mimics the sqlite3 cursor surface the
callers use: .execute()/.executemany(), .commit(), .close(), .fetchone(),
.fetchall(), lastrowid, and row['col'] access.
Design notes: Design notes:
* WAL mode, because the mailcmd process writes while the web app reads. * Audio is copied into a content-addressed store, not referenced from the
* The audio is copied into a content-addressed store rather than referencing Asterisk spool (which renumbers msgNNNN on delete).
the Asterisk spool, since Asterisk renumbers msgNNNN files whenever a * Every write is idempotent on (mailbox, origtime, callerid).
message is deleted - a stored path would silently point at the wrong
recording. The spool path is kept only as a hint for delete-on-disk.
* Every write is idempotent on (mailbox, origtime, callerid) so a re-run of
the importer cannot duplicate rows.
""" """
import hashlib import hashlib
import json import json
import os import os
import sqlite3
import time import time
import pymysql
# --------------------------------------------------------------------- config
DB_PATH = os.environ.get("VM_DB", "/var/lib/vm-transcribe/voicemail.db") DB_PATH = os.environ.get("VM_DB", "/var/lib/vm-transcribe/voicemail.db")
AUDIO_DIR = os.environ.get("VM_AUDIO_DIR", "/var/lib/vm-transcribe/audio") AUDIO_DIR = os.environ.get("VM_AUDIO_DIR", "/var/lib/vm-transcribe/audio")
SECRET_PATH = os.environ.get("VM_DB_SECRET", "/opt/vm-transcribe/db_secret")
def _mysql_config():
"""Load MySQL connection params from the secret file or env."""
cfg = {
"host": os.environ.get("VM_MYSQL_HOST", "localhost"),
"user": os.environ.get("VM_MYSQL_USER", "asterisk"),
"password": os.environ.get("VM_MYSQL_PASSWORD", ""),
"db": os.environ.get("VM_MYSQL_DB", "asterisk"),
}
if not cfg["password"] and os.path.exists(SECRET_PATH):
for line in open(SECRET_PATH, encoding="utf-8", errors="replace"):
line = line.strip()
if "=" not in line or line.startswith("#"):
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip()
if k == "MYSQL_HOST":
cfg["host"] = v
elif k == "MYSQL_USER":
cfg["user"] = v
elif k == "MYSQL_PASSWORD":
cfg["password"] = v
elif k == "MYSQL_DB":
cfg["db"] = v
return cfg
def _use_mysql():
# Explicit switch: VM_STORE=mysql|sqlite. Otherwise auto-detect from secret.
force = os.environ.get("VM_STORE", "").lower()
if force == "sqlite":
return False
if force == "mysql":
return True
return os.path.exists(SECRET_PATH) or bool(os.environ.get("VM_MYSQL_PASSWORD"))
# ----------------------------------------------------------------- schema
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
mailbox TEXT NOT NULL, mailbox VARCHAR(64) NOT NULL,
context TEXT DEFAULT 'default', context VARCHAR(64) DEFAULT 'default',
folder TEXT DEFAULT 'INBOX', folder VARCHAR(64) DEFAULT 'INBOX',
callerid TEXT, callerid TEXT,
contact_name TEXT, contact_name TEXT,
origtime INTEGER, contact_email TEXT,
duration INTEGER, origtime BIGINT,
transcript TEXT, duration INT,
summary TEXT, transcript MEDIUMTEXT,
summary MEDIUMTEXT,
intents TEXT, intents TEXT,
numbers TEXT, numbers TEXT,
audio_sha TEXT, audio_sha VARCHAR(64),
audio_ext TEXT DEFAULT 'wav', audio_ext VARCHAR(16) DEFAULT 'wav',
spool_path TEXT, spool_path TEXT,
is_read INTEGER DEFAULT 0, is_read TINYINT DEFAULT 0,
created_at INTEGER, created_at BIGINT,
UNIQUE (mailbox, origtime, callerid) UNIQUE KEY uq_msg (mailbox, origtime, callerid),
); KEY idx_msg_mailbox (mailbox, origtime)
CREATE INDEX IF NOT EXISTS idx_msg_mailbox ON messages (mailbox, origtime DESC); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
mailbox TEXT NOT NULL, mailbox VARCHAR(64) NOT NULL,
key TEXT NOT NULL, `key` VARCHAR(64) NOT NULL,
value TEXT, value TEXT,
PRIMARY KEY (mailbox, key) PRIMARY KEY (mailbox, `key`)
); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY, token VARCHAR(64) PRIMARY KEY,
mailbox TEXT NOT NULL, mailbox VARCHAR(64) NOT NULL,
created_at INTEGER, created_at BIGINT,
expires_at INTEGER expires_at BIGINT
); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""" """
# Settings a mailbox user is allowed to change, with defaults.
USER_SETTINGS = { USER_SETTINGS = {
"email_enabled": ("yes", "Send an email notification"), "email_enabled": ("yes", "Send an email notification"),
"email_address": ("", "Override the notification email address"), "email_address": ("", "Override the notification email address"),
@ -75,19 +123,122 @@ USER_SETTINGS = {
} }
# ------------------------------------------------------------------ mysql con
class _MySQLCon:
"""sqlite3-compatible wrapper around a PyMySQL connection."""
def __init__(self, cx):
self._cx = cx
self._cur = cx.cursor(pymysql.cursors.DictCursor)
def execute(self, sql, params=()):
# Translate sqlite placeholders (? ) to mysql (%s). Escape any literal %
# (e.g. LIKE patterns) so PyMySQL's mogrify doesn't treat them as
# format chars, without breaking the %s placeholders themselves.
_PH = "\x00__PH__\x00"
sql = sql.replace("%s", _PH)
sql = sql.replace("%", "%%")
sql = sql.replace(_PH, "%s")
sql = sql.replace("?", "%s")
sql = sql.replace("INSERT OR IGNORE", "INSERT IGNORE")
sql = sql.replace("AUTOINCREMENT", "AUTO_INCREMENT")
self._cur.execute(sql, _params(params))
return self
def executemany(self, sql, seq):
sql = sql.replace("?", "%s").replace("INSERT OR IGNORE", "INSERT IGNORE")
self._cur.executemany(sql, [_params(p) for p in seq])
return self
@property
def lastrowid(self):
return self._cur.lastrowid
def fetchone(self):
return _Row(self._cur.fetchone())
def fetchall(self):
return [_Row(r) for r in self._cur.fetchall()]
def commit(self):
self._cx.commit()
def rollback(self):
self._cx.rollback()
def close(self):
try:
self._cur.close()
except Exception:
pass
self._cx.close()
class _Row:
"""Dict-like row supporting row['col'] and row.col access."""
def __init__(self, row):
self._row = row or {}
def __getitem__(self, k):
if isinstance(k, int):
try:
return list(self._row.values())[k]
except IndexError:
raise IndexError(k)
return self._row.get(k)
def __contains__(self, k):
return k in self._row
def get(self, k, d=None):
return self._row.get(k, d)
def __repr__(self):
return repr(self._row)
def _params(p):
"""Coerce None/JSON-friendly values; keep tuples as-is for mysql."""
return p
# ---------------------------------------------------------------- connect
def connect(path=None): def connect(path=None):
if _use_mysql():
cfg = _mysql_config()
cx = pymysql.connect(
host=cfg["host"], user=cfg["user"], password=cfg["password"],
database=cfg["db"], charset="utf8mb4", autocommit=False,
connect_timeout=10)
con = _MySQLCon(cx)
for stmt in SCHEMA.split(";"):
s = stmt.strip()
if s:
try:
con._cur.execute(s)
except Exception as e:
# ignore "already exists" so a re-run is safe
if "already exists" not in str(e).lower():
raise
return con
# --- SQLite fallback (original behaviour) ---
import sqlite3
p = os.path.abspath(path or DB_PATH) p = os.path.abspath(path or DB_PATH)
os.makedirs(os.path.dirname(p), exist_ok=True) os.makedirs(os.path.dirname(p), exist_ok=True)
con = sqlite3.connect(p, timeout=20) con = sqlite3.connect(p, timeout=20)
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL") con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA busy_timeout=10000") con.execute("PRAGMA busy_timeout=10000")
con.executescript(SCHEMA) con.executescript(SCHEMA.replace("AUTO_INCREMENT", "AUTOINCREMENT")
.replace("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ""))
cols = {r[1] for r in con.execute("PRAGMA table_info(messages)")}
if "contact_email" not in cols:
con.execute("ALTER TABLE messages ADD COLUMN contact_email TEXT")
return con return con
def store_audio(audio_bytes, ext="wav", audio_dir=None): def store_audio(audio_bytes, ext="wav", audio_dir=None):
"""Content-addressed write. Returns the sha256 hex digest."""
d = audio_dir or AUDIO_DIR d = audio_dir or AUDIO_DIR
sha = hashlib.sha256(audio_bytes).hexdigest() sha = hashlib.sha256(audio_bytes).hexdigest()
sub = os.path.join(d, sha[:2]) sub = os.path.join(d, sha[:2])
@ -106,30 +257,31 @@ def audio_path(sha, ext="wav", audio_dir=None):
return os.path.join(d, sha[:2], "%s.%s" % (sha, ext)) return os.path.join(d, sha[:2], "%s.%s" % (sha, ext))
def add_message(con, mailbox, callerid=None, contact_name=None, origtime=None, def add_message(con, mailbox, callerid=None, contact_name=None, contact_email=None,
origtime=None,
duration=None, transcript="", summary="", intents=None, duration=None, transcript="", summary="", intents=None,
numbers=None, audio_bytes=None, audio_ext="wav", numbers=None, audio_bytes=None, audio_ext="wav",
spool_path=None, context="default", folder="INBOX"): spool_path=None, context="default", folder="INBOX"):
sha = store_audio(audio_bytes, audio_ext) if audio_bytes else None sha = store_audio(audio_bytes, audio_ext) if audio_bytes else None
now = int(time.time()) now = int(time.time())
cur = con.execute( con.execute(
"""INSERT OR IGNORE INTO messages """INSERT IGNORE INTO messages
(mailbox, context, folder, callerid, contact_name, origtime, duration, (mailbox, context, folder, callerid, contact_name, contact_email,
origtime, duration,
transcript, summary, intents, numbers, audio_sha, audio_ext, transcript, summary, intents, numbers, audio_sha, audio_ext,
spool_path, created_at) spool_path, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(str(mailbox), context, folder, callerid, contact_name, (str(mailbox), context, folder, callerid, contact_name, contact_email,
int(origtime or now), duration, transcript, summary, int(origtime or now), duration, transcript, summary,
json.dumps(intents or []), json.dumps(numbers or []), json.dumps(intents or []), json.dumps(numbers or []),
sha, audio_ext, spool_path, now)) sha, audio_ext, spool_path, now))
con.commit() con.commit()
return cur.lastrowid
def get_setting(con, mailbox, key, default=None): def get_setting(con, mailbox, key, default=None):
row = con.execute("SELECT value FROM settings WHERE mailbox=? AND key=?", row = con.execute("SELECT value FROM settings WHERE mailbox=%s AND `key`=%s",
(str(mailbox), key)).fetchone() (str(mailbox), key)).fetchone()
if row is not None: if row is not None and row["value"] is not None:
return row["value"] return row["value"]
if default is not None: if default is not None:
return default return default
@ -138,7 +290,7 @@ def get_setting(con, mailbox, key, default=None):
def get_settings(con, mailbox): def get_settings(con, mailbox):
out = {k: v[0] for k, v in USER_SETTINGS.items()} out = {k: v[0] for k, v in USER_SETTINGS.items()}
for r in con.execute("SELECT key, value FROM settings WHERE mailbox=?", for r in con.execute("SELECT `key`, value FROM settings WHERE mailbox=%s",
(str(mailbox),)): (str(mailbox),)):
if r["key"] in USER_SETTINGS: if r["key"] in USER_SETTINGS:
out[r["key"]] = r["value"] out[r["key"]] = r["value"]
@ -148,8 +300,9 @@ def get_settings(con, mailbox):
def set_setting(con, mailbox, key, value): def set_setting(con, mailbox, key, value):
if key not in USER_SETTINGS: if key not in USER_SETTINGS:
raise KeyError("unknown setting %r" % key) raise KeyError("unknown setting %r" % key)
con.execute("""INSERT INTO settings (mailbox, key, value) VALUES (?,?,?) con.execute(
ON CONFLICT(mailbox, key) DO UPDATE SET value=excluded.value""", """INSERT INTO settings (mailbox, `key`, value) VALUES (%s,%s,%s)
ON DUPLICATE KEY UPDATE value=VALUES(value)""",
(str(mailbox), key, value)) (str(mailbox), key, value))
con.commit() con.commit()