@vex-chat/cli 0.1.3
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/CLA.md +38 -0
- package/LICENSE +661 -0
- package/LICENSE-COMMERCIAL +10 -0
- package/LICENSING.md +15 -0
- package/README.md +62 -0
- package/package.json +53 -0
- package/src/vex-chat.js +4100 -0
- package/theme.yaml +36 -0
package/src/vex-chat.js
ADDED
|
@@ -0,0 +1,4100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Client, DeviceApprovalRequiredError } from "@vex-chat/libvex";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { createWriteStream } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
10
|
+
import * as fs from "node:fs/promises";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { unpack } from "msgpackr";
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const CLI_VERSION = require("../package.json").version;
|
|
17
|
+
const DEFAULT_HOST = "api.vex.wtf";
|
|
18
|
+
const LOCAL_HOST = "127.0.0.1:16777";
|
|
19
|
+
const COLOR = process.env.NO_COLOR === undefined;
|
|
20
|
+
const ANSI = {
|
|
21
|
+
blue: "\x1b[34m",
|
|
22
|
+
bold: "\x1b[1m",
|
|
23
|
+
cyan: "\x1b[36m",
|
|
24
|
+
dim: "\x1b[2m",
|
|
25
|
+
green: "\x1b[32m",
|
|
26
|
+
magenta: "\x1b[35m",
|
|
27
|
+
red: "\x1b[31m",
|
|
28
|
+
reset: "\x1b[0m",
|
|
29
|
+
reverse: "\x1b[7m",
|
|
30
|
+
white: "\x1b[37m",
|
|
31
|
+
yellow: "\x1b[33m",
|
|
32
|
+
azure: "\x1b[38;5;39m",
|
|
33
|
+
brightWhite: "\x1b[38;2;245;245;245m",
|
|
34
|
+
chartreuse: "\x1b[38;5;154m",
|
|
35
|
+
coral: "\x1b[38;5;203m",
|
|
36
|
+
cream: "\x1b[38;2;217;194;163m",
|
|
37
|
+
fireRed: "\x1b[38;2;231;0;0m",
|
|
38
|
+
forestGreen: "\x1b[38;2;43;80;29m",
|
|
39
|
+
gold: "\x1b[38;5;220m",
|
|
40
|
+
iceBlue: "\x1b[38;2;168;200;223m",
|
|
41
|
+
incineratorGreen: "\x1b[38;2;145;230;67m",
|
|
42
|
+
indigo: "\x1b[38;5;63m",
|
|
43
|
+
lavender: "\x1b[38;5;141m",
|
|
44
|
+
lime: "\x1b[38;5;118m",
|
|
45
|
+
mint: "\x1b[38;5;121m",
|
|
46
|
+
monokaiBlue: "\x1b[38;2;102;217;239m",
|
|
47
|
+
monokaiGreen: "\x1b[38;2;166;226;46m",
|
|
48
|
+
monokaiOrange: "\x1b[38;2;253;151;31m",
|
|
49
|
+
monokaiPink: "\x1b[38;2;249;38;114m",
|
|
50
|
+
monokaiPurple: "\x1b[38;2;174;129;255m",
|
|
51
|
+
monokaiYellow: "\x1b[38;2;230;219;116m",
|
|
52
|
+
nightBlack: "\x1b[38;2;10;10;10m",
|
|
53
|
+
orange: "\x1b[38;5;208m",
|
|
54
|
+
peachPink: "\x1b[38;2;197;105;139m",
|
|
55
|
+
pink: "\x1b[38;5;213m",
|
|
56
|
+
plum: "\x1b[38;5;177m",
|
|
57
|
+
royalPurple: "\x1b[38;2;42;7;91m",
|
|
58
|
+
sky: "\x1b[38;5;117m",
|
|
59
|
+
steel: "\x1b[38;5;67m",
|
|
60
|
+
teal: "\x1b[38;5;44m",
|
|
61
|
+
};
|
|
62
|
+
const ROOT_ACCENT = "#E70000";
|
|
63
|
+
// Mirrors apps/vex-cli/theme.yaml until theme loading becomes configurable.
|
|
64
|
+
const USER_ACCENTS = [
|
|
65
|
+
"#E70000",
|
|
66
|
+
"#91e643",
|
|
67
|
+
"#a8c8df",
|
|
68
|
+
"#c5698b",
|
|
69
|
+
"#d9c2a3",
|
|
70
|
+
"#2a075b",
|
|
71
|
+
"#2b501d",
|
|
72
|
+
"#F5F5F5",
|
|
73
|
+
];
|
|
74
|
+
const TARGET_ACCENTS = ["#a8c8df", "#d9c2a3", "#2b501d", "#2a075b", "#c5698b"];
|
|
75
|
+
|
|
76
|
+
async function main() {
|
|
77
|
+
const { flags, positionals } = parseArgs(process.argv.slice(2));
|
|
78
|
+
let command = positionals.shift() ?? "chat";
|
|
79
|
+
const ctx = await createContext(flags);
|
|
80
|
+
try {
|
|
81
|
+
if (!isCommand(command)) {
|
|
82
|
+
positionals.unshift(command);
|
|
83
|
+
command = "chat";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
switch (command) {
|
|
87
|
+
case "auth":
|
|
88
|
+
await authCommand(ctx, positionals);
|
|
89
|
+
break;
|
|
90
|
+
case "register":
|
|
91
|
+
await register(ctx, positionals);
|
|
92
|
+
break;
|
|
93
|
+
case "login":
|
|
94
|
+
await login(ctx, positionals);
|
|
95
|
+
break;
|
|
96
|
+
case "whoami":
|
|
97
|
+
await whoami(ctx, positionals);
|
|
98
|
+
break;
|
|
99
|
+
case "user":
|
|
100
|
+
await withReadyClient(
|
|
101
|
+
ctx,
|
|
102
|
+
positionals,
|
|
103
|
+
async (client, args) => {
|
|
104
|
+
const identifier = requireArg(
|
|
105
|
+
args,
|
|
106
|
+
0,
|
|
107
|
+
"user identifier",
|
|
108
|
+
);
|
|
109
|
+
const [user, err] =
|
|
110
|
+
await client.users.retrieve(identifier);
|
|
111
|
+
if (!user) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
err?.message ?? `User not found: ${identifier}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
printUser(user);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
break;
|
|
120
|
+
case "dm":
|
|
121
|
+
await dmCommand(ctx, positionals);
|
|
122
|
+
break;
|
|
123
|
+
case "server":
|
|
124
|
+
await serverCommand(ctx, positionals);
|
|
125
|
+
break;
|
|
126
|
+
case "servers":
|
|
127
|
+
await withReadyClient(ctx, positionals, async (client) => {
|
|
128
|
+
printServers(await client.servers.retrieve());
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
case "channels":
|
|
132
|
+
await withReadyClient(
|
|
133
|
+
ctx,
|
|
134
|
+
positionals,
|
|
135
|
+
async (client, args) => {
|
|
136
|
+
const serverID = requireArg(args, 0, "server id");
|
|
137
|
+
printChannels(await client.channels.retrieve(serverID));
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
break;
|
|
141
|
+
case "channel":
|
|
142
|
+
await channelCommand(ctx, positionals);
|
|
143
|
+
break;
|
|
144
|
+
case "invite":
|
|
145
|
+
await inviteCommand(ctx, positionals);
|
|
146
|
+
break;
|
|
147
|
+
case "group":
|
|
148
|
+
await groupCommand(ctx, positionals);
|
|
149
|
+
break;
|
|
150
|
+
case "send":
|
|
151
|
+
await sendCommand(ctx, positionals);
|
|
152
|
+
break;
|
|
153
|
+
case "chat":
|
|
154
|
+
await chat(ctx, positionals);
|
|
155
|
+
break;
|
|
156
|
+
case "help":
|
|
157
|
+
case "--help":
|
|
158
|
+
case "-h":
|
|
159
|
+
printHelp();
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
throw new Error(`Unknown command: ${command}`);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
await closeDebugStream(ctx);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isCommand(command) {
|
|
170
|
+
return [
|
|
171
|
+
"--help",
|
|
172
|
+
"-h",
|
|
173
|
+
"auth",
|
|
174
|
+
"channel",
|
|
175
|
+
"channels",
|
|
176
|
+
"chat",
|
|
177
|
+
"dm",
|
|
178
|
+
"group",
|
|
179
|
+
"help",
|
|
180
|
+
"invite",
|
|
181
|
+
"login",
|
|
182
|
+
"register",
|
|
183
|
+
"send",
|
|
184
|
+
"server",
|
|
185
|
+
"servers",
|
|
186
|
+
"user",
|
|
187
|
+
"whoami",
|
|
188
|
+
].includes(command);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseArgs(argv) {
|
|
192
|
+
const flags = {};
|
|
193
|
+
const positionals = [];
|
|
194
|
+
for (let i = 0; i < argv.length; i++) {
|
|
195
|
+
const arg = argv[i];
|
|
196
|
+
if (arg === "--") {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!arg.startsWith("--")) {
|
|
200
|
+
positionals.push(arg);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const key = arg.slice(2);
|
|
204
|
+
if (["debug", "http", "help", "local", "no-home"].includes(key)) {
|
|
205
|
+
flags[key] = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const next = argv[++i];
|
|
209
|
+
if (!next) throw new Error(`Missing value for --${key}`);
|
|
210
|
+
flags[key] = next;
|
|
211
|
+
}
|
|
212
|
+
return { flags, positionals };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function createContext(flags) {
|
|
216
|
+
const debugLevel = normalizeDebugLevel(
|
|
217
|
+
flags["debug-level"] ??
|
|
218
|
+
process.env.VEX_CHAT_DEBUG_LEVEL ??
|
|
219
|
+
process.env.VEX_CHAT_DEBUG,
|
|
220
|
+
);
|
|
221
|
+
const debug = Boolean(flags.debug) || debugLevel !== "off";
|
|
222
|
+
const local = Boolean(flags.local) || process.env.VEX_CHAT_LOCAL === "1";
|
|
223
|
+
const apiUrl = flags["api-url"];
|
|
224
|
+
const envApiUrl = process.env.VEX_CHAT_API_URL ?? process.env.API_URL;
|
|
225
|
+
const host = String(
|
|
226
|
+
local
|
|
227
|
+
? LOCAL_HOST
|
|
228
|
+
: (hostFromApiUrl(apiUrl) ??
|
|
229
|
+
flags.host ??
|
|
230
|
+
process.env.VEX_CHAT_HOST ??
|
|
231
|
+
process.env.API_HOST ??
|
|
232
|
+
hostFromApiUrl(envApiUrl) ??
|
|
233
|
+
DEFAULT_HOST),
|
|
234
|
+
);
|
|
235
|
+
const unsafeHttp =
|
|
236
|
+
local ||
|
|
237
|
+
Boolean(flags.http) ||
|
|
238
|
+
process.env.VEX_CHAT_HTTP === "1" ||
|
|
239
|
+
httpFromApiUrl(apiUrl ?? envApiUrl) ||
|
|
240
|
+
isLocalHost(host);
|
|
241
|
+
if (unsafeHttp && !process.env.NODE_ENV) {
|
|
242
|
+
process.env.NODE_ENV = "development";
|
|
243
|
+
}
|
|
244
|
+
const dataDir = path.resolve(
|
|
245
|
+
String(
|
|
246
|
+
flags["data-dir"] ??
|
|
247
|
+
process.env.VEX_CHAT_DATA_DIR ??
|
|
248
|
+
path.join(os.homedir(), ".vex-chat-cli"),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
252
|
+
await fs.mkdir(path.join(dataDir, "db"), { recursive: true, mode: 0o700 });
|
|
253
|
+
const activeDebugLevel =
|
|
254
|
+
debug && debugLevel === "off" ? "debug" : debugLevel;
|
|
255
|
+
const enableLibvexMailDebug =
|
|
256
|
+
process.env.VEX_CHAT_LIBVEX_DEBUG === "1" ||
|
|
257
|
+
shouldDebugAtLevel(activeDebugLevel, "trace");
|
|
258
|
+
if (enableLibvexMailDebug && !process.env.LIBVEX_DEBUG_DM) {
|
|
259
|
+
process.env.LIBVEX_DEBUG_DM = "1";
|
|
260
|
+
}
|
|
261
|
+
if (debug && !process.env.LIBVEX_DEBUG_LEVEL) {
|
|
262
|
+
process.env.LIBVEX_DEBUG_LEVEL = activeDebugLevel;
|
|
263
|
+
}
|
|
264
|
+
const debugFile = debug ? resolveDebugFile(flags, dataDir) : null;
|
|
265
|
+
if (debugFile) {
|
|
266
|
+
await fs.mkdir(path.dirname(debugFile), {
|
|
267
|
+
recursive: true,
|
|
268
|
+
mode: 0o700,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const debugStream = debugFile
|
|
272
|
+
? createWriteStream(debugFile, { flags: "a", mode: 0o600 })
|
|
273
|
+
: null;
|
|
274
|
+
const configPath = path.join(dataDir, "config.json");
|
|
275
|
+
return {
|
|
276
|
+
dataDir,
|
|
277
|
+
configPath,
|
|
278
|
+
clientOptions: {
|
|
279
|
+
dbFolder: path.join(dataDir, "db"),
|
|
280
|
+
deviceName: "vex-chat-cli",
|
|
281
|
+
host,
|
|
282
|
+
unsafeHttp,
|
|
283
|
+
...(flags["dev-key"] || process.env.DEV_API_KEY
|
|
284
|
+
? {
|
|
285
|
+
devApiKey: String(
|
|
286
|
+
flags["dev-key"] ?? process.env.DEV_API_KEY,
|
|
287
|
+
),
|
|
288
|
+
}
|
|
289
|
+
: {}),
|
|
290
|
+
},
|
|
291
|
+
username:
|
|
292
|
+
flags.username || flags.user
|
|
293
|
+
? String(flags.username ?? flags.user).toLowerCase()
|
|
294
|
+
: undefined,
|
|
295
|
+
noHome: Boolean(flags["no-home"]),
|
|
296
|
+
password: flags.password ? String(flags.password) : undefined,
|
|
297
|
+
debug,
|
|
298
|
+
debugFile,
|
|
299
|
+
debugLevel: debug ? activeDebugLevel : "off",
|
|
300
|
+
debugStream,
|
|
301
|
+
sound: normalizeSound(
|
|
302
|
+
flags.sound ?? process.env.VEX_CHAT_SOUND ?? "Glass",
|
|
303
|
+
),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function resolveDebugFile(flags, dataDir) {
|
|
308
|
+
const explicit = flags["debug-file"] ?? process.env.VEX_CHAT_DEBUG_FILE;
|
|
309
|
+
if (explicit) {
|
|
310
|
+
return path.resolve(String(explicit));
|
|
311
|
+
}
|
|
312
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
313
|
+
return path.join(dataDir, "logs", `vex-debug-${stamp}.log`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function closeDebugStream(ctx) {
|
|
317
|
+
if (!ctx?.debugStream) return;
|
|
318
|
+
await new Promise((resolve) => ctx.debugStream.end(resolve));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeDebugLevel(value) {
|
|
322
|
+
const raw = String(value ?? "")
|
|
323
|
+
.trim()
|
|
324
|
+
.toLowerCase();
|
|
325
|
+
if (!raw || ["0", "false", "off", "none"].includes(raw)) return "off";
|
|
326
|
+
if (["1", "true", "debug", "verbose"].includes(raw)) return "debug";
|
|
327
|
+
if (["2", "trace", "silly"].includes(raw)) return "trace";
|
|
328
|
+
return "debug";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isLocalHost(host) {
|
|
332
|
+
const h = host.split(":")[0];
|
|
333
|
+
return h === "127.0.0.1" || h === "localhost" || h === "::1";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizeSound(value) {
|
|
337
|
+
const sound = String(value ?? "").trim();
|
|
338
|
+
if (!sound || ["0", "false", "none", "off"].includes(sound.toLowerCase())) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
return sound;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function hostFromApiUrl(raw) {
|
|
345
|
+
if (!raw) return undefined;
|
|
346
|
+
try {
|
|
347
|
+
return new URL(raw).host;
|
|
348
|
+
} catch {
|
|
349
|
+
return raw;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function httpFromApiUrl(raw) {
|
|
354
|
+
if (!raw) return false;
|
|
355
|
+
try {
|
|
356
|
+
return new URL(raw).protocol === "http:";
|
|
357
|
+
} catch {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function register(ctx, args) {
|
|
363
|
+
const username = (args[0] ?? ctx.username)?.toLowerCase();
|
|
364
|
+
const password = args[1] ?? ctx.password;
|
|
365
|
+
if (!username) {
|
|
366
|
+
throw new Error("Usage: vex-chat register <username> [password]");
|
|
367
|
+
}
|
|
368
|
+
const config = await readConfig(ctx.configPath);
|
|
369
|
+
if (config.accounts[username]) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Local account already exists for ${username}. Use login or remove it from ${ctx.configPath}.`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
const privateKey = Client.generateSecretKey();
|
|
375
|
+
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
376
|
+
attachDebugClientEvents(ctx, client, `register:${username}`);
|
|
377
|
+
try {
|
|
378
|
+
try {
|
|
379
|
+
const [, registerErr] = await client.register(username, password);
|
|
380
|
+
if (registerErr) throw registerErr;
|
|
381
|
+
await connectAndWait(client, ctx, `register:${username}`);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (!isDeviceApprovalRequired(err)) throw err;
|
|
384
|
+
await waitForDeviceApproval(
|
|
385
|
+
ctx,
|
|
386
|
+
client,
|
|
387
|
+
config,
|
|
388
|
+
username,
|
|
389
|
+
privateKey,
|
|
390
|
+
{
|
|
391
|
+
challenge: err.challenge,
|
|
392
|
+
expiresAt: err.expiresAt,
|
|
393
|
+
requestID: err.requestID,
|
|
394
|
+
userID: err.userID,
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
await persistNewLocalAccount(ctx, config, username, privateKey, client);
|
|
400
|
+
console.log(
|
|
401
|
+
`${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
402
|
+
);
|
|
403
|
+
printWhoami(client);
|
|
404
|
+
} finally {
|
|
405
|
+
await client.close().catch(() => {});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function persistNewLocalAccount(
|
|
410
|
+
ctx,
|
|
411
|
+
config,
|
|
412
|
+
username,
|
|
413
|
+
privateKey,
|
|
414
|
+
client,
|
|
415
|
+
deviceID = client.me.device().deviceID,
|
|
416
|
+
) {
|
|
417
|
+
config.accounts[username] = {
|
|
418
|
+
deviceID,
|
|
419
|
+
privateKey,
|
|
420
|
+
userID: client.me.user().userID,
|
|
421
|
+
username,
|
|
422
|
+
};
|
|
423
|
+
config.lastUsername = username;
|
|
424
|
+
await writeConfig(ctx.configPath, config);
|
|
425
|
+
return config.accounts[username];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function persistPendingLocalAccount(
|
|
429
|
+
ctx,
|
|
430
|
+
config,
|
|
431
|
+
username,
|
|
432
|
+
privateKey,
|
|
433
|
+
pending,
|
|
434
|
+
) {
|
|
435
|
+
const previous = config.accounts[username] ?? {};
|
|
436
|
+
config.accounts[username] = {
|
|
437
|
+
...previous,
|
|
438
|
+
privateKey,
|
|
439
|
+
pendingApproval: {
|
|
440
|
+
challenge: pending.challenge,
|
|
441
|
+
expiresAt: pending.expiresAt,
|
|
442
|
+
requestID: pending.requestID,
|
|
443
|
+
},
|
|
444
|
+
...(pending.userID ? { userID: pending.userID } : {}),
|
|
445
|
+
username,
|
|
446
|
+
};
|
|
447
|
+
config.lastUsername = username;
|
|
448
|
+
await writeConfig(ctx.configPath, config);
|
|
449
|
+
return config.accounts[username];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function login(ctx, args) {
|
|
453
|
+
const username = (args[0] ?? ctx.username)?.toLowerCase();
|
|
454
|
+
const password = args[1] ?? ctx.password;
|
|
455
|
+
if (!username) {
|
|
456
|
+
throw new Error("Usage: vex-chat login <username> [password]");
|
|
457
|
+
}
|
|
458
|
+
if (!password) {
|
|
459
|
+
await loginWithDeviceApproval(ctx, username);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const { client, config } = await makeClient(ctx, username);
|
|
463
|
+
attachDebugClientEvents(ctx, client, `login:${username}`);
|
|
464
|
+
try {
|
|
465
|
+
const loginResult = await client.login(username, password);
|
|
466
|
+
if (!loginResult.ok)
|
|
467
|
+
throw new Error(loginResult.error ?? "Login failed.");
|
|
468
|
+
await connectAndWait(client, ctx, `login:${username}`);
|
|
469
|
+
config.accounts[username] = {
|
|
470
|
+
deviceID: client.me.device().deviceID,
|
|
471
|
+
privateKey: client.getKeys().private,
|
|
472
|
+
userID: client.me.user().userID,
|
|
473
|
+
username,
|
|
474
|
+
};
|
|
475
|
+
config.lastUsername = username;
|
|
476
|
+
await writeConfig(ctx.configPath, config);
|
|
477
|
+
console.log(
|
|
478
|
+
`${color(ROOT_ACCENT, "logged in")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
479
|
+
);
|
|
480
|
+
printWhoami(client);
|
|
481
|
+
} finally {
|
|
482
|
+
await client.close().catch(() => {});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function loginWithDeviceApproval(ctx, username) {
|
|
487
|
+
const config = await readConfig(ctx.configPath);
|
|
488
|
+
if (config.accounts[username]) {
|
|
489
|
+
const { client } = await authenticate(ctx, username);
|
|
490
|
+
try {
|
|
491
|
+
console.log(
|
|
492
|
+
`${color(ROOT_ACCENT, "using")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
493
|
+
);
|
|
494
|
+
printWhoami(client);
|
|
495
|
+
} finally {
|
|
496
|
+
await client.close().catch(() => {});
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const privateKey = Client.generateSecretKey();
|
|
502
|
+
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
503
|
+
attachDebugClientEvents(ctx, client, `login-request:${username}`);
|
|
504
|
+
try {
|
|
505
|
+
const [, registerErr] = await client.register(username);
|
|
506
|
+
if (!registerErr) {
|
|
507
|
+
await connectAndWait(client, ctx, `login-request:${username}`);
|
|
508
|
+
await persistNewLocalAccount(
|
|
509
|
+
ctx,
|
|
510
|
+
config,
|
|
511
|
+
username,
|
|
512
|
+
privateKey,
|
|
513
|
+
client,
|
|
514
|
+
);
|
|
515
|
+
console.log(
|
|
516
|
+
`${color(ROOT_ACCENT, "registered")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
517
|
+
);
|
|
518
|
+
printWhoami(client);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!isDeviceApprovalRequired(registerErr)) {
|
|
522
|
+
throw registerErr;
|
|
523
|
+
}
|
|
524
|
+
await waitForDeviceApproval(ctx, client, config, username, privateKey, {
|
|
525
|
+
challenge: registerErr.challenge,
|
|
526
|
+
expiresAt: registerErr.expiresAt,
|
|
527
|
+
requestID: registerErr.requestID,
|
|
528
|
+
userID: registerErr.userID,
|
|
529
|
+
});
|
|
530
|
+
} finally {
|
|
531
|
+
await client.close().catch(() => {});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function waitForDeviceApproval(
|
|
536
|
+
ctx,
|
|
537
|
+
client,
|
|
538
|
+
config,
|
|
539
|
+
username,
|
|
540
|
+
privateKey,
|
|
541
|
+
pending,
|
|
542
|
+
) {
|
|
543
|
+
await persistPendingLocalAccount(
|
|
544
|
+
ctx,
|
|
545
|
+
config,
|
|
546
|
+
username,
|
|
547
|
+
privateKey,
|
|
548
|
+
pending,
|
|
549
|
+
);
|
|
550
|
+
const existingApprovedDeviceID = await resolveStoredDeviceID(
|
|
551
|
+
ctx,
|
|
552
|
+
client,
|
|
553
|
+
{},
|
|
554
|
+
username,
|
|
555
|
+
);
|
|
556
|
+
if (existingApprovedDeviceID) {
|
|
557
|
+
return completeApprovedDeviceLogin(
|
|
558
|
+
ctx,
|
|
559
|
+
client,
|
|
560
|
+
config,
|
|
561
|
+
username,
|
|
562
|
+
privateKey,
|
|
563
|
+
existingApprovedDeviceID,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const code = matchingCodeStringForSignKey(client.getKeys().public);
|
|
568
|
+
console.log(
|
|
569
|
+
`${color(ROOT_ACCENT, "device approval required")} ${color("dim", `request=${pending.requestID}`)}`,
|
|
570
|
+
);
|
|
571
|
+
console.log(
|
|
572
|
+
`${color("dim", "matching code")} ${formatDeviceApprovalCode(code)}`,
|
|
573
|
+
);
|
|
574
|
+
console.log(
|
|
575
|
+
color(
|
|
576
|
+
"dim",
|
|
577
|
+
"Confirm this code on an existing signed-in device before approving.",
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
if (input.isTTY && output.isTTY) {
|
|
582
|
+
const rl = createInterface({ input, output });
|
|
583
|
+
try {
|
|
584
|
+
const answer = (await askText(rl, "notify existing devices?", "Y"))
|
|
585
|
+
.trim()
|
|
586
|
+
.toLowerCase();
|
|
587
|
+
if (answer === "n" || answer === "no") {
|
|
588
|
+
await client.devices
|
|
589
|
+
.abortPendingRegistration({
|
|
590
|
+
challenge: pending.challenge,
|
|
591
|
+
requestID: pending.requestID,
|
|
592
|
+
})
|
|
593
|
+
.catch(() => {});
|
|
594
|
+
delete config.accounts[username];
|
|
595
|
+
await writeConfig(ctx.configPath, config);
|
|
596
|
+
throw new Error("Device login cancelled.");
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
rl.close();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
await client.devices
|
|
604
|
+
.publishPendingRegistration({
|
|
605
|
+
challenge: pending.challenge,
|
|
606
|
+
requestID: pending.requestID,
|
|
607
|
+
})
|
|
608
|
+
.catch(async (err) => {
|
|
609
|
+
const approvedDeviceID = await resolveStoredDeviceID(
|
|
610
|
+
ctx,
|
|
611
|
+
client,
|
|
612
|
+
{},
|
|
613
|
+
username,
|
|
614
|
+
);
|
|
615
|
+
if (!approvedDeviceID) throw err;
|
|
616
|
+
});
|
|
617
|
+
console.log(color(ROOT_ACCENT, "waiting for approval..."));
|
|
618
|
+
|
|
619
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
620
|
+
await sleep(2000);
|
|
621
|
+
const current = await client.devices.pollPendingRegistration({
|
|
622
|
+
challenge: pending.challenge,
|
|
623
|
+
requestID: pending.requestID,
|
|
624
|
+
});
|
|
625
|
+
if (!current || current.status === "pending") {
|
|
626
|
+
const approvedDeviceID = await resolveStoredDeviceID(
|
|
627
|
+
ctx,
|
|
628
|
+
client,
|
|
629
|
+
{},
|
|
630
|
+
username,
|
|
631
|
+
);
|
|
632
|
+
if (approvedDeviceID) {
|
|
633
|
+
if (input.isTTY && output.isTTY) output.write("\n");
|
|
634
|
+
return completeApprovedDeviceLogin(
|
|
635
|
+
ctx,
|
|
636
|
+
client,
|
|
637
|
+
config,
|
|
638
|
+
username,
|
|
639
|
+
privateKey,
|
|
640
|
+
approvedDeviceID,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (input.isTTY && output.isTTY) output.write(color("dim", "."));
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (input.isTTY && output.isTTY) output.write("\n");
|
|
647
|
+
if (current.status === "approved" && current.approvedDeviceID) {
|
|
648
|
+
return completeApprovedDeviceLogin(
|
|
649
|
+
ctx,
|
|
650
|
+
client,
|
|
651
|
+
config,
|
|
652
|
+
username,
|
|
653
|
+
privateKey,
|
|
654
|
+
current.approvedDeviceID,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
if (current.status === "approved") {
|
|
658
|
+
const approvedDeviceID = await resolveStoredDeviceID(
|
|
659
|
+
ctx,
|
|
660
|
+
client,
|
|
661
|
+
{},
|
|
662
|
+
username,
|
|
663
|
+
);
|
|
664
|
+
if (approvedDeviceID) {
|
|
665
|
+
return completeApprovedDeviceLogin(
|
|
666
|
+
ctx,
|
|
667
|
+
client,
|
|
668
|
+
config,
|
|
669
|
+
username,
|
|
670
|
+
privateKey,
|
|
671
|
+
approvedDeviceID,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
delete config.accounts[username];
|
|
676
|
+
await writeConfig(ctx.configPath, config);
|
|
677
|
+
throw new Error(`Device login ${current.status}.`);
|
|
678
|
+
}
|
|
679
|
+
throw new Error("Timed out waiting for device approval.");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function completeApprovedDeviceLogin(
|
|
683
|
+
ctx,
|
|
684
|
+
client,
|
|
685
|
+
config,
|
|
686
|
+
username,
|
|
687
|
+
privateKey,
|
|
688
|
+
deviceID,
|
|
689
|
+
) {
|
|
690
|
+
const authErr = await client.loginWithDeviceKey(deviceID);
|
|
691
|
+
if (authErr) throw authErr;
|
|
692
|
+
await connectAndWait(client, ctx, `login-approved:${username}`);
|
|
693
|
+
const account = await persistNewLocalAccount(
|
|
694
|
+
ctx,
|
|
695
|
+
config,
|
|
696
|
+
username,
|
|
697
|
+
privateKey,
|
|
698
|
+
client,
|
|
699
|
+
deviceID,
|
|
700
|
+
);
|
|
701
|
+
console.log(
|
|
702
|
+
`${color(ROOT_ACCENT, "approved")} ${color(userAccent(client.me.user().userID), username)}`,
|
|
703
|
+
);
|
|
704
|
+
printWhoami(client);
|
|
705
|
+
return { account, client, config };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isDeviceApprovalRequired(err) {
|
|
709
|
+
return (
|
|
710
|
+
err instanceof DeviceApprovalRequiredError ||
|
|
711
|
+
err?.name === "DeviceApprovalRequiredError"
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function authCommand(ctx, args) {
|
|
716
|
+
const sub = args.shift() ?? "status";
|
|
717
|
+
switch (sub) {
|
|
718
|
+
case "register":
|
|
719
|
+
case "signup":
|
|
720
|
+
await register(ctx, args);
|
|
721
|
+
return;
|
|
722
|
+
case "login":
|
|
723
|
+
await login(ctx, args);
|
|
724
|
+
return;
|
|
725
|
+
case "requests":
|
|
726
|
+
case "approvals":
|
|
727
|
+
await deviceRequestsCommand(ctx, args);
|
|
728
|
+
return;
|
|
729
|
+
case "status":
|
|
730
|
+
case "whoami":
|
|
731
|
+
await whoami(ctx, args);
|
|
732
|
+
return;
|
|
733
|
+
case "accounts":
|
|
734
|
+
case "list":
|
|
735
|
+
await listAccounts(ctx);
|
|
736
|
+
return;
|
|
737
|
+
case "use":
|
|
738
|
+
await useAccount(ctx, args);
|
|
739
|
+
return;
|
|
740
|
+
default:
|
|
741
|
+
throw new Error(
|
|
742
|
+
"Usage: vex auth register <username> | login <username> [password] | requests | use <username> | accounts | status",
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function whoami(ctx, args) {
|
|
748
|
+
const username = args[0];
|
|
749
|
+
await withReadyClient(
|
|
750
|
+
{ ...ctx, username: username ?? ctx.username },
|
|
751
|
+
[],
|
|
752
|
+
async (client) => {
|
|
753
|
+
printWhoami(client);
|
|
754
|
+
},
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function listAccounts(ctx) {
|
|
759
|
+
const config = await readConfig(ctx.configPath);
|
|
760
|
+
const names = Object.keys(config.accounts).sort();
|
|
761
|
+
if (names.length === 0) {
|
|
762
|
+
console.log(color("dim", "no local accounts"));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
for (const name of names) {
|
|
766
|
+
const marker = name === config.lastUsername ? "*" : " ";
|
|
767
|
+
const account = config.accounts[name];
|
|
768
|
+
console.log(
|
|
769
|
+
`${color(marker === "*" ? ROOT_ACCENT : "dim", marker)} ${color(userAccent(account.userID), name)} ${color("dim", `user=${account.userID}`)} ${color("dim", `device=${account.deviceID}`)}`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function deviceRequestsCommand(ctx, args) {
|
|
775
|
+
await withReadyClient(ctx, [], async (client) => {
|
|
776
|
+
const action = (args[0] ?? "list").toLowerCase();
|
|
777
|
+
if (action === "approve" || action === "reject") {
|
|
778
|
+
const requestID = requireArg(args, 1, "request id");
|
|
779
|
+
if (action === "approve") {
|
|
780
|
+
await client.devices.approveRequest(requestID);
|
|
781
|
+
console.log(color(ROOT_ACCENT, "device request approved"));
|
|
782
|
+
} else {
|
|
783
|
+
await client.devices.rejectRequest(requestID);
|
|
784
|
+
console.log(color(ROOT_ACCENT, "device request rejected"));
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const requests = (await client.devices.listRequests()).filter(
|
|
789
|
+
(request) => request.status === "pending",
|
|
790
|
+
);
|
|
791
|
+
printDeviceRequests(requests);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function printDeviceRequests(requests) {
|
|
796
|
+
if (requests.length === 0) {
|
|
797
|
+
console.log(color("dim", "no pending device requests"));
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
for (const request of requests) {
|
|
801
|
+
console.log(formatDeviceRequestLine(request));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function formatDeviceRequestLine(request) {
|
|
806
|
+
const code = formatDeviceApprovalCode(request.signKey);
|
|
807
|
+
const device = color("white", request.deviceName ?? "unknown device");
|
|
808
|
+
const username = request.username
|
|
809
|
+
? ` ${color("dim", "for")} ${color(ROOT_ACCENT, `@${request.username}`)}`
|
|
810
|
+
: "";
|
|
811
|
+
return `${color(ROOT_ACCENT, "device request")} ${device}${username} ${code} ${color("dim", `request=${request.requestID}`)}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function useAccount(ctx, args) {
|
|
815
|
+
const username = requireArg(args, 0, "username").toLowerCase();
|
|
816
|
+
const config = await readConfig(ctx.configPath);
|
|
817
|
+
if (!config.accounts[username]) {
|
|
818
|
+
throw new Error(
|
|
819
|
+
`No local account for ${username}. Run vex auth register ${username} first.`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
config.lastUsername = username;
|
|
823
|
+
await writeConfig(ctx.configPath, config);
|
|
824
|
+
const account = config.accounts[username];
|
|
825
|
+
console.log(
|
|
826
|
+
`${color(ROOT_ACCENT, "using")} ${color(userAccent(account.userID), username)}`,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function dmCommand(ctx, args) {
|
|
831
|
+
const sub = args[0];
|
|
832
|
+
if (sub === "send") {
|
|
833
|
+
args.shift();
|
|
834
|
+
}
|
|
835
|
+
if (sub === "history") {
|
|
836
|
+
args.shift();
|
|
837
|
+
await withReadyClient(ctx, args, async (client, rest) => {
|
|
838
|
+
const user = await resolveUser(client, requireArg(rest, 0, "user"));
|
|
839
|
+
const history = await client.messages.retrieve(user.userID);
|
|
840
|
+
await printMessages(client, history, {
|
|
841
|
+
names: historyNameCache(client.me.user(), user),
|
|
842
|
+
targetLabel: `@${user.username}`,
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
await withReadyClient(ctx, args, async (client, rest) => {
|
|
848
|
+
const identifier = requireArg(rest, 0, "recipient");
|
|
849
|
+
const message = rest.slice(1).join(" ").trim();
|
|
850
|
+
if (!message) throw new Error("Message text is required.");
|
|
851
|
+
const user = await resolveUser(client, identifier);
|
|
852
|
+
await client.messages.send(user.userID, message);
|
|
853
|
+
console.log(
|
|
854
|
+
`${color(ROOT_ACCENT, "sent dm to")} ${color(userAccent(user.userID), user.username)}`,
|
|
855
|
+
);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function serverCommand(ctx, args) {
|
|
860
|
+
const sub = args.shift() ?? "list";
|
|
861
|
+
await withReadyClient(ctx, args, async (client, rest) => {
|
|
862
|
+
if (sub === "list" || sub === "ls") {
|
|
863
|
+
printServers(await client.servers.retrieve());
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (sub === "create") {
|
|
867
|
+
const name = rest.join(" ").trim();
|
|
868
|
+
if (!name) throw new Error("Usage: vex-chat server create <name>");
|
|
869
|
+
const server = await client.servers.create(name);
|
|
870
|
+
console.log(
|
|
871
|
+
`${color(ROOT_ACCENT, "created server")} ${color(serverAccent(server.serverID), server.name)} ${color("dim", server.serverID)}`,
|
|
872
|
+
);
|
|
873
|
+
printChannels(await client.channels.retrieve(server.serverID));
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (sub === "delete") {
|
|
877
|
+
await client.servers.delete(requireArg(rest, 0, "server id"));
|
|
878
|
+
console.log(color(ROOT_ACCENT, "server deleted"));
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
throw new Error(
|
|
882
|
+
"Usage: vex server list | create <name> | delete <server-id>",
|
|
883
|
+
);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function channelCommand(ctx, args) {
|
|
888
|
+
const sub = args.shift() ?? "list";
|
|
889
|
+
if (sub === "use") {
|
|
890
|
+
await useChannel(ctx, args);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
await withReadyClient(ctx, args, async (client, rest) => {
|
|
894
|
+
if (sub === "list" || sub === "ls") {
|
|
895
|
+
const serverID = requireArg(rest, 0, "server id");
|
|
896
|
+
printChannels(await client.channels.retrieve(serverID));
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (sub === "history") {
|
|
900
|
+
const accountState = accountUiState(meta.config, meta.account);
|
|
901
|
+
const channelID = rest[0] ?? accountState.lastChannel;
|
|
902
|
+
if (!channelID)
|
|
903
|
+
throw new Error(
|
|
904
|
+
"Missing channel id. Use vex channel use <channel-id> or pass one.",
|
|
905
|
+
);
|
|
906
|
+
await printMessages(
|
|
907
|
+
client,
|
|
908
|
+
await client.messages.retrieveGroup(channelID),
|
|
909
|
+
{
|
|
910
|
+
names: historyNameCache(client.me.user()),
|
|
911
|
+
},
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (sub === "create") {
|
|
916
|
+
const serverID = requireArg(rest, 0, "server id");
|
|
917
|
+
const name = rest.slice(1).join(" ").trim();
|
|
918
|
+
if (!name) throw new Error("Channel name is required.");
|
|
919
|
+
const channel = await client.channels.create(name, serverID);
|
|
920
|
+
console.log(
|
|
921
|
+
`${color(ROOT_ACCENT, "created channel")} ${color(channelAccent(channel), `#${channel.name}`)} ${color("dim", channel.channelID)}`,
|
|
922
|
+
);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
throw new Error(
|
|
926
|
+
"Usage: vex channel list <server-id> | create <server-id> <name> | use <channel-id> | history [channel-id]",
|
|
927
|
+
);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function inviteCommand(ctx, args) {
|
|
932
|
+
const sub = args.shift() ?? "list";
|
|
933
|
+
await withReadyClient(ctx, args, async (client, rest) => {
|
|
934
|
+
if (sub === "list" || sub === "ls") {
|
|
935
|
+
const serverID = requireArg(rest, 0, "server id");
|
|
936
|
+
const invites = await client.invites.retrieve(serverID);
|
|
937
|
+
if (invites.length === 0) {
|
|
938
|
+
console.log(color("dim", "no invites"));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
for (const invite of invites) {
|
|
942
|
+
console.log(
|
|
943
|
+
`${color(inviteAccent(invite.inviteID), inviteLink(invite.inviteID))} ${color("dim", `server=${invite.serverID}`)} ${color("dim", `expires=${invite.expires}`)}`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (sub === "create") {
|
|
949
|
+
const serverID = requireArg(rest, 0, "server id");
|
|
950
|
+
const duration = rest[1] ?? "1h";
|
|
951
|
+
const invite = await client.invites.create(serverID, duration);
|
|
952
|
+
printInvite(invite);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (sub === "redeem") {
|
|
956
|
+
const inviteID = parseInviteID(requireArg(rest, 0, "invite id"));
|
|
957
|
+
const permission = await client.invites.redeem(inviteID);
|
|
958
|
+
console.log(
|
|
959
|
+
`${color(ROOT_ACCENT, "redeemed invite")} ${color("dim", `for ${permission.resourceType} ${permission.resourceID}`)}`,
|
|
960
|
+
);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
throw new Error(
|
|
964
|
+
"Usage: vex invite list <server-id> | create <server-id> [duration] | redeem <invite-id>",
|
|
965
|
+
);
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function groupCommand(ctx, args) {
|
|
970
|
+
const sub = args[0];
|
|
971
|
+
if (sub === "send") {
|
|
972
|
+
args.shift();
|
|
973
|
+
}
|
|
974
|
+
await sendCommand(ctx, args);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function sendCommand(ctx, args) {
|
|
978
|
+
await withReadyClient(ctx, args, async (client, rest, meta) => {
|
|
979
|
+
const accountState = accountUiState(meta.config, meta.account);
|
|
980
|
+
let channelID = rest[0];
|
|
981
|
+
let messageParts = rest.slice(1);
|
|
982
|
+
if (messageParts.length === 0 && accountState.lastChannel) {
|
|
983
|
+
channelID = accountState.lastChannel;
|
|
984
|
+
messageParts = rest;
|
|
985
|
+
}
|
|
986
|
+
if (!channelID)
|
|
987
|
+
throw new Error(
|
|
988
|
+
"Missing channel id. Use vex channel use <channel-id> first, or pass one.",
|
|
989
|
+
);
|
|
990
|
+
const message = messageParts.join(" ").trim();
|
|
991
|
+
if (!message) throw new Error("Message text is required.");
|
|
992
|
+
await client.messages.group(channelID, message);
|
|
993
|
+
console.log(
|
|
994
|
+
`${color(ROOT_ACCENT, "sent group message to")} ${color(channelAccent(channelID), channelID)}`,
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function useChannel(ctx, args) {
|
|
1000
|
+
const channelID = requireArg(args, 0, "channel id");
|
|
1001
|
+
await withReadyClient(ctx, [], async (client, _rest, meta) => {
|
|
1002
|
+
const channel = await client.channels.retrieveByID(channelID);
|
|
1003
|
+
if (!channel) throw new Error(`Channel not found: ${channelID}`);
|
|
1004
|
+
await saveAccountUiState(ctx, meta.account, {
|
|
1005
|
+
lastChannel: channel.channelID,
|
|
1006
|
+
lastServer: channel.serverID,
|
|
1007
|
+
lastTarget: {
|
|
1008
|
+
id: channel.channelID,
|
|
1009
|
+
label: `#${channel.name}`,
|
|
1010
|
+
serverID: channel.serverID,
|
|
1011
|
+
type: "channel",
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
console.log(
|
|
1015
|
+
`${color(ROOT_ACCENT, "using")} ${color(channelAccent(channel), `#${channel.name}`)} ${color("dim", channel.channelID)}`,
|
|
1016
|
+
);
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function createServerInChat(ctx, client, state, name, rl) {
|
|
1021
|
+
const resolvedName = name || (rl ? await askText(rl, "server name") : "");
|
|
1022
|
+
if (!resolvedName) throw new Error("Server name is required.");
|
|
1023
|
+
debugLog(ctx, "server.create.start", { name: resolvedName });
|
|
1024
|
+
const server = await client.servers.create(resolvedName);
|
|
1025
|
+
const channels = await client.channels.retrieve(server.serverID);
|
|
1026
|
+
const channel = channels[0] ?? null;
|
|
1027
|
+
debugLog(ctx, "server.create.ok", {
|
|
1028
|
+
channelIDs: channels.map((item) => item.channelID),
|
|
1029
|
+
serverID: server.serverID,
|
|
1030
|
+
serverName: server.name,
|
|
1031
|
+
});
|
|
1032
|
+
await saveAccountUiState(ctx, state.account, {
|
|
1033
|
+
lastServer: server.serverID,
|
|
1034
|
+
});
|
|
1035
|
+
console.log(
|
|
1036
|
+
`${color(ROOT_ACCENT, "created server")} ${color(serverAccent(server.serverID), server.name)}`,
|
|
1037
|
+
);
|
|
1038
|
+
await refreshBuffers(client, state);
|
|
1039
|
+
if (channel) {
|
|
1040
|
+
await enterChannel(ctx, client, state, channel);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
async function createInviteInteractive(ctx, client, state, args, rl) {
|
|
1045
|
+
const config = await readConfig(ctx.configPath);
|
|
1046
|
+
const accountState = accountUiState(config, state.account);
|
|
1047
|
+
let serverID =
|
|
1048
|
+
state.target?.type === "channel" && state.target.serverID
|
|
1049
|
+
? state.target.serverID
|
|
1050
|
+
: accountState.lastServer;
|
|
1051
|
+
if (!serverID) {
|
|
1052
|
+
const server = await chooseServer(client, rl);
|
|
1053
|
+
if (!server) return;
|
|
1054
|
+
serverID = server.serverID;
|
|
1055
|
+
}
|
|
1056
|
+
const [first, second] = args;
|
|
1057
|
+
if (first && !looksLikeDuration(first)) {
|
|
1058
|
+
const user = await resolveUser(client, first);
|
|
1059
|
+
const duration = second ?? "1h";
|
|
1060
|
+
debugLog(ctx, "invite.dm.start", {
|
|
1061
|
+
duration,
|
|
1062
|
+
serverID,
|
|
1063
|
+
targetUserID: user.userID,
|
|
1064
|
+
targetUsername: user.username,
|
|
1065
|
+
});
|
|
1066
|
+
const invite = await client.invites.create(serverID, duration);
|
|
1067
|
+
await client.messages.send(
|
|
1068
|
+
user.userID,
|
|
1069
|
+
`Join ${state.target?.serverName ?? "my server"}: ${inviteLink(invite.inviteID)}`,
|
|
1070
|
+
);
|
|
1071
|
+
debugLog(ctx, "invite.dm.ok", {
|
|
1072
|
+
inviteID: invite.inviteID,
|
|
1073
|
+
targetUserID: user.userID,
|
|
1074
|
+
});
|
|
1075
|
+
console.log(
|
|
1076
|
+
`${color(ROOT_ACCENT, "sent invite to")} ${color(userAccent(user.userID), user.username)}`,
|
|
1077
|
+
);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const duration = first ?? (await askText(rl, "duration", "1h"));
|
|
1081
|
+
debugLog(ctx, "invite.create.start", {
|
|
1082
|
+
duration: duration || "1h",
|
|
1083
|
+
serverID,
|
|
1084
|
+
});
|
|
1085
|
+
const invite = await client.invites.create(serverID, duration || "1h");
|
|
1086
|
+
debugLog(ctx, "invite.create.ok", { inviteID: invite.inviteID, serverID });
|
|
1087
|
+
printInvite(invite);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function joinInviteInChat(ctx, client, state, rawInvite, rl) {
|
|
1091
|
+
const value = rawInvite || (await askText(rl, "invite code or link"));
|
|
1092
|
+
const inviteID = parseInviteID(value);
|
|
1093
|
+
debugLog(ctx, "invite.redeem.preview.start", { inviteID, raw: value });
|
|
1094
|
+
const preview = await fetchInvitePreview(client, inviteID);
|
|
1095
|
+
debugLog(ctx, "invite.redeem.preview.ok", {
|
|
1096
|
+
channelIDs: preview.channels.map((item) => item.channelID),
|
|
1097
|
+
inviteID,
|
|
1098
|
+
serverID: preview.server?.serverID,
|
|
1099
|
+
serverName: preview.server?.name,
|
|
1100
|
+
});
|
|
1101
|
+
printInvitePreview(preview);
|
|
1102
|
+
const answer = (await askText(rl, "join this server?", "Y"))
|
|
1103
|
+
.trim()
|
|
1104
|
+
.toLowerCase();
|
|
1105
|
+
if (answer && answer !== "y" && answer !== "yes") {
|
|
1106
|
+
console.log(color("dim", "cancelled"));
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
await redeemInviteInChat(ctx, client, state, inviteID, preview);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
async function joinServerOrInviteInChat(ctx, client, state, rawValue, rl) {
|
|
1114
|
+
if (isInviteInput(rawValue)) {
|
|
1115
|
+
await joinInviteInChat(ctx, client, state, rawValue, rl);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
await selectServerByName(ctx, client, state, rawValue, rl);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function redeemInviteInChat(ctx, client, state, inviteID, preview) {
|
|
1122
|
+
debugLog(ctx, "invite.redeem.start", { inviteID });
|
|
1123
|
+
const permission = await client.invites.redeem(inviteID);
|
|
1124
|
+
const server =
|
|
1125
|
+
preview.server ??
|
|
1126
|
+
(await client.servers.retrieveByID(permission.resourceID));
|
|
1127
|
+
const channels =
|
|
1128
|
+
preview.channels.length > 0
|
|
1129
|
+
? preview.channels
|
|
1130
|
+
: await client.channels
|
|
1131
|
+
.retrieve(permission.resourceID)
|
|
1132
|
+
.catch(() => []);
|
|
1133
|
+
console.log(
|
|
1134
|
+
`${color(ROOT_ACCENT, "joined")} ${color(serverAccent(server?.serverID), server?.name ?? "server")}`,
|
|
1135
|
+
);
|
|
1136
|
+
debugLog(ctx, "invite.redeem.ok", {
|
|
1137
|
+
channelIDs: channels.map((item) => item.channelID),
|
|
1138
|
+
permissionResourceID: permission.resourceID,
|
|
1139
|
+
permissionResourceType: permission.resourceType,
|
|
1140
|
+
serverID: server?.serverID,
|
|
1141
|
+
serverName: server?.name,
|
|
1142
|
+
});
|
|
1143
|
+
await refreshBuffers(client, state);
|
|
1144
|
+
|
|
1145
|
+
const channel =
|
|
1146
|
+
channels.find(
|
|
1147
|
+
(candidate) => candidate.name.toLowerCase() === "general",
|
|
1148
|
+
) ??
|
|
1149
|
+
channels[0] ??
|
|
1150
|
+
null;
|
|
1151
|
+
if (channel) {
|
|
1152
|
+
await enterChannel(ctx, client, state, channel, server);
|
|
1153
|
+
} else {
|
|
1154
|
+
console.log(
|
|
1155
|
+
color(
|
|
1156
|
+
"dim",
|
|
1157
|
+
"No channels in this server yet. Use /nav when one is available.",
|
|
1158
|
+
),
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function queueInvitePrompt(
|
|
1164
|
+
ctx,
|
|
1165
|
+
client,
|
|
1166
|
+
state,
|
|
1167
|
+
rl,
|
|
1168
|
+
inviteID,
|
|
1169
|
+
preview,
|
|
1170
|
+
sender = null,
|
|
1171
|
+
) {
|
|
1172
|
+
if (!rl || state.pendingInvitePrompts?.has(inviteID)) return;
|
|
1173
|
+
if (!state.pendingInvitePrompts) state.pendingInvitePrompts = new Set();
|
|
1174
|
+
state.pendingInvitePrompts.add(inviteID);
|
|
1175
|
+
state.promptQueue = (state.promptQueue ?? Promise.resolve())
|
|
1176
|
+
.catch(() => {})
|
|
1177
|
+
.then(async () => {
|
|
1178
|
+
const serverName = preview.server?.name ?? "this server";
|
|
1179
|
+
renderChatLine(
|
|
1180
|
+
rl,
|
|
1181
|
+
state,
|
|
1182
|
+
formatInvitePromptMessage(preview, inviteID, sender),
|
|
1183
|
+
);
|
|
1184
|
+
const answer = (await askText(rl, `join ${serverName}?`, "Y"))
|
|
1185
|
+
.trim()
|
|
1186
|
+
.toLowerCase();
|
|
1187
|
+
clearSubmittedPrompt();
|
|
1188
|
+
if (answer === "n" || answer === "no") {
|
|
1189
|
+
renderChatLine(
|
|
1190
|
+
rl,
|
|
1191
|
+
state,
|
|
1192
|
+
`${color(ROOT_ACCENT, "system")} ${color("dim", "invite dismissed")}`,
|
|
1193
|
+
);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
await redeemInviteInChat(ctx, client, state, inviteID, preview);
|
|
1197
|
+
})
|
|
1198
|
+
.catch((err) => {
|
|
1199
|
+
debugLog(ctx, "invite.prompt.error", { error: err, inviteID });
|
|
1200
|
+
renderChatLine(
|
|
1201
|
+
rl,
|
|
1202
|
+
state,
|
|
1203
|
+
`${color(ROOT_ACCENT, "system")} ${color(ROOT_ACCENT, err instanceof Error ? err.message : String(err))}`,
|
|
1204
|
+
);
|
|
1205
|
+
})
|
|
1206
|
+
.finally(() => {
|
|
1207
|
+
state.pendingInvitePrompts.delete(inviteID);
|
|
1208
|
+
refreshPrompt(rl, state);
|
|
1209
|
+
});
|
|
1210
|
+
return state.promptQueue;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function deferInvitePrompt(state, targetID, inviteID, preview, sender = null) {
|
|
1214
|
+
if (!targetID || !inviteID || !preview) return;
|
|
1215
|
+
if (!state.deferredInvitePrompts) state.deferredInvitePrompts = new Map();
|
|
1216
|
+
const key = `${targetID}:${inviteID}`;
|
|
1217
|
+
if (state.deferredInvitePrompts.has(key)) return;
|
|
1218
|
+
state.deferredInvitePrompts.set(key, {
|
|
1219
|
+
inviteID,
|
|
1220
|
+
preview,
|
|
1221
|
+
sender,
|
|
1222
|
+
targetID,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
async function flushDeferredInvitePrompts(ctx, client, state, rl, targetID) {
|
|
1227
|
+
if (!state.deferredInvitePrompts || !targetID) return;
|
|
1228
|
+
const prompts = [...state.deferredInvitePrompts.values()].filter(
|
|
1229
|
+
(item) => item.targetID === targetID,
|
|
1230
|
+
);
|
|
1231
|
+
for (const item of prompts) {
|
|
1232
|
+
state.deferredInvitePrompts.delete(`${item.targetID}:${item.inviteID}`);
|
|
1233
|
+
await queueInvitePrompt(
|
|
1234
|
+
ctx,
|
|
1235
|
+
client,
|
|
1236
|
+
state,
|
|
1237
|
+
rl,
|
|
1238
|
+
item.inviteID,
|
|
1239
|
+
item.preview,
|
|
1240
|
+
item.sender,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function fetchInvitePreview(client, inviteID) {
|
|
1246
|
+
try {
|
|
1247
|
+
const res = await client.http.get(
|
|
1248
|
+
`${client.getHost()}/invite/${inviteID}/preview`,
|
|
1249
|
+
);
|
|
1250
|
+
return unpack(new Uint8Array(res.data));
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
if (err?.response?.status !== 404) {
|
|
1253
|
+
throw err;
|
|
1254
|
+
}
|
|
1255
|
+
const res = await client.http.get(
|
|
1256
|
+
`${client.getHost()}/invite/${inviteID}`,
|
|
1257
|
+
);
|
|
1258
|
+
const invite = unpack(new Uint8Array(res.data));
|
|
1259
|
+
return { channels: [], invite, server: null };
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function selectDmInChat(ctx, client, state, names, identifier, rl) {
|
|
1264
|
+
const resolvedIdentifier =
|
|
1265
|
+
identifier || (await askText(rl, "username or user id"));
|
|
1266
|
+
const user = await resolveUser(client, resolvedIdentifier);
|
|
1267
|
+
names.set(user.userID, user.username);
|
|
1268
|
+
markDmRead(state, user.userID);
|
|
1269
|
+
if (
|
|
1270
|
+
state.pendingJump?.target?.type === "dm" &&
|
|
1271
|
+
state.pendingJump.target.id === user.userID
|
|
1272
|
+
) {
|
|
1273
|
+
state.pendingJump = null;
|
|
1274
|
+
}
|
|
1275
|
+
state.target = { id: user.userID, label: user.username, type: "dm" };
|
|
1276
|
+
addWindow(state, state.target);
|
|
1277
|
+
await saveTarget(ctx, state, state.target);
|
|
1278
|
+
await enterDm(client, state, user);
|
|
1279
|
+
await flushDeferredInvitePrompts(ctx, client, state, rl, user.userID);
|
|
1280
|
+
return user;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function bindKeypressShortcuts(ctx, client, state, names, rl) {
|
|
1284
|
+
if (!input.isTTY) return () => {};
|
|
1285
|
+
emitKeypressEvents(input, rl);
|
|
1286
|
+
const onKeypress = (_chunk, key = {}) => {
|
|
1287
|
+
if (key.name !== "tab" || !state.pendingJump) return;
|
|
1288
|
+
if ((rl.line ?? "").trim()) return;
|
|
1289
|
+
void jumpToPendingNotification(ctx, client, state, names, rl);
|
|
1290
|
+
};
|
|
1291
|
+
input.on("keypress", onKeypress);
|
|
1292
|
+
return () => input.off("keypress", onKeypress);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
async function jumpToPendingNotification(ctx, client, state, names, rl) {
|
|
1296
|
+
const pending = state.pendingJump;
|
|
1297
|
+
if (!pending) return;
|
|
1298
|
+
rl.write(null, { ctrl: true, name: "u" });
|
|
1299
|
+
clearActivePrompt();
|
|
1300
|
+
try {
|
|
1301
|
+
pushPreviousTarget(state, state.target);
|
|
1302
|
+
await openTarget(ctx, client, state, names, pending.target, rl);
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1305
|
+
} finally {
|
|
1306
|
+
safeSetPrompt(rl, promptFor(state));
|
|
1307
|
+
safePrompt(rl);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async function openTarget(ctx, client, state, names, target, rl) {
|
|
1312
|
+
if (target.type === "device-request") {
|
|
1313
|
+
await inspectDeviceRequestInChat(ctx, client, state, target.id, rl);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (target.type === "dm") {
|
|
1317
|
+
await selectDmInChat(ctx, client, state, names, target.id, rl);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const channel = {
|
|
1321
|
+
channelID: target.id,
|
|
1322
|
+
name: target.label.replace(/^#/, ""),
|
|
1323
|
+
serverID: target.serverID,
|
|
1324
|
+
};
|
|
1325
|
+
await enterChannel(ctx, client, state, channel, {
|
|
1326
|
+
name: target.serverName,
|
|
1327
|
+
serverID: target.serverID,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function enterDm(client, state, user) {
|
|
1332
|
+
clearScreen();
|
|
1333
|
+
renderHeader(state, client.me.user(), `@${user.username}`);
|
|
1334
|
+
console.log("");
|
|
1335
|
+
const history = await client.messages.retrieve(user.userID);
|
|
1336
|
+
if (history.length === 0) {
|
|
1337
|
+
console.log(color("dim", "No local history yet."));
|
|
1338
|
+
} else {
|
|
1339
|
+
console.log(color("bold", "Recent history"));
|
|
1340
|
+
await printMessages(client, history.slice(-30), {
|
|
1341
|
+
names: historyNameCache(client.me.user(), user),
|
|
1342
|
+
targetLabel: `@${user.username}`,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
console.log("");
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
async function restoreInitialTarget(ctx, client, state, names) {
|
|
1349
|
+
const target = state.target;
|
|
1350
|
+
if (!target) return false;
|
|
1351
|
+
try {
|
|
1352
|
+
if (target.type === "dm") {
|
|
1353
|
+
const user = await resolveUser(client, target.id);
|
|
1354
|
+
names.set(user.userID, user.username);
|
|
1355
|
+
state.target = {
|
|
1356
|
+
id: user.userID,
|
|
1357
|
+
label: user.username,
|
|
1358
|
+
type: "dm",
|
|
1359
|
+
};
|
|
1360
|
+
addWindow(state, state.target);
|
|
1361
|
+
await saveTarget(ctx, state, state.target);
|
|
1362
|
+
await enterDm(client, state, user);
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
if (
|
|
1366
|
+
!state.buffers.some(
|
|
1367
|
+
(buffer) =>
|
|
1368
|
+
buffer.type === "channel" && buffer.id === target.id,
|
|
1369
|
+
)
|
|
1370
|
+
) {
|
|
1371
|
+
debugLog(ctx, "target.restore.skip.inaccessible", { target });
|
|
1372
|
+
state.target = null;
|
|
1373
|
+
await saveTarget(ctx, state, null);
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
const channel = await client.channels.retrieveByID(target.id);
|
|
1377
|
+
if (!channel) return false;
|
|
1378
|
+
const server =
|
|
1379
|
+
target.serverID || channel.serverID
|
|
1380
|
+
? await client.servers
|
|
1381
|
+
.retrieveByID(target.serverID ?? channel.serverID)
|
|
1382
|
+
.catch(() => null)
|
|
1383
|
+
: null;
|
|
1384
|
+
await enterChannel(
|
|
1385
|
+
ctx,
|
|
1386
|
+
client,
|
|
1387
|
+
state,
|
|
1388
|
+
channel,
|
|
1389
|
+
server ?? {
|
|
1390
|
+
name: target.serverName,
|
|
1391
|
+
serverID: target.serverID ?? channel.serverID,
|
|
1392
|
+
},
|
|
1393
|
+
);
|
|
1394
|
+
return true;
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
debugLog(ctx, "target.restore.error", {
|
|
1397
|
+
error: err,
|
|
1398
|
+
target,
|
|
1399
|
+
});
|
|
1400
|
+
state.target = null;
|
|
1401
|
+
await saveTarget(ctx, state, null);
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async function openInbox(ctx, client, state, names, rl) {
|
|
1407
|
+
const rows = await listDmRows(client, state, names);
|
|
1408
|
+
if (rows.length === 0) {
|
|
1409
|
+
console.log(
|
|
1410
|
+
color("dim", "Inbox empty. Use /user <username> to open a DM."),
|
|
1411
|
+
);
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
const selected = await chooseItem(
|
|
1415
|
+
rl,
|
|
1416
|
+
"inbox",
|
|
1417
|
+
rows,
|
|
1418
|
+
(row) => renderDmChoice(row),
|
|
1419
|
+
{
|
|
1420
|
+
defaultIndex: defaultDmIndex(rows),
|
|
1421
|
+
},
|
|
1422
|
+
);
|
|
1423
|
+
if (!selected) return null;
|
|
1424
|
+
return selectDmInChat(ctx, client, state, names, selected.userID, rl);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
async function openDeviceRequests(ctx, client, state, rl) {
|
|
1428
|
+
const rows = (await client.devices.listRequests())
|
|
1429
|
+
.filter((request) => request.status === "pending")
|
|
1430
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1431
|
+
for (const request of rows) {
|
|
1432
|
+
state.deviceRequests.set(request.requestID, request);
|
|
1433
|
+
}
|
|
1434
|
+
if (rows.length === 0) {
|
|
1435
|
+
console.log(color("dim", "No pending device login requests."));
|
|
1436
|
+
return null;
|
|
1437
|
+
}
|
|
1438
|
+
const selected = await chooseItem(rl, "device request", rows, (request) =>
|
|
1439
|
+
formatDeviceRequestLine(request),
|
|
1440
|
+
);
|
|
1441
|
+
if (!selected) return null;
|
|
1442
|
+
await inspectDeviceRequestInChat(
|
|
1443
|
+
ctx,
|
|
1444
|
+
client,
|
|
1445
|
+
state,
|
|
1446
|
+
selected.requestID,
|
|
1447
|
+
rl,
|
|
1448
|
+
);
|
|
1449
|
+
return selected;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
async function handleDeviceRequestEvent(ctx, client, state, rl, update) {
|
|
1453
|
+
if (update.status !== "pending") {
|
|
1454
|
+
const cached = state.deviceRequests.get(update.requestID);
|
|
1455
|
+
state.deviceRequests.delete(update.requestID);
|
|
1456
|
+
if (
|
|
1457
|
+
state.pendingJump?.target?.type === "device-request" &&
|
|
1458
|
+
state.pendingJump.target.id === update.requestID
|
|
1459
|
+
) {
|
|
1460
|
+
state.pendingJump = null;
|
|
1461
|
+
}
|
|
1462
|
+
if (cached) {
|
|
1463
|
+
renderChatLine(
|
|
1464
|
+
rl,
|
|
1465
|
+
state,
|
|
1466
|
+
`${color(ROOT_ACCENT, "system")} device request ${color(ROOT_ACCENT, update.status)} ${color("dim", update.requestID)}`,
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const request = await client.devices
|
|
1473
|
+
.getRequest(update.requestID)
|
|
1474
|
+
.catch(() => null);
|
|
1475
|
+
if (!request || request.status !== "pending") return;
|
|
1476
|
+
state.deviceRequests.set(request.requestID, request);
|
|
1477
|
+
setPendingJump(
|
|
1478
|
+
state,
|
|
1479
|
+
{
|
|
1480
|
+
id: request.requestID,
|
|
1481
|
+
label: request.deviceName ?? "device request",
|
|
1482
|
+
type: "device-request",
|
|
1483
|
+
},
|
|
1484
|
+
request.createdAt,
|
|
1485
|
+
);
|
|
1486
|
+
renderDeviceRequestNotification(rl, state, request);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async function notifyPendingDeviceRequests(client, state, rl) {
|
|
1490
|
+
const requests = await client.devices.listRequests().catch(() => []);
|
|
1491
|
+
for (const request of requests) {
|
|
1492
|
+
if (request.status !== "pending") continue;
|
|
1493
|
+
if (state.deviceRequests.has(request.requestID)) continue;
|
|
1494
|
+
state.deviceRequests.set(request.requestID, request);
|
|
1495
|
+
setPendingJump(
|
|
1496
|
+
state,
|
|
1497
|
+
{
|
|
1498
|
+
id: request.requestID,
|
|
1499
|
+
label: request.deviceName ?? "device request",
|
|
1500
|
+
type: "device-request",
|
|
1501
|
+
},
|
|
1502
|
+
request.createdAt,
|
|
1503
|
+
);
|
|
1504
|
+
renderDeviceRequestNotification(rl, state, request);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function renderDeviceRequestNotification(rl, state, request) {
|
|
1509
|
+
renderChatLine(
|
|
1510
|
+
rl,
|
|
1511
|
+
state,
|
|
1512
|
+
`${color(ROOT_ACCENT, "system")} device login request from ${color("white", request.deviceName ?? "unknown device")} ${formatDeviceApprovalCode(request.signKey)} ${color("dim", "- press Tab to inspect")}`,
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function inspectDeviceRequestInChat(ctx, client, state, requestID, rl) {
|
|
1517
|
+
const request =
|
|
1518
|
+
(await client.devices.getRequest(requestID).catch(() => null)) ??
|
|
1519
|
+
state.deviceRequests.get(requestID);
|
|
1520
|
+
if (!request) {
|
|
1521
|
+
state.deviceRequests.delete(requestID);
|
|
1522
|
+
console.log(color("dim", "Device request no longer exists."));
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
if (request.status !== "pending") {
|
|
1526
|
+
state.deviceRequests.delete(requestID);
|
|
1527
|
+
console.log(color("dim", `Device request is ${request.status}.`));
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
renderChatLine(
|
|
1532
|
+
rl,
|
|
1533
|
+
state,
|
|
1534
|
+
[
|
|
1535
|
+
color(ROOT_ACCENT, "DEVICE LOGIN REQUEST"),
|
|
1536
|
+
`${color("dim", "device")} ${color("white", request.deviceName ?? "unknown device")}`,
|
|
1537
|
+
`${color("dim", "request")} ${request.requestID}`,
|
|
1538
|
+
`${color("dim", "code")} ${formatDeviceApprovalCode(request.signKey)}`,
|
|
1539
|
+
color("dim", "Approve only if this code matches the new device."),
|
|
1540
|
+
].join("\n"),
|
|
1541
|
+
);
|
|
1542
|
+
const answer = (await askText(rl, "approve this device?", "Y"))
|
|
1543
|
+
.trim()
|
|
1544
|
+
.toLowerCase();
|
|
1545
|
+
if (answer === "n" || answer === "no") {
|
|
1546
|
+
await client.devices.rejectRequest(request.requestID);
|
|
1547
|
+
state.deviceRequests.delete(request.requestID);
|
|
1548
|
+
clearPendingDeviceRequest(state, request.requestID);
|
|
1549
|
+
renderChatLine(
|
|
1550
|
+
rl,
|
|
1551
|
+
state,
|
|
1552
|
+
`${color(ROOT_ACCENT, "system")} device request rejected`,
|
|
1553
|
+
);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
await client.devices.approveRequest(request.requestID);
|
|
1557
|
+
state.deviceRequests.delete(request.requestID);
|
|
1558
|
+
clearPendingDeviceRequest(state, request.requestID);
|
|
1559
|
+
renderChatLine(
|
|
1560
|
+
rl,
|
|
1561
|
+
state,
|
|
1562
|
+
`${color(ROOT_ACCENT, "system")} device request approved`,
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function clearPendingDeviceRequest(state, requestID) {
|
|
1567
|
+
if (
|
|
1568
|
+
state.pendingJump?.target?.type === "device-request" &&
|
|
1569
|
+
state.pendingJump.target.id === requestID
|
|
1570
|
+
) {
|
|
1571
|
+
state.pendingJump = null;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
async function listDmRows(client, state, names) {
|
|
1576
|
+
const byUser = new Map();
|
|
1577
|
+
for (const user of await client.users.familiars().catch(() => [])) {
|
|
1578
|
+
names.set(user.userID, user.username);
|
|
1579
|
+
byUser.set(user.userID, {
|
|
1580
|
+
userID: user.userID,
|
|
1581
|
+
username: user.username,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
for (const buffer of state.buffers ?? []) {
|
|
1585
|
+
if (buffer.type !== "dm") continue;
|
|
1586
|
+
byUser.set(buffer.id, {
|
|
1587
|
+
...(byUser.get(buffer.id) ?? {}),
|
|
1588
|
+
userID: buffer.id,
|
|
1589
|
+
username: buffer.label,
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
for (const [userID, activity] of state.dms ?? []) {
|
|
1593
|
+
byUser.set(userID, {
|
|
1594
|
+
...(byUser.get(userID) ?? {}),
|
|
1595
|
+
...activity,
|
|
1596
|
+
userID,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
const rows = [];
|
|
1600
|
+
for (const row of byUser.values()) {
|
|
1601
|
+
const username =
|
|
1602
|
+
row.username ?? (await cachedUsername(client, names, row.userID));
|
|
1603
|
+
rows.push({ ...row, username });
|
|
1604
|
+
}
|
|
1605
|
+
return rows.sort((a, b) => {
|
|
1606
|
+
const unreadDiff = (b.unread ?? 0) - (a.unread ?? 0);
|
|
1607
|
+
if (unreadDiff !== 0) return unreadDiff;
|
|
1608
|
+
return String(b.lastAt ?? "").localeCompare(String(a.lastAt ?? ""));
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function renderDmChoice(row) {
|
|
1613
|
+
const unread =
|
|
1614
|
+
row.unread > 0
|
|
1615
|
+
? color(ROOT_ACCENT, `${row.unread} unread`)
|
|
1616
|
+
: color("dim", "read");
|
|
1617
|
+
const when = row.lastAt
|
|
1618
|
+
? color("dim", formatMessageTime(row.lastAt))
|
|
1619
|
+
: color("dim", "no recent messages");
|
|
1620
|
+
const preview = row.lastMessage
|
|
1621
|
+
? ` ${color("dim", truncateInline(row.lastMessage, 64))}`
|
|
1622
|
+
: "";
|
|
1623
|
+
return `${color(userAccent(row.userID), `@${row.username}`)} ${unread} ${when}${preview}`;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function defaultDmIndex(rows) {
|
|
1627
|
+
const unreadIndex = rows.findIndex((row) => row.unread > 0);
|
|
1628
|
+
return unreadIndex >= 0 ? unreadIndex : 0;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function sendDmInChat(
|
|
1632
|
+
ctx,
|
|
1633
|
+
client,
|
|
1634
|
+
state,
|
|
1635
|
+
names,
|
|
1636
|
+
identifier,
|
|
1637
|
+
messageParts,
|
|
1638
|
+
rl,
|
|
1639
|
+
) {
|
|
1640
|
+
const resolvedIdentifier =
|
|
1641
|
+
identifier || (await askText(rl, "username or user id"));
|
|
1642
|
+
const user = await resolveUser(client, resolvedIdentifier);
|
|
1643
|
+
names.set(user.userID, user.username);
|
|
1644
|
+
const message =
|
|
1645
|
+
messageParts.length > 0
|
|
1646
|
+
? messageParts.join(" ")
|
|
1647
|
+
: await askText(rl, `message to ${user.username}`);
|
|
1648
|
+
if (!message) {
|
|
1649
|
+
console.log(color("dim", "cancelled"));
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
debugLog(ctx, "message.send.dm.start", {
|
|
1653
|
+
message,
|
|
1654
|
+
targetUserID: user.userID,
|
|
1655
|
+
targetUsername: user.username,
|
|
1656
|
+
});
|
|
1657
|
+
beginSendingStatus(state, rl);
|
|
1658
|
+
try {
|
|
1659
|
+
await client.messages.send(user.userID, message);
|
|
1660
|
+
} finally {
|
|
1661
|
+
endSendingStatus(state, rl);
|
|
1662
|
+
}
|
|
1663
|
+
debugLog(ctx, "message.send.dm.ok", {
|
|
1664
|
+
message,
|
|
1665
|
+
targetUserID: user.userID,
|
|
1666
|
+
targetUsername: user.username,
|
|
1667
|
+
});
|
|
1668
|
+
addWindow(state, {
|
|
1669
|
+
id: user.userID,
|
|
1670
|
+
label: user.username,
|
|
1671
|
+
type: "dm",
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
async function selectChannelInChat(ctx, client, state, rl) {
|
|
1676
|
+
const channel = await chooseChannel(client, rl);
|
|
1677
|
+
if (!channel) return null;
|
|
1678
|
+
await enterChannel(
|
|
1679
|
+
ctx,
|
|
1680
|
+
client,
|
|
1681
|
+
state,
|
|
1682
|
+
channel,
|
|
1683
|
+
channel.serverName ? { name: channel.serverName } : null,
|
|
1684
|
+
);
|
|
1685
|
+
return channel;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
async function selectChannelByName(ctx, client, state, query, rl) {
|
|
1689
|
+
const channels = await listAllChannels(client);
|
|
1690
|
+
if (channels.length === 0) {
|
|
1691
|
+
console.log(
|
|
1692
|
+
color(
|
|
1693
|
+
"dim",
|
|
1694
|
+
"No channels. Use redeem <code> to accept an invite, or /create.",
|
|
1695
|
+
),
|
|
1696
|
+
);
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
const channel = query
|
|
1700
|
+
? await chooseBestMatch(
|
|
1701
|
+
rl,
|
|
1702
|
+
"channel",
|
|
1703
|
+
channels,
|
|
1704
|
+
query,
|
|
1705
|
+
channelSearchText,
|
|
1706
|
+
renderChannelChoice,
|
|
1707
|
+
)
|
|
1708
|
+
: await chooseItem(rl, "channel", channels, renderChannelChoice);
|
|
1709
|
+
if (!channel) return null;
|
|
1710
|
+
await enterChannel(ctx, client, state, channel, {
|
|
1711
|
+
name: channel.serverName,
|
|
1712
|
+
serverID: channel.serverID,
|
|
1713
|
+
});
|
|
1714
|
+
return channel;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
async function selectServerByName(ctx, client, state, query, rl) {
|
|
1718
|
+
const servers = await client.servers.retrieve();
|
|
1719
|
+
if (servers.length === 0) {
|
|
1720
|
+
console.log(
|
|
1721
|
+
color(
|
|
1722
|
+
"dim",
|
|
1723
|
+
"No servers. Use redeem <code> to accept an invite, or /create.",
|
|
1724
|
+
),
|
|
1725
|
+
);
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
const server = query
|
|
1729
|
+
? await chooseBestMatch(
|
|
1730
|
+
rl,
|
|
1731
|
+
"server",
|
|
1732
|
+
servers,
|
|
1733
|
+
query,
|
|
1734
|
+
(item) => item.name,
|
|
1735
|
+
(item) => color(serverAccent(item.serverID), item.name),
|
|
1736
|
+
)
|
|
1737
|
+
: await chooseItem(rl, "server", servers, (item) =>
|
|
1738
|
+
color(serverAccent(item.serverID), item.name),
|
|
1739
|
+
);
|
|
1740
|
+
if (!server) return null;
|
|
1741
|
+
const channel = await defaultChannelFromServer(client, server);
|
|
1742
|
+
if (channel) {
|
|
1743
|
+
await enterChannel(ctx, client, state, channel, server);
|
|
1744
|
+
}
|
|
1745
|
+
return server;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function navigateInChat(ctx, client, state, names, rl) {
|
|
1749
|
+
console.log(`${color(ROOT_ACCENT, "1")}. ${color("white", "channel")}`);
|
|
1750
|
+
console.log(
|
|
1751
|
+
`${color(ROOT_ACCENT, "2")}. ${color("white", "direct message")}`,
|
|
1752
|
+
);
|
|
1753
|
+
const answer = await askText(rl, "open");
|
|
1754
|
+
if (answer === "1" || answer.toLowerCase() === "channel") {
|
|
1755
|
+
await selectChannelInChat(ctx, client, state, rl);
|
|
1756
|
+
} else if (
|
|
1757
|
+
answer === "2" ||
|
|
1758
|
+
answer.toLowerCase() === "dm" ||
|
|
1759
|
+
answer.toLowerCase() === "direct message"
|
|
1760
|
+
) {
|
|
1761
|
+
await selectDmInChat(ctx, client, state, names, "", rl);
|
|
1762
|
+
} else {
|
|
1763
|
+
console.log(color("dim", "cancelled"));
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
async function chooseServer(client, rl) {
|
|
1768
|
+
const servers = await client.servers.retrieve();
|
|
1769
|
+
if (servers.length === 0) {
|
|
1770
|
+
console.log(
|
|
1771
|
+
color(
|
|
1772
|
+
"dim",
|
|
1773
|
+
"No servers yet. Use redeem <code> to accept an invite, or /create to make one.",
|
|
1774
|
+
),
|
|
1775
|
+
);
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
return chooseItem(rl, "server", servers, (server) =>
|
|
1779
|
+
color(serverAccent(server.serverID), server.name),
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async function chooseChannel(client, rl) {
|
|
1784
|
+
const server = await chooseServer(client, rl);
|
|
1785
|
+
if (!server) return null;
|
|
1786
|
+
return chooseChannelFromServer(client, server, rl);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async function defaultChannelFromServer(client, server) {
|
|
1790
|
+
const channels = await client.channels.retrieve(server.serverID);
|
|
1791
|
+
if (channels.length === 0) {
|
|
1792
|
+
console.log(color("dim", "no channels"));
|
|
1793
|
+
return null;
|
|
1794
|
+
}
|
|
1795
|
+
return {
|
|
1796
|
+
...channels[defaultChannelIndex(channels)],
|
|
1797
|
+
serverName: server.name,
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async function chooseChannelFromServer(client, server, rl) {
|
|
1802
|
+
const channels = await client.channels.retrieve(server.serverID);
|
|
1803
|
+
if (channels.length === 0) {
|
|
1804
|
+
console.log(color("dim", "no channels"));
|
|
1805
|
+
return null;
|
|
1806
|
+
}
|
|
1807
|
+
const detailRows = await Promise.all(
|
|
1808
|
+
channels.map(async (channel) => {
|
|
1809
|
+
try {
|
|
1810
|
+
const users = await client.channels.userList(channel.channelID);
|
|
1811
|
+
return { channel, members: users.length };
|
|
1812
|
+
} catch {
|
|
1813
|
+
return { channel, members: null };
|
|
1814
|
+
}
|
|
1815
|
+
}),
|
|
1816
|
+
);
|
|
1817
|
+
const row = await chooseItem(
|
|
1818
|
+
rl,
|
|
1819
|
+
"channel",
|
|
1820
|
+
detailRows,
|
|
1821
|
+
({ channel, members }) => {
|
|
1822
|
+
const memberText =
|
|
1823
|
+
members === null
|
|
1824
|
+
? ""
|
|
1825
|
+
: color(
|
|
1826
|
+
"dim",
|
|
1827
|
+
` - ${members} member${members === 1 ? "" : "s"}`,
|
|
1828
|
+
);
|
|
1829
|
+
return `${color(channelAccent(channel), `#${channel.name}`)}${memberText}`;
|
|
1830
|
+
},
|
|
1831
|
+
{
|
|
1832
|
+
defaultIndex: defaultChannelIndex(
|
|
1833
|
+
detailRows.map((row) => row.channel),
|
|
1834
|
+
),
|
|
1835
|
+
},
|
|
1836
|
+
);
|
|
1837
|
+
return row ? { ...row.channel, serverName: server.name } : null;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async function listAllChannels(client) {
|
|
1841
|
+
const channels = [];
|
|
1842
|
+
const servers = await client.servers.retrieve();
|
|
1843
|
+
for (const server of servers) {
|
|
1844
|
+
const serverChannels = await client.channels
|
|
1845
|
+
.retrieve(server.serverID)
|
|
1846
|
+
.catch(() => []);
|
|
1847
|
+
for (const channel of serverChannels) {
|
|
1848
|
+
channels.push({ ...channel, serverName: server.name });
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return channels;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
async function chooseBestMatch(rl, label, items, query, searchText, render) {
|
|
1855
|
+
const needle = normalizeSearch(query);
|
|
1856
|
+
const matches = items.filter((item) =>
|
|
1857
|
+
normalizeSearch(searchText(item)).includes(needle),
|
|
1858
|
+
);
|
|
1859
|
+
if (matches.length === 1) {
|
|
1860
|
+
return matches[0];
|
|
1861
|
+
}
|
|
1862
|
+
if (matches.length > 1) {
|
|
1863
|
+
console.log(color("dim", `Multiple ${label}s matched "${query}".`));
|
|
1864
|
+
return chooseItem(rl, label, matches, render, {
|
|
1865
|
+
defaultIndex: defaultMatchIndex(label, matches),
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
console.log(color("dim", `No ${label} matched "${query}".`));
|
|
1869
|
+
return chooseItem(rl, label, items, render, {
|
|
1870
|
+
defaultIndex: defaultMatchIndex(label, items),
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function channelSearchText(channel) {
|
|
1875
|
+
return `${channel.serverName ?? ""} ${channel.name} ${channel.serverName ?? ""}/${channel.name}`;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function renderChannelChoice(channel) {
|
|
1879
|
+
const server = channel.serverName ? `${channel.serverName}/` : "";
|
|
1880
|
+
return color(channelAccent(channel), `${server}#${channel.name}`);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function normalizeSearch(value) {
|
|
1884
|
+
return String(value).trim().toLowerCase().replace(/^#/, "");
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
async function chooseItem(rl, label, items, render, options = {}) {
|
|
1888
|
+
const defaultIndex = Number.isInteger(options.defaultIndex)
|
|
1889
|
+
? options.defaultIndex
|
|
1890
|
+
: 0;
|
|
1891
|
+
for (let i = 0; i < items.length; i++) {
|
|
1892
|
+
const marker = i === defaultIndex ? "*" : " ";
|
|
1893
|
+
console.log(
|
|
1894
|
+
`${color(marker === "*" ? ROOT_ACCENT : "dim", marker)} ${color(ROOT_ACCENT, i + 1)}. ${render(items[i])}`,
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
const answer = await askText(
|
|
1898
|
+
rl,
|
|
1899
|
+
`${label} number`,
|
|
1900
|
+
String(defaultIndex + 1),
|
|
1901
|
+
);
|
|
1902
|
+
const index = answer ? Number.parseInt(answer, 10) - 1 : defaultIndex;
|
|
1903
|
+
const item = items[index];
|
|
1904
|
+
if (!item) {
|
|
1905
|
+
console.log(color("dim", "cancelled"));
|
|
1906
|
+
return null;
|
|
1907
|
+
}
|
|
1908
|
+
return item;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function defaultMatchIndex(label, items) {
|
|
1912
|
+
if (label !== "channel") return 0;
|
|
1913
|
+
return defaultChannelIndex(items);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function defaultChannelIndex(channels) {
|
|
1917
|
+
const index = channels.findIndex(
|
|
1918
|
+
(channel) => channel.name?.toLowerCase() === "general",
|
|
1919
|
+
);
|
|
1920
|
+
return index >= 0 ? index : 0;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
async function refreshBuffers(client, state) {
|
|
1924
|
+
const existingDms = Array.isArray(state.buffers)
|
|
1925
|
+
? state.buffers.filter((buffer) => buffer.type === "dm")
|
|
1926
|
+
: [];
|
|
1927
|
+
const buffers = [...existingDms];
|
|
1928
|
+
const servers = await client.servers.retrieve();
|
|
1929
|
+
for (const server of servers) {
|
|
1930
|
+
const channels = await client.channels.retrieve(server.serverID);
|
|
1931
|
+
for (const channel of channels) {
|
|
1932
|
+
buffers.push({
|
|
1933
|
+
id: channel.channelID,
|
|
1934
|
+
label: `#${channel.name}`,
|
|
1935
|
+
serverID: server.serverID,
|
|
1936
|
+
serverName: server.name,
|
|
1937
|
+
type: "channel",
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
state.buffers = buffers;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function addWindow(state, target) {
|
|
1945
|
+
if (!Array.isArray(state.buffers)) {
|
|
1946
|
+
state.buffers = [];
|
|
1947
|
+
}
|
|
1948
|
+
const index = state.buffers.findIndex((buffer) => buffer.id === target.id);
|
|
1949
|
+
if (index >= 0) {
|
|
1950
|
+
state.buffers[index] = { ...state.buffers[index], ...target };
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
state.buffers.unshift({ ...target });
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function pushPreviousTarget(state, target) {
|
|
1957
|
+
const copy = cloneTarget(target);
|
|
1958
|
+
if (!copy) return;
|
|
1959
|
+
state.previousTargets = (state.previousTargets ?? []).filter(
|
|
1960
|
+
(item) => !sameTarget(item, copy),
|
|
1961
|
+
);
|
|
1962
|
+
state.previousTargets.push(copy);
|
|
1963
|
+
state.previousTargets = state.previousTargets.slice(-2);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function cloneTarget(target) {
|
|
1967
|
+
return target ? { ...target } : null;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function sameTarget(a, b) {
|
|
1971
|
+
return Boolean(a && b && a.type === b.type && a.id === b.id);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
function printWindows(state) {
|
|
1975
|
+
if (!state.buffers || state.buffers.length === 0) {
|
|
1976
|
+
console.log(
|
|
1977
|
+
color("dim", "No open chats. Use /join, /channels, or /user."),
|
|
1978
|
+
);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
console.log(color("bold", "Windows"));
|
|
1982
|
+
for (let i = 0; i < state.buffers.length; i++) {
|
|
1983
|
+
const buffer = state.buffers[i];
|
|
1984
|
+
const marker = buffer.id === state.target?.id ? "*" : " ";
|
|
1985
|
+
console.log(
|
|
1986
|
+
`${color(marker === "*" ? ROOT_ACCENT : "dim", marker)} ${color(ROOT_ACCENT, i + 1)}. ${color(targetAccent(buffer), targetLabel(buffer))}`,
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
async function switchBuffer(ctx, client, state, number) {
|
|
1992
|
+
if (!Number.isFinite(number)) {
|
|
1993
|
+
printWindows(state);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (!state.buffers || state.buffers.length === 0) {
|
|
1997
|
+
await refreshBuffers(client, state);
|
|
1998
|
+
}
|
|
1999
|
+
const buffer = state.buffers[number - 1];
|
|
2000
|
+
if (!buffer) {
|
|
2001
|
+
console.log(color(ROOT_ACCENT, `No window ${number}.`));
|
|
2002
|
+
printWindows(state);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
if (buffer.type === "dm") {
|
|
2006
|
+
const user = await resolveUser(client, buffer.id);
|
|
2007
|
+
await selectDmInChat(
|
|
2008
|
+
ctx,
|
|
2009
|
+
client,
|
|
2010
|
+
state,
|
|
2011
|
+
new Map([[user.userID, user.username]]),
|
|
2012
|
+
user.username,
|
|
2013
|
+
null,
|
|
2014
|
+
);
|
|
2015
|
+
} else if (buffer.type === "channel") {
|
|
2016
|
+
const channel = await client.channels.retrieveByID(buffer.id);
|
|
2017
|
+
if (!channel) throw new Error(`Channel not found: ${buffer.id}`);
|
|
2018
|
+
await enterChannel(ctx, client, state, channel, {
|
|
2019
|
+
name: buffer.serverName,
|
|
2020
|
+
serverID: buffer.serverID,
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
async function printMembers(client, state) {
|
|
2026
|
+
if (state.target?.type !== "channel") {
|
|
2027
|
+
console.log(color("dim", "/members is available in channels."));
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const users = await client.channels.userList(state.target.id);
|
|
2031
|
+
if (users.length === 0) {
|
|
2032
|
+
console.log(color("dim", "No visible members."));
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
console.log(
|
|
2036
|
+
`${color("bold", "Members in")} ${color(targetAccent(state.target), targetLabel(state.target))}`,
|
|
2037
|
+
);
|
|
2038
|
+
for (const user of users) {
|
|
2039
|
+
const username = color(userAccent(user.userID), user.username);
|
|
2040
|
+
console.log(
|
|
2041
|
+
` ${username} ${color("dim", `(${shortID(user.userID)})`)}`,
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
async function openServerSelector(ctx, client, state, rl) {
|
|
2047
|
+
const servers = await client.servers.retrieve();
|
|
2048
|
+
if (servers.length === 0) {
|
|
2049
|
+
console.log(
|
|
2050
|
+
color(
|
|
2051
|
+
"dim",
|
|
2052
|
+
"No servers. Use redeem <code> to accept an invite, or /create.",
|
|
2053
|
+
),
|
|
2054
|
+
);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
const rows = await Promise.all(
|
|
2058
|
+
servers.map(async (server) => ({
|
|
2059
|
+
channels: await client.channels
|
|
2060
|
+
.retrieve(server.serverID)
|
|
2061
|
+
.catch(() => []),
|
|
2062
|
+
server,
|
|
2063
|
+
})),
|
|
2064
|
+
);
|
|
2065
|
+
const selected = await chooseItem(
|
|
2066
|
+
rl,
|
|
2067
|
+
"server",
|
|
2068
|
+
rows,
|
|
2069
|
+
({ channels, server }) => {
|
|
2070
|
+
const marker =
|
|
2071
|
+
server.serverID === state.target?.serverID ? "* " : "";
|
|
2072
|
+
const channelText =
|
|
2073
|
+
channels.length === 1
|
|
2074
|
+
? "1 channel"
|
|
2075
|
+
: `${channels.length} channels`;
|
|
2076
|
+
const serverText =
|
|
2077
|
+
server.serverID === state.target?.serverID
|
|
2078
|
+
? boldColor(
|
|
2079
|
+
serverAccent(server.serverID),
|
|
2080
|
+
`${marker}${server.name}`,
|
|
2081
|
+
)
|
|
2082
|
+
: color(
|
|
2083
|
+
serverAccent(server.serverID),
|
|
2084
|
+
`${marker}${server.name}`,
|
|
2085
|
+
);
|
|
2086
|
+
return `${serverText} ${color("dim", channelText)}`;
|
|
2087
|
+
},
|
|
2088
|
+
);
|
|
2089
|
+
if (!selected) return;
|
|
2090
|
+
const channel = await defaultChannelFromServer(client, selected.server);
|
|
2091
|
+
if (channel) {
|
|
2092
|
+
await enterChannel(ctx, client, state, channel, selected.server);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
async function enterChannel(ctx, client, state, channel, server = null) {
|
|
2097
|
+
state.target = {
|
|
2098
|
+
id: channel.channelID,
|
|
2099
|
+
label: `#${channel.name}`,
|
|
2100
|
+
serverID: channel.serverID,
|
|
2101
|
+
serverName: server?.name,
|
|
2102
|
+
type: "channel",
|
|
2103
|
+
};
|
|
2104
|
+
if (
|
|
2105
|
+
state.pendingJump?.target?.type === "channel" &&
|
|
2106
|
+
state.pendingJump.target.id === state.target.id
|
|
2107
|
+
) {
|
|
2108
|
+
state.pendingJump = null;
|
|
2109
|
+
}
|
|
2110
|
+
await saveTarget(ctx, state, state.target);
|
|
2111
|
+
debugLog(ctx, "channel.enter", {
|
|
2112
|
+
channelID: channel.channelID,
|
|
2113
|
+
channelName: channel.name,
|
|
2114
|
+
serverID: channel.serverID,
|
|
2115
|
+
serverName: server?.name,
|
|
2116
|
+
});
|
|
2117
|
+
const history = await client.messages.retrieveGroup(channel.channelID);
|
|
2118
|
+
clearScreen();
|
|
2119
|
+
renderHeader(state, client.me.user(), state.target.label);
|
|
2120
|
+
console.log("");
|
|
2121
|
+
if (history.length === 0) {
|
|
2122
|
+
console.log(color("dim", "No local history yet."));
|
|
2123
|
+
} else {
|
|
2124
|
+
console.log(color("bold", "Recent history"));
|
|
2125
|
+
await printMessages(client, history.slice(-30), {
|
|
2126
|
+
names: historyNameCache(client.me.user()),
|
|
2127
|
+
targetLabel: targetLabel(state.target),
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
console.log("");
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
async function chat(ctx, args) {
|
|
2134
|
+
const username =
|
|
2135
|
+
(args[0] ?? ctx.username)
|
|
2136
|
+
? String(args[0] ?? ctx.username).toLowerCase()
|
|
2137
|
+
: undefined;
|
|
2138
|
+
const { account, client, config } = await authenticateOrRegister(
|
|
2139
|
+
ctx,
|
|
2140
|
+
username,
|
|
2141
|
+
);
|
|
2142
|
+
attachDebugClientEvents(ctx, client, `chat:${account.username}`);
|
|
2143
|
+
const accountState = accountUiState(config, account);
|
|
2144
|
+
const state = {
|
|
2145
|
+
account,
|
|
2146
|
+
avatarMarkers: new Map(),
|
|
2147
|
+
buffers: [],
|
|
2148
|
+
deferredInvitePrompts: new Map(),
|
|
2149
|
+
deviceRequests: new Map(),
|
|
2150
|
+
dms: new Map(),
|
|
2151
|
+
host: ctx.clientOptions.host,
|
|
2152
|
+
pendingJump: null,
|
|
2153
|
+
pendingInvitePrompts: new Set(),
|
|
2154
|
+
previousTargets: [],
|
|
2155
|
+
promptQueue: Promise.resolve(),
|
|
2156
|
+
renderedMessageKeys: new Map(),
|
|
2157
|
+
serverMemberCache: new Map(),
|
|
2158
|
+
status: {
|
|
2159
|
+
activity: "starting",
|
|
2160
|
+
lastActivityAt: Date.now(),
|
|
2161
|
+
network: "connecting",
|
|
2162
|
+
},
|
|
2163
|
+
target: accountState.lastTarget ?? null,
|
|
2164
|
+
};
|
|
2165
|
+
if (state.target?.type === "dm") {
|
|
2166
|
+
addWindow(state, state.target);
|
|
2167
|
+
}
|
|
2168
|
+
const names = new Map([[account.userID, account.username]]);
|
|
2169
|
+
let rl = null;
|
|
2170
|
+
|
|
2171
|
+
client.on("message", async (message) => {
|
|
2172
|
+
bumpActivity(state, message.direction === "incoming" ? "recv" : "send");
|
|
2173
|
+
debugLog(ctx, "message.event", messageDebugPayload(message));
|
|
2174
|
+
if (shouldSkipRenderedMessage(state, message)) {
|
|
2175
|
+
debugLog(
|
|
2176
|
+
ctx,
|
|
2177
|
+
"message.event.skip.rendered",
|
|
2178
|
+
messageDebugPayload(message),
|
|
2179
|
+
);
|
|
2180
|
+
refreshPrompt(rl, state);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
const route = await messageRoute(client, state, message);
|
|
2184
|
+
if (!message.group) {
|
|
2185
|
+
await recordDmActivity(client, state, names, message, route);
|
|
2186
|
+
}
|
|
2187
|
+
debugLog(ctx, "message.route", {
|
|
2188
|
+
...messageDebugPayload(message),
|
|
2189
|
+
activeTarget: state.target,
|
|
2190
|
+
route,
|
|
2191
|
+
});
|
|
2192
|
+
const author =
|
|
2193
|
+
message.direction === "incoming" || route.render
|
|
2194
|
+
? await cachedUsername(client, names, message.authorID)
|
|
2195
|
+
: null;
|
|
2196
|
+
const authorID = message.authorID;
|
|
2197
|
+
if (message.direction === "incoming" && !route.render) {
|
|
2198
|
+
const inviteID = message.decrypted
|
|
2199
|
+
? extractInviteID(message.message)
|
|
2200
|
+
: null;
|
|
2201
|
+
if (inviteID) {
|
|
2202
|
+
try {
|
|
2203
|
+
deferInvitePrompt(
|
|
2204
|
+
state,
|
|
2205
|
+
route.targetObject?.id ?? dmPeerID(state, message),
|
|
2206
|
+
inviteID,
|
|
2207
|
+
await fetchInvitePreview(client, inviteID),
|
|
2208
|
+
{
|
|
2209
|
+
avatar: await avatarMarkerForUser(
|
|
2210
|
+
client,
|
|
2211
|
+
state,
|
|
2212
|
+
authorID,
|
|
2213
|
+
),
|
|
2214
|
+
userID: authorID,
|
|
2215
|
+
username: author,
|
|
2216
|
+
},
|
|
2217
|
+
);
|
|
2218
|
+
} catch (err) {
|
|
2219
|
+
debugLog(ctx, "invite.preview.error", {
|
|
2220
|
+
error: err,
|
|
2221
|
+
inviteID,
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
if (route.targetObject) {
|
|
2226
|
+
setPendingJump(state, route.targetObject, message.timestamp);
|
|
2227
|
+
}
|
|
2228
|
+
playIncomingSound(ctx.sound);
|
|
2229
|
+
notifyIncomingMessage(author);
|
|
2230
|
+
const avatar = await avatarMarkerForUser(client, state, authorID);
|
|
2231
|
+
renderNotificationLine(rl, state, {
|
|
2232
|
+
author,
|
|
2233
|
+
authorID,
|
|
2234
|
+
avatar,
|
|
2235
|
+
isDm: route.isDm,
|
|
2236
|
+
target: route.target,
|
|
2237
|
+
targetID: route.targetObject?.id ?? message.group,
|
|
2238
|
+
targetType: route.targetObject?.type,
|
|
2239
|
+
});
|
|
2240
|
+
refreshPrompt(rl, state);
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
if (!route.render) {
|
|
2244
|
+
refreshPrompt(rl, state);
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
if (!message.decrypted) {
|
|
2248
|
+
debugLog(
|
|
2249
|
+
ctx,
|
|
2250
|
+
"message.render.undecrypted",
|
|
2251
|
+
messageDebugPayload(message),
|
|
2252
|
+
);
|
|
2253
|
+
renderChatLine(rl, state, `[undecrypted] ${message.mailID}`);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
debugLog(ctx, "message.render", {
|
|
2257
|
+
...messageDebugPayload(message),
|
|
2258
|
+
author,
|
|
2259
|
+
route,
|
|
2260
|
+
});
|
|
2261
|
+
if (
|
|
2262
|
+
message.direction === "incoming" &&
|
|
2263
|
+
route.isDm &&
|
|
2264
|
+
!route.isActiveDm
|
|
2265
|
+
) {
|
|
2266
|
+
const inviteID = message.decrypted
|
|
2267
|
+
? extractInviteID(message.message)
|
|
2268
|
+
: null;
|
|
2269
|
+
if (inviteID) {
|
|
2270
|
+
try {
|
|
2271
|
+
deferInvitePrompt(
|
|
2272
|
+
state,
|
|
2273
|
+
dmPeerID(state, message),
|
|
2274
|
+
inviteID,
|
|
2275
|
+
await fetchInvitePreview(client, inviteID),
|
|
2276
|
+
{
|
|
2277
|
+
avatar: await avatarMarkerForUser(
|
|
2278
|
+
client,
|
|
2279
|
+
state,
|
|
2280
|
+
authorID,
|
|
2281
|
+
),
|
|
2282
|
+
userID: authorID,
|
|
2283
|
+
username: author,
|
|
2284
|
+
},
|
|
2285
|
+
);
|
|
2286
|
+
} catch (err) {
|
|
2287
|
+
debugLog(ctx, "invite.preview.error", {
|
|
2288
|
+
error: err,
|
|
2289
|
+
inviteID,
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
playIncomingSound(ctx.sound);
|
|
2294
|
+
notifyIncomingMessage(author);
|
|
2295
|
+
const avatar = await avatarMarkerForUser(client, state, authorID);
|
|
2296
|
+
renderNotificationLine(rl, state, {
|
|
2297
|
+
author,
|
|
2298
|
+
authorID,
|
|
2299
|
+
avatar,
|
|
2300
|
+
isDm: true,
|
|
2301
|
+
target: route.target,
|
|
2302
|
+
targetID: dmPeerID(state, message),
|
|
2303
|
+
targetType: "dm",
|
|
2304
|
+
});
|
|
2305
|
+
refreshPrompt(rl, state);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
const inviteID =
|
|
2309
|
+
message.direction === "incoming" && message.decrypted
|
|
2310
|
+
? extractInviteID(message.message)
|
|
2311
|
+
: null;
|
|
2312
|
+
let renderedMessage = message.message;
|
|
2313
|
+
let invitePreview = null;
|
|
2314
|
+
if (inviteID) {
|
|
2315
|
+
try {
|
|
2316
|
+
invitePreview = await fetchInvitePreview(client, inviteID);
|
|
2317
|
+
renderedMessage = replaceInviteLinkWithPreview(
|
|
2318
|
+
message.message,
|
|
2319
|
+
inviteID,
|
|
2320
|
+
invitePreview,
|
|
2321
|
+
);
|
|
2322
|
+
} catch (err) {
|
|
2323
|
+
debugLog(ctx, "invite.preview.error", {
|
|
2324
|
+
error: err,
|
|
2325
|
+
inviteID,
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
renderChatLine(
|
|
2330
|
+
rl,
|
|
2331
|
+
state,
|
|
2332
|
+
formatMessageLine({
|
|
2333
|
+
direction: message.direction,
|
|
2334
|
+
isDm: route.isDm,
|
|
2335
|
+
message: renderedMessage,
|
|
2336
|
+
target: route.target,
|
|
2337
|
+
timestamp: message.timestamp,
|
|
2338
|
+
who: author,
|
|
2339
|
+
whoID: authorID,
|
|
2340
|
+
targetID: route.targetObject?.id ?? message.group,
|
|
2341
|
+
targetType: route.targetObject?.type,
|
|
2342
|
+
}),
|
|
2343
|
+
);
|
|
2344
|
+
if (message.direction === "incoming") {
|
|
2345
|
+
playIncomingSound(ctx.sound);
|
|
2346
|
+
}
|
|
2347
|
+
if (inviteID && invitePreview) {
|
|
2348
|
+
queueInvitePrompt(ctx, client, state, rl, inviteID, invitePreview, {
|
|
2349
|
+
userID: authorID,
|
|
2350
|
+
username: author,
|
|
2351
|
+
});
|
|
2352
|
+
} else if (inviteID) {
|
|
2353
|
+
renderChatLine(
|
|
2354
|
+
rl,
|
|
2355
|
+
state,
|
|
2356
|
+
`${color(ROOT_ACCENT, "system")} ${color("dim", `invite detected, type /join ${inviteLink(inviteID)} to inspect it`)}`,
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2359
|
+
refreshPrompt(rl, state);
|
|
2360
|
+
});
|
|
2361
|
+
for (const [event, activity] of [
|
|
2362
|
+
["connected", "online"],
|
|
2363
|
+
["decryptingMail", "mail"],
|
|
2364
|
+
["ready", "ready"],
|
|
2365
|
+
["disconnect", "offline"],
|
|
2366
|
+
]) {
|
|
2367
|
+
client.on(event, () => {
|
|
2368
|
+
bumpActivity(state, activity);
|
|
2369
|
+
refreshPrompt(rl, state);
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
client.on("deviceRequest", async (update) => {
|
|
2373
|
+
debugLog(ctx, "deviceRequest.event", update);
|
|
2374
|
+
try {
|
|
2375
|
+
await handleDeviceRequestEvent(ctx, client, state, rl, update);
|
|
2376
|
+
} catch (err) {
|
|
2377
|
+
debugLog(ctx, "deviceRequest.error", {
|
|
2378
|
+
error: err,
|
|
2379
|
+
update,
|
|
2380
|
+
});
|
|
2381
|
+
} finally {
|
|
2382
|
+
refreshPrompt(rl, state);
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
await connectAndWait(client, ctx, `chat:${account.username}`);
|
|
2387
|
+
await refreshBuffers(client, state);
|
|
2388
|
+
const restoredTarget = await restoreInitialTarget(
|
|
2389
|
+
ctx,
|
|
2390
|
+
client,
|
|
2391
|
+
state,
|
|
2392
|
+
names,
|
|
2393
|
+
);
|
|
2394
|
+
|
|
2395
|
+
rl = createInterface({ input, output, prompt: promptFor(state) });
|
|
2396
|
+
const keypressCleanup = bindKeypressShortcuts(
|
|
2397
|
+
ctx,
|
|
2398
|
+
client,
|
|
2399
|
+
state,
|
|
2400
|
+
names,
|
|
2401
|
+
rl,
|
|
2402
|
+
);
|
|
2403
|
+
if (!restoredTarget) {
|
|
2404
|
+
renderHeader(
|
|
2405
|
+
state,
|
|
2406
|
+
account,
|
|
2407
|
+
state.target ? targetLabel(state.target) : "Chat",
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
if (ctx.debugFile) {
|
|
2411
|
+
console.log(color("dim", `debug log ${ctx.debugFile}`));
|
|
2412
|
+
}
|
|
2413
|
+
if (!state.target) {
|
|
2414
|
+
printNoChatMessage(state);
|
|
2415
|
+
}
|
|
2416
|
+
await notifyPendingDeviceRequests(client, state, rl);
|
|
2417
|
+
safeSetPrompt(rl, promptFor(state));
|
|
2418
|
+
safePrompt(rl);
|
|
2419
|
+
for await (const line of rl) {
|
|
2420
|
+
clearSubmittedPrompt();
|
|
2421
|
+
const trimmed = line.trim();
|
|
2422
|
+
try {
|
|
2423
|
+
if (!trimmed) {
|
|
2424
|
+
clearActivePrompt();
|
|
2425
|
+
safePrompt(rl);
|
|
2426
|
+
continue;
|
|
2427
|
+
}
|
|
2428
|
+
if (trimmed === "/quit" || trimmed === "/exit") break;
|
|
2429
|
+
if (trimmed === "/help") {
|
|
2430
|
+
printInteractiveHelp();
|
|
2431
|
+
} else if (trimmed === "/whoami") {
|
|
2432
|
+
printWhoami(client);
|
|
2433
|
+
} else if (trimmed === "/accounts") {
|
|
2434
|
+
await listAccounts(ctx);
|
|
2435
|
+
} else if (trimmed === "/servers") {
|
|
2436
|
+
await openServerSelector(ctx, client, state, rl);
|
|
2437
|
+
} else if (trimmed === "/server" || trimmed === "/join") {
|
|
2438
|
+
await selectServerByName(ctx, client, state, "", rl);
|
|
2439
|
+
} else if (trimmed.startsWith("/server ")) {
|
|
2440
|
+
await selectServerByName(
|
|
2441
|
+
ctx,
|
|
2442
|
+
client,
|
|
2443
|
+
state,
|
|
2444
|
+
trimmed.slice(8).trim(),
|
|
2445
|
+
rl,
|
|
2446
|
+
);
|
|
2447
|
+
} else if (trimmed.startsWith("/join ")) {
|
|
2448
|
+
await joinServerOrInviteInChat(
|
|
2449
|
+
ctx,
|
|
2450
|
+
client,
|
|
2451
|
+
state,
|
|
2452
|
+
trimmed.slice(6).trim(),
|
|
2453
|
+
rl,
|
|
2454
|
+
);
|
|
2455
|
+
} else if (trimmed.startsWith("join ")) {
|
|
2456
|
+
await joinServerOrInviteInChat(
|
|
2457
|
+
ctx,
|
|
2458
|
+
client,
|
|
2459
|
+
state,
|
|
2460
|
+
trimmed.slice(5).trim(),
|
|
2461
|
+
rl,
|
|
2462
|
+
);
|
|
2463
|
+
} else if (trimmed === "/channels") {
|
|
2464
|
+
if (state.target?.type === "channel" && state.target.serverID) {
|
|
2465
|
+
const server = {
|
|
2466
|
+
name: state.target.serverName ?? "server",
|
|
2467
|
+
serverID: state.target.serverID,
|
|
2468
|
+
};
|
|
2469
|
+
const channel = await chooseChannelFromServer(
|
|
2470
|
+
client,
|
|
2471
|
+
server,
|
|
2472
|
+
rl,
|
|
2473
|
+
);
|
|
2474
|
+
if (channel)
|
|
2475
|
+
await enterChannel(ctx, client, state, channel, server);
|
|
2476
|
+
} else {
|
|
2477
|
+
await selectChannelByName(ctx, client, state, "", rl);
|
|
2478
|
+
}
|
|
2479
|
+
} else if (trimmed.startsWith("/channels ")) {
|
|
2480
|
+
console.log(
|
|
2481
|
+
color(
|
|
2482
|
+
"dim",
|
|
2483
|
+
"Use /channels, then choose a channel by number.",
|
|
2484
|
+
),
|
|
2485
|
+
);
|
|
2486
|
+
} else if (trimmed === "/window") {
|
|
2487
|
+
await refreshBuffers(client, state);
|
|
2488
|
+
printWindows(state);
|
|
2489
|
+
} else if (trimmed.startsWith("/window ")) {
|
|
2490
|
+
const number = Number.parseInt(
|
|
2491
|
+
splitWords(trimmed)[1] ?? "",
|
|
2492
|
+
10,
|
|
2493
|
+
);
|
|
2494
|
+
await switchBuffer(ctx, client, state, number);
|
|
2495
|
+
} else if (
|
|
2496
|
+
trimmed === "/channel" ||
|
|
2497
|
+
trimmed.startsWith("/channel ")
|
|
2498
|
+
) {
|
|
2499
|
+
console.log(color("dim", "Use /channels to choose a channel."));
|
|
2500
|
+
} else if (trimmed === "/create" || trimmed === "/create server") {
|
|
2501
|
+
await createServerInChat(ctx, client, state, "", rl);
|
|
2502
|
+
} else if (trimmed.startsWith("/create server ")) {
|
|
2503
|
+
const name = trimmed.slice(15).trim();
|
|
2504
|
+
await createServerInChat(ctx, client, state, name, rl);
|
|
2505
|
+
} else if (trimmed === "/dm") {
|
|
2506
|
+
await openInbox(ctx, client, state, names, rl);
|
|
2507
|
+
} else if (trimmed.startsWith("/dm ")) {
|
|
2508
|
+
const [identifier, ...messageParts] = splitWords(
|
|
2509
|
+
trimmed.slice(4),
|
|
2510
|
+
);
|
|
2511
|
+
if (messageParts.length === 0) {
|
|
2512
|
+
await selectDmInChat(
|
|
2513
|
+
ctx,
|
|
2514
|
+
client,
|
|
2515
|
+
state,
|
|
2516
|
+
names,
|
|
2517
|
+
identifier,
|
|
2518
|
+
rl,
|
|
2519
|
+
);
|
|
2520
|
+
} else {
|
|
2521
|
+
await sendDmInChat(
|
|
2522
|
+
ctx,
|
|
2523
|
+
client,
|
|
2524
|
+
state,
|
|
2525
|
+
names,
|
|
2526
|
+
identifier,
|
|
2527
|
+
messageParts,
|
|
2528
|
+
rl,
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
2531
|
+
} else if (trimmed.startsWith("dm ")) {
|
|
2532
|
+
const [identifier, ...messageParts] = splitWords(
|
|
2533
|
+
trimmed.slice(3),
|
|
2534
|
+
);
|
|
2535
|
+
if (messageParts.length === 0) {
|
|
2536
|
+
await selectDmInChat(
|
|
2537
|
+
ctx,
|
|
2538
|
+
client,
|
|
2539
|
+
state,
|
|
2540
|
+
names,
|
|
2541
|
+
identifier,
|
|
2542
|
+
rl,
|
|
2543
|
+
);
|
|
2544
|
+
} else {
|
|
2545
|
+
await sendDmInChat(
|
|
2546
|
+
ctx,
|
|
2547
|
+
client,
|
|
2548
|
+
state,
|
|
2549
|
+
names,
|
|
2550
|
+
identifier,
|
|
2551
|
+
messageParts,
|
|
2552
|
+
rl,
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
} else if (trimmed === "/to") {
|
|
2556
|
+
await selectDmInChat(ctx, client, state, names, "", rl);
|
|
2557
|
+
} else if (trimmed.startsWith("/to ")) {
|
|
2558
|
+
await selectDmInChat(
|
|
2559
|
+
ctx,
|
|
2560
|
+
client,
|
|
2561
|
+
state,
|
|
2562
|
+
names,
|
|
2563
|
+
trimmed.slice(4).trim(),
|
|
2564
|
+
rl,
|
|
2565
|
+
);
|
|
2566
|
+
} else if (trimmed === "/user") {
|
|
2567
|
+
await selectDmInChat(ctx, client, state, names, "", rl);
|
|
2568
|
+
} else if (trimmed.startsWith("/user ")) {
|
|
2569
|
+
await selectDmInChat(
|
|
2570
|
+
ctx,
|
|
2571
|
+
client,
|
|
2572
|
+
state,
|
|
2573
|
+
names,
|
|
2574
|
+
trimmed.slice(6).trim(),
|
|
2575
|
+
rl,
|
|
2576
|
+
);
|
|
2577
|
+
} else if (trimmed === "/nav") {
|
|
2578
|
+
await navigateInChat(ctx, client, state, names, rl);
|
|
2579
|
+
} else if (trimmed.startsWith("/nav ")) {
|
|
2580
|
+
console.log(
|
|
2581
|
+
color("dim", "Use /nav, then choose a channel or DM."),
|
|
2582
|
+
);
|
|
2583
|
+
} else if (trimmed === "/inbox" || trimmed === "/dms") {
|
|
2584
|
+
await openInbox(ctx, client, state, names, rl);
|
|
2585
|
+
} else if (trimmed === "/devices" || trimmed === "/requests") {
|
|
2586
|
+
await openDeviceRequests(ctx, client, state, rl);
|
|
2587
|
+
} else if (trimmed === "redeem" || trimmed === "/redeem") {
|
|
2588
|
+
await joinInviteInChat(ctx, client, state, "", rl);
|
|
2589
|
+
} else if (trimmed.startsWith("redeem ")) {
|
|
2590
|
+
await joinInviteInChat(
|
|
2591
|
+
ctx,
|
|
2592
|
+
client,
|
|
2593
|
+
state,
|
|
2594
|
+
trimmed.slice(7).trim(),
|
|
2595
|
+
rl,
|
|
2596
|
+
);
|
|
2597
|
+
} else if (trimmed.startsWith("/redeem ")) {
|
|
2598
|
+
await joinInviteInChat(
|
|
2599
|
+
ctx,
|
|
2600
|
+
client,
|
|
2601
|
+
state,
|
|
2602
|
+
trimmed.slice(8).trim(),
|
|
2603
|
+
rl,
|
|
2604
|
+
);
|
|
2605
|
+
} else if (
|
|
2606
|
+
trimmed === "/invite redeem" ||
|
|
2607
|
+
trimmed.startsWith("/invite redeem ")
|
|
2608
|
+
) {
|
|
2609
|
+
console.log(
|
|
2610
|
+
color(
|
|
2611
|
+
"dim",
|
|
2612
|
+
"Use redeem <invite-code-or-link> to accept a server invite.",
|
|
2613
|
+
),
|
|
2614
|
+
);
|
|
2615
|
+
} else if (
|
|
2616
|
+
trimmed === "/invite" ||
|
|
2617
|
+
trimmed.startsWith("/invite ")
|
|
2618
|
+
) {
|
|
2619
|
+
const rest = splitWords(trimmed.slice(7));
|
|
2620
|
+
await createInviteInteractive(ctx, client, state, rest, rl);
|
|
2621
|
+
} else if (trimmed === "/members") {
|
|
2622
|
+
await printMembers(client, state);
|
|
2623
|
+
} else if (trimmed === "/names") {
|
|
2624
|
+
console.log(
|
|
2625
|
+
color(
|
|
2626
|
+
"dim",
|
|
2627
|
+
"Use /members to list people in the current channel.",
|
|
2628
|
+
),
|
|
2629
|
+
);
|
|
2630
|
+
} else if (trimmed === "/history") {
|
|
2631
|
+
console.log(
|
|
2632
|
+
color(
|
|
2633
|
+
"dim",
|
|
2634
|
+
"Recent history is shown when you open a chat.",
|
|
2635
|
+
),
|
|
2636
|
+
);
|
|
2637
|
+
} else if (state.target?.type === "dm") {
|
|
2638
|
+
debugLog(ctx, "message.send.dm.current.start", {
|
|
2639
|
+
message: trimmed,
|
|
2640
|
+
targetUserID: state.target.id,
|
|
2641
|
+
target: targetLabel(state.target),
|
|
2642
|
+
});
|
|
2643
|
+
beginSendingStatus(state, rl);
|
|
2644
|
+
try {
|
|
2645
|
+
await client.messages.send(state.target.id, trimmed);
|
|
2646
|
+
} finally {
|
|
2647
|
+
endSendingStatus(state, rl);
|
|
2648
|
+
}
|
|
2649
|
+
debugLog(ctx, "message.send.dm.current.ok", {
|
|
2650
|
+
message: trimmed,
|
|
2651
|
+
targetUserID: state.target.id,
|
|
2652
|
+
target: targetLabel(state.target),
|
|
2653
|
+
});
|
|
2654
|
+
} else if (state.target?.type === "channel") {
|
|
2655
|
+
debugLog(ctx, "message.send.group.start", {
|
|
2656
|
+
channelID: state.target.id,
|
|
2657
|
+
message: trimmed,
|
|
2658
|
+
serverID: state.target.serverID,
|
|
2659
|
+
target: targetLabel(state.target),
|
|
2660
|
+
});
|
|
2661
|
+
beginSendingStatus(state, rl);
|
|
2662
|
+
try {
|
|
2663
|
+
await client.messages.group(state.target.id, trimmed);
|
|
2664
|
+
} finally {
|
|
2665
|
+
endSendingStatus(state, rl);
|
|
2666
|
+
}
|
|
2667
|
+
debugLog(ctx, "message.send.group.ok", {
|
|
2668
|
+
channelID: state.target.id,
|
|
2669
|
+
message: trimmed,
|
|
2670
|
+
serverID: state.target.serverID,
|
|
2671
|
+
target: targetLabel(state.target),
|
|
2672
|
+
});
|
|
2673
|
+
} else {
|
|
2674
|
+
printNoChatMessage(state);
|
|
2675
|
+
}
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
debugLog(ctx, "command.error", {
|
|
2678
|
+
error: err,
|
|
2679
|
+
input: trimmed,
|
|
2680
|
+
target: state.target,
|
|
2681
|
+
});
|
|
2682
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
2683
|
+
}
|
|
2684
|
+
clearActivePrompt();
|
|
2685
|
+
safeSetPrompt(rl, promptFor(state));
|
|
2686
|
+
safePrompt(rl);
|
|
2687
|
+
}
|
|
2688
|
+
rl.close();
|
|
2689
|
+
keypressCleanup();
|
|
2690
|
+
await client.close().catch(() => {});
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
async function withReadyClient(ctx, args, fn) {
|
|
2694
|
+
const username = (ctx.username ?? undefined) || undefined;
|
|
2695
|
+
const { account, client, config } = await authenticate(ctx, username);
|
|
2696
|
+
try {
|
|
2697
|
+
await connectAndWait(client, ctx, `command:${username ?? "current"}`);
|
|
2698
|
+
await fn(client, args, { account, config });
|
|
2699
|
+
} finally {
|
|
2700
|
+
await client.close().catch(() => {});
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
async function authenticate(ctx, explicitUsername) {
|
|
2705
|
+
const config = await readConfig(ctx.configPath);
|
|
2706
|
+
const username = (explicitUsername ?? config.lastUsername)?.toLowerCase();
|
|
2707
|
+
if (!username) {
|
|
2708
|
+
throw new Error(
|
|
2709
|
+
"No local account selected. Use --username or run register/login first.",
|
|
2710
|
+
);
|
|
2711
|
+
}
|
|
2712
|
+
const account = config.accounts[username];
|
|
2713
|
+
if (!account) {
|
|
2714
|
+
throw new Error(
|
|
2715
|
+
`No local account for ${username}. Run register/login first.`,
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
const client = await Client.create(account.privateKey, ctx.clientOptions);
|
|
2719
|
+
attachDebugClientEvents(ctx, client, `auth:${username}`);
|
|
2720
|
+
const deviceID = await resolveStoredDeviceID(
|
|
2721
|
+
ctx,
|
|
2722
|
+
client,
|
|
2723
|
+
account,
|
|
2724
|
+
username,
|
|
2725
|
+
);
|
|
2726
|
+
const deviceErr = deviceID
|
|
2727
|
+
? await client.loginWithDeviceKey(deviceID)
|
|
2728
|
+
: new Error("missing device id");
|
|
2729
|
+
if (deviceErr && account.pendingApproval) {
|
|
2730
|
+
return waitForDeviceApproval(
|
|
2731
|
+
ctx,
|
|
2732
|
+
client,
|
|
2733
|
+
config,
|
|
2734
|
+
username,
|
|
2735
|
+
account.privateKey,
|
|
2736
|
+
account.pendingApproval,
|
|
2737
|
+
);
|
|
2738
|
+
}
|
|
2739
|
+
if (deviceErr && ctx.password) {
|
|
2740
|
+
const loginResult = await client.login(username, ctx.password);
|
|
2741
|
+
if (!loginResult.ok)
|
|
2742
|
+
throw new Error(loginResult.error ?? "Login failed.");
|
|
2743
|
+
} else if (deviceErr) {
|
|
2744
|
+
throw new Error(
|
|
2745
|
+
`Device-key login failed for ${username}: ${deviceErr.message}. Retry with --password.`,
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
if (deviceID) {
|
|
2749
|
+
account.deviceID = deviceID;
|
|
2750
|
+
}
|
|
2751
|
+
account.userID = client.me.user().userID;
|
|
2752
|
+
account.username = client.me.user().username ?? username;
|
|
2753
|
+
config.accounts[username] = account;
|
|
2754
|
+
await writeConfig(ctx.configPath, config);
|
|
2755
|
+
return { account, client, config };
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
async function resolveStoredDeviceID(ctx, client, account, username) {
|
|
2759
|
+
if (account.deviceID) return account.deviceID;
|
|
2760
|
+
const signKey = client.getKeys().public;
|
|
2761
|
+
const device = await client.devices.retrieve(signKey).catch((err) => {
|
|
2762
|
+
debugLog(ctx, "auth.deviceMigration.error", {
|
|
2763
|
+
error: err,
|
|
2764
|
+
username,
|
|
2765
|
+
});
|
|
2766
|
+
return null;
|
|
2767
|
+
});
|
|
2768
|
+
if (!device?.deviceID) return null;
|
|
2769
|
+
debugLog(ctx, "auth.deviceMigration.ok", {
|
|
2770
|
+
deviceID: device.deviceID,
|
|
2771
|
+
username,
|
|
2772
|
+
});
|
|
2773
|
+
return device.deviceID;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
async function authenticateOrRegister(ctx, explicitUsername) {
|
|
2777
|
+
const config = await readConfig(ctx.configPath);
|
|
2778
|
+
const username = (explicitUsername ?? config.lastUsername)?.toLowerCase();
|
|
2779
|
+
if (username && config.accounts[username]) {
|
|
2780
|
+
return authenticate(ctx, username);
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
const rl = createInterface({ input, output });
|
|
2784
|
+
try {
|
|
2785
|
+
console.log("Welcome to vex.");
|
|
2786
|
+
const entered = (username ?? (await rl.question("username: ")))
|
|
2787
|
+
.trim()
|
|
2788
|
+
.toLowerCase();
|
|
2789
|
+
if (!entered) throw new Error("username is required");
|
|
2790
|
+
if (config.accounts[entered]) {
|
|
2791
|
+
return authenticate(ctx, entered);
|
|
2792
|
+
}
|
|
2793
|
+
const answer = (await rl.question(`register ${entered}? [Y/n] `))
|
|
2794
|
+
.trim()
|
|
2795
|
+
.toLowerCase();
|
|
2796
|
+
if (answer && answer !== "y" && answer !== "yes") {
|
|
2797
|
+
throw new Error("No local account selected.");
|
|
2798
|
+
}
|
|
2799
|
+
const privateKey = Client.generateSecretKey();
|
|
2800
|
+
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
2801
|
+
attachDebugClientEvents(ctx, client, `register:${entered}`);
|
|
2802
|
+
try {
|
|
2803
|
+
const [, registerErr] = await client.register(entered);
|
|
2804
|
+
if (registerErr) throw registerErr;
|
|
2805
|
+
await connectAndWait(client, ctx, `register:${entered}`);
|
|
2806
|
+
} catch (err) {
|
|
2807
|
+
if (!isDeviceApprovalRequired(err)) throw err;
|
|
2808
|
+
return waitForDeviceApproval(
|
|
2809
|
+
ctx,
|
|
2810
|
+
client,
|
|
2811
|
+
config,
|
|
2812
|
+
entered,
|
|
2813
|
+
privateKey,
|
|
2814
|
+
{
|
|
2815
|
+
challenge: err.challenge,
|
|
2816
|
+
expiresAt: err.expiresAt,
|
|
2817
|
+
requestID: err.requestID,
|
|
2818
|
+
userID: err.userID,
|
|
2819
|
+
},
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
const account = await persistNewLocalAccount(
|
|
2823
|
+
ctx,
|
|
2824
|
+
config,
|
|
2825
|
+
entered,
|
|
2826
|
+
privateKey,
|
|
2827
|
+
client,
|
|
2828
|
+
);
|
|
2829
|
+
return { account, client, config };
|
|
2830
|
+
} finally {
|
|
2831
|
+
rl.close();
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
async function makeClient(ctx, username) {
|
|
2836
|
+
const config = await readConfig(ctx.configPath);
|
|
2837
|
+
const account = config.accounts[username];
|
|
2838
|
+
const privateKey = account?.privateKey ?? Client.generateSecretKey();
|
|
2839
|
+
const client = await Client.create(privateKey, ctx.clientOptions);
|
|
2840
|
+
return { client, config };
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
async function connectAndWait(client, ctx = null, label = "client") {
|
|
2844
|
+
if (client.__vexCliConnected) {
|
|
2845
|
+
debugLog(ctx, "client.connect.skip.connected", { label });
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
debugLog(ctx, "client.connect.start", { label });
|
|
2849
|
+
await new Promise((resolve, reject) => {
|
|
2850
|
+
const timer = setTimeout(() => {
|
|
2851
|
+
debugLog(ctx, "client.connect.timeout", { label });
|
|
2852
|
+
reject(new Error("Timed out waiting for client connection."));
|
|
2853
|
+
}, 20_000);
|
|
2854
|
+
client.once("connected", () => {
|
|
2855
|
+
clearTimeout(timer);
|
|
2856
|
+
client.__vexCliConnected = true;
|
|
2857
|
+
debugLog(ctx, "client.connect.ok", { label });
|
|
2858
|
+
resolve();
|
|
2859
|
+
});
|
|
2860
|
+
client.connect().catch((err) => {
|
|
2861
|
+
clearTimeout(timer);
|
|
2862
|
+
debugLog(ctx, "client.connect.error", { error: err, label });
|
|
2863
|
+
reject(err);
|
|
2864
|
+
});
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function attachDebugClientEvents(ctx, client, label) {
|
|
2869
|
+
if (!ctx.debug || client.__vexCliDebugAttached) return;
|
|
2870
|
+
client.__vexCliDebugAttached = true;
|
|
2871
|
+
const base = () => {
|
|
2872
|
+
try {
|
|
2873
|
+
return {
|
|
2874
|
+
deviceID: client.me.device().deviceID,
|
|
2875
|
+
label,
|
|
2876
|
+
userID: client.me.user().userID,
|
|
2877
|
+
username: client.me.user().username,
|
|
2878
|
+
};
|
|
2879
|
+
} catch {
|
|
2880
|
+
return { label };
|
|
2881
|
+
}
|
|
2882
|
+
};
|
|
2883
|
+
for (const event of [
|
|
2884
|
+
"connected",
|
|
2885
|
+
"disconnect",
|
|
2886
|
+
"decryptingMail",
|
|
2887
|
+
"ready",
|
|
2888
|
+
]) {
|
|
2889
|
+
client.on(event, () => debugLog(ctx, `client.${event}`, base()));
|
|
2890
|
+
}
|
|
2891
|
+
client.on("session", (session, user) =>
|
|
2892
|
+
debugLog(ctx, "client.session", {
|
|
2893
|
+
...base(),
|
|
2894
|
+
peerDeviceID: session.deviceID,
|
|
2895
|
+
peerUserID: session.userID,
|
|
2896
|
+
peerUsername: user?.username,
|
|
2897
|
+
sessionID: session.sessionID,
|
|
2898
|
+
}),
|
|
2899
|
+
);
|
|
2900
|
+
client.on("retryRequest", (request) =>
|
|
2901
|
+
debugLog(ctx, "client.retryRequest", {
|
|
2902
|
+
...base(),
|
|
2903
|
+
mailID: request?.mailID,
|
|
2904
|
+
source: request?.source,
|
|
2905
|
+
}),
|
|
2906
|
+
);
|
|
2907
|
+
client.on("deviceRequest", (update) =>
|
|
2908
|
+
debugLog(ctx, "client.deviceRequest", {
|
|
2909
|
+
...base(),
|
|
2910
|
+
requestID: update?.requestID,
|
|
2911
|
+
status: update?.status,
|
|
2912
|
+
}),
|
|
2913
|
+
);
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
function debugLog(ctx, event, data = {}, level = "debug") {
|
|
2917
|
+
if (!ctx?.debug) return;
|
|
2918
|
+
if (!shouldDebugAtLevel(ctx.debugLevel, level)) return;
|
|
2919
|
+
if (
|
|
2920
|
+
isHeartbeatDebugEvent(event, data) &&
|
|
2921
|
+
!shouldDebugAtLevel(ctx.debugLevel, "trace")
|
|
2922
|
+
)
|
|
2923
|
+
return;
|
|
2924
|
+
const payload = {
|
|
2925
|
+
data,
|
|
2926
|
+
event,
|
|
2927
|
+
time: new Date().toISOString(),
|
|
2928
|
+
};
|
|
2929
|
+
const line = `[vex-cli:debug] ${JSON.stringify(payload, jsonReplacer)}\n`;
|
|
2930
|
+
if (ctx.debugStream) {
|
|
2931
|
+
ctx.debugStream.write(line);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
process.stderr.write(line);
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
function shouldDebugAtLevel(current, needed) {
|
|
2938
|
+
const levels = { off: 0, debug: 1, trace: 2 };
|
|
2939
|
+
return (levels[current] ?? 0) >= (levels[needed] ?? 1);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function isHeartbeatDebugEvent(event, data) {
|
|
2943
|
+
if (/\b(?:ping|pong)\b/i.test(event)) return true;
|
|
2944
|
+
const type = typeof data?.type === "string" ? data.type : "";
|
|
2945
|
+
const messageType =
|
|
2946
|
+
typeof data?.message?.type === "string" ? data.message.type : "";
|
|
2947
|
+
return (
|
|
2948
|
+
type === "ping" ||
|
|
2949
|
+
type === "pong" ||
|
|
2950
|
+
messageType === "ping" ||
|
|
2951
|
+
messageType === "pong"
|
|
2952
|
+
);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
function jsonReplacer(_key, value) {
|
|
2956
|
+
if (value instanceof Uint8Array) {
|
|
2957
|
+
return { bytes: value.length, hex: Buffer.from(value).toString("hex") };
|
|
2958
|
+
}
|
|
2959
|
+
if (value instanceof Error) {
|
|
2960
|
+
return { message: value.message, name: value.name, stack: value.stack };
|
|
2961
|
+
}
|
|
2962
|
+
return value;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
function messageDebugPayload(message) {
|
|
2966
|
+
return {
|
|
2967
|
+
authorID: message.authorID,
|
|
2968
|
+
decrypted: message.decrypted,
|
|
2969
|
+
direction: message.direction,
|
|
2970
|
+
forward: message.forward,
|
|
2971
|
+
group: message.group,
|
|
2972
|
+
mailID: message.mailID,
|
|
2973
|
+
message: message.message,
|
|
2974
|
+
readerID: message.readerID,
|
|
2975
|
+
recipient: message.recipient,
|
|
2976
|
+
sender: message.sender,
|
|
2977
|
+
timestamp: message.timestamp,
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
async function resolveUser(client, identifier) {
|
|
2982
|
+
if (!identifier) throw new Error("User identifier is required.");
|
|
2983
|
+
const [user, err] = await client.users.retrieve(identifier);
|
|
2984
|
+
if (!user) throw new Error(err?.message ?? `User not found: ${identifier}`);
|
|
2985
|
+
return user;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
async function cachedUsername(client, cache, userID) {
|
|
2989
|
+
if (cache.has(userID)) return cache.get(userID);
|
|
2990
|
+
const [user] = await client.users.retrieve(userID);
|
|
2991
|
+
const name = user?.username ?? shortID(userID);
|
|
2992
|
+
cache.set(userID, name);
|
|
2993
|
+
return name;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
async function readConfig(configPath) {
|
|
2997
|
+
try {
|
|
2998
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
2999
|
+
const parsed = JSON.parse(raw);
|
|
3000
|
+
return {
|
|
3001
|
+
...parsed,
|
|
3002
|
+
accounts:
|
|
3003
|
+
parsed.accounts && typeof parsed.accounts === "object"
|
|
3004
|
+
? parsed.accounts
|
|
3005
|
+
: {},
|
|
3006
|
+
lastChannel:
|
|
3007
|
+
typeof parsed.lastChannel === "string"
|
|
3008
|
+
? parsed.lastChannel
|
|
3009
|
+
: undefined,
|
|
3010
|
+
lastServer:
|
|
3011
|
+
typeof parsed.lastServer === "string"
|
|
3012
|
+
? parsed.lastServer
|
|
3013
|
+
: undefined,
|
|
3014
|
+
lastTarget: isTarget(parsed.lastTarget) ? parsed.lastTarget : null,
|
|
3015
|
+
lastUsername:
|
|
3016
|
+
typeof parsed.lastUsername === "string"
|
|
3017
|
+
? parsed.lastUsername
|
|
3018
|
+
: undefined,
|
|
3019
|
+
};
|
|
3020
|
+
} catch {
|
|
3021
|
+
return { accounts: {}, lastTarget: null };
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
async function writeConfig(configPath, config) {
|
|
3026
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", {
|
|
3027
|
+
mode: 0o600,
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function requireArg(args, index, name) {
|
|
3032
|
+
const value = args[index];
|
|
3033
|
+
if (!value) throw new Error(`Missing ${name}.`);
|
|
3034
|
+
return value;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
function splitWords(value) {
|
|
3038
|
+
return value.trim().split(/\s+/).filter(Boolean);
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
function sleep(ms) {
|
|
3042
|
+
return new Promise((resolve) => {
|
|
3043
|
+
setTimeout(resolve, ms);
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
async function askText(rl, label, fallback = "") {
|
|
3048
|
+
const suffix = fallback ? ` [${fallback}]` : "";
|
|
3049
|
+
let answer = "";
|
|
3050
|
+
try {
|
|
3051
|
+
answer = await rl.question(`${label}${suffix}: `);
|
|
3052
|
+
} catch (err) {
|
|
3053
|
+
if (
|
|
3054
|
+
!(err instanceof Error) ||
|
|
3055
|
+
!err.message.includes("readline was closed")
|
|
3056
|
+
) {
|
|
3057
|
+
throw err;
|
|
3058
|
+
}
|
|
3059
|
+
return fallback;
|
|
3060
|
+
}
|
|
3061
|
+
return answer.trim() || fallback;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
function looksLikeUUID(value) {
|
|
3065
|
+
return (
|
|
3066
|
+
typeof value === "string" &&
|
|
3067
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
3068
|
+
value,
|
|
3069
|
+
)
|
|
3070
|
+
);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
function looksLikeDuration(value) {
|
|
3074
|
+
return (
|
|
3075
|
+
typeof value === "string" && /^\d+(?:ms|s|m|h|d|w|mo|y)?$/i.test(value)
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
function parseInviteID(value) {
|
|
3080
|
+
const trimmed = value.trim();
|
|
3081
|
+
if (looksLikeUUID(trimmed)) return trimmed;
|
|
3082
|
+
const match = trimmed.match(
|
|
3083
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i,
|
|
3084
|
+
);
|
|
3085
|
+
if (!match) throw new Error(`Invalid invite: ${value}`);
|
|
3086
|
+
return match[0];
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
function isInviteInput(value) {
|
|
3090
|
+
try {
|
|
3091
|
+
parseInviteID(value);
|
|
3092
|
+
return true;
|
|
3093
|
+
} catch {
|
|
3094
|
+
return false;
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
function extractInviteID(value) {
|
|
3099
|
+
const match = value.match(
|
|
3100
|
+
/vex:\/\/invite\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i,
|
|
3101
|
+
);
|
|
3102
|
+
return match?.[1] ?? null;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
function replaceInviteLinkWithPreview(message, inviteID, preview) {
|
|
3106
|
+
return message.replace(
|
|
3107
|
+
new RegExp(`vex://invite/${escapeRegExp(inviteID)}`, "i"),
|
|
3108
|
+
formatInvitePreviewLine(preview),
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function formatInvitePreviewLine(preview) {
|
|
3113
|
+
return `${color(ROOT_ACCENT, "invite")} ${color(serverAccent(preview.server?.serverID ?? preview.invite?.serverID), preview.server?.name ?? "server")} ${formatInviteChannelSummary(preview.channels)} ${color("dim", `expires ${formatMessageTime(preview.invite.expiration)}`)}`;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
function formatInvitePromptMessage(preview, inviteID, sender = null) {
|
|
3117
|
+
const senderName = sender?.username ?? "someone";
|
|
3118
|
+
const senderText = sender?.userID
|
|
3119
|
+
? `${sender?.avatar ? `${sender.avatar} ` : ""}${color(userAccent(sender.userID), `@${senderName}`)}`
|
|
3120
|
+
: color(ROOT_ACCENT, senderName);
|
|
3121
|
+
return [
|
|
3122
|
+
`${color(ROOT_ACCENT, "invite")} ${color("dim", "from")} ${senderText}`,
|
|
3123
|
+
formatInvitePreviewBox(preview, inviteID),
|
|
3124
|
+
color("dim", "Want to join? Y/n"),
|
|
3125
|
+
].join("\n");
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
function formatInvitePreviewBox(preview, inviteID) {
|
|
3129
|
+
const serverName = preview.server?.name ?? "Server";
|
|
3130
|
+
const link = inviteLink(inviteID);
|
|
3131
|
+
const rows = [
|
|
3132
|
+
`SERVER INVITE - ${serverName}`,
|
|
3133
|
+
`channels ${plainInviteChannelSummary(preview.channels)}`,
|
|
3134
|
+
`expires ${formatMessageTime(preview.invite.expiration)}`,
|
|
3135
|
+
`link ${terminalLink(link, link)}`,
|
|
3136
|
+
`command /join ${link}`,
|
|
3137
|
+
];
|
|
3138
|
+
return asciiBox(rows);
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function plainInviteChannelSummary(channels) {
|
|
3142
|
+
if (!channels || channels.length === 0) return "none listed";
|
|
3143
|
+
const names = channels
|
|
3144
|
+
.slice(0, 3)
|
|
3145
|
+
.map((channel) => `#${channel.name}`)
|
|
3146
|
+
.join(", ");
|
|
3147
|
+
const extra = channels.length > 3 ? ` +${channels.length - 3} more` : "";
|
|
3148
|
+
return `${names}${extra}`;
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
function asciiBox(rows) {
|
|
3152
|
+
const width = Math.max(...rows.map((row) => visibleLength(row)));
|
|
3153
|
+
const border = `+${"-".repeat(width + 2)}+`;
|
|
3154
|
+
const body = rows.map(
|
|
3155
|
+
(row) => `| ${row}${" ".repeat(width - visibleLength(row))} |`,
|
|
3156
|
+
);
|
|
3157
|
+
return [border, ...body, border].join("\n");
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
function visibleLength(value) {
|
|
3161
|
+
return String(value)
|
|
3162
|
+
.replace(/\x1b\]8;;.*?\x07/g, "")
|
|
3163
|
+
.replace(/\x1b\]8;;\x07/g, "")
|
|
3164
|
+
.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
function terminalLink(label, href) {
|
|
3168
|
+
if (process.env.NO_COLOR !== undefined) return label;
|
|
3169
|
+
return `\x1b]8;;${href}\x07${label}\x1b]8;;\x07`;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
function escapeRegExp(value) {
|
|
3173
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
function inviteLink(inviteID) {
|
|
3177
|
+
return `vex://invite/${inviteID}`;
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
function clearScreen() {
|
|
3181
|
+
output.write("\x1b[2J\x1b[3J\x1b[H");
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
function clearActivePrompt() {
|
|
3185
|
+
if (output.isTTY) {
|
|
3186
|
+
output.write("\r\x1b[2K");
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
function clearSubmittedPrompt() {
|
|
3191
|
+
if (input.isTTY && output.isTTY) {
|
|
3192
|
+
output.write("\r\x1b[2K\x1b[1A\r\x1b[2K");
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
async function messageRoute(client, state, message) {
|
|
3197
|
+
const isDm = !message.group;
|
|
3198
|
+
if (!isDm) {
|
|
3199
|
+
const isActiveChannel =
|
|
3200
|
+
state.target?.type === "channel" &&
|
|
3201
|
+
state.target.id === message.group;
|
|
3202
|
+
const targetObject = isActiveChannel
|
|
3203
|
+
? state.target
|
|
3204
|
+
: await channelTargetForMessage(client, state, message.group);
|
|
3205
|
+
const target = targetObject
|
|
3206
|
+
? targetLabel(targetObject)
|
|
3207
|
+
: `#${shortID(message.group)}`;
|
|
3208
|
+
return {
|
|
3209
|
+
isActiveDm: false,
|
|
3210
|
+
isDm: false,
|
|
3211
|
+
reason: isActiveChannel ? "active-channel" : "other-channel",
|
|
3212
|
+
render: isActiveChannel,
|
|
3213
|
+
target,
|
|
3214
|
+
targetObject,
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
const peerID = dmPeerID(state, message);
|
|
3219
|
+
const isActiveDm =
|
|
3220
|
+
state.target?.type === "dm" && state.target.id === peerID;
|
|
3221
|
+
if (isActiveDm) {
|
|
3222
|
+
return {
|
|
3223
|
+
isActiveDm: true,
|
|
3224
|
+
isDm: true,
|
|
3225
|
+
reason: "active-dm",
|
|
3226
|
+
render: true,
|
|
3227
|
+
target: targetLabel(state.target),
|
|
3228
|
+
targetObject: state.target,
|
|
3229
|
+
};
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
const canInlineInServer =
|
|
3233
|
+
state.target?.type === "channel" &&
|
|
3234
|
+
state.target.serverID &&
|
|
3235
|
+
peerID &&
|
|
3236
|
+
(await userSharesServer(client, state, state.target.serverID, peerID));
|
|
3237
|
+
|
|
3238
|
+
return {
|
|
3239
|
+
isActiveDm: false,
|
|
3240
|
+
isDm: true,
|
|
3241
|
+
reason: canInlineInServer ? "server-scoped-dm" : "offscreen-dm",
|
|
3242
|
+
render: Boolean(canInlineInServer),
|
|
3243
|
+
target: "DM",
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
async function channelTargetForMessage(client, state, channelID) {
|
|
3248
|
+
const existing = state.buffers?.find((buffer) => buffer.id === channelID);
|
|
3249
|
+
if (existing) return existing;
|
|
3250
|
+
const channel = await client.channels
|
|
3251
|
+
.retrieveByID(channelID)
|
|
3252
|
+
.catch(() => null);
|
|
3253
|
+
if (!channel) return null;
|
|
3254
|
+
const servers = await client.servers.retrieve().catch(() => []);
|
|
3255
|
+
const server = servers.find((item) => item.serverID === channel.serverID);
|
|
3256
|
+
return {
|
|
3257
|
+
id: channel.channelID,
|
|
3258
|
+
label: `#${channel.name}`,
|
|
3259
|
+
serverID: channel.serverID,
|
|
3260
|
+
serverName: server?.name,
|
|
3261
|
+
type: "channel",
|
|
3262
|
+
};
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function dmPeerID(state, message) {
|
|
3266
|
+
if (message.direction === "outgoing") {
|
|
3267
|
+
return message.readerID === state.account?.userID
|
|
3268
|
+
? message.authorID
|
|
3269
|
+
: message.readerID;
|
|
3270
|
+
}
|
|
3271
|
+
return message.authorID;
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
async function recordDmActivity(client, state, names, message, route = null) {
|
|
3275
|
+
const userID = dmPeerID(state, message);
|
|
3276
|
+
if (!userID || userID === state.account?.userID) return;
|
|
3277
|
+
const username = await cachedUsername(client, names, userID);
|
|
3278
|
+
const existing = state.dms.get(userID) ?? {
|
|
3279
|
+
unread: 0,
|
|
3280
|
+
userID,
|
|
3281
|
+
username,
|
|
3282
|
+
};
|
|
3283
|
+
const isUnread =
|
|
3284
|
+
message.direction === "incoming" &&
|
|
3285
|
+
!(route?.isActiveDm ?? isActiveDm(state, userID));
|
|
3286
|
+
state.dms.set(userID, {
|
|
3287
|
+
...existing,
|
|
3288
|
+
direction: message.direction,
|
|
3289
|
+
lastAt: message.timestamp ?? new Date().toISOString(),
|
|
3290
|
+
lastMessage: message.message,
|
|
3291
|
+
unread: isUnread ? (existing.unread ?? 0) + 1 : (existing.unread ?? 0),
|
|
3292
|
+
userID,
|
|
3293
|
+
username,
|
|
3294
|
+
});
|
|
3295
|
+
if (isUnread) {
|
|
3296
|
+
setPendingJump(
|
|
3297
|
+
state,
|
|
3298
|
+
{ id: userID, label: username, type: "dm" },
|
|
3299
|
+
message.timestamp,
|
|
3300
|
+
);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function markDmRead(state, userID) {
|
|
3305
|
+
const existing = state.dms?.get(userID);
|
|
3306
|
+
if (!existing) return;
|
|
3307
|
+
state.dms.set(userID, { ...existing, unread: 0 });
|
|
3308
|
+
if (
|
|
3309
|
+
state.pendingJump?.target?.type === "dm" &&
|
|
3310
|
+
state.pendingJump.target.id === userID
|
|
3311
|
+
) {
|
|
3312
|
+
const next = nextUnreadDm(state);
|
|
3313
|
+
state.pendingJump = next
|
|
3314
|
+
? {
|
|
3315
|
+
lastAt: next.lastAt,
|
|
3316
|
+
target: {
|
|
3317
|
+
id: next.userID,
|
|
3318
|
+
label: next.username,
|
|
3319
|
+
type: "dm",
|
|
3320
|
+
},
|
|
3321
|
+
}
|
|
3322
|
+
: null;
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
function setPendingJump(state, target, timestamp = null) {
|
|
3327
|
+
state.pendingJump = {
|
|
3328
|
+
lastAt: timestamp ?? new Date().toISOString(),
|
|
3329
|
+
target,
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
function nextUnreadDm(state) {
|
|
3334
|
+
const rows = [...(state.dms?.values() ?? [])].filter(
|
|
3335
|
+
(row) => (row.unread ?? 0) > 0,
|
|
3336
|
+
);
|
|
3337
|
+
rows.sort((a, b) => new Date(b.lastAt ?? 0) - new Date(a.lastAt ?? 0));
|
|
3338
|
+
return rows[0] ?? null;
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
function isActiveDm(state, userID) {
|
|
3342
|
+
return state.target?.type === "dm" && state.target.id === userID;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
async function userSharesServer(client, state, serverID, userID) {
|
|
3346
|
+
const key = `${serverID}:${userID}`;
|
|
3347
|
+
const cached = state.serverMemberCache.get(key);
|
|
3348
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
3349
|
+
return cached.value;
|
|
3350
|
+
}
|
|
3351
|
+
const value = await serverHasUser(client, serverID, userID).catch(
|
|
3352
|
+
() => false,
|
|
3353
|
+
);
|
|
3354
|
+
state.serverMemberCache.set(key, { expiresAt: Date.now() + 30_000, value });
|
|
3355
|
+
return value;
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
async function serverHasUser(client, serverID, userID) {
|
|
3359
|
+
const channels = await client.channels.retrieve(serverID);
|
|
3360
|
+
for (const channel of channels) {
|
|
3361
|
+
const users = await client.channels
|
|
3362
|
+
.userList(channel.channelID)
|
|
3363
|
+
.catch(() => []);
|
|
3364
|
+
if (users.some((user) => user.userID === userID)) {
|
|
3365
|
+
return true;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
return false;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
function color(name, value) {
|
|
3372
|
+
if (!COLOR) return String(value);
|
|
3373
|
+
return `${colorCode(name)}${String(value)}${ANSI.reset}`;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
function boldColor(name, value) {
|
|
3377
|
+
if (!COLOR) return String(value);
|
|
3378
|
+
return `${ANSI.bold}${colorCode(name)}${String(value)}${ANSI.reset}`;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
function rgbColor({ b, g, r }, value) {
|
|
3382
|
+
if (!COLOR) return String(value);
|
|
3383
|
+
return `\x1b[38;2;${r};${g};${b}m${String(value)}${ANSI.reset}`;
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
function colorCode(name) {
|
|
3387
|
+
if (typeof name === "string" && /^#[0-9a-f]{6}$/i.test(name)) {
|
|
3388
|
+
const { b, g, r } = hexToRgb(name);
|
|
3389
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
3390
|
+
}
|
|
3391
|
+
return ANSI[name] ?? "";
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
async function avatarMarkerForUser(client, state, userID) {
|
|
3395
|
+
if (!userID) return "";
|
|
3396
|
+
if (state.avatarMarkers.has(userID)) {
|
|
3397
|
+
return await state.avatarMarkers.get(userID);
|
|
3398
|
+
}
|
|
3399
|
+
const markerPromise = fetchAvatarMarker(client, userID).catch(() => "");
|
|
3400
|
+
state.avatarMarkers.set(userID, markerPromise);
|
|
3401
|
+
const marker = await markerPromise;
|
|
3402
|
+
state.avatarMarkers.set(userID, marker);
|
|
3403
|
+
return marker;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
async function fetchAvatarMarker(client, userID) {
|
|
3407
|
+
const res = await client.http.get(`${client.getHost()}/avatar/${userID}`, {
|
|
3408
|
+
responseType: "arraybuffer",
|
|
3409
|
+
validateStatus: (status) => status === 200 || status === 404,
|
|
3410
|
+
});
|
|
3411
|
+
if (res.status !== 200) return "";
|
|
3412
|
+
const bytes = new Uint8Array(res.data);
|
|
3413
|
+
if (bytes.length === 0) return "";
|
|
3414
|
+
return rgbColor(avatarColorFromBytes(bytes), "●");
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
function avatarColorFromBytes(bytes) {
|
|
3418
|
+
let hash = 0;
|
|
3419
|
+
for (const byte of bytes) {
|
|
3420
|
+
hash = (hash * 33 + byte) >>> 0;
|
|
3421
|
+
}
|
|
3422
|
+
return {
|
|
3423
|
+
b: 96 + ((hash >>> 16) % 160),
|
|
3424
|
+
g: 96 + ((hash >>> 8) % 160),
|
|
3425
|
+
r: 96 + (hash % 160),
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
function hashID(value) {
|
|
3430
|
+
let hash = 2166136261;
|
|
3431
|
+
for (const char of String(value)) {
|
|
3432
|
+
hash ^= char.charCodeAt(0);
|
|
3433
|
+
hash = Math.imul(hash, 16777619);
|
|
3434
|
+
}
|
|
3435
|
+
return hash >>> 0;
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
function paletteAccent(value, palette) {
|
|
3439
|
+
return palette[hashID(value) % palette.length];
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
function userAccent(userID) {
|
|
3443
|
+
if (!userID) return "white";
|
|
3444
|
+
const hash = hashID(userID);
|
|
3445
|
+
const first = USER_ACCENTS[hash % USER_ACCENTS.length];
|
|
3446
|
+
const second =
|
|
3447
|
+
USER_ACCENTS[(hash >>> 8) % USER_ACCENTS.length] ??
|
|
3448
|
+
USER_ACCENTS[(hash + 1) % USER_ACCENTS.length];
|
|
3449
|
+
const amount = 0.18 + ((hash >>> 16) % 48) / 100;
|
|
3450
|
+
const mixed = mixHex(first, second === first ? "#F5F5F5" : second, amount);
|
|
3451
|
+
return ensureReadableHex(mixed);
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
function hexToRgb(hex) {
|
|
3455
|
+
const value = hex.replace("#", "");
|
|
3456
|
+
return {
|
|
3457
|
+
b: Number.parseInt(value.slice(4, 6), 16),
|
|
3458
|
+
g: Number.parseInt(value.slice(2, 4), 16),
|
|
3459
|
+
r: Number.parseInt(value.slice(0, 2), 16),
|
|
3460
|
+
};
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
function rgbToHex({ b, g, r }) {
|
|
3464
|
+
const toHex = (value) =>
|
|
3465
|
+
Math.max(0, Math.min(255, Math.round(value)))
|
|
3466
|
+
.toString(16)
|
|
3467
|
+
.padStart(2, "0");
|
|
3468
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
function mixHex(first, second, amount) {
|
|
3472
|
+
const a = hexToRgb(first);
|
|
3473
|
+
const b = hexToRgb(second);
|
|
3474
|
+
return rgbToHex({
|
|
3475
|
+
b: a.b + (b.b - a.b) * amount,
|
|
3476
|
+
g: a.g + (b.g - a.g) * amount,
|
|
3477
|
+
r: a.r + (b.r - a.r) * amount,
|
|
3478
|
+
});
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
function ensureReadableHex(hex) {
|
|
3482
|
+
const rgb = hexToRgb(hex);
|
|
3483
|
+
const luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
|
|
3484
|
+
if (luminance >= 95) return hex;
|
|
3485
|
+
return mixHex(hex, "#F5F5F5", 0.35);
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
function serverAccent(serverID) {
|
|
3489
|
+
if (!serverID) return "red";
|
|
3490
|
+
return paletteAccent(serverID, TARGET_ACCENTS);
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
function channelAccent(channel) {
|
|
3494
|
+
const id =
|
|
3495
|
+
typeof channel === "string"
|
|
3496
|
+
? channel
|
|
3497
|
+
: (channel?.id ?? channel?.channelID ?? channel?.serverID);
|
|
3498
|
+
return serverAccent(id);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
function targetAccent(target) {
|
|
3502
|
+
if (!target) return "red";
|
|
3503
|
+
if (target.type === "dm") return userAccent(target.id);
|
|
3504
|
+
return channelAccent(target);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
function inviteAccent(inviteID) {
|
|
3508
|
+
if (!inviteID) return ROOT_ACCENT;
|
|
3509
|
+
return paletteAccent(inviteID, TARGET_ACCENTS);
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
async function saveTarget(ctx, state, target) {
|
|
3513
|
+
state.target = target;
|
|
3514
|
+
await saveAccountUiState(ctx, state.account, targetToAccountUi(target));
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
function targetToAccountUi(target) {
|
|
3518
|
+
const patch = { lastTarget: target };
|
|
3519
|
+
if (target?.type === "channel") {
|
|
3520
|
+
patch.lastChannel = target.id;
|
|
3521
|
+
if (target.serverID) patch.lastServer = target.serverID;
|
|
3522
|
+
}
|
|
3523
|
+
return patch;
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
function accountUiState(config, account) {
|
|
3527
|
+
if (!account) return {};
|
|
3528
|
+
const key = account.username?.toLowerCase();
|
|
3529
|
+
const stored = key ? config.accounts?.[key]?.ui : null;
|
|
3530
|
+
if (!stored || typeof stored !== "object") return {};
|
|
3531
|
+
return {
|
|
3532
|
+
lastChannel:
|
|
3533
|
+
typeof stored.lastChannel === "string"
|
|
3534
|
+
? stored.lastChannel
|
|
3535
|
+
: undefined,
|
|
3536
|
+
lastServer:
|
|
3537
|
+
typeof stored.lastServer === "string"
|
|
3538
|
+
? stored.lastServer
|
|
3539
|
+
: undefined,
|
|
3540
|
+
lastTarget: isTarget(stored.lastTarget) ? stored.lastTarget : null,
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
async function saveAccountUiState(ctx, account, patch) {
|
|
3545
|
+
const config = await readConfig(ctx.configPath);
|
|
3546
|
+
const key = account?.username?.toLowerCase();
|
|
3547
|
+
if (!key || !config.accounts[key]) return;
|
|
3548
|
+
const current = accountUiState(config, config.accounts[key]);
|
|
3549
|
+
config.accounts[key] = {
|
|
3550
|
+
...config.accounts[key],
|
|
3551
|
+
ui: {
|
|
3552
|
+
...current,
|
|
3553
|
+
...patch,
|
|
3554
|
+
},
|
|
3555
|
+
};
|
|
3556
|
+
if (patch.lastTarget === null) {
|
|
3557
|
+
config.accounts[key].ui.lastTarget = null;
|
|
3558
|
+
}
|
|
3559
|
+
await writeConfig(ctx.configPath, config);
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
function isTarget(value) {
|
|
3563
|
+
return (
|
|
3564
|
+
typeof value === "object" &&
|
|
3565
|
+
value !== null &&
|
|
3566
|
+
(value.type === "dm" || value.type === "channel") &&
|
|
3567
|
+
typeof value.id === "string" &&
|
|
3568
|
+
typeof value.label === "string"
|
|
3569
|
+
);
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
function renderChatLine(rl, state, line) {
|
|
3573
|
+
const activeLine = rl?.line ?? "";
|
|
3574
|
+
const activeCursor = rl?.cursor ?? activeLine.length;
|
|
3575
|
+
clearActivePrompt();
|
|
3576
|
+
output.write(`${line}\n`);
|
|
3577
|
+
restoreActivePrompt(rl, state, activeLine, activeCursor);
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
function renderNotificationLine(
|
|
3581
|
+
rl,
|
|
3582
|
+
state,
|
|
3583
|
+
{ author, authorID, avatar, isDm, target },
|
|
3584
|
+
) {
|
|
3585
|
+
const jump = state.pendingJump ? color("dim", " - press Tab to open") : "";
|
|
3586
|
+
const authorText = `${avatar ? `${avatar} ` : ""}${color(
|
|
3587
|
+
userAccent(authorID),
|
|
3588
|
+
isDm ? `@${author}` : author,
|
|
3589
|
+
)}`;
|
|
3590
|
+
const targetText = color("dim", target);
|
|
3591
|
+
const message = isDm
|
|
3592
|
+
? `DM message received from ${authorText}`
|
|
3593
|
+
: `Channel message received in ${targetText} from ${authorText}`;
|
|
3594
|
+
renderChatLine(
|
|
3595
|
+
rl,
|
|
3596
|
+
state,
|
|
3597
|
+
`${color(ROOT_ACCENT, "system")} ${message}${jump}`,
|
|
3598
|
+
);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
function printNoChatMessage(state) {
|
|
3602
|
+
const hasKnownChats =
|
|
3603
|
+
(state.buffers?.length ?? 0) > 0 || (state.dms?.size ?? 0) > 0;
|
|
3604
|
+
const title = hasKnownChats ? "No chat open." : "No chats yet.";
|
|
3605
|
+
const guidance = hasKnownChats
|
|
3606
|
+
? "Use /join to enter a server, /channels to pick a channel, or /inbox to open a DM."
|
|
3607
|
+
: "Create a server with /create, join one with /join <invite-link>, or send a DM with /dm <user> <message>.";
|
|
3608
|
+
console.log(
|
|
3609
|
+
`${color(ROOT_ACCENT, "system")} ${color("dim", `${title} ${guidance}`)}`,
|
|
3610
|
+
);
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
function restoreActivePrompt(rl, state, line, cursor) {
|
|
3614
|
+
if (!rl || !input.isTTY || !output.isTTY) return;
|
|
3615
|
+
output.write(`${promptFor(state)}${line}`);
|
|
3616
|
+
const offset = line.length - cursor;
|
|
3617
|
+
if (offset > 0) {
|
|
3618
|
+
output.write(`\x1b[${offset}D`);
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
function shouldSkipRenderedMessage(state, message) {
|
|
3623
|
+
const key = renderMessageKey(message);
|
|
3624
|
+
const now = Date.now();
|
|
3625
|
+
for (const [cachedKey, expiresAt] of state.renderedMessageKeys) {
|
|
3626
|
+
if (expiresAt <= now) {
|
|
3627
|
+
state.renderedMessageKeys.delete(cachedKey);
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
if (state.renderedMessageKeys.has(key)) {
|
|
3631
|
+
return true;
|
|
3632
|
+
}
|
|
3633
|
+
state.renderedMessageKeys.set(key, now + 1_000);
|
|
3634
|
+
return false;
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
function renderMessageKey(message) {
|
|
3638
|
+
if (message.direction === "outgoing") {
|
|
3639
|
+
const target = message.group ?? message.readerID ?? "unknown";
|
|
3640
|
+
return `out:${message.authorID}:${target}:${message.message}`;
|
|
3641
|
+
}
|
|
3642
|
+
if (message.mailID) {
|
|
3643
|
+
return `mail:${message.mailID}`;
|
|
3644
|
+
}
|
|
3645
|
+
const target = message.group ?? message.readerID ?? "unknown";
|
|
3646
|
+
return `in:${message.authorID}:${target}:${message.timestamp}:${message.message}`;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
function safePrompt(rl, preserveCursor = false) {
|
|
3650
|
+
try {
|
|
3651
|
+
rl.prompt(preserveCursor);
|
|
3652
|
+
} catch (err) {
|
|
3653
|
+
if (
|
|
3654
|
+
!(err instanceof Error) ||
|
|
3655
|
+
!err.message.includes("readline was closed")
|
|
3656
|
+
) {
|
|
3657
|
+
throw err;
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
function safeSetPrompt(rl, prompt) {
|
|
3663
|
+
try {
|
|
3664
|
+
rl.setPrompt(prompt);
|
|
3665
|
+
} catch (err) {
|
|
3666
|
+
if (
|
|
3667
|
+
!(err instanceof Error) ||
|
|
3668
|
+
!err.message.includes("readline was closed")
|
|
3669
|
+
) {
|
|
3670
|
+
throw err;
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
function refreshPrompt(rl, state) {
|
|
3676
|
+
if (!rl || !input.isTTY || !output.isTTY) return;
|
|
3677
|
+
safeSetPrompt(rl, promptFor(state));
|
|
3678
|
+
const activeLine = rl.line ?? "";
|
|
3679
|
+
const activeCursor = rl.cursor ?? activeLine.length;
|
|
3680
|
+
clearActivePrompt();
|
|
3681
|
+
restoreActivePrompt(rl, state, activeLine, activeCursor);
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
function promptFor(state) {
|
|
3685
|
+
const user = state.account?.username ?? "vex";
|
|
3686
|
+
const target = state.target ? targetLabel(state.target) : "no-channel";
|
|
3687
|
+
return `${color("dim", formatMessageTime(new Date()))} ${color("dim", target)} ${boldColor(userAccent(state.account?.userID), user)}${color("dim", ":")} `;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
function bumpActivity(state, activity = "net") {
|
|
3691
|
+
if (!state.status) {
|
|
3692
|
+
state.status = {
|
|
3693
|
+
activity: "",
|
|
3694
|
+
lastActivityAt: 0,
|
|
3695
|
+
network: "online",
|
|
3696
|
+
};
|
|
3697
|
+
}
|
|
3698
|
+
const mapped = statusActivity(activity);
|
|
3699
|
+
state.status.activity = mapped.activity;
|
|
3700
|
+
if (mapped.network) {
|
|
3701
|
+
state.status.network = mapped.network;
|
|
3702
|
+
}
|
|
3703
|
+
state.status.lastActivityAt = Date.now();
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
function beginSendingStatus(state, rl) {
|
|
3707
|
+
bumpActivity(state, "send");
|
|
3708
|
+
state.status.pendingSends = (state.status.pendingSends ?? 0) + 1;
|
|
3709
|
+
refreshPrompt(rl, state);
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
function endSendingStatus(state, rl) {
|
|
3713
|
+
if (!state.status) return;
|
|
3714
|
+
state.status.pendingSends = Math.max(
|
|
3715
|
+
0,
|
|
3716
|
+
(state.status.pendingSends ?? 1) - 1,
|
|
3717
|
+
);
|
|
3718
|
+
if (state.status.pendingSends === 0) {
|
|
3719
|
+
bumpActivity(state, "online");
|
|
3720
|
+
}
|
|
3721
|
+
refreshPrompt(rl, state);
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
function statusActivity(activity) {
|
|
3725
|
+
switch (activity) {
|
|
3726
|
+
case "connect":
|
|
3727
|
+
return { activity: "connecting", network: "connecting" };
|
|
3728
|
+
case "online":
|
|
3729
|
+
case "ready":
|
|
3730
|
+
return { activity: "online", network: "online" };
|
|
3731
|
+
case "offline":
|
|
3732
|
+
return { activity: "offline", network: "offline" };
|
|
3733
|
+
case "mail":
|
|
3734
|
+
return { activity: "checking mail", network: "syncing" };
|
|
3735
|
+
case "recv":
|
|
3736
|
+
return { activity: "received", network: "online" };
|
|
3737
|
+
case "send":
|
|
3738
|
+
return { activity: "sending", network: "online" };
|
|
3739
|
+
default:
|
|
3740
|
+
return { activity, network: null };
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
function targetLabel(target) {
|
|
3745
|
+
if (target.type === "channel") {
|
|
3746
|
+
return target.serverName
|
|
3747
|
+
? `${target.serverName}/${target.label}`
|
|
3748
|
+
: target.label;
|
|
3749
|
+
}
|
|
3750
|
+
return `@${target.label}`;
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
function shortID(id) {
|
|
3754
|
+
return id.slice(0, 8);
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
function matchingCodeForSignKey(signKey) {
|
|
3758
|
+
if (!signKey) return ["", "", "", ""];
|
|
3759
|
+
return String(signKey)
|
|
3760
|
+
.replace(/[^0-9a-fA-F]/g, "")
|
|
3761
|
+
.toUpperCase()
|
|
3762
|
+
.slice(0, 4)
|
|
3763
|
+
.padEnd(4, "·")
|
|
3764
|
+
.split("")
|
|
3765
|
+
.slice(0, 4);
|
|
3766
|
+
}
|
|
3767
|
+
|
|
3768
|
+
function matchingCodeStringForSignKey(signKey) {
|
|
3769
|
+
return matchingCodeForSignKey(signKey).join("");
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
function formatDeviceApprovalCode(signKeyOrCode) {
|
|
3773
|
+
const chars = /^[0-9A-F·]{4}$/.test(String(signKeyOrCode))
|
|
3774
|
+
? String(signKeyOrCode).split("")
|
|
3775
|
+
: matchingCodeForSignKey(signKeyOrCode);
|
|
3776
|
+
return chars
|
|
3777
|
+
.map((char) => color(ROOT_ACCENT, `[${char || "·"}]`))
|
|
3778
|
+
.join(" ");
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
function renderHeader(state, user, title) {
|
|
3782
|
+
const username = user?.username ?? state.account?.username ?? "unknown";
|
|
3783
|
+
const host = state.host ?? "unknown-host";
|
|
3784
|
+
const target = state.target
|
|
3785
|
+
? targetLabel(state.target)
|
|
3786
|
+
: "no chat selected";
|
|
3787
|
+
console.log(formatStartupMark(CLI_VERSION));
|
|
3788
|
+
console.log(
|
|
3789
|
+
`${color("dim", title)} ${color("dim", "|")} ${boldColor(userAccent(user?.userID ?? state.account?.userID), username)} ${color("dim", "on")} ${color(ROOT_ACCENT, host)} ${color("dim", "|")} ${color("dim", target)}`,
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
function formatStartupMark(version) {
|
|
3794
|
+
return [
|
|
3795
|
+
color(ROOT_ACCENT, "██╗ ██╗ ████████╗ ██╗ ██╗"),
|
|
3796
|
+
color(ROOT_ACCENT, "██║ ██║ ██╔═════╝ ╚██╗ ██╔╝"),
|
|
3797
|
+
color(ROOT_ACCENT, "██║ ██║ ██║ ╚██╗██╔╝ "),
|
|
3798
|
+
color(ROOT_ACCENT, "╚██╗ ██╔╝ ██████╗ ╚███╔╝ "),
|
|
3799
|
+
color(ROOT_ACCENT, " ╚██╗ ██╔╝ ██╔══╝ ██╔██╗ "),
|
|
3800
|
+
color(ROOT_ACCENT, " ╚██╗██╔╝ ██║ ██╔╝╚██╗ "),
|
|
3801
|
+
`${color(ROOT_ACCENT, " ╚███╔╝ ████████╗ ██╔╝ ╚██╗")} ${color("dim", `v${version}`)}`,
|
|
3802
|
+
color(ROOT_ACCENT, " ╚══╝ ╚═══════╝ ╚═╝ ╚═╝"),
|
|
3803
|
+
].join("\n");
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
function printWhoami(client) {
|
|
3807
|
+
const user = client.me.user();
|
|
3808
|
+
const device = client.me.device();
|
|
3809
|
+
console.log(
|
|
3810
|
+
`${color(ROOT_ACCENT, "username")} ${boldColor(userAccent(user.userID), user.username)}`,
|
|
3811
|
+
);
|
|
3812
|
+
console.log(`${color(ROOT_ACCENT, "user ")} ${user.userID}`);
|
|
3813
|
+
console.log(`${color(ROOT_ACCENT, "device ")} ${device.deviceID}`);
|
|
3814
|
+
console.log(`${color(ROOT_ACCENT, "name ")} ${device.name}`);
|
|
3815
|
+
console.log(`${color(ROOT_ACCENT, "login ")} ${device.lastLogin}`);
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
function printUser(user) {
|
|
3819
|
+
console.log(
|
|
3820
|
+
`${color(userAccent(user.userID), user.username)} ${color("dim", "user=")}${user.userID} ${color("dim", "signKey=")}${user.signKey}`,
|
|
3821
|
+
);
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
function printServers(servers) {
|
|
3825
|
+
if (servers.length === 0) {
|
|
3826
|
+
console.log(color("dim", "no servers"));
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3829
|
+
for (const server of servers) {
|
|
3830
|
+
console.log(color(serverAccent(server.serverID), server.name));
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
|
|
3834
|
+
function printChannels(channels) {
|
|
3835
|
+
if (channels.length === 0) {
|
|
3836
|
+
console.log(color("dim", "no channels"));
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
for (const channel of channels) {
|
|
3840
|
+
console.log(
|
|
3841
|
+
`${color(channelAccent(channel), `#${channel.name}`)} ${color("dim", channel.channelID)} ${color("dim", `server=${channel.serverID}`)}`,
|
|
3842
|
+
);
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
function printInvite(invite) {
|
|
3847
|
+
const link = inviteLink(invite.inviteID);
|
|
3848
|
+
console.log(
|
|
3849
|
+
`${color(ROOT_ACCENT, "invite")} ${color(inviteAccent(invite.inviteID), terminalLink(link, link))}`,
|
|
3850
|
+
);
|
|
3851
|
+
console.log(
|
|
3852
|
+
`${color("dim", "expires")} ${invite.expiration ?? invite.expires}`,
|
|
3853
|
+
);
|
|
3854
|
+
console.log(
|
|
3855
|
+
`${color("dim", "share this link to invite someone to the server")}`,
|
|
3856
|
+
);
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
function printInvitePreview(preview) {
|
|
3860
|
+
console.log(formatInvitePreviewBox(preview, preview.invite.inviteID));
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
function formatInviteChannelSummary(channels) {
|
|
3864
|
+
if (!channels || channels.length === 0) {
|
|
3865
|
+
return color("dim", "- no channels");
|
|
3866
|
+
}
|
|
3867
|
+
const names = channels
|
|
3868
|
+
.slice(0, 3)
|
|
3869
|
+
.map((channel) => color(channelAccent(channel), `#${channel.name}`))
|
|
3870
|
+
.join(", ");
|
|
3871
|
+
const extra = channels.length > 3 ? ` +${channels.length - 3} more` : "";
|
|
3872
|
+
return `- ${names}${color("dim", extra)}`;
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
function playIncomingSound(sound) {
|
|
3876
|
+
if (!sound) return;
|
|
3877
|
+
if (output.isTTY) {
|
|
3878
|
+
output.write("\x07");
|
|
3879
|
+
}
|
|
3880
|
+
const audioFile = resolveSoundFile(sound);
|
|
3881
|
+
if (!audioFile) return;
|
|
3882
|
+
const player = process.platform === "darwin" ? "afplay" : "paplay";
|
|
3883
|
+
execFile(player, [audioFile], { timeout: 2_000 }, () => {});
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
function resolveSoundFile(sound) {
|
|
3887
|
+
if (sound.includes("/") || sound.includes(".")) {
|
|
3888
|
+
return path.resolve(sound.replace(/^~/, os.homedir()));
|
|
3889
|
+
}
|
|
3890
|
+
if (process.platform === "darwin") {
|
|
3891
|
+
return `/System/Library/Sounds/${sound}.aiff`;
|
|
3892
|
+
}
|
|
3893
|
+
return null;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
function notifyIncomingMessage(author) {
|
|
3897
|
+
if (process.platform !== "darwin") {
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
const title = `DM from ${author}`;
|
|
3901
|
+
const body = "Press Tab in vex to open.";
|
|
3902
|
+
execFile(
|
|
3903
|
+
"osascript",
|
|
3904
|
+
[
|
|
3905
|
+
"-e",
|
|
3906
|
+
`display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`,
|
|
3907
|
+
],
|
|
3908
|
+
{ timeout: 1_000 },
|
|
3909
|
+
() => {},
|
|
3910
|
+
);
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
function truncateInline(value, maxLength) {
|
|
3914
|
+
const clean = String(value).replace(/\s+/g, " ").trim();
|
|
3915
|
+
return clean.length > maxLength
|
|
3916
|
+
? `${clean.slice(0, Math.max(0, maxLength - 3))}...`
|
|
3917
|
+
: clean;
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
function appleScriptString(value) {
|
|
3921
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
function historyNameCache(...users) {
|
|
3925
|
+
const names = new Map();
|
|
3926
|
+
for (const user of users) {
|
|
3927
|
+
if (user?.userID && user?.username) {
|
|
3928
|
+
names.set(user.userID, user.username);
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
return names;
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
async function printMessages(client, messages, options = {}) {
|
|
3935
|
+
if (messages.length === 0) {
|
|
3936
|
+
console.log(color("dim", "no messages"));
|
|
3937
|
+
return;
|
|
3938
|
+
}
|
|
3939
|
+
const names = options.names ?? historyNameCache(client.me.user());
|
|
3940
|
+
const targets = options.targets ?? new Map();
|
|
3941
|
+
for (const message of messages) {
|
|
3942
|
+
const target =
|
|
3943
|
+
options.targetLabel ??
|
|
3944
|
+
(await historyTargetLabel(client, names, targets, message));
|
|
3945
|
+
const who = await historyAuthorName(client, names, message.authorID);
|
|
3946
|
+
console.log(
|
|
3947
|
+
formatMessageLine({
|
|
3948
|
+
direction: message.direction,
|
|
3949
|
+
isDm: !message.group,
|
|
3950
|
+
message: message.message,
|
|
3951
|
+
target,
|
|
3952
|
+
targetID:
|
|
3953
|
+
message.group ||
|
|
3954
|
+
(message.direction === "outgoing"
|
|
3955
|
+
? message.readerID
|
|
3956
|
+
: message.authorID),
|
|
3957
|
+
targetType: message.group ? "channel" : "dm",
|
|
3958
|
+
timestamp: message.timestamp,
|
|
3959
|
+
who,
|
|
3960
|
+
whoID: message.authorID,
|
|
3961
|
+
}),
|
|
3962
|
+
);
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
async function historyAuthorName(client, names, userID) {
|
|
3967
|
+
try {
|
|
3968
|
+
return await cachedUsername(client, names, userID);
|
|
3969
|
+
} catch {
|
|
3970
|
+
return shortID(userID);
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
async function historyTargetLabel(client, names, targets, message) {
|
|
3975
|
+
if (message.group) {
|
|
3976
|
+
if (targets.has(message.group)) return targets.get(message.group);
|
|
3977
|
+
const channel = await client.channels
|
|
3978
|
+
.retrieveByID(message.group)
|
|
3979
|
+
.catch(() => null);
|
|
3980
|
+
if (!channel) {
|
|
3981
|
+
const fallback = `#${shortID(message.group)}`;
|
|
3982
|
+
targets.set(message.group, fallback);
|
|
3983
|
+
return fallback;
|
|
3984
|
+
}
|
|
3985
|
+
const servers = await client.servers.retrieve().catch(() => []);
|
|
3986
|
+
const server = servers.find(
|
|
3987
|
+
(item) => item.serverID === channel.serverID,
|
|
3988
|
+
);
|
|
3989
|
+
const target = targetLabel({
|
|
3990
|
+
id: channel.channelID,
|
|
3991
|
+
label: `#${channel.name}`,
|
|
3992
|
+
serverID: channel.serverID,
|
|
3993
|
+
serverName: server?.name,
|
|
3994
|
+
type: "channel",
|
|
3995
|
+
});
|
|
3996
|
+
targets.set(message.group, target);
|
|
3997
|
+
return target;
|
|
3998
|
+
}
|
|
3999
|
+
const peerID =
|
|
4000
|
+
message.direction === "outgoing" ? message.readerID : message.authorID;
|
|
4001
|
+
try {
|
|
4002
|
+
return `@${await cachedUsername(client, names, peerID)}`;
|
|
4003
|
+
} catch {
|
|
4004
|
+
return `@${shortID(peerID)}`;
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
function formatMessageLine({
|
|
4009
|
+
direction,
|
|
4010
|
+
message,
|
|
4011
|
+
target,
|
|
4012
|
+
timestamp,
|
|
4013
|
+
who,
|
|
4014
|
+
whoID,
|
|
4015
|
+
}) {
|
|
4016
|
+
const whoText = color(userAccent(whoID), who);
|
|
4017
|
+
return `${color("dim", formatMessageTime(timestamp))} ${color("dim", target)} ${whoText}${color("dim", ":")} ${message}`;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
function formatMessageTime(timestamp) {
|
|
4021
|
+
const date = new Date(timestamp);
|
|
4022
|
+
if (Number.isNaN(date.getTime())) return timestamp;
|
|
4023
|
+
const hours = date.getHours();
|
|
4024
|
+
const hour = hours % 12 || 12;
|
|
4025
|
+
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
4026
|
+
const meridiem = hours >= 12 ? "p" : "a";
|
|
4027
|
+
return `${String(hour).padStart(2, "0")}:${minute}${meridiem}`;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
function printHelp() {
|
|
4031
|
+
console.log(`vex
|
|
4032
|
+
|
|
4033
|
+
Commands:
|
|
4034
|
+
vex open the live terminal chat app
|
|
4035
|
+
vex <username> open as a specific local user
|
|
4036
|
+
vex chat [username] open the live terminal chat app
|
|
4037
|
+
vex auth register <username>
|
|
4038
|
+
vex auth login <username> request approval as a second device
|
|
4039
|
+
vex auth requests list pending device login requests
|
|
4040
|
+
vex auth accounts
|
|
4041
|
+
vex auth use <username>
|
|
4042
|
+
vex whoami
|
|
4043
|
+
|
|
4044
|
+
Flags:
|
|
4045
|
+
--username <name> local account to use
|
|
4046
|
+
--user <name> alias for --username
|
|
4047
|
+
--password <password> fallback password for login
|
|
4048
|
+
--api-url <url> API base URL, e.g. http://127.0.0.1:16777
|
|
4049
|
+
--host <host:port> API host, default api.vex.wtf
|
|
4050
|
+
--local connect to local Spire at 127.0.0.1:16777 over http/ws
|
|
4051
|
+
--http use http/ws
|
|
4052
|
+
--dev-key <key> send x-dev-api-key
|
|
4053
|
+
--debug write send/receive/connect diagnostics to a log file
|
|
4054
|
+
--debug-file <path> debug log path, default under the CLI data dir
|
|
4055
|
+
--debug-level <level> debug or trace; trace includes libvex mail details
|
|
4056
|
+
--data-dir <dir> local CLI account and sqlite storage
|
|
4057
|
+
--sound <name-or-path> incoming message sound, default Glass; use off to disable
|
|
4058
|
+
|
|
4059
|
+
Once chat is open, type /help for chat commands.
|
|
4060
|
+
`);
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
function printInteractiveHelp() {
|
|
4064
|
+
console.log(`${color("bold", "Chat commands")}
|
|
4065
|
+
|
|
4066
|
+
${color(ROOT_ACCENT, "/join [server]")} enter a server's #general
|
|
4067
|
+
${color(ROOT_ACCENT, "/servers")} browse your servers
|
|
4068
|
+
${color(ROOT_ACCENT, "/channels")} choose a channel
|
|
4069
|
+
${color(ROOT_ACCENT, "/user <user>")} open a DM conversation
|
|
4070
|
+
${color(ROOT_ACCENT, "/inbox")} show DMs, unread counts, and recent senders
|
|
4071
|
+
${color(ROOT_ACCENT, "/dm")} alias for /inbox
|
|
4072
|
+
${color(ROOT_ACCENT, "/dm <user>")} open a DM conversation
|
|
4073
|
+
${color(ROOT_ACCENT, "/dm <user> <message>")} send a DM without leaving the current chat
|
|
4074
|
+
${color(ROOT_ACCENT, "/to <user>")} open a DM conversation
|
|
4075
|
+
${color(ROOT_ACCENT, "/invite")} create an invite for the current server
|
|
4076
|
+
${color(ROOT_ACCENT, "/invite <user>")} send an invite link by DM
|
|
4077
|
+
${color(ROOT_ACCENT, "/join <invite-link>")} preview and accept a server invite
|
|
4078
|
+
${color(ROOT_ACCENT, "/create")} create a server and enter #general
|
|
4079
|
+
${color(ROOT_ACCENT, "/members")} list people in the current channel
|
|
4080
|
+
${color(ROOT_ACCENT, "/devices")} review pending device login requests
|
|
4081
|
+
${color(ROOT_ACCENT, "/accounts")} list local users
|
|
4082
|
+
${color(ROOT_ACCENT, "/whoami")} show your login
|
|
4083
|
+
${color(ROOT_ACCENT, "/quit")} leave chat
|
|
4084
|
+
|
|
4085
|
+
Plain text sends to the current channel or DM.`);
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
main()
|
|
4089
|
+
.then(() => {
|
|
4090
|
+
process.exit(0);
|
|
4091
|
+
})
|
|
4092
|
+
.catch((err) => {
|
|
4093
|
+
console.error(
|
|
4094
|
+
color(
|
|
4095
|
+
ROOT_ACCENT,
|
|
4096
|
+
err instanceof Error ? err.message : String(err),
|
|
4097
|
+
),
|
|
4098
|
+
);
|
|
4099
|
+
process.exit(1);
|
|
4100
|
+
});
|