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

67
src/vm_auth.py Normal file
View File

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
Voicemail portal - parses Asterisk voicemail.conf for mailbox auth.
voicemail.conf mailbox lines look like:
7940 => 5159,Jamie,jp@txt3.com ; 01273 805515
mailbox => password,name,email,pager,options
"""
import os
import re
VM_CONF = os.environ.get("VM_ASTERISK_CONF", "/etc/asterisk/voicemail.conf")
# Sections that are not mailbox contexts
_NON_CONTEXT = {"general", "zonemessages"}
def parse_mailboxes(path=None):
"""Return {mailbox: {"pin","name","email","context"}}."""
p = path or VM_CONF
out = {}
context = "default"
try:
with open(p, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith(";") or line.startswith("#"):
continue
m = re.match(r"^\[([^\]]+)\]", line)
if m:
context = m.group(1).strip()
continue
if context in _NON_CONTEXT:
continue
m = re.match(r"^(\d+)\s*=>\s*(.+)$", line)
if not m:
continue
mbox, rest = m.group(1), m.group(2)
rest = rest.split(";", 1)[0].strip() # strip trailing comment
parts = [x.strip() for x in rest.split(",")]
pin = parts[0] if parts else ""
name = parts[1] if len(parts) > 1 else mbox
mail = parts[2] if len(parts) > 2 else ""
out[mbox] = {"pin": pin, "name": name, "email": mail,
"context": context}
except FileNotFoundError:
pass
return out
def check_login(mailbox, pin, path=None):
"""Constant-time-ish PIN check. Returns the mailbox dict or None."""
import hmac
boxes = parse_mailboxes(path)
info = boxes.get(str(mailbox).strip())
if not info or not info.get("pin"):
return None
if hmac.compare_digest(str(info["pin"]), str(pin).strip()):
return info
return None
if __name__ == "__main__":
for mb, i in parse_mailboxes().items():
print("%-8s %-18s %-28s ctx=%s pin=%s"
% (mb, i["name"], i["email"], i["context"], "*" * len(i["pin"])))

334
src/vm_contacts.py Normal file
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)))

229
src/vm_import.py Normal file
View File

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Backfill existing Asterisk voicemails into the portal database.
Walks the voicemail spool, reads each message's .txt metadata, transcribes the
recording with faster-whisper, summarises it, resolves the caller against
contacts, and inserts it into the same SQLite store the live mailcmd writes to.
Safe to re-run: rows are unique on (mailbox, origtime, callerid), so an
interrupted run resumes rather than duplicating. Already-imported messages are
skipped without being transcribed again (the expensive part).
Usage (run as the asterisk user so it can read the spool):
vm_import.py --dry-run # show what would be done
vm_import.py # import everything
vm_import.py --mailbox 7940 # one mailbox
vm_import.py --folder INBOX # one folder
vm_import.py --limit 10 # first N (useful for a trial run)
vm_import.py --no-transcribe # metadata + audio only, no whisper
vm_import.py --reverse # newest first
"""
import argparse
import configparser
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SPOOL = os.environ.get("VM_SPOOL", "/var/spool/asterisk/voicemail")
AUDIO_EXTS = (".wav", ".WAV", ".gsm", ".wav49", ".ogg", ".mp3")
def log(msg):
print(msg, flush=True)
def parse_info(path):
"""Parse an Asterisk msgNNNN.txt message information file."""
out = {}
try:
cp = configparser.ConfigParser(strict=False, inline_comment_prefixes=None)
with open(path, encoding="utf-8", errors="replace") as fh:
text = fh.read()
cp.read_string(text)
if cp.has_section("message"):
out = dict(cp["message"])
except Exception:
# fall back to a naive key=value scrape
try:
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if "=" in line and not line.strip().startswith(";"):
k, v = line.split("=", 1)
out[k.strip()] = v.strip()
except Exception:
pass
return out
def find_messages(spool, want_mailbox=None, want_folder=None):
"""Yield (context, mailbox, folder, basepath, audio_path, info_path)."""
if not os.path.isdir(spool):
return
for context in sorted(os.listdir(spool)):
cdir = os.path.join(spool, context)
if not os.path.isdir(cdir):
continue
for mailbox in sorted(os.listdir(cdir)):
if want_mailbox and mailbox != want_mailbox:
continue
mdir = os.path.join(cdir, mailbox)
if not os.path.isdir(mdir):
continue
for folder in sorted(os.listdir(mdir)):
if folder in ("tmp",):
continue
if want_folder and folder != want_folder:
continue
fdir = os.path.join(mdir, folder)
if not os.path.isdir(fdir):
continue
# group by msgNNNN stem
stems = set()
for fn in os.listdir(fdir):
m = re.match(r"^(msg\d+)\.", fn)
if m:
stems.add(m.group(1))
for stem in sorted(stems):
base = os.path.join(fdir, stem)
audio = None
for ext in AUDIO_EXTS:
if os.path.exists(base + ext):
audio = base + ext
break
info = base + ".txt"
if audio:
yield (context, mailbox, folder, base, audio,
info if os.path.exists(info) else None)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--spool", default=SPOOL)
ap.add_argument("--mailbox")
ap.add_argument("--folder")
ap.add_argument("--limit", type=int)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--no-transcribe", action="store_true")
ap.add_argument("--reverse", action="store_true")
ap.add_argument("--model", default=os.environ.get("VM_WHISPER_MODEL", "base.en"))
args = ap.parse_args()
import vm_store
con = vm_store.connect()
items = list(find_messages(args.spool, args.mailbox, args.folder))
if args.reverse:
items.reverse()
log("found %d message(s) in %s" % (len(items), args.spool))
if not items:
return 0
# Which are already imported? Compare on (mailbox, origtime, callerid).
existing = set()
for r in con.execute("SELECT mailbox, origtime, callerid FROM messages"):
existing.add((str(r["mailbox"]), r["origtime"], r["callerid"]))
# Load whisper once, lazily - it is the slow part.
model = None
def transcribe(path):
nonlocal model
if model is None:
from faster_whisper import WhisperModel
log(" loading whisper model %s (cpu/int8)..." % args.model)
model = WhisperModel(
args.model, device="cpu", compute_type="int8",
cpu_threads=max(1, (os.cpu_count() or 2) - 1),
download_root=os.environ.get("VM_MODEL_CACHE",
"/opt/vm-transcribe/models"))
segs, _info = model.transcribe(
path, beam_size=1, vad_filter=True,
vad_parameters=dict(min_silence_duration_ms=500),
condition_on_previous_text=False)
return re.sub(r"\s+", " ", " ".join(s.text.strip() for s in segs)).strip()
# Reuse the live pipeline's summariser / tagger / contact lookup so
# backfilled messages look identical to new ones.
import vm_mailcmd
try:
import vm_contacts
except Exception:
vm_contacts = None
stats = {"imported": 0, "skipped": 0, "failed": 0, "no_speech": 0}
t0 = time.time()
todo = items[:args.limit] if args.limit else items
for i, (context, mailbox, folder, base, audio, info) in enumerate(todo, 1):
meta = parse_info(info) if info else {}
callerid = meta.get("callerid") or None
try:
origtime = int(meta.get("origtime") or 0) or int(os.path.getmtime(audio))
except Exception:
origtime = int(os.path.getmtime(audio))
try:
duration = int(float(meta.get("duration") or 0)) or None
except Exception:
duration = None
tag = "%s/%s/%s" % (mailbox, folder, os.path.basename(base))
if (str(mailbox), origtime, callerid) in existing:
stats["skipped"] += 1
continue
if args.dry_run:
log(" [%d/%d] WOULD IMPORT %-28s caller=%-22s %s"
% (i, len(todo), tag, callerid or "?",
time.strftime("%Y-%m-%d %H:%M", time.localtime(origtime))))
stats["imported"] += 1
continue
try:
transcript = "" if args.no_transcribe else transcribe(audio)
if not transcript:
stats["no_speech"] += 1
summary = vm_mailcmd.summarise(transcript) if transcript else ""
intents = vm_mailcmd.detect_intents(transcript) if transcript else []
numbers = vm_mailcmd.find_numbers(transcript) if transcript else []
contact = None
if vm_contacts and callerid:
try:
contact = vm_contacts.resolve(callerid, log=lambda m: None)
except Exception:
contact = None
with open(audio, "rb") as fh:
audio_bytes = fh.read()
vm_store.add_message(
con, mailbox, callerid=callerid, contact_name=contact,
origtime=origtime, duration=duration, transcript=transcript,
summary=summary, intents=intents, numbers=numbers,
audio_bytes=audio_bytes,
audio_ext=os.path.splitext(audio)[1].lstrip(".").lower() or "wav",
spool_path=audio, context=context, folder=folder)
existing.add((str(mailbox), origtime, callerid))
stats["imported"] += 1
log(" [%d/%d] %-28s %-22s %3ds %s"
% (i, len(todo), tag, (callerid or "?")[:22], duration or 0,
(summary[:60] + "...") if len(summary) > 60 else summary))
except Exception as e:
stats["failed"] += 1
log(" [%d/%d] FAILED %s: %s" % (i, len(todo), tag, e))
con.close()
el = time.time() - t0
log("\ndone in %dm%02ds - imported=%d skipped=%d no_speech=%d failed=%d"
% (el // 60, el % 60, stats["imported"], stats["skipped"],
stats["no_speech"], stats["failed"]))
return 0
if __name__ == "__main__":
sys.exit(main())

533
src/vm_mailcmd.py Executable file
View File

@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""
Asterisk voicemail mailcmd replacement.
Reads the RFC822 message Asterisk pipes to `sendmail -t` on stdin, then:
a) transcribes the attached voicemail audio with faster-whisper (CPU, int8)
b) produces a short extractive summary + callback-number / intent hints
c) rebuilds the message as multipart/mixed containing a
multipart/alternative (text/plain + styled text/html) and the original
audio attachment, and delivers it via SMTP on localhost:25.
Fail-safe: any error at all -> the ORIGINAL message is relayed unchanged, so a
voicemail notification is never lost. All diagnostics go to LOG_PATH.
"""
import email
import email.policy
import email.utils
import os
import re
import smtplib
import subprocess
import sys
import tempfile
import traceback
from datetime import datetime
from email.message import EmailMessage
from html import escape
# ------------------------------------------------------------------ settings
VENV_PY = "/opt/vm-transcribe/venv/bin/python3"
MODEL_SIZE = os.environ.get("VM_WHISPER_MODEL", "base.en")
MODEL_CACHE = os.environ.get("VM_MODEL_CACHE", "/opt/vm-transcribe/models")
SMTP_HOST = "localhost"
SMTP_PORT = 25
LOG_PATH = os.environ.get("VM_LOG", "/var/log/asterisk/vm_mailcmd.log")
MAX_SECONDS = 900 # ignore absurdly long recordings
BRAND = "Voicemail"
ACCENT = "#2f6fed"
def log(msg):
try:
with open(LOG_PATH, "a") as fh:
fh.write("%s %s\n" % (datetime.now().isoformat(timespec="seconds"), msg))
except Exception:
pass
# ------------------------------------------------------------- transcription
def transcribe(wav_path):
"""Return transcript text (may be '')."""
from faster_whisper import WhisperModel
model = WhisperModel(
MODEL_SIZE,
device="cpu",
compute_type="int8",
cpu_threads=max(1, (os.cpu_count() or 2) - 1),
download_root=MODEL_CACHE,
)
segments, info = model.transcribe(
wav_path,
beam_size=1,
vad_filter=True,
vad_parameters=dict(min_silence_duration_ms=500),
condition_on_previous_text=False,
)
parts = []
for seg in segments:
if seg.start > MAX_SECONDS:
break
parts.append(seg.text.strip())
text = " ".join(p for p in parts if p).strip()
text = re.sub(r"\s+", " ", text)
log("transcribed %.1fs audio, lang=%s, %d chars"
% (getattr(info, "duration", 0.0), getattr(info, "language", "?"), len(text)))
return text
# ----------------------------------------------------------------- summarise
_STOP = set("""a an and are as at be been but by for from had has have he her his i if in into is it
its me my not of on or our she so than that the their them then there these they this to was we were
what when which who will with would you your um uh yeah okay ok just like know really got get""".split())
_PHONE_RE = re.compile(
r"(?:(?:\+|00)\d{1,3}[ .\-]?)?(?:\(?\d{2,5}\)?[ .\-]?){2,5}\d{2,4}")
_SPOKEN_DIGITS = {
"zero": "0", "oh": "0", "one": "1", "two": "2", "three": "3", "four": "4",
"five": "5", "six": "6", "seven": "7", "eight": "8", "nine": "9",
"double": "", "triple": "",
}
_INTENTS = [
("Call back requested", r"\b(call (me|us|him|her|them)? ?back|give (me|us) a (call|ring|bell)|"
r"ring (me|us) back|get back to (me|us)|reach (me|us))\b"),
("Urgent", r"\b(urgent|asap|as soon as possible|emergency|immediately|straight away|"
r"right away|critical)\b"),
("Appointment", r"\b(appointment|meeting|schedule|reschedule|booking|book(ed)? (you )?in|"
r"confirm(ing)? (the|your)? ?(time|date|slot))\b"),
("Payment / invoice", r"\b(invoice|payment|pay(ing|ment)? ?(due|late)?|billing|account balance|"
r"overdue|quote|estimate)\b"),
("Delivery", r"\b(deliver(y|ies)?|parcel|package|courier|dispatch|shipment)\b"),
("Complaint / issue", r"\b(complaint|complain|problem|issue|not working|broken|fault|unhappy|"
r"disappointed)\b"),
("Cancellation", r"\b(cancel(l(ed|ing))?|can't make it|cannot make it|postpone)\b"),
("Sales / marketing", r"\b(special offer|promotion|no obligation|free quote|marketing|"
r"we noticed your website|SEO)\b"),
]
def _split_sentences(text):
parts = re.split(r"(?<=[.!?])\s+", text)
out = []
for p in parts:
p = p.strip()
if not p:
continue
# very long run-ons with no punctuation: chop on discourse markers
if len(p) > 220:
out.extend(s.strip() for s in re.split(r"\s+(?:and then|but|so|however)\s+", p) if s.strip())
else:
out.append(p)
return out
def find_numbers(text):
"""Callback numbers, both digit-written and spoken-out."""
found = []
for m in _PHONE_RE.finditer(text):
cand = m.group(0).strip(" .-")
digits = re.sub(r"\D", "", cand)
if 7 <= len(digits) <= 15:
found.append(cand)
# spoken digit runs: "oh seven nine one ..."
words = re.findall(r"[a-z]+", text.lower())
run, runs = [], []
for w in words:
if w in _SPOKEN_DIGITS:
run.append(_SPOKEN_DIGITS[w])
else:
if len(run) >= 7:
runs.append("".join(run))
run = []
if len(run) >= 7:
runs.append("".join(run))
found.extend(runs)
seen, uniq = set(), []
for f in found:
k = re.sub(r"\D", "", f)
if k and k not in seen:
seen.add(k)
uniq.append(f)
return uniq[:3]
def detect_intents(text):
low = text.lower()
return [label for label, pat in _INTENTS if re.search(pat, low)]
def summarise(text, max_sentences=3):
"""Frequency-scored extractive summary, original order preserved."""
sents = _split_sentences(text)
if not sents:
return ""
if len(sents) <= max_sentences:
return " ".join(sents)
freq = {}
for w in re.findall(r"[a-z']+", text.lower()):
if w in _STOP or len(w) < 3:
continue
freq[w] = freq.get(w, 0) + 1
if not freq:
return " ".join(sents[:max_sentences])
top = max(freq.values())
scored = []
for i, s in enumerate(sents):
words = [w for w in re.findall(r"[a-z']+", s.lower()) if w not in _STOP and len(w) >= 3]
if not words:
score = 0.0
else:
score = sum(freq.get(w, 0) / top for w in words) / (len(words) ** 0.5)
if i == 0:
score *= 1.35 # openers carry the reason for the call
if re.search(r"\d", s):
score *= 1.15 # numbers/dates matter
scored.append((score, i, s))
keep = sorted(sorted(scored, reverse=True)[:max_sentences], key=lambda t: t[1])
return " ".join(s for _, _, s in keep)
# --------------------------------------------------------------- MIME output
def header_val(msg, name, default=""):
v = msg.get(name)
return str(v) if v else default
def parse_vm_fields(body_text, subject):
"""Best-effort scrape of the Asterisk notification body for context."""
f = {}
m = re.search(r"in mailbox (\S+?)[ ,]", body_text)
if m:
f["mailbox"] = m.group(1)
m = re.search(r"from (.+?), on (.+?),? so you might", body_text, re.S)
if m:
f["from"] = m.group(1).strip()
f["date"] = m.group(2).strip().rstrip(",").replace("\n", " ")
else:
m = re.search(r"from (.+?), on (.+?),", body_text)
if m:
f["from"] = m.group(1).strip()
f["date"] = m.group(2).strip()
m = re.search(r"a (\d+:\d+) long message", body_text)
if m:
f["duration"] = m.group(1)
m = re.search(r"number (\d+)", body_text)
if m:
f["msgnum"] = m.group(1)
m = re.search(r"mailbox (\S+)", subject)
if m and "mailbox" not in f:
f["mailbox"] = m.group(1)
return f
def build_plain(fields, summary, intents, numbers, transcript):
L = []
L.append("NEW VOICEMAIL")
L.append("=" * 40)
meta = [("From", fields.get("from")), ("Mailbox", fields.get("mailbox")),
("Received", fields.get("date")), ("Duration", fields.get("duration")),
("Message", fields.get("msgnum"))]
for k, v in meta:
if v:
L.append("%-10s %s" % (k + ":", v))
L.append("")
L.append("SUMMARY")
L.append("-" * 40)
L.append(summary or "(no speech detected in this recording)")
if intents:
L.append("")
L.append("Tags: " + ", ".join(intents))
if numbers:
L.append("Callback number(s) heard: " + ", ".join(numbers))
L.append("")
L.append("FULL TRANSCRIPT")
L.append("-" * 40)
L.append(transcript or "(no speech detected)")
L.append("")
L.append("The original recording is attached.")
return "\n".join(L)
def build_html(fields, summary, intents, numbers, transcript):
def row(label, value):
if not value:
return ""
return (
'<tr>'
'<td style="padding:4px 14px 4px 0;font:600 12px/1.5 -apple-system,Segoe UI,Roboto,'
'Helvetica,Arial,sans-serif;color:#8a94a6;text-transform:uppercase;letter-spacing:.5px;'
'white-space:nowrap;vertical-align:top;">%s</td>'
'<td style="padding:4px 0;font:400 14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,'
'Arial,sans-serif;color:#1c2430;">%s</td></tr>' % (escape(label), escape(value))
)
chips = "".join(
'<span style="display:inline-block;margin:0 6px 6px 0;padding:4px 11px;border-radius:999px;'
'background:#eef3ff;color:%s;font:600 12px/1.4 -apple-system,Segoe UI,Roboto,Helvetica,'
'Arial,sans-serif;">%s</span>' % (ACCENT, escape(t)) for t in intents)
numbers_html = ""
if numbers:
links = " &nbsp;".join(
'<a href="tel:%s" style="color:%s;text-decoration:none;font-weight:600;">%s</a>'
% (escape(re.sub(r"[^\d+]", "", n)), ACCENT, escape(n)) for n in numbers)
numbers_html = (
'<div style="margin-top:14px;padding:12px 14px;background:#f6f8fc;border-left:3px solid %s;'
'border-radius:4px;font:400 14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,'
'sans-serif;color:#1c2430;">&#128222; Callback number heard: %s</div>' % (ACCENT, links))
transcript_html = escape(transcript or "(no speech detected)").replace("\n", "<br>")
summary_html = escape(summary or "(no speech detected in this recording)")
return """<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:24px 12px;background:#eef1f6;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0"
style="width:600px;max-width:100%%;border-collapse:collapse;background:#ffffff;
border-radius:12px;overflow:hidden;box-shadow:0 2px 10px rgba(20,30,50,.08);">
<tr><td style="background:linear-gradient(135deg,%(accent)s,#1b4bb8);padding:22px 28px;">
<div style="font:700 19px/1.3 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#fff;">
&#9993;&#65039; New %(brand)s</div>
<div style="font:400 13px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:#d9e3ff;margin-top:3px;">Transcribed and summarised automatically</div>
</td></tr>
<tr><td style="padding:22px 28px 6px 28px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
%(rows)s
</table>
</td></tr>
<tr><td style="padding:16px 28px 0 28px;">
<div style="font:700 12px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:#8a94a6;text-transform:uppercase;letter-spacing:.6px;">Summary</div>
<div style="margin-top:8px;padding:16px 18px;background:#f9fbff;border:1px solid #e3e9f5;
border-radius:8px;font:400 15px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,
sans-serif;color:#101828;">%(summary)s</div>
%(chipwrap)s
%(numbers)s
</td></tr>
<tr><td style="padding:22px 28px 0 28px;">
<div style="font:700 12px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:#8a94a6;text-transform:uppercase;letter-spacing:.6px;">Full transcript</div>
<div style="margin-top:8px;padding:16px 18px;background:#ffffff;border:1px solid #e8ecf3;
border-radius:8px;font:400 14px/1.7 -apple-system,Segoe UI,Roboto,Helvetica,Arial,
sans-serif;color:#39424e;">%(transcript)s</div>
</td></tr>
<tr><td style="padding:20px 28px 26px 28px;">
<div style="padding:12px 14px;background:#f3f5f9;border-radius:8px;
font:400 13px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:#5b6675;">&#127911; The original recording is attached to this email.</div>
</td></tr>
<tr><td style="padding:14px 28px;background:#fafbfd;border-top:1px solid #eceff5;
font:400 11px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
color:#98a1b0;">
Machine transcription may contain errors &mdash; listen to the recording if in doubt.
</td></tr>
</table>
</td></tr></table>
</body></html>""" % {
"accent": ACCENT,
"brand": escape(BRAND),
"rows": (row("From", fields.get("from")) + row("Mailbox", fields.get("mailbox")) +
row("Received", fields.get("date")) + row("Duration", fields.get("duration")) +
row("Message", fields.get("msgnum"))),
"summary": summary_html,
"chipwrap": ('<div style="margin-top:12px;">%s</div>' % chips) if chips else "",
"numbers": numbers_html,
"transcript": transcript_html,
}
# ---------------------------------------------------------------------- main
def relay(raw_bytes, envelope_from, rcpts):
dry = os.environ.get("VM_DRYRUN")
if dry:
with open(dry, "wb") as fh:
fh.write(raw_bytes if isinstance(raw_bytes, bytes) else raw_bytes.encode())
log("DRYRUN wrote %s (%d bytes) for %s" % (dry, len(raw_bytes), rcpts))
return
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as s:
s.sendmail(envelope_from, rcpts, raw_bytes)
def main():
raw = sys.stdin.buffer.read()
orig = email.message_from_bytes(raw, policy=email.policy.default)
rcpts = []
for h in ("To", "Cc", "Bcc"):
for addr in email.utils.getaddresses(orig.get_all(h, [])):
if addr[1]:
rcpts.append(addr[1])
env_from = (email.utils.parseaddr(header_val(orig, "From"))[1]
or "voicemail@localhost")
if not rcpts:
log("no recipients found; relaying original")
relay(raw, env_from, rcpts or [env_from])
return
try:
# ---- pull out audio + original text body
audio_part, body_text = None, ""
for part in orig.walk():
ctype = part.get_content_type()
if part.get_content_maintype() == "audio" or (
part.get_filename() or "").lower().endswith(
(".wav", ".gsm", ".mp3", ".ogg", ".WAV")):
if audio_part is None:
audio_part = part
elif ctype == "text/plain" and not body_text:
try:
body_text = part.get_content()
except Exception:
body_text = part.get_payload(decode=True).decode("utf-8", "replace")
if audio_part is None:
log("no audio attachment; relaying original")
relay(raw, env_from, rcpts)
return
audio_bytes = audio_part.get_payload(decode=True)
fname = audio_part.get_filename() or "voicemail.wav"
suffix = os.path.splitext(fname)[1] or ".wav"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tf:
tf.write(audio_bytes)
tmp_audio = tf.name
try:
src = tmp_audio
if suffix.lower() not in (".wav", ".mp3", ".ogg", ".flac", ".m4a"):
conv = tmp_audio + ".wav"
subprocess.run(["/usr/bin/sox", tmp_audio, "-r", "16000", "-c", "1", conv],
check=True, capture_output=True)
src = conv
transcript = transcribe(src)
finally:
for p in (tmp_audio, tmp_audio + ".wav"):
try:
os.unlink(p)
except OSError:
pass
fields = parse_vm_fields(body_text, header_val(orig, "Subject"))
# ---- caller-ID -> contact name (best-effort)
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_contacts
who = vm_contacts.resolve(fields.get("from", ""), log=log)
if who:
fields["contact"] = who
num = vm_contacts.extract_number(fields.get("from", ""))
fields["from"] = "%s (%s)" % (who, num) if num else who
except Exception:
log("contact lookup failed (non-fatal):\n" + traceback.format_exc())
summary = summarise(transcript)
intents = detect_intents(transcript)
numbers = find_numbers(transcript)
# ---- assemble multipart/mixed > multipart/alternative + attachment
out = EmailMessage()
for h in ("From", "To", "Cc", "Reply-To", "Date", "Message-ID",
"X-Asterisk-CallerID", "X-Asterisk-VM-Mailbox"):
if orig.get(h):
out[h] = orig[h]
if not out.get("From"):
out["From"] = env_from
subj_bits = []
if fields.get("from"):
subj_bits.append(fields["from"])
if fields.get("mailbox"):
subj_bits.append("mbox %s" % fields["mailbox"])
gist = (summary or transcript or "no speech detected").strip()
if len(gist) > 90:
gist = gist[:87].rsplit(" ", 1)[0] + "..."
out["Subject"] = "Voicemail%s: %s" % (
(" from " + subj_bits[0]) if subj_bits else "", gist)
out["X-Voicemail-Transcribed"] = "faster-whisper/%s" % MODEL_SIZE
if intents:
out["X-Voicemail-Tags"] = ", ".join(intents)
out.set_content(build_plain(fields, summary, intents, numbers, transcript))
out.add_alternative(build_html(fields, summary, intents, numbers, transcript),
subtype="html")
maintype, _, subtype = (audio_part.get_content_type() or "audio/x-wav").partition("/")
out.add_attachment(audio_bytes, maintype=maintype or "audio",
subtype=subtype or "x-wav", filename=fname)
relay(out.as_bytes(), env_from, rcpts)
log("sent enriched notification to %s (%d char transcript, tags=%s)"
% (",".join(rcpts), len(transcript), intents))
# ---- persist to the SQLite store for the web app (best-effort)
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_store
con = vm_store.connect()
origtime = None
d = orig.get("Date")
if d:
try:
origtime = int(email.utils.mktime_tz(email.utils.parsedate_tz(str(d))))
except Exception:
origtime = None
dur = None
if fields.get("duration") and ":" in fields["duration"]:
mm, ss = fields["duration"].split(":")[:2]
try:
dur = int(mm) * 60 + int(ss)
except ValueError:
dur = None
vm_store.add_message(
con, fields.get("mailbox") or "unknown",
callerid=fields.get("from"), contact_name=fields.get("contact"),
origtime=origtime, duration=dur, transcript=transcript,
summary=summary, intents=intents, numbers=numbers,
audio_bytes=audio_bytes,
audio_ext=(os.path.splitext(fname)[1].lstrip(".") or "wav"))
con.close()
log("stored message for mailbox %s in the web-app database"
% fields.get("mailbox"))
except Exception:
log("db store failed (non-fatal):\n" + traceback.format_exc())
# ---- Telegram: strictly best-effort, email is already delivered
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_telegram
vm_telegram.notify(
fields.get("mailbox"), fields, summary, intents, numbers,
transcript, audio_bytes=audio_bytes, audio_name=fname, log=log)
except Exception:
log("telegram notify failed (email was sent OK):\n" + traceback.format_exc())
except Exception:
log("FAILED, relaying original:\n" + traceback.format_exc())
try:
relay(raw, env_from, rcpts)
except Exception:
log("relay of original ALSO failed:\n" + traceback.format_exc())
sys.exit(1)
if __name__ == "__main__":
main()

158
src/vm_store.py Normal file
View File

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
SQLite store for voicemail transcripts, shared by the mailcmd hook and the
web app.
Design notes:
* WAL mode, because the mailcmd process writes while the web app reads.
* The audio is copied into a content-addressed store rather than referencing
the Asterisk spool, since Asterisk renumbers msgNNNN files whenever a
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 json
import os
import sqlite3
import time
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")
SCHEMA = """
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox TEXT NOT NULL,
context TEXT DEFAULT 'default',
folder TEXT DEFAULT 'INBOX',
callerid TEXT,
contact_name TEXT,
origtime INTEGER,
duration INTEGER,
transcript TEXT,
summary TEXT,
intents TEXT,
numbers TEXT,
audio_sha TEXT,
audio_ext TEXT DEFAULT 'wav',
spool_path TEXT,
is_read INTEGER DEFAULT 0,
created_at INTEGER,
UNIQUE (mailbox, origtime, callerid)
);
CREATE INDEX IF NOT EXISTS idx_msg_mailbox ON messages (mailbox, origtime DESC);
CREATE TABLE IF NOT EXISTS settings (
mailbox TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (mailbox, key)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
mailbox TEXT NOT NULL,
created_at INTEGER,
expires_at INTEGER
);
"""
# Settings a mailbox user is allowed to change, with defaults.
USER_SETTINGS = {
"email_enabled": ("yes", "Send an email notification"),
"email_address": ("", "Override the notification email address"),
"attach_audio": ("yes", "Attach the recording to the email"),
"telegram_enabled": ("no", "Send a Telegram DM"),
"telegram_chat_id": ("", "Telegram chat ID (message the bot first)"),
"telegram_audio": ("yes", "Include the recording as a Telegram voice note"),
"telegram_transcript":("yes", "Send the full transcript as a follow-up"),
"transcribe": ("yes", "Transcribe recordings to text"),
"summarise": ("yes", "Include an automatic summary"),
"contact_lookup": ("yes", "Resolve caller ID against contacts"),
}
def connect(path=None):
p = os.path.abspath(path or DB_PATH)
os.makedirs(os.path.dirname(p), exist_ok=True)
con = sqlite3.connect(p, timeout=20)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA busy_timeout=10000")
con.executescript(SCHEMA)
return con
def store_audio(audio_bytes, ext="wav", audio_dir=None):
"""Content-addressed write. Returns the sha256 hex digest."""
d = audio_dir or AUDIO_DIR
sha = hashlib.sha256(audio_bytes).hexdigest()
sub = os.path.join(d, sha[:2])
os.makedirs(sub, exist_ok=True)
dest = os.path.join(sub, "%s.%s" % (sha, ext))
if not os.path.exists(dest):
tmp = dest + ".tmp"
with open(tmp, "wb") as fh:
fh.write(audio_bytes)
os.replace(tmp, dest)
return sha
def audio_path(sha, ext="wav", audio_dir=None):
d = audio_dir or AUDIO_DIR
return os.path.join(d, sha[:2], "%s.%s" % (sha, ext))
def add_message(con, mailbox, callerid=None, contact_name=None, origtime=None,
duration=None, transcript="", summary="", intents=None,
numbers=None, audio_bytes=None, audio_ext="wav",
spool_path=None, context="default", folder="INBOX"):
sha = store_audio(audio_bytes, audio_ext) if audio_bytes else None
now = int(time.time())
cur = con.execute(
"""INSERT OR IGNORE INTO messages
(mailbox, context, folder, callerid, contact_name, origtime, duration,
transcript, summary, intents, numbers, audio_sha, audio_ext,
spool_path, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(str(mailbox), context, folder, callerid, contact_name,
int(origtime or now), duration, transcript, summary,
json.dumps(intents or []), json.dumps(numbers or []),
sha, audio_ext, spool_path, now))
con.commit()
return cur.lastrowid
def get_setting(con, mailbox, key, default=None):
row = con.execute("SELECT value FROM settings WHERE mailbox=? AND key=?",
(str(mailbox), key)).fetchone()
if row is not None:
return row["value"]
if default is not None:
return default
return USER_SETTINGS.get(key, ("", ""))[0]
def get_settings(con, mailbox):
out = {k: v[0] for k, v in USER_SETTINGS.items()}
for r in con.execute("SELECT key, value FROM settings WHERE mailbox=?",
(str(mailbox),)):
if r["key"] in USER_SETTINGS:
out[r["key"]] = r["value"]
return out
def set_setting(con, mailbox, key, value):
if key not in USER_SETTINGS:
raise KeyError("unknown setting %r" % key)
con.execute("""INSERT INTO settings (mailbox, key, value) VALUES (?,?,?)
ON CONFLICT(mailbox, key) DO UPDATE SET value=excluded.value""",
(str(mailbox), key, value))
con.commit()
def truthy(v):
return str(v).strip().lower() in ("1", "yes", "true", "on")

247
src/vm_telegram.py Normal file
View File

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Telegram delivery for Asterisk voicemail notifications.
Routing is driven by /opt/vm-transcribe/telegram.conf, re-read on every call so
edits take effect without restarting anything.
Delivery is strictly best-effort: the email has already been sent by the time
this runs, so every failure is logged and swallowed rather than raised.
"""
import configparser
import json
import mimetypes
import os
import re
import shutil
import subprocess
import tempfile
import urllib.parse
import urllib.request
from html import escape
CONF_PATH = os.environ.get("VM_TG_CONF", "/opt/vm-transcribe/telegram.conf")
API = "https://api.telegram.org/bot%s/%s"
# Telegram hard limits
CAPTION_LIMIT = 1024
MESSAGE_LIMIT = 4096
# --------------------------------------------------------------- config load
class Route:
__slots__ = ("chat_ids", "send_audio", "send_transcript", "timeout", "token")
def __init__(self, token, chat_ids, send_audio, send_transcript, timeout):
self.token = token
self.chat_ids = chat_ids
self.send_audio = send_audio
self.send_transcript = send_transcript
self.timeout = timeout
def _split_ids(raw):
return [c.strip() for c in (raw or "").replace(";", ",").split(",") if c.strip()]
def load_route(mailbox):
"""Return a Route for this mailbox, or None if Telegram is off/unmapped."""
if not os.path.exists(CONF_PATH):
return None
cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";"))
cp.read(CONF_PATH)
if not cp.has_section("telegram"):
return None
g = cp["telegram"]
if not g.getboolean("enabled", fallback=False):
return None
token = (g.get("token", "") or "").strip()
if not token:
return None
sect = "mailbox:%s" % mailbox if mailbox else None
m = cp[sect] if (sect and cp.has_section(sect)) else None
def val(key, fallback):
if m is not None and key in m:
return m[key]
return g.get(key, fallback)
def boolval(key, fallback):
if m is not None and key in m:
return m.getboolean(key, fallback=fallback)
return g.getboolean(key, fallback=fallback)
chat_ids = _split_ids(val("chat_id", "")) if m is not None else []
if not chat_ids:
chat_ids = _split_ids(g.get("default_chat_id", ""))
if not chat_ids:
return None
return Route(
token=token,
chat_ids=chat_ids,
send_audio=boolval("send_audio", True),
send_transcript=boolval("send_transcript", True),
timeout=int(val("timeout", "20") or 20),
)
# ------------------------------------------------------------------ HTTP bits
def _multipart(fields, files):
"""Build a multipart/form-data body. files = [(name, filename, bytes, ctype)]"""
boundary = "----vmtg%s" % os.urandom(12).hex()
out = bytearray()
for k, v in fields.items():
out += b"--%s\r\n" % boundary.encode()
out += b'Content-Disposition: form-data; name="%s"\r\n\r\n' % k.encode()
out += str(v).encode() + b"\r\n"
for name, fname, data, ctype in files:
out += b"--%s\r\n" % boundary.encode()
out += (b'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
% (name.encode(), fname.encode()))
out += b"Content-Type: %s\r\n\r\n" % ctype.encode()
out += data + b"\r\n"
out += b"--%s--\r\n" % boundary.encode()
return bytes(out), "multipart/form-data; boundary=%s" % boundary
def _call(route, method, fields, files=None, log=print):
url = API % (route.token, method)
try:
if files:
body, ctype = _multipart(fields, files)
else:
body = urllib.parse.urlencode(fields).encode()
ctype = "application/x-www-form-urlencoded"
req = urllib.request.Request(url, data=body, headers={"Content-Type": ctype})
with urllib.request.urlopen(req, timeout=route.timeout) as r:
resp = json.loads(r.read().decode("utf-8", "replace"))
if not resp.get("ok"):
log("telegram %s failed: %s" % (method, resp.get("description")))
return False
return True
except Exception as e:
log("telegram %s error: %s" % (method, e))
return False
# ----------------------------------------------------------------- formatting
def _clip(s, limit):
if len(s) <= limit:
return s
return s[: limit - 20].rsplit(" ", 1)[0] + "\n\n[...truncated]"
def build_caption(fields, summary, intents, numbers):
L = ["\U0001F4E7 <b>New voicemail</b>"]
if fields.get("from"):
L.append("\U0001F464 <b>From:</b> %s" % escape(fields["from"]))
meta = []
if fields.get("mailbox"):
meta.append("mailbox %s" % escape(fields["mailbox"]))
if fields.get("duration"):
meta.append(escape(fields["duration"]))
if fields.get("date"):
meta.append(escape(fields["date"]))
if meta:
L.append("\U0001F553 %s" % " \u00b7 ".join(meta))
L.append("")
L.append("<b>Summary</b>")
L.append(escape(summary or "(no speech detected in this recording)"))
if intents:
L.append("")
L.append("\U0001F3F7 " + " ".join("#" + re.sub(r"[^A-Za-z]+", "", t) for t in intents))
if numbers:
pretty = ", ".join(
'<a href="tel:%s">%s</a>' % (escape(re.sub(r"[^\d+]", "", n)), escape(n))
for n in numbers)
L.append("\U0001F4DE Callback: %s" % pretty)
return _clip("\n".join(L), CAPTION_LIMIT)
# ---------------------------------------------------------------- audio prep
def to_voice_ogg(audio_bytes, suffix, log=print):
"""Transcode to ogg/opus for a native Telegram voice note. None on failure."""
ff = shutil.which("ffmpeg") or "/usr/bin/ffmpeg"
if not os.path.exists(ff):
log("ffmpeg not found; sending original audio as a document")
return None
src = dst = None
try:
with tempfile.NamedTemporaryFile(suffix=suffix or ".wav", delete=False) as tf:
tf.write(audio_bytes)
src = tf.name
dst = src + ".ogg"
subprocess.run(
[ff, "-v", "error", "-y", "-i", src,
"-c:a", "libopus", "-b:a", "24k", "-ar", "48000", "-ac", "1",
"-application", "voip", dst],
check=True, capture_output=True, timeout=120)
with open(dst, "rb") as fh:
return fh.read()
except Exception as e:
log("opus transcode failed (%s); falling back to raw audio" % e)
return None
finally:
for p in (src, dst):
if p:
try:
os.unlink(p)
except OSError:
pass
# ----------------------------------------------------------------- entrypoint
def notify(mailbox, fields, summary, intents, numbers, transcript,
audio_bytes=None, audio_name="voicemail.wav", log=print):
"""Best-effort Telegram delivery. Returns number of chats notified."""
try:
route = load_route(mailbox)
except Exception as e:
log("telegram config error: %s" % e)
return 0
if route is None:
return 0
caption = build_caption(fields, summary, intents, numbers)
suffix = os.path.splitext(audio_name)[1] or ".wav"
voice = None
if route.send_audio and audio_bytes:
voice = to_voice_ogg(audio_bytes, suffix, log=log)
sent = 0
for chat_id in route.chat_ids:
ok = False
if route.send_audio and audio_bytes:
if voice is not None:
ok = _call(route, "sendVoice",
{"chat_id": chat_id, "caption": caption, "parse_mode": "HTML"},
files=[("voice", "voicemail.ogg", voice, "audio/ogg")], log=log)
if not ok:
ctype = mimetypes.guess_type(audio_name)[0] or "audio/wav"
ok = _call(route, "sendDocument",
{"chat_id": chat_id, "caption": caption, "parse_mode": "HTML"},
files=[("document", audio_name, audio_bytes, ctype)], log=log)
if not ok:
ok = _call(route, "sendMessage",
{"chat_id": chat_id, "text": caption, "parse_mode": "HTML",
"disable_web_page_preview": "true"}, log=log)
if ok and route.send_transcript and transcript and transcript.strip() != (summary or "").strip():
body = "\U0001F4DD <b>Full transcript</b>\n\n" + escape(transcript)
_call(route, "sendMessage",
{"chat_id": chat_id, "text": _clip(body, MESSAGE_LIMIT),
"parse_mode": "HTML", "disable_web_page_preview": "true"}, log=log)
if ok:
sent += 1
log("telegram: notified %d/%d chat(s) for mailbox %s"
% (sent, len(route.chat_ids), mailbox))
return sent

89
src/vm_tg_setup.py Normal file
View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
Helper: discover Telegram chat IDs, and send a test voicemail notification.
# 1. who has messaged the bot?
/opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_tg_setup.py ids
# 2. verify a route end-to-end (uses telegram.conf, real audio optional)
/opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_tg_setup.py test 1001
"""
import configparser
import json
import os
import sys
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vm_telegram as T
def token():
cp = configparser.ConfigParser(inline_comment_prefixes=("#", ";"))
cp.read(T.CONF_PATH)
tok = (cp.get("telegram", "token", fallback="") or "").strip()
if not tok:
sys.exit("No token set in %s" % T.CONF_PATH)
return tok
def cmd_ids():
tok = token()
url = T.API % (tok, "getUpdates")
with urllib.request.urlopen(url, timeout=20) as r:
d = json.loads(r.read().decode())
if not d.get("ok"):
sys.exit("API error: %s" % d.get("description"))
seen = {}
for u in d.get("result", []):
msg = u.get("message") or u.get("channel_post") or {}
ch = msg.get("chat") or {}
if ch.get("id") is not None:
name = " ".join(x for x in (ch.get("title"), ch.get("first_name"),
ch.get("last_name"),
("@" + ch["username"]) if ch.get("username") else None) if x)
seen[ch["id"]] = "%s [%s]" % (name or "?", ch.get("type"))
if not seen:
print("No chats found. Send your bot a message first (or /start), then re-run.")
print("Note: getUpdates returns nothing if a webhook is set, and only ~24h of history.")
return
print("Chat IDs that have talked to this bot:")
for cid, who in seen.items():
print(" chat_id = %-16s %s" % (cid, who))
def cmd_test(mailbox):
route = T.load_route(mailbox)
if route is None:
sys.exit("No route for mailbox %r (check enabled/token/chat_id in %s)"
% (mailbox, T.CONF_PATH))
print("Route: chats=%s audio=%s transcript=%s"
% (route.chat_ids, route.send_audio, route.send_transcript))
audio = None
for cand in ("/home/jp/asterisk-vm/test_vm.wav",):
if os.path.exists(cand):
audio = open(cand, "rb").read()
break
fields = {"from": "Test Caller <07700900123>", "mailbox": mailbox or "test",
"date": "now", "duration": "0:23", "msgnum": "1"}
n = T.notify(
mailbox, fields,
"This is a test of the voicemail Telegram delivery. If you can read this "
"and play the attached voice note, the route works.",
["Call back requested"], ["07700900123"],
"This is a test of the voicemail Telegram delivery. If you can read this "
"and play the attached voice note, the route works.",
audio_bytes=audio, audio_name="test.wav")
print("notified %d chat(s)" % n)
sys.exit(0 if n else 1)
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in ("ids", "test"):
sys.exit(__doc__)
if sys.argv[1] == "ids":
cmd_ids()
else:
cmd_test(sys.argv[2] if len(sys.argv) > 2 else None)

427
src/vm_web.py Normal file
View File

@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""
Voicemail portal - FastAPI app.
Mailbox users log in with their existing voicemail mailbox number + PIN from
/etc/asterisk/voicemail.conf, then can:
* list voicemails with transcript, summary and tags
* play or download the recording
* mark read / delete (DB row, stored audio, and the spool file if present)
* edit their own notification settings
Runs as the 'asterisk' user behind Apache. Sessions are signed cookies backed
by a DB table so logout/expiry is enforced server-side.
"""
import json
import os
import secrets
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fastapi import Cookie, FastAPI, Form, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from html import escape
import vm_auth
import vm_store
SESSION_HOURS = int(os.environ.get("VM_SESSION_HOURS", "12"))
COOKIE = "vm_session"
BASE = os.environ.get("VM_BASE_PATH", "") # e.g. "/voicemail" if sub-pathed
# Cookies are Secure by default (the app is served over HTTPS). Set
# VM_INSECURE_COOKIE=1 only for local plain-HTTP testing.
SECURE_COOKIE = os.environ.get("VM_INSECURE_COOKIE", "") not in ("1", "yes", "true")
app = FastAPI(title="Voicemail Portal", docs_url=None, redoc_url=None,
openapi_url=None)
# ----------------------------------------------------------------- sessions
def new_session(mailbox):
con = vm_store.connect()
tok = secrets.token_urlsafe(32)
now = int(time.time())
con.execute("INSERT INTO sessions (token, mailbox, created_at, expires_at)"
" VALUES (?,?,?,?)",
(tok, str(mailbox), now, now + SESSION_HOURS * 3600))
con.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
con.commit()
con.close()
return tok
def session_mailbox(token):
if not token:
return None
con = vm_store.connect()
r = con.execute("SELECT mailbox, expires_at FROM sessions WHERE token=?",
(token,)).fetchone()
con.close()
if not r or r["expires_at"] < time.time():
return None
return r["mailbox"]
def require(token):
mb = session_mailbox(token)
if not mb:
raise HTTPException(status_code=303, detail="login",
headers={"Location": BASE + "/login"})
return mb
# ------------------------------------------------------- brute-force lockout
# Voicemail PINs are short (often 4 digits) and this app is internet-facing,
# so failed logins are throttled per (mailbox, source IP). In-process state is
# fine: the service is a single uvicorn worker.
MAX_FAILS = int(os.environ.get("VM_MAX_FAILS", "5"))
LOCK_MINUTES = int(os.environ.get("VM_LOCK_MINUTES", "15"))
_fails = {} # (mailbox, ip) -> [count, first_ts]
def _lock_key(mailbox, ip):
return (str(mailbox).strip(), ip)
def _lock_check(mailbox, ip):
"""Return remaining lock time in minutes, or 0 if not locked."""
e = _fails.get(_lock_key(mailbox, ip))
if not e or e[0] < MAX_FAILS:
return 0
elapsed = time.time() - e[1]
if elapsed > LOCK_MINUTES * 60:
_fails.pop(_lock_key(mailbox, ip), None)
return 0
return max(1, int((LOCK_MINUTES * 60 - elapsed) // 60) + 1)
def _lock_fail(mailbox, ip):
k = _lock_key(mailbox, ip)
e = _fails.get(k)
now = time.time()
if not e or now - e[1] > LOCK_MINUTES * 60:
_fails[k] = [1, now]
else:
e[0] += 1
def _lock_clear(mailbox, ip):
_fails.pop(_lock_key(mailbox, ip), None)
# ------------------------------------------------------------------- layout
CSS = """
*{box-sizing:border-box}
body{margin:0;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,
Helvetica,Arial,sans-serif;background:#eef1f6;color:#1c2430}
a{color:#2f6fed}
header{background:linear-gradient(135deg,#2f6fed,#1b4bb8);color:#fff;
padding:16px 22px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
header h1{margin:0;font-size:18px;font-weight:700}
header .sp{flex:1}
header a{color:#dce6ff;text-decoration:none;font-size:14px;font-weight:600}
header a:hover{color:#fff;text-decoration:underline}
.wrap{max-width:880px;margin:22px auto;padding:0 14px}
.card{background:#fff;border:1px solid #e3e9f5;border-radius:12px;
padding:18px 20px;margin-bottom:14px;box-shadow:0 1px 4px rgba(20,30,50,.05)}
.meta{font-size:13px;color:#6b7686;margin-bottom:6px}
.who{font-weight:700;font-size:16px}
.sum{background:#f9fbff;border:1px solid #e3e9f5;border-radius:8px;
padding:12px 14px;margin:10px 0}
.tag{display:inline-block;background:#eef3ff;color:#2f6fed;border-radius:999px;
padding:3px 10px;font-size:12px;font-weight:600;margin:0 5px 5px 0}
audio{width:100%;margin-top:10px}
details{margin-top:8px}
summary{cursor:pointer;color:#2f6fed;font-size:14px;font-weight:600}
.tr{white-space:pre-wrap;color:#39424e;font-size:14px;margin-top:8px;
background:#fafbfd;padding:12px;border-radius:8px;border:1px solid #eceff5}
.row{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
button,.btn{font:600 14px/1 inherit;padding:9px 14px;border-radius:8px;
border:1px solid #d4dcea;background:#fff;color:#2b3442;cursor:pointer}
button:hover{background:#f4f7fd}
.danger{border-color:#f0c8c8;color:#b93a3a}
.danger:hover{background:#fdf3f3}
.primary{background:#2f6fed;border-color:#2f6fed;color:#fff}
.primary:hover{background:#2860d8}
input[type=text],input[type=password],input[type=email]{width:100%;padding:10px 12px;
border:1px solid #d4dcea;border-radius:8px;font:15px inherit;background:#fff}
label{display:block;margin:12px 0 5px;font-weight:600;font-size:14px}
.hint{font-size:12px;color:#7b8494;font-weight:400}
.empty{text-align:center;color:#7b8494;padding:40px 10px}
.unread{border-left:4px solid #2f6fed}
.err{background:#fdecec;border:1px solid #f5c2c2;color:#a32c2c;padding:10px 12px;
border-radius:8px;margin-bottom:12px;font-size:14px}
.ok{background:#eaf7ee;border:1px solid #bfe3ca;color:#22683a;padding:10px 12px;
border-radius:8px;margin-bottom:12px;font-size:14px}
table{width:100%;border-collapse:collapse}
td{padding:6px 0;vertical-align:top}
"""
def page(title, body, mailbox=None, name=None):
nav = ""
if mailbox:
nav = ('<a href="%s/">Messages</a><a href="%s/settings">Settings</a>'
'<a href="%s/logout">Log out</a>' % (BASE, BASE, BASE))
return HTMLResponse("""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s</title><style>%s</style></head><body>
<header><h1>&#9993; Voicemail</h1>%s<span class="sp"></span>%s</header>
<div class="wrap">%s</div></body></html>""" % (
escape(title), CSS,
('<span style="font-size:14px;color:#dce6ff">%s &middot; mailbox %s</span>'
% (escape(name or ""), escape(mailbox))) if mailbox else "",
nav, body))
def fmt_time(ts):
if not ts:
return ""
return time.strftime("%a %d %b %Y, %H:%M", time.localtime(ts))
def fmt_dur(sec):
if not sec:
return ""
return "%d:%02d" % (sec // 60, sec % 60)
# -------------------------------------------------------------------- routes
@app.get("/login", response_class=HTMLResponse)
def login_form(err: str = ""):
e = '<div class="err">%s</div>' % escape(err) if err else ""
return page("Log in", """%s<div class="card">
<form method="post" action="%s/login">
<label>Mailbox number</label>
<input type="text" name="mailbox" inputmode="numeric" autocomplete="username" autofocus>
<label>PIN <span class="hint">the same PIN you use on the phone</span></label>
<input type="password" name="pin" inputmode="numeric" autocomplete="current-password">
<div class="row"><button class="primary" type="submit">Log in</button></div>
</form></div>""" % (e, BASE))
@app.post("/login")
def do_login(request: Request, mailbox: str = Form(...), pin: str = Form(...)):
ip = request.client.host if request.client else "?"
locked = _lock_check(mailbox, ip)
if locked:
return RedirectResponse(
BASE + "/login?err=Too+many+failed+attempts.+Try+again+in+%d+minutes."
% locked, status_code=303)
info = vm_auth.check_login(mailbox, pin)
if not info:
_lock_fail(mailbox, ip)
time.sleep(1) # slow down brute force
return RedirectResponse(BASE + "/login?err=Incorrect+mailbox+or+PIN",
status_code=303)
_lock_clear(mailbox, ip)
tok = new_session(mailbox.strip())
r = RedirectResponse(BASE + "/", status_code=303)
r.set_cookie(COOKIE, tok, httponly=True, samesite="lax",
secure=SECURE_COOKIE, max_age=SESSION_HOURS * 3600,
path=BASE + "/")
return r
@app.get("/logout")
def logout(vm_session: str = Cookie(default=None)):
if vm_session:
con = vm_store.connect()
con.execute("DELETE FROM sessions WHERE token=?", (vm_session,))
con.commit()
con.close()
r = RedirectResponse(BASE + "/login", status_code=303)
r.delete_cookie(COOKIE, path=BASE + "/")
return r
@app.get("/", response_class=HTMLResponse)
def index(vm_session: str = Cookie(default=None), msg: str = ""):
mb = require(vm_session)
boxes = vm_auth.parse_mailboxes()
name = boxes.get(mb, {}).get("name", "")
con = vm_store.connect()
rows = con.execute(
"SELECT * FROM messages WHERE mailbox=? ORDER BY origtime DESC, id DESC",
(mb,)).fetchall()
con.close()
banner = '<div class="ok">%s</div>' % escape(msg) if msg else ""
if not rows:
return page("Messages", banner + '<div class="card empty">'
'No voicemails yet.<br><span class="hint">New messages appear '
'here automatically once transcribed.</span></div>',
mb, name)
out = [banner]
for r in rows:
tags = "".join('<span class="tag">%s</span>' % escape(t)
for t in json.loads(r["intents"] or "[]"))
nums = json.loads(r["numbers"] or "[]")
callback = ""
if nums:
callback = ' &middot; '.join(
'<a href="tel:%s">%s</a>' % (escape("".join(
ch for ch in n if ch.isdigit() or ch == "+")), escape(n))
for n in nums)
callback = '<div class="meta">&#128222; Callback: %s</div>' % callback
audio = ""
if r["audio_sha"]:
audio = ('<audio controls preload="none" src="%s/audio/%d"></audio>'
% (BASE, r["id"]))
transcript = ""
if r["transcript"]:
transcript = ('<details><summary>Full transcript</summary>'
'<div class="tr">%s</div></details>'
% escape(r["transcript"]))
out.append("""<div class="card%s">
<div class="who">%s</div>
<div class="meta">%s%s</div>
<div class="sum">%s</div>
%s%s
%s
%s
<div class="row">
<form method="post" action="%s/read/%d"><button>%s</button></form>
<a class="btn" href="%s/audio/%d?dl=1">Download</a>
<form method="post" action="%s/delete/%d"
onsubmit="return confirm('Delete this voicemail permanently?')">
<button class="danger">Delete</button></form>
</div></div>""" % (
"" if r["is_read"] else " unread",
escape(r["contact_name"] or r["callerid"] or "Unknown caller"),
escape(fmt_time(r["origtime"])),
(" &middot; " + escape(fmt_dur(r["duration"]))) if r["duration"] else "",
escape(r["summary"] or "(no speech detected)"),
('<div style="margin-top:8px">%s</div>' % tags) if tags else "",
callback, audio, transcript,
BASE, r["id"], "Mark unread" if r["is_read"] else "Mark read",
BASE, r["id"], BASE, r["id"]))
return page("Messages", "".join(out), mb, name)
@app.get("/audio/{msg_id}")
def audio(msg_id: int, dl: int = 0, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
con = vm_store.connect()
r = con.execute("SELECT * FROM messages WHERE id=? AND mailbox=?",
(msg_id, mb)).fetchone()
con.close()
if not r or not r["audio_sha"]:
raise HTTPException(404, "not found")
p = vm_store.audio_path(r["audio_sha"], r["audio_ext"] or "wav")
if not os.path.exists(p):
raise HTTPException(404, "recording missing")
fname = "voicemail-%s-%d.%s" % (mb, msg_id, r["audio_ext"] or "wav")
return FileResponse(
p, media_type="audio/wav",
filename=fname if dl else None,
headers={} if dl else {"Content-Disposition": 'inline; filename="%s"' % fname})
@app.post("/read/{msg_id}")
def toggle_read(msg_id: int, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
con = vm_store.connect()
con.execute("UPDATE messages SET is_read = 1 - is_read"
" WHERE id=? AND mailbox=?", (msg_id, mb))
con.commit()
con.close()
return RedirectResponse(BASE + "/", status_code=303)
@app.post("/delete/{msg_id}")
def delete(msg_id: int, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
con = vm_store.connect()
r = con.execute("SELECT * FROM messages WHERE id=? AND mailbox=?",
(msg_id, mb)).fetchone()
if not r:
con.close()
raise HTTPException(404, "not found")
# remove the stored audio only if no other row references it
if r["audio_sha"]:
others = con.execute("SELECT COUNT(*) c FROM messages"
" WHERE audio_sha=? AND id<>?",
(r["audio_sha"], msg_id)).fetchone()["c"]
if not others:
try:
os.unlink(vm_store.audio_path(r["audio_sha"], r["audio_ext"] or "wav"))
except OSError:
pass
# and the spool copy, when we know where it was
if r["spool_path"]:
base = os.path.splitext(r["spool_path"])[0]
for ext in (".wav", ".WAV", ".gsm", ".txt", ".wav49"):
try:
os.unlink(base + ext)
except OSError:
pass
con.execute("DELETE FROM messages WHERE id=? AND mailbox=?", (msg_id, mb))
con.commit()
con.close()
return RedirectResponse(BASE + "/?msg=Voicemail+deleted", status_code=303)
@app.get("/settings", response_class=HTMLResponse)
def settings_form(vm_session: str = Cookie(default=None), msg: str = ""):
mb = require(vm_session)
boxes = vm_auth.parse_mailboxes()
info = boxes.get(mb, {})
con = vm_store.connect()
cur = vm_store.get_settings(con, mb)
con.close()
rows = []
for key, (default, label) in vm_store.USER_SETTINGS.items():
val = cur.get(key, default)
if default in ("yes", "no"):
checked = " checked" if vm_store.truthy(val) else ""
rows.append('<tr><td><label style="font-weight:400;margin:0">'
'<input type="checkbox" name="%s" value="yes"%s> %s'
'</label></td></tr>' % (key, checked, escape(label)))
else:
rows.append('<tr><td><label>%s</label>'
'<input type="text" name="%s" value="%s"></td></tr>'
% (escape(label), key, escape(val or "")))
banner = '<div class="ok">%s</div>' % escape(msg) if msg else ""
return page("Settings", """%s<div class="card">
<div class="meta">Notification settings for <b>%s</b> (mailbox %s).
Phone PIN changes must still be made on the phone or by your administrator.</div>
<form method="post" action="%s/settings"><table>%s</table>
<div class="row"><button class="primary" type="submit">Save settings</button></div>
</form></div>""" % (banner, escape(info.get("name", "")), escape(mb), BASE,
"".join(rows)), mb, info.get("name", ""))
@app.post("/settings")
async def save_settings(request: Request, vm_session: str = Cookie(default=None)):
mb = require(vm_session)
form = await request.form()
con = vm_store.connect()
for key, (default, _label) in vm_store.USER_SETTINGS.items():
if default in ("yes", "no"):
vm_store.set_setting(con, mb, key, "yes" if form.get(key) else "no")
else:
vm_store.set_setting(con, mb, key, (form.get(key) or "").strip())
con.close()
return RedirectResponse(BASE + "/settings?msg=Settings+saved", status_code=303)
@app.get("/healthz")
def healthz():
con = vm_store.connect()
n = con.execute("SELECT COUNT(*) c FROM messages").fetchone()["c"]
con.close()
return {"ok": True, "messages": n}