me: voicemail page, greetings, My Phone home
ci / images (push) Canceled after 0s
ci / check (push) Canceled after 25s

/api/me/voicemail lists, streams (Range, download) and marks read or
deletes through AMI VoicemailMove/VoicemailRemove, then rescans so MWI
and the list agree. /api/me/greetings takes the raw recording or file and
converts it with ffmpeg to 8 kHz PCM (media/audio.ts). My Phone gets a
layout with Home (new voicemails) and Voicemail (play marks read,
download, delete, record/upload greetings via MediaRecorder); the list
refetches on the live voicemail.new event. e2e m4-t3 checks MWI goes off
within 5 s after play and delete, and a browser-recorded greeting is what
callers hear.

Task: M4-T3
This commit is contained in:
iamdoubz committed 2026-10-17 22:56:10 -05:00
1 parent fe2eb4cc22
commit f60dda0bb0
18 files changed
+978 -42

No files matched your search

+8 -3
View File
@@ -20,6 +20,7 @@ import { trunkRoutes } from './routes/trunks.ts';
import { didRoutes } from './routes/dids.ts'; import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts'; import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts'; import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {
@@ -91,10 +92,14 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(outboundRouteRoutes); app.register(outboundRouteRoutes);
app.register(settingsRoutes); app.register(settingsRoutes);
app.register(liveRoutes); app.register(liveRoutes);
app.register(meRoutes);
if (existsSync(publicDir)) { // Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, { root: publicDir, wildcard: false }); app.register(fastifyStatic, {
} root: publicDir,
serve: existsSync(publicDir),
wildcard: false,
});
app.setNotFoundHandler((req, reply) => { app.setNotFoundHandler((req, reply) => {
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url)) if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
return reply.sendFile('index.html'); return reply.sendFile('index.html');
+1
View File
@@ -63,6 +63,7 @@ const voicemail = watchVoicemail(
}, },
ami, ami,
); );
app.decorate('voicemail', voicemail);
app.decorate('live', live); app.decorate('live', live);
await runMigrations(db); await runMigrations(db);
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Audio uploads → what Asterisk plays (04 §6). ffmpeg is in the api image; spawned without a shell.
import { spawn } from 'node:child_process';
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
export class AudioError extends Error {}
/**
* Any audio ffmpeg reads (wav, mp3, ogg, opus, webm from MediaRecorder, m4a, flac) → 8 kHz mono
* 16-bit PCM wav at `out`, loudness-normalized. Written next to `out` and renamed, so Asterisk
* never plays a half-written file.
*/
export async function toPcmWav(input: Buffer, out: string): Promise<void> {
const dir = await mkdtemp(join(tmpdir(), 'sp-audio-'));
const src = join(dir, 'in'); // ffmpeg sniffs the format; m4a can't be read from a pipe
try {
await writeFile(src, input);
// ponytail: longer files are cut at 15 minutes instead of rejected (04 §6 says max 15 min).
const args = ['-nostdin', '-y', '-v', 'error', '-i', src, '-t', '900', '-vn', '-ac', '1'];
args.push('-ar', '8000', '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-c:a', 'pcm_s16le');
args.push('-f', 'wav', `${out}.tmp`);
await new Promise<void>((resolve, reject) => {
const p = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
p.stderr.on('data', (d: Buffer) => (err += d.toString()));
p.on('error', reject);
p.on('close', (code) => (code === 0 ? resolve() : reject(new AudioError(err.trim()))));
});
await rename(`${out}.tmp`, out);
} catch (e) {
await rm(`${out}.tmp`, { force: true });
throw e instanceof AudioError ? e : new AudioError((e as Error).message);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3): the signed-in user's own mailbox.
import { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { and, desc, eq, isNull } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { GREETINGS, type ListResponse, type VoicemailDto } from '@selfphoneng/shared';
import { requireAuth } from '../auth/guards.ts';
import { extensions, voicemailMessages } from '../db/schema.ts';
import { badRequest, HttpError, notFound } from '../errors.ts';
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
import { toVoicemailDto, type watchVoicemail } from '../services/voicemail.ts';
declare module 'fastify' {
interface FastifyInstance {
voicemail: ReturnType<typeof watchVoicemail>;
}
}
const IdParams = z.object({ id: z.uuid() });
const KindParams = z.object({ kind: z.enum(GREETINGS) });
const MoveBody = z.object({ folder: z.enum(['INBOX', 'Old']) });
export async function meRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAuth);
// Greetings arrive as the raw request body (a recorded Blob or a picked File), any audio type.
app.addContentTypeParser('*', { parseAs: 'buffer', bodyLimit: MAX_UPLOAD_BYTES }, (_r, b, done) =>
done(null, b),
);
async function myExtension(userId: string) {
const [ext] = await db
.select({ id: extensions.id, number: extensions.number })
.from(extensions)
.where(eq(extensions.userId, userId));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
return ext;
}
async function myMessage(userId: string, id: string) {
const ext = await myExtension(userId);
const [row] = await db
.select()
.from(voicemailMessages)
.where(
and(
eq(voicemailMessages.id, id),
eq(voicemailMessages.extensionId, ext.id),
isNull(voicemailMessages.deletedAt),
),
);
if (!row) throw notFound('Message not found');
return { ext, row };
}
/** VoicemailMove/Remove renumber the folder and update MWI (04 §5.6); then re-read the box. */
async function mailboxAction(fields: Record<string, string>, ext: string) {
if (!app.ami.isConnected())
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
await app.ami.action({ Context: 'default', Mailbox: ext, ...fields });
await app.voicemail.scan(ext);
}
app.get('/api/me/voicemail', async (req): Promise<ListResponse<VoicemailDto>> => {
const ext = await myExtension(req.user!.id);
const rows = await db
.select()
.from(voicemailMessages)
.where(and(eq(voicemailMessages.extensionId, ext.id), isNull(voicemailMessages.deletedAt)))
.orderBy(desc(voicemailMessages.receivedAt));
return { items: rows.map(toVoicemailDto), nextCursor: null };
});
app.get('/api/me/voicemail/:id/audio', async (req, reply) => {
const { row } = await myMessage(req.user!.id, IdParams.parse(req.params).id);
if ((req.query as { download?: string }).download)
reply.header(
'content-disposition',
`attachment; filename="voicemail-${row.receivedAt.toISOString().slice(0, 16).replace(/[T:]/g, '-')}.wav"`,
);
return reply.sendFile(row.filePath, app.voicemail.spoolDir); // handles Range
});
app.patch('/api/me/voicemail/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const { folder } = MoveBody.parse(req.body);
const { ext, row } = await myMessage(req.user!.id, id);
if (row.folder !== folder)
await mailboxAction(
{ Action: 'VoicemailMove', Folder: row.folder, ID: row.msgId, ToFolder: folder },
ext.number,
);
return toVoicemailDto((await myMessage(req.user!.id, id)).row);
});
app.delete('/api/me/voicemail/:id', async (req) => {
const { ext, row } = await myMessage(req.user!.id, IdParams.parse(req.params).id);
await mailboxAction(
{ Action: 'VoicemailRemove', Folder: row.folder, ID: row.msgId },
ext.number,
);
return { ok: true };
});
const greetingPath = (ext: string, kind: string) =>
join(app.voicemail.spoolDir, 'default', ext, `${kind}.wav`);
app.get('/api/me/greetings', async (req) => {
const ext = await myExtension(req.user!.id);
return Object.fromEntries(GREETINGS.map((k) => [k, existsSync(greetingPath(ext.number, k))]));
});
app.get('/api/me/greetings/:kind/audio', async (req, reply) => {
const { kind } = KindParams.parse(req.params);
const ext = await myExtension(req.user!.id);
if (!existsSync(greetingPath(ext.number, kind))) throw notFound('No greeting recorded');
return reply.sendFile(`default/${ext.number}/${kind}.wav`, app.voicemail.spoolDir);
});
app.put('/api/me/greetings/:kind', async (req) => {
const { kind } = KindParams.parse(req.params);
const ext = await myExtension(req.user!.id);
if (!Buffer.isBuffer(req.body) || req.body.length === 0)
throw badRequest('Send the audio file');
const out = greetingPath(ext.number, kind);
await mkdir(join(out, '..'), { recursive: true });
try {
await toPcmWav(req.body, out);
} catch (e) {
if (!(e instanceof AudioError)) throw e;
req.log.info({ err: e.message }, 'greeting conversion failed');
throw badRequest("That file isn't audio we can read");
}
return { ok: true };
});
app.delete('/api/me/greetings/:kind', async (req) => {
const { kind } = KindParams.parse(req.params);
const ext = await myExtension(req.user!.id);
await rm(greetingPath(ext.number, kind), { force: true });
return { ok: true };
});
}
+1
View File
@@ -221,6 +221,7 @@ export function watchVoicemail(d: VoicemailDeps, ami: Pick<EventEmitter, 'on'>)
if (context === 'default' && box && /^\d+$/.test(box)) void scan(box); if (context === 'default' && box && /^\d+$/.test(box)) void scan(box);
}); });
return { return {
spoolDir: d.spoolDir,
scan, scan,
/** Startup and AMI reconnects: messages may have come and gone while we weren't listening. */ /** Startup and AMI reconnects: messages may have come and gone while we weren't listening. */
scanAll: async () => { scanAll: async () => {
+18
View File
@@ -17,6 +17,7 @@ import { createGenerator } from '../src/generator/index.ts';
import { LiveState } from '../src/live/state.ts'; import { LiveState } from '../src/live/state.ts';
import type { AmiMessage } from '../src/ami/events.ts'; import type { AmiMessage } from '../src/ami/events.ts';
import type { Mail } from '../src/services/mail.ts'; import type { Mail } from '../src/services/mail.ts';
import { watchVoicemail } from '../src/services/voicemail.ts';
import { createTestDb } from './db.ts'; import { createTestDb } from './db.ts';
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */ /** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
@@ -90,6 +91,22 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
onApplied: () => void live.loadFromDb().catch(() => {}), // may land after close() onApplied: () => void live.loadFromDb().catch(() => {}), // may land after close()
}), }),
); );
const spoolDir = mkdtempSync(join(tmpdir(), 'sp-vm-'));
app.decorate(
'voicemail',
watchVoicemail(
{
db: t.db,
spoolDir,
ami,
live,
send: (m) => app.sendMail(m),
publicHost: 'pbx.test',
log: app.log,
},
ami.events,
),
);
extend?.(app); extend?.(app);
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password); await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
resetLoginLimiter(); resetLoginLimiter();
@@ -129,6 +146,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
mail, mail,
live, live,
genDir, genDir,
spoolDir,
login, login,
addUser, addUser,
clearMustChange, clearMustChange,
+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T3: /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3).
import { spawnSync } from 'node:child_process';
import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const hasFfmpeg = spawnSync('ffmpeg', ['-version']).status === 0;
/** 8 kHz mono silence as a wav file. */
function silenceWav(seconds = 1) {
const data = Buffer.alloc(8000 * 2 * seconds);
const h = Buffer.alloc(44);
h.write('RIFF', 0);
h.writeUInt32LE(36 + data.length, 4);
h.write('WAVEfmt ', 8);
h.writeUInt32LE(16, 16);
h.writeUInt16LE(1, 20);
h.writeUInt16LE(1, 22);
h.writeUInt32LE(8000, 24);
h.writeUInt32LE(16000, 28);
h.writeUInt16LE(2, 32);
h.writeUInt16LE(16, 34);
h.write('data', 36);
h.writeUInt32LE(data.length, 40);
return Buffer.concat([h, data]);
}
type Inject = (
method: string,
url: string,
payload?: unknown,
headers?: Record<string, string>,
) => Promise<LightMyRequestResponse>;
describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let dan: Inject;
let eve: Inject;
const box = () => join(t.spoolDir, 'default', '101');
const leave = (msgId: string, callerid: string) => {
mkdirSync(join(box(), 'INBOX'), { recursive: true });
writeFileSync(join(box(), 'INBOX', 'msg0000.wav'), silenceWav());
writeFileSync(
join(box(), 'INBOX', 'msg0000.txt'),
`[message]\ncallerid=${callerid}\norigtime=1792300385\nmsg_id=${msgId}\nduration=1\n`,
);
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const admin = await t.login(ADMIN.email, ADMIN.password);
const danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
await t.addUser('eve@example.com', 'eve-password-123');
await t.app.inject({
method: 'POST',
url: '/api/extensions',
headers: { ...H, cookie: admin },
payload: { number: '101', name: 'Dan Doe', userId: danId },
});
const as =
(cookie: string): Inject =>
(method, url, payload, headers) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie, ...headers },
});
dan = as(await t.login('dan@example.com', 'dan-password-123'));
eve = as(await t.login('eve@example.com', 'eve-password-123'));
// Asterisk's side of VoicemailMove/VoicemailRemove, on disk (one message, msg0000).
t.ami.action = async function (f: Record<string, string>) {
this.actions.push(f);
const from = join(box(), f.Folder!, 'msg0000');
if (f.Action === 'VoicemailMove') {
mkdirSync(join(box(), f.ToFolder!), { recursive: true });
for (const x of ['wav', 'txt'])
renameSync(`${from}.${x}`, join(box(), f.ToFolder!, `msg0000.${x}`));
}
if (f.Action === 'VoicemailRemove') for (const x of ['wav', 'txt']) rmSync(`${from}.${x}`);
return { Response: 'Success' };
};
leave('1792300385-00000000', '"Bob" <13125550000>');
await t.app.voicemail.scan('101');
});
afterAll(() => t?.close());
it('lists my messages; someone without an extension gets a 404', async () => {
const r = await dan('GET', '/api/me/voicemail');
expect(r.statusCode).toBe(200);
expect(r.json().items).toEqual([
expect.objectContaining({ folder: 'INBOX', callerName: 'Bob', durationS: 1 }),
]);
expect((await eve('GET', '/api/me/voicemail')).statusCode).toBe(404);
});
it('streams the audio with Range support; only to the owner', async () => {
const [m] = (await dan('GET', '/api/me/voicemail')).json().items;
const full = await dan('GET', `/api/me/voicemail/${m.id}/audio`);
expect(full.statusCode).toBe(200);
expect(full.headers['content-type']).toMatch(/^audio\/(x-)?wav/);
expect(full.rawPayload.subarray(0, 4).toString()).toBe('RIFF');
const part = await dan('GET', `/api/me/voicemail/${m.id}/audio`, undefined, {
range: 'bytes=0-99',
});
expect(part.statusCode).toBe(206);
expect(part.rawPayload).toHaveLength(100);
const dl = await dan('GET', `/api/me/voicemail/${m.id}/audio?download=1`);
expect(dl.headers['content-disposition']).toMatch(/^attachment; filename="voicemail-/);
expect((await eve('GET', `/api/me/voicemail/${m.id}/audio`)).statusCode).toBe(404);
});
it('mark read moves it to Old through AMI, then delete removes it', async () => {
const [m] = (await dan('GET', '/api/me/voicemail')).json().items;
const r = await dan('PATCH', `/api/me/voicemail/${m.id}`, { folder: 'Old' });
expect(r.statusCode).toBe(200);
expect(r.json()).toMatchObject({ id: m.id, folder: 'Old' });
expect(t.ami.actions.at(-1)).toEqual({
Context: 'default',
Mailbox: '101',
Action: 'VoicemailMove',
Folder: 'INBOX',
ID: '1792300385-00000000',
ToFolder: 'Old',
});
expect((await dan('DELETE', `/api/me/voicemail/${m.id}`)).statusCode).toBe(200);
expect(t.ami.actions.at(-1)).toMatchObject({ Action: 'VoicemailRemove', Folder: 'Old' });
expect((await dan('GET', '/api/me/voicemail')).json().items).toEqual([]);
expect((await dan('DELETE', `/api/me/voicemail/${m.id}`)).statusCode).toBe(404);
});
it('a mailbox action while Asterisk is down is a 503', async () => {
leave('1792300999-00000001', '<102>');
await t.app.voicemail.scan('101');
const [m] = (await dan('GET', '/api/me/voicemail')).json().items;
t.ami.connected = false;
expect((await dan('DELETE', `/api/me/voicemail/${m.id}`)).statusCode).toBe(503);
t.ami.connected = true;
});
it.skipIf(!hasFfmpeg)('greetings: upload any audio → 8 kHz wav; play; delete', async () => {
expect((await dan('GET', '/api/me/greetings')).json()).toEqual({
unavail: false,
busy: false,
greet: false,
temp: false,
});
const put = await dan('PUT', '/api/me/greetings/unavail', silenceWav(2), {
'content-type': 'audio/webm',
});
expect(put.statusCode, put.body).toBe(200);
expect((await dan('GET', '/api/me/greetings')).json().unavail).toBe(true);
const audio = await dan('GET', '/api/me/greetings/unavail/audio');
expect(audio.statusCode).toBe(200);
expect(audio.rawPayload.readUInt32LE(24)).toBe(8000);
const junk = await dan('PUT', '/api/me/greetings/busy', Buffer.from('not audio'), {
'content-type': 'audio/ogg',
});
expect(junk.statusCode).toBe(400);
expect((await dan('PUT', '/api/me/greetings/nope', silenceWav())).statusCode).toBe(400);
expect((await dan('DELETE', '/api/me/greetings/unavail')).statusCode).toBe(200);
expect((await dan('GET', '/api/me/greetings/unavail/audio')).statusCode).toBe(404);
});
});
+4 -16
View File
@@ -1,13 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T2: voicemail index, live event and email (04 §5.6, §10). // M4-T2: voicemail index, live event and email (04 §5.6, §10).
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared'; import type { LiveMessage } from '@selfphoneng/shared';
import { extensions, voicemailMessages } from '../src/db/schema.ts'; import { extensions, voicemailMessages } from '../src/db/schema.ts';
import { parseCallerId, watchVoicemail } from '../src/services/voicemail.ts'; import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
import { hasTestDb } from './db.ts'; import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts'; import { ADMIN, H, createTestApp } from './app.ts';
@@ -55,20 +54,9 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
headers: { ...H, cookie }, headers: { ...H, cookie },
payload: { number: '101', name: 'Dan Doe', userId }, payload: { number: '101', name: 'Dan Doe', userId },
}); });
spool = mkdtempSync(join(tmpdir(), 'sp-vm-')); spool = t.spoolDir;
t.live.on('message', (m) => live.push(m)); t.live.on('message', (m) => live.push(m));
w = watchVoicemail( w = t.app.voicemail;
{
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 () => { afterAll(async () => {
await t?.close(); await t?.close();
+30
View File
@@ -8,6 +8,7 @@ import type {
ExtensionCreate, ExtensionCreate,
ExtensionDto, ExtensionDto,
ExtensionUpdate, ExtensionUpdate,
GreetingKind,
ListResponse, ListResponse,
NumberKind, NumberKind,
OutboundRouteCreate, OutboundRouteCreate,
@@ -23,6 +24,7 @@ import type {
UserCreate, UserCreate,
UserDto, UserDto,
UserUpdate, UserUpdate,
VoicemailDto,
Warning, Warning,
} from '@selfphoneng/shared'; } from '@selfphoneng/shared';
@@ -141,3 +143,31 @@ export const systemApi = {
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'), setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'), completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'),
}; };
export const meApi = {
voicemail: () => api<ListResponse<VoicemailDto>>('GET', '/api/me/voicemail').then((r) => r.items),
audioUrl: (id: string, download = false) =>
`/api/me/voicemail/${id}/audio${download ? '?download=1' : ''}`,
moveVoicemail: (id: string, folder: 'INBOX' | 'Old') =>
api<VoicemailDto>('PATCH', `/api/me/voicemail/${id}`, { folder }),
deleteVoicemail: (id: string) => api<{ ok: true }>('DELETE', `/api/me/voicemail/${id}`),
greetings: () => api<Record<GreetingKind, boolean>>('GET', '/api/me/greetings'),
greetingUrl: (kind: GreetingKind) => `/api/me/greetings/${kind}/audio`,
/** The raw recording or file is the body; the server converts it with ffmpeg. */
putGreeting: async (kind: GreetingKind, audio: Blob) => {
const res = await fetch(`/api/me/greetings/${kind}`, {
method: 'PUT',
credentials: 'same-origin',
headers: {
'X-Requested-With': 'selfphoneng',
'Content-Type': audio.type || 'application/octet-stream',
},
body: audio,
});
if (!res.ok) {
const e = (await res.json().catch(() => null))?.error ?? {};
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
}
},
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
};
+5
View File
@@ -12,6 +12,8 @@ type LiveStore = {
trunks: Record<string, TrunkLive>; trunks: Record<string, TrunkLive>;
/** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */ /** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */
emergencies: EmergencyCall[]; emergencies: EmergencyCall[];
/** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
voicemailSeq: number;
}; };
export const useLive = create<LiveStore>(() => ({ export const useLive = create<LiveStore>(() => ({
@@ -20,6 +22,7 @@ export const useLive = create<LiveStore>(() => ({
extensions: {}, extensions: {},
trunks: {}, trunks: {},
emergencies: [], emergencies: [],
voicemailSeq: 0,
})); }));
function apply(m: LiveMessage) { function apply(m: LiveMessage) {
@@ -46,6 +49,8 @@ function apply(m: LiveMessage) {
} }
case 'emergency.call': case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] }; return { emergencies: [...s.emergencies, m.data] };
case 'voicemail.new':
return { voicemailSeq: s.voicemailSeq + 1 };
case 'ami.status': case 'ami.status':
return { amiConnected: m.data.connected }; return { amiConnected: m.data.connected };
default: default:
+77 -20
View File
@@ -1,20 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4) arrives in M4/M5; for now a signed-in landing page for non-admins. // My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
import { useQueryClient } from '@tanstack/react-query'; // with their milestones (M5–M7).
import { Link, useNavigate } from 'react-router'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import { APP_NAME, type AuthUser } from '@selfphoneng/shared'; import { APP_NAME, type AuthUser } from '@selfphoneng/shared';
import { authApi } from '../../api/client.ts'; import { authApi, meApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx'; import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Button } from '../../components/ui.tsx'; import { Alert, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { VoicemailPage, callerOf, isNew, mmss } from './VoicemailPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
export function MePage({ user }: { user: AuthUser }) { export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient(); const qc = useQueryClient();
const nav = useNavigate(); const nav = useNavigate();
useEffect(subscribeLive, []);
const voicemailSeq = useLive((s) => s.voicemailSeq);
useEffect(() => {
if (voicemailSeq) void qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
}, [voicemailSeq, qc]);
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
return ( return (
<main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 p-4 sm:p-8"> <main className="mx-auto flex min-h-dvh max-w-3xl flex-col gap-6 p-4 sm:p-8">
<header className="flex items-center justify-between gap-2"> <header className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">{APP_NAME}</h1> <h1 className="text-xl font-semibold">{APP_NAME}</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user.role === 'admin' && (
<Link className={`${link} text-sm`} to="/admin">
Admin
</Link>
)}
<ThemeToggle /> <ThemeToggle />
<Button <Button
variant="ghost" variant="ghost"
@@ -28,20 +47,58 @@ export function MePage({ user }: { user: AuthUser }) {
</Button> </Button>
</div> </div>
</header> </header>
<p> <nav
Hi {user.displayName}. Voicemail, call history and the browser softphone are coming in aria-label="My phone"
M4–M5. className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800"
</p> >
<p className="flex gap-4 text-sm"> <NavLink to="/me" end className={tab}>
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password"> Home
Change password </NavLink>
</Link> <NavLink to="/me/voicemail" className={tab}>
{user.role === 'admin' && ( Voicemail
<Link className="text-sky-700 underline dark:text-sky-400" to="/admin"> </NavLink>
Admin </nav>
</Link> <Routes>
)} <Route index element={<Home user={user} />} />
</p> <Route path="voicemail" element={<VoicemailPage />} />
</Routes>
</main> </main>
); );
} }
function Home({ user }: { user: AuthUser }) {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
return (
<div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p>
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
New voicemails
</h2>
{list.error && <Alert>{list.error.message}</Alert>}
{list.data && fresh.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
<ul className="flex flex-col gap-1">
{fresh.slice(0, 5).map((m) => (
<li key={m.id} className="text-sm">
<span className="font-semibold">{callerOf(m)}</span>{' '}
<span className="text-zinc-500">
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</span>
</li>
))}
</ul>
{list.data && list.data.length > 0 && (
<Link className={`${link} text-sm`} to="/me/voicemail">
Open voicemail
</Link>
)}
</section>
<p className="text-sm">
<Link className={link} to="/change-password">
Change password
</Link>
</p>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My voicemail (05 §4): messages (play marks read, download, delete) and greetings.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button } from '../../components/ui.tsx';
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
export const callerOf = (m: VoicemailDto) =>
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
export function VoicemailPage() {
return (
<div className="flex flex-col gap-8">
<Messages />
<Greetings />
</div>
);
}
function Messages() {
const qc = useQueryClient();
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const [deleting, setDeleting] = useState<VoicemailDto | null>(null);
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
const markRead = useMutation({
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: (id: string) => meApi.deleteVoicemail(id),
onSuccess: async () => {
setDeleting(null);
await refresh();
},
});
return (
<section aria-labelledby="vm-h" className="flex flex-col gap-3">
<h2 id="vm-h" className="text-lg font-semibold">
Voicemail
</h2>
{list.error && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
<ul className="flex flex-col gap-2">
{list.data?.map((m) => (
<li
key={m.id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
>
<div className="min-w-0 flex-1">
<p className={isNew(m) ? 'font-semibold' : ''}>
{callerOf(m)}
{isNew(m) && (
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
New
</span>
)}
</p>
<p className="text-sm text-zinc-500">
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</p>
</div>
<audio
controls
preload="none"
src={meApi.audioUrl(m.id)}
aria-label={`Voicemail from ${callerOf(m)}`}
className="w-full sm:w-64"
onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)}
/>
<div className="flex gap-2">
<a
href={meApi.audioUrl(m.id, true)}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Download
</a>
<Button variant="ghost" onClick={() => setDeleting(m)}>
Delete
</Button>
</div>
</li>
))}
</ul>
<ConfirmDialog
open={deleting !== null}
title="Delete voicemail"
message={
<p>Delete the message from {deleting && callerOf(deleting)}? This can't be undone.</p>
}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
onCancel={() => setDeleting(null)}
onConfirm={() => deleting && remove.mutate(deleting.id)}
/>
</section>
);
}
const GREETING_LABELS: [GreetingKind, string, string][] = [
['unavail', "When you don't answer", 'Callers hear the standard "is unavailable" message.'],
['busy', "When you're on another call", 'Callers hear the standard "is busy" message.'],
['greet', 'Your name', 'Callers hear your extension number.'],
];
function Greetings() {
const status = useQuery({ queryKey: ['me', 'greetings'], queryFn: meApi.greetings });
return (
<section aria-labelledby="gr-h" className="flex flex-col gap-3">
<h2 id="gr-h" className="text-lg font-semibold">
Greetings
</h2>
{status.error && <Alert>{status.error.message}</Alert>}
{status.data &&
GREETING_LABELS.map(([kind, label, fallback]) => (
<Greeting
key={kind}
kind={kind}
label={label}
fallback={fallback}
recorded={status.data[kind]}
version={status.dataUpdatedAt}
/>
))}
</section>
);
}
function Greeting(props: {
kind: GreetingKind;
label: string;
fallback: string;
recorded: boolean;
version: number;
}) {
const { kind, label, recorded } = props;
const qc = useQueryClient();
const file = useRef<HTMLInputElement>(null);
const recorder = useRef<MediaRecorder | null>(null);
const [recording, setRecording] = useState(false);
const [micError, setMicError] = useState<string | null>(null);
const done = () => qc.invalidateQueries({ queryKey: ['me', 'greetings'] });
const upload = useMutation({
mutationFn: (audio: Blob) => meApi.putGreeting(kind, audio),
onSuccess: done,
});
const remove = useMutation({ mutationFn: () => meApi.deleteGreeting(kind), onSuccess: done });
async function startRecording() {
setMicError(null);
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setMicError('No microphone, or the browser blocked it.');
return;
}
const rec = new MediaRecorder(stream);
const chunks: Blob[] = [];
rec.ondataavailable = (e) => chunks.push(e.data);
rec.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
upload.mutate(new Blob(chunks, { type: rec.mimeType }));
};
rec.start();
recorder.current = rec;
setRecording(true);
}
function stopRecording() {
recorder.current?.stop();
recorder.current = null;
setRecording(false);
}
const id = `greeting-${kind}`;
return (
<div
role="group"
aria-labelledby={id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
>
<p id={id} className="font-medium">
{label}
</p>
{recorded ? (
<audio
controls
preload="none"
src={`${meApi.greetingUrl(kind)}?v=${props.version}`}
aria-label={`${label} greeting`}
className="w-full sm:w-80"
/>
) : (
<p className="text-sm text-zinc-500">Not recorded. {props.fallback}</p>
)}
<div className="flex flex-wrap gap-2">
{recording ? (
<Button variant="danger" onClick={stopRecording}>
Stop and save
</Button>
) : (
<Button onClick={startRecording} disabled={upload.isPending}>
Record
</Button>
)}
<Button onClick={() => file.current?.click()} disabled={recording || upload.isPending}>
Upload a file
</Button>
<input
ref={file}
type="file"
accept="audio/*"
hidden
aria-label={`Upload ${label} greeting`}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload.mutate(f);
e.target.value = '';
}}
/>
{recorded && (
<Button variant="ghost" onClick={() => remove.mutate()} disabled={remove.isPending}>
Delete
</Button>
)}
</div>
{recording && <p className="text-sm text-red-600 dark:text-red-400">Recording…</p>}
{upload.isPending && <p className="text-sm text-zinc-500">Saving…</p>}
{upload.isSuccess && !upload.isPending && <p className="text-sm text-zinc-500">Saved.</p>}
{(micError ?? upload.error?.message ?? remove.error?.message) && (
<Alert>{micError ?? upload.error?.message ?? remove.error?.message}</Alert>
)}
</div>
);
}
+4 -1
View File
@@ -312,7 +312,10 @@ Auth & self:
| GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) | | GET | `/api/me/voicemail/:id/audio` | user | streams the wav (`Content-Type: audio/wav`, supports Range) |
| PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` | | PATCH | `/api/me/voicemail/:id` | user | `{folder: 'Old'|'INBOX'}` |
| DELETE | `/api/me/voicemail/:id` | user | | | DELETE | `/api/me/voicemail/:id` | user | |
| PUT/DELETE | `/api/me/greetings/:kind` | user | multipart upload | | GET | `/api/me/greetings` | user | `{unavail, busy, greet, temp}`: which are recorded |
| GET | `/api/me/greetings/:kind/audio` | user | the wav |
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | personal contacts | | GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | personal contacts |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out | | POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
+1 -1
View File
@@ -94,7 +94,7 @@ Implementation notes (M3):
| Page | Path | Contents | | Page | Path | Contents |
|---|---|---| |---|---|---|
| Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list | | Home | `/me` | Status card (DND toggle, forwarding summary, queue login toggles), missed calls, new voicemails, colleagues presence list. M4 ships only new voicemails; the rest arrives with DND/forwarding (M6), call history (M7) and presence (M5-T3) |
| Calls | `/me/calls` | Own call history, click a number to call back, add to contacts | | Calls | `/me/calls` | Own call history, click a number to call back, add to contacts |
| Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete. Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name | | Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete. Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name |
| Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call | | Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call |
+4
View File
@@ -5,6 +5,10 @@
export const VOICEMAIL_FOLDERS = ['INBOX', 'Old', 'Urgent'] as const; export const VOICEMAIL_FOLDERS = ['INBOX', 'Old', 'Urgent'] as const;
export type VoicemailFolder = (typeof VOICEMAIL_FOLDERS)[number]; export type VoicemailFolder = (typeof VOICEMAIL_FOLDERS)[number];
/** unavail/busy: played before the beep; greet: your name; temp: overrides both while set. */
export const GREETINGS = ['unavail', 'busy', 'greet', 'temp'] as const;
export type GreetingKind = (typeof GREETINGS)[number];
export type VoicemailDto = { export type VoicemailDto = {
id: string; id: string;
folder: VoicemailFolder; folder: VoicemailFolder;
+6
View File
@@ -51,3 +51,9 @@ to the DID rings the new user's softphone and an outbound call leaves with the D
sink (`smtp-sink.mjs`, shared with `m3-t4.mjs`), adds a user and extension 150 with voicemail, 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 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`. wav attached. The voicemail itself (MWI on a phone, `*97`) is covered by `pnpm test:asterisk`.
`m4-t3.mjs <baseUrl> <adminEmail> <adminPassword> <vmDir> <shotsDir>` is the M4-T3 acceptance on the
same setup: it adds a user with extension 160 and a baresip phone with the mwi module, leaves
messages, then as that user in the browser plays one (marks it read) and deletes another, each
time checking the phone's MWI goes off within 5 s. It records the "no answer" greeting with a
fake microphone and checks a caller hears it.
+3 -1
View File
@@ -9,7 +9,8 @@ import { join } from 'node:path';
const EDGE = process.env.BROWSER ?? 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'; const EDGE = process.env.BROWSER ?? 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function launch({ width = 1280, height = 800, shotsDir }) { /** `args`: extra browser flags, e.g. a fake microphone for MediaRecorder tests. */
export async function launch({ width = 1280, height = 800, shotsDir, args = [] }) {
const profile = mkdtempSync(join(tmpdir(), 'sp-edge-')); const profile = mkdtempSync(join(tmpdir(), 'sp-edge-'));
const port = 9300 + Math.floor(Math.random() * 500); const port = 9300 + Math.floor(Math.random() * 500);
const proc = spawn(EDGE, [ const proc = spawn(EDGE, [
@@ -18,6 +19,7 @@ export async function launch({ width = 1280, height = 800, shotsDir }) {
`--user-data-dir=${profile}`, `--user-data-dir=${profile}`,
'--no-first-run', '--no-first-run',
`--window-size=${width},${height}`, `--window-size=${width},${height}`,
...args,
'about:blank', 'about:blank',
]); ]);
let info; let info;
+219
View File
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T3 acceptance in a browser: as a normal user, marking a voicemail read (by playing it) and
// deleting one turn the phone's message-waiting light off within 5 s, and a greeting recorded in
// the browser (fake microphone) is what callers hear.
// Runs on the m2-live setup with a voicemail dir, like m4-t2.mjs (after m1-t4.mjs). The "phone" is
// baresip with its mwi module, registered as the new user's extension 160.
// Usage: node scripts/e2e/m4-t3.mjs <baseUrl> <adminEmail> <adminPassword> <vmDir> <shotsDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch } from './cdp.mjs';
const NET = 'sp-live';
const AST = 'sp-live-ast';
const PHONE = 'sp-live-phone160';
const EXT = '160';
const USER = { email: 'mo@example.com', password: 'mo-new-password-123' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const docker = (...a) => {
const r = spawnSync('docker', a, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const [base, email, pw, vmDir, shotsDir] = process.argv.slice(2);
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 }),
});
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;
};
const created = await api('POST', '/api/users', { email: USER.email, displayName: 'Mo Lee' });
const ext = await api('POST', '/api/extensions', {
number: EXT,
name: 'Mo Lee',
userId: created.user.id,
});
const creds = await api('GET', `/api/extensions/${ext.id}/sip-credentials`);
// The desk phone: subscribes to MWI and prints every NOTIFY body.
const dir = mkdtempSync(join(tmpdir(), 'sp-phone160-'));
mkdirSync(join(dir, 'p'));
writeFileSync(
join(dir, 'p', 'config'),
'sip_listen 0.0.0.0:5074\nmodule_path /usr/lib/baresip/modules\nmodule g711.so\nmodule aufile.so\nmodule menu.so\nmodule account.so\nmodule mwi.so\naudio_player aufile,/dev/null\naudio_alert aufile,/dev/null\n',
);
writeFileSync(
join(dir, 'p', 'accounts'),
`<sip:${EXT}@${AST};transport=udp>;auth_pass=${creds.password};answermode=manual;audio_codecs=PCMU;mwi=yes\n`,
);
docker('rm', '-f', PHONE);
assert.ok(
docker(
'run',
'-d',
'--rm',
'--name',
PHONE,
'--network',
NET,
'-v',
`${dir}:/phone`,
'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 /phone/p > /phone/p/log 2>&1',
).ok,
);
const mwi = () => {
const all = existsSync(join(dir, 'p', 'log'))
? readFileSync(join(dir, 'p', 'log'), 'utf8').match(/Messages-Waiting:\s*\w+/gi)
: null;
return all?.at(-1)?.split(/:\s*/)[1]?.toLowerCase() ?? null;
};
const waitMwi = async (want, ms, what) => {
const end = Date.now() + ms;
while (mwi() !== want && Date.now() < end) await sleep(100);
assert.equal(mwi(), want, what);
};
/** A Local channel leaves a message: waits out the ~12 s greeting, then "speaks". */
const leaveMessage = () =>
cli(
`channel originate Local/${EXT}@sp-vm-direct application Playback silence/10&silence/5&tt-weasels`,
);
const p = await launch({
shotsDir,
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--autoplay-policy=no-user-gesture-required',
],
});
try {
for (
let i = 0;
i < 180 && !new RegExp(`${EXT}/sip:.*Avail`).test(cli('pjsip show contacts'));
i++
)
await sleep(1000);
await waitMwi('no', 10_000, 'phone subscribed to MWI, light off');
console.log(`phone ${EXT} registered, MWI off`);
leaveMessage();
await waitMwi('yes', 60_000, 'MWI on after a message');
console.log('message left, MWI on');
// Sign in as the user (first login: forced password change), open Voicemail.
await p.goto(`${base}/login`);
await p.waitFor(`!!document.querySelector('input[type=email]')`);
await p.fill('Email', USER.email);
await p.fill('Password', created.temporaryPassword);
await p.click('Sign in');
await p.waitFor(`location.pathname === '/change-password'`, 'forced password change');
await p.fill('Current password', created.temporaryPassword);
await p.fill('New password', USER.password);
await p.fill('Repeat new password', USER.password);
await p.click('Change password');
await p.waitFor(`location.pathname === '/me'`, 'my phone home');
await p.waitFor(`document.body.innerText.includes('New voicemails')`);
await p.waitFor(`document.body.innerText.includes('Open voicemail')`, 'new voicemail on home');
await p.shot('m4-t3-01-home');
await p.click('Voicemail');
await p.waitFor(`document.querySelectorAll('audio[aria-label^="Voicemail from"]').length === 1`);
assert.match(await p.text(), /New/);
await p.shot('m4-t3-02-voicemail');
// Play → marks read → MWI off within 5 s.
const t0 = Date.now();
// play() can stay pending in headless Edge; the play event is what marks it read.
await p.evaluate(
`(document.querySelector('audio[aria-label^="Voicemail from"]').play().catch(() => {}), true)`,
);
await waitMwi('no', 5000, 'MWI off within 5 s of marking read');
console.log(`played in the browser: MWI off after ${((Date.now() - t0) / 1000).toFixed(1)} s`);
await p.waitFor(`!document.body.innerText.includes('New\\n')`, 'no longer marked new');
// A second message, deleted in the browser → MWI off within 5 s.
leaveMessage();
await waitMwi('yes', 60_000, 'MWI on after the second message');
await p.evaluate(`dispatchEvent(new Event('focus'))`); // React Query refetches on focus
await p.waitFor(
`document.querySelectorAll('audio[aria-label^="Voicemail from"]').length === 2`,
'second message listed',
15_000,
);
await p.evaluate(
`[...document.querySelectorAll('li')].find(li => li.innerText.includes('New')).querySelector('button').click()`,
);
await p.waitFor(`document.body.innerText.includes('Delete voicemail')`, 'confirm dialog');
const t1 = Date.now();
await p.evaluate(
`[...document.querySelectorAll('dialog button')].find(b => b.textContent === 'Delete').click()`,
);
await waitMwi('no', 5000, 'MWI off within 5 s of deleting');
console.log(`deleted in the browser: MWI off after ${((Date.now() - t1) / 1000).toFixed(1)} s`);
await p.waitFor(
`document.querySelectorAll('audio[aria-label^="Voicemail from"]').length === 1`,
'one message left',
);
// Record the "no answer" greeting with the fake microphone (a beep tone).
await p.evaluate(
`document.querySelector('[role=group][aria-labelledby=greeting-unavail] button').click()`,
);
await p.waitFor(`document.body.innerText.includes('Recording…')`, 'recording');
await sleep(3000);
await p.click('Stop and save');
await p.waitFor(`document.body.innerText.includes('Saved.')`, 'greeting saved', 15_000);
await p.waitFor(
`!!document.querySelector('audio[aria-label="When you don\\'t answer greeting"]')`,
);
await p.shot('m4-t3-03-greeting');
const greeting = join(vmDir, 'default', EXT, 'unavail.wav');
assert.ok(existsSync(greeting), 'unavail.wav in the spool');
const wav = readFileSync(greeting);
assert.equal(wav.readUInt32LE(24), 8000, '8 kHz');
console.log(`greeting recorded in the browser: ${((wav.length - 44) / 16000).toFixed(1)} s wav`);
// A caller now hears it instead of "the person at extension 160 is unavailable".
cli(`channel originate Local/${EXT}@sp-vm-direct application Wait 6`);
let heard = '';
for (let i = 0; i < 40 && !heard; i++) {
await sleep(250);
heard =
/Playing '([^']*default\/160\/unavail[^']*)'/.exec(
docker('logs', '--tail', '30', AST).out,
)?.[1] ?? '';
}
assert.ok(heard, 'the caller hears the recorded greeting');
console.log(`caller heard ${heard}`);
cli('channel request hangup all');
assert.deepEqual(p.consoleErrors, [], 'no console errors');
console.log('M4-T3 (voicemail page, MWI, greeting): PASS');
} catch (e) {
await p.shot('m4-t3-fail').catch(() => {});
console.log(`FAIL: ${e.message}`);
console.log(`phone log tail:\n${docker('exec', PHONE, 'tail', '-n', '15', '/phone/p/log').out}`);
process.exitCode = 1;
} finally {
await p.close();
docker('rm', '-f', PHONE);
}