wldx-admin-cli 0.0.0-stage → 1.2.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,456 @@
1
+ // wx-admin queue: the call groups (call queues) of a PBX, over the PBX REST API. The OpenAPI spreads them over three
2
+ // tags; they are one object with one id (checked live: the same ids, title = name):
3
+ // - Dialplan Call Groups, /Dialplan/CallGroups/ (scope callgroups): the configuration of WMS → Dialplan → Call
4
+ // groups: {id, title, members, settings}. members are the static ones, "101@internalcalls" ("…,2" adds a priority
5
+ // for the priority strategy). There is no GET of one group: the list is read and the group picked from it. POST
6
+ // and PUT answer "Success" without the object: create reads the list again to find the new id by its title.
7
+ // GET /Dialplan/CallGroups/{id}/stat: the counters of the queue now (members logged in, available, busy; calls waiting).
8
+ // - Call Queue, /PBX/settings/CallQueues/ (scope callgroups): the same queues as they run now: {id, name, members:
9
+ // [{name: "Local/101@internalcalls", stateInterface: "SIP/101", membership: static|dynamic, penalty, callsTaken,
10
+ // lastCall, isInCall, status (the device state), paused}]}. Dynamic members (agents who log in and out) are added
11
+ // and removed here: POST …/{id}/members/dynamic/, DELETE …/{id}/members/dynamic/{member, URI-encoded}.
12
+ // - Call Group Management, /api/v2/call-groups/{id}/ (scope pbx:*; served at https://<pbx>/api/v2, outside
13
+ // /api/v1): the same ids, for delegated managers (users with MANAGE_CALLGROUP on the ACL group of
14
+ // settings.aclGroup) who are not admins: dynamic members (--manager) and the forward of a call in the queue.
15
+ // In the settings the OpenAPI enums of autoPause (no|yes|all) and announce.holdTime (yes|no|once) are strings: a
16
+ // YAML 1.1 reader turns them into booleans.
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 = 'callgroups';
25
+ export const STRATEGIES = ['ringall', 'wringall', 'roundrobin', 'leastrecent', 'fewestcalls', 'linear', 'priority'];
26
+ // Settings the PBX gives and does not take back (readonly in the OpenAPI).
27
+ const READONLY = ['autoPauseBusy', 'autoPauseUnAvail'];
28
+ // The settings of a new group, the defaults of the OpenAPI: POST refuses a group without retry, autoPauseTime,
29
+ // autoPauseDelay and maxLen ("FieldRequired", seen live), so create fills them.
30
+ const DEFAULTS = {
31
+ strategy: 'ringall',
32
+ timeout: 60,
33
+ retry: 1,
34
+ maxLen: 5,
35
+ wrapUpTime: 0,
36
+ weight: 0,
37
+ ringInUse: false,
38
+ autoPause: 'no',
39
+ autoPauseTime: 5,
40
+ autoPauseDelay: 0,
41
+ queueManager: '',
42
+ cid: '',
43
+ rule: '',
44
+ announce: {frequency: 0, holdTime: 'yes'},
45
+ exitAllMembers: {busy: false, paused: false, ringing: false},
46
+ };
47
+ // Asterisk device states of queue members (status of /PBX/settings/CallQueues/{id}/).
48
+ const DEVICE = [
49
+ 'unknown',
50
+ 'not in use',
51
+ 'in use',
52
+ 'busy',
53
+ 'invalid',
54
+ 'unavailable',
55
+ 'ringing',
56
+ 'ringing, in use',
57
+ 'on hold',
58
+ ];
59
+
60
+ async function readJsonInput(ctx, from) {
61
+ const data = await readJsonFile(ctx, from); // no credentials, nothing hidden in the home folder
62
+ if (!data || typeof data !== 'object' || Array.isArray(data))
63
+ throw new UsageError('--from holds one JSON object {title?, members?, settings?}');
64
+ return data;
65
+ }
66
+
67
+ const listGroups = async (client) =>
68
+ (await client.call('GET', '/Dialplan/CallGroups/', undefined, {scope: SCOPE}))?.records ?? [];
69
+
70
+ // A call group by id or by title (exact, then without case).
71
+ async function findGroup(client, ref) {
72
+ const s = String(ref);
73
+ const list = await listGroups(client);
74
+ if (/^\d+$/.test(s)) {
75
+ const found = list.find((g) => String(g.id) === s);
76
+ if (!found) throw new UsageError(`No call group ${s} (wx-admin queue list)`);
77
+ return found;
78
+ }
79
+ return pickByName(list, s, (g) => g.title, 'call group', 'wx-admin queue list');
80
+ }
81
+
82
+ // The id of a queue: digits as they are (no request: a delegated manager may not read the list), else by title.
83
+ const queueId = async (client, ref) => (/^\d+$/.test(String(ref)) ? Number(ref) : (await findGroup(client, ref)).id);
84
+
85
+ // A static member: an extension ("101", "101,2" with a priority) → "101@internalcalls[,2]"; "x@context[,p]" as given.
86
+ function staticMember(spec) {
87
+ const s = String(spec).trim();
88
+ const m = /^([A-Za-z0-9_*#+.-]+)(@[A-Za-z0-9_-]+)?(,[1-9]\d*)?$/.exec(s);
89
+ if (!m) throw new UsageError(`A member is an extension, or extension@context, with ,PRIORITY if needed: ${spec}`);
90
+ return `${m[1]}${m[2] ?? '@internalcalls'}${m[3] ?? ''}`;
91
+ }
92
+
93
+ // A dynamic member (an interface of the queue): an extension → "Local/101@internalcalls"; "Local/…", "SIP/…" as given.
94
+ function dynamicMember(spec) {
95
+ const s = String(spec).trim();
96
+ if (/^\d+$/.test(s)) return `Local/${s}@internalcalls`;
97
+ if (/^(Local|SIP|PJSIP)\/[A-Za-z0-9_.+-]+(@[A-Za-z0-9_-]+)?$/.test(s)) return s;
98
+ throw new UsageError(`A dynamic member is an extension, Local/EXT@CONTEXT or SIP/EXT: ${spec}`);
99
+ }
100
+
101
+ const TITLE = /^[^\x00-\x1f\x7f/\\]{1,64}$/;
102
+
103
+ const settingOptions = {
104
+ strategy: {type: 'string', choices: STRATEGIES, description: 'how calls ring the members'},
105
+ timeout: {type: 'string', placeholder: 'SECONDS', description: 'how long a member rings (0 to 120)'},
106
+ 'wrap-up': {type: 'string', placeholder: 'SECONDS', description: 'pause of a member after a call'},
107
+ 'max-len': {type: 'string', placeholder: 'N', description: 'at most N calls waiting'},
108
+ member: {
109
+ type: 'string',
110
+ multiple: true,
111
+ placeholder: 'EXT',
112
+ description: 'a static member: an extension, EXT@CONTEXT, EXT,PRIORITY',
113
+ },
114
+ };
115
+
116
+ // The settings the options change, checked.
117
+ function settingChanges(values) {
118
+ const out = {};
119
+ if (values.strategy !== undefined) out.strategy = values.strategy;
120
+ const num = (opt, key, max) => {
121
+ if (values[opt] === undefined) return;
122
+ const n = Number(values[opt]);
123
+ if (!/^\d+$/.test(values[opt]) || (max !== undefined && n > max))
124
+ throw new UsageError(`--${opt} is a whole number${max !== undefined ? ` up to ${max}` : ''}: ${values[opt]}`);
125
+ out[key] = n;
126
+ };
127
+ num('timeout', 'timeout', 120);
128
+ num('wrap-up', 'wrapUpTime');
129
+ num('max-len', 'maxLen');
130
+ return out;
131
+ }
132
+
133
+ // What PUT takes back of a group: everything but the readonly settings.
134
+ function writable(g) {
135
+ const settings = Object.fromEntries(Object.entries(g.settings ?? {}).filter(([k]) => !READONLY.includes(k)));
136
+ if (settings.announce && typeof settings.announce === 'object') {
137
+ const {roundSeconds, ...announce} = settings.announce;
138
+ settings.announce = announce;
139
+ }
140
+ return {title: g.title, members: g.members ?? [], settings};
141
+ }
142
+
143
+ const memberExt = (m) => String(m).replace(/@internalcalls/, '');
144
+
145
+ const header = (g) => {
146
+ const s = g.settings ?? {};
147
+ const bits = [
148
+ s.strategy,
149
+ s.timeout !== undefined ? `ring ${s.timeout} s` : '',
150
+ s.wrapUpTime ? `wrap-up ${s.wrapUpTime} s` : '',
151
+ s.maxLen ? `up to ${s.maxLen} waiting` : '',
152
+ ].filter(Boolean);
153
+ return `${g.title} (id ${g.id})${bits.length ? `: ${bits.join(', ')}` : ''}`;
154
+ };
155
+
156
+ function groupLines(g, ctx) {
157
+ const c = paint(ctx);
158
+ const lines = [header(g), `members: ${g.members?.length ? g.members.map(memberExt).join(', ') : c.dim('none')}`];
159
+ if (g.liveMembers) {
160
+ lines.push(g.liveMembers.length ? 'now:' : c.dim('nobody in the queue now'));
161
+ lines.push(
162
+ ...table(
163
+ g.liveMembers.map((m) => [
164
+ ` ${m.name}`,
165
+ m.membership ?? '',
166
+ `${DEVICE[Number(m.status)] ?? String(m.status ?? '')}${m.paused === '1' || m.paused === 1 ? c.yellow(', paused') : ''}`,
167
+ `${m.callsTaken ?? 0} calls`,
168
+ ]),
169
+ ),
170
+ );
171
+ }
172
+ return lines;
173
+ }
174
+
175
+ // A request to the v2 routes of the call groups (https://<pbx>/api/v2/…): see the comment at the top.
176
+ const v2 = (client, method, apiPathOf, body) => client.call(method, apiPathOf, body, {scope: 'pbx', api: 'v2'});
177
+
178
+ export const groups = {
179
+ queue: {
180
+ summary: 'call groups (queues): list, show, create, change, delete, stats, dynamic members, forward a call',
181
+ commands: {
182
+ list: {
183
+ summary: 'the call groups of the PBX',
184
+ usage: 'wx-admin queue list',
185
+ options: {},
186
+ args: [0, 0],
187
+ readOnly: true,
188
+ async run(ctx) {
189
+ return {records: await listGroups(await pbxClient(ctx))};
190
+ },
191
+ text: (r) =>
192
+ r.records.length
193
+ ? table(
194
+ r.records.map((g) => [
195
+ g.id,
196
+ g.title,
197
+ g.settings?.strategy ?? '',
198
+ `${g.members?.length ?? 0} ${g.members?.length === 1 ? 'member' : 'members'}`,
199
+ ]),
200
+ )
201
+ : 'No call groups',
202
+ },
203
+ get: {
204
+ summary: 'one call group: settings, static members, and who is in the queue now',
205
+ usage: 'wx-admin queue get <queue>',
206
+ details:
207
+ 'A queue is its id or its name. liveMembers is the queue as it runs now (static and dynamic members, their state).',
208
+ options: {},
209
+ args: [1, 1],
210
+ readOnly: true,
211
+ async run(ctx) {
212
+ const client = await pbxClient(ctx);
213
+ const g = await findGroup(client, ctx.positionals[0]);
214
+ const q = await client.call('GET', apiPath`/PBX/settings/CallQueues/${g.id}/`, undefined, {scope: SCOPE});
215
+ return {...g, liveMembers: q?.members ?? []};
216
+ },
217
+ text: (g, ctx) => groupLines(g, ctx),
218
+ },
219
+ create: {
220
+ summary: 'create a call group',
221
+ usage:
222
+ 'wx-admin queue create <name> [--member EXT]... [--strategy S] [--timeout SECONDS] [--wrap-up SECONDS] [--max-len N] [--from FILE|-]',
223
+ details: [
224
+ '--from reads {members, settings} as JSON (as queue get --output json gives them); the options win.',
225
+ 'The group is not reachable until a dialplan sends calls to it (an application Queue).',
226
+ ].join('\n'),
227
+ options: {
228
+ ...settingOptions,
229
+ from: {
230
+ type: 'string',
231
+ placeholder: 'FILE',
232
+ description: 'members and settings as JSON (a file, or - for stdin)',
233
+ },
234
+ },
235
+ args: [1, 1],
236
+ async run(ctx) {
237
+ const title = ctx.positionals[0];
238
+ if (!TITLE.test(title))
239
+ throw new UsageError(`A call group name is 1 to 64 characters, without / and \\: ${title}`);
240
+ const src = ctx.values.from ? await readJsonInput(ctx, ctx.values.from) : {};
241
+ const members = (ctx.values.member ?? src.members ?? []).map(staticMember);
242
+ const body = {
243
+ title,
244
+ members,
245
+ settings: {...DEFAULTS, ...(src.settings ?? {}), ...settingChanges(ctx.values)},
246
+ };
247
+ const client = await pbxClient(ctx);
248
+ const before = await listGroups(client);
249
+ if (before.some((g) => g.title === title))
250
+ throw new UsageError(`A call group ${title} exists: choose another name`);
251
+ await client.call('POST', '/Dialplan/CallGroups/', body, {scope: SCOPE});
252
+ const made = (await listGroups(client)).filter(
253
+ (g) => g.title === title && !before.some((b) => b.id === g.id),
254
+ );
255
+ if (!made.length) throw new Error(`The PBX answered Success, but no call group ${title} is in its list`);
256
+ return made.at(-1);
257
+ },
258
+ text: (g, ctx) => [`Created ${header(g)}`, ...groupLines(g, ctx).slice(1)],
259
+ },
260
+ update: {
261
+ summary: 'change a call group: name, settings, static members (--member replaces them)',
262
+ usage:
263
+ 'wx-admin queue update <queue> [--name NAME] [--member EXT]... [--no-members] [--strategy S] [--timeout SECONDS] [--wrap-up SECONDS] [--max-len N] [--from FILE|-] [--yes]',
264
+ details:
265
+ 'The group is read and written back whole; --from holds {title?, members?, settings?} (settings are merged).',
266
+ options: {
267
+ name: {type: 'string', placeholder: 'NAME', description: 'the new name'},
268
+ ...settingOptions,
269
+ 'no-members': {type: 'boolean', description: 'remove every static member'},
270
+ from: {
271
+ type: 'string',
272
+ placeholder: 'FILE',
273
+ description: 'title, members and settings as JSON (a file, or - for stdin)',
274
+ },
275
+ yes: {
276
+ type: 'boolean',
277
+ description: 'yes, replace the static members (--member, --no-members, members in --from)',
278
+ },
279
+ },
280
+ args: [1, 1],
281
+ async run(ctx) {
282
+ const v = ctx.values;
283
+ const changes = settingChanges(v);
284
+ if (v.name === undefined && !v.member && !v['no-members'] && !v.from && !Object.keys(changes).length)
285
+ throw new UsageError('Nothing to change', ctx.usage());
286
+ if (v.member && v['no-members']) throw new UsageError('--member or --no-members, not both');
287
+ if (v.name !== undefined && !TITLE.test(v.name))
288
+ throw new UsageError(`A call group name is 1 to 64 characters, without / and \\: ${v.name}`);
289
+ const src = v.from ? await readJsonInput(ctx, v.from) : {};
290
+ if ((v.member || v['no-members'] || src.members !== undefined) && !v.yes)
291
+ throw new UsageError(
292
+ 'Replacing the static members changes who the queue rings: confirm with --yes',
293
+ ctx.usage(),
294
+ );
295
+ const client = await pbxClient(ctx);
296
+ const current = await findGroup(client, ctx.positionals[0]);
297
+ const body = writable(current);
298
+ if (src.title !== undefined) body.title = src.title;
299
+ if (v.name !== undefined) body.title = v.name;
300
+ // The members the PBX has stay as they are; new ones are checked.
301
+ if (src.members !== undefined) body.members = src.members.map(staticMember);
302
+ if (v.member) body.members = v.member.map(staticMember);
303
+ if (v['no-members']) body.members = [];
304
+ body.settings = {...body.settings, ...(src.settings ?? {}), ...changes};
305
+ await client.call('PUT', apiPath`/Dialplan/CallGroups/${current.id}/`, body, {scope: SCOPE});
306
+ return {...body, id: current.id};
307
+ },
308
+ text: (g, ctx) => [`Updated ${header(g)}`, ...groupLines(g, ctx).slice(1)],
309
+ },
310
+ delete: {
311
+ summary: 'delete call groups (needs --yes: calls sent to them stop)',
312
+ usage: 'wx-admin queue delete <queue>... --yes',
313
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
314
+ args: [1, 50],
315
+ async run(ctx) {
316
+ if (!ctx.values.yes)
317
+ throw new UsageError(
318
+ 'Deleting a call group stops the calls the dialplans send to it: confirm with --yes',
319
+ ctx.usage(),
320
+ );
321
+ const client = await pbxClient(ctx);
322
+ const found = [];
323
+ for (const ref of ctx.positionals) found.push(await findGroup(client, ref));
324
+ const deleted = [];
325
+ for (const g of found) {
326
+ await client.call('DELETE', apiPath`/Dialplan/CallGroups/${g.id}/`, undefined, {scope: SCOPE});
327
+ deleted.push({id: g.id, title: g.title});
328
+ }
329
+ return {deleted};
330
+ },
331
+ text: (r) => r.deleted.map((g) => `Deleted ${g.title} (id ${g.id})`),
332
+ },
333
+ stats: {
334
+ summary: 'the queues now: members logged in, available, busy; calls waiting (all, or the ones given)',
335
+ usage: 'wx-admin queue stats [<queue>...]',
336
+ options: {},
337
+ args: [0, 50],
338
+ readOnly: true,
339
+ async run(ctx) {
340
+ const client = await pbxClient(ctx);
341
+ const ids = [];
342
+ if (!ctx.positionals.length) for (const g of await listGroups(client)) ids.push(g.id);
343
+ for (const ref of ctx.positionals) ids.push(await queueId(client, ref));
344
+ const stats = [];
345
+ for (const id of ids)
346
+ stats.push(await client.call('GET', apiPath`/Dialplan/CallGroups/${id}/stat`, undefined, {scope: SCOPE}));
347
+ return {stats};
348
+ },
349
+ text(r, ctx) {
350
+ if (!r.stats.length) return 'No call groups';
351
+ const c = paint(ctx);
352
+ return table([
353
+ [
354
+ c.dim('id'),
355
+ c.dim('queue'),
356
+ c.dim('members'),
357
+ c.dim('logged in'),
358
+ c.dim('available'),
359
+ c.dim('busy'),
360
+ c.dim('waiting'),
361
+ ],
362
+ ...r.stats.map((s) => [
363
+ s.id,
364
+ s.title,
365
+ s.totalMembers,
366
+ s.loggedMembers,
367
+ s.availableMembers,
368
+ s.busyMembers,
369
+ s.waitingCalls ? c.yellow(s.waitingCalls) : s.waitingCalls,
370
+ ]),
371
+ ]);
372
+ },
373
+ },
374
+ 'member-add': {
375
+ summary: 'add a dynamic member (an agent) to a queue, at once, until removed',
376
+ usage: 'wx-admin queue member-add <queue> <member> [--penalty N] [--paused] [--manager]',
377
+ details: [
378
+ 'A member is an extension (Local/EXT@internalcalls), or an interface: Local/EXT@CONTEXT, SIP/EXT.',
379
+ '--manager uses the API of the delegated managers (MANAGE_CALLGROUP on the ACL group of the queue; scope pbx:*):',
380
+ 'for a user who manages the queue without being an admin.',
381
+ ].join('\n'),
382
+ options: {
383
+ penalty: {type: 'string', placeholder: 'N', description: 'the penalty of the member (higher rings later)'},
384
+ paused: {type: 'boolean', description: 'add the member paused'},
385
+ manager: {type: 'boolean', description: 'as a delegated manager of the queue (the /api/v2 routes)'},
386
+ },
387
+ args: [2, 2],
388
+ async run(ctx) {
389
+ const v = ctx.values;
390
+ const member = dynamicMember(ctx.positionals[1]);
391
+ if (v.penalty !== undefined && !/^\d+$/.test(v.penalty))
392
+ throw new UsageError(`--penalty is a whole number: ${v.penalty}`);
393
+ const body = {
394
+ member,
395
+ ...(v.penalty !== undefined ? {penalty: Number(v.penalty)} : {}),
396
+ ...(v.paused ? {paused: 1} : {}),
397
+ };
398
+ const client = await pbxClient(ctx);
399
+ const id = await queueId(client, ctx.positionals[0]);
400
+ const r = v.manager
401
+ ? await v2(client, 'POST', apiPath`/call-groups/${id}/members/dynamic/`, body)
402
+ : await client.call('POST', apiPath`/PBX/settings/CallQueues/${id}/members/dynamic/`, body, {scope: SCOPE});
403
+ return {queue: id, member, result: r};
404
+ },
405
+ text: (r) =>
406
+ `Added ${r.member} to the queue ${r.queue}${r.result?.Message ? ` (${oneLine(r.result.Message, 80)})` : ''}`,
407
+ },
408
+ 'member-remove': {
409
+ summary: 'remove a dynamic member (an agent) from a queue',
410
+ usage: 'wx-admin queue member-remove <queue> <member> [--manager]',
411
+ details: 'Static members (the ones of the configuration) are changed with queue update --member.',
412
+ options: {manager: {type: 'boolean', description: 'as a delegated manager of the queue (the /api/v2 routes)'}},
413
+ args: [2, 2],
414
+ async run(ctx) {
415
+ const member = dynamicMember(ctx.positionals[1]);
416
+ const client = await pbxClient(ctx);
417
+ const id = await queueId(client, ctx.positionals[0]);
418
+ const encoded = encodeURIComponent(member);
419
+ const r = ctx.values.manager
420
+ ? await v2(client, 'DELETE', `${apiPath`/call-groups/${id}/members/dynamic/`}${encoded}`)
421
+ : await client.call(
422
+ 'DELETE',
423
+ `${apiPath`/PBX/settings/CallQueues/${id}/members/dynamic/`}${encoded}`,
424
+ undefined,
425
+ {scope: SCOPE},
426
+ );
427
+ return {queue: id, member, result: r};
428
+ },
429
+ text: (r) =>
430
+ `Removed ${r.member} from the queue ${r.queue}${r.result?.Message ? ` (${oneLine(r.result.Message, 80)})` : ''}`,
431
+ },
432
+ forward: {
433
+ summary: 'forward a call waiting in a queue to an extension or a number',
434
+ usage: 'wx-admin queue forward <queue> <sip-call-id> <destination>',
435
+ details: [
436
+ 'The call is the SIP Call-ID of a call in the queue now (as the call events of the PBX name it).',
437
+ 'It uses the API of the delegated managers (MANAGE_CALLGROUP on the ACL group of the queue; scope pbx:*).',
438
+ ].join('\n'),
439
+ options: {},
440
+ args: [3, 3],
441
+ async run(ctx) {
442
+ const [ref, sipCallId, destination] = ctx.positionals;
443
+ if (!/^[^\s]{1,100}$/.test(sipCallId)) throw new UsageError(`Not a SIP Call-ID: ${sipCallId}`);
444
+ if (!/^\+?[0-9*#]{1,100}$/.test(destination))
445
+ throw new UsageError(`The destination is an extension or a number: ${destination}`);
446
+ const client = await pbxClient(ctx);
447
+ const id = await queueId(client, ref);
448
+ const r = await v2(client, 'POST', apiPath`/call-groups/${id}/forward/`, {sipCallId, destination});
449
+ return {queue: id, sipCallId, destination, result: r};
450
+ },
451
+ text: (r) =>
452
+ `Forwarded the call to ${r.destination}${r.result?.message ? ` (${oneLine(r.result.message, 80)})` : ''}`,
453
+ },
454
+ },
455
+ },
456
+ };