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,243 @@
1
+ // Queue statistics of the live stream of Wildix Data Analytics (WDA): the subscribe options that ask for them and
2
+ // the figures derived from the raw counters. Pure functions, no I/O (ported unchanged from the stats.mjs of the
3
+ // wildix-live-monitor skill, which its dashboard shares).
4
+ //
5
+ // The services topic sends counters, never rates: with stats asked for, every service of the subscribe snapshot
6
+ // carries `stats`, and later changes come as `service_stats_update` events (at most one per 5 seconds per queue,
7
+ // and at each 15-minute bucket roll-over). Counters of a window are added when a call progresses: offered when it
8
+ // enters the queue, answered when it is answered, the rest (abandoned, unanswered, talk, wrap-up) when it ends.
9
+
10
+ export const SLA_SECONDS = [5, 10, 15, 20, 25, 30];
11
+ export const WINDOWS = ['today', '15', '30', '45', '60'];
12
+
13
+ const round = (n, d = 0) =>
14
+ n === null || n === undefined || !Number.isFinite(n) ? null : Math.round(n * 10 ** d) / 10 ** d;
15
+ const ratio = (a, b) => (b ? a / b : null);
16
+ const pct = (a, b) => round(b ? (a / b) * 100 : null, 1);
17
+ const sec = (ms) => round(ms === null || ms === undefined ? null : ms / 1000);
18
+ const min = (ms) => round(ms === null || ms === undefined ? null : ms / 60000, 1);
19
+ const iso = (ms) => (typeof ms === 'number' ? new Date(ms).toISOString() : undefined);
20
+
21
+ // The subscribe options of a stats window: `today` from the local midnight (WDA keeps hourly UTC buckets, so the
22
+ // day must start on a whole UTC hour: in a zone with a half-hour offset the window starts at the UTC hour before
23
+ // the local midnight), or the last 15, 30, 45 or 60 minutes. sla: the answer-within target, one of SLA_SECONDS.
24
+ export function statsOptions(window = 'today', sla = 20, now = Date.now()) {
25
+ const base = {stats: true, serviceLevelSeconds: sla};
26
+ if (window !== 'today') return {...base, periodMinutes: Number(window)};
27
+ const offset = -new Date(now).getTimezoneOffset();
28
+ if (offset % 60 === 0) return {...base, periodStart: 'today', utcOffsetMinutes: offset};
29
+ const midnight = new Date(now);
30
+ midnight.setHours(0, 0, 0, 0);
31
+ return {...base, periodStartAt: Math.floor(midnight.getTime() / 3600000) * 3600000};
32
+ }
33
+
34
+ // The figures of one queue's stats (or of the sum of several, see sumStats). Rates follow the definitions of the
35
+ // service: service level = answeredWithinSl / (answered + abandoned - shortAbandoned + unanswered), short abandons
36
+ // (within 5 s, likely misdials) left out of the base; ASA = answeredWaitSumMs / answered.
37
+ export function queueFigures(s, now = Date.now()) {
38
+ if (!s) return undefined;
39
+ const base = s.answered + (s.abandoned - s.shortAbandoned) + s.unanswered;
40
+ return {
41
+ window: {from: iso(s.windowStartedAt), minutes: round(s.periodSeconds / 60)},
42
+ offered: s.offered,
43
+ answered: s.answered,
44
+ abandoned: s.abandoned,
45
+ shortAbandoned: s.shortAbandoned,
46
+ unanswered: s.unanswered,
47
+ transferred: s.transferred,
48
+ unknown: s.unknown,
49
+ outbound: s.outbound,
50
+ serviceLevel: pct(s.answeredWithinSl, base),
51
+ slaSeconds: s.serviceLevelSeconds,
52
+ answeredWithinSla: s.answeredWithinSl,
53
+ abandonRate: pct(s.abandoned - s.shortAbandoned, base),
54
+ asaSeconds: sec(ratio(s.answeredWaitSumMs, s.answered)),
55
+ avgTalkSeconds: sec(ratio(s.talkSumMs, s.talkCount)),
56
+ avgRingSeconds: sec(ratio(s.ringSumMs, s.ringCount)),
57
+ maxWaitSeconds: sec(s.maxWaitMs || null),
58
+ wrapUpMinutes: min(s.acwSumMs),
59
+ now: {
60
+ waiting: s.callsWaiting,
61
+ longestWaitSeconds: s.longestWaitStartedAt ? sec(now - s.longestWaitStartedAt) : 0,
62
+ talking: s.callsActive,
63
+ agents: {
64
+ total: s.agentsTotal,
65
+ available: s.agentsAvailable,
66
+ talking: s.agentsTalking,
67
+ paused: s.agentsPaused,
68
+ wrapUp: s.agentsWrapUp,
69
+ loggedOff: s.agentsLoggedOff,
70
+ },
71
+ },
72
+ };
73
+ }
74
+
75
+ const SUMMED = [
76
+ 'offered',
77
+ 'outbound',
78
+ 'answered',
79
+ 'unanswered',
80
+ 'unknown',
81
+ 'abandoned',
82
+ 'shortAbandoned',
83
+ 'transferred',
84
+ 'answeredWaitSumMs',
85
+ 'abandonedWaitSumMs',
86
+ 'unansweredWaitSumMs',
87
+ 'waitCount',
88
+ 'talkSumMs',
89
+ 'talkCount',
90
+ 'acwSumMs',
91
+ 'ringSumMs',
92
+ 'ringCount',
93
+ 'answeredWithinSl',
94
+ 'callsWaiting',
95
+ 'callsActive',
96
+ 'agentsTotal',
97
+ 'agentsAvailable',
98
+ 'agentsTalking',
99
+ 'agentsPaused',
100
+ 'agentsWrapUp',
101
+ 'agentsLoggedOff',
102
+ ];
103
+
104
+ // The counters of several queues added up (counters first, rates after: never an average of rates). An agent in
105
+ // two queues counts in both agent totals.
106
+ export function sumStats(list) {
107
+ const items = list.filter(Boolean);
108
+ if (!items.length) return undefined;
109
+ const out = {};
110
+ for (const k of SUMMED) out[k] = items.reduce((n, s) => n + (s[k] ?? 0), 0);
111
+ out.maxWaitMs = Math.max(...items.map((s) => s.maxWaitMs ?? 0));
112
+ const waits = items.map((s) => s.longestWaitStartedAt).filter(Boolean);
113
+ if (waits.length) out.longestWaitStartedAt = Math.min(...waits);
114
+ out.windowStartedAt = Math.min(...items.map((s) => s.windowStartedAt ?? Infinity));
115
+ out.periodSeconds = Math.max(...items.map((s) => s.periodSeconds ?? 0));
116
+ const slas = [...new Set(items.map((s) => s.serviceLevelSeconds))];
117
+ out.serviceLevelSeconds = slas.length === 1 ? slas[0] : undefined;
118
+ return out;
119
+ }
120
+
121
+ // The figures of one agent in the window. Occupancy: the share of the time ready for calls that was spent on
122
+ // calls, busyMs / (availableMs + busyMs).
123
+ export function agentFigures(a) {
124
+ if (!a) return undefined;
125
+ return {
126
+ offered: a.offered,
127
+ answered: a.answered,
128
+ missed: a.missed,
129
+ answerRate: pct(a.answered, a.offered),
130
+ avgTalkSeconds: sec(ratio(a.talkSumMs, a.talkCount)),
131
+ avgRingToAnswerSeconds: sec(ratio(a.ringToAnswerSumMs, a.ringToAnswerCount)),
132
+ loggedInMinutes: min(a.loggedInMs),
133
+ availableMinutes: min(a.availableMs),
134
+ busyMinutes: min(a.busyMs),
135
+ pausedMinutes: min(a.pausedMs),
136
+ wrapUpMinutes: min(a.wrapUpMs),
137
+ occupancy: pct(a.busyMs, (a.availableMs ?? 0) + (a.busyMs ?? 0)),
138
+ };
139
+ }
140
+
141
+ // The per-agent counters of a queue are keyed by the agent id, <queue id>_<extension>: the same person has another
142
+ // id in every queue. Added up per extension, for a person's figures across queues.
143
+ export function agentStatsByExtension(services) {
144
+ const out = new Map();
145
+ for (const s of services) {
146
+ for (const [id, a] of Object.entries(s.stats?.agentStats ?? {})) {
147
+ const ext =
148
+ s.agents?.find((x) => x.id === id)?.userExtension ??
149
+ (id.startsWith(`${s.id}_`) ? id.slice(s.id.length + 1) : id);
150
+ const prev = out.get(ext);
151
+ if (!prev) out.set(ext, {...a});
152
+ else for (const [k, v] of Object.entries(a)) if (typeof v === 'number') prev[k] = (prev[k] ?? 0) + v;
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // ── keeping a queue current from the events of the services topic ─────────────────────────────────────────────
159
+ // A `service` event with eventTrigger service.init or service.update carries the whole queue; agent_join the
160
+ // agents that joined, agent_leave the ids of those that left (nothing else), agent_update only what changed of the
161
+ // agents named; service.end removes the queue. A `serviceCall` event (call_init, call_update, call_end) is one
162
+ // call of a queue, with `action` (ring, cancel, answer, …) and the agents it concerns (relatedAgents).
163
+ // `service_stats_update` replaces the stats.
164
+ export function applyServiceEvent(queues, raw, now = Date.now()) {
165
+ const id = raw?.id;
166
+ if (!id) return;
167
+ const q = queues.get(id);
168
+ const ev = raw.event;
169
+ const trigger = raw.eventTrigger ?? '';
170
+ if (ev === 'service_stats_update' || trigger === 'service.stats_update') {
171
+ if (q && raw.stats) q.stats = raw.stats;
172
+ return;
173
+ }
174
+ if (ev === 'service') {
175
+ if (trigger === 'service.end') {
176
+ queues.delete(id);
177
+ return;
178
+ }
179
+ const next = q ?? {id, agents: [], calls: new Map()};
180
+ for (const k of ['pbx', 'name', 'strategy', 'maxCalls', 'aclGroup', 'timeZone'])
181
+ if (raw[k] !== undefined) next[k] = raw[k];
182
+ const agents = Array.isArray(raw.agents) ? raw.agents : [];
183
+ if (trigger === 'service.agent_leave') {
184
+ const gone = new Set(agents.map((a) => a.id));
185
+ next.agents = next.agents.filter((a) => !gone.has(a.id));
186
+ } else if (trigger === 'service.agent_update' && q) {
187
+ for (const a of agents) {
188
+ const i = next.agents.findIndex((x) => x.id === a.id);
189
+ if (i < 0) continue;
190
+ const prev = next.agents[i];
191
+ const merged = {...prev, ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== undefined))};
192
+ if (
193
+ merged.status !== prev.status ||
194
+ String(merged.paused) !== String(prev.paused) ||
195
+ merged.reason !== prev.reason
196
+ )
197
+ merged.since = raw.time ?? now;
198
+ next.agents[i] = merged;
199
+ }
200
+ } else if (trigger === 'service.agent_join' && q) {
201
+ for (const a of agents) {
202
+ const i = next.agents.findIndex((x) => x.id === a.id);
203
+ const added = {...a, since: raw.time ?? now};
204
+ if (i < 0) next.agents.push(added);
205
+ else next.agents[i] = added;
206
+ }
207
+ } else if (agents.length || !q) {
208
+ const prevById = new Map(next.agents.map((a) => [a.id, a]));
209
+ next.agents = agents.filter((a) => a.userExtension).map((a) => ({...a, since: prevById.get(a.id)?.since}));
210
+ }
211
+ // service.init and service.update list the calls waiting in the queue: a waiting call that is no longer listed
212
+ // has left it; answered calls are not listed and stay until their call_end.
213
+ if (Array.isArray(raw.calls) && (trigger === 'service.init' || trigger === 'service.update')) {
214
+ const listed = new Set(raw.calls.map(String));
215
+ for (const [cid, c] of next.calls)
216
+ if (!c.answeredBy && c.direction !== 'outgoing' && !listed.has(cid)) next.calls.delete(cid);
217
+ for (const cid of listed) if (!next.calls.has(cid)) next.calls.set(cid, {callId: cid, direction: 'incoming'});
218
+ }
219
+ if (raw.stats) next.stats = raw.stats;
220
+ queues.set(id, next);
221
+ return;
222
+ }
223
+ if ((ev === 'serviceCall' || ev === 'service_call' || (!ev && raw.callId)) && q && raw.callId) {
224
+ if (trigger === 'service.call_end') {
225
+ q.calls.delete(raw.callId);
226
+ return;
227
+ }
228
+ const c = q.calls.get(raw.callId) ?? {callId: raw.callId, ringing: []};
229
+ c.direction = raw.direction ?? c.direction ?? 'incoming';
230
+ c.start = raw.startTime ?? c.start ?? raw.time ?? now;
231
+ if (raw.customer) c.customer = raw.customer;
232
+ c.ringing ??= [];
233
+ const related = raw.relatedAgents ?? [];
234
+ if (raw.action === 'ring') c.ringing = [...new Set([...c.ringing, ...related])];
235
+ else if (raw.action === 'cancel') c.ringing = c.ringing.filter((x) => !related.includes(x));
236
+ else if (raw.action === 'answer') {
237
+ c.ringing = [];
238
+ c.answeredBy = related[0];
239
+ c.answeredAt = raw.answerTime ?? raw.time ?? now;
240
+ }
241
+ q.calls.set(raw.callId, c);
242
+ }
243
+ }
@@ -0,0 +1,318 @@
1
+ // Text output of `wx live`: queues, agents, conversations and presence as aligned tables, the events of `watch` and
2
+ // `transcript --follow` as one readable line each. The JSON output is the data itself (src/live/views.mjs).
3
+ import {duration, localTime, oneLine, paint, table} from 'wldx-core/format';
4
+
5
+ // Agents are listed under their queue when this many queues or fewer are shown (else the table of queues only).
6
+ export const AGENT_LIST_MAX = 10;
7
+
8
+ const clockTime = (value) => localTime(value, {seconds: true}).slice(11);
9
+ const num = (v) => (v === null || v === undefined ? '' : String(v));
10
+ const percent = (v) => (v === null || v === undefined ? '-' : `${v}%`);
11
+ const secs = (v) => (v === null || v === undefined ? '-' : duration(v));
12
+ const isPaused = (a) => a?.paused === true || a?.paused === 'true';
13
+
14
+ // A person in a call: name, else phone, else extension; the extension in brackets when there is a name.
15
+ function who(p) {
16
+ if (!p) return '';
17
+ const ext = p.extension ?? p.userExtension;
18
+ const name = p.name || p.phone || ext || '';
19
+ return ext && name !== ext ? `${name} (${ext})` : String(name);
20
+ }
21
+
22
+ function agentStatus(a, c) {
23
+ const status = isPaused(a) ? 'paused' : String(a.status ?? '');
24
+ if (status === 'available') return c.green(status);
25
+ if (status === 'paused') return c.yellow(status);
26
+ return c.dim(status);
27
+ }
28
+
29
+ function windowLabel(w) {
30
+ if (!w) return '';
31
+ return w.from ? `since ${localTime(w.from)}${w.minutes ? ` (${w.minutes} min)` : ''}` : '';
32
+ }
33
+
34
+ // One line of figures: of a queue, or of all the queues shown.
35
+ export function figuresLine(s) {
36
+ if (!s) return '';
37
+ const parts = [
38
+ `offered ${num(s.offered)}`,
39
+ `answered ${num(s.answered)}`,
40
+ `abandoned ${num(s.abandoned)}${s.shortAbandoned ? ` (${s.shortAbandoned} short)` : ''}`,
41
+ `SL ${percent(s.serviceLevel)}${s.slaSeconds ? ` (${s.slaSeconds} s)` : ''}`,
42
+ `ASA ${secs(s.asaSeconds)}`,
43
+ `talk ${secs(s.avgTalkSeconds)}`,
44
+ ];
45
+ if (s.now)
46
+ parts.push(
47
+ `waiting ${num(s.now.waiting)}${s.now.longestWaitSeconds ? ` (longest ${duration(s.now.longestWaitSeconds)})` : ''}`,
48
+ `talking ${num(s.now.talking)}`,
49
+ );
50
+ return parts.join(' · ');
51
+ }
52
+
53
+ function agentsTable(agents, c, withStats) {
54
+ const head = [
55
+ 'AGENT',
56
+ 'EXT',
57
+ 'STATUS',
58
+ 'REASON',
59
+ 'MESSAGE',
60
+ ...(withStats ? ['ANSWERED', 'MISSED', 'OCCUPANCY'] : []),
61
+ ];
62
+ const rows = agents.map((a) => [
63
+ a.name ?? '',
64
+ a.extension ?? a.userExtension ?? '',
65
+ agentStatus(a, c),
66
+ a.reason ? (a.reason === 'DND' ? c.red(a.reason) : c.yellow(a.reason)) : '',
67
+ oneLine(a.message ?? '', 40),
68
+ ...(withStats ? [num(a.stats?.answered), num(a.stats?.missed), percent(a.stats?.occupancy)] : []),
69
+ ]);
70
+ return table([head.map((h) => c.dim(h)), ...rows]).map((l) => ` ${l}`);
71
+ }
72
+
73
+ export function queuesText(r, ctx) {
74
+ const c = paint(ctx);
75
+ const withStats = Boolean(r.stats);
76
+ const lines = [];
77
+ const pbxCount = r.pbxs ? Object.keys(r.pbxs).length : 0;
78
+ lines.push(
79
+ `${r.total} queue${r.total === 1 ? '' : 's'}${pbxCount ? ` on ${pbxCount} PBXs` : ''}, ${r.callsWaitingOrTalking} call${r.callsWaitingOrTalking === 1 ? '' : 's'} waiting or talking`,
80
+ );
81
+ if (withStats) lines.push(`All shown ${windowLabel(r.stats.window)}: ${figuresLine(r.stats)}`);
82
+ if (!r.queues?.length) return lines;
83
+ lines.push('');
84
+ const head = [
85
+ 'QUEUE',
86
+ 'CALLS',
87
+ 'AVAILABLE',
88
+ 'PAUSED',
89
+ 'STRATEGY',
90
+ ...(pbxCount ? ['PBX'] : []),
91
+ ...(withStats ? ['OFFERED', 'ANSWERED', 'ABANDONED', 'SL', 'ASA', 'WAITING'] : []),
92
+ ];
93
+ const rows = r.queues.map((q) => {
94
+ const agents = q.agents ?? [];
95
+ const available = agents.filter((a) => a.status === 'available' && !isPaused(a)).length;
96
+ const paused = agents.filter(isPaused).length;
97
+ const s = q.figures ?? q.stats; // with --full: the raw stats and the figures beside them
98
+ return [
99
+ oneLine(q.name ?? q.id, 40),
100
+ q.calls ? c.bold(String(Array.isArray(q.calls) ? q.calls.length : q.calls)) : '0',
101
+ `${available}/${agents.length}`,
102
+ paused ? c.yellow(String(paused)) : '0',
103
+ q.strategy ?? '',
104
+ ...(pbxCount ? [q.pbx ?? ''] : []),
105
+ ...(withStats
106
+ ? [
107
+ num(s?.offered),
108
+ num(s?.answered),
109
+ num(s?.abandoned),
110
+ percent(s?.serviceLevel),
111
+ secs(s?.asaSeconds),
112
+ num(s?.now?.waiting),
113
+ ]
114
+ : []),
115
+ ];
116
+ });
117
+ lines.push(...table([head.map((h) => c.dim(h)), ...rows]));
118
+ if (r.queues.length > AGENT_LIST_MAX) {
119
+ ctx?.info?.(
120
+ `The agents are listed for ${AGENT_LIST_MAX} queues or fewer: narrow with --name, --busy, --paused or --active, or use --output json.`,
121
+ );
122
+ return lines;
123
+ }
124
+ for (const q of r.queues) {
125
+ lines.push(
126
+ '',
127
+ c.bold(`${q.name ?? q.id}`) +
128
+ c.dim(` · ${q.strategy ?? ''}${q.maxCalls ? ` · max ${q.maxCalls} calls` : ''} · ${q.id}`),
129
+ );
130
+ if (Array.isArray(q.callIds) && q.callIds.length) lines.push(` calls: ${q.callIds.join(', ')}`);
131
+ if (q.figures ?? q.stats) lines.push(` ${figuresLine(q.figures ?? q.stats)}`);
132
+ if (q.agents?.length) lines.push(...agentsTable(q.agents, c, withStats));
133
+ else lines.push(c.dim(' no agents'));
134
+ }
135
+ return lines;
136
+ }
137
+
138
+ const lastFlow = (item) => (Array.isArray(item.flows) ? item.flows.at(-1) : undefined);
139
+
140
+ // One conversation: [type, status, who, detail].
141
+ function conversationCells(item) {
142
+ if (item.type === 'call' || item.event === 'call') {
143
+ const f = lastFlow(item) ?? {};
144
+ const legs = Array.isArray(item.flows) && item.flows.length > 1 ? ` · ${item.flows.length} legs` : '';
145
+ return [
146
+ 'call',
147
+ f.status ?? '',
148
+ `${who(f.caller)} → ${who(f.callee)}`,
149
+ [f.direction, f.queue && `queue ${f.queue}`, f.transcription && `transcription ${f.transcription}`]
150
+ .filter(Boolean)
151
+ .join(' · ') + legs,
152
+ ];
153
+ }
154
+ const sentiment = item.insights?.sentiment !== undefined ? `sentiment ${item.insights.sentiment}` : '';
155
+ if (item.type === 'chat' || item.event === 'chat') {
156
+ const people = (item.customers ?? [])
157
+ .map((p) => p.name)
158
+ .filter(Boolean)
159
+ .join(', ');
160
+ return [
161
+ 'chat',
162
+ item.status ?? '',
163
+ people,
164
+ [item.subject && oneLine(item.subject, 60), sentiment].filter(Boolean).join(' · '),
165
+ ];
166
+ }
167
+ if (item.type === 'conference' || item.event === 'conference') {
168
+ return [
169
+ 'conference',
170
+ '',
171
+ `${item.participants?.length ?? 0} participants`,
172
+ [item.subject && oneLine(item.subject, 60), sentiment].filter(Boolean).join(' · '),
173
+ ];
174
+ }
175
+ return [String(item.type ?? item.event ?? ''), '', '', item.key ?? ''];
176
+ }
177
+
178
+ export function conversationsText(r, ctx) {
179
+ const c = paint(ctx);
180
+ const counts = Object.entries(r.byType ?? {})
181
+ .map(([k, n]) => `${k}s ${n}`)
182
+ .join(', ');
183
+ const lines = [
184
+ `${r.total} in progress${counts ? ` (all: ${counts})` : ''}${r.shown < r.total ? `, newest ${r.shown} shown` : ''}`,
185
+ ];
186
+ if (!r.items?.length) return lines;
187
+ lines.push('');
188
+ const rows = r.items.map((item) => [
189
+ localTime(item.start),
190
+ ...conversationCells(item).map((v, i) => (i === 1 && v ? c.cyan(v) : oneLine(v, 80))),
191
+ c.dim(item.id ?? ''),
192
+ ]);
193
+ lines.push(...table([['STARTED', 'TYPE', 'STATUS', 'WHO', 'DETAIL', 'ID'].map((h) => c.dim(h)), ...rows]));
194
+ return lines;
195
+ }
196
+
197
+ function presenceStatus(status, c) {
198
+ const s = String(status ?? '');
199
+ if (s === 'ONLINE' || s === 'AVAILABLE') return c.green(s);
200
+ if (s === 'DND') return c.red(s);
201
+ if (s === 'AWAY') return c.yellow(s);
202
+ return c.dim(s);
203
+ }
204
+
205
+ export function presenceText(r, ctx) {
206
+ const c = paint(ctx);
207
+ const rows = (r.presence ?? []).map((p) => [
208
+ p.extension ?? '',
209
+ presenceStatus(p.status, c),
210
+ oneLine(p.message ?? '', 50),
211
+ p.until ? localTime(p.until) : '',
212
+ p.phone ?? p.telephony ?? '',
213
+ p.company ? `company ${p.company}` : '',
214
+ ]);
215
+ return table([['EXT', 'STATUS', 'MESSAGE', 'UNTIL', 'PHONE', ''].map((h) => c.dim(h)), ...rows]);
216
+ }
217
+
218
+ const textLine = (t) =>
219
+ `${t.at !== undefined ? `[${clockTime(t.at)}] ` : ''}${t.name ?? t.speaker ?? '?'}: ${t.text ?? ''}`;
220
+
221
+ export function transcriptText(r, ctx) {
222
+ const c = paint(ctx);
223
+ const head = [`${r.type} ${r.id}`];
224
+ if (r.call) {
225
+ const [, status, people] = conversationCells(r.call);
226
+ head.push([status, people].filter(Boolean).join(' · '));
227
+ }
228
+ const lines = [c.bold(head.filter(Boolean).join(': '))];
229
+ if (!r.transcript?.length) lines.push(c.dim('nothing transcribed yet'));
230
+ for (const t of r.transcript ?? []) lines.push(textLine(t));
231
+ for (const a of r.annotations ?? []) lines.push(c.dim(`${a.key}: ${oneLine(JSON.stringify(a.payload), 100)}`));
232
+ return lines;
233
+ }
234
+
235
+ // One event of `watch` or `transcript --follow` as one line: the local time, the event, what it says.
236
+ export function eventText(e, ctx) {
237
+ const c = paint(ctx);
238
+ const d = e.data ?? {};
239
+ let what;
240
+ switch (e.event) {
241
+ case 'READY':
242
+ case 'RECONNECTED':
243
+ what = `watching ${(d.topics ?? []).join(', ') || 'nothing'}${d.calls ? ' (calls only)' : ''}${d.mine ? ' (my calls)' : ''}${d.extension ? ` · extension ${d.extension}` : ''}${d.license ? ` · ${d.license}` : ''}`;
244
+ break;
245
+ case 'RECONNECTING':
246
+ what =
247
+ d.reason === 'token'
248
+ ? 'renewing the login'
249
+ : `connection lost (code ${d.code}), attempt ${d.attempt} in ${d.inSeconds} s`;
250
+ break;
251
+ case 'ERROR':
252
+ what = c.red(`${d.message}${d.code ? ` (${d.code})` : ''}`);
253
+ break;
254
+ case 'QUEUES':
255
+ what = `${d.queues} queues known${d.stats ? ` · stats ${d.stats.periodMinutes ? `last ${d.stats.periodMinutes} min` : 'today'}, SLA ${d.stats.serviceLevelSeconds} s` : ''}`;
256
+ break;
257
+ case 'QUEUE': {
258
+ const agents = (d.agents ?? []).map((a) =>
259
+ `${a.name ?? a.extension ?? a.userExtension ?? '?'} ${isPaused(a) ? 'paused' : (a.status ?? '')}${a.reason ? ` ${a.reason}` : ''}${a.message ? ` "${oneLine(a.message, 30)}"` : ''}`.trim(),
260
+ );
261
+ what = [d.trigger ?? d.eventTrigger, d.name ?? d.id, d.calls ? `${d.calls} calls` : '', agents.join('; ')]
262
+ .filter(Boolean)
263
+ .join(' · ');
264
+ break;
265
+ }
266
+ case 'QUEUE_CALL':
267
+ what = [
268
+ d.trigger ?? d.eventTrigger,
269
+ `queue ${d.queueId ?? d.id}`,
270
+ `call ${d.callId}`,
271
+ d.direction,
272
+ d.customer && (typeof d.customer === 'object' ? (d.customer.phone ?? d.customer.name) : d.customer),
273
+ d.agents !== undefined && `${d.agents} agents`,
274
+ ]
275
+ .filter(Boolean)
276
+ .join(' · ');
277
+ break;
278
+ case 'QUEUE_STATS':
279
+ what = `${d.name ?? d.id}: ${figuresLine(d)}`;
280
+ break;
281
+ case 'PRESENCE':
282
+ what = [
283
+ d.extension,
284
+ d.status,
285
+ d.message && `"${oneLine(d.message, 40)}"`,
286
+ d.until && `until ${localTime(d.until)}`,
287
+ d.phone ?? d.telephony,
288
+ ]
289
+ .filter(Boolean)
290
+ .join(' · ');
291
+ break;
292
+ case 'SNAPSHOT':
293
+ what = `${d.type} ${d.id}: ${d.transcript?.length ?? 0} lines so far${(d.transcript ?? []).map((t) => `\n ${textLine(t)}`).join('')}`;
294
+ break;
295
+ case 'TEXT':
296
+ what = `${d.final === false ? c.dim('(partial) ') : ''}${textLine(d)}`;
297
+ break;
298
+ case 'ANNOTATION':
299
+ what = `${d.key}: ${oneLine(JSON.stringify(d.payload), 100)}`;
300
+ break;
301
+ case 'END':
302
+ what = `${d.id} ended (${d.event})`;
303
+ break;
304
+ default: {
305
+ if (/^(CALL|CHAT|CONFERENCE)(_|$)/.test(e.event)) {
306
+ const [, status, people, detail] = conversationCells(d);
307
+ const extra = d.key ? `${d.key}${d.insights ? ` ${oneLine(JSON.stringify(d.insights), 80)}` : ''}` : '';
308
+ what = [d.trigger, status, people, detail, extra, c.dim(d.id ?? '')]
309
+ .filter(Boolean)
310
+ .map((v) => oneLine(v, 100))
311
+ .join(' · ');
312
+ } else {
313
+ what = oneLine(JSON.stringify(d), 140);
314
+ }
315
+ }
316
+ }
317
+ return `${c.dim(clockTime(ctx?.clock ? ctx.clock() : Date.now()))} ${c.bold(e.event)} ${what}`;
318
+ }