mioku-plugin-chat 2.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/core/base.ts ADDED
@@ -0,0 +1,936 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import { logger } from "mioki";
3
+ import type { SkillPermissionRole } from "mioku";
4
+ import type { AIInstance, AIService } from "mioku";
5
+ import type {
6
+ ChatConfig,
7
+ ChatMessage,
8
+ TargetMessage,
9
+ ToolContext,
10
+ } from "../types";
11
+ import type { ChatDatabase } from "../db";
12
+ import type { HumanizeEngine } from "../humanize";
13
+ import { parseLineMarkers, splitByReplyMarkers } from "../utils/queue";
14
+ import { getGroupHistory } from "../utils";
15
+ import type { ScreenshotService } from "mioku";
16
+ import { synthesizeAudioBase64 } from "./media/audio";
17
+ import {
18
+ extractStandaloneMarkdownBlock,
19
+ splitOutgoingUnits,
20
+ summarizeMarkdown,
21
+ MARKDOWN_OPEN_TAG,
22
+ } from "./media/markdown-message";
23
+
24
+ export interface SendAIResponseOptions {
25
+ ctx: MiokiContext;
26
+ groupId: number;
27
+ messages: string[];
28
+ config: ChatConfig;
29
+ sentIndices?: Set<number>;
30
+ onLineSent?: () => void | Promise<void>;
31
+ }
32
+
33
+ const FAST_TYPING_BASE_MS = 150;
34
+ const FAST_TYPING_PER_CHAR_MS = 65;
35
+ const FAST_TYPING_MIN_MS = 150;
36
+ const FAST_TYPING_MAX_MS = 2000;
37
+ const DEFAULT_TYPING_DELAY_MAX_TOTAL_MS = 10_000;
38
+
39
+ function calculateTypingDelayMs(text: string): number {
40
+ const chars = Array.from(text.replace(/\s+/g, "")).length;
41
+ const estimated = FAST_TYPING_BASE_MS + chars * FAST_TYPING_PER_CHAR_MS;
42
+ return Math.max(FAST_TYPING_MIN_MS, Math.min(FAST_TYPING_MAX_MS, estimated));
43
+ }
44
+
45
+ function createTypingDelayController(config: ChatConfig) {
46
+ const rawMaxTotalMs = Number(config.typingDelayMaxTotalMs);
47
+ const maxTotalMs =
48
+ Number.isFinite(rawMaxTotalMs) && rawMaxTotalMs >= 0
49
+ ? rawMaxTotalMs
50
+ : DEFAULT_TYPING_DELAY_MAX_TOTAL_MS;
51
+
52
+ return {
53
+ spentMs: 0,
54
+ maxTotalMs,
55
+ };
56
+ }
57
+
58
+ async function waitTypingDelay(
59
+ text: string,
60
+ controller: ReturnType<typeof createTypingDelayController>,
61
+ ): Promise<void> {
62
+ const remainingMs = controller.maxTotalMs - controller.spentMs;
63
+ if (remainingMs <= 0) {
64
+ return;
65
+ }
66
+
67
+ const delayMs = Math.min(calculateTypingDelayMs(text), remainingMs);
68
+ if (delayMs <= 0) {
69
+ return;
70
+ }
71
+
72
+ controller.spentMs += delayMs;
73
+ await new Promise((r) => setTimeout(r, delayMs));
74
+ }
75
+
76
+ export async function sendAIResponse(
77
+ options: SendAIResponseOptions,
78
+ selfId: number,
79
+ ): Promise<void> {
80
+ const {
81
+ ctx,
82
+ groupId,
83
+ messages,
84
+ config,
85
+ sentIndices,
86
+ onLineSent,
87
+ } = options;
88
+ const typingDelayEnabled = config.enableTypingDelay ?? false;
89
+ const enableMarkdownScreenshot = config.enableMarkdownScreenshot ?? true;
90
+ const bot = ctx.pickBot(selfId);
91
+ if (!bot) {
92
+ ctx.logger.error(
93
+ `[sendAIResponse] bot ${String(selfId)} not found, skip sending group message`,
94
+ );
95
+ return;
96
+ }
97
+
98
+ if (messages.length === 0) return;
99
+
100
+ for (let i = 0; i < messages.length; i++) {
101
+ if (sentIndices?.has(i)) continue;
102
+
103
+ const expandedLines = expandOutgoingLines(messages[i]);
104
+
105
+ let pendingReply: number | undefined;
106
+ let lastDelayBasisText = "";
107
+
108
+ for (let j = 0; j < expandedLines.length; j++) {
109
+ const line = expandedLines[j];
110
+
111
+ const { cleanText, atUsers, pokeUsers, quoteId, audioText } =
112
+ parseLineMarkers(line);
113
+
114
+ if (quoteId !== undefined) {
115
+ pendingReply = quoteId;
116
+ }
117
+
118
+ const markdownContent = extractStandaloneMarkdownBlock(cleanText);
119
+ const hasContent = cleanText && cleanText.trim().length > 0;
120
+ const hasSendablePayload = Boolean(
121
+ hasContent ||
122
+ markdownContent ||
123
+ atUsers.length > 0 ||
124
+ pokeUsers.length > 0 ||
125
+ audioText,
126
+ );
127
+ const isLastLine = j === expandedLines.length - 1;
128
+
129
+ if (!hasSendablePayload && !isLastLine) {
130
+ continue;
131
+ }
132
+
133
+ if (!hasSendablePayload) {
134
+ pendingReply = undefined;
135
+ continue;
136
+ }
137
+
138
+ if (pokeUsers.length > 0) {
139
+ for (const pokeId of pokeUsers) {
140
+ await bot.api("group_poke", {
141
+ group_id: groupId,
142
+ user_id: pokeId,
143
+ });
144
+ }
145
+ }
146
+
147
+ const lineSegments: any[] = [];
148
+
149
+ const finalQuoteId = pendingReply;
150
+ if (finalQuoteId !== undefined) {
151
+ lineSegments.push({ type: "reply", id: String(finalQuoteId) });
152
+ pendingReply = undefined;
153
+ }
154
+
155
+ for (const atId of atUsers) {
156
+ lineSegments.push(ctx.segment.at(atId));
157
+ }
158
+
159
+ if (markdownContent) {
160
+ const screenshotService = ctx.services?.screenshot as
161
+ | ScreenshotService
162
+ | undefined;
163
+ const imagePath = await buildMarkdownImage(
164
+ ctx,
165
+ markdownContent,
166
+ screenshotService,
167
+ enableMarkdownScreenshot,
168
+ );
169
+
170
+ if (imagePath) {
171
+ const finalQuoteIdForImage = finalQuoteId;
172
+ await dispatchSegments(
173
+ bot,
174
+ groupId,
175
+ undefined,
176
+ (imageSource?: string) => {
177
+ const segments: any[] = [];
178
+ if (finalQuoteIdForImage !== undefined) {
179
+ segments.push({
180
+ type: "reply",
181
+ id: String(finalQuoteIdForImage),
182
+ });
183
+ }
184
+ for (const atId of atUsers) {
185
+ segments.push(ctx.segment.at(atId));
186
+ }
187
+ segments.push(
188
+ ctx.segment.image(
189
+ normalizeImageSource(imageSource || imagePath),
190
+ ),
191
+ );
192
+ return segments;
193
+ },
194
+ imagePath,
195
+ );
196
+ lastDelayBasisText = summarizeMarkdown(markdownContent);
197
+ if (typingDelayEnabled && j < expandedLines.length - 1) {
198
+ const delayMs = calculateTypingDelayMs(lastDelayBasisText || line);
199
+ await new Promise((r) => setTimeout(r, delayMs));
200
+ }
201
+ continue;
202
+ }
203
+ }
204
+
205
+ const sendableText = markdownContent ?? cleanText;
206
+ const audioSource = await resolveAudioSource(ctx, {
207
+ audioText,
208
+ config,
209
+ });
210
+ const fallbackText = !audioSource && audioText ? audioText : undefined;
211
+ if (sendableText) {
212
+ lineSegments.push(ctx.segment.text(sendableText));
213
+ } else if (fallbackText) {
214
+ lineSegments.push(ctx.segment.text(fallbackText));
215
+ }
216
+ if (audioSource) {
217
+ lineSegments.push(ctx.segment.record(audioSource));
218
+ }
219
+
220
+ if (lineSegments.length > 0) {
221
+ await bot.sendGroupMsg(groupId, lineSegments);
222
+ lastDelayBasisText = sendableText || fallbackText || audioText || line;
223
+ }
224
+
225
+ if (typingDelayEnabled && j < expandedLines.length - 1) {
226
+ const delayMs = calculateTypingDelayMs(lastDelayBasisText || line);
227
+ await new Promise((r) => setTimeout(r, delayMs));
228
+ }
229
+ }
230
+
231
+ if (typingDelayEnabled && i < messages.length - 1) {
232
+ const delayMs = calculateTypingDelayMs(lastDelayBasisText || messages[i]);
233
+ await new Promise((r) => setTimeout(r, delayMs));
234
+ }
235
+
236
+ await onLineSent?.();
237
+ }
238
+ }
239
+
240
+ export async function sendMessage(
241
+ ctx: MiokiContext,
242
+ groupId: number | undefined,
243
+ userId: number,
244
+ text: string,
245
+ config: ChatConfig,
246
+ selfId: number,
247
+ ): Promise<void> {
248
+ const typingDelayEnabled = config.enableTypingDelay ?? false;
249
+ const enableMarkdownScreenshot = config.enableMarkdownScreenshot ?? true;
250
+ try {
251
+ const bot = ctx.pickBot(selfId);
252
+ if (!bot) {
253
+ ctx.logger.error(
254
+ `[sendMessage] bot ${String(selfId)} not found, skip sending`,
255
+ );
256
+ return;
257
+ }
258
+
259
+ // 应用错别字生成器
260
+ const expandedLines = expandOutgoingLines(text);
261
+
262
+ let pendingReply: number | undefined;
263
+ let lastDelayBasisText = "";
264
+
265
+ for (let j = 0; j < expandedLines.length; j++) {
266
+ const line = expandedLines[j];
267
+
268
+ const { cleanText, atUsers, pokeUsers, quoteId, audioText } =
269
+ parseLineMarkers(line);
270
+
271
+ if (quoteId !== undefined) {
272
+ pendingReply = quoteId;
273
+ }
274
+
275
+ const markdownContent = extractStandaloneMarkdownBlock(cleanText);
276
+ const hasContent = cleanText && cleanText.trim().length > 0;
277
+ const hasSendablePayload = Boolean(
278
+ hasContent ||
279
+ markdownContent ||
280
+ atUsers.length > 0 ||
281
+ pokeUsers.length > 0 ||
282
+ audioText,
283
+ );
284
+ const isLastLine = j === expandedLines.length - 1;
285
+
286
+ if (!hasSendablePayload && !isLastLine) {
287
+ continue;
288
+ }
289
+
290
+ if (!hasSendablePayload) {
291
+ pendingReply = undefined;
292
+ continue;
293
+ }
294
+
295
+ // 戳人 - 立即执行
296
+ if (groupId && pokeUsers.length > 0) {
297
+ for (const pokeId of pokeUsers) {
298
+ await bot.api("group_poke", {
299
+ group_id: groupId,
300
+ user_id: pokeId,
301
+ });
302
+ }
303
+ }
304
+
305
+ const hasAt = atUsers.length > 0;
306
+
307
+ if (markdownContent) {
308
+ const screenshotService = ctx.services?.screenshot as
309
+ | ScreenshotService
310
+ | undefined;
311
+ const imagePath = await buildMarkdownImage(
312
+ ctx,
313
+ markdownContent,
314
+ screenshotService,
315
+ enableMarkdownScreenshot,
316
+ );
317
+
318
+ if (imagePath) {
319
+ const finalQuoteIdForImage = pendingReply;
320
+ pendingReply = undefined;
321
+ await dispatchSegments(
322
+ bot,
323
+ groupId,
324
+ userId,
325
+ (imageSource?: string) => {
326
+ const segments: any[] = [];
327
+ if (finalQuoteIdForImage !== undefined) {
328
+ segments.push({
329
+ type: "reply",
330
+ id: String(finalQuoteIdForImage),
331
+ });
332
+ }
333
+ for (const atId of atUsers) {
334
+ if (String(atId) !== String(selfId)) {
335
+ segments.push(ctx.segment.at(atId));
336
+ }
337
+ }
338
+ segments.push(
339
+ ctx.segment.image(
340
+ normalizeImageSource(imageSource || imagePath),
341
+ ),
342
+ );
343
+ return segments;
344
+ },
345
+ imagePath,
346
+ );
347
+ lastDelayBasisText = summarizeMarkdown(markdownContent);
348
+ if (typingDelayEnabled && j < expandedLines.length - 1) {
349
+ const delayMs = calculateTypingDelayMs(lastDelayBasisText || line);
350
+ await new Promise((r) => setTimeout(r, delayMs));
351
+ }
352
+ continue;
353
+ }
354
+ }
355
+
356
+ const audioSource = await resolveAudioSource(ctx, {
357
+ audioText,
358
+ config,
359
+ });
360
+ const fallbackText = !audioSource && audioText ? audioText : undefined;
361
+
362
+ if (hasAt) {
363
+ const sendableText = markdownContent ?? cleanText;
364
+ const segments: any[] = [];
365
+ if (pendingReply !== undefined) {
366
+ segments.push({ type: "reply", id: String(pendingReply) });
367
+ pendingReply = undefined;
368
+ }
369
+ if (markdownContent) {
370
+ for (const atId of atUsers) {
371
+ if (String(atId) !== String(selfId)) {
372
+ segments.push(ctx.segment.at(atId));
373
+ }
374
+ }
375
+ if (sendableText) {
376
+ segments.push(ctx.segment.text(sendableText));
377
+ } else if (fallbackText) {
378
+ segments.push(ctx.segment.text(fallbackText));
379
+ }
380
+ if (audioSource) {
381
+ segments.push(ctx.segment.record(audioSource));
382
+ }
383
+
384
+ if (segments.length > 0 && groupId) {
385
+ await bot.sendGroupMsg(groupId, segments);
386
+ lastDelayBasisText =
387
+ sendableText || fallbackText || audioText || line;
388
+ }
389
+ if (typingDelayEnabled && j < expandedLines.length - 1) {
390
+ const delayMs = calculateTypingDelayMs(lastDelayBasisText || line);
391
+ await new Promise((r) => setTimeout(r, delayMs));
392
+ }
393
+ continue;
394
+ }
395
+ // 有 @ 用户时,构建消息保持原始位置
396
+ // 先将原始行按 @ 标记分割,然后重新构建
397
+ let remaining = line;
398
+ // 支持三种格式: [[[at:xxx]]], (((at:xxx))), (((xxx)))
399
+ const atPatterns = [
400
+ /\[\[\[at:(\d+)\]\]\]/g,
401
+ /\(\(\(at:(\d+)\)\)\)/g,
402
+ /\(\(\((\d+)\)\)\)/g,
403
+ ];
404
+
405
+ let lastIndex = 0;
406
+ let match;
407
+
408
+ // 依次处理每种格式
409
+ for (const atPattern of atPatterns) {
410
+ atPattern.lastIndex = 0; // 重置正则
411
+ while ((match = atPattern.exec(remaining)) !== null) {
412
+ // 添加 @ 之前的文本
413
+ const beforeAt = remaining.slice(lastIndex, match.index);
414
+ if (beforeAt) {
415
+ // 清理 reply 和 poke 标记残留
416
+ const cleaned = beforeAt
417
+ .replace(/\[\[\[reply:-?\d+\]\]\]/g, "")
418
+ .replace(/\(\(\(reply:-?\d+\)\)\)/g, "")
419
+ .replace(/\[\[\[poke:\d+\]\]\]/g, "")
420
+ .replace(/\(\(\(poke:\d+\)\)\)/g, "")
421
+ .replace(/\[audio:[^\]]+\]/gi, "")
422
+ .trim();
423
+ if (cleaned) {
424
+ segments.push({ type: "text", text: cleaned });
425
+ }
426
+ }
427
+
428
+ const atId = match[1];
429
+ // 跳过 @ 机器人自己的情况
430
+ if (String(atId) !== String(selfId)) {
431
+ segments.push(ctx.segment.at(atId));
432
+ }
433
+
434
+ lastIndex = match.index + match[0].length;
435
+ }
436
+ }
437
+
438
+ // 添加 @ 之后的文本
439
+ const afterAt = remaining.slice(lastIndex);
440
+ if (afterAt) {
441
+ const cleaned = afterAt
442
+ .replace(/\[\[\[reply:-?\d+\]\]\]/g, "")
443
+ .replace(/\(\(\(reply:-?\d+\)\)\)/g, "")
444
+ .replace(/\[\[\[poke:\d+\]\]\]/g, "")
445
+ .replace(/\(\(\(poke:\d+\)\)\)/g, "")
446
+ .replace(/\[audio:[^\]]+\]/gi, "")
447
+ .trim();
448
+ if (cleaned) {
449
+ segments.push({ type: "text", text: cleaned });
450
+ }
451
+ }
452
+
453
+ if (audioSource) {
454
+ segments.push(ctx.segment.record(audioSource));
455
+ } else if (fallbackText) {
456
+ segments.push({ type: "text", text: fallbackText });
457
+ }
458
+
459
+ // 发送消息
460
+ if (segments.length > 0) {
461
+ if (groupId) {
462
+ await bot.sendGroupMsg(groupId, segments);
463
+ lastDelayBasisText =
464
+ sendableText || fallbackText || audioText || line;
465
+ }
466
+ }
467
+ } else {
468
+ // 没有 @ 用户时,发送普通文本消息
469
+ const sendableText = markdownContent ?? cleanText;
470
+ if (
471
+ sendableText ||
472
+ fallbackText ||
473
+ audioSource ||
474
+ pendingReply !== undefined
475
+ ) {
476
+ const sendSegments: any[] = [];
477
+ if (pendingReply !== undefined) {
478
+ sendSegments.push({ type: "reply", id: String(pendingReply) });
479
+ pendingReply = undefined;
480
+ }
481
+ if (sendableText) {
482
+ sendSegments.push(ctx.segment.text(sendableText));
483
+ } else if (fallbackText) {
484
+ sendSegments.push(ctx.segment.text(fallbackText));
485
+ }
486
+ if (audioSource) {
487
+ sendSegments.push(ctx.segment.record(audioSource));
488
+ }
489
+ if (sendSegments.length > 0) {
490
+ await dispatchSegments(bot, groupId, userId, () => sendSegments);
491
+ lastDelayBasisText =
492
+ sendableText || fallbackText || audioText || line;
493
+ }
494
+ }
495
+ }
496
+
497
+ if (typingDelayEnabled && j < expandedLines.length - 1) {
498
+ const delayMs = calculateTypingDelayMs(lastDelayBasisText || line);
499
+ await new Promise((r) => setTimeout(r, delayMs));
500
+ }
501
+ }
502
+ } catch (err) {
503
+ ctx.logger.error("[sendMessage] error:", err);
504
+ }
505
+ }
506
+
507
+ function expandOutgoingLines(text: string): string[] {
508
+ const units = splitOutgoingUnits(text);
509
+ const expandedLines: string[] = [];
510
+
511
+ for (const unit of units) {
512
+ if (!unit.trim()) {
513
+ continue;
514
+ }
515
+
516
+ if (unit.includes(MARKDOWN_OPEN_TAG)) {
517
+ expandedLines.push(unit);
518
+ continue;
519
+ }
520
+
521
+ const normalizedUnit = normalizeActionLineBreaks(unit);
522
+ const lineParts = normalizedUnit
523
+ .split(/\n+/)
524
+ .map((part) => part.trim())
525
+ .filter(Boolean);
526
+ for (const linePart of lineParts) {
527
+ const parts = splitByReplyMarkers(linePart);
528
+ expandedLines.push(...parts.filter((part) => part.trim()));
529
+ }
530
+ }
531
+
532
+ return expandedLines;
533
+ }
534
+
535
+ function normalizeActionLineBreaks(text: string): string {
536
+ return String(text || "").replace(
537
+ /\\\s*(?=(?:\[meme:[^\]]+\]|\[audio:[^\]]+\]|\[\[\[reply:-?\d+\]\]\]|\(\(\(reply:-?\d+\)\)\)))/gi,
538
+ "\n",
539
+ );
540
+ }
541
+
542
+ async function buildMarkdownImage(
543
+ ctx: MiokiContext,
544
+ markdownContent: string,
545
+ screenshotService: ScreenshotService | undefined,
546
+ enableMarkdownScreenshot: boolean,
547
+ ): Promise<string | null> {
548
+ if (!enableMarkdownScreenshot || !screenshotService) {
549
+ return null;
550
+ }
551
+
552
+ try {
553
+ return await screenshotService.screenshotMarkdown(markdownContent);
554
+ } catch (error) {
555
+ ctx.logger.error(`[MarkdownRender] failed: ${error}`);
556
+ return null;
557
+ }
558
+ }
559
+
560
+ async function dispatchSegments(
561
+ bot: any,
562
+ groupId: number | undefined,
563
+ userId: number | undefined,
564
+ buildSegments: (imageSource?: string) => any[],
565
+ fallbackImagePath?: string,
566
+ ): Promise<void> {
567
+ try {
568
+ await sendByTarget(bot, groupId, userId, buildSegments());
569
+ } catch (error) {
570
+ if (!fallbackImagePath || !isLocalFilePath(fallbackImagePath)) {
571
+ throw error;
572
+ }
573
+
574
+ const fsPromises = await import("fs/promises");
575
+ const buffer = await fsPromises.readFile(fallbackImagePath);
576
+ const base64Image = `base64://${buffer.toString("base64")}`;
577
+ await sendByTarget(bot, groupId, userId, buildSegments(base64Image));
578
+ }
579
+ }
580
+
581
+ async function sendByTarget(
582
+ bot: any,
583
+ groupId: number | undefined,
584
+ userId: number | undefined,
585
+ segments: any[],
586
+ ): Promise<void> {
587
+ if (groupId) {
588
+ await bot.sendGroupMsg(groupId, segments);
589
+ return;
590
+ }
591
+
592
+ if (userId) {
593
+ await bot.sendPrivateMsg(userId, segments);
594
+ return;
595
+ }
596
+
597
+ throw new Error("No valid message target");
598
+ }
599
+
600
+ function normalizeImageSource(file: string): string {
601
+ const value = String(file || "").trim();
602
+ if (!value) {
603
+ return value;
604
+ }
605
+
606
+ if (
607
+ value.startsWith("file://") ||
608
+ value.startsWith("base64://") ||
609
+ value.startsWith("data:") ||
610
+ value.startsWith("http://") ||
611
+ value.startsWith("https://")
612
+ ) {
613
+ return value;
614
+ }
615
+
616
+ if (isLocalFilePath(value)) {
617
+ return `file://${value}`;
618
+ }
619
+
620
+ return value;
621
+ }
622
+
623
+ function isLocalFilePath(value: string): boolean {
624
+ return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
625
+ }
626
+
627
+ async function resolveAudioSource(
628
+ ctx: MiokiContext,
629
+ options: {
630
+ audioText?: string;
631
+ config: ChatConfig;
632
+ },
633
+ ): Promise<string | null> {
634
+ const trimmed = String(options.audioText || "").trim();
635
+ if (!trimmed) {
636
+ return null;
637
+ }
638
+
639
+ if (!options.config.audio?.enabled || !options.config.audio.baseUrl?.trim()) {
640
+ return null;
641
+ }
642
+
643
+ try {
644
+ return await synthesizeAudioBase64(options.config.audio, trimmed);
645
+ } catch (error) {
646
+ ctx.logger.error(
647
+ `[audio] Failed to synthesize voice for "${trimmed}": ${error}`,
648
+ );
649
+ return null;
650
+ }
651
+ }
652
+
653
+ export interface GroupHistoryResult {
654
+ history: ChatMessage[];
655
+ rawHistory: Awaited<ReturnType<typeof getGroupHistory>>;
656
+ }
657
+
658
+ export async function getGroupHistoryMessages(
659
+ groupId: number,
660
+ groupSessionId: string,
661
+ ctx: MiokiContext,
662
+ historyCount: number,
663
+ db: ChatDatabase,
664
+ selfId: number,
665
+ mediaOptions?: {
666
+ ai?: AIInstance;
667
+ workingModel?: string;
668
+ multimodalWorkingModel?: string;
669
+ },
670
+ ): Promise<GroupHistoryResult> {
671
+ const rawHistory = await getGroupHistory(
672
+ groupId,
673
+ ctx,
674
+ historyCount,
675
+ selfId,
676
+ db,
677
+ mediaOptions,
678
+ );
679
+ const history: ChatMessage[] = rawHistory.map((msg) => ({
680
+ sessionId: groupSessionId,
681
+ role: "user" as const,
682
+ content: msg.content,
683
+ userId: msg.userId,
684
+ userName: msg.userName,
685
+ userRole: msg.userRole,
686
+ groupId,
687
+ timestamp: msg.timestamp,
688
+ messageId: msg.messageId,
689
+ }));
690
+
691
+ return { history, rawHistory };
692
+ }
693
+
694
+ export interface GroupInfoResult {
695
+ groupName: string | undefined;
696
+ memberCount: number | undefined;
697
+ }
698
+
699
+ export async function getGroupInfoData(
700
+ ctx: MiokiContext,
701
+ groupId: number,
702
+ selfId: number,
703
+ fallbackGroupName?: string,
704
+ ): Promise<GroupInfoResult> {
705
+ let groupName: string | undefined;
706
+ let memberCount: number | undefined;
707
+
708
+ try {
709
+ const groupInfo = await ctx.pickBot(selfId).getGroupInfo(groupId);
710
+ groupName = (groupInfo as any)?.group_name || fallbackGroupName;
711
+ memberCount = (groupInfo as any)?.member_count;
712
+ } catch {
713
+ groupName = fallbackGroupName;
714
+ }
715
+
716
+ return { groupName, memberCount };
717
+ }
718
+
719
+ export interface HumanizeContextsResult {
720
+ memoryContext: string | undefined;
721
+ topicContext: string | undefined;
722
+ expressionContext: string | undefined;
723
+ }
724
+
725
+ export async function getHumanizeContexts(
726
+ humanize: HumanizeEngine,
727
+ groupSessionId: string,
728
+ userName: string,
729
+ history: ChatMessage[],
730
+ triggerUserId?: number,
731
+ ): Promise<HumanizeContextsResult> {
732
+ const historyStartAt = history.length > 0 ? history[0].timestamp : undefined;
733
+
734
+ const topicContext = humanize.topicTracker.getTopicContext(
735
+ groupSessionId,
736
+ historyStartAt,
737
+ );
738
+ const expressionContext = triggerUserId
739
+ ? humanize.expressionLearner.getExpressionContextForUser(
740
+ triggerUserId,
741
+ userName,
742
+ )
743
+ : "";
744
+
745
+ const result = {
746
+ memoryContext: undefined,
747
+ topicContext: topicContext || undefined,
748
+ expressionContext: expressionContext || undefined,
749
+ };
750
+ logger.info(`[getHumanizeContexts] session=${groupSessionId} user=${userName} triggerUserId=${triggerUserId} memoryContext=undefined topicContext="${topicContext?.slice(0, 100) ?? ""}" expressionContext="${expressionContext?.slice(0, 100) ?? ""}"`);
751
+ return result;
752
+ }
753
+
754
+ export interface BuildToolContextOptions {
755
+ ctx: MiokiContext;
756
+ event: any;
757
+ selfId: number;
758
+ groupSessionId: string;
759
+ groupId?: number;
760
+ userId: number;
761
+ config: ChatConfig;
762
+ aiService: AIService;
763
+ db: ChatDatabase;
764
+ botRole: "owner" | "admin" | "member";
765
+ pendingImageUrls?: string[];
766
+ humanize: HumanizeEngine;
767
+ targetMessage: TargetMessage;
768
+ }
769
+
770
+ function resolveTriggerSkillRole(
771
+ ctx: MiokiContext,
772
+ event: any,
773
+ ): SkillPermissionRole {
774
+ const userId = event?.user_id || event?.sender?.user_id;
775
+ if (!userId) {
776
+ return "member";
777
+ }
778
+
779
+ try {
780
+ if (ctx.isOwner?.(event)) {
781
+ return "owner";
782
+ }
783
+ } catch {}
784
+
785
+ const senderRole = String(event?.sender?.role || "").toLowerCase();
786
+ if (senderRole === "owner" || senderRole === "admin") {
787
+ return "admin";
788
+ }
789
+
790
+ try {
791
+ if (ctx.isAdmin?.(event)) {
792
+ return "admin";
793
+ }
794
+ } catch {}
795
+
796
+ return "member";
797
+ }
798
+
799
+ export function buildToolContext(
800
+ options: BuildToolContextOptions,
801
+ ): ToolContext {
802
+ const {
803
+ ctx,
804
+ event,
805
+ selfId,
806
+ groupSessionId,
807
+ groupId,
808
+ userId,
809
+ config,
810
+ aiService,
811
+ db,
812
+ botRole,
813
+ pendingImageUrls,
814
+ humanize,
815
+ targetMessage,
816
+ } = options;
817
+
818
+ return {
819
+ ctx,
820
+ event,
821
+ sessionId: groupSessionId,
822
+ groupId,
823
+ userId,
824
+ triggerSkillRole: resolveTriggerSkillRole(ctx, event),
825
+ config,
826
+ aiService,
827
+ db,
828
+ botRole,
829
+ pendingImageUrls,
830
+ onTextContent: async (text) => {
831
+ const content = text.trim();
832
+ if (!content) return;
833
+ await sendMessage(
834
+ ctx,
835
+ groupId,
836
+ targetMessage.userId,
837
+ content,
838
+ config,
839
+ selfId,
840
+ );
841
+ },
842
+ };
843
+ }
844
+
845
+ export function saveBotMessages(
846
+ groupId: number,
847
+ groupSessionId: string,
848
+ messages: string[],
849
+ timestamp: number,
850
+ config: ChatConfig,
851
+ db: ChatDatabase,
852
+ ctx: MiokiContext,
853
+ selfId: number,
854
+ ): void {
855
+ const bot = ctx.pickBot(selfId);
856
+ const botNickname = config.nicknames[0] || (bot?.nickname ?? "Miku");
857
+
858
+ if (!bot) {
859
+ ctx.logger.warn(`[saveBotMessages] bot ${selfId} not available`);
860
+ return;
861
+ }
862
+
863
+ for (const msg of messages) {
864
+ const botMsg: ChatMessage = {
865
+ sessionId: groupSessionId,
866
+ role: "assistant",
867
+ content: msg ?? "",
868
+ userId: selfId ?? 0,
869
+ userName: botNickname ?? "Miku",
870
+ userRole: "member",
871
+ groupId,
872
+ timestamp,
873
+ };
874
+ db.saveMessage(botMsg);
875
+ }
876
+ }
877
+
878
+ export async function sendEmoji(
879
+ ctx: MiokiContext,
880
+ groupId: number,
881
+ emojiPath: string | null | undefined,
882
+ selfId: number,
883
+ ): Promise<void> {
884
+ if (!emojiPath) return;
885
+ const bot = ctx.pickBot(selfId);
886
+ if (!bot) {
887
+ ctx.logger.error(
888
+ `[sendEmoji] bot ${String(selfId)} not found, skip sending emoji`,
889
+ );
890
+ return;
891
+ }
892
+
893
+ try {
894
+ const emojiSegment = ctx.segment.image(`file://${emojiPath}`);
895
+ await bot.sendGroupMsg(groupId, [emojiSegment]);
896
+ } catch (err) {
897
+ try {
898
+ const fsPromises = await import("fs/promises");
899
+ const path = await import("path");
900
+
901
+ let fileExists: boolean;
902
+ try {
903
+ await fsPromises.access(emojiPath);
904
+ fileExists = true;
905
+ } catch {
906
+ fileExists = false;
907
+ }
908
+
909
+ if (!fileExists) {
910
+ ctx.logger.warn(`[Emoji] File not found: ${emojiPath}`);
911
+ return;
912
+ }
913
+
914
+ const buffer = await fsPromises.readFile(emojiPath);
915
+ const base64 = buffer.toString("base64");
916
+ const ext = path.extname(emojiPath).toLowerCase();
917
+ const mimeType =
918
+ ext === ".jpg" || ext === ".jpeg"
919
+ ? "image/jpeg"
920
+ : ext === ".png"
921
+ ? "image/png"
922
+ : ext === ".gif"
923
+ ? "image/gif"
924
+ : ext === ".webp"
925
+ ? "image/webp"
926
+ : "image/jpeg";
927
+
928
+ const base64DataUrl = `data:${mimeType};base64,${base64}`;
929
+ const base64Segment = ctx.segment.image(base64DataUrl);
930
+ await bot.sendGroupMsg(groupId, [base64Segment]);
931
+ ctx.logger.info(`[Emoji] Sent via base64: ${path.basename(emojiPath)}`);
932
+ } catch (base64Err) {
933
+ ctx.logger.error(`[Emoji] Base64 also failed: ${base64Err}`);
934
+ }
935
+ }
936
+ }