README, docs/{ARCHITECTURE,CONFIGURATION,INSTALL,OPERATIONS,SECURITY,TESTING}
and CHANGELOG were stale: they described the original SQLite + CardDAV +
zero-JS vm_web portal. Brought them in line with the actual code:
- MySQL (PyMySQL) as the default store; SQLite fallback only
- vm_contacts resolves from the MySQL contacts table (file/google/carddav removed)
- React SPA frontend + vm_api.py JSON API (:8098); vm_web.py now serves /audio/
- Apache vhost proxies /api/ -> :8098, /audio/ -> :8099, serves the SPA
- CSP relaxed to script-src 'self' 'unsafe-inline' (UI is now JavaScript)
- Added CHANGELOG 1.2.0 entry for the React/vm_api frontend work
7.1 KiB
Configuration
The backend services run from /opt/vm-transcribe/. Live secret-bearing files
live there (some git-ignored). The repo ships *.example templates under
config/.
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 / store (vm_store.py, used by mailcmd, web, api, import)
| Variable | Default | Meaning |
|---|---|---|
VM_DB |
/var/lib/vm-transcribe/voicemail.db |
SQLite path — used only as a fallback when no MySQL is configured. |
VM_AUDIO_DIR |
/var/lib/vm-transcribe/audio |
Where copied recordings live (<sha[:2]>/<sha>.wav). |
VM_LOG |
/var/log/asterisk/vm_mailcmd.log |
Log path. |
VM_MODEL_CACHE |
/opt/vm-transcribe/models |
Whisper model download/load dir. |
VM_WHISPER_MODEL |
base.en |
Model name. tiny/base/small trade speed for accuracy. |
VM_SPOOL |
/var/spool/asterisk/voicemail |
Spool root to scan (import). |
VM_ASTERISK_CONF |
/etc/asterisk/voicemail.conf |
For PIN lookup. |
MySQL (default store + contacts)
Set these as env vars (the API unit reads /opt/vm-transcribe/api.env) or
put them in /opt/vm-transcribe/db_secret (mode 640 root:root,
KEY=VALUE lines). When a credentials source exists, vm_store uses MySQL;
otherwise it falls back to SQLite.
| Variable | Default | Meaning |
|---|---|---|
VM_MYSQL_HOST |
localhost |
MySQL host. |
VM_MYSQL_USER |
asterisk |
Least-privilege DB user (owns the asterisk DB; not root). |
VM_MYSQL_PASSWORD |
(from db_secret) |
DB password. |
VM_MYSQL_DB |
asterisk |
Database name — the same DB Asterisk's CDR uses. |
db_secret key names: MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD,
MYSQL_DB.
Portal / API hardening
| Var | Default | Meaning |
|---|---|---|
VM_SESSION_HOURS |
12 |
Session lifetime. |
VM_MAX_FAILS |
5 |
Failed attempts per (mailbox, IP) before locking. |
VM_LOCK_MINUTES |
15 |
Lock duration. |
VM_INSECURE_COOKIE |
unset | Set to 1/yes/true only for plain-HTTP testing; normally cookies are Secure. |
VM_BASE_PATH |
"" |
Set to e.g. /voicemail if served under a sub-path. |
VM_CORS_ORIGINS |
* |
Comma list of allowed CORS origins for the JSON API. |
voicemail.conf (Asterisk)
One line is all the pipeline needs:
mailcmd=/opt/vm-transcribe/venv/bin/python3 /opt/vm-transcribe/vm_mailcmd.py
Place it in the [general] section (or any context). After editing:
sudo asterisk -rx 'voicemail reload'
install.sh backs up the file and adds this line automatically.
telegram.conf
[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):
- Match the
[mailbox:N]section for the voicemail's mailbox number. chat_idmay be a single id, a comma-separated list (fan-out), or a group id (negative).- If no mailbox section matches, fall back to
default_chat_id. - If
enabled = noor no route resolves, nothing is sent — email still goes out. - 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
[contacts]
enabled = yes
backends = mysql ; ONLY 'mysql' is implemented (MySQL contacts table).
# The schema still parses file/google/carddav entries,
# but the registry is {"mysql": lookup_mysql} so any
# other value is ignored / logged as unknown.
cache_path = /var/lib/vm-transcribe/contacts_cache.json
cache_ttl = 86400
[file] ; NOT USED — kept for reference only
[google] ; NOT USED — kept for reference only
[carddav] ; NOT USED — kept for reference only
Contacts are resolved exclusively from the MySQL contacts table on the
asterisk DB. Numbers are stored canonical E.164; normalize_uk() collapses
+44… and 0… (and 0044…) so both forms match. Matching uses the full digit
string (up to 15 digits), not just last-9. Creating/updating a contact via the
API backfills messages.contact_name/contact_email for matching callerids.
Load the table from a vCard/CSV export with:
sudo -u asterisk /opt/vm-transcribe/venv/bin/python3 \
/opt/vm-transcribe/vm_import_contacts.py /path/to/contacts.vcf
Portal settings (per mailbox, in MySQL)
These are edited from the React 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). |
systemd units
Two units, both running as asterisk and binding loopback only.
vm-api.service (:8098 JSON API)
Environment=VM_SESSION_HOURS=12
EnvironmentFile=-/opt/vm-transcribe/api.env ; MySQL creds + VM_* overrides
# Hardening: ProtectSystem=full, ProtectHome=read-only, NoNewPrivileges,
# PrivateTmp, IPAddressAllow=127.0.0.1
ExecStart=/opt/vm-transcribe/venv/bin/uvicorn vm_api:app --host 127.0.0.1 --port 8098 --workers 2
vm-portal.service (:8099 legacy HTML / audio)
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
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
# Hardening: NoNewPrivileges, PrivateTmp, ProtectSystem=full, ProtectHome,
# ReadWritePaths=/var/lib/vm-transcribe /var/log/asterisk,
# IPAddressAllow=localhost, IPAddressDeny=any
A service restart clears the in-memory lockout counters. Testing lockout will lock you out of your own verification for
VM_LOCK_MINUTES.