diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 2406730..c7f332f 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -20,6 +20,7 @@ import { trunkRoutes } from './routes/trunks.ts';
import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
+import { meRoutes } from './routes/me.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -91,10 +92,14 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(outboundRouteRoutes);
app.register(settingsRoutes);
app.register(liveRoutes);
+ app.register(meRoutes);
- if (existsSync(publicDir)) {
- app.register(fastifyStatic, { root: publicDir, wildcard: false });
- }
+ // Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
+ app.register(fastifyStatic, {
+ root: publicDir,
+ serve: existsSync(publicDir),
+ wildcard: false,
+ });
app.setNotFoundHandler((req, reply) => {
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
return reply.sendFile('index.html');
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 3cc1c8c..6b70ef6 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -63,6 +63,7 @@ const voicemail = watchVoicemail(
},
ami,
);
+app.decorate('voicemail', voicemail);
app.decorate('live', live);
await runMigrations(db);
diff --git a/apps/api/src/media/audio.ts b/apps/api/src/media/audio.ts
new file mode 100644
index 0000000..20eecda
--- /dev/null
+++ b/apps/api/src/media/audio.ts
@@ -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 {
+ 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((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 });
+ }
+}
diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts
new file mode 100644
index 0000000..d0f7030
--- /dev/null
+++ b/apps/api/src/routes/me.ts
@@ -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;
+ }
+}
+
+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, 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> => {
+ 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 };
+ });
+}
diff --git a/apps/api/src/services/voicemail.ts b/apps/api/src/services/voicemail.ts
index be27641..b3ac893 100644
--- a/apps/api/src/services/voicemail.ts
+++ b/apps/api/src/services/voicemail.ts
@@ -221,6 +221,7 @@ export function watchVoicemail(d: VoicemailDeps, ami: Pick)
if (context === 'default' && box && /^\d+$/.test(box)) void scan(box);
});
return {
+ spoolDir: d.spoolDir,
scan,
/** Startup and AMI reconnects: messages may have come and gone while we weren't listening. */
scanAll: async () => {
diff --git a/apps/api/test/app.ts b/apps/api/test/app.ts
index 058310e..da355db 100644
--- a/apps/api/test/app.ts
+++ b/apps/api/test/app.ts
@@ -17,6 +17,7 @@ import { createGenerator } from '../src/generator/index.ts';
import { LiveState } from '../src/live/state.ts';
import type { AmiMessage } from '../src/ami/events.ts';
import type { Mail } from '../src/services/mail.ts';
+import { watchVoicemail } from '../src/services/voicemail.ts';
import { createTestDb } from './db.ts';
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
@@ -90,6 +91,22 @@ export async function createTestApp(extend?: (app: ReturnType)
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);
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
resetLoginLimiter();
@@ -129,6 +146,7 @@ export async function createTestApp(extend?: (app: ReturnType)
mail,
live,
genDir,
+ spoolDir,
login,
addUser,
clearMustChange,
diff --git a/apps/api/test/me-voicemail.test.ts b/apps/api/test/me-voicemail.test.ts
new file mode 100644
index 0000000..9df5a95
--- /dev/null
+++ b/apps/api/test/me-voicemail.test.ts
@@ -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,
+) => Promise;
+
+describe.skipIf(!hasTestDb)('/api/me/voicemail and greetings', () => {
+ let t: Awaited>;
+ 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) {
+ 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);
+ });
+});
diff --git a/apps/api/test/voicemail.test.ts b/apps/api/test/voicemail.test.ts
index 0b9dec3..8bfda80 100644
--- a/apps/api/test/voicemail.test.ts
+++ b/apps/api/test/voicemail.test.ts
@@ -1,13 +1,12 @@
// 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 { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
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 { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -55,20 +54,9 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
headers: { ...H, cookie },
payload: { number: '101', name: 'Dan Doe', userId },
});
- spool = mkdtempSync(join(tmpdir(), 'sp-vm-'));
+ spool = t.spoolDir;
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,
- );
+ w = t.app.voicemail;
});
afterAll(async () => {
await t?.close();
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 2494e98..25ba114 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -8,6 +8,7 @@ import type {
ExtensionCreate,
ExtensionDto,
ExtensionUpdate,
+ GreetingKind,
ListResponse,
NumberKind,
OutboundRouteCreate,
@@ -23,6 +24,7 @@ import type {
UserCreate,
UserDto,
UserUpdate,
+ VoicemailDto,
Warning,
} from '@selfphoneng/shared';
@@ -141,3 +143,31 @@ export const systemApi = {
setupStatus: () => api<{ completed: boolean }>('GET', '/api/setup/status'),
completeSetup: () => api<{ ok: true }>('POST', '/api/setup/complete'),
};
+
+export const meApi = {
+ voicemail: () => api>('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('PATCH', `/api/me/voicemail/${id}`, { folder }),
+ deleteVoicemail: (id: string) => api<{ ok: true }>('DELETE', `/api/me/voicemail/${id}`),
+ greetings: () => api>('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}`),
+};
diff --git a/apps/web/src/live/store.ts b/apps/web/src/live/store.ts
index 517875f..a2ceaae 100644
--- a/apps/web/src/live/store.ts
+++ b/apps/web/src/live/store.ts
@@ -12,6 +12,8 @@ type LiveStore = {
trunks: Record;
/** 911 alerts received while this tab was open, newest last; dismissed ones are removed. */
emergencies: EmergencyCall[];
+ /** Bumped on every voicemail.new, so the voicemail list knows to refetch. */
+ voicemailSeq: number;
};
export const useLive = create(() => ({
@@ -20,6 +22,7 @@ export const useLive = create(() => ({
extensions: {},
trunks: {},
emergencies: [],
+ voicemailSeq: 0,
}));
function apply(m: LiveMessage) {
@@ -46,6 +49,8 @@ function apply(m: LiveMessage) {
}
case 'emergency.call':
return { emergencies: [...s.emergencies, m.data] };
+ case 'voicemail.new':
+ return { voicemailSeq: s.voicemailSeq + 1 };
case 'ami.status':
return { amiConnected: m.data.connected };
default:
diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx
index 342ffd4..1bc33c4 100644
--- a/apps/web/src/pages/user/MePage.tsx
+++ b/apps/web/src/pages/user/MePage.tsx
@@ -1,20 +1,39 @@
// 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.
-import { useQueryClient } from '@tanstack/react-query';
-import { Link, useNavigate } from 'react-router';
+// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
+// with their milestones (M5–M7).
+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 { authApi } from '../../api/client.ts';
+import { authApi, meApi } from '../../api/client.ts';
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 }) {
const qc = useQueryClient();
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 (
-
+
-
- Hi {user.displayName}. Voicemail, call history and the browser softphone are coming in
- M4–M5.
-
-
-
- Change password
-
- {user.role === 'admin' && (
-
- Admin
-
- )}
-
+
+
+ } />
+ } />
+
);
}
+
+function Home({ user }: { user: AuthUser }) {
+ const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
+ const fresh = list.data?.filter(isNew) ?? [];
+ return (
+
+
Hi {user.displayName}.
+
+
+ New voicemails
+
+ {list.error && {list.error.message}}
+ {list.data && fresh.length === 0 && None.
}
+
+ {fresh.slice(0, 5).map((m) => (
+ -
+ {callerOf(m)}{' '}
+
+ {new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
+
+
+ ))}
+
+ {list.data && list.data.length > 0 && (
+
+ Open voicemail
+
+ )}
+
+
+
+ Change password
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/user/VoicemailPage.tsx b/apps/web/src/pages/user/VoicemailPage.tsx
new file mode 100644
index 0000000..e38bad1
--- /dev/null
+++ b/apps/web/src/pages/user/VoicemailPage.tsx
@@ -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 (
+
+
+
+
+ );
+}
+
+function Messages() {
+ const qc = useQueryClient();
+ const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
+ const [deleting, setDeleting] = useState(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 (
+
+
+ Voicemail
+
+ {list.error && {list.error.message}}
+ {list.data?.length === 0 && No messages.
}
+
+ {list.data?.map((m) => (
+ -
+
+
+ {callerOf(m)}
+ {isNew(m) && (
+
+ New
+
+ )}
+
+
+ {m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
+ {new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
+
+
+
+ ))}
+
+ Delete the message from {deleting && callerOf(deleting)}? This can't be undone.
+ }
+ confirmLabel="Delete"
+ busy={remove.isPending}
+ error={remove.error?.message}
+ onCancel={() => setDeleting(null)}
+ onConfirm={() => deleting && remove.mutate(deleting.id)}
+ />
+
+ );
+}
+
+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 (
+
+
+ Greetings
+
+ {status.error && {status.error.message}}
+ {status.data &&
+ GREETING_LABELS.map(([kind, label, fallback]) => (
+
+ ))}
+
+ );
+}
+
+function Greeting(props: {
+ kind: GreetingKind;
+ label: string;
+ fallback: string;
+ recorded: boolean;
+ version: number;
+}) {
+ const { kind, label, recorded } = props;
+ const qc = useQueryClient();
+ const file = useRef(null);
+ const recorder = useRef(null);
+ const [recording, setRecording] = useState(false);
+ const [micError, setMicError] = useState(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 (
+
+
+ {label}
+
+ {recorded ? (
+
+ ) : (
+
Not recorded. {props.fallback}
+ )}
+
+ {recording ? (
+
+ ) : (
+
+ )}
+
+ {
+ const f = e.target.files?.[0];
+ if (f) upload.mutate(f);
+ e.target.value = '';
+ }}
+ />
+ {recorded && (
+
+ )}
+
+ {recording &&
Recording…
}
+ {upload.isPending &&
Saving…
}
+ {upload.isSuccess && !upload.isPending &&
Saved.
}
+ {(micError ?? upload.error?.message ?? remove.error?.message) && (
+
{micError ?? upload.error?.message ?? remove.error?.message}
+ )}
+
+ );
+}
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 56ba725..32da3dd 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -312,7 +312,10 @@ Auth & self:
| 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'}` |
| 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 |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/, Context: sp-from-ext, Exten: , Priority: 1, CallerID: "" <>, Async: true`. The user's phone rings first, then the call goes out |
diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md
index 15a66ad..61dd522 100644
--- a/docs/spec/05-web-ui.md
+++ b/docs/spec/05-web-ui.md
@@ -94,7 +94,7 @@ Implementation notes (M3):
| 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 |
| 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 |
diff --git a/packages/shared/src/voicemail.ts b/packages/shared/src/voicemail.ts
index 4ca1446..3f6098a 100644
--- a/packages/shared/src/voicemail.ts
+++ b/packages/shared/src/voicemail.ts
@@ -5,6 +5,10 @@
export const VOICEMAIL_FOLDERS = ['INBOX', 'Old', 'Urgent'] as const;
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 = {
id: string;
folder: VoicemailFolder;
diff --git a/scripts/e2e/README.md b/scripts/e2e/README.md
index 6b9bc14..34894e0 100644
--- a/scripts/e2e/README.md
+++ b/scripts/e2e/README.md
@@ -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,
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`.
+
+`m4-t3.mjs ` 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.
diff --git a/scripts/e2e/cdp.mjs b/scripts/e2e/cdp.mjs
index 6313e7f..44602dc 100644
--- a/scripts/e2e/cdp.mjs
+++ b/scripts/e2e/cdp.mjs
@@ -9,7 +9,8 @@ import { join } from 'node:path';
const EDGE = process.env.BROWSER ?? 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe';
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 port = 9300 + Math.floor(Math.random() * 500);
const proc = spawn(EDGE, [
@@ -18,6 +19,7 @@ export async function launch({ width = 1280, height = 800, shotsDir }) {
`--user-data-dir=${profile}`,
'--no-first-run',
`--window-size=${width},${height}`,
+ ...args,
'about:blank',
]);
let info;
diff --git a/scripts/e2e/m4-t3.mjs b/scripts/e2e/m4-t3.mjs
new file mode 100644
index 0000000..21c2be9
--- /dev/null
+++ b/scripts/e2e/m4-t3.mjs
@@ -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
+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'),
+ `;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);
+}