@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.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 (74) hide show
  1. package/LICENSE +661 -661
  2. package/README.md +0 -14
  3. package/package.json +1 -1
  4. package/vendor/gugle-chat-bot/LICENSE +661 -661
  5. package/vendor/gugle-chat-bot/package.json +57 -57
  6. package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
  7. package/vendor/gugle-chat-bot/src/api.json +4284 -4284
  8. package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
  9. package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
  10. package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
  11. package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
  12. package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
  13. package/vendor/gugle-chat-bot/src/custom.ts +548 -548
  14. package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
  15. package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
  16. package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
  17. package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
  18. package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
  19. package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
  20. package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
  21. package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
  22. package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
  23. package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
  24. package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
  25. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
  26. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
  27. package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
  28. package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
  29. package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
  30. package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
  31. package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
  32. package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
  33. package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
  34. package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
  35. package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
  36. package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
  37. package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
  38. package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
  39. package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
  40. package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
  41. package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
  42. package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
  43. package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
  44. package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
  45. package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
  46. package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
  47. package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
  48. package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
  49. package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
  50. package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
  51. package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
  52. package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
  53. package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
  54. package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
  55. package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
  56. package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
  57. package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
  58. package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
  59. package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
  60. package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
  61. package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
  62. package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
  63. package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
  64. package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
  65. package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
  66. package/vendor/gugle-chat-bot/src/index.ts +695 -695
  67. package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
  68. package/vendor/gugle-chat-bot/src/result.json +359 -359
  69. package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
  70. package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
  71. package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
  72. package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
  73. package/vendor/gugle-chat-bot/tsconfig.json +46 -46
  74. package/scripts/dump-npm-latest.mjs +0 -30
@@ -1,1128 +1,1128 @@
1
- import { ChannelType, Client, GatewayIntentBits, Guild, Message as DiscordMessage, TextChannel } from 'discord.js';
2
- import { GroupMessageSentEvent, QQBot } from '@/index';
3
- import { GroupMessageWSMSG, Message, SentMessage } from '@/type';
4
- import { ConfigStore } from '@/config/manager';
5
- import { escapeDiscord, isDiscordMarkdown, renderDiscordMessageToImage } from '@/features/discord-bridge/markdown';
6
- import { DiscordMsgCommandSource, isDiscordAdmin } from '@/features/discord-bridge/source';
7
- import { executeCommand, normalizeCommandText } from '@/command';
8
- import { Github } from '@/features/github';
9
- import { HermesBridge } from '@/features/hermes';
10
- import { getHermesConfig } from '@/features/hermes/config';
11
- import { ReactionManager } from '@/features/hermes/reaction';
12
- import { handleSlashInteraction, registerSlashCommands } from '@/features/discord-bridge/slash';
13
-
14
- /**
15
- * QQ 群 ⇄ Discord 频道互通模块。
16
- *
17
- * 配置(configs/features/discord-bridge.json,v1,ConfigStore 热重载):
18
- * ```json
19
- * {
20
- * "version": 1,
21
- * "token": "DISCORD_BOT_TOKEN",
22
- * "bridges": {
23
- * "940551045929639949#general": {
24
- * "group": "659356928",
25
- * "need_reply": "false",
26
- * "need_cmd": "false"
27
- * }
28
- * }
29
- * }
30
- * ```
31
- * - key 为 `"服务器ID#频道名称"`(频道名以 Discord 当前名为准,改名需同步改配置);
32
- * - `group` 为互通的 QQ 群号;
33
- * - `need_reply=true`:Discord→QQ 全量转发;QQ→Discord 仅转发「回复桥消息」的回复内容;
34
- * - `need_cmd=true`:双向普通消息均不转发,仅 `/send <msg> [group|channel]` 与
35
- * 「回复桥消息」互通(回复会以回复形式回传到对侧);
36
- * - `/send` 不填目标时默认发送到配置文件中第一个互通条目的对端。
37
- *
38
- * 附加能力:
39
- * - 机器人自身发出的 QQ 群消息转发到该群绑定的频道(经 QQBot.onGroupMessageSent
40
- * 钩子,fromBridge 标记防止回环):与普通 QQ 消息同一套门控——need_cmd / need_reply
41
- * 频道仅放行「对该频道桥消息的回复」,webhook 推送(GitHub 订阅通知)一律不进;
42
- * - 机器人自己在互通频道发出的 Discord 消息(命令回复、AI 回复、GitHub 卡片、
43
- * 斜杠命令回复等)经 messageCreate 回推同步到 QQ(桥自身转发跳过防回环,
44
- * 门控与普通 Discord 消息一致),QQ 侧可看到完整对话;
45
- * - Discord 频道中可直接使用全部已注册命令(/ 或 ! 前缀),回复发在 Discord 频道;
46
- * 未注册命令的 /xxx 文本按普通消息转发,不会回发 Invalid command;
47
- * - 同时把命令树注册为 Discord 原生斜杠命令(guild 级、自动补全),交互经 token
48
- * 精确回放执行,回复发在 Discord 频道(需邀请链接含 applications.commands 权限);
49
- * - Discord 消息中的 `#编号` / `owner/repo#编号` 会查询 GitHub Issue/PR 并以图片卡片
50
- * 回复在 Discord 频道(一条消息多个编号全部解析,与 QQ 侧同一套渲染);
51
- * - 互通频道消息可触发 Hermes AI(审批回复 /@提及 / 关键词),频道是否启用由其桥接的
52
- * QQ 群在 hermes.json groups 中决定,AI 回复发在 Discord 频道。
53
- *
54
- * 前置条件:Discord 开发者后台为 bot 开启 Message Content Intent,并邀请入对应服务器。
55
- */
56
-
57
- // ---------------------------------------------------------------------------
58
- // 配置
59
- // ---------------------------------------------------------------------------
60
-
61
- export interface BridgeEntry {
62
- /** QQ 群号(字符串形式,兼容配置里写成数字) */
63
- group: string;
64
- /** true 时 QQ→Discord 仅转发对桥消息的回复 */
65
- need_reply: string;
66
- /** true 时双向仅 /send 与对桥消息的回复互通 */
67
- need_cmd: string;
68
- }
69
-
70
- export interface DiscordBridgeConfig {
71
- version: number;
72
- token: string;
73
- /** key: "服务器ID#频道名称" */
74
- bridges: Record<string, BridgeEntry>;
75
- }
76
-
77
- function normalizeBridgeFlag(raw: unknown): string | null {
78
- if (typeof raw === 'boolean') return raw ? 'true' : 'false';
79
- if (raw === 'true' || raw === 'false') return raw;
80
- return null;
81
- }
82
-
83
- function normalizeDiscordBridgeConfig(raw: unknown): DiscordBridgeConfig | null {
84
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
85
- const record = raw as Record<string, unknown>;
86
- const token = typeof record.token === 'string' ? record.token : '';
87
- const bridges: Record<string, BridgeEntry> = {};
88
- if (record.bridges && typeof record.bridges === 'object' && !Array.isArray(record.bridges)) {
89
- for (const [key, value] of Object.entries(record.bridges as Record<string, unknown>)) {
90
- if (!/^\d+#.+$/.test(key)) continue;
91
- if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
92
- const entry = value as Record<string, unknown>;
93
- let group: string;
94
- if (typeof entry.group === 'string' && /^\d+$/.test(entry.group)) group = entry.group;
95
- else if (typeof entry.group === 'number' && Number.isSafeInteger(entry.group)) group = String(entry.group);
96
- else continue;
97
- const needReply = normalizeBridgeFlag(entry.need_reply) ?? 'false';
98
- const needCmd = normalizeBridgeFlag(entry.need_cmd) ?? 'false';
99
- bridges[key] = { group, need_reply: needReply, need_cmd: needCmd };
100
- }
101
- }
102
- return { version: 1, token, bridges };
103
- }
104
-
105
- const bridgeStore = new ConfigStore<DiscordBridgeConfig>({
106
- path: 'configs/features/discord-bridge.json',
107
- version: 1,
108
- factory: () => ({ version: 1, token: '', bridges: {} }),
109
- normalize: normalizeDiscordBridgeConfig
110
- });
111
-
112
- /** 读取互通生效配置(热重载) */
113
- export function getDiscordBridgeConfig(): DiscordBridgeConfig {
114
- return bridgeStore.get();
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // 内存路由表(回复链映射)
119
- // ---------------------------------------------------------------------------
120
-
121
- /** 有界 FIFO 缓存:转发消息的 id 映射,容量封顶防止长期运行内存膨胀 */
122
- class BoundedCache<V> {
123
- private readonly map = new Map<number, V>();
124
-
125
- public constructor(private readonly capacity: number = 2000) {}
126
-
127
- public get(key: number): V | undefined {
128
- return this.map.get(key);
129
- }
130
-
131
- public set(key: number, value: V): void {
132
- if (this.map.has(key)) this.map.delete(key);
133
- this.map.set(key, value);
134
- if (this.map.size > this.capacity) {
135
- const oldest = this.map.keys().next().value;
136
- if (oldest !== undefined) this.map.delete(oldest);
137
- }
138
- }
139
- }
140
-
141
- // ---------------------------------------------------------------------------
142
- // DiscordBridge
143
- // ---------------------------------------------------------------------------
144
-
145
- const DISCORD_MESSAGE_LIMIT = 2000;
146
- const SEND_COMMAND_PREFIX = '/send';
147
-
148
- /** 已解析的互通条目(配置 key 拆分后的视图) */
149
- interface ResolvedBridge {
150
- key: string;
151
- guildId: string;
152
- channelName: string;
153
- entry: BridgeEntry;
154
- }
155
-
156
- export class DiscordBridge {
157
- private static instance?: DiscordBridge;
158
-
159
- public static getInstance(): DiscordBridge {
160
- if (!DiscordBridge.instance) DiscordBridge.instance = new DiscordBridge();
161
- return DiscordBridge.instance;
162
- }
163
-
164
- private client?: Client;
165
- private bot?: QQBot;
166
- private started = false;
167
-
168
- /** QQ 消息 id →(bridge key → Discord 消息 id):同群多频道时每个频道各有自己的转发,回复引用必须按频道取 */
169
- private readonly discordByQQ = new BoundedCache<Record<string, string>>();
170
- /** Discord 消息 id → QQ 消息 id */
171
- private readonly qqByDiscord = new BoundedCache<number>();
172
- /** 本桥转发到 QQ 的消息 id(回复链 & need_reply/need_cmd 判定) */
173
- private readonly qqFromBridge = new BoundedCache<true>();
174
- /** 本桥转发到 Discord 的消息 id(回复链 & need_cmd 判定) */
175
- private readonly dcFromBridge = new BoundedCache<true>();
176
- /** 转发到 QQ 的消息 id → 来源 bridge key(同群多频道时回复要回到原频道) */
177
- private readonly bridgeByQQForward = new BoundedCache<string>();
178
- /**
179
- * 桥发往 Discord 的待发消息计数(`${channelId}\n${content}` → 条数)。
180
- * messageCreate 回推可能先于 send() 返回(此时 dcFromBridge 尚未登记),
181
- * 发送前先登记、回推时核销,保证乱序下也不会把桥转发回传 QQ 形成回环。
182
- */
183
- private readonly pendingBridgeSends = new Map<string, number>();
184
-
185
- private constructor() {}
186
-
187
- /** 该群是否配置了互通条目(QQ 侧 /send 分发与桥接共用同一判定) */
188
- public static isSendTargetGroup(groupId: number | string): boolean {
189
- const group = String(groupId);
190
- return Object.values(getDiscordBridgeConfig().bridges).some(entry => entry.group === group);
191
- }
192
-
193
- /** after-start 钩子:启动 Discord 客户端(token 为空时禁用本功能) */
194
- public start(bot: QQBot): void {
195
- if (this.started) return;
196
- this.started = true;
197
- this.bot = bot;
198
- // 机器人自身发出的群消息(GitHub 通知、命令回复等)也转发到 Discord。
199
- // 注意:OneBot 不会把 bot 自己的消息回推为 message 事件,只能挂发送出口钩子。
200
- bot.onGroupMessageSent(event => {
201
- this.handleQQOutgoingMessage(event).catch(error => {
202
- bot.logger?.error(
203
- `[DiscordBridge] 机器人消息转发失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
204
- );
205
- });
206
- });
207
- const token = getDiscordBridgeConfig().token;
208
- if (!token) {
209
- bot.logger?.warn('[DiscordBridge] token 未配置(configs/features/discord-bridge.json),互通功能已禁用');
210
- return;
211
- }
212
- this.client = new Client({
213
- intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent]
214
- });
215
- this.client.on('messageCreate', message => {
216
- this.handleDiscordMessage(message).catch(error => {
217
- this.bot?.logger?.error(
218
- `[DiscordBridge] 处理 Discord 消息失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
219
- );
220
- });
221
- });
222
- this.client.on('error', error => {
223
- this.bot?.logger?.error(`[DiscordBridge] Discord 客户端错误: ${error.message}`);
224
- });
225
- this.client.once('clientReady', client => {
226
- this.bot?.logger?.info(`[DiscordBridge] 已登录 Discord:${client.user.tag}`);
227
- // 清理上次进程退出时残留的「AI 处理中」表情回应(Discord 侧)
228
- this.cleanupStaleHermesReactions(client).catch(error => {
229
- this.bot?.logger?.error(
230
- `[DiscordBridge] 残留表情回应清理失败: ${error instanceof Error ? error.message : String(error)}`
231
- );
232
- });
233
- // 注册原生斜杠命令(guild 级即时生效;bridges 配置去重的全部服务器)
234
- const guildIds = [...new Set(Object.keys(getDiscordBridgeConfig().bridges).map(key => key.split('#')[0]!))];
235
- registerSlashCommands(client, guildIds, bot).catch(error => {
236
- this.bot?.logger?.error(
237
- `[DiscordBridge] 注册斜杠命令异常: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
238
- );
239
- });
240
- });
241
- // 原生斜杠命令交互:经 token 精确回放交给 gugle-command 执行,回复发在 Discord
242
- this.client.on('interactionCreate', interaction => {
243
- if (!interaction.isChatInputCommand() || !this.bot) return;
244
- const bot = this.bot;
245
- handleSlashInteraction(bot, interaction, (guildId, channelName) => {
246
- const bridge = channelName ? this.resolveByChannel(guildId, channelName) : undefined;
247
- const group = bridge ? Number(bridge.entry.group) : NaN;
248
- return Number.isSafeInteger(group) ? group : undefined;
249
- }).catch(error => {
250
- bot.logger?.error(
251
- `[DiscordBridge] 斜杠命令交互失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
252
- );
253
- });
254
- });
255
- this.client.login(token).catch(error => {
256
- this.bot?.logger?.error(
257
- `[DiscordBridge] Discord 登录失败: ${error instanceof Error ? error.message : String(error)}`
258
- );
259
- });
260
- }
261
-
262
- /**
263
- * 启动清理:摘除上次进程在 AI 处理中退出时残留的 Discord 表情回应。
264
- * 记录由 HermesBridge 在贴表情时立即持久化(data/hermes-reactions.json);
265
- * 消息已删除/频道不可达等失败仅清理记录,不做重试。
266
- */
267
- private async cleanupStaleHermesReactions(client: Client): Promise<void> {
268
- const reactions = ReactionManager.getInstance();
269
- const stale = reactions.listDiscord();
270
- for (const record of stale) {
271
- try {
272
- const channel = record.channelId ? await client.channels.fetch(record.channelId) : null;
273
- if (channel?.isTextBased()) {
274
- const message = await channel.messages.fetch(record.messageId);
275
- const reaction =
276
- message.reactions.resolve(record.emoji) ??
277
- message.reactions.cache.find(item => item.emoji.name === record.emoji);
278
- await reaction?.users.remove(client.user?.id);
279
- }
280
- } catch (error) {
281
- this.bot?.logger?.warn(
282
- `[DiscordBridge] 摘除残留表情回应失败(${record.key}): ${error instanceof Error ? error.message : String(error)}`
283
- );
284
- }
285
- reactions.drop(record.key);
286
- }
287
- if (stale.length > 0) {
288
- this.bot?.logger?.info(`[DiscordBridge] 已清理 ${stale.length} 条残留的 Discord 表情回应`);
289
- }
290
- }
291
-
292
- // -------------------------------------------------------------------------
293
- // QQ → Discord
294
- // -------------------------------------------------------------------------
295
-
296
- /** message-event-group 钩子:把 QQ 群消息转发到 Discord(或执行 /send) */
297
- public handleQQMessage(bot: QQBot, msg: GroupMessageWSMSG): void {
298
- try {
299
- // 同一 QQ 群可绑定多个频道:普通消息逐条门控并转发到所有放行的频道
300
- const bridges = this.resolveAllByGroup(msg.group_id);
301
- if (bridges.length === 0) return;
302
-
303
- // /send 命令(两个 need_cmd 状态都可用;与全局命令分发互斥,此处独立处理)
304
- // 仅拦截「/send + 空白/结尾」,/sendxxx 之类的消息仍按普通消息转发
305
- const raw = msg.raw_message ?? '';
306
- if (this.isSendCommand(raw)) {
307
- this.handleSendFromQQ(bot, msg, raw).catch(error => {
308
- bot.logger?.error(
309
- `[DiscordBridge] QQ /send 执行失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
310
- );
311
- });
312
- return;
313
- }
314
-
315
- const replyId = this.extractQQReplyId(msg);
316
- // 「对桥消息的回复」需定位到该桥消息来自的频道(回复要回到原频道)
317
- const replyBridge = replyId !== undefined ? this.bridgeByQQForward.get(replyId) : undefined;
318
- const isReplyToBridge = replyBridge !== undefined;
319
-
320
- for (const bridge of bridges) {
321
- // 门控:need_cmd / need_reply 任一开启时,仅放行「对(该频道)桥消息的回复」
322
- if (bridge.entry.need_cmd === 'true' || bridge.entry.need_reply === 'true') {
323
- if (!isReplyToBridge || replyBridge !== bridge.key) continue;
324
- }
325
- this.forwardQQToDiscord(bot, msg, bridge, replyId).catch(error => {
326
- bot.logger?.error(
327
- `[DiscordBridge] QQ→Discord 转发失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
328
- );
329
- });
330
- }
331
- } catch (error) {
332
- bot.logger?.error(
333
- `[DiscordBridge] QQ 消息处理异常: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
334
- );
335
- }
336
- }
337
-
338
- /** 提取 QQ 消息中的回复目标 id(reply 段不计入正文) */
339
- private extractQQReplyId(msg: GroupMessageWSMSG): number | undefined {
340
- if (!Array.isArray(msg.message)) return undefined;
341
- const reply = msg.message.find(segment => segment.type === 'reply');
342
- if (!reply) return undefined;
343
- const id = Number(reply.data.id);
344
- return Number.isSafeInteger(id) ? id : undefined;
345
- }
346
-
347
- /** QQ 消息段拆分:文本/附件分离(附件以 Discord 附件上传,文本不混入 URL) */
348
- private splitQQMessage(msg: GroupMessageWSMSG): { text: string; attachments: { url: string; name: string }[] } {
349
- const textParts: string[] = [];
350
- const attachments: { url: string; name: string }[] = [];
351
- if (Array.isArray(msg.message)) {
352
- for (const segment of msg.message) {
353
- switch (segment.type) {
354
- case 'text':
355
- textParts.push(segment.data.text);
356
- break;
357
- case 'at':
358
- textParts.push(`@${segment.data.qq}`);
359
- break;
360
- case 'face':
361
- textParts.push('[表情]');
362
- break;
363
- case 'image':
364
- case 'record':
365
- case 'video':
366
- case 'file': {
367
- const url = segment.data.url || segment.data.file;
368
- if (url && /^https?:\/\//.test(url)) {
369
- const fallback =
370
- segment.type === 'image'
371
- ? 'image.png'
372
- : segment.type === 'record'
373
- ? 'audio.amr'
374
- : segment.type === 'video'
375
- ? 'video.mp4'
376
- : 'file';
377
- attachments.push({ url, name: fallback });
378
- }
379
- break;
380
- }
381
- default:
382
- break;
383
- }
384
- }
385
- }
386
- return { text: textParts.join('').trim(), attachments };
387
- }
388
-
389
- /** 按字节内容识别常见图片格式,返回对应扩展名(识别不了返回 null) */
390
- private detectImageExt(buf: Buffer): string | null {
391
- if (buf.length < 12) return null;
392
- if (buf[0] === 0xff && buf[1] === 0xd8) return 'jpg';
393
- if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
394
- if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
395
- if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf.toString('ascii', 8, 12) === 'WEBP')
396
- return 'webp';
397
- if (buf[0] === 0x42 && buf[1] === 0x4d) return 'bmp';
398
- return null;
399
- }
400
-
401
- /**
402
- * 下载附件为 Buffer 并修正扩展名。
403
- * NapCat 多媒体直链带 rkey 防盗链,由 bot 侧下载再上传比让 Discord 服务器抓取更可靠;
404
- * 文件名必须带真实图片扩展名,Discord 才会内嵌渲染而不是显示为附件卡片。
405
- */
406
- private async fetchAttachment(
407
- bot: QQBot,
408
- item: { url: string; name: string }
409
- ): Promise<{ attachment: Buffer; name: string } | undefined> {
410
- try {
411
- const res = await bot.axiosInstance.get<ArrayBuffer>(item.url, { responseType: 'arraybuffer', timeout: 30000 });
412
- const buf = Buffer.from(res.data);
413
- const ext = this.detectImageExt(buf);
414
- let name = item.name;
415
- if (ext) {
416
- // 图片:确保扩展名与真实格式一致(URL 里常是 download?fileid=... 没有扩展名)
417
- name = name.replace(/\.[a-z0-9]+$/i, '') + '.' + ext;
418
- }
419
- return { attachment: buf, name };
420
- } catch (error) {
421
- bot.logger?.error(
422
- `[DiscordBridge] 附件下载失败(${item.url.slice(0, 80)}…): ${error instanceof Error ? error.message : String(error)}`
423
- );
424
- return undefined;
425
- }
426
- }
427
-
428
- /** QQ→Discord 转发主流程;bodyOverride 用于 /send(剥离命令前缀后的正文) */
429
- private async forwardQQToDiscord(
430
- bot: QQBot,
431
- msg: GroupMessageWSMSG,
432
- bridge: ResolvedBridge,
433
- replyId?: number,
434
- viaCommand: boolean = false,
435
- bodyOverride?: string
436
- ): Promise<void> {
437
- if (!this.client) return;
438
- const channel = await this.resolveDiscordChannel(bridge);
439
- if (!channel) return;
440
-
441
- const senderName = msg.sender.card || msg.sender.nickname;
442
- const header = `【${msg.group_name}|${msg.group_id}】${senderName}(${msg.sender.user_id}):`;
443
- const { text, attachments } =
444
- bodyOverride !== undefined ? { text: bodyOverride, attachments: [] as { url: string; name: string }[] } : this.splitQQMessage(msg);
445
- if (!text && attachments.length === 0) return;
446
-
447
- // QQ 消息符合 Discord markdown → 原文发送(Discord 原生渲染);否则转义为 plain text
448
- // 注意:仅在文本非空时拼接换行,避免纯图片消息出现孤立的来源行
449
- const rendered = isDiscordMarkdown(text) ? text : escapeDiscord(text);
450
- const content = this.truncateDiscord(text ? `${header}\n${rendered}` : header);
451
-
452
- const referenceId = replyId !== undefined ? this.discordByQQ.get(replyId)?.[bridge.key] : undefined;
453
- // 附件由 bot 下载为 Buffer 上传(避免 Discord 服务器抓取 NapCat 防盗链直链失败/拿到非图片响应),
454
- // 并按字节内容修正扩展名,保证图片在 Discord 内嵌渲染
455
- const files = (
456
- await Promise.all(attachments.map(item => this.fetchAttachment(bot, item)))
457
- ).filter((item): item is { attachment: Buffer; name: string } => item !== undefined);
458
- const sent = await this.sendWithReference(channel, { content, files }, referenceId);
459
- this.registerDiscordMessage(sent.id, msg.message_id, bridge.key, viaCommand);
460
- bot.logger?.debug(`[DiscordBridge] QQ→Discord:${msg.group_id} → ${bridge.key}(${sent.id})`);
461
- }
462
-
463
- /**
464
- * 机器人自身发出的群消息 → Discord(GitHub 通知、命令回复、AI 对话等)。
465
- *
466
- * 由 QQBot.onGroupMessageSent 钩子驱动(OneBot 不回推 bot 自己的 message 事件)。
467
- * 门控与普通 QQ 消息一致:need_cmd / need_reply 频道仅放行「对该频道桥消息的回复」
468
- * (如 AI 回复 /send 过来的消息),webhook 推送(GitHub 订阅通知)一律不进。
469
- */
470
- private async handleQQOutgoingMessage(event: GroupMessageSentEvent): Promise<void> {
471
- // 桥自身转发到 QQ 的消息必须跳过,否则「Discord→QQ→Discord」形成回环
472
- if (event.fromBridge) return;
473
- // 发送失败(无 messageId)说明消息根本没发出去,无需转发
474
- if (event.messageId === undefined) return;
475
- if (!this.client || !this.bot) return;
476
- const bot = this.bot;
477
-
478
- const bridges = this.resolveAllByGroup(event.groupId);
479
- if (bridges.length === 0) return;
480
-
481
- const loginInfo = bot.getLoginInfoSync();
482
- const senderName = loginInfo?.nickname ?? 'QQ Bot';
483
- const senderId = loginInfo?.user_id ?? 'unknown';
484
- const header = `【QQ群 ${event.groupId}】${senderName}(${senderId}):`;
485
-
486
- // 拆分发送载荷:文本 / 附件(base64 或直链)/ 回复引用
487
- const textParts: string[] = [];
488
- const downloads: { url: string; name: string }[] = [];
489
- const files: { attachment: Buffer; name: string }[] = [];
490
- let replyQQId: number | undefined;
491
- // QQ 侧 AI 长回复现在以聊天记录(node 段)形式发出,若直接跳过会导致这类消息完全不镜像到 Discord。
492
- // 因此这里把 node 内的文本拍平后追加到 textParts,再走下方既有门控正常转发。
493
- // 已知限制:node 内的图片/语音/视频/文件等附件不镜像(仅抽取 text/at/face 文本)。
494
- const nodeTexts: string[] = [];
495
- for (const segment of event.message) {
496
- if (segment.type === 'node') {
497
- const nodeText = this.flattenForwardNodeText(segment.data.content);
498
- if (nodeText) nodeTexts.push(nodeText);
499
- continue;
500
- }
501
- switch (segment.type) {
502
- case 'text':
503
- textParts.push(segment.data.text);
504
- break;
505
- case 'at':
506
- textParts.push(`@${segment.data.qq}`);
507
- break;
508
- case 'face':
509
- textParts.push('[表情]');
510
- break;
511
- case 'reply': {
512
- const id = Number(segment.data.id);
513
- if (Number.isSafeInteger(id)) replyQQId = id;
514
- break;
515
- }
516
- case 'image':
517
- case 'record':
518
- case 'video':
519
- case 'file': {
520
- const fallback =
521
- segment.type === 'image'
522
- ? 'image.png'
523
- : segment.type === 'record'
524
- ? 'audio.amr'
525
- : segment.type === 'video'
526
- ? 'video.mp4'
527
- : 'file';
528
- // bot 发送的图片多为 base64 载荷(GitHub 卡片、markdown 渲染图),直接解码为附件
529
- const base64 = this.extractBase64Payload(segment.data.file);
530
- if (base64) {
531
- files.push({ attachment: Buffer.from(base64, 'base64'), name: fallback });
532
- break;
533
- }
534
- const url = segment.data.url || segment.data.file;
535
- if (url && /^https?:\/\//.test(url)) downloads.push({ url, name: fallback });
536
- break;
537
- }
538
- default:
539
- break;
540
- }
541
- }
542
- for (const item of downloads) {
543
- const fetched = await this.fetchAttachment(bot, item);
544
- if (fetched) files.push(fetched);
545
- }
546
-
547
- // 聊天记录按节点分段展示:节点文本之间补换行,避免前一段末尾与后一段开头粘连
548
- if (nodeTexts.length > 0) {
549
- if (textParts.length > 0) textParts.push('\n');
550
- textParts.push(nodeTexts.join('\n'));
551
- }
552
-
553
- const text = textParts.join('').trim();
554
- if (!text && files.length === 0) return;
555
-
556
- const rendered = isDiscordMarkdown(text) ? text : escapeDiscord(text);
557
- const content = this.truncateDiscord(text ? `${header}\n${rendered}` : header);
558
-
559
- // 「对桥消息的回复」按桥定位来源频道(与普通 QQ 消息门控同一判定):
560
- // AI 回复 /send 到 QQ 的消息等场景可回到原频道,其余机器人消息不进 need_cmd / need_reply 频道
561
- const replyBridgeKey = replyQQId !== undefined ? this.bridgeByQQForward.get(replyQQId) : undefined;
562
-
563
- for (const bridge of bridges) {
564
- if (!this.shouldForwardBotMessage(bridge, event.fromWebhook, replyBridgeKey)) continue;
565
- const channel = await this.resolveDiscordChannel(bridge);
566
- if (!channel) continue;
567
- try {
568
- // 回复引用按频道各自的转发映射取(同群多频道时同一 QQ 消息在每个频道各有转发)
569
- const referenceId = replyQQId !== undefined ? this.discordByQQ.get(replyQQId)?.[bridge.key] : undefined;
570
- const sent = await this.sendWithReference(channel, { content, files }, referenceId);
571
- // 登记映射:Discord 用户回复机器人消息时可按回复链回传到 QQ
572
- this.registerDiscordMessage(sent.id, event.messageId, bridge.key);
573
- bot.logger?.debug(`[DiscordBridge] 机器人消息→Discord:${event.groupId} → ${bridge.key}(${sent.id})`);
574
- } catch (error) {
575
- bot.logger?.error(
576
- `[DiscordBridge] 机器人消息转发失败(${bridge.key}): ${error instanceof Error ? error.message : String(error)}`
577
- );
578
- }
579
- }
580
- }
581
-
582
- /** 聊天记录(node 段)内文本的递归深度上限:防御异常数据结构导致的无限递归 */
583
- private static readonly FORWARD_NODE_MAX_DEPTH = 4;
584
-
585
- /**
586
- * 抽取合并转发(聊天记录)节点内容里的文本。
587
- *
588
- * QQ 侧超长 AI 回复现在以聊天记录(node)形式发出,这里把节点内容递归拍平:
589
- * text → 原文、at → `@QQ号`、face → `[表情]`,嵌套的 node 继续下钻;
590
- * 达到深度上限、或遇到非数组/未知类型时安全跳过(不抛错)。
591
- * 已知限制:节点内的图片/语音/视频/文件等附件不镜像(仅回传文本)。
592
- *
593
- * @param content 节点内容(Message[]、纯文本字符串,或结构异常的未知值)
594
- * @param depth 当前递归深度,超过上限即停止下钻
595
- */
596
- private flattenForwardNodeText(content: unknown, depth: number = 0): string {
597
- if (depth >= DiscordBridge.FORWARD_NODE_MAX_DEPTH) return '';
598
- if (typeof content === 'string') return content;
599
- if (!Array.isArray(content)) return '';
600
- const parts: string[] = [];
601
- for (const raw of content) {
602
- if (!raw || typeof raw !== 'object') continue;
603
- const segment = raw as { type?: unknown; data?: unknown };
604
- const data = (segment.data ?? {}) as { text?: unknown; qq?: unknown; content?: unknown };
605
- switch (segment.type) {
606
- case 'text':
607
- if (typeof data.text === 'string') parts.push(data.text);
608
- break;
609
- case 'at':
610
- if (data.qq !== undefined && data.qq !== null) parts.push(`@${String(data.qq)}`);
611
- break;
612
- case 'face':
613
- parts.push('[表情]');
614
- break;
615
- case 'node':
616
- parts.push(this.flattenForwardNodeText(data.content, depth + 1));
617
- break;
618
- default:
619
- break;
620
- }
621
- }
622
- return parts.join('');
623
- }
624
-
625
- /**
626
- * 机器人消息门控(与普通 QQ 用户消息同一套语义):
627
- * - 未开启门控的频道:全部放行;
628
- * - need_cmd / need_reply 频道:仅放行「对该频道桥消息的回复」
629
- * (replyBridgeKey 为被回复的 QQ 桥消息来源频道);webhook 推送一律不进。
630
- */
631
- private shouldForwardBotMessage(bridge: ResolvedBridge, fromWebhook: boolean, replyBridgeKey?: string): boolean {
632
- const gated = bridge.entry.need_cmd === 'true' || bridge.entry.need_reply === 'true';
633
- if (!gated) return true;
634
- if (fromWebhook) return false;
635
- return replyBridgeKey === bridge.key;
636
- }
637
-
638
- /**
639
- * 带回复引用发送;被引用消息已删除或不可见(MESSAGE_REFERENCE_UNKNOWN)时
640
- * 降级为无引用发送,保证消息不丢。
641
- */
642
- private async sendWithReference(
643
- channel: TextChannel,
644
- payload: { content: string; files: { attachment: Buffer; name: string }[] },
645
- referenceId?: string
646
- ): Promise<DiscordMessage> {
647
- // 发送前登记待发标记:messageCreate 回推可能先于本方法返回,接收侧据此识别桥转发
648
- const pendingKey = `${channel.id}\n${payload.content}`;
649
- if (this.pendingBridgeSends.size > 500) this.pendingBridgeSends.clear();
650
- this.pendingBridgeSends.set(pendingKey, (this.pendingBridgeSends.get(pendingKey) ?? 0) + 1);
651
- try {
652
- return await channel.send({
653
- ...payload,
654
- ...(referenceId ? { reply: { messageReference: referenceId } } : {}),
655
- allowedMentions: { parse: [], repliedUser: false }
656
- });
657
- } catch (error) {
658
- if (referenceId && error instanceof Error && error.message.includes('MESSAGE_REFERENCE_UNKNOWN')) {
659
- return await channel.send({ ...payload, allowedMentions: { parse: [], repliedUser: false } });
660
- }
661
- throw error;
662
- }
663
- }
664
-
665
- /** 从 OneBot 文件字段提取 base64 载荷(`base64://…` 或 `data:image/…;base64,…`),非 base64 返回 undefined */
666
- private extractBase64Payload(file: string): string | undefined {
667
- if (file.startsWith('base64://')) return file.slice('base64://'.length).trim();
668
- const match = /^data:image\/[a-z0-9.+-]+;base64,\s*(.+)$/i.exec(file);
669
- return match?.[1]?.trim();
670
- }
671
-
672
- // -------------------------------------------------------------------------
673
- // Discord → QQ
674
- // -------------------------------------------------------------------------
675
-
676
- /** messageCreate 回调:把 Discord 频道消息转发到 QQ(或执行 /send) */
677
- private async handleDiscordMessage(message: DiscordMessage): Promise<void> {
678
- if (message.webhookId) return;
679
- if (message.channel.type !== ChannelType.GuildText) return;
680
- const guildId = message.guildId;
681
- if (!guildId) return;
682
-
683
- const bridge = this.resolveByChannel(guildId, message.channel.name);
684
- if (!bridge) return;
685
-
686
- // messageCreate 同样回推 bot 自己的消息:机器人自己在互通频道发的消息(命令回复、
687
- // AI 回复、GitHub 卡片、斜杠命令回复等)同步到 QQ,让 QQ 侧看到完整对话;
688
- // 桥自身 QQ→Discord 的转发已在 handleOwnDiscordMessage 内跳过,不会回环
689
- if (message.author.bot) {
690
- if (message.author.id === this.client?.user?.id) {
691
- await this.handleOwnDiscordMessage(message, bridge);
692
- }
693
- return;
694
- }
695
-
696
- const raw = message.content ?? '';
697
- if (this.isSendCommand(raw)) {
698
- await this.handleSendFromDiscord(message, raw);
699
- return;
700
- }
701
-
702
- // 命令分发:/ 或 ! 前缀且首段命中已注册命令时,在 Discord 侧直接执行并把回复发在
703
- // Discord 频道;命令消息本身仍按后续流程转发到 QQ(QQ 侧看到的是 bot 代发的文本,
704
- // 不会二次执行)。未命中注册的 /xxx 文本同样按普通消息继续走转发流程
705
- await this.handleDiscordCommand(message, bridge, raw);
706
-
707
- const referenceId = message.reference?.messageId;
708
- const referenceKey = referenceId ? this.snowflakeToKey(referenceId) : undefined;
709
- const isReplyToBridge = referenceKey !== undefined && this.dcFromBridge.get(referenceKey) === true;
710
-
711
- // Discord 侧 #编号 查询 GitHub Issue/PR(与 QQ 侧共用同一套解析/渲染,回复发在 Discord
712
- // 频道);不受 need_cmd 门控影响,消息本身仍按门控决定是否转发到 QQ
713
- if (this.bot) {
714
- const groupId = Number(bridge.entry.group);
715
- const results = await Github.processDiscordMessage(
716
- this.bot,
717
- raw,
718
- Number.isSafeInteger(groupId) ? groupId : undefined
719
- );
720
- for (const result of results) {
721
- await this.sendGithubResultToDiscord(message, result);
722
- }
723
- }
724
-
725
- // Hermes AI:互通频道消息可触发 AI(审批回复 / @提及 / 关键词,频道是否启用由其桥接的
726
- // QQ 群在 hermes.json groups 中决定)。AI 回复经注入的委托发在 Discord 频道;
727
- // 消息本身仍按门控决定是否转发到 QQ
728
- if (this.bot) {
729
- const bot = this.bot;
730
- const channel = message.channel as TextChannel;
731
- HermesBridge.getInstance().handleDiscordMessage(bot, message, {
732
- groupId: bridge.entry.group,
733
- ...(this.client?.user?.id ? { botUserId: this.client.user.id } : {}),
734
- guildName: message.guild?.name ?? bridge.guildId,
735
- channelName: channel.name,
736
- isAdmin: isDiscordAdmin(message),
737
- send: async (text, quote) => {
738
- // 委托侧兜底 2000 字上限(Hermes 已按 maxMessageLength 切分,通常不会触发)
739
- const chunks: string[] = [];
740
- let rest = text;
741
- while (rest.length > DISCORD_MESSAGE_LIMIT) {
742
- chunks.push(rest.slice(0, DISCORD_MESSAGE_LIMIT));
743
- rest = rest.slice(DISCORD_MESSAGE_LIMIT);
744
- }
745
- if (rest) chunks.push(rest);
746
- for (let index = 0; index < chunks.length; index++) {
747
- const content = chunks[index]!;
748
- try {
749
- if (quote && index === 0) {
750
- await message.reply({ content, allowedMentions: { parse: [], repliedUser: false } });
751
- } else {
752
- await channel.send({ content, allowedMentions: { parse: [] } });
753
- }
754
- } catch (error) {
755
- bot.logger?.error(
756
- `[DiscordBridge] Hermes 回复发送失败: ${error instanceof Error ? error.message : String(error)}`
757
- );
758
- }
759
- }
760
- },
761
- sendImage: async base64 => {
762
- await channel
763
- .send({ files: [{ attachment: Buffer.from(base64, 'base64'), name: 'image.png' }] })
764
- .catch(error => {
765
- bot.logger?.error(
766
- `[DiscordBridge] Hermes 图片发送失败: ${error instanceof Error ? error.message : String(error)}`
767
- );
768
- });
769
- },
770
- // 摘除机器人对触发消息的「AI 处理中」表情回应(完成/失败/停止时由 HermesBridge 调用)
771
- removeReaction: async () => {
772
- const emoji = getHermesConfig().reactionEmojiDiscord;
773
- const reaction =
774
- message.reactions.resolve(emoji) ?? message.reactions.cache.find(item => item.emoji.name === emoji);
775
- await reaction?.users.remove();
776
- }
777
- });
778
- }
779
-
780
- // 门控:need_cmd 开启时仅放行「对桥消息的回复」(need_reply 不限制此方向)
781
- if (bridge.entry.need_cmd === 'true' && !isReplyToBridge) return;
782
-
783
- await this.forwardDiscordToQQ(message, bridge, referenceId);
784
- }
785
-
786
- /**
787
- * 机器人自己在 Discord 互通频道发出的消息 → QQ(命令回复、AI 回复、GitHub 卡片、
788
- * 斜杠命令回复等,复用 forwardDiscordToQQ 的渲染/回复链/附件处理)。
789
- *
790
- * 防回环:桥自身 QQ→Discord 的转发在发送前登记待发标记(pendingBridgeSends,
791
- * 兼容 messageCreate 先于 send 返回的乱序)、发送后登记 dcFromBridge,双重识别后跳过;
792
- * 门控与普通 Discord 消息一致(need_cmd 频道仅放行「对桥消息的回复」)。
793
- * 转发到 QQ 的消息带 fromBridge 标记且登记为桥消息,QQ 侧回复它可路由回本频道。
794
- */
795
- private async handleOwnDiscordMessage(message: DiscordMessage, bridge: ResolvedBridge): Promise<void> {
796
- // 乱序防回环:messageCreate 先于 registerDiscordMessage 到达时 dcFromBridge 查不到,
797
- // 按「频道+正文」核销发送前登记的待发标记(仅桥转发走 sendWithReference,必然登记)
798
- const pendingKey = `${message.channelId}\n${message.content ?? ''}`;
799
- const pending = this.pendingBridgeSends.get(pendingKey) ?? 0;
800
- if (pending > 0) {
801
- if (pending === 1) this.pendingBridgeSends.delete(pendingKey);
802
- else this.pendingBridgeSends.set(pendingKey, pending - 1);
803
- return;
804
- }
805
- if (this.dcFromBridge.get(this.snowflakeToKey(message.id)) === true) return;
806
- // 交互延迟占位等无内容消息不同步
807
- if (!message.content && message.attachments.size === 0) return;
808
- const referenceId = message.reference?.messageId;
809
- const referenceKey = referenceId ? this.snowflakeToKey(referenceId) : undefined;
810
- const isReplyToBridge = referenceKey !== undefined && this.dcFromBridge.get(referenceKey) === true;
811
- if (bridge.entry.need_cmd === 'true' && !isReplyToBridge) return;
812
- await this.forwardDiscordToQQ(message, bridge, referenceId);
813
- }
814
-
815
- /**
816
- * Discord 命令分发:命中已注册命令根节点则执行并返回 true(消息不再转发),
817
- * 否则返回 false 交回普通转发流程。
818
- */
819
- private async handleDiscordCommand(message: DiscordMessage, bridge: ResolvedBridge, raw: string): Promise<boolean> {
820
- if (!this.bot) return false;
821
- const trimmed = raw.trim();
822
- if (!trimmed.startsWith('/') && !trimmed.startsWith('!')) return false;
823
- // Discord 客户端会把 / 开头内容当作斜杠命令输入,额外支持 ! 前缀兜底,统一归一化为 /
824
- const normalized = normalizeCommandText(trimmed);
825
- const rootName = normalized.slice(1).split(/\s/)[0];
826
- if (!rootName || !this.isRegisteredCommand(rootName)) return false;
827
- const groupId = Number(bridge.entry.group);
828
- const source = new DiscordMsgCommandSource(this.bot, message, Number.isSafeInteger(groupId) ? groupId : undefined);
829
- try {
830
- executeCommand(this.bot.getCommandManager(), source, normalized);
831
- } catch (error) {
832
- this.bot.logger?.error(
833
- `[DiscordBridge] Discord 命令执行失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
834
- );
835
- }
836
- return true;
837
- }
838
-
839
- /** 首段是否为已注册命令(遍历全部命名空间根节点的字面量子节点) */
840
- private isRegisteredCommand(name: string): boolean {
841
- const manager = this.bot?.getCommandManager();
842
- if (!manager) return false;
843
- for (const root of manager.roots.values()) {
844
- if (root.children.some(child => child.isLiteral() && child.toString() === name)) return true;
845
- }
846
- return false;
847
- }
848
-
849
- /** 把 GitHub Issue/PR 查询结果以 Discord 回复发回原频道(图片为附件,失败提示为文本) */
850
- private async sendGithubResultToDiscord(message: DiscordMessage, result: Message): Promise<void> {
851
- try {
852
- if (result.type === 'image') {
853
- const base64 = this.extractBase64Payload(result.data.file);
854
- if (!base64) return;
855
- await message.reply({
856
- files: [{ attachment: Buffer.from(base64, 'base64'), name: 'github.png' }],
857
- allowedMentions: { parse: [], repliedUser: false }
858
- });
859
- return;
860
- }
861
- if (result.type === 'text') {
862
- await message.reply({
863
- content: this.truncateDiscord(result.data.text),
864
- allowedMentions: { parse: [], repliedUser: false }
865
- });
866
- }
867
- } catch (error) {
868
- this.bot?.logger?.error(
869
- `[DiscordBridge] GitHub 卡片回复失败: ${error instanceof Error ? error.message : String(error)}`
870
- );
871
- }
872
- }
873
-
874
- /** Discord→QQ 转发主流程;contentOverride 用于 /send(剥离命令前缀后的正文) */
875
- private async forwardDiscordToQQ(
876
- message: DiscordMessage,
877
- bridge: ResolvedBridge,
878
- referenceId?: string | null,
879
- viaCommand: boolean = false,
880
- contentOverride?: string
881
- ): Promise<void> {
882
- if (!this.bot) return;
883
- const bot = this.bot;
884
- const channel = message.channel as TextChannel;
885
- const guildName = message.guild?.name ?? bridge.guildId;
886
- const authorName = message.member?.displayName ?? message.author.username;
887
- const header = `【${guildName}|${channel.name}】${authorName}(${message.author.username}):`;
888
-
889
- const content = contentOverride ?? message.content ?? '';
890
- const segments: Message[] = [];
891
-
892
- // 回复链:转发到 QQ 时带 reply 引用
893
- const replyTarget = referenceId ? this.qqByDiscord.get(this.snowflakeToKey(referenceId)) : undefined;
894
- if (replyTarget !== undefined) {
895
- segments.push({ type: 'reply', data: { id: replyTarget } });
896
- }
897
-
898
- if (content) {
899
- if (isDiscordMarkdown(content)) {
900
- // 含 Discord markdown → 渲染为图片转发(头部并入图片)
901
- try {
902
- const base64 = await renderDiscordMessageToImage(header, content, message);
903
- segments.push({ type: 'image', data: { file: `base64://${base64}` } });
904
- } catch (error) {
905
- // 渲染失败降级为纯文本转发,保证消息不丢
906
- bot.logger?.error(
907
- `[DiscordBridge] markdown 渲染失败,降级纯文本: ${error instanceof Error ? error.message : String(error)}`
908
- );
909
- segments.push({ type: 'text', data: { text: `${header}\n${this.plainDiscordContent(message, content)}` } });
910
- }
911
- } else {
912
- segments.push({ type: 'text', data: { text: `${header}\n${this.plainDiscordContent(message, content)}` } });
913
- }
914
- } else if (segments.length === 0 || message.attachments.size > 0) {
915
- // 无文字但有附件/纯 embed:补头部行
916
- segments.push({ type: 'text', data: { text: header } });
917
- }
918
-
919
- // 附件:图片直接发图片段,其余类型以链接文本行转发
920
- for (const attachment of message.attachments.values()) {
921
- if (attachment.contentType?.startsWith('image/')) {
922
- segments.push({ type: 'image', data: { file: attachment.url } });
923
- } else {
924
- segments.push({ type: 'text', data: { text: `\n[附件] ${attachment.name}: ${attachment.url}` } });
925
- }
926
- }
927
-
928
- // 纯 embed 消息兜底
929
- if (!content && message.attachments.size === 0 && message.embeds.length > 0) {
930
- const embed = message.embeds[0];
931
- segments.push({ type: 'text', data: { text: `\n[Embed] ${embed?.title ?? ''} ${embed?.url ?? ''}`.trim() } });
932
- }
933
-
934
- if (segments.length === 0) return;
935
- // fromBridge:标记为桥自身发出的消息,发送出口钩子据此跳过,避免回环
936
- const qqMessageId = await bot.sendGroupMsg(bridge.entry.group, segments, { fromBridge: true });
937
- if (qqMessageId !== undefined) {
938
- this.registerQQMessage(qqMessageId, message.id, bridge.key, viaCommand);
939
- this.bridgeByQQForward.set(qqMessageId, bridge.key);
940
- bot.logger?.debug(`[DiscordBridge] Discord→QQ:${bridge.key} → ${bridge.entry.group}(${qqMessageId})`);
941
- }
942
- }
943
-
944
- /** Discord 消息正文纯文本化(提及/频道/表情 → 可读文本);contentOverride 用于 /send 剥离前缀后的正文 */
945
- private plainDiscordContent(message: DiscordMessage, contentOverride?: string): string {
946
- let text = contentOverride ?? message.content;
947
- text = text.replace(/<@!?(\d+)>/g, (_match, id: string) => {
948
- const name = message.mentions.members?.get(id)?.displayName ?? message.mentions.users.get(id)?.username;
949
- return `@${name ?? id}`;
950
- });
951
- text = text.replace(/<@&(\d+)>/g, (_match, id: string) => {
952
- return `@${message.guild?.roles.cache.get(id)?.name ?? id}`;
953
- });
954
- text = text.replace(/<#(\d+)>/g, (_match, id: string) => {
955
- return `#${message.guild?.channels.cache.get(id)?.name ?? id}`;
956
- });
957
- text = text.replace(/<a?:(\w{2,32}):\d+>/g, ':$1:');
958
- return text.trim();
959
- }
960
-
961
- // -------------------------------------------------------------------------
962
- // /send <msg> [group|channel]
963
- // -------------------------------------------------------------------------
964
-
965
- /** 判断文本是否为 /send 命令(/send 后必须为空白或结尾,避免误吞 /sendxxx 消息) */
966
- private isSendCommand(raw: string): boolean {
967
- return raw.startsWith(SEND_COMMAND_PREFIX) && (raw.length === SEND_COMMAND_PREFIX.length || /\s/.test(raw[SEND_COMMAND_PREFIX.length]!));
968
- }
969
-
970
- /**
971
- * 解析 /send 参数:尾部 token 命中某个 bridge 的 QQ 群号或频道 key
972
- * (`guildId#channel`,或 `#channel` 后缀唯一匹配)时作为目标,其余为消息正文。
973
- * 不填目标时默认发送到配置文件中第一个互通条目的对端。
974
- */
975
- private parseSendArgs(text: string): { message: string; target?: string } | null {
976
- const body = text.slice(SEND_COMMAND_PREFIX.length).trim();
977
- if (!body) return null;
978
- const tokens = body.split(/\s+/);
979
- const bridges = getDiscordBridgeConfig().bridges;
980
- const keys = Object.keys(bridges);
981
- if (tokens.length >= 2) {
982
- const last = tokens[tokens.length - 1]!;
983
- // QQ 群号
984
- const byGroup = keys.find(key => bridges[key]!.group === last);
985
- if (byGroup) return { message: tokens.slice(0, -1).join(' ').trim(), target: byGroup };
986
- // 完整 key
987
- if (keys.includes(last)) return { message: tokens.slice(0, -1).join(' ').trim(), target: last };
988
- // #频道名 后缀(唯一匹配)
989
- if (last.startsWith('#')) {
990
- const channelName = last.slice(1);
991
- const matched = keys.filter(key => key.split('#')[1] === channelName);
992
- const only = matched[0];
993
- if (matched.length === 1 && only) return { message: tokens.slice(0, -1).join(' ').trim(), target: only };
994
- }
995
- }
996
- return { message: body };
997
- }
998
-
999
- /** QQ 侧 /send:发送到目标 Discord 频道(或目标 QQ 群) */
1000
- private async handleSendFromQQ(bot: QQBot, msg: GroupMessageWSMSG, raw: string): Promise<void> {
1001
- const parsed = this.parseSendArgs(raw);
1002
- if (!parsed || !parsed.message) {
1003
- bot.sendGroupMsg(msg.group_id, [
1004
- { type: 'reply', data: { id: msg.message_id } },
1005
- { type: 'text', data: { text: `用法:${SEND_COMMAND_PREFIX} <消息> [QQ群号|服务器ID#频道名]` } }
1006
- ]);
1007
- return;
1008
- }
1009
- const targetKey = parsed.target ?? Object.keys(getDiscordBridgeConfig().bridges)[0];
1010
- if (!targetKey) {
1011
- bot.sendGroupMsg(msg.group_id, [{ type: 'text', data: { text: '[DiscordBridge] 尚未配置任何互通频道' } }]);
1012
- return;
1013
- }
1014
- const bridge = this.resolveByKey(targetKey);
1015
- if (!bridge) return;
1016
- await this.forwardQQToDiscord(bot, msg, bridge, undefined, true, parsed.message);
1017
- }
1018
-
1019
- /** Discord 侧 /send:发送到目标 QQ 群(或目标 Discord 频道) */
1020
- private async handleSendFromDiscord(message: DiscordMessage, raw: string): Promise<void> {
1021
- const parsed = this.parseSendArgs(raw);
1022
- if (!parsed || !parsed.message) {
1023
- await message.reply(`用法:${SEND_COMMAND_PREFIX} <消息> [QQ群号|服务器ID#频道名]`).catch(() => undefined);
1024
- return;
1025
- }
1026
- const targetKey = parsed.target ?? Object.keys(getDiscordBridgeConfig().bridges)[0];
1027
- if (!targetKey) {
1028
- await message.reply('[DiscordBridge] 尚未配置任何互通频道').catch(() => undefined);
1029
- return;
1030
- }
1031
- const bridge = this.resolveByKey(targetKey);
1032
- if (!bridge) return;
1033
- // 来源频道即目标频道时按普通消息处理,避免同频道复读
1034
- await this.forwardDiscordToQQ(message, bridge, null, true, parsed.message);
1035
- }
1036
-
1037
- // -------------------------------------------------------------------------
1038
- // 路由表与频道解析
1039
- // -------------------------------------------------------------------------
1040
-
1041
- /** 登记一次 QQ→Discord 转发的 id 映射(回复链用;按 bridge key 分别记录,同群多频道互不覆盖) */
1042
- private registerDiscordMessage(discordId: string, qqId: number, bridgeKey: string, viaCommand: boolean = false): void {
1043
- const key = this.snowflakeToKey(discordId);
1044
- const entry = this.discordByQQ.get(qqId) ?? {};
1045
- entry[bridgeKey] = discordId;
1046
- this.discordByQQ.set(qqId, entry);
1047
- this.qqByDiscord.set(key, qqId);
1048
- this.dcFromBridge.set(key, true);
1049
- }
1050
-
1051
- /** 登记一次 Discord→QQ 转发的 id 映射(回复链用;按 bridge key 分别记录,同群多频道互不覆盖) */
1052
- private registerQQMessage(qqId: number, discordId: string, bridgeKey: string, viaCommand: boolean = false): void {
1053
- this.qqByDiscord.set(this.snowflakeToKey(discordId), qqId);
1054
- const entry = this.discordByQQ.get(qqId) ?? {};
1055
- entry[bridgeKey] = discordId;
1056
- this.discordByQQ.set(qqId, entry);
1057
- this.qqFromBridge.set(qqId, true);
1058
- }
1059
-
1060
- /**
1061
- * Discord snowflake 是 19 位十进制数,超出 JS Number 安全整数范围。
1062
- * 回复链映射用十进制字符串数值化(按模 2^42 折叠)。模数取 2^42 而非 2^53:
1063
- * 折叠的中间值 value*10+d 必须小于 2^53 才不损失精度(2^53 模数会让中间值
1064
- * 达到 ~9e16 > 2^53,相邻 id 折叠后相撞);2^42 下中间值 ~4.4e13,全程精确。
1065
- * 不依赖 BigInt(target < ES2020 无 BigInt 类型)。
1066
- */
1067
- private snowflakeToKey(id: string): number {
1068
- let value = 0;
1069
- const MODULUS = 2 ** 42;
1070
- for (const char of id) {
1071
- value = (value * 10 + (char.charCodeAt(0) - 48)) % MODULUS;
1072
- }
1073
- return value;
1074
- }
1075
-
1076
- private resolveByKey(key: string): ResolvedBridge | undefined {
1077
- const entry = getDiscordBridgeConfig().bridges[key];
1078
- if (!entry) return undefined;
1079
- const [guildId, channelName] = key.split('#');
1080
- if (!guildId || !channelName) return undefined;
1081
- return { key, guildId, channelName, entry };
1082
- }
1083
-
1084
- /** 该 QQ 群绑定的全部互通条目(同群可绑多个频道,普通消息逐条门控) */
1085
- private resolveAllByGroup(groupId: number | string): ResolvedBridge[] {
1086
- const group = String(groupId);
1087
- const bridges = getDiscordBridgeConfig().bridges;
1088
- return Object.keys(bridges)
1089
- .filter(key => bridges[key]!.group === group)
1090
- .map(key => this.resolveByKey(key)!);
1091
- }
1092
-
1093
- private resolveByChannel(guildId: string, channelName: string): ResolvedBridge | undefined {
1094
- return this.resolveByKey(`${guildId}#${channelName}`);
1095
- }
1096
-
1097
- /** 解析配置条目对应的 Discord 文本频道(缓存优先,未缓存时按频道名抓取) */
1098
- private async resolveDiscordChannel(bridge: ResolvedBridge): Promise<TextChannel | undefined> {
1099
- if (!this.client) return undefined;
1100
- const guild: Guild | undefined = this.client.guilds.cache.get(bridge.guildId);
1101
- if (!guild) {
1102
- this.bot?.logger?.warn(`[DiscordBridge] 未找到 Discord 服务器 ${bridge.guildId}(${bridge.key})`);
1103
- return undefined;
1104
- }
1105
- const channel = guild.channels.cache.find(
1106
- item => item.type === ChannelType.GuildText && item.name === bridge.channelName
1107
- );
1108
- if (channel) return channel as TextChannel;
1109
- try {
1110
- const fetched = await guild.channels.fetch();
1111
- const target = fetched?.find(
1112
- item => item?.type === ChannelType.GuildText && item.name === bridge.channelName
1113
- );
1114
- if (target) return target as TextChannel;
1115
- } catch (error) {
1116
- this.bot?.logger?.error(
1117
- `[DiscordBridge] 拉取频道列表失败(${bridge.key}): ${error instanceof Error ? error.message : String(error)}`
1118
- );
1119
- }
1120
- this.bot?.logger?.warn(`[DiscordBridge] 未找到 Discord 频道 ${bridge.key}(频道改名需同步配置)`);
1121
- return undefined;
1122
- }
1123
-
1124
- private truncateDiscord(text: string): string {
1125
- if (text.length <= DISCORD_MESSAGE_LIMIT) return text;
1126
- return `${text.slice(0, DISCORD_MESSAGE_LIMIT - 16)}\n…(消息过长已截断)`;
1127
- }
1128
- }
1
+ import { ChannelType, Client, GatewayIntentBits, Guild, Message as DiscordMessage, TextChannel } from 'discord.js';
2
+ import { GroupMessageSentEvent, QQBot } from '@/index';
3
+ import { GroupMessageWSMSG, Message, SentMessage } from '@/type';
4
+ import { ConfigStore } from '@/config/manager';
5
+ import { escapeDiscord, isDiscordMarkdown, renderDiscordMessageToImage } from '@/features/discord-bridge/markdown';
6
+ import { DiscordMsgCommandSource, isDiscordAdmin } from '@/features/discord-bridge/source';
7
+ import { executeCommand, normalizeCommandText } from '@/command';
8
+ import { Github } from '@/features/github';
9
+ import { HermesBridge } from '@/features/hermes';
10
+ import { getHermesConfig } from '@/features/hermes/config';
11
+ import { ReactionManager } from '@/features/hermes/reaction';
12
+ import { handleSlashInteraction, registerSlashCommands } from '@/features/discord-bridge/slash';
13
+
14
+ /**
15
+ * QQ 群 ⇄ Discord 频道互通模块。
16
+ *
17
+ * 配置(configs/features/discord-bridge.json,v1,ConfigStore 热重载):
18
+ * ```json
19
+ * {
20
+ * "version": 1,
21
+ * "token": "DISCORD_BOT_TOKEN",
22
+ * "bridges": {
23
+ * "940551045929639949#general": {
24
+ * "group": "659356928",
25
+ * "need_reply": "false",
26
+ * "need_cmd": "false"
27
+ * }
28
+ * }
29
+ * }
30
+ * ```
31
+ * - key 为 `"服务器ID#频道名称"`(频道名以 Discord 当前名为准,改名需同步改配置);
32
+ * - `group` 为互通的 QQ 群号;
33
+ * - `need_reply=true`:Discord→QQ 全量转发;QQ→Discord 仅转发「回复桥消息」的回复内容;
34
+ * - `need_cmd=true`:双向普通消息均不转发,仅 `/send <msg> [group|channel]` 与
35
+ * 「回复桥消息」互通(回复会以回复形式回传到对侧);
36
+ * - `/send` 不填目标时默认发送到配置文件中第一个互通条目的对端。
37
+ *
38
+ * 附加能力:
39
+ * - 机器人自身发出的 QQ 群消息转发到该群绑定的频道(经 QQBot.onGroupMessageSent
40
+ * 钩子,fromBridge 标记防止回环):与普通 QQ 消息同一套门控——need_cmd / need_reply
41
+ * 频道仅放行「对该频道桥消息的回复」,webhook 推送(GitHub 订阅通知)一律不进;
42
+ * - 机器人自己在互通频道发出的 Discord 消息(命令回复、AI 回复、GitHub 卡片、
43
+ * 斜杠命令回复等)经 messageCreate 回推同步到 QQ(桥自身转发跳过防回环,
44
+ * 门控与普通 Discord 消息一致),QQ 侧可看到完整对话;
45
+ * - Discord 频道中可直接使用全部已注册命令(/ 或 ! 前缀),回复发在 Discord 频道;
46
+ * 未注册命令的 /xxx 文本按普通消息转发,不会回发 Invalid command;
47
+ * - 同时把命令树注册为 Discord 原生斜杠命令(guild 级、自动补全),交互经 token
48
+ * 精确回放执行,回复发在 Discord 频道(需邀请链接含 applications.commands 权限);
49
+ * - Discord 消息中的 `#编号` / `owner/repo#编号` 会查询 GitHub Issue/PR 并以图片卡片
50
+ * 回复在 Discord 频道(一条消息多个编号全部解析,与 QQ 侧同一套渲染);
51
+ * - 互通频道消息可触发 Hermes AI(审批回复 /@提及 / 关键词),频道是否启用由其桥接的
52
+ * QQ 群在 hermes.json groups 中决定,AI 回复发在 Discord 频道。
53
+ *
54
+ * 前置条件:Discord 开发者后台为 bot 开启 Message Content Intent,并邀请入对应服务器。
55
+ */
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // 配置
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export interface BridgeEntry {
62
+ /** QQ 群号(字符串形式,兼容配置里写成数字) */
63
+ group: string;
64
+ /** true 时 QQ→Discord 仅转发对桥消息的回复 */
65
+ need_reply: string;
66
+ /** true 时双向仅 /send 与对桥消息的回复互通 */
67
+ need_cmd: string;
68
+ }
69
+
70
+ export interface DiscordBridgeConfig {
71
+ version: number;
72
+ token: string;
73
+ /** key: "服务器ID#频道名称" */
74
+ bridges: Record<string, BridgeEntry>;
75
+ }
76
+
77
+ function normalizeBridgeFlag(raw: unknown): string | null {
78
+ if (typeof raw === 'boolean') return raw ? 'true' : 'false';
79
+ if (raw === 'true' || raw === 'false') return raw;
80
+ return null;
81
+ }
82
+
83
+ function normalizeDiscordBridgeConfig(raw: unknown): DiscordBridgeConfig | null {
84
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
85
+ const record = raw as Record<string, unknown>;
86
+ const token = typeof record.token === 'string' ? record.token : '';
87
+ const bridges: Record<string, BridgeEntry> = {};
88
+ if (record.bridges && typeof record.bridges === 'object' && !Array.isArray(record.bridges)) {
89
+ for (const [key, value] of Object.entries(record.bridges as Record<string, unknown>)) {
90
+ if (!/^\d+#.+$/.test(key)) continue;
91
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
92
+ const entry = value as Record<string, unknown>;
93
+ let group: string;
94
+ if (typeof entry.group === 'string' && /^\d+$/.test(entry.group)) group = entry.group;
95
+ else if (typeof entry.group === 'number' && Number.isSafeInteger(entry.group)) group = String(entry.group);
96
+ else continue;
97
+ const needReply = normalizeBridgeFlag(entry.need_reply) ?? 'false';
98
+ const needCmd = normalizeBridgeFlag(entry.need_cmd) ?? 'false';
99
+ bridges[key] = { group, need_reply: needReply, need_cmd: needCmd };
100
+ }
101
+ }
102
+ return { version: 1, token, bridges };
103
+ }
104
+
105
+ const bridgeStore = new ConfigStore<DiscordBridgeConfig>({
106
+ path: 'configs/features/discord-bridge.json',
107
+ version: 1,
108
+ factory: () => ({ version: 1, token: '', bridges: {} }),
109
+ normalize: normalizeDiscordBridgeConfig
110
+ });
111
+
112
+ /** 读取互通生效配置(热重载) */
113
+ export function getDiscordBridgeConfig(): DiscordBridgeConfig {
114
+ return bridgeStore.get();
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 内存路由表(回复链映射)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** 有界 FIFO 缓存:转发消息的 id 映射,容量封顶防止长期运行内存膨胀 */
122
+ class BoundedCache<V> {
123
+ private readonly map = new Map<number, V>();
124
+
125
+ public constructor(private readonly capacity: number = 2000) {}
126
+
127
+ public get(key: number): V | undefined {
128
+ return this.map.get(key);
129
+ }
130
+
131
+ public set(key: number, value: V): void {
132
+ if (this.map.has(key)) this.map.delete(key);
133
+ this.map.set(key, value);
134
+ if (this.map.size > this.capacity) {
135
+ const oldest = this.map.keys().next().value;
136
+ if (oldest !== undefined) this.map.delete(oldest);
137
+ }
138
+ }
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // DiscordBridge
143
+ // ---------------------------------------------------------------------------
144
+
145
+ const DISCORD_MESSAGE_LIMIT = 2000;
146
+ const SEND_COMMAND_PREFIX = '/send';
147
+
148
+ /** 已解析的互通条目(配置 key 拆分后的视图) */
149
+ interface ResolvedBridge {
150
+ key: string;
151
+ guildId: string;
152
+ channelName: string;
153
+ entry: BridgeEntry;
154
+ }
155
+
156
+ export class DiscordBridge {
157
+ private static instance?: DiscordBridge;
158
+
159
+ public static getInstance(): DiscordBridge {
160
+ if (!DiscordBridge.instance) DiscordBridge.instance = new DiscordBridge();
161
+ return DiscordBridge.instance;
162
+ }
163
+
164
+ private client?: Client;
165
+ private bot?: QQBot;
166
+ private started = false;
167
+
168
+ /** QQ 消息 id →(bridge key → Discord 消息 id):同群多频道时每个频道各有自己的转发,回复引用必须按频道取 */
169
+ private readonly discordByQQ = new BoundedCache<Record<string, string>>();
170
+ /** Discord 消息 id → QQ 消息 id */
171
+ private readonly qqByDiscord = new BoundedCache<number>();
172
+ /** 本桥转发到 QQ 的消息 id(回复链 & need_reply/need_cmd 判定) */
173
+ private readonly qqFromBridge = new BoundedCache<true>();
174
+ /** 本桥转发到 Discord 的消息 id(回复链 & need_cmd 判定) */
175
+ private readonly dcFromBridge = new BoundedCache<true>();
176
+ /** 转发到 QQ 的消息 id → 来源 bridge key(同群多频道时回复要回到原频道) */
177
+ private readonly bridgeByQQForward = new BoundedCache<string>();
178
+ /**
179
+ * 桥发往 Discord 的待发消息计数(`${channelId}\n${content}` → 条数)。
180
+ * messageCreate 回推可能先于 send() 返回(此时 dcFromBridge 尚未登记),
181
+ * 发送前先登记、回推时核销,保证乱序下也不会把桥转发回传 QQ 形成回环。
182
+ */
183
+ private readonly pendingBridgeSends = new Map<string, number>();
184
+
185
+ private constructor() {}
186
+
187
+ /** 该群是否配置了互通条目(QQ 侧 /send 分发与桥接共用同一判定) */
188
+ public static isSendTargetGroup(groupId: number | string): boolean {
189
+ const group = String(groupId);
190
+ return Object.values(getDiscordBridgeConfig().bridges).some(entry => entry.group === group);
191
+ }
192
+
193
+ /** after-start 钩子:启动 Discord 客户端(token 为空时禁用本功能) */
194
+ public start(bot: QQBot): void {
195
+ if (this.started) return;
196
+ this.started = true;
197
+ this.bot = bot;
198
+ // 机器人自身发出的群消息(GitHub 通知、命令回复等)也转发到 Discord。
199
+ // 注意:OneBot 不会把 bot 自己的消息回推为 message 事件,只能挂发送出口钩子。
200
+ bot.onGroupMessageSent(event => {
201
+ this.handleQQOutgoingMessage(event).catch(error => {
202
+ bot.logger?.error(
203
+ `[DiscordBridge] 机器人消息转发失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
204
+ );
205
+ });
206
+ });
207
+ const token = getDiscordBridgeConfig().token;
208
+ if (!token) {
209
+ bot.logger?.warn('[DiscordBridge] token 未配置(configs/features/discord-bridge.json),互通功能已禁用');
210
+ return;
211
+ }
212
+ this.client = new Client({
213
+ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent]
214
+ });
215
+ this.client.on('messageCreate', message => {
216
+ this.handleDiscordMessage(message).catch(error => {
217
+ this.bot?.logger?.error(
218
+ `[DiscordBridge] 处理 Discord 消息失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
219
+ );
220
+ });
221
+ });
222
+ this.client.on('error', error => {
223
+ this.bot?.logger?.error(`[DiscordBridge] Discord 客户端错误: ${error.message}`);
224
+ });
225
+ this.client.once('clientReady', client => {
226
+ this.bot?.logger?.info(`[DiscordBridge] 已登录 Discord:${client.user.tag}`);
227
+ // 清理上次进程退出时残留的「AI 处理中」表情回应(Discord 侧)
228
+ this.cleanupStaleHermesReactions(client).catch(error => {
229
+ this.bot?.logger?.error(
230
+ `[DiscordBridge] 残留表情回应清理失败: ${error instanceof Error ? error.message : String(error)}`
231
+ );
232
+ });
233
+ // 注册原生斜杠命令(guild 级即时生效;bridges 配置去重的全部服务器)
234
+ const guildIds = [...new Set(Object.keys(getDiscordBridgeConfig().bridges).map(key => key.split('#')[0]!))];
235
+ registerSlashCommands(client, guildIds, bot).catch(error => {
236
+ this.bot?.logger?.error(
237
+ `[DiscordBridge] 注册斜杠命令异常: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
238
+ );
239
+ });
240
+ });
241
+ // 原生斜杠命令交互:经 token 精确回放交给 gugle-command 执行,回复发在 Discord
242
+ this.client.on('interactionCreate', interaction => {
243
+ if (!interaction.isChatInputCommand() || !this.bot) return;
244
+ const bot = this.bot;
245
+ handleSlashInteraction(bot, interaction, (guildId, channelName) => {
246
+ const bridge = channelName ? this.resolveByChannel(guildId, channelName) : undefined;
247
+ const group = bridge ? Number(bridge.entry.group) : NaN;
248
+ return Number.isSafeInteger(group) ? group : undefined;
249
+ }).catch(error => {
250
+ bot.logger?.error(
251
+ `[DiscordBridge] 斜杠命令交互失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
252
+ );
253
+ });
254
+ });
255
+ this.client.login(token).catch(error => {
256
+ this.bot?.logger?.error(
257
+ `[DiscordBridge] Discord 登录失败: ${error instanceof Error ? error.message : String(error)}`
258
+ );
259
+ });
260
+ }
261
+
262
+ /**
263
+ * 启动清理:摘除上次进程在 AI 处理中退出时残留的 Discord 表情回应。
264
+ * 记录由 HermesBridge 在贴表情时立即持久化(data/hermes-reactions.json);
265
+ * 消息已删除/频道不可达等失败仅清理记录,不做重试。
266
+ */
267
+ private async cleanupStaleHermesReactions(client: Client): Promise<void> {
268
+ const reactions = ReactionManager.getInstance();
269
+ const stale = reactions.listDiscord();
270
+ for (const record of stale) {
271
+ try {
272
+ const channel = record.channelId ? await client.channels.fetch(record.channelId) : null;
273
+ if (channel?.isTextBased()) {
274
+ const message = await channel.messages.fetch(record.messageId);
275
+ const reaction =
276
+ message.reactions.resolve(record.emoji) ??
277
+ message.reactions.cache.find(item => item.emoji.name === record.emoji);
278
+ await reaction?.users.remove(client.user?.id);
279
+ }
280
+ } catch (error) {
281
+ this.bot?.logger?.warn(
282
+ `[DiscordBridge] 摘除残留表情回应失败(${record.key}): ${error instanceof Error ? error.message : String(error)}`
283
+ );
284
+ }
285
+ reactions.drop(record.key);
286
+ }
287
+ if (stale.length > 0) {
288
+ this.bot?.logger?.info(`[DiscordBridge] 已清理 ${stale.length} 条残留的 Discord 表情回应`);
289
+ }
290
+ }
291
+
292
+ // -------------------------------------------------------------------------
293
+ // QQ → Discord
294
+ // -------------------------------------------------------------------------
295
+
296
+ /** message-event-group 钩子:把 QQ 群消息转发到 Discord(或执行 /send) */
297
+ public handleQQMessage(bot: QQBot, msg: GroupMessageWSMSG): void {
298
+ try {
299
+ // 同一 QQ 群可绑定多个频道:普通消息逐条门控并转发到所有放行的频道
300
+ const bridges = this.resolveAllByGroup(msg.group_id);
301
+ if (bridges.length === 0) return;
302
+
303
+ // /send 命令(两个 need_cmd 状态都可用;与全局命令分发互斥,此处独立处理)
304
+ // 仅拦截「/send + 空白/结尾」,/sendxxx 之类的消息仍按普通消息转发
305
+ const raw = msg.raw_message ?? '';
306
+ if (this.isSendCommand(raw)) {
307
+ this.handleSendFromQQ(bot, msg, raw).catch(error => {
308
+ bot.logger?.error(
309
+ `[DiscordBridge] QQ /send 执行失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
310
+ );
311
+ });
312
+ return;
313
+ }
314
+
315
+ const replyId = this.extractQQReplyId(msg);
316
+ // 「对桥消息的回复」需定位到该桥消息来自的频道(回复要回到原频道)
317
+ const replyBridge = replyId !== undefined ? this.bridgeByQQForward.get(replyId) : undefined;
318
+ const isReplyToBridge = replyBridge !== undefined;
319
+
320
+ for (const bridge of bridges) {
321
+ // 门控:need_cmd / need_reply 任一开启时,仅放行「对(该频道)桥消息的回复」
322
+ if (bridge.entry.need_cmd === 'true' || bridge.entry.need_reply === 'true') {
323
+ if (!isReplyToBridge || replyBridge !== bridge.key) continue;
324
+ }
325
+ this.forwardQQToDiscord(bot, msg, bridge, replyId).catch(error => {
326
+ bot.logger?.error(
327
+ `[DiscordBridge] QQ→Discord 转发失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
328
+ );
329
+ });
330
+ }
331
+ } catch (error) {
332
+ bot.logger?.error(
333
+ `[DiscordBridge] QQ 消息处理异常: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
334
+ );
335
+ }
336
+ }
337
+
338
+ /** 提取 QQ 消息中的回复目标 id(reply 段不计入正文) */
339
+ private extractQQReplyId(msg: GroupMessageWSMSG): number | undefined {
340
+ if (!Array.isArray(msg.message)) return undefined;
341
+ const reply = msg.message.find(segment => segment.type === 'reply');
342
+ if (!reply) return undefined;
343
+ const id = Number(reply.data.id);
344
+ return Number.isSafeInteger(id) ? id : undefined;
345
+ }
346
+
347
+ /** QQ 消息段拆分:文本/附件分离(附件以 Discord 附件上传,文本不混入 URL) */
348
+ private splitQQMessage(msg: GroupMessageWSMSG): { text: string; attachments: { url: string; name: string }[] } {
349
+ const textParts: string[] = [];
350
+ const attachments: { url: string; name: string }[] = [];
351
+ if (Array.isArray(msg.message)) {
352
+ for (const segment of msg.message) {
353
+ switch (segment.type) {
354
+ case 'text':
355
+ textParts.push(segment.data.text);
356
+ break;
357
+ case 'at':
358
+ textParts.push(`@${segment.data.qq}`);
359
+ break;
360
+ case 'face':
361
+ textParts.push('[表情]');
362
+ break;
363
+ case 'image':
364
+ case 'record':
365
+ case 'video':
366
+ case 'file': {
367
+ const url = segment.data.url || segment.data.file;
368
+ if (url && /^https?:\/\//.test(url)) {
369
+ const fallback =
370
+ segment.type === 'image'
371
+ ? 'image.png'
372
+ : segment.type === 'record'
373
+ ? 'audio.amr'
374
+ : segment.type === 'video'
375
+ ? 'video.mp4'
376
+ : 'file';
377
+ attachments.push({ url, name: fallback });
378
+ }
379
+ break;
380
+ }
381
+ default:
382
+ break;
383
+ }
384
+ }
385
+ }
386
+ return { text: textParts.join('').trim(), attachments };
387
+ }
388
+
389
+ /** 按字节内容识别常见图片格式,返回对应扩展名(识别不了返回 null) */
390
+ private detectImageExt(buf: Buffer): string | null {
391
+ if (buf.length < 12) return null;
392
+ if (buf[0] === 0xff && buf[1] === 0xd8) return 'jpg';
393
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
394
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
395
+ if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf.toString('ascii', 8, 12) === 'WEBP')
396
+ return 'webp';
397
+ if (buf[0] === 0x42 && buf[1] === 0x4d) return 'bmp';
398
+ return null;
399
+ }
400
+
401
+ /**
402
+ * 下载附件为 Buffer 并修正扩展名。
403
+ * NapCat 多媒体直链带 rkey 防盗链,由 bot 侧下载再上传比让 Discord 服务器抓取更可靠;
404
+ * 文件名必须带真实图片扩展名,Discord 才会内嵌渲染而不是显示为附件卡片。
405
+ */
406
+ private async fetchAttachment(
407
+ bot: QQBot,
408
+ item: { url: string; name: string }
409
+ ): Promise<{ attachment: Buffer; name: string } | undefined> {
410
+ try {
411
+ const res = await bot.axiosInstance.get<ArrayBuffer>(item.url, { responseType: 'arraybuffer', timeout: 30000 });
412
+ const buf = Buffer.from(res.data);
413
+ const ext = this.detectImageExt(buf);
414
+ let name = item.name;
415
+ if (ext) {
416
+ // 图片:确保扩展名与真实格式一致(URL 里常是 download?fileid=... 没有扩展名)
417
+ name = name.replace(/\.[a-z0-9]+$/i, '') + '.' + ext;
418
+ }
419
+ return { attachment: buf, name };
420
+ } catch (error) {
421
+ bot.logger?.error(
422
+ `[DiscordBridge] 附件下载失败(${item.url.slice(0, 80)}…): ${error instanceof Error ? error.message : String(error)}`
423
+ );
424
+ return undefined;
425
+ }
426
+ }
427
+
428
+ /** QQ→Discord 转发主流程;bodyOverride 用于 /send(剥离命令前缀后的正文) */
429
+ private async forwardQQToDiscord(
430
+ bot: QQBot,
431
+ msg: GroupMessageWSMSG,
432
+ bridge: ResolvedBridge,
433
+ replyId?: number,
434
+ viaCommand: boolean = false,
435
+ bodyOverride?: string
436
+ ): Promise<void> {
437
+ if (!this.client) return;
438
+ const channel = await this.resolveDiscordChannel(bridge);
439
+ if (!channel) return;
440
+
441
+ const senderName = msg.sender.card || msg.sender.nickname;
442
+ const header = `【${msg.group_name}|${msg.group_id}】${senderName}(${msg.sender.user_id}):`;
443
+ const { text, attachments } =
444
+ bodyOverride !== undefined ? { text: bodyOverride, attachments: [] as { url: string; name: string }[] } : this.splitQQMessage(msg);
445
+ if (!text && attachments.length === 0) return;
446
+
447
+ // QQ 消息符合 Discord markdown → 原文发送(Discord 原生渲染);否则转义为 plain text
448
+ // 注意:仅在文本非空时拼接换行,避免纯图片消息出现孤立的来源行
449
+ const rendered = isDiscordMarkdown(text) ? text : escapeDiscord(text);
450
+ const content = this.truncateDiscord(text ? `${header}\n${rendered}` : header);
451
+
452
+ const referenceId = replyId !== undefined ? this.discordByQQ.get(replyId)?.[bridge.key] : undefined;
453
+ // 附件由 bot 下载为 Buffer 上传(避免 Discord 服务器抓取 NapCat 防盗链直链失败/拿到非图片响应),
454
+ // 并按字节内容修正扩展名,保证图片在 Discord 内嵌渲染
455
+ const files = (
456
+ await Promise.all(attachments.map(item => this.fetchAttachment(bot, item)))
457
+ ).filter((item): item is { attachment: Buffer; name: string } => item !== undefined);
458
+ const sent = await this.sendWithReference(channel, { content, files }, referenceId);
459
+ this.registerDiscordMessage(sent.id, msg.message_id, bridge.key, viaCommand);
460
+ bot.logger?.debug(`[DiscordBridge] QQ→Discord:${msg.group_id} → ${bridge.key}(${sent.id})`);
461
+ }
462
+
463
+ /**
464
+ * 机器人自身发出的群消息 → Discord(GitHub 通知、命令回复、AI 对话等)。
465
+ *
466
+ * 由 QQBot.onGroupMessageSent 钩子驱动(OneBot 不回推 bot 自己的 message 事件)。
467
+ * 门控与普通 QQ 消息一致:need_cmd / need_reply 频道仅放行「对该频道桥消息的回复」
468
+ * (如 AI 回复 /send 过来的消息),webhook 推送(GitHub 订阅通知)一律不进。
469
+ */
470
+ private async handleQQOutgoingMessage(event: GroupMessageSentEvent): Promise<void> {
471
+ // 桥自身转发到 QQ 的消息必须跳过,否则「Discord→QQ→Discord」形成回环
472
+ if (event.fromBridge) return;
473
+ // 发送失败(无 messageId)说明消息根本没发出去,无需转发
474
+ if (event.messageId === undefined) return;
475
+ if (!this.client || !this.bot) return;
476
+ const bot = this.bot;
477
+
478
+ const bridges = this.resolveAllByGroup(event.groupId);
479
+ if (bridges.length === 0) return;
480
+
481
+ const loginInfo = bot.getLoginInfoSync();
482
+ const senderName = loginInfo?.nickname ?? 'QQ Bot';
483
+ const senderId = loginInfo?.user_id ?? 'unknown';
484
+ const header = `【QQ群 ${event.groupId}】${senderName}(${senderId}):`;
485
+
486
+ // 拆分发送载荷:文本 / 附件(base64 或直链)/ 回复引用
487
+ const textParts: string[] = [];
488
+ const downloads: { url: string; name: string }[] = [];
489
+ const files: { attachment: Buffer; name: string }[] = [];
490
+ let replyQQId: number | undefined;
491
+ // QQ 侧 AI 长回复现在以聊天记录(node 段)形式发出,若直接跳过会导致这类消息完全不镜像到 Discord。
492
+ // 因此这里把 node 内的文本拍平后追加到 textParts,再走下方既有门控正常转发。
493
+ // 已知限制:node 内的图片/语音/视频/文件等附件不镜像(仅抽取 text/at/face 文本)。
494
+ const nodeTexts: string[] = [];
495
+ for (const segment of event.message) {
496
+ if (segment.type === 'node') {
497
+ const nodeText = this.flattenForwardNodeText(segment.data.content);
498
+ if (nodeText) nodeTexts.push(nodeText);
499
+ continue;
500
+ }
501
+ switch (segment.type) {
502
+ case 'text':
503
+ textParts.push(segment.data.text);
504
+ break;
505
+ case 'at':
506
+ textParts.push(`@${segment.data.qq}`);
507
+ break;
508
+ case 'face':
509
+ textParts.push('[表情]');
510
+ break;
511
+ case 'reply': {
512
+ const id = Number(segment.data.id);
513
+ if (Number.isSafeInteger(id)) replyQQId = id;
514
+ break;
515
+ }
516
+ case 'image':
517
+ case 'record':
518
+ case 'video':
519
+ case 'file': {
520
+ const fallback =
521
+ segment.type === 'image'
522
+ ? 'image.png'
523
+ : segment.type === 'record'
524
+ ? 'audio.amr'
525
+ : segment.type === 'video'
526
+ ? 'video.mp4'
527
+ : 'file';
528
+ // bot 发送的图片多为 base64 载荷(GitHub 卡片、markdown 渲染图),直接解码为附件
529
+ const base64 = this.extractBase64Payload(segment.data.file);
530
+ if (base64) {
531
+ files.push({ attachment: Buffer.from(base64, 'base64'), name: fallback });
532
+ break;
533
+ }
534
+ const url = segment.data.url || segment.data.file;
535
+ if (url && /^https?:\/\//.test(url)) downloads.push({ url, name: fallback });
536
+ break;
537
+ }
538
+ default:
539
+ break;
540
+ }
541
+ }
542
+ for (const item of downloads) {
543
+ const fetched = await this.fetchAttachment(bot, item);
544
+ if (fetched) files.push(fetched);
545
+ }
546
+
547
+ // 聊天记录按节点分段展示:节点文本之间补换行,避免前一段末尾与后一段开头粘连
548
+ if (nodeTexts.length > 0) {
549
+ if (textParts.length > 0) textParts.push('\n');
550
+ textParts.push(nodeTexts.join('\n'));
551
+ }
552
+
553
+ const text = textParts.join('').trim();
554
+ if (!text && files.length === 0) return;
555
+
556
+ const rendered = isDiscordMarkdown(text) ? text : escapeDiscord(text);
557
+ const content = this.truncateDiscord(text ? `${header}\n${rendered}` : header);
558
+
559
+ // 「对桥消息的回复」按桥定位来源频道(与普通 QQ 消息门控同一判定):
560
+ // AI 回复 /send 到 QQ 的消息等场景可回到原频道,其余机器人消息不进 need_cmd / need_reply 频道
561
+ const replyBridgeKey = replyQQId !== undefined ? this.bridgeByQQForward.get(replyQQId) : undefined;
562
+
563
+ for (const bridge of bridges) {
564
+ if (!this.shouldForwardBotMessage(bridge, event.fromWebhook, replyBridgeKey)) continue;
565
+ const channel = await this.resolveDiscordChannel(bridge);
566
+ if (!channel) continue;
567
+ try {
568
+ // 回复引用按频道各自的转发映射取(同群多频道时同一 QQ 消息在每个频道各有转发)
569
+ const referenceId = replyQQId !== undefined ? this.discordByQQ.get(replyQQId)?.[bridge.key] : undefined;
570
+ const sent = await this.sendWithReference(channel, { content, files }, referenceId);
571
+ // 登记映射:Discord 用户回复机器人消息时可按回复链回传到 QQ
572
+ this.registerDiscordMessage(sent.id, event.messageId, bridge.key);
573
+ bot.logger?.debug(`[DiscordBridge] 机器人消息→Discord:${event.groupId} → ${bridge.key}(${sent.id})`);
574
+ } catch (error) {
575
+ bot.logger?.error(
576
+ `[DiscordBridge] 机器人消息转发失败(${bridge.key}): ${error instanceof Error ? error.message : String(error)}`
577
+ );
578
+ }
579
+ }
580
+ }
581
+
582
+ /** 聊天记录(node 段)内文本的递归深度上限:防御异常数据结构导致的无限递归 */
583
+ private static readonly FORWARD_NODE_MAX_DEPTH = 4;
584
+
585
+ /**
586
+ * 抽取合并转发(聊天记录)节点内容里的文本。
587
+ *
588
+ * QQ 侧超长 AI 回复现在以聊天记录(node)形式发出,这里把节点内容递归拍平:
589
+ * text → 原文、at → `@QQ号`、face → `[表情]`,嵌套的 node 继续下钻;
590
+ * 达到深度上限、或遇到非数组/未知类型时安全跳过(不抛错)。
591
+ * 已知限制:节点内的图片/语音/视频/文件等附件不镜像(仅回传文本)。
592
+ *
593
+ * @param content 节点内容(Message[]、纯文本字符串,或结构异常的未知值)
594
+ * @param depth 当前递归深度,超过上限即停止下钻
595
+ */
596
+ private flattenForwardNodeText(content: unknown, depth: number = 0): string {
597
+ if (depth >= DiscordBridge.FORWARD_NODE_MAX_DEPTH) return '';
598
+ if (typeof content === 'string') return content;
599
+ if (!Array.isArray(content)) return '';
600
+ const parts: string[] = [];
601
+ for (const raw of content) {
602
+ if (!raw || typeof raw !== 'object') continue;
603
+ const segment = raw as { type?: unknown; data?: unknown };
604
+ const data = (segment.data ?? {}) as { text?: unknown; qq?: unknown; content?: unknown };
605
+ switch (segment.type) {
606
+ case 'text':
607
+ if (typeof data.text === 'string') parts.push(data.text);
608
+ break;
609
+ case 'at':
610
+ if (data.qq !== undefined && data.qq !== null) parts.push(`@${String(data.qq)}`);
611
+ break;
612
+ case 'face':
613
+ parts.push('[表情]');
614
+ break;
615
+ case 'node':
616
+ parts.push(this.flattenForwardNodeText(data.content, depth + 1));
617
+ break;
618
+ default:
619
+ break;
620
+ }
621
+ }
622
+ return parts.join('');
623
+ }
624
+
625
+ /**
626
+ * 机器人消息门控(与普通 QQ 用户消息同一套语义):
627
+ * - 未开启门控的频道:全部放行;
628
+ * - need_cmd / need_reply 频道:仅放行「对该频道桥消息的回复」
629
+ * (replyBridgeKey 为被回复的 QQ 桥消息来源频道);webhook 推送一律不进。
630
+ */
631
+ private shouldForwardBotMessage(bridge: ResolvedBridge, fromWebhook: boolean, replyBridgeKey?: string): boolean {
632
+ const gated = bridge.entry.need_cmd === 'true' || bridge.entry.need_reply === 'true';
633
+ if (!gated) return true;
634
+ if (fromWebhook) return false;
635
+ return replyBridgeKey === bridge.key;
636
+ }
637
+
638
+ /**
639
+ * 带回复引用发送;被引用消息已删除或不可见(MESSAGE_REFERENCE_UNKNOWN)时
640
+ * 降级为无引用发送,保证消息不丢。
641
+ */
642
+ private async sendWithReference(
643
+ channel: TextChannel,
644
+ payload: { content: string; files: { attachment: Buffer; name: string }[] },
645
+ referenceId?: string
646
+ ): Promise<DiscordMessage> {
647
+ // 发送前登记待发标记:messageCreate 回推可能先于本方法返回,接收侧据此识别桥转发
648
+ const pendingKey = `${channel.id}\n${payload.content}`;
649
+ if (this.pendingBridgeSends.size > 500) this.pendingBridgeSends.clear();
650
+ this.pendingBridgeSends.set(pendingKey, (this.pendingBridgeSends.get(pendingKey) ?? 0) + 1);
651
+ try {
652
+ return await channel.send({
653
+ ...payload,
654
+ ...(referenceId ? { reply: { messageReference: referenceId } } : {}),
655
+ allowedMentions: { parse: [], repliedUser: false }
656
+ });
657
+ } catch (error) {
658
+ if (referenceId && error instanceof Error && error.message.includes('MESSAGE_REFERENCE_UNKNOWN')) {
659
+ return await channel.send({ ...payload, allowedMentions: { parse: [], repliedUser: false } });
660
+ }
661
+ throw error;
662
+ }
663
+ }
664
+
665
+ /** 从 OneBot 文件字段提取 base64 载荷(`base64://…` 或 `data:image/…;base64,…`),非 base64 返回 undefined */
666
+ private extractBase64Payload(file: string): string | undefined {
667
+ if (file.startsWith('base64://')) return file.slice('base64://'.length).trim();
668
+ const match = /^data:image\/[a-z0-9.+-]+;base64,\s*(.+)$/i.exec(file);
669
+ return match?.[1]?.trim();
670
+ }
671
+
672
+ // -------------------------------------------------------------------------
673
+ // Discord → QQ
674
+ // -------------------------------------------------------------------------
675
+
676
+ /** messageCreate 回调:把 Discord 频道消息转发到 QQ(或执行 /send) */
677
+ private async handleDiscordMessage(message: DiscordMessage): Promise<void> {
678
+ if (message.webhookId) return;
679
+ if (message.channel.type !== ChannelType.GuildText) return;
680
+ const guildId = message.guildId;
681
+ if (!guildId) return;
682
+
683
+ const bridge = this.resolveByChannel(guildId, message.channel.name);
684
+ if (!bridge) return;
685
+
686
+ // messageCreate 同样回推 bot 自己的消息:机器人自己在互通频道发的消息(命令回复、
687
+ // AI 回复、GitHub 卡片、斜杠命令回复等)同步到 QQ,让 QQ 侧看到完整对话;
688
+ // 桥自身 QQ→Discord 的转发已在 handleOwnDiscordMessage 内跳过,不会回环
689
+ if (message.author.bot) {
690
+ if (message.author.id === this.client?.user?.id) {
691
+ await this.handleOwnDiscordMessage(message, bridge);
692
+ }
693
+ return;
694
+ }
695
+
696
+ const raw = message.content ?? '';
697
+ if (this.isSendCommand(raw)) {
698
+ await this.handleSendFromDiscord(message, raw);
699
+ return;
700
+ }
701
+
702
+ // 命令分发:/ 或 ! 前缀且首段命中已注册命令时,在 Discord 侧直接执行并把回复发在
703
+ // Discord 频道;命令消息本身仍按后续流程转发到 QQ(QQ 侧看到的是 bot 代发的文本,
704
+ // 不会二次执行)。未命中注册的 /xxx 文本同样按普通消息继续走转发流程
705
+ await this.handleDiscordCommand(message, bridge, raw);
706
+
707
+ const referenceId = message.reference?.messageId;
708
+ const referenceKey = referenceId ? this.snowflakeToKey(referenceId) : undefined;
709
+ const isReplyToBridge = referenceKey !== undefined && this.dcFromBridge.get(referenceKey) === true;
710
+
711
+ // Discord 侧 #编号 查询 GitHub Issue/PR(与 QQ 侧共用同一套解析/渲染,回复发在 Discord
712
+ // 频道);不受 need_cmd 门控影响,消息本身仍按门控决定是否转发到 QQ
713
+ if (this.bot) {
714
+ const groupId = Number(bridge.entry.group);
715
+ const results = await Github.processDiscordMessage(
716
+ this.bot,
717
+ raw,
718
+ Number.isSafeInteger(groupId) ? groupId : undefined
719
+ );
720
+ for (const result of results) {
721
+ await this.sendGithubResultToDiscord(message, result);
722
+ }
723
+ }
724
+
725
+ // Hermes AI:互通频道消息可触发 AI(审批回复 / @提及 / 关键词,频道是否启用由其桥接的
726
+ // QQ 群在 hermes.json groups 中决定)。AI 回复经注入的委托发在 Discord 频道;
727
+ // 消息本身仍按门控决定是否转发到 QQ
728
+ if (this.bot) {
729
+ const bot = this.bot;
730
+ const channel = message.channel as TextChannel;
731
+ HermesBridge.getInstance().handleDiscordMessage(bot, message, {
732
+ groupId: bridge.entry.group,
733
+ ...(this.client?.user?.id ? { botUserId: this.client.user.id } : {}),
734
+ guildName: message.guild?.name ?? bridge.guildId,
735
+ channelName: channel.name,
736
+ isAdmin: isDiscordAdmin(message),
737
+ send: async (text, quote) => {
738
+ // 委托侧兜底 2000 字上限(Hermes 已按 maxMessageLength 切分,通常不会触发)
739
+ const chunks: string[] = [];
740
+ let rest = text;
741
+ while (rest.length > DISCORD_MESSAGE_LIMIT) {
742
+ chunks.push(rest.slice(0, DISCORD_MESSAGE_LIMIT));
743
+ rest = rest.slice(DISCORD_MESSAGE_LIMIT);
744
+ }
745
+ if (rest) chunks.push(rest);
746
+ for (let index = 0; index < chunks.length; index++) {
747
+ const content = chunks[index]!;
748
+ try {
749
+ if (quote && index === 0) {
750
+ await message.reply({ content, allowedMentions: { parse: [], repliedUser: false } });
751
+ } else {
752
+ await channel.send({ content, allowedMentions: { parse: [] } });
753
+ }
754
+ } catch (error) {
755
+ bot.logger?.error(
756
+ `[DiscordBridge] Hermes 回复发送失败: ${error instanceof Error ? error.message : String(error)}`
757
+ );
758
+ }
759
+ }
760
+ },
761
+ sendImage: async base64 => {
762
+ await channel
763
+ .send({ files: [{ attachment: Buffer.from(base64, 'base64'), name: 'image.png' }] })
764
+ .catch(error => {
765
+ bot.logger?.error(
766
+ `[DiscordBridge] Hermes 图片发送失败: ${error instanceof Error ? error.message : String(error)}`
767
+ );
768
+ });
769
+ },
770
+ // 摘除机器人对触发消息的「AI 处理中」表情回应(完成/失败/停止时由 HermesBridge 调用)
771
+ removeReaction: async () => {
772
+ const emoji = getHermesConfig().reactionEmojiDiscord;
773
+ const reaction =
774
+ message.reactions.resolve(emoji) ?? message.reactions.cache.find(item => item.emoji.name === emoji);
775
+ await reaction?.users.remove();
776
+ }
777
+ });
778
+ }
779
+
780
+ // 门控:need_cmd 开启时仅放行「对桥消息的回复」(need_reply 不限制此方向)
781
+ if (bridge.entry.need_cmd === 'true' && !isReplyToBridge) return;
782
+
783
+ await this.forwardDiscordToQQ(message, bridge, referenceId);
784
+ }
785
+
786
+ /**
787
+ * 机器人自己在 Discord 互通频道发出的消息 → QQ(命令回复、AI 回复、GitHub 卡片、
788
+ * 斜杠命令回复等,复用 forwardDiscordToQQ 的渲染/回复链/附件处理)。
789
+ *
790
+ * 防回环:桥自身 QQ→Discord 的转发在发送前登记待发标记(pendingBridgeSends,
791
+ * 兼容 messageCreate 先于 send 返回的乱序)、发送后登记 dcFromBridge,双重识别后跳过;
792
+ * 门控与普通 Discord 消息一致(need_cmd 频道仅放行「对桥消息的回复」)。
793
+ * 转发到 QQ 的消息带 fromBridge 标记且登记为桥消息,QQ 侧回复它可路由回本频道。
794
+ */
795
+ private async handleOwnDiscordMessage(message: DiscordMessage, bridge: ResolvedBridge): Promise<void> {
796
+ // 乱序防回环:messageCreate 先于 registerDiscordMessage 到达时 dcFromBridge 查不到,
797
+ // 按「频道+正文」核销发送前登记的待发标记(仅桥转发走 sendWithReference,必然登记)
798
+ const pendingKey = `${message.channelId}\n${message.content ?? ''}`;
799
+ const pending = this.pendingBridgeSends.get(pendingKey) ?? 0;
800
+ if (pending > 0) {
801
+ if (pending === 1) this.pendingBridgeSends.delete(pendingKey);
802
+ else this.pendingBridgeSends.set(pendingKey, pending - 1);
803
+ return;
804
+ }
805
+ if (this.dcFromBridge.get(this.snowflakeToKey(message.id)) === true) return;
806
+ // 交互延迟占位等无内容消息不同步
807
+ if (!message.content && message.attachments.size === 0) return;
808
+ const referenceId = message.reference?.messageId;
809
+ const referenceKey = referenceId ? this.snowflakeToKey(referenceId) : undefined;
810
+ const isReplyToBridge = referenceKey !== undefined && this.dcFromBridge.get(referenceKey) === true;
811
+ if (bridge.entry.need_cmd === 'true' && !isReplyToBridge) return;
812
+ await this.forwardDiscordToQQ(message, bridge, referenceId);
813
+ }
814
+
815
+ /**
816
+ * Discord 命令分发:命中已注册命令根节点则执行并返回 true(消息不再转发),
817
+ * 否则返回 false 交回普通转发流程。
818
+ */
819
+ private async handleDiscordCommand(message: DiscordMessage, bridge: ResolvedBridge, raw: string): Promise<boolean> {
820
+ if (!this.bot) return false;
821
+ const trimmed = raw.trim();
822
+ if (!trimmed.startsWith('/') && !trimmed.startsWith('!')) return false;
823
+ // Discord 客户端会把 / 开头内容当作斜杠命令输入,额外支持 ! 前缀兜底,统一归一化为 /
824
+ const normalized = normalizeCommandText(trimmed);
825
+ const rootName = normalized.slice(1).split(/\s/)[0];
826
+ if (!rootName || !this.isRegisteredCommand(rootName)) return false;
827
+ const groupId = Number(bridge.entry.group);
828
+ const source = new DiscordMsgCommandSource(this.bot, message, Number.isSafeInteger(groupId) ? groupId : undefined);
829
+ try {
830
+ executeCommand(this.bot.getCommandManager(), source, normalized);
831
+ } catch (error) {
832
+ this.bot.logger?.error(
833
+ `[DiscordBridge] Discord 命令执行失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
834
+ );
835
+ }
836
+ return true;
837
+ }
838
+
839
+ /** 首段是否为已注册命令(遍历全部命名空间根节点的字面量子节点) */
840
+ private isRegisteredCommand(name: string): boolean {
841
+ const manager = this.bot?.getCommandManager();
842
+ if (!manager) return false;
843
+ for (const root of manager.roots.values()) {
844
+ if (root.children.some(child => child.isLiteral() && child.toString() === name)) return true;
845
+ }
846
+ return false;
847
+ }
848
+
849
+ /** 把 GitHub Issue/PR 查询结果以 Discord 回复发回原频道(图片为附件,失败提示为文本) */
850
+ private async sendGithubResultToDiscord(message: DiscordMessage, result: Message): Promise<void> {
851
+ try {
852
+ if (result.type === 'image') {
853
+ const base64 = this.extractBase64Payload(result.data.file);
854
+ if (!base64) return;
855
+ await message.reply({
856
+ files: [{ attachment: Buffer.from(base64, 'base64'), name: 'github.png' }],
857
+ allowedMentions: { parse: [], repliedUser: false }
858
+ });
859
+ return;
860
+ }
861
+ if (result.type === 'text') {
862
+ await message.reply({
863
+ content: this.truncateDiscord(result.data.text),
864
+ allowedMentions: { parse: [], repliedUser: false }
865
+ });
866
+ }
867
+ } catch (error) {
868
+ this.bot?.logger?.error(
869
+ `[DiscordBridge] GitHub 卡片回复失败: ${error instanceof Error ? error.message : String(error)}`
870
+ );
871
+ }
872
+ }
873
+
874
+ /** Discord→QQ 转发主流程;contentOverride 用于 /send(剥离命令前缀后的正文) */
875
+ private async forwardDiscordToQQ(
876
+ message: DiscordMessage,
877
+ bridge: ResolvedBridge,
878
+ referenceId?: string | null,
879
+ viaCommand: boolean = false,
880
+ contentOverride?: string
881
+ ): Promise<void> {
882
+ if (!this.bot) return;
883
+ const bot = this.bot;
884
+ const channel = message.channel as TextChannel;
885
+ const guildName = message.guild?.name ?? bridge.guildId;
886
+ const authorName = message.member?.displayName ?? message.author.username;
887
+ const header = `【${guildName}|${channel.name}】${authorName}(${message.author.username}):`;
888
+
889
+ const content = contentOverride ?? message.content ?? '';
890
+ const segments: Message[] = [];
891
+
892
+ // 回复链:转发到 QQ 时带 reply 引用
893
+ const replyTarget = referenceId ? this.qqByDiscord.get(this.snowflakeToKey(referenceId)) : undefined;
894
+ if (replyTarget !== undefined) {
895
+ segments.push({ type: 'reply', data: { id: replyTarget } });
896
+ }
897
+
898
+ if (content) {
899
+ if (isDiscordMarkdown(content)) {
900
+ // 含 Discord markdown → 渲染为图片转发(头部并入图片)
901
+ try {
902
+ const base64 = await renderDiscordMessageToImage(header, content, message);
903
+ segments.push({ type: 'image', data: { file: `base64://${base64}` } });
904
+ } catch (error) {
905
+ // 渲染失败降级为纯文本转发,保证消息不丢
906
+ bot.logger?.error(
907
+ `[DiscordBridge] markdown 渲染失败,降级纯文本: ${error instanceof Error ? error.message : String(error)}`
908
+ );
909
+ segments.push({ type: 'text', data: { text: `${header}\n${this.plainDiscordContent(message, content)}` } });
910
+ }
911
+ } else {
912
+ segments.push({ type: 'text', data: { text: `${header}\n${this.plainDiscordContent(message, content)}` } });
913
+ }
914
+ } else if (segments.length === 0 || message.attachments.size > 0) {
915
+ // 无文字但有附件/纯 embed:补头部行
916
+ segments.push({ type: 'text', data: { text: header } });
917
+ }
918
+
919
+ // 附件:图片直接发图片段,其余类型以链接文本行转发
920
+ for (const attachment of message.attachments.values()) {
921
+ if (attachment.contentType?.startsWith('image/')) {
922
+ segments.push({ type: 'image', data: { file: attachment.url } });
923
+ } else {
924
+ segments.push({ type: 'text', data: { text: `\n[附件] ${attachment.name}: ${attachment.url}` } });
925
+ }
926
+ }
927
+
928
+ // 纯 embed 消息兜底
929
+ if (!content && message.attachments.size === 0 && message.embeds.length > 0) {
930
+ const embed = message.embeds[0];
931
+ segments.push({ type: 'text', data: { text: `\n[Embed] ${embed?.title ?? ''} ${embed?.url ?? ''}`.trim() } });
932
+ }
933
+
934
+ if (segments.length === 0) return;
935
+ // fromBridge:标记为桥自身发出的消息,发送出口钩子据此跳过,避免回环
936
+ const qqMessageId = await bot.sendGroupMsg(bridge.entry.group, segments, { fromBridge: true });
937
+ if (qqMessageId !== undefined) {
938
+ this.registerQQMessage(qqMessageId, message.id, bridge.key, viaCommand);
939
+ this.bridgeByQQForward.set(qqMessageId, bridge.key);
940
+ bot.logger?.debug(`[DiscordBridge] Discord→QQ:${bridge.key} → ${bridge.entry.group}(${qqMessageId})`);
941
+ }
942
+ }
943
+
944
+ /** Discord 消息正文纯文本化(提及/频道/表情 → 可读文本);contentOverride 用于 /send 剥离前缀后的正文 */
945
+ private plainDiscordContent(message: DiscordMessage, contentOverride?: string): string {
946
+ let text = contentOverride ?? message.content;
947
+ text = text.replace(/<@!?(\d+)>/g, (_match, id: string) => {
948
+ const name = message.mentions.members?.get(id)?.displayName ?? message.mentions.users.get(id)?.username;
949
+ return `@${name ?? id}`;
950
+ });
951
+ text = text.replace(/<@&(\d+)>/g, (_match, id: string) => {
952
+ return `@${message.guild?.roles.cache.get(id)?.name ?? id}`;
953
+ });
954
+ text = text.replace(/<#(\d+)>/g, (_match, id: string) => {
955
+ return `#${message.guild?.channels.cache.get(id)?.name ?? id}`;
956
+ });
957
+ text = text.replace(/<a?:(\w{2,32}):\d+>/g, ':$1:');
958
+ return text.trim();
959
+ }
960
+
961
+ // -------------------------------------------------------------------------
962
+ // /send <msg> [group|channel]
963
+ // -------------------------------------------------------------------------
964
+
965
+ /** 判断文本是否为 /send 命令(/send 后必须为空白或结尾,避免误吞 /sendxxx 消息) */
966
+ private isSendCommand(raw: string): boolean {
967
+ return raw.startsWith(SEND_COMMAND_PREFIX) && (raw.length === SEND_COMMAND_PREFIX.length || /\s/.test(raw[SEND_COMMAND_PREFIX.length]!));
968
+ }
969
+
970
+ /**
971
+ * 解析 /send 参数:尾部 token 命中某个 bridge 的 QQ 群号或频道 key
972
+ * (`guildId#channel`,或 `#channel` 后缀唯一匹配)时作为目标,其余为消息正文。
973
+ * 不填目标时默认发送到配置文件中第一个互通条目的对端。
974
+ */
975
+ private parseSendArgs(text: string): { message: string; target?: string } | null {
976
+ const body = text.slice(SEND_COMMAND_PREFIX.length).trim();
977
+ if (!body) return null;
978
+ const tokens = body.split(/\s+/);
979
+ const bridges = getDiscordBridgeConfig().bridges;
980
+ const keys = Object.keys(bridges);
981
+ if (tokens.length >= 2) {
982
+ const last = tokens[tokens.length - 1]!;
983
+ // QQ 群号
984
+ const byGroup = keys.find(key => bridges[key]!.group === last);
985
+ if (byGroup) return { message: tokens.slice(0, -1).join(' ').trim(), target: byGroup };
986
+ // 完整 key
987
+ if (keys.includes(last)) return { message: tokens.slice(0, -1).join(' ').trim(), target: last };
988
+ // #频道名 后缀(唯一匹配)
989
+ if (last.startsWith('#')) {
990
+ const channelName = last.slice(1);
991
+ const matched = keys.filter(key => key.split('#')[1] === channelName);
992
+ const only = matched[0];
993
+ if (matched.length === 1 && only) return { message: tokens.slice(0, -1).join(' ').trim(), target: only };
994
+ }
995
+ }
996
+ return { message: body };
997
+ }
998
+
999
+ /** QQ 侧 /send:发送到目标 Discord 频道(或目标 QQ 群) */
1000
+ private async handleSendFromQQ(bot: QQBot, msg: GroupMessageWSMSG, raw: string): Promise<void> {
1001
+ const parsed = this.parseSendArgs(raw);
1002
+ if (!parsed || !parsed.message) {
1003
+ bot.sendGroupMsg(msg.group_id, [
1004
+ { type: 'reply', data: { id: msg.message_id } },
1005
+ { type: 'text', data: { text: `用法:${SEND_COMMAND_PREFIX} <消息> [QQ群号|服务器ID#频道名]` } }
1006
+ ]);
1007
+ return;
1008
+ }
1009
+ const targetKey = parsed.target ?? Object.keys(getDiscordBridgeConfig().bridges)[0];
1010
+ if (!targetKey) {
1011
+ bot.sendGroupMsg(msg.group_id, [{ type: 'text', data: { text: '[DiscordBridge] 尚未配置任何互通频道' } }]);
1012
+ return;
1013
+ }
1014
+ const bridge = this.resolveByKey(targetKey);
1015
+ if (!bridge) return;
1016
+ await this.forwardQQToDiscord(bot, msg, bridge, undefined, true, parsed.message);
1017
+ }
1018
+
1019
+ /** Discord 侧 /send:发送到目标 QQ 群(或目标 Discord 频道) */
1020
+ private async handleSendFromDiscord(message: DiscordMessage, raw: string): Promise<void> {
1021
+ const parsed = this.parseSendArgs(raw);
1022
+ if (!parsed || !parsed.message) {
1023
+ await message.reply(`用法:${SEND_COMMAND_PREFIX} <消息> [QQ群号|服务器ID#频道名]`).catch(() => undefined);
1024
+ return;
1025
+ }
1026
+ const targetKey = parsed.target ?? Object.keys(getDiscordBridgeConfig().bridges)[0];
1027
+ if (!targetKey) {
1028
+ await message.reply('[DiscordBridge] 尚未配置任何互通频道').catch(() => undefined);
1029
+ return;
1030
+ }
1031
+ const bridge = this.resolveByKey(targetKey);
1032
+ if (!bridge) return;
1033
+ // 来源频道即目标频道时按普通消息处理,避免同频道复读
1034
+ await this.forwardDiscordToQQ(message, bridge, null, true, parsed.message);
1035
+ }
1036
+
1037
+ // -------------------------------------------------------------------------
1038
+ // 路由表与频道解析
1039
+ // -------------------------------------------------------------------------
1040
+
1041
+ /** 登记一次 QQ→Discord 转发的 id 映射(回复链用;按 bridge key 分别记录,同群多频道互不覆盖) */
1042
+ private registerDiscordMessage(discordId: string, qqId: number, bridgeKey: string, viaCommand: boolean = false): void {
1043
+ const key = this.snowflakeToKey(discordId);
1044
+ const entry = this.discordByQQ.get(qqId) ?? {};
1045
+ entry[bridgeKey] = discordId;
1046
+ this.discordByQQ.set(qqId, entry);
1047
+ this.qqByDiscord.set(key, qqId);
1048
+ this.dcFromBridge.set(key, true);
1049
+ }
1050
+
1051
+ /** 登记一次 Discord→QQ 转发的 id 映射(回复链用;按 bridge key 分别记录,同群多频道互不覆盖) */
1052
+ private registerQQMessage(qqId: number, discordId: string, bridgeKey: string, viaCommand: boolean = false): void {
1053
+ this.qqByDiscord.set(this.snowflakeToKey(discordId), qqId);
1054
+ const entry = this.discordByQQ.get(qqId) ?? {};
1055
+ entry[bridgeKey] = discordId;
1056
+ this.discordByQQ.set(qqId, entry);
1057
+ this.qqFromBridge.set(qqId, true);
1058
+ }
1059
+
1060
+ /**
1061
+ * Discord snowflake 是 19 位十进制数,超出 JS Number 安全整数范围。
1062
+ * 回复链映射用十进制字符串数值化(按模 2^42 折叠)。模数取 2^42 而非 2^53:
1063
+ * 折叠的中间值 value*10+d 必须小于 2^53 才不损失精度(2^53 模数会让中间值
1064
+ * 达到 ~9e16 > 2^53,相邻 id 折叠后相撞);2^42 下中间值 ~4.4e13,全程精确。
1065
+ * 不依赖 BigInt(target < ES2020 无 BigInt 类型)。
1066
+ */
1067
+ private snowflakeToKey(id: string): number {
1068
+ let value = 0;
1069
+ const MODULUS = 2 ** 42;
1070
+ for (const char of id) {
1071
+ value = (value * 10 + (char.charCodeAt(0) - 48)) % MODULUS;
1072
+ }
1073
+ return value;
1074
+ }
1075
+
1076
+ private resolveByKey(key: string): ResolvedBridge | undefined {
1077
+ const entry = getDiscordBridgeConfig().bridges[key];
1078
+ if (!entry) return undefined;
1079
+ const [guildId, channelName] = key.split('#');
1080
+ if (!guildId || !channelName) return undefined;
1081
+ return { key, guildId, channelName, entry };
1082
+ }
1083
+
1084
+ /** 该 QQ 群绑定的全部互通条目(同群可绑多个频道,普通消息逐条门控) */
1085
+ private resolveAllByGroup(groupId: number | string): ResolvedBridge[] {
1086
+ const group = String(groupId);
1087
+ const bridges = getDiscordBridgeConfig().bridges;
1088
+ return Object.keys(bridges)
1089
+ .filter(key => bridges[key]!.group === group)
1090
+ .map(key => this.resolveByKey(key)!);
1091
+ }
1092
+
1093
+ private resolveByChannel(guildId: string, channelName: string): ResolvedBridge | undefined {
1094
+ return this.resolveByKey(`${guildId}#${channelName}`);
1095
+ }
1096
+
1097
+ /** 解析配置条目对应的 Discord 文本频道(缓存优先,未缓存时按频道名抓取) */
1098
+ private async resolveDiscordChannel(bridge: ResolvedBridge): Promise<TextChannel | undefined> {
1099
+ if (!this.client) return undefined;
1100
+ const guild: Guild | undefined = this.client.guilds.cache.get(bridge.guildId);
1101
+ if (!guild) {
1102
+ this.bot?.logger?.warn(`[DiscordBridge] 未找到 Discord 服务器 ${bridge.guildId}(${bridge.key})`);
1103
+ return undefined;
1104
+ }
1105
+ const channel = guild.channels.cache.find(
1106
+ item => item.type === ChannelType.GuildText && item.name === bridge.channelName
1107
+ );
1108
+ if (channel) return channel as TextChannel;
1109
+ try {
1110
+ const fetched = await guild.channels.fetch();
1111
+ const target = fetched?.find(
1112
+ item => item?.type === ChannelType.GuildText && item.name === bridge.channelName
1113
+ );
1114
+ if (target) return target as TextChannel;
1115
+ } catch (error) {
1116
+ this.bot?.logger?.error(
1117
+ `[DiscordBridge] 拉取频道列表失败(${bridge.key}): ${error instanceof Error ? error.message : String(error)}`
1118
+ );
1119
+ }
1120
+ this.bot?.logger?.warn(`[DiscordBridge] 未找到 Discord 频道 ${bridge.key}(频道改名需同步配置)`);
1121
+ return undefined;
1122
+ }
1123
+
1124
+ private truncateDiscord(text: string): string {
1125
+ if (text.length <= DISCORD_MESSAGE_LIMIT) return text;
1126
+ return `${text.slice(0, DISCORD_MESSAGE_LIMIT - 16)}\n…(消息过长已截断)`;
1127
+ }
1128
+ }