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