@chbo297/infoflow 2026.3.18 → 2026.5.4
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 -528
- package/dist/index.js +21 -0
- package/dist/src/accounts.js +110 -0
- package/dist/src/actions.js +386 -0
- package/dist/src/bot.js +1010 -0
- package/dist/src/channel.js +385 -0
- package/dist/src/infoflow-req-parse.js +394 -0
- package/dist/src/logging.js +102 -0
- package/dist/src/markdown-local-images.js +65 -0
- package/dist/src/media.js +318 -0
- package/dist/src/monitor.js +145 -0
- package/dist/src/reply-dispatcher.js +301 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/send.js +820 -0
- package/dist/src/sent-message-store.js +190 -0
- package/dist/src/targets.js +90 -0
- package/dist/src/types.js +4 -0
- package/dist/src/ws-receiver.js +378 -0
- package/openclaw.plugin.json +194 -0
- package/package.json +18 -3
- package/scripts/deploy.sh +215 -0
- package/src/accounts.ts +25 -3
- package/src/actions.ts +9 -3
- package/src/bot.ts +63 -20
- package/src/channel.ts +64 -45
- package/src/infoflow-req-parse.ts +2 -2
- package/src/infoflow-sdk.d.ts +12 -0
- package/src/monitor.ts +21 -2
- package/src/reply-dispatcher.ts +2 -5
- package/src/types.ts +11 -0
- package/src/ws-receiver.ts +482 -0
- package/tsconfig.build.json +6 -0
package/dist/src/bot.js
ADDED
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import { buildAgentMediaPayload, getAgentScopedMediaLocalRoots, } from "openclaw/plugin-sdk/agent-media-payload";
|
|
2
|
+
import { buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, recordPendingHistoryEntryIfEnabled, } from "openclaw/plugin-sdk/reply-history";
|
|
3
|
+
import { resolveInfoflowAccount } from "./accounts.js";
|
|
4
|
+
import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
|
|
5
|
+
import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
|
|
6
|
+
import { getInfoflowRuntime } from "./runtime.js";
|
|
7
|
+
import { findSentMessage } from "./sent-message-store.js";
|
|
8
|
+
/**
|
|
9
|
+
* Check if the bot was @mentioned in the message body.
|
|
10
|
+
* Matches appAgentId, robotName, or robotId against AT items (same order as Baidu reference plugin).
|
|
11
|
+
*/
|
|
12
|
+
export function checkBotMentioned(bodyItems, identityOrName) {
|
|
13
|
+
const identity = typeof identityOrName === "string" || identityOrName === undefined
|
|
14
|
+
? { robotName: identityOrName }
|
|
15
|
+
: identityOrName;
|
|
16
|
+
const { robotName, appAgentId, robotId } = identity;
|
|
17
|
+
const appAgentIdStr = appAgentId != null ? String(appAgentId) : undefined;
|
|
18
|
+
const normalizedRobotName = robotName?.toLowerCase();
|
|
19
|
+
const normalizedRobotId = robotId?.trim();
|
|
20
|
+
for (const item of bodyItems) {
|
|
21
|
+
if (item.type !== "AT")
|
|
22
|
+
continue;
|
|
23
|
+
if (appAgentIdStr && item.robotid != null && String(item.robotid) === appAgentIdStr)
|
|
24
|
+
return true;
|
|
25
|
+
if (normalizedRobotName && item.name?.toLowerCase() === normalizedRobotName)
|
|
26
|
+
return true;
|
|
27
|
+
if (normalizedRobotId && item.robotid != null && String(item.robotid) === normalizedRobotId)
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* When the bot is @mentioned, return that AT item's robotid string for persistence.
|
|
34
|
+
*/
|
|
35
|
+
function getBotRobotidFromBody(bodyItems, robotName, robotId) {
|
|
36
|
+
const normalizedRobotName = robotName?.toLowerCase();
|
|
37
|
+
const normalizedRobotId = robotId?.trim();
|
|
38
|
+
for (const item of bodyItems) {
|
|
39
|
+
if (item.type !== "AT")
|
|
40
|
+
continue;
|
|
41
|
+
if (normalizedRobotId && item.robotid != null && String(item.robotid) === normalizedRobotId) {
|
|
42
|
+
return normalizedRobotId;
|
|
43
|
+
}
|
|
44
|
+
if (normalizedRobotName &&
|
|
45
|
+
item.name?.toLowerCase() === normalizedRobotName &&
|
|
46
|
+
item.robotid != null) {
|
|
47
|
+
return String(item.robotid);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if any entry in the watchlist was @mentioned in the message body.
|
|
54
|
+
* Matching priority: userid > robotid (parsed as number) > name (fallback).
|
|
55
|
+
* Returns the matched ID (from watchMentions), or undefined if none matched.
|
|
56
|
+
*/
|
|
57
|
+
function checkWatchMentioned(bodyItems, watchMentions) {
|
|
58
|
+
if (!watchMentions.length)
|
|
59
|
+
return undefined;
|
|
60
|
+
const normalizedIds = watchMentions.map((n) => n.toLowerCase());
|
|
61
|
+
// Pre-parse numeric entries for robotid matching
|
|
62
|
+
const numericIds = watchMentions.map((n) => {
|
|
63
|
+
const num = Number(n);
|
|
64
|
+
return Number.isFinite(num) ? num : null;
|
|
65
|
+
});
|
|
66
|
+
for (const item of bodyItems) {
|
|
67
|
+
if (item.type !== "AT")
|
|
68
|
+
continue;
|
|
69
|
+
// Priority 1: match userid (human AT)
|
|
70
|
+
if (item.userid) {
|
|
71
|
+
const idx = normalizedIds.indexOf(item.userid.toLowerCase());
|
|
72
|
+
if (idx !== -1)
|
|
73
|
+
return watchMentions[idx];
|
|
74
|
+
}
|
|
75
|
+
// Priority 2: match robotid (robot AT, watchMentions entry parsed as number)
|
|
76
|
+
if (item.robotid != null) {
|
|
77
|
+
const idx = numericIds.indexOf(item.robotid);
|
|
78
|
+
if (idx !== -1)
|
|
79
|
+
return watchMentions[idx];
|
|
80
|
+
}
|
|
81
|
+
// Priority 3: match by display name (fallback to name-based lookup)
|
|
82
|
+
if (item.name) {
|
|
83
|
+
const idx = normalizedIds.indexOf(item.name.toLowerCase());
|
|
84
|
+
if (idx !== -1)
|
|
85
|
+
return watchMentions[idx];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/** Normalize watchRegex config to string[] (supports legacy single string). */
|
|
91
|
+
function normalizeWatchRegex(v) {
|
|
92
|
+
if (v == null)
|
|
93
|
+
return [];
|
|
94
|
+
return Array.isArray(v) ? v : [v];
|
|
95
|
+
}
|
|
96
|
+
/** Check if message content matches any of the configured watchRegex patterns. Uses "s" (dotAll) so that . matches newlines. */
|
|
97
|
+
function checkWatchRegex(mes, patterns) {
|
|
98
|
+
if (!patterns.length)
|
|
99
|
+
return false;
|
|
100
|
+
for (const pattern of patterns) {
|
|
101
|
+
try {
|
|
102
|
+
if (new RegExp(pattern, "is").test(mes))
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// skip invalid pattern
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/** Return the first matching pattern index, or -1 if none match. Used for triggerReason and prompt. */
|
|
112
|
+
function findMatchingWatchRegex(mes, patterns) {
|
|
113
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
114
|
+
try {
|
|
115
|
+
if (new RegExp(patterns[i], "is").test(mes))
|
|
116
|
+
return i;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// skip invalid pattern
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return -1;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Extract non-bot mention IDs from inbound group message body items.
|
|
126
|
+
* Returns human userIds and robot agentIds (excluding the bot itself, matched by robotName).
|
|
127
|
+
*/
|
|
128
|
+
function extractMentionIds(bodyItems, robotName) {
|
|
129
|
+
const normalizedRobotName = robotName?.toLowerCase();
|
|
130
|
+
const userIds = [];
|
|
131
|
+
const agentIds = [];
|
|
132
|
+
const seenUsers = new Set();
|
|
133
|
+
const seenAgents = new Set();
|
|
134
|
+
for (const item of bodyItems) {
|
|
135
|
+
if (item.type !== "AT")
|
|
136
|
+
continue;
|
|
137
|
+
if (item.robotid != null) {
|
|
138
|
+
// Skip the bot itself (matched by name)
|
|
139
|
+
if (normalizedRobotName && item.name?.toLowerCase() === normalizedRobotName)
|
|
140
|
+
continue;
|
|
141
|
+
if (!seenAgents.has(item.robotid)) {
|
|
142
|
+
seenAgents.add(item.robotid);
|
|
143
|
+
agentIds.push(item.robotid);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (item.userid) {
|
|
147
|
+
const key = item.userid.toLowerCase();
|
|
148
|
+
if (!seenUsers.has(key)) {
|
|
149
|
+
seenUsers.add(key);
|
|
150
|
+
userIds.push(item.userid);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { userIds, agentIds };
|
|
155
|
+
}
|
|
156
|
+
/** Check if the message @mentions other bots or human users (excluding the bot itself). */
|
|
157
|
+
function hasOtherMentions(mentionIds) {
|
|
158
|
+
if (!mentionIds)
|
|
159
|
+
return false;
|
|
160
|
+
return mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* When in follow-up window and message has other @mentions (not this bot): record only and
|
|
164
|
+
* return "record_only" (no LLM dispatch). Otherwise return "dispatch".
|
|
165
|
+
*/
|
|
166
|
+
function resolveFollowUpOtherMentioned(params) {
|
|
167
|
+
const { mentionIds, groupId, bodyForAgent, senderName, fromuser } = params;
|
|
168
|
+
if (!hasOtherMentions(mentionIds))
|
|
169
|
+
return "dispatch";
|
|
170
|
+
const groupIdStr = groupId != null ? String(groupId) : undefined;
|
|
171
|
+
if (groupIdStr) {
|
|
172
|
+
recordPendingHistoryEntryIfEnabled({
|
|
173
|
+
historyMap: chatHistories,
|
|
174
|
+
historyKey: groupIdStr,
|
|
175
|
+
entry: {
|
|
176
|
+
sender: senderName || fromuser,
|
|
177
|
+
body: bodyForAgent,
|
|
178
|
+
timestamp: Date.now(),
|
|
179
|
+
},
|
|
180
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
logVerbose(`[infoflow:bot] skip dispatch: from=${fromuser}, group=${groupId}, reason=followUp-other-mentioned (record only, no LLM)`);
|
|
184
|
+
return "record_only";
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Reply-to-bot detection (引用回复机器人消息)
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
/**
|
|
190
|
+
* Check if the message is a reply (引用回复) to one of the bot's own messages.
|
|
191
|
+
* Looks up replyData body items' messageid against the sent-message-store.
|
|
192
|
+
*/
|
|
193
|
+
function checkReplyToBot(bodyItems, accountId) {
|
|
194
|
+
for (const item of bodyItems) {
|
|
195
|
+
if (item.type !== "replyData")
|
|
196
|
+
continue;
|
|
197
|
+
const msgId = item.messageid;
|
|
198
|
+
if (msgId == null)
|
|
199
|
+
continue;
|
|
200
|
+
const msgIdStr = String(msgId);
|
|
201
|
+
if (!msgIdStr)
|
|
202
|
+
continue;
|
|
203
|
+
try {
|
|
204
|
+
const found = findSentMessage(accountId, msgIdStr);
|
|
205
|
+
if (found)
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// DB lookup failure should not block message processing
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Shared reply judgment rules (reused across prompt builders)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
/** Shared judgment rules and reply format requirements for all conditional-reply prompts */
|
|
218
|
+
function buildReplyJudgmentRules() {
|
|
219
|
+
return [
|
|
220
|
+
"# Rules for Group Message Response",
|
|
221
|
+
"",
|
|
222
|
+
"## When to Reply",
|
|
223
|
+
"",
|
|
224
|
+
"Reply if ANY of the following is true:",
|
|
225
|
+
"- The message is directed at you — either by explicit mention, or by contextual signals suggesting the user expects your response (e.g., a question following your previous reply, a topic clearly within your role, or conversational flow implying you are the intended recipient)",
|
|
226
|
+
"- The message contains a clear question or request that you can answer using your knowledge, skills, tools, or reasoning",
|
|
227
|
+
"- You have relevant domain expertise, documentation, or codebase context that adds value",
|
|
228
|
+
"",
|
|
229
|
+
"## When NOT to Reply — output only `NO_REPLY`",
|
|
230
|
+
"",
|
|
231
|
+
"Do NOT reply if ANY of the following is true:",
|
|
232
|
+
"- The message is casual chatter, banter, emoji-only, or has no actionable question/request",
|
|
233
|
+
"- The user explicitly indicates they don't want your response",
|
|
234
|
+
"- The message is directed at another person, not at you",
|
|
235
|
+
"- You lack the context or knowledge to give a useful answer (e.g., private/internal info you don't have access to)",
|
|
236
|
+
"- The message intent is ambiguous and a wrong guess would be more disruptive than silence",
|
|
237
|
+
"",
|
|
238
|
+
"## Response Format",
|
|
239
|
+
"",
|
|
240
|
+
"- If you can answer: respond directly and concisely. Do not explain why you chose to answer. Do not add filler or pleasantries.",
|
|
241
|
+
"- If you cannot answer: output exactly `NO_REPLY` — nothing else, no explanation, no apology.",
|
|
242
|
+
"",
|
|
243
|
+
"## Guiding Principle",
|
|
244
|
+
"",
|
|
245
|
+
"When in doubt, prefer silence (`NO_REPLY`). A missing reply is far less disruptive than an irrelevant or incorrect one in a group chat.",
|
|
246
|
+
].join("\n");
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Build a GroupSystemPrompt for watch-mention triggered messages.
|
|
250
|
+
* Instructs the agent to reply only when confident, otherwise use NO_REPLY.
|
|
251
|
+
*/
|
|
252
|
+
function buildWatchMentionPrompt(mentionedId) {
|
|
253
|
+
return [
|
|
254
|
+
`Someone in the group @mentioned ${mentionedId}. As ${mentionedId}'s assistant, you observed this message.`,
|
|
255
|
+
"Decide whether you can answer on their behalf or provide help.",
|
|
256
|
+
"",
|
|
257
|
+
buildReplyJudgmentRules(),
|
|
258
|
+
"",
|
|
259
|
+
"# Examples",
|
|
260
|
+
"",
|
|
261
|
+
'Message: "What is 1+1?"',
|
|
262
|
+
"→ 2",
|
|
263
|
+
"",
|
|
264
|
+
'Message: "What is the qt parameter for search requests in the client code?"',
|
|
265
|
+
"(Assuming documentation records qt=s)",
|
|
266
|
+
"→ According to the documentation, the qt parameter for search requests is qt=s",
|
|
267
|
+
"",
|
|
268
|
+
'Message: "asdfghjkl random gibberish"',
|
|
269
|
+
"→ NO_REPLY",
|
|
270
|
+
"",
|
|
271
|
+
'Message: "Can you check today\'s release progress?"',
|
|
272
|
+
"(Assuming no relevant information available)",
|
|
273
|
+
"→ NO_REPLY",
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Build a GroupSystemPrompt for watch-content triggered messages.
|
|
278
|
+
* Instructs the agent to reply only when confident, otherwise use NO_REPLY.
|
|
279
|
+
*/
|
|
280
|
+
function buildWatchRegexPrompt(patterns) {
|
|
281
|
+
const label = patterns.length ? `(${patterns.join(" | ")})` : "";
|
|
282
|
+
return [
|
|
283
|
+
`The message content matched one of the configured watch patterns ${label}.`,
|
|
284
|
+
"As the group assistant, you observed this message. Decide whether you can provide help or a valuable reply.",
|
|
285
|
+
"",
|
|
286
|
+
buildReplyJudgmentRules(),
|
|
287
|
+
].join("\n");
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Build a GroupSystemPrompt for follow-up replies after bot's last response.
|
|
291
|
+
* Uses three-tier semantic priority: (1) intent to talk to bot → must reply,
|
|
292
|
+
* (2) explicit stop request → must not reply, (3) topic continuity judgment.
|
|
293
|
+
*
|
|
294
|
+
* When isReplyToBot is true, injects a strong signal that the user quoted the bot's message.
|
|
295
|
+
*/
|
|
296
|
+
function buildFollowUpPrompt(isReplyToBot) {
|
|
297
|
+
const lines = [
|
|
298
|
+
"You just replied to a message in this group. Someone has now sent a new message.",
|
|
299
|
+
"Follow the priority rules below **in order** to decide whether to reply.",
|
|
300
|
+
"",
|
|
301
|
+
];
|
|
302
|
+
if (isReplyToBot) {
|
|
303
|
+
lines.push("**Important context: this message is a quoted reply to your previous message. This is a strong signal that the user is following up with you.**", "");
|
|
304
|
+
}
|
|
305
|
+
lines.push("# Priority 1: The sender intends to talk to you → MUST reply", "", "Based on semantic analysis, if the sender shows ANY of the following intents or expectations, you **MUST** reply (do NOT output NO_REPLY):", "- Asking a follow-up question about your previous answer (e.g. 'why?', 'what else?', 'what if...?')", "- Quoted/replied to your message (indicating a conversation with you)", "- Addressing you by name, or using words like 'bot', 'assistant', etc.", "- Requesting you to do something (e.g. 'help me...', 'explain...', 'translate...')", "- Semantically expects a reply from you", "", "# Priority 2: Explicitly asking you to stop → MUST NOT reply", "", "If the message explicitly tells you to stop replying (e.g. 'shut up', 'stop', 'don't reply',", "'no need for bot', or equivalent expressions in any language),", "output only NO_REPLY.", "", "# Priority 3: No explicit intent → Judge topic continuity", "", "If neither Priority 1 nor Priority 2 applies:", "- If the message continues the same topic you previously replied to, and you can provide valuable help → reply.", "- If it is a new/unrelated topic, or you cannot add value → output only NO_REPLY.", "", buildReplyJudgmentRules());
|
|
306
|
+
return lines.join("\n");
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Build a GroupSystemPrompt for proactive mode.
|
|
310
|
+
* Instructs the agent to think about the message and reply when helpful.
|
|
311
|
+
*/
|
|
312
|
+
function buildProactivePrompt() {
|
|
313
|
+
return [
|
|
314
|
+
"You observed this message in the group. Decide whether you can provide help or a valuable reply.",
|
|
315
|
+
"If you need more context or clarification, you may ask follow-up questions.",
|
|
316
|
+
"",
|
|
317
|
+
buildReplyJudgmentRules(),
|
|
318
|
+
].join("\n");
|
|
319
|
+
}
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Group reply tracking (in-memory) for follow-up window
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
/** In-memory map tracking bot's last reply timestamp per group */
|
|
324
|
+
const groupLastReplyMap = new Map();
|
|
325
|
+
/** In-memory map accumulating recent group messages for context injection when bot is @mentioned */
|
|
326
|
+
const chatHistories = new Map();
|
|
327
|
+
/** Record that the bot replied to a group (called after successful send) */
|
|
328
|
+
export function recordGroupReply(groupId) {
|
|
329
|
+
groupLastReplyMap.set(groupId, Date.now());
|
|
330
|
+
}
|
|
331
|
+
/** Check if a group is within the follow-up window */
|
|
332
|
+
function isWithinFollowUpWindow(groupId, windowSeconds) {
|
|
333
|
+
const lastReply = groupLastReplyMap.get(groupId);
|
|
334
|
+
if (!lastReply)
|
|
335
|
+
return false;
|
|
336
|
+
return Date.now() - lastReply < windowSeconds * 1000;
|
|
337
|
+
}
|
|
338
|
+
/** Infer replyMode from legacy requireMention + watchMentions fields */
|
|
339
|
+
function inferLegacyReplyMode(account) {
|
|
340
|
+
const requireMention = account.config.requireMention !== false;
|
|
341
|
+
const hasWatch = (account.config.watchMentions ?? []).length > 0;
|
|
342
|
+
if (!requireMention)
|
|
343
|
+
return "proactive";
|
|
344
|
+
if (hasWatch)
|
|
345
|
+
return "mention-and-watch";
|
|
346
|
+
return "mention-only";
|
|
347
|
+
}
|
|
348
|
+
/** Resolve effective group config by merging group-level → account-level → legacy defaults */
|
|
349
|
+
function resolveGroupConfig(account, groupId) {
|
|
350
|
+
const groupCfg = groupId != null ? account.config.groups?.[String(groupId)] : undefined;
|
|
351
|
+
return {
|
|
352
|
+
replyMode: groupCfg?.replyMode ?? account.config.replyMode ?? inferLegacyReplyMode(account),
|
|
353
|
+
followUp: groupCfg?.followUp ?? account.config.followUp ?? true,
|
|
354
|
+
followUpWindow: groupCfg?.followUpWindow ?? account.config.followUpWindow ?? 300,
|
|
355
|
+
watchMentions: groupCfg?.watchMentions ?? account.config.watchMentions ?? [],
|
|
356
|
+
watchRegex: normalizeWatchRegex(groupCfg?.watchRegex ?? account.config.watchRegex),
|
|
357
|
+
systemPrompt: groupCfg?.systemPrompt,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Handles an incoming private chat message from Infoflow.
|
|
362
|
+
* Receives the raw decrypted message data and dispatches to the agent.
|
|
363
|
+
*/
|
|
364
|
+
export async function handlePrivateChatMessage(params) {
|
|
365
|
+
const { cfg, msgData, accountId, statusSink } = params;
|
|
366
|
+
// Extract sender and content from msgData (flexible field names)
|
|
367
|
+
const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
368
|
+
const mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
|
|
369
|
+
// Extract sender name (FromUserName is more human-readable than FromUserId)
|
|
370
|
+
const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
|
|
371
|
+
// Extract message ID for dedup tracking
|
|
372
|
+
const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
|
|
373
|
+
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
374
|
+
// Extract timestamp (CreateTime is in seconds, convert to milliseconds)
|
|
375
|
+
const createTime = msgData.CreateTime ?? msgData.createtime;
|
|
376
|
+
const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
|
|
377
|
+
// Detect image messages: MsgType=image with PicUrl
|
|
378
|
+
const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
|
|
379
|
+
const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
|
|
380
|
+
const imageUrls = [];
|
|
381
|
+
if (msgType === "image" && picUrl.trim()) {
|
|
382
|
+
imageUrls.push(picUrl.trim());
|
|
383
|
+
}
|
|
384
|
+
logVerbose(`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, mes=${mes}, msgType=${msgType}, raw msgData: ${JSON.stringify(msgData)}`);
|
|
385
|
+
if (!fromuser || (!mes.trim() && imageUrls.length === 0)) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// For image-only messages (no text), use placeholder
|
|
389
|
+
let effectiveMes = mes.trim();
|
|
390
|
+
if (!effectiveMes && imageUrls.length > 0) {
|
|
391
|
+
effectiveMes = "<media:image>";
|
|
392
|
+
}
|
|
393
|
+
// Delegate to the common message handler (private chat)
|
|
394
|
+
await handleInfoflowMessage({
|
|
395
|
+
cfg,
|
|
396
|
+
event: {
|
|
397
|
+
fromuser,
|
|
398
|
+
mes: effectiveMes,
|
|
399
|
+
chatType: "direct",
|
|
400
|
+
senderName,
|
|
401
|
+
messageId: messageIdStr,
|
|
402
|
+
timestamp,
|
|
403
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
404
|
+
},
|
|
405
|
+
accountId,
|
|
406
|
+
statusSink,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Handles an incoming group chat message from Infoflow.
|
|
411
|
+
* Receives the raw decrypted message data and dispatches to the agent.
|
|
412
|
+
*/
|
|
413
|
+
export async function handleGroupChatMessage(params) {
|
|
414
|
+
const { cfg, msgData, accountId, statusSink } = params;
|
|
415
|
+
logVerbose(`[infoflow] group chat: raw msgData: ${JSON.stringify(msgData)}`);
|
|
416
|
+
// Extract sender from nested structure or flat fields.
|
|
417
|
+
// Some Infoflow events (including bot-authored forwards) only populate `fromid` on the root,
|
|
418
|
+
// so include msgData.fromid as a final fallback.
|
|
419
|
+
const header = msgData.message?.header;
|
|
420
|
+
const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? msgData.fromid ?? "");
|
|
421
|
+
// Extract message ID (priority: header.messageid > header.msgid > MsgId)
|
|
422
|
+
const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
|
|
423
|
+
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
424
|
+
const rawGroupId = msgData.groupid ?? header?.groupid;
|
|
425
|
+
const groupid = typeof rawGroupId === "number" ? rawGroupId : rawGroupId ? Number(rawGroupId) : undefined;
|
|
426
|
+
// Extract timestamp (time is in milliseconds)
|
|
427
|
+
const rawTime = msgData.time ?? header?.servertime;
|
|
428
|
+
const timestamp = rawTime != null ? Number(rawTime) : Date.now();
|
|
429
|
+
if (!fromuser) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
// Extract message content from body array or flat content field
|
|
433
|
+
const message = msgData.message;
|
|
434
|
+
const bodyItems = (message?.body ?? msgData.body ?? []);
|
|
435
|
+
// Resolve account to get robotName for mention detection
|
|
436
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
437
|
+
const robotName = account.config.robotName;
|
|
438
|
+
const mentionIdentity = {
|
|
439
|
+
robotName,
|
|
440
|
+
appAgentId: account.config.appAgentId,
|
|
441
|
+
robotId: account.config.robotId?.trim() || undefined,
|
|
442
|
+
};
|
|
443
|
+
const rawEventType = String(msgData.eventtype ?? "");
|
|
444
|
+
const wasMentioned = msgData.wasMentioned === true
|
|
445
|
+
? true
|
|
446
|
+
: rawEventType === "ALL_MESSAGE_FORWARD"
|
|
447
|
+
? checkBotMentioned(bodyItems, mentionIdentity)
|
|
448
|
+
: rawEventType === "MESSAGE_RECEIVE"
|
|
449
|
+
? true
|
|
450
|
+
: checkBotMentioned(bodyItems, mentionIdentity);
|
|
451
|
+
// When bot is @mentioned, discover and persist robotId from the AT item so we can ignore our own messages later.
|
|
452
|
+
let effectiveRobotId = account.config.robotId?.trim() || undefined;
|
|
453
|
+
const discoveredRobotId = getBotRobotidFromBody(bodyItems, robotName, effectiveRobotId);
|
|
454
|
+
if (wasMentioned && discoveredRobotId != null) {
|
|
455
|
+
const newRobotId = discoveredRobotId;
|
|
456
|
+
if (newRobotId !== effectiveRobotId) {
|
|
457
|
+
try {
|
|
458
|
+
const runtime = getInfoflowRuntime();
|
|
459
|
+
const cfg = runtime.config.loadConfig();
|
|
460
|
+
const channel = (cfg.channels ?? {});
|
|
461
|
+
const infoflow = (channel.infoflow ?? {});
|
|
462
|
+
const accounts = { ...(infoflow.accounts ?? {}) };
|
|
463
|
+
const accountCfg = {
|
|
464
|
+
...(accounts[accountId] ?? {}),
|
|
465
|
+
robotId: newRobotId,
|
|
466
|
+
};
|
|
467
|
+
accounts[accountId] = accountCfg;
|
|
468
|
+
infoflow.accounts = accounts;
|
|
469
|
+
channel.infoflow = infoflow;
|
|
470
|
+
cfg.channels = channel;
|
|
471
|
+
await runtime.config.writeConfigFile(cfg);
|
|
472
|
+
logVerbose(`[infoflow] group chat: persisted robotId=${newRobotId} for account ${accountId}`);
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
getInfoflowBotLog().warn(`[infoflow] failed to persist robotId: ${formatInfoflowError(e)}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
effectiveRobotId = newRobotId;
|
|
479
|
+
}
|
|
480
|
+
// Ignore our own bot messages: only when robotId is set, treat fromid === robotId as own message.
|
|
481
|
+
const fromid = msgData.fromid;
|
|
482
|
+
if (effectiveRobotId != null && effectiveRobotId !== "" && fromid != null && fromid !== "") {
|
|
483
|
+
if (String(fromid) === effectiveRobotId) {
|
|
484
|
+
logVerbose(`[infoflow] group chat: ignoring own bot message (fromid=${fromid}, robotId=${effectiveRobotId})`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Extract non-bot mention IDs (userIds + agentIds) for LLM-driven @mentions
|
|
489
|
+
const mentionIds = extractMentionIds(bodyItems, robotName);
|
|
490
|
+
// Build three versions: mes (for CommandBody, no @xxx), rawMes (for RawBody, with @xxx),
|
|
491
|
+
// and bodyForAgent (for LLM: @name with robotid when present so model sees "@地图不打烊 (robotid:N)")
|
|
492
|
+
let textContent = "";
|
|
493
|
+
let rawTextContent = "";
|
|
494
|
+
let agentVisibleText = "";
|
|
495
|
+
const replyContextItems = [];
|
|
496
|
+
const imageUrls = [];
|
|
497
|
+
if (Array.isArray(bodyItems)) {
|
|
498
|
+
for (const item of bodyItems) {
|
|
499
|
+
if (item.type === "replyData") {
|
|
500
|
+
// 引用回复:提取被引用消息的内容(可能有多条引用)
|
|
501
|
+
const replyBody = (item.content ?? "").trim();
|
|
502
|
+
if (replyBody) {
|
|
503
|
+
replyContextItems.push(replyBody);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
else if (item.type === "TEXT" || item.type === "MD") {
|
|
507
|
+
textContent += item.content ?? "";
|
|
508
|
+
rawTextContent += item.content ?? "";
|
|
509
|
+
agentVisibleText += item.content ?? "";
|
|
510
|
+
}
|
|
511
|
+
else if (item.type === "LINK") {
|
|
512
|
+
const label = item.label ?? "";
|
|
513
|
+
if (label) {
|
|
514
|
+
textContent += ` ${label} `;
|
|
515
|
+
rawTextContent += ` ${label} `;
|
|
516
|
+
agentVisibleText += ` ${label} `;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
else if (item.type === "AT") {
|
|
520
|
+
// AT elements only go into rawTextContent and agentVisibleText, not textContent
|
|
521
|
+
const name = item.name ?? "";
|
|
522
|
+
if (name) {
|
|
523
|
+
rawTextContent += `@${name} `;
|
|
524
|
+
agentVisibleText +=
|
|
525
|
+
item.robotid != null ? `@${name} (robotid:${item.robotid}) ` : `@${name} `;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else if (item.type === "IMAGE") {
|
|
529
|
+
// 提取图片下载地址
|
|
530
|
+
const url = item.downloadurl;
|
|
531
|
+
if (typeof url === "string" && url.trim()) {
|
|
532
|
+
imageUrls.push(url.trim());
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (typeof item.content === "string" && item.content.trim()) {
|
|
536
|
+
// Fallback: for any other item types with string content, treat content as text.
|
|
537
|
+
textContent += item.content;
|
|
538
|
+
rawTextContent += item.content;
|
|
539
|
+
agentVisibleText += item.content;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
let mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
|
|
544
|
+
const rawMes = rawTextContent.trim() || mes;
|
|
545
|
+
const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
|
|
546
|
+
if (!mes && !replyContext && imageUrls.length === 0) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
// 纯图片消息:设置占位符
|
|
550
|
+
if (!mes && imageUrls.length > 0) {
|
|
551
|
+
mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
|
|
552
|
+
}
|
|
553
|
+
// If mes is empty but replyContext exists, use a placeholder so the message is not dropped
|
|
554
|
+
if (!mes && replyContext) {
|
|
555
|
+
mes = "(引用回复)";
|
|
556
|
+
}
|
|
557
|
+
// Body for LLM: include @mentions with robotid so model sees e.g. "@地图不打烊 (robotid:N)"
|
|
558
|
+
const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
|
|
559
|
+
// Extract sender name from header or fallback to fromuser
|
|
560
|
+
const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
|
|
561
|
+
// Detect reply-to-bot: check if any replyData item quotes a bot-sent message
|
|
562
|
+
const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
|
|
563
|
+
// Delegate to the common message handler (group chat)
|
|
564
|
+
await handleInfoflowMessage({
|
|
565
|
+
cfg,
|
|
566
|
+
event: {
|
|
567
|
+
fromuser,
|
|
568
|
+
mes,
|
|
569
|
+
rawMes,
|
|
570
|
+
bodyForAgent,
|
|
571
|
+
chatType: "group",
|
|
572
|
+
groupId: groupid,
|
|
573
|
+
senderName,
|
|
574
|
+
wasMentioned,
|
|
575
|
+
messageId: messageIdStr,
|
|
576
|
+
timestamp,
|
|
577
|
+
bodyItems,
|
|
578
|
+
mentionIds: mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
|
|
579
|
+
replyContext,
|
|
580
|
+
isReplyToBot: isReplyToBot || undefined,
|
|
581
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
582
|
+
},
|
|
583
|
+
accountId,
|
|
584
|
+
statusSink,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Resolves route, builds envelope, records session meta, and dispatches reply for one incoming Infoflow message.
|
|
589
|
+
* Called from monitor after webhook request is validated.
|
|
590
|
+
*/
|
|
591
|
+
export async function handleInfoflowMessage(params) {
|
|
592
|
+
const { cfg, event, accountId, statusSink } = params;
|
|
593
|
+
const { fromuser, mes, chatType, groupId, senderName } = event;
|
|
594
|
+
// Single source for "body shown to LLM": already computed in group handler (line ~666)
|
|
595
|
+
const bodyForAgent = event.bodyForAgent ?? mes;
|
|
596
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
597
|
+
const core = getInfoflowRuntime();
|
|
598
|
+
const isGroup = chatType === "group";
|
|
599
|
+
// Convert groupId (number) to string for peerId since routing expects string
|
|
600
|
+
const peerId = isGroup ? (groupId !== undefined ? String(groupId) : fromuser) : fromuser;
|
|
601
|
+
// Resolve per-group config for replyMode gating
|
|
602
|
+
const groupCfg = isGroup ? resolveGroupConfig(account, groupId) : undefined;
|
|
603
|
+
// "ignore" mode: discard immediately, no save, no think, no reply
|
|
604
|
+
if (isGroup && groupCfg?.replyMode === "ignore") {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Resolve route based on chat type
|
|
608
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
609
|
+
cfg,
|
|
610
|
+
channel: "infoflow",
|
|
611
|
+
accountId: account.accountId,
|
|
612
|
+
peer: {
|
|
613
|
+
kind: isGroup ? "group" : "direct",
|
|
614
|
+
id: peerId,
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
618
|
+
agentId: route.agentId,
|
|
619
|
+
});
|
|
620
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
621
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
622
|
+
storePath,
|
|
623
|
+
sessionKey: route.sessionKey,
|
|
624
|
+
});
|
|
625
|
+
// Build conversation label and from address based on chat type
|
|
626
|
+
const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
|
|
627
|
+
const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
628
|
+
const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
629
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
630
|
+
channel: "Infoflow",
|
|
631
|
+
from: fromLabel,
|
|
632
|
+
timestamp: Date.now(),
|
|
633
|
+
previousTimestamp,
|
|
634
|
+
envelope: envelopeOptions,
|
|
635
|
+
body: bodyForAgent,
|
|
636
|
+
});
|
|
637
|
+
// Inject accumulated group chat history into the body for context
|
|
638
|
+
const historyKey = isGroup && groupId !== undefined ? String(groupId) : undefined;
|
|
639
|
+
let combinedBody = body;
|
|
640
|
+
if (isGroup && historyKey) {
|
|
641
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
642
|
+
historyMap: chatHistories,
|
|
643
|
+
historyKey,
|
|
644
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
645
|
+
currentMessage: body,
|
|
646
|
+
formatEntry: (entry) => core.channel.reply.formatAgentEnvelope({
|
|
647
|
+
channel: "Infoflow",
|
|
648
|
+
from: entry.sender,
|
|
649
|
+
timestamp: entry.timestamp ?? Date.now(),
|
|
650
|
+
body: entry.body,
|
|
651
|
+
}),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const inboundHistory = isGroup && historyKey
|
|
655
|
+
? (chatHistories.get(historyKey) ?? []).map((e) => ({
|
|
656
|
+
sender: e.sender,
|
|
657
|
+
body: e.body,
|
|
658
|
+
timestamp: e.timestamp,
|
|
659
|
+
}))
|
|
660
|
+
: undefined;
|
|
661
|
+
// --- Resolve inbound media (images) ---
|
|
662
|
+
const INFOFLOW_MAX_IMAGES = 20;
|
|
663
|
+
const mediaMaxBytes = 30 * 1024 * 1024; // 30MB default, matching Feishu
|
|
664
|
+
const mediaList = [];
|
|
665
|
+
const failReasons = [];
|
|
666
|
+
if (event.imageUrls && event.imageUrls.length > 0) {
|
|
667
|
+
// Collect unique hostnames from image URLs for SSRF allowlist.
|
|
668
|
+
// Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
|
|
669
|
+
// internal IPs on Baidu's network, so they need to be explicitly allowed.
|
|
670
|
+
const allowedHostnames = [];
|
|
671
|
+
for (const imageUrl of event.imageUrls) {
|
|
672
|
+
try {
|
|
673
|
+
const hostname = new URL(imageUrl).hostname;
|
|
674
|
+
if (hostname && !allowedHostnames.includes(hostname)) {
|
|
675
|
+
allowedHostnames.push(hostname);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// invalid URL, will fail at fetch time
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const ssrfPolicy = allowedHostnames.length > 0 ? { allowedHostnames } : undefined;
|
|
683
|
+
const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
|
|
684
|
+
const results = await Promise.allSettled(urls.map(async (imageUrl) => {
|
|
685
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
686
|
+
url: imageUrl,
|
|
687
|
+
maxBytes: mediaMaxBytes,
|
|
688
|
+
ssrfPolicy,
|
|
689
|
+
});
|
|
690
|
+
const saved = await core.channel.media.saveMediaBuffer(fetched.buffer, fetched.contentType ?? undefined, "inbound", mediaMaxBytes);
|
|
691
|
+
logVerbose(`[infoflow] downloaded image from ${imageUrl}, saved to ${saved.path}`);
|
|
692
|
+
return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
|
|
693
|
+
}));
|
|
694
|
+
for (const result of results) {
|
|
695
|
+
if (result.status === "fulfilled") {
|
|
696
|
+
mediaList.push(result.value);
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
const reason = String(result.reason);
|
|
700
|
+
logVerbose(`[infoflow] failed to download image: ${reason}`);
|
|
701
|
+
failReasons.push(reason);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
706
|
+
// If user sent images but some/all downloads failed, adjust the body to inform the LLM.
|
|
707
|
+
const requestedImageCount = event.imageUrls?.length ?? 0;
|
|
708
|
+
const downloadedImageCount = mediaList.length;
|
|
709
|
+
const failedImageCount = requestedImageCount - downloadedImageCount;
|
|
710
|
+
if (requestedImageCount > 0 && failedImageCount > 0) {
|
|
711
|
+
// Deduplicate error reasons and truncate for readability
|
|
712
|
+
const uniqueReasons = [...new Set(failReasons)];
|
|
713
|
+
const reasonSummary = uniqueReasons.map((r) => r.slice(0, 200)).join("; ");
|
|
714
|
+
if (downloadedImageCount === 0) {
|
|
715
|
+
// All failed
|
|
716
|
+
const failNote = `[The user sent ${requestedImageCount > 1 ? `${requestedImageCount} images` : "an image"}, ` +
|
|
717
|
+
`but failed to load: ${reasonSummary}]`;
|
|
718
|
+
if (combinedBody.includes("<media:image>")) {
|
|
719
|
+
combinedBody = combinedBody.replace(/<media:image>(\s*\(\d+ images\))?/, failNote);
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
combinedBody += `\n\n${failNote}`;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
// Partial failure: some images loaded, some didn't
|
|
727
|
+
const failNote = `[${failedImageCount} of ${requestedImageCount} images failed to load: ${reasonSummary}]`;
|
|
728
|
+
combinedBody += `\n\n${failNote}`;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
732
|
+
Body: combinedBody,
|
|
733
|
+
RawBody: event.rawMes ?? mes,
|
|
734
|
+
CommandBody: mes,
|
|
735
|
+
BodyForAgent: bodyForAgent,
|
|
736
|
+
From: fromAddress,
|
|
737
|
+
To: toAddress,
|
|
738
|
+
SessionKey: route.sessionKey,
|
|
739
|
+
AccountId: route.accountId,
|
|
740
|
+
ChatType: chatType,
|
|
741
|
+
ConversationLabel: fromLabel,
|
|
742
|
+
GroupSubject: isGroup ? `group:${groupId}` : undefined,
|
|
743
|
+
SenderName: senderName || fromuser,
|
|
744
|
+
SenderId: fromuser,
|
|
745
|
+
Provider: "infoflow",
|
|
746
|
+
Surface: "infoflow",
|
|
747
|
+
MessageSid: event.messageId ?? `${Date.now()}`,
|
|
748
|
+
Timestamp: event.timestamp ?? Date.now(),
|
|
749
|
+
OriginatingChannel: "infoflow",
|
|
750
|
+
OriginatingTo: toAddress,
|
|
751
|
+
WasMentioned: isGroup ? event.wasMentioned : undefined,
|
|
752
|
+
ReplyToBody: event.replyContext ? event.replyContext.join("\n---\n") : undefined,
|
|
753
|
+
InboundHistory: inboundHistory,
|
|
754
|
+
CommandAuthorized: true,
|
|
755
|
+
...mediaPayload,
|
|
756
|
+
});
|
|
757
|
+
// Ensure BodyForAgent stays set for group messages (with @ and robotid) so the LLM sees full context
|
|
758
|
+
if (isGroup && bodyForAgent !== mes) {
|
|
759
|
+
ctxPayload.BodyForAgent = bodyForAgent;
|
|
760
|
+
logVerbose(`[infoflow] group: BodyForAgent set for LLM (${bodyForAgent.length} chars, includes @/robotid)`);
|
|
761
|
+
}
|
|
762
|
+
// Record session using recordInboundSession for proper session tracking
|
|
763
|
+
await core.channel.session.recordInboundSession({
|
|
764
|
+
storePath,
|
|
765
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
766
|
+
ctx: ctxPayload,
|
|
767
|
+
onRecordError: (err) => {
|
|
768
|
+
getInfoflowBotLog().error(`[infoflow] failed updating session meta (sessionKey=${route.sessionKey}, accountId=${accountId}): ${formatInfoflowError(err)}`);
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
// Reply mode gating for group messages
|
|
772
|
+
// Session is already recorded above for context history
|
|
773
|
+
let triggerReason = "direct-message";
|
|
774
|
+
if (isGroup && groupCfg) {
|
|
775
|
+
const { replyMode } = groupCfg;
|
|
776
|
+
const groupIdStr = groupId !== undefined ? String(groupId) : undefined;
|
|
777
|
+
// "record" mode: save to session only, no think, no reply
|
|
778
|
+
if (replyMode === "record") {
|
|
779
|
+
if (groupIdStr) {
|
|
780
|
+
logVerbose(`[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=record-mode`);
|
|
781
|
+
recordPendingHistoryEntryIfEnabled({
|
|
782
|
+
historyMap: chatHistories,
|
|
783
|
+
historyKey: groupIdStr,
|
|
784
|
+
entry: {
|
|
785
|
+
sender: senderName || fromuser,
|
|
786
|
+
body: bodyForAgent,
|
|
787
|
+
timestamp: Date.now(),
|
|
788
|
+
},
|
|
789
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const canDetectMention = Boolean(account.config.robotName);
|
|
795
|
+
const wasMentioned = event.wasMentioned === true;
|
|
796
|
+
if (replyMode === "mention-only") {
|
|
797
|
+
// Only reply if bot was @mentioned
|
|
798
|
+
const shouldReply = canDetectMention && wasMentioned;
|
|
799
|
+
if (shouldReply) {
|
|
800
|
+
triggerReason = "bot-mentioned";
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
// Check follow-up window: if bot recently replied, allow LLM to decide
|
|
804
|
+
if (groupCfg.followUp &&
|
|
805
|
+
groupIdStr &&
|
|
806
|
+
isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)) {
|
|
807
|
+
if (hasOtherMentions(event.mentionIds)) {
|
|
808
|
+
if (resolveFollowUpOtherMentioned({
|
|
809
|
+
mentionIds: event.mentionIds,
|
|
810
|
+
groupId,
|
|
811
|
+
bodyForAgent,
|
|
812
|
+
senderName: senderName || fromuser,
|
|
813
|
+
fromuser,
|
|
814
|
+
}) === "record_only") {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
triggerReason = "followUp";
|
|
820
|
+
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot === true);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
if (groupIdStr) {
|
|
825
|
+
logVerbose(`[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-only-not-mentioned`);
|
|
826
|
+
recordPendingHistoryEntryIfEnabled({
|
|
827
|
+
historyMap: chatHistories,
|
|
828
|
+
historyKey: groupIdStr,
|
|
829
|
+
entry: {
|
|
830
|
+
sender: senderName || fromuser,
|
|
831
|
+
body: bodyForAgent,
|
|
832
|
+
timestamp: Date.now(),
|
|
833
|
+
},
|
|
834
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
else if (replyMode === "mention-and-watch") {
|
|
842
|
+
// Reply if bot @mentioned, or if watched person @mentioned, or follow-up
|
|
843
|
+
const botMentioned = canDetectMention && wasMentioned;
|
|
844
|
+
if (botMentioned) {
|
|
845
|
+
triggerReason = "bot-mentioned";
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
// Check watch-mention
|
|
849
|
+
const watchMentions = groupCfg.watchMentions;
|
|
850
|
+
const matchedWatchId = watchMentions.length > 0 && event.bodyItems
|
|
851
|
+
? checkWatchMentioned(event.bodyItems, watchMentions)
|
|
852
|
+
: undefined;
|
|
853
|
+
if (matchedWatchId) {
|
|
854
|
+
triggerReason = `watchMentions(${matchedWatchId})`;
|
|
855
|
+
// Watch-mention triggered: instruct agent to reply only if confident
|
|
856
|
+
ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
|
|
857
|
+
}
|
|
858
|
+
else if (groupCfg.watchRegex.length > 0 && checkWatchRegex(mes, groupCfg.watchRegex)) {
|
|
859
|
+
const idx = findMatchingWatchRegex(mes, groupCfg.watchRegex);
|
|
860
|
+
triggerReason =
|
|
861
|
+
idx >= 0
|
|
862
|
+
? `watchRegex(${groupCfg.watchRegex[idx]})`
|
|
863
|
+
: `watchRegex(${groupCfg.watchRegex.join("|")})`;
|
|
864
|
+
// Watch-content triggered: message matched one of the configured regex patterns
|
|
865
|
+
ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(groupCfg.watchRegex);
|
|
866
|
+
}
|
|
867
|
+
else if (groupCfg.followUp &&
|
|
868
|
+
groupIdStr &&
|
|
869
|
+
isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)) {
|
|
870
|
+
if (hasOtherMentions(event.mentionIds)) {
|
|
871
|
+
if (resolveFollowUpOtherMentioned({
|
|
872
|
+
mentionIds: event.mentionIds,
|
|
873
|
+
groupId,
|
|
874
|
+
bodyForAgent,
|
|
875
|
+
senderName: senderName || fromuser,
|
|
876
|
+
fromuser,
|
|
877
|
+
}) === "record_only") {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
triggerReason = "followUp";
|
|
883
|
+
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot === true);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
else {
|
|
887
|
+
if (groupIdStr) {
|
|
888
|
+
logVerbose(`[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-and-watch-no-trigger`);
|
|
889
|
+
recordPendingHistoryEntryIfEnabled({
|
|
890
|
+
historyMap: chatHistories,
|
|
891
|
+
historyKey: groupIdStr,
|
|
892
|
+
entry: {
|
|
893
|
+
sender: senderName || fromuser,
|
|
894
|
+
body: bodyForAgent,
|
|
895
|
+
timestamp: Date.now(),
|
|
896
|
+
},
|
|
897
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
else if (replyMode === "proactive") {
|
|
905
|
+
// Always think and potentially reply
|
|
906
|
+
const botMentioned = canDetectMention && wasMentioned;
|
|
907
|
+
if (botMentioned) {
|
|
908
|
+
triggerReason = "bot-mentioned";
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
// Check watch-mention first (higher priority prompt)
|
|
912
|
+
const watchMentions = groupCfg.watchMentions;
|
|
913
|
+
const matchedWatchId = watchMentions.length > 0 && event.bodyItems
|
|
914
|
+
? checkWatchMentioned(event.bodyItems, watchMentions)
|
|
915
|
+
: undefined;
|
|
916
|
+
if (matchedWatchId) {
|
|
917
|
+
triggerReason = `watchMentions(${matchedWatchId})`;
|
|
918
|
+
ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
triggerReason = "proactive";
|
|
922
|
+
ctxPayload.GroupSystemPrompt = buildProactivePrompt();
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// Inject per-group systemPrompt (append, don't replace)
|
|
927
|
+
if (groupCfg.systemPrompt) {
|
|
928
|
+
const existing = ctxPayload.GroupSystemPrompt ?? "";
|
|
929
|
+
ctxPayload.GroupSystemPrompt = existing
|
|
930
|
+
? `${existing}\n\n---\n\n${groupCfg.systemPrompt}`
|
|
931
|
+
: groupCfg.systemPrompt;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// Build unified target: "group:<id>" for group chat, username for private chat
|
|
935
|
+
const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
|
|
936
|
+
// Provide mention context to the LLM so it can decide who to @mention
|
|
937
|
+
if (isGroup && event.mentionIds) {
|
|
938
|
+
const parts = [];
|
|
939
|
+
if (event.mentionIds.userIds.length > 0) {
|
|
940
|
+
parts.push(`User IDs: ${event.mentionIds.userIds.join(", ")}`);
|
|
941
|
+
}
|
|
942
|
+
if (event.mentionIds.agentIds.length > 0) {
|
|
943
|
+
parts.push(`Bot IDs: ${event.mentionIds.agentIds.join(", ")}`);
|
|
944
|
+
}
|
|
945
|
+
if (parts.length > 0) {
|
|
946
|
+
ctxPayload.Body += `\n\n[System: @mentioned in group: ${parts.join("; ")}. To @mention someone in your reply, use the @id format]`;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const mentionIdsLog = isGroup && event.mentionIds
|
|
950
|
+
? `, mentionIds={userIds:[${event.mentionIds.userIds.join(",")}], agentIds:[${event.mentionIds.agentIds.join(",")}]}`
|
|
951
|
+
: "";
|
|
952
|
+
const bodyPreview = ctxPayload.Body != null
|
|
953
|
+
? String(ctxPayload.Body)
|
|
954
|
+
: "";
|
|
955
|
+
const bodyLog = `bodyLen=${bodyPreview.length} bodyPreview=${bodyPreview.length > 5000 ? bodyPreview.slice(0, 5000) + "..." : bodyPreview}`;
|
|
956
|
+
const sysPrompt = ctxPayload.GroupSystemPrompt != null
|
|
957
|
+
? String(ctxPayload.GroupSystemPrompt)
|
|
958
|
+
: "";
|
|
959
|
+
const sysPromptLog = `groupSystemPromptLen=${sysPrompt.length} groupSystemPromptPreview=${sysPrompt.length > 5000 ? sysPrompt.slice(0, 5000) + "..." : sysPrompt}`;
|
|
960
|
+
logVerbose(`[infoflow:bot] dispatching to LLM: from=${fromuser}, group=${groupId ?? "N/A"}, trigger=${triggerReason}, replyMode=${groupCfg?.replyMode ?? "N/A"}${mentionIdsLog} | ${bodyLog} | ${sysPromptLog}`);
|
|
961
|
+
const { dispatcherOptions, replyOptions } = createInfoflowReplyDispatcher({
|
|
962
|
+
cfg,
|
|
963
|
+
agentId: route.agentId,
|
|
964
|
+
accountId: account.accountId,
|
|
965
|
+
to,
|
|
966
|
+
statusSink,
|
|
967
|
+
// @mention the sender back when bot was directly @mentioned in a group
|
|
968
|
+
atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
|
|
969
|
+
// Pass mention IDs for LLM-driven @mention resolution in outbound text
|
|
970
|
+
mentionIds: isGroup ? event.mentionIds : undefined,
|
|
971
|
+
// Pass inbound messageId for outbound reply-to (group only)
|
|
972
|
+
replyToMessageId: isGroup ? event.messageId : undefined,
|
|
973
|
+
replyToPreview: isGroup ? bodyForAgent : undefined,
|
|
974
|
+
mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, route.agentId),
|
|
975
|
+
});
|
|
976
|
+
const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
977
|
+
ctx: ctxPayload,
|
|
978
|
+
cfg,
|
|
979
|
+
dispatcherOptions,
|
|
980
|
+
replyOptions,
|
|
981
|
+
});
|
|
982
|
+
const didReply = dispatchResult?.queuedFinal ?? false;
|
|
983
|
+
// Clear accumulated history after dispatch (it's now in the session transcript)
|
|
984
|
+
if (isGroup && historyKey) {
|
|
985
|
+
clearHistoryEntriesIfEnabled({
|
|
986
|
+
historyMap: chatHistories,
|
|
987
|
+
historyKey,
|
|
988
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
// Record bot reply timestamp for follow-up window tracking
|
|
992
|
+
if (didReply && isGroup && groupId !== undefined) {
|
|
993
|
+
recordGroupReply(String(groupId));
|
|
994
|
+
}
|
|
995
|
+
logVerbose(`[infoflow] dispatch complete: ${chatType} from ${fromuser}, replied=${didReply}, finalCount=${dispatchResult?.counts.final ?? 0}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`);
|
|
996
|
+
}
|
|
997
|
+
// ---------------------------------------------------------------------------
|
|
998
|
+
// Test-only exports (@internal)
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
/** @internal — Check if bot was mentioned in message body. Only exported for tests. */
|
|
1001
|
+
export const _checkBotMentioned = checkBotMentioned;
|
|
1002
|
+
export const _getBotRobotidFromBody = getBotRobotidFromBody;
|
|
1003
|
+
/** @internal — Check if any watch-list name was @mentioned. Only exported for tests. */
|
|
1004
|
+
export const _checkWatchMentioned = checkWatchMentioned;
|
|
1005
|
+
/** @internal — Extract non-bot mention IDs. Only exported for tests. */
|
|
1006
|
+
export const _extractMentionIds = extractMentionIds;
|
|
1007
|
+
/** @internal — Check if message matches any watchRegex pattern (dotAll). Only exported for tests. */
|
|
1008
|
+
export const _checkWatchRegex = checkWatchRegex;
|
|
1009
|
+
/** @internal — Check if message is a reply to one of the bot's own messages. Only exported for tests. */
|
|
1010
|
+
export const _checkReplyToBot = checkReplyToBot;
|