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