From f4a72ba0cbfc015e1bee09c6ecad4977ad971e7d Mon Sep 17 00:00:00 2001 From: rianli Date: Tue, 3 Feb 2026 13:33:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(qqbot):=20=E6=B7=BB=E5=8A=A0=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD=E5=8F=8A=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=BD=BD=E8=8D=B7?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 新增 qqbot-media 技能,支持 标签发送本地图片 - 添加图片尺寸检测工具 (image-size.ts),自动识别常见图片格式 - 支持将本地图片上传至 QQ 富媒体服务器 优化改进: - 定时任务支持结构化 JSON 载荷格式 - 优化 标签正则表达式,避免误匹配反引号内的说明文字 - 完善消息处理流程和错误处理 文件变更: - src/gateway.ts: 添加图片处理、上传逻辑 - src/outbound.ts: 增强外发消息能力 - src/utils/image-size.ts: 新增图片尺寸解析工具 - skills/qqbot-media/SKILL.md: 新增图片功能说明文档 - skills/qqbot-cron/SKILL.md: 补充结构化载荷说明 --- src/gateway.ts | 189 ++++++++++++++++++++++++++----------------------- 1 file changed, 102 insertions(+), 87 deletions(-) diff --git a/src/gateway.ts b/src/gateway.ts index be98012..3d276bb 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -695,106 +695,121 @@ openclaw cron add \\ if (qqimgMatches.length > 0) { log?.info(`[qqbot:${account.accountId}] Detected ${qqimgMatches.length} tag(s)`); - // 提取标签外的文本(作为描述发送) - let textWithoutTags = replyText; - const imagePaths: string[] = []; + // 构建发送队列:根据内容在原文中的实际位置顺序发送 + // type: 'text' | 'image', content: 文本内容或图片路径 + const sendQueue: Array<{ type: "text" | "image"; content: string }> = []; - for (const match of qqimgMatches) { - const fullMatch = match[0]; - const imagePath = match[1]?.trim(); + let lastIndex = 0; + // 使用新的正则来获取带索引的匹配结果 + const qqimgRegexWithIndex = /([^<>]+)<\/qqimg>/gi; + let match; + + while ((match = qqimgRegexWithIndex.exec(replyText)) !== null) { + // 添加标签前的文本 + const textBefore = replyText.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim(); + if (textBefore) { + sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) }); + } + // 添加图片 + const imagePath = match[1]?.trim(); if (imagePath) { - imagePaths.push(imagePath); + sendQueue.push({ type: "image", content: imagePath }); log?.info(`[qqbot:${account.accountId}] Found image path in : ${imagePath}`); } - // 从文本中移除标签 - textWithoutTags = textWithoutTags.replace(fullMatch, ""); + lastIndex = match.index + match[0].length; } - // 清理多余空行 - textWithoutTags = textWithoutTags.replace(/\n{3,}/g, "\n\n").trim(); + // 添加最后一个标签后的文本 + const textAfter = replyText.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim(); + if (textAfter) { + sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) }); + } - // 发送图片 - for (const imagePath of imagePaths) { - try { - let imageUrl = imagePath; - - // 判断是本地文件还是 URL - const isLocalPath = imagePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(imagePath); - const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://"); - - if (isLocalPath) { - // 本地文件:转换为 Base64 Data URL - if (!fs.existsSync(imagePath)) { - log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`); - await sendErrorMessage(`图片文件不存在: ${imagePath}`); + log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`); + + // 按顺序发送 + for (const item of sendQueue) { + if (item.type === "text") { + // 发送文本 + try { + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CMessage(token, event.senderId, item.content, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId); + } else if (event.channelId) { + await sendChannelMessage(token, event.channelId, item.content, event.messageId); + } + }); + log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`); + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`); + } + } else if (item.type === "image") { + // 发送图片 + const imagePath = item.content; + try { + let imageUrl = imagePath; + + // 判断是本地文件还是 URL + const isLocalPath = imagePath.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(imagePath); + const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://"); + + if (isLocalPath) { + // 本地文件:转换为 Base64 Data URL + if (!fs.existsSync(imagePath)) { + log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`); + await sendErrorMessage(`图片文件不存在: ${imagePath}`); + continue; + } + + const fileBuffer = fs.readFileSync(imagePath); + const base64Data = fileBuffer.toString("base64"); + const ext = path.extname(imagePath).toLowerCase(); + const mimeTypes: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + }; + const mimeType = mimeTypes[ext]; + if (!mimeType) { + log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`); + await sendErrorMessage(`不支持的图片格式: ${ext}`); + continue; + } + imageUrl = `data:${mimeType};base64,${base64Data}`; + log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`); + } else if (!isHttpUrl) { + log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`); continue; } - const fileBuffer = fs.readFileSync(imagePath); - const base64Data = fileBuffer.toString("base64"); - const ext = path.extname(imagePath).toLowerCase(); - const mimeTypes: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - }; - const mimeType = mimeTypes[ext]; - if (!mimeType) { - log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`); - await sendErrorMessage(`不支持的图片格式: ${ext}`); - continue; - } - imageUrl = `data:${mimeType};base64,${base64Data}`; - log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`); - } else if (!isHttpUrl) { - log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`); - continue; - } - - // 发送图片 - await sendWithTokenRetry(async (token) => { - if (event.type === "c2c") { - await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); - } else if (event.type === "group" && event.groupOpenid) { - await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); - } else if (event.channelId) { - // 频道使用 Markdown 格式(如果是公网 URL) - if (isHttpUrl) { - await sendChannelMessage(token, event.channelId, `![](${imagePath})`, event.messageId); - } else { - // 频道不支持富媒体 Base64 - log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`); + // 发送图片 + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId); + } else if (event.channelId) { + // 频道使用 Markdown 格式(如果是公网 URL) + if (isHttpUrl) { + await sendChannelMessage(token, event.channelId, `![](${imagePath})`, event.messageId); + } else { + // 频道不支持富媒体 Base64 + log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`); + } } - } - }); - log?.info(`[qqbot:${account.accountId}] Sent image via tag: ${imagePath.slice(0, 60)}...`); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to send image from : ${err}`); - await sendErrorMessage(`发送图片失败: ${err}`); - } - } - - // 发送剩余的文本(如果有) - if (textWithoutTags) { - textWithoutTags = filterInternalMarkers(textWithoutTags); - try { - await sendWithTokenRetry(async (token) => { - if (event.type === "c2c") { - await sendC2CMessage(token, event.senderId, textWithoutTags, event.messageId); - } else if (event.type === "group" && event.groupOpenid) { - await sendGroupMessage(token, event.groupOpenid, textWithoutTags, event.messageId); - } else if (event.channelId) { - await sendChannelMessage(token, event.channelId, textWithoutTags, event.messageId); - } - }); - log?.info(`[qqbot:${account.accountId}] Sent caption text: ${textWithoutTags.slice(0, 50)}...`); - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Failed to send caption text: ${err}`); + }); + log?.info(`[qqbot:${account.accountId}] Sent image via tag: ${imagePath.slice(0, 60)}...`); + } catch (err) { + log?.error(`[qqbot:${account.accountId}] Failed to send image from : ${err}`); + await sendErrorMessage(`发送图片失败: ${err}`); + } } }