wldx-cli 0.1.1 → 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 +272 -23
- 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
package/src/auth.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// The Wildix login of the user,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// RefreshToken
|
|
1
|
+
// The Wildix login of the user (x-bees, x-hoppers, Collaboration 7): Cognito CUSTOM_AUTH with a one-time code sent
|
|
2
|
+
// by e-mail. Shared with the wildix-auth agent skill: one file per profile (e-mail) in $WILDIX_CONFIG_DIR/tokens (default
|
|
3
|
+
// ~/.wildix/tokens), named <e-mail with @ as _at_>.prod.json, holding the Cognito set {IdToken, AccessToken,
|
|
4
|
+
// RefreshToken, ExpiresIn, email, savedAt}. An expired IdToken is renewed with the RefreshToken and written back in
|
|
5
|
+
// the same format, so the skill and the CLI keep using one session.
|
|
5
6
|
//
|
|
6
|
-
// The
|
|
7
|
+
// The tokens are never printed and never put on a command line or into the environment of a child process.
|
|
7
8
|
import crypto from 'node:crypto';
|
|
8
9
|
import fs from 'node:fs';
|
|
9
10
|
import os from 'node:os';
|
|
@@ -14,17 +15,29 @@ import {USER_AGENT} from './version.mjs';
|
|
|
14
15
|
export const CLIENT_ID = '2un9vsk5ghu0ocurajqe4a6l1e';
|
|
15
16
|
export const COGNITO_URL = 'https://cognito-idp.eu-central-1.amazonaws.com/';
|
|
16
17
|
const RENEW_EARLY_MS = 60_000;
|
|
17
|
-
|
|
18
|
+
// Cognito keeps the session of a sign-in that waits for its code for 3 minutes.
|
|
19
|
+
export const PENDING_TTL_MS = 3 * 60_000;
|
|
20
|
+
export const LOGIN_HINT = 'Log in with: wx login <e-mail>';
|
|
18
21
|
|
|
19
22
|
export const configDir = (env = process.env) => env.WILDIX_CONFIG_DIR || path.join(os.homedir(), '.wildix');
|
|
20
23
|
export const tokensDir = (env = process.env) => path.join(configDir(env), 'tokens');
|
|
21
24
|
|
|
25
|
+
const EMAIL = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
26
|
+
|
|
27
|
+
export function checkEmail(email) {
|
|
28
|
+
if (typeof email !== 'string' || !EMAIL.test(email) || email.includes('..')) throw new AuthError(`Not a valid e-mail: ${email}`);
|
|
29
|
+
return email.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
// The e-mail becomes part of a file name: letters, digits and . _ % + - @ only, no "..".
|
|
23
33
|
export function tokenFile(email, env = process.env) {
|
|
24
34
|
if (!/^[A-Za-z0-9._%+@-]+$/.test(email) || email.includes('..')) throw new AuthError(`Not a valid e-mail: ${email}`);
|
|
25
35
|
return path.join(tokensDir(env), `${email.replace(/@/g, '_at_')}.prod.json`);
|
|
26
36
|
}
|
|
27
37
|
|
|
38
|
+
// A sign-in that waits for its code (wx login --send-code, then --code): the Cognito session, privately.
|
|
39
|
+
export const pendingFile = (email, env = process.env) => path.join(tokensDir(env), `.${email.replace(/@/g, '_at_')}.pending.json`);
|
|
40
|
+
|
|
28
41
|
function readJson(file) {
|
|
29
42
|
try {
|
|
30
43
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -54,7 +67,7 @@ export function sessions(env = process.env) {
|
|
|
54
67
|
const dir = tokensDir(env);
|
|
55
68
|
let names;
|
|
56
69
|
try {
|
|
57
|
-
names = fs.readdirSync(dir).filter((n) => n.endsWith('.prod.json')).sort();
|
|
70
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.prod.json') && !n.startsWith('.')).sort();
|
|
58
71
|
} catch {
|
|
59
72
|
return [];
|
|
60
73
|
}
|
|
@@ -64,65 +77,198 @@ export function sessions(env = process.env) {
|
|
|
64
77
|
.map((set) => ({email: set.email, expiresAt: expiresAt(set)}));
|
|
65
78
|
}
|
|
66
79
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
// ── profiles ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// A profile is an e-mail logged in to Wildix. The default one is kept in $WILDIX_CONFIG_DIR/wx.json ({"defaultProfile":
|
|
82
|
+
// e-mail}, 0600); the wildix-auth skill does not read it.
|
|
83
|
+
export const settingsFile = (env = process.env) => path.join(configDir(env), 'wx.json');
|
|
84
|
+
const readSettings = (env) => readJson(settingsFile(env)) ?? {};
|
|
85
|
+
const sameEmail = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
|
|
86
|
+
|
|
87
|
+
// The default profile, when it is still logged in; else undefined.
|
|
88
|
+
export function defaultProfile(env = process.env) {
|
|
89
|
+
const wanted = readSettings(env).defaultProfile;
|
|
90
|
+
return wanted ? sessions(env).find((s) => sameEmail(s.email, wanted))?.email : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function setDefaultProfile(email, env = process.env) {
|
|
94
|
+
const settings = readSettings(env);
|
|
95
|
+
if (email) settings.defaultProfile = email;
|
|
96
|
+
else delete settings.defaultProfile;
|
|
97
|
+
writePrivate(settingsFile(env), settings);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Which profile, and why: --profile, else WILDIX_EMAIL, else the default profile, else the only one logged in.
|
|
101
|
+
// Returns {email, by: 'option'|'environment'|'default'|'only'}.
|
|
102
|
+
export function chooseProfile(env = process.env, profile) {
|
|
71
103
|
const list = sessions(env);
|
|
104
|
+
const given = profile || env.WILDIX_EMAIL;
|
|
105
|
+
// An e-mail given in another case is the saved session of that e-mail.
|
|
106
|
+
if (given) return {email: list.find((s) => sameEmail(s.email, given))?.email ?? given, by: profile ? 'option' : 'environment'};
|
|
72
107
|
if (!list.length) throw new AuthError(`Not logged in to Wildix. ${LOGIN_HINT}`);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
108
|
+
const byDefault = defaultProfile(env);
|
|
109
|
+
if (byDefault) return {email: byDefault, by: 'default'};
|
|
110
|
+
if (list.length === 1) return {email: list[0].email, by: 'only'};
|
|
111
|
+
throw new AuthError(
|
|
112
|
+
`Several profiles are logged in and none is the default. Choose one for this command with --profile, or set the default: wx profile use <e-mail>\n${list.map((s) => ` ${s.email}`).join('\n')}`,
|
|
113
|
+
);
|
|
77
114
|
}
|
|
78
115
|
|
|
116
|
+
export const pickProfile = (env = process.env, profile) => chooseProfile(env, profile).email;
|
|
117
|
+
|
|
79
118
|
// The format of the skill: whole seconds, no milliseconds (its bash parses it with `date -f %Y-%m-%dT%H:%M:%SZ`).
|
|
80
119
|
export const stamp = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
81
120
|
|
|
121
|
+
function writePrivate(file, data) {
|
|
122
|
+
const dir = path.dirname(file);
|
|
123
|
+
fs.mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
124
|
+
try {
|
|
125
|
+
fs.chmodSync(dir, 0o700);
|
|
126
|
+
} catch {
|
|
127
|
+
// not ours to change: the file itself is still 0600
|
|
128
|
+
}
|
|
129
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}`);
|
|
130
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, {mode: 0o600, flag: 'wx'});
|
|
131
|
+
fs.renameSync(tmp, file);
|
|
132
|
+
}
|
|
133
|
+
|
|
82
134
|
// Writes a token set privately (0600) and atomically; a set without IdToken, RefreshToken and ExpiresIn is refused.
|
|
83
135
|
export function saveTokens(file, set) {
|
|
84
136
|
if (typeof set?.IdToken !== 'string' || typeof set?.RefreshToken !== 'string' || typeof set?.ExpiresIn !== 'number') {
|
|
85
137
|
throw new AuthError('The answer carried no complete set of tokens: nothing was saved.');
|
|
86
138
|
}
|
|
87
|
-
|
|
88
|
-
fs.mkdirSync(dir, {recursive: true, mode: 0o700});
|
|
89
|
-
const tmp = path.join(dir, `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}`);
|
|
90
|
-
fs.writeFileSync(tmp, `${JSON.stringify(set, null, 2)}\n`, {mode: 0o600, flag: 'wx'});
|
|
91
|
-
fs.renameSync(tmp, file);
|
|
139
|
+
writePrivate(file, set);
|
|
92
140
|
}
|
|
93
141
|
|
|
94
|
-
|
|
142
|
+
// One call to the Cognito identity provider. Returns the answer; throws AuthError with Cognito's reason.
|
|
143
|
+
export async function cognito(target, payload, fetchImpl = globalThis.fetch) {
|
|
95
144
|
const res = await fetchImpl(COGNITO_URL, {
|
|
96
145
|
method: 'POST',
|
|
97
146
|
headers: {
|
|
98
147
|
'user-agent': USER_AGENT,
|
|
99
148
|
'content-type': 'application/x-amz-json-1.1',
|
|
100
149
|
'cache-control': 'no-store',
|
|
101
|
-
'x-amz-target':
|
|
150
|
+
'x-amz-target': `AWSCognitoIdentityProviderService.${target}`,
|
|
102
151
|
},
|
|
103
|
-
body: JSON.stringify(
|
|
152
|
+
body: JSON.stringify(payload),
|
|
104
153
|
redirect: 'error',
|
|
105
154
|
signal: AbortSignal.timeout(30_000),
|
|
106
155
|
});
|
|
107
156
|
const body = await res.json().catch(() => ({}));
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
if (!res.ok || body?.__type) {
|
|
158
|
+
const err = new AuthError(body?.message || body?.__type || `HTTP ${res.status}`);
|
|
159
|
+
err.type = body?.__type;
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
return body ?? {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function renew(email, set, {env, fetchImpl, now}) {
|
|
166
|
+
let body;
|
|
167
|
+
try {
|
|
168
|
+
body = await cognito('InitiateAuth', {AuthFlow: 'REFRESH_TOKEN_AUTH', ClientId: CLIENT_ID, AuthParameters: {REFRESH_TOKEN: set.RefreshToken}, ClientMetadata: {}}, fetchImpl);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (e instanceof AuthError) throw new AuthError(`The session of ${email} could not be renewed (${e.message}). ${LOGIN_HINT}`);
|
|
171
|
+
throw e;
|
|
111
172
|
}
|
|
112
|
-
|
|
113
|
-
|
|
173
|
+
const result = body.AuthenticationResult;
|
|
174
|
+
if (typeof result?.IdToken !== 'string') throw new AuthError(`The session of ${email} could not be renewed. ${LOGIN_HINT}`);
|
|
175
|
+
// Cognito sends no new RefreshToken on a refresh unless rotation is on for the client: then the new one is kept,
|
|
176
|
+
// else the saved one.
|
|
177
|
+
const next = {...result, email, savedAt: stamp(now), RefreshToken: result.RefreshToken ?? set.RefreshToken, ExpiresIn: result.ExpiresIn ?? set.ExpiresIn};
|
|
114
178
|
saveTokens(tokenFile(email, env), next);
|
|
115
179
|
return next;
|
|
116
180
|
}
|
|
117
181
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
182
|
+
// The saved token set of the profile as it is (not renewed), or undefined.
|
|
183
|
+
export function savedSession(email, env = process.env) {
|
|
184
|
+
const set = readJson(tokenFile(email, env));
|
|
185
|
+
return typeof set?.IdToken === 'string' ? set : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The saved token set of the profile, renewed when its IdToken expires within a minute.
|
|
189
|
+
export async function getSession({profile, env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
|
|
190
|
+
const email = pickProfile(env, profile);
|
|
123
191
|
const set = readJson(tokenFile(email, env));
|
|
124
192
|
if (typeof set?.IdToken !== 'string') throw new AuthError(`No saved session for ${email}. ${LOGIN_HINT}`);
|
|
125
|
-
if (expiresAt(set) - RENEW_EARLY_MS > now) return set
|
|
193
|
+
if (expiresAt(set) - RENEW_EARLY_MS > now) return set;
|
|
126
194
|
if (typeof set.RefreshToken !== 'string') throw new AuthError(`The session of ${email} has expired. ${LOGIN_HINT}`);
|
|
127
|
-
return
|
|
195
|
+
return renew(email, set, {env, fetchImpl, now});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A valid IdToken of the profile: WILDIX_ID_TOKEN as given (tests, CI), else the saved session (renewed).
|
|
199
|
+
export async function getToken(options = {}) {
|
|
200
|
+
const env = options.env ?? process.env;
|
|
201
|
+
if (env.WILDIX_ID_TOKEN) return env.WILDIX_ID_TOKEN;
|
|
202
|
+
return (await getSession(options)).IdToken;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── signing in ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// Starts a sign-in: Wildix sends a one-time code to the e-mail. Returns the Cognito session that the code answers.
|
|
207
|
+
export async function startLogin(email, fetchImpl = globalThis.fetch) {
|
|
208
|
+
let body;
|
|
209
|
+
try {
|
|
210
|
+
body = await cognito('InitiateAuth', {AuthFlow: 'CUSTOM_AUTH', ClientId: CLIENT_ID, AuthParameters: {USERNAME: email}, ClientMetadata: {}}, fetchImpl);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e instanceof AuthError) throw new AuthError(`Wildix did not start a sign-in for ${email}: ${e.message}`);
|
|
213
|
+
throw e;
|
|
214
|
+
}
|
|
215
|
+
if (typeof body.Session !== 'string') throw new AuthError(`Wildix did not start a sign-in for ${email}.`);
|
|
216
|
+
return body.Session;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Answers the code. Returns {saved: true} with the session stored, or {saved: false, session} when the code was not
|
|
220
|
+
// accepted (Cognito answers a wrong code with a new challenge and a new session, which takes the next try).
|
|
221
|
+
export async function finishLogin(email, session, code, {env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
|
|
222
|
+
let body;
|
|
223
|
+
try {
|
|
224
|
+
body = await cognito('RespondToAuthChallenge', {
|
|
225
|
+
ChallengeName: 'CUSTOM_CHALLENGE',
|
|
226
|
+
ChallengeResponses: {USERNAME: email, ANSWER: code},
|
|
227
|
+
ClientId: CLIENT_ID,
|
|
228
|
+
Session: session,
|
|
229
|
+
}, fetchImpl);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
if (e instanceof AuthError) throw new AuthError(`The sign-in of ${email} failed: ${e.message}. Start again: wx login ${email}`);
|
|
232
|
+
throw e;
|
|
233
|
+
}
|
|
234
|
+
const result = body.AuthenticationResult;
|
|
235
|
+
if (typeof result?.IdToken !== 'string') return {saved: false, session: body.Session};
|
|
236
|
+
saveTokens(tokenFile(email, env), {...result, email, savedAt: stamp(now)});
|
|
237
|
+
// The first profile becomes the default one; a second login does not change it.
|
|
238
|
+
const isDefault = !defaultProfile(env) || sameEmail(defaultProfile(env), email);
|
|
239
|
+
if (!defaultProfile(env)) setDefaultProfile(email, env);
|
|
240
|
+
return {saved: true, isDefault};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function savePending(email, session, {env = process.env, now = Date.now()} = {}) {
|
|
244
|
+
writePrivate(pendingFile(email, env), {email, session, startedAt: now});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The waiting sign-in of the e-mail, or undefined when there is none or it is older than Cognito keeps it.
|
|
248
|
+
export function readPending(email, {env = process.env, now = Date.now()} = {}) {
|
|
249
|
+
const p = readJson(pendingFile(email, env));
|
|
250
|
+
if (typeof p?.session !== 'string' || !(now - p.startedAt < PENDING_TTL_MS)) return undefined;
|
|
251
|
+
return p;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function dropPending(email, env = process.env) {
|
|
255
|
+
fs.rmSync(pendingFile(email, env), {force: true});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── signing out ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
259
|
+
// Revokes the refresh token of the profile (this device) and deletes its local session. With everywhere, signs the
|
|
260
|
+
// profile out of every device (GlobalSignOut, which needs a valid AccessToken). An error leaves the local session
|
|
261
|
+
// as it was: it is still valid, and the call can be repeated.
|
|
262
|
+
export async function logout(email, {everywhere = false, env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
|
|
263
|
+
const file = tokenFile(email, env);
|
|
264
|
+
const set = readJson(file);
|
|
265
|
+
if (!set) throw new AuthError(`No saved session for ${email}.`);
|
|
266
|
+
if (everywhere) {
|
|
267
|
+
const fresh = await getSession({profile: email, env, fetchImpl, now});
|
|
268
|
+
await cognito('GlobalSignOut', {AccessToken: fresh.AccessToken}, fetchImpl);
|
|
269
|
+
} else {
|
|
270
|
+
await cognito('RevokeToken', {Token: set.RefreshToken, ClientId: CLIENT_ID}, fetchImpl);
|
|
271
|
+
}
|
|
272
|
+
fs.rmSync(file, {force: true});
|
|
273
|
+
if (sameEmail(readSettings(env).defaultProfile ?? '', email)) setDefaultProfile(undefined, env);
|
|
128
274
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Text output of the chat commands: one line per channel, message, member or person, local times, names of
|
|
2
|
+
// people instead of their ids (looked up by the command, see withNames in lib.mjs).
|
|
3
|
+
import {localTime, oneLine, paint, table} from '../format.mjs';
|
|
4
|
+
import {displayName, nameIn} from './lib.mjs';
|
|
5
|
+
|
|
6
|
+
const reactions = (counts) => {
|
|
7
|
+
const parts = Object.entries(counts ?? {}).filter(([, n]) => n > 0).map(([t, n]) => `${t} ${n}`);
|
|
8
|
+
return parts.length ? `[${parts.join(', ')}]` : '';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// The text of a message on one line, with what goes with it: files, reply, edits, reactions.
|
|
12
|
+
export function messageText(m, data, {max = 2000} = {}) {
|
|
13
|
+
if (!m) return '';
|
|
14
|
+
if (m.messageType === 'deleted' || m.deletedAt) return '(deleted)';
|
|
15
|
+
const parts = [];
|
|
16
|
+
if (m.system) parts.push('[system]');
|
|
17
|
+
if (m.quote) parts.push(`(reply to ${nameIn(data, m.quote.userId)})`);
|
|
18
|
+
if (m.forward) parts.push('(forwarded)');
|
|
19
|
+
if (m.text) parts.push(oneLine(m.text, max));
|
|
20
|
+
for (const f of m.attachments ?? []) parts.push(`[file: ${oneLine(f.name, 80)}]`);
|
|
21
|
+
if (m.giphy) parts.push('[giphy]');
|
|
22
|
+
if (m.pollId) parts.push('[poll]');
|
|
23
|
+
if (m.editedAt) parts.push('(edited)');
|
|
24
|
+
if (m.pinned) parts.push('(pinned)');
|
|
25
|
+
const r = reactions(m.reactionCounts);
|
|
26
|
+
if (r) parts.push(r);
|
|
27
|
+
return parts.join(' ');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Messages as a transcript, oldest first: time, author, text, and the message id.
|
|
31
|
+
export function messageLines(messages, data, ctx) {
|
|
32
|
+
const c = paint(ctx);
|
|
33
|
+
const sorted = [...(messages ?? [])].sort((a, b) => (a.timelineSeq ?? 0) - (b.timelineSeq ?? 0));
|
|
34
|
+
return table(sorted.map((m) => [c.dim(localTime(m.createdAt)), c.bold(nameIn(data, m.userId) || '?'), `${messageText(m, data)} ${c.dim(m.messageId)}`]));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 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) => ch?.subject || (ch?.peerId ? nameIn(data, ch.peerId) : '') || (ch?.channelType === 'direct' ? '(direct)' : '(no subject)');
|
|
39
|
+
|
|
40
|
+
// The people a channel line names: the other person of a 1:1 channel, the author of the last message.
|
|
41
|
+
export const channelUserIds = (ch) => [ch?.peerId, ch?.lastMessage?.userId];
|
|
42
|
+
|
|
43
|
+
function flags(ch, c) {
|
|
44
|
+
const i = ch.inbox;
|
|
45
|
+
if (!i) return '';
|
|
46
|
+
const f = [];
|
|
47
|
+
if (i.unreadCount) f.push(c.yellow(`${i.unreadCount} new`));
|
|
48
|
+
if (i.mentionUnreadCount) f.push(c.yellow(`@${i.mentionUnreadCount}`));
|
|
49
|
+
if (i.pinned) f.push('pinned');
|
|
50
|
+
if (i.muted) f.push('muted');
|
|
51
|
+
if (i.hidden) f.push('archived');
|
|
52
|
+
return f.join(' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const preview = (ch, data) => {
|
|
56
|
+
const m = ch.lastMessage;
|
|
57
|
+
if (!m) return '';
|
|
58
|
+
const text = m.messageType === 'deleted' ? '(deleted)' : oneLine(m.text ?? (m.pollId ? '[poll]' : ''), 80);
|
|
59
|
+
return `${nameIn(data, m.userId)}: ${text}`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Channels: last activity, id, inbox flags, title, the last message.
|
|
63
|
+
export function channelLines(channels, data, ctx) {
|
|
64
|
+
const c = paint(ctx);
|
|
65
|
+
return table((channels ?? []).map((ch) => [c.dim(localTime(ch.lastActivityAt)), ch.channelId, flags(ch, c), c.bold(oneLine(channelTitle(ch, data), 50)), c.dim(preview(ch, data))]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function channelDetails(ch, data, ctx) {
|
|
69
|
+
const c = paint(ctx);
|
|
70
|
+
if (!ch) return [];
|
|
71
|
+
const rows = [
|
|
72
|
+
['Channel', ch.channelId],
|
|
73
|
+
['Title', c.bold(channelTitle(ch, data))],
|
|
74
|
+
['Type', [ch.channelType, ch.access, ch.broadcast && 'broadcast', ch.external && 'external'].filter(Boolean).join(' · ')],
|
|
75
|
+
];
|
|
76
|
+
if (ch.description) rows.push(['Description', oneLine(ch.description, 300)]);
|
|
77
|
+
if (ch.peerId) rows.push(['With', `${nameIn(data, ch.peerId)} (${ch.peerId})`]);
|
|
78
|
+
rows.push(['Members', String(ch.membersCount ?? '')]);
|
|
79
|
+
rows.push(['Created', `${localTime(ch.createdAt)}${ch.createdBy ? ` by ${nameIn(data, ch.createdBy)}` : ''}`]);
|
|
80
|
+
rows.push(['Last activity', localTime(ch.lastActivityAt)]);
|
|
81
|
+
if (ch.pinnedMessageIds?.length) rows.push(['Pinned', ch.pinnedMessageIds.join(', ')]);
|
|
82
|
+
if (ch.autoRecord !== undefined || ch.autoTranscribe !== undefined) {
|
|
83
|
+
rows.push(['Conferences', [ch.autoRecord && 'auto record', ch.autoTranscribe && 'auto transcribe'].filter(Boolean).join(', ') || 'manual']);
|
|
84
|
+
}
|
|
85
|
+
if (ch.inbox) rows.push(['Inbox', flags(ch, c) || 'read']);
|
|
86
|
+
return table(rows);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A person on one line: name, id, e-mail, extension, bot.
|
|
90
|
+
export function userLine(u, c) {
|
|
91
|
+
return [c.bold(displayName(u) ?? ''), u.id, u.email ?? '', u.pbxExtension ? `ext ${u.pbxExtension}` : '', u.bot ? 'bot' : '', u.deactivatedAt ? c.dim('deactivated') : ''];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const nextPage = (cursor, c) => (cursor ? [c.dim(`More: --cursor ${cursor}`)] : []);
|
|
95
|
+
|
|
96
|
+
// The text of a command that changes something: what was done (the JSON output keeps the answer of the service).
|
|
97
|
+
export const done = (what) => (r, ctx) => (typeof what === 'function' ? what(r, ctx) : what);
|
package/src/chat/lib.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Helpers of the chat commands (wx chat, channel, inbox, file, user, section): the checks of the agent skill's
|
|
2
|
+
// lib.sh (whole numbers in a range, ids, reaction types, secret paths), the user id of the logged-in user, names
|
|
3
|
+
// of users for the text output, and the presigned upload and download of files (no token goes to those URLs).
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {jwtClaims} from '../auth.mjs';
|
|
8
|
+
import {UsageError} from '../errors.mjs';
|
|
9
|
+
import {readForUpload, refuseUnsafeWrite} from '../files.mjs';
|
|
10
|
+
import {apiPath, request} from '../http.mjs';
|
|
11
|
+
import {streamClient} from '../stream.mjs';
|
|
12
|
+
|
|
13
|
+
// The operations the service answers with HTTP 400 "Operation <Name> is not implemented." (checked 2026-11-05).
|
|
14
|
+
export const NOT_IMPLEMENTED_CHECKED = '2026-11-05';
|
|
15
|
+
export const NOT_IMPLEMENTED = ' (not implemented by the service yet)';
|
|
16
|
+
export const notImplemented = (operation) =>
|
|
17
|
+
`The service does not implement ${operation} yet (it answers HTTP 400 "Operation ${operation} is not implemented.", checked ${NOT_IMPLEMENTED_CHECKED}). Do not retry it.`;
|
|
18
|
+
|
|
19
|
+
// A whole number from min to max (the service refuses anything else with a 400 that tells little); undefined stays.
|
|
20
|
+
export function checkInt(name, value, min, max) {
|
|
21
|
+
if (value === undefined) return undefined;
|
|
22
|
+
const s = String(value);
|
|
23
|
+
if (!/^\d{1,12}$/.test(s) || Number(s) < min || Number(s) > max) throw new UsageError(`--${name} must be a whole number from ${min} to ${max}: ${value}`);
|
|
24
|
+
return Number(s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// An id that goes into a request path or a body: id characters only, never "/", "?", "#", ".." or nothing.
|
|
28
|
+
export function checkId(what, value) {
|
|
29
|
+
const v = String(value ?? '');
|
|
30
|
+
if (!/^[A-Za-z0-9_+][A-Za-z0-9_.:+-]*$/.test(v) || v.includes('..')) throw new UsageError(`${what} is not a valid id: ${value}`);
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function checkReaction(type) {
|
|
35
|
+
if (!/^[a-z0-9_+-]{1,64}$/.test(String(type))) throw new UsageError(`A reaction type must match [a-z0-9_+-] (up to 64): ${type}`);
|
|
36
|
+
return type;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The id of a new group channel: 8-4-4-4-8 characters. Without one the service makes a 36-character id that none
|
|
40
|
+
// of its own calls accepts afterwards.
|
|
41
|
+
export const CHANNEL_ID = /^[0-9a-z_]{8}-[0-9a-z_]{4}-[0-9a-z_]{4}-[0-9a-z_]{4}-[0-9a-z_]{8}$/;
|
|
42
|
+
export const newChannelId = () => randomUUID().slice(0, -4);
|
|
43
|
+
|
|
44
|
+
// The text argument; "-" reads stdin.
|
|
45
|
+
export async function readText(arg, stdin) {
|
|
46
|
+
if (arg !== '-') return arg;
|
|
47
|
+
const chunks = [];
|
|
48
|
+
for await (const c of stdin) chunks.push(Buffer.from(c));
|
|
49
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const api = (ctx) => ctx.client(streamClient);
|
|
53
|
+
|
|
54
|
+
// The stream user id of the logged-in user, found by the e-mail of the token (once per context).
|
|
55
|
+
const ME = Symbol('me');
|
|
56
|
+
export async function meId(ctx, client = api(ctx)) {
|
|
57
|
+
if (ctx[ME]) return ctx[ME];
|
|
58
|
+
const email = jwtClaims(ctx.token).email;
|
|
59
|
+
if (!email) throw new Error('The login token carries no e-mail');
|
|
60
|
+
const r = await client.call('POST', '/users/query', {query: {emails: [email]}});
|
|
61
|
+
const id = r?.users?.[0]?.id;
|
|
62
|
+
if (!id) throw new Error('Could not find your chat user');
|
|
63
|
+
ctx[ME] = id;
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const displayName = (u) => u?.displayName ?? u?.name;
|
|
68
|
+
|
|
69
|
+
// The users of these ids, one users query per 100 ids: Map id -> user. A lookup that fails gives no users (a name
|
|
70
|
+
// is a nicety of the text output: the id is printed instead).
|
|
71
|
+
export async function usersById(client, ids) {
|
|
72
|
+
const unique = [...new Set(ids.filter((id) => typeof id === 'string' && id))];
|
|
73
|
+
const out = new Map();
|
|
74
|
+
for (let i = 0; i < unique.length; i += 100) {
|
|
75
|
+
try {
|
|
76
|
+
const r = await client.call('POST', '/users/query', {query: {ids: unique.slice(i, i + 100)}});
|
|
77
|
+
for (const u of r?.users ?? []) out.set(u.id, u);
|
|
78
|
+
} catch {
|
|
79
|
+
// names stay ids
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Names for the text output ride on the result under a symbol: JSON output (JSON.stringify) never sees them.
|
|
86
|
+
export const NAMES = Symbol('names');
|
|
87
|
+
export async function withNames(ctx, data, ids, client = api(ctx)) {
|
|
88
|
+
if (ctx.output !== 'text' || !data || typeof data !== 'object') return data;
|
|
89
|
+
const users = await usersById(client, ids);
|
|
90
|
+
Object.defineProperty(data, NAMES, {value: users, enumerable: false});
|
|
91
|
+
return data;
|
|
92
|
+
}
|
|
93
|
+
export const nameIn = (data, id) => (id ? displayName(data?.[NAMES]?.get(id)) ?? id : '');
|
|
94
|
+
|
|
95
|
+
// The ids of people a message names: its author, the author of its quote.
|
|
96
|
+
export const messageUserIds = (m) => [m?.userId, m?.quote?.userId, m?.pinnedBy];
|
|
97
|
+
|
|
98
|
+
// ── files ───────────────────────────────────────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
// The checks of files to attach and to save: src/files.mjs.
|
|
101
|
+
export {refuseSecretPath} from '../files.mjs';
|
|
102
|
+
|
|
103
|
+
const MIME = {
|
|
104
|
+
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', html: 'text/html', htm: 'text/html', css: 'text/css', js: 'text/javascript',
|
|
105
|
+
json: 'application/json', xml: 'application/xml', pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
|
|
106
|
+
tar: 'application/x-tar', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
107
|
+
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
108
|
+
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
109
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', heic: 'image/heic',
|
|
110
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
111
|
+
};
|
|
112
|
+
export const mimeOf = (file) => MIME[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
|
|
113
|
+
|
|
114
|
+
// A presigned URL of the storage: https (http only with WILDIX_ALLOW_CUSTOM_URL=1, tests). It gets no token.
|
|
115
|
+
function presigned(url, env) {
|
|
116
|
+
let u;
|
|
117
|
+
try {
|
|
118
|
+
u = new URL(url);
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error('The service gave no valid storage link');
|
|
121
|
+
}
|
|
122
|
+
if (u.protocol !== 'https:' && !(env.WILDIX_ALLOW_CUSTOM_URL === '1' && u.protocol === 'http:')) throw new Error(`The storage link is not https: ${u.protocol}`);
|
|
123
|
+
return u.href;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ATTACHMENT_FIELDS = ['fsId', 'name', 'size', 'mime', 'width', 'height', 'thumbnail'];
|
|
127
|
+
export const attachmentOf = (f) => Object.fromEntries(ATTACHMENT_FIELDS.filter((k) => f?.[k] !== undefined && f?.[k] !== null).map((k) => [k, f[k]]));
|
|
128
|
+
|
|
129
|
+
// Uploads a file to a channel and returns the attachment object to put into a message (fsId, name, size, mime…).
|
|
130
|
+
export async function uploadFile(ctx, channel, file, client = api(ctx)) {
|
|
131
|
+
const data = readForUpload(path.resolve(ctx.cwd ?? process.cwd(), file), ctx.env);
|
|
132
|
+
const name = path.basename(file);
|
|
133
|
+
const r = await client.call('POST', apiPath`/channels/${channel}/files`, {name});
|
|
134
|
+
if (!r?.fsId || !r?.presignedUploadUrl) throw new Error('Unexpected answer of the upload: no fsId or link');
|
|
135
|
+
const fsId = checkId('The file id of the answer', r.fsId);
|
|
136
|
+
await request(presigned(r.presignedUploadUrl, ctx.env), {method: 'PUT', raw: {data, contentType: mimeOf(file)}, responseType: 'text', fetchImpl: ctx.fetch, timeoutMs: 600_000, service: 'the storage'});
|
|
137
|
+
const info = await client.call('GET', apiPath`/channels/${channel}/files/${fsId}`);
|
|
138
|
+
return attachmentOf(info?.file ?? {fsId, name, size: data.length});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Saves a file of a message to `to`, which must not exist yet (nothing is ever overwritten).
|
|
142
|
+
export async function downloadFile(ctx, channel, message, fsId, to, client = api(ctx)) {
|
|
143
|
+
const out = path.resolve(ctx.cwd ?? process.cwd(), to);
|
|
144
|
+
refuseUnsafeWrite(out, ctx.env);
|
|
145
|
+
if (fs.existsSync(out)) throw new UsageError(`${to} exists already: choose another path (nothing was overwritten or deleted)`);
|
|
146
|
+
const dir = path.dirname(out);
|
|
147
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) throw new UsageError(`The folder of ${to} does not exist`);
|
|
148
|
+
const r = await client.call('GET', apiPath`/channels/${channel}/messages/${message}/files/${fsId}/download-url` + '?download=1');
|
|
149
|
+
if (!r?.presignedDownloadUrl) throw new Error('No download link in the answer');
|
|
150
|
+
const data = await request(presigned(r.presignedDownloadUrl, ctx.env), {responseType: 'buffer', fetchImpl: ctx.fetch, timeoutMs: 600_000, service: 'the storage'});
|
|
151
|
+
const tmp = path.join(dir, `.download.${process.pid}.${randomUUID().slice(0, 8)}`);
|
|
152
|
+
fs.writeFileSync(tmp, data, {flag: 'wx', mode: 0o600});
|
|
153
|
+
try {
|
|
154
|
+
fs.linkSync(tmp, out); // fails when the path appeared meanwhile: never overwrites
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e.code === 'EEXIST') throw new UsageError(`${to} appeared meanwhile: nothing was overwritten`);
|
|
157
|
+
throw e;
|
|
158
|
+
} finally {
|
|
159
|
+
fs.rmSync(tmp, {force: true});
|
|
160
|
+
}
|
|
161
|
+
return {ok: true, file: out, bytes: data.length};
|
|
162
|
+
}
|