@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/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
|
+
}
|
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
|
+
"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
|
-
"@
|
|
37
|
-
"@
|
|
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.
|
|
41
|
-
"@zhin.js/im-contract": "1.1.
|
|
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
46
|
"@zhin.js/permission": "1.1.3",
|
|
44
|
-
"@zhin.js/skill": "1.1.
|
|
45
|
-
"@zhin.js/tool": "1.1.
|
|
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.
|
|
50
|
-
"@zhin.js/command": "^1.1.
|
|
51
|
-
"@zhin.js/core": "^1.1.
|
|
52
|
-
"zhin.js": "^1.1.
|
|
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.
|
|
71
|
-
"zhin.js": "1.1.
|
|
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 {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
109
|
-
this.#
|
|
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.#
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
}
|