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.
@@ -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 savePromises = characters.map(async (character) => {
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 record = db.getImageByUrl(imageUrl);
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
- if (record) {
459
- return `[${record.type}:${record.description}]`;
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 OR IGNORE INTO images (hash, url, type, description, emotion, character, file_path, created_at)
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`),
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
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 { getImageTag } from "../core/media/image-analyzer";
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 = sourceNickname;
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
- for (const imageSeg of msg.message.filter((seg: any) => seg.type === "image")) {
363
- const imageUrl = imageSeg.url || imageSeg.data?.url;
364
- if (imageUrl) {
365
- parts.push(
366
- db?.getImageByUrl
367
- ? getImageTag(String(imageUrl), db as any)
368
- : "[image]",
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(cardSeg.type),
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(