diff --git a/openclaw.plugin.json b/openclaw.plugin.json index d755651..478c872 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -3,7 +3,7 @@ "name": "QQ Bot Channel", "description": "QQ Bot channel plugin with message support, cron jobs, and proactive messaging", "channels": ["qqbot"], - "skills": ["skills/qqbot-cron"], + "skills": ["skills/qqbot-cron", "skills/qqbot-media"], "capabilities": { "proactiveMessaging": true, "cronJobs": true, diff --git a/skills/qqbot-media/SKILL.md b/skills/qqbot-media/SKILL.md new file mode 100644 index 0000000..40c4b98 --- /dev/null +++ b/skills/qqbot-media/SKILL.md @@ -0,0 +1,107 @@ +--- +triggers: + - qqbot + - qq + - 发送图片 + - 发送文件 + - 图片 + - 本地文件 + - 本地图片 +priority: 80 +--- + +# QQBot 媒体发送指南 + +## 📸 发送本地图片 + +当需要发送本地图片时,**必须使用 Markdown 图片语法**: + +``` +![](本地绝对路径) +``` + +### ✅ 正确方式 + +``` +这是你要的图片: +![](/Users/xxx/images/photo.jpg) +``` + +或者带描述: + +``` +这是截图: +![截图](/tmp/screenshot.png) +``` + +### ❌ 错误方式(不会发送图片) + +直接放路径**不会**发送图片: + +``` +这是图片: +/Users/xxx/images/photo.jpg +``` + +> **原理**:系统只识别 `![](路径)` 格式的本地图片。裸露的路径会被当作普通文本处理。 + +### 🔤 告知路径信息(不发送图片) + +如果你需要**告知用户图片的保存路径**(而不是发送图片),直接写路径即可: + +``` +图片已保存在:/Users/xxx/images/photo.jpg +``` + +或用反引号强调: + +``` +图片已保存在:`/Users/xxx/images/photo.jpg` +``` + +### ⚠️ 注意事项 + +1. **使用绝对路径**:路径必须以 `/` 开头(macOS/Linux)或盘符开头(Windows,如 `C:\`) +2. **支持的格式**:jpg, jpeg, png, gif, webp, bmp +3. **无需调用其他工具**:不需要用 `read_file` 读取文件内容,直接输出 `![](路径)` 即可 +4. **文件必须存在**:确保路径指向的文件确实存在 + +### 📌 示例场景 + +**用户说**:"发送 /tmp/screenshot.png 给我" + +**正确回复**: +``` +好的,这是截图: +![](/tmp/screenshot.png) +``` + +**用户说**:"图片保存在哪?" + +**正确回复**: +``` +图片保存在:/Users/xxx/downloads/image.jpg +``` + +## 🖼️ 发送网络图片 + +发送网络图片时,也使用 Markdown 图片语法: + +``` +这是图片: +![](https://example.com/image.png) +``` + +或直接放 URL 也可以(系统会自动识别图片 URL): + +``` +这是图片: +https://example.com/image.png +``` + +## 🎵 其他说明 + +- 当前仅支持图片格式,音频/视频等格式暂不支持 +- 群消息和私聊消息的图片发送方式相同 +- 图片大小建议不超过 10MB +- 参考文档:https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/send-receive/rich-media.html diff --git a/src/gateway.ts b/src/gateway.ts index 1ebe187..f6dae41 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -1,5 +1,6 @@ import WebSocket from "ws"; import path from "node:path"; +import * as fs from "node:fs"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js"; import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js"; @@ -713,7 +714,7 @@ openclaw cron add \\ /** * 检查并收集图片 URL - * 支持:公网 URL (http/https) 和 Base64 Data URL (data:image/...) + * 支持:公网 URL (http/https)、Base64 Data URL (data:image/...) 和本地文件路径 */ const collectImageUrl = (url: string | undefined | null): boolean => { if (!url) return false; @@ -740,7 +741,45 @@ openclaw cron add \\ url.startsWith("../"); if (isLocalPath) { - log?.info(`[qqbot:${account.accountId}] Skipped local file path (OpenClaw should convert to Base64 or upload): ${url.slice(0, 80)}`); + // 🎯 新增:自动读取本地文件并转换为 Base64 Data URL + try { + 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 = { + ".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)}`); } @@ -759,13 +798,21 @@ openclaw cron add \\ // 提取文本中的图片格式 // 1. 提取 markdown 格式的图片 ![alt](url) 或 ![#宽px #高px](url) - const mdImageRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gi; + // 🎯 同时支持 http/https URL 和本地路径 + const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi; const mdMatches = [...replyText.matchAll(mdImageRegex)]; for (const match of mdMatches) { - const url = match[2]; + const url = match[2]?.trim(); if (url && !imageUrls.includes(url)) { - imageUrls.push(url); - log?.info(`[qqbot:${account.accountId}] Extracted image from markdown: ${url.slice(0, 80)}...`); + // 判断是公网 URL 还是本地路径 + if (url.startsWith('http://') || url.startsWith('https://')) { + imageUrls.push(url); + 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)) { + // 本地路径:以 /Users, /home, /tmp, /var, /private 或 Windows 盘符开头,且以图片扩展名结尾 + collectImageUrl(url); + log?.info(`[qqbot:${account.accountId}] Extracted local image from markdown: ${url}`); + } } } @@ -780,6 +827,24 @@ 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 模式 const useMarkdown = account.markdownSupport === true; log?.info(`[qqbot:${account.accountId}] Markdown mode: ${useMarkdown}, images: ${imageUrls.length}`); @@ -798,28 +863,64 @@ openclaw cron add \\ // 根据模式处理图片 if (useMarkdown) { - // ============ Markdown 模式:使用 ![#宽px #高px](url) 格式 ============ - // QQBot 的 markdown 图片格式要求:![#宽px #高px](url) - // 需要自动获取图片尺寸,或使用默认尺寸 + // ============ Markdown 模式 ============ + // 🎯 关键改动:区分公网 URL 和本地文件/Base64 + // - 公网 URL (http/https) → 使用 Markdown 图片格式 ![#宽px #高px](url) + // - 本地文件/Base64 (data:image/...) → 使用富媒体 API 发送 + // 分离图片:公网 URL vs Base64/本地文件 + const httpImageUrls: string[] = []; // 公网 URL,用于 Markdown 嵌入 + const base64ImageUrls: string[] = []; // Base64,用于富媒体 API + + for (const url of imageUrls) { + if (url.startsWith("data:image/")) { + base64ImageUrls.push(url); + } else if (url.startsWith("http://") || url.startsWith("https://")) { + httpImageUrls.push(url); + } + } + + log?.info(`[qqbot:${account.accountId}] Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`); + + // 🔹 第一步:通过富媒体 API 发送 Base64 图片(本地文件已转换为 Base64) + if (base64ImageUrls.length > 0) { + log?.info(`[qqbot:${account.accountId}] Sending ${base64ImageUrls.length} image(s) via Rich Media API...`); + for (const imageUrl of base64ImageUrls) { + 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) { + // 频道暂不支持富媒体,跳过 + log?.info(`[qqbot:${account.accountId}] Channel does not support rich media, skipping Base64 image`); + } + }); + log?.info(`[qqbot:${account.accountId}] Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`); + } catch (imgErr) { + log?.error(`[qqbot:${account.accountId}] Failed to send Base64 image via Rich Media API: ${imgErr}`); + } + } + } + + // 🔹 第二步:处理文本和公网 URL 图片 // 记录已存在于文本中的 markdown 图片 URL const existingMdUrls = new Set(mdMatches.map(m => m[2])); - // 需要追加的图片(从 mediaUrl/mediaUrls 来的) + // 需要追加的公网图片(从 mediaUrl/mediaUrls 来的,且不在文本中) const imagesToAppend: string[] = []; - // 处理需要追加的图片:获取尺寸并格式化 - for (const url of imageUrls) { + // 处理需要追加的公网 URL 图片:获取尺寸并格式化 + for (const url of httpImageUrls) { if (!existingMdUrls.has(url)) { // 这个 URL 不在文本的 markdown 格式中,需要追加 - // 尝试获取图片尺寸 try { const size = await getImageSize(url); const mdImage = formatQQBotMarkdownImage(url, size); imagesToAppend.push(mdImage); - log?.info(`[qqbot:${account.accountId}] Formatted image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`); + log?.info(`[qqbot:${account.accountId}] Formatted HTTP image: ${size ? `${size.width}x${size.height}` : 'default size'} - ${url.slice(0, 60)}...`); } catch (err) { - // 获取尺寸失败,使用默认尺寸 log?.info(`[qqbot:${account.accountId}] Failed to get image size, using default: ${err}`); const mdImage = formatQQBotMarkdownImage(url, null); imagesToAppend.push(mdImage); @@ -835,20 +936,17 @@ openclaw cron add \\ // 检查是否已经有 QQBot 格式的尺寸 ![#宽px #高px](url) if (!hasQQBotImageSize(fullMatch)) { - // 没有尺寸信息,需要补充 try { const size = await getImageSize(imgUrl); const newMdImage = formatQQBotMarkdownImage(imgUrl, size); textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage); log?.info(`[qqbot:${account.accountId}] Updated image with size: ${size ? `${size.width}x${size.height}` : 'default'} - ${imgUrl.slice(0, 60)}...`); } catch (err) { - // 获取尺寸失败,使用默认尺寸 log?.info(`[qqbot:${account.accountId}] Failed to get image size for existing md, using default: ${err}`); const newMdImage = formatQQBotMarkdownImage(imgUrl, null); textWithoutImages = textWithoutImages.replace(fullMatch, newMdImage); } } - // 如果已经有尺寸信息,保留原格式 } // 从文本中移除裸 URL 图片(已转换为 markdown 格式) @@ -856,7 +954,7 @@ openclaw cron add \\ textWithoutImages = textWithoutImages.replace(match[0], "").trim(); } - // 追加需要添加的图片到文本末尾 + // 追加需要添加的公网图片到文本末尾 if (imagesToAppend.length > 0) { textWithoutImages = textWithoutImages.trim(); if (textWithoutImages) { @@ -866,7 +964,7 @@ openclaw cron add \\ } } - // 发送带图片的 markdown 消息(文本+图片一起发送) + // 🔹 第三步:发送带公网图片的 markdown 消息 if (textWithoutImages.trim()) { try { await sendWithTokenRetry(async (token) => { @@ -878,7 +976,7 @@ openclaw cron add \\ await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); } }); - log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${imageUrls.length} images (${event.type})`); + log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`); } catch (err) { log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`); } diff --git a/src/utils/image-size.ts b/src/utils/image-size.ts new file mode 100644 index 0000000..de59997 --- /dev/null +++ b/src/utils/image-size.ts @@ -0,0 +1,266 @@ +/** + * 图片尺寸工具 + * 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式 + * + * QQBot markdown 图片格式: ![#宽px #高px](url) + */ + +import { Buffer } from "buffer"; + +export interface ImageSize { + width: number; + height: number; +} + +/** 默认图片尺寸(当无法获取时使用) */ +export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 }; + +/** + * 从 PNG 文件头解析图片尺寸 + * PNG 文件头结构: 前 8 字节是签名,IHDR 块从第 8 字节开始 + * IHDR 块: 长度(4) + 类型(4, "IHDR") + 宽度(4) + 高度(4) + ... + */ +function parsePngSize(buffer: Buffer): ImageSize | null { + // PNG 签名: 89 50 4E 47 0D 0A 1A 0A + if (buffer.length < 24) return null; + if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47) { + return null; + } + // IHDR 块从第 8 字节开始,宽度在第 16-19 字节,高度在第 20-23 字节 + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return { width, height }; +} + +/** + * 从 JPEG 文件解析图片尺寸 + * JPEG 尺寸在 SOF0/SOF2 块中 + */ +function parseJpegSize(buffer: Buffer): ImageSize | null { + // JPEG 签名: FF D8 FF + if (buffer.length < 4) return null; + if (buffer[0] !== 0xFF || buffer[1] !== 0xD8) { + return null; + } + + let offset = 2; + while (offset < buffer.length - 9) { + if (buffer[offset] !== 0xFF) { + offset++; + continue; + } + + const marker = buffer[offset + 1]; + // SOF0 (0xC0) 或 SOF2 (0xC2) 包含图片尺寸 + if (marker === 0xC0 || marker === 0xC2) { + // 格式: FF C0 长度(2) 精度(1) 高度(2) 宽度(2) + if (offset + 9 <= buffer.length) { + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + return { width, height }; + } + } + + // 跳过当前块 + if (offset + 3 < buffer.length) { + const blockLength = buffer.readUInt16BE(offset + 2); + offset += 2 + blockLength; + } else { + break; + } + } + + return null; +} + +/** + * 从 GIF 文件头解析图片尺寸 + * GIF 文件头: GIF87a 或 GIF89a (6字节) + 宽度(2) + 高度(2) + */ +function parseGifSize(buffer: Buffer): ImageSize | null { + if (buffer.length < 10) return null; + const signature = buffer.toString("ascii", 0, 6); + if (signature !== "GIF87a" && signature !== "GIF89a") { + return null; + } + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + return { width, height }; +} + +/** + * 从 WebP 文件解析图片尺寸 + * WebP 文件头: RIFF(4) + 文件大小(4) + WEBP(4) + VP8/VP8L/VP8X(4) + ... + */ +function parseWebpSize(buffer: Buffer): ImageSize | null { + if (buffer.length < 30) return null; + + // 检查 RIFF 和 WEBP 签名 + const riff = buffer.toString("ascii", 0, 4); + const webp = buffer.toString("ascii", 8, 12); + if (riff !== "RIFF" || webp !== "WEBP") { + return null; + } + + const chunkType = buffer.toString("ascii", 12, 16); + + // VP8 (有损压缩) + if (chunkType === "VP8 ") { + // VP8 帧头从第 23 字节开始,检查签名 9D 01 2A + if (buffer.length >= 30 && buffer[23] === 0x9D && buffer[24] === 0x01 && buffer[25] === 0x2A) { + const width = buffer.readUInt16LE(26) & 0x3FFF; + const height = buffer.readUInt16LE(28) & 0x3FFF; + return { width, height }; + } + } + + // VP8L (无损压缩) + if (chunkType === "VP8L") { + // VP8L 签名: 0x2F + if (buffer.length >= 25 && buffer[20] === 0x2F) { + const bits = buffer.readUInt32LE(21); + const width = (bits & 0x3FFF) + 1; + const height = ((bits >> 14) & 0x3FFF) + 1; + return { width, height }; + } + } + + // VP8X (扩展格式) + if (chunkType === "VP8X") { + if (buffer.length >= 30) { + // 宽度和高度在第 24-26 和 27-29 字节(24位小端) + const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1; + const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1; + return { width, height }; + } + } + + return null; +} + +/** + * 从图片数据 Buffer 解析尺寸 + */ +export function parseImageSize(buffer: Buffer): ImageSize | null { + // 尝试各种格式 + return parsePngSize(buffer) + ?? parseJpegSize(buffer) + ?? parseGifSize(buffer) + ?? parseWebpSize(buffer); +} + +/** + * 从公网 URL 获取图片尺寸 + * 只下载前 64KB 数据,足够解析大部分图片格式的头部 + */ +export async function getImageSizeFromUrl(url: string, timeoutMs = 5000): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // 使用 Range 请求只获取前 64KB + const response = await fetch(url, { + signal: controller.signal, + headers: { + "Range": "bytes=0-65535", + "User-Agent": "QQBot-Image-Size-Detector/1.0", + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok && response.status !== 206) { + console.log(`[image-size] Failed to fetch ${url}: ${response.status}`); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const size = parseImageSize(buffer); + if (size) { + console.log(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`); + } + + return size; + } catch (err) { + console.log(`[image-size] Error fetching ${url.slice(0, 60)}...: ${err}`); + return null; + } +} + +/** + * 从 Base64 Data URL 获取图片尺寸 + */ +export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null { + try { + // 格式: data:image/png;base64,xxxxx + const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/); + if (!matches) { + return null; + } + + const base64Data = matches[1]; + const buffer = Buffer.from(base64Data, "base64"); + + const size = parseImageSize(buffer); + if (size) { + console.log(`[image-size] Got size from Base64: ${size.width}x${size.height}`); + } + + return size; + } catch (err) { + console.log(`[image-size] Error parsing Base64: ${err}`); + return null; + } +} + +/** + * 获取图片尺寸(自动判断来源) + * @param source - 图片 URL 或 Base64 Data URL + * @returns 图片尺寸,失败返回 null + */ +export async function getImageSize(source: string): Promise { + if (source.startsWith("data:")) { + return getImageSizeFromDataUrl(source); + } + + if (source.startsWith("http://") || source.startsWith("https://")) { + return getImageSizeFromUrl(source); + } + + return null; +} + +/** + * 生成 QQBot markdown 图片格式 + * 格式: ![#宽px #高px](url) + * + * @param url - 图片 URL + * @param size - 图片尺寸,如果为 null 则使用默认尺寸 + * @returns QQBot markdown 图片字符串 + */ +export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string { + const { width, height } = size ?? DEFAULT_IMAGE_SIZE; + return `![#${width}px #${height}px](${url})`; +} + +/** + * 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息 + * 格式: ![#宽px #高px](url) + */ +export function hasQQBotImageSize(markdownImage: string): boolean { + return /!\[#\d+px\s+#\d+px\]/.test(markdownImage); +} + +/** + * 从已有的 QQBot 格式 markdown 图片中提取尺寸 + * 格式: ![#宽px #高px](url) + */ +export function extractQQBotImageSize(markdownImage: string): ImageSize | null { + const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/); + if (match) { + return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) }; + } + return null; +}