@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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @zhin.js/adapter-discord
2
2
 
3
+ ## 1.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - b9b3953: Use the canonical media MIME type to assign a filename extension when an outbound attachment has no explicit name, allowing Discord to display image previews instead of unknown file attachments.
8
+
9
+ Preserve canonical reply message references in Gateway sends so quote replies reference the original message rather than silently sending plain text.
10
+
11
+ - b9b3953: Project Discord Gateway readiness from the connected client's live readiness state so runtime health no longer reports an observed ready connection as offline. Loss of readiness and stop remain offline.
12
+
13
+ Enable partial DM channels so the SDK admits messages from private channels that have not yet been cached.
14
+
15
+ - Updated dependencies [b9b3953]
16
+ - Updated dependencies [b9b3953]
17
+ - Updated dependencies [b9b3953]
18
+ - Updated dependencies [b9b3953]
19
+ - Updated dependencies [b9b3953]
20
+ - Updated dependencies [b9b3953]
21
+ - Updated dependencies [b9b3953]
22
+ - Updated dependencies [b9b3953]
23
+ - Updated dependencies [b9b3953]
24
+ - Updated dependencies [b9b3953]
25
+ - Updated dependencies [b9b3953]
26
+ - @zhin.js/adapter@1.1.16
27
+ - @zhin.js/im-contract@1.1.3
28
+ - @zhin.js/core@1.1.40
29
+ - @zhin.js/host-http@1.1.3
30
+ - @zhin.js/tool@1.1.3
31
+ - @zhin.js/skill@1.1.3
32
+ - @zhin.js/agent-feature@1.1.3
33
+
34
+ ## 1.1.4
35
+
36
+ ### Patch Changes
37
+
38
+ - 43eda99: Unify Notice and Request payloads with their public types and runtime exports. Use camelCase fields, preserve native data under metadata, and bind canonical endpoint identity and request actions to the held generation. Remove legacy data aliases and from factories.
39
+
40
+ Keep login and lifecycle signals as independent SystemEvent payloads on system.receive, without chat conversation fields or user interaction. Update built-in adapter projections and authoring guidance.
41
+
42
+ - Updated dependencies [43eda99]
43
+ - Updated dependencies [0de4836]
44
+ - @zhin.js/core@1.1.39
45
+ - @zhin.js/permission@1.1.3
46
+ - @zhin.js/adapter@1.1.15
47
+
3
48
  ## 1.1.3
4
49
 
5
50
  ### Patch Changes
package/README.md CHANGED
@@ -97,3 +97,30 @@ plugins:
97
97
  ## 许可证
98
98
 
99
99
  MIT License
100
+
101
+
102
+ 受控 Gateway 故障验收可为单个测试 endpoint 设置 `gatewayFaultProxyUrl: ws://127.0.0.1:18090/`。
103
+ 只接受此格式的 loopback origin;REST API 仍走 SDK 默认路径。入口通过正式 `ws.buildStrategy`
104
+ 使用已验证的 `@discordjs/ws` 1.2.3,SDK 继续负责心跳、Identify 和 Resume,初连及平台返回的
105
+ 区域 `resume_gateway_url` 均转到本地代理。Endpoint 的启动、停止和失败复位统一使用
106
+ `createEndpointLifecycle`,SDK 断线恢复状态由 `isReady()` 投影为 `reconnecting`。
107
+
108
+ 代理启动、切断与恢复见仓库 `scripts/platform-acceptance/FAULT-PROXY.md`。
109
+ 本地 SDK 测试不能替代真实 Discord 消息及断线恢复验收。
110
+
111
+ ### 出站失败与重试边界
112
+
113
+ Gateway 客户端的 REST 设置 `retries: 0` 并以 `rejectOnRateLimit` 拒绝后台 429 排队,二者分别覆盖网络/5xx 重试与限流重发。真实 SDK HTTP fixture 验证连接丢失、500、429、403 各只有一个 POST。已有 Gateway 代理不控制 Discord REST,不能据此宣称出站 API 故障验收通过。API 专用入口需通过正式 REST agent/dispatcher 保留 discord.com HTTPS 身份和证书校验,仅把测试实例 TCP 路由固定到 loopback。验收项目已提供独立 `restApiProxy` / `DISCORD_REST_API_PROXY_PORT`。SDK合同见 [RESTOptions](https://discord.js.org/docs/packages/rest/main/RESTOptions:Interface)。
114
+
115
+ ### REST 专用 TLS 故障验收
116
+
117
+ Gateway 实例可选 `restApiProxy: { port: 18570 }`,默认关闭。正式 RESTOptions.agent 使用 Undici Dispatcher,只允许真实 `https://discord.com:443`,保留原API URL、SNI和证书链/域名校验,TCP固定到 `127.0.0.1:18570`。错误目的地拒绝且不回退直连。与 `gatewayFaultProxyUrl` 分开;不要同时cut两条链路。SDK REST 的 Gateway discovery 等 API 也经过此代理,启动时必须先forwarding。正式 WebSocket连接不经过此REST Dispatcher;Gateway正常运行中API断开不能被记为入站WSS已重连。
118
+
119
+ ```bash
120
+ node scripts/platform-acceptance/tcp-fault-proxy.mjs --upstream-host discord.com --upstream-port 443 --port 18570 --control-port 18571
121
+ DISCORD_REST_API_PROXY_PORT=18570 pnpm --filter platform-acceptance-bot dev:discord
122
+ ```
123
+
124
+ forwarding 下以新sample确认真实消息基线;control `POST /cut` 后另发新sample,出站结果须失败/unknown且无自动POST重发;`POST /recover` 后先确认旧sample未补发,再新sample确认恢复。检查平台可见消息与报告回执,不重发未知sample。此模式仅Gateway REST、JSON消息能力;Interactions Endpoint 与其他CDN目的地不适用,配置Interactions会明确拒绝。本地真实SDK TLS fixture覆盖服务收到POST后切断、可信CA但SAN不匹配拒绝、默认直连与手动恢复,不能当成实机结果。停止/连接失败会销毁专用Dispatcher,并取消尚未完成TLS握手。
125
+
126
+ 公开扩展点参考:[Discord RESTOptions.agent](https://discord.js.org/docs/packages/rest/main/RESTOptions:Interface)、[Undici 6.28 Agent](https://github.com/nodejs/undici/blob/v6.28.0/docs/docs/api/Agent.md)。
package/lib/endpoint.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
- import type { EndpointContentPort, EndpointControl, EndpointManagement, EndpointSendRequest } from 'zhin.js/adapter';
1
+ import { Endpoint, type EndpointContentPort, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
3
2
  import type { HttpHost } from '@zhin.js/host-http';
4
3
  import { type MessageRef } from '@zhin.js/im-contract';
5
4
  import type { CapabilityId } from 'zhin.js';
@@ -20,6 +19,7 @@ export declare class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransp
20
19
  constructor(options: DiscordEndpointOptions);
21
20
  /** The actual discord.js-compatible client used by this connection. */
22
21
  get client(): DiscordClientTransport;
22
+ get transportState(): EndpointTransportState | undefined;
23
23
  start(): Promise<void>;
24
24
  open(): void;
25
25
  close(): void;
@@ -63,6 +63,8 @@ export declare class DiscordInteractionsEndpoint extends Endpoint<DiscordRestCli
63
63
  send({ conversation, payload }: EndpointSendRequest): Promise<string>;
64
64
  recallMessage(message: MessageRef): Promise<void>;
65
65
  admit(msg: DiscordInboundMessage): void;
66
+ /** Test / internal: admit a button interaction when open. */
67
+ admitButton(interaction: DiscordButtonInbound): void;
66
68
  admitPlatform(event: Record<string, unknown>): void;
67
69
  }
68
70
  /**
package/lib/endpoint.js CHANGED
@@ -1,8 +1,9 @@
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 { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
6
7
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
7
8
  import { connectDiscordGatewayClient, defaultCreateClient, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
8
9
  import { discordInboundConversation, formatButtonContent, formatButtonSegments, formatInboundContent, formatInboundSegments, formatOutboundBody, senderDisplayName, } from './protocol.js';
@@ -18,7 +19,9 @@ export class DiscordGatewayEndpoint extends Endpoint {
18
19
  #fetch;
19
20
  #client = null;
20
21
  #open = false;
21
- #started = false;
22
+ #lifecycle = createEndpointLifecycle({ name: 'discord', reconnect: false });
23
+ #cleanup;
24
+ #retiring;
22
25
  management = createDiscordEndpointManagement(() => this.#requireClient());
23
26
  control = Object.freeze({
24
27
  recall: (message) => this.recallMessage(message),
@@ -44,40 +47,92 @@ export class DiscordGatewayEndpoint extends Endpoint {
44
47
  get client() {
45
48
  return this.#requireClient();
46
49
  }
50
+ get transportState() {
51
+ if (this.#lifecycle.state !== 'open')
52
+ return this.#lifecycle.state;
53
+ if (!this.#client)
54
+ return 'closed';
55
+ if (!this.#client.isReady)
56
+ return this.#lifecycle.state;
57
+ return this.#client.isReady() ? 'open' : 'reconnecting';
58
+ }
47
59
  async start() {
48
- if (this.#started)
49
- return;
50
- this.#started = true;
51
- try {
52
- const intents = this.#options.config.intents?.length
53
- ? [...this.#options.config.intents]
54
- : DEFAULT_INTENTS;
55
- this.#client = this.#createClient(intents);
56
- await connectDiscordGatewayClient(this.#client, this.#options.config, {
57
- onPlatformEvent: (name, event) => {
58
- void this.#emitPlatformEvent(name, event);
59
- },
60
- onMessage: (msg) => this.admit(msg),
61
- onButton: (interaction) => this.admitButton(interaction),
62
- onGuildMemberAdd: (member) => {
63
- receiveDiscordGuildMemberSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, 'member_increase', member, this.#logger);
64
- },
65
- onGuildMemberRemove: (member) => {
66
- receiveDiscordGuildMemberSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, 'member_decrease', member, this.#logger);
67
- },
68
- });
69
- this.#logger.info(formatCompact({
70
- op: 'connect',
71
- endpoint: this.#options.config.id,
72
- mode: 'gateway',
73
- user: this.#client.user?.tag,
74
- }));
75
- }
76
- catch (error) {
77
- await this.stop();
78
- this.#logger.error('Failed to connect Discord gateway:', error);
79
- throw error;
80
- }
60
+ await this.#retiring;
61
+ await this.#lifecycle.start(async (handle) => {
62
+ let cleanup;
63
+ let cancelled = false;
64
+ try {
65
+ const intents = this.#options.config.intents?.length
66
+ ? [...this.#options.config.intents]
67
+ : DEFAULT_INTENTS;
68
+ const restAgent = this.#options.config.restApiProxy ? createDiscordRestApiAgent(this.#options.config.restApiProxy) : undefined;
69
+ let client;
70
+ try {
71
+ client = this.#createClient(intents, this.#options.config.gatewayFaultProxyUrl, restAgent ? { agent: restAgent } : undefined);
72
+ }
73
+ catch (error) {
74
+ await restAgent?.destroyProxy();
75
+ throw error;
76
+ }
77
+ this.#client = client;
78
+ const abort = new AbortController();
79
+ let stopping;
80
+ cleanup = () => {
81
+ cancelled = true;
82
+ abort.abort();
83
+ if (this.#client === client)
84
+ this.#client = null;
85
+ return stopping ??= (async () => { client.removeAllListeners(); try {
86
+ await client.destroy();
87
+ }
88
+ finally {
89
+ await restAgent?.destroyProxy();
90
+ } })();
91
+ };
92
+ this.#cleanup = cleanup;
93
+ handle.onForceClose(() => { void cleanup?.().catch(() => { }); });
94
+ await connectDiscordGatewayClient(client, this.#options.config, {
95
+ onPlatformEvent: (name, event) => {
96
+ if (this.#client !== client)
97
+ return;
98
+ void this.#emitPlatformEvent(name, event);
99
+ },
100
+ onMessage: (msg) => { if (this.#client === client)
101
+ return this.admit(msg); },
102
+ onButton: (interaction) => { if (this.#client === client)
103
+ return this.admitButton(interaction); },
104
+ onGuildMemberAdd: (member) => {
105
+ if (this.#client !== client)
106
+ return;
107
+ receiveDiscordGuildMemberSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, 'member_increase', member, this.#logger);
108
+ },
109
+ onGuildMemberRemove: (member) => {
110
+ if (this.#client !== client)
111
+ return;
112
+ receiveDiscordGuildMemberSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, 'member_decrease', member, this.#logger);
113
+ },
114
+ }, abort.signal);
115
+ if (abort.signal.aborted || this.#client !== client)
116
+ return;
117
+ this.#logger.info(formatCompact({
118
+ op: 'connect',
119
+ endpoint: this.#options.config.id,
120
+ mode: 'gateway',
121
+ user: this.#client.user?.tag,
122
+ }));
123
+ }
124
+ catch (error) {
125
+ const stopped = cancelled;
126
+ try {
127
+ await cleanup?.();
128
+ }
129
+ catch { /* original connect failure wins */ }
130
+ if (stopped)
131
+ return;
132
+ this.#logger.error('Failed to connect Discord gateway:', error);
133
+ throw error;
134
+ }
135
+ });
81
136
  }
82
137
  open() {
83
138
  this.#open = true;
@@ -87,17 +142,17 @@ export class DiscordGatewayEndpoint extends Endpoint {
87
142
  }
88
143
  async stop() {
89
144
  this.#open = false;
90
- if (this.#client) {
91
- try {
92
- this.#client.removeAllListeners();
93
- await this.#client.destroy();
94
- }
95
- catch {
96
- /* ignore */
97
- }
98
- this.#client = null;
145
+ const cleanup = this.#cleanup;
146
+ this.#cleanup = undefined;
147
+ this.#lifecycle.stop();
148
+ const retiring = this.#retiring ?? Promise.resolve().then(async () => { try {
149
+ await cleanup?.();
99
150
  }
100
- this.#started = false;
151
+ catch { /* best-effort SDK shutdown */ } });
152
+ this.#retiring = retiring;
153
+ await retiring;
154
+ if (this.#retiring === retiring)
155
+ this.#retiring = undefined;
101
156
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
102
157
  }
103
158
  async send({ conversation, payload }) {
@@ -197,13 +252,26 @@ export class DiscordGatewayEndpoint extends Endpoint {
197
252
  });
198
253
  }
199
254
  async #sendBody(channelId, body) {
200
- const channel = await this.#requireClient().channels.fetch(channelId);
201
- if (!channel || !channel.isTextBased() || !channel.send) {
202
- throw new Error(`Channel ${channelId} is not a text channel`);
255
+ let stage = 'fetch_channel';
256
+ try {
257
+ const channel = await this.#requireClient().channels.fetch(channelId);
258
+ if (!channel || !channel.isTextBased() || !channel.send) {
259
+ throw new Error(`Channel ${channelId} is not a text channel`);
260
+ }
261
+ stage = 'render';
262
+ const options = await toMessageCreateOptions(body);
263
+ stage = 'send';
264
+ const result = await channel.send(options);
265
+ return result.id;
266
+ }
267
+ catch (error) {
268
+ const details = error;
269
+ this.#logger.warn(formatCompact({ op: 'discord_send_failed', stage,
270
+ code: typeof details?.code === 'number' ? details.code : 'unknown',
271
+ status: typeof details?.status === 'number' ? details.status : undefined,
272
+ }));
273
+ throw error;
203
274
  }
204
- const options = await toMessageCreateOptions(body);
205
- const result = await channel.send(options);
206
- return result.id;
207
275
  }
208
276
  #requireClient() {
209
277
  if (!this.#client)
@@ -333,6 +401,31 @@ export class DiscordInteractionsEndpoint extends Endpoint {
333
401
  }));
334
402
  });
335
403
  }
404
+ /** Test / internal: admit a button interaction when open. */
405
+ admitButton(interaction) {
406
+ if (!this.#open)
407
+ return;
408
+ const conversation = discordInboundConversation(String(this.#options.id), interaction);
409
+ void this.emit('message.receive', {
410
+ conversation,
411
+ message: { conversation, id: interaction.id },
412
+ content: formatButtonContent(interaction),
413
+ segments: formatButtonSegments(interaction),
414
+ sender: { id: interaction.userId, name: interaction.userName },
415
+ endpointId: this.#options.config.id,
416
+ metadata: Object.freeze({
417
+ eventType: 'button',
418
+ payload: interaction.customId,
419
+ sourceMessageId: interaction.sourceMessageId,
420
+ }),
421
+ }).catch((err) => {
422
+ this.#logger.warn(formatCompact({
423
+ op: 'discord_gateway_receive_failed',
424
+ target: `${conversation.kind}:${conversation.id}`,
425
+ error: err instanceof Error ? err.message : String(err),
426
+ }));
427
+ });
428
+ }
336
429
  admitPlatform(event) {
337
430
  if (!this.#open)
338
431
  return;
@@ -0,0 +1,19 @@
1
+ import { Collection } from '@discordjs/collection';
2
+ import { WebSocketShard, type IShardingStrategy, type WebSocketManager, type WebSocketShardDestroyOptions } from '@discordjs/ws';
3
+ export declare function validateDiscordGatewayFaultProxy(value: string): string;
4
+ /** Public gateway host only; credentials and arbitrary proxy targets are rejected. */
5
+ export declare function discordGatewayProxyURL(proxy: string, upstream: string): string;
6
+ /** Undo only this proxy's exact internal route when SDK updates sequence. */
7
+ export declare function canonicalDiscordGatewayURL(proxy: string, value: string): string;
8
+ /** Keeps SDK heartbeat/Identify/Resume. Only connection URLs use the test proxy. */
9
+ export declare class DiscordFaultProxyStrategy implements IShardingStrategy {
10
+ #private;
11
+ readonly manager: WebSocketManager;
12
+ readonly proxy: string;
13
+ constructor(manager: WebSocketManager, proxy: string);
14
+ spawn(shardIds: number[]): Promise<void>;
15
+ connect(): Promise<void>;
16
+ destroy(options?: Omit<WebSocketShardDestroyOptions, 'recover'>): Promise<void>;
17
+ send(shardId: number, payload: Parameters<WebSocketShard['send']>[0]): Promise<void>;
18
+ fetchStatus(): Promise<Collection<number, import("discord.js").WebSocketShardStatus>>;
19
+ }
@@ -0,0 +1,104 @@
1
+ import { getLogger, formatCompact } from '@zhin.js/logger';
2
+ import { Collection } from '@discordjs/collection';
3
+ import { SimpleContextFetchingStrategy, WebSocketShard, WebSocketShardEvents, managerToFetchingStrategyOptions, } from '@discordjs/ws';
4
+ export function validateDiscordGatewayFaultProxy(value) {
5
+ const url = new URL(value);
6
+ if (url.protocol !== 'ws:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
7
+ throw new Error('Discord gateway fault proxy must be ws://127.0.0.1:PORT/');
8
+ return url.origin;
9
+ }
10
+ /** Public gateway host only; credentials and arbitrary proxy targets are rejected. */
11
+ export function discordGatewayProxyURL(proxy, upstream) {
12
+ const origin = validateDiscordGatewayFaultProxy(proxy);
13
+ const target = new URL(upstream);
14
+ if (target.protocol !== 'wss:' || !/^gateway(?:-[a-z0-9-]+)?\.discord\.gg$/.test(target.hostname)
15
+ || (target.port && target.port !== '443') || target.username || target.password || target.pathname !== '/' || target.hash
16
+ || [...target.searchParams].some(([key, value]) => !(key === 'v' && /^\d{1,2}$/.test(value)) && !(key === 'encoding' && value === 'json') && !(key === 'compress' && value === 'zlib-stream')))
17
+ throw new Error('Invalid Discord gateway host');
18
+ return `${origin}/__discord_gateway/${target.hostname}`;
19
+ }
20
+ /** Undo only this proxy's exact internal route when SDK updates sequence. */
21
+ export function canonicalDiscordGatewayURL(proxy, value) {
22
+ const origin = validateDiscordGatewayFaultProxy(proxy);
23
+ const url = new URL(value);
24
+ if (url.origin !== origin) {
25
+ discordGatewayProxyURL(proxy, value);
26
+ return value;
27
+ }
28
+ const host = url.pathname.match(/^\/__discord_gateway\/(gateway(?:-[a-z0-9-]+)?\.discord\.gg)$/)?.[1];
29
+ if (!host || url.username || url.password || url.search || url.hash)
30
+ throw new Error('Invalid internal Discord gateway route');
31
+ return `wss://${host}/`;
32
+ }
33
+ /** Keeps SDK heartbeat/Identify/Resume. Only connection URLs use the test proxy. */
34
+ export class DiscordFaultProxyStrategy {
35
+ manager;
36
+ proxy;
37
+ #failureReject;
38
+ #failure = new Promise((_, reject) => { this.#failureReject = reject; });
39
+ #diagnosed = new Set();
40
+ #shards = new Collection();
41
+ constructor(manager, proxy) {
42
+ this.manager = manager;
43
+ this.proxy = proxy;
44
+ validateDiscordGatewayFaultProxy(proxy);
45
+ void this.#failure.catch(() => { });
46
+ }
47
+ async spawn(shardIds) {
48
+ const options = await managerToFetchingStrategyOptions(this.manager);
49
+ const mapped = { ...options, gatewayInformation: { ...options.gatewayInformation, url: discordGatewayProxyURL(this.proxy, options.gatewayInformation.url) } };
50
+ for (const shardId of shardIds) {
51
+ const context = new SimpleContextFetchingStrategy(this.manager, mapped);
52
+ const retrieve = context.retrieveSessionInfo.bind(context);
53
+ const update = context.updateSessionInfo.bind(context);
54
+ const invalid = () => {
55
+ const error = new Error('Invalid Discord gateway session');
56
+ this.#failureReject(error);
57
+ void Promise.resolve(this.manager.emit(WebSocketShardEvents.Error, { error, shardId })).catch(() => { });
58
+ void shard.destroy({ code: 1000, reason: 'Invalid gateway session' }).catch(() => { });
59
+ };
60
+ context.retrieveSessionInfo = async (id) => {
61
+ try {
62
+ const session = await retrieve(id);
63
+ if (!session)
64
+ return null;
65
+ const canonical = canonicalDiscordGatewayURL(this.proxy, session.resumeURL);
66
+ const target = new URL(canonical);
67
+ if (!this.#diagnosed.has(target.hostname) && this.#diagnosed.size < 8) {
68
+ this.#diagnosed.add(target.hostname);
69
+ getLogger('discord').debug(formatCompact({ op: 'discord_gateway_resume_target', hostname: target.hostname, pathIsRoot: target.pathname === '/', queryKeys: [...new Set(target.searchParams.keys())].join(',') }));
70
+ }
71
+ return { ...session, resumeURL: discordGatewayProxyURL(this.proxy, canonical) };
72
+ }
73
+ catch {
74
+ invalid();
75
+ return null;
76
+ }
77
+ };
78
+ context.updateSessionInfo = async (id, session) => {
79
+ try {
80
+ return await update(id, session ? { ...session, resumeURL: canonicalDiscordGatewayURL(this.proxy, session.resumeURL) } : null);
81
+ }
82
+ catch {
83
+ invalid();
84
+ }
85
+ };
86
+ const shard = new WebSocketShard(context, shardId);
87
+ for (const event of Object.values(WebSocketShardEvents))
88
+ shard.on(event, (payload) => this.manager.emit(event, { ...payload, shardId }));
89
+ this.#shards.set(shardId, shard);
90
+ }
91
+ }
92
+ async connect() { await Promise.race([Promise.all([...this.#shards.values()].map(shard => shard.connect())), this.#failure]); }
93
+ async destroy(options) {
94
+ await Promise.all([...this.#shards.values()].map(shard => shard.destroy(options)));
95
+ this.#shards.clear();
96
+ }
97
+ async send(shardId, payload) {
98
+ const shard = this.#shards.get(shardId);
99
+ if (!shard)
100
+ throw new RangeError('Discord shard not found');
101
+ await shard.send(payload);
102
+ }
103
+ async fetchStatus() { return this.#shards.mapValues(shard => shard.status); }
104
+ }
package/lib/gateway.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { GatewayIntentBits, type MessageCreateOptions } from 'discord.js';
1
+ import { GatewayIntentBits, type RESTOptions, type MessageCreateOptions } from 'discord.js';
2
2
  import { type DiscordButtonInbound, type DiscordInboundMessage, type DiscordOutboundBody, type ResolvedDiscordGatewayConfig } from './protocol.js';
3
3
  export declare const DEFAULT_INTENTS: GatewayIntentBits[];
4
4
  /** Minimal client surface used by the endpoint (real discord.js or test mock). */
5
5
  export interface DiscordClientTransport {
6
+ isReady?(): boolean;
6
7
  login(token: string): Promise<string>;
7
8
  destroy(): Promise<void>;
8
9
  on(event: string, listener: (...args: unknown[]) => void): void;
@@ -121,8 +122,8 @@ export interface DiscordClientTransport {
121
122
  };
122
123
  };
123
124
  }
124
- export type CreateDiscordClient = (intents: readonly number[]) => DiscordClientTransport;
125
- export declare function defaultCreateClient(intents: readonly number[]): DiscordClientTransport;
125
+ export type CreateDiscordClient = (intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions?: Partial<RESTOptions>) => DiscordClientTransport;
126
+ export declare function defaultCreateClient(intents: readonly number[], gatewayFaultProxyUrl?: string, restOptions?: Partial<RESTOptions>): DiscordClientTransport;
126
127
  export declare function resolveSenderRole(msg: DiscordInboundMessage): string | undefined;
127
128
  export declare function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | null;
128
129
  export declare function toMessageCreateOptions(body: DiscordOutboundBody): Promise<MessageCreateOptions>;
@@ -141,4 +142,4 @@ export interface DiscordGatewayConnectHandlers {
141
142
  userName?: string;
142
143
  }): void;
143
144
  }
144
- export declare function connectDiscordGatewayClient(client: DiscordClientTransport, config: ResolvedDiscordGatewayConfig, handlers: DiscordGatewayConnectHandlers): Promise<void>;
145
+ export declare function connectDiscordGatewayClient(client: DiscordClientTransport, config: ResolvedDiscordGatewayConfig, handlers: DiscordGatewayConnectHandlers, signal?: AbortSignal): Promise<void>;
package/lib/gateway.js CHANGED
@@ -1,6 +1,7 @@
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
- import { Client, GatewayIntentBits, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, REST, Routes, PermissionFlagsBits, } from 'discord.js';
4
+ import { Client, Partials, GatewayIntentBits, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, REST, Routes, PermissionFlagsBits, } from 'discord.js';
4
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
5
6
  import { activityTypeCode, resolveChannelKind, } from './protocol.js';
6
7
  const logger = getLogger('discord');
@@ -12,8 +13,8 @@ export const DEFAULT_INTENTS = [
12
13
  GatewayIntentBits.GuildMembers,
13
14
  GatewayIntentBits.GuildMessageReactions,
14
15
  ];
15
- export function defaultCreateClient(intents) {
16
- return new Client({ intents: [...intents] });
16
+ export function defaultCreateClient(intents, gatewayFaultProxyUrl, restOptions = {}) {
17
+ return new Client({ rest: { ...restOptions, retries: 0, rejectOnRateLimit: () => true }, intents: [...intents], partials: [Partials.Channel], ...(gatewayFaultProxyUrl ? { ws: { buildStrategy: manager => new DiscordFaultProxyStrategy(manager, gatewayFaultProxyUrl) } } : {}) });
17
18
  }
18
19
  export function resolveSenderRole(msg) {
19
20
  if (msg.isGuildOwner)
@@ -78,6 +79,8 @@ export function normalizeDiscordMessage(raw) {
78
79
  }
79
80
  export async function toMessageCreateOptions(body) {
80
81
  const options = {};
82
+ if (body.reply)
83
+ options.reply = { ...body.reply };
81
84
  if (body.content)
82
85
  options.content = body.content;
83
86
  if (body.embeds?.length) {
@@ -194,9 +197,19 @@ function decodeBase64(value) {
194
197
  const comma = stripped.startsWith('data:') ? stripped.indexOf(',') : -1;
195
198
  return Buffer.from(comma >= 0 ? stripped.slice(comma + 1) : stripped, 'base64');
196
199
  }
197
- export async function connectDiscordGatewayClient(client, config, handlers) {
200
+ export async function connectDiscordGatewayClient(client, config, handlers, signal) {
198
201
  return new Promise((resolve, reject) => {
199
202
  let settled = false;
203
+ const onAbort = () => { if (!settled) {
204
+ settled = true;
205
+ reject(new Error('Discord startup stopped'));
206
+ } };
207
+ if (signal?.aborted) {
208
+ onAbort();
209
+ return;
210
+ }
211
+ signal?.addEventListener('abort', onAbort, { once: true });
212
+ const releaseAbort = () => signal?.removeEventListener('abort', onAbort);
200
213
  client.on('messageCreate', (raw) => {
201
214
  handlers.onPlatformEvent('messageCreate', raw);
202
215
  const msg = normalizeDiscordMessage(raw);
@@ -211,16 +224,27 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
211
224
  client.on('interactionCreate', (raw) => {
212
225
  handlers.onPlatformEvent('interactionCreate', raw);
213
226
  const interaction = raw;
214
- if (!interaction.isButton?.())
227
+ const isButton = interaction.isButton?.() === true;
228
+ logger.info(formatCompact({ op: 'discord_gateway_interaction_received', button: isButton, cachedChannel: !!interaction.channel, hasChannelId: !!interaction.channelId }));
229
+ if (!isButton)
215
230
  return;
216
- void interaction.deferUpdate?.().catch(() => { });
217
- if (!interaction.channel)
231
+ void interaction.deferUpdate?.().then(() => {
232
+ logger.info(formatCompact({ op: 'discord_gateway_button_ack', status: 'confirmed' }));
233
+ }).catch(() => {
234
+ // SDK errors may contain token-bearing callback URLs; log no raw error.
235
+ logger.warn(formatCompact({ op: 'discord_gateway_button_ack', status: 'failed' }));
236
+ });
237
+ const channelId = interaction.channelId ?? interaction.channel?.id;
238
+ if (!channelId) {
239
+ logger.warn(formatCompact({ op: 'discord_gateway_button_dropped', reason: 'missing_channel_id' }));
218
240
  return;
241
+ }
219
242
  handlers.onButton({
220
243
  id: interaction.id,
221
244
  customId: interaction.customId,
222
- channelId: interaction.channel.id,
223
- channelKind: resolveChannelKind(interaction.channel.type),
245
+ channelId,
246
+ channelKind: interaction.channel ? resolveChannelKind(interaction.channel.type) : interaction.guildId ? 'channel' : 'private',
247
+ ...(interaction.guildId ? { guildId: interaction.guildId } : {}),
224
248
  userId: interaction.user.id,
225
249
  userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
226
250
  sourceMessageId: interaction.message?.id,
@@ -253,6 +277,8 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
253
277
  });
254
278
  });
255
279
  client.once('clientReady', () => {
280
+ if (signal?.aborted)
281
+ return;
256
282
  handlers.onPlatformEvent('clientReady', client.user);
257
283
  void (async () => {
258
284
  try {
@@ -268,12 +294,14 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
268
294
  }
269
295
  if (!settled) {
270
296
  settled = true;
297
+ releaseAbort();
271
298
  resolve();
272
299
  }
273
300
  }
274
301
  catch (error) {
275
302
  if (!settled) {
276
303
  settled = true;
304
+ releaseAbort();
277
305
  reject(error);
278
306
  }
279
307
  }
@@ -283,15 +311,20 @@ export async function connectDiscordGatewayClient(client, config, handlers) {
283
311
  logger.error('Discord client error:', error);
284
312
  if (!settled) {
285
313
  settled = true;
314
+ releaseAbort();
286
315
  reject(error instanceof Error ? error : new Error(String(error)));
287
316
  }
288
317
  });
289
318
  client.on('warn', (info) => {
290
319
  logger.warn('Discord client warning:', info);
291
320
  });
292
- client.login(config.token).catch((error) => {
321
+ client.login(config.token).then(() => {
322
+ if (signal?.aborted)
323
+ void Promise.resolve(client.destroy()).catch(() => { });
324
+ }).catch((error) => {
293
325
  if (!settled) {
294
326
  settled = true;
327
+ releaseAbort();
295
328
  const raw = error instanceof Error ? error.message : String(error);
296
329
  const tokenShapeHint = config.token.split('.').length === 3
297
330
  ? ''