voicemail: index messages, live event, email with the recording

services/voicemail.ts rescans a mailbox on Asterisk's MessageWaiting event
(and every mailbox on AMI connect) instead of watching files, upserts
voicemail_messages by msg_id, marks vanished ones deleted, sends
voicemail.new to the owner only, and emails the wav. Delete-after-email
uses AMI VoicemailRemove, and only when the audio was attached. Asterisk
24 has VoicemailMove/VoicemailRemove, so 04 §5.6 drops the renumbering
plan. New VOICEMAIL_DIR setting; e2e m4-t2 checks the email arrives
within 10 s with a playable attachment (SMTP sink shared with m3-t4).

Task: M4-T2
This commit is contained in:
iamdoubz committed 2026-10-17 22:33:52 -05:00
1 parent 0d29131d18
commit 8d752ba705
16 files changed
+642 -58

No files matched your search

+1 -1
View File
@@ -1 +1 @@
9468
10280
+2
View File
@@ -15,6 +15,8 @@ const EnvSchema = z.object({
AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
+14
View File
@@ -11,6 +11,7 @@ import { ensureInitialAdmin } from './services/bootstrap.ts';
import { LiveState } from './live/state.ts';
import { smtpMailer } from './services/mail.ts';
import { watchEmergencies } from './services/emergency.ts';
import { watchVoicemail } from './services/voicemail.ts';
let config;
try {
@@ -50,6 +51,18 @@ app.decorate('ami', ami);
const sendMail = smtpMailer(db);
app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
const voicemail = watchVoicemail(
{
db,
spoolDir: resolve(config.VOICEMAIL_DIR),
ami,
live,
send: sendMail,
publicHost: config.PUBLIC_HOSTNAME,
log: app.log,
},
ami,
);
app.decorate('live', live);
await runMigrations(db);
@@ -72,6 +85,7 @@ ami.on('connected', () => {
app.log.info('asterisk restarted: restart no longer required');
await generator.apply({ trigger: 'startup', force: true });
await live.loadFromAsterisk();
await voicemail.scanAll();
})().catch((err) => app.log.error({ err }, 'startup sync failed'));
});
ami.start();
+9 -5
View File
@@ -34,13 +34,17 @@ export async function liveRoutes(app: FastifyInstance) {
? `trunk:${m.data.trunkId}`
: m.type === 'emergency.call'
? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls
: m.type;
: m.type === 'voicemail.new'
? `voicemail:${m.data.id}`
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
c.admin ||
(m.type === 'emergency.call'
? m.data.audienceUserIds.includes(c.userId)
: !m.type.startsWith('trunk.'));
m.type === 'voicemail.new'
? m.data.userId === c.userId // private, even from admins
: c.admin ||
(m.type === 'emergency.call'
? m.data.audienceUserIds.includes(c.userId)
: !m.type.startsWith('trunk.'));
app.live.on('message', (m) => {
for (const c of clients) if (maySee(c, m)) c.pending.set(keyOf(m), m);
+8 -3
View File
@@ -5,7 +5,12 @@ import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from './settings.ts';
export type Mail = { to: string[]; subject: string; text: string };
export type Mail = {
to: string[];
subject: string;
text: string;
attachments?: { filename: string; path: string }[];
};
export type SendMail = (m: Mail) => Promise<void>;
export class MailNotConfigured extends Error {
@@ -16,7 +21,7 @@ export class MailNotConfigured extends Error {
/** Reads the SMTP settings on every send, so changes apply without a restart. */
export function smtpMailer(db: Db): SendMail {
return async ({ to, subject, text }) => {
return async ({ to, subject, text, attachments }) => {
const smtp = await getSetting(db, 'smtp');
if (!smtp) throw new MailNotConfigured();
const transport = nodemailer.createTransport({
@@ -27,6 +32,6 @@ export function smtpMailer(db: Db): SendMail {
connectionTimeout: 15_000,
greetingTimeout: 15_000,
});
await transport.sendMail({ from: smtp.from, to, subject, text });
await transport.sendMail({ from: smtp.from, to, subject, text, attachments });
};
}
+234
View File
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Voicemail index (04 §5.6). Asterisk records into the spool; this keeps voicemail_messages in step
// with it, tells the owner, and emails them. A mailbox is rescanned on Asterisk's MessageWaiting
// event, which fires whenever a message is left, listened to or deleted (from a phone or from us),
// so there is no file watcher (inotify is unreliable on bind mounts).
import type { EventEmitter } from 'node:events';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { and, eq, isNull, notInArray } from 'drizzle-orm';
import {
formatNumber,
VOICEMAIL_FOLDERS,
type LiveMessage,
type VoicemailDto,
type VoicemailFolder,
} from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, users, voicemailMessages } from '../db/schema.ts';
import { MailNotConfigured, type SendMail } from './mail.ts';
import { getSetting } from './settings.ts';
export type VoicemailDeps = {
db: Db;
/** /var/spool/asterisk/voicemail */
spoolDir: string;
ami: { action(fields: Record<string, string>): Promise<unknown> };
live: { emit(event: 'message', m: LiveMessage): unknown };
send: SendMail;
publicHost: string;
log: { info(o: object, msg: string): void; error(o: object, msg: string): void };
};
type Row = typeof voicemailMessages.$inferSelect;
/** msgNNNN.txt: an INI file with one [message] section. */
export function parseMessageTxt(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const m = /^([a-z_]+)=(.*)$/.exec(line);
if (m) out[m[1]!] = m[2]!;
}
return out;
}
/** `"Bob" <102>`, `<102>`, `102`, `Bob`, `Unknown`. */
export function parseCallerId(v = ''): { name: string | null; number: string | null } {
const m = /^\s*"?([^"<]*?)"?\s*<([^>]*)>\s*$/.exec(v);
if (m) return { name: m[1]!.trim() || null, number: m[2]!.trim() || null };
const s = v.trim();
if (!s || /^unknown$/i.test(s)) return { name: null, number: null };
return /^[0-9*#+]+$/.test(s) ? { name: null, number: s } : { name: s, number: null };
}
export const toVoicemailDto = (r: Row): VoicemailDto => ({
id: r.id,
folder: r.folder as VoicemailFolder,
callerNumber: r.callerNumber,
callerName: r.callerName,
durationS: r.durationS,
receivedAt: r.receivedAt.toISOString(),
});
/** Everything in one mailbox's INBOX, Old and Urgent folders. filePath is relative to the spool. */
async function readMailbox(spoolDir: string, ext: string) {
const found: (Omit<typeof voicemailMessages.$inferInsert, 'extensionId'> & { msgId: string })[] =
[];
for (const folder of VOICEMAIL_FOLDERS) {
const rel = `default/${ext}/${folder}`;
const names = await readdir(join(spoolDir, rel)).catch(() => [] as string[]);
for (const n of names.filter((n) => /^msg\d{4}\.txt$/.test(n)).sort()) {
const info = parseMessageTxt(await readFile(join(spoolDir, rel, n), 'utf8').catch(() => ''));
if (!info.msg_id) continue; // still being written: the next MessageWaiting picks it up
const cid = parseCallerId(info.callerid);
found.push({
msgId: info.msg_id,
folder,
callerName: cid.name,
callerNumber: cid.number,
durationS: Number(info.duration) || 0,
receivedAt: new Date(Number(info.origtime) * 1000),
filePath: `${rel}/${n.replace(/\.txt$/, '.wav')}`,
});
}
}
return found;
}
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
type Box = {
id: string;
number: string;
userId: string | null;
email: string | null;
voicemailEmail: boolean;
voicemailAttachAudio: boolean;
voicemailDeleteAfterEmail: boolean;
};
async function announce(d: VoicemailDeps, box: Box, row: Row) {
if (box.userId)
d.live.emit('message', {
type: 'voicemail.new',
data: { ...toVoicemailDto(row), userId: box.userId },
});
if (!box.voicemailEmail || !box.email) return;
const tz = await getSetting(d.db, 'general.timezone');
const from = row.callerName ?? (row.callerNumber ? formatNumber(row.callerNumber) : 'unknown');
const at = row.receivedAt.toLocaleString('en-US', { timeZone: tz });
// sv-SE formats as "2026-10-17 15:13:05".
const stamp = row.receivedAt.toLocaleString('sv-SE', { timeZone: tz }).slice(0, 16);
try {
await d.send({
to: [box.email],
subject: `New voicemail from ${from} (${mmss(row.durationS)})`,
text: [
`Caller: ${row.callerName ?? 'unknown'}`,
`Number: ${row.callerNumber ? formatNumber(row.callerNumber) : 'unknown'}`,
`Received: ${at}`,
`Length: ${mmss(row.durationS)}`,
'',
`Listen: https://${d.publicHost}/me/voicemail`,
].join('\n'),
attachments: box.voicemailAttachAudio
? [
{
filename: `voicemail-${stamp.replace(/[ :]/g, '-')}.wav`,
path: join(d.spoolDir, row.filePath),
},
]
: undefined,
});
} catch (err) {
if (!(err instanceof MailNotConfigured))
d.log.error({ err, ext: box.number, msgId: row.msgId }, 'voicemail email failed');
return;
}
await d.db
.update(voicemailMessages)
.set({ emailedAt: new Date() })
.where(eq(voicemailMessages.id, row.id));
// Only when the audio went with the email, so the message can't be lost.
if (box.voicemailDeleteAfterEmail && box.voicemailAttachAudio) {
await d.ami.action({
Action: 'VoicemailRemove',
Context: 'default',
Mailbox: box.number,
Folder: row.folder,
ID: row.msgId,
});
await d.db
.update(voicemailMessages)
.set({ deletedAt: new Date() })
.where(eq(voicemailMessages.id, row.id));
}
}
/** Brings one mailbox's rows in line with its files. New INBOX/Urgent messages are announced. */
export async function scanMailbox(d: VoicemailDeps, number: string) {
const [box] = await d.db
.select({
id: extensions.id,
number: extensions.number,
userId: extensions.userId,
email: users.email,
voicemailEmail: extensions.voicemailEmail,
voicemailAttachAudio: extensions.voicemailAttachAudio,
voicemailDeleteAfterEmail: extensions.voicemailDeleteAfterEmail,
})
.from(extensions)
.leftJoin(users, eq(users.id, extensions.userId))
.where(eq(extensions.number, number));
if (!box) return;
const found = await readMailbox(d.spoolDir, number);
const known = new Set(
(
await d.db
.select({ msgId: voicemailMessages.msgId })
.from(voicemailMessages)
.where(eq(voicemailMessages.extensionId, box.id))
).map((r) => r.msgId),
);
for (const f of found) {
const [row] = (await d.db
.insert(voicemailMessages)
.values({ ...f, extensionId: box.id })
.onConflictDoUpdate({
target: voicemailMessages.msgId,
set: { folder: f.folder, filePath: f.filePath, deletedAt: null },
})
.returning()) as [Row];
if (!known.has(f.msgId) && f.folder !== 'Old') await announce(d, box, row);
}
const seen = found.map((f) => f.msgId);
await d.db
.update(voicemailMessages)
.set({ deletedAt: new Date() })
.where(
and(
eq(voicemailMessages.extensionId, box.id),
isNull(voicemailMessages.deletedAt),
seen.length ? notInArray(voicemailMessages.msgId, seen) : undefined,
),
);
}
/** Rescans a mailbox on every MessageWaiting; one scan at a time per mailbox. */
export function watchVoicemail(d: VoicemailDeps, ami: Pick<EventEmitter, 'on'>) {
const chains = new Map<string, Promise<void>>();
const scan = (number: string) => {
const next = (chains.get(number) ?? Promise.resolve())
.then(() => scanMailbox(d, number))
.catch((err: unknown) => d.log.error({ err, ext: number }, 'voicemail scan failed'));
chains.set(number, next);
return next;
};
ami.on('event', (m: AmiMessage) => {
if (field(m, 'Event') !== 'MessageWaiting') return;
const [box, context] = (field(m, 'Mailbox') ?? '').split('@');
if (context === 'default' && box && /^\d+$/.test(box)) void scan(box);
});
return {
scan,
/** Startup and AMI reconnects: messages may have come and gone while we weren't listening. */
scanAll: async () => {
const rows = await d.db
.select({ number: extensions.number })
.from(extensions)
.where(eq(extensions.voicemailEnabled, true));
await Promise.all(rows.map((r) => scan(r.number)));
},
};
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T2: voicemail index, live event and email (04 §5.6, §10).
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared';
import { extensions, voicemailMessages } from '../src/db/schema.ts';
import { parseCallerId, watchVoicemail } from '../src/services/voicemail.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe('parseCallerId', () => {
it.each([
['"Bob Smith" <13125550000>', { name: 'Bob Smith', number: '13125550000' }],
['<102>', { name: null, number: '102' }],
['Bob <102>', { name: 'Bob', number: '102' }],
['13125550000', { name: null, number: '13125550000' }],
['Unknown', { name: null, number: null }],
['', { name: null, number: null }],
])('%s', (v, want) => expect(parseCallerId(v)).toEqual(want));
});
const tick = () => new Promise((r) => setTimeout(r, 50));
describe.skipIf(!hasTestDb)('voicemail watcher', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let w: ReturnType<typeof watchVoicemail>;
let spool: string;
let userId: string;
const live: LiveMessage[] = [];
/** Writes a message the way app_voicemail leaves it. */
function leave(folder: string, n: number, msgId: string, callerid: string, duration = 7) {
const dir = join(spool, 'default', '101', folder);
mkdirSync(dir, { recursive: true });
const base = join(dir, `msg${String(n).padStart(4, '0')}`);
writeFileSync(`${base}.wav`, 'RIFF fake audio');
writeFileSync(
`${base}.txt`,
`;\n; Message Information file\n;\n[message]\norigmailbox=101\ncallerid=${callerid}\norigtime=1792300385\nmsg_id=${msgId}\nflag=\nduration=${duration}\n`,
);
}
const rows = () => t.db.select().from(voicemailMessages).orderBy(voicemailMessages.msgId);
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
userId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
await t.app.inject({
method: 'POST',
url: '/api/extensions',
headers: { ...H, cookie },
payload: { number: '101', name: 'Dan Doe', userId },
});
spool = mkdtempSync(join(tmpdir(), 'sp-vm-'));
t.live.on('message', (m) => live.push(m));
w = watchVoicemail(
{
db: t.db,
spoolDir: spool,
ami: t.ami,
live: t.live,
send: t.app.sendMail,
publicHost: 'pbx.test',
log: t.app.log,
},
t.ami.events,
);
});
afterAll(async () => {
await t?.close();
rmSync(spool, { recursive: true, force: true });
});
it('a new message: indexed, owner told, emailed with the audio (on MessageWaiting)', async () => {
leave('INBOX', 0, '1792300385-00000000', '"Bob Smith" <13125550000>', 75);
t.ami.events.emit('event', { Event: 'MessageWaiting', Mailbox: '101@default', New: '1' });
await tick();
await w.scan('101'); // waits for the event's scan too (same mailbox chain)
const [r] = await rows();
expect(r).toMatchObject({
folder: 'INBOX',
callerName: 'Bob Smith',
callerNumber: '13125550000',
durationS: 75,
filePath: 'default/101/INBOX/msg0000.wav',
deletedAt: null,
});
expect(r!.receivedAt.getTime()).toBe(1792300385_000);
expect(r!.emailedAt).not.toBeNull();
expect(live.filter((m) => m.type === 'voicemail.new')).toEqual([
expect.objectContaining({ data: expect.objectContaining({ id: r!.id, userId }) }),
]);
expect(t.mails).toHaveLength(1);
const mail = t.mails[0]!;
expect(mail.to).toEqual(['dan@example.com']);
expect(mail.subject).toBe('New voicemail from Bob Smith (1:15)');
expect(mail.text).toContain('Number: (312) 555-0000');
expect(mail.text).toContain('https://pbx.test/me/voicemail');
expect(mail.attachments).toEqual([
{
filename: expect.stringMatching(/^voicemail-\d{4}-\d\d-\d\d-\d\d-\d\d\.wav$/),
path: join(spool, 'default/101/INBOX/msg0000.wav'),
},
]);
});
it('rescans are idempotent: no second email or event', async () => {
await w.scan('101');
expect(t.mails).toHaveLength(1);
expect(live.filter((m) => m.type === 'voicemail.new')).toHaveLength(1);
});
it('listened to on the phone (INBOX → Old): folder and path follow', async () => {
mkdirSync(join(spool, 'default/101/Old'), { recursive: true });
for (const ext of ['wav', 'txt'])
renameSync(
join(spool, `default/101/INBOX/msg0000.${ext}`),
join(spool, `default/101/Old/msg0000.${ext}`),
);
await w.scan('101');
expect((await rows())[0]).toMatchObject({
folder: 'Old',
filePath: 'default/101/Old/msg0000.wav',
});
});
it('deleted on the phone: the row is marked deleted', async () => {
rmSync(join(spool, 'default/101/Old'), { recursive: true });
await w.scan('101');
expect((await rows())[0]!.deletedAt).not.toBeNull();
});
it('delete-after-email removes it through AMI; no email setting means no email', async () => {
await t.db
.update(extensions)
.set({ voicemailDeleteAfterEmail: true })
.where(eq(extensions.number, '101'));
leave('INBOX', 0, '1792300400-00000001', '<102>');
await w.scan('101');
expect(t.ami.actions.at(-1)).toEqual({
Action: 'VoicemailRemove',
Context: 'default',
Mailbox: '101',
Folder: 'INBOX',
ID: '1792300400-00000001',
});
const r = (await rows()).find((x) => x.msgId === '1792300400-00000001')!;
expect(r.deletedAt).not.toBeNull();
await t.db
.update(extensions)
.set({ voicemailEmail: false, voicemailDeleteAfterEmail: false })
.where(eq(extensions.number, '101'));
leave('INBOX', 1, '1792300500-00000002', 'Unknown');
const before = t.mails.length;
await w.scan('101');
expect(t.mails).toHaveLength(before);
expect(live.filter((m) => m.type === 'voicemail.new')).toHaveLength(3);
});
it('a failed email is logged, the message stays indexed and is not marked emailed', async () => {
await t.db.update(extensions).set({ voicemailEmail: true }).where(eq(extensions.number, '101'));
t.mail.fail = 'smtp down';
leave('INBOX', 2, '1792300600-00000003', '<103>');
await w.scan('101');
t.mail.fail = null;
const r = (await rows()).find((x) => x.msgId === '1792300600-00000003')!;
expect(r).toMatchObject({ emailedAt: null, deletedAt: null });
});
});
+20 -15
View File
@@ -236,21 +236,26 @@ Keep one function per key type (`setDnd(ext, on)`, etc.). Routes call these afte
### 5.6 Voicemail watcher (`services/voicemail.ts`)
- Watch `/var/spool/asterisk/voicemail/default/*/{INBOX,Old,Urgent}/msg*.txt` with chokidar (`awaitWriteFinish: { stabilityThreshold: 2000 }`).
- Parse the `.txt` file: INI-like, section `[message]`, keys `callerid`, `origtime` (epoch),
`duration`, `msg_id`, `flag`. `callerid` looks like `"Name" <13125551234>`.
- **add**: upsert `voicemail_messages` by `msg_id` (update `folder`, `file_path`).
If it's new and in INBOX: fire `voicemail.new` (WS + webhook) and, if the user has
`voicemail_email` and SMTP is configured, send an email (§10) with the `.wav` attached
if `voicemail_attach_audio`. Then set `emailed_at`. If `voicemail_delete_after_email`, delete it (below).
- **unlink**: if no file with that `msg_id` is left anywhere in the mailbox, set `deleted_at`.
- **API operations** (`markRead` = move INBOX→Old, `delete`, `move`): Asterisk expects
files in each folder numbered `msg0000…msgNNNN` with no gaps. So every
operation must, under a per-mailbox lock: move/delete the `.txt` and `.wav`
together, renumber the remaining files in that folder to close gaps, then
send AMI `VoicemailRefresh Context: default Mailbox: <ext>` so MWI lights update.
First check `manager show commands` on the installed Asterisk for `VoicemailRemove`/`VoicemailMove`
actions. If they exist, use them instead and delete the renumbering code.
- **When to look:** rescan one mailbox's `INBOX`, `Old` and `Urgent` folders on every AMI
`MessageWaiting` event (`Mailbox: 101@default`), and every mailbox on each AMI (re)connect.
Asterisk raises it after a message is saved, listened to, moved or deleted, from a phone or
from us. No file watcher: inotify is unreliable on bind mounts, and this needs no dependency.
Scans of one mailbox run one at a time. (M4-T2; the spec first said chokidar.)
- Parse each `msgNNNN.txt`: INI-like, section `[message]`, keys `callerid`, `origtime` (epoch),
`duration`, `msg_id`, `flag`. `callerid` looks like `"Name" <13125551234>`. A file without
`msg_id` is still being written: skip it, the next event picks it up.
- Upsert `voicemail_messages` by `msg_id` (update `folder`, `file_path`; `file_path` is relative
to the spool, e.g. `default/101/INBOX/msg0000.wav`, because Asterisk renumbers files).
If it's new and not in `Old`: send `voicemail.new` over the live WebSocket (only to the owner,
not to admins; webhook in M11-T5) and, if the user has `voicemail_email`, an email address and
SMTP is configured, send an email (§10) with the `.wav` attached if `voicemail_attach_audio`.
Then set `emailed_at`. If `voicemail_delete_after_email` **and** the audio was attached, remove
it with AMI `VoicemailRemove` and set `deleted_at`. A failed email is logged; the message stays.
- Rows whose `msg_id` is no longer in any folder get `deleted_at`.
- **API operations** (`markRead` = INBOX→Old, `delete`): use the AMI actions `VoicemailMove`
(`Context, Mailbox, Folder, ID, ToFolder`) and `VoicemailRemove` (`Context, Mailbox, Folder, ID`),
where `ID` is the `msg_id`. They exist in Asterisk 24 (checked on 24.0.0-rc2), renumber the
folder and update MWI, so there is no renumbering code.
- Greetings: `PUT /api/me/greetings/:kind` (kind: `unavail` | `busy` | `greet` (name) | `temp`)
converts the upload to `/var/spool/asterisk/voicemail/default/<ext>/<kind>.wav`
(8 kHz mono PCM). `DELETE` removes it.
+1
View File
@@ -10,3 +10,4 @@ export * from './live.ts';
export * from './trunks.ts';
export * from './dids.ts';
export * from './outbound.ts';
export * from './voicemail.ts';
+3
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Live state shapes sent over the /api/live WebSocket (04 §5.1, §5.2).
import type { VoicemailDto } from './voicemail.ts';
export type ExtensionState =
'idle' | 'inuse' | 'busy' | 'unavailable' | 'ringing' | 'onhold' | 'ringinuse';
@@ -59,6 +60,8 @@ export type LiveMessage =
| { type: 'trunk.updated'; data: TrunkLive }
| { type: 'trunk.removed'; data: { trunkId: string } }
| { type: 'emergency.call'; data: EmergencyCall }
/** Only to the mailbox owner. */
| { type: 'voicemail.new'; data: VoicemailDto & { userId: string } }
| { type: 'ami.status'; data: { connected: boolean } }
| {
type: 'system.configApplied';
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Voicemail messages (02 voicemail_messages, 04 §5.6).
/** Asterisk's mailbox folders that we index. */
export const VOICEMAIL_FOLDERS = ['INBOX', 'Old', 'Urgent'] as const;
export type VoicemailFolder = (typeof VOICEMAIL_FOLDERS)[number];
export type VoicemailDto = {
id: string;
folder: VoicemailFolder;
callerNumber: string | null;
callerName: string | null;
durationS: number;
receivedAt: string;
};
+6
View File
@@ -45,3 +45,9 @@ baresip phones, and `trunk-test.ts` with registration against a fake provider) n
`m3-t5.mjs` is the M3-T5 acceptance and needs its own fresh database: it signs in with the
initial password, walks all ten wizard steps against a fake voip.ms, then proves an inbound call
to the DID rings the new user's softphone and an outbound call leaves with the DID as caller ID.
`m4-t2.mjs` is the M4-T2 acceptance (no browser): start m2-live with a voicemail dir
(`m2-live.mjs up <confDir> <vmDir>`) and the api with `VOICEMAIL_DIR=<vmDir>`. It runs the SMTP
sink (`smtp-sink.mjs`, shared with `m3-t4.mjs`), adds a user and extension 150 with voicemail,
leaves a message from a Local channel, and checks the email arrives within 10 s with a PCM 8 kHz
wav attached. The voicemail itself (MWI on a phone, `*97`) is covered by `pnpm test:asterisk`.
+6 -3
View File
@@ -3,9 +3,10 @@
// AMI published on 127.0.0.1:15038, the api writing its config into that container, two baresip
// phones, and the dashboard checked in a headless browser.
//
// 1. node scripts/e2e/m2-live.mjs up <confDir>
// 1. node scripts/e2e/m2-live.mjs up <confDir> [<voicemailDir>]
// starts Asterisk; then start the api with AMI_PORT=15038 AMI_SECRET=m2-live-secret
// ASTERISK_CONFIG_DIR=<confDir> and run m1-t4.mjs to get past the first login
// ASTERISK_CONFIG_DIR=<confDir> [VOICEMAIL_DIR=<voicemailDir>] and run m1-t4.mjs to get
// past the first login
// 2. node scripts/e2e/m2-live.mjs check <baseUrl> <adminEmail> <adminPassword> <shotsDir>
// 3. node scripts/e2e/m2-live.mjs down
import assert from 'node:assert/strict';
@@ -28,8 +29,9 @@ const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'up') {
const conf = args[0];
const [conf, vm] = args;
mkdirSync(conf, { recursive: true });
if (vm) mkdirSync(vm, { recursive: true });
const dir = mkdtempSync(join(tmpdir(), 'sp-live-'));
writeFileSync(
join(dir, 'manager.conf.template'),
@@ -53,6 +55,7 @@ if (cmd === 'up') {
`${conf}:/etc/asterisk/selfphoneng`,
'-v',
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
...(vm ? ['-v', `${vm}:/var/spool/asterisk/voicemail`] : []),
IMAGE,
);
assert.ok(r.ok, r.out);
+2 -31
View File
@@ -7,10 +7,10 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
import { smtpSink } from './smtp-sink.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
@@ -23,36 +23,7 @@ const docker = (...a) => {
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const [base, email, pw, shotsDir] = process.argv.slice(2);
/** Just enough SMTP for nodemailer: no auth, no TLS, keeps every message. */
const mails = [];
const smtp = createServer((sock) => {
let data = false;
let buf = '';
let msg = '';
sock.write('220 sink ESMTP\r\n');
sock.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\r\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 2);
if (data) {
if (line === '.') {
data = false;
mails.push(msg);
msg = '';
sock.write('250 ok\r\n');
} else msg += line + '\n';
} else if (/^EHLO/i.test(line)) sock.write('250-sink\r\n250 OK\r\n');
else if (/^DATA/i.test(line)) {
data = true;
sock.write('354 go\r\n');
} else if (/^QUIT/i.test(line)) sock.end('221 bye\r\n');
else sock.write('250 ok\r\n');
}
});
});
await new Promise((r) => smtp.listen(2525, '127.0.0.1', r));
const { mails, server: smtp } = await smtpSink();
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T2 acceptance: a voicemail left in Asterisk is emailed within 10 s with a playable wav.
// Runs on the m2-live setup started with a voicemail dir (m2-live.mjs up <confDir> <vmDir>) and the
// api started with VOICEMAIL_DIR=<vmDir>, after m1-t4.mjs. Runs its own SMTP sink on 127.0.0.1:2525.
// The caller is a Local channel playing a sound file, so no phones are needed.
// Usage: node scripts/e2e/m4-t2.mjs <baseUrl> <adminEmail> <adminPassword> <vmDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { smtpSink } from './smtp-sink.mjs';
const AST = 'sp-live-ast';
const EXT = '150';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const [base, email, pw, vmDir] = process.argv.slice(2);
const { mails, server } = await smtpSink();
try {
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
assert.equal(login.status, 200, 'admin login');
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: body && JSON.stringify(body),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
await api('PATCH', '/api/settings', {
smtp: {
host: '127.0.0.1',
port: 2525,
secure: false,
username: '',
from: 'PBX <pbx@example.com>',
},
});
const user = await api('POST', '/api/users', {
email: 'val@example.com',
displayName: 'Val Mail',
});
await api('POST', '/api/extensions', {
number: EXT,
name: 'Val Mail',
userId: (user.user ?? user).id,
});
for (
let i = 0;
i < 20 && !new RegExp(`^default\\s+${EXT}\\s`, 'm').test(cli('voicemail show users'));
i++
)
await sleep(500);
assert.match(
cli('voicemail show users'),
new RegExp(`^default\\s+${EXT}\\s`, 'm'),
'mailbox in Asterisk',
);
console.log(`mailbox ${EXT} configured`);
// Straight to voicemail; the "caller" waits out the greeting, then speaks (~6 s).
cli(
`channel originate Local/${EXT}@sp-vm-direct application Playback silence/10&silence/5&tt-weasels&tt-weasels`,
);
const txt = join(vmDir, 'default', EXT, 'INBOX', 'msg0000.txt');
for (let i = 0; i < 240 && !existsSync(txt); i++) await sleep(250);
assert.ok(existsSync(txt), 'Asterisk saved the message');
const t0 = Date.now();
console.log('message saved');
while (!mails.length && Date.now() - t0 < 10_000) await sleep(100);
assert.equal(mails.length, 1, 'one email within 10 s');
console.log(`email arrived after ${((Date.now() - t0) / 1000).toFixed(1)} s`);
const raw = mails[0];
assert.match(raw, /^To: val@example\.com$/m);
assert.match(raw, /^Subject: New voicemail from unknown \(0:\d\d\)$/m);
const boundary = /boundary="?([^";\n]+)"?/.exec(raw)?.[1];
assert.ok(boundary, 'multipart email');
const part = raw.split(`--${boundary}`).find((p) => /filename="?voicemail-[\d-]+\.wav/.test(p));
assert.ok(part, 'wav attachment');
const wav = Buffer.from(part.split(/\n\n/).slice(1).join('').replace(/\s+/g, ''), 'base64');
assert.equal(wav.toString('ascii', 0, 4), 'RIFF');
assert.equal(wav.toString('ascii', 8, 12), 'WAVE');
assert.equal(wav.readUInt16LE(20), 1, 'PCM');
assert.equal(wav.readUInt16LE(22), 1, 'mono');
assert.equal(wav.readUInt32LE(24), 8000, '8 kHz');
const seconds = (wav.length - 44) / 16000;
assert.ok(seconds >= 2, `audio is ${seconds.toFixed(1)} s`);
console.log(`attachment: PCM 8 kHz mono, ${seconds.toFixed(1)} s`);
console.log('M4-T2 (voicemail email): PASS');
} catch (e) {
console.log(`FAIL: ${e.message}`);
console.log(spawnSync('docker', ['logs', '--tail', '40', AST], { encoding: 'utf8' }).stdout);
process.exitCode = 1;
} finally {
server.close();
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Just enough SMTP for nodemailer: no auth, no TLS, keeps every message (raw RFC 5322 text).
import { createServer } from 'node:net';
export async function smtpSink(port = 2525) {
const mails = [];
const server = createServer((sock) => {
let data = false;
let buf = '';
let msg = '';
sock.write('220 sink ESMTP\r\n');
sock.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\r\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 2);
if (data) {
if (line === '.') {
data = false;
mails.push(msg);
msg = '';
sock.write('250 ok\r\n');
} else msg += line.replace(/^\.\./, '.') + '\n';
} else if (/^EHLO/i.test(line)) sock.write('250-sink\r\n250 OK\r\n');
else if (/^DATA/i.test(line)) {
data = true;
sock.write('354 go\r\n');
} else if (/^QUIT/i.test(line)) sock.end('221 bye\r\n');
else sock.write('250 ok\r\n');
}
});
});
await new Promise((r) => server.listen(port, '127.0.0.1', r));
return { mails, server };
}