feat: 添加富媒体消息和图床支持
This commit is contained in:
158
src/api.ts
158
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<UploadMediaResponse> {
|
||||
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<UploadMediaResponse> {
|
||||
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);
|
||||
}
|
||||
|
||||
159
src/gateway.ts
159
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<string | null> {
|
||||
if (isImageServerRunning()) {
|
||||
return `http://0.0.0.0:${IMAGE_SERVER_PORT}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const config: Partial<ImageServerConfig> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
||||
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
||||
|
||||
const hasReplacement = replyText !== originalText;
|
||||
if (hasReplacement) {
|
||||
replyText += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||
for (const match of base64Matches) {
|
||||
const dataUrl = match[2] || match[3];
|
||||
if (dataUrl && imageServerBaseUrl) {
|
||||
// 将 base64 保存到本地图床
|
||||
try {
|
||||
const savedUrl = saveImage(dataUrl);
|
||||
imageUrls.push(savedUrl);
|
||||
log?.info(`[qqbot:${account.accountId}] Saved base64 image to local server`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to save base64 image: ${err}`);
|
||||
}
|
||||
}
|
||||
// 从文本中移除 base64
|
||||
replyText = replyText.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 2. 提取 URL 图片(Markdown 格式或纯 URL)
|
||||
const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
|
||||
const urlMatches = [...replyText.matchAll(imageUrlRegex)];
|
||||
|
||||
for (const match of urlMatches) {
|
||||
// match[2] 是 Markdown 格式的 URL,match[3] 是纯 URL
|
||||
const url = match[2] || match[3];
|
||||
if (url) {
|
||||
imageUrls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
// 从文本中移除图片 URL,避免被 QQ 拦截
|
||||
let textWithoutImages = replyText;
|
||||
for (const match of urlMatches) {
|
||||
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||
}
|
||||
|
||||
// 处理剩余文本中的 URL 点号
|
||||
const originalText = textWithoutImages;
|
||||
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||
|
||||
const hasReplacement = textWithoutImages !== originalText;
|
||||
if (hasReplacement && textWithoutImages.trim()) {
|
||||
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWithTokenRetry(async (token) => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
// 快速断开太多次,等待更长时间再重连
|
||||
|
||||
340
src/image-server.ts
Normal file
340
src/image-server.ts
Normal file
@@ -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<ImageServerConfig> = {
|
||||
port: 18765,
|
||||
storageDir: "./qqbot-images",
|
||||
baseUrl: "",
|
||||
ttlSeconds: 3600, // 默认 1 小时过期
|
||||
allowedFormats: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||
};
|
||||
|
||||
let serverInstance: http.Server | null = null;
|
||||
let currentConfig: Required<ImageServerConfig> = { ...DEFAULT_CONFIG };
|
||||
let imageIndex = new Map<string, StoredImage>();
|
||||
|
||||
/**
|
||||
* 生成安全的随机 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<string, string> = {
|
||||
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<string, string> = {
|
||||
"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<ImageServerConfig>): Promise<string> {
|
||||
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<void> {
|
||||
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<ImageServerConfig> {
|
||||
return { ...currentConfig };
|
||||
}
|
||||
Reference in New Issue
Block a user