wldx-cli 0.1.0 → 1.0.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/README.md +279 -27
- package/package.json +1 -1
- package/src/auth.mjs +181 -35
- package/src/chat/format.mjs +97 -0
- package/src/chat/lib.mjs +162 -0
- package/src/chat/watch.mjs +251 -0
- package/src/cli.mjs +167 -47
- package/src/commands/calls.mjs +367 -0
- package/src/commands/channel.mjs +326 -0
- package/src/commands/chat.mjs +388 -60
- package/src/commands/conversations.mjs +251 -0
- package/src/commands/file.mjs +72 -0
- package/src/commands/inbox.mjs +101 -0
- package/src/commands/index.mjs +54 -9
- package/src/commands/live.mjs +404 -0
- package/src/commands/login.mjs +126 -0
- package/src/commands/meetings.mjs +299 -0
- package/src/commands/phone.mjs +452 -0
- package/src/commands/profile.mjs +51 -0
- package/src/commands/reports.mjs +431 -0
- package/src/commands/section.mjs +60 -0
- package/src/commands/status.mjs +95 -0
- package/src/commands/user.mjs +130 -0
- package/src/completion.mjs +47 -20
- package/src/files.mjs +98 -0
- package/src/format.mjs +82 -0
- package/src/history/text.mjs +60 -0
- package/src/history/wda.mjs +161 -0
- package/src/http.mjs +30 -13
- package/src/live/socket.mjs +121 -0
- package/src/live/stats.mjs +211 -0
- package/src/live/text.mjs +219 -0
- package/src/live/views.mjs +321 -0
- package/src/phone/control.mjs +377 -0
- package/src/phone/events.mjs +189 -0
- package/src/phone/pbx.mjs +140 -0
- package/src/phone/sip.mjs +316 -0
- package/src/phone/xml.mjs +56 -0
- package/src/services.mjs +58 -0
- package/src/stream.mjs +4 -11
- package/src/commands/auth.mjs +0 -32
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// wx live: what is going on now in the logged-in user's company, from the live stream of Wildix Data Analytics
|
|
2
|
+
// (WDA, wss://wda-ws.wildix.com): call queues and their agents (with the statistics of today or the last hour), the
|
|
3
|
+
// calls, customer chats and conferences in progress, presence of colleagues, the live transcript of a call, and a
|
|
4
|
+
// feed of all this. Read only: nothing here changes a call, a queue or a status. Port of the wildix-live-monitor
|
|
5
|
+
// skill (live.sh, live.mjs, stats.mjs): the same commands, options, limits and JSON; `watch` and
|
|
6
|
+
// `transcript --follow` print one JSON object per event, {"event": "<EVENT>", "data": {…}} (the skill printed
|
|
7
|
+
// `<EVENT> {…}`).
|
|
8
|
+
import {jwtClaims} from '../auth.mjs';
|
|
9
|
+
import {AuthError, UsageError} from '../errors.mjs';
|
|
10
|
+
import {connect, explain, subscription} from '../live/socket.mjs';
|
|
11
|
+
import {SLA_SECONDS, WINDOWS, statsOptions} from '../live/stats.mjs';
|
|
12
|
+
import {conversationsText, eventText, presenceText, queuesText, transcriptText} from '../live/text.mjs';
|
|
13
|
+
import {callIdBySip, conversationsResult, drop, makeViews, presenceResult, queuesResult, transcriptEvents, transcriptSnapshot, watchFrames} from '../live/views.mjs';
|
|
14
|
+
import {wdaWsUrl} from '../services.mjs';
|
|
15
|
+
|
|
16
|
+
// Settings of the connection; tests replace them (the WebSocket constructor, shorter waits).
|
|
17
|
+
export const liveOptions = {
|
|
18
|
+
WebSocket: undefined, // else ctx.WebSocket, else the global WebSocket of Node 22+
|
|
19
|
+
retryDelays: [1, 2, 5, 10], // seconds before the attempts to reconnect, then retryMaxDelay
|
|
20
|
+
retryMaxDelay: 30,
|
|
21
|
+
connectAttempts: 5, // a watch gives up after this many failed connections in a row
|
|
22
|
+
keepaliveMs: 4 * 60 * 1000, // the gateway drops a connection quiet for 10 minutes; subscribing again is harmless
|
|
23
|
+
tokenMarginMs: 2 * 60 * 1000, // a connection lost this close to the expiry of the token reconnects with a fresh one
|
|
24
|
+
authTimeoutMs: 20_000,
|
|
25
|
+
snapshotTimeoutMs: 30_000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MAX_SECONDS = 604_800;
|
|
29
|
+
|
|
30
|
+
const retryDelay = (attempt) => liveOptions.retryDelays[attempt - 1] ?? liveOptions.retryMaxDelay;
|
|
31
|
+
|
|
32
|
+
// What the live commands need from the login token (never the token itself).
|
|
33
|
+
function loginOf(token) {
|
|
34
|
+
const claims = jwtClaims(token);
|
|
35
|
+
const ext = claims['custom:pbx_user_extension'];
|
|
36
|
+
return {company: claims.company, myExtension: ext ? String(ext) : undefined, license: claims['custom:pbx_license'], exp: claims.exp};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function open(ctx, token, login) {
|
|
40
|
+
return connect({
|
|
41
|
+
url: wdaWsUrl(ctx.env),
|
|
42
|
+
token,
|
|
43
|
+
license: login.license,
|
|
44
|
+
WebSocket: ctx.WebSocket ?? liveOptions.WebSocket ?? globalThis.WebSocket,
|
|
45
|
+
timeoutMs: liveOptions.authTimeoutMs,
|
|
46
|
+
snapshotTimeoutMs: liveOptions.snapshotTimeoutMs,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function viewsOf(ctx, login) {
|
|
51
|
+
return makeViews({full: Boolean(ctx.values.full), company: login.company, myExtension: login.myExtension, now: () => ctx.clock()});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A one-shot command: connect, run fn(connection, login), close. Ctrl-C closes the connection and prints nothing.
|
|
55
|
+
async function oneShot(ctx, fn) {
|
|
56
|
+
const login = loginOf(ctx.token);
|
|
57
|
+
wdaWsUrl(ctx.env); // refuses a bad override before connecting
|
|
58
|
+
const conn = await open(ctx, ctx.token, login);
|
|
59
|
+
const onAbort = () => conn.close();
|
|
60
|
+
ctx.signal?.addEventListener('abort', onAbort, {once: true});
|
|
61
|
+
try {
|
|
62
|
+
return await fn(conn, login);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (ctx.signal?.aborted) return undefined;
|
|
65
|
+
throw e;
|
|
66
|
+
} finally {
|
|
67
|
+
ctx.signal?.removeEventListener('abort', onAbort);
|
|
68
|
+
conn.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── option checks (the limits of live.sh) ─────────────────────────────────────────────────────────────────────
|
|
73
|
+
function wholeNumber(name, value, min, max) {
|
|
74
|
+
if (value === undefined) return undefined;
|
|
75
|
+
if (!/^[0-9]{1,12}$/.test(value) || Number(value) < min || Number(value) > max) throw new UsageError(`--${name} must be a whole number from ${min} to ${max}: ${value}`);
|
|
76
|
+
return Number(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function check(name, value, pattern, what) {
|
|
80
|
+
if (value !== undefined && !pattern.test(value)) throw new UsageError(`${what}: ${value}`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const EXTENSION = /^[0-9]{1,15}$/;
|
|
85
|
+
const wantsStats = (v) => Boolean(v.stats || v.window !== undefined || v.sla !== undefined);
|
|
86
|
+
const statsOptsOf = (ctx) => statsOptions(ctx.values.window ?? 'today', Number(ctx.values.sla ?? 20), ctx.clock());
|
|
87
|
+
|
|
88
|
+
const full = {type: 'boolean', description: 'everything the service sends, also e-mail, IP address, location and user agent of people'};
|
|
89
|
+
const statsOptionsDef = {
|
|
90
|
+
stats: {type: 'boolean', description: 'the figures of each queue, of each agent in it and of all of them together'},
|
|
91
|
+
window: {type: 'string', choices: WINDOWS, description: 'the window of the figures: today (default, from the local midnight) or the last minutes (implies --stats)'},
|
|
92
|
+
sla: {type: 'string', choices: SLA_SECONDS.map(String), description: 'the answer-within target of the service level, seconds (20 by default; implies --stats)'},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ── commands ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
const queues = {
|
|
97
|
+
summary: 'the call queues of the company: strategy, calls in them now, agents with their status (Premium)',
|
|
98
|
+
usage: 'wx live queues [--name TEXT] [--pbx SERIAL] [--busy] [--paused] [--active] [--stats] [--window W] [--sla S] [--full]',
|
|
99
|
+
details: [
|
|
100
|
+
'The queues with calls first. An agent\'s status is the queue\'s view (available, pause…); reason and message are',
|
|
101
|
+
'the user\'s own status (DND, AWAY and its text). --stats counts since the local midnight (--window today) or over',
|
|
102
|
+
'the last 15/30/45/60 minutes (WDA counts in 15-minute buckets: window.minutes says what was covered). In text the',
|
|
103
|
+
'agents are listed under their queue when 10 queues or fewer are shown.',
|
|
104
|
+
].join('\n'),
|
|
105
|
+
options: {
|
|
106
|
+
name: {type: 'string', placeholder: 'TEXT', description: 'only the queues whose name contains the text (any case)'},
|
|
107
|
+
pbx: {type: 'string', placeholder: 'SERIAL', description: 'only the queues of one PBX of the company'},
|
|
108
|
+
busy: {type: 'boolean', description: 'only the queues with calls now'},
|
|
109
|
+
paused: {type: 'boolean', description: 'only the queues with a paused agent'},
|
|
110
|
+
active: {type: 'boolean', description: 'only the queues with a call now or in the window'},
|
|
111
|
+
...statsOptionsDef,
|
|
112
|
+
full,
|
|
113
|
+
},
|
|
114
|
+
args: [0, 0],
|
|
115
|
+
async run(ctx) {
|
|
116
|
+
const v = ctx.values;
|
|
117
|
+
check('pbx', v.pbx, /^[A-Za-z0-9]{4,32}$/, 'Not a PBX serial');
|
|
118
|
+
if (v.name !== undefined && v.name.length > 100) throw new UsageError('--name is too long (100 characters at most)');
|
|
119
|
+
const stats = wantsStats(v);
|
|
120
|
+
return oneShot(ctx, async (conn, login) => {
|
|
121
|
+
const items = await conn.snapshot('services', stats ? statsOptsOf(ctx) : {});
|
|
122
|
+
return queuesResult(items, {pbx: v.pbx, name: v.name, busy: v.busy, paused: v.paused, active: v.active, stats}, viewsOf(ctx, login));
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
text: queuesText,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const conversations = {
|
|
129
|
+
summary: 'the calls, customer chats and conferences going on now, newest first, with their live insights (Premium)',
|
|
130
|
+
usage: 'wx live conversations [--calls] [--chats] [--conferences] [--mine] [--status S] [--limit N] [--full]',
|
|
131
|
+
details: 'byType counts all of them before the filters. --status keeps the chats in that status (or the calls whose last flow is in it).',
|
|
132
|
+
options: {
|
|
133
|
+
calls: {type: 'boolean', description: 'only calls'},
|
|
134
|
+
chats: {type: 'boolean', description: 'only customer chats'},
|
|
135
|
+
conferences: {type: 'boolean', description: 'only conferences'},
|
|
136
|
+
mine: {type: 'boolean', description: 'only your own calls'},
|
|
137
|
+
status: {type: 'string', placeholder: 'S', description: 'only this status: TALKING, HOLD, CONNECTING, WAITFIRSTREPLY, WAITREPLYFROMAGENT, WAITREPLYFROMGUEST…'},
|
|
138
|
+
limit: {type: 'string', placeholder: 'N', description: 'at most N items, 1 to 1000 (50 by default)'},
|
|
139
|
+
full,
|
|
140
|
+
},
|
|
141
|
+
args: [0, 0],
|
|
142
|
+
async run(ctx) {
|
|
143
|
+
const v = ctx.values;
|
|
144
|
+
check('status', v.status, /^[A-Za-z_]{1,40}$/, 'Not a status');
|
|
145
|
+
const limit = wholeNumber('limit', v.limit, 1, 1000) ?? 50;
|
|
146
|
+
const kinds = ['calls', 'chats', 'conferences'].filter((k) => v[k]).map((k) => k.slice(0, -1));
|
|
147
|
+
return oneShot(ctx, async (conn, login) => {
|
|
148
|
+
const items = await conn.snapshot('conversations');
|
|
149
|
+
return conversationsResult(items, {kinds, mine: v.mine, status: v.status, limit}, viewsOf(ctx, login));
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
text: conversationsText,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const presence = {
|
|
156
|
+
summary: 'status, status message, "until" and phone registration of colleagues (is someone available)',
|
|
157
|
+
usage: 'wx live presence <extension>... [--full]',
|
|
158
|
+
details: 'Up to 50 extensions of your company. phone is REGISTERED when a phone of theirs is online; an extension nobody has gives status unknown.',
|
|
159
|
+
options: {full},
|
|
160
|
+
args: [1, 50],
|
|
161
|
+
async run(ctx) {
|
|
162
|
+
for (const ext of ctx.positionals) check('extension', ext, EXTENSION, 'Not an extension');
|
|
163
|
+
if (!loginOf(ctx.token).company) throw new Error('this login names no company');
|
|
164
|
+
return oneShot(ctx, async (conn, login) => {
|
|
165
|
+
const items = await conn.snapshot('presence', {filter: ctx.positionals.map((ext) => [null, login.company, ext])});
|
|
166
|
+
return presenceResult(items, ctx.positionals, viewsOf(ctx, login));
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
text: presenceText,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// The WDA id of a call: as given when it is one, else through the conversations of the company (Premium only).
|
|
173
|
+
async function wdaCallId(conn, id, login) {
|
|
174
|
+
if (login.company && id.startsWith(`${login.company}_`)) return id;
|
|
175
|
+
let items;
|
|
176
|
+
try {
|
|
177
|
+
items = await conn.snapshot('conversations', {state: true});
|
|
178
|
+
} catch (e) {
|
|
179
|
+
throw new Error(`${e.message}. Without it, give the WDA id of the call (${login.company ?? '<company>'}_…)`);
|
|
180
|
+
}
|
|
181
|
+
const found = callIdBySip(items, id);
|
|
182
|
+
if (!found) throw new Error(`no call going on now has the SIP call id ${id}`);
|
|
183
|
+
return found;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const transcript = {
|
|
187
|
+
summary: 'the live transcript of a call or conference in progress; --follow prints each new line until it ends',
|
|
188
|
+
usage: 'wx live transcript <callId|conference:ID> [--follow] [--for SECONDS] [--full]',
|
|
189
|
+
details: [
|
|
190
|
+
'<callId> is the WDA id of a call (from wx live conversations) or its SIP call id (turned into the WDA id through',
|
|
191
|
+
'the conversations: Premium only). A transcript exists only while the call lasts and transcription runs on it.',
|
|
192
|
+
'With --follow: SNAPSHOT, then TEXT (final: false is a partial phrase a final one replaces), CALL, ANNOTATION, END.',
|
|
193
|
+
].join('\n'),
|
|
194
|
+
options: {
|
|
195
|
+
follow: {type: 'boolean', description: 'then print each change until the call ends'},
|
|
196
|
+
for: {type: 'string', placeholder: 'SECONDS', description: 'with --follow: stop after SECONDS (1 to 604800)'},
|
|
197
|
+
full,
|
|
198
|
+
},
|
|
199
|
+
args: [1, 1],
|
|
200
|
+
async run(ctx) {
|
|
201
|
+
const arg = ctx.positionals[0];
|
|
202
|
+
if (!/^(conference:)?[A-Za-z0-9._@:+-]{1,200}$/.test(arg)) throw new UsageError(`Name the call: its WDA id or its SIP call id (or conference:<id>): ${arg}`);
|
|
203
|
+
const secs = wholeNumber('for', ctx.values.for, 1, MAX_SECONDS) ?? 0;
|
|
204
|
+
return oneShot(ctx, async (conn, login) => {
|
|
205
|
+
const views = viewsOf(ctx, login);
|
|
206
|
+
const kind = arg.startsWith('conference:') ? 'conference' : 'call';
|
|
207
|
+
const id = kind === 'conference' ? arg.slice('conference:'.length) : await wdaCallId(conn, arg, login);
|
|
208
|
+
let items;
|
|
209
|
+
try {
|
|
210
|
+
items = await conn.snapshot('transcriptions', {state: true, filter: [[kind, id]]});
|
|
211
|
+
} catch (e) {
|
|
212
|
+
throw new Error(e.message.includes('not found') ? `${kind} ${id} is not going on now (a live transcript exists only while it lasts)` : e.message);
|
|
213
|
+
}
|
|
214
|
+
const {call, snapshot} = transcriptSnapshot(items, id, kind, views);
|
|
215
|
+
if (!ctx.values.follow) return snapshot;
|
|
216
|
+
ctx.emit({event: 'SNAPSHOT', data: snapshot});
|
|
217
|
+
const state = {call};
|
|
218
|
+
await new Promise((resolve, reject) => {
|
|
219
|
+
let timer;
|
|
220
|
+
const done = (err) => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
conn.listeners.delete(listener);
|
|
223
|
+
ctx.signal?.removeEventListener('abort', onAbort);
|
|
224
|
+
if (err) reject(err);
|
|
225
|
+
else resolve();
|
|
226
|
+
};
|
|
227
|
+
const onAbort = () => done();
|
|
228
|
+
const listener = (d) => {
|
|
229
|
+
if (d.event === '_closed') return done(new Error(`the connection was lost (code ${d.code})`));
|
|
230
|
+
if (d.event !== 'broadcast' || d.topic !== 'transcriptions') return;
|
|
231
|
+
for (const it of Array.isArray(d.data) ? d.data : [d.data]) {
|
|
232
|
+
const {events, end} = transcriptEvents(it, id, state, views);
|
|
233
|
+
for (const e of events) ctx.emit(e);
|
|
234
|
+
if (end) return done();
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
conn.listeners.add(listener);
|
|
238
|
+
if (ctx.signal?.aborted) return done();
|
|
239
|
+
ctx.signal?.addEventListener('abort', onAbort, {once: true});
|
|
240
|
+
if (secs) timer = setTimeout(() => done(), secs * 1000);
|
|
241
|
+
});
|
|
242
|
+
return undefined;
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
text: transcriptText,
|
|
246
|
+
textEvent: eventText,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const watch = {
|
|
250
|
+
summary: 'a live feed: one line per change of the conversations, queues or presence until stopped',
|
|
251
|
+
usage: 'wx live watch [--conversations] [--calls] [--mine] [--queues] [--stats] [--window W] [--sla S] [--presence EXT]... [--for SECONDS] [--full]',
|
|
252
|
+
details: [
|
|
253
|
+
'Without a choice: --conversations. --stats implies --queues and adds QUEUES and QUEUE_STATS lines. Events: READY,',
|
|
254
|
+
'RECONNECTING, RECONNECTED, ERROR, CALL, CALL_COMPLETE, CALL_INTERRUPTED, CALL_ANNOTATION, CHAT…, CONFERENCE…,',
|
|
255
|
+
'QUEUE, QUEUE_CALL, QUEUES, QUEUE_STATS, PRESENCE. With --output json one object per line: {"event", "data"}.',
|
|
256
|
+
'It reconnects by itself, also when the login token is renewed. A whole company is busy: watch what you need.',
|
|
257
|
+
].join('\n'),
|
|
258
|
+
options: {
|
|
259
|
+
conversations: {type: 'boolean', description: 'the calls, chats and conferences (the default)'},
|
|
260
|
+
calls: {type: 'boolean', description: 'only the calls of the conversations'},
|
|
261
|
+
mine: {type: 'boolean', description: 'only your own calls'},
|
|
262
|
+
queues: {type: 'boolean', description: 'the call queues and the calls entering and leaving them'},
|
|
263
|
+
...statsOptionsDef,
|
|
264
|
+
presence: {type: 'string', multiple: true, placeholder: 'EXT', description: 'the presence of this extension'},
|
|
265
|
+
for: {type: 'string', placeholder: 'SECONDS', description: 'stop after SECONDS (1 to 604800)'},
|
|
266
|
+
full,
|
|
267
|
+
},
|
|
268
|
+
args: [0, 0],
|
|
269
|
+
run(ctx) {
|
|
270
|
+
const v = ctx.values;
|
|
271
|
+
const presenceExt = v.presence ?? [];
|
|
272
|
+
for (const ext of presenceExt) check('presence', ext, EXTENSION, 'Not an extension');
|
|
273
|
+
const secs = wholeNumber('for', v.for, 1, MAX_SECONDS) ?? 0;
|
|
274
|
+
wdaWsUrl(ctx.env); // refuses a bad override before connecting
|
|
275
|
+
let token = ctx.token;
|
|
276
|
+
const login = loginOf(token);
|
|
277
|
+
if (presenceExt.length && !login.company) throw new Error('this login names no company');
|
|
278
|
+
const wantStats = wantsStats(v);
|
|
279
|
+
const wantQueues = Boolean(v.queues) || wantStats;
|
|
280
|
+
const wantConv = Boolean(v.conversations || v.calls || v.mine) || (!wantQueues && !presenceExt.length);
|
|
281
|
+
const statsOpts = () => statsOptsOf(ctx);
|
|
282
|
+
const handler = watchFrames({
|
|
283
|
+
views: viewsOf(ctx, login),
|
|
284
|
+
wantConv,
|
|
285
|
+
wantQueues,
|
|
286
|
+
wantStats,
|
|
287
|
+
calls: v.calls,
|
|
288
|
+
mine: v.mine,
|
|
289
|
+
statsOpts,
|
|
290
|
+
explain: (topic, d) => explain(topic, d, login.license),
|
|
291
|
+
});
|
|
292
|
+
// With stats the queues are subscribed with their current state, to learn their names (a stats update names
|
|
293
|
+
// only the queue id); the state itself is not printed. The keepalive asks again without the state.
|
|
294
|
+
const queueSub = (state) => subscription('services', wantStats ? {state, ...statsOpts()} : {state: false});
|
|
295
|
+
const subscriptions = (first = false) => [
|
|
296
|
+
...(wantConv ? [subscription('conversations', {state: false})] : []),
|
|
297
|
+
...(wantQueues ? [queueSub(first && wantStats)] : []),
|
|
298
|
+
...(presenceExt.length ? [subscription('presence', {state: false, filter: presenceExt.map((e) => [null, login.company, e])})] : []),
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
let conn;
|
|
303
|
+
let keepalive;
|
|
304
|
+
let retryTimer;
|
|
305
|
+
let deadlineTimer;
|
|
306
|
+
let attempts = 0;
|
|
307
|
+
let ready = false;
|
|
308
|
+
let stopped = false;
|
|
309
|
+
let renewedFor; // the token a "token" reconnection was made for (once per token, then the usual waits)
|
|
310
|
+
|
|
311
|
+
const finish = (err) => {
|
|
312
|
+
if (stopped) return;
|
|
313
|
+
stopped = true;
|
|
314
|
+
clearInterval(keepalive);
|
|
315
|
+
clearTimeout(retryTimer);
|
|
316
|
+
clearTimeout(deadlineTimer);
|
|
317
|
+
ctx.signal?.removeEventListener('abort', onAbort);
|
|
318
|
+
conn?.close();
|
|
319
|
+
if (err) reject(err);
|
|
320
|
+
else resolve(undefined);
|
|
321
|
+
};
|
|
322
|
+
const onAbort = () => finish();
|
|
323
|
+
|
|
324
|
+
const onFrame = (d) => {
|
|
325
|
+
if (stopped) return;
|
|
326
|
+
if (d.event === '_closed') {
|
|
327
|
+
clearInterval(keepalive);
|
|
328
|
+
conn = undefined;
|
|
329
|
+
const exp = loginOf(token).exp;
|
|
330
|
+
if (Number.isFinite(exp) && exp * 1000 - ctx.clock() < liveOptions.tokenMarginMs && renewedFor !== token) {
|
|
331
|
+
renewedFor = token;
|
|
332
|
+
ctx.emit({event: 'RECONNECTING', data: {reason: 'token'}});
|
|
333
|
+
start();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
attempts += 1;
|
|
337
|
+
const wait = retryDelay(attempts);
|
|
338
|
+
ctx.emit({event: 'RECONNECTING', data: {code: d.code, attempt: attempts, inSeconds: wait}});
|
|
339
|
+
retryTimer = setTimeout(start, wait * 1000);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const {events, fatal} = handler.frame(d);
|
|
343
|
+
for (const e of events) ctx.emit(e);
|
|
344
|
+
if (fatal) finish(new Error(fatal));
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
async function start() {
|
|
348
|
+
if (stopped) return;
|
|
349
|
+
let c;
|
|
350
|
+
try {
|
|
351
|
+
// Before every reconnection: the token again, renewed when it expired.
|
|
352
|
+
if (ready) token = await ctx.freshToken();
|
|
353
|
+
c = await open(ctx, token, login);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if (stopped) return;
|
|
356
|
+
if (e instanceof AuthError) return finish(e);
|
|
357
|
+
attempts += 1;
|
|
358
|
+
if (attempts >= liveOptions.connectAttempts) return finish(e);
|
|
359
|
+
retryTimer = setTimeout(start, retryDelay(attempts) * 1000);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (stopped) {
|
|
363
|
+
c.close();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
conn = c;
|
|
367
|
+
handler.reset();
|
|
368
|
+
c.listeners.add(onFrame);
|
|
369
|
+
for (const s of subscriptions(true)) c.send(s);
|
|
370
|
+
attempts = 0;
|
|
371
|
+
clearInterval(keepalive);
|
|
372
|
+
keepalive = setInterval(() => subscriptions().forEach((s) => conn?.send(s)), liveOptions.keepaliveMs);
|
|
373
|
+
ctx.emit({
|
|
374
|
+
event: ready ? 'RECONNECTED' : 'READY',
|
|
375
|
+
data: drop({
|
|
376
|
+
extension: login.myExtension,
|
|
377
|
+
license: login.license,
|
|
378
|
+
topics: [wantConv && 'conversations', wantQueues && 'queues', presenceExt.length && 'presence'].filter(Boolean),
|
|
379
|
+
calls: v.calls || undefined,
|
|
380
|
+
mine: v.mine || undefined,
|
|
381
|
+
stats: wantQueues && wantStats ? statsOpts() : undefined,
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
ready = true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (ctx.signal?.aborted) return finish();
|
|
388
|
+
ctx.signal?.addEventListener('abort', onAbort, {once: true});
|
|
389
|
+
if (secs) deadlineTimer = setTimeout(() => finish(), secs * 1000);
|
|
390
|
+
start();
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
textEvent: eventText,
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
export const groups = {
|
|
397
|
+
live: {
|
|
398
|
+
summary: 'what is going on now: queues and agents, calls and chats in progress, presence, live transcripts',
|
|
399
|
+
auth: true,
|
|
400
|
+
commands: {queues, conversations, presence, transcript, watch},
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
export default {groups};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// wx login, wx logout: the Wildix login of the user (a one-time code sent by e-mail).
|
|
2
|
+
import {createInterface} from 'node:readline/promises';
|
|
3
|
+
import {
|
|
4
|
+
PENDING_TTL_MS,
|
|
5
|
+
checkEmail,
|
|
6
|
+
defaultProfile,
|
|
7
|
+
dropPending,
|
|
8
|
+
finishLogin,
|
|
9
|
+
logout as signOut,
|
|
10
|
+
pickProfile,
|
|
11
|
+
readPending,
|
|
12
|
+
savePending,
|
|
13
|
+
startLogin,
|
|
14
|
+
} from '../auth.mjs';
|
|
15
|
+
import {AuthError, UsageError} from '../errors.mjs';
|
|
16
|
+
|
|
17
|
+
const TRIES = 3;
|
|
18
|
+
|
|
19
|
+
// The answer of a login: the profile, and whether it is the default one (the first login makes it so).
|
|
20
|
+
const loggedIn = (ctx, email, r) => ({profile: email, loggedIn: true, default: r.isDefault, defaultProfile: defaultProfile(ctx.env) ?? null});
|
|
21
|
+
|
|
22
|
+
function checkCode(code) {
|
|
23
|
+
const c = String(code).trim();
|
|
24
|
+
if (!/^[A-Za-z0-9]{4,12}$/.test(c)) throw new UsageError(`Not a code from the e-mail: ${code}`);
|
|
25
|
+
return c;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The prompt goes to stderr (stdout is data); the answer is the next line of stdin (lines are buffered, so typed
|
|
29
|
+
// ahead or piped answers are not lost).
|
|
30
|
+
async function ask(lines, ctx, question) {
|
|
31
|
+
ctx.stderr.write(question);
|
|
32
|
+
const next = await lines.next();
|
|
33
|
+
if (next.done) throw new AuthError('No answer: stdin was closed. Nothing was saved.');
|
|
34
|
+
return next.value.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function interactive(ctx, emailArg) {
|
|
38
|
+
const rl = createInterface({input: ctx.stdin, terminal: false});
|
|
39
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
40
|
+
try {
|
|
41
|
+
const email = checkEmail(emailArg ?? (await ask(lines, ctx, 'E-mail of your Wildix account: ')));
|
|
42
|
+
let session = await startLogin(email, ctx.fetch);
|
|
43
|
+
ctx.info(`A code was sent to ${email}.`);
|
|
44
|
+
for (let attempt = 1; attempt <= TRIES; attempt += 1) {
|
|
45
|
+
const code = checkCode(await ask(lines, ctx, 'Code from the e-mail: '));
|
|
46
|
+
const r = await finishLogin(email, session, code, {env: ctx.env, fetchImpl: ctx.fetch, now: ctx.clock()});
|
|
47
|
+
if (r.saved) return loggedIn(ctx, email, r);
|
|
48
|
+
session = r.session;
|
|
49
|
+
if (!session) break;
|
|
50
|
+
if (attempt < TRIES) ctx.info('The code was not accepted. Try again.');
|
|
51
|
+
}
|
|
52
|
+
throw new AuthError(`The code was not accepted. Start again: wx login ${email}`);
|
|
53
|
+
} finally {
|
|
54
|
+
rl.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const login = {
|
|
59
|
+
summary: 'log in with a one-time code sent to your e-mail',
|
|
60
|
+
usage: 'wx login [<e-mail>] [--send-code | --code CODE]',
|
|
61
|
+
details: [
|
|
62
|
+
'In a terminal, wx login asks for the code and waits. Without one (scripts, agents), sign in in two steps:',
|
|
63
|
+
' wx login <e-mail> --send-code Wildix e-mails a code',
|
|
64
|
+
' wx login <e-mail> --code <code> within 3 minutes',
|
|
65
|
+
'The session is shared with the wildix-auth agent skill (~/.wildix/tokens).',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
options: {
|
|
68
|
+
'send-code': {type: 'boolean', description: 'only send the code; finish later with --code'},
|
|
69
|
+
code: {type: 'string', placeholder: 'CODE', description: 'the code from the e-mail, after --send-code'},
|
|
70
|
+
},
|
|
71
|
+
args: [0, 1],
|
|
72
|
+
async run(ctx) {
|
|
73
|
+
const {values, positionals} = ctx;
|
|
74
|
+
if (values['send-code'] && values.code) throw new UsageError('Give --send-code or --code, not both', ctx.usage());
|
|
75
|
+
if (values['send-code'] || values.code) {
|
|
76
|
+
if (!positionals[0]) throw new UsageError('The e-mail is needed', ctx.usage());
|
|
77
|
+
const email = checkEmail(positionals[0]);
|
|
78
|
+
const now = ctx.clock();
|
|
79
|
+
if (values['send-code']) {
|
|
80
|
+
savePending(email, await startLogin(email, ctx.fetch), {env: ctx.env, now});
|
|
81
|
+
return {profile: email, codeSent: true, expiresAt: new Date(now + PENDING_TTL_MS).toISOString()};
|
|
82
|
+
}
|
|
83
|
+
const code = checkCode(values.code);
|
|
84
|
+
const pending = readPending(email, {env: ctx.env, now});
|
|
85
|
+
if (!pending) throw new AuthError(`No sign-in waits for a code for ${email} (or it is older than 3 minutes). Start with: wx login ${email} --send-code`);
|
|
86
|
+
const r = await finishLogin(email, pending.session, code, {env: ctx.env, fetchImpl: ctx.fetch, now});
|
|
87
|
+
if (r.saved) {
|
|
88
|
+
dropPending(email, ctx.env);
|
|
89
|
+
return loggedIn(ctx, email, r);
|
|
90
|
+
}
|
|
91
|
+
if (r.session) savePending(email, r.session, {env: ctx.env, now: pending.startedAt});
|
|
92
|
+
else dropPending(email, ctx.env);
|
|
93
|
+
throw new AuthError(`The code was not accepted.${r.session ? ` Try again: wx login ${email} --code <code>` : ` Start again: wx login ${email} --send-code`}`);
|
|
94
|
+
}
|
|
95
|
+
if (!ctx.stdin.isTTY) {
|
|
96
|
+
throw new UsageError('No terminal to ask for the code: use wx login <e-mail> --send-code, then wx login <e-mail> --code <code>', ctx.usage());
|
|
97
|
+
}
|
|
98
|
+
return interactive(ctx, positionals[0]);
|
|
99
|
+
},
|
|
100
|
+
text(r) {
|
|
101
|
+
if (r.codeSent) return `A code was sent to ${r.profile}. Finish before ${r.expiresAt}: wx login ${r.profile} --code <code>`;
|
|
102
|
+
if (r.default) return `Logged in as ${r.profile} (the default profile)`;
|
|
103
|
+
return [`Logged in as ${r.profile}. The default profile stays ${r.defaultProfile}.`, `Use it with --profile ${r.profile}, or make it the default: wx profile use ${r.profile}`];
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const logout = {
|
|
108
|
+
summary: 'log out: revoke the session of this device, or of every device',
|
|
109
|
+
usage: 'wx logout [--profile EMAIL] [--everywhere]',
|
|
110
|
+
options: {
|
|
111
|
+
everywhere: {type: 'boolean', description: 'sign out of every device and app, not only this one'},
|
|
112
|
+
},
|
|
113
|
+
args: [0, 0],
|
|
114
|
+
async run(ctx) {
|
|
115
|
+
const email = pickProfile(ctx.env, ctx.values.profile);
|
|
116
|
+
const everywhere = Boolean(ctx.values.everywhere);
|
|
117
|
+
try {
|
|
118
|
+
await signOut(email, {everywhere, env: ctx.env, fetchImpl: ctx.fetch, now: ctx.clock()});
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e instanceof AuthError) throw new AuthError(`${email} was NOT logged out (${e.message}). The local session is kept.`);
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
return {profile: email, loggedOut: true, everywhere};
|
|
124
|
+
},
|
|
125
|
+
text: (r) => `Logged out ${r.profile} (${r.everywhere ? 'every device' : 'this device'})`,
|
|
126
|
+
};
|