@zhin.js/adapter-discord 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,1056 +1,1145 @@
1
1
  import {
2
- Client,
3
- GatewayIntentBits,
4
- Message as DiscordMessage,
5
- TextChannel,
6
- DMChannel,
7
- NewsChannel,
8
- ThreadChannel,
9
- EmbedBuilder,
10
- AttachmentBuilder,
11
- MessageCreateOptions,
12
- ChannelType,
13
- REST,
14
- Routes,
15
- ApplicationCommandData,
16
- ChatInputCommandInteraction,
17
- InteractionType,
18
- InteractionResponseType
19
- } from 'discord.js';
2
+ Client,
3
+ GatewayIntentBits,
4
+ Message as DiscordMessage,
5
+ TextChannel,
6
+ DMChannel,
7
+ NewsChannel,
8
+ ThreadChannel,
9
+ EmbedBuilder,
10
+ AttachmentBuilder,
11
+ MessageCreateOptions,
12
+ ChannelType,
13
+ REST,
14
+ Routes,
15
+ ApplicationCommandData,
16
+ ChatInputCommandInteraction,
17
+ InteractionType,
18
+ InteractionResponseType,
19
+ } from "discord.js";
20
20
  import {
21
- Bot,
22
- BotConfig,
23
- Adapter,
24
- Plugin,
25
- registerAdapter,
26
- Message,
27
- SendOptions,
28
- SendContent,
29
- MessageSegment,
30
- segment,
31
- useContext
21
+ Bot,
22
+ Adapter,
23
+ Plugin,
24
+ registerAdapter,
25
+ Message,
26
+ SendOptions,
27
+ SendContent,
28
+ MessageSegment,
29
+ segment,
30
+ useContext,
31
+ usePlugin,
32
32
  } from "zhin.js";
33
- import type { Context } from 'koa';
34
- import { createReadStream } from 'fs';
35
- import { promises as fs } from 'fs';
36
- import path from 'path';
33
+ import type { Context } from "koa";
34
+ import { createReadStream } from "fs";
35
+ import { promises as fs } from "fs";
36
+ import path from "path";
37
37
 
38
38
  // 声明模块,注册 discord 适配器类型
39
- declare module '@zhin.js/types'{
40
- interface RegisteredAdapters{
41
- discord:Adapter<DiscordBot>
42
- 'discord-interactions':Adapter<DiscordInteractionsBot>
43
- }
39
+ declare module "@zhin.js/types" {
40
+ interface RegisteredAdapters {
41
+ discord: Adapter<DiscordBot>;
42
+ "discord-interactions": Adapter<DiscordInteractionsBot>;
43
+ }
44
44
  }
45
45
 
46
46
  // Discord Gateway 模式配置
47
- export type DiscordBotConfig = BotConfig & {
48
- context: 'discord'
49
- token: string
50
- name: string
51
- intents?: GatewayIntentBits[]
52
- // Discord 特有配置
53
- enableSlashCommands?: boolean
54
- globalCommands?: boolean
55
- defaultActivity?: {
56
- name: string
57
- type: 'PLAYING' | 'STREAMING' | 'LISTENING' | 'WATCHING' | 'COMPETING'
58
- url?: string
59
- }
60
- // Slash Commands 定义
61
- slashCommands?: ApplicationCommandData[]
62
- }
47
+ export type DiscordBotConfig = Bot.Config & {
48
+ context: "discord";
49
+ token: string;
50
+ name: string;
51
+ intents?: GatewayIntentBits[];
52
+ // Discord 特有配置
53
+ enableSlashCommands?: boolean;
54
+ globalCommands?: boolean;
55
+ defaultActivity?: {
56
+ name: string;
57
+ type: "PLAYING" | "STREAMING" | "LISTENING" | "WATCHING" | "COMPETING";
58
+ url?: string;
59
+ };
60
+ // Slash Commands 定义
61
+ slashCommands?: ApplicationCommandData[];
62
+ };
63
63
 
64
64
  // Discord Interactions 模式配置
65
- export interface DiscordInteractionsConfig extends BotConfig {
66
- context: 'discord-interactions'
67
- token: string
68
- name: string
69
- applicationId: string
70
- publicKey: string // Discord 应用的 Public Key
71
- interactionsPath: string // 交互端点路径,如 '/discord/interactions'
72
- // 是否同时使用 Gateway(默认 false)
73
- useGateway?: boolean
74
- intents?: GatewayIntentBits[]
75
- // Slash Commands 定义
76
- slashCommands?: ApplicationCommandData[]
77
- globalCommands?: boolean
78
- defaultActivity?: {
79
- name: string
80
- type: 'PLAYING' | 'STREAMING' | 'LISTENING' | 'WATCHING' | 'COMPETING'
81
- url?: string
82
- }
65
+ export interface DiscordInteractionsConfig extends Bot.Config {
66
+ context: "discord-interactions";
67
+ token: string;
68
+ name: string;
69
+ applicationId: string;
70
+ publicKey: string; // Discord 应用的 Public Key
71
+ interactionsPath: string; // 交互端点路径,如 '/discord/interactions'
72
+ // 是否同时使用 Gateway(默认 false)
73
+ useGateway?: boolean;
74
+ intents?: GatewayIntentBits[];
75
+ // Slash Commands 定义
76
+ slashCommands?: ApplicationCommandData[];
77
+ globalCommands?: boolean;
78
+ defaultActivity?: {
79
+ name: string;
80
+ type: "PLAYING" | "STREAMING" | "LISTENING" | "WATCHING" | "COMPETING";
81
+ url?: string;
82
+ };
83
83
  }
84
84
 
85
85
  // Bot 接口
86
86
  export interface DiscordBot {
87
- $config: DiscordBotConfig
87
+ $config: DiscordBotConfig;
88
88
  }
89
89
 
90
90
  export interface DiscordInteractionsBot {
91
- $config: DiscordInteractionsConfig
91
+ $config: DiscordInteractionsConfig;
92
92
  }
93
93
 
94
94
  // Discord 消息类型
95
95
  type DiscordChannelMessage = DiscordMessage<boolean>;
96
-
96
+ const plugin = usePlugin();
97
97
  // 主要的 DiscordBot 类
98
- export class DiscordBot extends Client implements Bot<DiscordChannelMessage, DiscordBotConfig> {
99
- $connected?: boolean
100
- private slashCommandHandlers: Map<string, (interaction: ChatInputCommandInteraction) => Promise<void>> = new Map()
101
-
102
- constructor(private plugin: Plugin, public $config: DiscordBotConfig) {
103
- const intents = $config.intents || [
104
- GatewayIntentBits.Guilds,
105
- GatewayIntentBits.GuildMessages,
106
- GatewayIntentBits.MessageContent,
107
- GatewayIntentBits.DirectMessages,
108
- GatewayIntentBits.GuildMembers,
109
- GatewayIntentBits.GuildMessageReactions
110
- ];
111
-
112
- super({ intents });
113
- this.$connected = false;
114
- }
115
-
116
- private async handleDiscordMessage(msg: DiscordChannelMessage): Promise<void> {
117
- // 忽略机器人消息
118
- if (msg.author.bot) return;
119
-
120
- const message = this.$formatMessage(msg);
121
- this.plugin.dispatch('message.receive', message);
122
- this.plugin.logger.info(`recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(message.$content)}`);
123
- this.plugin.dispatch(`message.${message.$channel.type}.receive`, message);
124
- }
125
-
126
- private async handleSlashCommand(interaction: ChatInputCommandInteraction): Promise<void> {
127
- const commandName = interaction.commandName;
128
- const handler = this.slashCommandHandlers.get(commandName);
129
-
130
- if (handler) {
131
- try {
132
- await handler(interaction);
133
- this.plugin.logger.info(`Executed slash command: /${commandName} by ${interaction.user.tag}`);
134
- } catch (error) {
135
- this.plugin.logger.error(`Error executing slash command /${commandName}:`, error);
136
-
137
- const errorMessage = 'An error occurred while executing this command.';
138
- if (interaction.replied || interaction.deferred) {
139
- await interaction.followUp({ content: errorMessage, ephemeral: true });
140
- } else {
141
- await interaction.reply({ content: errorMessage, ephemeral: true });
142
- }
143
- }
98
+ export class DiscordBot
99
+ extends Client
100
+ implements Bot<DiscordChannelMessage, DiscordBotConfig>
101
+ {
102
+ $connected?: boolean;
103
+ private slashCommandHandlers: Map<
104
+ string,
105
+ (interaction: ChatInputCommandInteraction) => Promise<void>
106
+ > = new Map();
107
+
108
+ constructor(public $config: DiscordBotConfig) {
109
+ const intents = $config.intents || [
110
+ GatewayIntentBits.Guilds,
111
+ GatewayIntentBits.GuildMessages,
112
+ GatewayIntentBits.MessageContent,
113
+ GatewayIntentBits.DirectMessages,
114
+ GatewayIntentBits.GuildMembers,
115
+ GatewayIntentBits.GuildMessageReactions,
116
+ ];
117
+
118
+ super({ intents });
119
+ this.$connected = false;
120
+ }
121
+
122
+ private async handleDiscordMessage(
123
+ msg: DiscordChannelMessage
124
+ ): Promise<void> {
125
+ // 忽略机器人消息
126
+ if (msg.author.bot) return;
127
+
128
+ const message = this.$formatMessage(msg);
129
+ plugin.dispatch("message.receive", message);
130
+ plugin.logger.info(
131
+ `recv ${message.$channel.type}(${message.$channel.id}): ${segment.raw(
132
+ message.$content
133
+ )}`
134
+ );
135
+ plugin.dispatch(`message.${message.$channel.type}.receive`, message);
136
+ }
137
+
138
+ private async handleSlashCommand(
139
+ interaction: ChatInputCommandInteraction
140
+ ): Promise<void> {
141
+ const commandName = interaction.commandName;
142
+ const handler = this.slashCommandHandlers.get(commandName);
143
+
144
+ if (handler) {
145
+ try {
146
+ await handler(interaction);
147
+ plugin.logger.info(
148
+ `Executed slash command: /${commandName} by ${interaction.user.tag}`
149
+ );
150
+ } catch (error) {
151
+ plugin.logger.error(
152
+ `Error executing slash command /${commandName}:`,
153
+ error
154
+ );
155
+
156
+ const errorMessage = "An error occurred while executing this command.";
157
+ if (interaction.replied || interaction.deferred) {
158
+ await interaction.followUp({
159
+ content: errorMessage,
160
+ ephemeral: true,
161
+ });
144
162
  } else {
145
- this.plugin.logger.warn(`Unknown slash command: /${commandName}`);
146
- if (!interaction.replied) {
147
- await interaction.reply({
148
- content: 'Unknown command.',
149
- ephemeral: true
150
- });
151
- }
163
+ await interaction.reply({ content: errorMessage, ephemeral: true });
152
164
  }
153
- }
154
-
155
- async $connect(): Promise<void> {
156
- return new Promise((resolve, reject) => {
157
- // 监听消息事件
158
- this.on('messageCreate', this.handleDiscordMessage.bind(this));
159
-
160
- // 监听交互事件(Slash Commands)
161
- if (this.$config.enableSlashCommands) {
162
- this.on('interactionCreate', async (interaction) => {
163
- if (interaction.isChatInputCommand()) {
164
- await this.handleSlashCommand(interaction);
165
- }
166
- });
167
- }
168
-
169
- // 监听就绪事件
170
- this.once('ready', async () => {
171
- this.$connected = true;
172
- this.plugin.logger.info(`Discord bot ${this.$config.name} connected successfully as ${this.user?.tag}`);
173
-
174
- // 设置活动状态
175
- if (this.$config.defaultActivity) {
176
- this.user?.setActivity(this.$config.defaultActivity.name, {
177
- type: this.getActivityType(this.$config.defaultActivity.type),
178
- url: this.$config.defaultActivity.url
179
- });
180
- }
181
-
182
- // 注册 Slash Commands
183
- if (this.$config.enableSlashCommands && this.$config.slashCommands) {
184
- await this.registerSlashCommands();
185
- }
186
-
187
- resolve();
188
- });
189
-
190
- // 监听错误事件
191
- this.on('error', (error) => {
192
- this.plugin.logger.error('Discord client error:', error);
193
- this.$connected = false;
194
- reject(error);
195
- });
196
-
197
- // 登录
198
- this.login(this.$config.token).catch((error) => {
199
- this.plugin.logger.error('Failed to login to Discord:', error);
200
- this.$connected = false;
201
- reject(error);
202
- });
165
+ }
166
+ } else {
167
+ plugin.logger.warn(`Unknown slash command: /${commandName}`);
168
+ if (!interaction.replied) {
169
+ await interaction.reply({
170
+ content: "Unknown command.",
171
+ ephemeral: true,
203
172
  });
173
+ }
204
174
  }
205
-
206
- async $disconnect(): Promise<void> {
207
- try {
208
- await this.destroy();
209
- this.$connected = false;
210
- this.plugin.logger.info(`Discord bot ${this.$config.name} disconnected`);
211
- } catch (error) {
212
- this.plugin.logger.error('Error disconnecting Discord bot:', error);
213
- throw error;
175
+ }
176
+
177
+ async $connect(): Promise<void> {
178
+ return new Promise((resolve, reject) => {
179
+ // 监听消息事件
180
+ this.on("messageCreate", this.handleDiscordMessage.bind(this));
181
+
182
+ // 监听交互事件(Slash Commands)
183
+ if (this.$config.enableSlashCommands) {
184
+ this.on("interactionCreate", async (interaction) => {
185
+ if (interaction.isChatInputCommand()) {
186
+ await this.handleSlashCommand(interaction);
187
+ }
188
+ });
189
+ }
190
+
191
+ // 监听就绪事件
192
+ this.once("clientReady", async () => {
193
+ this.$connected = true;
194
+ plugin.logger.info(
195
+ `Discord bot ${this.$config.name} connected successfully as ${this.user?.tag}`
196
+ );
197
+
198
+ // 设置活动状态
199
+ if (this.$config.defaultActivity) {
200
+ this.user?.setActivity(this.$config.defaultActivity.name, {
201
+ type: this.getActivityType(this.$config.defaultActivity.type),
202
+ url: this.$config.defaultActivity.url,
203
+ });
214
204
  }
215
- }
216
205
 
217
- $formatMessage(msg: DiscordChannelMessage): Message<DiscordChannelMessage> {
218
- // 确定聊天类型和ID
219
- let channelType: 'private' | 'group' | 'channel';
220
- let channelId: string;
221
-
222
- if (msg.channel.type === ChannelType.DM) {
223
- channelType = 'private';
224
- channelId = msg.channel.id;
225
- } else if (msg.channel.type === ChannelType.GroupDM) {
226
- channelType = 'group';
227
- channelId = msg.channel.id;
228
- } else {
229
- channelType = 'channel';
230
- channelId = msg.channel.id;
206
+ // 注册 Slash Commands
207
+ if (this.$config.enableSlashCommands && this.$config.slashCommands) {
208
+ await this.registerSlashCommands();
231
209
  }
232
210
 
233
- // 转换消息内容为 segment 格式
234
- const content = this.parseMessageContent(msg);
211
+ resolve();
212
+ });
235
213
 
236
- const result = Message.from(msg, {
237
- $id: msg.id,
238
- $adapter: 'discord',
239
- $bot: this.$config.name,
240
- $sender: {
241
- id: msg.author.id,
242
- name: msg.member?.displayName || msg.author.displayName
243
- },
244
- $channel: {
245
- id: channelId,
246
- type: channelType
247
- },
248
- $content: content,
249
- $raw: msg.content,
250
- $timestamp: msg.createdTimestamp,
251
- $reply: async (content: SendContent, quote?: boolean | string): Promise<string> => {
252
- if (!Array.isArray(content)) content = [content];
253
-
254
- const sendOptions: MessageCreateOptions = {};
255
-
256
- // 处理回复消息
257
- if (quote) {
258
- const replyId = typeof quote === "boolean" ? result.$id : quote;
259
- try {
260
- const replyMessage = await msg.channel.messages.fetch(replyId);
261
- sendOptions.reply = { messageReference: replyMessage };
262
- } catch (error) {
263
- this.plugin.logger.warn(`Could not find message to reply to: ${replyId}`);
264
- }
265
- }
266
-
267
- const res = await this.sendContentToChannel(msg.channel as any, content, sendOptions);
268
- return res.id;
269
- }
270
- });
214
+ // 监听错误事件
215
+ this.on("error", (error) => {
216
+ plugin.logger.error("Discord client error:", error);
217
+ this.$connected = false;
218
+ reject(error);
219
+ });
271
220
 
272
- return result;
221
+ // 登录
222
+ this.login(this.$config.token).catch((error) => {
223
+ plugin.logger.error("Failed to login to Discord:", error);
224
+ this.$connected = false;
225
+ reject(error);
226
+ });
227
+ });
228
+ }
229
+
230
+ async $disconnect(): Promise<void> {
231
+ try {
232
+ await this.destroy();
233
+ this.$connected = false;
234
+ plugin.logger.info(`Discord bot ${this.$config.name} disconnected`);
235
+ } catch (error) {
236
+ plugin.logger.error("Error disconnecting Discord bot:", error);
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ $formatMessage(msg: DiscordChannelMessage): Message<DiscordChannelMessage> {
242
+ // 确定聊天类型和ID
243
+ let channelType: "private" | "group" | "channel";
244
+ let channelId: string;
245
+
246
+ if (msg.channel.type === ChannelType.DM) {
247
+ channelType = "private";
248
+ channelId = msg.channel.id;
249
+ } else if (msg.channel.type === ChannelType.GroupDM) {
250
+ channelType = "group";
251
+ channelId = msg.channel.id;
252
+ } else {
253
+ channelType = "channel";
254
+ channelId = msg.channel.id;
273
255
  }
274
256
 
275
- // 解析 Discord 消息内容为 segment 格式
276
- parseMessageContent(msg: DiscordChannelMessage): MessageSegment[] {
277
- const segments: MessageSegment[] = [];
278
-
279
- // 回复消息处理
280
- if (msg.reference) {
281
- segments.push({
282
- type: 'reply',
283
- data: {
284
- id: msg.reference.messageId,
285
- channel_id: msg.reference.channelId,
286
- guild_id: msg.reference.guildId
287
- }
288
- });
289
- }
290
-
291
- // 文本消息(包含提及、表情等)
292
- if (msg.content) {
293
- segments.push(...this.parseTextContent(msg.content, msg));
294
- }
295
-
296
- // 附件消息
297
- for (const attachment of msg.attachments.values()) {
298
- segments.push(...this.parseAttachment(attachment));
299
- }
300
-
301
- // Embed 消息
302
- for (const embed of msg.embeds) {
303
- segments.push({
304
- type: 'embed',
305
- data: {
306
- title: embed.title,
307
- description: embed.description,
308
- color: embed.color,
309
- url: embed.url,
310
- thumbnail: embed.thumbnail,
311
- image: embed.image,
312
- author: embed.author,
313
- footer: embed.footer,
314
- fields: embed.fields,
315
- timestamp: embed.timestamp
316
- }
317
- });
318
- }
257
+ // 转换消息内容为 segment 格式
258
+ const content = this.parseMessageContent(msg);
259
+
260
+ const result = Message.from(msg, {
261
+ $id: msg.id,
262
+ $adapter: "discord",
263
+ $bot: this.$config.name,
264
+ $sender: {
265
+ id: msg.author.id,
266
+ name: msg.member?.displayName || msg.author.displayName,
267
+ },
268
+ $channel: {
269
+ id: channelId,
270
+ type: channelType,
271
+ },
272
+ $content: content,
273
+ $raw: msg.content,
274
+ $timestamp: msg.createdTimestamp,
275
+ $reply: async (
276
+ content: SendContent,
277
+ quote?: boolean | string
278
+ ): Promise<string> => {
279
+ if (!Array.isArray(content)) content = [content];
319
280
 
320
- // 贴纸消息
321
- for (const sticker of msg.stickers.values()) {
322
- segments.push({
323
- type: 'sticker',
324
- data: {
325
- id: sticker.id,
326
- name: sticker.name,
327
- url: sticker.url,
328
- format: sticker.format,
329
- tags: sticker.tags
330
- }
331
- });
281
+ const sendOptions: MessageCreateOptions = {};
282
+
283
+ // 处理回复消息
284
+ if (quote) {
285
+ const replyId = typeof quote === "boolean" ? result.$id : quote;
286
+ try {
287
+ const replyMessage = await msg.channel.messages.fetch(replyId);
288
+ sendOptions.reply = { messageReference: replyMessage };
289
+ } catch (error) {
290
+ plugin.logger.warn(
291
+ `Could not find message to reply to: ${replyId}`
292
+ );
293
+ }
332
294
  }
333
295
 
334
- return segments.length > 0 ? segments : [{ type: 'text', data: { text: '' } }];
296
+ const res = await this.sendContentToChannel(
297
+ msg.channel as any,
298
+ content,
299
+ sendOptions
300
+ );
301
+ return res.id;
302
+ },
303
+ });
304
+
305
+ return result;
306
+ }
307
+
308
+ // 解析 Discord 消息内容为 segment 格式
309
+ parseMessageContent(msg: DiscordChannelMessage): MessageSegment[] {
310
+ const segments: MessageSegment[] = [];
311
+
312
+ // 回复消息处理
313
+ if (msg.reference) {
314
+ segments.push({
315
+ type: "reply",
316
+ data: {
317
+ id: msg.reference.messageId,
318
+ channel_id: msg.reference.channelId,
319
+ guild_id: msg.reference.guildId,
320
+ },
321
+ });
335
322
  }
336
323
 
337
- // 解析文本内容,处理提及、频道引用、角色引用等
338
- parseTextContent(content: string, msg: DiscordChannelMessage): MessageSegment[] {
339
- const segments: MessageSegment[] = [];
340
- let lastIndex = 0;
341
-
342
- // 匹配用户提及 <@!?用户ID>
343
- const userMentionRegex = /<@!?(\d+)>/g;
344
- // 匹配频道提及 <#频道ID>
345
- const channelMentionRegex = /<#(\d+)>/g;
346
- // 匹配角色提及 <@&角色ID>
347
- const roleMentionRegex = /<@&(\d+)>/g;
348
- // 匹配自定义表情 <:名称:ID> 或 <a:名称:ID>
349
- const emojiRegex = /<a?:(\w+):(\d+)>/g;
350
-
351
- const allMatches: Array<{
352
- match: RegExpExecArray;
353
- type: 'user' | 'channel' | 'role' | 'emoji';
354
- }> = [];
355
-
356
- // 收集所有匹配项
357
- let match;
358
- while ((match = userMentionRegex.exec(content)) !== null) {
359
- allMatches.push({ match, type: 'user' });
360
- }
361
- while ((match = channelMentionRegex.exec(content)) !== null) {
362
- allMatches.push({ match, type: 'channel' });
363
- }
364
- while ((match = roleMentionRegex.exec(content)) !== null) {
365
- allMatches.push({ match, type: 'role' });
366
- }
367
- while ((match = emojiRegex.exec(content)) !== null) {
368
- allMatches.push({ match, type: 'emoji' });
369
- }
370
-
371
- // 按位置排序
372
- allMatches.sort((a, b) => a.match.index! - b.match.index!);
373
-
374
- // 处理每个匹配项
375
- for (const { match, type } of allMatches) {
376
- const matchStart = match.index!;
377
- const matchEnd = matchStart + match[0].length;
378
-
379
- // 添加匹配项前的文本
380
- if (matchStart > lastIndex) {
381
- const beforeText = content.slice(lastIndex, matchStart);
382
- if (beforeText.trim()) {
383
- segments.push({ type: 'text', data: { text: beforeText } });
384
- }
385
- }
386
-
387
- // 添加特殊内容段
388
- switch (type) {
389
- case 'user':
390
- const userId = match[1];
391
- const user = msg.mentions.users.get(userId);
392
- segments.push({
393
- type: 'at',
394
- data: {
395
- id: userId,
396
- name: user?.username || 'Unknown',
397
- text: match[0]
398
- }
399
- });
400
- break;
401
-
402
- case 'channel':
403
- const channelId = match[1];
404
- const channel = msg.mentions.channels.get(channelId);
405
- segments.push({
406
- type: 'channel_mention',
407
- data: {
408
- id: channelId,
409
- name: (channel as any)?.name || 'unknown-channel',
410
- text: match[0]
411
- }
412
- });
413
- break;
414
-
415
- case 'role':
416
- const roleId = match[1];
417
- const role = msg.mentions.roles.get(roleId);
418
- segments.push({
419
- type: 'role_mention',
420
- data: {
421
- id: roleId,
422
- name: role?.name || 'unknown-role',
423
- text: match[0]
424
- }
425
- });
426
- break;
427
-
428
- case 'emoji':
429
- const emojiName = match[1];
430
- const emojiId = match[2];
431
- const isAnimated = match[0].startsWith('<a:');
432
- segments.push({
433
- type: 'emoji',
434
- data: {
435
- id: emojiId,
436
- name: emojiName,
437
- animated: isAnimated,
438
- url: `https://cdn.discordapp.com/emojis/${emojiId}.${isAnimated ? 'gif' : 'png'}`,
439
- text: match[0]
440
- }
441
- });
442
- break;
443
- }
444
-
445
- lastIndex = matchEnd;
446
- }
447
-
448
- // 添加最后剩余的文本
449
- if (lastIndex < content.length) {
450
- const remainingText = content.slice(lastIndex);
451
- if (remainingText.trim()) {
452
- segments.push({ type: 'text', data: { text: remainingText } });
453
- }
454
- }
324
+ // 文本消息(包含提及、表情等)
325
+ if (msg.content) {
326
+ segments.push(...this.parseTextContent(msg.content, msg));
327
+ }
455
328
 
456
- return segments.length > 0 ? segments : [{ type: 'text', data: { text: content } }];
329
+ // 附件消息
330
+ for (const attachment of msg.attachments.values()) {
331
+ segments.push(...this.parseAttachment(attachment));
457
332
  }
458
333
 
459
- // 解析附件
460
- parseAttachment(attachment: any): MessageSegment[] {
461
- const segments: MessageSegment[] = [];
462
-
463
- if (attachment.contentType?.startsWith('image/')) {
464
- segments.push({
465
- type: 'image',
466
- data: {
467
- id: attachment.id,
468
- name: attachment.name,
469
- url: attachment.url,
470
- proxy_url: attachment.proxyURL,
471
- size: attachment.size,
472
- width: attachment.width,
473
- height: attachment.height,
474
- content_type: attachment.contentType
475
- }
476
- });
477
- } else if (attachment.contentType?.startsWith('audio/')) {
478
- segments.push({
479
- type: 'audio',
480
- data: {
481
- id: attachment.id,
482
- name: attachment.name,
483
- url: attachment.url,
484
- proxy_url: attachment.proxyURL,
485
- size: attachment.size,
486
- content_type: attachment.contentType
487
- }
488
- });
489
- } else if (attachment.contentType?.startsWith('video/')) {
490
- segments.push({
491
- type: 'video',
492
- data: {
493
- id: attachment.id,
494
- name: attachment.name,
495
- url: attachment.url,
496
- proxy_url: attachment.proxyURL,
497
- size: attachment.size,
498
- width: attachment.width,
499
- height: attachment.height,
500
- content_type: attachment.contentType
501
- }
502
- });
503
- } else {
504
- segments.push({
505
- type: 'file',
506
- data: {
507
- id: attachment.id,
508
- name: attachment.name,
509
- url: attachment.url,
510
- proxy_url: attachment.proxyURL,
511
- size: attachment.size,
512
- content_type: attachment.contentType
513
- }
514
- });
515
- }
334
+ // Embed 消息
335
+ for (const embed of msg.embeds) {
336
+ segments.push({
337
+ type: "embed",
338
+ data: {
339
+ title: embed.title,
340
+ description: embed.description,
341
+ color: embed.color,
342
+ url: embed.url,
343
+ thumbnail: embed.thumbnail,
344
+ image: embed.image,
345
+ author: embed.author,
346
+ footer: embed.footer,
347
+ fields: embed.fields,
348
+ timestamp: embed.timestamp,
349
+ },
350
+ });
351
+ }
516
352
 
517
- return segments;
353
+ // 贴纸消息
354
+ for (const sticker of msg.stickers.values()) {
355
+ segments.push({
356
+ type: "sticker",
357
+ data: {
358
+ id: sticker.id,
359
+ name: sticker.name,
360
+ url: sticker.url,
361
+ format: sticker.format,
362
+ tags: sticker.tags,
363
+ },
364
+ });
518
365
  }
519
366
 
520
- async $sendMessage(options: SendOptions): Promise<string> {
521
- options = await this.plugin.app.handleBeforeSend(options);
522
-
523
- try {
524
- const channel = await this.channels.fetch(options.id);
525
- if (!channel || !channel.isTextBased()) {
526
- throw new Error(`Channel ${options.id} is not a text channel`);
527
- }
528
-
529
- const result = await this.sendContentToChannel(channel as any, options.content);
530
- this.plugin.logger.info(`send ${options.type}(${options.id}): ${segment.raw(options.content)}`);
531
- return result.id;
532
- } catch (error) {
533
- this.plugin.logger.error('Failed to send Discord message:', error);
534
- throw error;
535
- }
367
+ return segments.length > 0
368
+ ? segments
369
+ : [{ type: "text", data: { text: "" } }];
370
+ }
371
+
372
+ // 解析文本内容,处理提及、频道引用、角色引用等
373
+ parseTextContent(
374
+ content: string,
375
+ msg: DiscordChannelMessage
376
+ ): MessageSegment[] {
377
+ const segments: MessageSegment[] = [];
378
+ let lastIndex = 0;
379
+
380
+ // 匹配用户提及 <@!?用户ID>
381
+ const userMentionRegex = /<@!?(\d+)>/g;
382
+ // 匹配频道提及 <#频道ID>
383
+ const channelMentionRegex = /<#(\d+)>/g;
384
+ // 匹配角色提及 <@&角色ID>
385
+ const roleMentionRegex = /<@&(\d+)>/g;
386
+ // 匹配自定义表情 <:名称:ID> 或 <a:名称:ID>
387
+ const emojiRegex = /<a?:(\w+):(\d+)>/g;
388
+
389
+ const allMatches: Array<{
390
+ match: RegExpExecArray;
391
+ type: "user" | "channel" | "role" | "emoji";
392
+ }> = [];
393
+
394
+ // 收集所有匹配项
395
+ let match;
396
+ while ((match = userMentionRegex.exec(content)) !== null) {
397
+ allMatches.push({ match, type: "user" });
398
+ }
399
+ while ((match = channelMentionRegex.exec(content)) !== null) {
400
+ allMatches.push({ match, type: "channel" });
401
+ }
402
+ while ((match = roleMentionRegex.exec(content)) !== null) {
403
+ allMatches.push({ match, type: "role" });
404
+ }
405
+ while ((match = emojiRegex.exec(content)) !== null) {
406
+ allMatches.push({ match, type: "emoji" });
536
407
  }
537
408
 
538
- // 发送内容到频道
539
- async sendContentToChannel(
540
- channel: TextChannel | DMChannel | NewsChannel | ThreadChannel,
541
- content: SendContent,
542
- extraOptions: MessageCreateOptions = {}
543
- ): Promise<DiscordMessage<boolean>> {
544
- if (!Array.isArray(content)) content = [content];
409
+ // 按位置排序
410
+ allMatches.sort((a, b) => a.match.index! - b.match.index!);
545
411
 
546
- const messageOptions: MessageCreateOptions = { ...extraOptions };
547
- let textContent = '';
548
- const embeds: EmbedBuilder[] = [];
549
- const files: AttachmentBuilder[] = [];
550
-
551
- for (const segment of content) {
552
- if (typeof segment === 'string') {
553
- textContent += segment;
554
- continue;
555
- }
556
-
557
- const { type, data } = segment;
558
-
559
- switch (type) {
560
- case 'text':
561
- textContent += data.text || '';
562
- break;
563
-
564
- case 'at':
565
- textContent += `<@${data.id}>`;
566
- break;
567
-
568
- case 'channel_mention':
569
- textContent += `<#${data.id}>`;
570
- break;
571
-
572
- case 'role_mention':
573
- textContent += `<@&${data.id}>`;
574
- break;
575
-
576
- case 'emoji':
577
- textContent += data.animated ? `<a:${data.name}:${data.id}>` : `<:${data.name}:${data.id}>`;
578
- break;
579
-
580
- case 'image':
581
- case 'audio':
582
- case 'video':
583
- case 'file':
584
- await this.handleFileSegment(data, files, textContent);
585
- break;
586
-
587
- case 'embed':
588
- embeds.push(this.createEmbedFromData(data));
589
- break;
590
-
591
- default:
592
- // 未知类型作为文本处理
593
- textContent += data.text || `[${type}]`;
594
- }
595
- }
412
+ // 处理每个匹配项
413
+ for (const { match, type } of allMatches) {
414
+ const matchStart = match.index!;
415
+ const matchEnd = matchStart + match[0].length;
596
416
 
597
- // 设置消息内容
598
- if (textContent.trim()) {
599
- messageOptions.content = textContent.trim();
417
+ // 添加匹配项前的文本
418
+ if (matchStart > lastIndex) {
419
+ const beforeText = content.slice(lastIndex, matchStart);
420
+ if (beforeText.trim()) {
421
+ segments.push({ type: "text", data: { text: beforeText } });
600
422
  }
423
+ }
424
+
425
+ // 添加特殊内容段
426
+ switch (type) {
427
+ case "user":
428
+ const userId = match[1];
429
+ const user = msg.mentions.users.get(userId);
430
+ segments.push({
431
+ type: "at",
432
+ data: {
433
+ id: userId,
434
+ name: user?.username || "Unknown",
435
+ text: match[0],
436
+ },
437
+ });
438
+ break;
439
+
440
+ case "channel":
441
+ const channelId = match[1];
442
+ const channel = msg.mentions.channels.get(channelId);
443
+ segments.push({
444
+ type: "channel_mention",
445
+ data: {
446
+ id: channelId,
447
+ name: (channel as any)?.name || "unknown-channel",
448
+ text: match[0],
449
+ },
450
+ });
451
+ break;
452
+
453
+ case "role":
454
+ const roleId = match[1];
455
+ const role = msg.mentions.roles.get(roleId);
456
+ segments.push({
457
+ type: "role_mention",
458
+ data: {
459
+ id: roleId,
460
+ name: role?.name || "unknown-role",
461
+ text: match[0],
462
+ },
463
+ });
464
+ break;
465
+
466
+ case "emoji":
467
+ const emojiName = match[1];
468
+ const emojiId = match[2];
469
+ const isAnimated = match[0].startsWith("<a:");
470
+ segments.push({
471
+ type: "emoji",
472
+ data: {
473
+ id: emojiId,
474
+ name: emojiName,
475
+ animated: isAnimated,
476
+ url: `https://cdn.discordapp.com/emojis/${emojiId}.${
477
+ isAnimated ? "gif" : "png"
478
+ }`,
479
+ text: match[0],
480
+ },
481
+ });
482
+ break;
483
+ }
601
484
 
602
- if (embeds.length > 0) {
603
- messageOptions.embeds = embeds.slice(0, 10); // Discord 限制最多10个embed
604
- }
485
+ lastIndex = matchEnd;
486
+ }
605
487
 
606
- if (files.length > 0) {
607
- messageOptions.files = files;
608
- }
488
+ // 添加最后剩余的文本
489
+ if (lastIndex < content.length) {
490
+ const remainingText = content.slice(lastIndex);
491
+ if (remainingText.trim()) {
492
+ segments.push({ type: "text", data: { text: remainingText } });
493
+ }
494
+ }
609
495
 
610
- // 发送消息
611
- return await channel.send(messageOptions);
496
+ return segments.length > 0
497
+ ? segments
498
+ : [{ type: "text", data: { text: content } }];
499
+ }
500
+
501
+ // 解析附件
502
+ parseAttachment(attachment: any): MessageSegment[] {
503
+ const segments: MessageSegment[] = [];
504
+
505
+ if (attachment.contentType?.startsWith("image/")) {
506
+ segments.push({
507
+ type: "image",
508
+ data: {
509
+ id: attachment.id,
510
+ name: attachment.name,
511
+ url: attachment.url,
512
+ proxy_url: attachment.proxyURL,
513
+ size: attachment.size,
514
+ width: attachment.width,
515
+ height: attachment.height,
516
+ content_type: attachment.contentType,
517
+ },
518
+ });
519
+ } else if (attachment.contentType?.startsWith("audio/")) {
520
+ segments.push({
521
+ type: "audio",
522
+ data: {
523
+ id: attachment.id,
524
+ name: attachment.name,
525
+ url: attachment.url,
526
+ proxy_url: attachment.proxyURL,
527
+ size: attachment.size,
528
+ content_type: attachment.contentType,
529
+ },
530
+ });
531
+ } else if (attachment.contentType?.startsWith("video/")) {
532
+ segments.push({
533
+ type: "video",
534
+ data: {
535
+ id: attachment.id,
536
+ name: attachment.name,
537
+ url: attachment.url,
538
+ proxy_url: attachment.proxyURL,
539
+ size: attachment.size,
540
+ width: attachment.width,
541
+ height: attachment.height,
542
+ content_type: attachment.contentType,
543
+ },
544
+ });
545
+ } else {
546
+ segments.push({
547
+ type: "file",
548
+ data: {
549
+ id: attachment.id,
550
+ name: attachment.name,
551
+ url: attachment.url,
552
+ proxy_url: attachment.proxyURL,
553
+ size: attachment.size,
554
+ content_type: attachment.contentType,
555
+ },
556
+ });
612
557
  }
613
- async $recallMessage(id:string):Promise<void> {
614
-
558
+
559
+ return segments;
560
+ }
561
+
562
+ async $sendMessage(options: SendOptions): Promise<string> {
563
+ options = await plugin.app.handleBeforeSend(options);
564
+
565
+ try {
566
+ const channel = await this.channels.fetch(options.id);
567
+ if (!channel || !channel.isTextBased()) {
568
+ throw new Error(`Channel ${options.id} is not a text channel`);
569
+ }
570
+
571
+ const result = await this.sendContentToChannel(
572
+ channel as any,
573
+ options.content
574
+ );
575
+ plugin.logger.info(
576
+ `send ${options.type}(${options.id}): ${segment.raw(options.content)}`
577
+ );
578
+ return result.id;
579
+ } catch (error) {
580
+ plugin.logger.error("Failed to send Discord message:", error);
581
+ throw error;
615
582
  }
616
- // 处理文件段
617
- async handleFileSegment(data: any, files: AttachmentBuilder[], textContent: string): Promise<void> {
618
- if (data.file && await this.fileExists(data.file)) {
619
- // 本地文件
620
- files.push(new AttachmentBuilder(createReadStream(data.file), {
621
- name: data.name || path.basename(data.file)
622
- }));
623
- } else if (data.url) {
624
- // URL 文件
625
- files.push(new AttachmentBuilder(data.url, {
626
- name: data.name || 'attachment'
627
- }));
628
- } else if (data.buffer) {
629
- // Buffer 数据
630
- files.push(new AttachmentBuilder(data.buffer, {
631
- name: data.name || 'attachment'
632
- }));
633
- }
583
+ }
584
+
585
+ // 发送内容到频道
586
+ async sendContentToChannel(
587
+ channel: TextChannel | DMChannel | NewsChannel | ThreadChannel,
588
+ content: SendContent,
589
+ extraOptions: MessageCreateOptions = {}
590
+ ): Promise<DiscordMessage<boolean>> {
591
+ if (!Array.isArray(content)) content = [content];
592
+
593
+ const messageOptions: MessageCreateOptions = { ...extraOptions };
594
+ let textContent = "";
595
+ const embeds: EmbedBuilder[] = [];
596
+ const files: AttachmentBuilder[] = [];
597
+
598
+ for (const segment of content) {
599
+ if (typeof segment === "string") {
600
+ textContent += segment;
601
+ continue;
602
+ }
603
+
604
+ const { type, data } = segment;
605
+
606
+ switch (type) {
607
+ case "text":
608
+ textContent += data.text || "";
609
+ break;
610
+
611
+ case "at":
612
+ textContent += `<@${data.id}>`;
613
+ break;
614
+
615
+ case "channel_mention":
616
+ textContent += `<#${data.id}>`;
617
+ break;
618
+
619
+ case "role_mention":
620
+ textContent += `<@&${data.id}>`;
621
+ break;
622
+
623
+ case "emoji":
624
+ textContent += data.animated
625
+ ? `<a:${data.name}:${data.id}>`
626
+ : `<:${data.name}:${data.id}>`;
627
+ break;
628
+
629
+ case "image":
630
+ case "audio":
631
+ case "video":
632
+ case "file":
633
+ await this.handleFileSegment(data, files, textContent);
634
+ break;
635
+
636
+ case "embed":
637
+ embeds.push(this.createEmbedFromData(data));
638
+ break;
639
+
640
+ default:
641
+ // 未知类型作为文本处理
642
+ textContent += data.text || `[${type}]`;
643
+ }
634
644
  }
635
645
 
636
- // 从数据创建 Embed
637
- createEmbedFromData(data: any): EmbedBuilder {
638
- const embed = new EmbedBuilder();
639
-
640
- if (data.title) embed.setTitle(data.title);
641
- if (data.description) embed.setDescription(data.description);
642
- if (data.color) embed.setColor(data.color);
643
- if (data.url) embed.setURL(data.url);
644
- if (data.thumbnail?.url) embed.setThumbnail(data.thumbnail.url);
645
- if (data.image?.url) embed.setImage(data.image.url);
646
- if (data.author) embed.setAuthor(data.author);
647
- if (data.footer) embed.setFooter(data.footer);
648
- if (data.timestamp) embed.setTimestamp(new Date(data.timestamp));
649
- if (data.fields && Array.isArray(data.fields)) {
650
- embed.addFields(data.fields);
651
- }
652
-
653
- return embed;
646
+ // 设置消息内容
647
+ if (textContent.trim()) {
648
+ messageOptions.content = textContent.trim();
654
649
  }
655
650
 
656
- // 工具方法:获取活动类型
657
- private getActivityType(type: string) {
658
- const activityTypes = {
659
- 'PLAYING': 0,
660
- 'STREAMING': 1,
661
- 'LISTENING': 2,
662
- 'WATCHING': 3,
663
- 'COMPETING': 5
664
- };
665
- return activityTypes[type as keyof typeof activityTypes] || 0;
651
+ if (embeds.length > 0) {
652
+ messageOptions.embeds = embeds.slice(0, 10); // Discord 限制最多10个embed
666
653
  }
667
654
 
668
- // 注册 Slash Commands
669
- private async registerSlashCommands(): Promise<void> {
670
- if (!this.$config.slashCommands || !this.user) return;
671
-
672
- try {
673
- const rest = new REST({ version: '10' }).setToken(this.$config.token);
674
-
675
- if (this.$config.globalCommands) {
676
- // 注册全局命令
677
- await rest.put(
678
- Routes.applicationCommands(this.user.id),
679
- { body: this.$config.slashCommands }
680
- );
681
- this.plugin.logger.info('Successfully registered global slash commands');
682
- } else {
683
- // 为每个服务器注册命令
684
- for (const guild of this.guilds.cache.values()) {
685
- await rest.put(
686
- Routes.applicationGuildCommands(this.user.id, guild.id),
687
- { body: this.$config.slashCommands }
688
- );
689
- }
690
- this.plugin.logger.info('Successfully registered guild slash commands');
691
- }
692
- } catch (error) {
693
- this.plugin.logger.error('Failed to register slash commands:', error);
694
- }
655
+ if (files.length > 0) {
656
+ messageOptions.files = files;
695
657
  }
696
658
 
697
- // 添加 Slash Command 处理器
698
- addSlashCommandHandler(commandName: string, handler: (interaction: ChatInputCommandInteraction) => Promise<void>) {
699
- this.slashCommandHandlers.set(commandName, handler);
659
+ // 发送消息
660
+ return await channel.send(messageOptions);
661
+ }
662
+ async $recallMessage(id: string): Promise<void> {}
663
+ // 处理文件段
664
+ async handleFileSegment(
665
+ data: any,
666
+ files: AttachmentBuilder[],
667
+ textContent: string
668
+ ): Promise<void> {
669
+ if (data.file && (await this.fileExists(data.file))) {
670
+ // 本地文件
671
+ files.push(
672
+ new AttachmentBuilder(createReadStream(data.file), {
673
+ name: data.name || path.basename(data.file),
674
+ })
675
+ );
676
+ } else if (data.url) {
677
+ // URL 文件
678
+ files.push(
679
+ new AttachmentBuilder(data.url, {
680
+ name: data.name || "attachment",
681
+ })
682
+ );
683
+ } else if (data.buffer) {
684
+ // Buffer 数据
685
+ files.push(
686
+ new AttachmentBuilder(data.buffer, {
687
+ name: data.name || "attachment",
688
+ })
689
+ );
700
690
  }
701
-
702
- // 移除 Slash Command 处理器
703
- removeSlashCommandHandler(commandName: string): boolean {
704
- return this.slashCommandHandlers.delete(commandName);
691
+ }
692
+
693
+ // 从数据创建 Embed
694
+ createEmbedFromData(data: any): EmbedBuilder {
695
+ const embed = new EmbedBuilder();
696
+
697
+ if (data.title) embed.setTitle(data.title);
698
+ if (data.description) embed.setDescription(data.description);
699
+ if (data.color) embed.setColor(data.color);
700
+ if (data.url) embed.setURL(data.url);
701
+ if (data.thumbnail?.url) embed.setThumbnail(data.thumbnail.url);
702
+ if (data.image?.url) embed.setImage(data.image.url);
703
+ if (data.author) embed.setAuthor(data.author);
704
+ if (data.footer) embed.setFooter(data.footer);
705
+ if (data.timestamp) embed.setTimestamp(new Date(data.timestamp));
706
+ if (data.fields && Array.isArray(data.fields)) {
707
+ embed.addFields(data.fields);
705
708
  }
706
709
 
707
- // 工具方法:检查文件是否存在
708
- private async fileExists(filePath: string): Promise<boolean> {
709
- try {
710
- await fs.access(filePath);
711
- return true;
712
- } catch {
713
- return false;
710
+ return embed;
711
+ }
712
+
713
+ // 工具方法:获取活动类型
714
+ private getActivityType(type: string) {
715
+ const activityTypes = {
716
+ PLAYING: 0,
717
+ STREAMING: 1,
718
+ LISTENING: 2,
719
+ WATCHING: 3,
720
+ COMPETING: 5,
721
+ };
722
+ return activityTypes[type as keyof typeof activityTypes] || 0;
723
+ }
724
+
725
+ // 注册 Slash Commands
726
+ private async registerSlashCommands(): Promise<void> {
727
+ if (!this.$config.slashCommands || !this.user) return;
728
+
729
+ try {
730
+ const rest = new REST({ version: "10" }).setToken(this.$config.token);
731
+
732
+ if (this.$config.globalCommands) {
733
+ // 注册全局命令
734
+ await rest.put(Routes.applicationCommands(this.user.id), {
735
+ body: this.$config.slashCommands,
736
+ });
737
+ plugin.logger.info("Successfully registered global slash commands");
738
+ } else {
739
+ // 为每个服务器注册命令
740
+ for (const guild of this.guilds.cache.values()) {
741
+ await rest.put(
742
+ Routes.applicationGuildCommands(this.user.id, guild.id),
743
+ { body: this.$config.slashCommands }
744
+ );
714
745
  }
746
+ plugin.logger.info("Successfully registered guild slash commands");
747
+ }
748
+ } catch (error) {
749
+ plugin.logger.error("Failed to register slash commands:", error);
715
750
  }
716
-
717
- // 静态方法:格式化内容为文本(用于日志显示)
718
- static formatContentToText(content: SendContent): string {
719
- if (!Array.isArray(content)) content = [content];
720
-
721
- return content.map(segment => {
722
- if (typeof segment === 'string') return segment;
723
-
724
- switch (segment.type) {
725
- case 'text':
726
- return segment.data.text || '';
727
- case 'at':
728
- return `@${segment.data.name || segment.data.id}`;
729
- case 'channel_mention':
730
- return `#${segment.data.name}`;
731
- case 'role_mention':
732
- return `@${segment.data.name}`;
733
- case 'image':
734
- return '[图片]';
735
- case 'audio':
736
- return '[音频]';
737
- case 'video':
738
- return '[视频]';
739
- case 'file':
740
- return '[文件]';
741
- case 'embed':
742
- return '[嵌入消息]';
743
- case 'emoji':
744
- return `:${segment.data.name}:`;
745
- default:
746
- return `[${segment.type}]`;
747
- }
748
- }).join('');
751
+ }
752
+
753
+ // 添加 Slash Command 处理器
754
+ addSlashCommandHandler(
755
+ commandName: string,
756
+ handler: (interaction: ChatInputCommandInteraction) => Promise<void>
757
+ ) {
758
+ this.slashCommandHandlers.set(commandName, handler);
759
+ }
760
+
761
+ // 移除 Slash Command 处理器
762
+ removeSlashCommandHandler(commandName: string): boolean {
763
+ return this.slashCommandHandlers.delete(commandName);
764
+ }
765
+
766
+ // 工具方法:检查文件是否存在
767
+ private async fileExists(filePath: string): Promise<boolean> {
768
+ try {
769
+ await fs.access(filePath);
770
+ return true;
771
+ } catch {
772
+ return false;
749
773
  }
774
+ }
775
+
776
+ // 静态方法:格式化内容为文本(用于日志显示)
777
+ static formatContentToText(content: SendContent): string {
778
+ if (!Array.isArray(content)) content = [content];
779
+
780
+ return content
781
+ .map((segment) => {
782
+ if (typeof segment === "string") return segment;
783
+
784
+ switch (segment.type) {
785
+ case "text":
786
+ return segment.data.text || "";
787
+ case "at":
788
+ return `@${segment.data.name || segment.data.id}`;
789
+ case "channel_mention":
790
+ return `#${segment.data.name}`;
791
+ case "role_mention":
792
+ return `@${segment.data.name}`;
793
+ case "image":
794
+ return "[图片]";
795
+ case "audio":
796
+ return "[音频]";
797
+ case "video":
798
+ return "[视频]";
799
+ case "file":
800
+ return "[文件]";
801
+ case "embed":
802
+ return "[嵌入消息]";
803
+ case "emoji":
804
+ return `:${segment.data.name}:`;
805
+ default:
806
+ return `[${segment.type}]`;
807
+ }
808
+ })
809
+ .join("");
810
+ }
750
811
  }
751
812
 
752
813
  // ================================================================================================
753
814
  // DiscordInteractionsBot 类(Interactions 端点模式)
754
815
  // ================================================================================================
755
816
 
756
- import * as nacl from 'tweetnacl';
757
-
758
- export class DiscordInteractionsBot extends Client implements Bot<any, DiscordInteractionsConfig> {
759
- $connected?: boolean
760
- private router: any
761
- private slashCommandHandlers: Map<string, (interaction: any) => Promise<void>> = new Map()
762
-
763
- constructor(private plugin: Plugin, router: any, public $config: DiscordInteractionsConfig) {
764
- const intents = $config.intents || [
765
- GatewayIntentBits.Guilds,
766
- GatewayIntentBits.GuildMessages,
767
- GatewayIntentBits.MessageContent,
768
- ];
769
-
770
- super({ intents });
771
- this.$connected = false;
772
- this.router = router;
773
-
774
- // 设置交互端点路由
775
- this.setupInteractionsEndpoint();
817
+ import * as nacl from "tweetnacl";
818
+
819
+ export class DiscordInteractionsBot
820
+ extends Client
821
+ implements Bot<any, DiscordInteractionsConfig>
822
+ {
823
+ $connected?: boolean;
824
+ private router: any;
825
+ private slashCommandHandlers: Map<
826
+ string,
827
+ (interaction: any) => Promise<void>
828
+ > = new Map();
829
+
830
+ constructor(router: any, public $config: DiscordInteractionsConfig) {
831
+ const intents = $config.intents || [
832
+ GatewayIntentBits.Guilds,
833
+ GatewayIntentBits.GuildMessages,
834
+ GatewayIntentBits.MessageContent,
835
+ ];
836
+
837
+ super({ intents });
838
+ this.$connected = false;
839
+ this.router = router;
840
+
841
+ // 设置交互端点路由
842
+ this.setupInteractionsEndpoint();
843
+ }
844
+
845
+ private setupInteractionsEndpoint(): void {
846
+ // 设置路由处理 Discord Interactions
847
+ this.router.post(this.$config.interactionsPath, (ctx: Context) => {
848
+ this.handleInteraction(ctx);
849
+ });
850
+ }
851
+
852
+ private async handleInteraction(ctx: Context): Promise<void> {
853
+ try {
854
+ const signature = ctx.get("x-signature-ed25519");
855
+ const timestamp = ctx.get("x-signature-timestamp");
856
+ const bodyString = JSON.stringify((ctx.request as any).body);
857
+
858
+ // 验证请求签名
859
+ if (!this.verifyDiscordSignature(bodyString, signature, timestamp)) {
860
+ plugin.logger.warn("Invalid Discord signature");
861
+ ctx.status = 401;
862
+ ctx.body = "Unauthorized";
863
+ return;
864
+ }
865
+
866
+ const interaction = (ctx.request as any).body;
867
+
868
+ // 处理不同类型的交互
869
+ if (interaction.type === InteractionType.Ping) {
870
+ // PING - Discord 验证端点
871
+ ctx.body = { type: InteractionResponseType.Pong };
872
+ } else if (interaction.type === InteractionType.ApplicationCommand) {
873
+ // APPLICATION_COMMAND - 应用命令
874
+ const response = await this.handleApplicationCommand(interaction);
875
+ ctx.body = response;
876
+ } else {
877
+ // 其他交互类型
878
+ ctx.status = 400;
879
+ ctx.body = "Unsupported interaction type";
880
+ }
881
+ } catch (error) {
882
+ plugin.logger.error("Interactions error:", error);
883
+ ctx.status = 500;
884
+ ctx.body = "Internal Server Error";
776
885
  }
777
-
778
- private setupInteractionsEndpoint(): void {
779
- // 设置路由处理 Discord Interactions
780
- this.router.post(this.$config.interactionsPath, (ctx: Context) => {
781
- this.handleInteraction(ctx);
782
- });
886
+ }
887
+
888
+ private verifyDiscordSignature(
889
+ body: string,
890
+ signature: string,
891
+ timestamp: string
892
+ ): boolean {
893
+ try {
894
+ const publicKey = Buffer.from(this.$config.publicKey, "hex");
895
+ const sig = Buffer.from(signature, "hex");
896
+ const message = Buffer.from(timestamp + body, "utf8");
897
+
898
+ return nacl.sign.detached.verify(message, sig, publicKey);
899
+ } catch (error) {
900
+ plugin.logger.error("Signature verification error:", error);
901
+ return false;
783
902
  }
784
-
785
- private async handleInteraction(ctx: Context): Promise<void> {
786
- try {
787
- const signature = ctx.get('x-signature-ed25519');
788
- const timestamp = ctx.get('x-signature-timestamp');
789
- const bodyString = JSON.stringify((ctx.request as any).body);
790
-
791
- // 验证请求签名
792
- if (!this.verifyDiscordSignature(bodyString, signature, timestamp)) {
793
- this.plugin.logger.warn('Invalid Discord signature');
794
- ctx.status = 401;
795
- ctx.body = 'Unauthorized';
796
- return;
797
- }
798
-
799
- const interaction = (ctx.request as any).body;
800
-
801
- // 处理不同类型的交互
802
- if (interaction.type === InteractionType.Ping) {
803
- // PING - Discord 验证端点
804
- ctx.body = { type: InteractionResponseType.Pong };
805
- } else if (interaction.type === InteractionType.ApplicationCommand) {
806
- // APPLICATION_COMMAND - 应用命令
807
- const response = await this.handleApplicationCommand(interaction);
808
- ctx.body = response;
809
- } else {
810
- // 其他交互类型
811
- ctx.status = 400;
812
- ctx.body = 'Unsupported interaction type';
813
- }
814
-
815
- } catch (error) {
816
- this.plugin.logger.error('Interactions error:', error);
817
- ctx.status = 500;
818
- ctx.body = 'Internal Server Error';
819
- }
903
+ }
904
+
905
+ private async handleApplicationCommand(interaction: any): Promise<any> {
906
+ // 处理应用命令
907
+ const commandName = interaction.data.name;
908
+
909
+ // 转换为标准消息格式并分发
910
+ const message = this.formatInteractionAsMessage(interaction);
911
+ plugin.dispatch("message.receive", message);
912
+
913
+ // 查找自定义处理器
914
+ const handler = this.slashCommandHandlers.get(commandName);
915
+ if (handler) {
916
+ try {
917
+ await handler(interaction);
918
+ } catch (error) {
919
+ plugin.logger.error(
920
+ `Error in slash command handler for ${commandName}:`,
921
+ error
922
+ );
923
+ }
820
924
  }
821
925
 
822
- private verifyDiscordSignature(body: string, signature: string, timestamp: string): boolean {
823
- try {
824
- const publicKey = Buffer.from(this.$config.publicKey, 'hex');
825
- const sig = Buffer.from(signature, 'hex');
826
- const message = Buffer.from(timestamp + body, 'utf8');
827
-
828
- return nacl.sign.detached.verify(message, sig, publicKey);
829
- } catch (error) {
830
- this.plugin.logger.error('Signature verification error:', error);
831
- return false;
832
- }
926
+ // 默认响应
927
+ return {
928
+ type: InteractionResponseType.ChannelMessageWithSource,
929
+ data: {
930
+ content: `处理命令: ${commandName}`,
931
+ flags: 64, // EPHEMERAL - 只有用户可见
932
+ },
933
+ };
934
+ }
935
+
936
+ private formatInteractionAsMessage(interaction: any): Message<any> {
937
+ const channelType = interaction.guild_id ? "channel" : "private";
938
+ const channelId = interaction.channel_id;
939
+
940
+ // 解析命令参数为内容
941
+ const options = interaction.data.options || [];
942
+ const content = [segment.text(`/${interaction.data.name}`)];
943
+
944
+ for (const option of options) {
945
+ content.push(segment.text(` ${option.name}:${option.value}`));
833
946
  }
834
947
 
835
- private async handleApplicationCommand(interaction: any): Promise<any> {
836
- // 处理应用命令
837
- const commandName = interaction.data.name;
838
-
839
- // 转换为标准消息格式并分发
840
- const message = this.formatInteractionAsMessage(interaction);
841
- this.plugin.dispatch('message.receive', message);
842
-
843
- // 查找自定义处理器
844
- const handler = this.slashCommandHandlers.get(commandName);
845
- if (handler) {
846
- try {
847
- await handler(interaction);
848
- } catch (error) {
849
- this.plugin.logger.error(`Error in slash command handler for ${commandName}:`, error);
850
- }
851
- }
948
+ return Message.from(interaction, {
949
+ $id: interaction.id,
950
+ $adapter: "discord-interactions",
951
+ $bot: this.$config.name,
952
+ $sender: {
953
+ id: interaction.user?.id || interaction.member?.user?.id,
954
+ name: interaction.user?.username || interaction.member?.user?.username,
955
+ },
956
+ $channel: {
957
+ id: channelId,
958
+ type: channelType as any,
959
+ },
960
+ $raw: JSON.stringify(interaction),
961
+ $timestamp: Date.now(),
962
+ $content: content,
963
+ $reply: async (content: SendContent): Promise<string> => {
964
+ return this.$sendMessage({
965
+ ...this.$formatMessage(interaction),
966
+ content: content,
967
+ });
968
+ },
969
+ });
970
+ }
852
971
 
853
- // 默认响应
854
- return {
855
- type: InteractionResponseType.ChannelMessageWithSource,
856
- data: {
857
- content: `处理命令: ${commandName}`,
858
- flags: 64 // EPHEMERAL - 只有用户可见
859
- }
860
- };
972
+ private formatSendContent(content: SendContent): any {
973
+ if (typeof content === "string") {
974
+ return { content };
861
975
  }
862
976
 
863
- private formatInteractionAsMessage(interaction: any): Message<any> {
864
- const channelType = interaction.guild_id ? 'channel' : 'private';
865
- const channelId = interaction.channel_id;
866
-
867
- // 解析命令参数为内容
868
- const options = interaction.data.options || [];
869
- const content = [segment.text(`/${interaction.data.name}`)];
870
-
871
- for (const option of options) {
872
- content.push(segment.text(` ${option.name}:${option.value}`));
873
- }
874
-
875
- return Message.from(interaction, {
876
- $id: interaction.id,
877
- $adapter: 'discord-interactions',
878
- $bot: this.$config.name,
879
- $sender: {
880
- id: interaction.user?.id || interaction.member?.user?.id,
881
- name: interaction.user?.username || interaction.member?.user?.username
882
- },
883
- $channel: {
884
- id: channelId,
885
- type: channelType as any
886
- },
887
- $raw: JSON.stringify(interaction),
888
- $timestamp: Date.now(),
889
- $content: content,
890
- $reply: async (content: SendContent): Promise<string> => {
891
- return this.$sendMessage({
892
- ...this.$formatMessage(interaction),
893
- content: content
894
- });
895
- }
896
- });
897
- }
977
+ if (Array.isArray(content)) {
978
+ const textParts: string[] = [];
979
+ let embed: any = null;
898
980
 
899
- private formatSendContent(content: SendContent): any {
900
- if (typeof content === 'string') {
901
- return { content };
902
- }
903
-
904
- if (Array.isArray(content)) {
905
- const textParts: string[] = [];
906
- let embed: any = null;
907
-
908
- for (const item of content) {
909
- if (typeof item === 'string') {
910
- textParts.push(item);
911
- } else {
912
- const segment = item as MessageSegment;
913
- switch (segment.type) {
914
- case 'text':
915
- textParts.push(segment.data.text || segment.data.content || '');
916
- break;
917
- case 'embed':
918
- embed = segment.data;
919
- break;
920
- }
921
- }
922
- }
923
-
924
- const result: any = {};
925
- if (textParts.length > 0) {
926
- result.content = textParts.join('');
927
- }
928
- if (embed) {
929
- result.embeds = [embed];
930
- }
931
-
932
- return result;
981
+ for (const item of content) {
982
+ if (typeof item === "string") {
983
+ textParts.push(item);
984
+ } else {
985
+ const segment = item as MessageSegment;
986
+ switch (segment.type) {
987
+ case "text":
988
+ textParts.push(segment.data.text || segment.data.content || "");
989
+ break;
990
+ case "embed":
991
+ embed = segment.data;
992
+ break;
993
+ }
933
994
  }
934
-
935
- return { content: String(content) };
936
- }
995
+ }
937
996
 
938
- async $connect(): Promise<void> {
939
- try {
940
- // 注册 Slash Commands
941
- if (this.$config.slashCommands) {
942
- await this.registerSlashCommands();
943
- }
944
-
945
- // 如果启用 Gateway,连接 Discord Gateway
946
- if (this.$config.useGateway) {
947
- await this.login(this.$config.token);
948
-
949
- // 设置活动状态
950
- if (this.$config.defaultActivity) {
951
- this.user?.setActivity(this.$config.defaultActivity.name, {
952
- type: this.getActivityType(this.$config.defaultActivity.type),
953
- url: this.$config.defaultActivity.url
954
- });
955
- }
956
- }
957
-
958
- this.$connected = true;
959
- this.plugin.logger.info(`Discord interactions bot connected: ${this.$config.name}`);
960
- this.plugin.logger.info(`Interactions endpoint: ${this.$config.interactionsPath}`);
961
-
962
- } catch (error) {
963
- this.plugin.logger.error('Failed to connect Discord interactions bot:', error);
964
- throw error;
965
- }
966
- }
997
+ const result: any = {};
998
+ if (textParts.length > 0) {
999
+ result.content = textParts.join("");
1000
+ }
1001
+ if (embed) {
1002
+ result.embeds = [embed];
1003
+ }
967
1004
 
968
- async $disconnect(): Promise<void> {
969
- try {
970
- if (this.isReady()) {
971
- await this.destroy();
972
- }
973
- this.$connected = false;
974
- this.plugin.logger.info('Discord interactions bot disconnected');
975
- } catch (error) {
976
- this.plugin.logger.error('Error disconnecting Discord interactions bot:', error);
977
- }
1005
+ return result;
978
1006
  }
979
1007
 
980
- // Slash Commands 管理
981
- private async registerSlashCommands(): Promise<void> {
982
- if (!this.$config.slashCommands) return;
983
-
984
- try {
985
- const rest = new REST({ version: '10' }).setToken(this.$config.token);
986
-
987
- if (this.$config.globalCommands) {
988
- await rest.put(
989
- Routes.applicationCommands(this.$config.applicationId),
990
- { body: this.$config.slashCommands }
991
- );
992
- this.plugin.logger.info('Successfully registered global slash commands');
993
- } else {
994
- this.plugin.logger.info('Note: Guild commands registration requires connecting to Gateway first');
995
- }
996
- } catch (error) {
997
- this.plugin.logger.error('Failed to register slash commands:', error);
1008
+ return { content: String(content) };
1009
+ }
1010
+
1011
+ async $connect(): Promise<void> {
1012
+ try {
1013
+ // 注册 Slash Commands
1014
+ if (this.$config.slashCommands) {
1015
+ await this.registerSlashCommands();
1016
+ }
1017
+
1018
+ // 如果启用 Gateway,连接 Discord Gateway
1019
+ if (this.$config.useGateway) {
1020
+ await this.login(this.$config.token);
1021
+
1022
+ // 设置活动状态
1023
+ if (this.$config.defaultActivity) {
1024
+ this.user?.setActivity(this.$config.defaultActivity.name, {
1025
+ type: this.getActivityType(this.$config.defaultActivity.type),
1026
+ url: this.$config.defaultActivity.url,
1027
+ });
998
1028
  }
1029
+ }
1030
+
1031
+ this.$connected = true;
1032
+ plugin.logger.info(
1033
+ `Discord interactions bot connected: ${this.$config.name}`
1034
+ );
1035
+ plugin.logger.info(
1036
+ `Interactions endpoint: ${this.$config.interactionsPath}`
1037
+ );
1038
+ } catch (error) {
1039
+ plugin.logger.error("Failed to connect Discord interactions bot:", error);
1040
+ throw error;
999
1041
  }
1000
-
1001
- // 添加 Slash Command 处理器
1002
- addSlashCommandHandler(commandName: string, handler: (interaction: any) => Promise<void>): void {
1003
- this.slashCommandHandlers.set(commandName, handler);
1004
- }
1005
-
1006
- // 移除 Slash Command 处理器
1007
- removeSlashCommandHandler(commandName: string): boolean {
1008
- return this.slashCommandHandlers.delete(commandName);
1042
+ }
1043
+
1044
+ async $disconnect(): Promise<void> {
1045
+ try {
1046
+ if (this.isReady()) {
1047
+ await this.destroy();
1048
+ }
1049
+ this.$connected = false;
1050
+ plugin.logger.info("Discord interactions bot disconnected");
1051
+ } catch (error) {
1052
+ plugin.logger.error(
1053
+ "Error disconnecting Discord interactions bot:",
1054
+ error
1055
+ );
1009
1056
  }
1057
+ }
1010
1058
 
1011
- // 工具方法
1012
- private getActivityType(type: string): any {
1013
- const activityTypes: any = {
1014
- 'PLAYING': 0,
1015
- 'STREAMING': 1,
1016
- 'LISTENING': 2,
1017
- 'WATCHING': 3,
1018
- 'COMPETING': 5
1019
- };
1020
- return activityTypes[type] || 0;
1021
- }
1059
+ // Slash Commands 管理
1060
+ private async registerSlashCommands(): Promise<void> {
1061
+ if (!this.$config.slashCommands) return;
1022
1062
 
1023
- // 简化实现 - 只支持基本消息格式化和发送
1024
- $formatMessage(msg: any): Message<any> {
1025
- return this.formatInteractionAsMessage(msg);
1026
- }
1063
+ try {
1064
+ const rest = new REST({ version: "10" }).setToken(this.$config.token);
1027
1065
 
1028
- async $sendMessage(options: SendOptions): Promise<string> {
1029
- // 简化实现 - 通过 REST API 发送消息
1030
- try {
1031
- const rest = new REST({ version: '10' }).setToken(this.$config.token);
1032
- const messageContent = this.formatSendContent(options.content);
1033
-
1034
- await rest.post(
1035
- Routes.channelMessages(options.id),
1036
- { body: messageContent }
1037
- );
1038
- } catch (error) {
1039
- this.plugin.logger.error('Failed to send message:', error);
1040
- }
1041
- return ''
1066
+ if (this.$config.globalCommands) {
1067
+ await rest.put(Routes.applicationCommands(this.$config.applicationId), {
1068
+ body: this.$config.slashCommands,
1069
+ });
1070
+ plugin.logger.info("Successfully registered global slash commands");
1071
+ } else {
1072
+ plugin.logger.info(
1073
+ "Note: Guild commands registration requires connecting to Gateway first"
1074
+ );
1075
+ }
1076
+ } catch (error) {
1077
+ plugin.logger.error("Failed to register slash commands:", error);
1042
1078
  }
1043
- async $recallMessage(id:string):Promise<void> {
1044
-
1079
+ }
1080
+
1081
+ // 添加 Slash Command 处理器
1082
+ addSlashCommandHandler(
1083
+ commandName: string,
1084
+ handler: (interaction: any) => Promise<void>
1085
+ ): void {
1086
+ this.slashCommandHandlers.set(commandName, handler);
1087
+ }
1088
+
1089
+ // 移除 Slash Command 处理器
1090
+ removeSlashCommandHandler(commandName: string): boolean {
1091
+ return this.slashCommandHandlers.delete(commandName);
1092
+ }
1093
+
1094
+ // 工具方法
1095
+ private getActivityType(type: string): any {
1096
+ const activityTypes: any = {
1097
+ PLAYING: 0,
1098
+ STREAMING: 1,
1099
+ LISTENING: 2,
1100
+ WATCHING: 3,
1101
+ COMPETING: 5,
1102
+ };
1103
+ return activityTypes[type] || 0;
1104
+ }
1105
+
1106
+ // 简化实现 - 只支持基本消息格式化和发送
1107
+ $formatMessage(msg: any): Message<any> {
1108
+ return this.formatInteractionAsMessage(msg);
1109
+ }
1110
+
1111
+ async $sendMessage(options: SendOptions): Promise<string> {
1112
+ // 简化实现 - 通过 REST API 发送消息
1113
+ try {
1114
+ const rest = new REST({ version: "10" }).setToken(this.$config.token);
1115
+ const messageContent = this.formatSendContent(options.content);
1116
+
1117
+ await rest.post(Routes.channelMessages(options.id), {
1118
+ body: messageContent,
1119
+ });
1120
+ } catch (error) {
1121
+ plugin.logger.error("Failed to send message:", error);
1045
1122
  }
1123
+ return "";
1124
+ }
1125
+ async $recallMessage(id: string): Promise<void> {}
1046
1126
  }
1047
1127
 
1048
1128
  // 注册 Gateway 模式适配器
1049
- registerAdapter(new Adapter('discord', (plugin: Plugin, config: any) => new DiscordBot(plugin, config as DiscordBotConfig)))
1129
+ registerAdapter(
1130
+ new Adapter(
1131
+ "discord",
1132
+ (config: any) => new DiscordBot(config as DiscordBotConfig)
1133
+ )
1134
+ );
1050
1135
 
1051
1136
  // 注册 Interactions 端点模式适配器(需要 router)
1052
- useContext('router', (router) => {
1053
- registerAdapter(new Adapter('discord-interactions',
1054
- (plugin: Plugin, config: any) => new DiscordInteractionsBot(plugin, router, config as DiscordInteractionsConfig)
1055
- ));
1137
+ useContext("router", (router) => {
1138
+ registerAdapter(
1139
+ new Adapter(
1140
+ "discord-interactions",
1141
+ (config: any) =>
1142
+ new DiscordInteractionsBot(router, config as DiscordInteractionsConfig)
1143
+ )
1144
+ );
1056
1145
  });