koishi-plugin-best-cave 2.0.3 → 2.0.5
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 +5 -8
- package/lib/FileManager.d.ts +59 -0
- package/lib/ProfileManager.d.ts +50 -0
- package/lib/ReviewManager.d.ts +52 -0
- package/lib/Utils.d.ts +40 -46
- package/lib/index.d.ts +13 -17
- package/lib/index.js +290 -322
- package/package.json +1 -1
package/lib/DataManager.d.ts
CHANGED
|
@@ -2,9 +2,8 @@ import { Context, Logger } from 'koishi';
|
|
|
2
2
|
import { FileManager } from './FileManager';
|
|
3
3
|
import { Config } from './index';
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* @description
|
|
7
|
-
* 负责处理回声洞数据的导入和导出功能。
|
|
5
|
+
* @class DataManager
|
|
6
|
+
* @description 负责处理回声洞数据的导入和导出功能,提供数据迁移和备份的能力。
|
|
8
7
|
*/
|
|
9
8
|
export declare class DataManager {
|
|
10
9
|
private ctx;
|
|
@@ -12,21 +11,19 @@ export declare class DataManager {
|
|
|
12
11
|
private fileManager;
|
|
13
12
|
private logger;
|
|
14
13
|
/**
|
|
15
|
-
* 创建一个 DataManager 实例。
|
|
16
14
|
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
17
|
-
* @param config -
|
|
15
|
+
* @param config - 插件配置对象。
|
|
18
16
|
* @param fileManager - 文件管理器实例,用于读写导入/导出文件。
|
|
19
17
|
* @param logger - 日志记录器实例。
|
|
20
18
|
*/
|
|
21
19
|
constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger);
|
|
22
20
|
/**
|
|
23
21
|
* 注册与数据导入导出相关的 `.export` 和 `.import` 子命令。
|
|
24
|
-
* @param cave - 主 `cave`
|
|
22
|
+
* @param cave - 主 `cave` 命令实例,用于挂载子命令。
|
|
25
23
|
*/
|
|
26
24
|
registerCommands(cave: any): void;
|
|
27
25
|
/**
|
|
28
|
-
* 导出所有状态为 'active'
|
|
29
|
-
* 数据将被序列化为 JSON 并保存到 `cave_export.json` 文件中。
|
|
26
|
+
* 导出所有状态为 'active' 的回声洞数据到 `cave_export.json` 文件。
|
|
30
27
|
* @returns 一个描述导出结果的字符串消息。
|
|
31
28
|
*/
|
|
32
29
|
exportData(): Promise<string>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Logger } from 'koishi';
|
|
2
|
+
import { Config } from './index';
|
|
3
|
+
/**
|
|
4
|
+
* @class FileManager
|
|
5
|
+
* @description 对文件资源的存储、读取和删除操作。
|
|
6
|
+
* 内置了基于 Promise 的文件锁,以防止对本地文件的并发写入冲突。
|
|
7
|
+
*/
|
|
8
|
+
export declare class FileManager {
|
|
9
|
+
private logger;
|
|
10
|
+
private resourceDir;
|
|
11
|
+
private locks;
|
|
12
|
+
private s3Client?;
|
|
13
|
+
private s3Bucket?;
|
|
14
|
+
/**
|
|
15
|
+
* @param baseDir - Koishi 应用的基础数据目录 (ctx.baseDir)。
|
|
16
|
+
* @param config - 插件的配置对象。
|
|
17
|
+
* @param logger - 日志记录器实例。
|
|
18
|
+
*/
|
|
19
|
+
constructor(baseDir: string, config: Config, logger: Logger);
|
|
20
|
+
/**
|
|
21
|
+
* 确保本地资源目录存在,若不存在则以递归方式创建。
|
|
22
|
+
* @private
|
|
23
|
+
*/
|
|
24
|
+
private ensureDirectory;
|
|
25
|
+
/**
|
|
26
|
+
* 获取给定文件名的完整本地路径。
|
|
27
|
+
* @param fileName - 文件名。
|
|
28
|
+
* @returns 文件的绝对路径。
|
|
29
|
+
* @private
|
|
30
|
+
*/
|
|
31
|
+
private getFullPath;
|
|
32
|
+
/**
|
|
33
|
+
* 使用文件锁安全地执行一个异步文件操作,防止对同一文件的并发访问。
|
|
34
|
+
* @template T - 异步操作的返回类型。
|
|
35
|
+
* @param fileName - 需要加锁的文件名。
|
|
36
|
+
* @param operation - 要执行的异步函数。
|
|
37
|
+
* @returns 返回异步操作的结果。
|
|
38
|
+
* @private
|
|
39
|
+
*/
|
|
40
|
+
private withLock;
|
|
41
|
+
/**
|
|
42
|
+
* 保存文件,自动路由到 S3 或本地存储。
|
|
43
|
+
* @param fileName - 文件名,将用作 S3 Key 或本地文件名。
|
|
44
|
+
* @param data - 要写入的 Buffer 数据。
|
|
45
|
+
* @returns 返回保存时使用的文件名。
|
|
46
|
+
*/
|
|
47
|
+
saveFile(fileName: string, data: Buffer): Promise<string>;
|
|
48
|
+
/**
|
|
49
|
+
* 读取文件,自动从 S3 或本地存储获取。
|
|
50
|
+
* @param fileName - 要读取的文件名。
|
|
51
|
+
* @returns 文件的 Buffer 数据。
|
|
52
|
+
*/
|
|
53
|
+
readFile(fileName: string): Promise<Buffer>;
|
|
54
|
+
/**
|
|
55
|
+
* 删除文件,自动从 S3 或本地存储删除。
|
|
56
|
+
* @param fileName - 要删除的文件名。
|
|
57
|
+
*/
|
|
58
|
+
deleteFile(fileName: string): Promise<void>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
/**
|
|
3
|
+
* @interface UserProfile
|
|
4
|
+
* @description 数据库中 `cave_user` 表的记录结构。
|
|
5
|
+
* @property {string} userId - 用户的唯一 ID,作为主键。
|
|
6
|
+
* @property {string} nickname - 用户在回声洞中显示的自定义昵称。
|
|
7
|
+
*/
|
|
8
|
+
export interface UserProfile {
|
|
9
|
+
userId: string;
|
|
10
|
+
nickname: string;
|
|
11
|
+
}
|
|
12
|
+
declare module 'koishi' {
|
|
13
|
+
interface Tables {
|
|
14
|
+
cave_user: UserProfile;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* @class ProfileManager
|
|
19
|
+
* @description 负责管理用户在回声洞插件中的自定义昵称。
|
|
20
|
+
* 提供设置、获取和清除昵称的数据库操作和相关命令。
|
|
21
|
+
*/
|
|
22
|
+
export declare class ProfileManager {
|
|
23
|
+
private ctx;
|
|
24
|
+
/**
|
|
25
|
+
* @param ctx - Koishi 上下文,用于数据库交互。
|
|
26
|
+
*/
|
|
27
|
+
constructor(ctx: Context);
|
|
28
|
+
/**
|
|
29
|
+
* 注册与用户昵称相关的 `.profile` 子命令。
|
|
30
|
+
* @param cave - 主 `cave` 命令实例,用于挂载子命令。
|
|
31
|
+
*/
|
|
32
|
+
registerCommands(cave: any): void;
|
|
33
|
+
/**
|
|
34
|
+
* 设置或更新指定用户的昵称。
|
|
35
|
+
* @param userId - 目标用户的 ID。
|
|
36
|
+
* @param nickname - 要设置的新昵称。
|
|
37
|
+
*/
|
|
38
|
+
setNickname(userId: string, nickname: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* 获取指定用户的昵称。
|
|
41
|
+
* @param userId - 目标用户的 ID。
|
|
42
|
+
* @returns 返回用户的昵称字符串,如果用户未设置则返回 null。
|
|
43
|
+
*/
|
|
44
|
+
getNickname(userId: string): Promise<string | null>;
|
|
45
|
+
/**
|
|
46
|
+
* 清除指定用户的昵称设置。
|
|
47
|
+
* @param userId - 目标用户的 ID。
|
|
48
|
+
*/
|
|
49
|
+
clearNickname(userId: string): Promise<void>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Context, h, Logger } from 'koishi';
|
|
2
|
+
import { CaveObject, Config } from './index';
|
|
3
|
+
import { FileManager } from './FileManager';
|
|
4
|
+
/**
|
|
5
|
+
* @class ReviewManager
|
|
6
|
+
* @description 负责处理回声洞的审核流程。
|
|
7
|
+
* 此管理器将处理新回声洞的提交、向管理员发送审核通知,并响应管理员的审核操作。
|
|
8
|
+
*/
|
|
9
|
+
export declare class ReviewManager {
|
|
10
|
+
private ctx;
|
|
11
|
+
private config;
|
|
12
|
+
private fileManager;
|
|
13
|
+
private logger;
|
|
14
|
+
/**
|
|
15
|
+
* @param ctx - Koishi 上下文。
|
|
16
|
+
* @param config - 插件配置对象。
|
|
17
|
+
* @param fileManager - 文件管理器实例。
|
|
18
|
+
* @param logger - 日志记录器实例。
|
|
19
|
+
*/
|
|
20
|
+
constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger);
|
|
21
|
+
/**
|
|
22
|
+
* 注册与审核相关的 `.review` 子命令。
|
|
23
|
+
* @param cave - 主 `cave` 命令实例,用于挂载子命令。
|
|
24
|
+
*/
|
|
25
|
+
registerCommands(cave: any): void;
|
|
26
|
+
/**
|
|
27
|
+
* 列出所有待审核的回声洞。
|
|
28
|
+
* @private
|
|
29
|
+
*/
|
|
30
|
+
private listPendingCaves;
|
|
31
|
+
/**
|
|
32
|
+
* 将一条新回声洞提交给管理员进行审核。
|
|
33
|
+
* 如果没有配置管理员,将自动通过审核。
|
|
34
|
+
* @param cave - 新创建的、状态为 'pending' 的回声洞对象。
|
|
35
|
+
*/
|
|
36
|
+
sendForReview(cave: CaveObject): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* 构建用于发送给管理员的审核消息。
|
|
39
|
+
* @param cave - 待审核的回声洞对象。
|
|
40
|
+
* @returns 一个可直接发送的消息数组。
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
private buildReviewMessage;
|
|
44
|
+
/**
|
|
45
|
+
* 处理管理员的审核决定(通过或拒绝)。
|
|
46
|
+
* @param action - 'approve' (通过) 或 'reject' (拒绝)。
|
|
47
|
+
* @param cave - 被审核的回声洞对象。
|
|
48
|
+
* @param adminUserName - 执行操作的管理员昵称。
|
|
49
|
+
* @returns 返回给操作者的确认消息。
|
|
50
|
+
*/
|
|
51
|
+
processReview(action: 'approve' | 'reject', cave: CaveObject, adminUserName: string): Promise<string | (string | h)[]>;
|
|
52
|
+
}
|
package/lib/Utils.d.ts
CHANGED
|
@@ -2,78 +2,72 @@ import { Context, h, Logger, Session } from 'koishi';
|
|
|
2
2
|
import { CaveObject, Config, StoredElement } from './index';
|
|
3
3
|
import { FileManager } from './FileManager';
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* @param elements -
|
|
7
|
-
* @returns
|
|
5
|
+
* 将数据库存储格式的元素数组转换为 Koishi 消息元素 (h-element) 数组。
|
|
6
|
+
* @param elements - StoredElement 对象数组。
|
|
7
|
+
* @returns 可用于发送的 h-element 数组。
|
|
8
8
|
*/
|
|
9
9
|
export declare function storedFormatToHElements(elements: StoredElement[]): h[];
|
|
10
10
|
/**
|
|
11
|
-
* 将指向本地媒体文件的 h
|
|
12
|
-
* @param element -
|
|
11
|
+
* 将指向本地媒体文件的 h-element 转换为内联 Base64 数据。
|
|
12
|
+
* @param element - 包含本地文件路径的媒体 h-element。
|
|
13
13
|
* @param fileManager - FileManager 实例,用于读取文件。
|
|
14
|
-
* @param logger - Logger
|
|
15
|
-
* @returns 转换后的 h
|
|
14
|
+
* @param logger - Logger 实例,用于记录日志。
|
|
15
|
+
* @returns 转换后的 h-element,其 src 为 Base64 数据 URI,或在失败时返回一个文本提示。
|
|
16
16
|
*/
|
|
17
17
|
export declare function mediaElementToBase64(element: h, fileManager: FileManager, logger: Logger): Promise<h>;
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
* 此函数会处理 S3 URL、文件映射路径或本地文件到 Base64 的转换。
|
|
19
|
+
* 根据配置构建一条包含回声洞内容的完整可发送消息。
|
|
21
20
|
* @param cave - 要展示的回声洞对象。
|
|
22
|
-
* @param config -
|
|
21
|
+
* @param config - 插件配置对象。
|
|
23
22
|
* @param fileManager - FileManager 实例。
|
|
24
23
|
* @param logger - Logger 实例。
|
|
25
|
-
* @returns
|
|
24
|
+
* @returns 一个包含字符串和 h-element 的消息数组。
|
|
26
25
|
*/
|
|
27
26
|
export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
|
|
28
27
|
/**
|
|
29
|
-
*
|
|
28
|
+
* 遍历并转换消息元素为可存储格式,同时识别待下载的媒体文件。
|
|
29
|
+
* @param sourceElements - 源消息中的 h-element 数组。
|
|
30
|
+
* @param newId - 新回声洞的 ID。
|
|
31
|
+
* @param channelId - 频道 ID。
|
|
32
|
+
* @param userId - 用户 ID。
|
|
33
|
+
* @returns 一个包含待存储元素和待下载媒体列表的对象。
|
|
34
|
+
*/
|
|
35
|
+
export declare function prepareElementsForStorage(sourceElements: h[], newId: number, channelId: string, userId: string): {
|
|
36
|
+
finalElementsForDb: StoredElement[];
|
|
37
|
+
mediaToDownload: {
|
|
38
|
+
url: string;
|
|
39
|
+
fileName: string;
|
|
40
|
+
}[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 清理数据库中所有标记为 'delete' 状态的回声洞及其关联的文件。
|
|
30
44
|
* @param ctx - Koishi 上下文。
|
|
31
45
|
* @param fileManager - FileManager 实例,用于删除文件。
|
|
32
46
|
* @param logger - Logger 实例。
|
|
33
47
|
*/
|
|
34
48
|
export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger): Promise<void>;
|
|
35
49
|
/**
|
|
36
|
-
*
|
|
37
|
-
* @param session -
|
|
38
|
-
* @param config -
|
|
39
|
-
* @returns
|
|
40
|
-
*/
|
|
41
|
-
export declare function getScopeQuery(session: Session, config: Config): object;
|
|
42
|
-
/**
|
|
43
|
-
* 获取下一个可用的回声洞 ID。
|
|
44
|
-
* 策略是找到当前已存在的 ID 中最小的未使用正整数。
|
|
45
|
-
* @param ctx - Koishi 上下文。
|
|
46
|
-
* @param query - 查询回声洞的范围条件,用于分群模式。
|
|
47
|
-
* @returns 一个可用的新 ID。
|
|
48
|
-
* @performance 对于非常大的数据集,此函数可能会有性能瓶颈,因为它需要获取所有现有 ID。
|
|
49
|
-
*/
|
|
50
|
-
export declare function getNextCaveId(ctx: Context, query?: object): Promise<number>;
|
|
51
|
-
/**
|
|
52
|
-
* 下载网络媒体资源并保存到文件存储中(本地或 S3)。
|
|
53
|
-
* @param ctx - Koishi 上下文。
|
|
54
|
-
* @param fileManager - FileManager 实例。
|
|
55
|
-
* @param url - 媒体资源的 URL。
|
|
56
|
-
* @param originalName - 原始文件名,用于获取扩展名。
|
|
57
|
-
* @param type - 媒体类型 ('img', 'video', 'audio', 'file')。
|
|
58
|
-
* @param caveId - 新建回声洞的 ID。
|
|
59
|
-
* @param index - 媒体在消息中的索引。
|
|
60
|
-
* @param channelId - 频道 ID。
|
|
61
|
-
* @param userId - 用户 ID。
|
|
62
|
-
* @returns 保存后的文件名/标识符。
|
|
50
|
+
* 根据插件配置和会话上下文,生成数据库查询的作用域条件。
|
|
51
|
+
* @param session - 当前 Koishi 会话对象。
|
|
52
|
+
* @param config - 插件配置对象。
|
|
53
|
+
* @returns 一个用于数据库查询的条件对象,其类型已精确定义。
|
|
63
54
|
*/
|
|
64
|
-
export declare function
|
|
55
|
+
export declare function getScopeQuery(session: Session, config: Config): {
|
|
56
|
+
status: 'active';
|
|
57
|
+
channelId?: string;
|
|
58
|
+
};
|
|
65
59
|
/**
|
|
66
|
-
*
|
|
67
|
-
* @param session -
|
|
68
|
-
* @param config -
|
|
60
|
+
* 检查用户在当前频道是否处于指令冷却中。
|
|
61
|
+
* @param session - 当前 Koishi 会话对象。
|
|
62
|
+
* @param config - 插件配置对象。
|
|
69
63
|
* @param lastUsed - 存储各频道最后使用时间的 Map。
|
|
70
|
-
* @returns
|
|
64
|
+
* @returns 若处于冷却中,返回提示信息字符串;否则返回 null。
|
|
71
65
|
*/
|
|
72
66
|
export declare function checkCooldown(session: Session, config: Config, lastUsed: Map<string, number>): string | null;
|
|
73
67
|
/**
|
|
74
68
|
* 更新指定频道的指令使用时间戳。
|
|
75
|
-
* @param session -
|
|
76
|
-
* @param config -
|
|
69
|
+
* @param session - 当前 Koishi 会话对象。
|
|
70
|
+
* @param config - 插件配置对象。
|
|
77
71
|
* @param lastUsed - 存储各频道最后使用时间的 Map。
|
|
78
72
|
*/
|
|
79
73
|
export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
|
package/lib/index.d.ts
CHANGED
|
@@ -3,25 +3,27 @@ export declare const name = "best-cave";
|
|
|
3
3
|
export declare const inject: string[];
|
|
4
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\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
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* @
|
|
6
|
+
* @interface StoredElement
|
|
7
|
+
* @description 单个消息元素的数据库存储格式。
|
|
8
|
+
* @property type - 元素类型: 'text', 'image', 'video', 'audio', 'file'。
|
|
8
9
|
* @property content - 文本内容,仅用于 'text' 类型。
|
|
9
|
-
* @property file -
|
|
10
|
+
* @property file - 文件标识符 (本地文件名或 S3 Key),用于媒体类型。
|
|
10
11
|
*/
|
|
11
12
|
export interface StoredElement {
|
|
12
|
-
type: 'text' | '
|
|
13
|
+
type: 'text' | 'image' | 'video' | 'audio' | 'file';
|
|
13
14
|
content?: string;
|
|
14
15
|
file?: string;
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
* @
|
|
19
|
-
* @property
|
|
18
|
+
* @interface CaveObject
|
|
19
|
+
* @description `cave` 数据表的完整对象模型。
|
|
20
|
+
* @property id - 回声洞的唯一数字 ID (主键)。
|
|
21
|
+
* @property elements - 构成回声洞内容的 StoredElement 数组。
|
|
20
22
|
* @property channelId - 提交回声洞的频道 ID,若为私聊则为 null。
|
|
21
23
|
* @property userId - 提交用户的 ID。
|
|
22
24
|
* @property userName - 提交用户的昵称。
|
|
23
25
|
* @property status - 回声洞状态: 'active' (活跃), 'delete' (待删除), 'pending' (待审核)。
|
|
24
|
-
* @property time -
|
|
26
|
+
* @property time - 提交的时间戳。
|
|
25
27
|
*/
|
|
26
28
|
export interface CaveObject {
|
|
27
29
|
id: number;
|
|
@@ -37,9 +39,6 @@ declare module 'koishi' {
|
|
|
37
39
|
cave: CaveObject;
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
|
-
/**
|
|
41
|
-
* 插件的配置接口。
|
|
42
|
-
*/
|
|
43
42
|
export interface Config {
|
|
44
43
|
coolDown: number;
|
|
45
44
|
perChannel: boolean;
|
|
@@ -50,19 +49,16 @@ export interface Config {
|
|
|
50
49
|
caveFormat: string;
|
|
51
50
|
localPath?: string;
|
|
52
51
|
enableS3: boolean;
|
|
52
|
+
publicUrl?: string;
|
|
53
53
|
endpoint?: string;
|
|
54
|
+
bucket?: string;
|
|
54
55
|
region?: string;
|
|
55
56
|
accessKeyId?: string;
|
|
56
57
|
secretAccessKey?: string;
|
|
57
|
-
bucket?: string;
|
|
58
|
-
publicUrl?: string;
|
|
59
58
|
}
|
|
60
|
-
/**
|
|
61
|
-
* 使用 Koishi Schema 定义插件的配置项,用于生成配置界面。
|
|
62
|
-
*/
|
|
63
59
|
export declare const Config: Schema<Config>;
|
|
64
60
|
/**
|
|
65
|
-
*
|
|
61
|
+
* 插件主逻辑入口。
|
|
66
62
|
* @param ctx - Koishi 上下文。
|
|
67
63
|
* @param config - 用户提供的插件配置。
|
|
68
64
|
*/
|
package/lib/index.js
CHANGED
|
@@ -37,7 +37,7 @@ __export(index_exports, {
|
|
|
37
37
|
usage: () => usage
|
|
38
38
|
});
|
|
39
39
|
module.exports = __toCommonJS(index_exports);
|
|
40
|
-
var
|
|
40
|
+
var import_koishi2 = require("koishi");
|
|
41
41
|
|
|
42
42
|
// src/FileManager.ts
|
|
43
43
|
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
@@ -45,9 +45,8 @@ var fs = __toESM(require("fs/promises"));
|
|
|
45
45
|
var path = __toESM(require("path"));
|
|
46
46
|
var FileManager = class {
|
|
47
47
|
/**
|
|
48
|
-
* 创建一个 FileManager 实例。
|
|
49
48
|
* @param baseDir - Koishi 应用的基础数据目录 (ctx.baseDir)。
|
|
50
|
-
* @param config -
|
|
49
|
+
* @param config - 插件的配置对象。
|
|
51
50
|
* @param logger - 日志记录器实例。
|
|
52
51
|
*/
|
|
53
52
|
constructor(baseDir, config, logger2) {
|
|
@@ -68,24 +67,19 @@ var FileManager = class {
|
|
|
68
67
|
static {
|
|
69
68
|
__name(this, "FileManager");
|
|
70
69
|
}
|
|
71
|
-
// 本地资源存储目录的绝对路径。
|
|
72
70
|
resourceDir;
|
|
73
|
-
// 本地文件锁,键为文件绝对路径,值为一个 Promise,用于防止对同一文件的并发访问。
|
|
74
71
|
locks = /* @__PURE__ */ new Map();
|
|
75
|
-
// S3 客户端实例,仅在启用 S3 时初始化。
|
|
76
72
|
s3Client;
|
|
77
|
-
// S3 存储桶名称。
|
|
78
73
|
s3Bucket;
|
|
79
74
|
/**
|
|
80
|
-
*
|
|
81
|
-
* 这是一个幂等操作。
|
|
75
|
+
* 确保本地资源目录存在,若不存在则以递归方式创建。
|
|
82
76
|
* @private
|
|
83
77
|
*/
|
|
84
78
|
async ensureDirectory() {
|
|
85
79
|
try {
|
|
86
80
|
await fs.mkdir(this.resourceDir, { recursive: true });
|
|
87
81
|
} catch (error) {
|
|
88
|
-
this.logger.error(
|
|
82
|
+
this.logger.error(`Failed to create resource directory ${this.resourceDir}:`, error);
|
|
89
83
|
throw error;
|
|
90
84
|
}
|
|
91
85
|
}
|
|
@@ -99,8 +93,7 @@ var FileManager = class {
|
|
|
99
93
|
return path.join(this.resourceDir, fileName);
|
|
100
94
|
}
|
|
101
95
|
/**
|
|
102
|
-
*
|
|
103
|
-
* 这可以防止对同一文件的并发读写造成数据损坏。
|
|
96
|
+
* 使用文件锁安全地执行一个异步文件操作,防止对同一文件的并发访问。
|
|
104
97
|
* @template T - 异步操作的返回类型。
|
|
105
98
|
* @param fileName - 需要加锁的文件名。
|
|
106
99
|
* @param operation - 要执行的异步函数。
|
|
@@ -110,19 +103,24 @@ var FileManager = class {
|
|
|
110
103
|
async withLock(fileName, operation) {
|
|
111
104
|
const fullPath = this.getFullPath(fileName);
|
|
112
105
|
while (this.locks.has(fullPath)) {
|
|
113
|
-
await this.locks.get(fullPath)
|
|
106
|
+
await this.locks.get(fullPath).catch(() => {
|
|
107
|
+
});
|
|
114
108
|
}
|
|
115
|
-
const promise = operation()
|
|
116
|
-
this.locks.delete(fullPath);
|
|
117
|
-
});
|
|
109
|
+
const promise = operation();
|
|
118
110
|
this.locks.set(fullPath, promise);
|
|
119
|
-
|
|
111
|
+
try {
|
|
112
|
+
return await promise;
|
|
113
|
+
} finally {
|
|
114
|
+
if (this.locks.get(fullPath) === promise) {
|
|
115
|
+
this.locks.delete(fullPath);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
120
118
|
}
|
|
121
119
|
/**
|
|
122
|
-
*
|
|
123
|
-
* @param fileName - 文件名,将用作 S3
|
|
120
|
+
* 保存文件,自动路由到 S3 或本地存储。
|
|
121
|
+
* @param fileName - 文件名,将用作 S3 Key 或本地文件名。
|
|
124
122
|
* @param data - 要写入的 Buffer 数据。
|
|
125
|
-
* @returns
|
|
123
|
+
* @returns 返回保存时使用的文件名。
|
|
126
124
|
*/
|
|
127
125
|
async saveFile(fileName, data) {
|
|
128
126
|
if (this.s3Client) {
|
|
@@ -131,20 +129,18 @@ var FileManager = class {
|
|
|
131
129
|
Key: fileName,
|
|
132
130
|
Body: data,
|
|
133
131
|
ACL: "public-read"
|
|
134
|
-
// 默认将文件权限设置为公开可读,方便通过 URL 访问。
|
|
135
132
|
});
|
|
136
133
|
await this.s3Client.send(command);
|
|
137
|
-
return fileName;
|
|
138
134
|
} else {
|
|
139
135
|
await this.ensureDirectory();
|
|
140
136
|
const filePath = this.getFullPath(fileName);
|
|
141
137
|
await this.withLock(fileName, () => fs.writeFile(filePath, data));
|
|
142
|
-
return fileName;
|
|
143
138
|
}
|
|
139
|
+
return fileName;
|
|
144
140
|
}
|
|
145
141
|
/**
|
|
146
|
-
* 读取文件,自动从 S3
|
|
147
|
-
* @param fileName -
|
|
142
|
+
* 读取文件,自动从 S3 或本地存储获取。
|
|
143
|
+
* @param fileName - 要读取的文件名。
|
|
148
144
|
* @returns 文件的 Buffer 数据。
|
|
149
145
|
*/
|
|
150
146
|
async readFile(fileName) {
|
|
@@ -162,26 +158,26 @@ var FileManager = class {
|
|
|
162
158
|
}
|
|
163
159
|
}
|
|
164
160
|
/**
|
|
165
|
-
* 删除文件,自动从 S3
|
|
166
|
-
* @param
|
|
161
|
+
* 删除文件,自动从 S3 或本地存储删除。
|
|
162
|
+
* @param fileName - 要删除的文件名。
|
|
167
163
|
*/
|
|
168
|
-
async deleteFile(
|
|
164
|
+
async deleteFile(fileName) {
|
|
169
165
|
if (this.s3Client) {
|
|
170
166
|
const command = new import_client_s3.DeleteObjectCommand({
|
|
171
167
|
Bucket: this.s3Bucket,
|
|
172
|
-
Key:
|
|
168
|
+
Key: fileName
|
|
173
169
|
});
|
|
174
170
|
await this.s3Client.send(command).catch((err) => {
|
|
175
|
-
this.logger.warn(`删除文件 ${
|
|
171
|
+
this.logger.warn(`删除文件 ${fileName} 失败:`, err);
|
|
176
172
|
});
|
|
177
173
|
} else {
|
|
178
|
-
const filePath = this.getFullPath(
|
|
179
|
-
await this.withLock(
|
|
174
|
+
const filePath = this.getFullPath(fileName);
|
|
175
|
+
await this.withLock(fileName, async () => {
|
|
180
176
|
try {
|
|
181
177
|
await fs.unlink(filePath);
|
|
182
178
|
} catch (error) {
|
|
183
179
|
if (error.code !== "ENOENT") {
|
|
184
|
-
this.logger.warn(`删除文件 ${
|
|
180
|
+
this.logger.warn(`删除文件 ${fileName} 失败:`, error);
|
|
185
181
|
}
|
|
186
182
|
}
|
|
187
183
|
});
|
|
@@ -192,19 +188,15 @@ var FileManager = class {
|
|
|
192
188
|
// src/ProfileManager.ts
|
|
193
189
|
var ProfileManager = class {
|
|
194
190
|
/**
|
|
195
|
-
*
|
|
196
|
-
* @param ctx - Koishi 上下文,用于初始化数据库模型。
|
|
191
|
+
* @param ctx - Koishi 上下文,用于数据库交互。
|
|
197
192
|
*/
|
|
198
193
|
constructor(ctx) {
|
|
199
194
|
this.ctx = ctx;
|
|
200
195
|
this.ctx.model.extend("cave_user", {
|
|
201
196
|
userId: "string",
|
|
202
|
-
// 用户 ID
|
|
203
197
|
nickname: "string"
|
|
204
|
-
// 用户自定义昵称
|
|
205
198
|
}, {
|
|
206
199
|
primary: "userId"
|
|
207
|
-
// 使用 userId 作为主键,确保每个用户只有一条昵称记录。
|
|
208
200
|
});
|
|
209
201
|
}
|
|
210
202
|
static {
|
|
@@ -212,7 +204,7 @@ var ProfileManager = class {
|
|
|
212
204
|
}
|
|
213
205
|
/**
|
|
214
206
|
* 注册与用户昵称相关的 `.profile` 子命令。
|
|
215
|
-
* @param cave - 主 `cave`
|
|
207
|
+
* @param cave - 主 `cave` 命令实例,用于挂载子命令。
|
|
216
208
|
*/
|
|
217
209
|
registerCommands(cave) {
|
|
218
210
|
cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置你在回声洞中显示的昵称。不提供昵称则清除记录。").action(async ({ session }, nickname) => {
|
|
@@ -231,19 +223,16 @@ var ProfileManager = class {
|
|
|
231
223
|
* @param nickname - 要设置的新昵称。
|
|
232
224
|
*/
|
|
233
225
|
async setNickname(userId, nickname) {
|
|
234
|
-
await this.ctx.database.upsert("cave_user", [{
|
|
235
|
-
userId,
|
|
236
|
-
nickname
|
|
237
|
-
}]);
|
|
226
|
+
await this.ctx.database.upsert("cave_user", [{ userId, nickname }]);
|
|
238
227
|
}
|
|
239
228
|
/**
|
|
240
229
|
* 获取指定用户的昵称。
|
|
241
230
|
* @param userId - 目标用户的 ID。
|
|
242
|
-
* @returns
|
|
231
|
+
* @returns 返回用户的昵称字符串,如果用户未设置则返回 null。
|
|
243
232
|
*/
|
|
244
233
|
async getNickname(userId) {
|
|
245
|
-
const
|
|
246
|
-
return
|
|
234
|
+
const [profile] = await this.ctx.database.get("cave_user", { userId });
|
|
235
|
+
return profile?.nickname || null;
|
|
247
236
|
}
|
|
248
237
|
/**
|
|
249
238
|
* 清除指定用户的昵称设置。
|
|
@@ -254,6 +243,102 @@ var ProfileManager = class {
|
|
|
254
243
|
}
|
|
255
244
|
};
|
|
256
245
|
|
|
246
|
+
// src/DataManager.ts
|
|
247
|
+
var DataManager = class {
|
|
248
|
+
/**
|
|
249
|
+
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
250
|
+
* @param config - 插件配置对象。
|
|
251
|
+
* @param fileManager - 文件管理器实例,用于读写导入/导出文件。
|
|
252
|
+
* @param logger - 日志记录器实例。
|
|
253
|
+
*/
|
|
254
|
+
constructor(ctx, config, fileManager, logger2) {
|
|
255
|
+
this.ctx = ctx;
|
|
256
|
+
this.config = config;
|
|
257
|
+
this.fileManager = fileManager;
|
|
258
|
+
this.logger = logger2;
|
|
259
|
+
}
|
|
260
|
+
static {
|
|
261
|
+
__name(this, "DataManager");
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 注册与数据导入导出相关的 `.export` 和 `.import` 子命令。
|
|
265
|
+
* @param cave - 主 `cave` 命令实例,用于挂载子命令。
|
|
266
|
+
*/
|
|
267
|
+
registerCommands(cave) {
|
|
268
|
+
cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(async ({ session }) => {
|
|
269
|
+
if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限导出数据";
|
|
270
|
+
try {
|
|
271
|
+
await session.send("正在导出数据,请稍候...");
|
|
272
|
+
return await this.exportData();
|
|
273
|
+
} catch (error) {
|
|
274
|
+
this.logger.error("导出数据时发生错误:", error);
|
|
275
|
+
return `导出失败: ${error.message}`;
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(async ({ session }) => {
|
|
279
|
+
if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限导入数据";
|
|
280
|
+
try {
|
|
281
|
+
await session.send("正在导入数据,请稍候...");
|
|
282
|
+
return await this.importData();
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.logger.error("导入数据时发生错误:", error);
|
|
285
|
+
return `导入失败: ${error.message}`;
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* 导出所有状态为 'active' 的回声洞数据到 `cave_export.json` 文件。
|
|
291
|
+
* @returns 一个描述导出结果的字符串消息。
|
|
292
|
+
*/
|
|
293
|
+
async exportData() {
|
|
294
|
+
const fileName = "cave_export.json";
|
|
295
|
+
const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
|
|
296
|
+
const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
|
|
297
|
+
const data = JSON.stringify(portableCaves, null, 2);
|
|
298
|
+
await this.fileManager.saveFile(fileName, Buffer.from(data));
|
|
299
|
+
return `成功导出 ${portableCaves.length} 条数据`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* 从 `cave_import.json` 文件导入回声洞数据。
|
|
303
|
+
* @returns 一个描述导入结果的字符串消息。
|
|
304
|
+
*/
|
|
305
|
+
async importData() {
|
|
306
|
+
const fileName = "cave_import.json";
|
|
307
|
+
let importedData;
|
|
308
|
+
try {
|
|
309
|
+
const fileContent = await this.fileManager.readFile(fileName);
|
|
310
|
+
importedData = JSON.parse(fileContent.toString("utf-8"));
|
|
311
|
+
if (!Array.isArray(importedData)) {
|
|
312
|
+
throw new Error("导入文件格式非 JSON 数组");
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return `读取导入文件失败: ${error.message}`;
|
|
316
|
+
}
|
|
317
|
+
const allCaves = await this.ctx.database.get("cave", {}, { fields: ["id"] });
|
|
318
|
+
const existingIds = new Set(allCaves.map((c) => c.id));
|
|
319
|
+
let nextId = 1;
|
|
320
|
+
const cavesToCreate = [];
|
|
321
|
+
for (const caveData of importedData) {
|
|
322
|
+
while (existingIds.has(nextId)) {
|
|
323
|
+
nextId++;
|
|
324
|
+
}
|
|
325
|
+
const newId = nextId;
|
|
326
|
+
const newCave = {
|
|
327
|
+
...caveData,
|
|
328
|
+
id: newId,
|
|
329
|
+
channelId: caveData.channelId,
|
|
330
|
+
status: "active"
|
|
331
|
+
};
|
|
332
|
+
cavesToCreate.push(newCave);
|
|
333
|
+
existingIds.add(newId);
|
|
334
|
+
}
|
|
335
|
+
if (cavesToCreate.length > 0) {
|
|
336
|
+
await this.ctx.database.upsert("cave", cavesToCreate);
|
|
337
|
+
}
|
|
338
|
+
return `成功导入 ${cavesToCreate.length} 条回声洞数据`;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
257
342
|
// src/Utils.ts
|
|
258
343
|
var import_koishi = require("koishi");
|
|
259
344
|
var path2 = __toESM(require("path"));
|
|
@@ -271,8 +356,7 @@ function storedFormatToHElements(elements) {
|
|
|
271
356
|
switch (el.type) {
|
|
272
357
|
case "text":
|
|
273
358
|
return import_koishi.h.text(el.content);
|
|
274
|
-
case "
|
|
275
|
-
return (0, import_koishi.h)("image", { src: el.file });
|
|
359
|
+
case "image":
|
|
276
360
|
case "video":
|
|
277
361
|
case "audio":
|
|
278
362
|
case "file":
|
|
@@ -292,7 +376,7 @@ async function mediaElementToBase64(element, fileManager, logger2) {
|
|
|
292
376
|
return (0, import_koishi.h)(element.type, { ...element.attrs, src: `data:${mimeType};base64,${data.toString("base64")}` });
|
|
293
377
|
} catch (error) {
|
|
294
378
|
logger2.warn(`转换本地文件 ${fileName} 为 Base64 失败:`, error);
|
|
295
|
-
return
|
|
379
|
+
return import_koishi.h.text(`[${element.type}]`);
|
|
296
380
|
}
|
|
297
381
|
}
|
|
298
382
|
__name(mediaElementToBase64, "mediaElementToBase64");
|
|
@@ -302,47 +386,68 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
|
|
|
302
386
|
const isMedia = ["image", "video", "audio", "file"].includes(element.type);
|
|
303
387
|
const fileName = element.attrs.src;
|
|
304
388
|
if (!isMedia || !fileName) {
|
|
305
|
-
return
|
|
389
|
+
return element;
|
|
306
390
|
}
|
|
307
391
|
if (config.enableS3 && config.publicUrl) {
|
|
308
|
-
const fullUrl = config.publicUrl.endsWith("/") ?
|
|
309
|
-
return
|
|
392
|
+
const fullUrl = new URL(fileName, config.publicUrl.endsWith("/") ? config.publicUrl : `${config.publicUrl}/`).href;
|
|
393
|
+
return (0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl });
|
|
310
394
|
}
|
|
311
395
|
if (config.localPath) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
return Promise.resolve((0, import_koishi.h)(element.type, { ...element.attrs, src: fileUri }));
|
|
396
|
+
const fileUri = `file://${path2.join(config.localPath, fileName)}`;
|
|
397
|
+
return (0, import_koishi.h)(element.type, { ...element.attrs, src: fileUri });
|
|
315
398
|
}
|
|
316
399
|
return mediaElementToBase64(element, fileManager, logger2);
|
|
317
400
|
}));
|
|
318
401
|
const finalMessage = [];
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
if (separatorIndex === -1) {
|
|
323
|
-
headerFormat = formatString;
|
|
324
|
-
footerFormat = "";
|
|
325
|
-
} else {
|
|
326
|
-
headerFormat = formatString.substring(0, separatorIndex);
|
|
327
|
-
footerFormat = formatString.substring(separatorIndex + 1);
|
|
328
|
-
}
|
|
329
|
-
const headerText = headerFormat.replace("{id}", cave.id.toString()).replace("{name}", cave.userName);
|
|
330
|
-
if (headerText.trim()) finalMessage.push((0, import_koishi.h)("p", {}, headerText));
|
|
402
|
+
const [headerFormat, footerFormat] = config.caveFormat.split("|");
|
|
403
|
+
const replacer = /* @__PURE__ */ __name((str) => str.replace("{id}", cave.id.toString()).replace("{name}", cave.userName), "replacer");
|
|
404
|
+
if (headerFormat?.trim()) finalMessage.push(replacer(headerFormat));
|
|
331
405
|
finalMessage.push(...processedElements);
|
|
332
|
-
|
|
333
|
-
if (footerText.trim()) finalMessage.push((0, import_koishi.h)("p", {}, footerText));
|
|
406
|
+
if (footerFormat?.trim()) finalMessage.push(replacer(footerFormat));
|
|
334
407
|
return finalMessage;
|
|
335
408
|
}
|
|
336
409
|
__name(buildCaveMessage, "buildCaveMessage");
|
|
410
|
+
function prepareElementsForStorage(sourceElements, newId, channelId, userId) {
|
|
411
|
+
const finalElementsForDb = [];
|
|
412
|
+
const mediaToDownload = [];
|
|
413
|
+
let mediaIndex = 0;
|
|
414
|
+
const processElement = /* @__PURE__ */ __name((el) => {
|
|
415
|
+
const elementType = el.type;
|
|
416
|
+
if (["image", "video", "audio", "file"].includes(elementType) && el.attrs.src) {
|
|
417
|
+
const fileIdentifier = el.attrs.src;
|
|
418
|
+
if (fileIdentifier.startsWith("http")) {
|
|
419
|
+
mediaIndex++;
|
|
420
|
+
const originalName = el.attrs.file;
|
|
421
|
+
const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
|
|
422
|
+
const ext = originalName ? path2.extname(originalName) : "";
|
|
423
|
+
const finalExt = ext || defaultExtMap[elementType] || ".dat";
|
|
424
|
+
const generatedFileName = `${newId}_${mediaIndex}_${channelId}_${userId}${finalExt}`;
|
|
425
|
+
finalElementsForDb.push({ type: elementType, file: generatedFileName });
|
|
426
|
+
mediaToDownload.push({ url: fileIdentifier, fileName: generatedFileName });
|
|
427
|
+
} else {
|
|
428
|
+
finalElementsForDb.push({ type: elementType, file: fileIdentifier });
|
|
429
|
+
}
|
|
430
|
+
} else if (elementType === "text" && el.attrs.content?.trim()) {
|
|
431
|
+
finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
|
|
432
|
+
}
|
|
433
|
+
if (el.children) {
|
|
434
|
+
el.children.forEach(processElement);
|
|
435
|
+
}
|
|
436
|
+
}, "processElement");
|
|
437
|
+
sourceElements.forEach(processElement);
|
|
438
|
+
return { finalElementsForDb, mediaToDownload };
|
|
439
|
+
}
|
|
440
|
+
__name(prepareElementsForStorage, "prepareElementsForStorage");
|
|
337
441
|
async function cleanupPendingDeletions(ctx, fileManager, logger2) {
|
|
338
442
|
try {
|
|
339
443
|
const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
|
|
340
444
|
if (cavesToDelete.length === 0) return;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
445
|
+
const filesToDelete = cavesToDelete.flatMap(
|
|
446
|
+
(cave) => cave.elements.filter((el) => el.file).map((el) => el.file)
|
|
447
|
+
);
|
|
448
|
+
await Promise.all(filesToDelete.map((file) => fileManager.deleteFile(file)));
|
|
449
|
+
const idsToRemove = cavesToDelete.map((cave) => cave.id);
|
|
450
|
+
await ctx.database.remove("cave", { id: { $in: idsToRemove } });
|
|
346
451
|
} catch (error) {
|
|
347
452
|
logger2.error("清理回声洞时发生错误:", error);
|
|
348
453
|
}
|
|
@@ -356,25 +461,6 @@ function getScopeQuery(session, config) {
|
|
|
356
461
|
return baseQuery;
|
|
357
462
|
}
|
|
358
463
|
__name(getScopeQuery, "getScopeQuery");
|
|
359
|
-
async function getNextCaveId(ctx, query = {}) {
|
|
360
|
-
const allCaves = await ctx.database.get("cave", query, { fields: ["id"] });
|
|
361
|
-
const existingIds = new Set(allCaves.map((c) => c.id));
|
|
362
|
-
let newId = 1;
|
|
363
|
-
while (existingIds.has(newId)) {
|
|
364
|
-
newId++;
|
|
365
|
-
}
|
|
366
|
-
return newId;
|
|
367
|
-
}
|
|
368
|
-
__name(getNextCaveId, "getNextCaveId");
|
|
369
|
-
async function downloadMedia(ctx, fileManager, url, originalName, type, caveId, index, channelId, userId) {
|
|
370
|
-
const defaultExtMap = { "img": ".jpg", "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
|
|
371
|
-
const ext = originalName ? path2.extname(originalName) : "";
|
|
372
|
-
const finalExt = ext || defaultExtMap[type] || ".dat";
|
|
373
|
-
const fileName = `${caveId}_${index}_${channelId}_${userId}${finalExt}`;
|
|
374
|
-
const response = await ctx.http.get(url, { responseType: "arraybuffer", timeout: 3e4 });
|
|
375
|
-
return fileManager.saveFile(fileName, Buffer.from(response));
|
|
376
|
-
}
|
|
377
|
-
__name(downloadMedia, "downloadMedia");
|
|
378
464
|
function checkCooldown(session, config, lastUsed) {
|
|
379
465
|
if (config.coolDown <= 0 || !session.channelId || config.adminUsers.includes(session.userId)) {
|
|
380
466
|
return null;
|
|
@@ -395,107 +481,13 @@ function updateCooldownTimestamp(session, config, lastUsed) {
|
|
|
395
481
|
}
|
|
396
482
|
__name(updateCooldownTimestamp, "updateCooldownTimestamp");
|
|
397
483
|
|
|
398
|
-
// src/DataManager.ts
|
|
399
|
-
var DataManager = class {
|
|
400
|
-
/**
|
|
401
|
-
* 创建一个 DataManager 实例。
|
|
402
|
-
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
403
|
-
* @param config - 插件配置。
|
|
404
|
-
* @param fileManager - 文件管理器实例,用于读写导入/导出文件。
|
|
405
|
-
* @param logger - 日志记录器实例。
|
|
406
|
-
*/
|
|
407
|
-
constructor(ctx, config, fileManager, logger2) {
|
|
408
|
-
this.ctx = ctx;
|
|
409
|
-
this.config = config;
|
|
410
|
-
this.fileManager = fileManager;
|
|
411
|
-
this.logger = logger2;
|
|
412
|
-
}
|
|
413
|
-
static {
|
|
414
|
-
__name(this, "DataManager");
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* 注册与数据导入导出相关的 `.export` 和 `.import` 子命令。
|
|
418
|
-
* @param cave - 主 `cave` 命令的实例,用于挂载子命令。
|
|
419
|
-
*/
|
|
420
|
-
registerCommands(cave) {
|
|
421
|
-
cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(async ({ session }) => {
|
|
422
|
-
if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限导出数据";
|
|
423
|
-
try {
|
|
424
|
-
await session.send("正在导出数据,请稍候...");
|
|
425
|
-
const resultMessage = await this.exportData();
|
|
426
|
-
return resultMessage;
|
|
427
|
-
} catch (error) {
|
|
428
|
-
this.logger.error("导出数据时发生错误:", error);
|
|
429
|
-
return `导出失败: ${error.message}`;
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(async ({ session }) => {
|
|
433
|
-
if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限导入数据";
|
|
434
|
-
try {
|
|
435
|
-
await session.send("正在导入数据,请稍候...");
|
|
436
|
-
const resultMessage = await this.importData();
|
|
437
|
-
return resultMessage;
|
|
438
|
-
} catch (error) {
|
|
439
|
-
this.logger.error("导入数据时发生错误:", error);
|
|
440
|
-
return `导入失败: ${error.message}`;
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
/**
|
|
445
|
-
* 导出所有状态为 'active' 的回声洞数据。
|
|
446
|
-
* 数据将被序列化为 JSON 并保存到 `cave_export.json` 文件中。
|
|
447
|
-
* @returns 一个描述导出结果的字符串消息。
|
|
448
|
-
*/
|
|
449
|
-
async exportData() {
|
|
450
|
-
const fileName = "cave_export.json";
|
|
451
|
-
const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
|
|
452
|
-
const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
|
|
453
|
-
const data = JSON.stringify(portableCaves, null, 2);
|
|
454
|
-
await this.fileManager.saveFile(fileName, Buffer.from(data));
|
|
455
|
-
return `成功导出 ${portableCaves.length} 条数据`;
|
|
456
|
-
}
|
|
457
|
-
/**
|
|
458
|
-
* 从 `cave_import.json` 文件导入回声洞数据。
|
|
459
|
-
* @returns 一个描述导入结果的字符串消息。
|
|
460
|
-
*/
|
|
461
|
-
async importData() {
|
|
462
|
-
const fileName = "cave_import.json";
|
|
463
|
-
let importedCaves;
|
|
464
|
-
try {
|
|
465
|
-
const fileContent = await this.fileManager.readFile(fileName);
|
|
466
|
-
importedCaves = JSON.parse(fileContent.toString("utf-8"));
|
|
467
|
-
if (!Array.isArray(importedCaves)) {
|
|
468
|
-
throw new Error("导入文件格式无效");
|
|
469
|
-
}
|
|
470
|
-
} catch (error) {
|
|
471
|
-
this.logger.error(`读取导入文件失败:`, error);
|
|
472
|
-
return `读取导入文件失败: ${error.message || "未知错误"}`;
|
|
473
|
-
}
|
|
474
|
-
let successCount = 0;
|
|
475
|
-
for (const cave of importedCaves) {
|
|
476
|
-
const newId = await getNextCaveId(this.ctx, {});
|
|
477
|
-
const newCave = {
|
|
478
|
-
...cave,
|
|
479
|
-
id: newId,
|
|
480
|
-
channelId: cave.channelId || null,
|
|
481
|
-
// 确保 channelId 存在,若无则为 null。
|
|
482
|
-
status: "active"
|
|
483
|
-
// 导入的数据直接设为 active 状态。
|
|
484
|
-
};
|
|
485
|
-
await this.ctx.database.create("cave", newCave);
|
|
486
|
-
successCount++;
|
|
487
|
-
}
|
|
488
|
-
return `成功导入 ${successCount} 条回声洞数据`;
|
|
489
|
-
}
|
|
490
|
-
};
|
|
491
|
-
|
|
492
484
|
// src/ReviewManager.ts
|
|
493
|
-
var
|
|
485
|
+
var APPROVE_ACTIONS = /* @__PURE__ */ new Set(["y", "yes", "pass", "approve"]);
|
|
486
|
+
var REJECT_ACTIONS = /* @__PURE__ */ new Set(["n", "no", "deny", "reject"]);
|
|
494
487
|
var ReviewManager = class {
|
|
495
488
|
/**
|
|
496
|
-
* 创建一个 ReviewManager 实例。
|
|
497
489
|
* @param ctx - Koishi 上下文。
|
|
498
|
-
* @param config -
|
|
490
|
+
* @param config - 插件配置对象。
|
|
499
491
|
* @param fileManager - 文件管理器实例。
|
|
500
492
|
* @param logger - 日志记录器实例。
|
|
501
493
|
*/
|
|
@@ -510,7 +502,7 @@ var ReviewManager = class {
|
|
|
510
502
|
}
|
|
511
503
|
/**
|
|
512
504
|
* 注册与审核相关的 `.review` 子命令。
|
|
513
|
-
* @param cave - 主 `cave`
|
|
505
|
+
* @param cave - 主 `cave` 命令实例,用于挂载子命令。
|
|
514
506
|
*/
|
|
515
507
|
registerCommands(cave) {
|
|
516
508
|
cave.subcommand(".review [id:posint] [action:string]", "审核回声洞").usage("查看或审核回声洞,使用 <Y/N> 进行审核。").action(async ({ session }, id, action) => {
|
|
@@ -518,44 +510,46 @@ var ReviewManager = class {
|
|
|
518
510
|
return "抱歉,你没有权限执行审核";
|
|
519
511
|
}
|
|
520
512
|
if (!id) {
|
|
521
|
-
|
|
522
|
-
if (pendingCaves.length === 0) {
|
|
523
|
-
return "当前没有需要审核的回声洞";
|
|
524
|
-
}
|
|
525
|
-
const pendingIds = pendingCaves.map((c) => c.id).join(", ");
|
|
526
|
-
return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
|
|
527
|
-
${pendingIds}`;
|
|
513
|
+
return this.listPendingCaves();
|
|
528
514
|
}
|
|
529
515
|
const [targetCave] = await this.ctx.database.get("cave", { id });
|
|
530
|
-
if (!targetCave) {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (targetCave.status !== "pending") {
|
|
534
|
-
return `回声洞(${id})无需审核`;
|
|
535
|
-
}
|
|
536
|
-
if (id && !action) {
|
|
516
|
+
if (!targetCave) return `回声洞(${id})不存在`;
|
|
517
|
+
if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
|
|
518
|
+
if (!action) {
|
|
537
519
|
return this.buildReviewMessage(targetCave);
|
|
538
520
|
}
|
|
539
521
|
const normalizedAction = action.toLowerCase();
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
} else {
|
|
546
|
-
return `无效操作: "${action}"
|
|
547
|
-
请使用 "Y" (通过) 或 "N" (拒绝)`;
|
|
522
|
+
if (APPROVE_ACTIONS.has(normalizedAction)) {
|
|
523
|
+
return this.processReview("approve", targetCave, session.username);
|
|
524
|
+
}
|
|
525
|
+
if (REJECT_ACTIONS.has(normalizedAction)) {
|
|
526
|
+
return this.processReview("reject", targetCave, session.username);
|
|
548
527
|
}
|
|
549
|
-
return
|
|
528
|
+
return `无效操作: "${action}"
|
|
529
|
+
请使用 "Y" (通过) 或 "N" (拒绝)`;
|
|
550
530
|
});
|
|
551
531
|
}
|
|
552
532
|
/**
|
|
553
|
-
*
|
|
533
|
+
* 列出所有待审核的回声洞。
|
|
534
|
+
* @private
|
|
535
|
+
*/
|
|
536
|
+
async listPendingCaves() {
|
|
537
|
+
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
|
|
538
|
+
if (pendingCaves.length === 0) {
|
|
539
|
+
return "当前没有需要审核的回声洞";
|
|
540
|
+
}
|
|
541
|
+
const pendingIds = pendingCaves.map((c) => c.id).join(", ");
|
|
542
|
+
return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
|
|
543
|
+
${pendingIds}`;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* 将一条新回声洞提交给管理员进行审核。
|
|
547
|
+
* 如果没有配置管理员,将自动通过审核。
|
|
554
548
|
* @param cave - 新创建的、状态为 'pending' 的回声洞对象。
|
|
555
549
|
*/
|
|
556
550
|
async sendForReview(cave) {
|
|
557
551
|
if (!this.config.adminUsers?.length) {
|
|
558
|
-
this.logger.warn(
|
|
552
|
+
this.logger.warn(`No admin users configured. Cave ${cave.id} has been auto-approved.`);
|
|
559
553
|
await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
|
|
560
554
|
return;
|
|
561
555
|
}
|
|
@@ -563,51 +557,45 @@ ${pendingIds}`;
|
|
|
563
557
|
try {
|
|
564
558
|
await this.ctx.broadcast(this.config.adminUsers, reviewMessage);
|
|
565
559
|
} catch (error) {
|
|
566
|
-
this.logger.error(
|
|
560
|
+
this.logger.error(`Failed to broadcast review request for cave ${cave.id}:`, error);
|
|
567
561
|
}
|
|
568
562
|
}
|
|
569
563
|
/**
|
|
570
|
-
*
|
|
564
|
+
* 构建用于发送给管理员的审核消息。
|
|
571
565
|
* @param cave - 待审核的回声洞对象。
|
|
572
566
|
* @returns 一个可直接发送的消息数组。
|
|
573
567
|
* @private
|
|
574
568
|
*/
|
|
575
569
|
async buildReviewMessage(cave) {
|
|
576
570
|
const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
|
|
577
|
-
return [
|
|
578
|
-
(0, import_koishi2.h)("p", `以下内容待审核:`),
|
|
579
|
-
...caveContent
|
|
580
|
-
];
|
|
571
|
+
return [`待审核`, ...caveContent];
|
|
581
572
|
}
|
|
582
573
|
/**
|
|
583
574
|
* 处理管理员的审核决定(通过或拒绝)。
|
|
584
575
|
* @param action - 'approve' (通过) 或 'reject' (拒绝)。
|
|
585
|
-
* @param
|
|
586
|
-
* @param adminUserName -
|
|
576
|
+
* @param cave - 被审核的回声洞对象。
|
|
577
|
+
* @param adminUserName - 执行操作的管理员昵称。
|
|
587
578
|
* @returns 返回给操作者的确认消息。
|
|
588
579
|
*/
|
|
589
|
-
async processReview(action,
|
|
590
|
-
const [cave] = await this.ctx.database.get("cave", { id: caveId });
|
|
591
|
-
if (!cave) return `回声洞(${caveId})不存在`;
|
|
592
|
-
if (cave.status !== "pending") return `回声洞(${caveId})无需审核`;
|
|
580
|
+
async processReview(action, cave, adminUserName) {
|
|
593
581
|
let resultMessage;
|
|
594
582
|
let broadcastMessage;
|
|
595
583
|
if (action === "approve") {
|
|
596
|
-
await this.ctx.database.upsert("cave", [{ id:
|
|
597
|
-
resultMessage = `回声洞(${
|
|
598
|
-
broadcastMessage = `回声洞(${
|
|
584
|
+
await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
|
|
585
|
+
resultMessage = `回声洞(${cave.id})已通过`;
|
|
586
|
+
broadcastMessage = `回声洞(${cave.id})已由管理员 "${adminUserName}" 通过`;
|
|
599
587
|
} else {
|
|
600
|
-
await this.ctx.database.upsert("cave", [{ id:
|
|
601
|
-
resultMessage = `回声洞(${
|
|
588
|
+
await this.ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
589
|
+
resultMessage = `回声洞(${cave.id})已拒绝`;
|
|
602
590
|
const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
|
|
603
|
-
broadcastMessage = [
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
591
|
+
broadcastMessage = [`回声洞(${cave.id})已由管理员 "${adminUserName}" 拒绝`, ...caveContent];
|
|
592
|
+
cleanupPendingDeletions(this.ctx, this.fileManager, this.logger).catch((err) => {
|
|
593
|
+
this.logger.error(`Background cleanup failed for rejected cave ${cave.id}:`, err);
|
|
594
|
+
});
|
|
607
595
|
}
|
|
608
|
-
if (
|
|
609
|
-
|
|
610
|
-
this.logger.error(
|
|
596
|
+
if (this.config.adminUsers?.length) {
|
|
597
|
+
this.ctx.broadcast(this.config.adminUsers, broadcastMessage).catch((err) => {
|
|
598
|
+
this.logger.error(`Failed to broadcast review result for cave ${cave.id}:`, err);
|
|
611
599
|
});
|
|
612
600
|
}
|
|
613
601
|
return resultMessage;
|
|
@@ -630,49 +618,41 @@ var usage = `
|
|
|
630
618
|
<p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p>
|
|
631
619
|
</div>
|
|
632
620
|
`;
|
|
633
|
-
var logger = new
|
|
634
|
-
var Config =
|
|
635
|
-
|
|
636
|
-
coolDown:
|
|
637
|
-
perChannel:
|
|
638
|
-
enableProfile:
|
|
639
|
-
enableIO:
|
|
640
|
-
caveFormat:
|
|
641
|
-
adminUsers:
|
|
621
|
+
var logger = new import_koishi2.Logger("best-cave");
|
|
622
|
+
var Config = import_koishi2.Schema.intersect([
|
|
623
|
+
import_koishi2.Schema.object({
|
|
624
|
+
coolDown: import_koishi2.Schema.number().default(10).description("冷却时间(秒)"),
|
|
625
|
+
perChannel: import_koishi2.Schema.boolean().default(false).description("启用分群模式"),
|
|
626
|
+
enableProfile: import_koishi2.Schema.boolean().default(false).description("启用自定义昵称"),
|
|
627
|
+
enableIO: import_koishi2.Schema.boolean().default(false).description("启用导入导出"),
|
|
628
|
+
caveFormat: import_koishi2.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本"),
|
|
629
|
+
adminUsers: import_koishi2.Schema.array(import_koishi2.Schema.string()).default([]).description("管理员 ID 列表")
|
|
642
630
|
}).description("基础配置"),
|
|
643
|
-
|
|
644
|
-
enableReview:
|
|
631
|
+
import_koishi2.Schema.object({
|
|
632
|
+
enableReview: import_koishi2.Schema.boolean().default(false).description("启用审核")
|
|
645
633
|
}).description("审核配置"),
|
|
646
|
-
|
|
647
|
-
localPath:
|
|
648
|
-
enableS3:
|
|
649
|
-
publicUrl:
|
|
650
|
-
endpoint:
|
|
651
|
-
bucket:
|
|
652
|
-
region:
|
|
653
|
-
accessKeyId:
|
|
654
|
-
secretAccessKey:
|
|
634
|
+
import_koishi2.Schema.object({
|
|
635
|
+
localPath: import_koishi2.Schema.string().description("文件映射路径"),
|
|
636
|
+
enableS3: import_koishi2.Schema.boolean().default(false).description("启用 S3 存储"),
|
|
637
|
+
publicUrl: import_koishi2.Schema.string().description("公共访问 URL").role("link"),
|
|
638
|
+
endpoint: import_koishi2.Schema.string().description("端点 (Endpoint)").role("link"),
|
|
639
|
+
bucket: import_koishi2.Schema.string().description("存储桶 (Bucket)"),
|
|
640
|
+
region: import_koishi2.Schema.string().default("auto").description("区域 (Region)"),
|
|
641
|
+
accessKeyId: import_koishi2.Schema.string().description("Access Key ID").role("secret"),
|
|
642
|
+
secretAccessKey: import_koishi2.Schema.string().description("Secret Access Key").role("secret")
|
|
655
643
|
}).description("存储配置")
|
|
656
644
|
]);
|
|
657
645
|
function apply(ctx, config) {
|
|
658
646
|
ctx.model.extend("cave", {
|
|
659
647
|
id: "unsigned",
|
|
660
|
-
// 无符号整数,作为主键。
|
|
661
648
|
elements: "json",
|
|
662
|
-
// 存储为 JSON 字符串的元素数组。
|
|
663
649
|
channelId: "string",
|
|
664
|
-
// 频道 ID。
|
|
665
650
|
userId: "string",
|
|
666
|
-
// 用户 ID。
|
|
667
651
|
userName: "string",
|
|
668
|
-
// 用户昵称。
|
|
669
652
|
status: "string",
|
|
670
|
-
// 回声洞状态。
|
|
671
653
|
time: "timestamp"
|
|
672
|
-
// 提交时间。
|
|
673
654
|
}, {
|
|
674
655
|
primary: "id"
|
|
675
|
-
// 将 'id' 字段设置为主键。
|
|
676
656
|
});
|
|
677
657
|
const fileManager = new FileManager(ctx.baseDir, config, logger);
|
|
678
658
|
const lastUsed = /* @__PURE__ */ new Map();
|
|
@@ -694,56 +674,40 @@ function apply(ctx, config) {
|
|
|
694
674
|
}
|
|
695
675
|
const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
|
|
696
676
|
const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId });
|
|
697
|
-
|
|
698
|
-
|
|
677
|
+
if (randomCave) {
|
|
678
|
+
updateCooldownTimestamp(session, config, lastUsed);
|
|
679
|
+
return buildCaveMessage(randomCave, config, fileManager, logger);
|
|
680
|
+
}
|
|
681
|
+
return "未能获取到回声洞";
|
|
699
682
|
} catch (error) {
|
|
700
683
|
logger.error("随机获取回声洞失败:", error);
|
|
701
|
-
return "随机获取回声洞失败";
|
|
702
684
|
}
|
|
703
685
|
});
|
|
704
686
|
cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
|
|
705
|
-
cleanupPendingDeletions(ctx, fileManager, logger);
|
|
706
|
-
const savedFileIdentifiers = [];
|
|
707
687
|
try {
|
|
708
|
-
let sourceElements;
|
|
709
|
-
if (
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
688
|
+
let sourceElements = session.quote?.elements;
|
|
689
|
+
if (!sourceElements) {
|
|
690
|
+
const sourceText = content?.trim();
|
|
691
|
+
if (sourceText) {
|
|
692
|
+
sourceElements = import_koishi2.h.parse(sourceText);
|
|
693
|
+
} else {
|
|
694
|
+
await session.send("请在一分钟内发送你要添加的内容");
|
|
695
|
+
const reply = await session.prompt(6e4);
|
|
696
|
+
if (!reply) return "操作超时,已取消添加";
|
|
697
|
+
sourceElements = import_koishi2.h.parse(reply);
|
|
698
|
+
}
|
|
718
699
|
}
|
|
719
700
|
const scopeQuery = getScopeQuery(session, config);
|
|
720
|
-
const
|
|
721
|
-
const
|
|
722
|
-
let
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const elementType = el.type === "image" ? "img" : el.type;
|
|
726
|
-
if (["img", "video", "audio", "file"].includes(elementType) && el.attrs.src) {
|
|
727
|
-
let fileIdentifier = el.attrs.src;
|
|
728
|
-
if (fileIdentifier.startsWith("http")) {
|
|
729
|
-
mediaIndex++;
|
|
730
|
-
const originalName = el.attrs.file;
|
|
731
|
-
const savedId = await downloadMedia(ctx, fileManager, fileIdentifier, originalName, elementType, newId, mediaIndex, session.channelId, session.userId);
|
|
732
|
-
savedFileIdentifiers.push(savedId);
|
|
733
|
-
fileIdentifier = savedId;
|
|
734
|
-
}
|
|
735
|
-
finalElementsForDb.push({ type: elementType, file: fileIdentifier });
|
|
736
|
-
} else if (elementType === "text" && el.attrs.content?.trim()) {
|
|
737
|
-
finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
|
|
738
|
-
}
|
|
739
|
-
if (el.children) await traverseAndProcess(el.children);
|
|
740
|
-
}
|
|
701
|
+
const allCaves = await ctx.database.get("cave", scopeQuery, { fields: ["id"] });
|
|
702
|
+
const existingIds = new Set(allCaves.map((c) => c.id));
|
|
703
|
+
let newId = 1;
|
|
704
|
+
while (existingIds.has(newId)) {
|
|
705
|
+
newId++;
|
|
741
706
|
}
|
|
742
|
-
|
|
743
|
-
await traverseAndProcess(sourceElements);
|
|
707
|
+
const { finalElementsForDb, mediaToDownload } = prepareElementsForStorage(sourceElements, newId, session.channelId, session.userId);
|
|
744
708
|
if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
|
|
745
709
|
let userName = session.username;
|
|
746
|
-
if (config.enableProfile) {
|
|
710
|
+
if (config.enableProfile && profileManager) {
|
|
747
711
|
userName = await profileManager.getNickname(session.userId) || userName;
|
|
748
712
|
}
|
|
749
713
|
const newCave = {
|
|
@@ -756,17 +720,24 @@ function apply(ctx, config) {
|
|
|
756
720
|
time: /* @__PURE__ */ new Date()
|
|
757
721
|
};
|
|
758
722
|
await ctx.database.create("cave", newCave);
|
|
759
|
-
|
|
723
|
+
try {
|
|
724
|
+
const downloadPromises = mediaToDownload.map(async (media) => {
|
|
725
|
+
const response = await ctx.http.get(media.url, { responseType: "arraybuffer", timeout: 3e4 });
|
|
726
|
+
await fileManager.saveFile(media.fileName, Buffer.from(response));
|
|
727
|
+
});
|
|
728
|
+
await Promise.all(downloadPromises);
|
|
729
|
+
} catch (fileError) {
|
|
730
|
+
await ctx.database.remove("cave", { id: newId });
|
|
731
|
+
logger.error("媒体文件存储失败:", fileError);
|
|
732
|
+
return "添加失败:媒体文件存储失败";
|
|
733
|
+
}
|
|
734
|
+
if (newCave.status === "pending" && reviewManager) {
|
|
760
735
|
reviewManager.sendForReview(newCave);
|
|
761
736
|
return `提交成功,序号为(${newCave.id})`;
|
|
762
737
|
}
|
|
763
738
|
return `添加成功,序号为(${newId})`;
|
|
764
739
|
} catch (error) {
|
|
765
740
|
logger.error("添加回声洞失败:", error);
|
|
766
|
-
if (savedFileIdentifiers.length > 0) {
|
|
767
|
-
logger.info(`添加失败,回滚并删除 ${savedFileIdentifiers.length} 个文件...`);
|
|
768
|
-
await Promise.all(savedFileIdentifiers.map((fileId) => fileManager.deleteFile(fileId)));
|
|
769
|
-
}
|
|
770
741
|
return "添加失败,请稍后再试";
|
|
771
742
|
}
|
|
772
743
|
});
|
|
@@ -777,9 +748,7 @@ function apply(ctx, config) {
|
|
|
777
748
|
try {
|
|
778
749
|
const query = { ...getScopeQuery(session, config), id };
|
|
779
750
|
const [targetCave] = await ctx.database.get("cave", query);
|
|
780
|
-
if (!targetCave) {
|
|
781
|
-
return `回声洞(${id})不存在`;
|
|
782
|
-
}
|
|
751
|
+
if (!targetCave) return `回声洞(${id})不存在`;
|
|
783
752
|
updateCooldownTimestamp(session, config, lastUsed);
|
|
784
753
|
return buildCaveMessage(targetCave, config, fileManager, logger);
|
|
785
754
|
} catch (error) {
|
|
@@ -797,15 +766,14 @@ function apply(ctx, config) {
|
|
|
797
766
|
if (!isOwner && !isAdmin) {
|
|
798
767
|
return "抱歉,你没有权限删除这条回声洞";
|
|
799
768
|
}
|
|
800
|
-
await ctx.database.upsert("cave", [{ id, status: "delete" }]);
|
|
801
|
-
cleanupPendingDeletions(ctx, fileManager, logger);
|
|
802
769
|
const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
770
|
+
await ctx.database.upsert("cave", [{ id, status: "delete" }]);
|
|
771
|
+
session.send([`已删除`, ...caveMessage]);
|
|
772
|
+
cleanupPendingDeletions(ctx, fileManager, logger).catch((err) => {
|
|
773
|
+
logger.error(`删除回声洞(${id})失败:`, err);
|
|
774
|
+
});
|
|
807
775
|
} catch (error) {
|
|
808
|
-
logger.error(
|
|
776
|
+
logger.error(`标记删除回声洞(${id})失败:`, error);
|
|
809
777
|
return "删除失败,请稍后再试";
|
|
810
778
|
}
|
|
811
779
|
});
|