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.
@@ -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 - 媒体类型 ('img', 'video', 'audio', 'file')。
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.text(`[${element.type}]`);
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 [`待审核`, ...caveContent];
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 { finalElementsForDb, mediaToDownload } = await prepareElementsForStorage(sourceElements, newId, session.channelId, session.userId);
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
- session.send([`已删除`, ...caveMessage]);
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 "删除失败,请稍后再试";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "最强大的回声洞现已重构完成啦!注意数据格式需要使用脚本转换哦~",
4
- "version": "2.0.4",
4
+ "version": "2.0.6",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],