@zhin.js/adapter-discord 1.1.17 → 1.1.19
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 +66 -0
- package/README.md +19 -2
- package/adapters/discord/index.js +3 -23
- package/adapters/discord/index.ts +2 -27
- package/agents/discord/skills/discord/tools/send_embed/index.js +1 -1
- package/agents/discord/skills/discord/tools/send_embed/index.ts +1 -1
- package/agents/discord/skills/discord-guild/tools/create_thread/index.js +6 -2
- package/agents/discord/skills/discord-guild/tools/create_thread/index.ts +5 -2
- package/agents/discord/skills/discord-guild/tools/forum_post/index.js +1 -1
- package/agents/discord/skills/discord-guild/tools/forum_post/index.ts +1 -1
- package/commands/discord/endpoint/definition.js +0 -2
- package/commands/discord/endpoint/definition.ts +0 -2
- package/lib/adapter.d.ts +14 -0
- package/lib/adapter.js +55 -0
- package/lib/client.d.ts +9 -9
- package/lib/client.js +0 -2
- package/lib/console-spec.d.ts +12 -0
- package/lib/console-spec.js +19 -0
- package/lib/console.d.ts +2 -0
- package/lib/console.js +13 -0
- package/lib/endpoint.d.ts +36 -32
- package/lib/endpoint.js +174 -170
- package/lib/gateway.d.ts +6 -124
- package/lib/gateway.js +88 -63
- package/lib/index.d.ts +4 -3
- package/lib/index.js +3 -2
- package/lib/protocol.d.ts +4 -0
- package/lib/protocol.js +1 -0
- package/lib/webhook.d.ts +2 -3
- package/lib/webhook.js +42 -30
- package/package.json +27 -19
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +3 -4
- package/src/adapter.ts +38 -0
- package/src/client.ts +6 -12
- package/src/console-spec.ts +24 -0
- package/src/console.ts +17 -0
- package/src/endpoint.ts +197 -172
- package/src/gateway.ts +103 -151
- package/src/index.ts +2 -3
- package/src/protocol.ts +5 -0
- package/src/webhook.ts +42 -32
- package/lib/discord-runtime-state.d.ts +0 -1
- package/lib/discord-runtime-state.js +0 -6
- package/src/discord-runtime-state.ts +0 -7
package/src/endpoint.ts
CHANGED
|
@@ -2,33 +2,40 @@ import { createDiscordRestApiAgent } from './rest-api-proxy.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
|
|
4
4
|
*/
|
|
5
|
-
import { ChannelType } from 'discord.js';
|
|
5
|
+
import { Client, ChannelType } from 'discord.js';
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
createEndpointSpecification,
|
|
8
|
+
endpointSpec,
|
|
9
|
+
type Endpoint,
|
|
10
|
+
type EndpointSpecification,
|
|
11
|
+
createEndpointLifecycle,
|
|
8
12
|
type EndpointChannel,
|
|
9
13
|
type EndpointContentPort,
|
|
10
14
|
type EndpointContentResolveContext,
|
|
11
15
|
type EndpointControl,
|
|
16
|
+
type EndpointRecallRequest,
|
|
17
|
+
type EndpointReactionRequest,
|
|
12
18
|
type EndpointGroup,
|
|
13
19
|
type EndpointManagement,
|
|
14
20
|
type EndpointSendRequest,
|
|
15
21
|
type EndpointTransportState,
|
|
22
|
+
type EndpointManagementRequest,
|
|
23
|
+
createEndpointOperation,
|
|
24
|
+
type EndpointGroupRequest,
|
|
16
25
|
} from 'zhin.js/adapter';
|
|
17
26
|
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
18
27
|
import {
|
|
19
28
|
type ConversationReference,
|
|
20
29
|
type ConversationResolution,
|
|
21
|
-
type MessageRef,
|
|
22
30
|
} from '@zhin.js/im-contract';
|
|
23
31
|
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
24
32
|
import type { CapabilityId } from 'zhin.js';
|
|
25
33
|
import {
|
|
26
34
|
connectDiscordGatewayClient,
|
|
27
|
-
|
|
35
|
+
discordClientOptions,
|
|
28
36
|
DEFAULT_INTENTS,
|
|
29
37
|
resolveSenderRole,
|
|
30
38
|
toMessageCreateOptions,
|
|
31
|
-
type CreateDiscordClient,
|
|
32
39
|
type DiscordClientTransport,
|
|
33
40
|
} from './gateway.js';
|
|
34
41
|
import {
|
|
@@ -52,102 +59,89 @@ const DISCORD_API = 'https://discord.com/api/v10';
|
|
|
52
59
|
/** 出站 HTTP 调用统一 30s 超时。 */
|
|
53
60
|
const OUTBOUND_TIMEOUT_MS = 30_000;
|
|
54
61
|
export type {
|
|
55
|
-
CreateDiscordClient,
|
|
56
62
|
DiscordClientTransport,
|
|
57
63
|
} from './gateway.js';
|
|
58
64
|
|
|
59
65
|
export interface DiscordEndpointOptions {
|
|
60
66
|
readonly id: CapabilityId;
|
|
61
67
|
readonly config: ResolvedDiscordGatewayConfig;
|
|
62
|
-
readonly
|
|
68
|
+
readonly restApiAgent?: ReturnType<typeof createDiscordRestApiAgent>;
|
|
63
69
|
readonly fetch?: typeof globalThis.fetch;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
export class DiscordGatewayEndpoint extends Endpoint
|
|
72
|
+
export class DiscordGatewayEndpoint extends Client implements Endpoint {
|
|
73
|
+
readonly [endpointSpec]: EndpointSpecification;
|
|
67
74
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
68
75
|
|
|
69
76
|
readonly #options: DiscordEndpointOptions;
|
|
70
|
-
readonly #createClient: CreateDiscordClient;
|
|
71
77
|
readonly #fetch: typeof globalThis.fetch;
|
|
72
|
-
#
|
|
78
|
+
#connection?: AbortController;
|
|
79
|
+
#sdkDisposal?: Promise<void>;
|
|
73
80
|
#open = false;
|
|
74
81
|
readonly #lifecycle = createEndpointLifecycle({ name: 'discord', reconnect: false });
|
|
75
82
|
#cleanup?: () => Promise<void>;
|
|
76
83
|
#retiring?: Promise<void>;
|
|
77
|
-
readonly management: EndpointManagement = createDiscordEndpointManagement(
|
|
84
|
+
readonly #management: EndpointManagement = createDiscordEndpointManagement(
|
|
78
85
|
() => this.#requireClient(),
|
|
79
86
|
);
|
|
80
|
-
readonly control: EndpointControl = Object.freeze({
|
|
81
|
-
recall:
|
|
82
|
-
addReaction:
|
|
83
|
-
message: MessageRef,
|
|
84
|
-
emoji: string,
|
|
85
|
-
hint?: { readonly sceneType?: string; readonly channelId?: string },
|
|
86
|
-
) => {
|
|
87
|
-
const channelId = hint?.channelId ?? message.conversation.id;
|
|
88
|
-
if (!channelId || !message.id) return null;
|
|
89
|
-
await this.#addReaction(channelId, message.id, emoji);
|
|
90
|
-
return emoji;
|
|
91
|
-
},
|
|
87
|
+
readonly #control: EndpointControl = Object.freeze<EndpointControl>({
|
|
88
|
+
recall: request => this.#recallMessage(request),
|
|
89
|
+
addReaction: request => this.#addReaction(request),
|
|
92
90
|
});
|
|
93
|
-
readonly content: EndpointContentPort = Object.freeze({
|
|
91
|
+
readonly #content: EndpointContentPort = Object.freeze({
|
|
94
92
|
resolve: (reference: ConversationReference, context: EndpointContentResolveContext) =>
|
|
95
93
|
resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
|
|
96
94
|
});
|
|
97
95
|
|
|
98
96
|
constructor(options: DiscordEndpointOptions) {
|
|
99
|
-
super(
|
|
97
|
+
super(discordClientOptions(options.config.intents?.length ? options.config.intents : DEFAULT_INTENTS,
|
|
98
|
+
options.config.gatewayFaultProxyUrl, options.restApiAgent ? { agent: options.restApiAgent } : {}));
|
|
100
99
|
this.#logger = getAdapterLogger('discord', options.config.id);
|
|
101
100
|
this.#options = options;
|
|
102
|
-
this.#createClient = options.createClient ?? defaultCreateClient;
|
|
103
101
|
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
102
|
+
const endpoint = this;
|
|
103
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
104
|
+
start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
105
|
+
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
|
|
106
|
+
get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
|
|
107
|
+
management: this.#management, control: this.#control, content: this.#content,
|
|
108
|
+
});
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
get client(): DiscordClientTransport {
|
|
108
|
-
return this.#requireClient();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
get transportState(): EndpointTransportState | undefined {
|
|
111
|
+
get #transportState(): EndpointTransportState | undefined {
|
|
112
112
|
if (this.#lifecycle.state !== 'open') return this.#lifecycle.state;
|
|
113
|
-
if (!this.#
|
|
114
|
-
|
|
115
|
-
return this.#client.isReady() ? 'open' : 'reconnecting';
|
|
113
|
+
if (!this.#connection) return 'closed';
|
|
114
|
+
return this.isReady() ? 'open' : 'reconnecting';
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
async
|
|
117
|
+
async #startFramework(executionSignal: AbortSignal): Promise<void> {
|
|
119
118
|
await this.#retiring;
|
|
120
119
|
await this.#lifecycle.start(async handle => {
|
|
121
120
|
let cleanup: (() => Promise<void>) | undefined; let cancelled = false;
|
|
122
121
|
try {
|
|
123
|
-
const
|
|
124
|
-
? [...this.#options.config.intents]
|
|
125
|
-
: DEFAULT_INTENTS;
|
|
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;
|
|
122
|
+
const client = this;
|
|
131
123
|
const abort = new AbortController(); let stopping: Promise<void> | undefined;
|
|
124
|
+
this.#connection = abort;
|
|
132
125
|
cleanup = () => {
|
|
133
126
|
cancelled = true;
|
|
134
127
|
abort.abort();
|
|
135
|
-
if (this.#
|
|
136
|
-
return stopping ??= (
|
|
128
|
+
if (this.#connection === abort) this.#connection = undefined;
|
|
129
|
+
return stopping ??= this.#disposeSdk();
|
|
137
130
|
};
|
|
138
131
|
this.#cleanup = cleanup;
|
|
139
132
|
handle.onForceClose(() => { void cleanup?.().catch(() => {}); });
|
|
140
133
|
await connectDiscordGatewayClient(client, this.#options.config, {
|
|
134
|
+
receive: action => this[endpointSpec].events.run(action),
|
|
141
135
|
onPlatformEvent: (name, event) => {
|
|
142
|
-
if (this.#
|
|
136
|
+
if (this.#connection !== abort) return;
|
|
143
137
|
void this.#emitPlatformEvent(name, event);
|
|
144
138
|
},
|
|
145
|
-
onMessage: (msg) => { if (this.#
|
|
146
|
-
onButton: (interaction) => { if (this.#
|
|
139
|
+
onMessage: (msg) => { if (this.#connection === abort) return this.admit(msg); },
|
|
140
|
+
onButton: (interaction) => { if (this.#connection === abort) return this.admitButton(interaction); },
|
|
147
141
|
onGuildMemberAdd: (member) => {
|
|
148
|
-
if (this.#
|
|
142
|
+
if (this.#connection !== abort) return;
|
|
149
143
|
receiveDiscordGuildMemberSideEvent(
|
|
150
|
-
(name, payload) => this.emit(name, payload),
|
|
144
|
+
(name, payload) => this[endpointSpec].events.emit(name, payload),
|
|
151
145
|
this.#options.config.id,
|
|
152
146
|
'member_increase',
|
|
153
147
|
member,
|
|
@@ -155,22 +149,22 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
155
149
|
);
|
|
156
150
|
},
|
|
157
151
|
onGuildMemberRemove: (member) => {
|
|
158
|
-
if (this.#
|
|
152
|
+
if (this.#connection !== abort) return;
|
|
159
153
|
receiveDiscordGuildMemberSideEvent(
|
|
160
|
-
(name, payload) => this.emit(name, payload),
|
|
154
|
+
(name, payload) => this[endpointSpec].events.emit(name, payload),
|
|
161
155
|
this.#options.config.id,
|
|
162
156
|
'member_decrease',
|
|
163
157
|
member,
|
|
164
158
|
this.#logger,
|
|
165
159
|
);
|
|
166
160
|
},
|
|
167
|
-
}, abort.signal);
|
|
168
|
-
if (abort.signal.aborted || this.#
|
|
161
|
+
}, AbortSignal.any([abort.signal, executionSignal]));
|
|
162
|
+
if (abort.signal.aborted || this.#connection !== abort) return;
|
|
169
163
|
this.#logger.info(formatCompact({
|
|
170
164
|
op: 'connect',
|
|
171
165
|
endpoint: this.#options.config.id,
|
|
172
166
|
mode: 'gateway',
|
|
173
|
-
user: this
|
|
167
|
+
user: this.user?.tag,
|
|
174
168
|
}));
|
|
175
169
|
} catch (error) {
|
|
176
170
|
const stopped = cancelled;
|
|
@@ -182,29 +176,29 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
182
176
|
});
|
|
183
177
|
}
|
|
184
178
|
|
|
185
|
-
|
|
179
|
+
#openFramework(): void {
|
|
186
180
|
this.#open = true;
|
|
187
181
|
}
|
|
188
182
|
|
|
189
|
-
|
|
183
|
+
#closeFramework(): void {
|
|
190
184
|
this.#open = false;
|
|
191
185
|
}
|
|
192
186
|
|
|
193
|
-
async
|
|
187
|
+
async #stopFramework(): Promise<void> {
|
|
194
188
|
this.#open = false;
|
|
195
189
|
const cleanup = this.#cleanup;
|
|
196
190
|
this.#cleanup = undefined;
|
|
197
191
|
this.#lifecycle.stop();
|
|
198
|
-
const retiring = this.#retiring ?? Promise.resolve().then(
|
|
192
|
+
const retiring = this.#retiring ?? Promise.resolve().then(() => cleanup ? cleanup() : this.#disposeSdk());
|
|
199
193
|
this.#retiring = retiring;
|
|
200
194
|
await retiring;
|
|
201
195
|
if (this.#retiring === retiring) this.#retiring = undefined;
|
|
202
196
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
203
197
|
}
|
|
204
198
|
|
|
205
|
-
async
|
|
199
|
+
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
206
200
|
const body = formatOutboundBody(payload);
|
|
207
|
-
const snowflake = await this.#sendBody(conversation.id, body);
|
|
201
|
+
const snowflake = await this.#sendBody({ channelId: conversation.id, body, signal });
|
|
208
202
|
this.#logger.debug(formatCompact({
|
|
209
203
|
op: 'discord_send',
|
|
210
204
|
endpoint: this.#options.config.id,
|
|
@@ -214,12 +208,13 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
214
208
|
return snowflake;
|
|
215
209
|
}
|
|
216
210
|
|
|
217
|
-
async recallMessage(message:
|
|
211
|
+
async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
|
|
218
212
|
if (!message.id) return;
|
|
219
|
-
const
|
|
213
|
+
const operation = createEndpointOperation({ signal });
|
|
214
|
+
const channel = await operation.prepare(() => this.#requireClient().channels.fetch(message.conversation.id));
|
|
220
215
|
if (!channel?.isTextBased() || !channel.messages) return;
|
|
221
|
-
const msg = await channel.messages.fetch(message.id);
|
|
222
|
-
await msg.delete();
|
|
216
|
+
const msg = await operation.prepare(() => channel.messages.fetch(message.id));
|
|
217
|
+
await operation.run(() => msg.delete());
|
|
223
218
|
}
|
|
224
219
|
|
|
225
220
|
/** Test / internal: admit a message when open. */
|
|
@@ -227,8 +222,10 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
227
222
|
if (!this.#open) return;
|
|
228
223
|
if (msg.authorBot) return;
|
|
229
224
|
const conversation = discordInboundConversation(String(this.#options.id), msg);
|
|
230
|
-
void this.emit('message.receive', {
|
|
225
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
231
226
|
conversation,
|
|
227
|
+
event: msg.event,
|
|
228
|
+
platformSentAt: msg.platformSentAt,
|
|
232
229
|
message: { conversation, id: msg.id },
|
|
233
230
|
content: formatInboundContent(msg),
|
|
234
231
|
segments: formatInboundSegments(msg),
|
|
@@ -259,7 +256,9 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
259
256
|
admitButton(interaction: DiscordButtonInbound): void {
|
|
260
257
|
if (!this.#open) return;
|
|
261
258
|
const conversation = discordInboundConversation(String(this.#options.id), interaction);
|
|
262
|
-
void this.emit('message.receive', {
|
|
259
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
260
|
+
event: interaction.event,
|
|
261
|
+
platformSentAt: interaction.platformSentAt,
|
|
263
262
|
conversation,
|
|
264
263
|
message: { conversation, id: interaction.id },
|
|
265
264
|
content: formatButtonContent(interaction),
|
|
@@ -280,17 +279,19 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
280
279
|
});
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
async #addReaction(
|
|
284
|
-
const
|
|
285
|
-
if (!
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await
|
|
282
|
+
async #addReaction({ message, emoji, hint, signal }: EndpointReactionRequest): Promise<string | null> {
|
|
283
|
+
const channelId = hint?.channelId ?? message.conversation.id;
|
|
284
|
+
if (!channelId || !message.id) return null;
|
|
285
|
+
const operation = createEndpointOperation({ signal });
|
|
286
|
+
const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
|
|
287
|
+
if (!channel?.isTextBased() || !channel.messages) throw new Error(`Channel ${channelId} 不是文本频道`);
|
|
288
|
+
const native = await operation.prepare(() => channel.messages.fetch(message.id));
|
|
289
|
+
await operation.run(() => native.react(emoji));
|
|
290
|
+
return emoji;
|
|
290
291
|
}
|
|
291
292
|
|
|
292
293
|
async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
|
|
293
|
-
await this.
|
|
294
|
+
await this[endpointSpec].events.platform(name, event).catch((error) => {
|
|
294
295
|
this.#logger.warn(formatCompact({
|
|
295
296
|
op: 'discord_platform_event_failed',
|
|
296
297
|
event: name,
|
|
@@ -299,17 +300,18 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
299
300
|
});
|
|
300
301
|
}
|
|
301
302
|
|
|
302
|
-
async #sendBody(channelId
|
|
303
|
+
async #sendBody({ channelId, body, signal }: DiscordCreateMessageRequest): Promise<string> {
|
|
304
|
+
const operation = createEndpointOperation({ signal });
|
|
303
305
|
let stage = 'fetch_channel';
|
|
304
306
|
try {
|
|
305
|
-
const channel = await this.#requireClient().channels.fetch(channelId);
|
|
306
|
-
if (!channel || !channel.
|
|
307
|
+
const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
|
|
308
|
+
if (!channel || !channel.isSendable()) {
|
|
307
309
|
throw new Error(`Channel ${channelId} is not a text channel`);
|
|
308
310
|
}
|
|
309
311
|
stage = 'render';
|
|
310
|
-
const options = await toMessageCreateOptions(body);
|
|
312
|
+
const options = await operation.prepare(() => toMessageCreateOptions(body));
|
|
311
313
|
stage = 'send';
|
|
312
|
-
const result = await channel.send(options);
|
|
314
|
+
const result = await operation.run(async () => channel.send(options));
|
|
313
315
|
return result.id;
|
|
314
316
|
} catch (error) {
|
|
315
317
|
const details = error as { code?: unknown; status?: unknown };
|
|
@@ -321,9 +323,17 @@ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
|
|
|
321
323
|
}
|
|
322
324
|
}
|
|
323
325
|
|
|
326
|
+
#disposeSdk(): Promise<void> {
|
|
327
|
+
this.#sdkDisposal ??= Promise.resolve().then(async () => {
|
|
328
|
+
this.removeAllListeners();
|
|
329
|
+
try { await super.destroy(); } finally { await this.#options.restApiAgent?.destroyProxy(); }
|
|
330
|
+
});
|
|
331
|
+
return this.#sdkDisposal;
|
|
332
|
+
}
|
|
333
|
+
|
|
324
334
|
#requireClient(): DiscordClientTransport {
|
|
325
|
-
if (!this.#
|
|
326
|
-
return this
|
|
335
|
+
if (!this.#connection) throw new Error('Discord client not connected');
|
|
336
|
+
return this;
|
|
327
337
|
}
|
|
328
338
|
}
|
|
329
339
|
|
|
@@ -334,45 +344,57 @@ export interface DiscordInteractionsEndpointOptions {
|
|
|
334
344
|
readonly fetch?: typeof globalThis.fetch;
|
|
335
345
|
}
|
|
336
346
|
|
|
347
|
+
/** One Discord REST request retains the caller lifetime through response-body consumption. */
|
|
348
|
+
export interface DiscordRestRequest {
|
|
349
|
+
readonly method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
350
|
+
readonly path: string;
|
|
351
|
+
readonly body?: unknown;
|
|
352
|
+
readonly signal?: AbortSignal;
|
|
353
|
+
}
|
|
354
|
+
/** Protocol target and payload for a single message publication. */
|
|
355
|
+
export interface DiscordCreateMessageRequest {
|
|
356
|
+
readonly channelId: string;
|
|
357
|
+
readonly body: DiscordOutboundBody;
|
|
358
|
+
readonly signal?: AbortSignal;
|
|
359
|
+
}
|
|
360
|
+
/** Protocol target for a single existing-message deletion. */
|
|
361
|
+
export interface DiscordDeleteMessageRequest {
|
|
362
|
+
readonly channelId: string;
|
|
363
|
+
readonly messageId: string;
|
|
364
|
+
readonly signal?: AbortSignal;
|
|
365
|
+
}
|
|
366
|
+
|
|
337
367
|
/** Minimal Discord REST client used when Gateway is intentionally disabled. */
|
|
338
368
|
export class DiscordRestClient {
|
|
339
|
-
constructor(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return (
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
createMessage(channelId: string, body: unknown): Promise<{ id?: string }> {
|
|
366
|
-
return this.request('POST', `/channels/${channelId}/messages`, body);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
deleteMessage(channelId: string, messageId: string): Promise<void> {
|
|
370
|
-
return this.request('DELETE', `/channels/${channelId}/messages/${messageId}`);
|
|
369
|
+
constructor(readonly token: string, readonly fetch: typeof globalThis.fetch = globalThis.fetch) {}
|
|
370
|
+
|
|
371
|
+
async request<T = unknown>({ method, path, body, signal }: DiscordRestRequest): Promise<T> {
|
|
372
|
+
const deadline = AbortSignal.timeout(OUTBOUND_TIMEOUT_MS);
|
|
373
|
+
const operationSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
374
|
+
const operation = createEndpointOperation({ signal: operationSignal });
|
|
375
|
+
const execute = async () => {
|
|
376
|
+
const response = await this.fetch(`${DISCORD_API}${path}`, {
|
|
377
|
+
method,
|
|
378
|
+
headers: { Authorization: `Bot ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
|
379
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
380
|
+
signal: operationSignal,
|
|
381
|
+
});
|
|
382
|
+
const text = await response.text();
|
|
383
|
+
if (!response.ok) throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
|
|
384
|
+
return (text ? JSON.parse(text) : undefined) as T;
|
|
385
|
+
};
|
|
386
|
+
return operation.run(execute);
|
|
387
|
+
}
|
|
388
|
+
createMessage({ channelId, body, signal }: DiscordCreateMessageRequest): Promise<{ id?: string }> {
|
|
389
|
+
return this.request({ method: 'POST', path: `/channels/${channelId}/messages`, body, signal });
|
|
390
|
+
}
|
|
391
|
+
deleteMessage({ channelId, messageId, signal }: DiscordDeleteMessageRequest): Promise<void> {
|
|
392
|
+
return this.request({ method: 'DELETE', path: `/channels/${channelId}/messages/${messageId}`, signal });
|
|
371
393
|
}
|
|
372
394
|
}
|
|
373
395
|
|
|
374
|
-
export class DiscordInteractionsEndpoint extends
|
|
375
|
-
readonly
|
|
396
|
+
export class DiscordInteractionsEndpoint extends DiscordRestClient implements Endpoint {
|
|
397
|
+
readonly [endpointSpec]: EndpointSpecification;
|
|
376
398
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
377
399
|
|
|
378
400
|
readonly #options: DiscordInteractionsEndpointOptions;
|
|
@@ -380,20 +402,24 @@ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
|
|
|
380
402
|
#routeReleases: HttpRouteRegistration[] = [];
|
|
381
403
|
#open = false;
|
|
382
404
|
#started = false;
|
|
383
|
-
readonly control: EndpointControl = Object.freeze({
|
|
384
|
-
recall:
|
|
405
|
+
readonly #control: EndpointControl = Object.freeze<EndpointControl>({
|
|
406
|
+
recall: request => this.#recallMessage(request),
|
|
385
407
|
});
|
|
386
|
-
readonly content: EndpointContentPort = Object.freeze({
|
|
408
|
+
readonly #content: EndpointContentPort = Object.freeze({
|
|
387
409
|
resolve: (reference: ConversationReference, context: EndpointContentResolveContext) =>
|
|
388
410
|
resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
|
|
389
411
|
});
|
|
390
412
|
|
|
391
413
|
constructor(options: DiscordInteractionsEndpointOptions) {
|
|
392
|
-
super();
|
|
414
|
+
super(options.config.token, options.fetch ?? globalThis.fetch);
|
|
393
415
|
this.#logger = getAdapterLogger('discord', options.config.id);
|
|
394
416
|
this.#options = options;
|
|
395
417
|
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
396
|
-
this
|
|
418
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
419
|
+
start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
420
|
+
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
|
|
421
|
+
control: this.#control, content: this.#content,
|
|
422
|
+
});
|
|
397
423
|
}
|
|
398
424
|
|
|
399
425
|
get isOpen(): boolean {
|
|
@@ -404,7 +430,7 @@ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
|
|
|
404
430
|
return this.#options.config;
|
|
405
431
|
}
|
|
406
432
|
|
|
407
|
-
async
|
|
433
|
+
async #startFramework(): Promise<void> {
|
|
408
434
|
if (this.#started) return;
|
|
409
435
|
this.#started = true;
|
|
410
436
|
this.#routeReleases.push(...registerDiscordInteractionRoutes(this.#options.http, this));
|
|
@@ -416,37 +442,39 @@ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
|
|
|
416
442
|
}));
|
|
417
443
|
}
|
|
418
444
|
|
|
419
|
-
|
|
445
|
+
#openFramework(): void {
|
|
420
446
|
this.#open = true;
|
|
421
447
|
}
|
|
422
448
|
|
|
423
|
-
|
|
449
|
+
#closeFramework(): void {
|
|
424
450
|
this.#open = false;
|
|
425
451
|
}
|
|
426
452
|
|
|
427
|
-
async
|
|
453
|
+
async #stopFramework(): Promise<void> {
|
|
428
454
|
this.#open = false;
|
|
429
455
|
for (const release of this.#routeReleases.splice(0)) release();
|
|
430
456
|
this.#started = false;
|
|
431
457
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
432
458
|
}
|
|
433
459
|
|
|
434
|
-
async
|
|
460
|
+
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
435
461
|
const body = formatOutboundBody(payload);
|
|
436
|
-
const data = await this.
|
|
462
|
+
const data = await this.createMessage({ channelId: conversation.id, body, signal });
|
|
437
463
|
return data.id ?? '';
|
|
438
464
|
}
|
|
439
465
|
|
|
440
|
-
async recallMessage(message:
|
|
466
|
+
async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
|
|
441
467
|
if (!message.id) return;
|
|
442
|
-
await this.
|
|
468
|
+
await this.deleteMessage({ channelId: message.conversation.id, messageId: message.id, signal });
|
|
443
469
|
}
|
|
444
470
|
|
|
445
471
|
admit(msg: DiscordInboundMessage): void {
|
|
446
472
|
if (!this.#open) return;
|
|
447
473
|
const conversation = discordInboundConversation(String(this.#options.id), msg);
|
|
448
|
-
void this.emit('message.receive', {
|
|
474
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
449
475
|
conversation,
|
|
476
|
+
event: msg.event,
|
|
477
|
+
platformSentAt: msg.platformSentAt,
|
|
450
478
|
message: { conversation, id: msg.id },
|
|
451
479
|
content: formatInboundContent(msg),
|
|
452
480
|
segments: formatInboundSegments(msg),
|
|
@@ -475,7 +503,9 @@ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
|
|
|
475
503
|
admitButton(interaction: DiscordButtonInbound): void {
|
|
476
504
|
if (!this.#open) return;
|
|
477
505
|
const conversation = discordInboundConversation(String(this.#options.id), interaction);
|
|
478
|
-
void this.emit('message.receive', {
|
|
506
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
507
|
+
event: interaction.event,
|
|
508
|
+
platformSentAt: interaction.platformSentAt,
|
|
479
509
|
conversation,
|
|
480
510
|
message: { conversation, id: interaction.id },
|
|
481
511
|
content: formatButtonContent(interaction),
|
|
@@ -499,7 +529,7 @@ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
|
|
|
499
529
|
admitPlatform(event: Record<string, unknown>): void {
|
|
500
530
|
if (!this.#open) return;
|
|
501
531
|
const type = typeof event.type === 'number' ? `interaction.${event.type}` : 'interaction';
|
|
502
|
-
void this.
|
|
532
|
+
void this[endpointSpec].events.platform(type, event).catch((error) => {
|
|
503
533
|
this.#logger.warn(formatCompact({
|
|
504
534
|
op: 'discord_platform_event_failed',
|
|
505
535
|
event: type,
|
|
@@ -560,16 +590,6 @@ async function resolveDiscordContent(
|
|
|
560
590
|
}
|
|
561
591
|
}
|
|
562
592
|
|
|
563
|
-
/**
|
|
564
|
-
* Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
|
|
565
|
-
* Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
|
|
566
|
-
* 并以字符串回传给 listGroupMembers,因此这里保留原始字符串(仅按契约
|
|
567
|
-
* 类型声明强转),是全链路最不丢信息的方案。
|
|
568
|
-
*/
|
|
569
|
-
function toGroupId(id: string): number {
|
|
570
|
-
return id as unknown as number;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
593
|
/**
|
|
574
594
|
* DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
|
|
575
595
|
* 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
|
|
@@ -578,38 +598,43 @@ export function createDiscordEndpointManagement(
|
|
|
578
598
|
requireClient: () => DiscordClientTransport,
|
|
579
599
|
): EndpointManagement {
|
|
580
600
|
return Object.freeze<EndpointManagement>({
|
|
581
|
-
async listGroups(): Promise<readonly EndpointGroup[]> {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
}
|
|
590
|
-
return groups;
|
|
591
|
-
},
|
|
592
|
-
async listChannels(): Promise<readonly EndpointChannel[]> {
|
|
593
|
-
const channels: EndpointChannel[] = [];
|
|
594
|
-
for (const guild of requireClient().guilds.cache.values()) {
|
|
595
|
-
if (!guild?.id) continue;
|
|
596
|
-
const guildId = String(guild.id);
|
|
597
|
-
const guildName = String(guild.name ?? guildId);
|
|
598
|
-
for (const channel of guild.channels?.cache?.values() ?? []) {
|
|
599
|
-
if (!channel?.id) continue;
|
|
600
|
-
if (channel.type !== ChannelType.GuildText) continue;
|
|
601
|
-
channels.push({
|
|
602
|
-
id: String(channel.id),
|
|
603
|
-
name: channel.name ? String(channel.name) : undefined,
|
|
604
|
-
parent: { type: 'guild', id: guildId, name: guildName },
|
|
601
|
+
async listGroups(request: EndpointManagementRequest = {}): Promise<readonly EndpointGroup[]> {
|
|
602
|
+
return createEndpointOperation(request).run(() => {
|
|
603
|
+
const groups: EndpointGroup[] = [];
|
|
604
|
+
for (const guild of requireClient().guilds.cache.values()) {
|
|
605
|
+
if (!guild?.id) continue;
|
|
606
|
+
groups.push({
|
|
607
|
+
group_id: String(guild.id),
|
|
608
|
+
name: String(guild.name ?? guild.id),
|
|
605
609
|
});
|
|
606
610
|
}
|
|
607
|
-
|
|
608
|
-
|
|
611
|
+
return groups;
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
async listChannels(request: EndpointManagementRequest = {}): Promise<readonly EndpointChannel[]> {
|
|
615
|
+
return createEndpointOperation(request).run(() => {
|
|
616
|
+
const channels: EndpointChannel[] = [];
|
|
617
|
+
for (const guild of requireClient().guilds.cache.values()) {
|
|
618
|
+
if (!guild?.id) continue;
|
|
619
|
+
const guildId = String(guild.id);
|
|
620
|
+
const guildName = String(guild.name ?? guildId);
|
|
621
|
+
for (const channel of guild.channels?.cache?.values() ?? []) {
|
|
622
|
+
if (!channel?.id) continue;
|
|
623
|
+
if (channel.type !== ChannelType.GuildText) continue;
|
|
624
|
+
channels.push({
|
|
625
|
+
id: String(channel.id),
|
|
626
|
+
name: channel.name ? String(channel.name) : undefined,
|
|
627
|
+
parent: { type: 'guild', id: guildId, name: guildName },
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return channels;
|
|
632
|
+
});
|
|
609
633
|
},
|
|
610
|
-
async listGroupMembers(groupId:
|
|
611
|
-
const
|
|
612
|
-
const
|
|
634
|
+
async listGroupMembers({ groupId, signal }: EndpointGroupRequest): Promise<readonly unknown[]> {
|
|
635
|
+
const operation = createEndpointOperation({ signal });
|
|
636
|
+
const guild = await operation.run(() => requireClient().guilds.fetch(groupId));
|
|
637
|
+
const members = await operation.run(() => guild.members.fetch({ limit: 100 })) as Map<string, {
|
|
613
638
|
id: string;
|
|
614
639
|
user: { username: string };
|
|
615
640
|
nickname: string | null;
|