@forgetmelody/koishi-plugin-best-cave 0.0.0-stage → 1.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 +661 -0
- package/lib/AIManager.d.ts +99 -0
- package/lib/DataManager.d.ts +36 -0
- package/lib/FileManager.d.ts +48 -0
- package/lib/HashManager.d.ts +73 -0
- package/lib/NameManager.d.ts +45 -0
- package/lib/PendManager.d.ts +31 -0
- package/lib/Utils.d.ts +100 -0
- package/lib/index.d.ts +72 -0
- package/lib/index.js +1981 -0
- package/package.json +31 -3
- package/readme.md +125 -0
- package/README.md +0 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1981 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name2 in all)
|
|
10
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
Config: () => Config,
|
|
34
|
+
apply: () => apply,
|
|
35
|
+
inject: () => inject,
|
|
36
|
+
name: () => name,
|
|
37
|
+
usage: () => usage
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(src_exports);
|
|
40
|
+
var import_koishi3 = require("koishi");
|
|
41
|
+
|
|
42
|
+
// src/FileManager.ts
|
|
43
|
+
var import_client_s3 = require("@aws-sdk/client-s3");
|
|
44
|
+
var fs = __toESM(require("fs/promises"));
|
|
45
|
+
var path = __toESM(require("path"));
|
|
46
|
+
var FileManager = class {
|
|
47
|
+
/**
|
|
48
|
+
* @constructor
|
|
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
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
this.s3Bucket = config.bucket;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
static {
|
|
69
|
+
__name(this, "FileManager");
|
|
70
|
+
}
|
|
71
|
+
resourceDir;
|
|
72
|
+
locks = /* @__PURE__ */ new Map();
|
|
73
|
+
s3Client;
|
|
74
|
+
s3Bucket;
|
|
75
|
+
/**
|
|
76
|
+
* @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。
|
|
77
|
+
* @template T 异步操作的返回类型。
|
|
78
|
+
* @param fullPath 需要加锁的文件的完整路径。
|
|
79
|
+
* @param operation 要执行的异步函数。
|
|
80
|
+
* @returns 异步操作的结果。
|
|
81
|
+
*/
|
|
82
|
+
async withLock(fullPath, operation) {
|
|
83
|
+
while (this.locks.has(fullPath)) await this.locks.get(fullPath);
|
|
84
|
+
const promise = operation().finally(() => {
|
|
85
|
+
this.locks.delete(fullPath);
|
|
86
|
+
});
|
|
87
|
+
this.locks.set(fullPath, promise);
|
|
88
|
+
return promise;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* @description 保存文件,自动选择 S3 或本地存储。
|
|
92
|
+
* @param fileName 用作 S3 Key 或本地文件名。
|
|
93
|
+
* @param data 要写入的 Buffer 数据。
|
|
94
|
+
* @returns 保存时使用的文件名。
|
|
95
|
+
*/
|
|
96
|
+
async saveFile(fileName, data) {
|
|
97
|
+
if (this.s3Client) {
|
|
98
|
+
const command = new import_client_s3.PutObjectCommand({
|
|
99
|
+
Bucket: this.s3Bucket,
|
|
100
|
+
Key: fileName,
|
|
101
|
+
Body: data,
|
|
102
|
+
ACL: "public-read"
|
|
103
|
+
});
|
|
104
|
+
await this.s3Client.send(command);
|
|
105
|
+
} else {
|
|
106
|
+
await fs.mkdir(this.resourceDir, { recursive: true }).catch((error) => {
|
|
107
|
+
this.logger.error(`创建资源目录失败 ${this.resourceDir}:`, error);
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
const filePath = path.join(this.resourceDir, fileName);
|
|
111
|
+
await this.withLock(filePath, () => fs.writeFile(filePath, data));
|
|
112
|
+
}
|
|
113
|
+
return fileName;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* @description 读取文件,自动从 S3 或本地存储读取。
|
|
117
|
+
* @param fileName 要读取的文件名/标识符。
|
|
118
|
+
* @returns 文件的 Buffer 数据。
|
|
119
|
+
*/
|
|
120
|
+
async readFile(fileName) {
|
|
121
|
+
if (this.s3Client) {
|
|
122
|
+
const command = new import_client_s3.GetObjectCommand({ Bucket: this.s3Bucket, Key: fileName });
|
|
123
|
+
const response = await this.s3Client.send(command);
|
|
124
|
+
return Buffer.from(await response.Body.transformToByteArray());
|
|
125
|
+
} else {
|
|
126
|
+
const filePath = path.join(this.resourceDir, fileName);
|
|
127
|
+
return this.withLock(filePath, () => fs.readFile(filePath));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* @description 删除文件,自动从 S3 或本地删除。
|
|
132
|
+
* @param fileIdentifier 要删除的文件名/标识符。
|
|
133
|
+
*/
|
|
134
|
+
async deleteFile(fileIdentifier) {
|
|
135
|
+
try {
|
|
136
|
+
if (this.s3Client) {
|
|
137
|
+
await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier }));
|
|
138
|
+
} else {
|
|
139
|
+
const filePath = path.join(this.resourceDir, fileIdentifier);
|
|
140
|
+
await this.withLock(filePath, () => fs.unlink(filePath));
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
|
|
144
|
+
this.logger.warn(`删除文件 ${fileIdentifier} 失败:`, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/NameManager.ts
|
|
151
|
+
var NameManager = class {
|
|
152
|
+
/**
|
|
153
|
+
* @constructor
|
|
154
|
+
* @param ctx - Koishi 上下文,用于初始化数据库模型。
|
|
155
|
+
*/
|
|
156
|
+
constructor(ctx) {
|
|
157
|
+
this.ctx = ctx;
|
|
158
|
+
this.ctx.model.extend("cave_user", {
|
|
159
|
+
userId: "string",
|
|
160
|
+
nickname: "string"
|
|
161
|
+
}, {
|
|
162
|
+
primary: "userId"
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
static {
|
|
166
|
+
__name(this, "NameManager");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* @description 注册 `.name` 子命令,用于管理用户昵称。
|
|
170
|
+
* @param cave - 主 `cave` 命令实例。
|
|
171
|
+
*/
|
|
172
|
+
registerCommands(cave) {
|
|
173
|
+
cave.subcommand(".name [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
|
|
174
|
+
const trimmedNickname = nickname?.trim();
|
|
175
|
+
if (trimmedNickname) {
|
|
176
|
+
await this.setNickname(session.userId, trimmedNickname);
|
|
177
|
+
return `昵称已更新为:${trimmedNickname}`;
|
|
178
|
+
}
|
|
179
|
+
await this.clearNickname(session.userId);
|
|
180
|
+
return "昵称已清除";
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* @description 设置或更新指定用户的昵称。
|
|
185
|
+
* @param userId - 目标用户的 ID。
|
|
186
|
+
* @param nickname - 要设置的新昵称。
|
|
187
|
+
*/
|
|
188
|
+
async setNickname(userId, nickname) {
|
|
189
|
+
await this.ctx.database.upsert("cave_user", [{ userId, nickname }]);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* @description 获取指定用户的昵称。
|
|
193
|
+
* @param userId - 目标用户的 ID。
|
|
194
|
+
* @returns 用户的昵称字符串或 null。
|
|
195
|
+
*/
|
|
196
|
+
async getNickname(userId) {
|
|
197
|
+
const [name2] = await this.ctx.database.get("cave_user", { userId });
|
|
198
|
+
return name2?.nickname ?? null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* @description 清除指定用户的昵称设置。
|
|
202
|
+
* @param userId - 目标用户的 ID。
|
|
203
|
+
*/
|
|
204
|
+
async clearNickname(userId) {
|
|
205
|
+
await this.ctx.database.remove("cave_user", { userId });
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/DataManager.ts
|
|
210
|
+
var DataManager = class {
|
|
211
|
+
/**
|
|
212
|
+
* @constructor
|
|
213
|
+
* @param ctx Koishi 上下文,用于数据库操作。
|
|
214
|
+
* @param config 插件配置。
|
|
215
|
+
* @param fileManager 文件管理器实例。
|
|
216
|
+
* @param logger 日志记录器实例。
|
|
217
|
+
*/
|
|
218
|
+
constructor(ctx, config, fileManager, logger2) {
|
|
219
|
+
this.ctx = ctx;
|
|
220
|
+
this.config = config;
|
|
221
|
+
this.fileManager = fileManager;
|
|
222
|
+
this.logger = logger2;
|
|
223
|
+
}
|
|
224
|
+
static {
|
|
225
|
+
__name(this, "DataManager");
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* @description 注册 `.export` 和 `.import` 子命令。
|
|
229
|
+
* @param cave - 主 `cave` 命令实例。
|
|
230
|
+
*/
|
|
231
|
+
registerCommands(cave) {
|
|
232
|
+
const commandAction = /* @__PURE__ */ __name((action) => async ({ session }) => {
|
|
233
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
234
|
+
try {
|
|
235
|
+
await session.send("正在处理,请稍候...");
|
|
236
|
+
return await action();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
this.logger.error("数据操作时发生错误:", error);
|
|
239
|
+
return `操作失败: ${error.message}`;
|
|
240
|
+
}
|
|
241
|
+
}, "commandAction");
|
|
242
|
+
cave.subcommand(".export", "导出回声洞数据", { hidden: true, authority: 4 }).usage("将所有回声洞数据导出到 cave.json 中。").action(commandAction(() => this.exportData()));
|
|
243
|
+
cave.subcommand(".import", "导入回声洞数据", { hidden: true, authority: 4 }).usage("从 cave.json 中导入回声洞数据。").action(commandAction(() => this.importData()));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* @description 导出所有 'active' 状态的回声洞数据到 `cave.json`。
|
|
247
|
+
* @returns 描述导出结果的消息字符串。
|
|
248
|
+
*/
|
|
249
|
+
async exportData() {
|
|
250
|
+
const fileName = "cave.json";
|
|
251
|
+
const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
|
|
252
|
+
await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(cavesToExport, null, 2)));
|
|
253
|
+
return `成功导出 ${cavesToExport.length} 条数据`;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* @description 从 `cave.json` 文件导入回声洞数据。
|
|
257
|
+
* @returns 描述导入结果的消息字符串。
|
|
258
|
+
*/
|
|
259
|
+
async importData() {
|
|
260
|
+
const fileName = "cave.json";
|
|
261
|
+
let importedCaves;
|
|
262
|
+
try {
|
|
263
|
+
const fileContent = await this.fileManager.readFile(fileName);
|
|
264
|
+
importedCaves = JSON.parse(fileContent.toString("utf-8"));
|
|
265
|
+
if (!Array.isArray(importedCaves) || !importedCaves.length) throw new Error("导入文件格式无效或为空");
|
|
266
|
+
} catch (error) {
|
|
267
|
+
throw new Error(`读取导入文件失败: ${error.message}`);
|
|
268
|
+
}
|
|
269
|
+
const allDbCaves = await this.ctx.database.get("cave", {}, { fields: ["id"] });
|
|
270
|
+
const existingIds = new Set(allDbCaves.map((c) => c.id));
|
|
271
|
+
let maxId = allDbCaves.length > 0 ? Math.max(...allDbCaves.map((c) => c.id)) : 0;
|
|
272
|
+
const nonConflictingCaves = [];
|
|
273
|
+
const conflictingCaves = [];
|
|
274
|
+
let invalidCount = 0;
|
|
275
|
+
for (const importedCave of importedCaves) {
|
|
276
|
+
if (typeof importedCave.id !== "number" || !Array.isArray(importedCave.elements)) {
|
|
277
|
+
this.logger.warn(`回声洞(${importedCave.id})无效: ${JSON.stringify(importedCave)}`);
|
|
278
|
+
invalidCount++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (existingIds.has(importedCave.id)) {
|
|
282
|
+
conflictingCaves.push(importedCave);
|
|
283
|
+
} else {
|
|
284
|
+
nonConflictingCaves.push({ ...importedCave, status: "active" });
|
|
285
|
+
existingIds.add(importedCave.id);
|
|
286
|
+
maxId = Math.max(maxId, importedCave.id);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const newCavesFromConflicts = conflictingCaves.map((cave) => {
|
|
290
|
+
maxId++;
|
|
291
|
+
this.logger.info(`回声洞(${cave.id})已转移至(${maxId})`);
|
|
292
|
+
return { ...cave, id: maxId, status: "active" };
|
|
293
|
+
});
|
|
294
|
+
const finalCavesToUpsert = [...nonConflictingCaves, ...newCavesFromConflicts];
|
|
295
|
+
if (finalCavesToUpsert.length > 0) await this.ctx.database.upsert("cave", finalCavesToUpsert);
|
|
296
|
+
return `成功导入 ${finalCavesToUpsert.length} 条数据`;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// src/PendManager.ts
|
|
301
|
+
var import_koishi2 = require("koishi");
|
|
302
|
+
|
|
303
|
+
// src/Utils.ts
|
|
304
|
+
var import_koishi = require("koishi");
|
|
305
|
+
var path2 = __toESM(require("path"));
|
|
306
|
+
var crypto = __toESM(require("crypto"));
|
|
307
|
+
var import_jimp = __toESM(require("jimp"));
|
|
308
|
+
var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
|
|
309
|
+
function resolveCaveHeaderFooter(cave, config, prefix) {
|
|
310
|
+
const data = {
|
|
311
|
+
id: cave.id.toString(),
|
|
312
|
+
name: cave.userName,
|
|
313
|
+
user: cave.userId,
|
|
314
|
+
channel: cave.channelId,
|
|
315
|
+
time: cave.time.toLocaleString()
|
|
316
|
+
};
|
|
317
|
+
const placeholderRegex = /\{([^}]+)\}/g;
|
|
318
|
+
const replacer = /* @__PURE__ */ __name((match, rawContent) => {
|
|
319
|
+
const isReviewMode = !!prefix;
|
|
320
|
+
const [normalPart, reviewPart] = rawContent.split("/", 2);
|
|
321
|
+
const contentToProcess = isReviewMode ? reviewPart !== void 0 ? reviewPart : normalPart : normalPart;
|
|
322
|
+
if (!contentToProcess?.trim()) return "";
|
|
323
|
+
const useMask = contentToProcess.startsWith("*");
|
|
324
|
+
const key = (useMask ? contentToProcess.substring(1) : contentToProcess).trim();
|
|
325
|
+
if (!key) return "";
|
|
326
|
+
const originalValue = data[key];
|
|
327
|
+
if (originalValue === void 0 || originalValue === null) return match;
|
|
328
|
+
const valueStr = String(originalValue);
|
|
329
|
+
if (!useMask) return valueStr;
|
|
330
|
+
const len = valueStr.length;
|
|
331
|
+
if (len <= 5) return valueStr;
|
|
332
|
+
let keep = 0;
|
|
333
|
+
if (len <= 7) keep = 2;
|
|
334
|
+
else keep = 3;
|
|
335
|
+
return `${valueStr.substring(0, keep)}***${valueStr.substring(len - keep)}`;
|
|
336
|
+
}, "replacer");
|
|
337
|
+
const [rawHeader, rawFooter] = config.caveFormat.split("|", 2);
|
|
338
|
+
let header = rawHeader ? rawHeader.replace(placeholderRegex, replacer).trim() : "";
|
|
339
|
+
if (prefix) header = `${prefix}${header}`;
|
|
340
|
+
const footer = rawFooter ? rawFooter.replace(placeholderRegex, replacer).trim() : "";
|
|
341
|
+
return { header, footer };
|
|
342
|
+
}
|
|
343
|
+
__name(resolveCaveHeaderFooter, "resolveCaveHeaderFooter");
|
|
344
|
+
async function buildCaveMessage(cave, config, fileManager, logger2, platform, prefix) {
|
|
345
|
+
async function transformToH(elements) {
|
|
346
|
+
return Promise.all(elements.map(async (el) => {
|
|
347
|
+
if (el.type === "text") return import_koishi.h.text(el.content);
|
|
348
|
+
if (el.type === "at") return (0, import_koishi.h)("at", { id: el.content });
|
|
349
|
+
if (el.type === "reply") return (0, import_koishi.h)("reply", { id: el.content });
|
|
350
|
+
if (el.type === "face") return (0, import_koishi.h)("face", { id: el.content });
|
|
351
|
+
if (el.type === "forward") {
|
|
352
|
+
try {
|
|
353
|
+
const forwardNodes = Array.isArray(el.content) ? el.content : [];
|
|
354
|
+
const messageNodes = await Promise.all(forwardNodes.map(async (node) => {
|
|
355
|
+
const author = (0, import_koishi.h)("author", { id: node.userId, name: node.userName });
|
|
356
|
+
const contentElements = await transformToH(node.elements);
|
|
357
|
+
const unwrappedContent = [];
|
|
358
|
+
const nestedMessageNodes = [];
|
|
359
|
+
for (const contentEl of contentElements) {
|
|
360
|
+
if (contentEl.type === "message" && contentEl.attrs.forward) {
|
|
361
|
+
nestedMessageNodes.push(...contentEl.children);
|
|
362
|
+
} else {
|
|
363
|
+
unwrappedContent.push(contentEl);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const resultNodes = [];
|
|
367
|
+
if (unwrappedContent.length > 0) resultNodes.push((0, import_koishi.h)("message", {}, [author, ...unwrappedContent]));
|
|
368
|
+
resultNodes.push(...nestedMessageNodes);
|
|
369
|
+
return resultNodes;
|
|
370
|
+
}));
|
|
371
|
+
return (0, import_koishi.h)("message", { forward: true }, messageNodes.flat());
|
|
372
|
+
} catch (error) {
|
|
373
|
+
logger2.warn(`解析回声洞(${cave.id})合并转发内容失败:`, error);
|
|
374
|
+
return import_koishi.h.text("[合并转发]");
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (["image", "video", "audio", "file"].includes(el.type)) {
|
|
378
|
+
const fileName = el.file;
|
|
379
|
+
if (!fileName) return (0, import_koishi.h)("p", {}, `[${el.type}]`);
|
|
380
|
+
if (config.enableS3 && config.publicUrl) return (0, import_koishi.h)(el.type, { ...el, src: new URL(fileName, config.publicUrl).href });
|
|
381
|
+
if (config.localPath) return (0, import_koishi.h)(el.type, { ...el, src: `file://${path2.join(config.localPath, fileName)}` });
|
|
382
|
+
try {
|
|
383
|
+
const data = await fileManager.readFile(fileName);
|
|
384
|
+
const mimeType = mimeTypeMap[path2.extname(fileName).toLowerCase()] || "application/octet-stream";
|
|
385
|
+
return (0, import_koishi.h)(el.type, { ...el, src: `data:${mimeType};base64,${data.toString("base64")}` });
|
|
386
|
+
} catch (error) {
|
|
387
|
+
logger2.warn(`转换文件 ${fileName} 为 Base64 失败:`, error);
|
|
388
|
+
return (0, import_koishi.h)("p", {}, `[${el.type}]`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
})).then((hElements) => hElements.flat().filter(Boolean));
|
|
393
|
+
}
|
|
394
|
+
__name(transformToH, "transformToH");
|
|
395
|
+
const caveHElements = await transformToH(cave.elements);
|
|
396
|
+
const { header, footer } = resolveCaveHeaderFooter(cave, config, prefix);
|
|
397
|
+
const problematicTypes = ["video", "audio", "file", "forward"];
|
|
398
|
+
const placeholderMap = { video: "[视频]", audio: "[音频]", file: "[文件]", forward: "[合并转发]" };
|
|
399
|
+
const containsProblematic = platform === "onebot" && caveHElements.some((el) => problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward);
|
|
400
|
+
if (!containsProblematic) {
|
|
401
|
+
const finalMessage = [];
|
|
402
|
+
if (header) finalMessage.push(header + "\n");
|
|
403
|
+
finalMessage.push(...caveHElements);
|
|
404
|
+
if (footer) finalMessage.push("\n" + footer);
|
|
405
|
+
return [finalMessage.length > 0 ? finalMessage : []];
|
|
406
|
+
}
|
|
407
|
+
const initialMessageContent = [];
|
|
408
|
+
const followUpMessages = [];
|
|
409
|
+
for (const el of caveHElements) {
|
|
410
|
+
if (problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward) {
|
|
411
|
+
const placeholderKey = el.type === "message" && el.attrs.forward ? "forward" : el.type;
|
|
412
|
+
initialMessageContent.push(import_koishi.h.text(placeholderMap[placeholderKey]));
|
|
413
|
+
followUpMessages.push([el]);
|
|
414
|
+
} else {
|
|
415
|
+
initialMessageContent.push(el);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const finalInitialMessage = [];
|
|
419
|
+
if (header) finalInitialMessage.push(header + "\n");
|
|
420
|
+
finalInitialMessage.push(...initialMessageContent);
|
|
421
|
+
if (footer) finalInitialMessage.push("\n" + footer);
|
|
422
|
+
return [finalInitialMessage, ...followUpMessages].filter((msg) => msg.length > 0);
|
|
423
|
+
}
|
|
424
|
+
__name(buildCaveMessage, "buildCaveMessage");
|
|
425
|
+
var qqImageCache = /* @__PURE__ */ new Map();
|
|
426
|
+
var qqImageTypes = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
427
|
+
async function uploadQQImage(session, buffer, fileName) {
|
|
428
|
+
const contentType = qqImageTypes[path2.extname(fileName).toLowerCase()];
|
|
429
|
+
if (!contentType || buffer.length > 10 * 1024 * 1024) return null;
|
|
430
|
+
const cached = qqImageCache.get(`${session.bot.selfId}:${fileName}`);
|
|
431
|
+
if (cached) return cached;
|
|
432
|
+
const bot = session.bot;
|
|
433
|
+
const request = bot.internal.http();
|
|
434
|
+
const md5 = crypto.createHash("md5").update(buffer).digest("hex");
|
|
435
|
+
const sha1 = crypto.createHash("sha1").update(buffer).digest("hex");
|
|
436
|
+
const prepared = await request("/v2/media-images/prepare", {
|
|
437
|
+
method: "POST",
|
|
438
|
+
data: { file_name: fileName, file_size: buffer.length, content_type: contentType, md5, sha1 }
|
|
439
|
+
});
|
|
440
|
+
await bot.ctx.http.put(prepared.presigned_url, buffer, { headers: { "Content-Type": contentType } });
|
|
441
|
+
const completed = await request("/v2/media-images/complete", {
|
|
442
|
+
method: "POST",
|
|
443
|
+
data: { upload_id: prepared.upload_id, md5 }
|
|
444
|
+
});
|
|
445
|
+
if (!completed?.url) return null;
|
|
446
|
+
qqImageCache.set(`${session.bot.selfId}:${fileName}`, completed.url);
|
|
447
|
+
return completed.url;
|
|
448
|
+
}
|
|
449
|
+
__name(uploadQQImage, "uploadQQImage");
|
|
450
|
+
async function getImageSize(buffer) {
|
|
451
|
+
try {
|
|
452
|
+
const image = await import_jimp.default.read(buffer);
|
|
453
|
+
return { width: image.bitmap.width, height: image.bitmap.height };
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
__name(getImageSize, "getImageSize");
|
|
459
|
+
async function buildQQCaveMarkdown(cave, config, fileManager, logger2, session, prefix) {
|
|
460
|
+
const { header, footer } = resolveCaveHeaderFooter(cave, config, prefix);
|
|
461
|
+
const lines = [];
|
|
462
|
+
if (header) lines.push(header);
|
|
463
|
+
for (const el of cave.elements) {
|
|
464
|
+
if (el.type === "text") {
|
|
465
|
+
if (el.content) lines.push(el.content);
|
|
466
|
+
} else if (el.type === "at") {
|
|
467
|
+
if (!session.isDirect && el.content) lines.push(`<qqbot-at-user id="${el.content}" />`);
|
|
468
|
+
} else if (el.type === "image") {
|
|
469
|
+
const fileName = el.file;
|
|
470
|
+
if (!fileName) {
|
|
471
|
+
lines.push("[图片]");
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
let buffer;
|
|
475
|
+
try {
|
|
476
|
+
buffer = await fileManager.readFile(fileName);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
logger2.warn(`读取图片 ${fileName} 失败:`, error);
|
|
479
|
+
}
|
|
480
|
+
let url = null;
|
|
481
|
+
if (config.enableS3 && config.publicUrl) {
|
|
482
|
+
url = new URL(fileName, config.publicUrl).href;
|
|
483
|
+
} else if (buffer) {
|
|
484
|
+
try {
|
|
485
|
+
url = await uploadQQImage(session, buffer, fileName);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
logger2.warn(`上传图片 ${fileName} 到 QQ 素材库失败:`, error);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (!url) {
|
|
491
|
+
lines.push("[图片]");
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const size = buffer ? await getImageSize(buffer) : null;
|
|
495
|
+
lines.push(size ? `` : ``);
|
|
496
|
+
} else if (el.type === "video") {
|
|
497
|
+
lines.push("[视频]");
|
|
498
|
+
} else if (el.type === "audio") {
|
|
499
|
+
lines.push("[音频]");
|
|
500
|
+
} else if (el.type === "file") {
|
|
501
|
+
lines.push("[文件]");
|
|
502
|
+
} else if (el.type === "forward") {
|
|
503
|
+
lines.push("[合并转发]");
|
|
504
|
+
} else if (el.type === "face") {
|
|
505
|
+
lines.push("[表情]");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (footer) lines.push(footer);
|
|
509
|
+
return lines.join("\n");
|
|
510
|
+
}
|
|
511
|
+
__name(buildQQCaveMarkdown, "buildQQCaveMarkdown");
|
|
512
|
+
async function sendQQCaveMarkdown(session, content) {
|
|
513
|
+
const bot = session.bot;
|
|
514
|
+
const s = session;
|
|
515
|
+
const data = {
|
|
516
|
+
msg_type: 2,
|
|
517
|
+
markdown: { content: content || " " },
|
|
518
|
+
keyboard: {
|
|
519
|
+
content: {
|
|
520
|
+
rows: [{
|
|
521
|
+
buttons: [{
|
|
522
|
+
render_data: { label: "再来一个cave", style: 1 },
|
|
523
|
+
action: { type: 2, permission: { type: 2 }, data: "cave", enter: true }
|
|
524
|
+
}]
|
|
525
|
+
}]
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
const passive = Date.now() - s.timestamp < 5 * 60 * 1e3;
|
|
530
|
+
if (passive && s.messageId) {
|
|
531
|
+
s.seq = (s.seq || 0) + 1;
|
|
532
|
+
data.msg_id = s.messageId;
|
|
533
|
+
data.msg_seq = s.seq;
|
|
534
|
+
} else if (passive && s.qq?.id) {
|
|
535
|
+
data.event_id = s.qq.id;
|
|
536
|
+
}
|
|
537
|
+
if (s.isDirect) {
|
|
538
|
+
await bot.internal.sendPrivateMessage(s.channelId, data);
|
|
539
|
+
} else {
|
|
540
|
+
await bot.internal.sendMessage(s.channelId, data);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
__name(sendQQCaveMarkdown, "sendQQCaveMarkdown");
|
|
544
|
+
async function sendCave(session, cave, config, fileManager, logger2, prefix) {
|
|
545
|
+
if (session.platform === "qq") {
|
|
546
|
+
try {
|
|
547
|
+
const content = await buildQQCaveMarkdown(cave, config, fileManager, logger2, session, prefix);
|
|
548
|
+
await sendQQCaveMarkdown(session, content);
|
|
549
|
+
return;
|
|
550
|
+
} catch (error) {
|
|
551
|
+
logger2.warn(`以 QQ Markdown 发送回声洞(${cave.id})失败,回退到普通消息:`, error);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const messages = await buildCaveMessage(cave, config, fileManager, logger2, session.platform, prefix);
|
|
555
|
+
for (const message of messages) if (message.length > 0) await session.send(import_koishi.h.normalize(message));
|
|
556
|
+
}
|
|
557
|
+
__name(sendCave, "sendCave");
|
|
558
|
+
function parseCaveIds(inputs) {
|
|
559
|
+
const tokens = inputs.flatMap((item) => `${item}`.split(/[|\s,,、]+/)).filter(Boolean);
|
|
560
|
+
const ids = tokens.filter((token) => /^[1-9]\d*$/.test(token)).map(Number);
|
|
561
|
+
const invalid = tokens.filter((token) => !/^[1-9]\d*$/.test(token));
|
|
562
|
+
return { ids: [...new Set(ids)], invalid };
|
|
563
|
+
}
|
|
564
|
+
__name(parseCaveIds, "parseCaveIds");
|
|
565
|
+
async function getNextCaveId(ctx, reusableIds) {
|
|
566
|
+
for (const id of reusableIds) {
|
|
567
|
+
if (id > 0) {
|
|
568
|
+
reusableIds.delete(id);
|
|
569
|
+
return id;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (reusableIds.has(0)) {
|
|
573
|
+
reusableIds.delete(0);
|
|
574
|
+
const [lastCave] = await ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
|
|
575
|
+
const newId2 = (lastCave?.id || 0) + 1;
|
|
576
|
+
reusableIds.add(0);
|
|
577
|
+
return newId2;
|
|
578
|
+
}
|
|
579
|
+
const allCaveIds = (await ctx.database.get("cave", {}, { fields: ["id"] })).map((c) => c.id);
|
|
580
|
+
const existingIds = new Set(allCaveIds);
|
|
581
|
+
let newId = 1;
|
|
582
|
+
while (existingIds.has(newId)) newId++;
|
|
583
|
+
if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) reusableIds.add(0);
|
|
584
|
+
return newId;
|
|
585
|
+
}
|
|
586
|
+
__name(getNextCaveId, "getNextCaveId");
|
|
587
|
+
async function processMessageElements(sourceElements, newId, session, creationTime) {
|
|
588
|
+
const mediaToSave = [];
|
|
589
|
+
const urlToFileMap = /* @__PURE__ */ new Map();
|
|
590
|
+
let mediaIndex = 0;
|
|
591
|
+
const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply", "face": "face" };
|
|
592
|
+
const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
|
|
593
|
+
async function transform(elements) {
|
|
594
|
+
const result = [];
|
|
595
|
+
async function processForwardContent(segments) {
|
|
596
|
+
const innerResult = [];
|
|
597
|
+
for (const segment of segments) {
|
|
598
|
+
const sType = typeMap[segment.type];
|
|
599
|
+
if (!sType) continue;
|
|
600
|
+
if (sType === "text" && segment.data?.text?.trim()) {
|
|
601
|
+
innerResult.push({ type: "text", content: segment.data.text.trim() });
|
|
602
|
+
} else if (sType === "at" && (segment.data?.id || segment.data?.qq)) {
|
|
603
|
+
innerResult.push({ type: "at", content: segment.data.id || segment.data.qq });
|
|
604
|
+
} else if (sType === "reply" && segment.data?.id) {
|
|
605
|
+
innerResult.push({ type: "reply", content: segment.data.id });
|
|
606
|
+
} else if (["image", "video", "audio", "file"].includes(sType) && (segment.data?.src || segment.data?.url)) {
|
|
607
|
+
let fileIdentifier = segment.data.src || segment.data.url;
|
|
608
|
+
if (fileIdentifier.startsWith("http")) {
|
|
609
|
+
if (urlToFileMap.has(fileIdentifier)) {
|
|
610
|
+
fileIdentifier = urlToFileMap.get(fileIdentifier);
|
|
611
|
+
} else {
|
|
612
|
+
const ext = path2.extname(segment.data.file || "") || defaultExtMap[sType];
|
|
613
|
+
const currentMediaIndex = ++mediaIndex;
|
|
614
|
+
const newFileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}_${creationTime.getTime()}${ext}`;
|
|
615
|
+
mediaToSave.push({ sourceUrl: fileIdentifier, fileName: newFileName });
|
|
616
|
+
urlToFileMap.set(fileIdentifier, newFileName);
|
|
617
|
+
fileIdentifier = newFileName;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
innerResult.push({ type: sType, file: fileIdentifier });
|
|
621
|
+
} else if (sType === "forward" && Array.isArray(segment.data?.content)) {
|
|
622
|
+
const nestedForwardNodes = [];
|
|
623
|
+
for (const nestedNode of segment.data.content) {
|
|
624
|
+
if (!nestedNode.message || !Array.isArray(nestedNode.message)) continue;
|
|
625
|
+
const nestedContentElements = await processForwardContent(nestedNode.message);
|
|
626
|
+
if (nestedContentElements.length > 0) {
|
|
627
|
+
nestedForwardNodes.push({ userId: nestedNode.sender?.user_id, userName: nestedNode.sender?.nickname, elements: nestedContentElements });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (nestedForwardNodes.length > 0) innerResult.push({ type: "forward", content: nestedForwardNodes });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return innerResult;
|
|
634
|
+
}
|
|
635
|
+
__name(processForwardContent, "processForwardContent");
|
|
636
|
+
for (const el of elements) {
|
|
637
|
+
const type = typeMap[el.type];
|
|
638
|
+
if (!type) {
|
|
639
|
+
if (el.children) result.push(...await transform(el.children));
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (type === "text" && el.attrs.content?.trim()) {
|
|
643
|
+
result.push({ type: "text", content: el.attrs.content.trim() });
|
|
644
|
+
} else if (type === "at" && el.attrs.id) {
|
|
645
|
+
result.push({ type: "at", content: el.attrs.id });
|
|
646
|
+
} else if (type === "reply" && el.attrs.id) {
|
|
647
|
+
result.push({ type: "reply", content: el.attrs.id });
|
|
648
|
+
} else if (type === "forward" && Array.isArray(el.attrs.content)) {
|
|
649
|
+
const forwardNodes = [];
|
|
650
|
+
for (const node of el.attrs.content) {
|
|
651
|
+
if (!node.message || !Array.isArray(node.message)) continue;
|
|
652
|
+
const contentElements = await processForwardContent(node.message);
|
|
653
|
+
if (contentElements.length > 0) {
|
|
654
|
+
forwardNodes.push({ userId: node.sender?.user_id, userName: node.sender?.nickname, elements: contentElements });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (forwardNodes.length > 0) result.push({ type: "forward", content: forwardNodes });
|
|
658
|
+
} else if (["image", "video", "audio", "file"].includes(type) && el.attrs.src) {
|
|
659
|
+
let fileIdentifier = el.attrs.src;
|
|
660
|
+
if (fileIdentifier.startsWith("http")) {
|
|
661
|
+
if (urlToFileMap.has(fileIdentifier)) {
|
|
662
|
+
fileIdentifier = urlToFileMap.get(fileIdentifier);
|
|
663
|
+
} else {
|
|
664
|
+
const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type];
|
|
665
|
+
const currentMediaIndex = ++mediaIndex;
|
|
666
|
+
const newFileName = `${newId}-${currentMediaIndex}_${session.channelId}-${session.userId}_${creationTime.getTime()}${ext}`;
|
|
667
|
+
mediaToSave.push({ sourceUrl: fileIdentifier, fileName: newFileName });
|
|
668
|
+
urlToFileMap.set(fileIdentifier, newFileName);
|
|
669
|
+
fileIdentifier = newFileName;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
result.push({ type, file: fileIdentifier });
|
|
673
|
+
} else if (type === "face" && el.attrs.id) {
|
|
674
|
+
result.push({ type: "face", content: el.attrs.id });
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
__name(transform, "transform");
|
|
680
|
+
const finalElementsForDb = await transform(sourceElements);
|
|
681
|
+
return { finalElementsForDb, mediaToSave };
|
|
682
|
+
}
|
|
683
|
+
__name(processMessageElements, "processMessageElements");
|
|
684
|
+
function clusterItemsFromPairs(pairs) {
|
|
685
|
+
const parent = /* @__PURE__ */ new Map();
|
|
686
|
+
const allIds = /* @__PURE__ */ new Set();
|
|
687
|
+
const find = /* @__PURE__ */ __name((i) => {
|
|
688
|
+
if (!parent.has(i)) {
|
|
689
|
+
parent.set(i, i);
|
|
690
|
+
return i;
|
|
691
|
+
}
|
|
692
|
+
if (parent.get(i) === i) return i;
|
|
693
|
+
const root = find(parent.get(i));
|
|
694
|
+
parent.set(i, root);
|
|
695
|
+
return root;
|
|
696
|
+
}, "find");
|
|
697
|
+
const union = /* @__PURE__ */ __name((i, j) => {
|
|
698
|
+
const rootI = find(i);
|
|
699
|
+
const rootJ = find(j);
|
|
700
|
+
if (rootI !== rootJ) parent.set(rootI, rootJ);
|
|
701
|
+
}, "union");
|
|
702
|
+
for (const [id1, id2] of pairs) {
|
|
703
|
+
union(id1, id2);
|
|
704
|
+
allIds.add(id1);
|
|
705
|
+
allIds.add(id2);
|
|
706
|
+
}
|
|
707
|
+
if (allIds.size === 0) return [];
|
|
708
|
+
const clusterMap = /* @__PURE__ */ new Map();
|
|
709
|
+
allIds.forEach((id) => {
|
|
710
|
+
const root = find(id);
|
|
711
|
+
if (!clusterMap.has(root)) clusterMap.set(root, []);
|
|
712
|
+
clusterMap.get(root).push(id);
|
|
713
|
+
});
|
|
714
|
+
return Array.from(clusterMap.values()).filter((c) => c.length > 1);
|
|
715
|
+
}
|
|
716
|
+
__name(clusterItemsFromPairs, "clusterItemsFromPairs");
|
|
717
|
+
function generateFromLSH(items, getBucketInfo) {
|
|
718
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
719
|
+
items.forEach((item) => {
|
|
720
|
+
const { id, keys } = getBucketInfo(item);
|
|
721
|
+
if (!id || !keys || keys.length === 0) return;
|
|
722
|
+
keys.forEach((key) => {
|
|
723
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
724
|
+
buckets.get(key).push(id);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
const candidatePairs = /* @__PURE__ */ new Set();
|
|
728
|
+
for (const ids of buckets.values()) {
|
|
729
|
+
if (ids.length < 2) continue;
|
|
730
|
+
const uniqueIds = [...new Set(ids)].sort((a, b) => a - b);
|
|
731
|
+
if (uniqueIds.length < 2) continue;
|
|
732
|
+
for (let i = 0; i < uniqueIds.length; i++) {
|
|
733
|
+
for (let j = i + 1; j < uniqueIds.length; j++) {
|
|
734
|
+
const pairKey = `${uniqueIds[i]}-${uniqueIds[j]}`;
|
|
735
|
+
candidatePairs.add(pairKey);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return candidatePairs;
|
|
740
|
+
}
|
|
741
|
+
__name(generateFromLSH, "generateFromLSH");
|
|
742
|
+
async function processNewCave(ctx, config, fileManager, logger2, reusableIds, newCave, session, mediaToSave, hashManager, aiManager, reviewManager) {
|
|
743
|
+
const newId = newCave.id;
|
|
744
|
+
try {
|
|
745
|
+
const initialDownloads = [];
|
|
746
|
+
if (mediaToSave.length > 0) {
|
|
747
|
+
const downloadPromises = mediaToSave.map(async (media) => {
|
|
748
|
+
const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 6e4 }));
|
|
749
|
+
return { candidateFile: media.fileName, buffer };
|
|
750
|
+
});
|
|
751
|
+
initialDownloads.push(...await Promise.all(downloadPromises));
|
|
752
|
+
}
|
|
753
|
+
const mediaForProcessing = [];
|
|
754
|
+
const fileRemapping = /* @__PURE__ */ new Map();
|
|
755
|
+
const canonicalFilesToSave = /* @__PURE__ */ new Map();
|
|
756
|
+
if (hashManager) {
|
|
757
|
+
const hashToCanonicalFile = /* @__PURE__ */ new Map();
|
|
758
|
+
const hashableExtensions = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif"];
|
|
759
|
+
const sanitizableExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"];
|
|
760
|
+
for (const { candidateFile, buffer } of initialDownloads) {
|
|
761
|
+
const ext = path2.extname(candidateFile).toLowerCase();
|
|
762
|
+
let finalBuffer = buffer;
|
|
763
|
+
let hash;
|
|
764
|
+
if (hashableExtensions.includes(ext)) {
|
|
765
|
+
try {
|
|
766
|
+
hash = await hashManager.generatePHash(finalBuffer);
|
|
767
|
+
} catch {
|
|
768
|
+
if (sanitizableExtensions.includes(ext)) {
|
|
769
|
+
try {
|
|
770
|
+
const sanitized = hashManager.sanitizeImageBuffer(finalBuffer);
|
|
771
|
+
if (!sanitized.equals(finalBuffer)) {
|
|
772
|
+
finalBuffer = sanitized;
|
|
773
|
+
hash = await hashManager.generatePHash(finalBuffer);
|
|
774
|
+
}
|
|
775
|
+
} catch (e) {
|
|
776
|
+
logger2.warn(`图片修复失败 (${candidateFile}): ${e.message}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (hash && hashToCanonicalFile.has(hash)) {
|
|
782
|
+
fileRemapping.set(candidateFile, hashToCanonicalFile.get(hash));
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (hash) hashToCanonicalFile.set(hash, candidateFile);
|
|
786
|
+
canonicalFilesToSave.set(candidateFile, finalBuffer);
|
|
787
|
+
fileRemapping.set(candidateFile, candidateFile);
|
|
788
|
+
mediaForProcessing.push({ fileName: candidateFile, buffer: finalBuffer, hash });
|
|
789
|
+
}
|
|
790
|
+
newCave.elements.forEach((el) => {
|
|
791
|
+
if (el.file && fileRemapping.has(el.file)) el.file = fileRemapping.get(el.file);
|
|
792
|
+
});
|
|
793
|
+
} else {
|
|
794
|
+
initialDownloads.forEach((d) => {
|
|
795
|
+
canonicalFilesToSave.set(d.candidateFile, d.buffer);
|
|
796
|
+
mediaForProcessing.push({ fileName: d.candidateFile, buffer: d.buffer });
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
let textHashesToStore = [];
|
|
800
|
+
let imageHashesToStore = [];
|
|
801
|
+
if (config.enableSimilarity && hashManager) {
|
|
802
|
+
try {
|
|
803
|
+
const combinedText = newCave.elements.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
|
|
804
|
+
if (combinedText) {
|
|
805
|
+
const newSimhash = hashManager.generateTextSimhash(combinedText);
|
|
806
|
+
if (newSimhash) {
|
|
807
|
+
const existingTextHashes = await ctx.database.get("cave_hash", { type: "text" });
|
|
808
|
+
for (const existing of existingTextHashes) {
|
|
809
|
+
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
|
|
810
|
+
if (similarity >= config.textThreshold) {
|
|
811
|
+
await session.send(`回声洞(${newId})添加失败:文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
|
|
812
|
+
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
textHashesToStore.push({ hash: newSimhash, type: "text" });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const imagesWithHash = mediaForProcessing.filter((m) => m.hash !== void 0);
|
|
820
|
+
if (imagesWithHash.length > 0) {
|
|
821
|
+
const dbImageHashes = await ctx.database.get("cave_hash", { type: "image" });
|
|
822
|
+
const newImageHashes = /* @__PURE__ */ new Set();
|
|
823
|
+
for (const media of imagesWithHash) {
|
|
824
|
+
const imageHash = media.hash;
|
|
825
|
+
if (newImageHashes.has(imageHash)) continue;
|
|
826
|
+
for (const existing of dbImageHashes) {
|
|
827
|
+
const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
|
|
828
|
+
if (similarity >= config.imageThreshold) {
|
|
829
|
+
await session.send(`回声洞(${newId})添加失败:图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
|
|
830
|
+
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
newImageHashes.add(imageHash);
|
|
835
|
+
}
|
|
836
|
+
imageHashesToStore = Array.from(newImageHashes).map((hash) => ({ hash, type: "image" }));
|
|
837
|
+
}
|
|
838
|
+
} catch (error) {
|
|
839
|
+
logger2.warn("相似度比较失败:", error);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (canonicalFilesToSave.size > 0) await Promise.all(Array.from(canonicalFilesToSave.entries()).map(([fileName, buffer]) => fileManager.saveFile(fileName, buffer)));
|
|
843
|
+
let analysisResult;
|
|
844
|
+
if (config.enableAI && aiManager) {
|
|
845
|
+
const analyses = await aiManager.analyze([newCave], mediaForProcessing);
|
|
846
|
+
if (analyses.length > 0) {
|
|
847
|
+
analysisResult = analyses[0];
|
|
848
|
+
await ctx.database.upsert("cave_meta", analyses);
|
|
849
|
+
const duplicateIds = await aiManager.checkForDuplicates(analysisResult, newCave);
|
|
850
|
+
if (duplicateIds?.length > 0) {
|
|
851
|
+
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateIds.join("|")})重复`);
|
|
852
|
+
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (config.enableSimilarity && hashManager) {
|
|
858
|
+
const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: newCave.id }));
|
|
859
|
+
if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
|
|
860
|
+
}
|
|
861
|
+
let finalStatus = "active";
|
|
862
|
+
const needsManualReview = config.enablePend && session.cid !== config.adminChannel;
|
|
863
|
+
if (needsManualReview) {
|
|
864
|
+
if (config.enableAI && config.enableApprove && analysisResult) {
|
|
865
|
+
if (analysisResult.rating >= config.approveThreshold) {
|
|
866
|
+
finalStatus = "active";
|
|
867
|
+
} else if (config.onAIReviewFail) {
|
|
868
|
+
finalStatus = "pending";
|
|
869
|
+
} else {
|
|
870
|
+
await session.send(`回声洞(${newId})添加失败:AI 审核未通过 (评分: ${analysisResult.rating})`);
|
|
871
|
+
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
finalStatus = "pending";
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
await ctx.database.upsert("cave", [{ id: newId, status: finalStatus, elements: newCave.elements }]);
|
|
879
|
+
if (finalStatus === "pending" && reviewManager) reviewManager.sendForPend({ ...newCave, status: finalStatus });
|
|
880
|
+
} catch (error) {
|
|
881
|
+
logger2.error(`回声洞(${newId})处理失败:`, error);
|
|
882
|
+
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
|
|
883
|
+
await session.send(`回声洞(${newId})处理失败: ${error.message}`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
__name(processNewCave, "processNewCave");
|
|
887
|
+
|
|
888
|
+
// src/PendManager.ts
|
|
889
|
+
var PendManager = class {
|
|
890
|
+
/**
|
|
891
|
+
* @param ctx Koishi 上下文。
|
|
892
|
+
* @param config 插件配置。
|
|
893
|
+
* @param fileManager 文件管理器实例。
|
|
894
|
+
* @param logger 日志记录器实例。
|
|
895
|
+
* @param reusableIds 可复用 ID 的内存缓存。
|
|
896
|
+
*/
|
|
897
|
+
constructor(ctx, config, fileManager, logger2) {
|
|
898
|
+
this.ctx = ctx;
|
|
899
|
+
this.config = config;
|
|
900
|
+
this.fileManager = fileManager;
|
|
901
|
+
this.logger = logger2;
|
|
902
|
+
}
|
|
903
|
+
static {
|
|
904
|
+
__name(this, "PendManager");
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* @description 注册与审核相关的子命令。
|
|
908
|
+
* @param cave - 主 `cave` 命令实例。
|
|
909
|
+
*/
|
|
910
|
+
registerCommands(cave) {
|
|
911
|
+
const pend = cave.subcommand(".pend [id:posint]", "审核回声洞", { hidden: true }).usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => {
|
|
912
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
913
|
+
if (id) {
|
|
914
|
+
const [targetCave] = await this.ctx.database.get("cave", { id });
|
|
915
|
+
if (!targetCave) return `回声洞(${id})不存在`;
|
|
916
|
+
if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
|
|
917
|
+
const caveMessages = await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger, session.platform, "待审核");
|
|
918
|
+
for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi2.h.normalize(message));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
|
|
922
|
+
if (!pendingCaves.length) return "当前没有需要审核的回声洞";
|
|
923
|
+
return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
|
|
924
|
+
${pendingCaves.map((c) => c.id).join("|")}`;
|
|
925
|
+
});
|
|
926
|
+
const createPendAction = /* @__PURE__ */ __name((actionType) => async ({ session }, ...args) => {
|
|
927
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
928
|
+
const { ids, invalid } = parseCaveIds(args);
|
|
929
|
+
let idsToProcess = ids;
|
|
930
|
+
if (args.length === 0) {
|
|
931
|
+
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
|
|
932
|
+
if (!pendingCaves.length) return "当前没有需要审核的回声洞";
|
|
933
|
+
idsToProcess = pendingCaves.map((c) => c.id);
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const targetStatus = actionType === "approve" ? "active" : "delete";
|
|
937
|
+
const actionText = actionType === "approve" ? "通过" : "拒绝";
|
|
938
|
+
const cavesToProcess = idsToProcess.length === 0 ? [] : await this.ctx.database.get("cave", { id: { $in: idsToProcess }, status: "pending" });
|
|
939
|
+
if (cavesToProcess.length === 0) return `回声洞(${[...invalid, ...idsToProcess].join("|")})无需审核或不存在`;
|
|
940
|
+
const processedIds = cavesToProcess.map((cave2) => cave2.id);
|
|
941
|
+
await this.ctx.database.upsert("cave", processedIds.map((id) => ({ id, status: targetStatus })));
|
|
942
|
+
return `已${actionText}回声洞(${processedIds.join("|")})`;
|
|
943
|
+
} catch (error) {
|
|
944
|
+
this.logger.error(`审核操作失败:`, error);
|
|
945
|
+
return `操作失败: ${error.message}`;
|
|
946
|
+
}
|
|
947
|
+
}, "createPendAction");
|
|
948
|
+
pend.subcommand(".Y [...ids:string]", "通过审核").usage("通过一个或多个指定 ID 的回声洞审核,序号之间以空格或 | 分隔。若不指定 ID,则通过所有待审核的回声洞。").action(createPendAction("approve"));
|
|
949
|
+
pend.subcommand(".N [...ids:string]", "拒绝审核").usage("拒绝一个或多个指定 ID 的回声洞审核,序号之间以空格或 | 分隔。若不指定 ID,则拒绝所有待审核的回声洞。").action(createPendAction("reject"));
|
|
950
|
+
if (this.config.enableAI) {
|
|
951
|
+
pend.subcommand(".A <threshold:number>", "自动通过审核").usage("根据评分自动通过不小于指定阈值的回声洞。默认使用配置中的阈值。").action(async ({ session }, threshold) => {
|
|
952
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
953
|
+
const finalThreshold = threshold ?? this.config.approveThreshold;
|
|
954
|
+
try {
|
|
955
|
+
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
|
|
956
|
+
if (pendingCaves.length === 0) return "当前没有需要审核的回声洞";
|
|
957
|
+
const pendingCaveIds = pendingCaves.map((c) => c.id);
|
|
958
|
+
const pendingMeta = await this.ctx.database.get("cave_meta", { cave: { $in: pendingCaveIds } });
|
|
959
|
+
const idsToApprove = pendingMeta.filter((meta) => meta.rating >= finalThreshold).map((meta) => meta.cave);
|
|
960
|
+
if (idsToApprove.length === 0) return `没有找到评分不小于 ${finalThreshold} 的待审核回声洞`;
|
|
961
|
+
await this.ctx.database.upsert("cave", idsToApprove.map((id) => ({ id, status: "active" })));
|
|
962
|
+
return `已自动通过回声洞(${idsToApprove.join("|")})`;
|
|
963
|
+
} catch (error) {
|
|
964
|
+
this.logger.error("自动审核操作失败:", error);
|
|
965
|
+
return `操作失败: ${error.message}`;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* @description 将新回声洞提交到管理群组以供审核。
|
|
972
|
+
* @param cave 新创建的、状态为 'pending' 的回声洞对象。
|
|
973
|
+
*/
|
|
974
|
+
async sendForPend(cave) {
|
|
975
|
+
if (!this.config.adminChannel?.includes(":")) {
|
|
976
|
+
this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`);
|
|
977
|
+
await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const [platform] = this.config.adminChannel.split(":", 1);
|
|
982
|
+
const caveMessages = await buildCaveMessage(cave, this.config, this.fileManager, this.logger, platform, "待审核");
|
|
983
|
+
for (const message of caveMessages) if (message.length > 0) await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(message));
|
|
984
|
+
} catch (error) {
|
|
985
|
+
this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// src/HashManager.ts
|
|
991
|
+
var import_jimp2 = __toESM(require("jimp"));
|
|
992
|
+
var crypto2 = __toESM(require("crypto"));
|
|
993
|
+
var HashManager = class {
|
|
994
|
+
/**
|
|
995
|
+
* @constructor
|
|
996
|
+
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
997
|
+
* @param config - 插件配置,用于获取相似度阈值等。
|
|
998
|
+
* @param logger - 日志记录器实例。
|
|
999
|
+
* @param fileManager - 文件管理器实例,用于读取图片文件。
|
|
1000
|
+
*/
|
|
1001
|
+
constructor(ctx, config, logger2, fileManager) {
|
|
1002
|
+
this.ctx = ctx;
|
|
1003
|
+
this.config = config;
|
|
1004
|
+
this.logger = logger2;
|
|
1005
|
+
this.fileManager = fileManager;
|
|
1006
|
+
this.ctx.model.extend("cave_hash", {
|
|
1007
|
+
cave: "unsigned",
|
|
1008
|
+
hash: "string",
|
|
1009
|
+
type: "string"
|
|
1010
|
+
}, {
|
|
1011
|
+
primary: ["cave", "hash", "type"],
|
|
1012
|
+
indexes: ["type"]
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
static {
|
|
1016
|
+
__name(this, "HashManager");
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* @description 注册与哈希功能相关的子命令。
|
|
1020
|
+
* @param cave - 主 `cave` 命令实例。
|
|
1021
|
+
*/
|
|
1022
|
+
registerCommands(cave) {
|
|
1023
|
+
cave.subcommand(".hash", "校验回声洞", { hidden: true, authority: 3 }).usage("校验缺失哈希的回声洞,补全哈希记录。").action(async ({ session }) => {
|
|
1024
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1025
|
+
try {
|
|
1026
|
+
const allCaves = await this.ctx.database.get("cave", { status: "active" });
|
|
1027
|
+
const existingHashes = await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] });
|
|
1028
|
+
const hashedCaveIds = new Set(existingHashes.map((h4) => h4.cave));
|
|
1029
|
+
const cavesToProcess = allCaves.filter((cave2) => !hashedCaveIds.has(cave2.id));
|
|
1030
|
+
if (cavesToProcess.length === 0) return "无需补全回声洞哈希";
|
|
1031
|
+
await session.send(`开始补全 ${cavesToProcess.length} 个回声洞的哈希...`);
|
|
1032
|
+
let hashesToInsert = [];
|
|
1033
|
+
let processedCaveCount = 0;
|
|
1034
|
+
let totalHashesGenerated = 0;
|
|
1035
|
+
let errorCount = 0;
|
|
1036
|
+
const flushBatch = /* @__PURE__ */ __name(async () => {
|
|
1037
|
+
if (hashesToInsert.length === 0) return;
|
|
1038
|
+
await this.ctx.database.upsert("cave_hash", hashesToInsert);
|
|
1039
|
+
totalHashesGenerated += hashesToInsert.length;
|
|
1040
|
+
this.logger.info(`[${processedCaveCount}/${cavesToProcess.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`);
|
|
1041
|
+
hashesToInsert = [];
|
|
1042
|
+
}, "flushBatch");
|
|
1043
|
+
for (const cave2 of cavesToProcess) {
|
|
1044
|
+
processedCaveCount++;
|
|
1045
|
+
try {
|
|
1046
|
+
const tempHashes = [];
|
|
1047
|
+
const uniqueHashTracker = /* @__PURE__ */ new Set();
|
|
1048
|
+
const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
|
|
1049
|
+
const key = `${hashObj.hash}-${hashObj.type}`;
|
|
1050
|
+
if (!uniqueHashTracker.has(key)) {
|
|
1051
|
+
tempHashes.push(hashObj);
|
|
1052
|
+
uniqueHashTracker.add(key);
|
|
1053
|
+
}
|
|
1054
|
+
}, "addUniqueHash");
|
|
1055
|
+
const combinedText = cave2.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
1056
|
+
if (combinedText) {
|
|
1057
|
+
const textHash = this.generateTextSimhash(combinedText);
|
|
1058
|
+
if (textHash) addUniqueHash({ cave: cave2.id, hash: textHash, type: "text" });
|
|
1059
|
+
}
|
|
1060
|
+
for (const el of cave2.elements.filter((el2) => el2.type === "image" && el2.file)) {
|
|
1061
|
+
const imageBuffer = await this.fileManager.readFile(el.file);
|
|
1062
|
+
const imageHash = await this.generatePHash(imageBuffer);
|
|
1063
|
+
addUniqueHash({ cave: cave2.id, hash: imageHash, type: "image" });
|
|
1064
|
+
}
|
|
1065
|
+
const newHashesForCave = tempHashes;
|
|
1066
|
+
if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave);
|
|
1067
|
+
if (hashesToInsert.length >= 100) await flushBatch();
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
errorCount++;
|
|
1070
|
+
this.logger.warn(`补全回声洞(${cave2.id})哈希时出错: ${error.message}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
await flushBatch();
|
|
1074
|
+
const successCount = processedCaveCount - errorCount;
|
|
1075
|
+
return `已补全 ${successCount} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`;
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
this.logger.error("补全哈希失败:", error);
|
|
1078
|
+
return `操作失败: ${error.message}`;
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
cave.subcommand(".check", "检查相似度", { hidden: true }).usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async ({ session, options }) => {
|
|
1082
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1083
|
+
await session.send("正在检查,请稍候...");
|
|
1084
|
+
try {
|
|
1085
|
+
const textThreshold = options.textThreshold ?? this.config.textThreshold;
|
|
1086
|
+
const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
|
|
1087
|
+
const allHashes = await this.ctx.database.get("cave_hash", {});
|
|
1088
|
+
if (allHashes.length < 2) return "无可比较哈希";
|
|
1089
|
+
const candidatePairs = generateFromLSH(allHashes, (hashObj) => {
|
|
1090
|
+
const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0");
|
|
1091
|
+
const keys = [];
|
|
1092
|
+
for (let i = 0; i < 4; i++) {
|
|
1093
|
+
const band = binHash.substring(i * 16, (i + 1) * 16);
|
|
1094
|
+
keys.push(`${hashObj.type}:${i}:${band}`);
|
|
1095
|
+
}
|
|
1096
|
+
return { id: hashObj.cave, keys };
|
|
1097
|
+
});
|
|
1098
|
+
const hashLookup = /* @__PURE__ */ new Map();
|
|
1099
|
+
allHashes.forEach((h4) => {
|
|
1100
|
+
if (!hashLookup.has(h4.cave)) hashLookup.set(h4.cave, { text: [], image: [] });
|
|
1101
|
+
const entry = hashLookup.get(h4.cave);
|
|
1102
|
+
if (h4.type === "text") entry.text.push(h4.hash);
|
|
1103
|
+
else if (h4.type === "image") entry.image.push(h4.hash);
|
|
1104
|
+
});
|
|
1105
|
+
const textPairs = [];
|
|
1106
|
+
const imagePairs = [];
|
|
1107
|
+
for (const pairKey of candidatePairs) {
|
|
1108
|
+
const [id1, id2] = pairKey.split("-").map(Number);
|
|
1109
|
+
const cave1Hashes = hashLookup.get(id1);
|
|
1110
|
+
const cave2Hashes = hashLookup.get(id2);
|
|
1111
|
+
if (cave1Hashes?.text.length && cave2Hashes?.text.length) {
|
|
1112
|
+
for (const hash1 of cave1Hashes.text) {
|
|
1113
|
+
for (const hash2 of cave2Hashes.text) {
|
|
1114
|
+
const similarity = this.calculateSimilarity(hash1, hash2);
|
|
1115
|
+
if (similarity >= textThreshold) textPairs.push({ id1, id2, similarity });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (cave1Hashes?.image.length && cave2Hashes?.image.length) {
|
|
1120
|
+
for (const hash1 of cave1Hashes.image) {
|
|
1121
|
+
for (const hash2 of cave2Hashes.image) {
|
|
1122
|
+
const similarity = this.calculateSimilarity(hash1, hash2);
|
|
1123
|
+
if (similarity >= imageThreshold) imagePairs.push({ id1, id2, similarity });
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (textPairs.length === 0 && imagePairs.length === 0) return "未发现高相似度的内容";
|
|
1129
|
+
const generateReportForType = /* @__PURE__ */ __name((pairs) => {
|
|
1130
|
+
if (pairs.length === 0) return { reportLines: [], clusters: [] };
|
|
1131
|
+
const numericPairs = pairs.map((p) => [p.id1, p.id2]);
|
|
1132
|
+
const validClusters = clusterItemsFromPairs(numericPairs);
|
|
1133
|
+
const reportLines = [];
|
|
1134
|
+
validClusters.forEach((cluster) => {
|
|
1135
|
+
const sortedCluster = cluster.sort((a, b) => a - b);
|
|
1136
|
+
const clusterPairs = pairs.filter((p) => cluster.includes(p.id1) && cluster.includes(p.id2)).sort((a, b) => b.similarity - a.similarity);
|
|
1137
|
+
const scores = clusterPairs.map((p) => `${p.similarity.toFixed(2)}%`).join("/");
|
|
1138
|
+
reportLines.push(`- ${sortedCluster.join("|")} = ${scores}`);
|
|
1139
|
+
});
|
|
1140
|
+
return { reportLines, clusters: validClusters };
|
|
1141
|
+
}, "generateReportForType");
|
|
1142
|
+
const textResult = generateReportForType(textPairs);
|
|
1143
|
+
const imageResult = generateReportForType(imagePairs);
|
|
1144
|
+
const totalClusters = textResult.clusters.length + imageResult.clusters.length;
|
|
1145
|
+
if (totalClusters === 0) return "未发现高相似度的内容";
|
|
1146
|
+
let report = `共发现 ${totalClusters} 组高相似度的内容:`;
|
|
1147
|
+
if (textResult.reportLines.length > 0) {
|
|
1148
|
+
report += `
|
|
1149
|
+
[文本相似]`;
|
|
1150
|
+
report += `
|
|
1151
|
+
${textResult.reportLines.join("\n")}`;
|
|
1152
|
+
}
|
|
1153
|
+
if (imageResult.reportLines.length > 0) {
|
|
1154
|
+
report += `
|
|
1155
|
+
[图片相似]`;
|
|
1156
|
+
report += `
|
|
1157
|
+
${imageResult.reportLines.join("\n")}`;
|
|
1158
|
+
}
|
|
1159
|
+
return report.trim();
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
this.logger.error("检查相似度失败:", error);
|
|
1162
|
+
return `检查失败: ${error.message}`;
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
cave.subcommand(".fix [...ids:string]", "修复回声洞", { hidden: true, authority: 3 }).usage("扫描并修复回声洞中的图片,可指定一个或多个 ID,序号之间以空格或 | 分隔。").action(async ({ session }, ...args) => {
|
|
1166
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1167
|
+
const { ids } = parseCaveIds(args);
|
|
1168
|
+
let cavesToProcess;
|
|
1169
|
+
try {
|
|
1170
|
+
await session.send("正在修复,请稍候...");
|
|
1171
|
+
if (args.length === 0) {
|
|
1172
|
+
cavesToProcess = await this.ctx.database.get("cave", { status: "active" });
|
|
1173
|
+
} else {
|
|
1174
|
+
cavesToProcess = ids.length === 0 ? [] : await this.ctx.database.get("cave", { id: { $in: ids }, status: "active" });
|
|
1175
|
+
}
|
|
1176
|
+
if (!cavesToProcess.length) return "无可修复的回声洞";
|
|
1177
|
+
let fixedFiles = 0;
|
|
1178
|
+
let errorCount = 0;
|
|
1179
|
+
for (const cave2 of cavesToProcess) {
|
|
1180
|
+
const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file);
|
|
1181
|
+
for (const element of imageElements) {
|
|
1182
|
+
try {
|
|
1183
|
+
const originalBuffer = await this.fileManager.readFile(element.file);
|
|
1184
|
+
const sanitizedBuffer = this.sanitizeImageBuffer(originalBuffer);
|
|
1185
|
+
if (!originalBuffer.equals(sanitizedBuffer)) {
|
|
1186
|
+
await this.fileManager.saveFile(element.file, sanitizedBuffer);
|
|
1187
|
+
fixedFiles++;
|
|
1188
|
+
}
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
|
|
1191
|
+
this.logger.warn(`无法修复回声洞(${cave2.id})的图片(${element.file}):`, error);
|
|
1192
|
+
errorCount++;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return `已修复 ${cavesToProcess.length} 个回声洞的 ${fixedFiles} 张图片(失败 ${errorCount} 条)`;
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
this.logger.error("修复图像文件时发生严重错误:", error);
|
|
1200
|
+
return `操作失败: ${error.message}`;
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
|
|
1206
|
+
* @param imageBuffer - 原始的图片 Buffer。
|
|
1207
|
+
* @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
|
|
1208
|
+
*/
|
|
1209
|
+
sanitizeImageBuffer(imageBuffer) {
|
|
1210
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
1211
|
+
const JPEG_SIGNATURE = Buffer.from([255, 216]);
|
|
1212
|
+
const GIF_SIGNATURE = Buffer.from("GIF");
|
|
1213
|
+
const WEBP_SIGNATURE = Buffer.from("WEBP");
|
|
1214
|
+
let sanitizedBuffer = imageBuffer;
|
|
1215
|
+
if (imageBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
|
|
1216
|
+
const IEND_CHUNK = Buffer.from("IEND");
|
|
1217
|
+
const iendIndex = imageBuffer.lastIndexOf(IEND_CHUNK);
|
|
1218
|
+
if (iendIndex !== -1) {
|
|
1219
|
+
const endOfPngData = iendIndex + 8;
|
|
1220
|
+
if (imageBuffer.length > endOfPngData) sanitizedBuffer = imageBuffer.slice(0, endOfPngData);
|
|
1221
|
+
}
|
|
1222
|
+
} else if (imageBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
|
|
1223
|
+
const EOI_MARKER = Buffer.from([255, 217]);
|
|
1224
|
+
const eoiIndex = imageBuffer.lastIndexOf(EOI_MARKER);
|
|
1225
|
+
if (eoiIndex !== -1) {
|
|
1226
|
+
const endOfJpegData = eoiIndex + 2;
|
|
1227
|
+
if (imageBuffer.length > endOfJpegData) sanitizedBuffer = imageBuffer.slice(0, endOfJpegData);
|
|
1228
|
+
}
|
|
1229
|
+
} else if (imageBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
|
|
1230
|
+
const GIF_TERMINATOR = Buffer.from([59]);
|
|
1231
|
+
const terminatorIndex = imageBuffer.lastIndexOf(GIF_TERMINATOR);
|
|
1232
|
+
if (terminatorIndex !== -1) {
|
|
1233
|
+
const endOfGifData = terminatorIndex + 1;
|
|
1234
|
+
if (imageBuffer.length > endOfGifData) sanitizedBuffer = imageBuffer.slice(0, endOfGifData);
|
|
1235
|
+
}
|
|
1236
|
+
} else if (imageBuffer.slice(8, 12).equals(WEBP_SIGNATURE)) {
|
|
1237
|
+
const fileSize = imageBuffer.readUInt32LE(4);
|
|
1238
|
+
const expectedLength = fileSize + 8;
|
|
1239
|
+
if (imageBuffer.length > expectedLength) sanitizedBuffer = imageBuffer.slice(0, expectedLength);
|
|
1240
|
+
}
|
|
1241
|
+
return sanitizedBuffer;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
|
|
1245
|
+
* @param input - 输入的数字数组。
|
|
1246
|
+
* @returns DCT 变换后的数组。
|
|
1247
|
+
*/
|
|
1248
|
+
dct1D(input) {
|
|
1249
|
+
const N = input.length;
|
|
1250
|
+
const output = new Array(N).fill(0);
|
|
1251
|
+
const c0 = 1 / Math.sqrt(2);
|
|
1252
|
+
for (let k = 0; k < N; k++) {
|
|
1253
|
+
let sum = 0;
|
|
1254
|
+
for (let n = 0; n < N; n++) sum += input[n] * Math.cos(Math.PI * (2 * n + 1) * k / (2 * N));
|
|
1255
|
+
const ck = k === 0 ? c0 : 1;
|
|
1256
|
+
output[k] = Math.sqrt(2 / N) * ck * sum;
|
|
1257
|
+
}
|
|
1258
|
+
return output;
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* @description 执行二维离散余弦变换 (DCT-II) 的方法。
|
|
1262
|
+
* 通过对行和列分别应用一维 DCT 来实现。
|
|
1263
|
+
* @param matrix - 输入的 N x N 像素亮度矩阵。
|
|
1264
|
+
* @returns DCT 变换后的 N x N 系数矩阵。
|
|
1265
|
+
*/
|
|
1266
|
+
dct2D(matrix) {
|
|
1267
|
+
const N = matrix.length;
|
|
1268
|
+
if (N === 0) return [];
|
|
1269
|
+
const tempMatrix = matrix.map((row) => this.dct1D(row));
|
|
1270
|
+
const transposed = tempMatrix.map((_, colIndex) => tempMatrix.map((row) => row[colIndex]));
|
|
1271
|
+
const dctResultTransposed = transposed.map((row) => this.dct1D(row));
|
|
1272
|
+
const dctResult = dctResultTransposed.map((_, colIndex) => dctResultTransposed.map((row) => row[colIndex]));
|
|
1273
|
+
return dctResult;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* @description pHash 算法核心实现,使用 Jimp 和自定义 DCT。
|
|
1277
|
+
* @param imageBuffer - 图片的 Buffer。
|
|
1278
|
+
* @returns 64位十六进制 pHash 字符串。
|
|
1279
|
+
*/
|
|
1280
|
+
async generatePHash(imageBuffer) {
|
|
1281
|
+
const image = await import_jimp2.default.read(imageBuffer);
|
|
1282
|
+
image.resize(32, 32, import_jimp2.default.RESIZE_BILINEAR).greyscale();
|
|
1283
|
+
const matrix = Array.from({ length: 32 }, () => new Array(32).fill(0));
|
|
1284
|
+
image.scan(0, 0, 32, 32, (x, y, idx) => {
|
|
1285
|
+
matrix[y][x] = image.bitmap.data[idx];
|
|
1286
|
+
});
|
|
1287
|
+
const dctMatrix = this.dct2D(matrix);
|
|
1288
|
+
const coefficients = [];
|
|
1289
|
+
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) coefficients.push(dctMatrix[y][x]);
|
|
1290
|
+
const acCoefficients = coefficients.slice(1);
|
|
1291
|
+
const average = acCoefficients.reduce((sum, val) => sum + val, 0) / acCoefficients.length;
|
|
1292
|
+
let binaryHash = "";
|
|
1293
|
+
for (const val of coefficients) binaryHash += val > average ? "1" : "0";
|
|
1294
|
+
return BigInt("0b" + binaryHash).toString(16).padStart(16, "0");
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* @description 根据汉明距离计算相似度百分比。
|
|
1298
|
+
* @param hex1 - 第一个哈希。
|
|
1299
|
+
* @param hex2 - 第二个哈希。
|
|
1300
|
+
* @returns 相似度 (0-100)。
|
|
1301
|
+
*/
|
|
1302
|
+
calculateSimilarity(hex1, hex2) {
|
|
1303
|
+
let distance = 0;
|
|
1304
|
+
let bin1 = "";
|
|
1305
|
+
for (const char of hex1) bin1 += parseInt(char, 16).toString(2).padStart(4, "0");
|
|
1306
|
+
let bin2 = "";
|
|
1307
|
+
for (const char of hex2) bin2 += parseInt(char, 16).toString(2).padStart(4, "0");
|
|
1308
|
+
const len = Math.min(bin1.length, bin2.length);
|
|
1309
|
+
for (let i = 0; i < len; i++) if (bin1[i] !== bin2[i]) distance++;
|
|
1310
|
+
const hashLength = Math.max(hex1.length, hex2.length) * 4;
|
|
1311
|
+
return hashLength === 0 ? 100 : (1 - distance / hashLength) * 100;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* @description 为文本生成 64 位 Simhash 字符串。
|
|
1315
|
+
* @param text - 需要处理的文本。
|
|
1316
|
+
* @returns 16位十六进制 Simhash 字符串。
|
|
1317
|
+
*/
|
|
1318
|
+
generateTextSimhash(text) {
|
|
1319
|
+
const cleanText = (text || "").toLowerCase().replace(/\s+/g, "");
|
|
1320
|
+
if (!cleanText) return "";
|
|
1321
|
+
const tokens = Array.from(cleanText);
|
|
1322
|
+
const tokenArray = Array.from(new Set(tokens));
|
|
1323
|
+
if (tokenArray.length === 0) return "";
|
|
1324
|
+
const vector = new Array(64).fill(0);
|
|
1325
|
+
tokenArray.forEach((token) => {
|
|
1326
|
+
const hash = crypto2.createHash("md5").update(token).digest();
|
|
1327
|
+
for (let i = 0; i < 64; i++) vector[i] += hash[Math.floor(i / 8)] >> i % 8 & 1 ? 1 : -1;
|
|
1328
|
+
});
|
|
1329
|
+
const binaryHash = vector.map((v) => v > 0 ? "1" : "0").join("");
|
|
1330
|
+
return BigInt("0b" + binaryHash).toString(16).padStart(16, "0");
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
// src/AIManager.ts
|
|
1335
|
+
var path3 = __toESM(require("path"));
|
|
1336
|
+
var AIManager = class {
|
|
1337
|
+
/**
|
|
1338
|
+
* @constructor
|
|
1339
|
+
* @description AIManager 的构造函数。
|
|
1340
|
+
* @param {Context} ctx - Koishi 的上下文对象。
|
|
1341
|
+
* @param {Config} config - 插件的配置对象。
|
|
1342
|
+
* @param {Logger} logger - 日志记录器实例。
|
|
1343
|
+
* @param {FileManager} fileManager - 文件管理器实例。
|
|
1344
|
+
*/
|
|
1345
|
+
constructor(ctx, config, logger2, fileManager) {
|
|
1346
|
+
this.ctx = ctx;
|
|
1347
|
+
this.config = config;
|
|
1348
|
+
this.logger = logger2;
|
|
1349
|
+
this.fileManager = fileManager;
|
|
1350
|
+
this.http = ctx.http;
|
|
1351
|
+
this.ctx.model.extend("cave_meta", {
|
|
1352
|
+
cave: "unsigned",
|
|
1353
|
+
rating: "unsigned",
|
|
1354
|
+
type: "string",
|
|
1355
|
+
keywords: "json"
|
|
1356
|
+
}, {
|
|
1357
|
+
primary: "cave"
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
static {
|
|
1361
|
+
__name(this, "AIManager");
|
|
1362
|
+
}
|
|
1363
|
+
http;
|
|
1364
|
+
endpointIndex = 0;
|
|
1365
|
+
retryTime = 0;
|
|
1366
|
+
/**
|
|
1367
|
+
* @description 用于分析的 AI 系统提示词。
|
|
1368
|
+
*/
|
|
1369
|
+
ANALYSIS_SYSTEM_PROMPT = `你需要分析给定的内容,并按照以下规则进行评分和分类,并提取内容中的关键词,然后输出回复。
|
|
1370
|
+
你的回复必须且只能是一个JSON对象,严禁含有注释说明等其他文字,例: {"rating": 88,"type": "Game","keywords": ["Minecraft", "Nether"]}。`;
|
|
1371
|
+
/**
|
|
1372
|
+
* @description 用于查重的 AI 系统提示词。
|
|
1373
|
+
*/
|
|
1374
|
+
DUPLICATE_SYSTEM_PROMPT = `你需要比较给定的“新内容”与“候选内容”,判断是否在核心主旨上近似,忽略非关键元素的差异。
|
|
1375
|
+
你的回复必须且只能是一个JSON数组,严禁含有注释说明等其他文字,只包含与新内容重复的候选内容的ID,例: [1, 2]或[](若无重复)`;
|
|
1376
|
+
/**
|
|
1377
|
+
* @description 注册与 AI 功能相关的管理命令。
|
|
1378
|
+
* @param {any} cave - \`cave\` 命令的实例,用于挂载子命令。
|
|
1379
|
+
*/
|
|
1380
|
+
registerCommands(cave) {
|
|
1381
|
+
cave.subcommand(".ai", "分析回声洞", { hidden: true, authority: 4 }).usage("分析尚未分析的回声洞,补全回声洞记录。").action(async ({ session }) => {
|
|
1382
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1383
|
+
try {
|
|
1384
|
+
const allCaves = await this.ctx.database.get("cave", { status: "active" });
|
|
1385
|
+
const analyzedCaveIds = new Set((await this.ctx.database.get("cave_meta", {}, { fields: ["cave"] })).map((meta) => meta.cave));
|
|
1386
|
+
const cavesToAnalyze = allCaves.filter((cave2) => !analyzedCaveIds.has(cave2.id));
|
|
1387
|
+
if (cavesToAnalyze.length === 0) return "无需分析回声洞";
|
|
1388
|
+
await session.send(`开始分析 ${cavesToAnalyze.length} 个回声洞...`);
|
|
1389
|
+
let successCount = 0;
|
|
1390
|
+
let failedCount = 0;
|
|
1391
|
+
for (let i = 0; i < cavesToAnalyze.length; i += 20) {
|
|
1392
|
+
const batch = cavesToAnalyze.slice(i, i + 20);
|
|
1393
|
+
this.logger.info(`[${i + 1}/${cavesToAnalyze.length}] 正在分析 ${batch.length} 个回声洞...`);
|
|
1394
|
+
const results = await Promise.allSettled(batch.map((cave2) => this.analyze([cave2])));
|
|
1395
|
+
const successfulAnalyses = [];
|
|
1396
|
+
for (let j = 0; j < results.length; j++) {
|
|
1397
|
+
const result = results[j];
|
|
1398
|
+
if (result.status === "fulfilled" && result.value.length > 0) {
|
|
1399
|
+
successfulAnalyses.push(result.value[0]);
|
|
1400
|
+
} else {
|
|
1401
|
+
failedCount++;
|
|
1402
|
+
if (result.status === "rejected") this.logger.error(`分析回声洞(${batch[j].id})失败:`, result.reason);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
if (successfulAnalyses.length > 0) {
|
|
1406
|
+
await this.ctx.database.upsert("cave_meta", successfulAnalyses);
|
|
1407
|
+
successCount += successfulAnalyses.length;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return `已分析 ${successCount} 个回声洞(失败 ${failedCount} 个)`;
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
this.logger.error("分析回声洞失败:", error);
|
|
1413
|
+
return `操作失败: ${error.message}`;
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
cave.subcommand(".compare", "比较重复性", { hidden: true }).usage("检查回声洞,找出可能重复的内容。").action(async ({ session }) => {
|
|
1417
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1418
|
+
await session.send("正在检查,请稍候...");
|
|
1419
|
+
try {
|
|
1420
|
+
const allMeta = await this.ctx.database.get("cave_meta", {});
|
|
1421
|
+
if (allMeta.length < 2) return "无可比较数据";
|
|
1422
|
+
const combinedTags = /* @__PURE__ */ __name((meta) => [meta.type, ...meta.keywords || []].filter(Boolean), "combinedTags");
|
|
1423
|
+
const candidatePairs = generateFromLSH(allMeta, (meta) => ({ id: meta.cave, keys: combinedTags(meta) }));
|
|
1424
|
+
if (candidatePairs.size === 0) return "未发现相似内容";
|
|
1425
|
+
const metaMap = new Map(allMeta.map((meta) => [meta.cave, meta]));
|
|
1426
|
+
const filteredPairs = /* @__PURE__ */ new Set();
|
|
1427
|
+
for (const pairKey of candidatePairs) {
|
|
1428
|
+
const [id1, id2] = pairKey.split("-").map(Number);
|
|
1429
|
+
const meta1 = metaMap.get(id1);
|
|
1430
|
+
const meta2 = metaMap.get(id2);
|
|
1431
|
+
if (meta1 && meta2) {
|
|
1432
|
+
const tags1 = combinedTags(meta1);
|
|
1433
|
+
const tags2 = combinedTags(meta2);
|
|
1434
|
+
const similarity = this.calculateSimilarity(tags1, tags2);
|
|
1435
|
+
if (similarity >= 66) {
|
|
1436
|
+
const sortedPairKey = [id1, id2].sort((a, b) => a - b).join("-");
|
|
1437
|
+
filteredPairs.add(sortedPairKey);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (filteredPairs.size === 0) return "未发现相似内容";
|
|
1442
|
+
const groupedCandidates = /* @__PURE__ */ new Map();
|
|
1443
|
+
const allCaveIds = /* @__PURE__ */ new Set();
|
|
1444
|
+
filteredPairs.forEach((pairKey) => {
|
|
1445
|
+
const [id1, id2] = pairKey.split("-").map(Number);
|
|
1446
|
+
if (!groupedCandidates.has(id1)) groupedCandidates.set(id1, /* @__PURE__ */ new Set());
|
|
1447
|
+
groupedCandidates.get(id1).add(id2);
|
|
1448
|
+
allCaveIds.add(id1);
|
|
1449
|
+
allCaveIds.add(id2);
|
|
1450
|
+
});
|
|
1451
|
+
let preliminaryReport = `共发现 ${groupedCandidates.size} 组可能相似的内容:`;
|
|
1452
|
+
for (const [mainId, candidateIdsSet] of groupedCandidates.entries()) {
|
|
1453
|
+
const cluster = [mainId, ...Array.from(candidateIdsSet)].sort((a, b) => a - b);
|
|
1454
|
+
preliminaryReport += `
|
|
1455
|
+
- ${cluster.join("|")}`;
|
|
1456
|
+
}
|
|
1457
|
+
preliminaryReport += "\n正在分析,请稍候...";
|
|
1458
|
+
await session.send(preliminaryReport);
|
|
1459
|
+
const caveData = await this.ctx.database.get("cave", { id: { $in: Array.from(allCaveIds) }, status: "active" });
|
|
1460
|
+
const allCaves = new Map(caveData.map((c) => [c.id, c]));
|
|
1461
|
+
const duplicatePairs = [];
|
|
1462
|
+
for (const [mainId, candidateIdsSet] of groupedCandidates.entries()) {
|
|
1463
|
+
const mainCave = allCaves.get(mainId);
|
|
1464
|
+
const candidateCaves = Array.from(candidateIdsSet).map((id) => allCaves.get(id)).filter((c) => !!c);
|
|
1465
|
+
if (mainCave && candidateCaves.length > 0) {
|
|
1466
|
+
const duplicateIds = await this.IsDuplicate(mainCave, candidateCaves);
|
|
1467
|
+
if (duplicateIds && duplicateIds.length > 0) duplicateIds.forEach((candidateId) => duplicatePairs.push({ id1: mainId, id2: candidateId }));
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (duplicatePairs.length === 0) return "未发现高重复性的内容";
|
|
1471
|
+
const numericPairs = duplicatePairs.map((p) => [p.id1, p.id2]);
|
|
1472
|
+
const validClusters = clusterItemsFromPairs(numericPairs);
|
|
1473
|
+
if (validClusters.length === 0) return "未发现高重复性的内容";
|
|
1474
|
+
let report = `共发现 ${validClusters.length} 组高重复性的内容:`;
|
|
1475
|
+
validClusters.forEach((cluster) => {
|
|
1476
|
+
report += `
|
|
1477
|
+
- ${cluster.sort((a, b) => a - b).join("|")}`;
|
|
1478
|
+
});
|
|
1479
|
+
return report;
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
this.logger.error("检查重复性失败:", error);
|
|
1482
|
+
return `检查失败: ${error.message}`;
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
cave.subcommand(".rank", "查询评分排行", { hidden: true }).option("count", "-n <limit:posint> 显示数量", { fallback: 30 }).option("greater", "-g <rating:number> 显示下限").option("less", "-l <rating:number> 显示上限").option("pending", "-p 查询待审核回声洞").action(async ({ session, options }) => {
|
|
1486
|
+
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1487
|
+
try {
|
|
1488
|
+
const statusToQuery = options.pending ? "pending" : "active";
|
|
1489
|
+
const caves = await this.ctx.database.get("cave", { status: statusToQuery }, { fields: ["id"] });
|
|
1490
|
+
const caveIds = caves.map((c) => c.id);
|
|
1491
|
+
if (caveIds.length === 0) return `暂无${statusToQuery === "pending" ? "待审核" : "已通过"}的回声洞`;
|
|
1492
|
+
const metaQuery = { cave: { $in: caveIds } };
|
|
1493
|
+
if (options.greater !== void 0) metaQuery.rating = { ...metaQuery.rating, $gte: options.greater };
|
|
1494
|
+
if (options.less !== void 0) metaQuery.rating = { ...metaQuery.rating, $lte: options.less };
|
|
1495
|
+
const rankedMeta = await this.ctx.database.get("cave_meta", metaQuery);
|
|
1496
|
+
if (rankedMeta.length === 0) return "暂无相符的评分记录";
|
|
1497
|
+
const sortedMeta = rankedMeta.sort((a, b) => b.rating - a.rating);
|
|
1498
|
+
const topMeta = sortedMeta.slice(0, options.count);
|
|
1499
|
+
const formattedEntries = topMeta.map((meta) => `${meta.cave}(${meta.rating})`);
|
|
1500
|
+
const reportChunks = [];
|
|
1501
|
+
for (let i = 0; i < formattedEntries.length; i += 3) reportChunks.push(formattedEntries.slice(i, i + 3).join("|"));
|
|
1502
|
+
let report = `${statusToQuery === "pending" ? "待审核" : ""}回声洞评分排行:
|
|
1503
|
+
`;
|
|
1504
|
+
report += reportChunks.join("\n");
|
|
1505
|
+
return report.trim();
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
this.logger.error("查询评分排行失败:", error);
|
|
1508
|
+
return `查询失败: ${error.message}`;
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* @description 检查新内容是否与数据库中已存在的回声洞重复。
|
|
1514
|
+
* @param {CaveMetaObject} newAnalysis - 新内容经过 AI 分析后的元数据。
|
|
1515
|
+
* @param {CaveObject} newCave - 待检查的新回声洞的完整对象。
|
|
1516
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复或检查失败,则为空数组。
|
|
1517
|
+
*/
|
|
1518
|
+
async checkForDuplicates(newAnalysis, newCave) {
|
|
1519
|
+
try {
|
|
1520
|
+
if (!newAnalysis || !newAnalysis.type) return [];
|
|
1521
|
+
const allNewTags = [newAnalysis.type, ...newAnalysis.keywords || []];
|
|
1522
|
+
if (allNewTags.length === 1 && !allNewTags[0]) return [];
|
|
1523
|
+
const allMeta = await this.ctx.database.get("cave_meta", { type: newAnalysis.type }, { fields: ["cave", "type", "keywords"] });
|
|
1524
|
+
const similarCaveIds = allMeta.filter((meta) => {
|
|
1525
|
+
if (meta.cave === newCave.id) return false;
|
|
1526
|
+
const existingTags = [meta.type, ...meta.keywords || []];
|
|
1527
|
+
return this.calculateSimilarity(allNewTags, existingTags) >= 66;
|
|
1528
|
+
}).map((meta) => meta.cave);
|
|
1529
|
+
if (similarCaveIds.length === 0) return [];
|
|
1530
|
+
const potentialDuplicates = await this.ctx.database.get("cave", { id: { $in: similarCaveIds } });
|
|
1531
|
+
if (potentialDuplicates.length === 0) return [];
|
|
1532
|
+
return await this.IsDuplicate(newCave, potentialDuplicates);
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
this.logger.error("查重回声洞出错:", error);
|
|
1535
|
+
return [];
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* @description 对一个或多个回声洞内容进行 AI 分析。
|
|
1540
|
+
* @param {CaveObject[]} caves - 需要分析的回声洞对象数组。
|
|
1541
|
+
* @param {{ fileName: string; buffer: Buffer }[]} [mediaBuffers] - 可选的媒体文件缓存。
|
|
1542
|
+
* @returns {Promise<CaveMetaObject[]>} - 一个 Promise,解析为分析结果(\`CaveMetaObject\`)的数组。
|
|
1543
|
+
*/
|
|
1544
|
+
async analyze(caves, mediaBuffers) {
|
|
1545
|
+
const analysisPromises = caves.map(async (cave) => {
|
|
1546
|
+
try {
|
|
1547
|
+
const contentForAI = await this.prepareContent(cave, mediaBuffers ? new Map(mediaBuffers.map((m) => [m.fileName, m.buffer])) : void 0);
|
|
1548
|
+
if (!contentForAI) return null;
|
|
1549
|
+
const userMessage = { role: "user", content: contentForAI };
|
|
1550
|
+
const response = await this.requestAI([userMessage], `${this.ANALYSIS_SYSTEM_PROMPT}
|
|
1551
|
+
${this.config.systemPrompt}`);
|
|
1552
|
+
if (response) {
|
|
1553
|
+
return {
|
|
1554
|
+
cave: cave.id,
|
|
1555
|
+
rating: Math.max(0, Math.min(100, response.rating || 0)),
|
|
1556
|
+
type: response.type || "",
|
|
1557
|
+
keywords: response.keywords || []
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
return null;
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
this.logger.error(`分析回声洞(${cave.id})失败:`, error);
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
const results = await Promise.all(analysisPromises);
|
|
1567
|
+
return results.filter((result) => !!result);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* @description 准备单个回声洞的内容(文本和图片)以供 AI 模型处理。
|
|
1571
|
+
* @param {CaveObject} cave - 要处理的回声洞对象。
|
|
1572
|
+
* @param {Map<string, Buffer>} [mediaMap] - 媒体文件的缓存 Map。
|
|
1573
|
+
* @returns {Promise<any[] | null>} - 一个 Promise,解析为适合 AI 请求的 content 数组,如果回声洞为空则返回 null。
|
|
1574
|
+
*/
|
|
1575
|
+
async prepareContent(cave, mediaMap) {
|
|
1576
|
+
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join("\n");
|
|
1577
|
+
const imageElements = await Promise.all(
|
|
1578
|
+
cave.elements.filter((el) => el.type === "image" && el.file).map(async (el) => {
|
|
1579
|
+
try {
|
|
1580
|
+
const buffer = mediaMap?.get(el.file) ?? await this.fileManager.readFile(el.file);
|
|
1581
|
+
const mimeType = path3.extname(el.file).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
|
1582
|
+
return { type: "image_url", image_url: { url: `data:${mimeType};base64,${buffer.toString("base64")}` } };
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
this.logger.warn(`读取文件(${el.file})失败:`, error);
|
|
1585
|
+
return null;
|
|
1586
|
+
}
|
|
1587
|
+
})
|
|
1588
|
+
);
|
|
1589
|
+
const images = imageElements.filter(Boolean);
|
|
1590
|
+
if (!combinedText.trim() && images.length === 0) return null;
|
|
1591
|
+
const contentForAI = [];
|
|
1592
|
+
if (combinedText.trim()) contentForAI.push({ type: "text", text: `${combinedText}` });
|
|
1593
|
+
contentForAI.push(...images);
|
|
1594
|
+
return contentForAI;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* @description 使用 AI 批量判断一个主要回声洞是否与一组候选回声洞中的任何一个重复。
|
|
1598
|
+
* @param {CaveObject} mainCave - 主要的回声洞。
|
|
1599
|
+
* @param {CaveObject[]} candidateCaves - 用于比较的候选回声洞数组。
|
|
1600
|
+
* @returns {Promise<number[]>} - 一个 Promise,解析为重复的回声洞 ID 数组。如果不重复或检查失败,则为空数组。
|
|
1601
|
+
*/
|
|
1602
|
+
async IsDuplicate(mainCave, candidateCaves) {
|
|
1603
|
+
try {
|
|
1604
|
+
const formatContent = /* @__PURE__ */ __name((elements) => elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" "), "formatContent");
|
|
1605
|
+
const newContentText = formatContent(mainCave.elements);
|
|
1606
|
+
const candidatesText = candidateCaves.map((cave) => `{"id": ${cave.id}, "text": "${formatContent(cave.elements).replace(/"/g, '\\"')}"}`).join("\n");
|
|
1607
|
+
const userMessageContent = `新内容:
|
|
1608
|
+
${newContentText}
|
|
1609
|
+
候选内容:
|
|
1610
|
+
${candidatesText}`;
|
|
1611
|
+
const userMessage = { role: "user", content: JSON.stringify(userMessageContent) };
|
|
1612
|
+
const response = await this.requestAI([userMessage], this.DUPLICATE_SYSTEM_PROMPT);
|
|
1613
|
+
return response || [];
|
|
1614
|
+
} catch (error) {
|
|
1615
|
+
this.logger.error(`比较回声洞(${mainCave.id})失败:`, error);
|
|
1616
|
+
return [];
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* @description 计算两组关键词之间的相似度(Jaccard 相似系数)。
|
|
1621
|
+
* @param {string[]} keywordsA - 第一组关键词。
|
|
1622
|
+
* @param {string[]} keywordsB - 第二组关键词。
|
|
1623
|
+
* @returns {number} - 返回 0 到 100 之间的相似度百分比。
|
|
1624
|
+
*/
|
|
1625
|
+
calculateSimilarity(keywordsA, keywordsB) {
|
|
1626
|
+
if (!keywordsA?.length || !keywordsB?.length) return 0;
|
|
1627
|
+
const setA = new Set(keywordsA);
|
|
1628
|
+
const setB = new Set(keywordsB);
|
|
1629
|
+
const intersection = new Set([...setA].filter((x) => setB.has(x)));
|
|
1630
|
+
const union = /* @__PURE__ */ new Set([...setA, ...setB]);
|
|
1631
|
+
return union.size > 0 ? intersection.size / union.size * 100 : 0;
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* @description 向配置的 AI 服务端点发送请求的通用方法。
|
|
1635
|
+
* @template T - 期望从 AI 响应中解析出的 JSON 对象的类型。
|
|
1636
|
+
* @param {any[]} messages - 发送给 AI 的消息数组。
|
|
1637
|
+
* @param {string} systemPrompt - 系统提示词。
|
|
1638
|
+
* @returns {Promise<T>} - 一个 Promise,解析为从 AI 响应中解析出的 JSON 对象。
|
|
1639
|
+
* @throws {Error} - 如果 AI 服务持续失败或响应无法解析,则抛出错误。
|
|
1640
|
+
*/
|
|
1641
|
+
async requestAI(messages, systemPrompt) {
|
|
1642
|
+
const now = Date.now();
|
|
1643
|
+
if (now < this.retryTime) {
|
|
1644
|
+
const waitTime = this.retryTime - now;
|
|
1645
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
1646
|
+
}
|
|
1647
|
+
const endpointConfig = this.config.endpoints[this.endpointIndex];
|
|
1648
|
+
this.endpointIndex = (this.endpointIndex + 1) % this.config.endpoints.length;
|
|
1649
|
+
const payload = { model: endpointConfig.model, messages: [{ role: "system", content: systemPrompt }, ...messages] };
|
|
1650
|
+
const fullUrl = `${endpointConfig.url.replace(/\/$/, "")}/chat/completions`;
|
|
1651
|
+
const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${endpointConfig.key}` };
|
|
1652
|
+
let response;
|
|
1653
|
+
try {
|
|
1654
|
+
response = await this.http.post(fullUrl, payload, { headers, timeout: 6e5 });
|
|
1655
|
+
} catch (httpError) {
|
|
1656
|
+
this.retryTime = Date.now() + 3e4;
|
|
1657
|
+
throw httpError;
|
|
1658
|
+
}
|
|
1659
|
+
try {
|
|
1660
|
+
const content = response?.choices?.[0]?.message?.content;
|
|
1661
|
+
if (!content?.trim()) throw new Error();
|
|
1662
|
+
const potentialStrings = /* @__PURE__ */ new Set();
|
|
1663
|
+
const jsonBlockMatch = content.match(/```json\s*([\s\S]*?)\s*```/i);
|
|
1664
|
+
if (jsonBlockMatch?.[1]) potentialStrings.add(jsonBlockMatch[1]);
|
|
1665
|
+
const firstBrace = content.indexOf("{");
|
|
1666
|
+
const lastBrace = content.lastIndexOf("}");
|
|
1667
|
+
const firstBracket = content.indexOf("[");
|
|
1668
|
+
const lastBracket = content.lastIndexOf("]");
|
|
1669
|
+
if (firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket)) {
|
|
1670
|
+
if (lastBrace > firstBrace) potentialStrings.add(content.substring(firstBrace, lastBrace + 1));
|
|
1671
|
+
} else if (firstBracket !== -1) {
|
|
1672
|
+
if (lastBracket > firstBracket) potentialStrings.add(content.substring(firstBracket, lastBracket + 1));
|
|
1673
|
+
}
|
|
1674
|
+
potentialStrings.add(content);
|
|
1675
|
+
for (const jsonString of potentialStrings) {
|
|
1676
|
+
try {
|
|
1677
|
+
const result = JSON.parse(jsonString);
|
|
1678
|
+
this.retryTime = 0;
|
|
1679
|
+
return result;
|
|
1680
|
+
} catch (e) {
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
throw new Error();
|
|
1684
|
+
} catch (parsingError) {
|
|
1685
|
+
this.logger.error("解析失败:", JSON.stringify(response, null, 2));
|
|
1686
|
+
throw parsingError;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
// src/index.ts
|
|
1692
|
+
var name = "best-cave";
|
|
1693
|
+
var inject = ["database"];
|
|
1694
|
+
var usage = `
|
|
1695
|
+
<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);">
|
|
1696
|
+
<h2 style="margin-top: 0; color: #4a6ee0;">📌 插件说明</h2>
|
|
1697
|
+
<p>📖 <strong>使用文档</strong>:请点击左上角的 <strong>插件主页</strong> 查看插件使用文档</p>
|
|
1698
|
+
<p>🔍 <strong>更多插件</strong>:可访问 <a href="https://github.com/YisRime" style="color:#4a6ee0;text-decoration:none;">苡淞的 GitHub</a> 查看本人的所有插件</p>
|
|
1699
|
+
</div>
|
|
1700
|
+
<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);">
|
|
1701
|
+
<h2 style="margin-top: 0; color: #e0574a;">❤️ 支持与反馈</h2>
|
|
1702
|
+
<p>🌟 喜欢这个插件?请在 <a href="https://github.com/YisRime" style="color:#e0574a;text-decoration:none;">GitHub</a> 上给我一个 Star!</p>
|
|
1703
|
+
<p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p>
|
|
1704
|
+
</div>
|
|
1705
|
+
`;
|
|
1706
|
+
var DEFAULT_PROMPT = `1."rating" (整数): 对内容进行公正客观的评分,以下为评分标准:
|
|
1707
|
+
从以下维度分别评分,每项0-10分,总分为各项之和,最高100分。
|
|
1708
|
+
- 原创性: 评估内容的创意和独特性。
|
|
1709
|
+
- 8-10分: 完全原创的梗或高质量的二次创作,展现出独特的巧思。
|
|
1710
|
+
- 4-7分: 对现有梗或模板进行了巧妙的改造或融合,赋予了新的趣味。
|
|
1711
|
+
- 0-3分: 简单套用常见模板,或是陈旧内容的再利用,甚至完全照搬。
|
|
1712
|
+
- 内容价值: 评估内容所蕴含的幽默、情感或信息价值。
|
|
1713
|
+
- 8-10分: 能引发强烈共鸣或深度思考,具有极高的娱乐性或启发性。
|
|
1714
|
+
- 4-7分: 幽默感强,能让人会心一笑,或是在特定圈层中具有高度相关性。
|
|
1715
|
+
- 0-3分: 内容平淡,笑点模糊,难以引起共鸣,甚至内容空洞、无意义。
|
|
1716
|
+
- 视觉呈现: 评估图像的质量和元素的协调性。
|
|
1717
|
+
- 8-10分: 构图、P图技术或截图时机堪称完美,视觉元素清晰且极具表现力。
|
|
1718
|
+
- 4-7分: 图像清晰,元素搭配得当,能有效服务于主题表达,但存在轻微瑕疵。
|
|
1719
|
+
- 0-3分: 图像模糊、分辨率低,或视觉元素严重影响观感,甚至完全无法辨认。
|
|
1720
|
+
- 文本功底: 评估内容中的文字表达能力。不包含任何文本元素时,此项计为5分。
|
|
1721
|
+
- 8-10分: 文字精炼、幽默且一语中的,与图片配合天衣无缝。
|
|
1722
|
+
- 4-7分: 文字通顺,能准确表达核心笑点或信息,但可能略显啰嗦。
|
|
1723
|
+
- 0-3分: 文字表达不清,存在语病,或与图片关联性不强。
|
|
1724
|
+
- 传播潜力: 评估内容被二次创作、分享和讨论的可能性。
|
|
1725
|
+
- 8-10分: “梗”感十足,极易引发模仿、分享和病毒式传播。
|
|
1726
|
+
- 4-7分: 具有成为热点的潜质,易于在社交圈内传播和讨论。
|
|
1727
|
+
- 0-3分: 内容过于小众,难以被大众理解,无法引发分享意愿。
|
|
1728
|
+
- 娱乐效果: 评估内容的趣味性和吸引力。
|
|
1729
|
+
- 8-10分: 极度搞笑或有趣,能立刻吸引用户注意力并带来愉悦感。
|
|
1730
|
+
- 4-7分: 具有明显的笑点或趣味性,能有效调动观看者情绪。
|
|
1731
|
+
- 0-3分: 趣味性较弱,难以引人发笑或产生兴趣,甚至枯燥乏味。
|
|
1732
|
+
- 逻辑清晰: 评估内容的叙事或表达是否连贯易懂。
|
|
1733
|
+
- 8-10分: 无论是笑话、故事还是玩梗,逻辑都非常清晰,核心意图一目了然。
|
|
1734
|
+
- 4-7分: 内容主旨明确,大部分人都能轻松理解其意图,但存在细节上的模糊。
|
|
1735
|
+
- 0-3分: 逻辑混乱,表达不知所云,需要费力猜测其含义,甚至完全没有逻辑。
|
|
1736
|
+
- 制作完善: 评估内容的完整度和精良程度。
|
|
1737
|
+
- 8-10分: 无论是P图还是对话截图,细节处理到位,内容完整精致。
|
|
1738
|
+
- 4-7分: 内容主体完整,但在细节上(如裁剪、打码)存在瑕疵。
|
|
1739
|
+
- 0-3分: 内容残缺不全,或制作粗糙,有明显的未完成感。
|
|
1740
|
+
- 内容导向: 评估内容是否积极健康。
|
|
1741
|
+
- 8-10分: 内容积极向上,或为中性、善意的幽默。
|
|
1742
|
+
- 4-7分: 内容中性,不包含明显的价值观偏向。
|
|
1743
|
+
- 0-3分: 包含有冒犯性的元素,甚至宣扬不良价值观。
|
|
1744
|
+
- 内容合规: 评估内容是否符合规范。
|
|
1745
|
+
- 10分: 内容符合法律法规和道德规范,适合大众阅读。
|
|
1746
|
+
- 0分: 包含广告、引流、令人不适、争议性等NSFW内容。
|
|
1747
|
+
2."type" (字符串): 对内容进行准确且规范的分类,以下为分类规范:
|
|
1748
|
+
- Game: 与电子游戏直接相关或源自于电子游戏的内容。
|
|
1749
|
+
- ACG: 与动漫、漫画及广义二次元文化紧密相关的内容。
|
|
1750
|
+
- Internet: 源于互联网的流行文化、迷因或社群现象。
|
|
1751
|
+
- Reality: 取材于现实世界的日常经验和场景的内容。
|
|
1752
|
+
- Creative: 具有原创性、艺术性或巧妙构思的内容。
|
|
1753
|
+
- Other: 不适合归入以上任何一类的无关或小众内容。
|
|
1754
|
+
3."keywords" (字符串数组): 从内容中直接提取具体且全面的关键词,以下为提取准则:
|
|
1755
|
+
- 必须源自可直接识别的文字与元素,仅在无可识别内容时才可使用描述性关键词说明。
|
|
1756
|
+
- 要求需通过多维度准确定义内容,且必须规范、简短,禁止使用组合词与分类性词汇`;
|
|
1757
|
+
var logger = new import_koishi3.Logger("best-cave");
|
|
1758
|
+
var Config = import_koishi3.Schema.intersect([
|
|
1759
|
+
import_koishi3.Schema.object({
|
|
1760
|
+
perChannel: import_koishi3.Schema.boolean().default(false).description("启用分群模式"),
|
|
1761
|
+
enableName: import_koishi3.Schema.boolean().default(false).description("启用自定义昵称"),
|
|
1762
|
+
enableIO: import_koishi3.Schema.boolean().default(false).description("启用导入导出"),
|
|
1763
|
+
adminChannel: import_koishi3.Schema.string().default("onebot:").description("管理群组 ID"),
|
|
1764
|
+
caveFormat: import_koishi3.Schema.string().default("回声洞 ——({id})|—— {name}").description("自定义文本(参见 README)")
|
|
1765
|
+
}).description("基础配置"),
|
|
1766
|
+
import_koishi3.Schema.object({
|
|
1767
|
+
enablePend: import_koishi3.Schema.boolean().default(false).description("启用审核"),
|
|
1768
|
+
enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
|
|
1769
|
+
textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("文本相似度阈值 (%)"),
|
|
1770
|
+
imageThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("图片相似度阈值 (%)")
|
|
1771
|
+
}).description("复核配置"),
|
|
1772
|
+
import_koishi3.Schema.object({
|
|
1773
|
+
enableAI: import_koishi3.Schema.boolean().default(false).description("启用 AI"),
|
|
1774
|
+
enableApprove: import_koishi3.Schema.boolean().default(false).description("启用自动审核"),
|
|
1775
|
+
onAIReviewFail: import_koishi3.Schema.boolean().default(true).description("拒绝时转人工"),
|
|
1776
|
+
approveThreshold: import_koishi3.Schema.number().min(0).max(100).step(1).default(60).description("评分阈值"),
|
|
1777
|
+
endpoints: import_koishi3.Schema.array(import_koishi3.Schema.object({
|
|
1778
|
+
url: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link").required(),
|
|
1779
|
+
key: import_koishi3.Schema.string().description("密钥 (API Key)").role("secret"),
|
|
1780
|
+
model: import_koishi3.Schema.string().description("模型 (Model)").required()
|
|
1781
|
+
})).description("端点列表").role("table"),
|
|
1782
|
+
systemPrompt: import_koishi3.Schema.string().role("textarea").default(DEFAULT_PROMPT).description("系统提示词")
|
|
1783
|
+
}).description("模型配置"),
|
|
1784
|
+
import_koishi3.Schema.object({
|
|
1785
|
+
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
1786
|
+
enableS3: import_koishi3.Schema.boolean().default(false).description("启用 S3 存储"),
|
|
1787
|
+
publicUrl: import_koishi3.Schema.string().description("公共访问 URL").role("link"),
|
|
1788
|
+
endpoint: import_koishi3.Schema.string().description("端点 (Endpoint)").role("link"),
|
|
1789
|
+
bucket: import_koishi3.Schema.string().description("存储桶 (Bucket)"),
|
|
1790
|
+
region: import_koishi3.Schema.string().default("auto").description("区域 (Region)"),
|
|
1791
|
+
accessKeyId: import_koishi3.Schema.string().description("Access Key ID").role("secret"),
|
|
1792
|
+
secretAccessKey: import_koishi3.Schema.string().description("Secret Access Key").role("secret")
|
|
1793
|
+
}).description("存储配置")
|
|
1794
|
+
]);
|
|
1795
|
+
async function cleanupCaves(ctx, fileManager, reusableIds, config, logger2, cavesToCleanup) {
|
|
1796
|
+
if (cavesToCleanup.length === 0) return;
|
|
1797
|
+
try {
|
|
1798
|
+
const idsToDelete = cavesToCleanup.map((c) => c.id);
|
|
1799
|
+
for (const cave of cavesToCleanup) await Promise.all(cave.elements.filter((el) => el.file).map((el) => fileManager.deleteFile(el.file)));
|
|
1800
|
+
reusableIds.delete(0);
|
|
1801
|
+
idsToDelete.forEach((id) => reusableIds.add(id));
|
|
1802
|
+
await ctx.database.remove("cave", { id: { $in: idsToDelete } });
|
|
1803
|
+
if (config.enableSimilarity) await ctx.database.remove("cave_hash", { cave: { $in: idsToDelete } });
|
|
1804
|
+
if (config.enableAI) await ctx.database.remove("cave_meta", { cave: { $in: idsToDelete } });
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
logger2.error("清理回声洞出错:", error);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
__name(cleanupCaves, "cleanupCaves");
|
|
1810
|
+
function apply(ctx, config) {
|
|
1811
|
+
ctx.model.extend("cave", {
|
|
1812
|
+
id: "unsigned",
|
|
1813
|
+
elements: "json",
|
|
1814
|
+
channelId: "string",
|
|
1815
|
+
userId: "string",
|
|
1816
|
+
userName: "string",
|
|
1817
|
+
status: "string",
|
|
1818
|
+
time: "timestamp"
|
|
1819
|
+
}, {
|
|
1820
|
+
primary: "id",
|
|
1821
|
+
indexes: ["status", "channelId", "userId"]
|
|
1822
|
+
});
|
|
1823
|
+
const fileManager = new FileManager(ctx.baseDir, config, logger);
|
|
1824
|
+
const reusableIds = /* @__PURE__ */ new Set();
|
|
1825
|
+
const profileManager = config.enableName ? new NameManager(ctx) : null;
|
|
1826
|
+
const reviewManager = config.enablePend ? new PendManager(ctx, config, fileManager, logger) : null;
|
|
1827
|
+
const hashManager = config.enableSimilarity ? new HashManager(ctx, config, logger, fileManager) : null;
|
|
1828
|
+
const dataManager = config.enableIO ? new DataManager(ctx, config, fileManager, logger) : null;
|
|
1829
|
+
const aiManager = config.enableAI ? new AIManager(ctx, config, logger, fileManager) : null;
|
|
1830
|
+
const cleanupInterval = ctx.setInterval(async () => {
|
|
1831
|
+
const cavesToDelete = await ctx.database.get("cave", { status: "delete" });
|
|
1832
|
+
await cleanupCaves(ctx, fileManager, reusableIds, config, logger, cavesToDelete);
|
|
1833
|
+
}, 60 * 60 * 1e3);
|
|
1834
|
+
ctx.on("dispose", async () => {
|
|
1835
|
+
cleanupInterval();
|
|
1836
|
+
const cavesMarkedForDelete = await ctx.database.get("cave", { status: "delete" });
|
|
1837
|
+
const stalePreloadCaves = await ctx.database.get("cave", { status: "preload" });
|
|
1838
|
+
const allCavesToCleanup = [...cavesMarkedForDelete, ...stalePreloadCaves];
|
|
1839
|
+
await cleanupCaves(ctx, fileManager, reusableIds, config, logger, allCavesToCleanup);
|
|
1840
|
+
});
|
|
1841
|
+
const cave = ctx.command("cave", "回声洞").option("add", "-a [content:text] 添加回声洞").option("view", "-g [id:posint] 查看指定回声洞").option("delete", "-r [ids:text] 删除指定回声洞").option("list", "-l 查询投稿统计").usage("随机抽取一条已添加的回声洞。").action(async ({ session, options }) => {
|
|
1842
|
+
if (options.add) return session.execute(`cave.add ${options.add}`);
|
|
1843
|
+
if (options.view) return session.execute(`cave.view ${options.view}`);
|
|
1844
|
+
if (options.delete) return session.execute(`cave.del ${options.delete}`);
|
|
1845
|
+
if (options.list) return session.execute("cave.list");
|
|
1846
|
+
try {
|
|
1847
|
+
const query = config.perChannel && session.channelId ? { status: "active", channelId: session.channelId } : { status: "active" };
|
|
1848
|
+
const candidates = await ctx.database.get("cave", query, { fields: ["id"] });
|
|
1849
|
+
if (!candidates.length) return `当前${config.perChannel && session.channelId ? "本群" : ""}还没有任何回声洞`;
|
|
1850
|
+
const randomId = candidates[Math.floor(Math.random() * candidates.length)].id;
|
|
1851
|
+
const [randomCave] = await ctx.database.get("cave", { ...query, id: randomId });
|
|
1852
|
+
await sendCave(session, randomCave, config, fileManager, logger);
|
|
1853
|
+
} catch (error) {
|
|
1854
|
+
logger.error("随机获取回声洞失败:", error);
|
|
1855
|
+
return "随机获取回声洞失败";
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
cave.subcommand(".add [content:text]", "添加回声洞").usage("添加一条回声洞。可直接发送内容,也可回复或引用消息。").action(async ({ session }, content) => {
|
|
1859
|
+
let sourceElements;
|
|
1860
|
+
if (session.quote?.elements) {
|
|
1861
|
+
sourceElements = session.quote.elements;
|
|
1862
|
+
} else if (content?.trim()) {
|
|
1863
|
+
sourceElements = import_koishi3.h.parse(content);
|
|
1864
|
+
} else {
|
|
1865
|
+
await session.send("请在一分钟内发送你要添加的内容");
|
|
1866
|
+
const reply = await session.prompt(6e4);
|
|
1867
|
+
if (!reply) return "等待操作超时";
|
|
1868
|
+
sourceElements = import_koishi3.h.parse(reply);
|
|
1869
|
+
}
|
|
1870
|
+
const newId = await getNextCaveId(ctx, reusableIds);
|
|
1871
|
+
const creationTime = /* @__PURE__ */ new Date();
|
|
1872
|
+
const { finalElementsForDb, mediaToSave } = await processMessageElements(sourceElements, newId, session, creationTime);
|
|
1873
|
+
if (finalElementsForDb.length === 0) return "无可添加内容";
|
|
1874
|
+
const userName = (config.enableName && profileManager ? await profileManager.getNickname(session.userId) : null) || session.username;
|
|
1875
|
+
const newCave = { id: newId, elements: finalElementsForDb, channelId: session.channelId, userId: session.userId, userName, status: "preload", time: creationTime };
|
|
1876
|
+
await ctx.database.create("cave", newCave);
|
|
1877
|
+
const needsReviewImmediately = config.enablePend && session.cid !== config.adminChannel;
|
|
1878
|
+
session.send(needsReviewImmediately ? `提交成功,序号为(${newCave.id})` : `添加成功,序号为(${newCave.id})`);
|
|
1879
|
+
processNewCave(ctx, config, fileManager, logger, reusableIds, newCave, session, mediaToSave, hashManager, aiManager, reviewManager);
|
|
1880
|
+
});
|
|
1881
|
+
cave.subcommand(".view <id:posint>", "查看指定回声洞").action(async ({ session }, id) => {
|
|
1882
|
+
if (!id) return "请输入要查看的回声洞序号";
|
|
1883
|
+
try {
|
|
1884
|
+
const scopeQuery = config.perChannel && session.channelId ? { status: "active", channelId: session.channelId } : { status: "active" };
|
|
1885
|
+
const [targetCave] = await ctx.database.get("cave", { ...scopeQuery, id });
|
|
1886
|
+
if (!targetCave) return `回声洞(${id})不存在`;
|
|
1887
|
+
await sendCave(session, targetCave, config, fileManager, logger);
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
logger.error(`查看回声洞(${id})失败:`, error);
|
|
1890
|
+
return "查看失败,请稍后再试";
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
cave.subcommand(".del [...ids:string]", "删除指定回声洞").usage("删除一个或多个指定序号的回声洞,序号之间以空格或 | 分隔。").action(async ({ session }, ...args) => {
|
|
1894
|
+
const { ids, invalid } = parseCaveIds(args);
|
|
1895
|
+
if (ids.length === 0 && invalid.length === 0) return "请输入要删除的回声洞序号";
|
|
1896
|
+
try {
|
|
1897
|
+
const isAdmin = session.cid === config.adminChannel;
|
|
1898
|
+
const targetCaves = ids.length === 0 ? [] : await ctx.database.get("cave", { id: { $in: ids }, status: "active" });
|
|
1899
|
+
const cavesToDelete = isAdmin ? targetCaves : targetCaves.filter((cave2) => cave2.userId === session.userId);
|
|
1900
|
+
const deletableIds = new Set(cavesToDelete.map((cave2) => cave2.id));
|
|
1901
|
+
const processedIds = ids.filter((id) => deletableIds.has(id));
|
|
1902
|
+
if (processedIds.length === 0) return `回声洞(${[...invalid, ...ids].join("|")})不存在`;
|
|
1903
|
+
await ctx.database.upsert("cave", processedIds.map((id) => ({ id, status: "delete" })));
|
|
1904
|
+
const notes = [];
|
|
1905
|
+
const orderedCaves = processedIds.map((id) => cavesToDelete.find((cave2) => cave2.id === id));
|
|
1906
|
+
if (orderedCaves.length === 1) {
|
|
1907
|
+
await sendCave(session, orderedCaves[0], config, fileManager, logger, "已删除");
|
|
1908
|
+
} else if (session.platform === "qq") {
|
|
1909
|
+
for (const cave2 of orderedCaves) await sendCave(session, cave2, config, fileManager, logger, "已删除");
|
|
1910
|
+
} else {
|
|
1911
|
+
const groupedMessages = await Promise.all(orderedCaves.map(async (cave2) => {
|
|
1912
|
+
const caveMessages = await buildCaveMessage(cave2, config, fileManager, logger, session.platform, "已删除");
|
|
1913
|
+
const content = caveMessages.filter((message) => message.length > 0).flatMap((message) => import_koishi3.h.normalize(message));
|
|
1914
|
+
if (content.length === 0) return null;
|
|
1915
|
+
return (0, import_koishi3.h)("message", {}, [(0, import_koishi3.h)("author", { id: cave2.userId, name: cave2.userName }), ...content]);
|
|
1916
|
+
}));
|
|
1917
|
+
await session.send((0, import_koishi3.h)("message", { forward: true }, groupedMessages.filter(Boolean)));
|
|
1918
|
+
}
|
|
1919
|
+
const missedIds = [...invalid, ...ids.filter((id) => !processedIds.includes(id))];
|
|
1920
|
+
if (missedIds.length > 0) notes.push(`回声洞(${missedIds.join("|")})不存在`);
|
|
1921
|
+
if (notes.length > 0) return notes.join("\n");
|
|
1922
|
+
} catch (error) {
|
|
1923
|
+
logger.error(`标记回声洞(${args.join("|")})失败:`, error);
|
|
1924
|
+
return "删除失败,请稍后再试";
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
cave.subcommand(".list", "查询投稿统计").usage("查询排行或指定用户的回声洞统计。").option("user", "-u <user:user> 指定用户").option("all", "-a 查看排行").action(async ({ session, options }) => {
|
|
1928
|
+
if (options.all) {
|
|
1929
|
+
if (session.cid !== config.adminChannel) return "此指令仅限在管理群组中使用";
|
|
1930
|
+
try {
|
|
1931
|
+
const aggregatedStats = await ctx.database.select("cave", { status: "active" }).groupBy(["userId", "userName"], { count: /* @__PURE__ */ __name((row) => import_koishi3.$.count(row.id), "count") }).execute();
|
|
1932
|
+
if (!aggregatedStats.length) return "目前没有回声洞投稿";
|
|
1933
|
+
const userStats = /* @__PURE__ */ new Map();
|
|
1934
|
+
for (const stat of aggregatedStats) {
|
|
1935
|
+
const existing = userStats.get(stat.userId);
|
|
1936
|
+
if (existing) {
|
|
1937
|
+
existing.count += stat.count;
|
|
1938
|
+
const existingGroup = aggregatedStats.find((s) => s.userId === stat.userId && s.userName === existing.userName);
|
|
1939
|
+
if (stat.count > (existingGroup?.count || 0)) existing.userName = stat.userName;
|
|
1940
|
+
} else {
|
|
1941
|
+
userStats.set(stat.userId, { userName: stat.userName, count: stat.count });
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
const sortedStats = Array.from(userStats.values()).sort((a, b) => b.count - a.count);
|
|
1945
|
+
let report = "回声洞投稿数量排行:\n";
|
|
1946
|
+
sortedStats.forEach((stat, index) => {
|
|
1947
|
+
report += `${index + 1}. ${stat.userName}: ${stat.count} 条
|
|
1948
|
+
`;
|
|
1949
|
+
});
|
|
1950
|
+
return report.trim();
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
logger.error("查询排行失败:", error);
|
|
1953
|
+
return "查询失败,请稍后再试";
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
const targetUserId = options.user || session.userId;
|
|
1957
|
+
const isQueryingSelf = !options.user;
|
|
1958
|
+
const scopeQuery = config.perChannel && session.channelId ? { status: "active", channelId: session.channelId } : { status: "active" };
|
|
1959
|
+
const query = { ...scopeQuery, userId: targetUserId };
|
|
1960
|
+
const userCaves = await ctx.database.get("cave", query);
|
|
1961
|
+
if (!userCaves.length) return isQueryingSelf ? "你还没有投稿过回声洞" : `用户 ${targetUserId} 还没有投稿过回声洞`;
|
|
1962
|
+
const caveIds = userCaves.map((c) => c.id).sort((a, b) => a - b).join("|");
|
|
1963
|
+
const userName = userCaves.sort((a, b) => b.time.getTime() - a.time.getTime())[0].userName;
|
|
1964
|
+
return `${isQueryingSelf ? "你" : userName}已投稿 ${userCaves.length} 条回声洞,序号为:
|
|
1965
|
+
${caveIds}`;
|
|
1966
|
+
});
|
|
1967
|
+
if (profileManager) profileManager.registerCommands(cave);
|
|
1968
|
+
if (dataManager) dataManager.registerCommands(cave);
|
|
1969
|
+
if (reviewManager) reviewManager.registerCommands(cave);
|
|
1970
|
+
if (hashManager) hashManager.registerCommands(cave);
|
|
1971
|
+
if (aiManager) aiManager.registerCommands(cave);
|
|
1972
|
+
}
|
|
1973
|
+
__name(apply, "apply");
|
|
1974
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1975
|
+
0 && (module.exports = {
|
|
1976
|
+
Config,
|
|
1977
|
+
apply,
|
|
1978
|
+
inject,
|
|
1979
|
+
name,
|
|
1980
|
+
usage
|
|
1981
|
+
});
|