koishi-plugin-best-cave 2.7.19 → 2.7.20

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.
@@ -0,0 +1,80 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { Config } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @description 数据库 `cave_hash` 表的完整对象模型。
6
+ */
7
+ export interface CaveHashObject {
8
+ cave: number;
9
+ hash: string;
10
+ type: 'text' | 'image';
11
+ }
12
+ /**
13
+ * @class HashManager
14
+ * @description 负责生成、存储和比较文本与图片的哈希值。
15
+ * 实现了基于 Simhash 的文本查重和基于 DCT 感知哈希 (pHash) 的图片查重方案。
16
+ */
17
+ export declare class HashManager {
18
+ private ctx;
19
+ private config;
20
+ private logger;
21
+ private fileManager;
22
+ /**
23
+ * @constructor
24
+ * @param ctx - Koishi 上下文,用于数据库操作。
25
+ * @param config - 插件配置,用于获取相似度阈值等。
26
+ * @param logger - 日志记录器实例。
27
+ * @param fileManager - 文件管理器实例,用于读取图片文件。
28
+ */
29
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
30
+ /**
31
+ * @description 注册与哈希功能相关的子命令。
32
+ * @param cave - 主 `cave` 命令实例。
33
+ */
34
+ registerCommands(cave: any): void;
35
+ /**
36
+ * @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
37
+ * @param imageBuffer - 原始的图片 Buffer。
38
+ * @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
39
+ */
40
+ sanitizeImageBuffer(imageBuffer: Buffer): Buffer;
41
+ /**
42
+ * @description 执行一维离散余弦变换 (DCT-II) 的方法。
43
+ * @param input - 输入的数字数组。
44
+ * @returns DCT 变换后的数组。
45
+ */
46
+ private dct1D;
47
+ /**
48
+ * @description 执行二维离散余弦变换 (DCT-II) 的方法。
49
+ * 通过对行和列分别应用一维 DCT 来实现。
50
+ * @param matrix - 输入的 N x N 像素亮度矩阵。
51
+ * @returns DCT 变换后的 N x N 系数矩阵。
52
+ */
53
+ private dct2D;
54
+ /**
55
+ * @description pHash 算法核心实现,使用 Jimp 和自定义 DCT。
56
+ * @param imageBuffer - 图片的 Buffer。
57
+ * @returns 64位十六进制 pHash 字符串。
58
+ */
59
+ generatePHash(imageBuffer: Buffer): Promise<string>;
60
+ /**
61
+ * @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。
62
+ * @param hex1 - 第一个哈希。
63
+ * @param hex2 - 第二个哈希。
64
+ * @returns 汉明距离。
65
+ */
66
+ calculateHammingDistance(hex1: string, hex2: string): number;
67
+ /**
68
+ * @description 根据汉明距离计算相似度百分比。
69
+ * @param hex1 - 第一个哈希。
70
+ * @param hex2 - 第二个哈希。
71
+ * @returns 相似度 (0-100)。
72
+ */
73
+ calculateSimilarity(hex1: string, hex2: string): number;
74
+ /**
75
+ * @description 为文本生成 64 位 Simhash 字符串。
76
+ * @param text - 需要处理的文本。
77
+ * @returns 16位十六进制 Simhash 字符串。
78
+ */
79
+ generateTextSimhash(text: string): string;
80
+ }
package/lib/Utils.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { Context, h, Logger, Session } from 'koishi';
2
+ import { CaveObject, Config, StoredElement } from './index';
3
+ import { FileManager } from './FileManager';
4
+ import { HashManager, CaveHashObject } from './HashManager';
5
+ /**
6
+ * @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
7
+ * @param cave 回声洞对象。
8
+ * @param config 插件配置。
9
+ * @param fileManager 文件管理器实例。
10
+ * @param logger 日志记录器实例。
11
+ * @param platform 目标平台名称 (e.g., 'onebot')。
12
+ * @param prefix 可选的消息前缀 (e.g., '已删除', '待审核')。
13
+ * @returns 包含多条消息的数组,每条消息是一个 (string | h)[] 数组。
14
+ */
15
+ export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger, platform?: string, prefix?: string): Promise<(string | h)[][]>;
16
+ /**
17
+ * @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
18
+ * @param ctx Koishi 上下文。
19
+ * @param fileManager 文件管理器实例。
20
+ * @param logger 日志记录器实例。
21
+ * @param reusableIds 可复用 ID 的内存缓存。
22
+ */
23
+ export declare function cleanupPendingDeletions(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
24
+ /**
25
+ * @description 根据配置和会话,生成数据库查询的范围条件。
26
+ * @param session 当前会话。
27
+ * @param config 插件配置。
28
+ * @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
29
+ * @returns 数据库查询条件对象。
30
+ */
31
+ export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
32
+ /**
33
+ * @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
34
+ * @param ctx Koishi 上下文。
35
+ * @param reusableIds 可复用 ID 的内存缓存。
36
+ * @returns 可用的新 ID。
37
+ */
38
+ export declare function getNextCaveId(ctx: Context, reusableIds: Set<number>): Promise<number>;
39
+ /**
40
+ * @description 解析消息元素,分离出文本和待下载的媒体文件。
41
+ * @param sourceElements 原始的 Koishi 消息元素数组。
42
+ * @param newId 这条回声洞的新 ID。
43
+ * @param session 触发操作的会话。
44
+ * @param config 插件配置。
45
+ * @param logger 日志实例。
46
+ * @param creationTime 统一的创建时间戳,用于生成文件名。
47
+ * @returns 包含数据库元素和待保存媒体列表的对象。
48
+ */
49
+ export declare function processMessageElements(sourceElements: h[], newId: number, session: Session, creationTime: Date): Promise<{
50
+ finalElementsForDb: StoredElement[];
51
+ mediaToSave: {
52
+ sourceUrl: string;
53
+ fileName: string;
54
+ }[];
55
+ }>;
56
+ /**
57
+ * @description 执行文本 (Simhash) 和图片 (pHash) 相似度查重。
58
+ * @returns 一个对象,指示是否发现重复项;如果未发现,则返回生成的哈希。
59
+ */
60
+ export declare function performSimilarityChecks(ctx: Context, config: Config, hashManager: HashManager, logger: Logger, finalElementsForDb: StoredElement[], downloadedMedia: {
61
+ fileName: string;
62
+ buffer: Buffer;
63
+ }[]): Promise<{
64
+ duplicate: boolean;
65
+ message?: string;
66
+ textHashesToStore?: Omit<CaveHashObject, 'cave'>[];
67
+ imageHashesToStore?: Omit<CaveHashObject, 'cave'>[];
68
+ }>;
69
+ /**
70
+ * @description 校验会话是否来自指定的管理群组。
71
+ * @param session 当前会话。
72
+ * @param config 插件配置。
73
+ * @returns 如果校验不通过,返回错误信息字符串;如果通过,返回 null。
74
+ */
75
+ export declare function requireAdmin(session: Session, config: Config): string | null;
76
+ /**
77
+ * @class DSU
78
+ * @description 一个通用的并查集(Disjoint Set Union)数据结构,用于高效地处理集合的合并与查找。
79
+ * 非常适合用于将相似或重复的项进行聚类。
80
+ */
81
+ export declare class DSU {
82
+ private parent;
83
+ /**
84
+ * 查找元素的根节点,并进行路径压缩优化。
85
+ * @param i - 要查找的元素 ID。
86
+ * @returns 元素的根节点 ID。
87
+ */
88
+ find(i: number): number;
89
+ /**
90
+ * 合并两个元素所在的集合。
91
+ * @param i - 第一个元素。
92
+ * @param j - 第二个元素。
93
+ */
94
+ union(i: number, j: number): void;
95
+ }
96
+ /**
97
+ * @description 通用的 LSH (局部敏感哈希) 候选对生成器。
98
+ * @param items 要处理的项目数组。
99
+ * @param getBucketInfo 一个函数,接收单个项目,并返回其唯一 ID 和一个桶键数组。
100
+ * @returns 一个 Set,包含所有候选对的字符串键 (e.g., "123-456")。
101
+ */
102
+ export declare function generateFromLSH<T>(items: T[], getBucketInfo: (item: T) => {
103
+ id: number;
104
+ keys: string[];
105
+ }): Set<string>;
package/lib/index.js CHANGED
@@ -487,25 +487,67 @@ async function performSimilarityChecks(ctx, config, hashManager, logger2, finalE
487
487
  }
488
488
  }
489
489
  __name(performSimilarityChecks, "performSimilarityChecks");
490
- async function handleFileUploads(ctx, config, fileManager, logger2, cave, downloadedMedia, reusableIds, needsReview) {
491
- try {
492
- await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
493
- const finalStatus = needsReview ? "pending" : "active";
494
- await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
495
- return finalStatus;
496
- } catch (fileProcessingError) {
497
- logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
498
- await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
499
- cleanupPendingDeletions(ctx, config, fileManager, logger2, reusableIds);
500
- throw fileProcessingError;
501
- }
502
- }
503
- __name(handleFileUploads, "handleFileUploads");
504
490
  function requireAdmin(session, config) {
505
491
  if (session.cid !== config.adminChannel) return "此指令仅限在管理群组中使用";
506
492
  return null;
507
493
  }
508
494
  __name(requireAdmin, "requireAdmin");
495
+ var DSU = class {
496
+ static {
497
+ __name(this, "DSU");
498
+ }
499
+ parent = /* @__PURE__ */ new Map();
500
+ /**
501
+ * 查找元素的根节点,并进行路径压缩优化。
502
+ * @param i - 要查找的元素 ID。
503
+ * @returns 元素的根节点 ID。
504
+ */
505
+ find(i) {
506
+ if (!this.parent.has(i)) {
507
+ this.parent.set(i, i);
508
+ return i;
509
+ }
510
+ if (this.parent.get(i) === i) return i;
511
+ const root = this.find(this.parent.get(i));
512
+ this.parent.set(i, root);
513
+ return root;
514
+ }
515
+ /**
516
+ * 合并两个元素所在的集合。
517
+ * @param i - 第一个元素。
518
+ * @param j - 第二个元素。
519
+ */
520
+ union(i, j) {
521
+ const rootI = this.find(i);
522
+ const rootJ = this.find(j);
523
+ if (rootI !== rootJ) this.parent.set(rootI, rootJ);
524
+ }
525
+ };
526
+ function generateFromLSH(items, getBucketInfo) {
527
+ const buckets = /* @__PURE__ */ new Map();
528
+ items.forEach((item) => {
529
+ const { id, keys } = getBucketInfo(item);
530
+ if (!id || !keys || keys.length === 0) return;
531
+ keys.forEach((key) => {
532
+ if (!buckets.has(key)) buckets.set(key, []);
533
+ buckets.get(key).push(id);
534
+ });
535
+ });
536
+ const candidatePairs = /* @__PURE__ */ new Set();
537
+ for (const ids of buckets.values()) {
538
+ if (ids.length < 2) continue;
539
+ const uniqueIds = [...new Set(ids)].sort((a, b) => a - b);
540
+ if (uniqueIds.length < 2) continue;
541
+ for (let i = 0; i < uniqueIds.length; i++) {
542
+ for (let j = i + 1; j < uniqueIds.length; j++) {
543
+ const pairKey = `${uniqueIds[i]}-${uniqueIds[j]}`;
544
+ candidatePairs.add(pairKey);
545
+ }
546
+ }
547
+ }
548
+ return candidatePairs;
549
+ }
550
+ __name(generateFromLSH, "generateFromLSH");
509
551
 
510
552
  // src/DataManager.ts
511
553
  var DataManager = class {
@@ -718,7 +760,7 @@ var HashManager = class {
718
760
  __name(this, "HashManager");
719
761
  }
720
762
  /**
721
- * @description 注册与哈希功能相关的 `.hash` 和 `.check` 子命令。
763
+ * @description 注册与哈希功能相关的子命令。
722
764
  * @param cave - 主 `cave` 命令实例。
723
765
  */
724
766
  registerCommands(cave) {
@@ -788,48 +830,78 @@ var HashManager = class {
788
830
  const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
789
831
  const allHashes = await this.ctx.database.get("cave_hash", {});
790
832
  if (allHashes.length < 2) return "无可比较哈希";
791
- const buckets = /* @__PURE__ */ new Map();
792
- const hashLookup = /* @__PURE__ */ new Map();
793
- for (const hashObj of allHashes) {
794
- if (!hashLookup.has(hashObj.cave)) hashLookup.set(hashObj.cave, {});
795
- hashLookup.get(hashObj.cave)[hashObj.type] = hashObj.hash;
833
+ const candidatePairs = generateFromLSH(allHashes, (hashObj) => {
796
834
  const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0");
835
+ const keys = [];
797
836
  for (let i = 0; i < 4; i++) {
798
837
  const band = binHash.substring(i * 16, (i + 1) * 16);
799
- const bucketKey = `${hashObj.type}:${i}:${band}`;
800
- if (!buckets.has(bucketKey)) buckets.set(bucketKey, []);
801
- buckets.get(bucketKey).push(hashObj.cave);
838
+ keys.push(`${hashObj.type}:${i}:${band}`);
802
839
  }
803
- }
804
- const candidatePairs = /* @__PURE__ */ new Set();
805
- const similarPairs = { text: /* @__PURE__ */ new Set(), image: /* @__PURE__ */ new Set() };
806
- for (const ids of buckets.values()) {
807
- if (ids.length < 2) continue;
808
- for (let i = 0; i < ids.length; i++) {
809
- for (let j = i + 1; j < ids.length; j++) {
810
- const id1 = ids[i];
811
- const id2 = ids[j];
812
- const pairKey = [id1, id2].sort((a, b) => a - b).join("-");
813
- if (candidatePairs.has(pairKey)) continue;
814
- candidatePairs.add(pairKey);
815
- const cave1Hashes = hashLookup.get(id1);
816
- const cave2Hashes = hashLookup.get(id2);
817
- if (cave1Hashes?.text && cave2Hashes?.text) {
818
- const similarity = this.calculateSimilarity(cave1Hashes.text, cave2Hashes.text);
819
- if (similarity >= textThreshold) similarPairs.text.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
820
- }
821
- if (cave1Hashes?.image && cave2Hashes?.image) {
822
- const similarity = this.calculateSimilarity(cave1Hashes.image, cave2Hashes.image);
823
- if (similarity >= imageThreshold) similarPairs.image.add(`${id1} & ${id2} = ${similarity.toFixed(2)}%`);
824
- }
825
- }
840
+ return { id: hashObj.cave, keys };
841
+ });
842
+ const hashLookup = /* @__PURE__ */ new Map();
843
+ allHashes.forEach((h4) => {
844
+ if (!hashLookup.has(h4.cave)) hashLookup.set(h4.cave, {});
845
+ hashLookup.get(h4.cave)[h4.type] = h4.hash;
846
+ });
847
+ const textPairs = [];
848
+ const imagePairs = [];
849
+ for (const pairKey of candidatePairs) {
850
+ const [id1, id2] = pairKey.split("-").map(Number);
851
+ const cave1Hashes = hashLookup.get(id1);
852
+ const cave2Hashes = hashLookup.get(id2);
853
+ if (cave1Hashes?.text && cave2Hashes?.text) {
854
+ const similarity = this.calculateSimilarity(cave1Hashes.text, cave2Hashes.text);
855
+ if (similarity >= textThreshold) textPairs.push({ id1, id2, similarity });
826
856
  }
857
+ if (cave1Hashes?.image && cave2Hashes?.image) {
858
+ const similarity = this.calculateSimilarity(cave1Hashes.image, cave2Hashes.image);
859
+ if (similarity >= imageThreshold) imagePairs.push({ id1, id2, similarity });
860
+ }
861
+ }
862
+ if (textPairs.length === 0 && imagePairs.length === 0) return "未发现高相似度的内容";
863
+ const generateReportForType = /* @__PURE__ */ __name((pairs) => {
864
+ if (pairs.length === 0) return { reportLines: [], clusters: [] };
865
+ const dsu = new DSU();
866
+ const allIds = /* @__PURE__ */ new Set();
867
+ pairs.forEach((p) => {
868
+ dsu.union(p.id1, p.id2);
869
+ allIds.add(p.id1);
870
+ allIds.add(p.id2);
871
+ });
872
+ const clusterMap = /* @__PURE__ */ new Map();
873
+ allIds.forEach((id) => {
874
+ const root = dsu.find(id);
875
+ if (!clusterMap.has(root)) clusterMap.set(root, []);
876
+ clusterMap.get(root).push(id);
877
+ });
878
+ const validClusters = Array.from(clusterMap.values()).filter((c) => c.length > 1);
879
+ const reportLines = [];
880
+ validClusters.forEach((cluster) => {
881
+ const sortedCluster = cluster.sort((a, b) => a - b);
882
+ const clusterPairs = pairs.filter((p) => cluster.includes(p.id1) && cluster.includes(p.id2)).sort((a, b) => b.similarity - a.similarity);
883
+ const scores = clusterPairs.map((p) => `${p.similarity.toFixed(2)}%`).join("/");
884
+ reportLines.push(`- ${sortedCluster.join("|")} = ${scores}`);
885
+ });
886
+ return { reportLines, clusters: validClusters };
887
+ }, "generateReportForType");
888
+ const textResult = generateReportForType(textPairs);
889
+ const imageResult = generateReportForType(imagePairs);
890
+ const totalClusters = textResult.clusters.length + imageResult.clusters.length;
891
+ if (totalClusters === 0) return "未发现高相似度的内容";
892
+ let report = `共发现 ${totalClusters} 组高相似度的内容:`;
893
+ if (textResult.reportLines.length > 0) {
894
+ report += `
895
+ [文本相似]`;
896
+ report += `
897
+ ${textResult.reportLines.join("\n")}`;
898
+ }
899
+ if (imageResult.reportLines.length > 0) {
900
+ report += `
901
+ [图片相似]`;
902
+ report += `
903
+ ${imageResult.reportLines.join("\n")}`;
827
904
  }
828
- const totalFindings = similarPairs.text.size + similarPairs.image.size;
829
- if (totalFindings === 0) return "未发现高相似度的内容";
830
- let report = `已发现 ${totalFindings} 组高相似度的内容:`;
831
- if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
832
- if (similarPairs.image.size > 0) report += "\n图片内容相似:\n" + [...similarPairs.image].join("\n");
833
905
  return report.trim();
834
906
  } catch (error) {
835
907
  this.logger.error("检查相似度失败:", error);
@@ -1082,7 +1154,7 @@ var AIManager = class {
1082
1154
  if (cavesToAnalyze.length === 0) return "无需分析回声洞";
1083
1155
  await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
1084
1156
  let successCount = 0;
1085
- const batchSize = 10;
1157
+ const batchSize = 100;
1086
1158
  for (let i = 0; i < cavesToAnalyze.length; i += batchSize) {
1087
1159
  const batch = cavesToAnalyze.slice(i, i + batchSize);
1088
1160
  this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 条回声洞...`);
@@ -1104,27 +1176,42 @@ var AIManager = class {
1104
1176
  try {
1105
1177
  const allMeta = await this.ctx.database.get("cave_meta", {});
1106
1178
  if (allMeta.length < 2) return "无可比较数据";
1179
+ const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: meta.keywords }));
1180
+ if (candidatePairs.size === 0) return "未发现相似内容";
1107
1181
  const allCaves = new Map((await this.ctx.database.get("cave", { status: "active" })).map((c) => [c.id, c]));
1108
- const foundPairs = /* @__PURE__ */ new Set();
1109
- const checkedPairs = /* @__PURE__ */ new Set();
1110
- for (let i = 0; i < allMeta.length; i++) {
1111
- for (let j = i + 1; j < allMeta.length; j++) {
1112
- const meta1 = allMeta[i];
1113
- const meta2 = allMeta[j];
1114
- const pairKey = [meta1.cave, meta2.cave].sort((a, b) => a - b).join("-");
1115
- if (checkedPairs.has(pairKey)) continue;
1116
- const similarity = this.calculateKeywordSimilarity(meta1.keywords, meta2.keywords);
1117
- if (similarity >= 80) {
1118
- const cave1 = allCaves.get(meta1.cave);
1119
- const cave2 = allCaves.get(meta2.cave);
1120
- if (cave1 && cave2 && await this.isContentDuplicateAI(cave1, cave2)) foundPairs.add(`${cave1.id} & ${cave2.id}`);
1121
- checkedPairs.add(pairKey);
1122
- }
1123
- }
1124
- }
1125
- if (foundPairs.size === 0) return "检查完成,未发现高重复性的内容。";
1126
- return `检查完成,共发现 ${foundPairs.size} 组可能重复的内容:
1127
- ${[...foundPairs].join("\n")}`;
1182
+ const duplicatePairs = [];
1183
+ const comparisonPromises = Array.from(candidatePairs).map(async (pairKey) => {
1184
+ const [id1, id2] = pairKey.split("-").map(Number);
1185
+ const cave1 = allCaves.get(id1);
1186
+ const cave2 = allCaves.get(id2);
1187
+ if (cave1 && cave2 && await this.isContentDuplicateAI(cave1, cave2)) return { id1, id2 };
1188
+ return null;
1189
+ });
1190
+ const results = await Promise.all(comparisonPromises);
1191
+ duplicatePairs.push(...results.filter(Boolean));
1192
+ if (duplicatePairs.length === 0) return "未发现高重复性的内容";
1193
+ const dsu = new DSU();
1194
+ const allIds = /* @__PURE__ */ new Set();
1195
+ duplicatePairs.forEach((p) => {
1196
+ dsu.union(p.id1, p.id2);
1197
+ allIds.add(p.id1);
1198
+ allIds.add(p.id2);
1199
+ });
1200
+ const clusters = /* @__PURE__ */ new Map();
1201
+ allIds.forEach((id) => {
1202
+ const root = dsu.find(id);
1203
+ if (!clusters.has(root)) clusters.set(root, []);
1204
+ clusters.get(root).push(id);
1205
+ });
1206
+ const validClusters = Array.from(clusters.values()).filter((c) => c.length > 1);
1207
+ if (validClusters.length === 0) return "未发现高重复性的内容";
1208
+ let report = `共发现 ${validClusters.length} 组高重复性的内容:`;
1209
+ validClusters.forEach((cluster) => {
1210
+ const sortedCluster = cluster.sort((a, b) => a - b);
1211
+ report += `
1212
+ - ${sortedCluster.join("|")}`;
1213
+ });
1214
+ return report;
1128
1215
  } catch (error) {
1129
1216
  this.logger.error("检查重复性失败:", error);
1130
1217
  return `检查失败: ${error.message}`;
@@ -1258,7 +1345,7 @@ ${combinedText}` }, ...images];
1258
1345
  "Content-Type": "application/json",
1259
1346
  "Authorization": `Bearer ${this.config.aiApiKey}`
1260
1347
  };
1261
- const response = await this.http.post(fullUrl, payload, { headers, timeout: 6e4 });
1348
+ const response = await this.http.post(fullUrl, payload, { headers, timeout: 18e4 });
1262
1349
  const content = response?.choices?.[0]?.message?.content;
1263
1350
  if (typeof content !== "string" || !content.trim()) throw new Error();
1264
1351
  try {
@@ -1371,58 +1458,68 @@ function apply(ctx, config) {
1371
1458
  }
1372
1459
  });
1373
1460
  cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
1374
- try {
1375
- let sourceElements;
1376
- if (session.quote?.elements) {
1377
- sourceElements = session.quote.elements;
1378
- } else if (content?.trim()) {
1379
- sourceElements = import_koishi3.h.parse(content);
1380
- } else {
1381
- await session.send("请在一分钟内发送你要添加的内容");
1382
- const reply = await session.prompt(6e4);
1383
- if (!reply) return "等待操作超时";
1384
- sourceElements = import_koishi3.h.parse(reply);
1385
- }
1386
- const newId = await getNextCaveId(ctx, reusableIds);
1387
- const creationTime = /* @__PURE__ */ new Date();
1388
- const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, creationTime);
1389
- if (finalElementsForDb.length === 0) return "无可添加内容";
1390
- const hasMedia = mediaToSave.length > 0;
1391
- const downloadedMedia = [];
1392
- if (hasMedia) {
1393
- for (const media of mediaToSave) {
1394
- const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
1395
- downloadedMedia.push({ fileName: media.fileName, buffer });
1461
+ let sourceElements;
1462
+ if (session.quote?.elements) {
1463
+ sourceElements = session.quote.elements;
1464
+ } else if (content?.trim()) {
1465
+ sourceElements = import_koishi3.h.parse(content);
1466
+ } else {
1467
+ await session.send("请在一分钟内发送你要添加的内容");
1468
+ const reply = await session.prompt(6e4);
1469
+ if (!reply) return "等待操作超时";
1470
+ sourceElements = import_koishi3.h.parse(reply);
1471
+ }
1472
+ const newId = await getNextCaveId(ctx, reusableIds);
1473
+ const creationTime = /* @__PURE__ */ new Date();
1474
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, creationTime);
1475
+ if (finalElementsForDb.length === 0) return "无可添加内容";
1476
+ const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1477
+ const needsReview = config.enablePend && session.cid !== config.adminChannel;
1478
+ const finalStatus = needsReview ? "pending" : "active";
1479
+ const newCave = await ctx.database.create("cave", {
1480
+ id: newId,
1481
+ elements: finalElementsForDb,
1482
+ channelId: session.channelId,
1483
+ userId: session.userId,
1484
+ userName,
1485
+ status: finalStatus,
1486
+ time: creationTime
1487
+ });
1488
+ session.send(needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`);
1489
+ (async () => {
1490
+ try {
1491
+ const hasMedia = mediaToSave.length > 0;
1492
+ const downloadedMedia = [];
1493
+ if (hasMedia) {
1494
+ for (const media of mediaToSave) {
1495
+ const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
1496
+ downloadedMedia.push({ fileName: media.fileName, buffer });
1497
+ }
1498
+ }
1499
+ let textHashesToStore = [];
1500
+ let imageHashesToStore = [];
1501
+ if (hashManager) {
1502
+ for (const media of downloadedMedia) media.buffer = hashManager.sanitizeImageBuffer(media.buffer);
1503
+ const checkResult = await performSimilarityChecks(ctx, config, hashManager, logger, finalElementsForDb, downloadedMedia);
1504
+ if (checkResult.duplicate) {
1505
+ await session.send(`回声洞(${newId})添加失败:${checkResult.message}`);
1506
+ await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1507
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1508
+ return;
1509
+ }
1510
+ textHashesToStore = checkResult.textHashesToStore;
1511
+ imageHashesToStore = checkResult.imageHashesToStore;
1396
1512
  }
1397
- }
1398
- let textHashesToStore = [];
1399
- let imageHashesToStore = [];
1400
- if (hashManager) {
1401
- for (const media of downloadedMedia) media.buffer = hashManager.sanitizeImageBuffer(media.buffer);
1402
- const checkResult = await performSimilarityChecks(ctx, config, hashManager, logger, finalElementsForDb, downloadedMedia);
1403
- if (checkResult.duplicate) return checkResult.message;
1404
- textHashesToStore = checkResult.textHashesToStore;
1405
- imageHashesToStore = checkResult.imageHashesToStore;
1406
- }
1407
- if (aiManager) {
1408
- const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1409
- if (duplicateResult?.duplicate && duplicateResult.ids?.length > 0) return `内容与回声洞(${duplicateResult.ids.join("|")})重复`;
1410
- }
1411
- const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
1412
- const needsReview = config.enablePend && session.cid !== config.adminChannel;
1413
- let finalStatus = hasMedia ? "preload" : needsReview ? "pending" : "active";
1414
- const newCave = await ctx.database.create("cave", {
1415
- id: newId,
1416
- elements: finalElementsForDb,
1417
- channelId: session.channelId,
1418
- userId: session.userId,
1419
- userName,
1420
- status: finalStatus,
1421
- time: creationTime
1422
- });
1423
- if (hasMedia) finalStatus = await handleFileUploads(ctx, config, fileManager, logger, newCave, downloadedMedia, reusableIds, needsReview);
1424
- if (finalStatus !== "preload") {
1425
- newCave.status = finalStatus;
1513
+ if (aiManager) {
1514
+ const duplicateResult = await aiManager.checkForDuplicates(finalElementsForDb, downloadedMedia);
1515
+ if (duplicateResult?.duplicate && duplicateResult.ids?.length > 0) {
1516
+ await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateResult.ids.join("|")})重复`);
1517
+ await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1518
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1519
+ return;
1520
+ }
1521
+ }
1522
+ if (hasMedia) await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
1426
1523
  if (aiManager) {
1427
1524
  const analyses = await aiManager.analyze([newCave], downloadedMedia);
1428
1525
  if (analyses.length > 0) await ctx.database.upsert("cave_meta", analyses);
@@ -1432,12 +1529,13 @@ function apply(ctx, config) {
1432
1529
  if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
1433
1530
  }
1434
1531
  if (finalStatus === "pending" && reviewManager) reviewManager.sendForPend(newCave);
1532
+ } catch (error) {
1533
+ logger.error(`回声洞(${newId})处理失败:`, error);
1534
+ await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
1535
+ await cleanupPendingDeletions(ctx, config, fileManager, logger, reusableIds);
1536
+ await session.send(`回声洞(${newId})处理失败: ${error.message}`);
1435
1537
  }
1436
- return needsReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
1437
- } catch (error) {
1438
- logger.error("添加回声洞失败:", error);
1439
- return "添加失败,请稍后再试";
1440
- }
1538
+ })();
1441
1539
  });
1442
1540
  cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
1443
1541
  if (!id) return "请输入要查看的回声洞序号";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
- "description": "功能强大、高度可定制的回声洞。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "2.7.19",
3
+ "description": "功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
+ "version": "2.7.20",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -2,24 +2,27 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-best-cave?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-best-cave)
4
4
 
5
- 功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。
5
+ 功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。
6
6
 
7
7
  ## 🧩 前置依赖
8
8
 
9
9
  - **数据库 (`koishi-plugin-database`)**: 本插件强依赖数据库来存储数据,请确保您已至少配置了一个数据库插件(如 `koishi-plugin-database-sqlite` 或 `koishi-plugin-database-mysql`)。
10
10
  - **图片处理 (`sharp`)**: 如果您启用了 `enableSimilarity` (内容查重) 功能,插件需要使用 `sharp` 库来处理图片。通常情况下它会自动安装,如遇安装问题,请参考 `sharp` 的官方文档解决。
11
+ - **AI 服务**: 如果您启用了 `enableAI` (AI 功能),则需要一个兼容 OpenAI API 的服务提供商,并获取相应的 **Endpoint** 和 **API Key**。
11
12
 
12
13
  ## ✨ 功能亮点
13
14
 
14
- - **丰富的内容形式**:不止于文本,轻松发布包含图片、视频、音频甚至文件的混合内容。插件能自动解析回复或引用的消息,并将其完整存入回声洞。
15
+ - **丰富的内容形式**:不止于文本,轻松发布包含图片、视频、音频、文件、合并转发的混合内容。插件能自动解析回复或引用的消息,并将其完整存入回声洞。
15
16
  - **灵活的存储后端**:媒体文件可存储在 **本地服务器** (`data/cave` 目录),或配置使用任何 **S3 兼容** 的云端对象存储(如 AWS S3, MinIO, 阿里云 OSS, 腾讯云 COS 等)。支持通过公共URL、本地文件路径或Base64三种方式发送媒体。
16
- - **高级内容查重**:(可选) 启用后,插件会在添加时自动计算文本 (Simhash) 和图片 (pHash) 的哈希值。对于相似度过高的投稿将直接拒绝,有效防止重复。管理员还可通过维护工具找出局部相似或拼接的图片。
17
+ - **AI 智能分析**:(可选) 启用后,插件能调用大语言模型对投稿内容进行智能分析,自动提取**关键词**、生成**一句话描述**并进行**趣味性评分**,为未来的内容检索和管理提供支持。
18
+ - **高级内容查重**:(可选) 插件提供两种查重模式:
19
+ - **哈希查重**:在添加时自动计算文本 (Simhash) 和图片 (pHash) 的哈希值。对于相似度过高的投稿将直接拒绝,有效防止重复。
20
+ - **AI 语义查重**:在添加时利用 AI 模型判断新内容与现有内容在**语义或“梗”的本质上**是否重复,能识别各类变体和转述。
17
21
  - **完善的审核机制**:(可选) 启用后,所有新投稿都将进入待审核状态,并通知管理群组。只有管理员审核通过后,内容才会对用户可见,确保内容质量。
18
22
  - **精细的作用域**:通过 `perChannel` 配置,可设定回声洞是在所有群聊中共享(全局模式),还是在每个群聊中独立(分群模式)。
19
23
  - **专属用户昵称**:(可选) 用户可以为自己在回声洞中的发言设置一个专属昵称,增加趣味性。
20
24
  - **便捷的数据管理**:(可选) 管理员可通过指令轻松地将所有回声洞数据导出为 `JSON` 文件备份,或从文件中恢复数据,迁移无忧。
21
- - **强大的维护工具**:管理员可以通过 `cave.hash` 指令为历史数据批量生成哈希值,并通过 `cave.check` 获取一份所有内容的相似度报告。
22
- - **权限分离**:普通用户可删除自己的投稿。审核、数据管理等高级操作则仅限在指定的 **管理群组** 内由管理员执行。
25
+ - **强大的维护工具**:管理员可以通过一系列指令为历史数据批量生成哈希值 (`cave.hash`)、补全 AI 分析 (`cave.ai`)、获取相似度报告 (`cave.check`) 或 AI 重复性报告 (`cave.compare`)。
23
26
  - **高效的ID管理**:优先复用已删除的ID,并在无可用ID时采用高效的“最大ID+1”策略,确保回声洞序号紧凑,并能高效处理大量数据。
24
27
 
25
28
  ## 📖 指令说明
@@ -29,28 +32,31 @@
29
32
  | 指令 | 别名/选项 | 说明 |
30
33
  | :--- | :--- | :--- |
31
34
  | `cave` | | 随机查看一条回声洞。 |
32
- | `cave.add [内容]` | `cave -a [内容]` | 添加新的回声洞。可以直接跟文字,也可以**回复一条带图片/视频的消息后发送 `cave.add`**,或等待机器人提示后发送。 |
35
+ | `cave.add [内容]` | `cave -a [内容]` | 添加新的回声洞。可以直接跟文字,也可以**回复一条消息后发送 `cave.add`**,或等待机器人提示后发送。 |
33
36
  | `cave.view <序号>` | `cave -g <序号>` | 查看指定序号的回声洞。 |
34
37
  | `cave.del <序号>` | `cave -r <序号>` | 删除指定序号的回声洞。仅投稿人或在管理群组内的管理员可操作。 |
35
38
  | `cave.list` | `cave -l` | 查询并列出自己投稿过的所有回声洞序号及总数。 |
36
39
  | | `-u <用户>` | 查询指定用户(需@或使用ID)投稿的所有回声洞。 |
37
40
  | | `-a` | **(仅限管理群组)** 查看所有用户的投稿数量排行榜。 |
38
41
 
39
- ### 模块化指令
42
+ ### 模块化管理指令
40
43
 
41
- 这些指令只有在配置中启用了相应功能后才可用。
44
+ 这些指令只有在配置中启用了相应功能后才可用,且大部分仅限在**管理群组**中使用。
42
45
 
43
46
  | 指令 | 所需配置 | 说明 |
44
47
  | :--- | :--- | :--- |
45
- | `cave.name [昵称]` | `enableName: true` | 设置你在回声洞中显示的昵称。若不提供昵称,则清除现有设置。 |
46
- | `cave.pend` | `enablePend: true` | **(仅限管理群组)** 列出所有待审核的回声洞ID。 |
47
- | `cave.pend <序号>` | `enablePend: true` | **(仅限管理群组)** 查看指定待审核内容的详情。 |
48
- | `cave.pend.Y [...序号]` | `enablePend: true` | **(仅限管理群组)** 通过审核。若不提供序号,则通过所有待审核内容。 |
49
- | `cave.pend.N [...序号]` | `enablePend: true` | **(仅限管理群组)** 拒绝审核。若不提供序号,则拒绝所有待审核内容。 |
50
- | `cave.export` | `enableIO: true` | **(仅限管理群组)** 将所有`active`状态的回声洞导出到 `data/cave/cave_export.json`。 |
51
- | `cave.import` | `enableIO: true` | **(仅限管理群组)** 从 `data/cave/cave_import.json` 文件中导入数据。 |
52
- | `cave.hash` | `enableSimilarity: true` | **(仅限管理群组)** 校验所有历史数据,为缺失哈希的回声洞补全记录。 |
53
- | `cave.check` | `enableSimilarity: true` | **(仅限管理群组)** 检查所有回声洞的哈希,生成一份关于文本和图片相似度的报告。 |
48
+ | `cave.name [昵称]` | `enableName: true` | **(用户指令)** 设置你在回声洞中显示的昵称。若不提供昵称,则清除现有设置。 |
49
+ | `cave.pend` | `enablePend: true` | **(管理)** 列出所有待审核的回声洞ID。 |
50
+ | `cave.pend <序号>` | `enablePend: true` | **(管理)** 查看指定待审核内容的详情。 |
51
+ | `cave.pend.Y [...序号]` | `enablePend: true` | **(管理)** 通过审核。若不提供序号,则通过所有待审核内容。 |
52
+ | `cave.pend.N [...序号]` | `enablePend: true` | **(管理)** 拒绝审核。若不提供序号,则拒绝所有待审核内容。 |
53
+ | `cave.export` | `enableIO: true` | **(管理)** 将所有`active`状态的回声洞导出到 `data/cave/cave.json`。 |
54
+ | `cave.import` | `enableIO: true` | **(管理)** 从 `data/cave/cave.json` 文件中导入数据。 |
55
+ | `cave.hash` | `enableSimilarity: true` | **(管理)** 校验所有历史数据,为缺失哈希的回声洞补全记录。 |
56
+ | `cave.check` | `enableSimilarity: true` | **(管理)** 检查所有回声洞的哈希,生成一份关于文本和图片相似度的报告。 |
57
+ | `cave.fix [...序号]` | `enableSimilarity: true`| **(管理)** 扫描并修复回声洞中的图片(移除多余数据)。可指定ID或扫描全部。 |
58
+ | `cave.ai` | `enableAI: true` | **(管理)** 分析所有历史数据,为缺失AI元数据的回声洞补全记录。 |
59
+ | `cave.compare` | `enableAI: true` | **(管理)** 检查所有回声洞的AI关键词,生成一份关于内容重复性的报告。 |
54
60
 
55
61
  ## ⚙️ 配置说明
56
62
 
@@ -62,16 +68,25 @@
62
68
  | `enableName` | `boolean` | `false` | 是否启用自定义昵称功能 (`cave.name` 指令)。 |
63
69
  | `enableIO` | `boolean` | `false` | 是否启用数据导入/导出功能 (`cave.export` / `.import` 指令)。 |
64
70
  | `adminChannel` | `string` | `'onebot:'` | **管理群组ID**。格式为 `平台名:群号`,如 `onebot:12345678`。管理指令仅在此群组生效。若配置无效,审核将自动通过。 |
65
- | `caveFormat` | `string` | `'回声洞 ——({id})\|—— {*name}'` | 回声洞消息的显示格式。`\|`为页眉页脚分隔符。支持强大的占位符语法:• **基本**: `{id}`, `{name}`, `{time}`, `{user}`, `{channel}`• **自动打码**: `{*user}` (在占位符前加\*)• **审核可见**: `{/channel}` (在占位符前加/)• **组合**: `{*user/user}` (常规打码/审核时完整) |
71
+ | `caveFormat` | `string` | `'回声洞 ——({id})\|—— {name}'` | 回声洞消息的显示格式。`\|`为页眉页脚分隔符。支持强大的占位符语法:• **基本**: `{id}`, `{name}`, `{time}`, `{user}`, `{channel}`• **自动打码**: `{*user}` (在占位符前加 `*`)• **审核可见**: `{name/}` (在 `/` 后留空,表示常规模式下不显示)• **审核替换**: `{user/name}` (常规模式显示 `user`,审核模式显示 `name`)• **组合**: `{*user/user}` (常规模式下打码显示 `user`,审核时完整显示 `user`) |
66
72
 
67
73
  ### 复核配置 (审核与查重)
68
74
 
69
75
  | 配置项 | 类型 | 默认值 | 说明 |
70
76
  | :--- | :--- | :--- | :--- |
71
77
  | `enablePend` | `boolean` | `false` | 是否启用审核机制。启用后,新投稿将进入`pending`状态。 |
72
- | `enableSimilarity` | `boolean` | `false` | 是否启用内容相似度检查(查重)。 |
73
- | `textThreshold` | `number` | `90` | **文本**相似度阈值 (0-100)。基于Simhash汉明距离计算,超过此值将被拒绝。 |
74
- | `imageThreshold` | `number` | `90` | **图片整体**相似度阈值 (0-100)。基于pHash汉明距离计算,超过此值将被拒绝。 |
78
+ | `enableSimilarity` | `boolean` | `false` | 是否启用基于哈希的内容相似度检查(查重)。 |
79
+ | `textThreshold` | `number` | `95` | **文本**相似度阈值 (0-100)。基于Simhash汉明距离计算,超过此值将被拒绝。 |
80
+ | `imageThreshold` | `number` | `95` | **图片**相似度阈值 (0-100)。基于pHash汉明距离计算,超过此值将被拒绝。 |
81
+
82
+ ### 模型配置 (AI)
83
+
84
+ | 配置项 | 类型 | 默认值 | 说明 |
85
+ | :--- | :--- | :--- | :--- |
86
+ | `enableAI` | `boolean` | `false` | 是否启用 AI 分析与查重功能。 |
87
+ | `aiEndpoint` | `string` | `'https://api.siliconflow.cn/v1'` | **(AI 必填)** 兼容 OpenAI 的 API 端点 (Endpoint)。 |
88
+ | `aiApiKey` | `string` | | **(AI 必填)** API 密钥 (Key)。 |
89
+ | `aiModel` | `string` | `'THUDM/GLM-4.1V-9B-Thinking'` | **(AI 可选)** 使用的语言模型名称 (Model)。需为支持图片理解的多模态模型。 |
75
90
 
76
91
  ### 存储配置
77
92
 
@@ -93,6 +108,6 @@
93
108
  2. **本地文件路径**:如果 `localPath` 已配置。
94
109
  3. **Base64 编码**:如果以上两项均未配置,将文件转为 Base64 发送(可能受平台大小限制或支持问题)。
95
110
  2. **文件存储权限**:若使用本地存储,请确保 Koishi 拥有对 `data/cave` 目录的读写权限。若使用 S3,请确保 Access Key 权限和存储桶策略(如ACL设为`public-read`)配置正确。
96
- 3. **异步删除**:删除操作(`cave.del` 或审核拒绝)会将内容状态标记为 `delete`,然后由后台任务异步清理关联的文件和数据库条目,以避免阻塞当前指令。
97
- 4. **数据迁移**:导入功能会读取插件数据目录下的 `cave_import.json`。导出功能则会生成 `cave_export.json`。请在操作前放置或备份好相应文件。导入时,ID会从现有最大ID开始自增,不会覆盖或修改老数据。
98
- 5. **查重机制**:图片查重在投稿时,仅基于**整图**的相似度进行判断和拒绝。插件还会为图片的四个象限生成哈希,但这些局部哈希仅用于 `cave.check` 指令生成相似度报告,供管理员识别拼接图、裁剪图等情况,**并不会在投稿时直接导致拒绝**。
111
+ 3. **异步处理**:投稿、删除等操作涉及文件下载、哈希计算、AI 分析和文件清理,这些耗时操作均在后台异步执行,以避免阻塞当前指令。您可能会先收到“添加成功”的消息,稍后才会收到查重失败或处理失败的提示。
112
+ 4. **数据迁移**:导入/导出功能均使用插件数据目录下的 `cave.json` 文件。导入时,若遇到与数据库中现有 ID 冲突的条目,该条目会被分配一个新的、当前最大的 ID 加一的序号,不会覆盖或修改老数据。
113
+ 5. **查重机制**:投稿时的查重是实时进行的,若相似度超过阈值会直接拒绝。而 `.check` 和 `.compare` 指令则用于对整个数据库进行批量扫描,生成全面的相似度/重复性报告,帮助管理员发现更复杂或历史遗留的重复内容。