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,130 @@
|
|
|
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 '../auth.mjs';
|
|
4
|
+
import {UsageError} from '../errors.mjs';
|
|
5
|
+
import {paint, table} from '../format.mjs';
|
|
6
|
+
import {query} from '../http.mjs';
|
|
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 = [['Name', paint(ctx).bold(displayName(u) ?? '')], ['Id', u.id], ['E-mail', u.email ?? '']];
|
|
24
|
+
if (u.pbxExtension) rows.push(['Extension', u.pbxExtension]);
|
|
25
|
+
if (u.timeZone) rows.push(['Time zone', u.timeZone]);
|
|
26
|
+
if (u.locale) rows.push(['Locale', u.locale]);
|
|
27
|
+
return table(rows);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const brief = (r) => ({
|
|
32
|
+
margin: r?.margin,
|
|
33
|
+
nextCursor: r?.nextCursor,
|
|
34
|
+
results: (r?.results ?? []).map((x) => ({id: x.user?.id, name: x.user?.displayName, email: x.user?.email, extension: x.user?.pbxExtension, bot: x.user?.bot, confidence: x.confidence, matchedOn: x.matchedOn})),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const find = {
|
|
38
|
+
summary: 'find a colleague by name (any script, typos, as it sounds), e-mail or phone',
|
|
39
|
+
usage: '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]',
|
|
40
|
+
details: [
|
|
41
|
+
'An e-mail or a phone number (with its country code, +…) is looked up exactly (confidence 1). confidence (0..1)',
|
|
42
|
+
'is how much the query looks like the name, not the probability that it is the right person (default cut 0.5;',
|
|
43
|
+
'--min-confidence 0 shows every candidate). margin is the confidence of the first hit minus the second: near 0,',
|
|
44
|
+
'the answer is ambiguous: ask which one, never guess. --brief keeps id, name, email, extension, bot, confidence,',
|
|
45
|
+
'matchedOn (JSON).',
|
|
46
|
+
].join('\n'),
|
|
47
|
+
options: {
|
|
48
|
+
mode: {type: 'string', choices: ['auto', 'name', 'email', 'phone'], description: 'what the query is (auto by default)'},
|
|
49
|
+
kind: {type: 'string', choices: ['human', 'bot', 'all'], description: 'people (default), bots or both'},
|
|
50
|
+
'with-extension': {type: 'boolean', description: 'only people with a PBX extension'},
|
|
51
|
+
'min-confidence': {type: 'string', placeholder: '0..1', description: 'drop hits below this confidence (default 0.5)'},
|
|
52
|
+
brief: {type: 'boolean', description: 'only the fields needed to pick a person'},
|
|
53
|
+
size: {type: 'string', placeholder: '1-50', description: 'results per page (default 20)'},
|
|
54
|
+
cursor: {type: 'string', description: 'the next page (nextCursor of the previous one)'},
|
|
55
|
+
},
|
|
56
|
+
args: [1, 1],
|
|
57
|
+
async run(ctx) {
|
|
58
|
+
const {values} = ctx;
|
|
59
|
+
const minc = values['min-confidence'];
|
|
60
|
+
if (minc !== undefined && !/^(0|1|0?\.[0-9]+|1\.0+)$/.test(minc)) throw new UsageError(`--min-confidence is a number from 0 to 1: ${minc}`);
|
|
61
|
+
const q = query({
|
|
62
|
+
q: ctx.positionals[0],
|
|
63
|
+
mode: values.mode,
|
|
64
|
+
kind: values.kind,
|
|
65
|
+
withExtensionOnly: values['with-extension'] && 'true',
|
|
66
|
+
minConfidence: minc,
|
|
67
|
+
size: checkInt('size', values.size, 1, 50),
|
|
68
|
+
cursor: values.cursor,
|
|
69
|
+
});
|
|
70
|
+
const r = await api(ctx).call('GET', `/search/users${q}`);
|
|
71
|
+
return values.brief ? brief(r) : r;
|
|
72
|
+
},
|
|
73
|
+
text(r, ctx) {
|
|
74
|
+
const c = paint(ctx);
|
|
75
|
+
const hits = r?.results ?? [];
|
|
76
|
+
if (!hits.length) return 'Nobody found.';
|
|
77
|
+
const rows = hits.map((x) => {
|
|
78
|
+
const u = x.user ?? {id: x.id, displayName: x.name, email: x.email, pbxExtension: x.extension, bot: x.bot};
|
|
79
|
+
return [...userLine(u, c), `${Number(x.confidence ?? 0).toFixed(2)} ${x.matchedOn ?? ''}`.trim()];
|
|
80
|
+
});
|
|
81
|
+
const lines = table(rows);
|
|
82
|
+
if (hits.length > 1 && typeof r.margin === 'number') lines.push(r.margin < 0.1 ? c.yellow(`Ambiguous (margin ${r.margin.toFixed(2)}): several people fit, ask which one`) : c.dim(`margin ${r.margin.toFixed(2)}`));
|
|
83
|
+
return [...lines, ...nextPage(r.nextCursor, c)];
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const EMAIL = /@/;
|
|
88
|
+
const PHONE = /^\+?[0-9]{4,17}$/;
|
|
89
|
+
|
|
90
|
+
const get = {
|
|
91
|
+
summary: 'people by user id, e-mail or phone (exact lookup)',
|
|
92
|
+
usage: 'wx user get [<id|email|phone>...] [--id USERID]... [--email EMAIL]... [--phone PHONE]...',
|
|
93
|
+
details: '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.',
|
|
94
|
+
options: {
|
|
95
|
+
id: {type: 'string', multiple: true, placeholder: 'USERID', description: 'a user id'},
|
|
96
|
+
email: {type: 'string', multiple: true, placeholder: 'EMAIL', description: 'an e-mail address'},
|
|
97
|
+
phone: {type: 'string', multiple: true, placeholder: 'PHONE', description: 'a phone number'},
|
|
98
|
+
},
|
|
99
|
+
args: [0, 100],
|
|
100
|
+
async run(ctx) {
|
|
101
|
+
const {values} = ctx;
|
|
102
|
+
const ids = [...(values.id ?? [])];
|
|
103
|
+
const emails = [...(values.email ?? [])];
|
|
104
|
+
const phones = [...(values.phone ?? [])];
|
|
105
|
+
for (const v of ctx.positionals) {
|
|
106
|
+
if (EMAIL.test(v)) emails.push(v);
|
|
107
|
+
else if (PHONE.test(v.replace(/[\s()-]/g, ''))) phones.push(v.replace(/[\s()-]/g, ''));
|
|
108
|
+
else ids.push(v);
|
|
109
|
+
}
|
|
110
|
+
const q = {};
|
|
111
|
+
if (ids.length) q.ids = ids.map((id) => checkId('The user id', id));
|
|
112
|
+
if (emails.length) q.emails = emails;
|
|
113
|
+
if (phones.length) q.phones = phones;
|
|
114
|
+
if (!Object.keys(q).length) throw new UsageError('Give a user id, an e-mail or a phone (or --id, --email, --phone)', ctx.usage());
|
|
115
|
+
return api(ctx).call('POST', '/users/query', {query: q});
|
|
116
|
+
},
|
|
117
|
+
text(r, ctx) {
|
|
118
|
+
const c = paint(ctx);
|
|
119
|
+
const list = r?.users ?? [];
|
|
120
|
+
return list.length ? table(list.map((u) => userLine(u, c))) : 'Nobody found.';
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const user = {
|
|
125
|
+
summary: 'people: you, a colleague by name, e-mail or phone',
|
|
126
|
+
auth: true,
|
|
127
|
+
commands: {me, find, get},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const groups = {user};
|
package/src/completion.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Shell completion. `wx completion <shell>` prints a small script; on every Tab it runs
|
|
2
2
|
// `wx __complete -- <words after wx, the last one being the word under the cursor>`, which prints the candidates,
|
|
3
3
|
// one per line as "value<TAB>description". The candidates come from the command definitions (and the saved
|
|
4
|
-
//
|
|
4
|
+
// profiles for --profile), so completion follows the commands without a regenerated script. No network.
|
|
5
5
|
import {sessions} from './auth.mjs';
|
|
6
|
-
import {COMMON_OPTIONS,
|
|
6
|
+
import {COMMON_OPTIONS, GLOBAL_OPTIONS, REGISTRY, commandOf, groupOf, singleOf} from './commands/index.mjs';
|
|
7
7
|
|
|
8
8
|
export const SHELLS = ['bash', 'zsh', 'fish'];
|
|
9
9
|
|
|
@@ -11,60 +11,87 @@ export const TOP_LEVEL = {
|
|
|
11
11
|
completion: 'print the shell completion script (bash, zsh, fish)',
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const TOP_OPTIONS = {'--help': 'help of wx', '--version': 'the version of wx'};
|
|
14
|
+
const TOP_OPTIONS = {'--help': 'help of wx', '--version': 'the version of wx', '--profile': 'the profile (a logged-in e-mail) to use', '--output': 'text or json'};
|
|
15
15
|
|
|
16
16
|
function valuesOf(option, env) {
|
|
17
17
|
if (option.choices) return option.choices.map((v) => [v]);
|
|
18
|
-
if (option.complete === '
|
|
18
|
+
if (option.complete === 'profiles') return sessions(env).map((s) => [s.email]);
|
|
19
19
|
return [];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// The candidates for the word under the cursor: ["value\tdescription", ...].
|
|
23
|
-
export function complete(words, env = process.env) {
|
|
23
|
+
export function complete(words, env = process.env, registry = REGISTRY) {
|
|
24
24
|
const cur = words.length ? words[words.length - 1] : '';
|
|
25
|
-
const done = words.slice(0, -1);
|
|
26
25
|
const offer = (pairs) => pairs.filter(([v]) => v.startsWith(cur)).map(([v, d]) => (d ? `${v}\t${d}` : v));
|
|
26
|
+
// The global options may stand anywhere: the value of one is offered, and they are left out of what names the
|
|
27
|
+
// command (wx --profile a@b.c chat <Tab> completes like wx chat <Tab>).
|
|
28
|
+
const globalOf = (word) => {
|
|
29
|
+
const name = word?.startsWith('--') ? word.slice(2).split('=')[0] : undefined;
|
|
30
|
+
return name && Object.hasOwn(GLOBAL_OPTIONS, name) ? name : undefined;
|
|
31
|
+
};
|
|
32
|
+
const before = words.slice(0, -1);
|
|
33
|
+
const last = before[before.length - 1];
|
|
34
|
+
if (globalOf(last) && !last.includes('=') && GLOBAL_OPTIONS[globalOf(last)].type === 'string') {
|
|
35
|
+
return offer(valuesOf(GLOBAL_OPTIONS[globalOf(last)], env));
|
|
36
|
+
}
|
|
37
|
+
const done = [];
|
|
38
|
+
for (let i = 0; i < before.length; i += 1) {
|
|
39
|
+
const name = globalOf(before[i]);
|
|
40
|
+
if (name) {
|
|
41
|
+
if (GLOBAL_OPTIONS[name].type === 'string' && !before[i].includes('=')) i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
done.push(before[i]);
|
|
45
|
+
}
|
|
27
46
|
|
|
28
47
|
if (done.length === 0) {
|
|
29
48
|
return offer([
|
|
30
|
-
...Object.entries(
|
|
49
|
+
...Object.entries(registry.groups).map(([name, g]) => [name, g.summary]),
|
|
50
|
+
...Object.entries(registry.commands).map(([name, c]) => [name, c.summary]),
|
|
31
51
|
...Object.entries(TOP_LEVEL),
|
|
32
52
|
...(cur.startsWith('-') ? Object.entries(TOP_OPTIONS) : []),
|
|
33
53
|
]);
|
|
34
54
|
}
|
|
35
55
|
if (done[0] === 'completion') return done.length === 1 ? offer(SHELLS.map((s) => [s, `the script for ${s}`])) : [];
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
// The command and how many words name it: 1 for a single command, 2 for a group and its command.
|
|
58
|
+
let cmd = singleOf(done[0], registry);
|
|
59
|
+
let depth = 1;
|
|
60
|
+
if (!cmd) {
|
|
61
|
+
const group = groupOf(done[0], registry);
|
|
62
|
+
if (!group) return [];
|
|
63
|
+
if (done.length === 1) return offer(Object.entries(group.commands).map(([name, c]) => [name, c.summary]));
|
|
64
|
+
cmd = commandOf(group, done[1]);
|
|
65
|
+
depth = 2;
|
|
66
|
+
}
|
|
42
67
|
if (!cmd) return [];
|
|
43
|
-
const options = {...cmd.options, ...COMMON_OPTIONS};
|
|
68
|
+
const options = {...cmd.options, ...COMMON_OPTIONS, ...GLOBAL_OPTIONS};
|
|
44
69
|
const prev = done[done.length - 1];
|
|
45
|
-
if (done.length >
|
|
70
|
+
if (done.length > depth && prev.startsWith('--')) {
|
|
46
71
|
const option = Object.hasOwn(options, prev.slice(2)) ? options[prev.slice(2)] : undefined;
|
|
47
72
|
if (option?.type === 'string') return offer(valuesOf(option, env));
|
|
48
73
|
}
|
|
49
74
|
if (cur.startsWith('-')) {
|
|
50
|
-
const used = new Set(done.slice(
|
|
75
|
+
const used = new Set(done.slice(depth).filter((w) => w.startsWith('--')).map((w) => w.slice(2)));
|
|
51
76
|
return offer(Object.entries(options).filter(([name, o]) => o.multiple || !used.has(name)).map(([name, o]) => [`--${name}`, o.description]));
|
|
52
77
|
}
|
|
78
|
+
// A command whose argument is a profile (wx profile use <Tab>).
|
|
79
|
+
if (cmd.complete === 'profiles') return offer(valuesOf({complete: 'profiles'}, env));
|
|
53
80
|
return [];
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
const SCRIPTS = {
|
|
57
84
|
bash: `# wx completion for bash. Add to ~/.bashrc: eval "$(wx completion bash)"
|
|
58
85
|
_wx_complete() {
|
|
59
|
-
# The words are taken
|
|
86
|
+
# The words are taken as an array first: bash 3.2 (macOS) joins the elements of a quoted array slice when IFS
|
|
60
87
|
# is a newline.
|
|
61
88
|
local words=("\${COMP_WORDS[@]:1:COMP_CWORD}") out line
|
|
62
89
|
COMPREPLY=()
|
|
63
90
|
out=$(wx __complete -- "\${words[@]}" 2>/dev/null) || return 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
COMPREPLY+=("\${line%%$'\\t'*}")
|
|
67
|
-
done
|
|
91
|
+
# Line by line from a here-string: no word splitting and no glob expansion of the candidates.
|
|
92
|
+
while IFS= read -r line; do
|
|
93
|
+
[[ -n $line ]] && COMPREPLY+=("\${line%%$'\\t'*}")
|
|
94
|
+
done <<< "$out"
|
|
68
95
|
}
|
|
69
96
|
complete -F _wx_complete wx
|
|
70
97
|
`,
|
package/src/files.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Local files that commands read or write on behalf of the user, where the path may have come from fetched text
|
|
2
|
+
// (an agent asked to "save the attachment to …" or to "attach …"): the guards that keep secrets in and startup
|
|
3
|
+
// files out.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {configDir} from './auth.mjs';
|
|
8
|
+
import {UsageError} from './errors.mjs';
|
|
9
|
+
|
|
10
|
+
// macOS and Windows file systems ignore case by default: compare paths without it there.
|
|
11
|
+
const FOLD = process.platform === 'darwin' || process.platform === 'win32';
|
|
12
|
+
const norm = (p) => (FOLD ? p.toLowerCase() : p);
|
|
13
|
+
const within = (child, parent) => norm(child) === norm(parent) || norm(child).startsWith(norm(parent) + path.sep);
|
|
14
|
+
|
|
15
|
+
// The real path (symlinks resolved, true case on macOS) of a path that may not exist yet: the nearest existing
|
|
16
|
+
// ancestor resolved, the rest appended.
|
|
17
|
+
export function realPath(p) {
|
|
18
|
+
const abs = path.resolve(p);
|
|
19
|
+
let head = abs;
|
|
20
|
+
const rest = [];
|
|
21
|
+
for (;;) {
|
|
22
|
+
try {
|
|
23
|
+
return path.join(fs.realpathSync.native(head), ...rest);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') throw e;
|
|
26
|
+
const up = path.dirname(head);
|
|
27
|
+
if (up === head) return abs;
|
|
28
|
+
rest.unshift(path.basename(head));
|
|
29
|
+
head = up;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const homeOf = (env) => env.HOME || os.homedir();
|
|
35
|
+
|
|
36
|
+
// Where a file written for the user must never land: anything hidden (a segment that starts with ".": ~/.ssh,
|
|
37
|
+
// ~/.zshenv, .git/hooks, node_modules/.bin…) and the macOS startup folders. A downloaded file there could give
|
|
38
|
+
// someone a login or run code; a person who really wants it there can move it.
|
|
39
|
+
const STARTUP = ['Library/LaunchAgents', 'Library/LaunchDaemons', 'Library/StartupItems'];
|
|
40
|
+
|
|
41
|
+
// Returns the real path; throws a UsageError for a path that is refused.
|
|
42
|
+
export function refuseUnsafeWrite(target, env = process.env) {
|
|
43
|
+
const real = realPath(target);
|
|
44
|
+
const hidden = real.split(path.sep).find((s) => s.startsWith('.') && s !== '.' && s !== '..');
|
|
45
|
+
if (hidden) throw new UsageError(`Refusing to write into a hidden file or folder (${hidden}): ${target}. Choose a visible path.`);
|
|
46
|
+
const homes = [homeOf(env), realPath(homeOf(env))];
|
|
47
|
+
if (homes.some((h) => STARTUP.some((d) => within(real, path.join(h, d)) || within(real, path.join('/', d))))) {
|
|
48
|
+
throw new UsageError(`Refusing to write into a startup folder: ${target}`);
|
|
49
|
+
}
|
|
50
|
+
return real;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Credentials: folders and files that hold keys and tokens, and names that look like secrets.
|
|
54
|
+
const SECRET_FOLDERS = ['.wildix', '.ssh', '.aws', '.gnupg', '.config/gcloud', '.kube', '.docker', '.claude', '.agents'];
|
|
55
|
+
const SECRET_FILES = ['.netrc', '.npmrc'];
|
|
56
|
+
const SECRET_NAME = /^(\.env|\.env\..*|.*\.pem|.*\.key|.*\.p12|.*\.pfx|id_rsa.*|id_ed25519.*|id_ecdsa.*|.*\.kdbx|credentials.*\.json|.*secret.*|.*\.token)$/is;
|
|
57
|
+
|
|
58
|
+
// A file to send out of the machine (an attachment) is refused when it is, or points to, a credential: its real
|
|
59
|
+
// path (symlinks resolved, true case) in a credentials folder, a secret-looking name (given or real), or a file with
|
|
60
|
+
// other hard links (which could be one). The user can copy the file elsewhere if they really mean it.
|
|
61
|
+
export function refuseSecretPath(file, env = process.env) {
|
|
62
|
+
let real;
|
|
63
|
+
let stat;
|
|
64
|
+
try {
|
|
65
|
+
real = fs.realpathSync.native(file);
|
|
66
|
+
stat = fs.statSync(real);
|
|
67
|
+
} catch {
|
|
68
|
+
real = realPath(file); // not there (yet): its folder still counts
|
|
69
|
+
}
|
|
70
|
+
const home = homeOf(env);
|
|
71
|
+
const roots = [home, realPath(home)];
|
|
72
|
+
const folders = [configDir(env), realPath(configDir(env)), ...roots.flatMap((h) => SECRET_FOLDERS.map((d) => path.join(h, d)))];
|
|
73
|
+
const files = roots.flatMap((h) => SECRET_FILES.map((f) => path.join(h, f)));
|
|
74
|
+
if (folders.some((d) => within(real, d)) || files.some((f) => norm(f) === norm(real))) {
|
|
75
|
+
throw new UsageError(`Refusing to attach a file from a credentials folder: ${file}`);
|
|
76
|
+
}
|
|
77
|
+
for (const name of new Set([path.basename(file), path.basename(real)])) {
|
|
78
|
+
if (SECRET_NAME.test(name)) throw new UsageError(`Refusing to attach what looks like a secret: ${name}`);
|
|
79
|
+
}
|
|
80
|
+
if (stat?.isFile() && stat.nlink > 1) throw new UsageError(`Refusing to attach a file with other hard links (it could be a credential): ${file}`);
|
|
81
|
+
return {real, stat};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Reads a file to send out: checked with refuseSecretPath, then read from the descriptor that was opened, which
|
|
85
|
+
// must still be the file that was checked (nothing can be swapped in between).
|
|
86
|
+
export function readForUpload(file, env = process.env) {
|
|
87
|
+
const {real, stat} = refuseSecretPath(file, env);
|
|
88
|
+
if (!stat) throw new UsageError(`File not found: ${file}`);
|
|
89
|
+
if (!stat.isFile()) throw new UsageError(`Not a file: ${file}`);
|
|
90
|
+
const fd = fs.openSync(real, 'r');
|
|
91
|
+
try {
|
|
92
|
+
const now = fs.fstatSync(fd);
|
|
93
|
+
if (now.ino !== stat.ino || now.dev !== stat.dev || now.nlink > 1) throw new UsageError(`The file changed while it was checked: ${file}`);
|
|
94
|
+
return fs.readFileSync(fd);
|
|
95
|
+
} finally {
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/format.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Text output: what `--output text` (the default) prints. A command gives `text(result, ctx)` (and `textEvent(event,
|
|
2
|
+
// ctx)` for a streaming one) returning a string or an array of lines; without one, toText() prints the data as
|
|
3
|
+
// indented "key: value" lines. Helpers for formatters: colors (only on a terminal), times, tables.
|
|
4
|
+
|
|
5
|
+
// Colors: on when ctx.color (a terminal, no --no-color, no NO_COLOR).
|
|
6
|
+
const CODES = {bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39], blue: [34, 39], cyan: [36, 39]};
|
|
7
|
+
export function paint(ctx) {
|
|
8
|
+
const on = Boolean(ctx?.color);
|
|
9
|
+
return Object.fromEntries(Object.entries(CODES).map(([name, [a, b]]) => [name, (s) => (on ? `\x1b[${a}m${s}\x1b[${b}m` : String(s))]));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
13
|
+
|
|
14
|
+
// "2026-11-07 10:23" in the local time zone of the machine (ISO string or ms); '' for nothing.
|
|
15
|
+
export function localTime(value, {seconds = false} = {}) {
|
|
16
|
+
if (value === undefined || value === null || value === '') return '';
|
|
17
|
+
const d = new Date(typeof value === 'number' || /^\d+$/.test(String(value)) ? Number(value) : value);
|
|
18
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
19
|
+
const t = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
20
|
+
return seconds ? `${t}:${pad2(d.getSeconds())}` : t;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// "1:05:09" / "5:09" from seconds.
|
|
24
|
+
export function duration(seconds) {
|
|
25
|
+
if (!Number.isFinite(seconds)) return '';
|
|
26
|
+
const s = Math.max(0, Math.round(seconds));
|
|
27
|
+
const h = Math.floor(s / 3600);
|
|
28
|
+
const m = Math.floor((s % 3600) / 60);
|
|
29
|
+
return h ? `${h}:${pad2(m)}:${pad2(s % 60)}` : `${m}:${pad2(s % 60)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One line: newlines folded, cut to max characters.
|
|
33
|
+
export function oneLine(text, max = 120) {
|
|
34
|
+
const s = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Aligned columns: rows are arrays of cells; the last column is not padded. Colors in cells are not counted.
|
|
39
|
+
export function table(rows) {
|
|
40
|
+
const visible = (s) => String(s ?? '').replace(/\x1b\[\d+m/g, '').length;
|
|
41
|
+
const widths = [];
|
|
42
|
+
for (const row of rows) row.forEach((c, i) => (widths[i] = Math.max(widths[i] ?? 0, visible(c))));
|
|
43
|
+
return rows.map((row) => row.map((c, i) => (i === row.length - 1 ? String(c ?? '') : String(c ?? '') + ' '.repeat(widths[i] - visible(c)))).join(' ').trimEnd());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const scalar = (v) => v === null || typeof v !== 'object';
|
|
47
|
+
|
|
48
|
+
// Any data as indented "key: value" lines (the fallback of commands without a formatter).
|
|
49
|
+
export function toText(value, indent = '') {
|
|
50
|
+
if (value === undefined) return [];
|
|
51
|
+
if (scalar(value)) return [`${indent}${value}`];
|
|
52
|
+
const lines = [];
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
if (value.every(scalar)) return [`${indent}${value.join(', ')}`];
|
|
55
|
+
value.forEach((item, i) => {
|
|
56
|
+
if (i && !scalar(item)) lines.push('');
|
|
57
|
+
lines.push(...toText(item, indent));
|
|
58
|
+
});
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
for (const [k, v] of Object.entries(value)) {
|
|
62
|
+
if (v === undefined) continue;
|
|
63
|
+
if (scalar(v)) lines.push(`${indent}${k}: ${v}`);
|
|
64
|
+
else if (Array.isArray(v) && v.every(scalar)) lines.push(`${indent}${k}: ${v.join(', ')}`);
|
|
65
|
+
else if (Array.isArray(v) && !v.length) lines.push(`${indent}${k}: (none)`);
|
|
66
|
+
else lines.push(`${indent}${k}:`, ...toText(v, `${indent} `));
|
|
67
|
+
}
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Text for a terminal from data that may hold control sequences (a message, a name, an error of a service): kept
|
|
72
|
+
// are newlines, tabs and the color codes paint() itself writes; every other control character is dropped, so a
|
|
73
|
+
// fetched text cannot write the clipboard (OSC 52), move the cursor, rewrite lines or hide text.
|
|
74
|
+
const OWN_SGR = new Set(Object.values(CODES).flat().map(String));
|
|
75
|
+
export function clean(text) {
|
|
76
|
+
// NUL goes first: it then marks the color codes that are kept, and no input can forge such a mark.
|
|
77
|
+
return String(text)
|
|
78
|
+
.replace(/\u0000/g, '')
|
|
79
|
+
.replace(/\x1b\[(\d+)m/g, (m, code) => (OWN_SGR.has(code) ? `\u0000${code}\u0000` : ''))
|
|
80
|
+
.replace(/[\x01-\x08\x0b-\x1f\x7f-\x9f]/g, '')
|
|
81
|
+
.replace(/\u0000(\d+)\u0000/g, '\x1b[$1m');
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 '../format.mjs';
|
|
3
|
+
|
|
4
|
+
const SECTIONS = ['topics', 'keyPoints', 'review', 'decisions', 'progress', 'issues', 'actionItems', 'nextSteps', 'customerInsights', 'customerResponse', 'salesProposals', 'competitiveInformation', 'implementationTimeline', 'budget', 'tags'];
|
|
5
|
+
|
|
6
|
+
// "actionItems" -> "Action items"
|
|
7
|
+
export const label = (key) => {
|
|
8
|
+
const words = key.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
|
|
9
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const valueText = (v) => (v === null || v === undefined ? '' : typeof v === 'object' ? (Array.isArray(v) && v.every((x) => typeof x !== 'object') ? v.join('; ') : JSON.stringify(v)) : String(v));
|
|
13
|
+
|
|
14
|
+
function bullets(title, items, indent = '') {
|
|
15
|
+
const list = (items ?? []).filter((x) => x !== null && x !== undefined && x !== '');
|
|
16
|
+
if (!list.length) return [];
|
|
17
|
+
return [`${indent}${title}:`, ...list.map((x) => `${indent} - ${oneLine(valueText(x), 400)}`)];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The sections of a summary ({topics: [...], decisions: [...], ...}) as bullet lists.
|
|
21
|
+
export function sectionLines(json, indent = '') {
|
|
22
|
+
const lines = [];
|
|
23
|
+
for (const key of SECTIONS) if (Array.isArray(json?.[key])) lines.push(...bullets(label(key), json[key], indent));
|
|
24
|
+
if (json?.csat && (json.csat.score || json.csat.reason)) lines.push(`${indent}CSAT: ${[json.csat.score, json.csat.reason && oneLine(json.csat.reason, 300)].filter(Boolean).join(' · ')}`);
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// GET .../summary: {status, summary: {title, brief, json}}.
|
|
29
|
+
export function summaryLines(r) {
|
|
30
|
+
if (r?.status !== 'SUCCEEDED') return [`No summary: ${r?.status ?? 'UNKNOWN'}`];
|
|
31
|
+
const s = r.summary ?? {};
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (s.title) lines.push(oneLine(s.title, 200));
|
|
34
|
+
if (s.brief) lines.push(oneLine(s.brief, 2000));
|
|
35
|
+
const sections = sectionLines(s.json);
|
|
36
|
+
if (sections.length) lines.push('', ...sections);
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// {projectionId: {fieldId: value}} as "projection" lines with indented "field: value".
|
|
41
|
+
export function insightLines(compact, indent = '') {
|
|
42
|
+
const lines = [];
|
|
43
|
+
for (const [id, fields] of Object.entries(compact ?? {})) {
|
|
44
|
+
lines.push(`${indent}${id}:`);
|
|
45
|
+
for (const [f, v] of Object.entries(fields ?? {})) lines.push(`${indent} ${f}: ${oneLine(valueText(v), 300)}`);
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A party of a call: "Name (ext 201) +3902…", whatever is known.
|
|
51
|
+
export function party(p) {
|
|
52
|
+
if (!p) return '';
|
|
53
|
+
const ext = p.userExtension ?? p.extension;
|
|
54
|
+
return [p.name, ext && `(${ext})`, p.phone && p.phone !== ext ? p.phone : undefined].filter(Boolean).join(' ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Seconds or milliseconds as "m:ss".
|
|
58
|
+
export const secs = (s) => (Number.isFinite(s) ? duration(s) : '');
|
|
59
|
+
// A duration from milliseconds; '—' when there is none (no calls in the window).
|
|
60
|
+
export const millis = (ms) => (Number.isFinite(ms) ? duration(ms / 1000) : '—');
|
|
@@ -0,0 +1,161 @@
|
|
|
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 '../errors.mjs';
|
|
7
|
+
import {refuseUnsafeWrite} from '../files.mjs';
|
|
8
|
+
import {request} from '../http.mjs';
|
|
9
|
+
import {wdaUrl} from '../services.mjs';
|
|
10
|
+
|
|
11
|
+
const FORBIDDEN_HINT = 'The login has no access to this record (a meeting or call of other people, or a role without the right).';
|
|
12
|
+
|
|
13
|
+
// wdaClient({token, env, fetchImpl}) (for ctx.client): get(path), post(path, body), soft(path) (null on a 404).
|
|
14
|
+
// The path is relative to /v2 and built with apiPath`` by the caller.
|
|
15
|
+
export function wdaClient({token, env, fetchImpl}) {
|
|
16
|
+
const root = `${wdaUrl(env)}/v2`;
|
|
17
|
+
async function call(method, p, body) {
|
|
18
|
+
try {
|
|
19
|
+
return await request(root + p, {method, token, body, fetchImpl, service: 'WDA'});
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e instanceof HttpError && e.status === 403) e.message += `\n${FORBIDDEN_HINT}`;
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
get: (p) => call('GET', p),
|
|
27
|
+
post: (p, body) => call('POST', p, body),
|
|
28
|
+
async soft(p) {
|
|
29
|
+
try {
|
|
30
|
+
return await call('GET', p);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (e instanceof HttpError && e.status === 404) return null;
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// An optional part of a readable card (a summary, insights): null when it is not there (404); another failure is
|
|
40
|
+
// told on stderr and the card goes on without it (as the scripts of the skill do).
|
|
41
|
+
export async function optional(promise, info) {
|
|
42
|
+
try {
|
|
43
|
+
return await promise;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (!(e instanceof HttpError && e.status === 404)) info(`Warning: ${e.message.split('\n')[0]}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Nothing to show: no transcription, or no such record. Exit code 3, as in the scripts of the skill.
|
|
51
|
+
export class NoDataError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'NoDataError';
|
|
55
|
+
this.exitCode = 3;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ID = /^[A-Za-z0-9_+][A-Za-z0-9_.:+-]*$/;
|
|
60
|
+
|
|
61
|
+
// An id that goes into a request path (a call id, a queue id, a file id).
|
|
62
|
+
export function checkId(what, value) {
|
|
63
|
+
const v = String(value ?? '');
|
|
64
|
+
if (!ID.test(v) || v.includes('..')) throw new UsageError(`${what} is not a valid id: ${value}`);
|
|
65
|
+
return v;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A conference id from an id or a link (the last path segment of .../insights/conferences/<id>).
|
|
69
|
+
export function conferenceId(input) {
|
|
70
|
+
const v = String(input).split(/[?#]/)[0].replace(/\/$/, '').split('/').pop();
|
|
71
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(v) || v.includes('..') || v === 'conferences') throw new UsageError(`not a conference id: ${input}`);
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A call: the call id and the flow index (a transferred call has several flows; the first is 0).
|
|
76
|
+
export function callFlow(positionals) {
|
|
77
|
+
const [id, flow = '0'] = positionals;
|
|
78
|
+
if (!/^\d{1,9}$/.test(flow)) throw new UsageError(`the flow index must be a number: ${flow}`);
|
|
79
|
+
return {callId: checkId('the call id', id), flow: Number(flow)};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A whole number from min to max (the services refuse anything else with a 400 that tells little).
|
|
83
|
+
export function intOption(name, value, min, max) {
|
|
84
|
+
if (value === undefined) return undefined;
|
|
85
|
+
if (!/^\d{1,12}$/.test(value) || Number(value) < min || Number(value) > max) throw new UsageError(`${name} must be a whole number from ${min} to ${max}: ${value}`);
|
|
86
|
+
return Number(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function checkZone(value) {
|
|
90
|
+
if (value === undefined) return undefined;
|
|
91
|
+
if (!/^[A-Za-z][A-Za-z0-9_+/-]{0,63}$/.test(value)) throw new UsageError(`not a time zone: ${value}`);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// An ISO time given to a filter (--from, --to of the call history and the search): passed on as it is, once it
|
|
96
|
+
// is a time at all.
|
|
97
|
+
export function checkIso(name, value) {
|
|
98
|
+
if (value === undefined) return undefined;
|
|
99
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(value) || Number.isNaN(Date.parse(value))) throw new UsageError(`${name} must be an ISO time (2026-11-07 or 2026-11-07T09:00:00Z): ${value}`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The local IANA time zone (TZ, else the zone of the machine).
|
|
104
|
+
export const localZone = (env) => env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
105
|
+
|
|
106
|
+
// jq's todate: "2026-11-05T11:21:29Z" from milliseconds.
|
|
107
|
+
export const isoSeconds = (ms) => new Date(Math.floor((ms || 0) / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
108
|
+
|
|
109
|
+
// jq's `a // b // c`: the first value that is not null, undefined or false.
|
|
110
|
+
export const alt = (...values) => values.find((v) => v !== null && v !== undefined && v !== false) ?? null;
|
|
111
|
+
|
|
112
|
+
// {k: obj[k] ?? null} for the keys (jq's `{a, b}`).
|
|
113
|
+
export const pick = (obj, keys) => Object.fromEntries(keys.map((k) => [k, obj?.[k] ?? null]));
|
|
114
|
+
|
|
115
|
+
// jq's `unique`: sorted, no repeats.
|
|
116
|
+
export const unique = (values) => [...new Set(values)].sort();
|
|
117
|
+
|
|
118
|
+
// The insights that succeeded, as {projectionId: {fieldId: value}}.
|
|
119
|
+
export function compactInsights(r) {
|
|
120
|
+
const out = {};
|
|
121
|
+
for (const p of r?.insights ?? []) {
|
|
122
|
+
if (p.status !== 'SUCCEEDED' || !(p.fields ?? []).length) continue;
|
|
123
|
+
out[p.id] = Object.fromEntries(p.fields.map((f) => [f.id, f.value ?? null]));
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The participants of a conference by name (or e-mail), each once.
|
|
129
|
+
export const participantNames = (conference) =>
|
|
130
|
+
unique((conference?.participants ?? []).map((p) => alt(p.info?.name, p.info?.email)).filter((n) => n !== null).map((n) => String(n).replace(/\s+/g, ' ').trim()));
|
|
131
|
+
|
|
132
|
+
// Writes a transcript to a private file (folders 0700, file 0600) and returns its absolute path. An existing path
|
|
133
|
+
// is replaced only when it is a transcript of the same conference written earlier (its second line is
|
|
134
|
+
// "# conferenceId: <id>"): the path may have come from fetched text, so nothing else is touched.
|
|
135
|
+
export function writePrivate(file, content, {cwd, conferenceId: id, env = process.env} = {}) {
|
|
136
|
+
if (!file) throw new UsageError('empty output path');
|
|
137
|
+
const target = path.resolve(cwd ?? process.cwd(), file);
|
|
138
|
+
refuseUnsafeWrite(target, env);
|
|
139
|
+
let exists = false;
|
|
140
|
+
try {
|
|
141
|
+
fs.lstatSync(target);
|
|
142
|
+
exists = true;
|
|
143
|
+
} catch (e) {
|
|
144
|
+
if (e.code !== 'ENOENT') throw e;
|
|
145
|
+
}
|
|
146
|
+
if (exists) {
|
|
147
|
+
let second;
|
|
148
|
+
try {
|
|
149
|
+
second = fs.readFileSync(target, 'utf8').slice(0, 8192).split('\n')[1];
|
|
150
|
+
} catch {
|
|
151
|
+
second = undefined;
|
|
152
|
+
}
|
|
153
|
+
if (!id || second !== `# conferenceId: ${id}`) throw new Error(`${file} exists: choose another path (nothing was overwritten)`);
|
|
154
|
+
}
|
|
155
|
+
fs.mkdirSync(path.dirname(target), {recursive: true, mode: 0o700});
|
|
156
|
+
fs.writeFileSync(target, content, {mode: 0o600, flag: exists ? 'w' : 'wx'});
|
|
157
|
+
return target;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// "1 queue", "2 queues".
|
|
161
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|