mioku-plugin-chat 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/configs/base.ts +12 -0
- package/configs/personalization.ts +84 -0
- package/configs/settings.ts +54 -0
- package/context.ts +98 -0
- package/core/base.ts +936 -0
- package/core/chat-engine.ts +779 -0
- package/core/external-skills.ts +94 -0
- package/core/feature-prompts.ts +47 -0
- package/core/media/audio.ts +73 -0
- package/core/media/gif-extractor.ts +118 -0
- package/core/media/history-media.ts +751 -0
- package/core/media/image-analyzer.ts +447 -0
- package/core/media/markdown-message.ts +190 -0
- package/core/multimodal.ts +180 -0
- package/core/prompt.ts +827 -0
- package/core/tools.ts +862 -0
- package/core/web/searxng.ts +184 -0
- package/core/web/web-reader.ts +792 -0
- package/db.ts +849 -0
- package/humanize/emoji-agent.ts +397 -0
- package/humanize/expression.ts +152 -0
- package/humanize/index.ts +40 -0
- package/humanize/memory.ts +131 -0
- package/humanize/planner.ts +249 -0
- package/humanize/topic.ts +205 -0
- package/humanize/utils.ts +28 -0
- package/index.ts +1830 -0
- package/manage/cooldown.ts +445 -0
- package/manage/group-structured-history.ts +195 -0
- package/manage/idle-check.ts +186 -0
- package/manage/queue-processor.ts +228 -0
- package/manage/rate-limiter.ts +312 -0
- package/manage/session.ts +96 -0
- package/manage/skill-session.ts +173 -0
- package/manage/types.ts +138 -0
- package/package.json +51 -0
- package/tsconfig.json +7 -0
- package/types.ts +381 -0
- package/utils/index.ts +9 -0
- package/utils/message.ts +496 -0
- package/utils/queue.ts +186 -0
package/utils/message.ts
ADDED
|
@@ -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
|
+
}
|