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,391 @@
1
+ // What the live commands print, from the items of the WDA live stream: short views by default, the raw items with
2
+ // `full`. Ported from live.mjs of the wildix-live-monitor skill (same fields, same order, same filters), as pure
3
+ // functions: the commands in src/commands/live.mjs only connect, subscribe and hand the items over.
4
+ import {agentFigures, queueFigures, sumStats} from './stats.mjs';
5
+
6
+ export const drop = (o) => JSON.parse(JSON.stringify(o)); // removes the undefined fields
7
+
8
+ const isPaused = (a) => a?.paused === 'true' || a?.paused === true;
9
+
10
+ // The view functions bound to one run: full (raw items), company and extension of the login, the clock.
11
+ export function makeViews({full = false, company, myExtension, now = () => Date.now()} = {}) {
12
+ // Left out unless full: e-mail, public IP address, geographic location and user agent of people.
13
+ function party(p) {
14
+ if (!p) return undefined;
15
+ if (full) return p;
16
+ return drop({
17
+ name: p.name,
18
+ phone: p.phone,
19
+ extension: p.userExtension,
20
+ department: p.userDepartment,
21
+ group: p.groupName,
22
+ type: p.type,
23
+ role: p.role,
24
+ device: p.userDevice,
25
+ sipCallId: p.sipCallId,
26
+ });
27
+ }
28
+
29
+ function flowView(f) {
30
+ return drop({
31
+ flowIndex: f.flowIndex,
32
+ status: f.status,
33
+ direction: f.direction,
34
+ caller: party(f.caller),
35
+ callee: party(f.callee),
36
+ destination: f.destination,
37
+ queue: f.queue ?? f.service,
38
+ start: f.startTime ? new Date(f.startTime).toISOString() : undefined,
39
+ talkSeconds: f.talkTime ? Math.round(f.talkTime / 1000) : undefined,
40
+ waitSeconds: f.waitTime ? Math.round(f.waitTime / 1000) : undefined,
41
+ holdSeconds: f.holdTime ? Math.round(f.holdTime / 1000) : undefined,
42
+ recording: f.recordings?.length ? true : undefined,
43
+ transcription: f.transcriptionStatus,
44
+ tags: f.tags?.length ? f.tags : undefined,
45
+ });
46
+ }
47
+
48
+ function insights(a) {
49
+ if (!a) return undefined;
50
+ const v = (k) => a[`copilot.${k}`]?.payload?.metric?.value;
51
+ return drop({sentiment: v('sentiment'), csat: v('csat'), topics: v('topics'), signals: v('signals')});
52
+ }
53
+
54
+ function conversationView(c) {
55
+ if (full) return c;
56
+ const base = {id: c.id, type: c.event, trigger: c.eventTrigger, start: c.start};
57
+ if (c.event === 'call') return drop({...base, flows: (Array.isArray(c.flows) ? c.flows : []).map(flowView)});
58
+ if (c.event === 'chat') {
59
+ return drop({
60
+ ...base,
61
+ status: c.status,
62
+ subject: c.subject,
63
+ direction: c.direction,
64
+ channelId: c.channelId,
65
+ customers: c.customers?.map((p) => drop({name: p.name, company: p.company})),
66
+ agents: c.agents?.map((p) => drop({name: p.name, bot: p.bot || undefined})),
67
+ tags: c.tags?.length ? c.tags : undefined,
68
+ insights: insights(c.annotationsByKey),
69
+ });
70
+ }
71
+ if (c.event === 'conference') {
72
+ return drop({
73
+ ...base,
74
+ subject: c.subject,
75
+ participants: c.participants?.map((p) => drop({name: p.name, role: p.role, type: p.type})),
76
+ insights: insights(c.annotationsByKey),
77
+ });
78
+ }
79
+ // completions and annotations: say what ended or changed, not more
80
+ return drop({
81
+ ...base,
82
+ flows: typeof c.flows === 'number' ? c.flows : undefined,
83
+ key: c.key,
84
+ insights: insights(c.annotationsByKey),
85
+ });
86
+ }
87
+
88
+ function agentView(a, stats) {
89
+ if (full) return stats ? {...a, stats: agentFigures(stats)} : a;
90
+ return drop({
91
+ name: a.name,
92
+ extension: a.userExtension,
93
+ department: a.department,
94
+ status: a.status,
95
+ paused: isPaused(a) || undefined,
96
+ reason: a.reason || undefined,
97
+ message: a.message || undefined,
98
+ stats: stats ? agentFigures(stats) : undefined,
99
+ });
100
+ }
101
+
102
+ function queueView(s) {
103
+ const agentStats = s.stats?.agentStats ?? {};
104
+ if (full) return s.stats ? {...s, figures: queueFigures(s.stats, now())} : s;
105
+ return drop({
106
+ id: s.id,
107
+ name: s.name,
108
+ pbx: s.pbx,
109
+ strategy: s.strategy,
110
+ maxCalls: s.maxCalls ? Number(s.maxCalls) : undefined,
111
+ calls: s.calls?.length ?? 0,
112
+ callIds: s.calls?.length ? s.calls : undefined,
113
+ stats: queueFigures(s.stats, now()),
114
+ agents: s.agents?.map((a) => agentView(a, s.stats ? agentStats[a.id] : undefined)),
115
+ });
116
+ }
117
+
118
+ function serviceCallView(c) {
119
+ if (full) return c;
120
+ return drop({
121
+ queueId: c.id,
122
+ trigger: c.eventTrigger,
123
+ callId: c.callId,
124
+ direction: c.direction,
125
+ customer: c.customer?.phone ?? c.customer?.name,
126
+ start: c.startTime ? new Date(c.startTime).toISOString() : undefined,
127
+ agents: c.relatedAgents?.length,
128
+ });
129
+ }
130
+
131
+ function presenceView(p) {
132
+ if (full) return p;
133
+ return drop({
134
+ extension: p.extension,
135
+ company: p.company !== company ? p.company : undefined,
136
+ status: p.status,
137
+ message: p.message || undefined,
138
+ until: p.until || undefined,
139
+ phone: p.telephony,
140
+ });
141
+ }
142
+
143
+ function textView(chunk, call) {
144
+ const flow = Array.isArray(call?.flows) ? call.flows.find((f) => f.flowIndex === chunk.flowIndex) : undefined;
145
+ const who = chunk.speaker === 'caller' ? flow?.caller : chunk.speaker === 'callee' ? flow?.callee : undefined;
146
+ return drop({
147
+ flow: chunk.flowIndex,
148
+ speaker: chunk.speaker ?? chunk.participant?.name,
149
+ name: who?.name ?? chunk.participant?.name ?? chunk.name,
150
+ text: chunk.text,
151
+ final: chunk.isFinal,
152
+ at: chunk.start ?? chunk.time,
153
+ language: chunk.language,
154
+ sentiment: chunk.sentiment,
155
+ });
156
+ }
157
+
158
+ const isMineCall = (c) =>
159
+ Boolean(myExtension) &&
160
+ Array.isArray(c.flows) &&
161
+ c.flows.some((f) => f.caller?.userExtension === myExtension || f.callee?.userExtension === myExtension);
162
+
163
+ return {
164
+ full,
165
+ company,
166
+ myExtension,
167
+ now,
168
+ party,
169
+ flowView,
170
+ insights,
171
+ conversationView,
172
+ agentView,
173
+ queueView,
174
+ serviceCallView,
175
+ presenceView,
176
+ textView,
177
+ isMineCall,
178
+ };
179
+ }
180
+
181
+ // The last item of every id, in the order of their first appearance.
182
+ export const lastById = (items) => {
183
+ const m = new Map();
184
+ for (const it of items) if (it?.id) m.set(it.id, it);
185
+ return [...m.values()];
186
+ };
187
+
188
+ // Transcription items: a stored transcript ({flowIndex, chunks: {id: chunk}}), a live line ({chunk}), or a
189
+ // conference transcript; each becomes chunks with the flow index kept.
190
+ export function chunksOf(item) {
191
+ if (item?.chunk) return [{...item.chunk, flowIndex: item.flowIndex, participant: item.participant}];
192
+ if (item?.chunks && typeof item.chunks === 'object')
193
+ return Object.values(item.chunks).map((c) => ({...c, flowIndex: item.flowIndex}));
194
+ return [];
195
+ }
196
+
197
+ // `queues`: the snapshot of the services topic, filtered and sorted, the queues with calls first.
198
+ // filters: {pbx, name, busy, paused, active}; stats: whether the statistics were asked for
199
+ export function queuesResult(items, {pbx, name, busy, paused, active, stats} = {}, views = makeViews()) {
200
+ let qs = lastById(items.filter((i) => i?.event === 'service'));
201
+ if (pbx) qs = qs.filter((q) => q.pbx === pbx);
202
+ const text = name?.toLowerCase();
203
+ if (text) qs = qs.filter((q) => q.name?.toLowerCase().includes(text));
204
+ if (busy) qs = qs.filter((q) => q.calls?.length);
205
+ if (paused) qs = qs.filter((q) => q.agents?.some(isPaused));
206
+ if (active) qs = qs.filter((q) => q.calls?.length || q.stats?.offered || q.stats?.outbound);
207
+ qs.sort(
208
+ (a, b) =>
209
+ (b.calls?.length ?? 0) - (a.calls?.length ?? 0) ||
210
+ (b.stats?.offered ?? 0) - (a.stats?.offered ?? 0) ||
211
+ String(a.name).localeCompare(String(b.name)),
212
+ );
213
+ const pbxs = [...new Set(qs.map((q) => q.pbx))];
214
+ return drop({
215
+ total: qs.length,
216
+ callsWaitingOrTalking: qs.reduce((n, q) => n + (q.calls?.length ?? 0), 0),
217
+ pbxs: pbxs.length > 1 ? Object.fromEntries(pbxs.map((p) => [p, qs.filter((q) => q.pbx === p).length])) : undefined,
218
+ stats: stats ? queueFigures(sumStats(qs.map((q) => q.stats)), views.now()) : undefined,
219
+ queues: qs.map(views.queueView),
220
+ });
221
+ }
222
+
223
+ // `conversations`: the calls, chats and conferences of the snapshot, newest first.
224
+ // kinds: ['call', 'chat', 'conference'] to keep (none: all); mine; status; limit (50)
225
+ export function conversationsResult(items, {kinds = [], mine, status, limit = 50} = {}, views = makeViews()) {
226
+ let cs = lastById(items.filter((i) => ['call', 'chat', 'conference'].includes(i?.event)));
227
+ const byType = {};
228
+ for (const c of cs) byType[c.event] = (byType[c.event] ?? 0) + 1;
229
+ if (kinds.length) cs = cs.filter((c) => kinds.includes(c.event));
230
+ if (mine) cs = cs.filter(views.isMineCall);
231
+ const want = status?.toUpperCase();
232
+ if (want)
233
+ cs = cs.filter(
234
+ (c) =>
235
+ String(c.status ?? (Array.isArray(c.flows) ? c.flows.at(-1)?.status : undefined) ?? '').toUpperCase() === want,
236
+ );
237
+ cs.sort((a, b) => String(b.start).localeCompare(String(a.start)));
238
+ return {
239
+ total: cs.length,
240
+ byType,
241
+ shown: Math.min(limit, cs.length),
242
+ items: cs.slice(0, limit).map(views.conversationView),
243
+ };
244
+ }
245
+
246
+ // `presence`: one entry per extension asked for, in the order given; `status: unknown` for nobody.
247
+ export function presenceResult(items, extensions, views = makeViews()) {
248
+ const found = new Map(items.filter(Boolean).map((p) => [String(p.extension), p]));
249
+ return {
250
+ presence: extensions.map((ext) =>
251
+ found.has(ext) ? views.presenceView(found.get(ext)) : {extension: ext, status: 'unknown'},
252
+ ),
253
+ };
254
+ }
255
+
256
+ // The WDA id of a call from the conversations snapshot, by the SIP call id of one of its parties.
257
+ export function callIdBySip(items, sipCallId) {
258
+ const call = items.find(
259
+ (c) =>
260
+ c?.event === 'call' &&
261
+ Array.isArray(c.flows) &&
262
+ c.flows.some((f) => f.caller?.sipCallId === sipCallId || f.callee?.sipCallId === sipCallId),
263
+ );
264
+ return call?.id;
265
+ }
266
+
267
+ // `transcript`: what was said so far (final lines only), the call and its annotations.
268
+ export function transcriptSnapshot(items, id, kind, views = makeViews()) {
269
+ const call = items.find((i) => i?.event === 'call' || i?.event === 'conference');
270
+ const lines = items
271
+ .flatMap(chunksOf)
272
+ .filter((c) => c.text)
273
+ .sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
274
+ const annotations = items.filter((i) => String(i?.event).endsWith('_annotation'));
275
+ return {
276
+ call,
277
+ snapshot: drop({
278
+ id,
279
+ type: kind,
280
+ call: call ? views.conversationView(call) : undefined,
281
+ transcript: lines.filter((c) => c.isFinal !== false).map((c) => views.textView(c, call)),
282
+ annotations: annotations.length ? annotations.map((a) => drop({key: a.key, payload: a.payload})) : undefined,
283
+ }),
284
+ };
285
+ }
286
+
287
+ // `transcript --follow`: the events of one broadcast item. state.call is the call last seen (names the speakers).
288
+ // Returns {events: [{event, data}], end: boolean}.
289
+ export function transcriptEvents(it, id, state, views = makeViews()) {
290
+ if (!it) return {events: [], end: false};
291
+ if (it.event === 'call' || it.event === 'conference') {
292
+ state.call = it;
293
+ return {events: [{event: 'CALL', data: views.conversationView(it)}], end: false};
294
+ }
295
+ if (/_complete$|_interrupted$/.test(String(it.event)))
296
+ return {events: [{event: 'END', data: {id, event: it.event}}], end: true};
297
+ if (String(it.event).endsWith('_annotation'))
298
+ return {events: [{event: 'ANNOTATION', data: drop({key: it.key, payload: it.payload})}], end: false};
299
+ return {
300
+ events: chunksOf(it)
301
+ .filter((c) => c.text)
302
+ .map((c) => ({event: 'TEXT', data: views.textView(c, state.call)})),
303
+ end: false,
304
+ };
305
+ }
306
+
307
+ // `watch`: turns the frames of the connection into events. o: {views, wantConv, wantQueues, wantStats, calls, mine,
308
+ // statsOpts(), explain(topic, d)}. frame(d) returns {events: [{event, data}], fatal?: message}; reset() is called
309
+ // on every (re)connection (with stats the queues are read again before QUEUE_STATS lines).
310
+ export function watchFrames(o) {
311
+ const {views} = o;
312
+ const full = views.full;
313
+ const queueNames = new Map();
314
+ const mineIds = new Set();
315
+ let queuesReady = !o.wantStats;
316
+ const ev = (event, data) => ({event, data: data ?? {}});
317
+
318
+ function frame(d) {
319
+ const events = [];
320
+ if (d.event === 'error') {
321
+ events.push(ev('ERROR', {message: o.explain(d.source?.topic ?? 'subscription', d), code: d.code}));
322
+ return d.code === 403 ? {events, fatal: events[0].data.message} : {events};
323
+ }
324
+ if (d.event === 'subscribed' && d.topic === 'services' && !queuesReady) {
325
+ queuesReady = true;
326
+ events.push(ev('QUEUES', {queues: queueNames.size, stats: o.statsOpts()}));
327
+ return {events};
328
+ }
329
+ if (d.event !== 'broadcast') return {events};
330
+ for (const it of Array.isArray(d.data) ? d.data : [d.data]) {
331
+ if (!it || typeof it !== 'object') continue;
332
+ if (d.topic === 'presence') events.push(ev('PRESENCE', views.presenceView(it)));
333
+ else if (d.topic === 'services') {
334
+ if (it.event === 'service' && it.name) queueNames.set(it.id, it.name);
335
+ if (!queuesReady) continue;
336
+ if (it.event === 'service_stats_update') {
337
+ if (!o.wantStats) continue;
338
+ const agentStats = it.stats?.agentStats ?? {};
339
+ events.push(
340
+ ev(
341
+ 'QUEUE_STATS',
342
+ full
343
+ ? it
344
+ : drop({
345
+ id: it.id,
346
+ name: queueNames.get(it.id),
347
+ ...queueFigures(it.stats, views.now()),
348
+ agents: Object.keys(agentStats).length
349
+ ? Object.fromEntries(
350
+ Object.entries(agentStats).map(([k, v]) => [
351
+ k.startsWith(`${it.id}_`) ? k.slice(String(it.id).length + 1) : k,
352
+ agentFigures(v),
353
+ ]),
354
+ )
355
+ : undefined,
356
+ }),
357
+ ),
358
+ );
359
+ continue;
360
+ }
361
+ // A change names what changed (eventTrigger) and carries only that: usually one agent, no queue name.
362
+ if (it.event === 'service')
363
+ events.push(ev('QUEUE', full ? it : drop({trigger: it.eventTrigger, ...views.queueView(it)})));
364
+ else if (it.event === 'serviceCall' || it.event === 'service_call')
365
+ events.push(ev('QUEUE_CALL', views.serviceCallView(it)));
366
+ else events.push(ev(String(it.event).toUpperCase(), full ? it : drop({id: it.id, trigger: it.eventTrigger})));
367
+ } else if (d.topic === 'conversations') {
368
+ const type = String(it.event);
369
+ if (o.calls && !type.startsWith('call')) continue;
370
+ if (o.mine) {
371
+ if (type === 'call' && views.isMineCall(it)) mineIds.add(it.id);
372
+ if (!mineIds.has(it.id)) continue;
373
+ // The skill forgot the call at any other event (an annotation too); here only when it ends.
374
+ if (/_complete$|_interrupted$/.test(type)) mineIds.delete(it.id);
375
+ }
376
+ events.push(ev(type.toUpperCase(), views.conversationView(it)));
377
+ }
378
+ }
379
+ return {events};
380
+ }
381
+
382
+ return {
383
+ frame,
384
+ reset() {
385
+ queuesReady = !o.wantStats;
386
+ },
387
+ get queueNames() {
388
+ return queueNames;
389
+ },
390
+ };
391
+ }