koishi-plugin-best-cave 2.0.5 → 2.0.7

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/index.js CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  });
39
39
  module.exports = __toCommonJS(index_exports);
40
40
  var import_koishi2 = require("koishi");
41
+ var path3 = __toESM(require("path"));
41
42
 
42
43
  // src/FileManager.ts
43
44
  var import_client_s3 = require("@aws-sdk/client-s3");
@@ -45,9 +46,10 @@ var fs = __toESM(require("fs/promises"));
45
46
  var path = __toESM(require("path"));
46
47
  var FileManager = class {
47
48
  /**
48
- * @param baseDir - Koishi 应用的基础数据目录 (ctx.baseDir)。
49
- * @param config - 插件的配置对象。
50
- * @param logger - 日志记录器实例。
49
+ * @constructor
50
+ * @param baseDir Koishi 应用的基础数据目录 (ctx.baseDir)。
51
+ * @param config 插件的配置对象。
52
+ * @param logger 日志记录器实例。
51
53
  */
52
54
  constructor(baseDir, config, logger2) {
53
55
  this.logger = logger2;
@@ -72,55 +74,27 @@ var FileManager = class {
72
74
  s3Client;
73
75
  s3Bucket;
74
76
  /**
75
- * 确保本地资源目录存在,若不存在则以递归方式创建。
76
- * @private
77
- */
78
- async ensureDirectory() {
79
- try {
80
- await fs.mkdir(this.resourceDir, { recursive: true });
81
- } catch (error) {
82
- this.logger.error(`Failed to create resource directory ${this.resourceDir}:`, error);
83
- throw error;
84
- }
85
- }
86
- /**
87
- * 获取给定文件名的完整本地路径。
88
- * @param fileName - 文件名。
89
- * @returns 文件的绝对路径。
90
- * @private
91
- */
92
- getFullPath(fileName) {
93
- return path.join(this.resourceDir, fileName);
94
- }
95
- /**
96
- * 使用文件锁安全地执行一个异步文件操作,防止对同一文件的并发访问。
97
- * @template T - 异步操作的返回类型。
98
- * @param fileName - 需要加锁的文件名。
99
- * @param operation - 要执行的异步函数。
77
+ * @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。
78
+ * @template T 异步操作的返回类型。
79
+ * @param fullPath 需要加锁的文件的完整路径。
80
+ * @param operation 要执行的异步函数。
100
81
  * @returns 返回异步操作的结果。
101
- * @private
102
82
  */
103
- async withLock(fileName, operation) {
104
- const fullPath = this.getFullPath(fileName);
83
+ async withLock(fullPath, operation) {
105
84
  while (this.locks.has(fullPath)) {
106
- await this.locks.get(fullPath).catch(() => {
107
- });
85
+ await this.locks.get(fullPath);
108
86
  }
109
- const promise = operation();
87
+ const promise = operation().finally(() => {
88
+ this.locks.delete(fullPath);
89
+ });
110
90
  this.locks.set(fullPath, promise);
111
- try {
112
- return await promise;
113
- } finally {
114
- if (this.locks.get(fullPath) === promise) {
115
- this.locks.delete(fullPath);
116
- }
117
- }
91
+ return promise;
118
92
  }
119
93
  /**
120
- * 保存文件,自动路由到 S3 或本地存储。
121
- * @param fileName - 文件名,将用作 S3 Key 或本地文件名。
122
- * @param data - 要写入的 Buffer 数据。
123
- * @returns 返回保存时使用的文件名。
94
+ * @description 保存文件,自动选择 S3 或本地存储。
95
+ * @param fileName 用作 S3 Key 或本地文件名。
96
+ * @param data 要写入的 Buffer 数据。
97
+ * @returns 返回保存时使用的文件名/标识符。
124
98
  */
125
99
  async saveFile(fileName, data) {
126
100
  if (this.s3Client) {
@@ -129,55 +103,52 @@ var FileManager = class {
129
103
  Key: fileName,
130
104
  Body: data,
131
105
  ACL: "public-read"
106
+ // 默认为公开可读
132
107
  });
133
108
  await this.s3Client.send(command);
134
109
  } else {
135
- await this.ensureDirectory();
136
- const filePath = this.getFullPath(fileName);
137
- await this.withLock(fileName, () => fs.writeFile(filePath, data));
110
+ await fs.mkdir(this.resourceDir, { recursive: true }).catch((error) => {
111
+ this.logger.error(`创建资源目录失败 ${this.resourceDir}:`, error);
112
+ throw error;
113
+ });
114
+ const filePath = path.join(this.resourceDir, fileName);
115
+ await this.withLock(filePath, () => fs.writeFile(filePath, data));
138
116
  }
139
117
  return fileName;
140
118
  }
141
119
  /**
142
- * 读取文件,自动从 S3 或本地存储获取。
143
- * @param fileName - 要读取的文件名。
120
+ * @description 读取文件,自动从 S3 或本地存储读取。
121
+ * @param fileName 要读取的文件名/标识符。
144
122
  * @returns 文件的 Buffer 数据。
145
123
  */
146
124
  async readFile(fileName) {
147
125
  if (this.s3Client) {
148
- const command = new import_client_s3.GetObjectCommand({
149
- Bucket: this.s3Bucket,
150
- Key: fileName
151
- });
126
+ const command = new import_client_s3.GetObjectCommand({ Bucket: this.s3Bucket, Key: fileName });
152
127
  const response = await this.s3Client.send(command);
153
- const byteArray = await response.Body.transformToByteArray();
154
- return Buffer.from(byteArray);
128
+ return Buffer.from(await response.Body.transformToByteArray());
155
129
  } else {
156
- const filePath = this.getFullPath(fileName);
157
- return this.withLock(fileName, () => fs.readFile(filePath));
130
+ const filePath = path.join(this.resourceDir, fileName);
131
+ return this.withLock(filePath, () => fs.readFile(filePath));
158
132
  }
159
133
  }
160
134
  /**
161
- * 删除文件,自动从 S3 或本地存储删除。
162
- * @param fileName - 要删除的文件名。
135
+ * @description 删除文件,自动从 S3 或本地删除。
136
+ * @param fileIdentifier 要删除的文件名/标识符。
163
137
  */
164
- async deleteFile(fileName) {
138
+ async deleteFile(fileIdentifier) {
165
139
  if (this.s3Client) {
166
- const command = new import_client_s3.DeleteObjectCommand({
167
- Bucket: this.s3Bucket,
168
- Key: fileName
169
- });
140
+ const command = new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier });
170
141
  await this.s3Client.send(command).catch((err) => {
171
- this.logger.warn(`删除文件 ${fileName} 失败:`, err);
142
+ this.logger.warn(`删除 S3 文件 ${fileIdentifier} 失败:`, err);
172
143
  });
173
144
  } else {
174
- const filePath = this.getFullPath(fileName);
175
- await this.withLock(fileName, async () => {
145
+ const filePath = path.join(this.resourceDir, fileIdentifier);
146
+ await this.withLock(filePath, async () => {
176
147
  try {
177
148
  await fs.unlink(filePath);
178
149
  } catch (error) {
179
150
  if (error.code !== "ENOENT") {
180
- this.logger.warn(`删除文件 ${fileName} 失败:`, error);
151
+ this.logger.warn(`删除本地文件 ${filePath} 失败:`, error);
181
152
  }
182
153
  }
183
154
  });
@@ -188,37 +159,42 @@ var FileManager = class {
188
159
  // src/ProfileManager.ts
189
160
  var ProfileManager = class {
190
161
  /**
191
- * @param ctx - Koishi 上下文,用于数据库交互。
162
+ * @constructor
163
+ * @param ctx - Koishi 上下文,用于初始化数据库模型。
192
164
  */
193
165
  constructor(ctx) {
194
166
  this.ctx = ctx;
195
167
  this.ctx.model.extend("cave_user", {
196
168
  userId: "string",
169
+ // 用户 ID
197
170
  nickname: "string"
171
+ // 用户自定义昵称
198
172
  }, {
199
173
  primary: "userId"
174
+ // 保证每个用户只有一条昵称记录。
200
175
  });
201
176
  }
202
177
  static {
203
178
  __name(this, "ProfileManager");
204
179
  }
205
180
  /**
206
- * 注册与用户昵称相关的 `.profile` 子命令。
207
- * @param cave - 主 `cave` 命令实例,用于挂载子命令。
181
+ * @description 注册 `.profile` 子命令,用于管理用户昵称。
182
+ * @param cave - 主 `cave` 命令实例。
208
183
  */
209
184
  registerCommands(cave) {
210
- cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置你在回声洞中显示的昵称。不提供昵称则清除记录。").action(async ({ session }, nickname) => {
185
+ cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置你在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
211
186
  const trimmedNickname = nickname?.trim();
212
- if (!trimmedNickname) {
187
+ if (trimmedNickname) {
188
+ await this.setNickname(session.userId, trimmedNickname);
189
+ return `昵称已更新为:${trimmedNickname}`;
190
+ } else {
213
191
  await this.clearNickname(session.userId);
214
192
  return "昵称已清除";
215
193
  }
216
- await this.setNickname(session.userId, trimmedNickname);
217
- return `昵称已更新为:${trimmedNickname}`;
218
194
  });
219
195
  }
220
196
  /**
221
- * 设置或更新指定用户的昵称。
197
+ * @description 设置或更新指定用户的昵称。
222
198
  * @param userId - 目标用户的 ID。
223
199
  * @param nickname - 要设置的新昵称。
224
200
  */
@@ -226,16 +202,16 @@ var ProfileManager = class {
226
202
  await this.ctx.database.upsert("cave_user", [{ userId, nickname }]);
227
203
  }
228
204
  /**
229
- * 获取指定用户的昵称。
205
+ * @description 获取指定用户的昵称。
230
206
  * @param userId - 目标用户的 ID。
231
- * @returns 返回用户的昵称字符串,如果用户未设置则返回 null。
207
+ * @returns 返回用户的昵称字符串,如果未设置则返回 null。
232
208
  */
233
209
  async getNickname(userId) {
234
- const [profile] = await this.ctx.database.get("cave_user", { userId });
235
- return profile?.nickname || null;
210
+ const profile = await this.ctx.database.get("cave_user", { userId });
211
+ return profile[0]?.nickname ?? null;
236
212
  }
237
213
  /**
238
- * 清除指定用户的昵称设置。
214
+ * @description 清除指定用户的昵称设置。
239
215
  * @param userId - 目标用户的 ID。
240
216
  */
241
217
  async clearNickname(userId) {
@@ -243,13 +219,108 @@ var ProfileManager = class {
243
219
  }
244
220
  };
245
221
 
222
+ // src/Utils.ts
223
+ var import_koishi = require("koishi");
224
+ var path2 = __toESM(require("path"));
225
+ var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
226
+ function storedFormatToHElements(elements) {
227
+ return elements.map((el) => {
228
+ if (el.type === "text") return import_koishi.h.text(el.content);
229
+ if (["image", "video", "audio", "file"].includes(el.type)) return (0, import_koishi.h)(el.type, { src: el.file });
230
+ return null;
231
+ }).filter(Boolean);
232
+ }
233
+ __name(storedFormatToHElements, "storedFormatToHElements");
234
+ async function buildCaveMessage(cave, config, fileManager, logger2) {
235
+ const caveHElements = storedFormatToHElements(cave.elements);
236
+ const processedElements = await Promise.all(caveHElements.map(async (element) => {
237
+ const isMedia = ["image", "video", "audio", "file"].includes(element.type);
238
+ const fileName = element.attrs.src;
239
+ if (!isMedia || !fileName) return element;
240
+ if (config.enableS3 && config.publicUrl) {
241
+ const fullUrl = config.publicUrl.endsWith("/") ? `${config.publicUrl}${fileName}` : `${config.publicUrl}/${fileName}`;
242
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl });
243
+ }
244
+ if (config.localPath) {
245
+ const fileUri = `file://${path2.join(config.localPath, fileName)}`;
246
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: fileUri });
247
+ }
248
+ try {
249
+ const data = await fileManager.readFile(fileName);
250
+ const ext = path2.extname(fileName).toLowerCase();
251
+ const mimeType = mimeTypeMap[ext] || "application/octet-stream";
252
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: `data:${mimeType};base64,${data.toString("base64")}` });
253
+ } catch (error) {
254
+ logger2.warn(`转换文件 ${fileName} 为 Base64 失败:`, error);
255
+ return (0, import_koishi.h)("p", {}, `[${element.type}]`);
256
+ }
257
+ }));
258
+ const finalMessage = [];
259
+ const [headerFormat, footerFormat = ""] = config.caveFormat.split("|");
260
+ 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);
263
+ finalMessage.push(...processedElements);
264
+ const footerText = footerFormat.replace(/\{id\}|\{name\}/g, (match) => replacements[match.slice(1, -1)]);
265
+ if (footerText.trim()) finalMessage.push(footerText);
266
+ return finalMessage;
267
+ }
268
+ __name(buildCaveMessage, "buildCaveMessage");
269
+ async function cleanupPendingDeletions(ctx, fileManager, logger2) {
270
+ try {
271
+ const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
272
+ if (!cavesToDelete.length) return;
273
+ for (const cave of cavesToDelete) {
274
+ const deletePromises = cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file));
275
+ await Promise.all(deletePromises);
276
+ await ctx.database.remove("cave", { id: cave.id });
277
+ }
278
+ } catch (error) {
279
+ logger2.error("清理回声洞时发生错误:", error);
280
+ }
281
+ }
282
+ __name(cleanupPendingDeletions, "cleanupPendingDeletions");
283
+ function getScopeQuery(session, config) {
284
+ const baseQuery = { status: "active" };
285
+ return config.perChannel && session.channelId ? { ...baseQuery, channelId: session.channelId } : baseQuery;
286
+ }
287
+ __name(getScopeQuery, "getScopeQuery");
288
+ async function getNextCaveId(ctx, query = {}) {
289
+ const allCaveIds = (await ctx.database.get("cave", query, { fields: ["id"] })).map((c) => c.id);
290
+ const existingIds = new Set(allCaveIds);
291
+ let newId = 1;
292
+ while (existingIds.has(newId)) {
293
+ newId++;
294
+ }
295
+ return newId;
296
+ }
297
+ __name(getNextCaveId, "getNextCaveId");
298
+ function checkCooldown(session, config, lastUsed) {
299
+ if (config.coolDown <= 0 || !session.channelId || config.adminUsers.includes(session.userId)) return null;
300
+ const now = Date.now();
301
+ 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} 秒后重试`;
305
+ }
306
+ return null;
307
+ }
308
+ __name(checkCooldown, "checkCooldown");
309
+ function updateCooldownTimestamp(session, config, lastUsed) {
310
+ if (config.coolDown > 0 && session.channelId) {
311
+ lastUsed.set(session.channelId, Date.now());
312
+ }
313
+ }
314
+ __name(updateCooldownTimestamp, "updateCooldownTimestamp");
315
+
246
316
  // src/DataManager.ts
247
317
  var DataManager = class {
248
318
  /**
249
- * @param ctx - Koishi 上下文,用于数据库操作。
250
- * @param config - 插件配置对象。
251
- * @param fileManager - 文件管理器实例,用于读写导入/导出文件。
252
- * @param logger - 日志记录器实例。
319
+ * @constructor
320
+ * @param ctx Koishi 上下文,用于数据库操作。
321
+ * @param config 插件配置。
322
+ * @param fileManager 文件管理器实例。
323
+ * @param logger 日志记录器实例。
253
324
  */
254
325
  constructor(ctx, config, fileManager, logger2) {
255
326
  this.ctx = ctx;
@@ -261,8 +332,8 @@ var DataManager = class {
261
332
  __name(this, "DataManager");
262
333
  }
263
334
  /**
264
- * 注册与数据导入导出相关的 `.export` 和 `.import` 子命令。
265
- * @param cave - 主 `cave` 命令实例,用于挂载子命令。
335
+ * @description 注册 `.export` 和 `.import` 子命令。
336
+ * @param cave - 主 `cave` 命令实例。
266
337
  */
267
338
  registerCommands(cave) {
268
339
  cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(async ({ session }) => {
@@ -287,8 +358,8 @@ var DataManager = class {
287
358
  });
288
359
  }
289
360
  /**
290
- * 导出所有状态为 'active' 的回声洞数据到 `cave_export.json` 文件。
291
- * @returns 一个描述导出结果的字符串消息。
361
+ * @description 导出所有 'active' 状态的回声洞数据到 `cave_export.json`。
362
+ * @returns 描述导出结果的消息字符串。
292
363
  */
293
364
  async exportData() {
294
365
  const fileName = "cave_export.json";
@@ -299,197 +370,46 @@ var DataManager = class {
299
370
  return `成功导出 ${portableCaves.length} 条数据`;
300
371
  }
301
372
  /**
302
- * 从 `cave_import.json` 文件导入回声洞数据。
303
- * @returns 一个描述导入结果的字符串消息。
373
+ * @description 从 `cave_import.json` 文件导入回声洞数据。
374
+ * @returns 描述导入结果的消息字符串。
304
375
  */
305
376
  async importData() {
306
377
  const fileName = "cave_import.json";
307
- let importedData;
378
+ let importedCaves;
308
379
  try {
309
380
  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
- }
381
+ importedCaves = JSON.parse(fileContent.toString("utf-8"));
382
+ if (!Array.isArray(importedCaves)) throw new Error("导入文件格式无效");
314
383
  } catch (error) {
315
- return `读取导入文件失败: ${error.message}`;
384
+ this.logger.error(`读取导入文件失败:`, error);
385
+ return `读取导入文件失败: ${error.message || "未知错误"}`;
316
386
  }
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;
387
+ let successCount = 0;
388
+ for (const cave of importedCaves) {
389
+ const newId = await getNextCaveId(this.ctx, {});
326
390
  const newCave = {
327
- ...caveData,
391
+ ...cave,
328
392
  id: newId,
329
- channelId: caveData.channelId,
393
+ channelId: cave.channelId || null,
394
+ // 保证 channelId 存在
330
395
  status: "active"
396
+ // 导入的数据直接设为活跃状态
331
397
  };
332
- cavesToCreate.push(newCave);
333
- existingIds.add(newId);
398
+ await this.ctx.database.create("cave", newCave);
399
+ successCount++;
334
400
  }
335
- if (cavesToCreate.length > 0) {
336
- await this.ctx.database.upsert("cave", cavesToCreate);
337
- }
338
- return `成功导入 ${cavesToCreate.length} 条回声洞数据`;
401
+ return `成功导入 ${successCount} 条回声洞数据`;
339
402
  }
340
403
  };
341
404
 
342
- // src/Utils.ts
343
- var import_koishi = require("koishi");
344
- var path2 = __toESM(require("path"));
345
- var mimeTypeMap = {
346
- ".png": "image/png",
347
- ".jpg": "image/jpeg",
348
- ".jpeg": "image/jpeg",
349
- ".gif": "image/gif",
350
- ".mp4": "video/mp4",
351
- ".mp3": "audio/mpeg",
352
- ".webp": "image/webp"
353
- };
354
- function storedFormatToHElements(elements) {
355
- return elements.map((el) => {
356
- switch (el.type) {
357
- case "text":
358
- return import_koishi.h.text(el.content);
359
- case "image":
360
- case "video":
361
- case "audio":
362
- case "file":
363
- return (0, import_koishi.h)(el.type, { src: el.file });
364
- default:
365
- return null;
366
- }
367
- }).filter(Boolean);
368
- }
369
- __name(storedFormatToHElements, "storedFormatToHElements");
370
- async function mediaElementToBase64(element, fileManager, logger2) {
371
- const fileName = element.attrs.src;
372
- try {
373
- const data = await fileManager.readFile(fileName);
374
- const ext = path2.extname(fileName).toLowerCase();
375
- const mimeType = mimeTypeMap[ext] || "application/octet-stream";
376
- return (0, import_koishi.h)(element.type, { ...element.attrs, src: `data:${mimeType};base64,${data.toString("base64")}` });
377
- } catch (error) {
378
- logger2.warn(`转换本地文件 ${fileName} 为 Base64 失败:`, error);
379
- return import_koishi.h.text(`[${element.type}]`);
380
- }
381
- }
382
- __name(mediaElementToBase64, "mediaElementToBase64");
383
- async function buildCaveMessage(cave, config, fileManager, logger2) {
384
- const caveHElements = storedFormatToHElements(cave.elements);
385
- const processedElements = await Promise.all(caveHElements.map((element) => {
386
- const isMedia = ["image", "video", "audio", "file"].includes(element.type);
387
- const fileName = element.attrs.src;
388
- if (!isMedia || !fileName) {
389
- return element;
390
- }
391
- if (config.enableS3 && config.publicUrl) {
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 });
394
- }
395
- if (config.localPath) {
396
- const fileUri = `file://${path2.join(config.localPath, fileName)}`;
397
- return (0, import_koishi.h)(element.type, { ...element.attrs, src: fileUri });
398
- }
399
- return mediaElementToBase64(element, fileManager, logger2);
400
- }));
401
- const finalMessage = [];
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));
405
- finalMessage.push(...processedElements);
406
- if (footerFormat?.trim()) finalMessage.push(replacer(footerFormat));
407
- return finalMessage;
408
- }
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");
441
- async function cleanupPendingDeletions(ctx, fileManager, logger2) {
442
- try {
443
- const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
444
- if (cavesToDelete.length === 0) return;
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 } });
451
- } catch (error) {
452
- logger2.error("清理回声洞时发生错误:", error);
453
- }
454
- }
455
- __name(cleanupPendingDeletions, "cleanupPendingDeletions");
456
- function getScopeQuery(session, config) {
457
- const baseQuery = { status: "active" };
458
- if (config.perChannel && session.channelId) {
459
- return { ...baseQuery, channelId: session.channelId };
460
- }
461
- return baseQuery;
462
- }
463
- __name(getScopeQuery, "getScopeQuery");
464
- function checkCooldown(session, config, lastUsed) {
465
- if (config.coolDown <= 0 || !session.channelId || config.adminUsers.includes(session.userId)) {
466
- return null;
467
- }
468
- const now = Date.now();
469
- const lastTime = lastUsed.get(session.channelId) || 0;
470
- if (now - lastTime < config.coolDown * 1e3) {
471
- const waitTime = Math.ceil((config.coolDown * 1e3 - (now - lastTime)) / 1e3);
472
- return `指令冷却中,请在 ${waitTime} 秒后重试`;
473
- }
474
- return null;
475
- }
476
- __name(checkCooldown, "checkCooldown");
477
- function updateCooldownTimestamp(session, config, lastUsed) {
478
- if (config.coolDown > 0 && session.channelId) {
479
- lastUsed.set(session.channelId, Date.now());
480
- }
481
- }
482
- __name(updateCooldownTimestamp, "updateCooldownTimestamp");
483
-
484
405
  // src/ReviewManager.ts
485
- var APPROVE_ACTIONS = /* @__PURE__ */ new Set(["y", "yes", "pass", "approve"]);
486
- var REJECT_ACTIONS = /* @__PURE__ */ new Set(["n", "no", "deny", "reject"]);
487
406
  var ReviewManager = class {
488
407
  /**
489
- * @param ctx - Koishi 上下文。
490
- * @param config - 插件配置对象。
491
- * @param fileManager - 文件管理器实例。
492
- * @param logger - 日志记录器实例。
408
+ * @constructor
409
+ * @param ctx Koishi 上下文。
410
+ * @param config 插件配置。
411
+ * @param fileManager 文件管理器实例。
412
+ * @param logger 日志记录器实例。
493
413
  */
494
414
  constructor(ctx, config, fileManager, logger2) {
495
415
  this.ctx = ctx;
@@ -501,101 +421,75 @@ var ReviewManager = class {
501
421
  __name(this, "ReviewManager");
502
422
  }
503
423
  /**
504
- * 注册与审核相关的 `.review` 子命令。
505
- * @param cave - 主 `cave` 命令实例,用于挂载子命令。
424
+ * @description 注册与审核相关的 `.review` 子命令。
425
+ * @param cave - 主 `cave` 命令实例。
506
426
  */
507
427
  registerCommands(cave) {
508
428
  cave.subcommand(".review [id:posint] [action:string]", "审核回声洞").usage("查看或审核回声洞,使用 <Y/N> 进行审核。").action(async ({ session }, id, action) => {
509
- if (!this.config.adminUsers.includes(session.userId)) {
510
- return "抱歉,你没有权限执行审核";
511
- }
429
+ if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限执行审核";
512
430
  if (!id) {
513
- return this.listPendingCaves();
431
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
432
+ if (pendingCaves.length === 0) return "当前没有需要审核的回声洞";
433
+ return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
434
+ ${pendingCaves.map((c) => c.id).join(", ")}`;
514
435
  }
515
436
  const [targetCave] = await this.ctx.database.get("cave", { id });
516
437
  if (!targetCave) return `回声洞(${id})不存在`;
517
438
  if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
518
- if (!action) {
519
- return this.buildReviewMessage(targetCave);
439
+ if (id && !action) {
440
+ return [`待审核:`, ...await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger)];
520
441
  }
521
442
  const normalizedAction = action.toLowerCase();
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);
527
- }
528
- return `无效操作: "${action}"
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}"
529
447
  请使用 "Y" (通过) 或 "N" (拒绝)`;
448
+ return this.processReview(reviewAction, id, session.username);
530
449
  });
531
450
  }
532
451
  /**
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
- * 如果没有配置管理员,将自动通过审核。
548
- * @param cave - 新创建的、状态为 'pending' 的回声洞对象。
452
+ * @description 将新回声洞提交给管理员审核。
453
+ * @param cave 新创建的、状态为 'pending' 的回声洞对象。
549
454
  */
550
455
  async sendForReview(cave) {
551
456
  if (!this.config.adminUsers?.length) {
552
- this.logger.warn(`No admin users configured. Cave ${cave.id} has been auto-approved.`);
457
+ this.logger.warn(`未配置管理员,回声洞(${cave.id})已自动通过审核`);
553
458
  await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
554
459
  return;
555
460
  }
556
- const reviewMessage = await this.buildReviewMessage(cave);
461
+ const reviewMessage = [`待审核:`, ...await buildCaveMessage(cave, this.config, this.fileManager, this.logger)];
557
462
  try {
558
463
  await this.ctx.broadcast(this.config.adminUsers, reviewMessage);
559
464
  } catch (error) {
560
- this.logger.error(`Failed to broadcast review request for cave ${cave.id}:`, error);
465
+ this.logger.error(`广播回声洞(${cave.id})审核请求失败:`, error);
561
466
  }
562
467
  }
563
468
  /**
564
- * 构建用于发送给管理员的审核消息。
565
- * @param cave - 待审核的回声洞对象。
566
- * @returns 一个可直接发送的消息数组。
567
- * @private
568
- */
569
- async buildReviewMessage(cave) {
570
- const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
571
- return [`待审核`, ...caveContent];
572
- }
573
- /**
574
- * 处理管理员的审核决定(通过或拒绝)。
575
- * @param action - 'approve' (通过) 或 'reject' (拒绝)。
576
- * @param cave - 被审核的回声洞对象。
577
- * @param adminUserName - 执行操作的管理员昵称。
469
+ * @description 处理管理员的审核决定(通过或拒绝)。
470
+ * @param action 'approve' (通过) 或 'reject' (拒绝)。
471
+ * @param caveId 被审核的回声洞 ID。
472
+ * @param adminUserName 操作管理员的昵称。
578
473
  * @returns 返回给操作者的确认消息。
579
474
  */
580
- async processReview(action, cave, adminUserName) {
475
+ async processReview(action, caveId, adminUserName) {
476
+ const [cave] = await this.ctx.database.get("cave", { id: caveId, status: "pending" });
477
+ if (!cave) return `回声洞(${caveId})不存在或无需审核`;
581
478
  let resultMessage;
582
479
  let broadcastMessage;
583
480
  if (action === "approve") {
584
- await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
585
- resultMessage = `回声洞(${cave.id})已通过`;
586
- broadcastMessage = `回声洞(${cave.id})已由管理员 "${adminUserName}" 通过`;
481
+ await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
482
+ resultMessage = `回声洞(${caveId})已通过`;
483
+ broadcastMessage = `回声洞(${caveId})已由管理员 "${adminUserName}" 通过`;
587
484
  } else {
588
- await this.ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
589
- resultMessage = `回声洞(${cave.id})已拒绝`;
590
- const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
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
- });
485
+ await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
486
+ resultMessage = `回声洞(${caveId})已拒绝`;
487
+ broadcastMessage = `回声洞(${caveId})已由管理员 "${adminUserName}" 拒绝`;
488
+ cleanupPendingDeletions(this.ctx, this.fileManager, this.logger);
595
489
  }
596
490
  if (this.config.adminUsers?.length) {
597
491
  this.ctx.broadcast(this.config.adminUsers, broadcastMessage).catch((err) => {
598
- this.logger.error(`Failed to broadcast review result for cave ${cave.id}:`, err);
492
+ this.logger.error(`广播回声洞(${cave.id})审核结果失败:`, err);
599
493
  });
600
494
  }
601
495
  return resultMessage;
@@ -611,7 +505,6 @@ var usage = `
611
505
  <p>📖 <strong>使用文档</strong>:请点击左上角的 <strong>插件主页</strong> 查看插件使用文档</p>
612
506
  <p>🔍 <strong>更多插件</strong>:可访问 <a href="https://github.com/YisRime" style="color:#4a6ee0;text-decoration:none;">苡淞的 GitHub</a> 查看本人的所有插件</p>
613
507
  </div>
614
-
615
508
  <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);">
616
509
  <h2 style="margin-top: 0; color: #e0574a;">❤️ 支持与反馈</h2>
617
510
  <p>🌟 喜欢这个插件?请在 <a href="https://github.com/YisRime" style="color:#e0574a;text-decoration:none;">GitHub</a> 上给我一个 Star!</p>
@@ -651,9 +544,7 @@ function apply(ctx, config) {
651
544
  userName: "string",
652
545
  status: "string",
653
546
  time: "timestamp"
654
- }, {
655
- primary: "id"
656
- });
547
+ }, { primary: "id" });
657
548
  const fileManager = new FileManager(ctx.baseDir, config, logger);
658
549
  const lastUsed = /* @__PURE__ */ new Map();
659
550
  let profileManager;
@@ -674,64 +565,94 @@ function apply(ctx, config) {
674
565
  }
675
566
  const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
676
567
  const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId });
677
- if (randomCave) {
678
- updateCooldownTimestamp(session, config, lastUsed);
679
- return buildCaveMessage(randomCave, config, fileManager, logger);
680
- }
681
- return "未能获取到回声洞";
568
+ updateCooldownTimestamp(session, config, lastUsed);
569
+ return buildCaveMessage(randomCave, config, fileManager, logger);
682
570
  } catch (error) {
683
571
  logger.error("随机获取回声洞失败:", error);
572
+ return "随机获取回声洞失败";
684
573
  }
685
574
  });
686
575
  cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
687
576
  try {
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
- }
577
+ let sourceElements;
578
+ if (session.quote?.elements) {
579
+ sourceElements = session.quote.elements;
580
+ } else if (content?.trim()) {
581
+ sourceElements = import_koishi2.h.parse(content);
582
+ } else {
583
+ await session.send("请在一分钟内发送你要添加的内容");
584
+ const reply = await session.prompt(6e4);
585
+ if (!reply) return "操作超时,已取消添加";
586
+ sourceElements = import_koishi2.h.parse(reply);
699
587
  }
700
- const scopeQuery = getScopeQuery(session, config);
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++;
588
+ const idScopeQuery = {};
589
+ if (config.perChannel && session.channelId) {
590
+ idScopeQuery["channelId"] = session.channelId;
706
591
  }
707
- const { finalElementsForDb, mediaToDownload } = prepareElementsForStorage(sourceElements, newId, session.channelId, session.userId);
708
- if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
709
- let userName = session.username;
710
- if (config.enableProfile && profileManager) {
711
- userName = await profileManager.getNickname(session.userId) || userName;
592
+ const newId = await getNextCaveId(ctx, idScopeQuery);
593
+ const finalElementsForDb = [];
594
+ const mediaToSave = [];
595
+ let mediaIndex = 0;
596
+ const typeMap = {
597
+ "img": "image",
598
+ "image": "image",
599
+ "video": "video",
600
+ "audio": "audio",
601
+ "file": "file",
602
+ "text": "text"
603
+ };
604
+ async function traverseAndProcess(elements) {
605
+ for (const el of elements) {
606
+ const normalizedType = typeMap[el.type];
607
+ if (!normalizedType) {
608
+ if (el.children) await traverseAndProcess(el.children);
609
+ continue;
610
+ }
611
+ if (["image", "video", "audio", "file"].includes(normalizedType) && el.attrs.src) {
612
+ let fileIdentifier = el.attrs.src;
613
+ if (fileIdentifier.startsWith("http")) {
614
+ mediaIndex++;
615
+ const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
616
+ const ext = el.attrs.file && path3.extname(el.attrs.file) ? path3.extname(el.attrs.file) : defaultExtMap[normalizedType] || ".dat";
617
+ const channelIdentifier = session.channelId || "private";
618
+ const fileName = `${newId}_${mediaIndex}_${channelIdentifier}_${session.userId}${ext}`;
619
+ mediaToSave.push({ sourceUrl: fileIdentifier, fileName });
620
+ fileIdentifier = fileName;
621
+ }
622
+ finalElementsForDb.push({ type: normalizedType, file: fileIdentifier });
623
+ } else if (normalizedType === "text" && el.attrs.content?.trim()) {
624
+ finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
625
+ }
626
+ if (el.children) {
627
+ await traverseAndProcess(el.children);
628
+ }
629
+ }
712
630
  }
631
+ __name(traverseAndProcess, "traverseAndProcess");
632
+ await traverseAndProcess(sourceElements);
633
+ if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
634
+ const customNickname = config.enableProfile ? await profileManager.getNickname(session.userId) : null;
713
635
  const newCave = {
714
636
  id: newId,
715
637
  elements: finalElementsForDb,
716
638
  channelId: session.channelId,
717
639
  userId: session.userId,
718
- userName,
640
+ userName: customNickname || session.username,
719
641
  status: config.enableReview ? "pending" : "active",
720
642
  time: /* @__PURE__ */ new Date()
721
643
  };
722
644
  await ctx.database.create("cave", newCave);
723
645
  try {
724
- const downloadPromises = mediaToDownload.map(async (media) => {
725
- const response = await ctx.http.get(media.url, { responseType: "arraybuffer", timeout: 3e4 });
646
+ await Promise.all(mediaToSave.map(async (media) => {
647
+ const response = await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 });
726
648
  await fileManager.saveFile(media.fileName, Buffer.from(response));
727
- });
728
- await Promise.all(downloadPromises);
729
- } catch (fileError) {
649
+ }));
650
+ } catch (fileSaveError) {
651
+ logger.error(`文件保存失败:`, fileSaveError);
730
652
  await ctx.database.remove("cave", { id: newId });
731
- logger.error("媒体文件存储失败:", fileError);
732
- return "添加失败:媒体文件存储失败";
653
+ throw fileSaveError;
733
654
  }
734
- if (newCave.status === "pending" && reviewManager) {
655
+ if (newCave.status === "pending") {
735
656
  reviewManager.sendForReview(newCave);
736
657
  return `提交成功,序号为(${newCave.id})`;
737
658
  }
@@ -761,19 +682,15 @@ function apply(ctx, config) {
761
682
  try {
762
683
  const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
763
684
  if (!targetCave) return `回声洞(${id})不存在`;
764
- const isOwner = targetCave.userId === session.userId;
765
- const isAdmin = config.adminUsers.includes(session.userId);
766
- if (!isOwner && !isAdmin) {
685
+ if (targetCave.userId !== session.userId && !config.adminUsers.includes(session.userId)) {
767
686
  return "抱歉,你没有权限删除这条回声洞";
768
687
  }
769
- const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
770
688
  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
- });
689
+ const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
690
+ cleanupPendingDeletions(ctx, fileManager, logger);
691
+ return [`已删除`, ...caveMessage];
775
692
  } catch (error) {
776
- logger.error(`标记删除回声洞(${id})失败:`, error);
693
+ logger.error(`标记回声洞(${id})失败:`, error);
777
694
  return "删除失败,请稍后再试";
778
695
  }
779
696
  });