koishi-plugin-emojiluna-plus 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/dist/index.js +10 -0
- package/dist/style.css +1 -0
- package/lib/adapters/autoCommands.d.ts +4 -0
- package/lib/adapters/autoCommands.js +111 -0
- package/lib/adapters/backend.d.ts +3 -0
- package/lib/adapters/backend.js +11 -0
- package/lib/adapters/chatlunaIntegration.d.ts +3 -0
- package/lib/adapters/chatlunaIntegration.js +48 -0
- package/lib/adapters/commands.d.ts +4 -0
- package/lib/adapters/commands.js +330 -0
- package/lib/adapters/consoleListeners.d.ts +3 -0
- package/lib/adapters/consoleListeners.js +168 -0
- package/lib/adapters/index.d.ts +2 -0
- package/lib/adapters/index.js +7 -0
- package/lib/adapters/render.d.ts +7 -0
- package/lib/adapters/render.js +35 -0
- package/lib/adapters/restApi.d.ts +3 -0
- package/lib/adapters/restApi.js +225 -0
- package/lib/collectors/autoCollector.d.ts +75 -0
- package/lib/collectors/autoCollector.js +728 -0
- package/lib/collectors/index.d.ts +1 -0
- package/lib/collectors/index.js +17 -0
- package/lib/config.d.ts +194 -0
- package/lib/config.js +369 -0
- package/lib/debug.d.ts +13 -0
- package/lib/debug.js +44 -0
- package/lib/image/imageProcessor.d.ts +20 -0
- package/lib/image/imageProcessor.js +243 -0
- package/lib/image/index.d.ts +1 -0
- package/lib/image/index.js +17 -0
- package/lib/index.d.ts +57 -0
- package/lib/index.js +40 -0
- package/lib/services/aiAnalyzer.d.ts +24 -0
- package/lib/services/aiAnalyzer.js +162 -0
- package/lib/services/emojiluna.d.ts +102 -0
- package/lib/services/emojiluna.js +910 -0
- package/lib/services/index.d.ts +2 -0
- package/lib/services/index.js +18 -0
- package/lib/storage/index.d.ts +1 -0
- package/lib/storage/index.js +17 -0
- package/lib/storage/uploadManager.d.ts +102 -0
- package/lib/storage/uploadManager.js +424 -0
- package/lib/types.d.ts +159 -0
- package/lib/types.js +69 -0
- package/lib/utils.d.ts +29 -0
- package/lib/utils.js +256 -0
- package/package.json +99 -0
- package/readme.md +174 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AutoCollector = void 0;
|
|
4
|
+
const koishi_1 = require("koishi");
|
|
5
|
+
const image_1 = require("../image");
|
|
6
|
+
const utils_1 = require("../utils");
|
|
7
|
+
const HOUR_MS = 3_600_000;
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
const PENDING_REF_TTL_MS = 60_000;
|
|
10
|
+
const REJECTED_HASH_TTL_MS = 6 * HOUR_MS;
|
|
11
|
+
const AI_VERDICT_TTL_MS = 24 * HOUR_MS;
|
|
12
|
+
const SIGNATURE_SAMPLE_FRAMES = 3;
|
|
13
|
+
const AVERAGE_HASH_GRID = { width: 8, height: 8 };
|
|
14
|
+
const DIFFERENCE_HASH_GRID = { width: 9, height: 8 };
|
|
15
|
+
const MAX_SIGNATURE_CACHE = 2000;
|
|
16
|
+
const FRAME_WEIGHT = 0.7;
|
|
17
|
+
const ASPECT_WEIGHT = 0.2;
|
|
18
|
+
const SIZE_WEIGHT = 0.1;
|
|
19
|
+
const TONE_PENALTY_SCALE = 0.6;
|
|
20
|
+
const CONTRAST_PENALTY_SCALE = 0.2;
|
|
21
|
+
const APPEARANCE_PENALTY_CAP = 0.15;
|
|
22
|
+
const RECENT_LOG_SIZE = 8;
|
|
23
|
+
const PROBE_IMAGE_LIMIT = 3;
|
|
24
|
+
const MAX_NAME_LENGTH = 48;
|
|
25
|
+
const SIGNATURE_READY_BUDGET_MS = 15_000;
|
|
26
|
+
const PRELOAD_PAGE_SIZE = 200;
|
|
27
|
+
const POPCOUNT = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
|
|
28
|
+
class AutoCollector {
|
|
29
|
+
ctx;
|
|
30
|
+
config;
|
|
31
|
+
debug;
|
|
32
|
+
signatures = new Map();
|
|
33
|
+
frequency = new Map();
|
|
34
|
+
counters = new Map();
|
|
35
|
+
globalDay = createCounter();
|
|
36
|
+
pendingRefs = new Map();
|
|
37
|
+
inFlight = new Set();
|
|
38
|
+
rejectedHashes = new Map();
|
|
39
|
+
aiVerdicts = new Map();
|
|
40
|
+
queue = [];
|
|
41
|
+
active = 0;
|
|
42
|
+
nameSeq = 0;
|
|
43
|
+
manualEnabled = null;
|
|
44
|
+
signaturesReady = false;
|
|
45
|
+
stopped = false;
|
|
46
|
+
metrics = {
|
|
47
|
+
seen: 0,
|
|
48
|
+
collected: 0,
|
|
49
|
+
rejected: {},
|
|
50
|
+
};
|
|
51
|
+
recent = [];
|
|
52
|
+
constructor(ctx, config, debug) {
|
|
53
|
+
this.ctx = ctx;
|
|
54
|
+
this.config = config;
|
|
55
|
+
this.debug = debug;
|
|
56
|
+
ctx.setInterval(() => this.pruneCaches(), 5 * 60_000);
|
|
57
|
+
void this.preloadSignatures();
|
|
58
|
+
}
|
|
59
|
+
get luna() {
|
|
60
|
+
return this.ctx.emojiluna;
|
|
61
|
+
}
|
|
62
|
+
get isEnabled() {
|
|
63
|
+
return !this.stopped && (this.manualEnabled ?? this.config.autoCollect);
|
|
64
|
+
}
|
|
65
|
+
start() {
|
|
66
|
+
if (!this.config.autoCollect) {
|
|
67
|
+
this.debug.log('collect', '自动获取未启用,跳过监听注册');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.ctx.on('message', (session) => this.observe(session));
|
|
71
|
+
this.ctx.logger.info('Auto collector started');
|
|
72
|
+
}
|
|
73
|
+
observe(session) {
|
|
74
|
+
if (!this.isEnabled)
|
|
75
|
+
return 0;
|
|
76
|
+
const images = koishi_1.h.select(session.elements ?? [], 'img');
|
|
77
|
+
if (images.length === 0)
|
|
78
|
+
return 0;
|
|
79
|
+
const isDirect = Boolean(session.isDirect);
|
|
80
|
+
const chatKey = this.chatKey(session, isDirect);
|
|
81
|
+
this.metrics.seen += images.length;
|
|
82
|
+
const scope = this.evaluateScope(isDirect, session);
|
|
83
|
+
if (!scope.ok) {
|
|
84
|
+
this.reject('生效范围', scope.detail, chatKey);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
let queued = 0;
|
|
88
|
+
for (const element of images) {
|
|
89
|
+
if (this.queue.length >= this.config.autoCollectQueueSize) {
|
|
90
|
+
this.reject('队列', `队列已满 ${this.config.autoCollectQueueSize}`, chatKey);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const limit = this.evaluateLimit(chatKey);
|
|
94
|
+
if (!limit.ok) {
|
|
95
|
+
this.reject('限流', limit.detail, chatKey);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
this.queue.push({ session, element, chatKey, isDirect });
|
|
99
|
+
queued += 1;
|
|
100
|
+
}
|
|
101
|
+
if (queued)
|
|
102
|
+
this.pump();
|
|
103
|
+
return queued;
|
|
104
|
+
}
|
|
105
|
+
async probe(session, dryRun = true, elements) {
|
|
106
|
+
const isDirect = Boolean(session.isDirect);
|
|
107
|
+
const chatKey = this.chatKey(session, isDirect);
|
|
108
|
+
const images = koishi_1.h.select(elements ?? session.elements ?? [], 'img');
|
|
109
|
+
if (images.length === 0) {
|
|
110
|
+
return [{ gate: '取图', ok: false, detail: '消息中没有图片元素' }];
|
|
111
|
+
}
|
|
112
|
+
const trace = [
|
|
113
|
+
this.evaluateScope(isDirect, session),
|
|
114
|
+
this.evaluateLimit(chatKey),
|
|
115
|
+
{
|
|
116
|
+
gate: '服务',
|
|
117
|
+
ok: this.luna.isInitialized,
|
|
118
|
+
detail: this.luna.isInitialized
|
|
119
|
+
? 'emojiluna 已就绪'
|
|
120
|
+
: 'emojiluna 尚未完成初始化,仍将尝试入库',
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
for (const element of images.slice(0, PROBE_IMAGE_LIMIT)) {
|
|
124
|
+
await this.processJob({ session, element, chatKey, isDirect, trace, dryRun }, true);
|
|
125
|
+
}
|
|
126
|
+
return trace;
|
|
127
|
+
}
|
|
128
|
+
stats() {
|
|
129
|
+
return {
|
|
130
|
+
enabled: this.isEnabled,
|
|
131
|
+
configured: this.config.autoCollect,
|
|
132
|
+
mode: this.config.autoCollectMode,
|
|
133
|
+
seen: this.metrics.seen,
|
|
134
|
+
collected: this.metrics.collected,
|
|
135
|
+
rejected: { ...this.metrics.rejected },
|
|
136
|
+
queue: this.queue.length,
|
|
137
|
+
active: this.active,
|
|
138
|
+
signatures: this.signatures.size,
|
|
139
|
+
preloaded: this.signaturesReady,
|
|
140
|
+
recent: [...this.recent],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
setEnabled(enabled) {
|
|
144
|
+
this.manualEnabled = enabled;
|
|
145
|
+
}
|
|
146
|
+
resetCounters() {
|
|
147
|
+
this.counters.clear();
|
|
148
|
+
this.globalDay = createCounter();
|
|
149
|
+
this.metrics = { seen: 0, collected: 0, rejected: {} };
|
|
150
|
+
this.recent = [];
|
|
151
|
+
this.debug.clear();
|
|
152
|
+
}
|
|
153
|
+
dispose() {
|
|
154
|
+
this.stopped = true;
|
|
155
|
+
this.queue = [];
|
|
156
|
+
}
|
|
157
|
+
pump() {
|
|
158
|
+
while (!this.stopped &&
|
|
159
|
+
this.active < this.config.autoCollectQueue &&
|
|
160
|
+
this.queue.length > 0) {
|
|
161
|
+
const job = this.queue.shift();
|
|
162
|
+
this.active += 1;
|
|
163
|
+
this.processJob(job, false)
|
|
164
|
+
.catch((error) => this.ctx.logger.error(`Auto collect job failed: ${error.message}`))
|
|
165
|
+
.finally(() => {
|
|
166
|
+
this.active -= 1;
|
|
167
|
+
this.pump();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async processJob(job, probe) {
|
|
172
|
+
const { session, element, chatKey, isDirect, dryRun } = job;
|
|
173
|
+
const pass = (result) => {
|
|
174
|
+
job.trace?.push(result);
|
|
175
|
+
this.debug.log('collect', `${result.gate}: ${result.detail}`);
|
|
176
|
+
if (result.ok)
|
|
177
|
+
return true;
|
|
178
|
+
if (!probe)
|
|
179
|
+
this.reject(result.gate, result.detail, chatKey);
|
|
180
|
+
return probe;
|
|
181
|
+
};
|
|
182
|
+
const ref = (0, utils_1.getImageRefs)(element)[0];
|
|
183
|
+
if (!ref) {
|
|
184
|
+
return void pass({
|
|
185
|
+
gate: '取图',
|
|
186
|
+
ok: false,
|
|
187
|
+
detail: '图片元素没有 url / src / file / path 属性',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (this.pendingRefs.has(ref)) {
|
|
191
|
+
return void pass({
|
|
192
|
+
gate: '取图',
|
|
193
|
+
ok: false,
|
|
194
|
+
detail: `同一来源正在处理: ${ref}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
this.pendingRefs.set(ref, Date.now());
|
|
198
|
+
try {
|
|
199
|
+
const image = await (0, utils_1.readImageBuffer)(this.ctx, element, {
|
|
200
|
+
timeout: this.config.fetchTimeout,
|
|
201
|
+
allowLocalPaths: this.config.allowLocalPaths,
|
|
202
|
+
localPathRoots: this.config.localPathRoots,
|
|
203
|
+
});
|
|
204
|
+
if (image.kind !== 'ok') {
|
|
205
|
+
return void pass({
|
|
206
|
+
gate: image.failure === 'unsupported-format' ? '格式' : '取图',
|
|
207
|
+
ok: false,
|
|
208
|
+
detail: image.detail,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const size = this.evaluateSize(image.buffer.length);
|
|
212
|
+
if (!pass(size))
|
|
213
|
+
return;
|
|
214
|
+
const hash = (0, utils_1.hashBuffer)(image.buffer);
|
|
215
|
+
if (!pass(this.evaluateKnownHash(hash)))
|
|
216
|
+
return;
|
|
217
|
+
this.inFlight.add(hash);
|
|
218
|
+
try {
|
|
219
|
+
if (!pass(this.evaluateFrequency(hash, chatKey)))
|
|
220
|
+
return;
|
|
221
|
+
const signature = await this.buildSignature(image.buffer);
|
|
222
|
+
if (signature) {
|
|
223
|
+
if (!pass(this.evaluateResolution(signature)))
|
|
224
|
+
return;
|
|
225
|
+
if (!pass(this.evaluateSimilarity(signature)))
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!pass(await this.evaluateAiFilter(hash, image.buffer)))
|
|
229
|
+
return;
|
|
230
|
+
if (!pass(this.evaluateLibrary()))
|
|
231
|
+
return;
|
|
232
|
+
if (dryRun) {
|
|
233
|
+
return void pass({
|
|
234
|
+
gate: '入库',
|
|
235
|
+
ok: true,
|
|
236
|
+
detail: `试运行:将保存为 "${this.buildName(session, isDirect)}",未实际写入`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const saved = await this.save(image.buffer, signature, hash, session, isDirect);
|
|
240
|
+
if (!pass(saved))
|
|
241
|
+
return;
|
|
242
|
+
this.consumeQuota(chatKey);
|
|
243
|
+
this.metrics.collected += 1;
|
|
244
|
+
this.record(`${saved.detail} @ ${chatKey}`);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
this.inFlight.delete(hash);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.pendingRefs.delete(ref);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async save(buffer, signature, hash, session, isDirect) {
|
|
255
|
+
const name = this.buildName(session, isDirect);
|
|
256
|
+
const tags = this.buildTags(session, isDirect);
|
|
257
|
+
try {
|
|
258
|
+
const emoji = await this.luna.addEmoji({ name, category: this.config.autoCollectCategory, tags }, buffer);
|
|
259
|
+
if (signature)
|
|
260
|
+
this.rememberSignature(hash, signature);
|
|
261
|
+
return {
|
|
262
|
+
gate: '入库',
|
|
263
|
+
ok: true,
|
|
264
|
+
detail: `已收集 "${emoji.name}" (${emoji.id}),${this.config.autoAnalyze ? '已排入 AI 分析队列' : '未启用 AI 分析'}`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
const message = error.message ?? String(error);
|
|
269
|
+
if (/already exists|已存在/i.test(message)) {
|
|
270
|
+
this.rejectedHashes.set(hash, Date.now());
|
|
271
|
+
}
|
|
272
|
+
return { gate: '入库', ok: false, detail: message };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
evaluateScope(isDirect, session) {
|
|
276
|
+
if (isDirect) {
|
|
277
|
+
return {
|
|
278
|
+
gate: '生效范围',
|
|
279
|
+
ok: this.config.collectDirect,
|
|
280
|
+
detail: this.config.collectDirect
|
|
281
|
+
? '私聊已启用'
|
|
282
|
+
: '私聊自动获取未启用',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (!this.config.collectGroup) {
|
|
286
|
+
return {
|
|
287
|
+
gate: '生效范围',
|
|
288
|
+
ok: false,
|
|
289
|
+
detail: '群聊自动获取未启用',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const guildId = String(session.guildId ?? session.channelId ?? '');
|
|
293
|
+
const whitelist = this.config.whitelistGroups.map(String);
|
|
294
|
+
const blacklist = this.config.blacklistGroups.map(String);
|
|
295
|
+
const listed = (list) => list.includes(guildId) || list.includes(String(session.channelId));
|
|
296
|
+
if (this.config.groupScope === 'whitelist') {
|
|
297
|
+
return {
|
|
298
|
+
gate: '生效范围',
|
|
299
|
+
ok: listed(whitelist),
|
|
300
|
+
detail: listed(whitelist)
|
|
301
|
+
? `白名单群 ${guildId}`
|
|
302
|
+
: `群 ${guildId} 不在白名单内`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (this.config.groupScope === 'blacklist') {
|
|
306
|
+
return {
|
|
307
|
+
gate: '生效范围',
|
|
308
|
+
ok: !listed(blacklist),
|
|
309
|
+
detail: listed(blacklist)
|
|
310
|
+
? `群 ${guildId} 在黑名单内`
|
|
311
|
+
: `群 ${guildId} 未被排除`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { gate: '生效范围', ok: true, detail: `所有群(含 ${guildId})` };
|
|
315
|
+
}
|
|
316
|
+
evaluateSize(bytes) {
|
|
317
|
+
const sizeKB = bytes / 1024;
|
|
318
|
+
const sizeMB = sizeKB / 1024;
|
|
319
|
+
if (sizeKB < this.config.minEmojiSize) {
|
|
320
|
+
return {
|
|
321
|
+
gate: '大小',
|
|
322
|
+
ok: false,
|
|
323
|
+
detail: `过小 ${sizeKB.toFixed(1)}KB < ${this.config.minEmojiSize}KB`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (sizeMB > this.config.maxEmojiSize) {
|
|
327
|
+
return {
|
|
328
|
+
gate: '大小',
|
|
329
|
+
ok: false,
|
|
330
|
+
detail: `过大 ${sizeMB.toFixed(2)}MB > ${this.config.maxEmojiSize}MB`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
gate: '大小',
|
|
335
|
+
ok: true,
|
|
336
|
+
detail: sizeKB > 1024 ? `${sizeMB.toFixed(2)}MB` : `${sizeKB.toFixed(1)}KB`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
evaluateResolution(signature) {
|
|
340
|
+
const min = this.config.minEdgePixels;
|
|
341
|
+
const edge = Math.min(signature.width, signature.height);
|
|
342
|
+
if (min > 0 && edge < min) {
|
|
343
|
+
return {
|
|
344
|
+
gate: '分辨率',
|
|
345
|
+
ok: false,
|
|
346
|
+
detail: `${signature.width}x${signature.height} 短边 ${edge} < ${min}`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
gate: '分辨率',
|
|
351
|
+
ok: true,
|
|
352
|
+
detail: `${signature.width}x${signature.height}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
evaluateKnownHash(hash) {
|
|
356
|
+
if (this.inFlight.has(hash)) {
|
|
357
|
+
return { gate: '去重', ok: false, detail: '同一张图片正在处理中' };
|
|
358
|
+
}
|
|
359
|
+
if (this.luna.uploadManager.hasHash(hash)) {
|
|
360
|
+
return { gate: '去重', ok: false, detail: '库中已存在相同内容的表情' };
|
|
361
|
+
}
|
|
362
|
+
const rejectedAt = this.rejectedHashes.get(hash);
|
|
363
|
+
if (rejectedAt && Date.now() - rejectedAt < REJECTED_HASH_TTL_MS) {
|
|
364
|
+
return { gate: '去重', ok: false, detail: '此前已判定为重复,暂不再试' };
|
|
365
|
+
}
|
|
366
|
+
return { gate: '去重', ok: true, detail: `内容哈希 ${hash.slice(0, 8)}` };
|
|
367
|
+
}
|
|
368
|
+
evaluateSimilarity(signature) {
|
|
369
|
+
let best = null;
|
|
370
|
+
for (const [hash, cached] of this.signatures) {
|
|
371
|
+
const score = similarityScore(signature, cached);
|
|
372
|
+
if (score >= this.config.similarityThreshold && (!best || score > best.score)) {
|
|
373
|
+
best = { hash, score };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!best) {
|
|
377
|
+
return {
|
|
378
|
+
gate: '相似',
|
|
379
|
+
ok: true,
|
|
380
|
+
detail: `与近 ${this.signatures.size} 张特征均不相似`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
gate: '相似',
|
|
385
|
+
ok: false,
|
|
386
|
+
detail: `与表情 ${best.hash.slice(0, 8)} 相似度 ${best.score.toFixed(3)},阈值 ${this.config.similarityThreshold}`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
evaluateFrequency(hash, chatKey) {
|
|
390
|
+
const windowMs = this.config.frequencyWindow * 60_000;
|
|
391
|
+
const key = `${hash}|${chatKey}`;
|
|
392
|
+
const now = Date.now();
|
|
393
|
+
const stamps = (this.frequency.get(key) ?? []).filter((stamp) => now - stamp <= windowMs);
|
|
394
|
+
stamps.push(now);
|
|
395
|
+
this.frequency.set(key, stamps);
|
|
396
|
+
const reached = stamps.length >= this.config.emojiFrequencyThreshold;
|
|
397
|
+
if (this.config.autoCollectMode !== 'frequency') {
|
|
398
|
+
return {
|
|
399
|
+
gate: '频次',
|
|
400
|
+
ok: true,
|
|
401
|
+
detail: `窗口内第 ${stamps.length} 次(即时模式不要求频次)`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
gate: '频次',
|
|
406
|
+
ok: reached,
|
|
407
|
+
detail: `窗口内第 ${stamps.length}/${this.config.emojiFrequencyThreshold} 次`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
evaluateLimit(chatKey) {
|
|
411
|
+
const counter = this.windowCounter(chatKey);
|
|
412
|
+
const quota = this.config.groupAutoCollectLimit?.[chatKey.split(':')[1]];
|
|
413
|
+
const hourLimit = quota?.hourLimit ?? 20;
|
|
414
|
+
const dayLimit = quota?.dayLimit ?? 100;
|
|
415
|
+
if (hourLimit > 0 && counter.hourCount >= hourLimit) {
|
|
416
|
+
return {
|
|
417
|
+
gate: '限流',
|
|
418
|
+
ok: false,
|
|
419
|
+
detail: `本会话本小时已收集 ${counter.hourCount}/${hourLimit}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
if (dayLimit > 0 && counter.dayCount >= dayLimit) {
|
|
423
|
+
return {
|
|
424
|
+
gate: '限流',
|
|
425
|
+
ok: false,
|
|
426
|
+
detail: `本会话今日已收集 ${counter.dayCount}/${dayLimit}`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (this.config.globalDayLimit > 0 &&
|
|
430
|
+
this.globalDay.dayCount >= this.config.globalDayLimit) {
|
|
431
|
+
return {
|
|
432
|
+
gate: '限流',
|
|
433
|
+
ok: false,
|
|
434
|
+
detail: `全局今日已收集 ${this.globalDay.dayCount}/${this.config.globalDayLimit}`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
gate: '限流',
|
|
439
|
+
ok: true,
|
|
440
|
+
detail: `本会话 ${counter.hourCount}/${hourLimit} 每小时、${counter.dayCount}/${dayLimit} 每日`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
evaluateLibrary() {
|
|
444
|
+
const limit = this.config.maxEmojiCount;
|
|
445
|
+
const count = this.luna.getEmojiCount();
|
|
446
|
+
if (limit > 0 && count >= limit) {
|
|
447
|
+
return { gate: '库存', ok: false, detail: `库内 ${count}/${limit} 已达上限` };
|
|
448
|
+
}
|
|
449
|
+
return { gate: '库存', ok: true, detail: `库内 ${count} 张` };
|
|
450
|
+
}
|
|
451
|
+
async evaluateAiFilter(hash, buffer) {
|
|
452
|
+
if (!this.config.enableImageTypeFilter) {
|
|
453
|
+
return { gate: 'AI过滤', ok: true, detail: '未启用图片类型过滤' };
|
|
454
|
+
}
|
|
455
|
+
const cached = this.aiVerdicts.get(hash);
|
|
456
|
+
if (cached && Date.now() - cached.at < AI_VERDICT_TTL_MS) {
|
|
457
|
+
const { gate, ok, detail } = cached;
|
|
458
|
+
return { gate, ok, detail: `复用上次判定:${detail}` };
|
|
459
|
+
}
|
|
460
|
+
const fallbackOk = this.config.aiFilterFallback === 'accept';
|
|
461
|
+
let verdict = {
|
|
462
|
+
gate: 'AI过滤',
|
|
463
|
+
ok: fallbackOk,
|
|
464
|
+
detail: fallbackOk
|
|
465
|
+
? 'AI 未返回判定,按兜底策略接受'
|
|
466
|
+
: 'AI 未返回判定,按兜底策略拒绝',
|
|
467
|
+
};
|
|
468
|
+
try {
|
|
469
|
+
const result = await (0, utils_1.withTimeout)(this.luna.filterImageByType(buffer.toString('base64')), this.config.aiFilterTimeout, 'AI 图片类型过滤');
|
|
470
|
+
if (result) {
|
|
471
|
+
verdict = {
|
|
472
|
+
gate: 'AI过滤',
|
|
473
|
+
ok: result.isAcceptable,
|
|
474
|
+
detail: `类型 ${result.imageType},置信度 ${result.confidence ?? 0},${result.reason ?? ''}`.trim(),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
verdict = {
|
|
480
|
+
gate: 'AI过滤',
|
|
481
|
+
ok: fallbackOk,
|
|
482
|
+
detail: `AI 过滤失败(${error.message}),按兜底策略${fallbackOk ? '接受' : '拒绝'}`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
this.aiVerdicts.set(hash, { ...verdict, at: Date.now() });
|
|
486
|
+
return verdict;
|
|
487
|
+
}
|
|
488
|
+
async buildSignature(buffer) {
|
|
489
|
+
try {
|
|
490
|
+
const metadata = await (0, image_1.getImageMetadata)(buffer);
|
|
491
|
+
const indices = (0, image_1.sampleFrameIndices)(metadata.frameCount, SIGNATURE_SAMPLE_FRAMES);
|
|
492
|
+
const frames = [];
|
|
493
|
+
for (const index of indices) {
|
|
494
|
+
const frame = await (0, image_1.extractFrameRgba)(buffer, metadata, index);
|
|
495
|
+
const averagePixels = await (0, image_1.resizeFrameToGrayscale)(frame, AVERAGE_HASH_GRID.width, AVERAGE_HASH_GRID.height);
|
|
496
|
+
const differencePixels = await (0, image_1.resizeFrameToGrayscale)(frame, DIFFERENCE_HASH_GRID.width, DIFFERENCE_HASH_GRID.height);
|
|
497
|
+
frames.push({
|
|
498
|
+
averageHash: averageHash(averagePixels),
|
|
499
|
+
differenceHash: differenceHash(differencePixels),
|
|
500
|
+
...describeTone(averagePixels),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
if (!frames.length || !metadata.width || !metadata.height)
|
|
504
|
+
return null;
|
|
505
|
+
return { frames, width: metadata.width, height: metadata.height };
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
this.debug.log('collect', `感知特征提取失败,跳过相似判定: ${error.message}`);
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async preloadSignatures() {
|
|
513
|
+
const startedAt = Date.now();
|
|
514
|
+
try {
|
|
515
|
+
await (0, utils_1.withTimeout)(this.luna.ready, SIGNATURE_READY_BUDGET_MS, 'emojiluna 初始化');
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
this.ctx.logger.warn(`EmojiLuna is not ready for auto collect preload: ${error.message}`);
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
let offset = 0;
|
|
522
|
+
while (!this.stopped) {
|
|
523
|
+
const page = await this.luna.getEmojiPage({
|
|
524
|
+
limit: PRELOAD_PAGE_SIZE,
|
|
525
|
+
offset,
|
|
526
|
+
});
|
|
527
|
+
const items = page?.items ?? [];
|
|
528
|
+
if (!items.length)
|
|
529
|
+
break;
|
|
530
|
+
for (const emoji of items) {
|
|
531
|
+
const buffer = await this.luna.readEmojiBuffer(emoji);
|
|
532
|
+
if (!buffer)
|
|
533
|
+
continue;
|
|
534
|
+
const signature = await this.buildSignature(buffer);
|
|
535
|
+
if (signature)
|
|
536
|
+
this.rememberSignature((0, utils_1.hashBuffer)(buffer), signature);
|
|
537
|
+
}
|
|
538
|
+
offset += items.length;
|
|
539
|
+
if (items.length < PRELOAD_PAGE_SIZE)
|
|
540
|
+
break;
|
|
541
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
this.ctx.logger.warn(`Failed to preload emoji signatures: ${error.message}`);
|
|
546
|
+
}
|
|
547
|
+
this.signaturesReady = true;
|
|
548
|
+
this.debug.log('collect', `预热感知特征完成:${this.signatures.size} 条,用时 ${Date.now() - startedAt}ms`);
|
|
549
|
+
}
|
|
550
|
+
rememberSignature(hash, signature) {
|
|
551
|
+
if (this.signatures.has(hash))
|
|
552
|
+
this.signatures.delete(hash);
|
|
553
|
+
this.signatures.set(hash, signature);
|
|
554
|
+
while (this.signatures.size > MAX_SIGNATURE_CACHE) {
|
|
555
|
+
const oldest = this.signatures.keys().next().value;
|
|
556
|
+
this.signatures.delete(oldest);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
windowCounter(chatKey) {
|
|
560
|
+
const now = Date.now();
|
|
561
|
+
const counter = this.counters.get(chatKey) ?? createCounter(now);
|
|
562
|
+
this.counters.set(chatKey, counter);
|
|
563
|
+
if (now - counter.hourStart >= HOUR_MS) {
|
|
564
|
+
counter.hourStart = now;
|
|
565
|
+
counter.hourCount = 0;
|
|
566
|
+
}
|
|
567
|
+
if (now - counter.dayStart >= DAY_MS) {
|
|
568
|
+
counter.dayStart = now;
|
|
569
|
+
counter.dayCount = 0;
|
|
570
|
+
}
|
|
571
|
+
return counter;
|
|
572
|
+
}
|
|
573
|
+
consumeQuota(chatKey) {
|
|
574
|
+
const counter = this.windowCounter(chatKey);
|
|
575
|
+
counter.hourCount += 1;
|
|
576
|
+
counter.dayCount += 1;
|
|
577
|
+
const now = Date.now();
|
|
578
|
+
if (now - this.globalDay.dayStart >= DAY_MS) {
|
|
579
|
+
this.globalDay = createCounter(now);
|
|
580
|
+
}
|
|
581
|
+
this.globalDay.dayCount += 1;
|
|
582
|
+
}
|
|
583
|
+
pruneCaches() {
|
|
584
|
+
const now = Date.now();
|
|
585
|
+
const windowMs = this.config.frequencyWindow * 60_000;
|
|
586
|
+
pruneExpired(this.pendingRefs, now, PENDING_REF_TTL_MS);
|
|
587
|
+
pruneExpired(this.rejectedHashes, now, REJECTED_HASH_TTL_MS);
|
|
588
|
+
pruneByTimestamp(this.aiVerdicts, now, AI_VERDICT_TTL_MS);
|
|
589
|
+
for (const [key, stamps] of this.frequency) {
|
|
590
|
+
const kept = stamps.filter((stamp) => now - stamp <= windowMs);
|
|
591
|
+
if (kept.length)
|
|
592
|
+
this.frequency.set(key, kept);
|
|
593
|
+
else
|
|
594
|
+
this.frequency.delete(key);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
chatKey(session, isDirect) {
|
|
598
|
+
return isDirect
|
|
599
|
+
? `direct:${session.userId ?? session.channelId}`
|
|
600
|
+
: `group:${session.guildId ?? session.channelId}`;
|
|
601
|
+
}
|
|
602
|
+
buildName(session, isDirect) {
|
|
603
|
+
const now = new Date();
|
|
604
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
605
|
+
const tokens = {
|
|
606
|
+
date: `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`,
|
|
607
|
+
time: `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`,
|
|
608
|
+
sender: String(session.username || session.userId || 'unknown'),
|
|
609
|
+
chat: isDirect
|
|
610
|
+
? 'direct'
|
|
611
|
+
: String(session.guildId ?? session.channelId ?? ''),
|
|
612
|
+
index: String(++this.nameSeq % 100_000),
|
|
613
|
+
};
|
|
614
|
+
return this.config.autoCollectNameTemplate
|
|
615
|
+
.replace(/\{(\w+)\}/g, (_, key) => tokens[key] ?? `{${key}}`)
|
|
616
|
+
.slice(0, MAX_NAME_LENGTH);
|
|
617
|
+
}
|
|
618
|
+
buildTags(session, isDirect) {
|
|
619
|
+
const scope = isDirect
|
|
620
|
+
? '私聊'
|
|
621
|
+
: `群:${session.guildId ?? session.channelId ?? 'unknown'}`;
|
|
622
|
+
return [...new Set(['自动获取', scope])]
|
|
623
|
+
.map((tag) => tag.trim())
|
|
624
|
+
.filter(Boolean);
|
|
625
|
+
}
|
|
626
|
+
reject(gate, detail, chatKey) {
|
|
627
|
+
this.metrics.rejected[gate] = (this.metrics.rejected[gate] ?? 0) + 1;
|
|
628
|
+
this.record(`拦截 ${gate} @ ${chatKey}: ${detail}`);
|
|
629
|
+
}
|
|
630
|
+
record(text) {
|
|
631
|
+
this.recent.push(text);
|
|
632
|
+
while (this.recent.length > RECENT_LOG_SIZE)
|
|
633
|
+
this.recent.shift();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
exports.AutoCollector = AutoCollector;
|
|
637
|
+
function createCounter(now = Date.now()) {
|
|
638
|
+
return { hourStart: now, hourCount: 0, dayStart: now, dayCount: 0 };
|
|
639
|
+
}
|
|
640
|
+
function pruneExpired(map, now, ttl) {
|
|
641
|
+
for (const [key, at] of map) {
|
|
642
|
+
if (now - at > ttl)
|
|
643
|
+
map.delete(key);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function pruneByTimestamp(map, now, ttl) {
|
|
647
|
+
for (const [key, value] of map) {
|
|
648
|
+
if (now - value.at > ttl)
|
|
649
|
+
map.delete(key);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function toHex(bits) {
|
|
653
|
+
let hex = '';
|
|
654
|
+
for (let i = 0; i < bits.length; i += 4) {
|
|
655
|
+
hex += parseInt(bits.slice(i, i + 4), 2).toString(16);
|
|
656
|
+
}
|
|
657
|
+
return hex;
|
|
658
|
+
}
|
|
659
|
+
function averageHash(pixels) {
|
|
660
|
+
const total = pixels.reduce((sum, value) => sum + value, 0);
|
|
661
|
+
const mean = total / (pixels.length || 1);
|
|
662
|
+
let bits = '';
|
|
663
|
+
for (const value of pixels)
|
|
664
|
+
bits += value > mean ? '1' : '0';
|
|
665
|
+
return toHex(bits);
|
|
666
|
+
}
|
|
667
|
+
function differenceHash(pixels) {
|
|
668
|
+
const { width, height } = DIFFERENCE_HASH_GRID;
|
|
669
|
+
let bits = '';
|
|
670
|
+
for (let y = 0; y < height; y++) {
|
|
671
|
+
const row = y * width;
|
|
672
|
+
for (let x = 0; x < width - 1; x++) {
|
|
673
|
+
bits += pixels[row + x] > pixels[row + x + 1] ? '1' : '0';
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return toHex(bits);
|
|
677
|
+
}
|
|
678
|
+
function describeTone(pixels) {
|
|
679
|
+
const total = pixels.length || 1;
|
|
680
|
+
const mean = pixels.reduce((sum, value) => sum + value, 0) / total;
|
|
681
|
+
const variance = pixels.reduce((sum, value) => sum + (value - mean) ** 2, 0) / total;
|
|
682
|
+
return { mean, stdev: Math.sqrt(variance) };
|
|
683
|
+
}
|
|
684
|
+
function hexHamming(left, right) {
|
|
685
|
+
const length = Math.min(left.length, right.length);
|
|
686
|
+
let distance = 0;
|
|
687
|
+
for (let i = 0; i < length; i++) {
|
|
688
|
+
const a = parseInt(left[i], 16);
|
|
689
|
+
const b = parseInt(right[i], 16);
|
|
690
|
+
if (Number.isNaN(a) || Number.isNaN(b)) {
|
|
691
|
+
distance += 4;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
distance += POPCOUNT[(a ^ b) & 0xf];
|
|
695
|
+
}
|
|
696
|
+
return distance + Math.abs(left.length - right.length) * 4;
|
|
697
|
+
}
|
|
698
|
+
function hashSimilarity(left, right) {
|
|
699
|
+
const bits = Math.max(left.length, right.length) * 4;
|
|
700
|
+
if (!bits)
|
|
701
|
+
return 0;
|
|
702
|
+
return 1 - hexHamming(left, right) / bits;
|
|
703
|
+
}
|
|
704
|
+
function frameSimilarity(left, right) {
|
|
705
|
+
const structure = 0.5 * hashSimilarity(left.averageHash, right.averageHash) +
|
|
706
|
+
0.5 * hashSimilarity(left.differenceHash, right.differenceHash);
|
|
707
|
+
const penalty = Math.min(APPEARANCE_PENALTY_CAP, (Math.abs(left.mean - right.mean) / 255) * TONE_PENALTY_SCALE +
|
|
708
|
+
(Math.abs(left.stdev - right.stdev) / 128) * CONTRAST_PENALTY_SCALE);
|
|
709
|
+
return Math.max(0, structure - penalty);
|
|
710
|
+
}
|
|
711
|
+
function framesSimilarity(frames, others) {
|
|
712
|
+
if (!frames.length || !others.length)
|
|
713
|
+
return 0;
|
|
714
|
+
const best = frames.map((frame) => Math.max(...others.map((other) => frameSimilarity(frame, other))));
|
|
715
|
+
return best.reduce((sum, score) => sum + score, 0) / best.length;
|
|
716
|
+
}
|
|
717
|
+
function similarityScore(left, right) {
|
|
718
|
+
const aspectOf = (item) => item.height === 0 ? 0 : item.width / item.height;
|
|
719
|
+
const aspect = Math.max(0, 1 - Math.abs(aspectOf(left) - aspectOf(right)));
|
|
720
|
+
const areaLeft = left.width * left.height;
|
|
721
|
+
const areaRight = right.width * right.height;
|
|
722
|
+
const size = areaLeft > 0 && areaRight > 0
|
|
723
|
+
? Math.min(areaLeft, areaRight) / Math.max(areaLeft, areaRight)
|
|
724
|
+
: 0;
|
|
725
|
+
return (framesSimilarity(left.frames, right.frames) * FRAME_WEIGHT +
|
|
726
|
+
aspect * ASPECT_WEIGHT +
|
|
727
|
+
size * SIZE_WEIGHT);
|
|
728
|
+
}
|