@zhin.js/adapter-discord 1.0.87 → 1.1.2

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 (91) hide show
  1. package/CHANGELOG.md +949 -66
  2. package/PERMITS.md +25 -0
  3. package/README.md +60 -67
  4. package/adapters/discord/index.js +41 -0
  5. package/adapters/discord/index.ts +57 -0
  6. package/agents/discord/agent.json +20 -0
  7. package/agents/discord/boundaries.md +3 -0
  8. package/agents/discord/conventions.md +3 -0
  9. package/agents/discord/skills/discord/SKILL.md +21 -0
  10. package/agents/discord/skills/discord/tools/add_role/index.js +24 -0
  11. package/agents/discord/skills/discord/tools/add_role/index.ts +26 -0
  12. package/agents/discord/skills/discord/tools/create_thread/index.js +29 -0
  13. package/agents/discord/skills/discord/tools/create_thread/index.ts +28 -0
  14. package/agents/discord/skills/discord/tools/forum_post/index.js +33 -0
  15. package/agents/discord/skills/discord/tools/forum_post/index.ts +33 -0
  16. package/agents/discord/skills/discord/tools/list_roles/index.js +28 -0
  17. package/agents/discord/skills/discord/tools/list_roles/index.ts +34 -0
  18. package/agents/discord/skills/discord/tools/react/index.js +24 -0
  19. package/agents/discord/skills/discord/tools/react/index.ts +24 -0
  20. package/agents/discord/skills/discord/tools/remove_role/index.js +24 -0
  21. package/agents/discord/skills/discord/tools/remove_role/index.ts +26 -0
  22. package/agents/discord/skills/discord/tools/send_embed/index.js +43 -0
  23. package/agents/discord/skills/discord/tools/send_embed/index.ts +38 -0
  24. package/agents/discord/system.md +3 -0
  25. package/commands/discord/endpoint/add/[id]/index.js +3 -0
  26. package/commands/discord/endpoint/add/[id]/index.ts +3 -0
  27. package/commands/discord/endpoint/definition.js +19 -0
  28. package/commands/discord/endpoint/definition.ts +19 -0
  29. package/commands/discord/endpoint/list/index.js +3 -0
  30. package/commands/discord/endpoint/list/index.ts +3 -0
  31. package/commands/discord/endpoint/remove/[id]/index.js +3 -0
  32. package/commands/discord/endpoint/remove/[id]/index.ts +3 -0
  33. package/lib/client.d.ts +16 -0
  34. package/lib/client.js +8 -0
  35. package/lib/discord-runtime-state.d.ts +1 -0
  36. package/lib/discord-runtime-state.js +6 -0
  37. package/lib/endpoint.d.ts +72 -0
  38. package/lib/endpoint.js +458 -0
  39. package/lib/gateway.d.ts +144 -0
  40. package/lib/gateway.js +303 -0
  41. package/lib/index.d.ts +6 -18
  42. package/lib/index.js +6 -323
  43. package/lib/platform-permit.d.ts +14 -0
  44. package/lib/platform-permit.js +42 -0
  45. package/lib/protocol.d.ts +145 -0
  46. package/lib/protocol.js +320 -0
  47. package/lib/side-event-dispatch.d.ts +8 -0
  48. package/lib/side-event-dispatch.js +24 -0
  49. package/lib/webhook.d.ts +14 -0
  50. package/lib/webhook.js +88 -0
  51. package/package.json +74 -30
  52. package/plugin.js +19 -0
  53. package/schema.json +205 -0
  54. package/src/client.ts +24 -0
  55. package/src/discord-runtime-state.ts +7 -0
  56. package/src/endpoint.ts +562 -0
  57. package/src/gateway.ts +426 -0
  58. package/src/index.ts +57 -323
  59. package/src/platform-permit.ts +57 -0
  60. package/src/protocol.ts +475 -0
  61. package/src/side-event-dispatch.ts +37 -0
  62. package/src/webhook.ts +123 -0
  63. package/client/Dashboard.tsx +0 -195
  64. package/client/index.tsx +0 -11
  65. package/client/tsconfig.json +0 -7
  66. package/client/utils/api.ts +0 -17
  67. package/dist/index.js +0 -29
  68. package/lib/adapter.d.ts +0 -19
  69. package/lib/adapter.d.ts.map +0 -1
  70. package/lib/adapter.js +0 -90
  71. package/lib/adapter.js.map +0 -1
  72. package/lib/bot-interactions.d.ts +0 -33
  73. package/lib/bot-interactions.d.ts.map +0 -1
  74. package/lib/bot-interactions.js +0 -278
  75. package/lib/bot-interactions.js.map +0 -1
  76. package/lib/bot.d.ts +0 -117
  77. package/lib/bot.d.ts.map +0 -1
  78. package/lib/bot.js +0 -904
  79. package/lib/bot.js.map +0 -1
  80. package/lib/index.d.ts.map +0 -1
  81. package/lib/index.js.map +0 -1
  82. package/lib/types.d.ts +0 -49
  83. package/lib/types.d.ts.map +0 -1
  84. package/lib/types.js +0 -2
  85. package/lib/types.js.map +0 -1
  86. package/plugin.yml +0 -3
  87. package/skills/discord/SKILL.md +0 -104
  88. package/src/adapter.ts +0 -107
  89. package/src/bot-interactions.ts +0 -348
  90. package/src/bot.ts +0 -1037
  91. package/src/types.ts +0 -60
@@ -0,0 +1,562 @@
1
+ import { Endpoint } from 'zhin.js/adapter';
2
+ /**
3
+ * DiscordEndpoint — lifecycle, outbound, admit, gateway / interactions modes, agent tool surface.
4
+ */
5
+ import { ChannelType } from 'discord.js';
6
+ import type {
7
+ EndpointChannel,
8
+ EndpointContentPort,
9
+ EndpointContentResolveContext,
10
+ EndpointControl,
11
+ EndpointGroup,
12
+ EndpointManagement,
13
+ EndpointSendRequest,
14
+ } from 'zhin.js/adapter';
15
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
16
+ import {
17
+ type ConversationReference,
18
+ type ConversationResolution,
19
+ type MessageRef,
20
+ } from '@zhin.js/im-contract';
21
+ import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
22
+ import type { CapabilityId } from 'zhin.js';
23
+ import {
24
+ connectDiscordGatewayClient,
25
+ defaultCreateClient,
26
+ DEFAULT_INTENTS,
27
+ resolveSenderRole,
28
+ toMessageCreateOptions,
29
+ type CreateDiscordClient,
30
+ type DiscordClientTransport,
31
+ } from './gateway.js';
32
+ import {
33
+ discordInboundConversation,
34
+ formatButtonContent,
35
+ formatButtonSegments,
36
+ formatInboundContent,
37
+ formatInboundSegments,
38
+ formatOutboundBody,
39
+ senderDisplayName,
40
+ type DiscordButtonInbound,
41
+ type DiscordInboundMessage,
42
+ type DiscordOutboundBody,
43
+ type ResolvedDiscordGatewayConfig,
44
+ type ResolvedDiscordInteractionsConfig,
45
+ } from './protocol.js';
46
+ import { registerDiscordInteractionRoutes } from './webhook.js';
47
+ import { receiveDiscordGuildMemberSideEvent } from './side-event-dispatch.js';
48
+
49
+ const DISCORD_API = 'https://discord.com/api/v10';
50
+ /** 出站 HTTP 调用统一 30s 超时。 */
51
+ const OUTBOUND_TIMEOUT_MS = 30_000;
52
+ export type {
53
+ CreateDiscordClient,
54
+ DiscordClientTransport,
55
+ } from './gateway.js';
56
+
57
+ export interface DiscordEndpointOptions {
58
+ readonly id: CapabilityId;
59
+ readonly config: ResolvedDiscordGatewayConfig;
60
+ readonly createClient?: CreateDiscordClient;
61
+ readonly fetch?: typeof globalThis.fetch;
62
+ }
63
+
64
+ export class DiscordGatewayEndpoint extends Endpoint<DiscordClientTransport> {
65
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
66
+
67
+ readonly #options: DiscordEndpointOptions;
68
+ readonly #createClient: CreateDiscordClient;
69
+ readonly #fetch: typeof globalThis.fetch;
70
+ #client: DiscordClientTransport | null = null;
71
+ #open = false;
72
+ #started = false;
73
+ readonly management: EndpointManagement = createDiscordEndpointManagement(
74
+ () => this.#requireClient(),
75
+ );
76
+ readonly control: EndpointControl = Object.freeze({
77
+ recall: (message: MessageRef) => this.recallMessage(message),
78
+ addReaction: async (
79
+ message: MessageRef,
80
+ emoji: string,
81
+ hint?: { readonly sceneType?: string; readonly channelId?: string },
82
+ ) => {
83
+ const channelId = hint?.channelId ?? message.conversation.id;
84
+ if (!channelId || !message.id) return null;
85
+ await this.#addReaction(channelId, message.id, emoji);
86
+ return emoji;
87
+ },
88
+ });
89
+ readonly content: EndpointContentPort = Object.freeze({
90
+ resolve: (reference: ConversationReference, context: EndpointContentResolveContext) =>
91
+ resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
92
+ });
93
+
94
+ constructor(options: DiscordEndpointOptions) {
95
+ super();
96
+ this.#logger = getAdapterLogger('discord', options.config.id);
97
+ this.#options = options;
98
+ this.#createClient = options.createClient ?? defaultCreateClient;
99
+ this.#fetch = options.fetch ?? globalThis.fetch;
100
+ }
101
+
102
+ /** The actual discord.js-compatible client used by this connection. */
103
+ get client(): DiscordClientTransport {
104
+ return this.#requireClient();
105
+ }
106
+
107
+ async start(): Promise<void> {
108
+ if (this.#started) return;
109
+ this.#started = true;
110
+ try {
111
+ const intents = this.#options.config.intents?.length
112
+ ? [...this.#options.config.intents]
113
+ : DEFAULT_INTENTS;
114
+ this.#client = this.#createClient(intents);
115
+ await connectDiscordGatewayClient(this.#client, this.#options.config, {
116
+ onPlatformEvent: (name, event) => {
117
+ void this.#emitPlatformEvent(name, event);
118
+ },
119
+ onMessage: (msg) => this.admit(msg),
120
+ onButton: (interaction) => this.admitButton(interaction),
121
+ onGuildMemberAdd: (member) => {
122
+ receiveDiscordGuildMemberSideEvent(
123
+ (name, payload) => this.emit(name, payload),
124
+ this.#options.config.id,
125
+ 'member_increase',
126
+ member,
127
+ this.#logger,
128
+ );
129
+ },
130
+ onGuildMemberRemove: (member) => {
131
+ receiveDiscordGuildMemberSideEvent(
132
+ (name, payload) => this.emit(name, payload),
133
+ this.#options.config.id,
134
+ 'member_decrease',
135
+ member,
136
+ this.#logger,
137
+ );
138
+ },
139
+ });
140
+ this.#logger.info(formatCompact({
141
+ op: 'connect',
142
+ endpoint: this.#options.config.id,
143
+ mode: 'gateway',
144
+ user: this.#client.user?.tag,
145
+ }));
146
+ } catch (error) {
147
+ await this.stop();
148
+ this.#logger.error('Failed to connect Discord gateway:', error);
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ open(): void {
154
+ this.#open = true;
155
+ }
156
+
157
+ close(): void {
158
+ this.#open = false;
159
+ }
160
+
161
+ async stop(): Promise<void> {
162
+ this.#open = false;
163
+ if (this.#client) {
164
+ try {
165
+ this.#client.removeAllListeners();
166
+ await this.#client.destroy();
167
+ } catch {
168
+ /* ignore */
169
+ }
170
+ this.#client = null;
171
+ }
172
+ this.#started = false;
173
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
174
+ }
175
+
176
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
177
+ const body = formatOutboundBody(payload);
178
+ const snowflake = await this.#sendBody(conversation.id, body);
179
+ this.#logger.debug(formatCompact({
180
+ op: 'discord_send',
181
+ endpoint: this.#options.config.id,
182
+ target: conversation.id,
183
+ messageId: snowflake,
184
+ }));
185
+ return snowflake;
186
+ }
187
+
188
+ async recallMessage(message: MessageRef): Promise<void> {
189
+ if (!message.id) return;
190
+ const channel = await this.#requireClient().channels.fetch(message.conversation.id);
191
+ if (!channel?.isTextBased() || !channel.messages) return;
192
+ const msg = await channel.messages.fetch(message.id);
193
+ await msg.delete();
194
+ }
195
+
196
+ /** Test / internal: admit a message when open. */
197
+ admit(msg: DiscordInboundMessage): void {
198
+ if (!this.#open) return;
199
+ if (msg.authorBot) return;
200
+ const conversation = discordInboundConversation(String(this.#options.id), msg);
201
+ void this.emit('message.receive', {
202
+ conversation,
203
+ message: { conversation, id: msg.id },
204
+ content: formatInboundContent(msg),
205
+ segments: formatInboundSegments(msg),
206
+ sender: {
207
+ id: msg.authorId,
208
+ name: senderDisplayName(msg) || undefined,
209
+ ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)!] } : {}),
210
+ },
211
+ endpointId: this.#options.config.id,
212
+ ...(msg.mentionedBot ? { mentioned: true } : {}),
213
+ metadata: Object.freeze({
214
+ channelKind: msg.channelKind,
215
+ userId: msg.authorId,
216
+ guildId: msg.guildId,
217
+ permissions: msg.permissionTokens,
218
+ role: resolveSenderRole(msg),
219
+ }),
220
+ }).catch((err) => {
221
+ this.#logger.warn(formatCompact({
222
+ op: 'discord_gateway_receive_failed',
223
+ target: `${conversation.kind}:${conversation.id}`,
224
+ error: err instanceof Error ? err.message : String(err),
225
+ }));
226
+ });
227
+ }
228
+
229
+ /** Test / internal: admit a button interaction when open. */
230
+ admitButton(interaction: DiscordButtonInbound): void {
231
+ if (!this.#open) return;
232
+ const conversation = discordInboundConversation(String(this.#options.id), interaction);
233
+ void this.emit('message.receive', {
234
+ conversation,
235
+ message: { conversation, id: interaction.id },
236
+ content: formatButtonContent(interaction),
237
+ segments: formatButtonSegments(interaction),
238
+ sender: { id: interaction.userId, name: interaction.userName },
239
+ endpointId: this.#options.config.id,
240
+ metadata: Object.freeze({
241
+ eventType: 'button',
242
+ payload: interaction.customId,
243
+ sourceMessageId: interaction.sourceMessageId,
244
+ }),
245
+ }).catch((err) => {
246
+ this.#logger.warn(formatCompact({
247
+ op: 'discord_gateway_receive_failed',
248
+ target: `${conversation.kind}:${conversation.id}`,
249
+ error: err instanceof Error ? err.message : String(err),
250
+ }));
251
+ });
252
+ }
253
+
254
+ async #addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
255
+ const channel = await this.#requireClient().channels.fetch(channelId);
256
+ if (!channel?.isTextBased() || !channel.messages) {
257
+ throw new Error(`Channel ${channelId} 不是文本频道`);
258
+ }
259
+ const message = await channel.messages.fetch(messageId);
260
+ await message.react(emoji);
261
+ }
262
+
263
+ async #emitPlatformEvent(name: string, event: unknown): Promise<void> {
264
+ await this.emitPlatform(name, event).catch((error) => {
265
+ this.#logger.warn(formatCompact({
266
+ op: 'discord_platform_event_failed',
267
+ event: name,
268
+ error: error instanceof Error ? error.message : String(error),
269
+ }));
270
+ });
271
+ }
272
+
273
+ async #sendBody(channelId: string, body: DiscordOutboundBody): Promise<string> {
274
+ const channel = await this.#requireClient().channels.fetch(channelId);
275
+ if (!channel || !channel.isTextBased() || !channel.send) {
276
+ throw new Error(`Channel ${channelId} is not a text channel`);
277
+ }
278
+ const options = await toMessageCreateOptions(body);
279
+ const result = await channel.send(options);
280
+ return result.id;
281
+ }
282
+
283
+ #requireClient(): DiscordClientTransport {
284
+ if (!this.#client) throw new Error('Discord client not connected');
285
+ return this.#client;
286
+ }
287
+ }
288
+
289
+ export interface DiscordInteractionsEndpointOptions {
290
+ readonly id: CapabilityId;
291
+ readonly http: HttpHost;
292
+ readonly config: ResolvedDiscordInteractionsConfig;
293
+ readonly fetch?: typeof globalThis.fetch;
294
+ }
295
+
296
+ /** Minimal Discord REST client used when Gateway is intentionally disabled. */
297
+ export class DiscordRestClient {
298
+ constructor(
299
+ readonly token: string,
300
+ readonly fetch: typeof globalThis.fetch = globalThis.fetch,
301
+ ) {}
302
+
303
+ async request<T = unknown>(
304
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
305
+ path: string,
306
+ body?: unknown,
307
+ ): Promise<T> {
308
+ const response = await this.fetch(`${DISCORD_API}${path}`, {
309
+ method,
310
+ headers: {
311
+ Authorization: `Bot ${this.token}`,
312
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
313
+ },
314
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
315
+ signal: AbortSignal.timeout(OUTBOUND_TIMEOUT_MS),
316
+ });
317
+ const text = await response.text();
318
+ if (!response.ok) {
319
+ throw new Error(`Discord API ${method} ${path} failed (${response.status}): ${text.slice(0, 200)}`);
320
+ }
321
+ return (text ? JSON.parse(text) : undefined) as T;
322
+ }
323
+
324
+ createMessage(channelId: string, body: unknown): Promise<{ id?: string }> {
325
+ return this.request('POST', `/channels/${channelId}/messages`, body);
326
+ }
327
+
328
+ deleteMessage(channelId: string, messageId: string): Promise<void> {
329
+ return this.request('DELETE', `/channels/${channelId}/messages/${messageId}`);
330
+ }
331
+ }
332
+
333
+ export class DiscordInteractionsEndpoint extends Endpoint<DiscordRestClient> {
334
+ readonly client: DiscordRestClient;
335
+ readonly #logger!: ReturnType<typeof getAdapterLogger>;
336
+
337
+ readonly #options: DiscordInteractionsEndpointOptions;
338
+ readonly #fetch: typeof globalThis.fetch;
339
+ #routeReleases: HttpRouteRegistration[] = [];
340
+ #open = false;
341
+ #started = false;
342
+ readonly control: EndpointControl = Object.freeze({
343
+ recall: (message: MessageRef) => this.recallMessage(message),
344
+ });
345
+ readonly content: EndpointContentPort = Object.freeze({
346
+ resolve: (reference: ConversationReference, context: EndpointContentResolveContext) =>
347
+ resolveDiscordContent(this.#fetch, this.#options.config.token, reference, context),
348
+ });
349
+
350
+ constructor(options: DiscordInteractionsEndpointOptions) {
351
+ super();
352
+ this.#logger = getAdapterLogger('discord', options.config.id);
353
+ this.#options = options;
354
+ this.#fetch = options.fetch ?? globalThis.fetch;
355
+ this.client = new DiscordRestClient(options.config.token, this.#fetch);
356
+ }
357
+
358
+ get isOpen(): boolean {
359
+ return this.#open;
360
+ }
361
+
362
+ get config(): ResolvedDiscordInteractionsConfig {
363
+ return this.#options.config;
364
+ }
365
+
366
+ async start(): Promise<void> {
367
+ if (this.#started) return;
368
+ this.#started = true;
369
+ this.#routeReleases.push(...registerDiscordInteractionRoutes(this.#options.http, this));
370
+ this.#logger.info(formatCompact({
371
+ op: 'connect',
372
+ endpoint: this.#options.config.id,
373
+ mode: 'interactions',
374
+ path: this.#options.config.interactionsPath,
375
+ }));
376
+ }
377
+
378
+ open(): void {
379
+ this.#open = true;
380
+ }
381
+
382
+ close(): void {
383
+ this.#open = false;
384
+ }
385
+
386
+ async stop(): Promise<void> {
387
+ this.#open = false;
388
+ for (const release of this.#routeReleases.splice(0)) release();
389
+ this.#started = false;
390
+ this.#logger.debug(formatCompact({ op: 'disconnect' }));
391
+ }
392
+
393
+ async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
394
+ const body = formatOutboundBody(payload);
395
+ const data = await this.client.createMessage(conversation.id, body);
396
+ return data.id ?? '';
397
+ }
398
+
399
+ async recallMessage(message: MessageRef): Promise<void> {
400
+ if (!message.id) return;
401
+ await this.client.deleteMessage(message.conversation.id, message.id);
402
+ }
403
+
404
+ admit(msg: DiscordInboundMessage): void {
405
+ if (!this.#open) return;
406
+ const conversation = discordInboundConversation(String(this.#options.id), msg);
407
+ void this.emit('message.receive', {
408
+ conversation,
409
+ message: { conversation, id: msg.id },
410
+ content: formatInboundContent(msg),
411
+ segments: formatInboundSegments(msg),
412
+ sender: {
413
+ id: msg.authorId,
414
+ name: senderDisplayName(msg) || undefined,
415
+ ...(resolveSenderRole(msg) ? { roles: [resolveSenderRole(msg)!] } : {}),
416
+ },
417
+ endpointId: this.#options.config.id,
418
+ metadata: Object.freeze({
419
+ channelKind: msg.channelKind,
420
+ userId: msg.authorId,
421
+ guildId: msg.guildId,
422
+ eventType: 'application_command',
423
+ }),
424
+ }).catch((err) => {
425
+ this.#logger.warn(formatCompact({
426
+ op: 'discord_gateway_receive_failed',
427
+ target: `${conversation.kind}:${conversation.id}`,
428
+ error: err instanceof Error ? err.message : String(err),
429
+ }));
430
+ });
431
+ }
432
+
433
+ admitPlatform(event: Record<string, unknown>): void {
434
+ if (!this.#open) return;
435
+ const type = typeof event.type === 'number' ? `interaction.${event.type}` : 'interaction';
436
+ void this.emitPlatform(type, event).catch((error) => {
437
+ this.#logger.warn(formatCompact({
438
+ op: 'discord_platform_event_failed',
439
+ event: type,
440
+ error: error instanceof Error ? error.message : String(error),
441
+ }));
442
+ });
443
+ }
444
+ }
445
+
446
+ async function resolveDiscordContent(
447
+ fetch: typeof globalThis.fetch,
448
+ token: string,
449
+ reference: ConversationReference,
450
+ context: EndpointContentResolveContext,
451
+ ): Promise<ConversationResolution> {
452
+ if (reference.kind === 'forward') {
453
+ return Object.freeze({ status: 'unsupported', code: 'discord_merged_forward_unavailable' });
454
+ }
455
+ if (reference.kind === 'media') {
456
+ return reference.media.kind === 'file'
457
+ ? Object.freeze({ status: 'unsupported', code: 'discord_opaque_media_unavailable' })
458
+ : Object.freeze({ status: 'resolved', reference, value: reference.media });
459
+ }
460
+ try {
461
+ context.signal.throwIfAborted();
462
+ const response = await fetch(
463
+ `${DISCORD_API}/channels/${reference.message.conversation.id}/messages/${reference.message.id}`,
464
+ { headers: { Authorization: `Bot ${token}` }, signal: context.signal },
465
+ );
466
+ if (response.status === 404) return Object.freeze({ status: 'not_found', code: 'discord_message_not_found' });
467
+ if (response.status === 403) return Object.freeze({ status: 'forbidden', code: 'discord_message_forbidden' });
468
+ if (!response.ok) return Object.freeze({ status: 'failed', code: 'discord_message_fetch_failed' });
469
+ const row = await response.json() as Record<string, unknown>;
470
+ const author = row.author as Record<string, unknown> | undefined;
471
+ const attachments = Array.isArray(row.attachments) ? row.attachments : [];
472
+ const segments: import('@zhin.js/im-contract').Segment[] = [];
473
+ if (typeof row.content === 'string' && row.content.trim()) segments.push({ type: 'text', data: { text: row.content } });
474
+ for (const item of attachments.slice(0, context.maxEntries)) {
475
+ const attachment = item as Record<string, unknown>;
476
+ if (typeof attachment.url !== 'string') continue;
477
+ const mime = typeof attachment.content_type === 'string' ? attachment.content_type : undefined;
478
+ const type = mime?.startsWith('image/') ? 'image' : mime?.startsWith('audio/') ? 'audio' : mime?.startsWith('video/') ? 'video' : 'file';
479
+ segments.push({ type, data: { media: { kind: 'url', value: attachment.url, ...(mime ? { mime_type: mime } : {}), ...(attachment.filename ? { file_name: String(attachment.filename) } : {}), ...(typeof attachment.size === 'number' ? { size: attachment.size } : {}) } } });
480
+ }
481
+ return Object.freeze({
482
+ status: 'resolved',
483
+ reference,
484
+ value: Object.freeze({
485
+ ref: reference.message,
486
+ ...(author?.id ? { actor: Object.freeze({ id: String(author.id), ...(author.global_name || author.username ? { displayName: String(author.global_name ?? author.username) } : {}) }) } : {}),
487
+ segments: Object.freeze(segments),
488
+ timestamp: typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : Date.now(),
489
+ }),
490
+ });
491
+ } catch (error) {
492
+ if (context.signal.aborted) return Object.freeze({ status: 'expired', code: 'turn_aborted' });
493
+ return Object.freeze({ status: 'failed', code: 'discord_content_resolution_failed', message: error instanceof Error ? error.message : String(error) });
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Discord snowflake 是 64 位整数的字符串形式,超出 Number.MAX_SAFE_INTEGER,
499
+ * Number() 转换会丢精度。Console 社交面只把 group_id 当 JSON 值透传、
500
+ * 并以字符串回传给 listGroupMembers,因此这里保留原始字符串(仅按契约
501
+ * 类型声明强转),是全链路最不丢信息的方案。
502
+ */
503
+ function toGroupId(id: string): number {
504
+ return id as unknown as number;
505
+ }
506
+
507
+ /**
508
+ * DiscordGatewayEndpoint 的 EndpointManagement 语义端口(参照 qq 的工厂模式)。
509
+ * 数据源为 discord.js SDK 缓存:guilds.cache / guild.channels.cache / guild.members。
510
+ */
511
+ export function createDiscordEndpointManagement(
512
+ requireClient: () => DiscordClientTransport,
513
+ ): EndpointManagement {
514
+ return Object.freeze<EndpointManagement>({
515
+ async listGroups(): Promise<readonly EndpointGroup[]> {
516
+ const groups: EndpointGroup[] = [];
517
+ for (const guild of requireClient().guilds.cache.values()) {
518
+ if (!guild?.id) continue;
519
+ groups.push({
520
+ group_id: toGroupId(String(guild.id)),
521
+ name: String(guild.name ?? guild.id),
522
+ });
523
+ }
524
+ return groups;
525
+ },
526
+ async listChannels(): Promise<readonly EndpointChannel[]> {
527
+ const channels: EndpointChannel[] = [];
528
+ for (const guild of requireClient().guilds.cache.values()) {
529
+ if (!guild?.id) continue;
530
+ const guildId = String(guild.id);
531
+ const guildName = String(guild.name ?? guildId);
532
+ for (const channel of guild.channels?.cache?.values() ?? []) {
533
+ if (!channel?.id) continue;
534
+ if (channel.type !== ChannelType.GuildText) continue;
535
+ channels.push({
536
+ id: String(channel.id),
537
+ name: channel.name ? String(channel.name) : undefined,
538
+ parent: { type: 'guild', id: guildId, name: guildName },
539
+ });
540
+ }
541
+ }
542
+ return channels;
543
+ },
544
+ async listGroupMembers(groupId: string): Promise<readonly unknown[]> {
545
+ const guild = await requireClient().guilds.fetch(groupId);
546
+ const members = await guild.members.fetch({ limit: 100 }) as Map<string, {
547
+ id: string;
548
+ user: { username: string };
549
+ nickname: string | null;
550
+ roles: { cache: { map(fn: (role: { id: string }) => string): string[] } };
551
+ joinedAt?: Date | null;
552
+ }>;
553
+ return [...members.values()].map((member) => ({
554
+ id: member.id,
555
+ username: member.user.username,
556
+ nickname: member.nickname,
557
+ roles: member.roles.cache.map((role) => role.id),
558
+ joined_at: member.joinedAt?.toISOString(),
559
+ }));
560
+ },
561
+ });
562
+ }