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