@core-workspace/infoflow-openclaw-plugin 2026.3.9 → 2026.3.27-beta.0

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CLAUDE.md +135 -0
  3. package/COLLABORATION_REPORT.md +209 -0
  4. package/PROJECT_GUIDE.md +355 -0
  5. package/README.md +158 -66
  6. package/docs/dev-guide.md +63 -50
  7. package/docs/qa-feature-list.md +452 -0
  8. package/docs/webhook-guide.md +178 -0
  9. package/index.ts +28 -2
  10. package/openclaw.plugin.json +131 -21
  11. package/package.json +16 -3
  12. package/scripts/deploy.sh +66 -7
  13. package/scripts/postinstall.cjs +80 -0
  14. package/skills/infoflow-dev/SKILL.md +2 -2
  15. package/skills/infoflow-dev/references/api.md +1 -1
  16. package/src/adapter/inbound/webhook-parser.ts +27 -5
  17. package/src/adapter/inbound/ws-receiver.ts +304 -43
  18. package/src/adapter/outbound/markdown-local-images.ts +80 -0
  19. package/src/adapter/outbound/reply-dispatcher.ts +146 -65
  20. package/src/adapter/outbound/target-resolver.ts +4 -3
  21. package/src/channel/accounts.ts +97 -22
  22. package/src/channel/channel.ts +456 -12
  23. package/src/channel/media.ts +20 -6
  24. package/src/channel/monitor.ts +8 -3
  25. package/src/channel/outbound.ts +358 -21
  26. package/src/channel/streaming.ts +740 -0
  27. package/src/commands/changelog.ts +80 -0
  28. package/src/commands/doctor.ts +545 -0
  29. package/src/commands/logs.ts +449 -0
  30. package/src/commands/version.ts +20 -0
  31. package/src/compat/openclaw-sdk.ts +218 -0
  32. package/src/handler/message-handler.ts +673 -166
  33. package/src/logging.ts +1 -1
  34. package/src/runtime.ts +1 -1
  35. package/src/security/dm-policy.ts +1 -4
  36. package/src/security/group-policy.ts +174 -51
  37. package/src/tools/actions/index.ts +15 -13
  38. package/src/tools/cron/relay.ts +1154 -0
  39. package/src/tools/hooks/index.ts +13 -1
  40. package/src/tools/index.ts +714 -32
  41. package/src/types.ts +144 -25
  42. package/src/utils/audio/g722/dct_tables.ts +381 -0
  43. package/src/utils/audio/g722/decoder.ts +919 -0
  44. package/src/utils/audio/g722/defs.ts +105 -0
  45. package/src/utils/audio/g722/hd-parser.ts +247 -0
  46. package/src/utils/audio/g722/huff_tables.ts +240 -0
  47. package/src/utils/audio/g722/index.ts +78 -0
  48. package/src/utils/audio/g722/output_decoded.pcm +0 -0
  49. package/src/utils/audio/g722/output_decoded.wav +0 -0
  50. package/src/utils/audio/g722/tables.ts +173 -0
  51. package/src/utils/audio/g722/test_api.ts +31 -0
  52. package/src/utils/audio/g722/test_voice.hd +0 -0
  53. package/src/utils/bos/im-bos-client.ts +219 -0
  54. package/src/utils/group-agent-cache.ts +142 -0
  55. package/src/utils/token-adapter.ts +120 -51
@@ -1,21 +1,32 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import {
2
5
  buildPendingHistoryContextFromMap,
3
6
  clearHistoryEntriesIfEnabled,
4
7
  DEFAULT_GROUP_HISTORY_LIMIT,
5
- type HistoryEntry,
6
8
  recordPendingHistoryEntryIfEnabled,
7
- buildAgentMediaPayload,
8
- } from "openclaw/plugin-sdk";
9
+ } from "../compat/openclaw-sdk.js";
10
+ import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
11
+ import { buildAgentMediaPayload } from "../compat/openclaw-sdk.js";
12
+ import { createInfoflowReplyDispatcher } from "../adapter/outbound/reply-dispatcher.js";
9
13
  import { resolveInfoflowAccount } from "../channel/accounts.js";
14
+ import { sendInfoflowMessage, queryASRResult, fetchGroupMemberList } from "../channel/outbound.js";
15
+ import {
16
+ InfoflowStreamingCardSession,
17
+ normalizeStreamingFallbackFormat,
18
+ } from "../channel/streaming.js";
10
19
  import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "../logging.js";
11
- import { createInfoflowReplyDispatcher } from "../adapter/outbound/reply-dispatcher.js";
12
- import { sendInfoflowMessage } from "../channel/outbound.js";
13
20
  import { getInfoflowRuntime } from "../runtime.js";
21
+ import { checkDmPolicy, checkGroupPolicy } from "../security/dm-policy.js";
14
22
  import {
15
23
  checkBotMentioned,
16
24
  checkWatchMentioned,
17
25
  checkWatchRegex,
18
26
  extractMentionIds,
27
+ getBotRobotidFromBody,
28
+ checkReplyToBot,
29
+ hasOtherMentions,
19
30
  recordGroupReply,
20
31
  isWithinFollowUpWindow,
21
32
  resolveGroupConfig,
@@ -23,23 +34,56 @@ import {
23
34
  buildWatchRegexPrompt,
24
35
  buildFollowUpPrompt,
25
36
  buildProactivePrompt,
26
- type InfoflowBodyItem,
27
37
  } from "../security/group-policy.js";
28
- import { checkDmPolicy, checkGroupPolicy } from "../security/dm-policy.js";
29
38
  import type {
30
39
  InfoflowChatType,
40
+ InfoflowInboundBodyItem,
31
41
  InfoflowMessageEvent,
32
42
  InfoflowMentionIds,
33
43
  InfoflowReplyMode,
44
+ InfoflowGroupSessionMode,
34
45
  InfoflowGroupConfig,
46
+ InfoflowSender,
35
47
  HandleInfoflowMessageParams,
36
48
  HandlePrivateChatParams,
37
49
  HandleGroupChatParams,
38
- ResolvedInfoflowAccount,
39
50
  } from "../types.js";
51
+ import { descSender, type InfoflowMessageFormat } from "../types.js";
52
+ import { decodeHdToWav } from "../utils/audio/g722/index.js";
53
+ import { isCacheValid, updateGroupAgentCache } from "../utils/group-agent-cache.js";
40
54
 
41
55
  // Re-export types for external consumers
42
- export type { InfoflowChatType, InfoflowMessageEvent } from "../types.js";
56
+ export type { InfoflowChatType, InfoflowMessageEvent, InfoflowSender } from "../types.js";
57
+
58
+ function buildInfoflowCronTargetPrompt(params: { chatType: InfoflowChatType; to: string }): string {
59
+ const target = `infoflow:${params.to}`;
60
+ if (params.chatType === "group") {
61
+ return [
62
+ `当前会话来自如流群,当前群发送目标固定为 ${target}。`,
63
+ "如果你要创建定时消息、cron、system-event,必须优先使用 infoflow_cron 工具。",
64
+ `infoflow_cron 会自动绑定当前群 ${target},并在有 FromUserId 时附带创建人信息。`,
65
+ `只有在你明确无法使用 infoflow_cron 时,才允许显式写 target=${target}。`,
66
+ ].join("");
67
+ }
68
+
69
+ return [
70
+ `当前会话来自如流单聊,发送目标为 ${target}。`,
71
+ "如果你要创建定时消息、cron、system-event,必须优先使用 infoflow_cron 工具。",
72
+ `infoflow_cron 会直接使用当前会话可信的 FromUserId,并绑定为 ${target}。`,
73
+ ].join("");
74
+ }
75
+
76
+ function appendInfoflowSystemPrompt(
77
+ existing: string | undefined,
78
+ extra: string | undefined,
79
+ ): string | undefined {
80
+ const next = extra?.trim();
81
+ if (!next) {
82
+ return existing;
83
+ }
84
+ const prev = existing?.trim();
85
+ return prev ? `${prev}\n\n---\n\n${next}` : next;
86
+ }
43
87
 
44
88
  // ---------------------------------------------------------------------------
45
89
  // Group reply tracking (in-memory) for follow-up window
@@ -55,52 +99,86 @@ const chatHistories = new Map<string, HistoryEntry[]>();
55
99
  export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
56
100
  const { cfg, msgData, accountId, statusSink } = params;
57
101
 
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 ?? "");
102
+ logVerbose(`[infoflow:dm] raw private message:\n${JSON.stringify(msgData, null, 2)}`);
61
103
 
62
- // Extract sender name (FromUserName is more human-readable than FromUserId)
104
+ // ---------------------------------------------------------------------------
105
+ // 1. Extract fields from msgData
106
+ // ---------------------------------------------------------------------------
107
+ const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
108
+ let mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
63
109
  const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
110
+ const rawImid = msgData.FromId ?? msgData.fromid ?? msgData.imid ?? msgData.fromimid;
111
+ const imid = rawImid != null ? String(rawImid) : undefined;
112
+ // 构建私聊发送者:目前仅支持普通账户
113
+ const buildSender = (): InfoflowSender | undefined => {
114
+ if (!fromuser) return undefined;
115
+ return {
116
+ kind: "regular",
117
+ userid: fromuser,
118
+ name: senderName || undefined,
119
+ imid: imid,
120
+ };
121
+ };
122
+
123
+ const sender = buildSender();
124
+ if (!sender) {
125
+ getInfoflowBotLog().warn(`[inbound:dm] dropped: fromuser is empty`);
126
+ return;
127
+ }
64
128
 
65
129
  // Extract message ID for dedup tracking
66
130
  const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
67
131
  const messageIdStr = messageId != null ? String(messageId) : undefined;
68
132
 
133
+ // Extract secondary message ID for private reply (msgid2)
134
+ const rawMsgid2 = msgData.MsgId2 ?? msgData.msgid2;
135
+ const msgid2Str = rawMsgid2 != null ? String(rawMsgid2) : undefined;
136
+
69
137
  // Extract timestamp (CreateTime is in seconds, convert to milliseconds)
70
138
  const createTime = msgData.CreateTime ?? msgData.createtime;
71
139
  const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
72
140
 
73
- // Detect image messages: MsgType=image with PicUrl
74
141
  const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
75
142
  const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
143
+ const voiceUrl = String(msgData.VoiceUrl ?? msgData.voiceUrl ?? "");
144
+ const fromPlatform = String(msgData.FromPlatform ?? msgData.fromPlatform ?? "");
145
+ const agentId = String(msgData.agentId ?? "");
146
+ const openCode = String(msgData.OpenCode ?? msgData.openCode ?? "");
147
+
148
+ // Image handling
76
149
  const imageUrls: string[] = [];
77
150
  if (msgType === "image" && picUrl.trim()) {
78
151
  imageUrls.push(picUrl.trim());
79
152
  }
80
153
 
81
- logVerbose(
82
- `[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, mes=${mes}, msgType=${msgType}, raw msgData: ${JSON.stringify(msgData)}`,
154
+ getInfoflowBotLog().info(
155
+ `[inbound:dm] from=${fromuser}, name=${senderName}, msgType=${msgType}, msgId=${messageIdStr ?? "?"}, text=${mes.slice(0, 80)}${mes.length > 80 ? "..." : ""}, images=${imageUrls.length}${msgType === "voice" ? `, voice=true` : ""}`,
83
156
  );
84
157
 
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)) {
158
+ if (!fromuser || (!mes.trim() && imageUrls.length === 0 && msgType !== "voice")) {
159
+ getInfoflowBotLog().warn(
160
+ `[inbound:dm] dropped: fromuser=${fromuser || "(empty)"}, mes_empty=${!mes.trim()}, images=${imageUrls.length}`,
161
+ );
90
162
  return;
91
163
  }
92
164
 
93
- // Check dmPolicy: send a hint when user is not authorized
165
+ // ---------------------------------------------------------------------------
166
+ // 3. dmPolicy check (before any network calls)
167
+ // ---------------------------------------------------------------------------
94
168
  const account = resolveInfoflowAccount({ cfg, accountId });
95
169
  const dmResult = checkDmPolicy(account, fromuser);
96
170
  if (!dmResult.allowed) {
97
- logVerbose(`[infoflow] private message rejected: dmPolicy=allowlist, fromuser=${fromuser}`);
171
+ getInfoflowBotLog().warn(
172
+ `[inbound:dm] rejected: dmPolicy=${account.config.dmPolicy ?? "allowlist"}, fromuser=${fromuser}`,
173
+ );
98
174
  sendInfoflowMessage({
99
175
  cfg,
100
176
  to: fromuser,
101
177
  contents: [{ type: "text", content: "🚫 抱歉,您暂无使用权限,请联系龙虾主开通~" }],
102
178
  accountId: account.accountId,
103
- }).catch(() => { /* ignore send errors */ });
179
+ }).catch(() => {
180
+ /* ignore send errors */
181
+ });
104
182
  return;
105
183
  }
106
184
  if ("note" in dmResult && dmResult.note === "pairing") {
@@ -108,7 +186,89 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
108
186
  logVerbose(`[infoflow] private message: dmPolicy=pairing, fromuser=${fromuser}`);
109
187
  }
110
188
 
111
- // For image-only messages (no text), use placeholder
189
+ // ---------------------------------------------------------------------------
190
+ // 4. Voice handling: Content empty → .mp3 download or .hd ASR
191
+ // ---------------------------------------------------------------------------
192
+ const isVoiceMp3 = msgType === "voice" && voiceUrl.includes(".mp3");
193
+ let localWavPath: string | undefined;
194
+ if (msgType === "voice" && !mes.trim() && voiceUrl) {
195
+ if (isVoiceMp3) {
196
+ // mp3 语音文件:由 handleInfoflowMessage 下载到本地,路径传给 openclaw
197
+ mes = "[语音消息]";
198
+ } else {
199
+ // .hd 等格式:通过 VoiceUrl MD5 调用 ASR 获取识别文本
200
+ const md5 = extractMd5FromVoiceUrl(voiceUrl);
201
+ if (md5) {
202
+ const asrResult = await queryASRResult({ account, md5 });
203
+ if (asrResult.ok && asrResult.content) {
204
+ mes = asrResult.content;
205
+ getInfoflowBotLog().info(
206
+ `[inbound:dm] voice ASR success: md5=${md5}, text=${mes.slice(0, 80)}`,
207
+ );
208
+ } else {
209
+ getInfoflowBotLog().info(
210
+ `[inbound:dm] voice ASR failed: md5=${md5}, error=${asrResult.error ?? "empty"}`,
211
+ );
212
+ if (asrResult.error?.includes("plat.clientError, 非法的企业机器人")) {
213
+ sendInfoflowMessage({
214
+ cfg,
215
+ to: fromuser,
216
+ contents: [
217
+ {
218
+ type: "text",
219
+ content:
220
+ "当前机器人暂不支持语音转文本,为了更好的体验,可联系管理员在企业管理后台,应用中心-设置-开放API开启[单聊语音消息转文本]权限",
221
+ },
222
+ ],
223
+ accountId: account.accountId,
224
+ }).catch(() => {});
225
+ }
226
+ // ASR 失败回退:下载 .hd → decodeHdToWav → .wav 交给 openclaw 处理
227
+ try {
228
+ const voiceTmpDir = join(homedir(), ".openclaw", "tmp", "voice");
229
+ mkdirSync(voiceTmpDir, { recursive: true });
230
+ const hdPath = join(voiceTmpDir, `${md5}.hd`);
231
+ const wavPath = join(voiceTmpDir, `${md5}.wav`);
232
+ const hdRes = await fetch(voiceUrl);
233
+ if (!hdRes.ok) throw new Error(`download hd failed: status=${hdRes.status}`);
234
+ writeFileSync(hdPath, Buffer.from(await hdRes.arrayBuffer()));
235
+ const decodeResult = decodeHdToWav(hdPath, wavPath);
236
+ try {
237
+ unlinkSync(hdPath);
238
+ } catch {
239
+ /* ignore */
240
+ }
241
+ if (decodeResult) {
242
+ mes = "[语音消息]";
243
+ localWavPath = wavPath;
244
+ getInfoflowBotLog().info(
245
+ `[inbound:dm] voice hd→wav ok: md5=${md5}, duration=${decodeResult.duration.toFixed(1)}s, path=${wavPath}`,
246
+ );
247
+ } else {
248
+ getInfoflowBotLog().info(`[inbound:dm] voice hd→wav decode failed: md5=${md5}`);
249
+ }
250
+ } catch (err) {
251
+ getInfoflowBotLog().info(
252
+ `[inbound:dm] voice hd download/decode error: md5=${md5}, ${formatInfoflowError(err)}`,
253
+ );
254
+ }
255
+ }
256
+ } else {
257
+ getInfoflowBotLog().info(
258
+ `[inbound:dm] voice: cannot extract MD5 from VoiceUrl=${voiceUrl.slice(0, 80)}`,
259
+ );
260
+ }
261
+ }
262
+ }
263
+
264
+ // After ASR, if still no content (and not mp3 voice), drop the message
265
+ if (!mes.trim() && imageUrls.length === 0) {
266
+ return;
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // 5. Build effective message and dispatch
271
+ // ---------------------------------------------------------------------------
112
272
  let effectiveMes = mes.trim();
113
273
  if (!effectiveMes && imageUrls.length > 0) {
114
274
  effectiveMes = "<media:image>";
@@ -118,13 +278,18 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
118
278
  await handleInfoflowMessage({
119
279
  cfg,
120
280
  event: {
121
- fromuser,
281
+ sender,
122
282
  mes: effectiveMes,
123
283
  chatType: "direct",
124
- senderName,
125
284
  messageId: messageIdStr,
285
+ msgid2: msgid2Str,
126
286
  timestamp,
127
287
  imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
288
+ voiceUrl: msgType === "voice" && voiceUrl ? voiceUrl : undefined,
289
+ localVoicePath: localWavPath,
290
+ fromPlatform: fromPlatform || undefined,
291
+ agentId: agentId || undefined,
292
+ openCode: openCode || undefined,
128
293
  },
129
294
  accountId,
130
295
  statusSink,
@@ -142,12 +307,17 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
142
307
  const header = (msgData.message as Record<string, unknown>)?.header as
143
308
  | Record<string, unknown>
144
309
  | undefined;
145
- const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
310
+ const rawFromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
146
311
 
147
312
  // 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;
313
+ const senderImid =
314
+ msgData.fromid ?? header?.imid ?? header?.fromimid ?? msgData.imid ?? msgData.fromimid;
149
315
  const senderImidStr = senderImid != null ? String(senderImid) : undefined;
150
316
 
317
+ // 机器人发送者在 ALL_MESSAGE_FORWARD 中通常没有 fromuserid,
318
+ // 用 senderImidStr(数字 robot ID)作为兜底,加 "bot:" 前缀避免与人类用户 ID 碰撞
319
+ const senderIsBot = !rawFromuser && !!senderImidStr;
320
+
151
321
  // Extract message ID (priority: header.messageid > header.msgid > MsgId)
152
322
  const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
153
323
  const messageIdStr = messageId != null ? String(messageId) : undefined;
@@ -161,42 +331,63 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
161
331
  const timestamp = rawTime != null ? Number(rawTime) : Date.now();
162
332
 
163
333
  // 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) {
334
+ logVerbose(`[inbound:group:raw] msgData: ${JSON.stringify(msgData, null, 2)}`);
335
+
336
+ // 构建群聊发送者(含 name):机器人 agentid 暂无处取值(不填),imid 保持不变;普通用户用 rawFromuser 作为 userid
337
+ const buildSender = (): InfoflowSender | undefined => {
338
+ const name = String(
339
+ header?.username ?? header?.nickname ?? msgData.username ?? (rawFromuser || senderImidStr),
340
+ );
341
+ if (senderIsBot) {
342
+ return senderImidStr ? { kind: "robot", imid: senderImidStr, name } : undefined;
343
+ }
344
+ return rawFromuser
345
+ ? { kind: "regular", userid: rawFromuser, imid: senderImidStr, name }
346
+ : undefined;
347
+ };
348
+
349
+ const sender = buildSender();
350
+ if (!sender) {
351
+ getInfoflowBotLog().warn(
352
+ `[inbound:group] dropped: fromuser and senderImidStr both empty, groupId=${groupid}`,
353
+ );
181
354
  return;
182
355
  }
183
356
 
184
357
  // Extract message content from body array or flat content field
185
358
  const message = msgData.message as Record<string, unknown> | undefined;
186
- const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowBodyItem[];
359
+ const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowInboundBodyItem[];
187
360
 
188
361
  // Resolve account to get robotName for mention detection
189
362
  const account = resolveInfoflowAccount({ cfg, accountId });
190
363
  const robotName = account.config.robotName;
191
364
 
192
365
  // Check groupPolicy allowlist
193
- const wasMentionedEarly = checkBotMentioned(bodyItems, robotName);
366
+ // wasMentioned 判断优先级:
367
+ // 1. ws-receiver 已注入 msgData.wasMentioned 时优先使用
368
+ // 2. webhook 模式下:
369
+ // - MESSAGE_RECEIVE: 明确 @机器人 事件
370
+ // - ALL_MESSAGE_FORWARD: 全量消息,需要回退到 body AT 检测
371
+ // 3. 兜底:body 里 AT 元素(robotid 或 robotName)判定
372
+ const rawEventType = String(msgData.eventtype ?? "");
373
+ const wasMentionedEarly =
374
+ msgData.wasMentioned === true
375
+ ? true
376
+ : rawEventType === "ALL_MESSAGE_FORWARD"
377
+ ? checkBotMentioned(bodyItems, account.config)
378
+ : rawEventType === "MESSAGE_RECEIVE"
379
+ ? true // 明确的 @机器人 事件
380
+ : checkBotMentioned(bodyItems, account.config);
194
381
  const groupPolicyResult = checkGroupPolicy(account, groupid, wasMentionedEarly);
195
382
  if (!groupPolicyResult.allowed) {
196
383
  if (groupPolicyResult.reason === "disabled") {
197
- logVerbose(`[infoflow] group message rejected: groupPolicy=disabled`);
384
+ getInfoflowBotLog().warn(
385
+ `[inbound:group] rejected: groupPolicy=disabled, groupId=${groupid}`,
386
+ );
198
387
  } else {
199
- logVerbose(`[infoflow] group message rejected: group=${groupPolicyResult.groupIdStr} not in groupAllowFrom`);
388
+ getInfoflowBotLog().warn(
389
+ `[inbound:group] rejected: groupPolicy=allowlist, group=${groupPolicyResult.groupIdStr} not in groupAllowFrom`,
390
+ );
200
391
  // 发送无权限提示,仅当消息是 @机器人 时才回复,避免在无关群里刷屏
201
392
  if (groupPolicyResult.wasMentioned && groupPolicyResult.groupIdStr) {
202
393
  sendInfoflowMessage({
@@ -204,7 +395,9 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
204
395
  to: `group:${groupPolicyResult.groupIdStr}`,
205
396
  contents: [{ type: "text", content: "🚫 抱歉,该群暂无使用权限,请联系龙虾主开通~" }],
206
397
  accountId: account.accountId,
207
- }).catch(() => { /* ignore send errors */ });
398
+ }).catch(() => {
399
+ /* ignore send errors */
400
+ });
208
401
  }
209
402
  }
210
403
  return;
@@ -213,12 +406,67 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
213
406
  // Check if bot was @mentioned (by robotName)
214
407
  const wasMentioned = wasMentionedEarly;
215
408
 
409
+ // --- robotId auto-discovery + self-message ignore ---
410
+ let effectiveRobotId = account.config.robotId;
411
+ if (wasMentioned) {
412
+ const discoveredRobotId = getBotRobotidFromBody(bodyItems, robotName, effectiveRobotId);
413
+ if (discoveredRobotId && discoveredRobotId !== effectiveRobotId) {
414
+ effectiveRobotId = discoveredRobotId;
415
+ // Persist discovered robotId to config (fire-and-forget)
416
+ try {
417
+ const runtime = getInfoflowRuntime();
418
+ const currentCfg = runtime.config.loadConfig();
419
+ const section = (currentCfg.channels?.["infoflow"] ?? {}) as Record<string, unknown>;
420
+ const accounts = (section.accounts ?? {}) as Record<string, Record<string, unknown>>;
421
+ // When `accounts` block exists (multi-account mode), only write to the matching account entry;
422
+ // when no `accounts` block exists (single-account / legacy mode), write to the channel root.
423
+ const acctCfg: Record<string, unknown> | undefined =
424
+ accounts[accountId] ?? (!section.accounts ? section : undefined);
425
+ if (!acctCfg) {
426
+ logVerbose(
427
+ `[infoflow:bot] cannot persist robotId: accountId=${accountId} not found in config accounts`,
428
+ );
429
+ } else {
430
+ acctCfg.robotId = discoveredRobotId;
431
+ runtime.config.writeConfigFile(currentCfg);
432
+ logVerbose(
433
+ `[infoflow:bot] robotId auto-discovered: ${discoveredRobotId} for account=${accountId}`,
434
+ );
435
+ }
436
+ } catch (err) {
437
+ logVerbose(`[infoflow:bot] failed to persist robotId: ${formatInfoflowError(err)}`);
438
+ }
439
+ }
440
+ }
441
+ // Ignore own messages: if fromid matches the bot's robotId
442
+ const fromid = msgData.fromid ?? header?.fromid;
443
+ if (effectiveRobotId && fromid != null && String(fromid) === effectiveRobotId) {
444
+ logVerbose(
445
+ `[infoflow:bot] ignoring own message: fromid=${fromid}, robotId=${effectiveRobotId}`,
446
+ );
447
+ return;
448
+ }
449
+
216
450
  // Extract non-bot mention IDs (userIds + agentIds) for LLM-driven @mentions
217
- const mentionIds = extractMentionIds(bodyItems, robotName);
451
+ const mentionIds = extractMentionIds(bodyItems, robotName, effectiveRobotId);
452
+
453
+ // Preload group agent name→agentId cache (skip if cache is still valid)
454
+ if (groupid !== undefined && !isCacheValid(groupid)) {
455
+ try {
456
+ const memberResult = await fetchGroupMemberList({ account, groupId: groupid });
457
+ if (memberResult.ok && memberResult.agents) {
458
+ updateGroupAgentCache(groupid, memberResult.agents);
459
+ }
460
+ } catch (err) {
461
+ logVerbose(`[inbound:group] group agent cache preload failed: ${formatInfoflowError(err)}`);
462
+ }
463
+ }
218
464
 
219
- // Build two versions: mes (for CommandBody, no @xxx) and rawMes (for RawBody, with @xxx)
465
+ // Build three versions: mes (CommandBody, no @xxx), rawMes (RawBody, with @xxx),
466
+ // agentVisibleText (for LLM, with @name (robotid:N))
220
467
  let textContent = "";
221
468
  let rawTextContent = "";
469
+ let agentVisibleText = "";
222
470
  const replyContextItems: string[] = [];
223
471
  const imageUrls: string[] = [];
224
472
  if (Array.isArray(bodyItems)) {
@@ -232,43 +480,57 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
232
480
  } else if (item.type === "TEXT") {
233
481
  textContent += item.content ?? "";
234
482
  rawTextContent += item.content ?? "";
483
+ agentVisibleText += item.content ?? "";
235
484
  } else if (item.type === "LINK") {
236
485
  const label = item.label ?? "";
237
486
  if (label) {
238
487
  textContent += ` ${label} `;
239
488
  rawTextContent += ` ${label} `;
489
+ agentVisibleText += ` ${label} `;
240
490
  }
241
491
  } else if (item.type === "AT") {
242
- // AT elements only go into rawTextContent, not textContent
243
492
  const name = item.name ?? "";
244
493
  if (name) {
245
- rawTextContent += `@${name} `;
494
+ const atLabel =
495
+ item.robotid != null
496
+ ? `@${name}(robotid:${item.robotid}) `
497
+ : `@${name}(userid:${item.userid}) `;
498
+ textContent += atLabel;
499
+ rawTextContent += atLabel;
500
+ agentVisibleText += atLabel;
246
501
  }
247
502
  } else if (item.type === "IMAGE") {
248
503
  // 提取图片下载地址
249
- logVerbose(`[DEBUG bot.groupchat] IMAGE item: ${JSON.stringify(item, null, 2)}`);
250
504
  const url = item.downloadurl;
251
505
  if (typeof url === "string" && url.trim()) {
252
- logVerbose(`[DEBUG bot.groupchat] 提取到图片URL: ${url}`);
253
506
  imageUrls.push(url.trim());
254
- } else {
255
- logVerbose(`[DEBUG bot.groupchat] WARNING: IMAGE item 缺少有效的 downloadurl 字段`);
256
507
  }
508
+ } else if (item.type === "MD") {
509
+ // Markdown 消息(机器人等场景),提取文本内容
510
+ textContent += item.content ?? "";
511
+ rawTextContent += item.content ?? "";
512
+ agentVisibleText += item.content ?? "";
257
513
  }
258
514
  }
259
515
  }
260
516
 
261
517
  let mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
262
518
  const rawMes = rawTextContent.trim() || mes;
519
+ const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
263
520
 
264
521
  const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
265
522
 
523
+ // Check if this message is a quoted reply to one of the bot's own previously sent messages
524
+ const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
525
+
266
526
  if (!mes && !replyContext && imageUrls.length === 0) {
527
+ getInfoflowBotLog().warn(
528
+ `[inbound:group] dropped: empty body, sender=${descSender(sender)}, groupId=${groupid}`,
529
+ );
267
530
  return;
268
531
  }
269
532
  // 纯图片消息:设置占位符
270
533
  if (!mes && imageUrls.length > 0) {
271
- logVerbose(`[DEBUG bot.groupchat] 纯图片消息: ${imageUrls.length} 张图片`);
272
534
  mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
273
535
  }
274
536
  // If mes is empty but replyContext exists, use a placeholder so the message is not dropped
@@ -276,19 +538,19 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
276
538
  mes = "(引用回复)";
277
539
  }
278
540
 
279
- // Extract sender name from header or fallback to fromuser
280
- const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
541
+ logVerbose(
542
+ `[inbound:group] ${descSender(sender)}, group=${groupid}, msgId=${messageIdStr ?? "?"}, mentioned=${wasMentioned}, text=${mes.slice(0, 80)}${mes.length > 80 ? "..." : ""}, images=${imageUrls.length}, reply=${replyContextItems.length > 0}`,
543
+ );
281
544
 
282
545
  // Delegate to the common message handler (group chat)
283
546
  await handleInfoflowMessage({
284
547
  cfg,
285
548
  event: {
286
- fromuser,
549
+ sender,
287
550
  mes,
288
551
  rawMes,
289
552
  chatType: "group",
290
553
  groupId: groupid,
291
- senderName,
292
554
  wasMentioned,
293
555
  messageId: messageIdStr,
294
556
  timestamp,
@@ -297,7 +559,8 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
297
559
  mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
298
560
  replyContext,
299
561
  imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
300
- senderImid: senderImidStr, // 传递发送者的 imid
562
+ bodyForAgent,
563
+ isReplyToBot: isReplyToBot || undefined,
301
564
  },
302
565
  accountId,
303
566
  statusSink,
@@ -310,23 +573,43 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
310
573
  */
311
574
  export async function handleInfoflowMessage(params: HandleInfoflowMessageParams): Promise<void> {
312
575
  const { cfg, event, accountId, statusSink } = params;
313
- const { fromuser, mes, chatType, groupId, senderName } = event;
576
+ const { sender, mes, chatType, groupId } = event;
577
+ const senderDesc = descSender(sender);
578
+ const senderImid = sender.imid;
579
+ // 带类型前缀的发送者标识,用于历史记录条目
580
+ const senderTag = sender.kind === "regular" ? `reg:${sender.userid}` : `bot:${sender.imid}`;
581
+ const bodyForAgent = event.bodyForAgent ?? mes;
314
582
 
315
583
  const account = resolveInfoflowAccount({ cfg, accountId });
316
584
  const core = getInfoflowRuntime();
317
585
 
318
586
  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
587
 
322
- // Resolve per-group config for replyMode gating
588
+ // Resolve per-group config for replyMode gating (needed for groupSessionMode)
323
589
  const groupCfg = isGroup ? resolveGroupConfig(account, groupId) : undefined;
324
590
 
325
591
  // "ignore" mode: discard immediately, no save, no think, no reply
326
592
  if (isGroup && groupCfg?.replyMode === "ignore") {
593
+ logVerbose(`[inbound:group] dropped: replyMode=ignore, ${senderDesc}, groupId=${groupId}`);
327
594
  return;
328
595
  }
329
596
 
597
+ // Determine group session mode for peerId generation (group config overrides account config)
598
+ const groupSessionMode = isGroup ? groupCfg!.groupSessionMode : "group";
599
+ // 会话路由 peerId:
600
+ // 群聊 user 模式:groupId:{groupId};{userid || imid},按用户分会话
601
+ // 群聊 group 模式:groupId
602
+ // 群聊无 groupId(兜底):userid || imid
603
+ // 私聊:userid || imid
604
+ const senderId = sender.kind === "regular" ? sender.userid : (sender.imid ?? "");
605
+ const peerId = isGroup
606
+ ? groupId !== undefined
607
+ ? groupSessionMode === "user"
608
+ ? `groupId:${groupId};${senderId}`
609
+ : String(groupId)
610
+ : senderId
611
+ : senderId;
612
+
330
613
  // Resolve route based on chat type
331
614
  const route = core.channel.routing.resolveAgentRoute({
332
615
  cfg,
@@ -347,10 +630,20 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
347
630
  sessionKey: route.sessionKey,
348
631
  });
349
632
 
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}`;
633
+ // 构建会话标签:
634
+ // 私聊 regular:优先 userid,fallback name,兜底"未知"
635
+ // 私聊 robot:优先 name,fallback imid,兜底"未知"
636
+ // 群聊 regular:group:{groupId}:{userid || imid}
637
+ // 群聊 robot:group:{groupId}:{name || imid}
638
+ const fromLabel = isGroup
639
+ ? sender.kind === "regular"
640
+ ? `group:${groupId}:${sender.userid || sender.imid}`
641
+ : `group:${groupId}:${sender.name || sender.imid}`
642
+ : sender.kind === "regular"
643
+ ? sender.userid || sender.name || "未知"
644
+ : sender.name || sender.imid || "未知";
645
+ const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${senderId}`;
646
+ const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${senderId}`;
354
647
 
355
648
  const body = core.channel.reply.formatAgentEnvelope({
356
649
  channel: "Infoflow",
@@ -358,7 +651,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
358
651
  timestamp: Date.now(),
359
652
  previousTimestamp,
360
653
  envelope: envelopeOptions,
361
- body: mes,
654
+ body: bodyForAgent,
362
655
  });
363
656
 
364
657
  // Inject accumulated group chat history into the body for context
@@ -395,9 +688,8 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
395
688
  const mediaList: Array<{ path: string; contentType?: string }> = [];
396
689
  const failReasons: string[] = [];
397
690
 
398
- logVerbose(`[DEBUG bot] 图片处理开始: imageUrls数量=${event.imageUrls?.length ?? 0}`);
691
+ logVerbose(`[inbound] 图片处理: urls=${event.imageUrls?.length ?? 0}`);
399
692
  if (event.imageUrls && event.imageUrls.length > 0) {
400
- logVerbose(`[DEBUG bot] 待下载图片URLs: ${JSON.stringify(event.imageUrls)}`);
401
693
  // Collect unique hostnames from image URLs for SSRF allowlist.
402
694
  // Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
403
695
  // internal IPs on Baidu's network, so they need to be explicitly allowed.
@@ -417,38 +709,88 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
417
709
  const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
418
710
  const results = await Promise.allSettled(
419
711
  urls.map(async (imageUrl) => {
420
- logVerbose(`[DEBUG bot] 开始下载图片: ${imageUrl}`);
421
712
  const fetched = await core.channel.media.fetchRemoteMedia({
422
713
  url: imageUrl,
423
714
  maxBytes: mediaMaxBytes,
424
715
  ssrfPolicy,
425
716
  });
426
- logVerbose(`[DEBUG bot] 图片下载成功: size=${fetched.buffer.length}, contentType=${fetched.contentType}`);
427
717
  const saved = await core.channel.media.saveMediaBuffer(
428
718
  fetched.buffer,
429
719
  fetched.contentType ?? undefined,
430
720
  "inbound",
431
721
  mediaMaxBytes,
432
722
  );
433
- logVerbose(`[infoflow] downloaded image from ${imageUrl}, saved to ${saved.path}`);
434
- logVerbose(`[DEBUG bot] 图片保存成功: path=${saved.path}`);
723
+ logVerbose(
724
+ `[inbound] image downloaded: url=${imageUrl}, size=${fetched.buffer.length}, saved=${saved.path}`,
725
+ );
435
726
  return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
436
727
  }),
437
728
  );
438
729
  for (const result of results) {
439
730
  if (result.status === "fulfilled") {
440
731
  mediaList.push(result.value);
441
- logVerbose(`[DEBUG bot] 图片处理成功: ${result.value.path}`);
442
732
  } else {
443
733
  const reason = String(result.reason);
444
- logVerbose(`[infoflow] failed to download image: ${reason}`);
445
- logVerbose(`[DEBUG bot] 图片下载失败: ${reason}`);
734
+ logVerbose(`[inbound] image download failed: ${reason}`);
446
735
  failReasons.push(reason);
447
736
  }
448
737
  }
449
738
  }
450
739
 
451
- logVerbose(`[DEBUG bot] 图片处理完成: 成功=${mediaList.length}, 失败=${failReasons.length}`);
740
+ logVerbose(`[inbound] 图片处理完成: ok=${mediaList.length}, fail=${failReasons.length}`);
741
+
742
+ // --- Resolve inbound media (voice mp3) ---
743
+ if (event.voiceUrl && event.voiceUrl.includes(".mp3")) {
744
+ try {
745
+ const voiceHostname = new URL(event.voiceUrl).hostname;
746
+ const ssrfPolicy = voiceHostname ? { allowedHostnames: [voiceHostname] } : undefined;
747
+ const fetched = await core.channel.media.fetchRemoteMedia({
748
+ url: event.voiceUrl,
749
+ maxBytes: mediaMaxBytes,
750
+ ssrfPolicy,
751
+ });
752
+ const saved = await core.channel.media.saveMediaBuffer(
753
+ fetched.buffer,
754
+ fetched.contentType ?? "audio/mpeg",
755
+ "inbound",
756
+ mediaMaxBytes,
757
+ );
758
+ mediaList.push({ path: saved.path, contentType: saved.contentType ?? "audio/mpeg" });
759
+ logVerbose(
760
+ `[inbound] voice mp3 downloaded: url=${event.voiceUrl}, size=${fetched.buffer.length}, saved=${saved.path}`,
761
+ );
762
+ } catch (err) {
763
+ const reason = formatInfoflowError(err);
764
+ logVerbose(`[inbound] voice mp3 download failed: ${reason}`);
765
+ failReasons.push(reason);
766
+ }
767
+ }
768
+
769
+ // --- Resolve inbound media (voice wav from hd→wav conversion) ---
770
+ if (event.localVoicePath) {
771
+ try {
772
+ const wavBuffer = readFileSync(event.localVoicePath);
773
+ const saved = await core.channel.media.saveMediaBuffer(
774
+ wavBuffer,
775
+ "audio/wav",
776
+ "inbound",
777
+ mediaMaxBytes,
778
+ );
779
+ mediaList.push({ path: saved.path, contentType: saved.contentType ?? "audio/wav" });
780
+ logVerbose(
781
+ `[inbound] voice wav loaded: path=${event.localVoicePath}, size=${wavBuffer.length}, saved=${saved.path}`,
782
+ );
783
+ try {
784
+ unlinkSync(event.localVoicePath);
785
+ } catch {
786
+ /* ignore */
787
+ }
788
+ } catch (err) {
789
+ const reason = formatInfoflowError(err);
790
+ logVerbose(`[inbound] voice wav load failed: ${reason}`);
791
+ failReasons.push(reason);
792
+ }
793
+ }
452
794
 
453
795
  const mediaPayload = buildAgentMediaPayload(mediaList);
454
796
 
@@ -489,8 +831,8 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
489
831
  ChatType: chatType,
490
832
  ConversationLabel: fromLabel,
491
833
  GroupSubject: isGroup ? `group:${groupId}` : undefined,
492
- SenderName: senderName || fromuser,
493
- SenderId: fromuser,
834
+ SenderName: sender.name,
835
+ SenderId: sender.kind === "regular" ? sender.userid : sender.imid,
494
836
  Provider: "infoflow",
495
837
  Surface: "infoflow",
496
838
  MessageSid: event.messageId ?? `${Date.now()}`,
@@ -521,50 +863,84 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
521
863
  let triggerReason = "direct-message";
522
864
  if (isGroup && groupCfg) {
523
865
  const { replyMode } = groupCfg;
524
- const groupIdStr = groupId !== undefined ? String(groupId) : undefined;
866
+ // Generate key for history tracking and follow-up window based on groupSessionMode
867
+ const historyReplyKey = groupId !== undefined ? String(groupId) : undefined;
525
868
 
526
869
  // "record" mode: save to session only, no think, no reply
527
870
  if (replyMode === "record") {
528
- if (groupIdStr) {
871
+ if (historyReplyKey) {
529
872
  logVerbose(
530
- `[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=record-mode`,
873
+ `[infoflow:bot] pending: from=${senderDesc}, group=${groupId}, reason=record-mode`,
531
874
  );
532
875
  recordPendingHistoryEntryIfEnabled({
533
876
  historyMap: chatHistories,
534
- historyKey: groupIdStr,
535
- entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
877
+ historyKey: historyReplyKey,
878
+ entry: {
879
+ sender: senderTag,
880
+ body: bodyForAgent,
881
+ timestamp: Date.now(),
882
+ },
536
883
  limit: DEFAULT_GROUP_HISTORY_LIMIT,
537
884
  });
538
885
  }
539
886
  return;
540
887
  }
541
888
 
542
- const canDetectMention = Boolean(account.config.robotName);
889
+ // eventtype 区分保证了 wasMentioned 的可靠性,不再依赖 robotName 配置
543
890
  const wasMentioned = event.wasMentioned === true;
544
891
 
892
+ // 消息里 @了其他机器人(有 robotid 的 AT 元素但不是当前机器人)时,
893
+ // 不应触发 followUp,避免"@别人说话"被误认为是跟进问题
894
+ const mentionsOtherBot =
895
+ !wasMentioned &&
896
+ (event.bodyItems ?? []).some((item) => item.type === "AT" && item.robotid != null);
897
+
545
898
  if (replyMode === "mention-only") {
546
- // Only reply if bot was @mentioned
547
- const shouldReply = canDetectMention && wasMentioned;
899
+ // Only reply if bot was @mentioned.
900
+ const shouldReply = wasMentioned;
548
901
  if (shouldReply) {
549
902
  triggerReason = "bot-mentioned";
550
903
  } else {
551
- // Check follow-up window: if bot recently replied, allow LLM to decide
904
+ // Check follow-up window: if bot recently replied, allow LLM to decide.
905
+ // Skip followUp if the message is @mentioning another bot.
552
906
  if (
907
+ !mentionsOtherBot &&
553
908
  groupCfg.followUp &&
554
- groupIdStr &&
555
- isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
909
+ historyReplyKey &&
910
+ isWithinFollowUpWindow(historyReplyKey, groupCfg.followUpWindow)
556
911
  ) {
912
+ // Follow-up window: if message @mentions others, record only without dispatching
913
+ if (hasOtherMentions(event.mentionIds)) {
914
+ logVerbose(
915
+ `[infoflow:bot] followUp record-only: from=${senderDesc}, group=${groupId}, reason=followup-but-other-mentioned`,
916
+ );
917
+ recordPendingHistoryEntryIfEnabled({
918
+ historyMap: chatHistories,
919
+ historyKey: historyReplyKey,
920
+ entry: {
921
+ sender: senderTag,
922
+ body: bodyForAgent,
923
+ timestamp: Date.now(),
924
+ },
925
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
926
+ });
927
+ return;
928
+ }
557
929
  triggerReason = "followUp";
558
- ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
930
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot);
559
931
  } else {
560
- if (groupIdStr) {
932
+ if (historyReplyKey) {
561
933
  logVerbose(
562
- `[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-only-not-mentioned`,
934
+ `[infoflow:bot] pending: from=${senderDesc}, group=${groupId}, reason=mention-only-not-mentioned`,
563
935
  );
564
936
  recordPendingHistoryEntryIfEnabled({
565
937
  historyMap: chatHistories,
566
- historyKey: groupIdStr,
567
- entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
938
+ historyKey: historyReplyKey,
939
+ entry: {
940
+ sender: senderTag,
941
+ body: bodyForAgent,
942
+ timestamp: Date.now(),
943
+ },
568
944
  limit: DEFAULT_GROUP_HISTORY_LIMIT,
569
945
  });
570
946
  }
@@ -572,8 +948,8 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
572
948
  }
573
949
  }
574
950
  } else if (replyMode === "mention-and-watch") {
575
- // Reply if bot @mentioned, or if watched person @mentioned, or follow-up
576
- const botMentioned = canDetectMention && wasMentioned;
951
+ // Reply if bot @mentioned, or if watched person @mentioned, or follow-up.
952
+ const botMentioned = wasMentioned;
577
953
  if (botMentioned) {
578
954
  triggerReason = "bot-mentioned";
579
955
  } else {
@@ -584,31 +960,56 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
584
960
  ? checkWatchMentioned(event.bodyItems, watchMentions)
585
961
  : undefined;
586
962
 
963
+ const matchedRegexPattern = groupCfg.watchRegex
964
+ ? checkWatchRegex(mes, groupCfg.watchRegex)
965
+ : undefined;
966
+
587
967
  if (matchedWatchId) {
588
968
  triggerReason = `watchMentions(${matchedWatchId})`;
589
969
  // Watch-mention triggered: instruct agent to reply only if confident
590
970
  ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
591
- } else if (groupCfg.watchRegex && checkWatchRegex(mes, groupCfg.watchRegex)) {
592
- triggerReason = `watchRegex(${groupCfg.watchRegex})`;
971
+ } else if (matchedRegexPattern) {
972
+ triggerReason = `watchRegex(${matchedRegexPattern})`;
593
973
  // Watch-content triggered: message matched configured regex pattern
594
- ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(groupCfg.watchRegex);
974
+ ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(matchedRegexPattern);
595
975
  } else if (
976
+ !mentionsOtherBot &&
596
977
  groupCfg.followUp &&
597
- groupIdStr &&
598
- isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
978
+ historyReplyKey &&
979
+ isWithinFollowUpWindow(historyReplyKey, groupCfg.followUpWindow)
599
980
  ) {
981
+ // Follow-up window: if message @mentions others, record only without dispatching
982
+ if (hasOtherMentions(event.mentionIds)) {
983
+ logVerbose(
984
+ `[infoflow:bot] followUp record-only: from=${senderDesc}, group=${groupId}, reason=followup-but-other-mentioned`,
985
+ );
986
+ recordPendingHistoryEntryIfEnabled({
987
+ historyMap: chatHistories,
988
+ historyKey: historyReplyKey,
989
+ entry: {
990
+ sender: senderTag,
991
+ body: bodyForAgent,
992
+ timestamp: Date.now(),
993
+ },
994
+ limit: DEFAULT_GROUP_HISTORY_LIMIT,
995
+ });
996
+ return;
997
+ }
600
998
  triggerReason = "followUp";
601
- // Follow-up window: let LLM decide if this is a follow-up
602
- ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
999
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot);
603
1000
  } else {
604
- if (groupIdStr) {
1001
+ if (historyReplyKey) {
605
1002
  logVerbose(
606
- `[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-and-watch-no-trigger`,
1003
+ `[infoflow:bot] pending: from=${senderDesc}, group=${groupId}, reason=mention-and-watch-no-trigger`,
607
1004
  );
608
1005
  recordPendingHistoryEntryIfEnabled({
609
1006
  historyMap: chatHistories,
610
- historyKey: groupIdStr,
611
- entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
1007
+ historyKey: historyReplyKey,
1008
+ entry: {
1009
+ sender: senderTag,
1010
+ body: bodyForAgent,
1011
+ timestamp: Date.now(),
1012
+ },
612
1013
  limit: DEFAULT_GROUP_HISTORY_LIMIT,
613
1014
  });
614
1015
  }
@@ -617,7 +1018,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
617
1018
  }
618
1019
  } else if (replyMode === "proactive") {
619
1020
  // Always think and potentially reply
620
- const botMentioned = canDetectMention && wasMentioned;
1021
+ const botMentioned = wasMentioned;
621
1022
  if (botMentioned) {
622
1023
  triggerReason = "bot-mentioned";
623
1024
  } else {
@@ -646,8 +1047,45 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
646
1047
  }
647
1048
  }
648
1049
 
649
- // Build unified target: "group:<id>" for group chat, username for private chat
650
- const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
1050
+ // 回复目标:群聊用 group:{groupId},私聊 regular userid,robot imid
1051
+ const to =
1052
+ isGroup && groupId !== undefined
1053
+ ? `group:${groupId}`
1054
+ : sender.kind === "regular"
1055
+ ? sender.userid
1056
+ : (sender.imid ?? "");
1057
+
1058
+ ctxPayload.GroupSystemPrompt = appendInfoflowSystemPrompt(
1059
+ ctxPayload.GroupSystemPrompt,
1060
+ buildInfoflowCronTargetPrompt({
1061
+ chatType,
1062
+ to,
1063
+ }),
1064
+ );
1065
+
1066
+ const configuredMessageFormat: InfoflowMessageFormat = isGroup
1067
+ ? (account.config.groupMessageFormat ?? "markdown")
1068
+ : (account.config.dmMessageFormat ?? "markdown");
1069
+ let effectiveMessageFormat = configuredMessageFormat;
1070
+ let streamingCard: InfoflowStreamingCardSession | undefined;
1071
+
1072
+ if (configuredMessageFormat === "streaming-card") {
1073
+ streamingCard = new InfoflowStreamingCardSession({
1074
+ cfg,
1075
+ accountId: account.accountId,
1076
+ to,
1077
+ answerFormat: "markdown",
1078
+ fallbackFormat: normalizeStreamingFallbackFormat(configuredMessageFormat),
1079
+ });
1080
+ const started = await streamingCard.start();
1081
+ if (!started) {
1082
+ logVerbose(
1083
+ `[streaming] failed to start streaming card for to=${to}, falling back to standard reply`,
1084
+ );
1085
+ streamingCard = undefined;
1086
+ effectiveMessageFormat = normalizeStreamingFallbackFormat(configuredMessageFormat);
1087
+ }
1088
+ }
651
1089
 
652
1090
  // Provide mention context to the LLM so it can decide who to @mention
653
1091
  if (isGroup && event.mentionIds) {
@@ -663,48 +1101,44 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
663
1101
  }
664
1102
  }
665
1103
 
666
- logVerbose(
667
- `[infoflow:bot] dispatching to LLM: from=${fromuser}, group=${groupId ?? "N/A"}, trigger=${triggerReason}, replyMode=${groupCfg?.replyMode ?? "N/A"}`,
1104
+ getInfoflowBotLog().info(
1105
+ `[dispatch] from=${senderDesc}, to=${to}, chatType=${chatType}, trigger=${triggerReason}, replyMode=${groupCfg?.replyMode ?? "N/A"}, session=${route.sessionKey}`,
668
1106
  );
669
1107
 
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
- }
1108
+ logVerbose(
1109
+ `[dispatch:detail] trigger=${triggerReason}, messageId=${event.messageId}, senderImid=${senderImid}, images=${mediaList.length}`,
1110
+ );
681
1111
 
682
1112
  // Send "processing" hint if LLM takes longer than processingHintDelay seconds
683
1113
  // (default: 5s). Gives users feedback without spamming fast responses.
684
- const processingHintEnabled = account.config.processingHint !== false;
1114
+ const processingHintEnabled =
1115
+ effectiveMessageFormat !== "streaming-card" && account.config.processingHint !== false;
685
1116
  let cancelProcessingHint: (() => void) | undefined;
686
1117
  let hintWasSent = false;
687
1118
  const dispatchStartTime = Date.now();
688
1119
  if (processingHintEnabled) {
689
1120
  const delayMs = (account.config.processingHintDelay ?? 5) * 1000;
690
1121
  const processingReplyTo =
691
- isGroup && event.messageId
1122
+ event.messageId
692
1123
  ? {
693
1124
  messageid: event.messageId,
694
1125
  preview: mes ? (mes.length > 100 ? mes.slice(0, 100) + "..." : mes) : "",
695
- ...(event.senderImid ? { imid: event.senderImid } : {}),
1126
+ ...(senderImid ? { imid: senderImid } : {}),
696
1127
  replytype: "2" as const,
1128
+ ...(event.msgid2 ? { msgid2: event.msgid2 } : {}),
697
1129
  }
698
1130
  : undefined;
699
1131
  let cancelled = false;
700
- cancelProcessingHint = () => { cancelled = true; };
1132
+ cancelProcessingHint = () => {
1133
+ cancelled = true;
1134
+ };
701
1135
  setTimeout(() => {
702
1136
  if (cancelled) return;
703
1137
  hintWasSent = true;
704
1138
  sendInfoflowMessage({
705
1139
  cfg,
706
1140
  to,
707
- contents: [{ type: "text", content: "⏳ 处理中..." }],
1141
+ contents: [{ type: "text", content: "👌收到啦" }],
708
1142
  accountId: account.accountId,
709
1143
  replyTo: processingReplyTo,
710
1144
  }).catch(() => {});
@@ -716,19 +1150,24 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
716
1150
  agentId: route.agentId,
717
1151
  accountId: account.accountId,
718
1152
  to,
1153
+ streamingCard,
719
1154
  statusSink,
720
- // @mention the sender back when bot was directly @mentioned in a group
721
- atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
1155
+ // @mention the sender back when bot was directly @mentioned in a group (skip for bot senders)
1156
+ atOptions:
1157
+ isGroup && event.wasMentioned && event.sender.kind === "regular"
1158
+ ? { atUserIds: [event.sender.userid] }
1159
+ : undefined,
722
1160
  // Pass mention IDs for LLM-driven @mention resolution in outbound text
723
1161
  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"),
1162
+ // Pass inbound messageId for outbound reply-to
1163
+ replyToMessageId: event.messageId,
1164
+ replyToPreview: mes,
1165
+ replyToImid: senderImid,
1166
+ replyToMsgid2: event.msgid2,
1167
+ // Message format: per-chat-type config, falling back to "markdown"
1168
+ messageFormat: effectiveMessageFormat,
1169
+ // Chunk size: per-account config, default 1800
1170
+ textChunkLimit: account.config.textChunkLimit ?? 1800,
732
1171
  });
733
1172
 
734
1173
  // Cancel processing hint the moment the first real message starts being delivered,
@@ -736,38 +1175,89 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
736
1175
  if (cancelProcessingHint) {
737
1176
  const originalDeliver = dispatcherOptions.deliver;
738
1177
  let hintCancelledOnDeliver = false;
739
- dispatcherOptions.deliver = async (payload: Parameters<typeof originalDeliver>[0]) => {
1178
+ dispatcherOptions.deliver = async (
1179
+ payload: Parameters<typeof originalDeliver>[0],
1180
+ info?: Parameters<typeof originalDeliver>[1],
1181
+ ) => {
740
1182
  if (!hintCancelledOnDeliver) {
741
1183
  hintCancelledOnDeliver = true;
742
1184
  cancelProcessingHint!();
743
1185
  }
744
- return originalDeliver(payload);
1186
+ return originalDeliver(payload, info);
745
1187
  };
746
1188
  }
747
1189
 
748
- const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
749
- ctx: ctxPayload,
750
- cfg,
751
- dispatcherOptions,
752
- replyOptions,
753
- });
1190
+ let hintCancelledOnStream = false;
1191
+ const cancelHintOnStream = () => {
1192
+ if (cancelProcessingHint && !hintCancelledOnStream) {
1193
+ hintCancelledOnStream = true;
1194
+ cancelProcessingHint();
1195
+ }
1196
+ };
1197
+
1198
+ const mergedReplyOptions = {
1199
+ ...replyOptions,
1200
+ onPartialReply: async (payload: { text?: string; mediaUrls?: string[] }) => {
1201
+ cancelHintOnStream();
1202
+ streamingCard?.noteAssistantText(payload.text);
1203
+ },
1204
+ onReasoningStream: async (payload: { text?: string; mediaUrls?: string[] }) => {
1205
+ cancelHintOnStream();
1206
+ streamingCard?.noteReasoning(payload.text);
1207
+ },
1208
+ onToolStart: async (payload: { name?: string; phase?: string }) => {
1209
+ cancelHintOnStream();
1210
+ streamingCard?.noteToolStart(payload);
1211
+ },
1212
+ onToolResult: async (payload: { text?: string; mediaUrls?: string[] }) => {
1213
+ cancelHintOnStream();
1214
+ streamingCard?.noteToolResult(payload.text);
1215
+ if (payload.mediaUrls?.length) {
1216
+ for (const mediaUrl of payload.mediaUrls) {
1217
+ streamingCard?.noteToolResult(`- 附件: ${mediaUrl}`);
1218
+ }
1219
+ }
1220
+ },
1221
+ onAssistantMessageStart: async () => {
1222
+ cancelHintOnStream();
1223
+ },
1224
+ };
1225
+
1226
+ let dispatchResult:
1227
+ | Awaited<ReturnType<typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher>>
1228
+ | undefined;
1229
+ try {
1230
+ dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1231
+ ctx: ctxPayload,
1232
+ cfg,
1233
+ dispatcherOptions,
1234
+ replyOptions: mergedReplyOptions,
1235
+ });
1236
+ } catch (err) {
1237
+ await streamingCard?.failWithMessage("处理出错,请稍后重试");
1238
+ throw err;
1239
+ }
754
1240
 
755
1241
  // Fallback cancel: in case deliver was never called (e.g. empty response)
756
1242
  cancelProcessingHint?.();
757
1243
 
758
1244
  // 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
- }
1245
+ // if (hintWasSent) {
1246
+ // const elapsedSec = Math.round((Date.now() - dispatchStartTime) / 1000);
1247
+ // sendInfoflowMessage({
1248
+ // cfg,
1249
+ // to,
1250
+ // contents: [{ type: "text", content: `任务完成 ✨ (${elapsedSec}s)` }],
1251
+ // accountId: account.accountId,
1252
+ // }).catch(() => {});
1253
+ // }
768
1254
 
769
1255
  const didReply = dispatchResult?.queuedFinal ?? false;
770
1256
 
1257
+ if (!didReply && streamingCard?.isUsable()) {
1258
+ await streamingCard.complete();
1259
+ }
1260
+
771
1261
  // Clear accumulated history after dispatch (it's now in the session transcript)
772
1262
  if (isGroup && historyKey) {
773
1263
  clearHistoryEntriesIfEnabled({
@@ -779,14 +1269,31 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
779
1269
 
780
1270
  // Record bot reply timestamp for follow-up window tracking
781
1271
  if (didReply && isGroup && groupId !== undefined) {
782
- recordGroupReply(String(groupId));
1272
+ const replyKey = String(groupId);
1273
+ recordGroupReply(replyKey);
783
1274
  }
784
1275
 
785
- logVerbose(
786
- `[infoflow] dispatch complete: ${chatType} from ${fromuser}, replied=${didReply}, finalCount=${dispatchResult?.counts.final ?? 0}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
1276
+ getInfoflowBotLog().info(
1277
+ `[dispatch:done] from=${senderDesc}, to=${to}, replied=${didReply}, blocks=${dispatchResult?.counts.final ?? 0}, elapsed=${Date.now() - dispatchStartTime}ms`,
787
1278
  );
788
1279
  }
789
1280
 
1281
+ /**
1282
+ * Extracts the MD5 hash from a VoiceUrl's fileid parameter.
1283
+ * Example: "http://...?fileid=f161a9fd88ca2635ca1334c006c4ffb7.hd" → "F161A9FD88CA2635CA1334C006C4FFB7"
1284
+ */
1285
+ export function extractMd5FromVoiceUrl(voiceUrl: string): string | undefined {
1286
+ try {
1287
+ const url = new URL(voiceUrl);
1288
+ const fileid = url.searchParams.get("fileid") ?? "";
1289
+ // Remove file extension (e.g. ".hd") and return uppercase MD5
1290
+ const md5 = fileid.replace(/\.[^.]+$/, "");
1291
+ return md5 && /^[0-9a-f]{32}$/i.test(md5) ? md5.toUpperCase() : undefined;
1292
+ } catch {
1293
+ return undefined;
1294
+ }
1295
+ }
1296
+
790
1297
  // ---------------------------------------------------------------------------
791
1298
  // Test-only exports (@internal)
792
1299
  // ---------------------------------------------------------------------------