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/<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:
1 parent
e95271e694
commit
b8ff6e3386
34 files changed
+840
-21
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
@@ -68,6 +68,7 @@ function snapshot(): Snapshot {
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -157,6 +157,7 @@ function snapshot(): Snapshot {
|
||||
})),
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [
|
||||
{
|
||||
id: CDR_IDS.ivr,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ function snapshot(): Snapshot {
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -63,6 +63,7 @@ function snapshot(): Snapshot {
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -316,6 +316,8 @@ forced apply.
|
||||
- Duration: `ffprobe -v error -show_entries format=duration -of csv=p=0 <out>.wav`.
|
||||
- Prompts: `/var/lib/asterisk/sounds/custom/<id>.{wav,g722}` → `asterisk_name = custom/<id>`.
|
||||
- MOH: `/var/lib/asterisk/moh/<className>/<id>.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,...`) |
|
||||
|
||||
@@ -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<ext>` |
|
||||
| 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 |
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<typeof MohClassCreateSchema>;
|
||||
|
||||
export type MohClassDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
files: Omit<SoundDto, 'asteriskName'>[];
|
||||
createdAt: string;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// AMI published on 127.0.0.1:15038, the api writing its config into that container, two baresip
|
||||
// phones, and the dashboard checked in a headless browser.
|
||||
//
|
||||
// 1. node scripts/e2e/m2-live.mjs up <confDir> [<voicemailDir> [<soundsDir> [<recordingsDir>]]]
|
||||
// 1. node scripts/e2e/m2-live.mjs up <confDir> [<voicemailDir> [<soundsDir> [<recordingsDir> [<mohDir>]]]]
|
||||
// starts Asterisk; then start the api with AMI_PORT=15038 AMI_SECRET=m2-live-secret
|
||||
// ASTERISK_CONFIG_DIR=<confDir> [VOICEMAIL_DIR=<voicemailDir>] and run m1-t4.mjs to get
|
||||
// past the first login
|
||||
@@ -29,9 +29,9 @@ const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
|
||||
if (cmd === 'up') {
|
||||
const [conf, vm, 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);
|
||||
|
||||
@@ -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 <baseUrl> <adminEmail> <adminPassword> <shotsDir>
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { launch } from './cdp.mjs';
|
||||
|
||||
const AST = 'sp-live-ast';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const 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();
|
||||
}
|
||||
Reference in new issue
Block a user