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,101 @@
1
+ // wx section: the user's sections of the chat inbox (the agent skill's sections.sh). The list and its counters come
2
+ // from the inbox; creating, renaming, deleting and moving channels are not implemented by the service yet.
3
+ import {UsageError} from 'wldx-core/errors';
4
+ import {apiPath} from 'wldx-core/http';
5
+ import {channelId} from '../stream.mjs';
6
+ import {done} from '../chat/format.mjs';
7
+ import {NOT_IMPLEMENTED, api, notImplemented} from '../chat/lib.mjs';
8
+ import {sectionLines} from './inbox.mjs';
9
+
10
+ function sectionId(value) {
11
+ if (!/^[a-z0-9_-]{1,32}$/.test(String(value)))
12
+ throw new UsageError(`A section id must match [a-z0-9_-] (up to 32): ${value}`);
13
+ return value;
14
+ }
15
+
16
+ function sectionName(value) {
17
+ const v = String(value ?? '');
18
+ if (!v.trim() || v.length > 80) throw new UsageError(`A section name has 1 to 80 characters: ${value}`);
19
+ return v;
20
+ }
21
+
22
+ const list = {
23
+ summary: 'your inbox sections with their counters',
24
+ usage: 'wx section list',
25
+ details: 'The sections of wx inbox get. The channels of a section: wx chat list --section ID.',
26
+ options: {},
27
+ args: [0, 0],
28
+ async run(ctx) {
29
+ const r = await api(ctx).call('GET', '/inbox');
30
+ return {sections: r?.sections ?? []};
31
+ },
32
+ text: (r, ctx) => (r.sections.length ? sectionLines(r.sections, ctx) : 'No sections.'),
33
+ };
34
+
35
+ const make = (name, summary, usage, operation, args, request, text) => ({
36
+ summary: `${summary}${NOT_IMPLEMENTED}`,
37
+ usage: `wx section ${name} ${usage}`,
38
+ details: notImplemented(operation),
39
+ options: {},
40
+ args: [args, args],
41
+ async run(ctx) {
42
+ const [method, path, body] = request(ctx.positionals);
43
+ return api(ctx).call(method, path, body);
44
+ },
45
+ text: done(text),
46
+ });
47
+
48
+ const section = {
49
+ summary: `your inbox sections${NOT_IMPLEMENTED.replace(')', ', except list)')}`,
50
+ auth: true,
51
+ commands: {
52
+ list,
53
+ create: make(
54
+ 'create',
55
+ 'create a section',
56
+ '<sectionId> <name>',
57
+ 'CreateSection',
58
+ 2,
59
+ ([id, name]) => ['POST', '/sections', {sectionId: sectionId(id), name: sectionName(name)}],
60
+ 'Created',
61
+ ),
62
+ rename: make(
63
+ 'rename',
64
+ 'rename a section',
65
+ '<sectionId> <name>',
66
+ 'RenameSection',
67
+ 2,
68
+ ([id, name]) => ['PATCH', apiPath`/sections/${sectionId(id)}`, {name: sectionName(name)}],
69
+ 'Renamed',
70
+ ),
71
+ delete: make(
72
+ 'delete',
73
+ 'delete a section',
74
+ '<sectionId>',
75
+ 'DeleteSection',
76
+ 1,
77
+ ([id]) => ['DELETE', apiPath`/sections/${sectionId(id)}`],
78
+ 'Deleted',
79
+ ),
80
+ add: make(
81
+ 'add',
82
+ 'put a channel into a section',
83
+ '<channel> <sectionId>',
84
+ 'MoveChannelToSection',
85
+ 2,
86
+ ([ch, id]) => ['PUT', apiPath`/channels/${channelId(ch)}/sections/${sectionId(id)}`],
87
+ 'Added',
88
+ ),
89
+ remove: make(
90
+ 'remove',
91
+ 'take a channel out of a section',
92
+ '<channel> <sectionId>',
93
+ 'RemoveChannelFromSection',
94
+ 2,
95
+ ([ch, id]) => ['DELETE', apiPath`/channels/${channelId(ch)}/sections/${sectionId(id)}`],
96
+ 'Removed',
97
+ ),
98
+ },
99
+ };
100
+
101
+ export const groups = {section};
@@ -0,0 +1,159 @@
1
+ // wx user: people of the corporate directory on the stream service (the agent skill's users.sh and search.sh
2
+ // users): who you are, a colleague by name (any script, typos, as it sounds), e-mail or phone.
3
+ import {jwtClaims} from 'wldx-core/auth';
4
+ import {UsageError} from 'wldx-core/errors';
5
+ import {paint, table} from 'wldx-core/format';
6
+ import {query} from 'wldx-core/http';
7
+ import {nextPage, userLine} from '../chat/format.mjs';
8
+ import {api, checkId, checkInt, displayName} from '../chat/lib.mjs';
9
+
10
+ const me = {
11
+ summary: 'the logged-in user: id, name, e-mail, extension',
12
+ usage: 'wx user me',
13
+ options: {},
14
+ args: [0, 0],
15
+ async run(ctx) {
16
+ const email = jwtClaims(ctx.token).email;
17
+ if (!email) throw new Error('The login token carries no e-mail');
18
+ const r = await api(ctx).call('POST', '/users/query', {query: {emails: [email]}});
19
+ return r?.users?.[0] ?? null;
20
+ },
21
+ text(u, ctx) {
22
+ if (!u) return 'Not found in the chat directory.';
23
+ const rows = [
24
+ ['Name', paint(ctx).bold(displayName(u) ?? '')],
25
+ ['Id', u.id],
26
+ ['E-mail', u.email ?? ''],
27
+ ];
28
+ if (u.pbxExtension) rows.push(['Extension', u.pbxExtension]);
29
+ if (u.timeZone) rows.push(['Time zone', u.timeZone]);
30
+ if (u.locale) rows.push(['Locale', u.locale]);
31
+ return table(rows);
32
+ },
33
+ };
34
+
35
+ const brief = (r) => ({
36
+ margin: r?.margin,
37
+ nextCursor: r?.nextCursor,
38
+ results: (r?.results ?? []).map((x) => ({
39
+ id: x.user?.id,
40
+ name: x.user?.displayName,
41
+ email: x.user?.email,
42
+ extension: x.user?.pbxExtension,
43
+ bot: x.user?.bot,
44
+ confidence: x.confidence,
45
+ matchedOn: x.matchedOn,
46
+ })),
47
+ });
48
+
49
+ const find = {
50
+ summary: 'find a colleague by name (any script, typos, as it sounds), e-mail or phone',
51
+ usage:
52
+ 'wx user find <query> [--mode auto|name|email|phone] [--kind human|bot|all] [--with-extension] [--min-confidence 0..1] [--brief] [--size 1-50] [--cursor C]',
53
+ details: [
54
+ 'An e-mail or a phone number (with its country code, +…) is looked up exactly (confidence 1). confidence (0..1)',
55
+ 'is how much the query looks like the name, not the probability that it is the right person (default cut 0.5;',
56
+ '--min-confidence 0 shows every candidate). margin is the confidence of the first hit minus the second: near 0,',
57
+ 'the answer is ambiguous: ask which one, never guess. --brief keeps id, name, email, extension, bot, confidence,',
58
+ 'matchedOn (JSON).',
59
+ ].join('\n'),
60
+ options: {
61
+ mode: {
62
+ type: 'string',
63
+ choices: ['auto', 'name', 'email', 'phone'],
64
+ description: 'what the query is (auto by default)',
65
+ },
66
+ kind: {type: 'string', choices: ['human', 'bot', 'all'], description: 'people (default), bots or both'},
67
+ 'with-extension': {type: 'boolean', description: 'only people with a PBX extension'},
68
+ 'min-confidence': {
69
+ type: 'string',
70
+ placeholder: '0..1',
71
+ description: 'drop hits below this confidence (default 0.5)',
72
+ },
73
+ brief: {type: 'boolean', description: 'only the fields needed to pick a person'},
74
+ size: {type: 'string', placeholder: '1-50', description: 'results per page (default 20)'},
75
+ cursor: {type: 'string', description: 'the next page (nextCursor of the previous one)'},
76
+ },
77
+ args: [1, 1],
78
+ async run(ctx) {
79
+ const {values} = ctx;
80
+ const minc = values['min-confidence'];
81
+ if (minc !== undefined && !/^(0|1|0?\.[0-9]+|1\.0+)$/.test(minc))
82
+ throw new UsageError(`--min-confidence is a number from 0 to 1: ${minc}`);
83
+ const q = query({
84
+ q: ctx.positionals[0],
85
+ mode: values.mode,
86
+ kind: values.kind,
87
+ withExtensionOnly: values['with-extension'] && 'true',
88
+ minConfidence: minc,
89
+ size: checkInt('size', values.size, 1, 50),
90
+ cursor: values.cursor,
91
+ });
92
+ const r = await api(ctx).call('GET', `/search/users${q}`);
93
+ return values.brief ? brief(r) : r;
94
+ },
95
+ text(r, ctx) {
96
+ const c = paint(ctx);
97
+ const hits = r?.results ?? [];
98
+ if (!hits.length) return 'Nobody found.';
99
+ const rows = hits.map((x) => {
100
+ const u = x.user ?? {id: x.id, displayName: x.name, email: x.email, pbxExtension: x.extension, bot: x.bot};
101
+ return [...userLine(u, c), `${Number(x.confidence ?? 0).toFixed(2)} ${x.matchedOn ?? ''}`.trim()];
102
+ });
103
+ const lines = table(rows);
104
+ if (hits.length > 1 && typeof r.margin === 'number')
105
+ lines.push(
106
+ r.margin < 0.1
107
+ ? c.yellow(`Ambiguous (margin ${r.margin.toFixed(2)}): several people fit, ask which one`)
108
+ : c.dim(`margin ${r.margin.toFixed(2)}`),
109
+ );
110
+ return [...lines, ...nextPage(r.nextCursor, c)];
111
+ },
112
+ };
113
+
114
+ const EMAIL = /@/;
115
+ const PHONE = /^\+?[0-9]{4,17}$/;
116
+
117
+ const get = {
118
+ summary: 'people by user id, e-mail or phone (exact lookup)',
119
+ usage: 'wx user get [<id|email|phone>...] [--id USERID]... [--email EMAIL]... [--phone PHONE]...',
120
+ details:
121
+ 'A value given without an option is an e-mail when it has "@", a phone when it is digits (+ allowed), else a user id. For a search by name: wx user find.',
122
+ options: {
123
+ id: {type: 'string', multiple: true, placeholder: 'USERID', description: 'a user id'},
124
+ email: {type: 'string', multiple: true, placeholder: 'EMAIL', description: 'an e-mail address'},
125
+ phone: {type: 'string', multiple: true, placeholder: 'PHONE', description: 'a phone number'},
126
+ },
127
+ args: [0, 100],
128
+ async run(ctx) {
129
+ const {values} = ctx;
130
+ const ids = [...(values.id ?? [])];
131
+ const emails = [...(values.email ?? [])];
132
+ const phones = [...(values.phone ?? [])];
133
+ for (const v of ctx.positionals) {
134
+ if (EMAIL.test(v)) emails.push(v);
135
+ else if (PHONE.test(v.replace(/[\s()-]/g, ''))) phones.push(v.replace(/[\s()-]/g, ''));
136
+ else ids.push(v);
137
+ }
138
+ const q = {};
139
+ if (ids.length) q.ids = ids.map((id) => checkId('The user id', id));
140
+ if (emails.length) q.emails = emails;
141
+ if (phones.length) q.phones = phones;
142
+ if (!Object.keys(q).length)
143
+ throw new UsageError('Give a user id, an e-mail or a phone (or --id, --email, --phone)', ctx.usage());
144
+ return api(ctx).call('POST', '/users/query', {query: q});
145
+ },
146
+ text(r, ctx) {
147
+ const c = paint(ctx);
148
+ const list = r?.users ?? [];
149
+ return list.length ? table(list.map((u) => userLine(u, c))) : 'Nobody found.';
150
+ },
151
+ };
152
+
153
+ const user = {
154
+ summary: 'people: you, a colleague by name, e-mail or phone',
155
+ auth: true,
156
+ commands: {me, find, get},
157
+ };
158
+
159
+ export const groups = {user};
@@ -0,0 +1,86 @@
1
+ // Text formatters shared by wx calls, meetings and conversations: the AI summary, the insights, the people of a call.
2
+ import {duration, oneLine} from 'wldx-core/format';
3
+
4
+ const SECTIONS = [
5
+ 'topics',
6
+ 'keyPoints',
7
+ 'review',
8
+ 'decisions',
9
+ 'progress',
10
+ 'issues',
11
+ 'actionItems',
12
+ 'nextSteps',
13
+ 'customerInsights',
14
+ 'customerResponse',
15
+ 'salesProposals',
16
+ 'competitiveInformation',
17
+ 'implementationTimeline',
18
+ 'budget',
19
+ 'tags',
20
+ ];
21
+
22
+ // "actionItems" -> "Action items"
23
+ export const label = (key) => {
24
+ const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
25
+ return words.charAt(0).toUpperCase() + words.slice(1);
26
+ };
27
+
28
+ const valueText = (v) =>
29
+ v === null || v === undefined
30
+ ? ''
31
+ : typeof v === 'object'
32
+ ? Array.isArray(v) && v.every((x) => typeof x !== 'object')
33
+ ? v.join('; ')
34
+ : JSON.stringify(v)
35
+ : String(v);
36
+
37
+ function bullets(title, items, indent = '') {
38
+ const list = (items ?? []).filter((x) => x !== null && x !== undefined && x !== '');
39
+ if (!list.length) return [];
40
+ return [`${indent}${title}:`, ...list.map((x) => `${indent} - ${oneLine(valueText(x), 400)}`)];
41
+ }
42
+
43
+ // The sections of a summary ({topics: [...], decisions: [...], ...}) as bullet lists.
44
+ export function sectionLines(json, indent = '') {
45
+ const lines = [];
46
+ for (const key of SECTIONS) if (Array.isArray(json?.[key])) lines.push(...bullets(label(key), json[key], indent));
47
+ if (json?.csat && (json.csat.score || json.csat.reason))
48
+ lines.push(
49
+ `${indent}CSAT: ${[json.csat.score, json.csat.reason && oneLine(json.csat.reason, 300)].filter(Boolean).join(' · ')}`,
50
+ );
51
+ return lines;
52
+ }
53
+
54
+ // GET .../summary: {status, summary: {title, brief, json}}.
55
+ export function summaryLines(r) {
56
+ if (r?.status !== 'SUCCEEDED') return [`No summary: ${r?.status ?? 'UNKNOWN'}`];
57
+ const s = r.summary ?? {};
58
+ const lines = [];
59
+ if (s.title) lines.push(oneLine(s.title, 200));
60
+ if (s.brief) lines.push(oneLine(s.brief, 2000));
61
+ const sections = sectionLines(s.json);
62
+ if (sections.length) lines.push('', ...sections);
63
+ return lines;
64
+ }
65
+
66
+ // {projectionId: {fieldId: value}} as "projection" lines with indented "field: value".
67
+ export function insightLines(compact, indent = '') {
68
+ const lines = [];
69
+ for (const [id, fields] of Object.entries(compact ?? {})) {
70
+ lines.push(`${indent}${id}:`);
71
+ for (const [f, v] of Object.entries(fields ?? {})) lines.push(`${indent} ${f}: ${oneLine(valueText(v), 300)}`);
72
+ }
73
+ return lines;
74
+ }
75
+
76
+ // A party of a call: "Name (ext 201) +3902…", whatever is known.
77
+ export function party(p) {
78
+ if (!p) return '';
79
+ const ext = p.userExtension ?? p.extension;
80
+ return [p.name, ext && `(${ext})`, p.phone && p.phone !== ext ? p.phone : undefined].filter(Boolean).join(' ');
81
+ }
82
+
83
+ // Seconds or milliseconds as "m:ss".
84
+ export const secs = (s) => (Number.isFinite(s) ? duration(s) : '');
85
+ // A duration from milliseconds; '—' when there is none (no calls in the window).
86
+ export const millis = (ms) => (Number.isFinite(ms) ? duration(ms / 1000) : '—');
@@ -0,0 +1,171 @@
1
+ // Wildix Data Analytics (https://wda.wildix.com/v2): the history of calls and conferences, their AI insights, the
2
+ // search over conversations and the queue reports (x-caracal). A client bound to the token, and the helpers the
3
+ // commands of wx calls, meetings, conversations and reports share (ids, numbers, dates, the shapes of the skill).
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {HttpError, UsageError} from 'wldx-core/errors';
7
+ import {refuseUnsafeWrite} from 'wldx-core/files';
8
+ import {request} from 'wldx-core/http';
9
+ import {wdaUrl} from 'wldx-core/services';
10
+
11
+ const FORBIDDEN_HINT =
12
+ 'The login has no access to this record (a meeting or call of other people, or a role without the right).';
13
+
14
+ // wdaClient({token, env, fetchImpl}) (for ctx.client): get(path), post(path, body), soft(path) (null on a 404).
15
+ // The path is relative to /v2 and built with apiPath`` by the caller.
16
+ export function wdaClient({token, env, fetchImpl}) {
17
+ const root = `${wdaUrl(env)}/v2`;
18
+ async function call(method, p, body) {
19
+ try {
20
+ return await request(root + p, {method, token, body, fetchImpl, service: 'WDA'});
21
+ } catch (e) {
22
+ if (e instanceof HttpError && e.status === 403) e.message += `\n${FORBIDDEN_HINT}`;
23
+ throw e;
24
+ }
25
+ }
26
+ return {
27
+ get: (p) => call('GET', p),
28
+ post: (p, body) => call('POST', p, body),
29
+ async soft(p) {
30
+ try {
31
+ return await call('GET', p);
32
+ } catch (e) {
33
+ if (e instanceof HttpError && e.status === 404) return null;
34
+ throw e;
35
+ }
36
+ },
37
+ };
38
+ }
39
+
40
+ // An optional part of a readable card (a summary, insights): null when it is not there (404); another failure is
41
+ // told on stderr and the card goes on without it (as the scripts of the skill do).
42
+ export async function optional(promise, info) {
43
+ try {
44
+ return await promise;
45
+ } catch (e) {
46
+ if (!(e instanceof HttpError && e.status === 404)) info(`Warning: ${e.message.split('\n')[0]}`);
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Nothing to show: no transcription, or no such record. Exit code 3, as in the scripts of the skill.
52
+ export class NoDataError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'NoDataError';
56
+ this.exitCode = 3;
57
+ }
58
+ }
59
+
60
+ const ID = /^[A-Za-z0-9_+][A-Za-z0-9_.:+-]*$/;
61
+
62
+ // An id that goes into a request path (a call id, a queue id, a file id).
63
+ export function checkId(what, value) {
64
+ const v = String(value ?? '');
65
+ if (!ID.test(v) || v.includes('..')) throw new UsageError(`${what} is not a valid id: ${value}`);
66
+ return v;
67
+ }
68
+
69
+ // A conference id from an id or a link (the last path segment of .../insights/conferences/<id>).
70
+ export function conferenceId(input) {
71
+ const v = String(input).split(/[?#]/)[0].replace(/\/$/, '').split('/').pop();
72
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(v) || v.includes('..') || v === 'conferences')
73
+ throw new UsageError(`not a conference id: ${input}`);
74
+ return v;
75
+ }
76
+
77
+ // A call: the call id and the flow index (a transferred call has several flows; the first is 0).
78
+ export function callFlow(positionals) {
79
+ const [id, flow = '0'] = positionals;
80
+ if (!/^\d{1,9}$/.test(flow)) throw new UsageError(`the flow index must be a number: ${flow}`);
81
+ return {callId: checkId('the call id', id), flow: Number(flow)};
82
+ }
83
+
84
+ // A whole number from min to max (the services refuse anything else with a 400 that tells little).
85
+ export function intOption(name, value, min, max) {
86
+ if (value === undefined) return undefined;
87
+ if (!/^\d{1,12}$/.test(value) || Number(value) < min || Number(value) > max)
88
+ throw new UsageError(`${name} must be a whole number from ${min} to ${max}: ${value}`);
89
+ return Number(value);
90
+ }
91
+
92
+ export function checkZone(value) {
93
+ if (value === undefined) return undefined;
94
+ if (!/^[A-Za-z][A-Za-z0-9_+/-]{0,63}$/.test(value)) throw new UsageError(`not a time zone: ${value}`);
95
+ return value;
96
+ }
97
+
98
+ // An ISO time given to a filter (--from, --to of the call history and the search): passed on as it is, once it
99
+ // is a time at all.
100
+ export function checkIso(name, value) {
101
+ if (value === undefined) return undefined;
102
+ if (!/^\d{4}-\d{2}-\d{2}/.test(value) || Number.isNaN(Date.parse(value)))
103
+ throw new UsageError(`${name} must be an ISO time (2026-11-07 or 2026-11-07T09:00:00Z): ${value}`);
104
+ return value;
105
+ }
106
+
107
+ // The local IANA time zone (TZ, else the zone of the machine).
108
+ export const localZone = (env) => env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
109
+
110
+ // jq's todate: "2026-11-05T11:21:29Z" from milliseconds.
111
+ export const isoSeconds = (ms) => new Date(Math.floor((ms || 0) / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
112
+
113
+ // jq's `a // b // c`: the first value that is not null, undefined or false.
114
+ export const alt = (...values) => values.find((v) => v !== null && v !== undefined && v !== false) ?? null;
115
+
116
+ // {k: obj[k] ?? null} for the keys (jq's `{a, b}`).
117
+ export const pick = (obj, keys) => Object.fromEntries(keys.map((k) => [k, obj?.[k] ?? null]));
118
+
119
+ // jq's `unique`: sorted, no repeats.
120
+ export const unique = (values) => [...new Set(values)].sort();
121
+
122
+ // The insights that succeeded, as {projectionId: {fieldId: value}}.
123
+ export function compactInsights(r) {
124
+ const out = {};
125
+ for (const p of r?.insights ?? []) {
126
+ if (p.status !== 'SUCCEEDED' || !(p.fields ?? []).length) continue;
127
+ out[p.id] = Object.fromEntries(p.fields.map((f) => [f.id, f.value ?? null]));
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // The participants of a conference by name (or e-mail), each once.
133
+ export const participantNames = (conference) =>
134
+ unique(
135
+ (conference?.participants ?? [])
136
+ .map((p) => alt(p.info?.name, p.info?.email))
137
+ .filter((n) => n !== null)
138
+ .map((n) => String(n).replace(/\s+/g, ' ').trim()),
139
+ );
140
+
141
+ // Writes a transcript to a private file (folders 0700, file 0600) and returns its absolute path. An existing path
142
+ // is replaced only when it is a transcript of the same conference written earlier (its second line is
143
+ // "# conferenceId: <id>"): the path may have come from fetched text, so nothing else is touched.
144
+ export function writePrivate(file, content, {cwd, conferenceId: id, env = process.env} = {}) {
145
+ if (!file) throw new UsageError('empty output path');
146
+ const target = path.resolve(cwd ?? process.cwd(), file);
147
+ refuseUnsafeWrite(target, env);
148
+ let exists = false;
149
+ try {
150
+ fs.lstatSync(target);
151
+ exists = true;
152
+ } catch (e) {
153
+ if (e.code !== 'ENOENT') throw e;
154
+ }
155
+ if (exists) {
156
+ let second;
157
+ try {
158
+ second = fs.readFileSync(target, 'utf8').slice(0, 8192).split('\n')[1];
159
+ } catch {
160
+ second = undefined;
161
+ }
162
+ if (!id || second !== `# conferenceId: ${id}`)
163
+ throw new Error(`${file} exists: choose another path (nothing was overwritten)`);
164
+ }
165
+ fs.mkdirSync(path.dirname(target), {recursive: true, mode: 0o700});
166
+ fs.writeFileSync(target, content, {mode: 0o600, flag: exists ? 'w' : 'wx'});
167
+ return target;
168
+ }
169
+
170
+ // "1 queue", "2 queues".
171
+ export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
@@ -0,0 +1,139 @@
1
+ // The connection to the live stream of Wildix Data Analytics (WDA), wss://wda-ws.wildix.com (src/services.mjs).
2
+ //
3
+ // Protocol: {"action":"authorize","token"} -> {"event":"authorized"}; then
4
+ // {"action":"subscribe","topic","version","state","filter"} -> the current state as {"event":"broadcast","topic",
5
+ // "data":[…]} frames, then {"event":"subscribed","topic"}; afterwards one broadcast per change.
6
+ // An error is {"event":"error","message","code","source"}: `source` echoes the request, which for authorize holds
7
+ // the token, so it is never printed (and the token is scrubbed from every message shown).
8
+ import {AuthError} from 'wldx-core/errors';
9
+
10
+ export const AUTH_TIMEOUT_MS = 20_000;
11
+ export const SNAPSHOT_TIMEOUT_MS = 30_000;
12
+
13
+ export const subscription = (topic, extra = {}) => ({action: 'subscribe', topic, version: '2', ...extra});
14
+
15
+ // The message of a refused subscription, as the skill words it.
16
+ export function explain(topic, d, license) {
17
+ if (d.code === 403 && (topic === 'services' || topic === 'conversations')) {
18
+ return `the live service allows ${topic === 'services' ? 'call queues' : 'the conversations of the company'} only to users with a Premium license (this login: ${license ?? 'unknown'})`;
19
+ }
20
+ if (d.code === 401) return 'the live service lost the login: run the command again';
21
+ return `the live service refused the ${topic} subscription (${d.code ?? '?'}): ${d.message}`;
22
+ }
23
+
24
+ // Opens a connection and logs in. Resolves with the connection:
25
+ // listeners a Set of functions called with every frame after the login, and {event: '_closed', code} at the end
26
+ // send(obj) sends one request
27
+ // snapshot(topic, extra) subscribes with the state and resolves with every item broadcast until "subscribed"
28
+ // close() closes it (a _closed frame follows)
29
+ // Rejects with AuthError when the service refuses the token, with Error when it cannot connect.
30
+ export function connect({
31
+ url,
32
+ token,
33
+ WebSocket = globalThis.WebSocket,
34
+ license,
35
+ timeoutMs = AUTH_TIMEOUT_MS,
36
+ snapshotTimeoutMs = SNAPSHOT_TIMEOUT_MS,
37
+ }) {
38
+ const scrub = (s) =>
39
+ token
40
+ ? String(s ?? '')
41
+ .split(token)
42
+ .join('[token]')
43
+ : String(s ?? '');
44
+ return new Promise((resolve, reject) => {
45
+ const listeners = new Set();
46
+ let ws;
47
+ try {
48
+ ws = new WebSocket(`${url}/`);
49
+ } catch (e) {
50
+ reject(new Error(`could not connect to the live service: ${scrub(e.message)}`));
51
+ return;
52
+ }
53
+ let done = false;
54
+ const send = (obj) => {
55
+ try {
56
+ ws.send(JSON.stringify(obj));
57
+ } catch {
58
+ // closed meanwhile: the _closed frame tells the listeners
59
+ }
60
+ };
61
+ const conn = {
62
+ listeners,
63
+ send,
64
+ close() {
65
+ try {
66
+ ws.close(1000);
67
+ } catch {
68
+ // already closed
69
+ }
70
+ },
71
+ snapshot(topic, extra = {}) {
72
+ return new Promise((ok, fail) => {
73
+ const items = [];
74
+ const timer = setTimeout(
75
+ () => finish(new Error(`no answer for ${topic} in ${Math.round(snapshotTimeoutMs / 1000)} seconds`)),
76
+ snapshotTimeoutMs,
77
+ );
78
+ const listener = (d) => {
79
+ if (d.event === 'broadcast' && d.topic === topic)
80
+ items.push(...(Array.isArray(d.data) ? d.data : [d.data]));
81
+ else if (d.event === 'subscribed' && d.topic === topic) finish();
82
+ else if (d.event === 'error')
83
+ finish(Object.assign(new Error(scrub(explain(topic, d, license))), {code: d.code}));
84
+ else if (d.event === '_closed')
85
+ finish(new Error(`the live service closed the connection (code ${d.code})`));
86
+ };
87
+ function finish(err) {
88
+ clearTimeout(timer);
89
+ listeners.delete(listener);
90
+ if (err) fail(err);
91
+ else ok(items);
92
+ }
93
+ listeners.add(listener);
94
+ send(subscription(topic, {state: true, ...extra}));
95
+ });
96
+ },
97
+ scrub,
98
+ };
99
+ const timer = setTimeout(() => {
100
+ if (done) return;
101
+ done = true;
102
+ conn.close();
103
+ reject(new Error(`the live service did not answer in ${Math.round(timeoutMs / 1000)} seconds`));
104
+ }, timeoutMs);
105
+ ws.onopen = () => send({action: 'authorize', token});
106
+ ws.onmessage = (m) => {
107
+ let d;
108
+ try {
109
+ d = JSON.parse(typeof m.data === 'string' ? m.data : Buffer.from(m.data).toString('utf8'));
110
+ } catch {
111
+ return;
112
+ }
113
+ if (!d || typeof d !== 'object') return;
114
+ if (!done) {
115
+ if (d.event === 'authorized') {
116
+ done = true;
117
+ clearTimeout(timer);
118
+ resolve(conn);
119
+ } else if (d.event === 'error') {
120
+ done = true;
121
+ clearTimeout(timer);
122
+ conn.close();
123
+ reject(new AuthError(`the live service refused the login: ${scrub(d.message)}`));
124
+ }
125
+ return;
126
+ }
127
+ for (const l of [...listeners]) l(d);
128
+ };
129
+ ws.onclose = (ev) => {
130
+ if (!done) {
131
+ done = true;
132
+ clearTimeout(timer);
133
+ reject(new Error(`the live service closed the connection (code ${ev?.code})`));
134
+ }
135
+ for (const l of [...listeners]) l({event: '_closed', code: ev?.code});
136
+ };
137
+ ws.onerror = () => {};
138
+ });
139
+ }