UNPKG

koishi-plugin-best-cave

Version:

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

1,156 lines (1,148 loc) 92.2 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 commandAction = /* @__PURE__ */ __name((action) => async ({ session }) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; try { await session.send("正在处理,请稍候..."); return await action(); } catch (error) { this.logger.error("数据操作时发生错误:", error); return `操作失败: ${error.message}`; } }, "commandAction"); cave.subcommand(".export", "导出回声洞数据", { hidden: true, authority: 4 }).usage("将所有回声洞数据导出到 cave.json 中。").action(commandAction(() => this.exportData())); cave.subcommand(".import", "导入回声洞数据", { hidden: true, authority: 4 }).usage("从 cave.json 中导入回声洞数据。").action(commandAction(() => this.importData())); } /** * @description 导出所有 'active' 状态的回声洞数据到 `cave.json`。 * @returns 描述导出结果的消息字符串。 */ async exportData() { const fileName = "cave.json"; const cavesToExport = await this.ctx.database.get("cave", { status: "active" }); await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(cavesToExport, null, 2))); return `成功导出 ${cavesToExport.length} 条数据`; } /** * @description 从 `cave.json` 文件导入回声洞数据。 * @returns 描述导入结果的消息字符串。 */ async importData() { const fileName = "cave.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 allDbCaves = await this.ctx.database.get("cave", {}, { fields: ["id"] }); const existingIds = new Set(allDbCaves.map((c) => c.id)); let maxId = allDbCaves.length > 0 ? Math.max(...allDbCaves.map((c) => c.id)) : 0; const nonConflictingCaves = []; const conflictingCaves = []; let invalidCount = 0; for (const importedCave of importedCaves) { if (typeof importedCave.id !== "number" || !Array.isArray(importedCave.elements)) { this.logger.warn(`回声洞(${importedCave.id})无效: ${JSON.stringify(importedCave)}`); invalidCount++; continue; } if (existingIds.has(importedCave.id)) { conflictingCaves.push(importedCave); } else { nonConflictingCaves.push({ ...importedCave, status: "active" }); existingIds.add(importedCave.id); maxId = Math.max(maxId, importedCave.id); } } const newCavesFromConflicts = conflictingCaves.map((cave) => { maxId++; this.logger.info(`回声洞(${cave.id})已转移至(${maxId})`); return { ...cave, id: maxId, status: "active" }; }); const finalCavesToUpsert = [...nonConflictingCaves, ...newCavesFromConflicts]; if (finalCavesToUpsert.length > 0) await this.ctx.database.upsert("cave", finalCavesToUpsert); return `成功导入 ${finalCavesToUpsert.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 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, creationTime) { const mediaToSave = []; const urlToFileMap = /* @__PURE__ */ new Map(); 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")) { if (urlToFileMap.has(fileIdentifier)) { fileIdentifier = urlToFileMap.get(fileIdentifier); } else { const ext = path2.extname(segment.data.file || "") || defaultExtMap[sType]; const currentMediaIndex = ++mediaIndex; const newFileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}_${creationTime.getTime()}${ext}`; mediaToSave.push({ sourceUrl: fileIdentifier, fileName: newFileName }); urlToFileMap.set(fileIdentifier, newFileName); fileIdentifier = newFileName; } } 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")) { if (urlToFileMap.has(fileIdentifier)) { fileIdentifier = urlToFileMap.get(fileIdentifier); } else { const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type]; const currentMediaIndex = ++mediaIndex; const newFileName = `${newId}-${currentMediaIndex}_${session.channelId}-${session.userId}_${creationTime.getTime()}${ext}`; mediaToSave.push({ sourceUrl: fileIdentifier, fileName: newFileName }); urlToFileMap.set(fileIdentifier, newFileName); fileIdentifier = newFileName; } } 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"); function clusterItemsFromPairs(pairs) { const parent = /* @__PURE__ */ new Map(); const allIds = /* @__PURE__ */ new Set(); const find = /* @__PURE__ */ __name((i) => { if (!parent.has(i)) { parent.set(i, i); return i; } if (parent.get(i) === i) return i; const root = find(parent.get(i)); parent.set(i, root); return root; }, "find"); const union = /* @__PURE__ */ __name((i, j) => { const rootI = find(i); const rootJ = find(j); if (rootI !== rootJ) parent.set(rootI, rootJ); }, "union"); for (const [id1, id2] of pairs) { union(id1, id2); allIds.add(id1); allIds.add(id2); } if (allIds.size === 0) return []; const clusterMap = /* @__PURE__ */ new Map(); allIds.forEach((id) => { const root = find(id); if (!clusterMap.has(root)) clusterMap.set(root, []); clusterMap.get(root).push(id); }); return Array.from(clusterMap.values()).filter((c) => c.length > 1); } __name(clusterItemsFromPairs, "clusterItemsFromPairs"); function generateFromLSH(items, getBucketInfo) { const buckets = /* @__PURE__ */ new Map(); items.forEach((item) => { const { id, keys } = getBucketInfo(item); if (!id || !keys || keys.length === 0) return; keys.forEach((key) => { if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(id); }); }); const candidatePairs = /* @__PURE__ */ new Set(); for (const ids of buckets.values()) { if (ids.length < 2) continue; const uniqueIds = [...new Set(ids)].sort((a, b) => a - b); if (uniqueIds.length < 2) continue; for (let i = 0; i < uniqueIds.length; i++) { for (let j = i + 1; j < uniqueIds.length; j++) { const pairKey = `${uniqueIds[i]}-${uniqueIds[j]}`; candidatePairs.add(pairKey); } } } return candidatePairs; } __name(generateFromLSH, "generateFromLSH"); async function processNewCave(ctx, config, fileManager, logger2, reusableIds, newCave, session, mediaToSave, hashManager, aiManager, reviewManager) { const newId = newCave.id; try { const initialDownloads = []; if (mediaToSave.length > 0) { const downloadPromises = mediaToSave.map(async (media) => { const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 6e4 })); return { candidateFile: media.fileName, buffer }; }); initialDownloads.push(...await Promise.all(downloadPromises)); } const mediaForProcessing = []; const fileRemapping = /* @__PURE__ */ new Map(); const canonicalFilesToSave = /* @__PURE__ */ new Map(); if (hashManager) { const hashToCanonicalFile = /* @__PURE__ */ new Map(); const hashableExtensions = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif"]; const sanitizableExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]; for (const { candidateFile, buffer } of initialDownloads) { const ext = path2.extname(candidateFile).toLowerCase(); let finalBuffer = buffer; let hash; if (hashableExtensions.includes(ext)) { try { hash = await hashManager.generatePHash(finalBuffer); } catch { if (sanitizableExtensions.includes(ext)) { try { const sanitized = hashManager.sanitizeImageBuffer(finalBuffer); if (!sanitized.equals(finalBuffer)) { finalBuffer = sanitized; hash = await hashManager.generatePHash(finalBuffer); } } catch (e) { logger2.warn(`图片修复失败 (${candidateFile}): ${e.message}`); } } } } if (hash && hashToCanonicalFile.has(hash)) { fileRemapping.set(candidateFile, hashToCanonicalFile.get(hash)); continue; } if (hash) hashToCanonicalFile.set(hash, candidateFile); canonicalFilesToSave.set(candidateFile, finalBuffer); fileRemapping.set(candidateFile, candidateFile); mediaForProcessing.push({ fileName: candidateFile, buffer: finalBuffer, hash }); } newCave.elements.forEach((el) => { if (el.file && fileRemapping.has(el.file)) el.file = fileRemapping.get(el.file); }); } else { initialDownloads.forEach((d) => { canonicalFilesToSave.set(d.candidateFile, d.buffer); mediaForProcessing.push({ fileName: d.candidateFile, buffer: d.buffer }); }); } let textHashesToStore = []; let imageHashesToStore = []; if (config.enableSimilarity && hashManager) { try { const combinedText = newCave.elements.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: "text" }); for (const existing of existingTextHashes) { const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash); if (similarity >= config.textThreshold) { await session.send(`回声洞(${newId})添加失败:文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`); await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]); return; } } textHashesToStore.push({ hash: newSimhash, type: "text" }); } } const imagesWithHash = mediaForProcessing.filter((m) => m.hash !== void 0); if (imagesWithHash.length > 0) { const dbImageHashes = await ctx.database.get("cave_hash", { type: "image" }); const newImageHashes = /* @__PURE__ */ new Set(); for (const media of imagesWithHash) { const imageHash = media.hash; if (newImageHashes.has(imageHash)) continue; for (const existing of dbImageHashes) { const similarity = hashManager.calculateSimilarity(imageHash, existing.hash); if (similarity >= config.imageThreshold) { await session.send(`回声洞(${newId})添加失败:图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`); await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]); return; } } newImageHashes.add(imageHash); } imageHashesToStore = Array.from(newImageHashes).map((hash) => ({ hash, type: "image" })); } } catch (error) { logger2.warn("相似度比较失败:", error); } } if (canonicalFilesToSave.size > 0) await Promise.all(Array.from(canonicalFilesToSave.entries()).map(([fileName, buffer]) => fileManager.saveFile(fileName, buffer))); let analysisResult; if (config.enableAI && aiManager) { const analyses = await aiManager.analyze([newCave], mediaForProcessing); if (analyses.length > 0) { analysisResult = analyses[0]; await ctx.database.upsert("cave_meta", analyses); const duplicateIds = await aiManager.checkForDuplicates(analysisResult, newCave); if (duplicateIds?.length > 0) { await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateIds.join("|")})重复`); await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]); return; } } } if (config.enableSimilarity && hashManager) { const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: newCave.id })); if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert); } let finalStatus = "active"; const needsManualReview = config.enablePend && session.cid !== config.adminChannel; if (needsManualReview) { if (config.enableAI && config.enableApprove && analysisResult) { if (analysisResult.rating >= config.approveThreshold) { finalStatus = "active"; } else if (config.onAIReviewFail) { finalStatus = "pending"; } else { await session.send(`回声洞(${newId})添加失败:AI 审核未通过 (评分: ${analysisResult.rating})`); await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]); return; } } else { finalStatus = "pending"; } } await ctx.database.upsert("cave", [{ id: newId, status: finalStatus, elements: newCave.elements }]); if (finalStatus === "pending" && reviewManager) reviewManager.sendForPend({ ...newCave, status: finalStatus }); } catch (error) { logger2.error(`回声洞(${newId})处理失败:`, error); await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]); await session.send(`回声洞(${newId})处理失败: ${error.message}`); } } __name(processNewCave, "processNewCave"); // src/PendManager.ts var PendManager = class { /** * @param ctx Koishi 上下文。 * @param config 插件配置。 * @param fileManager 文件管理器实例。 * @param logger 日志记录器实例。 * @param reusableIds 可复用 ID 的内存缓存。 */ constructor(ctx, config, fileManager, logger2) { this.ctx = ctx; this.config = config; this.fileManager = fileManager; this.logger = logger2; } static { __name(this, "PendManager"); } /** * @description 注册与审核相关的子命令。 * @param cave - 主 `cave` 命令实例。 */ registerCommands(cave) { const pend = cave.subcommand(".pend [id:posint]", "审核回声洞", { hidden: true }).usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; 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) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; 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 }))); 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")); if (this.config.enableAI) { pend.subcommand(".A <threshold:number>", "自动通过审核").usage("根据评分自动通过不小于指定阈值的回声洞。默认使用配置中的阈值。").action(async ({ session }, threshold) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; const finalThreshold = threshold ?? this.config.approveThreshold; try { const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }); if (pendingCaves.length === 0) return "当前没有需要审核的回声洞"; const pendingCaveIds = pendingCaves.map((c) => c.id); const pendingMeta = await this.ctx.database.get("cave_meta", { cave: { $in: pendingCaveIds } }); const idsToApprove = pendingMeta.filter((meta) => meta.rating >= finalThreshold).map((meta) => meta.cave); if (idsToApprove.length === 0) return `没有找到评分不小于 ${finalThreshold} 的待审核回声洞`; await this.ctx.database.upsert("cave", idsToApprove.map((id) => ({ id, status: "active" }))); return `已自动通过回声洞(${idsToApprove.join("|")})`; } catch (error) { this.logger.error("自动审核操作失败:", error); return `操作失败: ${error.message}`; } }); } } /** * @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_jimp = __toESM(require("jimp")); 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 注册与哈希功能相关的子命令。 * @param cave - 主 `cave` 命令实例。 */ registerCommands(cave) { cave.subcommand(".hash", "校验回声洞", { hidden: true, authority: 3 }).usage("校验缺失哈希的回声洞,补全哈希记录。").action(async ({ session }) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; try { const allCaves = await this.ctx.database.get("cave", { status: "active" }); const existingHashes = await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] }); const hashedCaveIds = new Set(existingHashes.map((h4) => h4.cave)); const cavesToProcess = allCaves.filter((cave2) => !hashedCaveIds.has(cave2.id)); if (cavesToProcess.length === 0) return "无需补全回声洞哈希"; await session.send(`开始补全 ${cavesToProcess.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}/${cavesToProcess.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`); hashesToInsert = []; }, "flushBatch"); for (const cave2 of cavesToProcess) { processedCaveCount++; try { 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 = cave2.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "); if (combinedText) { const textHash = this.generateTextSimhash(combinedText); if (textHash) addUniqueHash({ cave: cave2.id, hash: textHash, type: "text" }); } for (const el of cave2.elements.filter((el2) => el2.type === "image" && el2.file)) { const imageBuffer = await this.fileManager.readFile(el.file); const imageHash = await this.generatePHash(imageBuffer); addUniqueHash({ cave: cave2.id, hash: imageHash, type: "image" }); } const newHashesForCave = tempHashes; if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave); if (hashesToInsert.length >= 100) await flushBatch(); } catch (error) { errorCount++; this.logger.warn(`补全回声洞(${cave2.id})哈希时出错: ${error.message}`); } } await flushBatch(); const successCount = processedCaveCount - errorCount; return `已补全 ${successCount} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`; } 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 ({ session, options }) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; await session.send("正在检查,请稍候..."); try { const textThreshold = options.textThreshold ?? this.config.textThreshold; const imageThreshold = options.imageThreshold ?? this.config.imageThreshold; const allHashes = await this.ctx.database.get("cave_hash", {}); if (allHashes.length < 2) return "无可比较哈希"; const candidatePairs = generateFromLSH(allHashes, (hashObj) => { const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0"); const keys = []; for (let i = 0; i < 4; i++) { const band = binHash.substring(i * 16, (i + 1) * 16); keys.push(`${hashObj.type}:${i}:${band}`); } return { id: hashObj.cave, keys }; }); const hashLookup = /* @__PURE__ */ new Map(); allHashes.forEach((h4) => { if (!hashLookup.has(h4.cave)) hashLookup.set(h4.cave, { text: [], image: [] }); const entry = hashLookup.get(h4.cave); if (h4.type === "text") entry.text.push(h4.hash); else if (h4.type === "image") entry.image.push(h4.hash); }); const textPairs = []; const imagePairs = []; for (const pairKey of candidatePairs) { const [id1, id2] = pairKey.split("-").map(Number); const cave1Hashes = hashLookup.get(id1); const cave2Hashes = hashLookup.get(id2); if (cave1Hashes?.text.length && cave2Hashes?.text.length) { for (const hash1 of cave1Hashes.text) { for (const hash2 of cave2Hashes.text) { const similarity = this.calculateSimilarity(hash1, hash2); if (similarity >= textThreshold) textPairs.push({ id1, id2, similarity }); } } } if (cave1Hashes?.image.length && cave2Hashes?.image.length) { for (const hash1 of cave1Hashes.image) { for (const hash2 of cave2Hashes.image) { const similarity = this.calculateSimilarity(hash1, hash2); if (similarity >= imageThreshold) imagePairs.push({ id1, id2, similarity }); } } } } if (textPairs.length === 0 && imagePairs.length === 0) return "未发现高相似度的内容"; const generateReportForType = /* @__PURE__ */ __name((pairs) => { if (pairs.length === 0) return { reportLines: [], clusters: [] }; const numericPairs = pairs.map((p) => [p.id1, p.id2]); const validClusters = clusterItemsFromPairs(numericPairs); const reportLines = []; validClusters.forEach((cluster) => { const sortedCluster = cluster.sort((a, b) => a - b); const clusterPairs = pairs.filter((p) => cluster.includes(p.id1) && cluster.includes(p.id2)).sort((a, b) => b.similarity - a.similarity); const scores = clusterPairs.map((p) => `${p.similarity.toFixed(2)}%`).join("/"); reportLines.push(`- ${sortedCluster.join("|")} = ${scores}`); }); return { reportLines, clusters: validClusters }; }, "generateReportForType"); const textResult = generateReportForType(textPairs); const imageResult = generateReportForType(imagePairs); const totalClusters = textResult.clusters.length + imageResult.clusters.length; if (totalClusters === 0) return "未发现高相似度的内容"; let report = `共发现 ${totalClusters} 组高相似度的内容:`; if (textResult.reportLines.length > 0) { report += ` [文本相似]`; report += ` ${textResult.reportLines.join("\n")}`; } if (imageResult.reportLines.length > 0) { report += ` [图片相似]`; report += ` ${imageResult.reportLines.join("\n")}`; } return report.trim(); } catch (error) { this.logger.error("检查相似度失败:", error); return `检查失败: ${error.message}`; } }); cave.subcommand(".fix [...ids:posint]", "修复回声洞", { hidden: true, authority: 3 }).usage("扫描并修复回声洞中的图片,可指定一个或多个 ID。").action(async ({ session }, ...ids) => { if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用"; let cavesToProcess; try { await session.send("正在修复,请稍候..."); if (ids.length === 0) { cavesToProcess = await this.ctx.database.get("cave", { status: "active" }); } else { cavesToProcess = await this.ctx.database.get("cave", { id: { $in: ids }, status: "active" }); } if (!cavesToProcess.length) return "无可修复的回声洞"; let fixedFiles = 0; let errorCount = 0; for (const cave2 of cavesToProcess) { const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file); for (const element of imageElements) { try { const originalBuffer = await this.fileManager.readFile(element.file); const sanitizedBuffer = this.sanitizeImageBuffer(originalBuffer); if (!originalBuffer.equals(sanitizedBuffer)) { await this.fileManager.saveFile(element.file, sanitizedBuffer); fixedFiles++; } } catch (error) { if (error.code !== "ENOENT" && error.name !== "NoSuchKey") { this.logger.warn(`无法修复回声洞(${cave2.id})的图片(${element.file}):`, error); errorCount++; } } } } return `已修复 ${cavesToProcess.length} 个回声洞的 ${fixedFiles} 张图片(失败 ${errorCount} 条)`; } catch (error) { this.logger.error("修复图像文件时发生严重错误:", error); return `操作失败: ${error.message}`; } }); } /** * @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。 * @param imageBuffer - 原始的图片 Buffer。 * @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。 */ sanitizeImageBuffer(imageBuffer) { const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const JPEG_SIGNATURE = Buffer.from([255, 216]); const GIF_SIGNATURE = Buffer.from("GIF"); const WEBP_SIGNATURE = Buffer.from("WEBP"); let sanitizedBuffer = imageBuffer; if (imageBuffer.slice(0, 8).equals(PNG_SIGNATURE)) { const IEND_CHUNK = Buffer.from("IEND"); const iendIndex = imageBuffer.lastIndexOf(IEND_CHUNK); if (iendIndex !== -1) { const endOfPngData = iendIndex + 8; if (imageBuffer.length > endOfPngData) sanitizedBuffer = imageBuffer.slice(0, endOfPngData); } } else if (imageBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) { const EOI_MARKER = Buffer.from([255, 217]); const eoiIndex = imageBuffer.lastIndexOf(EOI_MARKER); if (eoiIndex !== -1) { const endOfJpegData = eoiIndex + 2; if (imageBuffer.length > endOfJpegData) sanitizedBuffer = imageBuffer.slice(0, endOfJpegData); } } else if (imageBuffer.slice(0, 3).equals(GIF_SIGNATURE)) { const GIF_TERMINATOR = Buffer.from([59]); const terminatorIndex = imageBuffer.lastIndexOf(GIF_TERMINATOR); if (terminatorIndex !== -1) { const endOfGifData = terminatorIndex + 1; if (imageBuffer.length > endOfGifData) sanitizedBuffer = imageBuffer.slice(0, endOfGifData); } } else if (imageBuffer.slice(8, 12).equals(WEBP_SIGNATURE)) { const fileSize = imageBuffer.readUInt32LE(4); const expectedLength = fileSize + 8; if (imageBuffer.length > expectedLength) sanitizedBuffer = imageBuffer.slice(0, expectedLength); } return sanitizedBuffer; } /** * @description 执行一维离散余弦变换 (DCT-II) 的方法。 * @param input - 输入的数字数组。 * @returns DCT 变换后的数组。 */ dct1D(input) { const N = input.length; const output = new Array(N).fill(0); const c0 = 1 / Math.sqrt(2); for (let k = 0; k < N; k++) { let sum = 0; for (let n = 0; n < N; n++) sum += input[n] * Math.cos(Math.PI * (2 * n + 1) * k / (2 * N)); const ck = k === 0 ? c0 : 1; output[k] = Math.sqrt(2 / N) * ck * sum; } return output; } /** * @description 执行二维离散余弦变换 (DCT-II) 的方法。 * 通过对行和列分别应用一维 DCT 来实现。 * @param matrix - 输入的 N x N 像素亮度矩阵。 * @returns DCT 变换后的 N x N 系数矩阵。 */ dct2D(matrix) { const N = matrix.length; if (N === 0) return []; const tempMatrix = matrix.map((row) => this.dct1D(row)); const transposed = tempMatrix.map((_, colIndex) => tempMatrix.map((row) => row[colIndex])); const dctResultTransposed = transposed.map((row) => this.dct1D(row)); const dctResult = dctResultTransposed.map((_, colIndex) => dctResultTransposed.map((row) => row[colIndex])); return dctResult; } /** * @description pHash 算法核心实现,使用 Jimp 和自定义 DCT。 * @param imageBuffer - 图片的 Buffer。 * @returns 64位十六进制 pHash 字符串。 */ async generatePHash(imageBuffer) { const image = await import_jimp.default.read(imageBuffer); image.resize(32, 32, import_jimp.default.RESIZE_BILINEAR).greyscale(); const matrix = Array.from({ length: 32 }, () => new Array(32).fill(0)); image.scan(0, 0, 32, 32, (x, y, idx) => { matrix[y][x] = image.bitmap.data[idx]; }); const dctMatrix = this.dct2D(matrix); const coefficients = []; for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) coefficients.push(dctMatrix[y][x]); const acCoefficients = coefficients.slice(1); const average = acCoefficients.reduce((sum, val) => sum + val, 0) / acCoefficients.length; let binaryHash = ""; for (const val of coefficients) binaryHash += val > average ? "1" : "0"; return BigInt("0b" + binaryHash).toString(16).padStart(16, "0"); } /** * @description 根据汉明距离计算相似度百分比。 * @param hex1 - 第一个哈希。 * @param hex2 - 第二个哈希。 * @returns 相似度 (0-100)。 */ calculateSimilarity(hex1, hex2) { let distance = 0; let bin1 = ""; for (const char of hex1) bin1 += parseIn