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/index.ts ADDED
@@ -0,0 +1,1830 @@
1
+ import type { AIInstance, AIService } from "mioku";
2
+ import type { ConfigService } from "mioku";
3
+ import type { ScreenshotService } from "mioku";
4
+ import { definePlugin, MiokiContext } from "mioki";
5
+ import type { ChatConfig, ChatMessage, TargetMessage } from "./types";
6
+ import { initDatabase } from "./db";
7
+ import { SessionManager } from "./manage/session";
8
+ import { RateLimiter } from "./manage/rate-limiter";
9
+ import { runChat } from "./core/chat-engine";
10
+ import { HumanizeEngine } from "./humanize";
11
+ import { SkillSessionManager } from "./manage/skill-session";
12
+ import {
13
+ getBotRole,
14
+ getGroupHistory,
15
+ getQuotedContent,
16
+ isGroupAllowed,
17
+ isQuotingBot,
18
+ shouldTrigger,
19
+ } from "./utils";
20
+ import { BASE_CONFIG } from "./configs/base";
21
+ import { SETTINGS_CONFIG } from "./configs/settings";
22
+ import { PERSONALIZATION_CONFIG } from "./configs/personalization";
23
+ import { MessageQueueManager } from "./utils/queue";
24
+ import {
25
+ buildToolContext,
26
+ getGroupHistoryMessages,
27
+ getGroupInfoData,
28
+ getHumanizeContexts,
29
+ sendAIResponse,
30
+ sendMessage,
31
+ saveBotMessages,
32
+ sendEmoji,
33
+ } from "./core/base";
34
+ import {
35
+ summarizeGroupNotice,
36
+ summarizeHistoryCard,
37
+ summarizeHistoryForward,
38
+ summarizeHistoryVideo,
39
+ type HistoryMediaProcessingOptions,
40
+ } from "./core/media/history-media";
41
+ import { CooldownManager } from "./manage/cooldown";
42
+ import { IdleCheckManager } from "./manage/idle-check";
43
+ import { QueueProcessor } from "./manage/queue-processor";
44
+ import {
45
+ GroupStructuredHistoryManager,
46
+ type StructuredUserInput,
47
+ } from "./manage/group-structured-history";
48
+ import type {
49
+ RunRateLimitGuard,
50
+ HistoryMediaOptions,
51
+ GetGroupHistoryMessages,
52
+ SendAIResponse,
53
+ SaveBotMessages,
54
+ RunChat,
55
+ BuildToolContext,
56
+ } from "./manage/types";
57
+ import type { ChatPluginContext, ChatRuntime } from "./context";
58
+ import type { ChatRuntimeState } from "./context";
59
+ import type {
60
+ ChatRuntimeNoticeOptions,
61
+ ChatRuntimeResult,
62
+ ChatRuntimePromptInjection,
63
+ ChatRuntimeInformationRequestOptions,
64
+ ChatRuntimeCollectedInfo,
65
+ } from "mioku";
66
+ import type { AITool } from "mioku";
67
+
68
+ type RuntimeReplyContextType =
69
+ | "reply"
70
+ | "comment"
71
+ | "idle"
72
+ | "review"
73
+ | "poked";
74
+
75
+ interface ExecuteChatRuntimeRequestOptions {
76
+ event?: any;
77
+ selfId?: number;
78
+ groupId?: number;
79
+ userId?: number;
80
+ config: ChatConfig;
81
+ targetMessageContent?: string;
82
+ promptInjections?: ChatRuntimePromptInjection[];
83
+ extraTools?: AITool[];
84
+ send?: boolean;
85
+ replyContextType?: RuntimeReplyContextType;
86
+ }
87
+
88
+ function buildStructuredUserInputFromEvent(
89
+ event: any,
90
+ content: string,
91
+ fallbackTimestamp: number = Date.now(),
92
+ ): StructuredUserInput {
93
+ return {
94
+ userName:
95
+ event?.sender?.card ||
96
+ event?.sender?.nickname ||
97
+ String(event?.user_id || event?.sender?.user_id || 0),
98
+ userId: event?.user_id || event?.sender?.user_id || 0,
99
+ userRole: event?.sender?.role || "member",
100
+ userTitle: event?.sender?.title,
101
+ content,
102
+ messageId: event?.message_id,
103
+ timestamp:
104
+ typeof event?.time === "number" ? event.time * 1000 : fallbackTimestamp,
105
+ };
106
+ }
107
+
108
+ function buildStructuredUserInputFromTarget(
109
+ targetMessage: TargetMessage,
110
+ ): StructuredUserInput {
111
+ return {
112
+ userName: targetMessage.userName,
113
+ userId: targetMessage.userId,
114
+ userRole: targetMessage.userRole,
115
+ userTitle: targetMessage.userTitle,
116
+ content: targetMessage.content,
117
+ messageId: targetMessage.messageId,
118
+ timestamp: targetMessage.timestamp,
119
+ };
120
+ }
121
+
122
+ function buildHistoryMediaOptions(ai: AIInstance, config: ChatConfig) {
123
+ return {
124
+ ai,
125
+ workingModel: config.workingModel || config.model,
126
+ multimodalWorkingModel: config.multimodalWorkingModel || config.model,
127
+ };
128
+ }
129
+
130
+ function normalizeIdList(input: unknown): number[] {
131
+ if (!Array.isArray(input)) return [];
132
+ return Array.from(
133
+ new Set(
134
+ input
135
+ .map((item) => Math.floor(Number(item)))
136
+ .filter((id) => Number.isFinite(id) && id > 0),
137
+ ),
138
+ );
139
+ }
140
+
141
+ function getSegmentUrl(seg: {
142
+ url?: string;
143
+ data?: { url?: string };
144
+ }): string | null {
145
+ const url = seg?.url || seg?.data?.url;
146
+ return typeof url === "string" && url.trim() ? url.trim() : null;
147
+ }
148
+
149
+ function getSegmentSourceCandidates(seg: {
150
+ file?: string;
151
+ data?: { file?: string; path?: string; url?: string };
152
+ path?: string;
153
+ url?: string;
154
+ }): string[] {
155
+ return Array.from(
156
+ new Set(
157
+ [
158
+ seg?.file,
159
+ seg?.data?.file,
160
+ seg?.path,
161
+ seg?.data?.path,
162
+ seg?.url,
163
+ seg?.data?.url,
164
+ ]
165
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
166
+ .filter(Boolean),
167
+ ),
168
+ );
169
+ }
170
+
171
+ async function getVideoSourceCandidatesFromMessage(
172
+ bot: {
173
+ api(action: string, params?: Record<string, unknown>): Promise<unknown>;
174
+ },
175
+ messageId: number | string | undefined,
176
+ ): Promise<string[]> {
177
+ if (messageId == null) return [];
178
+ const result = (await bot.api("get_msg", { message_id: messageId })) as {
179
+ message?: unknown[];
180
+ data?: { message?: unknown[] };
181
+ };
182
+ const segments = result?.message || result?.data?.message || [];
183
+ if (!Array.isArray(segments)) return [];
184
+ const candidates: string[] = [];
185
+ for (const seg of segments as { type?: string }[]) {
186
+ if (seg?.type !== "video") continue;
187
+ candidates.push(
188
+ ...getSegmentSourceCandidates(
189
+ seg as Parameters<typeof getSegmentSourceCandidates>[0],
190
+ ),
191
+ );
192
+ }
193
+ return candidates;
194
+ }
195
+
196
+ function getForwardId(seg: {
197
+ id?: unknown;
198
+ data?: { id?: unknown };
199
+ }): string | null {
200
+ return String(seg?.id || seg?.data?.id || "");
201
+ }
202
+
203
+ function getCardData(seg: { data?: unknown }): string | null {
204
+ const data = seg?.data;
205
+ if (!data) return null;
206
+ return typeof data === "string" ? data : JSON.stringify(data);
207
+ }
208
+
209
+ function isMediaAnalysisBlocked(config: ChatConfig, userId: number): boolean {
210
+ return Boolean(config.mediaAnalysisBlacklistUsers?.includes(userId));
211
+ }
212
+
213
+ function buildHistoryMediaProcessingOptions(
214
+ ai: AIInstance,
215
+ config: ChatConfig,
216
+ db: {
217
+ getMediaSummary(key: string): import("./types").MediaSummaryRecord | null;
218
+ saveMediaSummary(summary: import("./types").MediaSummaryRecord): void;
219
+ },
220
+ bot: {
221
+ api<T = unknown>(
222
+ action: string,
223
+ params?: Record<string, unknown>,
224
+ ): Promise<T>;
225
+ },
226
+ groupId: number,
227
+ log: HistoryMediaProcessingOptions["logger"],
228
+ runAIRequest?: <T>(request: () => Promise<T>) => Promise<T | null>,
229
+ ): HistoryMediaProcessingOptions {
230
+ return {
231
+ ...buildHistoryMediaOptions(ai, config),
232
+ db,
233
+ logger: log,
234
+ bot,
235
+ groupId,
236
+ runAIRequest,
237
+ };
238
+ }
239
+
240
+ const chatPlugin = definePlugin({
241
+ name: "chat",
242
+ version: "1.0.0",
243
+ description: "AI 智能聊天插件",
244
+
245
+ async setup(ctx: MiokiContext) {
246
+ ctx.logger.info("聊天插件正在初始化...");
247
+
248
+ const aiService = ctx.services?.ai as AIService | undefined;
249
+ const configService = ctx.services?.config as ConfigService | undefined;
250
+ const screenshotService = ctx.services?.screenshot as
251
+ | ScreenshotService
252
+ | undefined;
253
+ let warnedMarkdownScreenshotUnavailable = false;
254
+
255
+ if (configService) {
256
+ await configService.registerConfig("chat", "base", BASE_CONFIG);
257
+ await configService.registerConfig("chat", "settings", SETTINGS_CONFIG);
258
+ await configService.registerConfig(
259
+ "chat",
260
+ "personalization",
261
+ PERSONALIZATION_CONFIG,
262
+ );
263
+ }
264
+
265
+ const getConfig = async (): Promise<ChatConfig> => {
266
+ if (!configService) {
267
+ return {
268
+ ...BASE_CONFIG,
269
+ ...SETTINGS_CONFIG,
270
+ ...PERSONALIZATION_CONFIG,
271
+ } as ChatConfig;
272
+ }
273
+ const base = await configService.getConfig("chat", "base");
274
+ const settings = await configService.getConfig("chat", "settings");
275
+ const personalization = await configService.getConfig(
276
+ "chat",
277
+ "personalization",
278
+ );
279
+ const merged = {
280
+ ...BASE_CONFIG,
281
+ ...SETTINGS_CONFIG,
282
+ ...PERSONALIZATION_CONFIG,
283
+ ...base,
284
+ ...settings,
285
+ ...personalization,
286
+ } as any;
287
+
288
+ if (typeof merged.stream !== "boolean") merged.stream = true;
289
+ if (typeof merged.enableMarkdownScreenshot !== "boolean")
290
+ merged.enableMarkdownScreenshot = true;
291
+ if (!screenshotService && merged.enableMarkdownScreenshot) {
292
+ merged.enableMarkdownScreenshot = false;
293
+ if (!warnedMarkdownScreenshotUnavailable) {
294
+ ctx.logger.warn(
295
+ "聊天插件未加载 screenshot 服务,Markdown 截图渲染已自动关闭",
296
+ );
297
+ warnedMarkdownScreenshotUnavailable = true;
298
+ }
299
+ }
300
+ merged.whitelistGroups = normalizeIdList(merged.whitelistGroups);
301
+ merged.blacklistGroups = normalizeIdList(merged.blacklistGroups);
302
+ merged.mediaAnalysisBlacklistUsers = normalizeIdList(
303
+ merged.mediaAnalysisBlacklistUsers ??
304
+ merged.imageAnalysisBlacklistUsers,
305
+ );
306
+ delete merged.imageAnalysisBlacklistUsers;
307
+ return merged as ChatConfig;
308
+ };
309
+
310
+ const config = await getConfig();
311
+
312
+ if (!config.apiKey) {
313
+ ctx.logger.warn(
314
+ "聊天插件未配置 API Key,请在 config/chat/base.json 中配置",
315
+ );
316
+ return;
317
+ }
318
+
319
+ const db = await initDatabase();
320
+ const sessionManager = new SessionManager(db, config.maxSessions);
321
+ const queueManager = new MessageQueueManager();
322
+ const rateLimiter = new RateLimiter({
323
+ dynamicDelay: config.dynamicDelay,
324
+ aiRequestLimits: config.aiRequestLimits,
325
+ });
326
+ const skillManager = new SkillSessionManager();
327
+
328
+ rateLimiter.setQueueLengthGetter((groupId: number) => {
329
+ return queueManager.getQueueLength(`group:${groupId}`);
330
+ });
331
+
332
+ if (!aiService) {
333
+ ctx.logger.error("聊天插件需要 AI 服务,但 AI 服务不可用");
334
+ return;
335
+ }
336
+
337
+ const mainAIInstance = await aiService.create({
338
+ name: "main",
339
+ apiUrl: config.apiUrl,
340
+ apiKey: config.apiKey,
341
+ modelType: config.isMultimodal ? "multimodal" : "text",
342
+ model: config.model,
343
+ });
344
+
345
+ const workAIInstance = await aiService.create({
346
+ name: "work",
347
+ apiUrl: config.apiUrl,
348
+ apiKey: config.apiKey,
349
+ modelType: "text",
350
+ model: config.workingModel,
351
+ });
352
+
353
+ const visionAIInstance = await aiService.create({
354
+ name: "vision",
355
+ apiUrl: config.apiUrl,
356
+ apiKey: config.apiKey,
357
+ modelType: "multimodal",
358
+ model: config.multimodalWorkingModel,
359
+ });
360
+
361
+ aiService.setDefault("main");
362
+
363
+ const humanize = new HumanizeEngine(
364
+ mainAIInstance,
365
+ workAIInstance,
366
+ config,
367
+ db,
368
+ );
369
+ await humanize.init();
370
+
371
+ const pokeCooldowns = new Map<number, number>();
372
+ const POKE_COOLDOWN_MS = 10 * 60_000;
373
+ const RATE_LIMIT_RETRY_DELAY_MS = 5_000;
374
+ const RATE_LIMIT_MAX_RETRIES = 2;
375
+ let rateLimitBlockedUntil = 0;
376
+ const processingSet = new Set<string>();
377
+ const groupStructuredHistory = new GroupStructuredHistoryManager();
378
+
379
+ function isRateLimitError(err: unknown): boolean {
380
+ return (
381
+ String(err).toLowerCase().includes("429") ||
382
+ String(err).toLowerCase().includes("rate limit")
383
+ );
384
+ }
385
+
386
+ function isRateLimitBlocked(): boolean {
387
+ return Date.now() < rateLimitBlockedUntil;
388
+ }
389
+
390
+ function markRateLimitBlocked(): void {
391
+ rateLimitBlockedUntil = Date.now() + RATE_LIMIT_RETRY_DELAY_MS;
392
+ }
393
+
394
+ async function runWithRateLimitGuard<T>(
395
+ request: () => Promise<T>,
396
+ options?: { userId?: number; groupId?: number; label?: string },
397
+ ): Promise<T | null> {
398
+ if (isRateLimitBlocked()) {
399
+ ctx.logger.warn(
400
+ `[Chat] AI request skipped due to rate limit block${options?.label ? ` (${options.label})` : ""}`,
401
+ );
402
+ return null;
403
+ }
404
+ if (!rateLimiter.canRunAIRequest(options?.userId, options?.groupId)) {
405
+ ctx.logger.warn(
406
+ `[Chat] AI request skipped due to RPM limit${options?.label ? ` (${options.label})` : ""}`,
407
+ );
408
+ return null;
409
+ }
410
+ rateLimiter.recordAIRequest(options?.userId, options?.groupId);
411
+
412
+ let retries = 0;
413
+ while (true) {
414
+ try {
415
+ const result = await request();
416
+ rateLimitBlockedUntil = 0;
417
+ return result;
418
+ } catch (err) {
419
+ if (!isRateLimitError(err)) throw err;
420
+ markRateLimitBlocked();
421
+ if (retries >= RATE_LIMIT_MAX_RETRIES) throw err;
422
+ retries += 1;
423
+ ctx.logger.warn(
424
+ `[Chat] Rate limit hit, waiting ${RATE_LIMIT_RETRY_DELAY_MS / 1000}s...`,
425
+ );
426
+ await new Promise((resolve) =>
427
+ setTimeout(resolve, RATE_LIMIT_RETRY_DELAY_MS),
428
+ );
429
+ if (isRateLimitBlocked()) rateLimitBlockedUntil = 0;
430
+ }
431
+ }
432
+ }
433
+
434
+ let cooldownManager!: CooldownManager;
435
+ let idleCheckManager!: IdleCheckManager;
436
+ let queueProcessor!: QueueProcessor;
437
+
438
+ const makeStartCooldownTimer =
439
+ () => (groupSessionId: string, groupId: number, selfId: number) => {
440
+ cooldownManager.startCooldownTimer(groupSessionId, groupId, selfId);
441
+ };
442
+
443
+ idleCheckManager = new IdleCheckManager(
444
+ ctx,
445
+ config,
446
+ db,
447
+ humanize,
448
+ mainAIInstance,
449
+ aiService,
450
+ skillManager,
451
+ groupStructuredHistory,
452
+ sessionManager,
453
+ runWithRateLimitGuard,
454
+ buildHistoryMediaOptions,
455
+ getGroupHistoryMessages,
456
+ getGroupInfoData,
457
+ sendAIResponse,
458
+ saveBotMessages,
459
+ runChat,
460
+ buildToolContext,
461
+ makeStartCooldownTimer(),
462
+ );
463
+
464
+ cooldownManager = new CooldownManager(
465
+ ctx,
466
+ config,
467
+ db,
468
+ humanize,
469
+ mainAIInstance,
470
+ aiService,
471
+ skillManager,
472
+ groupStructuredHistory,
473
+ sessionManager,
474
+ rateLimiter,
475
+ idleCheckManager,
476
+ runWithRateLimitGuard,
477
+ buildHistoryMediaOptions,
478
+ getGroupHistoryMessages,
479
+ getGroupInfoData,
480
+ getHumanizeContexts,
481
+ sendAIResponse,
482
+ saveBotMessages,
483
+ sendEmoji,
484
+ runChat,
485
+ buildToolContext,
486
+ buildStructuredUserInputFromEvent,
487
+ );
488
+
489
+ queueProcessor = new QueueProcessor(
490
+ ctx,
491
+ config,
492
+ db,
493
+ humanize,
494
+ mainAIInstance,
495
+ aiService,
496
+ skillManager,
497
+ groupStructuredHistory,
498
+ sessionManager,
499
+ rateLimiter,
500
+ queueManager,
501
+ runWithRateLimitGuard,
502
+ buildHistoryMediaOptions,
503
+ getGroupHistoryMessages,
504
+ getGroupInfoData,
505
+ getHumanizeContexts,
506
+ sendAIResponse,
507
+ saveBotMessages,
508
+ sendEmoji,
509
+ runChat,
510
+ buildToolContext,
511
+ buildStructuredUserInputFromEvent,
512
+ makeStartCooldownTimer(),
513
+ );
514
+
515
+ const cleanupInterval = setInterval(
516
+ () => skillManager.cleanup(),
517
+ 10 * 60_000,
518
+ );
519
+ idleCheckManager.start();
520
+
521
+ // Create plugin context for passing to functions
522
+ const pluginCtx: ChatPluginContext = {
523
+ ctx,
524
+ config,
525
+ db,
526
+ aiInstance: mainAIInstance,
527
+ workAIInstance,
528
+ visionAIInstance,
529
+ aiService,
530
+ humanize,
531
+ sessionManager,
532
+ skillManager,
533
+ rateLimiter,
534
+ queueManager,
535
+ groupStructuredHistory,
536
+ cooldownManager,
537
+ idleCheckManager,
538
+ queueProcessor,
539
+ runWithRateLimitGuard,
540
+ buildHistoryMediaOptions,
541
+ getGroupHistoryMessages,
542
+ getGroupInfoData,
543
+ getHumanizeContexts,
544
+ sendAIResponse,
545
+ sendMessage,
546
+ saveBotMessages,
547
+ sendEmoji,
548
+ buildToolContext,
549
+ buildStructuredUserInputFromTarget,
550
+ runChat,
551
+ };
552
+
553
+ const runtimeState: ChatRuntimeState = {
554
+ isRateLimitBlocked,
555
+ processingSet,
556
+ };
557
+
558
+ function buildRuntimeTargetMessageContent(
559
+ event: any,
560
+ overrideContent?: string,
561
+ ): string {
562
+ if (overrideContent?.trim()) {
563
+ return overrideContent.trim();
564
+ }
565
+ // If no event or event has no message property (synthetic event like group_member_increase),
566
+ // return the default prompt
567
+ if (!event || !event.message) {
568
+ return "[No new user message in this turn. Reply naturally based on the runtime instruction and recent context.]";
569
+ }
570
+ const text = ctx.text(event)?.trim() || "";
571
+ if (text) {
572
+ return text;
573
+ }
574
+ return "[No new user message in this turn. Reply naturally based on the runtime instruction and recent context.]";
575
+ }
576
+
577
+ interface ResolvedChatRuntimeContext {
578
+ event: any;
579
+ isGroup: boolean;
580
+ groupId?: number;
581
+ userId: number;
582
+ selfId: number;
583
+ sessionId: string;
584
+ personalSessionId?: string;
585
+ senderName: string;
586
+ userRole: "owner" | "admin" | "member";
587
+ userTitle?: string;
588
+ groupName?: string;
589
+ messageId?: number;
590
+ }
591
+
592
+ function resolveChatRuntimeContext(
593
+ options: ExecuteChatRuntimeRequestOptions,
594
+ ): ResolvedChatRuntimeContext {
595
+ if (options.event) {
596
+ const event = options.event;
597
+ const isGroup = event.message_type === "group";
598
+ const groupId: number | undefined = isGroup
599
+ ? event.group_id
600
+ : undefined;
601
+ const userId: number = event.user_id || event.sender?.user_id || 0;
602
+ const selfId: number = event.self_id;
603
+ return {
604
+ event,
605
+ isGroup,
606
+ groupId,
607
+ userId,
608
+ selfId,
609
+ sessionId: groupId ? `group:${groupId}` : `personal:${userId}`,
610
+ personalSessionId: groupId ? `personal:${userId}` : undefined,
611
+ senderName:
612
+ event.sender?.card || event.sender?.nickname || String(userId),
613
+ userRole: event.sender?.role || "member",
614
+ userTitle: event.sender?.title || undefined,
615
+ groupName: event.group_name,
616
+ messageId: event.message_id,
617
+ };
618
+ }
619
+
620
+ if (typeof options.selfId !== "number") {
621
+ throw new Error("Chat runtime requires either event or selfId");
622
+ }
623
+ if (
624
+ typeof options.groupId !== "number" &&
625
+ typeof options.userId !== "number"
626
+ ) {
627
+ throw new Error("Chat runtime requires groupId or userId");
628
+ }
629
+
630
+ const isGroup = typeof options.groupId === "number";
631
+ const userId = options.userId ?? 0;
632
+ const event = {
633
+ self_id: options.selfId,
634
+ message_type: isGroup ? "group" : "private",
635
+ group_id: options.groupId,
636
+ user_id: userId,
637
+ group_name: undefined,
638
+ sender: {
639
+ user_id: userId,
640
+ card: undefined,
641
+ nickname: undefined,
642
+ role: "member",
643
+ title: undefined,
644
+ },
645
+ };
646
+
647
+ return {
648
+ event,
649
+ isGroup,
650
+ groupId: options.groupId,
651
+ userId,
652
+ selfId: options.selfId,
653
+ sessionId: options.groupId
654
+ ? `group:${options.groupId}`
655
+ : `personal:${userId}`,
656
+ personalSessionId:
657
+ options.groupId && userId ? `personal:${userId}` : undefined,
658
+ senderName: options.groupId ? "system" : String(userId),
659
+ userRole: "member",
660
+ userTitle: undefined,
661
+ groupName: undefined,
662
+ messageId: undefined,
663
+ };
664
+ }
665
+
666
+ async function executeChatRuntimeRequest(
667
+ options: ExecuteChatRuntimeRequestOptions,
668
+ ): Promise<ChatRuntimeResult> {
669
+ const cfg = options.config;
670
+ const runtimeCtx = resolveChatRuntimeContext(options);
671
+ const {
672
+ event,
673
+ isGroup,
674
+ groupId,
675
+ userId,
676
+ selfId,
677
+ sessionId,
678
+ personalSessionId,
679
+ senderName,
680
+ userRole,
681
+ userTitle,
682
+ groupName: runtimeGroupName,
683
+ messageId,
684
+ } = runtimeCtx;
685
+ const targetContent = buildRuntimeTargetMessageContent(
686
+ event,
687
+ options.targetMessageContent,
688
+ );
689
+
690
+ sessionManager.getOrCreate(
691
+ sessionId,
692
+ groupId ? "group" : "personal",
693
+ groupId ?? userId,
694
+ );
695
+ if (groupId && personalSessionId) {
696
+ sessionManager.getOrCreate(personalSessionId, "personal", userId);
697
+ }
698
+
699
+ const rawHistory = groupId
700
+ ? await getGroupHistory(groupId, ctx, cfg.historyCount, selfId, db)
701
+ : [];
702
+ const history: ChatMessage[] = rawHistory.map((msg) => ({
703
+ sessionId,
704
+ role: "user",
705
+ content: msg.content,
706
+ userId: msg.userId,
707
+ userName: msg.userName,
708
+ userRole: msg.userRole,
709
+ groupId,
710
+ timestamp: msg.timestamp,
711
+ messageId: msg.messageId,
712
+ }));
713
+
714
+ const botRole = groupId
715
+ ? await getBotRole(groupId, ctx, selfId)
716
+ : "member";
717
+ const botNickname =
718
+ cfg.nicknames[0] || ctx.pickBot(selfId)?.nickname || "Bot";
719
+
720
+ let groupName: string | undefined;
721
+ let memberCount: number | undefined;
722
+ if (groupId) {
723
+ const groupInfo = await getGroupInfoData(
724
+ ctx,
725
+ groupId,
726
+ selfId,
727
+ runtimeGroupName,
728
+ );
729
+ groupName = groupInfo.groupName;
730
+ memberCount = groupInfo.memberCount;
731
+ } else {
732
+ groupName = runtimeGroupName;
733
+ }
734
+
735
+ const contexts = await getHumanizeContexts(
736
+ humanize,
737
+ sessionId,
738
+ senderName,
739
+ history,
740
+ );
741
+
742
+ const targetMessage: TargetMessage = {
743
+ userName: senderName,
744
+ userId,
745
+ userRole,
746
+ userTitle,
747
+ content: targetContent,
748
+ messageId,
749
+ timestamp: Date.now(),
750
+ };
751
+
752
+ const toolCtx = buildToolContext({
753
+ ctx,
754
+ event,
755
+ groupSessionId: sessionId,
756
+ groupId,
757
+ userId,
758
+ config: cfg,
759
+ aiService: aiService!,
760
+ db,
761
+ botRole,
762
+ humanize,
763
+ targetMessage,
764
+ selfId,
765
+ });
766
+
767
+ if (options.send === false) {
768
+ toolCtx.onTextContent = undefined;
769
+ toolCtx.sentMessageIndices = undefined;
770
+ }
771
+
772
+ const result = await runChat(
773
+ mainAIInstance,
774
+ toolCtx,
775
+ history,
776
+ targetMessage,
777
+ {
778
+ config: cfg,
779
+ groupName,
780
+ memberCount,
781
+ botNickname,
782
+ botRole,
783
+ aiService: aiService!,
784
+ isGroup,
785
+ memoryContext: contexts.memoryContext,
786
+ topicContext: contexts.topicContext,
787
+ expressionContext: contexts.expressionContext,
788
+ replyContext: {
789
+ type: options.replyContextType || "reply",
790
+ targetUser: targetMessage.userName,
791
+ targetMessage: targetMessage.content,
792
+ },
793
+ promptInjections: options.promptInjections,
794
+ },
795
+ humanize,
796
+ skillManager,
797
+ undefined,
798
+ {
799
+ extraTools: options.extraTools,
800
+ },
801
+ );
802
+
803
+ if (options.send !== false) {
804
+ if (groupId) {
805
+ await sendAIResponse(
806
+ {
807
+ ctx,
808
+ groupId,
809
+ messages: result.messages,
810
+ config: cfg,
811
+ sentIndices: toolCtx.sentMessageIndices,
812
+ },
813
+ selfId,
814
+ );
815
+
816
+ await sendEmoji(ctx, groupId, result.emojiPath, selfId);
817
+
818
+ const now = Date.now();
819
+ saveBotMessages(
820
+ groupId,
821
+ sessionId,
822
+ result.messages,
823
+ now,
824
+ cfg,
825
+ db,
826
+ ctx,
827
+ selfId,
828
+ );
829
+
830
+ cooldownManager.startCooldownTimer(sessionId, groupId, selfId);
831
+ } else {
832
+ const sentIndices = toolCtx.sentMessageIndices;
833
+ for (let i = 0; i < result.messages.length; i++) {
834
+ if (sentIndices?.has(i)) continue;
835
+ await sendMessage(
836
+ ctx,
837
+ undefined,
838
+ userId,
839
+ result.messages[i],
840
+ cfg,
841
+ selfId,
842
+ );
843
+ }
844
+
845
+ if (result.emojiPath) {
846
+ try {
847
+ const emojiSegment = ctx.segment.image(
848
+ `file://${result.emojiPath}`,
849
+ );
850
+ const bot = ctx.pickBot(selfId);
851
+ if (!bot) {
852
+ throw new Error(`bot ${String(selfId)} not found`);
853
+ }
854
+ await bot.sendPrivateMsg(userId, [emojiSegment]);
855
+ } catch (err) {
856
+ ctx.logger.warn(`[chat-runtime] Send emoji failed: ${err}`);
857
+ }
858
+ }
859
+ }
860
+ }
861
+
862
+ sessionManager.touch(sessionId);
863
+
864
+ return {
865
+ messages: result.messages,
866
+ toolCalls: result.toolCalls.map((toolCall) => ({
867
+ name: toolCall.name,
868
+ arguments: toolCall.args,
869
+ result: toolCall.result,
870
+ })),
871
+ collectedInfo: null,
872
+ };
873
+ }
874
+
875
+ const runtime: ChatRuntime = {
876
+ async requestInformation(
877
+ options: ChatRuntimeInformationRequestOptions,
878
+ ): Promise<ChatRuntimeResult> {
879
+ let collectedInfo: ChatRuntimeCollectedInfo | null = null;
880
+ const toolName = options.toolName || "submit_requested_information";
881
+ const extraTools: AITool[] = [
882
+ {
883
+ name: toolName,
884
+ description:
885
+ options.toolDescription ||
886
+ "Submit structured information extracted from the conversation when enough details are known.",
887
+ parameters: {
888
+ type: "object",
889
+ properties: {
890
+ data: options.schema,
891
+ isComplete: {
892
+ type: "boolean",
893
+ description:
894
+ "Whether the collected information is complete enough for the caller to continue.",
895
+ },
896
+ confidence: {
897
+ type: "number",
898
+ description:
899
+ "Confidence score between 0 and 1 for the submitted information.",
900
+ },
901
+ notes: {
902
+ type: "string",
903
+ description:
904
+ "Optional notes about ambiguity, assumptions, or remaining follow-up needs.",
905
+ },
906
+ },
907
+ required: ["data"],
908
+ },
909
+ handler: async (args) => {
910
+ collectedInfo = {
911
+ data: args?.data,
912
+ isComplete: args?.isComplete,
913
+ confidence: args?.confidence,
914
+ notes: args?.notes,
915
+ };
916
+ return {
917
+ success: true,
918
+ accepted: true,
919
+ collectedInfo,
920
+ };
921
+ },
922
+ },
923
+ ];
924
+
925
+ const promptInjections: ChatRuntimePromptInjection[] = [
926
+ {
927
+ title: "Information Collection Goal",
928
+ content: [
929
+ "Another plugin needs you to gather specific information from the current user while staying fully in your existing persona.",
930
+ `Task: ${options.task}`,
931
+ `Target schema: ${JSON.stringify(options.schema)}`,
932
+ `If the needed information is already clear from the target message, recent chat history, or obvious context, call the tool "${toolName}" immediately with structured data.`,
933
+ "If the information is incomplete, ask only the smallest natural follow-up question needed.",
934
+ "Do not mention schemas, forms, prompts, plugins, or tools to the user.",
935
+ "Keep the response natural and in-character rather than sounding like a questionnaire.",
936
+ ].join("\n"),
937
+ },
938
+ ...(options.promptInjections || []),
939
+ ];
940
+
941
+ const result = await executeChatRuntimeRequest({
942
+ ...options,
943
+ config: await getConfig(),
944
+ targetMessageContent: options.targetMessage,
945
+ promptInjections,
946
+ extraTools,
947
+ send: options.send,
948
+ replyContextType: "reply",
949
+ });
950
+
951
+ return {
952
+ ...result,
953
+ collectedInfo,
954
+ };
955
+ },
956
+
957
+ async generateNotice(
958
+ options: ChatRuntimeNoticeOptions,
959
+ ): Promise<ChatRuntimeResult> {
960
+ const promptInjections: ChatRuntimePromptInjection[] = [
961
+ {
962
+ title: "Notification Goal",
963
+ content: [
964
+ "Another plugin needs you to deliver a notification to the current user, but it must still sound like you and fit the current conversation.",
965
+ `Goal: ${options.instruction}`,
966
+ "Blend the notice into your normal speaking style instead of sounding like a rigid system announcement unless the situation clearly requires firmness.",
967
+ "Do not mention prompts, plugins, tools, or hidden instructions.",
968
+ ].join("\n"),
969
+ },
970
+ ...(options.promptInjections || []),
971
+ ];
972
+
973
+ return executeChatRuntimeRequest({
974
+ ...options,
975
+ config: await getConfig(),
976
+ targetMessageContent: options.instruction,
977
+ promptInjections,
978
+ send: options.send,
979
+ replyContextType: "reply",
980
+ });
981
+ },
982
+ };
983
+
984
+ // Register chat runtime so other plugins (like boot) can use it
985
+ aiService.registerChatRuntime(runtime);
986
+
987
+ // ==================== 消息处理 ====================
988
+ ctx.handle("message", async (e: any) => {
989
+ const cfg = await getConfig();
990
+ if (!cfg.apiKey) return;
991
+
992
+ const text = ctx.text(e) || "";
993
+ const isGroup = e.message_type === "group";
994
+ const groupId: number | undefined = isGroup ? e.group_id : undefined;
995
+ const userId: number = e.user_id || e.sender?.user_id;
996
+
997
+ if (userId === e.self_id) return;
998
+
999
+ // 命令处理
1000
+ if (text.startsWith("/空闲检查 ")) {
1001
+ await handleIdleCheckDebug(
1002
+ ctx,
1003
+ e,
1004
+ cfg,
1005
+ mainAIInstance,
1006
+ db,
1007
+ humanize,
1008
+ skillManager,
1009
+ runWithRateLimitGuard,
1010
+ getGroupHistoryMessages,
1011
+ buildHistoryMediaOptions,
1012
+ runChat,
1013
+ buildToolContext,
1014
+ sendAIResponse,
1015
+ saveBotMessages,
1016
+ );
1017
+ return;
1018
+ }
1019
+
1020
+ if (text === "/重置会话") {
1021
+ if (groupId) {
1022
+ sessionManager.resetBotMessages(`group:${groupId}`);
1023
+ groupStructuredHistory.clear(`group:${groupId}`);
1024
+ await e.reply("已清除本群会话中 AI 发送的消息~");
1025
+ } else {
1026
+ sessionManager.resetBotMessages(`personal:${userId}`);
1027
+ groupStructuredHistory.clear(`personal:${userId}`);
1028
+ await e.reply("已清除你的个人会话中 AI 发送的消息~");
1029
+ }
1030
+ return;
1031
+ }
1032
+
1033
+ if (groupId && !isGroupAllowed(groupId, cfg)) return;
1034
+
1035
+ // 媒体分析
1036
+ if (
1037
+ isGroup &&
1038
+ groupId &&
1039
+ e.message &&
1040
+ !isMediaAnalysisBlocked(cfg, userId)
1041
+ ) {
1042
+ const ai = aiService!.getDefault();
1043
+ const bot = ctx.pickBot(e.self_id) as any;
1044
+ const mediaOptions = ai
1045
+ ? buildHistoryMediaProcessingOptions(
1046
+ ai,
1047
+ cfg,
1048
+ db,
1049
+ bot,
1050
+ groupId,
1051
+ {
1052
+ info: (m) => ctx.logger.info(m),
1053
+ warn: (m) => ctx.logger.warn(m),
1054
+ error: (m) => ctx.logger.error(m),
1055
+ },
1056
+ (request) =>
1057
+ runWithRateLimitGuard(request, {
1058
+ userId,
1059
+ groupId,
1060
+ label: "history-media",
1061
+ }),
1062
+ )
1063
+ : undefined;
1064
+
1065
+ if (ai && cfg.isMultimodal) {
1066
+ const { processImage } = await import("./core/media/image-analyzer");
1067
+ for (const seg of e.message) {
1068
+ if (seg.type === "image") {
1069
+ const imageUrl = getSegmentUrl(seg);
1070
+ if (imageUrl) {
1071
+ processImage(ai, imageUrl, cfg.multimodalWorkingModel, db, {
1072
+ runAIRequest: (request) =>
1073
+ runWithRateLimitGuard(request, {
1074
+ userId,
1075
+ groupId,
1076
+ label: "image-analysis",
1077
+ }),
1078
+ }).catch((err) =>
1079
+ ctx.logger.error(`[image-analyzer] Failed: ${err}`),
1080
+ );
1081
+ }
1082
+ } else if (seg.type === "video" && mediaOptions) {
1083
+ const videoSources = [
1084
+ ...getSegmentSourceCandidates(seg),
1085
+ ...(await getVideoSourceCandidatesFromMessage(
1086
+ bot,
1087
+ e.message_id,
1088
+ ).catch(() => [])),
1089
+ ];
1090
+ if (videoSources.length > 0) {
1091
+ summarizeHistoryVideo(videoSources, mediaOptions).catch((err) =>
1092
+ ctx.logger.error(
1093
+ `[history-media] Failed to process video: ${err}`,
1094
+ ),
1095
+ );
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ if (mediaOptions) {
1102
+ for (const seg of e.message) {
1103
+ if (seg.type === "forward") {
1104
+ const forwardId = getForwardId(seg);
1105
+ if (forwardId)
1106
+ summarizeHistoryForward(forwardId, mediaOptions).catch((err) =>
1107
+ ctx.logger.error(
1108
+ `[history-media] Failed to process forward: ${err}`,
1109
+ ),
1110
+ );
1111
+ } else if (["xml", "json", "lightapp", "ark"].includes(seg.type)) {
1112
+ const cardData = getCardData(seg);
1113
+ if (cardData)
1114
+ summarizeHistoryCard(cardData, mediaOptions).catch((err) =>
1115
+ ctx.logger.error(
1116
+ `[history-media] Failed to process card: ${err}`,
1117
+ ),
1118
+ );
1119
+ }
1120
+ }
1121
+ if (e.sub_type === "notice") {
1122
+ summarizeGroupNotice(e, mediaOptions)
1123
+ .then((noticeMessage) => {
1124
+ if (!noticeMessage) return;
1125
+ db.saveMessage({
1126
+ sessionId: `group:${groupId}`,
1127
+ role: "user",
1128
+ content: noticeMessage.content,
1129
+ userId: noticeMessage.userId,
1130
+ userName: noticeMessage.userName,
1131
+ userRole: noticeMessage.userRole,
1132
+ groupId,
1133
+ groupName: e.group_name,
1134
+ timestamp: noticeMessage.timestamp,
1135
+ messageId: noticeMessage.messageId,
1136
+ });
1137
+ })
1138
+ .catch((err) =>
1139
+ ctx.logger.error(
1140
+ `[history-media] Failed to process group notice: ${err}`,
1141
+ ),
1142
+ );
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ const atBot = shouldTrigger(e, text, cfg, ctx);
1148
+ const quotedBot = isGroup ? await isQuotingBot(e, ctx) : null;
1149
+ const mentionedNickname =
1150
+ cfg.nicknames.length > 0 &&
1151
+ cfg.nicknames.some((n) => text.toLowerCase().includes(n.toLowerCase()));
1152
+
1153
+ const groupSessionId = groupId ? `group:${groupId}` : undefined;
1154
+
1155
+ // 记录群活动
1156
+ if (isGroup && groupId && groupSessionId) {
1157
+ idleCheckManager.recordActivity(groupSessionId);
1158
+
1159
+ if (cooldownManager.isInCooldown(groupSessionId)) {
1160
+ cooldownManager.collectMessage(
1161
+ groupSessionId,
1162
+ groupId,
1163
+ e,
1164
+ text,
1165
+ atBot,
1166
+ );
1167
+ return;
1168
+ }
1169
+
1170
+ if (queueProcessor.isInDynamicDelay(groupSessionId)) {
1171
+ if (atBot && !isRateLimitBlocked()) {
1172
+ rateLimiter.recordInteraction(groupId, userId);
1173
+ queueProcessor.collectDynamicDelayMessage(groupSessionId, e, text);
1174
+ }
1175
+ return;
1176
+ }
1177
+ }
1178
+
1179
+ // 检查是否在处理中
1180
+ if (isGroup && groupId && groupSessionId) {
1181
+ if (processingSet.has(groupSessionId)) {
1182
+ if ((atBot || mentionedNickname) && !isRateLimitBlocked()) {
1183
+ queueManager.enqueue(groupSessionId, e, cfg);
1184
+ rateLimiter.recordInteraction(groupId, userId);
1185
+ }
1186
+ return;
1187
+ }
1188
+ processingSet.add(groupSessionId);
1189
+ } else {
1190
+ const triggerKey = `personal:${userId}`;
1191
+ if (processingSet.has(triggerKey)) return;
1192
+ processingSet.add(triggerKey);
1193
+ }
1194
+
1195
+ try {
1196
+ if (atBot) {
1197
+ if (!rateLimiter.canProcess(userId, groupId, text)) return;
1198
+
1199
+ if (
1200
+ isGroup &&
1201
+ groupId &&
1202
+ groupSessionId &&
1203
+ cfg.dynamicDelay?.enabled
1204
+ ) {
1205
+ rateLimiter.recordInteraction(groupId, userId);
1206
+ const delayInfo = rateLimiter.getDelayInfo(groupId);
1207
+ if (delayInfo.shouldDelay) {
1208
+ rateLimiter.record(userId, groupId, text);
1209
+ queueProcessor.collectDynamicDelayMessage(
1210
+ groupSessionId,
1211
+ e,
1212
+ text,
1213
+ );
1214
+ queueProcessor.startDynamicDelayTimer(
1215
+ groupSessionId,
1216
+ groupId,
1217
+ delayInfo.delayMs,
1218
+ e.self_id,
1219
+ );
1220
+ return;
1221
+ }
1222
+ }
1223
+
1224
+ rateLimiter.record(userId, groupId, text);
1225
+ await processChat(e, pluginCtx, runtimeState);
1226
+ return;
1227
+ }
1228
+
1229
+ if (quotedBot || mentionedNickname) {
1230
+ const { history } = await getGroupHistoryMessages(
1231
+ groupId!,
1232
+ groupSessionId!,
1233
+ ctx,
1234
+ cfg.historyCount,
1235
+ db,
1236
+ e.self_id,
1237
+ pluginCtx.buildHistoryMediaOptions(pluginCtx.aiInstance, cfg),
1238
+ );
1239
+ const botNickname =
1240
+ cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
1241
+ const planResult = await humanize.actionPlanner.plan(
1242
+ groupSessionId!,
1243
+ botNickname,
1244
+ history,
1245
+ text,
1246
+ );
1247
+ if (planResult.action !== "reply") return;
1248
+ if (!rateLimiter.canProcess(userId, groupId, text)) return;
1249
+ rateLimiter.record(userId, groupId, text);
1250
+ await processChat(e, pluginCtx, runtimeState);
1251
+ return;
1252
+ }
1253
+ } finally {
1254
+ if (isGroup && groupId && groupSessionId) {
1255
+ processingSet.delete(groupSessionId);
1256
+ await queueProcessor.processQueuedMessages(groupSessionId, e.self_id);
1257
+ } else {
1258
+ processingSet.delete(`personal:${userId}`);
1259
+ }
1260
+ }
1261
+ });
1262
+
1263
+ // ==================== 戳一戳处理 ====================
1264
+ ctx.handle("notice.group.poke" as any, async (e: any) => {
1265
+ if (e.target_id !== e.self_id) return;
1266
+ const cfg = await getConfig();
1267
+ if (!cfg.apiKey) return;
1268
+ const groupId = e.group_id;
1269
+ if (!groupId || !isGroupAllowed(groupId, cfg)) return;
1270
+
1271
+ const lastPoke = pokeCooldowns.get(groupId) ?? 0;
1272
+ if (Date.now() - lastPoke < POKE_COOLDOWN_MS) return;
1273
+ pokeCooldowns.set(groupId, Date.now());
1274
+
1275
+ const groupSessionId = `group:${groupId}`;
1276
+ if (isRateLimitBlocked()) return;
1277
+
1278
+ if (processingSet.has(groupSessionId)) {
1279
+ if (!isRateLimitBlocked()) queueManager.enqueue(groupSessionId, e, cfg);
1280
+ return;
1281
+ }
1282
+
1283
+ processingSet.add(groupSessionId);
1284
+ sessionManager.getOrCreate(groupSessionId, "group", groupId);
1285
+
1286
+ try {
1287
+ const userId = e.user_id || e.operator_id;
1288
+ const botRole = await getBotRole(groupId, ctx, e.self_id);
1289
+ const botNickname =
1290
+ cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
1291
+
1292
+ let senderName = String(userId);
1293
+ try {
1294
+ const memberInfo = await ctx
1295
+ .pickBot(e.self_id)
1296
+ .getGroupMemberInfo(groupId, userId);
1297
+ senderName =
1298
+ (memberInfo as any).card ||
1299
+ (memberInfo as any).nickname ||
1300
+ String(userId);
1301
+ } catch {}
1302
+
1303
+ const targetMessage: TargetMessage = {
1304
+ userName: senderName,
1305
+ userId,
1306
+ userRole: "member",
1307
+ content: `[${senderName} poked you]`,
1308
+ timestamp: Date.now(),
1309
+ };
1310
+
1311
+ const { history } = await getGroupHistoryMessages(
1312
+ groupId,
1313
+ groupSessionId,
1314
+ ctx,
1315
+ cfg.historyCount,
1316
+ db,
1317
+ e.self_id,
1318
+ pluginCtx.buildHistoryMediaOptions(pluginCtx.aiInstance, cfg),
1319
+ );
1320
+ const { groupName, memberCount } = await getGroupInfoData(
1321
+ ctx,
1322
+ groupId,
1323
+ e.self_id,
1324
+ );
1325
+
1326
+ const toolCtx = buildToolContext({
1327
+ ctx,
1328
+ event: e,
1329
+ groupSessionId,
1330
+ groupId,
1331
+ userId,
1332
+ config: cfg,
1333
+ aiService: aiService!,
1334
+ db,
1335
+ botRole,
1336
+ humanize,
1337
+ targetMessage,
1338
+ selfId: e.self_id,
1339
+ });
1340
+
1341
+ const result = await runWithRateLimitGuard(
1342
+ () =>
1343
+ runChat(
1344
+ mainAIInstance,
1345
+ toolCtx,
1346
+ history,
1347
+ targetMessage,
1348
+ {
1349
+ config: cfg,
1350
+ groupName,
1351
+ memberCount,
1352
+ botNickname,
1353
+ botRole,
1354
+ aiService: aiService!,
1355
+ isGroup: true,
1356
+ replyContext: {
1357
+ type: "poked",
1358
+ targetUser: targetMessage.userName,
1359
+ targetMessage: targetMessage.content,
1360
+ },
1361
+ },
1362
+ humanize,
1363
+ skillManager,
1364
+ {
1365
+ manager: groupStructuredHistory,
1366
+ ttlMs: cfg.groupStructuredHistoryTtlMs,
1367
+ currentUserInputs: [
1368
+ buildStructuredUserInputFromTarget(targetMessage),
1369
+ ],
1370
+ },
1371
+ ),
1372
+ { userId, groupId, label: "poke" },
1373
+ );
1374
+ if (!result) return;
1375
+
1376
+ await sendAIResponse(
1377
+ {
1378
+ ctx,
1379
+ groupId,
1380
+ messages: result.messages,
1381
+ config: cfg,
1382
+ sentIndices: toolCtx.sentMessageIndices,
1383
+ },
1384
+ e.self_id,
1385
+ );
1386
+
1387
+ const now = Date.now();
1388
+ saveBotMessages(
1389
+ groupId,
1390
+ groupSessionId,
1391
+ result.messages,
1392
+ now,
1393
+ cfg,
1394
+ db,
1395
+ ctx,
1396
+ e.self_id,
1397
+ );
1398
+ idleCheckManager.recordBotMessages(
1399
+ groupSessionId,
1400
+ result.messages.length,
1401
+ e.self_id,
1402
+ );
1403
+
1404
+ await sendEmoji(ctx, groupId, result.emojiPath, e.self_id);
1405
+ sessionManager.touch(groupSessionId);
1406
+ } catch (err) {
1407
+ ctx.logger.error(`Poke processing failed: ${err}`);
1408
+ } finally {
1409
+ processingSet.delete(groupSessionId);
1410
+ }
1411
+ });
1412
+
1413
+ ctx.logger.info("聊天插件加载成功");
1414
+
1415
+ return () => {
1416
+ db.close();
1417
+ rateLimiter.dispose();
1418
+ clearInterval(cleanupInterval);
1419
+ cooldownManager.dispose();
1420
+ idleCheckManager.dispose();
1421
+ queueProcessor.dispose();
1422
+ processingSet.clear();
1423
+ pokeCooldowns.clear();
1424
+ ctx.logger.info("聊天插件已卸载");
1425
+ };
1426
+ },
1427
+ });
1428
+
1429
+ async function handleIdleCheckDebug(
1430
+ ctx: MiokiContext,
1431
+ e: any,
1432
+ cfg: ChatConfig,
1433
+ mainAIInstance: AIInstance,
1434
+ db: import("./db").ChatDatabase,
1435
+ humanize: HumanizeEngine,
1436
+ skillManager: SkillSessionManager,
1437
+ runWithRateLimitGuard: RunRateLimitGuard,
1438
+ getGroupHistoryMessages: GetGroupHistoryMessages,
1439
+ buildHistoryMediaOptions: (
1440
+ ai: AIInstance,
1441
+ cfg: ChatConfig,
1442
+ ) => HistoryMediaOptions,
1443
+ runChat: RunChat,
1444
+ buildToolContext: BuildToolContext,
1445
+ sendAIResponse: SendAIResponse,
1446
+ saveBotMessages: SaveBotMessages,
1447
+ ) {
1448
+ const isOwner = ctx.isOwner?.(e) ?? false;
1449
+ if (!isOwner) {
1450
+ await e.reply("只有主人才能使用这个指令~");
1451
+ return;
1452
+ }
1453
+ const groupIdStr = e.message[0]?.text?.replace("/空闲检查", "")?.trim() || "";
1454
+ const targetGroupId = parseInt(groupIdStr, 10);
1455
+ if (!targetGroupId) {
1456
+ await e.reply("请指定群号,如:/空闲检查 123456");
1457
+ return;
1458
+ }
1459
+
1460
+ const groupSessionId = `group:${targetGroupId}`;
1461
+ try {
1462
+ const now = Date.now();
1463
+ const botNickname =
1464
+ cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
1465
+ ctx.logger.info(`[Debug] Manual idle check: group ${targetGroupId}`);
1466
+
1467
+ const { history } = await getGroupHistoryMessages(
1468
+ targetGroupId,
1469
+ groupSessionId,
1470
+ ctx,
1471
+ cfg.historyCount,
1472
+ db,
1473
+ e.self_id,
1474
+ buildHistoryMediaOptions(mainAIInstance, cfg),
1475
+ );
1476
+ const planResult = await humanize.actionPlanner.plan(
1477
+ groupSessionId,
1478
+ botNickname,
1479
+ history,
1480
+ "[Check if you want to answer the call]",
1481
+ true,
1482
+ );
1483
+
1484
+ if (planResult.action !== "reply") {
1485
+ await e.reply(
1486
+ `[空闲检测] 群 ${targetGroupId}\n决定: ${planResult.action}\n原因: ${planResult.reason}`,
1487
+ );
1488
+ return;
1489
+ }
1490
+
1491
+ const targetMessage: TargetMessage = {
1492
+ userName: "系统",
1493
+ userId: 0,
1494
+ userRole: "member",
1495
+ content: "[No one in the group is talking? I'll answer!]",
1496
+ messageId: 0,
1497
+ timestamp: now,
1498
+ };
1499
+ const botRole = await getBotRole(targetGroupId, ctx, e.self_id);
1500
+ const toolCtx = buildToolContext({
1501
+ ctx,
1502
+ event: null,
1503
+ groupSessionId,
1504
+ groupId: targetGroupId,
1505
+ userId: 0,
1506
+ config: cfg,
1507
+ aiService: ctx.services!.ai as AIService,
1508
+ db,
1509
+ botRole,
1510
+ humanize,
1511
+ targetMessage,
1512
+ selfId: e.self_id,
1513
+ });
1514
+
1515
+ const result = await runWithRateLimitGuard(
1516
+ () =>
1517
+ runChat(
1518
+ mainAIInstance,
1519
+ toolCtx,
1520
+ history,
1521
+ targetMessage,
1522
+ {
1523
+ config: cfg,
1524
+ botNickname,
1525
+ botRole: toolCtx.botRole,
1526
+ aiService: ctx.services!.ai as AIService,
1527
+ isGroup: true,
1528
+ plannerThoughts: `You stumbled upon some message in this group and decided to reply.\nQuote messages from group friends appropriately (using [[[reply:message ID]]] format).\nDon't mention your intentions like "I'm here to answer".`,
1529
+ replyContext: { type: "idle" },
1530
+ },
1531
+ humanize,
1532
+ skillManager,
1533
+ ),
1534
+ { groupId: targetGroupId, label: "idle-check" },
1535
+ );
1536
+ if (!result) {
1537
+ await e.reply(`[空闲检测] 群 ${targetGroupId} 因限流被跳过`);
1538
+ return;
1539
+ }
1540
+
1541
+ await sendAIResponse(
1542
+ {
1543
+ ctx,
1544
+ groupId: targetGroupId,
1545
+ messages: result.messages,
1546
+ config: cfg,
1547
+ sentIndices: toolCtx.sentMessageIndices,
1548
+ },
1549
+ e.self_id,
1550
+ );
1551
+ const now2 = Date.now();
1552
+ saveBotMessages(
1553
+ targetGroupId,
1554
+ groupSessionId,
1555
+ result.messages,
1556
+ now2,
1557
+ cfg,
1558
+ db,
1559
+ ctx,
1560
+ e.self_id,
1561
+ );
1562
+ await e.reply(
1563
+ `[空闲检测] 群 ${targetGroupId} 已发送回复: ${planResult.reason}`,
1564
+ );
1565
+ } catch (err) {
1566
+ ctx.logger.error(`[Debug] Idle check failed: ${err}`);
1567
+ await e.reply(`[空闲检测] 失败: ${err}`);
1568
+ }
1569
+ }
1570
+
1571
+ async function processChat(
1572
+ e: any,
1573
+ pluginCtx: ChatPluginContext,
1574
+ runtimeState: ChatRuntimeState,
1575
+ ) {
1576
+ const { ctx, config: cfg } = pluginCtx;
1577
+ const isGroup = e.message_type === "group";
1578
+ const groupId: number | undefined = isGroup ? e.group_id : undefined;
1579
+ const userId: number = e.user_id || e.sender?.user_id;
1580
+ const selfId = e.self_id;
1581
+
1582
+ const personalSessionId = `personal:${userId}`;
1583
+ const groupSessionId = groupId ? `group:${groupId}` : personalSessionId;
1584
+
1585
+ if (runtimeState.isRateLimitBlocked()) {
1586
+ if (groupId) pluginCtx.queueManager.clearActiveTarget(groupSessionId);
1587
+ return;
1588
+ }
1589
+
1590
+ try {
1591
+ pluginCtx.sessionManager.getOrCreate(
1592
+ groupSessionId,
1593
+ groupId ? "group" : "personal",
1594
+ groupId ?? userId,
1595
+ );
1596
+ if (groupId)
1597
+ pluginCtx.sessionManager.getOrCreate(
1598
+ personalSessionId,
1599
+ "personal",
1600
+ userId,
1601
+ );
1602
+
1603
+ const quotedInfo = await getQuotedContent(e, ctx);
1604
+ const imageUrls: string[] = [];
1605
+
1606
+ if (e.message) {
1607
+ for (const seg of e.message) {
1608
+ if (seg.type === "image" && (seg.url || seg.data?.url))
1609
+ imageUrls.push(seg.url || seg.data.url);
1610
+ }
1611
+ }
1612
+ if (quotedInfo?.imageUrl) imageUrls.push(quotedInfo.imageUrl);
1613
+
1614
+ let messageContent = ctx.text(e) || "";
1615
+ let extraContext = "";
1616
+ if (quotedInfo) {
1617
+ const parts = [
1618
+ `[Quoted message #${quotedInfo.messageId} from ${quotedInfo.senderName}: ${quotedInfo.content}]`,
1619
+ ];
1620
+ if (quotedInfo.imageUrl) parts.push("[Quoted message contains an image]");
1621
+ extraContext = parts.join(" ");
1622
+ }
1623
+ if (extraContext) messageContent = extraContext + " " + messageContent;
1624
+
1625
+ const userMsg = {
1626
+ sessionId: groupSessionId,
1627
+ role: "user" as const,
1628
+ content: messageContent,
1629
+ userId,
1630
+ userName: e.sender?.card || e.sender?.nickname || String(userId),
1631
+ userRole: e.sender?.role || "member",
1632
+ userTitle: (e.sender as any)?.title || undefined,
1633
+ groupId,
1634
+ groupName: isGroup ? e.group_name : undefined,
1635
+ timestamp: Date.now(),
1636
+ messageId: e.message_id,
1637
+ };
1638
+ pluginCtx.db.saveMessage(userMsg);
1639
+
1640
+ pluginCtx.humanize.expressionLearner.onMessage(userMsg).then();
1641
+ pluginCtx.humanize.topicTracker.onMessage(groupSessionId).then();
1642
+
1643
+ const rawHistory = groupId
1644
+ ? await getGroupHistory(
1645
+ groupId,
1646
+ ctx,
1647
+ cfg.historyCount,
1648
+ e.self_id,
1649
+ pluginCtx.db,
1650
+ pluginCtx.buildHistoryMediaOptions(pluginCtx.aiInstance, cfg),
1651
+ )
1652
+ : [];
1653
+ const history: ChatMessage[] = rawHistory.map((msg: any) => ({
1654
+ sessionId: groupSessionId,
1655
+ role: "user" as const,
1656
+ content: msg.content,
1657
+ userId: msg.userId,
1658
+ userName: msg.userName,
1659
+ userRole: msg.userRole,
1660
+ groupId,
1661
+ timestamp: msg.timestamp,
1662
+ messageId: msg.messageId,
1663
+ }));
1664
+
1665
+ const botNickname =
1666
+ cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
1667
+ const botRole = groupId
1668
+ ? await getBotRole(groupId, ctx, e.self_id)
1669
+ : "member";
1670
+ let groupName: string | undefined;
1671
+ let memberCount: number | undefined;
1672
+ if (groupId) {
1673
+ const groupInfo = await pluginCtx.getGroupInfoData(
1674
+ ctx,
1675
+ groupId,
1676
+ e.self_id,
1677
+ e.group_name,
1678
+ );
1679
+ groupName = groupInfo.groupName;
1680
+ memberCount = groupInfo.memberCount;
1681
+ }
1682
+
1683
+ const senderName = e.sender?.card || e.sender?.nickname || String(userId);
1684
+ const contexts = await pluginCtx.getHumanizeContexts(
1685
+ pluginCtx.humanize,
1686
+ groupSessionId,
1687
+ senderName,
1688
+ history,
1689
+ userId,
1690
+ );
1691
+
1692
+ const targetMessage: TargetMessage = {
1693
+ userName: senderName,
1694
+ userId,
1695
+ userRole: e.sender?.role || "member",
1696
+ userTitle: (e.sender as any)?.title || undefined,
1697
+ content: messageContent,
1698
+ messageId: e.message_id,
1699
+ timestamp: Date.now(),
1700
+ };
1701
+
1702
+ if (groupId)
1703
+ pluginCtx.queueManager.setActiveTarget(groupSessionId, targetMessage);
1704
+
1705
+ const toolCtx = pluginCtx.buildToolContext({
1706
+ ctx,
1707
+ event: e,
1708
+ groupSessionId,
1709
+ groupId,
1710
+ userId,
1711
+ config: cfg,
1712
+ aiService: pluginCtx.aiService,
1713
+ db: pluginCtx.db,
1714
+ botRole,
1715
+ pendingImageUrls: imageUrls,
1716
+ humanize: pluginCtx.humanize,
1717
+ targetMessage,
1718
+ selfId: e.self_id,
1719
+ });
1720
+
1721
+ const result = await pluginCtx.runWithRateLimitGuard(
1722
+ () =>
1723
+ pluginCtx.runChat(
1724
+ pluginCtx.aiInstance,
1725
+ toolCtx,
1726
+ history,
1727
+ targetMessage,
1728
+ {
1729
+ config: cfg,
1730
+ groupName,
1731
+ memberCount,
1732
+ botNickname,
1733
+ botRole,
1734
+ aiService: pluginCtx.aiService,
1735
+ isGroup,
1736
+ memoryContext: contexts.memoryContext,
1737
+ topicContext: contexts.topicContext,
1738
+ expressionContext: contexts.expressionContext,
1739
+ replyContext: {
1740
+ type: "reply",
1741
+ targetUser: targetMessage.userName,
1742
+ targetMessage: targetMessage.content,
1743
+ },
1744
+ },
1745
+ pluginCtx.humanize,
1746
+ pluginCtx.skillManager,
1747
+ groupId
1748
+ ? {
1749
+ manager: pluginCtx.groupStructuredHistory,
1750
+ ttlMs: cfg.groupStructuredHistoryTtlMs,
1751
+ currentUserInputs: [
1752
+ pluginCtx.buildStructuredUserInputFromTarget(targetMessage),
1753
+ ],
1754
+ }
1755
+ : undefined,
1756
+ ),
1757
+ { userId, groupId, label: isGroup ? "group-chat" : "private-chat" },
1758
+ );
1759
+
1760
+ if (!result) {
1761
+ if (groupId) pluginCtx.queueManager.clearActiveTarget(groupSessionId);
1762
+ return;
1763
+ }
1764
+
1765
+ if (groupId) {
1766
+ await pluginCtx.sendAIResponse(
1767
+ {
1768
+ ctx,
1769
+ groupId,
1770
+ messages: result.messages,
1771
+ config: cfg,
1772
+ sentIndices: toolCtx.sentMessageIndices,
1773
+ },
1774
+ e.self_id,
1775
+ );
1776
+ await pluginCtx.sendEmoji(ctx, groupId, result.emojiPath, e.self_id);
1777
+
1778
+ const now = Date.now();
1779
+ pluginCtx.saveBotMessages(
1780
+ groupId,
1781
+ groupSessionId,
1782
+ result.messages,
1783
+ now,
1784
+ cfg,
1785
+ pluginCtx.db,
1786
+ ctx,
1787
+ e.self_id,
1788
+ );
1789
+ pluginCtx.idleCheckManager.recordBotMessages(
1790
+ groupSessionId,
1791
+ result.messages.length,
1792
+ e.self_id,
1793
+ );
1794
+
1795
+ pluginCtx.cooldownManager.startCooldownTimer(
1796
+ groupSessionId,
1797
+ groupId,
1798
+ e.self_id,
1799
+ );
1800
+ } else {
1801
+ const sentIndices = toolCtx.sentMessageIndices;
1802
+ for (let i = 0; i < result.messages.length; i++) {
1803
+ if (sentIndices?.has(i)) continue;
1804
+ await pluginCtx.sendMessage(
1805
+ ctx,
1806
+ undefined,
1807
+ userId,
1808
+ result.messages[i],
1809
+ cfg,
1810
+ e.self_id,
1811
+ );
1812
+ }
1813
+ if (result.emojiPath) {
1814
+ try {
1815
+ const emojiSegment = ctx.segment.image(`file://${result.emojiPath}`);
1816
+ await e.reply([emojiSegment]);
1817
+ } catch (err) {
1818
+ ctx.logger.warn(`[Emoticon] Send failed: ${err}`);
1819
+ }
1820
+ }
1821
+ }
1822
+
1823
+ pluginCtx.sessionManager.touch(groupSessionId);
1824
+ } catch (err) {
1825
+ ctx.logger.error(`Chat processing failed: ${err}`);
1826
+ if (groupId) pluginCtx.queueManager.clearActiveTarget(groupSessionId);
1827
+ }
1828
+ }
1829
+
1830
+ export default chatPlugin;