@ryantest/openclaw-qqbot 0.0.1

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 (197) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +483 -0
  3. package/README.zh.md +478 -0
  4. package/bin/qqbot-cli.js +243 -0
  5. package/clawdbot.plugin.json +16 -0
  6. package/dist/index.d.ts +17 -0
  7. package/dist/index.js +26 -0
  8. package/dist/src/admin-resolver.d.ts +27 -0
  9. package/dist/src/admin-resolver.js +122 -0
  10. package/dist/src/api.d.ts +156 -0
  11. package/dist/src/api.js +599 -0
  12. package/dist/src/channel.d.ts +11 -0
  13. package/dist/src/channel.js +354 -0
  14. package/dist/src/config.d.ts +25 -0
  15. package/dist/src/config.js +161 -0
  16. package/dist/src/credential-backup.d.ts +31 -0
  17. package/dist/src/credential-backup.js +66 -0
  18. package/dist/src/gateway.d.ts +18 -0
  19. package/dist/src/gateway.js +1265 -0
  20. package/dist/src/image-server.d.ts +68 -0
  21. package/dist/src/image-server.js +462 -0
  22. package/dist/src/inbound-attachments.d.ts +58 -0
  23. package/dist/src/inbound-attachments.js +234 -0
  24. package/dist/src/known-users.d.ts +100 -0
  25. package/dist/src/known-users.js +263 -0
  26. package/dist/src/message-queue.d.ts +50 -0
  27. package/dist/src/message-queue.js +115 -0
  28. package/dist/src/onboarding.d.ts +10 -0
  29. package/dist/src/onboarding.js +203 -0
  30. package/dist/src/outbound-deliver.d.ts +48 -0
  31. package/dist/src/outbound-deliver.js +462 -0
  32. package/dist/src/outbound.d.ts +203 -0
  33. package/dist/src/outbound.js +1102 -0
  34. package/dist/src/proactive.d.ts +170 -0
  35. package/dist/src/proactive.js +399 -0
  36. package/dist/src/ref-index-store.d.ts +70 -0
  37. package/dist/src/ref-index-store.js +273 -0
  38. package/dist/src/reply-dispatcher.d.ts +35 -0
  39. package/dist/src/reply-dispatcher.js +311 -0
  40. package/dist/src/runtime.d.ts +3 -0
  41. package/dist/src/runtime.js +10 -0
  42. package/dist/src/session-store.d.ts +52 -0
  43. package/dist/src/session-store.js +254 -0
  44. package/dist/src/slash-commands.d.ts +71 -0
  45. package/dist/src/slash-commands.js +1179 -0
  46. package/dist/src/startup-greeting.d.ts +30 -0
  47. package/dist/src/startup-greeting.js +78 -0
  48. package/dist/src/stt.d.ts +21 -0
  49. package/dist/src/stt.js +70 -0
  50. package/dist/src/tools/channel.d.ts +16 -0
  51. package/dist/src/tools/channel.js +234 -0
  52. package/dist/src/tools/remind.d.ts +2 -0
  53. package/dist/src/tools/remind.js +247 -0
  54. package/dist/src/types.d.ts +175 -0
  55. package/dist/src/types.js +1 -0
  56. package/dist/src/typing-keepalive.d.ts +27 -0
  57. package/dist/src/typing-keepalive.js +64 -0
  58. package/dist/src/update-checker.d.ts +34 -0
  59. package/dist/src/update-checker.js +166 -0
  60. package/dist/src/user-messages.d.ts +8 -0
  61. package/dist/src/user-messages.js +8 -0
  62. package/dist/src/utils/audio-convert.d.ts +89 -0
  63. package/dist/src/utils/audio-convert.js +704 -0
  64. package/dist/src/utils/file-utils.d.ts +55 -0
  65. package/dist/src/utils/file-utils.js +150 -0
  66. package/dist/src/utils/image-size.d.ts +51 -0
  67. package/dist/src/utils/image-size.js +234 -0
  68. package/dist/src/utils/media-tags.d.ts +14 -0
  69. package/dist/src/utils/media-tags.js +164 -0
  70. package/dist/src/utils/payload.d.ts +112 -0
  71. package/dist/src/utils/payload.js +186 -0
  72. package/dist/src/utils/platform.d.ts +137 -0
  73. package/dist/src/utils/platform.js +390 -0
  74. package/dist/src/utils/text-parsing.d.ts +32 -0
  75. package/dist/src/utils/text-parsing.js +80 -0
  76. package/dist/src/utils/upload-cache.d.ts +34 -0
  77. package/dist/src/utils/upload-cache.js +93 -0
  78. package/index.ts +31 -0
  79. package/moltbot.plugin.json +16 -0
  80. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  81. package/node_modules/@eshaz/web-worker/README.md +134 -0
  82. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  83. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  84. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  85. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  86. package/node_modules/@eshaz/web-worker/node.js +223 -0
  87. package/node_modules/@eshaz/web-worker/package.json +54 -0
  88. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  89. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  90. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  91. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  92. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  93. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  94. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  95. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  96. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  97. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  98. package/node_modules/mpg123-decoder/README.md +265 -0
  99. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  100. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  101. package/node_modules/mpg123-decoder/index.js +8 -0
  102. package/node_modules/mpg123-decoder/package.json +58 -0
  103. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  104. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  105. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  106. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  107. package/node_modules/silk-wasm/LICENSE +21 -0
  108. package/node_modules/silk-wasm/README.md +85 -0
  109. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  110. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  111. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  112. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  113. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  114. package/node_modules/silk-wasm/package.json +39 -0
  115. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  116. package/node_modules/simple-yenc/.prettierignore +1 -0
  117. package/node_modules/simple-yenc/LICENSE +7 -0
  118. package/node_modules/simple-yenc/README.md +163 -0
  119. package/node_modules/simple-yenc/dist/esm.js +1 -0
  120. package/node_modules/simple-yenc/dist/index.js +1 -0
  121. package/node_modules/simple-yenc/package.json +50 -0
  122. package/node_modules/simple-yenc/rollup.config.js +27 -0
  123. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  124. package/node_modules/ws/LICENSE +20 -0
  125. package/node_modules/ws/README.md +548 -0
  126. package/node_modules/ws/browser.js +8 -0
  127. package/node_modules/ws/index.js +13 -0
  128. package/node_modules/ws/lib/buffer-util.js +131 -0
  129. package/node_modules/ws/lib/constants.js +19 -0
  130. package/node_modules/ws/lib/event-target.js +292 -0
  131. package/node_modules/ws/lib/extension.js +203 -0
  132. package/node_modules/ws/lib/limiter.js +55 -0
  133. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  134. package/node_modules/ws/lib/receiver.js +706 -0
  135. package/node_modules/ws/lib/sender.js +602 -0
  136. package/node_modules/ws/lib/stream.js +161 -0
  137. package/node_modules/ws/lib/subprotocol.js +62 -0
  138. package/node_modules/ws/lib/validation.js +152 -0
  139. package/node_modules/ws/lib/websocket-server.js +554 -0
  140. package/node_modules/ws/lib/websocket.js +1393 -0
  141. package/node_modules/ws/package.json +69 -0
  142. package/node_modules/ws/wrapper.mjs +8 -0
  143. package/openclaw.plugin.json +16 -0
  144. package/package.json +76 -0
  145. package/scripts/cleanup-legacy-plugins.sh +124 -0
  146. package/scripts/proactive-api-server.ts +369 -0
  147. package/scripts/send-proactive.ts +293 -0
  148. package/scripts/set-markdown.sh +156 -0
  149. package/scripts/test-sendmedia.ts +116 -0
  150. package/scripts/upgrade-via-alt-pkg.sh +307 -0
  151. package/scripts/upgrade-via-npm.ps1 +296 -0
  152. package/scripts/upgrade-via-npm.sh +301 -0
  153. package/scripts/upgrade-via-source.sh +774 -0
  154. package/skills/qqbot-channel/SKILL.md +263 -0
  155. package/skills/qqbot-channel/references/api_references.md +521 -0
  156. package/skills/qqbot-media/SKILL.md +56 -0
  157. package/skills/qqbot-remind/SKILL.md +149 -0
  158. package/src/admin-resolver.ts +140 -0
  159. package/src/api.ts +819 -0
  160. package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
  161. package/src/channel.ts +381 -0
  162. package/src/config.ts +187 -0
  163. package/src/credential-backup.ts +72 -0
  164. package/src/gateway.log +43 -0
  165. package/src/gateway.ts +1404 -0
  166. package/src/image-server.ts +539 -0
  167. package/src/inbound-attachments.ts +304 -0
  168. package/src/known-users.ts +353 -0
  169. package/src/message-queue.ts +169 -0
  170. package/src/onboarding.ts +274 -0
  171. package/src/openclaw-2026-03-21.log +3729 -0
  172. package/src/openclaw-plugin-sdk.d.ts +522 -0
  173. package/src/outbound-deliver.ts +552 -0
  174. package/src/outbound.ts +1266 -0
  175. package/src/proactive.ts +530 -0
  176. package/src/ref-index-store.ts +357 -0
  177. package/src/reply-dispatcher.ts +334 -0
  178. package/src/runtime.ts +14 -0
  179. package/src/session-store.ts +303 -0
  180. package/src/slash-commands.ts +1305 -0
  181. package/src/startup-greeting.ts +98 -0
  182. package/src/stt.ts +86 -0
  183. package/src/tools/channel.ts +281 -0
  184. package/src/tools/remind.ts +296 -0
  185. package/src/types.ts +183 -0
  186. package/src/typing-keepalive.ts +59 -0
  187. package/src/update-checker.ts +179 -0
  188. package/src/user-messages.ts +7 -0
  189. package/src/utils/audio-convert.ts +803 -0
  190. package/src/utils/file-utils.ts +167 -0
  191. package/src/utils/image-size.ts +266 -0
  192. package/src/utils/media-tags.ts +182 -0
  193. package/src/utils/payload.ts +265 -0
  194. package/src/utils/platform.ts +435 -0
  195. package/src/utils/text-parsing.ts +82 -0
  196. package/src/utils/upload-cache.ts +128 -0
  197. package/tsconfig.json +16 -0
package/src/gateway.ts ADDED
@@ -0,0 +1,1404 @@
1
+ import WebSocket from "ws";
2
+ import path from "node:path";
3
+ import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
4
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT } 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 { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
9
+ import { matchSlashCommand, type SlashCommandContext, type SlashCommandFileResult } from "./slash-commands.js";
10
+ import { createMessageQueue, type QueuedMessage } from "./message-queue.js";
11
+ import { triggerUpdateCheck } from "./update-checker.js";
12
+ import { startImageServer, isImageServerRunning, type ImageServerConfig } from "./image-server.js";
13
+ import { resolveTTSConfig } from "./utils/audio-convert.js";
14
+ import { processAttachments, formatVoiceText } from "./inbound-attachments.js";
15
+ import { getQQBotDataDir, runDiagnostics } from "./utils/platform.js";
16
+
17
+ import { sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
18
+ import { parseFaceTags, parseRefIndices, buildAttachmentSummaries } from "./utils/text-parsing.js";
19
+ import { sendStartupGreetings, type AdminResolverContext } from "./admin-resolver.js";
20
+ import { sendWithTokenRetry, sendErrorToTarget, handleStructuredPayload, type ReplyContext, type MessageTarget } from "./reply-dispatcher.js";
21
+ import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
22
+ import { parseAndSendMediaTags, sendPlainReply, type DeliverEventContext, type DeliverAccountContext } from "./outbound-deliver.js";
23
+
24
+ // QQ Bot intents - 按权限级别分组
25
+ const INTENTS = {
26
+ // 基础权限(默认有)
27
+ GUILDS: 1 << 0, // 频道相关
28
+ GUILD_MEMBERS: 1 << 1, // 频道成员
29
+ PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域)
30
+ // 需要申请的权限
31
+ DIRECT_MESSAGE: 1 << 12, // 频道私信
32
+ GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
33
+ };
34
+
35
+ // 固定使用完整权限(群聊 + 私信 + 频道),不做降级
36
+ const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
37
+ const FULL_INTENTS_DESC = "群聊+私信+频道";
38
+
39
+ // 重连配置
40
+ const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
41
+ const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
42
+ const MAX_RECONNECT_ATTEMPTS = 100;
43
+ const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
44
+ const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
45
+
46
+ // 图床服务器配置(可通过环境变量覆盖)
47
+ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10);
48
+ // 使用绝对路径,确保文件保存和读取使用同一目录
49
+ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
50
+
51
+ export interface GatewayContext {
52
+ account: ResolvedQQBotAccount;
53
+ abortSignal: AbortSignal;
54
+ cfg: unknown;
55
+ onReady?: (data: unknown) => void;
56
+ onError?: (error: Error) => void;
57
+ log?: {
58
+ info: (msg: string) => void;
59
+ error: (msg: string) => void;
60
+ debug?: (msg: string) => void;
61
+ };
62
+ }
63
+
64
+ /**
65
+ * 启动图床服务器
66
+ */
67
+ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: string): Promise<string | null> {
68
+ if (isImageServerRunning()) {
69
+ return publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`;
70
+ }
71
+
72
+ try {
73
+ const config: Partial<ImageServerConfig> = {
74
+ port: IMAGE_SERVER_PORT,
75
+ storageDir: IMAGE_SERVER_DIR,
76
+ // 使用用户配置的公网地址,而不是 0.0.0.0
77
+ baseUrl: publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`,
78
+ ttlSeconds: 3600, // 1 小时过期
79
+ };
80
+ await startImageServer(config);
81
+ log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}, baseUrl: ${config.baseUrl}`);
82
+ return config.baseUrl!;
83
+ } catch (err) {
84
+ log?.error(`[qqbot] Failed to start image server: ${err}`);
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // 模块级变量:进程生命周期内只有首次为 true
90
+ // 区分 gateway restart(进程重启)和 health-monitor 断线重连
91
+ let isFirstReadyGlobal = true;
92
+
93
+ /**
94
+ * 启动 Gateway WebSocket 连接(带自动重连)
95
+ * 支持流式消息发送
96
+ */
97
+ export async function startGateway(ctx: GatewayContext): Promise<void> {
98
+ const { account, abortSignal, cfg, onReady, onError, log } = ctx;
99
+
100
+ if (!account.appId || !account.clientSecret) {
101
+ throw new Error("QQBot not configured (missing appId or clientSecret)");
102
+ }
103
+
104
+ // 启动环境诊断(首次连接时执行)
105
+ const diag = await runDiagnostics();
106
+ if (diag.warnings.length > 0) {
107
+ for (const w of diag.warnings) {
108
+ log?.info(`[qqbot:${account.accountId}] ${w}`);
109
+ }
110
+ }
111
+
112
+ // 后台版本检查(供 /bot-version、/bot-upgrade 指令被动查询)
113
+ triggerUpdateCheck(log);
114
+
115
+ // 初始化 API 配置(markdown 支持)
116
+ initApiConfig({
117
+ markdownSupport: account.markdownSupport,
118
+ });
119
+ log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`);
120
+
121
+ // 注册出站消息 refIdx 缓存钩子
122
+ // 所有消息发送函数在拿到 QQ 回包后,如果含 ref_idx 则自动回调此处缓存
123
+ onMessageSent((refIdx, meta) => {
124
+ log?.info(`[qqbot:${account.accountId}] onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`);
125
+ const attachments: RefAttachmentSummary[] = [];
126
+ if (meta.mediaType) {
127
+ const localPath = meta.mediaLocalPath;
128
+ // filename 取路径的 basename,如果没有路径信息则留空
129
+ const filename = localPath ? path.basename(localPath) : undefined;
130
+ const attachment: RefAttachmentSummary = {
131
+ type: meta.mediaType,
132
+ ...(localPath ? { localPath } : {}),
133
+ ...(filename ? { filename } : {}),
134
+ ...(meta.mediaUrl ? { url: meta.mediaUrl } : {}),
135
+ };
136
+ // 如果是语音消息且有 TTS 原文本,保存到 transcript 并标记来源为 tts
137
+ if (meta.mediaType === "voice" && meta.ttsText) {
138
+ attachment.transcript = meta.ttsText;
139
+ attachment.transcriptSource = "tts";
140
+ log?.info(`[qqbot:${account.accountId}] Saving voice transcript (TTS): ${meta.ttsText.slice(0, 50)}`);
141
+ }
142
+ attachments.push(attachment);
143
+ }
144
+ setRefIndex(refIdx, {
145
+ content: meta.text ?? "",
146
+ senderId: account.accountId,
147
+ senderName: account.accountId,
148
+ timestamp: Date.now(),
149
+ isBot: true,
150
+ ...(attachments.length > 0 ? { attachments } : {}),
151
+ });
152
+ log?.info(`[qqbot:${account.accountId}] Cached outbound refIdx: ${refIdx}, attachments=${JSON.stringify(attachments)}`);
153
+ });
154
+
155
+ // TTS 配置验证
156
+ const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
157
+ if (ttsCfg) {
158
+ const maskedKey = ttsCfg.apiKey.length > 8
159
+ ? `${ttsCfg.apiKey.slice(0, 4)}****${ttsCfg.apiKey.slice(-4)}`
160
+ : "****";
161
+ log?.info(`[qqbot:${account.accountId}] TTS configured: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, baseUrl=${ttsCfg.baseUrl}`);
162
+ log?.info(`[qqbot:${account.accountId}] TTS apiKey: ${maskedKey}${ttsCfg.queryParams ? `, queryParams=${JSON.stringify(ttsCfg.queryParams)}` : ""}${ttsCfg.speed !== undefined ? `, speed=${ttsCfg.speed}` : ""}`);
163
+ } else {
164
+ log?.info(`[qqbot:${account.accountId}] TTS not configured (voice messages will be unavailable)`);
165
+ }
166
+
167
+ // 如果配置了公网 URL,启动图床服务器
168
+ let imageServerBaseUrl: string | null = null;
169
+ if (account.imageServerBaseUrl) {
170
+ // 使用用户配置的公网地址作为 baseUrl
171
+ await ensureImageServer(log, account.imageServerBaseUrl);
172
+ imageServerBaseUrl = account.imageServerBaseUrl;
173
+ log?.info(`[qqbot:${account.accountId}] Image server enabled with URL: ${imageServerBaseUrl}`);
174
+ } else {
175
+ log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
176
+ }
177
+
178
+ let reconnectAttempts = 0;
179
+ let isAborted = false;
180
+ let currentWs: WebSocket | null = null;
181
+ let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
182
+ let sessionId: string | null = null;
183
+ let lastSeq: number | null = null;
184
+ let lastConnectTime: number = 0; // 上次连接成功的时间
185
+ let quickDisconnectCount = 0; // 连续快速断开次数
186
+ let isConnecting = false; // 防止并发连接
187
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
188
+ let shouldRefreshToken = false; // 下次连接是否需要刷新 token
189
+ // 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
190
+ // health-monitor 重连不会重新初始化为 true
191
+
192
+ const adminCtx: AdminResolverContext = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
193
+
194
+ // ============ P1-2: 尝试从持久化存储恢复 Session ============
195
+ // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
196
+ const savedSession = loadSession(account.accountId, account.appId);
197
+ if (savedSession) {
198
+ sessionId = savedSession.sessionId;
199
+ lastSeq = savedSession.lastSeq;
200
+ log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
201
+ }
202
+
203
+ // ============ 按用户并发的消息队列 ============
204
+ const msgQueue = createMessageQueue({
205
+ accountId: account.accountId,
206
+ log,
207
+ isAborted: () => isAborted,
208
+ });
209
+
210
+ // 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
211
+ // 紧急命令列表:这些命令会立即执行,不进入斜杠匹配流程
212
+ const URGENT_COMMANDS = ["/stop"];
213
+
214
+ const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
215
+ const content = (msg.content ?? "").trim();
216
+ if (!content.startsWith("/")) {
217
+ msgQueue.enqueue(msg);
218
+ return;
219
+ }
220
+
221
+ // 检测是否为紧急命令 — 立即执行,清空该用户队列
222
+ const contentLower = content.toLowerCase();
223
+ const isUrgentCommand = URGENT_COMMANDS.some(cmd => contentLower.startsWith(cmd.toLowerCase()));
224
+ if (isUrgentCommand) {
225
+ log?.info(`[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`);
226
+ const peerId = msgQueue.getMessagePeerId(msg);
227
+ const droppedCount = msgQueue.clearUserQueue(peerId);
228
+ if (droppedCount > 0) {
229
+ log?.info(`[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`);
230
+ }
231
+ msgQueue.executeImmediate(msg);
232
+ return;
233
+ }
234
+
235
+ const receivedAt = Date.now();
236
+ const peerId = msgQueue.getMessagePeerId(msg);
237
+
238
+ const cmdCtx: SlashCommandContext = {
239
+ type: msg.type,
240
+ senderId: msg.senderId,
241
+ senderName: msg.senderName,
242
+ messageId: msg.messageId,
243
+ eventTimestamp: msg.timestamp,
244
+ receivedAt,
245
+ rawContent: content,
246
+ args: "",
247
+ channelId: msg.channelId,
248
+ groupOpenid: msg.groupOpenid,
249
+ accountId: account.accountId,
250
+ appId: account.appId,
251
+ accountConfig: account.config,
252
+ queueSnapshot: msgQueue.getSnapshot(peerId),
253
+ };
254
+
255
+ try {
256
+ const reply = await matchSlashCommand(cmdCtx);
257
+ if (reply === null) {
258
+ // 不是插件级指令,正常入队交给框架
259
+ msgQueue.enqueue(msg);
260
+ return;
261
+ }
262
+
263
+ // 命中插件级指令,直接回复
264
+ log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
265
+ const token = await getAccessToken(account.appId, account.clientSecret);
266
+
267
+ // 解析回复:纯文本 or 带文件的结果
268
+ const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
269
+ const replyText = isFileResult ? (reply as SlashCommandFileResult).text : reply as string;
270
+ const replyFile = isFileResult ? (reply as SlashCommandFileResult).filePath : null;
271
+
272
+ // 先发送文本回复
273
+ if (msg.type === "c2c") {
274
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
275
+ } else if (msg.type === "group" && msg.groupOpenid) {
276
+ await sendGroupMessage(token, msg.groupOpenid, replyText, msg.messageId);
277
+ } else if (msg.channelId) {
278
+ await sendChannelMessage(token, msg.channelId, replyText, msg.messageId);
279
+ } else if (msg.type === "dm") {
280
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
281
+ }
282
+
283
+ // 如果有文件需要发送
284
+ if (replyFile) {
285
+ try {
286
+ const targetType = msg.type === "group" ? "group" : msg.type === "c2c" || msg.type === "dm" ? "c2c" : "channel";
287
+ const targetId = msg.type === "group" ? (msg.groupOpenid || msg.senderId) : msg.type === "c2c" || msg.type === "dm" ? msg.senderId : (msg.channelId || msg.senderId);
288
+ const mediaCtx: MediaTargetContext = {
289
+ targetType,
290
+ targetId,
291
+ account,
292
+ replyToId: msg.messageId,
293
+ logPrefix: `[qqbot:${account.accountId}]`,
294
+ };
295
+ await sendDocument(mediaCtx, replyFile);
296
+ log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`);
297
+ } catch (fileErr) {
298
+ log?.error(`[qqbot:${account.accountId}] Failed to send slash command file: ${fileErr}`);
299
+ }
300
+ }
301
+ } catch (err) {
302
+ log?.error(`[qqbot:${account.accountId}] Slash command error: ${err}`);
303
+ // 出错时回退到正常入队
304
+ msgQueue.enqueue(msg);
305
+ }
306
+ };
307
+
308
+ abortSignal.addEventListener("abort", () => {
309
+ isAborted = true;
310
+ if (reconnectTimer) {
311
+ clearTimeout(reconnectTimer);
312
+ reconnectTimer = null;
313
+ }
314
+ cleanup();
315
+ // P1-1: 停止后台 Token 刷新
316
+ stopBackgroundTokenRefresh(account.appId);
317
+ // P1-3: 保存已知用户数据
318
+ flushKnownUsers();
319
+ // P1-4: 保存引用索引数据
320
+ flushRefIndex();
321
+ });
322
+
323
+ const cleanup = () => {
324
+ if (heartbeatInterval) {
325
+ clearInterval(heartbeatInterval);
326
+ heartbeatInterval = null;
327
+ }
328
+ if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) {
329
+ currentWs.close();
330
+ }
331
+ currentWs = null;
332
+ };
333
+
334
+ const getReconnectDelay = () => {
335
+ const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1);
336
+ return RECONNECT_DELAYS[idx];
337
+ };
338
+
339
+ const scheduleReconnect = (customDelay?: number) => {
340
+ if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
341
+ log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`);
342
+ return;
343
+ }
344
+
345
+ // 取消已有的重连定时器
346
+ if (reconnectTimer) {
347
+ clearTimeout(reconnectTimer);
348
+ reconnectTimer = null;
349
+ }
350
+
351
+ const delay = customDelay ?? getReconnectDelay();
352
+ reconnectAttempts++;
353
+ log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
354
+
355
+ reconnectTimer = setTimeout(() => {
356
+ reconnectTimer = null;
357
+ if (!isAborted) {
358
+ connect();
359
+ }
360
+ }, delay);
361
+ };
362
+
363
+ const connect = async () => {
364
+ // 防止并发连接
365
+ if (isConnecting) {
366
+ log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`);
367
+ return;
368
+ }
369
+ isConnecting = true;
370
+
371
+ try {
372
+ cleanup();
373
+
374
+ // 如果标记了需要刷新 token,则清除缓存
375
+ if (shouldRefreshToken) {
376
+ log?.info(`[qqbot:${account.accountId}] Refreshing token...`);
377
+ clearTokenCache(account.appId);
378
+ shouldRefreshToken = false;
379
+ }
380
+
381
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
382
+ log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
383
+ const gatewayUrl = await getGatewayUrl(accessToken);
384
+
385
+ log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
386
+
387
+ const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } });
388
+ currentWs = ws;
389
+
390
+ const pluginRuntime = getQQBotRuntime();
391
+
392
+ // 处理收到的消息
393
+ const handleMessage = async (event: {
394
+ type: "c2c" | "guild" | "dm" | "group";
395
+ senderId: string;
396
+ senderName?: string;
397
+ content: string;
398
+ messageId: string;
399
+ timestamp: string;
400
+ channelId?: string;
401
+ guildId?: string;
402
+ groupOpenid?: string;
403
+ attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string; asr_refer_text?: string }>;
404
+ refMsgIdx?: string;
405
+ msgIdx?: string;
406
+ }) => {
407
+
408
+ log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`);
409
+ log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
410
+ if (event.attachments?.length) {
411
+ log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
412
+ }
413
+
414
+ pluginRuntime.channel.activity.record({
415
+ channel: "qqbot",
416
+ accountId: account.accountId,
417
+ direction: "inbound",
418
+ });
419
+
420
+ // 发送输入状态提示 + 启动自动续期(仅 C2C 私聊有效)
421
+ // refIdx 通过 Promise 延迟获取,在真正需要时再 await
422
+ const isC2C = event.type === "c2c" || event.type === "dm";
423
+ // 用对象包装避免 TS 控制流分析将 null 初始值窄化为 never
424
+ const typing: { keepAlive: TypingKeepAlive | null } = { keepAlive: null };
425
+
426
+ const inputNotifyPromise: Promise<string | undefined> = (async () => {
427
+ if (!isC2C) return undefined;
428
+ try {
429
+ let token = await getAccessToken(account.appId, account.clientSecret);
430
+ try {
431
+ const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, TYPING_INPUT_SECOND);
432
+ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`);
433
+ // 首次成功后启动定时续期
434
+ typing.keepAlive = new TypingKeepAlive(
435
+ () => getAccessToken(account.appId, account.clientSecret),
436
+ () => clearTokenCache(account.appId),
437
+ event.senderId,
438
+ event.messageId,
439
+ log,
440
+ `[qqbot:${account.accountId}]`,
441
+ );
442
+ typing.keepAlive.start();
443
+ return notifyResponse.refIdx;
444
+ } catch (notifyErr) {
445
+ const errMsg = String(notifyErr);
446
+ if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
447
+ log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
448
+ clearTokenCache(account.appId);
449
+ token = await getAccessToken(account.appId, account.clientSecret);
450
+ const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, TYPING_INPUT_SECOND);
451
+ typing.keepAlive = new TypingKeepAlive(
452
+ () => getAccessToken(account.appId, account.clientSecret),
453
+ () => clearTokenCache(account.appId),
454
+ event.senderId,
455
+ event.messageId,
456
+ log,
457
+ `[qqbot:${account.accountId}]`,
458
+ );
459
+ typing.keepAlive.start();
460
+ return notifyResponse.refIdx;
461
+ } else {
462
+ throw notifyErr;
463
+ }
464
+ }
465
+ } catch (err) {
466
+ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
467
+ return undefined;
468
+ }
469
+ })();
470
+
471
+ const isGroupChat = event.type === "guild" || event.type === "group";
472
+ // peerId 只放纯 ID,类型信息由 peer.kind 表达
473
+ // 群聊:用 groupOpenid(框架根据 kind:"group" 区分)
474
+ // 私聊:用 senderId(框架根据 dmScope 决定隔离粒度)
475
+ const peerId = event.type === "guild" ? (event.channelId ?? "unknown")
476
+ : event.type === "group" ? (event.groupOpenid ?? "unknown")
477
+ : event.senderId;
478
+
479
+ const route = pluginRuntime.channel.routing.resolveAgentRoute({
480
+ cfg,
481
+ channel: "qqbot",
482
+ accountId: account.accountId,
483
+ peer: {
484
+ kind: isGroupChat ? "group" : "direct",
485
+ id: peerId,
486
+ },
487
+ });
488
+
489
+ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
490
+
491
+ // 组装消息体
492
+ // 静态系统提示已移至 skills/qqbot-remind/SKILL.md 和 skills/qqbot-media/SKILL.md
493
+ // BodyForAgent 只保留必要的动态上下文信息
494
+
495
+ // ============ 用户标识信息 ============
496
+
497
+ // 收集额外的系统提示(如果配置了账户级别的 systemPrompt)
498
+ const systemPrompts: string[] = [];
499
+ if (account.systemPrompt) {
500
+ systemPrompts.push(account.systemPrompt);
501
+ }
502
+
503
+ // 处理附件(图片等)- 下载到本地供 openclaw 访问
504
+ const processed = await processAttachments(event.attachments, { accountId: account.accountId, cfg, log });
505
+ const { attachmentInfo, imageUrls, imageMediaTypes, voiceAttachmentPaths, voiceAttachmentUrls, voiceAsrReferTexts, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = processed;
506
+
507
+ // 语音转录文本注入到用户消息中
508
+ const voiceText = formatVoiceText(voiceTranscripts);
509
+ const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
510
+
511
+ // 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
512
+ const parsedContent = parseFaceTags(event.content);
513
+ const userContent = voiceText
514
+ ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
515
+ : parsedContent + attachmentInfo;
516
+
517
+ // ============ 引用消息处理 ============
518
+ let replyToId: string | undefined;
519
+ let replyToBody: string | undefined;
520
+ let replyToSender: string | undefined;
521
+ let replyToIsQuote = false;
522
+
523
+ // 1. 查找被引用消息
524
+ if (event.refMsgIdx) {
525
+ const refEntry = getRefIndex(event.refMsgIdx);
526
+ if (refEntry) {
527
+ replyToId = event.refMsgIdx;
528
+ replyToBody = formatRefEntryForAgent(refEntry);
529
+ replyToSender = refEntry.senderName ?? refEntry.senderId;
530
+ replyToIsQuote = true;
531
+ log?.info(`[qqbot:${account.accountId}] Quote detected: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`);
532
+ } else {
533
+ log?.info(`[qqbot:${account.accountId}] Quote detected but refMsgIdx not in cache: ${event.refMsgIdx}`);
534
+ replyToId = event.refMsgIdx;
535
+ replyToIsQuote = true;
536
+ // 缓存未命中时 replyToBody 为空,AI 只能知道"用户引用了一条消息"
537
+ }
538
+ }
539
+
540
+ // 2. 缓存当前消息自身的 msgIdx(供将来被引用时查找)
541
+ // 优先使用推送事件中的 msgIdx(来自 message_scene.ext),否则使用 InputNotify 返回的 refIdx
542
+ // inputNotifyPromise 在这里才 await,此时附件下载等工作已并行完成
543
+ const inputNotifyRefIdx = await inputNotifyPromise;
544
+ const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
545
+ if (currentMsgIdx) {
546
+ const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);
547
+ // 如果有语音转录,把转录文本和来源写入对应附件摘要
548
+ if (attSummaries && voiceTranscripts.length > 0) {
549
+ let voiceIdx = 0;
550
+ for (const att of attSummaries) {
551
+ if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
552
+ att.transcript = voiceTranscripts[voiceIdx];
553
+ // 保存转录来源
554
+ if (voiceIdx < voiceTranscriptSources.length) {
555
+ att.transcriptSource = voiceTranscriptSources[voiceIdx];
556
+ }
557
+ voiceIdx++;
558
+ }
559
+ }
560
+ }
561
+ setRefIndex(currentMsgIdx, {
562
+ content: parsedContent,
563
+ senderId: event.senderId,
564
+ senderName: event.senderName,
565
+ timestamp: new Date(event.timestamp).getTime(),
566
+ attachments: attSummaries,
567
+ });
568
+ log?.info(`[qqbot:${account.accountId}] Cached msgIdx=${currentMsgIdx} for future reference (source: ${event.msgIdx ? "message_scene.ext" : "InputNotify"})`);
569
+ }
570
+
571
+ // Body: 展示用的用户原文(Web UI 看到的)
572
+ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
573
+ channel: "qqbot",
574
+ from: event.senderName ?? event.senderId,
575
+ timestamp: new Date(event.timestamp).getTime(),
576
+ body: userContent,
577
+ chatType: isGroupChat ? "group" : "direct",
578
+ sender: {
579
+ id: event.senderId,
580
+ name: event.senderName,
581
+ },
582
+ envelope: envelopeOptions,
583
+ ...(imageUrls.length > 0 ? { imageUrls } : {}),
584
+ });
585
+
586
+ // BodyForAgent: AI 实际看到的完整上下文(动态数据 + 系统提示 + 用户输入)
587
+
588
+ // 构建媒体附件纯数据描述(图片 + 语音统一列出)
589
+ const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)];
590
+ const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)];
591
+ const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean);
592
+ const sttTranscriptCount = voiceTranscriptSources.filter((s) => s === "stt").length;
593
+ const asrFallbackCount = voiceTranscriptSources.filter((s) => s === "asr").length;
594
+ const fallbackCount = voiceTranscriptSources.filter((s) => s === "fallback").length;
595
+ if (voiceAttachmentPaths.length > 0 || voiceAttachmentUrls.length > 0 || uniqueVoiceAsrReferTexts.length > 0) {
596
+ const asrPreview = uniqueVoiceAsrReferTexts.length > 0
597
+ ? uniqueVoiceAsrReferTexts[0].slice(0, 50)
598
+ : "";
599
+ log?.info(
600
+ `[qqbot:${account.accountId}] Voice input summary: local=${uniqueVoicePaths.length}, remote=${uniqueVoiceUrls.length}, `
601
+ + `asrReferTexts=${uniqueVoiceAsrReferTexts.length}, transcripts=${voiceTranscripts.length}, `
602
+ + `source(stt/asr/fallback)=${sttTranscriptCount}/${asrFallbackCount}/${fallbackCount}`
603
+ + (asrPreview ? `, asr_preview="${asrPreview}${uniqueVoiceAsrReferTexts[0].length > 50 ? "..." : ""}"` : "")
604
+ );
605
+ }
606
+ // AI 看到的投递地址必须带完整前缀(qqbot:c2c: / qqbot:group:)
607
+ const qualifiedTarget = isGroupChat ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`;
608
+
609
+ // 动态检测 TTS 配置状态
610
+ const hasTTS = !!resolveTTSConfig(cfg as Record<string, unknown>);
611
+
612
+ // 引用消息上下文
613
+ let quotePart = "";
614
+ if (replyToIsQuote) {
615
+ if (replyToBody) {
616
+ quotePart = `[引用消息开始]\n${replyToBody}\n[引用消息结束]\n`;
617
+ } else {
618
+ quotePart = `[引用消息开始]\n原始内容不可用\n[引用消息结束]\n`;
619
+ }
620
+ }
621
+
622
+ // ============ 构建 contextInfo(静态/动态分离) ============
623
+ // 设计原则(参考 Telegram/Discord 做法):
624
+ // - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
625
+ // 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
626
+ // 且保证长 session 窗口截断后仍可见。
627
+ // - 动态标签:每条消息变化的数据(时间、附件、ASR),
628
+ // 以紧凑的 [ctx] 块标注在用户消息前,最小化 token 开销。
629
+
630
+ // --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
631
+ // 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
632
+ // 这里只补充 QQBot 独有的:投递地址(cron skill 需要)
633
+ const staticParts: string[] = [
634
+ `[QQBot] to=${qualifiedTarget}`,
635
+ ];
636
+ // TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
637
+ // STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
638
+ if (hasTTS) staticParts.push("语音合成已启用");
639
+ const staticInstruction = staticParts.join(" | ");
640
+
641
+ // 静态指引作为 systemPrompts 的首项注入
642
+ systemPrompts.unshift(staticInstruction);
643
+
644
+ // --- 动态上下文(仅框架信封未覆盖的附件信息) ---
645
+ const dynLines: string[] = [];
646
+ if (imageUrls.length > 0) {
647
+ dynLines.push(`- 图片: ${imageUrls.join(", ")}`);
648
+ }
649
+ if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
650
+ dynLines.push(`- 语音: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`);
651
+ }
652
+ if (uniqueVoiceAsrReferTexts.length > 0) {
653
+ dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
654
+ }
655
+ const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n" : "";
656
+
657
+ // 命令直接透传,不注入上下文
658
+ const userMessage = `${quotePart}${userContent}`;
659
+ const agentBody = userContent.startsWith("/")
660
+ ? userContent
661
+ : `${systemPrompts.join("\n")}\n\n${dynamicCtx}${userMessage}`;
662
+
663
+ log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
664
+
665
+ const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
666
+ : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
667
+ : `qqbot:c2c:${event.senderId}`;
668
+ const toAddress = fromAddress;
669
+
670
+ // 计算命令授权状态
671
+ // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
672
+ const allowFromList = account.config?.allowFrom ?? [];
673
+ const allowAll = allowFromList.length === 0 || allowFromList.some((entry: string) => entry === "*");
674
+ const commandAuthorized = allowAll || allowFromList.some((entry: string) =>
675
+ entry.toUpperCase() === event.senderId.toUpperCase()
676
+ );
677
+
678
+ // 分离 imageUrls 为本地路径和远程 URL,供 openclaw 原生媒体处理
679
+ const localMediaPaths: string[] = [];
680
+ const localMediaTypes: string[] = [];
681
+ const remoteMediaUrls: string[] = [];
682
+ const remoteMediaTypes: string[] = [];
683
+ for (let i = 0; i < imageUrls.length; i++) {
684
+ const u = imageUrls[i];
685
+ const t = imageMediaTypes[i] ?? "image/png";
686
+ if (u.startsWith("http://") || u.startsWith("https://")) {
687
+ remoteMediaUrls.push(u);
688
+ remoteMediaTypes.push(t);
689
+ } else {
690
+ localMediaPaths.push(u);
691
+ localMediaTypes.push(t);
692
+ }
693
+ }
694
+
695
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
696
+ Body: body,
697
+ BodyForAgent: agentBody,
698
+ RawBody: event.content,
699
+ CommandBody: event.content,
700
+ From: fromAddress,
701
+ To: toAddress,
702
+ SessionKey: route.sessionKey,
703
+ AccountId: route.accountId,
704
+ ChatType: isGroupChat ? "group" : "direct",
705
+ SenderId: event.senderId,
706
+ SenderName: event.senderName,
707
+ Provider: "qqbot",
708
+ Surface: "qqbot",
709
+ MessageSid: event.messageId,
710
+ Timestamp: new Date(event.timestamp).getTime(),
711
+ OriginatingChannel: "qqbot",
712
+ OriginatingTo: toAddress,
713
+ QQChannelId: event.channelId,
714
+ QQGuildId: event.guildId,
715
+ QQGroupOpenid: event.groupOpenid,
716
+ QQVoiceAsrReferAvailable: hasAsrReferFallback,
717
+ QQVoiceTranscriptSources: voiceTranscriptSources,
718
+ QQVoiceAttachmentPaths: uniqueVoicePaths,
719
+ QQVoiceAttachmentUrls: uniqueVoiceUrls,
720
+ QQVoiceAsrReferTexts: uniqueVoiceAsrReferTexts,
721
+ QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback",
722
+ CommandAuthorized: commandAuthorized,
723
+ // 传递媒体路径和 URL,使 openclaw 原生媒体处理(视觉等)能正常工作
724
+ ...(localMediaPaths.length > 0 ? {
725
+ MediaPaths: localMediaPaths,
726
+ MediaPath: localMediaPaths[0],
727
+ MediaTypes: localMediaTypes,
728
+ MediaType: localMediaTypes[0],
729
+ } : {}),
730
+ ...(remoteMediaUrls.length > 0 ? {
731
+ MediaUrls: remoteMediaUrls,
732
+ MediaUrl: remoteMediaUrls[0],
733
+ } : {}),
734
+ // 引用消息上下文(对齐 Telegram/Discord 的 ReplyTo 字段)
735
+ ...(replyToId ? {
736
+ ReplyToId: replyToId,
737
+ ReplyToBody: replyToBody,
738
+ ReplyToSender: replyToSender,
739
+ ReplyToIsQuote: replyToIsQuote,
740
+ } : {}),
741
+ });
742
+
743
+ // 构建回复上下文
744
+ const replyTarget: MessageTarget = {
745
+ type: event.type,
746
+ senderId: event.senderId,
747
+ messageId: event.messageId,
748
+ channelId: event.channelId,
749
+ groupOpenid: event.groupOpenid,
750
+ };
751
+ const replyCtx: ReplyContext = { target: replyTarget, account, cfg, log };
752
+
753
+ // 简化的 token 重试包装(使用 reply-dispatcher 的通用实现)
754
+ const sendWithRetry = <T>(sendFn: (token: string) => Promise<T>) =>
755
+ sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
756
+
757
+ // 发送错误提示的辅助函数
758
+ const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText);
759
+
760
+ try {
761
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
762
+
763
+ // 追踪是否有响应
764
+ let hasResponse = false;
765
+ let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
766
+ let toolDeliverCount = 0; // tool deliver 计数
767
+ const toolTexts: string[] = []; // 收集所有 tool deliver 文本
768
+ const toolMediaUrls: string[] = []; // 收集所有 tool deliver 媒体 URL
769
+ let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
770
+ const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
771
+ const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
772
+ const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
773
+ let toolRenewalCount = 0; // 已续期次数
774
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
775
+ let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
776
+
777
+ // tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
778
+ const sendToolFallback = async (): Promise<void> => {
779
+ // 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
780
+ if (toolMediaUrls.length > 0) {
781
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
782
+ const mediaTimeout = 45000; // 单个媒体发送超时 45s
783
+ for (const mediaUrl of toolMediaUrls) {
784
+ try {
785
+ const result = await Promise.race([
786
+ sendMediaAuto({
787
+ to: qualifiedTarget,
788
+ text: "",
789
+ mediaUrl,
790
+ accountId: account.accountId,
791
+ replyToId: event.messageId,
792
+ account,
793
+ }),
794
+ new Promise<{ channel: string; error: string }>((resolve) =>
795
+ setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)
796
+ ),
797
+ ]);
798
+ if (result.error) {
799
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
800
+ }
801
+ } catch (err) {
802
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
803
+ }
804
+ }
805
+ return;
806
+ }
807
+ // 其次转发工具产出的文本
808
+ if (toolTexts.length > 0) {
809
+ const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
810
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
811
+ await sendErrorMessage(text);
812
+ return;
813
+ }
814
+ // 既无媒体也无文本,静默处理(仅日志记录)
815
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
816
+ };
817
+
818
+ const timeoutPromise = new Promise<void>((_, reject) => {
819
+ timeoutId = setTimeout(() => {
820
+ if (!hasResponse) {
821
+ reject(new Error("Response timeout"));
822
+ }
823
+ }, responseTimeout);
824
+ });
825
+
826
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
827
+ ctx: ctxPayload,
828
+ cfg,
829
+ dispatcherOptions: {
830
+ responsePrefix: messagesConfig.responsePrefix,
831
+ deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, info: { kind: string }) => {
832
+ hasResponse = true;
833
+
834
+ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
835
+
836
+ // ============ 跳过工具调用的中间结果(带兜底保护) ============
837
+ if (info.kind === "tool") {
838
+ toolDeliverCount++;
839
+ const toolText = (payload.text ?? "").trim();
840
+ if (toolText) {
841
+ toolTexts.push(toolText);
842
+ }
843
+ // 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
844
+ if (payload.mediaUrls?.length) {
845
+ toolMediaUrls.push(...payload.mediaUrls);
846
+ }
847
+ if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
848
+ toolMediaUrls.push(payload.mediaUrl);
849
+ }
850
+ log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
851
+
852
+ // block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
853
+ if (hasBlockResponse && toolMediaUrls.length > 0) {
854
+ log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`);
855
+ const urlsToSend = [...toolMediaUrls];
856
+ toolMediaUrls.length = 0;
857
+ for (const mediaUrl of urlsToSend) {
858
+ try {
859
+ const result = await sendMediaAuto({
860
+ to: qualifiedTarget,
861
+ text: "",
862
+ mediaUrl,
863
+ accountId: account.accountId,
864
+ replyToId: event.messageId,
865
+ account,
866
+ });
867
+ if (result.error) {
868
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
869
+ } else {
870
+ log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
871
+ }
872
+ } catch (err) {
873
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
874
+ }
875
+ }
876
+ return;
877
+ }
878
+
879
+ // 兜底已发送,不再续期
880
+ if (toolFallbackSent) {
881
+ return;
882
+ }
883
+
884
+ // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
885
+ // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
886
+ if (toolOnlyTimeoutId) {
887
+ if (toolRenewalCount < maxToolRenewals) {
888
+ clearTimeout(toolOnlyTimeoutId);
889
+ toolRenewalCount++;
890
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
891
+ } else {
892
+ // 已达续期上限,不再重置,等定时器自然触发兜底
893
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
894
+ return;
895
+ }
896
+ }
897
+ toolOnlyTimeoutId = setTimeout(async () => {
898
+ if (!hasBlockResponse && !toolFallbackSent) {
899
+ toolFallbackSent = true;
900
+ log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
901
+ try {
902
+ await sendToolFallback();
903
+ } catch (sendErr) {
904
+ log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
905
+ }
906
+ }
907
+ }, toolOnlyTimeout);
908
+ return;
909
+ }
910
+
911
+ // 收到 block 回复,清除所有超时定时器
912
+ hasBlockResponse = true;
913
+ // 收到真正回复,立即停止输入状态续期(让 "输入中" 尽快消失)
914
+ typing.keepAlive?.stop();
915
+ if (timeoutId) {
916
+ clearTimeout(timeoutId);
917
+ timeoutId = null;
918
+ }
919
+ if (toolOnlyTimeoutId) {
920
+ clearTimeout(toolOnlyTimeoutId);
921
+ toolOnlyTimeoutId = null;
922
+ }
923
+ if (toolDeliverCount > 0) {
924
+ log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
925
+ }
926
+
927
+ // ============ 引用回复 ============
928
+ const quoteRef = event.msgIdx;
929
+ let quoteRefUsed = false;
930
+ const consumeQuoteRef = (): string | undefined => {
931
+ if (quoteRef && !quoteRefUsed) {
932
+ quoteRefUsed = true;
933
+ return quoteRef;
934
+ }
935
+ return undefined;
936
+ };
937
+
938
+ let replyText = payload.text ?? "";
939
+
940
+ // ============ 媒体标签解析 + 发送 ============
941
+ const deliverEvent: DeliverEventContext = {
942
+ type: event.type,
943
+ senderId: event.senderId,
944
+ messageId: event.messageId,
945
+ channelId: event.channelId,
946
+ groupOpenid: event.groupOpenid,
947
+ msgIdx: event.msgIdx,
948
+ };
949
+ const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log };
950
+
951
+ const mediaResult = await parseAndSendMediaTags(
952
+ replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef,
953
+ );
954
+ if (mediaResult.handled) {
955
+ pluginRuntime.channel.activity.record({
956
+ channel: "qqbot",
957
+ accountId: account.accountId,
958
+ direction: "outbound",
959
+ });
960
+ return;
961
+ }
962
+ replyText = mediaResult.normalizedText;
963
+
964
+ // ============ 结构化载荷检测与分发 ============
965
+ const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
966
+ channel: "qqbot",
967
+ accountId: account.accountId,
968
+ direction: "outbound",
969
+ });
970
+ const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
971
+ if (handled) return;
972
+
973
+ // ============ 非结构化消息发送 ============
974
+ await sendPlainReply(
975
+ payload, replyText, deliverEvent, deliverActx,
976
+ sendWithRetry, consumeQuoteRef, toolMediaUrls,
977
+ );
978
+
979
+ pluginRuntime.channel.activity.record({
980
+ channel: "qqbot",
981
+ accountId: account.accountId,
982
+ direction: "outbound",
983
+ });
984
+ },
985
+ onError: async (err: unknown) => {
986
+ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
987
+ hasResponse = true;
988
+ if (timeoutId) {
989
+ clearTimeout(timeoutId);
990
+ timeoutId = null;
991
+ }
992
+
993
+ // 发送错误提示给用户,显示完整错误信息
994
+ const errMsg = String(err);
995
+ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
996
+ log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
997
+ } else {
998
+ log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
999
+ }
1000
+ },
1001
+ },
1002
+ replyOptions: {
1003
+ disableBlockStreaming: true,
1004
+ },
1005
+ });
1006
+
1007
+ // 等待分发完成或超时
1008
+ try {
1009
+ await Promise.race([dispatchPromise, timeoutPromise]);
1010
+ } catch (err) {
1011
+ if (timeoutId) {
1012
+ clearTimeout(timeoutId);
1013
+ }
1014
+ if (!hasResponse) {
1015
+ log?.error(`[qqbot:${account.accountId}] No response within timeout`);
1016
+ }
1017
+ } finally {
1018
+ // 清理 tool-only 兜底定时器
1019
+ if (toolOnlyTimeoutId) {
1020
+ clearTimeout(toolOnlyTimeoutId);
1021
+ toolOnlyTimeoutId = null;
1022
+ }
1023
+ // dispatch 完成后,如果只有 tool 没有 block,且尚未发过兜底,立即兜底
1024
+ if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
1025
+ toolFallbackSent = true;
1026
+ log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
1027
+ await sendToolFallback();
1028
+ }
1029
+ }
1030
+ } catch (err) {
1031
+ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
1032
+ } finally {
1033
+ // 无论成功/失败/超时,都停止输入状态续期
1034
+ typing.keepAlive?.stop();
1035
+ }
1036
+ };
1037
+
1038
+ ws.on("open", () => {
1039
+ log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
1040
+ isConnecting = false; // 连接完成,释放锁
1041
+ reconnectAttempts = 0; // 连接成功,重置重试计数
1042
+ lastConnectTime = Date.now(); // 记录连接时间
1043
+ // 启动消息处理器(异步处理,防止阻塞心跳)
1044
+ msgQueue.startProcessor(handleMessage);
1045
+ // P1-1: 启动后台 Token 刷新
1046
+ startBackgroundTokenRefresh(account.appId, account.clientSecret, {
1047
+ log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void },
1048
+ });
1049
+ });
1050
+
1051
+ ws.on("message", async (data) => {
1052
+ try {
1053
+ const rawData = data.toString();
1054
+ const payload = JSON.parse(rawData) as WSPayload;
1055
+ const { op, d, s, t } = payload;
1056
+
1057
+ if (s) {
1058
+ lastSeq = s;
1059
+ // P1-2: 更新持久化存储中的 lastSeq(节流保存)
1060
+ if (sessionId) {
1061
+ saveSession({
1062
+ sessionId,
1063
+ lastSeq,
1064
+ lastConnectedAt: lastConnectTime,
1065
+ intentLevelIndex: 0,
1066
+ accountId: account.accountId,
1067
+ savedAt: Date.now(),
1068
+ appId: account.appId,
1069
+ });
1070
+ }
1071
+ }
1072
+
1073
+ log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
1074
+
1075
+ switch (op) {
1076
+ case 10: // Hello
1077
+ log?.info(`[qqbot:${account.accountId}] Hello received`);
1078
+
1079
+ // 如果有 session_id,尝试 Resume
1080
+ if (sessionId && lastSeq !== null) {
1081
+ log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`);
1082
+ ws.send(JSON.stringify({
1083
+ op: 6, // Resume
1084
+ d: {
1085
+ token: `QQBot ${accessToken}`,
1086
+ session_id: sessionId,
1087
+ seq: lastSeq,
1088
+ },
1089
+ }));
1090
+ } else {
1091
+ // 新连接,发送 Identify,始终使用完整权限
1092
+ log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${FULL_INTENTS} (${FULL_INTENTS_DESC})`);
1093
+ ws.send(JSON.stringify({
1094
+ op: 2,
1095
+ d: {
1096
+ token: `QQBot ${accessToken}`,
1097
+ intents: FULL_INTENTS,
1098
+ shard: [0, 1],
1099
+ },
1100
+ }));
1101
+ }
1102
+
1103
+ // 启动心跳
1104
+ const interval = (d as { heartbeat_interval: number }).heartbeat_interval;
1105
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
1106
+ heartbeatInterval = setInterval(() => {
1107
+ if (ws.readyState === WebSocket.OPEN) {
1108
+ ws.send(JSON.stringify({ op: 1, d: lastSeq }));
1109
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`);
1110
+ }
1111
+ }, interval);
1112
+ break;
1113
+
1114
+ case 0: // Dispatch
1115
+ log?.info(`[qqbot:${account.accountId}] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
1116
+ if (t === "READY") {
1117
+ const readyData = d as { session_id: string };
1118
+ sessionId = readyData.session_id;
1119
+ log?.info(`[qqbot:${account.accountId}] Ready with ${FULL_INTENTS_DESC}, session: ${sessionId}`);
1120
+ // P1-2: 保存新的 Session 状态
1121
+ saveSession({
1122
+ sessionId,
1123
+ lastSeq,
1124
+ lastConnectedAt: Date.now(),
1125
+ intentLevelIndex: 0,
1126
+ accountId: account.accountId,
1127
+ savedAt: Date.now(),
1128
+ appId: account.appId,
1129
+ });
1130
+ onReady?.(d);
1131
+
1132
+ // 仅 startGateway 后的首次 READY 才发送上线通知
1133
+ // ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
1134
+ if (!isFirstReadyGlobal) {
1135
+ log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
1136
+ } else {
1137
+ isFirstReadyGlobal = false;
1138
+ sendStartupGreetings(adminCtx, "READY");
1139
+ } // end isFirstReady
1140
+ } else if (t === "RESUMED") {
1141
+ log?.info(`[qqbot:${account.accountId}] Session resumed`);
1142
+ onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
1143
+ // RESUMED 也属于首次启动(gateway restart 通常走 resume)
1144
+ if (isFirstReadyGlobal) {
1145
+ isFirstReadyGlobal = false;
1146
+ sendStartupGreetings(adminCtx, "RESUMED");
1147
+ }
1148
+ // P1-2: 更新 Session 连接时间
1149
+ if (sessionId) {
1150
+ saveSession({
1151
+ sessionId,
1152
+ lastSeq,
1153
+ lastConnectedAt: Date.now(),
1154
+ intentLevelIndex: 0,
1155
+ accountId: account.accountId,
1156
+ savedAt: Date.now(),
1157
+ appId: account.appId,
1158
+ });
1159
+ }
1160
+ } else if (t === "C2C_MESSAGE_CREATE") {
1161
+ const event = d as C2CMessageEvent;
1162
+ // P1-3: 记录已知用户
1163
+ recordKnownUser({
1164
+ openid: event.author.user_openid,
1165
+ type: "c2c",
1166
+ accountId: account.accountId,
1167
+ });
1168
+ // 解析引用索引
1169
+ const c2cRefs = parseRefIndices(event.message_scene?.ext);
1170
+ // 斜杠指令拦截 → 不匹配则入队
1171
+ trySlashCommandOrEnqueue({
1172
+ type: "c2c",
1173
+ senderId: event.author.user_openid,
1174
+ content: event.content,
1175
+ messageId: event.id,
1176
+ timestamp: event.timestamp,
1177
+ attachments: event.attachments,
1178
+ refMsgIdx: c2cRefs.refMsgIdx,
1179
+ msgIdx: c2cRefs.msgIdx,
1180
+ });
1181
+ } else if (t === "AT_MESSAGE_CREATE") {
1182
+ const event = d as GuildMessageEvent;
1183
+ // P1-3: 记录已知用户(频道用户)
1184
+ recordKnownUser({
1185
+ openid: event.author.id,
1186
+ type: "c2c", // 频道用户按 c2c 类型存储
1187
+ nickname: event.author.username,
1188
+ accountId: account.accountId,
1189
+ });
1190
+ const guildRefs = parseRefIndices((event as any).message_scene?.ext);
1191
+ trySlashCommandOrEnqueue({
1192
+ type: "guild",
1193
+ senderId: event.author.id,
1194
+ senderName: event.author.username,
1195
+ content: event.content,
1196
+ messageId: event.id,
1197
+ timestamp: event.timestamp,
1198
+ channelId: event.channel_id,
1199
+ guildId: event.guild_id,
1200
+ attachments: event.attachments,
1201
+ refMsgIdx: guildRefs.refMsgIdx,
1202
+ msgIdx: guildRefs.msgIdx,
1203
+ });
1204
+ } else if (t === "DIRECT_MESSAGE_CREATE") {
1205
+ const event = d as GuildMessageEvent;
1206
+ // P1-3: 记录已知用户(频道私信用户)
1207
+ recordKnownUser({
1208
+ openid: event.author.id,
1209
+ type: "c2c",
1210
+ nickname: event.author.username,
1211
+ accountId: account.accountId,
1212
+ });
1213
+ const dmRefs = parseRefIndices((event as any).message_scene?.ext);
1214
+ trySlashCommandOrEnqueue({
1215
+ type: "dm",
1216
+ senderId: event.author.id,
1217
+ senderName: event.author.username,
1218
+ content: event.content,
1219
+ messageId: event.id,
1220
+ timestamp: event.timestamp,
1221
+ guildId: event.guild_id,
1222
+ attachments: event.attachments,
1223
+ refMsgIdx: dmRefs.refMsgIdx,
1224
+ msgIdx: dmRefs.msgIdx,
1225
+ });
1226
+ } else if (t === "GROUP_AT_MESSAGE_CREATE") {
1227
+ const event = d as GroupMessageEvent;
1228
+ // P1-3: 记录已知用户(群组用户)
1229
+ recordKnownUser({
1230
+ openid: event.author.member_openid,
1231
+ type: "group",
1232
+ groupOpenid: event.group_openid,
1233
+ accountId: account.accountId,
1234
+ });
1235
+ const groupRefs = parseRefIndices(event.message_scene?.ext);
1236
+ trySlashCommandOrEnqueue({
1237
+ type: "group",
1238
+ senderId: event.author.member_openid,
1239
+ content: event.content,
1240
+ messageId: event.id,
1241
+ timestamp: event.timestamp,
1242
+ groupOpenid: event.group_openid,
1243
+ attachments: event.attachments,
1244
+ refMsgIdx: groupRefs.refMsgIdx,
1245
+ msgIdx: groupRefs.msgIdx,
1246
+ });
1247
+ }
1248
+ break;
1249
+
1250
+ case 11: // Heartbeat ACK
1251
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
1252
+ break;
1253
+
1254
+ case 7: // Reconnect
1255
+ log?.info(`[qqbot:${account.accountId}] Server requested reconnect`);
1256
+ cleanup();
1257
+ scheduleReconnect();
1258
+ break;
1259
+
1260
+ case 9: // Invalid Session
1261
+ const canResume = d as boolean;
1262
+ log?.error(`[qqbot:${account.accountId}] Invalid session (${FULL_INTENTS_DESC}), can resume: ${canResume}, raw: ${rawData}`);
1263
+
1264
+ if (!canResume) {
1265
+ sessionId = null;
1266
+ lastSeq = null;
1267
+ // P1-2: 清除持久化的 Session
1268
+ clearSession(account.accountId);
1269
+ shouldRefreshToken = true;
1270
+ log?.info(`[qqbot:${account.accountId}] Will refresh token and retry with full intents (${FULL_INTENTS_DESC})`);
1271
+ }
1272
+ cleanup();
1273
+ // Invalid Session 后等待一段时间再重连
1274
+ scheduleReconnect(3000);
1275
+ break;
1276
+ }
1277
+ } catch (err) {
1278
+ log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
1279
+ }
1280
+ });
1281
+
1282
+ ws.on("close", (code, reason) => {
1283
+ log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
1284
+ isConnecting = false; // 释放锁
1285
+
1286
+ // 根据错误码处理(参考 QQ 官方文档)
1287
+ // 4004: CODE_INVALID_TOKEN - Token 无效,需刷新 token 重新连接
1288
+ // 4006: CODE_SESSION_NO_LONGER_VALID - 会话失效,需重新 identify
1289
+ // 4007: CODE_INVALID_SEQ - Resume 时 seq 无效,需重新 identify
1290
+ // 4008: CODE_RATE_LIMITED - 限流断开,等待后重连
1291
+ // 4009: CODE_SESSION_TIMED_OUT - 会话超时,需重新 identify
1292
+ // 4900-4913: 内部错误,需要重新 identify
1293
+ // 4914: 机器人已下架
1294
+ // 4915: 机器人已封禁
1295
+ if (code === 4914 || code === 4915) {
1296
+ log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`);
1297
+ cleanup();
1298
+ // 不重连,直接退出
1299
+ return;
1300
+ }
1301
+
1302
+ // 4004: Token 无效,强制刷新 token 后重连
1303
+ if (code === 4004) {
1304
+ log?.info(`[qqbot:${account.accountId}] Invalid token (4004), will refresh token and reconnect`);
1305
+ shouldRefreshToken = true;
1306
+ cleanup();
1307
+ if (!isAborted) {
1308
+ scheduleReconnect();
1309
+ }
1310
+ return;
1311
+ }
1312
+
1313
+ // 4008: 限流断开,等待后重连(不需要重新 identify)
1314
+ if (code === 4008) {
1315
+ log?.info(`[qqbot:${account.accountId}] Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms before reconnect`);
1316
+ cleanup();
1317
+ if (!isAborted) {
1318
+ scheduleReconnect(RATE_LIMIT_DELAY);
1319
+ }
1320
+ return;
1321
+ }
1322
+
1323
+ // 4006/4007/4009: 会话失效或超时,需要清除 session 重新 identify
1324
+ if (code === 4006 || code === 4007 || code === 4009) {
1325
+ const codeDesc: Record<number, string> = {
1326
+ 4006: "session no longer valid",
1327
+ 4007: "invalid seq on resume",
1328
+ 4009: "session timed out",
1329
+ };
1330
+ log?.info(`[qqbot:${account.accountId}] Error ${code} (${codeDesc[code]}), will re-identify`);
1331
+ sessionId = null;
1332
+ lastSeq = null;
1333
+ // 清除持久化的 Session
1334
+ clearSession(account.accountId);
1335
+ shouldRefreshToken = true;
1336
+ } else if (code >= 4900 && code <= 4913) {
1337
+ // 4900-4913 内部错误,清除 session 重新 identify
1338
+ log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`);
1339
+ sessionId = null;
1340
+ lastSeq = null;
1341
+ // 清除持久化的 Session
1342
+ clearSession(account.accountId);
1343
+ shouldRefreshToken = true;
1344
+ }
1345
+
1346
+ // 检测是否是快速断开(连接后很快就断了)
1347
+ const connectionDuration = Date.now() - lastConnectTime;
1348
+ if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) {
1349
+ quickDisconnectCount++;
1350
+ log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
1351
+
1352
+ // 如果连续快速断开超过阈值,等待更长时间
1353
+ if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
1354
+ log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`);
1355
+ log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`);
1356
+ quickDisconnectCount = 0;
1357
+ cleanup();
1358
+ // 快速断开太多次,等待更长时间再重连
1359
+ if (!isAborted && code !== 1000) {
1360
+ scheduleReconnect(RATE_LIMIT_DELAY);
1361
+ }
1362
+ return;
1363
+ }
1364
+ } else {
1365
+ // 连接持续时间够长,重置计数
1366
+ quickDisconnectCount = 0;
1367
+ }
1368
+
1369
+ cleanup();
1370
+
1371
+ // 非正常关闭则重连
1372
+ if (!isAborted && code !== 1000) {
1373
+ scheduleReconnect();
1374
+ }
1375
+ });
1376
+
1377
+ ws.on("error", (err) => {
1378
+ log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
1379
+ onError?.(err);
1380
+ });
1381
+
1382
+ } catch (err) {
1383
+ isConnecting = false; // 释放锁
1384
+ const errMsg = String(err);
1385
+ log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`);
1386
+
1387
+ // 如果是频率限制错误,等待更长时间
1388
+ if (errMsg.includes("Too many requests") || errMsg.includes("100001")) {
1389
+ log?.info(`[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`);
1390
+ scheduleReconnect(RATE_LIMIT_DELAY);
1391
+ } else {
1392
+ scheduleReconnect();
1393
+ }
1394
+ }
1395
+ };
1396
+
1397
+ // 开始连接
1398
+ await connect();
1399
+
1400
+ // 等待 abort 信号
1401
+ return new Promise((resolve) => {
1402
+ abortSignal.addEventListener("abort", () => resolve());
1403
+ });
1404
+ }