koishi-plugin-best-cave 2.3.0 → 2.3.1
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/HashManager.d.ts +102 -0
- package/lib/NameManager.d.ts +45 -0
- package/lib/PendManager.d.ts +32 -0
- package/lib/Utils.d.ts +71 -0
- package/lib/index.d.ts +53 -0
- package/lib/index.js +120 -67
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Context, Logger } from 'koishi';
|
|
2
|
+
import { Config, CaveObject } from './index';
|
|
3
|
+
import { FileManager } from './FileManager';
|
|
4
|
+
/**
|
|
5
|
+
* @description 数据库 `cave_hash` 表的完整对象模型。
|
|
6
|
+
*/
|
|
7
|
+
export interface CaveHashObject {
|
|
8
|
+
cave: number;
|
|
9
|
+
hash: string;
|
|
10
|
+
type: 'simhash' | 'phash_g' | 'phash_q1' | 'phash_q2' | 'phash_q3' | 'phash_q4';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* @class HashManager
|
|
14
|
+
* @description 负责生成、存储和比较文本与图片的哈希值。
|
|
15
|
+
* 实现了基于 Simhash 的文本查重和基于 DCT 感知哈希 (pHash) 的图片查重方案。
|
|
16
|
+
*/
|
|
17
|
+
export declare class HashManager {
|
|
18
|
+
private ctx;
|
|
19
|
+
private config;
|
|
20
|
+
private logger;
|
|
21
|
+
private fileManager;
|
|
22
|
+
/**
|
|
23
|
+
* @constructor
|
|
24
|
+
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
25
|
+
* @param config - 插件配置,用于获取相似度阈值等。
|
|
26
|
+
* @param logger - 日志记录器实例。
|
|
27
|
+
* @param fileManager - 文件管理器实例,用于读取图片文件。
|
|
28
|
+
*/
|
|
29
|
+
constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
|
|
30
|
+
/**
|
|
31
|
+
* @description 注册与哈希功能相关的 `.hash` 和 `.check` 子命令。
|
|
32
|
+
* @param cave - 主 `cave` 命令实例。
|
|
33
|
+
*/
|
|
34
|
+
registerCommands(cave: any): void;
|
|
35
|
+
/**
|
|
36
|
+
* @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。
|
|
37
|
+
* @returns 一个包含操作结果的报告字符串。
|
|
38
|
+
*/
|
|
39
|
+
generateHashesForHistoricalCaves(): Promise<string>;
|
|
40
|
+
/**
|
|
41
|
+
* @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
|
|
42
|
+
* @param cave - 回声洞对象。
|
|
43
|
+
* @returns 生成的哈希对象数组。
|
|
44
|
+
*/
|
|
45
|
+
generateAllHashesForCave(cave: Pick<CaveObject, 'id' | 'elements'>): Promise<CaveHashObject[]>;
|
|
46
|
+
/**
|
|
47
|
+
* @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。
|
|
48
|
+
* @param options 包含临时阈值的可选对象。
|
|
49
|
+
* @returns 一个包含检查结果的报告字符串。
|
|
50
|
+
*/
|
|
51
|
+
checkForSimilarCaves(options?: {
|
|
52
|
+
textThreshold?: number;
|
|
53
|
+
imageThreshold?: number;
|
|
54
|
+
}): Promise<string>;
|
|
55
|
+
/**
|
|
56
|
+
* @description 为单个图片Buffer生成全局pHash和四个象限的局部pHash。
|
|
57
|
+
* @param imageBuffer - 图片的Buffer数据。
|
|
58
|
+
* @returns 包含全局哈希和四象限哈希的对象。
|
|
59
|
+
*/
|
|
60
|
+
generateAllImageHashes(imageBuffer: Buffer): Promise<{
|
|
61
|
+
globalHash: string;
|
|
62
|
+
quadrantHashes: {
|
|
63
|
+
q1: string;
|
|
64
|
+
q2: string;
|
|
65
|
+
q3: string;
|
|
66
|
+
q4: string;
|
|
67
|
+
};
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* @description 执行二维离散余弦变换 (DCT-II)。
|
|
71
|
+
* @param matrix - 输入的 N x N 像素亮度矩阵。
|
|
72
|
+
* @returns DCT变换后的 N x N 系数矩阵。
|
|
73
|
+
*/
|
|
74
|
+
private _dct2D;
|
|
75
|
+
/**
|
|
76
|
+
* @description pHash 算法核心实现。
|
|
77
|
+
* @param imageBuffer - 图片的Buffer。
|
|
78
|
+
* @param size - 期望的哈希位数 (必须是完全平方数, 如 64 或 256)。
|
|
79
|
+
* @returns 十六进制pHash字符串。
|
|
80
|
+
*/
|
|
81
|
+
private _generatePHash;
|
|
82
|
+
/**
|
|
83
|
+
* @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。
|
|
84
|
+
* @param hex1 - 第一个哈希。
|
|
85
|
+
* @param hex2 - 第二个哈希。
|
|
86
|
+
* @returns 汉明距离。
|
|
87
|
+
*/
|
|
88
|
+
calculateHammingDistance(hex1: string, hex2: string): number;
|
|
89
|
+
/**
|
|
90
|
+
* @description 根据汉明距离计算相似度百分比。
|
|
91
|
+
* @param hex1 - 第一个哈希。
|
|
92
|
+
* @param hex2 - 第二个哈希。
|
|
93
|
+
* @returns 相似度 (0-100)。
|
|
94
|
+
*/
|
|
95
|
+
calculateSimilarity(hex1: string, hex2: string): number;
|
|
96
|
+
/**
|
|
97
|
+
* @description 为文本生成 64 位 Simhash 字符串。
|
|
98
|
+
* @param text - 需要处理的文本。
|
|
99
|
+
* @returns 16位十六进制 Simhash 字符串。
|
|
100
|
+
*/
|
|
101
|
+
generateTextSimhash(text: string): string;
|
|
102
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
/** 数据库 `cave_user` 表的结构。 */
|
|
3
|
+
export interface UserName {
|
|
4
|
+
userId: string;
|
|
5
|
+
nickname: string;
|
|
6
|
+
}
|
|
7
|
+
declare module 'koishi' {
|
|
8
|
+
interface Tables {
|
|
9
|
+
cave_user: UserName;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* @class NameManager
|
|
14
|
+
* @description 负责管理用户在回声洞中的自定义昵称。
|
|
15
|
+
*/
|
|
16
|
+
export declare class NameManager {
|
|
17
|
+
private ctx;
|
|
18
|
+
/**
|
|
19
|
+
* @constructor
|
|
20
|
+
* @param ctx - Koishi 上下文,用于初始化数据库模型。
|
|
21
|
+
*/
|
|
22
|
+
constructor(ctx: Context);
|
|
23
|
+
/**
|
|
24
|
+
* @description 注册 `.name` 子命令,用于管理用户昵称。
|
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Context, Logger } from 'koishi';
|
|
2
|
+
import { CaveObject, Config } from './index';
|
|
3
|
+
import { FileManager } from './FileManager';
|
|
4
|
+
/**
|
|
5
|
+
* @class PendManager
|
|
6
|
+
* @description 负责处理回声洞的审核流程,处理新洞的提交、审核通知和审核操作。
|
|
7
|
+
*/
|
|
8
|
+
export declare class PendManager {
|
|
9
|
+
private ctx;
|
|
10
|
+
private config;
|
|
11
|
+
private fileManager;
|
|
12
|
+
private logger;
|
|
13
|
+
private reusableIds;
|
|
14
|
+
/**
|
|
15
|
+
* @param ctx Koishi 上下文。
|
|
16
|
+
* @param config 插件配置。
|
|
17
|
+
* @param fileManager 文件管理器实例。
|
|
18
|
+
* @param logger 日志记录器实例。
|
|
19
|
+
* @param reusableIds 可复用 ID 的内存缓存。
|
|
20
|
+
*/
|
|
21
|
+
constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reusableIds: Set<number>);
|
|
22
|
+
/**
|
|
23
|
+
* @description 注册与审核相关的子命令。
|
|
24
|
+
* @param cave - 主 `cave` 命令实例。
|
|
25
|
+
*/
|
|
26
|
+
registerCommands(cave: any): void;
|
|
27
|
+
/**
|
|
28
|
+
* @description 将新回声洞提交到管理群组以供审核。
|
|
29
|
+
* @param cave 新创建的、状态为 'pending' 的回声洞对象。
|
|
30
|
+
*/
|
|
31
|
+
sendForPend(cave: CaveObject): Promise<void>;
|
|
32
|
+
}
|
package/lib/Utils.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Context, h, Logger, Session } from 'koishi';
|
|
2
|
+
import { CaveObject, Config, StoredElement } from './index';
|
|
3
|
+
import { FileManager } from './FileManager';
|
|
4
|
+
import { HashManager, CaveHashObject } from './HashManager';
|
|
5
|
+
import { PendManager } from './PendManager';
|
|
6
|
+
/**
|
|
7
|
+
* @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
|
|
8
|
+
* @param elements 从数据库读取的元素数组。
|
|
9
|
+
* @returns 转换后的 h() 元素数组。
|
|
10
|
+
*/
|
|
11
|
+
export declare function storedFormatToHElements(elements: StoredElement[]): h[];
|
|
12
|
+
/**
|
|
13
|
+
* @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
|
|
14
|
+
* @param cave 回声洞对象。
|
|
15
|
+
* @param config 插件配置。
|
|
16
|
+
* @param fileManager 文件管理器实例。
|
|
17
|
+
* @param logger 日志记录器实例。
|
|
18
|
+
* @returns 包含 h() 元素和字符串的消息数组。
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
|
|
21
|
+
/**
|
|
22
|
+
* @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
|
|
23
|
+
* @param ctx Koishi 上下文。
|
|
24
|
+
* @param fileManager 文件管理器实例。
|
|
25
|
+
* @param logger 日志记录器实例。
|
|
26
|
+
* @param reusableIds 可复用 ID 的内存缓存。
|
|
27
|
+
*/
|
|
28
|
+
export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* @description 根据配置和会话,生成数据库查询的范围条件。
|
|
31
|
+
* @param session 当前会话。
|
|
32
|
+
* @param config 插件配置。
|
|
33
|
+
* @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
|
|
34
|
+
* @returns 数据库查询条件对象。
|
|
35
|
+
*/
|
|
36
|
+
export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
|
|
37
|
+
/**
|
|
38
|
+
* @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
|
|
39
|
+
* @param ctx Koishi 上下文。
|
|
40
|
+
* @param query 查询范围条件。
|
|
41
|
+
* @param reusableIds 可复用 ID 的内存缓存。
|
|
42
|
+
* @returns 可用的新 ID。
|
|
43
|
+
*/
|
|
44
|
+
export declare function getNextCaveId(ctx: Context, query: object, reusableIds: Set<number>): Promise<number>;
|
|
45
|
+
/**
|
|
46
|
+
* @description 检查用户是否处于指令冷却中。
|
|
47
|
+
* @returns 若在冷却中则提示字符串,否则 null。
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkCooldown(session: Session, config: Config, lastUsed: Map<string, number>): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* @description 更新指定频道的指令使用时间戳。
|
|
52
|
+
*/
|
|
53
|
+
export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
|
|
54
|
+
/**
|
|
55
|
+
* @description 解析消息元素,分离出文本和待下载的媒体文件。
|
|
56
|
+
* @param sourceElements 原始的 Koishi 消息元素数组。
|
|
57
|
+
* @param newId 这条回声洞的新 ID。
|
|
58
|
+
* @param session 触发操作的会话。
|
|
59
|
+
* @returns 包含数据库元素和待保存媒体列表的对象。
|
|
60
|
+
*/
|
|
61
|
+
export declare function processMessageElements(sourceElements: h[], newId: number, session: Session): Promise<{
|
|
62
|
+
finalElementsForDb: StoredElement[];
|
|
63
|
+
mediaToSave: {
|
|
64
|
+
sourceUrl: string;
|
|
65
|
+
fileName: string;
|
|
66
|
+
}[];
|
|
67
|
+
}>;
|
|
68
|
+
export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: PendManager, cave: CaveObject, mediaToToSave: {
|
|
69
|
+
sourceUrl: string;
|
|
70
|
+
fileName: string;
|
|
71
|
+
}[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import { CaveHashObject } from './HashManager';
|
|
3
|
+
export declare const name = "best-cave";
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
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";
|
|
6
|
+
/**
|
|
7
|
+
* @description 存储在数据库中的单个消息元素。
|
|
8
|
+
*/
|
|
9
|
+
export interface StoredElement {
|
|
10
|
+
type: 'text' | 'image' | 'video' | 'audio' | 'file';
|
|
11
|
+
content?: string;
|
|
12
|
+
file?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* @description 数据库 `cave` 表的完整对象模型。
|
|
16
|
+
*/
|
|
17
|
+
export interface CaveObject {
|
|
18
|
+
id: number;
|
|
19
|
+
elements: StoredElement[];
|
|
20
|
+
channelId: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
userName: string;
|
|
23
|
+
status: 'active' | 'delete' | 'pending' | 'preload';
|
|
24
|
+
time: Date;
|
|
25
|
+
}
|
|
26
|
+
declare module 'koishi' {
|
|
27
|
+
interface Tables {
|
|
28
|
+
cave: CaveObject;
|
|
29
|
+
cave_hash: CaveHashObject;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export interface Config {
|
|
33
|
+
coolDown: number;
|
|
34
|
+
perChannel: boolean;
|
|
35
|
+
adminChannel: string;
|
|
36
|
+
enableName: boolean;
|
|
37
|
+
enableIO: boolean;
|
|
38
|
+
enablePend: boolean;
|
|
39
|
+
caveFormat: string;
|
|
40
|
+
enableSimilarity: boolean;
|
|
41
|
+
textThreshold: number;
|
|
42
|
+
imageThreshold: number;
|
|
43
|
+
localPath?: string;
|
|
44
|
+
enableS3: boolean;
|
|
45
|
+
endpoint?: string;
|
|
46
|
+
region?: string;
|
|
47
|
+
accessKeyId?: string;
|
|
48
|
+
secretAccessKey?: string;
|
|
49
|
+
bucket?: string;
|
|
50
|
+
publicUrl?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare const Config: Schema<Config>;
|
|
53
|
+
export declare function apply(ctx: Context, config: Config): void;
|
package/lib/index.js
CHANGED
|
@@ -149,8 +149,8 @@ var FileManager = class {
|
|
|
149
149
|
}
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
-
// src/
|
|
153
|
-
var
|
|
152
|
+
// src/NameManager.ts
|
|
153
|
+
var NameManager = class {
|
|
154
154
|
/**
|
|
155
155
|
* @constructor
|
|
156
156
|
* @param ctx - Koishi 上下文,用于初始化数据库模型。
|
|
@@ -165,14 +165,14 @@ var ProfileManager = class {
|
|
|
165
165
|
});
|
|
166
166
|
}
|
|
167
167
|
static {
|
|
168
|
-
__name(this, "
|
|
168
|
+
__name(this, "NameManager");
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
171
|
-
* @description 注册 `.
|
|
171
|
+
* @description 注册 `.name` 子命令,用于管理用户昵称。
|
|
172
172
|
* @param cave - 主 `cave` 命令实例。
|
|
173
173
|
*/
|
|
174
174
|
registerCommands(cave) {
|
|
175
|
-
cave.subcommand(".
|
|
175
|
+
cave.subcommand(".name [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
|
|
176
176
|
const trimmedNickname = nickname?.trim();
|
|
177
177
|
if (trimmedNickname) {
|
|
178
178
|
await this.setNickname(session.userId, trimmedNickname);
|
|
@@ -196,8 +196,8 @@ var ProfileManager = class {
|
|
|
196
196
|
* @returns 用户的昵称字符串或 null。
|
|
197
197
|
*/
|
|
198
198
|
async getNickname(userId) {
|
|
199
|
-
const [
|
|
200
|
-
return
|
|
199
|
+
const [name2] = await this.ctx.database.get("cave_user", { userId });
|
|
200
|
+
return name2?.nickname ?? null;
|
|
201
201
|
}
|
|
202
202
|
/**
|
|
203
203
|
* @description 清除指定用户的昵称设置。
|
|
@@ -287,7 +287,7 @@ var DataManager = class {
|
|
|
287
287
|
}
|
|
288
288
|
};
|
|
289
289
|
|
|
290
|
-
// src/
|
|
290
|
+
// src/PendManager.ts
|
|
291
291
|
var import_koishi2 = require("koishi");
|
|
292
292
|
|
|
293
293
|
// src/Utils.ts
|
|
@@ -379,6 +379,10 @@ async function getNextCaveId(ctx, query = {}, reusableIds) {
|
|
|
379
379
|
}
|
|
380
380
|
__name(getNextCaveId, "getNextCaveId");
|
|
381
381
|
function checkCooldown(session, config, lastUsed) {
|
|
382
|
+
const adminChannelId = config.adminChannel?.split(":")[1];
|
|
383
|
+
if (adminChannelId && session.channelId === adminChannelId) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
382
386
|
if (config.coolDown <= 0 || !session.channelId) return null;
|
|
383
387
|
const lastTime = lastUsed.get(session.channelId) || 0;
|
|
384
388
|
const remainingTime = lastTime + config.coolDown * 1e3 - Date.now();
|
|
@@ -441,7 +445,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
|
|
|
441
445
|
const { globalHash, quadrantHashes } = await hashManager.generateAllImageHashes(buffer);
|
|
442
446
|
for (const existing of existingGlobalHashes) {
|
|
443
447
|
const similarity = hashManager.calculateSimilarity(globalHash, existing.hash);
|
|
444
|
-
if (similarity >= config.
|
|
448
|
+
if (similarity >= config.imageThreshold) {
|
|
445
449
|
await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
|
|
446
450
|
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
447
451
|
cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
|
|
@@ -466,7 +470,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
|
|
|
466
470
|
}
|
|
467
471
|
}
|
|
468
472
|
await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
469
|
-
const finalStatus = config.
|
|
473
|
+
const finalStatus = config.enablePend ? "pending" : "active";
|
|
470
474
|
await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
|
|
471
475
|
if (hashManager) {
|
|
472
476
|
const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: cave.id }));
|
|
@@ -476,7 +480,7 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
|
|
|
476
480
|
}
|
|
477
481
|
if (finalStatus === "pending" && reviewManager) {
|
|
478
482
|
const [finalCave] = await ctx.database.get("cave", { id: cave.id });
|
|
479
|
-
if (finalCave) reviewManager.
|
|
483
|
+
if (finalCave) reviewManager.sendForPend(finalCave);
|
|
480
484
|
}
|
|
481
485
|
} catch (fileProcessingError) {
|
|
482
486
|
logger2.error(`回声洞(${cave.id})文件处理失败:`, fileProcessingError);
|
|
@@ -486,8 +490,8 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
|
|
|
486
490
|
}
|
|
487
491
|
__name(handleFileUploads, "handleFileUploads");
|
|
488
492
|
|
|
489
|
-
// src/
|
|
490
|
-
var
|
|
493
|
+
// src/PendManager.ts
|
|
494
|
+
var PendManager = class {
|
|
491
495
|
/**
|
|
492
496
|
* @param ctx Koishi 上下文。
|
|
493
497
|
* @param config 插件配置。
|
|
@@ -503,7 +507,7 @@ var ReviewManager = class {
|
|
|
503
507
|
this.reusableIds = reusableIds;
|
|
504
508
|
}
|
|
505
509
|
static {
|
|
506
|
-
__name(this, "
|
|
510
|
+
__name(this, "PendManager");
|
|
507
511
|
}
|
|
508
512
|
/**
|
|
509
513
|
* @description 注册与审核相关的子命令。
|
|
@@ -516,7 +520,7 @@ var ReviewManager = class {
|
|
|
516
520
|
}
|
|
517
521
|
return null;
|
|
518
522
|
}, "requireAdmin");
|
|
519
|
-
const
|
|
523
|
+
const pend = cave.subcommand(".pend [id:posint]", "审核回声洞").action(async ({ session }, id) => {
|
|
520
524
|
const adminError = requireAdmin(session);
|
|
521
525
|
if (adminError) return adminError;
|
|
522
526
|
if (id) {
|
|
@@ -530,7 +534,7 @@ var ReviewManager = class {
|
|
|
530
534
|
return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
|
|
531
535
|
${pendingCaves.map((c) => c.id).join("|")}`;
|
|
532
536
|
});
|
|
533
|
-
const
|
|
537
|
+
const createPendAction = /* @__PURE__ */ __name((actionType) => async ({ session }, id) => {
|
|
534
538
|
const adminError = requireAdmin(session);
|
|
535
539
|
if (adminError) return adminError;
|
|
536
540
|
try {
|
|
@@ -552,23 +556,23 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
|
|
|
552
556
|
this.logger.error(`审核操作失败:`, error);
|
|
553
557
|
return `操作失败: ${error.message}`;
|
|
554
558
|
}
|
|
555
|
-
}, "
|
|
556
|
-
|
|
557
|
-
|
|
559
|
+
}, "createPendAction");
|
|
560
|
+
pend.subcommand(".Y [id:posint]", "通过审核").action(createPendAction("approve"));
|
|
561
|
+
pend.subcommand(".N [id:posint]", "拒绝审核").action(createPendAction("reject"));
|
|
558
562
|
}
|
|
559
563
|
/**
|
|
560
564
|
* @description 将新回声洞提交到管理群组以供审核。
|
|
561
565
|
* @param cave 新创建的、状态为 'pending' 的回声洞对象。
|
|
562
566
|
*/
|
|
563
|
-
async
|
|
567
|
+
async sendForPend(cave) {
|
|
564
568
|
if (!this.config.adminChannel?.includes(":")) {
|
|
565
569
|
this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`);
|
|
566
570
|
await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
|
|
567
571
|
return;
|
|
568
572
|
}
|
|
569
573
|
try {
|
|
570
|
-
const
|
|
571
|
-
await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(
|
|
574
|
+
const pendMessage = [`待审核`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
|
|
575
|
+
await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(pendMessage));
|
|
572
576
|
} catch (error) {
|
|
573
577
|
this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
|
|
574
578
|
}
|
|
@@ -624,12 +628,12 @@ var HashManager = class {
|
|
|
624
628
|
return `操作失败: ${error.message}`;
|
|
625
629
|
}
|
|
626
630
|
});
|
|
627
|
-
cave.subcommand(".check", "检查相似度").usage("检查所有回声洞,找出相似度过高的内容。").action(async (argv) => {
|
|
631
|
+
cave.subcommand(".check", "检查相似度").usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async (argv) => {
|
|
628
632
|
const checkResult = adminCheck(argv);
|
|
629
633
|
if (checkResult) return checkResult;
|
|
630
634
|
await argv.session.send("正在检查,请稍候...");
|
|
631
635
|
try {
|
|
632
|
-
return await this.checkForSimilarCaves();
|
|
636
|
+
return await this.checkForSimilarCaves(argv.options);
|
|
633
637
|
} catch (error) {
|
|
634
638
|
this.logger.error("检查相似度失败:", error);
|
|
635
639
|
return `检查失败: ${error.message}`;
|
|
@@ -716,28 +720,33 @@ var HashManager = class {
|
|
|
716
720
|
}
|
|
717
721
|
/**
|
|
718
722
|
* @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。
|
|
723
|
+
* @param options 包含临时阈值的可选对象。
|
|
719
724
|
* @returns 一个包含检查结果的报告字符串。
|
|
720
725
|
*/
|
|
721
|
-
async checkForSimilarCaves() {
|
|
726
|
+
async checkForSimilarCaves(options = {}) {
|
|
727
|
+
const textThreshold = options.textThreshold ?? this.config.textThreshold;
|
|
728
|
+
const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
|
|
722
729
|
const allHashes = await this.ctx.database.get("cave_hash", {});
|
|
723
730
|
const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
|
|
724
731
|
const textHashes = /* @__PURE__ */ new Map();
|
|
725
732
|
const globalHashes = /* @__PURE__ */ new Map();
|
|
726
|
-
const
|
|
733
|
+
const quadrantHashesByCave = /* @__PURE__ */ new Map();
|
|
734
|
+
const partialHashToCaves = /* @__PURE__ */ new Map();
|
|
727
735
|
for (const hash of allHashes) {
|
|
728
736
|
if (hash.type === "simhash") {
|
|
729
737
|
textHashes.set(hash.cave, hash.hash);
|
|
730
738
|
} else if (hash.type === "phash_g") {
|
|
731
739
|
globalHashes.set(hash.cave, hash.hash);
|
|
732
740
|
} else if (hash.type.startsWith("phash_q")) {
|
|
733
|
-
if (!
|
|
734
|
-
|
|
741
|
+
if (!quadrantHashesByCave.has(hash.cave)) quadrantHashesByCave.set(hash.cave, /* @__PURE__ */ new Set());
|
|
742
|
+
quadrantHashesByCave.get(hash.cave).add(hash.hash);
|
|
743
|
+
if (!partialHashToCaves.has(hash.hash)) partialHashToCaves.set(hash.hash, /* @__PURE__ */ new Set());
|
|
744
|
+
partialHashToCaves.get(hash.hash).add(hash.cave);
|
|
735
745
|
}
|
|
736
746
|
}
|
|
737
747
|
const similarPairs = {
|
|
738
748
|
text: /* @__PURE__ */ new Set(),
|
|
739
|
-
global: /* @__PURE__ */ new Set()
|
|
740
|
-
partial: /* @__PURE__ */ new Set()
|
|
749
|
+
global: /* @__PURE__ */ new Set()
|
|
741
750
|
};
|
|
742
751
|
for (let i = 0; i < allCaveIds.length; i++) {
|
|
743
752
|
for (let j = i + 1; j < allCaveIds.length; j++) {
|
|
@@ -748,7 +757,7 @@ var HashManager = class {
|
|
|
748
757
|
const text2 = textHashes.get(id2);
|
|
749
758
|
if (text1 && text2) {
|
|
750
759
|
const similarity = this.calculateSimilarity(text1, text2);
|
|
751
|
-
if (similarity >=
|
|
760
|
+
if (similarity >= textThreshold) {
|
|
752
761
|
similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`);
|
|
753
762
|
}
|
|
754
763
|
}
|
|
@@ -756,32 +765,46 @@ var HashManager = class {
|
|
|
756
765
|
const global2 = globalHashes.get(id2);
|
|
757
766
|
if (global1 && global2) {
|
|
758
767
|
const similarity = this.calculateSimilarity(global1, global2);
|
|
759
|
-
if (similarity >=
|
|
768
|
+
if (similarity >= imageThreshold) {
|
|
760
769
|
similarPairs.global.add(`${pair} = ${similarity.toFixed(2)}%`);
|
|
761
770
|
}
|
|
762
771
|
}
|
|
763
|
-
const quads1 = quadrantHashes.get(id1);
|
|
764
|
-
const quads2 = quadrantHashes.get(id2);
|
|
765
|
-
if (quads1 && quads2 && quads1.size > 0 && quads2.size > 0) {
|
|
766
|
-
let matchFound = false;
|
|
767
|
-
for (const h1 of quads1) {
|
|
768
|
-
if (quads2.has(h1)) {
|
|
769
|
-
matchFound = true;
|
|
770
|
-
break;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
if (matchFound) {
|
|
774
|
-
similarPairs.partial.add(pair);
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
772
|
}
|
|
778
773
|
}
|
|
779
|
-
const
|
|
774
|
+
const allPartialCaveIds = Array.from(quadrantHashesByCave.keys());
|
|
775
|
+
const parent = /* @__PURE__ */ new Map();
|
|
776
|
+
const find = /* @__PURE__ */ __name((i) => {
|
|
777
|
+
if (parent.get(i) === i) return i;
|
|
778
|
+
parent.set(i, find(parent.get(i)));
|
|
779
|
+
return parent.get(i);
|
|
780
|
+
}, "find");
|
|
781
|
+
const union = /* @__PURE__ */ __name((i, j) => {
|
|
782
|
+
const rootI = find(i);
|
|
783
|
+
const rootJ = find(j);
|
|
784
|
+
if (rootI !== rootJ) parent.set(rootI, rootJ);
|
|
785
|
+
}, "union");
|
|
786
|
+
allPartialCaveIds.forEach((id) => parent.set(id, id));
|
|
787
|
+
for (const caveIds of partialHashToCaves.values()) {
|
|
788
|
+
if (caveIds.size <= 1) continue;
|
|
789
|
+
const ids = Array.from(caveIds);
|
|
790
|
+
for (let i = 1; i < ids.length; i++) union(ids[0], ids[i]);
|
|
791
|
+
}
|
|
792
|
+
const components = /* @__PURE__ */ new Map();
|
|
793
|
+
for (const id of allPartialCaveIds) {
|
|
794
|
+
const root = find(id);
|
|
795
|
+
if (!components.has(root)) components.set(root, /* @__PURE__ */ new Set());
|
|
796
|
+
components.get(root).add(id);
|
|
797
|
+
}
|
|
798
|
+
const partialGroups = [];
|
|
799
|
+
for (const component of components.values()) {
|
|
800
|
+
if (component.size > 1) partialGroups.push(Array.from(component).sort((a, b) => a - b).join(" & "));
|
|
801
|
+
}
|
|
802
|
+
const totalFindings = similarPairs.text.size + similarPairs.global.size + partialGroups.length;
|
|
780
803
|
if (totalFindings === 0) return "未发现高相似度的内容";
|
|
781
804
|
let report = `已发现 ${totalFindings} 组高相似度的内容:`;
|
|
782
805
|
if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
|
|
783
806
|
if (similarPairs.global.size > 0) report += "\n图片整体相似:\n" + [...similarPairs.global].join("\n");
|
|
784
|
-
if (
|
|
807
|
+
if (partialGroups.length > 0) report += "\n图片局部相同:\n" + partialGroups.join("\n");
|
|
785
808
|
return report.trim();
|
|
786
809
|
}
|
|
787
810
|
/**
|
|
@@ -949,16 +972,16 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
949
972
|
import_koishi3.Schema.object({
|
|
950
973
|
coolDown: import_koishi3.Schema.number().default(10).description("冷却时间(秒)"),
|
|
951
974
|
perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
|
|
952
|
-
|
|
975
|
+
enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
|
|
953
976
|
enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
|
|
954
977
|
adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
|
|
955
978
|
caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本")
|
|
956
979
|
}).description("基础配置"),
|
|
957
980
|
import_koishi3.Schema.object({
|
|
958
|
-
|
|
981
|
+
enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
|
|
959
982
|
enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
|
|
960
983
|
textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("文本相似度阈值 (%)"),
|
|
961
|
-
|
|
984
|
+
imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("图片相似度阈值 (%)")
|
|
962
985
|
}).description("复核配置"),
|
|
963
986
|
import_koishi3.Schema.object({
|
|
964
987
|
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
@@ -984,8 +1007,8 @@ function apply(ctx, config) {
|
|
|
984
1007
|
const fileManager = new FileManager(ctx.baseDir, config, logger);
|
|
985
1008
|
const lastUsed = /* @__PURE__ */ new Map();
|
|
986
1009
|
const reusableIds = /* @__PURE__ */ new Set();
|
|
987
|
-
const profileManager = config.
|
|
988
|
-
const reviewManager = config.
|
|
1010
|
+
const profileManager = config.enableName ? new NameManager(ctx) : null;
|
|
1011
|
+
const reviewManager = config.enablePend ? new PendManager(ctx, config, fileManager, logger, reusableIds) : null;
|
|
989
1012
|
const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
|
|
990
1013
|
const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger, hashManager) : null;
|
|
991
1014
|
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 }) => {
|
|
@@ -1042,9 +1065,9 @@ function apply(ctx, config) {
|
|
|
1042
1065
|
}
|
|
1043
1066
|
}
|
|
1044
1067
|
}
|
|
1045
|
-
const userName = (config.
|
|
1068
|
+
const userName = (config.enableName ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
1046
1069
|
const hasMedia = mediaToSave.length > 0;
|
|
1047
|
-
const initialStatus = hasMedia ? "preload" : config.
|
|
1070
|
+
const initialStatus = hasMedia ? "preload" : config.enablePend ? "pending" : "active";
|
|
1048
1071
|
const newCave = await ctx.database.create("cave", {
|
|
1049
1072
|
id: newId,
|
|
1050
1073
|
elements: finalElementsForDb,
|
|
@@ -1061,10 +1084,10 @@ function apply(ctx, config) {
|
|
|
1061
1084
|
await ctx.database.upsert("cave_hash", textHashesToStore.map((h4) => ({ ...h4, cave: newCave.id })));
|
|
1062
1085
|
}
|
|
1063
1086
|
if (initialStatus === "pending") {
|
|
1064
|
-
reviewManager.
|
|
1087
|
+
reviewManager.sendForPend(newCave);
|
|
1065
1088
|
}
|
|
1066
1089
|
}
|
|
1067
|
-
return initialStatus === "pending" || initialStatus === "preload" && config.
|
|
1090
|
+
return initialStatus === "pending" || initialStatus === "preload" && config.enablePend ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`;
|
|
1068
1091
|
} catch (error) {
|
|
1069
1092
|
logger.error("添加回声洞失败:", error);
|
|
1070
1093
|
return "添加失败,请稍后再试";
|
|
@@ -1101,17 +1124,47 @@ function apply(ctx, config) {
|
|
|
1101
1124
|
return "删除失败,请稍后再试";
|
|
1102
1125
|
}
|
|
1103
1126
|
});
|
|
1104
|
-
cave.subcommand(".list", "
|
|
1105
|
-
|
|
1106
|
-
const
|
|
1107
|
-
if (
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1127
|
+
cave.subcommand(".list", "查询投稿统计").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => {
|
|
1128
|
+
if (options.all) {
|
|
1129
|
+
const adminChannelId = config.adminChannel?.split(":")[1];
|
|
1130
|
+
if (session.channelId !== adminChannelId) {
|
|
1131
|
+
return "此指令仅限在管理群组中使用";
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
const allCaves = await ctx.database.get("cave", { status: "active" });
|
|
1135
|
+
if (!allCaves.length) return "目前没有任何回声洞投稿。";
|
|
1136
|
+
const userStats = /* @__PURE__ */ new Map();
|
|
1137
|
+
for (const cave2 of allCaves) {
|
|
1138
|
+
const { userId, userName: userName2 } = cave2;
|
|
1139
|
+
const stat = userStats.get(userId);
|
|
1140
|
+
if (stat) {
|
|
1141
|
+
stat.count++;
|
|
1142
|
+
stat.userName = userName2;
|
|
1143
|
+
} else {
|
|
1144
|
+
userStats.set(userId, { userName: userName2, count: 1 });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const sortedStats = Array.from(userStats.values()).sort((a, b) => b.count - a.count);
|
|
1148
|
+
let report = "回声洞投稿数量排行:\n";
|
|
1149
|
+
sortedStats.forEach((stat, index) => {
|
|
1150
|
+
report += `${index + 1}. ${stat.userName}: ${stat.count} 条
|
|
1151
|
+
`;
|
|
1152
|
+
});
|
|
1153
|
+
return report.trim();
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
logger.error("查询排行失败:", error);
|
|
1156
|
+
return "查询失败,请稍后再试";
|
|
1157
|
+
}
|
|
1114
1158
|
}
|
|
1159
|
+
const targetUserId = options.user || session.userId;
|
|
1160
|
+
const isQueryingSelf = !options.user;
|
|
1161
|
+
const query = { ...getScopeQuery(session, config), userId: targetUserId };
|
|
1162
|
+
const userCaves = await ctx.database.get("cave", query);
|
|
1163
|
+
if (!userCaves.length) return isQueryingSelf ? "你还没有投稿过回声洞" : `用户 ${targetUserId} 还没有投稿过回声洞`;
|
|
1164
|
+
const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("|");
|
|
1165
|
+
const userName = userCaves.sort((a, b) => b.time.getTime() - a.time.getTime())[0].userName;
|
|
1166
|
+
return `${isQueryingSelf ? "你" : userName}已投稿 ${userCaves.length} 条回声洞,序号为:
|
|
1167
|
+
${caveIds}`;
|
|
1115
1168
|
});
|
|
1116
1169
|
if (profileManager) profileManager.registerCommands(cave);
|
|
1117
1170
|
if (dataManager) dataManager.registerCommands(cave);
|