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

159
docs/CONFIGURATION.md Normal file
View File

@ -0,0 +1,159 @@
# Configuration
All config lives in `/opt/vm-transcribe/`. Templates are committed as
`*.example`; the live files are created by `install.sh` or by you. Secret-bearing
files (`telegram.conf`, `contacts.conf`) are mode `640 root:asterisk` and git-
ignored.
Most settings can also be set via environment variables, which is how the systemd
unit wires production values. Env vars override file values when both are present
unless noted.
---
## Environment variables (shared)
| Variable | Default | Used by | Meaning |
|---|---|---|---|
| `VM_DB` | `/var/lib/vm-transcribe/voicemail.db` | store, web, import | SQLite path. Created with WAL mode if missing. |
| `VM_AUDIO_DIR` | `/var/lib/vm-transcribe/audio` | store, web, import | Where copied recordings live (`<sha[:2]>/<sha>.wav`). |
| `VM_LOG` | `/var/log/asterisk/vm_mailcmd.log` | mailcmd | Log path. |
| `VM_MODEL_CACHE` | `/opt/vm-transcribe/models` | mailcmd, import | Whisper model download/load dir. |
| `VM_WHISPER_MODEL` | `base.en` | mailcmd, import | Model name. `tiny`/`base`/`small` trade speed for accuracy. |
| `VM_SPOOL` | `/var/spool/asterisk/voicemail` | import | Spool root to scan. |
| `VM_ASTERISK_CONF` | `/etc/asterisk/voicemail.conf` | web, auth | For PIN lookup. |
---
## voicemail.conf (Asterisk)
One line is all the pipeline needs:
```ini
mailcmd=/opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_mailcmd.py
```
Place it in the `[general]` section (or any context). After editing:
```bash
sudo asterisk -rx 'voicemail reload'
```
`install.sh` backs up the file and adds this line automatically.
---
## telegram.conf
```ini
[telegram]
enabled = yes
token = 123456789:AAH... ; from @BotFather
default_chat_id = ; fallback for unmapped mailboxes (blank = none)
send_audio = yes ; voice note with caption
send_transcript = yes ; full transcript as a follow-up message
timeout = 20 ; seconds per send attempt
[mailbox:1001]
chat_id = 123456789 ; single recipient
[mailbox:1002]
chat_id = 5551, 5552 ; comma list fans out to several people
send_transcript = no ; any global key can be overridden per mailbox
[mailbox:1003]
chat_id = -1001234567890 ; negative = group chat
send_audio = no ; text-only DM
```
**Routing rules** (evaluated per voicemail):
1. Match the `[mailbox:N]` section for the voicemail's mailbox number.
2. `chat_id` may be a single id, a comma-separated list (fan-out), or a group id
(negative).
3. If no mailbox section matches, fall back to `default_chat_id`.
4. If `enabled = no` or no route resolves, nothing is sent — email still goes out.
5. The file is re-read on every voicemail; no reload needed after editing.
**Delivery fallback chain** (per chat id, tried in order): `sendVoice`
`sendDocument``sendMessage` (text-only). So a Telegram outage or a bad audio
file still delivers the summary.
> Contact resolution note: a user must message the bot once before it can DM
> them. Telegram does not permit bots to initiate conversations.
---
## contacts.conf
```ini
[contacts]
backends = file ; space/comma separated: file google carddav
cache_ttl = 86400 ; seconds; cache hits AND misses
cache_file = /var/lib/vm-transcribe/contacts-cache.json
# --- 'file' backend (recommended): a vCard or CSV export -------------
[file]
path = /var/lib/vm-transcribe/contacts.vcf
# CSV (Google export "All contact data") is auto-detected:
# path = /var/lib/vm-transcribe/contacts.csv
# --- 'google' backend: People API via OAuth ---------------------------
[google]
token_file = /var/lib/vm-transcribe/google-token.json
# Obtain a token with the People API (contacts.readonly) scope. See
# docs/SECURITY.md. No app password here — OAuth only.
# --- 'carddav' backend: app password, Nextcloud/Fastmail/iCloud --------
[carddav]
# Google rejects app passwords (basic auth disabled 2024-09-30); a Google
# URL here is refused with a log warning and the backend is skipped.
url = https://contacts.fastmail.com/dav/addressbooks/user/xxx/Default
username = jp@txt3.com
app_password = ; app-specific password, not your login password
```
**Matching** is on the **last 9 digits** of the caller ID, so `+447700900123`,
`07700900123` and `447700900123` all collapse to the same contact. Names resolve
best-effort; on any backend error the caller ID is used as-is.
---
## Portal settings (per mailbox, in the SQLite store)
These are edited from the portal's **Settings** page by each user; they are not
config files. Every value defaults to `yes`.
| Setting | Effect |
|---|---|
| `summarise` | Run the summariser (if off, the raw transcript is shown). |
| `telegram` | Forward new voicemails to the mailbox's Telegram route (requires telegram.conf). |
| `email_notify` | Send the enriched email (if off, the message is stored but not emailed). |
| `highlight` | Colour the caller chip when a name is resolved from contacts. |
| `max_inbox` | Keep at most N messages; oldest beyond N are auto-deleted (0 = unlimited). |
---
## vm-portal.service
Production values are passed as environment in the unit file:
```ini
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
```
Login hardening (in-process):
| Var | Default | Meaning |
|---|---|---|
| `VM_MAX_FAILS` | `5` | Failed attempts per (mailbox, IP) before locking. |
| `VM_LOCK_MINUTES` | `15` | Lock duration. |
| `VM_SESSION_HOURS` | `12` | Session lifetime. |
| `VM_INSECURE_COOKIE` | unset | Set to `1` only for plain-HTTP testing; normally cookies are `Secure`. |
| `VM_BASE_PATH` | `""` | Set to e.g. `/voicemail` if the app is served under a sub-path. |
> A service restart clears the in-memory lockout counters. Testing lockout will
> lock you out of your own verification for `VM_LOCK_MINUTES`.