mioku-plugin-chat 2.3.1 → 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.
@@ -0,0 +1,316 @@
1
+ import type { ChatPluginContext, ChatHandlerState } from "../context";
2
+ import {
3
+ isGroupAllowed,
4
+ shouldTrigger,
5
+ isQuotingBot,
6
+ buildChatMessageFromEvent,
7
+ } from "../utils";
8
+ import {
9
+ buildHistoryMediaProcessingOptions,
10
+ getCardData,
11
+ getForwardId,
12
+ getSegmentUrl,
13
+ getVideoSourceCandidatesFromMessage,
14
+ isMediaAnalysisBlocked,
15
+ } from "../core/media/segment";
16
+ import {
17
+ getSegmentSourceCandidates,
18
+ summarizeGroupNotice,
19
+ summarizeHistoryCard,
20
+ summarizeHistoryForward,
21
+ summarizeHistoryVideo,
22
+ } from "../core/media/history-media";
23
+ import { handleIdleCheckDebug } from "./idle-debug";
24
+ import { processChat } from "../core/chat-turn";
25
+
26
+ const POKE_COOLDOWN_MS = 10 * 60_000;
27
+
28
+ export function createMessageHandler(
29
+ pluginCtx: ChatPluginContext,
30
+ state: ChatHandlerState,
31
+ ) {
32
+ const { ctx } = pluginCtx;
33
+ const { getConfig, matchMessageCommands, runtimeState } = state;
34
+
35
+ return async (e: any) => {
36
+ const cfg = await getConfig();
37
+ if (!cfg.apiKey) return;
38
+ if (!e?.message || !Array.isArray(e.message)) return;
39
+
40
+ const text = ctx.text(e) || "";
41
+ const isGroup = e.message_type === "group";
42
+ const groupId: number | undefined = isGroup ? e.group_id : undefined;
43
+ const userId: number = e.user_id || e.sender?.user_id;
44
+
45
+ if (userId === e.self_id) return;
46
+
47
+ if (matchMessageCommands && matchMessageCommands(text).length > 0) return;
48
+
49
+ if (text.startsWith("/空闲检查 ")) {
50
+ await handleIdleCheckDebug(pluginCtx, e, cfg);
51
+ return;
52
+ }
53
+
54
+ if (text === "/重置会话") {
55
+ if (groupId) {
56
+ pluginCtx.sessionManager.resetBotMessages(`group:${groupId}`);
57
+ pluginCtx.groupStructuredHistory.clear(`group:${groupId}`);
58
+ await e.reply("已清除本群会话中 AI 发送的消息~");
59
+ } else {
60
+ pluginCtx.sessionManager.resetBotMessages(`personal:${userId}`);
61
+ pluginCtx.groupStructuredHistory.clear(`personal:${userId}`);
62
+ await e.reply("已清除你的个人会话中 AI 发送的消息~");
63
+ }
64
+ return;
65
+ }
66
+
67
+ if (groupId && !isGroupAllowed(groupId, cfg)) return;
68
+
69
+ // 媒体分析
70
+ if (isGroup && groupId && e.message && !isMediaAnalysisBlocked(cfg, userId)) {
71
+ const ai = pluginCtx.aiService.getDefault();
72
+ const bot = ctx.pickBot(e.self_id) as any;
73
+ const mediaOptions = ai
74
+ ? buildHistoryMediaProcessingOptions(
75
+ ai,
76
+ cfg,
77
+ pluginCtx.db,
78
+ bot,
79
+ groupId,
80
+ {
81
+ info: (m) => ctx.logger.info(m),
82
+ warn: (m) => ctx.logger.warn(m),
83
+ error: (m) => ctx.logger.error(m),
84
+ },
85
+ (request) =>
86
+ pluginCtx.runWithRateLimitGuard(request, {
87
+ userId,
88
+ groupId,
89
+ label: "history-media",
90
+ }),
91
+ )
92
+ : undefined;
93
+
94
+ if (ai && cfg.enableMediaRecognition) {
95
+ const { processImage } = await import("../core/media/image-analyzer");
96
+ for (const seg of e.message) {
97
+ if (seg.type === "image") {
98
+ const imageUrl = getSegmentUrl(seg);
99
+ if (imageUrl) {
100
+ processImage(ai, imageUrl, cfg.multimodalWorkingModel, pluginCtx.db, {
101
+ runAIRequest: (request) =>
102
+ pluginCtx.runWithRateLimitGuard(request, {
103
+ userId,
104
+ groupId,
105
+ label: "image-analysis",
106
+ }),
107
+ }).catch((err) =>
108
+ ctx.logger.error(`[image-analyzer] Failed: ${err}`),
109
+ );
110
+ }
111
+ } else if (seg.type === "video" && mediaOptions) {
112
+ const videoSources = [
113
+ ...getSegmentSourceCandidates(seg),
114
+ ...(await getVideoSourceCandidatesFromMessage(
115
+ bot,
116
+ e.message_id,
117
+ ).catch(() => [])),
118
+ ];
119
+ if (videoSources.length > 0) {
120
+ summarizeHistoryVideo(videoSources, mediaOptions).catch((err) =>
121
+ ctx.logger.error(
122
+ `[history-media] Failed to process video: ${err}`,
123
+ ),
124
+ );
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ if (mediaOptions) {
131
+ for (const seg of e.message) {
132
+ if (seg.type === "forward") {
133
+ const forwardId = getForwardId(seg);
134
+ if (forwardId) {
135
+ summarizeHistoryForward(forwardId, mediaOptions).catch((err) =>
136
+ ctx.logger.error(
137
+ `[history-media] Failed to process forward: ${err}`,
138
+ ),
139
+ );
140
+ }
141
+ } else if (["xml", "json", "lightapp", "ark"].includes(seg.type)) {
142
+ const cardData = getCardData(seg);
143
+ if (cardData) {
144
+ summarizeHistoryCard(cardData, mediaOptions).catch((err) =>
145
+ ctx.logger.error(
146
+ `[history-media] Failed to process card: ${err}`,
147
+ ),
148
+ );
149
+ }
150
+ }
151
+ }
152
+ if (e.sub_type === "notice") {
153
+ summarizeGroupNotice(e, mediaOptions)
154
+ .then((noticeMessage) => {
155
+ if (!noticeMessage) return;
156
+ pluginCtx.db.saveMessage({
157
+ sessionId: `group:${groupId}`,
158
+ role: "user",
159
+ content: noticeMessage.content,
160
+ userId: noticeMessage.userId,
161
+ userName: noticeMessage.userName,
162
+ userRole: noticeMessage.userRole,
163
+ groupId,
164
+ groupName: e.group_name,
165
+ timestamp: noticeMessage.timestamp,
166
+ messageId: noticeMessage.messageId,
167
+ });
168
+ })
169
+ .catch((err) =>
170
+ ctx.logger.error(
171
+ `[history-media] Failed to process group notice: ${err}`,
172
+ ),
173
+ );
174
+ }
175
+ }
176
+ }
177
+
178
+ if (isGroup && groupId) {
179
+ const learnSessionId = `group:${groupId}`;
180
+ const learnMsg = buildChatMessageFromEvent(e, text, true, groupId);
181
+ const hasText = !!learnMsg.content?.trim();
182
+ const hasMultimodal =
183
+ !!cfg.isMultimodal && !!cfg.multimodalWorkingModel;
184
+ if (hasText || hasMultimodal) {
185
+ pluginCtx
186
+ .recordGroupMessageForLearning(learnMsg, learnSessionId)
187
+ .catch((err) =>
188
+ ctx.logger.error(`[learning] always-on record failed: ${err}`),
189
+ );
190
+ } else {
191
+ pluginCtx.humanize.topicTracker
192
+ .onMessage(learnSessionId)
193
+ .catch((err) =>
194
+ ctx.logger.error(`[topic] window advance failed: ${err}`),
195
+ );
196
+ }
197
+ }
198
+
199
+ const atBot = shouldTrigger(e, text, cfg, ctx);
200
+ const quotedBot = isGroup ? await isQuotingBot(e, ctx) : null;
201
+ const mentionedNickname =
202
+ cfg.nicknames.length > 0 &&
203
+ cfg.nicknames.some((n) => text.toLowerCase().includes(n.toLowerCase()));
204
+
205
+ const groupSessionId = groupId ? `group:${groupId}` : undefined;
206
+
207
+ // 记录群活动
208
+ if (isGroup && groupId && groupSessionId) {
209
+ pluginCtx.idleCheckManager.recordActivity(groupSessionId);
210
+
211
+ if (pluginCtx.cooldownManager.isInCooldown(groupSessionId)) {
212
+ pluginCtx.cooldownManager.collectMessage(
213
+ groupSessionId,
214
+ groupId,
215
+ e,
216
+ text,
217
+ atBot,
218
+ );
219
+ return;
220
+ }
221
+
222
+ if (pluginCtx.queueProcessor.isInDynamicDelay(groupSessionId)) {
223
+ if (atBot && !runtimeState.isRateLimitBlocked()) {
224
+ pluginCtx.rateLimiter.recordInteraction(groupId, userId);
225
+ pluginCtx.queueProcessor.collectDynamicDelayMessage(groupSessionId, e, text);
226
+ }
227
+ return;
228
+ }
229
+ }
230
+
231
+ // 检查是否在处理中
232
+ const processingSet = runtimeState.processingSet;
233
+ if (isGroup && groupId && groupSessionId) {
234
+ if (processingSet.has(groupSessionId)) {
235
+ if ((atBot || mentionedNickname) && !runtimeState.isRateLimitBlocked()) {
236
+ pluginCtx.queueManager.enqueue(groupSessionId, e, cfg);
237
+ pluginCtx.rateLimiter.recordInteraction(groupId, userId);
238
+ }
239
+ return;
240
+ }
241
+ processingSet.add(groupSessionId);
242
+ } else {
243
+ const triggerKey = `personal:${userId}`;
244
+ if (processingSet.has(triggerKey)) return;
245
+ processingSet.add(triggerKey);
246
+ }
247
+
248
+ try {
249
+ if (atBot) {
250
+ if (!pluginCtx.rateLimiter.canProcess(userId, groupId, text)) return;
251
+
252
+ if (isGroup && groupId && groupSessionId && cfg.dynamicDelay?.enabled) {
253
+ pluginCtx.rateLimiter.recordInteraction(groupId, userId);
254
+ const delayInfo = pluginCtx.rateLimiter.getDelayInfo(groupId);
255
+ if (delayInfo.shouldDelay) {
256
+ pluginCtx.rateLimiter.record(userId, groupId, text);
257
+ pluginCtx.queueProcessor.collectDynamicDelayMessage(
258
+ groupSessionId,
259
+ e,
260
+ text,
261
+ );
262
+ pluginCtx.queueProcessor.startDynamicDelayTimer(
263
+ groupSessionId,
264
+ groupId,
265
+ delayInfo.delayMs,
266
+ e.self_id,
267
+ );
268
+ return;
269
+ }
270
+ }
271
+
272
+ pluginCtx.rateLimiter.record(userId, groupId, text);
273
+ await processChat(e, pluginCtx, runtimeState);
274
+ return;
275
+ }
276
+
277
+ if (quotedBot || mentionedNickname) {
278
+ const { history } = await pluginCtx.getGroupHistoryMessages(
279
+ groupId!,
280
+ groupSessionId!,
281
+ ctx,
282
+ cfg.historyCount,
283
+ pluginCtx.db,
284
+ e.self_id,
285
+ pluginCtx.buildHistoryMediaOptions(pluginCtx.aiInstance, cfg),
286
+ );
287
+ const botNickname =
288
+ cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
289
+ const planResult = await pluginCtx.humanize.actionPlanner.plan(
290
+ groupSessionId!,
291
+ botNickname,
292
+ history,
293
+ text,
294
+ );
295
+ if (planResult.action !== "reply") return;
296
+ if (!pluginCtx.rateLimiter.canProcess(userId, groupId, text)) return;
297
+ pluginCtx.rateLimiter.record(userId, groupId, text);
298
+ await processChat(e, pluginCtx, runtimeState);
299
+ return;
300
+ }
301
+ } finally {
302
+ if (isGroup && groupId && groupSessionId) {
303
+ processingSet.delete(groupSessionId);
304
+ await pluginCtx.queueProcessor.processQueuedMessages(
305
+ groupSessionId,
306
+ e.self_id,
307
+ );
308
+ } else {
309
+ processingSet.delete(`personal:${userId}`);
310
+ }
311
+ }
312
+ };
313
+ }
314
+
315
+ // re-exported so poke handler can share the constant if needed
316
+ export { POKE_COOLDOWN_MS };
@@ -0,0 +1,165 @@
1
+ import type { ChatPluginContext, ChatHandlerState } from "../context";
2
+ import type { TargetMessage } from "../types";
3
+ import { getBotRole, isGroupAllowed } from "../utils";
4
+ import { buildStructuredUserInputFromTarget } from "../manage/group-structured-history";
5
+ import { POKE_COOLDOWN_MS } from "./message";
6
+
7
+ export function createPokeHandler(
8
+ pluginCtx: ChatPluginContext,
9
+ state: ChatHandlerState,
10
+ ) {
11
+ const { ctx } = pluginCtx;
12
+ const { getConfig, runtimeState, pokeCooldowns } = state;
13
+
14
+ return async (e: any) => {
15
+ if (e.target_id !== e.self_id) return;
16
+ const cfg = await getConfig();
17
+ if (!cfg.apiKey) return;
18
+ const groupId = e.group_id;
19
+ if (!groupId || !isGroupAllowed(groupId, cfg)) return;
20
+
21
+ const lastPoke = pokeCooldowns.get(groupId) ?? 0;
22
+ if (Date.now() - lastPoke < POKE_COOLDOWN_MS) return;
23
+ pokeCooldowns.set(groupId, Date.now());
24
+
25
+ const groupSessionId = `group:${groupId}`;
26
+ if (runtimeState.isRateLimitBlocked()) return;
27
+
28
+ const processingSet = runtimeState.processingSet;
29
+ if (processingSet.has(groupSessionId)) {
30
+ if (!runtimeState.isRateLimitBlocked()) {
31
+ pluginCtx.queueManager.enqueue(groupSessionId, e, cfg);
32
+ }
33
+ return;
34
+ }
35
+
36
+ processingSet.add(groupSessionId);
37
+ pluginCtx.sessionManager.getOrCreate(groupSessionId, "group", groupId);
38
+
39
+ try {
40
+ const userId = e.user_id || e.operator_id;
41
+ const botRole = await getBotRole(groupId, ctx, e.self_id);
42
+ const botNickname =
43
+ cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
44
+
45
+ let senderName = String(userId);
46
+ try {
47
+ const memberInfo = await ctx
48
+ .pickBot(e.self_id)
49
+ .getGroupMemberInfo(groupId, userId);
50
+ senderName =
51
+ (memberInfo as any).card ||
52
+ (memberInfo as any).nickname ||
53
+ String(userId);
54
+ } catch {}
55
+
56
+ const targetMessage: TargetMessage = {
57
+ userName: senderName,
58
+ userId,
59
+ userRole: "member",
60
+ content: `[${senderName} poked you]`,
61
+ timestamp: Date.now(),
62
+ };
63
+
64
+ const { history } = await pluginCtx.getGroupHistoryMessages(
65
+ groupId,
66
+ groupSessionId,
67
+ ctx,
68
+ cfg.historyCount,
69
+ pluginCtx.db,
70
+ e.self_id,
71
+ pluginCtx.buildHistoryMediaOptions(pluginCtx.aiInstance, cfg),
72
+ );
73
+ const { groupName, memberCount } = await pluginCtx.getGroupInfoData(
74
+ ctx,
75
+ groupId,
76
+ e.self_id,
77
+ );
78
+
79
+ const toolCtx = pluginCtx.buildToolContext({
80
+ ctx,
81
+ event: e,
82
+ groupSessionId,
83
+ groupId,
84
+ userId,
85
+ config: cfg,
86
+ aiService: pluginCtx.aiService,
87
+ db: pluginCtx.db,
88
+ botRole,
89
+ humanize: pluginCtx.humanize,
90
+ targetMessage,
91
+ selfId: e.self_id,
92
+ });
93
+
94
+ const result = await pluginCtx.runWithRateLimitGuard(
95
+ () =>
96
+ pluginCtx.runChat(
97
+ pluginCtx.aiInstance,
98
+ toolCtx,
99
+ history,
100
+ targetMessage,
101
+ {
102
+ config: cfg,
103
+ groupName,
104
+ memberCount,
105
+ botNickname,
106
+ botRole,
107
+ aiService: pluginCtx.aiService,
108
+ isGroup: true,
109
+ replyContext: {
110
+ type: "poked",
111
+ targetUser: targetMessage.userName,
112
+ targetMessage: targetMessage.content,
113
+ },
114
+ },
115
+ pluginCtx.humanize,
116
+ pluginCtx.skillManager,
117
+ {
118
+ manager: pluginCtx.groupStructuredHistory,
119
+ ttlMs: cfg.groupStructuredHistoryTtlMs,
120
+ currentUserInputs: [
121
+ buildStructuredUserInputFromTarget(targetMessage),
122
+ ],
123
+ },
124
+ ),
125
+ { userId, groupId, label: "poke" },
126
+ );
127
+ if (!result) return;
128
+
129
+ await pluginCtx.sendAIResponse(
130
+ {
131
+ ctx,
132
+ groupId,
133
+ messages: result.messages,
134
+ config: cfg,
135
+ sentIndices: toolCtx.sentMessageIndices,
136
+ },
137
+ e.self_id,
138
+ );
139
+
140
+ const now = Date.now();
141
+ pluginCtx.saveBotMessages(
142
+ groupId,
143
+ groupSessionId,
144
+ result.messages,
145
+ now,
146
+ cfg,
147
+ pluginCtx.db,
148
+ ctx,
149
+ e.self_id,
150
+ );
151
+ pluginCtx.idleCheckManager.recordBotMessages(
152
+ groupSessionId,
153
+ result.messages.length,
154
+ e.self_id,
155
+ );
156
+
157
+ await pluginCtx.sendEmoji(ctx, groupId, result.emojiPath, e.self_id);
158
+ pluginCtx.sessionManager.touch(groupSessionId);
159
+ } catch (err) {
160
+ ctx.logger.error(`Poke processing failed: ${err}`);
161
+ } finally {
162
+ processingSet.delete(groupSessionId);
163
+ }
164
+ };
165
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AIInstance } from "mioku";
2
2
  import { logger } from "mioki";
3
+ import { extractJsonObject } from "../utils/json";
3
4
  import type { ChatConfig, ChatMessage, TargetMessage } from "../types";
4
5
 
5
6
  export interface EmotionState {
@@ -132,10 +133,8 @@ ${input.targetMessage.userName}: ${input.targetMessage.content}`;
132
133
  });
133
134
 
134
135
  const content = response.content || "";
135
- const jsonMatch = content.match(/\{[\s\S]*\}/);
136
- if (!jsonMatch) return this.getDefaultEmotion();
137
-
138
- const parsed = JSON.parse(jsonMatch[0]);
136
+ const parsed = extractJsonObject(content);
137
+ if (parsed === undefined) return this.getDefaultEmotion();
139
138
  return this.resolveEmotion(parsed?.emotion);
140
139
  }
141
140
 
@@ -1,5 +1,6 @@
1
1
  import type { AIInstance } from "mioku";
2
2
  import { logger } from "mioki";
3
+ import { extractJsonObject } from "../utils/json";
3
4
  import type { ChatDatabase } from "../db";
4
5
  import type { ChatConfig, ChatMessage } from "../types";
5
6
 
@@ -132,10 +133,8 @@ If nothing reliable can be extracted, keep stable previous habits when possible.
132
133
  max_tokens: 600,
133
134
  });
134
135
 
135
- const jsonMatch = content.match(/\{[\s\S]*\}/);
136
- if (!jsonMatch) return;
137
-
138
- const parsed = JSON.parse(jsonMatch[0]);
136
+ const parsed = extractJsonObject(content);
137
+ if (parsed === undefined) return;
139
138
  if (!parsed.expressions || !Array.isArray(parsed.expressions)) return;
140
139
 
141
140
  const normalized = parsed.expressions
package/humanize/topic.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AIInstance } from "mioku";
2
2
  import { logger } from "mioki";
3
+ import { extractJsonObject } from "../utils/json";
3
4
  import type { ChatDatabase } from "../db";
4
5
  import type { ChatConfig } from "../types";
5
6
 
@@ -123,10 +124,8 @@ Output strictly in JSON format:
123
124
  max_tokens: 300,
124
125
  });
125
126
 
126
- const jsonMatch = content.match(/\{[\s\S]*\}/);
127
- if (!jsonMatch) return;
128
-
129
- const parsed = JSON.parse(jsonMatch[0]);
127
+ const parsed = extractJsonObject(content);
128
+ if (parsed === undefined) return;
130
129
  const summary = String(parsed.summary || "").trim();
131
130
  if (!summary) return;
132
131