UNPKG

koishi-plugin-best-cave

Version:

功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。

1,130 lines (1,121 loc) 56.5 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { Config: () => Config, apply: () => apply, inject: () => inject, name: () => name, usage: () => usage }); module.exports = __toCommonJS(src_exports); var import_koishi3 = require("koishi"); // src/FileManager.ts var import_client_s3 = require("@aws-sdk/client-s3"); var fs = __toESM(require("fs/promises")); var path = __toESM(require("path")); var FileManager = class { /** * @constructor * @param baseDir Koishi 应用的基础数据目录 (ctx.baseDir)。 * @param config 插件的配置对象。 * @param logger 日志记录器实例。 */ constructor(baseDir, config, logger2) { this.logger = logger2; this.resourceDir = path.join(baseDir, "data", "cave"); if (config.enableS3 && config.endpoint && config.bucket && config.accessKeyId && config.secretAccessKey) { this.s3Client = new import_client_s3.S3Client({ endpoint: config.endpoint, region: config.region, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } }); this.s3Bucket = config.bucket; } } static { __name(this, "FileManager"); } resourceDir; locks = /* @__PURE__ */ new Map(); s3Client; s3Bucket; /** * @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。 * @template T 异步操作的返回类型。 * @param fullPath 需要加锁的文件的完整路径。 * @param operation 要执行的异步函数。 * @returns 异步操作的结果。 */ async withLock(fullPath, operation) { while (this.locks.has(fullPath)) await this.locks.get(fullPath); const promise = operation().finally(() => { this.locks.delete(fullPath); }); this.locks.set(fullPath, promise); return promise; } /** * @description 保存文件,自动选择 S3 或本地存储。 * @param fileName 用作 S3 Key 或本地文件名。 * @param data 要写入的 Buffer 数据。 * @returns 保存时使用的文件名。 */ async saveFile(fileName, data) { if (this.s3Client) { const command = new import_client_s3.PutObjectCommand({ Bucket: this.s3Bucket, Key: fileName, Body: data, ACL: "public-read" }); await this.s3Client.send(command); } else { await fs.mkdir(this.resourceDir, { recursive: true }).catch((error) => { this.logger.error(`创建资源目录失败 ${this.resourceDir}:`, error); throw error; }); const filePath = path.join(this.resourceDir, fileName); await this.withLock(filePath, () => fs.writeFile(filePath, data)); } return fileName; } /** * @description 读取文件,自动从 S3 或本地存储读取。 * @param fileName 要读取的文件名/标识符。 * @returns 文件的 Buffer 数据。 */ async readFile(fileName) { if (this.s3Client) { const command = new import_client_s3.GetObjectCommand({ Bucket: this.s3Bucket, Key: fileName }); const response = await this.s3Client.send(command); return Buffer.from(await response.Body.transformToByteArray()); } else { const filePath = path.join(this.resourceDir, fileName); return this.withLock(filePath, () => fs.readFile(filePath)); } } /** * @description 删除文件,自动从 S3 或本地删除。 * @param fileIdentifier 要删除的文件名/标识符。 */ async deleteFile(fileIdentifier) { try { if (this.s3Client) { await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier })); } else { const filePath = path.join(this.resourceDir, fileIdentifier); await this.withLock(filePath, () => fs.unlink(filePath)); } } catch (error) { if (error.code !== "ENOENT" && error.name !== "NoSuchKey") { this.logger.warn(`删除文件 ${fileIdentifier} 失败:`, error); } } } }; // src/NameManager.ts var NameManager = class { /** * @constructor * @param ctx - Koishi 上下文,用于初始化数据库模型。 */ constructor(ctx) { this.ctx = ctx; this.ctx.model.extend("cave_user", { userId: "string", nickname: "string" }, { primary: "userId" }); } static { __name(this, "NameManager"); } /** * @description 注册 `.name` 子命令,用于管理用户昵称。 * @param cave - 主 `cave` 命令实例。 */ registerCommands(cave) { cave.subcommand(".name [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => { const trimmedNickname = nickname?.trim(); if (trimmedNickname) { await this.setNickname(session.userId, trimmedNickname); return `昵称已更新为:${trimmedNickname}`; } await this.clearNickname(session.userId); return "昵称已清除"; }); } /** * @description 设置或更新指定用户的昵称。 * @param userId - 目标用户的 ID。 * @param nickname - 要设置的新昵称。 */ async setNickname(userId, nickname) { await this.ctx.database.upsert("cave_user", [{ userId, nickname }]); } /** * @description 获取指定用户的昵称。 * @param userId - 目标用户的 ID。 * @returns 用户的昵称字符串或 null。 */ async getNickname(userId) { const [name2] = await this.ctx.database.get("cave_user", { userId }); return name2?.nickname ?? null; } /** * @description 清除指定用户的昵称设置。 * @param userId - 目标用户的 ID。 */ async clearNickname(userId) { await this.ctx.database.remove("cave_user", { userId }); } }; // src/DataManager.ts var DataManager = class { /** * @constructor * @param ctx Koishi 上下文,用于数据库操作。 * @param config 插件配置。 * @param fileManager 文件管理器实例。 * @param logger 日志记录器实例。 */ constructor(ctx, config, fileManager, logger2) { this.ctx = ctx; this.config = config; this.fileManager = fileManager; this.logger = logger2; } static { __name(this, "DataManager"); } /** * @description 注册 `.export` 和 `.import` 子命令。 * @param cave - 主 `cave` 命令实例。 */ registerCommands(cave) { const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => { if (session.channelId !== this.config.adminChannel?.split(":")[1]) return "此指令仅限在管理群组中使用"; try { await session.send("正在处理,请稍候..."); return await action(); } catch (error) { this.logger.error("数据操作时发生错误:", error); return `操作失败: ${error.message}`; } }, "requireAdmin"); cave.subcommand(".export", "导出回声洞数据", { hidden: true, authority: 4 }).usage("将所有回声洞数据导出到 cave_export.json 中。").action(requireAdmin(() => this.exportData())); cave.subcommand(".import", "导入回声洞数据", { hidden: true, authority: 4 }).usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData())); } /** * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。 * @returns 描述导出结果的消息字符串。 */ async exportData() { const fileName = "cave_export.json"; const cavesToExport = await this.ctx.database.get("cave", { status: "active" }); const portableCaves = cavesToExport.map(({ id, ...rest }) => rest); await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(portableCaves, null, 2))); return `成功导出 ${portableCaves.length} 条数据`; } /** * @description 从 `cave_import.json` 文件导入回声洞数据。 * @returns 描述导入结果的消息字符串。 */ async importData() { const fileName = "cave_import.json"; let importedCaves; try { const fileContent = await this.fileManager.readFile(fileName); importedCaves = JSON.parse(fileContent.toString("utf-8")); if (!Array.isArray(importedCaves) || !importedCaves.length) throw new Error("导入文件格式无效或为空"); } catch (error) { throw new Error(`读取导入文件失败: ${error.message}`); } const [lastCave] = await this.ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 }); let startId = (lastCave?.id || 0) + 1; const newCavesToInsert = importedCaves.map((cave, index) => ({ ...cave, id: startId + index, status: "active" })); await this.ctx.database.upsert("cave", newCavesToInsert); return `成功导入 ${newCavesToInsert.length} 条数据`; } }; // src/PendManager.ts var import_koishi2 = require("koishi"); // src/Utils.ts var import_koishi = require("koishi"); var path2 = __toESM(require("path")); var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" }; async function buildCaveMessage(cave, config, fileManager, logger2, platform, prefix) { async function transformToH(elements) { return Promise.all(elements.map(async (el) => { if (el.type === "text") return import_koishi.h.text(el.content); if (el.type === "at") return (0, import_koishi.h)("at", { id: el.content }); if (el.type === "reply") return (0, import_koishi.h)("reply", { id: el.content }); if (el.type === "face") return (0, import_koishi.h)("face", { id: el.content }); if (el.type === "forward") { try { const forwardNodes = Array.isArray(el.content) ? el.content : []; const messageNodes = await Promise.all(forwardNodes.map(async (node) => { const author = (0, import_koishi.h)("author", { id: node.userId, name: node.userName }); const contentElements = await transformToH(node.elements); const unwrappedContent = []; const nestedMessageNodes = []; for (const contentEl of contentElements) { if (contentEl.type === "message" && contentEl.attrs.forward) { nestedMessageNodes.push(...contentEl.children); } else { unwrappedContent.push(contentEl); } } const resultNodes = []; if (unwrappedContent.length > 0) resultNodes.push((0, import_koishi.h)("message", {}, [author, ...unwrappedContent])); resultNodes.push(...nestedMessageNodes); return resultNodes; })); return (0, import_koishi.h)("message", { forward: true }, messageNodes.flat()); } catch (error) { logger2.warn(`解析回声洞(${cave.id})合并转发内容失败:`, error); return import_koishi.h.text("[合并转发]"); } } if (["image", "video", "audio", "file"].includes(el.type)) { const fileName = el.file; if (!fileName) return (0, import_koishi.h)("p", {}, `[${el.type}]`); if (config.enableS3 && config.publicUrl) return (0, import_koishi.h)(el.type, { ...el, src: new URL(fileName, config.publicUrl).href }); if (config.localPath) return (0, import_koishi.h)(el.type, { ...el, src: `file://${path2.join(config.localPath, fileName)}` }); try { const data2 = await fileManager.readFile(fileName); const mimeType = mimeTypeMap[path2.extname(fileName).toLowerCase()] || "application/octet-stream"; return (0, import_koishi.h)(el.type, { ...el, src: `data:${mimeType};base64,${data2.toString("base64")}` }); } catch (error) { logger2.warn(`转换文件 ${fileName} 为 Base64 失败:`, error); return (0, import_koishi.h)("p", {}, `[${el.type}]`); } } return null; })).then((hElements) => hElements.flat().filter(Boolean)); } __name(transformToH, "transformToH"); const caveHElements = await transformToH(cave.elements); const data = { id: cave.id.toString(), name: cave.userName, user: cave.userId, channel: cave.channelId, time: cave.time.toLocaleString() }; const placeholderRegex = /\{([^}]+)\}/g; const replacer = /* @__PURE__ */ __name((match, rawContent) => { const isReviewMode = !!prefix; const [normalPart, reviewPart] = rawContent.split("/", 2); const contentToProcess = isReviewMode ? reviewPart !== void 0 ? reviewPart : normalPart : normalPart; if (!contentToProcess?.trim()) return ""; const useMask = contentToProcess.startsWith("*"); const key = (useMask ? contentToProcess.substring(1) : contentToProcess).trim(); if (!key) return ""; const originalValue = data[key]; if (originalValue === void 0 || originalValue === null) return match; const valueStr = String(originalValue); if (!useMask) return valueStr; const len = valueStr.length; if (len <= 5) return valueStr; let keep = 0; if (len <= 7) keep = 2; else keep = 3; return `${valueStr.substring(0, keep)}***${valueStr.substring(len - keep)}`; }, "replacer"); const [rawHeader, rawFooter] = config.caveFormat.split("|", 2); let header = rawHeader ? rawHeader.replace(placeholderRegex, replacer).trim() : ""; if (prefix) header = `${prefix}${header}`; const footer = rawFooter ? rawFooter.replace(placeholderRegex, replacer).trim() : ""; const problematicTypes = ["video", "audio", "file", "forward"]; const placeholderMap = { video: "[视频]", audio: "[音频]", file: "[文件]", forward: "[合并转发]" }; const containsProblematic = platform === "onebot" && caveHElements.some((el) => problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward); if (!containsProblematic) { const finalMessage = []; if (header) finalMessage.push(header + "\n"); finalMessage.push(...caveHElements); if (footer) finalMessage.push("\n" + footer); return [finalMessage.length > 0 ? finalMessage : []]; } const initialMessageContent = []; const followUpMessages = []; for (const el of caveHElements) { if (problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward) { const placeholderKey = el.type === "message" && el.attrs.forward ? "forward" : el.type; initialMessageContent.push(import_koishi.h.text(placeholderMap[placeholderKey])); followUpMessages.push([el]); } else { initialMessageContent.push(el); } } const finalInitialMessage = []; if (header) finalInitialMessage.push(header + "\n"); finalInitialMessage.push(...initialMessageContent); if (footer) finalInitialMessage.push("\n" + footer); return [finalInitialMessage, ...followUpMessages].filter((msg) => msg.length > 0); } __name(buildCaveMessage, "buildCaveMessage"); async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) { try { const cavesToDelete = await ctx.database.get("cave", { status: "delete" }); if (!cavesToDelete.length) return; const idsToDelete = cavesToDelete.map((c) => c.id); for (const cave of cavesToDelete) await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file))); reusableIds.delete(0); idsToDelete.forEach((id) => reusableIds.add(id)); await ctx.database.remove("cave", { id: { $in: idsToDelete } }); await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } }); } catch (error) { logger2.error("清理回声洞时发生错误:", error); } } __name(cleanupPendingDeletions, "cleanupPendingDeletions"); function getScopeQuery(session, config, includeStatus = true) { const baseQuery = includeStatus ? { status: "active" } : {}; return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery; } __name(getScopeQuery, "getScopeQuery"); async function getNextCaveId(ctx, reusableIds) { for (const id of reusableIds) { if (id > 0) { reusableIds.delete(id); return id; } } if (reusableIds.has(0)) { reusableIds.delete(0); const [lastCave] = await ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 }); const newId2 = (lastCave?.id || 0) + 1; reusableIds.add(0); return newId2; } const allCaveIds = (await ctx.database.get("cave", {}, { fields: ["id"] })).map((c) => c.id); const existingIds = new Set(allCaveIds); let newId = 1; while (existingIds.has(newId)) newId++; if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) reusableIds.add(0); return newId; } __name(getNextCaveId, "getNextCaveId"); async function processMessageElements(sourceElements, newId, session, config, logger2) { const mediaToSave = []; let mediaIndex = 0; const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply", "face": "face" }; const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" }; async function transform(elements) { const result = []; async function processForwardContent(segments) { const innerResult = []; for (const segment of segments) { const sType = typeMap[segment.type]; if (!sType) continue; if (sType === "text" && segment.data?.text?.trim()) { innerResult.push({ type: "text", content: segment.data.text.trim() }); } else if (sType === "at" && (segment.data?.id || segment.data?.qq)) { innerResult.push({ type: "at", content: segment.data.id || segment.data.qq }); } else if (sType === "reply" && segment.data?.id) { innerResult.push({ type: "reply", content: segment.data.id }); } else if (["image", "video", "audio", "file"].includes(sType) && (segment.data?.src || segment.data?.url)) { let fileIdentifier = segment.data.src || segment.data.url; if (fileIdentifier.startsWith("http")) { const ext = path2.extname(segment.data.file || "") || defaultExtMap[sType]; const currentMediaIndex = ++mediaIndex; const fileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}${ext}`; mediaToSave.push({ sourceUrl: fileIdentifier, fileName }); fileIdentifier = fileName; } innerResult.push({ type: sType, file: fileIdentifier }); } else if (sType === "forward" && Array.isArray(segment.data?.content)) { const nestedForwardNodes = []; for (const nestedNode of segment.data.content) { if (!nestedNode.message || !Array.isArray(nestedNode.message)) continue; const nestedContentElements = await processForwardContent(nestedNode.message); if (nestedContentElements.length > 0) { nestedForwardNodes.push({ userId: nestedNode.sender?.user_id, userName: nestedNode.sender?.nickname, elements: nestedContentElements }); } } if (nestedForwardNodes.length > 0) innerResult.push({ type: "forward", content: nestedForwardNodes }); } } return innerResult; } __name(processForwardContent, "processForwardContent"); for (const el of elements) { const type = typeMap[el.type]; if (!type) { if (el.children) result.push(...await transform(el.children)); continue; } if (type === "text" && el.attrs.content?.trim()) { result.push({ type: "text", content: el.attrs.content.trim() }); } else if (type === "at" && el.attrs.id) { result.push({ type: "at", content: el.attrs.id }); } else if (type === "reply" && el.attrs.id) { result.push({ type: "reply", content: el.attrs.id }); } else if (type === "forward" && Array.isArray(el.attrs.content)) { const forwardNodes = []; for (const node of el.attrs.content) { if (!node.message || !Array.isArray(node.message)) continue; const contentElements = await processForwardContent(node.message); if (contentElements.length > 0) { forwardNodes.push({ userId: node.sender?.user_id, userName: node.sender?.nickname, elements: contentElements }); } } if (forwardNodes.length > 0) result.push({ type: "forward", content: forwardNodes }); } else if (["image", "video", "audio", "file"].includes(type) && el.attrs.src) { let fileIdentifier = el.attrs.src; if (fileIdentifier.startsWith("http")) { const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type]; const currentMediaIndex = ++mediaIndex; const fileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}${ext}`; mediaToSave.push({ sourceUrl: fileIdentifier, fileName }); fileIdentifier = fileName; } result.push({ type, file: fileIdentifier }); } else if (type === "face" && el.attrs.id) { result.push({ type: "face", content: el.attrs.id }); } } return result; } __name(transform, "transform"); const finalElementsForDb = await transform(sourceElements); return { finalElementsForDb, mediaToSave }; } __name(processMessageElements, "processMessageElements"); async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToToSave, reusableIds, session, hashManager, textHashesToStore) { try { const downloadedMedia = []; const imageHashesToStore = []; const allExistingImageHashes = hashManager ? await ctx.database.get("cave_hash", { type: "phash" }) : []; for (const media of mediaToToSave) { const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 })); downloadedMedia.push({ fileName: media.fileName, buffer }); if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) { const imageHash = await hashManager.generatePHash(buffer, 256); for (const existing of allExistingImageHashes) { const similarity = hashManager.calculateSimilarity(imageHash, existing.hash); if (similarity >= config.imageThreshold) { await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`); await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]); cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds); return; } } imageHashesToStore.push({ hash: imageHash, type: "phash" }); } } await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer))); const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1]; const finalStatus = needsReview ? "pending" : "active"; await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]); if (hashManager) { const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id })); if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert); } if (finalStatus === "pending" && reviewManager) { const [finalCave] = await ctx.database.get("cave", { id: cave.id }); if (finalCave) reviewManager.sendForPend(finalCave); } } catch (fileProcessingError) { logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError); await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]); cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds); } } __name(handleFileUploads, "handleFileUploads"); // src/PendManager.ts var PendManager = class { /** * @param ctx Koishi 上下文。 * @param config 插件配置。 * @param fileManager 文件管理器实例。 * @param logger 日志记录器实例。 * @param reusableIds 可复用 ID 的内存缓存。 */ constructor(ctx, config, fileManager, logger2, reusableIds) { this.ctx = ctx; this.config = config; this.fileManager = fileManager; this.logger = logger2; this.reusableIds = reusableIds; } static { __name(this, "PendManager"); } /** * @description 注册与审核相关的子命令。 * @param cave - 主 `cave` 命令实例。 */ registerCommands(cave) { const requireAdmin = /* @__PURE__ */ __name((session) => { if (session.channelId !== this.config.adminChannel?.split(":")[1]) return "此指令仅限在管理群组中使用"; return null; }, "requireAdmin"); const pend = cave.subcommand(".pend [id:posint]", "审核回声洞", { hidden: true }).usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => { const adminError = requireAdmin(session); if (adminError) return adminError; if (id) { const [targetCave] = await this.ctx.database.get("cave", { id }); if (!targetCave) return `回声洞(${id})不存在`; if (targetCave.status !== "pending") return `回声洞(${id})无需审核`; const caveMessages = await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger, session.platform, "待审核"); for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi2.h.normalize(message)); return; } const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] }); if (!pendingCaves.length) return "当前没有需要审核的回声洞"; return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为: ${pendingCaves.map((c) => c.id).join("|")}`; }); const createPendAction = /* @__PURE__ */ __name((actionType) => async ({ session }, ...ids) => { const adminError = requireAdmin(session); if (adminError) return adminError; let idsToProcess = ids; if (idsToProcess.length === 0) { const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] }); if (!pendingCaves.length) return "当前没有需要审核的回声洞"; idsToProcess = pendingCaves.map((c) => c.id); } try { const targetStatus = actionType === "approve" ? "active" : "delete"; const actionText = actionType === "approve" ? "通过" : "拒绝"; const cavesToProcess = await this.ctx.database.get("cave", { id: { $in: idsToProcess }, status: "pending" }); if (cavesToProcess.length === 0) return `回声洞(${idsToProcess.join("|")})无需审核或不存在`; const processedIds = cavesToProcess.map((cave2) => cave2.id); await this.ctx.database.upsert("cave", processedIds.map((id) => ({ id, status: targetStatus }))); if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds); return `已${actionText}回声洞(${processedIds.join("|")})`; } catch (error) { this.logger.error(`审核操作失败:`, error); return `操作失败: ${error.message}`; } }, "createPendAction"); pend.subcommand(".Y [...ids:posint]", "通过审核").usage("通过一个或多个指定 ID 的回声洞审核。若不指定 ID,则通过所有待审核的回声洞。").action(createPendAction("approve")); pend.subcommand(".N [...ids:posint]", "拒绝审核").usage("拒绝一个或多个指定 ID 的回声洞审核。若不指定 ID,则拒绝所有待审核的回声洞。").action(createPendAction("reject")); } /** * @description 将新回声洞提交到管理群组以供审核。 * @param cave 新创建的、状态为 'pending' 的回声洞对象。 */ async sendForPend(cave) { if (!this.config.adminChannel?.includes(":")) { this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`); await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]); return; } try { const [platform] = this.config.adminChannel.split(":", 1); const caveMessages = await buildCaveMessage(cave, this.config, this.fileManager, this.logger, platform, "待审核"); for (const message of caveMessages) if (message.length > 0) await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(message)); } catch (error) { this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error); } } }; // src/HashManager.ts var import_sharp = __toESM(require("sharp")); var crypto = __toESM(require("crypto")); var HashManager = class { /** * @constructor * @param ctx - Koishi 上下文,用于数据库操作。 * @param config - 插件配置,用于获取相似度阈值等。 * @param logger - 日志记录器实例。 * @param fileManager - 文件管理器实例,用于读取图片文件。 */ constructor(ctx, config, logger2, fileManager) { this.ctx = ctx; this.config = config; this.logger = logger2; this.fileManager = fileManager; this.ctx.model.extend("cave_hash", { cave: "unsigned", hash: "string", type: "string" }, { primary: ["cave", "hash", "type"], indexes: ["type"] }); } static { __name(this, "HashManager"); } /** * @description 注册与哈希功能相关的 `.hash` 和 `.check` 子命令。 * @param cave - 主 `cave` 命令实例。 */ registerCommands(cave) { const adminCheck = /* @__PURE__ */ __name(({ session }) => { const adminChannelId = this.config.adminChannel?.split(":")[1]; if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用"; }, "adminCheck"); cave.subcommand(".hash", "校验回声洞", { hidden: true, authority: 3 }).usage("校验缺失哈希的回声洞,补全哈希记录。").action(async (argv) => { const checkResult = adminCheck(argv); if (checkResult) return checkResult; await argv.session.send("正在处理,请稍候..."); try { return await this.generateHashesForHistoricalCaves(); } catch (error) { this.logger.error("生成历史哈希失败:", error); return `操作失败: ${error.message}`; } }); cave.subcommand(".check", "检查相似度", { hidden: true }).usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async (argv) => { const checkResult = adminCheck(argv); if (checkResult) return checkResult; await argv.session.send("正在检查,请稍候..."); try { return await this.checkForSimilarCaves(argv.options); } catch (error) { this.logger.error("检查相似度失败:", error); return `检查失败: ${error.message}`; } }); } /** * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。 * @returns 一个包含操作结果的报告字符串。 */ async generateHashesForHistoricalCaves() { const allCaves = await this.ctx.database.get("cave", { status: "active" }); const existingHashes = await this.ctx.database.get("cave_hash", {}); const existingHashSet = new Set(existingHashes.map((h4) => `${h4.cave}-${h4.hash}-${h4.type}`)); if (allCaves.length === 0) return "无需补全回声洞哈希"; this.logger.info(`开始补全 ${allCaves.length} 个回声洞的哈希...`); let hashesToInsert = []; let processedCaveCount = 0; let totalHashesGenerated = 0; let errorCount = 0; const flushBatch = /* @__PURE__ */ __name(async () => { if (hashesToInsert.length === 0) return; await this.ctx.database.upsert("cave_hash", hashesToInsert); totalHashesGenerated += hashesToInsert.length; this.logger.info(`[${processedCaveCount}/${allCaves.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`); hashesToInsert = []; }, "flushBatch"); for (const cave of allCaves) { processedCaveCount++; try { const newHashesForCave = await this.generateAllHashesForCave(cave); for (const hashObj of newHashesForCave) { const uniqueKey = `${hashObj.cave}-${hashObj.hash}-${hashObj.type}`; if (!existingHashSet.has(uniqueKey)) { hashesToInsert.push(hashObj); existingHashSet.add(uniqueKey); } } if (hashesToInsert.length >= 100) await flushBatch(); } catch (error) { errorCount++; this.logger.warn(`补全回声洞(${cave.id})哈希时发生错误: ${error.message}`); } } await flushBatch(); return `已补全 ${allCaves.length} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`; } /** * @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。 * @param cave - 回声洞对象。 * @returns 生成的哈希对象数组。 */ async generateAllHashesForCave(cave) { const tempHashes = []; const uniqueHashTracker = /* @__PURE__ */ new Set(); const addUniqueHash = /* @__PURE__ */ __name((hashObj) => { const key = `${hashObj.hash}-${hashObj.type}`; if (!uniqueHashTracker.has(key)) { tempHashes.push(hashObj); uniqueHashTracker.add(key); } }, "addUniqueHash"); const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "); if (combinedText) { const textHash = this.generateTextSimhash(combinedText); if (textHash) addUniqueHash({ cave: cave.id, hash: textHash, type: "simhash" }); } for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) { try { const imageBuffer = await this.fileManager.readFile(el.file); const imageHash = await this.generatePHash(imageBuffer, 256); addUniqueHash({ cave: cave.id, hash: imageHash, type: "phash" }); } catch (e) { this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, e); } } return tempHashes; } /** * @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。 * @param options 包含临时阈值的可选对象。 * @returns 一个包含检查结果的报告字符串。 */ async checkForSimilarCaves(options = {}) { const textThreshold = options.textThreshold ?? this.config.textThreshold; const imageThreshold = options.imageThreshold ?? this.config.imageThreshold; const allHashes = await this.ctx.database.get("cave_hash", {}); const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))]; const textHashes = /* @__PURE__ */ new Map(); const imageHashes = /* @__PURE__ */ new Map(); for (const hash of allHashes) { if (hash.type === "simhash") { textHashes.set(hash.cave, hash.hash); } else if (hash.type === "phash") { imageHashes.set(hash.cave, hash.hash); } } const similarPairs = { text: /* @__PURE__ */ new Set(), image: /* @__PURE__ */ new Set() }; for (let i = 0; i < allCaveIds.length; i++) { for (let j = i + 1; j < allCaveIds.length; j++) { const id1 = allCaveIds[i]; const id2 = allCaveIds[j]; const pair = [id1, id2].sort((a, b) => a - b).join(" & "); const text1 = textHashes.get(id1); const text2 = textHashes.get(id2); if (text1 && text2) { const similarity = this.calculateSimilarity(text1, text2); if (similarity >= textThreshold) similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`); } const image1 = imageHashes.get(id1); const image2 = imageHashes.get(id2); if (image1 && image2) { const similarity = this.calculateSimilarity(image1, image2); if (similarity >= imageThreshold) similarPairs.image.add(`${pair} = ${similarity.toFixed(2)}%`); } } } const totalFindings = similarPairs.text.size + similarPairs.image.size; if (totalFindings === 0) return "未发现高相似度的内容"; let report = `已发现 ${totalFindings} 组高相似度的内容:`; if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n"); if (similarPairs.image.size > 0) report += "\n图片内容相似:\n" + [...similarPairs.image].join("\n"); return report.trim(); } /** * @description 执行二维离散余弦变换 (DCT-II)。 * @param matrix - 输入的 N x N 像素亮度矩阵。 * @returns DCT变换后的 N x N 系数矩阵。 */ _dct2D(matrix) { const N = matrix.length; if (N === 0) return []; const cosines = Array.from( { length: N }, (_, i) => Array.from({ length: N }, (_2, j) => Math.cos(Math.PI * (2 * i + 1) * j / (2 * N))) ); const applyDct1D = /* @__PURE__ */ __name((input) => { const output = new Array(N).fill(0); const scale = Math.sqrt(2 / N); for (let k = 0; k < N; k++) { let sum = 0; for (let n = 0; n < N; n++) sum += input[n] * cosines[n][k]; output[k] = scale * sum; } output[0] /= Math.sqrt(2); return output; }, "applyDct1D"); const tempMatrix = matrix.map((row) => applyDct1D(row)); const transposed = tempMatrix[0].map((_, col) => tempMatrix.map((row) => row[col])); const dctResult = transposed.map((row) => applyDct1D(row)); return dctResult[0].map((_, col) => dctResult.map((row) => row[col])); } /** * @description pHash 算法核心实现。 * @param imageBuffer - 图片的Buffer。 * @param size - 期望的哈希位数 (必须是完全平方数, 如 64 或 256)。 * @returns 十六进制pHash字符串。 */ async generatePHash(imageBuffer, size) { const dctSize = 32; const hashGridSize = Math.sqrt(size); if (!Number.isInteger(hashGridSize)) throw new Error("哈希位数必须是完全平方数"); const pixels = await (0, import_sharp.default)(imageBuffer).grayscale().resize(dctSize, dctSize, { fit: "fill" }).raw().toBuffer(); const matrix = []; for (let y = 0; y < dctSize; y++) matrix.push(Array.from(pixels.slice(y * dctSize, (y + 1) * dctSize))); const dctMatrix = this._dct2D(matrix); const coefficients = []; for (let y = 0; y < hashGridSize; y++) for (let x = 0; x < hashGridSize; x++) coefficients.push(dctMatrix[y][x]); const median = [...coefficients.slice(1)].sort((a, b) => a - b)[Math.floor((coefficients.length - 1) / 2)]; const binaryHash = coefficients.map((val) => val > median ? "1" : "0").join(""); return BigInt("0b" + binaryHash).toString(16).padStart(size / 4, "0"); } /** * @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。 * @param hex1 - 第一个哈希。 * @param hex2 - 第二个哈希。 * @returns 汉明距离。 */ calculateHammingDistance(hex1, hex2) { let distance = 0; const bin1 = hexToBinary(hex1); const bin2 = hexToBinary(hex2); const len = Math.min(bin1.length, bin2.length); for (let i = 0; i < len; i++) if (bin1[i] !== bin2[i]) distance++; return distance; } /** * @description 根据汉明距离计算相似度百分比。 * @param hex1 - 第一个哈希。 * @param hex2 - 第二个哈希。 * @returns 相似度 (0-100)。 */ calculateSimilarity(hex1, hex2) { const distance = this.calculateHammingDistance(hex1, hex2); const hashLength = Math.max(hex1.length, hex2.length) * 4; return hashLength === 0 ? 100 : (1 - distance / hashLength) * 100; } /** * @description 为文本生成 64 位 Simhash 字符串。 * @param text - 需要处理的文本。 * @returns 16位十六进制 Simhash 字符串。 */ generateTextSimhash(text) { const cleanText = (text || "").toLowerCase().replace(/\s+/g, ""); if (!cleanText) return ""; const n = 2; const tokens = /* @__PURE__ */ new Set(); if (cleanText.length < n) { tokens.add(cleanText); } else { for (let i = 0; i <= cleanText.length - n; i++) tokens.add(cleanText.substring(i, i + n)); } const tokenArray = Array.from(tokens); if (tokenArray.length === 0) return ""; const vector = new Array(64).fill(0); tokenArray.forEach((token) => { const hash = crypto.createHash("md5").update(token).digest(); for (let i = 0; i < 64; i++) vector[i] += hash[Math.floor(i / 8)] >> i % 8 & 1 ? 1 : -1; }); const binaryHash = vector.map((v) => v > 0 ? "1" : "0").join(""); return BigInt("0b" + binaryHash).toString(16).padStart(16, "0"); } }; function hexToBinary(hex) { let bin = ""; for (const char of hex) bin += parseInt(char, 16).toString(2).padStart(4, "0"); return bin; } __name(hexToBinary, "hexToBinary"); // src/index.ts var name = "best-cave"; var inject = ["database"]; var usage = ` <div style="border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);"> <h2 style="margin-top: 0; color: #4a6ee0;">📌 插件说明</h2> <p>📖 <strong>使用文档</strong>:请点击左上角的 <strong>插件主页</strong> 查看插件使用文档</p> <p>🔍 <strong>更多插件</strong>:可访问 <a href="https://github.com/YisRime" style="color:#4a6ee0;text-decoration:none;">苡淞的 GitHub</a> 查看本人的所有插件</p> </div> <div style="border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);"> <h2 style="margin-top: 0; color: #e0574a;">❤️ 支持与反馈</h2> <p>🌟 喜欢这个插件?请在 <a href="https://github.com/YisRime" style="color:#e0574a;text-decoration:none;">GitHub</a> 上给我一个 Star!</p> <p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p> </div> `; var logger = new import_koishi3.Logger("best-cave"); var Config = import_koishi3.Schema.intersect([ import_koishi3.Schema.object({ perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"), enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"), enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"), adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"), caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本(参见 README)") }).description("基础配置"), import_koishi3.Schema.object({ enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"), enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"), textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("文本相似度阈值 (%)"), imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("图片相似度阈值 (%)") }).description("复核配置"), import_koishi3.Schema.object({ localPath: import_koishi3.Schema.string().description("文件映射路径"), enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"), publicUrl: import_koishi3.Schema.string().description("公共访问 URL").role("link"), endpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link"), bucket: import_koishi3.Schema.string().description("存储桶 (Bucket)"), region: import_koishi3.Schema.string().default("auto").description("区域 (Region)"), accessKeyId: import_koishi3.Schema.string().description("Access Key ID").role("secret"), secretAccessKey: import_koishi3.Schema.string().description("Secret Access Key").role("secret") }).description("存储配置") ]); function apply(ctx, config) { ctx.model.extend("cave", { id: "unsigned", elements: "json", channelId: "string", userId: "string", userName: "string", status: "string", time: "timestamp" }, { primary: "id", indexes: ["status", "channelId", "userId"] }); const fileManager = new FileManager(ctx.baseDir, config, logger); const reusableIds = /* @__PURE__ */ new Set(); const profileManager = config.enableName ? new NameManager(ctx) : null; const reviewManager = config.enablePend ? new PendManager(ctx, config, fileManager, logger, reusableIds) : null; const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null; const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger) : null; ctx.on("ready", async () => { try { const staleCaves = await ctx.database.get("cave", { status: "preload" }); if (staleCaves.length > 0) { const idsToMark = staleCaves.map((c) => ({ id: c.id, status: "delete" })); await ctx.database.upsert("cave", idsToMark); await cleanupPendingDeletions(ctx, fileManager, logger, reusableIds); } } catch (error) { logger.error("清理残留回声洞时发生错误:", error); } }); const cave = ctx.command("cave", "回声洞").option("add", "-a <content:text> 添加回声洞").option("view", "-g <id:posint> 查看指定回声洞").option("delete", "-r <id:posint> 删除指定回声洞").option("list", "-l 查询投稿统计").usage("随机抽取一条已添加的回声洞。").action(async ({ session, options }) => { if (options.add) return session.execute(`cave.add ${options.add}`); if (options.view) return session.execute(`cave.view ${options.view}`); if (options.delete) return session.execute(`cave.del ${options.delete}`); if (options.list) return session.execute("cave.list"); try { const query = getScopeQuery(session, config); const candidates = await ctx.database.get("cave", query, { fields: ["id"] }); if (!candidates.length) return `当前${config.perChannel && session.channelId ? "本群" : ""}还没有任何回声洞`; const randomId = candidates[Math.floor(Math.random() * candidates.length)].id; const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId }); const messages = await buildCaveMessage(randomCave, config, fileManager, logger, session.platform); for (const message of messages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message)); } catch (error) { logger.error("随机获取回声洞失败:", error); return "随机获取回声洞失败"; } }); cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => { try { let sourceElements; if (session.quote?.elements) { sourceElements = session.quote.elements; } else if (content?.trim()) { sourceElements = import_koishi3.h.parse(content); } else { await session.send("请在一分钟内发送你要添加的内容"); const reply = await session.prompt(6e4); if (!reply) return "等待操作超时"; sourceElements = import_koishi3.h.parse(reply); } const newId = await getNextCaveId(ctx, reusableIds); const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, config, logger); if (finalElementsForDb.length === 0) return "无可添加内容"; const textHashesToStore = []; if (hashManager) { const combinedText = finalElementsForDb.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" "); if (combinedText) { const newSimhash = hashManager.generateTextSimhash(combinedText); if (newSimhash) { const existingTextHashes = await ctx.database.get("cave_hash", { type: "simhash" }); for (const existing of existingTextHashes) { const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash); if (similarity >= config.textThreshold) return `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`; } textHashesToStore.push({ hash: newSimhash, type: "simhash" }); } } } const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username; const hasMedia = mediaToSave.length > 0; const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1]; const initialStatus = hasMedia ? "preload" : needsReview ? "pending" : "active"; const newCave = await ctx.database.create("cave", { id: newId, elements: finalElementsForDb, channelId: session.channelId, userId: session.userId, userName, status: initialStatus, time: /* @__PURE__ */ new Date() }); if (hasMedia) { handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore); } else { if (hashManager && textHashesToStore.length > 0) await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id }))); if (initialStatus === "pending") reviewManager.sendForPend(newCave); } return needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`; } catch (error) { logger.error("添加回声洞失败:", error); return "添加失败,请稍后再试"; } }); cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => { if (!id) return "请输入要查看的回声洞序号"; try { const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id }); if (!targetCave) return `回声洞(${id})不存在`; const messages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform); for (const message of messages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message)); } catch (error) { logger.error(`查看回声洞(${id})失败:`, error); return "查看失败,请稍后再试"; } }); cave.subcommand(".del <id:posint>", "删除指定回声洞").action(async ({ session }, id) => { if (!id) return "请输入要删除的回声洞序号"; try { const [targetCave] = await ctx.database.get("cave", { id, status: "active" }); if (!targetCave) return `回声洞(${id})不存在`; const isAuthor = targetCave.userId === session.userId; const isAdmin = session.channelId === config.adminChannel?.split(":")[1]; if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞"; await ctx.database.upsert("cave", [{ id, status: "delete" }]); const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除"); cleanupPendingDeletions(ctx, fileManager, logger, reusableIds); for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message)); } catch (error) { logger.error(`标记回声洞(${id})失败:`, error); return "删除失败,请稍后再试"; } }); cave.subcommand(".list", "查询投稿统计").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => { if (options.all) { const adminChannelId = config.adminChannel?.split(":")[1]; if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用"; try { const aggregatedStats = await ctx.database.select("cave", { status: "a