sounds: upload, convert, play, rename, delete with references
/api/sounds (admin) takes the raw file, converts it with ffmpeg in one pass to <id>.wav (8 kHz) and <id>.g722 (HD for G.722 calls) in SOUNDS_DIR, measures it with ffprobe, and stores it as custom/<id>. Delete answers 409 with what still plays it (IVR greeting/invalid sound, announcements, the recording notice). Sounds & music page: drop or pick a file (name suggested from the file name), play, rename, delete. MOH classes stay in M8. The web client and greetings share one raw-upload helper; the api test audio helpers moved to test/audio.ts. e2e m6-t1: drag-and-drop upload in the browser, Asterisk plays custom/<id>, rename, delete refused while in use, then deleted. Task: M6-T1
This commit is contained in:
1 parent
609965bba5
commit
17db68a8c5
19 files changed
+752
-60
No files matched your search
@@ -21,6 +21,7 @@ import { didRoutes } from './routes/dids.ts';
|
||||
import { outboundRouteRoutes } from './routes/outbound.ts';
|
||||
import { liveRoutes } from './live/hub.ts';
|
||||
import { meRoutes } from './routes/me.ts';
|
||||
import { soundRoutes } from './routes/sounds.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -93,6 +94,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(soundRoutes);
|
||||
|
||||
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
|
||||
app.register(fastifyStatic, {
|
||||
|
||||
@@ -17,6 +17,8 @@ const EnvSchema = z.object({
|
||||
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
|
||||
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
|
||||
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
|
||||
/** Uploaded prompts, played as custom/<id> (04 §6). Dev: ../../data/sounds-custom */
|
||||
SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'),
|
||||
/** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */
|
||||
WEBRTC_WS_URL: z.url().optional(),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
|
||||
+48
-16
@@ -9,32 +9,64 @@ export const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export class AudioError extends Error {}
|
||||
|
||||
const LOUDNORM = 'loudnorm=I=-16:TP=-1.5:LRA=11';
|
||||
const WAV = ['-ar', '8000', '-af', LOUDNORM, '-c:a', 'pcm_s16le', '-f', 'wav'];
|
||||
/** The HD copy Asterisk picks for G.722 calls (same base name, .g722). */
|
||||
const G722 = ['-ar', '16000', '-af', LOUDNORM, '-c:a', 'g722', '-f', 'g722'];
|
||||
|
||||
function run(cmd: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let err = '';
|
||||
p.stdout.on('data', (d: Buffer) => (out += d.toString()));
|
||||
p.stderr.on('data', (d: Buffer) => (err += d.toString()));
|
||||
p.on('error', reject);
|
||||
p.on('close', (code) => (code === 0 ? resolve(out) : reject(new AudioError(err.trim()))));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Any audio ffmpeg reads (wav, mp3, ogg, opus, webm from MediaRecorder, m4a, flac) → each output,
|
||||
* mono and loudness-normalized. Outputs are written next to their final name and renamed, so
|
||||
* Asterisk never plays a half-written file.
|
||||
*/
|
||||
export async function toPcmWav(input: Buffer, out: string): Promise<void> {
|
||||
async function convert(input: Buffer, outputs: { path: string; format: string[] }[]) {
|
||||
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);
|
||||
const args = ['-nostdin', '-y', '-v', 'error', '-i', src, '-t', '900', '-vn'];
|
||||
for (const o of outputs) args.push('-ac', '1', ...o.format, `${o.path}.tmp`);
|
||||
await run('ffmpeg', args);
|
||||
for (const o of outputs) await rename(`${o.path}.tmp`, o.path);
|
||||
} catch (e) {
|
||||
await rm(`${out}.tmp`, { force: true });
|
||||
for (const o of outputs) await rm(`${o.path}.tmp`, { force: true });
|
||||
throw e instanceof AudioError ? e : new AudioError((e as Error).message);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Voicemail greetings: 8 kHz PCM wav at `out`. */
|
||||
export const toPcmWav = (input: Buffer, out: string) =>
|
||||
convert(input, [{ path: out, format: WAV }]);
|
||||
|
||||
/** Prompts: `<base>.wav` and `<base>.g722`; returns the length in ms. */
|
||||
export async function toPrompt(input: Buffer, base: string): Promise<number> {
|
||||
await convert(input, [
|
||||
{ path: `${base}.wav`, format: WAV },
|
||||
{ path: `${base}.g722`, format: G722 },
|
||||
]);
|
||||
const secs = await run('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'csv=p=0',
|
||||
`${base}.wav`,
|
||||
]);
|
||||
return Math.round(Number(secs.trim()) * 1000);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/sounds: recorded prompts (04 §6, §7.3). Admin only. Files live in SOUNDS_DIR as
|
||||
// <id>.wav (8 kHz) and <id>.g722 (HD); dialplan plays them as custom/<id>.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { asc, eq, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
SOUND_MAX_BYTES,
|
||||
SoundName,
|
||||
SoundUpdateSchema,
|
||||
type ListResponse,
|
||||
type SoundDto,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { announcements, ivrs, soundFiles } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { AudioError, toPrompt } from '../media/audio.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
type SoundRow = typeof soundFiles.$inferSelect;
|
||||
|
||||
const toDto = (s: SoundRow): SoundDto => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
asteriskName: s.asteriskName,
|
||||
durationMs: s.durationMs,
|
||||
originalFilename: s.originalFilename,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const UploadQuery = z.object({ name: SoundName, filename: z.string().max(200).optional() });
|
||||
|
||||
/** Everything that plays this sound, for the 409 on delete. */
|
||||
export async function soundReferences(db: Db, id: string): Promise<string[]> {
|
||||
const ivrRows = await db
|
||||
.select({ n: ivrs.name })
|
||||
.from(ivrs)
|
||||
.where(or(eq(ivrs.greetingSoundId, id), eq(ivrs.invalidSoundId, id)));
|
||||
const annRows = await db
|
||||
.select({ n: announcements.name })
|
||||
.from(announcements)
|
||||
.where(eq(announcements.soundId, id));
|
||||
return [
|
||||
...ivrRows.map((r) => `digital receptionist ${r.n}`),
|
||||
...annRows.map((r) => `announcement ${r.n}`),
|
||||
...((await getSetting(db, 'recordings.announcementSoundId')) === id
|
||||
? ['the call recording notice (Settings → Recording)']
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export async function soundRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
const dir = resolve(app.config.SOUNDS_DIR);
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
// The audio file is the raw request body (a File from the page), any audio type.
|
||||
app.addContentTypeParser('*', { parseAs: 'buffer', bodyLimit: SOUND_MAX_BYTES }, (_r, b, done) =>
|
||||
done(null, b),
|
||||
);
|
||||
|
||||
async function getSound(id: string) {
|
||||
const [s] = await db.select().from(soundFiles).where(eq(soundFiles.id, id));
|
||||
if (!s || s.kind !== 'prompt') throw notFound('Sound not found');
|
||||
return s;
|
||||
}
|
||||
|
||||
app.get('/api/sounds', async (): Promise<ListResponse<SoundDto>> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(soundFiles)
|
||||
.where(eq(soundFiles.kind, 'prompt'))
|
||||
.orderBy(asc(soundFiles.name));
|
||||
return { items: rows.map(toDto), nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/sounds/:id', async (req) => toDto(await getSound(IdParams.parse(req.params).id)));
|
||||
|
||||
app.get('/api/sounds/:id/audio', async (req, reply) => {
|
||||
const s = await getSound(IdParams.parse(req.params).id);
|
||||
return reply.sendFile(`${s.id}.wav`, dir);
|
||||
});
|
||||
|
||||
/** POST /api/sounds?name=Main greeting&filename=greeting.mp3, body = the file. */
|
||||
app.post('/api/sounds', async (req, reply) => {
|
||||
const { name, filename } = UploadQuery.parse(req.query);
|
||||
if (!Buffer.isBuffer(req.body) || req.body.length === 0)
|
||||
throw badRequest('Send the audio file');
|
||||
const id = randomUUID();
|
||||
await mkdir(dir, { recursive: true });
|
||||
let durationMs: number;
|
||||
try {
|
||||
durationMs = await toPrompt(req.body, resolve(dir, id));
|
||||
} catch (e) {
|
||||
if (!(e instanceof AudioError)) throw e;
|
||||
req.log.info({ err: e.message }, 'sound conversion failed');
|
||||
throw badRequest("That file isn't audio we can read");
|
||||
}
|
||||
const [row] = (await db
|
||||
.insert(soundFiles)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
kind: 'prompt',
|
||||
source: 'upload',
|
||||
originalFilename: filename ?? null,
|
||||
asteriskName: `custom/${id}`,
|
||||
durationMs,
|
||||
})
|
||||
.returning()) as [SoundRow];
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'sound',
|
||||
entityId: id,
|
||||
summary: `Uploaded sound "${name}"`,
|
||||
});
|
||||
return reply.code(201).send(toDto(row));
|
||||
});
|
||||
|
||||
app.patch('/api/sounds/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const { name } = SoundUpdateSchema.parse(req.body);
|
||||
const before = await getSound(id);
|
||||
const [after] = (await db
|
||||
.update(soundFiles)
|
||||
.set({ name })
|
||||
.where(eq(soundFiles.id, id))
|
||||
.returning()) as [SoundRow];
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'sound',
|
||||
entityId: id,
|
||||
summary: `Renamed sound "${before.name}" to "${name}"`,
|
||||
});
|
||||
return toDto(after);
|
||||
});
|
||||
|
||||
app.delete('/api/sounds/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const s = await getSound(id);
|
||||
const usedBy = await soundReferences(db, id);
|
||||
if (usedBy.length)
|
||||
throw conflict(`"${s.name}" is still used by ${usedBy.join(', ')}`, { usedBy });
|
||||
await db.delete(soundFiles).where(eq(soundFiles.id, id));
|
||||
await rm(resolve(dir, `${id}.wav`), { force: true });
|
||||
await rm(resolve(dir, `${id}.g722`), { force: true });
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'delete',
|
||||
entityType: 'sound',
|
||||
entityId: id,
|
||||
summary: `Deleted sound "${s.name}"`,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
INITIAL_ADMIN_PASSWORD: ADMIN.password,
|
||||
AMI_SECRET: 'ami-secret-123',
|
||||
LOG_LEVEL: 'fatal',
|
||||
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
|
||||
});
|
||||
setSecretKey(config.APP_SECRET);
|
||||
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Audio test helpers.
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const hasFfmpeg = spawnSync('ffmpeg', ['-version']).status === 0;
|
||||
|
||||
/** 8 kHz mono 16-bit silence as a wav file. */
|
||||
export 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]);
|
||||
}
|
||||
@@ -1,35 +1,14 @@
|
||||
// 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 { setSetting } from '../src/services/settings.ts';
|
||||
import { hasFfmpeg, silenceWav } from './audio.ts';
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T1: /api/sounds (04 §6): upload → wav + g722, play, rename, delete with references.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasFfmpeg, silenceWav } from './audio.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/sounds', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (
|
||||
method: string,
|
||||
url: string,
|
||||
payload?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
) => Promise<LightMyRequestResponse>;
|
||||
let id: string;
|
||||
const dir = () => t.app.config.SOUNDS_DIR;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload, headers) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie, ...headers },
|
||||
});
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('upload converts to an 8 kHz wav plus a G.722 copy and measures it', async () => {
|
||||
const r = await req(
|
||||
'POST',
|
||||
'/api/sounds?name=Main%20greeting&filename=greeting.wav',
|
||||
silenceWav(3),
|
||||
{ 'content-type': 'audio/wav' },
|
||||
);
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
const dto = r.json();
|
||||
id = dto.id;
|
||||
expect(dto).toMatchObject({
|
||||
name: 'Main greeting',
|
||||
asteriskName: `custom/${id}`,
|
||||
originalFilename: 'greeting.wav',
|
||||
});
|
||||
expect(dto.durationMs).toBeGreaterThan(2900);
|
||||
expect(dto.durationMs).toBeLessThan(3100);
|
||||
expect(readFileSync(join(dir(), `${id}.wav`)).readUInt32LE(24)).toBe(8000);
|
||||
expect(existsSync(join(dir(), `${id}.g722`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-audio and a missing name', async () => {
|
||||
const junk = await req('POST', '/api/sounds?name=x', Buffer.from('nope'), {
|
||||
'content-type': 'audio/mpeg',
|
||||
});
|
||||
expect(junk.statusCode).toBe(400);
|
||||
expect(junk.json().error.message).toMatch(/isn't audio/);
|
||||
const noName = await req('POST', '/api/sounds', silenceWav(), { 'content-type': 'audio/wav' });
|
||||
expect(noName.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('lists, plays and renames', async () => {
|
||||
expect((await req('GET', '/api/sounds')).json().items.map((s: { id: string }) => s.id)).toEqual(
|
||||
[id],
|
||||
);
|
||||
const audio = await req('GET', `/api/sounds/${id}/audio`);
|
||||
expect(audio.statusCode).toBe(200);
|
||||
expect(audio.rawPayload.subarray(0, 4).toString()).toBe('RIFF');
|
||||
const r = await req('PATCH', `/api/sounds/${id}`, { name: 'Welcome' });
|
||||
expect(r.json().name).toBe('Welcome');
|
||||
});
|
||||
|
||||
it('delete is refused while something plays it, then removes the files', async () => {
|
||||
await setSetting(t.db, 'recordings.announcementSoundId', id);
|
||||
const busy = await req('DELETE', `/api/sounds/${id}`);
|
||||
expect(busy.statusCode).toBe(409);
|
||||
expect(busy.json().error.details.usedBy).toEqual([
|
||||
'the call recording notice (Settings → Recording)',
|
||||
]);
|
||||
await setSetting(t.db, 'recordings.announcementSoundId', null);
|
||||
expect((await req('DELETE', `/api/sounds/${id}`)).statusCode).toBe(200);
|
||||
expect(existsSync(join(dir(), `${id}.wav`))).toBe(false);
|
||||
expect(existsSync(join(dir(), `${id}.g722`))).toBe(false);
|
||||
expect((await req('GET', `/api/sounds/${id}`)).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
+35
-15
@@ -17,6 +17,7 @@ import type {
|
||||
RouteTestResult,
|
||||
SettingsDto,
|
||||
SipCredentials,
|
||||
SoundDto,
|
||||
TrunkCreate,
|
||||
TrunkDto,
|
||||
TrunkTestResult,
|
||||
@@ -59,6 +60,25 @@ async function api<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** Audio uploads: the file itself is the body (no multipart), with its own Content-Type. */
|
||||
async function upload<T>(method: string, url: string, body: Blob): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'selfphoneng',
|
||||
'Content-Type': body.type || 'application/octet-stream',
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const e = data?.error ?? {};
|
||||
throw new ApiError(res.status, e.code ?? 'internal', e.message ?? res.statusText, e.details);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
me: () => api<{ user: AuthUser }>('GET', '/api/auth/me').then((r) => r.user),
|
||||
login: (email: string, password: string) =>
|
||||
@@ -157,20 +177,20 @@ export const meApi = {
|
||||
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);
|
||||
}
|
||||
},
|
||||
putGreeting: (kind: GreetingKind, audio: Blob) =>
|
||||
upload<{ ok: true }>('PUT', `/api/me/greetings/${kind}`, audio),
|
||||
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
|
||||
};
|
||||
|
||||
export const soundsApi = {
|
||||
list: () => api<ListResponse<SoundDto>>('GET', '/api/sounds').then((r) => r.items),
|
||||
audioUrl: (id: string) => `/api/sounds/${id}/audio`,
|
||||
upload: (name: string, file: File) =>
|
||||
upload<SoundDto>(
|
||||
'POST',
|
||||
`/api/sounds?${new URLSearchParams({ name, filename: file.name })}`,
|
||||
file,
|
||||
),
|
||||
rename: (id: string, name: string) => api<SoundDto>('PATCH', `/api/sounds/${id}`, { name }),
|
||||
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/sounds/${id}`),
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { TrunksPage } from './pages/admin/TrunksPage.tsx';
|
||||
import { NumbersPage } from './pages/admin/NumbersPage.tsx';
|
||||
import { OutboundPage } from './pages/admin/OutboundPage.tsx';
|
||||
import { SettingsPage } from './pages/admin/SettingsPage.tsx';
|
||||
import { SoundsPage } from './pages/admin/SoundsPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
import { SetupPage } from './pages/SetupPage.tsx';
|
||||
import './index.css';
|
||||
@@ -41,6 +42,8 @@ const router = createBrowserRouter([
|
||||
<NumbersPage />
|
||||
) : p.path === 'trunks' ? (
|
||||
<TrunksPage />
|
||||
) : p.path === 'sounds' ? (
|
||||
<SoundsPage />
|
||||
) : (
|
||||
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
|
||||
),
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'hours', label: 'Office hours', milestone: 'M6' },
|
||||
{ path: 'conferences', label: 'Conferences', milestone: 'M8' },
|
||||
{ path: 'paging', label: 'Paging', milestone: 'M8' },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: 'M6' },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: null },
|
||||
{ path: 'devices', label: 'Phones', milestone: 'M10' },
|
||||
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
|
||||
{ path: 'calls', label: 'Call history', milestone: 'M7' },
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
|
||||
// Upload by picking or dropping a file; play, rename, delete. Music on hold arrives with M8.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { SOUND_MAX_BYTES, type SoundDto } from '@selfphoneng/shared';
|
||||
import { soundsApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
const secs = (ms: number) =>
|
||||
`${Math.floor(ms / 60000)}:${String(Math.round(ms / 1000) % 60).padStart(2, '0')}`;
|
||||
const stem = (filename: string) =>
|
||||
filename
|
||||
.replace(/\.[^.]+$/, '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
export function SoundsPage() {
|
||||
const qc = useQueryClient();
|
||||
const sounds = useQuery({ queryKey: ['sounds'], queryFn: soundsApi.list });
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['sounds'] });
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [renaming, setRenaming] = useState<SoundDto | null>(null);
|
||||
const [deleting, setDeleting] = useState<SoundDto | null>(null);
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => soundsApi.upload(name.trim(), file!),
|
||||
onSuccess: async () => {
|
||||
setFile(null);
|
||||
setName('');
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => soundsApi.remove(id),
|
||||
onSuccess: async () => {
|
||||
setDeleting(null);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
function pick(f: File | undefined) {
|
||||
if (!f) return;
|
||||
add.reset();
|
||||
setFile(f);
|
||||
if (!name.trim()) setName(stem(f.name));
|
||||
}
|
||||
const tooBig = file !== null && file.size > SOUND_MAX_BYTES;
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h1 className="text-xl font-semibold">Sounds & music</h1>
|
||||
<form
|
||||
aria-label="Upload a sound"
|
||||
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-sky-500 bg-sky-50 dark:bg-sky-950' : 'border-zinc-300 dark:border-zinc-700'}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
pick(e.dataTransfer.files[0]);
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (file && name.trim() && !tooBig) add.mutate();
|
||||
}}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Drop an audio file here, or pick one. Any common format works (mp3, wav, m4a, ogg, flac);
|
||||
it's converted for phones and evened out in volume. Up to 20 MB.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Field label="File">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="text-sm"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Name">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Main greeting"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!file || !name.trim() || tooBig || add.isPending}
|
||||
>
|
||||
{add.isPending ? 'Converting…' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
{file && <p className="text-sm text-zinc-500">Selected: {file.name}</p>}
|
||||
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
|
||||
{add.error && <Alert>{add.error.message}</Alert>}
|
||||
</form>
|
||||
|
||||
{sounds.isError && <Alert>{sounds.error.message}</Alert>}
|
||||
{sounds.data?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No sounds yet. Upload greetings for your digital receptionist and announcements.
|
||||
</p>
|
||||
)}
|
||||
{sounds.data && sounds.data.length > 0 && (
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{sounds.data.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium">{s.name}</span>{' '}
|
||||
<span className="text-zinc-500">{secs(s.durationMs)}</span>
|
||||
</span>
|
||||
<audio
|
||||
controls
|
||||
preload="none"
|
||||
src={soundsApi.audioUrl(s.id)}
|
||||
aria-label={`Play ${s.name}`}
|
||||
className="h-9 w-full sm:w-64"
|
||||
/>
|
||||
<Button onClick={() => setRenaming(s)}>Rename</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleting(s)}>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<RenameDialog sound={renaming} onClose={() => setRenaming(null)} onDone={refresh} />
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete sound"
|
||||
message={<p>Delete "{deleting?.name}"? This can't be undone.</p>}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => {
|
||||
setDeleting(null);
|
||||
remove.reset();
|
||||
}}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDialog({
|
||||
sound,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
sound: SoundDto;
|
||||
onClose: () => void;
|
||||
onDone: () => Promise<unknown>;
|
||||
}) {
|
||||
const [name, setName] = useState(sound.name);
|
||||
const save = useMutation({
|
||||
mutationFn: () => soundsApi.rename(sound.id, name.trim()),
|
||||
onSuccess: async () => {
|
||||
await onDone();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Rename sound"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!name.trim() || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field label="Name">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -335,7 +335,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
|
||||
| `/api/announcements` | |
|
||||
| `/api/conferences` | |
|
||||
| `/api/paging-groups` | |
|
||||
| `/api/sounds` | upload is multipart `POST /api/sounds`, `GET /:id/audio`, `POST /tts` (M12) |
|
||||
| `/api/sounds` | upload is `POST /api/sounds?name=&filename=` with the raw file as the body (like greetings, no multipart), `GET /:id/audio`, `PATCH /:id {name}`, `DELETE /:id` (409 with `usedBy`: IVR greeting/invalid sound, announcements, the recording notice), `POST /tts` (M12). M6 lists prompts only; MOH classes and files come with M8 |
|
||||
| `/api/moh-classes` | |
|
||||
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked) |
|
||||
| `/api/blocked-numbers` | |
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './trunks.ts';
|
||||
export * from './dids.ts';
|
||||
export * from './outbound.ts';
|
||||
export * from './voicemail.ts';
|
||||
export * from './sounds.ts';
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Recorded prompts for IVRs, announcements and the recording notice (02 sound_files, 04 §6).
|
||||
// Music-on-hold classes and their files arrive with M8.
|
||||
import { z } from 'zod';
|
||||
|
||||
export const SoundName = z.string().trim().min(1, 'Required').max(80);
|
||||
export const SoundUpdateSchema = z.object({ name: SoundName });
|
||||
export type SoundUpdate = z.input<typeof SoundUpdateSchema>;
|
||||
|
||||
export type SoundDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** What dialplan plays, e.g. custom/3f2a…; never shown to people. */
|
||||
asteriskName: string;
|
||||
durationMs: number;
|
||||
originalFilename: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Upload limits shared by the page and the api (04 §6). */
|
||||
export const SOUND_MAX_BYTES = 20 * 1024 * 1024;
|
||||
@@ -74,3 +74,8 @@ a second tab (`cdp.mjs` `newTab()`).
|
||||
calls 172 and measures the time from Asterisk's hint changing to the dot changing (must be < 1 s).
|
||||
Then a second browser, as a new user with desk phone 174 and the softphone off, clicks **Call** on
|
||||
172: 174 must ring first (Asterisk channel order), then the two are connected.
|
||||
|
||||
`m6-t1.mjs` needs m2-live started with a sounds dir (`m2-live.mjs up <confDir> <vmDir> <soundsDir>`)
|
||||
and the api with `SOUNDS_DIR=<soundsDir>`. It drops a generated wav on **Sounds & music**, uploads it,
|
||||
checks that Asterisk can play `custom/<id>`, renames it, and deletes it, which is refused while it's
|
||||
the recording notice. `cdp.mjs` `fill()`/`click()` take an optional selector to search within.
|
||||
+2
-2
@@ -104,9 +104,9 @@ async function attach(wsUrl, { width, height, shotsDir }) {
|
||||
goto: (url) => send('Page.navigate', { url }),
|
||||
path: () => evaluate('location.pathname'),
|
||||
/** Sets an input/select found by its <label> text, the way a user typing would (React sees it). */
|
||||
fill: (label, value) =>
|
||||
fill: (label, value, within = 'body') =>
|
||||
evaluate(`(() => {
|
||||
const l = [...document.querySelectorAll('label')].find(l => l.textContent.trim().startsWith(${JSON.stringify(label)}) && l.htmlFor && document.getElementById(l.htmlFor)?.offsetParent !== null);
|
||||
const l = [...document.querySelector(${JSON.stringify(within)}).querySelectorAll('label')].find(l => l.textContent.trim().startsWith(${JSON.stringify(label)}) && l.htmlFor && document.getElementById(l.htmlFor)?.offsetParent !== null);
|
||||
if (!l) throw new Error('no visible field ' + ${JSON.stringify(label)});
|
||||
const el = document.getElementById(l.htmlFor);
|
||||
const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// AMI published on 127.0.0.1:15038, the api writing its config into that container, two baresip
|
||||
// phones, and the dashboard checked in a headless browser.
|
||||
//
|
||||
// 1. node scripts/e2e/m2-live.mjs up <confDir> [<voicemailDir>]
|
||||
// 1. node scripts/e2e/m2-live.mjs up <confDir> [<voicemailDir> [<soundsDir>]]
|
||||
// starts Asterisk; then start the api with AMI_PORT=15038 AMI_SECRET=m2-live-secret
|
||||
// ASTERISK_CONFIG_DIR=<confDir> [VOICEMAIL_DIR=<voicemailDir>] and run m1-t4.mjs to get
|
||||
// past the first login
|
||||
@@ -29,9 +29,9 @@ const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
|
||||
if (cmd === 'up') {
|
||||
const [conf, vm] = args;
|
||||
const [conf, vm, sounds] = args;
|
||||
mkdirSync(conf, { recursive: true });
|
||||
if (vm) mkdirSync(vm, { recursive: true });
|
||||
for (const d of [vm, sounds]) if (d) mkdirSync(d, { recursive: true });
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-live-'));
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
@@ -74,6 +74,7 @@ if (cmd === 'up') {
|
||||
'-v',
|
||||
`${join(dir, 'rtp.conf')}:/etc/asterisk/rtp.conf:ro`,
|
||||
...(vm ? ['-v', `${vm}:/var/spool/asterisk/voicemail`] : []),
|
||||
...(sounds ? ['-v', `${sounds}:/var/lib/asterisk/sounds/custom`] : []),
|
||||
IMAGE,
|
||||
);
|
||||
assert.ok(r.ok, r.out);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M6-T1 in a browser: drop an audio file on Sounds & music, upload it, play it, rename it, see
|
||||
// Asterisk play custom/<id>, and delete it (refused while it's the recording notice).
|
||||
// Runs on the m2-live setup with a sounds dir (m2-live.mjs up <confDir> <vmDir> <soundsDir>) and
|
||||
// the api started with SOUNDS_DIR=<soundsDir>, after m1-t4.mjs.
|
||||
// Usage: node scripts/e2e/m6-t1.mjs <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const AST = 'sp-live-ast';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...a) => spawnSync('docker', a, { encoding: 'utf8' }).stdout ?? '';
|
||||
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd);
|
||||
const [base, email, pw, shotsDir] = process.argv.slice(2);
|
||||
|
||||
const p = await launch({ shotsDir });
|
||||
try {
|
||||
await p.goto(`${base}/login`);
|
||||
await p.waitFor(`!!document.querySelector('input[type=email]')`);
|
||||
await p.fill('Email', email);
|
||||
await p.fill('Password', pw);
|
||||
await p.click('Sign in');
|
||||
await p.waitFor(`location.pathname === '/admin'`);
|
||||
await p.click('Sounds & music');
|
||||
await p.waitFor(`document.body.innerText.includes('No sounds yet')`, 'empty list');
|
||||
|
||||
// A 2 s 440 Hz tone as a wav file, dropped on the upload area like a user would.
|
||||
await p.evaluate(`(() => {
|
||||
const rate = 8000, n = rate * 2, buf = new ArrayBuffer(44 + n * 2), v = new DataView(buf);
|
||||
const s = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
|
||||
s(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); s(8, 'WAVEfmt '); v.setUint32(16, 16, true);
|
||||
v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true);
|
||||
v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||
s(36, 'data'); v.setUint32(40, n * 2, true);
|
||||
for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, Math.sin((2 * Math.PI * 440 * i) / rate) * 8000, true);
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([buf], 'main_greeting.wav', { type: 'audio/wav' }));
|
||||
const form = document.querySelector('form[aria-label="Upload a sound"]');
|
||||
form.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
return true;
|
||||
})()`);
|
||||
await p.waitFor(`document.body.innerText.includes('Selected: main_greeting.wav')`, 'file picked');
|
||||
assert.equal(
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('label')].find(l => l.textContent === 'Name').control.value`,
|
||||
),
|
||||
'main greeting',
|
||||
'name suggested from the file name',
|
||||
);
|
||||
await p.fill('Name', 'Main greeting');
|
||||
await p.click('Upload');
|
||||
await p.waitFor(
|
||||
`!!document.querySelector('audio[aria-label="Play Main greeting"]')`,
|
||||
'uploaded',
|
||||
15_000,
|
||||
);
|
||||
assert.match(await p.text(), /Main greeting 0:02/);
|
||||
await p.shot('m6-t1-01-uploaded');
|
||||
console.log('uploaded by drag and drop: "Main greeting" 0:02');
|
||||
|
||||
const sound = (await p.evaluate(`fetch('/api/sounds').then(r => r.json())`)).items[0];
|
||||
const status = await p.evaluate(`fetch('/api/sounds/${sound.id}/audio').then(r => r.status)`);
|
||||
assert.equal(status, 200, 'audio plays');
|
||||
|
||||
// Asterisk plays it: a Local channel answers with Playback(custom/<id>).
|
||||
const before = docker('logs', AST).length;
|
||||
cli(`channel originate Local/99@sp-vm-direct application Playback ${sound.asteriskName}`);
|
||||
let played = '';
|
||||
for (let i = 0; i < 20 && !played; i++) {
|
||||
await sleep(250);
|
||||
played = docker('logs', AST).slice(before);
|
||||
}
|
||||
assert.match(
|
||||
played,
|
||||
new RegExp(`Playing '${sound.asteriskName}\\.`),
|
||||
'Asterisk played custom/<id>',
|
||||
);
|
||||
assert.doesNotMatch(played, /Unable to open|not found/i);
|
||||
console.log(`Asterisk played ${sound.asteriskName}`);
|
||||
|
||||
await p.click('Rename');
|
||||
await p.waitFor(`!!document.querySelector('dialog[open]')`);
|
||||
await p.fill('Name', 'Welcome', 'dialog[open]');
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('dialog[open] button')].find(b => b.textContent === 'Save').click(), true`,
|
||||
);
|
||||
await p.waitFor(`!!document.querySelector('audio[aria-label="Play Welcome"]')`, 'renamed');
|
||||
console.log('renamed to "Welcome"');
|
||||
|
||||
// In use as the recording notice → the delete is refused with the reason.
|
||||
await p.evaluate(
|
||||
`fetch('/api/settings', { method: 'PATCH', headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' }, body: JSON.stringify({ 'recordings.announcementSoundId': '${sound.id}' }) }).then(r => r.status)`,
|
||||
);
|
||||
await p.click('Delete');
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('dialog[open] button')].find(b => b.textContent === 'Delete').click(), true`,
|
||||
);
|
||||
await p.waitFor(
|
||||
`document.body.innerText.includes('still used by the call recording notice')`,
|
||||
'refused',
|
||||
);
|
||||
await p.shot('m6-t1-02-in-use');
|
||||
console.log('delete refused while it is the recording notice');
|
||||
await p.evaluate(
|
||||
`fetch('/api/settings', { method: 'PATCH', headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' }, body: JSON.stringify({ 'recordings.announcementSoundId': null }) }).then(r => r.status)`,
|
||||
);
|
||||
await p.evaluate(
|
||||
`[...document.querySelectorAll('dialog[open] button')].find(b => b.textContent === 'Delete').click(), true`,
|
||||
);
|
||||
await p.waitFor(`document.body.innerText.includes('No sounds yet')`, 'deleted');
|
||||
console.log('deleted');
|
||||
assert.deepEqual(
|
||||
p.consoleErrors.filter((e) => !/409/.test(e)),
|
||||
[],
|
||||
'no console errors',
|
||||
);
|
||||
console.log('M6-T1 (sounds): PASS');
|
||||
} catch (e) {
|
||||
await p.shot('m6-t1-fail').catch(() => {});
|
||||
console.log(`FAIL: ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await p.close();
|
||||
}
|
||||
Reference in new issue
Block a user