@chbo297/infoflow 2026.3.9 → 2026.3.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.14
4
+
5
+ ### 新功能
6
+
7
+ #### watchRegex 支持数组
8
+
9
+ - `watchRegex` 可配置为字符串或字符串数组,支持多条正则;命中任一条即触发回复判断。
10
+
11
+ #### Markdown 本地图片
12
+
13
+ - 回复内容中的本地图片 URL(`/`、`./`、`file://` 等)会解析并转为 base64,以如流图片消息形式发送,避免链接不可访问。
14
+
15
+ ### 优化
16
+
17
+ #### follow-up(跟进回复)判定逻辑
18
+
19
+ - 机器人更智能地判断是否在跟进窗口内回复——
20
+ - **提高回复倾向**:当发言者表现出期望得到回应的意愿(例如追问、延续同一话题)时,增加机器人回复的可能性;
21
+ - **降低回复倾向**:当发言者明确禁止机器人回复(例如「不用回了」「别回复」等)时,降低机器人回复的可能性。
22
+
23
+ ### Bug 修复
24
+
25
+ - 修复消息分片发送时顺序错乱的问题。
26
+
27
+ ---
28
+
29
+ ## 2026.3.10
30
+
31
+ ### Bug 修复
32
+
33
+ - 修复来自 bot 的消息处理逻辑(避免对机器人自身消息错误响应)。
34
+
35
+ ---
36
+
3
37
  ## 2026.3.8
4
38
 
5
39
  ### 新功能
package/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
  - **按群独立配置**:每个群可设置不同的回复策略和系统提示词
20
20
  - **多账号支持**:一个实例管理多个如流机器人
21
21
  - **Agent 主动/定时发送**:LLM Agent 可主动或定时发送私聊消息、往群里发消息,支持 @指定用户或 @全员
22
+ - **Markdown 本地图片**:回复内容中的本地图片路径会自动转为图片消息发送
22
23
 
23
24
  ## 安装
24
25
 
@@ -102,16 +103,16 @@ https://your-domain/webhook/infoflow
102
103
 
103
104
  ## 正则匹配 (watchRegex)
104
105
 
105
- 通过正则表达式匹配群内聊天内容,当消息文本命中正则时,会触发机器人参与并回复(需配合 `replyMode` 为 `mention-and-watch` 或 `proactive`)。可在顶层、账号或按群单独配置。
106
+ 通过正则表达式匹配群内聊天内容,当消息文本命中任一正则时,会触发机器人参与并回复(需配合 `replyMode` 为 `mention-and-watch` 或 `proactive`)。`watchRegex` 可配置为**字符串**或**字符串数组**(多条正则,命中其一即触发)。可在顶层、账号或按群单独配置。
106
107
 
107
108
  ```json5
108
109
  {
109
110
  channels: {
110
111
  infoflow: {
111
- watchRegex: "^(帮忙|请帮我|求助)", // 顶层:匹配以这些词开头的消息
112
+ watchRegex: ["^(帮忙|请帮我)", "\\?$"], // 顶层:数组形式,多条正则
112
113
  groups: {
113
114
  "123456": {
114
- watchRegex: "\\?$|怎么|如何", // 该群:匹配以问号结尾或含「怎么」「如何」的消息
115
+ watchRegex: "\\?$|怎么|如何", // 该群:单条正则,匹配以问号结尾或含「怎么」「如何」的消息
115
116
  },
116
117
  },
117
118
  },
@@ -234,7 +235,7 @@ https://your-domain/webhook/infoflow
234
235
  | `followUp` | `boolean` | `true` | 是否启用跟进回复 |
235
236
  | `followUpWindow` | `number` | `300` | 跟进窗口(秒) |
236
237
  | `watchMentions` | `string[]` | `[]` | 关注提及的人员列表 |
237
- | `watchRegex` | `string` | — | 正则表达式,匹配群消息内容时触发回复 |
238
+ | `watchRegex` | `string` \| `string[]` | — | 正则或正则数组,匹配群消息内容时触发回复 |
238
239
  | `dmPolicy` | `string` | `"open"` | 私聊策略 |
239
240
  | `allowFrom` | `string[]` | `[]` | 私聊白名单 |
240
241
  | `groupPolicy` | `string` | `"open"` | 群聊策略 |
@@ -249,7 +250,7 @@ https://your-domain/webhook/infoflow
249
250
  |------|------|------|
250
251
  | `replyMode` | `string` | 覆盖该群的回复模式 |
251
252
  | `watchMentions` | `string[]` | 覆盖该群的关注列表 |
252
- | `watchRegex` | `string` | 覆盖该群的正则匹配规则,匹配群消息内容时触发回复 |
253
+ | `watchRegex` | `string` \| `string[]` | 覆盖该群的正则匹配规则(可为单条或数组),匹配群消息内容时触发回复 |
253
254
  | `followUp` | `boolean` | 覆盖该群的跟进开关 |
254
255
  | `followUpWindow` | `number` | 覆盖该群的跟进窗口 |
255
256
  | `systemPrompt` | `string` | 该群专属系统提示词 |
@@ -289,6 +290,7 @@ Baidu Infoflow (如流) enterprise messaging platform — OpenClaw channel plugi
289
290
  - **Per-group config**: each group can have its own reply strategy and system prompt
290
291
  - **Multi-account support**: manage multiple Infoflow bots from a single instance
291
292
  - **Agent-initiated / scheduled sending**: LLM Agent can proactively or on a schedule send DMs, post messages to groups, @specific users, or @all members
293
+ - **Markdown local images**: local image paths in reply content are converted and sent as image messages
292
294
 
293
295
  ## Install
294
296
 
@@ -372,16 +374,16 @@ Configure a list of people to watch. When someone in the group @mentions a perso
372
374
 
373
375
  ## Regex Match (watchRegex)
374
376
 
375
- Match group chat content with a regular expression; when a message matches, the bot is triggered to participate and reply (requires `replyMode` `mention-and-watch` or `proactive`). Can be set at top level, per account, or per group.
377
+ Match group chat content with a regular expression; when a message matches any pattern, the bot is triggered to participate and reply (requires `replyMode` `mention-and-watch` or `proactive`). `watchRegex` can be a **string** or **string array** (multiple patterns; any match triggers). Can be set at top level, per account, or per group.
376
378
 
377
379
  ```json5
378
380
  {
379
381
  channels: {
380
382
  infoflow: {
381
- watchRegex: "^(help|please|urgent)", // Top-level: match messages starting with these
383
+ watchRegex: ["^(help|please)", "\\?$"], // Top-level: array of patterns
382
384
  groups: {
383
385
  "123456": {
384
- watchRegex: "\\?$|how to|what is", // This group: match messages ending with ? or containing "how to"/"what is"
386
+ watchRegex: "\\?$|how to|what is", // This group: single pattern
385
387
  },
386
388
  },
387
389
  },
@@ -504,7 +506,7 @@ Set independent reply strategies for each group, overriding the global defaults.
504
506
  | `followUp` | `boolean` | `true` | Enable follow-up replies |
505
507
  | `followUpWindow` | `number` | `300` | Follow-up window (seconds) |
506
508
  | `watchMentions` | `string[]` | `[]` | List of people to watch for @mentions |
507
- | `watchRegex` | `string` | — | Regex to match group message content; when matched, trigger reply |
509
+ | `watchRegex` | `string` \| `string[]` | — | Regex or array of regexes; when matched, trigger reply |
508
510
  | `dmPolicy` | `string` | `"open"` | DM access policy |
509
511
  | `allowFrom` | `string[]` | `[]` | DM allowlist |
510
512
  | `groupPolicy` | `string` | `"open"` | Group access policy |
@@ -519,7 +521,7 @@ Set independent reply strategies for each group, overriding the global defaults.
519
521
  |-------|------|-------------|
520
522
  | `replyMode` | `string` | Override reply mode for this group |
521
523
  | `watchMentions` | `string[]` | Override watch list for this group |
522
- | `watchRegex` | `string` | Override regex for this group; match group content to trigger reply |
524
+ | `watchRegex` | `string` \| `string[]` | Override regex for this group (single or array); match group content to trigger reply |
523
525
  | `followUp` | `boolean` | Override follow-up toggle for this group |
524
526
  | `followUpWindow` | `number` | Override follow-up window for this group |
525
527
  | `systemPrompt` | `string` | Custom system prompt for this group |
@@ -30,7 +30,7 @@
30
30
  "followUp": { "type": "boolean", "default": true },
31
31
  "followUpWindow": { "type": "number", "default": 300 },
32
32
  "watchMentions": { "type": "array", "items": { "type": "string" } },
33
- "watchRegex": { "type": "string" },
33
+ "watchRegex": { "type": "array", "items": { "type": "string" } },
34
34
  "groups": {
35
35
  "type": "object",
36
36
  "additionalProperties": {
@@ -41,7 +41,7 @@
41
41
  "enum": ["ignore", "record", "mention-only", "mention-and-watch", "proactive"]
42
42
  },
43
43
  "watchMentions": { "type": "array", "items": { "type": "string" } },
44
- "watchRegex": { "type": "string" },
44
+ "watchRegex": { "type": "array", "items": { "type": "string" } },
45
45
  "followUp": { "type": "boolean" },
46
46
  "followUpWindow": { "type": "number" },
47
47
  "systemPrompt": { "type": "string" }
@@ -77,7 +77,7 @@
77
77
  "followUp": { "type": "boolean" },
78
78
  "followUpWindow": { "type": "number" },
79
79
  "watchMentions": { "type": "array", "items": { "type": "string" } },
80
- "watchRegex": { "type": "string" },
80
+ "watchRegex": { "type": "array", "items": { "type": "string" } },
81
81
  "groups": {
82
82
  "type": "object",
83
83
  "additionalProperties": {
@@ -88,7 +88,7 @@
88
88
  "enum": ["ignore", "record", "mention-only", "mention-and-watch", "proactive"]
89
89
  },
90
90
  "watchMentions": { "type": "array", "items": { "type": "string" } },
91
- "watchRegex": { "type": "string" },
91
+ "watchRegex": { "type": "array", "items": { "type": "string" } },
92
92
  "followUp": { "type": "boolean" },
93
93
  "followUpWindow": { "type": "number" },
94
94
  "systemPrompt": { "type": "string" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chbo297/infoflow",
3
- "version": "2026.3.9",
3
+ "version": "2026.3.14",
4
4
  "description": "OpenClaw Infoflow (如流) channel plugin for Baidu enterprise messaging",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/accounts.ts CHANGED
@@ -71,7 +71,7 @@ function mergeInfoflowAccountConfig(
71
71
  robotName?: string;
72
72
  requireMention?: boolean;
73
73
  watchMentions?: string[];
74
- watchRegex?: string;
74
+ watchRegex?: string | string[];
75
75
  appAgentId?: number;
76
76
  } {
77
77
  const raw = getChannelSection(cfg) ?? {};
@@ -88,11 +88,16 @@ function mergeInfoflowAccountConfig(
88
88
  robotName?: string;
89
89
  requireMention?: boolean;
90
90
  watchMentions?: string[];
91
- watchRegex?: string;
91
+ watchRegex?: string | string[];
92
92
  appAgentId?: number;
93
93
  };
94
94
  }
95
95
 
96
+ function normalizeWatchRegex(v: string | string[] | undefined): string[] {
97
+ if (v == null) return [];
98
+ return Array.isArray(v) ? v : [v];
99
+ }
100
+
96
101
  // ---------------------------------------------------------------------------
97
102
  // Account Resolution
98
103
  // ---------------------------------------------------------------------------
@@ -133,7 +138,7 @@ export function resolveInfoflowAccount(params: {
133
138
  robotName: merged.robotName?.trim() || undefined,
134
139
  requireMention: merged.requireMention,
135
140
  watchMentions: merged.watchMentions,
136
- watchRegex: merged.watchRegex,
141
+ watchRegex: normalizeWatchRegex(merged.watchRegex),
137
142
  appAgentId: merged.appAgentId,
138
143
  },
139
144
  };
package/src/actions.ts CHANGED
@@ -372,54 +372,56 @@ export const infoflowMessageActions: ChannelMessageActionAdapter = {
372
372
  `[infoflow:action:send] to=${to}, atAll=${atAll}, mentionUserIds=${mentionUserIdsRaw ?? "none"}`,
373
373
  );
374
374
 
375
- // Send text+mentions first (if any)
376
- if (contents.length > 0) {
377
- await sendInfoflowMessage({
378
- cfg,
379
- to,
380
- contents,
381
- accountId: accountId ?? undefined,
382
- replyTo,
383
- });
384
- }
385
-
386
- // Try native image send, fallback to link
375
+ // b-mode: fire text first (if any), then image/link, then await all
376
+ const p1 =
377
+ contents.length > 0
378
+ ? sendInfoflowMessage({
379
+ cfg,
380
+ to,
381
+ contents,
382
+ accountId: accountId ?? undefined,
383
+ replyTo,
384
+ })
385
+ : null;
386
+ let p2: Promise<{ ok: boolean; messageId?: string; error?: string }>;
387
387
  try {
388
388
  const prepared = await prepareInfoflowImageBase64({ mediaUrl });
389
389
  if (prepared.isImage) {
390
- const imgResult = await sendInfoflowImageMessage({
390
+ p2 = sendInfoflowImageMessage({
391
391
  cfg,
392
392
  to,
393
393
  base64Image: prepared.base64,
394
394
  accountId: accountId ?? undefined,
395
395
  replyTo: contents.length > 0 ? undefined : replyTo,
396
396
  });
397
- return jsonResult({
398
- ok: imgResult.ok,
399
- channel: "infoflow",
397
+ } else {
398
+ p2 = sendInfoflowMessage({
399
+ cfg,
400
400
  to,
401
- messageId: imgResult.messageId ?? (imgResult.ok ? "sent" : "failed"),
402
- ...(imgResult.error ? { error: imgResult.error } : {}),
401
+ contents: [{ type: "link", content: mediaUrl }],
402
+ accountId: accountId ?? undefined,
403
+ replyTo: contents.length > 0 ? undefined : replyTo,
403
404
  });
404
405
  }
405
406
  } catch {
406
- // fallback to link below
407
+ p2 = sendInfoflowMessage({
408
+ cfg,
409
+ to,
410
+ contents: [{ type: "link", content: mediaUrl }],
411
+ accountId: accountId ?? undefined,
412
+ replyTo: contents.length > 0 ? undefined : replyTo,
413
+ });
407
414
  }
408
-
409
- // Non-image or native send failed → send as link
410
- const linkResult = await sendInfoflowMessage({
411
- cfg,
412
- to,
413
- contents: [{ type: "link", content: mediaUrl }],
414
- accountId: accountId ?? undefined,
415
- replyTo: contents.length > 0 ? undefined : replyTo,
416
- });
415
+ const results = await Promise.all([p1, p2].filter(Boolean));
416
+ const last = results.at(-1) as
417
+ | { ok: boolean; messageId?: string; error?: string }
418
+ | undefined;
417
419
  return jsonResult({
418
- ok: linkResult.ok,
420
+ ok: last?.ok ?? false,
419
421
  channel: "infoflow",
420
422
  to,
421
- messageId: linkResult.messageId ?? (linkResult.ok ? "sent" : "failed"),
422
- ...(linkResult.error ? { error: linkResult.error } : {}),
423
+ messageId: last?.messageId ?? (last?.ok ? "sent" : "failed"),
424
+ ...(last?.error ? { error: last.error } : {}),
423
425
  });
424
426
  }
425
427
 
package/src/bot.ts CHANGED
@@ -6,10 +6,12 @@ import {
6
6
  recordPendingHistoryEntryIfEnabled,
7
7
  buildAgentMediaPayload,
8
8
  } from "openclaw/plugin-sdk";
9
+ import { getAgentScopedMediaLocalRoots } from "../../../src/media/local-roots.js";
9
10
  import { resolveInfoflowAccount } from "./accounts.js";
10
11
  import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
11
12
  import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
12
13
  import { getInfoflowRuntime } from "./runtime.js";
14
+ import { findSentMessage } from "./sent-message-store.js";
13
15
  import type {
14
16
  InfoflowChatType,
15
17
  InfoflowMessageEvent,
@@ -46,6 +48,8 @@ type InfoflowBodyItem = {
46
48
  userid?: string;
47
49
  /** IMAGE 类型 body item 的图片下载地址 */
48
50
  downloadurl?: string;
51
+ /** replyData 类型 body item 中被引用消息的 ID */
52
+ messageid?: string | number;
49
53
  };
50
54
 
51
55
  /**
@@ -103,13 +107,35 @@ function checkWatchMentioned(
103
107
  return undefined;
104
108
  }
105
109
 
106
- /** Check if message content matches the configured watchRegex regex pattern. Uses "s" (dotAll) so that . matches newlines in multi-line messages. */
107
- function checkWatchRegex(mes: string, pattern: string): boolean {
108
- try {
109
- return new RegExp(pattern, "is").test(mes);
110
- } catch {
111
- return false;
110
+ /** Normalize watchRegex config to string[] (supports legacy single string). */
111
+ function normalizeWatchRegex(v: string | string[] | undefined): string[] {
112
+ if (v == null) return [];
113
+ return Array.isArray(v) ? v : [v];
114
+ }
115
+
116
+ /** Check if message content matches any of the configured watchRegex patterns. Uses "s" (dotAll) so that . matches newlines. */
117
+ function checkWatchRegex(mes: string, patterns: string[]): boolean {
118
+ if (!patterns.length) return false;
119
+ for (const pattern of patterns) {
120
+ try {
121
+ if (new RegExp(pattern, "is").test(mes)) return true;
122
+ } catch {
123
+ // skip invalid pattern
124
+ }
125
+ }
126
+ return false;
127
+ }
128
+
129
+ /** Return the first matching pattern index, or -1 if none match. Used for triggerReason and prompt. */
130
+ function findMatchingWatchRegex(mes: string, patterns: string[]): number {
131
+ for (let i = 0; i < patterns.length; i++) {
132
+ try {
133
+ if (new RegExp(patterns[i], "is").test(mes)) return i;
134
+ } catch {
135
+ // skip invalid pattern
136
+ }
112
137
  }
138
+ return -1;
113
139
  }
114
140
 
115
141
  /**
@@ -144,6 +170,37 @@ function extractMentionIds(bodyItems: InfoflowBodyItem[], robotName?: string): I
144
170
  return { userIds, agentIds };
145
171
  }
146
172
 
173
+ /** Check if the message @mentions other bots or human users (excluding the bot itself). */
174
+ function hasOtherMentions(mentionIds?: InfoflowMentionIds): boolean {
175
+ if (!mentionIds) return false;
176
+ return mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Reply-to-bot detection (引用回复机器人消息)
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * Check if the message is a reply (引用回复) to one of the bot's own messages.
185
+ * Looks up replyData body items' messageid against the sent-message-store.
186
+ */
187
+ function checkReplyToBot(bodyItems: InfoflowBodyItem[], accountId: string): boolean {
188
+ for (const item of bodyItems) {
189
+ if (item.type !== "replyData") continue;
190
+ const msgId = item.messageid;
191
+ if (msgId == null) continue;
192
+ const msgIdStr = String(msgId);
193
+ if (!msgIdStr) continue;
194
+ try {
195
+ const found = findSentMessage(accountId, msgIdStr);
196
+ if (found) return true;
197
+ } catch {
198
+ // DB lookup failure should not block message processing
199
+ }
200
+ }
201
+ return false;
202
+ }
203
+
147
204
  // ---------------------------------------------------------------------------
148
205
  // Shared reply judgment rules (reused across prompt builders)
149
206
  // ---------------------------------------------------------------------------
@@ -151,26 +208,32 @@ function extractMentionIds(bodyItems: InfoflowBodyItem[], robotName?: string): I
151
208
  /** Shared judgment rules and reply format requirements for all conditional-reply prompts */
152
209
  function buildReplyJudgmentRules(): string {
153
210
  return [
154
- "# Rules",
211
+ "# Rules for Group Message Response",
155
212
  "",
156
- "## Can answer or help → Reply directly",
213
+ "## When to Reply",
157
214
  "",
158
- "Reply if ANY of these apply:",
159
- "- The question can be answered through common sense or logical reasoning (e.g. math, general knowledge)",
160
- "- You can find relevant clues or content in your knowledge base, documentation, or code",
161
- "- You have sufficient domain expertise to provide a valuable reference",
215
+ "Reply if ANY of the following is true:",
216
+ "- 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)",
217
+ "- The message contains a clear question or request that you can answer using your knowledge, skills, tools, or reasoning",
218
+ "- You have relevant domain expertise, documentation, or codebase context that adds value",
162
219
  "",
163
- "## Cannot answer Reply with NO_REPLY only",
220
+ "## When NOT to Reply output only `NO_REPLY`",
164
221
  "",
165
- "Do NOT reply if ANY of these apply:",
166
- "- The message contains no clear question or request (e.g. casual chat, meaningless content)",
167
- "- The question involves private information or context you have no knowledge of",
168
- "- You cannot understand the core intent of the message",
222
+ "Do NOT reply if ANY of the following is true:",
223
+ "- The message is casual chatter, banter, emoji-only, or has no actionable question/request",
224
+ "- The user explicitly indicates they don't want your response",
225
+ "- The message is directed at another person, not at you",
226
+ "- You lack the context or knowledge to give a useful answer (e.g., private/internal info you don't have access to)",
227
+ "- The message intent is ambiguous and a wrong guess would be more disruptive than silence",
169
228
  "",
170
- "# Response format",
229
+ "## Response Format",
171
230
  "",
172
- "- When you can answer: give a direct, concise answer. Do not explain why you chose to answer.",
173
- "- When you cannot answer: output only NO_REPLY with no other text.",
231
+ "- If you can answer: respond directly and concisely. Do not explain why you chose to answer. Do not add filler or pleasantries.",
232
+ "- If you cannot answer: output exactly `NO_REPLY` nothing else, no explanation, no apology.",
233
+ "",
234
+ "## Guiding Principle",
235
+ "",
236
+ "When in doubt, prefer silence (`NO_REPLY`). A missing reply is far less disruptive than an irrelevant or incorrect one in a group chat.",
174
237
  ].join("\n");
175
238
  }
176
239
 
@@ -207,9 +270,10 @@ function buildWatchMentionPrompt(mentionedId: string): string {
207
270
  * Build a GroupSystemPrompt for watch-content triggered messages.
208
271
  * Instructs the agent to reply only when confident, otherwise use NO_REPLY.
209
272
  */
210
- function buildWatchRegexPrompt(pattern: string): string {
273
+ function buildWatchRegexPrompt(patterns: string[]): string {
274
+ const label = patterns.length ? `(${patterns.join(" | ")})` : "";
211
275
  return [
212
- `The message content matched the configured watch pattern (${pattern}).`,
276
+ `The message content matched one of the configured watch patterns ${label}.`,
213
277
  "As the group assistant, you observed this message. Decide whether you can provide help or a valuable reply.",
214
278
  "",
215
279
  buildReplyJudgmentRules(),
@@ -218,14 +282,60 @@ function buildWatchRegexPrompt(pattern: string): string {
218
282
 
219
283
  /**
220
284
  * Build a GroupSystemPrompt for follow-up replies after bot's last response.
221
- * Instructs the agent to reply only if the message is a follow-up on the same topic.
285
+ * Uses three-tier semantic priority: (1) intent to talk to bot must reply,
286
+ * (2) explicit stop request → must not reply, (3) topic continuity judgment.
287
+ *
288
+ * When isReplyToBot is true, injects a strong signal that the user quoted the bot's message.
222
289
  */
223
- function buildFollowUpPrompt(): string {
224
- return [
290
+ function buildFollowUpPrompt(isReplyToBot: boolean): string {
291
+ const lines: string[] = [
225
292
  "You just replied to a message in this group. Someone has now sent a new message.",
226
- "First determine if this message is a follow-up or continuation of the same topic you previously replied to, then decide if you can continue to help.",
293
+ "Follow the priority rules below **in order** to decide whether to reply.",
227
294
  "",
228
- "Note: If this message is clearly a new topic or unrelated to your previous reply, respond with NO_REPLY.",
295
+ ];
296
+
297
+ if (isReplyToBot) {
298
+ lines.push(
299
+ "**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.**",
300
+ "",
301
+ );
302
+ }
303
+
304
+ lines.push(
305
+ "# Priority 1: The sender intends to talk to you → MUST reply",
306
+ "",
307
+ "Based on semantic analysis, if the sender shows ANY of the following intents or expectations, you **MUST** reply (do NOT output NO_REPLY):",
308
+ "- Asking a follow-up question about your previous answer (e.g. 'why?', 'what else?', 'what if...?')",
309
+ "- Quoted/replied to your message (indicating a conversation with you)",
310
+ "- Addressing you by name, or using words like 'bot', 'assistant', etc.",
311
+ "- Requesting you to do something (e.g. 'help me...', 'explain...', 'translate...')",
312
+ "- Semantically expects a reply from you",
313
+ "",
314
+ "# Priority 2: Explicitly asking you to stop → MUST NOT reply",
315
+ "",
316
+ "If the message explicitly tells you to stop replying (e.g. 'shut up', 'stop', 'don't reply',",
317
+ "'no need for bot', or equivalent expressions in any language),",
318
+ "output only NO_REPLY.",
319
+ "",
320
+ "# Priority 3: No explicit intent → Judge topic continuity",
321
+ "",
322
+ "If neither Priority 1 nor Priority 2 applies:",
323
+ "- If the message continues the same topic you previously replied to, and you can provide valuable help → reply.",
324
+ "- If it is a new/unrelated topic, or you cannot add value → output only NO_REPLY.",
325
+ "",
326
+ buildReplyJudgmentRules(),
327
+ );
328
+
329
+ return lines.join("\n");
330
+ }
331
+
332
+ /**
333
+ * Build a GroupSystemPrompt for follow-up messages that @mention another person or bot.
334
+ * Uses the conservative ReplyJudgmentRules since the message is likely directed at someone else.
335
+ */
336
+ function buildFollowUpOtherMentionedPrompt(): string {
337
+ return [
338
+ "You recently replied in this group. A new message has arrived, but it @mentions another person or bot — it is likely directed at them, not at you.",
229
339
  "",
230
340
  buildReplyJudgmentRules(),
231
341
  ].join("\n");
@@ -275,7 +385,7 @@ type ResolvedGroupConfig = {
275
385
  followUp: boolean;
276
386
  followUpWindow: number;
277
387
  watchMentions: string[];
278
- watchRegex?: string;
388
+ watchRegex: string[];
279
389
  systemPrompt?: string;
280
390
  };
281
391
 
@@ -300,7 +410,7 @@ function resolveGroupConfig(
300
410
  followUp: groupCfg?.followUp ?? account.config.followUp ?? true,
301
411
  followUpWindow: groupCfg?.followUpWindow ?? account.config.followUpWindow ?? 300,
302
412
  watchMentions: groupCfg?.watchMentions ?? account.config.watchMentions ?? [],
303
- watchRegex: groupCfg?.watchRegex ?? account.config.watchRegex,
413
+ watchRegex: normalizeWatchRegex(groupCfg?.watchRegex ?? account.config.watchRegex),
304
414
  systemPrompt: groupCfg?.systemPrompt,
305
415
  };
306
416
  }
@@ -373,11 +483,15 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
373
483
  export async function handleGroupChatMessage(params: HandleGroupChatParams): Promise<void> {
374
484
  const { cfg, msgData, accountId, statusSink } = params;
375
485
 
376
- // Extract sender from nested structure or flat fields
486
+ // Extract sender from nested structure or flat fields.
487
+ // Some Infoflow events (including bot-authored forwards) only populate `fromid` on the root,
488
+ // so include msgData.fromid as a final fallback.
377
489
  const header = (msgData.message as Record<string, unknown>)?.header as
378
490
  | Record<string, unknown>
379
491
  | undefined;
380
- const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
492
+ const fromuser = String(
493
+ header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? msgData.fromid ?? "",
494
+ );
381
495
 
382
496
  // Extract message ID (priority: header.messageid > header.msgid > MsgId)
383
497
  const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
@@ -426,7 +540,7 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
426
540
  if (replyBody) {
427
541
  replyContextItems.push(replyBody);
428
542
  }
429
- } else if (item.type === "TEXT") {
543
+ } else if (item.type === "TEXT" || item.type === "MD") {
430
544
  textContent += item.content ?? "";
431
545
  rawTextContent += item.content ?? "";
432
546
  } else if (item.type === "LINK") {
@@ -447,6 +561,10 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
447
561
  if (typeof url === "string" && url.trim()) {
448
562
  imageUrls.push(url.trim());
449
563
  }
564
+ } else if (typeof item.content === "string" && item.content.trim()) {
565
+ // Fallback: for any other item types with string content, treat content as text.
566
+ textContent += item.content;
567
+ rawTextContent += item.content;
450
568
  }
451
569
  }
452
570
  }
@@ -471,6 +589,9 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
471
589
  // Extract sender name from header or fallback to fromuser
472
590
  const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
473
591
 
592
+ // Detect reply-to-bot: check if any replyData item quotes a bot-sent message
593
+ const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
594
+
474
595
  // Delegate to the common message handler (group chat)
475
596
  await handleInfoflowMessage({
476
597
  cfg,
@@ -488,6 +609,7 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
488
609
  mentionIds:
489
610
  mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
490
611
  replyContext,
612
+ isReplyToBot: isReplyToBot || undefined,
491
613
  imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
492
614
  },
493
615
  accountId,
@@ -736,8 +858,13 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
736
858
  groupIdStr &&
737
859
  isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
738
860
  ) {
739
- triggerReason = "followUp";
740
- ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
861
+ if (hasOtherMentions(event.mentionIds)) {
862
+ triggerReason = "followUp-other-mentioned";
863
+ ctxPayload.GroupSystemPrompt = buildFollowUpOtherMentionedPrompt();
864
+ } else {
865
+ triggerReason = "followUp";
866
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot === true);
867
+ }
741
868
  } else {
742
869
  if (groupIdStr) {
743
870
  logVerbose(
@@ -770,18 +897,26 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
770
897
  triggerReason = `watchMentions(${matchedWatchId})`;
771
898
  // Watch-mention triggered: instruct agent to reply only if confident
772
899
  ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
773
- } else if (groupCfg.watchRegex && checkWatchRegex(mes, groupCfg.watchRegex)) {
774
- triggerReason = `watchRegex(${groupCfg.watchRegex})`;
775
- // Watch-content triggered: message matched configured regex pattern
900
+ } else if (groupCfg.watchRegex.length > 0 && checkWatchRegex(mes, groupCfg.watchRegex)) {
901
+ const idx = findMatchingWatchRegex(mes, groupCfg.watchRegex);
902
+ triggerReason =
903
+ idx >= 0
904
+ ? `watchRegex(${groupCfg.watchRegex[idx]})`
905
+ : `watchRegex(${groupCfg.watchRegex.join("|")})`;
906
+ // Watch-content triggered: message matched one of the configured regex patterns
776
907
  ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(groupCfg.watchRegex);
777
908
  } else if (
778
909
  groupCfg.followUp &&
779
910
  groupIdStr &&
780
911
  isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
781
912
  ) {
782
- triggerReason = "followUp";
783
- // Follow-up window: let LLM decide if this is a follow-up
784
- ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
913
+ if (hasOtherMentions(event.mentionIds)) {
914
+ triggerReason = "followUp-other-mentioned";
915
+ ctxPayload.GroupSystemPrompt = buildFollowUpOtherMentionedPrompt();
916
+ } else {
917
+ triggerReason = "followUp";
918
+ ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot === true);
919
+ }
785
920
  } else {
786
921
  if (groupIdStr) {
787
922
  logVerbose(
@@ -862,6 +997,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
862
997
  // Pass inbound messageId for outbound reply-to (group only)
863
998
  replyToMessageId: isGroup ? event.messageId : undefined,
864
999
  replyToPreview: isGroup ? mes : undefined,
1000
+ mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, route.agentId),
865
1001
  });
866
1002
 
867
1003
  const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
@@ -904,3 +1040,9 @@ export const _checkWatchMentioned = checkWatchMentioned;
904
1040
 
905
1041
  /** @internal — Extract non-bot mention IDs. Only exported for tests. */
906
1042
  export const _extractMentionIds = extractMentionIds;
1043
+
1044
+ /** @internal — Check if message matches any watchRegex pattern (dotAll). Only exported for tests. */
1045
+ export const _checkWatchRegex = checkWatchRegex;
1046
+
1047
+ /** @internal — Check if message is a reply to one of the bot's own messages. Only exported for tests. */
1048
+ export const _checkReplyToBot = checkReplyToBot;
package/src/channel.ts CHANGED
@@ -17,12 +17,13 @@ import {
17
17
  } from "./accounts.js";
18
18
  import { infoflowMessageActions } from "./actions.js";
19
19
  import { logVerbose } from "./logging.js";
20
+ import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
20
21
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
21
22
  import { startInfoflowMonitor } from "./monitor.js";
22
23
  import { getInfoflowRuntime } from "./runtime.js";
23
24
  import { sendInfoflowMessage } from "./send.js";
24
25
  import { normalizeInfoflowTarget, looksLikeInfoflowId } from "./targets.js";
25
- import type { ResolvedInfoflowAccount } from "./types.js";
26
+ import type { InfoflowOutboundReply, ResolvedInfoflowAccount } from "./types.js";
26
27
 
27
28
  // Re-export types and account functions for external consumers
28
29
  export type { InfoflowAccountConfig, ResolvedInfoflowAccount } from "./types.js";
@@ -209,19 +210,84 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
209
210
  chunkerMode: "markdown",
210
211
  textChunkLimit: 2048,
211
212
  chunker: (text, limit) => getInfoflowRuntime().channel.text.chunkText(text, limit),
212
- sendText: async ({ cfg, to, text, accountId }) => {
213
+ sendText: async ({ cfg, to, text, accountId, mediaLocalRoots, replyToId }) => {
213
214
  logVerbose(`[infoflow:sendText] to=${to}, accountId=${accountId}`);
214
- // Use "markdown" type even though param is named `text`: LLM outputs are often markdown,
215
- // and Infoflow's markdown type handles both plain text and markdown seamlessly.
216
- const result = await sendInfoflowMessage({
217
- cfg,
218
- to,
219
- contents: [{ type: "markdown", content: text }],
220
- accountId: accountId ?? undefined,
221
- });
215
+ const isGroup = /^group:\d+$/i.test(to.replace(/^infoflow:/i, ""));
216
+ const replyTo: InfoflowOutboundReply | undefined =
217
+ isGroup && replyToId?.trim() ? { messageid: replyToId.trim(), preview: "" } : undefined;
218
+
219
+ const segments = parseMarkdownForLocalImages(text);
220
+ let replyApplied = false;
221
+ const sendPromises: Promise<{ ok?: boolean; messageId?: string }>[] = [];
222
+
223
+ for (const segment of segments) {
224
+ if (segment.type === "text") {
225
+ const content = segment.content.trim();
226
+ if (!content) continue;
227
+ sendPromises.push(
228
+ sendInfoflowMessage({
229
+ cfg,
230
+ to,
231
+ contents: [{ type: "markdown", content: segment.content }],
232
+ accountId: accountId ?? undefined,
233
+ replyTo: replyApplied ? undefined : replyTo,
234
+ }),
235
+ );
236
+ replyApplied = true;
237
+ continue;
238
+ }
239
+ // segment.type === "image"
240
+ try {
241
+ const prepared = await prepareInfoflowImageBase64({
242
+ mediaUrl: segment.content,
243
+ mediaLocalRoots: mediaLocalRoots ?? undefined,
244
+ });
245
+ if (prepared.isImage) {
246
+ sendPromises.push(
247
+ sendInfoflowImageMessage({
248
+ cfg,
249
+ to,
250
+ base64Image: prepared.base64,
251
+ accountId: accountId ?? undefined,
252
+ replyTo: replyApplied ? undefined : replyTo,
253
+ }),
254
+ );
255
+ replyApplied = true;
256
+ } else {
257
+ sendPromises.push(
258
+ sendInfoflowMessage({
259
+ cfg,
260
+ to,
261
+ contents: [{ type: "link", content: segment.content }],
262
+ accountId: accountId ?? undefined,
263
+ replyTo: replyApplied ? undefined : replyTo,
264
+ }),
265
+ );
266
+ replyApplied = true;
267
+ }
268
+ } catch (err) {
269
+ logVerbose(`[infoflow:sendText] image prep failed, sending as link: ${err}`);
270
+ sendPromises.push(
271
+ sendInfoflowMessage({
272
+ cfg,
273
+ to,
274
+ contents: [{ type: "link", content: segment.content }],
275
+ accountId: accountId ?? undefined,
276
+ replyTo: replyApplied ? undefined : replyTo,
277
+ }),
278
+ );
279
+ replyApplied = true;
280
+ }
281
+ }
282
+
283
+ if (sendPromises.length === 0) {
284
+ return { channel: "infoflow", messageId: "failed" };
285
+ }
286
+ const results = await Promise.all(sendPromises);
287
+ const lastOk = results.filter((r) => r?.ok).at(-1);
222
288
  return {
223
289
  channel: "infoflow",
224
- messageId: result.ok ? (result.messageId ?? "sent") : "failed",
290
+ messageId: lastOk ? (lastOk.messageId ?? "sent") : "failed",
225
291
  };
226
292
  },
227
293
  sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
@@ -272,9 +338,11 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
272
338
  return { ok: linkResult.ok, messageId: linkResult.messageId };
273
339
  };
274
340
 
275
- // Dispatch: concurrent text + image, or text-only, or image-only
341
+ // b-mode: fire in upstream order (caption first, then media), then await all
276
342
  if (trimmedText && mediaUrl) {
277
- const [, imageResult] = await Promise.all([sendText(), sendImage()]);
343
+ const p1 = sendText();
344
+ const p2 = sendImage();
345
+ const [, imageResult] = await Promise.all([p1, p2]);
278
346
  return {
279
347
  channel: "infoflow",
280
348
  messageId: imageResult.ok ? (imageResult.messageId ?? "sent") : "failed",
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Parses markdown for local image links and splits content into ordered segments
3
+ * (text or image URL) so the channel can send text + image + text as separate messages.
4
+ */
5
+
6
+ function isLocalPath(url: string): boolean {
7
+ const trimmed = url.trim();
8
+ if (!trimmed) return false;
9
+ return (
10
+ trimmed.startsWith("/") ||
11
+ trimmed.startsWith("./") ||
12
+ trimmed.startsWith("../") ||
13
+ trimmed.startsWith("~") ||
14
+ trimmed.startsWith("file://")
15
+ );
16
+ }
17
+
18
+ /** Markdown image ![alt](url) and link [label](url) – capture URL from both */
19
+ const MARKDOWN_IMAGE_OR_LINK_RE = /!?\[[^\]]*\]\(([^)]+)\)/g;
20
+
21
+ export type MarkdownSegment =
22
+ | { type: "text"; content: string }
23
+ | { type: "image"; content: string };
24
+
25
+ /**
26
+ * Splits markdown into ordered segments. Local image URLs (including file://) are
27
+ * extracted so they can be sent as native image messages; surrounding text is kept in order.
28
+ * - If the whole input is a single line that looks like a local path, returns one image segment.
29
+ * - Otherwise finds ![alt](url) and [label](url); when url is local, produces text + image + text segments.
30
+ */
31
+ export function parseMarkdownForLocalImages(text: string): MarkdownSegment[] {
32
+ const trimmed = text.trimEnd();
33
+ if (!trimmed) {
34
+ return [{ type: "text", content: text }];
35
+ }
36
+
37
+ // Single line that is a local path: treat entire content as one image
38
+ if (!trimmed.includes("\n")) {
39
+ if (isLocalPath(trimmed)) {
40
+ return [{ type: "image", content: trimmed }];
41
+ }
42
+ // Backtick-wrapped path e.g. `/tmp/foo.png` → treat as image
43
+ const backtickMatch = trimmed.match(/^`([^`]+)`$/);
44
+ if (backtickMatch && isLocalPath(backtickMatch[1].trim())) {
45
+ return [{ type: "image", content: backtickMatch[1].trim() }];
46
+ }
47
+ // Angle-bracket-wrapped path e.g. <file:///tmp/foo.png> → treat as image
48
+ const angleMatch = trimmed.match(/^<([^>]+)>$/);
49
+ if (angleMatch && isLocalPath(angleMatch[1].trim())) {
50
+ return [{ type: "image", content: angleMatch[1].trim() }];
51
+ }
52
+ }
53
+
54
+ const segments: MarkdownSegment[] = [];
55
+ let lastIndex = 0;
56
+ const re = new RegExp(MARKDOWN_IMAGE_OR_LINK_RE.source, "g");
57
+ let match: RegExpExecArray | null;
58
+ while ((match = re.exec(text)) !== null) {
59
+ const url = match[1].trim();
60
+ if (!isLocalPath(url)) continue;
61
+ if (match.index > lastIndex) {
62
+ segments.push({ type: "text", content: text.slice(lastIndex, match.index) });
63
+ }
64
+ segments.push({ type: "image", content: url });
65
+ lastIndex = re.lastIndex;
66
+ }
67
+
68
+ if (segments.length === 0) {
69
+ return [{ type: "text", content: text }];
70
+ }
71
+ if (lastIndex < text.length) {
72
+ segments.push({ type: "text", content: text.slice(lastIndex) });
73
+ }
74
+ return segments;
75
+ }
package/src/media.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  * Infoflow native image sending: compress, base64-encode, and POST via Infoflow API.
3
3
  */
4
4
 
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
5
7
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
6
8
  import { resolveInfoflowAccount } from "./accounts.js";
7
9
  import { recordSentMessageId } from "./infoflow-req-parse.js";
@@ -90,8 +92,21 @@ export async function compressImageForInfoflow(params: {
90
92
 
91
93
  export type PrepareImageResult = { isImage: true; base64: string } | { isImage: false };
92
94
 
95
+ function isLocalMediaUrl(url: string): boolean {
96
+ const t = url.trim();
97
+ return (
98
+ t.startsWith("file://") ||
99
+ t.startsWith("/") ||
100
+ t.startsWith("./") ||
101
+ t.startsWith("../") ||
102
+ t.startsWith("~")
103
+ );
104
+ }
105
+
93
106
  /**
94
107
  * Downloads media, checks if it's an image, compresses to 1MB, and base64-encodes.
108
+ * For local paths, passes the file's parent directory as localRoots so any path is allowed
109
+ * (infoflow-only; does not change shared media layer).
95
110
  */
96
111
  export async function prepareInfoflowImageBase64(params: {
97
112
  mediaUrl: string;
@@ -100,11 +115,32 @@ export async function prepareInfoflowImageBase64(params: {
100
115
  const { mediaUrl, mediaLocalRoots } = params;
101
116
  const runtime = getInfoflowRuntime();
102
117
 
103
- // Download media
118
+ let localRoots: readonly string[] | undefined;
119
+ if (isLocalMediaUrl(mediaUrl)) {
120
+ let absPath: string;
121
+ if (mediaUrl.trim().startsWith("file://")) {
122
+ try {
123
+ absPath = fileURLToPath(mediaUrl.trim());
124
+ } catch {
125
+ absPath = path.resolve(mediaUrl.replace(/^file:\/\//i, ""));
126
+ }
127
+ } else {
128
+ absPath = path.resolve(mediaUrl.trim());
129
+ }
130
+ const dir = path.dirname(absPath);
131
+ if (dir && dir !== path.parse(dir).root) {
132
+ localRoots = [dir];
133
+ } else {
134
+ localRoots = mediaLocalRoots?.length ? [...mediaLocalRoots] : undefined;
135
+ }
136
+ } else {
137
+ localRoots = mediaLocalRoots?.length ? [...mediaLocalRoots] : undefined;
138
+ }
139
+
104
140
  const loaded = await runtime.media.loadWebMedia(mediaUrl, {
105
141
  maxBytes: 30 * 1024 * 1024, // 30MB download limit
106
142
  optimizeImages: false,
107
- localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
143
+ localRoots: localRoots ?? undefined,
108
144
  });
109
145
 
110
146
  // Check if it's an image
@@ -4,6 +4,7 @@ import {
4
4
  type ReplyPayload,
5
5
  } from "openclaw/plugin-sdk";
6
6
  import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "./logging.js";
7
+ import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
7
8
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
8
9
  import { getInfoflowRuntime } from "./runtime.js";
9
10
  import { sendInfoflowMessage } from "./send.js";
@@ -37,6 +38,8 @@ export type CreateInfoflowReplyDispatcherParams = {
37
38
  replyToMessageId?: string;
38
39
  /** Preview text of the inbound message for reply context */
39
40
  replyToPreview?: string;
41
+ /** Optional local filesystem roots for resolving local image paths in text */
42
+ mediaLocalRoots?: readonly string[];
40
43
  };
41
44
 
42
45
  /**
@@ -54,6 +57,7 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
54
57
  mentionIds,
55
58
  replyToMessageId,
56
59
  replyToPreview,
60
+ mediaLocalRoots,
57
61
  } = params;
58
62
  const core = getInfoflowRuntime();
59
63
 
@@ -150,81 +154,197 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
150
154
 
151
155
  // Chunk text to 2048 chars max (Infoflow limit)
152
156
  const chunks = core.channel.text.chunkText(messageText, 2048);
153
- // Only include @mentions in the first chunk (avoid duplicate @s)
154
157
  let isFirstChunk = true;
158
+ const textPromises: Promise<{ ok?: boolean; error?: string }>[] = [];
155
159
 
156
160
  for (const chunk of chunks) {
157
- const contents: InfoflowMessageContentItem[] = [];
158
-
159
- // Add AT content nodes for group messages (first chunk only)
160
- if (isFirstChunk && isGroup) {
161
- if (hasAtAll) {
162
- contents.push({ type: "at", content: "all" });
163
- } else if (hasAtUsers) {
164
- contents.push({ type: "at", content: allAtUserIds.join(",") });
161
+ const segments = parseMarkdownForLocalImages(chunk);
162
+
163
+ for (const segment of segments) {
164
+ const chunkReplyTo = !replyApplied ? replyTo : undefined;
165
+
166
+ if (segment.type === "text") {
167
+ const contents: InfoflowMessageContentItem[] = [];
168
+ if (isFirstChunk && isGroup) {
169
+ if (hasAtAll) {
170
+ contents.push({ type: "at", content: "all" });
171
+ } else if (hasAtUsers) {
172
+ contents.push({ type: "at", content: allAtUserIds.join(",") });
173
+ }
174
+ if (hasAtAgents) {
175
+ contents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
176
+ }
177
+ }
178
+ const trimmed = segment.content.trim();
179
+ if (contents.length > 0 || trimmed) {
180
+ contents.push({ type: "markdown", content: segment.content });
181
+ textPromises.push(
182
+ sendInfoflowMessage({
183
+ cfg,
184
+ to,
185
+ contents,
186
+ accountId,
187
+ replyTo: chunkReplyTo,
188
+ }),
189
+ );
190
+ if (chunkReplyTo) replyApplied = true;
191
+ }
192
+ isFirstChunk = false;
193
+ continue;
194
+ }
195
+
196
+ // segment.type === "image"
197
+ if (isFirstChunk && isGroup && (hasAtAll || hasAtUsers || hasAtAgents)) {
198
+ const atContents: InfoflowMessageContentItem[] = [];
199
+ if (hasAtAll) atContents.push({ type: "at", content: "all" });
200
+ else if (hasAtUsers) atContents.push({ type: "at", content: allAtUserIds.join(",") });
201
+ if (hasAtAgents)
202
+ atContents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
203
+ atContents.push({ type: "markdown", content: "" });
204
+ textPromises.push(
205
+ sendInfoflowMessage({
206
+ cfg,
207
+ to,
208
+ contents: atContents,
209
+ accountId,
210
+ replyTo: chunkReplyTo,
211
+ }),
212
+ );
213
+ if (chunkReplyTo) replyApplied = true;
165
214
  }
166
- if (hasAtAgents) {
167
- contents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
215
+ isFirstChunk = false;
216
+
217
+ try {
218
+ const prepared = await prepareInfoflowImageBase64({
219
+ mediaUrl: segment.content,
220
+ mediaLocalRoots: mediaLocalRoots ?? undefined,
221
+ });
222
+ if (prepared.isImage) {
223
+ const segmentReplyTo = !replyApplied ? replyTo : undefined;
224
+ textPromises.push(
225
+ sendInfoflowImageMessage({
226
+ cfg,
227
+ to,
228
+ base64Image: prepared.base64,
229
+ accountId,
230
+ replyTo: segmentReplyTo,
231
+ }).then((r) => {
232
+ if (r.ok) return r;
233
+ logVerbose(
234
+ `[infoflow] native image send failed: ${r.error}, falling back to link`,
235
+ );
236
+ return sendInfoflowMessage({
237
+ cfg,
238
+ to,
239
+ contents: [{ type: "link", content: segment.content }],
240
+ accountId,
241
+ replyTo: segmentReplyTo,
242
+ });
243
+ }),
244
+ );
245
+ if (!replyApplied) replyApplied = true;
246
+ } else {
247
+ textPromises.push(
248
+ sendInfoflowMessage({
249
+ cfg,
250
+ to,
251
+ contents: [{ type: "link", content: segment.content }],
252
+ accountId,
253
+ replyTo: !replyApplied ? replyTo : undefined,
254
+ }),
255
+ );
256
+ if (!replyApplied) replyApplied = true;
257
+ }
258
+ } catch (err) {
259
+ logVerbose(
260
+ `[infoflow] image prep failed in text segment, falling back to link: ${err}`,
261
+ );
262
+ textPromises.push(
263
+ sendInfoflowMessage({
264
+ cfg,
265
+ to,
266
+ contents: [{ type: "link", content: segment.content }],
267
+ accountId,
268
+ replyTo: !replyApplied ? replyTo : undefined,
269
+ }),
270
+ );
271
+ if (!replyApplied) replyApplied = true;
168
272
  }
169
273
  }
170
- isFirstChunk = false;
171
-
172
- // Add markdown content
173
- contents.push({ type: "markdown", content: chunk });
174
-
175
- // Only include replyTo on the first outbound message
176
- const chunkReplyTo = !replyApplied ? replyTo : undefined;
177
- const result = await sendInfoflowMessage({
178
- cfg,
179
- to,
180
- contents,
181
- accountId,
182
- replyTo: chunkReplyTo,
183
- });
184
- if (chunkReplyTo) replyApplied = true;
185
-
186
- if (result.ok) {
187
- statusSink?.({ lastOutboundAt: Date.now() });
188
- } else if (result.error) {
189
- getInfoflowSendLog().error(
190
- `[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`,
191
- );
274
+ }
275
+
276
+ if (textPromises.length > 0) {
277
+ const results = await Promise.all(textPromises);
278
+ for (const result of results) {
279
+ if (result?.ok) {
280
+ statusSink?.({ lastOutboundAt: Date.now() });
281
+ } else if (result?.error) {
282
+ getInfoflowSendLog().error(
283
+ `[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`,
284
+ );
285
+ }
192
286
  }
193
287
  }
194
288
  }
195
289
 
196
- // --- Media handling: send each media item as native image or fallback link ---
290
+ // --- Media handling: send each media item as native image or fallback link (b-mode: collect then await) ---
291
+ const mediaPromises: Promise<{ ok?: boolean; error?: string }>[] = [];
197
292
  for (const mediaUrl of mediaList) {
198
293
  const mediaReplyTo = !replyApplied ? replyTo : undefined;
199
294
  try {
200
295
  const prepared = await prepareInfoflowImageBase64({ mediaUrl });
201
296
  if (prepared.isImage) {
202
- const result = await sendInfoflowImageMessage({
297
+ mediaPromises.push(
298
+ sendInfoflowImageMessage({
299
+ cfg,
300
+ to,
301
+ base64Image: prepared.base64,
302
+ accountId,
303
+ replyTo: mediaReplyTo,
304
+ }).then((r) => {
305
+ if (r.ok) return r;
306
+ logVerbose(`[infoflow] native image send failed: ${r.error}, falling back to link`);
307
+ return sendInfoflowMessage({
308
+ cfg,
309
+ to,
310
+ contents: [{ type: "link", content: mediaUrl }],
311
+ accountId,
312
+ replyTo: mediaReplyTo,
313
+ });
314
+ }),
315
+ );
316
+ if (mediaReplyTo) replyApplied = true;
317
+ } else {
318
+ mediaPromises.push(
319
+ sendInfoflowMessage({
320
+ cfg,
321
+ to,
322
+ contents: [{ type: "link", content: mediaUrl }],
323
+ accountId,
324
+ replyTo: mediaReplyTo,
325
+ }),
326
+ );
327
+ if (mediaReplyTo) replyApplied = true;
328
+ }
329
+ } catch (err) {
330
+ logVerbose(`[infoflow] image prep failed, falling back to link: ${err}`);
331
+ mediaPromises.push(
332
+ sendInfoflowMessage({
203
333
  cfg,
204
334
  to,
205
- base64Image: prepared.base64,
335
+ contents: [{ type: "link", content: mediaUrl }],
206
336
  accountId,
207
337
  replyTo: mediaReplyTo,
208
- });
209
- if (result.ok) {
210
- if (mediaReplyTo) replyApplied = true;
211
- statusSink?.({ lastOutboundAt: Date.now() });
212
- continue;
213
- }
214
- logVerbose(`[infoflow] native image send failed: ${result.error}, falling back to link`);
215
- }
216
- } catch (err) {
217
- logVerbose(`[infoflow] image prep failed, falling back to link: ${err}`);
338
+ }),
339
+ );
340
+ if (mediaReplyTo) replyApplied = true;
341
+ }
342
+ }
343
+ if (mediaPromises.length > 0) {
344
+ const results = await Promise.all(mediaPromises);
345
+ for (const result of results) {
346
+ if (result?.ok) statusSink?.({ lastOutboundAt: Date.now() });
218
347
  }
219
- // Fallback: send as link
220
- await sendInfoflowMessage({
221
- cfg,
222
- to,
223
- contents: [{ type: "link", content: mediaUrl }],
224
- accountId,
225
- replyTo: mediaReplyTo,
226
- });
227
- if (mediaReplyTo) replyApplied = true;
228
348
  }
229
349
  };
230
350
 
package/src/types.ts CHANGED
@@ -22,7 +22,7 @@ export type InfoflowReplyMode =
22
22
  export type InfoflowGroupConfig = {
23
23
  replyMode?: InfoflowReplyMode;
24
24
  watchMentions?: string[];
25
- watchRegex?: string;
25
+ watchRegex?: string[];
26
26
  followUp?: boolean;
27
27
  followUpWindow?: number;
28
28
  systemPrompt?: string;
@@ -45,6 +45,8 @@ export type InfoflowInboundBodyItem = {
45
45
  userid?: string;
46
46
  /** IMAGE 类型 body item 的图片下载地址 */
47
47
  downloadurl?: string;
48
+ /** replyData 类型 body item 中被引用消息的 ID */
49
+ messageid?: string | number;
48
50
  };
49
51
 
50
52
  /** Mention IDs extracted from inbound group AT items (excluding the bot itself) */
@@ -114,7 +116,7 @@ export type InfoflowAccountConfig = {
114
116
  * the bot analyzes the message and replies only if confident. */
115
117
  watchMentions?: string[];
116
118
  /** Regex pattern to watch for in message content; triggers bot activation when matched */
117
- watchRegex?: string;
119
+ watchRegex?: string[];
118
120
  /** Reply mode controlling bot engagement level in groups */
119
121
  replyMode?: InfoflowReplyMode;
120
122
  /** Enable follow-up replies after bot responds to a mention (default: true) */
@@ -153,7 +155,7 @@ export type ResolvedInfoflowAccount = {
153
155
  * the bot analyzes the message and replies only if confident. */
154
156
  watchMentions?: string[];
155
157
  /** Regex pattern to watch for in message content; triggers bot activation when matched */
156
- watchRegex?: string;
158
+ watchRegex?: string[];
157
159
  /** Reply mode controlling bot engagement level in groups */
158
160
  replyMode?: InfoflowReplyMode;
159
161
  /** Enable follow-up replies after bot responds to a mention (default: true) */
@@ -191,6 +193,8 @@ export type InfoflowMessageEvent = {
191
193
  mentionIds?: InfoflowMentionIds;
192
194
  /** Reply/quote context extracted from replyData body items (supports multiple quotes) */
193
195
  replyContext?: string[];
196
+ /** Whether the message is a reply (引用回复) to one of the bot's own messages */
197
+ isReplyToBot?: boolean;
194
198
  /** Image download URLs extracted from IMAGE body items (group) or PicUrl (private) */
195
199
  imageUrls?: string[];
196
200
  };