koishi-plugin-best-cave 1.7.3 → 2.0.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/index.js CHANGED
@@ -5,9 +5,6 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __getProtoOf = Object.getPrototypeOf;
6
6
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
7
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
8
- var __commonJS = (cb, mod) => function __require() {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
- };
11
8
  var __export = (target, all) => {
12
9
  for (var name2 in all)
13
10
  __defProp(target, name2, { get: all[name2], enumerable: true });
@@ -30,1499 +27,575 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
30
27
  ));
31
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
29
 
33
- // src/locales/zh-CN.yml
34
- var require_zh_CN = __commonJS({
35
- "src/locales/zh-CN.yml"(exports2, module2) {
36
- module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", enableTextDuplicate: "启用文本查重", textDuplicateThreshold: "文本相似度阈值(0-1)", enableImageDuplicate: "启用图片查重", imageDuplicateThreshold: "图片相似度阈值(0-1)", imageMaxSize: "图片最大大小(MB)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", l: "查询投稿统计" }, pass: { description: "通过回声洞审核", usage: "通过指定ID的回声洞审核\ncave.pass <ID> - 通过审核\ncave.pass all - 通过所有待审核内容\n" }, reject: { description: "拒绝回声洞审核", usage: "拒绝指定ID的回声洞审核\ncave.reject <ID> - 拒绝审核\ncave.reject all - 拒绝所有待审核内容\n" }, add: { noContent: "请在一分钟内发送内容", operationTimeout: "操作超时,添加取消", videoDisabled: "不允许上传视频", submitPending: "提交成功,序号为({0})", addSuccess: "添加成功,序号为({0})", mediaSizeExceeded: "{0}文件大小超过限制", localFileNotAllowed: "检测到本地文件路径,无法保存" }, remove: { noPermission: "你无权删除他人添加的回声洞", deletePending: "删除(待审核)", deleted: "已删除" }, list: { pageInfo: "第 {0} / {1} 页", header: "当前共有 {0} 项回声洞:", totalItems: "用户 {0} 共计投稿 {1} 项:", idsLine: "{0}" }, audit: { noPending: "暂无待审核回声洞", pendingNotFound: "未找到待审核回声洞", pendingResult: "{0},剩余 {1} 个待审核回声洞:[{2}]", auditPassed: "已通过", auditRejected: "已拒绝", batchAuditResult: "已{0} {1}/{2} 项回声洞", title: "待审核回声洞:", from: "投稿人:", sendFailed: "发送审核消息失败,无法联系管理员 {0}" }, error: { noContent: "回声洞内容为空", getCave: "获取回声洞失败", noCave: "当前无回声洞", invalidId: "请输入有效的回声洞ID", notFound: "未找到该回声洞", exactDuplicateFound: "发现完全相同的", similarDuplicateFound: "发现相似度为 {0}% 的", addFailed: "添加失败,请稍后重试。" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } };
37
- }
38
- });
39
-
40
- // src/locales/en-US.yml
41
- var require_en_US = __commonJS({
42
- "src/locales/en-US.yml"(exports2, module2) {
43
- module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", enableTextDuplicate: "Enable text duplicate check", textDuplicateThreshold: "Text similarity threshold (0-1)", enableImageDuplicate: "Enable image duplicate check", imageDuplicateThreshold: "Image similarity threshold (0-1)", imageMaxSize: "Maximum image size (MB)", allowVideo: "Allow video upload", videoMaxSize: "Maximum video size (MB)", enablePagination: "Enable statistics pagination", itemsPerPage: "Items per page", blacklist: "Blacklist (users)", whitelist: "Moderation whitelist (users/groups/channels)" }, commands: { cave: { description: "Echo Cave", usage: "Support adding, drawing, viewing, and managing echo caves", examples: "Use cave to randomly draw an echo\nUse -a to add directly or add by reference\nUse -g to view specific echo\nUse -r to delete specific echo", options: { a: "Add echo", g: "View echo", r: "Delete echo", l: "Query submission statistics" }, pass: { description: "Approve cave submission", usage: "Approve cave submission with specific ID\ncave.pass <ID> - Approve submission\ncave.pass all - Approve all pending submissions\n" }, reject: { description: "Reject cave submission", usage: "Reject cave submission with specific ID\ncave.reject <ID> - Reject submission\ncave.reject all - Reject all pending submissions\n" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timeout, addition cancelled", videoDisabled: "Video upload not allowed", submitPending: "Submission successful, ID is ({0})", addSuccess: "Added successfully, ID is ({0})", mediaSizeExceeded: "{0} file size exceeds limit", localFileNotAllowed: "Local file path detected, cannot save" }, remove: { noPermission: "You don't have permission to delete others' echos", deletePending: "Delete (pending review)", deleted: "Deleted" }, list: { pageInfo: "Page {0} / {1}", header: "Currently there are {0} echos:", totalItems: "User {0} has submitted {1} items:", idsLine: "{0}" }, audit: { noPending: "No pending echos for review", pendingNotFound: "Pending echo not found", pendingResult: "{0}, {1} pending echos remaining: [{2}]", auditPassed: "Approved", auditRejected: "Rejected", batchAuditResult: "{0} {1}/{2} echos", title: "Pending echos:", from: "Submitted by:", sendFailed: "Failed to send moderation message, cannot contact administrator {0}" }, error: { noContent: "Echo content is empty", getCave: "Failed to get echo", noCave: "No echos available", invalidId: "Please enter a valid echo ID", notFound: "Echo not found", exactDuplicateFound: "Found exactly identical", similarDuplicateFound: "Found {0}% similar", addFailed: "Add failed, please try again later." }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is limited to administrators only", cooldown: "Group chat cooling down... Please try again in {0} seconds", caveTitle: "Echo Cave —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0} file size exceeds limit" } } } };
44
- }
45
- });
46
-
47
30
  // src/index.ts
48
- var src_exports = {};
49
- __export(src_exports, {
31
+ var index_exports = {};
32
+ __export(index_exports, {
50
33
  Config: () => Config,
51
34
  apply: () => apply,
52
35
  inject: () => inject,
53
36
  name: () => name,
54
37
  usage: () => usage
55
38
  });
56
- module.exports = __toCommonJS(src_exports);
57
- var import_koishi6 = require("koishi");
58
- var fs7 = __toESM(require("fs"));
59
- var path7 = __toESM(require("path"));
39
+ module.exports = __toCommonJS(index_exports);
40
+ var import_koishi3 = require("koishi");
60
41
 
61
- // src/utils/FileHandler.ts
62
- var fs = __toESM(require("fs"));
42
+ // src/FileManager.ts
43
+ var import_client_s3 = require("@aws-sdk/client-s3");
44
+ var fs = __toESM(require("fs/promises"));
63
45
  var path = __toESM(require("path"));
64
- var import_koishi = require("koishi");
65
- var logger = new import_koishi.Logger("fileHandler");
66
- var FileHandler = class {
67
- static {
68
- __name(this, "FileHandler");
69
- }
70
- static locks = /* @__PURE__ */ new Map();
71
- static RETRY_COUNT = 3;
72
- static RETRY_DELAY = 1e3;
73
- static CONCURRENCY_LIMIT = 5;
74
- /**
75
- * 并发控制
76
- * @param operation 要执行的操作
77
- * @param limit 并发限制
78
- * @returns 操作结果
79
- */
80
- static async withConcurrencyLimit(operation, limit = this.CONCURRENCY_LIMIT) {
81
- while (this.locks.size >= limit) {
82
- await Promise.race(this.locks.values());
83
- }
84
- return operation();
85
- }
86
- /**
87
- * 文件操作包装器
88
- * @param filePath 文件路径
89
- * @param operation 要执行的操作
90
- * @returns 操作结果
91
- */
92
- static async withFileOp(filePath, operation) {
93
- const key = filePath;
94
- while (this.locks.has(key)) {
95
- await this.locks.get(key);
96
- }
97
- const operationPromise = (async () => {
98
- for (let i = 0; i < this.RETRY_COUNT; i++) {
99
- try {
100
- return await operation();
101
- } catch (error) {
102
- if (i === this.RETRY_COUNT - 1) throw error;
103
- await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY));
46
+ var FileManager = class {
47
+ /**
48
+ * 创建一个 FileManager 实例。
49
+ * @param baseDir - Koishi 应用的基础数据目录 (ctx.baseDir)。
50
+ * @param config - 插件的完整配置对象。
51
+ * @param logger - 日志记录器实例。
52
+ */
53
+ constructor(baseDir, config, logger2) {
54
+ this.logger = logger2;
55
+ this.resourceDir = path.join(baseDir, "data", "cave");
56
+ if (config.enableS3 && config.endpoint && config.bucket && config.accessKeyId && config.secretAccessKey) {
57
+ this.s3Client = new import_client_s3.S3Client({
58
+ endpoint: config.endpoint,
59
+ region: config.region,
60
+ credentials: {
61
+ accessKeyId: config.accessKeyId,
62
+ secretAccessKey: config.secretAccessKey
104
63
  }
105
- }
106
- throw new Error("Operation failed after retries");
107
- })();
108
- this.locks.set(key, operationPromise);
109
- try {
110
- return await operationPromise;
111
- } finally {
112
- this.locks.delete(key);
113
- }
114
- }
115
- /**
116
- * 事务处理
117
- * @param operations 要执行的操作数组
118
- * @returns 操作结果数组
119
- */
120
- static async withTransaction(operations) {
121
- const results = [];
122
- const completed = /* @__PURE__ */ new Set();
123
- try {
124
- for (const { filePath, operation } of operations) {
125
- const result = await this.withFileOp(filePath, operation);
126
- results.push(result);
127
- completed.add(filePath);
128
- }
129
- return results;
130
- } catch (error) {
131
- await Promise.all(
132
- operations.filter(({ filePath }) => completed.has(filePath)).map(async ({ filePath, rollback }) => {
133
- if (rollback) {
134
- await this.withFileOp(filePath, rollback).catch(
135
- (e) => logger.error(`Rollback failed for ${filePath}: ${e.message}`)
136
- );
137
- }
138
- })
139
- );
140
- throw error;
64
+ });
65
+ this.s3Bucket = config.bucket;
141
66
  }
142
67
  }
143
- /**
144
- * 读取 JSON 数据
145
- * @param filePath 文件路径
146
- * @returns JSON 数据
147
- */
148
- static async readJsonData(filePath) {
149
- return this.withFileOp(filePath, async () => {
150
- try {
151
- const data = await fs.promises.readFile(filePath, "utf8");
152
- return JSON.parse(data || "[]");
153
- } catch (error) {
154
- return [];
155
- }
156
- });
157
- }
158
- /**
159
- * 写入 JSON 数据
160
- * @param filePath 文件路径
161
- * @param data 要写入的数据
162
- */
163
- static async writeJsonData(filePath, data) {
164
- const tmpPath = `${filePath}.tmp`;
165
- await this.withFileOp(filePath, async () => {
166
- await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2));
167
- await fs.promises.rename(tmpPath, filePath);
168
- });
169
- }
170
- /**
171
- * 确保目录存在
172
- * @param dir 目录路径
173
- */
174
- static async ensureDirectory(dir) {
175
- await this.withConcurrencyLimit(async () => {
176
- if (!fs.existsSync(dir)) {
177
- await fs.promises.mkdir(dir, { recursive: true });
178
- }
179
- });
180
- }
181
- /**
182
- * 确保 JSON 文件存在
183
- * @param filePath 文件路径
184
- */
185
- static async ensureJsonFile(filePath) {
186
- await this.withFileOp(filePath, async () => {
187
- if (!fs.existsSync(filePath)) {
188
- await fs.promises.writeFile(filePath, "[]", "utf8");
189
- }
190
- });
191
- }
192
- /**
193
- * 保存媒体文件
194
- * @param filePath 文件路径
195
- * @param data 文件数据
196
- */
197
- static async saveMediaFile(filePath, data) {
198
- await this.withConcurrencyLimit(async () => {
199
- const dir = path.dirname(filePath);
200
- await this.ensureDirectory(dir);
201
- await this.withFileOp(
202
- filePath,
203
- () => fs.promises.writeFile(filePath, data)
204
- );
205
- });
206
- }
207
- /**
208
- * 删除媒体文件
209
- * @param filePath 文件路径
210
- */
211
- static async deleteMediaFile(filePath) {
212
- await this.withFileOp(filePath, async () => {
213
- if (fs.existsSync(filePath)) {
214
- await fs.promises.unlink(filePath);
215
- }
216
- });
217
- }
218
- };
219
-
220
- // src/utils/IdManager.ts
221
- var fs2 = __toESM(require("fs"));
222
- var path2 = __toESM(require("path"));
223
- var import_koishi2 = require("koishi");
224
- var logger2 = new import_koishi2.Logger("IdManager");
225
- var IdManager = class {
226
68
  static {
227
- __name(this, "IdManager");
228
- }
229
- deletedIds = /* @__PURE__ */ new Set();
230
- maxId = 0;
231
- initialized = false;
232
- statusFilePath;
233
- stats = {};
234
- usedIds = /* @__PURE__ */ new Set();
235
- /**
236
- * 初始化ID管理器
237
- * @param baseDir - 基础目录路径
238
- */
239
- constructor(baseDir) {
240
- const caveDir = path2.join(baseDir, "data", "cave");
241
- this.statusFilePath = path2.join(caveDir, "status.json");
242
- }
243
- /**
244
- * 初始化ID管理系统
245
- * @param caveFilePath - 正式回声洞数据文件路径
246
- * @param pendingFilePath - 待处理回声洞数据文件路径
247
- * @throws 当初始化失败时抛出错误
69
+ __name(this, "FileManager");
70
+ }
71
+ // 本地资源存储目录的绝对路径。
72
+ resourceDir;
73
+ // 本地文件锁,键为文件绝对路径,值为一个 Promise,用于防止对同一文件的并发访问。
74
+ locks = /* @__PURE__ */ new Map();
75
+ // S3 客户端实例,仅在启用 S3 时初始化。
76
+ s3Client;
77
+ // S3 存储桶名称。
78
+ s3Bucket;
79
+ /**
80
+ * 确保本地资源目录存在。如果目录不存在,则会递归创建。
81
+ * 这是一个幂等操作。
82
+ * @private
248
83
  */
249
- async initialize(caveFilePath, pendingFilePath) {
250
- if (this.initialized) return;
84
+ async ensureDirectory() {
251
85
  try {
252
- const status = fs2.existsSync(this.statusFilePath) ? JSON.parse(await fs2.promises.readFile(this.statusFilePath, "utf8")) : {
253
- deletedIds: [],
254
- maxId: 0,
255
- stats: {},
256
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
257
- };
258
- const [caveData, pendingData] = await Promise.all([
259
- FileHandler.readJsonData(caveFilePath),
260
- FileHandler.readJsonData(pendingFilePath)
261
- ]);
262
- this.usedIds.clear();
263
- this.stats = {};
264
- const conflicts = /* @__PURE__ */ new Map();
265
- for (const data of [caveData, pendingData]) {
266
- for (const item of data) {
267
- if (this.usedIds.has(item.cave_id)) {
268
- if (!conflicts.has(item.cave_id)) {
269
- conflicts.set(item.cave_id, []);
270
- }
271
- conflicts.get(item.cave_id)?.push(item);
272
- } else {
273
- this.usedIds.add(item.cave_id);
274
- if (data === caveData && item.contributor_number !== "10000") {
275
- if (!this.stats[item.contributor_number]) {
276
- this.stats[item.contributor_number] = [];
277
- }
278
- this.stats[item.contributor_number].push(item.cave_id);
279
- }
280
- }
281
- }
282
- }
283
- if (conflicts.size > 0) {
284
- await this.handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData);
285
- }
286
- this.maxId = Math.max(
287
- status.maxId || 0,
288
- ...[...this.usedIds],
289
- ...status.deletedIds || [],
290
- 0
291
- );
292
- this.deletedIds = new Set(status.deletedIds || []);
293
- for (let i = 1; i <= this.maxId; i++) {
294
- if (!this.usedIds.has(i)) {
295
- this.deletedIds.add(i);
296
- }
297
- }
298
- await this.saveStatus();
299
- this.initialized = true;
300
- logger2.success(`Cave ID Manager initialized with ${this.maxId}(-${this.deletedIds.size}) IDs`);
86
+ await fs.mkdir(this.resourceDir, { recursive: true });
301
87
  } catch (error) {
302
- this.initialized = false;
303
- logger2.error(`ID Manager initialization failed: ${error.message}`);
88
+ this.logger.error(`创建资源目录失败 ${this.resourceDir}:`, error);
304
89
  throw error;
305
90
  }
306
91
  }
307
92
  /**
308
- * 处理ID冲突
309
- * @param conflicts - ID冲突映射表
310
- * @param caveFilePath - 正式回声洞数据文件路径
311
- * @param pendingFilePath - 待处理回声洞数据文件路径
312
- * @param caveData - 正式回声洞数据
313
- * @param pendingData - 待处理回声洞数据
93
+ * 获取给定文件名的完整本地路径。
94
+ * @param fileName - 文件名。
95
+ * @returns 文件的绝对路径。
314
96
  * @private
315
97
  */
316
- async handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData) {
317
- logger2.warn(`Found ${conflicts.size} ID conflicts`);
318
- let modified = false;
319
- for (const items of conflicts.values()) {
320
- items.slice(1).forEach((item) => {
321
- let newId = this.maxId + 1;
322
- while (this.usedIds.has(newId)) {
323
- newId++;
324
- }
325
- logger2.info(`Reassigning ID: ${item.cave_id} -> ${newId}`);
326
- item.cave_id = newId;
327
- this.usedIds.add(newId);
328
- this.maxId = Math.max(this.maxId, newId);
329
- modified = true;
330
- });
331
- }
332
- if (modified) {
333
- await Promise.all([
334
- FileHandler.writeJsonData(caveFilePath, caveData),
335
- FileHandler.writeJsonData(pendingFilePath, pendingData)
336
- ]);
337
- logger2.success("ID conflicts resolved");
338
- }
98
+ getFullPath(fileName) {
99
+ return path.join(this.resourceDir, fileName);
339
100
  }
340
101
  /**
341
- * 获取下一个可用的ID
342
- * @returns 下一个可用的ID
343
- * @throws 当ID管理器未初始化时抛出错误
344
- */
345
- getNextId() {
346
- if (!this.initialized) {
347
- throw new Error("IdManager not initialized");
348
- }
349
- let nextId;
350
- if (this.deletedIds.size > 0) {
351
- const minDeletedId = Math.min(...Array.from(this.deletedIds));
352
- if (!isNaN(minDeletedId) && minDeletedId > 0) {
353
- nextId = minDeletedId;
354
- this.deletedIds.delete(nextId);
355
- } else {
356
- nextId = this.maxId + 1;
357
- }
358
- } else {
359
- nextId = this.maxId + 1;
360
- }
361
- while (isNaN(nextId) || nextId <= 0 || this.usedIds.has(nextId)) {
362
- nextId = this.maxId + 1;
363
- this.maxId++;
364
- }
365
- this.usedIds.add(nextId);
366
- this.saveStatus().catch(
367
- (err) => logger2.error(`Failed to save status after getNextId: ${err.message}`)
368
- );
369
- return nextId;
370
- }
371
- /**
372
- * 标记ID为已删除状态
373
- * @param id - 要标记为删除的ID
374
- * @throws 当ID管理器未初始化时抛出错误
375
- */
376
- async markDeleted(id) {
377
- if (!this.initialized) {
378
- throw new Error("IdManager not initialized");
379
- }
380
- this.deletedIds.add(id);
381
- this.usedIds.delete(id);
382
- const maxUsedId = Math.max(...Array.from(this.usedIds), 0);
383
- const maxDeletedId = Math.max(...Array.from(this.deletedIds), 0);
384
- this.maxId = Math.max(maxUsedId, maxDeletedId);
385
- await this.saveStatus();
386
- }
387
- /**
388
- * 添加贡献统计
389
- * @param contributorNumber - 贡献者编号
390
- * @param caveId - 回声洞ID
391
- */
392
- async addStat(contributorNumber, caveId) {
393
- if (contributorNumber === "10000") return;
394
- if (!this.stats[contributorNumber]) {
395
- this.stats[contributorNumber] = [];
396
- }
397
- this.stats[contributorNumber].push(caveId);
398
- await this.saveStatus();
399
- }
400
- /**
401
- * 移除贡献统计
402
- * @param contributorNumber - 贡献者编号
403
- * @param caveId - 回声洞ID
404
- */
405
- async removeStat(contributorNumber, caveId) {
406
- if (this.stats[contributorNumber]) {
407
- this.stats[contributorNumber] = this.stats[contributorNumber].filter((id) => id !== caveId);
408
- if (this.stats[contributorNumber].length === 0) {
409
- delete this.stats[contributorNumber];
410
- }
411
- await this.saveStatus();
412
- }
413
- }
414
- /**
415
- * 获取所有贡献统计信息
416
- * @returns 贡献者编号到回声洞ID列表的映射
417
- */
418
- getStats() {
419
- return this.stats;
420
- }
421
- /**
422
- * 保存当前状态到文件
102
+ * 使用文件锁来安全地执行一个异步文件操作。
103
+ * 这可以防止对同一文件的并发读写造成数据损坏。
104
+ * @template T - 异步操作的返回类型。
105
+ * @param fileName - 需要加锁的文件名。
106
+ * @param operation - 要执行的异步函数。
107
+ * @returns 返回异步操作的结果。
423
108
  * @private
424
- * @throws 当保存失败时抛出错误
425
109
  */
426
- async saveStatus() {
427
- try {
428
- const status = {
429
- deletedIds: Array.from(this.deletedIds).sort((a, b) => a - b),
430
- maxId: this.maxId,
431
- stats: this.stats,
432
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
433
- };
434
- const tmpPath = `${this.statusFilePath}.tmp`;
435
- await fs2.promises.writeFile(tmpPath, JSON.stringify(status, null, 2), "utf8");
436
- await fs2.promises.rename(tmpPath, this.statusFilePath);
437
- } catch (error) {
438
- logger2.error(`Status save failed: ${error.message}`);
439
- throw error;
110
+ async withLock(fileName, operation) {
111
+ const fullPath = this.getFullPath(fileName);
112
+ while (this.locks.has(fullPath)) {
113
+ await this.locks.get(fullPath);
440
114
  }
441
- }
442
- };
443
-
444
- // src/utils/HashManager.ts
445
- var import_koishi3 = require("koishi");
446
- var fs3 = __toESM(require("fs"));
447
- var path3 = __toESM(require("path"));
448
-
449
- // src/utils/ContentHasher.ts
450
- var import_sharp = __toESM(require("sharp"));
451
- var ContentHasher = class {
452
- static {
453
- __name(this, "ContentHasher");
454
- }
455
- /**
456
- * 计算图片的感知哈希值
457
- * @param imageBuffer - 图片的二进制数据
458
- * @returns 返回64位的十六进制哈希字符串
459
- * @throws 当图片处理失败时可能抛出错误
460
- */
461
- static async calculateHash(imageBuffer) {
462
- const { data } = await (0, import_sharp.default)(imageBuffer).grayscale().resize(32, 32, { fit: "fill" }).raw().toBuffer({ resolveWithObject: true });
463
- const dctMatrix = this.computeDCT(data, 32);
464
- const features = this.extractFeatures(dctMatrix, 32);
465
- const median = this.calculateMedian(features);
466
- const binaryHash = features.map((val) => val > median ? "1" : "0").join("");
467
- return this.binaryToHex(binaryHash);
468
- }
469
- /**
470
- * 将二进制字符串转换为十六进制
471
- * @param binary - 二进制字符串
472
- * @returns 十六进制字符串
473
- * @private
474
- */
475
- static binaryToHex(binary) {
476
- const hex = [];
477
- for (let i = 0; i < binary.length; i += 4) {
478
- const chunk = binary.slice(i, i + 4);
479
- hex.push(parseInt(chunk, 2).toString(16));
115
+ const promise = operation().finally(() => {
116
+ this.locks.delete(fullPath);
117
+ });
118
+ this.locks.set(fullPath, promise);
119
+ return promise;
120
+ }
121
+ /**
122
+ * 保存文件,自动选择 S3 或本地存储。
123
+ * @param fileName - 文件名,将用作 S3 中的 Key 或本地文件名。
124
+ * @param data - 要写入的 Buffer 数据。
125
+ * @returns 返回保存时使用的文件名/标识符。
126
+ */
127
+ async saveFile(fileName, data) {
128
+ if (this.s3Client) {
129
+ const command = new import_client_s3.PutObjectCommand({
130
+ Bucket: this.s3Bucket,
131
+ Key: fileName,
132
+ Body: data,
133
+ ACL: "public-read"
134
+ // 默认将文件权限设置为公开可读,方便通过 URL 访问。
135
+ });
136
+ await this.s3Client.send(command);
137
+ return fileName;
138
+ } else {
139
+ await this.ensureDirectory();
140
+ const filePath = this.getFullPath(fileName);
141
+ await this.withLock(fileName, () => fs.writeFile(filePath, data));
142
+ return fileName;
480
143
  }
481
- return hex.join("");
482
144
  }
483
145
  /**
484
- * 将十六进制字符串转换为二进制
485
- * @param hex - 十六进制字符串
486
- * @returns 二进制字符串
487
- * @private
146
+ * 读取文件,自动从 S3 或本地存储读取。
147
+ * @param fileName - 要读取的文件名/标识符。
148
+ * @returns 文件的 Buffer 数据。
488
149
  */
489
- static hexToBinary(hex) {
490
- let binary = "";
491
- for (const char of hex) {
492
- const bin = parseInt(char, 16).toString(2).padStart(4, "0");
493
- binary += bin;
150
+ async readFile(fileName) {
151
+ if (this.s3Client) {
152
+ const command = new import_client_s3.GetObjectCommand({
153
+ Bucket: this.s3Bucket,
154
+ Key: fileName
155
+ });
156
+ const response = await this.s3Client.send(command);
157
+ const byteArray = await response.Body.transformToByteArray();
158
+ return Buffer.from(byteArray);
159
+ } else {
160
+ const filePath = this.getFullPath(fileName);
161
+ return this.withLock(fileName, () => fs.readFile(filePath));
494
162
  }
495
- return binary;
496
163
  }
497
164
  /**
498
- * 计算图像的DCT(离散余弦变换)
499
- * @param data - 图像数据
500
- * @param size - 图像尺寸
501
- * @returns DCT变换后的矩阵
502
- * @private
165
+ * 删除文件,自动从 S3 或本地删除。
166
+ * @param fileIdentifier - 要删除的文件名/标识符。
503
167
  */
504
- static computeDCT(data, size) {
505
- const matrix = Array(size).fill(0).map(() => Array(size).fill(0));
506
- const output = Array(size).fill(0).map(() => Array(size).fill(0));
507
- for (let i = 0; i < size; i++) {
508
- for (let j = 0; j < size; j++) {
509
- matrix[i][j] = data[i * size + j];
510
- }
511
- }
512
- for (let u = 0; u < size; u++) {
513
- for (let v = 0; v < size; v++) {
514
- let sum = 0;
515
- for (let x = 0; x < size; x++) {
516
- for (let y = 0; y < size; y++) {
517
- const cx = Math.cos((2 * x + 1) * u * Math.PI / (2 * size));
518
- const cy = Math.cos((2 * y + 1) * v * Math.PI / (2 * size));
519
- sum += matrix[x][y] * cx * cy;
168
+ async deleteFile(fileIdentifier) {
169
+ if (this.s3Client) {
170
+ const command = new import_client_s3.DeleteObjectCommand({
171
+ Bucket: this.s3Bucket,
172
+ Key: fileIdentifier
173
+ });
174
+ await this.s3Client.send(command).catch((err) => {
175
+ this.logger.warn(`删除文件 ${fileIdentifier} 失败:`, err);
176
+ });
177
+ } else {
178
+ const filePath = this.getFullPath(fileIdentifier);
179
+ await this.withLock(fileIdentifier, async () => {
180
+ try {
181
+ await fs.unlink(filePath);
182
+ } catch (error) {
183
+ if (error.code !== "ENOENT") {
184
+ this.logger.warn(`删除文件 ${filePath} 失败:`, error);
520
185
  }
521
186
  }
522
- output[u][v] = sum * this.getDCTCoefficient(u, size) * this.getDCTCoefficient(v, size);
523
- }
187
+ });
524
188
  }
525
- return output;
526
189
  }
190
+ };
191
+
192
+ // src/ProfileManager.ts
193
+ var ProfileManager = class {
527
194
  /**
528
- * 获取DCT系数
529
- * @param index - 索引值
530
- * @param size - 矩阵大小
531
- * @returns DCT系数
532
- * @private
195
+ * 创建一个 ProfileManager 实例。
196
+ * @param ctx - Koishi 上下文,用于初始化数据库模型。
533
197
  */
534
- static getDCTCoefficient(index, size) {
535
- return index === 0 ? Math.sqrt(1 / size) : Math.sqrt(2 / size);
198
+ constructor(ctx) {
199
+ this.ctx = ctx;
200
+ this.ctx.model.extend("cave_user", {
201
+ userId: "string",
202
+ // 用户 ID
203
+ nickname: "string"
204
+ // 用户自定义昵称
205
+ }, {
206
+ primary: "userId"
207
+ // 使用 userId 作为主键,确保每个用户只有一条昵称记录。
208
+ });
536
209
  }
537
- /**
538
- * 计算数组的中位数
539
- * @param arr - 输入数组
540
- * @returns 中位数
541
- * @private
542
- */
543
- static calculateMedian(arr) {
544
- const sorted = [...arr].sort((a, b) => a - b);
545
- const mid = Math.floor(sorted.length / 2);
546
- return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
210
+ static {
211
+ __name(this, "ProfileManager");
547
212
  }
548
213
  /**
549
- * 从DCT矩阵中提取特征值
550
- * @param matrix - DCT矩阵
551
- * @param size - 矩阵大小
552
- * @returns 特征值数组
553
- * @private
214
+ * 注册与用户昵称相关的 `.profile` 子命令。
215
+ * @param cave - 主 `cave` 命令的实例,用于挂载子命令。
554
216
  */
555
- static extractFeatures(matrix, size) {
556
- const features = [];
557
- const featureSize = 8;
558
- for (let i = 0; i < featureSize; i++) {
559
- for (let j = 0; j < featureSize; j++) {
560
- features.push(matrix[i][j]);
217
+ registerCommands(cave) {
218
+ cave.subcommand(".profile [nickname:text]", "设置显示昵称").usage("设置你在回声洞中显示的昵称。不提供昵称则清除记录。").action(async ({ session }, nickname) => {
219
+ const trimmedNickname = nickname?.trim();
220
+ if (!trimmedNickname) {
221
+ await this.clearNickname(session.userId);
222
+ return "昵称已清除";
561
223
  }
562
- }
563
- return features;
224
+ await this.setNickname(session.userId, trimmedNickname);
225
+ return `昵称已更新为:${trimmedNickname}`;
226
+ });
564
227
  }
565
228
  /**
566
- * 计算两个哈希值之间的汉明距离
567
- * @param hash1 - 第一个哈希值
568
- * @param hash2 - 第二个哈希值
569
- * @returns 汉明距离
570
- * @throws 当两个哈希值长度不等时抛出错误
229
+ * 设置或更新指定用户的昵称。
230
+ * @param userId - 目标用户的 ID。
231
+ * @param nickname - 要设置的新昵称。
571
232
  */
572
- static calculateDistance(hash1, hash2) {
573
- if (hash1.length !== hash2.length) {
574
- throw new Error("Hash lengths must be equal");
575
- }
576
- const bin1 = this.hexToBinary(hash1);
577
- const bin2 = this.hexToBinary(hash2);
578
- let distance = 0;
579
- for (let i = 0; i < bin1.length; i++) {
580
- if (bin1[i] !== bin2[i]) distance++;
581
- }
582
- return distance;
233
+ async setNickname(userId, nickname) {
234
+ await this.ctx.database.upsert("cave_user", [{
235
+ userId,
236
+ nickname
237
+ }]);
583
238
  }
584
239
  /**
585
- * 计算两个图片哈希值的相似度
586
- * @param hash1 - 第一个哈希值
587
- * @param hash2 - 第二个哈希值
588
- * @returns 返回0-1之间的相似度值,1表示完全相同,0表示完全不同
240
+ * 获取指定用户的昵称。
241
+ * @param userId - 目标用户的 ID。
242
+ * @returns 返回用户的昵称字符串。如果用户未设置昵称,则返回 null。
589
243
  */
590
- static calculateSimilarity(hash1, hash2) {
591
- const distance = this.calculateDistance(hash1, hash2);
592
- return (64 - distance) / 64;
244
+ async getNickname(userId) {
245
+ const profiles = await this.ctx.database.get("cave_user", { userId });
246
+ return profiles[0]?.nickname || null;
593
247
  }
594
248
  /**
595
- * 计算文本的哈希值
596
- * @param text - 输入文本
597
- * @returns 文本的哈希值(36进制字符串)
249
+ * 清除指定用户的昵称设置。
250
+ * @param userId - 目标用户的 ID。
598
251
  */
599
- static calculateTextHash(text) {
600
- const normalizedText = text.toLowerCase().trim().replace(/\s+/g, " ");
601
- let hash = 0;
602
- for (let i = 0; i < normalizedText.length; i++) {
603
- const char = normalizedText.charCodeAt(i);
604
- hash = (hash << 5) - hash + char;
605
- hash = hash & hash;
606
- }
607
- return hash.toString(36);
252
+ async clearNickname(userId) {
253
+ await this.ctx.database.remove("cave_user", { userId });
608
254
  }
609
255
  };
610
256
 
611
- // src/utils/HashManager.ts
612
- var import_util = require("util");
613
- var logger3 = new import_koishi3.Logger("HashManager");
614
- var readFileAsync = (0, import_util.promisify)(fs3.readFile);
615
- var HashManager = class _HashManager {
616
- /**
617
- * 初始化HashManager实例
618
- * @param caveDir 回声洞数据目录路径
619
- */
620
- constructor(caveDir) {
621
- this.caveDir = caveDir;
622
- }
623
- static {
624
- __name(this, "HashManager");
625
- }
626
- static HASH_FILE = "hash.json";
627
- static CAVE_FILE = "cave.json";
628
- static BATCH_SIZE = 50;
629
- imageHashes = /* @__PURE__ */ new Map();
630
- textHashes = /* @__PURE__ */ new Map();
631
- initialized = false;
632
- get filePath() {
633
- return path3.join(this.caveDir, _HashManager.HASH_FILE);
634
- }
635
- get resourceDir() {
636
- return path3.join(this.caveDir, "resources");
637
- }
638
- get caveFilePath() {
639
- return path3.join(this.caveDir, _HashManager.CAVE_FILE);
257
+ // src/Utils.ts
258
+ var import_koishi = require("koishi");
259
+ var path2 = __toESM(require("path"));
260
+ var mimeTypeMap = {
261
+ ".png": "image/png",
262
+ ".jpg": "image/jpeg",
263
+ ".jpeg": "image/jpeg",
264
+ ".gif": "image/gif",
265
+ ".mp4": "video/mp4",
266
+ ".mp3": "audio/mpeg",
267
+ ".webp": "image/webp"
268
+ };
269
+ function storedFormatToHElements(elements) {
270
+ return elements.map((el) => {
271
+ switch (el.type) {
272
+ case "text":
273
+ return import_koishi.h.text(el.content);
274
+ case "img":
275
+ return (0, import_koishi.h)("image", { src: el.file });
276
+ case "video":
277
+ case "audio":
278
+ case "file":
279
+ return (0, import_koishi.h)(el.type, { src: el.file });
280
+ default:
281
+ return null;
282
+ }
283
+ }).filter(Boolean);
284
+ }
285
+ __name(storedFormatToHElements, "storedFormatToHElements");
286
+ async function mediaElementToBase64(element, fileManager, logger2) {
287
+ const fileName = element.attrs.src;
288
+ try {
289
+ const data = await fileManager.readFile(fileName);
290
+ const ext = path2.extname(fileName).toLowerCase();
291
+ const mimeType = mimeTypeMap[ext] || "application/octet-stream";
292
+ return (0, import_koishi.h)(element.type, { ...element.attrs, src: `data:${mimeType};base64,${data.toString("base64")}` });
293
+ } catch (error) {
294
+ logger2.warn(`转换本地文件 ${fileName} 为 Base64 失败:`, error);
295
+ return (0, import_koishi.h)("p", {}, `[${element.type}]`);
640
296
  }
641
- /**
642
- * 初始化哈希存储
643
- * 读取现有哈希数据或重新构建哈希值
644
- * @throws 初始化失败时抛出错误
645
- */
646
- async initialize() {
647
- if (this.initialized) return;
648
- try {
649
- const hashData = await FileHandler.readJsonData(this.filePath).then((data) => data[0]).catch(() => null);
650
- if (!hashData?.imageHashes || !hashData?.textHashes || Object.keys(hashData.imageHashes).length === 0) {
651
- this.imageHashes.clear();
652
- this.textHashes.clear();
653
- await this.buildInitialHashes();
654
- } else {
655
- this.imageHashes = new Map(
656
- Object.entries(hashData.imageHashes).map(([k, v]) => [Number(k), v])
657
- );
658
- this.textHashes = new Map(
659
- Object.entries(hashData.textHashes).map(([k, v]) => [Number(k), v])
660
- );
661
- await this.updateMissingHashes();
662
- }
663
- const totalCaves = (/* @__PURE__ */ new Set([...this.imageHashes.keys(), ...this.textHashes.keys()])).size;
664
- this.initialized = true;
665
- logger3.success(`Cave Hash Manager initialized with ${totalCaves} hashes`);
666
- } catch (error) {
667
- logger3.error(`Initialization failed: ${error.message}`);
668
- this.initialized = false;
669
- throw error;
297
+ }
298
+ __name(mediaElementToBase64, "mediaElementToBase64");
299
+ async function buildCaveMessage(cave, config, fileManager, logger2) {
300
+ const caveHElements = storedFormatToHElements(cave.elements);
301
+ const processedElements = await Promise.all(caveHElements.map((element) => {
302
+ const isMedia = ["image", "video", "audio", "file"].includes(element.type);
303
+ const fileName = element.attrs.src;
304
+ if (!isMedia || !fileName) {
305
+ return Promise.resolve(element);
306
+ }
307
+ if (config.enableS3 && config.publicUrl) {
308
+ const fullUrl = config.publicUrl.endsWith("/") ? `${config.publicUrl}${fileName}` : `${config.publicUrl}/${fileName}`;
309
+ return Promise.resolve((0, import_koishi.h)(element.type, { ...element.attrs, src: fullUrl }));
310
+ }
311
+ return mediaElementToBase64(element, fileManager, logger2);
312
+ }));
313
+ return [
314
+ (0, import_koishi.h)("p", {}, `回声洞 ——(${cave.id})`),
315
+ ...processedElements,
316
+ (0, import_koishi.h)("p", {}, `—— ${cave.userName}`)
317
+ ];
318
+ }
319
+ __name(buildCaveMessage, "buildCaveMessage");
320
+ async function cleanupPendingDeletions(ctx, fileManager, logger2) {
321
+ try {
322
+ const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
323
+ if (cavesToDelete.length === 0) return;
324
+ for (const cave of cavesToDelete) {
325
+ const deletePromises = cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file));
326
+ await Promise.all(deletePromises);
327
+ await ctx.database.remove("cave", { id: cave.id });
670
328
  }
329
+ } catch (error) {
330
+ logger2.error("清理回声洞时发生错误:", error);
671
331
  }
672
- /**
673
- * 获取当前哈希存储状态
674
- * @returns 包含最后更新时间和所有条目的状态对象
675
- */
676
- async getStatus() {
677
- if (!this.initialized) await this.initialize();
678
- return {
679
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
680
- entries: Array.from(this.imageHashes.entries()).map(([caveId, imgHashes]) => ({
681
- caveId,
682
- imageHashes: imgHashes,
683
- textHashes: this.textHashes.get(caveId) || []
684
- }))
685
- };
332
+ }
333
+ __name(cleanupPendingDeletions, "cleanupPendingDeletions");
334
+ function getScopeQuery(session, config) {
335
+ const baseQuery = { status: "active" };
336
+ if (config.perChannel && session.channelId) {
337
+ return { ...baseQuery, channelId: session.channelId };
686
338
  }
687
- /**
688
- * 更新指定回声洞的图片哈希值
689
- * @param caveId 回声洞ID
690
- * @param content 图片buffer数组
691
- */
692
- async updateCaveContent(caveId, content) {
693
- if (!this.initialized) await this.initialize();
694
- try {
695
- if (content.images?.length) {
696
- const imageHashes = await Promise.all(
697
- content.images.map((buffer) => ContentHasher.calculateHash(buffer))
698
- );
699
- this.imageHashes.set(caveId, imageHashes);
700
- }
701
- if (content.texts?.length) {
702
- const textHashes = content.texts.map((text) => ContentHasher.calculateTextHash(text));
703
- this.textHashes.set(caveId, textHashes);
704
- }
705
- if (!content.images && !content.texts) {
706
- this.imageHashes.delete(caveId);
707
- this.textHashes.delete(caveId);
708
- }
709
- await this.saveContentHashes();
710
- } catch (error) {
711
- logger3.error(`Failed to update content hashes (cave ${caveId}): ${error.message}`);
712
- }
339
+ return baseQuery;
340
+ }
341
+ __name(getScopeQuery, "getScopeQuery");
342
+ async function getNextCaveId(ctx, query = {}) {
343
+ const allCaves = await ctx.database.get("cave", query, { fields: ["id"] });
344
+ const existingIds = new Set(allCaves.map((c) => c.id));
345
+ let newId = 1;
346
+ while (existingIds.has(newId)) {
347
+ newId++;
348
+ }
349
+ return newId;
350
+ }
351
+ __name(getNextCaveId, "getNextCaveId");
352
+ async function downloadMedia(ctx, fileManager, url, originalName, type, caveId, index, channelId, userId) {
353
+ const defaultExtMap = { "img": ".jpg", "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
354
+ const ext = originalName ? path2.extname(originalName) : "";
355
+ const finalExt = ext || defaultExtMap[type] || ".dat";
356
+ const fileName = `${caveId}_${index}_${channelId}_${userId}${finalExt}`;
357
+ const response = await ctx.http.get(url, { responseType: "arraybuffer", timeout: 3e4 });
358
+ return fileManager.saveFile(fileName, Buffer.from(response));
359
+ }
360
+ __name(downloadMedia, "downloadMedia");
361
+ function checkCooldown(session, config, lastUsed) {
362
+ if (config.cooldown <= 0 || !session.channelId || config.adminUsers.includes(session.userId)) {
363
+ return null;
364
+ }
365
+ const now = Date.now();
366
+ const lastTime = lastUsed.get(session.channelId) || 0;
367
+ if (now - lastTime < config.cooldown * 1e3) {
368
+ const waitTime = Math.ceil((config.cooldown * 1e3 - (now - lastTime)) / 1e3);
369
+ return `指令冷却中,请在 ${waitTime} 秒后重试`;
370
+ }
371
+ return null;
372
+ }
373
+ __name(checkCooldown, "checkCooldown");
374
+ function updateCooldownTimestamp(session, config, lastUsed) {
375
+ if (config.cooldown > 0 && session.channelId) {
376
+ lastUsed.set(session.channelId, Date.now());
713
377
  }
378
+ }
379
+ __name(updateCooldownTimestamp, "updateCooldownTimestamp");
380
+
381
+ // src/DataManager.ts
382
+ var DataManager = class {
714
383
  /**
715
- * 更新所有回声洞的哈希值
716
- * @param isInitialBuild 是否为初始构建
384
+ * 创建一个 DataManager 实例。
385
+ * @param ctx - Koishi 上下文,用于数据库操作。
386
+ * @param config - 插件配置。
387
+ * @param fileManager - 文件管理器实例,用于读写导入/导出文件。
388
+ * @param logger - 日志记录器实例。
717
389
  */
718
- async updateAllCaves(isInitialBuild = false) {
719
- if (!this.initialized && !isInitialBuild) {
720
- await this.initialize();
721
- return;
722
- }
723
- try {
724
- logger3.info("Starting full hash update...");
725
- const caveData = await this.loadCaveData();
726
- const cavesWithImages = caveData.filter(
727
- (cave) => cave.elements?.some((el) => el.type === "img" && el.file)
728
- );
729
- this.imageHashes.clear();
730
- let processedCount = 0;
731
- const totalImages = cavesWithImages.length;
732
- const processCave = /* @__PURE__ */ __name(async (cave) => {
733
- const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
734
- if (imgElements.length === 0) return;
735
- try {
736
- const hashes = await Promise.all(
737
- imgElements.map(async (imgElement) => {
738
- const filePath = path3.join(this.resourceDir, imgElement.file);
739
- if (!fs3.existsSync(filePath)) {
740
- logger3.warn(`Image file not found: ${filePath}`);
741
- return null;
742
- }
743
- const imgBuffer = await readFileAsync(filePath);
744
- return await ContentHasher.calculateHash(imgBuffer);
745
- })
746
- );
747
- const validHashes = hashes.filter((hash) => hash !== null);
748
- if (validHashes.length > 0) {
749
- this.imageHashes.set(cave.cave_id, validHashes);
750
- processedCount++;
751
- if (processedCount % 100 === 0) {
752
- logger3.info(`Progress: ${processedCount}/${totalImages}`);
753
- }
754
- }
755
- } catch (error) {
756
- logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
757
- }
758
- }, "processCave");
759
- await this.processBatch(cavesWithImages, processCave);
760
- await this.saveContentHashes();
761
- logger3.success(`Update completed. Processed ${processedCount}/${totalImages} images`);
762
- } catch (error) {
763
- logger3.error(`Full update failed: ${error.message}`);
764
- throw error;
765
- }
390
+ constructor(ctx, config, fileManager, logger2) {
391
+ this.ctx = ctx;
392
+ this.config = config;
393
+ this.fileManager = fileManager;
394
+ this.logger = logger2;
395
+ }
396
+ static {
397
+ __name(this, "DataManager");
766
398
  }
767
399
  /**
768
- * 查找重复的图片
769
- * @param content 待查找的图片buffer数组
770
- * @param thresholds 相似度阈值
771
- * @returns 匹配结果数组,包含索引、回声洞ID和相似度
400
+ * 注册与数据导入导出相关的 `.export` 和 `.import` 子命令。
401
+ * @param cave - 主 `cave` 命令的实例,用于挂载子命令。
772
402
  */
773
- async findDuplicates(content, thresholds) {
774
- if (!this.initialized) await this.initialize();
775
- const results = [];
776
- if (content.images?.length) {
777
- const imageResults = await this.findImageDuplicates(content.images, thresholds.image);
778
- results.push(...imageResults.map(
779
- (result) => result ? { ...result, type: "image" } : null
780
- ));
781
- }
782
- if (content.texts?.length) {
783
- const textResults = await this.findTextDuplicates(content.texts, thresholds.text);
784
- results.push(...textResults.map(
785
- (result) => result ? { ...result, type: "text" } : null
786
- ));
787
- }
788
- return results;
789
- }
790
- async findTextDuplicates(texts, threshold) {
791
- const inputHashes = texts.map((text) => ContentHasher.calculateTextHash(text));
792
- const existingHashes = Array.from(this.textHashes.entries());
793
- return inputHashes.map((hash, index) => {
794
- let maxSimilarity = 0;
795
- let matchedCaveId = null;
796
- for (const [caveId, hashes] of existingHashes) {
797
- for (const existingHash of hashes) {
798
- const similarity = this.calculateTextSimilarity(hash, existingHash);
799
- if (similarity >= threshold && similarity > maxSimilarity) {
800
- maxSimilarity = similarity;
801
- matchedCaveId = caveId;
802
- if (similarity === 1) break;
803
- }
804
- }
805
- if (maxSimilarity === 1) break;
403
+ registerCommands(cave) {
404
+ cave.subcommand(".export", "导出回声洞数据").usage("将所有回声洞数据导出到 cave_export.json。").action(async ({ session }) => {
405
+ if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限导出数据";
406
+ try {
407
+ await session.send("正在导出数据,请稍候...");
408
+ const resultMessage = await this.exportData();
409
+ return resultMessage;
410
+ } catch (error) {
411
+ this.logger.error("导出数据时发生错误:", error);
412
+ return `导出失败: ${error.message}`;
806
413
  }
807
- return matchedCaveId ? {
808
- index,
809
- caveId: matchedCaveId,
810
- similarity: maxSimilarity
811
- } : null;
812
414
  });
813
- }
814
- calculateTextSimilarity(hash1, hash2) {
815
- if (hash1 === hash2) return 1;
816
- const length = Math.max(hash1.length, hash2.length);
817
- let matches = 0;
818
- for (let i = 0; i < length; i++) {
819
- if (hash1[i] === hash2[i]) matches++;
820
- }
821
- return matches / length;
822
- }
823
- async findImageDuplicates(images, threshold) {
824
- if (!this.initialized) await this.initialize();
825
- const inputHashes = await Promise.all(
826
- images.map((buffer) => ContentHasher.calculateHash(buffer))
827
- );
828
- const existingHashes = Array.from(this.imageHashes.entries());
829
- return Promise.all(
830
- inputHashes.map(async (hash, index) => {
831
- try {
832
- let maxSimilarity = 0;
833
- let matchedCaveId = null;
834
- for (const [caveId, hashes] of existingHashes) {
835
- for (const existingHash of hashes) {
836
- const similarity = ContentHasher.calculateSimilarity(hash, existingHash);
837
- if (similarity >= threshold && similarity > maxSimilarity) {
838
- maxSimilarity = similarity;
839
- matchedCaveId = caveId;
840
- if (Math.abs(similarity - 1) < Number.EPSILON) break;
841
- }
842
- }
843
- if (Math.abs(maxSimilarity - 1) < Number.EPSILON) break;
844
- }
845
- return matchedCaveId ? {
846
- index,
847
- caveId: matchedCaveId,
848
- similarity: maxSimilarity
849
- } : null;
850
- } catch (error) {
851
- logger3.warn(`处理图片 ${index} 失败: ${error.message}`);
852
- return null;
853
- }
854
- })
855
- );
856
- }
857
- /**
858
- * 加载回声洞数据
859
- * @returns 回声洞数据数组
860
- * @private
861
- */
862
- async loadCaveData() {
863
- const data = await FileHandler.readJsonData(this.caveFilePath);
864
- return Array.isArray(data) ? data.flat() : [];
865
- }
866
- /**
867
- * 保存哈希数据到文件
868
- * @private
869
- */
870
- async saveContentHashes() {
871
- const data = {
872
- imageHashes: Object.fromEntries(this.imageHashes),
873
- textHashes: Object.fromEntries(this.textHashes),
874
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
875
- };
876
- await FileHandler.writeJsonData(this.filePath, [data]);
877
- }
878
- /**
879
- * 构建初始哈希数据
880
- * @private
881
- */
882
- async buildInitialHashes() {
883
- const caveData = await this.loadCaveData();
884
- let processedCount = 0;
885
- const totalCaves = caveData.length;
886
- logger3.info(`Building hash data for ${totalCaves} caves...`);
887
- for (const cave of caveData) {
415
+ cave.subcommand(".import", "导入回声洞数据").usage("从 cave_import.json 中导入回声洞数据。").action(async ({ session }) => {
416
+ if (!this.config.adminUsers.includes(session.userId)) return "抱歉,你没有权限导入数据";
888
417
  try {
889
- const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
890
- if (imgElements.length > 0) {
891
- const hashes = await Promise.all(
892
- imgElements.map(async (imgElement) => {
893
- const filePath = path3.join(this.resourceDir, imgElement.file);
894
- if (!fs3.existsSync(filePath)) {
895
- logger3.warn(`Image not found: ${filePath}`);
896
- return null;
897
- }
898
- const imgBuffer = await fs3.promises.readFile(filePath);
899
- return await ContentHasher.calculateHash(imgBuffer);
900
- })
901
- );
902
- const validHashes = hashes.filter((hash) => hash !== null);
903
- if (validHashes.length > 0) {
904
- this.imageHashes.set(cave.cave_id, validHashes);
905
- }
906
- }
907
- const textElements = cave.elements?.filter((el) => el.type === "text" && el.content) || [];
908
- if (textElements.length > 0) {
909
- const textHashes = textElements.map((el) => ContentHasher.calculateTextHash(el.content));
910
- this.textHashes.set(cave.cave_id, textHashes);
911
- }
912
- processedCount++;
913
- if (processedCount % 100 === 0) {
914
- logger3.info(`Progress: ${processedCount}/${totalCaves} caves`);
915
- }
418
+ await session.send("正在导入数据,请稍候...");
419
+ const resultMessage = await this.importData();
420
+ return resultMessage;
916
421
  } catch (error) {
917
- logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
422
+ this.logger.error("导入数据时发生错误:", error);
423
+ return `导入失败: ${error.message}`;
918
424
  }
919
- }
920
- await this.saveContentHashes();
921
- logger3.success(`Build completed. Processed ${processedCount}/${totalCaves} caves`);
425
+ });
922
426
  }
923
427
  /**
924
- * 更新缺失的哈希值
925
- * @private
428
+ * 导出所有状态为 'active' 的回声洞数据。
429
+ * 数据将被序列化为 JSON 并保存到 `cave_export.json` 文件中。
430
+ * @returns 一个描述导出结果的字符串消息。
926
431
  */
927
- async updateMissingHashes() {
928
- const caveData = await this.loadCaveData();
929
- let updatedCount = 0;
930
- for (const cave of caveData) {
931
- if (this.imageHashes.has(cave.cave_id)) continue;
932
- const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
933
- if (imgElements.length === 0) continue;
934
- try {
935
- const hashes = await Promise.all(
936
- imgElements.map(async (imgElement) => {
937
- const filePath = path3.join(this.resourceDir, imgElement.file);
938
- if (!fs3.existsSync(filePath)) {
939
- return null;
940
- }
941
- const imgBuffer = await fs3.promises.readFile(filePath);
942
- return ContentHasher.calculateHash(imgBuffer);
943
- })
944
- );
945
- const validHashes = hashes.filter((hash) => hash !== null);
946
- if (validHashes.length > 0) {
947
- this.imageHashes.set(cave.cave_id, validHashes);
948
- updatedCount++;
949
- }
950
- } catch (error) {
951
- logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
952
- }
953
- }
432
+ async exportData() {
433
+ const fileName = "cave_export.json";
434
+ const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
435
+ const portableCaves = cavesToExport.map(({ id, ...rest }) => rest);
436
+ const data = JSON.stringify(portableCaves, null, 2);
437
+ await this.fileManager.saveFile(fileName, Buffer.from(data));
438
+ return `成功导出 ${portableCaves.length} 条数据`;
954
439
  }
955
440
  /**
956
- * 批量处理数组项
957
- * @param items 待处理项数组
958
- * @param processor 处理函数
959
- * @param batchSize 批处理大小
960
- * @private
441
+ * 从 `cave_import.json` 文件导入回声洞数据。
442
+ * @returns 一个描述导入结果的字符串消息。
961
443
  */
962
- async processBatch(items, processor, batchSize = _HashManager.BATCH_SIZE) {
963
- for (let i = 0; i < items.length; i += batchSize) {
964
- const batch = items.slice(i, i + batchSize);
965
- await Promise.all(
966
- batch.map(async (item) => {
967
- try {
968
- await processor(item);
969
- } catch (error) {
970
- logger3.error(`Batch processing error: ${error.message}`);
971
- }
972
- })
973
- );
444
+ async importData() {
445
+ const fileName = "cave_import.json";
446
+ let importedCaves;
447
+ try {
448
+ const fileContent = await this.fileManager.readFile(fileName);
449
+ importedCaves = JSON.parse(fileContent.toString("utf-8"));
450
+ if (!Array.isArray(importedCaves)) {
451
+ throw new Error("导入文件格式无效");
452
+ }
453
+ } catch (error) {
454
+ this.logger.error(`读取导入文件失败:`, error);
455
+ return `读取导入文件失败: ${error.message || "未知错误"}`;
974
456
  }
457
+ let successCount = 0;
458
+ for (const cave of importedCaves) {
459
+ const newId = await getNextCaveId(this.ctx, {});
460
+ const newCave = {
461
+ ...cave,
462
+ id: newId,
463
+ channelId: cave.channelId || null,
464
+ // 确保 channelId 存在,若无则为 null。
465
+ status: "active"
466
+ // 导入的数据直接设为 active 状态。
467
+ };
468
+ await this.ctx.database.create("cave", newCave);
469
+ successCount++;
470
+ }
471
+ return `成功导入 ${successCount} 条回声洞数据`;
975
472
  }
976
473
  };
977
474
 
978
- // src/utils/AuditHandler.ts
979
- var import_koishi4 = require("koishi");
980
- var fs4 = __toESM(require("fs"));
981
- var path4 = __toESM(require("path"));
982
- var AuditManager = class {
475
+ // src/ReviewManager.ts
476
+ var import_koishi2 = require("koishi");
477
+ var ReviewManager = class {
983
478
  /**
984
- * 创建审核管理器实例
985
- * @param ctx - Koishi 上下文
986
- * @param config - 配置对象
987
- * @param idManager - ID 管理器实例
479
+ * 创建一个 ReviewManager 实例。
480
+ * @param ctx - Koishi 上下文。
481
+ * @param config - 插件配置。
482
+ * @param fileManager - 文件管理器实例。
483
+ * @param logger - 日志记录器实例。
988
484
  */
989
- constructor(ctx, config, idManager) {
485
+ constructor(ctx, config, fileManager, logger2) {
990
486
  this.ctx = ctx;
991
487
  this.config = config;
992
- this.idManager = idManager;
488
+ this.fileManager = fileManager;
489
+ this.logger = logger2;
993
490
  }
994
491
  static {
995
- __name(this, "AuditManager");
996
- }
997
- logger = new import_koishi4.Logger("AuditManager");
998
- /**
999
- * 处理审核操作
1000
- * @param pendingData - 待审核的洞数据数组
1001
- * @param isApprove - 是否通过审核
1002
- * @param caveFilePath - 洞数据文件路径
1003
- * @param resourceDir - 资源目录路径
1004
- * @param pendingFilePath - 待审核数据文件路径
1005
- * @param session - 会话对象
1006
- * @param targetId - 目标洞ID(可选)
1007
- * @returns 处理结果消息
1008
- */
1009
- async processAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session, targetId) {
1010
- if (pendingData.length === 0) {
1011
- return this.sendMessage(session, "commands.cave.audit.noPending", [], true);
1012
- }
1013
- if (typeof targetId === "number") {
1014
- return await this.handleSingleAudit(
1015
- pendingData,
1016
- isApprove,
1017
- caveFilePath,
1018
- resourceDir,
1019
- pendingFilePath,
1020
- targetId,
1021
- session
1022
- );
1023
- }
1024
- return await this.handleBatchAudit(
1025
- pendingData,
1026
- isApprove,
1027
- caveFilePath,
1028
- resourceDir,
1029
- pendingFilePath,
1030
- session
1031
- );
1032
- }
1033
- /**
1034
- * 处理单条审核
1035
- * @param pendingData - 待审核的洞数据数组
1036
- * @param isApprove - 是否通过审核
1037
- * @param caveFilePath - 洞数据文件路径
1038
- * @param resourceDir - 资源目录路径
1039
- * @param pendingFilePath - 待审核数据文件路径
1040
- * @param targetId - 目标洞ID
1041
- * @param session - 会话对象
1042
- * @returns 处理结果消息
1043
- * @private
1044
- */
1045
- async handleSingleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, targetId, session) {
1046
- const targetCave = pendingData.find((item) => item.cave_id === targetId);
1047
- if (!targetCave) {
1048
- return this.sendMessage(session, "commands.cave.audit.pendingNotFound", [], true);
1049
- }
1050
- const newPendingData = pendingData.filter((item) => item.cave_id !== targetId);
1051
- if (isApprove) {
1052
- const oldCaveData = await FileHandler.readJsonData(caveFilePath);
1053
- const newCaveData = [...oldCaveData, {
1054
- ...targetCave,
1055
- cave_id: targetId,
1056
- elements: this.cleanElementsForSave(targetCave.elements, false)
1057
- }];
1058
- await FileHandler.withTransaction([
1059
- {
1060
- filePath: caveFilePath,
1061
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, newCaveData), "operation"),
1062
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldCaveData), "rollback")
1063
- },
1064
- {
1065
- filePath: pendingFilePath,
1066
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, newPendingData), "operation"),
1067
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback")
1068
- }
1069
- ]);
1070
- await this.idManager.addStat(targetCave.contributor_number, targetId);
1071
- } else {
1072
- await FileHandler.writeJsonData(pendingFilePath, newPendingData);
1073
- await this.idManager.markDeleted(targetId);
1074
- await this.deleteMediaFiles(targetCave, resourceDir);
1075
- }
1076
- const remainingCount = newPendingData.length;
1077
- if (remainingCount > 0) {
1078
- const remainingIds = newPendingData.map((c) => c.cave_id).join(", ");
1079
- const action = isApprove ? "auditPassed" : "auditRejected";
1080
- return this.sendMessage(session, "commands.cave.audit.pendingResult", [
1081
- session.text(`commands.cave.audit.${action}`),
1082
- remainingCount,
1083
- remainingIds
1084
- ], false);
1085
- }
1086
- return this.sendMessage(
1087
- session,
1088
- isApprove ? "commands.cave.audit.auditPassed" : "commands.cave.audit.auditRejected",
1089
- [],
1090
- false
1091
- );
492
+ __name(this, "ReviewManager");
1092
493
  }
1093
494
  /**
1094
- * 处理批量审核
1095
- * @param pendingData - 待审核的洞数据数组
1096
- * @param isApprove - 是否通过审核
1097
- * @param caveFilePath - 洞数据文件路径
1098
- * @param resourceDir - 资源目录路径
1099
- * @param pendingFilePath - 待审核数据文件路径
1100
- * @param session - 会话对象
1101
- * @returns 处理结果消息
1102
- * @private
495
+ * 注册与审核相关的 `.review` 子命令。
496
+ * @param cave - 主 `cave` 命令的实例,用于挂载子命令。
1103
497
  */
1104
- async handleBatchAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session) {
1105
- const data = isApprove ? await FileHandler.readJsonData(caveFilePath) : null;
1106
- let processedCount = 0;
1107
- if (isApprove && data) {
1108
- const oldData = [...data];
1109
- const newData = [...data];
1110
- await FileHandler.withTransaction([
1111
- {
1112
- filePath: caveFilePath,
1113
- operation: /* @__PURE__ */ __name(async () => {
1114
- for (const cave of pendingData) {
1115
- newData.push({
1116
- ...cave,
1117
- cave_id: cave.cave_id,
1118
- elements: this.cleanElementsForSave(cave.elements, false)
1119
- });
1120
- processedCount++;
1121
- await this.idManager.addStat(cave.contributor_number, cave.cave_id);
1122
- }
1123
- return FileHandler.writeJsonData(caveFilePath, newData);
1124
- }, "operation"),
1125
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldData), "rollback")
1126
- },
1127
- {
1128
- filePath: pendingFilePath,
1129
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, []), "operation"),
1130
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback")
1131
- }
1132
- ]);
1133
- } else {
1134
- for (const cave of pendingData) {
1135
- await this.idManager.markDeleted(cave.cave_id);
1136
- await this.deleteMediaFiles(cave, resourceDir);
1137
- processedCount++;
498
+ registerCommands(cave) {
499
+ cave.subcommand(".review [id:posint] [action:string]", "审核回声洞").usage("查看或审核回声洞,使用 <Y/N> 进行审核。").action(async ({ session }, id, action) => {
500
+ if (!this.config.adminUsers.includes(session.userId)) {
501
+ return "抱歉,你没有权限执行审核";
1138
502
  }
1139
- await FileHandler.writeJsonData(pendingFilePath, []);
1140
- }
1141
- return this.sendMessage(session, "commands.cave.audit.batchAuditResult", [
1142
- isApprove ? "通过" : "拒绝",
1143
- processedCount,
1144
- pendingData.length
1145
- ], false);
1146
- }
1147
- /**
1148
- * 发送审核消息给管理员
1149
- * @param cave - 待审核的洞数据
1150
- * @param content - 消息内容
1151
- * @param session - 会话对象
1152
- */
1153
- async sendAuditMessage(cave, content, session) {
1154
- const auditMessage = `${session.text("commands.cave.audit.title")}
1155
- ${content}
1156
- ${session.text("commands.cave.audit.from")}${cave.contributor_number}`;
1157
- for (const managerId of this.config.manager) {
1158
- const bot = this.ctx.bots[0];
1159
- if (bot) {
1160
- try {
1161
- await bot.sendPrivateMessage(managerId, auditMessage);
1162
- } catch (error) {
1163
- this.logger.error(session.text("commands.cave.audit.sendFailed", [managerId]));
503
+ if (!id) {
504
+ const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
505
+ if (pendingCaves.length === 0) {
506
+ return "当前没有需要审核的回声洞";
1164
507
  }
508
+ const pendingIds = pendingCaves.map((c) => c.id).join(", ");
509
+ return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
510
+ ${pendingIds}`;
511
+ }
512
+ const [targetCave] = await this.ctx.database.get("cave", { id });
513
+ if (!targetCave) {
514
+ return `回声洞(${id})不存在`;
515
+ }
516
+ if (targetCave.status !== "pending") {
517
+ return `回声洞(${id})无需审核`;
518
+ }
519
+ if (id && !action) {
520
+ return this.buildReviewMessage(targetCave);
521
+ }
522
+ const normalizedAction = action.toLowerCase();
523
+ let reviewAction;
524
+ if (["y", "yes", "ok", "pass", "approve"].includes(normalizedAction)) {
525
+ reviewAction = "approve";
526
+ } else if (["n", "no", "deny", "reject"].includes(normalizedAction)) {
527
+ reviewAction = "reject";
528
+ } else {
529
+ return `无效操作: "${action}"
530
+ 请使用 "Y" (通过) 或 "N" (拒绝)`;
1165
531
  }
1166
- }
1167
- }
1168
- /**
1169
- * 删除媒体文件
1170
- * @param cave - 洞数据
1171
- * @param resourceDir - 资源目录路径
1172
- * @private
1173
- */
1174
- async deleteMediaFiles(cave, resourceDir) {
1175
- if (cave.elements) {
1176
- for (const element of cave.elements) {
1177
- if ((element.type === "img" || element.type === "video") && element.file) {
1178
- const fullPath = path4.join(resourceDir, element.file);
1179
- if (fs4.existsSync(fullPath)) {
1180
- await fs4.promises.unlink(fullPath);
1181
- }
1182
- }
1183
- }
1184
- }
1185
- }
1186
- /**
1187
- * 清理元素数据用于保存
1188
- * @param elements - 元素数组
1189
- * @param keepIndex - 是否保留索引
1190
- * @returns 清理后的元素数组
1191
- * @private
1192
- */
1193
- cleanElementsForSave(elements, keepIndex = false) {
1194
- if (!elements?.length) return [];
1195
- const cleanedElements = elements.map((element) => {
1196
- if (element.type === "text") {
1197
- const cleanedElement = {
1198
- type: "text",
1199
- content: element.content
1200
- };
1201
- if (keepIndex) cleanedElement.index = element.index;
1202
- return cleanedElement;
1203
- } else if (element.type === "img" || element.type === "video") {
1204
- const mediaElement = element;
1205
- const cleanedElement = {
1206
- type: mediaElement.type
1207
- };
1208
- if (mediaElement.file) cleanedElement.file = mediaElement.file;
1209
- if (keepIndex) cleanedElement.index = element.index;
1210
- return cleanedElement;
1211
- }
1212
- return element;
532
+ return this.processReview(reviewAction, id, session.username);
1213
533
  });
1214
- return keepIndex ? cleanedElements.sort((a, b) => (a.index || 0) - (b.index || 0)) : cleanedElements;
1215
534
  }
1216
535
  /**
1217
- * 发送消息
1218
- * @param session - 会话对象
1219
- * @param key - 消息key
1220
- * @param params - 消息参数
1221
- * @param isTemp - 是否为临时消息
1222
- * @param timeout - 临时消息超时时间
1223
- * @returns 空字符串
1224
- * @private
536
+ * 将一条新的回声洞提交给所有管理员进行审核。
537
+ * @param cave - 新创建的、状态为 'pending' 的回声洞对象。
1225
538
  */
1226
- async sendMessage(session, key, params = [], isTemp = true, timeout = 1e4) {
1227
- try {
1228
- const msg = await session.send(session.text(key, params));
1229
- if (isTemp && msg) {
1230
- setTimeout(async () => {
1231
- try {
1232
- await session.bot.deleteMessage(session.channelId, msg);
1233
- } catch (error) {
1234
- this.logger.debug(`Failed to delete temporary message: ${error.message}`);
1235
- }
1236
- }, timeout);
1237
- }
1238
- } catch (error) {
1239
- this.logger.error(`Failed to send message: ${error.message}`);
1240
- }
1241
- return "";
1242
- }
1243
- };
1244
-
1245
- // src/utils/MediaHandler.ts
1246
- var import_koishi5 = require("koishi");
1247
- var fs5 = __toESM(require("fs"));
1248
- var path5 = __toESM(require("path"));
1249
- var logger4 = new import_koishi5.Logger("MediaHandle");
1250
- async function buildMessage(cave, resourceDir, session) {
1251
- if (!cave?.elements?.length) {
1252
- return session.text("commands.cave.error.noContent");
1253
- }
1254
- const videoElement = cave.elements.find((el) => el.type === "video");
1255
- const nonVideoElements = cave.elements.filter((el) => el.type !== "video").sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
1256
- if (videoElement?.file) {
1257
- const basicInfo = [
1258
- session.text("commands.cave.message.caveTitle", [cave.cave_id]),
1259
- session.text("commands.cave.message.contributorSuffix", [cave.contributor_name])
1260
- ].join("\n");
1261
- await session?.send(basicInfo);
1262
- const filePath = path5.join(resourceDir, videoElement.file);
1263
- const base64Data = await processMediaFile(filePath, "video");
1264
- if (base64Data && session) {
1265
- await session.send((0, import_koishi5.h)("video", { src: base64Data }));
1266
- }
1267
- return "";
1268
- }
1269
- const lines = [session.text("commands.cave.message.caveTitle", [cave.cave_id])];
1270
- for (const element of nonVideoElements) {
1271
- if (element.type === "text") {
1272
- lines.push(element.content);
1273
- } else if (element.type === "img" && element.file) {
1274
- const filePath = path5.join(resourceDir, element.file);
1275
- const base64Data = await processMediaFile(filePath, "image");
1276
- if (base64Data) {
1277
- lines.push((0, import_koishi5.h)("image", { src: base64Data }));
1278
- }
1279
- }
1280
- }
1281
- lines.push(session.text("commands.cave.message.contributorSuffix", [cave.contributor_name]));
1282
- return lines.join("\n");
1283
- }
1284
- __name(buildMessage, "buildMessage");
1285
- async function sendMessage(session, key, params = [], isTemp = true, timeout = 1e4) {
1286
- try {
1287
- const msg = await session.send(session.text(key, params));
1288
- if (isTemp && msg) {
1289
- setTimeout(async () => {
1290
- try {
1291
- await session.bot.deleteMessage(session.channelId, msg);
1292
- } catch (error) {
1293
- logger4.debug(`Failed to delete temporary message: ${error.message}`);
1294
- }
1295
- }, timeout);
1296
- }
1297
- } catch (error) {
1298
- logger4.error(`Failed to send message: ${error.message}`);
1299
- }
1300
- return "";
1301
- }
1302
- __name(sendMessage, "sendMessage");
1303
- async function processMediaFile(filePath, type) {
1304
- const data = await fs5.promises.readFile(filePath).catch(() => null);
1305
- if (!data) return null;
1306
- return `data:${type}/${type === "image" ? "png" : "mp4"};base64,${data.toString("base64")}`;
1307
- }
1308
- __name(processMediaFile, "processMediaFile");
1309
- async function extractMediaContent(originalContent, config, session) {
1310
- const textParts = originalContent.split(/<(img|video)[^>]+>/).map((text, idx) => text.trim() && {
1311
- type: "text",
1312
- content: text.replace(/^(img|video)$/, "").trim(),
1313
- index: idx * 3
1314
- }).filter((text) => text && text.content);
1315
- const getMediaElements = /* @__PURE__ */ __name((type, maxSize) => {
1316
- const regex = new RegExp(`<${type}[^>]+src="([^"]+)"[^>]*>`, "g");
1317
- const elements = [];
1318
- const urls = [];
1319
- let match;
1320
- let idx = 0;
1321
- while ((match = regex.exec(originalContent)) !== null) {
1322
- const element = match[0];
1323
- const url = match[1];
1324
- const fileName = element.match(/file="([^"]+)"/)?.[1];
1325
- const fileSize = element.match(/fileSize="([^"]+)"/)?.[1];
1326
- if (fileSize) {
1327
- const sizeInBytes = parseInt(fileSize);
1328
- if (sizeInBytes > maxSize * 1024 * 1024) {
1329
- throw new Error(session.text("commands.cave.message.mediaSizeExceeded", [type]));
1330
- }
1331
- }
1332
- urls.push(url);
1333
- elements.push({
1334
- type,
1335
- index: type === "video" ? Number.MAX_SAFE_INTEGER : idx * 3 + 1,
1336
- fileName,
1337
- fileSize
1338
- });
1339
- idx++;
539
+ async sendForReview(cave) {
540
+ if (!this.config.adminUsers?.length) {
541
+ this.logger.warn(`未配置管理员,回声洞(${cave.id})已自动通过审核`);
542
+ await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
543
+ return;
1340
544
  }
1341
- return { urls, elements };
1342
- }, "getMediaElements");
1343
- const { urls: imageUrls, elements: imageElementsRaw } = getMediaElements("img", config.imageMaxSize);
1344
- const imageElements = imageElementsRaw;
1345
- const { urls: videoUrls, elements: videoElementsRaw } = getMediaElements("video", config.videoMaxSize);
1346
- const videoElements = videoElementsRaw;
1347
- return { imageUrls, imageElements, videoUrls, videoElements, textParts };
1348
- }
1349
- __name(extractMediaContent, "extractMediaContent");
1350
- async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config, ctx, session, buffers) {
1351
- const accept = mediaType === "img" ? "image/*" : "video/*";
1352
- const hashStorage = new HashManager(path5.join(ctx.baseDir, "data", "cave"));
1353
- await hashStorage.initialize();
1354
- const downloadTasks = urls.map(async (url, i) => {
1355
- const fileName = fileNames[i];
1356
- const ext = path5.extname(fileName || url) || (mediaType === "img" ? ".png" : ".mp4");
545
+ const reviewMessage = await this.buildReviewMessage(cave);
1357
546
  try {
1358
- const response = await ctx.http(decodeURIComponent(url).replace(/&amp;/g, "&"), {
1359
- method: "GET",
1360
- responseType: "arraybuffer",
1361
- timeout: 3e4,
1362
- headers: {
1363
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
1364
- "Accept": accept,
1365
- "Referer": "https://qq.com"
1366
- }
1367
- });
1368
- if (!response.data) throw new Error("empty_response");
1369
- const buffer = Buffer.from(response.data);
1370
- if (buffers && mediaType === "img") {
1371
- buffers.push(buffer);
1372
- }
1373
- const md5 = path5.basename(fileName || `${mediaType}`, ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
1374
- const files = await fs5.promises.readdir(resourceDir);
1375
- const duplicateFile = files.find((file) => {
1376
- const match = file.match(/^\d+_([^.]+)/);
1377
- return match && match[1] === md5;
1378
- });
1379
- if (duplicateFile) {
1380
- const duplicateCaveId = parseInt(duplicateFile.split("_")[0]);
1381
- if (!isNaN(duplicateCaveId)) {
1382
- const caveFilePath = path5.join(ctx.baseDir, "data", "cave", "cave.json");
1383
- const data = await FileHandler.readJsonData(caveFilePath);
1384
- const originalCave = data.find((item) => item.cave_id === duplicateCaveId);
1385
- if (originalCave) {
1386
- const message = session.text("commands.cave.error.exactDuplicateFound");
1387
- await session.send(message + await buildMessage(originalCave, resourceDir, session));
1388
- throw new Error("duplicate_found");
1389
- }
1390
- }
1391
- }
1392
- if (mediaType === "img" && config.enableImageDuplicate) {
1393
- const result = await hashStorage.findDuplicates(
1394
- { images: [buffer] },
1395
- {
1396
- image: config.imageDuplicateThreshold,
1397
- text: config.textDuplicateThreshold
1398
- }
1399
- );
1400
- if (result.length > 0 && result[0] !== null) {
1401
- const duplicate = result[0];
1402
- const similarity = duplicate.similarity;
1403
- if (similarity >= config.imageDuplicateThreshold) {
1404
- const caveFilePath = path5.join(ctx.baseDir, "data", "cave", "cave.json");
1405
- const data = await FileHandler.readJsonData(caveFilePath);
1406
- const originalCave = data.find((item) => item.cave_id === duplicate.caveId);
1407
- if (originalCave) {
1408
- const message = session.text(
1409
- "commands.cave.error.similarDuplicateFound",
1410
- [(similarity * 100).toFixed(1)]
1411
- );
1412
- await session.send(message + await buildMessage(originalCave, resourceDir, session));
1413
- throw new Error("duplicate_found");
1414
- }
1415
- }
1416
- }
1417
- }
1418
- const finalFileName = `${caveId}_${md5}${ext}`;
1419
- const filePath = path5.join(resourceDir, finalFileName);
1420
- await FileHandler.saveMediaFile(filePath, buffer);
1421
- return finalFileName;
547
+ await this.ctx.broadcast(this.config.adminUsers, reviewMessage);
1422
548
  } catch (error) {
1423
- if (error.message === "duplicate_found") {
1424
- throw error;
1425
- }
1426
- logger4.error(`Failed to download media: ${error.message}`);
1427
- throw new Error(session.text(`commands.cave.error.upload${mediaType === "img" ? "Image" : "Video"}Failed`));
549
+ this.logger.error(`广播回声洞(${cave.id})审核请求失败:`, error);
1428
550
  }
1429
- });
1430
- return Promise.all(downloadTasks);
1431
- }
1432
- __name(saveMedia, "saveMedia");
1433
-
1434
- // src/utils/ProcessHandle.ts
1435
- var fs6 = __toESM(require("fs"));
1436
- var path6 = __toESM(require("path"));
1437
- async function processList(session, config, idManager, userId, pageNum = 1) {
1438
- const stats = idManager.getStats();
1439
- if (userId && userId in stats) {
1440
- const ids = stats[userId];
1441
- return session.text("commands.cave.list.totalItems", [userId, ids.length]) + "\n" + session.text("commands.cave.list.idsLine", [ids.join(",")]);
1442
- }
1443
- const lines = Object.entries(stats).map(([cid, ids]) => {
1444
- return session.text("commands.cave.list.totalItems", [cid, ids.length]) + "\n" + session.text("commands.cave.list.idsLine", [ids.join(",")]);
1445
- });
1446
- const totalSubmissions = Object.values(stats).reduce((sum, arr) => sum + arr.length, 0);
1447
- if (config.enablePagination) {
1448
- const itemsPerPage = config.itemsPerPage;
1449
- const totalPages = Math.max(1, Math.ceil(lines.length / itemsPerPage));
1450
- pageNum = Math.min(Math.max(1, pageNum), totalPages);
1451
- const start = (pageNum - 1) * itemsPerPage;
1452
- const paginatedLines = lines.slice(start, start + itemsPerPage);
1453
- return session.text("commands.cave.list.header", [totalSubmissions]) + "\n" + paginatedLines.join("\n") + "\n" + session.text("commands.cave.list.pageInfo", [pageNum, totalPages]);
1454
- } else {
1455
- return session.text("commands.cave.list.header", [totalSubmissions]) + "\n" + lines.join("\n");
1456
- }
1457
- }
1458
- __name(processList, "processList");
1459
- async function processView(caveFilePath, resourceDir, session, options, content) {
1460
- const caveId = parseInt(content[0] || (typeof options.g === "string" ? options.g : ""));
1461
- if (isNaN(caveId)) return sendMessage(session, "commands.cave.error.invalidId", [], true);
1462
- const data = await FileHandler.readJsonData(caveFilePath);
1463
- const cave = data.find((item) => item.cave_id === caveId);
1464
- if (!cave) return sendMessage(session, "commands.cave.error.notFound", [], true);
1465
- return buildMessage(cave, resourceDir, session);
1466
- }
1467
- __name(processView, "processView");
1468
- async function processRandom(caveFilePath, resourceDir, session) {
1469
- const data = await FileHandler.readJsonData(caveFilePath);
1470
- if (data.length === 0) {
1471
- return sendMessage(session, "commands.cave.error.noCave", [], true);
1472
551
  }
1473
- const cave = (() => {
1474
- const validCaves = data.filter((cave2) => cave2.elements && cave2.elements.length > 0);
1475
- if (!validCaves.length) return void 0;
1476
- const randomIndex = Math.floor(Math.random() * validCaves.length);
1477
- return validCaves[randomIndex];
1478
- })();
1479
- return cave ? buildMessage(cave, resourceDir, session) : sendMessage(session, "commands.cave.error.getCave", [], true);
1480
- }
1481
- __name(processRandom, "processRandom");
1482
- async function processDelete(caveFilePath, resourceDir, pendingFilePath, session, config, options, content, idManager, HashManager2) {
1483
- const caveId = parseInt(content[0] || (typeof options.r === "string" ? options.r : ""));
1484
- if (isNaN(caveId)) return sendMessage(session, "commands.cave.error.invalidId", [], true);
1485
- const data = await FileHandler.readJsonData(caveFilePath);
1486
- const pendingData = await FileHandler.readJsonData(pendingFilePath);
1487
- const targetInData = data.find((item) => item.cave_id === caveId);
1488
- const targetInPending = pendingData.find((item) => item.cave_id === caveId);
1489
- if (!targetInData && !targetInPending) {
1490
- return sendMessage(session, "commands.cave.error.notFound", [], true);
1491
- }
1492
- const targetCave = targetInData || targetInPending;
1493
- const isPending = !targetInData;
1494
- if (targetCave.contributor_number !== session.userId && !config.manager.includes(session.userId)) {
1495
- return sendMessage(session, "commands.cave.remove.noPermission", [], true);
1496
- }
1497
- const caveContent = await buildMessage(targetCave, resourceDir, session);
1498
- if (targetCave.elements) {
1499
- await HashManager2.updateCaveContent(caveId, {
1500
- images: void 0,
1501
- texts: void 0
1502
- });
1503
- for (const element of targetCave.elements) {
1504
- if ((element.type === "img" || element.type === "video") && element.file) {
1505
- const fullPath = path6.join(resourceDir, element.file);
1506
- if (fs6.existsSync(fullPath)) {
1507
- await fs6.promises.unlink(fullPath);
1508
- }
1509
- }
552
+ /**
553
+ * 构建一条用于发送给管理员的、包含审核信息的消息。
554
+ * @param cave - 待审核的回声洞对象。
555
+ * @returns 一个可直接发送的消息数组。
556
+ * @private
557
+ */
558
+ async buildReviewMessage(cave) {
559
+ const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
560
+ return [
561
+ (0, import_koishi2.h)("p", `以下内容待审核:`),
562
+ ...caveContent
563
+ ];
564
+ }
565
+ /**
566
+ * 处理管理员的审核决定(通过或拒绝)。
567
+ * @param action - 'approve' (通过) 'reject' (拒绝)
568
+ * @param caveId - 被审核的回声洞 ID。
569
+ * @param adminUserName - 执行操作的管理员的昵称。
570
+ * @returns 返回给操作者的确认消息。
571
+ */
572
+ async processReview(action, caveId, adminUserName) {
573
+ const [cave] = await this.ctx.database.get("cave", { id: caveId });
574
+ if (!cave) return `回声洞(${caveId})不存在`;
575
+ if (cave.status !== "pending") return `回声洞(${caveId})无需审核`;
576
+ let resultMessage;
577
+ let broadcastMessage;
578
+ if (action === "approve") {
579
+ await this.ctx.database.upsert("cave", [{ id: caveId, status: "active" }]);
580
+ resultMessage = `回声洞(${caveId})已通过`;
581
+ broadcastMessage = `回声洞(${caveId})已由管理员 "${adminUserName}" 通过`;
582
+ } else {
583
+ await this.ctx.database.upsert("cave", [{ id: caveId, status: "delete" }]);
584
+ resultMessage = `回声洞(${caveId})已拒绝`;
585
+ const caveContent = await buildCaveMessage(cave, this.config, this.fileManager, this.logger);
586
+ broadcastMessage = [
587
+ (0, import_koishi2.h)("p", `回声洞(${caveId})已由管理员 "${adminUserName}" 拒绝`),
588
+ ...caveContent
589
+ ];
590
+ }
591
+ if (broadcastMessage && this.config.adminUsers?.length) {
592
+ await this.ctx.broadcast(this.config.adminUsers, broadcastMessage).catch((err) => {
593
+ this.logger.error(`广播回声洞(${cave.id})审核结果失败:`, err);
594
+ });
1510
595
  }
596
+ return resultMessage;
1511
597
  }
1512
- if (isPending) {
1513
- const newPendingData = pendingData.filter((item) => item.cave_id !== caveId);
1514
- await FileHandler.writeJsonData(pendingFilePath, newPendingData);
1515
- } else {
1516
- const newData = data.filter((item) => item.cave_id !== caveId);
1517
- await FileHandler.writeJsonData(caveFilePath, newData);
1518
- await idManager.removeStat(targetCave.contributor_number, caveId);
1519
- }
1520
- await idManager.markDeleted(caveId);
1521
- const deleteStatus = isPending ? session.text("commands.cave.remove.deletePending") : "";
1522
- const deleteMessage = session.text("commands.cave.remove.deleted");
1523
- return `${deleteMessage}${deleteStatus}${caveContent}`;
1524
- }
1525
- __name(processDelete, "processDelete");
598
+ };
1526
599
 
1527
600
  // src/index.ts
1528
601
  var name = "best-cave";
@@ -1540,300 +613,210 @@ var usage = `
1540
613
  <p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p>
1541
614
  </div>
1542
615
  `;
1543
- var logger5 = new import_koishi6.Logger("cave");
1544
- var Config = import_koishi6.Schema.object({
1545
- manager: import_koishi6.Schema.array(import_koishi6.Schema.string()).required(),
1546
- // 管理员用户ID
1547
- number: import_koishi6.Schema.number().default(60),
1548
- // 冷却时间()
1549
- enableAudit: import_koishi6.Schema.boolean().default(false),
1550
- // 启用审核
1551
- enableTextDuplicate: import_koishi6.Schema.boolean().default(true),
1552
- // 启用文本查重
1553
- textDuplicateThreshold: import_koishi6.Schema.number().default(0.9),
1554
- // 文本查重阈值
1555
- enableImageDuplicate: import_koishi6.Schema.boolean().default(true),
1556
- // 开启图片查重
1557
- imageDuplicateThreshold: import_koishi6.Schema.number().default(0.8),
1558
- // 图片查重阈值
1559
- imageMaxSize: import_koishi6.Schema.number().default(4),
1560
- // 图片大小限制(MB)
1561
- allowVideo: import_koishi6.Schema.boolean().default(true),
1562
- // 允许视频
1563
- videoMaxSize: import_koishi6.Schema.number().default(16),
1564
- // 视频大小限制(MB)
1565
- enablePagination: import_koishi6.Schema.boolean().default(false),
1566
- // 启用分页
1567
- itemsPerPage: import_koishi6.Schema.number().default(10),
1568
- // 每页条数
1569
- blacklist: import_koishi6.Schema.array(import_koishi6.Schema.string()).default([]),
1570
- // 黑名单
1571
- whitelist: import_koishi6.Schema.array(import_koishi6.Schema.string()).default([])
1572
- // 白名单
1573
- }).i18n({
1574
- "zh-CN": require_zh_CN()._config,
1575
- "en-US": require_en_US()._config
1576
- });
1577
- async function apply(ctx, config) {
1578
- ctx.i18n.define("zh-CN", require_zh_CN());
1579
- ctx.i18n.define("en-US", require_en_US());
1580
- const dataDir = path7.join(ctx.baseDir, "data");
1581
- const caveDir = path7.join(dataDir, "cave");
1582
- await FileHandler.ensureDirectory(caveDir);
1583
- await FileHandler.ensureDirectory(path7.join(caveDir, "resources"));
1584
- await FileHandler.ensureJsonFile(path7.join(caveDir, "cave.json"));
1585
- await FileHandler.ensureJsonFile(path7.join(caveDir, "pending.json"));
1586
- await FileHandler.ensureJsonFile(path7.join(caveDir, "hash.json"));
1587
- const idManager = new IdManager(ctx.baseDir);
1588
- const contentHashManager = new HashManager(caveDir);
1589
- const auditManager = new AuditManager(ctx, config, idManager);
1590
- await Promise.all([
1591
- idManager.initialize(path7.join(caveDir, "cave.json"), path7.join(caveDir, "pending.json")),
1592
- contentHashManager.initialize()
1593
- ]);
616
+ var logger = new import_koishi3.Logger("best-cave");
617
+ var Config = import_koishi3.Schema.intersect([
618
+ import_koishi3.Schema.object({
619
+ cooldown: import_koishi3.Schema.number().default(10).description("冷却时间(秒)"),
620
+ perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
621
+ enableProfile: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
622
+ enableDataIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
623
+ adminUsers: import_koishi3.Schema.array(import_koishi3.Schema.string()).default([]).description("管理员 ID 列表")
624
+ }).description("基础配置"),
625
+ import_koishi3.Schema.object({
626
+ enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核")
627
+ }).description("审核配置"),
628
+ import_koishi3.Schema.object({
629
+ enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
630
+ endpoint: import_koishi3.Schema.string().required().description("端点 (Endpoint)"),
631
+ bucket: import_koishi3.Schema.string().required().description("存储桶 (Bucket)"),
632
+ region: import_koishi3.Schema.string().default("auto").description("区域 (Region)"),
633
+ publicUrl: import_koishi3.Schema.string().description("公共访问 URL").role("link"),
634
+ accessKeyId: import_koishi3.Schema.string().required().description("Access Key ID").role("secret"),
635
+ secretAccessKey: import_koishi3.Schema.string().required().description("Secret Access Key").role("secret")
636
+ }).description("存储配置")
637
+ ]);
638
+ function apply(ctx, config) {
639
+ ctx.model.extend("cave", {
640
+ id: "unsigned",
641
+ // 无符号整数,作为主键。
642
+ elements: "json",
643
+ // 存储为 JSON 字符串的元素数组。
644
+ channelId: "string",
645
+ // 频道 ID。
646
+ userId: "string",
647
+ // 用户 ID。
648
+ userName: "string",
649
+ // 用户昵称。
650
+ status: "string",
651
+ // 回声洞状态。
652
+ time: "timestamp"
653
+ // 提交时间。
654
+ }, {
655
+ primary: "id"
656
+ // 'id' 字段设置为主键。
657
+ });
658
+ const fileManager = new FileManager(ctx.baseDir, config, logger);
1594
659
  const lastUsed = /* @__PURE__ */ new Map();
1595
- async function processAdd(ctx2, config2, caveFilePath, resourceDir, pendingFilePath, session, content) {
1596
- let caveId;
660
+ let profileManager;
661
+ let dataManager;
662
+ let reviewManager;
663
+ 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 }) => {
664
+ if (options.add) return session.execute(`cave.add ${options.add}`);
665
+ if (options.view) return session.execute(`cave.view ${options.view}`);
666
+ if (options.delete) return session.execute(`cave.del ${options.delete}`);
667
+ if (options.list) return session.execute("cave.list");
668
+ const cdMessage = checkCooldown(session, config, lastUsed);
669
+ if (cdMessage) return cdMessage;
1597
670
  try {
1598
- caveId = await idManager.getNextId();
1599
- if (isNaN(caveId) || caveId <= 0) {
1600
- throw new Error("Invalid ID generated");
1601
- }
1602
- const inputContent = content.length > 0 ? content.join("\n") : await (async () => {
1603
- await sendMessage(session, "commands.cave.add.noContent", [], true, 6e4);
1604
- const reply = await session.prompt({ timeout: 6e4 });
1605
- if (!reply) {
1606
- await sendMessage(session, "commands.cave.add.operationTimeout", [], true);
1607
- return null;
671
+ const query = getScopeQuery(session, config);
672
+ const candidates = await ctx.database.get("cave", query, { fields: ["id"] });
673
+ if (candidates.length === 0) {
674
+ return `当前${config.perChannel && session.channelId ? "本群" : ""}还没有任何回声洞`;
675
+ }
676
+ const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
677
+ const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId });
678
+ updateCooldownTimestamp(session, config, lastUsed);
679
+ return buildCaveMessage(randomCave, config, fileManager, logger);
680
+ } catch (error) {
681
+ logger.error("随机获取回声洞失败:", error);
682
+ return "随机获取回声洞失败";
683
+ }
684
+ });
685
+ cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可以直接发送内容,也可以回复或引用一条消息。").action(async ({ session }, content) => {
686
+ cleanupPendingDeletions(ctx, fileManager, logger);
687
+ const savedFileIdentifiers = [];
688
+ try {
689
+ let sourceElements;
690
+ if (session.quote?.elements) {
691
+ sourceElements = session.quote.elements;
692
+ } else if (content?.trim()) {
693
+ sourceElements = import_koishi3.h.parse(content);
694
+ } else {
695
+ await session.send("请在一分钟内发送你要添加的内容");
696
+ const reply = await session.prompt(6e4);
697
+ if (!reply) return "操作超时,已取消添加";
698
+ sourceElements = import_koishi3.h.parse(reply);
699
+ }
700
+ const scopeQuery = getScopeQuery(session, config);
701
+ const newId = await getNextCaveId(ctx, scopeQuery);
702
+ const finalElementsForDb = [];
703
+ let mediaIndex = 1;
704
+ async function traverseAndProcess(elements) {
705
+ for (const el of elements) {
706
+ const elementType = el.type === "image" ? "img" : el.type;
707
+ if (["img", "video", "audio", "file"].includes(elementType) && el.attrs.src) {
708
+ let fileIdentifier = el.attrs.src;
709
+ if (fileIdentifier.startsWith("http")) {
710
+ mediaIndex++;
711
+ const originalName = el.attrs.file;
712
+ const savedId = await downloadMedia(ctx, fileManager, fileIdentifier, originalName, elementType, newId, mediaIndex, session.channelId, session.userId);
713
+ savedFileIdentifiers.push(savedId);
714
+ fileIdentifier = savedId;
715
+ }
716
+ finalElementsForDb.push({ type: elementType, file: fileIdentifier });
717
+ } else if (elementType === "text" && el.attrs.content?.trim()) {
718
+ finalElementsForDb.push({ type: "text", content: el.attrs.content.trim() });
719
+ }
720
+ if (el.children) await traverseAndProcess(el.children);
1608
721
  }
1609
- return reply;
1610
- })();
1611
- if (!inputContent) {
1612
- return "";
1613
- }
1614
- if (inputContent.includes("/app/.config/QQ/")) {
1615
- return sendMessage(session, "commands.cave.add.localFileNotAllowed", [], true);
1616
722
  }
1617
- const bypassAudit = config2.whitelist.includes(session.userId) || config2.whitelist.includes(session.guildId) || config2.whitelist.includes(session.channelId);
1618
- const { imageUrls, imageElements, videoUrls, videoElements, textParts } = await extractMediaContent(inputContent, config2, session);
1619
- if (videoUrls.length > 0 && !config2.allowVideo) {
1620
- return sendMessage(session, "commands.cave.add.videoDisabled", [], true);
723
+ __name(traverseAndProcess, "traverseAndProcess");
724
+ await traverseAndProcess(sourceElements);
725
+ if (finalElementsForDb.length === 0) return "内容为空,已取消添加";
726
+ let userName = session.username;
727
+ if (config.enableProfile) {
728
+ userName = await profileManager.getNickname(session.userId) || userName;
1621
729
  }
1622
- const imageBuffers = [];
1623
- const [savedImages, savedVideos] = await Promise.all([
1624
- imageUrls.length > 0 ? saveMedia(
1625
- imageUrls,
1626
- imageElements.map((el) => el.fileName),
1627
- resourceDir,
1628
- caveId,
1629
- "img",
1630
- config2,
1631
- ctx2,
1632
- session,
1633
- imageBuffers
1634
- ) : [],
1635
- videoUrls.length > 0 ? saveMedia(
1636
- videoUrls,
1637
- videoElements.map((el) => el.fileName),
1638
- resourceDir,
1639
- caveId,
1640
- "video",
1641
- config2,
1642
- ctx2,
1643
- session
1644
- ) : []
1645
- ]);
1646
730
  const newCave = {
1647
- cave_id: caveId,
1648
- elements: [
1649
- ...textParts,
1650
- ...imageElements.map((el, idx) => ({
1651
- ...el,
1652
- file: savedImages[idx],
1653
- index: el.index
1654
- }))
1655
- ].sort((a, b) => a.index - b.index),
1656
- contributor_number: session.userId || "100000",
1657
- contributor_name: session.username || "User"
731
+ id: newId,
732
+ elements: finalElementsForDb,
733
+ channelId: session.channelId,
734
+ userId: session.userId,
735
+ userName,
736
+ status: config.enableReview ? "pending" : "active",
737
+ time: /* @__PURE__ */ new Date()
1658
738
  };
1659
- if (videoUrls.length > 0 && savedVideos.length > 0) {
1660
- newCave.elements.push({
1661
- type: "video",
1662
- file: savedVideos[0],
1663
- index: Number.MAX_SAFE_INTEGER
1664
- });
739
+ await ctx.database.create("cave", newCave);
740
+ if (newCave.status === "pending") {
741
+ reviewManager.sendForReview(newCave);
742
+ return `提交成功,序号为(${newCave.id})`;
1665
743
  }
1666
- const hashStorage = new HashManager(path7.join(ctx2.baseDir, "data", "cave"));
1667
- await hashStorage.initialize();
1668
- const hashStatus = await hashStorage.getStatus();
1669
- if (!hashStatus.lastUpdated || hashStatus.entries.length === 0) {
1670
- const existingData = await FileHandler.readJsonData(caveFilePath);
1671
- const hasImages = existingData.some(
1672
- (cave) => cave.elements?.some((element) => element.type === "img" && element.file)
1673
- );
1674
- if (hasImages) {
1675
- await hashStorage.updateAllCaves(true);
1676
- }
1677
- }
1678
- if (config2.enableAudit && !bypassAudit) {
1679
- const pendingData = await FileHandler.readJsonData(pendingFilePath);
1680
- pendingData.push(newCave);
1681
- await Promise.all([
1682
- FileHandler.writeJsonData(pendingFilePath, pendingData),
1683
- auditManager.sendAuditMessage(newCave, await buildMessage(newCave, resourceDir, session), session)
1684
- ]);
1685
- return sendMessage(session, "commands.cave.add.submitPending", [caveId], false);
1686
- }
1687
- const data = await FileHandler.readJsonData(caveFilePath);
1688
- data.push({
1689
- ...newCave,
1690
- elements: cleanElementsForSave(newCave.elements, false)
1691
- });
1692
- if (config2.enableImageDuplicate || config2.enableTextDuplicate) {
1693
- const duplicateResults = await contentHashManager.findDuplicates({
1694
- images: config2.enableImageDuplicate ? imageBuffers : void 0,
1695
- texts: config2.enableTextDuplicate ? textParts.filter((p) => p.type === "text").map((p) => p.content) : void 0
1696
- }, {
1697
- image: config2.imageDuplicateThreshold,
1698
- text: config2.textDuplicateThreshold
1699
- });
1700
- for (const result of duplicateResults) {
1701
- if (!result) continue;
1702
- const originalCave = data.find((item) => item.cave_id === result.caveId);
1703
- if (!originalCave) continue;
1704
- await idManager.markDeleted(caveId);
1705
- const duplicateMessage = session.text(
1706
- "commands.cave.error.similarDuplicateFound",
1707
- [(result.similarity * 100).toFixed(1)]
1708
- );
1709
- await session.send(duplicateMessage + await buildMessage(originalCave, resourceDir, session));
1710
- throw new Error("duplicate_found");
1711
- }
1712
- }
1713
- await Promise.all([
1714
- FileHandler.writeJsonData(caveFilePath, data),
1715
- contentHashManager.updateCaveContent(caveId, {
1716
- images: savedImages.length > 0 ? await Promise.all(savedImages.map((file) => fs7.promises.readFile(path7.join(resourceDir, file)))) : void 0,
1717
- texts: textParts.filter((p) => p.type === "text").map((p) => p.content)
1718
- })
1719
- ]);
1720
- await idManager.addStat(session.userId, caveId);
1721
- return sendMessage(session, "commands.cave.add.addSuccess", [caveId], false);
744
+ return `添加成功,序号为(${newId})`;
1722
745
  } catch (error) {
1723
- if (typeof caveId === "number" && !isNaN(caveId) && caveId > 0) {
1724
- await idManager.markDeleted(caveId);
1725
- }
1726
- if (error.message === "duplicate_found") {
1727
- return "";
746
+ logger.error("添加回声洞失败:", error);
747
+ if (savedFileIdentifiers.length > 0) {
748
+ logger.info(`添加失败,回滚并删除 ${savedFileIdentifiers.length} 个文件...`);
749
+ await Promise.all(savedFileIdentifiers.map((fileId) => fileManager.deleteFile(fileId)));
1728
750
  }
1729
- logger5.error(`Failed to process add command: ${error.message}`);
1730
- return sendMessage(session, "commands.cave.error.addFailed", [], true);
751
+ return "添加失败,请稍后再试";
1731
752
  }
1732
- }
1733
- __name(processAdd, "processAdd");
1734
- const caveCommand = ctx.command("cave [message]").option("a", "添加回声洞").option("g", "查看回声洞", { type: "string" }).option("r", "删除回声洞", { type: "string" }).option("l", "查询投稿统计", { type: "string" }).before(async ({ session }) => {
1735
- if (config.blacklist.includes(session.userId)) {
1736
- return sendMessage(session, "commands.cave.message.blacklisted", [], true);
1737
- }
1738
- }).action(async ({ session, options }, ...content) => {
1739
- const dataDir2 = path7.join(ctx.baseDir, "data");
1740
- const caveDir2 = path7.join(dataDir2, "cave");
1741
- const caveFilePath = path7.join(caveDir2, "cave.json");
1742
- const resourceDir = path7.join(caveDir2, "resources");
1743
- const pendingFilePath = path7.join(caveDir2, "pending.json");
1744
- const needsCooldown = !options.l && !options.a;
1745
- if (needsCooldown) {
1746
- const guildId = session.guildId;
1747
- const now = Date.now();
1748
- const lastTime = lastUsed.get(guildId) || 0;
1749
- const isManager = config.manager.includes(session.userId);
1750
- if (!isManager && now - lastTime < config.number * 1e3) {
1751
- const waitTime = Math.ceil((config.number * 1e3 - (now - lastTime)) / 1e3);
1752
- return sendMessage(session, "commands.cave.message.cooldown", [waitTime], true);
1753
- }
1754
- lastUsed.set(guildId, now);
1755
- }
1756
- if (options.l !== void 0) {
1757
- const input = typeof options.l === "string" ? options.l : content[0];
1758
- const num = parseInt(input);
1759
- if (config.manager.includes(session.userId)) {
1760
- if (!isNaN(num)) {
1761
- if (num < 1e4) {
1762
- return await processList(session, config, idManager, void 0, num);
1763
- } else {
1764
- return await processList(session, config, idManager, num.toString());
1765
- }
1766
- } else if (input) {
1767
- return await processList(session, config, idManager, input);
1768
- }
1769
- return await processList(session, config, idManager);
1770
- } else {
1771
- return await processList(session, config, idManager, session.userId);
753
+ });
754
+ cave.subcommand(".view <id:posint>", "查看指定回声洞").usage("通过序号查看对应的回声洞。").action(async ({ session }, id) => {
755
+ if (!id) return "请输入要查看的回声洞序号";
756
+ const cdMessage = checkCooldown(session, config, lastUsed);
757
+ if (cdMessage) return cdMessage;
758
+ try {
759
+ const query = { ...getScopeQuery(session, config), id };
760
+ const [targetCave] = await ctx.database.get("cave", query);
761
+ if (!targetCave) {
762
+ return `回声洞(${id})不存在`;
1772
763
  }
764
+ updateCooldownTimestamp(session, config, lastUsed);
765
+ return buildCaveMessage(targetCave, config, fileManager, logger);
766
+ } catch (error) {
767
+ logger.error(`查看回声洞(${id})失败:`, error);
768
+ return "查看失败,请稍后再试";
1773
769
  }
1774
- if (options.g) {
1775
- return await processView(caveFilePath, resourceDir, session, options, content);
1776
- }
1777
- if (options.r) {
1778
- return await processDelete(caveFilePath, resourceDir, pendingFilePath, session, config, options, content, idManager, contentHashManager);
1779
- }
1780
- if (options.a) {
1781
- return await processAdd(ctx, config, caveFilePath, resourceDir, pendingFilePath, session, content);
1782
- }
1783
- return await processRandom(caveFilePath, resourceDir, session);
1784
770
  });
1785
- caveCommand.subcommand(".pass <id:text>", "通过回声洞审核").before(async ({ session }) => {
1786
- if (!config.manager.includes(session.userId)) {
1787
- return sendMessage(session, "commands.cave.message.managerOnly", [], true);
771
+ cave.subcommand(".del <id:posint>", "删除指定回声洞").usage("通过序号删除对应的回声洞。").action(async ({ session }, id) => {
772
+ if (!id) return "请输入要删除的回声洞序号";
773
+ try {
774
+ const [targetCave] = await ctx.database.get("cave", { id, status: "active" });
775
+ if (!targetCave) return `回声洞(${id})不存在`;
776
+ const isOwner = targetCave.userId === session.userId;
777
+ const isAdmin = config.adminUsers.includes(session.userId);
778
+ if (!isOwner && !isAdmin) {
779
+ return "抱歉,你没有权限删除这条回声洞";
780
+ }
781
+ await ctx.database.upsert("cave", [{ id, status: "delete" }]);
782
+ cleanupPendingDeletions(ctx, fileManager, logger);
783
+ const caveMessage = await buildCaveMessage(targetCave, config, fileManager, logger);
784
+ return [
785
+ (0, import_koishi3.h)("p", {}, `以下内容已删除`),
786
+ ...caveMessage
787
+ ];
788
+ } catch (error) {
789
+ logger.error(`标记回声洞(${id})失败:`, error);
790
+ return "删除失败,请稍后再试";
1788
791
  }
1789
- }).action(async ({ session }, id) => {
1790
- const dataDir2 = path7.join(ctx.baseDir, "data");
1791
- const caveDir2 = path7.join(dataDir2, "cave");
1792
- const caveFilePath = path7.join(caveDir2, "cave.json");
1793
- const resourceDir = path7.join(caveDir2, "resources");
1794
- const pendingFilePath = path7.join(caveDir2, "pending.json");
1795
- const pendingData = await FileHandler.readJsonData(pendingFilePath);
1796
- return await auditManager.processAudit(pendingData, true, caveFilePath, resourceDir, pendingFilePath, session, id === "all" ? void 0 : parseInt(id));
1797
792
  });
1798
- caveCommand.subcommand(".reject <id:text>", "拒绝回声洞审核").before(async ({ session }) => {
1799
- if (!config.manager.includes(session.userId)) {
1800
- return sendMessage(session, "commands.cave.message.managerOnly", [], true);
793
+ cave.subcommand(".list", "查询我的投稿").usage("查询并列出你所有投稿的回声洞序号。").action(async ({ session }) => {
794
+ try {
795
+ const query = { ...getScopeQuery(session, config), userId: session.userId };
796
+ const userCaves = await ctx.database.get("cave", query);
797
+ if (userCaves.length === 0) return "你还没有投稿过回声洞";
798
+ const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join(", ");
799
+ return `你已投稿 ${userCaves.length} 条回声洞,序号为:
800
+ ${caveIds}`;
801
+ } catch (error) {
802
+ logger.error("查询投稿列表失败:", error);
803
+ return "查询失败,请稍后再试";
1801
804
  }
1802
- }).action(async ({ session }, id) => {
1803
- const dataDir2 = path7.join(ctx.baseDir, "data");
1804
- const caveDir2 = path7.join(dataDir2, "cave");
1805
- const caveFilePath = path7.join(caveDir2, "cave.json");
1806
- const resourceDir = path7.join(caveDir2, "resources");
1807
- const pendingFilePath = path7.join(caveDir2, "pending.json");
1808
- const pendingData = await FileHandler.readJsonData(pendingFilePath);
1809
- return await auditManager.processAudit(pendingData, false, caveFilePath, resourceDir, pendingFilePath, session, id === "all" ? void 0 : parseInt(id));
1810
805
  });
806
+ if (config.enableProfile) {
807
+ profileManager = new ProfileManager(ctx);
808
+ profileManager.registerCommands(cave);
809
+ }
810
+ if (config.enableDataIO) {
811
+ dataManager = new DataManager(ctx, config, fileManager, logger);
812
+ dataManager.registerCommands(cave);
813
+ }
814
+ if (config.enableReview) {
815
+ reviewManager = new ReviewManager(ctx, config, fileManager, logger);
816
+ reviewManager.registerCommands(cave);
817
+ }
1811
818
  }
1812
819
  __name(apply, "apply");
1813
- function cleanElementsForSave(elements, keepIndex = false) {
1814
- if (!elements?.length) return [];
1815
- const cleanedElements = elements.map((element) => {
1816
- if (element.type === "text") {
1817
- const cleanedElement = {
1818
- type: "text",
1819
- content: element.content
1820
- };
1821
- if (keepIndex) cleanedElement.index = element.index;
1822
- return cleanedElement;
1823
- } else if (element.type === "img" || element.type === "video") {
1824
- const mediaElement = element;
1825
- const cleanedElement = {
1826
- type: mediaElement.type
1827
- };
1828
- if (mediaElement.file) cleanedElement.file = mediaElement.file;
1829
- if (keepIndex) cleanedElement.index = element.index;
1830
- return cleanedElement;
1831
- }
1832
- return element;
1833
- });
1834
- return keepIndex ? cleanedElements.sort((a, b) => (a.index || 0) - (b.index || 0)) : cleanedElements;
1835
- }
1836
- __name(cleanElementsForSave, "cleanElementsForSave");
1837
820
  // Annotate the CommonJS export names for ESM import in node:
1838
821
  0 && (module.exports = {
1839
822
  Config,