koishi-plugin-best-cave 2.7.7 → 2.7.8

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);
@@ -768,33 +768,41 @@ var HashManager = class {
768
768
  const textThreshold = options.textThreshold ?? this.config.textThreshold;
769
769
  const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
770
770
  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);
771
+ if (allHashes.length < 2) return "无可比较哈希";
772
+ const buckets = /* @__PURE__ */ new Map();
773
+ const hashLookup = /* @__PURE__ */ new Map();
774
+ for (const hashObj of allHashes) {
775
+ if (!hashLookup.has(hashObj.cave)) hashLookup.set(hashObj.cave, {});
776
+ hashLookup.get(hashObj.cave)[hashObj.type] = hashObj.hash;
777
+ const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0");
778
+ for (let i = 0; i < 4; i++) {
779
+ const band = binHash.substring(i * 16, (i + 1) * 16);
780
+ const bucketKey = `${hashObj.type}:${i}:${band}`;
781
+ if (!buckets.has(bucketKey)) buckets.set(bucketKey, []);
782
+ buckets.get(bucketKey).push(hashObj.cave);
779
783
  }
780
784
  }
785
+ const candidatePairs = /* @__PURE__ */ new Set();
781
786
  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)}%`);
787
+ for (const ids of buckets.values()) {
788
+ if (ids.length < 2) continue;
789
+ for (let i = 0; i < ids.length; i++) {
790
+ for (let j = i + 1; j < ids.length; j++) {
791
+ const id1 = ids[i];
792
+ const id2 = ids[j];
793
+ const pairKey = [id1, id2].sort((a, b) => a - b).join("-");
794
+ if (candidatePairs.has(pairKey)) continue;
795
+ candidatePairs.add(pairKey);
796
+ const cave1Hashes = hashLookup.get(id1);
797
+ const cave2Hashes = hashLookup.get(id2);
798
+ if (cave1Hashes?.text && cave2Hashes?.text) {
799
+ const similarity = this.calculateSimilarity(cave1Hashes.text, cave2Hashes.text);
800
+ if (similarity >= textThreshold) similarPairs.text.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
801
+ }
802
+ if (cave1Hashes?.image && cave2Hashes?.image) {
803
+ const similarity = this.calculateSimilarity(cave1Hashes.image, cave2Hashes.image);
804
+ if (similarity >= imageThreshold) similarPairs.image.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
805
+ }
798
806
  }
799
807
  }
800
808
  }
@@ -809,6 +817,68 @@ var HashManager = class {
809
817
  return `检查失败: ${error.message}`;
810
818
  }
811
819
  });
820
+ cave.subcommand(".fix [...ids:posint]", "修复回声洞", { hidden: true, authority: 3 }).usage("扫描并修复回声洞中的图片,可指定一个或多个 ID。").action(async ({ session }, ...ids) => {
821
+ if (requireAdmin(session, this.config)) return requireAdmin(session, this.config);
822
+ let cavesToProcess;
823
+ try {
824
+ if (ids.length === 0) {
825
+ await session.send("正在修复,请稍候...");
826
+ cavesToProcess = await this.ctx.database.get("cave", { status: "active" });
827
+ } else {
828
+ cavesToProcess = await this.ctx.database.get("cave", { id: { $in: ids }, status: "active" });
829
+ }
830
+ if (!cavesToProcess.length) return "无可修复回声洞";
831
+ let fixedFiles = 0;
832
+ let errorCount = 0;
833
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
834
+ const JPEG_SIGNATURE = Buffer.from([255, 216]);
835
+ const GIF_SIGNATURE = Buffer.from("GIF");
836
+ for (const cave2 of cavesToProcess) {
837
+ const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file);
838
+ for (const element of imageElements) {
839
+ try {
840
+ const originalBuffer = await this.fileManager.readFile(element.file);
841
+ let sanitizedBuffer = originalBuffer;
842
+ if (originalBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
843
+ const IEND_CHUNK = Buffer.from("IEND");
844
+ const iendIndex = originalBuffer.lastIndexOf(IEND_CHUNK);
845
+ if (iendIndex !== -1) {
846
+ const endOfPngData = iendIndex + 8;
847
+ if (originalBuffer.length > endOfPngData) sanitizedBuffer = originalBuffer.slice(0, endOfPngData);
848
+ }
849
+ } else if (originalBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
850
+ const EOI_MARKER = Buffer.from([255, 217]);
851
+ const eoiIndex = originalBuffer.lastIndexOf(EOI_MARKER);
852
+ if (eoiIndex !== -1) {
853
+ const endOfJpegData = eoiIndex + 2;
854
+ if (originalBuffer.length > endOfJpegData) sanitizedBuffer = originalBuffer.slice(0, endOfJpegData);
855
+ }
856
+ } else if (originalBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
857
+ const GIF_TERMINATOR = Buffer.from([59]);
858
+ const terminatorIndex = originalBuffer.lastIndexOf(GIF_TERMINATOR);
859
+ if (terminatorIndex !== -1) {
860
+ const endOfGifData = terminatorIndex + 1;
861
+ if (originalBuffer.length > endOfGifData) sanitizedBuffer = originalBuffer.slice(0, endOfGifData);
862
+ }
863
+ }
864
+ if (!originalBuffer.equals(sanitizedBuffer)) {
865
+ await this.fileManager.saveFile(element.file, sanitizedBuffer);
866
+ fixedFiles++;
867
+ }
868
+ } catch (error) {
869
+ if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
870
+ this.logger.warn(`无法修复回声洞(${cave2.id})的图片(${element.file}):`, error);
871
+ errorCount++;
872
+ }
873
+ }
874
+ }
875
+ }
876
+ return `已修复 ${cavesToProcess.length} 个回声洞的 ${fixedFiles} 张图片(失败 ${errorCount} 条)`;
877
+ } catch (error) {
878
+ this.logger.error("修复图像文件时发生严重错误:", error);
879
+ return `操作失败: ${error.message}`;
880
+ }
881
+ });
812
882
  }
813
883
  /**
814
884
  * @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
@@ -835,8 +905,9 @@ var HashManager = class {
835
905
  const imageBuffer = await this.fileManager.readFile(el.file);
836
906
  const imageHash = await this.generatePHash(imageBuffer);
837
907
  addUniqueHash({ cave: cave.id, hash: imageHash, type: "image" });
838
- } catch (e) {
839
- this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, e);
908
+ } catch (error) {
909
+ this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, error);
910
+ throw error;
840
911
  }
841
912
  }
842
913
  return tempHashes;
@@ -1279,7 +1350,7 @@ function apply(ctx, config) {
1279
1350
  if (staleCaves.length > 0) {
1280
1351
  const idsToMark = staleCaves.map((c) => ({ id: c.id, status: "delete" }));
1281
1352
  await ctx.database.upsert("cave", idsToMark);
1282
- await cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1353
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1283
1354
  }
1284
1355
  } catch (error) {
1285
1356
  logger.error("清理残留回声洞时发生错误:", error);
@@ -1353,7 +1424,7 @@ function apply(ctx, config) {
1353
1424
  time: creationTime
1354
1425
  });
1355
1426
  if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, session);
1356
- if (finalStatus !== "preload" && finalStatus !== "delete") {
1427
+ if (finalStatus !== "preload") {
1357
1428
  newCave.status = finalStatus;
1358
1429
  if (aiManager) await aiManager.analyzeAndStore(newCave, downloadedMedia);
1359
1430
  if (hashManager) {
@@ -1390,8 +1461,8 @@ function apply(ctx, config) {
1390
1461
  if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
1391
1462
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
1392
1463
  const caveMessages = await buildCaveMessage(targetCave, config, fileManager, logger, session.platform, "已删除");
1393
- cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
1394
1464
  for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi3.h.normalize(message));
1465
+ cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1395
1466
  } catch (error) {
1396
1467
  logger.error(`标记回声洞(${id})失败:`, error);
1397
1468
  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.8",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],