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