/api/announcements CRUD (sound must be a prompt, destination must exist
and not be itself, delete refused while referenced); the generator
renders sp-ann-{id} (Answer, Playback, destApp) and destApp sends
announcement destinations there. The ivr golden case gains "press 3:
hours announcement, then back to the menu". Announcements are managed in
a section of Sounds & music (05 updated); the DestinationPicker gains
"Announcement".
Task: M6-T5
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M4-T1 check, automated (09 M4-T1): 102 calls 101, nobody answers, 102 leaves a message, the
|
|
// message-waiting light on 101's phone turns on (baresip mwi module), and *97 plays it.
|
|
// Same setup as call-test.ts: private Docker bridge, baresip phones, no image builds.
|
|
// Usage: node --conditions=source apps/api/test/asterisk/voicemail-test.ts
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
|
import { AmiClient } from '../../src/ami/client.ts';
|
|
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
|
import type { Snapshot } from '../../src/generator/load.ts';
|
|
|
|
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
|
const NET = 'sp-vm';
|
|
const AST = 'sp-vm-ast';
|
|
const PHONES = 'sp-vm-phones';
|
|
const AMI_PORT = 15039;
|
|
const AMI_SECRET = 'vm-test-secret';
|
|
const PASS = { '101': 'VmTest101xxxxxxxxxxxxxxx', '102': 'VmTest102xxxxxxxxxxxxxxx' };
|
|
const PIN = '4826';
|
|
|
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
const docker = (...args: string[]) => {
|
|
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
|
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
|
};
|
|
const log = (...a: unknown[]) => console.log(`[voicemail-test]`, ...a);
|
|
|
|
function snapshot(): Snapshot {
|
|
const settings = Object.fromEntries(
|
|
(Object.keys(SettingsSchemas) as SettingKey[])
|
|
.filter((k) => k !== 'sms.webhookToken')
|
|
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
|
|
) as Snapshot['settings'];
|
|
const ext = (number: '101' | '102', name: string) => ({
|
|
id: `00000000-0000-4000-8000-000000000${number}`,
|
|
number,
|
|
name,
|
|
sipPassword: PASS[number],
|
|
webrtcEnabled: false,
|
|
maxContacts: 5,
|
|
ringSeconds: 5,
|
|
voicemailEnabled: true,
|
|
voicemailPin: PIN,
|
|
outboundClass: 'national' as const,
|
|
outboundCallerId: null,
|
|
emergencyCallerId: null,
|
|
recordMode: 'never' as const,
|
|
pickupGroup: null,
|
|
language: 'en',
|
|
mohClass: 'default',
|
|
isAdmin: false,
|
|
});
|
|
return {
|
|
today: '2026-10-17',
|
|
settings,
|
|
dids: [],
|
|
trunks: [],
|
|
routes: [],
|
|
ringGroups: [],
|
|
ivrs: [],
|
|
timeConditions: [],
|
|
holidays: [],
|
|
announcements: [],
|
|
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
|
};
|
|
}
|
|
|
|
/** 101 never answers and subscribes to MWI; 102 auto-answers and "talks" (a sine tone). */
|
|
function phoneConfig(dir: string, ext: '101' | '102', port: number) {
|
|
const d = join(dir, `phone-${ext}`);
|
|
spawnSync('mkdir', ['-p', d]);
|
|
writeFileSync(
|
|
join(d, 'config'),
|
|
[
|
|
'poll_method epoll',
|
|
`sip_listen 0.0.0.0:${port}`,
|
|
'module_path /usr/lib/baresip/modules',
|
|
'module g711.so',
|
|
'module ausine.so',
|
|
'module aufile.so',
|
|
'module menu.so',
|
|
'module account.so',
|
|
'module mwi.so',
|
|
'audio_source ausine,880',
|
|
'ausrc_srate 48000',
|
|
'auplay_srate 48000',
|
|
'audio_player aufile,/dev/null',
|
|
'audio_alert aufile,/dev/null',
|
|
'rtp_ports 30000-30100',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
const answer = ext === '102' ? 'auto' : 'manual';
|
|
writeFileSync(
|
|
join(d, 'accounts'),
|
|
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=${answer};audio_codecs=PCMU;regint=60;mwi=yes\n`,
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const dir = mkdtempSync(join(tmpdir(), 'sp-vm-'));
|
|
const conf = join(dir, 'conf');
|
|
spawnSync('mkdir', ['-p', conf]);
|
|
const files = render(snapshot());
|
|
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
|
|
writeFileSync(
|
|
join(dir, 'manager.conf.template'),
|
|
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
|
);
|
|
phoneConfig(dir, '101', 5070);
|
|
phoneConfig(dir, '102', 5072);
|
|
|
|
const problems: string[] = [];
|
|
const ami = new AmiClient({
|
|
host: '127.0.0.1',
|
|
port: AMI_PORT,
|
|
username: 'selfphoneng',
|
|
secret: AMI_SECRET,
|
|
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
backoffMs: [500],
|
|
});
|
|
const count = async () => {
|
|
const r = await ami.action({ Action: 'MailboxCount', Mailbox: '101@default' });
|
|
return { new: Number(r.NewMessages), old: Number(r.OldMessages) };
|
|
};
|
|
const waitIdle = async () => {
|
|
for (
|
|
let i = 0;
|
|
i < 20 && /PJSIP\/|Local\//.test(await ami.command('core show channels concise'));
|
|
i++
|
|
)
|
|
await sleep(500);
|
|
};
|
|
try {
|
|
docker('rm', '-f', AST, PHONES);
|
|
docker('network', 'create', NET);
|
|
const run = docker(
|
|
'run',
|
|
'-d',
|
|
'--rm',
|
|
'--name',
|
|
AST,
|
|
'--network',
|
|
NET,
|
|
'-p',
|
|
`127.0.0.1:${AMI_PORT}:5038`,
|
|
'-e',
|
|
`AMI_SECRET=${AMI_SECRET}`,
|
|
'-v',
|
|
`${conf}:/etc/asterisk/selfphoneng`,
|
|
'-v',
|
|
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
|
|
IMAGE,
|
|
);
|
|
if (!run.ok) throw new Error(`asterisk: ${run.out}`);
|
|
for (
|
|
let i = 0;
|
|
i < 60 && !docker('exec', AST, 'asterisk', '-rx', 'core waitfullybooted').ok;
|
|
i++
|
|
)
|
|
await sleep(500);
|
|
ami.start();
|
|
await new Promise((r) => ami.once('connected', r));
|
|
|
|
log('starting baresip phones…');
|
|
const phones = docker(
|
|
'run',
|
|
'-d',
|
|
'--rm',
|
|
'--name',
|
|
PHONES,
|
|
'--network',
|
|
NET,
|
|
'-v',
|
|
`${dir}:/phones`,
|
|
'debian:trixie-slim',
|
|
'sh',
|
|
'-c',
|
|
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' +
|
|
'(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && ' +
|
|
'(baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 600',
|
|
);
|
|
if (!phones.ok) throw new Error(`phones: ${phones.out}`);
|
|
let contacts = '';
|
|
for (let i = 0; i < 180; i++) {
|
|
contacts = await ami.command('pjsip show contacts').catch(() => '');
|
|
if (/101\/sip:.*Avail/.test(contacts) && /102\/sip:.*Avail/.test(contacts)) break;
|
|
await sleep(1000);
|
|
}
|
|
if (!/101\/sip:.*Avail/.test(contacts) || !/102\/sip:.*Avail/.test(contacts))
|
|
throw new Error(`phones did not register:\n${contacts}`);
|
|
log('101 and 102 registered');
|
|
const before = await count();
|
|
if (before.new !== 0) problems.push(`mailbox not empty at start: ${JSON.stringify(before)}`);
|
|
|
|
// 102 picks up (auto-answer), then calls 101 like a normal internal call; 101 rings 5 s.
|
|
await ami.action({
|
|
Action: 'Originate',
|
|
Channel: 'PJSIP/102',
|
|
Context: 'sp-from-ext',
|
|
Exten: '101',
|
|
Priority: '1',
|
|
CallerID: '"Bob" <102>',
|
|
Async: 'true',
|
|
});
|
|
let recording = false;
|
|
for (let i = 0; i < 30 && !recording; i++) {
|
|
await sleep(1000);
|
|
recording = /!VoiceMail!/.test(await ami.command('core show channels concise'));
|
|
}
|
|
if (!recording) throw new Error('the unanswered call did not reach VoiceMail');
|
|
log('no answer → voicemail; waiting for the greeting and beep');
|
|
for (let i = 0; i < 40 && !/Recording the message/.test(docker('logs', AST).out); i++)
|
|
await sleep(500);
|
|
await sleep(5000); // five seconds of 880 Hz "speech"
|
|
await ami.command('channel request hangup all');
|
|
await waitIdle();
|
|
await sleep(1000);
|
|
|
|
const after = await count();
|
|
log(`mailbox 101: ${after.new} new, ${after.old} old`);
|
|
if (after.new !== 1) problems.push(`expected 1 new message, got ${JSON.stringify(after)}`);
|
|
const inbox = docker('exec', AST, 'ls', '/var/spool/asterisk/voicemail/default/101/INBOX').out;
|
|
if (!/msg0000\.wav/.test(inbox) || !/msg0000\.txt/.test(inbox))
|
|
problems.push(`no msg0000.wav/.txt in INBOX: ${inbox}`);
|
|
const meta = docker(
|
|
'exec',
|
|
AST,
|
|
'cat',
|
|
'/var/spool/asterisk/voicemail/default/101/INBOX/msg0000.txt',
|
|
).out;
|
|
const secs = Number(/^duration=(\d+)/m.exec(meta)?.[1]);
|
|
if (!/^callerid=.*<102>/m.test(meta) || !(secs >= 2))
|
|
problems.push(`unexpected message metadata:\n${meta}`);
|
|
|
|
// The phone's light: baresip prints the NOTIFY body it gets for its MWI subscription.
|
|
let mwiOn = false;
|
|
for (let i = 0; i < 10 && !mwiOn; i++) {
|
|
mwiOn = /Messages-Waiting:\s*yes/i.test(readFileSync(join(dir, 'phone-101', 'log'), 'utf8'));
|
|
if (!mwiOn) await sleep(1000);
|
|
}
|
|
if (!mwiOn) problems.push('101 never got Messages-Waiting: yes');
|
|
else log('101 MWI on');
|
|
|
|
// *97 from 101: log in with the PIN, press 1 to hear the new message. The trailing pauses
|
|
// keep the caller side up (the call ends when SendDTMF returns).
|
|
await ami.action({
|
|
Action: 'Originate',
|
|
Channel: 'Local/*97@sp-from-ext',
|
|
Application: 'SendDTMF',
|
|
Data: `${'w'.repeat(6)}${PIN}#${'w'.repeat(16)}1${'w'.repeat(60)}`,
|
|
Variable: '__SP_EXT=101',
|
|
Async: 'true',
|
|
});
|
|
let played = false;
|
|
for (let i = 0; i < 60 && !played; i++) {
|
|
await sleep(500);
|
|
played = /Playing '[^']*voicemail\/default\/101\/INBOX\/msg0000/.test(
|
|
docker('logs', AST).out,
|
|
);
|
|
}
|
|
if (!played) problems.push('*97 did not play msg0000');
|
|
else log('*97 is playing the message');
|
|
await sleep(3000);
|
|
await ami.command('channel request hangup all');
|
|
await waitIdle();
|
|
await sleep(1000);
|
|
const heard = await count();
|
|
if (heard.new !== 0 || heard.old !== 1)
|
|
problems.push(`after *97 expected 0 new / 1 old, got ${JSON.stringify(heard)}`);
|
|
for (let i = 0; i < 10; i++) {
|
|
const lines = readFileSync(join(dir, 'phone-101', 'log'), 'utf8').match(
|
|
/Messages-Waiting:\s*\w+/gi,
|
|
);
|
|
if (lines?.at(-1)?.toLowerCase().endsWith('no')) break;
|
|
if (i === 9) problems.push(`101 MWI did not turn off (last: ${lines?.at(-1)})`);
|
|
await sleep(1000);
|
|
}
|
|
|
|
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
|
const plainLog = docker('logs', AST).out.replace(/\x1b\[[0-9;]*m/g, '');
|
|
problems.push(
|
|
...plainLog
|
|
.split('\n')
|
|
.filter((l) => /ERROR\[/.test(l))
|
|
.map((l) => `log: ${l.trim()}`),
|
|
);
|
|
} catch (e) {
|
|
problems.push((e as Error).message);
|
|
} finally {
|
|
if (problems.length) log(`asterisk log (tail):\n${docker('logs', '--tail', '60', AST).out}`);
|
|
if (problems.length)
|
|
for (const e of ['101', '102'])
|
|
log(`phone ${e} log (tail):
|
|
${docker('exec', PHONES, 'tail', '-n', '25', `/phones/phone-${e}/log`).out}`);
|
|
ami.stop();
|
|
docker('rm', '-f', PHONES, AST);
|
|
docker('network', 'rm', NET);
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
if (problems.length) {
|
|
console.log('FAIL voicemail');
|
|
for (const p of problems) console.log(` ${p}`);
|
|
process.exit(1);
|
|
}
|
|
console.log('ok voicemail (leave, MWI on, *97 plays, MWI off)');
|
|
}
|
|
|
|
await main();
|