diff --git a/src/api.ts b/src/api.ts index 563c471..eaace99 100644 --- a/src/api.ts +++ b/src/api.ts @@ -213,3 +213,161 @@ export async function sendProactiveGroupMessage( msg_type: 0, }); } + +// ============ 富媒体消息支持 ============ + +/** + * 媒体文件类型 + */ +export enum MediaFileType { + IMAGE = 1, + VIDEO = 2, + VOICE = 3, + FILE = 4, // 暂未开放 +} + +/** + * 上传富媒体文件的响应 + */ +export interface UploadMediaResponse { + file_uuid: string; + file_info: string; + ttl: number; + id?: string; // 仅当 srv_send_msg=true 时返回 +} + +/** + * 上传富媒体文件到 C2C 单聊 + * @param accessToken 访问令牌 + * @param openid 用户 openid + * @param fileType 文件类型 + * @param url 媒体资源 URL + * @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送) + */ +export async function uploadC2CMedia( + accessToken: string, + openid: string, + fileType: MediaFileType, + url: string, + srvSendMsg = false +): Promise { + return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, { + file_type: fileType, + url, + srv_send_msg: srvSendMsg, + }); +} + +/** + * 上传富媒体文件到群聊 + * @param accessToken 访问令牌 + * @param groupOpenid 群 openid + * @param fileType 文件类型 + * @param url 媒体资源 URL + * @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送) + */ +export async function uploadGroupMedia( + accessToken: string, + groupOpenid: string, + fileType: MediaFileType, + url: string, + srvSendMsg = false +): Promise { + return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, { + file_type: fileType, + url, + srv_send_msg: srvSendMsg, + }); +} + +/** + * 发送 C2C 单聊富媒体消息 + * @param accessToken 访问令牌 + * @param openid 用户 openid + * @param fileInfo 从 uploadC2CMedia 获取的 file_info + * @param msgId 被动回复时需要的消息 ID + * @param content 可选的文字内容 + */ +export async function sendC2CMediaMessage( + accessToken: string, + openid: string, + fileInfo: string, + msgId?: string, + content?: string +): Promise<{ id: string; timestamp: number }> { + const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; + return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, { + msg_type: 7, // 富媒体消息类型 + media: { file_info: fileInfo }, + msg_seq: msgSeq, + ...(content ? { content } : {}), + ...(msgId ? { msg_id: msgId } : {}), + }); +} + +/** + * 发送群聊富媒体消息 + * @param accessToken 访问令牌 + * @param groupOpenid 群 openid + * @param fileInfo 从 uploadGroupMedia 获取的 file_info + * @param msgId 被动回复时需要的消息 ID + * @param content 可选的文字内容 + */ +export async function sendGroupMediaMessage( + accessToken: string, + groupOpenid: string, + fileInfo: string, + msgId?: string, + content?: string +): Promise<{ id: string; timestamp: string }> { + const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; + return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, { + msg_type: 7, // 富媒体消息类型 + media: { file_info: fileInfo }, + msg_seq: msgSeq, + ...(content ? { content } : {}), + ...(msgId ? { msg_id: msgId } : {}), + }); +} + +/** + * 发送带图片的 C2C 单聊消息(封装上传+发送) + * @param accessToken 访问令牌 + * @param openid 用户 openid + * @param imageUrl 图片 URL + * @param msgId 被动回复时需要的消息 ID + * @param content 可选的文字内容 + */ +export async function sendC2CImageMessage( + accessToken: string, + openid: string, + imageUrl: string, + msgId?: string, + content?: string +): Promise<{ id: string; timestamp: number }> { + // 先上传图片获取 file_info + const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false); + // 再发送富媒体消息 + return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content); +} + +/** + * 发送带图片的群聊消息(封装上传+发送) + * @param accessToken 访问令牌 + * @param groupOpenid 群 openid + * @param imageUrl 图片 URL + * @param msgId 被动回复时需要的消息 ID + * @param content 可选的文字内容 + */ +export async function sendGroupImageMessage( + accessToken: string, + groupOpenid: string, + imageUrl: string, + msgId?: string, + content?: string +): Promise<{ id: string; timestamp: string }> { + // 先上传图片获取 file_info + const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false); + // 再发送富媒体消息 + return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); +} diff --git a/src/gateway.ts b/src/gateway.ts index f933e47..3ed07aa 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -1,7 +1,8 @@ import WebSocket from "ws"; import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js"; -import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache } from "./api.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"; // QQ Bot intents const INTENTS = { @@ -19,6 +20,10 @@ const MAX_RECONNECT_ATTEMPTS = 100; const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值 const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开 +// 图床服务器配置(可通过环境变量覆盖) +const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10); +const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || "./qqbot-images"; + export interface GatewayContext { account: ResolvedQQBotAccount; abortSignal: AbortSignal; @@ -32,6 +37,30 @@ export interface GatewayContext { }; } +/** + * 启动图床服务器 + */ +async function ensureImageServer(log?: GatewayContext["log"]): Promise { + if (isImageServerRunning()) { + return `http://0.0.0.0:${IMAGE_SERVER_PORT}`; + } + + try { + const config: Partial = { + port: IMAGE_SERVER_PORT, + storageDir: IMAGE_SERVER_DIR, + baseUrl: `http://0.0.0.0:${IMAGE_SERVER_PORT}`, + ttlSeconds: 3600, // 1 小时过期 + }; + await startImageServer(config); + log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}`); + return `http://0.0.0.0:${IMAGE_SERVER_PORT}`; + } catch (err) { + log?.error(`[qqbot] Failed to start image server: ${err}`); + return null; + } +} + /** * 启动 Gateway WebSocket 连接(带自动重连) */ @@ -42,6 +71,9 @@ export async function startGateway(ctx: GatewayContext): Promise { throw new Error("QQBot not configured (missing appId or clientSecret)"); } + // 尝试启动图床服务器 + const imageServerBaseUrl = await ensureImageServer(log); + let reconnectAttempts = 0; let isAborted = false; let currentWs: WebSocket | null = null; @@ -306,29 +338,90 @@ export async function startGateway(ctx: GatewayContext): Promise { let replyText = payload.text ?? ""; if (!replyText.trim()) return; - // 处理回复内容,避免被 QQ 识别为 URL - const originalText = replyText; + // 提取回复中的图片 + const imageUrls: string[] = []; - // 把所有可能被识别为 URL 的点替换为下划线 - // 匹配:字母/数字.字母/数字 的模式 - replyText = replyText.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); + // 1. 提取 base64 图片(data:image/xxx;base64,...) + const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(? { - if (event.type === "c2c") { - await sendC2CMessage(token, event.senderId, replyText, event.messageId); - } else if (event.type === "group" && event.groupOpenid) { - await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId); - } else if (event.channelId) { - await sendChannelMessage(token, event.channelId, replyText, event.messageId); + // 先发送图片(如果有) + for (const imageUrl of imageUrls) { + 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); + } + // 频道消息暂不支持富媒体,跳过图片 + }); + log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`); + } catch (imgErr) { + log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`); + // 图片发送失败时,把 URL 加回文本(已处理过点号的版本) + const safeUrl = imageUrl.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); + textWithoutImages = `[图片: ${safeUrl}]\n${textWithoutImages}`; } - }); - log?.info(`[qqbot:${account.accountId}] Sent reply`); + } + + // 再发送文本(如果有) + if (textWithoutImages.trim()) { + await sendWithTokenRetry(async (token) => { + if (event.type === "c2c") { + await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId); + } else if (event.type === "group" && event.groupOpenid) { + await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId); + } else if (event.channelId) { + await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId); + } + }); + log?.info(`[qqbot:${account.accountId}] Sent text reply`); + } pluginRuntime.channel.activity.record({ channel: "qqbot", @@ -538,12 +631,28 @@ export async function startGateway(ctx: GatewayContext): Promise { log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`); isConnecting = false; // 释放锁 - // 4903 等错误码表示 session 创建失败,需要刷新 token - if (code === 4903 || code === 4009 || code === 4014) { - log?.info(`[qqbot:${account.accountId}] Session error (${code}), will refresh token`); + // 根据错误码处理 + // 4009: 可以重新发起 resume + // 4900-4913: 内部错误,需要重新 identify + // 4914: 机器人已下架 + // 4915: 机器人已封禁 + if (code === 4914 || code === 4915) { + log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`); + cleanup(); + // 不重连,直接退出 + return; + } + + if (code === 4009) { + // 4009 可以尝试 resume,保留 session + log?.info(`[qqbot:${account.accountId}] Error 4009, will try resume`); shouldRefreshToken = true; + } else if (code >= 4900 && code <= 4913) { + // 4900-4913 内部错误,清除 session 重新 identify + log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`); sessionId = null; lastSeq = null; + shouldRefreshToken = true; } // 检测是否是快速断开(连接后很快就断了) @@ -552,12 +661,10 @@ export async function startGateway(ctx: GatewayContext): Promise { quickDisconnectCount++; log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`); - // 如果连续快速断开超过阈值,清除 session 并等待更长时间 + // 如果连续快速断开超过阈值,等待更长时间 if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { - log?.info(`[qqbot:${account.accountId}] Too many quick disconnects, clearing session and refreshing token`); - sessionId = null; - lastSeq = null; - shouldRefreshToken = true; + log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`); + log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`); quickDisconnectCount = 0; cleanup(); // 快速断开太多次,等待更长时间再重连 diff --git a/src/image-server.ts b/src/image-server.ts new file mode 100644 index 0000000..b106885 --- /dev/null +++ b/src/image-server.ts @@ -0,0 +1,340 @@ +/** + * 本地图床服务器 + * 提供安全的图片存储和访问服务 + */ + +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; + +export interface ImageServerConfig { + /** 监听端口 */ + port: number; + /** 图片存储目录 */ + storageDir: string; + /** 外部访问的基础 URL(如 http://your-server:port),留空则自动生成 */ + baseUrl?: string; + /** 图片过期时间(秒),0 表示不过期 */ + ttlSeconds?: number; + /** 允许的图片格式 */ + allowedFormats?: string[]; +} + +interface StoredImage { + id: string; + filename: string; + mimeType: string; + createdAt: number; + ttl: number; +} + +const DEFAULT_CONFIG: Required = { + port: 18765, + storageDir: "./qqbot-images", + baseUrl: "", + ttlSeconds: 3600, // 默认 1 小时过期 + allowedFormats: ["png", "jpg", "jpeg", "gif", "webp"], +}; + +let serverInstance: http.Server | null = null; +let currentConfig: Required = { ...DEFAULT_CONFIG }; +let imageIndex = new Map(); + +/** + * 生成安全的随机 ID + */ +function generateImageId(): string { + return crypto.randomBytes(16).toString("hex"); +} + +/** + * 验证请求路径是否安全(防止目录遍历攻击) + */ +function isPathSafe(requestPath: string, baseDir: string): boolean { + const normalizedBase = path.resolve(baseDir); + const normalizedPath = path.resolve(baseDir, requestPath); + return normalizedPath.startsWith(normalizedBase + path.sep) || normalizedPath === normalizedBase; +} + +/** + * 获取 MIME 类型 + */ +function getMimeType(ext: string): string { + const mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + }; + return mimeTypes[ext.toLowerCase()] || "application/octet-stream"; +} + +/** + * 从 MIME 类型获取扩展名 + */ +function getExtFromMime(mimeType: string): string { + const extMap: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + }; + return extMap[mimeType] || "png"; +} + +/** + * 清理过期图片 + */ +function cleanupExpiredImages(): void { + const now = Date.now(); + const expiredIds: string[] = []; + + for (const [id, image] of imageIndex) { + if (image.ttl > 0 && now - image.createdAt > image.ttl * 1000) { + expiredIds.push(id); + } + } + + for (const id of expiredIds) { + const image = imageIndex.get(id); + if (image) { + const filePath = path.join(currentConfig.storageDir, image.filename); + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch { + // 忽略删除错误 + } + imageIndex.delete(id); + } + } +} + +/** + * 加载已有的图片索引 + */ +function loadImageIndex(): void { + const indexPath = path.join(currentConfig.storageDir, ".index.json"); + try { + if (fs.existsSync(indexPath)) { + const data = JSON.parse(fs.readFileSync(indexPath, "utf-8")); + imageIndex = new Map(Object.entries(data)); + } + } catch { + imageIndex = new Map(); + } +} + +/** + * 保存图片索引 + */ +function saveImageIndex(): void { + const indexPath = path.join(currentConfig.storageDir, ".index.json"); + try { + const data = Object.fromEntries(imageIndex); + fs.writeFileSync(indexPath, JSON.stringify(data, null, 2)); + } catch { + // 忽略保存错误 + } +} + +/** + * 处理 HTTP 请求 + */ +function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url || "/", `http://localhost:${currentConfig.port}`); + const pathname = url.pathname; + + // 设置 CORS 头(允许 QQ 服务器访问) + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + // 只允许 GET 请求访问图片 + if (req.method !== "GET") { + res.writeHead(405, { "Content-Type": "text/plain" }); + res.end("Method Not Allowed"); + return; + } + + // 解析图片 ID(路径格式: /images/{id}.{ext}) + const match = pathname.match(/^\/images\/([a-f0-9]{32})\.(\w+)$/); + if (!match) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + + const [, imageId, requestedExt] = match; + const image = imageIndex.get(imageId); + + if (!image) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Image Not Found"); + return; + } + + // 检查是否过期 + if (image.ttl > 0 && Date.now() - image.createdAt > image.ttl * 1000) { + res.writeHead(410, { "Content-Type": "text/plain" }); + res.end("Image Expired"); + return; + } + + // 安全检查:确保文件路径在存储目录内 + const filePath = path.join(currentConfig.storageDir, image.filename); + if (!isPathSafe(image.filename, currentConfig.storageDir)) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden"); + return; + } + + // 读取并返回图片 + try { + if (!fs.existsSync(filePath)) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("File Not Found"); + return; + } + + const imageData = fs.readFileSync(filePath); + res.writeHead(200, { + "Content-Type": image.mimeType, + "Content-Length": imageData.length, + "Cache-Control": image.ttl > 0 ? `max-age=${image.ttl}` : "max-age=31536000", + }); + res.end(imageData); + } catch (err) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Internal Server Error"); + } +} + +/** + * 启动图床服务器 + */ +export function startImageServer(config?: Partial): Promise { + return new Promise((resolve, reject) => { + if (serverInstance) { + const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`; + resolve(baseUrl); + return; + } + + currentConfig = { ...DEFAULT_CONFIG, ...config }; + + // 确保存储目录存在 + if (!fs.existsSync(currentConfig.storageDir)) { + fs.mkdirSync(currentConfig.storageDir, { recursive: true }); + } + + // 加载图片索引 + loadImageIndex(); + + // 启动定期清理 + const cleanupInterval = setInterval(cleanupExpiredImages, 60000); // 每分钟清理一次 + + serverInstance = http.createServer(handleRequest); + + serverInstance.on("error", (err) => { + clearInterval(cleanupInterval); + reject(err); + }); + + serverInstance.listen(currentConfig.port, () => { + const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`; + resolve(baseUrl); + }); + }); +} + +/** + * 停止图床服务器 + */ +export function stopImageServer(): Promise { + return new Promise((resolve) => { + if (serverInstance) { + serverInstance.close(() => { + serverInstance = null; + saveImageIndex(); + resolve(); + }); + } else { + resolve(); + } + }); +} + +/** + * 保存图片并返回访问 URL + * @param imageData 图片数据(Buffer 或 base64 字符串) + * @param mimeType 图片 MIME 类型 + * @param ttlSeconds 过期时间(秒),默认使用配置值 + * @returns 图片访问 URL + */ +export function saveImage( + imageData: Buffer | string, + mimeType: string = "image/png", + ttlSeconds?: number +): string { + // 转换 base64 为 Buffer + let buffer: Buffer; + if (typeof imageData === "string") { + // 处理 data URL 格式 + const base64Match = imageData.match(/^data:([^;]+);base64,(.+)$/); + if (base64Match) { + mimeType = base64Match[1]; + buffer = Buffer.from(base64Match[2], "base64"); + } else { + buffer = Buffer.from(imageData, "base64"); + } + } else { + buffer = imageData; + } + + // 生成唯一 ID 和文件名 + const imageId = generateImageId(); + const ext = getExtFromMime(mimeType); + const filename = `${imageId}.${ext}`; + + // 保存文件 + const filePath = path.join(currentConfig.storageDir, filename); + fs.writeFileSync(filePath, buffer); + + // 记录到索引 + const image: StoredImage = { + id: imageId, + filename, + mimeType, + createdAt: Date.now(), + ttl: ttlSeconds ?? currentConfig.ttlSeconds, + }; + imageIndex.set(imageId, image); + saveImageIndex(); + + // 返回访问 URL + const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`; + return `${baseUrl}/images/${imageId}.${ext}`; +} + +/** + * 检查图床服务器是否运行中 + */ +export function isImageServerRunning(): boolean { + return serverInstance !== null; +} + +/** + * 获取图床服务器配置 + */ +export function getImageServerConfig(): Required { + return { ...currentConfig }; +}