@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +19 -2
  3. package/adapters/discord/index.js +3 -23
  4. package/adapters/discord/index.ts +2 -27
  5. package/agents/discord/skills/discord/tools/send_embed/index.js +1 -1
  6. package/agents/discord/skills/discord/tools/send_embed/index.ts +1 -1
  7. package/agents/discord/skills/discord-guild/tools/create_thread/index.js +6 -2
  8. package/agents/discord/skills/discord-guild/tools/create_thread/index.ts +5 -2
  9. package/agents/discord/skills/discord-guild/tools/forum_post/index.js +1 -1
  10. package/agents/discord/skills/discord-guild/tools/forum_post/index.ts +1 -1
  11. package/commands/discord/endpoint/definition.js +0 -2
  12. package/commands/discord/endpoint/definition.ts +0 -2
  13. package/lib/adapter.d.ts +14 -0
  14. package/lib/adapter.js +55 -0
  15. package/lib/client.d.ts +9 -9
  16. package/lib/client.js +0 -2
  17. package/lib/console-spec.d.ts +12 -0
  18. package/lib/console-spec.js +19 -0
  19. package/lib/console.d.ts +2 -0
  20. package/lib/console.js +13 -0
  21. package/lib/endpoint.d.ts +36 -32
  22. package/lib/endpoint.js +174 -170
  23. package/lib/gateway.d.ts +6 -124
  24. package/lib/gateway.js +88 -63
  25. package/lib/index.d.ts +4 -3
  26. package/lib/index.js +3 -2
  27. package/lib/protocol.d.ts +4 -0
  28. package/lib/protocol.js +1 -0
  29. package/lib/webhook.d.ts +2 -3
  30. package/lib/webhook.js +42 -30
  31. package/package.json +27 -19
  32. package/pages/index/index.js +15 -0
  33. package/pages/index/index.ts +16 -0
  34. package/plugin.js +3 -4
  35. package/src/adapter.ts +38 -0
  36. package/src/client.ts +6 -12
  37. package/src/console-spec.ts +24 -0
  38. package/src/console.ts +17 -0
  39. package/src/endpoint.ts +197 -172
  40. package/src/gateway.ts +103 -151
  41. package/src/index.ts +2 -3
  42. package/src/protocol.ts +5 -0
  43. package/src/webhook.ts +42 -32
  44. package/lib/discord-runtime-state.d.ts +0 -1
  45. package/lib/discord-runtime-state.js +0 -6
  46. 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
- Endpoint, createEndpointLifecycle,
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
- defaultCreateClient,
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 createClient?: CreateDiscordClient;
68
+ readonly restApiAgent?: ReturnType<typeof createDiscordRestApiAgent>;
63
69
  readonly fetch?: typeof globalThis.fetch;
64
70
  }
65
71
 
66
- export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
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
- #client: DiscordClientTransport | null = null;
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: (message: MessageRef) => this.recallMessage(message),
82
- addReaction: async (
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
- /** The actual discord.js-compatible client used by this connection. */
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.#client) return 'closed';
114
- if (!this.#client.isReady) return this.#lifecycle.state;
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 start(): Promise<void> {
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 intents = this.#options.config.intents?.length
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.#client === client) this.#client = null;
136
- return stopping ??= (async () => { client.removeAllListeners(); try { await client.destroy(); } finally { await restAgent?.destroyProxy(); } })();
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.#client !== client) return;
136
+ if (this.#connection !== abort) return;
143
137
  void this.#emitPlatformEvent(name, event);
144
138
  },
145
- onMessage: (msg) => { if (this.#client === client) return this.admit(msg); },
146
- onButton: (interaction) => { if (this.#client === client) return this.admitButton(interaction); },
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.#client !== client) return;
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.#client !== client) return;
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.#client !== client) return;
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.#client.user?.tag,
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
- open(): void {
179
+ #openFramework(): void {
186
180
  this.#open = true;
187
181
  }
188
182
 
189
- close(): void {
183
+ #closeFramework(): void {
190
184
  this.#open = false;
191
185
  }
192
186
 
193
- async stop(): Promise<void> {
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(async () => { try { await cleanup?.(); } catch { /* best-effort SDK shutdown */ } });
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 send({ conversation, payload }: EndpointSendRequest): Promise<string> {
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: MessageRef): Promise<void> {
211
+ async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
218
212
  if (!message.id) return;
219
- const channel = await this.#requireClient().channels.fetch(message.conversation.id);
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(channelId: string, messageId: string, emoji: string): Promise<void> {
284
- const channel = await this.#requireClient().channels.fetch(channelId);
285
- if (!channel?.isTextBased() || !channel.messages) {
286
- throw new Error(`Channel ${channelId} 不是文本频道`);
287
- }
288
- const message = await channel.messages.fetch(messageId);
289
- await message.react(emoji);
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.emitPlatform(name, event).catch((error) => {
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: string, body: DiscordOutboundBody): Promise<string> {
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.isTextBased() || !channel.send) {
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.#client) throw new Error('Discord client not connected');
326
- return this.#client;
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
- readonly token: string,
341
- readonly fetch: typeof globalThis.fetch = globalThis.fetch,
342
- ) {}
343
-
344
- async request<T = unknown>(
345
- method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
346
- path: string,
347
- body?: unknown,
348
- ): Promise<T> {
349
- const response = await this.fetch(`${DISCORD_API}${path}`, {
350
- method,
351
- headers: {
352
- Authorization: `Bot ${this.token}`,
353
- ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
354
- },
355
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
356
- signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
357
- });
358
- const text = await response.text();
359
- if (!response.ok) {
360
- throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
361
- }
362
- return (text ? JSON.parse(text) : undefined) as T;
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 Endpoint<DiscordRestClient> {
375
- readonly client: DiscordRestClient;
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: (message: MessageRef) => this.recallMessage(message),
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.client = new DiscordRestClient(options.config.token, this.#fetch);
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 start(): Promise<void> {
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
- open(): void {
445
+ #openFramework(): void {
420
446
  this.#open = true;
421
447
  }
422
448
 
423
- close(): void {
449
+ #closeFramework(): void {
424
450
  this.#open = false;
425
451
  }
426
452
 
427
- async stop(): Promise<void> {
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 send({ conversation, payload }: EndpointSendRequest): Promise<string> {
460
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
435
461
  const body = formatOutboundBody(payload);
436
- const data = await this.client.createMessage(conversation.id, body);
462
+ const data = await this.createMessage({ channelId: conversation.id, body, signal });
437
463
  return data.id ?? '';
438
464
  }
439
465
 
440
- async recallMessage(message: MessageRef): Promise<void> {
466
+ async #recallMessage({ message, signal }: EndpointRecallRequest): Promise<void> {
441
467
  if (!message.id) return;
442
- await this.client.deleteMessage(message.conversation.id, message.id);
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.emitPlatform(type, event).catch((error) => {
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
- const groups: EndpointGroup[] = [];
583
- for (const guild of requireClient().guilds.cache.values()) {
584
- if (!guild?.id) continue;
585
- groups.push({
586
- group_id: toGroupId(String(guild.id)),
587
- name: String(guild.name ?? guild.id),
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
- return channels;
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: string): Promise<readonly unknown[]> {
611
- const guild = await requireClient().guilds.fetch(groupId);
612
- const members = await guild.members.fetch({ limit: 100 }) as Map<string, {
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;