koishi-plugin-best-cave 1.3.3 → 1.3.5

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.d.ts CHANGED
@@ -25,4 +25,5 @@ export interface Config {
25
25
  itemsPerPage: number;
26
26
  duplicateThreshold: number;
27
27
  enableDuplicate: boolean;
28
+ enableMD5: boolean;
28
29
  }
package/lib/index.js CHANGED
@@ -33,14 +33,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
33
33
  // src/locales/zh-CN.yml
34
34
  var require_zh_CN = __commonJS({
35
35
  "src/locales/zh-CN.yml"(exports2, module2) {
36
- module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", imageMaxSize: "图片最大大小(MB)", enableDuplicate: "启用图片查重", duplicateThreshold: "图片相似度阈值(0-1)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", p: "通过审核(批量)", d: "拒绝审核(批量)", l: "查询投稿统计" }, add: { noContent: "请在一分钟内发送内容", operationTimeout: "操作超时,添加取消", videoDisabled: "不允许上传视频", submitPending: "提交成功,序号为({0})", addSuccess: "添加成功,序号为({0})", mediaSizeExceeded: "{0}文件大小超过限制", localFileNotAllowed: "检测到本地文件路径,无法保存" }, remove: { noPermission: "你无权删除他人添加的回声洞", deletePending: "删除(待审核)", deleted: "已删除" }, list: { pageInfo: "第 {0} / {1} 页", header: "当前共有 {0} 项回声洞:", totalItems: "用户 {0} 共计投稿 {1} 项:", idsLine: "{0}" }, audit: { noPending: "暂无待审核回声洞", pendingNotFound: "未找到待审核回声洞", pendingResult: "{0},剩余 {1} 个待审核回声洞:[{2}]", auditPassed: "已通过", auditRejected: "已拒绝", batchAuditResult: "已{0} {1}/{2} 项回声洞", title: "待审核回声洞:", from: "投稿人:", sendFailed: "发送审核消息失败,无法联系管理员 {0}" }, error: { noContent: "回声洞内容为空", getCave: "获取回声洞失败", noCave: "没有回声洞", invalidId: "请输入有效的回声洞ID", notFound: "未找到该回声洞", exactDuplicateFound: "发现完全相同的", similarDuplicateFound: "发现相似度为 {0}% 的" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } };
36
+ module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", imageMaxSize: "图片最大大小(MB)", enableMD5: "启用MD5查重", enableDuplicate: "启用pHash查重", duplicateThreshold: "pHash相似度阈值(0-1)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", p: "通过审核(批量)", d: "拒绝审核(批量)", l: "查询投稿统计" }, add: { noContent: "请在一分钟内发送内容", operationTimeout: "操作超时,添加取消", videoDisabled: "不允许上传视频", submitPending: "提交成功,序号为({0})", addSuccess: "添加成功,序号为({0})", mediaSizeExceeded: "{0}文件大小超过限制", localFileNotAllowed: "检测到本地文件路径,无法保存" }, remove: { noPermission: "你无权删除他人添加的回声洞", deletePending: "删除(待审核)", deleted: "已删除" }, list: { pageInfo: "第 {0} / {1} 页", header: "当前共有 {0} 项回声洞:", totalItems: "用户 {0} 共计投稿 {1} 项:", idsLine: "{0}" }, audit: { noPending: "暂无待审核回声洞", pendingNotFound: "未找到待审核回声洞", pendingResult: "{0},剩余 {1} 个待审核回声洞:[{2}]", auditPassed: "已通过", auditRejected: "已拒绝", batchAuditResult: "已{0} {1}/{2} 项回声洞", title: "待审核回声洞:", from: "投稿人:", sendFailed: "发送审核消息失败,无法联系管理员 {0}" }, error: { noContent: "回声洞内容为空", getCave: "获取回声洞失败", noCave: "没有回声洞", invalidId: "请输入有效的回声洞ID", notFound: "未找到该回声洞", exactDuplicateFound: "发现完全相同的", similarDuplicateFound: "发现相似度为 {0}% 的" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } };
37
37
  }
38
38
  });
39
39
 
40
40
  // src/locales/en-US.yml
41
41
  var require_en_US = __commonJS({
42
42
  "src/locales/en-US.yml"(exports2, module2) {
43
- module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", imageMaxSize: "Maximum image size (MB)", enableDuplicate: "Enable image duplicate check", duplicateThreshold: "Image similarity threshold (0-1)", allowVideo: "Allow video upload", videoMaxSize: "Maximum video size (MB)", enablePagination: "Enable statistics pagination", itemsPerPage: "Items per page", blacklist: "Blacklist (users)", whitelist: "Moderation whitelist (users/groups/channels)" }, commands: { cave: { description: "Echo Cave", usage: "Support adding, drawing, viewing, and managing echo caves", examples: "Use cave to randomly draw an echo\nUse -a to add directly or add by reference\nUse -g to view specific echo\nUse -r to delete specific echo", options: { a: "Add echo", g: "View echo", r: "Delete echo", p: "Approve moderation (batch)", d: "Reject moderation (batch)", l: "Query submission statistics" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timeout, addition cancelled", videoDisabled: "Video upload not allowed", submitPending: "Submission successful, ID is ({0})", addSuccess: "Added successfully, ID is ({0})", mediaSizeExceeded: "{0} file size exceeds limit", localFileNotAllowed: "Local file path detected, cannot save" }, remove: { noPermission: "You don't have permission to delete others' echos", deletePending: "Delete (pending review)", deleted: "Deleted" }, list: { pageInfo: "Page {0} / {1}", header: "Currently there are {0} echos:", totalItems: "User {0} has submitted {1} items:", idsLine: "{0}" }, audit: { noPending: "No pending echos for review", pendingNotFound: "Pending echo not found", pendingResult: "{0}, {1} pending echos remaining: [{2}]", auditPassed: "Approved", auditRejected: "Rejected", batchAuditResult: "{0} {1}/{2} echos", title: "Pending echos:", from: "Submitted by:", sendFailed: "Failed to send moderation message, cannot contact administrator {0}" }, error: { noContent: "Echo content is empty", getCave: "Failed to get echo", noCave: "No echos available", invalidId: "Please enter a valid echo ID", notFound: "Echo not found", exactDuplicateFound: "Found exactly identical", similarDuplicateFound: "Found {0}% similar" }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is limited to administrators only", cooldown: "Group chat cooling down... Please try again in {0} seconds", caveTitle: "Echo Cave —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0} file size exceeds limit" } } } };
43
+ module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", imageMaxSize: "Maximum image size (MB)", enableMD5: "Enable MD5 duplicate check", enableDuplicate: "Enable pHash duplicate check", duplicateThreshold: "pHash similarity threshold (0-1)", allowVideo: "Allow video upload", videoMaxSize: "Maximum video size (MB)", enablePagination: "Enable statistics pagination", itemsPerPage: "Items per page", blacklist: "Blacklist (users)", whitelist: "Moderation whitelist (users/groups/channels)" }, commands: { cave: { description: "Echo Cave", usage: "Support adding, drawing, viewing, and managing echo caves", examples: "Use cave to randomly draw an echo\nUse -a to add directly or add by reference\nUse -g to view specific echo\nUse -r to delete specific echo", options: { a: "Add echo", g: "View echo", r: "Delete echo", p: "Approve moderation (batch)", d: "Reject moderation (batch)", l: "Query submission statistics" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timeout, addition cancelled", videoDisabled: "Video upload not allowed", submitPending: "Submission successful, ID is ({0})", addSuccess: "Added successfully, ID is ({0})", mediaSizeExceeded: "{0} file size exceeds limit", localFileNotAllowed: "Local file path detected, cannot save" }, remove: { noPermission: "You don't have permission to delete others' echos", deletePending: "Delete (pending review)", deleted: "Deleted" }, list: { pageInfo: "Page {0} / {1}", header: "Currently there are {0} echos:", totalItems: "User {0} has submitted {1} items:", idsLine: "{0}" }, audit: { noPending: "No pending echos for review", pendingNotFound: "Pending echo not found", pendingResult: "{0}, {1} pending echos remaining: [{2}]", auditPassed: "Approved", auditRejected: "Rejected", batchAuditResult: "{0} {1}/{2} echos", title: "Pending echos:", from: "Submitted by:", sendFailed: "Failed to send moderation message, cannot contact administrator {0}" }, error: { noContent: "Echo content is empty", getCave: "Failed to get echo", noCave: "No echos available", invalidId: "Please enter a valid echo ID", notFound: "Echo not found", exactDuplicateFound: "Found exactly identical", similarDuplicateFound: "Found {0}% similar" }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is limited to administrators only", cooldown: "Group chat cooling down... Please try again in {0} seconds", caveTitle: "Echo Cave —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0} file size exceeds limit" } } } };
44
44
  }
45
45
  });
46
46
 
@@ -293,7 +293,6 @@ var IdManager = class {
293
293
  );
294
294
  await this.saveStatus();
295
295
  this.initialized = true;
296
- logger2.success("ID Manager initialized");
297
296
  } catch (error) {
298
297
  this.initialized = false;
299
298
  logger2.error(`ID Manager initialization failed: ${error.message}`);
@@ -593,253 +592,131 @@ var ImageHasher = class {
593
592
  };
594
593
 
595
594
  // src/utils/HashStorage.ts
596
- var import_util = require("util");
595
+ var import_crypto = __toESM(require("crypto"));
597
596
  var logger3 = new import_koishi3.Logger("HashStorage");
598
- var readFileAsync = (0, import_util.promisify)(fs3.readFile);
599
597
  var HashStorage = class _HashStorage {
600
598
  /**
601
- * 初始化HashStorage实例
602
- * @param caveDir 回声洞数据目录路径
599
+ * 创建哈希存储实例
600
+ * @param caveDir - 回声洞数据目录路径
603
601
  */
604
602
  constructor(caveDir) {
605
603
  this.caveDir = caveDir;
604
+ this.filePath = path3.join(caveDir, "hash.json");
605
+ this.resourceDir = path3.join(caveDir, "resources");
606
+ this.caveFilePath = path3.join(caveDir, "cave.json");
606
607
  }
607
608
  static {
608
609
  __name(this, "HashStorage");
609
610
  }
610
- // 哈希数据文件名
611
- static HASH_FILE = "hash.json";
612
- // 回声洞数据文件名
613
- static CAVE_FILE = "cave.json";
614
- // 批处理大小
615
- static BATCH_SIZE = 50;
616
- // 存储回声洞ID到图片哈希值的映射
617
- hashes = /* @__PURE__ */ new Map();
618
- // 初始化状态标志
611
+ filePath;
612
+ resourceDir;
613
+ caveFilePath;
614
+ imageHashes = /* @__PURE__ */ new Map();
615
+ textHashes = /* @__PURE__ */ new Map();
619
616
  initialized = false;
620
- get filePath() {
621
- return path3.join(this.caveDir, _HashStorage.HASH_FILE);
622
- }
623
- get resourceDir() {
624
- return path3.join(this.caveDir, "resources");
625
- }
626
- get caveFilePath() {
627
- return path3.join(this.caveDir, _HashStorage.CAVE_FILE);
628
- }
629
617
  /**
630
618
  * 初始化哈希存储
631
- * 读取现有哈希数据或重新构建哈希值
632
619
  * @throws 初始化失败时抛出错误
633
620
  */
634
621
  async initialize() {
635
622
  if (this.initialized) return;
636
623
  try {
637
624
  const hashData = await FileHandler.readJsonData(this.filePath).then((data) => data[0]).catch(() => null);
638
- if (!hashData?.hashes || Object.keys(hashData.hashes).length === 0) {
639
- this.hashes.clear();
625
+ if (!hashData?.imageHashes) {
640
626
  await this.buildInitialHashes();
641
627
  } else {
642
- this.hashes = new Map(
643
- Object.entries(hashData.hashes).map(([k, v]) => [Number(k), v])
644
- );
628
+ this.loadHashData(hashData);
645
629
  await this.updateMissingHashes();
646
630
  }
647
631
  this.initialized = true;
648
632
  } catch (error) {
649
- logger3.error(`Initialization failed: ${error.message}`);
650
- this.initialized = false;
633
+ logger3.error(`Hash storage initialization failed: ${error.message}`);
651
634
  throw error;
652
635
  }
653
636
  }
654
637
  /**
655
- * 获取当前哈希存储状态
656
- * @returns 包含最后更新时间和所有条目的状态对象
638
+ * 加载哈希数据
639
+ * @param data - 要加载的哈希数据
640
+ * @private
657
641
  */
658
- async getStatus() {
659
- if (!this.initialized) await this.initialize();
660
- return {
661
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
662
- entries: Array.from(this.hashes.entries()).map(([caveId, hashes]) => ({
663
- caveId,
664
- hashes
665
- }))
666
- };
642
+ loadHashData(data) {
643
+ this.imageHashes = new Map(Object.entries(data.imageHashes).map(([k, v]) => [Number(k), v]));
644
+ this.textHashes = new Map(Object.entries(data.textHashes || {}).map(([k, v]) => [Number(k), v]));
667
645
  }
668
646
  /**
669
- * 更新指定回声洞的图片哈希值
670
- * @param caveId 回声洞ID
671
- * @param imgBuffers 图片buffer数组
647
+ * 更新指定回声洞的哈希值
648
+ * @param caveId - 回声洞ID
649
+ * @param type - 哈希类型(图像或文本)
650
+ * @param content - 要计算哈希的内容
672
651
  */
673
- async updateCaveHash(caveId, imgBuffers) {
652
+ async updateHash(caveId, type, content) {
674
653
  if (!this.initialized) await this.initialize();
675
654
  try {
676
- if (imgBuffers?.length) {
677
- const hashes = await Promise.all(
678
- imgBuffers.map((buffer) => ImageHasher.calculateHash(buffer))
679
- );
680
- this.hashes.set(caveId, hashes);
681
- logger3.info(`Added ${hashes.length} hashes for cave ${caveId}`);
682
- } else {
683
- this.hashes.delete(caveId);
684
- logger3.info(`Deleted hashes for cave ${caveId}`);
685
- }
655
+ const hash = type === "image" ? await ImageHasher.calculateHash(content) : _HashStorage.hashText(content);
656
+ const hashMap = type === "image" ? this.imageHashes : this.textHashes;
657
+ const existingHashes = hashMap.get(caveId) || [];
658
+ hashMap.set(caveId, [...existingHashes, hash]);
686
659
  await this.saveHashes();
687
660
  } catch (error) {
688
- logger3.error(`Failed to update hash (cave ${caveId}): ${error.message}`);
661
+ logger3.error(`Failed to update ${type} hash for cave ${caveId}: ${error.message}`);
689
662
  }
690
663
  }
691
664
  /**
692
- * 更新所有回声洞的哈希值
693
- * @param isInitialBuild 是否为初始构建
665
+ * 查找重复项
666
+ * @param type - 查找类型(图像或文本)
667
+ * @param hashes - 要查找的哈希值数组
668
+ * @param threshold - 相似度阈值,默认为1
669
+ * @returns 匹配结果数组
694
670
  */
695
- async updateAllCaves(isInitialBuild = false) {
696
- if (!this.initialized && !isInitialBuild) {
697
- await this.initialize();
698
- return;
699
- }
700
- try {
701
- logger3.info("Starting full hash update...");
702
- const caveData = await this.loadCaveData();
703
- const cavesWithImages = caveData.filter(
704
- (cave) => cave.elements?.some((el) => el.type === "img" && el.file)
705
- );
706
- this.hashes.clear();
707
- let processedCount = 0;
708
- const totalImages = cavesWithImages.length;
709
- const processCave = /* @__PURE__ */ __name(async (cave) => {
710
- const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
711
- if (imgElements.length === 0) return;
712
- try {
713
- const hashes = await Promise.all(
714
- imgElements.map(async (imgElement) => {
715
- const filePath = path3.join(this.resourceDir, imgElement.file);
716
- if (!fs3.existsSync(filePath)) {
717
- logger3.warn(`Image file not found: ${filePath}`);
718
- return null;
719
- }
720
- const imgBuffer = await readFileAsync(filePath);
721
- return await ImageHasher.calculateHash(imgBuffer);
722
- })
723
- );
724
- const validHashes = hashes.filter((hash) => hash !== null);
725
- if (validHashes.length > 0) {
726
- this.hashes.set(cave.cave_id, validHashes);
727
- processedCount++;
728
- if (processedCount % 100 === 0) {
729
- logger3.info(`Progress: ${processedCount}/${totalImages}`);
730
- }
731
- }
732
- } catch (error) {
733
- logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
734
- }
735
- }, "processCave");
736
- await this.processBatch(cavesWithImages, processCave);
737
- await this.saveHashes();
738
- logger3.success(`Update completed. Processed ${processedCount}/${totalImages} images`);
739
- } catch (error) {
740
- logger3.error(`Full update failed: ${error.message}`);
741
- throw error;
742
- }
743
- }
744
- /**
745
- * 查找重复的图片
746
- * @param imgBuffers 待查找的图片buffer数组
747
- * @param threshold 相似度阈值
748
- * @returns 匹配结果数组,包含索引、回声洞ID和相似度
749
- */
750
- async findDuplicates(imgBuffers, threshold) {
671
+ async findDuplicates(type, hashes, threshold = 1) {
751
672
  if (!this.initialized) await this.initialize();
752
- const inputHashes = await Promise.all(
753
- imgBuffers.map((buffer) => ImageHasher.calculateHash(buffer))
754
- );
755
- const existingHashes = Array.from(this.hashes.entries());
756
- return Promise.all(
757
- inputHashes.map(async (hash, index) => {
758
- try {
759
- let maxSimilarity = 0;
760
- let matchedCaveId = null;
761
- for (const [caveId, hashes] of existingHashes) {
762
- for (const existingHash of hashes) {
763
- const similarity = ImageHasher.calculateSimilarity(hash, existingHash);
764
- if (similarity >= threshold && similarity > maxSimilarity) {
765
- maxSimilarity = similarity;
766
- matchedCaveId = caveId;
767
- if (Math.abs(similarity - 1) < Number.EPSILON) break;
768
- }
769
- }
770
- if (Math.abs(maxSimilarity - 1) < Number.EPSILON) break;
673
+ const hashMap = type === "image" ? this.imageHashes : this.textHashes;
674
+ const calculateSimilarity = type === "image" ? ImageHasher.calculateSimilarity : (a, b) => a === b ? 1 : 0;
675
+ return hashes.map((hash, index) => {
676
+ let maxSimilarity = 0;
677
+ let matchedCaveId = null;
678
+ for (const [caveId, existingHashes] of hashMap.entries()) {
679
+ for (const existingHash of existingHashes) {
680
+ const similarity = calculateSimilarity(hash, existingHash);
681
+ if (similarity >= threshold && similarity > maxSimilarity) {
682
+ maxSimilarity = similarity;
683
+ matchedCaveId = caveId;
684
+ if (similarity === 1) break;
771
685
  }
772
- return matchedCaveId ? {
773
- index,
774
- caveId: matchedCaveId,
775
- similarity: maxSimilarity
776
- } : null;
777
- } catch (error) {
778
- logger3.warn(`处理图片 ${index} 失败: ${error.message}`);
779
- return null;
780
686
  }
781
- })
782
- );
783
- }
784
- /**
785
- * 加载回声洞数据
786
- * @returns 回声洞数据数组
787
- * @private
788
- */
789
- async loadCaveData() {
790
- const data = await FileHandler.readJsonData(this.caveFilePath);
791
- return Array.isArray(data) ? data.flat() : [];
687
+ if (maxSimilarity === 1) break;
688
+ }
689
+ return matchedCaveId ? { index, caveId: matchedCaveId, similarity: maxSimilarity } : null;
690
+ });
792
691
  }
793
692
  /**
794
- * 保存哈希数据到文件
795
- * @private
693
+ * 清除指定回声洞的所有哈希值
694
+ * @param caveId - 回声洞ID
796
695
  */
797
- async saveHashes() {
798
- const data = {
799
- hashes: Object.fromEntries(this.hashes),
800
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
801
- };
802
- await FileHandler.writeJsonData(this.filePath, [data]);
696
+ async clearHashes(caveId) {
697
+ if (!this.initialized) await this.initialize();
698
+ this.imageHashes.delete(caveId);
699
+ this.textHashes.delete(caveId);
700
+ await this.saveHashes();
803
701
  }
804
702
  /**
805
- * 构建初始哈希数据
703
+ * 构建初始哈希值
806
704
  * @private
807
705
  */
808
706
  async buildInitialHashes() {
809
707
  const caveData = await this.loadCaveData();
810
- let processedImageCount = 0;
811
- const totalImages = caveData.reduce((sum, cave) => sum + (cave.elements?.filter((el) => el.type === "img" && el.file).length || 0), 0);
812
- logger3.info(`Building hash data for ${totalImages} images...`);
708
+ let processedCount = 0;
709
+ const total = caveData.length;
813
710
  for (const cave of caveData) {
814
- const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
815
- if (imgElements.length === 0) continue;
816
- try {
817
- const hashes = await Promise.all(
818
- imgElements.map(async (imgElement) => {
819
- const filePath = path3.join(this.resourceDir, imgElement.file);
820
- if (!fs3.existsSync(filePath)) {
821
- logger3.warn(`Image not found: ${filePath}`);
822
- return null;
823
- }
824
- const imgBuffer = await fs3.promises.readFile(filePath);
825
- const hash = await ImageHasher.calculateHash(imgBuffer);
826
- processedImageCount++;
827
- if (processedImageCount % 100 === 0) {
828
- logger3.info(`Progress: ${processedImageCount}/${totalImages} images`);
829
- }
830
- return hash;
831
- })
832
- );
833
- const validHashes = hashes.filter((hash) => hash !== null);
834
- if (validHashes.length > 0) {
835
- this.hashes.set(cave.cave_id, validHashes);
836
- }
837
- } catch (error) {
838
- logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
711
+ await this.processCaveHashes(cave);
712
+ await this.processCaveTextHashes(cave);
713
+ processedCount++;
714
+ if (processedCount % 100 === 0 || processedCount === total) {
715
+ logger3.info(`Processing caves: ${processedCount}/${total} (${Math.floor(processedCount / total * 100)}%)`);
839
716
  }
840
717
  }
841
718
  await this.saveHashes();
842
- logger3.success(`Build completed. Processed ${processedImageCount}/${totalImages} images`);
719
+ logger3.info(`Processed ${processedCount} caves with ${this.imageHashes.size} images and ${this.textHashes.size} texts`);
843
720
  }
844
721
  /**
845
722
  * 更新缺失的哈希值
@@ -847,53 +724,97 @@ var HashStorage = class _HashStorage {
847
724
  */
848
725
  async updateMissingHashes() {
849
726
  const caveData = await this.loadCaveData();
850
- let updatedCount = 0;
851
- for (const cave of caveData) {
852
- if (this.hashes.has(cave.cave_id)) continue;
853
- const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
854
- if (imgElements.length === 0) continue;
855
- try {
856
- const hashes = await Promise.all(
857
- imgElements.map(async (imgElement) => {
858
- const filePath = path3.join(this.resourceDir, imgElement.file);
859
- if (!fs3.existsSync(filePath)) {
860
- return null;
861
- }
862
- const imgBuffer = await fs3.promises.readFile(filePath);
863
- return ImageHasher.calculateHash(imgBuffer);
864
- })
865
- );
866
- const validHashes = hashes.filter((hash) => hash !== null);
867
- if (validHashes.length > 0) {
868
- this.hashes.set(cave.cave_id, validHashes);
869
- updatedCount++;
727
+ const missingImageCaves = caveData.filter((cave) => !this.imageHashes.has(cave.cave_id));
728
+ const missingTextCaves = caveData.filter((cave) => !this.textHashes.has(cave.cave_id));
729
+ const total = missingImageCaves.length + missingTextCaves.length;
730
+ if (total > 0) {
731
+ let processedCount = 0;
732
+ for (const cave of missingImageCaves) {
733
+ await this.processCaveHashes(cave);
734
+ processedCount++;
735
+ if (processedCount % 100 === 0 || processedCount === total) {
736
+ logger3.info(`Updating missing hashes: ${processedCount}/${total} (${Math.floor(processedCount / total * 100)}%)`);
870
737
  }
871
- } catch (error) {
872
- logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
873
738
  }
739
+ for (const cave of missingTextCaves) {
740
+ await this.processCaveTextHashes(cave);
741
+ processedCount++;
742
+ if (processedCount % 100 === 0 || processedCount === total) {
743
+ logger3.info(`Updating missing hashes: ${processedCount}/${total} (${Math.floor(processedCount / total * 100)}%)`);
744
+ }
745
+ }
746
+ await this.saveHashes();
747
+ logger3.info(`Updated ${missingImageCaves.length} missing images and ${missingTextCaves.length} missing texts`);
874
748
  }
875
749
  }
876
750
  /**
877
- * 批量处理数组项
878
- * @param items 待处理项数组
879
- * @param processor 处理函数
880
- * @param batchSize 批处理大小
751
+ * 处理单个回声洞的哈希值
752
+ * @param cave - 回声洞数据
881
753
  * @private
882
754
  */
883
- async processBatch(items, processor, batchSize = _HashStorage.BATCH_SIZE) {
884
- for (let i = 0; i < items.length; i += batchSize) {
885
- const batch = items.slice(i, i + batchSize);
886
- await Promise.all(
887
- batch.map(async (item) => {
888
- try {
889
- await processor(item);
890
- } catch (error) {
891
- logger3.error(`Batch processing error: ${error.message}`);
892
- }
893
- })
894
- );
755
+ async processCaveHashes(cave) {
756
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
757
+ if (imgElements.length === 0) return;
758
+ try {
759
+ const hashes = await Promise.all(imgElements.map(async (el) => {
760
+ const filePath = path3.join(this.resourceDir, el.file);
761
+ return fs3.existsSync(filePath) ? ImageHasher.calculateHash(await fs3.promises.readFile(filePath)) : null;
762
+ }));
763
+ const validHashes = hashes.filter(Boolean);
764
+ if (validHashes.length) {
765
+ this.imageHashes.set(cave.cave_id, validHashes);
766
+ await this.saveHashes();
767
+ }
768
+ } catch (error) {
769
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
770
+ }
771
+ }
772
+ async processCaveTextHashes(cave) {
773
+ const textElements = cave.elements?.filter((el) => el.type === "text" && el.content) || [];
774
+ if (textElements.length === 0) return;
775
+ try {
776
+ const hashes = textElements.map((el) => _HashStorage.hashText(el.content));
777
+ if (hashes.length) {
778
+ this.textHashes.set(cave.cave_id, hashes);
779
+ }
780
+ } catch (error) {
781
+ logger3.error(`Failed to process text hashes for cave ${cave.cave_id}: ${error.message}`);
895
782
  }
896
783
  }
784
+ /**
785
+ * 保存哈希数据到文件
786
+ * @private
787
+ */
788
+ async saveHashes() {
789
+ try {
790
+ const data = {
791
+ imageHashes: Object.fromEntries(this.imageHashes),
792
+ textHashes: Object.fromEntries(this.textHashes),
793
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
794
+ };
795
+ await FileHandler.writeJsonData(this.filePath, [data]);
796
+ } catch (error) {
797
+ logger3.error(`Failed to save hash data: ${error.message}`);
798
+ throw error;
799
+ }
800
+ }
801
+ /**
802
+ * 加载回声洞数据
803
+ * @returns 回声洞数据数组
804
+ * @private
805
+ */
806
+ async loadCaveData() {
807
+ const data = await FileHandler.readJsonData(this.caveFilePath);
808
+ return Array.isArray(data) ? data.flat() : [];
809
+ }
810
+ /**
811
+ * 计算文本的哈希值
812
+ * @param text - 要计算哈希的文本
813
+ * @returns MD5哈希值
814
+ */
815
+ static hashText(text) {
816
+ return import_crypto.default.createHash("md5").update(text).digest("hex");
817
+ }
897
818
  };
898
819
 
899
820
  // src/index.ts
@@ -908,10 +829,12 @@ var Config = import_koishi4.Schema.object({
908
829
  // 启用审核
909
830
  imageMaxSize: import_koishi4.Schema.number().default(4),
910
831
  // 图片大小限制(MB)
832
+ enableMD5: import_koishi4.Schema.boolean().default(true),
833
+ // 启用MD5查重
911
834
  enableDuplicate: import_koishi4.Schema.boolean().default(true),
912
- // 开启查重
835
+ // 启用相似度查重
913
836
  duplicateThreshold: import_koishi4.Schema.number().default(0.8),
914
- // 查重阈值(0-1)
837
+ // 相似度查重阈值(0-1)
915
838
  allowVideo: import_koishi4.Schema.boolean().default(true),
916
839
  // 允许视频
917
840
  videoMaxSize: import_koishi4.Schema.number().default(16),
@@ -1023,7 +946,7 @@ async function apply(ctx, config) {
1023
946
  if (targetCave.elements) {
1024
947
  const hashStorage2 = new HashStorage(caveDir);
1025
948
  await hashStorage2.initialize();
1026
- await hashStorage2.updateCaveHash(caveId);
949
+ await hashStorage2.clearHashes(caveId);
1027
950
  for (const element of targetCave.elements) {
1028
951
  if ((element.type === "img" || element.type === "video") && element.file) {
1029
952
  const fullPath = path4.join(resourceDir, element.file);
@@ -1052,7 +975,7 @@ async function apply(ctx, config) {
1052
975
  const inputContent = content.length > 0 ? content.join("\n") : await (async () => {
1053
976
  await sendMessage(session, "commands.cave.add.noContent", [], true);
1054
977
  const reply = await session.prompt({ timeout: 6e4 });
1055
- if (!reply) throw new Error(session.text("commands.cave.add.operationTimeout"));
978
+ if (!reply) session.text("commands.cave.add.operationTimeout");
1056
979
  return reply;
1057
980
  })();
1058
981
  const caveId = await idManager.getNextId();
@@ -1061,6 +984,20 @@ async function apply(ctx, config) {
1061
984
  }
1062
985
  const bypassAudit = config2.whitelist.includes(session.userId) || config2.whitelist.includes(session.guildId) || config2.whitelist.includes(session.channelId);
1063
986
  const { imageUrls, imageElements, videoUrls, videoElements, textParts } = await extractMediaContent(inputContent, config2, session);
987
+ const pureText = textParts.filter((tp) => tp.type === "text").map((tp) => tp.content.trim()).join("\n").trim();
988
+ if (config2.enableMD5 && pureText) {
989
+ const textHash = HashStorage.hashText(pureText);
990
+ const textDuplicates = await hashStorage.findDuplicates("text", [textHash]);
991
+ if (textDuplicates[0]) {
992
+ const data2 = await FileHandler.readJsonData(caveFilePath);
993
+ const duplicateCave = data2.find((item) => item.cave_id === textDuplicates[0].caveId);
994
+ if (duplicateCave) {
995
+ const message = session.text("commands.cave.error.exactDuplicateFound");
996
+ await session.send(message + await buildMessage(duplicateCave, resourceDir, session));
997
+ throw new Error("duplicate_found");
998
+ }
999
+ }
1000
+ }
1064
1001
  if (videoUrls.length > 0 && !config2.allowVideo) {
1065
1002
  return sendMessage(session, "commands.cave.add.videoDisabled", [], true);
1066
1003
  }
@@ -1096,7 +1033,7 @@ async function apply(ctx, config) {
1096
1033
  // 保持原始文本和图片的相对位置
1097
1034
  index: el.index
1098
1035
  }))
1099
- ].sort((a, b) => a.index - a.index),
1036
+ ].sort((a) => a.index - a.index),
1100
1037
  contributor_number: session.userId,
1101
1038
  contributor_name: session.username
1102
1039
  };
@@ -1107,17 +1044,12 @@ async function apply(ctx, config) {
1107
1044
  index: Number.MAX_SAFE_INTEGER
1108
1045
  });
1109
1046
  }
1110
- const hashStorage2 = new HashStorage(path4.join(ctx2.baseDir, "data", "cave"));
1111
- await hashStorage2.initialize();
1112
- const hashStatus = await hashStorage2.getStatus();
1113
- if (!hashStatus.lastUpdated || hashStatus.entries.length === 0) {
1114
- const existingData = await FileHandler.readJsonData(caveFilePath);
1115
- const hasImages = existingData.some(
1116
- (cave) => cave.elements?.some((element) => element.type === "img" && element.file)
1117
- );
1118
- if (hasImages) {
1119
- await hashStorage2.updateAllCaves(true);
1120
- }
1047
+ const existingData = await FileHandler.readJsonData(caveFilePath);
1048
+ const hasImages = existingData.some(
1049
+ (cave) => cave.elements?.some((element) => element.type === "img" && element.file)
1050
+ );
1051
+ if (hasImages) {
1052
+ await hashStorage.initialize();
1121
1053
  }
1122
1054
  if (config2.enableAudit && !bypassAudit) {
1123
1055
  const pendingData = await FileHandler.readJsonData(pendingFilePath);
@@ -1135,9 +1067,14 @@ async function apply(ctx, config) {
1135
1067
  });
1136
1068
  await Promise.all([
1137
1069
  FileHandler.writeJsonData(caveFilePath, data),
1138
- hashStorage2.updateCaveHash(caveId)
1070
+ pureText && config2.enableMD5 ? hashStorage.updateHash(caveId, "text", pureText) : Promise.resolve(),
1071
+ savedImages?.length ? Promise.all(savedImages.map((buffer) => hashStorage.updateHash(caveId, "image", buffer))) : Promise.resolve()
1139
1072
  ]);
1140
1073
  await idManager.addStat(session.userId, caveId);
1074
+ if (config2.enableMD5 && pureText) {
1075
+ const textHash = HashStorage.hashText(pureText);
1076
+ await hashStorage.updateHash(caveId, "text", textHash);
1077
+ }
1141
1078
  return sendMessage(session, "commands.cave.add.addSuccess", [caveId], false);
1142
1079
  } catch (error) {
1143
1080
  logger4.error(`Failed to process add command: ${error.message}`);
@@ -1481,23 +1418,27 @@ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config
1481
1418
  const buffer = Buffer.from(response.data);
1482
1419
  if (mediaType === "img") {
1483
1420
  const baseName = path4.basename(fileName || "md5", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
1484
- const files = await fs4.promises.readdir(resourceDir);
1485
- const duplicateFile = files.find((file) => file.startsWith(baseName + "_"));
1486
- if (duplicateFile) {
1487
- const duplicateCaveId = parseInt(duplicateFile.split("_")[1]);
1488
- if (!isNaN(duplicateCaveId)) {
1489
- const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
1490
- const data = await FileHandler.readJsonData(caveFilePath);
1491
- const originalCave = data.find((item) => item.cave_id === duplicateCaveId);
1492
- if (originalCave) {
1493
- const message = session.text("commands.cave.error.exactDuplicateFound");
1494
- await session.send(message + await buildMessage(originalCave, resourceDir, session));
1495
- throw new Error("duplicate_found");
1421
+ if (config.enableMD5) {
1422
+ const files = await fs4.promises.readdir(resourceDir);
1423
+ const duplicateFile = files.find((file) => file.startsWith(baseName + "_"));
1424
+ if (duplicateFile) {
1425
+ const duplicateCaveId = parseInt(duplicateFile.split("_")[1]);
1426
+ if (!isNaN(duplicateCaveId)) {
1427
+ const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
1428
+ const data = await FileHandler.readJsonData(caveFilePath);
1429
+ const originalCave = data.find((item) => item.cave_id === duplicateCaveId);
1430
+ if (originalCave) {
1431
+ const message = session.text("commands.cave.error.exactDuplicateFound");
1432
+ await session.send(message + await buildMessage(originalCave, resourceDir, session));
1433
+ throw new Error("duplicate_found");
1434
+ }
1496
1435
  }
1497
1436
  }
1498
1437
  }
1499
1438
  if (config.enableDuplicate) {
1500
- const result = await hashStorage.findDuplicates([buffer], config.duplicateThreshold);
1439
+ const hashStorage2 = new HashStorage(path4.join(ctx.baseDir, "data", "cave"));
1440
+ await hashStorage2.initialize();
1441
+ const result = await hashStorage2.findDuplicates("image", [buffer.toString("base64")], config.duplicateThreshold);
1501
1442
  if (result.length > 0 && result[0] !== null) {
1502
1443
  const duplicate = result[0];
1503
1444
  const similarity = duplicate.similarity;
@@ -1522,6 +1463,23 @@ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config
1522
1463
  return finalFileName;
1523
1464
  } else {
1524
1465
  const baseName = path4.basename(fileName || "video", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
1466
+ if (config.enableMD5) {
1467
+ const files = await fs4.promises.readdir(resourceDir);
1468
+ const duplicateFile = files.find((file) => file.startsWith(baseName + "_"));
1469
+ if (duplicateFile) {
1470
+ const duplicateCaveId = parseInt(duplicateFile.split("_")[1]);
1471
+ if (!isNaN(duplicateCaveId)) {
1472
+ const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
1473
+ const data = await FileHandler.readJsonData(caveFilePath);
1474
+ const originalCave = data.find((item) => item.cave_id === duplicateCaveId);
1475
+ if (originalCave) {
1476
+ const message = session.text("commands.cave.error.exactDuplicateFound");
1477
+ await session.send(message + await buildMessage(originalCave, resourceDir, session));
1478
+ throw new Error("duplicate_found");
1479
+ }
1480
+ }
1481
+ }
1482
+ }
1525
1483
  const finalFileName = `${caveId}_${baseName}${ext}`;
1526
1484
  const filePath = path4.join(resourceDir, finalFileName);
1527
1485
  await FileHandler.saveMediaFile(filePath, buffer);
@@ -1,95 +1,87 @@
1
1
  /**
2
- * 哈希存储状态信息
3
- */
4
- interface HashStatus {
5
- /** 最后更新时间戳 */
6
- lastUpdated: string;
7
- /** 所有回声洞的哈希值条目 */
8
- entries: Array<{
9
- caveId: number;
10
- hashes: string[];
11
- }>;
12
- }
13
- /**
14
- * 图片哈希值存储管理类
15
- * 负责管理和维护回声洞图片的哈希值
2
+ * 哈希存储管理类
3
+ * @class HashStorage
16
4
  */
17
5
  export declare class HashStorage {
18
6
  private readonly caveDir;
19
- private static readonly HASH_FILE;
20
- private static readonly CAVE_FILE;
21
- private static readonly BATCH_SIZE;
22
- private hashes;
7
+ private readonly filePath;
8
+ private readonly resourceDir;
9
+ private readonly caveFilePath;
10
+ private imageHashes;
11
+ private textHashes;
23
12
  private initialized;
24
13
  /**
25
- * 初始化HashStorage实例
26
- * @param caveDir 回声洞数据目录路径
14
+ * 创建哈希存储实例
15
+ * @param caveDir - 回声洞数据目录路径
27
16
  */
28
17
  constructor(caveDir: string);
29
- private get filePath();
30
- private get resourceDir();
31
- private get caveFilePath();
32
18
  /**
33
19
  * 初始化哈希存储
34
- * 读取现有哈希数据或重新构建哈希值
35
20
  * @throws 初始化失败时抛出错误
36
21
  */
37
22
  initialize(): Promise<void>;
38
23
  /**
39
- * 获取当前哈希存储状态
40
- * @returns 包含最后更新时间和所有条目的状态对象
41
- */
42
- getStatus(): Promise<HashStatus>;
43
- /**
44
- * 更新指定回声洞的图片哈希值
45
- * @param caveId 回声洞ID
46
- * @param imgBuffers 图片buffer数组
24
+ * 加载哈希数据
25
+ * @param data - 要加载的哈希数据
26
+ * @private
47
27
  */
48
- updateCaveHash(caveId: number, imgBuffers?: Buffer[]): Promise<void>;
28
+ private loadHashData;
49
29
  /**
50
- * 更新所有回声洞的哈希值
51
- * @param isInitialBuild 是否为初始构建
30
+ * 更新指定回声洞的哈希值
31
+ * @param caveId - 回声洞ID
32
+ * @param type - 哈希类型(图像或文本)
33
+ * @param content - 要计算哈希的内容
52
34
  */
53
- updateAllCaves(isInitialBuild?: boolean): Promise<void>;
35
+ updateHash(caveId: number, type: 'image' | 'text', content: Buffer | string): Promise<void>;
54
36
  /**
55
- * 查找重复的图片
56
- * @param imgBuffers 待查找的图片buffer数组
57
- * @param threshold 相似度阈值
58
- * @returns 匹配结果数组,包含索引、回声洞ID和相似度
37
+ * 查找重复项
38
+ * @param type - 查找类型(图像或文本)
39
+ * @param hashes - 要查找的哈希值数组
40
+ * @param threshold - 相似度阈值,默认为1
41
+ * @returns 匹配结果数组
59
42
  */
60
- findDuplicates(imgBuffers: Buffer[], threshold: number): Promise<Array<{
43
+ findDuplicates(type: 'image' | 'text', hashes: string[], threshold?: number): Promise<Array<{
61
44
  index: number;
62
45
  caveId: number;
63
46
  similarity: number;
64
47
  } | null>>;
65
48
  /**
66
- * 加载回声洞数据
67
- * @returns 回声洞数据数组
49
+ * 清除指定回声洞的所有哈希值
50
+ * @param caveId - 回声洞ID
51
+ */
52
+ clearHashes(caveId: number): Promise<void>;
53
+ /**
54
+ * 构建初始哈希值
68
55
  * @private
69
56
  */
70
- private loadCaveData;
57
+ private buildInitialHashes;
71
58
  /**
72
- * 保存哈希数据到文件
59
+ * 更新缺失的哈希值
73
60
  * @private
74
61
  */
75
- private saveHashes;
62
+ private updateMissingHashes;
76
63
  /**
77
- * 构建初始哈希数据
64
+ * 处理单个回声洞的哈希值
65
+ * @param cave - 回声洞数据
78
66
  * @private
79
67
  */
80
- private buildInitialHashes;
68
+ private processCaveHashes;
69
+ private processCaveTextHashes;
81
70
  /**
82
- * 更新缺失的哈希值
71
+ * 保存哈希数据到文件
83
72
  * @private
84
73
  */
85
- private updateMissingHashes;
74
+ private saveHashes;
86
75
  /**
87
- * 批量处理数组项
88
- * @param items 待处理项数组
89
- * @param processor 处理函数
90
- * @param batchSize 批处理大小
76
+ * 加载回声洞数据
77
+ * @returns 回声洞数据数组
91
78
  * @private
92
79
  */
93
- private processBatch;
80
+ private loadCaveData;
81
+ /**
82
+ * 计算文本的哈希值
83
+ * @param text - 要计算哈希的文本
84
+ * @returns MD5哈希值
85
+ */
86
+ static hashText(text: string): string;
94
87
  }
95
- export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "最好的 cave 插件,可开关的审核系统,可引用添加,支持图文混合内容,可查阅投稿列表,完美复刻你的 .cave 体验!",
4
- "version": "1.3.3",
4
+ "version": "1.3.5",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -8,15 +8,36 @@
8
8
 
9
9
  ### 核心功能
10
10
 
11
- - 支持文字与图片混合保存
12
- - 智能处理各类图片与视频链接
13
- - 内容智能排序,保持原始布局
14
- - 完整的权限管理系统
15
- - 可选的内容审核流程
16
- - 群组调用冷却机制
17
- - 重复内容智能检测
18
- - 黑白名单系统
19
- - 分页显示支持
11
+ - **内容管理**
12
+ - 支持文字与图片混合保存,自动保持布局顺序
13
+ - 视频内容单独发送,支持多种格式
14
+ - 自动文本格式化与排版
15
+ - 引用消息自动解析和保存
16
+ - 支持引用已有内容的布局
17
+
18
+ - **审核机制**
19
+ - 可配置的审核开关与多级权限
20
+ - 完整的黑白名单系统(支持用户/群组/频道)
21
+ - 白名单用户自动跳过审核
22
+ - 支持单条和批量审核操作
23
+ - 拒绝审核时自动清理媒体文件
24
+ - 审核消息自动通知管理员
25
+
26
+ - **媒体处理**
27
+ - 智能处理多种类型媒体链接
28
+ - 支持本地图片上传和URL引用
29
+ - 自动文件大小检查与限制
30
+ - 基于感知哈希的图片查重
31
+ - 可配置的相似度阈值检测
32
+ - MD5文件名重复检查
33
+
34
+ - **使用体验**
35
+ - 基于群组的调用冷却机制
36
+ - 管理员操作不受冷却限制
37
+ - 支持按页浏览投稿记录
38
+ - 支持按用户ID查询统计
39
+ - 临时消息自动清理
40
+ - 错误提示自动消失
20
41
 
21
42
  ### 指令
22
43
 
@@ -54,9 +75,14 @@
54
75
  ### 注意事项
55
76
 
56
77
  1. 图片和视频会自动保存到本地,请确保存储空间充足
57
- 2. 管理员不受群组冷却时间限制
58
- 3. 开启审核模式后,白名单内的用户可直接投稿
59
- 4. 引用消息添加时会保留原消息的格式与布局
60
- 5. 支持自动检测重复图片内容,可通过阈值调整严格程度
78
+ 2. 管理员不受群组冷却时间限制且可查看所有用户统计
79
+ 3. 开启审核模式后,白名单内的用户/群组/频道可直接投稿
80
+ 4. 引用消息添加时会保留原消息的格式与布局顺序
81
+ 5. 支持两种重复检测机制:
82
+ - 基于MD5的精确查重
83
+ - 基于感知哈希的相似度查重
61
84
  6. 黑名单中的用户无法使用任何功能
62
- 7. 支持按页码查看投稿统计,提升大量数据的浏览体验
85
+ 7. 支持按页码和用户ID查看投稿统计
86
+ 8. 临时消息(如错误提示)会在10秒后自动消失
87
+ 9. 视频内容会单独发送以保证正常显示
88
+ 10. 支持自动清理被拒绝或删除的媒体文件