koishi-plugin-best-cave 2.0.8 → 2.1.0

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/Utils.d.ts CHANGED
@@ -39,11 +39,6 @@ export declare function getScopeQuery(session: Session, config: Config): object;
39
39
  * @performance 在大数据集下,此函数可能存在性能瓶颈,因为它需要获取所有现有ID。
40
40
  */
41
41
  export declare function getNextCaveId(ctx: Context, query?: object): Promise<number>;
42
- /**
43
- * @description 下载网络媒体资源并保存到文件存储中。
44
- * @returns 保存后的文件名/标识符。
45
- */
46
- export declare function downloadMedia(ctx: Context, fileManager: FileManager, url: string, originalName: string, type: string, caveId: number, index: number, channelId: string, userId: string): Promise<string>;
47
42
  /**
48
43
  * @description 检查用户是否处于指令冷却中。
49
44
  * @returns 若在冷却中则返回提示字符串,否则返回 null。
@@ -53,3 +48,32 @@ export declare function checkCooldown(session: Session, config: Config, lastUsed
53
48
  * @description 更新指定频道的指令使用时间戳。
54
49
  */
55
50
  export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
51
+ /**
52
+ * @description 解析消息元素,分离出文本和待下载的媒体文件。
53
+ * @param sourceElements - 原始的 Koishi 消息元素数组。
54
+ * @param newId - 这条回声洞的新 ID。
55
+ * @param channelId - 频道 ID。
56
+ * @param userId - 用户 ID。
57
+ * @returns 一个包含数据库元素和待保存媒体列表的对象。
58
+ */
59
+ export declare function processMessageElements(sourceElements: h[], newId: number, channelId: string, userId: string): Promise<{
60
+ finalElementsForDb: StoredElement[];
61
+ mediaToSave: {
62
+ sourceUrl: string;
63
+ fileName: string;
64
+ }[];
65
+ }>;
66
+ /**
67
+ * @description 异步处理文件上传和状态更新的后台任务。
68
+ * @param ctx - Koishi 上下文。
69
+ * @param config - 插件配置。
70
+ * @param fileManager - 文件管理器实例。
71
+ * @param logger - 日志记录器实例。
72
+ * @param reviewManager - 审核管理器实例 (可能为 null)。
73
+ * @param cave - 已创建的、状态为 'preload' 的回声洞对象。
74
+ * @param mediaToSave - 需要下载和保存的媒体文件列表。
75
+ */
76
+ export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: any, cave: CaveObject, mediaToSave: {
77
+ sourceUrl: string;
78
+ fileName: string;
79
+ }[]): Promise<void>;
package/lib/index.js CHANGED
@@ -38,7 +38,6 @@ __export(index_exports, {
38
38
  });
39
39
  module.exports = __toCommonJS(index_exports);
40
40
  var import_koishi3 = require("koishi");
41
- var path3 = __toESM(require("path"));
42
41
 
43
42
  // src/FileManager.ts
44
43
  var import_client_s3 = require("@aws-sdk/client-s3");
@@ -103,7 +102,6 @@ var FileManager = class {
103
102
  Key: fileName,
104
103
  Body: data,
105
104
  ACL: "public-read"
106
- // 默认为公开可读
107
105
  });
108
106
  await this.s3Client.send(command);
109
107
  } else {
@@ -136,22 +134,18 @@ var FileManager = class {
136
134
  * @param fileIdentifier 要删除的文件名/标识符。
137
135
  */
138
136
  async deleteFile(fileIdentifier) {
139
- if (this.s3Client) {
140
- const command = new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier });
141
- await this.s3Client.send(command).catch((err) => {
142
- this.logger.warn(`删除 S3 文件 ${fileIdentifier} 失败:`, err);
143
- });
144
- } else {
145
- const filePath = path.join(this.resourceDir, fileIdentifier);
146
- await this.withLock(filePath, async () => {
147
- try {
148
- await fs.unlink(filePath);
149
- } catch (error) {
150
- if (error.code !== "ENOENT") {
151
- this.logger.warn(`删除本地文件 ${filePath} 失败:`, error);
152
- }
153
- }
154
- });
137
+ try {
138
+ if (this.s3Client) {
139
+ const command = new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier });
140
+ await this.s3Client.send(command);
141
+ } else {
142
+ const filePath = path.join(this.resourceDir, fileIdentifier);
143
+ await this.withLock(filePath, () => fs.unlink(filePath));
144
+ }
145
+ } catch (error) {
146
+ if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
147
+ this.logger.warn(`删除文件 ${fileIdentifier} 失败:`, error);
148
+ }
155
149
  }
156
150
  }
157
151
  };
@@ -187,10 +181,9 @@ var ProfileManager = class {
187
181
  if (trimmedNickname) {
188
182
  await this.setNickname(session.userId, trimmedNickname);
189
183
  return `昵称已更新为:${trimmedNickname}`;
190
- } else {
191
- await this.clearNickname(session.userId);
192
- return "昵称已清除";
193
184
  }
185
+ await this.clearNickname(session.userId);
186
+ return "昵称已清除";
194
187
  });
195
188
  }
196
189
  /**
@@ -207,8 +200,8 @@ var ProfileManager = class {
207
200
  * @returns 返回用户的昵称字符串,如果未设置则返回 null。
208
201
  */
209
202
  async getNickname(userId) {
210
- const profile = await this.ctx.database.get("cave_user", { userId });
211
- return profile[0]?.nickname ?? null;
203
+ const [profile] = await this.ctx.database.get("cave_user", { userId }, { fields: ["nickname"] });
204
+ return profile?.nickname ?? null;
212
205
  }
213
206
  /**
214
207
  * @description 清除指定用户的昵称设置。
@@ -238,31 +231,28 @@ async function buildCaveMessage(cave, config, fileManager, logger2) {
238
231
  const fileName = element.attrs.src;
239
232
  if (!isMedia || !fileName) return element;
240
233
  if (config.enableS3 && config.publicUrl) {
241
- const fullUrl = config.publicUrl.endsWith("/") ? `${config.publicUrl}${fileName}` : `${config.publicUrl}/${fileName}`;
234
+ const fullUrl = new URL(fileName, config.publicUrl).href;
242
235
  return (0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl });
243
236
  }
244
237
  if (config.localPath) {
245
- const fileUri = `file://${path2.join(config.localPath, fileName)}`;
246
- return (0, import_koishi.h)(element.type, { ...element.attrs, src: fileUri });
238
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: `file://${path2.join(config.localPath, fileName)}` });
247
239
  }
248
240
  try {
249
241
  const data = await fileManager.readFile(fileName);
250
- const ext = path2.extname(fileName).toLowerCase();
251
- const mimeType = mimeTypeMap[ext] || "application/octet-stream";
242
+ const mimeType = mimeTypeMap[path2.extname(fileName).toLowerCase()] || "application/octet-stream";
252
243
  return (0, import_koishi.h)(element.type, { ...element.attrs, src: `data:${mimeType};base64,${data.toString("base64")}` });
253
244
  } catch (error) {
254
245
  logger2.warn(`转换文件 ${fileName} 为 Base64 失败:`, error);
255
246
  return (0, import_koishi.h)("p", {}, `[${element.type}]`);
256
247
  }
257
248
  }));
258
- const finalMessage = [];
259
- const [headerFormat, footerFormat = ""] = config.caveFormat.split("|");
260
249
  const replacements = { id: cave.id.toString(), name: cave.userName };
261
- const headerText = headerFormat.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]);
262
- if (headerText.trim()) finalMessage.push(headerText);
250
+ const formatPart = /* @__PURE__ */ __name((part) => part.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]).trim(), "formatPart");
251
+ const [header, footer] = config.caveFormat.split("|", 2).map(formatPart);
252
+ const finalMessage = [];
253
+ if (header) finalMessage.push(header + "\n");
263
254
  finalMessage.push(...processedElements);
264
- const footerText = footerFormat.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]);
265
- if (footerText.trim()) finalMessage.push(footerText);
255
+ if (footer) finalMessage.push("\n" + footer);
266
256
  return finalMessage;
267
257
  }
268
258
  __name(buildCaveMessage, "buildCaveMessage");
@@ -297,11 +287,10 @@ async function getNextCaveId(ctx, query = {}) {
297
287
  __name(getNextCaveId, "getNextCaveId");
298
288
  function checkCooldown(session, config, lastUsed) {
299
289
  if (config.coolDown <= 0 || !session.channelId) return null;
300
- const now = Date.now();
301
290
  const lastTime = lastUsed.get(session.channelId) || 0;
302
- if (now - lastTime < config.coolDown * 1e3) {
303
- const waitTime = Math.ceil((config.coolDown * 1e3 - (now - lastTime)) / 1e3);
304
- return `指令冷却中,请在 ${waitTime} 秒后重试`;
291
+ const remainingTime = lastTime + config.coolDown * 1e3 - Date.now();
292
+ if (remainingTime > 0) {
293
+ return `指令冷却中,请在 ${Math.ceil(remainingTime / 1e3)} 秒后重试`;
305
294
  }
306
295
  return null;
307
296
  }
@@ -312,6 +301,64 @@ function updateCooldownTimestamp(session, config, lastUsed) {
312
301
  }
313
302
  }
314
303
  __name(updateCooldownTimestamp, "updateCooldownTimestamp");
304
+ async function processMessageElements(sourceElements, newId, channelId, userId) {
305
+ const finalElementsForDb = [];
306
+ const mediaToSave = [];
307
+ let mediaIndex = 0;
308
+ const typeMap = {
309
+ "img": "image",
310
+ "image": "image",
311
+ "video": "video",
312
+ "audio": "audio",
313
+ "file": "file",
314
+ "text": "text"
315
+ };
316
+ const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
317
+ async function traverse(elements) {
318
+ for (const el of elements) {
319
+ const normalizedType = typeMap[el.type];
320
+ if (normalizedType) {
321
+ if (["image", "video", "audio", "file"].includes(normalizedType) && el.attrs.src) {
322
+ let fileIdentifier = el.attrs.src;
323
+ if (fileIdentifier.startsWith("http")) {
324
+ const ext = path2.extname(el.attrs.file || "") || defaultExtMap[normalizedType];
325
+ const fileName = `${newId}_${++mediaIndex}_${channelId || "private"}_${userId}${ext}`;
326
+ mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
327
+ fileIdentifier = fileName;
328
+ }
329
+ finalElementsForDb.push({ type: normalizedType, file: fileIdentifier });
330
+ } else if (normalizedType === "text" && el.attrs.content?.trim()) {
331
+ finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
332
+ }
333
+ }
334
+ if (el.children) await traverse(el.children);
335
+ }
336
+ }
337
+ __name(traverse, "traverse");
338
+ await traverse(sourceElements);
339
+ return { finalElementsForDb, mediaToSave };
340
+ }
341
+ __name(processMessageElements, "processMessageElements");
342
+ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManager, cave, mediaToSave) {
343
+ try {
344
+ const uploadPromises = mediaToSave.map(async (media) => {
345
+ const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
346
+ await fileManager.saveFile(media.fileName, Buffer.from(response));
347
+ });
348
+ await Promise.all(uploadPromises);
349
+ const finalStatus = config.enableReview ? "pending" : "active";
350
+ await ctx.database.upsert("cave", [{ id: cave.id, status: finalStatus }]);
351
+ if (finalStatus === "pending" && reviewManager) {
352
+ const [finalCave] = await ctx.database.get("cave", { id: cave.id });
353
+ if (finalCave) reviewManager.sendForReview(finalCave);
354
+ }
355
+ } catch (fileSaveError) {
356
+ logger2.error(`回声洞(${cave.id})文件保存失败:`, fileSaveError);
357
+ await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
358
+ cleanupPendingDeletions(ctx, fileManager, logger2);
359
+ }
360
+ }
361
+ __name(handleFileUploads, "handleFileUploads");
315
362
 
316
363
  // src/DataManager.ts
317
364
  var DataManager = class {
@@ -336,26 +383,19 @@ var DataManager = class {
336
383
  * @param cave - 主 `cave` 命令实例。
337
384
  */
338
385
  registerCommands(cave) {
339
- cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(async ({ session }) => {
340
- if (session.channelId !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
386
+ const requireAdmin = /* @__PURE__ */ __name((action) => async ({ session }) => {
387
+ const adminChannelId = this.config.adminChannel?.split(":")[1];
388
+ if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
341
389
  try {
342
- await session.send("正在导出数据,请稍候...");
343
- return await this.exportData();
390
+ await session.send("正在处理,请稍候...");
391
+ return await action();
344
392
  } catch (error) {
345
- this.logger.error("导出数据时发生错误:", error);
346
- return `导出失败: ${error.message}`;
393
+ this.logger.error("数据操作时发生错误:", error);
394
+ return `操作失败: ${error.message || "未知错误"}`;
347
395
  }
348
- });
349
- cave.subcommand(".import", "导入回声洞数据").usage(" cave_import.json 中导入回声洞数据。").action(async ({ session }) => {
350
- if (session.channelId !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
351
- try {
352
- await session.send("正在导入数据,请稍候...");
353
- return await this.importData();
354
- } catch (error) {
355
- this.logger.error("导入数据时发生错误:", error);
356
- return `导入失败: ${error.message}`;
357
- }
358
- });
396
+ }, "requireAdmin");
397
+ cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json").action(requireAdmin(() => this.exportData()));
398
+ cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(requireAdmin(() => this.importData()));
359
399
  }
360
400
  /**
361
401
  * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
@@ -382,18 +422,12 @@ var DataManager = class {
382
422
  if (!Array.isArray(importedCaves)) throw new Error("导入文件格式无效");
383
423
  } catch (error) {
384
424
  this.logger.error(`读取导入文件失败:`, error);
385
- return `读取导入文件失败: ${error.message || "未知错误"}`;
425
+ throw new Error(`读取导入文件失败: ${error.message || "未知错误"}`);
386
426
  }
387
427
  let successCount = 0;
388
428
  for (const cave of importedCaves) {
389
429
  const newId = await getNextCaveId(this.ctx, {});
390
- const newCave = {
391
- ...cave,
392
- id: newId,
393
- channelId: cave.channelId || null,
394
- // 保证 channelId 存在
395
- status: "active"
396
- };
430
+ const newCave = { ...cave, id: newId, status: "active" };
397
431
  await this.ctx.database.create("cave", newCave);
398
432
  successCount++;
399
433
  }
@@ -426,26 +460,29 @@ var ReviewManager = class {
426
460
  */
427
461
  registerCommands(cave) {
428
462
  cave.subcommand(".review [id:posint] [action:string]", "审核回声洞").usage("查看或审核回声洞,使用 <Y/N> 进行审核。").action(async ({ session }, id, action) => {
429
- if (session.channelId !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
463
+ const adminChannelId = this.config.adminChannel?.split(":")[1];
464
+ if (session.channelId !== adminChannelId) return "此指令仅限在管理群组中使用";
430
465
  if (!id) {
431
- const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
432
- if (pendingCaves.length === 0) return "当前没有需要审核的回声洞";
466
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
467
+ if (!pendingCaves.length) return "当前没有需要审核的回声洞";
433
468
  return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
434
469
  ${pendingCaves.map((c) => c.id).join(", ")}`;
435
470
  }
436
471
  const [targetCave] = await this.ctx.database.get("cave", { id });
437
472
  if (!targetCave) return `回声洞(${id})不存在`;
438
473
  if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
439
- if (id && !action) {
474
+ if (!action) {
440
475
  return [`待审核:`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
441
476
  }
442
477
  const normalizedAction = action.toLowerCase();
443
- let reviewAction;
444
- if (["y", "yes", "ok", "pass", "approve"].includes(normalizedAction)) reviewAction = "approve";
445
- else if (["n", "no", "deny", "reject"].includes(normalizedAction)) reviewAction = "reject";
446
- else return `无效操作: "${action}"
478
+ if (["y", "yes", "pass", "approve"].includes(normalizedAction)) {
479
+ return this.processReview("approve", id);
480
+ }
481
+ if (["n", "no", "deny", "reject"].includes(normalizedAction)) {
482
+ return this.processReview("reject", id);
483
+ }
484
+ return `无效操作: "${action}"
447
485
  请使用 "Y" (通过) 或 "N" (拒绝)`;
448
- return this.processReview(reviewAction, id);
449
486
  });
450
487
  }
451
488
  /**
@@ -453,13 +490,13 @@ ${pendingCaves.map((c) => c.id).join(", ")}`;
453
490
  * @param cave 新创建的、状态为 'pending' 的回声洞对象。
454
491
  */
455
492
  async sendForReview(cave) {
456
- if (!this.config.adminChannel) {
457
- this.logger.warn(`未配置管理群组,已自动通过回声洞(${cave.id})`);
493
+ if (!this.config.adminChannel?.includes(":")) {
494
+ this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`);
458
495
  await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
459
496
  return;
460
497
  }
461
- const reviewMessage = [`待审核:`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
462
498
  try {
499
+ const reviewMessage = [`待审核:`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
463
500
  await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(reviewMessage));
464
501
  } catch (error) {
465
502
  this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
@@ -474,16 +511,14 @@ ${pendingCaves.map((c) => c.id).join(", ")}`;
474
511
  async processReview(action, caveId) {
475
512
  const [cave] = await this.ctx.database.get("cave", { id: caveId, status: "pending" });
476
513
  if (!cave) return `回声洞(${caveId})无需审核`;
477
- let resultMessage;
478
514
  if (action === "approve") {
479
515
  await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
480
- resultMessage = `回声洞(${caveId})已通过`;
516
+ return `回声洞(${caveId})已通过`;
481
517
  } else {
482
518
  await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
483
- resultMessage = `回声洞(${caveId})已拒绝`;
484
519
  cleanupPendingDeletions(this.ctx, this.fileManager, this.logger);
520
+ return `回声洞(${caveId})已拒绝`;
485
521
  }
486
- return resultMessage;
487
522
  }
488
523
  };
489
524
 
@@ -509,7 +544,7 @@ var Config = import_koishi3.Schema.intersect([
509
544
  perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
510
545
  enableProfile: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
511
546
  enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
512
- adminChannel: import_koishi3.Schema.string().description("管理群组 ID"),
547
+ adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
513
548
  caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本")
514
549
  }).description("基础配置"),
515
550
  import_koishi3.Schema.object({
@@ -538,10 +573,10 @@ function apply(ctx, config) {
538
573
  }, { primary: "id" });
539
574
  const fileManager = new FileManager(ctx.baseDir, config, logger);
540
575
  const lastUsed = /* @__PURE__ */ new Map();
541
- let profileManager;
542
- let dataManager;
543
- let reviewManager;
544
- 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 }) => {
576
+ const profileManager = config.enableProfile ? new ProfileManager(ctx) : null;
577
+ const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger) : null;
578
+ const reviewManager = config.enableReview ? new ReviewManager(ctx, config, fileManager, logger) : null;
579
+ 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 }) => {
545
580
  if (options.add) return session.execute(`cave.add ${options.add}`);
546
581
  if (options.view) return session.execute(`cave.view ${options.view}`);
547
582
  if (options.delete) return session.execute(`cave.del ${options.delete}`);
@@ -551,7 +586,7 @@ function apply(ctx, config) {
551
586
  try {
552
587
  const query = getScopeQuery(session, config);
553
588
  const candidates = await ctx.database.get("cave", query, { fields: ["id"] });
554
- if (candidates.length === 0) {
589
+ if (!candidates.length) {
555
590
  return `当前${config.perChannel && session.channelId ? "本群" : ""}还没有任何回声洞`;
556
591
  }
557
592
  const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
@@ -565,89 +600,44 @@ function apply(ctx, config) {
565
600
  });
566
601
  cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
567
602
  try {
568
- let sourceElements;
569
- if (session.quote?.elements) {
570
- sourceElements = session.quote.elements;
571
- } else if (content?.trim()) {
603
+ let sourceElements = session.quote?.elements;
604
+ if (!sourceElements && content?.trim()) {
572
605
  sourceElements = import_koishi3.h.parse(content);
573
- } else {
606
+ }
607
+ if (!sourceElements) {
574
608
  await session.send("请在一分钟内发送你要添加的内容");
575
609
  const reply = await session.prompt(6e4);
576
610
  if (!reply) return "操作超时,已取消添加";
577
611
  sourceElements = import_koishi3.h.parse(reply);
578
612
  }
579
- const idScopeQuery = {};
580
- if (config.perChannel && session.channelId) {
581
- idScopeQuery["channelId"] = session.channelId;
582
- }
613
+ const idScopeQuery = config.perChannel && session.channelId ? { channelId: session.channelId } : {};
583
614
  const newId = await getNextCaveId(ctx, idScopeQuery);
584
- const finalElementsForDb = [];
585
- const mediaToSave = [];
586
- let mediaIndex = 0;
587
- const typeMap = {
588
- "img": "image",
589
- "image": "image",
590
- "video": "video",
591
- "audio": "audio",
592
- "file": "file",
593
- "text": "text"
594
- };
595
- async function traverseAndProcess(elements) {
596
- for (const el of elements) {
597
- const normalizedType = typeMap[el.type];
598
- if (!normalizedType) {
599
- if (el.children) await traverseAndProcess(el.children);
600
- continue;
601
- }
602
- if (["image", "video", "audio", "file"].includes(normalizedType) && el.attrs.src) {
603
- let fileIdentifier = el.attrs.src;
604
- if (fileIdentifier.startsWith("http")) {
605
- mediaIndex++;
606
- const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
607
- const ext = el.attrs.file && path3.extname(el.attrs.file) ? path3.extname(el.attrs.file) : defaultExtMap[normalizedType] || ".dat";
608
- const channelIdentifier = session.channelId || "private";
609
- const fileName = `${newId}_${mediaIndex}_${channelIdentifier}_${session.userId}${ext}`;
610
- mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
611
- fileIdentifier = fileName;
612
- }
613
- finalElementsForDb.push({ type: normalizedType, file: fileIdentifier });
614
- } else if (normalizedType === "text" && el.attrs.content?.trim()) {
615
- finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
616
- }
617
- if (el.children) {
618
- await traverseAndProcess(el.children);
619
- }
620
- }
621
- }
622
- __name(traverseAndProcess, "traverseAndProcess");
623
- await traverseAndProcess(sourceElements);
615
+ const { finalElementsForDb, mediaToSave } = await processMessageElements(
616
+ sourceElements,
617
+ newId,
618
+ session.channelId,
619
+ session.userId
620
+ );
624
621
  if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
625
- const customNickname = config.enableProfile ? await profileManager.getNickname(session.userId) : null;
622
+ const userName = (config.enableProfile ? await profileManager.getNickname(session.userId) : null) || session.username;
623
+ const hasMedia = mediaToSave.length > 0;
624
+ const initialStatus = hasMedia ? "preload" : config.enableReview ? "pending" : "active";
626
625
  const newCave = {
627
626
  id: newId,
628
627
  elements: finalElementsForDb,
629
628
  channelId: session.channelId,
630
629
  userId: session.userId,
631
- userName: customNickname || session.username,
632
- status: config.enableReview ? "pending" : "active",
630
+ userName,
631
+ status: initialStatus,
633
632
  time: /* @__PURE__ */ new Date()
634
633
  };
635
634
  await ctx.database.create("cave", newCave);
636
- try {
637
- await Promise.all(mediaToSave.map(async (media) => {
638
- const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
639
- await fileManager.saveFile(media.fileName, Buffer.from(response));
640
- }));
641
- } catch (fileSaveError) {
642
- logger.error(`文件保存失败:`, fileSaveError);
643
- await ctx.database.remove("cave", { id: newId });
644
- throw fileSaveError;
645
- }
646
- if (newCave.status === "pending") {
635
+ if (hasMedia) {
636
+ handleFileUploads(ctx, config, fileManager, logger, reviewManager, newCave, mediaToSave);
637
+ } else if (initialStatus === "pending") {
647
638
  reviewManager.sendForReview(newCave);
648
- return `提交成功,序号为(${newCave.id})`;
649
639
  }
650
- return `添加成功,序号为(${newId})`;
640
+ return initialStatus === "pending" || initialStatus === "preload" && config.enableReview ? `提交成功,序号为(${newId})` : `添加成功,序号为(${newId})`;
651
641
  } catch (error) {
652
642
  logger.error("添加回声洞失败:", error);
653
643
  return "添加失败,请稍后再试";
@@ -673,7 +663,10 @@ function apply(ctx, config) {
673
663
  try {
674
664
  const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
675
665
  if (!targetCave) return `回声洞(${id})不存在`;
676
- if (targetCave.userId !== session.userId && session.channelId !== config.adminChannel) {
666
+ const adminChannelId = config.adminChannel?.split(":")[1];
667
+ const isAuthor = targetCave.userId === session.userId;
668
+ const isAdmin = session.channelId === adminChannelId;
669
+ if (!isAuthor && !isAdmin) {
677
670
  return "你没有权限删除这条回声洞";
678
671
  }
679
672
  await ctx.database.upsert("cave", [{ id, status: "delete" }]);
@@ -688,8 +681,8 @@ function apply(ctx, config) {
688
681
  cave.subcommand(".list", "查询我的投稿").usage("查询并列出你所有投稿的回声洞序号。").action(async ({ session }) => {
689
682
  try {
690
683
  const query = { ...getScopeQuery(session, config), userId: session.userId };
691
- const userCaves = await ctx.database.get("cave", query);
692
- if (userCaves.length === 0) return "你还没有投稿过回声洞";
684
+ const userCaves = await ctx.database.get("cave", query, { fields: ["id"] });
685
+ if (!userCaves.length) return "你还没有投稿过回声洞";
693
686
  const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join(", ");
694
687
  return `你已投稿 ${userCaves.length} 条回声洞,序号为:
695
688
  ${caveIds}`;
@@ -698,18 +691,9 @@ ${caveIds}`;
698
691
  return "查询失败,请稍后再试";
699
692
  }
700
693
  });
701
- if (config.enableProfile) {
702
- profileManager = new ProfileManager(ctx);
703
- profileManager.registerCommands(cave);
704
- }
705
- if (config.enableIO) {
706
- dataManager = new DataManager(ctx, config, fileManager, logger);
707
- dataManager.registerCommands(cave);
708
- }
709
- if (config.enableReview) {
710
- reviewManager = new ReviewManager(ctx, config, fileManager, logger);
711
- reviewManager.registerCommands(cave);
712
- }
694
+ if (profileManager) profileManager.registerCommands(cave);
695
+ if (dataManager) dataManager.registerCommands(cave);
696
+ if (reviewManager) reviewManager.registerCommands(cave);
713
697
  }
714
698
  __name(apply, "apply");
715
699
  // Annotate the CommonJS export names for ESM import in node:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-best-cave",
3
3
  "description": "最强大的回声洞现已重构完成啦!注意数据格式需要使用脚本转换哦~",
4
- "version": "2.0.8",
4
+ "version": "2.1.0",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
@@ -1,36 +0,0 @@
1
- import { Context, Logger } from 'koishi';
2
- import { FileManager } from './FileManager';
3
- import { Config } from './index';
4
- /**
5
- * @class DataManager
6
- * @description 负责处理回声洞数据的导入和导出功能。
7
- */
8
- export declare class DataManager {
9
- private ctx;
10
- private config;
11
- private fileManager;
12
- private logger;
13
- /**
14
- * @constructor
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
- * @description 注册 `.export` 和 `.import` 子命令。
23
- * @param cave - 主 `cave` 命令实例。
24
- */
25
- registerCommands(cave: any): void;
26
- /**
27
- * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
28
- * @returns 描述导出结果的消息字符串。
29
- */
30
- exportData(): Promise<string>;
31
- /**
32
- * @description 从 `cave_import.json` 文件导入回声洞数据。
33
- * @returns 描述导入结果的消息字符串。
34
- */
35
- importData(): Promise<string>;
36
- }
@@ -1,48 +0,0 @@
1
- import { Logger } from 'koishi';
2
- import { Config } from './index';
3
- /**
4
- * @class FileManager
5
- * @description 封装了对文件的存储、读取和删除操作。
6
- * 能根据配置自动选择使用本地文件系统或 AWS S3 作为存储后端。
7
- * 内置 Promise 文件锁,防止本地文件的并发写入冲突。
8
- */
9
- export declare class FileManager {
10
- private logger;
11
- private resourceDir;
12
- private locks;
13
- private s3Client?;
14
- private s3Bucket?;
15
- /**
16
- * @constructor
17
- * @param baseDir Koishi 应用的基础数据目录 (ctx.baseDir)。
18
- * @param config 插件的配置对象。
19
- * @param logger 日志记录器实例。
20
- */
21
- constructor(baseDir: string, config: Config, logger: Logger);
22
- /**
23
- * @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。
24
- * @template T 异步操作的返回类型。
25
- * @param fullPath 需要加锁的文件的完整路径。
26
- * @param operation 要执行的异步函数。
27
- * @returns 返回异步操作的结果。
28
- */
29
- private withLock;
30
- /**
31
- * @description 保存文件,自动选择 S3 或本地存储。
32
- * @param fileName 用作 S3 Key 或本地文件名。
33
- * @param data 要写入的 Buffer 数据。
34
- * @returns 返回保存时使用的文件名/标识符。
35
- */
36
- saveFile(fileName: string, data: Buffer): Promise<string>;
37
- /**
38
- * @description 读取文件,自动从 S3 或本地存储读取。
39
- * @param fileName 要读取的文件名/标识符。
40
- * @returns 文件的 Buffer 数据。
41
- */
42
- readFile(fileName: string): Promise<Buffer>;
43
- /**
44
- * @description 删除文件,自动从 S3 或本地删除。
45
- * @param fileIdentifier 要删除的文件名/标识符。
46
- */
47
- deleteFile(fileIdentifier: string): Promise<void>;
48
- }
@@ -1,50 +0,0 @@
1
- import { Context } from 'koishi';
2
- /**
3
- * @description 数据库 `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
- * @class ProfileManager
18
- * @description 负责管理用户在回声洞中的自定义昵称。
19
- * 当插件配置 `enableProfile` 为 true 时实例化。
20
- */
21
- export declare class ProfileManager {
22
- private ctx;
23
- /**
24
- * @constructor
25
- * @param ctx - Koishi 上下文,用于初始化数据库模型。
26
- */
27
- constructor(ctx: Context);
28
- /**
29
- * @description 注册 `.profile` 子命令,用于管理用户昵称。
30
- * @param cave - 主 `cave` 命令实例。
31
- */
32
- registerCommands(cave: any): void;
33
- /**
34
- * @description 设置或更新指定用户的昵称。
35
- * @param userId - 目标用户的 ID。
36
- * @param nickname - 要设置的新昵称。
37
- */
38
- setNickname(userId: string, nickname: string): Promise<void>;
39
- /**
40
- * @description 获取指定用户的昵称。
41
- * @param userId - 目标用户的 ID。
42
- * @returns 返回用户的昵称字符串,如果未设置则返回 null。
43
- */
44
- getNickname(userId: string): Promise<string | null>;
45
- /**
46
- * @description 清除指定用户的昵称设置。
47
- * @param userId - 目标用户的 ID。
48
- */
49
- clearNickname(userId: string): Promise<void>;
50
- }
@@ -1,38 +0,0 @@
1
- import { Context, Logger } from 'koishi';
2
- import { CaveObject, Config } from './index';
3
- import { FileManager } from './FileManager';
4
- /**
5
- * @class ReviewManager
6
- * @description 负责处理回声洞的审核流程,处理新洞的提交、审核通知和审核操作。
7
- */
8
- export declare class ReviewManager {
9
- private ctx;
10
- private config;
11
- private fileManager;
12
- private logger;
13
- /**
14
- * @constructor
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
- * @description 注册与审核相关的 `.review` 子命令。
23
- * @param cave - 主 `cave` 命令实例。
24
- */
25
- registerCommands(cave: any): void;
26
- /**
27
- * @description 将新回声洞提交到管理群组以供审核。
28
- * @param cave 新创建的、状态为 'pending' 的回声洞对象。
29
- */
30
- sendForReview(cave: CaveObject): Promise<void>;
31
- /**
32
- * @description 处理管理员的审核决定(通过或拒绝)。
33
- * @param action 'approve' (通过) 或 'reject' (拒绝)。
34
- * @param caveId 被审核的回声洞 ID。
35
- * @returns 返回给操作者的确认消息。
36
- */
37
- processReview(action: 'approve' | 'reject', caveId: number): Promise<string>;
38
- }
package/lib/index.d.ts DELETED
@@ -1,48 +0,0 @@
1
- import { Context, Schema } from 'koishi';
2
- export declare const name = "best-cave";
3
- export declare const inject: string[];
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<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
- /**
6
- * @description 存储在数据库中的单个消息元素。
7
- */
8
- export interface StoredElement {
9
- type: 'text' | 'image' | 'video' | 'audio' | 'file';
10
- content?: string;
11
- file?: string;
12
- }
13
- /**
14
- * @description 数据库 `cave` 表的完整对象模型。
15
- */
16
- export interface CaveObject {
17
- id: number;
18
- elements: StoredElement[];
19
- channelId: string;
20
- userId: string;
21
- userName: string;
22
- status: 'active' | 'delete' | 'pending';
23
- time: Date;
24
- }
25
- declare module 'koishi' {
26
- interface Tables {
27
- cave: CaveObject;
28
- }
29
- }
30
- export interface Config {
31
- coolDown: number;
32
- perChannel: boolean;
33
- adminChannel: string;
34
- enableProfile: boolean;
35
- enableIO: boolean;
36
- enableReview: boolean;
37
- caveFormat: string;
38
- localPath?: string;
39
- enableS3: boolean;
40
- endpoint?: string;
41
- region?: string;
42
- accessKeyId?: string;
43
- secretAccessKey?: string;
44
- bucket?: string;
45
- publicUrl?: string;
46
- }
47
- export declare const Config: Schema<Config>;
48
- export declare function apply(ctx: Context, config: Config): void;