From b8ff6e338651c4db089a6ca71fe3773bb65ef849 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Mon, 19 Oct 2026 10:10:01 -0500 Subject: [PATCH] moh: music-on-hold classes, uploads, pickers for users and queues /api/moh-classes: create (slug, not "default"), delete (409 while an extension or queue uses it), add/play/remove files. Files are 8 kHz wav in MOH_DIR// and sound_files rows of kind moh. Extensions and queues may only pick default or an existing class. Two things found with real calls, written back into 03 and 04: - An empty class plays silence on Asterisk 24. Only classes with music are rendered, so a channel asking for an empty one gets default. - The generator reloads only changed files, and a file upload changes no config (except a class's first/last). The route sends moh reload after every file change. Web: a Music on hold section on Sounds & music, and a select of sets in the user drawer's Phone tab and the queue form. load-golden makes the class folders like the api does. Golden case queue gains two classes. e2e m8-t5: set made in the UI, upload listed by Asterisk, a queue caller hears it, a user's moh_suggest, delete refused while used, an emptied set falls back to default. Task: M8-T5 --- apps/api/src/app.ts | 2 + apps/api/src/config.ts | 2 + .../expected/musiconhold_selfphoneng.conf | 9 + .../queue/expected/pjsip_selfphoneng.conf | 4 +- .../queue/expected/queues_selfphoneng.conf | 2 +- .../generator/__golden__/queue/snapshot.json | 3 + apps/api/src/generator/golden-case.ts | 2 + apps/api/src/generator/index.ts | 2 + apps/api/src/generator/load.ts | 11 + apps/api/src/generator/moh.ts | 20 ++ apps/api/src/routes/extensions.ts | 3 + apps/api/src/routes/moh.ts | 215 ++++++++++++++++++ apps/api/src/routes/queues.ts | 3 + apps/api/test/app.ts | 1 + apps/api/test/asterisk/call-test.ts | 1 + apps/api/test/asterisk/cdr-record.ts | 1 + apps/api/test/asterisk/load-golden.mjs | 18 +- apps/api/test/asterisk/trunk-test.ts | 1 + apps/api/test/asterisk/voicemail-test.ts | 1 + apps/api/test/moh.test.ts | 107 +++++++++ apps/web/src/api/client.ts | 16 ++ apps/web/src/components/MohOptions.tsx | 15 ++ apps/web/src/pages/admin/QueuesPage.tsx | 9 +- apps/web/src/pages/admin/SoundsPage.tsx | 179 ++++++++++++++- apps/web/src/pages/admin/UserDrawer.tsx | 7 +- docs/spec/02-data-model.md | 2 +- docs/spec/03-config-generator.md | 5 + docs/spec/04-backend-api.md | 4 +- docs/spec/05-web-ui.md | 4 +- packages/shared/src/index.ts | 1 + packages/shared/src/moh.ts | 20 ++ packages/shared/src/sounds.ts | 2 +- scripts/e2e/m2-live.mjs | 7 +- scripts/e2e/m8-t5.mjs | 182 +++++++++++++++ 34 files changed, 840 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/generator/moh.ts create mode 100644 apps/api/src/routes/moh.ts create mode 100644 apps/api/test/moh.test.ts create mode 100644 apps/web/src/components/MohOptions.tsx create mode 100644 packages/shared/src/moh.ts create mode 100644 scripts/e2e/m8-t5.mjs diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e0b3944..272a6aa 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -26,6 +26,7 @@ import { ringGroupRoutes } from './routes/ringgroups.ts'; import { queueRoutes } from './routes/queues.ts'; import { conferenceRoutes } from './routes/conferences.ts'; import { pagingRoutes } from './routes/paging.ts'; +import { mohRoutes } from './routes/moh.ts'; import { ivrRoutes } from './routes/ivrs.ts'; import { timeConditionRoutes } from './routes/timeconditions.ts'; import { announcementRoutes } from './routes/announcements.ts'; @@ -110,6 +111,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public')) app.register(queueRoutes); app.register(conferenceRoutes); app.register(pagingRoutes); + app.register(mohRoutes); app.register(ivrRoutes); app.register(timeConditionRoutes); app.register(announcementRoutes); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index b059a28..cd59336 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -19,6 +19,8 @@ const EnvSchema = z.object({ VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'), /** Uploaded prompts, played as custom/ (04 §6). Dev: ../../data/sounds-custom */ SOUNDS_DIR: z.string().default('/var/lib/asterisk/sounds/custom'), + /** Music-on-hold classes, one folder each (04 §6, 03 §8). Dev: ../../data/moh */ + MOH_DIR: z.string().default('/var/lib/asterisk/moh'), /** MixMonitor's directory; recordings are YYYY/MM/DD/.wav under it (03 sp-sub-record). */ RECORDINGS_DIR: z.string().default('/var/spool/asterisk/monitor'), /** The browser softphone's SIP WebSocket. Default wss://PUBLIC_HOSTNAME/ws (the reverse proxy). */ diff --git a/apps/api/src/generator/__golden__/queue/expected/musiconhold_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/musiconhold_selfphoneng.conf index 6ae9960..9775c66 100644 --- a/apps/api/src/generator/__golden__/queue/expected/musiconhold_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/queue/expected/musiconhold_selfphoneng.conf @@ -1 +1,10 @@ ; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten. +[after-hours] +mode = files +directory = /var/lib/asterisk/moh/after-hours +sort = random + +[support-jazz] +mode = files +directory = /var/lib/asterisk/moh/support-jazz +sort = random diff --git a/apps/api/src/generator/__golden__/queue/expected/pjsip_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/pjsip_selfphoneng.conf index 287efbe..840c567 100644 --- a/apps/api/src/generator/__golden__/queue/expected/pjsip_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/queue/expected/pjsip_selfphoneng.conf @@ -191,7 +191,7 @@ mwi_subscribe_replaces_unsolicited = yes dtmf_mode = rfc4733 allow_subscribe = yes language = en -moh_suggest = default +moh_suggest = after-hours tone_zone = us set_var = SP_EXT=103 set_var = SP_CLASS=2 @@ -226,7 +226,7 @@ mwi_subscribe_replaces_unsolicited = yes dtmf_mode = rfc4733 allow_subscribe = yes language = en -moh_suggest = default +moh_suggest = after-hours tone_zone = us set_var = SP_EXT=103 set_var = SP_CLASS=2 diff --git a/apps/api/src/generator/__golden__/queue/expected/queues_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/queues_selfphoneng.conf index c7a08dc..f9f4d34 100644 --- a/apps/api/src/generator/__golden__/queue/expected/queues_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/queue/expected/queues_selfphoneng.conf @@ -6,7 +6,7 @@ timeout = 15 retry = 5 wrapuptime = 10 maxlen = 0 -musicclass = default +musicclass = support-jazz announce-frequency = 60 announce-position = yes ringinuse = no diff --git a/apps/api/src/generator/__golden__/queue/snapshot.json b/apps/api/src/generator/__golden__/queue/snapshot.json index 196eb51..9660ead 100644 --- a/apps/api/src/generator/__golden__/queue/snapshot.json +++ b/apps/api/src/generator/__golden__/queue/snapshot.json @@ -1,4 +1,5 @@ { + "mohClasses": ["support-jazz", "after-hours"], "extensions": [ { "id": "00000000-0000-4000-8000-000000000101", @@ -16,6 +17,7 @@ "id": "00000000-0000-4000-8000-000000000103", "number": "103", "name": "Sue Smith", + "mohClass": "after-hours", "sipPassword": "Cb3dEf9hIj2kLm4nOp6qRs8t" } ], @@ -24,6 +26,7 @@ "id": "44444444-4444-4444-8444-444444444550", "number": "550", "name": "Support", + "mohClass": "support-jazz", "members": [ { "number": "101", "name": "Dan Doe", "penalty": 0 }, { "number": "102", "name": "Ann, Lee", "penalty": 1 } diff --git a/apps/api/src/generator/golden-case.ts b/apps/api/src/generator/golden-case.ts index 06c97f2..e972215 100644 --- a/apps/api/src/generator/golden-case.ts +++ b/apps/api/src/generator/golden-case.ts @@ -25,6 +25,7 @@ export function loadCase(name: string): Snapshot { timeConditions?: Partial[]; holidays?: Snapshot['holidays']; announcements?: Snapshot['announcements']; + mohClasses?: string[]; }; const settings = Object.fromEntries( (Object.keys(SettingsSchemas) as SettingKey[]) @@ -116,6 +117,7 @@ export function loadCase(name: string): Snapshot { id: c.id!, number: c.number!, })), + mohClasses: raw.mohClasses ?? [], pagingGroups: (raw.pagingGroups ?? []).map((g, i) => ({ name: `Paging ${i + 1}`, duplex: false, diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts index b3a235e..c60759e 100644 --- a/apps/api/src/generator/index.ts +++ b/apps/api/src/generator/index.ts @@ -20,6 +20,7 @@ import { renderEmergency } from './dialplan/emergency.ts'; import { renderRingGroups } from './dialplan/ringgroups.ts'; import { renderQueueDialplan } from './dialplan/queues.ts'; import { renderQueues } from './queues.ts'; +import { renderMoh } from './moh.ts'; import { renderConfbridge, renderConferences } from './dialplan/conferences.ts'; import { renderPaging } from './dialplan/paging.ts'; import { renderIvrs } from './dialplan/ivrs.ts'; @@ -51,6 +52,7 @@ export type RenderedFiles = Record; const BODIES: Partial string>> = { 'pjsip_selfphoneng.conf': renderPjsip, 'rtp_selfphoneng.conf': renderRtp, + 'musiconhold_selfphoneng.conf': renderMoh, 'extensions_globals_selfphoneng.conf': renderGlobals, 'voicemail_selfphoneng.conf': renderVoicemail, 'queues_selfphoneng.conf': renderQueues, diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts index 60c4aa3..9947ae1 100644 --- a/apps/api/src/generator/load.ts +++ b/apps/api/src/generator/load.ts @@ -10,6 +10,7 @@ import { extensions, holidays, ivrOptions, + mohClasses, pagingGroupMembers, pagingGroups, ivrs, @@ -195,6 +196,11 @@ export type Snapshot = { timeConditions: SnapTimeCondition[]; holidays: SnapHoliday[]; announcements: SnapAnnouncement[]; + /** + * Music-on-hold classes that have music ('default' is built in and not here). An empty class + * would play silence; left out, Asterisk falls back to default (seen in M8-T5). + */ + mohClasses: string[]; /** Played before recording a call that a phone number records (Settings → Recording). */ recordingAnnouncement: string | null; }; @@ -254,6 +260,10 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise const tcRows = await db.select().from(timeConditions); const holidayRows = await db.select().from(holidays); const annRows = await db.select().from(announcements); + const mohRows = await db + .selectDistinct({ name: mohClasses.name }) + .from(mohClasses) + .innerJoin(soundFiles, eq(soundFiles.mohClassId, mohClasses.id)); const optionRows = await db.select().from(ivrOptions); const soundName = new Map( (await db.select({ id: soundFiles.id, name: soundFiles.asteriskName }).from(soundFiles)).map( @@ -352,6 +362,7 @@ export async function loadSnapshot(db: Db, now = new Date()): Promise maxMembers: c.maxMembers, record: c.record, })), + mohClasses: mohRows.map((m) => m.name), pagingGroups: pageRows.map((g) => ({ id: g.id, number: g.number, diff --git a/apps/api/src/generator/moh.ts b/apps/api/src/generator/moh.ts new file mode 100644 index 0000000..3bf2062 --- /dev/null +++ b/apps/api/src/generator/moh.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// musiconhold_selfphoneng.conf (03 §8): one class per moh_classes row, a folder Asterisk plays in +// random order. [default] lives in the static musiconhold.conf (01 §3.11). +import type { Snapshot } from './load.ts'; +import * as s from './sanitize.ts'; + +export function renderMoh(snap: Snapshot): string { + return [...snap.mohClasses] + .sort() + .map((name) => + [ + `[${s.ident(name)}]`, + 'mode = files', + `directory = /var/lib/asterisk/moh/${s.ident(name)}`, + 'sort = random', + '', + ].join('\n'), + ) + .join('\n'); +} diff --git a/apps/api/src/routes/extensions.ts b/apps/api/src/routes/extensions.ts index c04dd6e..fc673ce 100644 --- a/apps/api/src/routes/extensions.ts +++ b/apps/api/src/routes/extensions.ts @@ -24,6 +24,7 @@ import { } from '../crypto/secrets.ts'; import { audit } from '../services/audit.ts'; import { assertNumberAvailable, nextFreeNumber } from '../services/numbering.ts'; +import { assertMohClass } from './moh.ts'; import { findReferences } from '../services/destinations.ts'; import { getSetting } from '../services/settings.ts'; @@ -109,6 +110,7 @@ export async function extensionRoutes(app: FastifyInstance) { const { voicemailPin, ...body } = ExtensionCreateSchema.parse(req.body); await assertNumberAvailable(db, body.number, 'user'); await assertUserFree(db, body.userId); + await assertMohClass(db, body.mohClass); if (voicemailPin) checkPin(voicemailPin, body.number); let created: ExtRow; try { @@ -149,6 +151,7 @@ export async function extensionRoutes(app: FastifyInstance) { if (body.number && body.number !== before.number) await assertNumberAvailable(db, body.number, 'user', id); await assertUserFree(db, body.userId, id); + await assertMohClass(db, body.mohClass); if (voicemailPin) checkPin(voicemailPin, body.number ?? before.number); let after: ExtRow; try { diff --git a/apps/api/src/routes/moh.ts b/apps/api/src/routes/moh.ts new file mode 100644 index 0000000..440e07f --- /dev/null +++ b/apps/api/src/routes/moh.ts @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// /api/moh-classes (04 §6, §7.3): music-on-hold classes and their files. A class is a folder +// MOH_DIR// of .wav (8 kHz, wav only); Asterisk plays it in random order (03 §8). +// 'default' is built in (stock opsound files) and not stored. Admin only. +// ponytail: no rename (the name is the folder and is stored on extensions and queues); delete and +// re-create instead. +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { and, asc, eq } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + MohClassCreateSchema, + SOUND_MAX_BYTES, + SoundName, + type ListResponse, + type MohClassDto, +} from '@selfphoneng/shared'; +import { requireAdmin } from '../auth/guards.ts'; +import type { Db } from '../db/client.ts'; +import { extensions, mohClasses, queues, soundFiles } from '../db/schema.ts'; +import { badRequest, conflict, notFound } from '../errors.ts'; +import { AudioError, durationMs, toPcmWav } from '../media/audio.ts'; +import { audit } from '../services/audit.ts'; + +type ClassRow = typeof mohClasses.$inferSelect; + +/** Throws 400 unless `name` is 'default' or a stored class. */ +export async function assertMohClass(db: Db, name: string | undefined) { + if (name === undefined || name === 'default') return; + const [c] = await db.select().from(mohClasses).where(eq(mohClasses.name, name)); + if (!c) throw badRequest(`There is no music on hold called "${name}"`); +} + +async function toDto(db: Db, c: ClassRow): Promise { + const files = await db + .select() + .from(soundFiles) + .where(eq(soundFiles.mohClassId, c.id)) + .orderBy(asc(soundFiles.name)); + return { + id: c.id, + name: c.name, + files: files.map((f) => ({ + id: f.id, + name: f.name, + durationMs: f.durationMs, + originalFilename: f.originalFilename, + createdAt: f.createdAt.toISOString(), + })), + createdAt: c.createdAt.toISOString(), + }; +} + +const IdParams = z.object({ id: z.uuid() }); +const FileParams = z.object({ id: z.uuid(), fileId: z.uuid() }); +const UploadQuery = z.object({ name: SoundName, filename: z.string().max(200).optional() }); +const isUniqueViolation = (e: unknown) => + ((e as { cause?: { code?: string } }).cause?.code ?? (e as { code?: string }).code) === '23505'; + +export async function mohRoutes(app: FastifyInstance) { + const { db } = app; + const root = resolve(app.config.MOH_DIR); + app.addHook('preHandler', requireAdmin); + app.addContentTypeParser('*', { parseAs: 'buffer', bodyLimit: SOUND_MAX_BYTES }, (_r, b, done) => + done(null, b), + ); + + /** + * Only a class's first file and its last one change the config (03 §8), and the generator + * reloads only what changed: tell Asterisk to re-read the folders after every file (M8-T5). + * ponytail: skipped while AMI is down; the next startup apply reloads MOH anyway. + */ + async function reloadMoh() { + if (!app.ami.isConnected()) return; + await app.ami + .action({ Action: 'Command', Command: 'moh reload' }) + .catch((e: unknown) => app.log.warn({ err: e }, 'moh reload')); + } + + async function getClass(id: string) { + const [c] = await db.select().from(mohClasses).where(eq(mohClasses.id, id)); + if (!c) throw notFound('Music on hold not found'); + return c; + } + async function getFile(classId: string, fileId: string) { + const [f] = await db + .select() + .from(soundFiles) + .where(and(eq(soundFiles.id, fileId), eq(soundFiles.mohClassId, classId))); + if (!f) throw notFound('File not found'); + return f; + } + + app.get('/api/moh-classes', async (): Promise> => { + const rows = await db.select().from(mohClasses).orderBy(asc(mohClasses.name)); + return { items: await Promise.all(rows.map((c) => toDto(db, c))), nextCursor: null }; + }); + + app.post('/api/moh-classes', async (req, reply) => { + const { name } = MohClassCreateSchema.parse(req.body); + let c: ClassRow; + try { + [c] = (await db.insert(mohClasses).values({ name }).returning()) as [ClassRow]; + } catch (e) { + if (isUniqueViolation(e)) throw conflict(`"${name}" already exists`); + throw e; + } + await mkdir(resolve(root, name), { recursive: true }); + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'create', + entityType: 'moh_class', + entityId: c.id, + summary: `Created music on hold "${name}"`, + }); + return reply.code(201).send(await toDto(db, c)); + }); + + app.delete('/api/moh-classes/:id', async (req) => { + const c = await getClass(IdParams.parse(req.params).id); + const usedBy = [ + ...( + await db + .select({ n: extensions.number }) + .from(extensions) + .where(eq(extensions.mohClass, c.name)) + ).map((r) => `extension ${r.n}`), + ...(await db.select({ n: queues.name }).from(queues).where(eq(queues.mohClass, c.name))).map( + (r) => `queue ${r.n}`, + ), + ]; + if (usedBy.length) + throw conflict(`"${c.name}" is still used by ${usedBy.join(', ')}`, { usedBy }); + await db.delete(mohClasses).where(eq(mohClasses.id, c.id)); // its sound_files cascade + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'delete', + entityType: 'moh_class', + entityId: c.id, + summary: `Deleted music on hold "${c.name}"`, + }); + app.generator.requestApply('api-change'); + await rm(resolve(root, c.name), { recursive: true, force: true }); + return { ok: true }; + }); + + /** POST /api/moh-classes/:id/files?name=Jazz&filename=jazz.mp3, body = the file. */ + app.post('/api/moh-classes/:id/files', async (req, reply) => { + const c = await getClass(IdParams.parse(req.params).id); + 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(); + const file = resolve(root, c.name, `${id}.wav`); + await mkdir(resolve(root, c.name), { recursive: true }); + try { + await toPcmWav(req.body, file); + } catch (e) { + if (!(e instanceof AudioError)) throw e; + req.log.info({ err: e.message }, 'moh conversion failed'); + throw badRequest("That file isn't audio we can read"); + } + await db.insert(soundFiles).values({ + id, + name, + kind: 'moh', + mohClassId: c.id, + source: 'upload', + originalFilename: filename ?? null, + asteriskName: `moh/${c.name}/${id}`, + durationMs: (await durationMs(file)) ?? 0, + }); + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'create', + entityType: 'sound', + entityId: id, + summary: `Added "${name}" to music on hold "${c.name}"`, + }); + await reloadMoh(); + app.generator.requestApply('api-change'); + return reply.code(201).send(await toDto(db, c)); + }); + + app.get('/api/moh-classes/:id/files/:fileId/audio', async (req, reply) => { + const { id, fileId } = FileParams.parse(req.params); + const c = await getClass(id); + await getFile(id, fileId); + return reply.sendFile(`${fileId}.wav`, resolve(root, c.name)); + }); + + app.delete('/api/moh-classes/:id/files/:fileId', async (req) => { + const { id, fileId } = FileParams.parse(req.params); + const c = await getClass(id); + const f = await getFile(id, fileId); + await db.delete(soundFiles).where(eq(soundFiles.id, fileId)); + await rm(resolve(root, c.name, `${fileId}.wav`), { force: true }); + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'delete', + entityType: 'sound', + entityId: fileId, + summary: `Removed "${f.name}" from music on hold "${c.name}"`, + }); + await reloadMoh(); + app.generator.requestApply('api-change'); + return { ok: true }; + }); +} diff --git a/apps/api/src/routes/queues.ts b/apps/api/src/routes/queues.ts index 90432ef..c361069 100644 --- a/apps/api/src/routes/queues.ts +++ b/apps/api/src/routes/queues.ts @@ -21,6 +21,7 @@ import { findReferences, } from '../services/destinations.ts'; import { assertNumberAvailable } from '../services/numbering.ts'; +import { assertMohClass } from './moh.ts'; import { setPaused } from '../services/queues.ts'; import { queueStats, StatsQuery } from '../services/queuestats.ts'; @@ -113,6 +114,7 @@ export async function queueRoutes(app: FastifyInstance) { app.post('/api/queues', async (req, reply) => { const { members = [], ...body } = QueueCreateSchema.parse(req.body); await assertNumberAvailable(db, body.number, 'queue'); + await assertMohClass(db, body.mohClass); await assertDestinationExists(db, body.timeoutDestination); await assertMembers(db, members); let created: Row; @@ -145,6 +147,7 @@ export async function queueRoutes(app: FastifyInstance) { const before = await getQueue(id); if (body.number && body.number !== before.number) await assertNumberAvailable(db, body.number, 'queue', id); + await assertMohClass(db, body.mohClass); if (body.timeoutDestination) { if (body.timeoutDestination.type === 'queue' && body.timeoutDestination.id === id) throw badRequest("A queue can't send callers who waited too long back to itself"); diff --git a/apps/api/test/app.ts b/apps/api/test/app.ts index f0ba8ba..fe2e5a9 100644 --- a/apps/api/test/app.ts +++ b/apps/api/test/app.ts @@ -65,6 +65,7 @@ export async function createTestApp(extend?: (app: ReturnType) LOG_LEVEL: 'fatal', RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')), SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')), + MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')), }); setSecretKey(config.APP_SECRET); const pub = mkdtempSync(join(tmpdir(), 'sp-public-')); diff --git a/apps/api/test/asterisk/call-test.ts b/apps/api/test/asterisk/call-test.ts index 65b3af7..34b70d9 100644 --- a/apps/api/test/asterisk/call-test.ts +++ b/apps/api/test/asterisk/call-test.ts @@ -68,6 +68,7 @@ function snapshot(): Snapshot { queues: [], conferences: [], pagingGroups: [], + mohClasses: [], ivrs: [], timeConditions: [], holidays: [], diff --git a/apps/api/test/asterisk/cdr-record.ts b/apps/api/test/asterisk/cdr-record.ts index 38d17d9..c697d9d 100644 --- a/apps/api/test/asterisk/cdr-record.ts +++ b/apps/api/test/asterisk/cdr-record.ts @@ -157,6 +157,7 @@ function snapshot(): Snapshot { })), conferences: [], pagingGroups: [], + mohClasses: [], ivrs: [ { id: CDR_IDS.ivr, diff --git a/apps/api/test/asterisk/load-golden.mjs b/apps/api/test/asterisk/load-golden.mjs index c8e15fc..c2973e9 100644 --- a/apps/api/test/asterisk/load-golden.mjs +++ b/apps/api/test/asterisk/load-golden.mjs @@ -4,7 +4,15 @@ // Needs Docker and a built image (ASTERISK_IMAGE, default "selfphoneng-asterisk"). // Usage: pnpm test:asterisk [case ...] import { spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -40,6 +48,11 @@ async function check(name) { const tmp = mkdtempSync(join(tmpdir(), `sp-golden-${name}-`)); cpSync(join(GOLDEN, name, 'expected'), tmp, { recursive: true }); const problems = []; + // The api makes each MOH class's folder when the class is created (routes/moh.ts); do the same. + const moh = mkdtempSync(join(tmpdir(), `sp-golden-moh-`)); + const mohConf = readFileSync(join(tmp, 'musiconhold_selfphoneng.conf'), 'utf8'); + for (const [, d] of mohConf.matchAll(/^directory = \/var\/lib\/asterisk\/moh\/(.+)$/gm)) + mkdirSync(join(moh, d)); const run = docker( 'run', '-d', @@ -50,6 +63,8 @@ async function check(name) { 'AMI_SECRET=golden-test-secret', '-v', `${tmp}:/etc/asterisk/selfphoneng`, + '-v', + `${moh}:/var/lib/asterisk/moh`, IMAGE, ); if (!run.ok) throw new Error(`docker run failed: ${run.out}`); @@ -90,6 +105,7 @@ async function check(name) { } finally { docker('rm', '-f', NAME); rmSync(tmp, { recursive: true, force: true }); + rmSync(moh, { recursive: true, force: true }); } } diff --git a/apps/api/test/asterisk/trunk-test.ts b/apps/api/test/asterisk/trunk-test.ts index e857b68..f2396f0 100644 --- a/apps/api/test/asterisk/trunk-test.ts +++ b/apps/api/test/asterisk/trunk-test.ts @@ -107,6 +107,7 @@ function snapshot(): Snapshot { queues: [], conferences: [], pagingGroups: [], + mohClasses: [], ivrs: [], timeConditions: [], holidays: [], diff --git a/apps/api/test/asterisk/voicemail-test.ts b/apps/api/test/asterisk/voicemail-test.ts index 7074ec2..8fd0d9a 100644 --- a/apps/api/test/asterisk/voicemail-test.ts +++ b/apps/api/test/asterisk/voicemail-test.ts @@ -63,6 +63,7 @@ function snapshot(): Snapshot { queues: [], conferences: [], pagingGroups: [], + mohClasses: [], ivrs: [], timeConditions: [], holidays: [], diff --git a/apps/api/test/moh.test.ts b/apps/api/test/moh.test.ts new file mode 100644 index 0000000..f68e3fa --- /dev/null +++ b/apps/api/test/moh.test.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// M8-T5: /api/moh-classes (03 §8, 04 §6): classes, their files, and who may use them. +import { existsSync, readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hasFfmpeg, silenceWav } from './audio.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +describe.skipIf(!hasTestDb || !hasFfmpeg)('/api/moh-classes', () => { + let t: Awaited>; + let req: ( + method: string, + url: string, + payload?: unknown, + headers?: Record, + ) => Promise; + let classId: string; + let fileId: string; + let extId: string; + const mohDir = () => t.app.config.MOH_DIR; + const conf = async () => { + await t.app.generator.apply({ trigger: 'test' }); + return readFile(join(t.genDir, 'musiconhold_selfphoneng.conf'), 'utf8'); + }; + + 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 }, + }); + extId = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json().id; + }); + afterAll(() => t?.close()); + + it('creates a class: its folder; no section until it has music', async () => { + const r = await req('POST', '/api/moh-classes', { name: 'sales-jazz' }); + expect(r.statusCode, r.body).toBe(201); + classId = r.json().id; + expect(existsSync(join(mohDir(), 'sales-jazz'))).toBe(true); + expect(await conf()).not.toContain('sales-jazz'); // empty: Asterisk falls back to default + expect((await req('POST', '/api/moh-classes', { name: 'sales-jazz' })).statusCode).toBe(409); + for (const name of ['default', 'Sales', '../x', 'a b']) + expect((await req('POST', '/api/moh-classes', { name })).statusCode, name).toBe(400); + }); + + it('adds music: an 8 kHz wav in the folder, playable, listed', async () => { + const r = await req( + 'POST', + `/api/moh-classes/${classId}/files?name=Smooth&filename=smooth.wav`, + silenceWav(), + { 'content-type': 'audio/wav' }, + ); + expect(r.statusCode, r.body).toBe(201); + fileId = r.json().files[0].id; + expect(r.json().files[0]).toMatchObject({ name: 'Smooth', originalFilename: 'smooth.wav' }); + const wav = join(mohDir(), 'sales-jazz', `${fileId}.wav`); + expect(readFileSync(wav).readUInt32LE(24)).toBe(8000); + expect(await conf()).toContain( + '[sales-jazz]\nmode = files\ndirectory = /var/lib/asterisk/moh/sales-jazz\nsort = random', + ); + const audio = await req('GET', `/api/moh-classes/${classId}/files/${fileId}/audio`); + expect(audio.statusCode).toBe(200); + // not a prompt: the sounds list and pickers don't show it + expect((await req('GET', '/api/sounds')).json().items).toEqual([]); + const bad = await req('POST', `/api/moh-classes/${classId}/files?name=X`, Buffer.from('nope'), { + 'content-type': 'audio/wav', + }); + expect(bad.statusCode).toBe(400); + }); + + it('users and queues may pick only classes that exist; delete is refused while used', async () => { + const pick = (mohClass: string) => req('PATCH', `/api/extensions/${extId}`, { mohClass }); + expect((await pick('nope')).statusCode).toBe(400); + expect((await pick('sales-jazz')).statusCode).toBe(200); + const queue = await req('POST', '/api/queues', { + number: '550', + name: 'Support', + mohClass: 'nope', + timeoutDestination: { type: 'hangup' }, + }); + expect(queue.statusCode).toBe(400); + const del = await req('DELETE', `/api/moh-classes/${classId}`); + expect(del.statusCode).toBe(409); + expect(del.json().error.details.usedBy).toEqual(['extension 101']); + expect((await pick('default')).statusCode).toBe(200); + }); + + it('removing a file and the class removes them from disk and config', async () => { + expect((await req('DELETE', `/api/moh-classes/${classId}/files/${fileId}`)).statusCode).toBe( + 200, + ); + expect(existsSync(join(mohDir(), 'sales-jazz', `${fileId}.wav`))).toBe(false); + expect(await conf()).not.toContain('sales-jazz'); + expect((await req('DELETE', `/api/moh-classes/${classId}`)).statusCode).toBe(200); + expect(existsSync(join(mohDir(), 'sales-jazz'))).toBe(false); + expect(await conf()).not.toContain('sales-jazz'); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index e894b01..2d1c97f 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -48,6 +48,7 @@ import type { SettingsDto, SipCredentials, SoundDto, + MohClassDto, TimeConditionCreate, TimeConditionDto, TimeConditionUpdate, @@ -236,6 +237,21 @@ export const soundsApi = { remove: (id: string) => api<{ ok: true }>('DELETE', `/api/sounds/${id}`), }; +export const mohApi = { + list: () => api>('GET', '/api/moh-classes').then((r) => r.items), + create: (name: string) => api('POST', '/api/moh-classes', { name }), + remove: (id: string) => api<{ ok: true }>('DELETE', `/api/moh-classes/${id}`), + addFile: (id: string, name: string, file: File) => + upload( + 'POST', + `/api/moh-classes/${id}/files?${new URLSearchParams({ name, filename: file.name })}`, + file, + ), + audioUrl: (id: string, fileId: string) => `/api/moh-classes/${id}/files/${fileId}/audio`, + removeFile: (id: string, fileId: string) => + api<{ ok: true }>('DELETE', `/api/moh-classes/${id}/files/${fileId}`), +}; + export const ringGroupsApi = { list: () => api>('GET', '/api/ring-groups').then((r) => r.items), create: (g: RingGroupCreate) => api('POST', '/api/ring-groups', g), diff --git a/apps/web/src/components/MohOptions.tsx b/apps/web/src/components/MohOptions.tsx new file mode 100644 index 0000000..2525e83 --- /dev/null +++ b/apps/web/src/components/MohOptions.tsx @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// + )} diff --git a/apps/web/src/pages/admin/SoundsPage.tsx b/apps/web/src/pages/admin/SoundsPage.tsx index 67e3f15..e9c520b 100644 --- a/apps/web/src/pages/admin/SoundsPage.tsx +++ b/apps/web/src/pages/admin/SoundsPage.tsx @@ -1,17 +1,20 @@ // 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. +// Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their +// files (03 §8) and announcements live here too. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { AnnouncementCreateSchema, + MohClassName, SOUND_MAX_BYTES, type AnnouncementCreate, type AnnouncementDto, type Destination, + type MohClassDto, type SoundDto, } from '@selfphoneng/shared'; -import { announcementsApi, soundsApi } from '../../api/client.ts'; +import { announcementsApi, mohApi, soundsApi } from '../../api/client.ts'; import { DestinationPicker } from '../../components/DestinationPicker.tsx'; import { SoundPicker } from '../../components/SoundPicker.tsx'; import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx'; @@ -172,11 +175,183 @@ export function SoundsPage() { }} onConfirm={() => deleting && remove.mutate(deleting.id)} /> + ); } +/** Music on hold (03 §8): sets of music; users and queues pick one. 'default' is built in. */ +function MusicOnHold() { + const qc = useQueryClient(); + const classes = useQuery({ queryKey: ['moh-classes'], queryFn: mohApi.list }); + const refresh = () => qc.invalidateQueries({ queryKey: ['moh-classes'] }); + const [name, setName] = useState(''); + const [nameError, setNameError] = useState(); + const [deleting, setDeleting] = useState(null); + const create = useMutation({ + mutationFn: (n: string) => mohApi.create(n), + onSuccess: async () => { + setName(''); + await refresh(); + }, + }); + const remove = useMutation({ + mutationFn: (id: string) => mohApi.remove(id), + onSuccess: async () => { + setDeleting(null); + await refresh(); + }, + }); + + return ( +
+

+ Music on hold +

+

+ What callers hear on hold or waiting in a queue. Default music is built in. Make your own + set here, then choose it for a user (Phone tab) or a queue. +

+
{ + e.preventDefault(); + const parsed = MohClassName.safeParse(name.trim()); + setNameError(parsed.success ? undefined : parsed.error.issues[0]?.message); + if (parsed.success) create.mutate(parsed.data); + }} + > + + {(p) => ( + setName(e.target.value)} + /> + )} + + +
+ {create.error && {create.error.message}} + {classes.isError && {classes.error.message}} + {classes.data?.map((c) => ( + setDeleting(c)} /> + ))} + Delete "{deleting?.name}" and its music? This can't be undone.

} + confirmLabel="Delete" + busy={remove.isPending} + error={remove.error?.message} + onCancel={() => { + setDeleting(null); + remove.reset(); + }} + onConfirm={() => deleting && remove.mutate(deleting.id)} + /> +
+ ); +} + +function MohClassCard({ + cls, + onChanged, + onDelete, +}: { + cls: MohClassDto; + onChanged: () => Promise; + onDelete: () => void; +}) { + const [file, setFile] = useState(null); + const add = useMutation({ + mutationFn: (f: File) => mohApi.addFile(cls.id, stem(f.name) || 'Music', f), + onSuccess: async () => { + setFile(null); + await onChanged(); + }, + }); + const removeFile = useMutation({ + mutationFn: (fileId: string) => mohApi.removeFile(cls.id, fileId), + onSuccess: onChanged, + }); + const tooBig = file !== null && file.size > SOUND_MAX_BYTES; + + return ( +
+
+ {cls.name} + +
+ {cls.files.length === 0 && ( +

No music yet: callers hear the default music.

+ )} +
    + {cls.files.map((f) => ( +
  • + + {f.name} {secs(f.durationMs)} + +
  • + ))} +
+
{ + e.preventDefault(); + if (file && !tooBig) add.mutate(file); + }} + > + + {(p) => ( + { + add.reset(); + setFile(e.target.files?.[0] ?? null); + }} + /> + )} + + +
+ {tooBig && That file is larger than 20 MB.} + {add.error && {add.error.message}} + {removeFile.error && {removeFile.error.message}} +
+ ); +} + /** Announcements (03 §6.11): play one of these sounds, then send the caller on. */ function Announcements() { const qc = useQueryClient(); diff --git a/apps/web/src/pages/admin/UserDrawer.tsx b/apps/web/src/pages/admin/UserDrawer.tsx index 0df26cb..b28c276 100644 --- a/apps/web/src/pages/admin/UserDrawer.tsx +++ b/apps/web/src/pages/admin/UserDrawer.tsx @@ -19,6 +19,7 @@ import { import { didsApi, extensionsApi, usersApi } from '../../api/client.ts'; import { CLASS_LABEL } from './OutboundPage.tsx'; import { Dialog } from '../../components/Dialog.tsx'; +import { MohOptions } from '../../components/MohOptions.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; type Tab = 'general' | 'phone' | 'calling' | 'emergency' | 'credentials'; @@ -359,7 +360,11 @@ function PhoneTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void }) {(a) => } - {(a) => } + {(a) => ( + + )} diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index 1bfaf55..ec31788 100644 --- a/docs/spec/02-data-model.md +++ b/docs/spec/02-data-model.md @@ -281,7 +281,7 @@ create table sound_files ( ); create table moh_classes ( id uuid primary key default gen_random_uuid(), - name text not null unique -- slug [a-z0-9_-]; 'default' is built in and not stored here + name text not null unique -- slug [a-z0-9-] (same as other slugs); 'default' is built in and not stored here ); create table ivrs ( diff --git a/docs/spec/03-config-generator.md b/docs/spec/03-config-generator.md index cc354d8..9832865 100644 --- a/docs/spec/03-config-generator.md +++ b/docs/spec/03-config-generator.md @@ -979,6 +979,11 @@ sort = random ``` One per `moh_classes` row. The api keeps the folder contents in sync with `sound_files` (04 §6). +As built (M8-T5): one per `moh_classes` row **that has at least one file**. An empty class plays +silence in Asterisk 24; left out, a channel asking for it gets `default` instead (checked with a +real queue caller). Because only the first and last file change this file, the api also sends +`moh reload` after every file it adds or removes. Golden case `queue`; `scripts/e2e/m8-t5.mjs`. + ## 9. Snapshot → endpoint variables summary | Variable | Where set | Read by | diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md index 73c567a..bd33a08 100644 --- a/docs/spec/04-backend-api.md +++ b/docs/spec/04-backend-api.md @@ -316,6 +316,8 @@ forced apply. - Duration: `ffprobe -v error -show_entries format=duration -of csv=p=0 .wav`. - Prompts: `/var/lib/asterisk/sounds/custom/.{wav,g722}` → `asterisk_name = custom/`. - MOH: `/var/lib/asterisk/moh//.wav` (wav only). After any MOH change, call `generator.requestApply`. + As built (M8-T5): the folder is `MOH_DIR` (default `/var/lib/asterisk/moh`). Adding or removing a + file changes no config unless it's the class's first or last, so the route also sends `moh reload`. - Deleting a sound referenced by an IVR/announcement/settings → 409 with references. - Optional TTS (M12): `POST /api/sounds/tts {name, text, voice}` runs Piper (binary and voice models in the api image under `/opt/piper`), then the same conversion. @@ -383,7 +385,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET / | `/api/conferences` | | | `/api/paging-groups` | members are set via `memberIds: [uuid]` in POST/PATCH (no order). Not a destination, so delete never returns 409 | | `/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/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) | | `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked) | | `/api/blocked-numbers` | | | `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) | diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md index 9910fa0..1fd9ba9 100644 --- a/docs/spec/05-web-ui.md +++ b/docs/spec/05-web-ui.md @@ -34,12 +34,12 @@ Left sidebar navigation, in this order: | Outbound rules | `/admin/outbound` | Sortable list (drag to reorder). Form: name, patterns (rows of match/strip/prepend with a pattern help popover), required class, trunk order (multi-select with order), caller ID override. **Test box**: pick an extension + type a number → shows the matching route, final dialed number, trunk, caller ID, or the reason it's denied | | Ring groups | `/admin/ring-groups` | Form: number, name, strategy, ring seconds, members (ordered picker), music instead of ring, caller-name prefix, no-answer DestinationPicker | | (Dashboard, Home) | | As built (M8-T2): a "Parked calls" list (slot, caller, parked by, waiting time) on the Dashboard and on Home; Home's has **Pick up 701**, which calls the slot like Call back (softphone, or the desk phone rings first) | -| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats. As built (M8-T1): detail at `/admin/queues/:id`; music on hold offers only the current class until M8-T5 | +| Queues | `/admin/queues` | Form: every column from 02. Detail page shows live waiting callers + agent list with pause/unpause buttons + today's stats. As built (M8-T1): detail at `/admin/queues/:id`; music on hold is a select of `default` and the classes from Sounds & music (M8-T5) | | IVRs (Digital receptionist) | `/admin/ivrs` | Form: number, name, greeting (SoundPicker), keypad grid 0–9 * # where each key gets a DestinationPicker or "unused", timeout, retries, invalid sound, direct dial toggle, timeout and invalid destinations | | Office hours | `/admin/hours` | Time conditions: weekly grid editor (click-drag hours per day) that saves to `open_ranges`; timezone; holidays toggle; open/closed/holiday destinations; override digit; big status chip "Open now / Closed now" with Force open / Force closed / Automatic buttons. Holidays sub-tab: list + "Add US federal holidays for {year}" | | Conferences | `/admin/conferences` | number, name, PIN, admin PIN, max members, record. As built (M8-T3): admins see the PINs in the list (they hand them out); an admin PIN needs a different room PIN | | Paging | `/admin/paging` | number, name, members, duplex. As built (M8-T4): members are checkboxes (no order); the empty state explains `*80` | -| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) | +| Sounds & music | `/admin/sounds` | Prompts list with play buttons, upload (drag-drop), TTS (M12). MOH classes with their files (M8). As built (M8-T5): a **Music on hold** section: make a set (slug name), upload, play and remove its files, delete it. The user drawer's Phone tab and the queue form pick a set from a select. An **Announcements** section (play a sound, then a destination) lives here too: no page of its own (M6-T5) | | Phones (devices) | `/admin/devices` | Table: MAC, vendor, model, user, last provisioned. Form: MAC (any format → normalized), vendor, model, extension. Shows the provisioning URL to type into the phone, and "Preview config" | | Contacts | `/admin/contacts` | Company phonebook, CSV import | | Call history | `/admin/calls` | Filters + table + CSV export; a row expands to show the CDR legs and plays the recording | diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 060a245..dac0250 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,6 +12,7 @@ export * from './dids.ts'; export * from './outbound.ts'; export * from './voicemail.ts'; export * from './sounds.ts'; +export * from './moh.ts'; export * from './ringgroups.ts'; export * from './queues.ts'; export * from './conferences.ts'; diff --git a/packages/shared/src/moh.ts b/packages/shared/src/moh.ts new file mode 100644 index 0000000..4f5de7f --- /dev/null +++ b/packages/shared/src/moh.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Music-on-hold classes (02 moh_classes, 03 §8): a named folder of music. 'default' is built in +// (the stock opsound files) and not stored. +import { z } from 'zod'; +import type { SoundDto } from './sounds.ts'; + +/** Also the folder name under the MOH dir, so: a slug. */ +export const MohClassName = z + .string() + .regex(/^[a-z0-9-]{1,32}$/, 'Lowercase letters, digits and dashes') + .refine((n) => n !== 'default', '"default" is the built-in music'); +export const MohClassCreateSchema = z.object({ name: MohClassName }); +export type MohClassCreate = z.input; + +export type MohClassDto = { + id: string; + name: string; + files: Omit[]; + createdAt: string; +}; diff --git a/packages/shared/src/sounds.ts b/packages/shared/src/sounds.ts index 42ddde3..da3ca1a 100644 --- a/packages/shared/src/sounds.ts +++ b/packages/shared/src/sounds.ts @@ -1,6 +1,6 @@ // 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. +// Music-on-hold files are sound_files too (kind moh), listed per class (moh.ts). import { z } from 'zod'; export const SoundName = z.string().trim().min(1, 'Required').max(80); diff --git a/scripts/e2e/m2-live.mjs b/scripts/e2e/m2-live.mjs index 107215d..01990f0 100644 --- a/scripts/e2e/m2-live.mjs +++ b/scripts/e2e/m2-live.mjs @@ -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 [ [ []]] +// 1. node scripts/e2e/m2-live.mjs up [ [ [ []]]] // starts Asterisk; then start the api with AMI_PORT=15038 AMI_SECRET=m2-live-secret // ASTERISK_CONFIG_DIR= [VOICEMAIL_DIR=] 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, sounds, recs] = args; + const [conf, vm, sounds, recs, moh] = args; mkdirSync(conf, { recursive: true }); - for (const d of [vm, sounds, recs]) if (d) mkdirSync(d, { recursive: true }); + for (const d of [vm, sounds, recs, moh]) if (d) mkdirSync(d, { recursive: true }); const dir = mkdtempSync(join(tmpdir(), 'sp-live-')); writeFileSync( join(dir, 'manager.conf.template'), @@ -76,6 +76,7 @@ if (cmd === 'up') { ...(vm ? ['-v', `${vm}:/var/spool/asterisk/voicemail`] : []), ...(sounds ? ['-v', `${sounds}:/var/lib/asterisk/sounds/custom`] : []), ...(recs ? ['-v', `${recs}:/var/spool/asterisk/monitor`] : []), + ...(moh ? ['-v', `${moh}:/var/lib/asterisk/moh`] : []), IMAGE, ); assert.ok(r.ok, r.out); diff --git a/scripts/e2e/m8-t5.mjs b/scripts/e2e/m8-t5.mjs new file mode 100644 index 0000000..4375478 --- /dev/null +++ b/scripts/e2e/m8-t5.mjs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// M8-T5 with real calls: a music-on-hold set "sales-jazz" made on Sounds & music, a tone file +// uploaded into it. Asterisk lists the class with that file; a queue using it plays it to a +// waiting caller; a user using it gets moh_suggest (what the people they hold hear). An empty +// set falls back to the default music. Deleting the set is refused while the queue uses it. +// Runs on m2-live with a moh dir, after m1-t4.mjs. +// Usage: node scripts/e2e/m8-t5.mjs +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 cli = (cmd) => + spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? ''; +const astLog = () => + // eslint-disable-next-line no-control-regex -- Asterisk colors its log + spawnSync('docker', ['logs', AST], { encoding: 'utf8' }).stdout.replace(/\x1b\[[0-9;]*m/g, ''); +const [base, email, pw, 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, ok = true) => { + const r = await fetch(`${base}${url}`, { + method, + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + 'x-requested-with': 'selfphoneng', + cookie, + }, + body: body && JSON.stringify(body), + }); + const json = await r.json(); + if (ok) assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`); + return { status: r.status, json }; +}; +const waitUntil = async (fn, what, ms = 30_000) => { + const end = Date.now() + ms; + while (Date.now() < end) { + const v = await fn(); + if (v) return v; + await sleep(300); + } + throw new Error(`timeout waiting for: ${what}`); +}; +/** A caller waiting in queue `q` (no agents) for 20 s. */ +const caller = (q) => cli(`channel originate Local/${q}@sp-from-ext application Wait 20`); + +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'`); + + // The set and its music, in the UI. + await p.click('Sounds & music'); + await p.waitFor(`document.body.innerText.includes('Music on hold')`); + await p.fill('New set', 'Sales Jazz'); + await p.click('Add set'); + await p.waitFor( + `document.body.innerText.includes('Lowercase letters, digits and dashes')`, + 'bad name refused', + ); + await p.fill('New set', 'sales-jazz'); + await p.click('Add set'); + await p.waitFor( + `!!document.querySelector('[aria-label="Music on hold sales-jazz"]')`, + 'set created', + ); + // A 3 s 523 Hz tone picked in the set's file input. + await p.evaluate(`(() => { + const rate = 8000, n = rate * 3, 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 * 523 * i) / rate) * 8000, true); + const dt = new DataTransfer(); + dt.items.add(new File([buf], 'smooth_jazz.wav', { type: 'audio/wav' })); + const input = document.querySelector('[aria-label="Music on hold sales-jazz"] input[type=file]'); + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })()`); + await p.click('Upload', '[aria-label="Music on hold sales-jazz"]'); + await p.waitFor( + `!!document.querySelector('audio[aria-label="Play smooth jazz"]')`, + 'uploaded', + 15_000, + ); + assert.match(await p.text(), /smooth jazz 0:03/); + await p.shot('m8-t5-01-moh'); + console.log('set sales-jazz made in the UI (a bad name refused), "smooth jazz" 0:03 uploaded'); + + // Asterisk has the class with that file. + await waitUntil( + () => + /Class: sales-jazz\n\tFile: \/var\/lib\/asterisk\/moh\/sales-jazz\//.test( + cli('moh show files'), + ), + 'moh show files lists the upload', + ); + console.log('Asterisk: class sales-jazz plays the uploaded file'); + + // A queue with it plays it to a waiting caller. + const sets = (await api('GET', '/api/moh-classes')).json.items; + const jazz = sets.find((c) => c.name === 'sales-jazz'); + const queue = ( + await api('POST', '/api/queues', { + number: '560', + name: 'Jazz line', + mohClass: 'sales-jazz', + joinWhenNoAgents: true, + timeoutDestination: { type: 'hangup' }, + }) + ).json; + await waitUntil(() => /musicclass|560 has/.test(cli('queue show 560')), 'queue 560 loaded'); + let at = astLog().length; + caller('560'); + await waitUntil( + () => /Started music on hold, class 'sales-jazz'/.test(astLog().slice(at)), + 'caller hears sales-jazz', + ); + cli('channel request hangup all'); + console.log('queue 560: the waiting caller hears sales-jazz'); + + // A user with it: what the people they put on hold hear. + const ext = (await api('POST', '/api/extensions', { number: '330', name: 'Hal Hold' })).json; + assert.equal( + (await api('PATCH', `/api/extensions/${ext.id}`, { mohClass: 'nope' }, false)).status, + 400, + ); + await api('PATCH', `/api/extensions/${ext.id}`, { mohClass: 'sales-jazz' }); + await waitUntil( + () => /moh_suggest\s*:\s*sales-jazz/.test(cli('pjsip show endpoint 330')), + 'endpoint 330 suggests sales-jazz', + ); + console.log('user 330: moh_suggest sales-jazz (an unknown set was refused)'); + + // In use: can't delete. Emptied: the caller hears the default music. + const del = await api('DELETE', `/api/moh-classes/${jazz.id}`, undefined, false); + assert.equal(del.status, 409); + assert.match(del.json.error.message, /extension 330.*queue Jazz line|queue Jazz line/); + await api('DELETE', `/api/moh-classes/${jazz.id}/files/${jazz.files[0].id}`); + await waitUntil( + () => + !/Class: sales-jazz\n\tFile: \/var\/lib\/asterisk\/moh\/sales-jazz\//.test( + cli('moh show files'), + ), + 'emptied', + ); + at = astLog().length; + caller('560'); + await waitUntil( + () => /Started music on hold, class 'default'/.test(astLog().slice(at)), + 'empty set: default music', + ); + cli('channel request hangup all'); + console.log('delete refused while used; emptied set: the caller hears the default music'); + await api('DELETE', `/api/queues/${queue.id}`); + console.log('M8-T5 (music on hold): PASS'); +} catch (e) { + await p.shot('m8-t5-fail').catch(() => {}); + console.log(`FAIL: ${e.message}`); + console.log(`moh: ${cli('moh show classes')}`); + console.log(`browser errors: ${JSON.stringify(p.consoleErrors)}`); + process.exitCode = 1; +} finally { + cli('channel request hangup all'); + await p.close(); +}