@sliverp/qqbot 1.3.7 → 1.3.8

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.
@@ -1,7 +1,13 @@
1
1
  import WebSocket from "ws";
2
- import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
2
+ import path from "node:path";
3
+ import * as fs from "node:fs";
4
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify } from "./api.js";
5
+ import { loadSession, saveSession, clearSession } from "./session-store.js";
6
+ import { recordKnownUser, flushKnownUsers } from "./known-users.js";
3
7
  import { getQQBotRuntime } from "./runtime.js";
4
- import { downloadFile } from "./image-server.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";
5
11
  // QQ Bot intents - 按权限级别分组
6
12
  const INTENTS = {
7
13
  // 基础权限(默认有)
@@ -39,15 +45,130 @@ const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
39
45
  const MAX_RECONNECT_ATTEMPTS = 100;
40
46
  const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
41
47
  const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
48
+ // 图床服务器配置(可通过环境变量覆盖)
49
+ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10);
50
+ // 使用绝对路径,确保文件保存和读取使用同一目录
51
+ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-images");
52
+ // 消息队列配置(异步处理,防止阻塞心跳)
53
+ const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度
54
+ const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值
55
+ // ============ 消息回复限流器 ============
56
+ // 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息
57
+ const MESSAGE_REPLY_LIMIT = 4;
58
+ const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
59
+ const messageReplyTracker = new Map();
60
+ /**
61
+ * 检查是否可以回复该消息(限流检查)
62
+ * @param messageId 消息ID
63
+ * @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数
64
+ */
65
+ function checkMessageReplyLimit(messageId) {
66
+ const now = Date.now();
67
+ const record = messageReplyTracker.get(messageId);
68
+ // 清理过期记录(定期清理,避免内存泄漏)
69
+ if (messageReplyTracker.size > 10000) {
70
+ for (const [id, rec] of messageReplyTracker) {
71
+ if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
72
+ messageReplyTracker.delete(id);
73
+ }
74
+ }
75
+ }
76
+ if (!record) {
77
+ return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
78
+ }
79
+ // 检查是否过期
80
+ if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
81
+ messageReplyTracker.delete(messageId);
82
+ return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
83
+ }
84
+ // 检查是否超过限制
85
+ const remaining = MESSAGE_REPLY_LIMIT - record.count;
86
+ return { allowed: remaining > 0, remaining: Math.max(0, remaining) };
87
+ }
88
+ /**
89
+ * 记录一次消息回复
90
+ * @param messageId 消息ID
91
+ */
92
+ function recordMessageReply(messageId) {
93
+ const now = Date.now();
94
+ const record = messageReplyTracker.get(messageId);
95
+ if (!record) {
96
+ messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
97
+ }
98
+ else {
99
+ // 检查是否过期,过期则重新计数
100
+ if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
101
+ messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
102
+ }
103
+ else {
104
+ record.count++;
105
+ }
106
+ }
107
+ }
108
+ // ============ 内部标记过滤 ============
109
+ /**
110
+ * 过滤内部标记(如 [[reply_to: xxx]])
111
+ * 这些标记可能被 AI 错误地学习并输出,需要在发送前移除
112
+ */
113
+ function filterInternalMarkers(text) {
114
+ if (!text)
115
+ return text;
116
+ // 过滤 [[xxx: yyy]] 格式的内部标记
117
+ // 例如: [[reply_to: ROBOT1.0_kbc...]]
118
+ let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, "");
119
+ // 清理可能产生的多余空行
120
+ result = result.replace(/\n{3,}/g, "\n\n").trim();
121
+ return result;
122
+ }
123
+ /**
124
+ * 启动图床服务器
125
+ */
126
+ async function ensureImageServer(log, publicBaseUrl) {
127
+ if (isImageServerRunning()) {
128
+ return publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`;
129
+ }
130
+ try {
131
+ const config = {
132
+ port: IMAGE_SERVER_PORT,
133
+ storageDir: IMAGE_SERVER_DIR,
134
+ // 使用用户配置的公网地址,而不是 0.0.0.0
135
+ baseUrl: publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`,
136
+ ttlSeconds: 3600, // 1 小时过期
137
+ };
138
+ await startImageServer(config);
139
+ log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}, baseUrl: ${config.baseUrl}`);
140
+ return config.baseUrl;
141
+ }
142
+ catch (err) {
143
+ log?.error(`[qqbot] Failed to start image server: ${err}`);
144
+ return null;
145
+ }
146
+ }
42
147
  /**
43
148
  * 启动 Gateway WebSocket 连接(带自动重连)
149
+ * 支持流式消息发送
44
150
  */
45
151
  export async function startGateway(ctx) {
46
152
  const { account, abortSignal, cfg, onReady, onError, log } = ctx;
47
153
  if (!account.appId || !account.clientSecret) {
48
154
  throw new Error("QQBot not configured (missing appId or clientSecret)");
49
155
  }
50
- // 不再需要图床服务器,使用 file_data 直接上传二进制
156
+ // 初始化 API 配置(markdown 支持)
157
+ initApiConfig({
158
+ markdownSupport: account.markdownSupport,
159
+ });
160
+ log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`);
161
+ // 如果配置了公网 URL,启动图床服务器
162
+ let imageServerBaseUrl = null;
163
+ if (account.imageServerBaseUrl) {
164
+ // 使用用户配置的公网地址作为 baseUrl
165
+ await ensureImageServer(log, account.imageServerBaseUrl);
166
+ imageServerBaseUrl = account.imageServerBaseUrl;
167
+ log?.info(`[qqbot:${account.accountId}] Image server enabled with URL: ${imageServerBaseUrl}`);
168
+ }
169
+ else {
170
+ log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
171
+ }
51
172
  let reconnectAttempts = 0;
52
173
  let isAborted = false;
53
174
  let currentWs = null;
@@ -61,6 +182,68 @@ export async function startGateway(ctx) {
61
182
  let shouldRefreshToken = false; // 下次连接是否需要刷新 token
62
183
  let intentLevelIndex = 0; // 当前尝试的权限级别索引
63
184
  let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
185
+ // ============ P1-2: 尝试从持久化存储恢复 Session ============
186
+ const savedSession = loadSession(account.accountId);
187
+ if (savedSession) {
188
+ sessionId = savedSession.sessionId;
189
+ lastSeq = savedSession.lastSeq;
190
+ intentLevelIndex = savedSession.intentLevelIndex;
191
+ lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
192
+ log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
193
+ }
194
+ // ============ 消息队列(异步处理,防止阻塞心跳) ============
195
+ const messageQueue = [];
196
+ let messageProcessorRunning = false;
197
+ let messagesProcessed = 0; // 统计已处理消息数
198
+ /**
199
+ * 将消息加入队列(非阻塞)
200
+ */
201
+ const enqueueMessage = (msg) => {
202
+ if (messageQueue.length >= MESSAGE_QUEUE_SIZE) {
203
+ // 队列满了,丢弃最旧的消息
204
+ const dropped = messageQueue.shift();
205
+ log?.error(`[qqbot:${account.accountId}] Message queue full, dropping oldest message from ${dropped?.senderId}`);
206
+ }
207
+ if (messageQueue.length >= MESSAGE_QUEUE_WARN_THRESHOLD) {
208
+ log?.info(`[qqbot:${account.accountId}] Message queue size: ${messageQueue.length}/${MESSAGE_QUEUE_SIZE}`);
209
+ }
210
+ messageQueue.push(msg);
211
+ log?.debug?.(`[qqbot:${account.accountId}] Message enqueued, queue size: ${messageQueue.length}`);
212
+ };
213
+ /**
214
+ * 启动消息处理循环(独立于 WS 消息循环)
215
+ */
216
+ const startMessageProcessor = (handleMessageFn) => {
217
+ if (messageProcessorRunning)
218
+ return;
219
+ messageProcessorRunning = true;
220
+ const processLoop = async () => {
221
+ while (!isAborted) {
222
+ if (messageQueue.length === 0) {
223
+ // 队列为空,等待一小段时间
224
+ await new Promise(resolve => setTimeout(resolve, 50));
225
+ continue;
226
+ }
227
+ const msg = messageQueue.shift();
228
+ try {
229
+ await handleMessageFn(msg);
230
+ messagesProcessed++;
231
+ }
232
+ catch (err) {
233
+ // 捕获处理异常,防止影响队列循环
234
+ log?.error(`[qqbot:${account.accountId}] Message processor error: ${err}`);
235
+ }
236
+ }
237
+ messageProcessorRunning = false;
238
+ log?.info(`[qqbot:${account.accountId}] Message processor stopped`);
239
+ };
240
+ // 异步启动,不阻塞调用者
241
+ processLoop().catch(err => {
242
+ log?.error(`[qqbot:${account.accountId}] Message processor crashed: ${err}`);
243
+ messageProcessorRunning = false;
244
+ });
245
+ log?.info(`[qqbot:${account.accountId}] Message processor started`);
246
+ };
64
247
  abortSignal.addEventListener("abort", () => {
65
248
  isAborted = true;
66
249
  if (reconnectTimer) {
@@ -68,6 +251,10 @@ export async function startGateway(ctx) {
68
251
  reconnectTimer = null;
69
252
  }
70
253
  cleanup();
254
+ // P1-1: 停止后台 Token 刷新
255
+ stopBackgroundTokenRefresh();
256
+ // P1-3: 保存已知用户数据
257
+ flushKnownUsers();
71
258
  });
72
259
  const cleanup = () => {
73
260
  if (heartbeatInterval) {
@@ -130,12 +317,18 @@ export async function startGateway(ctx) {
130
317
  if (event.attachments?.length) {
131
318
  log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
132
319
  }
133
- // 对于 C2C 消息,可以发送输入状态提示(可选功能,暂时跳过)
134
320
  pluginRuntime.channel.activity.record({
135
321
  channel: "qqbot",
136
322
  accountId: account.accountId,
137
323
  direction: "inbound",
138
324
  });
325
+ try {
326
+ await sendC2CInputNotify(accessToken, event.senderId, event.messageId, 60);
327
+ log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
328
+ }
329
+ catch (err) {
330
+ log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
331
+ }
139
332
  const isGroup = event.type === "guild" || event.type === "group";
140
333
  const peerId = event.type === "guild" ? `channel:${event.channelId}`
141
334
  : event.type === "group" ? `group:${event.groupOpenid}`
@@ -152,12 +345,65 @@ export async function startGateway(ctx) {
152
345
  const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
153
346
  // 组装消息体,添加系统提示词
154
347
  let builtinPrompt = "";
155
- // 图片发送功能已改为使用 file_data 直接上传,不需要图床
348
+ // ============ 用户标识信息(用于定时提醒和主动消息) ============
349
+ const isGroupChat = event.type === "group";
350
+ const targetAddress = isGroupChat ? `group:${event.groupOpenid}` : event.senderId;
351
+ builtinPrompt += `
352
+ 【当前用户信息】
353
+ - 用户 openid: ${event.senderId}
354
+ - 用户昵称: ${event.senderName || "未知"}
355
+ - 消息类型: ${isGroupChat ? "群聊" : "私聊"}
356
+ - 当前消息 message_id: ${event.messageId}${isGroupChat ? `
357
+ - 群组 group_openid: ${event.groupOpenid}` : ""}
358
+
359
+ 【定时提醒能力】
360
+ 你可以帮助用户设置定时提醒。使用exec工具来来运行bash命令 openclaw cron 命令而不是直接使用 "cron" 工具:
361
+
362
+ 示例:5分钟后提醒用户喝水
363
+ \`\`\`bash
364
+ openclaw cron add \\
365
+ --name "提醒喝水-${event.senderName || "用户"}" \\
366
+ --at "5m" \\
367
+ --session isolated \\
368
+ --message "💧 该喝水啦!" \\
369
+ --deliver \\
370
+ --channel qqbot \\
371
+ --to "${targetAddress}" \\
372
+ --delete-after-run
373
+ \`\`\`
374
+
375
+ 关键参数说明:
376
+ - \`--to\`: 目标地址(当前用户: ${targetAddress})
377
+ - \`--at\`: 一次性定时任务的触发时间
378
+ - 相对时间格式:数字+单位,如 \`5m\`(5分钟)、\`1h\`(1小时)、\`2d\`(2天)【注意:不要加 + 号】
379
+ - 绝对时间格式:ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\`
380
+ - \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点)
381
+ - \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区
382
+ - \`--delete-after-run\`: 一次性任务必须添加此参数
383
+ - \`--message\`: 消息内容(必填,不能为空!这是定时提醒触发时直接发送给用户的内容)
384
+ - \`--session isolated\` 独立会话任务
385
+
386
+ ⚠️ 重要注意事项:
387
+ 1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
388
+ 2. --message 参数必须有实际内容,不能为空字符串
389
+ 3. cron add 命令不支持 --reply-to 参数,定时提醒只能作为主动消息发送`;
390
+ // 🎯 发送图片功能:使用 <qqimg> 标签发送本地或网络图片
391
+ // 系统会自动将本地文件转换为 Base64 发送,不需要图床服务器
156
392
  builtinPrompt += `
157
393
 
158
394
  【发送图片】
159
- 你可以发送本地图片文件给用户。只需在回复中直接引用图片的绝对路径即可,系统会自动处理。
160
- 支持 png、jpg、gif、webp 格式。`;
395
+ 你可以直接发送图片给用户!使用 <qqimg> 标签包裹图片路径:
396
+
397
+ <qqimg>图片路径</qqimg>
398
+
399
+ 示例:
400
+ - <qqimg>/Users/xxx/images/photo.jpg</qqimg> (本地文件)
401
+ - <qqimg>https://example.com/image.png</qqimg> (网络图片)
402
+
403
+ ⚠️ 注意:
404
+ - 必须使用 <qqimg>路径</qqimg> 格式
405
+ - 本地路径必须是绝对路径,支持 png、jpg、jpeg、gif、webp 格式
406
+ - 图片文件/URL 必须有效,否则发送失败`;
161
407
  const systemPrompts = [builtinPrompt];
162
408
  if (account.systemPrompt) {
163
409
  systemPrompts.push(account.systemPrompt);
@@ -166,18 +412,32 @@ export async function startGateway(ctx) {
166
412
  let attachmentInfo = "";
167
413
  const imageUrls = [];
168
414
  // 存到 clawdbot 工作目录下的 downloads 文件夹
169
- const downloadDir = `${process.env.HOME || "/home/ubuntu"}/clawd/downloads`;
415
+ const downloadDir = path.join(process.env.HOME || "/home/ubuntu", "clawd", "downloads");
170
416
  if (event.attachments?.length) {
417
+ // ============ 接收图片的自然语言描述生成 ============
418
+ // 根据需求 4:将图片信息转换为自然语言描述,便于 AI 理解
419
+ const imageDescriptions = [];
420
+ const otherAttachments = [];
171
421
  for (const att of event.attachments) {
172
422
  // 下载附件到本地,使用原始文件名
173
423
  const localPath = await downloadFile(att.url, downloadDir, att.filename);
174
424
  if (localPath) {
175
425
  if (att.content_type?.startsWith("image/")) {
176
426
  imageUrls.push(localPath);
177
- attachmentInfo += `\n[图片: ${localPath}]`;
427
+ // 构建自然语言描述(根据需求 4.2)
428
+ const format = att.content_type?.split("/")[1] || "未知格式";
429
+ const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
430
+ imageDescriptions.push(`
431
+ 用户发送了一张图片:
432
+ - 图片地址:${localPath}
433
+ - 图片格式:${format}
434
+ - 消息ID:${event.messageId}
435
+ - 发送时间:${timestamp}
436
+
437
+ 请根据图片内容进行回复。`);
178
438
  }
179
439
  else {
180
- attachmentInfo += `\n[附件: ${localPath}]`;
440
+ otherAttachments.push(`[附件: ${localPath}]`);
181
441
  }
182
442
  log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
183
443
  }
@@ -186,18 +446,39 @@ export async function startGateway(ctx) {
186
446
  log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`);
187
447
  if (att.content_type?.startsWith("image/")) {
188
448
  imageUrls.push(att.url);
189
- attachmentInfo += `\n[图片: ${att.url}] (下载失败,可能无法访问)`;
449
+ // 下载失败时的自然语言描述
450
+ const format = att.content_type?.split("/")[1] || "未知格式";
451
+ const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
452
+ imageDescriptions.push(`
453
+ 用户发送了一张图片(下载失败,使用原始URL):
454
+ - 图片地址:${att.url}
455
+ - 图片格式:${format}
456
+ - 消息ID:${event.messageId}
457
+ - 发送时间:${timestamp}
458
+
459
+ 请根据图片内容进行回复。`);
190
460
  }
191
461
  else {
192
- attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}] (下载失败)`;
462
+ otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`);
193
463
  }
194
464
  }
195
465
  }
466
+ // 组合附件信息:先图片描述,后其他附件
467
+ if (imageDescriptions.length > 0) {
468
+ attachmentInfo += "\n" + imageDescriptions.join("\n");
469
+ }
470
+ if (otherAttachments.length > 0) {
471
+ attachmentInfo += "\n" + otherAttachments.join("\n");
472
+ }
196
473
  }
197
474
  const userContent = event.content + attachmentInfo;
198
- const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
475
+ let messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
476
+ if (userContent.startsWith("/")) { // 保留Openclaw原始命令
477
+ messageBody = userContent;
478
+ }
479
+ log?.info(`[qqbot:${account.accountId}] messageBody: ${messageBody}`);
199
480
  const body = pluginRuntime.channel.reply.formatInboundEnvelope({
200
- channel: "QQBot",
481
+ channel: "qqbot",
201
482
  from: event.senderName ?? event.senderId,
202
483
  timestamp: new Date(event.timestamp).getTime(),
203
484
  body: messageBody,
@@ -214,6 +495,11 @@ export async function startGateway(ctx) {
214
495
  : event.type === "group" ? `qqbot:group:${event.groupOpenid}`
215
496
  : `qqbot:c2c:${event.senderId}`;
216
497
  const toAddress = fromAddress;
498
+ // 计算命令授权状态
499
+ // allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
500
+ const allowFromList = account.config?.allowFrom ?? [];
501
+ const allowAll = allowFromList.length === 0 || allowFromList.some((entry) => entry === "*");
502
+ const commandAuthorized = allowAll || allowFromList.some((entry) => entry.toUpperCase() === event.senderId.toUpperCase());
217
503
  const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
218
504
  Body: body,
219
505
  RawBody: event.content,
@@ -234,9 +520,8 @@ export async function startGateway(ctx) {
234
520
  QQChannelId: event.channelId,
235
521
  QQGuildId: event.guildId,
236
522
  QQGroupOpenid: event.groupOpenid,
523
+ CommandAuthorized: commandAuthorized,
237
524
  });
238
- // 打印 ctxPayload 详细信息(便于调试)
239
- log?.info(`[qqbot:${account.accountId}] ctxPayload: From=${fromAddress}, To=${toAddress}, SessionKey=${route.sessionKey}, AccountId=${route.accountId}, ChatType=${isGroup ? "group" : "direct"}, SenderId=${event.senderId}, MessageSid=${event.messageId}, BodyLen=${body?.length ?? 0}`);
240
525
  // 发送消息的辅助函数,带 token 过期重试
241
526
  const sendWithTokenRetry = async (sendFn) => {
242
527
  try {
@@ -280,7 +565,7 @@ export async function startGateway(ctx) {
280
565
  const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
281
566
  // 追踪是否有响应
282
567
  let hasResponse = false;
283
- const responseTimeout = 90000; // 90s 超时
568
+ const responseTimeout = 60000; // 60秒超时(1分钟)
284
569
  let timeoutId = null;
285
570
  const timeoutPromise = new Promise((_, reject) => {
286
571
  timeoutId = setTimeout(() => {
@@ -289,8 +574,11 @@ export async function startGateway(ctx) {
289
574
  }
290
575
  }, responseTimeout);
291
576
  });
292
- // 调用 dispatchReply
293
- log?.info(`[qqbot:${account.accountId}] dispatchReply: agentId=${route.agentId}, prefix=${messagesConfig.responsePrefix ?? "(none)"}`);
577
+ // ============ 消息发送目标 ============
578
+ // 确定发送目标
579
+ const targetTo = event.type === "c2c" ? event.senderId
580
+ : event.type === "group" ? `group:${event.groupOpenid}`
581
+ : `channel:${event.channelId}`;
294
582
  const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
295
583
  ctx: ctxPayload,
296
584
  cfg,
@@ -302,148 +590,543 @@ export async function startGateway(ctx) {
302
590
  clearTimeout(timeoutId);
303
591
  timeoutId = null;
304
592
  }
305
- log?.info(`[qqbot:${account.accountId}] deliver(${info.kind}): textLen=${payload.text?.length ?? 0}, mediaUrls=${payload.mediaUrls?.length ?? 0}, mediaUrl=${payload.mediaUrl ? "yes" : "no"}`);
306
- if (payload.text) {
307
- log?.info(`[qqbot:${account.accountId}] text preview: ${payload.text.slice(0, 150).replace(/\n/g, "\\n")}...`);
308
- }
593
+ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
309
594
  let replyText = payload.text ?? "";
310
- // 收集所有图片来源(本地路径或 base64 数据)
311
- const imageSources = [];
312
- // 处理 mediaUrls mediaUrl 字段(本地文件路径)
313
- const mediaPaths = [];
314
- if (payload.mediaUrls?.length) {
315
- mediaPaths.push(...payload.mediaUrls);
316
- }
317
- if (payload.mediaUrl && !mediaPaths.includes(payload.mediaUrl)) {
318
- mediaPaths.push(payload.mediaUrl);
319
- }
320
- for (const localPath of mediaPaths) {
321
- if (localPath) {
322
- // 直接添加本地路径,API 会读取文件并用 file_data 上传
323
- imageSources.push(localPath);
324
- log?.info(`[qqbot:${account.accountId}] Added media path: ${localPath}`);
325
- }
326
- }
327
- // 如果没有文本也没有图片,跳过
328
- if (!replyText.trim() && imageSources.length === 0) {
329
- log?.info(`[qqbot:${account.accountId}] Empty reply, skipping`);
330
- return;
331
- }
332
- // 0. 提取 <qqimg>路径</qqimg> 格式的图片(支持 </qqimg> 和 </img> 两种闭合方式)
595
+ // ============ 简单图片标签解析 ============
596
+ // 支持 <qqimg>路径</qqimg> 或 <qqimg>路径</img> 格式发送图片
597
+ // 这是比 QQBOT_PAYLOAD JSON 更简单的方式,适合大模型能力较弱的情况
598
+ // 注意:正则限制内容不能包含 < 和 >,避免误匹配 `<qqimg>` 这种反引号内的说明文字
599
+ // 🔧 支持两种闭合方式:</qqimg> 和 </img>(AI 可能输出不同格式)
333
600
  const qqimgRegex = /<qqimg>([^<>]+)<\/(?:qqimg|img)>/gi;
334
601
  const qqimgMatches = [...replyText.matchAll(qqimgRegex)];
335
- for (const match of qqimgMatches) {
336
- const imagePath = match[1]?.trim();
337
- if (imagePath) {
338
- imageSources.push(imagePath);
339
- log?.info(`[qqbot:${account.accountId}] Added <qqimg> path: ${imagePath}`);
602
+ if (qqimgMatches.length > 0) {
603
+ log?.info(`[qqbot:${account.accountId}] Detected ${qqimgMatches.length} <qqimg> tag(s)`);
604
+ // 构建发送队列:根据内容在原文中的实际位置顺序发送
605
+ // type: 'text' | 'image', content: 文本内容或图片路径
606
+ const sendQueue = [];
607
+ let lastIndex = 0;
608
+ // 使用新的正则来获取带索引的匹配结果(支持 </qqimg> 和 </img> 两种闭合方式)
609
+ const qqimgRegexWithIndex = /<qqimg>([^<>]+)<\/(?:qqimg|img)>/gi;
610
+ let match;
611
+ while ((match = qqimgRegexWithIndex.exec(replyText)) !== null) {
612
+ // 添加标签前的文本
613
+ const textBefore = replyText.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
614
+ if (textBefore) {
615
+ sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
616
+ }
617
+ // 添加图片
618
+ const imagePath = match[1]?.trim();
619
+ if (imagePath) {
620
+ sendQueue.push({ type: "image", content: imagePath });
621
+ log?.info(`[qqbot:${account.accountId}] Found image path in <qqimg>: ${imagePath}`);
622
+ }
623
+ lastIndex = match.index + match[0].length;
624
+ }
625
+ // 添加最后一个标签后的文本
626
+ const textAfter = replyText.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
627
+ if (textAfter) {
628
+ sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
629
+ }
630
+ log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
631
+ // 按顺序发送
632
+ for (const item of sendQueue) {
633
+ if (item.type === "text") {
634
+ // 发送文本
635
+ try {
636
+ await sendWithTokenRetry(async (token) => {
637
+ if (event.type === "c2c") {
638
+ await sendC2CMessage(token, event.senderId, item.content, event.messageId);
639
+ }
640
+ else if (event.type === "group" && event.groupOpenid) {
641
+ await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
642
+ }
643
+ else if (event.channelId) {
644
+ await sendChannelMessage(token, event.channelId, item.content, event.messageId);
645
+ }
646
+ });
647
+ log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
648
+ }
649
+ catch (err) {
650
+ log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
651
+ }
652
+ }
653
+ else if (item.type === "image") {
654
+ // 发送图片
655
+ const imagePath = item.content;
656
+ try {
657
+ let imageUrl = imagePath;
658
+ // 判断是本地文件还是 URL
659
+ const isLocalPath = imagePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(imagePath);
660
+ const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
661
+ if (isLocalPath) {
662
+ // 本地文件:转换为 Base64 Data URL
663
+ if (!fs.existsSync(imagePath)) {
664
+ log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`);
665
+ await sendErrorMessage(`图片文件不存在: ${imagePath}`);
666
+ continue;
667
+ }
668
+ const fileBuffer = fs.readFileSync(imagePath);
669
+ const base64Data = fileBuffer.toString("base64");
670
+ const ext = path.extname(imagePath).toLowerCase();
671
+ const mimeTypes = {
672
+ ".jpg": "image/jpeg",
673
+ ".jpeg": "image/jpeg",
674
+ ".png": "image/png",
675
+ ".gif": "image/gif",
676
+ ".webp": "image/webp",
677
+ ".bmp": "image/bmp",
678
+ };
679
+ const mimeType = mimeTypes[ext];
680
+ if (!mimeType) {
681
+ log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
682
+ await sendErrorMessage(`不支持的图片格式: ${ext}`);
683
+ continue;
684
+ }
685
+ imageUrl = `data:${mimeType};base64,${base64Data}`;
686
+ log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`);
687
+ }
688
+ else if (!isHttpUrl) {
689
+ log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`);
690
+ continue;
691
+ }
692
+ // 发送图片
693
+ await sendWithTokenRetry(async (token) => {
694
+ if (event.type === "c2c") {
695
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
696
+ }
697
+ else if (event.type === "group" && event.groupOpenid) {
698
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
699
+ }
700
+ else if (event.channelId) {
701
+ // 频道使用 Markdown 格式(如果是公网 URL)
702
+ if (isHttpUrl) {
703
+ await sendChannelMessage(token, event.channelId, `![](${imagePath})`, event.messageId);
704
+ }
705
+ else {
706
+ // 频道不支持富媒体 Base64
707
+ log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`);
708
+ }
709
+ }
710
+ });
711
+ log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
712
+ }
713
+ catch (err) {
714
+ log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${err}`);
715
+ await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`);
716
+ }
717
+ }
340
718
  }
341
- // 从文本中移除 <qqimg> 标签
342
- replyText = replyText.replace(match[0], "").trim();
719
+ // 记录活动并返回
720
+ pluginRuntime.channel.activity.record({
721
+ channel: "qqbot",
722
+ accountId: account.accountId,
723
+ direction: "outbound",
724
+ });
725
+ return;
343
726
  }
344
- // 0.5. 提取 MEDIA: 前缀的本地文件路径(从文本中)
345
- const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
346
- const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
347
- for (const match of mediaMatches) {
348
- const localPath = match[1];
349
- if (localPath) {
350
- imageSources.push(localPath);
351
- log?.info(`[qqbot:${account.accountId}] Added MEDIA path: ${localPath}`);
727
+ // ============ 结构化载荷检测与分发 ============
728
+ // 优先检测 QQBOT_PAYLOAD: 前缀,如果是结构化载荷则分发到对应处理器
729
+ const payloadResult = parseQQBotPayload(replyText);
730
+ if (payloadResult.isPayload) {
731
+ if (payloadResult.error) {
732
+ // 载荷解析失败,发送错误提示
733
+ log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
734
+ await sendErrorMessage(`[QQBot] 载荷解析失败: ${payloadResult.error}`);
735
+ return;
736
+ }
737
+ if (payloadResult.payload) {
738
+ const parsedPayload = payloadResult.payload;
739
+ log?.info(`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`);
740
+ // 根据 type 分发到对应处理器
741
+ if (isCronReminderPayload(parsedPayload)) {
742
+ // ============ 定时提醒载荷处理 ============
743
+ log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
744
+ // 将载荷编码为 Base64,构建 cron add 命令
745
+ const cronMessage = encodePayloadForCron(parsedPayload);
746
+ // 向用户确认提醒已设置(通过正常消息发送)
747
+ const confirmText = `⏰ 提醒已设置,将在指定时间发送: "${parsedPayload.content}"`;
748
+ try {
749
+ await sendWithTokenRetry(async (token) => {
750
+ if (event.type === "c2c") {
751
+ await sendC2CMessage(token, event.senderId, confirmText, event.messageId);
752
+ }
753
+ else if (event.type === "group" && event.groupOpenid) {
754
+ await sendGroupMessage(token, event.groupOpenid, confirmText, event.messageId);
755
+ }
756
+ else if (event.channelId) {
757
+ await sendChannelMessage(token, event.channelId, confirmText, event.messageId);
758
+ }
759
+ });
760
+ log?.info(`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
761
+ }
762
+ catch (err) {
763
+ log?.error(`[qqbot:${account.accountId}] Failed to send cron confirmation: ${err}`);
764
+ }
765
+ // 记录活动并返回(cron add 命令需要由 AI 执行,这里只处理载荷)
766
+ pluginRuntime.channel.activity.record({
767
+ channel: "qqbot",
768
+ accountId: account.accountId,
769
+ direction: "outbound",
770
+ });
771
+ return;
772
+ }
773
+ else if (isMediaPayload(parsedPayload)) {
774
+ // ============ 媒体消息载荷处理 ============
775
+ log?.info(`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`);
776
+ if (parsedPayload.mediaType === "image") {
777
+ // 处理图片发送
778
+ let imageUrl = parsedPayload.path;
779
+ // 如果是本地文件,转换为 Base64 Data URL
780
+ if (parsedPayload.source === "file") {
781
+ try {
782
+ if (!fs.existsSync(imageUrl)) {
783
+ await sendErrorMessage(`[QQBot] 图片文件不存在: ${imageUrl}`);
784
+ return;
785
+ }
786
+ const fileBuffer = fs.readFileSync(imageUrl);
787
+ const base64Data = fileBuffer.toString("base64");
788
+ const ext = path.extname(imageUrl).toLowerCase();
789
+ const mimeTypes = {
790
+ ".jpg": "image/jpeg",
791
+ ".jpeg": "image/jpeg",
792
+ ".png": "image/png",
793
+ ".gif": "image/gif",
794
+ ".webp": "image/webp",
795
+ ".bmp": "image/bmp",
796
+ };
797
+ const mimeType = mimeTypes[ext];
798
+ if (!mimeType) {
799
+ await sendErrorMessage(`[QQBot] 不支持的图片格式: ${ext}`);
800
+ return;
801
+ }
802
+ imageUrl = `data:${mimeType};base64,${base64Data}`;
803
+ log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`);
804
+ }
805
+ catch (readErr) {
806
+ log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
807
+ await sendErrorMessage(`[QQBot] 读取图片文件失败: ${readErr}`);
808
+ return;
809
+ }
810
+ }
811
+ // 发送图片
812
+ try {
813
+ await sendWithTokenRetry(async (token) => {
814
+ if (event.type === "c2c") {
815
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
816
+ }
817
+ else if (event.type === "group" && event.groupOpenid) {
818
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
819
+ }
820
+ else if (event.channelId) {
821
+ // 频道使用 Markdown 格式
822
+ await sendChannelMessage(token, event.channelId, `![](${parsedPayload.path})`, event.messageId);
823
+ }
824
+ });
825
+ log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
826
+ // 如果有描述文本,单独发送
827
+ if (parsedPayload.caption) {
828
+ await sendWithTokenRetry(async (token) => {
829
+ if (event.type === "c2c") {
830
+ await sendC2CMessage(token, event.senderId, parsedPayload.caption, event.messageId);
831
+ }
832
+ else if (event.type === "group" && event.groupOpenid) {
833
+ await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption, event.messageId);
834
+ }
835
+ else if (event.channelId) {
836
+ await sendChannelMessage(token, event.channelId, parsedPayload.caption, event.messageId);
837
+ }
838
+ });
839
+ }
840
+ }
841
+ catch (err) {
842
+ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
843
+ await sendErrorMessage(`[QQBot] 发送图片失败: ${err}`);
844
+ }
845
+ }
846
+ else if (parsedPayload.mediaType === "audio") {
847
+ // 音频发送暂不支持
848
+ log?.info(`[qqbot:${account.accountId}] Audio sending not yet implemented`);
849
+ await sendErrorMessage(`[QQBot] 音频发送功能暂未实现,敬请期待~`);
850
+ }
851
+ else if (parsedPayload.mediaType === "video") {
852
+ // 视频发送暂不支持
853
+ log?.info(`[qqbot:${account.accountId}] Video sending not supported`);
854
+ await sendErrorMessage(`[QQBot] 视频发送功能暂不支持`);
855
+ }
856
+ else {
857
+ log?.error(`[qqbot:${account.accountId}] Unknown media type: ${parsedPayload.mediaType}`);
858
+ await sendErrorMessage(`[QQBot] 不支持的媒体类型: ${parsedPayload.mediaType}`);
859
+ }
860
+ // 记录活动并返回
861
+ pluginRuntime.channel.activity.record({
862
+ channel: "qqbot",
863
+ accountId: account.accountId,
864
+ direction: "outbound",
865
+ });
866
+ return;
867
+ }
868
+ else {
869
+ // 未知的载荷类型
870
+ log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${parsedPayload.type}`);
871
+ await sendErrorMessage(`[QQBot] 不支持的载荷类型: ${parsedPayload.type}`);
872
+ return;
873
+ }
352
874
  }
353
- // 从文本中移除 MEDIA: 行
354
- replyText = replyText.replace(match[0], "").trim();
355
875
  }
356
- // 1. 提取本地绝对文件路径(/path/to/image.png 或 /path/to/image_123_png 格式)
357
- // 支持标准扩展名和下划线替换后的扩展名
358
- const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi;
359
- const localPathMatches = [...replyText.matchAll(localPathRegex)];
360
- for (const match of localPathMatches) {
361
- let localPath = match[1].trim();
362
- if (localPath) {
363
- // 如果是下划线格式的扩展名,转换回点格式
364
- localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, ".");
365
- imageSources.push(localPath);
366
- log?.info(`[qqbot:${account.accountId}] Added local path: ${localPath}`);
876
+ // ============ 非结构化消息:简化处理 ============
877
+ // 📝 设计原则:JSON payload (QQBOT_PAYLOAD) 是发送本地图片的唯一方式
878
+ // 非结构化消息只处理:公网 URL (http/https) 和 Base64 Data URL
879
+ const imageUrls = [];
880
+ /**
881
+ * 检查并收集图片 URL(仅支持公网 URL 和 Base64 Data URL)
882
+ * ⚠️ 本地文件路径必须使用 QQBOT_PAYLOAD JSON 格式发送
883
+ */
884
+ const collectImageUrl = (url) => {
885
+ if (!url)
886
+ return false;
887
+ const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
888
+ const isDataUrl = url.startsWith("data:image/");
889
+ if (isHttpUrl || isDataUrl) {
890
+ if (!imageUrls.includes(url)) {
891
+ imageUrls.push(url);
892
+ if (isDataUrl) {
893
+ log?.info(`[qqbot:${account.accountId}] Collected Base64 image (length: ${url.length})`);
894
+ }
895
+ else {
896
+ log?.info(`[qqbot:${account.accountId}] Collected media URL: ${url.slice(0, 80)}...`);
897
+ }
898
+ }
899
+ return true;
900
+ }
901
+ // ⚠️ 本地文件路径不再在此处处理,应使用 <qqimg> 标签
902
+ const isLocalPath = url.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(url);
903
+ if (isLocalPath) {
904
+ log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
905
+ log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <qqimg>${url}</qqimg> tag to send local images`);
906
+ }
907
+ return false;
908
+ };
909
+ // 处理 mediaUrls 和 mediaUrl 字段
910
+ if (payload.mediaUrls?.length) {
911
+ for (const url of payload.mediaUrls) {
912
+ collectImageUrl(url);
367
913
  }
368
- // 从文本中移除本地路径
369
- replyText = replyText.replace(match[0], "").trim();
370
914
  }
371
- // 1. 提取 base64 图片(data:image/xxx;base64,...)
372
- const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
373
- const base64Matches = [...replyText.matchAll(base64ImageRegex)];
374
- for (const match of base64Matches) {
375
- const dataUrl = match[2] || match[3];
376
- if (dataUrl) {
377
- // 直接添加 data URL,API 会提取 base64 并用 file_data 上传
378
- imageSources.push(dataUrl);
379
- log?.info(`[qqbot:${account.accountId}] Added base64 image`);
915
+ if (payload.mediaUrl) {
916
+ collectImageUrl(payload.mediaUrl);
917
+ }
918
+ // 提取文本中的图片格式(仅处理公网 URL)
919
+ // 📝 设计:本地路径必须使用 QQBOT_PAYLOAD JSON 格式发送
920
+ const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
921
+ const mdMatches = [...replyText.matchAll(mdImageRegex)];
922
+ for (const match of mdMatches) {
923
+ const url = match[2]?.trim();
924
+ if (url && !imageUrls.includes(url)) {
925
+ if (url.startsWith('http://') || url.startsWith('https://')) {
926
+ // 公网 URL:收集并处理
927
+ imageUrls.push(url);
928
+ log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
929
+ }
930
+ else if (/^\/?(?:Users|home|tmp|var|private|[A-Z]:)/i.test(url)) {
931
+ // 本地路径:记录日志提示,但不发送
932
+ log?.info(`[qqbot:${account.accountId}] ⚠️ Local path in markdown (not sending): ${url}`);
933
+ log?.info(`[qqbot:${account.accountId}] 💡 Use <qqimg>${url}</qqimg> tag to send local images`);
934
+ }
380
935
  }
381
- // 从文本中移除 base64
382
- replyText = replyText.replace(match[0], "").trim();
383
936
  }
384
- // 2. 提取 URL 图片(Markdown 格式或纯 URL)- 这种情况仍用 url 方式上传
385
- // 注意:网络 URL 需要 QQ 服务器能访问,可能会失败
386
- const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
387
- const urlMatches = [...replyText.matchAll(imageUrlRegex)];
388
- for (const match of urlMatches) {
389
- // match[2] Markdown 格式的 URL,match[3] 是纯 URL
390
- const url = match[2] || match[3];
391
- if (url) {
392
- // 网络 URL 暂时跳过,因为 QQ 服务器可能无法访问
393
- // TODO: 可以下载到本地再用 file_data 上传
394
- log?.info(`[qqbot:${account.accountId}] Skipping network URL (not supported yet): ${url}`);
937
+ // 提取裸 URL 图片(公网 URL
938
+ const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
939
+ const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
940
+ for (const match of bareUrlMatches) {
941
+ const url = match[1];
942
+ if (url && !imageUrls.includes(url)) {
943
+ imageUrls.push(url);
944
+ log?.info(`[qqbot:${account.accountId}] Extracted bare image URL: ${url.slice(0, 80)}...`);
395
945
  }
396
946
  }
397
- try {
398
- // 先发送图片(如果有)
399
- log?.info(`[qqbot:${account.accountId}] imageSources to send: ${imageSources.length} items`);
400
- for (const imageSource of imageSources) {
947
+ // 判断是否使用 markdown 模式
948
+ const useMarkdown = account.markdownSupport === true;
949
+ log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`);
950
+ let textWithoutImages = replyText;
951
+ // 🎯 过滤内部标记(如 [[reply_to: xxx]])
952
+ // 这些标记可能被 AI 错误地学习并输出
953
+ textWithoutImages = filterInternalMarkers(textWithoutImages);
954
+ // 根据模式处理图片
955
+ if (useMarkdown) {
956
+ // ============ Markdown 模式 ============
957
+ // 🎯 关键改动:区分公网 URL 和本地文件/Base64
958
+ // - 公网 URL (http/https) → 使用 Markdown 图片格式 ![#宽px #高px](url)
959
+ // - 本地文件/Base64 (data:image/...) → 使用富媒体 API 发送
960
+ // 分离图片:公网 URL vs Base64/本地文件
961
+ const httpImageUrls = []; // 公网 URL,用于 Markdown 嵌入
962
+ const base64ImageUrls = []; // Base64,用于富媒体 API
963
+ for (const url of imageUrls) {
964
+ if (url.startsWith("data:image/")) {
965
+ base64ImageUrls.push(url);
966
+ }
967
+ else if (url.startsWith("http://") || url.startsWith("https://")) {
968
+ httpImageUrls.push(url);
969
+ }
970
+ }
971
+ log?.info(`[qqbot:${account.accountId}] Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
972
+ // 🔹 第一步:通过富媒体 API 发送 Base64 图片(本地文件已转换为 Base64)
973
+ if (base64ImageUrls.length > 0) {
974
+ log?.info(`[qqbot:${account.accountId}] Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
975
+ for (const imageUrl of base64ImageUrls) {
976
+ try {
977
+ await sendWithTokenRetry(async (token) => {
978
+ if (event.type === "c2c") {
979
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
980
+ }
981
+ else if (event.type === "group" && event.groupOpenid) {
982
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
983
+ }
984
+ else if (event.channelId) {
985
+ // 频道暂不支持富媒体,跳过
986
+ log?.info(`[qqbot:${account.accountId}] Channel does not support rich media, skipping Base64 image`);
987
+ }
988
+ });
989
+ log?.info(`[qqbot:${account.accountId}] Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`);
990
+ }
991
+ catch (imgErr) {
992
+ log?.error(`[qqbot:${account.accountId}] Failed to send Base64 image via Rich Media API: ${imgErr}`);
993
+ }
994
+ }
995
+ }
996
+ // 🔹 第二步:处理文本和公网 URL 图片
997
+ // 记录已存在于文本中的 markdown 图片 URL
998
+ const existingMdUrls = new Set(mdMatches.map(m => m[2]));
999
+ // 需要追加的公网图片(从 mediaUrl/mediaUrls 来的,且不在文本中)
1000
+ const imagesToAppend = [];
1001
+ // 处理需要追加的公网 URL 图片:获取尺寸并格式化
1002
+ for (const url of httpImageUrls) {
1003
+ if (!existingMdUrls.has(url)) {
1004
+ // 这个 URL 不在文本的 markdown 格式中,需要追加
1005
+ try {
1006
+ const size = await getImageSize(url);
1007
+ const mdImage = formatQQBotMarkdownImage(url, size);
1008
+ imagesToAppend.push(mdImage);
1009
+ log?.info(`[qqbot:${account.accountId}] Formatted HTTP image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`);
1010
+ }
1011
+ catch (err) {
1012
+ log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`);
1013
+ const mdImage = formatQQBotMarkdownImage(url, null);
1014
+ imagesToAppend.push(mdImage);
1015
+ }
1016
+ }
1017
+ }
1018
+ // 处理文本中已有的 markdown 图片:补充公网 URL 的尺寸信息
1019
+ // 📝 本地路径不再特殊处理(保留在文本中),因为不通过非结构化消息发送
1020
+ for (const match of mdMatches) {
1021
+ const fullMatch = match[0]; // ![alt](url)
1022
+ const imgUrl = match[2]; // url 部分
1023
+ // 只处理公网 URL,补充尺寸信息
1024
+ const isHttpUrl = imgUrl.startsWith('http://') || imgUrl.startsWith('https://');
1025
+ if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
1026
+ try {
1027
+ const size = await getImageSize(imgUrl);
1028
+ const newMdImage = formatQQBotMarkdownImage(imgUrl, size);
1029
+ textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
1030
+ log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`);
1031
+ }
1032
+ catch (err) {
1033
+ log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`);
1034
+ const newMdImage = formatQQBotMarkdownImage(imgUrl, null);
1035
+ textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage);
1036
+ }
1037
+ }
1038
+ }
1039
+ // 从文本中移除裸 URL 图片(已转换为 markdown 格式)
1040
+ for (const match of bareUrlMatches) {
1041
+ textWithoutImages = textWithoutImages.replace(match[0], "").trim();
1042
+ }
1043
+ // 追加需要添加的公网图片到文本末尾
1044
+ if (imagesToAppend.length > 0) {
1045
+ textWithoutImages = textWithoutImages.trim();
1046
+ if (textWithoutImages) {
1047
+ textWithoutImages += "\n\n" + imagesToAppend.join("\n");
1048
+ }
1049
+ else {
1050
+ textWithoutImages = imagesToAppend.join("\n");
1051
+ }
1052
+ }
1053
+ // 🔹 第三步:发送带公网图片的 markdown 消息
1054
+ if (textWithoutImages.trim()) {
401
1055
  try {
402
1056
  await sendWithTokenRetry(async (token) => {
403
1057
  if (event.type === "c2c") {
404
- log?.info(`[qqbot:${account.accountId}] sendC2CImage -> ${event.senderId}, source: ${imageSource.slice(0, 50)}...`);
405
- await sendC2CImageMessage(token, event.senderId, imageSource, event.messageId);
1058
+ await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
406
1059
  }
407
1060
  else if (event.type === "group" && event.groupOpenid) {
408
- log?.info(`[qqbot:${account.accountId}] sendGroupImage -> ${event.groupOpenid}, source: ${imageSource.slice(0, 50)}...`);
409
- await sendGroupImageMessage(token, event.groupOpenid, imageSource, event.messageId);
1061
+ await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
1062
+ }
1063
+ else if (event.channelId) {
1064
+ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
410
1065
  }
411
- // 频道消息暂不支持富媒体,跳过图片
412
1066
  });
1067
+ log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
413
1068
  }
414
- catch (imgErr) {
415
- log?.error(`[qqbot:${account.accountId}] Image send failed: ${imgErr}`);
416
- // 图片发送失败时,显示错误信息而不是 URL
417
- const errMsg = String(imgErr).slice(0, 200);
418
- replyText = `[图片发送失败: ${errMsg}]\n${replyText}`;
1069
+ catch (err) {
1070
+ log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
419
1071
  }
420
1072
  }
421
- // 再发送文本(如果有)
422
- if (replyText.trim()) {
423
- await sendWithTokenRetry(async (token) => {
424
- if (event.type === "c2c") {
425
- log?.info(`[qqbot:${account.accountId}] sendC2CText -> ${event.senderId}, len=${replyText.length}`);
426
- await sendC2CMessage(token, event.senderId, replyText, event.messageId);
427
- }
428
- else if (event.type === "group" && event.groupOpenid) {
429
- log?.info(`[qqbot:${account.accountId}] sendGroupText -> ${event.groupOpenid}, len=${replyText.length}`);
430
- await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId);
1073
+ }
1074
+ else {
1075
+ // ============ 普通文本模式:使用富媒体 API 发送图片 ============
1076
+ // 从文本中移除所有图片相关内容
1077
+ for (const match of mdMatches) {
1078
+ textWithoutImages = textWithoutImages.replace(match[0], "").trim();
1079
+ }
1080
+ for (const match of bareUrlMatches) {
1081
+ textWithoutImages = textWithoutImages.replace(match[0], "").trim();
1082
+ }
1083
+ try {
1084
+ // 发送图片(通过富媒体 API)
1085
+ for (const imageUrl of imageUrls) {
1086
+ try {
1087
+ await sendWithTokenRetry(async (token) => {
1088
+ if (event.type === "c2c") {
1089
+ await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
1090
+ }
1091
+ else if (event.type === "group" && event.groupOpenid) {
1092
+ await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
1093
+ }
1094
+ else if (event.channelId) {
1095
+ // 频道暂不支持富媒体,发送文本 URL
1096
+ await sendChannelMessage(token, event.channelId, imageUrl, event.messageId);
1097
+ }
1098
+ });
1099
+ log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`);
431
1100
  }
432
- else if (event.channelId) {
433
- log?.info(`[qqbot:${account.accountId}] sendChannelText -> ${event.channelId}, len=${replyText.length}`);
434
- await sendChannelMessage(token, event.channelId, replyText, event.messageId);
1101
+ catch (imgErr) {
1102
+ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
435
1103
  }
436
- });
1104
+ }
1105
+ // 发送文本消息
1106
+ if (textWithoutImages.trim()) {
1107
+ await sendWithTokenRetry(async (token) => {
1108
+ if (event.type === "c2c") {
1109
+ await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
1110
+ }
1111
+ else if (event.type === "group" && event.groupOpenid) {
1112
+ await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
1113
+ }
1114
+ else if (event.channelId) {
1115
+ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
1116
+ }
1117
+ });
1118
+ log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
1119
+ }
1120
+ }
1121
+ catch (err) {
1122
+ log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
437
1123
  }
438
- pluginRuntime.channel.activity.record({
439
- channel: "qqbot",
440
- accountId: account.accountId,
441
- direction: "outbound",
442
- });
443
- }
444
- catch (err) {
445
- log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
446
1124
  }
1125
+ pluginRuntime.channel.activity.record({
1126
+ channel: "qqbot",
1127
+ accountId: account.accountId,
1128
+ direction: "outbound",
1129
+ });
447
1130
  },
448
1131
  onError: async (err) => {
449
1132
  log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
@@ -463,7 +1146,9 @@ export async function startGateway(ctx) {
463
1146
  }
464
1147
  },
465
1148
  },
466
- replyOptions: {},
1149
+ replyOptions: {
1150
+ disableBlockStreaming: false,
1151
+ },
467
1152
  });
468
1153
  // 等待分发完成或超时
469
1154
  try {
@@ -489,14 +1174,32 @@ export async function startGateway(ctx) {
489
1174
  isConnecting = false; // 连接完成,释放锁
490
1175
  reconnectAttempts = 0; // 连接成功,重置重试计数
491
1176
  lastConnectTime = Date.now(); // 记录连接时间
1177
+ // 启动消息处理器(异步处理,防止阻塞心跳)
1178
+ startMessageProcessor(handleMessage);
1179
+ // P1-1: 启动后台 Token 刷新
1180
+ startBackgroundTokenRefresh(account.appId, account.clientSecret, {
1181
+ log: log,
1182
+ });
492
1183
  });
493
1184
  ws.on("message", async (data) => {
494
1185
  try {
495
1186
  const rawData = data.toString();
496
1187
  const payload = JSON.parse(rawData);
497
1188
  const { op, d, s, t } = payload;
498
- if (s)
1189
+ if (s) {
499
1190
  lastSeq = s;
1191
+ // P1-2: 更新持久化存储中的 lastSeq(节流保存)
1192
+ if (sessionId) {
1193
+ saveSession({
1194
+ sessionId,
1195
+ lastSeq,
1196
+ lastConnectedAt: lastConnectTime,
1197
+ intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
1198
+ accountId: account.accountId,
1199
+ savedAt: Date.now(),
1200
+ });
1201
+ }
1202
+ }
500
1203
  log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
501
1204
  switch (op) {
502
1205
  case 10: // Hello
@@ -547,14 +1250,41 @@ export async function startGateway(ctx) {
547
1250
  lastSuccessfulIntentLevel = intentLevelIndex;
548
1251
  const successLevel = INTENT_LEVELS[intentLevelIndex];
549
1252
  log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
1253
+ // P1-2: 保存新的 Session 状态
1254
+ saveSession({
1255
+ sessionId,
1256
+ lastSeq,
1257
+ lastConnectedAt: Date.now(),
1258
+ intentLevelIndex,
1259
+ accountId: account.accountId,
1260
+ savedAt: Date.now(),
1261
+ });
550
1262
  onReady?.(d);
551
1263
  }
552
1264
  else if (t === "RESUMED") {
553
1265
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
1266
+ // P1-2: 更新 Session 连接时间
1267
+ if (sessionId) {
1268
+ saveSession({
1269
+ sessionId,
1270
+ lastSeq,
1271
+ lastConnectedAt: Date.now(),
1272
+ intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
1273
+ accountId: account.accountId,
1274
+ savedAt: Date.now(),
1275
+ });
1276
+ }
554
1277
  }
555
1278
  else if (t === "C2C_MESSAGE_CREATE") {
556
1279
  const event = d;
557
- await handleMessage({
1280
+ // P1-3: 记录已知用户
1281
+ recordKnownUser({
1282
+ openid: event.author.user_openid,
1283
+ type: "c2c",
1284
+ accountId: account.accountId,
1285
+ });
1286
+ // 使用消息队列异步处理,防止阻塞心跳
1287
+ enqueueMessage({
558
1288
  type: "c2c",
559
1289
  senderId: event.author.user_openid,
560
1290
  content: event.content,
@@ -565,7 +1295,14 @@ export async function startGateway(ctx) {
565
1295
  }
566
1296
  else if (t === "AT_MESSAGE_CREATE") {
567
1297
  const event = d;
568
- await handleMessage({
1298
+ // P1-3: 记录已知用户(频道用户)
1299
+ recordKnownUser({
1300
+ openid: event.author.id,
1301
+ type: "c2c", // 频道用户按 c2c 类型存储
1302
+ nickname: event.author.username,
1303
+ accountId: account.accountId,
1304
+ });
1305
+ enqueueMessage({
569
1306
  type: "guild",
570
1307
  senderId: event.author.id,
571
1308
  senderName: event.author.username,
@@ -579,7 +1316,14 @@ export async function startGateway(ctx) {
579
1316
  }
580
1317
  else if (t === "DIRECT_MESSAGE_CREATE") {
581
1318
  const event = d;
582
- await handleMessage({
1319
+ // P1-3: 记录已知用户(频道私信用户)
1320
+ recordKnownUser({
1321
+ openid: event.author.id,
1322
+ type: "c2c",
1323
+ nickname: event.author.username,
1324
+ accountId: account.accountId,
1325
+ });
1326
+ enqueueMessage({
583
1327
  type: "dm",
584
1328
  senderId: event.author.id,
585
1329
  senderName: event.author.username,
@@ -592,7 +1336,14 @@ export async function startGateway(ctx) {
592
1336
  }
593
1337
  else if (t === "GROUP_AT_MESSAGE_CREATE") {
594
1338
  const event = d;
595
- await handleMessage({
1339
+ // P1-3: 记录已知用户(群组用户)
1340
+ recordKnownUser({
1341
+ openid: event.author.member_openid,
1342
+ type: "group",
1343
+ groupOpenid: event.group_openid,
1344
+ accountId: account.accountId,
1345
+ });
1346
+ enqueueMessage({
596
1347
  type: "group",
597
1348
  senderId: event.author.member_openid,
598
1349
  content: event.content,
@@ -618,6 +1369,8 @@ export async function startGateway(ctx) {
618
1369
  if (!canResume) {
619
1370
  sessionId = null;
620
1371
  lastSeq = null;
1372
+ // P1-2: 清除持久化的 Session
1373
+ clearSession(account.accountId);
621
1374
  // 尝试降级到下一个权限级别
622
1375
  if (intentLevelIndex < INTENT_LEVELS.length - 1) {
623
1376
  intentLevelIndex++;