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
This commit is contained in:
1 parent
fe2eb4cc22
commit
f60dda0bb0
18 files changed
+978
-42
No files matched your search
+8
-3
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
};
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user