koishi-plugin-best-cave 2.0.4 → 2.0.6
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/FileManager.d.ts +64 -0
- package/lib/ProfileManager.d.ts +52 -0
- package/lib/ReviewManager.d.ts +49 -0
- package/lib/Utils.d.ts +1 -16
- package/lib/index.js +52 -48
- package/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Logger } from 'koishi';
|
|
2
|
+
import { Config } from './index';
|
|
3
|
+
/**
|
|
4
|
+
* 文件管理器 (FileManager)
|
|
5
|
+
* @description
|
|
6
|
+
* 封装了对文件(资源)的存储、读取和删除操作。
|
|
7
|
+
* 它能够根据插件配置自动选择使用本地文件系统或 AWS S3 作为存储后端。
|
|
8
|
+
* 内置了基于 Promise 的文件锁,以防止对本地文件的并发写入冲突。
|
|
9
|
+
*/
|
|
10
|
+
export declare class FileManager {
|
|
11
|
+
private logger;
|
|
12
|
+
private resourceDir;
|
|
13
|
+
private locks;
|
|
14
|
+
private s3Client?;
|
|
15
|
+
private s3Bucket?;
|
|
16
|
+
/**
|
|
17
|
+
* 创建一个 FileManager 实例。
|
|
18
|
+
* @param baseDir - Koishi 应用的基础数据目录 (ctx.baseDir)。
|
|
19
|
+
* @param config - 插件的完整配置对象。
|
|
20
|
+
* @param logger - 日志记录器实例。
|
|
21
|
+
*/
|
|
22
|
+
constructor(baseDir: string, config: Config, logger: Logger);
|
|
23
|
+
/**
|
|
24
|
+
* 确保本地资源目录存在。如果目录不存在,则会递归创建。
|
|
25
|
+
* 这是一个幂等操作。
|
|
26
|
+
* @private
|
|
27
|
+
*/
|
|
28
|
+
private ensureDirectory;
|
|
29
|
+
/**
|
|
30
|
+
* 获取给定文件名的完整本地路径。
|
|
31
|
+
* @param fileName - 文件名。
|
|
32
|
+
* @returns 文件的绝对路径。
|
|
33
|
+
* @private
|
|
34
|
+
*/
|
|
35
|
+
private getFullPath;
|
|
36
|
+
/**
|
|
37
|
+
* 使用文件锁来安全地执行一个异步文件操作。
|
|
38
|
+
* 这可以防止对同一文件的并发读写造成数据损坏。
|
|
39
|
+
* @template T - 异步操作的返回类型。
|
|
40
|
+
* @param fileName - 需要加锁的文件名。
|
|
41
|
+
* @param operation - 要执行的异步函数。
|
|
42
|
+
* @returns 返回异步操作的结果。
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
private withLock;
|
|
46
|
+
/**
|
|
47
|
+
* 保存文件,自动选择 S3 或本地存储。
|
|
48
|
+
* @param fileName - 文件名,将用作 S3 中的 Key 或本地文件名。
|
|
49
|
+
* @param data - 要写入的 Buffer 数据。
|
|
50
|
+
* @returns 返回保存时使用的文件名/标识符。
|
|
51
|
+
*/
|
|
52
|
+
saveFile(fileName: string, data: Buffer): Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* 读取文件,自动从 S3 或本地存储读取。
|
|
55
|
+
* @param fileName - 要读取的文件名/标识符。
|
|
56
|
+
* @returns 文件的 Buffer 数据。
|
|
57
|
+
*/
|
|
58
|
+
readFile(fileName: string): Promise<Buffer>;
|
|
59
|
+
/**
|
|
60
|
+
* 删除文件,自动从 S3 或本地删除。
|
|
61
|
+
* @param fileIdentifier - 要删除的文件名/标识符。
|
|
62
|
+
*/
|
|
63
|
+
deleteFile(fileIdentifier: string): Promise<void>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
/**
|
|
3
|
+
* 数据库中 `cave_user` 表的记录结构。
|
|
4
|
+
* @property userId - 用户的唯一 ID,作为主键。
|
|
5
|
+
* @property nickname - 用户设置的自定义昵称。
|
|
6
|
+
*/
|
|
7
|
+
export interface UserProfile {
|
|
8
|
+
userId: string;
|
|
9
|
+
nickname: string;
|
|
10
|
+
}
|
|
11
|
+
declare module 'koishi' {
|
|
12
|
+
interface Tables {
|
|
13
|
+
cave_user: UserProfile;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 个人资料管理器 (ProfileManager)
|
|
18
|
+
* @description
|
|
19
|
+
* 负责管理用户在回声洞插件中的自定义昵称。
|
|
20
|
+
* 提供设置、获取和清除昵称的数据库操作和相关命令。
|
|
21
|
+
* 此类仅在插件配置中启用了 `enableProfile` 时才会被实例化。
|
|
22
|
+
*/
|
|
23
|
+
export declare class ProfileManager {
|
|
24
|
+
private ctx;
|
|
25
|
+
/**
|
|
26
|
+
* 创建一个 ProfileManager 实例。
|
|
27
|
+
* @param ctx - Koishi 上下文,用于初始化数据库模型。
|
|
28
|
+
*/
|
|
29
|
+
constructor(ctx: Context);
|
|
30
|
+
/**
|
|
31
|
+
* 注册与用户昵称相关的 `.profile` 子命令。
|
|
32
|
+
* @param cave - 主 `cave` 命令的实例,用于挂载子命令。
|
|
33
|
+
*/
|
|
34
|
+
registerCommands(cave: any): void;
|
|
35
|
+
/**
|
|
36
|
+
* 设置或更新指定用户的昵称。
|
|
37
|
+
* @param userId - 目标用户的 ID。
|
|
38
|
+
* @param nickname - 要设置的新昵称。
|
|
39
|
+
*/
|
|
40
|
+
setNickname(userId: string, nickname: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* 获取指定用户的昵称。
|
|
43
|
+
* @param userId - 目标用户的 ID。
|
|
44
|
+
* @returns 返回用户的昵称字符串。如果用户未设置昵称,则返回 null。
|
|
45
|
+
*/
|
|
46
|
+
getNickname(userId: string): Promise<string | null>;
|
|
47
|
+
/**
|
|
48
|
+
* 清除指定用户的昵称设置。
|
|
49
|
+
* @param userId - 目标用户的 ID。
|
|
50
|
+
*/
|
|
51
|
+
clearNickname(userId: string): Promise<void>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Context, h, Logger } from 'koishi';
|
|
2
|
+
import { CaveObject, Config } from './index';
|
|
3
|
+
import { FileManager } from './FileManager';
|
|
4
|
+
/**
|
|
5
|
+
* 审核管理器 (ReviewManager)
|
|
6
|
+
* @description
|
|
7
|
+
* 负责处理回声洞的审核流程。当 `enableReview` 配置项开启时,
|
|
8
|
+
* 此管理器将被激活,用于处理新回声洞的提交、向管理员发送审核通知
|
|
9
|
+
* 以及处理管理员的审核操作(通过/拒绝)。
|
|
10
|
+
*/
|
|
11
|
+
export declare class ReviewManager {
|
|
12
|
+
private ctx;
|
|
13
|
+
private config;
|
|
14
|
+
private fileManager;
|
|
15
|
+
private logger;
|
|
16
|
+
/**
|
|
17
|
+
* 创建一个 ReviewManager 实例。
|
|
18
|
+
* @param ctx - Koishi 上下文。
|
|
19
|
+
* @param config - 插件配置。
|
|
20
|
+
* @param fileManager - 文件管理器实例。
|
|
21
|
+
* @param logger - 日志记录器实例。
|
|
22
|
+
*/
|
|
23
|
+
constructor(ctx: Context, config: Config, fileManager: FileManager, logger: Logger);
|
|
24
|
+
/**
|
|
25
|
+
* 注册与审核相关的 `.review` 子命令。
|
|
26
|
+
* @param cave - 主 `cave` 命令的实例,用于挂载子命令。
|
|
27
|
+
*/
|
|
28
|
+
registerCommands(cave: any): void;
|
|
29
|
+
/**
|
|
30
|
+
* 将一条新的回声洞提交给所有管理员进行审核。
|
|
31
|
+
* @param cave - 新创建的、状态为 'pending' 的回声洞对象。
|
|
32
|
+
*/
|
|
33
|
+
sendForReview(cave: CaveObject): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* 构建一条用于发送给管理员的、包含审核信息的消息。
|
|
36
|
+
* @param cave - 待审核的回声洞对象。
|
|
37
|
+
* @returns 一个可直接发送的消息数组。
|
|
38
|
+
* @private
|
|
39
|
+
*/
|
|
40
|
+
private buildReviewMessage;
|
|
41
|
+
/**
|
|
42
|
+
* 处理管理员的审核决定(通过或拒绝)。
|
|
43
|
+
* @param action - 'approve' (通过) 或 'reject' (拒绝)。
|
|
44
|
+
* @param caveId - 被审核的回声洞 ID。
|
|
45
|
+
* @param adminUserName - 执行操作的管理员的昵称。
|
|
46
|
+
* @returns 返回给操作者的确认消息。
|
|
47
|
+
*/
|
|
48
|
+
processReview(action: 'approve' | 'reject', caveId: number, adminUserName: string): Promise<string | (string | h)[]>;
|
|
49
|
+
}
|
package/lib/Utils.d.ts
CHANGED
|
@@ -25,21 +25,6 @@ export declare function mediaElementToBase64(element: h, fileManager: FileManage
|
|
|
25
25
|
* @returns 一个包含 h() 元素和字符串的消息数组。
|
|
26
26
|
*/
|
|
27
27
|
export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
|
|
28
|
-
/**
|
|
29
|
-
* 遍历消息元素,将其转换为可存储的格式,并识别需要下载的媒体文件。
|
|
30
|
-
* @param sourceElements - 源消息中的 h() 元素数组。
|
|
31
|
-
* @param newId - 新回声洞的 ID。
|
|
32
|
-
* @param channelId - 频道 ID。
|
|
33
|
-
* @param userId - 用户 ID。
|
|
34
|
-
* @returns 包含待存储元素和待下载媒体列表的对象。
|
|
35
|
-
*/
|
|
36
|
-
export declare function prepareElementsForStorage(sourceElements: h[], newId: number, channelId: string, userId: string): Promise<{
|
|
37
|
-
finalElementsForDb: StoredElement[];
|
|
38
|
-
mediaToDownload: {
|
|
39
|
-
url: string;
|
|
40
|
-
fileName: string;
|
|
41
|
-
}[];
|
|
42
|
-
}>;
|
|
43
28
|
/**
|
|
44
29
|
* 清理数据库中所有被标记为 'delete' 状态的回声洞及其关联的文件。
|
|
45
30
|
* @param ctx - Koishi 上下文。
|
|
@@ -69,7 +54,7 @@ export declare function getNextCaveId(ctx: Context, query?: object): Promise<num
|
|
|
69
54
|
* @param fileManager - FileManager 实例。
|
|
70
55
|
* @param url - 媒体资源的 URL。
|
|
71
56
|
* @param originalName - 原始文件名,用于获取扩展名。
|
|
72
|
-
* @param type - 媒体类型 ('
|
|
57
|
+
* @param type - 媒体类型 ('image', 'video', 'audio', 'file')。
|
|
73
58
|
* @param caveId - 新建回声洞的 ID。
|
|
74
59
|
* @param index - 媒体在消息中的索引。
|
|
75
60
|
* @param channelId - 频道 ID。
|
package/lib/index.js
CHANGED
|
@@ -291,7 +291,7 @@ async function mediaElementToBase64(element, fileManager, logger2) {
|
|
|
291
291
|
return (0, import_koishi.h)(element.type, { ...element.attrs, src: `data:${mimeType};base64,${data.toString("base64")}` });
|
|
292
292
|
} catch (error) {
|
|
293
293
|
logger2.warn(`转换本地文件 ${fileName} 为 Base64 失败:`, error);
|
|
294
|
-
return import_koishi.h
|
|
294
|
+
return (0, import_koishi.h)("p", {}, `[${element.type}]`);
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
__name(mediaElementToBase64, "mediaElementToBase64");
|
|
@@ -333,38 +333,6 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
|
|
|
333
333
|
return finalMessage;
|
|
334
334
|
}
|
|
335
335
|
__name(buildCaveMessage, "buildCaveMessage");
|
|
336
|
-
async function prepareElementsForStorage(sourceElements, newId, channelId, userId) {
|
|
337
|
-
const finalElementsForDb = [];
|
|
338
|
-
const mediaToDownload = [];
|
|
339
|
-
let mediaIndex = 0;
|
|
340
|
-
const stack = [...sourceElements].reverse();
|
|
341
|
-
while (stack.length > 0) {
|
|
342
|
-
const el = stack.pop();
|
|
343
|
-
const elementType = el.type;
|
|
344
|
-
if (el.children) {
|
|
345
|
-
stack.push(...[...el.children].reverse());
|
|
346
|
-
}
|
|
347
|
-
if (["image", "video", "audio", "file"].includes(elementType) && el.attrs.src) {
|
|
348
|
-
const fileIdentifier = el.attrs.src;
|
|
349
|
-
if (fileIdentifier.startsWith("http")) {
|
|
350
|
-
mediaIndex++;
|
|
351
|
-
const originalName = el.attrs.file;
|
|
352
|
-
const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
|
|
353
|
-
const ext = originalName ? path2.extname(originalName) : "";
|
|
354
|
-
const finalExt = ext || defaultExtMap[elementType] || ".dat";
|
|
355
|
-
const generatedFileName = `${newId}_${mediaIndex}_${channelId}_${userId}${finalExt}`;
|
|
356
|
-
finalElementsForDb.push({ type: elementType, file: generatedFileName });
|
|
357
|
-
mediaToDownload.push({ url: fileIdentifier, fileName: generatedFileName });
|
|
358
|
-
} else {
|
|
359
|
-
finalElementsForDb.push({ type: elementType, file: fileIdentifier });
|
|
360
|
-
}
|
|
361
|
-
} else if (elementType === "text" && el.attrs.content?.trim()) {
|
|
362
|
-
finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return { finalElementsForDb, mediaToDownload };
|
|
366
|
-
}
|
|
367
|
-
__name(prepareElementsForStorage, "prepareElementsForStorage");
|
|
368
336
|
async function cleanupPendingDeletions(ctx, fileManager, logger2) {
|
|
369
337
|
try {
|
|
370
338
|
const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
|
|
@@ -397,6 +365,15 @@ async function getNextCaveId(ctx, query = {}) {
|
|
|
397
365
|
return newId;
|
|
398
366
|
}
|
|
399
367
|
__name(getNextCaveId, "getNextCaveId");
|
|
368
|
+
async function downloadMedia(ctx, fileManager, url, originalName, type, caveId, index, channelId, userId) {
|
|
369
|
+
const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
|
|
370
|
+
const ext = originalName ? path2.extname(originalName) : "";
|
|
371
|
+
const finalExt = ext || defaultExtMap[type] || ".dat";
|
|
372
|
+
const fileName = `${caveId}_${index}_${channelId}_${userId}${finalExt}`;
|
|
373
|
+
const response = await ctx.http.get(url, { responseType: "arraybuffer", timeout: 3e4 });
|
|
374
|
+
return fileManager.saveFile(fileName, Buffer.from(response));
|
|
375
|
+
}
|
|
376
|
+
__name(downloadMedia, "downloadMedia");
|
|
400
377
|
function checkCooldown(session, config, lastUsed) {
|
|
401
378
|
if (config.coolDown <= 0 || !session.channelId || config.adminUsers.includes(session.userId)) {
|
|
402
379
|
return null;
|
|
@@ -595,7 +572,10 @@ ${pendingIds}`;
|
|
|
595
572
|
*/
|
|
596
573
|
async buildReviewMessage(cave) {
|
|
597
574
|
const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
|
|
598
|
-
return [
|
|
575
|
+
return [
|
|
576
|
+
`以下内容待审核:`,
|
|
577
|
+
...caveContent
|
|
578
|
+
];
|
|
599
579
|
}
|
|
600
580
|
/**
|
|
601
581
|
* 处理管理员的审核决定(通过或拒绝)。
|
|
@@ -616,12 +596,16 @@ ${pendingIds}`;
|
|
|
616
596
|
broadcastMessage = `回声洞(${caveId})已由管理员 "${adminUserName}" 通过`;
|
|
617
597
|
} else {
|
|
618
598
|
await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
|
|
619
|
-
resultMessage = `回声洞(${caveId})已拒绝`;
|
|
620
599
|
const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
|
|
600
|
+
resultMessage = [
|
|
601
|
+
`回声洞(${caveId})已拒绝`,
|
|
602
|
+
...caveContent
|
|
603
|
+
];
|
|
621
604
|
broadcastMessage = [
|
|
622
605
|
`回声洞(${caveId})已由管理员 "${adminUserName}" 拒绝`,
|
|
623
606
|
...caveContent
|
|
624
607
|
];
|
|
608
|
+
cleanupPendingDeletions(this.ctx, this.fileManager, this.logger);
|
|
625
609
|
}
|
|
626
610
|
if (broadcastMessage && this.config.adminUsers?.length) {
|
|
627
611
|
await this.ctx.broadcast(this.config.adminUsers, broadcastMessage).catch((err) => {
|
|
@@ -720,6 +704,7 @@ function apply(ctx, config) {
|
|
|
720
704
|
}
|
|
721
705
|
});
|
|
722
706
|
cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
|
|
707
|
+
const savedFileIdentifiers = [];
|
|
723
708
|
try {
|
|
724
709
|
let sourceElements;
|
|
725
710
|
if (session.quote?.elements) {
|
|
@@ -734,7 +719,29 @@ function apply(ctx, config) {
|
|
|
734
719
|
}
|
|
735
720
|
const scopeQuery = getScopeQuery(session, config);
|
|
736
721
|
const newId = await getNextCaveId(ctx, scopeQuery);
|
|
737
|
-
const
|
|
722
|
+
const finalElementsForDb = [];
|
|
723
|
+
let mediaIndex = 0;
|
|
724
|
+
async function traverseAndProcess(elements) {
|
|
725
|
+
for (const el of elements) {
|
|
726
|
+
const elementType = el.type;
|
|
727
|
+
if (["image", "video", "audio", "file"].includes(elementType) && el.attrs.src) {
|
|
728
|
+
let fileIdentifier = el.attrs.src;
|
|
729
|
+
if (fileIdentifier.startsWith("http")) {
|
|
730
|
+
mediaIndex++;
|
|
731
|
+
const originalName = el.attrs.file;
|
|
732
|
+
const savedId = await downloadMedia(ctx, fileManager, fileIdentifier, originalName, elementType, newId, mediaIndex, session.channelId, session.userId);
|
|
733
|
+
savedFileIdentifiers.push(savedId);
|
|
734
|
+
fileIdentifier = savedId;
|
|
735
|
+
}
|
|
736
|
+
finalElementsForDb.push({ type: elementType, file: fileIdentifier });
|
|
737
|
+
} else if (elementType === "text" && el.attrs.content?.trim()) {
|
|
738
|
+
finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
|
|
739
|
+
}
|
|
740
|
+
if (el.children) await traverseAndProcess(el.children);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
__name(traverseAndProcess, "traverseAndProcess");
|
|
744
|
+
await traverseAndProcess(sourceElements);
|
|
738
745
|
if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
|
|
739
746
|
let userName = session.username;
|
|
740
747
|
if (config.enableProfile) {
|
|
@@ -750,16 +757,6 @@ function apply(ctx, config) {
|
|
|
750
757
|
time: /* @__PURE__ */ new Date()
|
|
751
758
|
};
|
|
752
759
|
await ctx.database.create("cave", newCave);
|
|
753
|
-
try {
|
|
754
|
-
const downloadPromises = mediaToDownload.map(async (media) => {
|
|
755
|
-
const response = await ctx.http.get(media.url, { responseType: "arraybuffer", timeout: 3e4 });
|
|
756
|
-
await fileManager.saveFile(media.fileName, Buffer.from(response));
|
|
757
|
-
});
|
|
758
|
-
await Promise.all(downloadPromises);
|
|
759
|
-
} catch (fileError) {
|
|
760
|
-
await ctx.database.remove("cave", { id: newId });
|
|
761
|
-
throw fileError;
|
|
762
|
-
}
|
|
763
760
|
if (newCave.status === "pending") {
|
|
764
761
|
reviewManager.sendForReview(newCave);
|
|
765
762
|
return `提交成功,序号为(${newCave.id})`;
|
|
@@ -767,6 +764,10 @@ function apply(ctx, config) {
|
|
|
767
764
|
return `添加成功,序号为(${newId})`;
|
|
768
765
|
} catch (error) {
|
|
769
766
|
logger.error("添加回声洞失败:", error);
|
|
767
|
+
if (savedFileIdentifiers.length > 0) {
|
|
768
|
+
logger.info(`添加失败,回滚并删除 ${savedFileIdentifiers.length} 个文件...`);
|
|
769
|
+
await Promise.all(savedFileIdentifiers.map((fileId) => fileManager.deleteFile(fileId)));
|
|
770
|
+
}
|
|
770
771
|
return "添加失败,请稍后再试";
|
|
771
772
|
}
|
|
772
773
|
});
|
|
@@ -797,10 +798,13 @@ function apply(ctx, config) {
|
|
|
797
798
|
if (!isOwner && !isAdmin) {
|
|
798
799
|
return "抱歉,你没有权限删除这条回声洞";
|
|
799
800
|
}
|
|
800
|
-
const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
|
|
801
801
|
await ctx.database.upsert("cave", [{ id, status: "delete" }]);
|
|
802
|
-
|
|
802
|
+
const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
|
|
803
803
|
cleanupPendingDeletions(ctx, fileManager, logger);
|
|
804
|
+
return [
|
|
805
|
+
`以下内容已删除`,
|
|
806
|
+
...caveMessage
|
|
807
|
+
];
|
|
804
808
|
} catch (error) {
|
|
805
809
|
logger.error(`标记回声洞(${id})失败:`, error);
|
|
806
810
|
return "删除失败,请稍后再试";
|