koishi-plugin-best-cave 1.2.0 → 1.3.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
@@ -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)", 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: "未找到该回声洞", duplicateFound: "发现相似度为 {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)", duplicateThreshold: "Image similarity threshold (0-1)", allowVideo: "Allow video uploads", 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 cave entries", examples: "Use cave to randomly draw an entry\nUse -a to add directly or 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 submission (batch)", d: "Reject submission (batch)", l: "Check submission statistics" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timeout, addition cancelled", videoDisabled: "Video uploads are 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' entries", deletePending: "Delete (pending)", 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: "{0} {1}/{2} entries", title: "Pending entries:", from: "Submitted by:", sendFailed: "Failed to send moderation message, cannot contact administrator {0}" }, error: { noContent: "Entry content is empty", getCave: "Failed to get entry", noCave: "No entries available", invalidId: "Please enter a valid entry ID", notFound: "Entry not found", duplicateFound: "Found duplicate image with similarity of {0}%" }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is for administrators only", cooldown: "Group chat cooldown... 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,690 @@ __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
+ static async withConcurrencyLimit(operation, limit = this.CONCURRENCY_LIMIT) {
75
+ while (this.locks.size >= limit) {
76
+ await Promise.race(this.locks.values());
77
+ }
78
+ return operation();
79
+ }
80
+ // 文件操作包装器
81
+ static async withFileOp(filePath, operation) {
82
+ const key = filePath;
83
+ while (this.locks.has(key)) {
84
+ await this.locks.get(key);
85
+ }
86
+ const operationPromise = (async () => {
87
+ for (let i = 0; i < this.RETRY_COUNT; i++) {
88
+ try {
89
+ return await operation();
90
+ } catch (error) {
91
+ if (i === this.RETRY_COUNT - 1) throw error;
92
+ await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY));
93
+ }
94
+ }
95
+ throw new Error("Operation failed after retries");
96
+ })();
97
+ this.locks.set(key, operationPromise);
98
+ try {
99
+ return await operationPromise;
100
+ } finally {
101
+ this.locks.delete(key);
102
+ }
103
+ }
104
+ // 事务处理
105
+ static async withTransaction(operations) {
106
+ const results = [];
107
+ const completed = /* @__PURE__ */ new Set();
108
+ try {
109
+ for (const { filePath, operation } of operations) {
110
+ const result = await this.withFileOp(filePath, operation);
111
+ results.push(result);
112
+ completed.add(filePath);
113
+ }
114
+ return results;
115
+ } catch (error) {
116
+ await Promise.all(
117
+ operations.filter(({ filePath }) => completed.has(filePath)).map(async ({ filePath, rollback }) => {
118
+ if (rollback) {
119
+ await this.withFileOp(filePath, rollback).catch(
120
+ (e) => logger.error(`Rollback failed for ${filePath}: ${e.message}`)
121
+ );
122
+ }
123
+ })
124
+ );
125
+ throw error;
126
+ }
127
+ }
128
+ // JSON读写
129
+ static async readJsonData(filePath) {
130
+ return this.withFileOp(filePath, async () => {
131
+ try {
132
+ const data = await fs.promises.readFile(filePath, "utf8");
133
+ return JSON.parse(data || "[]");
134
+ } catch (error) {
135
+ return [];
136
+ }
137
+ });
138
+ }
139
+ static async writeJsonData(filePath, data) {
140
+ const tmpPath = `${filePath}.tmp`;
141
+ await this.withFileOp(filePath, async () => {
142
+ await fs.promises.writeFile(tmpPath, JSON.stringify(data, null, 2));
143
+ await fs.promises.rename(tmpPath, filePath);
144
+ });
145
+ }
146
+ // 目录和文件操作
147
+ static async ensureDirectory(dir) {
148
+ await this.withConcurrencyLimit(async () => {
149
+ if (!fs.existsSync(dir)) {
150
+ await fs.promises.mkdir(dir, { recursive: true });
151
+ }
152
+ });
153
+ }
154
+ static async ensureJsonFile(filePath) {
155
+ await this.withFileOp(filePath, async () => {
156
+ if (!fs.existsSync(filePath)) {
157
+ await fs.promises.writeFile(filePath, "[]", "utf8");
158
+ }
159
+ });
160
+ }
161
+ // 媒体文件操作
162
+ static async saveMediaFile(filePath, data) {
163
+ await this.withConcurrencyLimit(async () => {
164
+ const dir = path.dirname(filePath);
165
+ await this.ensureDirectory(dir);
166
+ await this.withFileOp(
167
+ filePath,
168
+ () => fs.promises.writeFile(filePath, data)
169
+ );
170
+ });
171
+ }
172
+ static async deleteMediaFile(filePath) {
173
+ await this.withFileOp(filePath, async () => {
174
+ if (fs.existsSync(filePath)) {
175
+ await fs.promises.unlink(filePath);
176
+ }
177
+ });
178
+ }
179
+ };
180
+
181
+ // src/utils/idManager.ts
182
+ var fs2 = __toESM(require("fs"));
183
+ var path2 = __toESM(require("path"));
184
+ var import_koishi2 = require("koishi");
185
+ var logger2 = new import_koishi2.Logger("idManager");
186
+ var IdManager = class {
187
+ static {
188
+ __name(this, "IdManager");
189
+ }
190
+ deletedIds = /* @__PURE__ */ new Set();
191
+ maxId = 0;
192
+ initialized = false;
193
+ statusFilePath;
194
+ stats = {};
195
+ usedIds = /* @__PURE__ */ new Set();
196
+ constructor(baseDir) {
197
+ const caveDir = path2.join(baseDir, "data", "cave");
198
+ this.statusFilePath = path2.join(caveDir, "status.json");
199
+ }
200
+ async initialize(caveFilePath, pendingFilePath) {
201
+ if (this.initialized) return;
202
+ try {
203
+ const status = fs2.existsSync(this.statusFilePath) ? JSON.parse(await fs2.promises.readFile(this.statusFilePath, "utf8")) : {
204
+ deletedIds: [],
205
+ maxId: 0,
206
+ stats: {},
207
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
208
+ };
209
+ const [caveData, pendingData] = await Promise.all([
210
+ FileHandler.readJsonData(caveFilePath),
211
+ FileHandler.readJsonData(pendingFilePath)
212
+ ]);
213
+ this.usedIds.clear();
214
+ this.stats = {};
215
+ const conflicts = /* @__PURE__ */ new Map();
216
+ for (const data of [caveData, pendingData]) {
217
+ for (const item of data) {
218
+ if (this.usedIds.has(item.cave_id)) {
219
+ if (!conflicts.has(item.cave_id)) {
220
+ conflicts.set(item.cave_id, []);
221
+ }
222
+ conflicts.get(item.cave_id)?.push(item);
223
+ } else {
224
+ this.usedIds.add(item.cave_id);
225
+ if (data === caveData && item.contributor_number !== "10000") {
226
+ if (!this.stats[item.contributor_number]) {
227
+ this.stats[item.contributor_number] = [];
228
+ }
229
+ this.stats[item.contributor_number].push(item.cave_id);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ if (conflicts.size > 0) {
235
+ await this.handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData);
236
+ }
237
+ this.maxId = Math.max(
238
+ status.maxId || 0,
239
+ ...[...this.usedIds],
240
+ ...status.deletedIds || [],
241
+ 0
242
+ );
243
+ this.deletedIds = new Set(
244
+ status.deletedIds?.filter((id) => !this.usedIds.has(id)) || []
245
+ );
246
+ await this.saveStatus();
247
+ this.initialized = true;
248
+ logger2.success("ID Manager initialized");
249
+ } catch (error) {
250
+ this.initialized = false;
251
+ logger2.error(`ID Manager initialization failed: ${error.message}`);
252
+ throw error;
253
+ }
254
+ }
255
+ async handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData) {
256
+ logger2.warn(`Found ${conflicts.size} ID conflicts`);
257
+ let modified = false;
258
+ for (const items of conflicts.values()) {
259
+ items.slice(1).forEach((item) => {
260
+ let newId = this.maxId + 1;
261
+ while (this.usedIds.has(newId)) {
262
+ newId++;
263
+ }
264
+ logger2.info(`Reassigning ID: ${item.cave_id} -> ${newId}`);
265
+ item.cave_id = newId;
266
+ this.usedIds.add(newId);
267
+ this.maxId = Math.max(this.maxId, newId);
268
+ modified = true;
269
+ });
270
+ }
271
+ if (modified) {
272
+ await Promise.all([
273
+ FileHandler.writeJsonData(caveFilePath, caveData),
274
+ FileHandler.writeJsonData(pendingFilePath, pendingData)
275
+ ]);
276
+ logger2.success("ID conflicts resolved");
277
+ }
278
+ }
279
+ getNextId() {
280
+ if (!this.initialized) {
281
+ throw new Error("IdManager not initialized");
282
+ }
283
+ let nextId;
284
+ if (this.deletedIds.size === 0) {
285
+ nextId = ++this.maxId;
286
+ } else {
287
+ nextId = Math.min(...Array.from(this.deletedIds));
288
+ this.deletedIds.delete(nextId);
289
+ }
290
+ while (this.usedIds.has(nextId)) {
291
+ nextId = ++this.maxId;
292
+ }
293
+ this.usedIds.add(nextId);
294
+ this.saveStatus().catch(
295
+ (err) => logger2.error(`Failed to save status after getNextId: ${err.message}`)
296
+ );
297
+ return nextId;
298
+ }
299
+ async markDeleted(id) {
300
+ if (!this.initialized) {
301
+ throw new Error("IdManager not initialized");
302
+ }
303
+ this.deletedIds.add(id);
304
+ this.usedIds.delete(id);
305
+ const maxUsedId = Math.max(...Array.from(this.usedIds), 0);
306
+ const maxDeletedId = Math.max(...Array.from(this.deletedIds), 0);
307
+ this.maxId = Math.max(maxUsedId, maxDeletedId);
308
+ await this.saveStatus();
309
+ }
310
+ async addStat(contributorNumber, caveId) {
311
+ if (contributorNumber === "10000") return;
312
+ if (!this.stats[contributorNumber]) {
313
+ this.stats[contributorNumber] = [];
314
+ }
315
+ this.stats[contributorNumber].push(caveId);
316
+ await this.saveStatus();
317
+ }
318
+ async removeStat(contributorNumber, caveId) {
319
+ if (this.stats[contributorNumber]) {
320
+ this.stats[contributorNumber] = this.stats[contributorNumber].filter((id) => id !== caveId);
321
+ if (this.stats[contributorNumber].length === 0) {
322
+ delete this.stats[contributorNumber];
323
+ }
324
+ await this.saveStatus();
325
+ }
326
+ }
327
+ getStats() {
328
+ return this.stats;
329
+ }
330
+ async saveStatus() {
331
+ try {
332
+ const status = {
333
+ deletedIds: Array.from(this.deletedIds).sort((a, b) => a - b),
334
+ maxId: this.maxId,
335
+ stats: this.stats,
336
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
337
+ };
338
+ const tmpPath = `${this.statusFilePath}.tmp`;
339
+ await fs2.promises.writeFile(tmpPath, JSON.stringify(status, null, 2), "utf8");
340
+ await fs2.promises.rename(tmpPath, this.statusFilePath);
341
+ } catch (error) {
342
+ logger2.error(`Status save failed: ${error.message}`);
343
+ throw error;
344
+ }
345
+ }
346
+ };
347
+
348
+ // src/utils/HashStorage.ts
349
+ var import_koishi3 = require("koishi");
350
+ var fs3 = __toESM(require("fs"));
351
+ var path3 = __toESM(require("path"));
352
+
353
+ // src/utils/ImageHasher.ts
354
+ var import_sharp = __toESM(require("sharp"));
355
+ var ImageHasher = class {
356
+ static {
357
+ __name(this, "ImageHasher");
358
+ }
359
+ /**
360
+ * 计算图片哈希值
361
+ */
362
+ static async calculateHash(imageBuffer) {
363
+ const { data } = await (0, import_sharp.default)(imageBuffer).grayscale().resize(32, 32, { fit: "fill" }).raw().toBuffer({ resolveWithObject: true });
364
+ const waveletMatrix = this.haarWaveletTransform(data, 32);
365
+ const features = this.extractFeatures(waveletMatrix, 32);
366
+ const mean = features.reduce((sum, val) => sum + val, 0) / features.length;
367
+ const binaryHash = features.map((val) => val > mean ? "1" : "0").join("");
368
+ return this.binaryToHex(binaryHash);
369
+ }
370
+ static binaryToHex(binary) {
371
+ const hex = [];
372
+ for (let i = 0; i < binary.length; i += 4) {
373
+ const chunk = binary.slice(i, i + 4);
374
+ hex.push(parseInt(chunk, 2).toString(16));
375
+ }
376
+ return hex.join("");
377
+ }
378
+ static hexToBinary(hex) {
379
+ let binary = "";
380
+ for (const char of hex) {
381
+ const bin = parseInt(char, 16).toString(2).padStart(4, "0");
382
+ binary += bin;
383
+ }
384
+ return binary;
385
+ }
386
+ static haarWaveletTransform(data, size) {
387
+ const matrix = Array(size).fill(0).map(() => Array(size).fill(0));
388
+ for (let i = 0; i < size; i++) {
389
+ for (let j = 0; j < size; j++) {
390
+ matrix[i][j] = data[i * size + j];
391
+ }
392
+ }
393
+ for (let i = 0; i < size; i++) {
394
+ this.haarTransform1D(matrix[i]);
395
+ }
396
+ for (let j = 0; j < size; j++) {
397
+ const col = matrix.map((row) => row[j]);
398
+ this.haarTransform1D(col);
399
+ for (let i = 0; i < size; i++) {
400
+ matrix[i][j] = col[i];
401
+ }
402
+ }
403
+ return matrix;
404
+ }
405
+ static haarTransform1D(arr) {
406
+ const len = arr.length;
407
+ const temp = new Array(len).fill(0);
408
+ for (let i = 0; i < len; i += 2) {
409
+ if (i + 1 < len) {
410
+ temp[i / 2] = (arr[i] + arr[i + 1]) / 2;
411
+ temp[len / 2 + i / 2] = (arr[i] - arr[i + 1]) / 2;
412
+ } else {
413
+ temp[i / 2] = arr[i];
414
+ }
415
+ }
416
+ for (let i = 0; i < len; i++) {
417
+ arr[i] = temp[i];
418
+ }
419
+ }
420
+ static extractFeatures(matrix, size) {
421
+ const features = [];
422
+ const featureSize = 8;
423
+ for (let i = 0; i < featureSize; i++) {
424
+ for (let j = 0; j < featureSize; j++) {
425
+ features.push(matrix[i][j]);
426
+ }
427
+ }
428
+ return features;
429
+ }
430
+ /**
431
+ * 计算两个hash值的汉明距离
432
+ */
433
+ static calculateDistance(hash1, hash2) {
434
+ if (hash1.length !== hash2.length) {
435
+ throw new Error("Hash lengths must be equal");
436
+ }
437
+ const bin1 = this.hexToBinary(hash1);
438
+ const bin2 = this.hexToBinary(hash2);
439
+ let distance = 0;
440
+ for (let i = 0; i < bin1.length; i++) {
441
+ if (bin1[i] !== bin2[i]) distance++;
442
+ }
443
+ return distance;
444
+ }
445
+ /**
446
+ * 计算图片相似度(0-1)
447
+ */
448
+ static calculateSimilarity(hash1, hash2) {
449
+ const distance = this.calculateDistance(hash1, hash2);
450
+ return (64 - distance) / 64;
451
+ }
452
+ /**
453
+ * 批量比较图片相似度
454
+ */
455
+ static batchCompareSimilarity(newHash, existingHashes) {
456
+ return existingHashes.map((hash) => this.calculateSimilarity(newHash, hash));
457
+ }
458
+ };
459
+
460
+ // src/utils/HashStorage.ts
461
+ var import_util = require("util");
462
+ var logger3 = new import_koishi3.Logger("HashStorage");
463
+ var readFileAsync = (0, import_util.promisify)(fs3.readFile);
464
+ var HashStorage = class _HashStorage {
465
+ constructor(caveDir) {
466
+ this.caveDir = caveDir;
467
+ }
468
+ static {
469
+ __name(this, "HashStorage");
470
+ }
471
+ // 哈希数据文件名
472
+ static HASH_FILE = "hash.json";
473
+ // 洞穴数据文件名
474
+ static CAVE_FILE = "cave.json";
475
+ // 批处理大小
476
+ static BATCH_SIZE = 50;
477
+ // 存储洞穴ID到图片哈希值的映射
478
+ hashes = /* @__PURE__ */ new Map();
479
+ // 初始化状态标志
480
+ initialized = false;
481
+ get filePath() {
482
+ return path3.join(this.caveDir, _HashStorage.HASH_FILE);
483
+ }
484
+ get resourceDir() {
485
+ return path3.join(this.caveDir, "resources");
486
+ }
487
+ get caveFilePath() {
488
+ return path3.join(this.caveDir, _HashStorage.CAVE_FILE);
489
+ }
490
+ async initialize() {
491
+ if (this.initialized) return;
492
+ try {
493
+ const hashData = await FileHandler.readJsonData(this.filePath).then((data) => data[0]).catch(() => null);
494
+ if (!hashData?.hashes || Object.keys(hashData.hashes).length === 0) {
495
+ this.hashes.clear();
496
+ await this.buildInitialHashes();
497
+ } else {
498
+ this.hashes = new Map(
499
+ Object.entries(hashData.hashes).map(([k, v]) => [Number(k), v])
500
+ );
501
+ await this.updateMissingHashes();
502
+ }
503
+ this.initialized = true;
504
+ } catch (error) {
505
+ logger3.error(`Initialization failed: ${error.message}`);
506
+ this.initialized = false;
507
+ throw error;
508
+ }
509
+ }
510
+ async getStatus() {
511
+ if (!this.initialized) await this.initialize();
512
+ return {
513
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString(),
514
+ entries: Array.from(this.hashes.entries()).map(([caveId, hashes]) => ({
515
+ caveId,
516
+ hashes
517
+ }))
518
+ };
519
+ }
520
+ async updateCaveHash(caveId, imgBuffers) {
521
+ if (!this.initialized) await this.initialize();
522
+ try {
523
+ if (imgBuffers?.length) {
524
+ const hashes = await Promise.all(
525
+ imgBuffers.map((buffer) => ImageHasher.calculateHash(buffer))
526
+ );
527
+ this.hashes.set(caveId, hashes);
528
+ } else {
529
+ this.hashes.delete(caveId);
530
+ }
531
+ await this.saveHashes();
532
+ } catch (error) {
533
+ logger3.error(`Failed to update hash (cave ${caveId}): ${error.message}`);
534
+ }
535
+ }
536
+ async updateAllCaves(isInitialBuild = false) {
537
+ if (!this.initialized && !isInitialBuild) {
538
+ await this.initialize();
539
+ return;
540
+ }
541
+ try {
542
+ logger3.info("Starting full hash update...");
543
+ const caveData = await this.loadCaveData();
544
+ const cavesWithImages = caveData.filter(
545
+ (cave) => cave.elements?.some((el) => el.type === "img" && el.file)
546
+ );
547
+ this.hashes.clear();
548
+ let processedCount = 0;
549
+ const totalImages = cavesWithImages.length;
550
+ const processCave = /* @__PURE__ */ __name(async (cave) => {
551
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
552
+ if (imgElements.length === 0) return;
553
+ try {
554
+ const hashes = await Promise.all(
555
+ imgElements.map(async (imgElement) => {
556
+ const filePath = path3.join(this.resourceDir, imgElement.file);
557
+ if (!fs3.existsSync(filePath)) {
558
+ logger3.warn(`Image file not found: ${filePath}`);
559
+ return null;
560
+ }
561
+ const imgBuffer = await readFileAsync(filePath);
562
+ return await ImageHasher.calculateHash(imgBuffer);
563
+ })
564
+ );
565
+ const validHashes = hashes.filter((hash) => hash !== null);
566
+ if (validHashes.length > 0) {
567
+ this.hashes.set(cave.cave_id, validHashes);
568
+ processedCount++;
569
+ if (processedCount % 100 === 0) {
570
+ logger3.info(`Progress: ${processedCount}/${totalImages}`);
571
+ }
572
+ }
573
+ } catch (error) {
574
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
575
+ }
576
+ }, "processCave");
577
+ await this.processBatch(cavesWithImages, processCave);
578
+ await this.saveHashes();
579
+ logger3.success(`Update completed. Processed ${processedCount}/${totalImages} images`);
580
+ } catch (error) {
581
+ logger3.error(`Full update failed: ${error.message}`);
582
+ throw error;
583
+ }
584
+ }
585
+ async findDuplicates(imgBuffers, threshold) {
586
+ if (!this.initialized) await this.initialize();
587
+ const inputHashes = await Promise.all(
588
+ imgBuffers.map((buffer) => ImageHasher.calculateHash(buffer))
589
+ );
590
+ const existingHashes = Array.from(this.hashes.entries());
591
+ return Promise.all(
592
+ inputHashes.map(async (hash, index) => {
593
+ try {
594
+ let maxSimilarity = 0;
595
+ let matchedCaveId = null;
596
+ for (const [caveId, hashes] of existingHashes) {
597
+ for (const existingHash of hashes) {
598
+ const similarity = ImageHasher.calculateSimilarity(hash, existingHash);
599
+ if (similarity >= threshold && similarity > maxSimilarity) {
600
+ maxSimilarity = similarity;
601
+ matchedCaveId = caveId;
602
+ if (Math.abs(similarity - 1) < Number.EPSILON) break;
603
+ }
604
+ }
605
+ if (Math.abs(maxSimilarity - 1) < Number.EPSILON) break;
606
+ }
607
+ return matchedCaveId ? {
608
+ index,
609
+ caveId: matchedCaveId,
610
+ similarity: maxSimilarity
611
+ } : null;
612
+ } catch (error) {
613
+ logger3.warn(`处理图片 ${index} 失败: ${error.message}`);
614
+ return null;
615
+ }
616
+ })
617
+ );
618
+ }
619
+ async loadCaveData() {
620
+ const data = await FileHandler.readJsonData(this.caveFilePath);
621
+ return Array.isArray(data) ? data.flat() : [];
622
+ }
623
+ async saveHashes() {
624
+ const data = {
625
+ hashes: Object.fromEntries(this.hashes),
626
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
627
+ };
628
+ await FileHandler.writeJsonData(this.filePath, [data]);
629
+ }
630
+ async buildInitialHashes() {
631
+ const caveData = await this.loadCaveData();
632
+ let processedImageCount = 0;
633
+ const totalImages = caveData.reduce((sum, cave) => sum + (cave.elements?.filter((el) => el.type === "img" && el.file).length || 0), 0);
634
+ logger3.info(`Building hash data for ${totalImages} images...`);
635
+ for (const cave of caveData) {
636
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
637
+ if (imgElements.length === 0) continue;
638
+ try {
639
+ const hashes = await Promise.all(
640
+ imgElements.map(async (imgElement) => {
641
+ const filePath = path3.join(this.resourceDir, imgElement.file);
642
+ if (!fs3.existsSync(filePath)) {
643
+ logger3.warn(`Image not found: ${filePath}`);
644
+ return null;
645
+ }
646
+ const imgBuffer = await fs3.promises.readFile(filePath);
647
+ const hash = await ImageHasher.calculateHash(imgBuffer);
648
+ processedImageCount++;
649
+ if (processedImageCount % 100 === 0) {
650
+ logger3.info(`Progress: ${processedImageCount}/${totalImages} images`);
651
+ }
652
+ return hash;
653
+ })
654
+ );
655
+ const validHashes = hashes.filter((hash) => hash !== null);
656
+ if (validHashes.length > 0) {
657
+ this.hashes.set(cave.cave_id, validHashes);
658
+ }
659
+ } catch (error) {
660
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
661
+ }
662
+ }
663
+ await this.saveHashes();
664
+ logger3.success(`Build completed. Processed ${processedImageCount}/${totalImages} images`);
665
+ }
666
+ async updateMissingHashes() {
667
+ const caveData = await this.loadCaveData();
668
+ let updatedCount = 0;
669
+ for (const cave of caveData) {
670
+ if (this.hashes.has(cave.cave_id)) continue;
671
+ const imgElements = cave.elements?.filter((el) => el.type === "img" && el.file) || [];
672
+ if (imgElements.length === 0) continue;
673
+ try {
674
+ const hashes = await Promise.all(
675
+ imgElements.map(async (imgElement) => {
676
+ const filePath = path3.join(this.resourceDir, imgElement.file);
677
+ if (!fs3.existsSync(filePath)) {
678
+ return null;
679
+ }
680
+ const imgBuffer = await fs3.promises.readFile(filePath);
681
+ return ImageHasher.calculateHash(imgBuffer);
682
+ })
683
+ );
684
+ const validHashes = hashes.filter((hash) => hash !== null);
685
+ if (validHashes.length > 0) {
686
+ this.hashes.set(cave.cave_id, validHashes);
687
+ updatedCount++;
688
+ }
689
+ } catch (error) {
690
+ logger3.error(`Failed to process cave ${cave.cave_id}: ${error.message}`);
691
+ }
692
+ }
693
+ if (updatedCount > 0) {
694
+ await this.saveHashes();
695
+ logger3.info(`Updated ${updatedCount} new hashes`);
696
+ }
697
+ }
698
+ async processBatch(items, processor, batchSize = _HashStorage.BATCH_SIZE) {
699
+ for (let i = 0; i < items.length; i += batchSize) {
700
+ const batch = items.slice(i, i + batchSize);
701
+ await Promise.all(
702
+ batch.map(async (item) => {
703
+ try {
704
+ await processor(item);
705
+ } catch (error) {
706
+ logger3.error(`Batch processing error: ${error.message}`);
707
+ }
708
+ })
709
+ );
710
+ }
711
+ }
712
+ };
713
+
714
+ // src/index.ts
59
715
  var name = "best-cave";
60
716
  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)
717
+ var Config = import_koishi4.Schema.object({
718
+ manager: import_koishi4.Schema.array(import_koishi4.Schema.string()).required(),
719
+ // 管理员用户ID
720
+ number: import_koishi4.Schema.number().default(60),
721
+ // 冷却时间()
722
+ enableAudit: import_koishi4.Schema.boolean().default(false),
723
+ // 启用审核
724
+ imageMaxSize: import_koishi4.Schema.number().default(4),
725
+ // 图片大小限制(MB)
726
+ duplicateThreshold: import_koishi4.Schema.number().default(0.8),
727
+ // 查重阈值(0-1)
728
+ allowVideo: import_koishi4.Schema.boolean().default(true),
729
+ // 允许视频
730
+ videoMaxSize: import_koishi4.Schema.number().default(16),
731
+ // 视频大小限制(MB)
732
+ enablePagination: import_koishi4.Schema.boolean().default(false),
733
+ // 启用分页
734
+ itemsPerPage: import_koishi4.Schema.number().default(10),
735
+ // 每页条数
736
+ blacklist: import_koishi4.Schema.array(import_koishi4.Schema.string()).default([]),
737
+ // 黑名单
738
+ whitelist: import_koishi4.Schema.array(import_koishi4.Schema.string()).default([])
739
+ // 白名单
72
740
  }).i18n({
73
741
  "zh-CN": require_zh_CN()._config,
74
742
  "en-US": require_en_US()._config
@@ -76,18 +744,20 @@ var Config = import_koishi.Schema.object({
76
744
  async function apply(ctx, config) {
77
745
  ctx.i18n.define("zh-CN", require_zh_CN());
78
746
  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");
747
+ const dataDir = path4.join(ctx.baseDir, "data");
748
+ const caveDir = path4.join(dataDir, "cave");
84
749
  await FileHandler.ensureDirectory(dataDir);
85
750
  await FileHandler.ensureDirectory(caveDir);
86
- await FileHandler.ensureDirectory(resourceDir);
87
- await FileHandler.ensureJsonFile(caveFilePath);
88
- await FileHandler.ensureJsonFile(pendingFilePath);
751
+ await FileHandler.ensureDirectory(path4.join(caveDir, "resources"));
752
+ await FileHandler.ensureJsonFile(path4.join(caveDir, "cave.json"));
753
+ await FileHandler.ensureJsonFile(path4.join(caveDir, "pending.json"));
754
+ await FileHandler.ensureJsonFile(path4.join(caveDir, "hash.json"));
89
755
  const idManager = new IdManager(ctx.baseDir);
90
- await idManager.initialize(caveFilePath, pendingFilePath);
756
+ const hashStorage = new HashStorage(caveDir);
757
+ await Promise.all([
758
+ idManager.initialize(path4.join(caveDir, "cave.json"), path4.join(caveDir, "pending.json")),
759
+ hashStorage.initialize()
760
+ ]);
91
761
  const lastUsed = /* @__PURE__ */ new Map();
92
762
  async function processList(session, config2, userId, pageNum = 1) {
93
763
  const stats = idManager.getStats();
@@ -111,53 +781,47 @@ async function apply(ctx, config) {
111
781
  }
112
782
  }
113
783
  __name(processList, "processList");
114
- async function processAudit(ctx2, pendingFilePath2, caveFilePath2, resourceDir2, session, options, content) {
115
- const pendingData = await FileHandler.readJsonData(pendingFilePath2);
784
+ async function processAudit(pendingFilePath, caveFilePath, resourceDir, session, options, content) {
785
+ const pendingData = await FileHandler.readJsonData(pendingFilePath);
116
786
  const isApprove = Boolean(options.p);
117
787
  if (options.p === true && content[0] === "all" || options.d === true && content[0] === "all") {
118
- return await handleAudit(ctx2, pendingData, isApprove, caveFilePath2, resourceDir2, pendingFilePath2, session);
788
+ return await handleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session);
119
789
  }
120
790
  const id = parseInt(content[0] || (typeof options.p === "string" ? options.p : "") || (typeof options.d === "string" ? options.d : ""));
121
791
  if (isNaN(id)) {
122
792
  return sendMessage(session, "commands.cave.error.invalidId", [], true);
123
793
  }
124
- return sendMessage(session, await handleAudit(ctx2, pendingData, isApprove, caveFilePath2, resourceDir2, pendingFilePath2, session, id), [], true);
794
+ return sendMessage(session, await handleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session, id), [], true);
125
795
  }
126
796
  __name(processAudit, "processAudit");
127
- async function processView(caveFilePath2, resourceDir2, session, options, content, config2) {
128
- if (!await checkCooldown(session, config2)) {
129
- return "";
130
- }
797
+ async function processView(caveFilePath, resourceDir, session, options, content) {
131
798
  const caveId = parseInt(content[0] || (typeof options.g === "string" ? options.g : ""));
132
799
  if (isNaN(caveId)) return sendMessage(session, "commands.cave.error.invalidId", [], true);
133
- const data = await FileHandler.readJsonData(caveFilePath2);
800
+ const data = await FileHandler.readJsonData(caveFilePath);
134
801
  const cave = data.find((item) => item.cave_id === caveId);
135
802
  if (!cave) return sendMessage(session, "commands.cave.error.notFound", [], true);
136
- return buildMessage(cave, resourceDir2, session);
803
+ return buildMessage(cave, resourceDir, session);
137
804
  }
138
805
  __name(processView, "processView");
139
- async function processRandom(caveFilePath2, resourceDir2, session, config2) {
140
- const data = await FileHandler.readJsonData(caveFilePath2);
806
+ async function processRandom(caveFilePath, resourceDir, session) {
807
+ const data = await FileHandler.readJsonData(caveFilePath);
141
808
  if (data.length === 0) {
142
809
  return sendMessage(session, "commands.cave.error.noCave", [], true);
143
810
  }
144
- if (!await checkCooldown(session, config2)) {
145
- return "";
146
- }
147
811
  const cave = (() => {
148
812
  const validCaves = data.filter((cave2) => cave2.elements && cave2.elements.length > 0);
149
813
  if (!validCaves.length) return void 0;
150
814
  const randomIndex = Math.floor(Math.random() * validCaves.length);
151
815
  return validCaves[randomIndex];
152
816
  })();
153
- return cave ? buildMessage(cave, resourceDir2, session) : sendMessage(session, "commands.cave.error.getCave", [], true);
817
+ return cave ? buildMessage(cave, resourceDir, session) : sendMessage(session, "commands.cave.error.getCave", [], true);
154
818
  }
155
819
  __name(processRandom, "processRandom");
156
- async function processDelete(caveFilePath2, resourceDir2, pendingFilePath2, session, config2, options, content) {
820
+ async function processDelete(caveFilePath, resourceDir, pendingFilePath, session, config2, options, content) {
157
821
  const caveId = parseInt(content[0] || (typeof options.r === "string" ? options.r : ""));
158
822
  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);
823
+ const data = await FileHandler.readJsonData(caveFilePath);
824
+ const pendingData = await FileHandler.readJsonData(pendingFilePath);
161
825
  const targetInData = data.find((item) => item.cave_id === caveId);
162
826
  const targetInPending = pendingData.find((item) => item.cave_id === caveId);
163
827
  if (!targetInData && !targetInPending) {
@@ -168,23 +832,26 @@ async function apply(ctx, config) {
168
832
  if (targetCave.contributor_number !== session.userId && !config2.manager.includes(session.userId)) {
169
833
  return sendMessage(session, "commands.cave.remove.noPermission", [], true);
170
834
  }
171
- const caveContent = await buildMessage(targetCave, resourceDir2, session);
835
+ const caveContent = await buildMessage(targetCave, resourceDir, session);
172
836
  if (targetCave.elements) {
837
+ const hashStorage2 = new HashStorage(caveDir);
838
+ await hashStorage2.initialize();
839
+ await hashStorage2.updateCaveHash(caveId);
173
840
  for (const element of targetCave.elements) {
174
841
  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);
842
+ const fullPath = path4.join(resourceDir, element.file);
843
+ if (fs4.existsSync(fullPath)) {
844
+ await fs4.promises.unlink(fullPath);
178
845
  }
179
846
  }
180
847
  }
181
848
  }
182
849
  if (isPending) {
183
850
  const newPendingData = pendingData.filter((item) => item.cave_id !== caveId);
184
- await FileHandler.writeJsonData(pendingFilePath2, newPendingData);
851
+ await FileHandler.writeJsonData(pendingFilePath, newPendingData);
185
852
  } else {
186
853
  const newData = data.filter((item) => item.cave_id !== caveId);
187
- await FileHandler.writeJsonData(caveFilePath2, newData);
854
+ await FileHandler.writeJsonData(caveFilePath, newData);
188
855
  await idManager.removeStat(targetCave.contributor_number, caveId);
189
856
  }
190
857
  await idManager.markDeleted(caveId);
@@ -193,7 +860,7 @@ async function apply(ctx, config) {
193
860
  return `${deleteMessage}${deleteStatus}${caveContent}`;
194
861
  }
195
862
  __name(processDelete, "processDelete");
196
- async function processAdd(ctx2, config2, caveFilePath2, resourceDir2, pendingFilePath2, session, content) {
863
+ async function processAdd(ctx2, config2, caveFilePath, resourceDir, pendingFilePath, session, content) {
197
864
  try {
198
865
  const inputContent = content.length > 0 ? content.join("\n") : await (async () => {
199
866
  await sendMessage(session, "commands.cave.add.noContent", [], true);
@@ -214,18 +881,20 @@ async function apply(ctx, config) {
214
881
  imageUrls.length > 0 ? saveMedia(
215
882
  imageUrls,
216
883
  imageElements.map((el) => el.fileName),
217
- resourceDir2,
884
+ resourceDir,
218
885
  caveId,
219
886
  "img",
887
+ config2,
220
888
  ctx2,
221
889
  session
222
890
  ) : [],
223
891
  videoUrls.length > 0 ? saveMedia(
224
892
  videoUrls,
225
893
  videoElements.map((el) => el.fileName),
226
- resourceDir2,
894
+ resourceDir,
227
895
  caveId,
228
896
  "video",
897
+ config2,
229
898
  ctx2,
230
899
  session
231
900
  ) : []
@@ -240,7 +909,7 @@ async function apply(ctx, config) {
240
909
  // 保持原始文本和图片的相对位置
241
910
  index: el.index
242
911
  }))
243
- ].sort((a, b) => a.index - b.index),
912
+ ].sort((a, b) => a.index - a.index),
244
913
  contributor_number: session.userId,
245
914
  contributor_name: session.username
246
915
  };
@@ -252,30 +921,44 @@ async function apply(ctx, config) {
252
921
  // 确保视频总是在最后
253
922
  });
254
923
  }
924
+ const hashStorage2 = new HashStorage(path4.join(ctx2.baseDir, "data", "cave"));
925
+ await hashStorage2.initialize();
926
+ const hashStatus = await hashStorage2.getStatus();
927
+ if (!hashStatus.lastUpdated || hashStatus.entries.length === 0) {
928
+ const existingData = await FileHandler.readJsonData(caveFilePath);
929
+ const hasImages = existingData.some(
930
+ (cave) => cave.elements?.some((element) => element.type === "img" && element.file)
931
+ );
932
+ if (hasImages) {
933
+ await hashStorage2.updateAllCaves(true);
934
+ }
935
+ }
255
936
  if (config2.enableAudit && !bypassAudit) {
256
- const pendingData = await FileHandler.readJsonData(pendingFilePath2);
937
+ const pendingData = await FileHandler.readJsonData(pendingFilePath);
257
938
  pendingData.push(newCave);
258
939
  await Promise.all([
259
- FileHandler.writeJsonData(pendingFilePath2, pendingData),
260
- sendAuditMessage(ctx2, config2, newCave, await buildMessage(newCave, resourceDir2, session), session)
940
+ FileHandler.writeJsonData(pendingFilePath, pendingData),
941
+ sendAuditMessage(ctx2, config2, newCave, await buildMessage(newCave, resourceDir, session), session)
261
942
  ]);
262
943
  return sendMessage(session, "commands.cave.add.submitPending", [caveId], false);
263
944
  }
264
- const data = await FileHandler.readJsonData(caveFilePath2);
945
+ const data = await FileHandler.readJsonData(caveFilePath);
265
946
  data.push({
266
947
  ...newCave,
267
948
  elements: cleanElementsForSave(newCave.elements, false)
268
949
  });
269
- await FileHandler.writeJsonData(caveFilePath2, data);
950
+ await Promise.all([
951
+ FileHandler.writeJsonData(caveFilePath, data),
952
+ hashStorage2.updateCaveHash(caveId)
953
+ ]);
270
954
  await idManager.addStat(session.userId, caveId);
271
955
  return sendMessage(session, "commands.cave.add.addSuccess", [caveId], false);
272
956
  } catch (error) {
273
- logger.error(`Failed to process add command: ${error.message}`);
274
- return sendMessage(session, `commands.cave.error.${error.code || "unknown"}`, [], true);
957
+ logger4.error(`Failed to process add command: ${error.message}`);
275
958
  }
276
959
  }
277
960
  __name(processAdd, "processAdd");
278
- async function handleAudit(ctx2, pendingData, isApprove, caveFilePath2, resourceDir2, pendingFilePath2, session, targetId) {
961
+ async function handleAudit(pendingData, isApprove, caveFilePath, resourceDir, pendingFilePath, session, targetId) {
279
962
  if (pendingData.length === 0) {
280
963
  return sendMessage(session, "commands.cave.audit.noPending", [], true);
281
964
  }
@@ -286,36 +969,34 @@ async function apply(ctx, config) {
286
969
  }
287
970
  const newPendingData = pendingData.filter((item) => item.cave_id !== targetId);
288
971
  if (isApprove) {
289
- const oldCaveData = await FileHandler.readJsonData(caveFilePath2);
972
+ const oldCaveData = await FileHandler.readJsonData(caveFilePath);
290
973
  const newCaveData = [...oldCaveData, {
291
974
  ...targetCave,
292
975
  cave_id: targetId,
293
- // 明确指定ID
294
- // 保存到 cave.json 时移除 index
295
976
  elements: cleanElementsForSave(targetCave.elements, false)
296
977
  }];
297
978
  await FileHandler.withTransaction([
298
979
  {
299
- filePath: caveFilePath2,
300
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath2, newCaveData), "operation"),
301
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath2, oldCaveData), "rollback")
980
+ filePath: caveFilePath,
981
+ operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, newCaveData), "operation"),
982
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldCaveData), "rollback")
302
983
  },
303
984
  {
304
- filePath: pendingFilePath2,
305
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, newPendingData), "operation"),
306
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, pendingData), "rollback")
985
+ filePath: pendingFilePath,
986
+ operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, newPendingData), "operation"),
987
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback")
307
988
  }
308
989
  ]);
309
990
  await idManager.addStat(targetCave.contributor_number, targetId);
310
991
  } else {
311
- await FileHandler.writeJsonData(pendingFilePath2, newPendingData);
992
+ await FileHandler.writeJsonData(pendingFilePath, newPendingData);
312
993
  await idManager.markDeleted(targetId);
313
994
  if (targetCave.elements) {
314
995
  for (const element of targetCave.elements) {
315
996
  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);
997
+ const fullPath = path4.join(resourceDir, element.file);
998
+ if (fs4.existsSync(fullPath)) {
999
+ await fs4.promises.unlink(fullPath);
319
1000
  }
320
1001
  }
321
1002
  }
@@ -338,48 +1019,46 @@ async function apply(ctx, config) {
338
1019
  false
339
1020
  );
340
1021
  }
341
- const data = isApprove ? await FileHandler.readJsonData(caveFilePath2) : null;
1022
+ const data = isApprove ? await FileHandler.readJsonData(caveFilePath) : null;
342
1023
  let processedCount = 0;
343
1024
  if (isApprove && data) {
344
1025
  const oldData = [...data];
345
1026
  const newData = [...data];
346
1027
  await FileHandler.withTransaction([
347
1028
  {
348
- filePath: caveFilePath2,
1029
+ filePath: caveFilePath,
349
1030
  operation: /* @__PURE__ */ __name(async () => {
350
1031
  for (const cave of pendingData) {
351
1032
  newData.push({
352
1033
  ...cave,
353
1034
  cave_id: cave.cave_id,
354
- // 确保ID保持不变
355
- // 保存到 cave.json 时移除 index
356
1035
  elements: cleanElementsForSave(cave.elements, false)
357
1036
  });
358
1037
  processedCount++;
359
1038
  await idManager.addStat(cave.contributor_number, cave.cave_id);
360
1039
  }
361
- return FileHandler.writeJsonData(caveFilePath2, newData);
1040
+ return FileHandler.writeJsonData(caveFilePath, newData);
362
1041
  }, "operation"),
363
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath2, oldData), "rollback")
1042
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldData), "rollback")
364
1043
  },
365
1044
  {
366
- filePath: pendingFilePath2,
367
- operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, []), "operation"),
368
- rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath2, pendingData), "rollback")
1045
+ filePath: pendingFilePath,
1046
+ operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, []), "operation"),
1047
+ rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(pendingFilePath, pendingData), "rollback")
369
1048
  }
370
1049
  ]);
371
1050
  } else {
372
1051
  for (const cave of pendingData) {
373
1052
  await idManager.markDeleted(cave.cave_id);
374
1053
  }
375
- await FileHandler.writeJsonData(pendingFilePath2, []);
1054
+ await FileHandler.writeJsonData(pendingFilePath, []);
376
1055
  for (const cave of pendingData) {
377
1056
  if (cave.elements) {
378
1057
  for (const element of cave.elements) {
379
1058
  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);
1059
+ const fullPath = path4.join(resourceDir, element.file);
1060
+ if (fs4.existsSync(fullPath)) {
1061
+ await fs4.promises.unlink(fullPath);
383
1062
  }
384
1063
  }
385
1064
  }
@@ -394,20 +1073,6 @@ async function apply(ctx, config) {
394
1073
  ], false);
395
1074
  }
396
1075
  __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
1076
  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
1077
  if (config.blacklist.includes(session.userId)) {
413
1078
  return sendMessage(session, "commands.cave.message.blacklisted", [], true);
@@ -416,11 +1081,23 @@ async function apply(ctx, config) {
416
1081
  return sendMessage(session, "commands.cave.message.managerOnly", [], true);
417
1082
  }
418
1083
  }).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");
1084
+ const dataDir2 = path4.join(ctx.baseDir, "data");
1085
+ const caveDir2 = path4.join(dataDir2, "cave");
1086
+ const caveFilePath = path4.join(caveDir2, "cave.json");
1087
+ const resourceDir = path4.join(caveDir2, "resources");
1088
+ const pendingFilePath = path4.join(caveDir2, "pending.json");
1089
+ const needsCooldown = !options.l && !options.a && !options.p && !options.d;
1090
+ if (needsCooldown) {
1091
+ const guildId = session.guildId;
1092
+ const now = Date.now();
1093
+ const lastTime = lastUsed.get(guildId) || 0;
1094
+ const isManager = config.manager.includes(session.userId);
1095
+ if (!isManager && now - lastTime < config.number * 1e3) {
1096
+ const waitTime = Math.ceil((config.number * 1e3 - (now - lastTime)) / 1e3);
1097
+ return sendMessage(session, "commands.cave.message.cooldown", [waitTime], true);
1098
+ }
1099
+ lastUsed.set(guildId, now);
1100
+ }
424
1101
  if (options.l !== void 0) {
425
1102
  const input = typeof options.l === "string" ? options.l : content[0];
426
1103
  const num = parseInt(input);
@@ -440,311 +1117,22 @@ async function apply(ctx, config) {
440
1117
  }
441
1118
  }
442
1119
  if (options.p || options.d) {
443
- return await processAudit(ctx, pendingFilePath2, caveFilePath2, resourceDir2, session, options, content);
1120
+ return await processAudit(pendingFilePath, caveFilePath, resourceDir, session, options, content);
444
1121
  }
445
1122
  if (options.g) {
446
- return await processView(caveFilePath2, resourceDir2, session, options, content, config);
1123
+ return await processView(caveFilePath, resourceDir, session, options, content);
447
1124
  }
448
1125
  if (options.r) {
449
- return await processDelete(caveFilePath2, resourceDir2, pendingFilePath2, session, config, options, content);
1126
+ return await processDelete(caveFilePath, resourceDir, pendingFilePath, session, config, options, content);
450
1127
  }
451
1128
  if (options.a) {
452
- return await processAdd(ctx, config, caveFilePath2, resourceDir2, pendingFilePath2, session, content);
1129
+ return await processAdd(ctx, config, caveFilePath, resourceDir, pendingFilePath, session, content);
453
1130
  }
454
- return await processRandom(caveFilePath2, resourceDir2, session, config);
1131
+ return await processRandom(caveFilePath, resourceDir, session);
455
1132
  });
456
1133
  }
457
1134
  __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
- };
1135
+ var logger4 = new import_koishi4.Logger("cave");
748
1136
  async function sendMessage(session, key, params = [], isTemp = true, timeout = 1e4) {
749
1137
  try {
750
1138
  const msg = await session.send(session.text(key, params));
@@ -753,12 +1141,12 @@ async function sendMessage(session, key, params = [], isTemp = true, timeout = 1
753
1141
  try {
754
1142
  await session.bot.deleteMessage(session.channelId, msg);
755
1143
  } catch (error) {
756
- logger.debug(`Failed to delete temporary message: ${error.message}`);
1144
+ logger4.debug(`Failed to delete temporary message: ${error.message}`);
757
1145
  }
758
1146
  }, timeout);
759
1147
  }
760
1148
  } catch (error) {
761
- logger.error(`Failed to send message: ${error.message}`);
1149
+ logger4.error(`Failed to send message: ${error.message}`);
762
1150
  }
763
1151
  return "";
764
1152
  }
@@ -773,7 +1161,7 @@ ${session.text("commands.cave.audit.from")}${cave.contributor_number}`;
773
1161
  try {
774
1162
  await bot.sendPrivateMessage(managerId, auditMessage);
775
1163
  } catch (error) {
776
- logger.error(session.text("commands.cave.audit.sendFailed", [managerId]));
1164
+ logger4.error(session.text("commands.cave.audit.sendFailed", [managerId]));
777
1165
  }
778
1166
  }
779
1167
  }
@@ -804,7 +1192,7 @@ function cleanElementsForSave(elements, keepIndex = false) {
804
1192
  }
805
1193
  __name(cleanElementsForSave, "cleanElementsForSave");
806
1194
  async function processMediaFile(filePath, type) {
807
- const data = await fs.promises.readFile(filePath).catch(() => null);
1195
+ const data = await fs4.promises.readFile(filePath).catch(() => null);
808
1196
  if (!data) return null;
809
1197
  return `data:${type}/${type === "image" ? "png" : "mp4"};base64,${data.toString("base64")}`;
810
1198
  }
@@ -821,10 +1209,10 @@ async function buildMessage(cave, resourceDir, session) {
821
1209
  session.text("commands.cave.message.contributorSuffix", [cave.contributor_name])
822
1210
  ].join("\n");
823
1211
  await session?.send(basicInfo);
824
- const filePath = path.join(resourceDir, videoElement.file);
1212
+ const filePath = path4.join(resourceDir, videoElement.file);
825
1213
  const base64Data = await processMediaFile(filePath, "video");
826
1214
  if (base64Data && session) {
827
- await session.send((0, import_koishi.h)("video", { src: base64Data }));
1215
+ await session.send((0, import_koishi4.h)("video", { src: base64Data }));
828
1216
  }
829
1217
  return "";
830
1218
  }
@@ -833,10 +1221,10 @@ async function buildMessage(cave, resourceDir, session) {
833
1221
  if (element.type === "text") {
834
1222
  lines.push(element.content);
835
1223
  } else if (element.type === "img" && element.file) {
836
- const filePath = path.join(resourceDir, element.file);
1224
+ const filePath = path4.join(resourceDir, element.file);
837
1225
  const base64Data = await processMediaFile(filePath, "image");
838
1226
  if (base64Data) {
839
- lines.push((0, import_koishi.h)("image", { src: base64Data }));
1227
+ lines.push((0, import_koishi4.h)("image", { src: base64Data }));
840
1228
  }
841
1229
  }
842
1230
  }
@@ -871,7 +1259,6 @@ async function extractMediaContent(originalContent, config, session) {
871
1259
  elements.push({
872
1260
  type,
873
1261
  index: type === "video" ? Number.MAX_SAFE_INTEGER : idx * 3 + 1,
874
- // 视频始终在最后
875
1262
  fileName,
876
1263
  fileSize
877
1264
  });
@@ -886,15 +1273,16 @@ async function extractMediaContent(originalContent, config, session) {
886
1273
  return { imageUrls, imageElements, videoUrls, videoElements, textParts };
887
1274
  }
888
1275
  __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/*" };
1276
+ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config, ctx, session) {
1277
+ const accept = mediaType === "img" ? "image/*" : "video/*";
1278
+ const hashStorage = new HashStorage(path4.join(ctx.baseDir, "data", "cave"));
1279
+ await hashStorage.initialize();
891
1280
  const downloadTasks = urls.map(async (url, i) => {
892
1281
  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);
1282
+ const ext = path4.extname(fileName || url) || (mediaType === "img" ? ".png" : ".mp4");
1283
+ const baseName = path4.basename(fileName || "media", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
1284
+ const finalFileName = `${caveId}_${baseName}${ext}`;
1285
+ const filePath = path4.join(resourceDir, finalFileName);
898
1286
  try {
899
1287
  const response = await ctx.http(decodeURIComponent(url).replace(/&amp;/g, "&"), {
900
1288
  method: "GET",
@@ -907,19 +1295,40 @@ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, ctx, s
907
1295
  }
908
1296
  });
909
1297
  if (!response.data) throw new Error("empty_response");
910
- await FileHandler.saveMediaFile(filePath, Buffer.from(response.data));
1298
+ if (mediaType === "img") {
1299
+ const buffer = Buffer.from(response.data);
1300
+ const result = await hashStorage.findDuplicates([buffer], config.duplicateThreshold);
1301
+ if (result.length > 0 && result[0] !== null) {
1302
+ const duplicate = result[0];
1303
+ const similarity = duplicate.similarity;
1304
+ if (similarity >= config.duplicateThreshold) {
1305
+ const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
1306
+ const data = await FileHandler.readJsonData(caveFilePath);
1307
+ const originalCave = data.find((item) => item.cave_id === duplicate.caveId);
1308
+ if (originalCave) {
1309
+ const message = session.text(
1310
+ "commands.cave.error.duplicateFound",
1311
+ [(similarity * 100).toFixed(1)]
1312
+ );
1313
+ await session.send(message + await buildMessage(originalCave, resourceDir, session));
1314
+ throw new Error("duplicate_found");
1315
+ }
1316
+ }
1317
+ }
1318
+ await FileHandler.saveMediaFile(filePath, buffer);
1319
+ } else {
1320
+ await FileHandler.saveMediaFile(filePath, Buffer.from(response.data));
1321
+ }
911
1322
  return finalFileName;
912
1323
  } catch (error) {
913
- logger.error(`Failed to download media: ${error.message}`);
914
- throw error;
1324
+ if (error.message === "duplicate_found") {
1325
+ throw error;
1326
+ }
1327
+ logger4.error(`Failed to download media: ${error.message}`);
1328
+ throw new Error(session.text(`commands.cave.error.upload${mediaType === "img" ? "Image" : "Video"}Failed`));
915
1329
  }
916
1330
  });
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;
1331
+ return Promise.all(downloadTasks);
923
1332
  }
924
1333
  __name(saveMedia, "saveMedia");
925
1334
  // Annotate the CommonJS export names for ESM import in node: