UNPKG

koishi-plugin-best-cave

Version:

回声洞,可自由添加内容(包括视频),具有可配置的 MD5/pHash 查重机制和可开关的审核系统,支持查阅投稿列表

1,383 lines (1,372 loc) 73.7 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 __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; 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/locales/zh-CN.yml var require_zh_CN = __commonJS({ "src/locales/zh-CN.yml"(exports2, module2) { module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", enableTextDuplicate: "启用文本查重", textDuplicateThreshold: "文本相似度阈值(0-1)", enableImageDuplicate: "启用图片查重", imageDuplicateThreshold: "图片相似度阈值(0-1)", imageMaxSize: "图片最大大小(MB)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", l: "查询投稿统计" }, pass: { description: "通过回声洞审核", usage: "通过指定ID的回声洞审核\ncave.pass <ID> - 通过审核\ncave.pass all - 通过所有待审核内容\n" }, reject: { description: "拒绝回声洞审核", usage: "拒绝指定ID的回声洞审核\ncave.reject <ID> - 拒绝审核\ncave.reject all - 拒绝所有待审核内容\n" }, add: { noContent: "请在一分钟内发送内容", operationTimeout: "操作超时,添加取消", videoDisabled: "不允许上传视频", submitPending: "提交成功,序号为({0})", addSuccess: "添加成功,序号为({0})", mediaSizeExceeded: "{0}文件大小超过限制", localFileNotAllowed: "检测到本地文件路径,无法保存" }, remove: { noPermission: "你无权删除他人添加的回声洞", deletePending: "删除(待审核)", deleted: "已删除" }, list: { pageInfo: "第 {0} / {1} 页", header: "当前共有 {0} 项回声洞:", totalItems: "用户 {0} 共计投稿 {1} 项:", idsLine: "{0}" }, audit: { noPending: "暂无待审核回声洞", pendingNotFound: "未找到待审核回声洞", pendingResult: "{0},剩余 {1} 个待审核回声洞:[{2}]", auditPassed: "已通过", auditRejected: "已拒绝", batchAuditResult: "已{0} {1}/{2} 项回声洞", title: "待审核回声洞:", from: "投稿人:", sendFailed: "发送审核消息失败,无法联系管理员 {0}" }, error: { noContent: "回声洞内容为空", getCave: "获取回声洞失败", noCave: "当前无回声洞", invalidId: "请输入有效的回声洞ID", notFound: "未找到该回声洞", exactDuplicateFound: "发现完全相同的", similarDuplicateFound: "发现相似度为 {0}% 的", addFailed: "添加失败,请稍后重试。" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } }; } }); // src/locales/en-US.yml var require_en_US = __commonJS({ "src/locales/en-US.yml"(exports2, module2) { module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", enableTextDuplicate: "Enable text duplicate check", textDuplicateThreshold: "Text similarity threshold (0-1)", enableImageDuplicate: "Enable image duplicate check", imageDuplicateThreshold: "Image similarity threshold (0-1)", imageMaxSize: "Maximum image size (MB)", allowVideo: "Allow video upload", videoMaxSize: "Maximum video size (MB)", enablePagination: "Enable statistics pagination", itemsPerPage: "Items per page", blacklist: "Blacklist (users)", whitelist: "Moderation whitelist (users/groups/channels)" }, commands: { cave: { description: "Echo Cave", usage: "Support adding, drawing, viewing, and managing echo caves", examples: "Use cave to randomly draw an echo\nUse -a to add directly or add by reference\nUse -g to view specific echo\nUse -r to delete specific echo", options: { a: "Add echo", g: "View echo", r: "Delete echo", l: "Query submission statistics" }, pass: { description: "Approve cave submission", usage: "Approve cave submission with specific ID\ncave.pass <ID> - Approve submission\ncave.pass all - Approve all pending submissions\n" }, reject: { description: "Reject cave submission", usage: "Reject cave submission with specific ID\ncave.reject <ID> - Reject submission\ncave.reject all - Reject all pending submissions\n" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timeout, addition cancelled", videoDisabled: "Video upload not allowed", submitPending: "Submission successful, ID is ({0})", addSuccess: "Added successfully, ID is ({0})", mediaSizeExceeded: "{0} file size exceeds limit", localFileNotAllowed: "Local file path detected, cannot save" }, remove: { noPermission: "You don't have permission to delete others' echos", deletePending: "Delete (pending review)", deleted: "Deleted" }, list: { pageInfo: "Page {0} / {1}", header: "Currently there are {0} echos:", totalItems: "User {0} has submitted {1} items:", idsLine: "{0}" }, audit: { noPending: "No pending echos for review", pendingNotFound: "Pending echo not found", pendingResult: "{0}, {1} pending echos remaining: [{2}]", auditPassed: "Approved", auditRejected: "Rejected", batchAuditResult: "{0} {1}/{2} echos", title: "Pending echos:", from: "Submitted by:", sendFailed: "Failed to send moderation message, cannot contact administrator {0}" }, error: { noContent: "Echo content is empty", getCave: "Failed to get echo", noCave: "No echos available", invalidId: "Please enter a valid echo ID", notFound: "Echo not found", exactDuplicateFound: "Found exactly identical", similarDuplicateFound: "Found {0}% similar", addFailed: "Add failed, please try again later." }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is limited to administrators only", cooldown: "Group chat cooling down... Please try again in {0} seconds", caveTitle: "Echo Cave —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0} file size exceeds limit" } } } }; } }); // src/index.ts var src_exports = {}; __export(src_exports, { Config: () => Config, apply: () => apply, inject: () => inject, name: () => name }); module.exports = __toCommonJS(src_exports); var import_koishi6 = require("koishi"); var fs7 = __toESM(require("fs")); var path7 = __toESM(require("path")); // src/utils/FileHandler.ts var fs = __toESM(require("fs")); var path = __toESM(require("path")); var import_koishi = require("koishi"); var logger = new import_koishi.Logger("fileHandler"); var FileHandler = class { static { __name(this, "FileHandler"); } static locks = /* @__PURE__ */ new Map(); static RETRY_COUNT = 3; static RETRY_DELAY = 1e3; static CONCURRENCY_LIMIT = 5; /** * 并发控制 * @param operation 要执行的操作 * @param limit 并发限制 * @returns 操作结果 */ static async withConcurrencyLimit(operation, limit = this.CONCURRENCY_LIMIT) { while (this.locks.size >= limit) { await Promise.race(this.locks.values()); } return operation(); } /** * 文件操作包装器 * @param filePath 文件路径 * @param operation 要执行的操作 * @returns 操作结果 */ static async withFileOp(filePath, operation) { const key = filePath; while (this.locks.has(key)) { await this.locks.get(key); } const operationPromise = (async () => { for (let i = 0; i < this.RETRY_COUNT; i++) { try { return await operation(); } catch (error) { if (i === this.RETRY_COUNT - 1) throw error; await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY)); } } throw new Error("Operation failed after retries"); })(); this.locks.set(key, operationPromise); try { return await operationPromise; } finally { this.locks.delete(key); } } /** * 事务处理 * @param operations 要执行的操作数组 * @returns 操作结果数组 */ static async withTransaction(operations) { const results = []; const completed = /* @__PURE__ */ new Set(); try { for (const { filePath, operation } of operations) { const result = await this.withFileOp(filePath, operation); results.push(result); completed.add(filePath); } return results; } catch (error) { await Promise.all( operations.filter(({ filePath }) => completed.has(filePath)).map(async ({ filePath, rollback }) => { if (rollback) { await this.withFileOp(filePath, rollback).catch( (e) => logger.error(`Rollback failed for ${filePath}: ${e.message}`) ); } }) ); throw error; } } /** * 读取 JSON 数据 * @param filePath 文件路径 * @returns JSON 数据 */ static async readJsonData(filePath) { return this.withFileOp(filePath, async () => { try { const data = await fs.promises.readFile(filePath, "utf8"); return JSON.parse(data || "[]"); } catch (error) { return []; } }); } /** * 写入 JSON 数据 * @param filePath 文件路径 * @param data 要写入的数据 */ static async writeJsonData(filePath, data) { const tmpPath = `${filePath}.tmp`; await this.withFileOp(filePath, async () => { await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2)); await fs.promises.rename(tmpPath, filePath); }); } /** * 确保目录存在 * @param dir 目录路径 */ static async ensureDirectory(dir) { await this.withConcurrencyLimit(async () => { if (!fs.existsSync(dir)) { await fs.promises.mkdir(dir, { recursive: true }); } }); } /** * 确保 JSON 文件存在 * @param filePath 文件路径 */ static async ensureJsonFile(filePath) { await this.withFileOp(filePath, async () => { if (!fs.existsSync(filePath)) { await fs.promises.writeFile(filePath, "[]", "utf8"); } }); } /** * 保存媒体文件 * @param filePath 文件路径 * @param data 文件数据 */ static async saveMediaFile(filePath, data) { await this.withConcurrencyLimit(async () => { const dir = path.dirname(filePath); await this.ensureDirectory(dir); await this.withFileOp( filePath, () => fs.promises.writeFile(filePath, data) ); }); } /** * 删除媒体文件 * @param filePath 文件路径 */ static async deleteMediaFile(filePath) { await this.withFileOp(filePath, async () => { if (fs.existsSync(filePath)) { await fs.promises.unlink(filePath); } }); } }; // src/utils/IdManager.ts var fs2 = __toESM(require("fs")); var path2 = __toESM(require("path")); var import_koishi2 = require("koishi"); var logger2 = new import_koishi2.Logger("IdManager"); var IdManager = class { static { __name(this, "IdManager"); } deletedIds = /* @__PURE__ */ new Set(); maxId = 0; initialized = false; statusFilePath; stats = {}; usedIds = /* @__PURE__ */ new Set(); /** * 初始化ID管理器 * @param baseDir - 基础目录路径 */ constructor(baseDir) { const caveDir = path2.join(baseDir, "data", "cave"); this.statusFilePath = path2.join(caveDir, "status.json"); } /** * 初始化ID管理系统 * @param caveFilePath - 正式回声洞数据文件路径 * @param pendingFilePath - 待处理回声洞数据文件路径 * @throws 当初始化失败时抛出错误 */ async initialize(caveFilePath, pendingFilePath) { if (this.initialized) return; try { const status = fs2.existsSync(this.statusFilePath) ? JSON.parse(await fs2.promises.readFile(this.statusFilePath, "utf8")) : { deletedIds: [], maxId: 0, stats: {}, lastUpdated: (/* @__PURE__ */ new Date()).toISOString() }; const [caveData, pendingData] = await Promise.all([ FileHandler.readJsonData(caveFilePath), FileHandler.readJsonData(pendingFilePath) ]); this.usedIds.clear(); this.stats = {}; const conflicts = /* @__PURE__ */ new Map(); for (const data of [caveData, pendingData]) { for (const item of data) { if (this.usedIds.has(item.cave_id)) { if (!conflicts.has(item.cave_id)) { conflicts.set(item.cave_id, []); } conflicts.get(item.cave_id)?.push(item); } else { this.usedIds.add(item.cave_id); if (data === caveData && item.contributor_number !== "10000") { if (!this.stats[item.contributor_number]) { this.stats[item.contributor_number] = []; } this.stats[item.contributor_number].push(item.cave_id); } } } } if (conflicts.size > 0) { await this.handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData); } this.maxId = Math.max( status.maxId || 0, ...[...this.usedIds], ...status.deletedIds || [], 0 ); this.deletedIds = new Set(status.deletedIds || []); for (let i = 1; i <= this.maxId; i++) { if (!this.usedIds.has(i)) { this.deletedIds.add(i); } } await this.saveStatus(); this.initialized = true; logger2.success(`Cave ID Manager initialized with ${this.maxId}(-${this.deletedIds.size}) IDs`); } catch (error) { this.initialized = false; logger2.error(`ID Manager initialization failed: ${error.message}`); throw error; } } /** * 处理ID冲突 * @param conflicts - ID冲突映射表 * @param caveFilePath - 正式回声洞数据文件路径 * @param pendingFilePath - 待处理回声洞数据文件路径 * @param caveData - 正式回声洞数据 * @param pendingData - 待处理回声洞数据 * @private */ async handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData) { logger2.warn(`Found ${conflicts.size} ID conflicts`); let modified = false; for (const items of conflicts.values()) { items.slice(1).forEach((item) => { let newId = this.maxId + 1; while (this.usedIds.has(newId)) { newId++; } logger2.info(`Reassigning ID: ${item.cave_id} -> ${newId}`); item.cave_id = newId; this.usedIds.add(newId); this.maxId = Math.max(this.maxId, newId); modified = true; }); } if (modified) { await Promise.all([ FileHandler.writeJsonData(caveFilePath, caveData), FileHandler.writeJsonData(pendingFilePath, pendingData) ]); logger2.success("ID conflicts resolved"); } } /** * 获取下一个可用的ID * @returns 下一个可用的ID * @throws 当ID管理器未初始化时抛出错误 */ getNextId() { if (!this.initialized) { throw new Error("IdManager not initialized"); } let nextId; if (this.deletedIds.size > 0) { const minDeletedId = Math.min(...Array.from(this.deletedIds)); if (!isNaN(minDeletedId) && minDeletedId > 0) { nextId = minDeletedId; this.deletedIds.delete(nextId); } else { nextId = this.maxId + 1; } } else { nextId = this.maxId + 1; } while (isNaN(nextId) || nextId <= 0 || this.usedIds.has(nextId)) { nextId = this.maxId + 1; this.maxId++; } this.usedIds.add(nextId); this.saveStatus().catch( (err) => logger2.error(`Failed to save status after getNextId: ${err.message}`) ); return nextId; } /** * 标记ID为已删除状态 * @param id - 要标记为删除的ID * @throws 当ID管理器未初始化时抛出错误 */ async markDeleted(id) { if (!this.initialized) { throw new Error("IdManager not initialized"); } this.deletedIds.add(id); this.usedIds.delete(id); const maxUsedId = Math.max(...Array.from(this.usedIds), 0); const maxDeletedId = Math.max(...Array.from(this.deletedIds), 0); this.maxId = Math.max(maxUsedId, maxDeletedId); await this.saveStatus(); } /** * 添加贡献统计 * @param contributorNumber - 贡献者编号 * @param caveId - 回声洞ID */ async addStat(contributorNumber, caveId) { if (contributorNumber === "10000") return; if (!this.stats[contributorNumber]) { this.stats[contributorNumber] = []; } this.stats[contributorNumber].push(caveId); await this.saveStatus(); } /** * 移除贡献统计 * @param contributorNumber - 贡献者编号 * @param caveId - 回声洞ID */ async removeStat(contributorNumber, caveId) { if (this.stats[contributorNumber]) { this.stats[contributorNumber] = this.stats[contributorNumber].filter((id) => id !== caveId); if (this.stats[contributorNumber].length === 0) { delete this.stats[contributorNumber]; } await this.saveStatus(); } } /** * 获取所有贡献统计信息 * @returns 贡献者编号到回声洞ID列表的映射 */ getStats() { return this.stats; } /** * 保存当前状态到文件 * @private * @throws 当保存失败时抛出错误 */ async saveStatus() { try { const status = { deletedIds: Array.from(this.deletedIds).sort((a, b) => a - b), maxId: this.maxId, stats: this.stats, lastUpdated: (/* @__PURE__ */ new Date()).toISOString() }; const tmpPath = `${this.statusFilePath}.tmp`; await fs2.promises.writeFile(tmpPath, JSON.stringify(status, null, 2), "utf8"); await fs2.promises.rename(tmpPath, this.statusFilePath); } catch (error) { logger2.error(`Status save failed: ${error.message}`); throw error; } } }; // src/utils/HashManager.ts var import_koishi3 = require("koishi"); var fs3 = __toESM(require("fs")); var path3 = __toESM(require("path")); // src/utils/ContentHasher.ts var import_sharp = __toESM(require("sharp")); var ContentHasher = class { static { __name(this, "ContentHasher"); } /** * 计算图片的感知哈希值 * @param imageBuffer - 图片的二进制数据 * @returns 返回64位的十六进制哈希字符串 * @throws 当图片处理失败时可能抛出错误 */ static async calculateHash(imageBuffer) { const { data } = await (0, import_sharp.default)(imageBuffer).grayscale().resize(32, 32, { fit: "fill" }).raw().toBuffer({ resolveWithObject: true }); const dctMatrix = this.computeDCT(data, 32); const features = this.extractFeatures(dctMatrix, 32); const median = this.calculateMedian(features); const binaryHash = features.map((val) => val > median ? "1" : "0").join(""); return this.binaryToHex(binaryHash); } /** * 将二进制字符串转换为十六进制 * @param binary - 二进制字符串 * @returns 十六进制字符串 * @private */ static binaryToHex(binary) { const hex = []; for (let i = 0; i < binary.length; i += 4) { const chunk = binary.slice(i, i + 4); hex.push(parseInt(chunk, 2).toString(16)); } return hex.join(""); } /** * 将十六进制字符串转换为二进制 * @param hex - 十六进制字符串 * @returns 二进制字符串 * @private */ static hexToBinary(hex) { let binary = ""; for (const char of hex) { const bin = parseInt(char, 16).toString(2).padStart(4, "0"); binary += bin; } return binary; } /** * 计算图像的DCT(离散余弦变换) * @param data - 图像数据 * @param size - 图像尺寸 * @returns DCT变换后的矩阵 * @private */ static computeDCT(data, size) { const matrix = Array(size).fill(0).map(() => Array(size).fill(0)); const output = Array(size).fill(0).map(() => Array(size).fill(0)); for (let i = 0; i < size; i++) { for (let j = 0; j < size; j++) { matrix[i][j] = data[i * size + j]; } } for (let u = 0; u < size; u++) { for (let v = 0; v < size; v++) { let sum = 0; for (let x = 0; x < size; x++) { for (let y = 0; y < size; y++) { const cx = Math.cos((2 * x + 1) * u * Math.PI / (2 * size)); const cy = Math.cos((2 * y + 1) * v * Math.PI / (2 * size)); sum += matrix[x][y] * cx * cy; } } output[u][v] = sum * this.getDCTCoefficient(u, size) * this.getDCTCoefficient(v, size); } } return output; } /** * 获取DCT系数 * @param index - 索引值 * @param size - 矩阵大小 * @returns DCT系数 * @private */ static getDCTCoefficient(index, size) { return index === 0 ? Math.sqrt(1 / size) : Math.sqrt(2 / size); } /** * 计算数组的中位数 * @param arr - 输入数组 * @returns 中位数 * @private */ static calculateMedian(arr) { const sorted = [...arr].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } /** * 从DCT矩阵中提取特征值 * @param matrix - DCT矩阵 * @param size - 矩阵大小 * @returns 特征值数组 * @private */ static extractFeatures(matrix, size) { const features = []; const featureSize = 8; for (let i = 0; i < featureSize; i++) { for (let j = 0; j < featureSize; j++) { features.push(matrix[i][j]); } } return features; } /** * 计算两个哈希值之间的汉明距离 * @param hash1 - 第一个哈希值 * @param hash2 - 第二个哈希值 * @returns 汉明距离 * @throws 当两个哈希值长度不等时抛出错误 */ static calculateDistance(hash1, hash2) { if (hash1.length !== hash2.length) { throw new Error("Hash lengths must be equal"); } const bin1 = this.hexToBinary(hash1); const bin2 = this.hexToBinary(hash2); let distance = 0; for (let i = 0; i < bin1.length; i++) { if (bin1[i] !== bin2[i]) distance++; } return distance; } /** * 计算两个图片哈希值的相似度 * @param hash1 - 第一个哈希值 * @param hash2 - 第二个哈希值 * @returns 返回0-1之间的相似度值,1表示完全相同,0表示完全不同 */ static calculateSimilarity(hash1, hash2) { const distance = this.calculateDistance(hash1, hash2); return (64 - distance) / 64; } /** * 计算文本的哈希值 * @param text - 输入文本 * @returns 文本的哈希值(36进制字符串) */ static calculateTextHash(text) { const normalizedText = text.toLowerCase().trim().replace(/\s+/g, " "); let hash = 0; for (let i = 0; i < normalizedText.length; i++) { const char = normalizedText.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return hash.toString(36); } }; // src/utils/HashManager.ts var import_util = require("util"); var logger3 = new import_koishi3.Logger("HashManager"); var readFileAsync = (0, import_util.promisify)(fs3.readFile); var HashManager = class _HashManager { /** * 初始化HashManager实例 * @param caveDir 回声洞数据目录路径 */ constructor(caveDir) { this.caveDir = caveDir; } static { __name(this, "HashManager"); } static HASH_FILE = "hash.json"; static CAVE_FILE = "cave.json"; static BATCH_SIZE = 50; imageHashes = /* @__PURE__ */ new Map(); textHashes = /* @__PURE__ */ new Map(); initialized = false; get filePath() { return path3.join(this.caveDir, _HashManager.HASH_FILE); } get resourceDir() { return path3.join(this.caveDir, "resources"); } get caveFilePath() { return path3.join(this.caveDir, _HashManager.CAVE_FILE); } /** * 初始化哈希存储 * 读取现有哈希数据或重新构建哈希值 * @throws 初始化失败时抛出错误 */ async initialize() { if (this.initialized) return; try { const hashData = await FileHandler.readJsonData(this.filePath).then((data) => data[0]).catch(() => null); if (!hashData?.imageHashes || !hashData?.textHashes || Object.keys(hashData.imageHashes).length === 0) { this.imageHashes.clear(); this.textHashes.clear(); await this.buildInitialHashes(); } else { this.imageHashes = new Map( Object.entries(hashData.imageHashes).map(([k, v]) => [Number(k), v]) ); this.textHashes = new Map( Object.entries(hashData.textHashes).map(([k, v]) => [Number(k), v]) ); await this.updateMissingHashes(); } const totalCaves = (/* @__PURE__ */ new Set([...this.imageHashes.keys(), ...this.textHashes.keys()])).size; this.initialized = true; logger3.success(`Cave Hash Manager initialized with ${totalCaves} hashes`); } catch (error) { logger3.error(`Initialization failed: ${error.message}`); this.initialized = false; throw error; } } /** * 获取当前哈希存储状态 * @returns 包含最后更新时间和所有条目的状态对象 */ async getStatus() { if (!this.initialized) await this.initialize(); return { lastUpdated: (/* @__PURE__ */ new Date()).toISOString(), entries: Array.from(this.imageHashes.entries()).map(([caveId, imgHashes]) => ({ caveId, imageHashes: imgHashes, textHashes: this.textHashes.get(caveId) || [] })) }; } /** * 更新指定回声洞的图片哈希值 * @param caveId 回声洞ID * @param content 图片buffer数组 */ async updateCaveContent(caveId, content) { if (!this.initialized) await this.initialize(); try { if (content.images?.length) { const imageHashes = await Promise.all( content.images.map((buffer) => ContentHasher.calculateHash(buffer)) ); this.imageHashes.set(caveId, imageHashes); } if (content.texts?.length) { const textHashes = content.texts.map((text) => ContentHasher.calculateTextHash(text)); this.textHashes.set(caveId, textHashes); } if (!content.images && !content.texts) { this.imageHashes.delete(caveId); this.textHashes.delete(caveId); } await this.saveContentHashes(); } catch (error) { logger3.error(`Failed to update content hashes (cave ${caveId}): ${error.message}`); } } /** * 更新所有回声洞的哈希值 * @param isInitialBuild 是否为初始构建 */ async updateAllCaves(isInitialBuild = false) { if (!this.initialized && !isInitialBuild) { await this.initialize(); return; } try { logger3.info("Starting full hash update..."); const caveData = await this.loadCaveData(); const cavesWithImages = caveData.filter( (cave) => cave.elements?.some((el) => el.type === "img" && el.file) ); this.imageHashes.clear(); let processedCount = 0; const totalImages = cavesWithImages.length; const processCave = /* @__PURE__ */ __name(async (cave) => { const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || []; if (imgElements.length === 0) return; try { const hashes = await Promise.all( imgElements.map(async (imgElement) => { const filePath = path3.join(this.resourceDir, imgElement.file); if (!fs3.existsSync(filePath)) { logger3.warn(`Image file not found: ${filePath}`); return null; } const imgBuffer = await readFileAsync(filePath); return await ContentHasher.calculateHash(imgBuffer); }) ); const validHashes = hashes.filter((hash) => hash !== null); if (validHashes.length > 0) { this.imageHashes.set(cave.cave_id, validHashes); processedCount++; if (processedCount % 100 === 0) { logger3.info(`Progress: ${processedCount}/${totalImages}`); } } } catch (error) { logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`); } }, "processCave"); await this.processBatch(cavesWithImages, processCave); await this.saveContentHashes(); logger3.success(`Update completed. Processed ${processedCount}/${totalImages} images`); } catch (error) { logger3.error(`Full update failed: ${error.message}`); throw error; } } /** * 查找重复的图片 * @param content 待查找的图片buffer数组 * @param thresholds 相似度阈值 * @returns 匹配结果数组,包含索引、回声洞ID和相似度 */ async findDuplicates(content, thresholds) { if (!this.initialized) await this.initialize(); const results = []; if (content.images?.length) { const imageResults = await this.findImageDuplicates(content.images, thresholds.image); results.push(...imageResults.map( (result) => result ? { ...result, type: "image" } : null )); } if (content.texts?.length) { const textResults = await this.findTextDuplicates(content.texts, thresholds.text); results.push(...textResults.map( (result) => result ? { ...result, type: "text" } : null )); } return results; } async findTextDuplicates(texts, threshold) { const inputHashes = texts.map((text) => ContentHasher.calculateTextHash(text)); const existingHashes = Array.from(this.textHashes.entries()); return inputHashes.map((hash, index) => { let maxSimilarity = 0; let matchedCaveId = null; for (const [caveId, hashes] of existingHashes) { for (const existingHash of hashes) { const similarity = this.calculateTextSimilarity(hash, existingHash); if (similarity >= threshold && similarity > maxSimilarity) { maxSimilarity = similarity; matchedCaveId = caveId; if (similarity === 1) break; } } if (maxSimilarity === 1) break; } return matchedCaveId ? { index, caveId: matchedCaveId, similarity: maxSimilarity } : null; }); } calculateTextSimilarity(hash1, hash2) { if (hash1 === hash2) return 1; const length = Math.max(hash1.length, hash2.length); let matches = 0; for (let i = 0; i < length; i++) { if (hash1[i] === hash2[i]) matches++; } return matches / length; } async findImageDuplicates(images, threshold) { if (!this.initialized) await this.initialize(); const inputHashes = await Promise.all( images.map((buffer) => ContentHasher.calculateHash(buffer)) ); const existingHashes = Array.from(this.imageHashes.entries()); return Promise.all( inputHashes.map(async (hash, index) => { try { let maxSimilarity = 0; let matchedCaveId = null; for (const [caveId, hashes] of existingHashes) { for (const existingHash of hashes) { const similarity = ContentHasher.calculateSimilarity(hash, existingHash); if (similarity >= threshold && similarity > maxSimilarity) { maxSimilarity = similarity; matchedCaveId = caveId; if (Math.abs(similarity - 1) < Number.EPSILON) break; } } if (Math.abs(maxSimilarity - 1) < Number.EPSILON) break; } return matchedCaveId ? { index, caveId: matchedCaveId, similarity: maxSimilarity } : null; } catch (error) { logger3.warn(`处理图片 ${index} 失败: ${error.message}`); return null; } }) ); } /** * 加载回声洞数据 * @returns 回声洞数据数组 * @private */ async loadCaveData() { const data = await FileHandler.readJsonData(this.caveFilePath); return Array.isArray(data) ? data.flat() : []; } /** * 保存哈希数据到文件 * @private */ async saveContentHashes() { const data = { imageHashes: Object.fromEntries(this.imageHashes), textHashes: Object.fromEntries(this.textHashes), lastUpdated: (/* @__PURE__ */ new Date()).toISOString() }; await FileHandler.writeJsonData(this.filePath, [data]); } /** * 构建初始哈希数据 * @private */ async buildInitialHashes() { const caveData = await this.loadCaveData(); let processedCount = 0; const totalCaves = caveData.length; logger3.info(`Building hash data for ${totalCaves} caves...`); for (const cave of caveData) { try { const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || []; if (imgElements.length > 0) { const hashes = await Promise.all( imgElements.map(async (imgElement) => { const filePath = path3.join(this.resourceDir, imgElement.file); if (!fs3.existsSync(filePath)) { logger3.warn(`Image not found: ${filePath}`); return null; } const imgBuffer = await fs3.promises.readFile(filePath); return await ContentHasher.calculateHash(imgBuffer); }) ); const validHashes = hashes.filter((hash) => hash !== null); if (validHashes.length > 0) { this.imageHashes.set(cave.cave_id, validHashes); } } const textElements = cave.elements?.filter((el) => el.type === "text" && el.content) || []; if (textElements.length > 0) { const textHashes = textElements.map((el) => ContentHasher.calculateTextHash(el.content)); this.textHashes.set(cave.cave_id, textHashes); } processedCount++; if (processedCount % 100 === 0) { logger3.info(`Progress: ${processedCount}/${totalCaves} caves`); } } catch (error) { logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`); } } await this.saveContentHashes(); logger3.success(`Build completed. Processed ${processedCount}/${totalCaves} caves`); } /** * 更新缺失的哈希值 * @private */ async updateMissingHashes() { const caveData = await this.loadCaveData(); let updatedCount = 0; for (const cave of caveData) { if (this.imageHashes.has(cave.cave_id)) continue; const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || []; if (imgElements.length === 0) continue; try { const hashes = await Promise.all( imgElements.map(async (imgElement) => { const filePath = path3.join(this.resourceDir, imgElement.file); if (!fs3.existsSync(filePath)) { return null; } const imgBuffer = await fs3.promises.readFile(filePath); return ContentHasher.calculateHash(imgBuffer); }) ); const validHashes = hashes.filter((hash) => hash !== null); if (validHashes.length > 0) { this.imageHashes.set(cave.cave_id, validHashes); updatedCount++; } } catch (error) { logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`); } } } /** * 批量处理数组项 * @param items 待处理项数组 * @param processor 处理函数 * @param batchSize 批处理大小 * @private */ async processBatch(items, processor, batchSize = _HashManager.BATCH_SIZE) { for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); await Promise.all( batch.map(async (item) => { try { await processor(item); } catch (error) { logger3.error(`Batch processing error: ${error.message}`); } }) ); } } }; // src/utils/AuditHandler.ts var import_koishi4 = require("koishi"); var fs4 = __toESM(require("fs")); var path4 = __toESM(require("path")); var AuditManager = class { /** * 创建审核管理器实例 * @param ctx - Koishi 上下文 * @param config - 配置对象 * @param idManager - ID 管理器实例 */ constructor(ctx, config, idManager) { this.ctx = ctx; this.config = config; this.idManager = idManager; } static { __name(this, "AuditManager"); } logger = new import_koishi4.Logger("AuditManager"); /** * 处理审核操作 * @param pendingData - 待审核的洞数据数组 * @param isApprove - 是否通过审核 * @param caveFilePath - 洞数据文件路径 * @param resourceDir - 资源目录路径 * @param pendingFilePath - 待审核数据文件路径 * @param session - 会话对象 * @param targetId - 目标洞ID(可选) * @returns 处理结果消息 */ async processAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session, targetId) { if (pendingData.length === 0) { return this.sendMessage(session, "commands.cave.audit.noPending", [], true); } if (typeof targetId === "number") { return await this.handleSingleAudit( pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, targetId, session ); } return await this.handleBatchAudit( pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session ); } /** * 处理单条审核 * @param pendingData - 待审核的洞数据数组 * @param isApprove - 是否通过审核 * @param caveFilePath - 洞数据文件路径 * @param resourceDir - 资源目录路径 * @param pendingFilePath - 待审核数据文件路径 * @param targetId - 目标洞ID * @param session - 会话对象 * @returns 处理结果消息 * @private */ async handleSingleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, targetId, session) { const targetCave = pendingData.find((item) => item.cave_id === targetId); if (!targetCave) { return this.sendMessage(session, "commands.cave.audit.pendingNotFound", [], true); } const newPendingData = pendingData.filter((item) => item.cave_id !== targetId); if (isApprove) { const oldCaveData = await FileHandler.readJsonData(caveFilePath); const newCaveData = [...oldCaveData, { ...targetCave, cave_id: targetId, elements: this.cleanElementsForSave(targetCave.elements, false) }]; await FileHandler.withTransaction([ { filePath: caveFilePath, operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, newCaveData), "operation"), rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldCaveData), "rollback") }, { filePath: pendingFilePath, operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, newPendingData), "operation"), rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback") } ]); await this.idManager.addStat(targetCave.contributor_number, targetId); } else { await FileHandler.writeJsonData(pendingFilePath, newPendingData); await this.idManager.markDeleted(targetId); await this.deleteMediaFiles(targetCave, resourceDir); } const remainingCount = newPendingData.length; if (remainingCount > 0) { const remainingIds = newPendingData.map((c) => c.cave_id).join(", "); const action = isApprove ? "auditPassed" : "auditRejected"; return this.sendMessage(session, "commands.cave.audit.pendingResult", [ session.text(`commands.cave.audit.${action}`), remainingCount, remainingIds ], false); } return this.sendMessage( session, isApprove ? "commands.cave.audit.auditPassed" : "commands.cave.audit.auditRejected", [], false ); } /** * 处理批量审核 * @param pendingData - 待审核的洞数据数组 * @param isApprove - 是否通过审核 * @param caveFilePath - 洞数据文件路径 * @param resourceDir - 资源目录路径 * @param pendingFilePath - 待审核数据文件路径 * @param session - 会话对象 * @returns 处理结果消息 * @private */ async handleBatchAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session) { const data = isApprove ? await FileHandler.readJsonData(caveFilePath) : null; let processedCount = 0; if (isApprove && data) { const oldData = [...data]; const newData = [...data]; await FileHandler.withTransaction([ { filePath: caveFilePath, operation: /* @__PURE__ */ __name(async () => { for (const cave of pendingData) { newData.push({ ...cave, cave_id: cave.cave_id, elements: this.cleanElementsForSave(cave.elements, false) }); processedCount++; await this.idManager.addStat(cave.contributor_number, cave.cave_id); } return FileHandler.writeJsonData(caveFilePath, newData); }, "operation"), rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldData), "rollback") }, { filePath: pendingFilePath, operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, []), "operation"), rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback") } ]); } else { for (const cave of pendingData) { await this.idManager.markDeleted(cave.cave_id); await this.deleteMediaFiles(cave, resourceDir); processedCount++; } await FileHandler.writeJsonData(pendingFilePath, []); } return this.sendMessage(session, "commands.cave.audit.batchAuditResult", [ isApprove ? "通过" : "拒绝", processedCount, pendingData.length ], false); } /** * 发送审核消息给管理员 * @param cave - 待审核的洞数据 * @param content - 消息内容 * @param session - 会话对象 */ async sendAuditMessage(cave, content, session) { const auditMessage = `${session.text("commands.cave.audit.title")} ${content} ${session.text("commands.cave.audit.from")}${cave.contributor_number}`; for (const managerId of this.config.manager) { const bot = this.ctx.bots[0]; if (bot) { try { await bot.sendPrivateMessage(managerId, auditMessage); } catch (error) { this.logger.error(session.text("commands.cave.audit.sendFailed", [managerId])); } } } } /** * 删除媒体文件 * @param cave - 洞数据 * @param resourceDir - 资源目录路径 * @private */ async deleteMediaFiles(cave, resourceDir) { if (cave.elements) { for (const element of cave.elements) { if ((element.type === "img" || element.type === "video") && element.file) { const fullPath = path4.join(resourceDir, element.file); if (fs4.existsSync(fullPath)) { await fs4.promises.unlink(fullPath); } } } } } /** * 清理元素数据用于保存 * @param elements - 元素数组 * @param keepIndex - 是否保留索引 * @returns 清理后的元素数组 * @private */ cleanElementsForSave(elements, keepIndex = false) { if (!elements?.length) return []; const cleanedElements = elements.map((element) => { if (element.type === "text") { const cleanedElement = { type: "text", content: element.content }; if (keepIndex) cleanedElement.index = element.index; return cleanedElement; } else if (element.type === "img" || element.type === "video") { const mediaElement = element; const cleanedElement = { type: mediaElement.type }; if (mediaElement.file) cleanedElement.file = mediaElement.file; if (keepIndex) cleanedElement.index = element.index; return cleanedElement; } return element; }); return keepIndex ? cleanedElements.sort((a, b) => (a.index || 0) - (b.index || 0)) : cleanedElements; } /** * 发送消息 * @param session - 会话对象 * @param key - 消息key * @param params - 消息参数 * @param isTemp - 是否为临时消息 * @param timeout - 临时消息超时时间 * @returns 空字符串 * @private */ async sendMessage(session, key, params = [], isTemp = true, timeout = 1e4) { try { const msg = await session.send(session.text(key, params)); if (isTemp && msg) { setTimeout(async () => { try { await session.bot.deleteMessage(session.channelId, msg); } catch (error) { this.logger.debug(`Failed to delete temporary message: ${error.message}`); } }, timeout); } } catch (error) { this.logger.error(`Failed to send message: ${error.message}`); } return ""; } }; // src/utils/MediaHandler.ts var import_koishi5 = require("koishi"); var fs5 = __toESM(require("fs")); var path5 = __toESM(require("path")); var logger4 = new import_koishi5.Logger("MediaHandle"); async function buildMessage(cave, resourceDir, session) { if (!cave?.elements?.length) { return session.text("commands.cave.error.noContent"); } const videoElement = cave.elements.find((el) => el.type === "video"); const nonVideoElements = cave.elements.filter((el) => el.type !== "video").sort((a, b) => (a.index ?? 0) - (b.index ?? 0)); if (videoElement?.file) { const basicInfo = [ session.text("commands.cave.message.caveTitle", [cave.cave_id]), session.text("commands.cave.message.contributorSuffix", [cave.contributor_name]) ].join("\n"); await session?.send(basicInfo); const filePath = path5.join(resourceDir, videoElement.file); const base64Data = await processMediaFile(filePath, "video"); if (base64Data && session) { await session.send((0, import_koishi5.h)("video", { src: base64Data })); } return ""; } const lines = [session.text("commands.cave.message.caveTitle", [cave.cave_id])]; for (const element of nonVideoElements) { if (element.type === "text") { lines.push(element.content); } else if (element.type === "img" && element.file) { const filePath = path5.join(resourceDir, element.file); const base64Data = await processMediaFile(filePath, "image"); if (base64Data) { lines.push((0, import_koishi5.h)("image", { src: base64Data })); } } } lines.push(session.text("commands.cave.message.contributorSuffix", [cave.contributor_name])); return lines.join("\n"); } __name(buildMessage, "buildMessage"); async function sendMessage(session, key, params = [], isTemp = true, timeout = 1e4) { try { const msg = await session.send(session.text(key, params)); if (isTemp && msg) { setTimeout(async () => { try { await session.bot.deleteMessage(session.channelId, msg); } catch (error) { logger4.debug(`Failed to delete temporary message: ${error.message}`); } }, timeout); } } catch (error) { logger4.error(`Failed to send message: ${error.message}`); } return ""; } __name(sendMessage, "sendMessage"); async function processMediaFile(filePath, type) { const data = await fs5.promises.readFile(filePath).catch(() => null); if (!data) return null; return `data:${type}/${type === "image" ? "png" : "mp4"};base64,${data.toString("base64")}`; } __name(processMediaFile, "processMediaFile"); async function extractMediaContent(originalContent, config, session) { const textParts = originalContent.split(/<(img|video)[^>]+>/).map((text, idx) => text.trim() && { type: "text", content: text.replace(/^(img|video)$/, "").trim(), index: idx * 3 }).filter((text) => text && text.content); const getMediaElements = /* @__PURE__ */ __name((type, maxSize) => { const regex = new RegExp(`<${type}[^>]+src="([^"]+)"[^>]*>`, "g"); const elements = []; const urls = []; let match; let idx = 0; while ((match = regex.exec(originalContent)) !== null) { const element = match[0]; const url = match[1]; const fileName = element.match(/file="([^"]+)"/)?.[1]; const fileSize = element.match(/fileSize="([^"]+)"/)?.[1]; if (fileSize) { const sizeInBytes = parseInt(fileSize); if (sizeInBytes > maxSize * 1024 * 1024) { throw new Error(session.text("commands.cave.message.mediaSizeExceeded", [type])); } } urls.push(url); elements.push({ type, index: type === "video" ? Number.MAX_SAFE_INTEGER : idx * 3 + 1, fileName, fileSize }); idx++; } return { urls, elements }; }, "getMediaElements"); const { urls: imageUrls, elements: imageElementsRaw } = getMediaElements("img", config.imageMaxSize); const imageElements = imageElementsRaw; const { urls: videoUrls, elements: videoElementsRaw } = getMediaElements("video", config.videoMaxSize); const videoElements = videoElementsRaw; return { imageUrls, imageElements, videoUrls, videoElements, textParts }; } __name(extractMediaContent, "extractMediaContent"); async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config, ctx, session, buffers) { const accept = mediaType === "img" ? "image/*" : "video/*"; const hashStorage = new HashManager(path5.join(ctx.baseDir, "data", "cave")); await hashStorage.initialize(); const downloadTasks = urls.map(async (url, i) => { const fileName = fileNames[i]; const ext = path5.extname(fileName || url) || (mediaType === "img" ? ".png" : ".mp4"); try { const response = await ctx.http(decodeURIComponent(url).replace(/&amp;/g, "&"), { method: "GET", responseType: "arraybuffer", timeout: 3e4, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Accept": accept, "Referer": "https://qq.com" } }); if (!response.data) throw new Error("empty_response"); const buffer = Buffer.from(response.data); if (buffers && mediaType === "img") { buffers.push(buffer); } const md5 = path5.basename(fileName || `${mediaType}`, ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ""); const files = await fs5.promises.readdir(resourceDir); const duplicateFile = files.find((file) => { const match = file.match(/^\d+_([^.]+)/); return match && match[1] === md5; }); if (duplicateFile) { const duplicateCaveId = parseInt(duplicateFile.split("_")[0]); if (!isNaN(duplicateCaveId)) { const caveFilePath = path5.join(ctx.baseDir, "data", "cave", "cave.json"); const data = await FileHandler.readJsonData(caveFilePath);