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

20
tests/make_test_mail.py Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Build a fake Asterisk voicemail notification (as sendmail -t would get it)."""
import sys
from email.message import EmailMessage
wav = sys.argv[1]
to = sys.argv[2]
m = EmailMessage()
m["From"] = "voicemail@txt3.net"
m["To"] = to
m["Subject"] = "New message 3 in mailbox 1001"
m.set_content(
"Dear Jamie:\n\n\tjust wanted to let you know you were just left a 0:37 long message "
"(number 3)\nin mailbox 1001 from Dave Roberts <07941223856>, on Thu, 13 Aug 2026 "
"07:12:00, so you might\nwant to check it when you get a chance. Thanks!\n\n"
"\t\t\t\t--Asterisk\n")
with open(wav, "rb") as fh:
m.add_attachment(fh.read(), maintype="audio", subtype="x-wav", filename="msg0003.wav")
sys.stdout.buffer.write(m.as_bytes())

113
tests/test_contacts.py Normal file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Offline tests for vm_contacts: vCard/CSV parsing, digit matching, cache."""
import os, sys, tempfile, json
sys.path.insert(0, "/home/jp/asterisk-vm")
d = tempfile.mkdtemp()
VCF = """BEGIN:VCARD
VERSION:3.0
FN:Dave Roberts
TEL;TYPE=CELL:+44 7941 223856
END:VCARD
BEGIN:VCARD
VERSION:3.0
FN:Alice Smith
TEL;TYPE=WORK:020 7946 0018
TEL;TYPE=CELL:07700 900123
END:VCARD
BEGIN:VCARD
VERSION:3.0
FN:No Number Person
END:VCARD
"""
vcf = os.path.join(d, "c.vcf"); open(vcf, "w").write(VCF)
CSV = """Name,Given Name,Family Name,Phone 1 - Type,Phone 1 - Value
Bob Jones,Bob,Jones,Mobile,+1 (555) 010-9876
Carol White,Carol,White,Mobile,07123 456789 ::: 02012345678
"""
csvp = os.path.join(d, "c.csv"); open(csvp, "w").write(CSV)
cache = os.path.join(d, "cache.json")
def write_conf(path_val, backends="file"):
c = os.path.join(d, "contacts.conf")
open(c, "w").write(f"""[contacts]
enabled = yes
backends = {backends}
cache_path = {cache}
cache_ttl = 86400
match_digits = 9
[file]
path = {path_val}
""")
os.environ["VM_CONTACTS_CONF"] = c
return c
import importlib
import vm_contacts as C
def fresh(path_val, backends="file"):
write_conf(path_val, backends)
if os.path.exists(cache): os.unlink(cache)
importlib.reload(C)
return C
print("== extract_number")
for s in ['Dave Roberts <07941223856>', '"Alice" <+447941223856>', '07700900123', 'unknown', '']:
print(" %-30r -> %r" % (s, C.extract_number(s)))
print("\n== vCard lookup, various formats of the SAME number")
c = fresh(vcf)
for s in ["<07941223856>", "<+447941223856>", "<447941223856>", "Dave <7941223856>"]:
print(" %-24s -> %r" % (s, c.resolve(s, log=lambda m: None)))
print("\n== vCard: second number on a multi-TEL contact")
c = fresh(vcf)
print(" Alice work 02079460018 ->", c.resolve("<02079460018>", log=lambda m: None))
c = fresh(vcf)
print(" Alice cell 07700900123 ->", c.resolve("<07700900123>", log=lambda m: None))
print("\n== unknown number -> None (and cached as a miss)")
c = fresh(vcf)
print(" ->", c.resolve("<07999999999>", log=lambda m: None))
print(" cache contents:", json.load(open(cache)))
print("\n== CSV backend (Google CSV export format, ::: multi-value)")
c = fresh(csvp)
print(" Bob 5550109876 ->", c.resolve("<+15550109876>", log=lambda m: None))
c = fresh(csvp)
print(" Carol 07123456789 ->", c.resolve("<07123456789>", log=lambda m: None))
c = fresh(csvp)
print(" Carol 2nd num 02012345678 ->", c.resolve("<02012345678>", log=lambda m: None))
print("\n== disabled / missing file / no number")
c = fresh(os.path.join(d, "nope.vcf"))
print(" missing file ->", c.resolve("<07941223856>", log=lambda m: None))
open(os.environ["VM_CONTACTS_CONF"], "a").write("\n")
w = os.path.join(d, "off.conf")
open(w, "w").write("[contacts]\nenabled = no\nbackends = file\n")
os.environ["VM_CONTACTS_CONF"] = w; importlib.reload(C)
print(" disabled ->", C.resolve("<07941223856>", log=lambda m: None))
c = fresh(vcf)
print(" empty callerid ->", c.resolve("", log=lambda m: None))
print("\n== carddav pointed at google must refuse app-password auth")
w2 = os.path.join(d, "cd.conf")
open(w2, "w").write(f"""[contacts]
enabled = yes
backends = carddav
cache_path = {os.path.join(d,'c2.json')}
match_digits = 9
[carddav]
url = https://www.google.com/carddav/v1/principals/me/lists/default/
username = me@gmail.com
app_password = abcdefghijklmnop
""")
os.environ["VM_CONTACTS_CONF"] = w2; importlib.reload(C)
msgs = []
print(" ->", C.resolve("<07941223856>", log=msgs.append))
for m in msgs: print(" [log]", m)

67
tests/test_telegram.py Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Offline tests for vm_telegram: config routing + caption building + opus."""
import os, sys, tempfile
sys.path.insert(0, "/home/jp/asterisk-vm")
CONF = """
[telegram]
enabled = yes
token = 111:AAA
default_chat_id = 999
send_audio = yes
send_transcript = yes
timeout = 20
[mailbox:1001]
chat_id = 123456789
[mailbox:1002]
chat_id = 5551, 5552
send_transcript = no
[mailbox:1003]
chat_id = -1001234567890
send_audio = no
"""
tf = tempfile.NamedTemporaryFile("w", suffix=".conf", delete=False)
tf.write(CONF); tf.close()
os.environ["VM_TG_CONF"] = tf.name
import vm_telegram as T
print("== routing")
for mb in ("1001", "1002", "1003", "1099", None):
r = T.load_route(mb)
if r is None:
print(" mailbox %-5s -> no route" % mb)
else:
print(" mailbox %-5s -> chats=%s audio=%s transcript=%s"
% (mb, r.chat_ids, r.send_audio, r.send_transcript))
print("\n== disabled master switch")
open(tf.name, "w").write(CONF.replace("enabled = yes", "enabled = no"))
print(" ->", T.load_route("1001"))
print("== missing token")
open(tf.name, "w").write(CONF.replace("token = 111:AAA", "token ="))
print(" ->", T.load_route("1001"))
open(tf.name, "w").write(CONF)
print("\n== caption")
fields = {"from": "Dave Roberts <07941223856>", "mailbox": "1001",
"date": "Thu, 13 Aug 2026 07:12:00", "duration": "0:37", "msgnum": "3"}
cap = T.build_caption(fields,
"Hi, this is Dave Roberts calling from Meridian Plumbing about the invoice. "
"Could you please call me back as soon as possible on 07941-223856.",
["Call back requested", "Urgent", "Payment / invoice"], ["07941-223856"])
print(cap)
print(" [caption length %d / %d]" % (len(cap), T.CAPTION_LIMIT))
print("\n== caption clipping (5000-char summary)")
big = T.build_caption(fields, "word " * 1000, [], [])
print(" length %d (limit %d) ok=%s" % (len(big), T.CAPTION_LIMIT, len(big) <= T.CAPTION_LIMIT))
print("\n== opus transcode")
wav = open("/home/jp/asterisk-vm/test_vm.wav", "rb").read()
ogg = T.to_voice_ogg(wav, ".wav")
print(" wav %d bytes -> ogg %s bytes" % (len(wav), len(ogg) if ogg else None))
print(" magic:", ogg[:4] if ogg else None)
os.unlink(tf.name)