@zhin.js/adapter-discord 1.1.18 → 1.1.20

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 (43) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +10 -3
  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.js +9 -0
  18. package/lib/console.d.ts +1 -7
  19. package/lib/console.js +2 -5
  20. package/lib/endpoint.d.ts +36 -32
  21. package/lib/endpoint.js +174 -170
  22. package/lib/gateway.d.ts +6 -124
  23. package/lib/gateway.js +88 -63
  24. package/lib/index.d.ts +4 -3
  25. package/lib/index.js +3 -2
  26. package/lib/protocol.d.ts +4 -0
  27. package/lib/protocol.js +1 -0
  28. package/lib/webhook.d.ts +2 -3
  29. package/lib/webhook.js +42 -30
  30. package/package.json +18 -18
  31. package/plugin.js +1 -4
  32. package/src/adapter.ts +38 -0
  33. package/src/client.ts +6 -12
  34. package/src/console-spec.ts +9 -0
  35. package/src/console.ts +2 -6
  36. package/src/endpoint.ts +197 -172
  37. package/src/gateway.ts +103 -151
  38. package/src/index.ts +2 -3
  39. package/src/protocol.ts +5 -0
  40. package/src/webhook.ts +42 -32
  41. package/lib/discord-runtime-state.d.ts +0 -1
  42. package/lib/discord-runtime-state.js +0 -6
  43. package/src/discord-runtime-state.ts +0 -7
package/lib/endpoint.js CHANGED
@@ -1,124 +1,103 @@
1
- import { createDiscordRestApiAgent } from './rest-api-proxy.js';
2
1
  /**
3
2
  * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
4
3
  */
5
- import { ChannelType } from 'discord.js';
6
- import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
4
+ import { Client, ChannelType } from 'discord.js';
5
+ import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createEndpointOperation, } from 'zhin.js/adapter';
7
6
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
- import { connectDiscordGatewayClient, defaultCreateClient, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
7
+ import { connectDiscordGatewayClient, discordClientOptions, DEFAULT_INTENTS, resolveSenderRole, toMessageCreateOptions, } from './gateway.js';
9
8
  import { discordInboundConversation, formatButtonContent, formatButtonSegments, formatInboundContent, formatInboundSegments, formatOutboundBody, senderDisplayName, } from './protocol.js';
10
9
  import { registerDiscordInteractionRoutes } from './webhook.js';
11
10
  import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
12
11
  const DISCORD_API = 'https://discord.com/api/v10';
13
12
  /** 出站 HTTP 调用统一 30s 超时。 */
14
13
  const OUTBOUND_TIMEOUT_MS = 30_000;
15
- export class DiscordGatewayEndpoint extends Endpoint {
14
+ export class DiscordGatewayEndpoint extends Client {
15
+ [endpointSpec];
16
16
  #logger;
17
17
  #options;
18
- #createClient;
19
18
  #fetch;
20
- #client = null;
19
+ #connection;
20
+ #sdkDisposal;
21
21
  #open = false;
22
22
  #lifecycle = createEndpointLifecycle({ name: 'discord', reconnect: false });
23
23
  #cleanup;
24
24
  #retiring;
25
- management = createDiscordEndpointManagement(() => this.#requireClient());
26
- control = Object.freeze({
27
- recall: (message) => this.recallMessage(message),
28
- addReaction: async (message, emoji, hint) => {
29
- const channelId = hint?.channelId ?? message.conversation.id;
30
- if (!channelId || !message.id)
31
- return null;
32
- await this.#addReaction(channelId, message.id, emoji);
33
- return emoji;
34
- },
25
+ #management = createDiscordEndpointManagement(() => this.#requireClient());
26
+ #control = Object.freeze({
27
+ recall: request => this.#recallMessage(request),
28
+ addReaction: request => this.#addReaction(request),
35
29
  });
36
- content = Object.freeze({
30
+ #content = Object.freeze({
37
31
  resolve: (reference, context) => resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
38
32
  });
39
33
  constructor(options) {
40
- super();
34
+ super(discordClientOptions(options.config.intents?.length ? options.config.intents : DEFAULT_INTENTS, options.config.gatewayFaultProxyUrl, options.restApiAgent ? { agent: options.restApiAgent } : {}));
41
35
  this.#logger = getAdapterLogger('discord', options.config.id);
42
36
  this.#options = options;
43
- this.#createClient = options.createClient ?? defaultCreateClient;
44
37
  this.#fetch = options.fetch ?? globalThis.fetch;
38
+ const endpoint = this;
39
+ this[endpointSpec] = createEndpointSpecification(this, {
40
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
41
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
42
+ get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
43
+ management: this.#management, control: this.#control, content: this.#content,
44
+ });
45
45
  }
46
- /** The actual discord.js-compatible client used by this connection. */
47
- get client() {
48
- return this.#requireClient();
49
- }
50
- get transportState() {
46
+ get #transportState() {
51
47
  if (this.#lifecycle.state !== 'open')
52
48
  return this.#lifecycle.state;
53
- if (!this.#client)
49
+ if (!this.#connection)
54
50
  return 'closed';
55
- if (!this.#client.isReady)
56
- return this.#lifecycle.state;
57
- return this.#client.isReady() ? 'open' : 'reconnecting';
51
+ return this.isReady() ? 'open' : 'reconnecting';
58
52
  }
59
- async start() {
53
+ async #startFramework(executionSignal) {
60
54
  await this.#retiring;
61
55
  await this.#lifecycle.start(async (handle) => {
62
56
  let cleanup;
63
57
  let cancelled = false;
64
58
  try {
65
- const intents = this.#options.config.intents?.length
66
- ? [...this.#options.config.intents]
67
- : DEFAULT_INTENTS;
68
- const restAgent = this.#options.config.restApiProxy ? createDiscordRestApiAgent(this.#options.config.restApiProxy) : undefined;
69
- let client;
70
- try {
71
- client = this.#createClient(intents, this.#options.config.gatewayFaultProxyUrl, restAgent ? { agent: restAgent } : undefined);
72
- }
73
- catch (error) {
74
- await restAgent?.destroyProxy();
75
- throw error;
76
- }
77
- this.#client = client;
59
+ const client = this;
78
60
  const abort = new AbortController();
79
61
  let stopping;
62
+ this.#connection = abort;
80
63
  cleanup = () => {
81
64
  cancelled = true;
82
65
  abort.abort();
83
- if (this.#client === client)
84
- this.#client = null;
85
- return stopping ??= (async () => { client.removeAllListeners(); try {
86
- await client.destroy();
87
- }
88
- finally {
89
- await restAgent?.destroyProxy();
90
- } })();
66
+ if (this.#connection === abort)
67
+ this.#connection = undefined;
68
+ return stopping ??= this.#disposeSdk();
91
69
  };
92
70
  this.#cleanup = cleanup;
93
71
  handle.onForceClose(() => { void cleanup?.().catch(() => { }); });
94
72
  await connectDiscordGatewayClient(client, this.#options.config, {
73
+ receive: action => this[endpointSpec].events.run(action),
95
74
  onPlatformEvent: (name, event) => {
96
- if (this.#client !== client)
75
+ if (this.#connection !== abort)
97
76
  return;
98
77
  void this.#emitPlatformEvent(name, event);
99
78
  },
100
- onMessage: (msg) => { if (this.#client === client)
79
+ onMessage: (msg) => { if (this.#connection === abort)
101
80
  return this.admit(msg); },
102
- onButton: (interaction) => { if (this.#client === client)
81
+ onButton: (interaction) => { if (this.#connection === abort)
103
82
  return this.admitButton(interaction); },
104
83
  onGuildMemberAdd: (member) => {
105
- if (this.#client !== client)
84
+ if (this.#connection !== abort)
106
85
  return;
107
- receiveDiscordGuildMemberSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, 'member_increase', member, this.#logger);
86
+ receiveDiscordGuildMemberSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), this.#options.config.id, 'member_increase', member, this.#logger);
108
87
  },
109
88
  onGuildMemberRemove: (member) => {
110
- if (this.#client !== client)
89
+ if (this.#connection !== abort)
111
90
  return;
112
- receiveDiscordGuildMemberSideEvent((name, payload) => this.emit(name, payload), this.#options.config.id, 'member_decrease', member, this.#logger);
91
+ receiveDiscordGuildMemberSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), this.#options.config.id, 'member_decrease', member, this.#logger);
113
92
  },
114
- }, abort.signal);
115
- if (abort.signal.aborted || this.#client !== client)
93
+ }, AbortSignal.any([abort.signal, executionSignal]));
94
+ if (abort.signal.aborted || this.#connection !== abort)
116
95
  return;
117
96
  this.#logger.info(formatCompact({
118
97
  op: 'connect',
119
98
  endpoint: this.#options.config.id,
120
99
  mode: 'gateway',
121
- user: this.#client.user?.tag,
100
+ user: this.user?.tag,
122
101
  }));
123
102
  }
124
103
  catch (error) {
@@ -134,30 +113,27 @@ export class DiscordGatewayEndpoint extends Endpoint {
134
113
  }
135
114
  });
136
115
  }
137
- open() {
116
+ #openFramework() {
138
117
  this.#open = true;
139
118
  }
140
- close() {
119
+ #closeFramework() {
141
120
  this.#open = false;
142
121
  }
143
- async stop() {
122
+ async #stopFramework() {
144
123
  this.#open = false;
145
124
  const cleanup = this.#cleanup;
146
125
  this.#cleanup = undefined;
147
126
  this.#lifecycle.stop();
148
- const retiring = this.#retiring ?? Promise.resolve().then(async () => { try {
149
- await cleanup?.();
150
- }
151
- catch { /* best-effort SDK shutdown */ } });
127
+ const retiring = this.#retiring ?? Promise.resolve().then(() => cleanup ? cleanup() : this.#disposeSdk());
152
128
  this.#retiring = retiring;
153
129
  await retiring;
154
130
  if (this.#retiring === retiring)
155
131
  this.#retiring = undefined;
156
132
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
157
133
  }
158
- async send({ conversation, payload }) {
134
+ async #sendFramework({ conversation, payload, signal }) {
159
135
  const body = formatOutboundBody(payload);
160
- const snowflake = await this.#sendBody(conversation.id, body);
136
+ const snowflake = await this.#sendBody({ channelId: conversation.id, body, signal });
161
137
  this.#logger.debug(formatCompact({
162
138
  op: 'discord_send',
163
139
  endpoint: this.#options.config.id,
@@ -166,14 +142,15 @@ export class DiscordGatewayEndpoint extends Endpoint {
166
142
  }));
167
143
  return snowflake;
168
144
  }
169
- async recallMessage(message) {
145
+ async #recallMessage({ message, signal }) {
170
146
  if (!message.id)
171
147
  return;
172
- const channel = await this.#requireClient().channels.fetch(message.conversation.id);
148
+ const operation = createEndpointOperation({ signal });
149
+ const channel = await operation.prepare(() => this.#requireClient().channels.fetch(message.conversation.id));
173
150
  if (!channel?.isTextBased() || !channel.messages)
174
151
  return;
175
- const msg = await channel.messages.fetch(message.id);
176
- await msg.delete();
152
+ const msg = await operation.prepare(() => channel.messages.fetch(message.id));
153
+ await operation.run(() => msg.delete());
177
154
  }
178
155
  /** Test / internal: admit a message when open. */
179
156
  admit(msg) {
@@ -182,8 +159,10 @@ export class DiscordGatewayEndpoint extends Endpoint {
182
159
  if (msg.authorBot)
183
160
  return;
184
161
  const conversation = discordInboundConversation(String(this.#options.id), msg);
185
- void this.emit('message.receive', {
162
+ void this[endpointSpec].events.emit('message.receive', {
186
163
  conversation,
164
+ event: msg.event,
165
+ platformSentAt: msg.platformSentAt,
187
166
  message: { conversation, id: msg.id },
188
167
  content: formatInboundContent(msg),
189
168
  segments: formatInboundSegments(msg),
@@ -214,7 +193,9 @@ export class DiscordGatewayEndpoint extends Endpoint {
214
193
  if (!this.#open)
215
194
  return;
216
195
  const conversation = discordInboundConversation(String(this.#options.id), interaction);
217
- void this.emit('message.receive', {
196
+ void this[endpointSpec].events.emit('message.receive', {
197
+ event: interaction.event,
198
+ platformSentAt: interaction.platformSentAt,
218
199
  conversation,
219
200
  message: { conversation, id: interaction.id },
220
201
  content: formatButtonContent(interaction),
@@ -234,16 +215,20 @@ export class DiscordGatewayEndpoint extends Endpoint {
234
215
  }));
235
216
  });
236
217
  }
237
- async #addReaction(channelId, messageId, emoji) {
238
- const channel = await this.#requireClient().channels.fetch(channelId);
239
- if (!channel?.isTextBased() || !channel.messages) {
218
+ async #addReaction({ message, emoji, hint, signal }) {
219
+ const channelId = hint?.channelId ?? message.conversation.id;
220
+ if (!channelId || !message.id)
221
+ return null;
222
+ const operation = createEndpointOperation({ signal });
223
+ const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
224
+ if (!channel?.isTextBased() || !channel.messages)
240
225
  throw new Error(`Channel ${channelId} 不是文本频道`);
241
- }
242
- const message = await channel.messages.fetch(messageId);
243
- await message.react(emoji);
226
+ const native = await operation.prepare(() => channel.messages.fetch(message.id));
227
+ await operation.run(() => native.react(emoji));
228
+ return emoji;
244
229
  }
245
230
  async #emitPlatformEvent(name, event) {
246
- await this.emitPlatform(name, event).catch((error) => {
231
+ await this[endpointSpec].events.platform(name, event).catch((error) => {
247
232
  this.#logger.warn(formatCompact({
248
233
  op: 'discord_platform_event_failed',
249
234
  event: name,
@@ -251,17 +236,18 @@ export class DiscordGatewayEndpoint extends Endpoint {
251
236
  }));
252
237
  });
253
238
  }
254
- async #sendBody(channelId, body) {
239
+ async #sendBody({ channelId, body, signal }) {
240
+ const operation = createEndpointOperation({ signal });
255
241
  let stage = 'fetch_channel';
256
242
  try {
257
- const channel = await this.#requireClient().channels.fetch(channelId);
258
- if (!channel || !channel.isTextBased() || !channel.send) {
243
+ const channel = await operation.prepare(() => this.#requireClient().channels.fetch(channelId));
244
+ if (!channel || !channel.isSendable()) {
259
245
  throw new Error(`Channel ${channelId} is not a text channel`);
260
246
  }
261
247
  stage = 'render';
262
- const options = await toMessageCreateOptions(body);
248
+ const options = await operation.prepare(() => toMessageCreateOptions(body));
263
249
  stage = 'send';
264
- const result = await channel.send(options);
250
+ const result = await operation.run(async () => channel.send(options));
265
251
  return result.id;
266
252
  }
267
253
  catch (error) {
@@ -273,10 +259,22 @@ export class DiscordGatewayEndpoint extends Endpoint {
273
259
  throw error;
274
260
  }
275
261
  }
262
+ #disposeSdk() {
263
+ this.#sdkDisposal ??= Promise.resolve().then(async () => {
264
+ this.removeAllListeners();
265
+ try {
266
+ await super.destroy();
267
+ }
268
+ finally {
269
+ await this.#options.restApiAgent?.destroyProxy();
270
+ }
271
+ });
272
+ return this.#sdkDisposal;
273
+ }
276
274
  #requireClient() {
277
- if (!this.#client)
275
+ if (!this.#connection)
278
276
  throw new Error('Discord client not connected');
279
- return this.#client;
277
+ return this;
280
278
  }
281
279
  }
282
280
  /** Minimal Discord REST client used when Gateway is intentionally disabled. */
@@ -287,49 +285,55 @@ export class DiscordRestClient {
287
285
  this.token = token;
288
286
  this.fetch = fetch;
289
287
  }
290
- async request(method, path, body) {
291
- const response = await this.fetch(`${DISCORD_API}${path}`, {
292
- method,
293
- headers: {
294
- Authorization: `Bot ${this.token}`,
295
- ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
296
- },
297
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
298
- signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
299
- });
300
- const text = await response.text();
301
- if (!response.ok) {
302
- throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
303
- }
304
- return (text ? JSON.parse(text) : undefined);
305
- }
306
- createMessage(channelId, body) {
307
- return this.request('POST', `/channels/${channelId}/messages`, body);
308
- }
309
- deleteMessage(channelId, messageId) {
310
- return this.request('DELETE', `/channels/${channelId}/messages/${messageId}`);
288
+ async request({ method, path, body, signal }) {
289
+ const deadline = AbortSignal.timeout(OUTBOUND_TIMEOUT_MS);
290
+ const operationSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
291
+ const operation = createEndpointOperation({ signal: operationSignal });
292
+ const execute = async () => {
293
+ const response = await this.fetch(`${DISCORD_API}${path}`, {
294
+ method,
295
+ headers: { Authorization: `Bot ${this.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
296
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
297
+ signal: operationSignal,
298
+ });
299
+ const text = await response.text();
300
+ if (!response.ok)
301
+ throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
302
+ return (text ? JSON.parse(text) : undefined);
303
+ };
304
+ return operation.run(execute);
305
+ }
306
+ createMessage({ channelId, body, signal }) {
307
+ return this.request({ method: 'POST', path: `/channels/${channelId}/messages`, body, signal });
308
+ }
309
+ deleteMessage({ channelId, messageId, signal }) {
310
+ return this.request({ method: 'DELETE', path: `/channels/${channelId}/messages/${messageId}`, signal });
311
311
  }
312
312
  }
313
- export class DiscordInteractionsEndpoint extends Endpoint {
314
- client;
313
+ export class DiscordInteractionsEndpoint extends DiscordRestClient {
314
+ [endpointSpec];
315
315
  #logger;
316
316
  #options;
317
317
  #fetch;
318
318
  #routeReleases = [];
319
319
  #open = false;
320
320
  #started = false;
321
- control = Object.freeze({
322
- recall: (message) => this.recallMessage(message),
321
+ #control = Object.freeze({
322
+ recall: request => this.#recallMessage(request),
323
323
  });
324
- content = Object.freeze({
324
+ #content = Object.freeze({
325
325
  resolve: (reference, context) => resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
326
326
  });
327
327
  constructor(options) {
328
- super();
328
+ super(options.config.token, options.fetch ?? globalThis.fetch);
329
329
  this.#logger = getAdapterLogger('discord', options.config.id);
330
330
  this.#options = options;
331
331
  this.#fetch = options.fetch ?? globalThis.fetch;
332
- this.client = new DiscordRestClient(options.config.token, this.#fetch);
332
+ this[endpointSpec] = createEndpointSpecification(this, {
333
+ start: () => this.#startFramework(), open: () => this.#openFramework(), close: () => this.#closeFramework(),
334
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
335
+ control: this.#control, content: this.#content,
336
+ });
333
337
  }
334
338
  get isOpen() {
335
339
  return this.#open;
@@ -337,7 +341,7 @@ export class DiscordInteractionsEndpoint extends Endpoint {
337
341
  get config() {
338
342
  return this.#options.config;
339
343
  }
340
- async start() {
344
+ async #startFramework() {
341
345
  if (this.#started)
342
346
  return;
343
347
  this.#started = true;
@@ -349,35 +353,37 @@ export class DiscordInteractionsEndpoint extends Endpoint {
349
353
  path: this.#options.config.interactionsPath,
350
354
  }));
351
355
  }
352
- open() {
356
+ #openFramework() {
353
357
  this.#open = true;
354
358
  }
355
- close() {
359
+ #closeFramework() {
356
360
  this.#open = false;
357
361
  }
358
- async stop() {
362
+ async #stopFramework() {
359
363
  this.#open = false;
360
364
  for (const release of this.#routeReleases.splice(0))
361
365
  release();
362
366
  this.#started = false;
363
367
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
364
368
  }
365
- async send({ conversation, payload }) {
369
+ async #sendFramework({ conversation, payload, signal }) {
366
370
  const body = formatOutboundBody(payload);
367
- const data = await this.client.createMessage(conversation.id, body);
371
+ const data = await this.createMessage({ channelId: conversation.id, body, signal });
368
372
  return data.id ?? '';
369
373
  }
370
- async recallMessage(message) {
374
+ async #recallMessage({ message, signal }) {
371
375
  if (!message.id)
372
376
  return;
373
- await this.client.deleteMessage(message.conversation.id, message.id);
377
+ await this.deleteMessage({ channelId: message.conversation.id, messageId: message.id, signal });
374
378
  }
375
379
  admit(msg) {
376
380
  if (!this.#open)
377
381
  return;
378
382
  const conversation = discordInboundConversation(String(this.#options.id), msg);
379
- void this.emit('message.receive', {
383
+ void this[endpointSpec].events.emit('message.receive', {
380
384
  conversation,
385
+ event: msg.event,
386
+ platformSentAt: msg.platformSentAt,
381
387
  message: { conversation, id: msg.id },
382
388
  content: formatInboundContent(msg),
383
389
  segments: formatInboundSegments(msg),
@@ -406,7 +412,9 @@ export class DiscordInteractionsEndpoint extends Endpoint {
406
412
  if (!this.#open)
407
413
  return;
408
414
  const conversation = discordInboundConversation(String(this.#options.id), interaction);
409
- void this.emit('message.receive', {
415
+ void this[endpointSpec].events.emit('message.receive', {
416
+ event: interaction.event,
417
+ platformSentAt: interaction.platformSentAt,
410
418
  conversation,
411
419
  message: { conversation, id: interaction.id },
412
420
  content: formatButtonContent(interaction),
@@ -430,7 +438,7 @@ export class DiscordInteractionsEndpoint extends Endpoint {
430
438
  if (!this.#open)
431
439
  return;
432
440
  const type = typeof event.type === 'number' ? `interaction.${event.type}` : 'interaction';
433
- void this.emitPlatform(type, event).catch((error) => {
441
+ void this[endpointSpec].events.platform(type, event).catch((error) => {
434
442
  this.#logger.warn(formatCompact({
435
443
  op: 'discord_platform_event_failed',
436
444
  event: type,
@@ -488,57 +496,53 @@ async function resolveDiscordContent(fetch, token, reference, context) {
488
496
  return Object.freeze({ status: 'failed', code: 'discord_content_resolution_failed', message: error instanceof Error ? error.message : String(error) });
489
497
  }
490
498
  }
491
- /**
492
- * Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
493
- * Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
494
- * 并以字符串回传给 listGroupMembers,因此这里保留原始字符串(仅按契约
495
- * 类型声明强转),是全链路最不丢信息的方案。
496
- */
497
- function toGroupId(id) {
498
- return id;
499
- }
500
499
  /**
501
500
  * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
502
501
  * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
503
502
  */
504
503
  export function createDiscordEndpointManagement(requireClient) {
505
504
  return Object.freeze({
506
- async listGroups() {
507
- const groups = [];
508
- for (const guild of requireClient().guilds.cache.values()) {
509
- if (!guild?.id)
510
- continue;
511
- groups.push({
512
- group_id: toGroupId(String(guild.id)),
513
- name: String(guild.name ?? guild.id),
514
- });
515
- }
516
- return groups;
517
- },
518
- async listChannels() {
519
- const channels = [];
520
- for (const guild of requireClient().guilds.cache.values()) {
521
- if (!guild?.id)
522
- continue;
523
- const guildId = String(guild.id);
524
- const guildName = String(guild.name ?? guildId);
525
- for (const channel of guild.channels?.cache?.values() ?? []) {
526
- if (!channel?.id)
505
+ async listGroups(request = {}) {
506
+ return createEndpointOperation(request).run(() => {
507
+ const groups = [];
508
+ for (const guild of requireClient().guilds.cache.values()) {
509
+ if (!guild?.id)
527
510
  continue;
528
- if (channel.type !== ChannelType.GuildText)
529
- continue;
530
- channels.push({
531
- id: String(channel.id),
532
- name: channel.name ? String(channel.name) : undefined,
533
- parent: { type: 'guild', id: guildId, name: guildName },
511
+ groups.push({
512
+ group_id: String(guild.id),
513
+ name: String(guild.name ?? guild.id),
534
514
  });
535
515
  }
536
- }
537
- return channels;
516
+ return groups;
517
+ });
518
+ },
519
+ async listChannels(request = {}) {
520
+ return createEndpointOperation(request).run(() => {
521
+ const channels = [];
522
+ for (const guild of requireClient().guilds.cache.values()) {
523
+ if (!guild?.id)
524
+ continue;
525
+ const guildId = String(guild.id);
526
+ const guildName = String(guild.name ?? guildId);
527
+ for (const channel of guild.channels?.cache?.values() ?? []) {
528
+ if (!channel?.id)
529
+ continue;
530
+ if (channel.type !== ChannelType.GuildText)
531
+ continue;
532
+ channels.push({
533
+ id: String(channel.id),
534
+ name: channel.name ? String(channel.name) : undefined,
535
+ parent: { type: 'guild', id: guildId, name: guildName },
536
+ });
537
+ }
538
+ }
539
+ return channels;
540
+ });
538
541
  },
539
- async listGroupMembers(groupId) {
540
- const guild = await requireClient().guilds.fetch(groupId);
541
- const members = await guild.members.fetch({ limit: 100 });
542
+ async listGroupMembers({ groupId, signal }) {
543
+ const operation = createEndpointOperation({ signal });
544
+ const guild = await operation.run(() => requireClient().guilds.fetch(groupId));
545
+ const members = await operation.run(() => guild.members.fetch({ limit: 100 }));
542
546
  return [...members.values()].map((member) => ({
543
547
  id: member.id,
544
548
  username: member.user.username,