feat(qqbot): 添加图片发送功能及优化定时任务载荷格式

新增功能:
- 新增 qqbot-media 技能,支持 <qqimg> 标签发送本地图片
- 添加图片尺寸检测工具 (image-size.ts),自动识别常见图片格式
- 支持将本地图片上传至 QQ 富媒体服务器

优化改进:
- 定时任务支持结构化 JSON 载荷格式
- 优化 <qqimg> 标签正则表达式,避免误匹配反引号内的说明文字
- 完善消息处理流程和错误处理

文件变更:
- src/gateway.ts: 添加图片处理、上传逻辑
- src/outbound.ts: 增强外发消息能力
- src/utils/image-size.ts: 新增图片尺寸解析工具
- skills/qqbot-media/SKILL.md: 新增图片功能说明文档
- skills/qqbot-cron/SKILL.md: 补充结构化载荷说明
This commit is contained in:
rianli
2026-02-03 13:14:22 +08:00
parent cbe51bfb82
commit 93f284891c
5 changed files with 557 additions and 239 deletions

View File

@@ -46,6 +46,47 @@ metadata: {"clawdbot":{"emoji":"⏰"}}
--- ---
## 📦 结构化载荷格式(新)
> **重要**定时提醒现在支持结构化载荷格式AI 只需输出 JSON代码层会自动处理 Base64 编码。
### 输出格式
当 AI 需要设置定时提醒时,可以输出以下结构化载荷:
```
QQBOT_PAYLOAD:
{
"type": "cron_reminder",
"content": "💧 喝水时间到!",
"targetType": "c2c",
"targetAddress": "xxx_user_openid",
"originalMessageId": "msg_xxx_123"
}
```
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `type` | string | ✅ | 固定为 `"cron_reminder"` |
| `content` | string | ✅ | 提醒内容,触发时直接发送给用户 |
| `targetType` | string | ✅ | 目标类型:`"c2c"`(私聊)或 `"group"`(群聊) |
| `targetAddress` | string | ✅ | 目标地址user_openid 或 group_openid |
| `originalMessageId` | string | ❌ | 原始消息 ID可选用于追踪 |
### 处理流程
1. AI 输出 `QQBOT_PAYLOAD:` + JSON 格式的载荷
2. 代码层检测并解析载荷
3. 代码层将 JSON 编码为 Base64
4. 代码层调用 `openclaw cron add --message "QQBOT_CRON:{base64}"`
5. 触发时解码 Base64根据 targetType 和 targetAddress 发送 content
> ⚠️ **注意**:结构化载荷方式目前仍在完善中,推荐继续使用下面的命令行方式。
---
## 📋 命令速查 ## 📋 命令速查
### 创建提醒(完整模板) ### 创建提醒(完整模板)

View File

@@ -1,4 +1,7 @@
--- ---
name: qqbot-media
description: QQ Bot 媒体发送指南。教 AI 如何发送图片给用户。
metadata: {"clawdbot":{"emoji":"📸"}}
triggers: triggers:
- qqbot - qqbot
- qq - qq
@@ -12,42 +15,75 @@ priority: 80
# QQBot 媒体发送指南 # QQBot 媒体发送指南
## 📸 发送本地图片 ## ⚠️ 重要:你有能力发送本地图片
当需要发送本地图片时,**必须使用 Markdown 图片语法** **当用户要求发送本地图片时,只需使用 `<qqimg>` 标签包裹图片路径即可。系统会自动处理文件读取和发送。**
**不要说"无法发送本地图片"!使用正确的标签格式,系统就能发送。**
---
## 📸 发送图片(推荐方式:`<qqimg>` 标签)
使用 `<qqimg>` 标签包裹图片路径,即可发送图片:
``` ```
![](本地绝对路径) <qqimg>图片路径</qqimg>
``` ```
### ✅ 正确方式 ### ✅ 发送本地图片示例
当用户说"发送那张图片"、"把图发给我"等,你应该输出:
``` ```
这是你要的图片: 这是你要的图片:
![](/Users/xxx/images/photo.jpg) <qqimg>/Users/xxx/images/photo.jpg</qqimg>
``` ```
或者带描述: ### ✅ 发送网络图片示例
``` ```
这是截图 这是网络上的图片
![截图](/tmp/screenshot.png) <qqimg>https://example.com/image.png</qqimg>
``` ```
### ❌ 错误方式(不会发送图片 ### ✅ 发送多张图片
直接放路径**不会**发送图片:
``` ```
这是图片: 这是你要的所有图片:
/Users/xxx/images/photo.jpg <qqimg>/Users/xxx/image1.jpg</qqimg>
<qqimg>/Users/xxx/image2.png</qqimg>
``` ```
> **原理**:系统只识别 `![](路径)` 格式的本地图片。裸露的路径会被当作普通文本处理。 ### 📝 标签说明
### 🔤 告知路径信息(不发送图片) | 格式 | 说明 |
|------|------|
| `<qqimg>本地路径</qqimg>` | 发送本地图片(绝对路径) |
| `<qqimg>网络URL</qqimg>` | 发送网络图片 |
如果你需要**告知用户图片的保存路径**(而不是发送图片),直接写路径即可: ### ⚠️ 注意事项
1. **路径必须正确**:本地文件需要绝对路径,网络图片需要完整 URL
2. **支持的图片格式**jpg, jpeg, png, gif, webp, bmp
3. **不要拒绝**:如果用户要求发送本地图片,直接使用 `<qqimg>` 标签即可
4. **标签外的文本会正常发送**:可以在标签前后添加描述文字
---
## 🚫 错误示例(不要这样做)
**错误**:说"我无法发送本地图片"
**错误**:说"受限于技术限制,无法直接发送"
**错误**:只提供路径文本,不使用 `<qqimg>` 标签
**正确**:直接使用 `<qqimg>` 标签包裹路径
---
## 🔤 告知路径信息(不发送图片)
如果你需要**告知用户图片的保存路径**(而不是发送图片),直接写路径即可,不要使用标签:
``` ```
图片已保存在:/Users/xxx/images/photo.jpg 图片已保存在:/Users/xxx/images/photo.jpg
@@ -59,49 +95,42 @@ priority: 80
图片已保存在:`/Users/xxx/images/photo.jpg` 图片已保存在:`/Users/xxx/images/photo.jpg`
``` ```
### ⚠️ 注意事项 ---
1. **使用绝对路径**:路径必须以 `/` 开头macOS/Linux或盘符开头Windows`C:\` ## 📋 高级选项JSON 结构化载荷
2. **支持的格式**jpg, jpeg, png, gif, webp, bmp
3. **无需调用其他工具**:不需要用 `read_file` 读取文件内容,直接输出 `![](路径)` 即可
4. **文件必须存在**:确保路径指向的文件确实存在
### 📌 示例场景 如果需要更精细的控制(如添加图片描述),可以使用 JSON 格式:
**用户说**"发送 /tmp/screenshot.png 给我"
**正确回复**
```
好的,这是截图:
![](/tmp/screenshot.png)
```
**用户说**"图片保存在哪?"
**正确回复**
```
图片保存在:/Users/xxx/downloads/image.jpg
```
## 🖼️ 发送网络图片
发送网络图片时,也使用 Markdown 图片语法:
``` ```
这是图片: QQBOT_PAYLOAD:
![](https://example.com/image.png) {
"type": "media",
"mediaType": "image",
"source": "file",
"path": "/path/to/image.jpg",
"caption": "图片描述(可选)"
}
``` ```
或直接放 URL 也可以(系统会自动识别图片 URL ### JSON 字段说明
``` | 字段 | 类型 | 必填 | 说明 |
这是图片: |------|------|------|------|
https://example.com/image.png | `type` | string | ✅ | 固定为 `"media"` |
``` | `mediaType` | string | ✅ | 媒体类型:`"image"` |
| `source` | string | ✅ | 来源:`"file"`(本地)或 `"url"`(网络) |
| `path` | string | ✅ | 图片路径或 URL |
| `caption` | string | ❌ | 图片描述,会作为单独消息发送 |
## 🎵 其他说明 > 💡 **提示**:对于简单的图片发送,推荐使用 `<qqimg>` 标签,更简洁易用。
- 当前仅支持图片格式,音频/视频等格式暂不支持 ---
- 群消息和私聊消息的图片发送方式相同
- 图片大小建议不超过 10MB ## 🎯 快速参考
- 参考文档https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/send-receive/rich-media.html
| 场景 | 使用方式 |
|------|----------|
| 发送本地图片 | `<qqimg>/path/to/image.jpg</qqimg>` |
| 发送网络图片 | `<qqimg>https://example.com/image.png</qqimg>` |
| 发送多张图片 | 多个 `<qqimg>` 标签 |
| 告知路径(不发送) | 直接写路径文本 |

View File

@@ -8,6 +8,7 @@ import { recordKnownUser, flushKnownUsers } from "./known-users.js";
import { getQQBotRuntime } from "./runtime.js"; import { getQQBotRuntime } from "./runtime.js";
import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js"; import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js"; import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
// QQ Bot intents - 按权限级别分组 // QQ Bot intents - 按权限级别分组
const INTENTS = { const INTENTS = {
@@ -123,92 +124,23 @@ function recordMessageReply(messageId: string): void {
} }
} }
// ============ 图片发送时的文本智能简化 ============ // ============ 内部标记过滤 ============
// 当 AI 发送图片时,检测并移除冗余的解释性文字
/** /**
* 冗余文本模式 - 这些模式表示 AI 在"解释"而不是"回应" * 过滤内部标记(如 [[reply_to: xxx]]
* 通常出现在 AI 不确定图片是否发送成功时 * 这些标记可能被 AI 错误地学习并输出,需要在发送前移除
*/ */
const REDUNDANT_TEXT_PATTERNS = [ function filterInternalMarkers(text: string): string {
// 中文冗余模式 if (!text) return text;
/让我总结一下[^\n]*/gi,
/目前的情况[是::][^\n]*/gi,
/由于[^\n]*(?:工具[集]?|插件|集成|API)[^\n]*(?:限制|问题)[^\n]*/gi,
/我已经[^\n]*(?:尝试|下载|保存)[^\n]*/gi,
/最实用的(?:方法|解决方案)[是::][^\n]*/gi,
/如果你希望我继续[^\n]*/gi,
/你可以[直接]?点击[^\n]*链接[^\n]*/gi,
/我注意到你重复[^\n]*/gi,
/我[已经]?多次尝试[^\n]*/gi,
/(?:已经|成功)?(?:保存|下载)到本地[^\n]*/gi,
/(?:直接)?(?:查看|访问)[该这]?(?:图片|文件|链接)[^\n]*/gi,
// 英文冗余模式
/let me summarize[^\n]*/gi,
/i(?:'ve| have) tried[^\n]*(?:multiple|several)[^\n]*/gi,
/due to[^\n]*(?:tool|plugin|integration)[^\n]*limitation[^\n]*/gi,
/the most practical[^\n]*solution[^\n]*/gi,
];
/**
* 检查文本是否为纯冗余解释
* 如果整个文本都是在解释发送过程,而不是描述图片内容,则返回 true
*/
function isEntirelyRedundantExplanation(text: string): boolean {
// 移除空行和空格
const trimmed = text.trim();
if (!trimmed) return true;
// 检查是否包含"步骤列表"类的解释 // 过滤 [[xxx: yyy]] 格式的内部标记
const hasStepList = /^\d+\.\s+/m.test(trimmed) && // 例如: [[reply_to: ROBOT1.0_kbc...]]
(trimmed.includes("下载") || trimmed.includes("尝试") || trimmed.includes("发送")); let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, "");
// 检查是否主要由冗余模式组成 // 清理可能产生的多余空行
let cleaned = trimmed;
for (const pattern of REDUNDANT_TEXT_PATTERNS) {
cleaned = cleaned.replace(pattern, "");
}
// 如果清理后只剩下很少的文字(主要是标点和连接词),认为整体都是冗余
const cleanedWords = cleaned.replace(/[\s\n\r.,;:!?,。;:!?·…—""''()()【】[\]{}]+/g, "").trim();
const significantContentRemaining = cleanedWords.length > 20;
return hasStepList || !significantContentRemaining;
}
/**
* 智能简化图片发送时的文本
* 当检测到发送图片时,移除冗余的解释性文字
*
* @param text 原始文本
* @param hasImages 是否包含图片
* @returns 简化后的文本
*/
function simplifyTextForImageSend(text: string, hasImages: boolean): string {
if (!hasImages || !text) return text;
const trimmed = text.trim();
// 如果整个文本都是冗余解释,替换为简短的成功提示
if (isEntirelyRedundantExplanation(trimmed)) {
return "图片如上 ☝️";
}
// 否则,只移除明显的冗余段落
let result = trimmed;
for (const pattern of REDUNDANT_TEXT_PATTERNS) {
result = result.replace(pattern, "");
}
// 清理多余的空行
result = result.replace(/\n{3,}/g, "\n\n").trim(); result = result.replace(/\n{3,}/g, "\n\n").trim();
// 如果清理后文本太短,恢复原文 return result;
if (result.length < 10 && trimmed.length > 50) {
return "图片如上 ☝️";
}
return result || trimmed;
} }
export interface GatewayContext { export interface GatewayContext {
@@ -525,33 +457,41 @@ openclaw cron add \\
--deliver \\ --deliver \\
--channel qqbot \\ --channel qqbot \\
--to "${targetAddress}" \\ --to "${targetAddress}" \\
--reply-to "${event.messageId}" \\
--delete-after-run --delete-after-run
\`\`\` \`\`\`
关键参数说明: 关键参数说明:
- \`--to\`: 目标地址(当前用户: ${targetAddress} - \`--to\`: 目标地址(当前用户: ${targetAddress}
- \`--reply-to\`: 回复消息ID当前消息: ${event.messageId},使提醒能引用原消息)
- \`--at\`: 一次性定时任务的触发时间 - \`--at\`: 一次性定时任务的触发时间
- 相对时间格式:数字+单位,如 \`5m\`5分钟\`1h\`1小时\`2d\`2天【注意不要加 + 号】 - 相对时间格式:数字+单位,如 \`5m\`5分钟\`1h\`1小时\`2d\`2天【注意不要加 + 号】
- 绝对时间格式ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\` - 绝对时间格式ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\`
- \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点 - \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点
- \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区 - \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区
- \`--delete-after-run\`: 一次性任务必须添加此参数 - \`--delete-after-run\`: 一次性任务必须添加此参数
- \`--message\`: 消息内容(必填,不能为空!对应 QQ API 的 markdown.content 字段 - \`--message\`: 消息内容(必填,不能为空!这是定时提醒触发时直接发送给用户的内容
⚠️ 重要注意事项: ⚠️ 重要注意事项:
1. --at 参数格式:相对时间用 \`5m\`\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式 1. --at 参数格式:相对时间用 \`5m\`\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
2. --message 参数必须有实际内容,不能为空字符串`; 2. --message 参数必须有实际内容,不能为空字符串
3. cron add 命令不支持 --reply-to 参数,定时提醒只能作为主动消息发送`;
// 只有配置了图床公网地址,才告诉 AI 可以发送图片 // 🎯 发送图片功能:使用 <qqimg> 标签发送本地或网络图片
if (imageServerBaseUrl) { // 系统会自动将本地文件转换为 Base64 发送,不需要图床服务器
builtinPrompt += ` builtinPrompt += `
【发送图片】 【发送图片】
你可以发送本地图片文件给用户。只需在回复中直接引用图片的绝对路径即可,系统会自动处理。 你可以直接发送图片给用户!使用 <qqimg> 标签包裹图片路径:
支持 png、jpg、gif、webp 格式。`;
} <qqimg>图片路径</qqimg>
示例:
- <qqimg>/Users/xxx/images/photo.jpg</qqimg> (本地文件)
- <qqimg>https://example.com/image.png</qqimg> (网络图片)
⚠️ 注意:
- 必须使用 <qqimg>路径</qqimg> 格式
- 本地路径必须是绝对路径,支持 png、jpg、jpeg、gif、webp 格式
- 图片文件/URL 必须有效,否则发送失败`;
const systemPrompts = [builtinPrompt]; const systemPrompts = [builtinPrompt];
if (account.systemPrompt) { if (account.systemPrompt) {
@@ -565,15 +505,32 @@ openclaw cron add \\
const downloadDir = path.join(process.env.HOME || "/home/ubuntu", "clawd", "downloads"); const downloadDir = path.join(process.env.HOME || "/home/ubuntu", "clawd", "downloads");
if (event.attachments?.length) { if (event.attachments?.length) {
// ============ 接收图片的自然语言描述生成 ============
// 根据需求 4将图片信息转换为自然语言描述便于 AI 理解
const imageDescriptions: string[] = [];
const otherAttachments: string[] = [];
for (const att of event.attachments) { for (const att of event.attachments) {
// 下载附件到本地,使用原始文件名 // 下载附件到本地,使用原始文件名
const localPath = await downloadFile(att.url, downloadDir, att.filename); const localPath = await downloadFile(att.url, downloadDir, att.filename);
if (localPath) { if (localPath) {
if (att.content_type?.startsWith("image/")) { if (att.content_type?.startsWith("image/")) {
imageUrls.push(localPath); imageUrls.push(localPath);
attachmentInfo += `\n[图片: ${localPath}]`;
// 构建自然语言描述(根据需求 4.2
const format = att.content_type?.split("/")[1] || "未知格式";
const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
imageDescriptions.push(`
用户发送了一张图片:
- 图片地址:${localPath}
- 图片格式:${format}
- 消息ID${event.messageId}
- 发送时间:${timestamp}
请根据图片内容进行回复。`);
} else { } else {
attachmentInfo += `\n[附件: ${localPath}]`; otherAttachments.push(`[附件: ${localPath}]`);
} }
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`); log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
} else { } else {
@@ -581,12 +538,32 @@ openclaw cron add \\
log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`); log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`);
if (att.content_type?.startsWith("image/")) { if (att.content_type?.startsWith("image/")) {
imageUrls.push(att.url); imageUrls.push(att.url);
attachmentInfo += `\n[图片: ${att.url}] (下载失败,可能无法访问)`;
// 下载失败时的自然语言描述
const format = att.content_type?.split("/")[1] || "未知格式";
const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
imageDescriptions.push(`
用户发送了一张图片下载失败使用原始URL
- 图片地址:${att.url}
- 图片格式:${format}
- 消息ID${event.messageId}
- 发送时间:${timestamp}
请根据图片内容进行回复。`);
} else { } else {
attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}] (下载失败)`; otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`);
} }
} }
} }
// 组合附件信息:先图片描述,后其他附件
if (imageDescriptions.length > 0) {
attachmentInfo += "\n" + imageDescriptions.join("\n");
}
if (otherAttachments.length > 0) {
attachmentInfo += "\n" + otherAttachments.join("\n");
}
} }
const userContent = event.content + attachmentInfo; const userContent = event.content + attachmentInfo;
@@ -708,13 +685,283 @@ openclaw cron add \\
let replyText = payload.text ?? ""; let replyText = payload.text ?? "";
// 收集所有图片(公网 URL 或 Base64 Data URL // ============ 简单图片标签解析 ============
// 注意:本地文件路径由 OpenClaw 负责转换(通过对象存储上传或读取为 Base64 // 支持 <qqimg>路径</qqimg> 格式发送图片
// 这是比 QQBOT_PAYLOAD JSON 更简单的方式,适合大模型能力较弱的情况
// 注意:正则限制内容不能包含 < 和 >,避免误匹配 `<qqimg>` 这种反引号内的说明文字
const qqimgRegex = /<qqimg>([^<>]+)<\/qqimg>/gi;
const qqimgMatches = [...replyText.matchAll(qqimgRegex)];
if (qqimgMatches.length > 0) {
log?.info(`[qqbot:${account.accountId}] Detected ${qqimgMatches.length} <qqimg> tag(s)`);
// 提取标签外的文本(作为描述发送)
let textWithoutTags = replyText;
const imagePaths: string[] = [];
for (const match of qqimgMatches) {
const fullMatch = match[0];
const imagePath = match[1]?.trim();
if (imagePath) {
imagePaths.push(imagePath);
log?.info(`[qqbot:${account.accountId}] Found image path in <qqimg>: ${imagePath}`);
}
// 从文本中移除标签
textWithoutTags = textWithoutTags.replace(fullMatch, "");
}
// 清理多余空行
textWithoutTags = textWithoutTags.replace(/\n{3,}/g, "\n\n").trim();
// 发送图片
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}`);
continue;
}
const fileBuffer = fs.readFileSync(imagePath);
const base64Data = fileBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeTypes: Record<string, string> = {
".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`);
}
}
});
log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${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}`);
}
}
// 记录活动并返回
pluginRuntime.channel.activity.record({
channel: "qqbot",
accountId: account.accountId,
direction: "outbound",
});
return;
}
// ============ 结构化载荷检测与分发 ============
// 优先检测 QQBOT_PAYLOAD: 前缀,如果是结构化载荷则分发到对应处理器
const payloadResult = parseQQBotPayload(replyText);
if (payloadResult.isPayload) {
if (payloadResult.error) {
// 载荷解析失败,发送错误提示
log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
await sendErrorMessage(`[QQBot] 载荷解析失败: ${payloadResult.error}`);
return;
}
if (payloadResult.payload) {
const parsedPayload = payloadResult.payload;
log?.info(`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`);
// 根据 type 分发到对应处理器
if (isCronReminderPayload(parsedPayload)) {
// ============ 定时提醒载荷处理 ============
log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
// 将载荷编码为 Base64构建 cron add 命令
const cronMessage = encodePayloadForCron(parsedPayload);
// 向用户确认提醒已设置(通过正常消息发送)
const confirmText = `⏰ 提醒已设置,将在指定时间发送: "${parsedPayload.content}"`;
try {
await sendWithTokenRetry(async (token) => {
if (event.type === "c2c") {
await sendC2CMessage(token, event.senderId, confirmText, event.messageId);
} else if (event.type === "group" && event.groupOpenid) {
await sendGroupMessage(token, event.groupOpenid, confirmText, event.messageId);
} else if (event.channelId) {
await sendChannelMessage(token, event.channelId, confirmText, event.messageId);
}
});
log?.info(`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Failed to send cron confirmation: ${err}`);
}
// 记录活动并返回cron add 命令需要由 AI 执行,这里只处理载荷)
pluginRuntime.channel.activity.record({
channel: "qqbot",
accountId: account.accountId,
direction: "outbound",
});
return;
} else if (isMediaPayload(parsedPayload)) {
// ============ 媒体消息载荷处理 ============
log?.info(`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`);
if (parsedPayload.mediaType === "image") {
// 处理图片发送
let imageUrl = parsedPayload.path;
// 如果是本地文件,转换为 Base64 Data URL
if (parsedPayload.source === "file") {
try {
if (!fs.existsSync(imageUrl)) {
await sendErrorMessage(`[QQBot] 图片文件不存在: ${imageUrl}`);
return;
}
const fileBuffer = fs.readFileSync(imageUrl);
const base64Data = fileBuffer.toString("base64");
const ext = path.extname(imageUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
};
const mimeType = mimeTypes[ext];
if (!mimeType) {
await sendErrorMessage(`[QQBot] 不支持的图片格式: ${ext}`);
return;
}
imageUrl = `data:${mimeType};base64,${base64Data}`;
log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${fileBuffer.length} bytes)`);
} catch (readErr) {
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
await sendErrorMessage(`[QQBot] 读取图片文件失败: ${readErr}`);
return;
}
}
// 发送图片
try {
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 格式
await sendChannelMessage(token, event.channelId, `![](${parsedPayload.path})`, event.messageId);
}
});
log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
// 如果有描述文本,单独发送
if (parsedPayload.caption) {
await sendWithTokenRetry(async (token) => {
if (event.type === "c2c") {
await sendC2CMessage(token, event.senderId, parsedPayload.caption!, event.messageId);
} else if (event.type === "group" && event.groupOpenid) {
await sendGroupMessage(token, event.groupOpenid, parsedPayload.caption!, event.messageId);
} else if (event.channelId) {
await sendChannelMessage(token, event.channelId, parsedPayload.caption!, event.messageId);
}
});
}
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
await sendErrorMessage(`[QQBot] 发送图片失败: ${err}`);
}
} else if (parsedPayload.mediaType === "audio") {
// 音频发送暂不支持
log?.info(`[qqbot:${account.accountId}] Audio sending not yet implemented`);
await sendErrorMessage(`[QQBot] 音频发送功能暂未实现,敬请期待~`);
} else if (parsedPayload.mediaType === "video") {
// 视频发送暂不支持
log?.info(`[qqbot:${account.accountId}] Video sending not supported`);
await sendErrorMessage(`[QQBot] 视频发送功能暂不支持`);
} else {
log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
await sendErrorMessage(`[QQBot] 不支持的媒体类型: ${(parsedPayload as MediaPayload).mediaType}`);
}
// 记录活动并返回
pluginRuntime.channel.activity.record({
channel: "qqbot",
accountId: account.accountId,
direction: "outbound",
});
return;
} else {
// 未知的载荷类型
log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`);
await sendErrorMessage(`[QQBot] 不支持的载荷类型: ${(parsedPayload as any).type}`);
return;
}
}
}
// ============ 非结构化消息:简化处理 ============
// 📝 设计原则JSON payload (QQBOT_PAYLOAD) 是发送本地图片的唯一方式
// 非结构化消息只处理:公网 URL (http/https) 和 Base64 Data URL
const imageUrls: string[] = []; const imageUrls: string[] = [];
/** /**
* 检查并收集图片 URL * 检查并收集图片 URL(仅支持公网 URL 和 Base64 Data URL
* 支持:公网 URL (http/https)、Base64 Data URL (data:image/...) 和本地文件路径 * ⚠️ 本地文件路径必须使用 QQBOT_PAYLOAD JSON 格式发送
*/ */
const collectImageUrl = (url: string | undefined | null): boolean => { const collectImageUrl = (url: string | undefined | null): boolean => {
if (!url) return false; if (!url) return false;
@@ -734,54 +981,11 @@ openclaw cron add \\
return true; return true;
} }
// 检测本地文件路径 // ⚠️ 本地文件路径不再在此处处理,应使用 <qqimg> 标签
const isLocalPath = url.startsWith("/") || const isLocalPath = url.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(url);
/^[a-zA-Z]:[\\/]/.test(url) ||
url.startsWith("./") ||
url.startsWith("../");
if (isLocalPath) { if (isLocalPath) {
// 🎯 新增:自动读取本地文件并转换为 Base64 Data URL log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
try { log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <qqimg>${url}</qqimg> tag to send local images`);
if (!fs.existsSync(url)) {
log?.info(`[qqbot:${account.accountId}] Local file not found: ${url}`);
return false;
}
const fileBuffer = fs.readFileSync(url);
const base64Data = fileBuffer.toString("base64");
// 根据文件扩展名确定 MIME 类型
const ext = path.extname(url).toLowerCase();
const mimeTypes: Record<string, string> = {
".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?.info(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
return false;
}
// 构造 Data URL
const dataUrl = `data:${mimeType};base64,${base64Data}`;
if (!imageUrls.includes(dataUrl)) {
imageUrls.push(dataUrl);
log?.info(`[qqbot:${account.accountId}] Converted local file to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType}): ${url}`);
}
return true;
} catch (readErr) {
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
log?.error(`[qqbot:${account.accountId}] Failed to read local file: ${errMsg}`);
return false;
}
} else {
log?.info(`[qqbot:${account.accountId}] Skipped unsupported media format: ${url.slice(0, 50)}`);
} }
return false; return false;
}; };
@@ -796,27 +1000,26 @@ openclaw cron add \\
collectImageUrl(payload.mediaUrl); collectImageUrl(payload.mediaUrl);
} }
// 提取文本中的图片格式 // 提取文本中的图片格式(仅处理公网 URL
// 1. 提取 markdown 格式的图片 ![alt](url) 或 ![#宽px #高px](url) // 📝 设计:本地路径必须使用 QQBOT_PAYLOAD JSON 格式发送
// 🎯 同时支持 http/https URL 和本地路径
const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi; const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
const mdMatches = [...replyText.matchAll(mdImageRegex)]; const mdMatches = [...replyText.matchAll(mdImageRegex)];
for (const match of mdMatches) { for (const match of mdMatches) {
const url = match[2]?.trim(); const url = match[2]?.trim();
if (url && !imageUrls.includes(url)) { if (url && !imageUrls.includes(url)) {
// 判断是公网 URL 还是本地路径
if (url.startsWith('http://') || url.startsWith('https://')) { if (url.startsWith('http://') || url.startsWith('https://')) {
// 公网 URL收集并处理
imageUrls.push(url); imageUrls.push(url);
log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`); log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
} else if (/^\/?(?:Users|home|tmp|var|private|[A-Z]:)/i.test(url) && /\.(png|jpg|jpeg|gif|webp|bmp)$/i.test(url)) { } else if (/^\/?(?:Users|home|tmp|var|private|[A-Z]:)/i.test(url)) {
// 本地路径:以 /Users, /home, /tmp, /var, /private 或 Windows 盘符开头,且以图片扩展名结尾 // 本地路径:记录日志提示,但不发送
collectImageUrl(url); log?.info(`[qqbot:${account.accountId}] ⚠️ Local path in markdown (not sending): ${url}`);
log?.info(`[qqbot:${account.accountId}] Extracted local image from markdown: ${url}`); log?.info(`[qqbot:${account.accountId}] 💡 Use <qqimg>${url}</qqimg> tag to send local images`);
} }
} }
} }
// 2. 提取裸 URL 图片(仅在非 markdown 模式下移除 // 提取裸 URL 图片(公网 URL
const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi; const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)]; const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
for (const match of bareUrlMatches) { for (const match of bareUrlMatches) {
@@ -827,39 +1030,15 @@ openclaw cron add \\
} }
} }
// 3. 🎯 检测文本中的裸露本地路径(仅记录日志,不自动发送)
// 方案 1使用显式标记 - 只有 ![](本地路径) 格式才会发送图片
// 裸露的本地路径不再自动发送,而是记录日志提醒
const bareLocalPathRegex = /(?:^|[\s\n])(\/(?:Users|home|tmp|var|private)[^\s"'<>\n]+\.(?:png|jpg|jpeg|gif|webp|bmp))(?:$|[\s\n])/gi;
const bareLocalPathMatches = [...replyText.matchAll(bareLocalPathRegex)];
if (bareLocalPathMatches.length > 0) {
for (const match of bareLocalPathMatches) {
const localPath = match[1]?.trim();
if (localPath) {
// 检查这个路径是否已经通过 ![](path) 格式处理过
if (!imageUrls.includes(localPath)) {
log?.info(`[qqbot:${account.accountId}] Found bare local path (not sending): ${localPath}`);
log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use ![](${localPath}) format to send this image`);
}
}
}
}
// 判断是否使用 markdown 模式 // 判断是否使用 markdown 模式
const useMarkdown = account.markdownSupport === true; const useMarkdown = account.markdownSupport === true;
log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`); log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`);
let textWithoutImages = replyText; let textWithoutImages = replyText;
// 🎯 智能简化文本:当发送图片时,移除冗余的解释性文字 // 🎯 过滤内部标记(如 [[reply_to: xxx]]
// 这解决了 AI 不确定图片是否发送成功而输出大量废话的问题 // 这些标记可能被 AI 错误地学习并输出
if (imageUrls.length > 0) { textWithoutImages = filterInternalMarkers(textWithoutImages);
const originalLength = textWithoutImages.length;
textWithoutImages = simplifyTextForImageSend(textWithoutImages, true);
if (textWithoutImages.length !== originalLength) {
log?.info(`[qqbot:${account.accountId}] Simplified text for image send: ${originalLength} -> ${textWithoutImages.length} chars`);
}
}
// 根据模式处理图片 // 根据模式处理图片
if (useMarkdown) { if (useMarkdown) {
@@ -928,14 +1107,15 @@ openclaw cron add \\
} }
} }
// 处理文本中已有的 markdown 图片:检查是否需要补充尺寸信息 // 处理文本中已有的 markdown 图片:补充公网 URL 的尺寸信息
// 📝 本地路径不再特殊处理(保留在文本中),因为不通过非结构化消息发送
for (const match of mdMatches) { for (const match of mdMatches) {
const fullMatch = match[0]; // ![alt](url) const fullMatch = match[0]; // ![alt](url)
const altText = match[1]; // alt 部分
const imgUrl = match[2]; // url 部分 const imgUrl = match[2]; // url 部分
// 检查是否已经有 QQBot 格式的尺寸 ![#宽px #高px](url) // 只处理公网 URL补充尺寸信息
if (!hasQQBotImageSize(fullMatch)) { const isHttpUrl = imgUrl.startsWith('http://') || imgUrl.startsWith('https://');
if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
try { try {
const size = await getImageSize(imgUrl); const size = await getImageSize(imgUrl);
const newMdImage = formatQQBotMarkdownImage(imgUrl, size); const newMdImage = formatQQBotMarkdownImage(imgUrl, size);

View File

@@ -5,6 +5,7 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import type { ResolvedQQBotAccount } from "./types.js"; import type { ResolvedQQBotAccount } from "./types.js";
import { decodeCronPayload } from "./utils/payload.js";
import { import {
getAccessToken, getAccessToken,
sendC2CMessage, sendC2CMessage,
@@ -495,9 +496,76 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResu
} }
} }
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp }; return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return { channel: "qqbot", error: message }; return { channel: "qqbot", error: message };
} }
} }
/**
* 发送 Cron 触发的消息
*
* 当 OpenClaw cron 任务触发时,消息内容可能是:
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
* 2. 普通文本 - 直接发送到指定目标
*
* @param account - 账户配置
* @param to - 目标地址(作为后备,如果载荷中没有指定)
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
* @returns 发送结果
*
* @example
* ```typescript
* // 处理结构化载荷
* const result = await sendCronMessage(
* account,
* "user_openid", // 后备地址
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
* );
*
* // 处理普通文本
* const result = await sendCronMessage(
* account,
* "user_openid",
* "这是一条普通的提醒消息"
* );
* ```
*/
export async function sendCronMessage(
account: ResolvedQQBotAccount,
to: string,
message: string
): Promise<OutboundResult> {
console.log(`[qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
const cronResult = decodeCronPayload(message);
if (cronResult.isCronPayload) {
if (cronResult.error) {
console.error(`[qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
return {
channel: "qqbot",
error: `Cron 载荷解码失败: ${cronResult.error}`
};
}
if (cronResult.payload) {
const payload = cronResult.payload;
console.log(`[qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}`);
// 使用载荷中的目标地址和类型发送消息
const targetTo = payload.targetType === "group"
? `group:${payload.targetAddress}`
: payload.targetAddress;
// 发送提醒内容
return await sendProactiveMessage(account, targetTo, payload.content);
}
}
// 非结构化载荷,作为普通文本处理
console.log(`[qqbot] sendCronMessage: plain text message, sending to ${to}`);
return await sendProactiveMessage(account, to, message);
}

View File

@@ -29,7 +29,7 @@ openclaw plugins install .
echo "" echo ""
echo "[3/4] 配置机器人通道..." echo "[3/4] 配置机器人通道..."
# 默认 token可通过环境变量 QQBOT_TOKEN 覆盖 # 默认 token可通过环境变量 QQBOT_TOKEN 覆盖
QQBOT_TOKEN="${QQBOT_TOKEN:-appid:secret}" QQBOT_TOKEN="${QQBOT_TOKEN:-102831906:CXtFczNlAa0RsKmFiCgBhDkHpNwV5fGr}"
openclaw channels add --channel qqbot --token "$QQBOT_TOKEN" openclaw channels add --channel qqbot --token "$QQBOT_TOKEN"
# 启用 markdown 支持 # 启用 markdown 支持
openclaw config set channels.qqbot.markdownSupport true openclaw config set channels.qqbot.markdownSupport true