wldx-admin-cli 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1059 @@
1
+ // wx-admin timetable | ivr | switch | paging | db: the routing objects that dialplans use, over the PBX REST API
2
+ // (tags Dialplan Time Table, Dialplan IVR, Dialplan switches, Dialplan Paging Groups, Dialplan DB), and the general
3
+ // dialplan settings (Dialplan General Settings), exported as `dialplanCommands` for the dialplan group.
4
+ //
5
+ // What the PBX does (seen live, WMS 7):
6
+ // - Each kind has only a list (GET /Dialplan/<Kind>/ → {records}); `get` reads the list. A write answers "Success",
7
+ // not the object: `create` reads the list again to give the new one with its id.
8
+ // - A PUT wants every field: a timetable without items is a 500, a switch or paging group without its title a 400, an
9
+ // IVR without type and parameters a 400, an IVR menu without a sound a 400 (although POST takes one). The commands
10
+ // read the object and write it back whole.
11
+ // - Titles are unique (400 "Already present"). The 400 names the field in `errors` as an object {field: {code,
12
+ // reason}}.
13
+ // - A switch is two-state (off 0, on 1) or three-state (isTreeType: also blinking 2), fixed when it is made.
14
+ // - Timetable import matches by title: a timetable of the same title is overwritten; ids in the file are ignored.
15
+ // - Dialplan DB: keys may hold "/" (family/a/b); deleting a missing key answers "Success"; deleting an empty family is
16
+ // a 500. The DB routes carry no Company API Key scope in the OpenAPI: `dialplan` is a guess.
17
+ import {readJsonInput as readJsonFile} from 'wldx-core/files';
18
+ import {pickByName} from '../match.mjs';
19
+ import {UsageError} from 'wldx-core/errors';
20
+ import {oneLine, paint, table} from 'wldx-core/format';
21
+ import {apiPath} from 'wldx-core/http';
22
+ import {pbxClient} from '../pbx.mjs';
23
+
24
+ const SCOPE = {scope: 'dialplan'};
25
+
26
+ // JSON from a file or stdin (-).
27
+ export async function readJsonInput(ctx, from) {
28
+ const data = await readJsonFile(ctx, from); // no credentials, nothing hidden in the home folder
29
+ return data;
30
+ }
31
+
32
+ // A whole number of an option, within [min, max].
33
+ export function wholeNumber(value, name, min = 0, max = Number.MAX_SAFE_INTEGER) {
34
+ const n = Number(value);
35
+ if (value === '' || !Number.isInteger(n) || n < min || n > max)
36
+ throw new UsageError(
37
+ `${name} is a whole number from ${min}${max < Number.MAX_SAFE_INTEGER ? ` to ${max}` : ''}: ${value}`,
38
+ );
39
+ return n;
40
+ }
41
+
42
+ // A record of a list by its id or its title (or `key`: name); exact title first, then without case.
43
+ export function findRecord(records, ref, noun, listCommand, key = 'title') {
44
+ const s = String(ref);
45
+ const byId = /^\d+$/.test(s) ? records.find((r) => String(r.id) === s) : undefined;
46
+ return byId ?? pickByName(records, s, (r) => r[key], noun, listCommand);
47
+ }
48
+
49
+ const recordsOf = async (client, base) => (await client.call('GET', base, undefined, SCOPE))?.records ?? [];
50
+
51
+ // After a POST that answers "Success": the new record, found by its title.
52
+ async function createdRecord(client, base, title) {
53
+ return (await recordsOf(client, base)).find((r) => r.title === title) ?? {title};
54
+ }
55
+
56
+ const needsYes = (ctx, why) => {
57
+ if (!ctx.values.yes) throw new UsageError(`${why}: confirm with --yes`, ctx.usage());
58
+ };
59
+
60
+ const TITLE = /^\S(.{0,98}\S)?$/;
61
+ const checkTitle = (t, noun) => {
62
+ if (typeof t !== 'string' || !TITLE.test(t))
63
+ throw new UsageError(`A ${noun} needs a title (1 to 100 characters, not blank): ${JSON.stringify(t)}`);
64
+ return t;
65
+ };
66
+
67
+ const titled = (r) => `${r.title}${r.id !== undefined ? ` (id ${r.id})` : ''}`;
68
+
69
+ // ---------------------------------------------------------------------------------------------------------------------
70
+ // Timetables: {id, title, state, items: [{from: {day, dayOfWeek}, to: {day, dayOfWeek}, time: {from, to}, month, year}]};
71
+ // 0 in a field means "any". The state is the PBX's code (0, 1 or 2; new ones get 0).
72
+
73
+ const TT = '/Dialplan/timeTables/';
74
+ const ttPath = (id) => apiPath`/Dialplan/timeTables/${id}/`;
75
+ const TIME = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
76
+
77
+ function checkTime(t, what) {
78
+ const m = TIME.exec(String(t ?? ''));
79
+ if (!m || Number(m[1]) > 23 || Number(m[2]) > 59 || Number(m[3] ?? 0) > 59)
80
+ throw new UsageError(`${what} is a time HH:MM or HH:MM:SS: ${t}`);
81
+ return `${m[1].padStart(2, '0')}:${m[2]}:${m[3] ?? '00'}`;
82
+ }
83
+
84
+ // A timetable item with every field (the PBX wants them all).
85
+ export function timetableItem(i) {
86
+ if (!i || typeof i !== 'object' || Array.isArray(i))
87
+ throw new UsageError('A timetable item is an object {from, to, time, month, year}');
88
+ const n = (v, what, max) => wholeNumber(v === undefined || v === null || v === '' ? 0 : v, what, 0, max);
89
+ return {
90
+ from: {day: n(i.from?.day, 'from.day', 31), dayOfWeek: n(i.from?.dayOfWeek, 'from.dayOfWeek', 7)},
91
+ to: {day: n(i.to?.day, 'to.day', 31), dayOfWeek: n(i.to?.dayOfWeek, 'to.dayOfWeek', 7)},
92
+ time: {
93
+ from: checkTime(i.time?.from ?? '00:00:00', 'time.from'),
94
+ to: checkTime(i.time?.to ?? '23:59:59', 'time.to'),
95
+ },
96
+ month: n(i.month, 'month', 12),
97
+ year: n(i.year, 'year', 9999),
98
+ };
99
+ }
100
+
101
+ // --item "weekdays=1-5 time=09:00-18:00 days=24-26 month=12 year=2026" or JSON.
102
+ function parseItem(spec) {
103
+ const s = String(spec).trim();
104
+ if (s.startsWith('{')) {
105
+ try {
106
+ return timetableItem(JSON.parse(s));
107
+ } catch (e) {
108
+ if (e instanceof UsageError) throw e;
109
+ throw new UsageError(`--item is not JSON: ${e.message}`);
110
+ }
111
+ }
112
+ const item = {from: {}, to: {}, time: {}};
113
+ for (const pair of s.split(/\s+/).filter(Boolean)) {
114
+ const at = pair.indexOf('=');
115
+ if (at < 1) throw new UsageError(`--item is key=value pairs (weekdays, days, time, month, year): ${pair}`);
116
+ const key = pair.slice(0, at);
117
+ const value = pair.slice(at + 1);
118
+ const [a, b = a] = key === 'time' ? value.split(/-(?=\d)/) : value.split('-');
119
+ if (key === 'weekdays') [item.from.dayOfWeek, item.to.dayOfWeek] = [a, b];
120
+ else if (key === 'days') [item.from.day, item.to.day] = [a, b];
121
+ else if (key === 'time') [item.time.from, item.time.to] = [a, b];
122
+ else if (key === 'month' || key === 'year') item[key] = value;
123
+ else throw new UsageError(`--item knows weekdays, days, time, month and year: ${key}`);
124
+ }
125
+ return timetableItem(item);
126
+ }
127
+
128
+ const span = (a, b) => (a === b ? `${a}` : `${a}-${b}`);
129
+ export function itemLine(i) {
130
+ const parts = [];
131
+ if (i.from?.dayOfWeek || i.to?.dayOfWeek) parts.push(`weekdays ${span(i.from.dayOfWeek, i.to.dayOfWeek)}`);
132
+ if (i.from?.day || i.to?.day) parts.push(`days ${span(i.from.day, i.to.day)}`);
133
+ if (i.month) parts.push(`month ${i.month}`);
134
+ if (i.year) parts.push(`year ${i.year}`);
135
+ if (!parts.length) parts.push('every day');
136
+ parts.push(`${i.time?.from ?? ''}-${i.time?.to ?? ''}`);
137
+ return parts.join(' ');
138
+ }
139
+
140
+ const STATE_CODES = ['0', '1', '2'];
141
+
142
+ const timetableLines = (t) => [
143
+ `${titled(t)} · state ${t.state}`,
144
+ ...(t.items?.length ? t.items.map((i) => ` ${itemLine(i)}`) : [' no items']),
145
+ ];
146
+
147
+ const ITEM_HELP = [
148
+ 'An --item is key=value pairs, 0 or missing meaning "any": weekdays=1-5 (day of the week 1 to 7), days=24-26 (day of the',
149
+ 'month), time=09:00-18:00, month=12, year=2026; or JSON {"from": {"day", "dayOfWeek"}, "to": {...}, "time": {"from",',
150
+ '"to"}, "month", "year"}.',
151
+ ].join('\n');
152
+
153
+ const timetable = {
154
+ summary: 'timetables (the hours a dialplan checks): list, show, create, change, delete, import',
155
+ commands: {
156
+ list: {
157
+ summary: 'the timetables of the PBX with their items',
158
+ usage: 'wx-admin timetable list',
159
+ options: {},
160
+ args: [0, 0],
161
+ async run(ctx) {
162
+ return (await pbxClient(ctx)).call('GET', TT, undefined, SCOPE);
163
+ },
164
+ text: (r) =>
165
+ r?.records?.length
166
+ ? table(
167
+ r.records.map((t) => [
168
+ t.id,
169
+ t.title,
170
+ `state ${t.state}`,
171
+ oneLine((t.items ?? []).map(itemLine).join('; '), 80),
172
+ ]),
173
+ )
174
+ : 'No timetables',
175
+ },
176
+ get: {
177
+ summary: 'one timetable and its items',
178
+ usage: 'wx-admin timetable get <timetable>',
179
+ details: 'A timetable is its id or its title.',
180
+ options: {},
181
+ args: [1, 1],
182
+ async run(ctx) {
183
+ return findRecord(
184
+ await recordsOf(await pbxClient(ctx), TT),
185
+ ctx.positionals[0],
186
+ 'timetable',
187
+ 'wx-admin timetable list',
188
+ );
189
+ },
190
+ text: timetableLines,
191
+ },
192
+ create: {
193
+ summary: 'create a timetable (without items it covers every day, all day)',
194
+ usage: 'wx-admin timetable create <title> [--state 0|1|2] [--item SPEC]... [--from FILE|-]',
195
+ details: `${ITEM_HELP}\n--from reads {"state", "items"} as JSON (the title is the argument).`,
196
+ options: {
197
+ state: {
198
+ type: 'string',
199
+ choices: STATE_CODES,
200
+ description: 'the state code of the PBX (default 0, what WMS gives new ones)',
201
+ },
202
+ item: {type: 'string', multiple: true, placeholder: 'SPEC', description: 'a period of the timetable'},
203
+ from: {
204
+ type: 'string',
205
+ placeholder: 'FILE',
206
+ description: 'the state and items as JSON (a file, or - for stdin)',
207
+ },
208
+ },
209
+ args: [1, 1],
210
+ async run(ctx) {
211
+ const title = checkTitle(ctx.positionals[0], 'timetable');
212
+ const src = ctx.values.from ? await readJsonInput(ctx, ctx.values.from) : {};
213
+ const items = ctx.values.item?.length
214
+ ? ctx.values.item.map(parseItem)
215
+ : Array.isArray(src.items)
216
+ ? src.items.map(timetableItem)
217
+ : undefined;
218
+ const body = {title, state: Number(ctx.values.state ?? src.state ?? 0)};
219
+ if (!STATE_CODES.includes(String(body.state))) throw new UsageError(`The state is 0, 1 or 2: ${src.state}`);
220
+ if (items?.length) body.items = items;
221
+ const client = await pbxClient(ctx);
222
+ await client.call('POST', TT, body, SCOPE);
223
+ return createdRecord(client, TT, title);
224
+ },
225
+ text: (t) => [`Created timetable ${titled(t)}`, ...timetableLines(t).slice(1)],
226
+ },
227
+ update: {
228
+ summary: 'change a timetable: title, state, items (--item replaces them all, --add-item adds)',
229
+ usage:
230
+ 'wx-admin timetable update <timetable> [--title TEXT] [--state 0|1|2] [--item SPEC]... [--add-item SPEC]... [--from FILE|-]',
231
+ details: `${ITEM_HELP}\n--from reads {"title", "state", "items"} as JSON; fields missing from it are kept.`,
232
+ options: {
233
+ title: {type: 'string', placeholder: 'TEXT', description: 'the new title'},
234
+ state: {type: 'string', choices: STATE_CODES, description: 'the new state code'},
235
+ item: {
236
+ type: 'string',
237
+ multiple: true,
238
+ placeholder: 'SPEC',
239
+ description: 'the periods (they replace all the items)',
240
+ },
241
+ 'add-item': {type: 'string', multiple: true, placeholder: 'SPEC', description: 'a period to add to the items'},
242
+ from: {
243
+ type: 'string',
244
+ placeholder: 'FILE',
245
+ description: 'the fields to change as JSON (a file, or - for stdin)',
246
+ },
247
+ },
248
+ args: [1, 1],
249
+ async run(ctx) {
250
+ const v = ctx.values;
251
+ if (v.title === undefined && v.state === undefined && !v.item?.length && !v['add-item']?.length && !v.from) {
252
+ throw new UsageError('Nothing to change: give --title, --state, --item, --add-item or --from', ctx.usage());
253
+ }
254
+ const client = await pbxClient(ctx);
255
+ const current = findRecord(
256
+ await recordsOf(client, TT),
257
+ ctx.positionals[0],
258
+ 'timetable',
259
+ 'wx-admin timetable list',
260
+ );
261
+ const src = v.from ? await readJsonInput(ctx, v.from) : {};
262
+ const body = {
263
+ title: checkTitle(v.title ?? src.title ?? current.title, 'timetable'),
264
+ state: Number(v.state ?? src.state ?? current.state ?? 0),
265
+ items: (v.item?.length
266
+ ? v.item.map(parseItem)
267
+ : Array.isArray(src.items)
268
+ ? src.items
269
+ : (current.items ?? [])
270
+ ).map(timetableItem),
271
+ };
272
+ if (!STATE_CODES.includes(String(body.state))) throw new UsageError(`The state is 0, 1 or 2: ${body.state}`);
273
+ body.items.push(...(v['add-item'] ?? []).map(parseItem));
274
+ if (!body.items.length) throw new UsageError('A timetable needs at least one item (the PBX refuses none)');
275
+ await client.call('PUT', ttPath(current.id), body, SCOPE);
276
+ return {id: current.id, ...body};
277
+ },
278
+ text: (t) => [`Updated timetable ${titled(t)}`, ...timetableLines(t).slice(1)],
279
+ },
280
+ delete: {
281
+ summary: 'delete timetables (needs --yes: rules that check them stop matching)',
282
+ usage: 'wx-admin timetable delete <timetable>... --yes',
283
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
284
+ args: [1, 50],
285
+ async run(ctx) {
286
+ needsYes(ctx, 'Deleting a timetable changes how the dialplans that check it route calls');
287
+ const client = await pbxClient(ctx);
288
+ const records = await recordsOf(client, TT);
289
+ const found = ctx.positionals.map((ref) => findRecord(records, ref, 'timetable', 'wx-admin timetable list'));
290
+ for (const t of found) await client.call('DELETE', ttPath(t.id), undefined, SCOPE);
291
+ return {deleted: found.map(({id, title}) => ({id, title}))};
292
+ },
293
+ text: (r) => `Deleted ${r.deleted.length === 1 ? 'timetable' : 'timetables'} ${r.deleted.map(titled).join(', ')}`,
294
+ },
295
+ import: {
296
+ summary:
297
+ 'create timetables from JSON (the output of timetable list or get); --replace overwrites those of the same title',
298
+ usage: 'wx-admin timetable import --from FILE|- [--replace]',
299
+ details: 'The JSON is {"records": [...]}, a list, or one timetable. The PBX matches by title and ignores ids.',
300
+ options: {
301
+ from: {type: 'string', placeholder: 'FILE', description: 'the timetables as JSON (a file, or - for stdin)'},
302
+ replace: {type: 'boolean', description: 'overwrite the timetables of the same title'},
303
+ },
304
+ args: [0, 0],
305
+ async run(ctx) {
306
+ if (!ctx.values.from) throw new UsageError('--from is needed', ctx.usage());
307
+ const src = await readJsonInput(ctx, ctx.values.from);
308
+ const list = Array.isArray(src) ? src : Array.isArray(src?.records) ? src.records : [src];
309
+ if (!list.length) throw new UsageError('No timetables in the JSON');
310
+ const records = list.map((t) => {
311
+ const state = Number(t?.state ?? 0);
312
+ if (!STATE_CODES.includes(String(state))) throw new UsageError(`The state is 0, 1 or 2: ${t?.state}`);
313
+ const items = (Array.isArray(t?.items) ? t.items : []).map(timetableItem);
314
+ if (!items.length) items.push(timetableItem({}));
315
+ return {title: checkTitle(t?.title, 'timetable'), state, items};
316
+ });
317
+ const titles = records.map((r) => r.title);
318
+ if (new Set(titles).size !== titles.length) throw new UsageError('The JSON names a title twice');
319
+ const client = await pbxClient(ctx);
320
+ const existing = new Set((await recordsOf(client, TT)).map((t) => t.title));
321
+ const replaced = titles.filter((t) => existing.has(t));
322
+ if (replaced.length && !ctx.values.replace)
323
+ throw new UsageError(`Timetables of these titles exist: ${replaced.join(', ')}. --replace overwrites them`);
324
+ await client.call('PUT', `${TT}import/`, {records}, SCOPE);
325
+ return {created: titles.filter((t) => !existing.has(t)), replaced};
326
+ },
327
+ text: (r) =>
328
+ [
329
+ r.created.length ? `Created ${r.created.join(', ')}` : '',
330
+ r.replaced.length ? `Replaced ${r.replaced.join(', ')}` : '',
331
+ ].filter(Boolean),
332
+ },
333
+ },
334
+ };
335
+
336
+ // ---------------------------------------------------------------------------------------------------------------------
337
+ // IVR: a tree {id, title, type: 'menu', parameters: {sound, times, timeout, backcode, context, exten}, items: [node]};
338
+ // a node is a menu (with its own parameters and items) or an item {number, type: 'item', parameters: {context, exten}}.
339
+ // number is the key pressed; context/exten of a menu are where the call goes when no key is pressed.
340
+
341
+ const IVR = '/Dialplan/Ivr/';
342
+ const ivrPath = (id) => apiPath`/Dialplan/Ivr/${id}/`;
343
+ const KEYS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'];
344
+ const DIALPLAN_NAME = /^[A-Za-z0-9_-]{1,64}$/;
345
+
346
+ // "dialplan/number" → {context, exten}.
347
+ function destination(spec, what) {
348
+ const s = String(spec ?? '');
349
+ const at = s.indexOf('/');
350
+ const context = at < 0 ? s : s.slice(0, at);
351
+ const exten = at < 0 ? '' : s.slice(at + 1);
352
+ if (!DIALPLAN_NAME.test(context) || !/^\S{1,128}$/.test(exten))
353
+ throw new UsageError(`${what} is DIALPLAN/NUMBER (a dialplan and the number to dial in it): ${s}`);
354
+ return {context, exten};
355
+ }
356
+
357
+ function menuParams(p, where) {
358
+ const params = p && typeof p === 'object' && !Array.isArray(p) ? p : {};
359
+ if (!params.sound) throw new UsageError(`${where} needs a sound (the PBX refuses a menu without one)`);
360
+ const backcode = String(params.backcode ?? '');
361
+ if (backcode && !KEYS.includes(backcode)) throw new UsageError(`${where}: the back key is one of ${KEYS.join(' ')}`);
362
+ return {
363
+ ...params,
364
+ sound: String(params.sound),
365
+ times: wholeNumber(params.times ?? 1, `${where}: times`, 1, 100),
366
+ timeout: wholeNumber(params.timeout ?? 5, `${where}: timeout`, 0, 3600),
367
+ backcode,
368
+ context: String(params.context ?? ''),
369
+ exten: String(params.exten ?? ''),
370
+ };
371
+ }
372
+
373
+ // A node with every field, its keys checked (one of 0-9 * #, once per menu).
374
+ function ivrNode(node, where) {
375
+ if (!node || typeof node !== 'object' || Array.isArray(node)) throw new UsageError(`${where} is not an object`);
376
+ const number = String(node.number ?? '');
377
+ if (!KEYS.includes(number))
378
+ throw new UsageError(`${where}: the key is one of ${KEYS.join(' ')}: ${JSON.stringify(node.number)}`);
379
+ if (node.type === 'menu')
380
+ return {
381
+ number,
382
+ type: 'menu',
383
+ parameters: menuParams(node.parameters, `${where} (menu)`),
384
+ items: ivrItems(node.items, `${where} → `),
385
+ };
386
+ if (node.type !== 'item' && node.type !== undefined)
387
+ throw new UsageError(`${where}: the type is menu or item: ${node.type}`);
388
+ const p = node.parameters ?? {};
389
+ return {number, type: 'item', parameters: {...p, context: String(p.context ?? ''), exten: String(p.exten ?? '')}};
390
+ }
391
+
392
+ function ivrItems(items, prefix) {
393
+ const list = (Array.isArray(items) ? items : []).map((n, i) =>
394
+ ivrNode(n, `${prefix}${n?.number ?? `item ${i + 1}`}`),
395
+ );
396
+ const keys = list.map((n) => n.number);
397
+ const twice = keys.find((k, i) => keys.indexOf(k) !== i);
398
+ if (twice !== undefined)
399
+ throw new UsageError(`The key ${twice} is used twice in one menu${prefix ? ` (${prefix.slice(0, -3)})` : ''}`);
400
+ return list;
401
+ }
402
+
403
+ const ivrBody = (title, parameters, items) => ({
404
+ title: checkTitle(title, 'IVR'),
405
+ type: 'menu',
406
+ parameters: menuParams(parameters, 'The IVR'),
407
+ items: ivrItems(items, ''),
408
+ });
409
+
410
+ const target = (p) => (p?.context || p?.exten ? `${p.context}/${p.exten}` : '');
411
+ const menuLine = (p) =>
412
+ `menu: sound ${p?.sound || '(none)'}, played ${p?.times}×, waits ${p?.timeout} s${target(p) ? `, then ${target(p)}` : ''}${p?.backcode ? `, back key ${p.backcode}` : ''}`;
413
+ const nodeLines = (items, indent) =>
414
+ (items ?? []).flatMap((n) =>
415
+ n.type === 'menu'
416
+ ? [`${indent}${n.number} → ${menuLine(n.parameters)}`, ...nodeLines(n.items, `${indent} `)]
417
+ : [`${indent}${n.number} → ${target(n.parameters) || '(nowhere)'}`],
418
+ );
419
+ const ivrLines = (r) => [titled(r), menuLine(r.parameters), ...nodeLines(r.items, ' ')];
420
+
421
+ // --key 1=users/100 → an item; it replaces the item or menu of that key.
422
+ function applyKeys(items, specs = []) {
423
+ const out = [...items];
424
+ for (const spec of specs) {
425
+ const at = String(spec).indexOf('=');
426
+ const number = String(spec).slice(0, at);
427
+ if (at < 1 || !KEYS.includes(number))
428
+ throw new UsageError(`--key is K=DIALPLAN/NUMBER, K one of ${KEYS.join(' ')}: ${spec}`);
429
+ const node = {number, type: 'item', parameters: destination(spec.slice(at + 1), `--key ${number}`)};
430
+ const i = out.findIndex((n) => String(n.number) === number);
431
+ if (i >= 0) out.splice(i, 1, node);
432
+ else out.push(node);
433
+ }
434
+ return out;
435
+ }
436
+
437
+ const IVR_OPTIONS = {
438
+ sound: {
439
+ type: 'string',
440
+ placeholder: 'PATH',
441
+ description: 'the sound file of the menu (as WMS names it, e.g. 00000/RingTones/piano2)',
442
+ },
443
+ times: {type: 'string', placeholder: 'N', description: 'how many times the sound is played (default 1)'},
444
+ timeout: {
445
+ type: 'string',
446
+ placeholder: 'SECONDS',
447
+ description: 'how long it waits for a key after the sound (default 5)',
448
+ },
449
+ 'timeout-to': {
450
+ type: 'string',
451
+ placeholder: 'DIALPLAN/NUMBER',
452
+ description: 'where the call goes when no key is pressed',
453
+ },
454
+ 'back-key': {type: 'string', choices: KEYS, description: 'the key that goes back to the previous menu'},
455
+ key: {
456
+ type: 'string',
457
+ multiple: true,
458
+ placeholder: 'K=DIALPLAN/NUMBER',
459
+ description: 'a key and where it sends the call',
460
+ },
461
+ };
462
+
463
+ function menuFromOptions(v, base) {
464
+ const p = {...base};
465
+ if (v.sound !== undefined) p.sound = v.sound;
466
+ if (v.times !== undefined) p.times = wholeNumber(v.times, '--times', 1, 100);
467
+ if (v.timeout !== undefined) p.timeout = wholeNumber(v.timeout, '--timeout', 0, 3600);
468
+ if (v['timeout-to'] !== undefined) Object.assign(p, destination(v['timeout-to'], '--timeout-to'));
469
+ if (v['back-key'] !== undefined) p.backcode = v['back-key'];
470
+ return p;
471
+ }
472
+
473
+ const IVR_DETAILS = [
474
+ '--key 1=users/100 sends the key 1 to the number 100 of the dialplan users. Submenus: --from a JSON tree',
475
+ '{"parameters": {...}, "items": [{"number": "2", "type": "menu", "parameters": {"sound", "times", "timeout"}, "items": [...]}]}.',
476
+ ].join('\n');
477
+
478
+ const ivr = {
479
+ summary: 'IVR menus (voice menus a dialplan sends calls to): list, show, create, change, delete',
480
+ commands: {
481
+ list: {
482
+ summary: 'the IVR menus of the PBX',
483
+ usage: 'wx-admin ivr list',
484
+ options: {},
485
+ args: [0, 0],
486
+ async run(ctx) {
487
+ return (await pbxClient(ctx)).call('GET', IVR, undefined, SCOPE);
488
+ },
489
+ text: (r) =>
490
+ r?.records?.length
491
+ ? table(
492
+ r.records.map((m) => [
493
+ m.id,
494
+ m.title,
495
+ m.parameters?.sound ?? '',
496
+ `keys ${(m.items ?? []).map((n) => n.number).join(' ') || '-'}`,
497
+ ]),
498
+ )
499
+ : 'No IVR menus',
500
+ },
501
+ get: {
502
+ summary: 'one IVR menu as a tree: keys and where they send the call',
503
+ usage: 'wx-admin ivr get <ivr>',
504
+ details: 'An IVR is its id or its title.',
505
+ options: {},
506
+ args: [1, 1],
507
+ async run(ctx) {
508
+ return findRecord(await recordsOf(await pbxClient(ctx), IVR), ctx.positionals[0], 'IVR', 'wx-admin ivr list');
509
+ },
510
+ text: ivrLines,
511
+ },
512
+ create: {
513
+ summary: 'create an IVR menu',
514
+ usage:
515
+ 'wx-admin ivr create <title> --sound PATH [--times N] [--timeout SECONDS] [--timeout-to DIALPLAN/NUMBER] [--back-key K] [--key K=DIALPLAN/NUMBER]... [--from FILE|-]',
516
+ details: IVR_DETAILS,
517
+ options: {
518
+ ...IVR_OPTIONS,
519
+ from: {
520
+ type: 'string',
521
+ placeholder: 'FILE',
522
+ description: 'the menu as JSON {parameters, items} (a file, or - for stdin)',
523
+ },
524
+ },
525
+ args: [1, 1],
526
+ async run(ctx) {
527
+ const title = ctx.positionals[0];
528
+ const src = ctx.values.from ? await readJsonInput(ctx, ctx.values.from) : {};
529
+ const body = ivrBody(
530
+ title,
531
+ menuFromOptions(ctx.values, src.parameters),
532
+ applyKeys(src.items ?? [], ctx.values.key),
533
+ );
534
+ const client = await pbxClient(ctx);
535
+ await client.call('POST', IVR, body, SCOPE);
536
+ return createdRecord(client, IVR, title);
537
+ },
538
+ text: (r) => [`Created IVR ${titled(r)}`, ...(r.parameters ? ivrLines(r).slice(1) : [])],
539
+ },
540
+ update: {
541
+ summary: 'change an IVR menu: title, sound, timeout, keys (--key sets one, --remove-key drops one)',
542
+ usage:
543
+ 'wx-admin ivr update <ivr> [--title TEXT] [--sound PATH] [--times N] [--timeout SECONDS] [--timeout-to DIALPLAN/NUMBER] [--back-key K] [--key K=DIALPLAN/NUMBER]... [--remove-key K]... [--from FILE|-]',
544
+ details: `${IVR_DETAILS}\n--from replaces the parameters and the items it holds; the rest is kept.`,
545
+ options: {
546
+ title: {type: 'string', placeholder: 'TEXT', description: 'the new title'},
547
+ ...IVR_OPTIONS,
548
+ 'remove-key': {type: 'string', multiple: true, choices: KEYS, description: 'a key to remove'},
549
+ from: {
550
+ type: 'string',
551
+ placeholder: 'FILE',
552
+ description: 'the menu as JSON {title, parameters, items} (a file, or - for stdin)',
553
+ },
554
+ },
555
+ args: [1, 1],
556
+ async run(ctx) {
557
+ const v = ctx.values;
558
+ const changes =
559
+ ['title', 'sound', 'times', 'timeout', 'timeout-to', 'back-key', 'from'].some((k) => v[k] !== undefined) ||
560
+ v.key?.length ||
561
+ v['remove-key']?.length;
562
+ if (!changes) throw new UsageError('Nothing to change', ctx.usage());
563
+ const client = await pbxClient(ctx);
564
+ const current = findRecord(await recordsOf(client, IVR), ctx.positionals[0], 'IVR', 'wx-admin ivr list');
565
+ const src = v.from ? await readJsonInput(ctx, v.from) : {};
566
+ let items = applyKeys(Array.isArray(src.items) ? src.items : (current.items ?? []), v.key);
567
+ for (const k of v['remove-key'] ?? []) {
568
+ if (!items.some((n) => String(n.number) === k))
569
+ throw new UsageError(`The IVR ${current.title} has no key ${k}`);
570
+ items = items.filter((n) => String(n.number) !== k);
571
+ }
572
+ const body = ivrBody(
573
+ v.title ?? src.title ?? current.title,
574
+ menuFromOptions(v, src.parameters ?? current.parameters),
575
+ items,
576
+ );
577
+ await client.call('PUT', ivrPath(current.id), body, SCOPE);
578
+ return {id: current.id, ...body};
579
+ },
580
+ text: (r) => [`Updated IVR ${titled(r)}`, ...ivrLines(r).slice(1)],
581
+ },
582
+ delete: {
583
+ summary: 'delete IVR menus (needs --yes: calls sent to them stop working)',
584
+ usage: 'wx-admin ivr delete <ivr>... --yes',
585
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
586
+ args: [1, 50],
587
+ async run(ctx) {
588
+ needsYes(ctx, 'Deleting an IVR stops the calls the dialplans send to it');
589
+ const client = await pbxClient(ctx);
590
+ const records = await recordsOf(client, IVR);
591
+ const found = ctx.positionals.map((ref) => findRecord(records, ref, 'IVR', 'wx-admin ivr list'));
592
+ for (const m of found) await client.call('DELETE', ivrPath(m.id), undefined, SCOPE);
593
+ return {deleted: found.map(({id, title}) => ({id, title}))};
594
+ },
595
+ text: (r) => `Deleted IVR ${r.deleted.map(titled).join(', ')}`,
596
+ },
597
+ },
598
+ };
599
+
600
+ // ---------------------------------------------------------------------------------------------------------------------
601
+ // Switches: {id, title, state, isTreeType}; state 0 off, 1 on, 2 blinking (three-state switches only). A dialplan
602
+ // branches on them (GotoIfSwitchIsOn, GotoIf3SwitchIsOnOrBlinking): changing the state changes how calls are routed.
603
+
604
+ const SW = '/Dialplan/Switches/';
605
+ const swPath = (id) => apiPath`/Dialplan/Switches/${id}/`;
606
+ const STATES = {off: 0, on: 1, blinking: 2};
607
+ const stateName = (n) => Object.keys(STATES).find((k) => STATES[k] === Number(n)) ?? String(n);
608
+ const switchLine = (s) => `${titled(s)} · ${stateName(s.state)} · ${s.isTreeType ? 'three-state' : 'two-state'}`;
609
+
610
+ async function setSwitch(ctx, state) {
611
+ needsYes(ctx, 'Changing a switch changes how the dialplans that check it route calls');
612
+ const client = await pbxClient(ctx);
613
+ const current = findRecord(await recordsOf(client, SW), ctx.positionals[0], 'switch', 'wx-admin switch list');
614
+ if (state === STATES.blinking && !current.isTreeType)
615
+ throw new UsageError(`The switch ${current.title} is two-state: it cannot blink`);
616
+ await client.call('PUT', swPath(current.id), {title: current.title, state}, SCOPE);
617
+ return {...current, state, previous: current.state};
618
+ }
619
+
620
+ const toggle = (name, state) => ({
621
+ summary: `set a switch ${name} (needs --yes: calls are routed by it)`,
622
+ usage: `wx-admin switch ${name === 'blinking' ? 'blink' : name} <switch> --yes`,
623
+ options: {yes: {type: 'boolean', description: `yes, set it ${name}`}},
624
+ args: [1, 1],
625
+ run: (ctx) => setSwitch(ctx, state),
626
+ text: (s) => `Switch ${titled(s)}: ${stateName(s.previous)} → ${stateName(s.state)}`,
627
+ });
628
+
629
+ const switchCommands = {
630
+ summary: 'switches (on/off flags dialplans branch on): list, show, create, rename, on, off, blink, delete',
631
+ commands: {
632
+ list: {
633
+ summary: 'the switches of the PBX and their state',
634
+ usage: 'wx-admin switch list',
635
+ options: {},
636
+ args: [0, 0],
637
+ async run(ctx) {
638
+ return (await pbxClient(ctx)).call('GET', SW, undefined, SCOPE);
639
+ },
640
+ text: (r, ctx) => {
641
+ if (!r?.records?.length) return 'No switches';
642
+ const c = paint(ctx);
643
+ return table(
644
+ r.records.map((s) => [
645
+ s.id,
646
+ s.title,
647
+ s.state === STATES.on ? c.green('on') : s.state === STATES.blinking ? c.yellow('blinking') : 'off',
648
+ s.isTreeType ? 'three-state' : 'two-state',
649
+ ]),
650
+ );
651
+ },
652
+ },
653
+ get: {
654
+ summary: 'one switch and its state',
655
+ usage: 'wx-admin switch get <switch>',
656
+ details: 'A switch is its id or its title.',
657
+ options: {},
658
+ args: [1, 1],
659
+ async run(ctx) {
660
+ return findRecord(
661
+ await recordsOf(await pbxClient(ctx), SW),
662
+ ctx.positionals[0],
663
+ 'switch',
664
+ 'wx-admin switch list',
665
+ );
666
+ },
667
+ text: switchLine,
668
+ },
669
+ create: {
670
+ summary: 'create a switch (off unless --state); --three-state lets it blink too (fixed once made)',
671
+ usage: 'wx-admin switch create <title> [--three-state] [--state off|on|blinking]',
672
+ options: {
673
+ 'three-state': {type: 'boolean', description: 'a switch with a third state, blinking'},
674
+ state: {type: 'string', choices: Object.keys(STATES), description: 'its first state (default off)'},
675
+ },
676
+ args: [1, 1],
677
+ async run(ctx) {
678
+ const title = checkTitle(ctx.positionals[0], 'switch');
679
+ const state = STATES[ctx.values.state ?? 'off'];
680
+ const isTreeType = Boolean(ctx.values['three-state']);
681
+ if (state === STATES.blinking && !isTreeType) throw new UsageError('Only a --three-state switch can blink');
682
+ const client = await pbxClient(ctx);
683
+ await client.call('POST', SW, {title, state, isTreeType}, SCOPE);
684
+ return createdRecord(client, SW, title);
685
+ },
686
+ text: (s) => `Created switch ${s.state === undefined ? titled(s) : switchLine(s)}`,
687
+ },
688
+ update: {
689
+ summary: 'rename a switch, or set its state (a state change needs --yes)',
690
+ usage: 'wx-admin switch update <switch> [--title TEXT] [--state off|on|blinking [--yes]]',
691
+ options: {
692
+ title: {type: 'string', placeholder: 'TEXT', description: 'the new title'},
693
+ state: {type: 'string', choices: Object.keys(STATES), description: 'the new state (needs --yes)'},
694
+ yes: {type: 'boolean', description: 'yes, change the state'},
695
+ },
696
+ args: [1, 1],
697
+ async run(ctx) {
698
+ const v = ctx.values;
699
+ if (v.title === undefined && v.state === undefined)
700
+ throw new UsageError('Nothing to change: give --title or --state', ctx.usage());
701
+ const client = await pbxClient(ctx);
702
+ const current = findRecord(await recordsOf(client, SW), ctx.positionals[0], 'switch', 'wx-admin switch list');
703
+ const state = v.state === undefined ? current.state : STATES[v.state];
704
+ if (state !== current.state)
705
+ needsYes(ctx, 'Changing a switch changes how the dialplans that check it route calls');
706
+ if (state === STATES.blinking && !current.isTreeType)
707
+ throw new UsageError(`The switch ${current.title} is two-state: it cannot blink`);
708
+ const body = {title: checkTitle(v.title ?? current.title, 'switch'), state};
709
+ await client.call('PUT', swPath(current.id), body, SCOPE);
710
+ return {...current, ...body};
711
+ },
712
+ text: (s) => `Updated switch ${switchLine(s)}`,
713
+ },
714
+ on: toggle('on', STATES.on),
715
+ off: toggle('off', STATES.off),
716
+ blink: toggle('blinking', STATES.blinking),
717
+ delete: {
718
+ summary: 'delete switches (needs --yes: rules that check them change)',
719
+ usage: 'wx-admin switch delete <switch>... --yes',
720
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
721
+ args: [1, 50],
722
+ async run(ctx) {
723
+ needsYes(ctx, 'Deleting a switch changes how the dialplans that check it route calls');
724
+ const client = await pbxClient(ctx);
725
+ const records = await recordsOf(client, SW);
726
+ const found = ctx.positionals.map((ref) => findRecord(records, ref, 'switch', 'wx-admin switch list'));
727
+ for (const s of found) await client.call('DELETE', swPath(s.id), undefined, SCOPE);
728
+ return {deleted: found.map(({id, title}) => ({id, title}))};
729
+ },
730
+ text: (r) => `Deleted ${r.deleted.length === 1 ? 'switch' : 'switches'} ${r.deleted.map(titled).join(', ')}`,
731
+ },
732
+ },
733
+ };
734
+
735
+ // ---------------------------------------------------------------------------------------------------------------------
736
+ // Paging groups: {id, title, members: [extension]}; the PBX does not check that the extensions exist.
737
+
738
+ const PG = '/Dialplan/PagingGroups/';
739
+ const pgPath = (id) => apiPath`/Dialplan/PagingGroups/${id}/`;
740
+ const EXTENSION = /^[0-9A-Za-z_*#+.-]{1,64}$/;
741
+ const checkMembers = (list) => {
742
+ for (const m of list) if (!EXTENSION.test(String(m))) throw new UsageError(`Not an extension: ${m}`);
743
+ return [...new Set(list.map(String))];
744
+ };
745
+ const pagingLine = (g) => `${titled(g)} · ${g.members?.length ? g.members.join(', ') : 'no members'}`;
746
+
747
+ const paging = {
748
+ summary: 'paging groups (the phones a page reaches): list, show, create, change, delete',
749
+ commands: {
750
+ list: {
751
+ summary: 'the paging groups of the PBX and their members',
752
+ usage: 'wx-admin paging list',
753
+ options: {},
754
+ args: [0, 0],
755
+ async run(ctx) {
756
+ return (await pbxClient(ctx)).call('GET', PG, undefined, SCOPE);
757
+ },
758
+ text: (r) =>
759
+ r?.records?.length
760
+ ? table(r.records.map((g) => [g.id, g.title, oneLine((g.members ?? []).join(', '), 80)]))
761
+ : 'No paging groups',
762
+ },
763
+ get: {
764
+ summary: 'one paging group and its members',
765
+ usage: 'wx-admin paging get <group>',
766
+ details: 'A group is its id or its title.',
767
+ options: {},
768
+ args: [1, 1],
769
+ async run(ctx) {
770
+ return findRecord(
771
+ await recordsOf(await pbxClient(ctx), PG),
772
+ ctx.positionals[0],
773
+ 'paging group',
774
+ 'wx-admin paging list',
775
+ );
776
+ },
777
+ text: pagingLine,
778
+ },
779
+ create: {
780
+ summary: 'create a paging group',
781
+ usage: 'wx-admin paging create <title> [--member EXTENSION]...',
782
+ options: {
783
+ member: {
784
+ type: 'string',
785
+ multiple: true,
786
+ placeholder: 'EXTENSION',
787
+ description: 'an extension the page reaches',
788
+ },
789
+ },
790
+ args: [1, 1],
791
+ async run(ctx) {
792
+ const title = checkTitle(ctx.positionals[0], 'paging group');
793
+ const client = await pbxClient(ctx);
794
+ await client.call('POST', PG, {title, members: checkMembers(ctx.values.member ?? [])}, SCOPE);
795
+ return createdRecord(client, PG, title);
796
+ },
797
+ text: (g) => `Created paging group ${pagingLine(g)}`,
798
+ },
799
+ update: {
800
+ summary: 'change a paging group: title, members (--member replaces them, --add and --remove change them)',
801
+ usage:
802
+ 'wx-admin paging update <group> [--title TEXT] [--member EXTENSION]... [--add EXTENSION]... [--remove EXTENSION]...',
803
+ options: {
804
+ title: {type: 'string', placeholder: 'TEXT', description: 'the new title'},
805
+ member: {
806
+ type: 'string',
807
+ multiple: true,
808
+ placeholder: 'EXTENSION',
809
+ description: 'the members (they replace all of them)',
810
+ },
811
+ add: {type: 'string', multiple: true, placeholder: 'EXTENSION', description: 'an extension to add'},
812
+ remove: {type: 'string', multiple: true, placeholder: 'EXTENSION', description: 'an extension to remove'},
813
+ },
814
+ args: [1, 1],
815
+ async run(ctx) {
816
+ const v = ctx.values;
817
+ if (v.title === undefined && !v.member?.length && !v.add?.length && !v.remove?.length)
818
+ throw new UsageError('Nothing to change', ctx.usage());
819
+ const client = await pbxClient(ctx);
820
+ const current = findRecord(
821
+ await recordsOf(client, PG),
822
+ ctx.positionals[0],
823
+ 'paging group',
824
+ 'wx-admin paging list',
825
+ );
826
+ let members = v.member?.length ? v.member : [...(current.members ?? []), ...(v.add ?? [])];
827
+ for (const m of v.remove ?? []) {
828
+ if (!members.map(String).includes(m)) throw new UsageError(`${m} is not a member of ${current.title}`);
829
+ members = members.filter((x) => String(x) !== m);
830
+ }
831
+ const body = {title: checkTitle(v.title ?? current.title, 'paging group'), members: checkMembers(members)};
832
+ await client.call('PUT', pgPath(current.id), body, SCOPE);
833
+ return {id: current.id, ...body};
834
+ },
835
+ text: (g) => `Updated paging group ${pagingLine(g)}`,
836
+ },
837
+ delete: {
838
+ summary: 'delete paging groups (needs --yes)',
839
+ usage: 'wx-admin paging delete <group>... --yes',
840
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
841
+ args: [1, 50],
842
+ async run(ctx) {
843
+ needsYes(ctx, 'Deleting a paging group stops the pages the dialplans send to it');
844
+ const client = await pbxClient(ctx);
845
+ const records = await recordsOf(client, PG);
846
+ const found = ctx.positionals.map((ref) => findRecord(records, ref, 'paging group', 'wx-admin paging list'));
847
+ for (const g of found) await client.call('DELETE', pgPath(g.id), undefined, SCOPE);
848
+ return {deleted: found.map(({id, title}) => ({id, title}))};
849
+ },
850
+ text: (r) => `Deleted paging ${r.deleted.length === 1 ? 'group' : 'groups'} ${r.deleted.map(titled).join(', ')}`,
851
+ },
852
+ },
853
+ };
854
+
855
+ // ---------------------------------------------------------------------------------------------------------------------
856
+ // Dialplan DB (the Asterisk database dialplans read and write): families of key → value; a key may hold "/".
857
+
858
+ const familyPath = (family) => apiPath`/Dialplan/DB/${family}/`;
859
+ function keyPath(family, key) {
860
+ const parts = String(key).split('/');
861
+ if (parts.some((p) => !p)) throw new UsageError(`Not a key: ${key}`);
862
+ try {
863
+ return familyPath(family) + parts.map((p) => apiPath`${p}`).join('/') + '/';
864
+ } catch {
865
+ throw new UsageError(`A key is letters, digits and _ . : + @ - (and / between parts): ${key}`);
866
+ }
867
+ }
868
+ const checkFamily = (family) => {
869
+ try {
870
+ return familyPath(family);
871
+ } catch {
872
+ throw new UsageError(`A family is letters, digits and _ . : + @ -: ${family}`);
873
+ }
874
+ };
875
+ const shortKey = (family, key) =>
876
+ String(key).startsWith(`/${family}/`) ? String(key).slice(family.length + 2) : String(key);
877
+
878
+ const db = {
879
+ summary: 'the dialplan database (families of keys and values that dialplans read and set): list, get, set, delete',
880
+ commands: {
881
+ list: {
882
+ summary: 'the keys and values of a family',
883
+ usage: 'wx-admin db list <family>',
884
+ options: {},
885
+ args: [1, 1],
886
+ async run(ctx) {
887
+ return (await pbxClient(ctx)).call('GET', checkFamily(ctx.positionals[0]), undefined, SCOPE);
888
+ },
889
+ text: (r, ctx) =>
890
+ r?.records?.length
891
+ ? table(r.records.map((x) => [shortKey(ctx.positionals[0], x.key), oneLine(x.value, 100)]))
892
+ : `No keys in ${ctx.positionals[0]}`,
893
+ },
894
+ get: {
895
+ summary: 'the value of a key',
896
+ usage: 'wx-admin db get <family> <key>',
897
+ options: {},
898
+ args: [2, 2],
899
+ async run(ctx) {
900
+ const [family, key] = ctx.positionals;
901
+ const value = await (await pbxClient(ctx)).call('GET', keyPath(family, key).slice(0, -1), undefined, SCOPE);
902
+ return {family, key, value};
903
+ },
904
+ text: (r) => String(r.value ?? ''),
905
+ },
906
+ set: {
907
+ summary: 'set the value of a key (made if missing)',
908
+ usage: 'wx-admin db set <family> <key> <value>',
909
+ options: {},
910
+ args: [3, 3],
911
+ async run(ctx) {
912
+ const [family, key, value] = ctx.positionals;
913
+ await (await pbxClient(ctx)).call('PUT', keyPath(family, key), {value}, SCOPE);
914
+ return {family, key, value};
915
+ },
916
+ text: (r) => `Set ${r.family}/${r.key} = ${r.value}`,
917
+ },
918
+ delete: {
919
+ summary: 'delete a key, or a whole family without a key (needs --yes)',
920
+ usage: 'wx-admin db delete <family> [<key>] --yes',
921
+ options: {yes: {type: 'boolean', description: 'yes, delete it'}},
922
+ args: [1, 2],
923
+ async run(ctx) {
924
+ const [family, key] = ctx.positionals;
925
+ needsYes(
926
+ ctx,
927
+ key ? 'Deleting a key changes what the dialplans read' : 'Deleting a family deletes all its keys',
928
+ );
929
+ const client = await pbxClient(ctx);
930
+ if (key) {
931
+ // The PBX answers "Success" for a missing key: read it first, so a typo is not reported as done.
932
+ await client.call('GET', keyPath(family, key).slice(0, -1), undefined, SCOPE);
933
+ await client.call('DELETE', keyPath(family, key), undefined, SCOPE);
934
+ return {family, deleted: [key]};
935
+ }
936
+ const keys = ((await client.call('GET', checkFamily(family), undefined, SCOPE))?.records ?? []).map((x) =>
937
+ shortKey(family, x.key),
938
+ );
939
+ if (!keys.length) throw new UsageError(`The family ${family} has no keys`);
940
+ await client.call('DELETE', checkFamily(family), undefined, SCOPE);
941
+ return {family, all: true, deleted: keys};
942
+ },
943
+ text: (r) =>
944
+ r.all
945
+ ? `Deleted the family ${r.family} and its ${r.deleted.length === 1 ? 'key' : `${r.deleted.length} keys`}: ${r.deleted.join(', ')}`
946
+ : `Deleted the key ${r.family}/${r.deleted[0]}`,
947
+ },
948
+ },
949
+ };
950
+
951
+ // ---------------------------------------------------------------------------------------------------------------------
952
+ // General dialplan settings (one object), for `wx-admin dialplan settings` and `settings-set`. A PUT gets the whole
953
+ // object: the current settings with the changes.
954
+
955
+ const GS = '/Dialplan/GeneralSettings/';
956
+ const SETTINGS = {
957
+ faxLang: 'string',
958
+ parkTimeout: 'integer',
959
+ prefixExtLine: 'string',
960
+ internationalPrefix: 'string',
961
+ nationalPrefix: 'string',
962
+ intDialTimeout: 'string',
963
+ transferDigitTimeout: 'string',
964
+ transferInterDigitTimeout: 'string',
965
+ endOfDigit: ['0', '1', '2'],
966
+ buttonSound: 'boolean',
967
+ attachFormatVm: ['mp3', 'wav'],
968
+ attachFormatRec: ['mp3', 'wav'],
969
+ recNotify: 'boolean',
970
+ recAttach: 'boolean',
971
+ faxAttach: 'boolean',
972
+ vmNotify: 'boolean',
973
+ describeVoiceMail: 'boolean',
974
+ trunkRegistrationNotification: 'boolean',
975
+ voiceMailMessageMoreInfo: 'boolean',
976
+ userVariables: 'lines',
977
+ quickDialPatterns: 'lines',
978
+ personalRecordType: ['default', 'record', 'transcribe', 'record_transcribe'],
979
+ };
980
+
981
+ function settingValue(key, raw) {
982
+ const type = SETTINGS[key];
983
+ if (!type) throw new UsageError(`Unknown setting ${key}: one of ${Object.keys(SETTINGS).join(', ')}`);
984
+ if (raw === null || typeof raw === 'object') throw new UsageError(`${key} is a single value`);
985
+ const s = String(raw);
986
+ if (Array.isArray(type)) {
987
+ if (!type.includes(s)) throw new UsageError(`${key} is one of ${type.join(', ')}: ${s}`);
988
+ return s;
989
+ }
990
+ if (type === 'boolean') {
991
+ if (!['true', 'false'].includes(s)) throw new UsageError(`${key} is true or false: ${s}`);
992
+ return s === 'true';
993
+ }
994
+ if (type === 'integer') return wholeNumber(s, key, 0, 32767);
995
+ if (type === 'lines') return typeof raw === 'string' ? raw.replace(/\\n/g, '\n') : s;
996
+ return s;
997
+ }
998
+
999
+ const settingText = (v) =>
1000
+ typeof v === 'string' && v.includes('\n') ? v.split('\n').filter(Boolean).join(' · ') : String(v ?? '');
1001
+
1002
+ export const dialplanCommands = {
1003
+ settings: {
1004
+ summary: 'the general dialplan settings (prefixes, timeouts, recording and voicemail mail, variables)',
1005
+ usage: 'wx-admin dialplan settings',
1006
+ options: {},
1007
+ args: [0, 0],
1008
+ async run(ctx) {
1009
+ return (await pbxClient(ctx)).call('GET', GS, undefined, SCOPE);
1010
+ },
1011
+ text: (s) => table(Object.entries(s ?? {}).map(([k, v]) => [k, settingText(v)])),
1012
+ },
1013
+ 'settings-set': {
1014
+ summary: 'change general dialplan settings (needs --yes: they apply to every call)',
1015
+ usage: 'wx-admin dialplan settings-set (--set KEY=VALUE... | --from FILE|-) --yes',
1016
+ details: [
1017
+ `The keys: ${Object.keys(SETTINGS).join(', ')}.`,
1018
+ "In userVariables and quickDialPatterns, \\n separates lines: --set 'quickDialPatterns=1[0-9][0-9]\\n2[0-9][0-9]'.",
1019
+ 'The others are kept as they are (the PBX gets the whole settings).',
1020
+ ].join('\n'),
1021
+ options: {
1022
+ set: {type: 'string', multiple: true, placeholder: 'KEY=VALUE', description: 'a setting and its new value'},
1023
+ from: {
1024
+ type: 'string',
1025
+ placeholder: 'FILE',
1026
+ description: 'the settings to change as JSON (a file, or - for stdin)',
1027
+ },
1028
+ yes: {type: 'boolean', description: 'yes, change them'},
1029
+ },
1030
+ args: [0, 0],
1031
+ async run(ctx) {
1032
+ const v = ctx.values;
1033
+ if (!v.set?.length && !v.from) throw new UsageError('Give --set KEY=VALUE or --from', ctx.usage());
1034
+ const changes = {};
1035
+ if (v.from) {
1036
+ const src = await readJsonInput(ctx, v.from);
1037
+ if (!src || typeof src !== 'object' || Array.isArray(src))
1038
+ throw new UsageError('--from is a JSON object of settings');
1039
+ for (const [k, val] of Object.entries(src)) changes[k] = settingValue(k, val);
1040
+ }
1041
+ for (const kv of v.set ?? []) {
1042
+ const at = kv.indexOf('=');
1043
+ if (at < 1) throw new UsageError(`--set is KEY=VALUE: ${kv}`);
1044
+ changes[kv.slice(0, at)] = settingValue(kv.slice(0, at), kv.slice(at + 1));
1045
+ }
1046
+ needsYes(ctx, 'The general dialplan settings apply to every call');
1047
+ const client = await pbxClient(ctx);
1048
+ const current = await client.call('GET', GS, undefined, SCOPE);
1049
+ await client.call('PUT', GS, {...current, ...changes}, SCOPE);
1050
+ return {changed: changes, settings: await client.call('GET', GS, undefined, SCOPE)};
1051
+ },
1052
+ text: (r) =>
1053
+ `Updated the dialplan settings: ${Object.entries(r.changed)
1054
+ .map(([k, v]) => `${k}=${settingText(v)}`)
1055
+ .join(', ')}`,
1056
+ },
1057
+ };
1058
+
1059
+ export const groups = {timetable, ivr, switch: switchCommands, paging, db};