wldx-cli 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.mjs CHANGED
@@ -1,9 +1,10 @@
1
- // The Wildix login of the user, shared with the wildix-auth agent skill: one file per account in
2
- // $WILDIX_CONFIG_DIR/tokens (default ~/.wildix/tokens), named <e-mail with @ as _at_>.prod.json, holding the Cognito
3
- // set {IdToken, AccessToken, RefreshToken, ExpiresIn, email, savedAt}. An expired IdToken is renewed with the
4
- // RefreshToken and written back in the same format, so the skill and the CLI keep using one session.
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 token is never printed and never put on a command line or into the environment of a child process.
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
- export const LOGIN_HINT = 'Log in with the wildix-auth agent skill (npx skills add gorets/wildix-skills -s wildix-auth); `wx login` is not there yet.';
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
- // Which account: --account, else WILDIX_EMAIL, else the only saved session.
68
- export function pickAccount(env = process.env, account) {
69
- const email = account || env.WILDIX_EMAIL;
70
- if (email) return email;
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
- if (list.length > 1) {
74
- throw new AuthError(`Several Wildix accounts are logged in. Pick one with --account or WILDIX_EMAIL:\n${list.map((s) => ` ${s.email}`).join('\n')}`);
75
- }
76
- return list[0].email;
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
- const dir = path.dirname(file);
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
- async function renew(email, set, {env, fetchImpl, now}) {
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': 'AWSCognitoIdentityProviderService.InitiateAuth',
150
+ 'x-amz-target': `AWSCognitoIdentityProviderService.${target}`,
102
151
  },
103
- body: JSON.stringify({AuthFlow: 'REFRESH_TOKEN_AUTH', ClientId: CLIENT_ID, AuthParameters: {REFRESH_TOKEN: set.RefreshToken}, ClientMetadata: {}}),
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
- const result = body?.AuthenticationResult;
109
- if (!res.ok || body?.__type || typeof result?.IdToken !== 'string') {
110
- throw new AuthError(`The session of ${email} could not be renewed (${body?.message || body?.__type || `HTTP ${res.status}`}). ${LOGIN_HINT}`);
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
- // Cognito does not send a new RefreshToken on a refresh: the saved one is kept.
113
- const next = {...result, email, savedAt: stamp(now), RefreshToken: set.RefreshToken, ExpiresIn: result.ExpiresIn ?? set.ExpiresIn};
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
- // A valid IdToken of the account: WILDIX_ID_TOKEN as given (tests, CI), else the saved session, renewed when it
119
- // expires within a minute.
120
- export async function getToken({account, env = process.env, fetchImpl = globalThis.fetch, now = Date.now()} = {}) {
121
- if (env.WILDIX_ID_TOKEN) return env.WILDIX_ID_TOKEN;
122
- const email = pickAccount(env, account);
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.IdToken;
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 (await renew(email, set, {env, fetchImpl, now})).IdToken;
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);
@@ -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
+ }