feat: 增加附件下载与本地图片上传功能
在 gateway.ts 中修改附件处理逻辑,将接收到的附件下载到本地目录供 clawdbot 访问,并支持将 AI 生成的本地文件通过 MEDIA: 前缀上传到图床。 在 image-server.ts 中新增 saveImageFromPath 函数用于保存本地图片到图床,新增 downloadFile 函数用于下载远程文件到本地,支持根据 Content-Type 或 URL 自动推断文件扩展名。 在 .gitignore 中添加 dist 忽略项。同时优化了错误提示信息,将错误截取长度从 100 字符增加到 500 字符。
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
@@ -2,7 +2,7 @@ import WebSocket from "ws";
|
|||||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
||||||
import { getQQBotRuntime } from "./runtime.js";
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
import { startImageServer, saveImage, isImageServerRunning, type ImageServerConfig } from "./image-server.js";
|
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||||
|
|
||||||
// QQ Bot intents - 按权限级别分组
|
// QQ Bot intents - 按权限级别分组
|
||||||
const INTENTS = {
|
const INTENTS = {
|
||||||
@@ -237,16 +237,32 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
systemPrompts.push(account.systemPrompt);
|
systemPrompts.push(account.systemPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理附件(图片等)
|
// 处理附件(图片等)- 下载到本地供 clawdbot 访问
|
||||||
let attachmentInfo = "";
|
let attachmentInfo = "";
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
const downloadDir = process.env.QQBOT_DOWNLOAD_DIR || "./qqbot-downloads";
|
||||||
|
|
||||||
if (event.attachments?.length) {
|
if (event.attachments?.length) {
|
||||||
for (const att of event.attachments) {
|
for (const att of event.attachments) {
|
||||||
if (att.content_type?.startsWith("image/")) {
|
// 下载附件到本地
|
||||||
imageUrls.push(att.url);
|
const localPath = await downloadFile(att.url, downloadDir);
|
||||||
attachmentInfo += `\n[图片: ${att.url}]`;
|
if (localPath) {
|
||||||
|
if (att.content_type?.startsWith("image/")) {
|
||||||
|
imageUrls.push(localPath);
|
||||||
|
attachmentInfo += `\n[图片: ${localPath}]`;
|
||||||
|
} else {
|
||||||
|
attachmentInfo += `\n[附件: ${localPath}]`;
|
||||||
|
}
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
|
||||||
} else {
|
} else {
|
||||||
attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}]`;
|
// 下载失败,提供原始 URL 作为后备
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`);
|
||||||
|
if (att.content_type?.startsWith("image/")) {
|
||||||
|
imageUrls.push(att.url);
|
||||||
|
attachmentInfo += `\n[图片: ${att.url}] (下载失败,可能无法访问)`;
|
||||||
|
} else {
|
||||||
|
attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}] (下载失败)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,6 +382,30 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
// 提取回复中的图片
|
// 提取回复中的图片
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
|
||||||
|
// 0. 提取 MEDIA: 前缀的本地文件路径
|
||||||
|
const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
|
||||||
|
const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
|
||||||
|
|
||||||
|
for (const match of mediaMatches) {
|
||||||
|
const localPath = match[1];
|
||||||
|
if (localPath && imageServerBaseUrl) {
|
||||||
|
// 将本地文件复制到图床
|
||||||
|
try {
|
||||||
|
const savedUrl = saveImageFromPath(localPath);
|
||||||
|
if (savedUrl) {
|
||||||
|
imageUrls.push(savedUrl);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Saved local image to server: ${localPath}`);
|
||||||
|
} else {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save local image (not found or not image): ${localPath}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save local image: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从文本中移除 MEDIA: 行
|
||||||
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 提取 base64 图片(data:image/xxx;base64,...)
|
// 1. 提取 base64 图片(data:image/xxx;base64,...)
|
||||||
const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
||||||
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
||||||
@@ -464,12 +504,13 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
// 发送错误提示给用户
|
// 发送错误提示给用户,显示完整错误信息
|
||||||
const errMsg = String(err);
|
const errMsg = String(err);
|
||||||
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
||||||
await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置");
|
await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置");
|
||||||
} else {
|
} else {
|
||||||
await sendErrorMessage(`[ClawdBot] 处理消息时出错: ${errMsg.slice(0, 100)}`);
|
// 显示完整错误信息,截取前 500 字符
|
||||||
|
await sendErrorMessage(`[ClawdBot] 出错: ${errMsg.slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -490,7 +531,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
||||||
await sendErrorMessage(`[ClawdBot] 处理消息失败: ${String(err).slice(0, 100)}`);
|
await sendErrorMessage(`[ClawdBot] 处理失败: ${String(err).slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,38 @@ export function saveImage(
|
|||||||
return `${baseUrl}/images/${imageId}.${ext}`;
|
return `${baseUrl}/images/${imageId}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地文件路径保存图片到图床
|
||||||
|
* @param filePath 本地文件路径
|
||||||
|
* @param ttlSeconds 过期时间(秒),默认使用配置值
|
||||||
|
* @returns 图片访问 URL,如果文件不存在或不是图片则返回 null
|
||||||
|
*/
|
||||||
|
export function saveImageFromPath(filePath: string, ttlSeconds?: number): string | null {
|
||||||
|
try {
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
// 根据扩展名获取 MIME 类型
|
||||||
|
const ext = path.extname(filePath).toLowerCase().replace(".", "");
|
||||||
|
const mimeType = getMimeType(ext);
|
||||||
|
|
||||||
|
// 只处理图片文件
|
||||||
|
if (!mimeType.startsWith("image/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 saveImage 保存
|
||||||
|
return saveImage(buffer, mimeType, ttlSeconds);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查图床服务器是否运行中
|
* 检查图床服务器是否运行中
|
||||||
*/
|
*/
|
||||||
@@ -332,6 +364,58 @@ export function isImageServerRunning(): boolean {
|
|||||||
return serverInstance !== null;
|
return serverInstance !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载远程文件并保存到本地
|
||||||
|
* @param url 远程文件 URL
|
||||||
|
* @param destDir 目标目录
|
||||||
|
* @param filename 文件名(可选,不含扩展名)
|
||||||
|
* @returns 本地文件路径,失败返回 null
|
||||||
|
*/
|
||||||
|
export async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destDir: string,
|
||||||
|
filename?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[image-server] Download failed: ${response.status} ${response.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
// 从 Content-Type 或 URL 推断扩展名
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
let ext = getExtFromMime(contentType);
|
||||||
|
if (ext === "png" && !contentType.includes("png")) {
|
||||||
|
// 尝试从 URL 获取扩展名
|
||||||
|
const urlExt = url.match(/\.(\w+)(?:\?|$)/)?.[1]?.toLowerCase();
|
||||||
|
if (urlExt && ["png", "jpg", "jpeg", "gif", "webp", "pdf", "doc", "docx", "txt", "json", "jsonl"].includes(urlExt)) {
|
||||||
|
ext = urlExt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const finalFilename = `${filename || generateImageId()}.${ext}`;
|
||||||
|
const filePath = path.join(destDir, finalFilename);
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[image-server] Download error:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取图床服务器配置
|
* 获取图床服务器配置
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user