@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 +34 -0
- package/README.md +12 -10
- package/openclaw.plugin.json +4 -4
- package/package.json +1 -1
- package/src/accounts.ts +8 -3
- package/src/actions.ts +33 -31
- package/src/bot.ts +182 -40
- package/src/channel.ts +81 -13
- package/src/markdown-local-images.ts +75 -0
- package/src/media.ts +38 -2
- package/src/reply-dispatcher.ts +175 -55
- package/src/types.ts +7 -3
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
|
-
|
|
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
|
|
383
|
+
watchRegex: ["^(help|please)", "\\?$"], // Top-level: array of patterns
|
|
382
384
|
groups: {
|
|
383
385
|
"123456": {
|
|
384
|
-
watchRegex: "\\?$|how to|what is", // This group:
|
|
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
|
|
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 |
|
package/openclaw.plugin.json
CHANGED
|
@@ -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
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
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
397
|
+
} else {
|
|
398
|
+
p2 = sendInfoflowMessage({
|
|
399
|
+
cfg,
|
|
400
400
|
to,
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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:
|
|
420
|
+
ok: last?.ok ?? false,
|
|
419
421
|
channel: "infoflow",
|
|
420
422
|
to,
|
|
421
|
-
messageId:
|
|
422
|
-
...(
|
|
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
|
-
/**
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
"##
|
|
213
|
+
"## When to Reply",
|
|
157
214
|
"",
|
|
158
|
-
"Reply if ANY of
|
|
159
|
-
"- The
|
|
160
|
-
"-
|
|
161
|
-
"- You have
|
|
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
|
-
"##
|
|
220
|
+
"## When NOT to Reply — output only `NO_REPLY`",
|
|
164
221
|
"",
|
|
165
|
-
"Do NOT reply if ANY of
|
|
166
|
-
"- The message
|
|
167
|
-
"- The
|
|
168
|
-
"-
|
|
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
|
-
"
|
|
229
|
+
"## Response Format",
|
|
171
230
|
"",
|
|
172
|
-
"-
|
|
173
|
-
"-
|
|
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(
|
|
273
|
+
function buildWatchRegexPrompt(patterns: string[]): string {
|
|
274
|
+
const label = patterns.length ? `(${patterns.join(" | ")})` : "";
|
|
211
275
|
return [
|
|
212
|
-
`The message content matched the configured watch
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
"
|
|
293
|
+
"Follow the priority rules below **in order** to decide whether to reply.",
|
|
227
294
|
"",
|
|
228
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
740
|
-
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
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
|
-
//
|
|
341
|
+
// b-mode: fire in upstream order (caption first, then media), then await all
|
|
276
342
|
if (trimmedText && mediaUrl) {
|
|
277
|
-
const
|
|
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  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  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
|
-
|
|
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:
|
|
143
|
+
localRoots: localRoots ?? undefined,
|
|
108
144
|
});
|
|
109
145
|
|
|
110
146
|
// Check if it's an image
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
contents: [{ type: "link", content: mediaUrl }],
|
|
206
336
|
accountId,
|
|
207
337
|
replyTo: mediaReplyTo,
|
|
208
|
-
})
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
};
|