koishi-plugin-best-cave 2.7.7 → 2.7.9

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/Utils.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare function buildCaveMessage(cave: CaveObject, config: Config, fileM
20
20
  * @param logger 日志记录器实例。
21
21
  * @param reusableIds 可复用 ID 的内存缓存。
22
22
  */
23
- export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
23
+ export declare function cleanupPendingDeletions(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
24
24
  /**
25
25
  * @description 根据配置和会话,生成数据库查询的范围条件。
26
26
  * @param session 当前会话。
@@ -81,7 +81,7 @@ export declare function performSimilarityChecks(ctx: Context, config: Config, ha
81
81
  export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, cave: CaveObject, downloadedMedia: {
82
82
  fileName: string;
83
83
  buffer: Buffer;
84
- }[], reusableIds: Set<number>, session: Session): Promise<'pending' | 'active' | 'delete'>;
84
+ }[], reusableIds: Set<number>, session: Session): Promise<'pending' | 'active'>;
85
85
  /**
86
86
  * @description 校验会话是否来自指定的管理群组。
87
87
  * @param session 当前会话。
package/lib/index.js CHANGED
@@ -321,7 +321,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2, platform, pr
321
321
  return [finalInitialMessage, ...followUpMessages].filter((msg) => msg.length > 0);
322
322
  }
323
323
  __name(buildCaveMessage, "buildCaveMessage");
324
- async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
324
+ async function cleanupPendingDeletions(ctx, config, fileManager, logger2, reusableIds) {
325
325
  try {
326
326
  const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
327
327
  if (!cavesToDelete.length) return;
@@ -331,7 +331,7 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
331
331
  idsToDelete.forEach((id) => reusableIds.add(id));
332
332
  await ctx.database.remove("cave", { id: { $in: idsToDelete } });
333
333
  await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
334
- await ctx.database.remove("cave_meta", { cave: { $in: idsToDelete } });
334
+ if (config.enableAI) await ctx.database.remove("cave_meta", { cave: { $in: idsToDelete } });
335
335
  } catch (error) {
336
336
  logger2.error("清理回声洞时发生错误:", error);
337
337
  }
@@ -492,8 +492,8 @@ async function handleFileUploads(ctx, config, fileManager, logger2, cave, downlo
492
492
  } catch (fileProcessingError) {
493
493
  logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
494
494
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
495
- cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
496
- return "delete";
495
+ cleanupPendingDeletions(ctx, config, fileManager, logger2, reusableIds);
496
+ throw fileProcessingError;
497
497
  }
498
498
  }
499
499
  __name(handleFileUploads, "handleFileUploads");
@@ -655,7 +655,7 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
655
655
  if (cavesToProcess.length === 0) return `回声洞(${idsToProcess.join("|")})无需审核或不存在`;
656
656
  const processedIds = cavesToProcess.map((cave2) => cave2.id);
657
657
  await this.ctx.database.upsert("cave", processedIds.map((id) => ({ id, status: targetStatus })));
658
- if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
658
+ if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.config, this.fileManager, this.logger, this.reusableIds);
659
659
  return `已${actionText}回声洞(${processedIds.join("|")})`;
660
660
  } catch (error) {
661
661
  this.logger.error(`审核操作失败:`, error);
@@ -722,42 +722,29 @@ var HashManager = class {
722
722
  if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
723
723
  try {
724
724
  const allCaves = await this.ctx.database.get("cave", { status: "active" });
725
- if (allCaves.length === 0) return "无需补全回声洞哈希";
726
- await session.send(`开始补全 ${allCaves.length} 个回声洞的哈希...`);
727
- const existingHashes = await this.ctx.database.get("cave_hash", {});
728
- const existingHashSet = new Set(existingHashes.map((h4) => `${h4.cave}-${h4.hash}-${h4.type}`));
729
- let hashesToInsert = [];
725
+ const existingHashes = await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] });
726
+ const hashedCaveIds = new Set(existingHashes.map((h4) => h4.cave));
727
+ const cavesToProcess = allCaves.filter((cave2) => !hashedCaveIds.has(cave2.id));
728
+ if (cavesToProcess.length === 0) return "无需补全回声洞哈希";
729
+ await session.send(`开始补全 ${cavesToProcess.length} 个回声洞的哈希...`);
730
+ const hashesToInsert = [];
730
731
  let processedCaveCount = 0;
731
- let totalHashesGenerated = 0;
732
732
  let errorCount = 0;
733
- const flushBatch = /* @__PURE__ */ __name(async () => {
734
- if (hashesToInsert.length === 0) return;
735
- await this.ctx.database.upsert("cave_hash", hashesToInsert);
736
- totalHashesGenerated += hashesToInsert.length;
737
- this.logger.info(`[${processedCaveCount}/${allCaves.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`);
738
- hashesToInsert = [];
739
- }, "flushBatch");
740
- for (const cave2 of allCaves) {
733
+ for (const cave2 of cavesToProcess) {
741
734
  processedCaveCount++;
742
735
  try {
743
736
  const newHashesForCave = await this.generateAllHashesForCave(cave2);
744
- for (const hashObj of newHashesForCave) {
745
- const uniqueKey = `${hashObj.cave}-${hashObj.hash}-${hashObj.type}`;
746
- if (!existingHashSet.has(uniqueKey)) {
747
- hashesToInsert.push(hashObj);
748
- existingHashSet.add(uniqueKey);
749
- }
750
- }
751
- if (hashesToInsert.length >= 100) await flushBatch();
737
+ if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave);
752
738
  } catch (error) {
753
739
  errorCount++;
754
740
  this.logger.warn(`补全回声洞(${cave2.id})哈希时出错: ${error.message}`);
755
741
  }
756
742
  }
757
- await flushBatch();
758
- return `已补全 ${allCaves.length} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`;
743
+ if (hashesToInsert.length > 0) await this.ctx.database.upsert("cave_hash", hashesToInsert);
744
+ const successCount = processedCaveCount - errorCount;
745
+ return `已补全 ${successCount} 个回声洞的 ${hashesToInsert.length} 条哈希(失败 ${errorCount} 条)`;
759
746
  } catch (error) {
760
- this.logger.error("生成哈希失败:", error);
747
+ this.logger.error("补全哈希失败:", error);
761
748
  return `操作失败: ${error.message}`;
762
749
  }
763
750
  });
@@ -768,33 +755,41 @@ var HashManager = class {
768
755
  const textThreshold = options.textThreshold ?? this.config.textThreshold;
769
756
  const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
770
757
  const allHashes = await this.ctx.database.get("cave_hash", {});
771
- const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
772
- const textHashes = /* @__PURE__ */ new Map();
773
- const imageHashes = /* @__PURE__ */ new Map();
774
- for (const hash of allHashes) {
775
- if (hash.type === "text") {
776
- textHashes.set(hash.cave, hash.hash);
777
- } else if (hash.type === "image") {
778
- imageHashes.set(hash.cave, hash.hash);
758
+ if (allHashes.length < 2) return "无可比较哈希";
759
+ const buckets = /* @__PURE__ */ new Map();
760
+ const hashLookup = /* @__PURE__ */ new Map();
761
+ for (const hashObj of allHashes) {
762
+ if (!hashLookup.has(hashObj.cave)) hashLookup.set(hashObj.cave, {});
763
+ hashLookup.get(hashObj.cave)[hashObj.type] = hashObj.hash;
764
+ const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0");
765
+ for (let i = 0; i < 4; i++) {
766
+ const band = binHash.substring(i * 16, (i + 1) * 16);
767
+ const bucketKey = `${hashObj.type}:${i}:${band}`;
768
+ if (!buckets.has(bucketKey)) buckets.set(bucketKey, []);
769
+ buckets.get(bucketKey).push(hashObj.cave);
779
770
  }
780
771
  }
772
+ const candidatePairs = /* @__PURE__ */ new Set();
781
773
  const similarPairs = { text: /* @__PURE__ */ new Set(), image: /* @__PURE__ */ new Set() };
782
- for (let i = 0; i < allCaveIds.length; i++) {
783
- for (let j = i + 1; j < allCaveIds.length; j++) {
784
- const id1 = allCaveIds[i];
785
- const id2 = allCaveIds[j];
786
- const pair = [id1, id2].sort((a, b) => a - b).join(" & ");
787
- const text1 = textHashes.get(id1);
788
- const text2 = textHashes.get(id2);
789
- if (text1 && text2) {
790
- const similarity = this.calculateSimilarity(text1, text2);
791
- if (similarity >= textThreshold) similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`);
792
- }
793
- const image1 = imageHashes.get(id1);
794
- const image2 = imageHashes.get(id2);
795
- if (image1 && image2) {
796
- const similarity = this.calculateSimilarity(image1, image2);
797
- if (similarity >= imageThreshold) similarPairs.image.add(`${pair} = ${similarity.toFixed(2)}%`);
774
+ for (const ids of buckets.values()) {
775
+ if (ids.length < 2) continue;
776
+ for (let i = 0; i < ids.length; i++) {
777
+ for (let j = i + 1; j < ids.length; j++) {
778
+ const id1 = ids[i];
779
+ const id2 = ids[j];
780
+ const pairKey = [id1, id2].sort((a, b) => a - b).join("-");
781
+ if (candidatePairs.has(pairKey)) continue;
782
+ candidatePairs.add(pairKey);
783
+ const cave1Hashes = hashLookup.get(id1);
784
+ const cave2Hashes = hashLookup.get(id2);
785
+ if (cave1Hashes?.text && cave2Hashes?.text) {
786
+ const similarity = this.calculateSimilarity(cave1Hashes.text, cave2Hashes.text);
787
+ if (similarity >= textThreshold) similarPairs.text.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
788
+ }
789
+ if (cave1Hashes?.image && cave2Hashes?.image) {
790
+ const similarity = this.calculateSimilarity(cave1Hashes.image, cave2Hashes.image);
791
+ if (similarity >= imageThreshold) similarPairs.image.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
792
+ }
798
793
  }
799
794
  }
800
795
  }
@@ -809,6 +804,68 @@ var HashManager = class {
809
804
  return `检查失败: ${error.message}`;
810
805
  }
811
806
  });
807
+ cave.subcommand(".fix [...ids:posint]", "修复回声洞", { hidden: true, authority: 3 }).usage("扫描并修复回声洞中的图片,可指定一个或多个 ID。").action(async ({ session }, ...ids) => {
808
+ if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
809
+ let cavesToProcess;
810
+ try {
811
+ await session.send("正在修复,请稍候...");
812
+ if (ids.length === 0) {
813
+ cavesToProcess = await this.ctx.database.get("cave", { status: "active" });
814
+ } else {
815
+ cavesToProcess = await this.ctx.database.get("cave", { id: { $in: ids }, status: "active" });
816
+ }
817
+ if (!cavesToProcess.length) return "无可修复的回声洞";
818
+ let fixedFiles = 0;
819
+ let errorCount = 0;
820
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
821
+ const JPEG_SIGNATURE = Buffer.from([255, 216]);
822
+ const GIF_SIGNATURE = Buffer.from("GIF");
823
+ for (const cave2 of cavesToProcess) {
824
+ const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file);
825
+ for (const element of imageElements) {
826
+ try {
827
+ const originalBuffer = await this.fileManager.readFile(element.file);
828
+ let sanitizedBuffer = originalBuffer;
829
+ if (originalBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
830
+ const IEND_CHUNK = Buffer.from("IEND");
831
+ const iendIndex = originalBuffer.lastIndexOf(IEND_CHUNK);
832
+ if (iendIndex !== -1) {
833
+ const endOfPngData = iendIndex + 8;
834
+ if (originalBuffer.length > endOfPngData) sanitizedBuffer = originalBuffer.slice(0, endOfPngData);
835
+ }
836
+ } else if (originalBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
837
+ const EOI_MARKER = Buffer.from([255, 217]);
838
+ const eoiIndex = originalBuffer.lastIndexOf(EOI_MARKER);
839
+ if (eoiIndex !== -1) {
840
+ const endOfJpegData = eoiIndex + 2;
841
+ if (originalBuffer.length > endOfJpegData) sanitizedBuffer = originalBuffer.slice(0, endOfJpegData);
842
+ }
843
+ } else if (originalBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
844
+ const GIF_TERMINATOR = Buffer.from([59]);
845
+ const terminatorIndex = originalBuffer.lastIndexOf(GIF_TERMINATOR);
846
+ if (terminatorIndex !== -1) {
847
+ const endOfGifData = terminatorIndex + 1;
848
+ if (originalBuffer.length > endOfGifData) sanitizedBuffer = originalBuffer.slice(0, endOfGifData);
849
+ }
850
+ }
851
+ if (!originalBuffer.equals(sanitizedBuffer)) {
852
+ await this.fileManager.saveFile(element.file, sanitizedBuffer);
853
+ fixedFiles++;
854
+ }
855
+ } catch (error) {
856
+ if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
857
+ this.logger.warn(`无法修复回声洞(${cave2.id})的图片(${element.file}):`, error);
858
+ errorCount++;
859
+ }
860
+ }
861
+ }
862
+ }
863
+ return `已修复 ${cavesToProcess.length} 个回声洞的 ${fixedFiles} 张图片(失败 ${errorCount} 条)`;
864
+ } catch (error) {
865
+ this.logger.error("修复图像文件时发生严重错误:", error);
866
+ return `操作失败: ${error.message}`;
867
+ }
868
+ });
812
869
  }
813
870
  /**
814
871
  * @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
@@ -835,8 +892,9 @@ var HashManager = class {
835
892
  const imageBuffer = await this.fileManager.readFile(el.file);
836
893
  const imageHash = await this.generatePHash(imageBuffer);
837
894
  addUniqueHash({ cave: cave.id, hash: imageHash, type: "image" });
838
- } catch (e) {
839
- this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, e);
895
+ } catch (error) {
896
+ this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, error);
897
+ throw error;
840
898
  }
841
899
  }
842
900
  return tempHashes;
@@ -1279,7 +1337,7 @@ function apply(ctx, config) {
1279
1337
  if (staleCaves.length > 0) {
1280
1338
  const idsToMark = staleCaves.map((c) => ({ id: c.id, status: "delete" }));
1281
1339
  await ctx.database.upsert("cave", idsToMark);
1282
- await cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1340
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1283
1341
  }
1284
1342
  } catch (error) {
1285
1343
  logger.error("清理残留回声洞时发生错误:", error);
@@ -1353,7 +1411,7 @@ function apply(ctx, config) {
1353
1411
  time: creationTime
1354
1412
  });
1355
1413
  if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, session);
1356
- if (finalStatus !== "preload" && finalStatus !== "delete") {
1414
+ if (finalStatus !== "preload") {
1357
1415
  newCave.status = finalStatus;
1358
1416
  if (aiManager) await aiManager.analyzeAndStore(newCave, downloadedMedia);
1359
1417
  if (hashManager) {
@@ -1390,8 +1448,8 @@ function apply(ctx, config) {
1390
1448
  if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
1391
1449
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
1392
1450
  const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除");
1393
- cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1394
1451
  for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1452
+ cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1395
1453
  } catch (error) {
1396
1454
  logger.error(`标记回声洞(${id})失败:`, error);
1397
1455
  return "删除失败,请稍后再试";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.7.7",
4
+ "version": "2.7.9",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],