commit 857284abbfdb4ebfc87cf07244df3a278b613e80 Author: jp Date: Thu Aug 13 09:31:41 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70aa1e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Local config with real credentials — never commit these. +config/telegram.conf +config/contacts.conf +*.conf.local + +# Runtime data +*.db +*.db-wal +*.db-shm +audio/ +models/ +*.log + +# Test artefacts +*.eml +*.wav +*.mp3 +*.ogg +preview.html +ui_preview.html +tstore.db* +taudio/ + +# Python +__pycache__/ +*.py[cod] +venv/ +.venv/ + +# Editor / OS +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..99ff0da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## 1.0.0 — 2026-08-13 + +Initial build on mail.txt3.net (Debian 12, Asterisk 20, Apache 2.4, Postfix). + +- **Transcription**: per-voicemail `mailcmd` (`vm_mailcmd.py`) transcribes the + attached recording with faster-whisper `base.en` (CPU, int8, VAD-filtered). +- **Summarisation**: local extractive summariser with intent tags and spoken- + digit callback-number extraction. No LLM, by choice. +- **Email**: rebuilt `multipart/alternative` notification (plain + styled HTML) + with the recording attached; relays the original Asterisk message unchanged on + any error (fail-safe). +- **Telegram** (optional): per-mailbox routed voice-note DM with summary caption + and transcript follow-up; degrades sendVoice → sendDocument → sendMessage. +- **Contacts** (optional): caller-ID → name via `file` (vCard/CSV export), + `google` (People API OAuth), or `carddav` (app password; Nextcloud/Fastmail/ + iCloud). Last-9-digit matching. Note: Google app passwords do not work. +- **Web portal**: FastAPI app at `https://vm.txt3.net` — PIN login (mailbox + + voicemail PIN from `voicemail.conf`), list, play, download, delete, per-user + settings. Zero JavaScript, `script-src 'none'` CSP, loopback-only backend + behind an Apache TLS reverse proxy. Brute-force lockout per (mailbox, IP). +- **Storage**: SQLite store with content-addressed audio (decoupled from + Asterisk's renumbering) and DB-backed sessions. +- **Backfill**: `vm_import.py` imports and transcribes existing spool recordings; + idempotent on `(mailbox, origtime, callerid)`. Backfilled 157 historical + messages on first run (3m40s, 0 failures). +- **Packaging**: venv at `/opt/vm-transcribe`, systemd `vm-portal.service`, + Apache vhost + certbot TLS, `install.sh`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f52cea6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b748e6 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# Asterisk Voicemail Transcription & Portal + +Turns Asterisk voicemail into something you can actually read, search and +manage: + +- **Transcribes** each recording locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper) (CPU, no cloud, no API key) +- **Summarises** it, tags intent (callback requested, urgent, invoice…) and + extracts callback numbers — including spoken-out digits +- **Emails** a graphically designed `multipart/alternative` notification + (plain + HTML) with the recording attached +- **DMs Telegram** optionally, as a playable voice note with the summary +- **Resolves caller ID to a name** from Google Contacts or any CardDAV server +- **Serves a web portal** where mailbox users log in with their existing phone + PIN to read transcripts, play/download recordings, delete messages and + manage their own notification settings + +Everything runs on the PBX host. No third-party service sees your voicemail. + +--- + +## What it looks like + +**Email notification** — styled HTML card with metadata, summary panel, intent +chips, a `tel:` callback link, full transcript and the audio attached. A plain +text alternative is always included. + +**Telegram** — voice note (ogg/opus) with the summary as its caption, intent +hashtags, and the transcript as a follow-up message. + +**Portal** — one card per voicemail: caller, timestamp, duration, summary, +tags, callback link, inline player, collapsible transcript, and Mark read / +Download / Delete. + +--- + +## Architecture + +``` + incoming call + │ + ▼ + ┌─────────────┐ voicemail.conf: mailcmd=… vm_mailcmd.py + │ Asterisk │ ─────────────────────────────┐ + └─────────────┘ pipes an RFC822 message │ + (notification + audio) ▼ + ┌───────────────┐ + │ vm_mailcmd.py │ + └───────┬───────┘ + ┌──────────────┬──────────────┬────┴─────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ + faster-whisper summarise() vm_contacts vm_store vm_telegram + (transcribe) + intents (name lookup) (SQLite + (voice note + + numbers audio CAS) DM) + │ │ + ▼ ▼ + multipart email ──▶ Postfix :25 ┌──────────────┐ + │ vm_web.py │ + │ (FastAPI) │ + └──────┬───────┘ + │ :8099 loopback + ▼ + Apache (TLS) + │ + ▼ + https://vm.txt3.net +``` + +**Delivery order is deliberate**: email first, then the database, then Telegram. +Each later stage is wrapped so a failure only logs. If anything throws at the +top level, the *original* Asterisk notification is relayed unchanged. A +voicemail notification is never lost because a summariser or an API failed. + +--- + +## Components + +| File | Role | +|---|---| +| `src/vm_mailcmd.py` | The `mailcmd` — entry point for every voicemail. Orchestrates everything. | +| `src/vm_store.py` | SQLite store: schema, settings, sessions, content-addressed audio. | +| `src/vm_telegram.py` | Telegram delivery, per-mailbox routing. | +| `src/vm_contacts.py` | Caller-ID → name via local export, Google People API, or CardDAV. | +| `src/vm_web.py` | FastAPI portal: login, list, play, delete, settings. | +| `src/vm_auth.py` | Parses `voicemail.conf` so users log in with their phone PIN. | +| `src/vm_import.py` | Backfills existing spool recordings into the database. | +| `src/vm_tg_setup.py` | Helper: discover Telegram chat IDs, test a route. | + +--- + +## Install + +See **[docs/INSTALL.md](docs/INSTALL.md)** for the full walkthrough. Short version: + +```bash +sudo scripts/install.sh # venv, deps, model cache, mailcmd wiring +sudo cp systemd/vm-portal.service /etc/systemd/system/ +sudo systemctl enable --now vm-portal +# then follow docs/INSTALL.md §5 for the Apache vhost + TLS +``` + +Backfill your existing voicemails: + +```bash +sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 \ + /opt/vm-transcribe/vm_import.py --dry-run # preview +sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 \ + /opt/vm-transcribe/vm_import.py # do it +``` + +--- + +## Documentation + +| Document | Contents | +|---|---| +| [docs/INSTALL.md](docs/INSTALL.md) | Step-by-step install, including TLS ordering | +| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | Every config file and option | +| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design decisions and why | +| [docs/OPERATIONS.md](docs/OPERATIONS.md) | Day-to-day running, backup, troubleshooting | +| [docs/SECURITY.md](docs/SECURITY.md) | Threat model, hardening, privacy | +| [docs/TESTING.md](docs/TESTING.md) | How to verify each part | +| [CHANGELOG.md](CHANGELOG.md) | Version history | + +--- + +## Requirements + +- Asterisk with `app_voicemail` (file-based spool, not ODBC/IMAP storage) +- Python 3.9+ +- `ffmpeg` (Telegram voice-note transcoding; `sox` optionally for gsm) +- An MTA listening on `localhost:25` (Postfix here) +- ~200 MB disk for the whisper `base.en` model +- Apache with `proxy`, `proxy_http`, `headers`, `rewrite`, `ssl` for the portal + +Runs comfortably on 4 CPU cores with no GPU: ~8 s to transcribe 23 s of audio. + +--- + +## Design notes worth knowing + +**Summarisation is local and extractive.** Frequency-scored sentence selection +with position/digit weighting, plus regex intent tags. No LLM, by choice — it +keeps voicemail content on your own hardware. `summarise()` in +`vm_mailcmd.py` is a single swap-in point if you want an abstractive model. + +**Audio is content-addressed, not referenced by spool path.** Asterisk renumbers +`msgNNNN` files when a message is deleted, so a stored path silently starts +pointing at the wrong recording. Recordings are copied to +`audio//.wav`. + +**App passwords cannot read Google Contacts.** Google disabled basic auth for +CardDAV/CalDAV/IMAP/SMTP/POP on 2024-09-30. Use the `file` backend (a vCard +export) or the `google` backend (OAuth). The `carddav` backend with an app +password works for Nextcloud, Fastmail and iCloud. + +**The portal ships zero JavaScript**, which lets its CSP be `script-src 'none'`. + +--- + +## Licence + +MIT — see [LICENSE](LICENSE). diff --git a/apache/vm.txt3.net-step1.conf b/apache/vm.txt3.net-step1.conf new file mode 100644 index 0000000..6ad3ba8 --- /dev/null +++ b/apache/vm.txt3.net-step1.conf @@ -0,0 +1,12 @@ + + ServerName vm.txt3.net + DocumentRoot /home/txt3/domains/vm.txt3.net/public_html + ErrorLog /var/log/virtualmin/vm.txt3.net_error_log + CustomLog /var/log/virtualmin/vm.txt3.net_access_log combined + + + Require all granted + Options -Indexes + AllowOverride None + + diff --git a/apache/vm.txt3.net.conf b/apache/vm.txt3.net.conf new file mode 100644 index 0000000..a274e60 --- /dev/null +++ b/apache/vm.txt3.net.conf @@ -0,0 +1,54 @@ + + ServerName vm.txt3.net + ErrorLog /var/log/virtualmin/vm.txt3.net_error_log + CustomLog /var/log/virtualmin/vm.txt3.net_access_log combined + + # Let certbot answer HTTP-01 challenges from the webroot + Alias /.well-known/acme-challenge/ /home/txt3/domains/vm.txt3.net/public_html/.well-known/acme-challenge/ + + Require all granted + Options -Indexes + + ProxyPass /.well-known ! + + # Everything else goes to HTTPS + RewriteEngine on + RewriteCond %{HTTPS} !=on + RewriteRule ^/(?!\.well-known)(.*)$ https://vm.txt3.net/$1 [R=301,L] + + + + ServerName vm.txt3.net + ErrorLog /var/log/virtualmin/vm.txt3.net_error_log + CustomLog /var/log/virtualmin/vm.txt3.net_access_log combined + + SSLEngine on + SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1 + # Replaced by certbot with the vm.txt3.net cert once issued. + SSLCertificateFile /etc/letsencrypt/live/vm.txt3.net/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/vm.txt3.net/privkey.pem + + # --- security headers ------------------------------------------------- + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "DENY" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Strict-Transport-Security "max-age=15768000" + # The app uses only inline +

✉ Voicemail

%s%s
+
%s
""" % ( + escape(title), CSS, + ('%s · mailbox %s' + % (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 = '
%s
' % escape(err) if err else "" + return page("Log in", """%s
+
+ + + + +
+
""" % (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 = '
%s
' % escape(msg) if msg else "" + if not rows: + return page("Messages", banner + '
' + 'No voicemails yet.
New messages appear ' + 'here automatically once transcribed.
', + mb, name) + + out = [banner] + for r in rows: + tags = "".join('%s' % escape(t) + for t in json.loads(r["intents"] or "[]")) + nums = json.loads(r["numbers"] or "[]") + callback = "" + if nums: + callback = ' · '.join( + '%s' % (escape("".join( + ch for ch in n if ch.isdigit() or ch == "+")), escape(n)) + for n in nums) + callback = '
📞 Callback: %s
' % callback + + audio = "" + if r["audio_sha"]: + audio = ('' + % (BASE, r["id"])) + + transcript = "" + if r["transcript"]: + transcript = ('
Full transcript' + '
%s
' + % escape(r["transcript"])) + + out.append("""
+
%s
+
%s%s
+
%s
+%s%s +%s +%s +
+
+ Download +
+
+
""" % ( + "" if r["is_read"] else " unread", + escape(r["contact_name"] or r["callerid"] or "Unknown caller"), + escape(fmt_time(r["origtime"])), + (" · " + escape(fmt_dur(r["duration"]))) if r["duration"] else "", + escape(r["summary"] or "(no speech detected)"), + ('
%s
' % 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('' % (key, checked, escape(label))) + else: + rows.append('' + '' + % (escape(label), key, escape(val or ""))) + + banner = '
%s
' % escape(msg) if msg else "" + return page("Settings", """%s
+
Notification settings for %s (mailbox %s). +Phone PIN changes must still be made on the phone or by your administrator.
+
%s
+
+
""" % (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} diff --git a/systemd/vm-portal.service b/systemd/vm-portal.service new file mode 100644 index 0000000..2b3f781 --- /dev/null +++ b/systemd/vm-portal.service @@ -0,0 +1,43 @@ +[Unit] +Description=Voicemail portal (transcripts, playback, per-mailbox settings) +Documentation=file:/opt/vm-transcribe/vm_web.py +After=network.target +Wants=network.target + +[Service] +Type=simple +# Runs as asterisk so it can read voicemail.conf and the spool. +User=asterisk +Group=asterisk +WorkingDirectory=/opt/vm-transcribe + +Environment=VM_DB=/var/lib/vm-transcribe/voicemail.db +Environment=VM_AUDIO_DIR=/var/lib/vm-transcribe/audio +Environment=VM_ASTERISK_CONF=/etc/asterisk/voicemail.conf +Environment=VM_SESSION_HOURS=12 +Environment=PYTHONUNBUFFERED=1 + +ExecStart=/opt/vm-transcribe/venv/bin/python -m uvicorn vm_web:app \ + --host 127.0.0.1 --port 8099 \ + --proxy-headers --forwarded-allow-ips 127.0.0.1 \ + --log-level info + +Restart=on-failure +RestartSec=3 + +# --- hardening --------------------------------------------------------- +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +# Only these paths need to be writable. +ReadWritePaths=/var/lib/vm-transcribe /var/log/asterisk +# Loopback only; Apache is the only client. +IPAddressAllow=localhost +IPAddressDeny=any + +[Install] +WantedBy=multi-user.target diff --git a/tests/make_test_mail.py b/tests/make_test_mail.py new file mode 100644 index 0000000..af7379b --- /dev/null +++ b/tests/make_test_mail.py @@ -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()) diff --git a/tests/test_contacts.py b/tests/test_contacts.py new file mode 100644 index 0000000..3c38cd1 --- /dev/null +++ b/tests/test_contacts.py @@ -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) diff --git a/tests/test_telegram.py b/tests/test_telegram.py new file mode 100644 index 0000000..c995f19 --- /dev/null +++ b/tests/test_telegram.py @@ -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)