koishi-plugin-best-cave 2.1.3 → 2.2.0

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.
package/lib/index.js CHANGED
@@ -77,7 +77,7 @@ var FileManager = class {
77
77
  * @template T 异步操作的返回类型。
78
78
  * @param fullPath 需要加锁的文件的完整路径。
79
79
  * @param operation 要执行的异步函数。
80
- * @returns 返回异步操作的结果。
80
+ * @returns 异步操作的结果。
81
81
  */
82
82
  async withLock(fullPath, operation) {
83
83
  while (this.locks.has(fullPath)) {
@@ -93,7 +93,7 @@ var FileManager = class {
93
93
  * @description 保存文件,自动选择 S3 或本地存储。
94
94
  * @param fileName 用作 S3 Key 或本地文件名。
95
95
  * @param data 要写入的 Buffer 数据。
96
- * @returns 返回保存时使用的文件名/标识符。
96
+ * @returns 保存时使用的文件名。
97
97
  */
98
98
  async saveFile(fileName, data) {
99
99
  if (this.s3Client) {
@@ -136,8 +136,7 @@ var FileManager = class {
136
136
  async deleteFile(fileIdentifier) {
137
137
  try {
138
138
  if (this.s3Client) {
139
- const command = new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier });
140
- await this.s3Client.send(command);
139
+ await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier }));
141
140
  } else {
142
141
  const filePath = path.join(this.resourceDir, fileIdentifier);
143
142
  await this.withLock(filePath, () => fs.unlink(filePath));
@@ -160,12 +159,9 @@ var ProfileManager = class {
160
159
  this.ctx = ctx;
161
160
  this.ctx.model.extend("cave_user", {
162
161
  userId: "string",
163
- // 用户 ID
164
162
  nickname: "string"
165
- // 用户自定义昵称
166
163
  }, {
167
164
  primary: "userId"
168
- // 保证每个用户只有一条昵称记录。
169
165
  });
170
166
  }
171
167
  static {
@@ -176,7 +172,7 @@ var ProfileManager = class {
176
172
  * @param cave - 主 `cave` 命令实例。
177
173
  */
178
174
  registerCommands(cave) {
179
- cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置你在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
175
+ cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
180
176
  const trimmedNickname = nickname?.trim();
181
177
  if (trimmedNickname) {
182
178
  await this.setNickname(session.userId, trimmedNickname);
@@ -197,10 +193,10 @@ var ProfileManager = class {
197
193
  /**
198
194
  * @description 获取指定用户的昵称。
199
195
  * @param userId - 目标用户的 ID。
200
- * @returns 返回用户的昵称字符串,如果未设置则返回 null。
196
+ * @returns 用户的昵称字符串或 null。
201
197
  */
202
198
  async getNickname(userId) {
203
- const [profile] = await this.ctx.database.get("cave_user", { userId }, { fields: ["nickname"] });
199
+ const [profile] = await this.ctx.database.get("cave_user", { userId });
204
200
  return profile?.nickname ?? null;
205
201
  }
206
202
  /**
@@ -220,14 +216,14 @@ var DataManager = class {
220
216
  * @param config 插件配置。
221
217
  * @param fileManager 文件管理器实例。
222
218
  * @param logger 日志记录器实例。
223
- * @param reusableIds 可复用 ID 的内存缓存。
219
+ * @param hashManager 哈希管理器实例,用于增量更新哈希。
224
220
  */
225
- constructor(ctx, config, fileManager, logger2, reusableIds) {
221
+ constructor(ctx, config, fileManager, logger2, hashManager) {
226
222
  this.ctx = ctx;
227
223
  this.config = config;
228
224
  this.fileManager = fileManager;
229
225
  this.logger = logger2;
230
- this.reusableIds = reusableIds;
226
+ this.hashManager = hashManager;
231
227
  }
232
228
  static {
233
229
  __name(this, "DataManager");
@@ -238,8 +234,9 @@ var DataManager = class {
238
234
  */
239
235
  registerCommands(cave) {
240
236
  const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
241
- const adminChannelId = this.config.adminChannel?.split(":")[1];
242
- if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
237
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
238
+ return "此指令仅限在管理群组中使用";
239
+ }
243
240
  try {
244
241
  await session.send("正在处理,请稍候...");
245
242
  return await action();
@@ -248,8 +245,8 @@ var DataManager = class {
248
245
  return `操作失败: ${error.message}`;
249
246
  }
250
247
  }, "requireAdmin");
251
- cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(requireAdmin(() => this.exportData()));
252
- cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
248
+ cave.subcommand(".export", "导出回声洞数据").action(requireAdmin(() => this.exportData()));
249
+ cave.subcommand(".import", "导入回声洞数据").action(requireAdmin(() => this.importData()));
253
250
  }
254
251
  /**
255
252
  * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
@@ -259,8 +256,7 @@ var DataManager = class {
259
256
  const fileName = "cave_export.json";
260
257
  const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
261
258
  const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
262
- const data = JSON.stringify(portableCaves, null, 2);
263
- await this.fileManager.saveFile(fileName, Buffer.from(data));
259
+ await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(portableCaves, null, 2)));
264
260
  return `成功导出 ${portableCaves.length} 条数据`;
265
261
  }
266
262
  /**
@@ -273,17 +269,13 @@ var DataManager = class {
273
269
  try {
274
270
  const fileContent = await this.fileManager.readFile(fileName);
275
271
  importedCaves = JSON.parse(fileContent.toString("utf-8"));
276
- if (!Array.isArray(importedCaves)) throw new Error("文件格式无效");
277
- if (!importedCaves.length) throw new Error("导入文件为空");
272
+ if (!Array.isArray(importedCaves) || !importedCaves.length) {
273
+ throw new Error("导入文件格式无效或为空");
274
+ }
278
275
  } catch (error) {
279
- this.logger.error(`读取导入文件失败:`, error);
280
276
  throw new Error(`读取导入文件失败: ${error.message}`);
281
277
  }
282
- const [lastCave] = await this.ctx.database.get("cave", {}, {
283
- sort: { id: "desc" },
284
- limit: 1,
285
- fields: ["id"]
286
- });
278
+ const [lastCave] = await this.ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
287
279
  let startId = (lastCave?.id || 0) + 1;
288
280
  const newCavesToInsert = importedCaves.map((cave, index) => ({
289
281
  ...cave,
@@ -291,8 +283,6 @@ var DataManager = class {
291
283
  status: "active"
292
284
  }));
293
285
  await this.ctx.database.upsert("cave", newCavesToInsert);
294
- this.reusableIds.clear();
295
- await this.ctx.database.remove("cave_hash", {});
296
286
  return `成功导入 ${newCavesToInsert.length} 条数据`;
297
287
  }
298
288
  };
@@ -304,7 +294,6 @@ var import_koishi2 = require("koishi");
304
294
  var import_koishi = require("koishi");
305
295
  var path2 = __toESM(require("path"));
306
296
  var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
307
- var MAX_ID_FLAG = 0;
308
297
  function storedFormatToHElements(elements) {
309
298
  return elements.map((el) => {
310
299
  if (el.type === "text") return import_koishi.h.text(el.content);
@@ -320,8 +309,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
320
309
  const fileName = element.attrs.src;
321
310
  if (!isMedia || !fileName) return element;
322
311
  if (config.enableS3 && config.publicUrl) {
323
- const fullUrl = new URL(fileName, config.publicUrl).href;
324
- return (0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl });
312
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: new URL(fileName, config.publicUrl).href });
325
313
  }
326
314
  if (config.localPath) {
327
315
  return (0, import_koishi.h)(element.type, { ...element.attrs, src: `file://${path2.join(config.localPath, fileName)}` });
@@ -336,8 +324,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
336
324
  }
337
325
  }));
338
326
  const replacements = { id: cave.id.toString(), name: cave.userName };
339
- const formatPart = /* @__PURE__ */ __name((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim(), "formatPart");
340
- const [header, footer] = config.caveFormat.split("|", 2).map(formatPart);
327
+ const [header, footer] = config.caveFormat.split("|", 2).map((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim());
341
328
  const finalMessage = [];
342
329
  if (header) finalMessage.push(header + "\n");
343
330
  finalMessage.push(...processedElements);
@@ -349,51 +336,44 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
349
336
  try {
350
337
  const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
351
338
  if (!cavesToDelete.length) return;
339
+ const idsToDelete = cavesToDelete.map((c) => c.id);
352
340
  for (const cave of cavesToDelete) {
353
- const deletePromises = cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file));
354
- await Promise.all(deletePromises);
355
- reusableIds.add(cave.id);
356
- reusableIds.delete(MAX_ID_FLAG);
357
- await ctx.database.remove("cave", { id: cave.id });
358
- await ctx.database.remove("cave_hash", { cave: cave.id });
341
+ await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file)));
359
342
  }
343
+ reusableIds.delete(0);
344
+ idsToDelete.forEach((id) => reusableIds.add(id));
345
+ await ctx.database.remove("cave", { id: { $in: idsToDelete } });
346
+ await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
360
347
  } catch (error) {
361
348
  logger2.error("清理回声洞时发生错误:", error);
362
349
  }
363
350
  }
364
351
  __name(cleanupPendingDeletions, "cleanupPendingDeletions");
365
- function getScopeQuery(session, config) {
366
- const baseQuery = { status: "active" };
352
+ function getScopeQuery(session, config, includeStatus = true) {
353
+ const baseQuery = includeStatus ? { status: "active" } : {};
367
354
  return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
368
355
  }
369
356
  __name(getScopeQuery, "getScopeQuery");
370
357
  async function getNextCaveId(ctx, query = {}, reusableIds) {
371
358
  for (const id of reusableIds) {
372
- if (id > MAX_ID_FLAG) {
359
+ if (id > 0) {
373
360
  reusableIds.delete(id);
374
361
  return id;
375
362
  }
376
363
  }
377
- if (reusableIds.has(MAX_ID_FLAG)) {
378
- reusableIds.delete(MAX_ID_FLAG);
379
- const [lastCave] = await ctx.database.get("cave", query, {
380
- fields: ["id"],
381
- sort: { id: "desc" },
382
- limit: 1
383
- });
364
+ if (reusableIds.has(0)) {
365
+ reusableIds.delete(0);
366
+ const [lastCave] = await ctx.database.get("cave", query, { sort: { id: "desc" }, limit: 1 });
384
367
  const newId2 = (lastCave?.id || 0) + 1;
385
- reusableIds.add(MAX_ID_FLAG);
368
+ reusableIds.add(0);
386
369
  return newId2;
387
370
  }
388
371
  const allCaveIds = (await ctx.database.get("cave", query, { fields: ["id"] })).map((c) => c.id);
389
372
  const existingIds = new Set(allCaveIds);
390
373
  let newId = 1;
391
- while (existingIds.has(newId)) {
392
- newId++;
393
- }
394
- const maxIdInDb = allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0;
395
- if (existingIds.size === maxIdInDb) {
396
- reusableIds.add(MAX_ID_FLAG);
374
+ while (existingIds.has(newId)) newId++;
375
+ if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) {
376
+ reusableIds.add(0);
397
377
  }
398
378
  return newId;
399
379
  }
@@ -414,35 +394,30 @@ function updateCooldownTimestamp(session, config, lastUsed) {
414
394
  }
415
395
  }
416
396
  __name(updateCooldownTimestamp, "updateCooldownTimestamp");
417
- async function processMessageElements(sourceElements, newId, channelId, userId) {
397
+ async function processMessageElements(sourceElements, newId, session) {
418
398
  const finalElementsForDb = [];
419
399
  const mediaToSave = [];
420
400
  let mediaIndex = 0;
421
- const typeMap = {
422
- "img": "image",
423
- "image": "image",
424
- "video": "video",
425
- "audio": "audio",
426
- "file": "file",
427
- "text": "text"
428
- };
401
+ const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text" };
429
402
  const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
430
403
  async function traverse(elements) {
431
404
  for (const el of elements) {
432
- const normalizedType = typeMap[el.type];
433
- if (normalizedType) {
434
- if (["image", "video", "audio", "file"].includes(normalizedType) && el.attrs.src) {
435
- let fileIdentifier = el.attrs.src;
436
- if (fileIdentifier.startsWith("http")) {
437
- const ext = path2.extname(el.attrs.file || "") || defaultExtMap[normalizedType];
438
- const fileName = `${newId}_${++mediaIndex}_${channelId || "private"}_${userId}${ext}`;
439
- mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
440
- fileIdentifier = fileName;
441
- }
442
- finalElementsForDb.push({ type: normalizedType, file: fileIdentifier });
443
- } else if (normalizedType === "text" && el.attrs.content?.trim()) {
444
- finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
405
+ const type = typeMap[el.type];
406
+ if (!type) {
407
+ if (el.children) await traverse(el.children);
408
+ continue;
409
+ }
410
+ if (type === "text" && el.attrs.content?.trim()) {
411
+ finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
412
+ } else if (type !== "text" && el.attrs.src) {
413
+ let fileIdentifier = el.attrs.src;
414
+ if (fileIdentifier.startsWith("http")) {
415
+ const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type];
416
+ const fileName = `${newId}_${++mediaIndex}_${session.channelId || "private"}_${session.userId}${ext}`;
417
+ mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
418
+ fileIdentifier = fileName;
445
419
  }
420
+ finalElementsForDb.push({ type, file: fileIdentifier });
446
421
  }
447
422
  if (el.children) await traverse(el.children);
448
423
  }
@@ -456,49 +431,35 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
456
431
  try {
457
432
  const downloadedMedia = [];
458
433
  const imageHashesToStore = [];
459
- let allNewImageHashes = [];
460
- if (hashManager) {
461
- for (const media of mediaToSave) {
462
- const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
463
- const buffer = Buffer.from(response);
464
- downloadedMedia.push({ fileName: media.fileName, buffer });
465
- const isImage = [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase());
466
- if (isImage) {
467
- const pHash = await hashManager.generateImagePHash(buffer);
468
- const subHashes = [...await hashManager.generateImageSubHashes(buffer)];
469
- allNewImageHashes.push(pHash, ...subHashes);
470
- imageHashesToStore.push({ hash: pHash, type: "image", subType: "pHash" });
471
- subHashes.forEach((sh) => imageHashesToStore.push({ hash: sh, type: "image", subType: "subImage" }));
472
- }
473
- }
474
- if (allNewImageHashes.length > 0) {
475
- const existingImageHashes = await ctx.database.get("cave_hash", { type: "image" });
434
+ for (const media of mediaToSave) {
435
+ const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
436
+ downloadedMedia.push({ fileName: media.fileName, buffer });
437
+ if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
438
+ const pHash = await hashManager.generateImagePHash(buffer);
439
+ const subHashes = await hashManager.generateImageSubHashes(buffer);
440
+ const allNewImageHashes = [pHash, ...subHashes];
441
+ const existingImageHashes = await ctx.database.get("cave_hash", { type: /^image_/ });
476
442
  for (const newHash of allNewImageHashes) {
477
443
  for (const existing of existingImageHashes) {
478
- const similarity = hashManager.calculateImageSimilarity(newHash, existing.hash);
444
+ const similarity = hashManager.calculateSimilarity(newHash, existing.hash);
479
445
  if (similarity >= config.imageThreshold) {
480
446
  await session.send(`图片与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`);
481
447
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
482
- cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
448
+ reusableIds.add(cave.id);
483
449
  return;
484
450
  }
485
451
  }
486
452
  }
487
- }
488
- } else {
489
- for (const media of mediaToSave) {
490
- const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
491
- downloadedMedia.push({ fileName: media.fileName, buffer: Buffer.from(response) });
453
+ const pHashEntry = { hash: pHash, type: "phash" };
454
+ const subHashEntries = [...subHashes].map((sh) => ({ hash: sh, type: "sub" }));
455
+ imageHashesToStore.push(pHashEntry, ...subHashEntries);
492
456
  }
493
457
  }
494
458
  await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
495
459
  const finalStatus = config.enableReview ? "pending" : "active";
496
460
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
497
461
  if (hashManager) {
498
- const allHashesToInsert = [
499
- ...textHashesToStore.map((h4) => ({ ...h4, cave: cave.id })),
500
- ...imageHashesToStore.map((h4) => ({ ...h4, cave: cave.id }))
501
- ];
462
+ const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
502
463
  if (allHashesToInsert.length > 0) {
503
464
  await ctx.database.upsert("cave_hash", allHashesToInsert);
504
465
  }
@@ -518,7 +479,6 @@ __name(handleFileUploads, "handleFileUploads");
518
479
  // src/ReviewManager.ts
519
480
  var ReviewManager = class {
520
481
  /**
521
- * @constructor
522
482
  * @param ctx Koishi 上下文。
523
483
  * @param config 插件配置。
524
484
  * @param fileManager 文件管理器实例。
@@ -541,50 +501,50 @@ var ReviewManager = class {
541
501
  */
542
502
  registerCommands(cave) {
543
503
  const requireAdmin = /* @__PURE__ */ __name((session) => {
544
- const adminChannelId = this.config.adminChannel?.split(":")[1];
545
- if (session.channelId !== adminChannelId) {
504
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
546
505
  return "此指令仅限在管理群组中使用";
547
506
  }
548
507
  return null;
549
508
  }, "requireAdmin");
550
- const review = cave.subcommand(".review [id:posint]", "审核回声洞").usage("查看所有待审核回声洞,或查看指定待审核回声洞。").action(async ({ session }, id) => {
509
+ const review = cave.subcommand(".review [id:posint]", "审核回声洞").action(async ({ session }, id) => {
551
510
  const adminError = requireAdmin(session);
552
511
  if (adminError) return adminError;
553
- if (!id) {
554
- const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
555
- if (!pendingCaves.length) return "当前没有需要审核的回声洞";
556
- return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
557
- ${pendingCaves.map((c) => c.id).join("|")}`;
512
+ if (id) {
513
+ const [targetCave] = await this.ctx.database.get("cave", { id });
514
+ if (!targetCave) return `回声洞(${id})不存在`;
515
+ if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
516
+ return [`待审核`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
558
517
  }
559
- const [targetCave] = await this.ctx.database.get("cave", { id });
560
- if (!targetCave) return `回声洞(${id})不存在`;
561
- if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
562
- return [`待审核`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
518
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
519
+ if (!pendingCaves.length) return "当前没有需要审核的回声洞";
520
+ return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
521
+ ${pendingCaves.map((c) => c.id).join("|")}`;
563
522
  });
564
523
  const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
565
524
  const adminError = requireAdmin(session);
566
525
  if (adminError) return adminError;
567
526
  try {
527
+ const targetStatus = actionType === "approve" ? "active" : "delete";
528
+ const actionText = actionType === "approve" ? "通过" : "拒绝";
568
529
  if (!id) {
569
530
  const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
570
- if (!pendingCaves.length) return `当前没有需要${actionType === "approve" ? "通过" : "拒绝"}的回声洞`;
571
- if (actionType === "approve") {
572
- await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "active" })));
573
- return `已通过 ${pendingCaves.length} 条回声洞`;
574
- } else {
575
- await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "delete" })));
576
- cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
577
- return `已拒绝 ${pendingCaves.length} 条回声洞`;
578
- }
531
+ if (!pendingCaves.length) return `当前没有需要${actionText}的回声洞`;
532
+ await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: targetStatus })));
533
+ if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
534
+ return `已批量${actionText} ${pendingCaves.length} 条回声洞`;
579
535
  }
580
- return this.processReview(actionType, id);
536
+ const [cave2] = await this.ctx.database.get("cave", { id, status: "pending" });
537
+ if (!cave2) return `回声洞(${id})无需审核`;
538
+ await this.ctx.database.upsert("cave", [{ id, status: targetStatus }]);
539
+ if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
540
+ return `回声洞(${id})已${actionText}`;
581
541
  } catch (error) {
582
542
  this.logger.error(`审核操作失败:`, error);
583
543
  return `操作失败: ${error.message}`;
584
544
  }
585
545
  }, "createReviewAction");
586
- review.subcommand(".Y [id:posint]", "通过审核").usage("通过回声洞审核,可批量操作。").action(createReviewAction("approve"));
587
- review.subcommand(".N [id:posint]", "拒绝审核").usage("拒绝回声洞审核,可批量操作。").action(createReviewAction("reject"));
546
+ review.subcommand(".Y [id:posint]", "通过审核").action(createReviewAction("approve"));
547
+ review.subcommand(".N [id:posint]", "拒绝审核").action(createReviewAction("reject"));
588
548
  }
589
549
  /**
590
550
  * @description 将新回声洞提交到管理群组以供审核。
@@ -603,28 +563,11 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
603
563
  this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
604
564
  }
605
565
  }
606
- /**
607
- * @description 处理管理员的审核决定(通过或拒绝)。
608
- * @param action 'approve' (通过) 或 'reject' (拒绝)。
609
- * @param caveId 被审核的回声洞 ID。
610
- * @returns 返回给操作者的确认消息。
611
- */
612
- async processReview(action, caveId) {
613
- const [cave] = await this.ctx.database.get("cave", { id: caveId, status: "pending" });
614
- if (!cave) return `回声洞(${caveId})无需审核`;
615
- if (action === "approve") {
616
- await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
617
- return `回声洞(${caveId})已通过`;
618
- } else {
619
- await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
620
- cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
621
- return `回声洞(${caveId})已拒绝`;
622
- }
623
- }
624
566
  };
625
567
 
626
568
  // src/HashManager.ts
627
569
  var import_sharp = __toESM(require("sharp"));
570
+ var crypto = __toESM(require("crypto"));
628
571
  var HashManager = class {
629
572
  /**
630
573
  * @constructor
@@ -638,6 +581,13 @@ var HashManager = class {
638
581
  this.config = config;
639
582
  this.logger = logger2;
640
583
  this.fileManager = fileManager;
584
+ this.ctx.model.extend("cave_hash", {
585
+ cave: "unsigned",
586
+ hash: "string",
587
+ type: "string"
588
+ }, {
589
+ primary: ["cave", "hash", "type"]
590
+ });
641
591
  }
642
592
  static {
643
593
  __name(this, "HashManager");
@@ -647,15 +597,14 @@ var HashManager = class {
647
597
  * @param cave - 主 `cave` 命令实例。
648
598
  */
649
599
  registerCommands(cave) {
650
- cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希。").action(async ({ session }) => {
600
+ cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希,并检查现有内容的相似度。").action(async ({ session }) => {
651
601
  const adminChannelId = this.config.adminChannel?.split(":")[1];
652
602
  if (session.channelId !== adminChannelId) {
653
603
  return "此指令仅限在管理群组中使用";
654
604
  }
655
605
  await session.send("正在处理,请稍候...");
656
606
  try {
657
- const report = await this.validateAllCaves();
658
- return report;
607
+ return await this.validateAllCaves();
659
608
  } catch (error) {
660
609
  this.logger.error("校验哈希失败:", error);
661
610
  return `校验失败: ${error.message}`;
@@ -663,74 +612,113 @@ var HashManager = class {
663
612
  });
664
613
  }
665
614
  /**
666
- * @description 检查数据库中所有回声洞,并为没有哈希记录的历史数据生成哈希。
667
- * @returns {Promise<string>} 返回一个包含操作结果的报告字符串。
615
+ * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希,并在此之后对所有内容进行相似度检查。
616
+ * @returns {Promise<string>} 一个包含操作结果的报告字符串。
668
617
  */
669
618
  async validateAllCaves() {
670
619
  const allCaves = await this.ctx.database.get("cave", { status: "active" });
671
- const existingHashes = await this.ctx.database.get("cave_hash", {});
672
- const existingHashedCaveIds = new Set(existingHashes.map((h4) => h4.cave));
673
- const hashesToInsert = [];
620
+ const existingHashedCaveIds = new Set((await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] })).map((h4) => h4.cave));
621
+ let hashesToInsert = [];
674
622
  let historicalCount = 0;
623
+ let totalHashesGenerated = 0;
624
+ let batchStartCaveCount = 0;
625
+ const flushHashes = /* @__PURE__ */ __name(async () => {
626
+ if (hashesToInsert.length > 0) {
627
+ this.logger.info(`补全第 ${batchStartCaveCount + 1} 到 ${historicalCount} 条回声洞哈希中...`);
628
+ await this.ctx.database.upsert("cave_hash", hashesToInsert);
629
+ totalHashesGenerated += hashesToInsert.length;
630
+ hashesToInsert = [];
631
+ batchStartCaveCount = historicalCount;
632
+ }
633
+ }, "flushHashes");
675
634
  for (const cave of allCaves) {
676
635
  if (existingHashedCaveIds.has(cave.id)) continue;
677
- this.logger.info(`正在为回声洞(${cave.id})生成哈希...`);
678
636
  historicalCount++;
679
- const textElements = cave.elements.filter((el) => el.type === "text" && el.content);
680
- for (const el of textElements) {
681
- const textHash = this.generateTextHash(el.content);
682
- hashesToInsert.push({ cave: cave.id, hash: textHash, type: "text", subType: "shingle" });
637
+ const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
638
+ if (combinedText) {
639
+ hashesToInsert.push({ cave: cave.id, hash: this.generateTextSimhash(combinedText), type: "sim" });
683
640
  }
684
- const imageElements = cave.elements.filter((el) => el.type === "image" && el.file);
685
- for (const el of imageElements) {
641
+ for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
686
642
  try {
687
643
  const imageBuffer = await this.fileManager.readFile(el.file);
688
644
  const pHash = await this.generateImagePHash(imageBuffer);
689
- hashesToInsert.push({ cave: cave.id, hash: pHash, type: "image", subType: "pHash" });
645
+ hashesToInsert.push({ cave: cave.id, hash: pHash, type: "phash" });
690
646
  const subHashes = await this.generateImageSubHashes(imageBuffer);
691
- subHashes.forEach((subHash) => {
692
- hashesToInsert.push({ cave: cave.id, hash: subHash, type: "image", subType: "subImage" });
693
- });
647
+ subHashes.forEach((subHash) => hashesToInsert.push({ cave: cave.id, hash: subHash, type: "sub" }));
694
648
  } catch (e) {
695
649
  this.logger.warn(`无法为回声洞(${cave.id})的内容(${el.file})生成哈希:`, e);
696
650
  }
697
651
  }
652
+ if (hashesToInsert.length >= 100) await flushHashes();
698
653
  }
699
- if (hashesToInsert.length > 0) {
700
- await this.ctx.database.upsert("cave_hash", hashesToInsert);
701
- } else {
702
- this.logger.info("无需补全哈希");
654
+ await flushHashes();
655
+ const generationReport = totalHashesGenerated > 0 ? `已补全 ${historicalCount} 个回声洞的 ${totalHashesGenerated} 条哈希
656
+ ` : "无需补全回声洞的哈希\n";
657
+ const allHashes = await this.ctx.database.get("cave_hash", {});
658
+ const caveTextHashes = /* @__PURE__ */ new Map();
659
+ const caveImagePHashes = /* @__PURE__ */ new Map();
660
+ for (const hash of allHashes) {
661
+ if (hash.type === "sim") {
662
+ caveTextHashes.set(hash.cave, hash.hash);
663
+ } else if (hash.type === "phash") {
664
+ if (!caveImagePHashes.has(hash.cave)) caveImagePHashes.set(hash.cave, []);
665
+ caveImagePHashes.get(hash.cave).push(hash.hash);
666
+ }
667
+ }
668
+ const caveIds = allCaves.map((c) => c.id);
669
+ const similarPairs = /* @__PURE__ */ new Set();
670
+ for (let i = 0; i < caveIds.length; i++) {
671
+ for (let j = i + 1; j < caveIds.length; j++) {
672
+ const id1 = caveIds[i];
673
+ const id2 = caveIds[j];
674
+ const textHash1 = caveTextHashes.get(id1);
675
+ const textHash2 = caveTextHashes.get(id2);
676
+ if (textHash1 && textHash2) {
677
+ const textSim = this.calculateSimilarity(textHash1, textHash2);
678
+ if (textSim >= this.config.textThreshold) {
679
+ similarPairs.add(`文本:(${id1},${id2}),相似度:${(textSim * 100).toFixed(2)}%`);
680
+ }
681
+ }
682
+ const imageHashes1 = caveImagePHashes.get(id1) || [];
683
+ const imageHashes2 = caveImagePHashes.get(id2) || [];
684
+ if (imageHashes1.length > 0 && imageHashes2.length > 0) {
685
+ for (const imgHash1 of imageHashes1) {
686
+ for (const imgHash2 of imageHashes2) {
687
+ const imgSim = this.calculateSimilarity(imgHash1, imgHash2);
688
+ if (imgSim >= this.config.imageThreshold) {
689
+ similarPairs.add(`图片:(${id1},${id2}),相似度:${(imgSim * 100).toFixed(2)}%`);
690
+ }
691
+ }
692
+ }
693
+ }
694
+ }
703
695
  }
704
- return `校验完成,共补全 ${historicalCount} 个回声洞的 ${hashesToInsert.length} 条哈希`;
696
+ const similarityReport = similarPairs.size > 0 ? `发现 ${similarPairs.size} 对高相似度内容:
697
+ ` + [...similarPairs].join("\n") : "未发现高相似度内容";
698
+ return `校验完成:
699
+ ${generationReport}${similarityReport}`;
705
700
  }
706
701
  /**
707
702
  * @description 将图片切割为4个象限并为每个象限生成pHash。
708
703
  * @param imageBuffer - 图片的 Buffer 数据。
709
- * @returns {Promise<Set<string>>} 返回一个包含最多4个唯一哈希值的集合。
704
+ * @returns {Promise<Set<string>>} 一个包含最多4个唯一哈希值的集合。
710
705
  */
711
706
  async generateImageSubHashes(imageBuffer) {
712
707
  const hashes = /* @__PURE__ */ new Set();
713
708
  try {
714
709
  const metadata = await (0, import_sharp.default)(imageBuffer).metadata();
715
710
  const { width, height } = metadata;
716
- if (!width || !height || width < 16 || height < 16) {
717
- return hashes;
718
- }
711
+ if (!width || !height || width < 16 || height < 16) return hashes;
719
712
  const regions = [
720
713
  { left: 0, top: 0, width: Math.floor(width / 2), height: Math.floor(height / 2) },
721
- // Top-left
722
714
  { left: Math.floor(width / 2), top: 0, width: Math.ceil(width / 2), height: Math.floor(height / 2) },
723
- // Top-right
724
715
  { left: 0, top: Math.floor(height / 2), width: Math.floor(width / 2), height: Math.ceil(height / 2) },
725
- // Bottom-left
726
716
  { left: Math.floor(width / 2), top: Math.floor(height / 2), width: Math.ceil(width / 2), height: Math.ceil(height / 2) }
727
- // Bottom-right
728
717
  ];
729
718
  for (const region of regions) {
730
719
  if (region.width < 8 || region.height < 8) continue;
731
720
  const quadrantBuffer = await (0, import_sharp.default)(imageBuffer).extract(region).toBuffer();
732
- const subHash = await this.generateImagePHash(quadrantBuffer);
733
- hashes.add(subHash);
721
+ hashes.add(await this.generateImagePHash(quadrantBuffer));
734
722
  }
735
723
  } catch (e) {
736
724
  this.logger.warn(`生成子哈希失败:`, e);
@@ -738,22 +726,15 @@ var HashManager = class {
738
726
  return hashes;
739
727
  }
740
728
  /**
741
- * @description 根据pHash(感知哈希)算法为图片生成哈希值。
742
- * @param imageBuffer - 图片的 Buffer 数据。
743
- * @returns {Promise<string>} 返回一个64位的二进制哈希字符串。
729
+ * @description 根据感知哈希(pHash)算法为图片生成哈希。
730
+ * @param imageBuffer 图片的 Buffer 数据。
731
+ * @returns 64位二进制哈希字符串。
744
732
  */
745
733
  async generateImagePHash(imageBuffer) {
746
734
  const smallImage = await (0, import_sharp.default)(imageBuffer).grayscale().resize(8, 8, { fit: "fill" }).raw().toBuffer();
747
- let totalLuminance = 0;
748
- for (let i = 0; i < 64; i++) {
749
- totalLuminance += smallImage[i];
750
- }
735
+ const totalLuminance = smallImage.reduce((acc, val) => acc + val, 0);
751
736
  const avgLuminance = totalLuminance / 64;
752
- let hash = "";
753
- for (let i = 0; i < 64; i++) {
754
- hash += smallImage[i] > avgLuminance ? "1" : "0";
755
- }
756
- return hash;
737
+ return Array.from(smallImage).map((lum) => lum > avgLuminance ? "1" : "0").join("");
757
738
  }
758
739
  /**
759
740
  * @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
@@ -763,61 +744,40 @@ var HashManager = class {
763
744
  */
764
745
  calculateHammingDistance(hash1, hash2) {
765
746
  let distance = 0;
766
- for (let i = 0; i < Math.min(hash1.length, hash2.length); i++) {
767
- if (hash1[i] !== hash2[i]) {
768
- distance++;
769
- }
747
+ const len = Math.min(hash1.length, hash2.length);
748
+ for (let i = 0; i < len; i++) {
749
+ if (hash1[i] !== hash2[i]) distance++;
770
750
  }
771
751
  return distance;
772
752
  }
773
753
  /**
774
- * @description 根据汉明距离计算图片pHash的相似度。
754
+ * @description 根据汉明距离计算图片或文本哈希的相似度。
775
755
  * @param hash1 - 第一个哈希字符串。
776
756
  * @param hash2 - 第二个哈希字符串。
777
757
  * @returns {number} 范围在0到1之间的相似度得分。
778
758
  */
779
- calculateImageSimilarity(hash1, hash2) {
759
+ calculateSimilarity(hash1, hash2) {
780
760
  const distance = this.calculateHammingDistance(hash1, hash2);
781
- const hashLength = 64;
782
- return 1 - distance / hashLength;
761
+ const hashLength = Math.max(hash1.length, hash2.length);
762
+ return hashLength === 0 ? 1 : 1 - distance / hashLength;
783
763
  }
784
764
  /**
785
- * @description 将文本分割成指定大小的“瓦片”(shingles),用于Jaccard相似度计算。
786
- * @param text - 输入的文本。
787
- * @param size - 每个瓦片的大小,默认为2。
788
- * @returns {Set<string>} 包含所有唯一瓦片的集合。
789
- */
790
- getShingles(text, size = 2) {
791
- const shingles = /* @__PURE__ */ new Set();
792
- const cleanedText = text.replace(/\s+/g, "");
793
- for (let i = 0; i <= cleanedText.length - size; i++) {
794
- shingles.add(cleanedText.substring(i, i + size));
795
- }
796
- return shingles;
797
- }
798
- /**
799
- * @description 为文本生成基于Shingling的哈希字符串。
765
+ * @description 为文本生成基于 Simhash 算法的哈希字符串。
800
766
  * @param text - 需要处理的文本。
801
- * @returns {string} 由排序后的shingles组成的、用'|'分隔的哈希字符串。
802
- */
803
- generateTextHash(text) {
804
- if (!text) return "";
805
- const shingles = Array.from(this.getShingles(text));
806
- return shingles.sort().join("|");
807
- }
808
- /**
809
- * @description 使用Jaccard相似度系数计算两个文本哈希的相似度。
810
- * @param hash1 - 第一个文本哈希。
811
- * @param hash2 - 第二个文本哈希。
812
- * @returns {number} 范围在0到1之间的相似度得分。
767
+ * @returns {string} 64位二进制 Simhash 字符串。
813
768
  */
814
- calculateTextSimilarity(hash1, hash2) {
815
- if (!hash1 || !hash2) return 0;
816
- const set1 = new Set(hash1.split("|"));
817
- const set2 = new Set(hash2.split("|"));
818
- const intersection = new Set([...set1].filter((x) => set2.has(x)));
819
- const union = /* @__PURE__ */ new Set([...set1, ...set2]);
820
- return union.size === 0 ? 1 : intersection.size / union.size;
769
+ generateTextSimhash(text) {
770
+ if (!text?.trim()) return "";
771
+ const tokens = text.toLowerCase().split(/[^a-z0-9\u4e00-\u9fa5]+/).filter(Boolean);
772
+ if (tokens.length === 0) return "";
773
+ const vector = new Array(64).fill(0);
774
+ tokens.forEach((token) => {
775
+ const hash = crypto.createHash("md5").update(token).digest();
776
+ for (let i = 0; i < 64; i++) {
777
+ vector[i] += hash[Math.floor(i / 8)] >> i % 8 & 1 ? 1 : -1;
778
+ }
779
+ });
780
+ return vector.map((v) => v > 0 ? "1" : "0").join("");
821
781
  }
822
782
  };
823
783
 
@@ -851,7 +811,7 @@ var Config = import_koishi3.Schema.intersect([
851
811
  enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
852
812
  textThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("文本相似度阈值"),
853
813
  imageThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("图片相似度阈值")
854
- }).description("审核与查重配置"),
814
+ }).description("复核配置"),
855
815
  import_koishi3.Schema.object({
856
816
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
857
817
  enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
@@ -873,21 +833,13 @@ function apply(ctx, config) {
873
833
  status: "string",
874
834
  time: "timestamp"
875
835
  }, { primary: "id" });
876
- ctx.model.extend("cave_hash", {
877
- cave: "unsigned",
878
- hash: "string",
879
- type: "string",
880
- subType: "string"
881
- }, {
882
- primary: ["cave", "hash", "subType"]
883
- });
884
836
  const fileManager = new FileManager(ctx.baseDir, config, logger);
885
837
  const lastUsed = /* @__PURE__ */ new Map();
886
838
  const reusableIds = /* @__PURE__ */ new Set();
887
839
  const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
888
- const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, reusableIds) : null;
889
840
  const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger, reusableIds) : null;
890
841
  const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
842
+ const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, hashManager) : null;
891
843
  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 }) => {
892
844
  if (options.add) return session.execute(`cave.add ${options.add}`);
893
845
  if (options.view) return session.execute(`cave.view ${options.view}`);
@@ -910,7 +862,7 @@ function apply(ctx, config) {
910
862
  return "随机获取回声洞失败";
911
863
  }
912
864
  });
913
- cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
865
+ cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
914
866
  try {
915
867
  let sourceElements = session.quote?.elements;
916
868
  if (!sourceElements && content?.trim()) {
@@ -922,39 +874,28 @@ function apply(ctx, config) {
922
874
  if (!reply) return "等待操作超时";
923
875
  sourceElements = import_koishi3.h.parse(reply);
924
876
  }
925
- const idScopeQuery = config.perChannel && session.channelId ? { channelId: session.channelId } : {};
926
- const newId = await getNextCaveId(ctx, idScopeQuery, reusableIds);
927
- const { finalElementsForDb, mediaToSave } = await processMessageElements(
928
- sourceElements,
929
- newId,
930
- session.channelId,
931
- session.userId
932
- );
933
- if (finalElementsForDb.length === 0) {
934
- return "无可添加内容";
935
- }
936
- let textHashesToStore = [];
877
+ const newId = await getNextCaveId(ctx, getScopeQuery(session, config, false), reusableIds);
878
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session);
879
+ if (finalElementsForDb.length === 0) return "无可添加内容";
880
+ const textHashesToStore = [];
937
881
  if (hashManager) {
938
- const textContents = finalElementsForDb.filter((el) => el.type === "text" && el.content).map((el) => el.content);
939
- if (textContents.length > 0) {
940
- const newTextHashes = textContents.map((text) => hashManager.generateTextHash(text));
941
- textHashesToStore = newTextHashes.map((hash) => ({ hash, type: "text", subType: "shingle" }));
942
- const existingTextHashes = await ctx.database.get("cave_hash", { type: "text", hash: { $in: newTextHashes } });
882
+ const combinedText = finalElementsForDb.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
883
+ if (combinedText) {
884
+ const newSimhash = hashManager.generateTextSimhash(combinedText);
885
+ const existingTextHashes = await ctx.database.get("cave_hash", { type: "sim" });
943
886
  for (const existing of existingTextHashes) {
944
- const matchedNewHash = textHashesToStore.find((h4) => h4.hash === existing.hash);
945
- if (matchedNewHash) {
946
- const similarity = hashManager.calculateTextSimilarity(matchedNewHash.hash, existing.hash);
947
- if (similarity >= config.textThreshold) {
948
- return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
949
- }
887
+ const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
888
+ if (similarity >= config.textThreshold) {
889
+ return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
950
890
  }
951
891
  }
892
+ textHashesToStore.push({ hash: newSimhash, type: "sim" });
952
893
  }
953
894
  }
954
895
  const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
955
896
  const hasMedia = mediaToSave.length > 0;
956
897
  const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
957
- const newCave = {
898
+ const newCave = await ctx.database.create("cave", {
958
899
  id: newId,
959
900
  elements: finalElementsForDb,
960
901
  channelId: session.channelId,
@@ -962,33 +903,29 @@ function apply(ctx, config) {
962
903
  userName,
963
904
  status: initialStatus,
964
905
  time: /* @__PURE__ */ new Date()
965
- };
966
- await ctx.database.create("cave", newCave);
906
+ });
967
907
  if (hasMedia) {
968
908
  handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
969
909
  } else {
970
910
  if (hashManager && textHashesToStore.length > 0) {
971
- const hashObjectsToInsert = textHashesToStore.map((h4) => ({ ...h4, cave: newId }));
972
- await ctx.database.upsert("cave_hash", hashObjectsToInsert);
911
+ await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
973
912
  }
974
913
  if (initialStatus === "pending") {
975
914
  reviewManager.sendForReview(newCave);
976
915
  }
977
916
  }
978
- const responseMessage = initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
979
- return responseMessage;
917
+ return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
980
918
  } catch (error) {
981
919
  logger.error("添加回声洞失败:", error);
982
920
  return "添加失败,请稍后再试";
983
921
  }
984
922
  });
985
- cave.subcommand(".view <id:posint>", "查看指定回声洞").usage("通过序号查看对应的回声洞。").action(async ({ session }, id) => {
923
+ cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
986
924
  if (!id) return "请输入要查看的回声洞序号";
987
925
  const cdMessage = checkCooldown(session, config, lastUsed);
988
926
  if (cdMessage) return cdMessage;
989
927
  try {
990
- const query = { ...getScopeQuery(session, config), id };
991
- const [targetCave] = await ctx.database.get("cave", query);
928
+ const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id });
992
929
  if (!targetCave) return `回声洞(${id})不存在`;
993
930
  updateCooldownTimestamp(session, config, lastUsed);
994
931
  return buildCaveMessage(targetCave, config, fileManager, logger);
@@ -997,17 +934,14 @@ function apply(ctx, config) {
997
934
  return "查看失败,请稍后再试";
998
935
  }
999
936
  });
1000
- cave.subcommand(".del <id:posint>", "删除指定回声洞").usage("通过序号删除对应的回声洞。").action(async ({ session }, id) => {
937
+ cave.subcommand(".del <id:posint>", "删除指定回声洞").action(async ({ session }, id) => {
1001
938
  if (!id) return "请输入要删除的回声洞序号";
1002
939
  try {
1003
940
  const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
1004
941
  if (!targetCave) return `回声洞(${id})不存在`;
1005
- const adminChannelId = config.adminChannel?.split(":")[1];
1006
942
  const isAuthor = targetCave.userId === session.userId;
1007
- const isAdmin = session.channelId === adminChannelId;
1008
- if (!isAuthor && !isAdmin) {
1009
- return "你没有权限删除这条回声洞";
1010
- }
943
+ const isAdmin = session.channelId === config.adminChannel?.split(":")[1];
944
+ if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
1011
945
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
1012
946
  const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
1013
947
  cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
@@ -1017,10 +951,9 @@ function apply(ctx, config) {
1017
951
  return "删除失败,请稍后再试";
1018
952
  }
1019
953
  });
1020
- cave.subcommand(".list", "查询我的投稿").usage("查询并列出你所有投稿的回声洞序号。").action(async ({ session }) => {
954
+ cave.subcommand(".list", "查询我的投稿").action(async ({ session }) => {
1021
955
  try {
1022
- const query = { ...getScopeQuery(session, config), userId: session.userId };
1023
- const userCaves = await ctx.database.get("cave", query, { fields: ["id"] });
956
+ const userCaves = await ctx.database.get("cave", { ...getScopeQuery(session, config), userId: session.userId });
1024
957
  if (!userCaves.length) return "你还没有投稿过回声洞";
1025
958
  const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join(", ");
1026
959
  return `你已投稿 ${userCaves.length} 条回声洞,序号为: