koishi-plugin-best-cave 2.1.2 → 2.1.4

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.
@@ -1,5 +1,6 @@
1
1
  import { Context, Logger } from 'koishi';
2
2
  import { FileManager } from './FileManager';
3
+ import { HashManager } from './HashManager';
3
4
  import { Config } from './index';
4
5
  /**
5
6
  * @class DataManager
@@ -10,16 +11,16 @@ export declare class DataManager {
10
11
  private config;
11
12
  private fileManager;
12
13
  private logger;
13
- private reusableIds;
14
+ private hashManager;
14
15
  /**
15
16
  * @constructor
16
17
  * @param ctx Koishi 上下文,用于数据库操作。
17
18
  * @param config 插件配置。
18
19
  * @param fileManager 文件管理器实例。
19
20
  * @param logger 日志记录器实例。
20
- * @param reusableIds 可复用 ID 的内存缓存。
21
+ * @param hashManager 哈希管理器实例,用于增量更新哈希。
21
22
  */
22
- constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>);
23
+ constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, hashManager: HashManager | null);
23
24
  /**
24
25
  * @description 注册 `.export` 和 `.import` 子命令。
25
26
  * @param cave - 主 `cave` 命令实例。
@@ -0,0 +1,48 @@
1
+ import { Logger } from 'koishi';
2
+ import { Config } from './index';
3
+ /**
4
+ * @class FileManager
5
+ * @description 封装了对文件的存储、读取和删除操作。
6
+ * 能根据配置自动选择使用本地文件系统或 AWS S3 作为存储后端。
7
+ * 内置 Promise 文件锁,防止本地文件的并发写入冲突。
8
+ */
9
+ export declare class FileManager {
10
+ private logger;
11
+ private resourceDir;
12
+ private locks;
13
+ private s3Client?;
14
+ private s3Bucket?;
15
+ /**
16
+ * @constructor
17
+ * @param baseDir Koishi 应用的基础数据目录 (ctx.baseDir)。
18
+ * @param config 插件的配置对象。
19
+ * @param logger 日志记录器实例。
20
+ */
21
+ constructor(baseDir: string, config: Config, logger: Logger);
22
+ /**
23
+ * @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。
24
+ * @template T 异步操作的返回类型。
25
+ * @param fullPath 需要加锁的文件的完整路径。
26
+ * @param operation 要执行的异步函数。
27
+ * @returns 异步操作的结果。
28
+ */
29
+ private withLock;
30
+ /**
31
+ * @description 保存文件,自动选择 S3 或本地存储。
32
+ * @param fileName 用作 S3 Key 或本地文件名。
33
+ * @param data 要写入的 Buffer 数据。
34
+ * @returns 保存时使用的文件名。
35
+ */
36
+ saveFile(fileName: string, data: Buffer): Promise<string>;
37
+ /**
38
+ * @description 读取文件,自动从 S3 或本地存储读取。
39
+ * @param fileName 要读取的文件名/标识符。
40
+ * @returns 文件的 Buffer 数据。
41
+ */
42
+ readFile(fileName: string): Promise<Buffer>;
43
+ /**
44
+ * @description 删除文件,自动从 S3 或本地删除。
45
+ * @param fileIdentifier 要删除的文件名/标识符。
46
+ */
47
+ deleteFile(fileIdentifier: string): Promise<void>;
48
+ }
@@ -0,0 +1,65 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { Config } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @class HashManager
6
+ * @description 封装了所有与文本和图片哈希生成、相似度比较、以及相关命令的功能。
7
+ */
8
+ export declare class HashManager {
9
+ private ctx;
10
+ private config;
11
+ private logger;
12
+ private fileManager;
13
+ private static readonly HASH_BATCH_SIZE;
14
+ private static readonly SIMHASH_BITS;
15
+ /**
16
+ * @constructor
17
+ * @param ctx - Koishi 上下文,用于数据库操作。
18
+ * @param config - 插件配置,用于获取相似度阈值。
19
+ * @param logger - 日志记录器实例。
20
+ * @param fileManager - 文件管理器实例,用于处理历史数据。
21
+ */
22
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
23
+ /**
24
+ * @description 注册与哈希校验相关的子命令。
25
+ * @param cave - 主 `cave` 命令实例。
26
+ */
27
+ registerCommands(cave: any): void;
28
+ /**
29
+ * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希,并在此之后对所有内容进行相似度检查。
30
+ * @returns {Promise<string>} 一个包含操作结果的报告字符串。
31
+ */
32
+ validateAllCaves(): Promise<string>;
33
+ /**
34
+ * @description 将图片切割为4个象限并为每个象限生成pHash。
35
+ * @param imageBuffer - 图片的 Buffer 数据。
36
+ * @returns {Promise<Set<string>>} 一个包含最多4个唯一哈希值的集合。
37
+ */
38
+ generateImageSubHashes(imageBuffer: Buffer): Promise<Set<string>>;
39
+ /**
40
+ * @description 根据感知哈希(pHash)算法为图片生成哈希。
41
+ * @param imageBuffer 图片的 Buffer 数据。
42
+ * @returns 64位二进制哈希字符串。
43
+ */
44
+ generateImagePHash(imageBuffer: Buffer): Promise<string>;
45
+ /**
46
+ * @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
47
+ * @param hash1 - 第一个哈希字符串。
48
+ * @param hash2 - 第二个哈希字符串。
49
+ * @returns {number} 两个哈希之间的距离。
50
+ */
51
+ calculateHammingDistance(hash1: string, hash2: string): number;
52
+ /**
53
+ * @description 根据汉明距离计算图片或文本哈希的相似度。
54
+ * @param hash1 - 第一个哈希字符串。
55
+ * @param hash2 - 第二个哈希字符串。
56
+ * @returns {number} 范围在0到1之间的相似度得分。
57
+ */
58
+ calculateSimilarity(hash1: string, hash2: string): number;
59
+ /**
60
+ * @description 为文本生成基于 Simhash 算法的哈希字符串。
61
+ * @param text - 需要处理的文本。
62
+ * @returns {string} 64位二进制 Simhash 字符串。
63
+ */
64
+ generateTextSimhash(text: string): string;
65
+ }
@@ -0,0 +1,45 @@
1
+ import { Context } from 'koishi';
2
+ /** 数据库 `cave_user` 表的结构。 */
3
+ export interface UserProfile {
4
+ userId: string;
5
+ nickname: string;
6
+ }
7
+ declare module 'koishi' {
8
+ interface Tables {
9
+ cave_user: UserProfile;
10
+ }
11
+ }
12
+ /**
13
+ * @class ProfileManager
14
+ * @description 负责管理用户在回声洞中的自定义昵称。
15
+ */
16
+ export declare class ProfileManager {
17
+ private ctx;
18
+ /**
19
+ * @constructor
20
+ * @param ctx - Koishi 上下文,用于初始化数据库模型。
21
+ */
22
+ constructor(ctx: Context);
23
+ /**
24
+ * @description 注册 `.profile` 子命令,用于管理用户昵称。
25
+ * @param cave - 主 `cave` 命令实例。
26
+ */
27
+ registerCommands(cave: any): void;
28
+ /**
29
+ * @description 设置或更新指定用户的昵称。
30
+ * @param userId - 目标用户的 ID。
31
+ * @param nickname - 要设置的新昵称。
32
+ */
33
+ setNickname(userId: string, nickname: string): Promise<void>;
34
+ /**
35
+ * @description 获取指定用户的昵称。
36
+ * @param userId - 目标用户的 ID。
37
+ * @returns 用户的昵称字符串或 null。
38
+ */
39
+ getNickname(userId: string): Promise<string | null>;
40
+ /**
41
+ * @description 清除指定用户的昵称设置。
42
+ * @param userId - 目标用户的 ID。
43
+ */
44
+ clearNickname(userId: string): Promise<void>;
45
+ }
@@ -12,7 +12,6 @@ export declare class ReviewManager {
12
12
  private logger;
13
13
  private reusableIds;
14
14
  /**
15
- * @constructor
16
15
  * @param ctx Koishi 上下文。
17
16
  * @param config 插件配置。
18
17
  * @param fileManager 文件管理器实例。
@@ -30,11 +29,4 @@ export declare class ReviewManager {
30
29
  * @param cave 新创建的、状态为 'pending' 的回声洞对象。
31
30
  */
32
31
  sendForReview(cave: CaveObject): Promise<void>;
33
- /**
34
- * @description 处理管理员的审核决定(通过或拒绝)。
35
- * @param action 'approve' (通过) 或 'reject' (拒绝)。
36
- * @param caveId 被审核的回声洞 ID。
37
- * @returns 返回给操作者的确认消息。
38
- */
39
- processReview(action: 'approve' | 'reject', caveId: number): Promise<string>;
40
32
  }
package/lib/Utils.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Context, h, Logger, Session } from 'koishi';
2
- import { CaveObject, Config, StoredElement } from './index';
2
+ import { CaveObject, Config, StoredElement, CaveHashObject } from './index';
3
3
  import { FileManager } from './FileManager';
4
+ import { HashManager } from './HashManager';
5
+ import { ReviewManager } from './ReviewManager';
4
6
  /**
5
7
  * @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
6
8
  * @param elements 从数据库读取的元素数组。
@@ -8,42 +10,41 @@ import { FileManager } from './FileManager';
8
10
  */
9
11
  export declare function storedFormatToHElements(elements: StoredElement[]): h[];
10
12
  /**
11
- * @description 构建一条用于发送的完整回声洞消息。
12
- * 此函数会处理 S3 URL、文件映射路径或本地文件到 Base64 的转换。
13
- * @param cave 要展示的回声洞对象。
13
+ * @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
14
+ * @param cave 回声洞对象。
14
15
  * @param config 插件配置。
15
- * @param fileManager FileManager 实例。
16
- * @param logger Logger 实例。
16
+ * @param fileManager 文件管理器实例。
17
+ * @param logger 日志记录器实例。
17
18
  * @returns 包含 h() 元素和字符串的消息数组。
18
19
  */
19
20
  export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
20
21
  /**
21
- * @description 清理数据库中所有被标记为 'delete' 状态的回声洞及其关联文件。
22
+ * @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
22
23
  * @param ctx Koishi 上下文。
23
- * @param fileManager FileManager 实例。
24
- * @param logger Logger 实例。
24
+ * @param fileManager 文件管理器实例。
25
+ * @param logger 日志记录器实例。
25
26
  * @param reusableIds 可复用 ID 的内存缓存。
26
27
  */
27
28
  export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
28
29
  /**
29
- * @description 根据配置(是否分群)和当前会话,生成数据库查询的范围条件。
30
- * @param session 当前会话对象。
30
+ * @description 根据配置和会话,生成数据库查询的范围条件。
31
+ * @param session 当前会话。
31
32
  * @param config 插件配置。
32
- * @returns 用于数据库查询的条件对象。
33
+ * @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
34
+ * @returns 数据库查询条件对象。
33
35
  */
34
- export declare function getScopeQuery(session: Session, config: Config): object;
36
+ export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
35
37
  /**
36
- * @description 获取下一个可用的回声洞 ID
37
- * 实现了三阶段逻辑:优先使用回收ID -> 扫描空闲ID -> 获取最大ID+1。
38
+ * @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
38
39
  * @param ctx Koishi 上下文。
39
- * @param query 查询范围条件,用于分群模式。
40
+ * @param query 查询范围条件。
40
41
  * @param reusableIds 可复用 ID 的内存缓存。
41
42
  * @returns 可用的新 ID。
42
43
  */
43
44
  export declare function getNextCaveId(ctx: Context, query: object, reusableIds: Set<number>): Promise<number>;
44
45
  /**
45
46
  * @description 检查用户是否处于指令冷却中。
46
- * @returns 若在冷却中则返回提示字符串,否则返回 null。
47
+ * @returns 若在冷却中则提示字符串,否则 null。
47
48
  */
48
49
  export declare function checkCooldown(session: Session, config: Config, lastUsed: Map<string, number>): string | null;
49
50
  /**
@@ -52,13 +53,12 @@ export declare function checkCooldown(session: Session, config: Config, lastUsed
52
53
  export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
53
54
  /**
54
55
  * @description 解析消息元素,分离出文本和待下载的媒体文件。
55
- * @param sourceElements - 原始的 Koishi 消息元素数组。
56
- * @param newId - 这条回声洞的新 ID。
57
- * @param channelId - 频道 ID。
58
- * @param userId - 用户 ID。
59
- * @returns 一个包含数据库元素和待保存媒体列表的对象。
56
+ * @param sourceElements 原始的 Koishi 消息元素数组。
57
+ * @param newId 这条回声洞的新 ID。
58
+ * @param session 触发操作的会话。
59
+ * @returns 包含数据库元素和待保存媒体列表的对象。
60
60
  */
61
- export declare function processMessageElements(sourceElements: h[], newId: number, channelId: string, userId: string): Promise<{
61
+ export declare function processMessageElements(sourceElements: h[], newId: number, session: Session): Promise<{
62
62
  finalElementsForDb: StoredElement[];
63
63
  mediaToSave: {
64
64
  sourceUrl: string;
@@ -66,17 +66,20 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
66
66
  }[];
67
67
  }>;
68
68
  /**
69
- * @description 异步处理文件上传和状态更新的后台任务。
69
+ * @description 异步处理文件上传、查重和状态更新的后台任务。
70
70
  * @param ctx - Koishi 上下文。
71
71
  * @param config - 插件配置。
72
- * @param fileManager - 文件管理器实例。
72
+ * @param fileManager - FileManager 实例,用于保存文件。
73
73
  * @param logger - 日志记录器实例。
74
- * @param reviewManager - 审核管理器实例 (可能为 null)。
75
- * @param cave - 已创建的、状态为 'preload' 的回声洞对象。
76
- * @param mediaToSave - 需要下载和保存的媒体文件列表。
77
- * @param reusableIds 可复用 ID 的内存缓存。
74
+ * @param reviewManager - ReviewManager 实例,用于提交审核。
75
+ * @param cave - 刚刚在数据库中创建的 `preload` 状态的回声洞对象。
76
+ * @param mediaToSave - 需要下载和处理的媒体文件列表。
77
+ * @param reusableIds - 可复用 ID 的内存缓存。
78
+ * @param session - 触发此操作的用户会话,用于发送反馈。
79
+ * @param hashManager - HashManager 实例,如果启用则用于哈希计算和比较。
80
+ * @param textHashesToStore - 已预先计算好的、待存入数据库的文本哈希对象数组。
78
81
  */
79
- export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: any, cave: CaveObject, mediaToSave: {
82
+ export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: ReviewManager, cave: CaveObject, mediaToSave: {
80
83
  sourceUrl: string;
81
84
  fileName: string;
82
- }[], reusableIds: Set<number>): Promise<void>;
85
+ }[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
package/lib/index.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { Context, Schema } from 'koishi';
2
+ export declare const name = "best-cave";
3
+ export declare const inject: string[];
4
+ export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
5
+ /**
6
+ * @description 存储在数据库中的单个消息元素。
7
+ */
8
+ export interface StoredElement {
9
+ type: 'text' | 'image' | 'video' | 'audio' | 'file';
10
+ content?: string;
11
+ file?: string;
12
+ }
13
+ /**
14
+ * @description 数据库 `cave` 表的完整对象模型。
15
+ */
16
+ export interface CaveObject {
17
+ id: number;
18
+ elements: StoredElement[];
19
+ channelId: string;
20
+ userId: string;
21
+ userName: string;
22
+ status: 'active' | 'delete' | 'pending' | 'preload';
23
+ time: Date;
24
+ }
25
+ /**
26
+ * @description 数据库 `cave_hash` 表的完整对象模型。
27
+ */
28
+ export interface CaveHashObject {
29
+ cave: number;
30
+ hash: string;
31
+ type: 'sim' | 'phash' | 'sub';
32
+ }
33
+ declare module 'koishi' {
34
+ interface Tables {
35
+ cave: CaveObject;
36
+ cave_hash: CaveHashObject;
37
+ }
38
+ }
39
+ export interface Config {
40
+ coolDown: number;
41
+ perChannel: boolean;
42
+ adminChannel: string;
43
+ enableProfile: boolean;
44
+ enableIO: boolean;
45
+ enableReview: boolean;
46
+ caveFormat: string;
47
+ enableSimilarity: boolean;
48
+ textThreshold: number;
49
+ imageThreshold: number;
50
+ localPath?: string;
51
+ enableS3: boolean;
52
+ endpoint?: string;
53
+ region?: string;
54
+ accessKeyId?: string;
55
+ secretAccessKey?: string;
56
+ bucket?: string;
57
+ publicUrl?: string;
58
+ }
59
+ export declare const Config: Schema<Config>;
60
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -77,7 +77,7 @@ var FileManager = class {
77
77
  * @template T 异步操作的返回类型。
78
78
  * @param fullPath 需要加锁的文件的完整路径。
79
79
  * @param operation 要执行的异步函数。
80
- * @returns 返回异步操作的结果。
80
+ * @returns 异步操作的结果。
81
81
  */
82
82
  async withLock(fullPath, operation) {
83
83
  while (this.locks.has(fullPath)) {
@@ -93,7 +93,7 @@ var FileManager = class {
93
93
  * @description 保存文件,自动选择 S3 或本地存储。
94
94
  * @param fileName 用作 S3 Key 或本地文件名。
95
95
  * @param data 要写入的 Buffer 数据。
96
- * @returns 返回保存时使用的文件名/标识符。
96
+ * @returns 保存时使用的文件名。
97
97
  */
98
98
  async saveFile(fileName, data) {
99
99
  if (this.s3Client) {
@@ -136,8 +136,7 @@ var FileManager = class {
136
136
  async deleteFile(fileIdentifier) {
137
137
  try {
138
138
  if (this.s3Client) {
139
- const command = new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier });
140
- await this.s3Client.send(command);
139
+ await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier }));
141
140
  } else {
142
141
  const filePath = path.join(this.resourceDir, fileIdentifier);
143
142
  await this.withLock(filePath, () => fs.unlink(filePath));
@@ -160,12 +159,9 @@ var ProfileManager = class {
160
159
  this.ctx = ctx;
161
160
  this.ctx.model.extend("cave_user", {
162
161
  userId: "string",
163
- // 用户 ID
164
162
  nickname: "string"
165
- // 用户自定义昵称
166
163
  }, {
167
164
  primary: "userId"
168
- // 保证每个用户只有一条昵称记录。
169
165
  });
170
166
  }
171
167
  static {
@@ -176,7 +172,7 @@ var ProfileManager = class {
176
172
  * @param cave - 主 `cave` 命令实例。
177
173
  */
178
174
  registerCommands(cave) {
179
- cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置你在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
175
+ cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
180
176
  const trimmedNickname = nickname?.trim();
181
177
  if (trimmedNickname) {
182
178
  await this.setNickname(session.userId, trimmedNickname);
@@ -197,10 +193,10 @@ var ProfileManager = class {
197
193
  /**
198
194
  * @description 获取指定用户的昵称。
199
195
  * @param userId - 目标用户的 ID。
200
- * @returns 返回用户的昵称字符串,如果未设置则返回 null。
196
+ * @returns 用户的昵称字符串或 null。
201
197
  */
202
198
  async getNickname(userId) {
203
- const [profile] = await this.ctx.database.get("cave_user", { userId }, { fields: ["nickname"] });
199
+ const [profile] = await this.ctx.database.get("cave_user", { userId });
204
200
  return profile?.nickname ?? null;
205
201
  }
206
202
  /**
@@ -220,14 +216,14 @@ var DataManager = class {
220
216
  * @param config 插件配置。
221
217
  * @param fileManager 文件管理器实例。
222
218
  * @param logger 日志记录器实例。
223
- * @param reusableIds 可复用 ID 的内存缓存。
219
+ * @param hashManager 哈希管理器实例,用于增量更新哈希。
224
220
  */
225
- constructor(ctx, config, fileManager, logger2, reusableIds) {
221
+ constructor(ctx, config, fileManager, logger2, hashManager) {
226
222
  this.ctx = ctx;
227
223
  this.config = config;
228
224
  this.fileManager = fileManager;
229
225
  this.logger = logger2;
230
- this.reusableIds = reusableIds;
226
+ this.hashManager = hashManager;
231
227
  }
232
228
  static {
233
229
  __name(this, "DataManager");
@@ -238,8 +234,9 @@ var DataManager = class {
238
234
  */
239
235
  registerCommands(cave) {
240
236
  const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
241
- const adminChannelId = this.config.adminChannel?.split(":")[1];
242
- if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
237
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
238
+ return "此指令仅限在管理群组中使用";
239
+ }
243
240
  try {
244
241
  await session.send("正在处理,请稍候...");
245
242
  return await action();
@@ -248,8 +245,8 @@ var DataManager = class {
248
245
  return `操作失败: ${error.message}`;
249
246
  }
250
247
  }, "requireAdmin");
251
- cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(requireAdmin(() => this.exportData()));
252
- cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
248
+ cave.subcommand(".export", "导出回声洞数据").action(requireAdmin(() => this.exportData()));
249
+ cave.subcommand(".import", "导入回声洞数据").action(requireAdmin(() => this.importData()));
253
250
  }
254
251
  /**
255
252
  * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
@@ -259,8 +256,7 @@ var DataManager = class {
259
256
  const fileName = "cave_export.json";
260
257
  const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
261
258
  const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
262
- const data = JSON.stringify(portableCaves, null, 2);
263
- await this.fileManager.saveFile(fileName, Buffer.from(data));
259
+ await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(portableCaves, null, 2)));
264
260
  return `成功导出 ${portableCaves.length} 条数据`;
265
261
  }
266
262
  /**
@@ -273,17 +269,13 @@ var DataManager = class {
273
269
  try {
274
270
  const fileContent = await this.fileManager.readFile(fileName);
275
271
  importedCaves = JSON.parse(fileContent.toString("utf-8"));
276
- if (!Array.isArray(importedCaves)) throw new Error("文件格式无效");
277
- if (!importedCaves.length) throw new Error("导入文件为空");
272
+ if (!Array.isArray(importedCaves) || !importedCaves.length) {
273
+ throw new Error("导入文件格式无效或为空");
274
+ }
278
275
  } catch (error) {
279
- this.logger.error(`读取导入文件失败:`, error);
280
276
  throw new Error(`读取导入文件失败: ${error.message}`);
281
277
  }
282
- const [lastCave] = await this.ctx.database.get("cave", {}, {
283
- sort: { id: "desc" },
284
- limit: 1,
285
- fields: ["id"]
286
- });
278
+ const [lastCave] = await this.ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
287
279
  let startId = (lastCave?.id || 0) + 1;
288
280
  const newCavesToInsert = importedCaves.map((cave, index) => ({
289
281
  ...cave,
@@ -291,7 +283,6 @@ var DataManager = class {
291
283
  status: "active"
292
284
  }));
293
285
  await this.ctx.database.upsert("cave", newCavesToInsert);
294
- this.reusableIds.clear();
295
286
  return `成功导入 ${newCavesToInsert.length} 条数据`;
296
287
  }
297
288
  };
@@ -319,8 +310,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
319
310
  const fileName = element.attrs.src;
320
311
  if (!isMedia || !fileName) return element;
321
312
  if (config.enableS3 && config.publicUrl) {
322
- const fullUrl = new URL(fileName, config.publicUrl).href;
323
- return (0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl });
313
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: new URL(fileName, config.publicUrl).href });
324
314
  }
325
315
  if (config.localPath) {
326
316
  return (0, import_koishi.h)(element.type, { ...element.attrs, src: `file://${path2.join(config.localPath, fileName)}` });
@@ -335,8 +325,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
335
325
  }
336
326
  }));
337
327
  const replacements = { id: cave.id.toString(), name: cave.userName };
338
- const formatPart = /* @__PURE__ */ __name((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim(), "formatPart");
339
- const [header, footer] = config.caveFormat.split("|", 2).map(formatPart);
328
+ const [header, footer] = config.caveFormat.split("|", 2).map((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim());
340
329
  const finalMessage = [];
341
330
  if (header) finalMessage.push(header + "\n");
342
331
  finalMessage.push(...processedElements);
@@ -348,20 +337,21 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
348
337
  try {
349
338
  const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
350
339
  if (!cavesToDelete.length) return;
340
+ const idsToDelete = cavesToDelete.map((c) => c.id);
351
341
  for (const cave of cavesToDelete) {
352
- const deletePromises = cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file));
353
- await Promise.all(deletePromises);
354
- reusableIds.add(cave.id);
355
- reusableIds.delete(MAX_ID_FLAG);
356
- await ctx.database.remove("cave", { id: cave.id });
342
+ await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file)));
357
343
  }
344
+ reusableIds.delete(MAX_ID_FLAG);
345
+ idsToDelete.forEach((id) => reusableIds.add(id));
346
+ await ctx.database.remove("cave", { id: { $in: idsToDelete } });
347
+ await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
358
348
  } catch (error) {
359
349
  logger2.error("清理回声洞时发生错误:", error);
360
350
  }
361
351
  }
362
352
  __name(cleanupPendingDeletions, "cleanupPendingDeletions");
363
- function getScopeQuery(session, config) {
364
- const baseQuery = { status: "active" };
353
+ function getScopeQuery(session, config, includeStatus = true) {
354
+ const baseQuery = includeStatus ? { status: "active" } : {};
365
355
  return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
366
356
  }
367
357
  __name(getScopeQuery, "getScopeQuery");
@@ -374,11 +364,7 @@ async function getNextCaveId(ctx, query = {}, reusableIds) {
374
364
  }
375
365
  if (reusableIds.has(MAX_ID_FLAG)) {
376
366
  reusableIds.delete(MAX_ID_FLAG);
377
- const [lastCave] = await ctx.database.get("cave", query, {
378
- fields: ["id"],
379
- sort: { id: "desc" },
380
- limit: 1
381
- });
367
+ const [lastCave] = await ctx.database.get("cave", query, { sort: { id: "desc" }, limit: 1 });
382
368
  const newId2 = (lastCave?.id || 0) + 1;
383
369
  reusableIds.add(MAX_ID_FLAG);
384
370
  return newId2;
@@ -386,11 +372,8 @@ async function getNextCaveId(ctx, query = {}, reusableIds) {
386
372
  const allCaveIds = (await ctx.database.get("cave", query, { fields: ["id"] })).map((c) => c.id);
387
373
  const existingIds = new Set(allCaveIds);
388
374
  let newId = 1;
389
- while (existingIds.has(newId)) {
390
- newId++;
391
- }
392
- const maxIdInDb = allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0;
393
- if (existingIds.size === maxIdInDb) {
375
+ while (existingIds.has(newId)) newId++;
376
+ if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) {
394
377
  reusableIds.add(MAX_ID_FLAG);
395
378
  }
396
379
  return newId;
@@ -412,35 +395,30 @@ function updateCooldownTimestamp(session, config, lastUsed) {
412
395
  }
413
396
  }
414
397
  __name(updateCooldownTimestamp, "updateCooldownTimestamp");
415
- async function processMessageElements(sourceElements, newId, channelId, userId) {
398
+ async function processMessageElements(sourceElements, newId, session) {
416
399
  const finalElementsForDb = [];
417
400
  const mediaToSave = [];
418
401
  let mediaIndex = 0;
419
- const typeMap = {
420
- "img": "image",
421
- "image": "image",
422
- "video": "video",
423
- "audio": "audio",
424
- "file": "file",
425
- "text": "text"
426
- };
402
+ const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text" };
427
403
  const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
428
404
  async function traverse(elements) {
429
405
  for (const el of elements) {
430
- const normalizedType = typeMap[el.type];
431
- if (normalizedType) {
432
- if (["image", "video", "audio", "file"].includes(normalizedType) && el.attrs.src) {
433
- let fileIdentifier = el.attrs.src;
434
- if (fileIdentifier.startsWith("http")) {
435
- const ext = path2.extname(el.attrs.file || "") || defaultExtMap[normalizedType];
436
- const fileName = `${newId}_${++mediaIndex}_${channelId || "private"}_${userId}${ext}`;
437
- mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
438
- fileIdentifier = fileName;
439
- }
440
- finalElementsForDb.push({ type: normalizedType, file: fileIdentifier });
441
- } else if (normalizedType === "text" && el.attrs.content?.trim()) {
442
- finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
406
+ const type = typeMap[el.type];
407
+ if (!type) {
408
+ if (el.children) await traverse(el.children);
409
+ continue;
410
+ }
411
+ if (type === "text" && el.attrs.content?.trim()) {
412
+ finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
413
+ } else if (type !== "text" && el.attrs.src) {
414
+ let fileIdentifier = el.attrs.src;
415
+ if (fileIdentifier.startsWith("http")) {
416
+ const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type];
417
+ const fileName = `${newId}_${++mediaIndex}_${session.channelId || "private"}_${session.userId}${ext}`;
418
+ mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
419
+ fileIdentifier = fileName;
443
420
  }
421
+ finalElementsForDb.push({ type, file: fileIdentifier });
444
422
  }
445
423
  if (el.children) await traverse(el.children);
446
424
  }
@@ -450,21 +428,49 @@ async function processMessageElements(sourceElements, newId, channelId, userId)
450
428
  return { finalElementsForDb, mediaToSave };
451
429
  }
452
430
  __name(processMessageElements, "processMessageElements");
453
- async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave, reusableIds) {
431
+ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave, reusableIds, session, hashManager, textHashesToStore) {
454
432
  try {
455
- const uploadPromises = mediaToSave.map(async (media) => {
456
- const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
457
- await fileManager.saveFile(media.fileName, Buffer.from(response));
458
- });
459
- await Promise.all(uploadPromises);
433
+ const downloadedMedia = [];
434
+ const imageHashesToStore = [];
435
+ for (const media of mediaToSave) {
436
+ const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
437
+ downloadedMedia.push({ fileName: media.fileName, buffer });
438
+ if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
439
+ const pHash = await hashManager.generateImagePHash(buffer);
440
+ const subHashes = await hashManager.generateImageSubHashes(buffer);
441
+ const allNewImageHashes = [pHash, ...subHashes];
442
+ const existingImageHashes = await ctx.database.get("cave_hash", { type: /^image_/ });
443
+ for (const newHash of allNewImageHashes) {
444
+ for (const existing of existingImageHashes) {
445
+ const similarity = hashManager.calculateSimilarity(newHash, existing.hash);
446
+ if (similarity >= config.imageThreshold) {
447
+ await session.send(`图片与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`);
448
+ await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
449
+ reusableIds.add(cave.id);
450
+ return;
451
+ }
452
+ }
453
+ }
454
+ const pHashEntry = { hash: pHash, type: "phash" };
455
+ const subHashEntries = [...subHashes].map((sh) => ({ hash: sh, type: "sub" }));
456
+ imageHashesToStore.push(pHashEntry, ...subHashEntries);
457
+ }
458
+ }
459
+ await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
460
460
  const finalStatus = config.enableReview ? "pending" : "active";
461
461
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
462
+ if (hashManager) {
463
+ const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
464
+ if (allHashesToInsert.length > 0) {
465
+ await ctx.database.upsert("cave_hash", allHashesToInsert);
466
+ }
467
+ }
462
468
  if (finalStatus === "pending" && reviewManager) {
463
469
  const [finalCave] = await ctx.database.get("cave", { id: cave.id });
464
470
  if (finalCave) reviewManager.sendForReview(finalCave);
465
471
  }
466
- } catch (fileSaveError) {
467
- logger2.error(`回声洞(${cave.id})文件保存失败:`, fileSaveError);
472
+ } catch (fileProcessingError) {
473
+ logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
468
474
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
469
475
  cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
470
476
  }
@@ -474,7 +480,6 @@ __name(handleFileUploads, "handleFileUploads");
474
480
  // src/ReviewManager.ts
475
481
  var ReviewManager = class {
476
482
  /**
477
- * @constructor
478
483
  * @param ctx Koishi 上下文。
479
484
  * @param config 插件配置。
480
485
  * @param fileManager 文件管理器实例。
@@ -497,51 +502,50 @@ var ReviewManager = class {
497
502
  */
498
503
  registerCommands(cave) {
499
504
  const requireAdmin = /* @__PURE__ */ __name((session) => {
500
- const adminChannelId = this.config.adminChannel?.split(":")[1];
501
- if (session.channelId !== adminChannelId) {
505
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
502
506
  return "此指令仅限在管理群组中使用";
503
507
  }
504
508
  return null;
505
509
  }, "requireAdmin");
506
- const review = cave.subcommand(".review [id:posint]", "审核回声洞").usage("查看所有待审核回声洞,或查看指定待审核回声洞。").action(async ({ session }, id) => {
510
+ const review = cave.subcommand(".review [id:posint]", "审核回声洞").action(async ({ session }, id) => {
507
511
  const adminError = requireAdmin(session);
508
512
  if (adminError) return adminError;
509
- if (!id) {
510
- const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
511
- if (!pendingCaves.length) return "当前没有需要审核的回声洞";
512
- return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
513
- ${pendingCaves.map((c) => c.id).join("|")}`;
513
+ if (id) {
514
+ const [targetCave] = await this.ctx.database.get("cave", { id });
515
+ if (!targetCave) return `回声洞(${id})不存在`;
516
+ if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
517
+ return [`待审核`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
514
518
  }
515
- const [targetCave] = await this.ctx.database.get("cave", { id });
516
- if (!targetCave) return `回声洞(${id})不存在`;
517
- if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
518
- return [`待审核:`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
519
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
520
+ if (!pendingCaves.length) return "当前没有需要审核的回声洞";
521
+ return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
522
+ ${pendingCaves.map((c) => c.id).join("|")}`;
519
523
  });
520
524
  const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
521
525
  const adminError = requireAdmin(session);
522
526
  if (adminError) return adminError;
523
- await session.send("正在处理,请稍候...");
524
527
  try {
528
+ const targetStatus = actionType === "approve" ? "active" : "delete";
529
+ const actionText = actionType === "approve" ? "通过" : "拒绝";
525
530
  if (!id) {
526
531
  const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
527
- if (!pendingCaves.length) return `当前没有需要${actionType === "approve" ? "通过" : "拒绝"}的回声洞`;
528
- if (actionType === "approve") {
529
- await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "active" })));
530
- return `已通过 ${pendingCaves.length} 条回声洞`;
531
- } else {
532
- await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "delete" })));
533
- cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
534
- return `已拒绝 ${pendingCaves.length} 条回声洞`;
535
- }
532
+ if (!pendingCaves.length) return `当前没有需要${actionText}的回声洞`;
533
+ await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: targetStatus })));
534
+ if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
535
+ return `已批量${actionText} ${pendingCaves.length} 条回声洞`;
536
536
  }
537
- return this.processReview(actionType, id);
537
+ const [cave2] = await this.ctx.database.get("cave", { id, status: "pending" });
538
+ if (!cave2) return `回声洞(${id})无需审核`;
539
+ await this.ctx.database.upsert("cave", [{ id, status: targetStatus }]);
540
+ if (targetStatus === "delete") cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
541
+ return `回声洞(${id})已${actionText}`;
538
542
  } catch (error) {
539
543
  this.logger.error(`审核操作失败:`, error);
540
544
  return `操作失败: ${error.message}`;
541
545
  }
542
546
  }, "createReviewAction");
543
- review.subcommand(".Y [id:posint]", "通过审核").usage("通过回声洞审核,可批量操作。").action(createReviewAction("approve"));
544
- review.subcommand(".N [id:posint]", "拒绝审核").usage("拒绝回声洞审核,可批量操作。").action(createReviewAction("reject"));
547
+ review.subcommand(".Y [id:posint]", "通过审核").action(createReviewAction("approve"));
548
+ review.subcommand(".N [id:posint]", "拒绝审核").action(createReviewAction("reject"));
545
549
  }
546
550
  /**
547
551
  * @description 将新回声洞提交到管理群组以供审核。
@@ -554,29 +558,227 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
554
558
  return;
555
559
  }
556
560
  try {
557
- const reviewMessage = [`待审核:`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
561
+ const reviewMessage = [`待审核`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
558
562
  await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(reviewMessage));
559
563
  } catch (error) {
560
564
  this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
561
565
  }
562
566
  }
567
+ };
568
+
569
+ // src/HashManager.ts
570
+ var import_sharp = __toESM(require("sharp"));
571
+ var crypto = __toESM(require("crypto"));
572
+ var HashManager = class _HashManager {
563
573
  /**
564
- * @description 处理管理员的审核决定(通过或拒绝)。
565
- * @param action 'approve' (通过) 或 'reject' (拒绝)。
566
- * @param caveId 被审核的回声洞 ID。
567
- * @returns 返回给操作者的确认消息。
574
+ * @constructor
575
+ * @param ctx - Koishi 上下文,用于数据库操作。
576
+ * @param config - 插件配置,用于获取相似度阈值。
577
+ * @param logger - 日志记录器实例。
578
+ * @param fileManager - 文件管理器实例,用于处理历史数据。
568
579
  */
569
- async processReview(action, caveId) {
570
- const [cave] = await this.ctx.database.get("cave", { id: caveId, status: "pending" });
571
- if (!cave) return `回声洞(${caveId})无需审核`;
572
- if (action === "approve") {
573
- await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
574
- return `回声洞(${caveId})已通过`;
575
- } else {
576
- await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
577
- cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
578
- return `回声洞(${caveId})已拒绝`;
580
+ constructor(ctx, config, logger2, fileManager) {
581
+ this.ctx = ctx;
582
+ this.config = config;
583
+ this.logger = logger2;
584
+ this.fileManager = fileManager;
585
+ this.ctx.model.extend("cave_hash", {
586
+ cave: "unsigned",
587
+ hash: "string",
588
+ type: "string"
589
+ }, {
590
+ primary: ["cave", "hash", "type"]
591
+ });
592
+ }
593
+ static {
594
+ __name(this, "HashManager");
595
+ }
596
+ static HASH_BATCH_SIZE = 1e3;
597
+ static SIMHASH_BITS = 64;
598
+ /**
599
+ * @description 注册与哈希校验相关的子命令。
600
+ * @param cave - 主 `cave` 命令实例。
601
+ */
602
+ registerCommands(cave) {
603
+ cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希,并检查现有内容的相似度。").action(async ({ session }) => {
604
+ const adminChannelId = this.config.adminChannel?.split(":")[1];
605
+ if (session.channelId !== adminChannelId) {
606
+ return "此指令仅限在管理群组中使用";
607
+ }
608
+ await session.send("正在处理,请稍候...");
609
+ try {
610
+ return await this.validateAllCaves();
611
+ } catch (error) {
612
+ this.logger.error("校验哈希失败:", error);
613
+ return `校验失败: ${error.message}`;
614
+ }
615
+ });
616
+ }
617
+ /**
618
+ * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希,并在此之后对所有内容进行相似度检查。
619
+ * @returns {Promise<string>} 一个包含操作结果的报告字符串。
620
+ */
621
+ async validateAllCaves() {
622
+ const allCaves = await this.ctx.database.get("cave", { status: "active" });
623
+ const existingHashedCaveIds = new Set((await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] })).map((h4) => h4.cave));
624
+ let hashesToInsert = [];
625
+ let historicalCount = 0;
626
+ let totalHashesGenerated = 0;
627
+ const flushHashes = /* @__PURE__ */ __name(async () => {
628
+ if (hashesToInsert.length > 0) {
629
+ await this.ctx.database.upsert("cave_hash", hashesToInsert);
630
+ totalHashesGenerated += hashesToInsert.length;
631
+ hashesToInsert = [];
632
+ }
633
+ }, "flushHashes");
634
+ for (const cave of allCaves) {
635
+ if (existingHashedCaveIds.has(cave.id)) continue;
636
+ this.logger.info(`正在为回声洞(${cave.id})生成哈希...`);
637
+ historicalCount++;
638
+ const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
639
+ if (combinedText) {
640
+ hashesToInsert.push({ cave: cave.id, hash: this.generateTextSimhash(combinedText), type: "sim" });
641
+ }
642
+ for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
643
+ try {
644
+ const imageBuffer = await this.fileManager.readFile(el.file);
645
+ const pHash = await this.generateImagePHash(imageBuffer);
646
+ hashesToInsert.push({ cave: cave.id, hash: pHash, type: "phash" });
647
+ const subHashes = await this.generateImageSubHashes(imageBuffer);
648
+ subHashes.forEach((subHash) => hashesToInsert.push({ cave: cave.id, hash: subHash, type: "sub" }));
649
+ } catch (e) {
650
+ this.logger.warn(`无法为回声洞(${cave.id})的内容(${el.file})生成哈希:`, e);
651
+ }
652
+ }
653
+ if (hashesToInsert.length >= _HashManager.HASH_BATCH_SIZE) await flushHashes();
654
+ }
655
+ await flushHashes();
656
+ const generationReport = totalHashesGenerated > 0 ? `已补全 ${historicalCount} 个回声洞的 ${totalHashesGenerated} 条哈希
657
+ ` : "无需补全回声洞的哈希\n";
658
+ const allHashes = await this.ctx.database.get("cave_hash", {});
659
+ const caveTextHashes = /* @__PURE__ */ new Map();
660
+ const caveImagePHashes = /* @__PURE__ */ new Map();
661
+ for (const hash of allHashes) {
662
+ if (hash.type === "sim") {
663
+ caveTextHashes.set(hash.cave, hash.hash);
664
+ } else if (hash.type === "phash") {
665
+ if (!caveImagePHashes.has(hash.cave)) caveImagePHashes.set(hash.cave, []);
666
+ caveImagePHashes.get(hash.cave).push(hash.hash);
667
+ }
668
+ }
669
+ const caveIds = allCaves.map((c) => c.id);
670
+ const similarPairs = /* @__PURE__ */ new Set();
671
+ for (let i = 0; i < caveIds.length; i++) {
672
+ for (let j = i + 1; j < caveIds.length; j++) {
673
+ const id1 = caveIds[i];
674
+ const id2 = caveIds[j];
675
+ const textHash1 = caveTextHashes.get(id1);
676
+ const textHash2 = caveTextHashes.get(id2);
677
+ if (textHash1 && textHash2) {
678
+ const textSim = this.calculateSimilarity(textHash1, textHash2);
679
+ if (textSim >= this.config.textThreshold) {
680
+ similarPairs.add(`文本:(${id1},${id2}),相似度:${(textSim * 100).toFixed(2)}%`);
681
+ }
682
+ }
683
+ const imageHashes1 = caveImagePHashes.get(id1) || [];
684
+ const imageHashes2 = caveImagePHashes.get(id2) || [];
685
+ if (imageHashes1.length > 0 && imageHashes2.length > 0) {
686
+ for (const imgHash1 of imageHashes1) {
687
+ for (const imgHash2 of imageHashes2) {
688
+ const imgSim = this.calculateSimilarity(imgHash1, imgHash2);
689
+ if (imgSim >= this.config.imageThreshold) {
690
+ similarPairs.add(`图片:(${id1},${id2}),相似度:${(imgSim * 100).toFixed(2)}%`);
691
+ }
692
+ }
693
+ }
694
+ }
695
+ }
696
+ }
697
+ const similarityReport = similarPairs.size > 0 ? `发现 ${similarPairs.size} 对高相似度内容:
698
+ ` + [...similarPairs].join("\n") : "未发现高相似度内容";
699
+ return `校验完成:
700
+ ${generationReport}${similarityReport}`;
701
+ }
702
+ /**
703
+ * @description 将图片切割为4个象限并为每个象限生成pHash。
704
+ * @param imageBuffer - 图片的 Buffer 数据。
705
+ * @returns {Promise<Set<string>>} 一个包含最多4个唯一哈希值的集合。
706
+ */
707
+ async generateImageSubHashes(imageBuffer) {
708
+ const hashes = /* @__PURE__ */ new Set();
709
+ try {
710
+ const metadata = await (0, import_sharp.default)(imageBuffer).metadata();
711
+ const { width, height } = metadata;
712
+ if (!width || !height || width < 16 || height < 16) return hashes;
713
+ const regions = [
714
+ { left: 0, top: 0, width: Math.floor(width / 2), height: Math.floor(height / 2) },
715
+ { left: Math.floor(width / 2), top: 0, width: Math.ceil(width / 2), height: Math.floor(height / 2) },
716
+ { left: 0, top: Math.floor(height / 2), width: Math.floor(width / 2), height: Math.ceil(height / 2) },
717
+ { left: Math.floor(width / 2), top: Math.floor(height / 2), width: Math.ceil(width / 2), height: Math.ceil(height / 2) }
718
+ ];
719
+ for (const region of regions) {
720
+ if (region.width < 8 || region.height < 8) continue;
721
+ const quadrantBuffer = await (0, import_sharp.default)(imageBuffer).extract(region).toBuffer();
722
+ hashes.add(await this.generateImagePHash(quadrantBuffer));
723
+ }
724
+ } catch (e) {
725
+ this.logger.warn(`生成子哈希失败:`, e);
726
+ }
727
+ return hashes;
728
+ }
729
+ /**
730
+ * @description 根据感知哈希(pHash)算法为图片生成哈希。
731
+ * @param imageBuffer 图片的 Buffer 数据。
732
+ * @returns 64位二进制哈希字符串。
733
+ */
734
+ async generateImagePHash(imageBuffer) {
735
+ const smallImage = await (0, import_sharp.default)(imageBuffer).grayscale().resize(8, 8, { fit: "fill" }).raw().toBuffer();
736
+ const totalLuminance = smallImage.reduce((acc, val) => acc + val, 0);
737
+ const avgLuminance = totalLuminance / 64;
738
+ return Array.from(smallImage).map((lum) => lum > avgLuminance ? "1" : "0").join("");
739
+ }
740
+ /**
741
+ * @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
742
+ * @param hash1 - 第一个哈希字符串。
743
+ * @param hash2 - 第二个哈希字符串。
744
+ * @returns {number} 两个哈希之间的距离。
745
+ */
746
+ calculateHammingDistance(hash1, hash2) {
747
+ let distance = 0;
748
+ const len = Math.min(hash1.length, hash2.length);
749
+ for (let i = 0; i < len; i++) {
750
+ if (hash1[i] !== hash2[i]) distance++;
579
751
  }
752
+ return distance;
753
+ }
754
+ /**
755
+ * @description 根据汉明距离计算图片或文本哈希的相似度。
756
+ * @param hash1 - 第一个哈希字符串。
757
+ * @param hash2 - 第二个哈希字符串。
758
+ * @returns {number} 范围在0到1之间的相似度得分。
759
+ */
760
+ calculateSimilarity(hash1, hash2) {
761
+ const distance = this.calculateHammingDistance(hash1, hash2);
762
+ const hashLength = Math.max(hash1.length, hash2.length);
763
+ return hashLength === 0 ? 1 : 1 - distance / hashLength;
764
+ }
765
+ /**
766
+ * @description 为文本生成基于 Simhash 算法的哈希字符串。
767
+ * @param text - 需要处理的文本。
768
+ * @returns {string} 64位二进制 Simhash 字符串。
769
+ */
770
+ generateTextSimhash(text) {
771
+ if (!text?.trim()) return "";
772
+ const tokens = text.toLowerCase().split(/[^a-z0-9\u4e00-\u9fa5]+/).filter(Boolean);
773
+ if (tokens.length === 0) return "";
774
+ const vector = new Array(_HashManager.SIMHASH_BITS).fill(0);
775
+ tokens.forEach((token) => {
776
+ const hash = crypto.createHash("md5").update(token).digest();
777
+ for (let i = 0; i < _HashManager.SIMHASH_BITS; i++) {
778
+ vector[i] += hash[Math.floor(i / 8)] >> i % 8 & 1 ? 1 : -1;
779
+ }
780
+ });
781
+ return vector.map((v) => v > 0 ? "1" : "0").join("");
580
782
  }
581
783
  };
582
784
 
@@ -606,8 +808,11 @@ var Config = import_koishi3.Schema.intersect([
606
808
  caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本")
607
809
  }).description("基础配置"),
608
810
  import_koishi3.Schema.object({
609
- enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核")
610
- }).description("审核配置"),
811
+ enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核"),
812
+ enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
813
+ textThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("文本相似度阈值"),
814
+ imageThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("图片相似度阈值")
815
+ }).description("复核配置"),
611
816
  import_koishi3.Schema.object({
612
817
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
613
818
  enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
@@ -633,8 +838,9 @@ function apply(ctx, config) {
633
838
  const lastUsed = /* @__PURE__ */ new Map();
634
839
  const reusableIds = /* @__PURE__ */ new Set();
635
840
  const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
636
- const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, reusableIds) : null;
637
841
  const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger, reusableIds) : null;
842
+ const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
843
+ const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, hashManager) : null;
638
844
  const cave = ctx.command("cave", "回声洞").option("add", "-a <content:text> 添加回声洞").option("view", "-g <id:posint> 查看指定回声洞").option("delete", "-r <id:posint> 删除指定回声洞").option("list", "-l 查询投稿统计").usage("随机抽取一条已添加的回声洞。").action(async ({ session, options }) => {
639
845
  if (options.add) return session.execute(`cave.add ${options.add}`);
640
846
  if (options.view) return session.execute(`cave.view ${options.view}`);
@@ -657,7 +863,7 @@ function apply(ctx, config) {
657
863
  return "随机获取回声洞失败";
658
864
  }
659
865
  });
660
- cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
866
+ cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
661
867
  try {
662
868
  let sourceElements = session.quote?.elements;
663
869
  if (!sourceElements && content?.trim()) {
@@ -666,24 +872,31 @@ function apply(ctx, config) {
666
872
  if (!sourceElements) {
667
873
  await session.send("请在一分钟内发送你要添加的内容");
668
874
  const reply = await session.prompt(6e4);
669
- if (!reply) return "操作超时,已取消添加";
875
+ if (!reply) return "等待操作超时";
670
876
  sourceElements = import_koishi3.h.parse(reply);
671
877
  }
672
- const idScopeQuery = config.perChannel && session.channelId ? { channelId: session.channelId } : {};
673
- const newId = await getNextCaveId(ctx, idScopeQuery, reusableIds);
674
- const { finalElementsForDb, mediaToSave } = await processMessageElements(
675
- sourceElements,
676
- newId,
677
- session.channelId,
678
- session.userId
679
- );
680
- if (finalElementsForDb.length === 0) {
681
- return "内容为空,已取消添加";
878
+ const newId = await getNextCaveId(ctx, getScopeQuery(session, config, false), reusableIds);
879
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session);
880
+ if (finalElementsForDb.length === 0) return "无可添加内容";
881
+ const textHashesToStore = [];
882
+ if (hashManager) {
883
+ const combinedText = finalElementsForDb.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
884
+ if (combinedText) {
885
+ const newSimhash = hashManager.generateTextSimhash(combinedText);
886
+ const existingTextHashes = await ctx.database.get("cave_hash", { type: "sim" });
887
+ for (const existing of existingTextHashes) {
888
+ const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
889
+ if (similarity >= config.textThreshold) {
890
+ return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
891
+ }
892
+ }
893
+ textHashesToStore.push({ hash: newSimhash, type: "sim" });
894
+ }
682
895
  }
683
896
  const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
684
897
  const hasMedia = mediaToSave.length > 0;
685
898
  const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
686
- const newCave = {
899
+ const newCave = await ctx.database.create("cave", {
687
900
  id: newId,
688
901
  elements: finalElementsForDb,
689
902
  channelId: session.channelId,
@@ -691,27 +904,29 @@ function apply(ctx, config) {
691
904
  userName,
692
905
  status: initialStatus,
693
906
  time: /* @__PURE__ */ new Date()
694
- };
695
- await ctx.database.create("cave", newCave);
907
+ });
696
908
  if (hasMedia) {
697
- handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds);
698
- } else if (initialStatus === "pending") {
699
- reviewManager.sendForReview(newCave);
909
+ handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
910
+ } else {
911
+ if (hashManager && textHashesToStore.length > 0) {
912
+ await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
913
+ }
914
+ if (initialStatus === "pending") {
915
+ reviewManager.sendForReview(newCave);
916
+ }
700
917
  }
701
- const responseMessage = initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
702
- return responseMessage;
918
+ return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
703
919
  } catch (error) {
704
920
  logger.error("添加回声洞失败:", error);
705
921
  return "添加失败,请稍后再试";
706
922
  }
707
923
  });
708
- cave.subcommand(".view <id:posint>", "查看指定回声洞").usage("通过序号查看对应的回声洞。").action(async ({ session }, id) => {
924
+ cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
709
925
  if (!id) return "请输入要查看的回声洞序号";
710
926
  const cdMessage = checkCooldown(session, config, lastUsed);
711
927
  if (cdMessage) return cdMessage;
712
928
  try {
713
- const query = { ...getScopeQuery(session, config), id };
714
- const [targetCave] = await ctx.database.get("cave", query);
929
+ const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id });
715
930
  if (!targetCave) return `回声洞(${id})不存在`;
716
931
  updateCooldownTimestamp(session, config, lastUsed);
717
932
  return buildCaveMessage(targetCave, config, fileManager, logger);
@@ -720,17 +935,14 @@ function apply(ctx, config) {
720
935
  return "查看失败,请稍后再试";
721
936
  }
722
937
  });
723
- cave.subcommand(".del <id:posint>", "删除指定回声洞").usage("通过序号删除对应的回声洞。").action(async ({ session }, id) => {
938
+ cave.subcommand(".del <id:posint>", "删除指定回声洞").action(async ({ session }, id) => {
724
939
  if (!id) return "请输入要删除的回声洞序号";
725
940
  try {
726
941
  const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
727
942
  if (!targetCave) return `回声洞(${id})不存在`;
728
- const adminChannelId = config.adminChannel?.split(":")[1];
729
943
  const isAuthor = targetCave.userId === session.userId;
730
- const isAdmin = session.channelId === adminChannelId;
731
- if (!isAuthor && !isAdmin) {
732
- return "你没有权限删除这条回声洞";
733
- }
944
+ const isAdmin = session.channelId === config.adminChannel?.split(":")[1];
945
+ if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
734
946
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
735
947
  const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
736
948
  cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
@@ -740,10 +952,9 @@ function apply(ctx, config) {
740
952
  return "删除失败,请稍后再试";
741
953
  }
742
954
  });
743
- cave.subcommand(".list", "查询我的投稿").usage("查询并列出你所有投稿的回声洞序号。").action(async ({ session }) => {
955
+ cave.subcommand(".list", "查询我的投稿").action(async ({ session }) => {
744
956
  try {
745
- const query = { ...getScopeQuery(session, config), userId: session.userId };
746
- const userCaves = await ctx.database.get("cave", query, { fields: ["id"] });
957
+ const userCaves = await ctx.database.get("cave", { ...getScopeQuery(session, config), userId: session.userId });
747
958
  if (!userCaves.length) return "你还没有投稿过回声洞";
748
959
  const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join(", ");
749
960
  return `你已投稿 ${userCaves.length} 条回声洞,序号为:
@@ -756,6 +967,7 @@ ${caveIds}`;
756
967
  if (profileManager) profileManager.registerCommands(cave);
757
968
  if (dataManager) dataManager.registerCommands(cave);
758
969
  if (reviewManager) reviewManager.registerCommands(cave);
970
+ if (hashManager) hashManager.registerCommands(cave);
759
971
  }
760
972
  __name(apply, "apply");
761
973
  // Annotate the CommonJS export names for ESM import in node:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "最强大的回声洞现已重构完成啦!注意数据格式需要使用脚本转换哦~",
4
- "version": "2.1.2",
4
+ "version": "2.1.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
@@ -28,6 +28,7 @@
28
28
  "koishi": "^4.18.3"
29
29
  },
30
30
  "dependencies": {
31
- "@aws-sdk/client-s3": "^3.300.0"
31
+ "@aws-sdk/client-s3": "^3.800.0",
32
+ "sharp": "^0.34.3"
32
33
  }
33
34
  }