mioku-plugin-chat 3.0.6 → 3.1.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/utils/message.ts CHANGED
@@ -111,7 +111,7 @@ export async function getQuotedContent(
111
111
  }
112
112
  }
113
113
 
114
- export function isGroupAllowed(groupId: number, cfg: ChatConfig): boolean {
114
+ export function isGroupAllowed(groupId: string, cfg: ChatConfig): boolean {
115
115
  if (cfg.whitelistGroups.length > 0) {
116
116
  return cfg.whitelistGroups.includes(groupId);
117
117
  }
@@ -190,9 +190,9 @@ async function mapWithConcurrency<T, R>(
190
190
  }
191
191
 
192
192
  export async function getBotRole(
193
- groupId: number,
193
+ groupId: string,
194
194
  ctx: MiokuContext,
195
- selfId: number,
195
+ selfId: string,
196
196
  ): Promise<"owner" | "admin" | "member"> {
197
197
  try {
198
198
  const bot = ctx.pickBot(selfId);
@@ -205,7 +205,7 @@ export async function getBotRole(
205
205
  }
206
206
 
207
207
  interface FormattedHistoryMessage {
208
- userId: number;
208
+ userId: string;
209
209
  userName: string;
210
210
  userRole: string;
211
211
  content: string;
@@ -223,7 +223,7 @@ interface HistoryFormatContext {
223
223
  }
224
224
  | undefined;
225
225
  historyMediaOptions: HistoryMediaProcessingOptions;
226
- botUin: number;
226
+ botUin: string;
227
227
  /** 本运行时全部已连接 bot 的 id:这些账号的消息一律不作为用户历史(防 bot 互触循环) */
228
228
  botIds: Set<string>;
229
229
  memberNameCache: Map<string, string>;
@@ -283,10 +283,17 @@ function extractQuotedText(messageSegs: any): string {
283
283
  return parts.join(" ").trim();
284
284
  }
285
285
 
286
- function buildReplyAnnotation(
286
+ /** 引用关系:id 必有,发送者与原文可能缺失(例如 onebot 历史只给 id) */
287
+ interface ReplyRef {
288
+ id: string;
289
+ name?: string;
290
+ text?: string;
291
+ }
292
+
293
+ function extractReplyRef(
287
294
  source: any,
288
295
  ctx: HistoryFormatContext,
289
- ): string | null {
296
+ ): ReplyRef | null {
290
297
  if (!source || typeof source !== "object") return null;
291
298
  const sourceId = source.id ?? source.message_id ?? source.message_seq;
292
299
  const sourceUserId = source.user_id;
@@ -294,18 +301,38 @@ function buildReplyAnnotation(
294
301
  source.sender?.card || source.sender?.nickname || source.nickname;
295
302
  const sourceText = extractQuotedText(source.message);
296
303
 
297
- if (sourceUserId == null && !sourceText && !sourceNickname) return null;
304
+ if (
305
+ sourceId == null &&
306
+ sourceUserId == null &&
307
+ !sourceText &&
308
+ !sourceNickname
309
+ ) {
310
+ return null;
311
+ }
298
312
 
299
- const idStr = sourceId != null ? String(sourceId) : "?";
300
- let displayName: string | undefined;
313
+ let name: string | undefined;
301
314
  if (sourceUserId != null) {
302
- displayName =
303
- ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
315
+ name = ctx.memberNameCache.get(String(sourceUserId)) || String(sourceUserId);
304
316
  }
305
- if (!displayName) displayName = sourceNickname || "unknown";
317
+ if (!name) name = sourceNickname || undefined;
318
+
319
+ return {
320
+ id: sourceId != null ? String(sourceId) : "",
321
+ name,
322
+ text: sourceText || undefined,
323
+ };
324
+ }
325
+
326
+ function renderReplyRef(ref: ReplyRef): string {
327
+ const head = `↪ reply to #${ref.id || "?"}`;
328
+ if (!ref.name && !ref.text) return head;
329
+ if (!ref.text) return `${head} ${ref.name}`;
330
+ return ref.name ? `${head} ${ref.name}: "${ref.text}"` : `${head}: "${ref.text}"`;
331
+ }
306
332
 
307
- const text = sourceText || "(empty)";
308
- return `↪ reply to #${idStr} ${displayName}: "${text}"`;
333
+ function summarizeQuotedContent(content: string): string {
334
+ const flat = content.replace(/\s+/g, " ").trim();
335
+ return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat;
309
336
  }
310
337
 
311
338
  async function getImageTagWithHashCache(
@@ -345,12 +372,14 @@ async function getImageTagWithHashCache(
345
372
  async function formatHistoryMessage(
346
373
  msg: HistoryMessage,
347
374
  ctx: HistoryFormatContext,
348
- ): Promise<FormattedHistoryMessage | null> {
375
+ ): Promise<{ formatted: FormattedHistoryMessage; reply: ReplyRef | null } | null> {
349
376
  // 自己或本运行时其它 bot 的消息不作为用户历史,
350
377
  // 否则多 bot 同群时会把彼此的消息当作用户接话,形成循环触发
351
378
  if (String(msg.user_id) === String(ctx.botUin)) return null;
352
379
  if (ctx.botIds.has(String(msg.user_id))) return null;
353
380
 
381
+ const reply = extractReplyRef((msg as { source?: unknown }).source, ctx);
382
+
354
383
  let content = "";
355
384
  try {
356
385
  content = await buildMessageContent(msg, ctx);
@@ -359,19 +388,22 @@ async function formatHistoryMessage(
359
388
  return null;
360
389
  }
361
390
 
362
- if (!content.trim()) return null;
391
+ if (!content.trim() && !reply) return null;
363
392
 
364
393
  return {
365
- userId: Number(msg.user_id || 0),
366
- userName: msg.nickname || String(msg.user_id || "unknown"),
367
- userRole: "member",
368
- content,
369
- messageId:
370
- msg.message_id != null && msg.message_id !== ""
371
- ? String(msg.message_id)
372
- : "",
373
- timestamp: msg.time ?? Date.now(),
374
- role: "user",
394
+ formatted: {
395
+ userId: String(msg.user_id ?? "").trim(),
396
+ userName: msg.nickname || String(msg.user_id ?? "unknown"),
397
+ userRole: "member",
398
+ content,
399
+ messageId:
400
+ msg.message_id != null && msg.message_id !== ""
401
+ ? String(msg.message_id)
402
+ : "",
403
+ timestamp: msg.time ?? Date.now(),
404
+ role: "user",
405
+ },
406
+ reply,
375
407
  };
376
408
  }
377
409
 
@@ -385,9 +417,6 @@ async function buildMessageContent(
385
417
  const { db, historyMediaOptions } = ctx;
386
418
  const parts: string[] = [];
387
419
 
388
- const replyAnnotation = buildReplyAnnotation(msg.source, ctx);
389
- if (replyAnnotation) parts.push(replyAnnotation);
390
-
391
420
  const textSegs = msg.message.filter((seg: any) => seg.type === "text");
392
421
  const textContent = textSegs
393
422
  .map((seg: any) => seg.data?.text || "")
@@ -501,12 +530,12 @@ async function buildMessageContent(
501
530
  * 返回格式化为 ChatMessage 数组
502
531
  */
503
532
  export async function getGroupHistory(
504
- groupId: number,
533
+ groupId: string,
505
534
  ctx: MiokuContext,
506
535
  count: number = 100,
507
- selfId: number,
536
+ selfId: string,
508
537
  db?: {
509
- getBotMessages(groupId: number, limit: number): ChatMessage[];
538
+ getBotMessages(groupId: string, limit: number): ChatMessage[];
510
539
  getImageByHash?(hash: string): ImageRecord | null;
511
540
  getImageByUrl?(url: string): ImageRecord | null;
512
541
  getMediaSummary?(key: string): MediaSummaryRecord | null;
@@ -514,7 +543,7 @@ export async function getGroupHistory(
514
543
  getMediaSummaryBySource?(sourceKey: string): MediaSummaryRecord | null;
515
544
  saveMediaSummarySource?(sourceKey: string, summaryKey: string): void;
516
545
  getStoredGroupNoticeMessages?(
517
- groupId: number,
546
+ groupId: string,
518
547
  limit?: number,
519
548
  ): ChatMessage[];
520
549
  },
@@ -525,7 +554,7 @@ export async function getGroupHistory(
525
554
  },
526
555
  ): Promise<
527
556
  Array<{
528
- userId: number;
557
+ userId: string;
529
558
  userName: string;
530
559
  userRole: string;
531
560
  content: string;
@@ -536,7 +565,7 @@ export async function getGroupHistory(
536
565
  > {
537
566
  // 先获取 bot 从数据库发送的消息
538
567
  const botMessages: Array<{
539
- userId: number;
568
+ userId: string;
540
569
  userName: string;
541
570
  userRole: string;
542
571
  content: string;
@@ -549,7 +578,7 @@ export async function getGroupHistory(
549
578
  const storedBotMessages = db.getBotMessages(groupId, count);
550
579
  for (const msg of storedBotMessages) {
551
580
  botMessages.push({
552
- userId: msg.userId ?? 0,
581
+ userId: String(msg.userId ?? ""),
553
582
  userName: msg.userName || "Miku",
554
583
  userRole: msg.userRole || "member",
555
584
  content: msg.content,
@@ -563,8 +592,8 @@ export async function getGroupHistory(
563
592
  db.getStoredGroupNoticeMessages?.(groupId, Math.min(count, 20)) || [];
564
593
  for (const msg of storedNoticeMessages) {
565
594
  botMessages.push({
566
- userId: msg.userId ?? 0,
567
- userName: msg.userName || String(msg.userId || "unknown"),
595
+ userId: String(msg.userId ?? ""),
596
+ userName: msg.userName || String(msg.userId ?? "unknown"),
568
597
  userRole: msg.userRole || "member",
569
598
  content: msg.content,
570
599
  messageId: msg.messageId ?? "",
@@ -623,15 +652,51 @@ export async function getGroupHistory(
623
652
  hashLookupCache,
624
653
  };
625
654
 
626
- const formattedResults = await mapWithConcurrency(
655
+ const processed = await mapWithConcurrency(
627
656
  messages,
628
657
  HISTORY_MEDIA_CONCURRENCY,
629
658
  (msg: any) => formatHistoryMessage(msg, formatCtx),
630
659
  );
631
- const formatted = formattedResults.filter(
632
- (msg): msg is FormattedHistoryMessage => Boolean(msg),
660
+ const entries = processed.filter(
661
+ (
662
+ item,
663
+ ): item is {
664
+ formatted: FormattedHistoryMessage;
665
+ reply: ReplyRef | null;
666
+ } => Boolean(item),
633
667
  );
634
668
 
669
+ // 引用关系单独渲染:被引用的消息若也在本批历史里,直接补上发送者与原文
670
+ const byMessageId = new Map<string, FormattedHistoryMessage>();
671
+ for (const item of entries) {
672
+ if (item.formatted.messageId) {
673
+ byMessageId.set(item.formatted.messageId, item.formatted);
674
+ }
675
+ }
676
+ for (const botMessage of botMessages) {
677
+ if (botMessage.messageId && !byMessageId.has(botMessage.messageId)) {
678
+ byMessageId.set(botMessage.messageId, botMessage);
679
+ }
680
+ }
681
+
682
+ const formatted = entries.map(({ formatted: message, reply }) => {
683
+ if (!reply) return message;
684
+ const quoted = reply.id ? byMessageId.get(reply.id) : undefined;
685
+ const annotation = renderReplyRef({
686
+ ...reply,
687
+ name: reply.name ?? quoted?.userName,
688
+ text:
689
+ reply.text ??
690
+ (quoted ? summarizeQuotedContent(quoted.content) || undefined : undefined),
691
+ });
692
+ return {
693
+ ...message,
694
+ content: message.content
695
+ ? `${annotation} ${message.content}`
696
+ : annotation,
697
+ };
698
+ });
699
+
635
700
  // 合并 bot 消息
636
701
  const allMessages = [...botMessages, ...formatted];
637
702
  allMessages.sort((a, b) => a.timestamp - b.timestamp);
@@ -652,9 +717,9 @@ export function buildChatMessageFromEvent(
652
717
  e: any,
653
718
  text: string,
654
719
  isGroup: boolean,
655
- groupId: number | undefined,
720
+ groupId: string | undefined,
656
721
  ): ChatMessage {
657
- const userId: number = e.user_id || e.sender?.user_id;
722
+ const userId: string = e.user_id || e.sender?.user_id;
658
723
  return {
659
724
  sessionId: groupId ? `group:${groupId}` : `personal:${userId}`,
660
725
  role: "user" as const,
@@ -670,13 +735,13 @@ export function buildChatMessageFromEvent(
670
735
  };
671
736
  }
672
737
 
673
- export function normalizeIdList(input: unknown): number[] {
738
+ export function normalizeIdList(input: unknown): string[] {
674
739
  if (!Array.isArray(input)) return [];
675
740
  return Array.from(
676
741
  new Set(
677
742
  input
678
- .map((item) => Math.floor(Number(item)))
679
- .filter((id) => Number.isFinite(id) && id > 0),
743
+ .map((item) => String(item ?? "").trim())
744
+ .filter((id) => id.length > 0),
680
745
  ),
681
746
  );
682
747
  }
package/utils/model.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { AIModelDescriptor, AIService } from "mioku";
2
+
3
+ const supportsVision = (model?: AIModelDescriptor): boolean =>
4
+ Boolean(model?.capabilities?.includes("vision"));
5
+
6
+ export function mainModelSupportsVision(
7
+ aiService: AIService | undefined,
8
+ fallbackModelId?: string,
9
+ ): boolean {
10
+ if (!aiService) return false;
11
+
12
+ const models = aiService.listModels?.() ?? [];
13
+ const boundId = aiService.getRoleBindings?.()?.main;
14
+ if (boundId) {
15
+ const bound = models.find((item) => item.id === boundId);
16
+ if (bound) return supportsVision(bound);
17
+ }
18
+
19
+ const instance = (aiService.listInstances?.() ?? []).find(
20
+ (item) => item.role === "main" || item.name === "main",
21
+ );
22
+ const modelId = fallbackModelId || instance?.modelId || "";
23
+ if (!modelId) return false;
24
+
25
+ return supportsVision(
26
+ models.find(
27
+ (item) =>
28
+ item.modelId === modelId && item.providerId === instance?.providerId,
29
+ ) ?? models.find((item) => item.modelId === modelId),
30
+ );
31
+ }
package/utils/queue.ts CHANGED
@@ -114,33 +114,34 @@ export function parseLineMarkers(
114
114
  quoteMode?: "skip",
115
115
  ): {
116
116
  cleanText: string;
117
- atUsers: number[];
118
- pokeUsers: number[];
117
+ atUsers: string[];
118
+ pokeUsers: string[];
119
119
  quoteId?: string;
120
120
  audioText?: string;
121
121
  } {
122
- const atUsers: number[] = [];
123
- const pokeUsers: number[] = [];
122
+ const atUsers: string[] = [];
123
+ const pokeUsers: string[] = [];
124
124
  let quoteId: string | undefined;
125
125
  let audioText: string | undefined;
126
126
 
127
127
  // 提取 AT 标记
128
- const atPatterns = [/\[at:(\d+)\]/g];
128
+ // 标记里是平台用户 id:QQ 号或 openid 都可能出现
129
+ const atPatterns = [/\[at:([^\]\s]+)\]/g];
129
130
  for (const pattern of atPatterns) {
130
131
  const matches = [...line.matchAll(pattern)];
131
132
  for (const match of matches) {
132
- const userId = parseInt(match[1], 10);
133
- atUsers.push(userId);
133
+ const userId = String(match[1]).trim();
134
+ if (userId) atUsers.push(userId);
134
135
  }
135
136
  }
136
137
 
137
138
  // 提取戳人标记
138
- const pokePatterns = [/\[poke:(\d+)\]/g];
139
+ const pokePatterns = [/\[poke:([^\]\s]+)\]/g];
139
140
  for (const pattern of pokePatterns) {
140
141
  const matches = [...line.matchAll(pattern)];
141
142
  for (const match of matches) {
142
- const userId = parseInt(match[1], 10);
143
- pokeUsers.push(userId);
143
+ const userId = String(match[1]).trim();
144
+ if (userId) pokeUsers.push(userId);
144
145
  }
145
146
  }
146
147
 
@@ -169,7 +170,7 @@ export function parseLineMarkers(
169
170
 
170
171
  // 清理标记
171
172
  let cleanText = line
172
- .replace(/\[at:\d+\]/g, "")
173
+ .replace(/\[at:[^\]\s]+\]/g, "")
173
174
  .replace(/\[poke:\d+\]/g, "")
174
175
  .replace(/\[reply:[^\]\n]+\]/g, "")
175
176
  .replace(/\[audio:[^\]]+\]/gi, "")