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,496 @@
1
+ import { logger, MiokiContext } from "mioki";
2
+ import type { AIInstance } from "mioku";
3
+ import type { ChatConfig, ChatMessage } from "../types";
4
+ import {
5
+ getCachedHistoryCardTag,
6
+ getCachedHistoryForwardTag,
7
+ getCachedHistoryVideoTag,
8
+ type HistoryMediaProcessingOptions,
9
+ } from "../core/media/history-media";
10
+ import { getImageTag } from "../core/media/image-analyzer";
11
+
12
+ const HISTORY_MEDIA_CONCURRENCY = 8;
13
+
14
+ export function shouldTrigger(
15
+ e: any,
16
+ text: string,
17
+ cfg: ChatConfig,
18
+ ctx: MiokiContext,
19
+ ): boolean {
20
+ if (e.message_type === "private") return false;
21
+
22
+ // Only check if message @s the bot (seg format: {type: "at", qq: "123456"})
23
+ const atSeg = e.message?.find((seg: any) => seg.type === "at");
24
+ return !!(atSeg && String(atSeg.qq) === String(e.self_id));
25
+ }
26
+
27
+ /**
28
+ * Check if the message quotes a bot message.
29
+ * Returns the quoted message content if quoting bot, null otherwise.
30
+ */
31
+ export async function isQuotingBot(
32
+ e: any,
33
+ ctx: MiokiContext,
34
+ ): Promise<{ quoted: true; messageId: string; content: string } | null> {
35
+ if (e.quote_id) {
36
+ try {
37
+ const quoteMsg = await ctx.getQuoteMsg(e);
38
+ if (quoteMsg && String(quoteMsg.sender.user_id) === String(e.self_id)) {
39
+ const quotedText = quoteMsg.message
40
+ ?.filter((s: any) => s.type === "text")
41
+ .map((s: any) => s.text)
42
+ .join("");
43
+ if (quotedText) {
44
+ return { quoted: true, messageId: e.quote_id, content: quotedText };
45
+ }
46
+ return null;
47
+ }
48
+ } catch (err) {
49
+ logger.error(err);
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Extract quoted content from a message (regardless of who was quoted).
57
+ * Returns the quoted text, message_id, sender name, and optional image URL, or null if no reply segment.
58
+ */
59
+ export async function getQuotedContent(
60
+ e: any,
61
+ ctx: MiokiContext,
62
+ ): Promise<
63
+ | {
64
+ messageId: string;
65
+ senderName: string;
66
+ content: string;
67
+ imageUrl?: string;
68
+ }
69
+ | null
70
+ | undefined
71
+ > {
72
+ if (e.quote_id) {
73
+ try {
74
+ const quotedMsg = await ctx.getQuoteMsg(e);
75
+ if (quotedMsg && quotedMsg.message) {
76
+ const senderName = quotedMsg.sender.nickname;
77
+ // 提取文本内容
78
+ const textContent = quotedMsg.message
79
+ .filter((s: any) => s.type === "text")
80
+ .map((s: any) => s.text || "")
81
+ .join("");
82
+
83
+ // 检测是否有图片
84
+ let imageUrl: string | undefined;
85
+ const imageSeg = quotedMsg.message.find((s: any) => s.type === "image");
86
+ if (imageSeg && typeof imageSeg === "object") {
87
+ imageUrl = (imageSeg as any).url || (imageSeg as any).data?.url;
88
+ }
89
+
90
+ return {
91
+ messageId: String(e.quote_id),
92
+ senderName,
93
+ content: textContent,
94
+ imageUrl,
95
+ };
96
+ } else return null;
97
+ } catch (err) {
98
+ // ignore
99
+ }
100
+ }
101
+ }
102
+
103
+ export function isGroupAllowed(groupId: number, cfg: ChatConfig): boolean {
104
+ if (cfg.whitelistGroups.length > 0) {
105
+ return cfg.whitelistGroups.includes(groupId);
106
+ }
107
+ if (cfg.blacklistGroups.length > 0) {
108
+ return !cfg.blacklistGroups.includes(groupId);
109
+ }
110
+ return true;
111
+ }
112
+
113
+ export function extractContent(
114
+ e: any,
115
+ cfg: ChatConfig,
116
+ ctx: MiokiContext,
117
+ ): { text: string; multimodal: any[] | null } {
118
+ let text = "";
119
+ try {
120
+ text = ctx.text(e) || "";
121
+ } catch {}
122
+
123
+ // If text is empty but user @'d the bot, describe the action
124
+ if (!text.trim() && e.message) {
125
+ const hasAt = e.message.some(
126
+ (seg: any) => seg.type === "at" && String(seg.qq) === String(e.self_id),
127
+ );
128
+ if (hasAt) {
129
+ text = "[@you with no text]";
130
+ }
131
+ }
132
+
133
+ if (!cfg.isMultimodal) return { text, multimodal: null };
134
+
135
+ const parts: any[] = [];
136
+ if (text) {
137
+ parts.push({ type: "text", text });
138
+ }
139
+
140
+ if (e.message) {
141
+ for (const seg of e.message) {
142
+ // Image seg format: {type: "image", url: "...", file: "..."}
143
+ if (seg.type === "image" && (seg.url || seg.data?.url)) {
144
+ parts.push({
145
+ type: "image_url",
146
+ image_url: { url: seg.url || seg.data.url, detail: "auto" },
147
+ });
148
+ } else if (seg.type === "record") {
149
+ parts.push({ type: "text", text: "[User sent a voice message]" });
150
+ } else if (seg.type === "video") {
151
+ parts.push({ type: "text", text: "[User sent a video]" });
152
+ }
153
+ }
154
+ }
155
+
156
+ if (parts.length > 1 || parts.some((p) => p.type === "image_url")) {
157
+ return { text, multimodal: parts };
158
+ }
159
+ return { text, multimodal: null };
160
+ }
161
+
162
+ async function mapWithConcurrency<T, R>(
163
+ items: T[],
164
+ concurrency: number,
165
+ mapper: (item: T, index: number) => Promise<R>,
166
+ ): Promise<R[]> {
167
+ const results = new Array<R>(items.length);
168
+ let nextIndex = 0;
169
+ const workerCount = Math.min(Math.max(concurrency, 1), items.length);
170
+
171
+ await Promise.all(
172
+ Array.from({ length: workerCount }, async () => {
173
+ while (nextIndex < items.length) {
174
+ const index = nextIndex;
175
+ nextIndex += 1;
176
+ results[index] = await mapper(items[index], index);
177
+ }
178
+ }),
179
+ );
180
+
181
+ return results;
182
+ }
183
+
184
+ export async function getBotRole(
185
+ groupId: number,
186
+ ctx: MiokiContext,
187
+ selfId: number,
188
+ ): Promise<"owner" | "admin" | "member"> {
189
+ try {
190
+ const memberInfo = await ctx
191
+ .pickBot(selfId)
192
+ .getGroupMemberInfo(groupId, selfId);
193
+ return (memberInfo.role as "owner" | "admin" | "member") || "member";
194
+ } catch {
195
+ return "member";
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 从 OneBot API 获取群聊历史消息
201
+ * 返回格式化为 ChatMessage 数组
202
+ */
203
+ export async function getGroupHistory(
204
+ groupId: number,
205
+ ctx: MiokiContext,
206
+ count: number = 100,
207
+ selfId: number,
208
+ db?: {
209
+ getBotMessages(groupId: number, limit: number): ChatMessage[];
210
+ getImageByHash?(hash: string): any;
211
+ getImageByUrl?(url: string): any;
212
+ getMediaSummary?(key: string): any;
213
+ saveMediaSummary?(summary: any): void;
214
+ getStoredGroupNoticeMessages?(
215
+ groupId: number,
216
+ limit?: number,
217
+ ): ChatMessage[];
218
+ },
219
+ mediaOptions?: {
220
+ ai?: AIInstance;
221
+ workingModel?: string;
222
+ multimodalWorkingModel?: string;
223
+ },
224
+ ): Promise<
225
+ Array<{
226
+ userId: number;
227
+ userName: string;
228
+ userRole: string;
229
+ content: string;
230
+ messageId: number;
231
+ timestamp: number;
232
+ }>
233
+ > {
234
+ // 先获取 bot 从数据库发送的消息
235
+ const botMessages: Array<{
236
+ userId: number;
237
+ userName: string;
238
+ userRole: string;
239
+ content: string;
240
+ messageId: number;
241
+ timestamp: number;
242
+ }> = [];
243
+
244
+ if (db) {
245
+ const storedBotMessages = db.getBotMessages(groupId, count);
246
+ for (const msg of storedBotMessages) {
247
+ botMessages.push({
248
+ userId: msg.userId ?? 0,
249
+ userName: msg.userName || "Miku",
250
+ userRole: msg.userRole || "member",
251
+ content: msg.content,
252
+ messageId: msg.messageId ?? 0,
253
+ timestamp: msg.timestamp,
254
+ });
255
+ }
256
+
257
+ const storedNoticeMessages =
258
+ db.getStoredGroupNoticeMessages?.(groupId, Math.min(count, 20)) || [];
259
+ for (const msg of storedNoticeMessages) {
260
+ botMessages.push({
261
+ userId: msg.userId ?? 0,
262
+ userName: msg.userName || String(msg.userId || "unknown"),
263
+ userRole: msg.userRole || "member",
264
+ content: msg.content,
265
+ messageId: msg.messageId ?? 0,
266
+ timestamp: msg.timestamp,
267
+ });
268
+ }
269
+ }
270
+
271
+ try {
272
+ const bot = ctx.pickBot(selfId) as any;
273
+ const historyMediaOptions: HistoryMediaProcessingOptions = {
274
+ ai: mediaOptions?.ai,
275
+ workingModel: mediaOptions?.workingModel,
276
+ multimodalWorkingModel: mediaOptions?.multimodalWorkingModel,
277
+ db:
278
+ db?.getMediaSummary && db?.saveMediaSummary
279
+ ? {
280
+ getMediaSummary: db.getMediaSummary.bind(db),
281
+ saveMediaSummary: db.saveMediaSummary.bind(db),
282
+ }
283
+ : undefined,
284
+ bot,
285
+ groupId,
286
+ };
287
+ // 调用 OneBot API 获取群聊历史
288
+ const result = await bot.api("get_group_msg_history", {
289
+ group_id: String(groupId),
290
+ message_seq: "0",
291
+ count: Math.min(count, 200), // 最多获取200条
292
+ reverse_order: false,
293
+ disable_get_url: false,
294
+ parse_mult_msg: true,
295
+ quick_reply: false,
296
+ });
297
+ const messages = result?.messages || result?.data?.messages || [];
298
+ if (!Array.isArray(messages)) {
299
+ logger.warn("[getGroupHistory] API 返回格式异常:", result);
300
+ return botMessages;
301
+ }
302
+
303
+ const botUin = selfId;
304
+
305
+ // 格式化消息
306
+ const formattedResults = await mapWithConcurrency(
307
+ messages,
308
+ HISTORY_MEDIA_CONCURRENCY,
309
+ async (msg) => {
310
+ // 跳过自己的消息
311
+ if (String(msg.user_id) === String(botUin)) {
312
+ return null;
313
+ }
314
+
315
+ // 提取文本内容
316
+ let content = "";
317
+ try {
318
+ if (
319
+ msg.message &&
320
+ Array.isArray(msg.message) &&
321
+ msg.message.length > 0
322
+ ) {
323
+ const textSegs = msg.message.filter(
324
+ (seg: any) => seg.type === "text",
325
+ );
326
+ const textContent = textSegs
327
+ .map((seg: any) => seg.data?.text || "")
328
+ .join("")
329
+ .trim();
330
+ const atSegs = msg.message.filter((seg: any) => seg.type === "at");
331
+ const atContent = atSegs
332
+ .map((seg: any) => {
333
+ // OneBot v11 格式: seg.qq
334
+ const atUid =
335
+ seg.qq || seg.data?.qq || seg.data?.id || seg.data?.user_id;
336
+ if (!atUid) {
337
+ return null;
338
+ }
339
+ if (atUid === "all" || atUid === "everyone") {
340
+ return "@全体成员";
341
+ }
342
+ return `@${atUid}`;
343
+ })
344
+ .filter((v: string | null) => v !== null)
345
+ .join(" ");
346
+ const parts: string[] = [];
347
+ if (atContent) {
348
+ parts.push(atContent);
349
+ }
350
+ if (textContent) {
351
+ parts.push(textContent);
352
+ }
353
+
354
+ // 处理图片消息
355
+ const imageSegs = msg.message.filter(
356
+ (seg: any) => seg.type === "image",
357
+ );
358
+ if (imageSegs.length > 0) {
359
+ for (const imageSeg of imageSegs) {
360
+ const imageUrl =
361
+ (imageSeg as any).url || (imageSeg as any).data?.url;
362
+ if (imageUrl) {
363
+ parts.push(
364
+ db?.getImageByHash
365
+ ? await getImageTag(String(imageUrl), db as any)
366
+ : "[image]",
367
+ );
368
+ }
369
+ }
370
+ }
371
+
372
+ // 处理视频消息:历史里只读取已缓存摘要,不触发旧视频分析。
373
+ const videoSegs = msg.message.filter(
374
+ (seg: any) => seg.type === "video",
375
+ );
376
+ for (const videoSeg of videoSegs) {
377
+ const videoUrl =
378
+ (videoSeg as any).url || (videoSeg as any).data?.url;
379
+ if (videoUrl) {
380
+ const tag = await getCachedHistoryVideoTag(
381
+ String(videoUrl),
382
+ historyMediaOptions,
383
+ );
384
+ parts.push(tag);
385
+ } else {
386
+ parts.push("[video]");
387
+ }
388
+ }
389
+
390
+ // 处理合并转发消息
391
+ const forwardSegs = msg.message.filter(
392
+ (seg: any) => seg.type === "forward",
393
+ );
394
+ for (const forwardSeg of forwardSegs) {
395
+ const forwardId =
396
+ (forwardSeg as any).id || (forwardSeg as any).data?.id;
397
+ if (forwardId) {
398
+ parts.push(
399
+ await getCachedHistoryForwardTag(
400
+ String(forwardId),
401
+ historyMediaOptions,
402
+ ),
403
+ );
404
+ } else {
405
+ parts.push("[forward]");
406
+ }
407
+ }
408
+
409
+ // 处理 XML/JSON/轻应用/Ark 卡片消息
410
+ const cardSegs = msg.message.filter((seg: any) =>
411
+ ["xml", "json", "lightapp", "ark"].includes(seg.type),
412
+ );
413
+ for (const cardSeg of cardSegs) {
414
+ const cardData =
415
+ (cardSeg as any).data?.data ||
416
+ (cardSeg as any).data?.xml ||
417
+ (cardSeg as any).data ||
418
+ (cardSeg as any).xml ||
419
+ "";
420
+ if (cardData) {
421
+ parts.push(
422
+ getCachedHistoryCardTag(
423
+ typeof cardData === "string"
424
+ ? cardData
425
+ : JSON.stringify(cardData),
426
+ historyMediaOptions,
427
+ ),
428
+ );
429
+ } else {
430
+ parts.push("[card]");
431
+ }
432
+ }
433
+
434
+ if (parts.length > 0) {
435
+ content = parts.join(" ");
436
+ } else if (Array.isArray(msg.message)) {
437
+ const segTypes = msg.message.map((seg: any) => seg.type);
438
+ const nonTextTypes = segTypes.filter(
439
+ (t: string) => t !== "text" && t !== "at",
440
+ );
441
+ if (nonTextTypes.length > 0) {
442
+ content = `[${nonTextTypes.join(", ")}]`;
443
+ } else {
444
+ return null;
445
+ }
446
+ }
447
+ }
448
+ } catch (err) {
449
+ logger.error("[getGroupHistory] process message error:", err);
450
+ return null;
451
+ }
452
+
453
+ // 跳过空消息
454
+ if (!content.trim()) {
455
+ return null;
456
+ }
457
+
458
+ return {
459
+ userId: msg.user_id,
460
+ userName:
461
+ msg.sender?.card || msg.sender?.nickname || String(msg.user_id),
462
+ userRole: msg.sender?.role || "member",
463
+ content,
464
+ messageId: msg.message_id,
465
+ timestamp: msg.time ? msg.time * 1000 : Date.now(),
466
+ };
467
+ },
468
+ );
469
+ const formatted = formattedResults.filter(
470
+ (
471
+ msg,
472
+ ): msg is {
473
+ userId: number;
474
+ userName: string;
475
+ userRole: string;
476
+ content: string;
477
+ messageId: number;
478
+ timestamp: number;
479
+ } => Boolean(msg),
480
+ );
481
+
482
+ // 合并 bot 消息
483
+ const allMessages = [...botMessages, ...formatted];
484
+ allMessages.sort((a, b) => a.timestamp - b.timestamp);
485
+
486
+ // 如果超过 count,截取最新的
487
+ if (allMessages.length > count) {
488
+ return allMessages.slice(-count);
489
+ }
490
+
491
+ return allMessages;
492
+ } catch (err) {
493
+ console.error("获取群聊历史失败:", err);
494
+ return botMessages;
495
+ }
496
+ }
package/utils/queue.ts ADDED
@@ -0,0 +1,186 @@
1
+ import type { ChatConfig, TargetMessage } from "../types";
2
+
3
+ /**
4
+ * 等待处理的队列消息
5
+ */
6
+ export interface QueuedMessage {
7
+ event: any;
8
+ config: ChatConfig;
9
+ triggerReason?: string;
10
+ queuedAt: number;
11
+ }
12
+
13
+ /**
14
+ * 消息队列管理器
15
+ * 处理群级别的 AI 请求队列,确保一个群只有一个 AI 在思考
16
+ */
17
+ export class MessageQueueManager {
18
+ private pendingMessages = new Map<string, QueuedMessage[]>();
19
+ private activeTargetMessages = new Map<string, TargetMessage>();
20
+
21
+ /**
22
+ * 将消息加入队列
23
+ */
24
+ enqueue(
25
+ groupSessionId: string,
26
+ event: any,
27
+ config: ChatConfig,
28
+ triggerReason?: string,
29
+ ): void {
30
+ const queue = this.pendingMessages.get(groupSessionId) ?? [];
31
+ queue.push({ event, config, triggerReason, queuedAt: Date.now() });
32
+ this.pendingMessages.set(groupSessionId, queue);
33
+ }
34
+
35
+ /**
36
+ * 获取队列长度
37
+ */
38
+ getQueueLength(groupSessionId: string): number {
39
+ return this.pendingMessages.get(groupSessionId)?.length ?? 0;
40
+ }
41
+
42
+ /**
43
+ * 检查群是否正在处理
44
+ */
45
+ isProcessing(groupSessionId: string): boolean {
46
+ return this.activeTargetMessages.has(groupSessionId);
47
+ }
48
+
49
+ /**
50
+ * 设置活跃的 targetMessage
51
+ */
52
+ setActiveTarget(groupSessionId: string, target: TargetMessage): void {
53
+ this.activeTargetMessages.set(groupSessionId, target);
54
+ }
55
+
56
+ /**
57
+ * 获取活跃的 targetMessage
58
+ */
59
+ getActiveTarget(groupSessionId: string): TargetMessage | undefined {
60
+ return this.activeTargetMessages.get(groupSessionId);
61
+ }
62
+
63
+ /**
64
+ * 清理活跃的 targetMessage
65
+ */
66
+ clearActiveTarget(groupSessionId: string): void {
67
+ this.activeTargetMessages.delete(groupSessionId);
68
+ }
69
+
70
+ /**
71
+ * 获取队列(用于处理)
72
+ */
73
+ getQueue(groupSessionId: string): QueuedMessage[] | undefined {
74
+ return this.pendingMessages.get(groupSessionId);
75
+ }
76
+
77
+ /**
78
+ * 清空队列
79
+ */
80
+ clearQueue(groupSessionId: string): void {
81
+ this.pendingMessages.delete(groupSessionId);
82
+ }
83
+
84
+ /**
85
+ * 检查是否有队列
86
+ */
87
+ hasQueue(groupSessionId: string): boolean {
88
+ const queue = this.pendingMessages.get(groupSessionId);
89
+ return !!queue && queue.length > 0;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 将包含多个 reply 标记的单行文本拆分为多行
95
+ * 例如 "[[[reply:1]]]文字A[[[reply:2]]]文字B" → ["[[[reply:1]]]文字A", "[[[reply:2]]]文字B"]
96
+ */
97
+ export function splitByReplyMarkers(line: string): string[] {
98
+ const parts = line.split(
99
+ /(?=\[\[\[reply:-?\d+\]\]\]|\(\(\(reply:-?\d+\)\)\))/,
100
+ );
101
+ return parts.filter((p) => p.trim());
102
+ }
103
+
104
+ /**
105
+ * 解析单行文本中的标记,按顺序提取 AT、戳人、引用
106
+ * @param line 要解析的文本
107
+ * @param quoteMode "skip" 跳过引用标记,其他值处理引用
108
+ */
109
+ export function parseLineMarkers(
110
+ line: string,
111
+ quoteMode?: "skip",
112
+ ): {
113
+ cleanText: string;
114
+ atUsers: number[];
115
+ pokeUsers: number[];
116
+ quoteId?: number;
117
+ audioText?: string;
118
+ } {
119
+ const atUsers: number[] = [];
120
+ const pokeUsers: number[] = [];
121
+ let quoteId: number | undefined;
122
+ let audioText: string | undefined;
123
+
124
+ // 提取 AT 标记
125
+ const atPatterns = [
126
+ /\[\[\[at:(\d+)\]\]\]/g,
127
+ /\(\(\(at:(\d+)\)\)\)/g,
128
+ /\(\(\((\d+)\)\)\)/g,
129
+ ];
130
+ for (const pattern of atPatterns) {
131
+ const matches = [...line.matchAll(pattern)];
132
+ for (const match of matches) {
133
+ const userId = parseInt(match[1], 10);
134
+ atUsers.push(userId);
135
+ }
136
+ }
137
+
138
+ // 提取戳人标记
139
+ const pokePatterns = [/\[\[\[poke:(\d+)\]\]\]/g, /\(\(\(poke:(\d+)\)\)\)/g];
140
+ for (const pattern of pokePatterns) {
141
+ const matches = [...line.matchAll(pattern)];
142
+ for (const match of matches) {
143
+ const userId = parseInt(match[1], 10);
144
+ pokeUsers.push(userId);
145
+ }
146
+ }
147
+
148
+ // 提取引用标记(仅在允许时)
149
+ if (quoteMode !== "skip") {
150
+ const replyPatterns = [
151
+ /\[\[\[reply:(-?\d+)\]\]\]/g,
152
+ /\(\(\(reply:(-?\d+)\)\)\)/g,
153
+ ];
154
+ for (const pattern of replyPatterns) {
155
+ const matches = [...line.matchAll(pattern)];
156
+ for (const match of matches) {
157
+ if (quoteId === undefined) {
158
+ quoteId = parseInt(match[1], 10);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // 提取语音标记(单行只取第一个)
165
+ const audioMatch = line.match(/\[audio:([^\]]+)\]/i);
166
+ if (audioMatch?.[1]) {
167
+ const value = audioMatch[1].trim();
168
+ if (value) {
169
+ audioText = value;
170
+ }
171
+ }
172
+
173
+ // 清理标记
174
+ let cleanText = line
175
+ .replace(/\[\[\[at:\d+\]\]\]/g, "")
176
+ .replace(/\(\(\(at:\d+\)\)\)/g, "")
177
+ .replace(/\(\(\(\d+\)\)\)/g, "")
178
+ .replace(/\[\[\[poke:\d+\]\]\]/g, "")
179
+ .replace(/\(\(\(poke:\d+\)\)\)/g, "")
180
+ .replace(/\[\[\[reply:-?\d+\]\]\]/g, "")
181
+ .replace(/\(\(\(reply:-?\d+\)\)\)/g, "")
182
+ .replace(/\[audio:[^\]]+\]/gi, "")
183
+ .trim();
184
+
185
+ return { cleanText, atUsers, pokeUsers, quoteId, audioText };
186
+ }