@chbo297/infoflow 2026.2.27 → 2026.2.28

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/src/bot.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  import { resolveInfoflowAccount } from "./accounts.js";
2
- import { getInfoflowBotLog } from "./logging.js";
2
+ import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
3
3
  import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
4
4
  import { getInfoflowRuntime } from "./runtime.js";
5
5
  import type {
6
6
  InfoflowChatType,
7
7
  InfoflowMessageEvent,
8
+ InfoflowMentionIds,
9
+ InfoflowReplyMode,
10
+ InfoflowGroupConfig,
8
11
  HandleInfoflowMessageParams,
9
12
  HandlePrivateChatParams,
10
13
  HandleGroupChatParams,
14
+ ResolvedInfoflowAccount,
11
15
  } from "./types.js";
12
16
 
13
17
  // Re-export types for external consumers
@@ -19,34 +23,249 @@ export type { InfoflowChatType, InfoflowMessageEvent } from "./types.js";
19
23
 
20
24
  /**
21
25
  * Body item in Infoflow group message, supporting TEXT, AT, LINK types.
26
+ * For AT items: robot mentions have `robotid` (number), human mentions have `userid` (string).
27
+ * These two fields are mutually exclusive.
22
28
  */
23
29
  type InfoflowBodyItem = {
24
30
  type?: string;
25
31
  content?: string;
26
32
  label?: string;
27
- /** Robot ID when type is AT */
33
+ /** 机器人 AT 时有此字段(数字),与 userid 互斥 */
28
34
  robotid?: number;
29
- /** Robot/user name when type is AT */
35
+ /** AT 元素的显示名称 */
30
36
  name?: string;
37
+ /** 人类用户 AT 时有此字段(uuap name),与 robotid 互斥 */
38
+ userid?: string;
31
39
  };
32
40
 
33
41
  /**
34
42
  * Check if the bot was @mentioned in the message body.
35
- * Matches configured robotName against AT elements (case-insensitive).
43
+ * Matches by robotName against the AT item's display name (case-insensitive).
36
44
  */
37
45
  function checkBotMentioned(bodyItems: InfoflowBodyItem[], robotName?: string): boolean {
38
- if (!robotName) {
39
- return false; // Cannot detect mentions without configured robotName
40
- }
46
+ if (!robotName) return false;
41
47
  const normalizedRobotName = robotName.toLowerCase();
42
48
  for (const item of bodyItems) {
43
- if (item.type === "AT" && item.name) {
44
- if (item.name.toLowerCase() === normalizedRobotName) {
45
- return true;
49
+ if (item.type !== "AT") continue;
50
+ if (item.name?.toLowerCase() === normalizedRobotName) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * Check if any entry in the watchlist was @mentioned in the message body.
57
+ * Matching priority: userid > robotid (parsed as number) > name (fallback).
58
+ * Returns the matched ID (from watchMentions), or undefined if none matched.
59
+ */
60
+ function checkWatchMentioned(
61
+ bodyItems: InfoflowBodyItem[],
62
+ watchMentions: string[],
63
+ ): string | undefined {
64
+ if (!watchMentions.length) return undefined;
65
+ const normalizedIds = watchMentions.map((n) => n.toLowerCase());
66
+ // Pre-parse numeric entries for robotid matching
67
+ const numericIds = watchMentions.map((n) => {
68
+ const num = Number(n);
69
+ return Number.isFinite(num) ? num : null;
70
+ });
71
+
72
+ for (const item of bodyItems) {
73
+ if (item.type !== "AT") continue;
74
+
75
+ // Priority 1: match userid (human AT)
76
+ if (item.userid) {
77
+ const idx = normalizedIds.indexOf(item.userid.toLowerCase());
78
+ if (idx !== -1) return watchMentions[idx];
79
+ }
80
+
81
+ // Priority 2: match robotid (robot AT, watchMentions entry parsed as number)
82
+ if (item.robotid != null) {
83
+ const idx = numericIds.indexOf(item.robotid);
84
+ if (idx !== -1) return watchMentions[idx];
85
+ }
86
+
87
+ // Priority 3: match by display name (fallback to name-based lookup)
88
+ if (item.name) {
89
+ const idx = normalizedIds.indexOf(item.name.toLowerCase());
90
+ if (idx !== -1) return watchMentions[idx];
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Extract non-bot mention IDs from inbound group message body items.
98
+ * Returns human userIds and robot agentIds (excluding the bot itself, matched by robotName).
99
+ */
100
+ function extractMentionIds(bodyItems: InfoflowBodyItem[], robotName?: string): InfoflowMentionIds {
101
+ const normalizedRobotName = robotName?.toLowerCase();
102
+ const userIds: string[] = [];
103
+ const agentIds: number[] = [];
104
+ const seenUsers = new Set<string>();
105
+ const seenAgents = new Set<number>();
106
+
107
+ for (const item of bodyItems) {
108
+ if (item.type !== "AT") continue;
109
+
110
+ if (item.robotid != null) {
111
+ // Skip the bot itself (matched by name)
112
+ if (normalizedRobotName && item.name?.toLowerCase() === normalizedRobotName) continue;
113
+ if (!seenAgents.has(item.robotid)) {
114
+ seenAgents.add(item.robotid);
115
+ agentIds.push(item.robotid);
116
+ }
117
+ } else if (item.userid) {
118
+ const key = item.userid.toLowerCase();
119
+ if (!seenUsers.has(key)) {
120
+ seenUsers.add(key);
121
+ userIds.push(item.userid);
46
122
  }
47
123
  }
48
124
  }
49
- return false;
125
+ return { userIds, agentIds };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Shared reply judgment rules (reused across prompt builders)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** Shared judgment rules and reply format requirements for all conditional-reply prompts */
133
+ function buildReplyJudgmentRules(): string {
134
+ return [
135
+ "# Rules",
136
+ "",
137
+ "## Can answer or help → Reply directly",
138
+ "",
139
+ "Reply if ANY of these apply:",
140
+ "- The question can be answered through common sense or logical reasoning (e.g. math, general knowledge)",
141
+ "- You can find relevant clues or content in your knowledge base, documentation, or code",
142
+ "- You have sufficient domain expertise to provide a valuable reference",
143
+ "",
144
+ "## Cannot answer → Reply with NO_REPLY only",
145
+ "",
146
+ "Do NOT reply if ANY of these apply:",
147
+ "- The message contains no clear question or request (e.g. casual chat, meaningless content)",
148
+ "- The question involves private information or context you have no knowledge of",
149
+ "- You cannot understand the core intent of the message",
150
+ "",
151
+ "# Response format",
152
+ "",
153
+ "- When you can answer: give a direct, concise answer. Do not explain why you chose to answer.",
154
+ "- When you cannot answer: output only NO_REPLY with no other text.",
155
+ ].join("\n");
156
+ }
157
+
158
+ /**
159
+ * Build a GroupSystemPrompt for watch-mention triggered messages.
160
+ * Instructs the agent to reply only when confident, otherwise use NO_REPLY.
161
+ */
162
+ function buildWatchMentionPrompt(mentionedId: string): string {
163
+ return [
164
+ `Someone in the group @mentioned ${mentionedId}. As ${mentionedId}'s assistant, you observed this message.`,
165
+ "Decide whether you can answer on their behalf or provide help.",
166
+ "",
167
+ buildReplyJudgmentRules(),
168
+ "",
169
+ "# Examples",
170
+ "",
171
+ 'Message: "What is 1+1?"',
172
+ "→ 2",
173
+ "",
174
+ 'Message: "What is the qt parameter for search requests in the client code?"',
175
+ "(Assuming documentation records qt=s)",
176
+ "→ According to the documentation, the qt parameter for search requests is qt=s",
177
+ "",
178
+ 'Message: "asdfghjkl random gibberish"',
179
+ "→ NO_REPLY",
180
+ "",
181
+ 'Message: "Can you check today\'s release progress?"',
182
+ "(Assuming no relevant information available)",
183
+ "→ NO_REPLY",
184
+ ].join("\n");
185
+ }
186
+
187
+ /**
188
+ * Build a GroupSystemPrompt for follow-up replies after bot's last response.
189
+ * Instructs the agent to reply only if the message is a follow-up on the same topic.
190
+ */
191
+ function buildFollowUpPrompt(): string {
192
+ return [
193
+ "You just replied to a message in this group. Someone has now sent a new message.",
194
+ "First determine if this message is a follow-up or continuation of the same topic you previously replied to, then decide if you can continue to help.",
195
+ "",
196
+ "Note: If this message is clearly a new topic or unrelated to your previous reply, respond with NO_REPLY.",
197
+ "",
198
+ buildReplyJudgmentRules(),
199
+ ].join("\n");
200
+ }
201
+
202
+ /**
203
+ * Build a GroupSystemPrompt for proactive mode.
204
+ * Instructs the agent to think about the message and reply when helpful.
205
+ */
206
+ function buildProactivePrompt(): string {
207
+ return [
208
+ "You observed this message in the group. Decide whether you can provide help or a valuable reply.",
209
+ "If you need more context or clarification, you may ask follow-up questions.",
210
+ "",
211
+ buildReplyJudgmentRules(),
212
+ ].join("\n");
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Group reply tracking (in-memory) for follow-up window
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /** In-memory map tracking bot's last reply timestamp per group */
220
+ const groupLastReplyMap = new Map<string, number>();
221
+
222
+ /** Record that the bot replied to a group (called after successful send) */
223
+ export function recordGroupReply(groupId: string): void {
224
+ groupLastReplyMap.set(groupId, Date.now());
225
+ }
226
+
227
+ /** Check if a group is within the follow-up window */
228
+ function isWithinFollowUpWindow(groupId: string, windowSeconds: number): boolean {
229
+ const lastReply = groupLastReplyMap.get(groupId);
230
+ if (!lastReply) return false;
231
+ return Date.now() - lastReply < windowSeconds * 1000;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Group config resolution
236
+ // ---------------------------------------------------------------------------
237
+
238
+ type ResolvedGroupConfig = {
239
+ replyMode: InfoflowReplyMode;
240
+ followUp: boolean;
241
+ followUpWindow: number;
242
+ watchMentions: string[];
243
+ systemPrompt?: string;
244
+ };
245
+
246
+ /** Infer replyMode from legacy requireMention + watchMentions fields */
247
+ function inferLegacyReplyMode(account: ResolvedInfoflowAccount): InfoflowReplyMode {
248
+ const requireMention = account.config.requireMention !== false;
249
+ const hasWatch = (account.config.watchMentions ?? []).length > 0;
250
+ if (!requireMention) return "proactive";
251
+ if (hasWatch) return "mention-and-watch";
252
+ return "mention-only";
253
+ }
254
+
255
+ /** Resolve effective group config by merging group-level → account-level → legacy defaults */
256
+ function resolveGroupConfig(
257
+ account: ResolvedInfoflowAccount,
258
+ groupId?: number,
259
+ ): ResolvedGroupConfig {
260
+ const groupCfg: InfoflowGroupConfig | undefined =
261
+ groupId != null ? account.config.groups?.[String(groupId)] : undefined;
262
+ return {
263
+ replyMode: groupCfg?.replyMode ?? account.config.replyMode ?? inferLegacyReplyMode(account),
264
+ followUp: groupCfg?.followUp ?? account.config.followUp ?? true,
265
+ followUpWindow: groupCfg?.followUpWindow ?? account.config.followUpWindow ?? 300,
266
+ watchMentions: groupCfg?.watchMentions ?? account.config.watchMentions ?? [],
267
+ systemPrompt: groupCfg?.systemPrompt,
268
+ };
50
269
  }
51
270
 
52
271
  /**
@@ -55,8 +274,6 @@ function checkBotMentioned(bodyItems: InfoflowBodyItem[], robotName?: string): b
55
274
  */
56
275
  export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
57
276
  const { cfg, msgData, accountId, statusSink } = params;
58
- const core = getInfoflowRuntime();
59
- const verbose = core.logging.shouldLogVerbose();
60
277
 
61
278
  // Extract sender and content from msgData (flexible field names)
62
279
  const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
@@ -73,11 +290,9 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
73
290
  const createTime = msgData.CreateTime ?? msgData.createtime;
74
291
  const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
75
292
 
76
- if (verbose) {
77
- getInfoflowBotLog().debug?.(
78
- `[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, raw msgData: ${JSON.stringify(msgData)}`,
79
- );
80
- }
293
+ logVerbose(
294
+ `[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, raw msgData: ${JSON.stringify(msgData)}`,
295
+ );
81
296
 
82
297
  if (!fromuser || !mes.trim()) {
83
298
  return;
@@ -105,8 +320,6 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
105
320
  */
106
321
  export async function handleGroupChatMessage(params: HandleGroupChatParams): Promise<void> {
107
322
  const { cfg, msgData, accountId, statusSink } = params;
108
- const core = getInfoflowRuntime();
109
- const verbose = core.logging.shouldLogVerbose();
110
323
 
111
324
  // Extract sender from nested structure or flat fields
112
325
  const header = (msgData.message as Record<string, unknown>)?.header as
@@ -126,11 +339,9 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
126
339
  const rawTime = msgData.time ?? header?.servertime;
127
340
  const timestamp = rawTime != null ? Number(rawTime) : Date.now();
128
341
 
129
- if (verbose) {
130
- getInfoflowBotLog().debug?.(
131
- `[infoflow] group chat: fromuser=${fromuser}, groupid=${groupid}, raw msgData: ${JSON.stringify(msgData)}`,
132
- );
133
- }
342
+ logVerbose(
343
+ `[infoflow] group chat: fromuser=${fromuser}, groupid=${groupid}, raw msgData: ${JSON.stringify(msgData)}`,
344
+ );
134
345
 
135
346
  if (!fromuser) {
136
347
  return;
@@ -144,9 +355,12 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
144
355
  const account = resolveInfoflowAccount({ cfg, accountId });
145
356
  const robotName = account.config.robotName;
146
357
 
147
- // Check if bot was @mentioned
358
+ // Check if bot was @mentioned (by robotName)
148
359
  const wasMentioned = checkBotMentioned(bodyItems, robotName);
149
360
 
361
+ // Extract non-bot mention IDs (userIds + agentIds) for LLM-driven @mentions
362
+ const mentionIds = extractMentionIds(bodyItems, robotName);
363
+
150
364
  // Build two versions: mes (for CommandBody, no @xxx) and rawMes (for RawBody, with @xxx)
151
365
  let textContent = "";
152
366
  let rawTextContent = "";
@@ -194,6 +408,9 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
194
408
  wasMentioned,
195
409
  messageId: messageIdStr,
196
410
  timestamp,
411
+ bodyItems,
412
+ mentionIds:
413
+ mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
197
414
  },
198
415
  accountId,
199
416
  statusSink,
@@ -210,18 +427,19 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
210
427
 
211
428
  const account = resolveInfoflowAccount({ cfg, accountId });
212
429
  const core = getInfoflowRuntime();
213
- const verbose = core.logging.shouldLogVerbose();
214
-
215
- if (verbose) {
216
- getInfoflowBotLog().debug?.(
217
- `[infoflow] handleInfoflowMessage invoked: accountId=${accountId}, chatType=${event.chatType}, fromuser=${event.fromuser}, groupId=${event.groupId}`,
218
- );
219
- }
220
430
 
221
431
  const isGroup = chatType === "group";
222
432
  // Convert groupId (number) to string for peerId since routing expects string
223
433
  const peerId = isGroup ? (groupId !== undefined ? String(groupId) : fromuser) : fromuser;
224
434
 
435
+ // Resolve per-group config for replyMode gating
436
+ const groupCfg = isGroup ? resolveGroupConfig(account, groupId) : undefined;
437
+
438
+ // "ignore" mode: discard immediately, no save, no think, no reply
439
+ if (isGroup && groupCfg?.replyMode === "ignore") {
440
+ return;
441
+ }
442
+
225
443
  // Resolve route based on chat type
226
444
  const route = core.channel.routing.resolveAgentRoute({
227
445
  cfg,
@@ -247,12 +465,6 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
247
465
  const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
248
466
  const toAddress = isGroup ? `infoflow:${groupId}` : `infoflow:${account.accountId}`;
249
467
 
250
- if (verbose) {
251
- getInfoflowBotLog().debug?.(
252
- `[infoflow] dispatch: chatType=${chatType}, agentId=${route.agentId}`,
253
- );
254
- }
255
-
256
468
  const body = core.channel.reply.formatAgentEnvelope({
257
469
  channel: "Infoflow",
258
470
  from: fromLabel,
@@ -291,31 +503,120 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
291
503
  sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
292
504
  ctx: ctxPayload,
293
505
  onRecordError: (err) => {
294
- getInfoflowBotLog().error(`[infoflow] failed updating session meta: ${String(err)}`);
506
+ getInfoflowBotLog().error(
507
+ `[infoflow] failed updating session meta (sessionKey=${route.sessionKey}, accountId=${accountId}): ${formatInfoflowError(err)}`,
508
+ );
295
509
  },
296
510
  });
297
511
 
298
- // Mention gating: skip reply if requireMention is enabled and bot was not mentioned
512
+ // Reply mode gating for group messages
299
513
  // Session is already recorded above for context history
300
- if (isGroup) {
301
- const requireMention = account.config.requireMention !== false;
514
+ if (isGroup && groupCfg) {
515
+ const { replyMode } = groupCfg;
516
+ const groupIdStr = groupId !== undefined ? String(groupId) : undefined;
517
+
518
+ // "record" mode: save to session only, no think, no reply
519
+ if (replyMode === "record") {
520
+ return;
521
+ }
522
+
302
523
  const canDetectMention = Boolean(account.config.robotName);
303
524
  const wasMentioned = event.wasMentioned === true;
304
525
 
305
- if (requireMention && canDetectMention && !wasMentioned) {
306
- return;
526
+ if (replyMode === "mention-only") {
527
+ // Only reply if bot was @mentioned
528
+ const shouldReply = canDetectMention && wasMentioned;
529
+ if (!shouldReply) {
530
+ // Check follow-up window: if bot recently replied, allow LLM to decide
531
+ if (
532
+ groupCfg.followUp &&
533
+ groupIdStr &&
534
+ isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
535
+ ) {
536
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
537
+ } else {
538
+ return;
539
+ }
540
+ }
541
+ } else if (replyMode === "mention-and-watch") {
542
+ // Reply if bot @mentioned, or if watched person @mentioned, or follow-up
543
+ const botMentioned = canDetectMention && wasMentioned;
544
+ if (!botMentioned) {
545
+ // Check watch-mention
546
+ const watchMentions = groupCfg.watchMentions;
547
+ const matchedWatchId =
548
+ watchMentions.length > 0 && event.bodyItems
549
+ ? checkWatchMentioned(event.bodyItems, watchMentions)
550
+ : undefined;
551
+
552
+ if (matchedWatchId) {
553
+ // Watch-mention triggered: instruct agent to reply only if confident
554
+ ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
555
+ } else if (
556
+ groupCfg.followUp &&
557
+ groupIdStr &&
558
+ isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
559
+ ) {
560
+ // Follow-up window: let LLM decide if this is a follow-up
561
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
562
+ } else {
563
+ return;
564
+ }
565
+ }
566
+ } else if (replyMode === "proactive") {
567
+ // Always think and potentially reply
568
+ const botMentioned = canDetectMention && wasMentioned;
569
+ if (!botMentioned) {
570
+ // Check watch-mention first (higher priority prompt)
571
+ const watchMentions = groupCfg.watchMentions;
572
+ const matchedWatchId =
573
+ watchMentions.length > 0 && event.bodyItems
574
+ ? checkWatchMentioned(event.bodyItems, watchMentions)
575
+ : undefined;
576
+ if (matchedWatchId) {
577
+ ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
578
+ } else {
579
+ ctxPayload.GroupSystemPrompt = buildProactivePrompt();
580
+ }
581
+ }
582
+ }
583
+
584
+ // Inject per-group systemPrompt (append, don't replace)
585
+ if (groupCfg.systemPrompt) {
586
+ const existing = ctxPayload.GroupSystemPrompt ?? "";
587
+ ctxPayload.GroupSystemPrompt = existing
588
+ ? `${existing}\n\n---\n\n${groupCfg.systemPrompt}`
589
+ : groupCfg.systemPrompt;
307
590
  }
308
591
  }
309
592
 
310
593
  // Build unified target: "group:<id>" for group chat, username for private chat
311
594
  const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
312
595
 
596
+ // Provide mention context to the LLM so it can decide who to @mention
597
+ if (isGroup && event.mentionIds) {
598
+ const parts: string[] = [];
599
+ if (event.mentionIds.userIds.length > 0) {
600
+ parts.push(`User IDs: ${event.mentionIds.userIds.join(", ")}`);
601
+ }
602
+ if (event.mentionIds.agentIds.length > 0) {
603
+ parts.push(`Bot IDs: ${event.mentionIds.agentIds.join(", ")}`);
604
+ }
605
+ if (parts.length > 0) {
606
+ ctxPayload.Body += `\n\n[System: @mentioned in group: ${parts.join("; ")}. To @mention someone in your reply, use the @id format]`;
607
+ }
608
+ }
609
+
313
610
  const { dispatcherOptions, replyOptions } = createInfoflowReplyDispatcher({
314
611
  cfg,
315
612
  agentId: route.agentId,
316
613
  accountId: account.accountId,
317
614
  to,
318
615
  statusSink,
616
+ // @mention the sender back when bot was directly @mentioned in a group
617
+ atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
618
+ // Pass mention IDs for LLM-driven @mention resolution in outbound text
619
+ mentionIds: isGroup ? event.mentionIds : undefined,
319
620
  });
320
621
 
321
622
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
@@ -325,9 +626,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
325
626
  replyOptions,
326
627
  });
327
628
 
328
- if (verbose) {
329
- getInfoflowBotLog().debug?.(`[infoflow] dispatch complete: ${chatType} from ${fromuser}`);
629
+ // Record bot reply timestamp for follow-up window tracking
630
+ if (isGroup && groupId !== undefined) {
631
+ recordGroupReply(String(groupId));
330
632
  }
633
+
634
+ logVerbose(
635
+ `[infoflow] dispatch complete: ${chatType} from ${fromuser}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
636
+ );
331
637
  }
332
638
 
333
639
  // ---------------------------------------------------------------------------
@@ -336,3 +642,9 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
336
642
 
337
643
  /** @internal — Check if bot was mentioned in message body. Only exported for tests. */
338
644
  export const _checkBotMentioned = checkBotMentioned;
645
+
646
+ /** @internal — Check if any watch-list name was @mentioned. Only exported for tests. */
647
+ export const _checkWatchMentioned = checkWatchMentioned;
648
+
649
+ /** @internal — Extract non-bot mention IDs. Only exported for tests. */
650
+ export const _extractMentionIds = extractMentionIds;
package/src/channel.ts CHANGED
@@ -15,7 +15,8 @@ import {
15
15
  resolveDefaultInfoflowAccountId,
16
16
  resolveInfoflowAccount,
17
17
  } from "./accounts.js";
18
- import { getInfoflowSendLog } from "./logging.js";
18
+ import { infoflowMessageActions } from "./actions.js";
19
+ import { logVerbose } from "./logging.js";
19
20
  import { startInfoflowMonitor } from "./monitor.js";
20
21
  import { getInfoflowRuntime } from "./runtime.js";
21
22
  import { sendInfoflowMessage } from "./send.js";
@@ -45,6 +46,12 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
45
46
  nativeCommands: true,
46
47
  },
47
48
  reload: { configPrefixes: ["channels.infoflow"] },
49
+ actions: infoflowMessageActions,
50
+ agentPrompt: {
51
+ messageToolHints: () => [
52
+ 'Infoflow group @mentions: set atAll=true to @all members, or mentionUserIds="user1,user2" (comma-separated uuapName) to @mention specific users. Only effective for group targets (group:<id>).',
53
+ ],
54
+ },
48
55
  config: {
49
56
  listAccountIds: (cfg) => listInfoflowAccountIds(cfg),
50
57
  resolveAccount: (cfg, accountId) => resolveInfoflowAccount({ cfg, accountId }),
@@ -200,10 +207,7 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
200
207
  textChunkLimit: 4000,
201
208
  chunker: (text, limit) => getInfoflowRuntime().channel.text.chunkText(text, limit),
202
209
  sendText: async ({ cfg, to, text, accountId }) => {
203
- const verbose = getInfoflowRuntime().logging.shouldLogVerbose();
204
- if (verbose) {
205
- getInfoflowSendLog().debug?.(`[infoflow:sendText] to=${to}, accountId=${accountId}`);
206
- }
210
+ logVerbose(`[infoflow:sendText] to=${to}, accountId=${accountId}`);
207
211
  // Use "markdown" type even though param is named `text`: LLM outputs are often markdown,
208
212
  // and Infoflow's markdown type handles both plain text and markdown seamlessly.
209
213
  const result = await sendInfoflowMessage({
@@ -218,12 +222,7 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
218
222
  };
219
223
  },
220
224
  sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
221
- const verbose = getInfoflowRuntime().logging.shouldLogVerbose();
222
- if (verbose) {
223
- getInfoflowSendLog().debug?.(
224
- `[infoflow:sendMedia] to=${to}, accountId=${accountId}, mediaUrl=${mediaUrl}`,
225
- );
226
- }
225
+ logVerbose(`[infoflow:sendMedia] to=${to}, accountId=${accountId}, mediaUrl=${mediaUrl}`);
227
226
 
228
227
  // Build contents array: text (if provided) + link for media URL
229
228
  const contents: InfoflowMessageContentItem[] = [];