mioku-plugin-chat 3.0.0 → 3.0.1

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/core/base.ts CHANGED
@@ -79,7 +79,7 @@ async function waitTypingDelay(
79
79
  export async function sendAIResponse(
80
80
  options: SendAIResponseOptions,
81
81
  selfId: number,
82
- ): Promise<void> {
82
+ ): Promise<Array<string | undefined>> {
83
83
  const {
84
84
  ctx,
85
85
  groupId,
@@ -96,17 +96,25 @@ export async function sendAIResponse(
96
96
  ctx.logger.error(
97
97
  `[sendAIResponse] bot ${String(selfId)} not found, skip sending group message`,
98
98
  );
99
- return;
99
+ return [];
100
100
  }
101
101
 
102
- if (messages.length === 0) return;
102
+ if (messages.length === 0) return [];
103
+
104
+ const sentMessageIds: Array<string | undefined> = new Array(messages.length);
105
+ const recordSentId = (messageIndex: number, messageId?: string) => {
106
+ if (!messageId) return;
107
+ if (messageIndex >= 0 && messageIndex < messages.length) {
108
+ sentMessageIds[messageIndex] ??= messageId;
109
+ }
110
+ };
103
111
 
104
112
  for (let i = 0; i < messages.length; i++) {
105
113
  if (sentIndices?.has(i)) continue;
106
114
 
107
115
  const expandedLines = expandOutgoingLines(messages[i]);
108
116
 
109
- let pendingReply: number | undefined;
117
+ let pendingReply: string | undefined;
110
118
  let lastDelayBasisText = "";
111
119
 
112
120
  for (let j = 0; j < expandedLines.length; j++) {
@@ -190,7 +198,7 @@ export async function sendAIResponse(
190
198
  return segments;
191
199
  },
192
200
  imagePath,
193
- );
201
+ ).then((sentId) => recordSentId(i, sentId));
194
202
  lastDelayBasisText = summarizeMarkdown(markdownContent);
195
203
  if (typingDelayEnabled && j < expandedLines.length - 1) {
196
204
  const delayMs = calculateTypingDelayMs(lastDelayBasisText || line);
@@ -217,7 +225,11 @@ export async function sendAIResponse(
217
225
  }
218
226
 
219
227
  if (lineSegments.length > 0) {
220
- await bot.sendMessage({ type: "group", group_id: groupId}, lineSegments);
228
+ const sent = await bot.sendMessage(
229
+ { type: "group", group_id: groupId },
230
+ lineSegments,
231
+ );
232
+ recordSentId(i, sent?.message_id);
221
233
  lastDelayBasisText = sendableText || fallbackText || audioText || line;
222
234
  }
223
235
 
@@ -234,6 +246,8 @@ export async function sendAIResponse(
234
246
 
235
247
  await onLineSent?.();
236
248
  }
249
+
250
+ return sentMessageIds;
237
251
  }
238
252
 
239
253
  export async function sendMessage(
@@ -259,7 +273,7 @@ export async function sendMessage(
259
273
  // 应用错别字生成器
260
274
  const expandedLines = expandOutgoingLines(text);
261
275
 
262
- let pendingReply: number | undefined;
276
+ let pendingReply: string | undefined;
263
277
  let lastDelayBasisText = "";
264
278
 
265
279
  for (let j = 0; j < expandedLines.length; j++) {
@@ -374,7 +388,10 @@ export async function sendMessage(
374
388
  }
375
389
 
376
390
  if (segments.length > 0 && groupId) {
377
- await bot.sendMessage({ type: "group", group_id: groupId}, segments);
391
+ await bot.sendMessage(
392
+ { type: "group", group_id: groupId },
393
+ segments,
394
+ );
378
395
  lastDelayBasisText =
379
396
  sendableText || fallbackText || audioText || line;
380
397
  }
@@ -396,7 +413,7 @@ export async function sendMessage(
396
413
  const beforeAt = remaining.slice(lastIndex, match.index);
397
414
  if (beforeAt) {
398
415
  const cleaned = beforeAt
399
- .replace(/\[reply:-?\d+\]/g, "")
416
+ .replace(/\[reply:[^\]\n]+\]/g, "")
400
417
  .replace(/\[poke:\d+\]/g, "")
401
418
  .replace(/\[audio:[^\]]+\]/gi, "")
402
419
  .trim();
@@ -417,7 +434,7 @@ export async function sendMessage(
417
434
  const afterAt = remaining.slice(lastIndex);
418
435
  if (afterAt) {
419
436
  const cleaned = afterAt
420
- .replace(/\[reply:-?\d+\]/g, "")
437
+ .replace(/\[reply:[^\]\n]+\]/g, "")
421
438
  .replace(/\[poke:\d+\]/g, "")
422
439
  .replace(/\[audio:[^\]]+\]/gi, "")
423
440
  .trim();
@@ -435,7 +452,10 @@ export async function sendMessage(
435
452
  // 发送消息
436
453
  if (segments.length > 0) {
437
454
  if (groupId) {
438
- await bot.sendMessage({ type: "group", group_id: groupId}, segments);
455
+ await bot.sendMessage(
456
+ { type: "group", group_id: groupId },
457
+ segments,
458
+ );
439
459
  lastDelayBasisText =
440
460
  sendableText || fallbackText || audioText || line;
441
461
  }
@@ -510,7 +530,7 @@ function expandOutgoingLines(text: string): string[] {
510
530
 
511
531
  function normalizeActionLineBreaks(text: string): string {
512
532
  return String(text || "").replace(
513
- /\\\s*(?=(?:\[\]|\[emotion:[^\]]+\]|\[audio:[^\]]+\]|\[reply:-?\d+\]))/gi,
533
+ /\\\s*(?=(?:\[\]|\[emotion:[^\]]+\]|\[audio:[^\]]+\]|\[reply:[^\]\n]+\]))/gi,
514
534
  "\n",
515
535
  );
516
536
  }
@@ -539,9 +559,10 @@ async function dispatchSegments(
539
559
  userId: number | undefined,
540
560
  buildSegments: (imageSource?: string) => any[],
541
561
  fallbackImagePath?: string,
542
- ): Promise<void> {
562
+ ): Promise<string | undefined> {
543
563
  try {
544
- await sendByTarget(bot, groupId, userId, buildSegments());
564
+ const sent = await sendByTarget(bot, groupId, userId, buildSegments());
565
+ return sent?.message_id;
545
566
  } catch (error) {
546
567
  if (!fallbackImagePath || !isLocalFilePath(fallbackImagePath)) {
547
568
  throw error;
@@ -550,7 +571,13 @@ async function dispatchSegments(
550
571
  const fsPromises = await import("fs/promises");
551
572
  const buffer = await fsPromises.readFile(fallbackImagePath);
552
573
  const base64Image = `base64://${buffer.toString("base64")}`;
553
- await sendByTarget(bot, groupId, userId, buildSegments(base64Image));
574
+ const sent = await sendByTarget(
575
+ bot,
576
+ groupId,
577
+ userId,
578
+ buildSegments(base64Image),
579
+ );
580
+ return sent?.message_id;
554
581
  }
555
582
  }
556
583
 
@@ -559,15 +586,19 @@ async function sendByTarget(
559
586
  groupId: number | undefined,
560
587
  userId: number | undefined,
561
588
  segments: readonly any[],
562
- ): Promise<void> {
589
+ ): Promise<import("mioku").SentMessage | undefined> {
563
590
  if (groupId) {
564
- await bot.sendMessage({ type: "group", group_id: groupId}, segments);
565
- return;
591
+ return await bot.sendMessage(
592
+ { type: "group", group_id: groupId },
593
+ segments,
594
+ );
566
595
  }
567
596
 
568
597
  if (userId) {
569
- await bot.sendMessage({ type: "private", user_id: userId}, segments);
570
- return;
598
+ return await bot.sendMessage(
599
+ { type: "private", user_id: userId },
600
+ segments,
601
+ );
571
602
  }
572
603
 
573
604
  throw new Error("No valid message target");
@@ -605,7 +636,7 @@ async function resolveAudioSource(
605
636
  options: {
606
637
  audioText?: string;
607
638
  config: ChatConfig;
608
- audioService?: AudioServiceApi;
639
+ audioService?: AudioServiceApi;
609
640
  },
610
641
  ): Promise<string | null> {
611
642
  const trimmed = String(options.audioText || "").trim();
@@ -735,7 +766,9 @@ export async function getHumanizeContexts(
735
766
  topicContext: topicContext || undefined,
736
767
  expressionContext: expressionContext || undefined,
737
768
  };
738
- logger.info(`[getHumanizeContexts] session=${groupSessionId} user=${userName} triggerUserId=${triggerUserId} memoryContext=undefined topicContext="${topicContext?.slice(0, 100) ?? ""}" expressionContext="${expressionContext?.slice(0, 100) ?? ""}"`);
769
+ logger.info(
770
+ `[getHumanizeContexts] session=${groupSessionId} user=${userName} triggerUserId=${triggerUserId} memoryContext=undefined topicContext="${topicContext?.slice(0, 100) ?? ""}" expressionContext="${expressionContext?.slice(0, 100) ?? ""}"`,
771
+ );
739
772
  return result;
740
773
  }
741
774
 
@@ -842,6 +875,7 @@ export function saveBotMessages(
842
875
  db: ChatDatabase,
843
876
  ctx: MiokuContext,
844
877
  bot: Bot | undefined,
878
+ sentMessageIds?: Array<string | undefined>,
845
879
  ): void {
846
880
  const botNickname = config.nicknames[0] || (bot?.nickname ?? "Miku");
847
881
  const selfId = bot ? Number(bot.bot_id) : 0;
@@ -851,7 +885,8 @@ export function saveBotMessages(
851
885
  return;
852
886
  }
853
887
 
854
- for (const msg of messages) {
888
+ for (let i = 0; i < messages.length; i++) {
889
+ const msg = messages[i];
855
890
  const botMsg: ChatMessage = {
856
891
  sessionId: groupSessionId,
857
892
  role: "assistant",
@@ -861,6 +896,7 @@ export function saveBotMessages(
861
896
  userRole: "member",
862
897
  groupId,
863
898
  timestamp,
899
+ messageId: sentMessageIds?.[i],
864
900
  };
865
901
  db.saveMessage(botMsg);
866
902
  }
@@ -874,15 +910,13 @@ export async function sendEmoji(
874
910
  ): Promise<void> {
875
911
  if (!emojiPath) return;
876
912
  if (!bot) {
877
- ctx.logger.error(
878
- `[sendEmoji] bot not available, skip sending emoji`,
879
- );
913
+ ctx.logger.error(`[sendEmoji] bot not available, skip sending emoji`);
880
914
  return;
881
915
  }
882
916
 
883
917
  try {
884
918
  const emojiSegment = ctx.segment.image(`file://${emojiPath}`);
885
- await bot.sendMessage({ type: "group", group_id: groupId}, [emojiSegment]);
919
+ await bot.sendMessage({ type: "group", group_id: groupId }, [emojiSegment]);
886
920
  } catch (err) {
887
921
  try {
888
922
  const fsPromises = await import("fs/promises");
@@ -917,7 +951,9 @@ export async function sendEmoji(
917
951
 
918
952
  const base64DataUrl = `data:${mimeType};base64,${base64}`;
919
953
  const base64Segment = ctx.segment.image(base64DataUrl);
920
- await bot.sendMessage({ type: "group", group_id: groupId}, [base64Segment]);
954
+ await bot.sendMessage({ type: "group", group_id: groupId }, [
955
+ base64Segment,
956
+ ]);
921
957
  ctx.logger.info(`[Emoji] Sent via base64: ${path.basename(emojiPath)}`);
922
958
  } catch (base64Err) {
923
959
  ctx.logger.error(`[Emoji] Base64 also failed: ${base64Err}`);
@@ -1,5 +1,4 @@
1
1
  import type { AIInstance, SessionToolDefinition } from "mioku";
2
- import { logger } from "mioku";
3
2
  import type { AITool } from "mioku";
4
3
  import type {
5
4
  ToolContext,
@@ -19,6 +18,8 @@ import {
19
18
  } from "./external-skills";
20
19
  import {
21
20
  consumeCompleteStreamUnits,
21
+ isQuoteMarkerOnlyUnit,
22
+ mergeQuoteMarkerUnits,
22
23
  splitOutgoingUnits,
23
24
  } from "./media/markdown-message";
24
25
  import {
@@ -90,15 +91,16 @@ export async function runChat(
90
91
  const staticPrompt = buildStaticSystemPrompt(staticCtx);
91
92
  const dynamicUserContext = buildDynamicUserContext(dynamicCtx);
92
93
 
93
- logger.info(
94
+ const engineLog = toolCtx.ctx.logger;
95
+ engineLog.info(
94
96
  `[chat-engine] Session ${toolCtx.sessionId} | target: ${targetMessage.userName}(${targetMessage.userId}): "${targetMessage.content}"`,
95
97
  );
96
98
  if (toolCtx.config.debug) {
97
- logger.info("[chat-engine] === Static System Prompt ===");
98
- logger.info(staticPrompt);
99
- logger.info("[chat-engine] === Dynamic User Context ===");
100
- logger.info(dynamicUserContext);
101
- logger.info("[chat-engine] === End Prompts ===");
99
+ engineLog.info("[chat-engine] === Static System Prompt ===");
100
+ engineLog.info(staticPrompt);
101
+ engineLog.info("[chat-engine] === Dynamic User Context ===");
102
+ engineLog.info(dynamicUserContext);
103
+ engineLog.info("[chat-engine] === End Prompts ===");
102
104
  }
103
105
 
104
106
  const hasStructuredHistory =
@@ -181,6 +183,7 @@ export async function runChat(
181
183
  streamedMessages.push(text);
182
184
  };
183
185
 
186
+ let pendingQuoteMarkerLines: string[] = [];
184
187
  const flushStreamBuffer = async (force: boolean): Promise<void> => {
185
188
  while (true) {
186
189
  const { units, rest } = consumeCompleteStreamUnits(streamBuffer, force);
@@ -191,17 +194,29 @@ export async function runChat(
191
194
 
192
195
  streamBuffer = rest;
193
196
  for (const unit of units) {
197
+ if (!unit.trim()) {
198
+ continue;
199
+ }
200
+ if (isQuoteMarkerOnlyUnit(unit)) {
201
+ pendingQuoteMarkerLines.push(unit.trim());
202
+ continue;
203
+ }
204
+ const mergedUnit = pendingQuoteMarkerLines.length
205
+ ? [...pendingQuoteMarkerLines, unit].join("\n")
206
+ : unit;
207
+ pendingQuoteMarkerLines = [];
194
208
  const unitIndex = streamUnitIndex;
195
209
  streamUnitIndex += 1;
196
- if (unit.trim()) {
197
- await emitStreamSegment(unit, unitIndex);
198
- }
210
+ await emitStreamSegment(mergedUnit, unitIndex);
199
211
  }
200
212
 
201
213
  if (!force) {
202
214
  break;
203
215
  }
204
216
  }
217
+ if (force) {
218
+ pendingQuoteMarkerLines = [];
219
+ }
205
220
  };
206
221
 
207
222
  const runComplete = () =>
@@ -252,7 +267,9 @@ export async function runChat(
252
267
  targetMessage,
253
268
  })
254
269
  .catch((err) =>
255
- logger.warn(`[chat-engine] background emotion refresh failed: ${err}`),
270
+ toolCtx.ctx.logger.warn(
271
+ `[chat-engine] background emotion refresh failed: ${err}`,
272
+ ),
256
273
  );
257
274
 
258
275
  if (streamEnabled) {
@@ -261,9 +278,9 @@ export async function runChat(
261
278
  }
262
279
 
263
280
  if (toolCtx.config.debug) {
264
- logger.info("[chat-engine] === Raw AI Reply ===");
265
- logger.info(response.content || "(empty)");
266
- logger.info("[chat-engine] === End Raw AI Reply ===");
281
+ toolCtx.ctx.logger.info("[chat-engine] === Raw AI Reply ===");
282
+ toolCtx.ctx.logger.info(response.content || "(empty)");
283
+ toolCtx.ctx.logger.info("[chat-engine] === End Raw AI Reply ===");
267
284
  }
268
285
 
269
286
  const allToolCalls = response.allToolCalls || [];
@@ -275,16 +292,18 @@ export async function runChat(
275
292
  }
276
293
 
277
294
  if (toolCtx.config.debug && response.reasoning) {
278
- logger.info(`[chat-engine] AI reasoning: ${response.reasoning}`);
295
+ toolCtx.ctx.logger.info(
296
+ `[chat-engine] AI reasoning: ${response.reasoning}`,
297
+ );
279
298
  }
280
299
 
281
300
  if (toolCtx.config.debug && allToolCalls.length > 0) {
282
301
  for (const toolCall of allToolCalls) {
283
302
  const resultPreview = JSON.stringify(toolCall.result);
284
- logger.info(
303
+ toolCtx.ctx.logger.info(
285
304
  `[chat-engine] Tool call: ${toolCall.name}(${JSON.stringify(toolCall.arguments).substring(0, 100)})`,
286
305
  );
287
- logger.info(
306
+ toolCtx.ctx.logger.info(
288
307
  `[chat-engine] Tool result: ${toolCall.name} -> ${resultPreview ? resultPreview.substring(0, 300) : "undefined"}`,
289
308
  );
290
309
  }
@@ -298,7 +317,9 @@ export async function runChat(
298
317
  currentUserMessages,
299
318
  [],
300
319
  );
301
- logger.info(`[chat-engine] Session ${toolCtx.sessionId} ended by tool`);
320
+ toolCtx.ctx.logger.info(
321
+ `[chat-engine] Session ${toolCtx.sessionId} ended by tool`,
322
+ );
302
323
  toolCtx.aiService.finalizeUsage?.(usageId, {
303
324
  sentUserMessages: 0,
304
325
  sentAssistantMessages: 0,
@@ -358,9 +379,9 @@ export async function runChat(
358
379
  }
359
380
  }
360
381
 
361
- const finalMessages = splitOutgoingUnits(finalText).filter(
362
- (unit) => unit.trim() && unit.trim() !== "---",
363
- );
382
+ const finalMessages = mergeQuoteMarkerUnits(
383
+ splitOutgoingUnits(finalText),
384
+ ).filter((unit) => unit.trim() && unit.trim() !== "---");
364
385
  const sentAssistantMessages = streamEnabled
365
386
  ? streamedMessages.length
366
387
  : finalMessages.length;
@@ -369,7 +390,7 @@ export async function runChat(
369
390
  sentAssistantMessages,
370
391
  });
371
392
 
372
- logger.info(
393
+ toolCtx.ctx.logger.info(
373
394
  `[chat-engine] Session ${toolCtx.sessionId} done | ${finalMessages.length} msg(s), ${allToolCalls.length} tool call(s)`,
374
395
  );
375
396
 
@@ -955,7 +976,7 @@ ${failedSummary}
955
976
  return text;
956
977
  }
957
978
  } catch (err) {
958
- logger.warn(
979
+ toolCtx.ctx.logger.warn(
959
980
  `[chat-engine] Failed to generate tool-failure fallback reply: ${err}`,
960
981
  );
961
982
  }
package/core/chat-turn.ts CHANGED
@@ -41,7 +41,7 @@ interface ResolvedRuntimeContext {
41
41
  userRole: "owner" | "admin" | "member";
42
42
  userTitle?: string;
43
43
  groupName?: string;
44
- messageId?: number;
44
+ messageId?: string;
45
45
  }
46
46
 
47
47
  const NO_NEW_MESSAGE =
@@ -147,13 +147,13 @@ export async function finalizeChatTurn(
147
147
  const { groupId, groupSessionId, userId, selfId, cfg, toolCtx, result, event } = args;
148
148
 
149
149
  if (groupId) {
150
- await pluginCtx.sendAIResponse(
150
+ const sentMessageIds = await pluginCtx.sendAIResponse(
151
151
  { ctx, groupId, messages: result.messages, config: cfg, sentIndices: toolCtx.sentMessageIndices, audioService: pluginCtx.audioService },
152
152
  selfId,
153
153
  );
154
154
  await pluginCtx.sendEmoji(ctx, groupId, result.emojiPath, event?.bot);
155
155
  const now = Date.now();
156
- pluginCtx.saveBotMessages(groupId, groupSessionId, result.messages, now, cfg, pluginCtx.db, ctx, event?.bot);
156
+ pluginCtx.saveBotMessages(groupId, groupSessionId, result.messages, now, cfg, pluginCtx.db, ctx, event?.bot, sentMessageIds);
157
157
  if (args.isLive) {
158
158
  pluginCtx.idleCheckManager.recordBotMessages(groupSessionId, result.messages.length, selfId);
159
159
  }
@@ -80,7 +80,7 @@ export interface GroupNoticeHistorySummary {
80
80
  userRole: string;
81
81
  content: string;
82
82
  timestamp: number;
83
- messageId: number;
83
+ messageId: string;
84
84
  }
85
85
 
86
86
  function runHistoryMediaAIRequest<T>(
@@ -393,7 +393,7 @@ export async function summarizeGroupNotice(
393
393
  userRole: "member",
394
394
  content,
395
395
  timestamp: normalizeNoticeTimestamp(notice),
396
- messageId: 0,
396
+ messageId: String((notice as { message_id?: unknown }).message_id ?? ""),
397
397
  };
398
398
  }
399
399
 
@@ -1,6 +1,28 @@
1
1
  export const MARKDOWN_OPEN_TAG = "<MARKDOWN>";
2
2
  export const MARKDOWN_CLOSE_TAG = "</MARKDOWN>";
3
3
 
4
+ const QUOTE_MARKER_ONLY = /^(?:\s*\[reply:[^\]\n]+\])+\s*$/i;
5
+
6
+ export function isQuoteMarkerOnlyUnit(unit: string): boolean {
7
+ return QUOTE_MARKER_ONLY.test(String(unit || "").trim());
8
+ }
9
+
10
+ export function mergeQuoteMarkerUnits(units: string[]): string[] {
11
+ const result: string[] = [];
12
+ let pending: string[] = [];
13
+
14
+ for (const unit of units) {
15
+ if (isQuoteMarkerOnlyUnit(unit)) {
16
+ pending.push(unit.trim());
17
+ continue;
18
+ }
19
+ result.push(pending.length > 0 ? [...pending, unit].join("\n") : unit);
20
+ pending = [];
21
+ }
22
+
23
+ return result;
24
+ }
25
+
4
26
  export function splitOutgoingUnits(text: string): string[] {
5
27
  const normalized = String(text || "").replace(/\r/g, "");
6
28
  const result: string[] = [];
@@ -127,7 +127,7 @@ export type MediaByMessageId =
127
127
  */
128
128
  export async function getMediaByMessageId(
129
129
  ctx: MiokuContext,
130
- messageId: number,
130
+ messageId: string | number,
131
131
  e: any,
132
132
  ): Promise<MediaByMessageId> {
133
133
  try {
@@ -153,7 +153,7 @@ export async function getMediaByMessageId(
153
153
 
154
154
  return null;
155
155
  } catch (err) {
156
- logger.error(
156
+ ctx.logger.error(
157
157
  `[multimodal] Failed to get media from message ${messageId}: ${err}`,
158
158
  );
159
159
  return null;
@@ -191,7 +191,7 @@ export async function getQuoteImageUrl(
191
191
  // 提取图片 URL
192
192
  return (imageSeg as any).url || (imageSeg as any).data?.url || null;
193
193
  } catch (err) {
194
- logger.error(`[multimodal] Failed to get quote image URL: ${err}`);
194
+ ctx.logger.error(`[multimodal] Failed to get quote image URL: ${err}`);
195
195
  return null;
196
196
  }
197
197
  }
package/core/prompt.ts CHANGED
@@ -45,7 +45,7 @@ export interface DynamicPromptContext {
45
45
  reviewMessages?: {
46
46
  contents: string[];
47
47
  userNames: string[];
48
- messageIds: number[];
48
+ messageIds: string[];
49
49
  };
50
50
  currentEmotion?: string;
51
51
  expressionContext?: string;
@@ -276,7 +276,7 @@ function buildResponseFormatSection(
276
276
  - **SPECIAL ACTIONS in your text (auto-parsed and removed from message):**
277
277
  - Use [at:123456] in your text to @ someone (123456 is the QQ number)
278
278
  - Use [poke:123456] in your text to poke someone. IMPORTANT: when you plan to poke a user, DON't describe your poke actions.
279
- - Use [reply:123456] at the START of a line to quote-reply that message (123456 is message_id)
279
+ - Use [reply:message_id] at the START of a line to quote-reply that message (copy the message_id EXACTLY as shown after # in the chat history; it may contain letters, e.g. [reply:P2rW...pMB])
280
280
  - **You can use MULTIPLE [reply:xxx] markers in different lines to quote multiple messages!**`);
281
281
 
282
282
  if (ctx.config.audio?.enabled) {
@@ -579,13 +579,15 @@ function buildChatHistorySection(ctx: DynamicPromptContext): string {
579
579
  for (const msg of chatHistory) {
580
580
  const time = new Date(msg.timestamp);
581
581
  const timeStr = `${String(time.getMonth() + 1).padStart(2, "0")}-${String(time.getDate()).padStart(2, "0")} ${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
582
+ const msgIdStr = msg.messageId ? `#${msg.messageId}` : "";
582
583
 
583
584
  if (msg.role === "assistant") {
585
+ const contentStr = msgIdStr ? `${msg.content} ${msgIdStr}` : msg.content;
584
586
  if (currentAssistantBlock && currentAssistantBlock.timeStr === timeStr) {
585
- currentAssistantBlock.contents.push(msg.content);
587
+ currentAssistantBlock.contents.push(contentStr);
586
588
  } else {
587
589
  flushAssistant();
588
- currentAssistantBlock = { timeStr, contents: [msg.content] };
590
+ currentAssistantBlock = { timeStr, contents: [contentStr] };
589
591
  }
590
592
  continue;
591
593
  }
@@ -600,16 +602,16 @@ function buildChatHistorySection(ctx: DynamicPromptContext): string {
600
602
  : "Member";
601
603
  const titleStr = msg.userTitle ? `, ${msg.userTitle}` : "";
602
604
  const qqStr = msg.userId ? `${msg.userId}` : "";
603
- const msgIdStr = msg.messageId ? ` #${msg.messageId}` : "";
604
605
  mergedLines.push(
605
- `[${timeStr}] ${name}(${qqStr}, ${roleLabel}${titleStr})${msgIdStr}): ${msg.content}`,
606
+ `[${timeStr}] ${name}(${qqStr}, ${roleLabel}${titleStr})${msgIdStr ? ` ${msgIdStr}` : ""}: ${msg.content}`,
606
607
  );
607
608
  }
608
609
  flushAssistant();
609
610
 
610
611
  return `## Recent Context
611
612
  ${mergedLines.join("\n")}
612
- -- DON'T repeat yourself or bring up old topics - focus on what's being said right now. --`;
613
+ -- DON'T repeat yourself or bring up old topics - focus on what's being said right now. --
614
+ -- Use [reply:message_id] with the #id shown after a message to quote-reply it. --`;
613
615
  }
614
616
 
615
617
  function buildTargetMessageSection(
package/core/tools/web.ts CHANGED
@@ -72,6 +72,12 @@ async function fetchGroupHistoryByMessageIdPaging(
72
72
  const bot = toolCtx.event?.bot;
73
73
  if (!bot) return [];
74
74
 
75
+ const otherBotIds = new Set<string>(
76
+ toolCtx.ctx.bots
77
+ .map((b) => String(b.bot_id))
78
+ .filter((id) => id !== String(bot.bot_id)),
79
+ );
80
+
75
81
  const collected: ChatMessage[] = [];
76
82
  const seenMessageIds = new Set<string>();
77
83
  let cursorMessageId = "";
@@ -113,6 +119,9 @@ async function fetchGroupHistoryByMessageIdPaging(
113
119
  let newAdded = 0;
114
120
 
115
121
  for (const raw of history) {
122
+ const rawUserId = String(raw.user_id || "");
123
+ if (rawUserId !== "" && otherBotIds.has(rawUserId)) continue;
124
+
116
125
  const messageId = String(raw.message_id || "");
117
126
  const key =
118
127
  messageId !== ""
@@ -134,7 +143,7 @@ async function fetchGroupHistoryByMessageIdPaging(
134
143
  userRole: "member",
135
144
  groupId: toolCtx.groupId,
136
145
  timestamp: ts,
137
- messageId: messageId !== "" ? Number(messageId) : undefined,
146
+ messageId: messageId !== "" ? messageId : undefined,
138
147
  });
139
148
  newAdded += 1;
140
149
 
@@ -156,7 +165,7 @@ async function fetchGroupHistoryByMessageIdPaging(
156
165
 
157
166
  collected.sort((a, b) => {
158
167
  if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
159
- return (a.messageId || 0) - (b.messageId || 0);
168
+ return String(a.messageId ?? "").localeCompare(String(b.messageId ?? ""));
160
169
  });
161
170
 
162
171
  return collected.length > limit ? collected.slice(-limit) : collected;
package/db.ts CHANGED
@@ -24,7 +24,7 @@ function toChatMessage(row: any): ChatMessage {
24
24
  groupId: row.group_id,
25
25
  groupName: row.group_name,
26
26
  timestamp: row.timestamp,
27
- messageId: row.message_id,
27
+ messageId: row.message_id != null ? String(row.message_id) : undefined,
28
28
  };
29
29
  }
30
30
 
@@ -174,7 +174,7 @@ export async function initDatabase(): Promise<ChatDatabase> {
174
174
  group_id INTEGER,
175
175
  group_name TEXT,
176
176
  timestamp INTEGER NOT NULL,
177
- message_id INTEGER
177
+ message_id TEXT
178
178
  );
179
179
 
180
180
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
@@ -452,7 +452,10 @@ export async function initDatabase(): Promise<ChatDatabase> {
452
452
  $groupId: msg.groupId ?? null,
453
453
  $groupName: msg.groupName ?? null,
454
454
  $timestamp: msg.timestamp,
455
- $messageId: msg.messageId ?? null,
455
+ $messageId:
456
+ msg.messageId == null || msg.messageId === ""
457
+ ? null
458
+ : String(msg.messageId),
456
459
  });
457
460
  },
458
461
 
@@ -64,7 +64,7 @@ export async function handleIdleCheckDebug(
64
64
  userId: 0,
65
65
  userRole: "member",
66
66
  content: "[No one in the group is talking? I'll answer!]",
67
- messageId: 0,
67
+ messageId: "",
68
68
  timestamp: now,
69
69
  };
70
70
  const botRole = await getBotRole(targetGroupId, ctx, e.self_id);
@@ -27,7 +27,7 @@ interface CooldownMessage {
27
27
  content: string;
28
28
  userName: string;
29
29
  userId: number;
30
- messageId: number;
30
+ messageId: string;
31
31
  timestamp: number;
32
32
  isDirectAt: boolean;
33
33
  }
@@ -8,7 +8,7 @@ export interface StructuredUserInput {
8
8
  userRole?: string;
9
9
  userTitle?: string;
10
10
  content: string;
11
- messageId?: number;
11
+ messageId?: string;
12
12
  timestamp: number;
13
13
  }
14
14
 
@@ -237,7 +237,7 @@ export class IdleCheckManager {
237
237
  userId: 0,
238
238
  userRole: "member",
239
239
  content: "[No one in the group is talking? I'll answer!]",
240
- messageId: 0,
240
+ messageId: "",
241
241
  timestamp: scheduledAt,
242
242
  };
243
243
  const botRole = await getBotRole(groupId, this.ctx, selfId);
@@ -25,7 +25,7 @@ import { finalizeChatTurn } from "../core/chat-turn";
25
25
  import { extractContent, getBotRole } from "../utils";
26
26
 
27
27
  interface DynamicDelayQueueData {
28
- messages: Array<{ event: any; content: string; userName: string; userId: number; messageId: number; timestamp: number }>;
28
+ messages: Array<{ event: any; content: string; userName: string; userId: number; messageId: string; timestamp: number }>;
29
29
  timer: NodeJS.Timeout | null;
30
30
  delayUntil: number;
31
31
  }
package/manage/types.ts CHANGED
@@ -66,7 +66,7 @@ export type GetHumanizeContexts = (
66
66
  export type SendAIResponse = (
67
67
  options: SendAIResponseOptions,
68
68
  selfId: number,
69
- ) => Promise<void>;
69
+ ) => Promise<Array<string | undefined>>;
70
70
 
71
71
  export type SaveBotMessages = (
72
72
  groupId: number,
@@ -77,6 +77,7 @@ export type SaveBotMessages = (
77
77
  db: ChatDatabase,
78
78
  ctx: MiokuContext,
79
79
  bot?: Bot,
80
+ sentMessageIds?: Array<string | undefined>,
80
81
  ) => void;
81
82
 
82
83
  export type SendEmoji = (
@@ -146,7 +147,7 @@ export type PromptCtxForRunChat = {
146
147
  reviewMessages?: {
147
148
  contents: string[];
148
149
  userNames: string[];
149
- messageIds: number[];
150
+ messageIds: string[];
150
151
  };
151
152
  promptInjections?: ChatRuntimePromptInjection[];
152
153
  emojiAgent?: EmojiAgent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -270,7 +270,7 @@ export interface ChatMessage {
270
270
  groupId?: number;
271
271
  groupName?: string;
272
272
  timestamp: number;
273
- messageId?: number; // QQ message_id
273
+ messageId?: string; // 消息 id
274
274
  }
275
275
 
276
276
  /**
@@ -282,7 +282,7 @@ export interface TargetMessage {
282
282
  userRole: string;
283
283
  userTitle?: string;
284
284
  content: string;
285
- messageId?: number;
285
+ messageId?: string;
286
286
  timestamp: number;
287
287
  }
288
288
 
@@ -336,7 +336,7 @@ export interface ChatResult {
336
336
  messages: string[];
337
337
  pendingAt: number[];
338
338
  pendingPoke: number[];
339
- pendingQuote?: number;
339
+ pendingQuote?: string;
340
340
  toolCalls: { name: string; args: any; result: any }[];
341
341
  emojiPath?: string | null;
342
342
  protocolMessages?: ChatCompletionMessageParam[];
package/utils/message.ts CHANGED
@@ -56,7 +56,7 @@ export async function isQuotingBot(
56
56
  return null;
57
57
  }
58
58
  } catch (err) {
59
- logger.error(err);
59
+ ctx.logger.error(err);
60
60
  }
61
61
  }
62
62
  return null;
@@ -214,7 +214,7 @@ interface FormattedHistoryMessage {
214
214
  userName: string;
215
215
  userRole: string;
216
216
  content: string;
217
- messageId: number;
217
+ messageId: string;
218
218
  timestamp: number;
219
219
  role: "user" | "assistant";
220
220
  }
@@ -229,6 +229,8 @@ interface HistoryFormatContext {
229
229
  | undefined;
230
230
  historyMediaOptions: HistoryMediaProcessingOptions;
231
231
  botUin: number;
232
+ /** 本运行时全部已连接 bot 的 id:这些账号的消息一律不作为用户历史(防 bot 互触循环) */
233
+ botIds: Set<string>;
232
234
  memberNameCache: Map<string, string>;
233
235
  hashLookupCache: Map<string, string | null>;
234
236
  }
@@ -258,6 +260,8 @@ async function resolveMemberName(
258
260
  }
259
261
 
260
262
  function extractQuotedText(messageSegs: any): string {
263
+ // icqq 的 source.message 是 brief 字符串;onebot 是 segment 数组
264
+ if (typeof messageSegs === "string") return messageSegs.trim();
261
265
  if (!Array.isArray(messageSegs) || messageSegs.length === 0) return "";
262
266
  const parts: string[] = [];
263
267
  for (const seg of messageSegs) {
@@ -347,7 +351,10 @@ async function formatHistoryMessage(
347
351
  msg: HistoryMessage,
348
352
  ctx: HistoryFormatContext,
349
353
  ): Promise<FormattedHistoryMessage | null> {
354
+ // 自己或本运行时其它 bot 的消息不作为用户历史,
355
+ // 否则多 bot 同群时会把彼此的消息当作用户接话,形成循环触发
350
356
  if (String(msg.user_id) === String(ctx.botUin)) return null;
357
+ if (ctx.botIds.has(String(msg.user_id))) return null;
351
358
 
352
359
  let content = "";
353
360
  try {
@@ -364,7 +371,10 @@ async function formatHistoryMessage(
364
371
  userName: msg.nickname || String(msg.user_id || "unknown"),
365
372
  userRole: "member",
366
373
  content,
367
- messageId: Number(msg.message_id) || 0,
374
+ messageId:
375
+ msg.message_id != null && msg.message_id !== ""
376
+ ? String(msg.message_id)
377
+ : "",
368
378
  timestamp: msg.time ?? Date.now(),
369
379
  role: "user",
370
380
  };
@@ -524,7 +534,7 @@ export async function getGroupHistory(
524
534
  userName: string;
525
535
  userRole: string;
526
536
  content: string;
527
- messageId: number;
537
+ messageId: string;
528
538
  timestamp: number;
529
539
  role: "user" | "assistant";
530
540
  }>
@@ -535,7 +545,7 @@ export async function getGroupHistory(
535
545
  userName: string;
536
546
  userRole: string;
537
547
  content: string;
538
- messageId: number;
548
+ messageId: string;
539
549
  timestamp: number;
540
550
  role: "user" | "assistant";
541
551
  }> = [];
@@ -548,7 +558,7 @@ export async function getGroupHistory(
548
558
  userName: msg.userName || "Miku",
549
559
  userRole: msg.userRole || "member",
550
560
  content: msg.content,
551
- messageId: msg.messageId ?? 0,
561
+ messageId: msg.messageId ?? "",
552
562
  timestamp: msg.timestamp,
553
563
  role: "assistant",
554
564
  });
@@ -562,7 +572,7 @@ export async function getGroupHistory(
562
572
  userName: msg.userName || String(msg.userId || "unknown"),
563
573
  userRole: msg.userRole || "member",
564
574
  content: msg.content,
565
- messageId: msg.messageId ?? 0,
575
+ messageId: msg.messageId ?? "",
566
576
  timestamp: msg.timestamp,
567
577
  role: "user",
568
578
  });
@@ -596,6 +606,8 @@ export async function getGroupHistory(
596
606
  : [];
597
607
 
598
608
  const botUin = selfId;
609
+ const botIds = new Set<string>(ctx.bots.map((b) => String(b.bot_id)));
610
+ botIds.add(String(botUin));
599
611
  const memberNameCache = new Map<string, string>();
600
612
  const hashLookupCache = new Map<string, string | null>();
601
613
 
@@ -611,6 +623,7 @@ export async function getGroupHistory(
611
623
  db,
612
624
  historyMediaOptions,
613
625
  botUin,
626
+ botIds,
614
627
  memberNameCache,
615
628
  hashLookupCache,
616
629
  };
package/utils/queue.ts CHANGED
@@ -100,7 +100,7 @@ export class MessageQueueManager {
100
100
  * 例如 "[reply:1]文字A[reply:2]文字B" → ["[reply:1]文字A", "[reply:2]文字B"]
101
101
  */
102
102
  export function splitByReplyMarkers(line: string): string[] {
103
- const parts = line.split(/(?=\[reply:-?\d+\])/);
103
+ const parts = line.split(/(?=\[reply:[^\]\n]+\])/);
104
104
  return parts.filter((p) => p.trim());
105
105
  }
106
106
 
@@ -116,12 +116,12 @@ export function parseLineMarkers(
116
116
  cleanText: string;
117
117
  atUsers: number[];
118
118
  pokeUsers: number[];
119
- quoteId?: number;
119
+ quoteId?: string;
120
120
  audioText?: string;
121
121
  } {
122
122
  const atUsers: number[] = [];
123
123
  const pokeUsers: number[] = [];
124
- let quoteId: number | undefined;
124
+ let quoteId: string | undefined;
125
125
  let audioText: string | undefined;
126
126
 
127
127
  // 提取 AT 标记
@@ -146,12 +146,13 @@ export function parseLineMarkers(
146
146
 
147
147
  // 提取引用标记(仅在允许时)
148
148
  if (quoteMode !== "skip") {
149
- const replyPatterns = [/\[reply:(-?\d+)\]/g];
149
+ const replyPatterns = [/\[reply:([^\]\n]+)\]/g];
150
150
  for (const pattern of replyPatterns) {
151
151
  const matches = [...line.matchAll(pattern)];
152
152
  for (const match of matches) {
153
153
  if (quoteId === undefined) {
154
- quoteId = parseInt(match[1], 10);
154
+ const value = match[1].trim();
155
+ if (value) quoteId = value;
155
156
  }
156
157
  }
157
158
  }
@@ -170,7 +171,7 @@ export function parseLineMarkers(
170
171
  let cleanText = line
171
172
  .replace(/\[at:\d+\]/g, "")
172
173
  .replace(/\[poke:\d+\]/g, "")
173
- .replace(/\[reply:-?\d+\]/g, "")
174
+ .replace(/\[reply:[^\]\n]+\]/g, "")
174
175
  .replace(/\[audio:[^\]]+\]/gi, "")
175
176
  .trim();
176
177