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.d.ts +7 -30
- package/lib/index.js +1045 -427
- package/lib/utils/HashStorage.d.ts +95 -0
- package/lib/utils/ImageHasher.d.ts +81 -0
- package/lib/utils/fileHandler.d.ts +63 -0
- package/lib/utils/idManager.d.ts +69 -0
- package/package.json +8 -2
- package/readme.md +29 -8
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: "管理员",
|
|
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: "
|
|
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
|
|
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 =
|
|
62
|
-
manager:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
enableAudit:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
80
|
-
const caveDir =
|
|
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(
|
|
87
|
-
await FileHandler.ensureJsonFile(
|
|
88
|
-
await FileHandler.ensureJsonFile(
|
|
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
|
-
|
|
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(
|
|
115
|
-
const pendingData = await FileHandler.readJsonData(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
992
|
+
return buildMessage(cave, resourceDir, session);
|
|
137
993
|
}
|
|
138
994
|
__name(processView, "processView");
|
|
139
|
-
async function processRandom(
|
|
140
|
-
const data = await FileHandler.readJsonData(
|
|
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,
|
|
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(
|
|
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(
|
|
160
|
-
const pendingData = await FileHandler.readJsonData(
|
|
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,
|
|
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 =
|
|
176
|
-
if (
|
|
177
|
-
await
|
|
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(
|
|
1040
|
+
await FileHandler.writeJsonData(pendingFilePath, newPendingData);
|
|
185
1041
|
} else {
|
|
186
1042
|
const newData = data.filter((item) => item.cave_id !== caveId);
|
|
187
|
-
await FileHandler.writeJsonData(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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(
|
|
1125
|
+
const pendingData = await FileHandler.readJsonData(pendingFilePath);
|
|
257
1126
|
pendingData.push(newCave);
|
|
258
1127
|
await Promise.all([
|
|
259
|
-
FileHandler.writeJsonData(
|
|
260
|
-
sendAuditMessage(ctx2, config2, newCave, await buildMessage(newCave,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
300
|
-
operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
301
|
-
rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
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:
|
|
305
|
-
operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
306
|
-
rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
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(
|
|
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 =
|
|
317
|
-
if (
|
|
318
|
-
await
|
|
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(
|
|
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:
|
|
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(
|
|
1228
|
+
return FileHandler.writeJsonData(caveFilePath, newData);
|
|
362
1229
|
}, "operation"),
|
|
363
|
-
rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
1230
|
+
rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(caveFilePath, oldData), "rollback")
|
|
364
1231
|
},
|
|
365
1232
|
{
|
|
366
|
-
filePath:
|
|
367
|
-
operation: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
368
|
-
rollback: /* @__PURE__ */ __name(async () => FileHandler.writeJsonData(
|
|
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(
|
|
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 =
|
|
381
|
-
if (
|
|
382
|
-
await
|
|
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 =
|
|
420
|
-
const caveDir2 =
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
const
|
|
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(
|
|
1308
|
+
return await processAudit(pendingFilePath, caveFilePath, resourceDir, session, options, content);
|
|
444
1309
|
}
|
|
445
1310
|
if (options.g) {
|
|
446
|
-
return await processView(
|
|
1311
|
+
return await processView(caveFilePath, resourceDir, session, options, content);
|
|
447
1312
|
}
|
|
448
1313
|
if (options.r) {
|
|
449
|
-
return await processDelete(
|
|
1314
|
+
return await processDelete(caveFilePath, resourceDir, pendingFilePath, session, config, options, content);
|
|
450
1315
|
}
|
|
451
1316
|
if (options.a) {
|
|
452
|
-
return await processAdd(ctx, config,
|
|
1317
|
+
return await processAdd(ctx, config, caveFilePath, resourceDir, pendingFilePath, session, content);
|
|
453
1318
|
}
|
|
454
|
-
return await processRandom(
|
|
1319
|
+
return await processRandom(caveFilePath, resourceDir, session);
|
|
455
1320
|
});
|
|
456
1321
|
}
|
|
457
1322
|
__name(apply, "apply");
|
|
458
|
-
var
|
|
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
|
-
|
|
1332
|
+
logger4.debug(`Failed to delete temporary message: ${error.message}`);
|
|
757
1333
|
}
|
|
758
1334
|
}, timeout);
|
|
759
1335
|
}
|
|
760
1336
|
} catch (error) {
|
|
761
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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 =
|
|
1412
|
+
const filePath = path4.join(resourceDir, element.file);
|
|
837
1413
|
const base64Data = await processMediaFile(filePath, "image");
|
|
838
1414
|
if (base64Data) {
|
|
839
|
-
lines.push((0,
|
|
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
|
|
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
|
|
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(/&/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
|
-
|
|
911
|
-
|
|
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
|
-
|
|
914
|
-
|
|
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
|
-
|
|
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:
|