jeopi-cli 16.6.13 → 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.
@@ -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 API = "https://discord.com/api/v10";
10
- const INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
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 gatewayUrl(value: unknown): string {
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: 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 = gatewayUrl(packet.d.resume_gateway_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(`${API}/users/@me`, { headers: headers() }, signal, deps.fetch);
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
- `${API}/gateway/bot`,
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 = gatewayUrl(gateway.url);
382
+ initialUrl = discordGatewayUrl(gateway.url);
374
383
  }
375
- const deliver = async (message: MessengerMessage): Promise<void> => {
376
- const thread = createdThreads.get(message.channelId);
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
- const channel = await messengerFetchJson(
392
- `${API}/channels/${encodeURIComponent(message.channelId)}`,
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
- isRecord(channel) &&
399
- typeof channel.type === "number" &&
400
- [10, 11, 12].includes(channel.type) &&
401
- typeof channel.parent_id === "string" &&
402
- channel.parent_id
390
+ !(error instanceof MessengerHttpError) ||
391
+ error.status !== 429 ||
392
+ attempt >= 3 ||
393
+ signal.aborted
403
394
  ) {
404
- await onMessage({
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 thread parent lookup failed");
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
- `${API}/channels/${encodeURIComponent(message.channelId)}`,
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
- `${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
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 = `${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
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(emoji)}/@me`,
505
- { method: "PUT", headers: headers() },
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 = `${API}/channels/${encodeURIComponent(channelId)}/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
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
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
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
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
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
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
- `${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
830
+ `${DISCORD_API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
672
831
  {
673
832
  method: "POST",
674
833
  headers: headers(),