koishi-plugin-best-cave 2.1.2 → 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.
- package/lib/FileManager.d.ts +48 -0
- package/lib/HashManager.d.ts +77 -0
- package/lib/ProfileManager.d.ts +50 -0
- package/lib/Utils.d.ts +12 -8
- package/lib/index.d.ts +61 -0
- package/lib/index.js +296 -18
- package/package.json +3 -2
|
@@ -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
|
+
}
|
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 从数据库读取的元素数组。
|
|
@@ -66,17 +67,20 @@ export declare function processMessageElements(sourceElements: h[], newId: numbe
|
|
|
66
67
|
}[];
|
|
67
68
|
}>;
|
|
68
69
|
/**
|
|
69
|
-
* @description
|
|
70
|
+
* @description 异步处理文件上传、查重和状态更新的后台任务。
|
|
70
71
|
* @param ctx - Koishi 上下文。
|
|
71
72
|
* @param config - 插件配置。
|
|
72
|
-
* @param fileManager -
|
|
73
|
+
* @param fileManager - FileManager 实例,用于保存文件。
|
|
73
74
|
* @param logger - 日志记录器实例。
|
|
74
|
-
* @param reviewManager -
|
|
75
|
-
* @param cave -
|
|
76
|
-
* @param mediaToSave -
|
|
77
|
-
* @param reusableIds 可复用 ID 的内存缓存。
|
|
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 - 已预先计算好的、待存入数据库的文本哈希对象数组。
|
|
78
82
|
*/
|
|
79
83
|
export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: any, cave: CaveObject, mediaToSave: {
|
|
80
84
|
sourceUrl: string;
|
|
81
85
|
fileName: string;
|
|
82
|
-
}[], reusableIds: Set<number>): 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
|
@@ -292,6 +292,7 @@ var DataManager = class {
|
|
|
292
292
|
}));
|
|
293
293
|
await this.ctx.database.upsert("cave", newCavesToInsert);
|
|
294
294
|
this.reusableIds.clear();
|
|
295
|
+
await this.ctx.database.remove("cave_hash", {});
|
|
295
296
|
return `成功导入 ${newCavesToInsert.length} 条数据`;
|
|
296
297
|
}
|
|
297
298
|
};
|
|
@@ -354,6 +355,7 @@ async function cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds) {
|
|
|
354
355
|
reusableIds.add(cave.id);
|
|
355
356
|
reusableIds.delete(MAX_ID_FLAG);
|
|
356
357
|
await ctx.database.remove("cave", { id: cave.id });
|
|
358
|
+
await ctx.database.remove("cave_hash", { cave: cave.id });
|
|
357
359
|
}
|
|
358
360
|
} catch (error) {
|
|
359
361
|
logger2.error("清理回声洞时发生错误:", error);
|
|
@@ -450,21 +452,63 @@ async function processMessageElements(sourceElements, newId, channelId, userId)
|
|
|
450
452
|
return { finalElementsForDb, mediaToSave };
|
|
451
453
|
}
|
|
452
454
|
__name(processMessageElements, "processMessageElements");
|
|
453
|
-
async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave, reusableIds) {
|
|
455
|
+
async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave, reusableIds, session, hashManager, textHashesToStore) {
|
|
454
456
|
try {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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)));
|
|
460
495
|
const finalStatus = config.enableReview ? "pending" : "active";
|
|
461
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
|
+
}
|
|
462
506
|
if (finalStatus === "pending" && reviewManager) {
|
|
463
507
|
const [finalCave] = await ctx.database.get("cave", { id: cave.id });
|
|
464
508
|
if (finalCave) reviewManager.sendForReview(finalCave);
|
|
465
509
|
}
|
|
466
|
-
} catch (
|
|
467
|
-
logger2.error(`回声洞(${cave.id}
|
|
510
|
+
} catch (fileProcessingError) {
|
|
511
|
+
logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
|
|
468
512
|
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
469
513
|
cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
|
|
470
514
|
}
|
|
@@ -515,12 +559,11 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
|
|
|
515
559
|
const [targetCave] = await this.ctx.database.get("cave", { id });
|
|
516
560
|
if (!targetCave) return `回声洞(${id})不存在`;
|
|
517
561
|
if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
|
|
518
|
-
return [
|
|
562
|
+
return [`待审核`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
|
|
519
563
|
});
|
|
520
564
|
const createReviewAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
|
|
521
565
|
const adminError = requireAdmin(session);
|
|
522
566
|
if (adminError) return adminError;
|
|
523
|
-
await session.send("正在处理,请稍候...");
|
|
524
567
|
try {
|
|
525
568
|
if (!id) {
|
|
526
569
|
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
|
|
@@ -554,7 +597,7 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
|
|
|
554
597
|
return;
|
|
555
598
|
}
|
|
556
599
|
try {
|
|
557
|
-
const reviewMessage = [
|
|
600
|
+
const reviewMessage = [`待审核`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
|
|
558
601
|
await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(reviewMessage));
|
|
559
602
|
} catch (error) {
|
|
560
603
|
this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
|
|
@@ -580,6 +623,204 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
|
|
|
580
623
|
}
|
|
581
624
|
};
|
|
582
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
|
+
|
|
583
824
|
// src/index.ts
|
|
584
825
|
var name = "best-cave";
|
|
585
826
|
var inject = ["database"];
|
|
@@ -606,8 +847,11 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
606
847
|
caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本")
|
|
607
848
|
}).description("基础配置"),
|
|
608
849
|
import_koishi3.Schema.object({
|
|
609
|
-
enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核")
|
|
610
|
-
|
|
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("审核与查重配置"),
|
|
611
855
|
import_koishi3.Schema.object({
|
|
612
856
|
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
613
857
|
enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
|
|
@@ -629,12 +873,21 @@ function apply(ctx, config) {
|
|
|
629
873
|
status: "string",
|
|
630
874
|
time: "timestamp"
|
|
631
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
|
+
});
|
|
632
884
|
const fileManager = new FileManager(ctx.baseDir, config, logger);
|
|
633
885
|
const lastUsed = /* @__PURE__ */ new Map();
|
|
634
886
|
const reusableIds = /* @__PURE__ */ new Set();
|
|
635
887
|
const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
|
|
636
888
|
const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, reusableIds) : null;
|
|
637
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;
|
|
638
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 }) => {
|
|
639
892
|
if (options.add) return session.execute(`cave.add ${options.add}`);
|
|
640
893
|
if (options.view) return session.execute(`cave.view ${options.view}`);
|
|
@@ -666,7 +919,7 @@ function apply(ctx, config) {
|
|
|
666
919
|
if (!sourceElements) {
|
|
667
920
|
await session.send("请在一分钟内发送你要添加的内容");
|
|
668
921
|
const reply = await session.prompt(6e4);
|
|
669
|
-
if (!reply) return "
|
|
922
|
+
if (!reply) return "等待操作超时";
|
|
670
923
|
sourceElements = import_koishi3.h.parse(reply);
|
|
671
924
|
}
|
|
672
925
|
const idScopeQuery = config.perChannel && session.channelId ? { channelId: session.channelId } : {};
|
|
@@ -678,7 +931,25 @@ function apply(ctx, config) {
|
|
|
678
931
|
session.userId
|
|
679
932
|
);
|
|
680
933
|
if (finalElementsForDb.length === 0) {
|
|
681
|
-
return "
|
|
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
|
+
}
|
|
682
953
|
}
|
|
683
954
|
const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
684
955
|
const hasMedia = mediaToSave.length > 0;
|
|
@@ -694,9 +965,15 @@ function apply(ctx, config) {
|
|
|
694
965
|
};
|
|
695
966
|
await ctx.database.create("cave", newCave);
|
|
696
967
|
if (hasMedia) {
|
|
697
|
-
handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave, reusableIds);
|
|
698
|
-
} else
|
|
699
|
-
|
|
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
|
+
}
|
|
700
977
|
}
|
|
701
978
|
const responseMessage = initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
|
|
702
979
|
return responseMessage;
|
|
@@ -756,6 +1033,7 @@ ${caveIds}`;
|
|
|
756
1033
|
if (profileManager) profileManager.registerCommands(cave);
|
|
757
1034
|
if (dataManager) dataManager.registerCommands(cave);
|
|
758
1035
|
if (reviewManager) reviewManager.registerCommands(cave);
|
|
1036
|
+
if (hashManager) hashManager.registerCommands(cave);
|
|
759
1037
|
}
|
|
760
1038
|
__name(apply, "apply");
|
|
761
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.
|
|
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.
|
|
31
|
+
"@aws-sdk/client-s3": "^3.800.0",
|
|
32
|
+
"sharp": "^0.34.3"
|
|
32
33
|
}
|
|
33
34
|
}
|