koishi-plugin-best-cave
Version:
回声洞,可自由添加内容(包括视频),具有可配置的 MD5/pHash 查重机制和可开关的审核系统,支持查阅投稿列表
1,383 lines (1,372 loc) • 73.7 kB
JavaScript
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(/&/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);