@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/lib/protocol.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- /** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
2
1
  import { type ConversationRef } from '@zhin.js/im-contract';
3
2
  import type { Segment } from '@zhin.js/core/runtime';
4
3
  export interface DiscordActivity {
@@ -12,6 +11,9 @@ export interface DiscordEndpointConfig {
12
11
  readonly token: string;
13
12
  /** Default `gateway`. `interactions` uses httpHostToken POST + Ed25519 verify. */
14
13
  readonly connection?: 'gateway' | 'interactions';
14
+ /** Test instance only: ws://127.0.0.1:PORT/ fault proxy. */
15
+ readonly restApiProxy?: import('./rest-api-proxy.js').DiscordRestApiProxy;
16
+ readonly gatewayFaultProxyUrl?: string;
15
17
  readonly intents?: readonly number[];
16
18
  readonly enableSlashCommands?: boolean;
17
19
  readonly globalCommands?: boolean;
@@ -27,6 +29,9 @@ export interface ResolvedDiscordGatewayConfig {
27
29
  readonly connection: 'gateway';
28
30
  readonly id: string;
29
31
  readonly token: string;
32
+ /** Test instance only: ws://127.0.0.1:PORT/ fault proxy. */
33
+ readonly restApiProxy?: import('./rest-api-proxy.js').DiscordRestApiProxy;
34
+ readonly gatewayFaultProxyUrl?: string;
30
35
  readonly intents?: readonly number[];
31
36
  readonly enableSlashCommands: boolean;
32
37
  readonly globalCommands: boolean;
@@ -70,6 +75,7 @@ export interface DiscordInboundMessage {
70
75
  readonly mentionedBot?: boolean;
71
76
  }
72
77
  export interface DiscordButtonInbound {
78
+ readonly guildId?: string;
73
79
  readonly id: string;
74
80
  readonly customId: string;
75
81
  readonly channelId: string;
@@ -94,6 +100,10 @@ export interface DiscordOutboundActionRow {
94
100
  components: DiscordOutboundComponentButton[];
95
101
  }
96
102
  export interface DiscordOutboundBody {
103
+ readonly reply?: {
104
+ readonly messageReference: string;
105
+ readonly failIfNotExists: boolean;
106
+ };
97
107
  readonly content?: string;
98
108
  readonly embeds?: ReadonlyArray<Record<string, unknown>>;
99
109
  readonly files?: ReadonlyArray<{
package/lib/protocol.js CHANGED
@@ -1,9 +1,14 @@
1
+ import { validateDiscordGatewayFaultProxy } from './gateway-fault-proxy.js';
1
2
  /** Discord Gateway protocol helpers. Canonicalization is owned by Core. */
2
3
  import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
3
4
  import { isMediaRef } from '@zhin.js/im-contract';
4
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
5
6
  const logger = getLogger('discord');
6
7
  export function resolveDiscordConfig(config) {
8
+ if (config.restApiProxy && (!Number.isInteger(config.restApiProxy.port) || config.restApiProxy.port < 1 || config.restApiProxy.port > 65535))
9
+ throw new TypeError('Invalid Discord restApiProxy port');
10
+ if (config.restApiProxy && config.connection === 'interactions')
11
+ throw new TypeError('Discord restApiProxy requires gateway mode');
7
12
  const id = requiredEndpointField(config.id, 'id');
8
13
  const token = requiredEndpointField(config.token, 'token');
9
14
  const connection = config.connection ?? 'gateway';
@@ -25,6 +30,8 @@ export function resolveDiscordConfig(config) {
25
30
  connection: 'gateway',
26
31
  id,
27
32
  token,
33
+ ...(config.restApiProxy ? { restApiProxy: config.restApiProxy } : {}),
34
+ gatewayFaultProxyUrl: config.gatewayFaultProxyUrl ? validateDiscordGatewayFaultProxy(config.gatewayFaultProxyUrl) : undefined,
28
35
  intents: config.intents,
29
36
  enableSlashCommands: config.enableSlashCommands === true,
30
37
  globalCommands: config.globalCommands === true,
@@ -168,6 +175,7 @@ export function formatOutboundBody(payload) {
168
175
  const embeds = [];
169
176
  const files = [];
170
177
  let components;
178
+ let reply;
171
179
  for (const item of segments) {
172
180
  if (typeof item === 'string') {
173
181
  content += item;
@@ -209,8 +217,13 @@ export function formatOutboundBody(payload) {
209
217
  }));
210
218
  break;
211
219
  }
220
+ const extension = {
221
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif',
222
+ 'image/webp': 'webp', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg',
223
+ 'audio/wav': 'wav', 'video/mp4': 'mp4', 'video/webm': 'webm',
224
+ }[media.mime_type?.split(';')[0].trim().toLowerCase() ?? ''];
212
225
  const name = media.file_name
213
- ?? (typeof data.name === 'string' && data.name ? data.name : item.type);
226
+ ?? (typeof data.name === 'string' && data.name ? data.name : `${item.type}${extension ? `.${extension}` : ''}`);
214
227
  if (media.kind === 'url') {
215
228
  files.push({ name, url: media.value });
216
229
  }
@@ -231,6 +244,14 @@ export function formatOutboundBody(payload) {
231
244
  }
232
245
  break;
233
246
  }
247
+ case 'reply':
248
+ if (typeof data.message_id === 'string' && data.message_id) {
249
+ reply = { messageReference: data.message_id, failIfNotExists: true };
250
+ }
251
+ break;
252
+ case 'share':
253
+ embeds.push({ title: String(data.title ?? ''), url: String(data.url ?? ''), description: String(data.description ?? data.content ?? ''), ...(data.image ? { image: { url: String(data.image) } } : {}) });
254
+ break;
234
255
  case 'embed':
235
256
  embeds.push({ ...data });
236
257
  break;
@@ -259,6 +280,7 @@ export function formatOutboundBody(payload) {
259
280
  ...(embeds.length > 0 ? { embeds: embeds.slice(0, 10) } : {}),
260
281
  ...(files.length > 0 ? { files } : {}),
261
282
  ...(components ? { components } : {}),
283
+ ...(reply ? { reply } : {}),
262
284
  };
263
285
  }
264
286
  export function activityTypeCode(type) {
@@ -0,0 +1,8 @@
1
+ import { Agent } from 'undici';
2
+ export interface DiscordRestApiProxy {
3
+ readonly port: number;
4
+ }
5
+ /** JSON REST only, fixed Discord TLS identity and loopback TCP route. */
6
+ export declare function createDiscordRestApiAgent(proxy: DiscordRestApiProxy): Agent & {
7
+ destroyProxy(): Promise<void>;
8
+ };
@@ -0,0 +1,47 @@
1
+ import tls from 'node:tls';
2
+ import { Agent } from 'undici';
3
+ /** JSON REST only, fixed Discord TLS identity and loopback TCP route. */
4
+ export function createDiscordRestApiAgent(proxy) {
5
+ const pending = new Set();
6
+ let closed = false;
7
+ const agent = new Agent({ connect(options, callback) {
8
+ if (closed) {
9
+ queueMicrotask(() => callback(new Error('Discord REST proxy stopped'), null));
10
+ return;
11
+ }
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) => {
20
+ if (complete)
21
+ return;
22
+ complete = true;
23
+ pending.delete(cancel);
24
+ clearTimeout(timer);
25
+ socket.off('error', onError);
26
+ if (error) {
27
+ socket.destroy();
28
+ callback(error, null);
29
+ }
30
+ else
31
+ callback(null, socket);
32
+ };
33
+ const cancel = () => finish(new Error('Discord REST proxy stopped'));
34
+ pending.add(cancel);
35
+ const onError = (error) => finish(error);
36
+ const timer = setTimeout(() => finish(new Error('Discord REST proxy TLS connection timed out')), 10_000);
37
+ timer.unref();
38
+ socket.once('error', onError);
39
+ socket.once('secureConnect', () => finish());
40
+ } });
41
+ return Object.assign(agent, { async destroyProxy() {
42
+ closed = true;
43
+ for (const cancel of [...pending])
44
+ cancel();
45
+ await agent.destroy();
46
+ } });
47
+ }
@@ -1,18 +1,17 @@
1
- import { buildNotice, senderFromId } from '@zhin.js/core';
1
+ import { composeSideEventName, buildNotice, senderFromId } from '@zhin.js/core';
2
2
  import { formatCompact } from '@zhin.js/logger';
3
3
  export function receiveDiscordGuildMemberSideEvent(emit, configId, kind, event, logger) {
4
4
  if (!emit)
5
5
  return;
6
6
  void emit('notice.receive', buildNotice(event, {
7
- $id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
8
- $adapter: 'discord',
9
- $endpoint: configId,
10
- $type: 'notice',
11
- $scene_id: event.guildId,
12
- $scene_type: 'group',
13
- $sub_type: kind,
14
- $actor: senderFromId(event.userId, event.userName),
15
- $timestamp: Date.now(),
7
+ id: `discord:guild_member:${kind}:${event.guildId}:${event.userId}:${Date.now()}`,
8
+ clientAdapter: 'discord',
9
+ endpointId: configId,
10
+ type: 'notice',
11
+ // Guild membership does not identify a replyable channel.
12
+ name: composeSideEventName('notice', 'group', kind),
13
+ target: senderFromId(event.userId, event.userName),
14
+ timestamp: Date.now(),
16
15
  })).catch((err) => {
17
16
  logger.warn(formatCompact({
18
17
  op: 'discord_side_event_failed',
package/lib/webhook.d.ts CHANGED
@@ -3,11 +3,12 @@
3
3
  */
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
5
5
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
6
- import { type DiscordInboundMessage, type ResolvedDiscordInteractionsConfig } from './protocol.js';
6
+ import { type DiscordInboundMessage, type DiscordButtonInbound, type ResolvedDiscordInteractionsConfig } from './protocol.js';
7
7
  export interface DiscordInteractionsHandler {
8
8
  readonly config: ResolvedDiscordInteractionsConfig;
9
9
  readonly isOpen: boolean;
10
10
  admit(msg: DiscordInboundMessage): void;
11
+ admitButton(interaction: DiscordButtonInbound): void;
11
12
  admitPlatform(event: Record<string, unknown>): void;
12
13
  }
13
14
  export declare function registerDiscordInteractionRoutes(http: HttpHost, handler: DiscordInteractionsHandler): HttpRouteRegistration[];
package/lib/webhook.js CHANGED
@@ -37,6 +37,20 @@ export async function handleDiscordInteractionRequest(request, response, handler
37
37
  writeJson(response, 200, { type: INTERACTION_RESPONSE_PONG });
38
38
  return;
39
39
  }
40
+ if (interaction.type === 3) {
41
+ const data = interaction.data;
42
+ if (data?.component_type === 2 && typeof data.custom_id === 'string') {
43
+ if (!handler.isOpen) {
44
+ response.writeHead(503);
45
+ response.end('Unavailable');
46
+ return;
47
+ }
48
+ const message = interactionToInboundMessage(interaction);
49
+ 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?.id });
50
+ writeJson(response, 200, { type: 6 });
51
+ return;
52
+ }
53
+ }
40
54
  if (interaction.type === INTERACTION_TYPE_APPLICATION_COMMAND) {
41
55
  if (handler.isOpen) {
42
56
  handler.admit(interactionToInboundMessage(interaction));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-discord",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Zhin.js Discord adapter for Plugin Runtime (Gateway WebSocket)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,23 +33,26 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "discord.js": "^14.27.0",
36
- "@zhin.js/adapter": "1.1.15",
37
- "@zhin.js/agent-feature": "1.1.2",
38
- "@zhin.js/core": "1.1.38",
36
+ "@discordjs/ws": "1.2.3",
37
+ "@discordjs/collection": "2.1.1",
38
+ "undici": "6.28.0",
39
+ "@zhin.js/adapter": "1.1.16",
40
+ "@zhin.js/agent-feature": "1.1.3",
41
+ "@zhin.js/core": "1.1.40",
39
42
  "@zhin.js/feature-kit": "1.1.2",
40
- "@zhin.js/host-http": "1.1.2",
41
- "@zhin.js/im-contract": "1.1.2",
43
+ "@zhin.js/host-http": "1.1.3",
44
+ "@zhin.js/im-contract": "1.1.3",
42
45
  "@zhin.js/logger": "1.1.1",
43
- "@zhin.js/permission": "1.1.2",
44
- "@zhin.js/skill": "1.1.2",
45
- "@zhin.js/tool": "1.1.2"
46
+ "@zhin.js/permission": "1.1.3",
47
+ "@zhin.js/skill": "1.1.3",
48
+ "@zhin.js/tool": "1.1.3"
46
49
  },
47
50
  "peerDependencies": {
48
51
  "zod": "^4.0.0",
49
- "@zhin.js/adapter": "^1.1.15",
50
- "@zhin.js/command": "^1.1.2",
51
- "@zhin.js/core": "^1.1.38",
52
- "zhin.js": "^1.1.3"
52
+ "@zhin.js/adapter": "^1.1.16",
53
+ "@zhin.js/command": "^1.1.4",
54
+ "@zhin.js/core": "^1.1.40",
55
+ "zhin.js": "^1.1.5"
53
56
  },
54
57
  "peerDependenciesMeta": {
55
58
  "@zhin.js/command": {
@@ -67,8 +70,8 @@
67
70
  "typescript": "^6.0.3",
68
71
  "vitest": "^4.1.10",
69
72
  "zod": "^4.4.3",
70
- "@zhin.js/host-http": "1.1.2",
71
- "zhin.js": "1.1.3"
73
+ "@zhin.js/host-http": "1.1.3",
74
+ "zhin.js": "1.1.5"
72
75
  },
73
76
  "files": [
74
77
  "adapters",
package/schema.json CHANGED
@@ -185,6 +185,26 @@
185
185
  "id": {
186
186
  "type": "string",
187
187
  "description": "Discord bot name"
188
+ },
189
+ "gatewayFaultProxyUrl": {
190
+ "type": "string",
191
+ "pattern": "^(?:ws://127\\.0\\.0\\.1:[0-9]+/?|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$",
192
+ "description": "Isolated acceptance only: loopback Gateway fault proxy; REST unchanged."
193
+ },
194
+ "restApiProxy": {
195
+ "type": "object",
196
+ "additionalProperties": false,
197
+ "properties": {
198
+ "port": {
199
+ "type": "integer",
200
+ "minimum": 1,
201
+ "maximum": 65535
202
+ }
203
+ },
204
+ "required": [
205
+ "port"
206
+ ],
207
+ "description": "Gateway测试实例REST专用故障代理:discord.com:443固定loopback TCP,保留SNI/证书校验,独立于Gateway代理"
188
208
  }
189
209
  },
190
210
  "required": [
@@ -197,6 +217,11 @@
197
217
  "type": "string",
198
218
  "default": "",
199
219
  "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
220
+ },
221
+ "gatewayFaultProxyUrl": {
222
+ "type": "string",
223
+ "pattern": "^(?:ws://127\\.0\\.0\\.1:[0-9]+/?|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$",
224
+ "description": "Isolated acceptance only: loopback Gateway fault proxy; REST unchanged."
200
225
  }
201
226
  },
202
227
  "required": [
package/src/endpoint.ts CHANGED
@@ -1,16 +1,18 @@
1
- import { Endpoint } from 'zhin.js/adapter';
1
+ import { createDiscordRestApiAgent } from './rest-api-proxy.js';
2
2
  /**
3
3
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
4
4
  */
5
5
  import { ChannelType } from 'discord.js';
6
- import type {
7
- EndpointChannel,
8
- EndpointContentPort,
9
- EndpointContentResolveContext,
10
- EndpointControl,
11
- EndpointGroup,
12
- EndpointManagement,
13
- EndpointSendRequest,
6
+ import {
7
+ Endpoint, createEndpointLifecycle,
8
+ type EndpointChannel,
9
+ type EndpointContentPort,
10
+ type EndpointContentResolveContext,
11
+ type EndpointControl,
12
+ type EndpointGroup,
13
+ type EndpointManagement,
14
+ type EndpointSendRequest,
15
+ type EndpointTransportState,
14
16
  } from 'zhin.js/adapter';
15
17
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
16
18
  import {
@@ -69,7 +71,9 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
69
71
  readonly #fetch: typeof globalThis.fetch;
70
72
  #client: DiscordClientTransport | null = null;
71
73
  #open = false;
72
- #started = false;
74
+ readonly #lifecycle = createEndpointLifecycle({ name: 'discord', reconnect: false });
75
+ #cleanup?: () => Promise<void>;
76
+ #retiring?: Promise<void>;
73
77
  readonly management: EndpointManagement = createDiscordEndpointManagement(
74
78
  () => this.#requireClient(),
75
79
  );
@@ -104,21 +108,44 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
104
108
  return this.#requireClient();
105
109
  }
106
110
 
111
+ get transportState(): EndpointTransportState | undefined {
112
+ if (this.#lifecycle.state !== 'open') return this.#lifecycle.state;
113
+ if (!this.#client) return 'closed';
114
+ if (!this.#client.isReady) return this.#lifecycle.state;
115
+ return this.#client.isReady() ? 'open' : 'reconnecting';
116
+ }
117
+
107
118
  async start(): Promise<void> {
108
- if (this.#started) return;
109
- this.#started = true;
119
+ await this.#retiring;
120
+ await this.#lifecycle.start(async handle => {
121
+ let cleanup: (() => Promise<void>) | undefined; let cancelled = false;
110
122
  try {
111
123
  const intents = this.#options.config.intents?.length
112
124
  ? [...this.#options.config.intents]
113
125
  : DEFAULT_INTENTS;
114
- this.#client = this.#createClient(intents);
115
- await connectDiscordGatewayClient(this.#client, this.#options.config, {
126
+ const restAgent = this.#options.config.restApiProxy ? createDiscordRestApiAgent(this.#options.config.restApiProxy) : undefined;
127
+ let client: DiscordClientTransport;
128
+ try { client = this.#createClient(intents, this.#options.config.gatewayFaultProxyUrl, restAgent ? { agent: restAgent } : undefined); }
129
+ catch (error) { await restAgent?.destroyProxy(); throw error; }
130
+ this.#client = client;
131
+ const abort = new AbortController(); let stopping: Promise<void> | undefined;
132
+ cleanup = () => {
133
+ cancelled = true;
134
+ abort.abort();
135
+ if (this.#client === client) this.#client = null;
136
+ return stopping ??= (async () => { client.removeAllListeners(); try { await client.destroy(); } finally { await restAgent?.destroyProxy(); } })();
137
+ };
138
+ this.#cleanup = cleanup;
139
+ handle.onForceClose(() => { void cleanup?.().catch(() => {}); });
140
+ await connectDiscordGatewayClient(client, this.#options.config, {
116
141
  onPlatformEvent: (name, event) => {
142
+ if (this.#client !== client) return;
117
143
  void this.#emitPlatformEvent(name, event);
118
144
  },
119
- onMessage: (msg) => this.admit(msg),
120
- onButton: (interaction) => this.admitButton(interaction),
145
+ onMessage: (msg) => { if (this.#client === client) return this.admit(msg); },
146
+ onButton: (interaction) => { if (this.#client === client) return this.admitButton(interaction); },
121
147
  onGuildMemberAdd: (member) => {
148
+ if (this.#client !== client) return;
122
149
  receiveDiscordGuildMemberSideEvent(
123
150
  (name, payload) => this.emit(name, payload),
124
151
  this.#options.config.id,
@@ -128,6 +155,7 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
128
155
  );
129
156
  },
130
157
  onGuildMemberRemove: (member) => {
158
+ if (this.#client !== client) return;
131
159
  receiveDiscordGuildMemberSideEvent(
132
160
  (name, payload) => this.emit(name, payload),
133
161
  this.#options.config.id,
@@ -136,7 +164,8 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
136
164
  this.#logger,
137
165
  );
138
166
  },
139
- });
167
+ }, abort.signal);
168
+ if (abort.signal.aborted || this.#client !== client) return;
140
169
  this.#logger.info(formatCompact({
141
170
  op: 'connect',
142
171
  endpoint: this.#options.config.id,
@@ -144,10 +173,13 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
144
173
  user: this.#client.user?.tag,
145
174
  }));
146
175
  } catch (error) {
147
- await this.stop();
176
+ const stopped = cancelled;
177
+ try { await cleanup?.(); } catch { /* original connect failure wins */ }
178
+ if (stopped) return;
148
179
  this.#logger.error('Failed to connect Discord gateway:', error);
149
180
  throw error;
150
181
  }
182
+ });
151
183
  }
152
184
 
153
185
  open(): void {
@@ -160,16 +192,13 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
160
192
 
161
193
  async stop(): Promise<void> {
162
194
  this.#open = false;
163
- if (this.#client) {
164
- try {
165
- this.#client.removeAllListeners();
166
- await this.#client.destroy();
167
- } catch {
168
- /* ignore */
169
- }
170
- this.#client = null;
171
- }
172
- this.#started = false;
195
+ const cleanup = this.#cleanup;
196
+ this.#cleanup = undefined;
197
+ this.#lifecycle.stop();
198
+ const retiring = this.#retiring ?? Promise.resolve().then(async () => { try { await cleanup?.(); } catch { /* best-effort SDK shutdown */ } });
199
+ this.#retiring = retiring;
200
+ await retiring;
201
+ if (this.#retiring === retiring) this.#retiring = undefined;
173
202
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
174
203
  }
175
204
 
@@ -271,13 +300,25 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
271
300
  }
272
301
 
273
302
  async #sendBody(channelId: string, body: DiscordOutboundBody): Promise<string> {
274
- const channel = await this.#requireClient().channels.fetch(channelId);
275
- if (!channel || !channel.isTextBased() || !channel.send) {
276
- throw new Error(`Channel ${channelId} is not a text channel`);
303
+ let stage = 'fetch_channel';
304
+ try {
305
+ const channel = await this.#requireClient().channels.fetch(channelId);
306
+ if (!channel || !channel.isTextBased() || !channel.send) {
307
+ throw new Error(`Channel ${channelId} is not a text channel`);
308
+ }
309
+ stage = 'render';
310
+ const options = await toMessageCreateOptions(body);
311
+ stage = 'send';
312
+ const result = await channel.send(options);
313
+ return result.id;
314
+ } catch (error) {
315
+ const details = error as { code?: unknown; status?: unknown };
316
+ this.#logger.warn(formatCompact({ op: 'discord_send_failed', stage,
317
+ code: typeof details?.code === 'number' ? details.code : 'unknown',
318
+ status: typeof details?.status === 'number' ? details.status : undefined,
319
+ }));
320
+ throw error;
277
321
  }
278
- const options = await toMessageCreateOptions(body);
279
- const result = await channel.send(options);
280
- return result.id;
281
322
  }
282
323
 
283
324
  #requireClient(): DiscordClientTransport {
@@ -430,6 +471,31 @@ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
430
471
  });
431
472
  }
432
473
 
474
+ /** Test / internal: admit a button interaction when open. */
475
+ admitButton(interaction: DiscordButtonInbound): void {
476
+ if (!this.#open) return;
477
+ const conversation = discordInboundConversation(String(this.#options.id), interaction);
478
+ void this.emit('message.receive', {
479
+ conversation,
480
+ message: { conversation, id: interaction.id },
481
+ content: formatButtonContent(interaction),
482
+ segments: formatButtonSegments(interaction),
483
+ sender: { id: interaction.userId, name: interaction.userName },
484
+ endpointId: this.#options.config.id,
485
+ metadata: Object.freeze({
486
+ eventType: 'button',
487
+ payload: interaction.customId,
488
+ sourceMessageId: interaction.sourceMessageId,
489
+ }),
490
+ }).catch((err) => {
491
+ this.#logger.warn(formatCompact({
492
+ op: 'discord_gateway_receive_failed',
493
+ target: `${conversation.kind}:${conversation.id}`,
494
+ error: err instanceof Error ? err.message : String(err),
495
+ }));
496
+ });
497
+ }
498
+
433
499
  admitPlatform(event: Record<string, unknown>): void {
434
500
  if (!this.#open) return;
435
501
  const type = typeof event.type === 'number' ? `interaction.${event.type}` : 'interaction';
@@ -0,0 +1,82 @@
1
+ import { getLogger, formatCompact } from '@zhin.js/logger';
2
+ import { Collection } from '@discordjs/collection';
3
+ import {
4
+ SimpleContextFetchingStrategy, WebSocketShard, WebSocketShardEvents,
5
+ managerToFetchingStrategyOptions,
6
+ type IShardingStrategy, type SessionInfo, type WebSocketManager,
7
+ type WebSocketShardDestroyOptions,
8
+ } from '@discordjs/ws';
9
+
10
+ export function validateDiscordGatewayFaultProxy(value: string): string {
11
+ const url = new URL(value);
12
+ if (url.protocol !== 'ws:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Discord gateway fault proxy must be ws://127.0.0.1:PORT/');
13
+ return url.origin;
14
+ }
15
+
16
+ /** Public gateway host only; credentials and arbitrary proxy targets are rejected. */
17
+ export function discordGatewayProxyURL(proxy: string, upstream: string): string {
18
+ const origin = validateDiscordGatewayFaultProxy(proxy); const target = new URL(upstream);
19
+ if (target.protocol !== 'wss:' || !/^gateway(?:-[a-z0-9-]+)?\.discord\.gg$/.test(target.hostname)
20
+ || (target.port && target.port !== '443') || target.username || target.password || target.pathname !== '/' || target.hash
21
+ || [...target.searchParams].some(([key, value]) => !(key === 'v' && /^\d{1,2}$/.test(value)) && !(key === 'encoding' && value === 'json') && !(key === 'compress' && value === 'zlib-stream'))) throw new Error('Invalid Discord gateway host');
22
+ return `${origin}/__discord_gateway/${target.hostname}`;
23
+ }
24
+
25
+ /** Undo only this proxy's exact internal route when SDK updates sequence. */
26
+ export function canonicalDiscordGatewayURL(proxy: string, value: string): string {
27
+ const origin = validateDiscordGatewayFaultProxy(proxy); const url = new URL(value);
28
+ if (url.origin !== origin) { discordGatewayProxyURL(proxy, value); return value; }
29
+ const host = url.pathname.match(/^\/__discord_gateway\/(gateway(?:-[a-z0-9-]+)?\.discord\.gg)$/)?.[1];
30
+ if (!host || url.username || url.password || url.search || url.hash) throw new Error('Invalid internal Discord gateway route');
31
+ return `wss://${host}/`;
32
+ }
33
+
34
+ /** Keeps SDK heartbeat/Identify/Resume. Only connection URLs use the test proxy. */
35
+ export class DiscordFaultProxyStrategy implements IShardingStrategy {
36
+ #failureReject!: (error: Error) => void;
37
+ readonly #failure = new Promise<never>((_, reject) => { this.#failureReject = reject; });
38
+ readonly #diagnosed = new Set<string>();
39
+ readonly #shards = new Collection<number, WebSocketShard>();
40
+ constructor(readonly manager: WebSocketManager, readonly proxy: string) { validateDiscordGatewayFaultProxy(proxy); void this.#failure.catch(() => {}); }
41
+ async spawn(shardIds: number[]): Promise<void> {
42
+ const options = await managerToFetchingStrategyOptions(this.manager);
43
+ const mapped = { ...options, gatewayInformation: { ...options.gatewayInformation, url: discordGatewayProxyURL(this.proxy, options.gatewayInformation.url) } };
44
+ for (const shardId of shardIds) {
45
+ const context = new SimpleContextFetchingStrategy(this.manager, mapped);
46
+ const retrieve = context.retrieveSessionInfo.bind(context); const update = context.updateSessionInfo.bind(context);
47
+ const invalid = () => {
48
+ const error = new Error('Invalid Discord gateway session'); this.#failureReject(error);
49
+ void Promise.resolve(this.manager.emit(WebSocketShardEvents.Error, { error, shardId })).catch(() => {});
50
+ void shard.destroy({ code: 1000, reason: 'Invalid gateway session' }).catch(() => {});
51
+ };
52
+ context.retrieveSessionInfo = async (id: number): Promise<SessionInfo | null> => {
53
+ try {
54
+ const session = await retrieve(id);
55
+ if (!session) return null;
56
+ const canonical = canonicalDiscordGatewayURL(this.proxy, session.resumeURL);
57
+ const target = new URL(canonical);
58
+ if (!this.#diagnosed.has(target.hostname) && this.#diagnosed.size < 8) {
59
+ this.#diagnosed.add(target.hostname);
60
+ getLogger('discord').debug(formatCompact({ op: 'discord_gateway_resume_target', hostname: target.hostname, pathIsRoot: target.pathname === '/', queryKeys: [...new Set(target.searchParams.keys())].join(',') }));
61
+ }
62
+ return { ...session, resumeURL: discordGatewayProxyURL(this.proxy, canonical) };
63
+ } catch { invalid(); return null; }
64
+ };
65
+ context.updateSessionInfo = async (id, session) => {
66
+ try { return await update(id, session ? { ...session, resumeURL: canonicalDiscordGatewayURL(this.proxy, session.resumeURL) } : null); }
67
+ catch { invalid(); }
68
+ };
69
+ const shard = new WebSocketShard(context, shardId);
70
+ for (const event of Object.values(WebSocketShardEvents)) shard.on(event, (payload?: object) => this.manager.emit(event, { ...payload, shardId } as never));
71
+ this.#shards.set(shardId, shard);
72
+ }
73
+ }
74
+ async connect(): Promise<void> { await Promise.race([Promise.all([...this.#shards.values()].map(shard => shard.connect())), this.#failure]); }
75
+ async destroy(options?: Omit<WebSocketShardDestroyOptions, 'recover'>): Promise<void> {
76
+ await Promise.all([...this.#shards.values()].map(shard => shard.destroy(options))); this.#shards.clear();
77
+ }
78
+ async send(shardId: number, payload: Parameters<WebSocketShard['send']>[0]): Promise<void> {
79
+ const shard = this.#shards.get(shardId); if (!shard) throw new RangeError('Discord shard not found'); await shard.send(payload);
80
+ }
81
+ async fetchStatus() { return this.#shards.mapValues(shard => shard.status); }
82
+ }