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:
1 parent
0d29131d18
commit
8d752ba705
16 files changed
+642
-58
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from './live.ts';
|
||||
export * from './trunks.ts';
|
||||
export * from './dids.ts';
|
||||
export * from './outbound.ts';
|
||||
export * from './voicemail.ts';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user