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,404 @@
1
+ // wx channel: channels of the Wildix stream service and their members, as the logged-in user (the agent skill's
2
+ // channels.sh, members.sh and search.sh channels).
3
+ import {UsageError} from 'wldx-core/errors';
4
+ import {localTime, paint, table} from 'wldx-core/format';
5
+ import {apiPath, query} from 'wldx-core/http';
6
+ import {channelId} from '../stream.mjs';
7
+ import {channelDetails, channelLines, channelUserIds, done, nextPage} from '../chat/format.mjs';
8
+ import {
9
+ CHANNEL_ID,
10
+ NOT_IMPLEMENTED,
11
+ api,
12
+ checkId,
13
+ checkInt,
14
+ nameIn,
15
+ newChannelId,
16
+ notImplemented,
17
+ withNames,
18
+ } from '../chat/lib.mjs';
19
+
20
+ const ACCESS = ['private', 'public'];
21
+ const UNSET = [
22
+ 'subject',
23
+ 'description',
24
+ 'picture',
25
+ 'pictureColor',
26
+ 'access',
27
+ 'broadcast',
28
+ 'context',
29
+ 'contextPtt',
30
+ 'contextEvents',
31
+ 'whatsappStatus',
32
+ 'messagingStatus',
33
+ 'kiteAssign',
34
+ 'service',
35
+ 'serviceTitle',
36
+ 'assignee',
37
+ 'sms',
38
+ 'mms',
39
+ 'whatsapp',
40
+ ];
41
+ const SORT = ['relevance', 'recency', 'recency_asc'];
42
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+
44
+ function email(value, what = '--invite') {
45
+ if (!EMAIL.test(String(value))) throw new UsageError(`${what} is not an e-mail address: ${value}`);
46
+ return value;
47
+ }
48
+
49
+ const channelResult = async (ctx, client, r) =>
50
+ withNames(ctx, r, [...channelUserIds(r?.channel), r?.channel?.createdBy], client);
51
+ const channelText = (r, ctx) => (r?.channel ? channelDetails(r.channel, r, ctx) : 'No channel.');
52
+
53
+ const get = {
54
+ summary: 'one channel',
55
+ usage: 'wx channel get <channel>',
56
+ details: 'A channel is an id or an inbox link. A deleted channel can still be read (the deletion is soft).',
57
+ options: {},
58
+ args: [1, 1],
59
+ async run(ctx) {
60
+ const client = api(ctx);
61
+ return channelResult(ctx, client, await client.call('GET', apiPath`/channels/${channelId(ctx.positionals[0])}`));
62
+ },
63
+ text: channelText,
64
+ };
65
+
66
+ const queryCmd = {
67
+ summary: `several channels by id${NOT_IMPLEMENTED}`,
68
+ usage: 'wx channel query <channel>...',
69
+ details: notImplemented('QueryChannels'),
70
+ options: {},
71
+ args: [1, 100],
72
+ async run(ctx) {
73
+ const client = api(ctx);
74
+ const r = await client.call('POST', '/channels/query', {query: {ids: ctx.positionals.map(channelId)}});
75
+ return withNames(ctx, r, (r?.channels ?? []).flatMap(channelUserIds), client);
76
+ },
77
+ text: (r, ctx) => (r?.channels?.length ? channelLines(r.channels, r, ctx) : 'No channels.'),
78
+ };
79
+
80
+ const search = {
81
+ summary: 'search your channels by subject or member',
82
+ usage:
83
+ 'wx channel search <query> [--type direct|group] [--member USERID] [--size 1-50] [--cursor C] [--sort relevance|recency|recency_asc]',
84
+ options: {
85
+ type: {type: 'string', choices: ['direct', 'group'], description: 'only 1:1 or group channels'},
86
+ member: {type: 'string', placeholder: 'USERID', description: 'only channels with this member'},
87
+ size: {type: 'string', placeholder: '1-50', description: 'results per page (default 20)'},
88
+ cursor: {type: 'string', description: 'the next page (nextCursor of the previous one)'},
89
+ sort: {type: 'string', choices: SORT, description: 'the order of the results'},
90
+ },
91
+ args: [1, 1],
92
+ async run(ctx) {
93
+ const {values} = ctx;
94
+ const q = query({
95
+ q: ctx.positionals[0],
96
+ channelType: values.type,
97
+ memberId: values.member === undefined ? undefined : checkId('--member', values.member),
98
+ size: checkInt('size', values.size, 1, 50),
99
+ cursor: values.cursor,
100
+ sort: values.sort,
101
+ });
102
+ const client = api(ctx);
103
+ const r = await client.call('GET', `/search/channels${q}`);
104
+ return withNames(
105
+ ctx,
106
+ r,
107
+ (r?.results ?? []).flatMap((x) => channelUserIds(x.channel)),
108
+ client,
109
+ );
110
+ },
111
+ text(r, ctx) {
112
+ const list = (r?.results ?? []).map((x) => x.channel).filter(Boolean);
113
+ if (!list.length) return 'Nothing found.';
114
+ return [...channelLines(list, r, ctx), ...nextPage(r.nextCursor, paint(ctx))];
115
+ },
116
+ };
117
+
118
+ const create = {
119
+ summary: 'create a group channel',
120
+ usage:
121
+ 'wx channel create --subject S [--description D] [--access private|public] [--member USERID]... [--invite EMAIL]... [--id 8-4-4-4-8] [--auto-transcribe | --no-auto-transcribe]',
122
+ details: [
123
+ 'An id of 8-4-4-4-8 characters is always sent (one is made when --id is not given): without it the service makes',
124
+ 'a 36-character id that none of its own calls accepts. --auto-transcribe makes a conference in the channel start',
125
+ 'transcription by itself; it can be set only now (update is not implemented by the service yet).',
126
+ ].join('\n'),
127
+ options: {
128
+ subject: {type: 'string', description: 'the name of the channel (required)'},
129
+ description: {type: 'string', description: 'what the channel is for'},
130
+ access: {type: 'string', choices: ACCESS, description: 'who can find and join it'},
131
+ member: {type: 'string', multiple: true, placeholder: 'USERID', description: 'add this user'},
132
+ invite: {type: 'string', multiple: true, placeholder: 'EMAIL', description: 'invite this e-mail address'},
133
+ id: {type: 'string', placeholder: '8-4-4-4-8', description: 'the id of the new channel'},
134
+ 'auto-transcribe': {type: 'boolean', description: 'conferences in it start transcription by themselves'},
135
+ 'no-auto-transcribe': {type: 'boolean', description: 'they do not'},
136
+ },
137
+ args: [0, 0],
138
+ async run(ctx) {
139
+ const {values} = ctx;
140
+ if (!values.subject) throw new UsageError('--subject is required', ctx.usage());
141
+ if (values['auto-transcribe'] && values['no-auto-transcribe'])
142
+ throw new UsageError('Give --auto-transcribe or --no-auto-transcribe, not both', ctx.usage());
143
+ const id = values.id ?? newChannelId();
144
+ if (!CHANNEL_ID.test(id)) throw new UsageError(`--id must look like 8-4-4-4-8 characters of [0-9a-z_]: ${id}`);
145
+ const body = {channelId: id, subject: values.subject};
146
+ if (values.description) body.description = values.description;
147
+ if (values.access) body.access = values.access;
148
+ if (values['auto-transcribe']) body.autoTranscribe = true;
149
+ if (values['no-auto-transcribe']) body.autoTranscribe = false;
150
+ if (values.member?.length) body.members = values.member.map((u) => checkId('--member', u));
151
+ if (values.invite?.length) body.membersToInvite = values.invite.map((e) => ({email: email(e)}));
152
+ const client = api(ctx);
153
+ return channelResult(ctx, client, await client.call('POST', '/channels/group', body));
154
+ },
155
+ text: (r, ctx) => (r?.channel ? ['Created:', ...channelDetails(r.channel, r, ctx)] : 'Created'),
156
+ };
157
+
158
+ function directBody(values, usage) {
159
+ if (values.user && values.email) throw new UsageError('Give --user or --email, not both', usage);
160
+ if (values.user) return {memberId: checkId('--user', values.user)};
161
+ if (values.email) return {memberToInvite: {email: email(values.email, '--email')}};
162
+ throw new UsageError('--user or --email is required', usage);
163
+ }
164
+ const DIRECT_OPTIONS = {
165
+ user: {type: 'string', placeholder: 'USERID', description: 'the other person (wx user find gives the id)'},
166
+ email: {type: 'string', placeholder: 'EMAIL', description: 'the other person by e-mail (invited when not a user)'},
167
+ };
168
+
169
+ const direct = {
170
+ summary: 'the 1:1 channel with a person (created when it does not exist yet)',
171
+ usage: 'wx channel direct (--user USERID | --email EMAIL)',
172
+ options: DIRECT_OPTIONS,
173
+ args: [0, 0],
174
+ async run(ctx) {
175
+ const client = api(ctx);
176
+ return channelResult(
177
+ ctx,
178
+ client,
179
+ await client.call('POST', '/channels/direct/get-or-create', directBody(ctx.values, ctx.usage())),
180
+ );
181
+ },
182
+ text: channelText,
183
+ };
184
+
185
+ const directId = {
186
+ summary: 'only the id of the 1:1 channel with a person (nothing is created)',
187
+ usage: 'wx channel direct-id (--user USERID | --email EMAIL)',
188
+ options: DIRECT_OPTIONS,
189
+ args: [0, 0],
190
+ async run(ctx) {
191
+ return api(ctx).call('POST', '/channels/direct/resolve-id', directBody(ctx.values, ctx.usage()));
192
+ },
193
+ text: (r) => r?.channelId ?? '',
194
+ };
195
+
196
+ const update = {
197
+ summary: `change the subject, description or access of a channel${NOT_IMPLEMENTED}`,
198
+ usage: 'wx channel update <channel> [--subject S] [--description D] [--access private|public] [--unset FIELD]...',
199
+ details: notImplemented('PartialUpdateChannel'),
200
+ options: {
201
+ subject: {type: 'string', description: 'the new name'},
202
+ description: {type: 'string', description: 'the new description'},
203
+ access: {type: 'string', choices: ACCESS, description: 'who can find and join it'},
204
+ unset: {type: 'string', multiple: true, choices: UNSET, description: 'clear this field'},
205
+ },
206
+ args: [1, 1],
207
+ async run(ctx) {
208
+ const {values} = ctx;
209
+ const ch = channelId(ctx.positionals[0]);
210
+ const set = {};
211
+ for (const k of ['subject', 'description', 'access']) if (values[k] !== undefined) set[k] = values[k];
212
+ const unset = values.unset ?? [];
213
+ if (!Object.keys(set).length && !unset.length)
214
+ throw new UsageError('Nothing to change: give --subject, --description, --access or --unset', ctx.usage());
215
+ const body = {set};
216
+ if (unset.length) body.unset = unset;
217
+ return api(ctx).call('PATCH', apiPath`/channels/${ch}`, body);
218
+ },
219
+ text: done('Updated'),
220
+ };
221
+
222
+ const simple = (name, summary, method, suffix, {details, body = {}, text} = {}) => ({
223
+ summary,
224
+ usage: `wx channel ${name} <channel>`,
225
+ ...(details ? {details} : {}),
226
+ options: {},
227
+ args: [1, 1],
228
+ async run(ctx) {
229
+ const ch = channelId(ctx.positionals[0]);
230
+ return api(ctx).call(method, apiPath`/channels/${ch}` + suffix, body);
231
+ },
232
+ text: done(text),
233
+ });
234
+
235
+ const del = {
236
+ summary: 'delete a channel for everyone (needs --yes)',
237
+ usage: 'wx channel delete <channel> --yes',
238
+ details: 'Removes the channel for every member. Confirm with the person first: --yes is not the confirmation.',
239
+ options: {yes: {type: 'boolean', description: 'yes, delete it for everyone'}},
240
+ args: [1, 1],
241
+ async run(ctx) {
242
+ const ch = channelId(ctx.positionals[0]);
243
+ if (!ctx.values.yes)
244
+ throw new UsageError(
245
+ 'delete removes the channel for everyone: confirm with the person, then add --yes',
246
+ ctx.usage(),
247
+ );
248
+ return api(ctx).call('DELETE', apiPath`/channels/${ch}`);
249
+ },
250
+ text: done('Deleted'),
251
+ };
252
+
253
+ const members = {
254
+ summary: 'members of a channel',
255
+ usage: 'wx channel members <channel> [--limit 1-100] [--offset 0-1000]',
256
+ options: {
257
+ limit: {type: 'string', placeholder: '1-100', description: 'how many'},
258
+ offset: {type: 'string', placeholder: '0-1000', description: 'skip this many'},
259
+ },
260
+ args: [1, 1],
261
+ async run(ctx) {
262
+ const ch = channelId(ctx.positionals[0]);
263
+ const q = query({
264
+ limit: checkInt('limit', ctx.values.limit, 1, 100),
265
+ offset: checkInt('offset', ctx.values.offset, 0, 1000),
266
+ });
267
+ const client = api(ctx);
268
+ const r = await client.call('GET', apiPath`/channels/${ch}/members` + q);
269
+ return withNames(
270
+ ctx,
271
+ r,
272
+ (r?.members ?? []).map((m) => m.userId),
273
+ client,
274
+ );
275
+ },
276
+ text(r, ctx) {
277
+ const c = paint(ctx);
278
+ const list = r?.members ?? [];
279
+ if (!list.length) return 'No members.';
280
+ return table(
281
+ list.map((m) => [c.bold(nameIn(r, m.userId)), m.userId, m.role ?? '', c.dim(`joined ${localTime(m.joinedAt)}`)]),
282
+ );
283
+ },
284
+ };
285
+
286
+ const member = {
287
+ summary: `one member of a channel${NOT_IMPLEMENTED}`,
288
+ usage: 'wx channel member <channel> <userId>',
289
+ details: notImplemented('GetChannelMember'),
290
+ options: {},
291
+ args: [2, 2],
292
+ async run(ctx) {
293
+ const ch = channelId(ctx.positionals[0]);
294
+ const user = checkId('The user id', ctx.positionals[1]);
295
+ const client = api(ctx);
296
+ const r = await client.call('GET', apiPath`/channels/${ch}/members/${user}`);
297
+ return withNames(ctx, r, [r?.member?.userId], client);
298
+ },
299
+ text: (r, ctx) =>
300
+ r?.member
301
+ ? table([
302
+ [
303
+ paint(ctx).bold(nameIn(r, r.member.userId)),
304
+ r.member.userId,
305
+ r.member.role ?? '',
306
+ `joined ${localTime(r.member.joinedAt)}`,
307
+ ],
308
+ ])
309
+ : 'No member.',
310
+ };
311
+
312
+ const add = {
313
+ summary: 'add people to a channel',
314
+ usage: 'wx channel add <channel> (--user USERID | --invite EMAIL)... [--hide-history]',
315
+ options: {
316
+ user: {type: 'string', multiple: true, placeholder: 'USERID', description: 'add this user'},
317
+ invite: {type: 'string', multiple: true, placeholder: 'EMAIL', description: 'invite this e-mail address'},
318
+ 'hide-history': {type: 'boolean', description: 'the new members do not see the earlier messages'},
319
+ },
320
+ args: [1, 1],
321
+ async run(ctx) {
322
+ const {values} = ctx;
323
+ const ch = channelId(ctx.positionals[0]);
324
+ const users = values.user ?? [];
325
+ const invites = values.invite ?? [];
326
+ if (!users.length && !invites.length) throw new UsageError('Give --user USERID or --invite EMAIL', ctx.usage());
327
+ const body = {};
328
+ if (users.length) body.members = users.map((u) => checkId('--user', u));
329
+ if (invites.length) body.membersToInvite = invites.map((e) => ({email: email(e)}));
330
+ if (values['hide-history']) body.hideHistory = true;
331
+ return api(ctx).call('PUT', apiPath`/channels/${ch}/members`, body);
332
+ },
333
+ text: done('Added'),
334
+ };
335
+
336
+ const remove = {
337
+ summary: 'remove a person from a channel',
338
+ usage: 'wx channel remove <channel> <userId>',
339
+ options: {},
340
+ args: [2, 2],
341
+ async run(ctx) {
342
+ const ch = channelId(ctx.positionals[0]);
343
+ const user = checkId('The user id', ctx.positionals[1]);
344
+ return api(ctx).call('DELETE', apiPath`/channels/${ch}/members/${user}`);
345
+ },
346
+ text: done('Removed'),
347
+ };
348
+
349
+ const readStatus = {
350
+ summary: 'who has read a channel up to where (channels with up to 100 members)',
351
+ usage: 'wx channel read-status <channel>',
352
+ options: {},
353
+ args: [1, 1],
354
+ async run(ctx) {
355
+ const ch = channelId(ctx.positionals[0]);
356
+ const client = api(ctx);
357
+ const r = await client.call('GET', apiPath`/channels/${ch}/read-status`);
358
+ return withNames(
359
+ ctx,
360
+ r,
361
+ (r?.members ?? []).map((m) => m.userId),
362
+ client,
363
+ );
364
+ },
365
+ text(r, ctx) {
366
+ const c = paint(ctx);
367
+ const list = r?.members ?? [];
368
+ if (!list.length) return 'No members.';
369
+ return table(
370
+ list.map((m) => [
371
+ c.bold(nameIn(r, m.userId)),
372
+ m.lastReadVisibleSeq === undefined ? c.dim('nothing read') : `up to #${m.lastReadVisibleSeq}`,
373
+ c.dim(localTime(m.lastReadAt)),
374
+ ]),
375
+ );
376
+ },
377
+ };
378
+
379
+ const channel = {
380
+ summary: 'chat channels: get, search, create, 1:1, update, join, leave, delete, members',
381
+ auth: true,
382
+ commands: {
383
+ get,
384
+ query: queryCmd,
385
+ search,
386
+ create,
387
+ direct,
388
+ 'direct-id': directId,
389
+ update,
390
+ join: simple('join', `join a public channel${NOT_IMPLEMENTED}`, 'POST', '/join', {
391
+ details: notImplemented('JoinChannel'),
392
+ text: 'Joined',
393
+ }),
394
+ leave: simple('leave', 'leave a channel', 'POST', '/leave', {text: 'Left'}),
395
+ delete: del,
396
+ members,
397
+ member,
398
+ add,
399
+ remove,
400
+ 'read-status': readStatus,
401
+ },
402
+ };
403
+
404
+ export const groups = {channel};