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