@zhin.js/adapter-discord 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gateway.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import { DiscordFaultProxyStrategy } from './gateway-fault-proxy.js';
1
2
  import { createReadStream, promises as fs } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import {
4
5
  Client,
6
+ Partials,
5
7
  GatewayIntentBits,
6
8
  EmbedBuilder,
7
9
  AttachmentBuilder,
@@ -9,6 +11,7 @@ import {
9
11
  ButtonBuilder,
10
12
  ButtonStyle,
11
13
  REST,
14
+ type RESTOptions,
12
15
  Routes,
13
16
  PermissionFlagsBits,
14
17
  type MessageCreateOptions,
@@ -37,6 +40,7 @@ export const DEFAULT_INTENTS = [
37
40
 
38
41
  /** Minimal client surface used by the endpoint (real discord.js or test mock). */
39
42
  export interface DiscordClientTransport {
43
+ isReady?(): boolean;
40
44
  login(token: string): Promise<string>;
41
45
  destroy(): Promise<void>;
42
46
  on(event: string, listener: (...args: unknown[]) => void): void;
@@ -109,10 +113,10 @@ export interface DiscordClientTransport {
109
113
  };
110
114
  }
111
115
 
112
- export type CreateDiscordClient = (intents: readonly number[]) => DiscordClientTransport;
116
+ export type CreateDiscordClient = (intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions?: Partial<RESTOptions>) => DiscordClientTransport;
113
117
 
114
- export function defaultCreateClient(intents: readonly number[]): DiscordClientTransport {
115
- return new Client({ intents: [...intents] }) as unknown as DiscordClientTransport;
118
+ export function defaultCreateClient(intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions: Partial<RESTOptions> = {}): DiscordClientTransport {
119
+ return new Client({ rest: { ...restOptions, retries: 0, rejectOnRateLimit: () => true }, intents: [...intents], partials: [Partials.Channel], ...(gatewayFaultProxyUrl ? { ws: { buildStrategy: manager => new DiscordFaultProxyStrategy(manager, gatewayFaultProxyUrl) } } : {}) }) as unknown as DiscordClientTransport;
116
120
  }
117
121
 
118
122
  export function resolveSenderRole(msg: DiscordInboundMessage): string | undefined {
@@ -176,6 +180,7 @@ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | n
176
180
 
177
181
  export async function toMessageCreateOptions(body: DiscordOutboundBody): Promise<MessageCreateOptions> {
178
182
  const options: MessageCreateOptions = {};
183
+ if (body.reply) options.reply = { ...body.reply };
179
184
  if (body.content) options.content = body.content;
180
185
  if (body.embeds?.length) {
181
186
  options.embeds = body.embeds.map((data) => {
@@ -296,9 +301,14 @@ export async function connectDiscordGatewayClient(
296
301
  client: DiscordClientTransport,
297
302
  config: ResolvedDiscordGatewayConfig,
298
303
  handlers: DiscordGatewayConnectHandlers,
304
+ signal?: AbortSignal,
299
305
  ): Promise<void> {
300
306
  return new Promise((resolve, reject) => {
301
307
  let settled = false;
308
+ const onAbort = () => { if (!settled) { settled = true; reject(new Error('Discord startup stopped')); } };
309
+ if (signal?.aborted) { onAbort(); return; }
310
+ signal?.addEventListener('abort', onAbort, { once: true });
311
+ const releaseAbort = () => signal?.removeEventListener('abort', onAbort);
302
312
 
303
313
  client.on('messageCreate', (raw) => {
304
314
  handlers.onPlatformEvent('messageCreate', raw);
@@ -319,17 +329,31 @@ export async function connectDiscordGatewayClient(
319
329
  id: string;
320
330
  customId: string;
321
331
  channel?: { id: string; type: number } | null;
332
+ channelId?: string;
333
+ guildId?: string | null;
322
334
  user: { id: string; username?: string; displayName?: string };
323
335
  message?: { id: string };
324
336
  };
325
- if (!interaction.isButton?.()) return;
326
- void interaction.deferUpdate?.().catch(() => { /* already ack */ });
327
- if (!interaction.channel) return;
337
+ const isButton = interaction.isButton?.() === true;
338
+ logger.info(formatCompact({ op: 'discord_gateway_interaction_received', button: isButton, cachedChannel: !!interaction.channel, hasChannelId: !!interaction.channelId }));
339
+ if (!isButton) return;
340
+ void interaction.deferUpdate?.().then(() => {
341
+ logger.info(formatCompact({ op: 'discord_gateway_button_ack', status: 'confirmed' }));
342
+ }).catch(() => {
343
+ // SDK errors may contain token-bearing callback URLs; log no raw error.
344
+ logger.warn(formatCompact({ op: 'discord_gateway_button_ack', status: 'failed' }));
345
+ });
346
+ const channelId = interaction.channelId ?? interaction.channel?.id;
347
+ if (!channelId) {
348
+ logger.warn(formatCompact({ op: 'discord_gateway_button_dropped', reason: 'missing_channel_id' }));
349
+ return;
350
+ }
328
351
  handlers.onButton({
329
352
  id: interaction.id,
330
353
  customId: interaction.customId,
331
- channelId: interaction.channel.id,
332
- channelKind: resolveChannelKind(interaction.channel.type),
354
+ channelId,
355
+ channelKind: interaction.channel ? resolveChannelKind(interaction.channel.type) : interaction.guildId ? 'channel' : 'private',
356
+ ...(interaction.guildId ? { guildId: interaction.guildId } : {}),
333
357
  userId: interaction.user.id,
334
358
  userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
335
359
  sourceMessageId: interaction.message?.id,
@@ -369,6 +393,7 @@ export async function connectDiscordGatewayClient(
369
393
  });
370
394
 
371
395
  client.once('clientReady', () => {
396
+ if (signal?.aborted) return;
372
397
  handlers.onPlatformEvent('clientReady', client.user);
373
398
  void (async () => {
374
399
  try {
@@ -384,11 +409,13 @@ export async function connectDiscordGatewayClient(
384
409
  }
385
410
  if (!settled) {
386
411
  settled = true;
412
+ releaseAbort();
387
413
  resolve();
388
414
  }
389
415
  } catch (error) {
390
416
  if (!settled) {
391
417
  settled = true;
418
+ releaseAbort();
392
419
  reject(error);
393
420
  }
394
421
  }
@@ -399,6 +426,7 @@ export async function connectDiscordGatewayClient(
399
426
  logger.error('Discord client error:', error);
400
427
  if (!settled) {
401
428
  settled = true;
429
+ releaseAbort();
402
430
  reject(error instanceof Error ? error : new Error(String(error)));
403
431
  }
404
432
  });
@@ -407,9 +435,12 @@ export async function connectDiscordGatewayClient(
407
435
  logger.warn('Discord client warning:', info);
408
436
  });
409
437
 
410
- client.login(config.token).catch((error) => {
438
+ client.login(config.token).then(() => {
439
+ if (signal?.aborted) void Promise.resolve(client.destroy()).catch(() => {});
440
+ }).catch((error) => {
411
441
  if (!settled) {
412
442
  settled = true;
443
+ releaseAbort();
413
444
  const raw = error instanceof Error ? error.message : String(error);
414
445
  const tokenShapeHint = config.token.split('.').length === 3
415
446
  ? ''
package/src/protocol.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
1
2
  /** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
2
3
 
3
4
  import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
@@ -19,6 +20,9 @@ export interface DiscordEndpointConfig {
19
20
  readonly token: string;
20
21
  /** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
21
22
  readonly connection?: 'gateway' | 'interactions';
23
+ /** Test instance only: ws://127.0.0.1:PORT/ fault proxy. */
24
+ readonly restApiProxy?: import('./rest-api-proxy.js').DiscordRestApiProxy;
25
+ readonly gatewayFaultProxyUrl?: string;
22
26
  readonly intents?: readonly number[];
23
27
  readonly enableSlashCommands?: boolean;
24
28
  readonly globalCommands?: boolean;
@@ -35,6 +39,9 @@ export interface ResolvedDiscordGatewayConfig {
35
39
  readonly connection: 'gateway';
36
40
  readonly id: string;
37
41
  readonly token: string;
42
+ /** Test instance only: ws://127.0.0.1:PORT/ fault proxy. */
43
+ readonly restApiProxy?: import('./rest-api-proxy.js').DiscordRestApiProxy;
44
+ readonly gatewayFaultProxyUrl?: string;
38
45
  readonly intents?: readonly number[];
39
46
  readonly enableSlashCommands: boolean;
40
47
  readonly globalCommands: boolean;
@@ -85,6 +92,7 @@ export interface DiscordInboundMessage {
85
92
  }
86
93
 
87
94
  export interface DiscordButtonInbound {
95
+ readonly guildId?: string;
88
96
  readonly id: string;
89
97
  readonly customId: string;
90
98
  readonly channelId: string;
@@ -113,6 +121,7 @@ export interface DiscordOutboundActionRow {
113
121
  }
114
122
 
115
123
  export interface DiscordOutboundBody {
124
+ readonly reply?: { readonly messageReference: string; readonly failIfNotExists: boolean };
116
125
  readonly content?: string;
117
126
  readonly embeds?: ReadonlyArray<Record<string, unknown>>;
118
127
  readonly files?: ReadonlyArray<{
@@ -125,6 +134,8 @@ export interface DiscordOutboundBody {
125
134
  }
126
135
 
127
136
  export function resolveDiscordConfig(config: DiscordEndpointConfig): ResolvedDiscordConfig {
137
+ if (config.restApiProxy && (!Number.isInteger(config.restApiProxy.port) || config.restApiProxy.port < 1 || config.restApiProxy.port > 65535)) throw new TypeError('Invalid Discord restApiProxy port');
138
+ if (config.restApiProxy && config.connection === 'interactions') throw new TypeError('Discord restApiProxy requires gateway mode');
128
139
  const id = requiredEndpointField(config.id, 'id');
129
140
  const token = requiredEndpointField(config.token, 'token');
130
141
  const connection = config.connection ?? 'gateway';
@@ -148,6 +159,8 @@ export function resolveDiscordConfig(config: DiscordEndpointConfig): ResolvedDis
148
159
  connection: 'gateway',
149
160
  id,
150
161
  token,
162
+ ...(config.restApiProxy ? { restApiProxy: config.restApiProxy } : {}),
163
+ gatewayFaultProxyUrl: config.gatewayFaultProxyUrl ? validateDiscordGatewayFaultProxy(config.gatewayFaultProxyUrl) : undefined,
151
164
  intents: config.intents,
152
165
  enableSlashCommands: config.enableSlashCommands === true,
153
166
  globalCommands: config.globalCommands === true,
@@ -306,6 +319,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
306
319
  const embeds: Record<string, unknown>[] = [];
307
320
  const files: Array<{ name: string; url?: string; file?: string; base64?: string }> = [];
308
321
  let components: DiscordOutboundBody['components'];
322
+ let reply: DiscordOutboundBody['reply'];
309
323
 
310
324
  for (const item of segments) {
311
325
  if (typeof item === 'string') {
@@ -348,8 +362,13 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
348
362
  }));
349
363
  break;
350
364
  }
365
+ const extension = ({
366
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
367
+ 'image/webp': 'webp', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg',
368
+ 'audio/wav': 'wav', 'video/mp4': 'mp4', 'video/webm': 'webm',
369
+ } as Record<string, string>)[media.mime_type?.split(';')[0].trim().toLowerCase() ?? ''];
351
370
  const name = media.file_name
352
- ?? (typeof data.name === 'string' && data.name ? data.name : item.type);
371
+ ?? (typeof data.name === 'string' && data.name ? data.name : `${item.type}${extension ? `.${extension}` : ''}`);
353
372
  if (media.kind === 'url') {
354
373
  files.push({ name, url: media.value });
355
374
  } else if (media.kind === 'base64') {
@@ -367,6 +386,14 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
367
386
  }
368
387
  break;
369
388
  }
389
+ case 'reply':
390
+ if (typeof data.message_id === 'string' && data.message_id) {
391
+ reply = { messageReference: data.message_id, failIfNotExists: true };
392
+ }
393
+ break;
394
+ case 'share':
395
+ embeds.push({ title: String(data.title ?? ''), url: String(data.url ?? ''), description: String(data.description ?? data.content ?? ''), ...(data.image ? { image: { url: String(data.image) } } : {}) });
396
+ break;
370
397
  case 'embed':
371
398
  embeds.push({ ...data });
372
399
  break;
@@ -400,6 +427,7 @@ export function formatOutboundBody(payload: unknown): DiscordOutboundBody {
400
427
  ...(embeds.length > 0 ? { embeds: embeds.slice(0, 10) } : {}),
401
428
  ...(files.length > 0 ? { files } : {}),
402
429
  ...(components ? { components } : {}),
430
+ ...(reply ? { reply } : {}),
403
431
  };
404
432
  }
405
433
 
@@ -0,0 +1,37 @@
1
+ import tls from 'node:tls';
2
+ import { Agent } from 'undici';
3
+
4
+ export interface DiscordRestApiProxy { readonly port: number }
5
+
6
+ /** JSON REST only, fixed Discord TLS identity and loopback TCP route. */
7
+ export function createDiscordRestApiAgent(proxy: DiscordRestApiProxy): Agent & { destroyProxy(): Promise<void> } {
8
+ const pending = new Set<() => void>();
9
+ let closed = false;
10
+ const agent = new Agent({ connect(options, callback) {
11
+ if (closed) { queueMicrotask(() => callback(new Error('Discord REST proxy stopped'), null)); return; }
12
+ if (options.hostname !== 'discord.com' || options.protocol !== 'https:' || Number(options.port || 443) !== 443) {
13
+ queueMicrotask(() => callback(new Error('Discord REST proxy rejected destination identity'), null));
14
+ return;
15
+ }
16
+ const socket = tls.connect({ host: '127.0.0.1', port: proxy.port,
17
+ servername: 'discord.com', rejectUnauthorized: true, ALPNProtocols: ['http/1.1'] });
18
+ let complete = false;
19
+ const finish = (error?: Error) => {
20
+ if (complete) return; complete = true;
21
+ pending.delete(cancel);
22
+ clearTimeout(timer); socket.off('error', onError);
23
+ if (error) { socket.destroy(); callback(error, null); }
24
+ else callback(null, socket);
25
+ };
26
+ const cancel = () => finish(new Error('Discord REST proxy stopped'));
27
+ pending.add(cancel);
28
+ const onError = (error: Error) => finish(error);
29
+ const timer = setTimeout(() => finish(new Error('Discord REST proxy TLS connection timed out')), 10_000);
30
+ timer.unref(); socket.once('error', onError); socket.once('secureConnect', () => finish());
31
+ } });
32
+ return Object.assign(agent, { async destroyProxy() {
33
+ closed = true;
34
+ for (const cancel of [...pending]) cancel();
35
+ await agent.destroy();
36
+ } });
37
+ }
@@ -1,4 +1,4 @@
1
- import { buildNotice, senderFromId } from '@zhin.js/core';
1
+ import { composeSideEventName, buildNotice, senderFromId } from '@zhin.js/core';
2
2
  import type { EndpointEventEmitter } from 'zhin.js/adapter';
3
3
  import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
4
4
 
@@ -17,15 +17,14 @@ export function receiveDiscordGuildMemberSideEvent(
17
17
  ): void {
18
18
  if (!emit) return;
19
19
  void emit('notice.receive', buildNotice(event, {
20
- $id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
21
- $adapter: 'discord' as never,
22
- $endpoint: configId,
23
- $type: 'notice',
24
- $scene_id: event.guildId,
25
- $scene_type: 'group',
26
- $sub_type: kind,
27
- $actor: senderFromId(event.userId, event.userName),
28
- $timestamp: Date.now(),
20
+ id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
21
+ clientAdapter: 'discord',
22
+ endpointId: configId,
23
+ type: 'notice',
24
+ // Guild membership does not identify a replyable channel.
25
+ name: composeSideEventName('notice', 'group', kind),
26
+ target: senderFromId(event.userId, event.userName),
27
+ timestamp: Date.now(),
29
28
  })).catch((err) => {
30
29
  logger.warn(formatCompact({
31
30
  op: 'discord_side_event_failed',
package/src/webhook.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  interactionToInboundMessage,
9
9
  verifyDiscordInteractionSignature,
10
10
  type DiscordInboundMessage,
11
+ type DiscordButtonInbound,
11
12
  type ResolvedDiscordInteractionsConfig,
12
13
  } from './protocol.js';
13
14
 
@@ -24,6 +25,7 @@ export interface DiscordInteractionsHandler {
24
25
  readonly config: ResolvedDiscordInteractionsConfig;
25
26
  readonly isOpen: boolean;
26
27
  admit(msg: DiscordInboundMessage): void;
28
+ admitButton(interaction: DiscordButtonInbound): void;
27
29
  admitPlatform(event: Record<string, unknown>): void;
28
30
  }
29
31
 
@@ -69,6 +71,16 @@ export async function handleDiscordInteractionRequest(
69
71
  writeJson(response, 200, { type: INTERACTION_RESPONSE_PONG });
70
72
  return;
71
73
  }
74
+ if (interaction.type === 3) {
75
+ const data = interaction.data as { component_type?: number; custom_id?: string } | undefined;
76
+ if (data?.component_type === 2 && typeof data.custom_id === 'string') {
77
+ if (!handler.isOpen) { response.writeHead(503); response.end('Unavailable'); return; }
78
+ const message = interactionToInboundMessage(interaction);
79
+ handler.admitButton({ id: message.id, customId: data.custom_id, channelId: message.channelId, channelKind: message.channelKind, userId: message.authorId, userName: message.authorName, ...(message.guildId ? { guildId: message.guildId } : {}), sourceMessageId: (interaction.message as { id?: string } | undefined)?.id });
80
+ writeJson(response, 200, { type: 6 });
81
+ return;
82
+ }
83
+ }
72
84
  if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
73
85
  if (handler.isOpen) {
74
86
  handler.admit(interactionToInboundMessage(interaction));