From f60dda0bb09731718d8beb94a6e099934b142b55 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Sat, 17 Oct 2026 22:56:10 -0500 Subject: [PATCH] me: voicemail page, greetings, My Phone home /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 --- apps/api/src/app.ts | 11 +- apps/api/src/index.ts | 1 + apps/api/src/media/audio.ts | 40 ++++ apps/api/src/routes/me.ts | 146 +++++++++++++ apps/api/src/services/voicemail.ts | 1 + apps/api/test/app.ts | 18 ++ apps/api/test/me-voicemail.test.ts | 169 +++++++++++++++ apps/api/test/voicemail.test.ts | 20 +- apps/web/src/api/client.ts | 30 +++ apps/web/src/live/store.ts | 5 + apps/web/src/pages/user/MePage.tsx | 97 +++++++-- apps/web/src/pages/user/VoicemailPage.tsx | 242 ++++++++++++++++++++++ docs/spec/04-backend-api.md | 5 +- docs/spec/05-web-ui.md | 2 +- packages/shared/src/voicemail.ts | 4 + scripts/e2e/README.md | 6 + scripts/e2e/cdp.mjs | 4 +- scripts/e2e/m4-t3.mjs | 219 ++++++++++++++++++++ 18 files changed, 978 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/media/audio.ts create mode 100644 apps/api/src/routes/me.ts create mode 100644 apps/api/test/me-voicemail.test.ts create mode 100644 apps/web/src/pages/user/VoicemailPage.tsx create mode 100644 scripts/e2e/m4-t3.mjs 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 (
-
+

{APP_NAME}

+ {user.role === 'admin' && ( + + Admin + + )}
-

- 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 ? ( +
+ ); +} 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); +}