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