@core-workspace/infoflow-openclaw-plugin 2026.3.8

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.
@@ -0,0 +1,364 @@
1
+ import {
2
+ applyAccountNameToChannelSection,
3
+ DEFAULT_ACCOUNT_ID,
4
+ deleteAccountFromConfigSection,
5
+ formatPairingApproveHint,
6
+ migrateBaseNameToDefaultAccount,
7
+ normalizeAccountId,
8
+ setAccountEnabledInConfigSection,
9
+ type ChannelPlugin,
10
+ type OpenClawConfig,
11
+ } from "openclaw/plugin-sdk";
12
+ import {
13
+ getChannelSection,
14
+ listInfoflowAccountIds,
15
+ resolveDefaultInfoflowAccountId,
16
+ resolveInfoflowAccount,
17
+ } from "./accounts.js";
18
+ import { infoflowMessageActions } from "../tools/actions/index.js";
19
+ import { logVerbose } from "../logging.js";
20
+ import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
21
+ import { startInfoflowMonitor, startInfoflowWSMonitor } from "./monitor.js";
22
+ import { getInfoflowRuntime } from "../runtime.js";
23
+ import { sendInfoflowMessage } from "./outbound.js";
24
+ import { normalizeInfoflowTarget, looksLikeInfoflowId } from "../adapter/outbound/target-resolver.js";
25
+ import type { ResolvedInfoflowAccount } from "../types.js";
26
+
27
+ // Re-export types and account functions for external consumers
28
+ export type { InfoflowAccountConfig, ResolvedInfoflowAccount } from "../types.js";
29
+ export { resolveInfoflowAccount } from "./accounts.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Channel plugin
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
36
+ id: "infoflow",
37
+ meta: {
38
+ id: "infoflow",
39
+ label: "Infoflow",
40
+ selectionLabel: "Infoflow (如流)",
41
+ docsPath: "/channels/infoflow",
42
+ blurb: "Baidu Infoflow enterprise messaging platform.",
43
+ showConfigured: true,
44
+ },
45
+ capabilities: {
46
+ chatTypes: ["direct", "group"],
47
+ nativeCommands: true,
48
+ unsend: true,
49
+ },
50
+ reload: { configPrefixes: ["channels.infoflow"] },
51
+ actions: infoflowMessageActions,
52
+ agentPrompt: {
53
+ messageToolHints: () => [
54
+ 'Infoflow group @mentions: set atAll=true to @all members, or mentionUserIds="user1,user2" (comma-separated uuapName) to @mention specific users. Only effective for group targets (group:<id>).',
55
+ 'Infoflow supports message recall (撤回): use action="delete" to recall the most recent message, or specify messageId to recall a specific message. Works for both private and group messages.',
56
+ ],
57
+ },
58
+ config: {
59
+ listAccountIds: (cfg) => listInfoflowAccountIds(cfg),
60
+ resolveAccount: (cfg, accountId) => resolveInfoflowAccount({ cfg, accountId }),
61
+ defaultAccountId: (cfg) => resolveDefaultInfoflowAccountId(cfg),
62
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
63
+ setAccountEnabledInConfigSection({
64
+ cfg,
65
+ sectionKey: "infoflow",
66
+ accountId,
67
+ enabled,
68
+ allowTopLevel: true,
69
+ }),
70
+ deleteAccount: ({ cfg, accountId }) =>
71
+ deleteAccountFromConfigSection({
72
+ cfg,
73
+ sectionKey: "infoflow",
74
+ accountId,
75
+ clearBaseFields: ["checkToken", "encodingAESKey", "appKey", "appSecret", "name"],
76
+ }),
77
+ isConfigured: (account) => account.configured,
78
+ describeAccount: (account) => ({
79
+ accountId: account.accountId,
80
+ name: account.name,
81
+ enabled: account.enabled,
82
+ configured: account.configured,
83
+ }),
84
+ },
85
+ security: {
86
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
87
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
88
+ const channelCfg = getChannelSection(cfg);
89
+ const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
90
+ const basePath = useAccountPath
91
+ ? `channels.infoflow.accounts.${resolvedAccountId}.`
92
+ : "channels.infoflow.";
93
+
94
+ return {
95
+ policy: ((account.config as Record<string, unknown>).dmPolicy as string) ?? "open",
96
+ allowFrom: ((account.config as Record<string, unknown>).allowFrom as string[]) ?? [],
97
+ policyPath: `${basePath}dmPolicy`,
98
+ allowFromPath: basePath,
99
+ approveHint: formatPairingApproveHint("infoflow"),
100
+ normalizeEntry: (raw: string) => raw.replace(/^infoflow:/i, ""),
101
+ };
102
+ },
103
+ collectWarnings: ({ account, cfg }) => {
104
+ const warnings: string[] = [];
105
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
106
+ const groupPolicy =
107
+ ((account.config as Record<string, unknown>).groupPolicy as string) ??
108
+ defaultGroupPolicy ??
109
+ "open";
110
+
111
+ if (groupPolicy === "open") {
112
+ warnings.push(
113
+ `- Infoflow groups: groupPolicy="open" allows any group to trigger. Consider setting channels.infoflow.groupPolicy="allowlist".`,
114
+ );
115
+ }
116
+ return warnings;
117
+ },
118
+ },
119
+ groups: {
120
+ resolveRequireMention: ({ cfg, accountId }) => {
121
+ const channelCfg = getChannelSection(cfg);
122
+ const accountCfg =
123
+ accountId && accountId !== DEFAULT_ACCOUNT_ID
124
+ ? channelCfg?.accounts?.[accountId]
125
+ : channelCfg;
126
+ return (accountCfg as Record<string, unknown> | undefined)?.requireMention !== false;
127
+ },
128
+ resolveToolPolicy: () => {
129
+ // Return undefined to use global policy
130
+ return undefined;
131
+ },
132
+ },
133
+ messaging: {
134
+ normalizeTarget: (raw) => normalizeInfoflowTarget(raw),
135
+ targetResolver: {
136
+ looksLikeId: looksLikeInfoflowId,
137
+ hint: "<username|group:groupId>",
138
+ },
139
+ },
140
+ setup: {
141
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
142
+ applyAccountName: ({ cfg, accountId, name }) =>
143
+ applyAccountNameToChannelSection({
144
+ cfg,
145
+ channelKey: "infoflow",
146
+ accountId,
147
+ name,
148
+ }),
149
+ validateInput: ({ input }) => {
150
+ if (!input.token) {
151
+ return "Infoflow requires --token (checkToken).";
152
+ }
153
+ return null;
154
+ },
155
+ applyAccountConfig: ({ cfg, accountId, input }) => {
156
+ const namedConfig = applyAccountNameToChannelSection({
157
+ cfg,
158
+ channelKey: "infoflow",
159
+ accountId,
160
+ name: input.name,
161
+ });
162
+ const next =
163
+ accountId !== DEFAULT_ACCOUNT_ID
164
+ ? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "infoflow" })
165
+ : namedConfig;
166
+
167
+ const patch: Record<string, unknown> = {};
168
+ if (input.token) {
169
+ patch.checkToken = input.token;
170
+ }
171
+
172
+ const existing = (next.channels?.["infoflow"] ?? {}) as Record<string, unknown>;
173
+ if (accountId === DEFAULT_ACCOUNT_ID) {
174
+ return {
175
+ ...next,
176
+ channels: {
177
+ ...next.channels,
178
+ infoflow: {
179
+ ...existing,
180
+ enabled: true,
181
+ ...patch,
182
+ },
183
+ },
184
+ } as OpenClawConfig;
185
+ }
186
+ const existingAccounts = (existing.accounts ?? {}) as Record<string, Record<string, unknown>>;
187
+ return {
188
+ ...next,
189
+ channels: {
190
+ ...next.channels,
191
+ infoflow: {
192
+ ...existing,
193
+ enabled: true,
194
+ accounts: {
195
+ ...existingAccounts,
196
+ [accountId]: {
197
+ ...existingAccounts[accountId],
198
+ enabled: true,
199
+ ...patch,
200
+ },
201
+ },
202
+ },
203
+ },
204
+ } as OpenClawConfig;
205
+ },
206
+ },
207
+ outbound: {
208
+ deliveryMode: "direct",
209
+ chunkerMode: "markdown",
210
+ textChunkLimit: 4000,
211
+ chunker: (text, limit) => getInfoflowRuntime().channel.text.chunkText(text, limit),
212
+ sendText: async ({ cfg, to, text, accountId }) => {
213
+ logVerbose(`[infoflow:sendText] to=${to}, accountId=${accountId}`);
214
+ // Use "markdown" type even though param is named `text`: LLM outputs are often markdown,
215
+ // and Infoflow's markdown type handles both plain text and markdown seamlessly.
216
+ const result = await sendInfoflowMessage({
217
+ cfg,
218
+ to,
219
+ contents: [{ type: "markdown", content: text }],
220
+ accountId: accountId ?? undefined,
221
+ });
222
+ return {
223
+ channel: "infoflow",
224
+ messageId: result.ok ? (result.messageId ?? "sent") : "failed",
225
+ };
226
+ },
227
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
228
+ logVerbose(`[infoflow:sendMedia] to=${to}, accountId=${accountId}, mediaUrl=${mediaUrl}`);
229
+
230
+ const trimmedText = text?.trim();
231
+
232
+ // Helper: send text as markdown
233
+ const sendText = () =>
234
+ sendInfoflowMessage({
235
+ cfg,
236
+ to,
237
+ contents: [{ type: "markdown", content: trimmedText! }],
238
+ accountId: accountId ?? undefined,
239
+ });
240
+
241
+ // Helper: attempt native image send, fall back to link
242
+ const sendImage = async (): Promise<{ ok: boolean; messageId?: string }> => {
243
+ if (!mediaUrl) return { ok: false };
244
+ try {
245
+ const prepared = await prepareInfoflowImageBase64({
246
+ mediaUrl,
247
+ mediaLocalRoots: mediaLocalRoots ?? undefined,
248
+ });
249
+ if (prepared.isImage) {
250
+ const result = await sendInfoflowImageMessage({
251
+ cfg,
252
+ to,
253
+ base64Image: prepared.base64,
254
+ accountId: accountId ?? undefined,
255
+ });
256
+ if (result.ok) return { ok: true, messageId: result.messageId };
257
+ // Native send failed, fall back to link
258
+ logVerbose(
259
+ `[infoflow:sendMedia] native image failed: ${result.error}, falling back to link`,
260
+ );
261
+ }
262
+ } catch (err) {
263
+ logVerbose(`[infoflow:sendMedia] image prep failed, falling back to link: ${err}`);
264
+ }
265
+ // Fallback: send as link
266
+ const linkResult = await sendInfoflowMessage({
267
+ cfg,
268
+ to,
269
+ contents: [{ type: "link", content: mediaUrl }],
270
+ accountId: accountId ?? undefined,
271
+ });
272
+ return { ok: linkResult.ok, messageId: linkResult.messageId };
273
+ };
274
+
275
+ // Dispatch: concurrent text + image, or text-only, or image-only
276
+ if (trimmedText && mediaUrl) {
277
+ const [, imageResult] = await Promise.all([sendText(), sendImage()]);
278
+ return {
279
+ channel: "infoflow",
280
+ messageId: imageResult.ok ? (imageResult.messageId ?? "sent") : "failed",
281
+ };
282
+ }
283
+ if (trimmedText) {
284
+ const result = await sendText();
285
+ return {
286
+ channel: "infoflow",
287
+ messageId: result.ok ? (result.messageId ?? "sent") : "failed",
288
+ };
289
+ }
290
+ if (mediaUrl) {
291
+ const result = await sendImage();
292
+ return {
293
+ channel: "infoflow",
294
+ messageId: result.ok ? (result.messageId ?? "sent") : "failed",
295
+ };
296
+ }
297
+
298
+ return { channel: "infoflow", messageId: "failed" };
299
+ },
300
+ },
301
+ status: {
302
+ defaultRuntime: {
303
+ accountId: DEFAULT_ACCOUNT_ID,
304
+ running: false,
305
+ lastStartAt: null,
306
+ lastStopAt: null,
307
+ lastError: null,
308
+ },
309
+ buildAccountSnapshot: ({ account, runtime }) => ({
310
+ accountId: account.accountId,
311
+ name: account.name,
312
+ enabled: account.enabled,
313
+ configured: account.configured,
314
+ running: runtime?.running ?? false,
315
+ lastStartAt: runtime?.lastStartAt ?? null,
316
+ lastStopAt: runtime?.lastStopAt ?? null,
317
+ lastError: runtime?.lastError ?? null,
318
+ lastInboundAt: runtime?.lastInboundAt ?? null,
319
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
320
+ }),
321
+ },
322
+ gateway: {
323
+ startAccount: async (ctx) => {
324
+ const account = ctx.account;
325
+ const connectionMode = account.config.connectionMode ?? "webhook";
326
+ ctx.log?.info(`[${account.accountId}] starting Infoflow ${connectionMode}`);
327
+ ctx.setStatus({
328
+ accountId: account.accountId,
329
+ running: true,
330
+ lastStartAt: Date.now(),
331
+ });
332
+
333
+ const monitorOptions = {
334
+ account,
335
+ config: ctx.cfg,
336
+ runtime: ctx.runtime,
337
+ abortSignal: ctx.abortSignal,
338
+ statusSink: (patch: Record<string, unknown>) =>
339
+ ctx.setStatus({ accountId: account.accountId, ...patch }),
340
+ };
341
+
342
+ const unregister =
343
+ connectionMode === "websocket"
344
+ ? await startInfoflowWSMonitor(monitorOptions)
345
+ : await startInfoflowMonitor(monitorOptions);
346
+
347
+ // Keep the channel alive until explicitly stopped.
348
+ // Without this, the promise resolves immediately and the gateway
349
+ // framework treats it as "channel exited", triggering auto-restart.
350
+ try {
351
+ await new Promise<void>((resolve) => {
352
+ ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true });
353
+ });
354
+ } finally {
355
+ unregister?.();
356
+ ctx.setStatus({
357
+ accountId: account.accountId,
358
+ running: false,
359
+ lastStopAt: Date.now(),
360
+ });
361
+ }
362
+ },
363
+ },
364
+ };