@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.
- package/README.md +989 -0
- package/docs/architecture-data-flow.md +429 -0
- package/docs/architecture.md +423 -0
- package/docs/dev-guide.md +611 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +138 -0
- package/package.json +40 -0
- package/scripts/deploy.sh +34 -0
- package/skills/infoflow-dev/SKILL.md +88 -0
- package/skills/infoflow-dev/references/api.md +413 -0
- package/src/adapter/inbound/webhook-parser.ts +433 -0
- package/src/adapter/inbound/ws-receiver.ts +226 -0
- package/src/adapter/outbound/reply-dispatcher.ts +281 -0
- package/src/adapter/outbound/target-resolver.ts +109 -0
- package/src/channel/accounts.ts +164 -0
- package/src/channel/channel.ts +364 -0
- package/src/channel/media.ts +365 -0
- package/src/channel/monitor.ts +184 -0
- package/src/channel/outbound.ts +934 -0
- package/src/events.ts +62 -0
- package/src/handler/message-handler.ts +801 -0
- package/src/logging.ts +123 -0
- package/src/runtime.ts +14 -0
- package/src/security/dm-policy.ts +80 -0
- package/src/security/group-policy.ts +271 -0
- package/src/tools/actions/index.ts +456 -0
- package/src/tools/hooks/index.ts +82 -0
- package/src/tools/index.ts +277 -0
- package/src/types.ts +277 -0
- package/src/utils/store/message-store.ts +295 -0
- package/src/utils/token-adapter.ts +90 -0
|
@@ -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
|
+
};
|