@chbo297/infoflow 2026.2.23

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChengBo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @chbo297/openclaw-infoflow
2
+
3
+ Baidu Infoflow (如流) channel plugin for OpenClaw.
4
+
5
+ ## Install (npm)
6
+
7
+ ```bash
8
+ openclaw plugins install @chbo297/infoflow
9
+ ```
10
+
11
+ ## Install (local checkout)
12
+
13
+ ```bash
14
+ openclaw plugins install ./path/to/openclaw-infoflow
15
+ ```
16
+
17
+ ## Config
18
+
19
+ ```json5
20
+ {
21
+ channels: {
22
+ infoflow: {
23
+ enabled: true,
24
+ apiHost: "https://apiin.im.baidu.com",
25
+ checkToken: "your-check-token",
26
+ encodingAESKey: "your-encoding-aes-key",
27
+ appKey: "your-app-key",
28
+ appSecret: "your-app-secret",
29
+ dmPolicy: "open", // "open" | "pairing" | "allowlist"
30
+ groupPolicy: "open", // "open" | "allowlist" | "disabled"
31
+ requireMention: true, // Bot only responds when @mentioned in groups
32
+ robotName: "YourBotName", // Required for @mention detection
33
+ },
34
+ },
35
+ }
36
+ ```
37
+
38
+ ## Multi-account support
39
+
40
+ ```json5
41
+ {
42
+ channels: {
43
+ infoflow: {
44
+ enabled: true,
45
+ accounts: {
46
+ work: {
47
+ checkToken: "token-1",
48
+ encodingAESKey: "key-1",
49
+ appKey: "app-key-1",
50
+ appSecret: "secret-1",
51
+ },
52
+ personal: {
53
+ checkToken: "token-2",
54
+ encodingAESKey: "key-2",
55
+ appKey: "app-key-2",
56
+ appSecret: "secret-2",
57
+ },
58
+ },
59
+ defaultAccount: "work",
60
+ },
61
+ },
62
+ }
63
+ ```
64
+
65
+ ## Webhook
66
+
67
+ Configure your Infoflow bot webhook URL to:
68
+ `https://your-domain/webhook/infoflow`
69
+
70
+ Restart the gateway after config changes.
71
+
72
+ ## License
73
+
74
+ MIT
package/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { infoflowPlugin } from "./src/channel.js";
3
+ import { handleInfoflowWebhookRequest } from "./src/monitor.js";
4
+ import { setInfoflowRuntime } from "./src/runtime.js";
5
+
6
+ const plugin = {
7
+ id: "infoflow",
8
+ name: "Infoflow",
9
+ description: "OpenClaw Infoflow channel plugin",
10
+ configSchema: emptyPluginConfigSchema(),
11
+ register(api: OpenClawPluginApi) {
12
+ setInfoflowRuntime(api.runtime);
13
+ api.registerChannel({ plugin: infoflowPlugin });
14
+ api.registerHttpHandler(handleInfoflowWebhookRequest);
15
+ },
16
+ };
17
+
18
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "infoflow",
3
+ "channels": ["infoflow"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@chbo297/infoflow",
3
+ "version": "2026.2.23",
4
+ "description": "OpenClaw Infoflow (如流) channel plugin for Baidu enterprise messaging",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "openclaw",
10
+ "openclaw-plugin",
11
+ "infoflow",
12
+ "baidu",
13
+ "chatbot",
14
+ "ai-agent",
15
+ "enterprise-messaging"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/chbo297/openclaw-infoflow"
20
+ },
21
+ "peerDependencies": {
22
+ "openclaw": "*"
23
+ },
24
+ "openclaw": {
25
+ "extensions": [
26
+ "./index.ts"
27
+ ],
28
+ "channel": {
29
+ "id": "infoflow",
30
+ "label": "Infoflow",
31
+ "selectionLabel": "Infoflow (如流)",
32
+ "docsPath": "/channels/infoflow",
33
+ "blurb": "Baidu Infoflow messaging platform.",
34
+ "order": 40
35
+ },
36
+ "install": {
37
+ "npmSpec": "@chbo297/infoflow",
38
+ "defaultChoice": "npm"
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Infoflow account resolution and configuration helpers.
3
+ * Handles multi-account support with config merging.
4
+ */
5
+
6
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk";
7
+ import type { InfoflowAccountConfig, ResolvedInfoflowAccount } from "./types.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Config Access Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Get the raw Infoflow channel section from config.
15
+ */
16
+ export function getChannelSection(cfg: OpenClawConfig): InfoflowAccountConfig | undefined {
17
+ return cfg.channels?.["infoflow"] as InfoflowAccountConfig | undefined;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Account ID Resolution
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * List all configured Infoflow account IDs.
26
+ * Returns [DEFAULT_ACCOUNT_ID] if no accounts are configured (backward compatibility).
27
+ */
28
+ export function listInfoflowAccountIds(cfg: OpenClawConfig): string[] {
29
+ const accounts = getChannelSection(cfg)?.accounts;
30
+ if (!accounts || typeof accounts !== "object") {
31
+ return [DEFAULT_ACCOUNT_ID];
32
+ }
33
+ const ids = Object.keys(accounts).filter(Boolean);
34
+ return ids.length === 0 ? [DEFAULT_ACCOUNT_ID] : ids.toSorted((a, b) => a.localeCompare(b));
35
+ }
36
+
37
+ /**
38
+ * Resolve the default account ID for Infoflow.
39
+ */
40
+ export function resolveDefaultInfoflowAccountId(cfg: OpenClawConfig): string {
41
+ const channel = getChannelSection(cfg);
42
+ if (channel?.defaultAccount?.trim()) {
43
+ return channel.defaultAccount.trim();
44
+ }
45
+ const ids = listInfoflowAccountIds(cfg);
46
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
47
+ return DEFAULT_ACCOUNT_ID;
48
+ }
49
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Config Merging
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Merge top-level Infoflow config with account-specific overrides.
58
+ * Account fields override base fields.
59
+ */
60
+ function mergeInfoflowAccountConfig(
61
+ cfg: OpenClawConfig,
62
+ accountId: string,
63
+ ): {
64
+ apiHost: string;
65
+ checkToken: string;
66
+ encodingAESKey: string;
67
+ appKey: string;
68
+ appSecret: string;
69
+ enabled?: boolean;
70
+ name?: string;
71
+ robotName?: string;
72
+ requireMention?: boolean;
73
+ } {
74
+ const raw = getChannelSection(cfg) ?? {};
75
+ const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
76
+ const account = raw.accounts?.[accountId] ?? {};
77
+ return { ...base, ...account } as {
78
+ apiHost: string;
79
+ checkToken: string;
80
+ encodingAESKey: string;
81
+ appKey: string;
82
+ appSecret: string;
83
+ enabled?: boolean;
84
+ name?: string;
85
+ robotName?: string;
86
+ requireMention?: boolean;
87
+ };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Account Resolution
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Resolve a complete Infoflow account with merged config.
96
+ */
97
+ export function resolveInfoflowAccount(params: {
98
+ cfg: OpenClawConfig;
99
+ accountId?: string | null;
100
+ }): ResolvedInfoflowAccount {
101
+ const accountId = normalizeAccountId(params.accountId);
102
+ const baseEnabled = getChannelSection(params.cfg)?.enabled !== false;
103
+ const merged = mergeInfoflowAccountConfig(params.cfg, accountId);
104
+ const accountEnabled = merged.enabled !== false;
105
+ const enabled = baseEnabled && accountEnabled;
106
+ const apiHost = merged.apiHost ?? "";
107
+ const checkToken = merged.checkToken ?? "";
108
+ const encodingAESKey = merged.encodingAESKey ?? "";
109
+ const appKey = merged.appKey ?? "";
110
+ const appSecret = merged.appSecret ?? "";
111
+ const configured =
112
+ Boolean(checkToken) && Boolean(encodingAESKey) && Boolean(appKey) && Boolean(appSecret);
113
+
114
+ return {
115
+ accountId,
116
+ name: merged.name?.trim() || undefined,
117
+ enabled,
118
+ configured,
119
+ config: {
120
+ enabled: merged.enabled,
121
+ name: merged.name,
122
+ apiHost,
123
+ checkToken,
124
+ encodingAESKey,
125
+ appKey,
126
+ appSecret,
127
+ robotName: merged.robotName?.trim() || undefined,
128
+ requireMention: merged.requireMention,
129
+ },
130
+ };
131
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { resolveInfoflowAccount } from "./accounts.js";
2
+ import { getInfoflowBotLog } from "./logging.js";
3
+ import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
4
+ import { getInfoflowRuntime } from "./runtime.js";
5
+ import type {
6
+ InfoflowChatType,
7
+ InfoflowMessageEvent,
8
+ HandleInfoflowMessageParams,
9
+ HandlePrivateChatParams,
10
+ HandleGroupChatParams,
11
+ } from "./types.js";
12
+
13
+ // Re-export types for external consumers
14
+ export type { InfoflowChatType, InfoflowMessageEvent } from "./types.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // @mention detection types and helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Body item in Infoflow group message, supporting TEXT, AT, LINK types.
22
+ */
23
+ type InfoflowBodyItem = {
24
+ type?: string;
25
+ content?: string;
26
+ label?: string;
27
+ /** Robot ID when type is AT */
28
+ robotid?: number;
29
+ /** Robot/user name when type is AT */
30
+ name?: string;
31
+ };
32
+
33
+ /**
34
+ * Check if the bot was @mentioned in the message body.
35
+ * Matches configured robotName against AT elements (case-insensitive).
36
+ */
37
+ function checkBotMentioned(bodyItems: InfoflowBodyItem[], robotName?: string): boolean {
38
+ if (!robotName) {
39
+ return false; // Cannot detect mentions without configured robotName
40
+ }
41
+ const normalizedRobotName = robotName.toLowerCase();
42
+ for (const item of bodyItems) {
43
+ if (item.type === "AT" && item.name) {
44
+ if (item.name.toLowerCase() === normalizedRobotName) {
45
+ return true;
46
+ }
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Handles an incoming private chat message from Infoflow.
54
+ * Receives the raw decrypted message data and dispatches to the agent.
55
+ */
56
+ export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
57
+ const { cfg, msgData, accountId, statusSink } = params;
58
+ const core = getInfoflowRuntime();
59
+ const verbose = core.logging.shouldLogVerbose();
60
+
61
+ // Extract sender and content from msgData (flexible field names)
62
+ const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
63
+ const mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
64
+
65
+ // Extract sender name (FromUserName is more human-readable than FromUserId)
66
+ const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
67
+
68
+ // Extract message ID for dedup tracking
69
+ const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
70
+ const messageIdStr = messageId != null ? String(messageId) : undefined;
71
+
72
+ // Extract timestamp (CreateTime is in seconds, convert to milliseconds)
73
+ const createTime = msgData.CreateTime ?? msgData.createtime;
74
+ const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
75
+
76
+ if (verbose) {
77
+ getInfoflowBotLog().debug?.(
78
+ `[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, raw msgData: ${JSON.stringify(msgData)}`,
79
+ );
80
+ }
81
+
82
+ if (!fromuser || !mes.trim()) {
83
+ return;
84
+ }
85
+
86
+ // Delegate to the common message handler (private chat)
87
+ await handleInfoflowMessage({
88
+ cfg,
89
+ event: {
90
+ fromuser,
91
+ mes,
92
+ chatType: "direct",
93
+ senderName,
94
+ messageId: messageIdStr,
95
+ timestamp,
96
+ },
97
+ accountId,
98
+ statusSink,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Handles an incoming group chat message from Infoflow.
104
+ * Receives the raw decrypted message data and dispatches to the agent.
105
+ */
106
+ export async function handleGroupChatMessage(params: HandleGroupChatParams): Promise<void> {
107
+ const { cfg, msgData, accountId, statusSink } = params;
108
+ const core = getInfoflowRuntime();
109
+ const verbose = core.logging.shouldLogVerbose();
110
+
111
+ // Extract sender from nested structure or flat fields
112
+ const header = (msgData.message as Record<string, unknown>)?.header as
113
+ | Record<string, unknown>
114
+ | undefined;
115
+ const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
116
+
117
+ // Extract message ID (priority: header.messageid > header.msgid > MsgId)
118
+ const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
119
+ const messageIdStr = messageId != null ? String(messageId) : undefined;
120
+
121
+ const rawGroupId = msgData.groupid ?? header?.groupid;
122
+ const groupid =
123
+ typeof rawGroupId === "number" ? rawGroupId : rawGroupId ? Number(rawGroupId) : undefined;
124
+
125
+ // Extract timestamp (time is in milliseconds)
126
+ const rawTime = msgData.time ?? header?.servertime;
127
+ const timestamp = rawTime != null ? Number(rawTime) : Date.now();
128
+
129
+ if (verbose) {
130
+ getInfoflowBotLog().debug?.(
131
+ `[infoflow] group chat: fromuser=${fromuser}, groupid=${groupid}, raw msgData: ${JSON.stringify(msgData)}`,
132
+ );
133
+ }
134
+
135
+ if (!fromuser) {
136
+ return;
137
+ }
138
+
139
+ // Extract message content from body array or flat content field
140
+ const message = msgData.message as Record<string, unknown> | undefined;
141
+ const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowBodyItem[];
142
+
143
+ // Resolve account to get robotName for mention detection
144
+ const account = resolveInfoflowAccount({ cfg, accountId });
145
+ const robotName = account.config.robotName;
146
+
147
+ // Check if bot was @mentioned
148
+ const wasMentioned = checkBotMentioned(bodyItems, robotName);
149
+
150
+ // Build two versions: mes (for CommandBody, no @xxx) and rawMes (for RawBody, with @xxx)
151
+ let textContent = "";
152
+ let rawTextContent = "";
153
+ if (Array.isArray(bodyItems)) {
154
+ for (const item of bodyItems) {
155
+ if (item.type === "TEXT") {
156
+ textContent += item.content ?? "";
157
+ rawTextContent += item.content ?? "";
158
+ } else if (item.type === "LINK") {
159
+ const label = item.label ?? "";
160
+ if (label) {
161
+ textContent += ` ${label} `;
162
+ rawTextContent += ` ${label} `;
163
+ }
164
+ } else if (item.type === "AT") {
165
+ // AT elements only go into rawTextContent, not textContent
166
+ const name = item.name ?? "";
167
+ if (name) {
168
+ rawTextContent += `@${name} `;
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ const mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
175
+ const rawMes = rawTextContent.trim() || mes;
176
+
177
+ if (!mes) {
178
+ return;
179
+ }
180
+
181
+ // Extract sender name from header or fallback to fromuser
182
+ const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
183
+
184
+ // Delegate to the common message handler (group chat)
185
+ await handleInfoflowMessage({
186
+ cfg,
187
+ event: {
188
+ fromuser,
189
+ mes,
190
+ rawMes,
191
+ chatType: "group",
192
+ groupId: groupid,
193
+ senderName,
194
+ wasMentioned,
195
+ messageId: messageIdStr,
196
+ timestamp,
197
+ },
198
+ accountId,
199
+ statusSink,
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Resolves route, builds envelope, records session meta, and dispatches reply for one incoming Infoflow message.
205
+ * Called from monitor after webhook request is validated.
206
+ */
207
+ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams): Promise<void> {
208
+ const { cfg, event, accountId, statusSink } = params;
209
+ const { fromuser, mes, chatType, groupId, senderName } = event;
210
+
211
+ const account = resolveInfoflowAccount({ cfg, accountId });
212
+ const core = getInfoflowRuntime();
213
+ const verbose = core.logging.shouldLogVerbose();
214
+
215
+ if (verbose) {
216
+ getInfoflowBotLog().debug?.(
217
+ `[infoflow] handleInfoflowMessage invoked: accountId=${accountId}, chatType=${event.chatType}, fromuser=${event.fromuser}, groupId=${event.groupId}`,
218
+ );
219
+ }
220
+
221
+ const isGroup = chatType === "group";
222
+ // Convert groupId (number) to string for peerId since routing expects string
223
+ const peerId = isGroup ? (groupId !== undefined ? String(groupId) : fromuser) : fromuser;
224
+
225
+ // Resolve route based on chat type
226
+ const route = core.channel.routing.resolveAgentRoute({
227
+ cfg,
228
+ channel: "infoflow",
229
+ accountId: account.accountId,
230
+ peer: {
231
+ kind: isGroup ? "group" : "direct",
232
+ id: peerId,
233
+ },
234
+ });
235
+
236
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
237
+ agentId: route.agentId,
238
+ });
239
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
240
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
241
+ storePath,
242
+ sessionKey: route.sessionKey,
243
+ });
244
+
245
+ // Build conversation label and from address based on chat type
246
+ const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
247
+ const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
248
+ const toAddress = isGroup ? `infoflow:${groupId}` : `infoflow:${account.accountId}`;
249
+
250
+ if (verbose) {
251
+ getInfoflowBotLog().debug?.(
252
+ `[infoflow] dispatch: chatType=${chatType}, agentId=${route.agentId}`,
253
+ );
254
+ }
255
+
256
+ const body = core.channel.reply.formatAgentEnvelope({
257
+ channel: "Infoflow",
258
+ from: fromLabel,
259
+ timestamp: Date.now(),
260
+ previousTimestamp,
261
+ envelope: envelopeOptions,
262
+ body: mes,
263
+ });
264
+
265
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
266
+ Body: body,
267
+ RawBody: event.rawMes ?? mes,
268
+ CommandBody: mes,
269
+ From: fromAddress,
270
+ To: toAddress,
271
+ SessionKey: route.sessionKey,
272
+ AccountId: route.accountId,
273
+ ChatType: chatType,
274
+ ConversationLabel: fromLabel,
275
+ GroupSubject: isGroup ? `group:${groupId}` : undefined,
276
+ SenderName: senderName || fromuser,
277
+ SenderId: fromuser,
278
+ Provider: "infoflow",
279
+ Surface: "infoflow",
280
+ MessageSid: event.messageId ?? `${Date.now()}`,
281
+ Timestamp: event.timestamp ?? Date.now(),
282
+ OriginatingChannel: "infoflow",
283
+ OriginatingTo: toAddress,
284
+ WasMentioned: isGroup ? event.wasMentioned : undefined,
285
+ CommandAuthorized: true,
286
+ });
287
+
288
+ // Record session using recordInboundSession for proper session tracking
289
+ await core.channel.session.recordInboundSession({
290
+ storePath,
291
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
292
+ ctx: ctxPayload,
293
+ onRecordError: (err) => {
294
+ getInfoflowBotLog().error(`[infoflow] failed updating session meta: ${String(err)}`);
295
+ },
296
+ });
297
+
298
+ // Mention gating: skip reply if requireMention is enabled and bot was not mentioned
299
+ // Session is already recorded above for context history
300
+ if (isGroup) {
301
+ const requireMention = account.config.requireMention !== false;
302
+ const canDetectMention = Boolean(account.config.robotName);
303
+ const wasMentioned = event.wasMentioned === true;
304
+
305
+ if (requireMention && canDetectMention && !wasMentioned) {
306
+ return;
307
+ }
308
+ }
309
+
310
+ // Build unified target: "group:<id>" for group chat, username for private chat
311
+ const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
312
+
313
+ const { dispatcherOptions, replyOptions } = createInfoflowReplyDispatcher({
314
+ cfg,
315
+ agentId: route.agentId,
316
+ accountId: account.accountId,
317
+ to,
318
+ statusSink,
319
+ });
320
+
321
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
322
+ ctx: ctxPayload,
323
+ cfg,
324
+ dispatcherOptions,
325
+ replyOptions,
326
+ });
327
+
328
+ if (verbose) {
329
+ getInfoflowBotLog().debug?.(`[infoflow] dispatch complete: ${chatType} from ${fromuser}`);
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Test-only exports (@internal)
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /** @internal — Check if bot was mentioned in message body. Only exported for tests. */
338
+ export const _checkBotMentioned = checkBotMentioned;