Files
asterisk-voicemail/docs/ARCHITECTURE.md
jp 857284abbf 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.
2026-08-13 09:31:41 +01:00

138 lines
5.7 KiB
Markdown

# Architecture
How the pieces fit and *why* they are shaped the way they are. Read this
before changing anything in `vm_mailcmd.py` or `vm_store.py`.
---
## Flow
```
Asterisk ──(RFC822 on stdin)──▶ vm_mailcmd.py
┌──────────┬───────────────┼───────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
transcribe summarise resolve store telegram
(whisper) +intents contact (sqlite) (DM)
│ │ │ │ │
└────▶ build multipart email ─▶ Postfix :25
(plain + HTML + audio attached)
└──▶ vm_store.add_message() (best-effort)
└──▶ vm_telegram.send() (best-effort)
```
**Email is sent before anything else.** The portal and Telegram are conveniences;
the email is the contract with the user. If the database is down or Telegram's
API throws, the mail has already gone. Each later stage is in its own `try/except`
that only logs. The reverse — a transcription error blocking the email — never
happens, because the email builder is given whatever text we have, even `""`.
At the very top, the whole `main()` body is wrapped so that *any* unexpected
exception relays the **original** Asterisk message unchanged. Nothing we add can
silently swallow a voicemail.
---
## Why faster-whisper, CPU, int8, base.en
- **faster-whisper** (CTranslate2) is dramatically faster than OpenAI's
`whisper` on CPU — ~8 s for 23 s of 8 kHz audio here, versus minutes.
- **CPU/int8** because this host has no GPU and the voicemail rate is low. A
GPU would be idle 99.9 % of the time.
- **base.en** is plenty for clear phone audio; `small.en` only if you see
accuracy problems with names/numbers. Set `VM_WHISPER_MODEL`.
The model is loaded once per process. Asterisk fires a fresh `mailcmd` per
voicemail, so there is a ~1 s model-load cost *per message* — acceptable at PBX
volumes, and the model is pre-cached at install so the first call doesn't also
pay a download.
---
## Why the summary is extractive, not an LLM
The user explicitly declined both OpenAI and Ollama. `summarise()` is:
1. split into sentences,
2. score each by word frequency (stop-words removed) plus a boost for
position (first/last sentence) and for sentences containing digits,
3. pick the top sentences, ordered as spoken, capped at ~3 and a length limit,
4. wrap with regex intent tags (`Call back requested`, `Urgent`,
`Payment / invoice`, …) and number extraction.
It reads well on phone voicemail and needs no network. The single function
`summarise()` is a deliberate **swap-in point** — replace it with a local model
or an API call without touching the rest of the pipeline.
---
## Why audio is content-addressed
Asterisk **renumbers** `msgNNNN` files when a message is deleted from a folder.
A stored path like `…/INBOX/msg0042.wav` therefore points at a *different*
recording a week later. So:
- On store, the audio is copied to
`audio/<sha256[:2]>/<sha256>.<ext>` (blake2/sha256 in `vm_store`).
- The spool path is kept only as a *delete hint*.
- On delete, the blob is unlinked only if no other row references that hash.
This decouples the portal's playback from Asterisk's ever-shifting filenames.
---
## Why the portal is its own service behind Apache
- A long-running `uvicorn` is the natural fit; Asterisk's `mailcmd` model
(one short-lived process per call) is wrong for a web app.
- It binds **127.0.0.1:8099 only** (`IPAddressAllow=localhost` in the unit), so
the only way in is the Apache reverse proxy, which terminates TLS and enforces
headers.
- Running as the `asterisk` user means it can read `voicemail.conf` (for PIN
auth) and the spool (for future direct-file features) without widening root.
---
## Auth model
Users log in with their **mailbox number + existing voicemail PIN** parsed from
`/etc/asterisk/voicemail.conf` (`vm_auth.py`). No password database to keep in
sync, no new credentials to provision. Sessions are stored in the DB (random
token, `Secure` cookie, `HttpOnly`, `SameSite=lax`, 12 h) so logout and expiry
are enforced server-side, not just by deleting a cookie.
Every data query is filtered by the session's mailbox. Cross-mailbox access must
404 — there is a test for this in [TESTING.md](TESTING.md).
---
## Lockout
Voicemail PINs are short (often 4 digits) and the portal is internet-facing, so
failed logins are throttled in-process: 5 per `(mailbox, IP)` then 15 minutes.
State is in memory by design (single worker). A restart clears it — fine, because
a restart is an admin action, not an attacker one.
---
## Telegram routing
`telegram.conf` is re-read on every voicemail (no reload). Resolution order:
`[mailbox:N]` `chat_id``default_chat_id` → nothing. A chat id may fan out to
several recipients (comma list) or be a group (negative). Per-mailbox sections
can override any global (`send_audio`, `send_transcript`). Delivery degrades
`sendVoice → sendDocument → sendMessage` so a summary always gets through.
---
## Privacy posture
All audio and transcripts stay on the PBX. The only egress is:
- **Postfix → your MX** for email (the existing path),
- **Telegram API** (only if you enable Telegram),
- **Google People API** (only if you enable the `google` contacts backend).
Nothing is sent to OpenAI, Anthropic, or any transcription vendor.