@tencent-connect/openclaw-qqbot 1.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +393 -0
  3. package/README.zh.md +390 -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 +22 -0
  8. package/dist/src/api.d.ts +138 -0
  9. package/dist/src/api.js +523 -0
  10. package/dist/src/channel.d.ts +3 -0
  11. package/dist/src/channel.js +337 -0
  12. package/dist/src/config.d.ts +25 -0
  13. package/dist/src/config.js +156 -0
  14. package/dist/src/gateway.d.ts +18 -0
  15. package/dist/src/gateway.js +2315 -0
  16. package/dist/src/image-server.d.ts +62 -0
  17. package/dist/src/image-server.js +401 -0
  18. package/dist/src/known-users.d.ts +100 -0
  19. package/dist/src/known-users.js +263 -0
  20. package/dist/src/onboarding.d.ts +10 -0
  21. package/dist/src/onboarding.js +203 -0
  22. package/dist/src/outbound.d.ts +150 -0
  23. package/dist/src/outbound.js +1175 -0
  24. package/dist/src/proactive.d.ts +170 -0
  25. package/dist/src/proactive.js +399 -0
  26. package/dist/src/runtime.d.ts +3 -0
  27. package/dist/src/runtime.js +10 -0
  28. package/dist/src/session-store.d.ts +52 -0
  29. package/dist/src/session-store.js +254 -0
  30. package/dist/src/types.d.ts +145 -0
  31. package/dist/src/types.js +1 -0
  32. package/dist/src/utils/audio-convert.d.ts +73 -0
  33. package/dist/src/utils/audio-convert.js +645 -0
  34. package/dist/src/utils/file-utils.d.ts +46 -0
  35. package/dist/src/utils/file-utils.js +107 -0
  36. package/dist/src/utils/image-size.d.ts +51 -0
  37. package/dist/src/utils/image-size.js +234 -0
  38. package/dist/src/utils/media-tags.d.ts +14 -0
  39. package/dist/src/utils/media-tags.js +120 -0
  40. package/dist/src/utils/payload.d.ts +112 -0
  41. package/dist/src/utils/payload.js +186 -0
  42. package/dist/src/utils/platform.d.ts +126 -0
  43. package/dist/src/utils/platform.js +358 -0
  44. package/dist/src/utils/upload-cache.d.ts +34 -0
  45. package/dist/src/utils/upload-cache.js +93 -0
  46. package/index.ts +27 -0
  47. package/moltbot.plugin.json +16 -0
  48. package/node_modules/@eshaz/web-worker/LICENSE +201 -0
  49. package/node_modules/@eshaz/web-worker/README.md +134 -0
  50. package/node_modules/@eshaz/web-worker/browser.js +17 -0
  51. package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
  52. package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
  53. package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
  54. package/node_modules/@eshaz/web-worker/node.js +223 -0
  55. package/node_modules/@eshaz/web-worker/package.json +54 -0
  56. package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
  57. package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
  58. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
  59. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
  60. package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
  61. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
  62. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
  63. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
  64. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
  65. package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
  66. package/node_modules/mpg123-decoder/README.md +265 -0
  67. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
  68. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
  69. package/node_modules/mpg123-decoder/index.js +8 -0
  70. package/node_modules/mpg123-decoder/package.json +58 -0
  71. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
  72. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
  73. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
  74. package/node_modules/mpg123-decoder/types.d.ts +30 -0
  75. package/node_modules/silk-wasm/LICENSE +21 -0
  76. package/node_modules/silk-wasm/README.md +85 -0
  77. package/node_modules/silk-wasm/lib/index.cjs +16 -0
  78. package/node_modules/silk-wasm/lib/index.d.ts +70 -0
  79. package/node_modules/silk-wasm/lib/index.mjs +16 -0
  80. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  81. package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
  82. package/node_modules/silk-wasm/package.json +39 -0
  83. package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
  84. package/node_modules/simple-yenc/.prettierignore +1 -0
  85. package/node_modules/simple-yenc/LICENSE +7 -0
  86. package/node_modules/simple-yenc/README.md +163 -0
  87. package/node_modules/simple-yenc/dist/esm.js +1 -0
  88. package/node_modules/simple-yenc/dist/index.js +1 -0
  89. package/node_modules/simple-yenc/package.json +50 -0
  90. package/node_modules/simple-yenc/rollup.config.js +27 -0
  91. package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
  92. package/node_modules/ws/LICENSE +20 -0
  93. package/node_modules/ws/README.md +548 -0
  94. package/node_modules/ws/browser.js +8 -0
  95. package/node_modules/ws/index.js +13 -0
  96. package/node_modules/ws/lib/buffer-util.js +131 -0
  97. package/node_modules/ws/lib/constants.js +19 -0
  98. package/node_modules/ws/lib/event-target.js +292 -0
  99. package/node_modules/ws/lib/extension.js +203 -0
  100. package/node_modules/ws/lib/limiter.js +55 -0
  101. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  102. package/node_modules/ws/lib/receiver.js +706 -0
  103. package/node_modules/ws/lib/sender.js +602 -0
  104. package/node_modules/ws/lib/stream.js +161 -0
  105. package/node_modules/ws/lib/subprotocol.js +62 -0
  106. package/node_modules/ws/lib/validation.js +152 -0
  107. package/node_modules/ws/lib/websocket-server.js +554 -0
  108. package/node_modules/ws/lib/websocket.js +1393 -0
  109. package/node_modules/ws/package.json +69 -0
  110. package/node_modules/ws/wrapper.mjs +8 -0
  111. package/openclaw.plugin.json +16 -0
  112. package/package.json +76 -0
  113. package/scripts/proactive-api-server.ts +356 -0
  114. package/scripts/pull-latest.sh +316 -0
  115. package/scripts/send-proactive.ts +273 -0
  116. package/scripts/set-markdown.sh +156 -0
  117. package/scripts/upgrade-and-run.sh +525 -0
  118. package/scripts/upgrade.sh +127 -0
  119. package/skills/qqbot-cron/SKILL.md +513 -0
  120. package/skills/qqbot-media/SKILL.md +194 -0
  121. package/src/api.ts +704 -0
  122. package/src/channel.ts +368 -0
  123. package/src/config.ts +182 -0
  124. package/src/gateway.ts +2459 -0
  125. package/src/image-server.ts +474 -0
  126. package/src/known-users.ts +353 -0
  127. package/src/onboarding.ts +274 -0
  128. package/src/openclaw-plugin-sdk.d.ts +483 -0
  129. package/src/outbound.ts +1301 -0
  130. package/src/proactive.ts +530 -0
  131. package/src/runtime.ts +14 -0
  132. package/src/session-store.ts +303 -0
  133. package/src/types.ts +153 -0
  134. package/src/utils/audio-convert.ts +738 -0
  135. package/src/utils/file-utils.ts +122 -0
  136. package/src/utils/image-size.ts +266 -0
  137. package/src/utils/media-tags.ts +134 -0
  138. package/src/utils/payload.ts +265 -0
  139. package/src/utils/platform.ts +404 -0
  140. package/src/utils/upload-cache.ts +128 -0
  141. package/tsconfig.json +16 -0
@@ -0,0 +1,2315 @@
1
+ import WebSocket from "ws";
2
+ import path from "node:path";
3
+ import * as fs from "node:fs";
4
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify } 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 { startImageServer, isImageServerRunning, downloadFile } from "./image-server.js";
9
+ import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
10
+ import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload } from "./utils/payload.js";
11
+ import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk, audioFileToSilkBase64, waitForFile, isAudioFile } from "./utils/audio-convert.js";
12
+ import { normalizeMediaTags } from "./utils/media-tags.js";
13
+ import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
14
+ import { getQQBotDataDir, isLocalPath as isLocalFilePath, looksLikeLocalPath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
15
+ function resolveSTTConfig(cfg) {
16
+ const c = cfg;
17
+ // 优先使用 channels.qqbot.stt(插件专属配置)
18
+ const channelStt = c?.channels?.qqbot?.stt;
19
+ if (channelStt && channelStt.enabled !== false) {
20
+ const providerId = channelStt?.provider || "openai";
21
+ const providerCfg = c?.models?.providers?.[providerId];
22
+ const baseUrl = channelStt?.baseUrl || providerCfg?.baseUrl;
23
+ const apiKey = channelStt?.apiKey || providerCfg?.apiKey;
24
+ const model = channelStt?.model || "whisper-1";
25
+ if (baseUrl && apiKey) {
26
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
27
+ }
28
+ }
29
+ // 回退到 tools.media.audio.models[0](框架级配置)
30
+ const audioModelEntry = c?.tools?.media?.audio?.models?.[0];
31
+ if (audioModelEntry) {
32
+ const providerId = audioModelEntry?.provider || "openai";
33
+ const providerCfg = c?.models?.providers?.[providerId];
34
+ const baseUrl = audioModelEntry?.baseUrl || providerCfg?.baseUrl;
35
+ const apiKey = audioModelEntry?.apiKey || providerCfg?.apiKey;
36
+ const model = audioModelEntry?.model || "whisper-1";
37
+ if (baseUrl && apiKey) {
38
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ async function transcribeAudio(audioPath, cfg) {
44
+ const sttCfg = resolveSTTConfig(cfg);
45
+ if (!sttCfg)
46
+ return null;
47
+ const fileBuffer = fs.readFileSync(audioPath);
48
+ const fileName = sanitizeFileName(path.basename(audioPath));
49
+ const mime = fileName.endsWith(".wav") ? "audio/wav"
50
+ : fileName.endsWith(".mp3") ? "audio/mpeg"
51
+ : fileName.endsWith(".ogg") ? "audio/ogg"
52
+ : "application/octet-stream";
53
+ const form = new FormData();
54
+ form.append("file", new Blob([fileBuffer], { type: mime }), fileName);
55
+ form.append("model", sttCfg.model);
56
+ const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
57
+ method: "POST",
58
+ headers: { "Authorization": `Bearer ${sttCfg.apiKey}` },
59
+ body: form,
60
+ });
61
+ if (!resp.ok) {
62
+ const detail = await resp.text().catch(() => "");
63
+ throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
64
+ }
65
+ const result = await resp.json();
66
+ return result.text?.trim() || null;
67
+ }
68
+ // QQ Bot intents - 按权限级别分组
69
+ const INTENTS = {
70
+ // 基础权限(默认有)
71
+ GUILDS: 1 << 0, // 频道相关
72
+ GUILD_MEMBERS: 1 << 1, // 频道成员
73
+ PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域)
74
+ // 需要申请的权限
75
+ DIRECT_MESSAGE: 1 << 12, // 频道私信
76
+ GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
77
+ };
78
+ // 权限级别:从高到低依次尝试
79
+ const INTENT_LEVELS = [
80
+ // Level 0: 完整权限(群聊 + 私信 + 频道)
81
+ {
82
+ name: "full",
83
+ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
84
+ description: "群聊+私信+频道",
85
+ },
86
+ // Level 1: 群聊 + 频道(无私信)
87
+ {
88
+ name: "group+channel",
89
+ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GROUP_AND_C2C,
90
+ description: "群聊+频道",
91
+ },
92
+ // Level 2: 仅频道(基础权限)
93
+ {
94
+ name: "channel-only",
95
+ intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS,
96
+ description: "仅频道消息",
97
+ },
98
+ ];
99
+ // 重连配置
100
+ const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
101
+ const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
102
+ const MAX_RECONNECT_ATTEMPTS = 100;
103
+ const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
104
+ const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
105
+ // 图床服务器配置(可通过环境变量覆盖)
106
+ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10);
107
+ // 使用绝对路径,确保文件保存和读取使用同一目录
108
+ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
109
+ // 消息队列配置(异步处理,防止阻塞心跳)
110
+ const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度(全局总量)
111
+ const PER_USER_QUEUE_SIZE = 20; // 单用户最大排队数
112
+ const MAX_CONCURRENT_USERS = 10; // 最大同时处理的用户数
113
+ // ============ 消息回复限流器 ============
114
+ // 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息
115
+ const MESSAGE_REPLY_LIMIT = 4;
116
+ const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
117
+ const messageReplyTracker = new Map();
118
+ /**
119
+ * 检查是否可以回复该消息(限流检查)
120
+ * @param messageId 消息ID
121
+ * @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数
122
+ */
123
+ function checkMessageReplyLimit(messageId) {
124
+ const now = Date.now();
125
+ const record = messageReplyTracker.get(messageId);
126
+ // 清理过期记录(定期清理,避免内存泄漏)
127
+ if (messageReplyTracker.size > 10000) {
128
+ for (const [id, rec] of messageReplyTracker) {
129
+ if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
130
+ messageReplyTracker.delete(id);
131
+ }
132
+ }
133
+ }
134
+ if (!record) {
135
+ return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
136
+ }
137
+ // 检查是否过期
138
+ if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
139
+ messageReplyTracker.delete(messageId);
140
+ return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
141
+ }
142
+ // 检查是否超过限制
143
+ const remaining = MESSAGE_REPLY_LIMIT - record.count;
144
+ return { allowed: remaining > 0, remaining: Math.max(0, remaining) };
145
+ }
146
+ /**
147
+ * 记录一次消息回复
148
+ * @param messageId 消息ID
149
+ */
150
+ function recordMessageReply(messageId) {
151
+ const now = Date.now();
152
+ const record = messageReplyTracker.get(messageId);
153
+ if (!record) {
154
+ messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
155
+ }
156
+ else {
157
+ // 检查是否过期,过期则重新计数
158
+ if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
159
+ messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
160
+ }
161
+ else {
162
+ record.count++;
163
+ }
164
+ }
165
+ }
166
+ // ============ QQ 表情标签解析 ============
167
+ /**
168
+ * 解析 QQ 表情标签,将 <faceType=1,faceId="13",ext="base64..."> 格式
169
+ * 替换为 【表情: 中文名】 格式
170
+ * ext 字段为 Base64 编码的 JSON,格式如 {"text":"呲牙"}
171
+ */
172
+ function parseFaceTags(text) {
173
+ if (!text)
174
+ return text;
175
+ // 匹配 <faceType=...,faceId="...",ext="..."> 格式的表情标签
176
+ return text.replace(/<faceType=\d+,faceId="[^"]*",ext="([^"]*)">/g, (_match, ext) => {
177
+ try {
178
+ const decoded = Buffer.from(ext, "base64").toString("utf-8");
179
+ const parsed = JSON.parse(decoded);
180
+ const faceName = parsed.text || "未知表情";
181
+ return `【表情: ${faceName}】`;
182
+ }
183
+ catch {
184
+ return _match;
185
+ }
186
+ });
187
+ }
188
+ // ============ 媒体发送友好错误提示 ============
189
+ /**
190
+ * 将媒体上传/发送错误转为对用户友好的提示文案
191
+ */
192
+ function formatMediaErrorMessage(mediaType, err) {
193
+ const msg = err instanceof Error ? err.message : String(err);
194
+ if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
195
+ return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
196
+ }
197
+ if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
198
+ return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
199
+ }
200
+ if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
201
+ return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
202
+ }
203
+ if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
204
+ return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
205
+ }
206
+ return `抱歉,${mediaType}发送失败了,请稍后再试~`;
207
+ }
208
+ // ============ 内部标记过滤 ============
209
+ /**
210
+ * 过滤内部标记(如 [[reply_to: xxx]])
211
+ * 这些标记可能被 AI 错误地学习并输出,需要在发送前移除
212
+ */
213
+ function filterInternalMarkers(text) {
214
+ if (!text)
215
+ return text;
216
+ // 过滤 [[xxx: yyy]] 格式的内部标记
217
+ // 例如: [[reply_to: ROBOT1.0_kbc...]]
218
+ let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, "");
219
+ // 清理可能产生的多余空行
220
+ result = result.replace(/\n{3,}/g, "\n\n").trim();
221
+ return result;
222
+ }
223
+ /**
224
+ * 启动图床服务器
225
+ */
226
+ async function ensureImageServer(log, publicBaseUrl) {
227
+ if (isImageServerRunning()) {
228
+ return publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`;
229
+ }
230
+ try {
231
+ const config = {
232
+ port: IMAGE_SERVER_PORT,
233
+ storageDir: IMAGE_SERVER_DIR,
234
+ // 使用用户配置的公网地址,而不是 0.0.0.0
235
+ baseUrl: publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`,
236
+ ttlSeconds: 3600, // 1 小时过期
237
+ };
238
+ await startImageServer(config);
239
+ log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}, baseUrl: ${config.baseUrl}`);
240
+ return config.baseUrl;
241
+ }
242
+ catch (err) {
243
+ log?.error(`[qqbot] Failed to start image server: ${err}`);
244
+ return null;
245
+ }
246
+ }
247
+ /**
248
+ * 启动 Gateway WebSocket 连接(带自动重连)
249
+ * 支持流式消息发送
250
+ */
251
+ export async function startGateway(ctx) {
252
+ const { account, abortSignal, cfg, onReady, onError, log } = ctx;
253
+ if (!account.appId || !account.clientSecret) {
254
+ throw new Error("QQBot not configured (missing appId or clientSecret)");
255
+ }
256
+ // 启动环境诊断(首次连接时执行)
257
+ const diag = await runDiagnostics();
258
+ if (diag.warnings.length > 0) {
259
+ for (const w of diag.warnings) {
260
+ log?.info(`[qqbot:${account.accountId}] ${w}`);
261
+ }
262
+ }
263
+ // 初始化 API 配置(markdown 支持)
264
+ initApiConfig({
265
+ markdownSupport: account.markdownSupport,
266
+ });
267
+ log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`);
268
+ // TTS 配置验证
269
+ const ttsCfg = resolveTTSConfig(cfg);
270
+ if (ttsCfg) {
271
+ const maskedKey = ttsCfg.apiKey.length > 8
272
+ ? `${ttsCfg.apiKey.slice(0, 4)}****${ttsCfg.apiKey.slice(-4)}`
273
+ : "****";
274
+ log?.info(`[qqbot:${account.accountId}] TTS configured: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, baseUrl=${ttsCfg.baseUrl}`);
275
+ log?.info(`[qqbot:${account.accountId}] TTS apiKey: ${maskedKey}${ttsCfg.queryParams ? `, queryParams=${JSON.stringify(ttsCfg.queryParams)}` : ""}${ttsCfg.speed !== undefined ? `, speed=${ttsCfg.speed}` : ""}`);
276
+ }
277
+ else {
278
+ log?.info(`[qqbot:${account.accountId}] TTS not configured (voice messages will be unavailable)`);
279
+ }
280
+ // 如果配置了公网 URL,启动图床服务器
281
+ let imageServerBaseUrl = null;
282
+ if (account.imageServerBaseUrl) {
283
+ // 使用用户配置的公网地址作为 baseUrl
284
+ await ensureImageServer(log, account.imageServerBaseUrl);
285
+ imageServerBaseUrl = account.imageServerBaseUrl;
286
+ log?.info(`[qqbot:${account.accountId}] Image server enabled with URL: ${imageServerBaseUrl}`);
287
+ }
288
+ else {
289
+ log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
290
+ }
291
+ let reconnectAttempts = 0;
292
+ let isAborted = false;
293
+ let currentWs = null;
294
+ let heartbeatInterval = null;
295
+ let sessionId = null;
296
+ let lastSeq = null;
297
+ let lastConnectTime = 0; // 上次连接成功的时间
298
+ let quickDisconnectCount = 0; // 连续快速断开次数
299
+ let isConnecting = false; // 防止并发连接
300
+ let reconnectTimer = null; // 重连定时器
301
+ let shouldRefreshToken = false; // 下次连接是否需要刷新 token
302
+ let intentLevelIndex = 0; // 当前尝试的权限级别索引
303
+ let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
304
+ // ============ P1-2: 尝试从持久化存储恢复 Session ============
305
+ // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
306
+ const savedSession = loadSession(account.accountId, account.appId);
307
+ if (savedSession) {
308
+ sessionId = savedSession.sessionId;
309
+ lastSeq = savedSession.lastSeq;
310
+ intentLevelIndex = savedSession.intentLevelIndex;
311
+ lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
312
+ log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
313
+ }
314
+ // ============ 按用户并发的消息队列(同用户串行,跨用户并行) ============
315
+ // 每个用户有独立队列,同一用户的消息串行处理(保持时序),
316
+ // 不同用户的消息并行处理(互不阻塞)。
317
+ const userQueues = new Map(); // peerId → 消息队列
318
+ const activeUsers = new Set(); // 正在处理中的用户
319
+ let messagesProcessed = 0;
320
+ let handleMessageFnRef = null;
321
+ let totalEnqueued = 0; // 全局已入队总数(用于溢出保护)
322
+ // 获取消息的路由 key(决定并发隔离粒度)
323
+ const getMessagePeerId = (msg) => {
324
+ if (msg.type === "guild")
325
+ return `guild:${msg.channelId ?? "unknown"}`;
326
+ if (msg.type === "group")
327
+ return `group:${msg.groupOpenid ?? "unknown"}`;
328
+ return `dm:${msg.senderId}`;
329
+ };
330
+ const enqueueMessage = (msg) => {
331
+ const peerId = getMessagePeerId(msg);
332
+ let queue = userQueues.get(peerId);
333
+ if (!queue) {
334
+ queue = [];
335
+ userQueues.set(peerId, queue);
336
+ }
337
+ // 单用户队列溢出保护
338
+ if (queue.length >= PER_USER_QUEUE_SIZE) {
339
+ const dropped = queue.shift();
340
+ log?.error(`[qqbot:${account.accountId}] Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`);
341
+ }
342
+ // 全局总量保护
343
+ totalEnqueued++;
344
+ if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
345
+ log?.error(`[qqbot:${account.accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`);
346
+ }
347
+ queue.push(msg);
348
+ log?.debug?.(`[qqbot:${account.accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`);
349
+ // 如果该用户没有正在处理的消息,立即启动处理
350
+ drainUserQueue(peerId);
351
+ };
352
+ // 处理指定用户队列中的消息(串行)
353
+ const drainUserQueue = async (peerId) => {
354
+ if (activeUsers.has(peerId))
355
+ return; // 该用户已有处理中的消息
356
+ if (activeUsers.size >= MAX_CONCURRENT_USERS) {
357
+ log?.info(`[qqbot:${account.accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`);
358
+ return; // 达到并发上限,等待其他用户处理完后触发
359
+ }
360
+ const queue = userQueues.get(peerId);
361
+ if (!queue || queue.length === 0) {
362
+ userQueues.delete(peerId);
363
+ return;
364
+ }
365
+ activeUsers.add(peerId);
366
+ try {
367
+ while (queue.length > 0 && !isAborted) {
368
+ const msg = queue.shift();
369
+ totalEnqueued = Math.max(0, totalEnqueued - 1);
370
+ try {
371
+ if (handleMessageFnRef) {
372
+ await handleMessageFnRef(msg);
373
+ messagesProcessed++;
374
+ }
375
+ }
376
+ catch (err) {
377
+ log?.error(`[qqbot:${account.accountId}] Message processor error for ${peerId}: ${err}`);
378
+ }
379
+ }
380
+ }
381
+ finally {
382
+ activeUsers.delete(peerId);
383
+ userQueues.delete(peerId);
384
+ // 处理完后,检查是否有等待并发槽位的用户
385
+ for (const [waitingPeerId, waitingQueue] of userQueues) {
386
+ if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
387
+ drainUserQueue(waitingPeerId);
388
+ break; // 每次只唤醒一个,避免瞬间并发激增
389
+ }
390
+ }
391
+ }
392
+ };
393
+ const startMessageProcessor = (handleMessageFn) => {
394
+ handleMessageFnRef = handleMessageFn;
395
+ log?.info(`[qqbot:${account.accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`);
396
+ };
397
+ abortSignal.addEventListener("abort", () => {
398
+ isAborted = true;
399
+ if (reconnectTimer) {
400
+ clearTimeout(reconnectTimer);
401
+ reconnectTimer = null;
402
+ }
403
+ cleanup();
404
+ // P1-1: 停止后台 Token 刷新
405
+ stopBackgroundTokenRefresh(account.appId);
406
+ // P1-3: 保存已知用户数据
407
+ flushKnownUsers();
408
+ });
409
+ const cleanup = () => {
410
+ if (heartbeatInterval) {
411
+ clearInterval(heartbeatInterval);
412
+ heartbeatInterval = null;
413
+ }
414
+ if (currentWs && (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING)) {
415
+ currentWs.close();
416
+ }
417
+ currentWs = null;
418
+ };
419
+ const getReconnectDelay = () => {
420
+ const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1);
421
+ return RECONNECT_DELAYS[idx];
422
+ };
423
+ const scheduleReconnect = (customDelay) => {
424
+ if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
425
+ log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`);
426
+ return;
427
+ }
428
+ // 取消已有的重连定时器
429
+ if (reconnectTimer) {
430
+ clearTimeout(reconnectTimer);
431
+ reconnectTimer = null;
432
+ }
433
+ const delay = customDelay ?? getReconnectDelay();
434
+ reconnectAttempts++;
435
+ log?.info(`[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
436
+ reconnectTimer = setTimeout(() => {
437
+ reconnectTimer = null;
438
+ if (!isAborted) {
439
+ connect();
440
+ }
441
+ }, delay);
442
+ };
443
+ const connect = async () => {
444
+ // 防止并发连接
445
+ if (isConnecting) {
446
+ log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`);
447
+ return;
448
+ }
449
+ isConnecting = true;
450
+ try {
451
+ cleanup();
452
+ // 如果标记了需要刷新 token,则清除缓存
453
+ if (shouldRefreshToken) {
454
+ log?.info(`[qqbot:${account.accountId}] Refreshing token...`);
455
+ clearTokenCache(account.appId);
456
+ shouldRefreshToken = false;
457
+ }
458
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
459
+ log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
460
+ const gatewayUrl = await getGatewayUrl(accessToken);
461
+ log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
462
+ const ws = new WebSocket(gatewayUrl);
463
+ currentWs = ws;
464
+ const pluginRuntime = getQQBotRuntime();
465
+ // 处理收到的消息
466
+ const handleMessage = async (event) => {
467
+ log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`);
468
+ log?.info(`[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`);
469
+ if (event.attachments?.length) {
470
+ log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
471
+ }
472
+ pluginRuntime.channel.activity.record({
473
+ channel: "qqbot",
474
+ accountId: account.accountId,
475
+ direction: "inbound",
476
+ });
477
+ // 发送输入状态提示(非关键,失败不影响主流程)
478
+ try {
479
+ let token = await getAccessToken(account.appId, account.clientSecret);
480
+ try {
481
+ await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
482
+ }
483
+ catch (notifyErr) {
484
+ const errMsg = String(notifyErr);
485
+ if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
486
+ log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
487
+ clearTokenCache(account.appId);
488
+ token = await getAccessToken(account.appId, account.clientSecret);
489
+ await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
490
+ }
491
+ else {
492
+ throw notifyErr;
493
+ }
494
+ }
495
+ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
496
+ }
497
+ catch (err) {
498
+ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
499
+ }
500
+ const isGroupChat = event.type === "guild" || event.type === "group";
501
+ // peerId 只放纯 ID,类型信息由 peer.kind 表达
502
+ // 群聊:用 groupOpenid(框架根据 kind:"group" 区分)
503
+ // 私聊:用 senderId(框架根据 dmScope 决定隔离粒度)
504
+ const peerId = event.type === "guild" ? (event.channelId ?? "unknown")
505
+ : event.type === "group" ? (event.groupOpenid ?? "unknown")
506
+ : event.senderId;
507
+ const route = pluginRuntime.channel.routing.resolveAgentRoute({
508
+ cfg,
509
+ channel: "qqbot",
510
+ accountId: account.accountId,
511
+ peer: {
512
+ kind: isGroupChat ? "group" : "direct",
513
+ id: peerId,
514
+ },
515
+ });
516
+ const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
517
+ // 组装消息体
518
+ // 静态系统提示已移至 skills/qqbot-cron/SKILL.md 和 skills/qqbot-media/SKILL.md
519
+ // BodyForAgent 只保留必要的动态上下文信息
520
+ // ============ 用户标识信息 ============
521
+ // 收集额外的系统提示(如果配置了账户级别的 systemPrompt)
522
+ const systemPrompts = [];
523
+ if (account.systemPrompt) {
524
+ systemPrompts.push(account.systemPrompt);
525
+ }
526
+ // 处理附件(图片等)- 下载到本地供 openclaw 访问
527
+ let attachmentInfo = "";
528
+ const imageUrls = [];
529
+ const imageMediaTypes = [];
530
+ const voiceTranscripts = [];
531
+ // 存到 .openclaw/qqbot 目录下的 downloads 文件夹
532
+ const downloadDir = getQQBotDataDir("downloads");
533
+ if (event.attachments?.length) {
534
+ const otherAttachments = [];
535
+ for (const att of event.attachments) {
536
+ // 修复 QQ 返回的 // 前缀 URL
537
+ const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
538
+ // 语音附件:优先下载 WAV(voice_wav_url),减少 SILK→WAV 转换
539
+ const isVoice = isVoiceAttachment(att);
540
+ let localPath = null;
541
+ let audioPath = null; // 用于 STT 的音频路径
542
+ if (isVoice && att.voice_wav_url) {
543
+ const wavUrl = att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url;
544
+ const wavLocalPath = await downloadFile(wavUrl, downloadDir);
545
+ if (wavLocalPath) {
546
+ localPath = wavLocalPath;
547
+ audioPath = wavLocalPath;
548
+ log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`);
549
+ }
550
+ else {
551
+ log?.error(`[qqbot:${account.accountId}] Failed to download voice_wav_url, falling back to original URL`);
552
+ }
553
+ }
554
+ // WAV 下载失败或不是语音附件:下载原始文件
555
+ if (!localPath) {
556
+ localPath = await downloadFile(attUrl, downloadDir, att.filename);
557
+ }
558
+ if (localPath) {
559
+ if (att.content_type?.startsWith("image/")) {
560
+ imageUrls.push(localPath);
561
+ imageMediaTypes.push(att.content_type);
562
+ }
563
+ else if (isVoice) {
564
+ // 语音消息处理:先检查 STT 是否可用,避免无意义的转换开销
565
+ const sttCfg = resolveSTTConfig(cfg);
566
+ if (!sttCfg) {
567
+ log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename} (STT not configured, skipping transcription)`);
568
+ voiceTranscripts.push("[语音消息 - 语音识别未配置,无法转录]");
569
+ }
570
+ else {
571
+ // 如果还没有 WAV 路径(voice_wav_url 不可用),需要 SILK→WAV 转换
572
+ if (!audioPath) {
573
+ const sttFormats = account.config?.audioFormatPolicy?.sttDirectFormats;
574
+ log?.info(`[qqbot:${account.accountId}] Voice attachment: ${att.filename}, converting SILK→WAV...`);
575
+ try {
576
+ const wavResult = await convertSilkToWav(localPath, downloadDir);
577
+ if (wavResult) {
578
+ audioPath = wavResult.wavPath;
579
+ log?.info(`[qqbot:${account.accountId}] Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`);
580
+ }
581
+ else {
582
+ audioPath = localPath; // 转换失败,尝试用原始文件
583
+ }
584
+ }
585
+ catch (convertErr) {
586
+ log?.error(`[qqbot:${account.accountId}] Voice conversion failed: ${convertErr}`);
587
+ voiceTranscripts.push("[语音消息 - 格式转换失败]");
588
+ continue;
589
+ }
590
+ }
591
+ // STT 转录
592
+ try {
593
+ const transcript = await transcribeAudio(audioPath, cfg);
594
+ if (transcript) {
595
+ log?.info(`[qqbot:${account.accountId}] STT transcript: ${transcript.slice(0, 100)}...`);
596
+ voiceTranscripts.push(transcript);
597
+ }
598
+ else {
599
+ log?.info(`[qqbot:${account.accountId}] STT returned empty result`);
600
+ voiceTranscripts.push("[语音消息 - 转录结果为空]");
601
+ }
602
+ }
603
+ catch (sttErr) {
604
+ log?.error(`[qqbot:${account.accountId}] STT failed: ${sttErr}`);
605
+ voiceTranscripts.push("[语音消息 - 转录失败]");
606
+ }
607
+ }
608
+ }
609
+ else {
610
+ otherAttachments.push(`[附件: ${localPath}]`);
611
+ }
612
+ log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
613
+ }
614
+ else {
615
+ // 下载失败,fallback 到原始 URL
616
+ log?.error(`[qqbot:${account.accountId}] Failed to download: ${attUrl}`);
617
+ if (att.content_type?.startsWith("image/")) {
618
+ imageUrls.push(attUrl);
619
+ imageMediaTypes.push(att.content_type);
620
+ }
621
+ else {
622
+ otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`);
623
+ }
624
+ }
625
+ }
626
+ if (otherAttachments.length > 0) {
627
+ attachmentInfo += "\n" + otherAttachments.join("\n");
628
+ }
629
+ }
630
+ // 语音转录文本注入到用户消息中
631
+ let voiceText = "";
632
+ if (voiceTranscripts.length > 0) {
633
+ voiceText = voiceTranscripts.length === 1
634
+ ? `[语音消息] ${voiceTranscripts[0]}`
635
+ : voiceTranscripts.map((t, i) => `[语音${i + 1}] ${t}`).join("\n");
636
+ }
637
+ // 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
638
+ const parsedContent = parseFaceTags(event.content);
639
+ const userContent = voiceText
640
+ ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
641
+ : parsedContent + attachmentInfo;
642
+ // Body: 展示用的用户原文(Web UI 看到的)
643
+ const body = pluginRuntime.channel.reply.formatInboundEnvelope({
644
+ channel: "qqbot",
645
+ from: event.senderName ?? event.senderId,
646
+ timestamp: new Date(event.timestamp).getTime(),
647
+ body: userContent,
648
+ chatType: isGroupChat ? "group" : "direct",
649
+ sender: {
650
+ id: event.senderId,
651
+ name: event.senderName,
652
+ },
653
+ envelope: envelopeOptions,
654
+ ...(imageUrls.length > 0 ? { imageUrls } : {}),
655
+ });
656
+ // BodyForAgent: AI 实际看到的完整上下文(动态数据 + 系统提示 + 用户输入)
657
+ const nowMs = Date.now();
658
+ // 构建媒体附件纯数据描述(图片 + 语音统一列出)
659
+ let receivedMediaSection = "";
660
+ if (imageUrls.length > 0) {
661
+ const entries = imageUrls.map((p, i) => ` - ${p} (${imageMediaTypes[i] || "unknown"})`);
662
+ receivedMediaSection = `\n- 附件:\n${entries.join("\n")}`;
663
+ }
664
+ // AI 看到的投递地址必须带完整前缀(qqbot:c2c: / qqbot:group:)
665
+ const qualifiedTarget = isGroupChat ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`;
666
+ // 动态检测 TTS/STT 配置状态
667
+ const hasTTS = !!resolveTTSConfig(cfg);
668
+ const hasSTT = !!resolveSTTConfig(cfg);
669
+ // 语音能力说明:<qqvoice> 标签本身只负责发送已有的音频文件,不依赖插件 TTS。
670
+ // TTS 只是生成音频文件的一种方式,框架侧的 TTS 工具(如 audio_speech)也能生成。
671
+ // 因此始终暴露 <qqvoice> 能力,但根据 TTS 状态给出不同的使用指引。
672
+ const ttsHint = hasTTS
673
+ ? `6. 🎤 插件 TTS 已启用: 如果你有 TTS 工具(如 audio_speech),可用它生成音频文件后用 <qqvoice> 发送`
674
+ : `6. ⚠️ 插件 TTS 未配置: 如果你有 TTS 工具(如 audio_speech),仍可用它生成音频文件后用 <qqvoice> 发送;若无 TTS 工具,则无法主动生成语音`;
675
+ const sttHint = hasSTT
676
+ ? `\n7. 用户发送的语音消息会自动转录为文字`
677
+ : `\n7. 语音识别未配置(STT),无法自动转录用户的语音消息`;
678
+ const voiceSection = `
679
+
680
+ 【发送语音 - 必须遵守】
681
+ 1. 发语音方法: 在回复文本中写 <qqvoice>本地音频文件路径</qqvoice>,系统自动处理
682
+ 2. 示例: "来听听吧! <qqvoice>/tmp/tts/voice.mp3</qqvoice>"
683
+ 3. 支持格式: .silk, .slk, .slac, .amr, .wav, .mp3, .ogg, .pcm
684
+ 4. ⚠️ <qqvoice> 只用于语音文件,图片请用 <qqimg>;两者不要混用
685
+ 5. 发送语音时,不要重复输出语音中已朗读的文字内容;语音前后的文字应是补充信息而非语音的文字版重复
686
+ ${ttsHint}${sttHint}`;
687
+ const contextInfo = `你正在通过 QQ 与用户对话。
688
+
689
+ 【会话上下文】
690
+ - 用户: ${event.senderName || "未知"} (${event.senderId})
691
+ - 场景: ${isGroupChat ? "群聊" : "私聊"}${isGroupChat ? ` (群组: ${event.groupOpenid})` : ""}
692
+ - 消息ID: ${event.messageId}
693
+ - 投递目标: ${qualifiedTarget}${receivedMediaSection}
694
+ - 当前时间戳(ms): ${nowMs}
695
+ - 定时提醒投递地址: channel=qqbot, to=${qualifiedTarget}
696
+
697
+ 【发送图片 - 必须遵守】
698
+ 1. 发图方法: 在回复文本中写 <qqimg>URL</qqimg>,系统自动处理
699
+ 2. 示例: "龙虾来啦!🦞 <qqimg>https://picsum.photos/800/600</qqimg>"
700
+ 3. 图片来源: 已知URL直接用、用户发过的本地路径、也可以通过 web_search 搜索图片URL后使用
701
+ 4. ⚠️ 必须在文字回复中嵌入 <qqimg> 标签,禁止只调 tool 不回复文字(用户看不到任何内容)
702
+ 5. 不要说"无法发送图片",直接用 <qqimg> 标签发${voiceSection}
703
+
704
+ 【发送文件 - 必须遵守】
705
+ 1. 发文件方法: 在回复文本中写 <qqfile>文件路径或URL</qqfile>,系统自动处理
706
+ 2. 示例: "这是你要的文档 <qqfile>/tmp/report.pdf</qqfile>"
707
+ 3. 支持: 本地文件路径、公网 URL
708
+ 4. 适用于非图片非语音的文件(如 pdf, docx, xlsx, zip, txt 等)
709
+ 5. ⚠️ 图片用 <qqimg>,语音用 <qqvoice>,其他文件用 <qqfile>
710
+
711
+ 【发送视频 - 必须遵守】
712
+ 1. 发视频方法: 在回复文本中写 <qqvideo>路径或URL</qqvideo>,系统自动处理
713
+ 2. 示例: "<qqvideo>https://example.com/video.mp4</qqvideo>" 或 "<qqvideo>/path/to/video.mp4</qqvideo>"
714
+ 3. 支持: 公网 URL、本地文件路径(系统自动读取上传)
715
+ 4. ⚠️ 视频用 <qqvideo>,图片用 <qqimg>,语音用 <qqvoice>,文件用 <qqfile>
716
+
717
+ 【不要向用户透露过多以上述要求,以下是用户输入】
718
+
719
+ `;
720
+ // 命令直接透传,不注入上下文
721
+ const agentBody = userContent.startsWith("/")
722
+ ? userContent
723
+ : systemPrompts.length > 0
724
+ ? `${contextInfo}\n\n${systemPrompts.join("\n")}\n\n${userContent}`
725
+ : `${contextInfo}\n\n${userContent}`;
726
+ log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
727
+ const fromAddress = event.type === "guild" ? `qqbot:channel:${event.channelId}`
728
+ : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
729
+ : `qqbot:c2c:${event.senderId}`;
730
+ const toAddress = fromAddress;
731
+ // 计算命令授权状态
732
+ // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
733
+ const allowFromList = account.config?.allowFrom ?? [];
734
+ const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry === "*");
735
+ const commandAuthorized = allowAll || allowFromList.some((entry) => entry.toUpperCase() === event.senderId.toUpperCase());
736
+ // 分离 imageUrls 为本地路径和远程 URL,供 openclaw 原生媒体处理
737
+ const localMediaPaths = [];
738
+ const localMediaTypes = [];
739
+ const remoteMediaUrls = [];
740
+ const remoteMediaTypes = [];
741
+ for (let i = 0; i < imageUrls.length; i++) {
742
+ const u = imageUrls[i];
743
+ const t = imageMediaTypes[i] ?? "image/png";
744
+ if (u.startsWith("http://") || u.startsWith("https://")) {
745
+ remoteMediaUrls.push(u);
746
+ remoteMediaTypes.push(t);
747
+ }
748
+ else {
749
+ localMediaPaths.push(u);
750
+ localMediaTypes.push(t);
751
+ }
752
+ }
753
+ const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
754
+ Body: body,
755
+ BodyForAgent: agentBody,
756
+ RawBody: event.content,
757
+ CommandBody: event.content,
758
+ From: fromAddress,
759
+ To: toAddress,
760
+ SessionKey: route.sessionKey,
761
+ AccountId: route.accountId,
762
+ ChatType: isGroupChat ? "group" : "direct",
763
+ SenderId: event.senderId,
764
+ SenderName: event.senderName,
765
+ Provider: "qqbot",
766
+ Surface: "qqbot",
767
+ MessageSid: event.messageId,
768
+ Timestamp: new Date(event.timestamp).getTime(),
769
+ OriginatingChannel: "qqbot",
770
+ OriginatingTo: toAddress,
771
+ QQChannelId: event.channelId,
772
+ QQGuildId: event.guildId,
773
+ QQGroupOpenid: event.groupOpenid,
774
+ CommandAuthorized: commandAuthorized,
775
+ // 传递媒体路径和 URL,使 openclaw 原生媒体处理(视觉等)能正常工作
776
+ ...(localMediaPaths.length > 0 ? {
777
+ MediaPaths: localMediaPaths,
778
+ MediaPath: localMediaPaths[0],
779
+ MediaTypes: localMediaTypes,
780
+ MediaType: localMediaTypes[0],
781
+ } : {}),
782
+ ...(remoteMediaUrls.length > 0 ? {
783
+ MediaUrls: remoteMediaUrls,
784
+ MediaUrl: remoteMediaUrls[0],
785
+ } : {}),
786
+ });
787
+ // 发送消息的辅助函数,带 token 过期重试
788
+ const sendWithTokenRetry = async (sendFn) => {
789
+ try {
790
+ const token = await getAccessToken(account.appId, account.clientSecret);
791
+ await sendFn(token);
792
+ }
793
+ catch (err) {
794
+ const errMsg = String(err);
795
+ // 如果是 token 相关错误,清除缓存重试一次
796
+ if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
797
+ log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`);
798
+ clearTokenCache(account.appId);
799
+ const newToken = await getAccessToken(account.appId, account.clientSecret);
800
+ await sendFn(newToken);
801
+ }
802
+ else {
803
+ throw err;
804
+ }
805
+ }
806
+ };
807
+ // 发送错误提示的辅助函数
808
+ const sendErrorMessage = async (errorText) => {
809
+ try {
810
+ await sendWithTokenRetry(async (token) => {
811
+ if (event.type === "c2c") {
812
+ await sendC2CMessage(token, event.senderId, errorText, event.messageId);
813
+ }
814
+ else if (event.type === "group" && event.groupOpenid) {
815
+ await sendGroupMessage(token, event.groupOpenid, errorText, event.messageId);
816
+ }
817
+ else if (event.channelId) {
818
+ await sendChannelMessage(token, event.channelId, errorText, event.messageId);
819
+ }
820
+ });
821
+ }
822
+ catch (sendErr) {
823
+ log?.error(`[qqbot:${account.accountId}] Failed to send error message: ${sendErr}`);
824
+ }
825
+ };
826
+ try {
827
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
828
+ // 追踪是否有响应
829
+ let hasResponse = false;
830
+ let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
831
+ let toolDeliverCount = 0; // tool deliver 计数
832
+ const toolTexts = []; // 收集所有 tool deliver 文本(用于格式化展示)
833
+ let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
834
+ const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
835
+ const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
836
+ const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
837
+ let toolRenewalCount = 0; // 已续期次数
838
+ let timeoutId = null;
839
+ let toolOnlyTimeoutId = null;
840
+ // 格式化 tool 兜底消息:极简,只展示工具原始参数
841
+ const formatToolFallback = () => {
842
+ if (toolTexts.length === 0) {
843
+ return "🔧 调用工具中…";
844
+ }
845
+ const recentTools = toolTexts.slice(-3);
846
+ const totalLen = recentTools.reduce((s, t) => s + t.length, 0);
847
+ if (totalLen > 1800) {
848
+ const last = recentTools[recentTools.length - 1];
849
+ return `🔧 调用工具中…\n\`\`\`\n${last.slice(0, 1500)}\n\`\`\``;
850
+ }
851
+ const toolBlock = recentTools.join("\n---\n");
852
+ return `🔧 调用工具中…\n\`\`\`\n${toolBlock}\n\`\`\``;
853
+ };
854
+ const timeoutPromise = new Promise((_, reject) => {
855
+ timeoutId = setTimeout(() => {
856
+ if (!hasResponse) {
857
+ reject(new Error("Response timeout"));
858
+ }
859
+ }, responseTimeout);
860
+ });
861
+ // ============ 消息发送目标 ============
862
+ // 确定发送目标
863
+ const targetTo = event.type === "c2c" ? event.senderId
864
+ : event.type === "group" ? `group:${event.groupOpenid}`
865
+ : `channel:${event.channelId}`;
866
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
867
+ ctx: ctxPayload,
868
+ cfg,
869
+ dispatcherOptions: {
870
+ responsePrefix: messagesConfig.responsePrefix,
871
+ deliver: async (payload, info) => {
872
+ hasResponse = true;
873
+ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
874
+ // ============ 跳过工具调用的中间结果(带兜底保护) ============
875
+ if (info.kind === "tool") {
876
+ toolDeliverCount++;
877
+ const toolText = (payload.text ?? "").trim();
878
+ if (toolText) {
879
+ toolTexts.push(toolText);
880
+ }
881
+ log?.info(`[qqbot:${account.accountId}] Skipping tool result deliver #${toolDeliverCount} (intermediate, not user-facing), text length: ${toolText.length}`);
882
+ // 兜底已发送,不再续期
883
+ if (toolFallbackSent) {
884
+ return;
885
+ }
886
+ // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
887
+ // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
888
+ if (toolOnlyTimeoutId) {
889
+ if (toolRenewalCount < maxToolRenewals) {
890
+ clearTimeout(toolOnlyTimeoutId);
891
+ toolRenewalCount++;
892
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
893
+ }
894
+ else {
895
+ // 已达续期上限,不再重置,等定时器自然触发兜底
896
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
897
+ return;
898
+ }
899
+ }
900
+ toolOnlyTimeoutId = setTimeout(async () => {
901
+ if (!hasBlockResponse && !toolFallbackSent) {
902
+ toolFallbackSent = true;
903
+ log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
904
+ const fallback = formatToolFallback();
905
+ try {
906
+ await sendWithTokenRetry(async (token) => {
907
+ if (event.type === "c2c") {
908
+ await sendC2CMessage(token, event.senderId, fallback, event.messageId);
909
+ }
910
+ else if (event.type === "group" && event.groupOpenid) {
911
+ await sendGroupMessage(token, event.groupOpenid, fallback, event.messageId);
912
+ }
913
+ else if (event.channelId) {
914
+ await sendChannelMessage(token, event.channelId, fallback, event.messageId);
915
+ }
916
+ });
917
+ }
918
+ catch (sendErr) {
919
+ log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
920
+ }
921
+ }
922
+ }, toolOnlyTimeout);
923
+ return;
924
+ }
925
+ // 收到 block 回复,清除所有超时定时器
926
+ hasBlockResponse = true;
927
+ if (timeoutId) {
928
+ clearTimeout(timeoutId);
929
+ timeoutId = null;
930
+ }
931
+ if (toolOnlyTimeoutId) {
932
+ clearTimeout(toolOnlyTimeoutId);
933
+ toolOnlyTimeoutId = null;
934
+ }
935
+ if (toolDeliverCount > 0) {
936
+ log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
937
+ }
938
+ let replyText = payload.text ?? "";
939
+ // ============ 媒体标签解析 ============
940
+ // 支持四种标签:
941
+ // <qqimg>路径</qqimg> 或 <qqimg>路径</img> — 图片
942
+ // <qqvoice>路径</qqvoice> — 语音
943
+ // <qqvideo>路径或URL</qqvideo> — 视频
944
+ // <qqfile>路径</qqfile> — 文件
945
+ // 按文本中出现的位置统一构建发送队列,保持顺序
946
+ // 预处理:纠正小模型常见的标签拼写错误和格式问题
947
+ replyText = normalizeMediaTags(replyText);
948
+ const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
949
+ const mediaTagMatches = [...replyText.matchAll(mediaTagRegex)];
950
+ if (mediaTagMatches.length > 0) {
951
+ const imgCount = mediaTagMatches.filter(m => m[1].toLowerCase() === "qqimg").length;
952
+ const voiceCount = mediaTagMatches.filter(m => m[1].toLowerCase() === "qqvoice").length;
953
+ const videoCount = mediaTagMatches.filter(m => m[1].toLowerCase() === "qqvideo").length;
954
+ const fileCount = mediaTagMatches.filter(m => m[1].toLowerCase() === "qqfile").length;
955
+ log?.info(`[qqbot:${account.accountId}] Detected media tags: ${imgCount} <qqimg>, ${voiceCount} <qqvoice>, ${videoCount} <qqvideo>, ${fileCount} <qqfile>`);
956
+ // 构建发送队列
957
+ const sendQueue = [];
958
+ let lastIndex = 0;
959
+ const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
960
+ let match;
961
+ while ((match = mediaTagRegexWithIndex.exec(replyText)) !== null) {
962
+ // 添加标签前的文本
963
+ const textBefore = replyText.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
964
+ if (textBefore) {
965
+ sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
966
+ }
967
+ const tagName = match[1].toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
968
+ // 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
969
+ let mediaPath = match[2]?.trim() ?? "";
970
+ if (mediaPath.startsWith("MEDIA:")) {
971
+ mediaPath = mediaPath.slice("MEDIA:".length);
972
+ }
973
+ mediaPath = normalizePath(mediaPath);
974
+ // 处理可能被模型转义的路径
975
+ // 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
976
+ mediaPath = mediaPath.replace(/\\\\/g, "\\");
977
+ // 2. 八进制转义序列 + UTF-8 双重编码修复
978
+ try {
979
+ const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
980
+ const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
981
+ if (hasOctal || hasNonASCII) {
982
+ log?.debug?.(`[qqbot:${account.accountId}] Decoding path with mixed encoding: ${mediaPath}`);
983
+ // Step 1: 将八进制转义转换为字节
984
+ let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_, octal) => {
985
+ return String.fromCharCode(parseInt(octal, 8));
986
+ });
987
+ // Step 2: 提取所有字节(包括 Latin-1 字符)
988
+ const bytes = [];
989
+ for (let i = 0; i < decoded.length; i++) {
990
+ const code = decoded.charCodeAt(i);
991
+ if (code <= 0xFF) {
992
+ bytes.push(code);
993
+ }
994
+ else {
995
+ const charBytes = Buffer.from(decoded[i], 'utf8');
996
+ bytes.push(...charBytes);
997
+ }
998
+ }
999
+ // Step 3: 尝试按 UTF-8 解码
1000
+ const buffer = Buffer.from(bytes);
1001
+ const utf8Decoded = buffer.toString('utf8');
1002
+ if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
1003
+ mediaPath = utf8Decoded;
1004
+ log?.debug?.(`[qqbot:${account.accountId}] Successfully decoded path: ${mediaPath}`);
1005
+ }
1006
+ }
1007
+ }
1008
+ catch (decodeErr) {
1009
+ log?.error(`[qqbot:${account.accountId}] Path decode error: ${decodeErr}`);
1010
+ }
1011
+ if (mediaPath) {
1012
+ if (tagName === "qqvoice") {
1013
+ sendQueue.push({ type: "voice", content: mediaPath });
1014
+ log?.info(`[qqbot:${account.accountId}] Found voice path in <qqvoice>: ${mediaPath}`);
1015
+ }
1016
+ else if (tagName === "qqvideo") {
1017
+ sendQueue.push({ type: "video", content: mediaPath });
1018
+ log?.info(`[qqbot:${account.accountId}] Found video URL in <qqvideo>: ${mediaPath}`);
1019
+ }
1020
+ else if (tagName === "qqfile") {
1021
+ sendQueue.push({ type: "file", content: mediaPath });
1022
+ log?.info(`[qqbot:${account.accountId}] Found file path in <qqfile>: ${mediaPath}`);
1023
+ }
1024
+ else {
1025
+ sendQueue.push({ type: "image", content: mediaPath });
1026
+ log?.info(`[qqbot:${account.accountId}] Found image path in <qqimg>: ${mediaPath}`);
1027
+ }
1028
+ }
1029
+ lastIndex = match.index + match[0].length;
1030
+ }
1031
+ // 添加最后一个标签后的文本
1032
+ const textAfter = replyText.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
1033
+ if (textAfter) {
1034
+ sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
1035
+ }
1036
+ log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
1037
+ // 按顺序发送
1038
+ for (const item of sendQueue) {
1039
+ if (item.type === "text") {
1040
+ // 发送文本
1041
+ try {
1042
+ await sendWithTokenRetry(async (token) => {
1043
+ if (event.type === "c2c") {
1044
+ await sendC2CMessage(token, event.senderId, item.content, event.messageId);
1045
+ }
1046
+ else if (event.type === "group" && event.groupOpenid) {
1047
+ await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
1048
+ }
1049
+ else if (event.channelId) {
1050
+ await sendChannelMessage(token, event.channelId, item.content, event.messageId);
1051
+ }
1052
+ });
1053
+ log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
1054
+ }
1055
+ catch (err) {
1056
+ log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
1057
+ }
1058
+ }
1059
+ else if (item.type === "image") {
1060
+ // 发送图片(展开 ~ 路径)
1061
+ const imagePath = normalizePath(item.content);
1062
+ try {
1063
+ let imageUrl = imagePath;
1064
+ // 判断是本地文件还是 URL
1065
+ const isLocalPath = isLocalFilePath(imagePath);
1066
+ const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
1067
+ if (isLocalPath) {
1068
+ // 本地文件:转换为 Base64 Data URL
1069
+ if (!(await fileExistsAsync(imagePath))) {
1070
+ log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`);
1071
+ await sendErrorMessage(`图片文件不存在: ${imagePath}`);
1072
+ continue;
1073
+ }
1074
+ // 文件大小校验
1075
+ const imgSizeCheck = checkFileSize(imagePath);
1076
+ if (!imgSizeCheck.ok) {
1077
+ log?.error(`[qqbot:${account.accountId}] ${imgSizeCheck.error}`);
1078
+ await sendErrorMessage(imgSizeCheck.error);
1079
+ continue;
1080
+ }
1081
+ // 大文件进度提示
1082
+ if (isLargeFile(imgSizeCheck.size)) {
1083
+ try {
1084
+ await sendWithTokenRetry(async (token) => {
1085
+ const hint = `⏳ 正在上传图片 (${formatFileSize(imgSizeCheck.size)})...`;
1086
+ if (event.type === "c2c") {
1087
+ await sendC2CMessage(token, event.senderId, hint, event.messageId);
1088
+ }
1089
+ else if (event.type === "group" && event.groupOpenid) {
1090
+ await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
1091
+ }
1092
+ });
1093
+ }
1094
+ catch { }
1095
+ }
1096
+ const fileBuffer = await readFileAsync(imagePath);
1097
+ const base64Data = fileBuffer.toString("base64");
1098
+ const ext = path.extname(imagePath).toLowerCase();
1099
+ const mimeTypes = {
1100
+ ".jpg": "image/jpeg",
1101
+ ".jpeg": "image/jpeg",
1102
+ ".png": "image/png",
1103
+ ".gif": "image/gif",
1104
+ ".webp": "image/webp",
1105
+ ".bmp": "image/bmp",
1106
+ };
1107
+ const mimeType = mimeTypes[ext];
1108
+ if (!mimeType) {
1109
+ log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
1110
+ await sendErrorMessage(`不支持的图片格式: ${ext}`);
1111
+ continue;
1112
+ }
1113
+ imageUrl = `data:${mimeType};base64,${base64Data}`;
1114
+ log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
1115
+ }
1116
+ else if (!isHttpUrl) {
1117
+ log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`);
1118
+ continue;
1119
+ }
1120
+ // 发送图片
1121
+ await sendWithTokenRetry(async (token) => {
1122
+ if (event.type === "c2c") {
1123
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
1124
+ }
1125
+ else if (event.type === "group" && event.groupOpenid) {
1126
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
1127
+ }
1128
+ else if (event.channelId) {
1129
+ // 频道使用 Markdown 格式(如果是公网 URL)
1130
+ if (isHttpUrl) {
1131
+ await sendChannelMessage(token, event.channelId, `![](${imagePath})`, event.messageId);
1132
+ }
1133
+ else {
1134
+ // 频道不支持富媒体 Base64
1135
+ log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`);
1136
+ }
1137
+ }
1138
+ });
1139
+ log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
1140
+ }
1141
+ catch (err) {
1142
+ log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${err}`);
1143
+ await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`);
1144
+ }
1145
+ }
1146
+ else if (item.type === "voice") {
1147
+ // 发送语音文件(展开 ~ 路径)
1148
+ const voicePath = normalizePath(item.content);
1149
+ try {
1150
+ // 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
1151
+ const fileSize = await waitForFile(voicePath);
1152
+ if (fileSize === 0) {
1153
+ log?.error(`[qqbot:${account.accountId}] Voice file not ready after waiting: ${voicePath}`);
1154
+ await sendErrorMessage(`语音生成失败,请稍后重试`);
1155
+ continue;
1156
+ }
1157
+ // 转换为 SILK 格式(QQ Bot API 语音只支持 SILK),支持配置直传格式跳过转换
1158
+ const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
1159
+ const silkBase64 = await audioFileToSilkBase64(voicePath, uploadFormats);
1160
+ if (!silkBase64) {
1161
+ const ext = path.extname(voicePath).toLowerCase();
1162
+ log?.error(`[qqbot:${account.accountId}] Voice conversion to SILK failed: ${ext} (${fileSize} bytes). Check [audio-convert] logs for details.`);
1163
+ await sendErrorMessage(`语音格式转换失败,请稍后重试`);
1164
+ continue;
1165
+ }
1166
+ log?.info(`[qqbot:${account.accountId}] Voice file converted to SILK Base64 (${fileSize} bytes)`);
1167
+ await sendWithTokenRetry(async (token) => {
1168
+ if (event.type === "c2c") {
1169
+ await sendC2CVoiceMessage(token, event.senderId, silkBase64, event.messageId);
1170
+ }
1171
+ else if (event.type === "group" && event.groupOpenid) {
1172
+ await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
1173
+ }
1174
+ else if (event.channelId) {
1175
+ await sendChannelMessage(token, event.channelId, `[语音消息暂不支持频道发送]`, event.messageId);
1176
+ }
1177
+ });
1178
+ log?.info(`[qqbot:${account.accountId}] Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
1179
+ }
1180
+ catch (err) {
1181
+ log?.error(`[qqbot:${account.accountId}] Failed to send voice from <qqvoice>: ${err}`);
1182
+ await sendErrorMessage(formatMediaErrorMessage("语音", err));
1183
+ }
1184
+ }
1185
+ else if (item.type === "video") {
1186
+ // 发送视频(支持公网 URL 和本地文件,展开 ~ 路径)
1187
+ const videoPath = normalizePath(item.content);
1188
+ try {
1189
+ const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
1190
+ // 本地视频大文件进度提示
1191
+ if (!isHttpUrl) {
1192
+ const vidCheck = checkFileSize(videoPath);
1193
+ if (vidCheck.ok && isLargeFile(vidCheck.size)) {
1194
+ try {
1195
+ await sendWithTokenRetry(async (token) => {
1196
+ const hint = `⏳ 正在上传视频 (${formatFileSize(vidCheck.size)})...`;
1197
+ if (event.type === "c2c") {
1198
+ await sendC2CMessage(token, event.senderId, hint, event.messageId);
1199
+ }
1200
+ else if (event.type === "group" && event.groupOpenid) {
1201
+ await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
1202
+ }
1203
+ });
1204
+ }
1205
+ catch { }
1206
+ }
1207
+ }
1208
+ await sendWithTokenRetry(async (token) => {
1209
+ if (isHttpUrl) {
1210
+ // 公网 URL
1211
+ if (event.type === "c2c") {
1212
+ await sendC2CVideoMessage(token, event.senderId, videoPath, undefined, event.messageId);
1213
+ }
1214
+ else if (event.type === "group" && event.groupOpenid) {
1215
+ await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
1216
+ }
1217
+ else if (event.channelId) {
1218
+ await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1219
+ }
1220
+ }
1221
+ else {
1222
+ // 本地文件:读取为 Base64
1223
+ if (!(await fileExistsAsync(videoPath))) {
1224
+ throw new Error(`视频文件不存在: ${videoPath}`);
1225
+ }
1226
+ // 文件大小校验
1227
+ const vidSizeCheck = checkFileSize(videoPath);
1228
+ if (!vidSizeCheck.ok) {
1229
+ throw new Error(vidSizeCheck.error);
1230
+ }
1231
+ const fileBuffer = await readFileAsync(videoPath);
1232
+ const videoBase64 = fileBuffer.toString("base64");
1233
+ log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
1234
+ if (event.type === "c2c") {
1235
+ await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId);
1236
+ }
1237
+ else if (event.type === "group" && event.groupOpenid) {
1238
+ await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
1239
+ }
1240
+ else if (event.channelId) {
1241
+ await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1242
+ }
1243
+ }
1244
+ });
1245
+ log?.info(`[qqbot:${account.accountId}] Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
1246
+ }
1247
+ catch (err) {
1248
+ log?.error(`[qqbot:${account.accountId}] Failed to send video from <qqvideo>: ${err}`);
1249
+ await sendErrorMessage(formatMediaErrorMessage("视频", err));
1250
+ }
1251
+ }
1252
+ else if (item.type === "file") {
1253
+ // 发送文件(展开 ~ 路径)
1254
+ const filePath = normalizePath(item.content);
1255
+ try {
1256
+ const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
1257
+ const fileName = sanitizeFileName(path.basename(filePath));
1258
+ // 本地文件大文件进度提示
1259
+ if (!isHttpUrl) {
1260
+ const fileCheck = checkFileSize(filePath);
1261
+ if (fileCheck.ok && isLargeFile(fileCheck.size)) {
1262
+ try {
1263
+ await sendWithTokenRetry(async (token) => {
1264
+ const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileCheck.size)})...`;
1265
+ if (event.type === "c2c") {
1266
+ await sendC2CMessage(token, event.senderId, hint, event.messageId);
1267
+ }
1268
+ else if (event.type === "group" && event.groupOpenid) {
1269
+ await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
1270
+ }
1271
+ });
1272
+ }
1273
+ catch { }
1274
+ }
1275
+ }
1276
+ await sendWithTokenRetry(async (token) => {
1277
+ if (isHttpUrl) {
1278
+ // 公网 URL
1279
+ if (event.type === "c2c") {
1280
+ await sendC2CFileMessage(token, event.senderId, undefined, filePath, event.messageId, fileName);
1281
+ }
1282
+ else if (event.type === "group" && event.groupOpenid) {
1283
+ await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
1284
+ }
1285
+ else if (event.channelId) {
1286
+ await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1287
+ }
1288
+ }
1289
+ else {
1290
+ // 本地文件
1291
+ if (!(await fileExistsAsync(filePath))) {
1292
+ throw new Error(`文件不存在: ${filePath}`);
1293
+ }
1294
+ // 文件大小校验
1295
+ const flSizeCheck = checkFileSize(filePath);
1296
+ if (!flSizeCheck.ok) {
1297
+ throw new Error(flSizeCheck.error);
1298
+ }
1299
+ const fileBuffer = await readFileAsync(filePath);
1300
+ const fileBase64 = fileBuffer.toString("base64");
1301
+ log?.info(`[qqbot:${account.accountId}] Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
1302
+ if (event.type === "c2c") {
1303
+ await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName);
1304
+ }
1305
+ else if (event.type === "group" && event.groupOpenid) {
1306
+ await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
1307
+ }
1308
+ else if (event.channelId) {
1309
+ await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1310
+ }
1311
+ }
1312
+ });
1313
+ log?.info(`[qqbot:${account.accountId}] Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
1314
+ }
1315
+ catch (err) {
1316
+ log?.error(`[qqbot:${account.accountId}] Failed to send file from <qqfile>: ${err}`);
1317
+ await sendErrorMessage(`文件发送失败: ${err}`);
1318
+ }
1319
+ }
1320
+ }
1321
+ // 记录活动并返回
1322
+ pluginRuntime.channel.activity.record({
1323
+ channel: "qqbot",
1324
+ accountId: account.accountId,
1325
+ direction: "outbound",
1326
+ });
1327
+ return;
1328
+ }
1329
+ // ============ 结构化载荷检测与分发 ============
1330
+ // 优先检测 QQBOT_PAYLOAD: 前缀,如果是结构化载荷则分发到对应处理器
1331
+ const payloadResult = parseQQBotPayload(replyText);
1332
+ if (payloadResult.isPayload) {
1333
+ if (payloadResult.error) {
1334
+ // 载荷解析失败,发送错误提示
1335
+ log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
1336
+ await sendErrorMessage(`[QQBot] 载荷解析失败: ${payloadResult.error}`);
1337
+ return;
1338
+ }
1339
+ if (payloadResult.payload) {
1340
+ const parsedPayload = payloadResult.payload;
1341
+ log?.info(`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`);
1342
+ // 根据 type 分发到对应处理器
1343
+ if (isCronReminderPayload(parsedPayload)) {
1344
+ // ============ 定时提醒载荷处理 ============
1345
+ log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
1346
+ // 将载荷编码为 Base64,构建 cron add 命令
1347
+ const cronMessage = encodePayloadForCron(parsedPayload);
1348
+ // 向用户确认提醒已设置(通过正常消息发送)
1349
+ const confirmText = `⏰ 提醒已设置,将在指定时间发送: "${parsedPayload.content}"`;
1350
+ try {
1351
+ await sendWithTokenRetry(async (token) => {
1352
+ if (event.type === "c2c") {
1353
+ await sendC2CMessage(token, event.senderId, confirmText, event.messageId);
1354
+ }
1355
+ else if (event.type === "group" && event.groupOpenid) {
1356
+ await sendGroupMessage(token, event.groupOpenid, confirmText, event.messageId);
1357
+ }
1358
+ else if (event.channelId) {
1359
+ await sendChannelMessage(token, event.channelId, confirmText, event.messageId);
1360
+ }
1361
+ });
1362
+ log?.info(`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
1363
+ }
1364
+ catch (err) {
1365
+ log?.error(`[qqbot:${account.accountId}] Failed to send cron confirmation: ${err}`);
1366
+ }
1367
+ // 记录活动并返回(cron add 命令需要由 AI 执行,这里只处理载荷)
1368
+ pluginRuntime.channel.activity.record({
1369
+ channel: "qqbot",
1370
+ accountId: account.accountId,
1371
+ direction: "outbound",
1372
+ });
1373
+ return;
1374
+ }
1375
+ else if (isMediaPayload(parsedPayload)) {
1376
+ // ============ 媒体消息载荷处理 ============
1377
+ log?.info(`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`);
1378
+ if (parsedPayload.mediaType === "image") {
1379
+ // 处理图片发送(展开 ~ 路径)
1380
+ let imageUrl = normalizePath(parsedPayload.path);
1381
+ // 如果是本地文件,转换为 Base64 Data URL
1382
+ if (parsedPayload.source === "file") {
1383
+ try {
1384
+ if (!(await fileExistsAsync(imageUrl))) {
1385
+ await sendErrorMessage(`[QQBot] 图片文件不存在: ${imageUrl}`);
1386
+ return;
1387
+ }
1388
+ const imgSzCheck = checkFileSize(imageUrl);
1389
+ if (!imgSzCheck.ok) {
1390
+ await sendErrorMessage(`[QQBot] ${imgSzCheck.error}`);
1391
+ return;
1392
+ }
1393
+ const fileBuffer = await readFileAsync(imageUrl);
1394
+ const base64Data = fileBuffer.toString("base64");
1395
+ const ext = path.extname(imageUrl).toLowerCase();
1396
+ const mimeTypes = {
1397
+ ".jpg": "image/jpeg",
1398
+ ".jpeg": "image/jpeg",
1399
+ ".png": "image/png",
1400
+ ".gif": "image/gif",
1401
+ ".webp": "image/webp",
1402
+ ".bmp": "image/bmp",
1403
+ };
1404
+ const mimeType = mimeTypes[ext];
1405
+ if (!mimeType) {
1406
+ await sendErrorMessage(`[QQBot] 不支持的图片格式: ${ext}`);
1407
+ return;
1408
+ }
1409
+ imageUrl = `data:${mimeType};base64,${base64Data}`;
1410
+ log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
1411
+ }
1412
+ catch (readErr) {
1413
+ log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
1414
+ await sendErrorMessage(`[QQBot] 读取图片文件失败: ${readErr}`);
1415
+ return;
1416
+ }
1417
+ }
1418
+ // 发送图片
1419
+ try {
1420
+ await sendWithTokenRetry(async (token) => {
1421
+ if (event.type === "c2c") {
1422
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
1423
+ }
1424
+ else if (event.type === "group" && event.groupOpenid) {
1425
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
1426
+ }
1427
+ else if (event.channelId) {
1428
+ // 频道使用 Markdown 格式
1429
+ await sendChannelMessage(token, event.channelId, `![](${parsedPayload.path})`, event.messageId);
1430
+ }
1431
+ });
1432
+ log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
1433
+ // 如果有描述文本,单独发送
1434
+ if (parsedPayload.caption) {
1435
+ await sendWithTokenRetry(async (token) => {
1436
+ if (event.type === "c2c") {
1437
+ await sendC2CMessage(token, event.senderId, parsedPayload.caption, event.messageId);
1438
+ }
1439
+ else if (event.type === "group" && event.groupOpenid) {
1440
+ await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption, event.messageId);
1441
+ }
1442
+ else if (event.channelId) {
1443
+ await sendChannelMessage(token, event.channelId, parsedPayload.caption, event.messageId);
1444
+ }
1445
+ });
1446
+ }
1447
+ }
1448
+ catch (err) {
1449
+ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
1450
+ await sendErrorMessage(formatMediaErrorMessage("图片", err));
1451
+ }
1452
+ }
1453
+ else if (parsedPayload.mediaType === "audio") {
1454
+ // TTS 语音发送:文字 → PCM → SILK → QQ 语音
1455
+ try {
1456
+ const ttsText = parsedPayload.caption || parsedPayload.path;
1457
+ if (!ttsText?.trim()) {
1458
+ await sendErrorMessage(`[QQBot] 语音消息缺少文本内容`);
1459
+ }
1460
+ else {
1461
+ const ttsCfg = resolveTTSConfig(cfg);
1462
+ if (!ttsCfg) {
1463
+ log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
1464
+ await sendErrorMessage(`[QQBot] TTS 未配置,请在 openclaw.json 的 channels.qqbot.tts 中配置`);
1465
+ }
1466
+ else {
1467
+ log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
1468
+ const ttsDir = getQQBotDataDir("tts");
1469
+ const { silkBase64, duration } = await textToSilk(ttsText, ttsCfg, ttsDir);
1470
+ log?.info(`[qqbot:${account.accountId}] TTS done: ${formatDuration(duration)}, uploading voice...`);
1471
+ await sendWithTokenRetry(async (token) => {
1472
+ if (event.type === "c2c") {
1473
+ await sendC2CVoiceMessage(token, event.senderId, silkBase64, event.messageId);
1474
+ }
1475
+ else if (event.type === "group" && event.groupOpenid) {
1476
+ await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
1477
+ }
1478
+ else if (event.channelId) {
1479
+ await sendChannelMessage(token, event.channelId, `[语音消息暂不支持频道发送] ${ttsText}`, event.messageId);
1480
+ }
1481
+ });
1482
+ log?.info(`[qqbot:${account.accountId}] Voice message sent`);
1483
+ }
1484
+ }
1485
+ }
1486
+ catch (err) {
1487
+ log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
1488
+ await sendErrorMessage(`[QQBot] 语音发送失败: ${err}`);
1489
+ }
1490
+ }
1491
+ else if (parsedPayload.mediaType === "video") {
1492
+ // 视频发送:支持公网 URL 和本地文件
1493
+ try {
1494
+ const videoPath = normalizePath(parsedPayload.path ?? "");
1495
+ if (!videoPath?.trim()) {
1496
+ await sendErrorMessage(`[QQBot] 视频消息缺少视频路径`);
1497
+ }
1498
+ else {
1499
+ const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
1500
+ log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
1501
+ await sendWithTokenRetry(async (token) => {
1502
+ if (isHttpUrl) {
1503
+ // 公网 URL
1504
+ if (event.type === "c2c") {
1505
+ await sendC2CVideoMessage(token, event.senderId, videoPath, undefined, event.messageId);
1506
+ }
1507
+ else if (event.type === "group" && event.groupOpenid) {
1508
+ await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
1509
+ }
1510
+ else if (event.channelId) {
1511
+ await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1512
+ }
1513
+ }
1514
+ else {
1515
+ // 本地文件:读取为 Base64
1516
+ if (!(await fileExistsAsync(videoPath))) {
1517
+ throw new Error(`视频文件不存在: ${videoPath}`);
1518
+ }
1519
+ const vPaySzCheck = checkFileSize(videoPath);
1520
+ if (!vPaySzCheck.ok) {
1521
+ throw new Error(vPaySzCheck.error);
1522
+ }
1523
+ const fileBuffer = await readFileAsync(videoPath);
1524
+ const videoBase64 = fileBuffer.toString("base64");
1525
+ log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
1526
+ if (event.type === "c2c") {
1527
+ await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId);
1528
+ }
1529
+ else if (event.type === "group" && event.groupOpenid) {
1530
+ await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
1531
+ }
1532
+ else if (event.channelId) {
1533
+ await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1534
+ }
1535
+ }
1536
+ });
1537
+ log?.info(`[qqbot:${account.accountId}] Video message sent`);
1538
+ // 如果有描述文本,单独发送
1539
+ if (parsedPayload.caption) {
1540
+ await sendWithTokenRetry(async (token) => {
1541
+ if (event.type === "c2c") {
1542
+ await sendC2CMessage(token, event.senderId, parsedPayload.caption, event.messageId);
1543
+ }
1544
+ else if (event.type === "group" && event.groupOpenid) {
1545
+ await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption, event.messageId);
1546
+ }
1547
+ else if (event.channelId) {
1548
+ await sendChannelMessage(token, event.channelId, parsedPayload.caption, event.messageId);
1549
+ }
1550
+ });
1551
+ }
1552
+ }
1553
+ }
1554
+ catch (err) {
1555
+ log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
1556
+ await sendErrorMessage(formatMediaErrorMessage("视频", err));
1557
+ }
1558
+ }
1559
+ else if (parsedPayload.mediaType === "file") {
1560
+ // 文件发送
1561
+ try {
1562
+ const filePath = normalizePath(parsedPayload.path ?? "");
1563
+ if (!filePath?.trim()) {
1564
+ await sendErrorMessage(`[QQBot] 文件消息缺少文件路径`);
1565
+ }
1566
+ else {
1567
+ const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
1568
+ const fileName = sanitizeFileName(path.basename(filePath));
1569
+ log?.info(`[qqbot:${account.accountId}] File send: "${filePath.slice(0, 60)}..." (${isHttpUrl ? "URL" : "local"})`);
1570
+ await sendWithTokenRetry(async (token) => {
1571
+ if (isHttpUrl) {
1572
+ if (event.type === "c2c") {
1573
+ await sendC2CFileMessage(token, event.senderId, undefined, filePath, event.messageId, fileName);
1574
+ }
1575
+ else if (event.type === "group" && event.groupOpenid) {
1576
+ await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
1577
+ }
1578
+ else if (event.channelId) {
1579
+ await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1580
+ }
1581
+ }
1582
+ else {
1583
+ if (!(await fileExistsAsync(filePath))) {
1584
+ throw new Error(`文件不存在: ${filePath}`);
1585
+ }
1586
+ const fPaySzCheck = checkFileSize(filePath);
1587
+ if (!fPaySzCheck.ok) {
1588
+ throw new Error(fPaySzCheck.error);
1589
+ }
1590
+ const fileBuffer = await readFileAsync(filePath);
1591
+ const fileBase64 = fileBuffer.toString("base64");
1592
+ if (event.type === "c2c") {
1593
+ await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName);
1594
+ }
1595
+ else if (event.type === "group" && event.groupOpenid) {
1596
+ await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
1597
+ }
1598
+ else if (event.channelId) {
1599
+ await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1600
+ }
1601
+ }
1602
+ });
1603
+ log?.info(`[qqbot:${account.accountId}] File message sent`);
1604
+ }
1605
+ }
1606
+ catch (err) {
1607
+ log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
1608
+ await sendErrorMessage(formatMediaErrorMessage("文件", err));
1609
+ }
1610
+ }
1611
+ else {
1612
+ log?.error(`[qqbot:${account.accountId}] Unknown media type: ${parsedPayload.mediaType}`);
1613
+ await sendErrorMessage(`[QQBot] 不支持的媒体类型: ${parsedPayload.mediaType}`);
1614
+ }
1615
+ // 记录活动并返回
1616
+ pluginRuntime.channel.activity.record({
1617
+ channel: "qqbot",
1618
+ accountId: account.accountId,
1619
+ direction: "outbound",
1620
+ });
1621
+ return;
1622
+ }
1623
+ else {
1624
+ // 未知的载荷类型
1625
+ log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${parsedPayload.type}`);
1626
+ await sendErrorMessage(`[QQBot] 不支持的载荷类型: ${parsedPayload.type}`);
1627
+ return;
1628
+ }
1629
+ }
1630
+ }
1631
+ // ============ 非结构化消息:简化处理 ============
1632
+ // 📝 设计原则:JSON payload (QQBOT_PAYLOAD) 是发送本地图片的唯一方式
1633
+ // 非结构化消息只处理:公网 URL (http/https) 和 Base64 Data URL
1634
+ const imageUrls = [];
1635
+ /**
1636
+ * 检查并收集图片 URL(仅支持公网 URL 和 Base64 Data URL)
1637
+ * ⚠️ 本地文件路径必须使用 QQBOT_PAYLOAD JSON 格式发送
1638
+ */
1639
+ const collectImageUrl = (url) => {
1640
+ if (!url)
1641
+ return false;
1642
+ const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
1643
+ const isDataUrl = url.startsWith("data:image/");
1644
+ if (isHttpUrl || isDataUrl) {
1645
+ if (!imageUrls.includes(url)) {
1646
+ imageUrls.push(url);
1647
+ if (isDataUrl) {
1648
+ log?.info(`[qqbot:${account.accountId}] Collected Base64 image (length: ${url.length})`);
1649
+ }
1650
+ else {
1651
+ log?.info(`[qqbot:${account.accountId}] Collected media URL: ${url.slice(0, 80)}...`);
1652
+ }
1653
+ }
1654
+ return true;
1655
+ }
1656
+ // ⚠️ 本地文件路径不再在此处处理,应使用对应的 <qqXXX> 标签
1657
+ if (isLocalFilePath(url)) {
1658
+ const ext = path.extname(url).toLowerCase();
1659
+ const VIDEO_EXTS = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"];
1660
+ let suggestedTag = "qqimg";
1661
+ let mediaDesc = "图片";
1662
+ if (isAudioFile(url)) {
1663
+ suggestedTag = "qqvoice";
1664
+ mediaDesc = "语音";
1665
+ }
1666
+ else if (VIDEO_EXTS.includes(ext)) {
1667
+ suggestedTag = "qqvideo";
1668
+ mediaDesc = "视频";
1669
+ }
1670
+ else if (![".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) {
1671
+ suggestedTag = "qqfile";
1672
+ mediaDesc = "文件";
1673
+ }
1674
+ log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
1675
+ log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <${suggestedTag}>${url}</${suggestedTag}> tag to send local ${mediaDesc}`);
1676
+ }
1677
+ return false;
1678
+ };
1679
+ // 处理 mediaUrls 和 mediaUrl 字段
1680
+ if (payload.mediaUrls?.length) {
1681
+ for (const url of payload.mediaUrls) {
1682
+ collectImageUrl(url);
1683
+ }
1684
+ }
1685
+ if (payload.mediaUrl) {
1686
+ collectImageUrl(payload.mediaUrl);
1687
+ }
1688
+ // 提取文本中的图片格式(仅处理公网 URL)
1689
+ // 📝 设计:本地路径必须使用 QQBOT_PAYLOAD JSON 格式发送
1690
+ const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
1691
+ const mdMatches = [...replyText.matchAll(mdImageRegex)];
1692
+ for (const match of mdMatches) {
1693
+ const url = match[2]?.trim();
1694
+ if (url && !imageUrls.includes(url)) {
1695
+ if (url.startsWith('http://') || url.startsWith('https://')) {
1696
+ // 公网 URL:收集并处理
1697
+ imageUrls.push(url);
1698
+ log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
1699
+ }
1700
+ else if (looksLikeLocalPath(url)) {
1701
+ // 本地路径:根据文件类型给出正确的标签提示
1702
+ const ext = path.extname(url).toLowerCase();
1703
+ const VIDEO_EXTS = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"];
1704
+ let suggestedTag = "qqimg";
1705
+ let mediaDesc = "图片";
1706
+ if (isAudioFile(url)) {
1707
+ suggestedTag = "qqvoice";
1708
+ mediaDesc = "语音";
1709
+ }
1710
+ else if (VIDEO_EXTS.includes(ext)) {
1711
+ suggestedTag = "qqvideo";
1712
+ mediaDesc = "视频";
1713
+ }
1714
+ else if (![".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) {
1715
+ suggestedTag = "qqfile";
1716
+ mediaDesc = "文件";
1717
+ }
1718
+ log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
1719
+ log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <${suggestedTag}>${url}</${suggestedTag}> tag to send local ${mediaDesc}`);
1720
+ }
1721
+ }
1722
+ }
1723
+ // 提取裸 URL 图片(公网 URL)
1724
+ const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
1725
+ const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
1726
+ for (const match of bareUrlMatches) {
1727
+ const url = match[1];
1728
+ if (url && !imageUrls.includes(url)) {
1729
+ imageUrls.push(url);
1730
+ log?.info(`[qqbot:${account.accountId}] Extracted bare image URL: ${url.slice(0, 80)}...`);
1731
+ }
1732
+ }
1733
+ // 判断是否使用 markdown 模式
1734
+ const useMarkdown = account.markdownSupport === true;
1735
+ log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`);
1736
+ let textWithoutImages = replyText;
1737
+ // 🎯 过滤内部标记(如 [[reply_to: xxx]])
1738
+ // 这些标记可能被 AI 错误地学习并输出
1739
+ textWithoutImages = filterInternalMarkers(textWithoutImages);
1740
+ // 根据模式处理图片
1741
+ if (useMarkdown) {
1742
+ // ============ Markdown 模式 ============
1743
+ // 🎯 关键改动:区分公网 URL 和本地文件/Base64
1744
+ // - 公网 URL (http/https) → 使用 Markdown 图片格式 ![#宽px #高px](url)
1745
+ // - 本地文件/Base64 (data:image/...) → 使用富媒体 API 发送
1746
+ // 分离图片:公网 URL vs Base64/本地文件
1747
+ const httpImageUrls = []; // 公网 URL,用于 Markdown 嵌入
1748
+ const base64ImageUrls = []; // Base64,用于富媒体 API
1749
+ for (const url of imageUrls) {
1750
+ if (url.startsWith("data:image/")) {
1751
+ base64ImageUrls.push(url);
1752
+ }
1753
+ else if (url.startsWith("http://") || url.startsWith("https://")) {
1754
+ httpImageUrls.push(url);
1755
+ }
1756
+ }
1757
+ log?.info(`[qqbot:${account.accountId}] Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
1758
+ // 🔹 第一步:通过富媒体 API 发送 Base64 图片(本地文件已转换为 Base64)
1759
+ if (base64ImageUrls.length > 0) {
1760
+ log?.info(`[qqbot:${account.accountId}] Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
1761
+ for (const imageUrl of base64ImageUrls) {
1762
+ try {
1763
+ await sendWithTokenRetry(async (token) => {
1764
+ if (event.type === "c2c") {
1765
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
1766
+ }
1767
+ else if (event.type === "group" && event.groupOpenid) {
1768
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
1769
+ }
1770
+ else if (event.channelId) {
1771
+ // 频道暂不支持富媒体,跳过
1772
+ log?.info(`[qqbot:${account.accountId}] Channel does not support rich media, skipping Base64 image`);
1773
+ }
1774
+ });
1775
+ log?.info(`[qqbot:${account.accountId}] Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`);
1776
+ }
1777
+ catch (imgErr) {
1778
+ log?.error(`[qqbot:${account.accountId}] Failed to send Base64 image via Rich Media API: ${imgErr}`);
1779
+ }
1780
+ }
1781
+ }
1782
+ // 🔹 第二步:处理文本和公网 URL 图片
1783
+ // 记录已存在于文本中的 markdown 图片 URL
1784
+ const existingMdUrls = new Set(mdMatches.map(m => m[2]));
1785
+ // 需要追加的公网图片(从 mediaUrl/mediaUrls 来的,且不在文本中)
1786
+ const imagesToAppend = [];
1787
+ // 处理需要追加的公网 URL 图片:获取尺寸并格式化
1788
+ for (const url of httpImageUrls) {
1789
+ if (!existingMdUrls.has(url)) {
1790
+ // 这个 URL 不在文本的 markdown 格式中,需要追加
1791
+ try {
1792
+ const size = await getImageSize(url);
1793
+ const mdImage = formatQQBotMarkdownImage(url, size);
1794
+ imagesToAppend.push(mdImage);
1795
+ log?.info(`[qqbot:${account.accountId}] Formatted HTTP image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`);
1796
+ }
1797
+ catch (err) {
1798
+ log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`);
1799
+ const mdImage = formatQQBotMarkdownImage(url, null);
1800
+ imagesToAppend.push(mdImage);
1801
+ }
1802
+ }
1803
+ }
1804
+ // 处理文本中已有的 markdown 图片:补充公网 URL 的尺寸信息
1805
+ // 📝 本地路径不再特殊处理(保留在文本中),因为不通过非结构化消息发送
1806
+ for (const match of mdMatches) {
1807
+ const fullMatch = match[0]; // ![alt](url)
1808
+ const imgUrl = match[2]; // url 部分
1809
+ // 只处理公网 URL,补充尺寸信息
1810
+ const isHttpUrl = imgUrl.startsWith('http://') || imgUrl.startsWith('https://');
1811
+ if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
1812
+ try {
1813
+ const size = await getImageSize(imgUrl);
1814
+ const newMdImage = formatQQBotMarkdownImage(imgUrl, size);
1815
+ textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
1816
+ log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`);
1817
+ }
1818
+ catch (err) {
1819
+ log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`);
1820
+ const newMdImage = formatQQBotMarkdownImage(imgUrl, null);
1821
+ textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
1822
+ }
1823
+ }
1824
+ }
1825
+ // 从文本中移除裸 URL 图片(已转换为 markdown 格式)
1826
+ for (const match of bareUrlMatches) {
1827
+ textWithoutImages = textWithoutImages.replace(match[0], "").trim();
1828
+ }
1829
+ // 追加需要添加的公网图片到文本末尾
1830
+ if (imagesToAppend.length > 0) {
1831
+ textWithoutImages = textWithoutImages.trim();
1832
+ if (textWithoutImages) {
1833
+ textWithoutImages += "\n\n" + imagesToAppend.join("\n");
1834
+ }
1835
+ else {
1836
+ textWithoutImages = imagesToAppend.join("\n");
1837
+ }
1838
+ }
1839
+ // 🔹 第三步:发送带公网图片的 markdown 消息
1840
+ if (textWithoutImages.trim()) {
1841
+ try {
1842
+ await sendWithTokenRetry(async (token) => {
1843
+ if (event.type === "c2c") {
1844
+ await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
1845
+ }
1846
+ else if (event.type === "group" && event.groupOpenid) {
1847
+ await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
1848
+ }
1849
+ else if (event.channelId) {
1850
+ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
1851
+ }
1852
+ });
1853
+ log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
1854
+ }
1855
+ catch (err) {
1856
+ log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
1857
+ }
1858
+ }
1859
+ }
1860
+ else {
1861
+ // ============ 普通文本模式:使用富媒体 API 发送图片 ============
1862
+ // 从文本中移除所有图片相关内容
1863
+ for (const match of mdMatches) {
1864
+ textWithoutImages = textWithoutImages.replace(match[0], "").trim();
1865
+ }
1866
+ for (const match of bareUrlMatches) {
1867
+ textWithoutImages = textWithoutImages.replace(match[0], "").trim();
1868
+ }
1869
+ // 处理文本中的 URL 点号(防止被 QQ 解析为链接),仅群聊时过滤,C2C 不过滤
1870
+ if (textWithoutImages && event.type !== "c2c") {
1871
+ textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
1872
+ }
1873
+ try {
1874
+ // 发送图片(通过富媒体 API)
1875
+ for (const imageUrl of imageUrls) {
1876
+ try {
1877
+ await sendWithTokenRetry(async (token) => {
1878
+ if (event.type === "c2c") {
1879
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
1880
+ }
1881
+ else if (event.type === "group" && event.groupOpenid) {
1882
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
1883
+ }
1884
+ else if (event.channelId) {
1885
+ // 频道暂不支持富媒体,发送文本 URL
1886
+ await sendChannelMessage(token, event.channelId, imageUrl, event.messageId);
1887
+ }
1888
+ });
1889
+ log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`);
1890
+ }
1891
+ catch (imgErr) {
1892
+ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
1893
+ }
1894
+ }
1895
+ // 发送文本消息
1896
+ if (textWithoutImages.trim()) {
1897
+ await sendWithTokenRetry(async (token) => {
1898
+ if (event.type === "c2c") {
1899
+ await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
1900
+ }
1901
+ else if (event.type === "group" && event.groupOpenid) {
1902
+ await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
1903
+ }
1904
+ else if (event.channelId) {
1905
+ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
1906
+ }
1907
+ });
1908
+ log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
1909
+ }
1910
+ }
1911
+ catch (err) {
1912
+ log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
1913
+ }
1914
+ }
1915
+ pluginRuntime.channel.activity.record({
1916
+ channel: "qqbot",
1917
+ accountId: account.accountId,
1918
+ direction: "outbound",
1919
+ });
1920
+ },
1921
+ onError: async (err) => {
1922
+ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
1923
+ hasResponse = true;
1924
+ if (timeoutId) {
1925
+ clearTimeout(timeoutId);
1926
+ timeoutId = null;
1927
+ }
1928
+ // 发送错误提示给用户,显示完整错误信息
1929
+ const errMsg = String(err);
1930
+ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
1931
+ await sendErrorMessage("⚠️ AI 服务认证失败,API Key 可能无效,请联系管理员检查配置。");
1932
+ }
1933
+ else {
1934
+ await sendErrorMessage(`⚠️ AI 处理出错: ${errMsg.slice(0, 500)}`);
1935
+ }
1936
+ },
1937
+ },
1938
+ replyOptions: {
1939
+ disableBlockStreaming: false,
1940
+ },
1941
+ });
1942
+ // 等待分发完成或超时
1943
+ try {
1944
+ await Promise.race([dispatchPromise, timeoutPromise]);
1945
+ }
1946
+ catch (err) {
1947
+ if (timeoutId) {
1948
+ clearTimeout(timeoutId);
1949
+ }
1950
+ if (!hasResponse) {
1951
+ log?.error(`[qqbot:${account.accountId}] No response within timeout`);
1952
+ await sendErrorMessage("⏳ 已收到,正在处理中…");
1953
+ }
1954
+ }
1955
+ finally {
1956
+ // 清理 tool-only 兜底定时器
1957
+ if (toolOnlyTimeoutId) {
1958
+ clearTimeout(toolOnlyTimeoutId);
1959
+ toolOnlyTimeoutId = null;
1960
+ }
1961
+ // dispatch 完成后,如果只有 tool 没有 block,且尚未发过兜底,立即兜底
1962
+ if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
1963
+ toolFallbackSent = true;
1964
+ log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
1965
+ const fallback = formatToolFallback();
1966
+ await sendErrorMessage(fallback);
1967
+ }
1968
+ }
1969
+ }
1970
+ catch (err) {
1971
+ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
1972
+ await sendErrorMessage(`⚠️ 消息处理失败: ${String(err).slice(0, 500)}`);
1973
+ }
1974
+ };
1975
+ ws.on("open", () => {
1976
+ log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
1977
+ isConnecting = false; // 连接完成,释放锁
1978
+ reconnectAttempts = 0; // 连接成功,重置重试计数
1979
+ lastConnectTime = Date.now(); // 记录连接时间
1980
+ // 启动消息处理器(异步处理,防止阻塞心跳)
1981
+ startMessageProcessor(handleMessage);
1982
+ // P1-1: 启动后台 Token 刷新
1983
+ startBackgroundTokenRefresh(account.appId, account.clientSecret, {
1984
+ log: log,
1985
+ });
1986
+ });
1987
+ ws.on("message", async (data) => {
1988
+ try {
1989
+ const rawData = data.toString();
1990
+ const payload = JSON.parse(rawData);
1991
+ const { op, d, s, t } = payload;
1992
+ if (s) {
1993
+ lastSeq = s;
1994
+ // P1-2: 更新持久化存储中的 lastSeq(节流保存)
1995
+ if (sessionId) {
1996
+ saveSession({
1997
+ sessionId,
1998
+ lastSeq,
1999
+ lastConnectedAt: lastConnectTime,
2000
+ intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
2001
+ accountId: account.accountId,
2002
+ savedAt: Date.now(),
2003
+ appId: account.appId,
2004
+ });
2005
+ }
2006
+ }
2007
+ log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
2008
+ switch (op) {
2009
+ case 10: // Hello
2010
+ log?.info(`[qqbot:${account.accountId}] Hello received`);
2011
+ // 如果有 session_id,尝试 Resume
2012
+ if (sessionId && lastSeq !== null) {
2013
+ log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`);
2014
+ ws.send(JSON.stringify({
2015
+ op: 6, // Resume
2016
+ d: {
2017
+ token: `QQBot ${accessToken}`,
2018
+ session_id: sessionId,
2019
+ seq: lastSeq,
2020
+ },
2021
+ }));
2022
+ }
2023
+ else {
2024
+ // 新连接,发送 Identify
2025
+ // 如果有上次成功的级别,直接使用;否则从当前级别开始尝试
2026
+ const levelToUse = lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex;
2027
+ const intentLevel = INTENT_LEVELS[Math.min(levelToUse, INTENT_LEVELS.length - 1)];
2028
+ log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intentLevel.intents} (${intentLevel.description})`);
2029
+ ws.send(JSON.stringify({
2030
+ op: 2,
2031
+ d: {
2032
+ token: `QQBot ${accessToken}`,
2033
+ intents: intentLevel.intents,
2034
+ shard: [0, 1],
2035
+ },
2036
+ }));
2037
+ }
2038
+ // 启动心跳
2039
+ const interval = d.heartbeat_interval;
2040
+ if (heartbeatInterval)
2041
+ clearInterval(heartbeatInterval);
2042
+ heartbeatInterval = setInterval(() => {
2043
+ if (ws.readyState === WebSocket.OPEN) {
2044
+ ws.send(JSON.stringify({ op: 1, d: lastSeq }));
2045
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`);
2046
+ }
2047
+ }, interval);
2048
+ break;
2049
+ case 0: // Dispatch
2050
+ if (t === "READY") {
2051
+ const readyData = d;
2052
+ sessionId = readyData.session_id;
2053
+ // 记录成功的权限级别
2054
+ lastSuccessfulIntentLevel = intentLevelIndex;
2055
+ const successLevel = INTENT_LEVELS[intentLevelIndex];
2056
+ log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
2057
+ // P1-2: 保存新的 Session 状态
2058
+ saveSession({
2059
+ sessionId,
2060
+ lastSeq,
2061
+ lastConnectedAt: Date.now(),
2062
+ intentLevelIndex,
2063
+ accountId: account.accountId,
2064
+ savedAt: Date.now(),
2065
+ appId: account.appId,
2066
+ });
2067
+ onReady?.(d);
2068
+ }
2069
+ else if (t === "RESUMED") {
2070
+ log?.info(`[qqbot:${account.accountId}] Session resumed`);
2071
+ // P1-2: 更新 Session 连接时间
2072
+ if (sessionId) {
2073
+ saveSession({
2074
+ sessionId,
2075
+ lastSeq,
2076
+ lastConnectedAt: Date.now(),
2077
+ intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
2078
+ accountId: account.accountId,
2079
+ savedAt: Date.now(),
2080
+ appId: account.appId,
2081
+ });
2082
+ }
2083
+ }
2084
+ else if (t === "C2C_MESSAGE_CREATE") {
2085
+ const event = d;
2086
+ // P1-3: 记录已知用户
2087
+ recordKnownUser({
2088
+ openid: event.author.user_openid,
2089
+ type: "c2c",
2090
+ accountId: account.accountId,
2091
+ });
2092
+ // 使用消息队列异步处理,防止阻塞心跳
2093
+ enqueueMessage({
2094
+ type: "c2c",
2095
+ senderId: event.author.user_openid,
2096
+ content: event.content,
2097
+ messageId: event.id,
2098
+ timestamp: event.timestamp,
2099
+ attachments: event.attachments,
2100
+ });
2101
+ }
2102
+ else if (t === "AT_MESSAGE_CREATE") {
2103
+ const event = d;
2104
+ // P1-3: 记录已知用户(频道用户)
2105
+ recordKnownUser({
2106
+ openid: event.author.id,
2107
+ type: "c2c", // 频道用户按 c2c 类型存储
2108
+ nickname: event.author.username,
2109
+ accountId: account.accountId,
2110
+ });
2111
+ enqueueMessage({
2112
+ type: "guild",
2113
+ senderId: event.author.id,
2114
+ senderName: event.author.username,
2115
+ content: event.content,
2116
+ messageId: event.id,
2117
+ timestamp: event.timestamp,
2118
+ channelId: event.channel_id,
2119
+ guildId: event.guild_id,
2120
+ attachments: event.attachments,
2121
+ });
2122
+ }
2123
+ else if (t === "DIRECT_MESSAGE_CREATE") {
2124
+ const event = d;
2125
+ // P1-3: 记录已知用户(频道私信用户)
2126
+ recordKnownUser({
2127
+ openid: event.author.id,
2128
+ type: "c2c",
2129
+ nickname: event.author.username,
2130
+ accountId: account.accountId,
2131
+ });
2132
+ enqueueMessage({
2133
+ type: "dm",
2134
+ senderId: event.author.id,
2135
+ senderName: event.author.username,
2136
+ content: event.content,
2137
+ messageId: event.id,
2138
+ timestamp: event.timestamp,
2139
+ guildId: event.guild_id,
2140
+ attachments: event.attachments,
2141
+ });
2142
+ }
2143
+ else if (t === "GROUP_AT_MESSAGE_CREATE") {
2144
+ const event = d;
2145
+ // P1-3: 记录已知用户(群组用户)
2146
+ recordKnownUser({
2147
+ openid: event.author.member_openid,
2148
+ type: "group",
2149
+ groupOpenid: event.group_openid,
2150
+ accountId: account.accountId,
2151
+ });
2152
+ enqueueMessage({
2153
+ type: "group",
2154
+ senderId: event.author.member_openid,
2155
+ content: event.content,
2156
+ messageId: event.id,
2157
+ timestamp: event.timestamp,
2158
+ groupOpenid: event.group_openid,
2159
+ attachments: event.attachments,
2160
+ });
2161
+ }
2162
+ break;
2163
+ case 11: // Heartbeat ACK
2164
+ log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`);
2165
+ break;
2166
+ case 7: // Reconnect
2167
+ log?.info(`[qqbot:${account.accountId}] Server requested reconnect`);
2168
+ cleanup();
2169
+ scheduleReconnect();
2170
+ break;
2171
+ case 9: // Invalid Session
2172
+ const canResume = d;
2173
+ const currentLevel = INTENT_LEVELS[intentLevelIndex];
2174
+ log?.error(`[qqbot:${account.accountId}] Invalid session (${currentLevel.description}), can resume: ${canResume}, raw: ${rawData}`);
2175
+ if (!canResume) {
2176
+ sessionId = null;
2177
+ lastSeq = null;
2178
+ // P1-2: 清除持久化的 Session
2179
+ clearSession(account.accountId);
2180
+ // 尝试降级到下一个权限级别
2181
+ if (intentLevelIndex < INTENT_LEVELS.length - 1) {
2182
+ intentLevelIndex++;
2183
+ const nextLevel = INTENT_LEVELS[intentLevelIndex];
2184
+ log?.info(`[qqbot:${account.accountId}] Downgrading intents to: ${nextLevel.description}`);
2185
+ }
2186
+ else {
2187
+ // 已经是最低权限级别了
2188
+ log?.error(`[qqbot:${account.accountId}] All intent levels failed. Please check AppID/Secret.`);
2189
+ shouldRefreshToken = true;
2190
+ }
2191
+ }
2192
+ cleanup();
2193
+ // Invalid Session 后等待一段时间再重连
2194
+ scheduleReconnect(3000);
2195
+ break;
2196
+ }
2197
+ }
2198
+ catch (err) {
2199
+ log?.error(`[qqbot:${account.accountId}] Message parse error: ${err}`);
2200
+ }
2201
+ });
2202
+ ws.on("close", (code, reason) => {
2203
+ log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
2204
+ isConnecting = false; // 释放锁
2205
+ // 根据错误码处理(参考 QQ 官方文档)
2206
+ // 4004: CODE_INVALID_TOKEN - Token 无效,需刷新 token 重新连接
2207
+ // 4006: CODE_SESSION_NO_LONGER_VALID - 会话失效,需重新 identify
2208
+ // 4007: CODE_INVALID_SEQ - Resume 时 seq 无效,需重新 identify
2209
+ // 4008: CODE_RATE_LIMITED - 限流断开,等待后重连
2210
+ // 4009: CODE_SESSION_TIMED_OUT - 会话超时,需重新 identify
2211
+ // 4900-4913: 内部错误,需要重新 identify
2212
+ // 4914: 机器人已下架
2213
+ // 4915: 机器人已封禁
2214
+ if (code === 4914 || code === 4915) {
2215
+ log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`);
2216
+ cleanup();
2217
+ // 不重连,直接退出
2218
+ return;
2219
+ }
2220
+ // 4004: Token 无效,强制刷新 token 后重连
2221
+ if (code === 4004) {
2222
+ log?.info(`[qqbot:${account.accountId}] Invalid token (4004), will refresh token and reconnect`);
2223
+ shouldRefreshToken = true;
2224
+ cleanup();
2225
+ if (!isAborted) {
2226
+ scheduleReconnect();
2227
+ }
2228
+ return;
2229
+ }
2230
+ // 4008: 限流断开,等待后重连(不需要重新 identify)
2231
+ if (code === 4008) {
2232
+ log?.info(`[qqbot:${account.accountId}] Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms before reconnect`);
2233
+ cleanup();
2234
+ if (!isAborted) {
2235
+ scheduleReconnect(RATE_LIMIT_DELAY);
2236
+ }
2237
+ return;
2238
+ }
2239
+ // 4006/4007/4009: 会话失效或超时,需要清除 session 重新 identify
2240
+ if (code === 4006 || code === 4007 || code === 4009) {
2241
+ const codeDesc = {
2242
+ 4006: "session no longer valid",
2243
+ 4007: "invalid seq on resume",
2244
+ 4009: "session timed out",
2245
+ };
2246
+ log?.info(`[qqbot:${account.accountId}] Error ${code} (${codeDesc[code]}), will re-identify`);
2247
+ sessionId = null;
2248
+ lastSeq = null;
2249
+ // 清除持久化的 Session
2250
+ clearSession(account.accountId);
2251
+ shouldRefreshToken = true;
2252
+ }
2253
+ else if (code >= 4900 && code <= 4913) {
2254
+ // 4900-4913 内部错误,清除 session 重新 identify
2255
+ log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`);
2256
+ sessionId = null;
2257
+ lastSeq = null;
2258
+ // 清除持久化的 Session
2259
+ clearSession(account.accountId);
2260
+ shouldRefreshToken = true;
2261
+ }
2262
+ // 检测是否是快速断开(连接后很快就断了)
2263
+ const connectionDuration = Date.now() - lastConnectTime;
2264
+ if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) {
2265
+ quickDisconnectCount++;
2266
+ log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
2267
+ // 如果连续快速断开超过阈值,等待更长时间
2268
+ if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
2269
+ log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`);
2270
+ log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`);
2271
+ quickDisconnectCount = 0;
2272
+ cleanup();
2273
+ // 快速断开太多次,等待更长时间再重连
2274
+ if (!isAborted && code !== 1000) {
2275
+ scheduleReconnect(RATE_LIMIT_DELAY);
2276
+ }
2277
+ return;
2278
+ }
2279
+ }
2280
+ else {
2281
+ // 连接持续时间够长,重置计数
2282
+ quickDisconnectCount = 0;
2283
+ }
2284
+ cleanup();
2285
+ // 非正常关闭则重连
2286
+ if (!isAborted && code !== 1000) {
2287
+ scheduleReconnect();
2288
+ }
2289
+ });
2290
+ ws.on("error", (err) => {
2291
+ log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`);
2292
+ onError?.(err);
2293
+ });
2294
+ }
2295
+ catch (err) {
2296
+ isConnecting = false; // 释放锁
2297
+ const errMsg = String(err);
2298
+ log?.error(`[qqbot:${account.accountId}] Connection failed: ${err}`);
2299
+ // 如果是频率限制错误,等待更长时间
2300
+ if (errMsg.includes("Too many requests") || errMsg.includes("100001")) {
2301
+ log?.info(`[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`);
2302
+ scheduleReconnect(RATE_LIMIT_DELAY);
2303
+ }
2304
+ else {
2305
+ scheduleReconnect();
2306
+ }
2307
+ }
2308
+ };
2309
+ // 开始连接
2310
+ await connect();
2311
+ // 等待 abort 信号
2312
+ return new Promise((resolve) => {
2313
+ abortSignal.addEventListener("abort", () => resolve());
2314
+ });
2315
+ }