koishi-plugin-best-cave 1.2.0 → 1.3.1

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
@@ -33,14 +33,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
33
33
  // src/locales/zh-CN.yml
34
34
  var require_zh_CN = __commonJS({
35
35
  "src/locales/zh-CN.yml"(exports2, module2) {
36
- module2.exports = { _config: { manager: "管理员", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)", number: "冷却时间(秒)", enableAudit: "启用审核", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", imageMaxSize: "图片最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", p: "通过审核(批量)", d: "拒绝审核(批量)", l: "查询投稿统计" }, 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: "未找到该回声洞" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } };
36
+ module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", imageMaxSize: "图片最大大小(MB)", enableDuplicate: "启用图片查重", duplicateThreshold: "图片相似度阈值(0-1)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", p: "通过审核(批量)", d: "拒绝审核(批量)", l: "查询投稿统计" }, 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}% 的" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } };
37
37
  }
38
38
  });
39
39
 
40
40
  // src/locales/en-US.yml
41
41
  var require_en_US = __commonJS({
42
42
  "src/locales/en-US.yml"(exports2, module2) {
43
- module2.exports = { _config: { manager: "Administrators", blacklist: "Blacklist (Users)", whitelist: "Audit Whitelist (Users/Groups/Channels)", number: "Cooldown Time (seconds)", enableAudit: "Enable Audit", allowVideo: "Allow Video Upload", videoMaxSize: "Maximum Video Size (MB)", imageMaxSize: "Maximum Image Size (MB)", enablePagination: "Enable Statistics Pagination", itemsPerPage: "Items Per Page" }, commands: { cave: { description: "Echo Cave", usage: "Supports adding, drawing, viewing, and managing echo cave entries", examples: "Use cave to randomly draw an entry\nUse -a to add directly or add by reference\nUse -g to view specific entry\nUse -r to delete specific entry", options: { a: "Add entry", g: "View entry", r: "Delete entry", p: "Approve audit (batch)", d: "Reject audit (batch)", l: "Query submission statistics" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timed out, addition cancelled", videoDisabled: "Video upload is not allowed", submitPending: "Submission successful, ID is ({0})", addSuccess: "Addition successful, 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' entries", deletePending: "Delete (pending audit)", deleted: "Deleted" }, list: { pageInfo: "Page {0} / {1}", header: "Currently there are {0} entries:", totalItems: "User {0} has submitted {1} entries:", idsLine: "{0}" }, audit: { noPending: "No pending entries", pendingNotFound: "Pending entry not found", pendingResult: "{0}, {1} pending entries remaining: [{2}]", auditPassed: "Approved", auditRejected: "Rejected", batchAuditResult: "Has {0} {1}/{2} entries", title: "Pending Entries:", from: "Submitted by:", sendFailed: "Failed to send audit message, cannot contact administrator {0}" }, error: { noContent: "Entry content is empty", getCave: "Failed to get entry", noCave: "No entries found", invalidId: "Please enter a valid entry ID", notFound: "Entry not found" }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is restricted to administrators", cooldown: "Group chat cooling down... Please try again in {0} seconds", caveTitle: "Echo Cave —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0} file size exceeds limit" } } } };
43
+ module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", imageMaxSize: "Maximum image size (MB)", enableDuplicate: "Enable image duplicate check", duplicateThreshold: "Image similarity threshold (0-1)", 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", p: "Approve moderation (batch)", d: "Reject moderation (batch)", l: "Query submission statistics" }, 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" }, 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
44
  }
45
45
  });
46
46
 
@@ -53,22 +53,879 @@ __export(src_exports, {
53
53
  name: () => name
54
54
  });
55
55
  module.exports = __toCommonJS(src_exports);
56
- var import_koishi = require("koishi");
56
+ var import_koishi4 = require("koishi");
57
+ var fs4 = __toESM(require("fs"));
58
+ var path4 = __toESM(require("path"));
59
+
60
+ // src/utils/fileHandler.ts
57
61
  var fs = __toESM(require("fs"));
58
62
  var path = __toESM(require("path"));
63
+ var import_koishi = require("koishi");
64
+ var logger = new import_koishi.Logger("fileHandler");
65
+ var FileHandler = class {
66
+ static {
67
+ __name(this, "FileHandler");
68
+ }
69
+ static locks = /* @__PURE__ */ new Map();
70
+ static RETRY_COUNT = 3;
71
+ static RETRY_DELAY = 1e3;
72
+ static CONCURRENCY_LIMIT = 5;
73
+ /**
74
+ * 并发控制
75
+ * @param operation 要执行的操作
76
+ * @param limit 并发限制
77
+ * @returns 操作结果
78
+ */
79
+ static async withConcurrencyLimit(operation, limit = this.CONCURRENCY_LIMIT) {
80
+ while (this.locks.size >= limit) {
81
+ await Promise.race(this.locks.values());
82
+ }
83
+ return operation();
84
+ }
85
+ /**
86
+ * 文件操作包装器
87
+ * @param filePath 文件路径
88
+ * @param operation 要执行的操作
89
+ * @returns 操作结果
90
+ */
91
+ static async withFileOp(filePath, operation) {
92
+ const key = filePath;
93
+ while (this.locks.has(key)) {
94
+ await this.locks.get(key);
95
+ }
96
+ const operationPromise = (async () => {
97
+ for (let i = 0; i < this.RETRY_COUNT; i++) {
98
+ try {
99
+ return await operation();
100
+ } catch (error) {
101
+ if (i === this.RETRY_COUNT - 1) throw error;
102
+ await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY));
103
+ }
104
+ }
105
+ throw new Error("Operation failed after retries");
106
+ })();
107
+ this.locks.set(key, operationPromise);
108
+ try {
109
+ return await operationPromise;
110
+ } finally {
111
+ this.locks.delete(key);
112
+ }
113
+ }
114
+ /**
115
+ * 事务处理
116
+ * @param operations 要执行的操作数组
117
+ * @returns 操作结果数组
118
+ */
119
+ static async withTransaction(operations) {
120
+ const results = [];
121
+ const completed = /* @__PURE__ */ new Set();
122
+ try {
123
+ for (const { filePath, operation } of operations) {
124
+ const result = await this.withFileOp(filePath, operation);
125
+ results.push(result);
126
+ completed.add(filePath);
127
+ }
128
+ return results;
129
+ } catch (error) {
130
+ await Promise.all(
131
+ operations.filter(({ filePath }) => completed.has(filePath)).map(async ({ filePath, rollback }) => {
132
+ if (rollback) {
133
+ await this.withFileOp(filePath, rollback).catch(
134
+ (e) => logger.error(`Rollback failed for ${filePath}: ${e.message}`)
135
+ );
136
+ }
137
+ })
138
+ );
139
+ throw error;
140
+ }
141
+ }
142
+ /**
143
+ * 读取 JSON 数据
144
+ * @param filePath 文件路径
145
+ * @returns JSON 数据
146
+ */
147
+ static async readJsonData(filePath) {
148
+ return this.withFileOp(filePath, async () => {
149
+ try {
150
+ const data = await fs.promises.readFile(filePath, "utf8");
151
+ return JSON.parse(data || "[]");
152
+ } catch (error) {
153
+ return [];
154
+ }
155
+ });
156
+ }
157
+ /**
158
+ * 写入 JSON 数据
159
+ * @param filePath 文件路径
160
+ * @param data 要写入的数据
161
+ */
162
+ static async writeJsonData(filePath, data) {
163
+ const tmpPath = `${filePath}.tmp`;
164
+ await this.withFileOp(filePath, async () => {
165
+ await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2));
166
+ await fs.promises.rename(tmpPath, filePath);
167
+ });
168
+ }
169
+ /**
170
+ * 确保目录存在
171
+ * @param dir 目录路径
172
+ */
173
+ static async ensureDirectory(dir) {
174
+ await this.withConcurrencyLimit(async () => {
175
+ if (!fs.existsSync(dir)) {
176
+ await fs.promises.mkdir(dir, { recursive: true });
177
+ }
178
+ });
179
+ }
180
+ /**
181
+ * 确保 JSON 文件存在
182
+ * @param filePath 文件路径
183
+ */
184
+ static async ensureJsonFile(filePath) {
185
+ await this.withFileOp(filePath, async () => {
186
+ if (!fs.existsSync(filePath)) {
187
+ await fs.promises.writeFile(filePath, "[]", "utf8");
188
+ }
189
+ });
190
+ }
191
+ /**
192
+ * 保存媒体文件
193
+ * @param filePath 文件路径
194
+ * @param data 文件数据
195
+ */
196
+ static async saveMediaFile(filePath, data) {
197
+ await this.withConcurrencyLimit(async () => {
198
+ const dir = path.dirname(filePath);
199
+ await this.ensureDirectory(dir);
200
+ await this.withFileOp(
201
+ filePath,
202
+ () => fs.promises.writeFile(filePath, data)
203
+ );
204
+ });
205
+ }
206
+ /**
207
+ * 删除媒体文件
208
+ * @param filePath 文件路径
209
+ */
210
+ static async deleteMediaFile(filePath) {
211
+ await this.withFileOp(filePath, async () => {
212
+ if (fs.existsSync(filePath)) {
213
+ await fs.promises.unlink(filePath);
214
+ }
215
+ });
216
+ }
217
+ };
218
+
219
+ // src/utils/idManager.ts
220
+ var fs2 = __toESM(require("fs"));
221
+ var path2 = __toESM(require("path"));
222
+ var import_koishi2 = require("koishi");
223
+ var logger2 = new import_koishi2.Logger("idManager");
224
+ var IdManager = class {
225
+ static {
226
+ __name(this, "IdManager");
227
+ }
228
+ deletedIds = /* @__PURE__ */ new Set();
229
+ maxId = 0;
230
+ initialized = false;
231
+ statusFilePath;
232
+ stats = {};
233
+ usedIds = /* @__PURE__ */ new Set();
234
+ /**
235
+ * 初始化ID管理器
236
+ * @param baseDir - 基础目录路径
237
+ */
238
+ constructor(baseDir) {
239
+ const caveDir = path2.join(baseDir, "data", "cave");
240
+ this.statusFilePath = path2.join(caveDir, "status.json");
241
+ }
242
+ /**
243
+ * 初始化ID管理系统
244
+ * @param caveFilePath - 正式回声洞数据文件路径
245
+ * @param pendingFilePath - 待处理回声洞数据文件路径
246
+ * @throws 当初始化失败时抛出错误
247
+ */
248
+ async initialize(caveFilePath, pendingFilePath) {
249
+ if (this.initialized) return;
250
+ try {
251
+ const status = fs2.existsSync(this.statusFilePath) ? JSON.parse(await fs2.promises.readFile(this.statusFilePath, "utf8")) : {
252
+ deletedIds: [],
253
+ maxId: 0,
254
+ stats: {},
255
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
256
+ };
257
+ const [caveData, pendingData] = await Promise.all([
258
+ FileHandler.readJsonData(caveFilePath),
259
+ FileHandler.readJsonData(pendingFilePath)
260
+ ]);
261
+ this.usedIds.clear();
262
+ this.stats = {};
263
+ const conflicts = /* @__PURE__ */ new Map();
264
+ for (const data of [caveData, pendingData]) {
265
+ for (const item of data) {
266
+ if (this.usedIds.has(item.cave_id)) {
267
+ if (!conflicts.has(item.cave_id)) {
268
+ conflicts.set(item.cave_id, []);
269
+ }
270
+ conflicts.get(item.cave_id)?.push(item);
271
+ } else {
272
+ this.usedIds.add(item.cave_id);
273
+ if (data === caveData && item.contributor_number !== "10000") {
274
+ if (!this.stats[item.contributor_number]) {
275
+ this.stats[item.contributor_number] = [];
276
+ }
277
+ this.stats[item.contributor_number].push(item.cave_id);
278
+ }
279
+ }
280
+ }
281
+ }
282
+ if (conflicts.size > 0) {
283
+ await this.handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData);
284
+ }
285
+ this.maxId = Math.max(
286
+ status.maxId || 0,
287
+ ...[...this.usedIds],
288
+ ...status.deletedIds || [],
289
+ 0
290
+ );
291
+ this.deletedIds = new Set(
292
+ status.deletedIds?.filter((id) => !this.usedIds.has(id)) || []
293
+ );
294
+ await this.saveStatus();
295
+ this.initialized = true;
296
+ logger2.success("ID Manager initialized");
297
+ } catch (error) {
298
+ this.initialized = false;
299
+ logger2.error(`ID Manager initialization failed: ${error.message}`);
300
+ throw error;
301
+ }
302
+ }
303
+ /**
304
+ * 处理ID冲突
305
+ * @param conflicts - ID冲突映射表
306
+ * @param caveFilePath - 正式回声洞数据文件路径
307
+ * @param pendingFilePath - 待处理回声洞数据文件路径
308
+ * @param caveData - 正式回声洞数据
309
+ * @param pendingData - 待处理回声洞数据
310
+ * @private
311
+ */
312
+ async handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData) {
313
+ logger2.warn(`Found ${conflicts.size} ID conflicts`);
314
+ let modified = false;
315
+ for (const items of conflicts.values()) {
316
+ items.slice(1).forEach((item) => {
317
+ let newId = this.maxId + 1;
318
+ while (this.usedIds.has(newId)) {
319
+ newId++;
320
+ }
321
+ logger2.info(`Reassigning ID: ${item.cave_id} -> ${newId}`);
322
+ item.cave_id = newId;
323
+ this.usedIds.add(newId);
324
+ this.maxId = Math.max(this.maxId, newId);
325
+ modified = true;
326
+ });
327
+ }
328
+ if (modified) {
329
+ await Promise.all([
330
+ FileHandler.writeJsonData(caveFilePath, caveData),
331
+ FileHandler.writeJsonData(pendingFilePath, pendingData)
332
+ ]);
333
+ logger2.success("ID conflicts resolved");
334
+ }
335
+ }
336
+ /**
337
+ * 获取下一个可用的ID
338
+ * @returns 下一个可用的ID
339
+ * @throws 当ID管理器未初始化时抛出错误
340
+ */
341
+ getNextId() {
342
+ if (!this.initialized) {
343
+ throw new Error("IdManager not initialized");
344
+ }
345
+ let nextId;
346
+ if (this.deletedIds.size === 0) {
347
+ nextId = ++this.maxId;
348
+ } else {
349
+ nextId = Math.min(...Array.from(this.deletedIds));
350
+ this.deletedIds.delete(nextId);
351
+ }
352
+ while (this.usedIds.has(nextId)) {
353
+ nextId = ++this.maxId;
354
+ }
355
+ this.usedIds.add(nextId);
356
+ this.saveStatus().catch(
357
+ (err) => logger2.error(`Failed to save status after getNextId: ${err.message}`)
358
+ );
359
+ return nextId;
360
+ }
361
+ /**
362
+ * 标记ID为已删除状态
363
+ * @param id - 要标记为删除的ID
364
+ * @throws 当ID管理器未初始化时抛出错误
365
+ */
366
+ async markDeleted(id) {
367
+ if (!this.initialized) {
368
+ throw new Error("IdManager not initialized");
369
+ }
370
+ this.deletedIds.add(id);
371
+ this.usedIds.delete(id);
372
+ const maxUsedId = Math.max(...Array.from(this.usedIds), 0);
373
+ const maxDeletedId = Math.max(...Array.from(this.deletedIds), 0);
374
+ this.maxId = Math.max(maxUsedId, maxDeletedId);
375
+ await this.saveStatus();
376
+ }
377
+ /**
378
+ * 添加贡献统计
379
+ * @param contributorNumber - 贡献者编号
380
+ * @param caveId - 回声洞ID
381
+ */
382
+ async addStat(contributorNumber, caveId) {
383
+ if (contributorNumber === "10000") return;
384
+ if (!this.stats[contributorNumber]) {
385
+ this.stats[contributorNumber] = [];
386
+ }
387
+ this.stats[contributorNumber].push(caveId);
388
+ await this.saveStatus();
389
+ }
390
+ /**
391
+ * 移除贡献统计
392
+ * @param contributorNumber - 贡献者编号
393
+ * @param caveId - 回声洞ID
394
+ */
395
+ async removeStat(contributorNumber, caveId) {
396
+ if (this.stats[contributorNumber]) {
397
+ this.stats[contributorNumber] = this.stats[contributorNumber].filter((id) => id !== caveId);
398
+ if (this.stats[contributorNumber].length === 0) {
399
+ delete this.stats[contributorNumber];
400
+ }
401
+ await this.saveStatus();
402
+ }
403
+ }
404
+ /**
405
+ * 获取所有贡献统计信息
406
+ * @returns 贡献者编号到回声洞ID列表的映射
407
+ */
408
+ getStats() {
409
+ return this.stats;
410
+ }
411
+ /**
412
+ * 保存当前状态到文件
413
+ * @private
414
+ * @throws 当保存失败时抛出错误
415
+ */
416
+ async saveStatus() {
417
+ try {
418
+ const status = {
419
+ deletedIds: Array.from(this.deletedIds).sort((a, b) => a - b),
420
+ maxId: this.maxId,
421
+ stats: this.stats,
422
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
423
+ };
424
+ const tmpPath = `${this.statusFilePath}.tmp`;
425
+ await fs2.promises.writeFile(tmpPath, JSON.stringify(status, null, 2), "utf8");
426
+ await fs2.promises.rename(tmpPath, this.statusFilePath);
427
+ } catch (error) {
428
+ logger2.error(`Status save failed: ${error.message}`);
429
+ throw error;
430
+ }
431
+ }
432
+ };
433
+
434
+ // src/utils/HashStorage.ts
435
+ var import_koishi3 = require("koishi");
436
+ var fs3 = __toESM(require("fs"));
437
+ var path3 = __toESM(require("path"));
438
+
439
+ // src/utils/ImageHasher.ts
440
+ var import_sharp = __toESM(require("sharp"));
441
+ var ImageHasher = class {
442
+ static {
443
+ __name(this, "ImageHasher");
444
+ }
445
+ /**
446
+ * 计算图片的感知哈希值
447
+ * @param imageBuffer - 图片的二进制数据
448
+ * @returns 返回64位的十六进制哈希字符串
449
+ * @throws 当图片处理失败时可能抛出错误
450
+ */
451
+ static async calculateHash(imageBuffer) {
452
+ const { data } = await (0, import_sharp.default)(imageBuffer).grayscale().resize(32, 32, { fit: "fill" }).raw().toBuffer({ resolveWithObject: true });
453
+ const dctMatrix = this.computeDCT(data, 32);
454
+ const features = this.extractFeatures(dctMatrix, 32);
455
+ const median = this.calculateMedian(features);
456
+ const binaryHash = features.map((val) => val > median ? "1" : "0").join("");
457
+ return this.binaryToHex(binaryHash);
458
+ }
459
+ /**
460
+ * 将二进制字符串转换为十六进制
461
+ * @param binary - 二进制字符串
462
+ * @returns 十六进制字符串
463
+ * @private
464
+ */
465
+ static binaryToHex(binary) {
466
+ const hex = [];
467
+ for (let i = 0; i < binary.length; i += 4) {
468
+ const chunk = binary.slice(i, i + 4);
469
+ hex.push(parseInt(chunk, 2).toString(16));
470
+ }
471
+ return hex.join("");
472
+ }
473
+ /**
474
+ * 将十六进制字符串转换为二进制
475
+ * @param hex - 十六进制字符串
476
+ * @returns 二进制字符串
477
+ * @private
478
+ */
479
+ static hexToBinary(hex) {
480
+ let binary = "";
481
+ for (const char of hex) {
482
+ const bin = parseInt(char, 16).toString(2).padStart(4, "0");
483
+ binary += bin;
484
+ }
485
+ return binary;
486
+ }
487
+ /**
488
+ * 计算图像的DCT(离散余弦变换)
489
+ * @param data - 图像数据
490
+ * @param size - 图像尺寸
491
+ * @returns DCT变换后的矩阵
492
+ * @private
493
+ */
494
+ static computeDCT(data, size) {
495
+ const matrix = Array(size).fill(0).map(() => Array(size).fill(0));
496
+ const output = Array(size).fill(0).map(() => Array(size).fill(0));
497
+ for (let i = 0; i < size; i++) {
498
+ for (let j = 0; j < size; j++) {
499
+ matrix[i][j] = data[i * size + j];
500
+ }
501
+ }
502
+ for (let u = 0; u < size; u++) {
503
+ for (let v = 0; v < size; v++) {
504
+ let sum = 0;
505
+ for (let x = 0; x < size; x++) {
506
+ for (let y = 0; y < size; y++) {
507
+ const cx = Math.cos((2 * x + 1) * u * Math.PI / (2 * size));
508
+ const cy = Math.cos((2 * y + 1) * v * Math.PI / (2 * size));
509
+ sum += matrix[x][y] * cx * cy;
510
+ }
511
+ }
512
+ output[u][v] = sum * this.getDCTCoefficient(u, size) * this.getDCTCoefficient(v, size);
513
+ }
514
+ }
515
+ return output;
516
+ }
517
+ /**
518
+ * 获取DCT系数
519
+ * @param index - 索引值
520
+ * @param size - 矩阵大小
521
+ * @returns DCT系数
522
+ * @private
523
+ */
524
+ static getDCTCoefficient(index, size) {
525
+ return index === 0 ? Math.sqrt(1 / size) : Math.sqrt(2 / size);
526
+ }
527
+ /**
528
+ * 计算数组的中位数
529
+ * @param arr - 输入数组
530
+ * @returns 中位数
531
+ * @private
532
+ */
533
+ static calculateMedian(arr) {
534
+ const sorted = [...arr].sort((a, b) => a - b);
535
+ const mid = Math.floor(sorted.length / 2);
536
+ return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
537
+ }
538
+ /**
539
+ * 从DCT矩阵中提取特征值
540
+ * @param matrix - DCT矩阵
541
+ * @param size - 矩阵大小
542
+ * @returns 特征值数组
543
+ * @private
544
+ */
545
+ static extractFeatures(matrix, size) {
546
+ const features = [];
547
+ const featureSize = 8;
548
+ for (let i = 0; i < featureSize; i++) {
549
+ for (let j = 0; j < featureSize; j++) {
550
+ features.push(matrix[i][j]);
551
+ }
552
+ }
553
+ return features;
554
+ }
555
+ /**
556
+ * 计算两个哈希值之间的汉明距离
557
+ * @param hash1 - 第一个哈希值
558
+ * @param hash2 - 第二个哈希值
559
+ * @returns 汉明距离
560
+ * @throws 当两个哈希值长度不等时抛出错误
561
+ */
562
+ static calculateDistance(hash1, hash2) {
563
+ if (hash1.length !== hash2.length) {
564
+ throw new Error("Hash lengths must be equal");
565
+ }
566
+ const bin1 = this.hexToBinary(hash1);
567
+ const bin2 = this.hexToBinary(hash2);
568
+ let distance = 0;
569
+ for (let i = 0; i < bin1.length; i++) {
570
+ if (bin1[i] !== bin2[i]) distance++;
571
+ }
572
+ return distance;
573
+ }
574
+ /**
575
+ * 计算两个图片哈希值的相似度
576
+ * @param hash1 - 第一个哈希值
577
+ * @param hash2 - 第二个哈希值
578
+ * @returns 返回0-1之间的相似度值,1表示完全相同,0表示完全不同
579
+ */
580
+ static calculateSimilarity(hash1, hash2) {
581
+ const distance = this.calculateDistance(hash1, hash2);
582
+ return (64 - distance) / 64;
583
+ }
584
+ /**
585
+ * 批量比较一个新哈希值与多个已存在哈希值的相似度
586
+ * @param newHash - 新的哈希值
587
+ * @param existingHashes - 已存在的哈希值数组
588
+ * @returns 相似度数组,每个元素对应一个已存在哈希值的相似度
589
+ */
590
+ static batchCompareSimilarity(newHash, existingHashes) {
591
+ return existingHashes.map((hash) => this.calculateSimilarity(newHash, hash));
592
+ }
593
+ };
594
+
595
+ // src/utils/HashStorage.ts
596
+ var import_util = require("util");
597
+ var logger3 = new import_koishi3.Logger("HashStorage");
598
+ var readFileAsync = (0, import_util.promisify)(fs3.readFile);
599
+ var HashStorage = class _HashStorage {
600
+ /**
601
+ * 初始化HashStorage实例
602
+ * @param caveDir 回声洞数据目录路径
603
+ */
604
+ constructor(caveDir) {
605
+ this.caveDir = caveDir;
606
+ }
607
+ static {
608
+ __name(this, "HashStorage");
609
+ }
610
+ // 哈希数据文件名
611
+ static HASH_FILE = "hash.json";
612
+ // 回声洞数据文件名
613
+ static CAVE_FILE = "cave.json";
614
+ // 批处理大小
615
+ static BATCH_SIZE = 50;
616
+ // 存储回声洞ID到图片哈希值的映射
617
+ hashes = /* @__PURE__ */ new Map();
618
+ // 初始化状态标志
619
+ initialized = false;
620
+ get filePath() {
621
+ return path3.join(this.caveDir, _HashStorage.HASH_FILE);
622
+ }
623
+ get resourceDir() {
624
+ return path3.join(this.caveDir, "resources");
625
+ }
626
+ get caveFilePath() {
627
+ return path3.join(this.caveDir, _HashStorage.CAVE_FILE);
628
+ }
629
+ /**
630
+ * 初始化哈希存储
631
+ * 读取现有哈希数据或重新构建哈希值
632
+ * @throws 初始化失败时抛出错误
633
+ */
634
+ async initialize() {
635
+ if (this.initialized) return;
636
+ try {
637
+ const hashData = await FileHandler.readJsonData(this.filePath).then((data) => data[0]).catch(() => null);
638
+ if (!hashData?.hashes || Object.keys(hashData.hashes).length === 0) {
639
+ this.hashes.clear();
640
+ await this.buildInitialHashes();
641
+ } else {
642
+ this.hashes = new Map(
643
+ Object.entries(hashData.hashes).map(([k, v]) => [Number(k), v])
644
+ );
645
+ await this.updateMissingHashes();
646
+ }
647
+ this.initialized = true;
648
+ } catch (error) {
649
+ logger3.error(`Initialization failed: ${error.message}`);
650
+ this.initialized = false;
651
+ throw error;
652
+ }
653
+ }
654
+ /**
655
+ * 获取当前哈希存储状态
656
+ * @returns 包含最后更新时间和所有条目的状态对象
657
+ */
658
+ async getStatus() {
659
+ if (!this.initialized) await this.initialize();
660
+ return {
661
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
662
+ entries: Array.from(this.hashes.entries()).map(([caveId, hashes]) => ({
663
+ caveId,
664
+ hashes
665
+ }))
666
+ };
667
+ }
668
+ /**
669
+ * 更新指定回声洞的图片哈希值
670
+ * @param caveId 回声洞ID
671
+ * @param imgBuffers 图片buffer数组
672
+ */
673
+ async updateCaveHash(caveId, imgBuffers) {
674
+ if (!this.initialized) await this.initialize();
675
+ try {
676
+ if (imgBuffers?.length) {
677
+ const hashes = await Promise.all(
678
+ imgBuffers.map((buffer) => ImageHasher.calculateHash(buffer))
679
+ );
680
+ this.hashes.set(caveId, hashes);
681
+ } else {
682
+ this.hashes.delete(caveId);
683
+ }
684
+ await this.saveHashes();
685
+ } catch (error) {
686
+ logger3.error(`Failed to update hash (cave ${caveId}): ${error.message}`);
687
+ }
688
+ }
689
+ /**
690
+ * 更新所有回声洞的哈希值
691
+ * @param isInitialBuild 是否为初始构建
692
+ */
693
+ async updateAllCaves(isInitialBuild = false) {
694
+ if (!this.initialized && !isInitialBuild) {
695
+ await this.initialize();
696
+ return;
697
+ }
698
+ try {
699
+ logger3.info("Starting full hash update...");
700
+ const caveData = await this.loadCaveData();
701
+ const cavesWithImages = caveData.filter(
702
+ (cave) => cave.elements?.some((el) => el.type === "img" && el.file)
703
+ );
704
+ this.hashes.clear();
705
+ let processedCount = 0;
706
+ const totalImages = cavesWithImages.length;
707
+ const processCave = /* @__PURE__ */ __name(async (cave) => {
708
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
709
+ if (imgElements.length === 0) return;
710
+ try {
711
+ const hashes = await Promise.all(
712
+ imgElements.map(async (imgElement) => {
713
+ const filePath = path3.join(this.resourceDir, imgElement.file);
714
+ if (!fs3.existsSync(filePath)) {
715
+ logger3.warn(`Image file not found: ${filePath}`);
716
+ return null;
717
+ }
718
+ const imgBuffer = await readFileAsync(filePath);
719
+ return await ImageHasher.calculateHash(imgBuffer);
720
+ })
721
+ );
722
+ const validHashes = hashes.filter((hash) => hash !== null);
723
+ if (validHashes.length > 0) {
724
+ this.hashes.set(cave.cave_id, validHashes);
725
+ processedCount++;
726
+ if (processedCount % 100 === 0) {
727
+ logger3.info(`Progress: ${processedCount}/${totalImages}`);
728
+ }
729
+ }
730
+ } catch (error) {
731
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
732
+ }
733
+ }, "processCave");
734
+ await this.processBatch(cavesWithImages, processCave);
735
+ await this.saveHashes();
736
+ logger3.success(`Update completed. Processed ${processedCount}/${totalImages} images`);
737
+ } catch (error) {
738
+ logger3.error(`Full update failed: ${error.message}`);
739
+ throw error;
740
+ }
741
+ }
742
+ /**
743
+ * 查找重复的图片
744
+ * @param imgBuffers 待查找的图片buffer数组
745
+ * @param threshold 相似度阈值
746
+ * @returns 匹配结果数组,包含索引、回声洞ID和相似度
747
+ */
748
+ async findDuplicates(imgBuffers, threshold) {
749
+ if (!this.initialized) await this.initialize();
750
+ const inputHashes = await Promise.all(
751
+ imgBuffers.map((buffer) => ImageHasher.calculateHash(buffer))
752
+ );
753
+ const existingHashes = Array.from(this.hashes.entries());
754
+ return Promise.all(
755
+ inputHashes.map(async (hash, index) => {
756
+ try {
757
+ let maxSimilarity = 0;
758
+ let matchedCaveId = null;
759
+ for (const [caveId, hashes] of existingHashes) {
760
+ for (const existingHash of hashes) {
761
+ const similarity = ImageHasher.calculateSimilarity(hash, existingHash);
762
+ if (similarity >= threshold && similarity > maxSimilarity) {
763
+ maxSimilarity = similarity;
764
+ matchedCaveId = caveId;
765
+ if (Math.abs(similarity - 1) < Number.EPSILON) break;
766
+ }
767
+ }
768
+ if (Math.abs(maxSimilarity - 1) < Number.EPSILON) break;
769
+ }
770
+ return matchedCaveId ? {
771
+ index,
772
+ caveId: matchedCaveId,
773
+ similarity: maxSimilarity
774
+ } : null;
775
+ } catch (error) {
776
+ logger3.warn(`处理图片 ${index} 失败: ${error.message}`);
777
+ return null;
778
+ }
779
+ })
780
+ );
781
+ }
782
+ /**
783
+ * 加载回声洞数据
784
+ * @returns 回声洞数据数组
785
+ * @private
786
+ */
787
+ async loadCaveData() {
788
+ const data = await FileHandler.readJsonData(this.caveFilePath);
789
+ return Array.isArray(data) ? data.flat() : [];
790
+ }
791
+ /**
792
+ * 保存哈希数据到文件
793
+ * @private
794
+ */
795
+ async saveHashes() {
796
+ const data = {
797
+ hashes: Object.fromEntries(this.hashes),
798
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
799
+ };
800
+ await FileHandler.writeJsonData(this.filePath, [data]);
801
+ }
802
+ /**
803
+ * 构建初始哈希数据
804
+ * @private
805
+ */
806
+ async buildInitialHashes() {
807
+ const caveData = await this.loadCaveData();
808
+ let processedImageCount = 0;
809
+ const totalImages = caveData.reduce((sum, cave) => sum + (cave.elements?.filter((el) => el.type === "img" && el.file).length || 0), 0);
810
+ logger3.info(`Building hash data for ${totalImages} images...`);
811
+ for (const cave of caveData) {
812
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
813
+ if (imgElements.length === 0) continue;
814
+ try {
815
+ const hashes = await Promise.all(
816
+ imgElements.map(async (imgElement) => {
817
+ const filePath = path3.join(this.resourceDir, imgElement.file);
818
+ if (!fs3.existsSync(filePath)) {
819
+ logger3.warn(`Image not found: ${filePath}`);
820
+ return null;
821
+ }
822
+ const imgBuffer = await fs3.promises.readFile(filePath);
823
+ const hash = await ImageHasher.calculateHash(imgBuffer);
824
+ processedImageCount++;
825
+ if (processedImageCount % 100 === 0) {
826
+ logger3.info(`Progress: ${processedImageCount}/${totalImages} images`);
827
+ }
828
+ return hash;
829
+ })
830
+ );
831
+ const validHashes = hashes.filter((hash) => hash !== null);
832
+ if (validHashes.length > 0) {
833
+ this.hashes.set(cave.cave_id, validHashes);
834
+ }
835
+ } catch (error) {
836
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
837
+ }
838
+ }
839
+ await this.saveHashes();
840
+ logger3.success(`Build completed. Processed ${processedImageCount}/${totalImages} images`);
841
+ }
842
+ /**
843
+ * 更新缺失的哈希值
844
+ * @private
845
+ */
846
+ async updateMissingHashes() {
847
+ const caveData = await this.loadCaveData();
848
+ let updatedCount = 0;
849
+ for (const cave of caveData) {
850
+ if (this.hashes.has(cave.cave_id)) continue;
851
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
852
+ if (imgElements.length === 0) continue;
853
+ try {
854
+ const hashes = await Promise.all(
855
+ imgElements.map(async (imgElement) => {
856
+ const filePath = path3.join(this.resourceDir, imgElement.file);
857
+ if (!fs3.existsSync(filePath)) {
858
+ return null;
859
+ }
860
+ const imgBuffer = await fs3.promises.readFile(filePath);
861
+ return ImageHasher.calculateHash(imgBuffer);
862
+ })
863
+ );
864
+ const validHashes = hashes.filter((hash) => hash !== null);
865
+ if (validHashes.length > 0) {
866
+ this.hashes.set(cave.cave_id, validHashes);
867
+ updatedCount++;
868
+ }
869
+ } catch (error) {
870
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
871
+ }
872
+ }
873
+ if (updatedCount > 0) {
874
+ await this.saveHashes();
875
+ logger3.info(`Updated ${updatedCount} new hashes`);
876
+ }
877
+ }
878
+ /**
879
+ * 批量处理数组项
880
+ * @param items 待处理项数组
881
+ * @param processor 处理函数
882
+ * @param batchSize 批处理大小
883
+ * @private
884
+ */
885
+ async processBatch(items, processor, batchSize = _HashStorage.BATCH_SIZE) {
886
+ for (let i = 0; i < items.length; i += batchSize) {
887
+ const batch = items.slice(i, i + batchSize);
888
+ await Promise.all(
889
+ batch.map(async (item) => {
890
+ try {
891
+ await processor(item);
892
+ } catch (error) {
893
+ logger3.error(`Batch processing error: ${error.message}`);
894
+ }
895
+ })
896
+ );
897
+ }
898
+ }
899
+ };
900
+
901
+ // src/index.ts
59
902
  var name = "best-cave";
60
903
  var inject = ["database"];
61
- var Config = import_koishi.Schema.object({
62
- manager: import_koishi.Schema.array(import_koishi.Schema.string()).required(),
63
- blacklist: import_koishi.Schema.array(import_koishi.Schema.string()).default([]),
64
- whitelist: import_koishi.Schema.array(import_koishi.Schema.string()).default([]),
65
- number: import_koishi.Schema.number().default(60),
66
- enableAudit: import_koishi.Schema.boolean().default(false),
67
- allowVideo: import_koishi.Schema.boolean().default(true),
68
- videoMaxSize: import_koishi.Schema.number().default(16),
69
- imageMaxSize: import_koishi.Schema.number().default(4),
70
- enablePagination: import_koishi.Schema.boolean().default(false),
71
- itemsPerPage: import_koishi.Schema.number().default(10)
904
+ var Config = import_koishi4.Schema.object({
905
+ manager: import_koishi4.Schema.array(import_koishi4.Schema.string()).required(),
906
+ // 管理员用户ID
907
+ number: import_koishi4.Schema.number().default(60),
908
+ // 冷却时间()
909
+ enableAudit: import_koishi4.Schema.boolean().default(false),
910
+ // 启用审核
911
+ imageMaxSize: import_koishi4.Schema.number().default(4),
912
+ // 图片大小限制(MB)
913
+ enableDuplicate: import_koishi4.Schema.boolean().default(true),
914
+ // 开启查重
915
+ duplicateThreshold: import_koishi4.Schema.number().default(0.8),
916
+ // 查重阈值(0-1)
917
+ allowVideo: import_koishi4.Schema.boolean().default(true),
918
+ // 允许视频
919
+ videoMaxSize: import_koishi4.Schema.number().default(16),
920
+ // 视频大小限制(MB)
921
+ enablePagination: import_koishi4.Schema.boolean().default(false),
922
+ // 启用分页
923
+ itemsPerPage: import_koishi4.Schema.number().default(10),
924
+ // 每页条数
925
+ blacklist: import_koishi4.Schema.array(import_koishi4.Schema.string()).default([]),
926
+ // 黑名单
927
+ whitelist: import_koishi4.Schema.array(import_koishi4.Schema.string()).default([])
928
+ // 白名单
72
929
  }).i18n({
73
930
  "zh-CN": require_zh_CN()._config,
74
931
  "en-US": require_en_US()._config
@@ -76,18 +933,20 @@ var Config = import_koishi.Schema.object({
76
933
  async function apply(ctx, config) {
77
934
  ctx.i18n.define("zh-CN", require_zh_CN());
78
935
  ctx.i18n.define("en-US", require_en_US());
79
- const dataDir = path.join(ctx.baseDir, "data");
80
- const caveDir = path.join(dataDir, "cave");
81
- const caveFilePath = path.join(caveDir, "cave.json");
82
- const resourceDir = path.join(caveDir, "resources");
83
- const pendingFilePath = path.join(caveDir, "pending.json");
936
+ const dataDir = path4.join(ctx.baseDir, "data");
937
+ const caveDir = path4.join(dataDir, "cave");
84
938
  await FileHandler.ensureDirectory(dataDir);
85
939
  await FileHandler.ensureDirectory(caveDir);
86
- await FileHandler.ensureDirectory(resourceDir);
87
- await FileHandler.ensureJsonFile(caveFilePath);
88
- await FileHandler.ensureJsonFile(pendingFilePath);
940
+ await FileHandler.ensureDirectory(path4.join(caveDir, "resources"));
941
+ await FileHandler.ensureJsonFile(path4.join(caveDir, "cave.json"));
942
+ await FileHandler.ensureJsonFile(path4.join(caveDir, "pending.json"));
943
+ await FileHandler.ensureJsonFile(path4.join(caveDir, "hash.json"));
89
944
  const idManager = new IdManager(ctx.baseDir);
90
- await idManager.initialize(caveFilePath, pendingFilePath);
945
+ const hashStorage = new HashStorage(caveDir);
946
+ await Promise.all([
947
+ idManager.initialize(path4.join(caveDir, "cave.json"), path4.join(caveDir, "pending.json")),
948
+ hashStorage.initialize()
949
+ ]);
91
950
  const lastUsed = /* @__PURE__ */ new Map();
92
951
  async function processList(session, config2, userId, pageNum = 1) {
93
952
  const stats = idManager.getStats();
@@ -111,53 +970,47 @@ async function apply(ctx, config) {
111
970
  }
112
971
  }
113
972
  __name(processList, "processList");
114
- async function processAudit(ctx2, pendingFilePath2, caveFilePath2, resourceDir2, session, options, content) {
115
- const pendingData = await FileHandler.readJsonData(pendingFilePath2);
973
+ async function processAudit(pendingFilePath, caveFilePath, resourceDir, session, options, content) {
974
+ const pendingData = await FileHandler.readJsonData(pendingFilePath);
116
975
  const isApprove = Boolean(options.p);
117
976
  if (options.p === true && content[0] === "all" || options.d === true && content[0] === "all") {
118
- return await handleAudit(ctx2, pendingData, isApprove, caveFilePath2, resourceDir2, pendingFilePath2, session);
977
+ return await handleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session);
119
978
  }
120
979
  const id = parseInt(content[0] || (typeof options.p === "string" ? options.p : "") || (typeof options.d === "string" ? options.d : ""));
121
980
  if (isNaN(id)) {
122
981
  return sendMessage(session, "commands.cave.error.invalidId", [], true);
123
982
  }
124
- return sendMessage(session, await handleAudit(ctx2, pendingData, isApprove, caveFilePath2, resourceDir2, pendingFilePath2, session, id), [], true);
983
+ return sendMessage(session, await handleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session, id), [], true);
125
984
  }
126
985
  __name(processAudit, "processAudit");
127
- async function processView(caveFilePath2, resourceDir2, session, options, content, config2) {
128
- if (!await checkCooldown(session, config2)) {
129
- return "";
130
- }
986
+ async function processView(caveFilePath, resourceDir, session, options, content) {
131
987
  const caveId = parseInt(content[0] || (typeof options.g === "string" ? options.g : ""));
132
988
  if (isNaN(caveId)) return sendMessage(session, "commands.cave.error.invalidId", [], true);
133
- const data = await FileHandler.readJsonData(caveFilePath2);
989
+ const data = await FileHandler.readJsonData(caveFilePath);
134
990
  const cave = data.find((item) => item.cave_id === caveId);
135
991
  if (!cave) return sendMessage(session, "commands.cave.error.notFound", [], true);
136
- return buildMessage(cave, resourceDir2, session);
992
+ return buildMessage(cave, resourceDir, session);
137
993
  }
138
994
  __name(processView, "processView");
139
- async function processRandom(caveFilePath2, resourceDir2, session, config2) {
140
- const data = await FileHandler.readJsonData(caveFilePath2);
995
+ async function processRandom(caveFilePath, resourceDir, session) {
996
+ const data = await FileHandler.readJsonData(caveFilePath);
141
997
  if (data.length === 0) {
142
998
  return sendMessage(session, "commands.cave.error.noCave", [], true);
143
999
  }
144
- if (!await checkCooldown(session, config2)) {
145
- return "";
146
- }
147
1000
  const cave = (() => {
148
1001
  const validCaves = data.filter((cave2) => cave2.elements && cave2.elements.length > 0);
149
1002
  if (!validCaves.length) return void 0;
150
1003
  const randomIndex = Math.floor(Math.random() * validCaves.length);
151
1004
  return validCaves[randomIndex];
152
1005
  })();
153
- return cave ? buildMessage(cave, resourceDir2, session) : sendMessage(session, "commands.cave.error.getCave", [], true);
1006
+ return cave ? buildMessage(cave, resourceDir, session) : sendMessage(session, "commands.cave.error.getCave", [], true);
154
1007
  }
155
1008
  __name(processRandom, "processRandom");
156
- async function processDelete(caveFilePath2, resourceDir2, pendingFilePath2, session, config2, options, content) {
1009
+ async function processDelete(caveFilePath, resourceDir, pendingFilePath, session, config2, options, content) {
157
1010
  const caveId = parseInt(content[0] || (typeof options.r === "string" ? options.r : ""));
158
1011
  if (isNaN(caveId)) return sendMessage(session, "commands.cave.error.invalidId", [], true);
159
- const data = await FileHandler.readJsonData(caveFilePath2);
160
- const pendingData = await FileHandler.readJsonData(pendingFilePath2);
1012
+ const data = await FileHandler.readJsonData(caveFilePath);
1013
+ const pendingData = await FileHandler.readJsonData(pendingFilePath);
161
1014
  const targetInData = data.find((item) => item.cave_id === caveId);
162
1015
  const targetInPending = pendingData.find((item) => item.cave_id === caveId);
163
1016
  if (!targetInData && !targetInPending) {
@@ -168,23 +1021,26 @@ async function apply(ctx, config) {
168
1021
  if (targetCave.contributor_number !== session.userId && !config2.manager.includes(session.userId)) {
169
1022
  return sendMessage(session, "commands.cave.remove.noPermission", [], true);
170
1023
  }
171
- const caveContent = await buildMessage(targetCave, resourceDir2, session);
1024
+ const caveContent = await buildMessage(targetCave, resourceDir, session);
172
1025
  if (targetCave.elements) {
1026
+ const hashStorage2 = new HashStorage(caveDir);
1027
+ await hashStorage2.initialize();
1028
+ await hashStorage2.updateCaveHash(caveId);
173
1029
  for (const element of targetCave.elements) {
174
1030
  if ((element.type === "img" || element.type === "video") && element.file) {
175
- const fullPath = path.join(resourceDir2, element.file);
176
- if (fs.existsSync(fullPath)) {
177
- await fs.promises.unlink(fullPath);
1031
+ const fullPath = path4.join(resourceDir, element.file);
1032
+ if (fs4.existsSync(fullPath)) {
1033
+ await fs4.promises.unlink(fullPath);
178
1034
  }
179
1035
  }
180
1036
  }
181
1037
  }
182
1038
  if (isPending) {
183
1039
  const newPendingData = pendingData.filter((item) => item.cave_id !== caveId);
184
- await FileHandler.writeJsonData(pendingFilePath2, newPendingData);
1040
+ await FileHandler.writeJsonData(pendingFilePath, newPendingData);
185
1041
  } else {
186
1042
  const newData = data.filter((item) => item.cave_id !== caveId);
187
- await FileHandler.writeJsonData(caveFilePath2, newData);
1043
+ await FileHandler.writeJsonData(caveFilePath, newData);
188
1044
  await idManager.removeStat(targetCave.contributor_number, caveId);
189
1045
  }
190
1046
  await idManager.markDeleted(caveId);
@@ -193,7 +1049,7 @@ async function apply(ctx, config) {
193
1049
  return `${deleteMessage}${deleteStatus}${caveContent}`;
194
1050
  }
195
1051
  __name(processDelete, "processDelete");
196
- async function processAdd(ctx2, config2, caveFilePath2, resourceDir2, pendingFilePath2, session, content) {
1052
+ async function processAdd(ctx2, config2, caveFilePath, resourceDir, pendingFilePath, session, content) {
197
1053
  try {
198
1054
  const inputContent = content.length > 0 ? content.join("\n") : await (async () => {
199
1055
  await sendMessage(session, "commands.cave.add.noContent", [], true);
@@ -214,18 +1070,20 @@ async function apply(ctx, config) {
214
1070
  imageUrls.length > 0 ? saveMedia(
215
1071
  imageUrls,
216
1072
  imageElements.map((el) => el.fileName),
217
- resourceDir2,
1073
+ resourceDir,
218
1074
  caveId,
219
1075
  "img",
1076
+ config2,
220
1077
  ctx2,
221
1078
  session
222
1079
  ) : [],
223
1080
  videoUrls.length > 0 ? saveMedia(
224
1081
  videoUrls,
225
1082
  videoElements.map((el) => el.fileName),
226
- resourceDir2,
1083
+ resourceDir,
227
1084
  caveId,
228
1085
  "video",
1086
+ config2,
229
1087
  ctx2,
230
1088
  session
231
1089
  ) : []
@@ -240,7 +1098,7 @@ async function apply(ctx, config) {
240
1098
  // 保持原始文本和图片的相对位置
241
1099
  index: el.index
242
1100
  }))
243
- ].sort((a, b) => a.index - b.index),
1101
+ ].sort((a, b) => a.index - a.index),
244
1102
  contributor_number: session.userId,
245
1103
  contributor_name: session.username
246
1104
  };
@@ -249,33 +1107,46 @@ async function apply(ctx, config) {
249
1107
  type: "video",
250
1108
  file: savedVideos[0],
251
1109
  index: Number.MAX_SAFE_INTEGER
252
- // 确保视频总是在最后
253
1110
  });
254
1111
  }
1112
+ const hashStorage2 = new HashStorage(path4.join(ctx2.baseDir, "data", "cave"));
1113
+ await hashStorage2.initialize();
1114
+ const hashStatus = await hashStorage2.getStatus();
1115
+ if (!hashStatus.lastUpdated || hashStatus.entries.length === 0) {
1116
+ const existingData = await FileHandler.readJsonData(caveFilePath);
1117
+ const hasImages = existingData.some(
1118
+ (cave) => cave.elements?.some((element) => element.type === "img" && element.file)
1119
+ );
1120
+ if (hasImages) {
1121
+ await hashStorage2.updateAllCaves(true);
1122
+ }
1123
+ }
255
1124
  if (config2.enableAudit && !bypassAudit) {
256
- const pendingData = await FileHandler.readJsonData(pendingFilePath2);
1125
+ const pendingData = await FileHandler.readJsonData(pendingFilePath);
257
1126
  pendingData.push(newCave);
258
1127
  await Promise.all([
259
- FileHandler.writeJsonData(pendingFilePath2, pendingData),
260
- sendAuditMessage(ctx2, config2, newCave, await buildMessage(newCave, resourceDir2, session), session)
1128
+ FileHandler.writeJsonData(pendingFilePath, pendingData),
1129
+ sendAuditMessage(ctx2, config2, newCave, await buildMessage(newCave, resourceDir, session), session)
261
1130
  ]);
262
1131
  return sendMessage(session, "commands.cave.add.submitPending", [caveId], false);
263
1132
  }
264
- const data = await FileHandler.readJsonData(caveFilePath2);
1133
+ const data = await FileHandler.readJsonData(caveFilePath);
265
1134
  data.push({
266
1135
  ...newCave,
267
1136
  elements: cleanElementsForSave(newCave.elements, false)
268
1137
  });
269
- await FileHandler.writeJsonData(caveFilePath2, data);
1138
+ await Promise.all([
1139
+ FileHandler.writeJsonData(caveFilePath, data),
1140
+ hashStorage2.updateCaveHash(caveId)
1141
+ ]);
270
1142
  await idManager.addStat(session.userId, caveId);
271
1143
  return sendMessage(session, "commands.cave.add.addSuccess", [caveId], false);
272
1144
  } catch (error) {
273
- logger.error(`Failed to process add command: ${error.message}`);
274
- return sendMessage(session, `commands.cave.error.${error.code || "unknown"}`, [], true);
1145
+ logger4.error(`Failed to process add command: ${error.message}`);
275
1146
  }
276
1147
  }
277
1148
  __name(processAdd, "processAdd");
278
- async function handleAudit(ctx2, pendingData, isApprove, caveFilePath2, resourceDir2, pendingFilePath2, session, targetId) {
1149
+ async function handleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session, targetId) {
279
1150
  if (pendingData.length === 0) {
280
1151
  return sendMessage(session, "commands.cave.audit.noPending", [], true);
281
1152
  }
@@ -286,36 +1157,34 @@ async function apply(ctx, config) {
286
1157
  }
287
1158
  const newPendingData = pendingData.filter((item) => item.cave_id !== targetId);
288
1159
  if (isApprove) {
289
- const oldCaveData = await FileHandler.readJsonData(caveFilePath2);
1160
+ const oldCaveData = await FileHandler.readJsonData(caveFilePath);
290
1161
  const newCaveData = [...oldCaveData, {
291
1162
  ...targetCave,
292
1163
  cave_id: targetId,
293
- // 明确指定ID
294
- // 保存到 cave.json 时移除 index
295
1164
  elements: cleanElementsForSave(targetCave.elements, false)
296
1165
  }];
297
1166
  await FileHandler.withTransaction([
298
1167
  {
299
- filePath: caveFilePath2,
300
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath2, newCaveData), "operation"),
301
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath2, oldCaveData), "rollback")
1168
+ filePath: caveFilePath,
1169
+ operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, newCaveData), "operation"),
1170
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldCaveData), "rollback")
302
1171
  },
303
1172
  {
304
- filePath: pendingFilePath2,
305
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, newPendingData), "operation"),
306
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, pendingData), "rollback")
1173
+ filePath: pendingFilePath,
1174
+ operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, newPendingData), "operation"),
1175
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback")
307
1176
  }
308
1177
  ]);
309
1178
  await idManager.addStat(targetCave.contributor_number, targetId);
310
1179
  } else {
311
- await FileHandler.writeJsonData(pendingFilePath2, newPendingData);
1180
+ await FileHandler.writeJsonData(pendingFilePath, newPendingData);
312
1181
  await idManager.markDeleted(targetId);
313
1182
  if (targetCave.elements) {
314
1183
  for (const element of targetCave.elements) {
315
1184
  if ((element.type === "img" || element.type === "video") && element.file) {
316
- const fullPath = path.join(resourceDir2, element.file);
317
- if (fs.existsSync(fullPath)) {
318
- await fs.promises.unlink(fullPath);
1185
+ const fullPath = path4.join(resourceDir, element.file);
1186
+ if (fs4.existsSync(fullPath)) {
1187
+ await fs4.promises.unlink(fullPath);
319
1188
  }
320
1189
  }
321
1190
  }
@@ -338,48 +1207,46 @@ async function apply(ctx, config) {
338
1207
  false
339
1208
  );
340
1209
  }
341
- const data = isApprove ? await FileHandler.readJsonData(caveFilePath2) : null;
1210
+ const data = isApprove ? await FileHandler.readJsonData(caveFilePath) : null;
342
1211
  let processedCount = 0;
343
1212
  if (isApprove && data) {
344
1213
  const oldData = [...data];
345
1214
  const newData = [...data];
346
1215
  await FileHandler.withTransaction([
347
1216
  {
348
- filePath: caveFilePath2,
1217
+ filePath: caveFilePath,
349
1218
  operation: /* @__PURE__ */ __name(async () => {
350
1219
  for (const cave of pendingData) {
351
1220
  newData.push({
352
1221
  ...cave,
353
1222
  cave_id: cave.cave_id,
354
- // 确保ID保持不变
355
- // 保存到 cave.json 时移除 index
356
1223
  elements: cleanElementsForSave(cave.elements, false)
357
1224
  });
358
1225
  processedCount++;
359
1226
  await idManager.addStat(cave.contributor_number, cave.cave_id);
360
1227
  }
361
- return FileHandler.writeJsonData(caveFilePath2, newData);
1228
+ return FileHandler.writeJsonData(caveFilePath, newData);
362
1229
  }, "operation"),
363
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath2, oldData), "rollback")
1230
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldData), "rollback")
364
1231
  },
365
1232
  {
366
- filePath: pendingFilePath2,
367
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, []), "operation"),
368
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, pendingData), "rollback")
1233
+ filePath: pendingFilePath,
1234
+ operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, []), "operation"),
1235
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback")
369
1236
  }
370
1237
  ]);
371
1238
  } else {
372
1239
  for (const cave of pendingData) {
373
1240
  await idManager.markDeleted(cave.cave_id);
374
1241
  }
375
- await FileHandler.writeJsonData(pendingFilePath2, []);
1242
+ await FileHandler.writeJsonData(pendingFilePath, []);
376
1243
  for (const cave of pendingData) {
377
1244
  if (cave.elements) {
378
1245
  for (const element of cave.elements) {
379
1246
  if ((element.type === "img" || element.type === "video") && element.file) {
380
- const fullPath = path.join(resourceDir2, element.file);
381
- if (fs.existsSync(fullPath)) {
382
- await fs.promises.unlink(fullPath);
1247
+ const fullPath = path4.join(resourceDir, element.file);
1248
+ if (fs4.existsSync(fullPath)) {
1249
+ await fs4.promises.unlink(fullPath);
383
1250
  }
384
1251
  }
385
1252
  }
@@ -394,20 +1261,6 @@ async function apply(ctx, config) {
394
1261
  ], false);
395
1262
  }
396
1263
  __name(handleAudit, "handleAudit");
397
- async function checkCooldown(session, config2) {
398
- const guildId = session.guildId;
399
- const now = Date.now();
400
- const lastTime = lastUsed.get(guildId) || 0;
401
- const isManager = config2.manager.includes(session.userId);
402
- if (!isManager && now - lastTime < config2.number * 1e3) {
403
- const waitTime = Math.ceil((config2.number * 1e3 - (now - lastTime)) / 1e3);
404
- await sendMessage(session, "commands.cave.message.cooldown", [waitTime], true);
405
- return false;
406
- }
407
- lastUsed.set(guildId, now);
408
- return true;
409
- }
410
- __name(checkCooldown, "checkCooldown");
411
1264
  ctx.command("cave [message]").option("a", "添加回声洞").option("g", "查看回声洞", { type: "string" }).option("r", "删除回声洞", { type: "string" }).option("p", "通过审核", { type: "string" }).option("d", "拒绝审核", { type: "string" }).option("l", "查询投稿统计", { type: "string" }).before(async ({ session, options }) => {
412
1265
  if (config.blacklist.includes(session.userId)) {
413
1266
  return sendMessage(session, "commands.cave.message.blacklisted", [], true);
@@ -416,11 +1269,23 @@ async function apply(ctx, config) {
416
1269
  return sendMessage(session, "commands.cave.message.managerOnly", [], true);
417
1270
  }
418
1271
  }).action(async ({ session, options }, ...content) => {
419
- const dataDir2 = path.join(ctx.baseDir, "data");
420
- const caveDir2 = path.join(dataDir2, "cave");
421
- const caveFilePath2 = path.join(caveDir2, "cave.json");
422
- const resourceDir2 = path.join(caveDir2, "resources");
423
- const pendingFilePath2 = path.join(caveDir2, "pending.json");
1272
+ const dataDir2 = path4.join(ctx.baseDir, "data");
1273
+ const caveDir2 = path4.join(dataDir2, "cave");
1274
+ const caveFilePath = path4.join(caveDir2, "cave.json");
1275
+ const resourceDir = path4.join(caveDir2, "resources");
1276
+ const pendingFilePath = path4.join(caveDir2, "pending.json");
1277
+ const needsCooldown = !options.l && !options.a && !options.p && !options.d;
1278
+ if (needsCooldown) {
1279
+ const guildId = session.guildId;
1280
+ const now = Date.now();
1281
+ const lastTime = lastUsed.get(guildId) || 0;
1282
+ const isManager = config.manager.includes(session.userId);
1283
+ if (!isManager && now - lastTime < config.number * 1e3) {
1284
+ const waitTime = Math.ceil((config.number * 1e3 - (now - lastTime)) / 1e3);
1285
+ return sendMessage(session, "commands.cave.message.cooldown", [waitTime], true);
1286
+ }
1287
+ lastUsed.set(guildId, now);
1288
+ }
424
1289
  if (options.l !== void 0) {
425
1290
  const input = typeof options.l === "string" ? options.l : content[0];
426
1291
  const num = parseInt(input);
@@ -440,311 +1305,22 @@ async function apply(ctx, config) {
440
1305
  }
441
1306
  }
442
1307
  if (options.p || options.d) {
443
- return await processAudit(ctx, pendingFilePath2, caveFilePath2, resourceDir2, session, options, content);
1308
+ return await processAudit(pendingFilePath, caveFilePath, resourceDir, session, options, content);
444
1309
  }
445
1310
  if (options.g) {
446
- return await processView(caveFilePath2, resourceDir2, session, options, content, config);
1311
+ return await processView(caveFilePath, resourceDir, session, options, content);
447
1312
  }
448
1313
  if (options.r) {
449
- return await processDelete(caveFilePath2, resourceDir2, pendingFilePath2, session, config, options, content);
1314
+ return await processDelete(caveFilePath, resourceDir, pendingFilePath, session, config, options, content);
450
1315
  }
451
1316
  if (options.a) {
452
- return await processAdd(ctx, config, caveFilePath2, resourceDir2, pendingFilePath2, session, content);
1317
+ return await processAdd(ctx, config, caveFilePath, resourceDir, pendingFilePath, session, content);
453
1318
  }
454
- return await processRandom(caveFilePath2, resourceDir2, session, config);
1319
+ return await processRandom(caveFilePath, resourceDir, session);
455
1320
  });
456
1321
  }
457
1322
  __name(apply, "apply");
458
- var logger = new import_koishi.Logger("cave");
459
- var FileHandler = class {
460
- static {
461
- __name(this, "FileHandler");
462
- }
463
- static locks = /* @__PURE__ */ new Map();
464
- static RETRY_COUNT = 3;
465
- static RETRY_DELAY = 1e3;
466
- static CONCURRENCY_LIMIT = 5;
467
- /**
468
- * 并发控制
469
- */
470
- static async withConcurrencyLimit(operation, limit = this.CONCURRENCY_LIMIT) {
471
- while (this.locks.size >= limit) {
472
- await Promise.race(this.locks.values());
473
- }
474
- return operation();
475
- }
476
- /**
477
- * 统一的文件操作包装器
478
- */
479
- static async withFileOp(filePath, operation) {
480
- const key = filePath;
481
- while (this.locks.has(key)) {
482
- await this.locks.get(key);
483
- }
484
- const operationPromise = (async () => {
485
- for (let i = 0; i < this.RETRY_COUNT; i++) {
486
- try {
487
- return await operation();
488
- } catch (error) {
489
- if (i === this.RETRY_COUNT - 1) throw error;
490
- await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY));
491
- }
492
- }
493
- throw new Error("Operation failed after retries");
494
- })();
495
- this.locks.set(key, operationPromise);
496
- try {
497
- return await operationPromise;
498
- } finally {
499
- this.locks.delete(key);
500
- }
501
- }
502
- /**
503
- * 事务处理
504
- */
505
- static async withTransaction(operations) {
506
- const results = [];
507
- const completed = /* @__PURE__ */ new Set();
508
- try {
509
- for (const { filePath, operation } of operations) {
510
- const result = await this.withFileOp(filePath, operation);
511
- results.push(result);
512
- completed.add(filePath);
513
- }
514
- return results;
515
- } catch (error) {
516
- await Promise.all(
517
- operations.filter(({ filePath }) => completed.has(filePath)).map(async ({ filePath, rollback }) => {
518
- if (rollback) {
519
- await this.withFileOp(filePath, rollback).catch(
520
- (e) => logger.error(`Rollback failed for ${filePath}: ${e.message}`)
521
- );
522
- }
523
- })
524
- );
525
- throw error;
526
- }
527
- }
528
- /**
529
- * JSON文件读写
530
- */
531
- static async readJsonData(filePath) {
532
- return this.withFileOp(filePath, async () => {
533
- try {
534
- const data = await fs.promises.readFile(filePath, "utf8");
535
- return JSON.parse(data || "[]");
536
- } catch (error) {
537
- return [];
538
- }
539
- });
540
- }
541
- static async writeJsonData(filePath, data) {
542
- const tmpPath = `${filePath}.tmp`;
543
- await this.withFileOp(filePath, async () => {
544
- await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2));
545
- await fs.promises.rename(tmpPath, filePath);
546
- });
547
- }
548
- /**
549
- * 文件系统操作
550
- */
551
- static async ensureDirectory(dir) {
552
- await this.withConcurrencyLimit(async () => {
553
- if (!fs.existsSync(dir)) {
554
- await fs.promises.mkdir(dir, { recursive: true });
555
- }
556
- });
557
- }
558
- static async ensureJsonFile(filePath) {
559
- await this.withFileOp(filePath, async () => {
560
- if (!fs.existsSync(filePath)) {
561
- await fs.promises.writeFile(filePath, "[]", "utf8");
562
- }
563
- });
564
- }
565
- /**
566
- * 媒体文件操作
567
- */
568
- static async saveMediaFile(filePath, data) {
569
- await this.withConcurrencyLimit(async () => {
570
- const dir = path.dirname(filePath);
571
- await this.ensureDirectory(dir);
572
- await this.withFileOp(
573
- filePath,
574
- () => fs.promises.writeFile(filePath, data)
575
- );
576
- });
577
- }
578
- static async deleteMediaFile(filePath) {
579
- await this.withFileOp(filePath, async () => {
580
- if (fs.existsSync(filePath)) {
581
- await fs.promises.unlink(filePath);
582
- }
583
- });
584
- }
585
- };
586
- var IdManager = class {
587
- static {
588
- __name(this, "IdManager");
589
- }
590
- deletedIds = /* @__PURE__ */ new Set();
591
- maxId = 0;
592
- initialized = false;
593
- statusFilePath;
594
- stats = {};
595
- usedIds = /* @__PURE__ */ new Set();
596
- // 新增:跟踪所有使用中的ID
597
- constructor(baseDir) {
598
- const caveDir = path.join(baseDir, "data", "cave");
599
- this.statusFilePath = path.join(caveDir, "status.json");
600
- }
601
- async initialize(caveFilePath, pendingFilePath) {
602
- if (this.initialized) return;
603
- try {
604
- this.initialized = true;
605
- const status = fs.existsSync(this.statusFilePath) ? JSON.parse(await fs.promises.readFile(this.statusFilePath, "utf8")) : {
606
- deletedIds: [],
607
- maxId: 0,
608
- stats: {},
609
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
610
- };
611
- const [caveData, pendingData] = await Promise.all([
612
- FileHandler.readJsonData(caveFilePath),
613
- FileHandler.readJsonData(pendingFilePath)
614
- ]);
615
- this.usedIds.clear();
616
- const conflicts = /* @__PURE__ */ new Map();
617
- const collectIds = /* @__PURE__ */ __name((items) => {
618
- items.forEach((item) => {
619
- if (this.usedIds.has(item.cave_id)) {
620
- if (!conflicts.has(item.cave_id)) {
621
- conflicts.set(item.cave_id, []);
622
- }
623
- conflicts.get(item.cave_id)?.push(item);
624
- } else {
625
- this.usedIds.add(item.cave_id);
626
- }
627
- });
628
- }, "collectIds");
629
- collectIds(caveData);
630
- collectIds(pendingData);
631
- if (conflicts.size > 0) {
632
- logger.warn(`Found ${conflicts.size} ID conflicts, auto-fixing...`);
633
- for (const [conflictId, items] of conflicts) {
634
- items.forEach((item, index) => {
635
- if (index > 0) {
636
- let newId = this.maxId + 1;
637
- while (this.usedIds.has(newId)) {
638
- newId++;
639
- }
640
- logger.info(`Reassigning ID ${item.cave_id} -> ${newId} for item`);
641
- item.cave_id = newId;
642
- this.usedIds.add(newId);
643
- this.maxId = Math.max(this.maxId, newId);
644
- }
645
- });
646
- }
647
- await Promise.all([
648
- FileHandler.writeJsonData(caveFilePath, caveData),
649
- FileHandler.writeJsonData(pendingFilePath, pendingData)
650
- ]);
651
- }
652
- this.maxId = Math.max(
653
- this.maxId,
654
- status.maxId || 0,
655
- ...[...this.usedIds]
656
- );
657
- this.deletedIds = new Set(
658
- status.deletedIds?.filter((id) => !this.usedIds.has(id)) || []
659
- );
660
- this.stats = {};
661
- for (const cave of caveData) {
662
- if (cave.contributor_number === "10000") continue;
663
- if (!this.stats[cave.contributor_number]) {
664
- this.stats[cave.contributor_number] = [];
665
- }
666
- this.stats[cave.contributor_number].push(cave.cave_id);
667
- }
668
- await this.saveStatus();
669
- this.initialized = true;
670
- } catch (error) {
671
- this.initialized = false;
672
- logger.error(`IdManager initialization failed: ${error.message}`);
673
- throw error;
674
- }
675
- }
676
- getNextId() {
677
- if (!this.initialized) {
678
- throw new Error("IdManager not initialized");
679
- }
680
- let nextId;
681
- if (this.deletedIds.size === 0) {
682
- nextId = ++this.maxId;
683
- } else {
684
- nextId = Math.min(...Array.from(this.deletedIds));
685
- this.deletedIds.delete(nextId);
686
- }
687
- while (this.usedIds.has(nextId)) {
688
- nextId = ++this.maxId;
689
- }
690
- this.usedIds.add(nextId);
691
- this.saveStatus().catch(
692
- (err) => logger.error(`Failed to save status after getNextId: ${err.message}`)
693
- );
694
- return nextId;
695
- }
696
- async markDeleted(id) {
697
- if (!this.initialized) {
698
- throw new Error("IdManager not initialized");
699
- }
700
- this.deletedIds.add(id);
701
- this.usedIds.delete(id);
702
- if (id === this.maxId) {
703
- const maxUsedId = Math.max(...Array.from(this.usedIds));
704
- this.maxId = maxUsedId;
705
- }
706
- await this.saveStatus();
707
- }
708
- // 添加新的统计记录
709
- async addStat(contributorNumber, caveId) {
710
- if (contributorNumber === "10000") return;
711
- if (!this.stats[contributorNumber]) {
712
- this.stats[contributorNumber] = [];
713
- }
714
- this.stats[contributorNumber].push(caveId);
715
- await this.saveStatus();
716
- }
717
- // 删除统计记录
718
- async removeStat(contributorNumber, caveId) {
719
- if (this.stats[contributorNumber]) {
720
- this.stats[contributorNumber] = this.stats[contributorNumber].filter((id) => id !== caveId);
721
- if (this.stats[contributorNumber].length === 0) {
722
- delete this.stats[contributorNumber];
723
- }
724
- await this.saveStatus();
725
- }
726
- }
727
- // 获取统计信息
728
- getStats() {
729
- return this.stats;
730
- }
731
- async saveStatus() {
732
- try {
733
- const status = {
734
- deletedIds: Array.from(this.deletedIds).sort((a, b) => a - b),
735
- maxId: this.maxId,
736
- stats: this.stats,
737
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
738
- };
739
- const tmpPath = `${this.statusFilePath}.tmp`;
740
- await fs.promises.writeFile(tmpPath, JSON.stringify(status, null, 2), "utf8");
741
- await fs.promises.rename(tmpPath, this.statusFilePath);
742
- } catch (error) {
743
- logger.error(`Failed to save status: ${error.message}`);
744
- throw error;
745
- }
746
- }
747
- };
1323
+ var logger4 = new import_koishi4.Logger("cave");
748
1324
  async function sendMessage(session, key, params = [], isTemp = true, timeout = 1e4) {
749
1325
  try {
750
1326
  const msg = await session.send(session.text(key, params));
@@ -753,12 +1329,12 @@ async function sendMessage(session, key, params = [], isTemp = true, timeout = 1
753
1329
  try {
754
1330
  await session.bot.deleteMessage(session.channelId, msg);
755
1331
  } catch (error) {
756
- logger.debug(`Failed to delete temporary message: ${error.message}`);
1332
+ logger4.debug(`Failed to delete temporary message: ${error.message}`);
757
1333
  }
758
1334
  }, timeout);
759
1335
  }
760
1336
  } catch (error) {
761
- logger.error(`Failed to send message: ${error.message}`);
1337
+ logger4.error(`Failed to send message: ${error.message}`);
762
1338
  }
763
1339
  return "";
764
1340
  }
@@ -773,7 +1349,7 @@ ${session.text("commands.cave.audit.from")}${cave.contributor_number}`;
773
1349
  try {
774
1350
  await bot.sendPrivateMessage(managerId, auditMessage);
775
1351
  } catch (error) {
776
- logger.error(session.text("commands.cave.audit.sendFailed", [managerId]));
1352
+ logger4.error(session.text("commands.cave.audit.sendFailed", [managerId]));
777
1353
  }
778
1354
  }
779
1355
  }
@@ -804,7 +1380,7 @@ function cleanElementsForSave(elements, keepIndex = false) {
804
1380
  }
805
1381
  __name(cleanElementsForSave, "cleanElementsForSave");
806
1382
  async function processMediaFile(filePath, type) {
807
- const data = await fs.promises.readFile(filePath).catch(() => null);
1383
+ const data = await fs4.promises.readFile(filePath).catch(() => null);
808
1384
  if (!data) return null;
809
1385
  return `data:${type}/${type === "image" ? "png" : "mp4"};base64,${data.toString("base64")}`;
810
1386
  }
@@ -821,10 +1397,10 @@ async function buildMessage(cave, resourceDir, session) {
821
1397
  session.text("commands.cave.message.contributorSuffix", [cave.contributor_name])
822
1398
  ].join("\n");
823
1399
  await session?.send(basicInfo);
824
- const filePath = path.join(resourceDir, videoElement.file);
1400
+ const filePath = path4.join(resourceDir, videoElement.file);
825
1401
  const base64Data = await processMediaFile(filePath, "video");
826
1402
  if (base64Data && session) {
827
- await session.send((0, import_koishi.h)("video", { src: base64Data }));
1403
+ await session.send((0, import_koishi4.h)("video", { src: base64Data }));
828
1404
  }
829
1405
  return "";
830
1406
  }
@@ -833,10 +1409,10 @@ async function buildMessage(cave, resourceDir, session) {
833
1409
  if (element.type === "text") {
834
1410
  lines.push(element.content);
835
1411
  } else if (element.type === "img" && element.file) {
836
- const filePath = path.join(resourceDir, element.file);
1412
+ const filePath = path4.join(resourceDir, element.file);
837
1413
  const base64Data = await processMediaFile(filePath, "image");
838
1414
  if (base64Data) {
839
- lines.push((0, import_koishi.h)("image", { src: base64Data }));
1415
+ lines.push((0, import_koishi4.h)("image", { src: base64Data }));
840
1416
  }
841
1417
  }
842
1418
  }
@@ -871,7 +1447,6 @@ async function extractMediaContent(originalContent, config, session) {
871
1447
  elements.push({
872
1448
  type,
873
1449
  index: type === "video" ? Number.MAX_SAFE_INTEGER : idx * 3 + 1,
874
- // 视频始终在最后
875
1450
  fileName,
876
1451
  fileSize
877
1452
  });
@@ -886,15 +1461,13 @@ async function extractMediaContent(originalContent, config, session) {
886
1461
  return { imageUrls, imageElements, videoUrls, videoElements, textParts };
887
1462
  }
888
1463
  __name(extractMediaContent, "extractMediaContent");
889
- async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, ctx, session) {
890
- const { ext, accept } = mediaType === "img" ? { ext: "png", accept: "image/*" } : { ext: "mp4", accept: "video/*" };
1464
+ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config, ctx, session) {
1465
+ const accept = mediaType === "img" ? "image/*" : "video/*";
1466
+ const hashStorage = new HashStorage(path4.join(ctx.baseDir, "data", "cave"));
1467
+ await hashStorage.initialize();
891
1468
  const downloadTasks = urls.map(async (url, i) => {
892
1469
  const fileName = fileNames[i];
893
- const fileExt = fileName?.match(/\.[a-zA-Z0-9]+$/)?.[0]?.slice(1) || ext;
894
- const baseName = fileName;
895
- path.basename(fileName, path.extname(fileName)).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
896
- const finalFileName = `${caveId}_${baseName}.${fileExt}`;
897
- const filePath = path.join(resourceDir, finalFileName);
1470
+ const ext = path4.extname(fileName || url) || (mediaType === "img" ? ".png" : ".mp4");
898
1471
  try {
899
1472
  const response = await ctx.http(decodeURIComponent(url).replace(/&amp;/g, "&"), {
900
1473
  method: "GET",
@@ -907,19 +1480,64 @@ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, ctx, s
907
1480
  }
908
1481
  });
909
1482
  if (!response.data) throw new Error("empty_response");
910
- await FileHandler.saveMediaFile(filePath, Buffer.from(response.data));
911
- return finalFileName;
1483
+ const buffer = Buffer.from(response.data);
1484
+ if (mediaType === "img") {
1485
+ const baseName = path4.basename(fileName || "md5", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
1486
+ const files = await fs4.promises.readdir(resourceDir);
1487
+ const duplicateFile = files.find((file) => file.startsWith(baseName + "_"));
1488
+ if (duplicateFile) {
1489
+ const duplicateCaveId = parseInt(duplicateFile.split("_")[1]);
1490
+ if (!isNaN(duplicateCaveId)) {
1491
+ const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
1492
+ const data = await FileHandler.readJsonData(caveFilePath);
1493
+ const originalCave = data.find((item) => item.cave_id === duplicateCaveId);
1494
+ if (originalCave) {
1495
+ const message = session.text("commands.cave.error.exactDuplicateFound");
1496
+ await session.send(message + await buildMessage(originalCave, resourceDir, session));
1497
+ throw new Error("duplicate_found");
1498
+ }
1499
+ }
1500
+ }
1501
+ if (config.enableDuplicate) {
1502
+ const result = await hashStorage.findDuplicates([buffer], config.duplicateThreshold);
1503
+ if (result.length > 0 && result[0] !== null) {
1504
+ const duplicate = result[0];
1505
+ const similarity = duplicate.similarity;
1506
+ if (similarity >= config.duplicateThreshold) {
1507
+ const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
1508
+ const data = await FileHandler.readJsonData(caveFilePath);
1509
+ const originalCave = data.find((item) => item.cave_id === duplicate.caveId);
1510
+ if (originalCave) {
1511
+ const message = session.text(
1512
+ "commands.cave.error.similarDuplicateFound",
1513
+ [(similarity * 100).toFixed(1)]
1514
+ );
1515
+ await session.send(message + await buildMessage(originalCave, resourceDir, session));
1516
+ throw new Error("duplicate_found");
1517
+ }
1518
+ }
1519
+ }
1520
+ }
1521
+ const finalFileName = `${caveId}_${baseName}${ext}`;
1522
+ const filePath = path4.join(resourceDir, finalFileName);
1523
+ await FileHandler.saveMediaFile(filePath, buffer);
1524
+ return finalFileName;
1525
+ } else {
1526
+ const baseName = path4.basename(fileName || "video", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
1527
+ const finalFileName = `${caveId}_${baseName}${ext}`;
1528
+ const filePath = path4.join(resourceDir, finalFileName);
1529
+ await FileHandler.saveMediaFile(filePath, buffer);
1530
+ return finalFileName;
1531
+ }
912
1532
  } catch (error) {
913
- logger.error(`Failed to download media: ${error.message}`);
914
- throw error;
1533
+ if (error.message === "duplicate_found") {
1534
+ throw error;
1535
+ }
1536
+ logger4.error(`Failed to download media: ${error.message}`);
1537
+ throw new Error(session.text(`commands.cave.error.upload${mediaType === "img" ? "Image" : "Video"}Failed`));
915
1538
  }
916
1539
  });
917
- const results = await Promise.allSettled(downloadTasks);
918
- const successfulResults = results.filter((result) => result.status === "fulfilled").map((result) => result.value);
919
- if (!successfulResults.length) {
920
- throw new Error(session.text(`commands.cave.error.upload${mediaType === "img" ? "Image" : "Video"}Failed`));
921
- }
922
- return successfulResults;
1540
+ return Promise.all(downloadTasks);
923
1541
  }
924
1542
  __name(saveMedia, "saveMedia");
925
1543
  // Annotate the CommonJS export names for ESM import in node: