@chbo297/infoflow 2026.3.18 → 2026.5.4

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/src/bot.ts CHANGED
@@ -1,12 +1,14 @@
1
+ import {
2
+ buildAgentMediaPayload,
3
+ getAgentScopedMediaLocalRoots,
4
+ } from "openclaw/plugin-sdk/agent-media-payload";
1
5
  import {
2
6
  buildPendingHistoryContextFromMap,
3
7
  clearHistoryEntriesIfEnabled,
4
8
  DEFAULT_GROUP_HISTORY_LIMIT,
5
9
  type HistoryEntry,
6
10
  recordPendingHistoryEntryIfEnabled,
7
- buildAgentMediaPayload,
8
- } from "openclaw/plugin-sdk";
9
- import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/mattermost";
11
+ } from "openclaw/plugin-sdk/reply-history";
10
12
  import { resolveInfoflowAccount } from "./accounts.js";
11
13
  import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
12
14
  import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
@@ -52,34 +54,62 @@ type InfoflowBodyItem = {
52
54
  messageid?: string | number;
53
55
  };
54
56
 
57
+ /** Identity used to detect @bot in group body (name, app agent id, or persisted robot id). */
58
+ export type InfoflowBotMentionIdentity = {
59
+ robotName?: string;
60
+ appAgentId?: number;
61
+ robotId?: string;
62
+ };
63
+
55
64
  /**
56
65
  * Check if the bot was @mentioned in the message body.
57
- * Matches by robotName against the AT item's display name (case-insensitive).
66
+ * Matches appAgentId, robotName, or robotId against AT items (same order as Baidu reference plugin).
58
67
  */
59
- function checkBotMentioned(bodyItems: InfoflowBodyItem[], robotName?: string): boolean {
60
- if (!robotName) return false;
61
- const normalizedRobotName = robotName.toLowerCase();
68
+ export function checkBotMentioned(
69
+ bodyItems: InfoflowBodyItem[],
70
+ identityOrName?: string | InfoflowBotMentionIdentity,
71
+ ): boolean {
72
+ const identity: InfoflowBotMentionIdentity =
73
+ typeof identityOrName === "string" || identityOrName === undefined
74
+ ? { robotName: identityOrName }
75
+ : identityOrName;
76
+ const { robotName, appAgentId, robotId } = identity;
77
+ const appAgentIdStr = appAgentId != null ? String(appAgentId) : undefined;
78
+ const normalizedRobotName = robotName?.toLowerCase();
79
+ const normalizedRobotId = robotId?.trim();
62
80
  for (const item of bodyItems) {
63
81
  if (item.type !== "AT") continue;
64
- if (item.name?.toLowerCase() === normalizedRobotName) return true;
82
+ if (appAgentIdStr && item.robotid != null && String(item.robotid) === appAgentIdStr)
83
+ return true;
84
+ if (normalizedRobotName && item.name?.toLowerCase() === normalizedRobotName) return true;
85
+ if (normalizedRobotId && item.robotid != null && String(item.robotid) === normalizedRobotId)
86
+ return true;
65
87
  }
66
88
  return false;
67
89
  }
68
90
 
69
91
  /**
70
- * When the bot is @mentioned (item.name matches robotName), return that AT item's robotid.
71
- * Used to discover and persist the account's robotId from incoming group messages.
92
+ * When the bot is @mentioned, return that AT item's robotid string for persistence.
72
93
  */
73
94
  function getBotRobotidFromBody(
74
95
  bodyItems: InfoflowBodyItem[],
75
96
  robotName?: string,
76
- ): number | undefined {
77
- if (!robotName) return undefined;
78
- const normalizedRobotName = robotName.toLowerCase();
97
+ robotId?: string,
98
+ ): string | undefined {
99
+ const normalizedRobotName = robotName?.toLowerCase();
100
+ const normalizedRobotId = robotId?.trim();
79
101
  for (const item of bodyItems) {
80
102
  if (item.type !== "AT") continue;
81
- if (item.name?.toLowerCase() === normalizedRobotName && item.robotid != null)
82
- return item.robotid;
103
+ if (normalizedRobotId && item.robotid != null && String(item.robotid) === normalizedRobotId) {
104
+ return normalizedRobotId;
105
+ }
106
+ if (
107
+ normalizedRobotName &&
108
+ item.name?.toLowerCase() === normalizedRobotName &&
109
+ item.robotid != null
110
+ ) {
111
+ return String(item.robotid);
112
+ }
83
113
  }
84
114
  return undefined;
85
115
  }
@@ -557,14 +587,27 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
557
587
  const account = resolveInfoflowAccount({ cfg, accountId });
558
588
  const robotName = account.config.robotName;
559
589
 
560
- // Check if bot was @mentioned (by robotName)
561
- const wasMentioned = checkBotMentioned(bodyItems, robotName);
590
+ const mentionIdentity: InfoflowBotMentionIdentity = {
591
+ robotName,
592
+ appAgentId: account.config.appAgentId,
593
+ robotId: account.config.robotId?.trim() || undefined,
594
+ };
595
+
596
+ const rawEventType = String(msgData.eventtype ?? "");
597
+ const wasMentioned =
598
+ msgData.wasMentioned === true
599
+ ? true
600
+ : rawEventType === "ALL_MESSAGE_FORWARD"
601
+ ? checkBotMentioned(bodyItems, mentionIdentity)
602
+ : rawEventType === "MESSAGE_RECEIVE"
603
+ ? true
604
+ : checkBotMentioned(bodyItems, mentionIdentity);
562
605
 
563
606
  // When bot is @mentioned, discover and persist robotId from the AT item so we can ignore our own messages later.
564
607
  let effectiveRobotId = account.config.robotId?.trim() || undefined;
565
- const discoveredRobotid = getBotRobotidFromBody(bodyItems, robotName);
566
- if (wasMentioned && discoveredRobotid != null) {
567
- const newRobotId = String(discoveredRobotid);
608
+ const discoveredRobotId = getBotRobotidFromBody(bodyItems, robotName, effectiveRobotId);
609
+ if (wasMentioned && discoveredRobotId != null) {
610
+ const newRobotId = discoveredRobotId;
568
611
  if (newRobotId !== effectiveRobotId) {
569
612
  try {
570
613
  const runtime = getInfoflowRuntime();
package/src/channel.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
1
2
  import {
2
3
  applyAccountNameToChannelSection,
3
4
  DEFAULT_ACCOUNT_ID,
@@ -6,9 +7,7 @@ import {
6
7
  migrateBaseNameToDefaultAccount,
7
8
  normalizeAccountId,
8
9
  setAccountEnabledInConfigSection,
9
- type ChannelPlugin,
10
- type OpenClawConfig,
11
- } from "openclaw/plugin-sdk";
10
+ } from "openclaw/plugin-sdk/core";
12
11
  import {
13
12
  getChannelSection,
14
13
  listInfoflowAccountIds,
@@ -19,20 +18,68 @@ import { infoflowMessageActions } from "./actions.js";
19
18
  import { logVerbose } from "./logging.js";
20
19
  import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
21
20
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
22
- import { startInfoflowMonitor } from "./monitor.js";
21
+ import { startInfoflowMonitor, startInfoflowWSMonitor } from "./monitor.js";
23
22
  import { getInfoflowRuntime } from "./runtime.js";
24
23
  import { sendInfoflowMessage } from "./send.js";
25
24
  import { normalizeInfoflowTarget, looksLikeInfoflowId } from "./targets.js";
26
25
  import type { InfoflowOutboundReply, ResolvedInfoflowAccount } from "./types.js";
27
26
 
28
27
  // Re-export types and account functions for external consumers
29
- export type { InfoflowAccountConfig, ResolvedInfoflowAccount } from "./types.js";
28
+ export type {
29
+ InfoflowAccountConfig,
30
+ InfoflowConnectionMode,
31
+ ResolvedInfoflowAccount,
32
+ } from "./types.js";
30
33
  export { resolveInfoflowAccount } from "./accounts.js";
31
34
 
32
35
  // ---------------------------------------------------------------------------
33
36
  // Channel plugin
34
37
  // ---------------------------------------------------------------------------
35
38
 
39
+ function applyInfoflowSetupPatch(params: {
40
+ cfg: OpenClawConfig;
41
+ accountId: string;
42
+ patch: Record<string, unknown>;
43
+ }): OpenClawConfig {
44
+ const { cfg, accountId, patch } = params;
45
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
46
+ const existingInfoflow = (channels["infoflow"] ?? {}) as Record<string, unknown>;
47
+
48
+ if (accountId === DEFAULT_ACCOUNT_ID) {
49
+ return {
50
+ ...cfg,
51
+ channels: {
52
+ ...channels,
53
+ infoflow: {
54
+ ...existingInfoflow,
55
+ enabled: true,
56
+ ...patch,
57
+ },
58
+ },
59
+ };
60
+ }
61
+
62
+ const existingAccounts = (existingInfoflow.accounts ?? {}) as Record<string, Record<string, unknown>>;
63
+ return {
64
+ ...cfg,
65
+ channels: {
66
+ ...channels,
67
+ infoflow: {
68
+ ...existingInfoflow,
69
+ enabled: true,
70
+ accounts: {
71
+ ...existingAccounts,
72
+ [accountId]: {
73
+ ...existingAccounts[accountId],
74
+ enabled: true,
75
+ ...patch,
76
+ },
77
+ },
78
+ },
79
+ },
80
+ };
81
+ }
82
+
36
83
  export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
37
84
  id: "infoflow",
38
85
  meta: {
@@ -96,7 +143,7 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
96
143
  policy: ((account.config as Record<string, unknown>).dmPolicy as string) ?? "open",
97
144
  allowFrom: ((account.config as Record<string, unknown>).allowFrom as string[]) ?? [],
98
145
  policyPath: `${basePath}dmPolicy`,
99
- allowFromPath: basePath,
146
+ allowFromPath: `${basePath}allowFrom`,
100
147
  approveHint: formatPairingApproveHint("infoflow"),
101
148
  normalizeEntry: (raw: string) => raw.replace(/^infoflow:/i, ""),
102
149
  };
@@ -169,40 +216,7 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
169
216
  if (input.token) {
170
217
  patch.checkToken = input.token;
171
218
  }
172
-
173
- const existing = (next.channels?.["infoflow"] ?? {}) as Record<string, unknown>;
174
- if (accountId === DEFAULT_ACCOUNT_ID) {
175
- return {
176
- ...next,
177
- channels: {
178
- ...next.channels,
179
- infoflow: {
180
- ...existing,
181
- enabled: true,
182
- ...patch,
183
- },
184
- },
185
- } as OpenClawConfig;
186
- }
187
- const existingAccounts = (existing.accounts ?? {}) as Record<string, Record<string, unknown>>;
188
- return {
189
- ...next,
190
- channels: {
191
- ...next.channels,
192
- infoflow: {
193
- ...existing,
194
- enabled: true,
195
- accounts: {
196
- ...existingAccounts,
197
- [accountId]: {
198
- ...existingAccounts[accountId],
199
- enabled: true,
200
- ...patch,
201
- },
202
- },
203
- },
204
- },
205
- } as OpenClawConfig;
219
+ return applyInfoflowSetupPatch({ cfg: next, accountId, patch });
206
220
  },
207
221
  },
208
222
  outbound: {
@@ -390,19 +404,24 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
390
404
  gateway: {
391
405
  startAccount: async (ctx) => {
392
406
  const account = ctx.account;
393
- ctx.log?.info(`[${account.accountId}] starting Infoflow webhook`);
407
+ const connectionMode = account.config.connectionMode ?? "webhook";
408
+ ctx.log?.info(`[${account.accountId}] starting Infoflow (${connectionMode})`);
394
409
  ctx.setStatus({
395
410
  accountId: account.accountId,
396
411
  running: true,
397
412
  lastStartAt: Date.now(),
398
413
  });
399
- const unregister = await startInfoflowMonitor({
414
+ const monitorOptions = {
400
415
  account,
401
416
  config: ctx.cfg,
402
- runtime: ctx.runtime,
403
417
  abortSignal: ctx.abortSignal,
404
- statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
405
- });
418
+ statusSink: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) =>
419
+ ctx.setStatus({ accountId: account.accountId, ...patch }),
420
+ };
421
+ const unregister =
422
+ connectionMode === "websocket"
423
+ ? await startInfoflowWSMonitor(monitorOptions)
424
+ : await startInfoflowMonitor(monitorOptions);
406
425
 
407
426
  // Keep the channel alive until explicitly stopped.
408
427
  // Without this, the promise resolves immediately and the gateway
@@ -1,7 +1,7 @@
1
1
  import { createHash, createDecipheriv, timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage } from "node:http";
3
3
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
- import { createDedupeCache } from "openclaw/plugin-sdk";
4
+ import { createDedupeCache } from "openclaw/plugin-sdk/core";
5
5
  // ---------------------------------------------------------------------------
6
6
  // Message deduplication
7
7
  // ---------------------------------------------------------------------------
@@ -98,7 +98,7 @@ function extractDedupeKey(msgData: Record<string, unknown>): string | null {
98
98
  * Returns true if the message is a duplicate (already seen within TTL).
99
99
  * Uses shared dedupe cache implementation.
100
100
  */
101
- function isDuplicateMessage(msgData: Record<string, unknown>): boolean {
101
+ export function isDuplicateMessage(msgData: Record<string, unknown>): boolean {
102
102
  const key = extractDedupeKey(msgData);
103
103
  if (!key) return false; // Cannot extract key, allow through
104
104
 
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Ambient types when `@baidu/infoflow-sdk-nodejs` is not installed (e.g. public npm).
3
+ * Install the SDK from Baidu's registry to use `connectionMode: "websocket"`.
4
+ */
5
+ declare module "@baidu/infoflow-sdk-nodejs" {
6
+ export class WSClient {
7
+ constructor(options: Record<string, unknown>);
8
+ on(event: string, handler: (...args: unknown[]) => void): void;
9
+ connect(): Promise<void>;
10
+ disconnect(): void;
11
+ }
12
+ }
package/src/monitor.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from "./infoflow-req-parse.js";
9
9
  import { getInfoflowWebhookLog, formatInfoflowError, logVerbose } from "./logging.js";
10
10
  import { getInfoflowRuntime } from "./runtime.js";
11
+ import { InfoflowWSReceiver } from "./ws-receiver.js";
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Types
@@ -16,7 +17,6 @@ import { getInfoflowRuntime } from "./runtime.js";
16
17
  export type InfoflowMonitorOptions = {
17
18
  account: ResolvedInfoflowAccount;
18
19
  config: OpenClawConfig;
19
- runtime: unknown;
20
20
  abortSignal: AbortSignal;
21
21
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
22
22
  };
@@ -79,7 +79,7 @@ function isInfoflowPath(requestPath: string): boolean {
79
79
  /**
80
80
  * Handles incoming Infoflow webhook HTTP requests.
81
81
  *
82
- * - Routes by path to registered targets (supports exact and suffix match).
82
+ * - Routes by path to registered targets (exact match).
83
83
  * - Only allows POST.
84
84
  * - Delegates body reading, echostr verification, authentication,
85
85
  * and message dispatch to infoflow_req_parse.
@@ -167,3 +167,22 @@ export async function startInfoflowMonitor(options: InfoflowMonitorOptions): Pro
167
167
 
168
168
  return unregister;
169
169
  }
170
+
171
+ /** Starts a WebSocket message receiver and returns a stop function. */
172
+ export async function startInfoflowWSMonitor(options: InfoflowMonitorOptions): Promise<() => void> {
173
+ const receiver = new InfoflowWSReceiver({
174
+ account: options.account,
175
+ config: options.config,
176
+ abortSignal: options.abortSignal,
177
+ statusSink: options.statusSink,
178
+ });
179
+
180
+ try {
181
+ await receiver.start();
182
+ } catch (err) {
183
+ receiver.stop();
184
+ throw err;
185
+ }
186
+
187
+ return () => receiver.stop();
188
+ }
@@ -1,8 +1,5 @@
1
- import {
2
- createReplyPrefixOptions,
3
- type OpenClawConfig,
4
- type ReplyPayload,
5
- } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig, ReplyPayload } from "openclaw/plugin-sdk";
2
+ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
6
3
  import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "./logging.js";
7
4
  import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
8
5
  import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
package/src/types.ts CHANGED
@@ -10,6 +10,9 @@ export type InfoflowDmPolicy = "open" | "pairing" | "allowlist";
10
10
  export type InfoflowGroupPolicy = "open" | "allowlist" | "disabled";
11
11
  export type InfoflowChatType = "direct" | "group";
12
12
 
13
+ /** Inbound message transport: webhook (Infoflow POSTs to your server) or websocket (plugin connects to gateway). */
14
+ export type InfoflowConnectionMode = "webhook" | "websocket";
15
+
13
16
  /** Reply mode controlling bot behavior per group */
14
17
  export type InfoflowReplyMode =
15
18
  | "ignore"
@@ -101,6 +104,11 @@ export type InfoflowAccountConfig = {
101
104
  enabled?: boolean;
102
105
  name?: string;
103
106
  apiHost?: string;
107
+ connectionMode?: InfoflowConnectionMode;
108
+ /** WebSocket gateway host (websocket mode). */
109
+ wsGateway?: string;
110
+ /** Override WebSocket handshake host (optional, intranet). */
111
+ wsConnectDomain?: string;
104
112
  checkToken?: string;
105
113
  encodingAESKey?: string;
106
114
  appKey?: string;
@@ -142,6 +150,9 @@ export type ResolvedInfoflowAccount = {
142
150
  enabled?: boolean;
143
151
  name?: string;
144
152
  apiHost: string;
153
+ connectionMode: InfoflowConnectionMode;
154
+ wsGateway: string;
155
+ wsConnectDomain?: string;
145
156
  checkToken: string;
146
157
  encodingAESKey: string;
147
158
  appKey: string;