koishi-plugin-best-cave 2.1.1 → 2.1.3

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,38 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { FileManager } from './FileManager';
3
+ import { Config } from './index';
4
+ /**
5
+ * @class DataManager
6
+ * @description 负责处理回声洞数据的导入和导出功能。
7
+ */
8
+ export declare class DataManager {
9
+ private ctx;
10
+ private config;
11
+ private fileManager;
12
+ private logger;
13
+ private reusableIds;
14
+ /**
15
+ * @constructor
16
+ * @param ctx Koishi 上下文,用于数据库操作。
17
+ * @param config 插件配置。
18
+ * @param fileManager 文件管理器实例。
19
+ * @param logger 日志记录器实例。
20
+ * @param reusableIds 可复用 ID 的内存缓存。
21
+ */
22
+ constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>);
23
+ /**
24
+ * @description 注册 `.export` 和 `.import` 子命令。
25
+ * @param cave - 主 `cave` 命令实例。
26
+ */
27
+ registerCommands(cave: any): void;
28
+ /**
29
+ * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
30
+ * @returns 描述导出结果的消息字符串。
31
+ */
32
+ exportData(): Promise<string>;
33
+ /**
34
+ * @description 从 `cave_import.json` 文件导入回声洞数据。
35
+ * @returns 描述导入结果的消息字符串。
36
+ */
37
+ importData(): Promise<string>;
38
+ }
@@ -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,77 @@
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
+ /**
14
+ * @constructor
15
+ * @param ctx - Koishi 上下文,用于数据库操作。
16
+ * @param config - 插件配置,用于获取相似度阈值。
17
+ * @param logger - 日志记录器实例。
18
+ * @param fileManager - 文件管理器实例,用于处理历史数据。
19
+ */
20
+ constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
21
+ /**
22
+ * @description 注册与哈希校验相关的子命令。
23
+ * @param cave - 主 `cave` 命令实例。
24
+ */
25
+ registerCommands(cave: any): void;
26
+ /**
27
+ * @description 检查数据库中所有回声洞,并为没有哈希记录的历史数据生成哈希。
28
+ * @returns {Promise<string>} 返回一个包含操作结果的报告字符串。
29
+ */
30
+ private validateAllCaves;
31
+ /**
32
+ * @description 将图片切割为4个象限并为每个象限生成pHash。
33
+ * @param imageBuffer - 图片的 Buffer 数据。
34
+ * @returns {Promise<Set<string>>} 返回一个包含最多4个唯一哈希值的集合。
35
+ */
36
+ generateImageSubHashes(imageBuffer: Buffer): Promise<Set<string>>;
37
+ /**
38
+ * @description 根据pHash(感知哈希)算法为图片生成哈希值。
39
+ * @param imageBuffer - 图片的 Buffer 数据。
40
+ * @returns {Promise<string>} 返回一个64位的二进制哈希字符串。
41
+ */
42
+ generateImagePHash(imageBuffer: Buffer): Promise<string>;
43
+ /**
44
+ * @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
45
+ * @param hash1 - 第一个哈希字符串。
46
+ * @param hash2 - 第二个哈希字符串。
47
+ * @returns {number} 两个哈希之间的距离。
48
+ */
49
+ calculateHammingDistance(hash1: string, hash2: string): number;
50
+ /**
51
+ * @description 根据汉明距离计算图片pHash的相似度。
52
+ * @param hash1 - 第一个哈希字符串。
53
+ * @param hash2 - 第二个哈希字符串。
54
+ * @returns {number} 范围在0到1之间的相似度得分。
55
+ */
56
+ calculateImageSimilarity(hash1: string, hash2: string): number;
57
+ /**
58
+ * @description 将文本分割成指定大小的“瓦片”(shingles),用于Jaccard相似度计算。
59
+ * @param text - 输入的文本。
60
+ * @param size - 每个瓦片的大小,默认为2。
61
+ * @returns {Set<string>} 包含所有唯一瓦片的集合。
62
+ */
63
+ private getShingles;
64
+ /**
65
+ * @description 为文本生成基于Shingling的哈希字符串。
66
+ * @param text - 需要处理的文本。
67
+ * @returns {string} 由排序后的shingles组成的、用'|'分隔的哈希字符串。
68
+ */
69
+ generateTextHash(text: string): string;
70
+ /**
71
+ * @description 使用Jaccard相似度系数计算两个文本哈希的相似度。
72
+ * @param hash1 - 第一个文本哈希。
73
+ * @param hash2 - 第二个文本哈希。
74
+ * @returns {number} 范围在0到1之间的相似度得分。
75
+ */
76
+ calculateTextSimilarity(hash1: string, hash2: string): number;
77
+ }
@@ -0,0 +1,50 @@
1
+ import { Context } from 'koishi';
2
+ /**
3
+ * @description 数据库 `cave_user` 表的结构定义。
4
+ * @property userId 用户唯一ID,作为主键。
5
+ * @property nickname 用户自定义的昵称。
6
+ */
7
+ export interface UserProfile {
8
+ userId: string;
9
+ nickname: string;
10
+ }
11
+ declare module 'koishi' {
12
+ interface Tables {
13
+ cave_user: UserProfile;
14
+ }
15
+ }
16
+ /**
17
+ * @class ProfileManager
18
+ * @description 负责管理用户在回声洞中的自定义昵称。
19
+ * 当插件配置 `enableProfile` 为 true 时实例化。
20
+ */
21
+ export declare class ProfileManager {
22
+ private ctx;
23
+ /**
24
+ * @constructor
25
+ * @param ctx - Koishi 上下文,用于初始化数据库模型。
26
+ */
27
+ constructor(ctx: Context);
28
+ /**
29
+ * @description 注册 `.profile` 子命令,用于管理用户昵称。
30
+ * @param cave - 主 `cave` 命令实例。
31
+ */
32
+ registerCommands(cave: any): void;
33
+ /**
34
+ * @description 设置或更新指定用户的昵称。
35
+ * @param userId - 目标用户的 ID。
36
+ * @param nickname - 要设置的新昵称。
37
+ */
38
+ setNickname(userId: string, nickname: string): Promise<void>;
39
+ /**
40
+ * @description 获取指定用户的昵称。
41
+ * @param userId - 目标用户的 ID。
42
+ * @returns 返回用户的昵称字符串,如果未设置则返回 null。
43
+ */
44
+ getNickname(userId: string): Promise<string | null>;
45
+ /**
46
+ * @description 清除指定用户的昵称设置。
47
+ * @param userId - 目标用户的 ID。
48
+ */
49
+ clearNickname(userId: string): Promise<void>;
50
+ }
@@ -0,0 +1,40 @@
1
+ import { Context, Logger } from 'koishi';
2
+ import { CaveObject, Config } from './index';
3
+ import { FileManager } from './FileManager';
4
+ /**
5
+ * @class ReviewManager
6
+ * @description 负责处理回声洞的审核流程,处理新洞的提交、审核通知和审核操作。
7
+ */
8
+ export declare class ReviewManager {
9
+ private ctx;
10
+ private config;
11
+ private fileManager;
12
+ private logger;
13
+ private reusableIds;
14
+ /**
15
+ * @constructor
16
+ * @param ctx Koishi 上下文。
17
+ * @param config 插件配置。
18
+ * @param fileManager 文件管理器实例。
19
+ * @param logger 日志记录器实例。
20
+ * @param reusableIds 可复用 ID 的内存缓存。
21
+ */
22
+ constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>);
23
+ /**
24
+ * @description 注册与审核相关的子命令。
25
+ * @param cave - 主 `cave` 命令实例。
26
+ */
27
+ registerCommands(cave: any): void;
28
+ /**
29
+ * @description 将新回声洞提交到管理群组以供审核。
30
+ * @param cave 新创建的、状态为 'pending' 的回声洞对象。
31
+ */
32
+ 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
+ }
package/lib/Utils.d.ts CHANGED
@@ -1,6 +1,7 @@
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';
4
5
  /**
5
6
  * @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
6
7
  * @param elements 从数据库读取的元素数组。
@@ -22,8 +23,9 @@ export declare function buildCaveMessage(cave: CaveObject, config: Config, fileM
22
23
  * @param ctx Koishi 上下文。
23
24
  * @param fileManager FileManager 实例。
24
25
  * @param logger Logger 实例。
26
+ * @param reusableIds 可复用 ID 的内存缓存。
25
27
  */
26
- export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger): Promise<void>;
28
+ export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
27
29
  /**
28
30
  * @description 根据配置(是否分群)和当前会话,生成数据库查询的范围条件。
29
31
  * @param session 当前会话对象。
@@ -32,13 +34,14 @@ export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileM
32
34
  */
33
35
  export declare function getScopeQuery(session: Session, config: Config): object;
34
36
  /**
35
- * @description 获取下一个可用的回声洞 ID(最小的未使用正整数)。
37
+ * @description 获取下一个可用的回声洞 ID
38
+ * 实现了三阶段逻辑:优先使用回收ID -> 扫描空闲ID -> 获取最大ID+1。
36
39
  * @param ctx Koishi 上下文。
37
40
  * @param query 查询范围条件,用于分群模式。
41
+ * @param reusableIds 可复用 ID 的内存缓存。
38
42
  * @returns 可用的新 ID。
39
- * @performance 在大数据集下,此函数可能存在性能瓶颈,因为它需要获取所有现有ID。
40
43
  */
41
- export declare function getNextCaveId(ctx: Context, query?: object): Promise<number>;
44
+ export declare function getNextCaveId(ctx: Context, query: object, reusableIds: Set<number>): Promise<number>;
42
45
  /**
43
46
  * @description 检查用户是否处于指令冷却中。
44
47
  * @returns 若在冷却中则返回提示字符串,否则返回 null。
@@ -64,16 +67,20 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
64
67
  }[];
65
68
  }>;
66
69
  /**
67
- * @description 异步处理文件上传和状态更新的后台任务。
70
+ * @description 异步处理文件上传、查重和状态更新的后台任务。
68
71
  * @param ctx - Koishi 上下文。
69
72
  * @param config - 插件配置。
70
- * @param fileManager - 文件管理器实例。
73
+ * @param fileManager - FileManager 实例,用于保存文件。
71
74
  * @param logger - 日志记录器实例。
72
- * @param reviewManager - 审核管理器实例 (可能为 null)。
73
- * @param cave - 已创建的、状态为 'preload' 的回声洞对象。
74
- * @param mediaToSave - 需要下载和保存的媒体文件列表。
75
+ * @param reviewManager - ReviewManager 实例,用于提交审核。
76
+ * @param cave - 刚刚在数据库中创建的 `preload` 状态的回声洞对象。
77
+ * @param mediaToSave - 需要下载和处理的媒体文件列表。
78
+ * @param reusableIds - 可复用 ID 的内存缓存。
79
+ * @param session - 触发此操作的用户会话,用于发送反馈。
80
+ * @param hashManager - HashManager 实例,如果启用则用于哈希计算和比较。
81
+ * @param textHashesToStore - 已预先计算好的、待存入数据库的文本哈希对象数组。
75
82
  */
76
83
  export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: any, cave: CaveObject, mediaToSave: {
77
84
  sourceUrl: string;
78
85
  fileName: string;
79
- }[]): Promise<void>;
86
+ }[], reusableIds: Set<number>, session: Session, hashManager: HashManager | null, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
package/lib/index.d.ts ADDED
@@ -0,0 +1,61 @@
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: 'text' | 'image';
32
+ subType: 'shingle' | 'pHash' | 'subImage';
33
+ }
34
+ declare module 'koishi' {
35
+ interface Tables {
36
+ cave: CaveObject;
37
+ cave_hash: CaveHashObject;
38
+ }
39
+ }
40
+ export interface Config {
41
+ coolDown: number;
42
+ perChannel: boolean;
43
+ adminChannel: string;
44
+ enableProfile: boolean;
45
+ enableIO: boolean;
46
+ enableReview: boolean;
47
+ caveFormat: string;
48
+ enableSimilarity: boolean;
49
+ textThreshold: number;
50
+ imageThreshold: number;
51
+ localPath?: string;
52
+ enableS3: boolean;
53
+ endpoint?: string;
54
+ region?: string;
55
+ accessKeyId?: string;
56
+ secretAccessKey?: string;
57
+ bucket?: string;
58
+ publicUrl?: string;
59
+ }
60
+ export declare const Config: Schema<Config>;
61
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -212,10 +212,99 @@ var ProfileManager = class {
212
212
  }
213
213
  };
214
214
 
215
+ // src/DataManager.ts
216
+ var DataManager = class {
217
+ /**
218
+ * @constructor
219
+ * @param ctx Koishi 上下文,用于数据库操作。
220
+ * @param config 插件配置。
221
+ * @param fileManager 文件管理器实例。
222
+ * @param logger 日志记录器实例。
223
+ * @param reusableIds 可复用 ID 的内存缓存。
224
+ */
225
+ constructor(ctx, config, fileManager, logger2, reusableIds) {
226
+ this.ctx = ctx;
227
+ this.config = config;
228
+ this.fileManager = fileManager;
229
+ this.logger = logger2;
230
+ this.reusableIds = reusableIds;
231
+ }
232
+ static {
233
+ __name(this, "DataManager");
234
+ }
235
+ /**
236
+ * @description 注册 `.export` 和 `.import` 子命令。
237
+ * @param cave - 主 `cave` 命令实例。
238
+ */
239
+ registerCommands(cave) {
240
+ const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
241
+ const adminChannelId = this.config.adminChannel?.split(":")[1];
242
+ if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
243
+ try {
244
+ await session.send("正在处理,请稍候...");
245
+ return await action();
246
+ } catch (error) {
247
+ this.logger.error("数据操作时发生错误:", error);
248
+ return `操作失败: ${error.message}`;
249
+ }
250
+ }, "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()));
253
+ }
254
+ /**
255
+ * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
256
+ * @returns 描述导出结果的消息字符串。
257
+ */
258
+ async exportData() {
259
+ const fileName = "cave_export.json";
260
+ const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
261
+ const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
262
+ const data = JSON.stringify(portableCaves, null, 2);
263
+ await this.fileManager.saveFile(fileName, Buffer.from(data));
264
+ return `成功导出 ${portableCaves.length} 条数据`;
265
+ }
266
+ /**
267
+ * @description 从 `cave_import.json` 文件导入回声洞数据。
268
+ * @returns 描述导入结果的消息字符串。
269
+ */
270
+ async importData() {
271
+ const fileName = "cave_import.json";
272
+ let importedCaves;
273
+ try {
274
+ const fileContent = await this.fileManager.readFile(fileName);
275
+ importedCaves = JSON.parse(fileContent.toString("utf-8"));
276
+ if (!Array.isArray(importedCaves)) throw new Error("文件格式无效");
277
+ if (!importedCaves.length) throw new Error("导入文件为空");
278
+ } catch (error) {
279
+ this.logger.error(`读取导入文件失败:`, error);
280
+ throw new Error(`读取导入文件失败: ${error.message}`);
281
+ }
282
+ const [lastCave] = await this.ctx.database.get("cave", {}, {
283
+ sort: { id: "desc" },
284
+ limit: 1,
285
+ fields: ["id"]
286
+ });
287
+ let startId = (lastCave?.id || 0) + 1;
288
+ const newCavesToInsert = importedCaves.map((cave, index) => ({
289
+ ...cave,
290
+ id: startId + index,
291
+ status: "active"
292
+ }));
293
+ await this.ctx.database.upsert("cave", newCavesToInsert);
294
+ this.reusableIds.clear();
295
+ await this.ctx.database.remove("cave_hash", {});
296
+ return `成功导入 ${newCavesToInsert.length} 条数据`;
297
+ }
298
+ };
299
+
300
+ // src/ReviewManager.ts
301
+ var import_koishi2 = require("koishi");
302
+
215
303
  // src/Utils.ts
216
304
  var import_koishi = require("koishi");
217
305
  var path2 = __toESM(require("path"));
218
306
  var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
307
+ var MAX_ID_FLAG = 0;
219
308
  function storedFormatToHElements(elements) {
220
309
  return elements.map((el) => {
221
310
  if (el.type === "text") return import_koishi.h.text(el.content);
@@ -256,14 +345,17 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
256
345
  return finalMessage;
257
346
  }
258
347
  __name(buildCaveMessage, "buildCaveMessage");
259
- async function cleanupPendingDeletions(ctx, fileManager, logger2) {
348
+ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
260
349
  try {
261
350
  const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
262
351
  if (!cavesToDelete.length) return;
263
352
  for (const cave of cavesToDelete) {
264
353
  const deletePromises = cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file));
265
354
  await Promise.all(deletePromises);
355
+ reusableIds.add(cave.id);
356
+ reusableIds.delete(MAX_ID_FLAG);
266
357
  await ctx.database.remove("cave", { id: cave.id });
358
+ await ctx.database.remove("cave_hash", { cave: cave.id });
267
359
  }
268
360
  } catch (error) {
269
361
  logger2.error("清理回声洞时发生错误:", error);
@@ -275,13 +367,34 @@ function getScopeQuery(session, config) {
275
367
  return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
276
368
  }
277
369
  __name(getScopeQuery, "getScopeQuery");
278
- async function getNextCaveId(ctx, query = {}) {
370
+ async function getNextCaveId(ctx, query = {}, reusableIds) {
371
+ for (const id of reusableIds) {
372
+ if (id > MAX_ID_FLAG) {
373
+ reusableIds.delete(id);
374
+ return id;
375
+ }
376
+ }
377
+ if (reusableIds.has(MAX_ID_FLAG)) {
378
+ reusableIds.delete(MAX_ID_FLAG);
379
+ const [lastCave] = await ctx.database.get("cave", query, {
380
+ fields: ["id"],
381
+ sort: { id: "desc" },
382
+ limit: 1
383
+ });
384
+ const newId2 = (lastCave?.id || 0) + 1;
385
+ reusableIds.add(MAX_ID_FLAG);
386
+ return newId2;
387
+ }
279
388
  const allCaveIds = (await ctx.database.get("cave", query, { fields: ["id"] })).map((c) => c.id);
280
389
  const existingIds = new Set(allCaveIds);
281
390
  let newId = 1;
282
391
  while (existingIds.has(newId)) {
283
392
  newId++;
284
393
  }
394
+ const maxIdInDb = allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0;
395
+ if (existingIds.size === maxIdInDb) {
396
+ reusableIds.add(MAX_ID_FLAG);
397
+ }
285
398
  return newId;
286
399
  }
287
400
  __name(getNextCaveId, "getNextCaveId");
@@ -339,104 +452,70 @@ async function processMessageElements(sourceElements, newId, channelId, userId)
339
452
  return { finalElementsForDb, mediaToSave };
340
453
  }
341
454
  __name(processMessageElements, "processMessageElements");
342
- async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave) {
455
+ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave, reusableIds, session, hashManager, textHashesToStore) {
343
456
  try {
344
- const uploadPromises = mediaToSave.map(async (media) => {
345
- const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
346
- await fileManager.saveFile(media.fileName, Buffer.from(response));
347
- });
348
- await Promise.all(uploadPromises);
457
+ const downloadedMedia = [];
458
+ const imageHashesToStore = [];
459
+ let allNewImageHashes = [];
460
+ if (hashManager) {
461
+ for (const media of mediaToSave) {
462
+ const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
463
+ const buffer = Buffer.from(response);
464
+ downloadedMedia.push({ fileName: media.fileName, buffer });
465
+ const isImage = [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase());
466
+ if (isImage) {
467
+ const pHash = await hashManager.generateImagePHash(buffer);
468
+ const subHashes = [...await hashManager.generateImageSubHashes(buffer)];
469
+ allNewImageHashes.push(pHash, ...subHashes);
470
+ imageHashesToStore.push({ hash: pHash, type: "image", subType: "pHash" });
471
+ subHashes.forEach((sh) => imageHashesToStore.push({ hash: sh, type: "image", subType: "subImage" }));
472
+ }
473
+ }
474
+ if (allNewImageHashes.length > 0) {
475
+ const existingImageHashes = await ctx.database.get("cave_hash", { type: "image" });
476
+ for (const newHash of allNewImageHashes) {
477
+ for (const existing of existingImageHashes) {
478
+ const similarity = hashManager.calculateImageSimilarity(newHash, existing.hash);
479
+ if (similarity >= config.imageThreshold) {
480
+ await session.send(`图片与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`);
481
+ await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
482
+ cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
483
+ return;
484
+ }
485
+ }
486
+ }
487
+ }
488
+ } else {
489
+ for (const media of mediaToSave) {
490
+ const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
491
+ downloadedMedia.push({ fileName: media.fileName, buffer: Buffer.from(response) });
492
+ }
493
+ }
494
+ await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
349
495
  const finalStatus = config.enableReview ? "pending" : "active";
350
496
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
497
+ if (hashManager) {
498
+ const allHashesToInsert = [
499
+ ...textHashesToStore.map((h4) => ({ ...h4, cave: cave.id })),
500
+ ...imageHashesToStore.map((h4) => ({ ...h4, cave: cave.id }))
501
+ ];
502
+ if (allHashesToInsert.length > 0) {
503
+ await ctx.database.upsert("cave_hash", allHashesToInsert);
504
+ }
505
+ }
351
506
  if (finalStatus === "pending" && reviewManager) {
352
507
  const [finalCave] = await ctx.database.get("cave", { id: cave.id });
353
508
  if (finalCave) reviewManager.sendForReview(finalCave);
354
509
  }
355
- } catch (fileSaveError) {
356
- logger2.error(`回声洞(${cave.id})文件保存失败:`, fileSaveError);
510
+ } catch (fileProcessingError) {
511
+ logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
357
512
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
358
- cleanupPendingDeletions(ctx, fileManager, logger2);
513
+ cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
359
514
  }
360
515
  }
361
516
  __name(handleFileUploads, "handleFileUploads");
362
517
 
363
- // src/DataManager.ts
364
- var DataManager = class {
365
- /**
366
- * @constructor
367
- * @param ctx Koishi 上下文,用于数据库操作。
368
- * @param config 插件配置。
369
- * @param fileManager 文件管理器实例。
370
- * @param logger 日志记录器实例。
371
- */
372
- constructor(ctx, config, fileManager, logger2) {
373
- this.ctx = ctx;
374
- this.config = config;
375
- this.fileManager = fileManager;
376
- this.logger = logger2;
377
- }
378
- static {
379
- __name(this, "DataManager");
380
- }
381
- /**
382
- * @description 注册 `.export` 和 `.import` 子命令。
383
- * @param cave - 主 `cave` 命令实例。
384
- */
385
- registerCommands(cave) {
386
- const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
387
- const adminChannelId = this.config.adminChannel?.split(":")[1];
388
- if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
389
- try {
390
- await session.send("正在处理,请稍候...");
391
- return await action();
392
- } catch (error) {
393
- this.logger.error("数据操作时发生错误:", error);
394
- return `操作失败: ${error.message || "未知错误"}`;
395
- }
396
- }, "requireAdmin");
397
- cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(requireAdmin(() => this.exportData()));
398
- cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
399
- }
400
- /**
401
- * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
402
- * @returns 描述导出结果的消息字符串。
403
- */
404
- async exportData() {
405
- const fileName = "cave_export.json";
406
- const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
407
- const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
408
- const data = JSON.stringify(portableCaves, null, 2);
409
- await this.fileManager.saveFile(fileName, Buffer.from(data));
410
- return `成功导出 ${portableCaves.length} 条数据`;
411
- }
412
- /**
413
- * @description 从 `cave_import.json` 文件导入回声洞数据。
414
- * @returns 描述导入结果的消息字符串。
415
- */
416
- async importData() {
417
- const fileName = "cave_import.json";
418
- let importedCaves;
419
- try {
420
- const fileContent = await this.fileManager.readFile(fileName);
421
- importedCaves = JSON.parse(fileContent.toString("utf-8"));
422
- if (!Array.isArray(importedCaves)) throw new Error("导入文件格式无效");
423
- } catch (error) {
424
- this.logger.error(`读取导入文件失败:`, error);
425
- throw new Error(`读取导入文件失败: ${error.message || "未知错误"}`);
426
- }
427
- let successCount = 0;
428
- for (const cave of importedCaves) {
429
- const newId = await getNextCaveId(this.ctx, {});
430
- const newCave = { ...cave, id: newId, status: "active" };
431
- await this.ctx.database.create("cave", newCave);
432
- successCount++;
433
- }
434
- return `成功导入 ${successCount} 条回声洞数据`;
435
- }
436
- };
437
-
438
518
  // src/ReviewManager.ts
439
- var import_koishi2 = require("koishi");
440
519
  var ReviewManager = class {
441
520
  /**
442
521
  * @constructor
@@ -444,46 +523,68 @@ var ReviewManager = class {
444
523
  * @param config 插件配置。
445
524
  * @param fileManager 文件管理器实例。
446
525
  * @param logger 日志记录器实例。
526
+ * @param reusableIds 可复用 ID 的内存缓存。
447
527
  */
448
- constructor(ctx, config, fileManager, logger2) {
528
+ constructor(ctx, config, fileManager, logger2, reusableIds) {
449
529
  this.ctx = ctx;
450
530
  this.config = config;
451
531
  this.fileManager = fileManager;
452
532
  this.logger = logger2;
533
+ this.reusableIds = reusableIds;
453
534
  }
454
535
  static {
455
536
  __name(this, "ReviewManager");
456
537
  }
457
538
  /**
458
- * @description 注册与审核相关的 `.review` 子命令。
539
+ * @description 注册与审核相关的子命令。
459
540
  * @param cave - 主 `cave` 命令实例。
460
541
  */
461
542
  registerCommands(cave) {
462
- cave.subcommand(".review [id:posint] [action:string]", "审核回声洞").usage("查看或审核回声洞,使用 <Y/N> 进行审核。").action(async ({ session }, id, action) => {
543
+ const requireAdmin = /* @__PURE__ */ __name((session) => {
463
544
  const adminChannelId = this.config.adminChannel?.split(":")[1];
464
- if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
545
+ if (session.channelId !== adminChannelId) {
546
+ return "此指令仅限在管理群组中使用";
547
+ }
548
+ return null;
549
+ }, "requireAdmin");
550
+ const review = cave.subcommand(".review [id:posint]", "审核回声洞").usage("查看所有待审核回声洞,或查看指定待审核回声洞。").action(async ({ session }, id) => {
551
+ const adminError = requireAdmin(session);
552
+ if (adminError) return adminError;
465
553
  if (!id) {
466
554
  const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
467
555
  if (!pendingCaves.length) return "当前没有需要审核的回声洞";
468
556
  return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
469
- ${pendingCaves.map((c) => c.id).join(", ")}`;
557
+ ${pendingCaves.map((c) => c.id).join("|")}`;
470
558
  }
471
559
  const [targetCave] = await this.ctx.database.get("cave", { id });
472
560
  if (!targetCave) return `回声洞(${id})不存在`;
473
561
  if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
474
- if (!action) {
475
- return [`待审核:`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
476
- }
477
- const normalizedAction = action.toLowerCase();
478
- if (["y", "yes", "pass", "approve"].includes(normalizedAction)) {
479
- return this.processReview("approve", id);
480
- }
481
- if (["n", "no", "deny", "reject"].includes(normalizedAction)) {
482
- return this.processReview("reject", id);
483
- }
484
- return `无效操作: "${action}"
485
- 请使用 "Y" (通过) 或 "N" (拒绝)`;
562
+ return [`待审核`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
486
563
  });
564
+ const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
565
+ const adminError = requireAdmin(session);
566
+ if (adminError) return adminError;
567
+ try {
568
+ if (!id) {
569
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
570
+ if (!pendingCaves.length) return `当前没有需要${actionType === "approve" ? "通过" : "拒绝"}的回声洞`;
571
+ if (actionType === "approve") {
572
+ await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "active" })));
573
+ return `已通过 ${pendingCaves.length} 条回声洞`;
574
+ } else {
575
+ await this.ctx.database.upsert("cave", pendingCaves.map((c) => ({ id: c.id, status: "delete" })));
576
+ cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
577
+ return `已拒绝 ${pendingCaves.length} 条回声洞`;
578
+ }
579
+ }
580
+ return this.processReview(actionType, id);
581
+ } catch (error) {
582
+ this.logger.error(`审核操作失败:`, error);
583
+ return `操作失败: ${error.message}`;
584
+ }
585
+ }, "createReviewAction");
586
+ review.subcommand(".Y [id:posint]", "通过审核").usage("通过回声洞审核,可批量操作。").action(createReviewAction("approve"));
587
+ review.subcommand(".N [id:posint]", "拒绝审核").usage("拒绝回声洞审核,可批量操作。").action(createReviewAction("reject"));
487
588
  }
488
589
  /**
489
590
  * @description 将新回声洞提交到管理群组以供审核。
@@ -496,7 +597,7 @@ ${pendingCaves.map((c) => c.id).join(", ")}`;
496
597
  return;
497
598
  }
498
599
  try {
499
- const reviewMessage = [`待审核:`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
600
+ const reviewMessage = [`待审核`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
500
601
  await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(reviewMessage));
501
602
  } catch (error) {
502
603
  this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
@@ -516,12 +617,210 @@ ${pendingCaves.map((c) => c.id).join(", ")}`;
516
617
  return `回声洞(${caveId})已通过`;
517
618
  } else {
518
619
  await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
519
- cleanupPendingDeletions(this.ctx, this.fileManager, this.logger);
620
+ cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
520
621
  return `回声洞(${caveId})已拒绝`;
521
622
  }
522
623
  }
523
624
  };
524
625
 
626
+ // src/HashManager.ts
627
+ var import_sharp = __toESM(require("sharp"));
628
+ var HashManager = class {
629
+ /**
630
+ * @constructor
631
+ * @param ctx - Koishi 上下文,用于数据库操作。
632
+ * @param config - 插件配置,用于获取相似度阈值。
633
+ * @param logger - 日志记录器实例。
634
+ * @param fileManager - 文件管理器实例,用于处理历史数据。
635
+ */
636
+ constructor(ctx, config, logger2, fileManager) {
637
+ this.ctx = ctx;
638
+ this.config = config;
639
+ this.logger = logger2;
640
+ this.fileManager = fileManager;
641
+ }
642
+ static {
643
+ __name(this, "HashManager");
644
+ }
645
+ /**
646
+ * @description 注册与哈希校验相关的子命令。
647
+ * @param cave - 主 `cave` 命令实例。
648
+ */
649
+ registerCommands(cave) {
650
+ cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希。").action(async ({ session }) => {
651
+ const adminChannelId = this.config.adminChannel?.split(":")[1];
652
+ if (session.channelId !== adminChannelId) {
653
+ return "此指令仅限在管理群组中使用";
654
+ }
655
+ await session.send("正在处理,请稍候...");
656
+ try {
657
+ const report = await this.validateAllCaves();
658
+ return report;
659
+ } catch (error) {
660
+ this.logger.error("校验哈希失败:", error);
661
+ return `校验失败: ${error.message}`;
662
+ }
663
+ });
664
+ }
665
+ /**
666
+ * @description 检查数据库中所有回声洞,并为没有哈希记录的历史数据生成哈希。
667
+ * @returns {Promise<string>} 返回一个包含操作结果的报告字符串。
668
+ */
669
+ async validateAllCaves() {
670
+ const allCaves = await this.ctx.database.get("cave", { status: "active" });
671
+ const existingHashes = await this.ctx.database.get("cave_hash", {});
672
+ const existingHashedCaveIds = new Set(existingHashes.map((h4) => h4.cave));
673
+ const hashesToInsert = [];
674
+ let historicalCount = 0;
675
+ for (const cave of allCaves) {
676
+ if (existingHashedCaveIds.has(cave.id)) continue;
677
+ this.logger.info(`正在为回声洞(${cave.id})生成哈希...`);
678
+ historicalCount++;
679
+ const textElements = cave.elements.filter((el) => el.type === "text" && el.content);
680
+ for (const el of textElements) {
681
+ const textHash = this.generateTextHash(el.content);
682
+ hashesToInsert.push({ cave: cave.id, hash: textHash, type: "text", subType: "shingle" });
683
+ }
684
+ const imageElements = cave.elements.filter((el) => el.type === "image" && el.file);
685
+ for (const el of imageElements) {
686
+ try {
687
+ const imageBuffer = await this.fileManager.readFile(el.file);
688
+ const pHash = await this.generateImagePHash(imageBuffer);
689
+ hashesToInsert.push({ cave: cave.id, hash: pHash, type: "image", subType: "pHash" });
690
+ const subHashes = await this.generateImageSubHashes(imageBuffer);
691
+ subHashes.forEach((subHash) => {
692
+ hashesToInsert.push({ cave: cave.id, hash: subHash, type: "image", subType: "subImage" });
693
+ });
694
+ } catch (e) {
695
+ this.logger.warn(`无法为回声洞(${cave.id})的内容(${el.file})生成哈希:`, e);
696
+ }
697
+ }
698
+ }
699
+ if (hashesToInsert.length > 0) {
700
+ await this.ctx.database.upsert("cave_hash", hashesToInsert);
701
+ } else {
702
+ this.logger.info("无需补全哈希");
703
+ }
704
+ return `校验完成,共补全 ${historicalCount} 个回声洞的 ${hashesToInsert.length} 条哈希`;
705
+ }
706
+ /**
707
+ * @description 将图片切割为4个象限并为每个象限生成pHash。
708
+ * @param imageBuffer - 图片的 Buffer 数据。
709
+ * @returns {Promise<Set<string>>} 返回一个包含最多4个唯一哈希值的集合。
710
+ */
711
+ async generateImageSubHashes(imageBuffer) {
712
+ const hashes = /* @__PURE__ */ new Set();
713
+ try {
714
+ const metadata = await (0, import_sharp.default)(imageBuffer).metadata();
715
+ const { width, height } = metadata;
716
+ if (!width || !height || width < 16 || height < 16) {
717
+ return hashes;
718
+ }
719
+ const regions = [
720
+ { left: 0, top: 0, width: Math.floor(width / 2), height: Math.floor(height / 2) },
721
+ // Top-left
722
+ { left: Math.floor(width / 2), top: 0, width: Math.ceil(width / 2), height: Math.floor(height / 2) },
723
+ // Top-right
724
+ { left: 0, top: Math.floor(height / 2), width: Math.floor(width / 2), height: Math.ceil(height / 2) },
725
+ // Bottom-left
726
+ { left: Math.floor(width / 2), top: Math.floor(height / 2), width: Math.ceil(width / 2), height: Math.ceil(height / 2) }
727
+ // Bottom-right
728
+ ];
729
+ for (const region of regions) {
730
+ if (region.width < 8 || region.height < 8) continue;
731
+ const quadrantBuffer = await (0, import_sharp.default)(imageBuffer).extract(region).toBuffer();
732
+ const subHash = await this.generateImagePHash(quadrantBuffer);
733
+ hashes.add(subHash);
734
+ }
735
+ } catch (e) {
736
+ this.logger.warn(`生成子哈希失败:`, e);
737
+ }
738
+ return hashes;
739
+ }
740
+ /**
741
+ * @description 根据pHash(感知哈希)算法为图片生成哈希值。
742
+ * @param imageBuffer - 图片的 Buffer 数据。
743
+ * @returns {Promise<string>} 返回一个64位的二进制哈希字符串。
744
+ */
745
+ async generateImagePHash(imageBuffer) {
746
+ const smallImage = await (0, import_sharp.default)(imageBuffer).grayscale().resize(8, 8, { fit: "fill" }).raw().toBuffer();
747
+ let totalLuminance = 0;
748
+ for (let i = 0; i < 64; i++) {
749
+ totalLuminance += smallImage[i];
750
+ }
751
+ const avgLuminance = totalLuminance / 64;
752
+ let hash = "";
753
+ for (let i = 0; i < 64; i++) {
754
+ hash += smallImage[i] > avgLuminance ? "1" : "0";
755
+ }
756
+ return hash;
757
+ }
758
+ /**
759
+ * @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
760
+ * @param hash1 - 第一个哈希字符串。
761
+ * @param hash2 - 第二个哈希字符串。
762
+ * @returns {number} 两个哈希之间的距离。
763
+ */
764
+ calculateHammingDistance(hash1, hash2) {
765
+ let distance = 0;
766
+ for (let i = 0; i < Math.min(hash1.length, hash2.length); i++) {
767
+ if (hash1[i] !== hash2[i]) {
768
+ distance++;
769
+ }
770
+ }
771
+ return distance;
772
+ }
773
+ /**
774
+ * @description 根据汉明距离计算图片pHash的相似度。
775
+ * @param hash1 - 第一个哈希字符串。
776
+ * @param hash2 - 第二个哈希字符串。
777
+ * @returns {number} 范围在0到1之间的相似度得分。
778
+ */
779
+ calculateImageSimilarity(hash1, hash2) {
780
+ const distance = this.calculateHammingDistance(hash1, hash2);
781
+ const hashLength = 64;
782
+ return 1 - distance / hashLength;
783
+ }
784
+ /**
785
+ * @description 将文本分割成指定大小的“瓦片”(shingles),用于Jaccard相似度计算。
786
+ * @param text - 输入的文本。
787
+ * @param size - 每个瓦片的大小,默认为2。
788
+ * @returns {Set<string>} 包含所有唯一瓦片的集合。
789
+ */
790
+ getShingles(text, size = 2) {
791
+ const shingles = /* @__PURE__ */ new Set();
792
+ const cleanedText = text.replace(/\s+/g, "");
793
+ for (let i = 0; i <= cleanedText.length - size; i++) {
794
+ shingles.add(cleanedText.substring(i, i + size));
795
+ }
796
+ return shingles;
797
+ }
798
+ /**
799
+ * @description 为文本生成基于Shingling的哈希字符串。
800
+ * @param text - 需要处理的文本。
801
+ * @returns {string} 由排序后的shingles组成的、用'|'分隔的哈希字符串。
802
+ */
803
+ generateTextHash(text) {
804
+ if (!text) return "";
805
+ const shingles = Array.from(this.getShingles(text));
806
+ return shingles.sort().join("|");
807
+ }
808
+ /**
809
+ * @description 使用Jaccard相似度系数计算两个文本哈希的相似度。
810
+ * @param hash1 - 第一个文本哈希。
811
+ * @param hash2 - 第二个文本哈希。
812
+ * @returns {number} 范围在0到1之间的相似度得分。
813
+ */
814
+ calculateTextSimilarity(hash1, hash2) {
815
+ if (!hash1 || !hash2) return 0;
816
+ const set1 = new Set(hash1.split("|"));
817
+ const set2 = new Set(hash2.split("|"));
818
+ const intersection = new Set([...set1].filter((x) => set2.has(x)));
819
+ const union = /* @__PURE__ */ new Set([...set1, ...set2]);
820
+ return union.size === 0 ? 1 : intersection.size / union.size;
821
+ }
822
+ };
823
+
525
824
  // src/index.ts
526
825
  var name = "best-cave";
527
826
  var inject = ["database"];
@@ -548,8 +847,11 @@ var Config = import_koishi3.Schema.intersect([
548
847
  caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本")
549
848
  }).description("基础配置"),
550
849
  import_koishi3.Schema.object({
551
- enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核")
552
- }).description("审核配置"),
850
+ enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核"),
851
+ enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
852
+ textThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("文本相似度阈值"),
853
+ imageThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("图片相似度阈值")
854
+ }).description("审核与查重配置"),
553
855
  import_koishi3.Schema.object({
554
856
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
555
857
  enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
@@ -571,11 +873,21 @@ function apply(ctx, config) {
571
873
  status: "string",
572
874
  time: "timestamp"
573
875
  }, { primary: "id" });
876
+ ctx.model.extend("cave_hash", {
877
+ cave: "unsigned",
878
+ hash: "string",
879
+ type: "string",
880
+ subType: "string"
881
+ }, {
882
+ primary: ["cave", "hash", "subType"]
883
+ });
574
884
  const fileManager = new FileManager(ctx.baseDir, config, logger);
575
885
  const lastUsed = /* @__PURE__ */ new Map();
886
+ const reusableIds = /* @__PURE__ */ new Set();
576
887
  const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
577
- const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger) : null;
578
- const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger) : null;
888
+ const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, reusableIds) : null;
889
+ const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger, reusableIds) : null;
890
+ const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
579
891
  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 }) => {
580
892
  if (options.add) return session.execute(`cave.add ${options.add}`);
581
893
  if (options.view) return session.execute(`cave.view ${options.view}`);
@@ -607,18 +919,38 @@ function apply(ctx, config) {
607
919
  if (!sourceElements) {
608
920
  await session.send("请在一分钟内发送你要添加的内容");
609
921
  const reply = await session.prompt(6e4);
610
- if (!reply) return "操作超时,已取消添加";
922
+ if (!reply) return "等待操作超时";
611
923
  sourceElements = import_koishi3.h.parse(reply);
612
924
  }
613
925
  const idScopeQuery = config.perChannel && session.channelId ? { channelId: session.channelId } : {};
614
- const newId = await getNextCaveId(ctx, idScopeQuery);
926
+ const newId = await getNextCaveId(ctx, idScopeQuery, reusableIds);
615
927
  const { finalElementsForDb, mediaToSave } = await processMessageElements(
616
928
  sourceElements,
617
929
  newId,
618
930
  session.channelId,
619
931
  session.userId
620
932
  );
621
- if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
933
+ if (finalElementsForDb.length === 0) {
934
+ return "无可添加内容";
935
+ }
936
+ let textHashesToStore = [];
937
+ if (hashManager) {
938
+ const textContents = finalElementsForDb.filter((el) => el.type === "text" && el.content).map((el) => el.content);
939
+ if (textContents.length > 0) {
940
+ const newTextHashes = textContents.map((text) => hashManager.generateTextHash(text));
941
+ textHashesToStore = newTextHashes.map((hash) => ({ hash, type: "text", subType: "shingle" }));
942
+ const existingTextHashes = await ctx.database.get("cave_hash", { type: "text", hash: { $in: newTextHashes } });
943
+ for (const existing of existingTextHashes) {
944
+ const matchedNewHash = textHashesToStore.find((h4) => h4.hash === existing.hash);
945
+ if (matchedNewHash) {
946
+ const similarity = hashManager.calculateTextSimilarity(matchedNewHash.hash, existing.hash);
947
+ if (similarity >= config.textThreshold) {
948
+ return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
949
+ }
950
+ }
951
+ }
952
+ }
953
+ }
622
954
  const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
623
955
  const hasMedia = mediaToSave.length > 0;
624
956
  const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
@@ -633,11 +965,18 @@ function apply(ctx, config) {
633
965
  };
634
966
  await ctx.database.create("cave", newCave);
635
967
  if (hasMedia) {
636
- handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave);
637
- } else if (initialStatus === "pending") {
638
- reviewManager.sendForReview(newCave);
968
+ handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
969
+ } else {
970
+ if (hashManager && textHashesToStore.length > 0) {
971
+ const hashObjectsToInsert = textHashesToStore.map((h4) => ({ ...h4, cave: newId }));
972
+ await ctx.database.upsert("cave_hash", hashObjectsToInsert);
973
+ }
974
+ if (initialStatus === "pending") {
975
+ reviewManager.sendForReview(newCave);
976
+ }
639
977
  }
640
- return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
978
+ const responseMessage = initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
979
+ return responseMessage;
641
980
  } catch (error) {
642
981
  logger.error("添加回声洞失败:", error);
643
982
  return "添加失败,请稍后再试";
@@ -671,7 +1010,7 @@ function apply(ctx, config) {
671
1010
  }
672
1011
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
673
1012
  const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
674
- cleanupPendingDeletions(ctx, fileManager, logger);
1013
+ cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
675
1014
  return [`已删除`, ...caveMessage];
676
1015
  } catch (error) {
677
1016
  logger.error(`标记回声洞(${id})失败:`, error);
@@ -694,6 +1033,7 @@ ${caveIds}`;
694
1033
  if (profileManager) profileManager.registerCommands(cave);
695
1034
  if (dataManager) dataManager.registerCommands(cave);
696
1035
  if (reviewManager) reviewManager.registerCommands(cave);
1036
+ if (hashManager) hashManager.registerCommands(cave);
697
1037
  }
698
1038
  __name(apply, "apply");
699
1039
  // 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.1",
4
+ "version": "2.1.3",
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
  }