moh: music-on-hold classes, uploads, pickers for users and queues
ci / check (push) Successful in 51s
ci / images (push) Successful in 18s

/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/<class>/ 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
This commit is contained in:
iamdoubz committed 2026-10-19 10:10:01 -05:00
1 parent e95271e694
commit b8ff6e3386
34 files changed
+840 -21

No files matched your search

+2
View File
@@ -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);
+2
View File
@@ -19,6 +19,8 @@ const EnvSchema = z.object({
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'),
/** 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/<uniqueid>.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). */
@@ -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
@@ -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
@@ -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
@@ -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 }
+2
View File
@@ -25,6 +25,7 @@ export function loadCase(name: string): Snapshot {
timeConditions?: Partial<Snapshot['timeConditions'][number]>[];
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,
+2
View File
@@ -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<GeneratedFileName, string>;
const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => 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,
+11
View File
@@ -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<Snapshot>
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<Snapshot>
maxMembers: c.maxMembers,
record: c.record,
})),
mohClasses: mohRows.map((m) => m.name),
pagingGroups: pageRows.map((g) => ({
id: g.id,
number: g.number,
+20
View File
@@ -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');
}
+3
View File
@@ -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 {
+215
View File
@@ -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/<name>/ of <id>.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<MohClassDto> {
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<ListResponse<MohClassDto>> => {
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 };
});
}
+3
View File
@@ -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");
+1
View File
@@ -65,6 +65,7 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
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-'));
+1
View File
@@ -68,6 +68,7 @@ function snapshot(): Snapshot {
queues: [],
conferences: [],
pagingGroups: [],
mohClasses: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -157,6 +157,7 @@ function snapshot(): Snapshot {
})),
conferences: [],
pagingGroups: [],
mohClasses: [],
ivrs: [
{
id: CDR_IDS.ivr,
+17 -1
View File
@@ -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 });
}
}
+1
View File
@@ -107,6 +107,7 @@ function snapshot(): Snapshot {
queues: [],
conferences: [],
pagingGroups: [],
mohClasses: [],
ivrs: [],
timeConditions: [],
holidays: [],
+1
View File
@@ -63,6 +63,7 @@ function snapshot(): Snapshot {
queues: [],
conferences: [],
pagingGroups: [],
mohClasses: [],
ivrs: [],
timeConditions: [],
holidays: [],
+107
View File
@@ -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<ReturnType<typeof createTestApp>>;
let req: (
method: string,
url: string,
payload?: unknown,
headers?: Record<string, string>,
) => Promise<LightMyRequestResponse>;
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');
});
});
+16
View File
@@ -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<ListResponse<MohClassDto>>('GET', '/api/moh-classes').then((r) => r.items),
create: (name: string) => api<MohClassDto>('POST', '/api/moh-classes', { name }),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/moh-classes/${id}`),
addFile: (id: string, name: string, file: File) =>
upload<MohClassDto>(
'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<ListResponse<RingGroupDto>>('GET', '/api/ring-groups').then((r) => r.items),
create: (g: RingGroupCreate) => api<RingGroupDto>('POST', '/api/ring-groups', g),
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// <option>s for a music-on-hold <select>: the built-in default, then the classes made on Sounds &
// music. `current` is always offered, so a saved value shows before the list loads.
import { useQuery } from '@tanstack/react-query';
import { mohApi } from '../api/client.ts';
export function MohOptions({ current }: { current: string }) {
const classes = useQuery({ queryKey: ['moh-classes'], queryFn: mohApi.list });
const names = [...new Set(['default', current, ...(classes.data?.map((c) => c.name) ?? [])])];
return names.map((n) => (
<option key={n} value={n}>
{n === 'default' ? 'Default music' : n}
</option>
));
}
+3 -6
View File
@@ -13,6 +13,7 @@ import {
} from '@selfphoneng/shared';
import { extensionsApi, queuesApi } from '../../api/client.ts';
import { DestinationPicker } from '../../components/DestinationPicker.tsx';
import { MohOptions } from '../../components/MohOptions.tsx';
import { ConfirmDialog, Dialog } from '../../components/Dialog.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
@@ -513,11 +514,7 @@ function QueueForm({
Tell callers their place in line
</label>
{form.announcePosition && num('announceEverySeconds', 'Every (seconds)')}
<Field
label="Music on hold"
error={errors.mohClass}
hint="More music arrives with Sounds & music"
>
<Field label="Music on hold" error={errors.mohClass} hint="Add music on Sounds & music">
{(p) => (
<select
{...p}
@@ -525,7 +522,7 @@ function QueueForm({
value={form.mohClass}
onChange={(e) => set('mohClass')(e.target.value)}
>
<option value={form.mohClass}>{form.mohClass}</option>
<MohOptions current={queue?.mohClass ?? 'default'} />
</select>
)}
</Field>
+177 -2
View File
@@ -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)}
/>
<MusicOnHold />
<Announcements />
</section>
);
}
/** 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<string>();
const [deleting, setDeleting] = useState<MohClassDto | null>(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 (
<section aria-labelledby="moh-h" className="flex flex-col gap-3">
<h2 id="moh-h" className="text-lg font-semibold">
Music on hold
</h2>
<p className="text-sm text-zinc-500">
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.
</p>
<form
aria-label="New music on hold"
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
const parsed = MohClassName.safeParse(name.trim());
setNameError(parsed.success ? undefined : parsed.error.issues[0]?.message);
if (parsed.success) create.mutate(parsed.data);
}}
>
<Field label="New set" error={nameError} hint="Like sales-jazz">
{(p) => (
<input
{...p}
className={inputClass}
value={name}
onChange={(e) => setName(e.target.value)}
/>
)}
</Field>
<Button type="submit" disabled={!name.trim() || create.isPending}>
Add set
</Button>
</form>
{create.error && <Alert>{create.error.message}</Alert>}
{classes.isError && <Alert>{classes.error.message}</Alert>}
{classes.data?.map((c) => (
<MohClassCard key={c.id} cls={c} onChanged={refresh} onDelete={() => setDeleting(c)} />
))}
<ConfirmDialog
open={deleting !== null}
title="Delete music on hold"
message={<p>Delete "{deleting?.name}" and its music? 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 MohClassCard({
cls,
onChanged,
onDelete,
}: {
cls: MohClassDto;
onChanged: () => Promise<unknown>;
onDelete: () => void;
}) {
const [file, setFile] = useState<File | null>(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 (
<div
aria-label={`Music on hold ${cls.name}`}
role="group"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 font-medium">{cls.name}</span>
<Button variant="ghost" onClick={onDelete}>
Delete set
</Button>
</div>
{cls.files.length === 0 && (
<p className="text-sm text-zinc-500">No music yet: callers hear the default music.</p>
)}
<ul className="flex flex-col gap-1">
{cls.files.map((f) => (
<li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="min-w-0 flex-1">
{f.name} <span className="text-zinc-500">{secs(f.durationMs)}</span>
</span>
<audio
controls
preload="none"
src={mohApi.audioUrl(cls.id, f.id)}
aria-label={`Play ${f.name}`}
className="h-9 w-full sm:w-64"
/>
<Button
variant="ghost"
disabled={removeFile.isPending}
onClick={() => removeFile.mutate(f.id)}
>
Remove
</Button>
</li>
))}
</ul>
<form
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
if (file && !tooBig) add.mutate(file);
}}
>
<Field label={`Add music to ${cls.name}`}>
{(p) => (
<input
{...p}
type="file"
accept="audio/*"
className="text-sm"
onChange={(e) => {
add.reset();
setFile(e.target.files?.[0] ?? null);
}}
/>
)}
</Field>
<Button type="submit" disabled={!file || tooBig || add.isPending}>
{add.isPending ? 'Converting…' : 'Upload'}
</Button>
</form>
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
{add.error && <Alert>{add.error.message}</Alert>}
{removeFile.error && <Alert>{removeFile.error.message}</Alert>}
</div>
);
}
/** Announcements (03 §6.11): play one of these sounds, then send the caller on. */
function Announcements() {
const qc = useQueryClient();
+6 -1
View File
@@ -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) => <input {...a} {...form.register('language')} className={inputClass} />}
</Field>
<Field label="Music on hold" error={errors.mohClass?.message}>
{(a) => <input {...a} {...form.register('mohClass')} className={inputClass} />}
{(a) => (
<select {...a} {...form.register('mohClass')} className={inputClass}>
<MohOptions current={ext.mohClass} />
</select>
)}
</Field>
</div>
</form>