feat(qqbot):优化发送图片功能逻辑
This commit is contained in:
@@ -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,
|
||||
|
||||
107
skills/qqbot-media/SKILL.md
Normal file
107
skills/qqbot-media/SKILL.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
triggers:
|
||||
- qqbot
|
||||
- qq
|
||||
- 发送图片
|
||||
- 发送文件
|
||||
- 图片
|
||||
- 本地文件
|
||||
- 本地图片
|
||||
priority: 80
|
||||
---
|
||||
|
||||
# QQBot 媒体发送指南
|
||||
|
||||
## 📸 发送本地图片
|
||||
|
||||
当需要发送本地图片时,**必须使用 Markdown 图片语法**:
|
||||
|
||||
```
|
||||

|
||||
```
|
||||
|
||||
### ✅ 正确方式
|
||||
|
||||
```
|
||||
这是你要的图片:
|
||||

|
||||
```
|
||||
|
||||
或者带描述:
|
||||
|
||||
```
|
||||
这是截图:
|
||||

|
||||
```
|
||||
|
||||
### ❌ 错误方式(不会发送图片)
|
||||
|
||||
直接放路径**不会**发送图片:
|
||||
|
||||
```
|
||||
这是图片:
|
||||
/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 给我"
|
||||
|
||||
**正确回复**:
|
||||
```
|
||||
好的,这是截图:
|
||||

|
||||
```
|
||||
|
||||
**用户说**:"图片保存在哪?"
|
||||
|
||||
**正确回复**:
|
||||
```
|
||||
图片保存在:/Users/xxx/downloads/image.jpg
|
||||
```
|
||||
|
||||
## 🖼️ 发送网络图片
|
||||
|
||||
发送网络图片时,也使用 Markdown 图片语法:
|
||||
|
||||
```
|
||||
这是图片:
|
||||

|
||||
```
|
||||
|
||||
或直接放 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
|
||||
140
src/gateway.ts
140
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<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)}`);
|
||||
}
|
||||
@@ -759,13 +798,21 @@ openclaw cron add \\
|
||||
|
||||
// 提取文本中的图片格式
|
||||
// 1. 提取 markdown 格式的图片  或 
|
||||
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) {
|
||||
// 检查这个路径是否已经通过  格式处理过
|
||||
if (!imageUrls.includes(localPath)) {
|
||||
log?.info(`[qqbot:${account.accountId}] Found bare local path (not sending): ${localPath}`);
|
||||
log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use  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 模式:使用  格式 ============
|
||||
// QQBot 的 markdown 图片格式要求:
|
||||
// 需要自动获取图片尺寸,或使用默认尺寸
|
||||
// ============ Markdown 模式 ============
|
||||
// 🎯 关键改动:区分公网 URL 和本地文件/Base64
|
||||
// - 公网 URL (http/https) → 使用 Markdown 图片格式 
|
||||
// - 本地文件/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 格式的尺寸 
|
||||
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}`);
|
||||
}
|
||||
|
||||
266
src/utils/image-size.ts
Normal file
266
src/utils/image-size.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 图片尺寸工具
|
||||
* 用于获取图片尺寸,生成 QQBot 的 markdown 图片格式
|
||||
*
|
||||
* QQBot markdown 图片格式: 
|
||||
*/
|
||||
|
||||
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<ImageSize | null> {
|
||||
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<ImageSize | null> {
|
||||
if (source.startsWith("data:")) {
|
||||
return getImageSizeFromDataUrl(source);
|
||||
}
|
||||
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) {
|
||||
return getImageSizeFromUrl(source);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 QQBot markdown 图片格式
|
||||
* 格式: 
|
||||
*
|
||||
* @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 ``;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 markdown 图片是否已经包含 QQBot 格式的尺寸信息
|
||||
* 格式: 
|
||||
*/
|
||||
export function hasQQBotImageSize(markdownImage: string): boolean {
|
||||
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已有的 QQBot 格式 markdown 图片中提取尺寸
|
||||
* 格式: 
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user