@core-workspace/infoflow-openclaw-plugin 2026.3.8

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.
@@ -0,0 +1,801 @@
1
+ import {
2
+ buildPendingHistoryContextFromMap,
3
+ clearHistoryEntriesIfEnabled,
4
+ DEFAULT_GROUP_HISTORY_LIMIT,
5
+ type HistoryEntry,
6
+ recordPendingHistoryEntryIfEnabled,
7
+ buildAgentMediaPayload,
8
+ } from "openclaw/plugin-sdk";
9
+ import { resolveInfoflowAccount } from "../channel/accounts.js";
10
+ import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "../logging.js";
11
+ import { createInfoflowReplyDispatcher } from "../adapter/outbound/reply-dispatcher.js";
12
+ import { sendInfoflowMessage } from "../channel/outbound.js";
13
+ import { getInfoflowRuntime } from "../runtime.js";
14
+ import {
15
+ checkBotMentioned,
16
+ checkWatchMentioned,
17
+ checkWatchRegex,
18
+ extractMentionIds,
19
+ recordGroupReply,
20
+ isWithinFollowUpWindow,
21
+ resolveGroupConfig,
22
+ buildWatchMentionPrompt,
23
+ buildWatchRegexPrompt,
24
+ buildFollowUpPrompt,
25
+ buildProactivePrompt,
26
+ type InfoflowBodyItem,
27
+ } from "../security/group-policy.js";
28
+ import { checkDmPolicy, checkGroupPolicy } from "../security/dm-policy.js";
29
+ import type {
30
+ InfoflowChatType,
31
+ InfoflowMessageEvent,
32
+ InfoflowMentionIds,
33
+ InfoflowReplyMode,
34
+ InfoflowGroupConfig,
35
+ HandleInfoflowMessageParams,
36
+ HandlePrivateChatParams,
37
+ HandleGroupChatParams,
38
+ ResolvedInfoflowAccount,
39
+ } from "../types.js";
40
+
41
+ // Re-export types for external consumers
42
+ export type { InfoflowChatType, InfoflowMessageEvent } from "../types.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Group reply tracking (in-memory) for follow-up window
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** In-memory map accumulating recent group messages for context injection when bot is @mentioned */
49
+ const chatHistories = new Map<string, HistoryEntry[]>();
50
+
51
+ /**
52
+ * Handles an incoming private chat message from Infoflow.
53
+ * Receives the raw decrypted message data and dispatches to the agent.
54
+ */
55
+ export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
56
+ const { cfg, msgData, accountId, statusSink } = params;
57
+
58
+ // Extract sender and content from msgData (flexible field names)
59
+ const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
60
+ const mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
61
+
62
+ // Extract sender name (FromUserName is more human-readable than FromUserId)
63
+ const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
64
+
65
+ // Extract message ID for dedup tracking
66
+ const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
67
+ const messageIdStr = messageId != null ? String(messageId) : undefined;
68
+
69
+ // Extract timestamp (CreateTime is in seconds, convert to milliseconds)
70
+ const createTime = msgData.CreateTime ?? msgData.createtime;
71
+ const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
72
+
73
+ // Detect image messages: MsgType=image with PicUrl
74
+ const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
75
+ const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
76
+ const imageUrls: string[] = [];
77
+ if (msgType === "image" && picUrl.trim()) {
78
+ imageUrls.push(picUrl.trim());
79
+ }
80
+
81
+ logVerbose(
82
+ `[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, mes=${mes}, msgType=${msgType}, raw msgData: ${JSON.stringify(msgData)}`,
83
+ );
84
+
85
+ logVerbose(
86
+ `[DEBUG private] content字段诊断: Content=${JSON.stringify(msgData.Content)}, content=${JSON.stringify(msgData.content)}, mes=${JSON.stringify(msgData.mes)}, MsgType=${JSON.stringify(msgData.MsgType)}, msgtype=${JSON.stringify(msgData.msgtype)}, PicUrl=${JSON.stringify(msgData.PicUrl)}, picurl=${JSON.stringify(msgData.picurl)}, imageUrls=${JSON.stringify(imageUrls)}`,
87
+ );
88
+
89
+ if (!fromuser || (!mes.trim() && imageUrls.length === 0)) {
90
+ return;
91
+ }
92
+
93
+ // Check dmPolicy: send a hint when user is not authorized
94
+ const account = resolveInfoflowAccount({ cfg, accountId });
95
+ const dmResult = checkDmPolicy(account, fromuser);
96
+ if (!dmResult.allowed) {
97
+ logVerbose(`[infoflow] private message rejected: dmPolicy=allowlist, fromuser=${fromuser}`);
98
+ sendInfoflowMessage({
99
+ cfg,
100
+ to: fromuser,
101
+ contents: [{ type: "text", content: "🚫 抱歉,您暂无使用权限,请联系龙虾主开通~" }],
102
+ accountId: account.accountId,
103
+ }).catch(() => { /* ignore send errors */ });
104
+ return;
105
+ }
106
+ if ("note" in dmResult && dmResult.note === "pairing") {
107
+ // pairing 由框架处理配对逻辑,此处仅做日志,不发提示以免干扰配对流程
108
+ logVerbose(`[infoflow] private message: dmPolicy=pairing, fromuser=${fromuser}`);
109
+ }
110
+
111
+ // For image-only messages (no text), use placeholder
112
+ let effectiveMes = mes.trim();
113
+ if (!effectiveMes && imageUrls.length > 0) {
114
+ effectiveMes = "<media:image>";
115
+ }
116
+
117
+ // Delegate to the common message handler (private chat)
118
+ await handleInfoflowMessage({
119
+ cfg,
120
+ event: {
121
+ fromuser,
122
+ mes: effectiveMes,
123
+ chatType: "direct",
124
+ senderName,
125
+ messageId: messageIdStr,
126
+ timestamp,
127
+ imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
128
+ },
129
+ accountId,
130
+ statusSink,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Handles an incoming group chat message from Infoflow.
136
+ * Receives the raw decrypted message data and dispatches to the agent.
137
+ */
138
+ export async function handleGroupChatMessage(params: HandleGroupChatParams): Promise<void> {
139
+ const { cfg, msgData, accountId, statusSink } = params;
140
+
141
+ // Extract sender from nested structure or flat fields
142
+ const header = (msgData.message as Record<string, unknown>)?.header as
143
+ | Record<string, unknown>
144
+ | undefined;
145
+ const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
146
+
147
+ // Extract sender's imid (数字ID) - the numeric user ID is in msgData.fromid
148
+ const senderImid = msgData.fromid ?? header?.imid ?? header?.fromimid ?? msgData.imid ?? msgData.fromimid;
149
+ const senderImidStr = senderImid != null ? String(senderImid) : undefined;
150
+
151
+ // Extract message ID (priority: header.messageid > header.msgid > MsgId)
152
+ const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
153
+ const messageIdStr = messageId != null ? String(messageId) : undefined;
154
+
155
+ const rawGroupId = msgData.groupid ?? header?.groupid;
156
+ const groupid =
157
+ typeof rawGroupId === "number" ? rawGroupId : rawGroupId ? Number(rawGroupId) : undefined;
158
+
159
+ // Extract timestamp (time is in milliseconds)
160
+ const rawTime = msgData.time ?? header?.servertime;
161
+ const timestamp = rawTime != null ? Number(rawTime) : Date.now();
162
+
163
+ // Debug: 打印完整的原始消息数据
164
+ logVerbose(`[DEBUG bot.groupchat] 完整 msgData: ${JSON.stringify(msgData, null, 2)}`);
165
+ logVerbose(`[DEBUG bot.groupchat] 完整 header: ${JSON.stringify(header, null, 2)}`);
166
+
167
+ // Debug: 输出所有可能的 ID 字段
168
+ logVerbose(`[DEBUG bot.groupchat] 查找 imid (期望值: 102752365):`);
169
+ logVerbose(` - header.imid: ${header?.imid}`);
170
+ logVerbose(` - header.fromimid: ${header?.fromimid}`);
171
+ logVerbose(` - header.fromuserid: ${header?.fromuserid}`);
172
+ logVerbose(` - msgData.imid: ${msgData.imid}`);
173
+ logVerbose(` - msgData.fromimid: ${msgData.fromimid}`);
174
+ logVerbose(` - msgData.fromuserid: ${msgData.fromuserid}`);
175
+ logVerbose(` - msgData.from: ${msgData.from}`);
176
+ logVerbose(` - msgData.userid: ${msgData.userid}`);
177
+ logVerbose(` - fromuser: ${fromuser}`);
178
+ logVerbose(` - senderImidStr: ${senderImidStr}`);
179
+
180
+ if (!fromuser) {
181
+ return;
182
+ }
183
+
184
+ // Extract message content from body array or flat content field
185
+ const message = msgData.message as Record<string, unknown> | undefined;
186
+ const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowBodyItem[];
187
+
188
+ // Resolve account to get robotName for mention detection
189
+ const account = resolveInfoflowAccount({ cfg, accountId });
190
+ const robotName = account.config.robotName;
191
+
192
+ // Check groupPolicy allowlist
193
+ const wasMentionedEarly = checkBotMentioned(bodyItems, robotName);
194
+ const groupPolicyResult = checkGroupPolicy(account, groupid, wasMentionedEarly);
195
+ if (!groupPolicyResult.allowed) {
196
+ if (groupPolicyResult.reason === "disabled") {
197
+ logVerbose(`[infoflow] group message rejected: groupPolicy=disabled`);
198
+ } else {
199
+ logVerbose(`[infoflow] group message rejected: group=${groupPolicyResult.groupIdStr} not in groupAllowFrom`);
200
+ // 发送无权限提示,仅当消息是 @机器人 时才回复,避免在无关群里刷屏
201
+ if (groupPolicyResult.wasMentioned && groupPolicyResult.groupIdStr) {
202
+ sendInfoflowMessage({
203
+ cfg,
204
+ to: `group:${groupPolicyResult.groupIdStr}`,
205
+ contents: [{ type: "text", content: "🚫 抱歉,该群暂无使用权限,请联系龙虾主开通~" }],
206
+ accountId: account.accountId,
207
+ }).catch(() => { /* ignore send errors */ });
208
+ }
209
+ }
210
+ return;
211
+ }
212
+
213
+ // Check if bot was @mentioned (by robotName)
214
+ const wasMentioned = wasMentionedEarly;
215
+
216
+ // Extract non-bot mention IDs (userIds + agentIds) for LLM-driven @mentions
217
+ const mentionIds = extractMentionIds(bodyItems, robotName);
218
+
219
+ // Build two versions: mes (for CommandBody, no @xxx) and rawMes (for RawBody, with @xxx)
220
+ let textContent = "";
221
+ let rawTextContent = "";
222
+ const replyContextItems: string[] = [];
223
+ const imageUrls: string[] = [];
224
+ if (Array.isArray(bodyItems)) {
225
+ for (const item of bodyItems) {
226
+ if (item.type === "replyData") {
227
+ // 引用回复:提取被引用消息的内容(可能有多条引用)
228
+ const replyBody = (item.content ?? "").trim();
229
+ if (replyBody) {
230
+ replyContextItems.push(replyBody);
231
+ }
232
+ } else if (item.type === "TEXT") {
233
+ textContent += item.content ?? "";
234
+ rawTextContent += item.content ?? "";
235
+ } else if (item.type === "LINK") {
236
+ const label = item.label ?? "";
237
+ if (label) {
238
+ textContent += ` ${label} `;
239
+ rawTextContent += ` ${label} `;
240
+ }
241
+ } else if (item.type === "AT") {
242
+ // AT elements only go into rawTextContent, not textContent
243
+ const name = item.name ?? "";
244
+ if (name) {
245
+ rawTextContent += `@${name} `;
246
+ }
247
+ } else if (item.type === "IMAGE") {
248
+ // 提取图片下载地址
249
+ logVerbose(`[DEBUG bot.groupchat] IMAGE item: ${JSON.stringify(item, null, 2)}`);
250
+ const url = item.downloadurl;
251
+ if (typeof url === "string" && url.trim()) {
252
+ logVerbose(`[DEBUG bot.groupchat] 提取到图片URL: ${url}`);
253
+ imageUrls.push(url.trim());
254
+ } else {
255
+ logVerbose(`[DEBUG bot.groupchat] WARNING: IMAGE item 缺少有效的 downloadurl 字段`);
256
+ }
257
+ }
258
+ }
259
+ }
260
+
261
+ let mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
262
+ const rawMes = rawTextContent.trim() || mes;
263
+
264
+ const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
265
+
266
+ if (!mes && !replyContext && imageUrls.length === 0) {
267
+ return;
268
+ }
269
+ // 纯图片消息:设置占位符
270
+ if (!mes && imageUrls.length > 0) {
271
+ logVerbose(`[DEBUG bot.groupchat] 纯图片消息: ${imageUrls.length} 张图片`);
272
+ mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
273
+ }
274
+ // If mes is empty but replyContext exists, use a placeholder so the message is not dropped
275
+ if (!mes && replyContext) {
276
+ mes = "(引用回复)";
277
+ }
278
+
279
+ // Extract sender name from header or fallback to fromuser
280
+ const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
281
+
282
+ // Delegate to the common message handler (group chat)
283
+ await handleInfoflowMessage({
284
+ cfg,
285
+ event: {
286
+ fromuser,
287
+ mes,
288
+ rawMes,
289
+ chatType: "group",
290
+ groupId: groupid,
291
+ senderName,
292
+ wasMentioned,
293
+ messageId: messageIdStr,
294
+ timestamp,
295
+ bodyItems,
296
+ mentionIds:
297
+ mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
298
+ replyContext,
299
+ imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
300
+ senderImid: senderImidStr, // 传递发送者的 imid
301
+ },
302
+ accountId,
303
+ statusSink,
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Resolves route, builds envelope, records session meta, and dispatches reply for one incoming Infoflow message.
309
+ * Called from monitor after webhook request is validated.
310
+ */
311
+ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams): Promise<void> {
312
+ const { cfg, event, accountId, statusSink } = params;
313
+ const { fromuser, mes, chatType, groupId, senderName } = event;
314
+
315
+ const account = resolveInfoflowAccount({ cfg, accountId });
316
+ const core = getInfoflowRuntime();
317
+
318
+ const isGroup = chatType === "group";
319
+ // Convert groupId (number) to string for peerId since routing expects string
320
+ const peerId = isGroup ? (groupId !== undefined ? String(groupId) : fromuser) : fromuser;
321
+
322
+ // Resolve per-group config for replyMode gating
323
+ const groupCfg = isGroup ? resolveGroupConfig(account, groupId) : undefined;
324
+
325
+ // "ignore" mode: discard immediately, no save, no think, no reply
326
+ if (isGroup && groupCfg?.replyMode === "ignore") {
327
+ return;
328
+ }
329
+
330
+ // Resolve route based on chat type
331
+ const route = core.channel.routing.resolveAgentRoute({
332
+ cfg,
333
+ channel: "infoflow",
334
+ accountId: account.accountId,
335
+ peer: {
336
+ kind: isGroup ? "group" : "direct",
337
+ id: peerId,
338
+ },
339
+ });
340
+
341
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
342
+ agentId: route.agentId,
343
+ });
344
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
345
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
346
+ storePath,
347
+ sessionKey: route.sessionKey,
348
+ });
349
+
350
+ // Build conversation label and from address based on chat type
351
+ const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
352
+ const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
353
+ const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
354
+
355
+ const body = core.channel.reply.formatAgentEnvelope({
356
+ channel: "Infoflow",
357
+ from: fromLabel,
358
+ timestamp: Date.now(),
359
+ previousTimestamp,
360
+ envelope: envelopeOptions,
361
+ body: mes,
362
+ });
363
+
364
+ // Inject accumulated group chat history into the body for context
365
+ const historyKey = isGroup && groupId !== undefined ? String(groupId) : undefined;
366
+ let combinedBody = body;
367
+ if (isGroup && historyKey) {
368
+ combinedBody = buildPendingHistoryContextFromMap({
369
+ historyMap: chatHistories,
370
+ historyKey,
371
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
372
+ currentMessage: body,
373
+ formatEntry: (entry) =>
374
+ core.channel.reply.formatAgentEnvelope({
375
+ channel: "Infoflow",
376
+ from: entry.sender,
377
+ timestamp: entry.timestamp ?? Date.now(),
378
+ body: entry.body,
379
+ }),
380
+ });
381
+ }
382
+
383
+ const inboundHistory =
384
+ isGroup && historyKey
385
+ ? (chatHistories.get(historyKey) ?? []).map((e) => ({
386
+ sender: e.sender,
387
+ body: e.body,
388
+ timestamp: e.timestamp,
389
+ }))
390
+ : undefined;
391
+
392
+ // --- Resolve inbound media (images) ---
393
+ const INFOFLOW_MAX_IMAGES = 20;
394
+ const mediaMaxBytes = 30 * 1024 * 1024; // 30MB default, matching Feishu
395
+ const mediaList: Array<{ path: string; contentType?: string }> = [];
396
+ const failReasons: string[] = [];
397
+
398
+ logVerbose(`[DEBUG bot] 图片处理开始: imageUrls数量=${event.imageUrls?.length ?? 0}`);
399
+ if (event.imageUrls && event.imageUrls.length > 0) {
400
+ logVerbose(`[DEBUG bot] 待下载图片URLs: ${JSON.stringify(event.imageUrls)}`);
401
+ // Collect unique hostnames from image URLs for SSRF allowlist.
402
+ // Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
403
+ // internal IPs on Baidu's network, so they need to be explicitly allowed.
404
+ const allowedHostnames: string[] = [];
405
+ for (const imageUrl of event.imageUrls) {
406
+ try {
407
+ const hostname = new URL(imageUrl).hostname;
408
+ if (hostname && !allowedHostnames.includes(hostname)) {
409
+ allowedHostnames.push(hostname);
410
+ }
411
+ } catch {
412
+ // invalid URL, will fail at fetch time
413
+ }
414
+ }
415
+ const ssrfPolicy = allowedHostnames.length > 0 ? { allowedHostnames } : undefined;
416
+
417
+ const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
418
+ const results = await Promise.allSettled(
419
+ urls.map(async (imageUrl) => {
420
+ logVerbose(`[DEBUG bot] 开始下载图片: ${imageUrl}`);
421
+ const fetched = await core.channel.media.fetchRemoteMedia({
422
+ url: imageUrl,
423
+ maxBytes: mediaMaxBytes,
424
+ ssrfPolicy,
425
+ });
426
+ logVerbose(`[DEBUG bot] 图片下载成功: size=${fetched.buffer.length}, contentType=${fetched.contentType}`);
427
+ const saved = await core.channel.media.saveMediaBuffer(
428
+ fetched.buffer,
429
+ fetched.contentType ?? undefined,
430
+ "inbound",
431
+ mediaMaxBytes,
432
+ );
433
+ logVerbose(`[infoflow] downloaded image from ${imageUrl}, saved to ${saved.path}`);
434
+ logVerbose(`[DEBUG bot] 图片保存成功: path=${saved.path}`);
435
+ return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
436
+ }),
437
+ );
438
+ for (const result of results) {
439
+ if (result.status === "fulfilled") {
440
+ mediaList.push(result.value);
441
+ logVerbose(`[DEBUG bot] 图片处理成功: ${result.value.path}`);
442
+ } else {
443
+ const reason = String(result.reason);
444
+ logVerbose(`[infoflow] failed to download image: ${reason}`);
445
+ logVerbose(`[DEBUG bot] 图片下载失败: ${reason}`);
446
+ failReasons.push(reason);
447
+ }
448
+ }
449
+ }
450
+
451
+ logVerbose(`[DEBUG bot] 图片处理完成: 成功=${mediaList.length}, 失败=${failReasons.length}`);
452
+
453
+ const mediaPayload = buildAgentMediaPayload(mediaList);
454
+
455
+ // If user sent images but some/all downloads failed, adjust the body to inform the LLM.
456
+ const requestedImageCount = event.imageUrls?.length ?? 0;
457
+ const downloadedImageCount = mediaList.length;
458
+ const failedImageCount = requestedImageCount - downloadedImageCount;
459
+ if (requestedImageCount > 0 && failedImageCount > 0) {
460
+ // Deduplicate error reasons and truncate for readability
461
+ const uniqueReasons = [...new Set(failReasons)];
462
+ const reasonSummary = uniqueReasons.map((r) => r.slice(0, 200)).join("; ");
463
+
464
+ if (downloadedImageCount === 0) {
465
+ // All failed
466
+ const failNote =
467
+ `[The user sent ${requestedImageCount > 1 ? `${requestedImageCount} images` : "an image"}, ` +
468
+ `but failed to load: ${reasonSummary}]`;
469
+ if (combinedBody.includes("<media:image>")) {
470
+ combinedBody = combinedBody.replace(/<media:image>(\s*\(\d+ images\))?/, failNote);
471
+ } else {
472
+ combinedBody += `\n\n${failNote}`;
473
+ }
474
+ } else {
475
+ // Partial failure: some images loaded, some didn't
476
+ const failNote = `[${failedImageCount} of ${requestedImageCount} images failed to load: ${reasonSummary}]`;
477
+ combinedBody += `\n\n${failNote}`;
478
+ }
479
+ }
480
+
481
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
482
+ Body: combinedBody,
483
+ RawBody: event.rawMes ?? mes,
484
+ CommandBody: mes,
485
+ From: fromAddress,
486
+ To: toAddress,
487
+ SessionKey: route.sessionKey,
488
+ AccountId: route.accountId,
489
+ ChatType: chatType,
490
+ ConversationLabel: fromLabel,
491
+ GroupSubject: isGroup ? `group:${groupId}` : undefined,
492
+ SenderName: senderName || fromuser,
493
+ SenderId: fromuser,
494
+ Provider: "infoflow",
495
+ Surface: "infoflow",
496
+ MessageSid: event.messageId ?? `${Date.now()}`,
497
+ Timestamp: event.timestamp ?? Date.now(),
498
+ OriginatingChannel: "infoflow",
499
+ OriginatingTo: toAddress,
500
+ WasMentioned: isGroup ? event.wasMentioned : undefined,
501
+ ReplyToBody: event.replyContext ? event.replyContext.join("\n---\n") : undefined,
502
+ InboundHistory: inboundHistory,
503
+ CommandAuthorized: true,
504
+ ...mediaPayload,
505
+ });
506
+
507
+ // Record session using recordInboundSession for proper session tracking
508
+ await core.channel.session.recordInboundSession({
509
+ storePath,
510
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
511
+ ctx: ctxPayload,
512
+ onRecordError: (err) => {
513
+ getInfoflowBotLog().error(
514
+ `[infoflow] failed updating session meta (sessionKey=${route.sessionKey}, accountId=${accountId}): ${formatInfoflowError(err)}`,
515
+ );
516
+ },
517
+ });
518
+
519
+ // Reply mode gating for group messages
520
+ // Session is already recorded above for context history
521
+ let triggerReason = "direct-message";
522
+ if (isGroup && groupCfg) {
523
+ const { replyMode } = groupCfg;
524
+ const groupIdStr = groupId !== undefined ? String(groupId) : undefined;
525
+
526
+ // "record" mode: save to session only, no think, no reply
527
+ if (replyMode === "record") {
528
+ if (groupIdStr) {
529
+ logVerbose(
530
+ `[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=record-mode`,
531
+ );
532
+ recordPendingHistoryEntryIfEnabled({
533
+ historyMap: chatHistories,
534
+ historyKey: groupIdStr,
535
+ entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
536
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
537
+ });
538
+ }
539
+ return;
540
+ }
541
+
542
+ const canDetectMention = Boolean(account.config.robotName);
543
+ const wasMentioned = event.wasMentioned === true;
544
+
545
+ if (replyMode === "mention-only") {
546
+ // Only reply if bot was @mentioned
547
+ const shouldReply = canDetectMention && wasMentioned;
548
+ if (shouldReply) {
549
+ triggerReason = "bot-mentioned";
550
+ } else {
551
+ // Check follow-up window: if bot recently replied, allow LLM to decide
552
+ if (
553
+ groupCfg.followUp &&
554
+ groupIdStr &&
555
+ isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
556
+ ) {
557
+ triggerReason = "followUp";
558
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
559
+ } else {
560
+ if (groupIdStr) {
561
+ logVerbose(
562
+ `[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-only-not-mentioned`,
563
+ );
564
+ recordPendingHistoryEntryIfEnabled({
565
+ historyMap: chatHistories,
566
+ historyKey: groupIdStr,
567
+ entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
568
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
569
+ });
570
+ }
571
+ return;
572
+ }
573
+ }
574
+ } else if (replyMode === "mention-and-watch") {
575
+ // Reply if bot @mentioned, or if watched person @mentioned, or follow-up
576
+ const botMentioned = canDetectMention && wasMentioned;
577
+ if (botMentioned) {
578
+ triggerReason = "bot-mentioned";
579
+ } else {
580
+ // Check watch-mention
581
+ const watchMentions = groupCfg.watchMentions;
582
+ const matchedWatchId =
583
+ watchMentions.length > 0 && event.bodyItems
584
+ ? checkWatchMentioned(event.bodyItems, watchMentions)
585
+ : undefined;
586
+
587
+ if (matchedWatchId) {
588
+ triggerReason = `watchMentions(${matchedWatchId})`;
589
+ // Watch-mention triggered: instruct agent to reply only if confident
590
+ ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
591
+ } else if (groupCfg.watchRegex && checkWatchRegex(mes, groupCfg.watchRegex)) {
592
+ triggerReason = `watchRegex(${groupCfg.watchRegex})`;
593
+ // Watch-content triggered: message matched configured regex pattern
594
+ ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(groupCfg.watchRegex);
595
+ } else if (
596
+ groupCfg.followUp &&
597
+ groupIdStr &&
598
+ isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
599
+ ) {
600
+ triggerReason = "followUp";
601
+ // Follow-up window: let LLM decide if this is a follow-up
602
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
603
+ } else {
604
+ if (groupIdStr) {
605
+ logVerbose(
606
+ `[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-and-watch-no-trigger`,
607
+ );
608
+ recordPendingHistoryEntryIfEnabled({
609
+ historyMap: chatHistories,
610
+ historyKey: groupIdStr,
611
+ entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
612
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
613
+ });
614
+ }
615
+ return;
616
+ }
617
+ }
618
+ } else if (replyMode === "proactive") {
619
+ // Always think and potentially reply
620
+ const botMentioned = canDetectMention && wasMentioned;
621
+ if (botMentioned) {
622
+ triggerReason = "bot-mentioned";
623
+ } else {
624
+ // Check watch-mention first (higher priority prompt)
625
+ const watchMentions = groupCfg.watchMentions;
626
+ const matchedWatchId =
627
+ watchMentions.length > 0 && event.bodyItems
628
+ ? checkWatchMentioned(event.bodyItems, watchMentions)
629
+ : undefined;
630
+ if (matchedWatchId) {
631
+ triggerReason = `watchMentions(${matchedWatchId})`;
632
+ ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
633
+ } else {
634
+ triggerReason = "proactive";
635
+ ctxPayload.GroupSystemPrompt = buildProactivePrompt();
636
+ }
637
+ }
638
+ }
639
+
640
+ // Inject per-group systemPrompt (append, don't replace)
641
+ if (groupCfg.systemPrompt) {
642
+ const existing = ctxPayload.GroupSystemPrompt ?? "";
643
+ ctxPayload.GroupSystemPrompt = existing
644
+ ? `${existing}\n\n---\n\n${groupCfg.systemPrompt}`
645
+ : groupCfg.systemPrompt;
646
+ }
647
+ }
648
+
649
+ // Build unified target: "group:<id>" for group chat, username for private chat
650
+ const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
651
+
652
+ // Provide mention context to the LLM so it can decide who to @mention
653
+ if (isGroup && event.mentionIds) {
654
+ const parts: string[] = [];
655
+ if (event.mentionIds.userIds.length > 0) {
656
+ parts.push(`User IDs: ${event.mentionIds.userIds.join(", ")}`);
657
+ }
658
+ if (event.mentionIds.agentIds.length > 0) {
659
+ parts.push(`Bot IDs: ${event.mentionIds.agentIds.join(", ")}`);
660
+ }
661
+ if (parts.length > 0) {
662
+ ctxPayload.Body += `\n\n[System: @mentioned in group: ${parts.join("; ")}. To @mention someone in your reply, use the @id format]`;
663
+ }
664
+ }
665
+
666
+ logVerbose(
667
+ `[infoflow:bot] dispatching to LLM: from=${fromuser}, group=${groupId ?? "N/A"}, trigger=${triggerReason}, replyMode=${groupCfg?.replyMode ?? "N/A"}`,
668
+ );
669
+
670
+ // Debug: Log reply-to context
671
+ logVerbose(`[DEBUG bot] event.messageId=${event.messageId}, event.senderImid=${event.senderImid}, isGroup=${isGroup}, mes=${mes.slice(0, 50)}`);
672
+ if (!event.messageId) {
673
+ logVerbose(`[DEBUG bot] WARNING: event.messageId is undefined/null!`);
674
+ }
675
+ if (!event.senderImid) {
676
+ logVerbose(`[DEBUG bot] WARNING: event.senderImid is undefined/null!`);
677
+ }
678
+ if (!isGroup) {
679
+ logVerbose(`[DEBUG bot] Not a group message, skipping reply-to`);
680
+ }
681
+
682
+ // Send "processing" hint if LLM takes longer than processingHintDelay seconds
683
+ // (default: 5s). Gives users feedback without spamming fast responses.
684
+ const processingHintEnabled = account.config.processingHint !== false;
685
+ let cancelProcessingHint: (() => void) | undefined;
686
+ let hintWasSent = false;
687
+ const dispatchStartTime = Date.now();
688
+ if (processingHintEnabled) {
689
+ const delayMs = (account.config.processingHintDelay ?? 5) * 1000;
690
+ const processingReplyTo =
691
+ isGroup && event.messageId
692
+ ? {
693
+ messageid: event.messageId,
694
+ preview: mes ? (mes.length > 100 ? mes.slice(0, 100) + "..." : mes) : "",
695
+ ...(event.senderImid ? { imid: event.senderImid } : {}),
696
+ replytype: "2" as const,
697
+ }
698
+ : undefined;
699
+ let cancelled = false;
700
+ cancelProcessingHint = () => { cancelled = true; };
701
+ setTimeout(() => {
702
+ if (cancelled) return;
703
+ hintWasSent = true;
704
+ sendInfoflowMessage({
705
+ cfg,
706
+ to,
707
+ contents: [{ type: "text", content: "⏳ 处理中..." }],
708
+ accountId: account.accountId,
709
+ replyTo: processingReplyTo,
710
+ }).catch(() => {});
711
+ }, delayMs);
712
+ }
713
+
714
+ const { dispatcherOptions, replyOptions } = createInfoflowReplyDispatcher({
715
+ cfg,
716
+ agentId: route.agentId,
717
+ accountId: account.accountId,
718
+ to,
719
+ statusSink,
720
+ // @mention the sender back when bot was directly @mentioned in a group
721
+ atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
722
+ // Pass mention IDs for LLM-driven @mention resolution in outbound text
723
+ mentionIds: isGroup ? event.mentionIds : undefined,
724
+ // Pass inbound messageId for outbound reply-to (group only)
725
+ replyToMessageId: isGroup ? event.messageId : undefined,
726
+ replyToPreview: isGroup ? mes : undefined,
727
+ replyToImid: isGroup ? event.senderImid : undefined,
728
+ // Message format: per-chat-type config, falling back to "text"
729
+ messageFormat: isGroup
730
+ ? (account.config.groupMessageFormat ?? "text")
731
+ : (account.config.dmMessageFormat ?? "text"),
732
+ });
733
+
734
+ // Cancel processing hint the moment the first real message starts being delivered,
735
+ // so the hint never appears after the bot's actual reply.
736
+ if (cancelProcessingHint) {
737
+ const originalDeliver = dispatcherOptions.deliver;
738
+ let hintCancelledOnDeliver = false;
739
+ dispatcherOptions.deliver = async (payload: Parameters<typeof originalDeliver>[0]) => {
740
+ if (!hintCancelledOnDeliver) {
741
+ hintCancelledOnDeliver = true;
742
+ cancelProcessingHint!();
743
+ }
744
+ return originalDeliver(payload);
745
+ };
746
+ }
747
+
748
+ const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
749
+ ctx: ctxPayload,
750
+ cfg,
751
+ dispatcherOptions,
752
+ replyOptions,
753
+ });
754
+
755
+ // Fallback cancel: in case deliver was never called (e.g. empty response)
756
+ cancelProcessingHint?.();
757
+
758
+ // If hint was shown to the user, send "搞定" so they know the task is done
759
+ if (hintWasSent) {
760
+ const elapsedSec = Math.round((Date.now() - dispatchStartTime) / 1000);
761
+ sendInfoflowMessage({
762
+ cfg,
763
+ to,
764
+ contents: [{ type: "text", content: `任务完成 ✨ (${elapsedSec}s)` }],
765
+ accountId: account.accountId,
766
+ }).catch(() => {});
767
+ }
768
+
769
+ const didReply = dispatchResult?.queuedFinal ?? false;
770
+
771
+ // Clear accumulated history after dispatch (it's now in the session transcript)
772
+ if (isGroup && historyKey) {
773
+ clearHistoryEntriesIfEnabled({
774
+ historyMap: chatHistories,
775
+ historyKey,
776
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
777
+ });
778
+ }
779
+
780
+ // Record bot reply timestamp for follow-up window tracking
781
+ if (didReply && isGroup && groupId !== undefined) {
782
+ recordGroupReply(String(groupId));
783
+ }
784
+
785
+ logVerbose(
786
+ `[infoflow] dispatch complete: ${chatType} from ${fromuser}, replied=${didReply}, finalCount=${dispatchResult?.counts.final ?? 0}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
787
+ );
788
+ }
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // Test-only exports (@internal)
792
+ // ---------------------------------------------------------------------------
793
+
794
+ /** @internal — Check if bot was mentioned in message body. Only exported for tests. */
795
+ export { checkBotMentioned as _checkBotMentioned } from "../security/group-policy.js";
796
+
797
+ /** @internal — Check if any watch-list name was @mentioned. Only exported for tests. */
798
+ export { checkWatchMentioned as _checkWatchMentioned } from "../security/group-policy.js";
799
+
800
+ /** @internal — Extract non-bot mention IDs. Only exported for tests. */
801
+ export { extractMentionIds as _extractMentionIds } from "../security/group-policy.js";