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.
@@ -0,0 +1,445 @@
1
+ import type { MiokiContext } from "mioki";
2
+ import type { AIInstance } from "mioku";
3
+ import type { ChatConfig, TargetMessage } from "../types";
4
+ import type { ChatDatabase } from "../db";
5
+ import type { HumanizeEngine } from "../humanize";
6
+ import type { SkillSessionManager } from "./skill-session";
7
+ import type { GroupStructuredHistoryManager } from "./group-structured-history";
8
+ import type { SessionManager } from "./session";
9
+ import type { RateLimiter } from "./rate-limiter";
10
+ import type { IdleCheckManager } from "./idle-check";
11
+ import type {
12
+ RunRateLimitGuard,
13
+ HistoryMediaOptions,
14
+ GetGroupHistoryMessages,
15
+ GetGroupInfoData,
16
+ GetHumanizeContexts,
17
+ SendAIResponse,
18
+ SaveBotMessages,
19
+ SendEmoji,
20
+ RunChat,
21
+ BuildToolContext,
22
+ BuildStructuredUserInput,
23
+ } from "./types";
24
+ import { getBotRole } from "../utils";
25
+
26
+ interface CooldownMessage {
27
+ event: any;
28
+ content: string;
29
+ userName: string;
30
+ userId: number;
31
+ messageId: number;
32
+ timestamp: number;
33
+ isDirectAt: boolean;
34
+ }
35
+
36
+ export class CooldownManager {
37
+ private groupCooldownUntil = new Map<string, number>();
38
+ private groupCooldownMessages = new Map<string, CooldownMessage[]>();
39
+ private cooldownTimeoutIds = new Map<string, NodeJS.Timeout>();
40
+
41
+ constructor(
42
+ private ctx: MiokiContext,
43
+ private cfg: ChatConfig,
44
+ private db: ChatDatabase,
45
+ private humanize: HumanizeEngine,
46
+ private aiInstance: AIInstance,
47
+ private aiService: import("mioku").AIService,
48
+ private skillManager: SkillSessionManager,
49
+ private groupStructuredHistory: GroupStructuredHistoryManager,
50
+ private sessionManager: SessionManager,
51
+ private rateLimiter: RateLimiter,
52
+ private idleCheckManager: IdleCheckManager,
53
+ private runWithRateLimitGuard: RunRateLimitGuard,
54
+ private buildHistoryMediaOptions: (
55
+ ai: AIInstance,
56
+ cfg: ChatConfig,
57
+ ) => HistoryMediaOptions,
58
+ private getGroupHistoryMessages: GetGroupHistoryMessages,
59
+ private getGroupInfoData: GetGroupInfoData,
60
+ private getHumanizeContexts: GetHumanizeContexts,
61
+ private sendAIResponse: SendAIResponse,
62
+ private saveBotMessages: SaveBotMessages,
63
+ private sendEmoji: SendEmoji,
64
+ private runChat: RunChat,
65
+ private buildToolContext: BuildToolContext,
66
+ private buildStructuredUserInputFromEvent: BuildStructuredUserInput,
67
+ ) {}
68
+
69
+ startCooldownTimer(
70
+ groupSessionId: string,
71
+ groupId: number,
72
+ selfId: number,
73
+ ): void {
74
+ const existingTimer = this.cooldownTimeoutIds.get(groupSessionId);
75
+ if (existingTimer) clearTimeout(existingTimer);
76
+
77
+ const cooldownMs = this.cfg.cooldownAfterReplyMs ?? 20_000;
78
+
79
+ const timer = setTimeout(async () => {
80
+ this.cooldownTimeoutIds.delete(groupSessionId);
81
+ const collected = this.groupCooldownMessages.get(groupSessionId) || [];
82
+ if (collected.length === 0) {
83
+ this.groupCooldownMessages.delete(groupSessionId);
84
+ this.groupCooldownUntil.delete(groupSessionId);
85
+ return;
86
+ }
87
+
88
+ const directAtMessages = collected.filter((m) => m.isDirectAt);
89
+ try {
90
+ if (directAtMessages.length > 0) {
91
+ await this.processReviewMessages(
92
+ groupSessionId,
93
+ groupId,
94
+ collected,
95
+ selfId,
96
+ );
97
+ } else {
98
+ await this.processCooldownWithPlanner(
99
+ groupSessionId,
100
+ groupId,
101
+ collected,
102
+ selfId,
103
+ );
104
+ }
105
+ } catch (err) {
106
+ this.ctx.logger.error(
107
+ `[Cooldown] Group ${groupId} processing failed: ${err}`,
108
+ );
109
+ } finally {
110
+ this.groupCooldownMessages.delete(groupSessionId);
111
+ this.groupCooldownUntil.delete(groupSessionId);
112
+ }
113
+ }, cooldownMs);
114
+
115
+ this.cooldownTimeoutIds.set(groupSessionId, timer);
116
+ this.groupCooldownUntil.set(groupSessionId, Date.now() + cooldownMs);
117
+ this.groupCooldownMessages.set(groupSessionId, []);
118
+ }
119
+
120
+ collectMessage(
121
+ groupSessionId: string,
122
+ groupId: number,
123
+ event: any,
124
+ content: string,
125
+ isDirectAt: boolean,
126
+ ): void {
127
+ const userName =
128
+ event.sender?.card || event.sender?.nickname || String(event.user_id);
129
+ const messages = this.groupCooldownMessages.get(groupSessionId) || [];
130
+ messages.push({
131
+ event,
132
+ content,
133
+ userName,
134
+ userId: event.user_id,
135
+ messageId: event.message_id,
136
+ timestamp: Date.now(),
137
+ isDirectAt,
138
+ });
139
+ this.groupCooldownMessages.set(groupSessionId, messages);
140
+ }
141
+
142
+ isInCooldown(groupSessionId: string): boolean {
143
+ return Date.now() < (this.groupCooldownUntil.get(groupSessionId) ?? 0);
144
+ }
145
+
146
+ dispose(): void {
147
+ for (const timer of this.cooldownTimeoutIds.values()) clearTimeout(timer);
148
+ this.cooldownTimeoutIds.clear();
149
+ this.groupCooldownUntil.clear();
150
+ this.groupCooldownMessages.clear();
151
+ }
152
+
153
+ private async processReviewMessages(
154
+ groupSessionId: string,
155
+ groupId: number,
156
+ collected: CooldownMessage[],
157
+ selfId: number,
158
+ ): Promise<void> {
159
+ const mergedContents = collected.map((m) => m.content);
160
+ const userNames = collected.map((m) => m.userName);
161
+ const messageIds = collected.map((m) => m.messageId);
162
+ const mergedContent = mergedContents.join("\n---\n");
163
+ const firstMsg = collected[0];
164
+
165
+ const targetMessage: TargetMessage = {
166
+ userName: userNames.join(", "),
167
+ userId: firstMsg.userId,
168
+ userRole: firstMsg.event.sender?.role || "member",
169
+ content: mergedContent,
170
+ messageId: firstMsg.messageId,
171
+ timestamp: Date.now(),
172
+ };
173
+
174
+ const { history } = await this.getGroupHistoryMessages(
175
+ groupId,
176
+ groupSessionId,
177
+ this.ctx,
178
+ this.cfg.historyCount,
179
+ this.db,
180
+ selfId,
181
+ this.buildHistoryMediaOptions(this.aiInstance, this.cfg),
182
+ );
183
+ const botNickname =
184
+ this.cfg.nicknames[0] || this.ctx.pickBot(selfId).nickname || "Bot";
185
+ const botRole = await getBotRole(groupId, this.ctx, selfId);
186
+ const { groupName, memberCount } = await this.getGroupInfoData(
187
+ this.ctx,
188
+ groupId,
189
+ selfId,
190
+ );
191
+
192
+ const toolCtx = this.buildToolContext({
193
+ ctx: this.ctx,
194
+ event: firstMsg.event,
195
+ groupSessionId,
196
+ groupId,
197
+ userId: targetMessage.userId,
198
+ config: this.cfg,
199
+ aiService: this.aiService,
200
+ db: this.db,
201
+ botRole,
202
+ humanize: this.humanize,
203
+ targetMessage,
204
+ selfId,
205
+ });
206
+
207
+ const contexts = await this.getHumanizeContexts(
208
+ this.humanize,
209
+ groupSessionId,
210
+ targetMessage.userName,
211
+ history,
212
+ targetMessage.userId,
213
+ );
214
+
215
+ const result = await this.runWithRateLimitGuard(
216
+ () =>
217
+ this.runChat(
218
+ this.aiInstance,
219
+ toolCtx,
220
+ history,
221
+ targetMessage,
222
+ {
223
+ config: this.cfg,
224
+ groupName,
225
+ memberCount,
226
+ botNickname,
227
+ botRole,
228
+ aiService: this.aiService,
229
+ isGroup: true,
230
+ memoryContext: contexts.memoryContext,
231
+ topicContext: contexts.topicContext,
232
+ expressionContext: contexts.expressionContext,
233
+ replyContext: {
234
+ type: "review",
235
+ targetUser: targetMessage.userName,
236
+ targetMessage: targetMessage.content,
237
+ },
238
+ reviewMessages: { contents: mergedContents, userNames, messageIds },
239
+ },
240
+ this.humanize,
241
+ this.skillManager,
242
+ {
243
+ manager: this.groupStructuredHistory,
244
+ ttlMs: this.cfg.groupStructuredHistoryTtlMs,
245
+ currentUserInputs: collected.map((msg) =>
246
+ this.buildStructuredUserInputFromEvent(
247
+ msg.event,
248
+ msg.content,
249
+ msg.timestamp,
250
+ ),
251
+ ),
252
+ },
253
+ ),
254
+ { userId: targetMessage.userId, groupId, label: "cooldown" },
255
+ );
256
+ if (!result) return;
257
+
258
+ await this.sendAIResponse(
259
+ {
260
+ ctx: this.ctx,
261
+ groupId,
262
+ messages: result.messages,
263
+ config: this.cfg,
264
+ sentIndices: toolCtx.sentMessageIndices,
265
+ },
266
+ selfId,
267
+ );
268
+ await this.sendEmoji(this.ctx, groupId, result.emojiPath, selfId);
269
+
270
+ const now = Date.now();
271
+ this.saveBotMessages(
272
+ groupId,
273
+ groupSessionId,
274
+ result.messages,
275
+ now,
276
+ this.cfg,
277
+ this.db,
278
+ this.ctx,
279
+ selfId,
280
+ );
281
+ this.idleCheckManager.recordBotMessages(
282
+ groupSessionId,
283
+ result.messages.length,
284
+ selfId,
285
+ );
286
+ this.sessionManager.touch(groupSessionId);
287
+ this.startCooldownTimer(groupSessionId, groupId, selfId);
288
+ }
289
+
290
+ private async processCooldownWithPlanner(
291
+ groupSessionId: string,
292
+ groupId: number,
293
+ collected: CooldownMessage[],
294
+ selfId: number,
295
+ ): Promise<void> {
296
+ const mergedContent = collected.map((m) => m.content).join("\n");
297
+ const firstMsg = collected[0];
298
+
299
+ const { history } = await this.getGroupHistoryMessages(
300
+ groupId,
301
+ groupSessionId,
302
+ this.ctx,
303
+ this.cfg.historyCount,
304
+ this.db,
305
+ selfId,
306
+ this.buildHistoryMediaOptions(this.aiInstance, this.cfg),
307
+ );
308
+ const botNickname =
309
+ this.cfg.nicknames[0] || this.ctx.pickBot(selfId).nickname || "Bot";
310
+
311
+ const planResult = await this.humanize.actionPlanner.plan(
312
+ groupSessionId,
313
+ botNickname,
314
+ history,
315
+ mergedContent,
316
+ );
317
+ if (planResult.action !== "reply") {
318
+ this.ctx.logger.info(
319
+ `[CooldownPlanner] Group ${groupId} planner decided not to reply: ${planResult.reason}`,
320
+ );
321
+ return;
322
+ }
323
+
324
+ const targetMessage: TargetMessage = {
325
+ userName: firstMsg.userName,
326
+ userId: firstMsg.userId,
327
+ userRole: firstMsg.event.sender?.role || "member",
328
+ content: mergedContent,
329
+ messageId: firstMsg.messageId,
330
+ timestamp: Date.now(),
331
+ };
332
+
333
+ const botRole = await getBotRole(groupId, this.ctx, selfId);
334
+ const toolCtx = this.buildToolContext({
335
+ ctx: this.ctx,
336
+ event: firstMsg.event,
337
+ groupSessionId,
338
+ groupId,
339
+ userId: targetMessage.userId,
340
+ config: this.cfg,
341
+ aiService: this.aiService,
342
+ db: this.db,
343
+ botRole,
344
+ humanize: this.humanize,
345
+ targetMessage,
346
+ selfId,
347
+ });
348
+ const { groupName, memberCount } = await this.getGroupInfoData(
349
+ this.ctx,
350
+ groupId,
351
+ selfId,
352
+ );
353
+ const contexts = await this.getHumanizeContexts(
354
+ this.humanize,
355
+ groupSessionId,
356
+ targetMessage.userName,
357
+ history,
358
+ targetMessage.userId,
359
+ );
360
+ const plannerThoughts = `After you spoke, the following messages were sent in the group. Use this context to respond naturally.\nPlanned reason: ${planResult.reason}`;
361
+
362
+ const result = await this.runWithRateLimitGuard(
363
+ () =>
364
+ this.runChat(
365
+ this.aiInstance,
366
+ toolCtx,
367
+ history,
368
+ targetMessage,
369
+ {
370
+ config: this.cfg,
371
+ groupName,
372
+ memberCount,
373
+ botNickname,
374
+ botRole: toolCtx.botRole,
375
+ aiService: this.aiService,
376
+ isGroup: true,
377
+ memoryContext: contexts.memoryContext,
378
+ topicContext: contexts.topicContext,
379
+ expressionContext: contexts.expressionContext,
380
+ plannerThoughts,
381
+ replyContext: {
382
+ type: "comment",
383
+ targetUser: targetMessage.userName,
384
+ targetMessage: targetMessage.content,
385
+ },
386
+ reviewMessages: {
387
+ contents: collected.map((m) => m.content),
388
+ userNames: collected.map((m) => m.userName),
389
+ messageIds: collected.map((m) => m.messageId),
390
+ },
391
+ },
392
+ this.humanize,
393
+ this.skillManager,
394
+ {
395
+ manager: this.groupStructuredHistory,
396
+ ttlMs: this.cfg.groupStructuredHistoryTtlMs,
397
+ currentUserInputs: collected.map((msg) =>
398
+ this.buildStructuredUserInputFromEvent(
399
+ msg.event,
400
+ msg.content,
401
+ msg.timestamp,
402
+ ),
403
+ ),
404
+ },
405
+ ),
406
+ { userId: targetMessage.userId, groupId, label: "cooldown-planner" },
407
+ );
408
+ if (!result) {
409
+ this.ctx.logger.warn(
410
+ `[CooldownPlanner] Group ${groupId} AI request skipped`,
411
+ );
412
+ return;
413
+ }
414
+
415
+ await this.sendAIResponse(
416
+ {
417
+ ctx: this.ctx,
418
+ groupId,
419
+ messages: result.messages,
420
+ config: this.cfg,
421
+ sentIndices: toolCtx.sentMessageIndices,
422
+ },
423
+ selfId,
424
+ );
425
+ await this.sendEmoji(this.ctx, groupId, result.emojiPath, selfId);
426
+ const now = Date.now();
427
+ this.saveBotMessages(
428
+ groupId,
429
+ groupSessionId,
430
+ result.messages,
431
+ now,
432
+ this.cfg,
433
+ this.db,
434
+ this.ctx,
435
+ selfId,
436
+ );
437
+ this.idleCheckManager.recordBotMessages(
438
+ groupSessionId,
439
+ result.messages.length,
440
+ selfId,
441
+ );
442
+ this.sessionManager.touch(groupSessionId);
443
+ this.startCooldownTimer(groupSessionId, groupId, selfId);
444
+ }
445
+ }
@@ -0,0 +1,195 @@
1
+ import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
2
+ import type { MultimodalContentItem } from "mioku";
3
+
4
+ export interface StructuredUserInput {
5
+ userName: string;
6
+ userId: number;
7
+ userRole?: string;
8
+ userTitle?: string;
9
+ content: string;
10
+ messageId?: number;
11
+ timestamp: number;
12
+ }
13
+
14
+ interface GroupStructuredHistoryState {
15
+ lastBotDirectedAt: number;
16
+ messages: ChatCompletionMessageParam[];
17
+ }
18
+
19
+ const DEFAULT_GROUP_STRUCTURED_HISTORY_TTL_MS = 10 * 60_000;
20
+
21
+ export class GroupStructuredHistoryManager {
22
+ private readonly states = new Map<string, GroupStructuredHistoryState>();
23
+
24
+ getMessages(
25
+ sessionId: string,
26
+ ttlMs?: number,
27
+ now: number = Date.now(),
28
+ ): ChatCompletionMessageParam[] {
29
+ this.clearExpired(sessionId, ttlMs, now);
30
+ const state = this.states.get(sessionId);
31
+ return state ? [...state.messages] : [];
32
+ }
33
+
34
+ touch(sessionId: string, ttlMs?: number, now: number = Date.now()): void {
35
+ this.clearExpired(sessionId, ttlMs, now);
36
+ const state = this.states.get(sessionId);
37
+ if (state) {
38
+ state.lastBotDirectedAt = now;
39
+ return;
40
+ }
41
+
42
+ this.states.set(sessionId, {
43
+ lastBotDirectedAt: now,
44
+ messages: [],
45
+ });
46
+ }
47
+
48
+ append(
49
+ sessionId: string,
50
+ messages: ChatCompletionMessageParam[],
51
+ ttlMs?: number,
52
+ now: number = Date.now(),
53
+ ): void {
54
+ this.clearExpired(sessionId, ttlMs, now);
55
+ const state = this.states.get(sessionId) ?? {
56
+ lastBotDirectedAt: now,
57
+ messages: [],
58
+ };
59
+
60
+ state.lastBotDirectedAt = now;
61
+ if (messages.length > 0) {
62
+ state.messages.push(...messages);
63
+ }
64
+ this.states.set(sessionId, state);
65
+ }
66
+
67
+ clear(sessionId: string): void {
68
+ this.states.delete(sessionId);
69
+ }
70
+
71
+ private clearExpired(
72
+ sessionId: string,
73
+ ttlMs?: number,
74
+ now: number = Date.now(),
75
+ ): void {
76
+ const state = this.states.get(sessionId);
77
+ if (!state) return;
78
+
79
+ if (
80
+ now - state.lastBotDirectedAt >
81
+ normalizeGroupStructuredHistoryTtl(ttlMs)
82
+ ) {
83
+ this.states.delete(sessionId);
84
+ }
85
+ }
86
+ }
87
+
88
+ export function buildStructuredUserMessages(
89
+ inputs: StructuredUserInput[],
90
+ ): ChatCompletionMessageParam[] {
91
+ return inputs.map((input) => ({
92
+ role: "user",
93
+ content: formatStructuredUserContent(input),
94
+ }));
95
+ }
96
+
97
+ export function attachImagesToCurrentUserMessages(
98
+ messages: ChatCompletionMessageParam[],
99
+ pendingImageUrls?: string[],
100
+ ): ChatCompletionMessageParam[] {
101
+ if (
102
+ !pendingImageUrls ||
103
+ pendingImageUrls.length === 0 ||
104
+ messages.length === 0
105
+ ) {
106
+ return messages;
107
+ }
108
+
109
+ const nextMessages = [...messages];
110
+ const lastIndex = nextMessages.length - 1;
111
+ const lastMessage = nextMessages[lastIndex];
112
+
113
+ if (lastMessage.role !== "user") {
114
+ return nextMessages;
115
+ }
116
+
117
+ const textContent =
118
+ typeof lastMessage.content === "string"
119
+ ? lastMessage.content
120
+ : extractTextFromContent(lastMessage.content);
121
+ const content: MultimodalContentItem[] = [
122
+ { type: "text", text: textContent || "[No text content]" },
123
+ ...pendingImageUrls.map((url) => ({
124
+ type: "image_url" as const,
125
+ image_url: { url },
126
+ })),
127
+ ];
128
+
129
+ nextMessages[lastIndex] = {
130
+ role: "user",
131
+ content,
132
+ } as ChatCompletionMessageParam;
133
+
134
+ return nextMessages;
135
+ }
136
+
137
+ function extractTextFromContent(
138
+ content: ChatCompletionMessageParam["content"] | null | undefined,
139
+ ): string {
140
+ if (typeof content === "string") {
141
+ return content;
142
+ }
143
+ if (!Array.isArray(content)) {
144
+ return "";
145
+ }
146
+
147
+ return content
148
+ .map((part) => {
149
+ if (!part || part.type !== "text") return "";
150
+ return part.text;
151
+ })
152
+ .filter(Boolean)
153
+ .join("\n");
154
+ }
155
+
156
+ function formatStructuredUserContent(input: StructuredUserInput): string {
157
+ const lines = [
158
+ "[Group Member Message]",
159
+ `Name: ${input.userName || "unknown"}`,
160
+ `QQ: ${input.userId || 0}`,
161
+ `Time: ${formatTimestamp(input.timestamp)}`,
162
+ ];
163
+
164
+ if (input.userRole) {
165
+ lines.push(`Role: ${input.userRole}`);
166
+ }
167
+ if (input.userTitle) {
168
+ lines.push(`Title: ${input.userTitle}`);
169
+ }
170
+ if (input.messageId != null) {
171
+ lines.push(`Message ID: ${input.messageId}`);
172
+ }
173
+
174
+ lines.push("Content:");
175
+ lines.push(input.content || "[No text content]");
176
+ return lines.join("\n");
177
+ }
178
+
179
+ function formatTimestamp(timestamp: number): string {
180
+ const date = new Date(timestamp);
181
+ const year = date.getFullYear();
182
+ const month = String(date.getMonth() + 1).padStart(2, "0");
183
+ const day = String(date.getDate()).padStart(2, "0");
184
+ const hours = String(date.getHours()).padStart(2, "0");
185
+ const minutes = String(date.getMinutes()).padStart(2, "0");
186
+ const seconds = String(date.getSeconds()).padStart(2, "0");
187
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
188
+ }
189
+
190
+ function normalizeGroupStructuredHistoryTtl(ttlMs?: number): number {
191
+ if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs <= 0) {
192
+ return DEFAULT_GROUP_STRUCTURED_HISTORY_TTL_MS;
193
+ }
194
+ return Math.floor(ttlMs);
195
+ }