@ih8e/express-cli 0.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 +124 -0
- package/dist/index.js +4056 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4056 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/root.ts
|
|
4
|
+
import { Command as Command15 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/auth.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/types/config.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
var configSchema = z.object({
|
|
12
|
+
host: z.string().min(1, "host is required \u2014 set EXPRESS_HOST or configure via `express config set host <host>`"),
|
|
13
|
+
protocol: z.enum(["https", "http"]).default("https"),
|
|
14
|
+
token: z.string().optional(),
|
|
15
|
+
locale: z.string().default("ru"),
|
|
16
|
+
platform: z.string().default("web"),
|
|
17
|
+
platform_package_id: z.string().default("ru.alfabank"),
|
|
18
|
+
app_version: z.string().default("3.66.47"),
|
|
19
|
+
output: z.enum(["table", "json"]).default("table")
|
|
20
|
+
});
|
|
21
|
+
var envSchema = z.object({
|
|
22
|
+
EXPRESS_HOST: z.string().optional(),
|
|
23
|
+
EXPRESS_TOKEN: z.string().optional(),
|
|
24
|
+
EXPRESS_LOCALE: z.string().optional(),
|
|
25
|
+
EXPRESS_OUTPUT: z.enum(["table", "json"]).optional()
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// src/config/store.ts
|
|
29
|
+
import Conf from "conf";
|
|
30
|
+
var store = new Conf({
|
|
31
|
+
projectName: "express-cli",
|
|
32
|
+
defaults: {
|
|
33
|
+
config: {},
|
|
34
|
+
authToken: null,
|
|
35
|
+
refreshToken: null,
|
|
36
|
+
rtsAuthToken: null,
|
|
37
|
+
apigwKeys: null,
|
|
38
|
+
tokenExpiresAt: null,
|
|
39
|
+
etsAuthToken: null
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
function getStoredConfig() {
|
|
43
|
+
return store.get("config") ?? {};
|
|
44
|
+
}
|
|
45
|
+
function setStoredConfig(partial) {
|
|
46
|
+
const current = getStoredConfig();
|
|
47
|
+
store.set("config", { ...current, ...partial });
|
|
48
|
+
}
|
|
49
|
+
function getAuthToken() {
|
|
50
|
+
return store.get("authToken") ?? null;
|
|
51
|
+
}
|
|
52
|
+
function setAuthToken(token) {
|
|
53
|
+
if (token === null) {
|
|
54
|
+
store.delete("authToken");
|
|
55
|
+
} else {
|
|
56
|
+
store.set("authToken", token);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function getRtsAuthToken() {
|
|
60
|
+
return store.get("rtsAuthToken") ?? null;
|
|
61
|
+
}
|
|
62
|
+
function setRtsAuthToken(token) {
|
|
63
|
+
if (token === null) {
|
|
64
|
+
store.delete("rtsAuthToken");
|
|
65
|
+
} else {
|
|
66
|
+
store.set("rtsAuthToken", token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function getRefreshToken() {
|
|
70
|
+
return store.get("refreshToken") ?? null;
|
|
71
|
+
}
|
|
72
|
+
function setRefreshToken(token) {
|
|
73
|
+
if (token === null) {
|
|
74
|
+
store.delete("refreshToken");
|
|
75
|
+
} else {
|
|
76
|
+
store.set("refreshToken", token);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function getTokenExpiresAt() {
|
|
80
|
+
return store.get("tokenExpiresAt") ?? null;
|
|
81
|
+
}
|
|
82
|
+
function setTokenExpiresAt(expiresAt) {
|
|
83
|
+
if (expiresAt === null) {
|
|
84
|
+
store.delete("tokenExpiresAt");
|
|
85
|
+
} else {
|
|
86
|
+
store.set("tokenExpiresAt", expiresAt);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function calcTokenExpiresAt(expiresIn) {
|
|
90
|
+
return Date.now() + Math.floor(expiresIn / 2) * 1e3;
|
|
91
|
+
}
|
|
92
|
+
function isTokenExpiringSoon() {
|
|
93
|
+
const expiresAt = getTokenExpiresAt();
|
|
94
|
+
if (!expiresAt) return true;
|
|
95
|
+
return Date.now() >= expiresAt;
|
|
96
|
+
}
|
|
97
|
+
function getEtsAuthToken() {
|
|
98
|
+
return store.get("etsAuthToken") ?? null;
|
|
99
|
+
}
|
|
100
|
+
function setEtsAuthToken(token) {
|
|
101
|
+
if (token === null) {
|
|
102
|
+
store.delete("etsAuthToken");
|
|
103
|
+
} else {
|
|
104
|
+
store.set("etsAuthToken", token);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function getApigwKeysRaw() {
|
|
108
|
+
return store.get("apigwKeys") ?? null;
|
|
109
|
+
}
|
|
110
|
+
function setApigwKeysRaw(data) {
|
|
111
|
+
if (data === null) {
|
|
112
|
+
store.delete("apigwKeys");
|
|
113
|
+
} else {
|
|
114
|
+
store.set("apigwKeys", data);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function clearAll() {
|
|
118
|
+
store.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/config/loader.ts
|
|
122
|
+
function loadConfig(cliOverrides = {}) {
|
|
123
|
+
const stored = getStoredConfig();
|
|
124
|
+
const env = {};
|
|
125
|
+
if (process.env.EXPRESS_HOST) env.host = process.env.EXPRESS_HOST;
|
|
126
|
+
if (process.env.EXPRESS_TOKEN) env.token = process.env.EXPRESS_TOKEN;
|
|
127
|
+
if (process.env.EXPRESS_LOCALE) env.locale = process.env.EXPRESS_LOCALE;
|
|
128
|
+
if (process.env.EXPRESS_OUTPUT) env.output = process.env.EXPRESS_OUTPUT;
|
|
129
|
+
const merged = {
|
|
130
|
+
...stored,
|
|
131
|
+
...env,
|
|
132
|
+
...cliOverrides
|
|
133
|
+
};
|
|
134
|
+
if (!merged.token) {
|
|
135
|
+
const storedToken = getAuthToken();
|
|
136
|
+
if (storedToken) merged.token = storedToken;
|
|
137
|
+
}
|
|
138
|
+
return configSchema.parse(merged);
|
|
139
|
+
}
|
|
140
|
+
function getBaseUrl(config) {
|
|
141
|
+
return `${config.protocol}://${config.host}`;
|
|
142
|
+
}
|
|
143
|
+
function getDomain(ctsHost) {
|
|
144
|
+
const parts = ctsHost.split(".");
|
|
145
|
+
return parts.length > 2 ? parts.slice(1).join(".") : ctsHost;
|
|
146
|
+
}
|
|
147
|
+
function getEtsBaseUrl(config) {
|
|
148
|
+
return `https://ets.${getDomain(config.host)}`;
|
|
149
|
+
}
|
|
150
|
+
function getWebOrigin(config) {
|
|
151
|
+
return `https://${getDomain(config.host)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/auth/import.ts
|
|
155
|
+
async function importToken(token, cliOverrides = {}) {
|
|
156
|
+
const config = loadConfig(cliOverrides);
|
|
157
|
+
const baseUrl = getBaseUrl(config);
|
|
158
|
+
const url = `${baseUrl}/api/v1/phonebook/profiles/self`;
|
|
159
|
+
const res = await fetch(url, {
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: `Bearer ${token}`,
|
|
162
|
+
Accept: "application/json"
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
|
|
167
|
+
}
|
|
168
|
+
const body = await res.json();
|
|
169
|
+
setAuthToken(token);
|
|
170
|
+
const profile = body.profile ?? body.result?.profile;
|
|
171
|
+
if (profile?.name) {
|
|
172
|
+
console.log(`Authenticated as: ${profile.name} (${profile.user_huid ?? "unknown huid"})`);
|
|
173
|
+
} else {
|
|
174
|
+
console.log("Token imported and validated successfully.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function logout() {
|
|
178
|
+
setAuthToken(null);
|
|
179
|
+
console.log("Logged out. Token removed.");
|
|
180
|
+
}
|
|
181
|
+
function status() {
|
|
182
|
+
const token = getAuthToken();
|
|
183
|
+
if (!token) {
|
|
184
|
+
console.log("Not authenticated. Use `express auth import <token>` to login.");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
console.log(`Authenticated. Token: ${token.slice(0, 20)}...`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/auth/keys.ts
|
|
191
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
192
|
+
import { randomBytes } from "crypto";
|
|
193
|
+
import nacl from "tweetnacl";
|
|
194
|
+
function generateSigningKeyPair() {
|
|
195
|
+
const privateKey = ed25519.utils.randomSecretKey();
|
|
196
|
+
const publicKey = ed25519.getPublicKey(privateKey);
|
|
197
|
+
const keyId = crypto.randomUUID();
|
|
198
|
+
return { keyId, privateKey, publicKey };
|
|
199
|
+
}
|
|
200
|
+
function generateEncryptionKeyPair() {
|
|
201
|
+
const keyPair = nacl.box.keyPair();
|
|
202
|
+
const keyId = crypto.randomUUID();
|
|
203
|
+
return {
|
|
204
|
+
keyId,
|
|
205
|
+
privateKey: keyPair.secretKey,
|
|
206
|
+
publicKey: keyPair.publicKey
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function saveApigwKeys(keys) {
|
|
210
|
+
const data = {
|
|
211
|
+
signingKey: {
|
|
212
|
+
keyId: keys.signingKey.keyId,
|
|
213
|
+
privateKey: Buffer.from(keys.signingKey.privateKey).toString("base64"),
|
|
214
|
+
publicKey: Buffer.from(keys.signingKey.publicKey).toString("base64")
|
|
215
|
+
},
|
|
216
|
+
encryptionKey: {
|
|
217
|
+
keyId: keys.encryptionKey.keyId,
|
|
218
|
+
privateKey: Buffer.from(keys.encryptionKey.privateKey).toString("base64"),
|
|
219
|
+
publicKey: Buffer.from(keys.encryptionKey.publicKey).toString("base64")
|
|
220
|
+
},
|
|
221
|
+
serverPublicKey: Buffer.from(keys.serverPublicKey).toString("base64"),
|
|
222
|
+
serverPublicKeyId: keys.serverPublicKeyId
|
|
223
|
+
};
|
|
224
|
+
if (keys.ctsKey) {
|
|
225
|
+
data.ctsKey = {
|
|
226
|
+
keyId: keys.ctsKey.keyId,
|
|
227
|
+
privateKey: Buffer.from(keys.ctsKey.privateKey).toString("base64"),
|
|
228
|
+
publicKey: Buffer.from(keys.ctsKey.publicKey).toString("base64")
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
setApigwKeysRaw(JSON.stringify(data));
|
|
232
|
+
}
|
|
233
|
+
function loadApigwKeys() {
|
|
234
|
+
const raw = getApigwKeysRaw();
|
|
235
|
+
if (!raw) return null;
|
|
236
|
+
try {
|
|
237
|
+
const data = JSON.parse(raw);
|
|
238
|
+
if (!data.encryptionKey || !data.serverPublicKey) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
signingKey: {
|
|
243
|
+
keyId: data.signingKey.keyId,
|
|
244
|
+
privateKey: new Uint8Array(Buffer.from(data.signingKey.privateKey, "base64")),
|
|
245
|
+
publicKey: new Uint8Array(Buffer.from(data.signingKey.publicKey, "base64"))
|
|
246
|
+
},
|
|
247
|
+
encryptionKey: {
|
|
248
|
+
keyId: data.encryptionKey.keyId,
|
|
249
|
+
privateKey: new Uint8Array(Buffer.from(data.encryptionKey.privateKey, "base64")),
|
|
250
|
+
publicKey: new Uint8Array(Buffer.from(data.encryptionKey.publicKey, "base64"))
|
|
251
|
+
},
|
|
252
|
+
ctsKey: data.ctsKey ? {
|
|
253
|
+
keyId: data.ctsKey.keyId,
|
|
254
|
+
privateKey: new Uint8Array(Buffer.from(data.ctsKey.privateKey, "base64")),
|
|
255
|
+
publicKey: new Uint8Array(Buffer.from(data.ctsKey.publicKey, "base64"))
|
|
256
|
+
} : void 0,
|
|
257
|
+
serverPublicKey: new Uint8Array(Buffer.from(data.serverPublicKey, "base64")),
|
|
258
|
+
serverPublicKeyId: data.serverPublicKeyId ?? data.rtsKeyId ?? data.encryptionKeyId ?? ""
|
|
259
|
+
};
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function signEd25519(privateKey, message) {
|
|
265
|
+
return ed25519.sign(message, privateKey);
|
|
266
|
+
}
|
|
267
|
+
function encryptToken(token, serverPublicKey, rtsPrivateKey) {
|
|
268
|
+
const message = new TextEncoder().encode(token);
|
|
269
|
+
const nonce = randomBytes(nacl.box.nonceLength);
|
|
270
|
+
const encrypted = nacl.box(message, nonce, serverPublicKey, rtsPrivateKey);
|
|
271
|
+
if (!encrypted) {
|
|
272
|
+
throw new Error("crypto_box encryption failed");
|
|
273
|
+
}
|
|
274
|
+
const combined = new Uint8Array(nonce.length + encrypted.length);
|
|
275
|
+
combined.set(nonce, 0);
|
|
276
|
+
combined.set(encrypted, nonce.length);
|
|
277
|
+
return Buffer.from(combined).toString("base64");
|
|
278
|
+
}
|
|
279
|
+
function generateNonce() {
|
|
280
|
+
return Buffer.from(randomBytes(32)).toString("base64");
|
|
281
|
+
}
|
|
282
|
+
function publicKeyToBase64(publicKey) {
|
|
283
|
+
return Buffer.from(publicKey).toString("base64");
|
|
284
|
+
}
|
|
285
|
+
function clearApigwKeys() {
|
|
286
|
+
setApigwKeysRaw(null);
|
|
287
|
+
}
|
|
288
|
+
function importCtsKey(privateKeyB64, keyId) {
|
|
289
|
+
const existing = loadApigwKeys();
|
|
290
|
+
if (!existing) {
|
|
291
|
+
throw new Error("No apigw keys yet. Run 'express auth login' or 'express auth qr' first.");
|
|
292
|
+
}
|
|
293
|
+
const privateKey = new Uint8Array(Buffer.from(privateKeyB64, "base64"));
|
|
294
|
+
if (privateKey.length !== nacl.box.secretKeyLength) {
|
|
295
|
+
throw new Error(`Invalid private key length: ${privateKey.length} bytes (expected ${nacl.box.secretKeyLength})`);
|
|
296
|
+
}
|
|
297
|
+
const publicKey = nacl.box.keyPair.fromSecretKey(privateKey).publicKey;
|
|
298
|
+
const ctsKey = { keyId, privateKey, publicKey };
|
|
299
|
+
saveApigwKeys({ ...existing, ctsKey });
|
|
300
|
+
return ctsKey;
|
|
301
|
+
}
|
|
302
|
+
function decryptRegistrationData(registrationDataB64, encryptionKey) {
|
|
303
|
+
const raw = Uint8Array.from(Buffer.from(registrationDataB64, "base64"));
|
|
304
|
+
const nonce = raw.slice(0, nacl.secretbox.nonceLength);
|
|
305
|
+
const ciphertext = raw.slice(nacl.secretbox.nonceLength);
|
|
306
|
+
const plaintext = nacl.secretbox.open(ciphertext, nonce, encryptionKey);
|
|
307
|
+
if (!plaintext) {
|
|
308
|
+
throw new Error("Failed to decrypt registration_data");
|
|
309
|
+
}
|
|
310
|
+
const json = new TextDecoder().decode(plaintext);
|
|
311
|
+
return JSON.parse(json);
|
|
312
|
+
}
|
|
313
|
+
function decryptRtsToken(encryptedRtsTokenB64, serverPublicKey, rtsPrivateKey) {
|
|
314
|
+
const raw = Uint8Array.from(Buffer.from(encryptedRtsTokenB64, "base64"));
|
|
315
|
+
const nonce = raw.slice(0, nacl.box.nonceLength);
|
|
316
|
+
const ciphertext = raw.slice(nacl.box.nonceLength);
|
|
317
|
+
const plaintext = nacl.box.open(ciphertext, nonce, serverPublicKey, rtsPrivateKey);
|
|
318
|
+
if (!plaintext) {
|
|
319
|
+
throw new Error("Failed to decrypt encrypted_rts_token");
|
|
320
|
+
}
|
|
321
|
+
return new TextDecoder().decode(plaintext);
|
|
322
|
+
}
|
|
323
|
+
function extractRtsKeyIdFromToken(token) {
|
|
324
|
+
const parts = token.split(".");
|
|
325
|
+
if (parts.length < 2) return "";
|
|
326
|
+
const bytes = Buffer.from(parts[1], "base64");
|
|
327
|
+
const text = bytes.toString("latin1");
|
|
328
|
+
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
|
|
329
|
+
const uuids = [];
|
|
330
|
+
let match;
|
|
331
|
+
while ((match = uuidRegex.exec(text)) !== null) {
|
|
332
|
+
uuids.push(match[0]);
|
|
333
|
+
}
|
|
334
|
+
return uuids.length >= 4 ? uuids[3] : "";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/auth/apigw-signer.ts
|
|
338
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
339
|
+
function buildSigningInput(method, path, created, nonce, rtsAccessToken, digest) {
|
|
340
|
+
return buildSigningInputInternal(method, path, created, nonce, rtsAccessToken, digest);
|
|
341
|
+
}
|
|
342
|
+
function buildQrSigningInput(method, path, created, nonce, digest) {
|
|
343
|
+
return buildSigningInputInternal(method, path, created, nonce, void 0, digest);
|
|
344
|
+
}
|
|
345
|
+
function buildSigningInputInternal(method, path, created, nonce, rtsAccessToken, digest) {
|
|
346
|
+
const lines = [];
|
|
347
|
+
const headerNames = [];
|
|
348
|
+
lines.push(`(request-target): ${method.toLowerCase()} ${path}`);
|
|
349
|
+
headerNames.push("(request-target)");
|
|
350
|
+
lines.push(`(created): ${created}`);
|
|
351
|
+
headerNames.push("(created)");
|
|
352
|
+
if (rtsAccessToken) {
|
|
353
|
+
lines.push(`(rts-access-token): ${rtsAccessToken}`);
|
|
354
|
+
headerNames.push("(rts-access-token)");
|
|
355
|
+
}
|
|
356
|
+
lines.push(`express-request-nonce: ${nonce}`);
|
|
357
|
+
headerNames.push("express-request-nonce");
|
|
358
|
+
if (digest) {
|
|
359
|
+
lines.push(`digest: SHA-256=${digest}`);
|
|
360
|
+
headerNames.push("digest");
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
signingString: lines.join("\n"),
|
|
364
|
+
signedHeaders: headerNames.join(" ")
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function computeDigest(body) {
|
|
368
|
+
const hash = sha256(new TextEncoder().encode(body));
|
|
369
|
+
return Buffer.from(hash).toString("base64");
|
|
370
|
+
}
|
|
371
|
+
async function signApigwRequest(params) {
|
|
372
|
+
const keys = loadApigwKeys();
|
|
373
|
+
if (!keys) {
|
|
374
|
+
throw new Error("Apigw keys not initialized. Run 'express auth login' first.");
|
|
375
|
+
}
|
|
376
|
+
const urlObj = new URL(params.url.startsWith("http") ? params.url : `${params.baseUrl}${params.url}`);
|
|
377
|
+
const path = urlObj.pathname + urlObj.search;
|
|
378
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
379
|
+
const nonce = generateNonce();
|
|
380
|
+
let digest;
|
|
381
|
+
if (params.body) {
|
|
382
|
+
digest = computeDigest(params.body);
|
|
383
|
+
}
|
|
384
|
+
const tokenForSigning = params.etsAuthToken || params.rtsToken || params.ctsToken;
|
|
385
|
+
const { signingString, signedHeaders } = buildSigningInput(
|
|
386
|
+
params.method,
|
|
387
|
+
path,
|
|
388
|
+
created,
|
|
389
|
+
nonce,
|
|
390
|
+
tokenForSigning,
|
|
391
|
+
digest
|
|
392
|
+
);
|
|
393
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
394
|
+
console.log(` [DEBUG] path: ${path}`);
|
|
395
|
+
console.log(` [DEBUG] signedHeaders: ${signedHeaders}`);
|
|
396
|
+
console.log(` [DEBUG] signingString:
|
|
397
|
+
${signingString}`);
|
|
398
|
+
console.log(` [DEBUG] signingKey keyId: ${keys.signingKey.keyId}`);
|
|
399
|
+
console.log(` [DEBUG] encryptionKey keyId: ${keys.encryptionKey.keyId}`);
|
|
400
|
+
}
|
|
401
|
+
const signature = signEd25519(keys.signingKey.privateKey, new TextEncoder().encode(signingString));
|
|
402
|
+
const signatureBase64 = Buffer.from(signature).toString("base64");
|
|
403
|
+
let serverPublicKey = keys.serverPublicKey;
|
|
404
|
+
try {
|
|
405
|
+
const etsBaseUrl = params.baseUrl;
|
|
406
|
+
const kdcStartRes = await fetch(`${etsBaseUrl}/api/v1/kdc/start`, {
|
|
407
|
+
headers: {
|
|
408
|
+
Accept: "application/json"
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
if (kdcStartRes.ok) {
|
|
412
|
+
const kdcStartData = await kdcStartRes.json();
|
|
413
|
+
if (kdcStartData.result) {
|
|
414
|
+
serverPublicKey = new Uint8Array(Buffer.from(kdcStartData.result, "base64"));
|
|
415
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
416
|
+
console.log(` [DEBUG] ETS server public key (curve25519): ${kdcStartData.result}`);
|
|
417
|
+
}
|
|
418
|
+
keys.serverPublicKey = serverPublicKey;
|
|
419
|
+
keys.serverPublicKeyId = "kdc-start-ets";
|
|
420
|
+
saveApigwKeys(keys);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
const tokenToEncrypt = params.etsAuthToken || params.rtsToken || params.ctsToken;
|
|
426
|
+
const encryptedToken = encryptToken(tokenToEncrypt, serverPublicKey, keys.encryptionKey.privateKey);
|
|
427
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
428
|
+
console.log(` [DEBUG] ctsToken length: ${params.ctsToken.length}`);
|
|
429
|
+
console.log(` [DEBUG] rtsToken: ${params.rtsToken ? `present (${params.rtsToken.length} chars)` : "not set"}`);
|
|
430
|
+
console.log(` [DEBUG] etsAuthToken: ${params.etsAuthToken ? `present (${params.etsAuthToken.length} chars)` : "not set"}`);
|
|
431
|
+
console.log(` [DEBUG] tokenToEncrypt: ${tokenToEncrypt.length} chars (source: ${params.etsAuthToken ? "etsAuthToken" : params.rtsToken ? "rtsToken" : "ctsToken"})`);
|
|
432
|
+
console.log(` [DEBUG] serverPublicKey b64: ${Buffer.from(serverPublicKey).toString("base64")}`);
|
|
433
|
+
console.log(` [DEBUG] encryptionPrivateKey b64: ${Buffer.from(keys.encryptionKey.privateKey).toString("base64")}`);
|
|
434
|
+
console.log(` [DEBUG] encryptionPublicKey b64: ${Buffer.from(keys.encryptionKey.publicKey).toString("base64")}`);
|
|
435
|
+
console.log(` [DEBUG] encryptedToken length: ${encryptedToken.length}`);
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
Authorization: `keyId="${keys.encryptionKey.keyId}",token="${encryptedToken}"`,
|
|
439
|
+
"Express-Proxy-Authorization": `Bearer ${params.ctsToken}`,
|
|
440
|
+
"Express-Request-Nonce": nonce,
|
|
441
|
+
...digest && { Digest: `SHA-256=${digest}` },
|
|
442
|
+
Signature: `keyId="${keys.signingKey.keyId}",algorithm="ed25519",headers="${signedHeaders}",signature="${signatureBase64}",created=${created}`,
|
|
443
|
+
...params.body && { "Content-Type": "application/json" }
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function signQrRequest(params) {
|
|
447
|
+
const urlObj = new URL(params.url);
|
|
448
|
+
const path = urlObj.pathname + urlObj.search;
|
|
449
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
450
|
+
const nonce = generateNonce();
|
|
451
|
+
const digest = computeDigest(params.body);
|
|
452
|
+
const { signingString, signedHeaders } = buildQrSigningInput(
|
|
453
|
+
params.method,
|
|
454
|
+
path,
|
|
455
|
+
created,
|
|
456
|
+
nonce,
|
|
457
|
+
digest
|
|
458
|
+
);
|
|
459
|
+
const signature = signEd25519(params.privateKey, new TextEncoder().encode(signingString));
|
|
460
|
+
const signatureBase64 = Buffer.from(signature).toString("base64");
|
|
461
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
462
|
+
console.log(` [DEBUG QR] path: ${path}`);
|
|
463
|
+
console.log(` [DEBUG QR] signedHeaders: ${signedHeaders}`);
|
|
464
|
+
console.log(` [DEBUG QR] signingString:
|
|
465
|
+
${signingString}`);
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
"Express-Request-Nonce": nonce,
|
|
469
|
+
Digest: `SHA-256=${digest}`,
|
|
470
|
+
Signature: `keyId="${params.registrationId}",algorithm="ed25519",headers="${signedHeaders}",signature="${signatureBase64}",created=${created}`,
|
|
471
|
+
"Content-Type": "application/json"
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/auth/device-login.ts
|
|
476
|
+
import nacl2 from "tweetnacl";
|
|
477
|
+
import { randomUUID } from "crypto";
|
|
478
|
+
async function fetchCurrentAccountCtsKey(baseUrl, token, userHuid, webOrigin) {
|
|
479
|
+
try {
|
|
480
|
+
const res = await fetch(`${baseUrl}/api/v1/kdc/keys/?user_huids=${userHuid}`, {
|
|
481
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${token}`, Origin: webOrigin }
|
|
482
|
+
});
|
|
483
|
+
if (!res.ok) return null;
|
|
484
|
+
const data = await res.json();
|
|
485
|
+
return data.result?.find((k) => k.kind === "cts")?.id ?? null;
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function extractResult(data) {
|
|
491
|
+
return data.result ?? data;
|
|
492
|
+
}
|
|
493
|
+
async function deviceLogin(cliOverrides = {}) {
|
|
494
|
+
const config = loadConfig(cliOverrides);
|
|
495
|
+
const baseUrl = getBaseUrl(config);
|
|
496
|
+
const etsBaseUrl = getEtsBaseUrl(config);
|
|
497
|
+
const webOrigin = getWebOrigin(config);
|
|
498
|
+
const currentToken = config.token ?? getAuthToken();
|
|
499
|
+
if (!currentToken) {
|
|
500
|
+
throw new Error("No Bearer token. Run 'express auth import <token>' first, then 'express auth login'.");
|
|
501
|
+
}
|
|
502
|
+
console.log("Step 1/5: Registering device with AD integration...");
|
|
503
|
+
const adBody = JSON.stringify({
|
|
504
|
+
app_version: config.app_version,
|
|
505
|
+
device: "Chrome 149.0",
|
|
506
|
+
device_software: "macOS 10.15.7",
|
|
507
|
+
manufacturer: "Google",
|
|
508
|
+
platform: config.platform,
|
|
509
|
+
locale: config.locale,
|
|
510
|
+
platform_package_id: config.platform_package_id,
|
|
511
|
+
device_meta: {
|
|
512
|
+
pushes: false,
|
|
513
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
514
|
+
permissions: { notifications: true }
|
|
515
|
+
},
|
|
516
|
+
device_hostname: null
|
|
517
|
+
});
|
|
518
|
+
const adRes = await fetch(`${baseUrl}/api/v1/ad_integration/token`, {
|
|
519
|
+
method: "PUT",
|
|
520
|
+
headers: {
|
|
521
|
+
"Accept": "application/json, text/plain, */*",
|
|
522
|
+
"Content-Type": "application/json",
|
|
523
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
524
|
+
"Origin": webOrigin,
|
|
525
|
+
"Referer": `${webOrigin}/`,
|
|
526
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
527
|
+
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
528
|
+
"Cache-Control": "no-cache",
|
|
529
|
+
"Pragma": "no-cache",
|
|
530
|
+
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
|
531
|
+
"sec-ch-ua-mobile": "?0",
|
|
532
|
+
"sec-ch-ua-platform": '"macOS"'
|
|
533
|
+
},
|
|
534
|
+
body: adBody
|
|
535
|
+
});
|
|
536
|
+
const adText = await adRes.text();
|
|
537
|
+
if (!adRes.ok) {
|
|
538
|
+
throw new Error(`AD integration failed (${adRes.status}): ${adText.slice(0, 500)}`);
|
|
539
|
+
}
|
|
540
|
+
console.log(` AD integration: ${adText.slice(0, 200)}`);
|
|
541
|
+
console.log("Step 2/5: Fetching user profile and server public key...");
|
|
542
|
+
const selfProfileRes = await fetch(`${baseUrl}/api/v1/phonebook/profiles/self`, {
|
|
543
|
+
headers: {
|
|
544
|
+
"Accept": "application/json, text/plain, */*",
|
|
545
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
546
|
+
"Origin": webOrigin,
|
|
547
|
+
"Referer": `${webOrigin}/`
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
let userHuid = "";
|
|
551
|
+
let serverId = "";
|
|
552
|
+
if (selfProfileRes.ok) {
|
|
553
|
+
const selfData = await selfProfileRes.json();
|
|
554
|
+
userHuid = selfData.profile?.user_huid ?? "";
|
|
555
|
+
serverId = selfData.profile?.server_id ?? userHuid;
|
|
556
|
+
console.log(` User HUID: ${userHuid || "unknown"}`);
|
|
557
|
+
}
|
|
558
|
+
let serverPublicKey = new Uint8Array(0);
|
|
559
|
+
let serverPublicKeyId = "";
|
|
560
|
+
const existingKeys = loadApigwKeys();
|
|
561
|
+
if (existingKeys?.serverPublicKey?.length) {
|
|
562
|
+
serverPublicKey = existingKeys.serverPublicKey;
|
|
563
|
+
serverPublicKeyId = existingKeys.serverPublicKeyId;
|
|
564
|
+
console.log(` Server public key (cached): ${serverPublicKeyId || "unknown"}`);
|
|
565
|
+
}
|
|
566
|
+
if (!serverPublicKey.length) {
|
|
567
|
+
try {
|
|
568
|
+
const kdcStartRes = await fetch(`${etsBaseUrl}/api/v1/kdc/start`, {
|
|
569
|
+
headers: { Accept: "application/json" }
|
|
570
|
+
});
|
|
571
|
+
if (kdcStartRes.ok) {
|
|
572
|
+
const kdcStartData = await kdcStartRes.json();
|
|
573
|
+
if (kdcStartData.result) {
|
|
574
|
+
serverPublicKey = new Uint8Array(Buffer.from(kdcStartData.result, "base64"));
|
|
575
|
+
serverPublicKeyId = "kdc-start-ets";
|
|
576
|
+
console.log(` Server public key (from ets): ${serverPublicKeyId}`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
} catch {
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!serverPublicKey.length && serverId) {
|
|
583
|
+
const kdcFetchUrl = `${baseUrl}/api/v1/kdc/keys/?ids=${serverId}`;
|
|
584
|
+
const kdcFetchRes = await fetch(kdcFetchUrl, {
|
|
585
|
+
headers: {
|
|
586
|
+
"Accept": "application/json",
|
|
587
|
+
"Authorization": `Bearer ${currentToken}`
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
if (kdcFetchRes.ok) {
|
|
591
|
+
const kdcFetchData = JSON.parse(await kdcFetchRes.text());
|
|
592
|
+
const kdcKeys = kdcFetchData.result ?? kdcFetchData;
|
|
593
|
+
const serverKey = Array.isArray(kdcKeys) ? kdcKeys.find((k) => (k.kind === "rest" || k.kind === "rts") && k.user_huid === serverId) : null;
|
|
594
|
+
if (!serverKey && Array.isArray(kdcKeys) && kdcKeys.length > 0) {
|
|
595
|
+
const first = kdcKeys[0];
|
|
596
|
+
serverPublicKey = new Uint8Array(Buffer.from(first.body, "base64"));
|
|
597
|
+
serverPublicKeyId = first.id;
|
|
598
|
+
} else if (serverKey) {
|
|
599
|
+
serverPublicKey = new Uint8Array(Buffer.from(serverKey.body, "base64"));
|
|
600
|
+
serverPublicKeyId = serverKey.id;
|
|
601
|
+
}
|
|
602
|
+
console.log(` Server public key (from KDC): ${serverPublicKeyId || "not found"}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (!serverPublicKey.length) {
|
|
606
|
+
throw new Error("Could not fetch server public key from KDC");
|
|
607
|
+
}
|
|
608
|
+
console.log("Step 3/5: Registering keys with KDC...");
|
|
609
|
+
let apigwSigningKey;
|
|
610
|
+
let apigwEncryptionKey;
|
|
611
|
+
if (existingKeys?.signingKey && existingKeys?.encryptionKey) {
|
|
612
|
+
apigwSigningKey = existingKeys.signingKey;
|
|
613
|
+
apigwEncryptionKey = existingKeys.encryptionKey;
|
|
614
|
+
console.log(` Reusing existing signing key: ${apigwSigningKey.keyId}`);
|
|
615
|
+
console.log(` Reusing existing encryption key: ${apigwEncryptionKey.keyId}`);
|
|
616
|
+
} else {
|
|
617
|
+
apigwSigningKey = generateSigningKeyPair();
|
|
618
|
+
apigwEncryptionKey = generateEncryptionKeyPair();
|
|
619
|
+
const apigwKeyPublicBase64 = publicKeyToBase64(apigwSigningKey.publicKey);
|
|
620
|
+
console.log(` Signing key: id=${apigwSigningKey.keyId} pub=${apigwKeyPublicBase64}`);
|
|
621
|
+
const kdcUrl = `${baseUrl}/api/v2/kdc/keys/${userHuid}`;
|
|
622
|
+
const kdcBody = JSON.stringify({
|
|
623
|
+
key: apigwKeyPublicBase64,
|
|
624
|
+
kind: "ed25519",
|
|
625
|
+
algo: "ed25519",
|
|
626
|
+
id: apigwSigningKey.keyId
|
|
627
|
+
});
|
|
628
|
+
const [kdcRes, etsKdcRes] = await Promise.all([
|
|
629
|
+
fetch(kdcUrl, {
|
|
630
|
+
method: "POST",
|
|
631
|
+
headers: {
|
|
632
|
+
"Accept": "application/json, text/plain, */*",
|
|
633
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
634
|
+
"Content-Type": "application/json",
|
|
635
|
+
"Origin": webOrigin,
|
|
636
|
+
"Referer": `${webOrigin}/`
|
|
637
|
+
},
|
|
638
|
+
body: kdcBody
|
|
639
|
+
}),
|
|
640
|
+
fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: {
|
|
643
|
+
"Accept": "application/json, text/plain, */*",
|
|
644
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
645
|
+
"Content-Type": "application/json",
|
|
646
|
+
"Origin": webOrigin,
|
|
647
|
+
"Referer": `${webOrigin}/`
|
|
648
|
+
},
|
|
649
|
+
body: kdcBody
|
|
650
|
+
})
|
|
651
|
+
]);
|
|
652
|
+
if (!kdcRes.ok) {
|
|
653
|
+
const kdcErrText = await kdcRes.text().catch(() => "");
|
|
654
|
+
console.log(` Warning: CTS KDC signing key registration failed (${kdcRes.status}): ${kdcErrText.slice(0, 200)}`);
|
|
655
|
+
} else {
|
|
656
|
+
console.log(` Signing key registered in CTS: ${apigwSigningKey.keyId}`);
|
|
657
|
+
}
|
|
658
|
+
if (!etsKdcRes.ok) {
|
|
659
|
+
const etsErrText = await etsKdcRes.text().catch(() => "");
|
|
660
|
+
console.log(` Warning: ETS KDC signing key registration failed (${etsKdcRes.status}): ${etsErrText.slice(0, 200)}`);
|
|
661
|
+
} else {
|
|
662
|
+
console.log(` Signing key registered in ETS: ${apigwSigningKey.keyId}`);
|
|
663
|
+
}
|
|
664
|
+
const encryptionKeyPublicBase64 = publicKeyToBase64(apigwEncryptionKey.publicKey);
|
|
665
|
+
const kdcEncBody = JSON.stringify({
|
|
666
|
+
key: encryptionKeyPublicBase64,
|
|
667
|
+
kind: "rts",
|
|
668
|
+
algo: "xsalsa20",
|
|
669
|
+
id: apigwEncryptionKey.keyId
|
|
670
|
+
});
|
|
671
|
+
const kdcEncRes = await fetch(kdcUrl, {
|
|
672
|
+
method: "POST",
|
|
673
|
+
headers: {
|
|
674
|
+
"Accept": "application/json, text/plain, */*",
|
|
675
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
676
|
+
"Content-Type": "application/json",
|
|
677
|
+
"Origin": webOrigin,
|
|
678
|
+
"Referer": `${webOrigin}/`
|
|
679
|
+
},
|
|
680
|
+
body: kdcEncBody
|
|
681
|
+
});
|
|
682
|
+
const etsKdcEncRes = await fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
headers: {
|
|
685
|
+
"Accept": "application/json, text/plain, */*",
|
|
686
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
687
|
+
"Content-Type": "application/json",
|
|
688
|
+
"Origin": webOrigin,
|
|
689
|
+
"Referer": `${webOrigin}/`
|
|
690
|
+
},
|
|
691
|
+
body: kdcEncBody
|
|
692
|
+
});
|
|
693
|
+
if (!kdcEncRes.ok) {
|
|
694
|
+
const kdcErrText = await kdcEncRes.text().catch(() => "");
|
|
695
|
+
console.log(` Warning: CTS KDC encryption key registration failed (${kdcEncRes.status}): ${kdcErrText.slice(0, 200)}`);
|
|
696
|
+
} else {
|
|
697
|
+
console.log(` Encryption key registered in CTS: ${apigwEncryptionKey.keyId}`);
|
|
698
|
+
}
|
|
699
|
+
if (!etsKdcEncRes.ok) {
|
|
700
|
+
const etsErrText = await etsKdcEncRes.text().catch(() => "");
|
|
701
|
+
console.log(` Warning: ETS KDC encryption key registration failed (${etsKdcEncRes.status}): ${etsErrText.slice(0, 200)}`);
|
|
702
|
+
} else {
|
|
703
|
+
console.log(` Encryption key registered in ETS: ${apigwEncryptionKey.keyId}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
let ctsKey = existingKeys?.ctsKey;
|
|
707
|
+
if (!ctsKey) {
|
|
708
|
+
const currentCts = await fetchCurrentAccountCtsKey(baseUrl, currentToken, userHuid, webOrigin);
|
|
709
|
+
if (currentCts) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`Account already has a shared CTS key (${currentCts}) that this CLI doesn't hold.
|
|
712
|
+
Minting a new one would break your other devices. Import the shared key instead:
|
|
713
|
+
express auth import-cts <private_key_b64> ${currentCts}
|
|
714
|
+
(extract it from a logged-in web client: IndexedDB authState \u2192 encryptionKeys \u2192 user.privateKeys.cts)`
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
const ctsKeyPair = nacl2.box.keyPair();
|
|
718
|
+
const ctsKeyId = crypto.randomUUID();
|
|
719
|
+
ctsKey = {
|
|
720
|
+
keyId: ctsKeyId,
|
|
721
|
+
privateKey: ctsKeyPair.secretKey,
|
|
722
|
+
publicKey: ctsKeyPair.publicKey
|
|
723
|
+
};
|
|
724
|
+
const ctsKeyPubB64 = Buffer.from(ctsKeyPair.publicKey).toString("base64");
|
|
725
|
+
const ctsKeyBody = JSON.stringify({ key: ctsKeyPubB64, kind: "cts", algo: "xsalsa20", id: ctsKeyId });
|
|
726
|
+
const [ctsCtsRes, etsCtsRes] = await Promise.all([
|
|
727
|
+
fetch(`${baseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
728
|
+
method: "POST",
|
|
729
|
+
headers: {
|
|
730
|
+
"Accept": "application/json, text/plain, */*",
|
|
731
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
732
|
+
"Content-Type": "application/json",
|
|
733
|
+
"Origin": webOrigin,
|
|
734
|
+
"Referer": `${webOrigin}/`
|
|
735
|
+
},
|
|
736
|
+
body: ctsKeyBody
|
|
737
|
+
}),
|
|
738
|
+
fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
739
|
+
method: "POST",
|
|
740
|
+
headers: {
|
|
741
|
+
"Accept": "application/json, text/plain, */*",
|
|
742
|
+
"Authorization": `Bearer ${currentToken}`,
|
|
743
|
+
"Content-Type": "application/json",
|
|
744
|
+
"Origin": webOrigin,
|
|
745
|
+
"Referer": `${webOrigin}/`
|
|
746
|
+
},
|
|
747
|
+
body: ctsKeyBody
|
|
748
|
+
})
|
|
749
|
+
]);
|
|
750
|
+
if (!ctsCtsRes.ok) {
|
|
751
|
+
const errText = await ctsCtsRes.text().catch(() => "");
|
|
752
|
+
console.log(` Warning: CTS encryption key registration failed (${ctsCtsRes.status}): ${errText.slice(0, 200)}`);
|
|
753
|
+
} else {
|
|
754
|
+
console.log(` CTS encryption key registered in CTS: ${ctsKeyId.slice(0, 8)}...`);
|
|
755
|
+
}
|
|
756
|
+
if (!etsCtsRes.ok) {
|
|
757
|
+
const errText = await etsCtsRes.text().catch(() => "");
|
|
758
|
+
console.log(` Warning: CTS encryption key registration in ETS failed (${etsCtsRes.status}): ${errText.slice(0, 200)}`);
|
|
759
|
+
} else {
|
|
760
|
+
console.log(` CTS encryption key registered in ETS: ${ctsKeyId.slice(0, 8)}...`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const apigwKeys = {
|
|
764
|
+
signingKey: apigwSigningKey,
|
|
765
|
+
encryptionKey: apigwEncryptionKey,
|
|
766
|
+
serverPublicKey,
|
|
767
|
+
serverPublicKeyId,
|
|
768
|
+
ctsKey
|
|
769
|
+
};
|
|
770
|
+
const previousKeysRaw = getApigwKeysRaw();
|
|
771
|
+
saveApigwKeys(apigwKeys);
|
|
772
|
+
console.log("Step 4/5: Activating device...");
|
|
773
|
+
const activationBody = JSON.stringify({
|
|
774
|
+
app_version: config.app_version,
|
|
775
|
+
locale: config.locale,
|
|
776
|
+
device_meta: {
|
|
777
|
+
pushes: false,
|
|
778
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
779
|
+
permissions: { notifications: true }
|
|
780
|
+
},
|
|
781
|
+
device_hostname: null
|
|
782
|
+
});
|
|
783
|
+
const activationUrl = `${etsBaseUrl}/api/v1/apigw/api/v1/authentication/activation`;
|
|
784
|
+
const activationHeaders = await signApigwRequest({
|
|
785
|
+
method: "PUT",
|
|
786
|
+
url: activationUrl,
|
|
787
|
+
baseUrl: etsBaseUrl,
|
|
788
|
+
body: activationBody,
|
|
789
|
+
ctsToken: currentToken
|
|
790
|
+
});
|
|
791
|
+
const actRes = await fetch(activationUrl, {
|
|
792
|
+
method: "PUT",
|
|
793
|
+
headers: {
|
|
794
|
+
"Accept": "application/json, text/plain, */*",
|
|
795
|
+
...activationHeaders,
|
|
796
|
+
"Origin": webOrigin,
|
|
797
|
+
"Referer": `${webOrigin}/`,
|
|
798
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
799
|
+
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
800
|
+
"Cache-Control": "no-cache",
|
|
801
|
+
"Pragma": "no-cache",
|
|
802
|
+
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
|
803
|
+
"sec-ch-ua-mobile": "?0",
|
|
804
|
+
"sec-ch-ua-platform": '"macOS"'
|
|
805
|
+
},
|
|
806
|
+
body: activationBody
|
|
807
|
+
});
|
|
808
|
+
const actText = await actRes.text();
|
|
809
|
+
if (!actRes.ok) {
|
|
810
|
+
if (previousKeysRaw) {
|
|
811
|
+
setApigwKeysRaw(previousKeysRaw);
|
|
812
|
+
console.log(" (Rolled back to previous apigw keys)");
|
|
813
|
+
} else {
|
|
814
|
+
clearApigwKeys();
|
|
815
|
+
}
|
|
816
|
+
throw new Error(`Device activation failed (${actRes.status}): ${actText.slice(0, 500)}`);
|
|
817
|
+
}
|
|
818
|
+
console.log(` Activation response: ${actText.slice(0, 300)}`);
|
|
819
|
+
let actData;
|
|
820
|
+
try {
|
|
821
|
+
actData = JSON.parse(actText);
|
|
822
|
+
} catch {
|
|
823
|
+
console.log(" (non-JSON activation response, continuing)");
|
|
824
|
+
actData = {};
|
|
825
|
+
}
|
|
826
|
+
const actResult = extractResult(actData);
|
|
827
|
+
const newAccessToken = actResult.access_token;
|
|
828
|
+
if (newAccessToken) {
|
|
829
|
+
setAuthToken(newAccessToken);
|
|
830
|
+
console.log(" Device activated. Token refreshed.");
|
|
831
|
+
} else {
|
|
832
|
+
console.log(" Device activated (using existing token).");
|
|
833
|
+
}
|
|
834
|
+
const effectiveToken = newAccessToken ?? currentToken;
|
|
835
|
+
const udid = randomUUID();
|
|
836
|
+
const pushBody = JSON.stringify({
|
|
837
|
+
phone: null,
|
|
838
|
+
token: null,
|
|
839
|
+
platform: "web_chrome",
|
|
840
|
+
user_huid: userHuid,
|
|
841
|
+
locale: config.locale,
|
|
842
|
+
udid,
|
|
843
|
+
encryption_key: "",
|
|
844
|
+
auth_key: "",
|
|
845
|
+
platform_package_id: config.platform_package_id
|
|
846
|
+
});
|
|
847
|
+
console.log("Step 5/5: Registering push token...");
|
|
848
|
+
const pushHeaders = await signApigwRequest({
|
|
849
|
+
method: "PUT",
|
|
850
|
+
url: `${etsBaseUrl}/api/v1/apigw/api/v1/push_service/protected/token`,
|
|
851
|
+
baseUrl: etsBaseUrl,
|
|
852
|
+
body: pushBody,
|
|
853
|
+
ctsToken: effectiveToken
|
|
854
|
+
});
|
|
855
|
+
const pushRes = await fetch(`${etsBaseUrl}/api/v1/apigw/api/v1/push_service/protected/token`, {
|
|
856
|
+
method: "PUT",
|
|
857
|
+
headers: {
|
|
858
|
+
"Accept": "application/json, text/plain, */*",
|
|
859
|
+
...pushHeaders,
|
|
860
|
+
"Origin": webOrigin,
|
|
861
|
+
"Referer": `${webOrigin}/`,
|
|
862
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
|
|
863
|
+
},
|
|
864
|
+
body: pushBody
|
|
865
|
+
});
|
|
866
|
+
const pushText = await pushRes.text();
|
|
867
|
+
console.log(` Push token: ${pushRes.status} \u2014 ${pushText.slice(0, 200)}`);
|
|
868
|
+
console.log("Login complete! Apigw endpoints are now available.");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/auth/qr-login.ts
|
|
872
|
+
import { randomUUID as randomUUID2, randomBytes as randomBytes2 } from "crypto";
|
|
873
|
+
import qrcode from "qrcode-terminal";
|
|
874
|
+
import nacl3 from "tweetnacl";
|
|
875
|
+
function extractResult2(data) {
|
|
876
|
+
return data.result ?? data;
|
|
877
|
+
}
|
|
878
|
+
async function fetchCurrentAccountCtsKey2(baseUrl, accessToken, userHuid, webOrigin) {
|
|
879
|
+
try {
|
|
880
|
+
const res = await fetch(`${baseUrl}/api/v1/kdc/keys/?user_huids=${userHuid}`, {
|
|
881
|
+
headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${accessToken}` }
|
|
882
|
+
});
|
|
883
|
+
if (!res.ok) return null;
|
|
884
|
+
const data = await res.json();
|
|
885
|
+
return data.result?.find((k) => k.kind === "cts")?.id ?? null;
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function commonHeaders(webOrigin) {
|
|
891
|
+
return {
|
|
892
|
+
"Accept": "application/json, text/plain, */*",
|
|
893
|
+
"Accept-Language": "ru-RU,ru;q=0.9",
|
|
894
|
+
"Connection": "keep-alive",
|
|
895
|
+
"Origin": webOrigin,
|
|
896
|
+
"Referer": `${webOrigin}/`,
|
|
897
|
+
"Sec-Fetch-Dest": "empty",
|
|
898
|
+
"Sec-Fetch-Mode": "cors",
|
|
899
|
+
"Sec-Fetch-Site": "same-site",
|
|
900
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
901
|
+
"sec-ch-ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
|
902
|
+
"sec-ch-ua-mobile": "?0",
|
|
903
|
+
"sec-ch-ua-platform": '"macOS"'
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
async function qrLogin(cliOverrides = {}) {
|
|
907
|
+
const config = loadConfig(cliOverrides);
|
|
908
|
+
const etsBaseUrl = getEtsBaseUrl(config);
|
|
909
|
+
const webOrigin = getWebOrigin(config);
|
|
910
|
+
const qrSigningKey = generateSigningKeyPair();
|
|
911
|
+
const registrationId = qrSigningKey.keyId;
|
|
912
|
+
const registrationToken = Buffer.from(randomBytes2(64)).toString("base64");
|
|
913
|
+
const signPubKey = publicKeyToBase64(qrSigningKey.publicKey);
|
|
914
|
+
const udid = randomUUID2();
|
|
915
|
+
const encryptionKey = randomBytes2(32);
|
|
916
|
+
const qrBody = JSON.stringify({
|
|
917
|
+
registration_id: registrationId,
|
|
918
|
+
registration_token: registrationToken,
|
|
919
|
+
sign_pub_key: signPubKey,
|
|
920
|
+
udid,
|
|
921
|
+
app_version: config.app_version,
|
|
922
|
+
device: "Chrome 149.0",
|
|
923
|
+
device_software: "macOS 10.15.7",
|
|
924
|
+
device_hostname: null,
|
|
925
|
+
device_meta: {
|
|
926
|
+
pushes: false,
|
|
927
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
928
|
+
permissions: { notifications: false }
|
|
929
|
+
},
|
|
930
|
+
locale: config.locale,
|
|
931
|
+
manufacturer: "Google",
|
|
932
|
+
platform: "web",
|
|
933
|
+
platform_package_id: "com.pyligrim.alphach"
|
|
934
|
+
});
|
|
935
|
+
const qrPayload = JSON.stringify({
|
|
936
|
+
registration_id: registrationId,
|
|
937
|
+
registration_token: registrationToken,
|
|
938
|
+
registration_key: Buffer.from(encryptionKey).toString("base64"),
|
|
939
|
+
version: 1
|
|
940
|
+
});
|
|
941
|
+
console.log("Step 1/7: Scan this QR code with your eXpress app:\n");
|
|
942
|
+
qrcode.generate(qrPayload, { small: true }, (qr) => {
|
|
943
|
+
console.log(qr);
|
|
944
|
+
});
|
|
945
|
+
console.log(`
|
|
946
|
+
registration_id: ${registrationId}`);
|
|
947
|
+
console.log(" Waiting for scan (server long-polling)...\n");
|
|
948
|
+
const etsUrl = `${etsBaseUrl}/api/v1/authentication/qr/mobile_to_web/request`;
|
|
949
|
+
const qrHeaders = signQrRequest({
|
|
950
|
+
method: "POST",
|
|
951
|
+
url: etsUrl,
|
|
952
|
+
body: qrBody,
|
|
953
|
+
registrationId,
|
|
954
|
+
privateKey: qrSigningKey.privateKey
|
|
955
|
+
});
|
|
956
|
+
let qrRes;
|
|
957
|
+
try {
|
|
958
|
+
qrRes = await fetch(etsUrl, {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: { ...commonHeaders(webOrigin), ...qrHeaders },
|
|
961
|
+
body: qrBody
|
|
962
|
+
});
|
|
963
|
+
} catch (err) {
|
|
964
|
+
throw new Error(`QR request network error: ${err.message}`);
|
|
965
|
+
}
|
|
966
|
+
const qrText = await qrRes.text();
|
|
967
|
+
if (!qrRes.ok) {
|
|
968
|
+
console.log(` Response (${qrRes.status}): ${qrText.slice(0, 500)}`);
|
|
969
|
+
throw new Error(`QR request failed (${qrRes.status}): ${qrText.slice(0, 500)}`);
|
|
970
|
+
}
|
|
971
|
+
let qrData;
|
|
972
|
+
try {
|
|
973
|
+
qrData = JSON.parse(qrText);
|
|
974
|
+
} catch {
|
|
975
|
+
throw new Error(`Invalid QR response: ${qrText.slice(0, 500)}`);
|
|
976
|
+
}
|
|
977
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
978
|
+
console.log(` [DEBUG] QR full response: ${qrText.slice(0, 1e3)}`);
|
|
979
|
+
}
|
|
980
|
+
const qrResult = extractResult2(qrData);
|
|
981
|
+
const ctsRegistrationToken = qrResult.cts_registration_token ?? "";
|
|
982
|
+
const rtsRegistrationToken = qrResult.rts_registration_token ?? "";
|
|
983
|
+
const registrationData = qrResult.registration_data ?? "";
|
|
984
|
+
console.log(" QR scanned! Got tokens from server.");
|
|
985
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
986
|
+
console.log(` [DEBUG] registration_data length: ${registrationData.length}`);
|
|
987
|
+
console.log(` [DEBUG] registration_data raw: ${registrationData.slice(0, 100)}...`);
|
|
988
|
+
console.log(` [DEBUG] encryptionKey (registration_key) hex: ${Buffer.from(encryptionKey).toString("hex")}`);
|
|
989
|
+
}
|
|
990
|
+
if (!ctsRegistrationToken && !rtsRegistrationToken) {
|
|
991
|
+
throw new Error(`No tokens in QR response: ${qrText.slice(0, 500)}`);
|
|
992
|
+
}
|
|
993
|
+
let rtsPrivateKey = null;
|
|
994
|
+
let rtsPublicKeyId = "";
|
|
995
|
+
let qrCtsPrivateKey = null;
|
|
996
|
+
let qrCtsKeyId = "";
|
|
997
|
+
if (registrationData) {
|
|
998
|
+
try {
|
|
999
|
+
const raw = Uint8Array.from(Buffer.from(registrationData, "base64"));
|
|
1000
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1001
|
+
console.log(` [DEBUG] registration_data decoded length: ${raw.length}`);
|
|
1002
|
+
console.log(` [DEBUG] first 40 bytes hex: ${Buffer.from(raw.slice(0, 40)).toString("hex")}`);
|
|
1003
|
+
console.log(` [DEBUG] encryptionKey hex: ${Buffer.from(encryptionKey).toString("hex")}`);
|
|
1004
|
+
console.log(` [DEBUG] encryptionKey length: ${encryptionKey.length}`);
|
|
1005
|
+
}
|
|
1006
|
+
const decrypted = decryptRegistrationData(registrationData, encryptionKey);
|
|
1007
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1008
|
+
console.log(" Decrypted registration_data:", JSON.stringify(decrypted).slice(0, 500));
|
|
1009
|
+
}
|
|
1010
|
+
if (decrypted && typeof decrypted === "object") {
|
|
1011
|
+
const data = decrypted;
|
|
1012
|
+
if (typeof data.rts_priv_key_body === "string") {
|
|
1013
|
+
rtsPrivateKey = new Uint8Array(Buffer.from(data.rts_priv_key_body, "base64"));
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof data.rts_pub_key_id === "string") {
|
|
1016
|
+
rtsPublicKeyId = data.rts_pub_key_id;
|
|
1017
|
+
}
|
|
1018
|
+
if (typeof data.cts_priv_key_body === "string" && typeof data.cts_pub_key_id === "string") {
|
|
1019
|
+
qrCtsPrivateKey = new Uint8Array(Buffer.from(data.cts_priv_key_body, "base64"));
|
|
1020
|
+
qrCtsKeyId = data.cts_pub_key_id;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
console.log(` Warning: could not decrypt registration_data: ${err.message}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
console.log("\nStep 2/7: Confirming with ETS...");
|
|
1028
|
+
const confirmUrl = `${etsBaseUrl}/api/v1/authentication/register_confirm/qr`;
|
|
1029
|
+
const confirmBody = JSON.stringify({
|
|
1030
|
+
registration_id: registrationId,
|
|
1031
|
+
temp_token: rtsRegistrationToken
|
|
1032
|
+
});
|
|
1033
|
+
const confirmHeaders = signQrRequest({
|
|
1034
|
+
method: "POST",
|
|
1035
|
+
url: confirmUrl,
|
|
1036
|
+
body: confirmBody,
|
|
1037
|
+
registrationId,
|
|
1038
|
+
privateKey: qrSigningKey.privateKey
|
|
1039
|
+
});
|
|
1040
|
+
const confirmRes = await fetch(confirmUrl, {
|
|
1041
|
+
method: "POST",
|
|
1042
|
+
headers: { ...commonHeaders(webOrigin), ...confirmHeaders },
|
|
1043
|
+
body: confirmBody
|
|
1044
|
+
});
|
|
1045
|
+
const confirmText = await confirmRes.text();
|
|
1046
|
+
if (!confirmRes.ok) {
|
|
1047
|
+
throw new Error(`ETS register_confirm failed (${confirmRes.status}): ${confirmText.slice(0, 500)}`);
|
|
1048
|
+
}
|
|
1049
|
+
const confirmData = extractResult2(JSON.parse(confirmText));
|
|
1050
|
+
const userHuid = confirmData.user_huid ?? "";
|
|
1051
|
+
const etsAuthToken = confirmData.auth_token ?? "";
|
|
1052
|
+
console.log(` ETS confirmed. User: ${userHuid || "unknown"}`);
|
|
1053
|
+
if (etsAuthToken) {
|
|
1054
|
+
setEtsAuthToken(etsAuthToken);
|
|
1055
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1056
|
+
console.log(` [DEBUG] ETS auth_token saved (${etsAuthToken.length} chars)`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!ctsRegistrationToken) {
|
|
1060
|
+
throw new Error("No cts_registration_token \u2014 cannot confirm with CTS");
|
|
1061
|
+
}
|
|
1062
|
+
console.log("\nStep 3/7: Confirming with CTS (AD integration)...");
|
|
1063
|
+
const ctsUrl = `${getBaseUrl(config)}/api/v1/ad_integration/register_confirm/qr`;
|
|
1064
|
+
const adConfirmBody = JSON.stringify({
|
|
1065
|
+
rts_registration_id: registrationId,
|
|
1066
|
+
temp_token: ctsRegistrationToken,
|
|
1067
|
+
ets: true
|
|
1068
|
+
});
|
|
1069
|
+
const adConfirmHeaders = signQrRequest({
|
|
1070
|
+
method: "POST",
|
|
1071
|
+
url: ctsUrl,
|
|
1072
|
+
body: adConfirmBody,
|
|
1073
|
+
registrationId,
|
|
1074
|
+
privateKey: qrSigningKey.privateKey
|
|
1075
|
+
});
|
|
1076
|
+
const adRes = await fetch(ctsUrl, {
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
headers: { ...commonHeaders(webOrigin), ...adConfirmHeaders },
|
|
1079
|
+
body: adConfirmBody
|
|
1080
|
+
});
|
|
1081
|
+
const adText = await adRes.text();
|
|
1082
|
+
if (!adRes.ok) {
|
|
1083
|
+
throw new Error(`AD integration confirm failed (${adRes.status}): ${adText.slice(0, 500)}`);
|
|
1084
|
+
}
|
|
1085
|
+
const adData = extractResult2(JSON.parse(adText));
|
|
1086
|
+
const accessToken = adData.access_token;
|
|
1087
|
+
const refreshToken2 = adData.refresh_token;
|
|
1088
|
+
const expiresIn = adData.expires_in;
|
|
1089
|
+
const serverId = adData.server_id ?? userHuid;
|
|
1090
|
+
const encryptedRtsToken = adData.encrypted_rts_token;
|
|
1091
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1092
|
+
const adDataRaw = JSON.parse(adText);
|
|
1093
|
+
console.log(` [DEBUG] AD confirm full result keys: ${JSON.stringify(Object.keys(adDataRaw.result || adDataRaw))}`);
|
|
1094
|
+
if (encryptedRtsToken) {
|
|
1095
|
+
console.log(` [DEBUG] encrypted_rts_token found: ${encryptedRtsToken.slice(0, 60)}...`);
|
|
1096
|
+
} else {
|
|
1097
|
+
console.log(` [DEBUG] encrypted_rts_token NOT found in response`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
if (!accessToken) {
|
|
1101
|
+
throw new Error(`No access_token in AD confirm response: ${adText.slice(0, 500)}`);
|
|
1102
|
+
}
|
|
1103
|
+
setAuthToken(accessToken);
|
|
1104
|
+
if (refreshToken2) {
|
|
1105
|
+
setRefreshToken(refreshToken2);
|
|
1106
|
+
}
|
|
1107
|
+
if (typeof expiresIn === "number") {
|
|
1108
|
+
setTokenExpiresAt(calcTokenExpiresAt(expiresIn));
|
|
1109
|
+
console.log(` Token expires in ${expiresIn}s (refresh after ${(expiresIn / 2 / 60).toFixed(0)} min)`);
|
|
1110
|
+
}
|
|
1111
|
+
console.log(` CTS confirmed. Access token: ${accessToken.slice(0, 40)}...`);
|
|
1112
|
+
console.log("\nStep 4/7: Registering device token...");
|
|
1113
|
+
const tokenUrl = `${getBaseUrl(config)}/api/v1/ad_integration/token`;
|
|
1114
|
+
const tokenBody = JSON.stringify({
|
|
1115
|
+
app_version: config.app_version,
|
|
1116
|
+
device: "Chrome 149.0",
|
|
1117
|
+
device_software: "macOS 10.15.7",
|
|
1118
|
+
manufacturer: "Google",
|
|
1119
|
+
platform: "web",
|
|
1120
|
+
locale: config.locale,
|
|
1121
|
+
platform_package_id: "com.pyligrim.alphach",
|
|
1122
|
+
device_meta: {
|
|
1123
|
+
pushes: false,
|
|
1124
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1125
|
+
permissions: { notifications: false }
|
|
1126
|
+
},
|
|
1127
|
+
device_hostname: null
|
|
1128
|
+
});
|
|
1129
|
+
const tokenRes = await fetch(tokenUrl, {
|
|
1130
|
+
method: "PUT",
|
|
1131
|
+
headers: {
|
|
1132
|
+
...commonHeaders(webOrigin),
|
|
1133
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1134
|
+
"Content-Type": "application/json"
|
|
1135
|
+
},
|
|
1136
|
+
body: tokenBody
|
|
1137
|
+
});
|
|
1138
|
+
if (!tokenRes.ok) {
|
|
1139
|
+
const tokenErrText = await tokenRes.text().catch(() => "");
|
|
1140
|
+
console.log(` Warning: device token registration failed (${tokenRes.status}): ${tokenErrText.slice(0, 200)}`);
|
|
1141
|
+
} else {
|
|
1142
|
+
console.log(" Device token registered.");
|
|
1143
|
+
}
|
|
1144
|
+
console.log("\nStep 5/7: Registering signing key + fetching server key...");
|
|
1145
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1146
|
+
console.log(` [DEBUG] serverId: ${serverId}`);
|
|
1147
|
+
console.log(` [DEBUG] userHuid: ${userHuid}`);
|
|
1148
|
+
}
|
|
1149
|
+
const apigwSigningKey = generateSigningKeyPair();
|
|
1150
|
+
const apigwKeyPublicBase64 = publicKeyToBase64(apigwSigningKey.publicKey);
|
|
1151
|
+
const kdcSignUrl = `${getBaseUrl(config)}/api/v2/kdc/keys/${userHuid}`;
|
|
1152
|
+
const kdcSignBody = JSON.stringify({
|
|
1153
|
+
key: apigwKeyPublicBase64,
|
|
1154
|
+
kind: "ed25519",
|
|
1155
|
+
algo: "ed25519",
|
|
1156
|
+
id: apigwSigningKey.keyId
|
|
1157
|
+
});
|
|
1158
|
+
const [kdcSignRes, etsKdcSignRes, etsKdcStartRes] = await Promise.all([
|
|
1159
|
+
fetch(kdcSignUrl, {
|
|
1160
|
+
method: "POST",
|
|
1161
|
+
headers: {
|
|
1162
|
+
...commonHeaders(webOrigin),
|
|
1163
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1164
|
+
"Content-Type": "application/json"
|
|
1165
|
+
},
|
|
1166
|
+
body: kdcSignBody
|
|
1167
|
+
}),
|
|
1168
|
+
fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
1169
|
+
method: "POST",
|
|
1170
|
+
headers: {
|
|
1171
|
+
...commonHeaders(webOrigin),
|
|
1172
|
+
Authorization: `Bearer ${etsAuthToken}`,
|
|
1173
|
+
"Content-Type": "application/json"
|
|
1174
|
+
},
|
|
1175
|
+
body: kdcSignBody
|
|
1176
|
+
}),
|
|
1177
|
+
fetch(`${etsBaseUrl}/api/v1/kdc/start`, {
|
|
1178
|
+
headers: {
|
|
1179
|
+
...commonHeaders(webOrigin)
|
|
1180
|
+
}
|
|
1181
|
+
})
|
|
1182
|
+
]);
|
|
1183
|
+
if (!kdcSignRes.ok) {
|
|
1184
|
+
const kdcErrText = await kdcSignRes.text().catch(() => "");
|
|
1185
|
+
console.log(` Warning: CTS KDC signing key registration failed (${kdcSignRes.status}): ${kdcErrText.slice(0, 200)}`);
|
|
1186
|
+
} else {
|
|
1187
|
+
const kdcSignData = await kdcSignRes.json().catch(() => null);
|
|
1188
|
+
console.log(` Signing key registered in CTS: ${apigwSigningKey.keyId}`, kdcSignData ? JSON.stringify(kdcSignData).slice(0, 200) : "");
|
|
1189
|
+
}
|
|
1190
|
+
if (!etsKdcSignRes.ok) {
|
|
1191
|
+
const etsErrText = await etsKdcSignRes.text().catch(() => "");
|
|
1192
|
+
console.log(` Warning: ETS KDC signing key registration failed (${etsKdcSignRes.status}): ${etsErrText.slice(0, 200)}`);
|
|
1193
|
+
} else {
|
|
1194
|
+
const etsSignData = await etsKdcSignRes.json().catch(() => null);
|
|
1195
|
+
console.log(` Signing key registered in ETS: ${apigwSigningKey.keyId}`, etsSignData ? JSON.stringify(etsSignData).slice(0, 200) : "");
|
|
1196
|
+
}
|
|
1197
|
+
let serverPublicKey = new Uint8Array(0);
|
|
1198
|
+
let serverPublicKeyId = "";
|
|
1199
|
+
if (etsKdcStartRes.ok) {
|
|
1200
|
+
const kdcStartText = await etsKdcStartRes.text();
|
|
1201
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1202
|
+
console.log(` [DEBUG] ETS KDC start response: ${kdcStartText.slice(0, 500)}`);
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
const kdcStartData = JSON.parse(kdcStartText);
|
|
1206
|
+
const keyBody = kdcStartData.result ?? kdcStartText;
|
|
1207
|
+
serverPublicKey = new Uint8Array(Buffer.from(keyBody, "base64"));
|
|
1208
|
+
serverPublicKeyId = "kdc-start-ets";
|
|
1209
|
+
const rawB64 = Buffer.from(serverPublicKey).toString("base64");
|
|
1210
|
+
console.log(` ETS server public key from /kdc/start: ${rawB64} (curve25519, used directly)`);
|
|
1211
|
+
} catch {
|
|
1212
|
+
try {
|
|
1213
|
+
serverPublicKey = new Uint8Array(Buffer.from(kdcStartText, "base64"));
|
|
1214
|
+
} catch {
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (!serverPublicKey.length) {
|
|
1219
|
+
if (process.env.EXPRESS_DEBUG && !etsKdcStartRes.ok) {
|
|
1220
|
+
console.log(` [DEBUG] ETS KDC start status: ${etsKdcStartRes.status}`);
|
|
1221
|
+
try {
|
|
1222
|
+
console.log(` [DEBUG] ETS KDC start body: ${(await etsKdcStartRes.text()).slice(0, 500)}`);
|
|
1223
|
+
} catch {
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
throw new Error("Could not fetch server public key from ETS /kdc/start");
|
|
1227
|
+
}
|
|
1228
|
+
if (rtsPrivateKey) {
|
|
1229
|
+
const rtsPubKeyB64 = Buffer.from(nacl3.box.keyPair.fromSecretKey(rtsPrivateKey).publicKey).toString("base64");
|
|
1230
|
+
const rtsKeyBody = JSON.stringify({ key: rtsPubKeyB64, kind: "rts", algo: "xsalsa20", id: rtsPublicKeyId });
|
|
1231
|
+
const [ctsRtsRes, etsRtsRes] = await Promise.all([
|
|
1232
|
+
fetch(kdcSignUrl, {
|
|
1233
|
+
method: "POST",
|
|
1234
|
+
headers: {
|
|
1235
|
+
...commonHeaders(webOrigin),
|
|
1236
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1237
|
+
"Content-Type": "application/json"
|
|
1238
|
+
},
|
|
1239
|
+
body: rtsKeyBody
|
|
1240
|
+
}),
|
|
1241
|
+
fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
1242
|
+
method: "POST",
|
|
1243
|
+
headers: {
|
|
1244
|
+
...commonHeaders(webOrigin),
|
|
1245
|
+
Authorization: `Bearer ${etsAuthToken}`,
|
|
1246
|
+
"Content-Type": "application/json"
|
|
1247
|
+
},
|
|
1248
|
+
body: rtsKeyBody
|
|
1249
|
+
})
|
|
1250
|
+
]);
|
|
1251
|
+
if (!ctsRtsRes.ok) {
|
|
1252
|
+
const errText = await ctsRtsRes.text().catch(() => "");
|
|
1253
|
+
console.log(` Warning: CTS RTS key registration failed (${ctsRtsRes.status}): ${errText.slice(0, 200)}`);
|
|
1254
|
+
} else {
|
|
1255
|
+
console.log(` RTS key registered in CTS: ${rtsPublicKeyId}`);
|
|
1256
|
+
}
|
|
1257
|
+
if (!etsRtsRes.ok) {
|
|
1258
|
+
const errText = await etsRtsRes.text().catch(() => "");
|
|
1259
|
+
console.log(` Warning: ETS RTS key registration failed (${etsRtsRes.status}): ${errText.slice(0, 200)}`);
|
|
1260
|
+
} else {
|
|
1261
|
+
console.log(` RTS key registered in ETS: ${rtsPublicKeyId}`);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (!rtsPrivateKey) {
|
|
1265
|
+
const fallbackKeyPair = nacl3.box.keyPair();
|
|
1266
|
+
rtsPrivateKey = fallbackKeyPair.secretKey;
|
|
1267
|
+
rtsPublicKeyId = crypto.randomUUID();
|
|
1268
|
+
const encPubB64 = Buffer.from(fallbackKeyPair.publicKey).toString("base64");
|
|
1269
|
+
const rtsFallbackBody = JSON.stringify({ key: encPubB64, kind: "rts", algo: "xsalsa20", id: rtsPublicKeyId });
|
|
1270
|
+
const [ctsEncRes, etsEncRes] = await Promise.all([
|
|
1271
|
+
fetch(kdcSignUrl, {
|
|
1272
|
+
method: "POST",
|
|
1273
|
+
headers: {
|
|
1274
|
+
...commonHeaders(webOrigin),
|
|
1275
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1276
|
+
"Content-Type": "application/json"
|
|
1277
|
+
},
|
|
1278
|
+
body: rtsFallbackBody
|
|
1279
|
+
}),
|
|
1280
|
+
fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
|
|
1281
|
+
method: "POST",
|
|
1282
|
+
headers: {
|
|
1283
|
+
...commonHeaders(webOrigin),
|
|
1284
|
+
Authorization: `Bearer ${etsAuthToken}`,
|
|
1285
|
+
"Content-Type": "application/json"
|
|
1286
|
+
},
|
|
1287
|
+
body: rtsFallbackBody
|
|
1288
|
+
})
|
|
1289
|
+
]);
|
|
1290
|
+
if (!ctsEncRes.ok) {
|
|
1291
|
+
const errText = await ctsEncRes.text().catch(() => "");
|
|
1292
|
+
console.log(` Warning: CTS fallback RTS key registration failed (${ctsEncRes.status}): ${errText.slice(0, 200)}`);
|
|
1293
|
+
} else {
|
|
1294
|
+
console.log(` Fallback encryption key registered in CTS: ${rtsPublicKeyId}`);
|
|
1295
|
+
}
|
|
1296
|
+
if (!etsEncRes.ok) {
|
|
1297
|
+
const errText = await etsEncRes.text().catch(() => "");
|
|
1298
|
+
console.log(` Warning: ETS fallback RTS key registration failed (${etsEncRes.status}): ${errText.slice(0, 200)}`);
|
|
1299
|
+
} else {
|
|
1300
|
+
console.log(` Fallback encryption key registered in ETS: ${rtsPublicKeyId}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const rtsPublicKey = nacl3.box.keyPair.fromSecretKey(rtsPrivateKey).publicKey;
|
|
1304
|
+
let rtsAuthToken = "";
|
|
1305
|
+
if (encryptedRtsToken && rtsPrivateKey && serverPublicKey.length) {
|
|
1306
|
+
try {
|
|
1307
|
+
rtsAuthToken = decryptRtsToken(encryptedRtsToken, serverPublicKey, rtsPrivateKey);
|
|
1308
|
+
setRtsAuthToken(rtsAuthToken);
|
|
1309
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
1310
|
+
console.log(` [DEBUG] Decrypted RTS auth token: ${rtsAuthToken.slice(0, 60)}...`);
|
|
1311
|
+
}
|
|
1312
|
+
console.log(` RTS auth token decrypted from encrypted_rts_token`);
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
console.log(` Warning: could not decrypt encrypted_rts_token: ${err.message}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const rtsIdFromToken = extractRtsKeyIdFromToken(accessToken);
|
|
1318
|
+
if (rtsIdFromToken && process.env.EXPRESS_DEBUG) {
|
|
1319
|
+
console.log(` [DEBUG] rts_id from CTS token: ${rtsIdFromToken}`);
|
|
1320
|
+
}
|
|
1321
|
+
const existingCts = loadApigwKeys()?.ctsKey;
|
|
1322
|
+
let ctsKey;
|
|
1323
|
+
if (qrCtsPrivateKey && qrCtsKeyId) {
|
|
1324
|
+
ctsKey = {
|
|
1325
|
+
keyId: qrCtsKeyId,
|
|
1326
|
+
privateKey: qrCtsPrivateKey,
|
|
1327
|
+
publicKey: nacl3.box.keyPair.fromSecretKey(qrCtsPrivateKey).publicKey
|
|
1328
|
+
};
|
|
1329
|
+
console.log(` Using CTS key from QR handshake: ${qrCtsKeyId.slice(0, 8)}... (shared account key)`);
|
|
1330
|
+
} else if (existingCts) {
|
|
1331
|
+
ctsKey = existingCts;
|
|
1332
|
+
console.log(` Reusing existing CTS key: ${existingCts.keyId.slice(0, 8)}... (not re-registering)`);
|
|
1333
|
+
} else {
|
|
1334
|
+
const currentCts = await fetchCurrentAccountCtsKey2(getBaseUrl(config), accessToken, userHuid, webOrigin);
|
|
1335
|
+
if (currentCts) {
|
|
1336
|
+
throw new Error(
|
|
1337
|
+
`Account already has a shared CTS key (${currentCts}) that this CLI doesn't hold.
|
|
1338
|
+
Minting a new one would break your other devices (they can't fetch its private key).
|
|
1339
|
+
Instead, extract the key from a logged-in web client (IndexedDB authState \u2192 encryptionKeys \u2192 user.privateKeys.cts) and run:
|
|
1340
|
+
express auth import-cts <private_key_b64> ${currentCts}
|
|
1341
|
+
Then re-run login, or just use 'auth refresh' for tokens.`
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
console.log(" No existing account CTS key found \u2014 minting a new one (first device).");
|
|
1345
|
+
const ctsKeyPair = nacl3.box.keyPair();
|
|
1346
|
+
const ctsKeyId = crypto.randomUUID();
|
|
1347
|
+
const ctsKeyPubB64 = Buffer.from(ctsKeyPair.publicKey).toString("base64");
|
|
1348
|
+
const ctsKeyBody = JSON.stringify({ key: ctsKeyPubB64, kind: "cts", algo: "xsalsa20", id: ctsKeyId });
|
|
1349
|
+
const ctsCtsKeyRes = await fetch(kdcSignUrl, {
|
|
1350
|
+
method: "POST",
|
|
1351
|
+
headers: {
|
|
1352
|
+
...commonHeaders(webOrigin),
|
|
1353
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1354
|
+
"Content-Type": "application/json"
|
|
1355
|
+
},
|
|
1356
|
+
body: ctsKeyBody
|
|
1357
|
+
});
|
|
1358
|
+
if (!ctsCtsKeyRes.ok) {
|
|
1359
|
+
const errText = await ctsCtsKeyRes.text().catch(() => "");
|
|
1360
|
+
console.log(` Warning: CTS encryption key registration failed (${ctsCtsKeyRes.status}): ${errText.slice(0, 200)}`);
|
|
1361
|
+
} else {
|
|
1362
|
+
console.log(` CTS encryption key registered: ${ctsKeyId.slice(0, 8)}...`);
|
|
1363
|
+
}
|
|
1364
|
+
ctsKey = {
|
|
1365
|
+
keyId: ctsKeyId,
|
|
1366
|
+
privateKey: ctsKeyPair.secretKey,
|
|
1367
|
+
publicKey: ctsKeyPair.publicKey
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
const apigwKeys = {
|
|
1371
|
+
signingKey: apigwSigningKey,
|
|
1372
|
+
encryptionKey: {
|
|
1373
|
+
keyId: rtsPublicKeyId,
|
|
1374
|
+
privateKey: rtsPrivateKey,
|
|
1375
|
+
publicKey: rtsPublicKey
|
|
1376
|
+
},
|
|
1377
|
+
ctsKey,
|
|
1378
|
+
serverPublicKey,
|
|
1379
|
+
serverPublicKeyId
|
|
1380
|
+
};
|
|
1381
|
+
saveApigwKeys(apigwKeys);
|
|
1382
|
+
console.log("\nStep 6/7: Activating apigw via ETS...");
|
|
1383
|
+
const activationUrl = `${etsBaseUrl}/api/v1/apigw/api/v1/authentication/activation`;
|
|
1384
|
+
const activationBody = JSON.stringify({
|
|
1385
|
+
app_version: config.app_version,
|
|
1386
|
+
locale: config.locale,
|
|
1387
|
+
device_meta: {
|
|
1388
|
+
pushes: false,
|
|
1389
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1390
|
+
permissions: { notifications: false }
|
|
1391
|
+
},
|
|
1392
|
+
device_hostname: null
|
|
1393
|
+
});
|
|
1394
|
+
const activationHeaders = await signApigwRequest({
|
|
1395
|
+
method: "PUT",
|
|
1396
|
+
url: activationUrl,
|
|
1397
|
+
baseUrl: etsBaseUrl,
|
|
1398
|
+
body: activationBody,
|
|
1399
|
+
ctsToken: accessToken,
|
|
1400
|
+
rtsToken: rtsAuthToken || void 0,
|
|
1401
|
+
etsAuthToken: etsAuthToken || void 0
|
|
1402
|
+
});
|
|
1403
|
+
const activationRes = await fetch(activationUrl, {
|
|
1404
|
+
method: "PUT",
|
|
1405
|
+
headers: { ...commonHeaders(webOrigin), ...activationHeaders },
|
|
1406
|
+
body: activationBody
|
|
1407
|
+
});
|
|
1408
|
+
if (!activationRes.ok) {
|
|
1409
|
+
const actErrText = await activationRes.text().catch(() => "");
|
|
1410
|
+
console.log(` Warning: apigw activation failed (${activationRes.status}): ${actErrText.slice(0, 200)}`);
|
|
1411
|
+
} else {
|
|
1412
|
+
console.log(" Apigw activated.");
|
|
1413
|
+
}
|
|
1414
|
+
console.log(`
|
|
1415
|
+
User HUID: ${userHuid || "unknown"}`);
|
|
1416
|
+
console.log(` Signing key: ${apigwSigningKey.keyId.slice(0, 8)}...`);
|
|
1417
|
+
console.log(` Encryption key: ${rtsPublicKeyId.slice(0, 8)}...`);
|
|
1418
|
+
console.log(` Server key: ${serverPublicKeyId.slice(0, 8)}...`);
|
|
1419
|
+
console.log("\nQR login complete! You are now authenticated.");
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/auth/token-refresh.ts
|
|
1423
|
+
var refreshPromise = null;
|
|
1424
|
+
async function refreshToken(cliOverrides = {}) {
|
|
1425
|
+
if (refreshPromise) return refreshPromise;
|
|
1426
|
+
refreshPromise = doRefresh(cliOverrides);
|
|
1427
|
+
try {
|
|
1428
|
+
return await refreshPromise;
|
|
1429
|
+
} finally {
|
|
1430
|
+
refreshPromise = null;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
async function doRefresh(cliOverrides = {}) {
|
|
1434
|
+
const currentToken = getAuthToken();
|
|
1435
|
+
const rt = getRefreshToken();
|
|
1436
|
+
if (!currentToken || !rt) return false;
|
|
1437
|
+
const config = loadConfig(cliOverrides);
|
|
1438
|
+
const baseUrl = getBaseUrl(config);
|
|
1439
|
+
const webOrigin = getWebOrigin(config);
|
|
1440
|
+
try {
|
|
1441
|
+
const res = await fetch(`${baseUrl}/api/v1/ad_integration/token/refresh`, {
|
|
1442
|
+
method: "POST",
|
|
1443
|
+
headers: {
|
|
1444
|
+
"Content-Type": "application/json",
|
|
1445
|
+
Accept: "application/json, text/plain, */*",
|
|
1446
|
+
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
1447
|
+
"Cache-Control": "no-cache",
|
|
1448
|
+
Connection: "keep-alive",
|
|
1449
|
+
Origin: webOrigin,
|
|
1450
|
+
Pragma: "no-cache",
|
|
1451
|
+
Referer: `${webOrigin}/`,
|
|
1452
|
+
"Sec-Fetch-Dest": "empty",
|
|
1453
|
+
"Sec-Fetch-Mode": "cors",
|
|
1454
|
+
"Sec-Fetch-Site": "same-site",
|
|
1455
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
|
1456
|
+
Authorization: `Bearer ${currentToken}`
|
|
1457
|
+
},
|
|
1458
|
+
body: JSON.stringify({ refresh_token: rt })
|
|
1459
|
+
});
|
|
1460
|
+
if (!res.ok) {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
const text = await res.text();
|
|
1464
|
+
const data = JSON.parse(text);
|
|
1465
|
+
const result = data.result ?? data;
|
|
1466
|
+
const newAccessToken = result.cts_access_token;
|
|
1467
|
+
const newRefreshToken = result.refresh_token;
|
|
1468
|
+
const expiresIn = result.expires_in;
|
|
1469
|
+
if (!newAccessToken) return false;
|
|
1470
|
+
setAuthToken(newAccessToken);
|
|
1471
|
+
if (newRefreshToken) setRefreshToken(newRefreshToken);
|
|
1472
|
+
if (typeof expiresIn === "number") {
|
|
1473
|
+
setTokenExpiresAt(calcTokenExpiresAt(expiresIn));
|
|
1474
|
+
}
|
|
1475
|
+
return true;
|
|
1476
|
+
} catch {
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/cli/auth.ts
|
|
1482
|
+
function createAuthCommand() {
|
|
1483
|
+
const cmd = new Command("auth");
|
|
1484
|
+
cmd.description("Authentication management");
|
|
1485
|
+
cmd.command("import <token>").description("Import Bearer token from browser").option("--host <host>", "eXpress host").action(async (token, opts) => {
|
|
1486
|
+
try {
|
|
1487
|
+
await importToken(token, opts.host ? { host: opts.host } : {});
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
console.error(`Error: ${err.message}`);
|
|
1490
|
+
process.exit(1);
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
cmd.command("login").description("Full device login (ed25519 signing for apigw endpoints)").option("--host <host>", "eXpress host").action(async (opts) => {
|
|
1494
|
+
try {
|
|
1495
|
+
await deviceLogin(opts.host ? { host: opts.host } : {});
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
console.error(`Error: ${err.message}`);
|
|
1498
|
+
process.exit(1);
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
cmd.command("qr").description("QR-code login (scan with eXpress mobile app)").option("--host <host>", "eXpress host").action(async (opts) => {
|
|
1502
|
+
try {
|
|
1503
|
+
await qrLogin(opts.host ? { host: opts.host } : {});
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
console.error(`Error: ${err.message}`);
|
|
1506
|
+
process.exit(1);
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
cmd.command("import-cts <private_key_b64> <key_id>").description("Import the account's shared CTS (E2E) key so the CLI decrypts like your phone/desktop. Get both values from the web client's IndexedDB (authState \u2192 encryptionKeys \u2192 user.privateKeys.cts: body + publicKeyId).").action((privateKeyB64, keyId) => {
|
|
1510
|
+
try {
|
|
1511
|
+
const ctsKey = importCtsKey(privateKeyB64, keyId);
|
|
1512
|
+
const pub = Buffer.from(ctsKey.publicKey).toString("base64");
|
|
1513
|
+
console.log(`CTS key imported: ${keyId}`);
|
|
1514
|
+
console.log(` derived public key: ${pub}`);
|
|
1515
|
+
console.log(" Verify this matches the KDC 'body' for this key_id. Do NOT run 'auth login/qr' with a different device now \u2014 it would re-register a new key and break sync.");
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
console.error(`Error: ${err.message}`);
|
|
1518
|
+
process.exit(1);
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
cmd.command("logout").description("Remove stored authentication token and apigw keys").action(() => {
|
|
1522
|
+
clearApigwKeys();
|
|
1523
|
+
logout();
|
|
1524
|
+
});
|
|
1525
|
+
cmd.command("status").description("Show current authentication status").action(() => {
|
|
1526
|
+
status();
|
|
1527
|
+
const expiresAt = getTokenExpiresAt();
|
|
1528
|
+
const hasRefresh = !!getRefreshToken();
|
|
1529
|
+
if (expiresAt) {
|
|
1530
|
+
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1e3));
|
|
1531
|
+
const expired = remaining === 0;
|
|
1532
|
+
console.log(`Token: ${expired ? "EXPIRED" : `expires in ${remaining}s (~${(remaining / 60).toFixed(0)} min)`}`);
|
|
1533
|
+
console.log(`Refresh: ${hasRefresh ? "available" : "not available"}`);
|
|
1534
|
+
} else {
|
|
1535
|
+
console.log("Token: expiry unknown");
|
|
1536
|
+
console.log(`Refresh: ${hasRefresh ? "available" : "not available"}`);
|
|
1537
|
+
}
|
|
1538
|
+
const keys = loadApigwKeys();
|
|
1539
|
+
if (keys) {
|
|
1540
|
+
console.log(`Apigw: enabled (signing: ${keys.signingKey.keyId.slice(0, 8)}..., encryption: ${keys.encryptionKey.keyId.slice(0, 8)}..., server: ${keys.serverPublicKeyId.slice(0, 8)}...)`);
|
|
1541
|
+
} else {
|
|
1542
|
+
console.log("Apigw: not configured. Run 'express auth login' or 'express auth qr' to enable.");
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
cmd.command("refresh").description("Refresh access token using refresh token").action(async () => {
|
|
1546
|
+
try {
|
|
1547
|
+
const refreshed = await refreshToken();
|
|
1548
|
+
if (refreshed) {
|
|
1549
|
+
const expiresAt = getTokenExpiresAt();
|
|
1550
|
+
const remaining = expiresAt ? Math.max(0, Math.floor((expiresAt - Date.now()) / 1e3)) : 0;
|
|
1551
|
+
console.log(`Token refreshed successfully (expires in ${remaining}s)`);
|
|
1552
|
+
} else {
|
|
1553
|
+
console.error("Failed to refresh token. Re-authenticate with `express auth qr`.");
|
|
1554
|
+
process.exit(1);
|
|
1555
|
+
}
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
console.error(`Error: ${err.message}`);
|
|
1558
|
+
process.exit(1);
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
return cmd;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/cli/api.ts
|
|
1565
|
+
import { Command as Command2 } from "commander";
|
|
1566
|
+
|
|
1567
|
+
// src/api/client.ts
|
|
1568
|
+
var ApiClient = class {
|
|
1569
|
+
config;
|
|
1570
|
+
baseUrl;
|
|
1571
|
+
etsBaseUrl;
|
|
1572
|
+
webOrigin;
|
|
1573
|
+
constructor(cliOverrides = {}) {
|
|
1574
|
+
this.config = loadConfig(cliOverrides);
|
|
1575
|
+
this.baseUrl = getBaseUrl(this.config);
|
|
1576
|
+
this.etsBaseUrl = getEtsBaseUrl(this.config);
|
|
1577
|
+
this.webOrigin = getWebOrigin(this.config);
|
|
1578
|
+
}
|
|
1579
|
+
getToken() {
|
|
1580
|
+
const token = this.config.token ?? getAuthToken();
|
|
1581
|
+
if (!token) {
|
|
1582
|
+
throw new Error("Not authenticated. Use `express auth import <token>` to login.");
|
|
1583
|
+
}
|
|
1584
|
+
return token;
|
|
1585
|
+
}
|
|
1586
|
+
isApigwPath(path) {
|
|
1587
|
+
return path.includes("/apigw/");
|
|
1588
|
+
}
|
|
1589
|
+
resolveUrl(path) {
|
|
1590
|
+
if (path.startsWith("http")) return path;
|
|
1591
|
+
if (this.isApigwPath(path)) {
|
|
1592
|
+
return `${this.etsBaseUrl}${path}`;
|
|
1593
|
+
}
|
|
1594
|
+
return `${this.baseUrl}${path}`;
|
|
1595
|
+
}
|
|
1596
|
+
async request(path, options = {}, retry = true) {
|
|
1597
|
+
if (retry && isTokenExpiringSoon()) {
|
|
1598
|
+
await refreshToken(this.config);
|
|
1599
|
+
}
|
|
1600
|
+
let token = this.getToken();
|
|
1601
|
+
const url = this.resolveUrl(path);
|
|
1602
|
+
const headers = {
|
|
1603
|
+
Accept: "application/json",
|
|
1604
|
+
...this.isApigwPath(path) ? await this.buildApigwHeaders(options.method ?? "GET", path, token, options.body) : { Authorization: `Bearer ${token}` },
|
|
1605
|
+
Origin: this.webOrigin,
|
|
1606
|
+
...options.headers ?? {}
|
|
1607
|
+
};
|
|
1608
|
+
if (options.body && !headers["Content-Type"]) {
|
|
1609
|
+
headers["Content-Type"] = "application/json";
|
|
1610
|
+
}
|
|
1611
|
+
const res = await fetch(url, {
|
|
1612
|
+
...options,
|
|
1613
|
+
headers
|
|
1614
|
+
});
|
|
1615
|
+
if (res.status === 401 && retry) {
|
|
1616
|
+
const text = await res.text().catch(() => "");
|
|
1617
|
+
const isExpired = text.includes("token_expired");
|
|
1618
|
+
if (isExpired) {
|
|
1619
|
+
const refreshed = await refreshToken(this.config);
|
|
1620
|
+
if (refreshed) {
|
|
1621
|
+
const newToken = this.getToken();
|
|
1622
|
+
const newHeaders = {
|
|
1623
|
+
Accept: "application/json",
|
|
1624
|
+
...this.isApigwPath(path) ? await this.buildApigwHeaders(options.method ?? "GET", path, newToken, options.body) : { Authorization: `Bearer ${newToken}` },
|
|
1625
|
+
Origin: this.webOrigin,
|
|
1626
|
+
...options.headers ?? {}
|
|
1627
|
+
};
|
|
1628
|
+
if (options.body && !newHeaders["Content-Type"]) {
|
|
1629
|
+
newHeaders["Content-Type"] = "application/json";
|
|
1630
|
+
}
|
|
1631
|
+
const retryRes = await fetch(url, { ...options, headers: newHeaders });
|
|
1632
|
+
return this.handleResponse(retryRes);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
throw new Error("Token expired and refresh failed. Please re-authenticate with `express auth qr`.");
|
|
1636
|
+
}
|
|
1637
|
+
return this.handleResponse(res);
|
|
1638
|
+
}
|
|
1639
|
+
async handleResponse(res) {
|
|
1640
|
+
if (res.status === 204) {
|
|
1641
|
+
return void 0;
|
|
1642
|
+
}
|
|
1643
|
+
if (!res.ok) {
|
|
1644
|
+
const text2 = await res.text().catch(() => "");
|
|
1645
|
+
throw new Error(`API error ${res.status}: ${res.statusText}${text2 ? ` \u2014 ${text2.slice(0, 200)}` : ""}`);
|
|
1646
|
+
}
|
|
1647
|
+
const text = await res.text();
|
|
1648
|
+
if (!text) {
|
|
1649
|
+
return void 0;
|
|
1650
|
+
}
|
|
1651
|
+
const data = JSON.parse(text);
|
|
1652
|
+
if (data && typeof data === "object" && "result" in data) {
|
|
1653
|
+
return data.result;
|
|
1654
|
+
}
|
|
1655
|
+
return data;
|
|
1656
|
+
}
|
|
1657
|
+
async buildApigwHeaders(method, path, ctsToken, body) {
|
|
1658
|
+
const rtsToken = getRtsAuthToken() ?? void 0;
|
|
1659
|
+
const etsAuthToken = getEtsAuthToken() ?? void 0;
|
|
1660
|
+
const etsBaseUrl = this.etsBaseUrl;
|
|
1661
|
+
const signed = await signApigwRequest({
|
|
1662
|
+
method,
|
|
1663
|
+
url: path.startsWith("http") ? path : `${etsBaseUrl}${path}`,
|
|
1664
|
+
baseUrl: etsBaseUrl,
|
|
1665
|
+
body,
|
|
1666
|
+
ctsToken,
|
|
1667
|
+
rtsToken,
|
|
1668
|
+
etsAuthToken
|
|
1669
|
+
});
|
|
1670
|
+
return {
|
|
1671
|
+
Authorization: signed.Authorization,
|
|
1672
|
+
"Express-Proxy-Authorization": signed["Express-Proxy-Authorization"],
|
|
1673
|
+
"Express-Request-Nonce": signed["Express-Request-Nonce"],
|
|
1674
|
+
...signed.Digest && { Digest: signed.Digest },
|
|
1675
|
+
Signature: signed.Signature
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
async get(path) {
|
|
1679
|
+
return this.request(path, { method: "GET" });
|
|
1680
|
+
}
|
|
1681
|
+
async post(path, body) {
|
|
1682
|
+
const jsonBody = body ? JSON.stringify(body) : void 0;
|
|
1683
|
+
return this.request(path, {
|
|
1684
|
+
method: "POST",
|
|
1685
|
+
body: jsonBody
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
async put(path, body) {
|
|
1689
|
+
const jsonBody = body ? JSON.stringify(body) : void 0;
|
|
1690
|
+
return this.request(path, {
|
|
1691
|
+
method: "PUT",
|
|
1692
|
+
body: jsonBody
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
async delete(path) {
|
|
1696
|
+
return this.request(path, { method: "DELETE" });
|
|
1697
|
+
}
|
|
1698
|
+
async rawRequest(path, options = {}) {
|
|
1699
|
+
const token = this.getToken();
|
|
1700
|
+
const url = this.resolveUrl(path);
|
|
1701
|
+
const headers = {
|
|
1702
|
+
Accept: "application/json",
|
|
1703
|
+
...this.isApigwPath(path) ? await this.buildApigwHeaders(options.method ?? "GET", path, token, options.body) : { Authorization: `Bearer ${token}` },
|
|
1704
|
+
Origin: this.webOrigin,
|
|
1705
|
+
...options.headers ?? {}
|
|
1706
|
+
};
|
|
1707
|
+
if (options.body && !headers["Content-Type"]) {
|
|
1708
|
+
headers["Content-Type"] = "application/json";
|
|
1709
|
+
}
|
|
1710
|
+
return fetch(url, { ...options, headers });
|
|
1711
|
+
}
|
|
1712
|
+
async downloadFile(url) {
|
|
1713
|
+
const token = this.getToken();
|
|
1714
|
+
const fullUrl = url.startsWith("http") ? url : `${this.baseUrl}${url}`;
|
|
1715
|
+
return fetch(fullUrl, {
|
|
1716
|
+
headers: {
|
|
1717
|
+
Authorization: `Bearer ${token}`,
|
|
1718
|
+
Referer: `${this.webOrigin}/`,
|
|
1719
|
+
Accept: "*/*"
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
isApigwReady() {
|
|
1724
|
+
return loadApigwKeys() !== null;
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
|
|
1728
|
+
// src/cli/output.ts
|
|
1729
|
+
import chalk from "chalk";
|
|
1730
|
+
import Table from "cli-table3";
|
|
1731
|
+
function formatOutput(data, format) {
|
|
1732
|
+
if (format === "json") {
|
|
1733
|
+
return JSON.stringify(data, null, 2);
|
|
1734
|
+
}
|
|
1735
|
+
if (typeof data === "string") return data;
|
|
1736
|
+
if (typeof data === "number" || typeof data === "boolean") return String(data);
|
|
1737
|
+
return JSON.stringify(data, null, 2);
|
|
1738
|
+
}
|
|
1739
|
+
function formatProfileTable(profiles) {
|
|
1740
|
+
const table = new Table({
|
|
1741
|
+
head: ["Name", "Email", "AD Login", "Department", "Position", "HUID"],
|
|
1742
|
+
style: { head: ["cyan"] }
|
|
1743
|
+
});
|
|
1744
|
+
for (const p of profiles) {
|
|
1745
|
+
table.push([
|
|
1746
|
+
p.name ?? "-",
|
|
1747
|
+
p.email ?? "-",
|
|
1748
|
+
p.ad_login ?? "-",
|
|
1749
|
+
p.department ?? "-",
|
|
1750
|
+
p.company_position ?? "-",
|
|
1751
|
+
p.user_huid ? p.user_huid.slice(0, 8) + "..." : "-"
|
|
1752
|
+
]);
|
|
1753
|
+
}
|
|
1754
|
+
return table.toString();
|
|
1755
|
+
}
|
|
1756
|
+
function formatStatusTable(statuses) {
|
|
1757
|
+
const table = new Table({
|
|
1758
|
+
head: ["HUID", "Status", "Last Seen"],
|
|
1759
|
+
style: { head: ["cyan"] }
|
|
1760
|
+
});
|
|
1761
|
+
const statusColors = {
|
|
1762
|
+
online: chalk.green,
|
|
1763
|
+
offline: chalk.gray,
|
|
1764
|
+
away: chalk.yellow,
|
|
1765
|
+
dnd: chalk.red,
|
|
1766
|
+
invisible: chalk.gray
|
|
1767
|
+
};
|
|
1768
|
+
for (const s of statuses) {
|
|
1769
|
+
const color = statusColors[s.status] ?? chalk.white;
|
|
1770
|
+
table.push([
|
|
1771
|
+
s.huid ? s.huid.slice(0, 8) + "..." : "-",
|
|
1772
|
+
color(s.status),
|
|
1773
|
+
s.last_seen ?? "-"
|
|
1774
|
+
]);
|
|
1775
|
+
}
|
|
1776
|
+
return table.toString();
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// src/cli/api.ts
|
|
1780
|
+
function createApiCommand() {
|
|
1781
|
+
const cmd = new Command2("api");
|
|
1782
|
+
cmd.description("Make arbitrary API request").argument("<path>", "API path (e.g. /api/v1/phonebook/profiles/self)").option("-X, --method <method>", "HTTP method", "GET").option("-d, --data <data>", "Request body (JSON)").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (path, opts) => {
|
|
1783
|
+
try {
|
|
1784
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
1785
|
+
let body;
|
|
1786
|
+
if (opts.data) {
|
|
1787
|
+
try {
|
|
1788
|
+
body = JSON.parse(opts.data);
|
|
1789
|
+
} catch {
|
|
1790
|
+
throw new Error("Invalid JSON in --data");
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const method = opts.method.toUpperCase();
|
|
1794
|
+
const res = await client.rawRequest(path, {
|
|
1795
|
+
method,
|
|
1796
|
+
body: body ? JSON.stringify(body) : void 0
|
|
1797
|
+
});
|
|
1798
|
+
const text = await res.text();
|
|
1799
|
+
console.log(`Response: status=${res.status}, content-type=${res.headers.get("content-type")}, body=${text.length} chars`);
|
|
1800
|
+
if (!text) {
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
let data;
|
|
1804
|
+
try {
|
|
1805
|
+
data = JSON.parse(text);
|
|
1806
|
+
} catch {
|
|
1807
|
+
console.log(`Response: status=${res.status}, non-JSON body (${text.length} chars): ${text.slice(0, 500)}`);
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
if (!res.ok) {
|
|
1811
|
+
console.error(`API error ${res.status}:`, JSON.stringify(data, null, 2));
|
|
1812
|
+
process.exit(1);
|
|
1813
|
+
}
|
|
1814
|
+
console.log(formatOutput(data, opts.output));
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
console.error(`Error: ${err.message}`);
|
|
1817
|
+
process.exit(1);
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
return cmd;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// src/cli/config-cmd.ts
|
|
1824
|
+
import { Command as Command3 } from "commander";
|
|
1825
|
+
function createConfigCommand() {
|
|
1826
|
+
const cmd = new Command3("config");
|
|
1827
|
+
cmd.description("Configuration management");
|
|
1828
|
+
cmd.command("get [key]").description("Get config value(s)").option("-o, --output <format>", "Output format", "json").action((key, opts) => {
|
|
1829
|
+
const config = getStoredConfig();
|
|
1830
|
+
const data = key ? { [key]: config[key] } : config;
|
|
1831
|
+
console.log(formatOutput(data, opts?.output ?? "json"));
|
|
1832
|
+
});
|
|
1833
|
+
cmd.command("set <key> <value>").description("Set config value").action((key, value) => {
|
|
1834
|
+
const current = getStoredConfig();
|
|
1835
|
+
let parsed = value;
|
|
1836
|
+
try {
|
|
1837
|
+
parsed = JSON.parse(value);
|
|
1838
|
+
} catch {
|
|
1839
|
+
}
|
|
1840
|
+
setStoredConfig({ ...current, [key]: parsed });
|
|
1841
|
+
console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
|
|
1842
|
+
});
|
|
1843
|
+
cmd.command("list").description("List all config values").option("-o, --output <format>", "Output format", "json").action((opts) => {
|
|
1844
|
+
console.log(formatOutput(getStoredConfig(), opts.output));
|
|
1845
|
+
});
|
|
1846
|
+
cmd.command("reset").description("Reset all configuration and tokens").action(() => {
|
|
1847
|
+
clearAll();
|
|
1848
|
+
console.log("All configuration and tokens cleared.");
|
|
1849
|
+
});
|
|
1850
|
+
return cmd;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/cli/status.ts
|
|
1854
|
+
import { Command as Command4 } from "commander";
|
|
1855
|
+
|
|
1856
|
+
// src/api/user.ts
|
|
1857
|
+
var UserApi = class {
|
|
1858
|
+
constructor(client) {
|
|
1859
|
+
this.client = client;
|
|
1860
|
+
}
|
|
1861
|
+
client;
|
|
1862
|
+
async getSelfProfile() {
|
|
1863
|
+
const data = await this.client.get("/api/v1/phonebook/profiles/self");
|
|
1864
|
+
return data.profile;
|
|
1865
|
+
}
|
|
1866
|
+
async getProfilesByHuid(huids) {
|
|
1867
|
+
const data = await this.client.post("/api/v1/phonebook/cts_profiles/query", { huids });
|
|
1868
|
+
if (!data) return [];
|
|
1869
|
+
const entries = Array.isArray(data) ? data : [];
|
|
1870
|
+
const profiles = [];
|
|
1871
|
+
for (const entry of entries) {
|
|
1872
|
+
const obj = entry;
|
|
1873
|
+
if (Array.isArray(obj.cts_profiles)) {
|
|
1874
|
+
profiles.push(...obj.cts_profiles);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return profiles;
|
|
1878
|
+
}
|
|
1879
|
+
async getUserStatuses(userHuids, short = false) {
|
|
1880
|
+
const keys = loadApigwKeys();
|
|
1881
|
+
const keyId = (keys?.ctsKey ?? keys?.encryptionKey)?.keyId ?? "";
|
|
1882
|
+
return this.client.post("/api/v1/user_statuses/get", {
|
|
1883
|
+
user_huids: userHuids,
|
|
1884
|
+
short,
|
|
1885
|
+
key_id: keyId
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
async getUserStatusHistory(since) {
|
|
1889
|
+
return this.client.post("/api/v1/user_statuses/history", {
|
|
1890
|
+
since: since ?? null
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
|
|
1895
|
+
// src/cli/status.ts
|
|
1896
|
+
function createStatusCommand() {
|
|
1897
|
+
const cmd = new Command4("status");
|
|
1898
|
+
cmd.description("Get user statuses");
|
|
1899
|
+
cmd.command("self").description("Get own status").option("-s, --short", "Short format", false).option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "table").action(async (opts) => {
|
|
1900
|
+
try {
|
|
1901
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
1902
|
+
const api = new UserApi(client);
|
|
1903
|
+
const profile = await api.getSelfProfile();
|
|
1904
|
+
const result = await api.getUserStatuses([profile.user_huid], opts.short);
|
|
1905
|
+
if (opts.output === "json") {
|
|
1906
|
+
console.log(formatOutput(result, "json"));
|
|
1907
|
+
} else {
|
|
1908
|
+
console.log(formatStatusTable(result.user_statuses));
|
|
1909
|
+
}
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
console.error(`Error: ${err.message}`);
|
|
1912
|
+
process.exit(1);
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
cmd.command("get <huids...>").description("Get statuses for users by HUID").option("-s, --short", "Short format", false).option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "table").action(async (huids, opts) => {
|
|
1916
|
+
try {
|
|
1917
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
1918
|
+
const api = new UserApi(client);
|
|
1919
|
+
const result = await api.getUserStatuses(huids, opts.short);
|
|
1920
|
+
if (opts.output === "json") {
|
|
1921
|
+
console.log(formatOutput(result, "json"));
|
|
1922
|
+
} else {
|
|
1923
|
+
console.log(formatStatusTable(result.user_statuses));
|
|
1924
|
+
}
|
|
1925
|
+
} catch (err) {
|
|
1926
|
+
console.error(`Error: ${err.message}`);
|
|
1927
|
+
process.exit(1);
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
cmd.command("history").description("Get status history").option("--since <iso-date>", "Since date (ISO 8601)").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (opts) => {
|
|
1931
|
+
try {
|
|
1932
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
1933
|
+
const api = new UserApi(client);
|
|
1934
|
+
const history = await api.getUserStatusHistory(opts.since);
|
|
1935
|
+
console.log(formatOutput(history, opts.output));
|
|
1936
|
+
} catch (err) {
|
|
1937
|
+
console.error(`Error: ${err.message}`);
|
|
1938
|
+
process.exit(1);
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
return cmd;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// src/cli/contacts.ts
|
|
1945
|
+
import { Command as Command5 } from "commander";
|
|
1946
|
+
import chalk2 from "chalk";
|
|
1947
|
+
|
|
1948
|
+
// src/api/websocket.ts
|
|
1949
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1950
|
+
async function fetchChatListViaWebSocket(params) {
|
|
1951
|
+
const { host, ctsToken, encryptionKeyId, timeoutMs = 15e3 } = params;
|
|
1952
|
+
const instanceId = randomUUID3();
|
|
1953
|
+
const wsUrl = `wss://${host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${encryptionKeyId}&version=6&background=false&voex_unencrypted=true&voex_multistream=true&voex_audio_bridge=true&instance_id=${instanceId}`;
|
|
1954
|
+
return new Promise((resolve, reject) => {
|
|
1955
|
+
let settled = false;
|
|
1956
|
+
const done = (fn) => {
|
|
1957
|
+
if (!settled) {
|
|
1958
|
+
settled = true;
|
|
1959
|
+
fn();
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
const timer = setTimeout(() => {
|
|
1963
|
+
try {
|
|
1964
|
+
ws.close();
|
|
1965
|
+
} catch {
|
|
1966
|
+
}
|
|
1967
|
+
done(() => reject(new Error("WebSocket timeout: no chat_list response")));
|
|
1968
|
+
}, timeoutMs);
|
|
1969
|
+
const hostParts = host.split(".");
|
|
1970
|
+
const webOrigin = `https://${hostParts.length > 2 ? hostParts.slice(1).join(".") : host}`;
|
|
1971
|
+
const ws = new WebSocket(wsUrl, {
|
|
1972
|
+
headers: {
|
|
1973
|
+
Origin: webOrigin,
|
|
1974
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
|
|
1975
|
+
}
|
|
1976
|
+
});
|
|
1977
|
+
const authRef = 0;
|
|
1978
|
+
const chatListRef = 1;
|
|
1979
|
+
const send = (msg) => {
|
|
1980
|
+
ws.send(JSON.stringify(msg));
|
|
1981
|
+
};
|
|
1982
|
+
ws.addEventListener("open", () => {
|
|
1983
|
+
send({ topic: "phoenix", event: "authenticate", payload: { token: ctsToken }, ref: authRef });
|
|
1984
|
+
});
|
|
1985
|
+
ws.addEventListener("message", (event) => {
|
|
1986
|
+
let msg;
|
|
1987
|
+
try {
|
|
1988
|
+
msg = JSON.parse(event.data);
|
|
1989
|
+
} catch {
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
if (msg.ref === authRef && msg.topic === "phoenix" && msg.event === "phx_reply") {
|
|
1993
|
+
if (msg.payload.status !== "ok") {
|
|
1994
|
+
clearTimeout(timer);
|
|
1995
|
+
ws.close();
|
|
1996
|
+
done(() => reject(new Error(`WebSocket auth failed: ${JSON.stringify(msg.payload.response)}`)));
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
send({ topic: "system", event: "chat_list", payload: { since: null, request_version: 6 }, ref: chatListRef });
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
if (msg.ref === chatListRef && msg.topic === "system" && msg.event === "phx_reply") {
|
|
2003
|
+
if (msg.payload.status !== "ok") {
|
|
2004
|
+
clearTimeout(timer);
|
|
2005
|
+
ws.close();
|
|
2006
|
+
done(() => reject(new Error(`WebSocket chat_list failed: ${JSON.stringify(msg.payload.response)}`)));
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const response = msg.payload.response;
|
|
2010
|
+
if (Array.isArray(response.chat_list)) {
|
|
2011
|
+
clearTimeout(timer);
|
|
2012
|
+
ws.close();
|
|
2013
|
+
done(() => resolve(response.chat_list));
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
ws.addEventListener("error", (err) => {
|
|
2018
|
+
clearTimeout(timer);
|
|
2019
|
+
done(() => reject(new Error(`WebSocket connection error: ${String(err)}`)));
|
|
2020
|
+
});
|
|
2021
|
+
ws.addEventListener("close", (event) => {
|
|
2022
|
+
clearTimeout(timer);
|
|
2023
|
+
done(() => reject(new Error(`WebSocket closed before completion (code=${event.code})`)));
|
|
2024
|
+
});
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// src/api/chats.ts
|
|
2029
|
+
var ChatsApi = class {
|
|
2030
|
+
constructor(client) {
|
|
2031
|
+
this.client = client;
|
|
2032
|
+
}
|
|
2033
|
+
client;
|
|
2034
|
+
async listChats() {
|
|
2035
|
+
const config = loadConfig();
|
|
2036
|
+
const host = new URL(getBaseUrl(config)).hostname;
|
|
2037
|
+
const ctsToken = getAuthToken();
|
|
2038
|
+
const keys = loadApigwKeys();
|
|
2039
|
+
const ctsKeyId = (keys?.ctsKey ?? keys?.encryptionKey)?.keyId;
|
|
2040
|
+
if (ctsToken && ctsKeyId) {
|
|
2041
|
+
try {
|
|
2042
|
+
return await fetchChatListViaWebSocket({
|
|
2043
|
+
host,
|
|
2044
|
+
ctsToken,
|
|
2045
|
+
encryptionKeyId: ctsKeyId
|
|
2046
|
+
});
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
2049
|
+
console.error(` [DEBUG] WebSocket failed, falling back to directory: ${err.message}`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return this.listChatsViaDirectory();
|
|
2054
|
+
}
|
|
2055
|
+
async listChatsViaDirectory() {
|
|
2056
|
+
const data = await this.client.get("/api/v1/corporate_directory/entries");
|
|
2057
|
+
const entries = data?.entries ?? [];
|
|
2058
|
+
const chatIds = entries.filter((e) => e.entry_type === "chat" && !e.deleted).map((e) => e.entry_id);
|
|
2059
|
+
if (chatIds.length === 0) return [];
|
|
2060
|
+
const result = await this.client.post("/api/v1/messaging/chats/open_chats_list", {
|
|
2061
|
+
chat_ids: chatIds
|
|
2062
|
+
});
|
|
2063
|
+
return result?.open_chats ?? [];
|
|
2064
|
+
}
|
|
2065
|
+
async getChatInfo(chatId) {
|
|
2066
|
+
const data = await this.client.post("/api/v1/messaging/chats/open_chats_list", {
|
|
2067
|
+
chat_ids: [chatId]
|
|
2068
|
+
});
|
|
2069
|
+
return data?.open_chats?.[0] ?? null;
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
// src/api/phonebook.ts
|
|
2074
|
+
var PhonebookApi = class {
|
|
2075
|
+
constructor(client) {
|
|
2076
|
+
this.client = client;
|
|
2077
|
+
}
|
|
2078
|
+
client;
|
|
2079
|
+
async getContacts() {
|
|
2080
|
+
const data = await this.client.get("/api/v1/apigw/api/v1/phonebook/contacts");
|
|
2081
|
+
if (!data) return [];
|
|
2082
|
+
if (Array.isArray(data)) return data;
|
|
2083
|
+
if (typeof data === "object" && data !== null) {
|
|
2084
|
+
const obj = data;
|
|
2085
|
+
if (Array.isArray(obj.contacts)) return obj.contacts;
|
|
2086
|
+
if (Array.isArray(obj.users)) return obj.users;
|
|
2087
|
+
if (Array.isArray(obj.result)) return obj.result;
|
|
2088
|
+
}
|
|
2089
|
+
return [];
|
|
2090
|
+
}
|
|
2091
|
+
async getContactChanges(since, udid = "latest_mobile") {
|
|
2092
|
+
return this.client.get(
|
|
2093
|
+
`/api/v1/apigw/api/v4/phonebook/user_contacts_changes?since=${encodeURIComponent(since)}&udid=${udid}`
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
async getEncryptedEntries(since, udid = "latest_mobile") {
|
|
2097
|
+
return this.client.get(
|
|
2098
|
+
`/api/v1/apigw/api/v4/phonebook/encrypted_entries?since=${encodeURIComponent(since)}&udid=${udid}`
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
async getCorporateDirectory(since) {
|
|
2102
|
+
const params = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
2103
|
+
const data = await this.client.get(`/api/v1/corporate_directory/entries${params}`);
|
|
2104
|
+
if (!data) return { entries: [] };
|
|
2105
|
+
if (typeof data === "object" && data !== null && "entries" in data) return data;
|
|
2106
|
+
return data;
|
|
2107
|
+
}
|
|
2108
|
+
async searchUsers(query, limit = 20) {
|
|
2109
|
+
return this.searchUsersPhonebook(query, limit);
|
|
2110
|
+
}
|
|
2111
|
+
async searchUsersPhonebook(query, limit) {
|
|
2112
|
+
const data = await this.client.get(
|
|
2113
|
+
`/api/v3/phonebook/search?query=${encodeURIComponent(query)}&active=true&disable_fuzzy_search=false`
|
|
2114
|
+
);
|
|
2115
|
+
if (!data || typeof data !== "object") return [];
|
|
2116
|
+
const obj = data;
|
|
2117
|
+
const result = obj.result;
|
|
2118
|
+
const phonebook = result?.phonebook ?? [];
|
|
2119
|
+
const huids = [
|
|
2120
|
+
...new Set(
|
|
2121
|
+
phonebook.flatMap((entry) => entry.contacts ?? []).map((c) => c.user_huid).filter(Boolean)
|
|
2122
|
+
)
|
|
2123
|
+
].slice(0, limit);
|
|
2124
|
+
if (huids.length === 0) return [];
|
|
2125
|
+
const raw = await this.client.rawRequest("/api/v1/phonebook/cts_profiles/query", {
|
|
2126
|
+
method: "POST",
|
|
2127
|
+
body: JSON.stringify({ huids })
|
|
2128
|
+
});
|
|
2129
|
+
const profileData = await raw.json();
|
|
2130
|
+
const entries = profileData.result ?? [];
|
|
2131
|
+
const profiles = [];
|
|
2132
|
+
for (const entry of entries) {
|
|
2133
|
+
profiles.push(...entry.cts_profiles ?? []);
|
|
2134
|
+
}
|
|
2135
|
+
return profiles.slice(0, limit);
|
|
2136
|
+
}
|
|
2137
|
+
async searchUsersApigw(query, limit, contacts) {
|
|
2138
|
+
const lowerQuery = query.toLowerCase();
|
|
2139
|
+
const matched = contacts.filter((c) => {
|
|
2140
|
+
const searchable = [c.name, c.email, c.ad_login, c.department, c.company_position, c.phone, c.ip_phone].filter(Boolean).join(" ").toLowerCase();
|
|
2141
|
+
return searchable.includes(lowerQuery);
|
|
2142
|
+
}).slice(0, limit);
|
|
2143
|
+
if (matched.length > 0) {
|
|
2144
|
+
const huids = matched.map((c) => c.huid).filter(Boolean);
|
|
2145
|
+
if (huids.length > 0) {
|
|
2146
|
+
const raw = await this.client.rawRequest("/api/v1/phonebook/cts_profiles/query", {
|
|
2147
|
+
method: "POST",
|
|
2148
|
+
body: JSON.stringify({ huids })
|
|
2149
|
+
});
|
|
2150
|
+
const data = await raw.json();
|
|
2151
|
+
const entries = data.result ?? (Array.isArray(data) ? data : []);
|
|
2152
|
+
const profiles = [];
|
|
2153
|
+
for (const entry of entries) {
|
|
2154
|
+
profiles.push(...entry.cts_profiles ?? []);
|
|
2155
|
+
}
|
|
2156
|
+
return profiles.slice(0, limit);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
return [];
|
|
2160
|
+
}
|
|
2161
|
+
async searchUsersDirectory(query, limit) {
|
|
2162
|
+
const dirData = await this.getCorporateDirectory();
|
|
2163
|
+
const entries = dirData?.entries ?? [];
|
|
2164
|
+
const userIds = entries.filter((e) => e.entry_type === "user" && !e.deleted).map((e) => e.entry_id);
|
|
2165
|
+
if (userIds.length === 0) {
|
|
2166
|
+
return [];
|
|
2167
|
+
}
|
|
2168
|
+
const batchSize = 100;
|
|
2169
|
+
const allProfiles = [];
|
|
2170
|
+
const lowerQuery = query.toLowerCase();
|
|
2171
|
+
for (let i = 0; i < userIds.length && allProfiles.length < limit; i += batchSize) {
|
|
2172
|
+
const batch = userIds.slice(i, i + batchSize);
|
|
2173
|
+
const raw = await this.client.rawRequest("/api/v1/phonebook/cts_profiles/query", {
|
|
2174
|
+
method: "POST",
|
|
2175
|
+
body: JSON.stringify({ huids: batch })
|
|
2176
|
+
});
|
|
2177
|
+
const data = await raw.json();
|
|
2178
|
+
const entries2 = data.result ?? (Array.isArray(data) ? data : []);
|
|
2179
|
+
for (const entry of entries2) {
|
|
2180
|
+
for (const p of entry.cts_profiles ?? []) {
|
|
2181
|
+
const searchable = [
|
|
2182
|
+
p.name,
|
|
2183
|
+
p.email,
|
|
2184
|
+
p.ad_login,
|
|
2185
|
+
p.department,
|
|
2186
|
+
p.company_position,
|
|
2187
|
+
p.phone,
|
|
2188
|
+
p.ip_phone
|
|
2189
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
2190
|
+
if (searchable.includes(lowerQuery)) {
|
|
2191
|
+
allProfiles.push(p);
|
|
2192
|
+
if (allProfiles.length >= limit) return allProfiles;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
return allProfiles;
|
|
2198
|
+
}
|
|
2199
|
+
};
|
|
2200
|
+
|
|
2201
|
+
// src/cli/contacts.ts
|
|
2202
|
+
function createContactsCommand() {
|
|
2203
|
+
const cmd = new Command5("contacts");
|
|
2204
|
+
cmd.description("Contacts and phonebook");
|
|
2205
|
+
cmd.command("self").description("Get own profile").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "table").action(async (opts) => {
|
|
2206
|
+
try {
|
|
2207
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2208
|
+
const api = new UserApi(client);
|
|
2209
|
+
const profile = await api.getSelfProfile();
|
|
2210
|
+
if (opts.output === "json") {
|
|
2211
|
+
console.log(formatOutput(profile, "json"));
|
|
2212
|
+
} else {
|
|
2213
|
+
console.log(formatProfileTable([profile]));
|
|
2214
|
+
}
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
console.error(`Error: ${err.message}`);
|
|
2217
|
+
process.exit(1);
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
cmd.command("query <huids...>").description("Query profiles by HUIDs").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "table").action(async (huids, opts) => {
|
|
2221
|
+
try {
|
|
2222
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2223
|
+
const api = new UserApi(client);
|
|
2224
|
+
const profiles = await api.getProfilesByHuid(huids);
|
|
2225
|
+
if (opts.output === "json") {
|
|
2226
|
+
console.log(formatOutput(profiles, "json"));
|
|
2227
|
+
} else {
|
|
2228
|
+
console.log(formatProfileTable(profiles));
|
|
2229
|
+
}
|
|
2230
|
+
} catch (err) {
|
|
2231
|
+
console.error(`Error: ${err.message}`);
|
|
2232
|
+
process.exit(1);
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
cmd.command("search <query>").description("Search users by name/email/department (apigw or directory)").option("-l, --limit <n>", "Max results", "20").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "table").action(async (query, opts) => {
|
|
2236
|
+
try {
|
|
2237
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2238
|
+
const limit = parseInt(opts.limit, 10);
|
|
2239
|
+
const phonebook = new PhonebookApi(client);
|
|
2240
|
+
let profiles = await phonebook.searchUsers(query, limit);
|
|
2241
|
+
if (profiles.length === 0) {
|
|
2242
|
+
try {
|
|
2243
|
+
const userApi = new UserApi(client);
|
|
2244
|
+
const chatsApi = new ChatsApi(client);
|
|
2245
|
+
const [allChats, self] = await Promise.all([chatsApi.listChats(), userApi.getSelfProfile()]);
|
|
2246
|
+
const myHuid = self.user_huid;
|
|
2247
|
+
const allMemberHuids = [...new Set(
|
|
2248
|
+
allChats.flatMap((c) => c.member_huids ?? []).filter((h) => h !== myHuid)
|
|
2249
|
+
)];
|
|
2250
|
+
if (allMemberHuids.length > 0) {
|
|
2251
|
+
const allProfiles = await userApi.getProfilesByHuid(allMemberHuids);
|
|
2252
|
+
const lowerQuery = query.toLowerCase();
|
|
2253
|
+
profiles = allProfiles.filter(
|
|
2254
|
+
(p) => [p.name, p.email, p.ad_login, p.department, p.company_position].filter(Boolean).join(" ").toLowerCase().includes(lowerQuery)
|
|
2255
|
+
).slice(0, limit);
|
|
2256
|
+
}
|
|
2257
|
+
} catch (err) {
|
|
2258
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
2259
|
+
console.error(` [DEBUG] Chat member search failed: ${err.message}`);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
if (profiles.length === 0) {
|
|
2264
|
+
console.log(chalk2.yellow("No users found."));
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (opts.output === "json") {
|
|
2268
|
+
console.log(formatOutput(profiles, "json"));
|
|
2269
|
+
} else {
|
|
2270
|
+
console.log(formatProfileTable(profiles));
|
|
2271
|
+
}
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
console.error(`Error: ${err.message}`);
|
|
2274
|
+
process.exit(1);
|
|
2275
|
+
}
|
|
2276
|
+
});
|
|
2277
|
+
cmd.command("list").description("List all contacts (requires 'express auth login')").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (opts) => {
|
|
2278
|
+
try {
|
|
2279
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2280
|
+
if (!client.isApigwReady()) {
|
|
2281
|
+
console.error(chalk2.yellow("Error: This command requires apigw auth. Run 'express auth login' first."));
|
|
2282
|
+
process.exit(1);
|
|
2283
|
+
}
|
|
2284
|
+
const api = new PhonebookApi(client);
|
|
2285
|
+
const contacts = await api.getContacts();
|
|
2286
|
+
if (!contacts || contacts.length === 0) {
|
|
2287
|
+
console.log(chalk2.yellow("No contacts found. The apigw phonebook endpoint may not have synced contacts yet."));
|
|
2288
|
+
console.log(chalk2.yellow("Use 'express contacts search <query>' to search via corporate directory."));
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
console.log(formatOutput(contacts, opts.output));
|
|
2292
|
+
} catch (err) {
|
|
2293
|
+
console.error(`Error: ${err.message}`);
|
|
2294
|
+
process.exit(1);
|
|
2295
|
+
}
|
|
2296
|
+
});
|
|
2297
|
+
cmd.command("directory").description("Corporate directory entries").option("--since <iso-date>", "Since date (ISO 8601)").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (opts) => {
|
|
2298
|
+
try {
|
|
2299
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2300
|
+
const api = new PhonebookApi(client);
|
|
2301
|
+
const entries = await api.getCorporateDirectory(opts.since);
|
|
2302
|
+
console.log(formatOutput(entries, opts.output));
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
console.error(`Error: ${err.message}`);
|
|
2305
|
+
process.exit(1);
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
return cmd;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// src/cli/settings.ts
|
|
2312
|
+
import { Command as Command6 } from "commander";
|
|
2313
|
+
|
|
2314
|
+
// src/api/settings.ts
|
|
2315
|
+
var SettingsApi = class {
|
|
2316
|
+
constructor(client) {
|
|
2317
|
+
this.client = client;
|
|
2318
|
+
}
|
|
2319
|
+
client;
|
|
2320
|
+
async getServerMeta() {
|
|
2321
|
+
return this.client.get("/api/v1/settings/server/meta/");
|
|
2322
|
+
}
|
|
2323
|
+
async getSettings(since) {
|
|
2324
|
+
const params = since ? `?since=${encodeURIComponent(since)}` : "";
|
|
2325
|
+
return this.client.get(`/api/v2/settings/${params}`);
|
|
2326
|
+
}
|
|
2327
|
+
async getRoles(deviceHash) {
|
|
2328
|
+
return this.client.get(`/api/v1/roles/rules?device_hash=${encodeURIComponent(deviceHash)}`);
|
|
2329
|
+
}
|
|
2330
|
+
};
|
|
2331
|
+
|
|
2332
|
+
// src/cli/settings.ts
|
|
2333
|
+
function createSettingsCommand() {
|
|
2334
|
+
const cmd = new Command6("settings");
|
|
2335
|
+
cmd.description("Server settings and meta");
|
|
2336
|
+
cmd.command("meta").description("Get server metadata").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (opts) => {
|
|
2337
|
+
try {
|
|
2338
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2339
|
+
const api = new SettingsApi(client);
|
|
2340
|
+
const meta = await api.getServerMeta();
|
|
2341
|
+
console.log(formatOutput(meta, opts.output));
|
|
2342
|
+
} catch (err) {
|
|
2343
|
+
console.error(`Error: ${err.message}`);
|
|
2344
|
+
process.exit(1);
|
|
2345
|
+
}
|
|
2346
|
+
});
|
|
2347
|
+
cmd.command("get").description("Get settings").option("--since <iso-date>", "Since date (ISO 8601)").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (opts) => {
|
|
2348
|
+
try {
|
|
2349
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2350
|
+
const api = new SettingsApi(client);
|
|
2351
|
+
const settings = await api.getSettings(opts.since);
|
|
2352
|
+
console.log(formatOutput(settings, opts.output));
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
console.error(`Error: ${err.message}`);
|
|
2355
|
+
process.exit(1);
|
|
2356
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
return cmd;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/cli/chats.ts
|
|
2362
|
+
import { Command as Command7 } from "commander";
|
|
2363
|
+
import Table2 from "cli-table3";
|
|
2364
|
+
import chalk3 from "chalk";
|
|
2365
|
+
async function resolveDmNames(chats, client) {
|
|
2366
|
+
const dmChats = chats.filter((c) => c.chat_type === "chat" && c.member_huids?.length);
|
|
2367
|
+
if (dmChats.length === 0) return;
|
|
2368
|
+
try {
|
|
2369
|
+
const userApi = new UserApi(client);
|
|
2370
|
+
const self = await userApi.getSelfProfile();
|
|
2371
|
+
const myHuid = self.user_huid;
|
|
2372
|
+
const otherHuids = [...new Set(
|
|
2373
|
+
dmChats.flatMap((c) => (c.member_huids ?? []).filter((h) => h !== myHuid))
|
|
2374
|
+
)];
|
|
2375
|
+
if (otherHuids.length === 0) return;
|
|
2376
|
+
const profiles = await userApi.getProfilesByHuid(otherHuids);
|
|
2377
|
+
const nameMap = new Map(profiles.map((p) => [p.user_huid, p.name]));
|
|
2378
|
+
for (const chat of chats) {
|
|
2379
|
+
if (chat.chat_type === "chat" && chat.member_huids) {
|
|
2380
|
+
const otherHuid = chat.member_huids.find((h) => h !== myHuid);
|
|
2381
|
+
if (otherHuid) chat.name = nameMap.get(otherHuid) ?? chat.name;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
if (process.env.EXPRESS_DEBUG) {
|
|
2386
|
+
console.error(` [DEBUG] Failed to resolve DM names: ${err.message}`);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
function typeLabel(chatType) {
|
|
2391
|
+
if (chatType === "channel" || chatType === "global") return chalk3.magenta(chatType);
|
|
2392
|
+
if (chatType === "group_chat") return chalk3.blue("group");
|
|
2393
|
+
if (chatType === "chat") return chalk3.green("dm");
|
|
2394
|
+
return chalk3.gray(chatType);
|
|
2395
|
+
}
|
|
2396
|
+
function memberCount(c) {
|
|
2397
|
+
return c.members_count != null ? String(c.members_count) : c.member_huids?.length != null ? String(c.member_huids.length) : "-";
|
|
2398
|
+
}
|
|
2399
|
+
function createChatsCommand() {
|
|
2400
|
+
const cmd = new Command7("chats");
|
|
2401
|
+
cmd.description("Chat management");
|
|
2402
|
+
cmd.command("list").description("List all chats (uses WebSocket for full list)").option("--host <host>", "eXpress host").option("--type <type>", "Filter by type: dm, group, channel, all", "all").option("-o, --output <format>", "Output format", "table").action(async (opts) => {
|
|
2403
|
+
try {
|
|
2404
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2405
|
+
const api = new ChatsApi(client);
|
|
2406
|
+
let chats = await api.listChats();
|
|
2407
|
+
await resolveDmNames(chats, client);
|
|
2408
|
+
if (opts.type !== "all") {
|
|
2409
|
+
const typeMap = {
|
|
2410
|
+
dm: ["chat"],
|
|
2411
|
+
group: ["group_chat"],
|
|
2412
|
+
channel: ["channel", "global"]
|
|
2413
|
+
};
|
|
2414
|
+
const allowed = typeMap[opts.type] ?? [];
|
|
2415
|
+
chats = chats.filter((c) => allowed.includes(c.chat_type));
|
|
2416
|
+
}
|
|
2417
|
+
if (opts.output === "json") {
|
|
2418
|
+
console.log(formatOutput(chats, "json"));
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
const table = new Table2({
|
|
2422
|
+
head: ["Name", "Type", "Members", "Chat ID"],
|
|
2423
|
+
style: { head: ["cyan"] }
|
|
2424
|
+
});
|
|
2425
|
+
for (const c of chats) {
|
|
2426
|
+
table.push([c.name ?? "-", typeLabel(c.chat_type), memberCount(c), c.group_chat_id.slice(0, 8) + "..."]);
|
|
2427
|
+
}
|
|
2428
|
+
console.log(table.toString());
|
|
2429
|
+
console.log(chalk3.gray(` ${chats.length} chats`));
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
console.error(`Error: ${err.message}`);
|
|
2432
|
+
process.exit(1);
|
|
2433
|
+
}
|
|
2434
|
+
});
|
|
2435
|
+
cmd.command("find <query>").description("Find chats by name (case-insensitive), shows full IDs").option("--host <host>", "eXpress host").option("--type <type>", "Filter by type: dm, group, channel, all", "all").option("-o, --output <format>", "Output format", "table").action(async (query, opts) => {
|
|
2436
|
+
try {
|
|
2437
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2438
|
+
const api = new ChatsApi(client);
|
|
2439
|
+
let chats = await api.listChats();
|
|
2440
|
+
await resolveDmNames(chats, client);
|
|
2441
|
+
const lowerQuery = query.toLowerCase();
|
|
2442
|
+
chats = chats.filter((c) => (c.name ?? "").toLowerCase().includes(lowerQuery));
|
|
2443
|
+
if (opts.type !== "all") {
|
|
2444
|
+
const typeMap = {
|
|
2445
|
+
dm: ["chat"],
|
|
2446
|
+
group: ["group_chat"],
|
|
2447
|
+
channel: ["channel", "global"]
|
|
2448
|
+
};
|
|
2449
|
+
const allowed = typeMap[opts.type] ?? [];
|
|
2450
|
+
chats = chats.filter((c) => allowed.includes(c.chat_type));
|
|
2451
|
+
}
|
|
2452
|
+
if (chats.length === 0) {
|
|
2453
|
+
console.log(chalk3.yellow(`No chats matching "${query}".`));
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (opts.output === "json") {
|
|
2457
|
+
console.log(formatOutput(chats, "json"));
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
const table = new Table2({
|
|
2461
|
+
head: ["Name", "Type", "Members", "Chat ID (full)"],
|
|
2462
|
+
style: { head: ["cyan"] }
|
|
2463
|
+
});
|
|
2464
|
+
for (const c of chats) {
|
|
2465
|
+
table.push([c.name ?? "-", typeLabel(c.chat_type), memberCount(c), c.group_chat_id]);
|
|
2466
|
+
}
|
|
2467
|
+
console.log(table.toString());
|
|
2468
|
+
} catch (err) {
|
|
2469
|
+
console.error(`Error: ${err.message}`);
|
|
2470
|
+
process.exit(1);
|
|
2471
|
+
}
|
|
2472
|
+
});
|
|
2473
|
+
cmd.command("info <chat-id>").description("Get chat details").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (chatId, opts) => {
|
|
2474
|
+
try {
|
|
2475
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2476
|
+
const api = new ChatsApi(client);
|
|
2477
|
+
const chat = await api.getChatInfo(chatId);
|
|
2478
|
+
if (!chat) {
|
|
2479
|
+
console.error(`Chat not found: ${chatId}`);
|
|
2480
|
+
process.exit(1);
|
|
2481
|
+
}
|
|
2482
|
+
console.log(formatOutput(chat, opts.output));
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
console.error(`Error: ${err.message}`);
|
|
2485
|
+
process.exit(1);
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
2488
|
+
return cmd;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// src/cli/send.ts
|
|
2492
|
+
import { Command as Command8 } from "commander";
|
|
2493
|
+
|
|
2494
|
+
// src/api/messaging.ts
|
|
2495
|
+
var MessagingApi = class {
|
|
2496
|
+
constructor(client) {
|
|
2497
|
+
this.client = client;
|
|
2498
|
+
}
|
|
2499
|
+
client;
|
|
2500
|
+
async sendMessage(params) {
|
|
2501
|
+
const payload = {
|
|
2502
|
+
group_chat_id: params.groupChatId,
|
|
2503
|
+
notification: {
|
|
2504
|
+
status: "ok",
|
|
2505
|
+
body: params.body,
|
|
2506
|
+
...params.metadata && { metadata: params.metadata },
|
|
2507
|
+
...params.mentions && { mentions: params.mentions },
|
|
2508
|
+
...params.stealthMode && {
|
|
2509
|
+
opts: { stealth_mode: true }
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
};
|
|
2513
|
+
return this.client.post("/api/v4/botx/notifications/direct", payload);
|
|
2514
|
+
}
|
|
2515
|
+
async sendFile(params) {
|
|
2516
|
+
const payload = {
|
|
2517
|
+
group_chat_id: params.groupChatId,
|
|
2518
|
+
notification: {
|
|
2519
|
+
status: "ok",
|
|
2520
|
+
body: params.caption ?? ""
|
|
2521
|
+
},
|
|
2522
|
+
file: {
|
|
2523
|
+
file_name: params.fileName,
|
|
2524
|
+
data: params.fileData
|
|
2525
|
+
}
|
|
2526
|
+
};
|
|
2527
|
+
return this.client.post("/api/v4/botx/notifications/direct", payload);
|
|
2528
|
+
}
|
|
2529
|
+
};
|
|
2530
|
+
|
|
2531
|
+
// src/api/messaging-ws.ts
|
|
2532
|
+
import { randomBytes as randomBytes3, randomUUID as randomUUID4 } from "crypto";
|
|
2533
|
+
import nacl4 from "tweetnacl";
|
|
2534
|
+
import sodium from "libsodium-wrappers-sumo";
|
|
2535
|
+
function buildTextPayload(text, fromHuid, chatId) {
|
|
2536
|
+
return JSON.stringify({
|
|
2537
|
+
type: "text",
|
|
2538
|
+
msg_id: randomUUID4(),
|
|
2539
|
+
from: fromHuid,
|
|
2540
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2541
|
+
group_chat_id: chatId,
|
|
2542
|
+
lat: 0,
|
|
2543
|
+
lng: 0,
|
|
2544
|
+
link_meta_disabled: false,
|
|
2545
|
+
stealth_forwarding: false,
|
|
2546
|
+
body: text
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
function payloadAad(chatId, syncId) {
|
|
2550
|
+
return new Uint8Array(Buffer.from(`${chatId}:${syncId}`));
|
|
2551
|
+
}
|
|
2552
|
+
async function sendMessageViaWebSocket(params) {
|
|
2553
|
+
const { client, chatId, body, timeoutMs = 2e4 } = params;
|
|
2554
|
+
await sodium.ready;
|
|
2555
|
+
const apigwKeys = loadApigwKeys();
|
|
2556
|
+
if (!apigwKeys) throw new Error("No apigw keys. Run 'express auth login' first.");
|
|
2557
|
+
const config = loadConfig();
|
|
2558
|
+
const host = new URL(getBaseUrl(config)).hostname;
|
|
2559
|
+
const webOrigin = getWebOrigin(config);
|
|
2560
|
+
const ctsToken = getAuthToken();
|
|
2561
|
+
const ctsKey = apigwKeys.ctsKey ?? apigwKeys.encryptionKey;
|
|
2562
|
+
const encKeyId = ctsKey.keyId;
|
|
2563
|
+
const participantKeyIds = await getChatKeyIds(host, webOrigin, ctsToken, encKeyId, chatId);
|
|
2564
|
+
const kdcKeys = await client.get(
|
|
2565
|
+
`/api/v1/kdc/keys/?ids=${participantKeyIds.join(",")}`
|
|
2566
|
+
) ?? [];
|
|
2567
|
+
const publicKeys = kdcKeys.filter((k) => k.kind === "cts");
|
|
2568
|
+
const symmetricKey = randomBytes3(32);
|
|
2569
|
+
const syncId = randomUUID4();
|
|
2570
|
+
const encryptedKeys = publicKeys.map((kdcKey) => {
|
|
2571
|
+
const recipientPubKey = new Uint8Array(Buffer.from(kdcKey.body, "base64"));
|
|
2572
|
+
const nonce = randomBytes3(nacl4.box.nonceLength);
|
|
2573
|
+
const ciphertext2 = nacl4.box(
|
|
2574
|
+
symmetricKey,
|
|
2575
|
+
nonce,
|
|
2576
|
+
recipientPubKey,
|
|
2577
|
+
ctsKey.privateKey
|
|
2578
|
+
);
|
|
2579
|
+
if (!ciphertext2) throw new Error(`Failed to encrypt key for ${kdcKey.id}`);
|
|
2580
|
+
const combined = Buffer.concat([nonce, Buffer.from(ciphertext2)]);
|
|
2581
|
+
return {
|
|
2582
|
+
key_id: kdcKey.id,
|
|
2583
|
+
key: combined.toString("base64"),
|
|
2584
|
+
algo: "xsalsa20:xchacha20_aead_ietf"
|
|
2585
|
+
};
|
|
2586
|
+
});
|
|
2587
|
+
const selfHuid = (await new UserApi(client).getSelfProfile()).user_huid;
|
|
2588
|
+
const plaintext = new Uint8Array(Buffer.from(buildTextPayload(body, selfHuid, chatId)));
|
|
2589
|
+
const msgNonce = randomBytes3(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
2590
|
+
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
|
2591
|
+
plaintext,
|
|
2592
|
+
payloadAad(chatId, syncId),
|
|
2593
|
+
null,
|
|
2594
|
+
new Uint8Array(msgNonce),
|
|
2595
|
+
new Uint8Array(symmetricKey)
|
|
2596
|
+
);
|
|
2597
|
+
const encryptedPayload = Buffer.concat([msgNonce, Buffer.from(ciphertext)]).toString("base64");
|
|
2598
|
+
const signingKey = apigwKeys.signingKey;
|
|
2599
|
+
const signBytes = signEd25519(
|
|
2600
|
+
signingKey.privateKey,
|
|
2601
|
+
new Uint8Array(Buffer.from(encryptedPayload, "utf8"))
|
|
2602
|
+
);
|
|
2603
|
+
const signature = {
|
|
2604
|
+
sign: Buffer.from(signBytes).toString("base64"),
|
|
2605
|
+
sign_key_id: signingKey.keyId,
|
|
2606
|
+
sign_algo: "ed25519"
|
|
2607
|
+
};
|
|
2608
|
+
return sendMessageNew({
|
|
2609
|
+
host,
|
|
2610
|
+
webOrigin,
|
|
2611
|
+
ctsToken,
|
|
2612
|
+
encKeyId,
|
|
2613
|
+
chatId,
|
|
2614
|
+
syncId,
|
|
2615
|
+
encryptedKeys,
|
|
2616
|
+
encryptedPayload,
|
|
2617
|
+
signature,
|
|
2618
|
+
timeoutMs
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
async function getChatKeyIds(host, webOrigin, ctsToken, encKeyId, chatId) {
|
|
2622
|
+
const wsUrl = `wss://${host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${encKeyId}&version=6&background=false&voex_unencrypted=true&instance_id=${randomUUID4()}`;
|
|
2623
|
+
return new Promise((resolve, reject) => {
|
|
2624
|
+
let settled = false;
|
|
2625
|
+
const done = (fn) => {
|
|
2626
|
+
if (!settled) {
|
|
2627
|
+
settled = true;
|
|
2628
|
+
fn();
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
const timer = setTimeout(() => {
|
|
2632
|
+
try {
|
|
2633
|
+
ws.close();
|
|
2634
|
+
} catch {
|
|
2635
|
+
}
|
|
2636
|
+
done(() => reject(new Error("Timeout getting chat key_ids")));
|
|
2637
|
+
}, 1e4);
|
|
2638
|
+
const ws = new WebSocket(wsUrl, {
|
|
2639
|
+
headers: { Origin: webOrigin, "User-Agent": "Mozilla/5.0" }
|
|
2640
|
+
});
|
|
2641
|
+
let ref = 0;
|
|
2642
|
+
const authRef = ref++;
|
|
2643
|
+
let chatInfoRef;
|
|
2644
|
+
const send = (msg) => ws.send(JSON.stringify(msg));
|
|
2645
|
+
ws.addEventListener("open", () => {
|
|
2646
|
+
send({ topic: "phoenix", event: "authenticate", payload: { token: ctsToken }, ref: authRef });
|
|
2647
|
+
});
|
|
2648
|
+
ws.addEventListener("message", (e) => {
|
|
2649
|
+
const msg = JSON.parse(e.data);
|
|
2650
|
+
if (msg.ref === authRef && msg.event === "phx_reply" && msg.payload?.status === "ok") {
|
|
2651
|
+
chatInfoRef = ref++;
|
|
2652
|
+
send({ topic: "system", event: "chat_info", payload: { group_chat_id: chatId, request_version: 6 }, ref: chatInfoRef });
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2655
|
+
if (msg.ref === chatInfoRef && msg.event === "phx_reply") {
|
|
2656
|
+
clearTimeout(timer);
|
|
2657
|
+
ws.close();
|
|
2658
|
+
const chatInfo = msg.payload?.response?.chat_info ?? msg.payload?.response;
|
|
2659
|
+
const keys = chatInfo?.keys ?? [];
|
|
2660
|
+
done(() => resolve(keys));
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2663
|
+
ws.addEventListener("error", (err) => {
|
|
2664
|
+
clearTimeout(timer);
|
|
2665
|
+
done(() => reject(new Error(`WS error: ${String(err)}`)));
|
|
2666
|
+
});
|
|
2667
|
+
ws.addEventListener("close", () => {
|
|
2668
|
+
clearTimeout(timer);
|
|
2669
|
+
done(() => reject(new Error("WS closed before chat_info")));
|
|
2670
|
+
});
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
async function sendMessageNew(params) {
|
|
2674
|
+
const { host, webOrigin, ctsToken, encKeyId, chatId, syncId, encryptedKeys, encryptedPayload, signature, timeoutMs } = params;
|
|
2675
|
+
const wsUrl = `wss://${host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${encKeyId}&version=6&background=false&voex_unencrypted=true&instance_id=${randomUUID4()}`;
|
|
2676
|
+
return new Promise((resolve, reject) => {
|
|
2677
|
+
let settled = false;
|
|
2678
|
+
const done = (fn) => {
|
|
2679
|
+
if (!settled) {
|
|
2680
|
+
settled = true;
|
|
2681
|
+
fn();
|
|
2682
|
+
}
|
|
2683
|
+
};
|
|
2684
|
+
const timer = setTimeout(() => {
|
|
2685
|
+
try {
|
|
2686
|
+
ws.close();
|
|
2687
|
+
} catch {
|
|
2688
|
+
}
|
|
2689
|
+
done(() => reject(new Error("Timeout sending message")));
|
|
2690
|
+
}, timeoutMs);
|
|
2691
|
+
const ws = new WebSocket(wsUrl, {
|
|
2692
|
+
headers: { Origin: webOrigin, "User-Agent": "Mozilla/5.0" }
|
|
2693
|
+
});
|
|
2694
|
+
let ref = 0;
|
|
2695
|
+
const authRef = ref++;
|
|
2696
|
+
let msgRef;
|
|
2697
|
+
const send = (msg) => ws.send(JSON.stringify(msg));
|
|
2698
|
+
ws.addEventListener("open", () => {
|
|
2699
|
+
send({ topic: "phoenix", event: "authenticate", payload: { token: ctsToken }, ref: authRef });
|
|
2700
|
+
});
|
|
2701
|
+
ws.addEventListener("message", (e) => {
|
|
2702
|
+
const msg = JSON.parse(e.data);
|
|
2703
|
+
if (msg.ref === authRef && msg.event === "phx_reply" && msg.payload?.status === "ok") {
|
|
2704
|
+
msgRef = ref++;
|
|
2705
|
+
send({
|
|
2706
|
+
topic: `groupchat:${chatId}`,
|
|
2707
|
+
event: "message_new",
|
|
2708
|
+
payload: {
|
|
2709
|
+
keys: encryptedKeys,
|
|
2710
|
+
group_chat_id: chatId,
|
|
2711
|
+
sync_id: syncId,
|
|
2712
|
+
payload: encryptedPayload,
|
|
2713
|
+
signature
|
|
2714
|
+
},
|
|
2715
|
+
ref: msgRef
|
|
2716
|
+
});
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
if (msg.ref === msgRef && msg.event === "phx_reply") {
|
|
2720
|
+
clearTimeout(timer);
|
|
2721
|
+
ws.close();
|
|
2722
|
+
if (msg.payload?.status === "ok") {
|
|
2723
|
+
done(() => resolve({ sync_id: syncId }));
|
|
2724
|
+
} else {
|
|
2725
|
+
done(() => reject(new Error(`message_new failed: ${JSON.stringify(msg.payload?.response)}`)));
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
});
|
|
2729
|
+
ws.addEventListener("error", (err) => {
|
|
2730
|
+
clearTimeout(timer);
|
|
2731
|
+
done(() => reject(new Error(`WS error: ${String(err)}`)));
|
|
2732
|
+
});
|
|
2733
|
+
ws.addEventListener("close", () => {
|
|
2734
|
+
clearTimeout(timer);
|
|
2735
|
+
done(() => reject(new Error("WS closed before message_new reply")));
|
|
2736
|
+
});
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// src/api/resolve.ts
|
|
2741
|
+
async function listChatsWithNames(client) {
|
|
2742
|
+
const chats = await new ChatsApi(client).listChats();
|
|
2743
|
+
const dmChats = chats.filter((c) => c.chat_type === "chat" && c.member_huids?.length);
|
|
2744
|
+
if (dmChats.length === 0) return chats;
|
|
2745
|
+
const userApi = new UserApi(client);
|
|
2746
|
+
const myHuid = (await userApi.getSelfProfile()).user_huid;
|
|
2747
|
+
const otherHuids = [...new Set(dmChats.flatMap((c) => (c.member_huids ?? []).filter((h) => h !== myHuid)))];
|
|
2748
|
+
if (otherHuids.length === 0) return chats;
|
|
2749
|
+
const nameMap = new Map((await userApi.getProfilesByHuid(otherHuids)).map((p) => [p.user_huid, p.name]));
|
|
2750
|
+
for (const chat of chats) {
|
|
2751
|
+
if (chat.chat_type === "chat" && chat.member_huids) {
|
|
2752
|
+
const other = chat.member_huids.find((h) => h !== myHuid);
|
|
2753
|
+
if (other) chat.name = nameMap.get(other) ?? chat.name;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
return chats;
|
|
2757
|
+
}
|
|
2758
|
+
async function resolveChatId(client, chatIdOrName) {
|
|
2759
|
+
if (/^[0-9a-f-]{36}$/i.test(chatIdOrName)) return chatIdOrName;
|
|
2760
|
+
const chats = await listChatsWithNames(client);
|
|
2761
|
+
const lower = chatIdOrName.toLowerCase();
|
|
2762
|
+
const matches = chats.filter((c) => (c.name ?? "").toLowerCase().includes(lower));
|
|
2763
|
+
if (matches.length === 0) throw new Error(`No chat found matching "${chatIdOrName}"`);
|
|
2764
|
+
if (matches.length > 1) {
|
|
2765
|
+
const names = matches.map((c) => ` ${c.name} (${c.group_chat_id})`).join("\n");
|
|
2766
|
+
throw new Error(`Multiple chats match "${chatIdOrName}":
|
|
2767
|
+
${names}
|
|
2768
|
+
Use the full chat ID.`);
|
|
2769
|
+
}
|
|
2770
|
+
return matches[0].group_chat_id;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// src/cli/send.ts
|
|
2774
|
+
import { readFileSync } from "fs";
|
|
2775
|
+
function createSendCommand() {
|
|
2776
|
+
const cmd = new Command8("send");
|
|
2777
|
+
cmd.description("Send messages and files");
|
|
2778
|
+
cmd.command("message <chat-id-or-name> <text>").description("Send a text message to a chat (chat ID or partial name)").option("--stealth", "Send in stealth mode", false).option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (chatIdOrName, text, opts) => {
|
|
2779
|
+
try {
|
|
2780
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2781
|
+
const chatId = await resolveChatId(client, chatIdOrName);
|
|
2782
|
+
const result = await sendMessageViaWebSocket({ client, chatId, body: text });
|
|
2783
|
+
console.log(formatOutput(result, opts.output));
|
|
2784
|
+
} catch (err) {
|
|
2785
|
+
console.error(`Error: ${err.message}`);
|
|
2786
|
+
process.exit(1);
|
|
2787
|
+
}
|
|
2788
|
+
});
|
|
2789
|
+
cmd.command("file <chat-id> <file-path>").description("Send a file to a chat").option("--caption <caption>", "File caption").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (chatId, filePath, opts) => {
|
|
2790
|
+
try {
|
|
2791
|
+
const data = readFileSync(filePath);
|
|
2792
|
+
const fileName = filePath.split("/").pop() ?? "file";
|
|
2793
|
+
const mimeMap = {
|
|
2794
|
+
".png": "image/png",
|
|
2795
|
+
".jpg": "image/jpeg",
|
|
2796
|
+
".jpeg": "image/jpeg",
|
|
2797
|
+
".gif": "image/gif",
|
|
2798
|
+
".pdf": "application/pdf",
|
|
2799
|
+
".txt": "text/plain",
|
|
2800
|
+
".json": "application/json",
|
|
2801
|
+
".csv": "text/csv",
|
|
2802
|
+
".zip": "application/zip"
|
|
2803
|
+
};
|
|
2804
|
+
const ext = fileName.includes(".") ? "." + fileName.split(".").pop().toLowerCase() : "";
|
|
2805
|
+
const mime = mimeMap[ext] ?? "application/octet-stream";
|
|
2806
|
+
const base64 = data.toString("base64");
|
|
2807
|
+
const dataUri = `data:${mime};base64,${base64}`;
|
|
2808
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2809
|
+
const api = new MessagingApi(client);
|
|
2810
|
+
const result = await api.sendFile({
|
|
2811
|
+
groupChatId: chatId,
|
|
2812
|
+
fileName,
|
|
2813
|
+
fileData: dataUri,
|
|
2814
|
+
caption: opts.caption
|
|
2815
|
+
});
|
|
2816
|
+
console.log(formatOutput(result, opts.output));
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
console.error(`Error: ${err.message}`);
|
|
2819
|
+
process.exit(1);
|
|
2820
|
+
}
|
|
2821
|
+
});
|
|
2822
|
+
return cmd;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// src/cli/avatar.ts
|
|
2826
|
+
import { Command as Command9 } from "commander";
|
|
2827
|
+
|
|
2828
|
+
// src/api/uploads.ts
|
|
2829
|
+
var UploadsApi = class {
|
|
2830
|
+
constructor(client) {
|
|
2831
|
+
this.client = client;
|
|
2832
|
+
}
|
|
2833
|
+
client;
|
|
2834
|
+
async downloadAvatar(huid) {
|
|
2835
|
+
const profile = await this.client.get(
|
|
2836
|
+
`/api/v1/phonebook/cts_profiles/query`
|
|
2837
|
+
);
|
|
2838
|
+
return null;
|
|
2839
|
+
}
|
|
2840
|
+
async download(url) {
|
|
2841
|
+
const res = await this.client.downloadFile(url);
|
|
2842
|
+
if (!res.ok) {
|
|
2843
|
+
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
2844
|
+
}
|
|
2845
|
+
const data = await res.arrayBuffer();
|
|
2846
|
+
const contentType = res.headers.get("content-type");
|
|
2847
|
+
return { data, contentType };
|
|
2848
|
+
}
|
|
2849
|
+
};
|
|
2850
|
+
|
|
2851
|
+
// src/cli/avatar.ts
|
|
2852
|
+
import { writeFileSync } from "fs";
|
|
2853
|
+
function createAvatarCommand() {
|
|
2854
|
+
const cmd = new Command9("avatar");
|
|
2855
|
+
cmd.description("Download user avatars");
|
|
2856
|
+
cmd.command("get <huid>").description("Download avatar for a user by HUID").option("-o, --output <path>", "Output file path (default: <huid>.png)").option("--host <host>", "eXpress host").action(async (huid, opts) => {
|
|
2857
|
+
try {
|
|
2858
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2859
|
+
const userApi = new UserApi(client);
|
|
2860
|
+
const uploadsApi = new UploadsApi(client);
|
|
2861
|
+
const profiles = await userApi.getProfilesByHuid([huid]);
|
|
2862
|
+
const profile = profiles?.[0];
|
|
2863
|
+
const avatarUrl = profile?.custom_avatar ?? profile?.avatar;
|
|
2864
|
+
if (!avatarUrl) {
|
|
2865
|
+
console.log("No avatar found for this user.");
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const { data, contentType } = await uploadsApi.download(avatarUrl);
|
|
2869
|
+
const ext = contentType?.includes("jpeg") ? ".jpg" : contentType?.includes("png") ? ".png" : ".png";
|
|
2870
|
+
const outputPath = opts.output ?? `${huid}${ext}`;
|
|
2871
|
+
writeFileSync(outputPath, Buffer.from(data));
|
|
2872
|
+
console.log(`Avatar saved to ${outputPath}`);
|
|
2873
|
+
} catch (err) {
|
|
2874
|
+
console.error(`Error: ${err.message}`);
|
|
2875
|
+
process.exit(1);
|
|
2876
|
+
}
|
|
2877
|
+
});
|
|
2878
|
+
cmd.command("self").description("Download your own avatar").option("-o, --output <path>", "Output file path (default: self_avatar.png)").option("--host <host>", "eXpress host").action(async (opts) => {
|
|
2879
|
+
try {
|
|
2880
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2881
|
+
const userApi = new UserApi(client);
|
|
2882
|
+
const uploadsApi = new UploadsApi(client);
|
|
2883
|
+
const profile = await userApi.getSelfProfile();
|
|
2884
|
+
const avatarUrl = profile.custom_avatar ?? profile.avatar;
|
|
2885
|
+
if (!avatarUrl) {
|
|
2886
|
+
console.log("No avatar found for your profile.");
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const { data, contentType } = await uploadsApi.download(avatarUrl);
|
|
2890
|
+
const ext = contentType?.includes("jpeg") ? ".jpg" : contentType?.includes("png") ? ".png" : ".png";
|
|
2891
|
+
const outputPath = opts.output ?? `self_avatar${ext}`;
|
|
2892
|
+
writeFileSync(outputPath, Buffer.from(data));
|
|
2893
|
+
console.log(`Avatar saved to ${outputPath}`);
|
|
2894
|
+
} catch (err) {
|
|
2895
|
+
console.error(`Error: ${err.message}`);
|
|
2896
|
+
process.exit(1);
|
|
2897
|
+
}
|
|
2898
|
+
});
|
|
2899
|
+
return cmd;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// src/cli/download.ts
|
|
2903
|
+
import { Command as Command10 } from "commander";
|
|
2904
|
+
import { writeFileSync as writeFileSync2 } from "fs";
|
|
2905
|
+
function createDownloadCommand() {
|
|
2906
|
+
const cmd = new Command10("download");
|
|
2907
|
+
cmd.description("Download a file from eXpress uploads").argument("<url>", "Full URL or path (e.g. /uploads/files/...)").option("-o, --output <path>", "Output file path (default: auto from URL)").option("--host <host>", "eXpress host").action(async (url, opts) => {
|
|
2908
|
+
try {
|
|
2909
|
+
const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
|
|
2910
|
+
const uploadsApi = new UploadsApi(client);
|
|
2911
|
+
const { data, contentType } = await uploadsApi.download(url);
|
|
2912
|
+
const urlPath = new URL(url.startsWith("http") ? url : `https://x${url}`).pathname;
|
|
2913
|
+
const baseName = urlPath.split("/").pop() ?? "download";
|
|
2914
|
+
const outputPath = opts.output ?? baseName;
|
|
2915
|
+
writeFileSync2(outputPath, Buffer.from(data));
|
|
2916
|
+
console.log(`Downloaded to ${outputPath} (${contentType ?? "unknown"}, ${data.byteLength} bytes)`);
|
|
2917
|
+
} catch (err) {
|
|
2918
|
+
console.error(`Error: ${err.message}`);
|
|
2919
|
+
process.exit(1);
|
|
2920
|
+
}
|
|
2921
|
+
});
|
|
2922
|
+
return cmd;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
// src/cli/messages.ts
|
|
2926
|
+
import { Command as Command11 } from "commander";
|
|
2927
|
+
|
|
2928
|
+
// src/api/messages-read.ts
|
|
2929
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2930
|
+
|
|
2931
|
+
// src/api/decrypt.ts
|
|
2932
|
+
import nacl5 from "tweetnacl";
|
|
2933
|
+
import sodium2 from "libsodium-wrappers-sumo";
|
|
2934
|
+
function decryptMessage(msg, ctsPrivateKey, myKeyId, keyMap, apigwKeys) {
|
|
2935
|
+
const encKey = msg.key;
|
|
2936
|
+
if (encKey.key_id !== myKeyId) {
|
|
2937
|
+
throw new Error(`key not for us (key_id=${encKey.key_id}, myKeyId=${myKeyId})`);
|
|
2938
|
+
}
|
|
2939
|
+
const keyRaw = Uint8Array.from(Buffer.from(encKey.key, "base64"));
|
|
2940
|
+
const nonce = keyRaw.slice(0, nacl5.box.nonceLength);
|
|
2941
|
+
const ciphertext = keyRaw.slice(nacl5.box.nonceLength);
|
|
2942
|
+
const senderPubKey = getSenderPublicKey(msg, keyMap, apigwKeys);
|
|
2943
|
+
if (!senderPubKey) throw new Error("cannot determine sender public key");
|
|
2944
|
+
const symmetricKey = nacl5.box.open(ciphertext, nonce, senderPubKey, ctsPrivateKey);
|
|
2945
|
+
if (!symmetricKey) throw new Error("nacl.box.open failed \u2014 wrong key pair");
|
|
2946
|
+
const payloadRaw = Uint8Array.from(Buffer.from(msg.payload, "base64"));
|
|
2947
|
+
const msgNonce = payloadRaw.slice(0, sodium2.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
2948
|
+
const msgCiphertext = payloadRaw.slice(sodium2.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
|
2949
|
+
const aad = new Uint8Array(Buffer.from(`${msg.group_chat_id}:${msg.sync_id}`));
|
|
2950
|
+
const plaintext = sodium2.crypto_aead_xchacha20poly1305_ietf_decrypt(null, msgCiphertext, aad, msgNonce, symmetricKey);
|
|
2951
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
2952
|
+
}
|
|
2953
|
+
function toDecrypted(msg, payload) {
|
|
2954
|
+
const base = { sync_id: msg.sync_id, sender: msg.sender ?? "", inserted_at: msg.inserted_at, decrypted: true, type: payload.type };
|
|
2955
|
+
if (payload.type === "image" && payload.payload) {
|
|
2956
|
+
const p = payload.payload;
|
|
2957
|
+
return {
|
|
2958
|
+
...base,
|
|
2959
|
+
body: payload.body ?? "",
|
|
2960
|
+
image: {
|
|
2961
|
+
fileName: p.file_name ?? "image",
|
|
2962
|
+
mimeType: p.file_mime_type,
|
|
2963
|
+
previewDataUri: p.blur_preview_file,
|
|
2964
|
+
width: p.file_preview_width,
|
|
2965
|
+
height: p.file_preview_height
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
return { ...base, body: payload.body ?? "" };
|
|
2970
|
+
}
|
|
2971
|
+
function getSenderPublicKey(msg, keyMap, apigwKeys) {
|
|
2972
|
+
const senderKeyId = msg.sender_key_id;
|
|
2973
|
+
if (!senderKeyId) return null;
|
|
2974
|
+
if (senderKeyId === apigwKeys.ctsKey?.keyId) return apigwKeys.ctsKey.publicKey;
|
|
2975
|
+
if (senderKeyId === apigwKeys.encryptionKey?.keyId) return apigwKeys.encryptionKey.publicKey;
|
|
2976
|
+
const kdcKey = keyMap.get(senderKeyId);
|
|
2977
|
+
if (kdcKey) return new Uint8Array(Buffer.from(kdcKey.body, "base64"));
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
async function decryptMessages(events, apigwKeys, client = new ApiClient()) {
|
|
2981
|
+
await sodium2.ready;
|
|
2982
|
+
const ctsKey = apigwKeys.ctsKey ?? apigwKeys.encryptionKey;
|
|
2983
|
+
const messages = events.filter((e) => e.event_type === "message_new" && e.payload && e.key);
|
|
2984
|
+
if (messages.length === 0) return [];
|
|
2985
|
+
const keyIds = [.../* @__PURE__ */ new Set([
|
|
2986
|
+
...messages.map((m) => m.sender_key_id).filter(Boolean),
|
|
2987
|
+
...messages.map((m) => m.key.key_id)
|
|
2988
|
+
])];
|
|
2989
|
+
const kdcKeys = await client.get(
|
|
2990
|
+
`/api/v1/kdc/keys/?ids=${keyIds.join(",")}`
|
|
2991
|
+
) ?? [];
|
|
2992
|
+
const keyMap = new Map(kdcKeys.map((k) => [k.id, k]));
|
|
2993
|
+
return messages.map((msg) => {
|
|
2994
|
+
try {
|
|
2995
|
+
return toDecrypted(msg, decryptMessage(msg, ctsKey.privateKey, ctsKey.keyId, keyMap, apigwKeys));
|
|
2996
|
+
} catch (err) {
|
|
2997
|
+
return { sync_id: msg.sync_id, body: "", sender: msg.sender ?? "", inserted_at: msg.inserted_at, decrypted: false, error: err.message };
|
|
2998
|
+
}
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// src/api/messages-read.ts
|
|
3003
|
+
async function readMessages(params) {
|
|
3004
|
+
const { chatId, limit = 20, timeoutMs = 2e4, direction = "backward" } = params;
|
|
3005
|
+
const apigwKeys = loadApigwKeys();
|
|
3006
|
+
if (!apigwKeys) throw new Error("No apigw keys. Run 'express auth login' first.");
|
|
3007
|
+
const config = loadConfig();
|
|
3008
|
+
const host = new URL(getBaseUrl(config)).hostname;
|
|
3009
|
+
const webOrigin = getWebOrigin(config);
|
|
3010
|
+
const ctsToken = getAuthToken();
|
|
3011
|
+
const ctsKey = apigwKeys.ctsKey ?? apigwKeys.encryptionKey;
|
|
3012
|
+
const history = await fetchEventsHistory(host, webOrigin, ctsToken, ctsKey.keyId, chatId, limit, timeoutMs, direction);
|
|
3013
|
+
return decryptMessages(history, apigwKeys);
|
|
3014
|
+
}
|
|
3015
|
+
async function fetchEventsHistory(host, webOrigin, ctsToken, encKeyId, chatId, limit, timeoutMs, direction = "backward") {
|
|
3016
|
+
const wsUrl = `wss://${host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${encKeyId}&version=6&background=false&voex_unencrypted=true&instance_id=${randomUUID5()}`;
|
|
3017
|
+
return new Promise((resolve, reject) => {
|
|
3018
|
+
let settled = false;
|
|
3019
|
+
const done = (fn) => {
|
|
3020
|
+
if (!settled) {
|
|
3021
|
+
settled = true;
|
|
3022
|
+
fn();
|
|
3023
|
+
}
|
|
3024
|
+
};
|
|
3025
|
+
const timer = setTimeout(() => {
|
|
3026
|
+
try {
|
|
3027
|
+
ws.close();
|
|
3028
|
+
} catch {
|
|
3029
|
+
}
|
|
3030
|
+
done(() => reject(new Error("Timeout fetching messages")));
|
|
3031
|
+
}, timeoutMs);
|
|
3032
|
+
const ws = new WebSocket(wsUrl, {
|
|
3033
|
+
headers: { Origin: webOrigin, "User-Agent": "Mozilla/5.0" }
|
|
3034
|
+
});
|
|
3035
|
+
let ref = 0;
|
|
3036
|
+
const authRef = ref++;
|
|
3037
|
+
let subRef = 0;
|
|
3038
|
+
let histRef = 0;
|
|
3039
|
+
const send = (msg) => ws.send(JSON.stringify(msg));
|
|
3040
|
+
ws.addEventListener("open", () => {
|
|
3041
|
+
send({ topic: "phoenix", event: "authenticate", payload: { token: ctsToken }, ref: authRef });
|
|
3042
|
+
});
|
|
3043
|
+
ws.addEventListener("message", (e) => {
|
|
3044
|
+
const msg = JSON.parse(e.data);
|
|
3045
|
+
const msgRef = typeof msg.ref === "string" ? parseInt(msg.ref, 10) : msg.ref;
|
|
3046
|
+
if (msgRef === authRef && msg.event === "phx_reply" && msg.payload?.status === "ok") {
|
|
3047
|
+
subRef = ref++;
|
|
3048
|
+
send({ topic: `groupchat:${chatId}`, event: "subscribe_to_chat_activities", payload: { group_chat_id: chatId }, ref: subRef });
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
if (msgRef === subRef && msg.event === "phx_reply") {
|
|
3052
|
+
histRef = ref++;
|
|
3053
|
+
send({
|
|
3054
|
+
topic: `groupchat:${chatId}`,
|
|
3055
|
+
event: "events_history",
|
|
3056
|
+
payload: {
|
|
3057
|
+
direction,
|
|
3058
|
+
group_chat_id: chatId,
|
|
3059
|
+
limit,
|
|
3060
|
+
skip_non_affecting_rc: true,
|
|
3061
|
+
skip_to_sync_id_event: true,
|
|
3062
|
+
last_ignore_messages_at: null
|
|
3063
|
+
},
|
|
3064
|
+
ref: histRef
|
|
3065
|
+
});
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
if (msgRef === histRef && msg.event === "phx_reply") {
|
|
3069
|
+
clearTimeout(timer);
|
|
3070
|
+
ws.close();
|
|
3071
|
+
const response = msg.payload?.response ?? msg.payload;
|
|
3072
|
+
const history = response?.history ?? [];
|
|
3073
|
+
done(() => resolve(Array.isArray(history) ? history : []));
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
});
|
|
3077
|
+
ws.addEventListener("error", (err) => {
|
|
3078
|
+
clearTimeout(timer);
|
|
3079
|
+
done(() => reject(new Error(`WS error: ${String(err)}`)));
|
|
3080
|
+
});
|
|
3081
|
+
ws.addEventListener("close", () => {
|
|
3082
|
+
clearTimeout(timer);
|
|
3083
|
+
done(() => reject(new Error("WS closed before messages received")));
|
|
3084
|
+
});
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// src/cli/messages.ts
|
|
3089
|
+
function createMessagesCommand() {
|
|
3090
|
+
const cmd = new Command11("messages");
|
|
3091
|
+
cmd.description("Read and decrypt chat messages");
|
|
3092
|
+
cmd.command("list <chat-id>").description("List recent messages from a chat").option("-n, --limit <number>", "Number of messages", "20").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "table").action(async (chatId, opts) => {
|
|
3093
|
+
try {
|
|
3094
|
+
const messages = await readMessages({
|
|
3095
|
+
chatId,
|
|
3096
|
+
limit: parseInt(opts.limit, 10) || 20
|
|
3097
|
+
});
|
|
3098
|
+
if (opts.output === "json") {
|
|
3099
|
+
console.log(formatOutput(messages, opts.output));
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
for (const msg of messages) {
|
|
3103
|
+
const time = msg.inserted_at ? new Date(msg.inserted_at).toLocaleString("ru-RU") : "?";
|
|
3104
|
+
const sender = msg.sender ? msg.sender.slice(0, 8) + "..." : "unknown";
|
|
3105
|
+
if (msg.decrypted) {
|
|
3106
|
+
console.log(`[${time}] ${sender}: ${msg.body}`);
|
|
3107
|
+
} else {
|
|
3108
|
+
console.log(`[${time}] ${sender}: [DECRYPT FAILED: ${msg.error}]`);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
} catch (err) {
|
|
3112
|
+
console.error(`Error: ${err.message}`);
|
|
3113
|
+
process.exit(1);
|
|
3114
|
+
}
|
|
3115
|
+
});
|
|
3116
|
+
return cmd;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// src/cli/listen.ts
|
|
3120
|
+
import { Command as Command12 } from "commander";
|
|
3121
|
+
import chalk4 from "chalk";
|
|
3122
|
+
|
|
3123
|
+
// src/session/session.ts
|
|
3124
|
+
import { EventEmitter } from "events";
|
|
3125
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3126
|
+
var HEARTBEAT_MS = 25e3;
|
|
3127
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
3128
|
+
var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
3129
|
+
var ExpressSession = class _ExpressSession extends EventEmitter {
|
|
3130
|
+
ws = null;
|
|
3131
|
+
ref = 0;
|
|
3132
|
+
pending = /* @__PURE__ */ new Map();
|
|
3133
|
+
heartbeatTimer = null;
|
|
3134
|
+
reconnectTimer = null;
|
|
3135
|
+
chats = [];
|
|
3136
|
+
threads = [];
|
|
3137
|
+
subscribed = /* @__PURE__ */ new Set();
|
|
3138
|
+
closing = false;
|
|
3139
|
+
connecting = false;
|
|
3140
|
+
reconnectAttempt = 0;
|
|
3141
|
+
host;
|
|
3142
|
+
webOrigin;
|
|
3143
|
+
keyId;
|
|
3144
|
+
apigwKeys;
|
|
3145
|
+
constructor() {
|
|
3146
|
+
super();
|
|
3147
|
+
const config = loadConfig();
|
|
3148
|
+
this.host = new URL(getBaseUrl(config)).hostname;
|
|
3149
|
+
this.webOrigin = getWebOrigin(config);
|
|
3150
|
+
const keys = loadApigwKeys();
|
|
3151
|
+
if (!keys) throw new Error("No apigw keys. Run 'express auth qr' first.");
|
|
3152
|
+
this.apigwKeys = keys;
|
|
3153
|
+
this.keyId = (keys.ctsKey ?? keys.encryptionKey).keyId;
|
|
3154
|
+
}
|
|
3155
|
+
getChats() {
|
|
3156
|
+
return this.chats;
|
|
3157
|
+
}
|
|
3158
|
+
getThreads() {
|
|
3159
|
+
return this.threads;
|
|
3160
|
+
}
|
|
3161
|
+
/** Initial connect (awaited by the caller). Reconnection afterwards is driven
|
|
3162
|
+
* solely by `handleClose` → `scheduleReconnect`. */
|
|
3163
|
+
async connect() {
|
|
3164
|
+
this.closing = false;
|
|
3165
|
+
await this.openSocket();
|
|
3166
|
+
}
|
|
3167
|
+
async openSocket() {
|
|
3168
|
+
if (this.closing) return;
|
|
3169
|
+
if (this.connecting || this.ws && this.ws.readyState === this.ws.OPEN) return;
|
|
3170
|
+
this.connecting = true;
|
|
3171
|
+
const token = getAuthToken();
|
|
3172
|
+
if (!token) {
|
|
3173
|
+
this.connecting = false;
|
|
3174
|
+
throw new Error("Not authenticated. Run 'express auth qr'.");
|
|
3175
|
+
}
|
|
3176
|
+
const url = `wss://${this.host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${this.keyId}&version=6&background=false&voex_unencrypted=true&voex_multistream=true&voex_audio_bridge=true&instance_id=${randomUUID6()}`;
|
|
3177
|
+
const ws = new WebSocket(url, {
|
|
3178
|
+
headers: { Origin: this.webOrigin, "User-Agent": "Mozilla/5.0" }
|
|
3179
|
+
});
|
|
3180
|
+
this.ws = ws;
|
|
3181
|
+
ws.addEventListener("message", (e) => {
|
|
3182
|
+
if (this.ws === ws) this.onMessage(e.data);
|
|
3183
|
+
});
|
|
3184
|
+
ws.addEventListener("close", () => this.handleClose(ws));
|
|
3185
|
+
ws.addEventListener("error", () => {
|
|
3186
|
+
});
|
|
3187
|
+
try {
|
|
3188
|
+
await new Promise((resolve, reject) => {
|
|
3189
|
+
const openTimer = setTimeout(() => {
|
|
3190
|
+
try {
|
|
3191
|
+
ws.close();
|
|
3192
|
+
} catch {
|
|
3193
|
+
}
|
|
3194
|
+
reject(new Error("WebSocket open timeout"));
|
|
3195
|
+
}, REQUEST_TIMEOUT_MS);
|
|
3196
|
+
ws.addEventListener("open", () => {
|
|
3197
|
+
clearTimeout(openTimer);
|
|
3198
|
+
this.request("phoenix", "authenticate", { token }).then(() => resolve()).catch(reject);
|
|
3199
|
+
});
|
|
3200
|
+
ws.addEventListener("close", () => {
|
|
3201
|
+
clearTimeout(openTimer);
|
|
3202
|
+
reject(new Error("closed during connect"));
|
|
3203
|
+
});
|
|
3204
|
+
});
|
|
3205
|
+
} catch (err) {
|
|
3206
|
+
if (this.ws === ws) {
|
|
3207
|
+
this.connecting = false;
|
|
3208
|
+
try {
|
|
3209
|
+
ws.close();
|
|
3210
|
+
} catch {
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
throw err;
|
|
3214
|
+
}
|
|
3215
|
+
if (this.ws !== ws) return;
|
|
3216
|
+
this.connecting = false;
|
|
3217
|
+
this.reconnectAttempt = 0;
|
|
3218
|
+
this.startHeartbeat();
|
|
3219
|
+
this.emit("connected");
|
|
3220
|
+
await this.loadChats(ws);
|
|
3221
|
+
}
|
|
3222
|
+
close() {
|
|
3223
|
+
this.closing = true;
|
|
3224
|
+
this.connecting = false;
|
|
3225
|
+
if (this.reconnectTimer) {
|
|
3226
|
+
clearTimeout(this.reconnectTimer);
|
|
3227
|
+
this.reconnectTimer = null;
|
|
3228
|
+
}
|
|
3229
|
+
this.stopHeartbeat();
|
|
3230
|
+
const ws = this.ws;
|
|
3231
|
+
this.ws = null;
|
|
3232
|
+
try {
|
|
3233
|
+
ws?.close();
|
|
3234
|
+
} catch {
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
/** chat_type values that accept `subscribe_to_chat_activities` (others like
|
|
3238
|
+
* "global"/"voex_call" reply "unmatched topic"). */
|
|
3239
|
+
static SUBSCRIBABLE = /* @__PURE__ */ new Set(["chat", "group_chat", "channel", "notes"]);
|
|
3240
|
+
/** Load the chat list and subscribe to each real chat's live activities.
|
|
3241
|
+
* Bails if the socket changes/closes mid-way (avoids error spam on teardown). */
|
|
3242
|
+
async loadChats(ws) {
|
|
3243
|
+
const response = await this.request("system", "chat_list", { since: null, request_version: 6 });
|
|
3244
|
+
const chats = response.chat_list ?? [];
|
|
3245
|
+
this.chats = Array.isArray(chats) ? chats : [];
|
|
3246
|
+
this.emit("chats", this.chats);
|
|
3247
|
+
const ids = this.chats.filter((c) => !c.chat_type || _ExpressSession.SUBSCRIBABLE.has(c.chat_type)).map((c) => c.group_chat_id).filter((id) => Boolean(id));
|
|
3248
|
+
await Promise.allSettled(ids.map((id) => this.subscribe(id, ws)));
|
|
3249
|
+
try {
|
|
3250
|
+
const tl = await this.request("system", "thread_list", { group_chat_id: null, limit: 200, request_version: 2 });
|
|
3251
|
+
this.threads = tl.thread_list ?? [];
|
|
3252
|
+
this.emit("threads", this.threads);
|
|
3253
|
+
const threadIds = this.threads.map((t) => t.thread_id).filter(Boolean);
|
|
3254
|
+
await Promise.allSettled(threadIds.map((id) => this.subscribe(id, ws)));
|
|
3255
|
+
} catch {
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
async subscribe(chatId, ws) {
|
|
3259
|
+
if (this.subscribed.has(chatId) || this.ws !== ws) return;
|
|
3260
|
+
try {
|
|
3261
|
+
await this.request(`groupchat:${chatId}`, "subscribe_to_chat_activities", { group_chat_id: chatId });
|
|
3262
|
+
this.subscribed.add(chatId);
|
|
3263
|
+
} catch {
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
onMessage(data) {
|
|
3267
|
+
let frame;
|
|
3268
|
+
try {
|
|
3269
|
+
frame = JSON.parse(data);
|
|
3270
|
+
} catch {
|
|
3271
|
+
return;
|
|
3272
|
+
}
|
|
3273
|
+
const ref = frame.ref == null ? null : typeof frame.ref === "string" ? parseInt(frame.ref, 10) : frame.ref;
|
|
3274
|
+
if (ref != null && this.pending.has(ref) && frame.event === "phx_reply") {
|
|
3275
|
+
const p = this.pending.get(ref);
|
|
3276
|
+
this.pending.delete(ref);
|
|
3277
|
+
clearTimeout(p.timer);
|
|
3278
|
+
if (frame.payload?.status === "ok") p.resolve(frame.payload.response ?? {});
|
|
3279
|
+
else p.reject(new Error(`${frame.event} failed: ${JSON.stringify(frame.payload?.response)}`));
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
if (ref == null && frame.event === "message_new") {
|
|
3283
|
+
const p = frame.payload;
|
|
3284
|
+
const chatId = p.group_chat_id ?? frame.topic.replace("groupchat:", "");
|
|
3285
|
+
const activity = {
|
|
3286
|
+
chatId,
|
|
3287
|
+
syncId: p.sync_id ?? "",
|
|
3288
|
+
sender: p.sender ?? "",
|
|
3289
|
+
senderKeyId: p.sender_key_id ?? ""
|
|
3290
|
+
};
|
|
3291
|
+
this.emit("activity", activity);
|
|
3292
|
+
void this.resolveMessage(activity);
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
/** Pushes have `key: null`; pull recent history (which includes our key) and decrypt. */
|
|
3296
|
+
async resolveMessage(activity) {
|
|
3297
|
+
try {
|
|
3298
|
+
const response = await this.request(`groupchat:${activity.chatId}`, "events_history", {
|
|
3299
|
+
direction: "backward",
|
|
3300
|
+
group_chat_id: activity.chatId,
|
|
3301
|
+
limit: 5,
|
|
3302
|
+
skip_non_affecting_rc: true,
|
|
3303
|
+
skip_to_sync_id_event: true,
|
|
3304
|
+
last_ignore_messages_at: null
|
|
3305
|
+
});
|
|
3306
|
+
const history = response.history ?? [];
|
|
3307
|
+
const decrypted = await decryptMessages(history, this.apigwKeys);
|
|
3308
|
+
const match = decrypted.find((m) => m.sync_id === activity.syncId) ?? decrypted[0];
|
|
3309
|
+
if (!match) return;
|
|
3310
|
+
const msg = {
|
|
3311
|
+
chatId: activity.chatId,
|
|
3312
|
+
syncId: match.sync_id,
|
|
3313
|
+
sender: match.sender,
|
|
3314
|
+
body: match.body,
|
|
3315
|
+
decrypted: match.decrypted,
|
|
3316
|
+
error: match.error,
|
|
3317
|
+
insertedAt: match.inserted_at,
|
|
3318
|
+
type: match.type,
|
|
3319
|
+
image: match.image
|
|
3320
|
+
};
|
|
3321
|
+
this.emit("message", msg);
|
|
3322
|
+
} catch (err) {
|
|
3323
|
+
this.emit("error", new Error(`decrypt push failed: ${err.message}`));
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
request(topic, event, payload) {
|
|
3327
|
+
const ws = this.ws;
|
|
3328
|
+
if (!ws || ws.readyState !== ws.OPEN) return Promise.reject(new Error("WebSocket not open"));
|
|
3329
|
+
const ref = this.ref++;
|
|
3330
|
+
return new Promise((resolve, reject) => {
|
|
3331
|
+
const timer = setTimeout(() => {
|
|
3332
|
+
this.pending.delete(ref);
|
|
3333
|
+
reject(new Error(`request timeout: ${event}`));
|
|
3334
|
+
}, REQUEST_TIMEOUT_MS);
|
|
3335
|
+
this.pending.set(ref, { resolve, reject, timer });
|
|
3336
|
+
ws.send(JSON.stringify({ topic, event, payload, ref }));
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
startHeartbeat() {
|
|
3340
|
+
this.stopHeartbeat();
|
|
3341
|
+
this.heartbeatTimer = setInterval(() => {
|
|
3342
|
+
const ws = this.ws;
|
|
3343
|
+
if (ws && ws.readyState === ws.OPEN) {
|
|
3344
|
+
ws.send(JSON.stringify({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.ref++ }));
|
|
3345
|
+
}
|
|
3346
|
+
}, HEARTBEAT_MS);
|
|
3347
|
+
}
|
|
3348
|
+
stopHeartbeat() {
|
|
3349
|
+
if (this.heartbeatTimer) {
|
|
3350
|
+
clearInterval(this.heartbeatTimer);
|
|
3351
|
+
this.heartbeatTimer = null;
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
/** Sole reconnect trigger. Ignores events from stale sockets. */
|
|
3355
|
+
handleClose(ws) {
|
|
3356
|
+
if (this.ws !== ws) return;
|
|
3357
|
+
this.ws = null;
|
|
3358
|
+
this.connecting = false;
|
|
3359
|
+
this.stopHeartbeat();
|
|
3360
|
+
for (const [, p] of this.pending) {
|
|
3361
|
+
clearTimeout(p.timer);
|
|
3362
|
+
p.reject(new Error("WebSocket closed"));
|
|
3363
|
+
}
|
|
3364
|
+
this.pending.clear();
|
|
3365
|
+
this.subscribed.clear();
|
|
3366
|
+
this.emit("disconnected", { code: 1006 });
|
|
3367
|
+
if (!this.closing) this.scheduleReconnect();
|
|
3368
|
+
}
|
|
3369
|
+
scheduleReconnect() {
|
|
3370
|
+
if (this.closing || this.reconnectTimer) return;
|
|
3371
|
+
this.reconnectAttempt += 1;
|
|
3372
|
+
const delayMs = Math.min(1e3 * 2 ** (this.reconnectAttempt - 1), MAX_RECONNECT_DELAY_MS);
|
|
3373
|
+
this.emit("reconnecting", { attempt: this.reconnectAttempt, delayMs });
|
|
3374
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
3375
|
+
this.reconnectTimer = null;
|
|
3376
|
+
if (this.closing) return;
|
|
3377
|
+
await refreshToken().catch(() => false);
|
|
3378
|
+
this.openSocket().catch(() => {
|
|
3379
|
+
});
|
|
3380
|
+
}, delayMs);
|
|
3381
|
+
}
|
|
3382
|
+
};
|
|
3383
|
+
|
|
3384
|
+
// src/cli/listen.ts
|
|
3385
|
+
function createListenCommand() {
|
|
3386
|
+
const cmd = new Command12("listen");
|
|
3387
|
+
cmd.description("Open a persistent session and stream incoming messages/notifications").option("--host <host>", "eXpress host").option("--activity", "Also print raw activity events (before decryption)", false).action(async (opts) => {
|
|
3388
|
+
let session;
|
|
3389
|
+
try {
|
|
3390
|
+
session = new ExpressSession();
|
|
3391
|
+
} catch (err) {
|
|
3392
|
+
console.error(`Error: ${err.message}`);
|
|
3393
|
+
process.exit(1);
|
|
3394
|
+
}
|
|
3395
|
+
const names = /* @__PURE__ */ new Map();
|
|
3396
|
+
const label = (chatId) => names.get(chatId) ?? chatId.slice(0, 8);
|
|
3397
|
+
const ts = (iso) => iso ? new Date(iso).toLocaleTimeString("ru-RU") : (/* @__PURE__ */ new Date()).toLocaleTimeString("ru-RU");
|
|
3398
|
+
session.on("connected", () => console.log(chalk4.green("\u25CF connected")));
|
|
3399
|
+
session.on("chats", (chats) => {
|
|
3400
|
+
for (const c of chats) {
|
|
3401
|
+
const id = c.group_chat_id ?? c.id;
|
|
3402
|
+
const name = c.name;
|
|
3403
|
+
if (id) names.set(id, name || id.slice(0, 8));
|
|
3404
|
+
}
|
|
3405
|
+
console.log(chalk4.dim(` ${chats.length} chats loaded, subscribing to activity\u2026`));
|
|
3406
|
+
});
|
|
3407
|
+
if (opts.activity) {
|
|
3408
|
+
session.on("activity", (a) => {
|
|
3409
|
+
console.log(chalk4.dim(` \xB7 activity in ${label(a.chatId)} (${a.syncId.slice(0, 8)})`));
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
session.on("message", (m) => {
|
|
3413
|
+
const who = m.sender.slice(0, 8);
|
|
3414
|
+
if (m.decrypted) {
|
|
3415
|
+
console.log(`${chalk4.dim(`[${ts(m.insertedAt)}]`)} ${chalk4.cyan(label(m.chatId))} ${chalk4.yellow(who)}: ${m.body}`);
|
|
3416
|
+
} else {
|
|
3417
|
+
console.log(`${chalk4.dim(`[${ts(m.insertedAt)}]`)} ${chalk4.cyan(label(m.chatId))} ${chalk4.yellow(who)}: ${chalk4.red(`[decrypt failed: ${m.error}]`)}`);
|
|
3418
|
+
}
|
|
3419
|
+
});
|
|
3420
|
+
session.on("reconnecting", ({ attempt, delayMs }) => {
|
|
3421
|
+
console.log(chalk4.yellow(`\u25CB reconnecting (attempt ${attempt}) in ${Math.round(delayMs / 1e3)}s\u2026`));
|
|
3422
|
+
});
|
|
3423
|
+
session.on("disconnected", ({ code }) => console.log(chalk4.yellow(`\u25CB disconnected (code=${code})`)));
|
|
3424
|
+
session.on("error", (err) => console.error(chalk4.red(`! ${err.message}`)));
|
|
3425
|
+
const shutdown = () => {
|
|
3426
|
+
console.log(chalk4.dim("\nclosing\u2026"));
|
|
3427
|
+
session.close();
|
|
3428
|
+
process.exit(0);
|
|
3429
|
+
};
|
|
3430
|
+
process.on("SIGINT", shutdown);
|
|
3431
|
+
process.on("SIGTERM", shutdown);
|
|
3432
|
+
try {
|
|
3433
|
+
await session.connect();
|
|
3434
|
+
console.log(chalk4.dim("Listening. Press Ctrl+C to stop."));
|
|
3435
|
+
} catch (err) {
|
|
3436
|
+
console.error(`Error: ${err.message}`);
|
|
3437
|
+
session.close();
|
|
3438
|
+
process.exit(1);
|
|
3439
|
+
}
|
|
3440
|
+
});
|
|
3441
|
+
return cmd;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// src/cli/tui.ts
|
|
3445
|
+
import { Command as Command13 } from "commander";
|
|
3446
|
+
|
|
3447
|
+
// src/tui/index.tsx
|
|
3448
|
+
import { render } from "ink";
|
|
3449
|
+
|
|
3450
|
+
// src/tui/app.tsx
|
|
3451
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3452
|
+
import { Box, Text, useApp, useInput, useStdin, useStdout } from "ink";
|
|
3453
|
+
import TextInput from "ink-text-input";
|
|
3454
|
+
|
|
3455
|
+
// src/tui/image.ts
|
|
3456
|
+
import terminalImage from "terminal-image";
|
|
3457
|
+
async function renderImage(data, cols, rows) {
|
|
3458
|
+
const b64 = data.includes(",") ? data.slice(data.indexOf(",") + 1) : data;
|
|
3459
|
+
const buf = Buffer.from(b64, "base64");
|
|
3460
|
+
const out = await terminalImage.buffer(buf, {
|
|
3461
|
+
width: Math.max(4, cols),
|
|
3462
|
+
...rows ? { height: rows } : {},
|
|
3463
|
+
preserveAspectRatio: true,
|
|
3464
|
+
preferNativeRender: false
|
|
3465
|
+
});
|
|
3466
|
+
return out.replace(/\n$/, "");
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
// src/tui/app.tsx
|
|
3470
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3471
|
+
var LIST_TYPES = /* @__PURE__ */ new Set(["chat", "group_chat", "channel", "notes"]);
|
|
3472
|
+
var shortHuid = (h) => h ? h.slice(0, 8) : "unknown";
|
|
3473
|
+
var clock = (iso) => {
|
|
3474
|
+
const d = iso ? new Date(iso) : /* @__PURE__ */ new Date();
|
|
3475
|
+
return Number.isNaN(d.getTime()) ? "--:--" : d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
|
|
3476
|
+
};
|
|
3477
|
+
var MENTION_RE = /@\{mention:([0-9a-fA-F-]{36})\}/g;
|
|
3478
|
+
function parseBody(body) {
|
|
3479
|
+
const out = [];
|
|
3480
|
+
let last = 0;
|
|
3481
|
+
let m;
|
|
3482
|
+
MENTION_RE.lastIndex = 0;
|
|
3483
|
+
while (m = MENTION_RE.exec(body)) {
|
|
3484
|
+
if (m.index > last) out.push({ text: body.slice(last, m.index) });
|
|
3485
|
+
out.push({ mention: m[1] });
|
|
3486
|
+
last = m.index + m[0].length;
|
|
3487
|
+
}
|
|
3488
|
+
if (last < body.length) out.push({ text: body.slice(last) });
|
|
3489
|
+
return out.length ? out : [{ text: body }];
|
|
3490
|
+
}
|
|
3491
|
+
var toDisplay = (m) => ({ syncId: m.sync_id, sender: m.sender, body: m.body, decrypted: m.decrypted, error: m.error, insertedAt: m.inserted_at, type: m.type, image: m.image });
|
|
3492
|
+
function App() {
|
|
3493
|
+
const { exit } = useApp();
|
|
3494
|
+
const { stdout } = useStdout();
|
|
3495
|
+
const { isRawModeSupported } = useStdin();
|
|
3496
|
+
const rows = stdout?.rows ?? 24;
|
|
3497
|
+
const cols = stdout?.columns ?? 80;
|
|
3498
|
+
const sessionRef = useRef(null);
|
|
3499
|
+
const [chats, setChats] = useState([]);
|
|
3500
|
+
const [threads, setThreads] = useState([]);
|
|
3501
|
+
const [selected, setSelected] = useState(0);
|
|
3502
|
+
const [messages, setMessages] = useState({});
|
|
3503
|
+
const [status2, setStatus] = useState({ text: "connecting\u2026", color: "yellow" });
|
|
3504
|
+
const [focus, setFocus] = useState("chats");
|
|
3505
|
+
const [draft, setDraft] = useState("");
|
|
3506
|
+
const [selfHuid, setSelfHuid] = useState("");
|
|
3507
|
+
const [names, setNames] = useState({});
|
|
3508
|
+
const [unread, setUnread] = useState({});
|
|
3509
|
+
const [msgCursor, setMsgCursor] = useState(0);
|
|
3510
|
+
const [discussionsOpen, setDiscussionsOpen] = useState(false);
|
|
3511
|
+
const [threadCursor, setThreadCursor] = useState(0);
|
|
3512
|
+
const [openThreadId, setOpenThreadId] = useState(null);
|
|
3513
|
+
const [threadTitles, setThreadTitles] = useState({});
|
|
3514
|
+
const [renderedImages, setRenderedImages] = useState({});
|
|
3515
|
+
const requested = useRef(/* @__PURE__ */ new Set());
|
|
3516
|
+
const titleRequested = useRef(/* @__PURE__ */ new Set());
|
|
3517
|
+
const imgRequested = useRef(/* @__PURE__ */ new Set());
|
|
3518
|
+
const currentIdRef = useRef(void 0);
|
|
3519
|
+
const visibleChats = useMemo(
|
|
3520
|
+
() => chats.filter((c) => LIST_TYPES.has(c.chat_type) && !c.left).sort((a, b) => (b.last_event_inserted_at ?? "").localeCompare(a.last_event_inserted_at ?? "")),
|
|
3521
|
+
[chats]
|
|
3522
|
+
);
|
|
3523
|
+
const current = visibleChats[selected];
|
|
3524
|
+
const currentId = current?.group_chat_id;
|
|
3525
|
+
currentIdRef.current = currentId;
|
|
3526
|
+
const threadsForChat = useMemo(
|
|
3527
|
+
() => threads.filter((t) => t.group_chat_id === currentId).sort((a, b) => (b.last_event_inserted_at ?? "").localeCompare(a.last_event_inserted_at ?? "")),
|
|
3528
|
+
[threads, currentId]
|
|
3529
|
+
);
|
|
3530
|
+
const activeMessages = openThreadId ? messages[openThreadId] : currentId ? messages[currentId] : void 0;
|
|
3531
|
+
const resolveHuids = (huids) => {
|
|
3532
|
+
const need = [...new Set(huids)].filter((h) => h && h.length >= 30 && !requested.current.has(h));
|
|
3533
|
+
if (need.length === 0) return;
|
|
3534
|
+
need.forEach((h) => requested.current.add(h));
|
|
3535
|
+
new UserApi(new ApiClient()).getProfilesByHuid(need).then((profiles) => setNames((prev) => {
|
|
3536
|
+
const next = { ...prev };
|
|
3537
|
+
for (const p of profiles) if (p.name) next[p.user_huid] = p.name;
|
|
3538
|
+
return next;
|
|
3539
|
+
})).catch(() => need.forEach((h) => requested.current.delete(h)));
|
|
3540
|
+
};
|
|
3541
|
+
const displayChatName = (c) => {
|
|
3542
|
+
if (c.chat_type === "chat" && c.member_huids && selfHuid) {
|
|
3543
|
+
const other = c.member_huids.find((h) => h !== selfHuid);
|
|
3544
|
+
if (other && names[other]) return names[other];
|
|
3545
|
+
}
|
|
3546
|
+
return c.name?.trim() || c.group_chat_id.slice(0, 8);
|
|
3547
|
+
};
|
|
3548
|
+
const senderLabel = (huid) => huid === selfHuid || huid === "me" ? "you" : names[huid] || shortHuid(huid);
|
|
3549
|
+
const fetchThreadTitle = (threadId) => {
|
|
3550
|
+
if (threadTitles[threadId] || titleRequested.current.has(threadId)) return;
|
|
3551
|
+
titleRequested.current.add(threadId);
|
|
3552
|
+
readMessages({ chatId: threadId, limit: 1, direction: "forward" }).then((h) => {
|
|
3553
|
+
const root = h.find((m) => m.decrypted && m.body);
|
|
3554
|
+
if (root) setThreadTitles((p) => ({ ...p, [threadId]: root.body.replace(/\s+/g, " ").trim() }));
|
|
3555
|
+
}).catch(() => titleRequested.current.delete(threadId));
|
|
3556
|
+
};
|
|
3557
|
+
const threadTitle = (threadId) => threadTitles[threadId];
|
|
3558
|
+
useEffect(() => {
|
|
3559
|
+
let session;
|
|
3560
|
+
try {
|
|
3561
|
+
session = new ExpressSession();
|
|
3562
|
+
} catch (err) {
|
|
3563
|
+
setStatus({ text: err.message, color: "red" });
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
sessionRef.current = session;
|
|
3567
|
+
session.on("connected", () => setStatus({ text: "connected", color: "green" }));
|
|
3568
|
+
session.on("chats", (list) => setChats(list));
|
|
3569
|
+
session.on("threads", (list) => setThreads(list));
|
|
3570
|
+
session.on("message", (m) => {
|
|
3571
|
+
setMessages((prev) => {
|
|
3572
|
+
const list = prev[m.chatId] ?? [];
|
|
3573
|
+
if (list.some((x) => x.syncId === m.syncId)) return prev;
|
|
3574
|
+
const next = { syncId: m.syncId, sender: m.sender, body: m.body, decrypted: m.decrypted, error: m.error, insertedAt: m.insertedAt, type: m.type, image: m.image };
|
|
3575
|
+
return { ...prev, [m.chatId]: [...list, next].slice(-200) };
|
|
3576
|
+
});
|
|
3577
|
+
if (m.chatId !== currentIdRef.current) setUnread((u) => ({ ...u, [m.chatId]: (u[m.chatId] ?? 0) + 1 }));
|
|
3578
|
+
});
|
|
3579
|
+
session.on("reconnecting", ({ attempt }) => setStatus({ text: `reconnecting (#${attempt})\u2026`, color: "yellow" }));
|
|
3580
|
+
session.on("disconnected", () => setStatus({ text: "disconnected", color: "yellow" }));
|
|
3581
|
+
session.on("error", () => {
|
|
3582
|
+
});
|
|
3583
|
+
session.connect().catch((err) => setStatus({ text: err.message, color: "red" }));
|
|
3584
|
+
new UserApi(new ApiClient()).getSelfProfile().then((p) => setSelfHuid(p.user_huid)).catch(() => {
|
|
3585
|
+
});
|
|
3586
|
+
return () => session.close();
|
|
3587
|
+
}, []);
|
|
3588
|
+
const send = async (text) => {
|
|
3589
|
+
const body = text.trim();
|
|
3590
|
+
setDraft("");
|
|
3591
|
+
const target = openThreadId ?? currentId;
|
|
3592
|
+
if (!body || !target) return;
|
|
3593
|
+
const localId = `local-${Date.now()}`;
|
|
3594
|
+
const optimistic = { syncId: localId, sender: selfHuid || "me", body, decrypted: true, insertedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3595
|
+
setMessages((prev) => ({ ...prev, [target]: [...prev[target] ?? [], optimistic].slice(-200) }));
|
|
3596
|
+
try {
|
|
3597
|
+
await sendMessageViaWebSocket({ client: new ApiClient(), chatId: target, body });
|
|
3598
|
+
} catch (err) {
|
|
3599
|
+
setMessages((prev) => ({
|
|
3600
|
+
...prev,
|
|
3601
|
+
[target]: (prev[target] ?? []).map((m) => m.syncId === localId ? { ...m, decrypted: false, error: err.message } : m)
|
|
3602
|
+
}));
|
|
3603
|
+
}
|
|
3604
|
+
};
|
|
3605
|
+
const loadHistory = (id) => {
|
|
3606
|
+
if (messages[id]) return;
|
|
3607
|
+
readMessages({ chatId: id, limit: 40 }).then((h) => setMessages((prev) => prev[id] ? prev : { ...prev, [id]: h.map(toDisplay) })).catch(() => setMessages((prev) => prev[id] ? prev : { ...prev, [id]: [] }));
|
|
3608
|
+
};
|
|
3609
|
+
useEffect(() => {
|
|
3610
|
+
if (currentId) loadHistory(currentId);
|
|
3611
|
+
}, [currentId]);
|
|
3612
|
+
useEffect(() => {
|
|
3613
|
+
if (openThreadId) {
|
|
3614
|
+
loadHistory(openThreadId);
|
|
3615
|
+
fetchThreadTitle(openThreadId);
|
|
3616
|
+
setMsgCursor((messages[openThreadId]?.length ?? 1) - 1);
|
|
3617
|
+
}
|
|
3618
|
+
}, [openThreadId]);
|
|
3619
|
+
useEffect(() => {
|
|
3620
|
+
if (!selfHuid) return;
|
|
3621
|
+
resolveHuids(visibleChats.filter((c) => c.chat_type === "chat").flatMap((c) => (c.member_huids ?? []).filter((h) => h !== selfHuid)));
|
|
3622
|
+
}, [chats, selfHuid]);
|
|
3623
|
+
useEffect(() => {
|
|
3624
|
+
if (!activeMessages) return;
|
|
3625
|
+
resolveHuids(activeMessages.flatMap((m) => [m.sender, ...parseBody(m.body).filter((s) => s.mention).map((s) => s.mention)]));
|
|
3626
|
+
}, [activeMessages]);
|
|
3627
|
+
useEffect(() => {
|
|
3628
|
+
setDiscussionsOpen(false);
|
|
3629
|
+
setOpenThreadId(null);
|
|
3630
|
+
if (currentId) setUnread((u) => u[currentId] ? { ...u, [currentId]: 0 } : u);
|
|
3631
|
+
}, [currentId]);
|
|
3632
|
+
useEffect(() => {
|
|
3633
|
+
if (!discussionsOpen) return;
|
|
3634
|
+
threadsForChat.forEach((t) => fetchThreadTitle(t.thread_id));
|
|
3635
|
+
}, [discussionsOpen, threadsForChat]);
|
|
3636
|
+
useEffect(() => {
|
|
3637
|
+
const m = focus === "thread" ? activeMessages?.[msgCursor] : void 0;
|
|
3638
|
+
const uri = m?.image?.previewDataUri;
|
|
3639
|
+
if (!m || !uri || renderedImages[m.syncId] || imgRequested.current.has(m.syncId)) return;
|
|
3640
|
+
imgRequested.current.add(m.syncId);
|
|
3641
|
+
const w = Math.min(40, Math.max(10, cols - 36));
|
|
3642
|
+
renderImage(uri, w).then((s) => setRenderedImages((p) => ({ ...p, [m.syncId]: s }))).catch(() => imgRequested.current.delete(m.syncId));
|
|
3643
|
+
}, [focus, msgCursor, activeMessages]);
|
|
3644
|
+
useInput((input, key) => {
|
|
3645
|
+
if (focus === "input") {
|
|
3646
|
+
if (key.escape) setFocus(openThreadId ? "thread" : "chats");
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
if (input === "q" || key.ctrl && input === "c") {
|
|
3650
|
+
sessionRef.current?.close();
|
|
3651
|
+
exit();
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
if (input === "t" && currentId && !openThreadId) {
|
|
3655
|
+
if (discussionsOpen) {
|
|
3656
|
+
setDiscussionsOpen(false);
|
|
3657
|
+
setFocus("chats");
|
|
3658
|
+
} else {
|
|
3659
|
+
setDiscussionsOpen(true);
|
|
3660
|
+
setThreadCursor(0);
|
|
3661
|
+
setFocus("discussions");
|
|
3662
|
+
}
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
if (focus === "chats") {
|
|
3666
|
+
if (key.upArrow || input === "k") setSelected((i) => Math.max(0, i - 1));
|
|
3667
|
+
else if (key.downArrow || input === "j") setSelected((i) => Math.min(visibleChats.length - 1, i + 1));
|
|
3668
|
+
else if ((key.rightArrow || key.tab) && currentId) {
|
|
3669
|
+
setMsgCursor((activeMessages?.length ?? 1) - 1);
|
|
3670
|
+
setFocus("thread");
|
|
3671
|
+
} else if (key.return && currentId) setFocus("input");
|
|
3672
|
+
} else if (focus === "discussions") {
|
|
3673
|
+
if (key.upArrow || input === "k") setThreadCursor((c) => Math.max(0, c - 1));
|
|
3674
|
+
else if (key.downArrow || input === "j") setThreadCursor((c) => Math.min(threadsForChat.length - 1, c + 1));
|
|
3675
|
+
else if (key.return && threadsForChat[threadCursor]) {
|
|
3676
|
+
setOpenThreadId(threadsForChat[threadCursor].thread_id);
|
|
3677
|
+
setFocus("thread");
|
|
3678
|
+
} else if (key.leftArrow || key.escape) {
|
|
3679
|
+
setDiscussionsOpen(false);
|
|
3680
|
+
setFocus("chats");
|
|
3681
|
+
}
|
|
3682
|
+
} else if (focus === "thread") {
|
|
3683
|
+
const len = activeMessages?.length ?? 0;
|
|
3684
|
+
if (key.upArrow || input === "k") setMsgCursor((c) => Math.max(0, c - 1));
|
|
3685
|
+
else if (key.downArrow || input === "j") setMsgCursor((c) => Math.min(len - 1, c + 1));
|
|
3686
|
+
else if (key.leftArrow || key.escape) {
|
|
3687
|
+
if (openThreadId) {
|
|
3688
|
+
setOpenThreadId(null);
|
|
3689
|
+
setFocus("discussions");
|
|
3690
|
+
} else setFocus("chats");
|
|
3691
|
+
} else if (key.return) setFocus("input");
|
|
3692
|
+
}
|
|
3693
|
+
}, { isActive: isRawModeSupported });
|
|
3694
|
+
const bodyHeight = Math.max(4, rows - 4);
|
|
3695
|
+
const rightWidth = Math.max(20, cols - 34);
|
|
3696
|
+
const listWindow = windowAround(selected, visibleChats.length, bodyHeight);
|
|
3697
|
+
const showDiscussions = discussionsOpen && !openThreadId;
|
|
3698
|
+
const estLines = (m) => {
|
|
3699
|
+
let n = 9 + senderLabel(m.sender).length + 2;
|
|
3700
|
+
for (const seg of parseBody(m.body)) n += seg.mention ? 1 + (names[seg.mention]?.length ?? 8) : seg.text?.length ?? 0;
|
|
3701
|
+
return Math.max(1, Math.ceil(n / rightWidth));
|
|
3702
|
+
};
|
|
3703
|
+
let threadStart = activeMessages ? activeMessages.length : 0;
|
|
3704
|
+
if (activeMessages) {
|
|
3705
|
+
let used = 0;
|
|
3706
|
+
while (threadStart > 0 && used < bodyHeight) {
|
|
3707
|
+
used += estLines(activeMessages[threadStart - 1]);
|
|
3708
|
+
threadStart--;
|
|
3709
|
+
}
|
|
3710
|
+
if (focus === "thread" && msgCursor < threadStart) threadStart = msgCursor;
|
|
3711
|
+
}
|
|
3712
|
+
const threadView = activeMessages ? activeMessages.slice(threadStart) : void 0;
|
|
3713
|
+
const renderBody = (m) => parseBody(m.body).map(
|
|
3714
|
+
(seg, i) => seg.mention ? /* @__PURE__ */ jsxs(Text, { color: "magenta", children: [
|
|
3715
|
+
"@",
|
|
3716
|
+
names[seg.mention] || shortHuid(seg.mention)
|
|
3717
|
+
] }, i) : /* @__PURE__ */ jsx(Text, { children: seg.text }, i)
|
|
3718
|
+
);
|
|
3719
|
+
const rightBorder = focus === "input" ? "green" : focus === "thread" || focus === "discussions" ? "cyan" : "gray";
|
|
3720
|
+
const rightTitle = openThreadId ? `\u{1F4AC} ${threadTitle(openThreadId) ?? "\u041E\u0431\u0441\u0443\u0436\u0434\u0435\u043D\u0438\u0435"}` : (current ? displayChatName(current) : "\u2014") + (threadsForChat.length ? ` \u{1F4AC} ${threadsForChat.length}` : "");
|
|
3721
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: rows, children: [
|
|
3722
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
3723
|
+
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
3724
|
+
/* @__PURE__ */ jsx(Text, { color: status2.color, children: "\u25CF" }),
|
|
3725
|
+
/* @__PURE__ */ jsx(Text, { children: " eXpress TUI \u2014 " }),
|
|
3726
|
+
/* @__PURE__ */ jsx(Text, { color: status2.color, children: status2.text }),
|
|
3727
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
3728
|
+
" (",
|
|
3729
|
+
visibleChats.length,
|
|
3730
|
+
" chats)"
|
|
3731
|
+
] })
|
|
3732
|
+
] }),
|
|
3733
|
+
/* @__PURE__ */ jsxs(Box, { flexGrow: 1, children: [
|
|
3734
|
+
/* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: 32, borderStyle: "single", borderColor: focus === "chats" ? "cyan" : "gray", paddingX: 1, children: [
|
|
3735
|
+
visibleChats.length === 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: "loading\u2026" }),
|
|
3736
|
+
visibleChats.slice(listWindow.start, listWindow.end).map((c, i) => {
|
|
3737
|
+
const idx = listWindow.start + i;
|
|
3738
|
+
const active = idx === selected;
|
|
3739
|
+
const n = unread[c.group_chat_id] ?? 0;
|
|
3740
|
+
return /* @__PURE__ */ jsxs(Text, { color: active ? "black" : n > 0 ? "cyan" : void 0, backgroundColor: active ? "cyan" : void 0, bold: n > 0 && !active, wrap: "truncate", children: [
|
|
3741
|
+
active ? "\u203A " : " ",
|
|
3742
|
+
displayChatName(c),
|
|
3743
|
+
n > 0 ? ` (${n})` : ""
|
|
3744
|
+
] }, c.group_chat_id);
|
|
3745
|
+
})
|
|
3746
|
+
] }),
|
|
3747
|
+
/* @__PURE__ */ jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: rightBorder, paddingX: 1, children: [
|
|
3748
|
+
/* @__PURE__ */ jsx(Text, { bold: true, wrap: "truncate", children: rightTitle }),
|
|
3749
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, children: showDiscussions ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3750
|
+
threadsForChat.length === 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: "no discussions" }),
|
|
3751
|
+
threadsForChat.map((t, i) => {
|
|
3752
|
+
const on = focus === "discussions" && i === threadCursor;
|
|
3753
|
+
const title = threadTitle(t.thread_id) ?? t.thread_id.slice(0, 8);
|
|
3754
|
+
return /* @__PURE__ */ jsxs(Text, { color: on ? "black" : void 0, backgroundColor: on ? "cyan" : void 0, wrap: "truncate", children: [
|
|
3755
|
+
on ? "\u203A " : " ",
|
|
3756
|
+
"\u{1F4AC} ",
|
|
3757
|
+
title,
|
|
3758
|
+
" ",
|
|
3759
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: !on, children: [
|
|
3760
|
+
"\xB7 ",
|
|
3761
|
+
t.counter ?? 0,
|
|
3762
|
+
" \xB7 ",
|
|
3763
|
+
clock(t.last_event_inserted_at ?? "")
|
|
3764
|
+
] })
|
|
3765
|
+
] }, t.thread_id);
|
|
3766
|
+
})
|
|
3767
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3768
|
+
!current && /* @__PURE__ */ jsx(Text, { dimColor: true, children: "select a chat" }),
|
|
3769
|
+
current && !threadView && /* @__PURE__ */ jsx(Text, { dimColor: true, children: "loading messages\u2026" }),
|
|
3770
|
+
threadView && threadView.length === 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: "no messages" }),
|
|
3771
|
+
threadView?.map((m, i) => {
|
|
3772
|
+
const idx = threadStart + i;
|
|
3773
|
+
const mine = m.sender === selfHuid || m.sender === "me";
|
|
3774
|
+
const onCursor = focus === "thread" && idx === msgCursor;
|
|
3775
|
+
const img = renderedImages[m.syncId];
|
|
3776
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
3777
|
+
/* @__PURE__ */ jsxs(Text, { wrap: "wrap", children: [
|
|
3778
|
+
/* @__PURE__ */ jsx(Text, { color: onCursor ? "cyan" : void 0, children: onCursor ? "\u258D" : " " }),
|
|
3779
|
+
/* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
3780
|
+
"[",
|
|
3781
|
+
clock(m.insertedAt),
|
|
3782
|
+
"] "
|
|
3783
|
+
] }),
|
|
3784
|
+
/* @__PURE__ */ jsx(Text, { color: mine ? "green" : "yellow", children: senderLabel(m.sender) }),
|
|
3785
|
+
/* @__PURE__ */ jsx(Text, { children: ": " }),
|
|
3786
|
+
!m.decrypted ? /* @__PURE__ */ jsxs(Text, { color: "red", children: [
|
|
3787
|
+
"[",
|
|
3788
|
+
m.error ? "send failed" : "decrypt failed",
|
|
3789
|
+
"]"
|
|
3790
|
+
] }) : m.image ? /* @__PURE__ */ jsxs(Text, { color: "cyan", children: [
|
|
3791
|
+
"\u{1F5BC} ",
|
|
3792
|
+
m.image.fileName,
|
|
3793
|
+
m.body ? ` \u2014 ${m.body}` : ""
|
|
3794
|
+
] }) : renderBody(m)
|
|
3795
|
+
] }),
|
|
3796
|
+
onCursor && m.image?.previewDataUri && (img ? /* @__PURE__ */ jsx(Text, { children: img }) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: " rendering preview\u2026" }))
|
|
3797
|
+
] }, m.syncId);
|
|
3798
|
+
})
|
|
3799
|
+
] }) }),
|
|
3800
|
+
current && !showDiscussions && /* @__PURE__ */ jsxs(Box, { children: [
|
|
3801
|
+
/* @__PURE__ */ jsx(Text, { color: focus === "input" ? "green" : "gray", children: focus === "input" ? "\u203A " : " " }),
|
|
3802
|
+
/* @__PURE__ */ jsx(TextInput, { value: draft, onChange: setDraft, onSubmit: send, focus: focus === "input", placeholder: focus === "input" ? "type a message\u2026" : "Enter to write" })
|
|
3803
|
+
] })
|
|
3804
|
+
] })
|
|
3805
|
+
] }),
|
|
3806
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
3807
|
+
" ",
|
|
3808
|
+
footerHint(focus, openThreadId != null)
|
|
3809
|
+
] }) })
|
|
3810
|
+
] });
|
|
3811
|
+
}
|
|
3812
|
+
function footerHint(focus, inThread) {
|
|
3813
|
+
if (focus === "input") return "Enter send \xB7 Esc cancel";
|
|
3814
|
+
if (focus === "discussions") return "\u2191/\u2193 discussions \xB7 Enter open \xB7 Esc back";
|
|
3815
|
+
if (focus === "thread") return `\u2191/\u2193 messages \xB7 \u2190 ${inThread ? "discussions" : "chats"} \xB7 Enter write \xB7 q quit`;
|
|
3816
|
+
return "\u2191/\u2193 chats \xB7 \u2192 open thread \xB7 t discussions \xB7 Enter write \xB7 q quit";
|
|
3817
|
+
}
|
|
3818
|
+
function windowAround(selected, total, size) {
|
|
3819
|
+
if (total <= size) return { start: 0, end: total };
|
|
3820
|
+
let start = selected - Math.floor(size / 2);
|
|
3821
|
+
start = Math.max(0, Math.min(start, total - size));
|
|
3822
|
+
return { start, end: start + size };
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
// src/tui/index.tsx
|
|
3826
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
3827
|
+
async function runTui() {
|
|
3828
|
+
const { waitUntilExit } = render(/* @__PURE__ */ jsx2(App, {}));
|
|
3829
|
+
await waitUntilExit();
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
// src/cli/tui.ts
|
|
3833
|
+
function createTuiCommand() {
|
|
3834
|
+
const cmd = new Command13("tui");
|
|
3835
|
+
cmd.description("Interactive terminal UI: chat list + live message thread").option("--host <host>", "eXpress host").action(async () => {
|
|
3836
|
+
try {
|
|
3837
|
+
await runTui();
|
|
3838
|
+
} catch (err) {
|
|
3839
|
+
console.error(`Error: ${err.message}`);
|
|
3840
|
+
process.exit(1);
|
|
3841
|
+
}
|
|
3842
|
+
});
|
|
3843
|
+
return cmd;
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
// src/cli/mcp.ts
|
|
3847
|
+
import { Command as Command14 } from "commander";
|
|
3848
|
+
|
|
3849
|
+
// src/mcp/server.ts
|
|
3850
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3851
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3852
|
+
import { z as z2 } from "zod";
|
|
3853
|
+
var ok = (data) => ({
|
|
3854
|
+
content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }]
|
|
3855
|
+
});
|
|
3856
|
+
var Inbox = class {
|
|
3857
|
+
buf = [];
|
|
3858
|
+
seq = 0;
|
|
3859
|
+
cursor = 0;
|
|
3860
|
+
waiters = [];
|
|
3861
|
+
add(m) {
|
|
3862
|
+
this.buf.push({ seq: ++this.seq, chatId: m.chatId, sender: m.sender, body: m.body, time: m.insertedAt, decrypted: m.decrypted, image: m.image?.fileName });
|
|
3863
|
+
if (this.buf.length > 500) this.buf.splice(0, this.buf.length - 500);
|
|
3864
|
+
const woken = this.waiters;
|
|
3865
|
+
this.waiters = [];
|
|
3866
|
+
woken.forEach((fn) => fn());
|
|
3867
|
+
}
|
|
3868
|
+
pending() {
|
|
3869
|
+
return this.buf.filter((x) => x.seq > this.cursor);
|
|
3870
|
+
}
|
|
3871
|
+
/** Return (and consume) messages arrived since the last take, waiting up to timeoutMs. */
|
|
3872
|
+
async take(timeoutMs) {
|
|
3873
|
+
if (this.pending().length === 0) {
|
|
3874
|
+
await new Promise((resolve) => {
|
|
3875
|
+
const fn = () => {
|
|
3876
|
+
clearTimeout(t);
|
|
3877
|
+
resolve();
|
|
3878
|
+
};
|
|
3879
|
+
const t = setTimeout(() => {
|
|
3880
|
+
this.waiters = this.waiters.filter((w) => w !== fn);
|
|
3881
|
+
resolve();
|
|
3882
|
+
}, timeoutMs);
|
|
3883
|
+
this.waiters.push(fn);
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
const items = this.pending();
|
|
3887
|
+
if (this.buf.length) this.cursor = this.buf[this.buf.length - 1].seq;
|
|
3888
|
+
return items;
|
|
3889
|
+
}
|
|
3890
|
+
};
|
|
3891
|
+
async function runMcpServer() {
|
|
3892
|
+
const server = new McpServer({ name: "express-cli", version: "0.1.0" });
|
|
3893
|
+
const inbox = new Inbox();
|
|
3894
|
+
const chatNames = /* @__PURE__ */ new Map();
|
|
3895
|
+
const senderNames = /* @__PURE__ */ new Map();
|
|
3896
|
+
let sessionReady = false;
|
|
3897
|
+
let session = null;
|
|
3898
|
+
try {
|
|
3899
|
+
session = new ExpressSession();
|
|
3900
|
+
session.on("connected", () => {
|
|
3901
|
+
sessionReady = true;
|
|
3902
|
+
});
|
|
3903
|
+
session.on("chats", (list) => {
|
|
3904
|
+
for (const c of list) chatNames.set(c.group_chat_id, c.name?.trim() || c.group_chat_id.slice(0, 8));
|
|
3905
|
+
});
|
|
3906
|
+
session.on("message", (m) => inbox.add(m));
|
|
3907
|
+
session.connect().catch((err) => {
|
|
3908
|
+
process.stderr.write(`[mcp] session connect failed: ${err.message}
|
|
3909
|
+
`);
|
|
3910
|
+
});
|
|
3911
|
+
listChatsWithNames(new ApiClient()).then((chats) => {
|
|
3912
|
+
for (const c of chats) chatNames.set(c.group_chat_id, c.name ?? c.group_chat_id.slice(0, 8));
|
|
3913
|
+
}).catch(() => {
|
|
3914
|
+
});
|
|
3915
|
+
} catch (err) {
|
|
3916
|
+
process.stderr.write(`[mcp] session unavailable: ${err.message}
|
|
3917
|
+
`);
|
|
3918
|
+
}
|
|
3919
|
+
const enrich = async (items) => {
|
|
3920
|
+
const unknown = [...new Set(items.map((i) => i.sender))].filter((h) => h && h.length >= 30 && !senderNames.has(h));
|
|
3921
|
+
if (unknown.length) {
|
|
3922
|
+
try {
|
|
3923
|
+
const profiles = await new UserApi(new ApiClient()).getProfilesByHuid(unknown);
|
|
3924
|
+
for (const p of profiles) if (p.name) senderNames.set(p.user_huid, p.name);
|
|
3925
|
+
} catch {
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
return items.map((i) => ({
|
|
3929
|
+
time: i.time,
|
|
3930
|
+
chat: chatNames.get(i.chatId) ?? i.chatId.slice(0, 8),
|
|
3931
|
+
chat_id: i.chatId,
|
|
3932
|
+
sender: senderNames.get(i.sender) ?? i.sender.slice(0, 8),
|
|
3933
|
+
message: i.decrypted ? i.image ? `\u{1F5BC} ${i.image}` : i.body : "[decrypt failed]"
|
|
3934
|
+
}));
|
|
3935
|
+
};
|
|
3936
|
+
server.registerTool("chats_list", {
|
|
3937
|
+
title: "List chats",
|
|
3938
|
+
description: "List chats (DMs, groups, channels) with names and full chat IDs. DM names are resolved to the person's full name.",
|
|
3939
|
+
inputSchema: { type: z2.enum(["all", "dm", "group", "channel"]).optional().describe("Filter by chat type (default all)") }
|
|
3940
|
+
}, async ({ type }) => {
|
|
3941
|
+
const chats = await listChatsWithNames(new ApiClient());
|
|
3942
|
+
const kind = { dm: "chat", group: "group_chat", channel: "channel" };
|
|
3943
|
+
const filtered = !type || type === "all" ? chats : chats.filter((c) => c.chat_type === kind[type]);
|
|
3944
|
+
return ok(filtered.map((c) => ({ name: c.name, chat_id: c.group_chat_id, type: c.chat_type, members: c.members_count })));
|
|
3945
|
+
});
|
|
3946
|
+
server.registerTool("chats_find", {
|
|
3947
|
+
title: "Find a chat by name",
|
|
3948
|
+
description: "Find chats whose name (person or group) contains the query. Returns name + full chat_id to use with other tools.",
|
|
3949
|
+
inputSchema: { query: z2.string().describe("Part of the chat or person name") }
|
|
3950
|
+
}, async ({ query }) => {
|
|
3951
|
+
const chats = await listChatsWithNames(new ApiClient());
|
|
3952
|
+
const q = query.toLowerCase();
|
|
3953
|
+
return ok(chats.filter((c) => (c.name ?? "").toLowerCase().includes(q)).map((c) => ({ name: c.name, chat_id: c.group_chat_id, type: c.chat_type })));
|
|
3954
|
+
});
|
|
3955
|
+
server.registerTool("messages_list", {
|
|
3956
|
+
title: "Read messages",
|
|
3957
|
+
description: "Read and decrypt recent messages from a chat, identified by name or chat_id.",
|
|
3958
|
+
inputSchema: {
|
|
3959
|
+
chat: z2.string().describe("Chat name (partial ok) or full chat_id"),
|
|
3960
|
+
limit: z2.number().int().min(1).max(200).optional().describe("How many recent messages (default 20)")
|
|
3961
|
+
}
|
|
3962
|
+
}, async ({ chat, limit }) => {
|
|
3963
|
+
const client = new ApiClient();
|
|
3964
|
+
const chatId = await resolveChatId(client, chat);
|
|
3965
|
+
const msgs = await readMessages({ chatId, limit: limit ?? 20 });
|
|
3966
|
+
return ok(msgs.map((m) => ({
|
|
3967
|
+
time: m.inserted_at,
|
|
3968
|
+
sender: m.sender,
|
|
3969
|
+
body: m.decrypted ? m.image ? `\u{1F5BC} ${m.image.fileName}${m.body ? ` \u2014 ${m.body}` : ""}` : m.body : `[decrypt failed: ${m.error}]`
|
|
3970
|
+
})));
|
|
3971
|
+
});
|
|
3972
|
+
server.registerTool("send_message", {
|
|
3973
|
+
title: "Send a message",
|
|
3974
|
+
description: "Send a text message to a chat, identified by name (partial ok) or chat_id. Returns the sync_id.",
|
|
3975
|
+
inputSchema: { chat: z2.string().describe("Chat name or full chat_id"), text: z2.string().describe("Message text") }
|
|
3976
|
+
}, async ({ chat, text }) => {
|
|
3977
|
+
const client = new ApiClient();
|
|
3978
|
+
const chatId = await resolveChatId(client, chat);
|
|
3979
|
+
const res = await sendMessageViaWebSocket({ client, chatId, body: text });
|
|
3980
|
+
return ok({ sent: true, chat_id: chatId, sync_id: res.sync_id });
|
|
3981
|
+
});
|
|
3982
|
+
server.registerTool("contacts_search", {
|
|
3983
|
+
title: "Search employees",
|
|
3984
|
+
description: "Global company phonebook search across all employees by name.",
|
|
3985
|
+
inputSchema: { query: z2.string().describe("Name to search"), limit: z2.number().int().min(1).max(50).optional() }
|
|
3986
|
+
}, async ({ query, limit }) => {
|
|
3987
|
+
const profiles = await new PhonebookApi(new ApiClient()).searchUsers(query, limit ?? 20);
|
|
3988
|
+
return ok(profiles.map((p) => ({ name: p.name, huid: p.user_huid, email: p.email, position: p.company_position, department: p.department })));
|
|
3989
|
+
});
|
|
3990
|
+
server.registerTool("contacts_self", {
|
|
3991
|
+
title: "My profile",
|
|
3992
|
+
description: "Get the authenticated user's own profile.",
|
|
3993
|
+
inputSchema: {}
|
|
3994
|
+
}, async () => ok(await new UserApi(new ApiClient()).getSelfProfile()));
|
|
3995
|
+
server.registerTool("wait_for_messages", {
|
|
3996
|
+
title: "Wait for incoming messages",
|
|
3997
|
+
description: "Block until new incoming messages arrive (from any chat/discussion), then return them. Returns messages received since the previous call to this tool; if none are pending, waits up to timeout_seconds. Your own sent messages are not included. Use this to react to new messages instead of polling.",
|
|
3998
|
+
inputSchema: { timeout_seconds: z2.number().int().min(1).max(120).optional().describe("Max seconds to wait when nothing is pending (default 30)") }
|
|
3999
|
+
}, async ({ timeout_seconds }) => {
|
|
4000
|
+
if (!session) return ok({ error: "Session unavailable \u2014 run 'express auth qr' to authenticate." });
|
|
4001
|
+
const items = await inbox.take((timeout_seconds ?? 30) * 1e3);
|
|
4002
|
+
return ok({ connected: sessionReady, count: items.length, messages: await enrich(items) });
|
|
4003
|
+
});
|
|
4004
|
+
server.registerTool("status", {
|
|
4005
|
+
title: "Auth status",
|
|
4006
|
+
description: "Check authentication and access-token status.",
|
|
4007
|
+
inputSchema: {}
|
|
4008
|
+
}, async () => {
|
|
4009
|
+
const exp = getTokenExpiresAt();
|
|
4010
|
+
return ok({
|
|
4011
|
+
authenticated: !!getAuthToken(),
|
|
4012
|
+
token_expires_in_seconds: exp ? Math.max(0, Math.floor((exp - Date.now()) / 1e3)) : null
|
|
4013
|
+
});
|
|
4014
|
+
});
|
|
4015
|
+
await server.connect(new StdioServerTransport());
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
// src/cli/mcp.ts
|
|
4019
|
+
function createMcpCommand() {
|
|
4020
|
+
const cmd = new Command14("mcp");
|
|
4021
|
+
cmd.description("Run as an MCP server (stdio): exposes chats/messages/contacts tools to MCP hosts").action(async () => {
|
|
4022
|
+
try {
|
|
4023
|
+
await runMcpServer();
|
|
4024
|
+
} catch (err) {
|
|
4025
|
+
console.error(`Error: ${err.message}`);
|
|
4026
|
+
process.exit(1);
|
|
4027
|
+
}
|
|
4028
|
+
});
|
|
4029
|
+
return cmd;
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
// src/cli/root.ts
|
|
4033
|
+
function createRootCommand() {
|
|
4034
|
+
const program2 = new Command15();
|
|
4035
|
+
program2.name("express-cli").description("CLI client for eXpress Chat").version("0.1.0").option("--host <host>", "eXpress server host");
|
|
4036
|
+
program2.addCommand(createAuthCommand());
|
|
4037
|
+
program2.addCommand(createApiCommand());
|
|
4038
|
+
program2.addCommand(createConfigCommand());
|
|
4039
|
+
program2.addCommand(createStatusCommand());
|
|
4040
|
+
program2.addCommand(createContactsCommand());
|
|
4041
|
+
program2.addCommand(createSettingsCommand());
|
|
4042
|
+
program2.addCommand(createChatsCommand());
|
|
4043
|
+
program2.addCommand(createSendCommand());
|
|
4044
|
+
program2.addCommand(createAvatarCommand());
|
|
4045
|
+
program2.addCommand(createDownloadCommand());
|
|
4046
|
+
program2.addCommand(createMessagesCommand());
|
|
4047
|
+
program2.addCommand(createListenCommand());
|
|
4048
|
+
program2.addCommand(createTuiCommand());
|
|
4049
|
+
program2.addCommand(createMcpCommand());
|
|
4050
|
+
return program2;
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
// src/index.ts
|
|
4054
|
+
var program = createRootCommand();
|
|
4055
|
+
program.parse();
|
|
4056
|
+
//# sourceMappingURL=index.js.map
|