wldx-cli 0.1.1 → 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,606 @@
1
+ // wx reports: the reports of the call queues (services, call groups) of the company over whole days, from the
2
+ // x-caracal service of WDA, and the list of the queues. Read only. Counts, never percentages from the service: a
3
+ // rate is computed from the counts (in the text only). A report covers one PBX: the one named by --pbx, else the
4
+ // PBX of the first --queue (its id is <company>_<pbx>_<number>), else the PBX of the login.
5
+ import {UsageError} from 'wldx-core/errors';
6
+ import {localTime, oneLine, table} from 'wldx-core/format';
7
+ import {apiPath, query} from 'wldx-core/http';
8
+ import {millis} from '../history/text.mjs';
9
+ import {callFlow, checkId, checkZone, intOption, plural, wdaClient} from '../history/wda.mjs';
10
+
11
+ export const OUTCOMES = [
12
+ 'offered',
13
+ 'received',
14
+ 'answered',
15
+ 'completed',
16
+ 'transferred',
17
+ 'abandoned',
18
+ 'unanswered',
19
+ 'lost',
20
+ 'lostAbandoned',
21
+ 'lostUnanswered',
22
+ 'overflow',
23
+ 'unknown',
24
+ ];
25
+
26
+ const FILTER_OPTIONS = {
27
+ from: {
28
+ type: 'string',
29
+ placeholder: 'YYYY-MM-DD',
30
+ description: 'the first day (today when --from and --to are left out)',
31
+ },
32
+ to: {type: 'string', placeholder: 'YYYY-MM-DD', description: 'the last day, inclusive (today by default)'},
33
+ days: {type: 'string', placeholder: 'N', description: 'the last N days, today included (1-90)'},
34
+ tz: {type: 'string', placeholder: 'ZONE', description: "the zone the days are cut in (the PBX's own by default)"},
35
+ pbx: {
36
+ type: 'string',
37
+ placeholder: 'SERIAL',
38
+ description: 'the PBX of the report (else the PBX of the first --queue, else of the login)',
39
+ },
40
+ queue: {
41
+ type: 'string',
42
+ multiple: true,
43
+ placeholder: 'ID',
44
+ description: 'only these queues (ids from wx reports list)',
45
+ },
46
+ extension: {
47
+ type: 'string',
48
+ multiple: true,
49
+ placeholder: 'EXT',
50
+ description: 'only the calls these users answered, or that rang them unanswered',
51
+ },
52
+ outcome: {type: 'string', multiple: true, choices: OUTCOMES, description: 'only calls of these outcomes'},
53
+ hours: {
54
+ type: 'string',
55
+ placeholder: 'HH:MM-HH:MM',
56
+ description: 'the time of day the call entered the queue (slow: reads every call)',
57
+ },
58
+ weekday: {type: 'string', multiple: true, placeholder: '1-7', description: '1 = Monday … 7 = Sunday (slow)'},
59
+ wait: {
60
+ type: 'string',
61
+ placeholder: 'FROM-TO',
62
+ description: 'a band of wait seconds: more than FROM, at most TO (TO may be left out) (slow)',
63
+ },
64
+ talk: {type: 'string', placeholder: 'FROM-TO', description: 'a band of talk seconds (slow)'},
65
+ reason: {
66
+ type: 'string',
67
+ multiple: true,
68
+ placeholder: 'R',
69
+ description: 'disconnect reasons: HANGUP, TRANSFER, TIMEOUT, EMPTY, EXITWITHKEY, … (slow)',
70
+ },
71
+ sla: {
72
+ type: 'string',
73
+ placeholder: 'S',
74
+ description: 'the SLA threshold in seconds, 5 to 120 in steps of 5 (20 by default)',
75
+ },
76
+ 'queue-sla': {type: 'string', multiple: true, placeholder: 'ID=S', description: 'the SLA threshold of one queue'},
77
+ };
78
+
79
+ const LADDER = {
80
+ band: {type: 'string', placeholder: 'S', description: 'the width of a band in seconds (1-3600)'},
81
+ steps: {type: 'string', placeholder: 'N', description: 'how many bands (1-60)'},
82
+ };
83
+
84
+ const pad2 = (n) => String(n).padStart(2, '0');
85
+ // The day N days before now, in the local time zone of the machine (as the script's strflocaltime).
86
+ const localDay = (now, back) => {
87
+ const d = new Date(now);
88
+ d.setDate(d.getDate() - back);
89
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
90
+ };
91
+
92
+ function checkDay(name, value) {
93
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
94
+ const d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
95
+ if (!m || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3])
96
+ throw new UsageError(`${name} must be a day as YYYY-MM-DD: ${value}`);
97
+ return value;
98
+ }
99
+
100
+ function checkPbx(value) {
101
+ if (!/^[A-Za-z0-9]{4,32}$/.test(value)) throw new UsageError(`not a PBX serial: ${value}`);
102
+ return value;
103
+ }
104
+
105
+ function band(name, value) {
106
+ const m = /^(\d{1,5})-(\d{1,5})?$/.exec(value);
107
+ if (!m) throw new UsageError(`${name} is a band of seconds as FROM-TO or FROM-: ${value}`);
108
+ return m[2] === undefined ? {from: Number(m[1])} : {from: Number(m[1]), to: Number(m[2])};
109
+ }
110
+
111
+ function slaSeconds(name, value) {
112
+ const s = intOption(name, value, 5, 120);
113
+ if (s % 5) throw new UsageError(`${name} is a multiple of 5: ${value}`);
114
+ return s;
115
+ }
116
+
117
+ // The filter of a report (and of the calls) from the options.
118
+ export function reportFilter(v, now) {
119
+ let from;
120
+ let to;
121
+ const days = intOption('--days', v.days, 1, 90);
122
+ if (days !== undefined) {
123
+ from = localDay(now, days - 1);
124
+ to = localDay(now, 0);
125
+ } else {
126
+ to = v.to ? checkDay('--to', v.to) : localDay(now, 0);
127
+ from = v.from ? checkDay('--from', v.from) : to;
128
+ }
129
+ if (from > to) throw new UsageError('--from is after --to');
130
+ const f = {date: {from, to}};
131
+ const tz = checkZone(v.tz);
132
+ if (tz) f.date.timeZone = tz;
133
+ const queues = (v.queue ?? []).map((q) => checkId('the queue id', q));
134
+ let pbx = v.pbx === undefined ? undefined : checkPbx(v.pbx);
135
+ if (!pbx && queues.length) {
136
+ const parts = queues[0].split('_');
137
+ const guess = parts.length > 1 ? parts[parts.length - 2] : '';
138
+ if (/^[A-Za-z0-9]{4,32}$/.test(guess)) pbx = guess;
139
+ }
140
+ if (pbx) f.pbx = pbx;
141
+ if (queues.length) f.serviceIds = queues;
142
+ if (v.extension?.length) {
143
+ for (const e of v.extension) if (!/^\d{1,15}$/.test(e)) throw new UsageError(`an extension is digits only: ${e}`);
144
+ f.extensions = v.extension;
145
+ }
146
+ if (v.outcome?.length) f.outcomes = v.outcome;
147
+ if (v.reason?.length) {
148
+ for (const r of v.reason) if (!/^[A-Za-z_]{1,40}$/.test(r)) throw new UsageError(`not a disconnect reason: ${r}`);
149
+ f.disconnectReasons = v.reason;
150
+ }
151
+ if (v.weekday?.length) {
152
+ for (const d of v.weekday)
153
+ if (!/^[1-7]$/.test(d)) throw new UsageError(`--weekday is 1 (Monday) to 7 (Sunday): ${d}`);
154
+ f.daysOfWeek = v.weekday.map(Number);
155
+ }
156
+ if (v.hours !== undefined) {
157
+ const m = /^((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d|24:00)$/.exec(v.hours);
158
+ if (!m) throw new UsageError(`--hours is HH:MM-HH:MM: ${v.hours}`);
159
+ f.hourRange = {from: m[1], to: m[2]};
160
+ }
161
+ if (v.wait !== undefined) f.waitSecondsRange = band('--wait', v.wait);
162
+ if (v.talk !== undefined) f.talkSecondsRange = band('--talk', v.talk);
163
+ if (v.sla !== undefined) f.slaSeconds = slaSeconds('--sla', v.sla);
164
+ if (v['queue-sla']?.length) {
165
+ f.serviceSlaSeconds = v['queue-sla'].map((x) => {
166
+ const m = /^([A-Za-z0-9_+][A-Za-z0-9_.:+-]*)=(\d{1,3})$/.exec(x);
167
+ if (!m) throw new UsageError(`--queue-sla is QUEUE_ID=SECONDS: ${x}`);
168
+ return {serviceId: m[1], slaSeconds: slaSeconds('the SLA of a queue', m[2])};
169
+ });
170
+ }
171
+ return f;
172
+ }
173
+
174
+ // The days of the report, for the heading of the text (not part of the JSON).
175
+ const withWindow = (r, filter) => Object.defineProperty(r, 'window', {value: filter.date, enumerable: false});
176
+
177
+ async function report(ctx, p, extra = {}) {
178
+ const filter = reportFilter(ctx.values, ctx.now);
179
+ const r = await ctx.client(wdaClient).post(`/xcaracal/${p}`, {filter, ...extra});
180
+ return withWindow(r, filter);
181
+ }
182
+
183
+ function ladder(v, widthKey, stepsKey) {
184
+ const o = {};
185
+ const b = intOption('--band', v.band, 1, 3600);
186
+ const s = intOption('--steps', v.steps, 1, 60);
187
+ if (b !== undefined) o[widthKey] = b;
188
+ if (s !== undefined) o[stepsKey] = s;
189
+ return o;
190
+ }
191
+
192
+ // The queues: {total, pbxs: {pbx: n}, queues: [{id, name, pbx, aclGroup?}]}, by PBX and name.
193
+ export function shapeQueues(r, {pbx, name} = {}) {
194
+ const needle = (name ?? '').toLowerCase();
195
+ const list = (r.services ?? []).filter(
196
+ (s) =>
197
+ (!pbx || s.pbx === pbx) &&
198
+ (!needle ||
199
+ String(s.name ?? '')
200
+ .toLowerCase()
201
+ .includes(needle)),
202
+ );
203
+ const pbxs = {};
204
+ for (const s of [...list].sort((a, b) => cmp(a.pbx, b.pbx))) pbxs[s.pbx] = (pbxs[s.pbx] ?? 0) + 1;
205
+ const queues = [...list]
206
+ .sort((a, b) => cmp(a.pbx, b.pbx) || cmp(a.name ?? '', b.name ?? ''))
207
+ .map((s) =>
208
+ Object.fromEntries(
209
+ Object.entries({id: s.id, name: s.name, pbx: s.pbx, aclGroup: s.aclGroup ? s.aclGroup : null}).filter(
210
+ ([, x]) => x !== null && x !== undefined,
211
+ ),
212
+ ),
213
+ );
214
+ return {total: list.length, pbxs, queues};
215
+ }
216
+
217
+ const cmp = (a, b) =>
218
+ a === b ? 0 : a === undefined || a === null ? -1 : b === undefined || b === null ? 1 : a < b ? -1 : 1;
219
+
220
+ // Customers' numbers and names are shown only with --full.
221
+ const CALLER = ['callerPhone', 'callerName'];
222
+ const dropCaller = (row) => (row ? Object.fromEntries(Object.entries(row).filter(([k]) => !CALLER.includes(k))) : row);
223
+
224
+ // Text.
225
+
226
+ const n = (x) => (Number.isFinite(x) ? String(x) : '');
227
+ const rate = (within, eligible) =>
228
+ eligible ? `${within}/${eligible} ${Math.round((100 * within) / eligible)}%` : 'no calls';
229
+ const slaText = (s) =>
230
+ s ? `${rate(s.answeredWithin ?? s.abandonedWithin ?? 0, s.eligible ?? 0)} within ${s.thresholdSeconds ?? 20} s` : '';
231
+
232
+ function heading(r) {
233
+ const w = r.window;
234
+ const days = w ? (w.from === w.to ? w.from : `${w.from} … ${w.to}`) : '';
235
+ return [days, r.pbx && `PBX ${r.pbx}`, r.timeZone].filter(Boolean).join(' · ');
236
+ }
237
+
238
+ function metricsRows(m = {}) {
239
+ const c = m.counts ?? {};
240
+ return [
241
+ ['Offered', n(c.offered)],
242
+ ['Received', n(c.received)],
243
+ [
244
+ 'Answered',
245
+ `${(c.completed ?? 0) + (c.transferred ?? 0)} (completed ${c.completed ?? 0}, transferred ${c.transferred ?? 0})`,
246
+ ],
247
+ ['Abandoned', `${c.abandoned ?? 0}${c.shortAbandoned ? ` (within 5 s: ${c.shortAbandoned})` : ''}`],
248
+ ['Unanswered', n(c.unanswered ?? 0)],
249
+ ['Lost', `${c.lost ?? 0} (abandoned ${c.lostAbandoned ?? 0}, unanswered ${c.lostUnanswered ?? 0})`],
250
+ ['Overflow', n(c.overflow ?? 0)],
251
+ ...(c.unknown ? [['Unknown', n(c.unknown)]] : []),
252
+ ['Service level', slaText(m.sla)],
253
+ ['Wait', `avg ${millis(m.avgWaitMs)}, max ${millis(m.maxWaitMs)}`],
254
+ ['Talk', `avg ${millis(m.avgTalkMs)}, total ${millis(m.totalTalkMs)}`],
255
+ ['Ring', `avg ${millis(m.avgRingMs)}`],
256
+ ['Place in line', `deepest ${m.maxStartPosition ?? 0}`],
257
+ ];
258
+ }
259
+
260
+ const METRIC_HEAD = ['Offered', 'Answered', 'Abandoned', 'Lost', 'Overflow', 'SLA', 'Avg wait', 'Max wait', 'Avg talk'];
261
+ const metricCells = (m = {}) => {
262
+ const c = m.counts ?? {};
263
+ return [
264
+ n(c.offered ?? 0),
265
+ n((c.completed ?? 0) + (c.transferred ?? 0)),
266
+ n(c.abandoned ?? 0),
267
+ n(c.lost ?? 0),
268
+ n(c.overflow ?? 0),
269
+ m.sla ? rate(m.sla.answeredWithin ?? 0, m.sla.eligible ?? 0) : '',
270
+ millis(m.avgWaitMs),
271
+ millis(m.maxWaitMs),
272
+ millis(m.avgTalkMs),
273
+ ];
274
+ };
275
+
276
+ // 1 = Monday … 7 = Sunday, as in the --weekday filter.
277
+ const ISO_WEEKDAYS = [undefined, 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
278
+ const periodLabel = (b, granularity) =>
279
+ granularity === 'dayOfWeek'
280
+ ? (ISO_WEEKDAYS[Number(b.bucketKey)] ?? b.bucketKey)
281
+ : (b.bucketKey ?? b.bucketStart ?? '');
282
+
283
+ const who = (name, ext) => [name, ext && `(${ext})`].filter(Boolean).join(' ');
284
+
285
+ function groupKey(g, dimension) {
286
+ switch (dimension) {
287
+ case 'byAgent':
288
+ return `${who(g.userName, g.userExtension) || g.agentId || '?'} · ${g.serviceName ?? g.serviceId ?? ''}`;
289
+ case 'byUser':
290
+ return who(g.userName, g.userExtension) || g.agentId || '?';
291
+ case 'byDisconnectReason':
292
+ return g.disconnectReason || '(none)';
293
+ case 'byTalkTime':
294
+ return g.band ? `${g.band.fromSeconds ?? 0}-${g.band.toSeconds ?? ''} s` : '?';
295
+ default:
296
+ return g.serviceName ?? g.serviceId ?? '?';
297
+ }
298
+ }
299
+
300
+ function callLine(c, full) {
301
+ return [
302
+ localTime(c.enteredAt, {seconds: true}),
303
+ oneLine(c.serviceName ?? c.serviceId ?? '', 30),
304
+ c.outcome ?? '',
305
+ `wait ${millis(c.waitMs)}`,
306
+ c.answered ? `talk ${millis(c.talkMs)}` : '',
307
+ c.answered ? who(c.answeredByName, c.answeredByExtension) : '',
308
+ full ? oneLine(who(c.callerName, c.callerPhone), 40) : '',
309
+ `${c.callId ?? ''} ${c.flowIndex ?? 0}`,
310
+ ];
311
+ }
312
+
313
+ const group = {
314
+ summary: 'reports of the call queues over days: service level, abandoned, lost, waits, agents',
315
+ auth: true,
316
+ commands: {
317
+ list: {
318
+ summary: 'the queues of the company: id, name, PBX (and how many on each PBX)',
319
+ usage: 'wx reports list [--name TEXT] [--pbx SERIAL]',
320
+ options: {
321
+ name: {type: 'string', placeholder: 'TEXT', description: 'only queues whose name contains this text'},
322
+ pbx: {type: 'string', placeholder: 'SERIAL', description: 'only the queues of this PBX'},
323
+ },
324
+ args: [0, 0],
325
+ async run(ctx) {
326
+ const {name, pbx} = ctx.values;
327
+ if (pbx !== undefined) checkPbx(pbx);
328
+ if (name !== undefined && name.length > 100) throw new UsageError('--name is too long');
329
+ return shapeQueues(await ctx.client(wdaClient).post('/stream/services', {}), {pbx, name});
330
+ },
331
+ text(r) {
332
+ const lines = [
333
+ `${plural(r.total, 'queue')} · ${
334
+ Object.entries(r.pbxs)
335
+ .map(([p, k]) => `PBX ${p}: ${k}`)
336
+ .join(', ') || 'no PBX'
337
+ }`,
338
+ ];
339
+ if (r.queues.length) lines.push(...table(r.queues.map((q) => [q.pbx ?? '', oneLine(q.name ?? '', 50), q.id])));
340
+ return lines;
341
+ },
342
+ },
343
+ summary: {
344
+ summary: 'every queue call of the filter: offered, answered, abandoned, lost, waits, talk, ring, the SLA counts',
345
+ usage: 'wx reports summary [filter]',
346
+ details:
347
+ 'Filter: --from/--to days or --days N (today by default; at most 90 days), --tz, --pbx, --queue, --extension, --outcome, --sla, --queue-sla; slow ones: --hours, --weekday, --wait, --talk, --reason.',
348
+ options: FILTER_OPTIONS,
349
+ args: [0, 0],
350
+ run: (ctx) => report(ctx, 'distribution/summary/query'),
351
+ text: (r) => [heading(r), ...table(metricsRows(r.metrics))],
352
+ },
353
+ 'by-queue': {
354
+ summary: 'the same figures per queue',
355
+ usage: 'wx reports by-queue [filter]',
356
+ options: FILTER_OPTIONS,
357
+ args: [0, 0],
358
+ run: (ctx) => report(ctx, 'distribution/by-service/query'),
359
+ text: (r) => [
360
+ heading(r),
361
+ ...table([
362
+ ['Queue', ...METRIC_HEAD],
363
+ ...(r.groups ?? []).map((g) => [oneLine(g.serviceName ?? g.serviceId ?? '', 40), ...metricCells(g.metrics)]),
364
+ ]),
365
+ ],
366
+ },
367
+ 'by-period': {
368
+ summary: 'the same figures as a series (hour: the 24 hours of the day over the window)',
369
+ usage: 'wx reports by-period --by hour|day|week|month|weekday [filter]',
370
+ options: {
371
+ ...FILTER_OPTIONS,
372
+ by: {
373
+ type: 'string',
374
+ choices: ['hour', 'day', 'week', 'month', 'weekday'],
375
+ description: 'the period of a bucket',
376
+ },
377
+ },
378
+ args: [0, 0],
379
+ run(ctx) {
380
+ if (!ctx.values.by) throw new UsageError('by-period needs --by hour|day|week|month|weekday', ctx.usage());
381
+ return report(ctx, 'distribution/by-period/query', {
382
+ granularity: ctx.values.by === 'weekday' ? 'dayOfWeek' : ctx.values.by,
383
+ });
384
+ },
385
+ text: (r) => [
386
+ heading(r),
387
+ ...table([
388
+ ['Period', ...METRIC_HEAD],
389
+ ...(r.buckets ?? []).map((b) => [periodLabel(b, r.granularity), ...metricCells(b.metrics)]),
390
+ ]),
391
+ ],
392
+ },
393
+ answered: {
394
+ summary: 'the answered calls: summary, or grouped by queue, agent, user, reason or talk time',
395
+ usage: 'wx reports answered [--by queue|agent|user|reason|talk-time] [--band S --steps N] [filter]',
396
+ details:
397
+ 'agent: a person in one queue; user: a person over every queue; talk-time: bands of --band seconds, --steps of them. The breakdowns read every call of the window (slow).',
398
+ options: {
399
+ ...FILTER_OPTIONS,
400
+ by: {
401
+ type: 'string',
402
+ choices: ['queue', 'agent', 'user', 'reason', 'talk-time'],
403
+ description: 'group the calls',
404
+ },
405
+ ...LADDER,
406
+ },
407
+ args: [0, 0],
408
+ run(ctx) {
409
+ const v = ctx.values;
410
+ if (v.by !== 'talk-time' && (v.band !== undefined || v.steps !== undefined))
411
+ throw new UsageError('--band and --steps go with --by talk-time');
412
+ if (!v.by) return report(ctx, 'answered/summary/query');
413
+ const dimension = {
414
+ queue: 'byService',
415
+ agent: 'byAgent',
416
+ user: 'byUser',
417
+ reason: 'byDisconnectReason',
418
+ 'talk-time': 'byTalkTime',
419
+ }[v.by];
420
+ return report(ctx, 'answered/breakdown/query', {
421
+ ...(dimension === 'byTalkTime' ? ladder(v, 'talkBandSeconds', 'talkBandSteps') : {}),
422
+ dimension,
423
+ });
424
+ },
425
+ text(r) {
426
+ if (!r.groups) {
427
+ return [
428
+ heading(r),
429
+ ...table([
430
+ [
431
+ 'Answered',
432
+ `${(r.completed ?? 0) + (r.transferred ?? 0)} (completed ${r.completed ?? 0}, transferred ${r.transferred ?? 0})`,
433
+ ],
434
+ ...(r.unknown ? [['Unknown', n(r.unknown)]] : []),
435
+ ['Service level', slaText(r.sla)],
436
+ ['Wait', `avg ${millis(r.avgWaitMs)}, max ${millis(r.maxWaitMs)}`],
437
+ ['Talk', `avg ${millis(r.avgTalkMs)}, total ${millis(r.totalTalkMs)}`],
438
+ ]),
439
+ ];
440
+ }
441
+ return [
442
+ `${heading(r)} · ${r.total ?? 0} answered`,
443
+ ...table([
444
+ ['', 'Calls', 'Completed', 'Transferred', 'Avg wait', 'Avg talk', 'Total talk'],
445
+ ...r.groups.map((g) => [
446
+ oneLine(groupKey(g, r.dimension), 60),
447
+ n(g.calls ?? 0),
448
+ n(g.completed ?? 0),
449
+ n(g.transferred ?? 0),
450
+ millis(g.avgWaitMs),
451
+ millis(g.avgTalkMs),
452
+ millis(g.totalTalkMs),
453
+ ]),
454
+ ]),
455
+ ];
456
+ },
457
+ },
458
+ unanswered: {
459
+ summary: 'the abandoned and unanswered calls: summary (lost, overflow), or grouped by queue or reason',
460
+ usage: 'wx reports unanswered [--by queue|reason] [filter]',
461
+ options: {...FILTER_OPTIONS, by: {type: 'string', choices: ['queue', 'reason'], description: 'group the calls'}},
462
+ args: [0, 0],
463
+ run(ctx) {
464
+ if (!ctx.values.by) return report(ctx, 'unanswered/summary/query');
465
+ return report(ctx, 'unanswered/breakdown/query', {
466
+ dimension: ctx.values.by === 'queue' ? 'byService' : 'byDisconnectReason',
467
+ });
468
+ },
469
+ text(r) {
470
+ if (!r.groups) {
471
+ return [
472
+ heading(r),
473
+ ...table([
474
+ ['Abandoned', `${r.abandoned ?? 0}${r.shortAbandoned ? ` (within 5 s: ${r.shortAbandoned})` : ''}`],
475
+ ['Unanswered', n(r.unanswered ?? 0)],
476
+ ['Lost', `${r.lost ?? 0} (abandoned ${r.lostAbandoned ?? 0}, unanswered ${r.lostUnanswered ?? 0})`],
477
+ ['Overflow', n(r.overflow ?? 0)],
478
+ ...(r.unknown ? [['Unknown', n(r.unknown)]] : []),
479
+ ['Given up', slaText(r.sla)],
480
+ [
481
+ 'Wait',
482
+ `avg ${millis(r.avgWaitMs)}, max ${millis(r.maxWaitMs)}, abandoned avg ${millis(r.abandonedAvgWaitMs)}`,
483
+ ],
484
+ ['Place in line', `avg start ${n(r.avgStartPosition ?? 0)}, avg final ${n(r.avgFinalPosition ?? 0)}`],
485
+ ]),
486
+ ];
487
+ }
488
+ return [
489
+ `${heading(r)} · ${r.total ?? 0} not answered`,
490
+ ...table([
491
+ ['', 'Abandoned', 'Unanswered', 'Lost', 'Overflow', 'Avg wait', 'Max wait'],
492
+ ...r.groups.map((g) => [
493
+ oneLine(groupKey(g, r.dimension), 60),
494
+ n(g.abandoned ?? 0),
495
+ n(g.unanswered ?? 0),
496
+ n(g.lost ?? 0),
497
+ n(g.overflow ?? 0),
498
+ millis(g.avgWaitMs),
499
+ millis(g.maxWaitMs),
500
+ ]),
501
+ ]),
502
+ ];
503
+ },
504
+ },
505
+ 'service-level': {
506
+ summary: 'how many calls were answered (or given up) within 20, 40, … seconds',
507
+ usage: 'wx reports service-level answered|unanswered [--band S] [--steps N] [filter]',
508
+ options: {...FILTER_OPTIONS, ...LADDER},
509
+ args: [1, 1],
510
+ run(ctx) {
511
+ const kind = ctx.positionals[0];
512
+ if (kind !== 'answered' && kind !== 'unanswered')
513
+ throw new UsageError('service-level answered|unanswered', ctx.usage());
514
+ return report(ctx, `${kind}/service-level/query`, ladder(ctx.values, 'waitBandSeconds', 'waitBandSteps'));
515
+ },
516
+ text(r, ctx) {
517
+ const unanswered = ctx.positionals[0] === 'unanswered';
518
+ const head = `${heading(r)} · ${r.total ?? 0} ${unanswered ? 'not answered' : 'answered'}${unanswered ? ` (lost ${r.lost ?? 0}, overflow ${r.overflow ?? 0})` : ''}`;
519
+ const rows = (r.bands ?? []).map((b) => [
520
+ `within ${b.withinSeconds} s`,
521
+ n(b.count),
522
+ `+${b.delta ?? 0}`,
523
+ ...(unanswered ? [`lost ${b.lost ?? 0}`, `overflow ${b.overflow ?? 0}`] : []),
524
+ ]);
525
+ return [head, ...table(rows)];
526
+ },
527
+ },
528
+ calls: {
529
+ summary: 'the queue calls one by one, newest first: queue, times, wait, talk, outcome, who answered',
530
+ usage: 'wx reports calls [--limit N] [--pages N] [--cursor C] [--call CALLID] [--full] [filter]',
531
+ details:
532
+ '50 a page (--limit up to 200); --pages follows the next pages (up to 20); --call: every queue a phone call went through. The caller (number and name) only with --full: show it when the user asks for the callers.',
533
+ options: {
534
+ ...FILTER_OPTIONS,
535
+ limit: {type: 'string', placeholder: 'N', description: 'calls per page (1-200, 50 by default)'},
536
+ pages: {type: 'string', placeholder: 'N', description: 'follow the next pages, up to N pages (1-20)'},
537
+ cursor: {type: 'string', placeholder: 'C', description: 'the next page (nextCursor of the previous answer)'},
538
+ call: {type: 'string', placeholder: 'CALLID', description: 'every queue this phone call went through'},
539
+ full: {type: 'boolean', description: "with the caller's number and name"},
540
+ },
541
+ args: [0, 0],
542
+ async run(ctx) {
543
+ const v = ctx.values;
544
+ const limit = intOption('--limit', v.limit, 1, 200);
545
+ const pages = intOption('--pages', v.pages, 1, 20) ?? 1;
546
+ if (v.cursor !== undefined && (v.cursor.length > 2048 || !/^[A-Za-z0-9_=+/.:-]+$/.test(v.cursor)))
547
+ throw new UsageError('not a cursor');
548
+ const filter = reportFilter(v, ctx.now);
549
+ const x = {};
550
+ if (limit !== undefined) x.limit = limit;
551
+ if (v.call !== undefined) x.callId = checkId('the call id', v.call);
552
+ if (v.cursor) x.cursor = v.cursor;
553
+ const wda = ctx.client(wdaClient);
554
+ let items = [];
555
+ let meta = {};
556
+ let next = '';
557
+ for (let i = 1; i <= pages; i += 1) {
558
+ const r = await wda.post('/xcaracal/services/calls/query', {filter, ...x});
559
+ items = items.concat(r.items ?? []);
560
+ meta = {timeZone: r.timeZone ?? null, pbx: r.pbx ?? null};
561
+ next = r.nextCursor ?? '';
562
+ if (!next) break;
563
+ x.cursor = next;
564
+ }
565
+ if (!v.full) items = items.map(dropCaller);
566
+ return withWindow({...meta, count: items.length, items, ...(next ? {nextCursor: next} : {})}, filter);
567
+ },
568
+ text(r, ctx) {
569
+ const lines = [`${heading(r)} · ${r.count} calls`];
570
+ if (r.items.length) lines.push(...table(r.items.map((c) => callLine(c, ctx.values.full))));
571
+ if (r.nextCursor) lines.push(`More: --cursor ${r.nextCursor}`);
572
+ return lines;
573
+ },
574
+ },
575
+ events: {
576
+ summary: 'one call in one queue: its row and its events (INIT, RING, CANCEL, ANSWER, END) with the agents rung',
577
+ usage: 'wx reports events <callId> <flowIndex> [--queue ID] [--full]',
578
+ options: {
579
+ queue: {type: 'string', placeholder: 'ID', description: 'the queue (when the call went through several)'},
580
+ full: {type: 'boolean', description: "with the caller's number and name"},
581
+ },
582
+ args: [2, 2],
583
+ async run(ctx) {
584
+ const {callId, flow} = callFlow(ctx.positionals);
585
+ const q = ctx.values.queue === undefined ? undefined : checkId('the queue id', ctx.values.queue);
586
+ const r = await ctx
587
+ .client(wdaClient)
588
+ .get(apiPath`/xcaracal/services/calls/${callId}/flows/${String(flow)}/events` + query({serviceId: q}));
589
+ return ctx.values.full ? r : {...r, call: dropCaller(r.call)};
590
+ },
591
+ text(r, ctx) {
592
+ const lines = r.call ? table([callLine(r.call, ctx.values.full)]) : [];
593
+ const rows = (r.events ?? []).map((e) => [
594
+ localTime(e.time, {seconds: true}),
595
+ e.action ?? '',
596
+ e.reason ?? '',
597
+ who(e.userName, e.userExtension),
598
+ e.queuePosition === undefined ? '' : `place ${e.queuePosition}`,
599
+ ]);
600
+ return [...lines, ...(rows.length ? table(rows).map((l) => ` ${l}`) : [' No events.'])];
601
+ },
602
+ },
603
+ },
604
+ };
605
+
606
+ export const groups = {reports: group};