@zhin.js/adapter-discord 1.0.86 → 1.1.0

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 (80) hide show
  1. package/CHANGELOG.md +863 -65
  2. package/README.md +61 -67
  3. package/adapters/discord.js +41 -0
  4. package/adapters/discord.ts +57 -0
  5. package/agent/PERMITS.md +25 -0
  6. package/{skills/discord/SKILL.md → agent/skills/discord.md} +1 -1
  7. package/agent/tools/add_role.ts +26 -0
  8. package/agent/tools/create_thread.ts +28 -0
  9. package/agent/tools/forum_post.ts +33 -0
  10. package/agent/tools/list_roles.ts +34 -0
  11. package/agent/tools/react.ts +24 -0
  12. package/agent/tools/remove_role.ts +26 -0
  13. package/agent/tools/send_embed.ts +38 -0
  14. package/commands/endpoint/add/[id].js +3 -0
  15. package/commands/endpoint/add/[id].ts +3 -0
  16. package/commands/endpoint/list.js +3 -0
  17. package/commands/endpoint/list.ts +3 -0
  18. package/commands/endpoint/remove/[id].js +3 -0
  19. package/commands/endpoint/remove/[id].ts +3 -0
  20. package/lib/client.d.ts +16 -0
  21. package/lib/client.js +8 -0
  22. package/lib/discord-endpoint-commands.d.ts +1 -0
  23. package/lib/discord-endpoint-commands.js +18 -0
  24. package/lib/discord-runtime-state.d.ts +1 -0
  25. package/lib/discord-runtime-state.js +6 -0
  26. package/lib/endpoint.d.ts +72 -0
  27. package/lib/endpoint.js +458 -0
  28. package/lib/gateway.d.ts +144 -0
  29. package/lib/gateway.js +303 -0
  30. package/lib/index.d.ts +6 -18
  31. package/lib/index.js +6 -323
  32. package/lib/platform-permit.d.ts +14 -0
  33. package/lib/platform-permit.js +42 -0
  34. package/lib/protocol.d.ts +162 -0
  35. package/lib/protocol.js +333 -0
  36. package/lib/side-event-dispatch.d.ts +8 -0
  37. package/lib/side-event-dispatch.js +24 -0
  38. package/lib/webhook.d.ts +14 -0
  39. package/lib/webhook.js +88 -0
  40. package/package.json +62 -29
  41. package/plugin.js +19 -0
  42. package/schema.json +141 -0
  43. package/src/client.ts +24 -0
  44. package/src/discord-endpoint-commands.ts +19 -0
  45. package/src/discord-runtime-state.ts +7 -0
  46. package/src/endpoint.ts +562 -0
  47. package/src/gateway.ts +426 -0
  48. package/src/index.ts +55 -323
  49. package/src/platform-permit.ts +57 -0
  50. package/src/protocol.ts +502 -0
  51. package/src/side-event-dispatch.ts +37 -0
  52. package/src/webhook.ts +123 -0
  53. package/client/Dashboard.tsx +0 -195
  54. package/client/index.tsx +0 -11
  55. package/client/tsconfig.json +0 -7
  56. package/client/utils/api.ts +0 -17
  57. package/dist/index.js +0 -29
  58. package/lib/adapter.d.ts +0 -19
  59. package/lib/adapter.d.ts.map +0 -1
  60. package/lib/adapter.js +0 -90
  61. package/lib/adapter.js.map +0 -1
  62. package/lib/bot-interactions.d.ts +0 -33
  63. package/lib/bot-interactions.d.ts.map +0 -1
  64. package/lib/bot-interactions.js +0 -278
  65. package/lib/bot-interactions.js.map +0 -1
  66. package/lib/bot.d.ts +0 -117
  67. package/lib/bot.d.ts.map +0 -1
  68. package/lib/bot.js +0 -904
  69. package/lib/bot.js.map +0 -1
  70. package/lib/index.d.ts.map +0 -1
  71. package/lib/index.js.map +0 -1
  72. package/lib/types.d.ts +0 -49
  73. package/lib/types.d.ts.map +0 -1
  74. package/lib/types.js +0 -2
  75. package/lib/types.js.map +0 -1
  76. package/plugin.yml +0 -3
  77. package/src/adapter.ts +0 -107
  78. package/src/bot-interactions.ts +0 -348
  79. package/src/bot.ts +0 -1037
  80. package/src/types.ts +0 -60
package/src/gateway.ts ADDED
@@ -0,0 +1,426 @@
1
+ import { createReadStream, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ Client,
5
+ GatewayIntentBits,
6
+ EmbedBuilder,
7
+ AttachmentBuilder,
8
+ ActionRowBuilder,
9
+ ButtonBuilder,
10
+ ButtonStyle,
11
+ REST,
12
+ Routes,
13
+ PermissionFlagsBits,
14
+ type MessageCreateOptions,
15
+ type Message as DiscordMessage,
16
+ } from 'discord.js';
17
+ import { formatCompact, getLogger } from '@zhin.js/logger';
18
+ import {
19
+ activityTypeCode,
20
+ resolveChannelKind,
21
+ type DiscordButtonInbound,
22
+ type DiscordInboundMessage,
23
+ type DiscordOutboundBody,
24
+ type ResolvedDiscordGatewayConfig,
25
+ } from './protocol.js';
26
+
27
+ const logger = getLogger('discord');
28
+
29
+ export const DEFAULT_INTENTS = [
30
+ GatewayIntentBits.Guilds,
31
+ GatewayIntentBits.GuildMessages,
32
+ GatewayIntentBits.MessageContent,
33
+ GatewayIntentBits.DirectMessages,
34
+ GatewayIntentBits.GuildMembers,
35
+ GatewayIntentBits.GuildMessageReactions,
36
+ ];
37
+
38
+ /** Minimal client surface used by the endpoint (real discord.js or test mock). */
39
+ export interface DiscordClientTransport {
40
+ login(token: string): Promise<string>;
41
+ destroy(): Promise<void>;
42
+ on(event: string, listener: (...args: unknown[]) => void): void;
43
+ once(event: string, listener: (...args: unknown[]) => void): void;
44
+ removeAllListeners(): void;
45
+ readonly user?: {
46
+ readonly id: string;
47
+ readonly tag?: string;
48
+ setActivity?(name: string, options?: { type?: number; url?: string }): void;
49
+ } | null;
50
+ channels: {
51
+ fetch(id: string): Promise<{
52
+ id: string;
53
+ type: number;
54
+ isTextBased(): boolean;
55
+ send?(options: MessageCreateOptions): Promise<{ id: string }>;
56
+ messages?: {
57
+ fetch(id: string): Promise<{
58
+ delete(): Promise<unknown>;
59
+ react(emoji: string): Promise<unknown>;
60
+ reactions: {
61
+ resolve(emoji: unknown): { users: { remove(userId: string): Promise<unknown> } } | null;
62
+ cache: { find(fn: (r: { emoji: { toString(): string; name?: string | null; id?: string | null } }) => boolean): { users: { remove(userId: string): Promise<unknown> } } | undefined };
63
+ };
64
+ }>;
65
+ };
66
+ threads?: {
67
+ create(options: Record<string, unknown>): Promise<{ id: string }>;
68
+ };
69
+ availableTags?: Array<{ id: string; name: string }>;
70
+ } | null>;
71
+ };
72
+ guilds: {
73
+ fetch(id: string): Promise<{
74
+ id: string;
75
+ name: string;
76
+ ownerId: string;
77
+ memberCount: number;
78
+ createdAt?: Date | null;
79
+ iconURL?(options?: { size?: number }): string | null;
80
+ roles: {
81
+ fetch(): Promise<unknown>;
82
+ cache: Map<string, {
83
+ id: string;
84
+ name: string;
85
+ hexColor: string;
86
+ position: number;
87
+ permissions: { bitfield: bigint };
88
+ }> | { map(fn: (role: {
89
+ id: string;
90
+ name: string;
91
+ hexColor: string;
92
+ position: number;
93
+ permissions: { bitfield: bigint };
94
+ }) => unknown): unknown[] };
95
+ };
96
+ members: {
97
+ fetch(userId: string | { limit?: number }): Promise<unknown>;
98
+ ban(userId: string, options?: { reason?: string; deleteMessageSeconds?: number }): Promise<unknown>;
99
+ unban(userId: string, reason?: string): Promise<unknown>;
100
+ };
101
+ }>;
102
+ cache: { values(): IterableIterator<{
103
+ id: string;
104
+ name: string;
105
+ channels: {
106
+ cache: { values(): IterableIterator<{ id: string; name?: string; type: number }> };
107
+ };
108
+ }> };
109
+ };
110
+ }
111
+
112
+ export type CreateDiscordClient = (intents: readonly number[]) => DiscordClientTransport;
113
+
114
+ export function defaultCreateClient(intents: readonly number[]): DiscordClientTransport {
115
+ return new Client({ intents: [...intents] }) as unknown as DiscordClientTransport;
116
+ }
117
+
118
+ export function resolveSenderRole(msg: DiscordInboundMessage): string | undefined {
119
+ if (msg.isGuildOwner) return 'owner';
120
+ const tokens = msg.permissionTokens ?? [];
121
+ if (tokens.includes('ADMINISTRATOR') || tokens.includes('MODERATE_MEMBERS')) return 'admin';
122
+ if (msg.guildId) return 'member';
123
+ return undefined;
124
+ }
125
+
126
+ export function normalizeDiscordMessage(raw: unknown): DiscordInboundMessage | null {
127
+ if (!raw || typeof raw !== 'object') return null;
128
+ const msg = raw as DiscordMessage;
129
+ if (!msg.author || !msg.channel) return null;
130
+
131
+ const permissionTokens: string[] = [];
132
+ let isGuildOwner = false;
133
+ const member = msg.member;
134
+ const guild = msg.guild;
135
+ if (member && guild) {
136
+ const checks: Array<[bigint, string]> = [
137
+ [PermissionFlagsBits.Administrator, 'ADMINISTRATOR'],
138
+ [PermissionFlagsBits.ManageRoles, 'MANAGE_ROLES'],
139
+ [PermissionFlagsBits.ModerateMembers, 'MODERATE_MEMBERS'],
140
+ [PermissionFlagsBits.ManageChannels, 'MANAGE_CHANNELS'],
141
+ [PermissionFlagsBits.ManageGuild, 'MANAGE_GUILD'],
142
+ ];
143
+ for (const [bit, name] of checks) {
144
+ if (member.permissions.has(bit)) permissionTokens.push(name);
145
+ }
146
+ if (guild.ownerId === msg.author.id) {
147
+ isGuildOwner = true;
148
+ permissionTokens.push('guild_owner', 'ADMINISTRATOR');
149
+ }
150
+ }
151
+
152
+ return {
153
+ id: msg.id,
154
+ content: msg.content ?? '',
155
+ channelId: msg.channel.id,
156
+ channelKind: resolveChannelKind(msg.channel.type),
157
+ authorId: msg.author.id,
158
+ authorName: member?.displayName || msg.author.displayName || msg.author.username,
159
+ authorBot: msg.author.bot,
160
+ createdTimestamp: msg.createdTimestamp,
161
+ guildId: guild?.id,
162
+ isGuildOwner,
163
+ permissionTokens,
164
+ attachments: [...msg.attachments.values()].map((a) => ({
165
+ id: a.id,
166
+ name: a.name ?? undefined,
167
+ url: a.url,
168
+ contentType: a.contentType ?? undefined,
169
+ size: a.size,
170
+ })),
171
+ embedTitles: msg.embeds.map((e) => e.title || e.description || 'embed').filter(Boolean) as string[],
172
+ stickerNames: [...msg.stickers.values()].map((s) => s.name),
173
+ replyToId: msg.reference?.messageId ?? undefined,
174
+ };
175
+ }
176
+
177
+ export async function toMessageCreateOptions(body: DiscordOutboundBody): Promise<MessageCreateOptions> {
178
+ const options: MessageCreateOptions = {};
179
+ if (body.content) options.content = body.content;
180
+ if (body.embeds?.length) {
181
+ options.embeds = body.embeds.map((data) => {
182
+ const embed = new EmbedBuilder();
183
+ if (data.title) embed.setTitle(String(data.title));
184
+ if (data.description) embed.setDescription(String(data.description));
185
+ if (data.color != null) embed.setColor(data.color as number);
186
+ if (data.url) embed.setURL(String(data.url));
187
+ const thumb = data.thumbnail as { url?: string } | undefined;
188
+ if (thumb?.url) embed.setThumbnail(thumb.url);
189
+ const image = data.image as { url?: string } | undefined;
190
+ if (image?.url) embed.setImage(image.url);
191
+ if (data.author) embed.setAuthor(data.author as { name: string });
192
+ if (data.footer) embed.setFooter(data.footer as { text: string });
193
+ if (data.timestamp) embed.setTimestamp(new Date(String(data.timestamp)));
194
+ if (Array.isArray(data.fields)) embed.addFields(data.fields as Array<{ name: string; value: string }>);
195
+ return embed;
196
+ });
197
+ }
198
+ if (body.files?.length) {
199
+ const files: AttachmentBuilder[] = [];
200
+ for (const file of body.files) {
201
+ if (file.base64) {
202
+ files.push(new AttachmentBuilder(decodeBase64(file.base64), {
203
+ name: file.name || 'attachment',
204
+ }));
205
+ } else if (file.file && await fileExists(file.file)) {
206
+ files.push(new AttachmentBuilder(createReadStream(file.file), {
207
+ name: file.name || path.basename(file.file),
208
+ }));
209
+ } else if (file.url) {
210
+ files.push(new AttachmentBuilder(file.url, { name: file.name || 'attachment' }));
211
+ } else {
212
+ logger.warn(formatCompact({
213
+ op: 'discord_outbound_media_dropped',
214
+ reason: 'missing_source',
215
+ name: file.name,
216
+ }));
217
+ }
218
+ }
219
+ if (files.length) options.files = files;
220
+ }
221
+ if (body.components?.length) {
222
+ options.components = body.components.map((row) =>
223
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
224
+ ...row.components.map((btn) => {
225
+ const b = new ButtonBuilder()
226
+ .setCustomId(btn.custom_id)
227
+ .setLabel(btn.label)
228
+ .setDisabled(!!btn.disabled);
229
+ if (btn.style === 4) b.setStyle(ButtonStyle.Danger);
230
+ else if (btn.style === 1) b.setStyle(ButtonStyle.Primary);
231
+ else b.setStyle(ButtonStyle.Secondary);
232
+ return b;
233
+ }),
234
+ ),
235
+ );
236
+ }
237
+ return options;
238
+ }
239
+
240
+ async function registerSlashCommands(
241
+ config: ResolvedDiscordGatewayConfig,
242
+ applicationId: string,
243
+ guildIds: readonly string[] = [],
244
+ ): Promise<void> {
245
+ if (!config.slashCommands?.length) return;
246
+ const rest = new REST({ version: '10' }).setToken(config.token);
247
+ if (config.globalCommands) {
248
+ await rest.put(Routes.applicationCommands(applicationId), {
249
+ body: config.slashCommands,
250
+ });
251
+ logger.info(formatCompact({ op: 'slash_commands', scope: 'global' }));
252
+ return;
253
+ }
254
+ // globalCommands=false 时按 guild 注册,否则 enableSlashCommands 会是静默 no-op
255
+ if (guildIds.length === 0) {
256
+ logger.warn(formatCompact({
257
+ op: 'slash_commands',
258
+ ok: false,
259
+ error: 'enableSlashCommands=true but globalCommands=false and no guilds cached; slash commands not registered',
260
+ }));
261
+ return;
262
+ }
263
+ for (const guildId of guildIds) {
264
+ await rest.put(Routes.applicationGuildCommands(applicationId, guildId), {
265
+ body: config.slashCommands,
266
+ });
267
+ }
268
+ logger.info(formatCompact({ op: 'slash_commands', scope: 'guild', guilds: guildIds.length }));
269
+ }
270
+
271
+ async function fileExists(filePath: string): Promise<boolean> {
272
+ try {
273
+ await fs.access(filePath);
274
+ return true;
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /** base64:// / data:*;base64, 前缀归一,Buffer.from 只接受纯 base64。 */
281
+ function decodeBase64(value: string): Buffer {
282
+ const stripped = value.startsWith('base64://') ? value.slice('base64://'.length) : value;
283
+ const comma = stripped.startsWith('data:') ? stripped.indexOf(',') : -1;
284
+ return Buffer.from(comma >= 0 ? stripped.slice(comma + 1) : stripped, 'base64');
285
+ }
286
+
287
+ export interface DiscordGatewayConnectHandlers {
288
+ onPlatformEvent(name: string, event: unknown): void;
289
+ onMessage(msg: DiscordInboundMessage): void;
290
+ onButton(interaction: DiscordButtonInbound): void;
291
+ onGuildMemberAdd?(member: { guildId: string; userId: string; userName?: string }): void;
292
+ onGuildMemberRemove?(member: { guildId: string; userId: string; userName?: string }): void;
293
+ }
294
+
295
+ export async function connectDiscordGatewayClient(
296
+ client: DiscordClientTransport,
297
+ config: ResolvedDiscordGatewayConfig,
298
+ handlers: DiscordGatewayConnectHandlers,
299
+ ): Promise<void> {
300
+ return new Promise((resolve, reject) => {
301
+ let settled = false;
302
+
303
+ client.on('messageCreate', (raw) => {
304
+ handlers.onPlatformEvent('messageCreate', raw);
305
+ const msg = normalizeDiscordMessage(raw);
306
+ if (!msg) return;
307
+ // clientReady 之后 client.user 一定可用;消息事件只会在此之后到达
308
+ const botId = client.user?.id;
309
+ const mentions = (raw as DiscordMessage).mentions;
310
+ const mentionedBot = !!botId && mentions?.users?.has?.(botId) === true;
311
+ handlers.onMessage(mentionedBot ? { ...msg, mentionedBot: true } : msg);
312
+ });
313
+
314
+ client.on('interactionCreate', (raw) => {
315
+ handlers.onPlatformEvent('interactionCreate', raw);
316
+ const interaction = raw as {
317
+ isButton?(): boolean;
318
+ deferUpdate?(): Promise<unknown>;
319
+ id: string;
320
+ customId: string;
321
+ channel?: { id: string; type: number } | null;
322
+ user: { id: string; username?: string; displayName?: string };
323
+ message?: { id: string };
324
+ };
325
+ if (!interaction.isButton?.()) return;
326
+ void interaction.deferUpdate?.().catch(() => { /* already ack */ });
327
+ if (!interaction.channel) return;
328
+ handlers.onButton({
329
+ id: interaction.id,
330
+ customId: interaction.customId,
331
+ channelId: interaction.channel.id,
332
+ channelKind: resolveChannelKind(interaction.channel.type),
333
+ userId: interaction.user.id,
334
+ userName: interaction.user.username || interaction.user.displayName || interaction.user.id,
335
+ sourceMessageId: interaction.message?.id,
336
+ });
337
+ });
338
+
339
+ client.on('guildMemberAdd', (raw) => {
340
+ handlers.onPlatformEvent('guildMemberAdd', raw);
341
+ const member = raw as {
342
+ guild?: { id?: string };
343
+ user?: { id?: string; username?: string; displayName?: string };
344
+ };
345
+ const guildId = member.guild?.id;
346
+ const userId = member.user?.id;
347
+ if (!guildId || !userId) return;
348
+ handlers.onGuildMemberAdd?.({
349
+ guildId,
350
+ userId,
351
+ userName: member.user?.username || member.user?.displayName,
352
+ });
353
+ });
354
+
355
+ client.on('guildMemberRemove', (raw) => {
356
+ handlers.onPlatformEvent('guildMemberRemove', raw);
357
+ const member = raw as {
358
+ guild?: { id?: string };
359
+ user?: { id?: string; username?: string; displayName?: string };
360
+ };
361
+ const guildId = member.guild?.id;
362
+ const userId = member.user?.id;
363
+ if (!guildId || !userId) return;
364
+ handlers.onGuildMemberRemove?.({
365
+ guildId,
366
+ userId,
367
+ userName: member.user?.username || member.user?.displayName,
368
+ });
369
+ });
370
+
371
+ client.once('clientReady', () => {
372
+ handlers.onPlatformEvent('clientReady', client.user);
373
+ void (async () => {
374
+ try {
375
+ if (config.defaultActivity && client.user?.setActivity) {
376
+ client.user.setActivity(config.defaultActivity.name, {
377
+ type: activityTypeCode(config.defaultActivity.type),
378
+ url: config.defaultActivity.url,
379
+ });
380
+ }
381
+ if (config.enableSlashCommands && config.slashCommands?.length && client.user) {
382
+ const guildIds = [...client.guilds.cache.values()].map((guild) => guild.id);
383
+ await registerSlashCommands(config, client.user.id, guildIds);
384
+ }
385
+ if (!settled) {
386
+ settled = true;
387
+ resolve();
388
+ }
389
+ } catch (error) {
390
+ if (!settled) {
391
+ settled = true;
392
+ reject(error);
393
+ }
394
+ }
395
+ })();
396
+ });
397
+
398
+ client.on('error', (error) => {
399
+ logger.error('Discord client error:', error);
400
+ if (!settled) {
401
+ settled = true;
402
+ reject(error instanceof Error ? error : new Error(String(error)));
403
+ }
404
+ });
405
+
406
+ client.on('warn', (info) => {
407
+ logger.warn('Discord client warning:', info);
408
+ });
409
+
410
+ client.login(config.token).catch((error) => {
411
+ if (!settled) {
412
+ settled = true;
413
+ const raw = error instanceof Error ? error.message : String(error);
414
+ const tokenShapeHint = config.token.split('.').length === 3
415
+ ? ''
416
+ : ';当前 token 不是标准三段式 Bot Token 格式,请到 Discord Developer Portal → Bot → Reset Token 重新获取(勿用 Client Secret)';
417
+ reject(
418
+ new Error(
419
+ `Discord 连接失败:请检查 token 是否为 Bot Token 且未过期、Bot 是否已开启所需 Intents${tokenShapeHint}(原始错误:${raw})`,
420
+ { cause: error instanceof Error ? error : undefined },
421
+ ),
422
+ );
423
+ }
424
+ });
425
+ });
426
+ }