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.
- package/LICENSE +21 -0
- package/README.md +404 -2
- package/bin/wx-admin.mjs +5 -0
- package/package.json +36 -3
- package/src/cli.mjs +71 -0
- package/src/commands/acl.mjs +376 -0
- package/src/commands/api.mjs +66 -0
- package/src/commands/config.mjs +280 -0
- package/src/commands/device.mjs +275 -0
- package/src/commands/dialplan.mjs +488 -0
- package/src/commands/index.mjs +85 -0
- package/src/commands/pbx.mjs +197 -0
- package/src/commands/phonebook.mjs +558 -0
- package/src/commands/queue.mjs +456 -0
- package/src/commands/records.mjs +623 -0
- package/src/commands/routing.mjs +1059 -0
- package/src/commands/sound.mjs +232 -0
- package/src/commands/system.mjs +668 -0
- package/src/commands/trunk.mjs +808 -0
- package/src/commands/user.mjs +574 -0
- package/src/match.mjs +15 -0
- package/src/pbx.mjs +369 -0
- package/src/secret.mjs +42 -0
- package/src/version.mjs +3 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// wx-admin dialplan: the dialplans (procedures) of a PBX and their rules, over the PBX REST API.
|
|
2
|
+
// The model of the API: a dialplan is {id, name, description, numbers, includedDialplans}; its rules are `numbers`,
|
|
3
|
+
// each {number (a pattern), apps: [{name, params}], comment}, run in order; includedDialplans are {dialplanId}
|
|
4
|
+
// (negative ids are built-in ones). There is no endpoint per rule: a rule is changed by writing the whole dialplan.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {pickByName} from '../match.mjs';
|
|
8
|
+
import {UsageError} from 'wldx-core/errors';
|
|
9
|
+
import {refuseUnsafeWrite, readJsonInput as readJsonFile} from 'wldx-core/files';
|
|
10
|
+
import {oneLine, paint, table} from 'wldx-core/format';
|
|
11
|
+
import {pbxClient} from '../pbx.mjs';
|
|
12
|
+
|
|
13
|
+
// The applications the API names (the enum of DialplanItemNumber in the PBX OpenAPI).
|
|
14
|
+
export const APPS = [
|
|
15
|
+
'Answer',
|
|
16
|
+
'AsrIvr',
|
|
17
|
+
'AuthenticateUser',
|
|
18
|
+
'Background',
|
|
19
|
+
'Callback',
|
|
20
|
+
'Conference',
|
|
21
|
+
'CorrectOutgoingNumber',
|
|
22
|
+
'CustomApp',
|
|
23
|
+
'DialHuntGroup',
|
|
24
|
+
'DialPhone',
|
|
25
|
+
'DialRemotePbx',
|
|
26
|
+
'DialTrunk',
|
|
27
|
+
'Directory',
|
|
28
|
+
'FastAGI',
|
|
29
|
+
'FaxToEmail',
|
|
30
|
+
'FeatureIntercom',
|
|
31
|
+
'FeaturePaging',
|
|
32
|
+
'GenerateDtmf',
|
|
33
|
+
'GenerateFlashHook',
|
|
34
|
+
'Goto',
|
|
35
|
+
'GotoIf',
|
|
36
|
+
'GotoIf3SwitchIsOnOrBlinking',
|
|
37
|
+
'GotoIfBusyCongested',
|
|
38
|
+
'GotoIfCallType',
|
|
39
|
+
'GoToIfFaxDetected',
|
|
40
|
+
'GotoIfGroupCount',
|
|
41
|
+
'GotoIfSwitchIsOn',
|
|
42
|
+
'GotoIfUserStatus',
|
|
43
|
+
'Hangup',
|
|
44
|
+
'IVR',
|
|
45
|
+
'LookUpCidNameInPhonebook',
|
|
46
|
+
'LookUpDestCidNameInPhonebook',
|
|
47
|
+
'MobilityExtensionLookUp',
|
|
48
|
+
'MuxMon',
|
|
49
|
+
'Playback',
|
|
50
|
+
'Queue',
|
|
51
|
+
'Set',
|
|
52
|
+
'Timetable',
|
|
53
|
+
'VerifyCaller',
|
|
54
|
+
'VoiceMail',
|
|
55
|
+
'VoiceMailMain',
|
|
56
|
+
'WaitExten',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const NUMBER = /^[^\s]{1,128}$/;
|
|
60
|
+
|
|
61
|
+
// The PBX wants every parameter of an application, empty ones too (the WMS form sends them all) and answers 400
|
|
62
|
+
// "Not found parameter <key> in <number>/<app>" otherwise. The parameters known so far, seen on a live PBX; a missing
|
|
63
|
+
// one the PBX names is added empty and the write is tried again (see write()).
|
|
64
|
+
const APP_PARAMS = {
|
|
65
|
+
DialPhone: ['number', 'timeout', 'options', 'msgForOperator'],
|
|
66
|
+
DialTrunk: ['number', 'trunk', 'timeout', 'maxCalls', 'options', 'classOfCall', 'routeOfficeNumbersInternally'],
|
|
67
|
+
Goto: ['number', 'dialplan'],
|
|
68
|
+
Hangup: ['causeCode'],
|
|
69
|
+
MuxMon: ['destination', 'options', 'mode', 'transcribe_mode'],
|
|
70
|
+
Playback: ['filename', 'noAnswer'],
|
|
71
|
+
Queue: ['queueId', 'msgForOperator', 'timeout', 'mohClass'],
|
|
72
|
+
Set: ['key', 'value'],
|
|
73
|
+
VoiceBot: ['botId', 'language', 'voice', 'welcomeMessage', 'voicebotRecording', 'backgroundSound'],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// params as the API gives them: an object, or [] when there are none (PHP).
|
|
77
|
+
const paramsOf = (p) => (Array.isArray(p) && !p.length ? {} : (p ?? {}));
|
|
78
|
+
|
|
79
|
+
async function readJsonInput(ctx, from) {
|
|
80
|
+
const data = await readJsonFile(ctx, from); // no credentials, nothing hidden in the home folder
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The path of dialplans by id: ids are whole numbers (checked), several are joined by commas.
|
|
85
|
+
function dialplanPath(...ids) {
|
|
86
|
+
for (const id of ids) if (!Number.isInteger(id)) throw new UsageError(`Not a dialplan id: ${id}`);
|
|
87
|
+
return `/PBX/Dialplans/${ids.join(',')}/`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A dialplan by id or by name.
|
|
91
|
+
async function dialplanId(client, ref) {
|
|
92
|
+
if (/^-?\d+$/.test(String(ref))) return Number(ref);
|
|
93
|
+
const list = (await client.call('GET', '/PBX/Dialplans/', undefined, {scope: 'dialplan'}))?.records ?? [];
|
|
94
|
+
return pickByName(list, ref, (d) => d.name, 'dialplan', 'wx-admin dialplan list').id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const getDialplan = async (client, ref) => {
|
|
98
|
+
const id = await dialplanId(client, ref);
|
|
99
|
+
return client.call('GET', dialplanPath(id), undefined, {scope: 'dialplan'});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// What PUT takes: description, numbers, includedDialplans (the name does not change).
|
|
103
|
+
const writable = (d) => ({
|
|
104
|
+
description: d.description ?? '',
|
|
105
|
+
numbers: d.numbers ?? [],
|
|
106
|
+
includedDialplans: d.includedDialplans ?? [],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const MISSING = /^Not found parameter (\S+) in (.+)\/(\w+)$/;
|
|
110
|
+
|
|
111
|
+
// POST or PUT a dialplan; when the PBX names parameters it misses, they are added empty and the write is tried
|
|
112
|
+
// again (up to 5 times), with a note for the person.
|
|
113
|
+
async function write(ctx, client, method, apiPath, body) {
|
|
114
|
+
for (let round = 0; ; round += 1) {
|
|
115
|
+
try {
|
|
116
|
+
return await client.call(method, apiPath, body, {scope: 'dialplan'});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
const missing =
|
|
119
|
+
round < 5 && e.status === 400 ? (e.errors ?? []).map((m) => MISSING.exec(String(m))).filter(Boolean) : [];
|
|
120
|
+
if (!missing.length || missing.length !== (e.errors ?? []).length) throw e;
|
|
121
|
+
for (const [, key, number, app] of missing) {
|
|
122
|
+
for (const r of body.numbers ?? []) {
|
|
123
|
+
if (r.number !== number) continue;
|
|
124
|
+
for (const a of r.apps) if (a.name === app && !(key in a.params)) a.params[key] = '';
|
|
125
|
+
}
|
|
126
|
+
ctx.info(`Added the empty parameter ${key} to ${app} in ${number} (the PBX wants every parameter)`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A checked rule: {number, apps: [{name, params}], comment}.
|
|
133
|
+
function checkRule(rule, ctx) {
|
|
134
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule))
|
|
135
|
+
throw new UsageError('A rule is an object {number, apps, comment}');
|
|
136
|
+
if (typeof rule.number !== 'string' || !NUMBER.test(rule.number))
|
|
137
|
+
throw new UsageError(`A rule needs a number (a pattern without spaces): ${JSON.stringify(rule.number)}`);
|
|
138
|
+
if (!Array.isArray(rule.apps) || !rule.apps.length)
|
|
139
|
+
throw new UsageError(`The rule ${rule.number} needs at least one application`);
|
|
140
|
+
for (const app of rule.apps) {
|
|
141
|
+
if (!app || typeof app.name !== 'string')
|
|
142
|
+
throw new UsageError(`An application of the rule ${rule.number} has no name`);
|
|
143
|
+
const params = paramsOf(app.params);
|
|
144
|
+
if (typeof params !== 'object' || Array.isArray(params))
|
|
145
|
+
throw new UsageError(`The params of ${app.name} are an object of strings`);
|
|
146
|
+
if (!APPS.includes(app.name))
|
|
147
|
+
ctx.info(
|
|
148
|
+
`Note: ${app.name} is not one of the applications this version knows (wx-admin dialplan apps); the PBX decides.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const complete = (a) => ({
|
|
152
|
+
...Object.fromEntries((APP_PARAMS[a.name] ?? []).map((k) => [k, ''])),
|
|
153
|
+
...paramsOf(a.params),
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
number: rule.number,
|
|
157
|
+
apps: rule.apps.map((a) => ({name: a.name, params: complete(a)})),
|
|
158
|
+
comment: rule.comment ?? '',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --app "Name key=value key=value" or --app '{"name": "Name", "params": {…}}'.
|
|
163
|
+
function parseApp(spec) {
|
|
164
|
+
const s = String(spec).trim();
|
|
165
|
+
if (s.startsWith('{')) {
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(s);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw new UsageError(`--app is not JSON: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const [name, ...pairs] = s.split(/\s+/);
|
|
173
|
+
const params = {};
|
|
174
|
+
for (const p of pairs) {
|
|
175
|
+
const at = p.indexOf('=');
|
|
176
|
+
if (at < 1) throw new UsageError(`--app parameters are key=value: ${p}`);
|
|
177
|
+
params[p.slice(0, at)] = p.slice(at + 1);
|
|
178
|
+
}
|
|
179
|
+
return {name, params};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const appLine = (a) => {
|
|
183
|
+
const set = Object.entries(a.params ?? {}).filter(([, v]) => v !== '' && v !== null && v !== undefined);
|
|
184
|
+
return set.length ? `${a.name}(${set.map(([k, v]) => `${k}=${v}`).join(', ')})` : a.name;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const ruleLines = (d, ctx) => {
|
|
188
|
+
const c = paint(ctx);
|
|
189
|
+
const numbers = d.numbers ?? [];
|
|
190
|
+
if (!numbers.length) return [c.dim('no rules')];
|
|
191
|
+
return numbers.map(
|
|
192
|
+
(r, i) =>
|
|
193
|
+
`${String(i + 1).padStart(3)} ${c.bold(r.number)} ${(r.apps ?? []).map(appLine).join(' → ')}${r.comment ? c.dim(` # ${oneLine(r.comment, 60)}`) : ''}`,
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const header = (d) => `${d.name} (id ${d.id})${d.description ? ` — ${d.description}` : ''}`;
|
|
198
|
+
|
|
199
|
+
export const groups = {
|
|
200
|
+
dialplan: {
|
|
201
|
+
summary: 'dialplans (procedures) and their rules: list, show, create, change, delete, export, import',
|
|
202
|
+
commands: {
|
|
203
|
+
list: {
|
|
204
|
+
readOnly: true,
|
|
205
|
+
summary: 'the dialplans of the PBX',
|
|
206
|
+
usage: 'wx-admin dialplan list',
|
|
207
|
+
options: {},
|
|
208
|
+
args: [0, 0],
|
|
209
|
+
async run(ctx) {
|
|
210
|
+
return (await pbxClient(ctx)).call('GET', '/PBX/Dialplans/', undefined, {scope: 'dialplan'});
|
|
211
|
+
},
|
|
212
|
+
text: (r) =>
|
|
213
|
+
r?.records?.length ? table(r.records.map((d) => [d.id, d.name, d.description ?? ''])) : 'No dialplans',
|
|
214
|
+
},
|
|
215
|
+
get: {
|
|
216
|
+
readOnly: true,
|
|
217
|
+
summary: 'one dialplan with its rules and included dialplans',
|
|
218
|
+
usage: 'wx-admin dialplan get <dialplan>',
|
|
219
|
+
details: 'A dialplan is its id or its name.',
|
|
220
|
+
options: {},
|
|
221
|
+
args: [1, 1],
|
|
222
|
+
async run(ctx) {
|
|
223
|
+
return getDialplan(await pbxClient(ctx), ctx.positionals[0]);
|
|
224
|
+
},
|
|
225
|
+
text: (d, ctx) => [
|
|
226
|
+
header(d),
|
|
227
|
+
...(d.includedDialplans?.length
|
|
228
|
+
? [`includes ${d.includedDialplans.map((x) => x.dialplanId).join(', ')}`]
|
|
229
|
+
: []),
|
|
230
|
+
...ruleLines(d, ctx),
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
create: {
|
|
234
|
+
summary: 'create a dialplan, empty or from a JSON file ({description, numbers, includedDialplans})',
|
|
235
|
+
usage: 'wx-admin dialplan create <name> [--description TEXT] [--from FILE|-]',
|
|
236
|
+
options: {
|
|
237
|
+
description: {type: 'string', placeholder: 'TEXT', description: 'what the dialplan is for'},
|
|
238
|
+
from: {
|
|
239
|
+
type: 'string',
|
|
240
|
+
placeholder: 'FILE',
|
|
241
|
+
description: 'the rules and includes as JSON (a file, or - for stdin)',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
args: [1, 1],
|
|
245
|
+
async run(ctx) {
|
|
246
|
+
const name = ctx.positionals[0];
|
|
247
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
|
|
248
|
+
throw new UsageError(`A dialplan name is letters, digits, _ and -: ${name}`);
|
|
249
|
+
const src = ctx.values.from ? await readJsonInput(ctx, ctx.values.from) : {};
|
|
250
|
+
const body = {name, ...writable(src)};
|
|
251
|
+
if (ctx.values.description !== undefined) body.description = ctx.values.description;
|
|
252
|
+
body.numbers = body.numbers.map((r) => checkRule(r, ctx));
|
|
253
|
+
return write(ctx, await pbxClient(ctx), 'POST', '/PBX/Dialplans/', body);
|
|
254
|
+
},
|
|
255
|
+
text: (d) => `Created ${header(d)}`,
|
|
256
|
+
},
|
|
257
|
+
update: {
|
|
258
|
+
summary: 'change a dialplan: its description, or its rules and includes from a JSON file (replaces them)',
|
|
259
|
+
usage: 'wx-admin dialplan update <dialplan> [--description TEXT] [--from FILE|-] [--yes]',
|
|
260
|
+
details: 'Fields missing from the file are kept. The name cannot change.',
|
|
261
|
+
options: {
|
|
262
|
+
description: {type: 'string', placeholder: 'TEXT', description: 'the new description'},
|
|
263
|
+
from: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
placeholder: 'FILE',
|
|
266
|
+
description: 'JSON with numbers and/or includedDialplans (a file, or - for stdin)',
|
|
267
|
+
},
|
|
268
|
+
yes: {type: 'boolean', description: 'yes, replace the rules or included dialplans'},
|
|
269
|
+
},
|
|
270
|
+
args: [1, 1],
|
|
271
|
+
async run(ctx) {
|
|
272
|
+
if (ctx.values.description === undefined && !ctx.values.from)
|
|
273
|
+
throw new UsageError('Nothing to change: give --description or --from', ctx.usage());
|
|
274
|
+
const client = await pbxClient(ctx);
|
|
275
|
+
const current = await getDialplan(client, ctx.positionals[0]);
|
|
276
|
+
const src = ctx.values.from ? await readJsonInput(ctx, ctx.values.from) : {};
|
|
277
|
+
if ((src.numbers !== undefined || src.includedDialplans !== undefined) && !ctx.values.yes)
|
|
278
|
+
throw new UsageError(
|
|
279
|
+
'The file replaces every rule (or included dialplan) of the dialplan: confirm with --yes',
|
|
280
|
+
ctx.usage(),
|
|
281
|
+
);
|
|
282
|
+
const body = writable({
|
|
283
|
+
...current,
|
|
284
|
+
...Object.fromEntries(
|
|
285
|
+
['description', 'numbers', 'includedDialplans']
|
|
286
|
+
.filter((k) => src[k] !== undefined)
|
|
287
|
+
.map((k) => [k, src[k]]),
|
|
288
|
+
),
|
|
289
|
+
});
|
|
290
|
+
if (ctx.values.description !== undefined) body.description = ctx.values.description;
|
|
291
|
+
body.numbers = body.numbers.map((r) => checkRule(r, ctx));
|
|
292
|
+
return write(ctx, client, 'PUT', dialplanPath(current.id), body);
|
|
293
|
+
},
|
|
294
|
+
text: (d) => `Updated ${header(d)}`,
|
|
295
|
+
},
|
|
296
|
+
delete: {
|
|
297
|
+
summary: 'delete dialplans (needs --yes: calls routed to them stop working)',
|
|
298
|
+
usage: 'wx-admin dialplan delete <dialplan>... --yes',
|
|
299
|
+
options: {yes: {type: 'boolean', description: 'yes, delete them'}},
|
|
300
|
+
args: [1, 50],
|
|
301
|
+
async run(ctx) {
|
|
302
|
+
if (!ctx.values.yes)
|
|
303
|
+
throw new UsageError('Deleting a dialplan stops the calls routed to it: confirm with --yes', ctx.usage());
|
|
304
|
+
const client = await pbxClient(ctx);
|
|
305
|
+
const ids = [];
|
|
306
|
+
for (const ref of ctx.positionals) ids.push(await dialplanId(client, ref));
|
|
307
|
+
await client.call('DELETE', dialplanPath(...ids), undefined, {scope: 'dialplan'});
|
|
308
|
+
return {deleted: ids};
|
|
309
|
+
},
|
|
310
|
+
text: (r) => `Deleted ${r.deleted.length === 1 ? 'dialplan' : 'dialplans'} ${r.deleted.join(', ')}`,
|
|
311
|
+
},
|
|
312
|
+
rules: {
|
|
313
|
+
readOnly: true,
|
|
314
|
+
summary: 'the rules of a dialplan, in order: number pattern → applications',
|
|
315
|
+
usage: 'wx-admin dialplan rules <dialplan>',
|
|
316
|
+
options: {},
|
|
317
|
+
args: [1, 1],
|
|
318
|
+
async run(ctx) {
|
|
319
|
+
const d = await getDialplan(await pbxClient(ctx), ctx.positionals[0]);
|
|
320
|
+
return {id: d.id, name: d.name, numbers: d.numbers ?? []};
|
|
321
|
+
},
|
|
322
|
+
text: (d, ctx) => ruleLines(d, ctx),
|
|
323
|
+
},
|
|
324
|
+
'rule-add': {
|
|
325
|
+
summary: 'add a rule to a dialplan (at the end, or at --position)',
|
|
326
|
+
usage:
|
|
327
|
+
'wx-admin dialplan rule-add <dialplan> <number> (--app "Name key=value…"... | --from FILE|-) [--comment TEXT] [--position N] [--replace]',
|
|
328
|
+
details: [
|
|
329
|
+
'The applications run in the order given: --app "DialPhone number=101 timeout=30" --app Hangup.',
|
|
330
|
+
'An --app can also be JSON: --app \'{"name": "Set", "params": {"var": "X", "value": "1"}}\'.',
|
|
331
|
+
'--from reads one rule as JSON: {"apps": [...], "comment": "..."} (its number is the argument).',
|
|
332
|
+
'A rule with the same number is refused unless --replace (which puts the new one in its place).',
|
|
333
|
+
].join('\n'),
|
|
334
|
+
options: {
|
|
335
|
+
app: {type: 'string', multiple: true, placeholder: 'SPEC', description: 'an application and its parameters'},
|
|
336
|
+
from: {type: 'string', placeholder: 'FILE', description: 'the rule as JSON (a file, or - for stdin)'},
|
|
337
|
+
comment: {type: 'string', placeholder: 'TEXT', description: 'a comment on the rule'},
|
|
338
|
+
position: {type: 'string', placeholder: 'N', description: 'put it at this place (1 = first)'},
|
|
339
|
+
replace: {type: 'boolean', description: 'replace the rule with the same number'},
|
|
340
|
+
},
|
|
341
|
+
args: [2, 2],
|
|
342
|
+
async run(ctx) {
|
|
343
|
+
const {values} = ctx;
|
|
344
|
+
const [ref, number] = ctx.positionals;
|
|
345
|
+
if (!values.app?.length === !values.from)
|
|
346
|
+
throw new UsageError('Give --app (one or more) or --from', ctx.usage());
|
|
347
|
+
const src = values.from ? await readJsonInput(ctx, values.from) : {apps: values.app.map(parseApp)};
|
|
348
|
+
const rule = checkRule({...src, number, comment: values.comment ?? src.comment ?? ''}, ctx);
|
|
349
|
+
const client = await pbxClient(ctx);
|
|
350
|
+
const current = await getDialplan(client, ref);
|
|
351
|
+
const numbers = (current.numbers ?? []).map((r) => checkRule(r, ctx));
|
|
352
|
+
const at = numbers.findIndex((r) => r.number === number);
|
|
353
|
+
if (at >= 0 && !values.replace)
|
|
354
|
+
throw new UsageError(
|
|
355
|
+
`The dialplan ${current.name} has a rule for ${number} already: --replace to replace it`,
|
|
356
|
+
);
|
|
357
|
+
if (at >= 0) numbers.splice(at, 1, rule);
|
|
358
|
+
else if (values.position !== undefined) {
|
|
359
|
+
const n = Number(values.position);
|
|
360
|
+
if (!Number.isInteger(n) || n < 1 || n > numbers.length + 1)
|
|
361
|
+
throw new UsageError(`--position is from 1 to ${numbers.length + 1}`);
|
|
362
|
+
numbers.splice(n - 1, 0, rule);
|
|
363
|
+
} else numbers.push(rule);
|
|
364
|
+
return write(ctx, client, 'PUT', dialplanPath(current.id), {...writable(current), numbers});
|
|
365
|
+
},
|
|
366
|
+
text: (d, ctx) => [`Updated ${header(d)}`, ...ruleLines(d, ctx)],
|
|
367
|
+
},
|
|
368
|
+
'rule-remove': {
|
|
369
|
+
summary: 'remove the rule of a number from a dialplan (needs --yes)',
|
|
370
|
+
usage: 'wx-admin dialplan rule-remove <dialplan> <number> --yes',
|
|
371
|
+
options: {yes: {type: 'boolean', description: 'yes, remove it'}},
|
|
372
|
+
args: [2, 2],
|
|
373
|
+
async run(ctx) {
|
|
374
|
+
if (!ctx.values.yes)
|
|
375
|
+
throw new UsageError('Removing a rule changes how calls are routed: confirm with --yes', ctx.usage());
|
|
376
|
+
const [ref, number] = ctx.positionals;
|
|
377
|
+
const client = await pbxClient(ctx);
|
|
378
|
+
const current = await getDialplan(client, ref);
|
|
379
|
+
const numbers = (current.numbers ?? []).filter((r) => r.number !== number).map((r) => checkRule(r, ctx));
|
|
380
|
+
if (numbers.length === (current.numbers ?? []).length)
|
|
381
|
+
throw new UsageError(`The dialplan ${current.name} has no rule for ${number}`);
|
|
382
|
+
return write(ctx, client, 'PUT', dialplanPath(current.id), {...writable(current), numbers});
|
|
383
|
+
},
|
|
384
|
+
text: (d, ctx) => [`Updated ${header(d)}`, ...ruleLines(d, ctx)],
|
|
385
|
+
},
|
|
386
|
+
export: {
|
|
387
|
+
summary: 'a dialplan as JSON that dialplan import takes (to stdout, or to a new file)',
|
|
388
|
+
usage: 'wx-admin dialplan export <dialplan> [--to FILE]',
|
|
389
|
+
options: {
|
|
390
|
+
to: {type: 'string', placeholder: 'FILE', description: 'write it to this file (which must not exist)'},
|
|
391
|
+
},
|
|
392
|
+
args: [1, 1],
|
|
393
|
+
async run(ctx) {
|
|
394
|
+
const d = await getDialplan(await pbxClient(ctx), ctx.positionals[0]);
|
|
395
|
+
const data = {name: d.name, ...writable(d)};
|
|
396
|
+
if (!ctx.values.to) return data;
|
|
397
|
+
const file = path.resolve(ctx.cwd ?? process.cwd(), ctx.values.to);
|
|
398
|
+
refuseUnsafeWrite(file, ctx.env);
|
|
399
|
+
try {
|
|
400
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, {flag: 'wx'});
|
|
401
|
+
} catch (e) {
|
|
402
|
+
if (e.code === 'EEXIST')
|
|
403
|
+
throw new UsageError(`${ctx.values.to} exists: choose another file (nothing was overwritten)`);
|
|
404
|
+
throw e;
|
|
405
|
+
}
|
|
406
|
+
return {file, name: d.name, rules: data.numbers.length};
|
|
407
|
+
},
|
|
408
|
+
text: (r) => (r.file ? `Exported ${r.name} (${r.rules} rules) to ${r.file}` : JSON.stringify(r, null, 2)),
|
|
409
|
+
},
|
|
410
|
+
import: {
|
|
411
|
+
summary: 'create a dialplan from the JSON of dialplan export; --replace overwrites one of the same name',
|
|
412
|
+
usage: 'wx-admin dialplan import --from FILE|- [--as NAME] [--replace --yes]',
|
|
413
|
+
options: {
|
|
414
|
+
from: {
|
|
415
|
+
type: 'string',
|
|
416
|
+
placeholder: 'FILE',
|
|
417
|
+
description: 'the JSON of dialplan export (a file, or - for stdin)',
|
|
418
|
+
},
|
|
419
|
+
as: {type: 'string', placeholder: 'NAME', description: 'another name for it'},
|
|
420
|
+
replace: {type: 'boolean', description: 'replace the rules of an existing dialplan of that name'},
|
|
421
|
+
yes: {type: 'boolean', description: 'yes, replace them (with --replace)'},
|
|
422
|
+
},
|
|
423
|
+
args: [0, 0],
|
|
424
|
+
async run(ctx) {
|
|
425
|
+
if (!ctx.values.from) throw new UsageError('--from is needed', ctx.usage());
|
|
426
|
+
const src = await readJsonInput(ctx, ctx.values.from);
|
|
427
|
+
const name = ctx.values.as ?? src.name;
|
|
428
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(name))
|
|
429
|
+
throw new UsageError(`A dialplan name is letters, digits, _ and -: ${name}`);
|
|
430
|
+
const body = writable(src);
|
|
431
|
+
body.numbers = body.numbers.map((r) => checkRule(r, ctx));
|
|
432
|
+
const client = await pbxClient(ctx);
|
|
433
|
+
const existing = (
|
|
434
|
+
(await client.call('GET', '/PBX/Dialplans/', undefined, {scope: 'dialplan'}))?.records ?? []
|
|
435
|
+
).find((d) => d.name === name);
|
|
436
|
+
if (existing && ctx.values.replace && !ctx.values.yes)
|
|
437
|
+
throw new UsageError(
|
|
438
|
+
`--replace overwrites every rule of the dialplan ${name}: confirm with --yes`,
|
|
439
|
+
ctx.usage(),
|
|
440
|
+
);
|
|
441
|
+
if (existing && !ctx.values.replace)
|
|
442
|
+
throw new UsageError(
|
|
443
|
+
`A dialplan ${name} exists (id ${existing.id}): --replace to overwrite its rules, or --as another name`,
|
|
444
|
+
);
|
|
445
|
+
return existing
|
|
446
|
+
? write(ctx, client, 'PUT', dialplanPath(existing.id), body)
|
|
447
|
+
: write(ctx, client, 'POST', '/PBX/Dialplans/', {name, ...body});
|
|
448
|
+
},
|
|
449
|
+
text: (d) => `Imported ${header(d)} (${(d.numbers ?? []).length} rules)`,
|
|
450
|
+
},
|
|
451
|
+
apps: {
|
|
452
|
+
readOnly: true,
|
|
453
|
+
summary: 'the dialplan applications; --used shows the ones the dialplans of the PBX use, with their parameters',
|
|
454
|
+
usage: 'wx-admin dialplan apps [--used]',
|
|
455
|
+
options: {
|
|
456
|
+
used: {
|
|
457
|
+
type: 'boolean',
|
|
458
|
+
description: 'read the dialplans of the PBX: the applications in use and their parameters',
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
args: [0, 0],
|
|
462
|
+
async run(ctx) {
|
|
463
|
+
if (!ctx.values.used) return {apps: APPS.map((name) => ({name}))};
|
|
464
|
+
const client = await pbxClient(ctx);
|
|
465
|
+
const seen = new Map();
|
|
466
|
+
for (const {id} of (await client.call('GET', '/PBX/Dialplans/', undefined, {scope: 'dialplan'}))?.records ??
|
|
467
|
+
[]) {
|
|
468
|
+
const d = await client.call('GET', dialplanPath(id), undefined, {scope: 'dialplan'});
|
|
469
|
+
for (const r of d.numbers ?? []) {
|
|
470
|
+
for (const a of r.apps ?? []) {
|
|
471
|
+
const s = seen.get(a.name) ?? {name: a.name, uses: 0, params: new Set(), example: undefined};
|
|
472
|
+
s.uses += 1;
|
|
473
|
+
for (const k of Object.keys(a.params ?? {})) s.params.add(k);
|
|
474
|
+
s.example ??= {dialplan: d.name, number: r.number, params: a.params};
|
|
475
|
+
seen.set(a.name, s);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return {apps: [...seen.values()].sort((a, b) => b.uses - a.uses).map((s) => ({...s, params: [...s.params]}))};
|
|
480
|
+
},
|
|
481
|
+
text: (r) =>
|
|
482
|
+
r.apps[0]?.uses === undefined
|
|
483
|
+
? r.apps.map((a) => a.name).join('\n')
|
|
484
|
+
: table(r.apps.map((a) => [a.name, `${a.uses}×`, a.params.join(', ')])),
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The commands of wx-admin. GROUPS: `wx-admin <group> <command>`; COMMANDS: single commands, `wx-admin <command>`.
|
|
2
|
+
// What a group and a command are: wldx-core/registry. login, logout, status and profile come from the core, shared
|
|
3
|
+
// with wx. PBX commands do not set `auth`: they choose their access themselves (src/pbx.mjs). `complete: 'pbxs'`
|
|
4
|
+
// offers the PBX with saved access. `readOnly: true` on a command that only reads lets it run with --all-pbx
|
|
5
|
+
// (READ_ONLY below sets it).
|
|
6
|
+
import {commands as api} from './api.mjs';
|
|
7
|
+
import {groups as acl} from './acl.mjs';
|
|
8
|
+
import {groups as config} from './config.mjs';
|
|
9
|
+
import {groups as device} from './device.mjs';
|
|
10
|
+
import {groups as dialplan} from './dialplan.mjs';
|
|
11
|
+
import {login, logout} from 'wldx-core/commands/login';
|
|
12
|
+
import {groups as pbx} from './pbx.mjs';
|
|
13
|
+
import {groups as phonebook} from './phonebook.mjs';
|
|
14
|
+
import {groups as profile} from 'wldx-core/commands/profile';
|
|
15
|
+
import {groups as queue} from './queue.mjs';
|
|
16
|
+
import {groups as records} from './records.mjs';
|
|
17
|
+
import {dialplanCommands, groups as routing} from './routing.mjs';
|
|
18
|
+
import {groups as sound} from './sound.mjs';
|
|
19
|
+
import status from 'wldx-core/commands/status';
|
|
20
|
+
import {groups as system} from './system.mjs';
|
|
21
|
+
import {groups as trunk} from './trunk.mjs';
|
|
22
|
+
import {groups as user} from './user.mjs';
|
|
23
|
+
|
|
24
|
+
// In the order of the help: the PBX and its people, routing, trunks and devices, content and records, access.
|
|
25
|
+
export const GROUPS = {
|
|
26
|
+
...system,
|
|
27
|
+
...user,
|
|
28
|
+
...acl,
|
|
29
|
+
...queue,
|
|
30
|
+
dialplan: {
|
|
31
|
+
...dialplan.dialplan,
|
|
32
|
+
summary: 'dialplans (procedures) and their rules, and the general dialplan settings',
|
|
33
|
+
commands: {...dialplan.dialplan.commands, ...dialplanCommands},
|
|
34
|
+
},
|
|
35
|
+
...routing,
|
|
36
|
+
...trunk,
|
|
37
|
+
...device,
|
|
38
|
+
...phonebook,
|
|
39
|
+
...sound,
|
|
40
|
+
...records,
|
|
41
|
+
...config,
|
|
42
|
+
...pbx,
|
|
43
|
+
...profile,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const COMMANDS = {...api, login, logout, status};
|
|
47
|
+
|
|
48
|
+
// The commands that only read the PBX (no change there, no file written): they may run with --all-pbx.
|
|
49
|
+
const READ_ONLY = {
|
|
50
|
+
system: ['info', 'license', 'settings', 'smtp', 'ntp', 'proxy', 'trusted-ip', 'ports', 'upgrade', 'upgrade-settings'],
|
|
51
|
+
user: ['list', 'get', 'presence', 'registrations', 'groups', 'departments'],
|
|
52
|
+
acl: ['groups', 'group', 'permissions', 'rules'],
|
|
53
|
+
queue: ['list', 'get', 'stats'],
|
|
54
|
+
dialplan: ['list', 'get', 'rules', 'apps', 'settings'],
|
|
55
|
+
timetable: ['list', 'get'],
|
|
56
|
+
ivr: ['list', 'get'],
|
|
57
|
+
switch: ['list', 'get'],
|
|
58
|
+
paging: ['list', 'get'],
|
|
59
|
+
db: ['list', 'get'],
|
|
60
|
+
trunk: ['list', 'get', 'groups', 'prices'],
|
|
61
|
+
device: ['list'],
|
|
62
|
+
phonebook: ['list', 'get', 'contacts', 'contact'],
|
|
63
|
+
sound: ['list'],
|
|
64
|
+
records: ['history', 'history-user', 'call', 'recordings', 'voicemails'],
|
|
65
|
+
pbx: ['check'],
|
|
66
|
+
};
|
|
67
|
+
for (const [group, names] of Object.entries(READ_ONLY)) {
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const cmd = GROUPS[group]?.commands[name];
|
|
70
|
+
if (!cmd) throw new Error(`READ_ONLY names a command that does not exist: ${group} ${name}`);
|
|
71
|
+
cmd.readOnly = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Its global options, taken anywhere like those of the core (--profile, --output, --pretty, --no-color).
|
|
76
|
+
export const GLOBAL_OPTIONS = {
|
|
77
|
+
pbx: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
placeholder: 'PBX',
|
|
80
|
+
needs: 'a PBX host',
|
|
81
|
+
description: 'the PBX to work on: name.wildixin.com, name, an IP, or the serial of a known PBX',
|
|
82
|
+
complete: 'pbxs',
|
|
83
|
+
},
|
|
84
|
+
'all-pbx': {type: 'boolean', description: 'run a command that reads on every PBX'},
|
|
85
|
+
};
|