@zhin.js/adapter-discord 1.1.4 → 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 +31 -0
- package/README.md +27 -0
- package/lib/endpoint.d.ts +4 -2
- package/lib/endpoint.js +144 -51
- package/lib/gateway-fault-proxy.d.ts +19 -0
- package/lib/gateway-fault-proxy.js +104 -0
- package/lib/gateway.d.ts +5 -4
- package/lib/gateway.js +43 -10
- package/lib/protocol.d.ts +11 -1
- package/lib/protocol.js +23 -1
- package/lib/rest-api-proxy.d.ts +8 -0
- package/lib/rest-api-proxy.js +47 -0
- package/lib/webhook.d.ts +2 -1
- package/lib/webhook.js +14 -0
- package/package.json +17 -14
- package/schema.json +25 -0
- package/src/endpoint.ts +100 -34
- package/src/gateway-fault-proxy.ts +82 -0
- package/src/gateway.ts +40 -9
- package/src/protocol.ts +29 -1
- package/src/rest-api-proxy.ts +37 -0
- package/src/webhook.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
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
|
+
|
|
3
34
|
## 1.1.4
|
|
4
35
|
|
|
5
36
|
### 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 {
|
|
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
|
-
#
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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?.().
|
|
217
|
-
|
|
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
|
|
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).
|
|
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
|
? ''
|
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<{
|