diff --git a/.gitignore b/.gitignore index b512c09..76add87 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules \ No newline at end of file +node_modules +dist \ No newline at end of file diff --git a/src/gateway.ts b/src/gateway.ts index cfe2048..e658020 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -2,7 +2,7 @@ import WebSocket from "ws"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.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 - 按权限级别分组 const INTENTS = { @@ -237,16 +237,32 @@ export async function startGateway(ctx: GatewayContext): Promise { systemPrompts.push(account.systemPrompt); } - // 处理附件(图片等) + // 处理附件(图片等)- 下载到本地供 clawdbot 访问 let attachmentInfo = ""; const imageUrls: string[] = []; + const downloadDir = process.env.QQBOT_DOWNLOAD_DIR || "./qqbot-downloads"; + if (event.attachments?.length) { for (const att of event.attachments) { - if (att.content_type?.startsWith("image/")) { - imageUrls.push(att.url); - attachmentInfo += `\n[图片: ${att.url}]`; + // 下载附件到本地 + const localPath = await downloadFile(att.url, downloadDir); + 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 { - 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 { // 提取回复中的图片 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,...) const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(? { clearTimeout(timeoutId); timeoutId = null; } - // 发送错误提示给用户 + // 发送错误提示给用户,显示完整错误信息 const errMsg = String(err); if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置"); } 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 { } } catch (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)}`); } }; diff --git a/src/image-server.ts b/src/image-server.ts index b106885..19a36c9 100644 --- a/src/image-server.ts +++ b/src/image-server.ts @@ -325,6 +325,38 @@ export function saveImage( 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; } +/** + * 下载远程文件并保存到本地 + * @param url 远程文件 URL + * @param destDir 目标目录 + * @param filename 文件名(可选,不含扩展名) + * @returns 本地文件路径,失败返回 null + */ +export async function downloadFile( + url: string, + destDir: string, + filename?: string +): Promise { + 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; + } +} + /** * 获取图床服务器配置 */