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.
- package/lib/DataManager.d.ts +38 -0
- package/lib/FileManager.d.ts +48 -0
- package/lib/HashManager.d.ts +77 -0
- package/lib/ProfileManager.d.ts +50 -0
- package/lib/ReviewManager.d.ts +40 -0
- package/lib/Utils.d.ts +18 -11
- package/lib/index.d.ts +61 -0
- package/lib/index.js +458 -118
- package/package.json +3 -2
|
@@ -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
|
|
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 -
|
|
73
|
-
* @param cave -
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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 (
|
|
356
|
-
logger2.error(`回声洞(${cave.id}
|
|
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
|
|
539
|
+
* @description 注册与审核相关的子命令。
|
|
459
540
|
* @param cave - 主 `cave` 命令实例。
|
|
460
541
|
*/
|
|
461
542
|
registerCommands(cave) {
|
|
462
|
-
|
|
543
|
+
const requireAdmin = /* @__PURE__ */ __name((session) => {
|
|
463
544
|
const adminChannelId = this.config.adminChannel?.split(":")[1];
|
|
464
|
-
if (session.channelId !== adminChannelId)
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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)
|
|
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
|
|
638
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|