@dsh-plugin/dsh-synapse 0.0.0-stage → 0.1.0-dev.41044818627

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 (150) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +18 -0
  3. package/README.md +136 -2
  4. package/cordis.patch.yml +3 -0
  5. package/lib/bot.cjs +8314 -0
  6. package/lib/bot.cjs.map +7 -0
  7. package/lib/build.json +1 -0
  8. package/lib/client.js +46 -0
  9. package/lib/host/agent.d.ts +21 -0
  10. package/lib/host/agent.js +151 -0
  11. package/lib/host/agent.js.map +1 -0
  12. package/lib/host/bot.d.ts +17 -0
  13. package/lib/host/bot.js +158 -0
  14. package/lib/host/bot.js.map +1 -0
  15. package/lib/host/client.d.ts +2 -0
  16. package/lib/host/client.js +19 -0
  17. package/lib/host/client.js.map +1 -0
  18. package/lib/host/config.d.ts +24 -0
  19. package/lib/host/config.js +192 -0
  20. package/lib/host/config.js.map +1 -0
  21. package/lib/host/files.d.ts +29 -0
  22. package/lib/host/files.js +188 -0
  23. package/lib/host/files.js.map +1 -0
  24. package/lib/host/gateway.d.ts +33 -0
  25. package/lib/host/gateway.js +237 -0
  26. package/lib/host/gateway.js.map +1 -0
  27. package/lib/host/github.d.ts +47 -0
  28. package/lib/host/github.js +219 -0
  29. package/lib/host/github.js.map +1 -0
  30. package/lib/host/http.d.ts +4 -0
  31. package/lib/host/http.js +41 -0
  32. package/lib/host/http.js.map +1 -0
  33. package/lib/host/index.d.ts +32 -0
  34. package/lib/host/index.js +192 -0
  35. package/lib/host/index.js.map +1 -0
  36. package/lib/host/inference-worker.d.ts +1 -0
  37. package/lib/host/inference-worker.js +72 -0
  38. package/lib/host/inference-worker.js.map +1 -0
  39. package/lib/host/mcp.d.ts +138 -0
  40. package/lib/host/mcp.js +104 -0
  41. package/lib/host/mcp.js.map +1 -0
  42. package/lib/host/memory.d.ts +26 -0
  43. package/lib/host/memory.js +155 -0
  44. package/lib/host/memory.js.map +1 -0
  45. package/lib/host/panel.d.ts +1 -0
  46. package/lib/host/panel.html +174 -0
  47. package/lib/host/panel.js +388 -0
  48. package/lib/host/panel.js.map +1 -0
  49. package/lib/host/runtime.d.ts +21 -0
  50. package/lib/host/runtime.js +145 -0
  51. package/lib/host/runtime.js.map +1 -0
  52. package/lib/host/types.d.ts +112 -0
  53. package/lib/host/types.js +2 -0
  54. package/lib/host/types.js.map +1 -0
  55. package/package.json +94 -4
  56. package/scripts/build.mjs +90 -0
  57. package/scripts/check.mjs +13 -0
  58. package/scripts/dump-npm-latest.mjs +49 -0
  59. package/scripts/model-smoke.mjs +25 -0
  60. package/src/agent.ts +203 -0
  61. package/src/bot.ts +173 -0
  62. package/src/client.ts +20 -0
  63. package/src/config.ts +217 -0
  64. package/src/files.ts +206 -0
  65. package/src/gateway.ts +276 -0
  66. package/src/github.ts +263 -0
  67. package/src/http.ts +41 -0
  68. package/src/index.ts +227 -0
  69. package/src/inference-worker.ts +89 -0
  70. package/src/mcp.ts +112 -0
  71. package/src/memory.ts +178 -0
  72. package/src/panel.html +174 -0
  73. package/src/panel.ts +440 -0
  74. package/src/runtime.ts +164 -0
  75. package/src/types.ts +106 -0
  76. package/test/core.test.mjs +279 -0
  77. package/test/fixtures/mcp-server.mjs +14 -0
  78. package/test/integration.test.mjs +253 -0
  79. package/test/panel.test.mjs +73 -0
  80. package/tsconfig.json +18 -0
  81. package/vendor/gugle-chat-bot/LICENSE +661 -0
  82. package/vendor/gugle-chat-bot/package.json +57 -0
  83. package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -0
  84. package/vendor/gugle-chat-bot/src/api.json +4285 -0
  85. package/vendor/gugle-chat-bot/src/command/index.ts +141 -0
  86. package/vendor/gugle-chat-bot/src/config/features.ts +548 -0
  87. package/vendor/gugle-chat-bot/src/config/index.ts +184 -0
  88. package/vendor/gugle-chat-bot/src/config/manager.ts +357 -0
  89. package/vendor/gugle-chat-bot/src/constants/index.ts +13 -0
  90. package/vendor/gugle-chat-bot/src/custom.ts +548 -0
  91. package/vendor/gugle-chat-bot/src/event/index.ts +118 -0
  92. package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -0
  93. package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -0
  94. package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -0
  95. package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -0
  96. package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -0
  97. package/vendor/gugle-chat-bot/src/features/bili/template/video.html +57 -0
  98. package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -0
  99. package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -0
  100. package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -0
  101. package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -0
  102. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -0
  103. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -0
  104. package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -0
  105. package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -0
  106. package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -0
  107. package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -0
  108. package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -0
  109. package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -0
  110. package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -0
  111. package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -0
  112. package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -0
  113. package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -0
  114. package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -0
  115. package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +469 -0
  116. package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -0
  117. package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -0
  118. package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -0
  119. package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -0
  120. package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -0
  121. package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -0
  122. package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -0
  123. package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -0
  124. package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -0
  125. package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -0
  126. package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -0
  127. package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -0
  128. package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -0
  129. package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -0
  130. package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -0
  131. package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -0
  132. package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -0
  133. package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -0
  134. package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -0
  135. package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -0
  136. package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -0
  137. package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +452 -0
  138. package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -0
  139. package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +78 -0
  140. package/vendor/gugle-chat-bot/src/features/welcome/index.ts +58 -0
  141. package/vendor/gugle-chat-bot/src/github-ip.json +5814 -0
  142. package/vendor/gugle-chat-bot/src/image/index.ts +278 -0
  143. package/vendor/gugle-chat-bot/src/index.ts +695 -0
  144. package/vendor/gugle-chat-bot/src/logger/index.ts +54 -0
  145. package/vendor/gugle-chat-bot/src/result.json +359 -0
  146. package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -0
  147. package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -0
  148. package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -0
  149. package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -0
  150. package/vendor/gugle-chat-bot/tsconfig.json +46 -0
@@ -0,0 +1,1533 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Message as DiscordMessage } from 'discord.js';
4
+ import { QQBot } from '@/index';
5
+ import { GroupMessageWSMSG, PrivateMessageWSMSG, ReceiveMessage, SentMessage } from '@/type';
6
+ import { getHermesConfig, isHermesAdmin } from '@/features/hermes/config';
7
+ import { HermesClient } from '@/features/hermes/client';
8
+ import { SkillManager } from '@/features/hermes/skills';
9
+ import { ChatHistoryStore } from '@/features/hermes/history';
10
+ import { buildConversationHistory, buildGroupContext, appendAutoCardNote } from '@/features/hermes/context';
11
+ import { MessageResolvers, parseMessage } from '@/features/hermes/message';
12
+ import { ReplyUnit, ReplyUnitOptions, splitReplyIntoUnits, takeCompleteUnits } from '@/features/hermes/reply-units';
13
+ import { ReactionManager } from '@/features/hermes/reaction';
14
+ import { Github } from '@/features/github';
15
+ import { isGithubEnabledGroup } from '@/config/features';
16
+ import { renderApprovalImage, renderProgressImage } from '@/features/hermes/render';
17
+ import {
18
+ decideGroupTrigger,
19
+ isResetCommand,
20
+ isStopCommand,
21
+ parseApprovalChoice,
22
+ splitMessageText
23
+ } from '@/features/hermes/trigger';
24
+ import {
25
+ Approval,
26
+ ApprovalChoice,
27
+ DiscordChatParams,
28
+ FormattedMessage,
29
+ GroupAdminApi,
30
+ HermesApprovalEvent,
31
+ MessageContentPart,
32
+ OneBotGetForwardMsgResponse,
33
+ OneBotGetMsgResponse,
34
+ OneBotGroupMemberInfo,
35
+ RouteInfo,
36
+ RunState,
37
+ Session
38
+ } from '@/features/hermes/types';
39
+
40
+ /**
41
+ * QQ ⇄ Hermes Agent 桥接(移植自 qq-hermes-bridge src/index.ts,MIT 协议,原作者 Amorter:
42
+ * https://github.com/Amorter/qq-hermes-bridge)。
43
+ *
44
+ * 与上游的差异:
45
+ * - OneBot 连接/事件/发送全部复用本仓库 QQBot(不再自建 WebSocket 客户端);
46
+ * - 配置改为 configs/features/hermes.json(ConfigStore 热重载),SOUL.md 读 configs/SOUL.md;
47
+ * - 管理员 = hermes admins ∪ management operators;
48
+ * - 群聊命令守卫:/ 或 ! 开头的消息不触发(除非显式 @bot),避免与 gugle-command 双重响应;
49
+ * - 审批否定词(不允许/不批准)匹配顺序修正(上游先匹配「批准」导致否定词不可达);
50
+ * - 不实现 COMPACT_LINES 合并转发(上游默认关闭),长回复按长度切分发送;
51
+ * - QQ 群聊的 AI 回复改为「逐句发送」:流式增量里已完整的句子立即作为独立消息发出,
52
+ * 单条超过 compactMaxChars 字或 compactMaxLines 行时改用聊天记录(合并转发)发送;
53
+ * 引用只贴在本轮首条单元上;QQ 私聊与 Discord 维持原有整段切分行为;
54
+ * - 触发消息的引用/聊天记录按深度解析(depth ≤ 1 转发图片原图、depth 2 仅文字、
55
+ * depth ≥ 3 记作 [聊天记录]),引用与聊天记录内的图片都会作为多模态输入转发给 AI
56
+ * (解析逻辑见 message.ts;get_msg 只回 file 时兜底调 NapCat get_image 换直链);
57
+ * - 多人共享历史在提交时按当前对话者标注归属([群聊背景] / [你回复 xxx 的话]),
58
+ * 系统提示显式声明当前对话者与标注约定,避免串台认错人(context.ts);
59
+ * - 技能系统在群管理技能之外扩展卡片类公共技能(查Issue / B站视频),技能可产出
60
+ * base64 图片,由完成处理器逐张发送(复用 github / bili 功能的渲染管线);
61
+ * - 查询类技能(MC版本 / MC服务器 / MC维基 / Modrinth版本,feedback 标记)的执行结果
62
+ * 不直接发摘要,而是作为 [工具结果] 输入再提交一轮 run 由 AI 整理回答(最多回喂
63
+ * MAX_FEEDBACK_DEPTH 轮防循环,达上限后数据附在回复里兜底);
64
+ * - 触发 AI 时给触发消息贴「处理中」表情回应(QQ 贴表情 / Discord reaction,
65
+ * reaction.ts),完整回复完成(含回喂轮)后摘除;记录立即持久化到
66
+ * data/hermes-reactions.json,进程在回应期间退出时下次启动自动清理残留;
67
+ * QQ 私聊消息 NapCat 通常不支持表情回应,贴失败时退化为一条文字回执
68
+ * (privateReceiptText),AI 首条回复发出后撤回,让用户确认「AI 收到了」;
69
+ * - AI 的群回复经 bot.sendGroupMsg 发出,会按既定行为同步转发到互通的 Discord 频道。
70
+ *
71
+ * Discord 频道触发(对上游的扩展):
72
+ * - 互通的 Discord 频道消息同样可触发 AI(@提及/关键词/requireMention 同一套规则),
73
+ * 频道是否启用由其桥接的 QQ 群是否在 hermes.json groups 中决定;
74
+ * - Discord 与 QQ 群共享同一份会话上下文(group:{群号}),AI 能看到两侧的对话;
75
+ * - AI 回复发在提问侧(Discord 提问回 Discord,QQ 提问回 QQ),发送经 DiscordBridge
76
+ * 注入的委托完成,本模块不持有 discord.js Client;
77
+ * - Discord 侧管理员(服务器拥有者/管理服务器/管理员权限)可审批与调用管理技能;
78
+ * QQ 用户黑白名单(allowedUsers/blockedUsers)不约束 Discord 用户。
79
+ */
80
+
81
+ /** 图片扩展名 → MIME 类型映射(本地图片转 base64 data URL 用) */
82
+ const IMAGE_MIME_BY_EXT: Record<string, string> = {
83
+ jpg: 'image/jpeg',
84
+ jpeg: 'image/jpeg',
85
+ png: 'image/png',
86
+ gif: 'image/gif',
87
+ webp: 'image/webp',
88
+ bmp: 'image/bmp'
89
+ };
90
+
91
+ /** 从路径/URL 扩展名推断 MIME,未知默认 image/jpeg */
92
+ function mimeFromPath(filePath: string): string {
93
+ const ext = filePath.split('.').pop()?.split(/[?#]/)[0]?.toLowerCase() || '';
94
+ return IMAGE_MIME_BY_EXT[ext] || 'image/jpeg';
95
+ }
96
+
97
+ /** 保留标签:命中即不得经流式/工具边界下发,统一留给 finishRun 处理 */
98
+ const RESERVED_LABEL_RE = /\[SKILL:|MEDIA:/;
99
+
100
+ export class HermesBridge {
101
+ private static instance?: HermesBridge;
102
+
103
+ public static getInstance(): HermesBridge {
104
+ if (!HermesBridge.instance) HermesBridge.instance = new HermesBridge();
105
+ return HermesBridge.instance;
106
+ }
107
+
108
+ private bot?: QQBot;
109
+ private readonly hermes = new HermesClient();
110
+ private readonly skillManager = new SkillManager();
111
+ /** 「处理中」表情回应(触发时贴上、完整回复后摘除、残留随重启清理) */
112
+ private readonly reactions = ReactionManager.getInstance();
113
+
114
+ /** 查询技能结果回喂的最大轮数(防止 AI 反复调用查询技能死循环) */
115
+ private static readonly MAX_FEEDBACK_DEPTH = 2;
116
+
117
+ /** 对话会话:sessionKey → Session */
118
+ private readonly sessions = new Map<string, Session>();
119
+ /** 活跃运行:runId → RunState */
120
+ private readonly activeRuns = new Map<string, RunState>();
121
+ /** 待审批记录:runId → Approval */
122
+ private readonly pendingApprovals = new Map<string, Approval>();
123
+ /** 已发送审批消息跟踪:runId → true(防止重复发送) */
124
+ private readonly approvalMessageSent = new Map<string, boolean>();
125
+ /** 群成员昵称缓存:groupId:userId → 群名片或昵称 */
126
+ private readonly memberCache = new Map<string, string>();
127
+
128
+ /** 历史存储(随持久化配置变更重建) */
129
+ private historyStore?: ChatHistoryStore;
130
+ private historyStoreKey = '';
131
+
132
+ private constructor() {}
133
+
134
+ // ===================================================================
135
+ // 事件入口
136
+ // ===================================================================
137
+
138
+ /** message-event-group 钩子 */
139
+ public handleGroupMessage(bot: QQBot, msg: GroupMessageWSMSG): void {
140
+ this.handleMessageSafe(bot, msg).catch(error => {
141
+ bot.logger?.error(`[Hermes] 群消息处理失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
142
+ });
143
+ }
144
+
145
+ /** message-event-private 钩子 */
146
+ public handlePrivateMessage(bot: QQBot, msg: PrivateMessageWSMSG): void {
147
+ this.handleMessageSafe(bot, msg).catch(error => {
148
+ bot.logger?.error(`[Hermes] 私聊消息处理失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
149
+ });
150
+ }
151
+
152
+ /** Discord 频道消息钩子(由 DiscordBridge 调用,发送经注入的委托完成) */
153
+ public handleDiscordMessage(bot: QQBot, message: DiscordMessage, params: DiscordChatParams): void {
154
+ this.handleDiscordMessageSafe(bot, message, params).catch(error => {
155
+ bot.logger?.error(
156
+ `[Hermes] Discord 消息处理失败: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`
157
+ );
158
+ });
159
+ }
160
+
161
+ // ===================================================================
162
+ // 访问控制与触发
163
+ // ===================================================================
164
+
165
+ /** 检查用户是否有权与 Bot 对话 */
166
+ private canChat(route: RouteInfo): boolean {
167
+ const config = getHermesConfig();
168
+ if (route.type === 'discord') {
169
+ // Discord 频道由其桥接的 QQ 群决定是否启用;QQ 用户黑白名单不约束 Discord 用户
170
+ return config.groups.includes(Number(route.groupId));
171
+ }
172
+ const uid = Number(route.userId);
173
+ if (config.blockedUsers.includes(uid)) return false;
174
+ if (config.allowedUsers.length > 0 && !config.allowedUsers.includes(uid) && !isHermesAdmin(uid)) return false;
175
+ if (route.type === 'group' && !config.groups.includes(Number(route.groupId))) return false;
176
+ return true;
177
+ }
178
+
179
+ /** 路由是否指向同一会话域(QQ 群与互通的 Discord 频道按桥接群号对齐) */
180
+ private routeMatches(a: RouteInfo, b: RouteInfo): boolean {
181
+ if (a.type === 'user' || b.type === 'user') return a.type === b.type && a.userId === b.userId;
182
+ return a.groupId !== undefined && a.groupId === b.groupId;
183
+ }
184
+
185
+ /** 路由发送者是否为管理员(QQ 侧取 hermes admins ∪ operators,Discord 侧取服务器权限) */
186
+ private isRouteAdmin(route: RouteInfo): boolean {
187
+ if (route.type === 'discord') return route.discord?.isAdmin === true;
188
+ return isHermesAdmin(Number(route.userId));
189
+ }
190
+
191
+ /** 检查是否 @了 Bot */
192
+ private hasAtSelf(message: ReceiveMessage): boolean {
193
+ if (!Array.isArray(message)) return false;
194
+ const selfId = this.bot?.getLoginInfoSync()?.user_id;
195
+ if (selfId === undefined) return false;
196
+ return message.some(segment => segment.type === 'at' && String(segment.data.qq) === String(selfId));
197
+ }
198
+
199
+ /** 提取消息中的纯文本 */
200
+ private extractText(message: ReceiveMessage | string): string {
201
+ if (typeof message === 'string') return message;
202
+ if (!Array.isArray(message)) return '';
203
+ return message
204
+ .filter(segment => segment.type === 'text')
205
+ .map(segment => (segment.type === 'text' ? segment.data.text : ''))
206
+ .join('')
207
+ .trim();
208
+ }
209
+
210
+ /**
211
+ * 统计消息中会被消息管道自动渲染卡片的 Issue/PR 引用数
212
+ * (该群启用 github 功能且仓库在允许列表内;私聊无自动卡片,返回 0)。
213
+ * 与 Github.processMessage / processDiscordMessage 的自动发卡口径一致。
214
+ */
215
+ private countAutoCardReferences(text: string, route: RouteInfo): number {
216
+ if (route.type === 'user' || !route.groupId) return 0;
217
+ const groupId = Number(route.groupId);
218
+ if (!Number.isSafeInteger(groupId) || !isGithubEnabledGroup(groupId)) return 0;
219
+ return Github.parseReferences(text).filter(reference => Github.isAllowedRepository(reference.repository)).length;
220
+ }
221
+
222
+ /** 发送者展示名:群名片(昵称)或昵称或 QQ 号 */
223
+ private senderLabel(sender: { card?: string; nickname?: string }, userId: string): string {
224
+ const card = sender.card?.trim();
225
+ const nick = sender.nickname?.trim();
226
+ const name = card && nick && card !== nick ? `${card}(${nick})` : card || nick || userId;
227
+ return `${name} (${userId})`;
228
+ }
229
+
230
+ // ===================================================================
231
+ // 会话管理
232
+ // ===================================================================
233
+
234
+ private get store(): ChatHistoryStore {
235
+ const config = getHermesConfig();
236
+ const key = `${config.persistHistoryMax}:${config.persistHistoryEnabled}`;
237
+ if (!this.historyStore || this.historyStoreKey !== key) {
238
+ this.historyStore = new ChatHistoryStore(config.persistHistoryMax, config.persistHistoryEnabled);
239
+ this.historyStoreKey = key;
240
+ }
241
+ return this.historyStore;
242
+ }
243
+
244
+ /** 获取会话键(QQ 群与互通的 Discord 频道按群共享,私聊按人隔离) */
245
+ private getSessionKey(route: RouteInfo): string {
246
+ return route.type === 'user' ? `user:${route.userId}` : `group:${route.groupId}`;
247
+ }
248
+
249
+ /** 获取或创建会话(优先从持久化存储恢复) */
250
+ private getSession(key: string): Session {
251
+ if (!this.sessions.has(key)) {
252
+ this.sessions.set(key, { history: this.store.load(key), sessionVersion: 0 });
253
+ }
254
+ return this.sessions.get(key)!;
255
+ }
256
+
257
+ /** 清除会话上下文(内存 + 持久化) */
258
+ private clearSession(route: RouteInfo): number {
259
+ const baseKey = this.getSessionKey(route);
260
+ const session = this.sessions.get(baseKey);
261
+ if (session) {
262
+ session.sessionVersion = (session.sessionVersion || 0) + 1;
263
+ session.history = [];
264
+ }
265
+ this.store.clear(baseKey);
266
+ return session?.sessionVersion || 0;
267
+ }
268
+
269
+ /** 追加历史消息(内存 + 持久化),跳过空内容;label 用于 assistant 消息标注回复对象 */
270
+ private appendHistory(key: string, role: string, content: string, userId?: string, label?: string): void {
271
+ if (!content.trim()) return;
272
+ const config = getHermesConfig();
273
+ const session = this.getSession(key);
274
+ session.history.push({
275
+ role,
276
+ content,
277
+ ...(userId !== undefined ? { userId } : {}),
278
+ ...(label !== undefined ? { label } : {})
279
+ });
280
+ const max = config.localHistoryMaxMessages * 2;
281
+ if (session.history.length > max) {
282
+ session.history = session.history.slice(-max);
283
+ }
284
+ this.store.save(key, session.history);
285
+ }
286
+
287
+ // ===================================================================
288
+ // OneBot API(QQBot 未封装的少量接口,经 HTTP API 直接调用)
289
+ // ===================================================================
290
+
291
+ private async callApi<T>(action: string, params: Record<string, unknown>): Promise<T> {
292
+ if (!this.bot) throw new Error('bot 未就绪');
293
+ const res = await this.bot.axiosInstance.post(`/${action}`, params);
294
+ const payload = res.data as { retcode?: number; msg?: string; wording?: string; data?: T };
295
+ if (payload?.retcode !== 0) {
296
+ throw new Error(`API 错误 ${payload?.retcode}: ${payload?.msg || payload?.wording || ''}`);
297
+ }
298
+ return payload?.data as T;
299
+ }
300
+
301
+ /** 技能执行用的群管理 API 门面 */
302
+ private groupAdminApi(): GroupAdminApi {
303
+ return {
304
+ setGroupBan: (groupId, userId, durationSec) =>
305
+ this.callApi('set_group_ban', { group_id: Number(groupId), user_id: Number(userId), duration: durationSec }),
306
+ setGroupKick: (groupId, userId) =>
307
+ this.callApi('set_group_kick', { group_id: Number(groupId), user_id: Number(userId), reject_add_request: false }),
308
+ setGroupWholeBan: (groupId, enable) =>
309
+ this.callApi('set_group_whole_ban', { group_id: Number(groupId), enable })
310
+ };
311
+ }
312
+
313
+ /** 解析群成员名称(群名片 → QQ 昵称 → QQ 号),带缓存 */
314
+ private async resolveMemberName(groupId: string | number, userId: string | number): Promise<string | null> {
315
+ const key = `${groupId}:${userId}`;
316
+ if (this.memberCache.has(key)) return this.memberCache.get(key)!;
317
+ try {
318
+ const info = await this.callApi<OneBotGroupMemberInfo>('get_group_member_info', {
319
+ group_id: Number(groupId),
320
+ user_id: Number(userId)
321
+ });
322
+ const name = info.card || info.nickname || String(userId);
323
+ this.memberCache.set(key, name);
324
+ return name;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ // ===================================================================
331
+ // 消息发送
332
+ // ===================================================================
333
+
334
+ /** 发送文本回复(超长按 maxMessageLength 切分;discord 路由经委托发送) */
335
+ private async sendReply(route: RouteInfo, text: string): Promise<void> {
336
+ if (route.type === 'discord') {
337
+ await route.discord?.send(text, false);
338
+ return;
339
+ }
340
+ if (!this.bot) return;
341
+ const chunks = splitMessageText(text, getHermesConfig().maxMessageLength);
342
+ for (const chunk of chunks) {
343
+ if (route.type === 'group') {
344
+ await this.bot.sendGroupMsg(route.groupId!, [{ type: 'text', data: { text: chunk } }]);
345
+ } else {
346
+ await this.bot.sendPrivateMsg(route.userId, [{ type: 'text', data: { text: chunk } }]);
347
+ }
348
+ }
349
+ }
350
+
351
+ /** 发送带引用的文本回复(群聊回复原消息,私聊退化为普通发送;discord 路由引用原消息) */
352
+ private async sendReplyWithMention(route: RouteInfo, text: string, userMsgId: number): Promise<void> {
353
+ if (route.type === 'discord') {
354
+ await route.discord?.send(text, true);
355
+ return;
356
+ }
357
+ if (!this.bot) return;
358
+ if (route.type === 'group' && userMsgId) {
359
+ const chunks = splitMessageText(text, getHermesConfig().maxMessageLength);
360
+ for (const chunk of chunks) {
361
+ await this.bot.sendGroupMsg(route.groupId!, [
362
+ { type: 'reply', data: { id: userMsgId } },
363
+ { type: 'text', data: { text: chunk } }
364
+ ]);
365
+ }
366
+ return;
367
+ }
368
+ await this.sendReply(route, text);
369
+ }
370
+
371
+ /** 发送图片(base64,允许已带 base64:// 前缀;discord 路由以附件发送) */
372
+ private async sendReplyImage(route: RouteInfo, imageData: string): Promise<void> {
373
+ if (route.type === 'discord') {
374
+ const base64 = imageData.startsWith('base64://') ? imageData.slice('base64://'.length) : imageData;
375
+ await route.discord?.sendImage(base64);
376
+ return;
377
+ }
378
+ if (!this.bot) return;
379
+ const file = imageData.startsWith('base64://') ? imageData : `base64://${imageData}`;
380
+ try {
381
+ if (route.type === 'group') {
382
+ await this.bot.sendGroupMsg(route.groupId!, [{ type: 'image', data: { file } }]);
383
+ } else {
384
+ await this.bot.sendPrivateMsg(route.userId, [{ type: 'image', data: { file } }]);
385
+ }
386
+ } catch (error) {
387
+ this.bot.logger?.error(`[Hermes] 图片发送失败: ${(error as Error).message},回退到文字`);
388
+ }
389
+ }
390
+
391
+ // ===================================================================
392
+ // QQ 群聊 AI 回复发送(逐句 + 超长转聊天记录)
393
+ // ===================================================================
394
+
395
+ /** 分句与合并转发阈值(每次发送时读取配置,热重载生效) */
396
+ private replyUnitOptions(): ReplyUnitOptions {
397
+ const config = getHermesConfig();
398
+ return {
399
+ maxUnitLength: config.maxMessageLength,
400
+ compactMaxChars: config.compactMaxChars,
401
+ compactMaxLines: config.compactMaxLines
402
+ };
403
+ }
404
+
405
+ /** 群聊 AI 文本是否走「逐句 + 聊天记录」新路径(私聊与 Discord 维持现状) */
406
+ private useReplyUnits(route: RouteInfo): boolean {
407
+ return route.type === 'group' && getHermesConfig().replyPerSentence;
408
+ }
409
+
410
+ /**
411
+ * 以聊天记录(合并转发)形式发送一段群聊文本。
412
+ * 单节点署名取 bot 昵称与 QQ 号;机器人信息缺失或发送失败时回退普通文本(不丢内容)。
413
+ */
414
+ private async sendGroupForward(route: RouteInfo, text: string): Promise<void> {
415
+ const bot = this.bot;
416
+ const login = bot?.getLoginInfoSync();
417
+ if (!bot || route.type !== 'group' || !login?.user_id) {
418
+ await this.sendReply(route, text);
419
+ return;
420
+ }
421
+ const messageId = await bot.sendGroupForwardMsg(route.groupId!, [
422
+ { name: getHermesConfig().botName, uin: login.user_id, content: text }
423
+ ]);
424
+ if (messageId === undefined) {
425
+ bot.logger?.warn(`[Hermes] 聊天记录发送失败,回退为普通文本(${text.length} 字)`);
426
+ await this.sendReply(route, text);
427
+ }
428
+ }
429
+
430
+ /**
431
+ * 把一段发送任务挂到会话的串行链上,保证按调用顺序执行。
432
+ *
433
+ * message.delta 的逐句下发展开是 fire-and-forget(不能阻塞 SSE 事件流),
434
+ * 若各自并发 await 发送,后一次 flush 的句子会插到前一次未发完的句子之前,
435
+ * 群聊里就出现「一、尾句、二、三」这种错序。因此所有 AI 文本发送统一经此串行化。
436
+ * 返回的 Promise 在该段发送完成后 resolve(调用方可按需 await)。
437
+ */
438
+ private enqueueSend(run: RunState | undefined, task: () => Promise<void>): Promise<void> {
439
+ if (!run) return task();
440
+ const previous = run.sendChain ?? Promise.resolve();
441
+ // 前一段失败不影响后续发送:链上只传递完成信号
442
+ const next = previous.then(task, task);
443
+ run.sendChain = next.catch(() => {});
444
+ return next;
445
+ }
446
+
447
+ /** 等待串行链上全部发送完成(finishRun 收尾与测试用) */
448
+ private async drainSends(run: RunState): Promise<void> {
449
+ // 链尾可能仍在追加,循环等到稳定
450
+ let current = run.sendChain;
451
+ while (current) {
452
+ await current.catch(() => {});
453
+ if (current === run.sendChain) return;
454
+ current = run.sendChain;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * 发送一条群聊 AI 回复单元:超阈值走聊天记录,否则普通文本。
460
+ * 引用段仅贴在本轮首个单元上(阶梯式回复,避免每条都重复引用)。
461
+ */
462
+ private async sendReplyUnit(route: RouteInfo, unit: ReplyUnit, replyMsgId: number, run?: RunState): Promise<void> {
463
+ await this.sendReplyUnits(route, [unit], replyMsgId, run);
464
+ }
465
+
466
+ /**
467
+ * 批量发送回复单元:整批作为串行链上的**一个任务**执行。
468
+ *
469
+ * 必须是整批入链而不是逐条入链:message.delta 的 flush 是 fire-and-forget,
470
+ * 若逐条入链,下一批会在「上一批还没发完」时把自己的任务追加到链上,
471
+ * 于是上一批剩余的句子被排到下一批之后,群聊里出现
472
+ * 「一一得一 / 尾句 / 一二得二 / 一三得三」这种错序。
473
+ */
474
+ private async sendReplyUnits(
475
+ route: RouteInfo,
476
+ units: ReplyUnit[],
477
+ replyMsgId: number,
478
+ run?: RunState
479
+ ): Promise<void> {
480
+ if (units.length === 0) return;
481
+ await this.enqueueSend(run, async () => {
482
+ for (const unit of units) {
483
+ await this.sendSingleUnit(route, unit, replyMsgId, run);
484
+ }
485
+ });
486
+ }
487
+
488
+ /** 单条单元的实际发送(不做排队,仅供串行链内部调用) */
489
+ private async sendSingleUnit(
490
+ route: RouteInfo,
491
+ unit: ReplyUnit,
492
+ replyMsgId: number,
493
+ run?: RunState
494
+ ): Promise<void> {
495
+ if (unit.asForward) {
496
+ await this.sendGroupForward(route, unit.text);
497
+ if (run) await this.recallReceiptOnce(run);
498
+ return;
499
+ }
500
+ if (!this.bot) return;
501
+ const attachReply = route.type === 'group' && replyMsgId > 0 && !(run?.replySent ?? false);
502
+ const segments: SentMessage = attachReply
503
+ ? [
504
+ { type: 'reply', data: { id: replyMsgId } },
505
+ { type: 'text', data: { text: unit.text } }
506
+ ]
507
+ : [{ type: 'text', data: { text: unit.text } }];
508
+ if (route.type === 'group') {
509
+ await this.bot.sendGroupMsg(route.groupId!, segments);
510
+ } else {
511
+ await this.bot.sendPrivateMsg(route.userId, segments);
512
+ }
513
+ // 无论发送成功与否都置位:避免失败时每条单元都重复贴引用
514
+ if (run && attachReply) run.replySent = true;
515
+ // 私聊回执:首条真实回复已发出,撤回「收到」提示
516
+ if (run) await this.recallReceiptOnce(run);
517
+ }
518
+
519
+ /**
520
+ * 首条真实回复发出后撤回私聊回执:用户已经看到 AI 的内容,回执不必常驻。
521
+ * 只执行一次(撤回后清空 run.receiptId)。
522
+ */
523
+ private async recallReceiptOnce(run: RunState): Promise<void> {
524
+ if (run.receiptId === undefined) return;
525
+ const receiptId = run.receiptId;
526
+ // exactOptionalPropertyTypes 下不能显式赋 undefined,用 delete 清除
527
+ delete run.receiptId;
528
+ await this.recallReceipt(run.route, receiptId);
529
+ }
530
+
531
+ /**
532
+ * QQ 群聊 AI 文本出口:按句拆成多条消息逐条发出;单条超过 compactMaxChars 字或
533
+ * compactMaxLines 行时改用聊天记录发送。非群聊路由或 replyPerSentence=false 时退回
534
+ * sendReply / sendReplyWithMention 的原有整段发送行为。
535
+ *
536
+ * 命中 `[SKILL:` / `MEDIA:` 的文本不下发(留给 finishRun 统一处理)。
537
+ */
538
+ private async sendAiReplyUnits(route: RouteInfo, text: string, replyMsgId: number, run?: RunState): Promise<void> {
539
+ if (!text.trim()) return;
540
+ if (!this.useReplyUnits(route)) {
541
+ await this.sendReplyWithMention(route, text, replyMsgId);
542
+ return;
543
+ }
544
+ if (RESERVED_LABEL_RE.test(text)) return;
545
+ const units = splitReplyIntoUnits(text, this.replyUnitOptions());
546
+ // 整批入链:保证本批句子连续发出,不被后续 flush 插队
547
+ await this.sendReplyUnits(route, units, replyMsgId, run);
548
+ }
549
+
550
+ /**
551
+ * 流式逐句下发:把 pendingText 里已完整的单元立即发出,未成形的部分写回 pendingText。
552
+ * 逐句单元不贴引用(由 finishRun 的收尾文本负责贴),保证「引用 + 首句」不会拆成两条。
553
+ */
554
+ private async flushStreamUnits(run: RunState): Promise<void> {
555
+ if (!this.useReplyUnits(run.route)) return;
556
+ if (!run.pendingText) return;
557
+ // 已出现保留标签:整段留给 finishRun(技能标签/图片路径绝不能原样发到群里)
558
+ if (RESERVED_LABEL_RE.test(run.pendingText)) return;
559
+ const { units, rest } = takeCompleteUnits(run.pendingText, this.replyUnitOptions());
560
+ run.pendingText = rest;
561
+ if (units.length === 0) return;
562
+ run.sentTextLength = run.messageDelta.length;
563
+ run.lastTextSent = Date.now();
564
+ // 整批入链(内部按序发完),避免与后续 flush 交错
565
+ await this.sendReplyUnits(run.route, units, run.userMsgId, run);
566
+ }
567
+
568
+ // ===================================================================
569
+ // 图片处理(Hermes 多模态输入 / MEDIA 标签输出)
570
+ // ===================================================================
571
+
572
+ /** 下载远程图片并编码为 base64:// */
573
+ private async downloadImageToBase64(imageUrl: string): Promise<string> {
574
+ const resp = await fetch(imageUrl);
575
+ if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
576
+ return `base64://${Buffer.from(await resp.arrayBuffer()).toString('base64')}`;
577
+ }
578
+
579
+ /** 下载远程图片并转成 base64 data URL(Hermes 多模态输入用) */
580
+ private async downloadImageToDataUrl(imageUrl: string): Promise<string> {
581
+ const resp = await fetch(imageUrl);
582
+ if (!resp.ok) throw new Error(`下载失败: ${resp.status}`);
583
+ const buffer = Buffer.from(await resp.arrayBuffer());
584
+ const contentType = (resp.headers.get('content-type') || '').split(';')[0]!.trim();
585
+ const mime = /^image\//i.test(contentType) ? contentType : mimeFromPath(imageUrl);
586
+ return `data:${mime};base64,${buffer.toString('base64')}`;
587
+ }
588
+
589
+ /**
590
+ * 解析图片段为 base64 data URL,全部转为 base64 后传给 Hermes。
591
+ * 优先下载 CDN url / http(s) file,其次读本地文件,最后兜底问一次 NapCat get_image
592
+ * (get_msg 返回的图片段常只有 file 没有 url,例如被引用消息里的图片)。
593
+ * 失败时返回 null(仅保留 [图片] 占位符),不抛错中断消息处理。
594
+ */
595
+ private async resolveImageDataUrl(segment: { data: { file: string; url?: string } }): Promise<string | null> {
596
+ const { url, file } = segment.data || {};
597
+ const remote = url || (file && /^https?:\/\//i.test(file) ? file : '');
598
+ if (remote) {
599
+ try {
600
+ return await this.downloadImageToDataUrl(remote);
601
+ } catch (error) {
602
+ this.bot?.logger?.error(`[Hermes] 图片下载失败 ${remote}: ${(error as Error).message}`);
603
+ }
604
+ }
605
+ if (file && !/^https?:\/\//i.test(file)) {
606
+ try {
607
+ const buffer = fs.readFileSync(file);
608
+ return `data:${mimeFromPath(file)};base64,${buffer.toString('base64')}`;
609
+ } catch (error) {
610
+ this.bot?.logger?.error(`[Hermes] 图片本地文件读取失败 ${file}: ${(error as Error).message}`);
611
+ }
612
+ }
613
+ return await this.resolveImageViaApi(file);
614
+ }
615
+
616
+ /**
617
+ * 兜底:请 NapCat 换取图片直链(get_image),再按直链下载为 data URL。
618
+ * 引用消息 / 聊天记录里的图片段常常只有 file 字段,这一步能把原图拿回来给 AI 识别。
619
+ */
620
+ private async resolveImageViaApi(file: string | undefined): Promise<string | null> {
621
+ if (!file) return null;
622
+ try {
623
+ const result = await this.callApi<{ url?: string; file?: string }>('get_image', { file });
624
+ const target = result?.url || result?.file;
625
+ if (!target) return null;
626
+ if (/^https?:\/\//i.test(target)) return await this.downloadImageToDataUrl(target);
627
+ const buffer = fs.readFileSync(target);
628
+ return `data:${mimeFromPath(target)};base64,${buffer.toString('base64')}`;
629
+ } catch (error) {
630
+ this.bot?.logger?.warn(`[Hermes] get_image 兜底失败 ${file}: ${(error as Error).message}`);
631
+ return null;
632
+ }
633
+ }
634
+
635
+ /**
636
+ * 组装消息解析用的 resolver(IO 由本类提供,深度/占位/去重逻辑在 message.ts 中)。
637
+ * 解析失败一律降级为 null / 抛错由 message.ts 转成占位符,不阻塞触发流程。
638
+ */
639
+ private messageResolvers(groupId: number | undefined): MessageResolvers {
640
+ return {
641
+ getMsg: messageId =>
642
+ this.callApi<OneBotGetMsgResponse>('get_msg', { message_id: messageId }).then(original => ({
643
+ sender: original?.sender,
644
+ time: original?.time,
645
+ message: original?.message
646
+ })),
647
+ getForward: forwardId => this.callApi<OneBotGetForwardMsgResponse>('get_forward_msg', { message_id: forwardId }),
648
+ resolveImage: segment => this.resolveImageDataUrl(segment),
649
+ resolveName: qq => (groupId === undefined ? Promise.resolve(null) : this.resolveMemberName(groupId, qq)),
650
+ formatTime: unixTs => this.formatTime(unixTs),
651
+ forwardImages: getHermesConfig().forwardImages
652
+ };
653
+ }
654
+
655
+ // ===================================================================
656
+ // 进度跟踪
657
+ // ===================================================================
658
+
659
+ /** 格式化毫秒为可读时长 */
660
+ private formatElapsed(ms: number): string {
661
+ if (ms < 1000) return `${ms}ms`;
662
+ if (ms < 60000) return `${(ms / 1000).toFixed(0)}s`;
663
+ return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`;
664
+ }
665
+
666
+ /** 检查是否应发送进度更新 */
667
+ private shouldSendProgress(run: RunState): boolean {
668
+ if (run.sendingProgress) return false;
669
+ const elapsed = (Date.now() - run.lastProgressSent) / 1000;
670
+ return elapsed >= getHermesConfig().progressRateLimitSec;
671
+ }
672
+
673
+ /** 发送进度卡片(渲染失败降级纯文本) */
674
+ private async sendProgressCard(runId: string): Promise<void> {
675
+ const run = this.activeRuns.get(runId);
676
+ if (!run || run.sendingProgress) return;
677
+ run.sendingProgress = true;
678
+
679
+ const now = Date.now();
680
+ const elapsed = this.formatElapsed(now - run.startedAt);
681
+
682
+ try {
683
+ const image = await renderProgressImage({
684
+ tools: run.tools,
685
+ currentTool: run.currentTool,
686
+ // 逐句下发后 pendingText 只剩未成形的尾巴,进度预览用完整增量更贴近实际输出
687
+ messageDelta: run.messageDelta,
688
+ elapsed
689
+ });
690
+ if (image) {
691
+ await this.sendReplyImage(run.route, image);
692
+ run.lastProgressSent = now;
693
+ return;
694
+ }
695
+
696
+ // 文字回退
697
+ const lines: string[] = [`⏳ Hermes 执行中 (${elapsed})`];
698
+ for (const tool of run.tools.slice(-8)) {
699
+ const icon = tool.error ? '❌' : '✅';
700
+ const duration = tool.duration ? ` (${this.formatElapsed(tool.duration)})` : '';
701
+ const preview = tool.preview ? ` → ${tool.preview.slice(0, 80)}` : '';
702
+ lines.push(`${icon} ${tool.name}${duration}${preview}`);
703
+ }
704
+ if (run.currentTool) {
705
+ const preview = run.currentTool.preview ? ` → ${run.currentTool.preview.slice(0, 80)}` : '';
706
+ lines.push(`⏳ ${run.currentTool.name}...${preview}`);
707
+ }
708
+ await this.sendReply(run.route, lines.join('\n'));
709
+ run.lastProgressSent = now;
710
+ } finally {
711
+ run.sendingProgress = false;
712
+ }
713
+ }
714
+
715
+ // ===================================================================
716
+ // 消息格式化(富文本 → AI 可理解的文本 + 图片)
717
+ // ===================================================================
718
+
719
+ /** 格式化 Unix 时间戳 */
720
+ private formatTime(unixTs: number): string {
721
+ const date = new Date(unixTs * 1000);
722
+ const pad = (value: number) => String(value).padStart(2, '0');
723
+ return `${date.getFullYear()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
724
+ }
725
+
726
+ /**
727
+ * 将 OneBot 消息转换为 AI 可理解的富文本格式(委托 message.ts 的深度解析)。
728
+ * 深度规则:引用/聊天记录逐层展开(depth+1),depth ≤ 1 的图片转发原图给 Hermes,
729
+ * depth 2 只解析文字,depth ≥ 3 一律记作 [聊天记录];详见 message.ts 文件头深度表。
730
+ */
731
+ private async formatMessage(
732
+ message: ReceiveMessage | string,
733
+ groupId: number | undefined
734
+ ): Promise<FormattedMessage> {
735
+ const parsed = await parseMessage(message, groupId, this.messageResolvers(groupId));
736
+ return { text: parsed.text, images: parsed.images };
737
+ }
738
+
739
+ // ===================================================================
740
+ // 消息处理主入口
741
+ // ===================================================================
742
+
743
+ private async handleMessageSafe(bot: QQBot, msg: GroupMessageWSMSG | PrivateMessageWSMSG): Promise<void> {
744
+ // 防自环:NapCat 配置回报自身消息时直接忽略
745
+ if (msg.user_id === msg.self_id) return;
746
+ this.bot = bot;
747
+
748
+ const route: RouteInfo =
749
+ msg.message_type === 'group'
750
+ ? { type: 'group', groupId: String(msg.group_id), userId: String(msg.sender.user_id) }
751
+ : { type: 'user', userId: String(msg.sender.user_id) };
752
+
753
+ if (!this.canChat(route)) return;
754
+
755
+ const config = getHermesConfig();
756
+ const rawText = this.extractText(msg.message);
757
+
758
+ // 审批回复优先于触发判定(仅管理员的选择类文本会被拦截,避免误伤正常聊天)
759
+ if (rawText && (await this.handleApprovalReply(route, rawText, msg.message_id))) return;
760
+
761
+ // 触发判定:私聊始终触发;群聊按 @提及/关键词/requireMention(含命令守卫)
762
+ let triggered = true;
763
+ let reason = 'private';
764
+ if (route.type === 'group') {
765
+ const decision = decideGroupTrigger({
766
+ text: rawText,
767
+ mentioned: this.hasAtSelf(msg.message),
768
+ requireMention: config.requireMention,
769
+ keywordTriggers: config.keywordTriggers
770
+ });
771
+ triggered = decision.triggered;
772
+ reason = decision.reason ?? '';
773
+
774
+ if (!triggered) {
775
+ // 未触发但群聊消息仍需记录到背景上下文(供 AI 感知群聊氛围)
776
+ if (rawText) {
777
+ this.appendHistory(this.getSessionKey(route), 'user', `${this.senderLabel(msg.sender, route.userId)}: ${rawText}`, route.userId);
778
+ }
779
+ return;
780
+ }
781
+ }
782
+
783
+ bot.logger?.info(`[Hermes] 触发: ${reason} from ${route.userId} in ${route.type}:${route.groupId || route.userId}`);
784
+
785
+ const formatted = await this.formatMessage(msg.message, msg.message_type === 'group' ? msg.group_id : undefined);
786
+ // 消息中的 #编号 会被消息管道自动渲染卡片时加注记,避免 AI 再调查Issue技能重复发卡
787
+ const text = appendAutoCardNote(formatted.text, this.countAutoCardReferences(formatted.text, route));
788
+ if (!text) return;
789
+
790
+ // 停止命令
791
+ if (isStopCommand(text)) {
792
+ await this.handleStopCommand(route);
793
+ return;
794
+ }
795
+
796
+ // 清除上下文命令
797
+ if (isResetCommand(text)) {
798
+ const newVersion = this.clearSession(route);
799
+ await this.sendReplyWithMention(route, `✅ 上下文已清除,开始新对话 (v${newVersion})`, msg.message_id);
800
+ return;
801
+ }
802
+
803
+ // 贴上「处理中」表情回应(立即持久化,进程崩溃时由下次启动清理残留)。
804
+ // 私聊消息 NapCat 通常不支持表情回应:失败时退化为一条文字回执,
805
+ // 让用户知道 AI 已收到(reactionKey 为 null 表示没贴上)。
806
+ const reactionKey = await this.reactions.addQQ(bot, msg.message_id);
807
+ const receiptId =
808
+ reactionKey === null && route.type === 'user' ? await this.sendPrivateReceipt(route) : undefined;
809
+
810
+ await this.startHermesRun(
811
+ bot,
812
+ route,
813
+ text,
814
+ formatted.images,
815
+ this.senderLabel(msg.sender, route.userId),
816
+ msg.message_id,
817
+ 0,
818
+ reactionKey ?? undefined,
819
+ receiptId
820
+ );
821
+ }
822
+
823
+ /**
824
+ * 私聊文字回执:贴表情失败时发一条简短提示,让用户知道 AI 已收到。
825
+ * 返回回执消息 id(供 AI 回复时撤回;撤回失败则留在聊天里,不影响对话)。
826
+ */
827
+ private async sendPrivateReceipt(route: RouteInfo): Promise<number | undefined> {
828
+ const config = getHermesConfig();
829
+ if (!config.privateReactionFallbackText || !config.privateReceiptText.trim()) return undefined;
830
+ if (!this.bot || route.type !== 'user') return undefined;
831
+ try {
832
+ return await this.bot.sendPrivateMsg(route.userId, [
833
+ { type: 'text', data: { text: config.privateReceiptText } }
834
+ ]);
835
+ } catch (error) {
836
+ this.bot.logger?.warn(`[Hermes] 私聊文字回执发送失败: ${(error as Error).message}`);
837
+ return undefined;
838
+ }
839
+ }
840
+
841
+ /** 撤回私聊文字回执(AI 开始回复时清掉,避免回执常驻聊天;失败只记日志) */
842
+ private async recallReceipt(route: RouteInfo, receiptId: number | undefined): Promise<void> {
843
+ if (receiptId === undefined || !this.bot) return;
844
+ try {
845
+ await this.bot.axiosInstance.post('/delete_msg', { message_id: receiptId });
846
+ } catch (error) {
847
+ this.bot.logger?.debug(`[Hermes] 撤回私聊回执失败(${receiptId}): ${(error as Error).message}`);
848
+ }
849
+ }
850
+
851
+ // ===================================================================
852
+ // Discord 消息处理
853
+ // ===================================================================
854
+
855
+ /** Discord 消息处理主入口(与 QQ 共用触发/审批/会话逻辑) */
856
+ private async handleDiscordMessageSafe(bot: QQBot, message: DiscordMessage, params: DiscordChatParams): Promise<void> {
857
+ if (message.author.bot || message.webhookId) return;
858
+ this.bot = bot;
859
+
860
+ const route: RouteInfo = {
861
+ type: 'discord',
862
+ groupId: params.groupId,
863
+ userId: `dc:${message.author.id}`,
864
+ channelId: message.channelId,
865
+ discord: params
866
+ };
867
+ if (!this.canChat(route)) return;
868
+
869
+ const config = getHermesConfig();
870
+ const rawText = this.plainDiscordText(message, params.botUserId);
871
+
872
+ // 审批回复优先于触发判定(仅管理员的选择类文本会被拦截)
873
+ if (rawText && (await this.handleApprovalReply(route, rawText, 0))) return;
874
+
875
+ const mentioned = params.botUserId ? message.mentions.has(params.botUserId) : false;
876
+ const decision = decideGroupTrigger({
877
+ text: rawText,
878
+ mentioned,
879
+ requireMention: config.requireMention,
880
+ keywordTriggers: config.keywordTriggers
881
+ });
882
+
883
+ const displayName = message.member?.displayName ?? message.author.username;
884
+ const label = this.senderLabel({ nickname: displayName }, route.userId);
885
+
886
+ if (!decision.triggered) {
887
+ // 未触发的频道消息记录到共享会话的背景上下文(AI 可感知两侧对话)
888
+ if (rawText) {
889
+ this.appendHistory(this.getSessionKey(route), 'user', `${label}: ${rawText}`, route.userId);
890
+ }
891
+ return;
892
+ }
893
+
894
+ bot.logger?.info(
895
+ `[Hermes] 触发: ${decision.reason} from ${route.userId} in discord:${params.guildName}#${params.channelName}`
896
+ );
897
+
898
+ const formatted = await this.formatDiscordMessage(message, params.botUserId);
899
+ // 与 QQ 侧同理:#编号 已由 DiscordBridge 自动渲染卡片时加注记,避免技能重复发卡
900
+ const text = appendAutoCardNote(formatted.text, this.countAutoCardReferences(formatted.text, route));
901
+ if (!text) return;
902
+
903
+ if (isStopCommand(text)) {
904
+ await this.handleStopCommand(route);
905
+ return;
906
+ }
907
+ if (isResetCommand(text)) {
908
+ const newVersion = this.clearSession(route);
909
+ await this.sendReplyWithMention(route, `✅ 上下文已清除,开始新对话 (v${newVersion})`, 0);
910
+ return;
911
+ }
912
+
913
+ // 贴上「处理中」表情回应(立即持久化,摘除动作由委托完成)
914
+ let reactionKey: string | undefined;
915
+ if (config.reactionEnabled) {
916
+ try {
917
+ await message.react(config.reactionEmojiDiscord);
918
+ reactionKey = this.reactions.persistDiscord(message.channelId, message.id, config.reactionEmojiDiscord);
919
+ } catch (error) {
920
+ bot.logger?.warn(`[Hermes] Discord 表情回应失败: ${(error as Error).message}`);
921
+ }
922
+ }
923
+
924
+ await this.startHermesRun(bot, route, text, formatted.images, label, 0, 0, reactionKey);
925
+ }
926
+
927
+ /** Discord 消息正文纯文本化:提及/角色/频道/自定义表情 → 可读文本,并剔除对 bot 的提及 */
928
+ private plainDiscordText(message: DiscordMessage, botUserId?: string): string {
929
+ let text = message.content ?? '';
930
+ if (botUserId) text = text.replace(new RegExp(`<@!?${botUserId}>`, 'g'), '');
931
+ text = text.replace(/<@!?(\d+)>/g, (_match, id: string) => {
932
+ const name = message.mentions.members?.get(id)?.displayName ?? message.mentions.users.get(id)?.username;
933
+ return `@${name ?? id}(${id})`;
934
+ });
935
+ text = text.replace(/<@&(\d+)>/g, (_match, id: string) => {
936
+ return `@${message.guild?.roles.cache.get(id)?.name ?? id}`;
937
+ });
938
+ text = text.replace(/<#(\d+)>/g, (_match, id: string) => {
939
+ return `#${message.guild?.channels.cache.get(id)?.name ?? id}`;
940
+ });
941
+ text = text.replace(/<a?:(\w{2,32}):\d+>/g, ':$1:');
942
+ return text.trim();
943
+ }
944
+
945
+ /**
946
+ * 将 Discord 消息转换为 AI 可理解的富文本格式:
947
+ * 正文 → 可读文本;回复引用 → 引用块(含其中图片);图片附件 → [图片] + 原图
948
+ * (forwardImages 可关);其余附件/贴纸 → 占位符。
949
+ */
950
+ private async formatDiscordMessage(message: DiscordMessage, botUserId?: string): Promise<FormattedMessage> {
951
+ const config = getHermesConfig();
952
+ const parts: string[] = [];
953
+ const images: string[] = [];
954
+
955
+ const text = this.plainDiscordText(message, botUserId);
956
+ if (text) parts.push(text);
957
+
958
+ // 回复引用:拉取被引用消息,转为引用块(含其中图片)
959
+ const referenceId = message.reference?.messageId;
960
+ if (referenceId) {
961
+ try {
962
+ const original = await message.channel.messages.fetch(referenceId);
963
+ const name = original.member?.displayName ?? original.author.username;
964
+ const time = this.formatTime(Math.floor(original.createdTimestamp / 1000));
965
+ const content = this.plainDiscordText(original, botUserId) || '[非文本消息]';
966
+ parts.push(`> ${name}(dc:${original.author.id}) ${time}\n> ${content}\n`);
967
+ if (config.forwardImages) {
968
+ for (const attachment of original.attachments.values()) {
969
+ if (attachment.contentType?.startsWith('image/')) {
970
+ const dataUrl = await this.downloadImageToDataUrlSafe(attachment.url);
971
+ if (dataUrl) images.push(dataUrl);
972
+ }
973
+ }
974
+ }
975
+ } catch {
976
+ parts.push('> [引用消息获取失败]\n');
977
+ }
978
+ }
979
+
980
+ // 附件:图片转多模态输入,其余类型占位
981
+ for (const attachment of message.attachments.values()) {
982
+ if (attachment.contentType?.startsWith('image/')) {
983
+ parts.push('[图片]');
984
+ if (config.forwardImages) {
985
+ const dataUrl = await this.downloadImageToDataUrlSafe(attachment.url);
986
+ if (dataUrl) images.push(dataUrl);
987
+ }
988
+ } else {
989
+ parts.push(`[附件 ${attachment.name}]`);
990
+ }
991
+ }
992
+ if (message.stickers.size > 0) parts.push('[贴纸]');
993
+
994
+ return { text: parts.join('\n').trim(), images };
995
+ }
996
+
997
+ /** 下载图片为 data URL,失败返回 null(仅保留 [图片] 占位符) */
998
+ private async downloadImageToDataUrlSafe(url: string): Promise<string | null> {
999
+ try {
1000
+ return await this.downloadImageToDataUrl(url);
1001
+ } catch (error) {
1002
+ this.bot?.logger?.error(`[Hermes] Discord 图片下载失败 ${url}: ${(error as Error).message}`);
1003
+ return null;
1004
+ }
1005
+ }
1006
+
1007
+ // ===================================================================
1008
+ // Hermes 运行提交(QQ 与 Discord 共用)
1009
+ // ===================================================================
1010
+
1011
+ /**
1012
+ * 组装提示词、提交 Hermes 运行并挂接 SSE 事件流。
1013
+ * feedbackDepth > 0 表示这是查询技能的回喂轮:text 是工具数据而非用户发言,
1014
+ * 不带发言人前缀(历史中保持在当前对话主线上)。
1015
+ * reactionKey 为「处理中」表情回应的持久化记录键:首轮由触发处贴上后传入,
1016
+ * 回喂轮原样转移,对话链完全结束时由 handleRunComplete 摘除。
1017
+ */
1018
+ private async startHermesRun(
1019
+ bot: QQBot,
1020
+ route: RouteInfo,
1021
+ text: string,
1022
+ images: string[],
1023
+ senderLabel: string,
1024
+ replyMsgId: number,
1025
+ feedbackDepth: number = 0,
1026
+ reactionKey?: string,
1027
+ receiptId?: number
1028
+ ): Promise<void> {
1029
+ const config = getHermesConfig();
1030
+
1031
+ // 构建用户提示词(私聊与回喂轮不带发言人前缀)
1032
+ const isFeedback = feedbackDepth > 0;
1033
+ const userPrompt = isFeedback || route.type === 'user' ? text : `${senderLabel}: ${text}`;
1034
+ // 带图消息:组装 OpenAI 多模态内容段,将原图传给 Hermes
1035
+ const userMessage: string | MessageContentPart[] =
1036
+ images.length > 0
1037
+ ? [
1038
+ { type: 'text', text: userPrompt },
1039
+ ...images.map((url): MessageContentPart => ({ type: 'image_url', image_url: { url } }))
1040
+ ]
1041
+ : userPrompt;
1042
+
1043
+ const sessionKey = this.getSessionKey(route);
1044
+ const session = this.getSession(sessionKey);
1045
+ const sessionVersion = session.sessionVersion || 0;
1046
+ // Hermes sessionId 按人区分;群聊/频道日志仍按群共享
1047
+ const hermesSessionBase = route.type === 'user' ? `user_${route.userId}` : `group_${route.groupId}_user_${route.userId}`;
1048
+ const hermesSessionId = sessionVersion > 0 ? `${hermesSessionBase}:v${sessionVersion}` : hermesSessionBase;
1049
+
1050
+ // 回喂轮(工具数据)与私聊不带发言人前缀;工具数据标记 userId=当前对话者,
1051
+ // 在历史重组时保持在对话主线上(不会被标注为 [群聊背景])
1052
+ const historyContent = isFeedback || route.type === 'user' ? text : `${senderLabel}: ${text}`;
1053
+ this.appendHistory(sessionKey, 'user', historyContent, route.userId);
1054
+
1055
+ // 组装系统提示词:configs/SOUL.md > systemPrompt > 空 + 群聊上下文 + 技能列表。
1056
+ // 群聊上下文显式声明当前对话者与历史标注约定(配合 buildConversationHistory 防串台)
1057
+ const baseSystem = this.loadSoulPrompt() || config.systemPrompt || '';
1058
+ const groupContext = route.type === 'user' ? '' : buildGroupContext(route, senderLabel);
1059
+ const skillsPrompt = this.skillManager.buildPrompt();
1060
+ const systemPrompt = [baseSystem, groupContext, skillsPrompt].filter(Boolean).join('\n\n') || undefined;
1061
+
1062
+ try {
1063
+ const { runId } = await this.hermes.submitRun({
1064
+ userMessage,
1065
+ sessionId: hermesSessionId,
1066
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
1067
+ conversationHistory: buildConversationHistory(session.history.slice(0, -1), route.userId)
1068
+ });
1069
+
1070
+ bot.logger?.info(`[Hermes] run 已提交: ${runId}`);
1071
+
1072
+ const runState: RunState = {
1073
+ route,
1074
+ tools: [],
1075
+ currentTool: null,
1076
+ startedAt: Date.now(),
1077
+ lastProgressSent: 0,
1078
+ sendingProgress: false,
1079
+ messageDelta: '',
1080
+ pendingText: '',
1081
+ sentTextLength: 0,
1082
+ lastTextSent: 0,
1083
+ finalOutput: '',
1084
+ userMsgId: replyMsgId,
1085
+ senderLabel,
1086
+ feedbackDepth,
1087
+ ...(reactionKey !== undefined ? { reactionKey } : {}),
1088
+ ...(receiptId !== undefined ? { receiptId } : {})
1089
+ };
1090
+ this.activeRuns.set(runId, runState);
1091
+
1092
+ // 流式输出:工具调用前后强制下发中间文本;群聊另有逐句即时下发(见 message.delta)
1093
+ const flushPendingText = async () => {
1094
+ const pending = runState.pendingText;
1095
+ if (!pending.trim()) return;
1096
+ // 命中 [SKILL: / MEDIA: 的文本留在缓冲区,交给 finishRun 统一处理
1097
+ if (RESERVED_LABEL_RE.test(pending)) return;
1098
+ runState.pendingText = '';
1099
+ runState.sentTextLength = runState.messageDelta.length;
1100
+ runState.lastTextSent = Date.now();
1101
+ try {
1102
+ if (this.useReplyUnits(runState.route)) {
1103
+ // 整批入链,避免被随后到达的 delta flush 插队
1104
+ await this.sendReplyUnits(
1105
+ runState.route,
1106
+ splitReplyIntoUnits(pending, this.replyUnitOptions()),
1107
+ runState.userMsgId,
1108
+ runState
1109
+ );
1110
+ } else {
1111
+ await this.sendReply(runState.route, pending.trim());
1112
+ }
1113
+ } catch (error) {
1114
+ this.bot?.logger?.error(`[Hermes] 文本发送错误: ${(error as Error).message}`);
1115
+ }
1116
+ };
1117
+
1118
+ const stream = this.hermes.streamEvents(runId, {
1119
+ 'tool.started': ev => {
1120
+ void flushPendingText();
1121
+ runState.currentTool = {
1122
+ name: ev.tool,
1123
+ ...(ev.preview !== undefined ? { preview: ev.preview } : {}),
1124
+ startedAt: ev.timestamp * 1000
1125
+ };
1126
+ },
1127
+
1128
+ 'tool.completed': ev => {
1129
+ runState.tools.push({
1130
+ name: ev.tool,
1131
+ duration: (ev.duration || 0) * 1000,
1132
+ error: ev.error || false,
1133
+ ...(runState.currentTool?.preview !== undefined ? { preview: runState.currentTool.preview } : {})
1134
+ });
1135
+ runState.currentTool = null;
1136
+ void flushPendingText();
1137
+ },
1138
+
1139
+ 'message.delta': ev => {
1140
+ runState.messageDelta += ev.delta || '';
1141
+ runState.pendingText += ev.delta || '';
1142
+
1143
+ // 群聊逐句即时下发:完整句子立刻发出,未成形的部分留在 pendingText
1144
+ if (this.useReplyUnits(runState.route)) {
1145
+ void this.flushStreamUnits(runState).catch(error =>
1146
+ this.bot?.logger?.error(`[Hermes] 逐句发送错误: ${(error as Error).message}`)
1147
+ );
1148
+ }
1149
+
1150
+ if (this.shouldSendProgress(runState) && runState.tools.length > 0) {
1151
+ this.sendProgressCard(runId).catch(error =>
1152
+ this.bot?.logger?.error(`[Hermes] 进度发送错误: ${(error as Error).message}`)
1153
+ );
1154
+ }
1155
+ },
1156
+
1157
+ 'approval.request': ev => {
1158
+ this.handleApprovalRequest(runId, ev);
1159
+ },
1160
+
1161
+ 'run.completed': ev => {
1162
+ runState.finalOutput = ev.output || '';
1163
+ },
1164
+
1165
+ 'run.failed': ev => {
1166
+ runState.finalOutput = `❌ 执行失败: ${ev.error || '未知错误'}`;
1167
+ runState.failed = true;
1168
+ },
1169
+
1170
+ _end: () => {
1171
+ void this.handleRunComplete(runId);
1172
+ },
1173
+
1174
+ _error: () => {
1175
+ void this.handleRunComplete(runId);
1176
+ }
1177
+ });
1178
+
1179
+ runState.stream = stream;
1180
+ } catch (error) {
1181
+ bot.logger?.error(`[Hermes] 提交错误: ${(error as Error).message}`);
1182
+ // 提交即失败:摘除「处理中」表情回应,并撤回私聊回执
1183
+ if (reactionKey) {
1184
+ await this.removeReaction(reactionKey, route).catch(() => {});
1185
+ }
1186
+ await this.recallReceipt(route, receiptId);
1187
+ await this.sendReplyWithMention(route, `❌ 调用 Hermes 失败: ${(error as Error).message}`, replyMsgId);
1188
+ }
1189
+ }
1190
+
1191
+ /** 摘除「处理中」表情回应并移除持久化记录(QQ 走 NapCat API;Discord 走路由委托) */
1192
+ private async removeReaction(reactionKey: string, route: RouteInfo): Promise<void> {
1193
+ if (route.type === 'discord') {
1194
+ try {
1195
+ await route.discord?.removeReaction?.();
1196
+ } catch (error) {
1197
+ this.bot?.logger?.warn(`[Hermes] Discord 摘除表情回应失败: ${(error as Error).message}`);
1198
+ }
1199
+ this.reactions.drop(reactionKey);
1200
+ return;
1201
+ }
1202
+ await this.reactions.removeQQ(this.bot, reactionKey);
1203
+ }
1204
+
1205
+ /** 启动时清理残留的 QQ 表情回应(上次进程在 AI 处理中退出导致);Discord 残留由 DiscordBridge 清理 */
1206
+ public async cleanupStaleQQReactions(bot: QQBot): Promise<void> {
1207
+ await this.reactions.cleanupQQ(bot);
1208
+ }
1209
+
1210
+ /** 读取 configs/SOUL.md 作为系统提示词(每次触发时读取,编辑免重启) */
1211
+ private loadSoulPrompt(): string {
1212
+ try {
1213
+ const soulPath = path.resolve(process.cwd(), 'configs', 'SOUL.md');
1214
+ if (fs.existsSync(soulPath)) return fs.readFileSync(soulPath, 'utf-8').trim();
1215
+ } catch {
1216
+ // 读取失败按无 SOUL.md 处理
1217
+ }
1218
+ return '';
1219
+ }
1220
+
1221
+ // ===================================================================
1222
+ // 运行完成处理
1223
+ // ===================================================================
1224
+
1225
+ /** 处理运行完成:发送最终回复,执行技能标签,处理 MEDIA 标签 */
1226
+ private async handleRunComplete(runId: string): Promise<void> {
1227
+ const run = this.activeRuns.get(runId);
1228
+ if (!run) return;
1229
+ this.activeRuns.delete(runId);
1230
+ this.pendingApprovals.delete(runId);
1231
+ this.approvalMessageSent.delete(runId);
1232
+
1233
+ // 「处理中」表情回应默认随本轮结束摘除;查询技能回喂会把记录键转移给
1234
+ // 下一轮 run(keepReaction),对话链完全结束时才摘除
1235
+ let keepReaction = false;
1236
+ try {
1237
+ await this.finishRun(run, () => {
1238
+ keepReaction = true;
1239
+ });
1240
+ } finally {
1241
+ if (!keepReaction && run.reactionKey) {
1242
+ await this.removeReaction(run.reactionKey, run.route).catch(() => {});
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ /** handleRunComplete 主体(拆出以便统一在 finally 中摘除表情回应;markKeepReaction 用于回喂转移) */
1248
+ private async finishRun(run: RunState, markKeepReaction: () => void): Promise<void> {
1249
+ try {
1250
+ await this.finishRunBody(run, markKeepReaction);
1251
+ } finally {
1252
+ // 收尾兜底:本轮若已把回执转移给查询技能的回喂轮(receiptTransferred),
1253
+ // 由下一轮负责撤回;否则无论走哪条分支(含提前 return)都在此撤回,
1254
+ // 避免「❌ 执行失败」这类走 sendReplyWithMention 的路径把回执留在聊天里。
1255
+ if (run.receiptTransferred !== true) {
1256
+ await this.recallReceiptOnce(run);
1257
+ }
1258
+ }
1259
+ }
1260
+
1261
+ /** finishRun 主体(拆出以便统一在 finally 中撤回私聊回执) */
1262
+ private async finishRunBody(run: RunState, markKeepReaction: () => void): Promise<void> {
1263
+ // 先等在途的逐句下发完成,再计算/发送尾段,否则尾段会插到未发完的句子之前
1264
+ await this.drainSends(run);
1265
+ let output = run.finalOutput || run.messageDelta;
1266
+
1267
+ // 逐句下发过时,remainingText 由流式路径负责;这里只处理「尚未下发的尾段」。
1268
+ // 说明:流式下发的单元已裁剪空白,无法与源串按下标对齐,因此改用缓冲区判断——
1269
+ // pendingText 为空表示全部已发出,非空表示还有未成形的尾段(可能同时含 run.completed 的完整输出差异)。
1270
+ if (this.useReplyUnits(run.route)) {
1271
+ const pending = run.pendingText.trim();
1272
+ run.pendingText = '';
1273
+ // run.completed 的完整输出比流式增量更长(SSE 丢帧等):补发差额,避免漏字。
1274
+ // 仅在 messageDelta 确实是 finalOutput 前缀时补发,否则宁可漏发也不重复发。
1275
+ const tailFromFinal =
1276
+ run.finalOutput && run.messageDelta && run.finalOutput.startsWith(run.messageDelta)
1277
+ ? run.finalOutput.slice(run.messageDelta.length).trim()
1278
+ : '';
1279
+ if (run.failed) {
1280
+ // 运行失败:失败文案优先发出(已逐句下发的部分不重复发)
1281
+ output = run.finalOutput || output;
1282
+ } else if (pending) {
1283
+ output = pending;
1284
+ } else if (tailFromFinal) {
1285
+ output = tailFromFinal;
1286
+ } else if (run.sentTextLength > 0) {
1287
+ return; // 全部已发送
1288
+ }
1289
+ // 否则保持 output = finalOutput || messageDelta(例如无流式增量、仅 run.completed 给全文)
1290
+ } else if (run.sentTextLength > 0 && run.messageDelta && output) {
1291
+ const unsentFromDelta = run.messageDelta.slice(run.sentTextLength).trim();
1292
+ if (unsentFromDelta) {
1293
+ output = unsentFromDelta;
1294
+ } else if (output.length > run.sentTextLength) {
1295
+ output = output.slice(run.sentTextLength).trim();
1296
+ } else {
1297
+ return; // 全部已发送
1298
+ }
1299
+ }
1300
+
1301
+ // 运行没有任何输出(SSE 连接失败 / 模型错误等),通知用户并记录空回复占位
1302
+ if (!output?.trim()) {
1303
+ const errorMsg = '❌ 执行失败:未收到 Hermes 响应,请稍后重试';
1304
+ this.appendHistory(this.getSessionKey(run.route), 'assistant', errorMsg, run.route.userId, run.senderLabel);
1305
+ await this.sendReplyWithMention(run.route, errorMsg, run.userMsgId).catch(() => {});
1306
+ return;
1307
+ }
1308
+
1309
+ // 执行技能标签(管理技能的权限按路由发送者判定:QQ 取 operators,Discord 取服务器权限);
1310
+ // 卡片类技能产出的图片在文本发送前逐张发出
1311
+ if (this.bot) {
1312
+ const skillOutput = await this.skillManager.processTags(output, run.route, {
1313
+ bot: this.bot,
1314
+ api: this.groupAdminApi(),
1315
+ isAdmin: () => this.isRouteAdmin(run.route)
1316
+ });
1317
+ output = skillOutput.text;
1318
+ for (const image of skillOutput.images) {
1319
+ await this.sendReplyImage(run.route, image).catch(error => {
1320
+ this.bot?.logger?.error(`[Hermes] 技能卡片发送失败: ${(error as Error).message}`);
1321
+ });
1322
+ }
1323
+
1324
+ // 查询类技能:把工具数据回喂给 AI 整理回答(再提交一轮 run,有防循环上限)
1325
+ if (skillOutput.feedback.length > 0) {
1326
+ const depth = run.feedbackDepth ?? 0;
1327
+ if (depth < HermesBridge.MAX_FEEDBACK_DEPTH) {
1328
+ // 中间文本(如「我查一下」)照常发出,工具数据记入历史后提交回喂轮
1329
+ if (output) {
1330
+ await this.sendAiReplyUnits(run.route, output, run.userMsgId, run).catch(error => {
1331
+ this.bot?.logger?.error(`[Hermes] 中间回复发送失败: ${(error as Error).message}`);
1332
+ });
1333
+ }
1334
+ this.appendHistory(this.getSessionKey(run.route), 'assistant', output, run.route.userId, run.senderLabel);
1335
+ const feedbackText = skillOutput.feedback.map(item => `[工具结果 ${item.skill}]\n${item.data}`).join('\n\n');
1336
+ // 表情回应记录键随回喂转移给下一轮,链尾才摘除;私聊回执同理转移
1337
+ markKeepReaction();
1338
+ const transferredReceipt = run.receiptId;
1339
+ if (transferredReceipt !== undefined) run.receiptTransferred = true;
1340
+ await this.startHermesRun(
1341
+ this.bot,
1342
+ run.route,
1343
+ `${feedbackText}\n\n(以上是工具返回的数据,请据此用简短口语化的话回复对方,不要重复调用相同技能)`,
1344
+ [],
1345
+ run.senderLabel,
1346
+ run.userMsgId,
1347
+ depth + 1,
1348
+ run.reactionKey,
1349
+ transferredReceipt
1350
+ );
1351
+ return;
1352
+ }
1353
+ // 已达回喂上限:数据直接附在回复里兜底发出,不再提交新 run
1354
+ output = `${output}\n\n${skillOutput.feedback.map(item => `${item.skill}:${item.data}`).join('\n')}`.trim();
1355
+ }
1356
+ }
1357
+ // 记录回复对象(userId + 展示标签):历史提交时据其标注「你回复 xxx 的话」,防串台
1358
+ this.appendHistory(this.getSessionKey(run.route), 'assistant', output, run.route.userId, run.senderLabel);
1359
+
1360
+ // 解析 MEDIA: 标签,发送图片
1361
+ const mediaRegex = /MEDIA:((?:\/|https?:\/\/)[^\s\n]+)/g;
1362
+ const mediaPaths: string[] = [];
1363
+ let match: RegExpExecArray | null;
1364
+ while ((match = mediaRegex.exec(output)) !== null) {
1365
+ mediaPaths.push(match[1]!);
1366
+ }
1367
+ const remainingText = output.replace(/MEDIA:(?:\/|https?:\/\/)[^\s\n]+/g, '').trim();
1368
+
1369
+ for (const mediaPath of mediaPaths) {
1370
+ try {
1371
+ const imageData =
1372
+ mediaPath.startsWith('http://') || mediaPath.startsWith('https://')
1373
+ ? await this.downloadImageToBase64(mediaPath)
1374
+ : `base64://${fs.readFileSync(mediaPath).toString('base64')}`;
1375
+ await this.sendReplyImage(run.route, imageData);
1376
+ } catch (error) {
1377
+ this.bot?.logger?.error(`[Hermes] 图片发送失败 ${mediaPath}: ${(error as Error).message}`);
1378
+ }
1379
+ }
1380
+
1381
+ if (remainingText) {
1382
+ // 群聊逐句发送(单条超阈值转聊天记录);私聊与 Discord 走原有整段发送
1383
+ await this.sendAiReplyUnits(run.route, remainingText, run.userMsgId, run);
1384
+ }
1385
+ // 兜底:回复已发出但回执还没撤(例如走的是非 sendReplyUnit 路径),这里补一次
1386
+ await this.recallReceiptOnce(run);
1387
+ }
1388
+
1389
+ // ===================================================================
1390
+ // 停止命令
1391
+ // ===================================================================
1392
+
1393
+ /** 处理停止命令(QQ 群与互通的 Discord 频道按会话域匹配,可互相停止) */
1394
+ private async handleStopCommand(route: RouteInfo): Promise<void> {
1395
+ for (const [runId, run] of this.activeRuns) {
1396
+ if (this.routeMatches(route, run.route)) {
1397
+ await this.hermes.stopRun(runId);
1398
+ await this.sendReply(route, '已停止当前任务 ✋');
1399
+ return;
1400
+ }
1401
+ }
1402
+ await this.sendReply(route, '当前没有正在运行的任务');
1403
+ }
1404
+
1405
+ // ===================================================================
1406
+ // 审批处理
1407
+ // ===================================================================
1408
+
1409
+ /** 处理审批请求 */
1410
+ private handleApprovalRequest(runId: string, ev: HermesApprovalEvent): void {
1411
+ const config = getHermesConfig();
1412
+ if (!config.approvalEnabled) return;
1413
+
1414
+ const run = this.activeRuns.get(runId);
1415
+ if (!run) return;
1416
+
1417
+ // 防止同一 run 重复发送审批消息(仍更新待审批数据,工具可能被多次调用)
1418
+ if (this.approvalMessageSent.has(runId)) {
1419
+ this.pendingApprovals.set(runId, { runId, route: run.route, data: ev, createdAt: Date.now() });
1420
+ return;
1421
+ }
1422
+
1423
+ const route = run.route;
1424
+ const command = ev.command || '未知命令';
1425
+ const patternKey = ev.pattern_key || '';
1426
+
1427
+ const riskLevel = /rm|delete|sudo|chmod|chown|kill|reboot|shutdown/.test(patternKey)
1428
+ ? 'high'
1429
+ : /curl|wget|pip|npm|apt|docker/.test(patternKey)
1430
+ ? 'medium'
1431
+ : 'low';
1432
+
1433
+ const approval: Approval = { runId, route, data: ev, createdAt: Date.now() };
1434
+ this.pendingApprovals.set(runId, approval);
1435
+ this.approvalMessageSent.set(runId, true);
1436
+
1437
+ if (config.approvalTimeoutSec > 0) {
1438
+ approval.timeoutTimer = setTimeout(() => {
1439
+ void (async () => {
1440
+ if (this.pendingApprovals.has(runId)) {
1441
+ this.pendingApprovals.delete(runId);
1442
+ this.approvalMessageSent.delete(runId);
1443
+ try {
1444
+ await this.hermes.resolveApproval(runId, 'deny');
1445
+ await this.sendReply(route, `⏱️ 审批超时,已自动拒绝: ${command.slice(0, 100)}`);
1446
+ } catch {
1447
+ // 忽略
1448
+ }
1449
+ }
1450
+ })();
1451
+ }, config.approvalTimeoutSec * 1000);
1452
+ }
1453
+
1454
+ this.sendApprovalCard(runId, command, riskLevel).catch(error =>
1455
+ this.bot?.logger?.error(`[Hermes] 审批卡片错误: ${(error as Error).message}`)
1456
+ );
1457
+ }
1458
+
1459
+ /** 发送审批卡片(渲染失败降级纯文本) */
1460
+ private async sendApprovalCard(runId: string, command: string, riskLevel: string): Promise<void> {
1461
+ const approval = this.pendingApprovals.get(runId);
1462
+ if (!approval) return;
1463
+
1464
+ const route = approval.route;
1465
+ const ev = approval.data;
1466
+
1467
+ const image = await renderApprovalImage({
1468
+ command,
1469
+ riskLevel,
1470
+ toolName: ev.pattern_key || '',
1471
+ runId,
1472
+ preview: ev.description || ''
1473
+ });
1474
+
1475
+ if (image) {
1476
+ await this.sendReplyImage(route, image);
1477
+ await this.sendReply(route, `⚠️ 上方命令需要审批。回复 "批准" / "拒绝" / "本次允许" / "始终允许" 来处理。`);
1478
+ return;
1479
+ }
1480
+
1481
+ const toolName = ev.pattern_key || '';
1482
+ const preview = ev.description || '';
1483
+ const lines = [
1484
+ `⚠️ 需要审批`,
1485
+ toolName ? `模式: ${toolName}` : '',
1486
+ `命令: ${command.slice(0, 300)}`,
1487
+ preview ? `说明: ${preview.slice(0, 200)}` : '',
1488
+ riskLevel === 'high' ? `风险: 🔴 高` : riskLevel === 'medium' ? `风险: 🟡 中` : `风险: 🔵 低`,
1489
+ '',
1490
+ `回复 "批准" / "拒绝" / "本次允许" / "始终允许" 来处理`,
1491
+ `(run: ${runId.slice(-8)})`
1492
+ ].filter(Boolean);
1493
+ await this.sendReply(route, lines.join('\n'));
1494
+ }
1495
+
1496
+ /**
1497
+ * 处理审批回复(在触发判定之前调用;命中返回 true 表示该消息已作为审批处理)。
1498
+ * 仅拦截「同一会话域存在待审批 + 文本可解析为审批选择 + 发送者是管理员」的消息;
1499
+ * 非管理员的选择类文本(如 "ok")不拦截,避免审批等待期间误伤正常聊天。
1500
+ * QQ 群与互通的 Discord 频道按会话域匹配,两侧管理员可互相审批。
1501
+ */
1502
+ private async handleApprovalReply(route: RouteInfo, text: string, msgId: number): Promise<boolean> {
1503
+ for (const [runId, approval] of this.pendingApprovals) {
1504
+ if (!this.routeMatches(route, approval.route)) continue;
1505
+
1506
+ const choice: ApprovalChoice | null = parseApprovalChoice(text);
1507
+ if (!choice) return false;
1508
+
1509
+ // 仅管理员可审批(非管理员不拦截,按普通消息继续走触发/转发流程)
1510
+ if (!this.isRouteAdmin(route)) return false;
1511
+
1512
+ if (approval.timeoutTimer) clearTimeout(approval.timeoutTimer);
1513
+ this.pendingApprovals.delete(runId);
1514
+ this.approvalMessageSent.delete(runId);
1515
+
1516
+ try {
1517
+ await this.hermes.resolveApproval(runId, choice);
1518
+ const labels: Record<ApprovalChoice, string> = {
1519
+ once: '已批准(一次)✅',
1520
+ deny: '已拒绝 ❌',
1521
+ always: '已设置始终允许 ♾️',
1522
+ session: '已允许本次会话 ✅'
1523
+ };
1524
+ await this.sendReplyWithMention(route, labels[choice], msgId);
1525
+ } catch (error) {
1526
+ await this.sendReply(route, `审批处理失败: ${(error as Error).message}`);
1527
+ }
1528
+
1529
+ return true;
1530
+ }
1531
+ return false;
1532
+ }
1533
+ }