jeopi-cli 16.6.14 → 16.6.17
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/CHANGELOG.md +20 -0
- package/README.md +2 -0
- package/dist/cli.js +3457 -3404
- package/dist/types/cli/messenger-cli.d.ts +10 -2
- package/dist/types/commands/messenger.d.ts +21 -1
- package/dist/types/messenger/discord-setup.d.ts +48 -0
- package/dist/types/messenger/discord.d.ts +3 -0
- package/dist/types/messenger/service.d.ts +19 -0
- package/dist/types/messenger/types.d.ts +4 -0
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +77 -2
- package/src/commands/messenger.ts +26 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/messenger/discord-setup.ts +563 -0
- package/src/messenger/discord.ts +213 -54
- package/src/messenger/gateway.ts +48 -22
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +55 -21
- package/src/messenger/session.ts +3 -0
- package/src/messenger/types.ts +4 -0
package/src/messenger/discord.ts
CHANGED
|
@@ -6,9 +6,10 @@ import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, Messe
|
|
|
6
6
|
// Protocol: https://docs.discord.com/developers/events/gateway
|
|
7
7
|
// Close codes: https://docs.discord.com/developers/topics/opcodes-and-status-codes
|
|
8
8
|
// REST messages: https://docs.discord.com/developers/resources/message#create-message
|
|
9
|
-
const
|
|
10
|
-
const
|
|
9
|
+
export const DISCORD_API = "https://discord.com/api/v10";
|
|
10
|
+
export const DISCORD_GATEWAY_INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
|
|
11
11
|
const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
|
12
|
+
const MAX_CLASSIFICATION_PENDING = 100;
|
|
12
13
|
|
|
13
14
|
// Discord strips boundary whitespace. Invisible guards preserve it in message content;
|
|
14
15
|
// they are part of the delivered text, and consume one UTF-16 unit each.
|
|
@@ -82,7 +83,7 @@ interface DiscordSession {
|
|
|
82
83
|
sequence: number | null;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
function
|
|
86
|
+
export function discordGatewayUrl(value: unknown): string {
|
|
86
87
|
let url: URL;
|
|
87
88
|
try {
|
|
88
89
|
if (typeof value !== "string") throw new Error();
|
|
@@ -158,7 +159,7 @@ function connectGateway(
|
|
|
158
159
|
token: string,
|
|
159
160
|
accountId: string,
|
|
160
161
|
session: DiscordSession,
|
|
161
|
-
onMessage: (message: MessengerMessage) => Promise<void>,
|
|
162
|
+
onMessage: (message: MessengerMessage, raw: unknown) => Promise<void>,
|
|
162
163
|
signal: AbortSignal,
|
|
163
164
|
WebSocketImpl: typeof WebSocket,
|
|
164
165
|
clock: DiscordClock,
|
|
@@ -267,7 +268,7 @@ function connectGateway(
|
|
|
267
268
|
} else {
|
|
268
269
|
send(2, {
|
|
269
270
|
token,
|
|
270
|
-
intents:
|
|
271
|
+
intents: DISCORD_GATEWAY_INTENTS,
|
|
271
272
|
properties: { os: process.platform, browser: "jeopi", device: "jeopi" },
|
|
272
273
|
});
|
|
273
274
|
}
|
|
@@ -287,7 +288,7 @@ function connectGateway(
|
|
|
287
288
|
return;
|
|
288
289
|
}
|
|
289
290
|
try {
|
|
290
|
-
session.url =
|
|
291
|
+
session.url = discordGatewayUrl(packet.d.resume_gateway_url);
|
|
291
292
|
} catch {
|
|
292
293
|
finish(new DiscordConfigurationError("Discord returned an untrusted resume Gateway URL"));
|
|
293
294
|
return;
|
|
@@ -304,7 +305,7 @@ function connectGateway(
|
|
|
304
305
|
// The gateway owns authorization, deduplication and its bounded work queue.
|
|
305
306
|
void Promise.resolve()
|
|
306
307
|
.then(() => {
|
|
307
|
-
if (!signal.aborted) return onMessage(message);
|
|
308
|
+
if (!signal.aborted) return onMessage(message, packet.d);
|
|
308
309
|
})
|
|
309
310
|
.catch(() => logger.warn("Discord message handler failed"));
|
|
310
311
|
}
|
|
@@ -328,6 +329,9 @@ export function createDiscordAdapter(
|
|
|
328
329
|
const token = env[config.tokenEnv]?.trim();
|
|
329
330
|
const clock = deps.clock ?? defaultClock;
|
|
330
331
|
const createdThreads = new Map<string, { parentChannelId: string; senderId: string }>();
|
|
332
|
+
const classificationTails = new Map<string, Promise<void>>();
|
|
333
|
+
const messageClassifications = new Map<string, Promise<void>>();
|
|
334
|
+
const pendingClassifications = new Set<string>();
|
|
331
335
|
const rememberThread = (threadId: string, parentChannelId: string, senderId: string): void => {
|
|
332
336
|
createdThreads.set(threadId, { parentChannelId, senderId });
|
|
333
337
|
if (createdThreads.size > 10_000) {
|
|
@@ -351,7 +355,12 @@ export function createDiscordAdapter(
|
|
|
351
355
|
let retryDelay = delay;
|
|
352
356
|
try {
|
|
353
357
|
if (!accountId) {
|
|
354
|
-
const user = await messengerFetchJson(
|
|
358
|
+
const user = await messengerFetchJson(
|
|
359
|
+
`${DISCORD_API}/users/@me`,
|
|
360
|
+
{ headers: headers() },
|
|
361
|
+
signal,
|
|
362
|
+
deps.fetch,
|
|
363
|
+
);
|
|
355
364
|
if (signal.aborted) return;
|
|
356
365
|
if (!isRecord(user) || typeof user.id !== "string" || !user.id || user.bot !== true) {
|
|
357
366
|
throw new DiscordConfigurationError("Discord credentials must identify a bot account");
|
|
@@ -360,7 +369,7 @@ export function createDiscordAdapter(
|
|
|
360
369
|
}
|
|
361
370
|
if (!initialUrl) {
|
|
362
371
|
const gateway = await messengerFetchJson(
|
|
363
|
-
`${
|
|
372
|
+
`${DISCORD_API}/gateway/bot`,
|
|
364
373
|
{ headers: headers() },
|
|
365
374
|
signal,
|
|
366
375
|
deps.fetch,
|
|
@@ -370,49 +379,159 @@ export function createDiscordAdapter(
|
|
|
370
379
|
if (isRecord(gateway.session_start_limit) && gateway.session_start_limit.remaining === 0) {
|
|
371
380
|
throw new DiscordConfigurationError("Discord Gateway session start limit is exhausted");
|
|
372
381
|
}
|
|
373
|
-
initialUrl =
|
|
382
|
+
initialUrl = discordGatewayUrl(gateway.url);
|
|
374
383
|
}
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
if (thread && thread.senderId === message.senderId) {
|
|
378
|
-
await onMessage({
|
|
379
|
-
...message,
|
|
380
|
-
threadId: message.channelId,
|
|
381
|
-
parentChannelId: thread.parentChannelId,
|
|
382
|
-
});
|
|
383
|
-
} else if (
|
|
384
|
-
!message.direct &&
|
|
385
|
-
message.mentioned &&
|
|
386
|
-
config.allowedUserIds.includes(message.senderId) &&
|
|
387
|
-
config.allowedChannelIds &&
|
|
388
|
-
!config.allowedChannelIds.includes(message.channelId)
|
|
389
|
-
) {
|
|
384
|
+
const lookup = async (url: string): Promise<unknown> => {
|
|
385
|
+
for (let attempt = 0; ; attempt++) {
|
|
390
386
|
try {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
{ headers: headers() },
|
|
394
|
-
signal,
|
|
395
|
-
deps.fetch,
|
|
396
|
-
);
|
|
387
|
+
return await messengerFetchJson(url, { headers: headers() }, signal, deps.fetch);
|
|
388
|
+
} catch (error) {
|
|
397
389
|
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
channel.parent_id
|
|
390
|
+
!(error instanceof MessengerHttpError) ||
|
|
391
|
+
error.status !== 429 ||
|
|
392
|
+
attempt >= 3 ||
|
|
393
|
+
signal.aborted
|
|
403
394
|
) {
|
|
404
|
-
|
|
405
|
-
...message,
|
|
406
|
-
threadId: message.channelId,
|
|
407
|
-
parentChannelId: channel.parent_id,
|
|
408
|
-
});
|
|
395
|
+
throw error;
|
|
409
396
|
}
|
|
397
|
+
await waitForReconnect(error.retryAfterMs ?? 1_000, signal, clock);
|
|
398
|
+
if (signal.aborted) throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const isBotReply = async (value: unknown, channelId: string): Promise<boolean> => {
|
|
403
|
+
if (!isRecord(value) || value.type !== 19 || !isRecord(value.message_reference)) return false;
|
|
404
|
+
const reference = value.message_reference;
|
|
405
|
+
if (
|
|
406
|
+
typeof reference.message_id !== "string" ||
|
|
407
|
+
(reference.channel_id && reference.channel_id !== channelId)
|
|
408
|
+
)
|
|
409
|
+
return false;
|
|
410
|
+
let original = value.referenced_message;
|
|
411
|
+
if (original === undefined) {
|
|
412
|
+
try {
|
|
413
|
+
original = await lookup(
|
|
414
|
+
`${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(reference.message_id)}`,
|
|
415
|
+
);
|
|
410
416
|
} catch {
|
|
411
|
-
if (!signal.aborted) logger.warn("Discord
|
|
417
|
+
if (!signal.aborted) logger.warn("Discord reply lookup failed");
|
|
418
|
+
return false;
|
|
412
419
|
}
|
|
413
|
-
} else {
|
|
414
|
-
await onMessage(message);
|
|
415
420
|
}
|
|
421
|
+
return (
|
|
422
|
+
isRecord(original) &&
|
|
423
|
+
original.id === reference.message_id &&
|
|
424
|
+
isRecord(original.author) &&
|
|
425
|
+
original.author.id === accountId
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
const forward = (message: MessengerMessage): void => {
|
|
429
|
+
if (signal.aborted) return;
|
|
430
|
+
try {
|
|
431
|
+
void onMessage(message).catch(() => logger.warn("Discord message handler failed"));
|
|
432
|
+
} catch {
|
|
433
|
+
logger.warn("Discord message handler failed");
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const classify = async (message: MessengerMessage, raw: unknown): Promise<void> => {
|
|
437
|
+
if (message.direct || !config.allowedUserIds.includes(message.senderId)) {
|
|
438
|
+
forward(message);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!message.mentioned && (await isBotReply(raw, message.channelId))) {
|
|
442
|
+
message = { ...message, mentioned: true };
|
|
443
|
+
}
|
|
444
|
+
const thread = createdThreads.get(message.channelId);
|
|
445
|
+
if (thread && thread.senderId === message.senderId) {
|
|
446
|
+
forward({ ...message, threadId: message.channelId, parentChannelId: thread.parentChannelId });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const channel = await lookup(`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`);
|
|
451
|
+
if (
|
|
452
|
+
!isRecord(channel) ||
|
|
453
|
+
typeof channel.type !== "number" ||
|
|
454
|
+
![10, 11, 12].includes(channel.type) ||
|
|
455
|
+
typeof channel.parent_id !== "string" ||
|
|
456
|
+
!channel.parent_id ||
|
|
457
|
+
(config.allowedChannelIds &&
|
|
458
|
+
!config.allowedChannelIds.includes(message.channelId) &&
|
|
459
|
+
!config.allowedChannelIds.includes(channel.parent_id))
|
|
460
|
+
) {
|
|
461
|
+
forward(message);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!message.mentioned && channel.owner_id !== accountId) {
|
|
465
|
+
let before = message.messageId;
|
|
466
|
+
for (;;) {
|
|
467
|
+
const history = await lookup(
|
|
468
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages?limit=100&before=${encodeURIComponent(before)}`,
|
|
469
|
+
);
|
|
470
|
+
if (!Array.isArray(history)) return;
|
|
471
|
+
let invoked = false;
|
|
472
|
+
for (const entry of history) {
|
|
473
|
+
if (
|
|
474
|
+
!isRecord(entry) ||
|
|
475
|
+
!isRecord(entry.author) ||
|
|
476
|
+
entry.author.bot ||
|
|
477
|
+
entry.webhook_id ||
|
|
478
|
+
typeof entry.author.id !== "string" ||
|
|
479
|
+
!config.allowedUserIds.includes(entry.author.id)
|
|
480
|
+
)
|
|
481
|
+
continue;
|
|
482
|
+
if (
|
|
483
|
+
(Array.isArray(entry.mentions) &&
|
|
484
|
+
entry.mentions.some(user => isRecord(user) && user.id === accountId)) ||
|
|
485
|
+
(await isBotReply(entry, message.channelId))
|
|
486
|
+
) {
|
|
487
|
+
invoked = true;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (invoked) break;
|
|
492
|
+
if (history.length < 100) {
|
|
493
|
+
forward(message);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const last = history[history.length - 1];
|
|
497
|
+
if (!isRecord(last) || typeof last.id !== "string" || !last.id || last.id === before) return;
|
|
498
|
+
before = last.id;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
forward({ ...message, threadId: message.channelId, parentChannelId: channel.parent_id });
|
|
502
|
+
} catch {
|
|
503
|
+
if (!signal.aborted) logger.warn("Discord thread lookup failed");
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
};
|
|
507
|
+
const deliver = (message: MessengerMessage, raw: unknown): Promise<void> => {
|
|
508
|
+
if (signal.aborted) return Promise.resolve();
|
|
509
|
+
if (message.direct || !config.allowedUserIds.includes(message.senderId)) {
|
|
510
|
+
forward(message);
|
|
511
|
+
return Promise.resolve();
|
|
512
|
+
}
|
|
513
|
+
const key = `${message.channelId}:${message.messageId}`;
|
|
514
|
+
if (pendingClassifications.has(key) || pendingClassifications.size >= MAX_CLASSIFICATION_PENDING) {
|
|
515
|
+
return Promise.resolve();
|
|
516
|
+
}
|
|
517
|
+
pendingClassifications.add(key);
|
|
518
|
+
const previous = Promise.all([
|
|
519
|
+
classificationTails.get(message.channelId),
|
|
520
|
+
messageClassifications.get(message.channelId),
|
|
521
|
+
]);
|
|
522
|
+
const current = previous
|
|
523
|
+
.then(() => (signal.aborted ? undefined : classify(message, raw)))
|
|
524
|
+
.catch(() => logger.warn("Discord classification failed"));
|
|
525
|
+
classificationTails.set(message.channelId, current);
|
|
526
|
+
messageClassifications.set(message.messageId, current);
|
|
527
|
+
void current.then(() => {
|
|
528
|
+
pendingClassifications.delete(key);
|
|
529
|
+
if (messageClassifications.get(message.messageId) === current)
|
|
530
|
+
messageClassifications.delete(message.messageId);
|
|
531
|
+
if (classificationTails.get(message.channelId) === current)
|
|
532
|
+
classificationTails.delete(message.channelId);
|
|
533
|
+
});
|
|
534
|
+
return current;
|
|
416
535
|
};
|
|
417
536
|
await connectGateway(
|
|
418
537
|
session.url ?? initialUrl,
|
|
@@ -459,7 +578,7 @@ export function createDiscordAdapter(
|
|
|
459
578
|
return message;
|
|
460
579
|
}
|
|
461
580
|
const channel = await messengerFetchJson(
|
|
462
|
-
`${
|
|
581
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
463
582
|
{ headers: headers() },
|
|
464
583
|
signal,
|
|
465
584
|
deps.fetch,
|
|
@@ -474,7 +593,7 @@ export function createDiscordAdapter(
|
|
|
474
593
|
}
|
|
475
594
|
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
476
595
|
const thread = await messengerFetchJson(
|
|
477
|
-
`${
|
|
596
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
|
|
478
597
|
{ method: "POST", headers: headers(), body: JSON.stringify({ name: `Request ${message.messageId}` }) },
|
|
479
598
|
signal,
|
|
480
599
|
deps.fetch,
|
|
@@ -485,7 +604,7 @@ export function createDiscordAdapter(
|
|
|
485
604
|
return { ...message, threadId: thread.id };
|
|
486
605
|
},
|
|
487
606
|
async status(message, state, signal) {
|
|
488
|
-
const base = `${
|
|
607
|
+
const base = `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
|
|
489
608
|
const processing = encodeURIComponent("⏳");
|
|
490
609
|
if (state !== "processing") {
|
|
491
610
|
try {
|
|
@@ -500,17 +619,44 @@ export function createDiscordAdapter(
|
|
|
500
619
|
}
|
|
501
620
|
}
|
|
502
621
|
const emoji = state === "processing" ? "⏳" : state === "success" ? "✅" : "❌";
|
|
622
|
+
try {
|
|
623
|
+
await messengerFetchJson(
|
|
624
|
+
`${base}/${encodeURIComponent(emoji)}/@me`,
|
|
625
|
+
{ method: "PUT", headers: headers() },
|
|
626
|
+
signal,
|
|
627
|
+
deps.fetch,
|
|
628
|
+
);
|
|
629
|
+
} finally {
|
|
630
|
+
if (state === "processing" && signal?.aborted) {
|
|
631
|
+
try {
|
|
632
|
+
await messengerFetchJson(
|
|
633
|
+
`${base}/${processing}/@me`,
|
|
634
|
+
{ method: "DELETE", headers: headers() },
|
|
635
|
+
AbortSignal.timeout(3_000),
|
|
636
|
+
deps.fetch,
|
|
637
|
+
);
|
|
638
|
+
} catch {
|
|
639
|
+
logger.warn("Discord late processing reaction removal failed");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
async clearStatus(message, signal) {
|
|
645
|
+
const base = `${DISCORD_API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
|
|
503
646
|
await messengerFetchJson(
|
|
504
|
-
`${base}/${encodeURIComponent(
|
|
505
|
-
{ method: "
|
|
647
|
+
`${base}/${encodeURIComponent("⏳")}/@me`,
|
|
648
|
+
{ method: "DELETE", headers: headers() },
|
|
506
649
|
signal,
|
|
507
650
|
deps.fetch,
|
|
508
651
|
);
|
|
509
652
|
},
|
|
653
|
+
releaseActivity(message) {
|
|
654
|
+
activityMessages.delete(JSON.stringify([message.threadId ?? message.channelId, message.messageId]));
|
|
655
|
+
},
|
|
510
656
|
async activity(message, text, signal) {
|
|
511
657
|
const channelId = message.threadId ?? message.channelId;
|
|
512
658
|
const key = JSON.stringify([channelId, message.messageId]);
|
|
513
|
-
const messages = `${
|
|
659
|
+
const messages = `${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
514
660
|
const statusId = activityMessages.get(key);
|
|
515
661
|
if (text === undefined) {
|
|
516
662
|
if (!statusId) return;
|
|
@@ -560,6 +706,19 @@ export function createDiscordAdapter(
|
|
|
560
706
|
);
|
|
561
707
|
if (!isRecord(created) || typeof created.id !== "string" || !created.id)
|
|
562
708
|
throw new Error("Discord activity message returned no ID");
|
|
709
|
+
if (signal?.aborted) {
|
|
710
|
+
try {
|
|
711
|
+
await messengerFetchJson(
|
|
712
|
+
`${messages}/${encodeURIComponent(created.id)}`,
|
|
713
|
+
{ method: "DELETE", headers: headers() },
|
|
714
|
+
AbortSignal.timeout(3_000),
|
|
715
|
+
deps.fetch,
|
|
716
|
+
);
|
|
717
|
+
} catch {
|
|
718
|
+
logger.warn("Discord late activity removal failed");
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
563
722
|
activityMessages.set(key, created.id);
|
|
564
723
|
},
|
|
565
724
|
async send(message, text, signal) {
|
|
@@ -571,7 +730,7 @@ export function createDiscordAdapter(
|
|
|
571
730
|
files = result.files;
|
|
572
731
|
} catch {
|
|
573
732
|
await messengerFetchJson(
|
|
574
|
-
`${
|
|
733
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
575
734
|
{
|
|
576
735
|
method: "POST",
|
|
577
736
|
headers: headers(),
|
|
@@ -614,7 +773,7 @@ export function createDiscordAdapter(
|
|
|
614
773
|
});
|
|
615
774
|
}
|
|
616
775
|
await messengerFetchJson(
|
|
617
|
-
`${
|
|
776
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
618
777
|
{
|
|
619
778
|
method: "POST",
|
|
620
779
|
headers: requestHeaders,
|
|
@@ -655,7 +814,7 @@ export function createDiscordAdapter(
|
|
|
655
814
|
for (let attempt = 0; ; attempt++) {
|
|
656
815
|
try {
|
|
657
816
|
await messengerFetchJson(
|
|
658
|
-
`${
|
|
817
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
659
818
|
{ method: "POST", headers: requestHeaders, body: form },
|
|
660
819
|
signal,
|
|
661
820
|
deps.fetch,
|
|
@@ -668,7 +827,7 @@ export function createDiscordAdapter(
|
|
|
668
827
|
}
|
|
669
828
|
if (!signal?.aborted) {
|
|
670
829
|
await messengerFetchJson(
|
|
671
|
-
`${
|
|
830
|
+
`${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
672
831
|
{
|
|
673
832
|
method: "POST",
|
|
674
833
|
headers: headers(),
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -141,24 +141,27 @@ function createActivityReporter(
|
|
|
141
141
|
closing ??= (async () => {
|
|
142
142
|
closed = true;
|
|
143
143
|
wake?.();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} catch (error) {
|
|
151
|
-
const wait =
|
|
152
|
-
error instanceof MessengerHttpError && error.status === 429
|
|
153
|
-
? (error.retryAfterMs ?? 1_000)
|
|
154
|
-
: 1_000;
|
|
155
|
-
// One bounded retry: removal holds the request queue.
|
|
156
|
-
if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
|
|
157
|
-
logger.warn("Messenger activity removal failed", { platform: destination.platform });
|
|
144
|
+
try {
|
|
145
|
+
await pump;
|
|
146
|
+
if (!shown) return;
|
|
147
|
+
for (let attempt = 0; ; attempt++) {
|
|
148
|
+
try {
|
|
149
|
+
await update(undefined);
|
|
158
150
|
return;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const wait =
|
|
153
|
+
error instanceof MessengerHttpError && error.status === 429
|
|
154
|
+
? (error.retryAfterMs ?? 1_000)
|
|
155
|
+
: 1_000;
|
|
156
|
+
if (attempt > 0 || wait > ACTIVITY_TIMEOUT_MS) {
|
|
157
|
+
logger.warn("Messenger activity removal failed", { platform: destination.platform });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
await Bun.sleep(wait);
|
|
159
161
|
}
|
|
160
|
-
await Bun.sleep(wait);
|
|
161
162
|
}
|
|
163
|
+
} finally {
|
|
164
|
+
adapter.releaseActivity?.(destination);
|
|
162
165
|
}
|
|
163
166
|
})();
|
|
164
167
|
return closing;
|
|
@@ -183,14 +186,18 @@ export function messengerSessionRoute(cwd: string, message: MessengerMessage): s
|
|
|
183
186
|
}
|
|
184
187
|
|
|
185
188
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* earlier requests in its conversation. A follow-up arriving in that new thread is ordered behind the request
|
|
189
|
-
* that created it by the prepared-conversation lock in `processRequest`.
|
|
189
|
+
* Discord creates a thread with the original message's ID. Reserve that conversation key before
|
|
190
|
+
* preparing it so follow-ups cannot overtake the request while thread creation is in flight.
|
|
190
191
|
*/
|
|
191
192
|
function schedulingKey(message: MessengerMessage, adapter: MessengerAdapter): string {
|
|
192
|
-
if (
|
|
193
|
-
|
|
193
|
+
if (
|
|
194
|
+
message.platform === "discord" &&
|
|
195
|
+
adapter.prepare &&
|
|
196
|
+
!message.direct &&
|
|
197
|
+
!message.threadId &&
|
|
198
|
+
!message.parentChannelId
|
|
199
|
+
) {
|
|
200
|
+
return messengerConversationKey({ ...message, threadId: message.messageId });
|
|
194
201
|
}
|
|
195
202
|
return messengerConversationKey(message);
|
|
196
203
|
}
|
|
@@ -271,6 +278,7 @@ export function createMessengerGateway(
|
|
|
271
278
|
let tail: Promise<void> | undefined;
|
|
272
279
|
let conversation = "";
|
|
273
280
|
let statusChain = Promise.resolve();
|
|
281
|
+
let processingStarted = false;
|
|
274
282
|
const status = (state: "processing" | "success" | "failure"): void => {
|
|
275
283
|
if (!adapter.status) return;
|
|
276
284
|
statusChain = statusChain.then(async () => {
|
|
@@ -281,7 +289,10 @@ export function createMessengerGateway(
|
|
|
281
289
|
const onAbort = (): void => stopped.resolve();
|
|
282
290
|
statusSignal.addEventListener("abort", onAbort, { once: true });
|
|
283
291
|
statusInFlight++;
|
|
284
|
-
const operation = Promise.resolve().then(() =>
|
|
292
|
+
const operation = Promise.resolve().then(() => {
|
|
293
|
+
if (state === "processing") processingStarted = true;
|
|
294
|
+
return adapter.status!(message, state, statusSignal);
|
|
295
|
+
});
|
|
285
296
|
void operation.then(
|
|
286
297
|
() => {
|
|
287
298
|
statusInFlight--;
|
|
@@ -345,6 +356,21 @@ export function createMessengerGateway(
|
|
|
345
356
|
}
|
|
346
357
|
} finally {
|
|
347
358
|
await activity.close();
|
|
359
|
+
if (signal.aborted && processingStarted && adapter.clearStatus && statusInFlight < MAX_STATUS_IN_FLIGHT) {
|
|
360
|
+
statusInFlight++;
|
|
361
|
+
const operation = Promise.resolve().then(() =>
|
|
362
|
+
adapter.clearStatus!(message, AbortSignal.timeout(STATUS_TIMEOUT_MS)),
|
|
363
|
+
);
|
|
364
|
+
void operation.then(
|
|
365
|
+
() => statusInFlight--,
|
|
366
|
+
() => statusInFlight--,
|
|
367
|
+
);
|
|
368
|
+
try {
|
|
369
|
+
await Promise.race([operation, Bun.sleep(STATUS_TIMEOUT_MS)]);
|
|
370
|
+
} catch {
|
|
371
|
+
logger.warn("Messenger cancelled status removal failed", { platform: message.platform });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
348
374
|
release();
|
|
349
375
|
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
350
376
|
resolve();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
|
|
6
|
+
/** A per-user background service that keeps one messenger profile's gateway running. */
|
|
7
|
+
export interface MessengerServicePlan {
|
|
8
|
+
/** Stable service name derived from the profile directory. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Profile directory: working directory and home of the run script and log. */
|
|
11
|
+
directory: string;
|
|
12
|
+
runScript: string;
|
|
13
|
+
logFile: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function messengerServicePlan(directory: string, runScript: string): MessengerServicePlan {
|
|
17
|
+
const slug =
|
|
18
|
+
path
|
|
19
|
+
.basename(directory)
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
22
|
+
.replace(/^-+|-+$/g, "") || "default";
|
|
23
|
+
return {
|
|
24
|
+
label: `dev.jeopi.messenger.${slug}`,
|
|
25
|
+
directory,
|
|
26
|
+
runScript,
|
|
27
|
+
logFile: path.join(directory, "gateway.log"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function xml(value: string): string {
|
|
32
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** launchd job: starts at login, restarts after a crash (throttled), logs to the profile directory. */
|
|
36
|
+
export function launchdPlist(plan: MessengerServicePlan): string {
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
39
|
+
<plist version="1.0">
|
|
40
|
+
<dict>
|
|
41
|
+
<key>Label</key><string>${xml(plan.label)}</string>
|
|
42
|
+
<key>ProgramArguments</key>
|
|
43
|
+
<array><string>/bin/sh</string><string>${xml(plan.runScript)}</string></array>
|
|
44
|
+
<key>WorkingDirectory</key><string>${xml(plan.directory)}</string>
|
|
45
|
+
<key>RunAtLoad</key><true/>
|
|
46
|
+
<key>KeepAlive</key><true/>
|
|
47
|
+
<key>ThrottleInterval</key><integer>30</integer>
|
|
48
|
+
<key>StandardOutPath</key><string>${xml(plan.logFile)}</string>
|
|
49
|
+
<key>StandardErrorPath</key><string>${xml(plan.logFile)}</string>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** systemd --user unit with the same lifecycle as the launchd job. */
|
|
56
|
+
export function systemdUnit(plan: MessengerServicePlan): string {
|
|
57
|
+
const quoted = (value: string): string => `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
58
|
+
return `[Unit]
|
|
59
|
+
Description=jeopi messenger gateway (${plan.label})
|
|
60
|
+
After=network-online.target
|
|
61
|
+
|
|
62
|
+
[Service]
|
|
63
|
+
WorkingDirectory=${quoted(plan.directory)}
|
|
64
|
+
ExecStart=/bin/sh ${quoted(plan.runScript)}
|
|
65
|
+
Restart=always
|
|
66
|
+
RestartSec=30
|
|
67
|
+
StandardOutput=append:${plan.logFile}
|
|
68
|
+
StandardError=append:${plan.logFile}
|
|
69
|
+
|
|
70
|
+
[Install]
|
|
71
|
+
WantedBy=default.target
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Install (or replace) and start the service, then confirm the platform reports it running.
|
|
77
|
+
* Returns a one-line description of where it was installed.
|
|
78
|
+
*/
|
|
79
|
+
export async function installMessengerService(
|
|
80
|
+
plan: MessengerServicePlan,
|
|
81
|
+
platform: NodeJS.Platform = process.platform,
|
|
82
|
+
home: string = os.homedir(),
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
if (platform === "darwin") {
|
|
85
|
+
const file = path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
|
|
86
|
+
await Bun.write(file, launchdPlist(plan));
|
|
87
|
+
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
88
|
+
await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
|
|
89
|
+
// bootstrap can fail briefly while a replaced job is still exiting.
|
|
90
|
+
let loaded = false;
|
|
91
|
+
for (let attempt = 0; attempt < 5 && !loaded; attempt++) {
|
|
92
|
+
if (attempt) await Bun.sleep(1_000);
|
|
93
|
+
loaded = (await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode === 0;
|
|
94
|
+
}
|
|
95
|
+
if (!loaded) throw new Error(`launchctl could not load ${file}`);
|
|
96
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
97
|
+
const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
|
|
98
|
+
if (status.exitCode === 0 && /\bstate = running\b/.test(status.text()))
|
|
99
|
+
return `launchd job ${plan.label} (${file})`;
|
|
100
|
+
await Bun.sleep(500);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
|
|
103
|
+
}
|
|
104
|
+
if (platform === "linux") {
|
|
105
|
+
const unit = `${plan.label}.service`;
|
|
106
|
+
const file = path.join(home, ".config", "systemd", "user", unit);
|
|
107
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
|
+
await Bun.write(file, systemdUnit(plan));
|
|
109
|
+
const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
|
|
110
|
+
const enable = await $`systemctl --user enable ${unit}`.quiet().nothrow();
|
|
111
|
+
const restart = await $`systemctl --user restart ${unit}`.quiet().nothrow();
|
|
112
|
+
if (reload.exitCode !== 0 || enable.exitCode !== 0 || restart.exitCode !== 0) {
|
|
113
|
+
throw new Error(`systemctl --user could not start ${unit}`);
|
|
114
|
+
}
|
|
115
|
+
const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
|
|
116
|
+
if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
|
|
117
|
+
return `systemd user unit ${unit} (${file})`;
|
|
118
|
+
}
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Background services are supported on macOS (launchd) and Linux (systemd --user); run ${plan.runScript} under your own process manager`,
|
|
121
|
+
);
|
|
122
|
+
}
|