koishi-plugin-best-cave 2.6.1 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/index.js +1178 -0
  2. package/package.json +6 -2
package/lib/index.js ADDED
@@ -0,0 +1,1178 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
8
+ var __export = (target, all) => {
9
+ for (var name2 in all)
10
+ __defProp(target, name2, { get: all[name2], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ Config: () => Config,
34
+ apply: () => apply,
35
+ inject: () => inject,
36
+ name: () => name,
37
+ usage: () => usage
38
+ });
39
+ module.exports = __toCommonJS(src_exports);
40
+ var import_koishi3 = require("koishi");
41
+
42
+ // src/FileManager.ts
43
+ var import_client_s3 = require("@aws-sdk/client-s3");
44
+ var fs = __toESM(require("fs/promises"));
45
+ var path = __toESM(require("path"));
46
+ var FileManager = class {
47
+ /**
48
+ * @constructor
49
+ * @param baseDir Koishi 应用的基础数据目录 (ctx.baseDir)。
50
+ * @param config 插件的配置对象。
51
+ * @param logger 日志记录器实例。
52
+ */
53
+ constructor(baseDir, config, logger2) {
54
+ this.logger = logger2;
55
+ this.resourceDir = path.join(baseDir, "data", "cave");
56
+ if (config.enableS3 && config.endpoint && config.bucket && config.accessKeyId && config.secretAccessKey) {
57
+ this.s3Client = new import_client_s3.S3Client({
58
+ endpoint: config.endpoint,
59
+ region: config.region,
60
+ credentials: {
61
+ accessKeyId: config.accessKeyId,
62
+ secretAccessKey: config.secretAccessKey
63
+ }
64
+ });
65
+ this.s3Bucket = config.bucket;
66
+ }
67
+ }
68
+ static {
69
+ __name(this, "FileManager");
70
+ }
71
+ resourceDir;
72
+ locks = /* @__PURE__ */ new Map();
73
+ s3Client;
74
+ s3Bucket;
75
+ /**
76
+ * @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。
77
+ * @template T 异步操作的返回类型。
78
+ * @param fullPath 需要加锁的文件的完整路径。
79
+ * @param operation 要执行的异步函数。
80
+ * @returns 异步操作的结果。
81
+ */
82
+ async withLock(fullPath, operation) {
83
+ while (this.locks.has(fullPath)) await this.locks.get(fullPath);
84
+ const promise = operation().finally(() => {
85
+ this.locks.delete(fullPath);
86
+ });
87
+ this.locks.set(fullPath, promise);
88
+ return promise;
89
+ }
90
+ /**
91
+ * @description 保存文件,自动选择 S3 或本地存储。
92
+ * @param fileName 用作 S3 Key 或本地文件名。
93
+ * @param data 要写入的 Buffer 数据。
94
+ * @returns 保存时使用的文件名。
95
+ */
96
+ async saveFile(fileName, data) {
97
+ if (this.s3Client) {
98
+ const command = new import_client_s3.PutObjectCommand({
99
+ Bucket: this.s3Bucket,
100
+ Key: fileName,
101
+ Body: data,
102
+ ACL: "public-read"
103
+ });
104
+ await this.s3Client.send(command);
105
+ } else {
106
+ await fs.mkdir(this.resourceDir, { recursive: true }).catch((error) => {
107
+ this.logger.error(`创建资源目录失败 ${this.resourceDir}:`, error);
108
+ throw error;
109
+ });
110
+ const filePath = path.join(this.resourceDir, fileName);
111
+ await this.withLock(filePath, () => fs.writeFile(filePath, data));
112
+ }
113
+ return fileName;
114
+ }
115
+ /**
116
+ * @description 读取文件,自动从 S3 或本地存储读取。
117
+ * @param fileName 要读取的文件名/标识符。
118
+ * @returns 文件的 Buffer 数据。
119
+ */
120
+ async readFile(fileName) {
121
+ if (this.s3Client) {
122
+ const command = new import_client_s3.GetObjectCommand({ Bucket: this.s3Bucket, Key: fileName });
123
+ const response = await this.s3Client.send(command);
124
+ return Buffer.from(await response.Body.transformToByteArray());
125
+ } else {
126
+ const filePath = path.join(this.resourceDir, fileName);
127
+ return this.withLock(filePath, () => fs.readFile(filePath));
128
+ }
129
+ }
130
+ /**
131
+ * @description 删除文件,自动从 S3 或本地删除。
132
+ * @param fileIdentifier 要删除的文件名/标识符。
133
+ */
134
+ async deleteFile(fileIdentifier) {
135
+ try {
136
+ if (this.s3Client) {
137
+ await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier }));
138
+ } else {
139
+ const filePath = path.join(this.resourceDir, fileIdentifier);
140
+ await this.withLock(filePath, () => fs.unlink(filePath));
141
+ }
142
+ } catch (error) {
143
+ if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
144
+ this.logger.warn(`删除文件 ${fileIdentifier} 失败:`, error);
145
+ }
146
+ }
147
+ }
148
+ };
149
+
150
+ // src/NameManager.ts
151
+ var NameManager = class {
152
+ /**
153
+ * @constructor
154
+ * @param ctx - Koishi 上下文,用于初始化数据库模型。
155
+ */
156
+ constructor(ctx) {
157
+ this.ctx = ctx;
158
+ this.ctx.model.extend("cave_user", {
159
+ userId: "string",
160
+ nickname: "string"
161
+ }, {
162
+ primary: "userId"
163
+ });
164
+ }
165
+ static {
166
+ __name(this, "NameManager");
167
+ }
168
+ /**
169
+ * @description 注册 `.name` 子命令,用于管理用户昵称。
170
+ * @param cave - 主 `cave` 命令实例。
171
+ */
172
+ registerCommands(cave) {
173
+ cave.subcommand(".name [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
174
+ const trimmedNickname = nickname?.trim();
175
+ if (trimmedNickname) {
176
+ await this.setNickname(session.userId, trimmedNickname);
177
+ return `昵称已更新为:${trimmedNickname}`;
178
+ }
179
+ await this.clearNickname(session.userId);
180
+ return "昵称已清除";
181
+ });
182
+ }
183
+ /**
184
+ * @description 设置或更新指定用户的昵称。
185
+ * @param userId - 目标用户的 ID。
186
+ * @param nickname - 要设置的新昵称。
187
+ */
188
+ async setNickname(userId, nickname) {
189
+ await this.ctx.database.upsert("cave_user", [{ userId, nickname }]);
190
+ }
191
+ /**
192
+ * @description 获取指定用户的昵称。
193
+ * @param userId - 目标用户的 ID。
194
+ * @returns 用户的昵称字符串或 null。
195
+ */
196
+ async getNickname(userId) {
197
+ const [name2] = await this.ctx.database.get("cave_user", { userId });
198
+ return name2?.nickname ?? null;
199
+ }
200
+ /**
201
+ * @description 清除指定用户的昵称设置。
202
+ * @param userId - 目标用户的 ID。
203
+ */
204
+ async clearNickname(userId) {
205
+ await this.ctx.database.remove("cave_user", { userId });
206
+ }
207
+ };
208
+
209
+ // src/DataManager.ts
210
+ var DataManager = class {
211
+ /**
212
+ * @constructor
213
+ * @param ctx Koishi 上下文,用于数据库操作。
214
+ * @param config 插件配置。
215
+ * @param fileManager 文件管理器实例。
216
+ * @param logger 日志记录器实例。
217
+ */
218
+ constructor(ctx, config, fileManager, logger2) {
219
+ this.ctx = ctx;
220
+ this.config = config;
221
+ this.fileManager = fileManager;
222
+ this.logger = logger2;
223
+ }
224
+ static {
225
+ __name(this, "DataManager");
226
+ }
227
+ /**
228
+ * @description 注册 `.export` 和 `.import` 子命令。
229
+ * @param cave - 主 `cave` 命令实例。
230
+ */
231
+ registerCommands(cave) {
232
+ const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
233
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) return "此指令仅限在管理群组中使用";
234
+ try {
235
+ await session.send("正在处理,请稍候...");
236
+ return await action();
237
+ } catch (error) {
238
+ this.logger.error("数据操作时发生错误:", error);
239
+ return `操作失败: ${error.message}`;
240
+ }
241
+ }, "requireAdmin");
242
+ cave.subcommand(".export", "导出回声洞数据", { hidden: true, authority: 4 }).usage("将所有回声洞数据导出到 cave_export.json 中。").action(requireAdmin(() => this.exportData()));
243
+ cave.subcommand(".import", "导入回声洞数据", { hidden: true, authority: 4 }).usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
244
+ }
245
+ /**
246
+ * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
247
+ * @returns 描述导出结果的消息字符串。
248
+ */
249
+ async exportData() {
250
+ const fileName = "cave_export.json";
251
+ const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
252
+ const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
253
+ await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(portableCaves, null, 2)));
254
+ return `成功导出 ${portableCaves.length} 条数据`;
255
+ }
256
+ /**
257
+ * @description 从 `cave_import.json` 文件导入回声洞数据。
258
+ * @returns 描述导入结果的消息字符串。
259
+ */
260
+ async importData() {
261
+ const fileName = "cave_import.json";
262
+ let importedCaves;
263
+ try {
264
+ const fileContent = await this.fileManager.readFile(fileName);
265
+ importedCaves = JSON.parse(fileContent.toString("utf-8"));
266
+ if (!Array.isArray(importedCaves) || !importedCaves.length) throw new Error("导入文件格式无效或为空");
267
+ } catch (error) {
268
+ throw new Error(`读取导入文件失败: ${error.message}`);
269
+ }
270
+ const [lastCave] = await this.ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
271
+ let startId = (lastCave?.id || 0) + 1;
272
+ const newCavesToInsert = importedCaves.map((cave, index) => ({
273
+ ...cave,
274
+ id: startId + index,
275
+ status: "active"
276
+ }));
277
+ await this.ctx.database.upsert("cave", newCavesToInsert);
278
+ return `成功导入 ${newCavesToInsert.length} 条数据`;
279
+ }
280
+ };
281
+
282
+ // src/PendManager.ts
283
+ var import_koishi2 = require("koishi");
284
+
285
+ // src/Utils.ts
286
+ var import_koishi = require("koishi");
287
+ var path2 = __toESM(require("path"));
288
+ var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
289
+ async function buildCaveMessage(cave, config, fileManager, logger2, platform, prefix) {
290
+ async function transformToH(elements) {
291
+ return Promise.all(elements.map(async (el) => {
292
+ if (el.type === "text") return import_koishi.h.text(el.content);
293
+ if (el.type === "at") return (0, import_koishi.h)("at", { id: el.content });
294
+ if (el.type === "reply") return (0, import_koishi.h)("reply", { id: el.content });
295
+ if (el.type === "face") return (0, import_koishi.h)("face", { id: el.content });
296
+ if (el.type === "forward") {
297
+ try {
298
+ const forwardNodes = Array.isArray(el.content) ? el.content : [];
299
+ const messageNodes = await Promise.all(forwardNodes.map(async (node) => {
300
+ const author = (0, import_koishi.h)("author", { id: node.userId, name: node.userName });
301
+ const contentElements = await transformToH(node.elements);
302
+ const unwrappedContent = [];
303
+ const nestedMessageNodes = [];
304
+ for (const contentEl of contentElements) {
305
+ if (contentEl.type === "message" && contentEl.attrs.forward) {
306
+ nestedMessageNodes.push(...contentEl.children);
307
+ } else {
308
+ unwrappedContent.push(contentEl);
309
+ }
310
+ }
311
+ const resultNodes = [];
312
+ if (unwrappedContent.length > 0) resultNodes.push((0, import_koishi.h)("message", {}, [author, ...unwrappedContent]));
313
+ resultNodes.push(...nestedMessageNodes);
314
+ return resultNodes;
315
+ }));
316
+ return (0, import_koishi.h)("message", { forward: true }, messageNodes.flat());
317
+ } catch (error) {
318
+ logger2.warn(`解析回声洞(${cave.id})合并转发内容失败:`, error);
319
+ return import_koishi.h.text("[合并转发]");
320
+ }
321
+ }
322
+ if (["image", "video", "audio", "file"].includes(el.type)) {
323
+ const fileName = el.file;
324
+ if (!fileName) return (0, import_koishi.h)("p", {}, `[${el.type}]`);
325
+ if (config.enableS3 && config.publicUrl) return (0, import_koishi.h)(el.type, { ...el, src: new URL(fileName, config.publicUrl).href });
326
+ if (config.localPath) return (0, import_koishi.h)(el.type, { ...el, src: `file://${path2.join(config.localPath, fileName)}` });
327
+ try {
328
+ const data2 = await fileManager.readFile(fileName);
329
+ const mimeType = mimeTypeMap[path2.extname(fileName).toLowerCase()] || "application/octet-stream";
330
+ return (0, import_koishi.h)(el.type, { ...el, src: `data:${mimeType};base64,${data2.toString("base64")}` });
331
+ } catch (error) {
332
+ logger2.warn(`转换文件 ${fileName} 为 Base64 失败:`, error);
333
+ return (0, import_koishi.h)("p", {}, `[${el.type}]`);
334
+ }
335
+ }
336
+ return null;
337
+ })).then((hElements) => hElements.flat().filter(Boolean));
338
+ }
339
+ __name(transformToH, "transformToH");
340
+ const caveHElements = await transformToH(cave.elements);
341
+ const data = {
342
+ id: cave.id.toString(),
343
+ name: cave.userName,
344
+ user: cave.userId,
345
+ channel: cave.channelId,
346
+ time: cave.time.toLocaleString()
347
+ };
348
+ const placeholderRegex = /\{([^}]+)\}/g;
349
+ const replacer = /* @__PURE__ */ __name((match, rawContent) => {
350
+ const isReviewMode = !!prefix;
351
+ const [normalPart, reviewPart] = rawContent.split("/", 2);
352
+ const contentToProcess = isReviewMode ? reviewPart !== void 0 ? reviewPart : normalPart : normalPart;
353
+ if (!contentToProcess?.trim()) return "";
354
+ const useMask = contentToProcess.startsWith("*");
355
+ const key = (useMask ? contentToProcess.substring(1) : contentToProcess).trim();
356
+ if (!key) return "";
357
+ const originalValue = data[key];
358
+ if (originalValue === void 0 || originalValue === null) return match;
359
+ const valueStr = String(originalValue);
360
+ if (!useMask) return valueStr;
361
+ const len = valueStr.length;
362
+ if (len <= 5) return valueStr;
363
+ let keep = 0;
364
+ if (len <= 7) keep = 2;
365
+ else keep = 3;
366
+ return `${valueStr.substring(0, keep)}***${valueStr.substring(len - keep)}`;
367
+ }, "replacer");
368
+ const [rawHeader, rawFooter] = config.caveFormat.split("|", 2);
369
+ let header = rawHeader ? rawHeader.replace(placeholderRegex, replacer).trim() : "";
370
+ if (prefix) header = `${prefix}${header}`;
371
+ const footer = rawFooter ? rawFooter.replace(placeholderRegex, replacer).trim() : "";
372
+ const problematicTypes = ["video", "audio", "file", "forward"];
373
+ const placeholderMap = { video: "[视频]", audio: "[音频]", file: "[文件]", forward: "[合并转发]" };
374
+ const containsProblematic = platform === "onebot" && caveHElements.some((el) => problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward);
375
+ if (!containsProblematic) {
376
+ const finalMessage = [];
377
+ if (header) finalMessage.push(header + "\n");
378
+ finalMessage.push(...caveHElements);
379
+ if (footer) finalMessage.push("\n" + footer);
380
+ return [finalMessage.length > 0 ? finalMessage : []];
381
+ }
382
+ const initialMessageContent = [];
383
+ const followUpMessages = [];
384
+ for (const el of caveHElements) {
385
+ if (problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward) {
386
+ const placeholderKey = el.type === "message" && el.attrs.forward ? "forward" : el.type;
387
+ initialMessageContent.push(import_koishi.h.text(placeholderMap[placeholderKey]));
388
+ followUpMessages.push([el]);
389
+ } else {
390
+ initialMessageContent.push(el);
391
+ }
392
+ }
393
+ const finalInitialMessage = [];
394
+ if (header) finalInitialMessage.push(header + "\n");
395
+ finalInitialMessage.push(...initialMessageContent);
396
+ if (footer) finalInitialMessage.push("\n" + footer);
397
+ return [finalInitialMessage, ...followUpMessages].filter((msg) => msg.length > 0);
398
+ }
399
+ __name(buildCaveMessage, "buildCaveMessage");
400
+ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
401
+ try {
402
+ const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
403
+ if (!cavesToDelete.length) return;
404
+ const idsToDelete = cavesToDelete.map((c) => c.id);
405
+ for (const cave of cavesToDelete) await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file)));
406
+ reusableIds.delete(0);
407
+ idsToDelete.forEach((id) => reusableIds.add(id));
408
+ await ctx.database.remove("cave", { id: { $in: idsToDelete } });
409
+ await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
410
+ } catch (error) {
411
+ logger2.error("清理回声洞时发生错误:", error);
412
+ }
413
+ }
414
+ __name(cleanupPendingDeletions, "cleanupPendingDeletions");
415
+ function getScopeQuery(session, config, includeStatus = true) {
416
+ const baseQuery = includeStatus ? { status: "active" } : {};
417
+ return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
418
+ }
419
+ __name(getScopeQuery, "getScopeQuery");
420
+ async function getNextCaveId(ctx, reusableIds) {
421
+ for (const id of reusableIds) {
422
+ if (id > 0) {
423
+ reusableIds.delete(id);
424
+ return id;
425
+ }
426
+ }
427
+ if (reusableIds.has(0)) {
428
+ reusableIds.delete(0);
429
+ const [lastCave] = await ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
430
+ const newId2 = (lastCave?.id || 0) + 1;
431
+ reusableIds.add(0);
432
+ return newId2;
433
+ }
434
+ const allCaveIds = (await ctx.database.get("cave", {}, { fields: ["id"] })).map((c) => c.id);
435
+ const existingIds = new Set(allCaveIds);
436
+ let newId = 1;
437
+ while (existingIds.has(newId)) newId++;
438
+ if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) reusableIds.add(0);
439
+ return newId;
440
+ }
441
+ __name(getNextCaveId, "getNextCaveId");
442
+ async function processMessageElements(sourceElements, newId, session, config, logger2) {
443
+ const mediaToSave = [];
444
+ let mediaIndex = 0;
445
+ const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply", "face": "face" };
446
+ const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
447
+ async function transform(elements) {
448
+ const result = [];
449
+ async function processForwardContent(segments) {
450
+ const innerResult = [];
451
+ for (const segment of segments) {
452
+ const sType = typeMap[segment.type];
453
+ if (!sType) continue;
454
+ if (sType === "text" && segment.data?.text?.trim()) {
455
+ innerResult.push({ type: "text", content: segment.data.text.trim() });
456
+ } else if (sType === "at" && (segment.data?.id || segment.data?.qq)) {
457
+ innerResult.push({ type: "at", content: segment.data.id || segment.data.qq });
458
+ } else if (sType === "reply" && segment.data?.id) {
459
+ innerResult.push({ type: "reply", content: segment.data.id });
460
+ } else if (["image", "video", "audio", "file"].includes(sType) && (segment.data?.src || segment.data?.url)) {
461
+ let fileIdentifier = segment.data.src || segment.data.url;
462
+ if (fileIdentifier.startsWith("http")) {
463
+ const ext = path2.extname(segment.data.file || "") || defaultExtMap[sType];
464
+ const currentMediaIndex = ++mediaIndex;
465
+ const fileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}${ext}`;
466
+ mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
467
+ fileIdentifier = fileName;
468
+ }
469
+ innerResult.push({ type: sType, file: fileIdentifier });
470
+ } else if (sType === "forward" && Array.isArray(segment.data?.content)) {
471
+ const nestedForwardNodes = [];
472
+ for (const nestedNode of segment.data.content) {
473
+ if (!nestedNode.message || !Array.isArray(nestedNode.message)) continue;
474
+ const nestedContentElements = await processForwardContent(nestedNode.message);
475
+ if (nestedContentElements.length > 0) {
476
+ nestedForwardNodes.push({ userId: nestedNode.sender?.user_id, userName: nestedNode.sender?.nickname, elements: nestedContentElements });
477
+ }
478
+ }
479
+ if (nestedForwardNodes.length > 0) innerResult.push({ type: "forward", content: nestedForwardNodes });
480
+ }
481
+ }
482
+ return innerResult;
483
+ }
484
+ __name(processForwardContent, "processForwardContent");
485
+ for (const el of elements) {
486
+ const type = typeMap[el.type];
487
+ if (!type) {
488
+ if (el.children) result.push(...await transform(el.children));
489
+ continue;
490
+ }
491
+ if (type === "text" && el.attrs.content?.trim()) {
492
+ result.push({ type: "text", content: el.attrs.content.trim() });
493
+ } else if (type === "at" && el.attrs.id) {
494
+ result.push({ type: "at", content: el.attrs.id });
495
+ } else if (type === "reply" && el.attrs.id) {
496
+ result.push({ type: "reply", content: el.attrs.id });
497
+ } else if (type === "forward" && Array.isArray(el.attrs.content)) {
498
+ const forwardNodes = [];
499
+ for (const node of el.attrs.content) {
500
+ if (!node.message || !Array.isArray(node.message)) continue;
501
+ const contentElements = await processForwardContent(node.message);
502
+ if (contentElements.length > 0) {
503
+ forwardNodes.push({ userId: node.sender?.user_id, userName: node.sender?.nickname, elements: contentElements });
504
+ }
505
+ }
506
+ if (forwardNodes.length > 0) result.push({ type: "forward", content: forwardNodes });
507
+ } else if (["image", "video", "audio", "file"].includes(type) && el.attrs.src) {
508
+ let fileIdentifier = el.attrs.src;
509
+ if (fileIdentifier.startsWith("http")) {
510
+ const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type];
511
+ const currentMediaIndex = ++mediaIndex;
512
+ const fileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}${ext}`;
513
+ mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
514
+ fileIdentifier = fileName;
515
+ }
516
+ result.push({ type, file: fileIdentifier });
517
+ } else if (type === "face" && el.attrs.id) {
518
+ result.push({ type: "face", content: el.attrs.id });
519
+ }
520
+ }
521
+ return result;
522
+ }
523
+ __name(transform, "transform");
524
+ const finalElementsForDb = await transform(sourceElements);
525
+ return { finalElementsForDb, mediaToSave };
526
+ }
527
+ __name(processMessageElements, "processMessageElements");
528
+ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToToSave, reusableIds, session, hashManager, textHashesToStore) {
529
+ try {
530
+ const downloadedMedia = [];
531
+ const imageHashesToStore = [];
532
+ const allExistingImageHashes = hashManager ? await ctx.database.get("cave_hash", { type: "phash" }) : [];
533
+ for (const media of mediaToToSave) {
534
+ const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
535
+ downloadedMedia.push({ fileName: media.fileName, buffer });
536
+ if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
537
+ const imageHash = await hashManager.generatePHash(buffer, 256);
538
+ for (const existing of allExistingImageHashes) {
539
+ const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
540
+ if (similarity >= config.imageThreshold) {
541
+ await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
542
+ await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
543
+ cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
544
+ return;
545
+ }
546
+ }
547
+ imageHashesToStore.push({ hash: imageHash, type: "phash" });
548
+ }
549
+ }
550
+ await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
551
+ const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
552
+ const finalStatus = needsReview ? "pending" : "active";
553
+ await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
554
+ if (hashManager) {
555
+ const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
556
+ if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
557
+ }
558
+ if (finalStatus === "pending" && reviewManager) {
559
+ const [finalCave] = await ctx.database.get("cave", { id: cave.id });
560
+ if (finalCave) reviewManager.sendForPend(finalCave);
561
+ }
562
+ } catch (fileProcessingError) {
563
+ logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
564
+ await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
565
+ cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
566
+ }
567
+ }
568
+ __name(handleFileUploads, "handleFileUploads");
569
+
570
+ // src/PendManager.ts
571
+ var PendManager = class {
572
+ /**
573
+ * @param ctx Koishi 上下文。
574
+ * @param config 插件配置。
575
+ * @param fileManager 文件管理器实例。
576
+ * @param logger 日志记录器实例。
577
+ * @param reusableIds 可复用 ID 的内存缓存。
578
+ */
579
+ constructor(ctx, config, fileManager, logger2, reusableIds) {
580
+ this.ctx = ctx;
581
+ this.config = config;
582
+ this.fileManager = fileManager;
583
+ this.logger = logger2;
584
+ this.reusableIds = reusableIds;
585
+ }
586
+ static {
587
+ __name(this, "PendManager");
588
+ }
589
+ /**
590
+ * @description 注册与审核相关的子命令。
591
+ * @param cave - 主 `cave` 命令实例。
592
+ */
593
+ registerCommands(cave) {
594
+ const requireAdmin = /* @__PURE__ */ __name((session) => {
595
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) return "此指令仅限在管理群组中使用";
596
+ return null;
597
+ }, "requireAdmin");
598
+ const pend = cave.subcommand(".pend [id:posint]", "审核回声洞", { hidden: true }).usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => {
599
+ const adminError = requireAdmin(session);
600
+ if (adminError) return adminError;
601
+ if (id) {
602
+ const [targetCave] = await this.ctx.database.get("cave", { id });
603
+ if (!targetCave) return `回声洞(${id})不存在`;
604
+ if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
605
+ const caveMessages = await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger, session.platform, "待审核");
606
+ for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi2.h.normalize(message));
607
+ return;
608
+ }
609
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
610
+ if (!pendingCaves.length) return "当前没有需要审核的回声洞";
611
+ return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
612
+ ${pendingCaves.map((c) => c.id).join("|")}`;
613
+ });
614
+ const createPendAction = /* @__PURE__ */ __name((actionType) => async ({ session }, ...ids) => {
615
+ const adminError = requireAdmin(session);
616
+ if (adminError) return adminError;
617
+ let idsToProcess = ids;
618
+ if (idsToProcess.length === 0) {
619
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
620
+ if (!pendingCaves.length) return "当前没有需要审核的回声洞";
621
+ idsToProcess = pendingCaves.map((c) => c.id);
622
+ }
623
+ try {
624
+ const targetStatus = actionType === "approve" ? "active" : "delete";
625
+ const actionText = actionType === "approve" ? "通过" : "拒绝";
626
+ const cavesToProcess = await this.ctx.database.get("cave", {
627
+ id: { $in: idsToProcess },
628
+ status: "pending"
629
+ });
630
+ if (cavesToProcess.length === 0) return `回声洞(${idsToProcess.join("|")})无需审核或不存在`;
631
+ const processedIds = cavesToProcess.map((cave2) => cave2.id);
632
+ await this.ctx.database.upsert("cave", processedIds.map((id) => ({ id, status: targetStatus })));
633
+ if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
634
+ return `已${actionText}回声洞(${processedIds.join("|")})`;
635
+ } catch (error) {
636
+ this.logger.error(`审核操作失败:`, error);
637
+ return `操作失败: ${error.message}`;
638
+ }
639
+ }, "createPendAction");
640
+ pend.subcommand(".Y [...ids:posint]", "通过审核").usage("通过一个或多个指定 ID 的回声洞审核。若不指定 ID,则通过所有待审核的回声洞。").action(createPendAction("approve"));
641
+ pend.subcommand(".N [...ids:posint]", "拒绝审核").usage("拒绝一个或多个指定 ID 的回声洞审核。若不指定 ID,则拒绝所有待审核的回声洞。").action(createPendAction("reject"));
642
+ }
643
+ /**
644
+ * @description 将新回声洞提交到管理群组以供审核。
645
+ * @param cave 新创建的、状态为 'pending' 的回声洞对象。
646
+ */
647
+ async sendForPend(cave) {
648
+ if (!this.config.adminChannel?.includes(":")) {
649
+ this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`);
650
+ await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
651
+ return;
652
+ }
653
+ try {
654
+ const [platform] = this.config.adminChannel.split(":", 1);
655
+ const caveMessages = await buildCaveMessage(cave, this.config, this.fileManager, this.logger, platform, "待审核");
656
+ for (const message of caveMessages) if (message.length > 0) await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(message));
657
+ } catch (error) {
658
+ this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
659
+ }
660
+ }
661
+ };
662
+
663
+ // src/HashManager.ts
664
+ var import_sharp = __toESM(require("sharp"));
665
+ var crypto = __toESM(require("crypto"));
666
+ var HashManager = class {
667
+ /**
668
+ * @constructor
669
+ * @param ctx - Koishi 上下文,用于数据库操作。
670
+ * @param config - 插件配置,用于获取相似度阈值等。
671
+ * @param logger - 日志记录器实例。
672
+ * @param fileManager - 文件管理器实例,用于读取图片文件。
673
+ */
674
+ constructor(ctx, config, logger2, fileManager) {
675
+ this.ctx = ctx;
676
+ this.config = config;
677
+ this.logger = logger2;
678
+ this.fileManager = fileManager;
679
+ this.ctx.model.extend("cave_hash", {
680
+ cave: "unsigned",
681
+ hash: "string",
682
+ type: "string"
683
+ }, {
684
+ primary: ["cave", "hash", "type"],
685
+ indexes: ["type"]
686
+ });
687
+ }
688
+ static {
689
+ __name(this, "HashManager");
690
+ }
691
+ /**
692
+ * @description 注册与哈希功能相关的 `.hash` 和 `.check` 子命令。
693
+ * @param cave - 主 `cave` 命令实例。
694
+ */
695
+ registerCommands(cave) {
696
+ const adminCheck = /* @__PURE__ */ __name(({ session }) => {
697
+ const adminChannelId = this.config.adminChannel?.split(":")[1];
698
+ if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
699
+ }, "adminCheck");
700
+ cave.subcommand(".hash", "校验回声洞", { hidden: true, authority: 3 }).usage("校验缺失哈希的回声洞,补全哈希记录。").action(async (argv) => {
701
+ const checkResult = adminCheck(argv);
702
+ if (checkResult) return checkResult;
703
+ await argv.session.send("正在处理,请稍候...");
704
+ try {
705
+ return await this.generateHashesForHistoricalCaves();
706
+ } catch (error) {
707
+ this.logger.error("生成历史哈希失败:", error);
708
+ return `操作失败: ${error.message}`;
709
+ }
710
+ });
711
+ cave.subcommand(".check", "检查相似度", { hidden: true }).usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async (argv) => {
712
+ const checkResult = adminCheck(argv);
713
+ if (checkResult) return checkResult;
714
+ await argv.session.send("正在检查,请稍候...");
715
+ try {
716
+ return await this.checkForSimilarCaves(argv.options);
717
+ } catch (error) {
718
+ this.logger.error("检查相似度失败:", error);
719
+ return `检查失败: ${error.message}`;
720
+ }
721
+ });
722
+ }
723
+ /**
724
+ * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。
725
+ * @returns 一个包含操作结果的报告字符串。
726
+ */
727
+ async generateHashesForHistoricalCaves() {
728
+ const allCaves = await this.ctx.database.get("cave", { status: "active" });
729
+ const existingHashes = await this.ctx.database.get("cave_hash", {});
730
+ const existingHashSet = new Set(existingHashes.map((h4) => `${h4.cave}-${h4.hash}-${h4.type}`));
731
+ if (allCaves.length === 0) return "无需补全回声洞哈希";
732
+ this.logger.info(`开始补全 ${allCaves.length} 个回声洞的哈希...`);
733
+ let hashesToInsert = [];
734
+ let processedCaveCount = 0;
735
+ let totalHashesGenerated = 0;
736
+ let errorCount = 0;
737
+ const flushBatch = /* @__PURE__ */ __name(async () => {
738
+ if (hashesToInsert.length === 0) return;
739
+ await this.ctx.database.upsert("cave_hash", hashesToInsert);
740
+ totalHashesGenerated += hashesToInsert.length;
741
+ this.logger.info(`[${processedCaveCount}/${allCaves.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`);
742
+ hashesToInsert = [];
743
+ }, "flushBatch");
744
+ for (const cave of allCaves) {
745
+ processedCaveCount++;
746
+ try {
747
+ const newHashesForCave = await this.generateAllHashesForCave(cave);
748
+ for (const hashObj of newHashesForCave) {
749
+ const uniqueKey = `${hashObj.cave}-${hashObj.hash}-${hashObj.type}`;
750
+ if (!existingHashSet.has(uniqueKey)) {
751
+ hashesToInsert.push(hashObj);
752
+ existingHashSet.add(uniqueKey);
753
+ }
754
+ }
755
+ if (hashesToInsert.length >= 100) await flushBatch();
756
+ } catch (error) {
757
+ errorCount++;
758
+ this.logger.warn(`补全回声洞(${cave.id})哈希时发生错误: ${error.message}`);
759
+ }
760
+ }
761
+ await flushBatch();
762
+ return `已补全 ${allCaves.length} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`;
763
+ }
764
+ /**
765
+ * @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
766
+ * @param cave - 回声洞对象。
767
+ * @returns 生成的哈希对象数组。
768
+ */
769
+ async generateAllHashesForCave(cave) {
770
+ const tempHashes = [];
771
+ const uniqueHashTracker = /* @__PURE__ */ new Set();
772
+ const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
773
+ const key = `${hashObj.hash}-${hashObj.type}`;
774
+ if (!uniqueHashTracker.has(key)) {
775
+ tempHashes.push(hashObj);
776
+ uniqueHashTracker.add(key);
777
+ }
778
+ }, "addUniqueHash");
779
+ const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
780
+ if (combinedText) {
781
+ const textHash = this.generateTextSimhash(combinedText);
782
+ if (textHash) addUniqueHash({ cave: cave.id, hash: textHash, type: "simhash" });
783
+ }
784
+ for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
785
+ try {
786
+ const imageBuffer = await this.fileManager.readFile(el.file);
787
+ const imageHash = await this.generatePHash(imageBuffer, 256);
788
+ addUniqueHash({ cave: cave.id, hash: imageHash, type: "phash" });
789
+ } catch (e) {
790
+ this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, e);
791
+ }
792
+ }
793
+ return tempHashes;
794
+ }
795
+ /**
796
+ * @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。
797
+ * @param options 包含临时阈值的可选对象。
798
+ * @returns 一个包含检查结果的报告字符串。
799
+ */
800
+ async checkForSimilarCaves(options = {}) {
801
+ const textThreshold = options.textThreshold ?? this.config.textThreshold;
802
+ const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
803
+ const allHashes = await this.ctx.database.get("cave_hash", {});
804
+ const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
805
+ const textHashes = /* @__PURE__ */ new Map();
806
+ const imageHashes = /* @__PURE__ */ new Map();
807
+ for (const hash of allHashes) {
808
+ if (hash.type === "simhash") {
809
+ textHashes.set(hash.cave, hash.hash);
810
+ } else if (hash.type === "phash") {
811
+ imageHashes.set(hash.cave, hash.hash);
812
+ }
813
+ }
814
+ const similarPairs = {
815
+ text: /* @__PURE__ */ new Set(),
816
+ image: /* @__PURE__ */ new Set()
817
+ };
818
+ for (let i = 0; i < allCaveIds.length; i++) {
819
+ for (let j = i + 1; j < allCaveIds.length; j++) {
820
+ const id1 = allCaveIds[i];
821
+ const id2 = allCaveIds[j];
822
+ const pair = [id1, id2].sort((a, b) => a - b).join(" & ");
823
+ const text1 = textHashes.get(id1);
824
+ const text2 = textHashes.get(id2);
825
+ if (text1 && text2) {
826
+ const similarity = this.calculateSimilarity(text1, text2);
827
+ if (similarity >= textThreshold) similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`);
828
+ }
829
+ const image1 = imageHashes.get(id1);
830
+ const image2 = imageHashes.get(id2);
831
+ if (image1 && image2) {
832
+ const similarity = this.calculateSimilarity(image1, image2);
833
+ if (similarity >= imageThreshold) similarPairs.image.add(`${pair} = ${similarity.toFixed(2)}%`);
834
+ }
835
+ }
836
+ }
837
+ const totalFindings = similarPairs.text.size + similarPairs.image.size;
838
+ if (totalFindings === 0) return "未发现高相似度的内容";
839
+ let report = `已发现 ${totalFindings} 组高相似度的内容:`;
840
+ if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
841
+ if (similarPairs.image.size > 0) report += "\n图片内容相似:\n" + [...similarPairs.image].join("\n");
842
+ return report.trim();
843
+ }
844
+ /**
845
+ * @description 执行二维离散余弦变换 (DCT-II)。
846
+ * @param matrix - 输入的 N x N 像素亮度矩阵。
847
+ * @returns DCT变换后的 N x N 系数矩阵。
848
+ */
849
+ _dct2D(matrix) {
850
+ const N = matrix.length;
851
+ if (N === 0) return [];
852
+ const cosines = Array.from(
853
+ { length: N },
854
+ (_, i) => Array.from({ length: N }, (_2, j) => Math.cos(Math.PI * (2 * i + 1) * j / (2 * N)))
855
+ );
856
+ const applyDct1D = /* @__PURE__ */ __name((input) => {
857
+ const output = new Array(N).fill(0);
858
+ const scale = Math.sqrt(2 / N);
859
+ for (let k = 0; k < N; k++) {
860
+ let sum = 0;
861
+ for (let n = 0; n < N; n++) sum += input[n] * cosines[n][k];
862
+ output[k] = scale * sum;
863
+ }
864
+ output[0] /= Math.sqrt(2);
865
+ return output;
866
+ }, "applyDct1D");
867
+ const tempMatrix = matrix.map((row) => applyDct1D(row));
868
+ const transposed = tempMatrix[0].map((_, col) => tempMatrix.map((row) => row[col]));
869
+ const dctResult = transposed.map((row) => applyDct1D(row));
870
+ return dctResult[0].map((_, col) => dctResult.map((row) => row[col]));
871
+ }
872
+ /**
873
+ * @description pHash 算法核心实现。
874
+ * @param imageBuffer - 图片的Buffer。
875
+ * @param size - 期望的哈希位数 (必须是完全平方数, 如 64 或 256)。
876
+ * @returns 十六进制pHash字符串。
877
+ */
878
+ async generatePHash(imageBuffer, size) {
879
+ const dctSize = 32;
880
+ const hashGridSize = Math.sqrt(size);
881
+ if (!Number.isInteger(hashGridSize)) throw new Error("哈希位数必须是完全平方数");
882
+ const pixels = await (0, import_sharp.default)(imageBuffer).grayscale().resize(dctSize, dctSize, { fit: "fill" }).raw().toBuffer();
883
+ const matrix = [];
884
+ for (let y = 0; y < dctSize; y++) matrix.push(Array.from(pixels.slice(y * dctSize, (y + 1) * dctSize)));
885
+ const dctMatrix = this._dct2D(matrix);
886
+ const coefficients = [];
887
+ for (let y = 0; y < hashGridSize; y++) for (let x = 0; x < hashGridSize; x++) coefficients.push(dctMatrix[y][x]);
888
+ const median = [...coefficients.slice(1)].sort((a, b) => a - b)[Math.floor((coefficients.length - 1) / 2)];
889
+ const binaryHash = coefficients.map((val) => val > median ? "1" : "0").join("");
890
+ return BigInt("0b" + binaryHash).toString(16).padStart(size / 4, "0");
891
+ }
892
+ /**
893
+ * @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。
894
+ * @param hex1 - 第一个哈希。
895
+ * @param hex2 - 第二个哈希。
896
+ * @returns 汉明距离。
897
+ */
898
+ calculateHammingDistance(hex1, hex2) {
899
+ let distance = 0;
900
+ const bin1 = hexToBinary(hex1);
901
+ const bin2 = hexToBinary(hex2);
902
+ const len = Math.min(bin1.length, bin2.length);
903
+ for (let i = 0; i < len; i++) if (bin1[i] !== bin2[i]) distance++;
904
+ return distance;
905
+ }
906
+ /**
907
+ * @description 根据汉明距离计算相似度百分比。
908
+ * @param hex1 - 第一个哈希。
909
+ * @param hex2 - 第二个哈希。
910
+ * @returns 相似度 (0-100)。
911
+ */
912
+ calculateSimilarity(hex1, hex2) {
913
+ const distance = this.calculateHammingDistance(hex1, hex2);
914
+ const hashLength = Math.max(hex1.length, hex2.length) * 4;
915
+ return hashLength === 0 ? 100 : (1 - distance / hashLength) * 100;
916
+ }
917
+ /**
918
+ * @description 为文本生成 64 位 Simhash 字符串。
919
+ * @param text - 需要处理的文本。
920
+ * @returns 16位十六进制 Simhash 字符串。
921
+ */
922
+ generateTextSimhash(text) {
923
+ const cleanText = (text || "").toLowerCase().replace(/\s+/g, "");
924
+ if (!cleanText) return "";
925
+ const n = 2;
926
+ const tokens = /* @__PURE__ */ new Set();
927
+ if (cleanText.length < n) {
928
+ tokens.add(cleanText);
929
+ } else {
930
+ for (let i = 0; i <= cleanText.length - n; i++) tokens.add(cleanText.substring(i, i + n));
931
+ }
932
+ const tokenArray = Array.from(tokens);
933
+ if (tokenArray.length === 0) return "";
934
+ const vector = new Array(64).fill(0);
935
+ tokenArray.forEach((token) => {
936
+ const hash = crypto.createHash("md5").update(token).digest();
937
+ for (let i = 0; i < 64; i++) vector[i] += hash[Math.floor(i / 8)] >> i % 8 & 1 ? 1 : -1;
938
+ });
939
+ const binaryHash = vector.map((v) => v > 0 ? "1" : "0").join("");
940
+ return BigInt("0b" + binaryHash).toString(16).padStart(16, "0");
941
+ }
942
+ };
943
+ function hexToBinary(hex) {
944
+ let bin = "";
945
+ for (const char of hex) bin += parseInt(char, 16).toString(2).padStart(4, "0");
946
+ return bin;
947
+ }
948
+ __name(hexToBinary, "hexToBinary");
949
+
950
+ // src/index.ts
951
+ var name = "best-cave";
952
+ var inject = ["database"];
953
+ var usage = `
954
+ <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);">
955
+ <h2 style="margin-top: 0; color: #4a6ee0;">📌 插件说明</h2>
956
+ <p>📖 <strong>使用文档</strong>:请点击左上角的 <strong>插件主页</strong> 查看插件使用文档</p>
957
+ <p>🔍 <strong>更多插件</strong>:可访问 <a href="https://github.com/YisRime" style="color:#4a6ee0;text-decoration:none;">苡淞的 GitHub</a> 查看本人的所有插件</p>
958
+ </div>
959
+ <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);">
960
+ <h2 style="margin-top: 0; color: #e0574a;">❤️ 支持与反馈</h2>
961
+ <p>🌟 喜欢这个插件?请在 <a href="https://github.com/YisRime" style="color:#e0574a;text-decoration:none;">GitHub</a> 上给我一个 Star!</p>
962
+ <p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p>
963
+ </div>
964
+ `;
965
+ var logger = new import_koishi3.Logger("best-cave");
966
+ var Config = import_koishi3.Schema.intersect([
967
+ import_koishi3.Schema.object({
968
+ perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
969
+ enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
970
+ enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
971
+ adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
972
+ caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本(参见 README)")
973
+ }).description("基础配置"),
974
+ import_koishi3.Schema.object({
975
+ enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
976
+ enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
977
+ textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("文本相似度阈值 (%)"),
978
+ imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(90).description("图片相似度阈值 (%)")
979
+ }).description("复核配置"),
980
+ import_koishi3.Schema.object({
981
+ localPath: import_koishi3.Schema.string().description("文件映射路径"),
982
+ enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
983
+ publicUrl: import_koishi3.Schema.string().description("公共访问 URL").role("link"),
984
+ endpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link"),
985
+ bucket: import_koishi3.Schema.string().description("存储桶 (Bucket)"),
986
+ region: import_koishi3.Schema.string().default("auto").description("区域 (Region)"),
987
+ accessKeyId: import_koishi3.Schema.string().description("Access Key ID").role("secret"),
988
+ secretAccessKey: import_koishi3.Schema.string().description("Secret Access Key").role("secret")
989
+ }).description("存储配置")
990
+ ]);
991
+ function apply(ctx, config) {
992
+ ctx.model.extend("cave", {
993
+ id: "unsigned",
994
+ elements: "json",
995
+ channelId: "string",
996
+ userId: "string",
997
+ userName: "string",
998
+ status: "string",
999
+ time: "timestamp"
1000
+ }, {
1001
+ primary: "id",
1002
+ indexes: ["status", "channelId", "userId"]
1003
+ });
1004
+ const fileManager = new FileManager(ctx.baseDir, config, logger);
1005
+ const reusableIds = /* @__PURE__ */ new Set();
1006
+ const profileManager = config.enableName ? new NameManager(ctx) : null;
1007
+ const reviewManager = config.enablePend ? new PendManager(ctx, config, fileManager, logger, reusableIds) : null;
1008
+ const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
1009
+ const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger) : null;
1010
+ ctx.on("ready", async () => {
1011
+ try {
1012
+ const staleCaves = await ctx.database.get("cave", { status: "preload" });
1013
+ if (staleCaves.length > 0) {
1014
+ const idsToMark = staleCaves.map((c) => ({ id: c.id, status: "delete" }));
1015
+ await ctx.database.upsert("cave", idsToMark);
1016
+ await cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1017
+ }
1018
+ } catch (error) {
1019
+ logger.error("清理残留回声洞时发生错误:", error);
1020
+ }
1021
+ });
1022
+ 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 }) => {
1023
+ if (options.add) return session.execute(`cave.add ${options.add}`);
1024
+ if (options.view) return session.execute(`cave.view ${options.view}`);
1025
+ if (options.delete) return session.execute(`cave.del ${options.delete}`);
1026
+ if (options.list) return session.execute("cave.list");
1027
+ try {
1028
+ const query = getScopeQuery(session, config);
1029
+ const candidates = await ctx.database.get("cave", query, { fields: ["id"] });
1030
+ if (!candidates.length) return `当前${config.perChannel && session.channelId ? "本群" : ""}还没有任何回声洞`;
1031
+ const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
1032
+ const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId });
1033
+ const messages = await buildCaveMessage(randomCave, config, fileManager, logger, session.platform);
1034
+ for (const message of messages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1035
+ } catch (error) {
1036
+ logger.error("随机获取回声洞失败:", error);
1037
+ return "随机获取回声洞失败";
1038
+ }
1039
+ });
1040
+ cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
1041
+ try {
1042
+ let sourceElements;
1043
+ if (session.quote?.elements) {
1044
+ sourceElements = session.quote.elements;
1045
+ } else if (content?.trim()) {
1046
+ sourceElements = import_koishi3.h.parse(content);
1047
+ } else {
1048
+ await session.send("请在一分钟内发送你要添加的内容");
1049
+ const reply = await session.prompt(6e4);
1050
+ if (!reply) return "等待操作超时";
1051
+ sourceElements = import_koishi3.h.parse(reply);
1052
+ }
1053
+ const newId = await getNextCaveId(ctx, reusableIds);
1054
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, config, logger);
1055
+ if (finalElementsForDb.length === 0) return "无可添加内容";
1056
+ const textHashesToStore = [];
1057
+ if (hashManager) {
1058
+ const combinedText = finalElementsForDb.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
1059
+ if (combinedText) {
1060
+ const newSimhash = hashManager.generateTextSimhash(combinedText);
1061
+ if (newSimhash) {
1062
+ const existingTextHashes = await ctx.database.get("cave_hash", { type: "simhash" });
1063
+ for (const existing of existingTextHashes) {
1064
+ const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
1065
+ if (similarity >= config.textThreshold) return `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`;
1066
+ }
1067
+ textHashesToStore.push({ hash: newSimhash, type: "simhash" });
1068
+ }
1069
+ }
1070
+ }
1071
+ const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1072
+ const hasMedia = mediaToSave.length > 0;
1073
+ const needsReview = config.enablePend && session.channelId !== config.adminChannel?.split(":")[1];
1074
+ const initialStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
1075
+ const newCave = await ctx.database.create("cave", {
1076
+ id: newId,
1077
+ elements: finalElementsForDb,
1078
+ channelId: session.channelId,
1079
+ userId: session.userId,
1080
+ userName,
1081
+ status: initialStatus,
1082
+ time: /* @__PURE__ */ new Date()
1083
+ });
1084
+ if (hasMedia) {
1085
+ handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
1086
+ } else {
1087
+ if (hashManager && textHashesToStore.length > 0) await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
1088
+ if (initialStatus === "pending") reviewManager.sendForPend(newCave);
1089
+ }
1090
+ return needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
1091
+ } catch (error) {
1092
+ logger.error("添加回声洞失败:", error);
1093
+ return "添加失败,请稍后再试";
1094
+ }
1095
+ });
1096
+ cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
1097
+ if (!id) return "请输入要查看的回声洞序号";
1098
+ try {
1099
+ const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id });
1100
+ if (!targetCave) return `回声洞(${id})不存在`;
1101
+ const messages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform);
1102
+ for (const message of messages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1103
+ } catch (error) {
1104
+ logger.error(`查看回声洞(${id})失败:`, error);
1105
+ return "查看失败,请稍后再试";
1106
+ }
1107
+ });
1108
+ cave.subcommand(".del <id:posint>", "删除指定回声洞").action(async ({ session }, id) => {
1109
+ if (!id) return "请输入要删除的回声洞序号";
1110
+ try {
1111
+ const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
1112
+ if (!targetCave) return `回声洞(${id})不存在`;
1113
+ const isAuthor = targetCave.userId === session.userId;
1114
+ const isAdmin = session.channelId === config.adminChannel?.split(":")[1];
1115
+ if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
1116
+ await ctx.database.upsert("cave", [{ id, status: "delete" }]);
1117
+ const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除");
1118
+ cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1119
+ for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1120
+ } catch (error) {
1121
+ logger.error(`标记回声洞(${id})失败:`, error);
1122
+ return "删除失败,请稍后再试";
1123
+ }
1124
+ });
1125
+ cave.subcommand(".list", "查询投稿统计").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => {
1126
+ if (options.all) {
1127
+ const adminChannelId = config.adminChannel?.split(":")[1];
1128
+ if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
1129
+ try {
1130
+ const aggregatedStats = await ctx.database.select("cave", { status: "active" }).groupBy(["userId", "userName"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.count(row.id), "count") }).execute();
1131
+ if (!aggregatedStats.length) return "目前没有回声洞投稿";
1132
+ const userStats = /* @__PURE__ */ new Map();
1133
+ for (const stat of aggregatedStats) {
1134
+ const existing = userStats.get(stat.userId);
1135
+ if (existing) {
1136
+ existing.count += stat.count;
1137
+ const existingGroup = aggregatedStats.find((s) => s.userId === stat.userId && s.userName === existing.userName);
1138
+ if (stat.count > (existingGroup?.count || 0)) existing.userName = stat.userName;
1139
+ } else {
1140
+ userStats.set(stat.userId, { userName: stat.userName, count: stat.count });
1141
+ }
1142
+ }
1143
+ const sortedStats = Array.from(userStats.values()).sort((a, b) => b.count - a.count);
1144
+ let report = "回声洞投稿数量排行:\n";
1145
+ sortedStats.forEach((stat, index) => {
1146
+ report += `${index + 1}. ${stat.userName}: ${stat.count} 条
1147
+ `;
1148
+ });
1149
+ return report.trim();
1150
+ } catch (error) {
1151
+ logger.error("查询排行失败:", error);
1152
+ return "查询失败,请稍后再试";
1153
+ }
1154
+ }
1155
+ const targetUserId = options.user || session.userId;
1156
+ const isQueryingSelf = !options.user;
1157
+ const query = { ...getScopeQuery(session, config), userId: targetUserId };
1158
+ const userCaves = await ctx.database.get("cave", query);
1159
+ if (!userCaves.length) return isQueryingSelf ? "你还没有投稿过回声洞" : `用户 ${targetUserId} 还没有投稿过回声洞`;
1160
+ const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("|");
1161
+ const userName = userCaves.sort((a, b) => b.time.getTime() - a.time.getTime())[0].userName;
1162
+ return `${isQueryingSelf ? "你" : userName}已投稿 ${userCaves.length} 条回声洞,序号为:
1163
+ ${caveIds}`;
1164
+ });
1165
+ if (profileManager) profileManager.registerCommands(cave);
1166
+ if (dataManager) dataManager.registerCommands(cave);
1167
+ if (reviewManager) reviewManager.registerCommands(cave);
1168
+ if (hashManager) hashManager.registerCommands(cave);
1169
+ }
1170
+ __name(apply, "apply");
1171
+ // Annotate the CommonJS export names for ESM import in node:
1172
+ 0 && (module.exports = {
1173
+ Config,
1174
+ apply,
1175
+ inject,
1176
+ name,
1177
+ usage
1178
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.6.1",
4
+ "version": "2.6.2",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
@@ -30,7 +30,11 @@
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-s3": "^3.800.0"
32
32
  },
33
- "devDependencies": {
33
+ "optionalDependencies": {
34
34
  "sharp": "^0.34.3"
35
+ },
36
+ "koishi": {
37
+ "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
38
+ "insecure": false
35
39
  }
36
40
  }