mioku-plugin-chat 2.5.0 → 2.5.2
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/core/media/image-analyzer.ts +87 -7
- package/db.ts +4 -1
- package/handlers/message.ts +4 -0
- package/package.json +1 -1
- package/utils/message.ts +78 -12
|
@@ -27,6 +27,13 @@ export interface ImageAnalysisResult {
|
|
|
27
27
|
|
|
28
28
|
export interface ImageAnalysisOptions {
|
|
29
29
|
runAIRequest?<T>(request: () => Promise<T>): Promise<T | null>;
|
|
30
|
+
/**
|
|
31
|
+
* When non-empty, only memes whose detected character is in this list
|
|
32
|
+
* get downloaded into `data/chat/meme/<character>/<emotion>/`. Other
|
|
33
|
+
* characters still get a DB record but no local file.
|
|
34
|
+
* Empty (default) = no restriction; save for every detected character.
|
|
35
|
+
*/
|
|
36
|
+
allowedCharacters?: string[];
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
function formatImageRecognitionLog(record: {
|
|
@@ -382,8 +389,29 @@ export async function processImage(
|
|
|
382
389
|
? analysis.characters
|
|
383
390
|
: [analysis.character || "unknown"];
|
|
384
391
|
|
|
385
|
-
//
|
|
386
|
-
const
|
|
392
|
+
// 应用 allowedCharacters 过滤:配置非空时只对列表内角色本地保存
|
|
393
|
+
const allowed = options?.allowedCharacters;
|
|
394
|
+
const hasRestriction = Array.isArray(allowed) && allowed.length > 0;
|
|
395
|
+
const normalizeName = (s: string) => String(s || "").trim().toLowerCase();
|
|
396
|
+
const allowedSet = hasRestriction
|
|
397
|
+
? new Set((allowed as string[]).map(normalizeName))
|
|
398
|
+
: null;
|
|
399
|
+
|
|
400
|
+
const saveableCharacters = allowedSet
|
|
401
|
+
? characters.filter((c) => allowedSet.has(normalizeName(c)))
|
|
402
|
+
: characters;
|
|
403
|
+
|
|
404
|
+
const skipped = characters.filter(
|
|
405
|
+
(c) => !saveableCharacters.includes(c),
|
|
406
|
+
);
|
|
407
|
+
if (skipped.length > 0) {
|
|
408
|
+
logger.info(
|
|
409
|
+
`[image-analyzer] Skipping local file save for characters outside allowedCharacters: ${skipped.join(", ")}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 为每个允许保存的角色创建文件夹并保存图片
|
|
414
|
+
const savePromises = saveableCharacters.map(async (character) => {
|
|
387
415
|
const memeDir = path.join(
|
|
388
416
|
process.cwd(),
|
|
389
417
|
"data",
|
|
@@ -429,7 +457,7 @@ export async function processImage(
|
|
|
429
457
|
// 保存到数据库
|
|
430
458
|
const record: ImageRecord = {
|
|
431
459
|
hash,
|
|
432
|
-
url: imageUrl,
|
|
460
|
+
url: normalizeImageUrl(imageUrl) || imageUrl,
|
|
433
461
|
type: analysis.type,
|
|
434
462
|
description: analysis.description || "未知",
|
|
435
463
|
emotion: analysis.emotion,
|
|
@@ -452,12 +480,64 @@ export async function processImage(
|
|
|
452
480
|
* 命中数据库记录时返回 [meme:描述] 或 [image:描述],否则返回 [image]。
|
|
453
481
|
* 收集聊天历史时用它,之前没识别到的图片不会再次触发请求。
|
|
454
482
|
*/
|
|
455
|
-
export function getImageTag(imageUrl: string, db: ChatDatabase): string {
|
|
456
|
-
const
|
|
483
|
+
export async function getImageTag(imageUrl: string, db: ChatDatabase): Promise<string> {
|
|
484
|
+
const exact = db.getImageByUrl(imageUrl);
|
|
485
|
+
if (exact) {
|
|
486
|
+
return `[${exact.type}:${exact.description}]`;
|
|
487
|
+
}
|
|
457
488
|
|
|
458
|
-
|
|
459
|
-
|
|
489
|
+
const normalized = normalizeImageUrl(imageUrl);
|
|
490
|
+
if (normalized && normalized !== imageUrl) {
|
|
491
|
+
const normalizedHit = db.getImageByUrl(normalized);
|
|
492
|
+
if (normalizedHit) {
|
|
493
|
+
return `[${normalizedHit.type}:${normalizedHit.description}]`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const hash = await calculateImageHash(imageUrl);
|
|
498
|
+
if (hash) {
|
|
499
|
+
const byHash = db.getImageByHash(hash);
|
|
500
|
+
if (byHash) {
|
|
501
|
+
try {
|
|
502
|
+
db.saveImage({
|
|
503
|
+
hash: byHash.hash,
|
|
504
|
+
url: normalized || imageUrl,
|
|
505
|
+
type: byHash.type,
|
|
506
|
+
description: byHash.description,
|
|
507
|
+
emotion: byHash.emotion,
|
|
508
|
+
character: byHash.character,
|
|
509
|
+
filePath: byHash.filePath,
|
|
510
|
+
createdAt: byHash.createdAt,
|
|
511
|
+
});
|
|
512
|
+
} catch {
|
|
513
|
+
// ignore — best-effort URL caching for next time
|
|
514
|
+
}
|
|
515
|
+
return `[${byHash.type}:${byHash.description}]`;
|
|
516
|
+
}
|
|
460
517
|
}
|
|
461
518
|
|
|
462
519
|
return "[image]";
|
|
463
520
|
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Strip query string and hash so URLs from different NapCat code paths
|
|
524
|
+
* (live vs. get_group_msg_history) still match. QQ gchat.qpic.cn URLs
|
|
525
|
+
* include varying `?term=2`, `?spec=0`, signed tokens, etc.
|
|
526
|
+
*/
|
|
527
|
+
export function normalizeImageUrl(url: string): string {
|
|
528
|
+
if (!url || typeof url !== "string") return "";
|
|
529
|
+
const trimmed = url.trim();
|
|
530
|
+
if (!trimmed) return "";
|
|
531
|
+
try {
|
|
532
|
+
const u = new URL(trimmed);
|
|
533
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
534
|
+
} catch {
|
|
535
|
+
const qIdx = trimmed.indexOf("?");
|
|
536
|
+
const hashIdx = trimmed.indexOf("#");
|
|
537
|
+
let cutAt = -1;
|
|
538
|
+
if (qIdx >= 0 && hashIdx >= 0) cutAt = Math.min(qIdx, hashIdx);
|
|
539
|
+
else if (qIdx >= 0) cutAt = qIdx;
|
|
540
|
+
else if (hashIdx >= 0) cutAt = hashIdx;
|
|
541
|
+
return cutAt >= 0 ? trimmed.slice(0, cutAt) : trimmed;
|
|
542
|
+
}
|
|
543
|
+
}
|
package/db.ts
CHANGED
|
@@ -377,8 +377,11 @@ export async function initDatabase(): Promise<ChatDatabase> {
|
|
|
377
377
|
`),
|
|
378
378
|
// 图片记录
|
|
379
379
|
insertImage: db.prepare(`
|
|
380
|
-
INSERT
|
|
380
|
+
INSERT INTO images (hash, url, type, description, emotion, character, file_path, created_at)
|
|
381
381
|
VALUES ($hash, $url, $type, $description, $emotion, $character, $filePath, $createdAt)
|
|
382
|
+
ON CONFLICT(hash) DO UPDATE SET
|
|
383
|
+
url = excluded.url,
|
|
384
|
+
created_at = excluded.created_at
|
|
382
385
|
`),
|
|
383
386
|
getImageByHash: db.prepare(`SELECT * FROM images WHERE hash = $hash`),
|
|
384
387
|
getImageByUrl: db.prepare(`SELECT * FROM images WHERE url = $url`),
|
package/handlers/message.ts
CHANGED
|
@@ -104,6 +104,10 @@ export function createMessageHandler(
|
|
|
104
104
|
groupId,
|
|
105
105
|
label: "image-analysis",
|
|
106
106
|
}),
|
|
107
|
+
allowedCharacters:
|
|
108
|
+
cfg.emoji?.characters && cfg.emoji.characters.length > 0
|
|
109
|
+
? cfg.emoji.characters
|
|
110
|
+
: undefined,
|
|
107
111
|
}).catch((err) =>
|
|
108
112
|
ctx.logger.error(`[image-analyzer] Failed: ${err}`),
|
|
109
113
|
);
|
package/package.json
CHANGED
package/utils/message.ts
CHANGED
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
type HistoryMediaProcessingOptions,
|
|
15
15
|
type MediaMessageSegment,
|
|
16
16
|
} from "../core/media/history-media";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
calculateImageHash,
|
|
19
|
+
normalizeImageUrl,
|
|
20
|
+
} from "../core/media/image-analyzer";
|
|
18
21
|
|
|
19
22
|
const HISTORY_MEDIA_CONCURRENCY = 8;
|
|
20
23
|
|
|
@@ -217,11 +220,14 @@ interface HistoryFormatContext {
|
|
|
217
220
|
db:
|
|
218
221
|
| {
|
|
219
222
|
getImageByUrl?(url: string): ImageRecord | null;
|
|
223
|
+
getImageByHash?(hash: string): ImageRecord | null;
|
|
224
|
+
saveImage?(image: ImageRecord): void;
|
|
220
225
|
}
|
|
221
226
|
| undefined;
|
|
222
227
|
historyMediaOptions: HistoryMediaProcessingOptions;
|
|
223
228
|
botUin: number;
|
|
224
229
|
memberNameCache: Map<string, string>;
|
|
230
|
+
hashLookupCache: Map<string, string | null>;
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
async function resolveMemberName(
|
|
@@ -286,7 +292,7 @@ function buildReplyAnnotation(source: any, ctx: HistoryFormatContext): string |
|
|
|
286
292
|
if (sourceUserId == null && !sourceText && !sourceNickname) return null;
|
|
287
293
|
|
|
288
294
|
const idStr = sourceId != null ? String(sourceId) : "?";
|
|
289
|
-
let displayName
|
|
295
|
+
let displayName: string | undefined;
|
|
290
296
|
if (sourceUserId != null) {
|
|
291
297
|
displayName = ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
|
|
292
298
|
}
|
|
@@ -296,6 +302,49 @@ function buildReplyAnnotation(source: any, ctx: HistoryFormatContext): string |
|
|
|
296
302
|
return `↪ reply to #${idStr} ${displayName}: "${text}"`;
|
|
297
303
|
}
|
|
298
304
|
|
|
305
|
+
async function getImageTagWithHashCache(
|
|
306
|
+
imageUrl: string,
|
|
307
|
+
db: NonNullable<HistoryFormatContext["db"]>,
|
|
308
|
+
ctx: HistoryFormatContext,
|
|
309
|
+
): Promise<string> {
|
|
310
|
+
const exact = db.getImageByUrl?.(imageUrl);
|
|
311
|
+
if (exact) return `[${exact.type}:${exact.description}]`;
|
|
312
|
+
|
|
313
|
+
const normalized = normalizeImageUrl(imageUrl);
|
|
314
|
+
if (normalized && normalized !== imageUrl) {
|
|
315
|
+
const hit = db.getImageByUrl?.(normalized);
|
|
316
|
+
if (hit) return `[${hit.type}:${hit.description}]`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let hash = ctx.hashLookupCache.get(`hash:${imageUrl}`);
|
|
320
|
+
if (hash === undefined) {
|
|
321
|
+
hash = (await calculateImageHash(imageUrl)) || null;
|
|
322
|
+
ctx.hashLookupCache.set(`hash:${imageUrl}`, hash);
|
|
323
|
+
}
|
|
324
|
+
if (!hash) return "[image]";
|
|
325
|
+
|
|
326
|
+
const byHash = db.getImageByHash?.(hash);
|
|
327
|
+
if (byHash) {
|
|
328
|
+
try {
|
|
329
|
+
db.saveImage?.({
|
|
330
|
+
hash: byHash.hash,
|
|
331
|
+
url: normalized || imageUrl,
|
|
332
|
+
type: byHash.type,
|
|
333
|
+
description: byHash.description,
|
|
334
|
+
emotion: byHash.emotion,
|
|
335
|
+
character: byHash.character,
|
|
336
|
+
filePath: byHash.filePath,
|
|
337
|
+
createdAt: byHash.createdAt,
|
|
338
|
+
});
|
|
339
|
+
} catch {
|
|
340
|
+
// best-effort URL caching for next call
|
|
341
|
+
}
|
|
342
|
+
return `[${byHash.type}:${byHash.description}]`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return "[image]";
|
|
346
|
+
}
|
|
347
|
+
|
|
299
348
|
async function formatHistoryMessage(
|
|
300
349
|
msg: any,
|
|
301
350
|
ctx: HistoryFormatContext,
|
|
@@ -359,15 +408,30 @@ async function buildMessageContent(
|
|
|
359
408
|
if (atContent) parts.push(atContent);
|
|
360
409
|
if (textContent) parts.push(textContent);
|
|
361
410
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
411
|
+
const imageSegments = msg.message.filter((seg: any) => seg.type === "image");
|
|
412
|
+
if (imageSegments.length > 0 && db?.getImageByUrl) {
|
|
413
|
+
const imageTags = await Promise.all(
|
|
414
|
+
imageSegments.map(async (seg: any) => {
|
|
415
|
+
const imageUrl = seg.url || seg.data?.url;
|
|
416
|
+
if (!imageUrl) return "[image]";
|
|
417
|
+
|
|
418
|
+
const cached = ctx.hashLookupCache.get(imageUrl);
|
|
419
|
+
if (cached !== undefined) {
|
|
420
|
+
return cached;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const tag = await getImageTagWithHashCache(
|
|
424
|
+
String(imageUrl),
|
|
425
|
+
db as any,
|
|
426
|
+
ctx,
|
|
427
|
+
);
|
|
428
|
+
ctx.hashLookupCache.set(imageUrl, tag);
|
|
429
|
+
return tag;
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
for (const tag of imageTags) parts.push(tag);
|
|
433
|
+
} else if (imageSegments.length > 0) {
|
|
434
|
+
for (const _ of imageSegments) parts.push("[image]");
|
|
371
435
|
}
|
|
372
436
|
|
|
373
437
|
for (const videoSeg of msg.message.filter(
|
|
@@ -393,7 +457,7 @@ async function buildMessageContent(
|
|
|
393
457
|
}
|
|
394
458
|
|
|
395
459
|
for (const cardSeg of msg.message.filter((seg: any) =>
|
|
396
|
-
["xml", "json", "lightapp", "ark"].includes(
|
|
460
|
+
["xml", "json", "lightapp", "ark"].includes(seg.type),
|
|
397
461
|
)) {
|
|
398
462
|
const cardData =
|
|
399
463
|
cardSeg.data?.data || cardSeg.data?.xml || cardSeg.data || cardSeg.xml || "";
|
|
@@ -530,6 +594,7 @@ export async function getGroupHistory(
|
|
|
530
594
|
|
|
531
595
|
const botUin = selfId;
|
|
532
596
|
const memberNameCache = new Map<string, string>();
|
|
597
|
+
const hashLookupCache = new Map<string, string | null>();
|
|
533
598
|
|
|
534
599
|
for (const m of messages) {
|
|
535
600
|
const uid = m?.user_id;
|
|
@@ -544,6 +609,7 @@ export async function getGroupHistory(
|
|
|
544
609
|
historyMediaOptions,
|
|
545
610
|
botUin,
|
|
546
611
|
memberNameCache,
|
|
612
|
+
hashLookupCache,
|
|
547
613
|
};
|
|
548
614
|
|
|
549
615
|
const formattedResults = await mapWithConcurrency(
|