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