@leoqlin/openclaw-qqbot 1.6.7-alpha1

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 (218) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +484 -0
  3. package/README.zh.md +479 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/dist/index.d.ts +17 -0
  6. package/dist/index.js +26 -0
  7. package/dist/src/admin-resolver.d.ts +33 -0
  8. package/dist/src/admin-resolver.js +157 -0
  9. package/dist/src/api.d.ts +301 -0
  10. package/dist/src/api.js +890 -0
  11. package/dist/src/channel.d.ts +29 -0
  12. package/dist/src/channel.js +452 -0
  13. package/dist/src/config.d.ts +56 -0
  14. package/dist/src/config.js +278 -0
  15. package/dist/src/credential-backup.d.ts +31 -0
  16. package/dist/src/credential-backup.js +66 -0
  17. package/dist/src/deliver-debounce.d.ts +74 -0
  18. package/dist/src/deliver-debounce.js +174 -0
  19. package/dist/src/gateway.d.ts +18 -0
  20. package/dist/src/gateway.js +2005 -0
  21. package/dist/src/group-history.d.ts +136 -0
  22. package/dist/src/group-history.js +226 -0
  23. package/dist/src/image-server.d.ts +87 -0
  24. package/dist/src/image-server.js +570 -0
  25. package/dist/src/inbound-attachments.d.ts +60 -0
  26. package/dist/src/inbound-attachments.js +248 -0
  27. package/dist/src/known-users.d.ts +100 -0
  28. package/dist/src/known-users.js +263 -0
  29. package/dist/src/message-gating.d.ts +53 -0
  30. package/dist/src/message-gating.js +107 -0
  31. package/dist/src/message-queue.d.ts +89 -0
  32. package/dist/src/message-queue.js +257 -0
  33. package/dist/src/onboarding.d.ts +10 -0
  34. package/dist/src/onboarding.js +203 -0
  35. package/dist/src/outbound-deliver.d.ts +48 -0
  36. package/dist/src/outbound-deliver.js +392 -0
  37. package/dist/src/outbound.d.ts +205 -0
  38. package/dist/src/outbound.js +938 -0
  39. package/dist/src/proactive.d.ts +170 -0
  40. package/dist/src/proactive.js +399 -0
  41. package/dist/src/ref-index-store.d.ts +101 -0
  42. package/dist/src/ref-index-store.js +298 -0
  43. package/dist/src/reply-dispatcher.d.ts +35 -0
  44. package/dist/src/reply-dispatcher.js +311 -0
  45. package/dist/src/request-context.d.ts +25 -0
  46. package/dist/src/request-context.js +37 -0
  47. package/dist/src/runtime.d.ts +3 -0
  48. package/dist/src/runtime.js +10 -0
  49. package/dist/src/session-store.d.ts +52 -0
  50. package/dist/src/session-store.js +254 -0
  51. package/dist/src/slash-commands.d.ts +77 -0
  52. package/dist/src/slash-commands.js +1866 -0
  53. package/dist/src/startup-greeting.d.ts +30 -0
  54. package/dist/src/startup-greeting.js +97 -0
  55. package/dist/src/streaming.d.ts +247 -0
  56. package/dist/src/streaming.js +899 -0
  57. package/dist/src/stt.d.ts +21 -0
  58. package/dist/src/stt.js +70 -0
  59. package/dist/src/tools/channel.d.ts +16 -0
  60. package/dist/src/tools/channel.js +234 -0
  61. package/dist/src/tools/remind.d.ts +2 -0
  62. package/dist/src/tools/remind.js +256 -0
  63. package/dist/src/types.d.ts +367 -0
  64. package/dist/src/types.js +17 -0
  65. package/dist/src/typing-keepalive.d.ts +27 -0
  66. package/dist/src/typing-keepalive.js +64 -0
  67. package/dist/src/update-checker.d.ts +36 -0
  68. package/dist/src/update-checker.js +171 -0
  69. package/dist/src/utils/audio-convert.d.ts +98 -0
  70. package/dist/src/utils/audio-convert.js +755 -0
  71. package/dist/src/utils/chunked-upload.d.ts +68 -0
  72. package/dist/src/utils/chunked-upload.js +341 -0
  73. package/dist/src/utils/file-utils.d.ts +61 -0
  74. package/dist/src/utils/file-utils.js +172 -0
  75. package/dist/src/utils/image-size.d.ts +51 -0
  76. package/dist/src/utils/image-size.js +234 -0
  77. package/dist/src/utils/media-send.d.ts +158 -0
  78. package/dist/src/utils/media-send.js +499 -0
  79. package/dist/src/utils/media-tags.d.ts +14 -0
  80. package/dist/src/utils/media-tags.js +165 -0
  81. package/dist/src/utils/payload.d.ts +112 -0
  82. package/dist/src/utils/payload.js +186 -0
  83. package/dist/src/utils/pkg-version.d.ts +5 -0
  84. package/dist/src/utils/pkg-version.js +61 -0
  85. package/dist/src/utils/platform.d.ts +137 -0
  86. package/dist/src/utils/platform.js +390 -0
  87. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  88. package/dist/src/utils/ssrf-guard.js +91 -0
  89. package/dist/src/utils/text-parsing.d.ts +36 -0
  90. package/dist/src/utils/text-parsing.js +75 -0
  91. package/dist/src/utils/upload-cache.d.ts +34 -0
  92. package/dist/src/utils/upload-cache.js +93 -0
  93. package/index.ts +31 -0
  94. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  95. package/node_modules/@eshaz/web-worker/README.md +134 -0
  96. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  97. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  98. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  99. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  100. package/node_modules/@eshaz/web-worker/node.js +223 -0
  101. package/node_modules/@eshaz/web-worker/package.json +54 -0
  102. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  103. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  104. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  105. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  106. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  107. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  108. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  109. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  110. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  111. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  112. package/node_modules/mpg123-decoder/README.md +265 -0
  113. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  114. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  115. package/node_modules/mpg123-decoder/index.js +8 -0
  116. package/node_modules/mpg123-decoder/package.json +58 -0
  117. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  118. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  119. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  120. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  121. package/node_modules/silk-wasm/LICENSE +21 -0
  122. package/node_modules/silk-wasm/README.md +85 -0
  123. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  124. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  125. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  126. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  127. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  128. package/node_modules/silk-wasm/package.json +39 -0
  129. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  130. package/node_modules/simple-yenc/.prettierignore +1 -0
  131. package/node_modules/simple-yenc/LICENSE +7 -0
  132. package/node_modules/simple-yenc/README.md +163 -0
  133. package/node_modules/simple-yenc/dist/esm.js +1 -0
  134. package/node_modules/simple-yenc/dist/index.js +1 -0
  135. package/node_modules/simple-yenc/package.json +50 -0
  136. package/node_modules/simple-yenc/rollup.config.js +27 -0
  137. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  138. package/node_modules/ws/LICENSE +20 -0
  139. package/node_modules/ws/README.md +548 -0
  140. package/node_modules/ws/browser.js +8 -0
  141. package/node_modules/ws/index.js +22 -0
  142. package/node_modules/ws/lib/buffer-util.js +131 -0
  143. package/node_modules/ws/lib/constants.js +19 -0
  144. package/node_modules/ws/lib/event-target.js +292 -0
  145. package/node_modules/ws/lib/extension.js +203 -0
  146. package/node_modules/ws/lib/limiter.js +55 -0
  147. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  148. package/node_modules/ws/lib/receiver.js +706 -0
  149. package/node_modules/ws/lib/sender.js +602 -0
  150. package/node_modules/ws/lib/stream.js +161 -0
  151. package/node_modules/ws/lib/subprotocol.js +62 -0
  152. package/node_modules/ws/lib/validation.js +152 -0
  153. package/node_modules/ws/lib/websocket-server.js +554 -0
  154. package/node_modules/ws/lib/websocket.js +1393 -0
  155. package/node_modules/ws/package.json +70 -0
  156. package/node_modules/ws/wrapper.mjs +21 -0
  157. package/openclaw.plugin.json +17 -0
  158. package/package.json +70 -0
  159. package/preload.cjs +33 -0
  160. package/scripts/cleanup-legacy-plugins.sh +124 -0
  161. package/scripts/link-sdk-core.cjs +185 -0
  162. package/scripts/postinstall-link-sdk.js +126 -0
  163. package/scripts/proactive-api-server.ts +369 -0
  164. package/scripts/send-proactive.ts +293 -0
  165. package/scripts/set-markdown.sh +156 -0
  166. package/scripts/test-sendmedia.ts +116 -0
  167. package/scripts/upgrade-via-npm.ps1 +460 -0
  168. package/scripts/upgrade-via-npm.sh +652 -0
  169. package/scripts/upgrade-via-source.sh +1026 -0
  170. package/skills/qqbot-channel/SKILL.md +263 -0
  171. package/skills/qqbot-channel/references/api_references.md +521 -0
  172. package/skills/qqbot-media/SKILL.md +60 -0
  173. package/skills/qqbot-remind/SKILL.md +159 -0
  174. package/src/admin-resolver.ts +181 -0
  175. package/src/api.ts +1284 -0
  176. package/src/channel.ts +477 -0
  177. package/src/config.ts +347 -0
  178. package/src/credential-backup.ts +72 -0
  179. package/src/deliver-debounce.ts +229 -0
  180. package/src/gateway.ts +2245 -0
  181. package/src/group-history.ts +328 -0
  182. package/src/image-server.ts +675 -0
  183. package/src/inbound-attachments.ts +321 -0
  184. package/src/known-users.ts +353 -0
  185. package/src/message-gating.ts +190 -0
  186. package/src/message-queue.ts +352 -0
  187. package/src/onboarding.ts +274 -0
  188. package/src/openclaw-plugin-sdk.d.ts +587 -0
  189. package/src/outbound-deliver.ts +473 -0
  190. package/src/outbound.ts +1131 -0
  191. package/src/proactive.ts +530 -0
  192. package/src/ref-index-store.ts +412 -0
  193. package/src/reply-dispatcher.ts +334 -0
  194. package/src/request-context.ts +49 -0
  195. package/src/runtime.ts +14 -0
  196. package/src/session-store.ts +303 -0
  197. package/src/slash-commands.ts +2030 -0
  198. package/src/startup-greeting.ts +120 -0
  199. package/src/streaming.ts +1077 -0
  200. package/src/stt.ts +86 -0
  201. package/src/tools/channel.ts +281 -0
  202. package/src/tools/remind.ts +308 -0
  203. package/src/types.ts +391 -0
  204. package/src/typing-keepalive.ts +59 -0
  205. package/src/update-checker.ts +186 -0
  206. package/src/utils/audio-convert.ts +859 -0
  207. package/src/utils/chunked-upload.ts +483 -0
  208. package/src/utils/file-utils.ts +193 -0
  209. package/src/utils/image-size.ts +266 -0
  210. package/src/utils/media-send.ts +631 -0
  211. package/src/utils/media-tags.ts +183 -0
  212. package/src/utils/payload.ts +265 -0
  213. package/src/utils/pkg-version.ts +64 -0
  214. package/src/utils/platform.ts +435 -0
  215. package/src/utils/ssrf-guard.ts +102 -0
  216. package/src/utils/text-parsing.ts +85 -0
  217. package/src/utils/upload-cache.ts +128 -0
  218. package/tsconfig.json +16 -0
@@ -0,0 +1,2005 @@
1
+ import WebSocket from "ws";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, acknowledgeInteraction, getApiPluginVersion, setApiLogger } from "./api.js";
5
+ import { loadSession, saveSession, clearSession } from "./session-store.js";
6
+ import { recordKnownUser, flushKnownUsers } from "./known-users.js";
7
+ import { getQQBotRuntime } from "./runtime.js";
8
+ import { isGroupAllowed, resolveGroupName, resolveGroupPrompt, resolveHistoryLimit, resolveGroupPolicy, resolveGroupConfig, resolveIgnoreOtherMentions, resolveMentionPatterns } from "./config.js";
9
+ import { qqbotPlugin, stripMentionText, detectWasMentioned } from "./channel.js";
10
+ import { recordPendingHistoryEntry, buildPendingHistoryContext, buildMergedMessageContext, clearPendingHistory, formatAttachmentTags, formatMessageContent, toAttachmentSummaries, } from "./group-history.js";
11
+ import { setRefIndex, getRefIndex, formatRefEntryForAgent, formatMessageReferenceForAgent, flushRefIndex } from "./ref-index-store.js";
12
+ import { matchSlashCommand, getFrameworkVersion, parseFrameworkDateVersion } from "./slash-commands.js";
13
+ import { createMessageQueue } from "./message-queue.js";
14
+ import { triggerUpdateCheck } from "./update-checker.js";
15
+ import { startImageServer, isImageServerRunning } from "./image-server.js";
16
+ import { resolveTTSConfig } from "./utils/audio-convert.js";
17
+ import { processAttachments, formatVoiceText } from "./inbound-attachments.js";
18
+ import { getQQBotDataDir, runDiagnostics } from "./utils/platform.js";
19
+ import { sendDocument, sendMedia as sendMediaAuto } from "./outbound.js";
20
+ import { parseFaceTags, parseRefIndices, buildAttachmentSummaries } from "./utils/text-parsing.js";
21
+ import { sendStartupGreetings } from "./admin-resolver.js";
22
+ import { sendWithTokenRetry, sendErrorToTarget, handleStructuredPayload } from "./reply-dispatcher.js";
23
+ import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
24
+ import { parseAndSendMediaTags, sendPlainReply } from "./outbound-deliver.js";
25
+ import { createDeliverDebouncer } from "./deliver-debounce.js";
26
+ import { runWithRequestContext } from "./request-context.js";
27
+ import { StreamingController, shouldUseStreaming } from "./streaming.js";
28
+ import { resolveGroupMessageGate } from "./message-gating.js";
29
+ // ============ Interaction 处理 ============
30
+ /** 配置查询交互类型 */
31
+ const INTERACTION_TYPE_CONFIG_QUERY = 2001;
32
+ /** 配置更新交互类型 */
33
+ const INTERACTION_TYPE_CONFIG_UPDATE = 2002;
34
+ /** 处理 INTERACTION_CREATE 事件 */
35
+ async function handleInteractionCreate(params) {
36
+ const { event, account, cfg, log } = params;
37
+ const token = await getAccessToken(account.appId, account.clientSecret);
38
+ if (event.data?.type === INTERACTION_TYPE_CONFIG_QUERY) {
39
+ // 从框架 configApi 读取最新配置(而非闭包中的旧 cfg),确保配置查询返回的数据与磁盘一致
40
+ const runtime = getQQBotRuntime();
41
+ const configApi = runtime.config;
42
+ const latestCfg = configApi.loadConfig();
43
+ const groupOpenid = event.group_openid ?? "";
44
+ const groupCfg = groupOpenid ? resolveGroupConfig(latestCfg, groupOpenid, account.accountId) : null;
45
+ const groupPolicy = resolveGroupPolicy(latestCfg, account.accountId);
46
+ // require_mention 协议:字符串 "mention" | "always"(mention=@机器人时激活,always=总是激活)
47
+ const configRequireMention = groupCfg?.requireMention ?? true;
48
+ const requireMentionMode = configRequireMention ? "mention" : "always";
49
+ const pluginVersion = getApiPluginVersion();
50
+ const fwVersionRaw = getFrameworkVersion();
51
+ const clawVer = parseFrameworkDateVersion(fwVersionRaw) ?? fwVersionRaw;
52
+ // 通过路由解析 agentId(与消息处理流程一致),用于 agent-aware 的 mentionPatterns
53
+ const interactionAgentId = groupOpenid
54
+ ? runtime.channel?.routing?.resolveAgentRoute?.({
55
+ cfg: latestCfg,
56
+ channel: "qqbot",
57
+ accountId: account.accountId,
58
+ peer: { kind: "group", id: groupOpenid },
59
+ })?.agentId
60
+ : undefined;
61
+ // mention_patterns 协议:逗号分隔的字符串(@文本的名称提及BOT名,多个使用,分隔)
62
+ const mentionPatternsArr = resolveMentionPatterns(latestCfg, interactionAgentId);
63
+ const mentionPatterns = mentionPatternsArr.join(",");
64
+ const clawCfg = {
65
+ channel_type: "qqbot",
66
+ channel_ver: pluginVersion,
67
+ claw_type: "openclaw",
68
+ claw_ver: clawVer,
69
+ require_mention: requireMentionMode,
70
+ group_policy: groupPolicy,
71
+ mention_patterns: mentionPatterns,
72
+ online_state: "online",
73
+ };
74
+ await acknowledgeInteraction(token, event.id, 0, { claw_cfg: clawCfg });
75
+ log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_QUERY}) sent: ${event.id}, claw_cfg=${JSON.stringify(clawCfg)}`);
76
+ }
77
+ else if (event.data?.type === INTERACTION_TYPE_CONFIG_UPDATE) {
78
+ // type=2002: 配置更新交互,从 resolved.claw_cfg 获取更新信息并写入本地配置
79
+ const resolved = event.data.resolved;
80
+ const clawCfgUpdate = resolved?.claw_cfg;
81
+ const groupOpenid = event.group_openid ?? "";
82
+ const runtime = getQQBotRuntime();
83
+ const configApi = runtime.config;
84
+ const currentCfg = structuredClone(configApi.loadConfig());
85
+ const qqbot = (currentCfg.channels ?? {}).qqbot;
86
+ let changed = false;
87
+ if (clawCfgUpdate) {
88
+ // 更新 require_mention(群级别)——协议为 "mention" | "always",写回配置时转为 boolean
89
+ if (clawCfgUpdate.require_mention !== undefined && groupOpenid && qqbot) {
90
+ const requireMentionBool = clawCfgUpdate.require_mention === "mention";
91
+ const accountId = account.accountId;
92
+ const isNamedAccount = accountId !== "default" && qqbot.accounts?.[accountId];
93
+ if (isNamedAccount) {
94
+ const accounts = qqbot.accounts;
95
+ const acct = accounts[accountId] ?? {};
96
+ const groups = (acct.groups ?? {});
97
+ groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
98
+ acct.groups = groups;
99
+ accounts[accountId] = acct;
100
+ qqbot.accounts = accounts;
101
+ }
102
+ else {
103
+ const groups = (qqbot.groups ?? {});
104
+ groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool };
105
+ qqbot.groups = groups;
106
+ }
107
+ changed = true;
108
+ }
109
+ }
110
+ if (changed) {
111
+ await configApi.writeConfigFile(currentCfg);
112
+ log?.info(`[qqbot:${account.accountId}] Config updated via interaction ${event.id}: ${JSON.stringify({
113
+ require_mention: clawCfgUpdate?.require_mention,
114
+ group_openid: groupOpenid || undefined,
115
+ })}`);
116
+ }
117
+ // 无论更新是否成功,ACK 都上报最新的 claw_cfg 快照(写入后重新读取确保一致)
118
+ const latestCfg = changed ? configApi.loadConfig() : currentCfg;
119
+ const updatedGroupCfg = groupOpenid ? resolveGroupConfig(latestCfg, groupOpenid, account.accountId) : null;
120
+ const updatedRequireMention = updatedGroupCfg?.requireMention ?? true;
121
+ const updatedRequireMentionMode = updatedRequireMention ? "mention" : "always";
122
+ const pluginVersion = getApiPluginVersion();
123
+ const fwVersionRaw = getFrameworkVersion();
124
+ const clawVer = parseFrameworkDateVersion(fwVersionRaw) ?? fwVersionRaw;
125
+ const ackClawCfg = {
126
+ channel_type: "qqbot",
127
+ channel_ver: pluginVersion,
128
+ claw_type: "openclaw",
129
+ claw_ver: clawVer,
130
+ require_mention: updatedRequireMentionMode,
131
+ online_state: "online",
132
+ };
133
+ await acknowledgeInteraction(token, event.id, 0, { claw_cfg: ackClawCfg });
134
+ log?.info(`[qqbot:${account.accountId}] Interaction ACK (type=${INTERACTION_TYPE_CONFIG_UPDATE}) sent: ${event.id}, claw_cfg=${JSON.stringify(ackClawCfg)}`);
135
+ }
136
+ else {
137
+ // 其他类型:普通 ACK
138
+ await acknowledgeInteraction(token, event.id);
139
+ log?.debug?.(`[qqbot:${account.accountId}] Interaction ACK sent: ${event.id}`);
140
+ }
141
+ }
142
+ /** 解析 session store 文件路径 */
143
+ function resolveSessionStorePath(cfg, agentId) {
144
+ const sessionCfg = cfg?.session;
145
+ const store = sessionCfg?.store;
146
+ const resolvedAgentId = agentId || "default";
147
+ if (store) {
148
+ let expanded = store;
149
+ if (expanded.includes("{agentId}")) {
150
+ expanded = expanded.replaceAll("{agentId}", resolvedAgentId);
151
+ }
152
+ if (expanded.startsWith("~")) {
153
+ const home = process.env.HOME || process.env.USERPROFILE || "";
154
+ expanded = expanded.replace(/^~/, home);
155
+ }
156
+ return path.resolve(expanded);
157
+ }
158
+ // 默认路径: ~/.openclaw/agents/{agentId}/sessions/sessions.json
159
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim()
160
+ || process.env.CLAWDBOT_STATE_DIR?.trim()
161
+ || path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
162
+ return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
163
+ }
164
+ // ============ Mention Gating — 已抽取到 message-gating.ts ============
165
+ // ============ Command Detection(委托框架运行时 commands-registry) ============
166
+ /**
167
+ * 检测消息是否包含框架控制命令(如 /activation、/status 等)。
168
+ *
169
+ * 不再使用静态 KNOWN_CONTROL_COMMANDS 列表,而是委托给框架运行时
170
+ * pluginRuntime.channel.text.hasControlCommand(),确保框架新增命令时
171
+ * 无需手动同步。
172
+ *
173
+ * 如果 pluginRuntime 尚未初始化(极端边界),回退到简单的 "/" 前缀检测。
174
+ */
175
+ function hasControlCommand(text) {
176
+ if (!text || !text.startsWith("/"))
177
+ return false;
178
+ try {
179
+ const runtime = getQQBotRuntime();
180
+ const runtimeHasControlCommand = runtime?.channel?.text?.hasControlCommand;
181
+ if (typeof runtimeHasControlCommand === "function") {
182
+ return runtimeHasControlCommand(text);
183
+ }
184
+ }
185
+ catch {
186
+ // runtime 未初始化,fallback
187
+ }
188
+ // fallback:简单的 "/" + word 检测(宁可误判为 true 也不漏掉命令)
189
+ return /^\/[a-z][a-z0-9_-]*/i.test(text);
190
+ }
191
+ // ============ Text Command Gating ============
192
+ /**
193
+ * 判断文本命令是否启用。
194
+ * 当 cfg.commands.text === false 时禁用;QQ Bot 仅支持文本命令(无 native slash command)。
195
+ */
196
+ function shouldHandleTextCommands(cfg) {
197
+ const commands = cfg.commands;
198
+ // 仅当显式设置为 false 时禁用(默认启用)
199
+ return commands?.text !== false;
200
+ }
201
+ // ============ hasAnyMention 检测 ============
202
+ /**
203
+ * 检测消息中是否包含任何 @mention(不限于 @bot)。
204
+ * 如果消息 @ 了任何人,即使是控制命令也不应该 bypass mention 门控。
205
+ */
206
+ function hasAnyMention(params) {
207
+ // QQ 事件中 mentions 数组包含了消息中所有被 @ 的用户(含 bot)
208
+ if (params.mentions && params.mentions.length > 0)
209
+ return true;
210
+ // 兜底:检查文本中是否有 <@xxx> 格式的 mention
211
+ if (params.content && /<@!?\w+>/.test(params.content))
212
+ return true;
213
+ return false;
214
+ }
215
+ // ============ implicitMention 检测 ============
216
+ /**
217
+ * 检测引用回复是否构成隐式 mention。
218
+ * 如果用户回复的是 bot 发出的消息,视为隐式 mention。
219
+ */
220
+ function resolveImplicitMention(params) {
221
+ if (!params.refMsgIdx)
222
+ return false;
223
+ const refEntry = params.getRefEntry(params.refMsgIdx);
224
+ return refEntry?.isBot === true;
225
+ }
226
+ /**
227
+ * 解析 groupActivation(session store > 配置 requireMention > 默认值)
228
+ * @returns "mention" | "always"
229
+ */
230
+ function resolveGroupActivation(params) {
231
+ const defaultActivation = params.configRequireMention ? "mention" : "always";
232
+ try {
233
+ const storePath = resolveSessionStorePath(params.cfg, params.agentId);
234
+ if (!fs.existsSync(storePath)) {
235
+ return defaultActivation;
236
+ }
237
+ const raw = fs.readFileSync(storePath, "utf-8");
238
+ const store = JSON.parse(raw);
239
+ const entry = store[params.sessionKey];
240
+ if (!entry?.groupActivation) {
241
+ return defaultActivation;
242
+ }
243
+ const normalized = entry.groupActivation.trim().toLowerCase();
244
+ if (normalized === "mention" || normalized === "always") {
245
+ return normalized;
246
+ }
247
+ return defaultActivation;
248
+ }
249
+ catch {
250
+ // session store 读取失败时 fallback 到配置文件
251
+ return defaultActivation;
252
+ }
253
+ }
254
+ // QQ Bot intents - 按权限级别分组
255
+ const INTENTS = {
256
+ // 基础权限(默认有)
257
+ GUILDS: 1 << 0, // 频道相关
258
+ GUILD_MEMBERS: 1 << 1, // 频道成员
259
+ PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域)
260
+ // 需要申请的权限
261
+ DIRECT_MESSAGE: 1 << 12, // 频道私信
262
+ GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
263
+ INTERACTION: 1 << 26, // 按钮交互回调
264
+ };
265
+ // 固定使用完整权限(群聊 + 私信 + 频道 + 交互),不做降级
266
+ const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C | INTENTS.INTERACTION;
267
+ const FULL_INTENTS_DESC = "群聊+私信+频道+交互";
268
+ // 重连配置
269
+ const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
270
+ const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
271
+ const MAX_RECONNECT_ATTEMPTS = 100;
272
+ const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
273
+ const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
274
+ // 图床服务器配置(可通过环境变量覆盖)
275
+ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10);
276
+ // 使用绝对路径,确保文件保存和读取使用同一目录
277
+ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
278
+ /**
279
+ * 启动图床服务器
280
+ */
281
+ async function ensureImageServer(log, publicBaseUrl) {
282
+ if (isImageServerRunning()) {
283
+ return publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`;
284
+ }
285
+ try {
286
+ const config = {
287
+ port: IMAGE_SERVER_PORT,
288
+ storageDir: IMAGE_SERVER_DIR,
289
+ // 使用用户配置的公网地址,而不是 0.0.0.0
290
+ baseUrl: publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`,
291
+ ttlSeconds: 3600, // 1 小时过期
292
+ };
293
+ await startImageServer(config);
294
+ log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}, baseUrl: ${config.baseUrl}`);
295
+ return config.baseUrl;
296
+ }
297
+ catch (err) {
298
+ log?.error(`[qqbot] Failed to start image server: ${err}`);
299
+ return null;
300
+ }
301
+ }
302
+ // 模块级变量:per-account 首次 READY 跟踪
303
+ // 区分 gateway restart(进程重启)和 health-monitor 断线重连
304
+ // 每个 account 首次 READY/RESUMED 时从 Set 中移除,之后不再发送问候语
305
+ const _pendingFirstReady = new Set();
306
+ /**
307
+ * 启动 Gateway WebSocket 连接(带自动重连)
308
+ * 支持流式消息发送
309
+ */
310
+ export async function startGateway(ctx) {
311
+ const { account, abortSignal, cfg, onReady, onError, log } = ctx;
312
+ if (!account.appId || !account.clientSecret) {
313
+ throw new Error("QQBot not configured (missing appId or clientSecret)");
314
+ }
315
+ // 启动环境诊断(首次连接时执行)
316
+ const diag = await runDiagnostics();
317
+ if (diag.warnings.length > 0) {
318
+ for (const w of diag.warnings) {
319
+ log?.info(`[qqbot:${account.accountId}] ${w}`);
320
+ }
321
+ }
322
+ // 预检 openclaw runtime 模块是否可正常解析(兼容性诊断)
323
+ // openclaw 3.23+ 存在 plugin-sdk/root-alias.cjs 回归 bug,
324
+ // 内置插件(qwen-portal-auth 等)全部加载失败,导致 AI agent 调用返回
325
+ // "Unable to resolve plugin runtime module"。提前检测并告警。
326
+ try {
327
+ const pluginRuntime = getQQBotRuntime();
328
+ if (pluginRuntime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
329
+ log?.info(`[qqbot:${account.accountId}] Runtime module preflight: OK`);
330
+ }
331
+ else {
332
+ log?.error(`[qqbot:${account.accountId}] ⚠️ Runtime preflight: dispatchReply API 不可用,AI 消息处理可能失败。请检查 openclaw 版本兼容性`);
333
+ }
334
+ }
335
+ catch (preflightErr) {
336
+ log?.error(`[qqbot:${account.accountId}] ⚠️ Runtime preflight failed: ${preflightErr}. AI 消息处理可能失败`);
337
+ }
338
+ // 后台版本检查(供 /bot-version、/bot-upgrade 指令被动查询)
339
+ triggerUpdateCheck(log);
340
+ // 初始化 API 配置(markdown 支持)
341
+ // 将框架 log 注入 api 模块,统一日志输出
342
+ if (log) {
343
+ setApiLogger(log);
344
+ }
345
+ initApiConfig({
346
+ markdownSupport: account.markdownSupport,
347
+ });
348
+ log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`);
349
+ // 注册出站消息 refIdx 缓存钩子
350
+ // 所有消息发送函数在拿到 QQ 回包后,如果含 ref_idx 则自动回调此处缓存
351
+ onMessageSent((refIdx, meta) => {
352
+ log?.info(`[qqbot:${account.accountId}] onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`);
353
+ const attachments = [];
354
+ if (meta.mediaType) {
355
+ const localPath = meta.mediaLocalPath;
356
+ // filename 取路径的 basename,如果没有路径信息则留空
357
+ const filename = localPath ? path.basename(localPath) : undefined;
358
+ const attachment = {
359
+ type: meta.mediaType,
360
+ ...(localPath ? { localPath } : {}),
361
+ ...(filename ? { filename } : {}),
362
+ ...(meta.mediaUrl ? { url: meta.mediaUrl } : {}),
363
+ };
364
+ // 如果是语音消息且有 TTS 原文本,保存到 transcript 并标记来源为 tts
365
+ if (meta.mediaType === "voice" && meta.ttsText) {
366
+ attachment.transcript = meta.ttsText;
367
+ attachment.transcriptSource = "tts";
368
+ log?.info(`[qqbot:${account.accountId}] Saving voice transcript (TTS): ${meta.ttsText.slice(0, 50)}`);
369
+ }
370
+ attachments.push(attachment);
371
+ }
372
+ setRefIndex(refIdx, {
373
+ content: meta.text ?? "",
374
+ senderId: account.accountId,
375
+ senderName: account.accountId,
376
+ timestamp: Date.now(),
377
+ isBot: true,
378
+ ...(attachments.length > 0 ? { attachments } : {}),
379
+ });
380
+ log?.info(`[qqbot:${account.accountId}] Cached outbound refIdx: ${refIdx}, attachments=${JSON.stringify(attachments)}`);
381
+ });
382
+ // TTS 配置验证
383
+ const ttsCfg = resolveTTSConfig(cfg);
384
+ if (ttsCfg) {
385
+ const maskedKey = ttsCfg.apiKey.length > 8
386
+ ? `${ttsCfg.apiKey.slice(0, 4)}****${ttsCfg.apiKey.slice(-4)}`
387
+ : "****";
388
+ log?.info(`[qqbot:${account.accountId}] TTS configured: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, baseUrl=${ttsCfg.baseUrl}`);
389
+ log?.info(`[qqbot:${account.accountId}] TTS apiKey: ${maskedKey}${ttsCfg.queryParams ? `, queryParams=${JSON.stringify(ttsCfg.queryParams)}` : ""}${ttsCfg.speed !== undefined ? `, speed=${ttsCfg.speed}` : ""}`);
390
+ }
391
+ else {
392
+ log?.info(`[qqbot:${account.accountId}] TTS not configured (voice messages will be unavailable)`);
393
+ }
394
+ // 如果配置了公网 URL,启动图床服务器
395
+ let imageServerBaseUrl = null;
396
+ if (account.imageServerBaseUrl) {
397
+ // 使用用户配置的公网地址作为 baseUrl
398
+ await ensureImageServer(log, account.imageServerBaseUrl);
399
+ imageServerBaseUrl = account.imageServerBaseUrl;
400
+ log?.info(`[qqbot:${account.accountId}] Image server enabled with URL: ${imageServerBaseUrl}`);
401
+ }
402
+ else {
403
+ log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
404
+ }
405
+ let reconnectAttempts = 0;
406
+ let isAborted = false;
407
+ let currentWs = null;
408
+ let heartbeatInterval = null;
409
+ let sessionId = null;
410
+ let lastSeq = null;
411
+ let lastConnectTime = 0; // 上次连接成功的时间
412
+ let quickDisconnectCount = 0; // 连续快速断开次数
413
+ let isConnecting = false; // 防止并发连接
414
+ let reconnectTimer = null; // 重连定时器
415
+ let shouldRefreshToken = false; // 下次连接是否需要刷新 token
416
+ // 标记此 account 为待发问候(进程重启时 Set 里已有,断线重连不会重新加入)
417
+ _pendingFirstReady.add(account.accountId);
418
+ const adminCtx = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
419
+ // ============ P1-2: 尝试从持久化存储恢复 Session ============
420
+ // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
421
+ const savedSession = loadSession(account.accountId, account.appId);
422
+ if (savedSession) {
423
+ sessionId = savedSession.sessionId;
424
+ lastSeq = savedSession.lastSeq;
425
+ log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
426
+ }
427
+ // ============ 消息队列(复用 createMessageQueue,内置群消息合并/淘汰策略) ============
428
+ const msgQueue = createMessageQueue({
429
+ accountId: account.accountId,
430
+ log,
431
+ isAborted: () => isAborted,
432
+ });
433
+ // 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
434
+ // 紧急命令列表:这些命令会立即执行,不进入斜杠匹配流程
435
+ const URGENT_COMMANDS = ["/stop"];
436
+ const trySlashCommandOrEnqueue = async (msg) => {
437
+ const content = (msg.content ?? "").trim();
438
+ if (!content.startsWith("/")) {
439
+ msgQueue.enqueue(msg);
440
+ return;
441
+ }
442
+ // 检测是否为紧急命令 — 立即执行,清空该用户队列
443
+ const contentLower = content.toLowerCase();
444
+ const isUrgentCommand = URGENT_COMMANDS.some(cmd => contentLower.startsWith(cmd.toLowerCase()));
445
+ if (isUrgentCommand) {
446
+ log?.info(`[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`);
447
+ const peerId = msgQueue.getMessagePeerId(msg);
448
+ const droppedCount = msgQueue.clearUserQueue(peerId);
449
+ if (droppedCount > 0) {
450
+ log?.info(`[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`);
451
+ }
452
+ msgQueue.executeImmediate(msg);
453
+ return;
454
+ }
455
+ const receivedAt = Date.now();
456
+ const peerId = msgQueue.getMessagePeerId(msg);
457
+ const cmdCtx = {
458
+ type: msg.type,
459
+ senderId: msg.senderId,
460
+ senderName: msg.senderName,
461
+ messageId: msg.messageId,
462
+ eventTimestamp: msg.timestamp,
463
+ receivedAt,
464
+ rawContent: content,
465
+ args: "",
466
+ channelId: msg.channelId,
467
+ groupOpenid: msg.groupOpenid,
468
+ accountId: account.accountId,
469
+ appId: account.appId,
470
+ accountConfig: account.config,
471
+ queueSnapshot: msgQueue.getSnapshot(peerId),
472
+ };
473
+ try {
474
+ const reply = await matchSlashCommand(cmdCtx);
475
+ if (reply === null) {
476
+ // 不是插件级指令,正常入队交给框架
477
+ msgQueue.enqueue(msg);
478
+ return;
479
+ }
480
+ // 命中插件级指令,直接回复
481
+ log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
482
+ const token = await getAccessToken(account.appId, account.clientSecret);
483
+ // 解析回复:纯文本 or 带文件的结果
484
+ const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
485
+ const replyText = isFileResult ? reply.text : reply;
486
+ const replyFile = isFileResult ? reply.filePath : null;
487
+ // 先发送文本回复
488
+ if (msg.type === "c2c") {
489
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
490
+ }
491
+ else if (msg.type === "group" && msg.groupOpenid) {
492
+ await sendGroupMessage(token, msg.groupOpenid, replyText, msg.messageId);
493
+ }
494
+ else if (msg.channelId) {
495
+ await sendChannelMessage(token, msg.channelId, replyText, msg.messageId);
496
+ }
497
+ else if (msg.type === "dm") {
498
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
499
+ }
500
+ // 如果有文件需要发送
501
+ if (replyFile) {
502
+ try {
503
+ const targetType = msg.type === "group" ? "group" : msg.type === "c2c" || msg.type === "dm" ? "c2c" : "channel";
504
+ const targetId = msg.type === "group" ? (msg.groupOpenid || msg.senderId) : msg.type === "c2c" || msg.type === "dm" ? msg.senderId : (msg.channelId || msg.senderId);
505
+ const mediaCtx = {
506
+ targetType,
507
+ targetId,
508
+ account,
509
+ replyToId: msg.messageId,
510
+ logPrefix: `[qqbot:${account.accountId}]`,
511
+ };
512
+ await sendDocument(mediaCtx, replyFile);
513
+ log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`);
514
+ }
515
+ catch (fileErr) {
516
+ log?.error(`[qqbot:${account.accountId}] Failed to send slash command file: ${fileErr}`);
517
+ }
518
+ }
519
+ }
520
+ catch (err) {
521
+ log?.error(`[qqbot:${account.accountId}] Slash command error: ${err}`);
522
+ // 出错时回退到正常入队
523
+ msgQueue.enqueue(msg);
524
+ }
525
+ };
526
+ abortSignal.addEventListener("abort", () => {
527
+ isAborted = true;
528
+ if (reconnectTimer) {
529
+ clearTimeout(reconnectTimer);
530
+ reconnectTimer = null;
531
+ }
532
+ cleanup();
533
+ // P1-1: 停止后台 Token 刷新
534
+ stopBackgroundTokenRefresh(account.appId);
535
+ // P1-3: 保存已知用户数据
536
+ flushKnownUsers();
537
+ // P1-4: 保存引用索引数据
538
+ flushRefIndex();
539
+ });
540
+ const cleanup = () => {
541
+ if (heartbeatInterval) {
542
+ clearInterval(heartbeatInterval);
543
+ heartbeatInterval = null;
544
+ }
545
+ if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) {
546
+ currentWs.close();
547
+ }
548
+ currentWs = null;
549
+ };
550
+ const getReconnectDelay = () => {
551
+ const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1);
552
+ return RECONNECT_DELAYS[idx];
553
+ };
554
+ const scheduleReconnect = (customDelay) => {
555
+ if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
556
+ log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`);
557
+ return;
558
+ }
559
+ // 取消已有的重连定时器
560
+ if (reconnectTimer) {
561
+ clearTimeout(reconnectTimer);
562
+ reconnectTimer = null;
563
+ }
564
+ const delay = customDelay ?? getReconnectDelay();
565
+ reconnectAttempts++;
566
+ log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
567
+ reconnectTimer = setTimeout(() => {
568
+ reconnectTimer = null;
569
+ if (!isAborted) {
570
+ connect();
571
+ }
572
+ }, delay);
573
+ };
574
+ const connect = async () => {
575
+ // 防止并发连接
576
+ if (isConnecting) {
577
+ log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`);
578
+ return;
579
+ }
580
+ isConnecting = true;
581
+ try {
582
+ cleanup();
583
+ // 如果标记了需要刷新 token,则清除缓存
584
+ if (shouldRefreshToken) {
585
+ log?.info(`[qqbot:${account.accountId}] Refreshing token...`);
586
+ clearTokenCache(account.appId);
587
+ shouldRefreshToken = false;
588
+ }
589
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
590
+ log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
591
+ const gatewayUrl = await getGatewayUrl(accessToken);
592
+ log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
593
+ const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } });
594
+ currentWs = ws;
595
+ const pluginRuntime = getQQBotRuntime();
596
+ // 群历史消息缓存:非@消息写入此 Map,被@时一次性注入上下文后清空
597
+ const groupHistories = new Map();
598
+ // 处理收到的消息
599
+ const handleMessage = async (event) => {
600
+ log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`);
601
+ log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
602
+ if (event.attachments?.length) {
603
+ log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
604
+ }
605
+ pluginRuntime.channel.activity.record({
606
+ channel: "qqbot",
607
+ accountId: account.accountId,
608
+ direction: "inbound",
609
+ });
610
+ // 发送输入状态提示 + 启动自动续期(仅 C2C 私聊有效)
611
+ // refIdx 通过 Promise 延迟获取,在真正需要时再 await
612
+ const isC2C = event.type === "c2c" || event.type === "dm";
613
+ // 用对象包装避免 TS 控制流分析将 null 初始值窄化为 never
614
+ const typing = { keepAlive: null };
615
+ const inputNotifyPromise = (async () => {
616
+ if (!isC2C)
617
+ return undefined;
618
+ try {
619
+ let token = await getAccessToken(account.appId, account.clientSecret);
620
+ try {
621
+ const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, TYPING_INPUT_SECOND);
622
+ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`);
623
+ // 首次成功后启动定时续期
624
+ typing.keepAlive = new TypingKeepAlive(() => getAccessToken(account.appId, account.clientSecret), () => clearTokenCache(account.appId), event.senderId, event.messageId, log, `[qqbot:${account.accountId}]`);
625
+ typing.keepAlive.start();
626
+ return notifyResponse.refIdx;
627
+ }
628
+ catch (notifyErr) {
629
+ const errMsg = String(notifyErr);
630
+ if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
631
+ log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
632
+ clearTokenCache(account.appId);
633
+ token = await getAccessToken(account.appId, account.clientSecret);
634
+ const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, TYPING_INPUT_SECOND);
635
+ typing.keepAlive = new TypingKeepAlive(() => getAccessToken(account.appId, account.clientSecret), () => clearTokenCache(account.appId), event.senderId, event.messageId, log, `[qqbot:${account.accountId}]`);
636
+ typing.keepAlive.start();
637
+ return notifyResponse.refIdx;
638
+ }
639
+ else {
640
+ throw notifyErr;
641
+ }
642
+ }
643
+ }
644
+ catch (err) {
645
+ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
646
+ return undefined;
647
+ }
648
+ })();
649
+ const isGroupChat = event.type === "guild" || event.type === "group";
650
+ // peerId 只放纯 ID,类型信息由 peer.kind 表达
651
+ // 群聊:用 groupOpenid(框架根据 kind:"group" 区分)
652
+ // 私聊:用 senderId(框架根据 dmScope 决定隔离粒度)
653
+ const peerId = event.type === "guild" ? (event.channelId ?? "unknown")
654
+ : event.type === "group" ? (event.groupOpenid ?? "unknown")
655
+ : event.senderId;
656
+ const route = pluginRuntime.channel.routing.resolveAgentRoute({
657
+ cfg,
658
+ channel: "qqbot",
659
+ accountId: account.accountId,
660
+ peer: {
661
+ kind: isGroupChat ? "group" : "direct",
662
+ id: peerId,
663
+ },
664
+ });
665
+ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
666
+ // 组装消息体
667
+ // 静态系统提示已移至 skills/qqbot-remind/SKILL.md 和 skills/qqbot-media/SKILL.md
668
+ // BodyForAgent 只保留必要的动态上下文信息
669
+ // ============ 用户标识信息 ============
670
+ // 收集额外的系统提示(如果配置了账户级别的 systemPrompt)
671
+ const systemPrompts = [];
672
+ if (account.systemPrompt) {
673
+ systemPrompts.push(account.systemPrompt);
674
+ }
675
+ // 处理附件(图片等)- 下载到本地供 openclaw 访问
676
+ const processed = await processAttachments(event.attachments, { appId: account.appId, peerId, cfg, log });
677
+ const { attachmentInfo, imageUrls, imageMediaTypes, voiceAttachmentPaths, voiceAttachmentUrls, voiceAsrReferTexts, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = processed;
678
+ // 语音转录文本注入到用户消息中
679
+ const voiceText = formatVoiceText(voiceTranscripts);
680
+ const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
681
+ // 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
682
+ const parsedContent = parseFaceTags(event.content);
683
+ let userContent = voiceText
684
+ ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
685
+ : parsedContent + attachmentInfo;
686
+ // 统一处理 <@member_openid> → @username / 移除 @bot mention
687
+ if (event.type === "group" && event.mentions?.length) {
688
+ userContent = stripMentionText(userContent, event.mentions) ?? userContent;
689
+ }
690
+ else if (event.mentions?.length) {
691
+ for (const m of event.mentions) {
692
+ if (m.member_openid && m.username) {
693
+ userContent = userContent.replace(new RegExp(`<@${m.member_openid}>`, "g"), `@${m.username}`);
694
+ }
695
+ }
696
+ }
697
+ // ============ 引用消息处理 ============
698
+ let replyToId;
699
+ let replyToBody;
700
+ let replyToSender;
701
+ let replyToIsQuote = false;
702
+ // 引用消息处理:优先使用本地 refIndex 缓存(同步、已处理),缓存未命中时降级到 messageReference
703
+ if (event.refMsgIdx) {
704
+ const refEntry = getRefIndex(event.refMsgIdx);
705
+ replyToId = event.refMsgIdx;
706
+ replyToIsQuote = true;
707
+ if (refEntry) {
708
+ // 缓存命中:直接使用已处理好的内容(同步,无需再下载附件)
709
+ replyToBody = formatRefEntryForAgent(refEntry);
710
+ replyToSender = refEntry.senderName ?? refEntry.senderId;
711
+ log?.info(`[qqbot:${account.accountId}] Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`);
712
+ }
713
+ else if (event.messageReference) {
714
+ // 缓存未命中,降级到 messageReference:需异步下载附件、语音转录、表情解析
715
+ replyToBody = await formatMessageReferenceForAgent(event.messageReference, { appId: account.appId, peerId, cfg, log });
716
+ log?.info(`[qqbot:${account.accountId}] Quote detected via message_reference (cache miss): id=${replyToId}, content="${replyToBody.slice(0, 80)}..."`);
717
+ }
718
+ else {
719
+ // 缓存未命中且无 messageReference:AI 只能知道"用户引用了一条消息"
720
+ log?.info(`[qqbot:${account.accountId}] Quote detected but no cache and no messageReference: refMsgIdx=${event.refMsgIdx}`);
721
+ }
722
+ }
723
+ // 2. 缓存当前消息自身的 msgIdx(供将来被引用时查找)
724
+ // 优先使用推送事件中的 msgIdx(来自 message_scene.ext),否则使用 InputNotify 返回的 refIdx
725
+ // inputNotifyPromise 在这里才 await,此时附件下载等工作已并行完成
726
+ const inputNotifyRefIdx = await inputNotifyPromise;
727
+ const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
728
+ if (currentMsgIdx) {
729
+ const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);
730
+ // 如果有语音转录,把转录文本和来源写入对应附件摘要
731
+ if (attSummaries && voiceTranscripts.length > 0) {
732
+ let voiceIdx = 0;
733
+ for (const att of attSummaries) {
734
+ if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
735
+ att.transcript = voiceTranscripts[voiceIdx];
736
+ // 保存转录来源
737
+ if (voiceIdx < voiceTranscriptSources.length) {
738
+ att.transcriptSource = voiceTranscriptSources[voiceIdx];
739
+ }
740
+ voiceIdx++;
741
+ }
742
+ }
743
+ }
744
+ setRefIndex(currentMsgIdx, {
745
+ content: parsedContent,
746
+ senderId: event.senderId,
747
+ senderName: event.senderName,
748
+ timestamp: new Date(event.timestamp).getTime(),
749
+ attachments: attSummaries,
750
+ });
751
+ log?.info(`[qqbot:${account.accountId}] Cached msgIdx=${currentMsgIdx} for future reference (source: ${event.msgIdx ? "message_scene.ext" : "InputNotify"})`);
752
+ }
753
+ // Body: 展示用的用户原文(Web UI 看到的)
754
+ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
755
+ channel: "qqbot",
756
+ from: event.senderName ?? event.senderId,
757
+ timestamp: new Date(event.timestamp).getTime(),
758
+ body: userContent,
759
+ chatType: isGroupChat ? "group" : "direct",
760
+ sender: {
761
+ id: event.senderId,
762
+ name: event.senderName,
763
+ },
764
+ envelope: envelopeOptions,
765
+ ...(imageUrls.length > 0 ? { imageUrls } : {}),
766
+ });
767
+ // BodyForAgent: AI 实际看到的完整上下文(动态数据 + 系统提示 + 用户输入)
768
+ // 构建媒体附件纯数据描述(图片 + 语音统一列出)
769
+ const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)];
770
+ const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)];
771
+ const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean);
772
+ const sttTranscriptCount = voiceTranscriptSources.filter((s) => s === "stt").length;
773
+ const asrFallbackCount = voiceTranscriptSources.filter((s) => s === "asr").length;
774
+ const fallbackCount = voiceTranscriptSources.filter((s) => s === "fallback").length;
775
+ if (voiceAttachmentPaths.length > 0 || voiceAttachmentUrls.length > 0 || uniqueVoiceAsrReferTexts.length > 0) {
776
+ const asrPreview = uniqueVoiceAsrReferTexts.length > 0
777
+ ? uniqueVoiceAsrReferTexts[0].slice(0, 50)
778
+ : "";
779
+ log?.info(`[qqbot:${account.accountId}] Voice input summary: local=${uniqueVoicePaths.length}, remote=${uniqueVoiceUrls.length}, `
780
+ + `asrReferTexts=${uniqueVoiceAsrReferTexts.length}, transcripts=${voiceTranscripts.length}, `
781
+ + `source(stt/asr/fallback)=${sttTranscriptCount}/${asrFallbackCount}/${fallbackCount}`
782
+ + (asrPreview ? `, asr_preview="${asrPreview}${uniqueVoiceAsrReferTexts[0].length > 50 ? "..." : ""}"` : ""));
783
+ }
784
+ // AI 看到的投递地址必须带完整前缀(qqbot:c2c: / qqbot:group:)
785
+ const qualifiedTarget = isGroupChat ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`;
786
+ // 动态检测 TTS 配置状态
787
+ const hasTTS = !!resolveTTSConfig(cfg);
788
+ // 引用消息上下文
789
+ let quotePart = "";
790
+ if (replyToIsQuote) {
791
+ if (replyToBody) {
792
+ quotePart = `[引用消息开始]\n${replyToBody}\n[引用消息结束]\n`;
793
+ }
794
+ else {
795
+ quotePart = `[引用消息开始]\n原始内容不可用\n[引用消息结束]\n`;
796
+ }
797
+ }
798
+ // ============ 构建 contextInfo(静态/动态分离) ============
799
+ // 设计原则:
800
+ // - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
801
+ // 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
802
+ // 且保证长 session 窗口截断后仍可见。
803
+ // - 动态标签:每条消息变化的数据(时间、附件、ASR),
804
+ // 以紧凑的 [ctx] 块标注在用户消息前,最小化 token 开销。
805
+ // --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
806
+ // 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
807
+ // 投递地址通过 AsyncLocalStorage 请求上下文传递给 remind 工具,无需在 agentBody 中暴露
808
+ const staticParts = [];
809
+ // TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
810
+ // STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
811
+ if (hasTTS)
812
+ staticParts.push("语音合成已启用");
813
+ // 仅在有静态指引时注入 systemPrompts
814
+ if (staticParts.length > 0) {
815
+ const staticInstruction = staticParts.join(" | ");
816
+ systemPrompts.unshift(staticInstruction);
817
+ }
818
+ // --- 动态上下文 ---
819
+ const dynLines = [];
820
+ if (imageUrls.length > 0) {
821
+ dynLines.push(`- 图片: ${imageUrls.join(", ")}`);
822
+ }
823
+ if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
824
+ dynLines.push(`- 语音: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`);
825
+ }
826
+ if (uniqueVoiceAsrReferTexts.length > 0) {
827
+ dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
828
+ }
829
+ const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n\n" : "";
830
+ // --- 命令授权(所有消息类型共用,群消息门控也需要) ---
831
+ // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
832
+ const allowFromList = account.config?.allowFrom ?? [];
833
+ const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry === "*");
834
+ const commandAuthorized = allowAll || allowFromList.some((entry) => entry.toUpperCase() === event.senderId.toUpperCase());
835
+ // --- 群消息上下文:插件只提供策略,框架自动组装 hint ---
836
+ let groupSystemPrompt = "";
837
+ let wasMentioned = false;
838
+ let groupSubject = "";
839
+ let senderLabel = "";
840
+ if (event.type === "group" && event.groupOpenid) {
841
+ // 1. 群策略检查(直接用 config 工具函数,与 Discord 的 allow-list.ts 同理)
842
+ if (!isGroupAllowed(cfg, event.groupOpenid, account.accountId)) {
843
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid} not allowed by groupPolicy, skipping`);
844
+ return;
845
+ }
846
+ // 2. @检测(委托 mentions 适配器)
847
+ const mentionPatternsForDetect = resolveMentionPatterns(cfg, route.agentId);
848
+ wasMentioned = detectWasMentioned({
849
+ eventType: event.eventType,
850
+ mentions: event.mentions,
851
+ content: event.content,
852
+ mentionPatterns: mentionPatternsForDetect,
853
+ });
854
+ // 3. requireMention 门控
855
+ // 优先级:session store 中的 /activation 命令 > 配置文件 requireMention > 默认值
856
+ // 未被 @ 时:消息仍写入上下文(让 bot 拥有完整对话记忆),但不触发 AI 回复
857
+ const configRequireMention = qqbotPlugin.groups?.resolveRequireMention?.({
858
+ cfg: cfg,
859
+ accountId: account.accountId,
860
+ groupId: event.groupOpenid,
861
+ }) ?? true;
862
+ const activation = resolveGroupActivation({
863
+ cfg: cfg,
864
+ agentId: route.agentId,
865
+ sessionKey: route.sessionKey,
866
+ configRequireMention,
867
+ });
868
+ const requireMention = activation === "mention";
869
+ // 4. 隐式 mention:引用回复 bot 的消息视为隐式 mention
870
+ const implicitMention = resolveImplicitMention({
871
+ refMsgIdx: event.refMsgIdx,
872
+ getRefEntry: getRefIndex,
873
+ });
874
+ // 4.5 统一门控:ignoreOtherMentions → shouldBlock → mention 门控
875
+ // 三层判断收敛到 resolveGroupMessageGate()
876
+ const contentForCommand = event.content?.trim() ?? "";
877
+ const allowTextCommands = shouldHandleTextCommands(cfg);
878
+ const gate = resolveGroupMessageGate({
879
+ ignoreOtherMentions: resolveIgnoreOtherMentions(cfg, event.groupOpenid, account.accountId),
880
+ hasAnyMention: hasAnyMention({ mentions: event.mentions, content: event.content }),
881
+ wasMentioned,
882
+ implicitMention,
883
+ allowTextCommands,
884
+ isControlCommand: hasControlCommand(contentForCommand),
885
+ commandAuthorized,
886
+ requireMention,
887
+ canDetectMention: true,
888
+ });
889
+ if (gate.action === "drop_other_mention") {
890
+ // @了其他人但未 @bot:记录历史后丢弃
891
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
892
+ const senderForHistory = event.senderName
893
+ ? `${event.senderName} (${event.senderId})`
894
+ : event.senderId;
895
+ const historyAttachments = toAttachmentSummaries(event.attachments);
896
+ recordPendingHistoryEntry({
897
+ historyMap: groupHistories,
898
+ historyKey: event.groupOpenid,
899
+ limit: historyLimit,
900
+ entry: {
901
+ sender: senderForHistory,
902
+ body: userContent,
903
+ timestamp: new Date(event.timestamp).getTime(),
904
+ messageId: event.messageId,
905
+ attachments: historyAttachments,
906
+ },
907
+ });
908
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: drop message (ignoreOtherMentions=true, other user mentioned, bot not mentioned)`);
909
+ return;
910
+ }
911
+ if (gate.action === "block_unauthorized_command") {
912
+ // 未授权控制命令:静默拦截,不交给 AI
913
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: blocked unauthorized control command from ${event.senderId}: ${contentForCommand.slice(0, 50)}`);
914
+ return;
915
+ }
916
+ if (gate.action === "skip_no_mention") {
917
+ // 非 @bot 消息:记录到群历史缓存后跳过 AI
918
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
919
+ const senderForHistory = event.senderName
920
+ ? `${event.senderName} (${event.senderId})`
921
+ : event.senderId;
922
+ const historyAttachments = toAttachmentSummaries(event.attachments);
923
+ recordPendingHistoryEntry({
924
+ historyMap: groupHistories,
925
+ historyKey: event.groupOpenid,
926
+ limit: historyLimit,
927
+ entry: {
928
+ sender: senderForHistory,
929
+ body: userContent,
930
+ timestamp: new Date(event.timestamp).getTime(),
931
+ messageId: event.messageId,
932
+ attachments: historyAttachments,
933
+ },
934
+ });
935
+ log?.info(`[qqbot:${account.accountId}] Group ${event.groupOpenid}: activation=${activation} (configRequireMention=${configRequireMention}) not mentioned, recorded to history (limit=${historyLimit}, cached=${(groupHistories.get(event.groupOpenid) ?? []).length}${historyAttachments ? `, attachments=${historyAttachments.length}` : ""})`);
936
+ return;
937
+ }
938
+ // gate.action === "pass" — 更新 wasMentioned 为 effectiveWasMentioned(含 implicit + bypass)
939
+ wasMentioned = gate.effectiveWasMentioned;
940
+ // 5. 发送者标签
941
+ senderLabel = event.senderName
942
+ ? `${event.senderName} (${event.senderId})`
943
+ : event.senderId;
944
+ // 6. 群名称(从 config 中读取,fallback 为 openid 前 8 位)
945
+ groupSubject = resolveGroupName(cfg, event.groupOpenid, account.accountId);
946
+ // 7. GroupSystemPrompt — 根据消息来源(机器人/人类)和 @状态 注入差异化 PE
947
+ // 基础提示从 resolveGroupIntroHint 获取(群名称、平台限制等静态信息),
948
+ // 然后根据运行时状态追加针对性行为指引。
949
+ const baseHint = qqbotPlugin.groups?.resolveGroupIntroHint?.({
950
+ cfg: cfg,
951
+ accountId: account.accountId,
952
+ groupId: event.groupOpenid,
953
+ }) ?? "";
954
+ let behaviorPrompt = "";
955
+ // 从配置读取群行为 PE
956
+ behaviorPrompt = resolveGroupPrompt(cfg, event.groupOpenid, account.accountId);
957
+ groupSystemPrompt = [baseHint, behaviorPrompt].filter(Boolean).join("\n");
958
+ }
959
+ const mergedCount = event._mergedCount;
960
+ // 群消息 user prompt 带上发送者昵称(合并消息已内嵌发送者前缀,不再重复添加)
961
+ const isMergedMsg = mergedCount && mergedCount > 1;
962
+ const senderPrefix = (event.type === "group" && !isMergedMsg)
963
+ ? `[${event.senderName ? `${event.senderName} (${event.senderId})` : event.senderId}] `
964
+ : "";
965
+ const isAtYouTag = event.type === "group"
966
+ ? (wasMentioned ? " (@你)" : "")
967
+ : "";
968
+ // 合并消息:前面的消息用 envelope 历史格式,最后一条用当前消息格式(与 mention 单条回复对齐)
969
+ // BodyForAgent 只包含动态上下文 + 用户消息,不拼入 systemPrompts。
970
+ // systemPrompts([QQBot] to=...、TTS 能力声明等)通过 GroupSystemPrompt 注入到
971
+ // 框架的 extraSystemPrompt 中,不会存入 transcript 的 user turn content,
972
+ // 避免 Web UI 不显示用户 query 的问题。
973
+ let userMessage;
974
+ const mergedMessages = event._mergedMessages;
975
+ if (isMergedMsg && mergedMessages?.length) {
976
+ // --- 辅助:格式化单条子消息内容(表情解析 + mention 清理 + 附件标签) ---
977
+ const formatSubMsgContent = (m) => formatMessageContent({
978
+ content: m.content ?? "",
979
+ chatType: m.type,
980
+ mentions: m.mentions,
981
+ attachments: m.attachments,
982
+ parseFaceTags,
983
+ stripMentionText: (text, mentions) => stripMentionText(text, mentions) ?? text,
984
+ });
985
+ // 前面的消息使用 envelope 历史格式
986
+ const preceding = mergedMessages.slice(0, -1);
987
+ const lastMsg = mergedMessages[mergedMessages.length - 1];
988
+ const envelopeParts = preceding.map((m) => {
989
+ const msgContent = formatSubMsgContent(m);
990
+ const senderName = m.senderName
991
+ ? (m.senderName.includes(m.senderId) ? m.senderName : `${m.senderName} (${m.senderId})`)
992
+ : m.senderId;
993
+ return pluginRuntime.channel.reply.formatInboundEnvelope({
994
+ channel: "qqbot",
995
+ from: senderName,
996
+ timestamp: new Date(m.timestamp).getTime(),
997
+ body: msgContent,
998
+ chatType: "group",
999
+ envelope: envelopeOptions,
1000
+ });
1001
+ });
1002
+ // 最后一条消息使用简洁格式:[发送者]: 内容 (@你)
1003
+ const lastContent = formatSubMsgContent(lastMsg);
1004
+ const lastSenderName = lastMsg.senderName
1005
+ ? (lastMsg.senderName.includes(lastMsg.senderId) ? lastMsg.senderName : `${lastMsg.senderName} (${lastMsg.senderId})`)
1006
+ : lastMsg.senderId;
1007
+ const lastPart = `[${lastSenderName}] ${lastContent}${isAtYouTag}`;
1008
+ // 前置消息用段落标签包裹(类似引用消息的 [引用消息开始]...[引用消息结束])
1009
+ userMessage = buildMergedMessageContext({
1010
+ precedingParts: envelopeParts,
1011
+ currentMessage: lastPart,
1012
+ });
1013
+ }
1014
+ else {
1015
+ // 命令直接透传,不注入上下文
1016
+ userMessage = senderPrefix ? `${senderPrefix}${quotePart}${userContent}${isAtYouTag}` : `${quotePart}${userContent}`;
1017
+ }
1018
+ let agentBody = userContent.startsWith("/")
1019
+ ? userContent
1020
+ : `${dynamicCtx}${userMessage}`;
1021
+ // 被@时:将累积的非@历史消息注入上下文
1022
+ // 消息格式使用 formatInboundEnvelope 与正常消息保持一致
1023
+ if (event.type === "group" && event.groupOpenid) {
1024
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
1025
+ const envelopeOpts = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
1026
+ agentBody = buildPendingHistoryContext({
1027
+ historyMap: groupHistories,
1028
+ historyKey: event.groupOpenid,
1029
+ limit: historyLimit,
1030
+ currentMessage: agentBody,
1031
+ formatEntry: (entry) => {
1032
+ // 将附件描述追加到消息 body 末尾,确保富媒体上下文不丢失
1033
+ const attachmentDesc = formatAttachmentTags(entry.attachments);
1034
+ const bodyWithAttachments = attachmentDesc
1035
+ ? `${entry.body} ${attachmentDesc}`
1036
+ : entry.body;
1037
+ return pluginRuntime.channel.reply.formatInboundEnvelope({
1038
+ channel: "qqbot",
1039
+ from: entry.sender,
1040
+ timestamp: entry.timestamp,
1041
+ body: bodyWithAttachments,
1042
+ chatType: "group",
1043
+ envelope: envelopeOpts,
1044
+ });
1045
+ },
1046
+ });
1047
+ }
1048
+ log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
1049
+ const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
1050
+ : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
1051
+ : `qqbot:c2c:${event.senderId}`;
1052
+ const toAddress = fromAddress;
1053
+ // 分离 imageUrls 为本地路径和远程 URL,供 openclaw 原生媒体处理
1054
+ const localMediaPaths = [];
1055
+ const localMediaTypes = [];
1056
+ const remoteMediaUrls = [];
1057
+ const remoteMediaTypes = [];
1058
+ for (let i = 0; i < imageUrls.length; i++) {
1059
+ const u = imageUrls[i];
1060
+ const t = imageMediaTypes[i] ?? "image/png";
1061
+ if (u.startsWith("http://") || u.startsWith("https://")) {
1062
+ remoteMediaUrls.push(u);
1063
+ remoteMediaTypes.push(t);
1064
+ }
1065
+ else {
1066
+ localMediaPaths.push(u);
1067
+ localMediaTypes.push(t);
1068
+ }
1069
+ }
1070
+ // QQBot 静态系统提示(投递地址、TTS 能力等)合并到 GroupSystemPrompt,
1071
+ // 通过框架的 extraSystemPrompt 机制注入 AI system prompt,
1072
+ // 不会存入 transcript 的 user turn content。
1073
+ const qqbotSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : "";
1074
+ const mergedGroupSystemPrompt = [qqbotSystemInstruction, groupSystemPrompt].filter(Boolean).join("\n") || undefined;
1075
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
1076
+ Body: body,
1077
+ BodyForAgent: agentBody,
1078
+ RawBody: event.content,
1079
+ CommandBody: event.content,
1080
+ From: fromAddress,
1081
+ To: toAddress,
1082
+ SessionKey: route.sessionKey,
1083
+ AccountId: route.accountId,
1084
+ ChatType: isGroupChat ? "group" : "direct",
1085
+ GroupSystemPrompt: mergedGroupSystemPrompt,
1086
+ // 群消息元数据(框架级字段)
1087
+ WasMentioned: isGroupChat ? wasMentioned : undefined,
1088
+ SenderLabel: isGroupChat ? senderLabel : undefined,
1089
+ GroupSubject: isGroupChat ? groupSubject : undefined,
1090
+ SenderId: event.senderId,
1091
+ SenderName: event.senderName,
1092
+ Provider: "qqbot",
1093
+ Surface: "qqbot",
1094
+ MessageSid: event.messageId,
1095
+ Timestamp: new Date(event.timestamp).getTime(),
1096
+ OriginatingChannel: "qqbot",
1097
+ OriginatingTo: toAddress,
1098
+ QQChannelId: event.channelId,
1099
+ QQGuildId: event.guildId,
1100
+ QQGroupOpenid: event.groupOpenid,
1101
+ QQVoiceAsrReferAvailable: hasAsrReferFallback,
1102
+ QQVoiceTranscriptSources: voiceTranscriptSources,
1103
+ QQVoiceAttachmentPaths: uniqueVoicePaths,
1104
+ QQVoiceAttachmentUrls: uniqueVoiceUrls,
1105
+ QQVoiceAsrReferTexts: uniqueVoiceAsrReferTexts,
1106
+ QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback",
1107
+ CommandAuthorized: commandAuthorized,
1108
+ // 传递媒体路径和 URL,使 openclaw 原生媒体处理(视觉等)能正常工作
1109
+ ...(localMediaPaths.length > 0 ? {
1110
+ MediaPaths: localMediaPaths,
1111
+ MediaPath: localMediaPaths[0],
1112
+ MediaTypes: localMediaTypes,
1113
+ MediaType: localMediaTypes[0],
1114
+ } : {}),
1115
+ ...(remoteMediaUrls.length > 0 ? {
1116
+ MediaUrls: remoteMediaUrls,
1117
+ MediaUrl: remoteMediaUrls[0],
1118
+ } : {}),
1119
+ // 引用消息上下文
1120
+ ...(replyToId ? {
1121
+ ReplyToId: replyToId,
1122
+ ReplyToBody: replyToBody,
1123
+ ReplyToSender: replyToSender,
1124
+ ReplyToIsQuote: replyToIsQuote,
1125
+ } : {}),
1126
+ });
1127
+ // 构建回复上下文
1128
+ const replyTarget = {
1129
+ type: event.type,
1130
+ senderId: event.senderId,
1131
+ messageId: event.messageId,
1132
+ channelId: event.channelId,
1133
+ groupOpenid: event.groupOpenid,
1134
+ };
1135
+ const replyCtx = { target: replyTarget, account, cfg, log };
1136
+ // 简化的 token 重试包装(使用 reply-dispatcher 的通用实现)
1137
+ const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
1138
+ // 发送错误提示的辅助函数
1139
+ const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
1140
+ // 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
1141
+ // (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
1142
+ await runWithRequestContext({ target: qualifiedTarget, accountId: account.accountId }, async () => {
1143
+ try {
1144
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
1145
+ // 追踪是否有响应
1146
+ let hasResponse = false;
1147
+ let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
1148
+ let toolDeliverCount = 0; // tool deliver 计数
1149
+ const toolTexts = []; // 收集所有 tool deliver 文本
1150
+ const toolMediaUrls = []; // 收集所有 tool deliver 媒体 URL
1151
+ let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
1152
+ const blockDeliveredMediaUrls = new Set(); // block deliver 已处理的 mediaUrl,用于 tool 后到时去重
1153
+ const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
1154
+ const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
1155
+ const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
1156
+ let toolRenewalCount = 0; // 已续期次数
1157
+ let timeoutId = null;
1158
+ let toolOnlyTimeoutId = null;
1159
+ // ============ Deliver Debouncer:合并短时间内连续到达的 block deliver ============
1160
+ const debounceConfig = account.config?.deliverDebounce;
1161
+ let debouncer = null;
1162
+ // tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
1163
+ const sendToolFallback = async () => {
1164
+ // 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
1165
+ if (toolMediaUrls.length > 0) {
1166
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
1167
+ const mediaTimeout = 45000; // 单个媒体发送超时 45s
1168
+ for (const mediaUrl of toolMediaUrls) {
1169
+ try {
1170
+ const result = await Promise.race([
1171
+ sendMediaAuto({
1172
+ to: qualifiedTarget,
1173
+ text: "",
1174
+ mediaUrl,
1175
+ accountId: account.accountId,
1176
+ replyToId: event.messageId,
1177
+ account,
1178
+ }),
1179
+ new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)),
1180
+ ]);
1181
+ if (result.error) {
1182
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
1183
+ }
1184
+ }
1185
+ catch (err) {
1186
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
1187
+ }
1188
+ }
1189
+ return;
1190
+ }
1191
+ // 其次转发工具产出的文本
1192
+ if (toolTexts.length > 0) {
1193
+ const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
1194
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
1195
+ await sendErrorMessage(text);
1196
+ return;
1197
+ }
1198
+ // 既无媒体也无文本,静默处理(仅日志记录)
1199
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
1200
+ };
1201
+ const timeoutPromise = new Promise((_, reject) => {
1202
+ timeoutId = setTimeout(() => {
1203
+ if (!hasResponse) {
1204
+ reject(new Error("Response timeout"));
1205
+ }
1206
+ }, responseTimeout);
1207
+ });
1208
+ // ============ 流式消息控制器 ============
1209
+ const targetType = event.type === "c2c" ? "c2c"
1210
+ : event.type === "group" ? "group"
1211
+ : "channel";
1212
+ const useStreaming = shouldUseStreaming(account, targetType);
1213
+ log?.info(`[qqbot:${account.accountId}] Streaming ${useStreaming ? "enabled" : "disabled"} for ${targetType} message from ${event.senderId}`);
1214
+ let streamingController = null;
1215
+ if (useStreaming) {
1216
+ log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
1217
+ streamingController = new StreamingController({
1218
+ account,
1219
+ userId: event.senderId,
1220
+ replyToMsgId: event.messageId,
1221
+ eventId: event.messageId,
1222
+ logPrefix: `[qqbot:${account.accountId}:streaming]`,
1223
+ log,
1224
+ mediaContext: {
1225
+ account,
1226
+ event: {
1227
+ type: event.type,
1228
+ senderId: event.senderId,
1229
+ messageId: event.messageId,
1230
+ groupOpenid: event.groupOpenid,
1231
+ channelId: event.channelId,
1232
+ },
1233
+ log,
1234
+ },
1235
+ });
1236
+ }
1237
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1238
+ ctx: ctxPayload,
1239
+ cfg,
1240
+ dispatcherOptions: {
1241
+ responsePrefix: messagesConfig.responsePrefix,
1242
+ deliver: async (payload, info) => {
1243
+ hasResponse = true;
1244
+ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
1245
+ // ============ 跳过工具调用的中间结果(带兜底保护) ============
1246
+ if (info.kind === "tool") {
1247
+ toolDeliverCount++;
1248
+ const toolText = (payload.text ?? "").trim();
1249
+ if (toolText) {
1250
+ toolTexts.push(toolText);
1251
+ }
1252
+ // 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
1253
+ if (payload.mediaUrls?.length) {
1254
+ toolMediaUrls.push(...payload.mediaUrls);
1255
+ }
1256
+ if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
1257
+ toolMediaUrls.push(payload.mediaUrl);
1258
+ }
1259
+ log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
1260
+ // block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
1261
+ if (hasBlockResponse && toolMediaUrls.length > 0) {
1262
+ // 去重:跳过已被 block deliver 的 sendPlainReply 处理过的 URL
1263
+ const urlsToSend = toolMediaUrls.filter(url => !blockDeliveredMediaUrls.has(url));
1264
+ const skippedCount = toolMediaUrls.length - urlsToSend.length;
1265
+ toolMediaUrls.length = 0;
1266
+ if (urlsToSend.length === 0) {
1267
+ log?.info(`[qqbot:${account.accountId}] All ${skippedCount} tool media URL(s) already handled by block deliver, skipping`);
1268
+ return;
1269
+ }
1270
+ log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${urlsToSend.length} tool media URL(s) (deduped from block deliver)`);
1271
+ for (const mediaUrl of urlsToSend) {
1272
+ try {
1273
+ const result = await sendMediaAuto({
1274
+ to: qualifiedTarget,
1275
+ text: "",
1276
+ mediaUrl,
1277
+ accountId: account.accountId,
1278
+ replyToId: event.messageId,
1279
+ account,
1280
+ });
1281
+ if (result.error) {
1282
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
1283
+ }
1284
+ else {
1285
+ log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
1286
+ }
1287
+ }
1288
+ catch (err) {
1289
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
1290
+ }
1291
+ }
1292
+ return;
1293
+ }
1294
+ // 兜底已发送,不再续期
1295
+ if (toolFallbackSent) {
1296
+ return;
1297
+ }
1298
+ // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
1299
+ // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
1300
+ if (toolOnlyTimeoutId) {
1301
+ if (toolRenewalCount < maxToolRenewals) {
1302
+ clearTimeout(toolOnlyTimeoutId);
1303
+ toolRenewalCount++;
1304
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
1305
+ }
1306
+ else {
1307
+ // 已达续期上限,不再重置,等定时器自然触发兜底
1308
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
1309
+ return;
1310
+ }
1311
+ }
1312
+ toolOnlyTimeoutId = setTimeout(async () => {
1313
+ if (!hasBlockResponse && !toolFallbackSent) {
1314
+ toolFallbackSent = true;
1315
+ log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
1316
+ try {
1317
+ await sendToolFallback();
1318
+ }
1319
+ catch (sendErr) {
1320
+ log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
1321
+ }
1322
+ }
1323
+ }, toolOnlyTimeout);
1324
+ return;
1325
+ }
1326
+ // 收到 block 回复,清除所有超时定时器
1327
+ hasBlockResponse = true;
1328
+ // 收到真正回复,立即停止输入状态续期(让 "输入中" 尽快消失)
1329
+ typing.keepAlive?.stop();
1330
+ if (timeoutId) {
1331
+ clearTimeout(timeoutId);
1332
+ timeoutId = null;
1333
+ }
1334
+ if (toolOnlyTimeoutId) {
1335
+ clearTimeout(toolOnlyTimeoutId);
1336
+ toolOnlyTimeoutId = null;
1337
+ }
1338
+ if (toolDeliverCount > 0) {
1339
+ log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
1340
+ }
1341
+ // ============ 流式模式处理 ============
1342
+ // 流式模式下,所有 block deliver 内容(含媒体标签)统一交由 StreamingController 处理。
1343
+ // StreamingController 内部有重试机制;如果一个分片都没发出去则降级到普通消息。
1344
+ if (streamingController && !streamingController.isTerminalPhase) {
1345
+ const deliverTextLen = (payload.text ?? "").length;
1346
+ const deliverPreview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
1347
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver entry, textLen=${deliverTextLen}, phase=${streamingController.currentPhase}, sentChunks=${streamingController.sentChunkCount_debug}, preview="${deliverPreview}"`);
1348
+ try {
1349
+ await streamingController.onDeliver(payload);
1350
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver done, phase=${streamingController.currentPhase}`);
1351
+ }
1352
+ catch (err) {
1353
+ // StreamingController 内部已有重试,这里只打日志
1354
+ log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
1355
+ }
1356
+ let replyText = payload.text ?? "";
1357
+ // 群消息:模型回复 NO_REPLY 表示无需回复,跳过发送
1358
+ // 注意:核心框架的 reply-delivery 已会拦截 NO_REPLY,此处为双重保险
1359
+ const trimmedReply = replyText.trim();
1360
+ if (event.type === "group" && (trimmedReply === "NO_REPLY" || trimmedReply === "[SKIP]")) {
1361
+ log?.info(`[qqbot:${account.accountId}] Model decided to skip group message (token=${trimmedReply}) from ${event.senderId}: ${event.content?.slice(0, 50)}`);
1362
+ return;
1363
+ }
1364
+ // 检查是否因流式 API 不可用而需要降级(ensureStreamingStarted 全部失败)
1365
+ // 如果需要降级,不 return,让本次 deliver 的 payload.text(全量文本)继续走普通发送逻辑
1366
+ if (streamingController.shouldFallbackToStatic) {
1367
+ log?.info(`[qqbot:${account.accountId}] Streaming API unavailable, falling back to static for this deliver`);
1368
+ // 不 return,继续走普通发送逻辑(payload.text 是完整文本)
1369
+ }
1370
+ else {
1371
+ // 流式正常处理,不走普通发送逻辑
1372
+ pluginRuntime.channel.activity.record({
1373
+ channel: "qqbot",
1374
+ accountId: account.accountId,
1375
+ direction: "outbound",
1376
+ });
1377
+ return;
1378
+ }
1379
+ }
1380
+ // ============ 实际发送逻辑(可被 debouncer 包裹) ============
1381
+ const executeDeliver = async (deliverPayload, _deliverInfo) => {
1382
+ // ============ 引用回复 ============
1383
+ const quoteRef = event.msgIdx;
1384
+ let quoteRefUsed = false;
1385
+ const consumeQuoteRef = () => {
1386
+ if (quoteRef && !quoteRefUsed) {
1387
+ quoteRefUsed = true;
1388
+ return quoteRef;
1389
+ }
1390
+ return undefined;
1391
+ };
1392
+ let replyText = deliverPayload.text ?? "";
1393
+ // ============ 媒体标签解析 + 发送 ============
1394
+ const deliverEvent = {
1395
+ type: event.type,
1396
+ senderId: event.senderId,
1397
+ messageId: event.messageId,
1398
+ channelId: event.channelId,
1399
+ groupOpenid: event.groupOpenid,
1400
+ msgIdx: event.msgIdx,
1401
+ };
1402
+ const deliverActx = { account, qualifiedTarget, log };
1403
+ const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef);
1404
+ if (mediaResult.handled) {
1405
+ pluginRuntime.channel.activity.record({
1406
+ channel: "qqbot",
1407
+ accountId: account.accountId,
1408
+ direction: "outbound",
1409
+ });
1410
+ return;
1411
+ }
1412
+ replyText = mediaResult.normalizedText;
1413
+ // ============ 结构化载荷检测与分发 ============
1414
+ const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
1415
+ channel: "qqbot",
1416
+ accountId: account.accountId,
1417
+ direction: "outbound",
1418
+ });
1419
+ const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
1420
+ if (handled)
1421
+ return;
1422
+ // ============ 非结构化消息发送 ============
1423
+ // 记录 block deliver 处理的 mediaUrl,供 tool 后到时去重
1424
+ if (deliverPayload.mediaUrl)
1425
+ blockDeliveredMediaUrls.add(deliverPayload.mediaUrl);
1426
+ if (deliverPayload.mediaUrls)
1427
+ for (const u of deliverPayload.mediaUrls)
1428
+ blockDeliveredMediaUrls.add(u);
1429
+ await sendPlainReply(deliverPayload, replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, toolMediaUrls);
1430
+ pluginRuntime.channel.activity.record({
1431
+ channel: "qqbot",
1432
+ accountId: account.accountId,
1433
+ direction: "outbound",
1434
+ });
1435
+ };
1436
+ // ============ Debounce 合并回复 ============
1437
+ if (!debouncer) {
1438
+ debouncer = createDeliverDebouncer(debounceConfig, executeDeliver, log, `[qqbot:${account.accountId}:debounce]`);
1439
+ }
1440
+ if (debouncer) {
1441
+ await debouncer.deliver(payload, info);
1442
+ }
1443
+ else {
1444
+ await executeDeliver(payload, info);
1445
+ }
1446
+ },
1447
+ onError: async (err) => {
1448
+ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
1449
+ hasResponse = true;
1450
+ if (timeoutId) {
1451
+ clearTimeout(timeoutId);
1452
+ timeoutId = null;
1453
+ }
1454
+ // 流式模式:委托给 streaming controller 处理错误
1455
+ if (streamingController && !streamingController.isTerminalPhase) {
1456
+ try {
1457
+ await streamingController.onError(err);
1458
+ }
1459
+ catch (streamErr) {
1460
+ log?.error(`[qqbot:${account.accountId}] Streaming onError failed: ${streamErr}`);
1461
+ }
1462
+ // 如果 onError 中因无分片发出而降级,不 return,走普通错误处理
1463
+ if (streamingController.shouldFallbackToStatic) {
1464
+ log?.info(`[qqbot:${account.accountId}] Streaming onError: no chunk sent, falling back to static error handling`);
1465
+ // 不 return,继续走普通错误处理
1466
+ }
1467
+ else {
1468
+ return;
1469
+ }
1470
+ }
1471
+ const errMsg = String(err);
1472
+ // 兼容 openclaw 3.23+ 的 plugin-sdk/root-alias.cjs 模块解析失败
1473
+ if (errMsg.includes("Unable to resolve plugin runtime module") || errMsg.includes("root-alias.cjs")) {
1474
+ log?.error(`[qqbot:${account.accountId}] ⚠️ openclaw 框架 runtime 模块解析失败,可能是 openclaw 版本与 plugin-sdk 不兼容。请尝试: npm install -g openclaw@latest && openclaw gateway restart`);
1475
+ await sendErrorMessage("⚠️ AI 服务暂时不可用:openclaw 框架运行时模块加载失败。\n\n请管理员执行:\nnpm install -g openclaw@latest\nopenclaw gateway restart\n\n斜杠命令(如 /bot-ping)不受影响。");
1476
+ return;
1477
+ }
1478
+ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
1479
+ log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
1480
+ }
1481
+ else {
1482
+ log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
1483
+ }
1484
+ },
1485
+ },
1486
+ replyOptions: {
1487
+ // 流式模式时禁用 block streaming
1488
+ disableBlockStreaming: !useStreaming,
1489
+ // 流式模式下注册 onPartialReply 回调,接收流式文本增量
1490
+ ...(streamingController ? {
1491
+ onPartialReply: async (payload) => {
1492
+ const textLen = payload.text?.length ?? 0;
1493
+ const preview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
1494
+ log?.debug?.(`[qqbot:${account.accountId}] onPartialReply called, textLen=${textLen}, phase=${streamingController.currentPhase}, isTerminal=${streamingController.isTerminalPhase}, preview="${preview}"`);
1495
+ try {
1496
+ await streamingController.onPartialReply(payload);
1497
+ log?.debug?.(`[qqbot:${account.accountId}] onPartialReply done, phase=${streamingController.currentPhase}`);
1498
+ }
1499
+ catch (err) {
1500
+ // StreamingController 内部已有重试,这里只打日志
1501
+ log?.error(`[qqbot:${account.accountId}] Streaming onPartialReply error: ${err}`);
1502
+ }
1503
+ },
1504
+ } : {}),
1505
+ },
1506
+ });
1507
+ // 等待分发完成或超时
1508
+ try {
1509
+ await Promise.race([dispatchPromise, timeoutPromise]);
1510
+ }
1511
+ catch (err) {
1512
+ if (timeoutId) {
1513
+ clearTimeout(timeoutId);
1514
+ }
1515
+ log?.error(`[qqbot:${account.accountId}] Dispatch failed: ${err}${!hasResponse ? " (no response received)" : ""}`);
1516
+ }
1517
+ finally {
1518
+ // 清理 tool-only 兜底定时器
1519
+ if (toolOnlyTimeoutId) {
1520
+ clearTimeout(toolOnlyTimeoutId);
1521
+ toolOnlyTimeoutId = null;
1522
+ }
1523
+ // dispatch 完成后,如果只有 tool 没有 block,且尚未发过兜底,立即兜底
1524
+ if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
1525
+ toolFallbackSent = true;
1526
+ log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
1527
+ await sendToolFallback();
1528
+ }
1529
+ // 销毁 debouncer,flush 剩余缓冲的文本
1530
+ if (debouncer) {
1531
+ await debouncer.dispose();
1532
+ debouncer = null;
1533
+ }
1534
+ // ============ 流式消息收尾 ============
1535
+ // dispatch 完成后,标记流式控制器已完成并触发 onIdle(发送终结分片)
1536
+ if (streamingController && !streamingController.isTerminalPhase) {
1537
+ try {
1538
+ streamingController.markFullyComplete();
1539
+ await streamingController.onIdle();
1540
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming controller finalized`);
1541
+ }
1542
+ catch (err) {
1543
+ log?.error(`[qqbot:${account.accountId}] Streaming finalization error: ${err}`);
1544
+ // 尝试中止
1545
+ try {
1546
+ await streamingController.abortStreaming();
1547
+ }
1548
+ catch { /* ignore */ }
1549
+ }
1550
+ }
1551
+ // ============ 流式降级到非流式 ============
1552
+ // 无需额外处理:如果流式 API 不可用(shouldFallbackToStatic),
1553
+ // deliver 回调中已自动跳过流式拦截,走普通消息发送逻辑。
1554
+ // (每次 deliver 收到的都是全量文本,不需要在 controller 内部保存累积文本)
1555
+ if (streamingController?.shouldFallbackToStatic) {
1556
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
1557
+ }
1558
+ // 回复完成后清空群历史缓存(每次回复后重新累积)
1559
+ if (event.type === "group" && event.groupOpenid) {
1560
+ const historyLimit = resolveHistoryLimit(cfg, event.groupOpenid, account.accountId);
1561
+ clearPendingHistory({
1562
+ historyMap: groupHistories,
1563
+ historyKey: event.groupOpenid,
1564
+ limit: historyLimit,
1565
+ });
1566
+ }
1567
+ }
1568
+ }
1569
+ catch (err) {
1570
+ const errStr = String(err);
1571
+ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
1572
+ // 兼容 openclaw 3.23+ runtime 模块解析失败:给用户发可操作的提示
1573
+ if (errStr.includes("Unable to resolve plugin runtime module") || errStr.includes("root-alias.cjs")) {
1574
+ try {
1575
+ await sendErrorMessage("⚠️ AI 服务暂时不可用:openclaw 框架运行时模块加载失败。\n\n请管理员执行:\nnpm install -g openclaw@latest\nopenclaw gateway restart\n\n斜杠命令(如 /bot-ping)不受影响。");
1576
+ }
1577
+ catch { /* best-effort */ }
1578
+ }
1579
+ }
1580
+ finally {
1581
+ // 无论成功/失败/超时,都停止输入状态续期
1582
+ typing.keepAlive?.stop();
1583
+ }
1584
+ }); // end runWithRequestContext
1585
+ };
1586
+ ws.on("open", () => {
1587
+ log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
1588
+ isConnecting = false; // 连接完成,释放锁
1589
+ reconnectAttempts = 0; // 连接成功,重置重试计数
1590
+ lastConnectTime = Date.now(); // 记录连接时间
1591
+ // 启动消息处理器(异步处理,防止阻塞心跳)
1592
+ msgQueue.startProcessor(handleMessage);
1593
+ // P1-1: 启动后台 Token 刷新
1594
+ startBackgroundTokenRefresh(account.appId, account.clientSecret, {
1595
+ log: log,
1596
+ });
1597
+ });
1598
+ ws.on("message", async (data) => {
1599
+ try {
1600
+ const rawData = data.toString();
1601
+ const payload = JSON.parse(rawData);
1602
+ const { op, d, s, t } = payload;
1603
+ if (s) {
1604
+ lastSeq = s;
1605
+ // P1-2: 更新持久化存储中的 lastSeq(节流保存)
1606
+ if (sessionId) {
1607
+ saveSession({
1608
+ sessionId,
1609
+ lastSeq,
1610
+ lastConnectedAt: lastConnectTime,
1611
+ intentLevelIndex: 0,
1612
+ accountId: account.accountId,
1613
+ savedAt: Date.now(),
1614
+ appId: account.appId,
1615
+ });
1616
+ }
1617
+ }
1618
+ log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
1619
+ switch (op) {
1620
+ case 10: // Hello
1621
+ log?.info(`[qqbot:${account.accountId}] Hello received`);
1622
+ // 如果有 session_id,尝试 Resume
1623
+ if (sessionId && lastSeq !== null) {
1624
+ log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`);
1625
+ ws.send(JSON.stringify({
1626
+ op: 6, // Resume
1627
+ d: {
1628
+ token: `QQBot ${accessToken}`,
1629
+ session_id: sessionId,
1630
+ seq: lastSeq,
1631
+ },
1632
+ }));
1633
+ }
1634
+ else {
1635
+ // 新连接,发送 Identify,始终使用完整权限
1636
+ log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${FULL_INTENTS} (${FULL_INTENTS_DESC})`);
1637
+ ws.send(JSON.stringify({
1638
+ op: 2,
1639
+ d: {
1640
+ token: `QQBot ${accessToken}`,
1641
+ intents: FULL_INTENTS,
1642
+ shard: [0, 1],
1643
+ },
1644
+ }));
1645
+ }
1646
+ // 启动心跳
1647
+ const interval = d.heartbeat_interval;
1648
+ if (heartbeatInterval)
1649
+ clearInterval(heartbeatInterval);
1650
+ heartbeatInterval = setInterval(() => {
1651
+ if (ws.readyState === WebSocket.OPEN) {
1652
+ ws.send(JSON.stringify({ op: 1, d: lastSeq }));
1653
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`);
1654
+ }
1655
+ }, interval);
1656
+ break;
1657
+ case 0: // Dispatch
1658
+ log?.info(`[qqbot:${account.accountId}] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
1659
+ if (t === "READY") {
1660
+ const readyData = d;
1661
+ sessionId = readyData.session_id;
1662
+ log?.info(`[qqbot:${account.accountId}] Ready with ${FULL_INTENTS_DESC}, session: ${sessionId}`);
1663
+ // P1-2: 保存新的 Session 状态
1664
+ saveSession({
1665
+ sessionId,
1666
+ lastSeq,
1667
+ lastConnectedAt: Date.now(),
1668
+ intentLevelIndex: 0,
1669
+ accountId: account.accountId,
1670
+ savedAt: Date.now(),
1671
+ appId: account.appId,
1672
+ });
1673
+ onReady?.(d);
1674
+ // 仅 startGateway 后的首次 READY 才发送上线通知
1675
+ // ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
1676
+ if (!_pendingFirstReady.has(account.accountId)) {
1677
+ log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
1678
+ }
1679
+ else {
1680
+ _pendingFirstReady.delete(account.accountId);
1681
+ sendStartupGreetings(adminCtx, "READY");
1682
+ } // end isFirstReady
1683
+ }
1684
+ else if (t === "RESUMED") {
1685
+ log?.info(`[qqbot:${account.accountId}] Session resumed`);
1686
+ onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
1687
+ // RESUMED 也属于首次启动(gateway restart 通常走 resume)
1688
+ if (_pendingFirstReady.has(account.accountId)) {
1689
+ _pendingFirstReady.delete(account.accountId);
1690
+ sendStartupGreetings(adminCtx, "RESUMED");
1691
+ }
1692
+ // P1-2: 更新 Session 连接时间
1693
+ if (sessionId) {
1694
+ saveSession({
1695
+ sessionId,
1696
+ lastSeq,
1697
+ lastConnectedAt: Date.now(),
1698
+ intentLevelIndex: 0,
1699
+ accountId: account.accountId,
1700
+ savedAt: Date.now(),
1701
+ appId: account.appId,
1702
+ });
1703
+ }
1704
+ }
1705
+ else if (t === "C2C_MESSAGE_CREATE") {
1706
+ const event = d;
1707
+ // P1-3: 记录已知用户
1708
+ recordKnownUser({
1709
+ openid: event.author.user_openid,
1710
+ type: "c2c",
1711
+ accountId: account.accountId,
1712
+ });
1713
+ // 解析引用索引
1714
+ const c2cRefs = parseRefIndices(event.message_scene?.ext, event.message_reference);
1715
+ // 斜杠指令拦截 → 不匹配则入队
1716
+ trySlashCommandOrEnqueue({
1717
+ type: "c2c",
1718
+ senderId: event.author.user_openid,
1719
+ content: event.content,
1720
+ messageId: event.id,
1721
+ timestamp: event.timestamp,
1722
+ attachments: event.attachments,
1723
+ refMsgIdx: c2cRefs.refMsgIdx,
1724
+ msgIdx: c2cRefs.msgIdx,
1725
+ messageReference: event.message_reference,
1726
+ });
1727
+ }
1728
+ else if (t === "AT_MESSAGE_CREATE") {
1729
+ const event = d;
1730
+ // P1-3: 记录已知用户(频道用户)
1731
+ recordKnownUser({
1732
+ openid: event.author.id,
1733
+ type: "c2c", // 频道用户按 c2c 类型存储
1734
+ nickname: event.author.username,
1735
+ accountId: account.accountId,
1736
+ });
1737
+ const guildRefs = parseRefIndices(event.message_scene?.ext, event.message_reference);
1738
+ trySlashCommandOrEnqueue({
1739
+ type: "guild",
1740
+ senderId: event.author.id,
1741
+ senderName: event.author.username,
1742
+ content: event.content,
1743
+ messageId: event.id,
1744
+ timestamp: event.timestamp,
1745
+ channelId: event.channel_id,
1746
+ guildId: event.guild_id,
1747
+ attachments: event.attachments,
1748
+ refMsgIdx: guildRefs.refMsgIdx,
1749
+ msgIdx: guildRefs.msgIdx,
1750
+ });
1751
+ }
1752
+ else if (t === "DIRECT_MESSAGE_CREATE") {
1753
+ const event = d;
1754
+ // P1-3: 记录已知用户(频道私信用户)
1755
+ recordKnownUser({
1756
+ openid: event.author.id,
1757
+ type: "c2c",
1758
+ nickname: event.author.username,
1759
+ accountId: account.accountId,
1760
+ });
1761
+ const dmRefs = parseRefIndices(event.message_scene?.ext, event.message_reference);
1762
+ trySlashCommandOrEnqueue({
1763
+ type: "dm",
1764
+ senderId: event.author.id,
1765
+ senderName: event.author.username,
1766
+ content: event.content,
1767
+ messageId: event.id,
1768
+ timestamp: event.timestamp,
1769
+ guildId: event.guild_id,
1770
+ attachments: event.attachments,
1771
+ refMsgIdx: dmRefs.refMsgIdx,
1772
+ msgIdx: dmRefs.msgIdx,
1773
+ });
1774
+ }
1775
+ else if (t === "GROUP_AT_MESSAGE_CREATE") {
1776
+ const event = d;
1777
+ // 被 @ 的消息,直接入队回复
1778
+ recordKnownUser({
1779
+ openid: event.author.member_openid,
1780
+ type: "group",
1781
+ nickname: event.author.username,
1782
+ groupOpenid: event.group_openid,
1783
+ accountId: account.accountId,
1784
+ });
1785
+ const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_reference);
1786
+ trySlashCommandOrEnqueue({
1787
+ type: "group",
1788
+ senderId: event.author.member_openid,
1789
+ senderName: event.author.username,
1790
+ content: event.content,
1791
+ messageId: event.id,
1792
+ timestamp: event.timestamp,
1793
+ groupOpenid: event.group_openid,
1794
+ attachments: event.attachments,
1795
+ refMsgIdx: groupRefs.refMsgIdx,
1796
+ msgIdx: groupRefs.msgIdx,
1797
+ eventType: "GROUP_AT_MESSAGE_CREATE",
1798
+ mentions: event.mentions,
1799
+ messageScene: event.message_scene,
1800
+ messageReference: event.message_reference,
1801
+ });
1802
+ }
1803
+ else if (t === "GROUP_MESSAGE_CREATE") {
1804
+ const event = d;
1805
+ recordKnownUser({
1806
+ openid: event.author.member_openid,
1807
+ type: "group",
1808
+ nickname: event.author.username,
1809
+ groupOpenid: event.group_openid,
1810
+ accountId: account.accountId,
1811
+ });
1812
+ const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_reference);
1813
+ trySlashCommandOrEnqueue({
1814
+ type: "group",
1815
+ senderId: event.author.member_openid,
1816
+ senderName: event.author.username,
1817
+ senderIsBot: event.author.bot,
1818
+ content: event.content,
1819
+ messageId: event.id,
1820
+ timestamp: event.timestamp,
1821
+ groupOpenid: event.group_openid,
1822
+ attachments: event.attachments,
1823
+ refMsgIdx: groupRefs.refMsgIdx,
1824
+ msgIdx: groupRefs.msgIdx,
1825
+ eventType: "GROUP_MESSAGE_CREATE",
1826
+ mentions: event.mentions,
1827
+ messageScene: event.message_scene,
1828
+ messageReference: event.message_reference,
1829
+ });
1830
+ }
1831
+ else if (t === "GROUP_ADD_ROBOT") {
1832
+ const event = d;
1833
+ log?.info(`[qqbot:${account.accountId}] Bot added to group: ${event.group_openid} by ${event.op_member_openid}`);
1834
+ recordKnownUser({
1835
+ openid: event.op_member_openid,
1836
+ type: "group",
1837
+ groupOpenid: event.group_openid,
1838
+ accountId: account.accountId,
1839
+ });
1840
+ }
1841
+ else if (t === "GROUP_DEL_ROBOT") {
1842
+ const event = d;
1843
+ log?.info(`[qqbot:${account.accountId}] Bot removed from group: ${event.group_openid} by ${event.op_member_openid}`);
1844
+ }
1845
+ else if (t === "GROUP_MSG_REJECT") {
1846
+ const event = d;
1847
+ log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} rejected bot proactive messages (by ${event.op_member_openid})`);
1848
+ }
1849
+ else if (t === "GROUP_MSG_RECEIVE") {
1850
+ const event = d;
1851
+ log?.info(`[qqbot:${account.accountId}] Group ${event.group_openid} accepted bot proactive messages (by ${event.op_member_openid})`);
1852
+ }
1853
+ else if (t === "INTERACTION_CREATE") {
1854
+ const event = d;
1855
+ const resolved = event.data?.resolved;
1856
+ const sceneDesc = event.scene ?? (event.chat_type === 0 ? "guild" : event.chat_type === 1 ? "group" : "c2c");
1857
+ log?.info(`[qqbot:${account.accountId}] Interaction: scene=${sceneDesc}, type=${event.data?.type}, button_id=${resolved?.button_id}, button_data=${resolved?.button_data}, user=${event.group_member_openid || event.user_openid || resolved?.user_id || "unknown"}`);
1858
+ handleInteractionCreate({ event, account, cfg, log }).catch((err) => {
1859
+ log?.error(`[qqbot:${account.accountId}] Failed to handle interaction ${event.id}: ${err}`);
1860
+ });
1861
+ }
1862
+ break;
1863
+ case 11: // Heartbeat ACK
1864
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
1865
+ break;
1866
+ case 7: // Reconnect
1867
+ log?.info(`[qqbot:${account.accountId}] Server requested reconnect`);
1868
+ cleanup();
1869
+ scheduleReconnect();
1870
+ break;
1871
+ case 9: // Invalid Session
1872
+ const canResume = d;
1873
+ log?.error(`[qqbot:${account.accountId}] Invalid session (${FULL_INTENTS_DESC}), can resume: ${canResume}, raw: ${rawData}`);
1874
+ if (!canResume) {
1875
+ sessionId = null;
1876
+ lastSeq = null;
1877
+ // P1-2: 清除持久化的 Session
1878
+ clearSession(account.accountId);
1879
+ shouldRefreshToken = true;
1880
+ log?.info(`[qqbot:${account.accountId}] Will refresh token and retry with full intents (${FULL_INTENTS_DESC})`);
1881
+ }
1882
+ cleanup();
1883
+ // Invalid Session 后等待一段时间再重连
1884
+ scheduleReconnect(3000);
1885
+ break;
1886
+ }
1887
+ }
1888
+ catch (err) {
1889
+ log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
1890
+ }
1891
+ });
1892
+ ws.on("close", (code, reason) => {
1893
+ log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
1894
+ isConnecting = false; // 释放锁
1895
+ // 根据错误码处理(见 QQ 官方文档)
1896
+ // 4004: CODE_INVALID_TOKEN - Token 无效,需刷新 token 重新连接
1897
+ // 4006: CODE_SESSION_NO_LONGER_VALID - 会话失效,需重新 identify
1898
+ // 4007: CODE_INVALID_SEQ - Resume 时 seq 无效,需重新 identify
1899
+ // 4008: CODE_RATE_LIMITED - 限流断开,等待后重连
1900
+ // 4009: CODE_SESSION_TIMED_OUT - 会话超时,需重新 identify
1901
+ // 4900-4913: 内部错误,需要重新 identify
1902
+ // 4914: 机器人已下架
1903
+ // 4915: 机器人已封禁
1904
+ if (code === 4914 || code === 4915) {
1905
+ log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`);
1906
+ cleanup();
1907
+ // 不重连,直接退出
1908
+ return;
1909
+ }
1910
+ // 4004: Token 无效,强制刷新 token 后重连
1911
+ if (code === 4004) {
1912
+ log?.info(`[qqbot:${account.accountId}] Invalid token (4004), will refresh token and reconnect`);
1913
+ shouldRefreshToken = true;
1914
+ cleanup();
1915
+ if (!isAborted) {
1916
+ scheduleReconnect();
1917
+ }
1918
+ return;
1919
+ }
1920
+ // 4008: 限流断开,等待后重连(不需要重新 identify)
1921
+ if (code === 4008) {
1922
+ log?.info(`[qqbot:${account.accountId}] Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms before reconnect`);
1923
+ cleanup();
1924
+ if (!isAborted) {
1925
+ scheduleReconnect(RATE_LIMIT_DELAY);
1926
+ }
1927
+ return;
1928
+ }
1929
+ // 4006/4007/4009: 会话失效或超时,需要清除 session 重新 identify
1930
+ if (code === 4006 || code === 4007 || code === 4009) {
1931
+ const codeDesc = {
1932
+ 4006: "session no longer valid",
1933
+ 4007: "invalid seq on resume",
1934
+ 4009: "session timed out",
1935
+ };
1936
+ log?.info(`[qqbot:${account.accountId}] Error ${code} (${codeDesc[code]}), will re-identify`);
1937
+ sessionId = null;
1938
+ lastSeq = null;
1939
+ // 清除持久化的 Session
1940
+ clearSession(account.accountId);
1941
+ shouldRefreshToken = true;
1942
+ }
1943
+ else if (code >= 4900 && code <= 4913) {
1944
+ // 4900-4913 内部错误,清除 session 重新 identify
1945
+ log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`);
1946
+ sessionId = null;
1947
+ lastSeq = null;
1948
+ // 清除持久化的 Session
1949
+ clearSession(account.accountId);
1950
+ shouldRefreshToken = true;
1951
+ }
1952
+ // 检测是否是快速断开(连接后很快就断了)
1953
+ const connectionDuration = Date.now() - lastConnectTime;
1954
+ if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) {
1955
+ quickDisconnectCount++;
1956
+ log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
1957
+ // 如果连续快速断开超过阈值,等待更长时间
1958
+ if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
1959
+ log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`);
1960
+ log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`);
1961
+ quickDisconnectCount = 0;
1962
+ cleanup();
1963
+ // 快速断开太多次,等待更长时间再重连
1964
+ if (!isAborted && code !== 1000) {
1965
+ scheduleReconnect(RATE_LIMIT_DELAY);
1966
+ }
1967
+ return;
1968
+ }
1969
+ }
1970
+ else {
1971
+ // 连接持续时间够长,重置计数
1972
+ quickDisconnectCount = 0;
1973
+ }
1974
+ cleanup();
1975
+ // 非正常关闭则重连
1976
+ if (!isAborted && code !== 1000) {
1977
+ scheduleReconnect();
1978
+ }
1979
+ });
1980
+ ws.on("error", (err) => {
1981
+ log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
1982
+ onError?.(err);
1983
+ });
1984
+ }
1985
+ catch (err) {
1986
+ isConnecting = false; // 释放锁
1987
+ const errMsg = String(err);
1988
+ log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`);
1989
+ // 如果是频率限制错误,等待更长时间
1990
+ if (errMsg.includes("Too many requests") || errMsg.includes("100001")) {
1991
+ log?.info(`[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`);
1992
+ scheduleReconnect(RATE_LIMIT_DELAY);
1993
+ }
1994
+ else {
1995
+ scheduleReconnect();
1996
+ }
1997
+ }
1998
+ };
1999
+ // 开始连接
2000
+ await connect();
2001
+ // 等待 abort 信号
2002
+ return new Promise((resolve) => {
2003
+ abortSignal.addEventListener("abort", () => resolve());
2004
+ });
2005
+ }