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.
- package/README.md +281 -37
- package/package.json +9 -6
- package/src/chat/format.mjs +129 -0
- package/src/chat/lib.mjs +210 -0
- package/src/chat/watch.mjs +282 -0
- package/src/cli.mjs +13 -132
- package/src/commands/calls.mjs +470 -0
- package/src/commands/channel.mjs +404 -0
- package/src/commands/chat.mjs +444 -61
- package/src/commands/conversations.mjs +368 -0
- package/src/commands/file.mjs +80 -0
- package/src/commands/inbox.mjs +108 -0
- package/src/commands/index.mjs +33 -12
- package/src/commands/live.mjs +462 -0
- package/src/commands/meetings.mjs +386 -0
- package/src/commands/phone.mjs +564 -0
- package/src/commands/reports.mjs +606 -0
- package/src/commands/section.mjs +101 -0
- package/src/commands/user.mjs +159 -0
- package/src/history/text.mjs +86 -0
- package/src/history/wda.mjs +171 -0
- package/src/live/socket.mjs +139 -0
- package/src/live/stats.mjs +243 -0
- package/src/live/text.mjs +318 -0
- package/src/live/views.mjs +391 -0
- package/src/phone/control.mjs +437 -0
- package/src/phone/events.mjs +220 -0
- package/src/phone/pbx.mjs +158 -0
- package/src/phone/sip.mjs +386 -0
- package/src/phone/xml.mjs +57 -0
- package/src/stream.mjs +12 -14
- package/src/version.mjs +0 -1
- package/src/auth.mjs +0 -128
- package/src/commands/auth.mjs +0 -32
- package/src/completion.mjs +0 -105
- package/src/errors.mjs +0 -30
- package/src/http.mjs +0 -47
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// wx conversations: the search over calls, conferences and chats with the AI summary of each (WDA Insights), the
|
|
2
|
+
// counts, the statistics of a dimension (the data of a chart) and the tags. By default only the conversations the
|
|
3
|
+
// logged-in user took part in; --everyone drops that (the service still decides what the login may see).
|
|
4
|
+
import {jwtClaims} from 'wldx-core/auth';
|
|
5
|
+
import {UsageError} from 'wldx-core/errors';
|
|
6
|
+
import {localTime, oneLine, table} from 'wldx-core/format';
|
|
7
|
+
import {query} from 'wldx-core/http';
|
|
8
|
+
import {secs} from '../history/text.mjs';
|
|
9
|
+
import {alt, checkIso, checkZone, intOption, isoSeconds, localZone, pick, unique, wdaClient} from '../history/wda.mjs';
|
|
10
|
+
|
|
11
|
+
export const WHEN = [
|
|
12
|
+
'TODAY',
|
|
13
|
+
'YESTERDAY',
|
|
14
|
+
'THIS_WEEK',
|
|
15
|
+
'PREVIOUS_WEEK',
|
|
16
|
+
'THIS_MONTH',
|
|
17
|
+
'PREVIOUS_MONTH',
|
|
18
|
+
'THIS_QUARTER',
|
|
19
|
+
'LAST_30_DAYS',
|
|
20
|
+
];
|
|
21
|
+
export const DIMENSIONS = [
|
|
22
|
+
'direction',
|
|
23
|
+
'entity',
|
|
24
|
+
'channel',
|
|
25
|
+
'recorded',
|
|
26
|
+
'language',
|
|
27
|
+
'country',
|
|
28
|
+
'location',
|
|
29
|
+
'pbx',
|
|
30
|
+
'trunk',
|
|
31
|
+
'queue',
|
|
32
|
+
'service',
|
|
33
|
+
'extension',
|
|
34
|
+
'group',
|
|
35
|
+
'department',
|
|
36
|
+
'tag',
|
|
37
|
+
'chatbot',
|
|
38
|
+
'voicebot',
|
|
39
|
+
'duration',
|
|
40
|
+
'connectTime',
|
|
41
|
+
'talkTime',
|
|
42
|
+
'waitTime',
|
|
43
|
+
'queueTime',
|
|
44
|
+
'holdTime',
|
|
45
|
+
'date',
|
|
46
|
+
'dayOfWeek',
|
|
47
|
+
'hourOfDay',
|
|
48
|
+
];
|
|
49
|
+
const ZONED = new Set(['date', 'dayOfWeek', 'hourOfDay']);
|
|
50
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
51
|
+
|
|
52
|
+
// The options of the filter (find, count, aggregate).
|
|
53
|
+
const FILTER_OPTIONS = {
|
|
54
|
+
entity: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
multiple: true,
|
|
57
|
+
choices: ['call', 'conference', 'chat'],
|
|
58
|
+
description: 'only calls, conferences or chats',
|
|
59
|
+
},
|
|
60
|
+
email: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
multiple: true,
|
|
63
|
+
placeholder: 'E',
|
|
64
|
+
description: 'a participant by e-mail (all named must have taken part)',
|
|
65
|
+
},
|
|
66
|
+
phone: {type: 'string', multiple: true, placeholder: 'P', description: 'a participant by phone number'},
|
|
67
|
+
extension: {type: 'string', multiple: true, placeholder: 'X', description: 'a participant by extension'},
|
|
68
|
+
everyone: {
|
|
69
|
+
type: 'boolean',
|
|
70
|
+
description: 'not only your own conversations: everything the login may see (only when asked)',
|
|
71
|
+
},
|
|
72
|
+
transcribed: {type: 'boolean', description: 'only conversations with a transcript'},
|
|
73
|
+
recorded: {type: 'boolean', description: 'only recorded conversations'},
|
|
74
|
+
direction: {type: 'string', choices: ['inbound', 'outbound', 'internal'], description: 'only this direction'},
|
|
75
|
+
from: {type: 'string', placeholder: 'ISO', description: 'from this time (ISO)'},
|
|
76
|
+
to: {type: 'string', placeholder: 'ISO', description: 'up to this time (ISO)'},
|
|
77
|
+
tz: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
placeholder: 'ZONE',
|
|
80
|
+
description: 'the time zone of --from/--to/--when (and of the time dimensions of aggregate)',
|
|
81
|
+
},
|
|
82
|
+
when: {type: 'string', choices: WHEN, description: 'a ready period'},
|
|
83
|
+
tag: {type: 'string', multiple: true, placeholder: 'T', description: 'only conversations with this tag'},
|
|
84
|
+
language: {type: 'string', multiple: true, placeholder: 'L', description: 'only conversations in this language'},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// The ListConversations filter from the options. Without a participant and without --everyone: the user's own
|
|
88
|
+
// conversations (the e-mail of the login).
|
|
89
|
+
export function buildFilter(values, token) {
|
|
90
|
+
const f = {};
|
|
91
|
+
if (values.entity?.length) f.entity = Object.fromEntries(values.entity.map((e) => [e, true]));
|
|
92
|
+
const parts = [
|
|
93
|
+
...(values.email ?? []).map((email) => ({email})),
|
|
94
|
+
...(values.phone ?? []).map((phone) => ({phone})),
|
|
95
|
+
...(values.extension ?? []).map((extension) => ({extension})),
|
|
96
|
+
];
|
|
97
|
+
if (!parts.length && !values.everyone) {
|
|
98
|
+
const me = String(jwtClaims(token).email ?? '').toLowerCase();
|
|
99
|
+
if (!me) throw new Error('the token carries no e-mail');
|
|
100
|
+
parts.push({email: me});
|
|
101
|
+
}
|
|
102
|
+
if (parts.length) f.participants = parts;
|
|
103
|
+
if (values.transcribed) f.transcribed = true;
|
|
104
|
+
if (values.recorded) f.recorded = true;
|
|
105
|
+
if (values.direction) f.direction = {[values.direction]: true};
|
|
106
|
+
const tz = checkZone(values.tz);
|
|
107
|
+
if (values.when) f.date = {predefined: {type: values.when, ...(tz ? {timeZone: tz} : {})}};
|
|
108
|
+
else if (values.from || values.to) {
|
|
109
|
+
f.date = {
|
|
110
|
+
absolute: {
|
|
111
|
+
...(values.from ? {from: checkIso('--from', values.from)} : {}),
|
|
112
|
+
...(values.to ? {to: checkIso('--to', values.to)} : {}),
|
|
113
|
+
...(tz ? {timeZone: tz} : {}),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (values.tag?.length) f.tag = values.tag;
|
|
118
|
+
if (values.language?.length) f.language = values.language;
|
|
119
|
+
return f;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One item of `find --brief`.
|
|
123
|
+
export function briefItem(it) {
|
|
124
|
+
const [type, v = {}] = Object.entries(it.event ?? {})[0] ?? [null, {}];
|
|
125
|
+
return {
|
|
126
|
+
type,
|
|
127
|
+
id: alt(v.id, v.chatId),
|
|
128
|
+
flowIndex: v.flowIndex ?? null,
|
|
129
|
+
start: isoSeconds(v.startTime),
|
|
130
|
+
durationSec: Math.round((v.duration ?? 0) / 1000),
|
|
131
|
+
title: v.subject ?? null,
|
|
132
|
+
direction: v.direction ?? null,
|
|
133
|
+
status: alt(v.status, v.callStatus),
|
|
134
|
+
participants:
|
|
135
|
+
type === 'conference'
|
|
136
|
+
? unique((v.participants ?? []).map((p) => alt(p.info?.name, p.info?.email)).filter((n) => n !== null))
|
|
137
|
+
: null,
|
|
138
|
+
summary: pick(it.summary, ['status', 'title', 'brief']),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The AggregationDefinition of one dimension.
|
|
143
|
+
export function aggregationOf(dim, tz) {
|
|
144
|
+
if (dim.startsWith('projection:')) {
|
|
145
|
+
const m = /^projection:([A-Za-z0-9_-]{1,100}):([A-Za-z0-9_-]{1,100})$/.exec(dim);
|
|
146
|
+
if (!m) throw new UsageError(`a projection is projection:<id>:<field>: ${dim}`);
|
|
147
|
+
return {projection: {id: m[1], fieldId: m[2]}};
|
|
148
|
+
}
|
|
149
|
+
if (!DIMENSIONS.includes(dim))
|
|
150
|
+
throw new UsageError(`not a dimension: ${dim} (one of ${DIMENSIONS.join(', ')}, or projection:<id>:<field>)`);
|
|
151
|
+
return {[dim]: ZONED.has(dim) ? {timeZone: tz} : {}};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const dimName = (agg) => {
|
|
155
|
+
const k = Object.keys(agg ?? {})[0];
|
|
156
|
+
return k === 'projection' ? `projection:${agg.projection.id}:${agg.projection.fieldId}` : k;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// One level of an aggregation: the buckets without their drill-down filters, weekdays named, hours and weekdays in
|
|
160
|
+
// order, the N biggest values kept (not of the time dimensions), the nested level named by its dimension.
|
|
161
|
+
export function level(r, top) {
|
|
162
|
+
const d = dimName(r.aggregation);
|
|
163
|
+
let buckets = [...(r.buckets ?? [])];
|
|
164
|
+
if (d === 'hourOfDay' || d === 'dayOfWeek') buckets.sort((a, b) => Number(a.key) - Number(b.key));
|
|
165
|
+
if (top !== undefined && !ZONED.has(d)) buckets = buckets.sort((a, b) => b.count - a.count).slice(0, top);
|
|
166
|
+
return {
|
|
167
|
+
by: d,
|
|
168
|
+
buckets: buckets.map((b) => ({
|
|
169
|
+
key: b.key ?? null,
|
|
170
|
+
count: b.count ?? null,
|
|
171
|
+
...(d === 'dayOfWeek' ? {day: WEEKDAYS[Number(b.key)] ?? null} : {}),
|
|
172
|
+
...(b.interval ? {interval: b.interval} : {}),
|
|
173
|
+
...(b.aggregation ? {then: level(b.aggregation, top)} : {}),
|
|
174
|
+
})),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function projectionsOf(r) {
|
|
179
|
+
return {
|
|
180
|
+
count: r.count ?? null,
|
|
181
|
+
projections: (r.result ?? [])
|
|
182
|
+
.filter((x) => x.aggregation?.projection && (x.buckets ?? []).length > 0)
|
|
183
|
+
.map((x) => ({
|
|
184
|
+
by: `projection:${x.aggregation.projection.id}:${x.aggregation.projection.fieldId}`,
|
|
185
|
+
values: x.buckets.reduce((n, b) => n + (b.count ?? 0), 0),
|
|
186
|
+
top: [...x.buckets]
|
|
187
|
+
.sort((a, b) => b.count - a.count)
|
|
188
|
+
.slice(0, 5)
|
|
189
|
+
.map((b) => ({key: b.key ?? null, count: b.count ?? null})),
|
|
190
|
+
})),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const bucketLabel = (b) => (b.day ? `${b.day}` : b.key === null || b.key === '' ? '(none)' : String(b.key));
|
|
195
|
+
|
|
196
|
+
// The buckets of a level (and of the levels under it, indented) as rows of label and count.
|
|
197
|
+
function levelRows(l, indent = '') {
|
|
198
|
+
return l.buckets.flatMap((b) => [
|
|
199
|
+
[`${indent}${oneLine(bucketLabel(b), 60)}`, String(b.count)],
|
|
200
|
+
...(b.then ? levelRows(b.then, `${indent} `) : []),
|
|
201
|
+
]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const chain = (l) => (l.buckets.find((b) => b.then) ? [l.by, ...chain(l.buckets.find((b) => b.then).then)] : [l.by]);
|
|
205
|
+
|
|
206
|
+
function aggregateText(r, ctx) {
|
|
207
|
+
const v = ctx.values;
|
|
208
|
+
if (v.raw) return JSON.stringify(r, null, 2);
|
|
209
|
+
const whose = v.everyone ? 'visible to the login' : 'your own';
|
|
210
|
+
if (v.projections) {
|
|
211
|
+
if (!r.projections.length) return `${r.count} conversations (${whose}); no AI fields with values.`;
|
|
212
|
+
return [
|
|
213
|
+
`${r.count} conversations (${whose}); AI fields with values:`,
|
|
214
|
+
...table(
|
|
215
|
+
r.projections.map((p) => [
|
|
216
|
+
p.by,
|
|
217
|
+
String(p.values),
|
|
218
|
+
p.top.map((t) => `${oneLine(t.key, 30)} ${t.count}`).join(', '),
|
|
219
|
+
]),
|
|
220
|
+
),
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
const head = `${r.count} conversations (${whose}), times in ${r.timeZone}`;
|
|
224
|
+
if (r.results)
|
|
225
|
+
return [
|
|
226
|
+
head,
|
|
227
|
+
...r.results.flatMap((l) => [
|
|
228
|
+
'',
|
|
229
|
+
`by ${l.by}:`,
|
|
230
|
+
...(l.buckets.length ? table(levelRows(l, ' ')) : [' (none)']),
|
|
231
|
+
]),
|
|
232
|
+
];
|
|
233
|
+
return [
|
|
234
|
+
head,
|
|
235
|
+
'',
|
|
236
|
+
`by ${chain(r).join(', then ')}:`,
|
|
237
|
+
...(r.buckets.length ? table(levelRows(r, ' ')) : [' (none)']),
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function findText(r, ctx) {
|
|
242
|
+
const items = ctx.values.brief ? (r.items ?? []) : (r.items ?? []).map(briefItem);
|
|
243
|
+
const lines = items.length
|
|
244
|
+
? table(
|
|
245
|
+
items.map((i) => [
|
|
246
|
+
localTime(i.start),
|
|
247
|
+
i.type ?? '',
|
|
248
|
+
secs(i.durationSec),
|
|
249
|
+
[i.direction, i.status].filter(Boolean).join(' '),
|
|
250
|
+
oneLine(alt(i.title, i.summary?.title, '') ?? '', 70),
|
|
251
|
+
`${i.id ?? ''}${i.type === 'call' && i.flowIndex ? ` ${i.flowIndex}` : ''}`,
|
|
252
|
+
]),
|
|
253
|
+
)
|
|
254
|
+
: ['No conversations.'];
|
|
255
|
+
const next = ctx.values.brief ? r.nextCursor : r.lastEvaluatedKey;
|
|
256
|
+
if (next) lines.push(`More: --cursor ${typeof next === 'string' ? next : JSON.stringify(next)}`);
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const group = {
|
|
261
|
+
summary: 'search calls, meetings and chats with their AI summary; counts and statistics',
|
|
262
|
+
auth: true,
|
|
263
|
+
commands: {
|
|
264
|
+
find: {
|
|
265
|
+
summary: 'the search over conversations with the summary of each, 10 per page',
|
|
266
|
+
usage:
|
|
267
|
+
'wx conversations find [--entity call|conference|chat]... [--email E]... [--phone P]... [--extension X]... [--everyone] [--transcribed] [--recorded] [--direction inbound|outbound|internal] [--from ISO --to ISO [--tz ZONE]] [--when PERIOD] [--tag T]... [--language L]... [--cursor KEY] [--brief]',
|
|
268
|
+
details:
|
|
269
|
+
'Only the conversations you took part in, unless --everyone (only when asked: it may return the conversations of colleagues). The next page: --cursor with nextCursor (lastEvaluatedKey) of the answer.',
|
|
270
|
+
options: {
|
|
271
|
+
...FILTER_OPTIONS,
|
|
272
|
+
cursor: {type: 'string', placeholder: 'KEY', description: 'the next page (nextCursor of the previous answer)'},
|
|
273
|
+
brief: {
|
|
274
|
+
type: 'boolean',
|
|
275
|
+
description:
|
|
276
|
+
'one short object per conversation: type, id, start, duration, title, direction, status, participants, summary',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
args: [0, 0],
|
|
280
|
+
async run(ctx) {
|
|
281
|
+
const body = {filter: buildFilter(ctx.values, ctx.token)};
|
|
282
|
+
if (ctx.values.cursor) body.exclusiveStartKey = ctx.values.cursor;
|
|
283
|
+
const r = await ctx.client(wdaClient).post('/insights/conversations', body);
|
|
284
|
+
return ctx.values.brief ? {items: (r.items ?? []).map(briefItem), nextCursor: r.lastEvaluatedKey ?? null} : r;
|
|
285
|
+
},
|
|
286
|
+
text: findText,
|
|
287
|
+
},
|
|
288
|
+
count: {
|
|
289
|
+
summary: 'how many conversations match the filter',
|
|
290
|
+
usage: 'wx conversations count [the filter of find, without --cursor and --brief]',
|
|
291
|
+
options: FILTER_OPTIONS,
|
|
292
|
+
args: [0, 0],
|
|
293
|
+
run: (ctx) =>
|
|
294
|
+
ctx.client(wdaClient).post('/insights/conversations/count', {filter: buildFilter(ctx.values, ctx.token)}),
|
|
295
|
+
text: (r, ctx) =>
|
|
296
|
+
`${r.count ?? JSON.stringify(r)} conversations (${ctx.values.everyone ? 'visible to the login' : 'your own'})`,
|
|
297
|
+
},
|
|
298
|
+
aggregate: {
|
|
299
|
+
summary: 'how many conversations fall into each value of a dimension (the data of a chart)',
|
|
300
|
+
usage:
|
|
301
|
+
'wx conversations aggregate (--by DIM[,DIM...] | --by DIM --then DIM [--then DIM] | --projections) [the filter of find] [--tz ZONE] [--top N] [--raw]',
|
|
302
|
+
details: [
|
|
303
|
+
'Several dimensions side by side (--by a,b: at most 10), or one broken down by others (--by a --then b [--then c]).',
|
|
304
|
+
`Dimensions: ${DIMENSIONS.join(' ')}, and projection:<id>:<field> (an AI insight field; --projections lists those with values).`,
|
|
305
|
+
'date, dayOfWeek and hourOfDay are cut in --tz (the local zone by default). --top keeps the N biggest values of each level (the time dimensions stay whole).',
|
|
306
|
+
'Counts only your own conversations unless --everyone.',
|
|
307
|
+
].join('\n'),
|
|
308
|
+
options: {
|
|
309
|
+
...FILTER_OPTIONS,
|
|
310
|
+
by: {type: 'string', placeholder: 'DIM[,DIM]', description: 'the dimensions (comma-separated)'},
|
|
311
|
+
then: {
|
|
312
|
+
type: 'string',
|
|
313
|
+
multiple: true,
|
|
314
|
+
placeholder: 'DIM',
|
|
315
|
+
description: 'break each value of --by down by this dimension (at most two)',
|
|
316
|
+
},
|
|
317
|
+
top: {type: 'string', placeholder: 'N', description: 'keep the N biggest values of each level (1-1000)'},
|
|
318
|
+
raw: {type: 'boolean', description: 'the answer of the service as it is'},
|
|
319
|
+
projections: {
|
|
320
|
+
type: 'boolean',
|
|
321
|
+
description: 'the AI insight fields that have values in the filter, with their biggest values',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
args: [0, 0],
|
|
325
|
+
async run(ctx) {
|
|
326
|
+
const v = ctx.values;
|
|
327
|
+
const top = intOption('--top', v.top, 1, 1000);
|
|
328
|
+
const filter = buildFilter(v, ctx.token);
|
|
329
|
+
const wda = ctx.client(wdaClient);
|
|
330
|
+
if (v.projections) {
|
|
331
|
+
const r = await wda.post('/insights/conversations/aggregations', {filter});
|
|
332
|
+
return v.raw ? r : projectionsOf(r);
|
|
333
|
+
}
|
|
334
|
+
if (!v.by) throw new UsageError('aggregate needs --by (or --projections)', ctx.usage());
|
|
335
|
+
const tz = checkZone(v.tz) ?? localZone(ctx.env);
|
|
336
|
+
const bys = v.by.split(',');
|
|
337
|
+
const thens = v.then ?? [];
|
|
338
|
+
if (thens.length) {
|
|
339
|
+
if (bys.length !== 1) throw new UsageError('with --then, --by names one dimension');
|
|
340
|
+
if (thens.length > 2) throw new UsageError('at most two --then');
|
|
341
|
+
const aggregations = [bys[0], ...thens].map((d) => aggregationOf(d, tz));
|
|
342
|
+
const r = await wda.post('/insights/conversations/aggregations/nested', {filter, aggregations});
|
|
343
|
+
return v.raw ? r : {count: r.count ?? null, timeZone: tz, ...level(r.result ?? {}, top)};
|
|
344
|
+
}
|
|
345
|
+
if (bys.length > 10) throw new UsageError('at most 10 dimensions side by side');
|
|
346
|
+
const insights = bys.map((d) => aggregationOf(d, tz));
|
|
347
|
+
const r = await wda.post('/insights/conversations/aggregations', {filter, insights});
|
|
348
|
+
return v.raw ? r : {count: r.count ?? null, timeZone: tz, results: (r.result ?? []).map((x) => level(x, top))};
|
|
349
|
+
},
|
|
350
|
+
text: aggregateText,
|
|
351
|
+
},
|
|
352
|
+
tags: {
|
|
353
|
+
summary: 'the tags that start with a prefix',
|
|
354
|
+
usage: 'wx conversations tags <prefix> [--limit 1-20]',
|
|
355
|
+
options: {limit: {type: 'string', placeholder: '1-20', description: 'how many tags'}},
|
|
356
|
+
args: [1, 1],
|
|
357
|
+
run: (ctx) =>
|
|
358
|
+
ctx
|
|
359
|
+
.client(wdaClient)
|
|
360
|
+
.get(
|
|
361
|
+
`/insights/conversations/tags${query({tagPrefix: ctx.positionals[0], limit: intOption('--limit', ctx.values.limit, 1, 20)})}`,
|
|
362
|
+
),
|
|
363
|
+
text: (r) => ((r.tags ?? []).length ? r.tags.map(String) : 'No tags.'),
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export const groups = {conversations: group};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// wx file: files in chat channels (the agent skill's files.sh). Uploads and downloads go through presigned links
|
|
2
|
+
// of the storage, which get no token.
|
|
3
|
+
import {UsageError} from 'wldx-core/errors';
|
|
4
|
+
import {table} from 'wldx-core/format';
|
|
5
|
+
import {apiPath} from 'wldx-core/http';
|
|
6
|
+
import {channelId, messageId} from '../stream.mjs';
|
|
7
|
+
import {api, checkId, downloadFile, uploadFile} from '../chat/lib.mjs';
|
|
8
|
+
|
|
9
|
+
const fileRows = (f) =>
|
|
10
|
+
table([
|
|
11
|
+
['File', f.name ?? ''],
|
|
12
|
+
['fsId', f.fsId ?? ''],
|
|
13
|
+
['Type', f.mime ?? ''],
|
|
14
|
+
['Size', f.size === undefined ? '' : `${f.size} bytes`],
|
|
15
|
+
...(f.width ? [['Image', `${f.width}x${f.height}`]] : []),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const upload = {
|
|
19
|
+
summary: 'upload a file to a channel and print the attachment object (to put into a message)',
|
|
20
|
+
usage: 'wx file upload <channel> <path>',
|
|
21
|
+
details:
|
|
22
|
+
'Sending a file in one step: wx chat send <channel> <text> --file PATH. Files in credential folders and files that look like secrets are refused.',
|
|
23
|
+
options: {},
|
|
24
|
+
args: [2, 2],
|
|
25
|
+
async run(ctx) {
|
|
26
|
+
return uploadFile(ctx, channelId(ctx.positionals[0]), ctx.positionals[1]);
|
|
27
|
+
},
|
|
28
|
+
text: (f) => fileRows(f),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const info = {
|
|
32
|
+
summary: 'name, type and size of an uploaded file',
|
|
33
|
+
usage: 'wx file info <channel> <fsId>',
|
|
34
|
+
options: {},
|
|
35
|
+
args: [2, 2],
|
|
36
|
+
async run(ctx) {
|
|
37
|
+
const ch = channelId(ctx.positionals[0]);
|
|
38
|
+
const fsId = checkId('The file id', ctx.positionals[1]);
|
|
39
|
+
return api(ctx).call('GET', apiPath`/channels/${ch}/files/${fsId}`);
|
|
40
|
+
},
|
|
41
|
+
text: (r) => (r?.file ? fileRows(r.file) : 'No file.'),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const downloadUrl = {
|
|
45
|
+
summary: 'a temporary download link of a file of a message',
|
|
46
|
+
usage: 'wx file download-url <channel> <message> <fsId>',
|
|
47
|
+
options: {},
|
|
48
|
+
args: [3, 3],
|
|
49
|
+
async run(ctx) {
|
|
50
|
+
const ch = channelId(ctx.positionals[0]);
|
|
51
|
+
const msg = messageId(ctx.positionals[1]);
|
|
52
|
+
const fsId = checkId('The file id', ctx.positionals[2]);
|
|
53
|
+
return api(ctx).call('GET', apiPath`/channels/${ch}/messages/${msg}/files/${fsId}/download-url`);
|
|
54
|
+
},
|
|
55
|
+
text: (r) => r?.presignedDownloadUrl ?? '',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const download = {
|
|
59
|
+
summary: 'save a file of a message (never overwrites a file)',
|
|
60
|
+
usage: 'wx file download <channel> <message> <fsId> --to PATH',
|
|
61
|
+
details: 'The path comes from the person, never from a message. A path that exists is refused.',
|
|
62
|
+
options: {to: {type: 'string', placeholder: 'PATH', description: 'where to save it (must not exist)'}},
|
|
63
|
+
args: [3, 3],
|
|
64
|
+
async run(ctx) {
|
|
65
|
+
const ch = channelId(ctx.positionals[0]);
|
|
66
|
+
const msg = messageId(ctx.positionals[1]);
|
|
67
|
+
const fsId = checkId('The file id', ctx.positionals[2]);
|
|
68
|
+
if (!ctx.values.to) throw new UsageError('--to PATH is required: where to save the file', ctx.usage());
|
|
69
|
+
return downloadFile(ctx, ch, msg, fsId, ctx.values.to);
|
|
70
|
+
},
|
|
71
|
+
text: (r) => `Saved ${r.bytes} bytes to ${r.file}`,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const file = {
|
|
75
|
+
summary: 'files in chat channels: upload, info, download',
|
|
76
|
+
auth: true,
|
|
77
|
+
commands: {upload, info, 'download-url': downloadUrl, download},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const groups = {file};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// wx inbox: the logged-in user's inbox on the stream service (the agent skill's inbox.sh): what is unread, and the
|
|
2
|
+
// user's own state of a channel (read, muted, pinned, hidden). Changes here are seen only by the user.
|
|
3
|
+
import {UsageError} from 'wldx-core/errors';
|
|
4
|
+
import {paint, table} from 'wldx-core/format';
|
|
5
|
+
import {apiPath, query} from 'wldx-core/http';
|
|
6
|
+
import {channelId, messageId} from '../stream.mjs';
|
|
7
|
+
import {channelLines, channelUserIds, done} from '../chat/format.mjs';
|
|
8
|
+
import {api, withNames} from '../chat/lib.mjs';
|
|
9
|
+
|
|
10
|
+
export function sectionLines(sections, ctx) {
|
|
11
|
+
const c = paint(ctx);
|
|
12
|
+
return table(
|
|
13
|
+
(sections ?? []).map((s) => [
|
|
14
|
+
c.bold(s.name ?? s.sectionId),
|
|
15
|
+
s.sectionId,
|
|
16
|
+
s.type ?? '',
|
|
17
|
+
`${s.channelCount ?? 0} channels`,
|
|
18
|
+
s.unmutedUnreadMessageCount
|
|
19
|
+
? c.yellow(`${s.unmutedUnreadMessageCount} new in ${s.unmutedUnreadChannelCount ?? '?'}`)
|
|
20
|
+
: '',
|
|
21
|
+
s.unreadMentionCount ? c.yellow(`@${s.unreadMentionCount}`) : '',
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const get = {
|
|
27
|
+
summary: 'your inbox: recent channels with your state, and the counters of the sections',
|
|
28
|
+
usage: 'wx inbox get',
|
|
29
|
+
options: {},
|
|
30
|
+
args: [0, 0],
|
|
31
|
+
async run(ctx) {
|
|
32
|
+
const client = api(ctx);
|
|
33
|
+
const r = await client.call('GET', '/inbox');
|
|
34
|
+
return withNames(ctx, r, (r?.channels ?? []).flatMap(channelUserIds), client);
|
|
35
|
+
},
|
|
36
|
+
text(r, ctx) {
|
|
37
|
+
const lines = sectionLines(r?.sections, ctx);
|
|
38
|
+
if (r?.channels?.length) lines.push('', ...channelLines(r.channels, r, ctx));
|
|
39
|
+
return lines.length ? lines : 'Nothing in the inbox.';
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const mark = (read) => ({
|
|
44
|
+
summary: read
|
|
45
|
+
? 'mark a channel read, up to the latest message (or up to a message)'
|
|
46
|
+
: 'mark a channel unread from a message on',
|
|
47
|
+
usage: `wx inbox ${read ? 'mark-read' : 'mark-unread'} <channel> [--message MESSAGE]${read ? '' : ' [--force]'}`,
|
|
48
|
+
options: {
|
|
49
|
+
message: {type: 'string', description: 'the message (id or inbox link)'},
|
|
50
|
+
...(read ? {} : {force: {type: 'boolean', description: 'also when the channel has no unread messages'}}),
|
|
51
|
+
},
|
|
52
|
+
args: [1, 1],
|
|
53
|
+
async run(ctx) {
|
|
54
|
+
const ch = channelId(ctx.positionals[0]);
|
|
55
|
+
const body = {};
|
|
56
|
+
if (ctx.values.message !== undefined) body.messageId = messageId(ctx.values.message);
|
|
57
|
+
if (read) return api(ctx).call('POST', apiPath`/channels/${ch}/read`, body);
|
|
58
|
+
return api(ctx).call('PUT', apiPath`/channels/${ch}/unread` + query({force: ctx.values.force && 'true'}), body);
|
|
59
|
+
},
|
|
60
|
+
text: done(read ? 'Marked read' : 'Marked unread'),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const mute = {
|
|
64
|
+
summary: 'mute a channel (no notifications), for good or until a time',
|
|
65
|
+
usage: 'wx inbox mute <channel> [--until ISO8601]',
|
|
66
|
+
options: {until: {type: 'string', placeholder: 'ISO8601', description: 'until this time (2026-11-08T09:00:00Z)'}},
|
|
67
|
+
args: [1, 1],
|
|
68
|
+
async run(ctx) {
|
|
69
|
+
const ch = channelId(ctx.positionals[0]);
|
|
70
|
+
const body = {};
|
|
71
|
+
if (ctx.values.until !== undefined) {
|
|
72
|
+
if (!/^\d{4}-\d{2}-\d{2}T/.test(ctx.values.until) || Number.isNaN(Date.parse(ctx.values.until)))
|
|
73
|
+
throw new UsageError(`--until must be an ISO 8601 time: ${ctx.values.until}`);
|
|
74
|
+
body.mutedUntil = ctx.values.until;
|
|
75
|
+
}
|
|
76
|
+
return api(ctx).call('POST', apiPath`/channels/${ch}/mute`, body);
|
|
77
|
+
},
|
|
78
|
+
text: done('Muted'),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const simple = (name, summary, method, suffix, text, body) => ({
|
|
82
|
+
summary,
|
|
83
|
+
usage: `wx inbox ${name} <channel>`,
|
|
84
|
+
options: {},
|
|
85
|
+
args: [1, 1],
|
|
86
|
+
async run(ctx) {
|
|
87
|
+
return api(ctx).call(method, apiPath`/channels/${channelId(ctx.positionals[0])}` + suffix, body);
|
|
88
|
+
},
|
|
89
|
+
text: done(text),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const inbox = {
|
|
93
|
+
summary: 'your inbox: unread, mark read or unread, mute, pin, hide (only for you)',
|
|
94
|
+
auth: true,
|
|
95
|
+
commands: {
|
|
96
|
+
get,
|
|
97
|
+
'mark-read': mark(true),
|
|
98
|
+
'mark-unread': mark(false),
|
|
99
|
+
mute,
|
|
100
|
+
unmute: simple('unmute', 'unmute a channel', 'DELETE', '/mute', 'Unmuted'),
|
|
101
|
+
pin: simple('pin', 'pin a channel at the top of your inbox', 'POST', '/pin', 'Pinned', {}),
|
|
102
|
+
unpin: simple('unpin', 'unpin a channel', 'DELETE', '/pin', 'Unpinned'),
|
|
103
|
+
hide: simple('hide', 'archive a channel (hide it from your inbox)', 'POST', '/hide', 'Hidden', {}),
|
|
104
|
+
show: simple('show', 'restore an archived channel', 'POST', '/show', 'Shown', {}),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const groups = {inbox};
|
package/src/commands/index.mjs
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import chat from './chat.mjs';
|
|
1
|
+
// The commands of wx. GROUPS: `wx <group> <command>`; COMMANDS: single commands, `wx <command>`. What a group and
|
|
2
|
+
// a command are: wldx-core/registry. login, logout, status and profile come from the core, shared with wx-admin.
|
|
3
|
+
import {groups as calls} from './calls.mjs';
|
|
4
|
+
import {groups as channel} from './channel.mjs';
|
|
5
|
+
import {groups as chat} from './chat.mjs';
|
|
6
|
+
import {groups as conversations} from './conversations.mjs';
|
|
7
|
+
import {groups as file} from './file.mjs';
|
|
8
|
+
import {groups as inbox} from './inbox.mjs';
|
|
9
|
+
import {groups as live} from './live.mjs';
|
|
10
|
+
import {login, logout} from 'wldx-core/commands/login';
|
|
11
|
+
import {groups as meetings} from './meetings.mjs';
|
|
12
|
+
import {groups as phone} from './phone.mjs';
|
|
13
|
+
import {groups as profile} from 'wldx-core/commands/profile';
|
|
14
|
+
import {groups as reports} from './reports.mjs';
|
|
15
|
+
import {groups as section} from './section.mjs';
|
|
16
|
+
import status from 'wldx-core/commands/status';
|
|
17
|
+
import {groups as user} from './user.mjs';
|
|
6
18
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
// In the order of the help: chats, then calls and meetings on record, then now, then the phone.
|
|
20
|
+
export const GROUPS = {
|
|
21
|
+
...chat,
|
|
22
|
+
...channel,
|
|
23
|
+
...inbox,
|
|
24
|
+
...file,
|
|
25
|
+
...user,
|
|
26
|
+
...section,
|
|
27
|
+
...calls,
|
|
28
|
+
...meetings,
|
|
29
|
+
...conversations,
|
|
30
|
+
...reports,
|
|
31
|
+
...live,
|
|
32
|
+
...phone,
|
|
33
|
+
...profile,
|
|
12
34
|
};
|
|
13
35
|
|
|
14
|
-
export const
|
|
15
|
-
export const commandOf = (group, name) => (group && Object.hasOwn(group.commands, name) ? group.commands[name] : undefined);
|
|
36
|
+
export const COMMANDS = {login, logout, status};
|