koishi-plugin-best-cave 2.1.3 → 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` 命令实例。
@@ -24,14 +24,14 @@ export declare class FileManager {
24
24
  * @template T 异步操作的返回类型。
25
25
  * @param fullPath 需要加锁的文件的完整路径。
26
26
  * @param operation 要执行的异步函数。
27
- * @returns 返回异步操作的结果。
27
+ * @returns 异步操作的结果。
28
28
  */
29
29
  private withLock;
30
30
  /**
31
31
  * @description 保存文件,自动选择 S3 或本地存储。
32
32
  * @param fileName 用作 S3 Key 或本地文件名。
33
33
  * @param data 要写入的 Buffer 数据。
34
- * @returns 返回保存时使用的文件名/标识符。
34
+ * @returns 保存时使用的文件名。
35
35
  */
36
36
  saveFile(fileName: string, data: Buffer): Promise<string>;
37
37
  /**
@@ -10,6 +10,8 @@ export declare class HashManager {
10
10
  private config;
11
11
  private logger;
12
12
  private fileManager;
13
+ private static readonly HASH_BATCH_SIZE;
14
+ private static readonly SIMHASH_BITS;
13
15
  /**
14
16
  * @constructor
15
17
  * @param ctx - Koishi 上下文,用于数据库操作。
@@ -24,20 +26,20 @@ export declare class HashManager {
24
26
  */
25
27
  registerCommands(cave: any): void;
26
28
  /**
27
- * @description 检查数据库中所有回声洞,并为没有哈希记录的历史数据生成哈希。
28
- * @returns {Promise<string>} 返回一个包含操作结果的报告字符串。
29
+ * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希,并在此之后对所有内容进行相似度检查。
30
+ * @returns {Promise<string>} 一个包含操作结果的报告字符串。
29
31
  */
30
- private validateAllCaves;
32
+ validateAllCaves(): Promise<string>;
31
33
  /**
32
34
  * @description 将图片切割为4个象限并为每个象限生成pHash。
33
35
  * @param imageBuffer - 图片的 Buffer 数据。
34
- * @returns {Promise<Set<string>>} 返回一个包含最多4个唯一哈希值的集合。
36
+ * @returns {Promise<Set<string>>} 一个包含最多4个唯一哈希值的集合。
35
37
  */
36
38
  generateImageSubHashes(imageBuffer: Buffer): Promise<Set<string>>;
37
39
  /**
38
- * @description 根据pHash(感知哈希)算法为图片生成哈希值。
39
- * @param imageBuffer - 图片的 Buffer 数据。
40
- * @returns {Promise<string>} 返回一个64位的二进制哈希字符串。
40
+ * @description 根据感知哈希(pHash)算法为图片生成哈希。
41
+ * @param imageBuffer 图片的 Buffer 数据。
42
+ * @returns 64位二进制哈希字符串。
41
43
  */
42
44
  generateImagePHash(imageBuffer: Buffer): Promise<string>;
43
45
  /**
@@ -48,30 +50,16 @@ export declare class HashManager {
48
50
  */
49
51
  calculateHammingDistance(hash1: string, hash2: string): number;
50
52
  /**
51
- * @description 根据汉明距离计算图片pHash的相似度。
53
+ * @description 根据汉明距离计算图片或文本哈希的相似度。
52
54
  * @param hash1 - 第一个哈希字符串。
53
55
  * @param hash2 - 第二个哈希字符串。
54
56
  * @returns {number} 范围在0到1之间的相似度得分。
55
57
  */
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;
58
+ calculateSimilarity(hash1: string, hash2: string): number;
64
59
  /**
65
- * @description 为文本生成基于Shingling的哈希字符串。
60
+ * @description 为文本生成基于 Simhash 算法的哈希字符串。
66
61
  * @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之间的相似度得分。
62
+ * @returns {string} 64位二进制 Simhash 字符串。
75
63
  */
76
- calculateTextSimilarity(hash1: string, hash2: string): number;
64
+ generateTextSimhash(text: string): string;
77
65
  }
@@ -1,9 +1,5 @@
1
1
  import { Context } from 'koishi';
2
- /**
3
- * @description 数据库 `cave_user` 表的结构定义。
4
- * @property userId 用户唯一ID,作为主键。
5
- * @property nickname 用户自定义的昵称。
6
- */
2
+ /** 数据库 `cave_user` 表的结构。 */
7
3
  export interface UserProfile {
8
4
  userId: string;
9
5
  nickname: string;
@@ -16,7 +12,6 @@ declare module 'koishi' {
16
12
  /**
17
13
  * @class ProfileManager
18
14
  * @description 负责管理用户在回声洞中的自定义昵称。
19
- * 当插件配置 `enableProfile` 为 true 时实例化。
20
15
  */
21
16
  export declare class ProfileManager {
22
17
  private ctx;
@@ -39,7 +34,7 @@ export declare class ProfileManager {
39
34
  /**
40
35
  * @description 获取指定用户的昵称。
41
36
  * @param userId - 目标用户的 ID。
42
- * @returns 返回用户的昵称字符串,如果未设置则返回 null。
37
+ * @returns 用户的昵称字符串或 null。
43
38
  */
44
39
  getNickname(userId: string): Promise<string | null>;
45
40
  /**
@@ -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
@@ -2,6 +2,7 @@ import { Context, h, Logger, Session } from 'koishi';
2
2
  import { CaveObject, Config, StoredElement, CaveHashObject } from './index';
3
3
  import { FileManager } from './FileManager';
4
4
  import { HashManager } from './HashManager';
5
+ import { ReviewManager } from './ReviewManager';
5
6
  /**
6
7
  * @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
7
8
  * @param elements 从数据库读取的元素数组。
@@ -9,42 +10,41 @@ import { HashManager } from './HashManager';
9
10
  */
10
11
  export declare function storedFormatToHElements(elements: StoredElement[]): h[];
11
12
  /**
12
- * @description 构建一条用于发送的完整回声洞消息。
13
- * 此函数会处理 S3 URL、文件映射路径或本地文件到 Base64 的转换。
14
- * @param cave 要展示的回声洞对象。
13
+ * @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
14
+ * @param cave 回声洞对象。
15
15
  * @param config 插件配置。
16
- * @param fileManager FileManager 实例。
17
- * @param logger Logger 实例。
16
+ * @param fileManager 文件管理器实例。
17
+ * @param logger 日志记录器实例。
18
18
  * @returns 包含 h() 元素和字符串的消息数组。
19
19
  */
20
20
  export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
21
21
  /**
22
- * @description 清理数据库中所有被标记为 'delete' 状态的回声洞及其关联文件。
22
+ * @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
23
23
  * @param ctx Koishi 上下文。
24
- * @param fileManager FileManager 实例。
25
- * @param logger Logger 实例。
24
+ * @param fileManager 文件管理器实例。
25
+ * @param logger 日志记录器实例。
26
26
  * @param reusableIds 可复用 ID 的内存缓存。
27
27
  */
28
28
  export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
29
29
  /**
30
- * @description 根据配置(是否分群)和当前会话,生成数据库查询的范围条件。
31
- * @param session 当前会话对象。
30
+ * @description 根据配置和会话,生成数据库查询的范围条件。
31
+ * @param session 当前会话。
32
32
  * @param config 插件配置。
33
- * @returns 用于数据库查询的条件对象。
33
+ * @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
34
+ * @returns 数据库查询条件对象。
34
35
  */
35
- export declare function getScopeQuery(session: Session, config: Config): object;
36
+ export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
36
37
  /**
37
- * @description 获取下一个可用的回声洞 ID
38
- * 实现了三阶段逻辑:优先使用回收ID -> 扫描空闲ID -> 获取最大ID+1。
38
+ * @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
39
39
  * @param ctx Koishi 上下文。
40
- * @param query 查询范围条件,用于分群模式。
40
+ * @param query 查询范围条件。
41
41
  * @param reusableIds 可复用 ID 的内存缓存。
42
42
  * @returns 可用的新 ID。
43
43
  */
44
44
  export declare function getNextCaveId(ctx: Context, query: object, reusableIds: Set<number>): Promise<number>;
45
45
  /**
46
46
  * @description 检查用户是否处于指令冷却中。
47
- * @returns 若在冷却中则返回提示字符串,否则返回 null。
47
+ * @returns 若在冷却中则提示字符串,否则 null。
48
48
  */
49
49
  export declare function checkCooldown(session: Session, config: Config, lastUsed: Map<string, number>): string | null;
50
50
  /**
@@ -53,13 +53,12 @@ export declare function checkCooldown(session: Session, config: Config, lastUsed
53
53
  export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
54
54
  /**
55
55
  * @description 解析消息元素,分离出文本和待下载的媒体文件。
56
- * @param sourceElements - 原始的 Koishi 消息元素数组。
57
- * @param newId - 这条回声洞的新 ID。
58
- * @param channelId - 频道 ID。
59
- * @param userId - 用户 ID。
60
- * @returns 一个包含数据库元素和待保存媒体列表的对象。
56
+ * @param sourceElements 原始的 Koishi 消息元素数组。
57
+ * @param newId 这条回声洞的新 ID。
58
+ * @param session 触发操作的会话。
59
+ * @returns 包含数据库元素和待保存媒体列表的对象。
61
60
  */
62
- 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<{
63
62
  finalElementsForDb: StoredElement[];
64
63
  mediaToSave: {
65
64
  sourceUrl: string;
@@ -80,7 +79,7 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
80
79
  * @param hashManager - HashManager 实例,如果启用则用于哈希计算和比较。
81
80
  * @param textHashesToStore - 已预先计算好的、待存入数据库的文本哈希对象数组。
82
81
  */
83
- 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: {
84
83
  sourceUrl: string;
85
84
  fileName: string;
86
- }[], reusableIds: Set<number>, session: Session, hashManager: HashManager | null, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
85
+ }[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
package/lib/index.d.ts CHANGED
@@ -28,8 +28,7 @@ export interface CaveObject {
28
28
  export interface CaveHashObject {
29
29
  cave: number;
30
30
  hash: string;
31
- type: 'text' | 'image';
32
- subType: 'shingle' | 'pHash' | 'subImage';
31
+ type: 'sim' | 'phash' | 'sub';
33
32
  }
34
33
  declare module 'koishi' {
35
34
  interface Tables {
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,8 +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
- await this.ctx.database.remove("cave_hash", {});
296
286
  return `成功导入 ${newCavesToInsert.length} 条数据`;
297
287
  }
298
288
  };
@@ -320,8 +310,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
320
310
  const fileName = element.attrs.src;
321
311
  if (!isMedia || !fileName) return element;
322
312
  if (config.enableS3 && config.publicUrl) {
323
- const fullUrl = new URL(fileName, config.publicUrl).href;
324
- 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 });
325
314
  }
326
315
  if (config.localPath) {
327
316
  return (0, import_koishi.h)(element.type, { ...element.attrs, src: `file://${path2.join(config.localPath, fileName)}` });
@@ -336,8 +325,7 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
336
325
  }
337
326
  }));
338
327
  const replacements = { id: cave.id.toString(), name: cave.userName };
339
- const formatPart = /* @__PURE__ */ __name((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim(), "formatPart");
340
- 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());
341
329
  const finalMessage = [];
342
330
  if (header) finalMessage.push(header + "\n");
343
331
  finalMessage.push(...processedElements);
@@ -349,21 +337,21 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
349
337
  try {
350
338
  const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
351
339
  if (!cavesToDelete.length) return;
340
+ const idsToDelete = cavesToDelete.map((c) => c.id);
352
341
  for (const cave of cavesToDelete) {
353
- const deletePromises = cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file));
354
- await Promise.all(deletePromises);
355
- reusableIds.add(cave.id);
356
- reusableIds.delete(MAX_ID_FLAG);
357
- await ctx.database.remove("cave", { id: cave.id });
358
- await ctx.database.remove("cave_hash", { cave: cave.id });
342
+ await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file)));
359
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 } });
360
348
  } catch (error) {
361
349
  logger2.error("清理回声洞时发生错误:", error);
362
350
  }
363
351
  }
364
352
  __name(cleanupPendingDeletions, "cleanupPendingDeletions");
365
- function getScopeQuery(session, config) {
366
- const baseQuery = { status: "active" };
353
+ function getScopeQuery(session, config, includeStatus = true) {
354
+ const baseQuery = includeStatus ? { status: "active" } : {};
367
355
  return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
368
356
  }
369
357
  __name(getScopeQuery, "getScopeQuery");
@@ -376,11 +364,7 @@ async function getNextCaveId(ctx, query = {}, reusableIds) {
376
364
  }
377
365
  if (reusableIds.has(MAX_ID_FLAG)) {
378
366
  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
- });
367
+ const [lastCave] = await ctx.database.get("cave", query, { sort: { id: "desc" }, limit: 1 });
384
368
  const newId2 = (lastCave?.id || 0) + 1;
385
369
  reusableIds.add(MAX_ID_FLAG);
386
370
  return newId2;
@@ -388,11 +372,8 @@ async function getNextCaveId(ctx, query = {}, reusableIds) {
388
372
  const allCaveIds = (await ctx.database.get("cave", query, { fields: ["id"] })).map((c) => c.id);
389
373
  const existingIds = new Set(allCaveIds);
390
374
  let newId = 1;
391
- while (existingIds.has(newId)) {
392
- newId++;
393
- }
394
- const maxIdInDb = allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0;
395
- if (existingIds.size === maxIdInDb) {
375
+ while (existingIds.has(newId)) newId++;
376
+ if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) {
396
377
  reusableIds.add(MAX_ID_FLAG);
397
378
  }
398
379
  return newId;
@@ -414,35 +395,30 @@ function updateCooldownTimestamp(session, config, lastUsed) {
414
395
  }
415
396
  }
416
397
  __name(updateCooldownTimestamp, "updateCooldownTimestamp");
417
- async function processMessageElements(sourceElements, newId, channelId, userId) {
398
+ async function processMessageElements(sourceElements, newId, session) {
418
399
  const finalElementsForDb = [];
419
400
  const mediaToSave = [];
420
401
  let mediaIndex = 0;
421
- const typeMap = {
422
- "img": "image",
423
- "image": "image",
424
- "video": "video",
425
- "audio": "audio",
426
- "file": "file",
427
- "text": "text"
428
- };
402
+ const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text" };
429
403
  const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
430
404
  async function traverse(elements) {
431
405
  for (const el of elements) {
432
- const normalizedType = typeMap[el.type];
433
- if (normalizedType) {
434
- if (["image", "video", "audio", "file"].includes(normalizedType) && el.attrs.src) {
435
- let fileIdentifier = el.attrs.src;
436
- if (fileIdentifier.startsWith("http")) {
437
- const ext = path2.extname(el.attrs.file || "") || defaultExtMap[normalizedType];
438
- const fileName = `${newId}_${++mediaIndex}_${channelId || "private"}_${userId}${ext}`;
439
- mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
440
- fileIdentifier = fileName;
441
- }
442
- finalElementsForDb.push({ type: normalizedType, file: fileIdentifier });
443
- } else if (normalizedType === "text" && el.attrs.content?.trim()) {
444
- 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;
445
420
  }
421
+ finalElementsForDb.push({ type, file: fileIdentifier });
446
422
  }
447
423
  if (el.children) await traverse(el.children);
448
424
  }
@@ -456,49 +432,35 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
456
432
  try {
457
433
  const downloadedMedia = [];
458
434
  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" });
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_/ });
476
443
  for (const newHash of allNewImageHashes) {
477
444
  for (const existing of existingImageHashes) {
478
- const similarity = hashManager.calculateImageSimilarity(newHash, existing.hash);
445
+ const similarity = hashManager.calculateSimilarity(newHash, existing.hash);
479
446
  if (similarity >= config.imageThreshold) {
480
447
  await session.send(`图片与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`);
481
448
  await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
482
- cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
449
+ reusableIds.add(cave.id);
483
450
  return;
484
451
  }
485
452
  }
486
453
  }
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) });
454
+ const pHashEntry = { hash: pHash, type: "phash" };
455
+ const subHashEntries = [...subHashes].map((sh) => ({ hash: sh, type: "sub" }));
456
+ imageHashesToStore.push(pHashEntry, ...subHashEntries);
492
457
  }
493
458
  }
494
459
  await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
495
460
  const finalStatus = config.enableReview ? "pending" : "active";
496
461
  await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
497
462
  if (hashManager) {
498
- const allHashesToInsert = [
499
- ...textHashesToStore.map((h4) => ({ ...h4, cave: cave.id })),
500
- ...imageHashesToStore.map((h4) => ({ ...h4, cave: cave.id }))
501
- ];
463
+ const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
502
464
  if (allHashesToInsert.length > 0) {
503
465
  await ctx.database.upsert("cave_hash", allHashesToInsert);
504
466
  }
@@ -518,7 +480,6 @@ __name(handleFileUploads, "handleFileUploads");
518
480
  // src/ReviewManager.ts
519
481
  var ReviewManager = class {
520
482
  /**
521
- * @constructor
522
483
  * @param ctx Koishi 上下文。
523
484
  * @param config 插件配置。
524
485
  * @param fileManager 文件管理器实例。
@@ -541,50 +502,50 @@ var ReviewManager = class {
541
502
  */
542
503
  registerCommands(cave) {
543
504
  const requireAdmin = /* @__PURE__ */ __name((session) => {
544
- const adminChannelId = this.config.adminChannel?.split(":")[1];
545
- if (session.channelId !== adminChannelId) {
505
+ if (session.channelId !== this.config.adminChannel?.split(":")[1]) {
546
506
  return "此指令仅限在管理群组中使用";
547
507
  }
548
508
  return null;
549
509
  }, "requireAdmin");
550
- const review = cave.subcommand(".review [id:posint]", "审核回声洞").usage("查看所有待审核回声洞,或查看指定待审核回声洞。").action(async ({ session }, id) => {
510
+ const review = cave.subcommand(".review [id:posint]", "审核回声洞").action(async ({ session }, id) => {
551
511
  const adminError = requireAdmin(session);
552
512
  if (adminError) return adminError;
553
- if (!id) {
554
- const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
555
- if (!pendingCaves.length) return "当前没有需要审核的回声洞";
556
- return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
557
- ${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)];
558
518
  }
559
- const [targetCave] = await this.ctx.database.get("cave", { id });
560
- if (!targetCave) return `回声洞(${id})不存在`;
561
- if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
562
- 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("|")}`;
563
523
  });
564
524
  const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
565
525
  const adminError = requireAdmin(session);
566
526
  if (adminError) return adminError;
567
527
  try {
528
+ const targetStatus = actionType === "approve" ? "active" : "delete";
529
+ const actionText = actionType === "approve" ? "通过" : "拒绝";
568
530
  if (!id) {
569
531
  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
- }
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} 条回声洞`;
579
536
  }
580
- 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}`;
581
542
  } catch (error) {
582
543
  this.logger.error(`审核操作失败:`, error);
583
544
  return `操作失败: ${error.message}`;
584
545
  }
585
546
  }, "createReviewAction");
586
- review.subcommand(".Y [id:posint]", "通过审核").usage("通过回声洞审核,可批量操作。").action(createReviewAction("approve"));
587
- 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"));
588
549
  }
589
550
  /**
590
551
  * @description 将新回声洞提交到管理群组以供审核。
@@ -603,29 +564,12 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
603
564
  this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
604
565
  }
605
566
  }
606
- /**
607
- * @description 处理管理员的审核决定(通过或拒绝)。
608
- * @param action 'approve' (通过) 或 'reject' (拒绝)。
609
- * @param caveId 被审核的回声洞 ID。
610
- * @returns 返回给操作者的确认消息。
611
- */
612
- async processReview(action, caveId) {
613
- const [cave] = await this.ctx.database.get("cave", { id: caveId, status: "pending" });
614
- if (!cave) return `回声洞(${caveId})无需审核`;
615
- if (action === "approve") {
616
- await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
617
- return `回声洞(${caveId})已通过`;
618
- } else {
619
- await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
620
- cleanupPendingDeletions(this.ctx, this.fileManager, this.logger, this.reusableIds);
621
- return `回声洞(${caveId})已拒绝`;
622
- }
623
- }
624
567
  };
625
568
 
626
569
  // src/HashManager.ts
627
570
  var import_sharp = __toESM(require("sharp"));
628
- var HashManager = class {
571
+ var crypto = __toESM(require("crypto"));
572
+ var HashManager = class _HashManager {
629
573
  /**
630
574
  * @constructor
631
575
  * @param ctx - Koishi 上下文,用于数据库操作。
@@ -638,24 +582,32 @@ var HashManager = class {
638
582
  this.config = config;
639
583
  this.logger = logger2;
640
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
+ });
641
592
  }
642
593
  static {
643
594
  __name(this, "HashManager");
644
595
  }
596
+ static HASH_BATCH_SIZE = 1e3;
597
+ static SIMHASH_BITS = 64;
645
598
  /**
646
599
  * @description 注册与哈希校验相关的子命令。
647
600
  * @param cave - 主 `cave` 命令实例。
648
601
  */
649
602
  registerCommands(cave) {
650
- cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希。").action(async ({ session }) => {
603
+ cave.subcommand(".hash", "校验回声洞").usage("校验所有回声洞,为历史数据生成哈希,并检查现有内容的相似度。").action(async ({ session }) => {
651
604
  const adminChannelId = this.config.adminChannel?.split(":")[1];
652
605
  if (session.channelId !== adminChannelId) {
653
606
  return "此指令仅限在管理群组中使用";
654
607
  }
655
608
  await session.send("正在处理,请稍候...");
656
609
  try {
657
- const report = await this.validateAllCaves();
658
- return report;
610
+ return await this.validateAllCaves();
659
611
  } catch (error) {
660
612
  this.logger.error("校验哈希失败:", error);
661
613
  return `校验失败: ${error.message}`;
@@ -663,74 +615,111 @@ var HashManager = class {
663
615
  });
664
616
  }
665
617
  /**
666
- * @description 检查数据库中所有回声洞,并为没有哈希记录的历史数据生成哈希。
667
- * @returns {Promise<string>} 返回一个包含操作结果的报告字符串。
618
+ * @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希,并在此之后对所有内容进行相似度检查。
619
+ * @returns {Promise<string>} 一个包含操作结果的报告字符串。
668
620
  */
669
621
  async validateAllCaves() {
670
622
  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 = [];
623
+ const existingHashedCaveIds = new Set((await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] })).map((h4) => h4.cave));
624
+ let hashesToInsert = [];
674
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");
675
634
  for (const cave of allCaves) {
676
635
  if (existingHashedCaveIds.has(cave.id)) continue;
677
636
  this.logger.info(`正在为回声洞(${cave.id})生成哈希...`);
678
637
  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" });
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" });
683
641
  }
684
- const imageElements = cave.elements.filter((el) => el.type === "image" && el.file);
685
- for (const el of imageElements) {
642
+ for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
686
643
  try {
687
644
  const imageBuffer = await this.fileManager.readFile(el.file);
688
645
  const pHash = await this.generateImagePHash(imageBuffer);
689
- hashesToInsert.push({ cave: cave.id, hash: pHash, type: "image", subType: "pHash" });
646
+ hashesToInsert.push({ cave: cave.id, hash: pHash, type: "phash" });
690
647
  const subHashes = await this.generateImageSubHashes(imageBuffer);
691
- subHashes.forEach((subHash) => {
692
- hashesToInsert.push({ cave: cave.id, hash: subHash, type: "image", subType: "subImage" });
693
- });
648
+ subHashes.forEach((subHash) => hashesToInsert.push({ cave: cave.id, hash: subHash, type: "sub" }));
694
649
  } catch (e) {
695
650
  this.logger.warn(`无法为回声洞(${cave.id})的内容(${el.file})生成哈希:`, e);
696
651
  }
697
652
  }
653
+ if (hashesToInsert.length >= _HashManager.HASH_BATCH_SIZE) await flushHashes();
698
654
  }
699
- if (hashesToInsert.length > 0) {
700
- await this.ctx.database.upsert("cave_hash", hashesToInsert);
701
- } else {
702
- this.logger.info("无需补全哈希");
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
+ }
703
696
  }
704
- return `校验完成,共补全 ${historicalCount} 个回声洞的 ${hashesToInsert.length} 条哈希`;
697
+ const similarityReport = similarPairs.size > 0 ? `发现 ${similarPairs.size} 对高相似度内容:
698
+ ` + [...similarPairs].join("\n") : "未发现高相似度内容";
699
+ return `校验完成:
700
+ ${generationReport}${similarityReport}`;
705
701
  }
706
702
  /**
707
703
  * @description 将图片切割为4个象限并为每个象限生成pHash。
708
704
  * @param imageBuffer - 图片的 Buffer 数据。
709
- * @returns {Promise<Set<string>>} 返回一个包含最多4个唯一哈希值的集合。
705
+ * @returns {Promise<Set<string>>} 一个包含最多4个唯一哈希值的集合。
710
706
  */
711
707
  async generateImageSubHashes(imageBuffer) {
712
708
  const hashes = /* @__PURE__ */ new Set();
713
709
  try {
714
710
  const metadata = await (0, import_sharp.default)(imageBuffer).metadata();
715
711
  const { width, height } = metadata;
716
- if (!width || !height || width < 16 || height < 16) {
717
- return hashes;
718
- }
712
+ if (!width || !height || width < 16 || height < 16) return hashes;
719
713
  const regions = [
720
714
  { left: 0, top: 0, width: Math.floor(width / 2), height: Math.floor(height / 2) },
721
- // Top-left
722
715
  { left: Math.floor(width / 2), top: 0, width: Math.ceil(width / 2), height: Math.floor(height / 2) },
723
- // Top-right
724
716
  { left: 0, top: Math.floor(height / 2), width: Math.floor(width / 2), height: Math.ceil(height / 2) },
725
- // Bottom-left
726
717
  { left: Math.floor(width / 2), top: Math.floor(height / 2), width: Math.ceil(width / 2), height: Math.ceil(height / 2) }
727
- // Bottom-right
728
718
  ];
729
719
  for (const region of regions) {
730
720
  if (region.width < 8 || region.height < 8) continue;
731
721
  const quadrantBuffer = await (0, import_sharp.default)(imageBuffer).extract(region).toBuffer();
732
- const subHash = await this.generateImagePHash(quadrantBuffer);
733
- hashes.add(subHash);
722
+ hashes.add(await this.generateImagePHash(quadrantBuffer));
734
723
  }
735
724
  } catch (e) {
736
725
  this.logger.warn(`生成子哈希失败:`, e);
@@ -738,22 +727,15 @@ var HashManager = class {
738
727
  return hashes;
739
728
  }
740
729
  /**
741
- * @description 根据pHash(感知哈希)算法为图片生成哈希值。
742
- * @param imageBuffer - 图片的 Buffer 数据。
743
- * @returns {Promise<string>} 返回一个64位的二进制哈希字符串。
730
+ * @description 根据感知哈希(pHash)算法为图片生成哈希。
731
+ * @param imageBuffer 图片的 Buffer 数据。
732
+ * @returns 64位二进制哈希字符串。
744
733
  */
745
734
  async generateImagePHash(imageBuffer) {
746
735
  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
- }
736
+ const totalLuminance = smallImage.reduce((acc, val) => acc + val, 0);
751
737
  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;
738
+ return Array.from(smallImage).map((lum) => lum > avgLuminance ? "1" : "0").join("");
757
739
  }
758
740
  /**
759
741
  * @description 计算两个哈希字符串之间的汉明距离(不同字符的数量)。
@@ -763,61 +745,40 @@ var HashManager = class {
763
745
  */
764
746
  calculateHammingDistance(hash1, hash2) {
765
747
  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
- }
748
+ const len = Math.min(hash1.length, hash2.length);
749
+ for (let i = 0; i < len; i++) {
750
+ if (hash1[i] !== hash2[i]) distance++;
770
751
  }
771
752
  return distance;
772
753
  }
773
754
  /**
774
- * @description 根据汉明距离计算图片pHash的相似度。
755
+ * @description 根据汉明距离计算图片或文本哈希的相似度。
775
756
  * @param hash1 - 第一个哈希字符串。
776
757
  * @param hash2 - 第二个哈希字符串。
777
758
  * @returns {number} 范围在0到1之间的相似度得分。
778
759
  */
779
- calculateImageSimilarity(hash1, hash2) {
760
+ calculateSimilarity(hash1, hash2) {
780
761
  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;
762
+ const hashLength = Math.max(hash1.length, hash2.length);
763
+ return hashLength === 0 ? 1 : 1 - distance / hashLength;
797
764
  }
798
765
  /**
799
- * @description 为文本生成基于Shingling的哈希字符串。
766
+ * @description 为文本生成基于 Simhash 算法的哈希字符串。
800
767
  * @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之间的相似度得分。
768
+ * @returns {string} 64位二进制 Simhash 字符串。
813
769
  */
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;
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("");
821
782
  }
822
783
  };
823
784
 
@@ -851,7 +812,7 @@ var Config = import_koishi3.Schema.intersect([
851
812
  enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
852
813
  textThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("文本相似度阈值"),
853
814
  imageThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.9).description("图片相似度阈值")
854
- }).description("审核与查重配置"),
815
+ }).description("复核配置"),
855
816
  import_koishi3.Schema.object({
856
817
  localPath: import_koishi3.Schema.string().description("文件映射路径"),
857
818
  enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
@@ -873,21 +834,13 @@ function apply(ctx, config) {
873
834
  status: "string",
874
835
  time: "timestamp"
875
836
  }, { 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
- });
884
837
  const fileManager = new FileManager(ctx.baseDir, config, logger);
885
838
  const lastUsed = /* @__PURE__ */ new Map();
886
839
  const reusableIds = /* @__PURE__ */ new Set();
887
840
  const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
888
- const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, reusableIds) : null;
889
841
  const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger, reusableIds) : null;
890
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;
891
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 }) => {
892
845
  if (options.add) return session.execute(`cave.add ${options.add}`);
893
846
  if (options.view) return session.execute(`cave.view ${options.view}`);
@@ -910,7 +863,7 @@ function apply(ctx, config) {
910
863
  return "随机获取回声洞失败";
911
864
  }
912
865
  });
913
- cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
866
+ cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
914
867
  try {
915
868
  let sourceElements = session.quote?.elements;
916
869
  if (!sourceElements && content?.trim()) {
@@ -922,39 +875,28 @@ function apply(ctx, config) {
922
875
  if (!reply) return "等待操作超时";
923
876
  sourceElements = import_koishi3.h.parse(reply);
924
877
  }
925
- const idScopeQuery = config.perChannel && session.channelId ? { channelId: session.channelId } : {};
926
- const newId = await getNextCaveId(ctx, idScopeQuery, reusableIds);
927
- const { finalElementsForDb, mediaToSave } = await processMessageElements(
928
- sourceElements,
929
- newId,
930
- session.channelId,
931
- session.userId
932
- );
933
- if (finalElementsForDb.length === 0) {
934
- return "无可添加内容";
935
- }
936
- let textHashesToStore = [];
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 = [];
937
882
  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 } });
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" });
943
887
  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
- }
888
+ const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
889
+ if (similarity >= config.textThreshold) {
890
+ return `内容与回声洞(${existing.cave})的相似度(${(similarity * 100).toFixed(2)}%)过高`;
950
891
  }
951
892
  }
893
+ textHashesToStore.push({ hash: newSimhash, type: "sim" });
952
894
  }
953
895
  }
954
896
  const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
955
897
  const hasMedia = mediaToSave.length > 0;
956
898
  const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
957
- const newCave = {
899
+ const newCave = await ctx.database.create("cave", {
958
900
  id: newId,
959
901
  elements: finalElementsForDb,
960
902
  channelId: session.channelId,
@@ -962,33 +904,29 @@ function apply(ctx, config) {
962
904
  userName,
963
905
  status: initialStatus,
964
906
  time: /* @__PURE__ */ new Date()
965
- };
966
- await ctx.database.create("cave", newCave);
907
+ });
967
908
  if (hasMedia) {
968
909
  handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds, session, hashManager, textHashesToStore);
969
910
  } else {
970
911
  if (hashManager && textHashesToStore.length > 0) {
971
- const hashObjectsToInsert = textHashesToStore.map((h4) => ({ ...h4, cave: newId }));
972
- await ctx.database.upsert("cave_hash", hashObjectsToInsert);
912
+ await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
973
913
  }
974
914
  if (initialStatus === "pending") {
975
915
  reviewManager.sendForReview(newCave);
976
916
  }
977
917
  }
978
- const responseMessage = initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
979
- return responseMessage;
918
+ return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
980
919
  } catch (error) {
981
920
  logger.error("添加回声洞失败:", error);
982
921
  return "添加失败,请稍后再试";
983
922
  }
984
923
  });
985
- cave.subcommand(".view <id:posint>", "查看指定回声洞").usage("通过序号查看对应的回声洞。").action(async ({ session }, id) => {
924
+ cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
986
925
  if (!id) return "请输入要查看的回声洞序号";
987
926
  const cdMessage = checkCooldown(session, config, lastUsed);
988
927
  if (cdMessage) return cdMessage;
989
928
  try {
990
- const query = { ...getScopeQuery(session, config), id };
991
- const [targetCave] = await ctx.database.get("cave", query);
929
+ const [targetCave] = await ctx.database.get("cave", { ...getScopeQuery(session, config), id });
992
930
  if (!targetCave) return `回声洞(${id})不存在`;
993
931
  updateCooldownTimestamp(session, config, lastUsed);
994
932
  return buildCaveMessage(targetCave, config, fileManager, logger);
@@ -997,17 +935,14 @@ function apply(ctx, config) {
997
935
  return "查看失败,请稍后再试";
998
936
  }
999
937
  });
1000
- cave.subcommand(".del <id:posint>", "删除指定回声洞").usage("通过序号删除对应的回声洞。").action(async ({ session }, id) => {
938
+ cave.subcommand(".del <id:posint>", "删除指定回声洞").action(async ({ session }, id) => {
1001
939
  if (!id) return "请输入要删除的回声洞序号";
1002
940
  try {
1003
941
  const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
1004
942
  if (!targetCave) return `回声洞(${id})不存在`;
1005
- const adminChannelId = config.adminChannel?.split(":")[1];
1006
943
  const isAuthor = targetCave.userId === session.userId;
1007
- const isAdmin = session.channelId === adminChannelId;
1008
- if (!isAuthor && !isAdmin) {
1009
- return "你没有权限删除这条回声洞";
1010
- }
944
+ const isAdmin = session.channelId === config.adminChannel?.split(":")[1];
945
+ if (!isAuthor && !isAdmin) return "你没有权限删除这条回声洞";
1011
946
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
1012
947
  const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
1013
948
  cleanupPendingDeletions(ctx, fileManager, logger, reusableIds);
@@ -1017,10 +952,9 @@ function apply(ctx, config) {
1017
952
  return "删除失败,请稍后再试";
1018
953
  }
1019
954
  });
1020
- cave.subcommand(".list", "查询我的投稿").usage("查询并列出你所有投稿的回声洞序号。").action(async ({ session }) => {
955
+ cave.subcommand(".list", "查询我的投稿").action(async ({ session }) => {
1021
956
  try {
1022
- const query = { ...getScopeQuery(session, config), userId: session.userId };
1023
- const userCaves = await ctx.database.get("cave", query, { fields: ["id"] });
957
+ const userCaves = await ctx.database.get("cave", { ...getScopeQuery(session, config), userId: session.userId });
1024
958
  if (!userCaves.length) return "你还没有投稿过回声洞";
1025
959
  const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join(", ");
1026
960
  return `你已投稿 ${userCaves.length} 条回声洞,序号为:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "最强大的回声洞现已重构完成啦!注意数据格式需要使用脚本转换哦~",
4
- "version": "2.1.3",
4
+ "version": "2.1.4",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],