@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/.claude/settings.local.json +9 -0
- package/CHANGELOG.md +82 -0
- package/openclaw.plugin.json +86 -2
- package/package.json +1 -1
- package/src/accounts.ts +3 -0
- package/src/actions.ts +108 -0
- package/src/bot.ts +359 -47
- package/src/channel.ts +10 -11
- package/src/infoflow-req-parse.ts +17 -35
- package/src/logging.ts +10 -28
- package/src/monitor.ts +7 -15
- package/src/reply-dispatcher.ts +81 -12
- package/src/send.ts +25 -7
- package/src/targets.ts +6 -27
- package/src/types.ts +70 -2
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
|
-
/**
|
|
33
|
+
/** 机器人 AT 时有此字段(数字),与 userid 互斥 */
|
|
28
34
|
robotid?: number;
|
|
29
|
-
/**
|
|
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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
506
|
+
getInfoflowBotLog().error(
|
|
507
|
+
`[infoflow] failed updating session meta (sessionKey=${route.sessionKey}, accountId=${accountId}): ${formatInfoflowError(err)}`,
|
|
508
|
+
);
|
|
295
509
|
},
|
|
296
510
|
});
|
|
297
511
|
|
|
298
|
-
//
|
|
512
|
+
// Reply mode gating for group messages
|
|
299
513
|
// Session is already recorded above for context history
|
|
300
|
-
if (isGroup) {
|
|
301
|
-
const
|
|
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 (
|
|
306
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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[] = [];
|