wldx-cli 1.0.0 → 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 +15 -20
- package/package.json +9 -6
- package/src/chat/format.mjs +40 -8
- package/src/chat/lib.mjs +71 -23
- package/src/chat/watch.mjs +46 -15
- package/src/cli.mjs +13 -252
- package/src/commands/calls.mjs +135 -32
- package/src/commands/channel.mjs +99 -21
- package/src/commands/chat.mjs +77 -22
- package/src/commands/conversations.mjs +146 -29
- package/src/commands/file.mjs +13 -5
- package/src/commands/inbox.mjs +20 -13
- package/src/commands/index.mjs +5 -29
- package/src/commands/live.mjs +82 -24
- package/src/commands/meetings.mjs +114 -27
- package/src/commands/phone.mjs +145 -33
- package/src/commands/reports.mjs +219 -44
- package/src/commands/section.mjs +49 -8
- package/src/commands/user.mjs +42 -13
- package/src/history/text.mjs +30 -4
- package/src/history/wda.mjs +20 -10
- package/src/live/socket.mjs +25 -7
- package/src/live/stats.mjs +41 -9
- package/src/live/text.mjs +122 -23
- package/src/live/views.mjs +93 -23
- package/src/phone/control.mjs +93 -33
- package/src/phone/events.mjs +38 -7
- package/src/phone/pbx.mjs +29 -11
- package/src/phone/sip.mjs +87 -17
- package/src/phone/xml.mjs +2 -1
- package/src/stream.mjs +10 -5
- package/src/version.mjs +0 -1
- package/src/auth.mjs +0 -274
- package/src/commands/login.mjs +0 -126
- package/src/commands/profile.mjs +0 -51
- package/src/commands/status.mjs +0 -95
- package/src/completion.mjs +0 -132
- package/src/errors.mjs +0 -30
- package/src/files.mjs +0 -98
- package/src/format.mjs +0 -82
- package/src/http.mjs +0 -64
- package/src/services.mjs +0 -58
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# wldx-cli — `wx`, a command line for Wildix
|
|
2
2
|
|
|
3
|
-
**Version 1.
|
|
3
|
+
**Version 1.1.0** · unofficial · MIT
|
|
4
4
|
|
|
5
5
|
`wx` works with Wildix as **the logged-in user**: chats, calls and meetings on record, queue reports, what is going
|
|
6
6
|
on now, and your phone. It is the command-line twin of the
|
|
@@ -15,7 +15,8 @@ wx chat list
|
|
|
15
15
|
npx wldx-cli chat list # without installing
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Requires Node.js 22 or newer.
|
|
18
|
+
Requires Node.js 22 or newer. One dependency: [`wldx-core`](https://github.com/gorets/wildix-cli/tree/main/packages/core), the core it shares with `wx-admin`, from the
|
|
19
|
+
same repository and of the same version.
|
|
19
20
|
|
|
20
21
|
Unofficial: not made or supported by Wildix. It uses the same internal APIs as the Wildix apps, which may change.
|
|
21
22
|
|
|
@@ -317,27 +318,21 @@ another login · `3` nothing to show (no transcript yet) · `64` wrong usage.
|
|
|
317
318
|
|
|
318
319
|
## Development
|
|
319
320
|
|
|
321
|
+
`wx` lives in the pnpm workspace of [gorets/wildix-cli](https://github.com/gorets/wildix-cli) next to `wx-admin` and
|
|
322
|
+
their shared core (`packages/core`): see the [README](https://github.com/gorets/wildix-cli) and [AGENTS.md](https://github.com/gorets/wildix-cli/blob/main/AGENTS.md) there.
|
|
323
|
+
|
|
320
324
|
```sh
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
325
|
+
pnpm install # at the root of the repository
|
|
326
|
+
pnpm --filter wldx-cli test # node:test, no network
|
|
327
|
+
node packages/wx/bin/wx.mjs status # this checkout, without installing
|
|
324
328
|
```
|
|
325
329
|
|
|
326
|
-
Layout: `bin/wx.mjs` (entry) → `src/cli.mjs` (router) → `src/commands/<group>.mjs`,
|
|
327
|
-
`src/chat`, `src/history`, `src/live`, `src/phone`.
|
|
328
|
-
`
|
|
329
|
-
|
|
330
|
-
yet; after changing commands, regenerate the reference: `node tools/readme-commands.mjs`.
|
|
330
|
+
Layout: `bin/wx.mjs` (entry) → `src/cli.mjs` (the spec on the router of the core) → `src/commands/<group>.mjs`,
|
|
331
|
+
with helpers per domain in `src/chat`, `src/history`, `src/live`, `src/phone`. Login, sessions, addresses, requests
|
|
332
|
+
and text output come from `wldx-core`. See [AGENTS.md](https://github.com/gorets/wildix-cli/blob/main/packages/wx/AGENTS.md). `node test/dev.mjs <module> <args>` runs a command
|
|
333
|
+
module that is not listed yet; after changing commands, regenerate the reference: `node tools/readme-commands.mjs`.
|
|
331
334
|
|
|
332
335
|
## Releasing
|
|
333
336
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
the tests and **stages** the version with provenance through npm trusted publishing (no npm token in the
|
|
337
|
-
repository).
|
|
338
|
-
3. Approve it with 2FA, which makes it public: `npm stage list wldx-cli`, then `npm stage approve <stage-id>`
|
|
339
|
-
(`npm stage download <stage-id>` to look at the tarball first).
|
|
340
|
-
|
|
341
|
-
The trusted publisher on npmjs.com (package settings → Trusted publishing): GitHub Actions, `gorets/wildix-cli`,
|
|
342
|
-
workflow `release.yml`, permission "npm stage publish". Version 0.1.0 was published by hand, because a trusted
|
|
343
|
-
publisher can be configured only for a package that exists.
|
|
337
|
+
Together with `wx-admin` and the core, one version for the three: see Releasing in the
|
|
338
|
+
[README of the repository](https://github.com/gorets/wildix-cli#releasing).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wldx-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Unofficial command line for Wildix (x-bees chats and more), acting as the logged-in user",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"wildix",
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
"author": "Vladimir Gorobets",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/gorets/wildix-cli.git"
|
|
15
|
+
"url": "git+https://github.com/gorets/wildix-cli.git",
|
|
16
|
+
"directory": "packages/wx"
|
|
16
17
|
},
|
|
17
|
-
"homepage": "https://github.com/gorets/wildix-cli#readme",
|
|
18
|
+
"homepage": "https://github.com/gorets/wildix-cli/tree/main/packages/wx#readme",
|
|
18
19
|
"bugs": "https://github.com/gorets/wildix-cli/issues",
|
|
19
20
|
"type": "module",
|
|
20
21
|
"bin": {
|
|
@@ -27,8 +28,10 @@
|
|
|
27
28
|
"engines": {
|
|
28
29
|
"node": ">=22"
|
|
29
30
|
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"wldx-core": "1.1.0"
|
|
33
|
+
},
|
|
30
34
|
"scripts": {
|
|
31
|
-
"test": "node --test test/*.test.mjs"
|
|
32
|
-
"check": "npm test && npm pack --dry-run"
|
|
35
|
+
"test": "node --test test/*.test.mjs"
|
|
33
36
|
}
|
|
34
|
-
}
|
|
37
|
+
}
|
package/src/chat/format.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Text output of the chat commands: one line per channel, message, member or person, local times, names of
|
|
2
2
|
// people instead of their ids (looked up by the command, see withNames in lib.mjs).
|
|
3
|
-
import {localTime, oneLine, paint, table} from '
|
|
3
|
+
import {localTime, oneLine, paint, table} from 'wldx-core/format';
|
|
4
4
|
import {displayName, nameIn} from './lib.mjs';
|
|
5
5
|
|
|
6
6
|
const reactions = (counts) => {
|
|
7
|
-
const parts = Object.entries(counts ?? {})
|
|
7
|
+
const parts = Object.entries(counts ?? {})
|
|
8
|
+
.filter(([, n]) => n > 0)
|
|
9
|
+
.map(([t, n]) => `${t} ${n}`);
|
|
8
10
|
return parts.length ? `[${parts.join(', ')}]` : '';
|
|
9
11
|
};
|
|
10
12
|
|
|
@@ -31,11 +33,20 @@ export function messageText(m, data, {max = 2000} = {}) {
|
|
|
31
33
|
export function messageLines(messages, data, ctx) {
|
|
32
34
|
const c = paint(ctx);
|
|
33
35
|
const sorted = [...(messages ?? [])].sort((a, b) => (a.timelineSeq ?? 0) - (b.timelineSeq ?? 0));
|
|
34
|
-
return table(
|
|
36
|
+
return table(
|
|
37
|
+
sorted.map((m) => [
|
|
38
|
+
c.dim(localTime(m.createdAt)),
|
|
39
|
+
c.bold(nameIn(data, m.userId) || '?'),
|
|
40
|
+
`${messageText(m, data)} ${c.dim(m.messageId)}`,
|
|
41
|
+
]),
|
|
42
|
+
);
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
// The title of a channel: its subject, or for a 1:1 channel the name of the other person.
|
|
38
|
-
export const channelTitle = (ch, data) =>
|
|
46
|
+
export const channelTitle = (ch, data) =>
|
|
47
|
+
ch?.subject ||
|
|
48
|
+
(ch?.peerId ? nameIn(data, ch.peerId) : '') ||
|
|
49
|
+
(ch?.channelType === 'direct' ? '(direct)' : '(no subject)');
|
|
39
50
|
|
|
40
51
|
// The people a channel line names: the other person of a 1:1 channel, the author of the last message.
|
|
41
52
|
export const channelUserIds = (ch) => [ch?.peerId, ch?.lastMessage?.userId];
|
|
@@ -62,7 +73,15 @@ const preview = (ch, data) => {
|
|
|
62
73
|
// Channels: last activity, id, inbox flags, title, the last message.
|
|
63
74
|
export function channelLines(channels, data, ctx) {
|
|
64
75
|
const c = paint(ctx);
|
|
65
|
-
return table(
|
|
76
|
+
return table(
|
|
77
|
+
(channels ?? []).map((ch) => [
|
|
78
|
+
c.dim(localTime(ch.lastActivityAt)),
|
|
79
|
+
ch.channelId,
|
|
80
|
+
flags(ch, c),
|
|
81
|
+
c.bold(oneLine(channelTitle(ch, data), 50)),
|
|
82
|
+
c.dim(preview(ch, data)),
|
|
83
|
+
]),
|
|
84
|
+
);
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
export function channelDetails(ch, data, ctx) {
|
|
@@ -71,7 +90,10 @@ export function channelDetails(ch, data, ctx) {
|
|
|
71
90
|
const rows = [
|
|
72
91
|
['Channel', ch.channelId],
|
|
73
92
|
['Title', c.bold(channelTitle(ch, data))],
|
|
74
|
-
[
|
|
93
|
+
[
|
|
94
|
+
'Type',
|
|
95
|
+
[ch.channelType, ch.access, ch.broadcast && 'broadcast', ch.external && 'external'].filter(Boolean).join(' · '),
|
|
96
|
+
],
|
|
75
97
|
];
|
|
76
98
|
if (ch.description) rows.push(['Description', oneLine(ch.description, 300)]);
|
|
77
99
|
if (ch.peerId) rows.push(['With', `${nameIn(data, ch.peerId)} (${ch.peerId})`]);
|
|
@@ -80,7 +102,10 @@ export function channelDetails(ch, data, ctx) {
|
|
|
80
102
|
rows.push(['Last activity', localTime(ch.lastActivityAt)]);
|
|
81
103
|
if (ch.pinnedMessageIds?.length) rows.push(['Pinned', ch.pinnedMessageIds.join(', ')]);
|
|
82
104
|
if (ch.autoRecord !== undefined || ch.autoTranscribe !== undefined) {
|
|
83
|
-
rows.push([
|
|
105
|
+
rows.push([
|
|
106
|
+
'Conferences',
|
|
107
|
+
[ch.autoRecord && 'auto record', ch.autoTranscribe && 'auto transcribe'].filter(Boolean).join(', ') || 'manual',
|
|
108
|
+
]);
|
|
84
109
|
}
|
|
85
110
|
if (ch.inbox) rows.push(['Inbox', flags(ch, c) || 'read']);
|
|
86
111
|
return table(rows);
|
|
@@ -88,7 +113,14 @@ export function channelDetails(ch, data, ctx) {
|
|
|
88
113
|
|
|
89
114
|
// A person on one line: name, id, e-mail, extension, bot.
|
|
90
115
|
export function userLine(u, c) {
|
|
91
|
-
return [
|
|
116
|
+
return [
|
|
117
|
+
c.bold(displayName(u) ?? ''),
|
|
118
|
+
u.id,
|
|
119
|
+
u.email ?? '',
|
|
120
|
+
u.pbxExtension ? `ext ${u.pbxExtension}` : '',
|
|
121
|
+
u.bot ? 'bot' : '',
|
|
122
|
+
u.deactivatedAt ? c.dim('deactivated') : '',
|
|
123
|
+
];
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
export const nextPage = (cursor, c) => (cursor ? [c.dim(`More: --cursor ${cursor}`)] : []);
|
package/src/chat/lib.mjs
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
import {randomUUID} from 'node:crypto';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import {jwtClaims} from '
|
|
8
|
-
import {UsageError} from '
|
|
9
|
-
import {readForUpload, refuseUnsafeWrite} from '
|
|
10
|
-
import {apiPath, request} from '
|
|
7
|
+
import {jwtClaims} from 'wldx-core/auth';
|
|
8
|
+
import {UsageError} from 'wldx-core/errors';
|
|
9
|
+
import {readForUpload, refuseUnsafeWrite} from 'wldx-core/files';
|
|
10
|
+
import {apiPath, request} from 'wldx-core/http';
|
|
11
11
|
import {streamClient} from '../stream.mjs';
|
|
12
12
|
|
|
13
13
|
// The operations the service answers with HTTP 400 "Operation <Name> is not implemented." (checked 2026-11-05).
|
|
@@ -20,19 +20,22 @@ export const notImplemented = (operation) =>
|
|
|
20
20
|
export function checkInt(name, value, min, max) {
|
|
21
21
|
if (value === undefined) return undefined;
|
|
22
22
|
const s = String(value);
|
|
23
|
-
if (!/^\d{1,12}$/.test(s) || Number(s) < min || Number(s) > max)
|
|
23
|
+
if (!/^\d{1,12}$/.test(s) || Number(s) < min || Number(s) > max)
|
|
24
|
+
throw new UsageError(`--${name} must be a whole number from ${min} to ${max}: ${value}`);
|
|
24
25
|
return Number(s);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// An id that goes into a request path or a body: id characters only, never "/", "?", "#", ".." or nothing.
|
|
28
29
|
export function checkId(what, value) {
|
|
29
30
|
const v = String(value ?? '');
|
|
30
|
-
if (!/^[A-Za-z0-9_+][A-Za-z0-9_.:+-]*$/.test(v) || v.includes('..'))
|
|
31
|
+
if (!/^[A-Za-z0-9_+][A-Za-z0-9_.:+-]*$/.test(v) || v.includes('..'))
|
|
32
|
+
throw new UsageError(`${what} is not a valid id: ${value}`);
|
|
31
33
|
return v;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function checkReaction(type) {
|
|
35
|
-
if (!/^[a-z0-9_+-]{1,64}$/.test(String(type)))
|
|
37
|
+
if (!/^[a-z0-9_+-]{1,64}$/.test(String(type)))
|
|
38
|
+
throw new UsageError(`A reaction type must match [a-z0-9_+-] (up to 64): ${type}`);
|
|
36
39
|
return type;
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -90,7 +93,7 @@ export async function withNames(ctx, data, ids, client = api(ctx)) {
|
|
|
90
93
|
Object.defineProperty(data, NAMES, {value: users, enumerable: false});
|
|
91
94
|
return data;
|
|
92
95
|
}
|
|
93
|
-
export const nameIn = (data, id) => (id ? displayName(data?.[NAMES]?.get(id)) ?? id : '');
|
|
96
|
+
export const nameIn = (data, id) => (id ? (displayName(data?.[NAMES]?.get(id)) ?? id) : '');
|
|
94
97
|
|
|
95
98
|
// The ids of people a message names: its author, the author of its quote.
|
|
96
99
|
export const messageUserIds = (m) => [m?.userId, m?.quote?.userId, m?.pinnedBy];
|
|
@@ -98,16 +101,42 @@ export const messageUserIds = (m) => [m?.userId, m?.quote?.userId, m?.pinnedBy];
|
|
|
98
101
|
// ── files ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
99
102
|
|
|
100
103
|
// The checks of files to attach and to save: src/files.mjs.
|
|
101
|
-
export {refuseSecretPath} from '
|
|
104
|
+
export {refuseSecretPath} from 'wldx-core/files';
|
|
102
105
|
|
|
103
106
|
const MIME = {
|
|
104
|
-
txt: 'text/plain',
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
txt: 'text/plain',
|
|
108
|
+
md: 'text/markdown',
|
|
109
|
+
csv: 'text/csv',
|
|
110
|
+
html: 'text/html',
|
|
111
|
+
htm: 'text/html',
|
|
112
|
+
css: 'text/css',
|
|
113
|
+
js: 'text/javascript',
|
|
114
|
+
json: 'application/json',
|
|
115
|
+
xml: 'application/xml',
|
|
116
|
+
pdf: 'application/pdf',
|
|
117
|
+
zip: 'application/zip',
|
|
118
|
+
gz: 'application/gzip',
|
|
119
|
+
tar: 'application/x-tar',
|
|
120
|
+
doc: 'application/msword',
|
|
121
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
122
|
+
xls: 'application/vnd.ms-excel',
|
|
123
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
124
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
125
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
126
|
+
png: 'image/png',
|
|
127
|
+
jpg: 'image/jpeg',
|
|
128
|
+
jpeg: 'image/jpeg',
|
|
129
|
+
gif: 'image/gif',
|
|
130
|
+
webp: 'image/webp',
|
|
131
|
+
svg: 'image/svg+xml',
|
|
132
|
+
heic: 'image/heic',
|
|
133
|
+
mp3: 'audio/mpeg',
|
|
134
|
+
wav: 'audio/wav',
|
|
135
|
+
ogg: 'audio/ogg',
|
|
136
|
+
m4a: 'audio/mp4',
|
|
137
|
+
mp4: 'video/mp4',
|
|
138
|
+
mov: 'video/quicktime',
|
|
139
|
+
webm: 'video/webm',
|
|
111
140
|
};
|
|
112
141
|
export const mimeOf = (file) => MIME[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
|
|
113
142
|
|
|
@@ -119,12 +148,14 @@ function presigned(url, env) {
|
|
|
119
148
|
} catch {
|
|
120
149
|
throw new Error('The service gave no valid storage link');
|
|
121
150
|
}
|
|
122
|
-
if (u.protocol !== 'https:' && !(env.WILDIX_ALLOW_CUSTOM_URL === '1' && u.protocol === 'http:'))
|
|
151
|
+
if (u.protocol !== 'https:' && !(env.WILDIX_ALLOW_CUSTOM_URL === '1' && u.protocol === 'http:'))
|
|
152
|
+
throw new Error(`The storage link is not https: ${u.protocol}`);
|
|
123
153
|
return u.href;
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
const ATTACHMENT_FIELDS = ['fsId', 'name', 'size', 'mime', 'width', 'height', 'thumbnail'];
|
|
127
|
-
export const attachmentOf = (f) =>
|
|
157
|
+
export const attachmentOf = (f) =>
|
|
158
|
+
Object.fromEntries(ATTACHMENT_FIELDS.filter((k) => f?.[k] !== undefined && f?.[k] !== null).map((k) => [k, f[k]]));
|
|
128
159
|
|
|
129
160
|
// Uploads a file to a channel and returns the attachment object to put into a message (fsId, name, size, mime…).
|
|
130
161
|
export async function uploadFile(ctx, channel, file, client = api(ctx)) {
|
|
@@ -133,7 +164,14 @@ export async function uploadFile(ctx, channel, file, client = api(ctx)) {
|
|
|
133
164
|
const r = await client.call('POST', apiPath`/channels/${channel}/files`, {name});
|
|
134
165
|
if (!r?.fsId || !r?.presignedUploadUrl) throw new Error('Unexpected answer of the upload: no fsId or link');
|
|
135
166
|
const fsId = checkId('The file id of the answer', r.fsId);
|
|
136
|
-
await request(presigned(r.presignedUploadUrl, ctx.env), {
|
|
167
|
+
await request(presigned(r.presignedUploadUrl, ctx.env), {
|
|
168
|
+
method: 'PUT',
|
|
169
|
+
raw: {data, contentType: mimeOf(file)},
|
|
170
|
+
responseType: 'text',
|
|
171
|
+
fetchImpl: ctx.fetch,
|
|
172
|
+
timeoutMs: 600_000,
|
|
173
|
+
service: 'the storage',
|
|
174
|
+
});
|
|
137
175
|
const info = await client.call('GET', apiPath`/channels/${channel}/files/${fsId}`);
|
|
138
176
|
return attachmentOf(info?.file ?? {fsId, name, size: data.length});
|
|
139
177
|
}
|
|
@@ -142,12 +180,22 @@ export async function uploadFile(ctx, channel, file, client = api(ctx)) {
|
|
|
142
180
|
export async function downloadFile(ctx, channel, message, fsId, to, client = api(ctx)) {
|
|
143
181
|
const out = path.resolve(ctx.cwd ?? process.cwd(), to);
|
|
144
182
|
refuseUnsafeWrite(out, ctx.env);
|
|
145
|
-
if (fs.existsSync(out))
|
|
183
|
+
if (fs.existsSync(out))
|
|
184
|
+
throw new UsageError(`${to} exists already: choose another path (nothing was overwritten or deleted)`);
|
|
146
185
|
const dir = path.dirname(out);
|
|
147
|
-
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
148
|
-
|
|
186
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
187
|
+
throw new UsageError(`The folder of ${to} does not exist`);
|
|
188
|
+
const r = await client.call(
|
|
189
|
+
'GET',
|
|
190
|
+
apiPath`/channels/${channel}/messages/${message}/files/${fsId}/download-url` + '?download=1',
|
|
191
|
+
);
|
|
149
192
|
if (!r?.presignedDownloadUrl) throw new Error('No download link in the answer');
|
|
150
|
-
const data = await request(presigned(r.presignedDownloadUrl, ctx.env), {
|
|
193
|
+
const data = await request(presigned(r.presignedDownloadUrl, ctx.env), {
|
|
194
|
+
responseType: 'buffer',
|
|
195
|
+
fetchImpl: ctx.fetch,
|
|
196
|
+
timeoutMs: 600_000,
|
|
197
|
+
service: 'the storage',
|
|
198
|
+
});
|
|
151
199
|
const tmp = path.join(dir, `.download.${process.pid}.${randomUUID().slice(0, 8)}`);
|
|
152
200
|
fs.writeFileSync(tmp, data, {flag: 'wx', mode: 0o600});
|
|
153
201
|
try {
|
package/src/chat/watch.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// ctx.emit(); READY when subscribed, RECONNECTING / RECONNECTED while the connection is renewed (the service closes
|
|
4
4
|
// one after two hours; the token, checked only when a connection opens, is renewed with ctx.freshToken()), END at
|
|
5
5
|
// the end (ctx.signal, --count or --for).
|
|
6
|
-
import {jwtClaims} from '
|
|
7
|
-
import {AuthError} from '
|
|
8
|
-
import {localTime, oneLine, paint} from '
|
|
9
|
-
import {streamWsUrl} from '
|
|
6
|
+
import {jwtClaims} from 'wldx-core/auth';
|
|
7
|
+
import {AuthError} from 'wldx-core/errors';
|
|
8
|
+
import {localTime, oneLine, paint} from 'wldx-core/format';
|
|
9
|
+
import {streamWsUrl} from 'wldx-core/services';
|
|
10
10
|
import {streamClient} from '../stream.mjs';
|
|
11
11
|
import {displayName, meId} from './lib.mjs';
|
|
12
12
|
|
|
@@ -16,8 +16,17 @@ export const EVENT_GROUPS = {
|
|
|
16
16
|
reads: ['read.cursor.updated', 'read.receipt.updated'],
|
|
17
17
|
members: ['member.added', 'member.updated', 'member.removed'],
|
|
18
18
|
channels: [
|
|
19
|
-
'channel.created',
|
|
20
|
-
'channel.
|
|
19
|
+
'channel.created',
|
|
20
|
+
'channel.updated',
|
|
21
|
+
'channel.truncated',
|
|
22
|
+
'channel.added',
|
|
23
|
+
'channel.removed',
|
|
24
|
+
'channel.hidden',
|
|
25
|
+
'channel.visible',
|
|
26
|
+
'channel.muted',
|
|
27
|
+
'channel.unmuted',
|
|
28
|
+
'channel.pinned',
|
|
29
|
+
'channel.unpinned',
|
|
21
30
|
],
|
|
22
31
|
typing: ['typing.start', 'typing.stop'],
|
|
23
32
|
};
|
|
@@ -26,7 +35,8 @@ export const EVENT_CHOICES = [...Object.keys(EVENT_GROUPS), ...Object.values(EVE
|
|
|
26
35
|
const KEEPALIVE_MS = 4 * 60 * 1000; // the gateway drops a connection that is quiet for 10 minutes
|
|
27
36
|
const BACKOFF = [1, 2, 5, 10]; // seconds, then 30
|
|
28
37
|
const drop = (o) => JSON.parse(JSON.stringify(o)); // removes the undefined fields
|
|
29
|
-
const unreadOf = (counters) =>
|
|
38
|
+
const unreadOf = (counters) =>
|
|
39
|
+
counters ? Object.values(counters).reduce((n, c) => n + (c?.unmutedUnreadMessageCount ?? 0), 0) : undefined;
|
|
30
40
|
|
|
31
41
|
// Whether an event passes the filters. A reaction event does not say who reacted (its message is someone else's
|
|
32
42
|
// work), so a reaction has no author: it is never taken for the user's own action, and --from leaves it out.
|
|
@@ -34,7 +44,7 @@ export function wanted(e, {channels, senders, types, mine, me}) {
|
|
|
34
44
|
if (types.size && !types.has(e.eventType)) return false;
|
|
35
45
|
if (channels.size && !channels.has(e.channelId)) return false;
|
|
36
46
|
const isReaction = e.eventType.startsWith('reaction.');
|
|
37
|
-
const author = isReaction ? undefined : e.message?.userId ?? e.readerUserId ?? e.typingUserId;
|
|
47
|
+
const author = isReaction ? undefined : (e.message?.userId ?? e.readerUserId ?? e.typingUserId);
|
|
38
48
|
if (isReaction && senders.size) return false;
|
|
39
49
|
if (senders.size && !senders.has(author)) return false;
|
|
40
50
|
if (!mine) {
|
|
@@ -74,7 +84,18 @@ export async function view(e, nameOf) {
|
|
|
74
84
|
const tokenExp = (token) => (Number.isFinite(jwtClaims(token).exp) ? jwtClaims(token).exp * 1000 : 0);
|
|
75
85
|
|
|
76
86
|
// Runs until ctx.signal, `count` events (0: no limit) or `seconds` (0: no limit). Resolves when it stops.
|
|
77
|
-
export async function watch(
|
|
87
|
+
export async function watch(
|
|
88
|
+
ctx,
|
|
89
|
+
{
|
|
90
|
+
channels = new Set(),
|
|
91
|
+
senders = new Set(),
|
|
92
|
+
types = new Set(),
|
|
93
|
+
mine = false,
|
|
94
|
+
raw = false,
|
|
95
|
+
count = 0,
|
|
96
|
+
seconds = 0,
|
|
97
|
+
} = {},
|
|
98
|
+
) {
|
|
78
99
|
let token = ctx.token;
|
|
79
100
|
let client = streamClient({token, env: ctx.env, fetchImpl: ctx.fetch});
|
|
80
101
|
const me = await meId(ctx, client);
|
|
@@ -88,7 +109,9 @@ export async function watch(ctx, {channels = new Set(), senders = new Set(), typ
|
|
|
88
109
|
if (!names.has(id)) {
|
|
89
110
|
let name;
|
|
90
111
|
try {
|
|
91
|
-
name = displayName(
|
|
112
|
+
name = displayName(
|
|
113
|
+
(await client.call('POST', '/users/query', {query: {ids: [id]}}, {timeoutMs: 10_000}))?.users?.[0],
|
|
114
|
+
);
|
|
92
115
|
} catch {
|
|
93
116
|
// a name is a nicety: the id is printed anyway
|
|
94
117
|
}
|
|
@@ -203,11 +226,17 @@ export async function watch(ctx, {channels = new Set(), senders = new Set(), typ
|
|
|
203
226
|
}
|
|
204
227
|
attempts += 1;
|
|
205
228
|
if (!opened && attempts >= 5) {
|
|
206
|
-
finish(
|
|
229
|
+
finish(
|
|
230
|
+
new AuthError(
|
|
231
|
+
`The chat service did not accept the connection (code ${ev?.code}): log in again (wx login) if this repeats`,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
207
234
|
return;
|
|
208
235
|
}
|
|
209
236
|
const wait = BACKOFF[attempts - 1] ?? 30;
|
|
210
|
-
queue = queue.then(
|
|
237
|
+
queue = queue.then(
|
|
238
|
+
() => !stopping && ctx.emit({event: 'RECONNECTING', code: ev?.code, attempt: attempts, inSeconds: wait}),
|
|
239
|
+
);
|
|
211
240
|
retry = setTimeout(again, wait * 1000);
|
|
212
241
|
};
|
|
213
242
|
};
|
|
@@ -219,8 +248,8 @@ export async function watch(ctx, {channels = new Set(), senders = new Set(), typ
|
|
|
219
248
|
export function eventText(e, ctx) {
|
|
220
249
|
const c = paint(ctx);
|
|
221
250
|
const at = c.dim(localTime(ctx?.clock ? ctx.clock() : Date.now(), {seconds: true}));
|
|
222
|
-
const where = e.channel ? c.bold(oneLine(e.channel, 40)) : e.channelId ?? '';
|
|
223
|
-
const who = (p) => (p ? p.name ?? p.id : '?');
|
|
251
|
+
const where = e.channel ? c.bold(oneLine(e.channel, 40)) : (e.channelId ?? '');
|
|
252
|
+
const who = (p) => (p ? (p.name ?? p.id) : '?');
|
|
224
253
|
switch (true) {
|
|
225
254
|
case e.event === 'READY':
|
|
226
255
|
return `${at} ${c.green('watching the chats')}${e.types ? ` (${e.types.join(', ')})` : ''}${e.channels ? ` in ${e.channels.join(', ')}` : ''}${e.from ? ` from ${e.from.join(', ')}` : ''}. Ctrl-C stops.`;
|
|
@@ -236,7 +265,9 @@ export function eventText(e, ctx) {
|
|
|
236
265
|
return `${at} ${e.event.padEnd(15)} ${where} ${c.bold(who(e.from))}: ${text}`;
|
|
237
266
|
}
|
|
238
267
|
case e.event?.startsWith('reaction.'): {
|
|
239
|
-
const counts = Object.entries(e.reactions ?? {})
|
|
268
|
+
const counts = Object.entries(e.reactions ?? {})
|
|
269
|
+
.map(([t, n]) => `${t} ${n}`)
|
|
270
|
+
.join(', ');
|
|
240
271
|
return `${at} ${e.event.padEnd(15)} ${where} on a message of ${who(e.messageAuthor)}: ${counts ? `[${counts}]` : '(none)'}`;
|
|
241
272
|
}
|
|
242
273
|
case e.event?.startsWith('read.'):
|