@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,281 @@
1
+ import {
2
+ createReplyPrefixOptions,
3
+ type OpenClawConfig,
4
+ type ReplyPayload,
5
+ } from "openclaw/plugin-sdk";
6
+ import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "../../logging.js";
7
+ import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "../../channel/media.js";
8
+ import { getInfoflowRuntime } from "../../runtime.js";
9
+ import { isLikelyLocalPath, sendInfoflowMessage } from "../../channel/outbound.js";
10
+ import type {
11
+ InfoflowAtOptions,
12
+ InfoflowMentionIds,
13
+ InfoflowMessageContentItem,
14
+ InfoflowOutboundReply,
15
+ } from "../../types.js";
16
+
17
+ const PREVIEW_MAX_LENGTH = 100;
18
+
19
+ function truncatePreview(text?: string): string {
20
+ if (!text) return "";
21
+ if (text.length <= PREVIEW_MAX_LENGTH) return text;
22
+ return text.slice(0, PREVIEW_MAX_LENGTH) + "...";
23
+ }
24
+
25
+ export type CreateInfoflowReplyDispatcherParams = {
26
+ cfg: OpenClawConfig;
27
+ agentId: string;
28
+ accountId: string;
29
+ /** Target: "group:<id>" for group chat, username for private chat */
30
+ to: string;
31
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
32
+ /** AT options for @mentioning members in group messages */
33
+ atOptions?: InfoflowAtOptions;
34
+ /** Mention IDs from inbound message for resolving @id in LLM output */
35
+ mentionIds?: InfoflowMentionIds;
36
+ /** Inbound message ID for outbound reply-to (group only) */
37
+ replyToMessageId?: string;
38
+ /** Preview text of the inbound message for reply context */
39
+ replyToPreview?: string;
40
+ /** IMID of the user who sent the message being replied to */
41
+ replyToImid?: string;
42
+ /**
43
+ * Message format for outbound text: "text" or "markdown".
44
+ * Note: "markdown" does not support reply-to (quote) — replyTo will be ignored when using markdown.
45
+ * Default: "text"
46
+ */
47
+ messageFormat?: "text" | "markdown";
48
+ };
49
+
50
+ /**
51
+ * Builds dispatcherOptions and replyOptions for dispatchReplyWithBufferedBlockDispatcher.
52
+ * Encapsulates prefix options, chunked deliver (send via Infoflow API + statusSink), and onError.
53
+ */
54
+ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatcherParams) {
55
+ const {
56
+ cfg,
57
+ agentId,
58
+ accountId,
59
+ to,
60
+ statusSink,
61
+ atOptions,
62
+ mentionIds,
63
+ replyToMessageId,
64
+ replyToPreview,
65
+ replyToImid,
66
+ messageFormat = "text",
67
+ } = params;
68
+ const core = getInfoflowRuntime();
69
+
70
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
71
+ cfg,
72
+ agentId,
73
+ channel: "infoflow",
74
+ accountId,
75
+ });
76
+
77
+ // Check if target is a group (format: group:<id>)
78
+ const isGroup = /^group:\d+$/i.test(to);
79
+
80
+ // markdown format does not support reply-to; fall back to no replyTo when using markdown
81
+ const effectiveReplyToMessageId = messageFormat === "markdown" ? undefined : replyToMessageId;
82
+
83
+ // Build id→type map for resolving @id in LLM output (distinguishes user vs agent)
84
+ const mentionIdMap = new Map<string, "user" | "agent">();
85
+ if (mentionIds) {
86
+ for (const id of mentionIds.userIds) {
87
+ mentionIdMap.set(id.toLowerCase(), "user");
88
+ }
89
+ for (const id of mentionIds.agentIds) {
90
+ mentionIdMap.set(String(id).toLowerCase(), "agent");
91
+ }
92
+ }
93
+
94
+ // Build replyTo context (only used for the first outbound message)
95
+ // Note: replyTo is suppressed when messageFormat is "markdown" (not supported by API)
96
+ logVerbose(`[DEBUG reply-dispatcher] isGroup=${isGroup}, replyToMessageId=${replyToMessageId}, replyToImid=${replyToImid}, replyToPreview=${replyToPreview?.slice(0, 50)}`);
97
+
98
+ const replyTo: InfoflowOutboundReply | undefined =
99
+ isGroup && effectiveReplyToMessageId
100
+ ? {
101
+ messageid: effectiveReplyToMessageId,
102
+ preview: truncatePreview(replyToPreview),
103
+ ...(replyToImid ? { imid: replyToImid } : {}),
104
+ replytype: "2" // "2" = 引用模式
105
+ }
106
+ : undefined;
107
+ let replyApplied = false;
108
+
109
+ // Debug: Log constructed replyTo
110
+ logVerbose(`[DEBUG reply-dispatcher] replyTo constructed: ${JSON.stringify(replyTo)}`);
111
+ if (replyTo) {
112
+ logVerbose(`[DEBUG reply-dispatcher] Creating reply context with messageid=${replyTo.messageid}`);
113
+ } else {
114
+ logVerbose(`[DEBUG reply-dispatcher] replyTo is undefined - not creating reply context`);
115
+ }
116
+
117
+ const deliver = async (payload: ReplyPayload) => {
118
+ const text = payload.text ?? "";
119
+ logVerbose(`[infoflow] deliver called: to=${to}, text=${text}`);
120
+
121
+ // Normalize media URL list (same pattern as Feishu reply-dispatcher)
122
+ const mediaList =
123
+ payload.mediaUrls && payload.mediaUrls.length > 0
124
+ ? payload.mediaUrls
125
+ : payload.mediaUrl
126
+ ? [payload.mediaUrl]
127
+ : [];
128
+
129
+ logVerbose(`[infoflow] deliver called: to=${to}, text=${text}, mediaList=${JSON.stringify(mediaList)}`);
130
+ if (!text.trim() && mediaList.length === 0) {
131
+ return;
132
+ }
133
+
134
+ // --- Text handling (existing logic) ---
135
+ if (text.trim()) {
136
+ // Resolve @id patterns in LLM output text to user/agent IDs
137
+ const resolvedUserIds: string[] = [];
138
+ const resolvedAgentIds: number[] = [];
139
+ if (isGroup && mentionIdMap.size > 0) {
140
+ const mentionPattern = /@([\w.]+)/g;
141
+ let match: RegExpExecArray | null;
142
+ while ((match = mentionPattern.exec(text)) !== null) {
143
+ const id = match[1];
144
+ const type = mentionIdMap.get(id.toLowerCase());
145
+ if (type === "user" && !resolvedUserIds.includes(id)) {
146
+ resolvedUserIds.push(id);
147
+ } else if (type === "agent") {
148
+ const numId = Number(id);
149
+ if (Number.isFinite(numId) && !resolvedAgentIds.includes(numId)) {
150
+ resolvedAgentIds.push(numId);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // Merge atOptions user IDs (sender echo-back) with LLM-resolved user IDs
157
+ const atOptionIds = atOptions?.atAll ? [] : (atOptions?.atUserIds ?? []);
158
+ const allAtUserIds = [...atOptionIds];
159
+ for (const id of resolvedUserIds) {
160
+ if (!allAtUserIds.includes(id)) {
161
+ allAtUserIds.push(id);
162
+ }
163
+ }
164
+ const hasAtAll = atOptions?.atAll === true;
165
+ const hasAtUsers = allAtUserIds.length > 0;
166
+ const hasAtAgents = resolvedAgentIds.length > 0;
167
+
168
+ // Use the original text without prepending @ mentions
169
+ // AT nodes will be added to contents for notification, but text stays clean
170
+ let messageText = text;
171
+
172
+ // Chunk text to 4000 chars max (Infoflow limit)
173
+ const chunks = core.channel.text.chunkText(messageText, 4000);
174
+ // Only include @mentions in the first chunk (avoid duplicate @s)
175
+ let isFirstChunk = true;
176
+
177
+ for (const chunk of chunks) {
178
+ const contents: InfoflowMessageContentItem[] = [];
179
+
180
+ // Add AT content nodes for group messages (first chunk only)
181
+ if (isFirstChunk && isGroup) {
182
+ if (hasAtAll) {
183
+ contents.push({ type: "at", content: "all" });
184
+ } else if (hasAtUsers) {
185
+ contents.push({ type: "at", content: allAtUserIds.join(",") });
186
+ }
187
+ if (hasAtAgents) {
188
+ contents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
189
+ }
190
+ }
191
+ isFirstChunk = false;
192
+
193
+ // Add text content using configured format
194
+ contents.push({ type: messageFormat, content: chunk });
195
+
196
+ // Only include replyTo on the first outbound message
197
+ const chunkReplyTo = !replyApplied ? replyTo : undefined;
198
+
199
+ // Debug: Log when sending with replyTo
200
+ logVerbose(`[DEBUG deliver] chunkReplyTo: ${JSON.stringify(chunkReplyTo)}`);
201
+ logVerbose(`[DEBUG deliver] replyApplied=${replyApplied}, replyTo exists=${!!replyTo}`);
202
+
203
+ const result = await sendInfoflowMessage({
204
+ cfg,
205
+ to,
206
+ contents,
207
+ accountId,
208
+ replyTo: chunkReplyTo,
209
+ });
210
+ if (chunkReplyTo) replyApplied = true;
211
+
212
+ if (result.ok) {
213
+ statusSink?.({ lastOutboundAt: Date.now() });
214
+ } else if (result.error) {
215
+ getInfoflowSendLog().error(
216
+ `[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`,
217
+ );
218
+ }
219
+ }
220
+ }
221
+
222
+ // --- Media handling: send each media item as native image or fallback link ---
223
+ for (const mediaUrl of mediaList) {
224
+ const mediaReplyTo = !replyApplied ? replyTo : undefined;
225
+ try {
226
+ const paths = isLikelyLocalPath(mediaUrl)?[mediaUrl]:undefined;
227
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl , mediaLocalRoots: paths });
228
+ if (prepared.isImage) {
229
+ const result = await sendInfoflowImageMessage({
230
+ cfg,
231
+ to,
232
+ base64Image: prepared.base64,
233
+ accountId,
234
+ replyTo: mediaReplyTo,
235
+ });
236
+ if (result.ok) {
237
+ if (mediaReplyTo) replyApplied = true;
238
+ statusSink?.({ lastOutboundAt: Date.now() });
239
+ continue;
240
+ }
241
+ logVerbose(`[infoflow] native image send failed: ${result.error}, falling back to link`);
242
+ }
243
+ } catch (err) {
244
+ logVerbose(`[infoflow] image prep failed, falling back to link: ${err}`);
245
+ }
246
+ // Fallback: send as link
247
+ await sendInfoflowMessage({
248
+ cfg,
249
+ to,
250
+ contents: [{ type: "link", content: mediaUrl }],
251
+ accountId,
252
+ replyTo: mediaReplyTo,
253
+ });
254
+ if (mediaReplyTo) replyApplied = true;
255
+ }
256
+ };
257
+
258
+ const onError = (err: unknown) => {
259
+ getInfoflowSendLog().error(
260
+ `[infoflow] reply error to=${to}, accountId=${accountId}: ${formatInfoflowError(err)}`,
261
+ );
262
+ // Send error hint so users know the bot didn't silently fail
263
+ sendInfoflowMessage({
264
+ cfg,
265
+ to,
266
+ contents: [{ type: "text", content: "处理出错,请稍后重试" }],
267
+ accountId,
268
+ }).catch(() => {});
269
+ };
270
+
271
+ return {
272
+ dispatcherOptions: {
273
+ ...prefixOptions,
274
+ deliver,
275
+ onError,
276
+ },
277
+ replyOptions: {
278
+ onModelSelected,
279
+ },
280
+ };
281
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Infoflow target resolution utilities.
3
+ * Handles user and group ID formats for message targeting.
4
+ */
5
+
6
+ import { logVerbose } from "../../logging.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Target Format Constants
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Prefix for group targets: "group:123456" */
13
+ const GROUP_PREFIX = "group:";
14
+
15
+ /** Prefix for user targets (optional): "user:username" */
16
+ const USER_PREFIX = "user:";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Target Normalization
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Normalizes an Infoflow target string.
24
+ * Strips channel prefix and normalizes format.
25
+ *
26
+ * Examples:
27
+ * "infoflow:username" -> "username"
28
+ * "infoflow:group:123456" -> "group:123456"
29
+ * "user:username" -> "username"
30
+ * "group:123456" -> "group:123456"
31
+ * "username" -> "username"
32
+ * "123456" -> "group:123456" (pure digits treated as group)
33
+ */
34
+ export function normalizeInfoflowTarget(raw: string): string | undefined {
35
+ logVerbose(`[infoflow:normalizeTarget] input: "${raw}"`);
36
+
37
+ const trimmed = raw.trim();
38
+ if (!trimmed) {
39
+ logVerbose(`[infoflow:normalizeTarget] empty input, returning undefined`);
40
+ return undefined;
41
+ }
42
+
43
+ // Strip infoflow: prefix
44
+ let target = trimmed.replace(/^infoflow:/i, "");
45
+
46
+ // Strip user: prefix (normalize to plain username)
47
+ if (target.toLowerCase().startsWith(USER_PREFIX)) {
48
+ target = target.slice(USER_PREFIX.length);
49
+ }
50
+
51
+ // Keep group: prefix as-is
52
+ if (target.toLowerCase().startsWith(GROUP_PREFIX)) {
53
+ logVerbose(`[infoflow:normalizeTarget] output: "${target}" (group)`);
54
+ return target;
55
+ }
56
+
57
+ // Pure digits -> treat as group ID
58
+ if (/^\d+$/.test(target)) {
59
+ const result = `${GROUP_PREFIX}${target}`;
60
+ logVerbose(`[infoflow:normalizeTarget] output: "${result}" (digits -> group)`);
61
+ return result;
62
+ }
63
+
64
+ // Otherwise it's a username
65
+ logVerbose(`[infoflow:normalizeTarget] output: "${target}" (username)`);
66
+ return target;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Target ID Detection
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Checks if the input looks like a valid Infoflow target ID.
75
+ * Returns true if the system should use this value directly without directory lookup.
76
+ *
77
+ * Valid formats:
78
+ * - group:123456 (group ID with prefix)
79
+ * - user:username (user ID with prefix)
80
+ * - 123456789 (pure digits = group ID)
81
+ * - username (alphanumeric starting with letter = username/uuapName)
82
+ */
83
+ export function looksLikeInfoflowId(raw: string): boolean {
84
+ const trimmed = raw.trim();
85
+ if (!trimmed) {
86
+ return false;
87
+ }
88
+
89
+ // Strip infoflow: prefix for checking
90
+ const target = trimmed.replace(/^infoflow:/i, "");
91
+
92
+ // Explicit prefixes are always valid
93
+ if (/^(group|user):/i.test(target)) {
94
+ return true;
95
+ }
96
+
97
+ // Pure digits (group ID)
98
+ if (/^\d+$/.test(target)) {
99
+ return true;
100
+ }
101
+
102
+ // Alphanumeric starting with letter (username/uuapName)
103
+ // e.g., username, user123, testuser
104
+ if (/^[a-zA-Z][a-zA-Z0-9_]*$/.test(target)) {
105
+ return true;
106
+ }
107
+
108
+ return false;
109
+ }
@@ -0,0 +1,164 @@
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, InfoflowConnectionMode, 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
+ connectionMode?: InfoflowConnectionMode;
72
+ wsGateway?: string;
73
+ robotName?: string;
74
+ requireMention?: boolean;
75
+ watchMentions?: string[];
76
+ watchRegex?: string;
77
+ appAgentId?: number;
78
+ dmMessageFormat?: "text" | "markdown";
79
+ groupMessageFormat?: "text" | "markdown";
80
+ dmPolicy?: string;
81
+ allowFrom?: string[];
82
+ groupPolicy?: string;
83
+ groupAllowFrom?: string[];
84
+ } {
85
+ const raw = getChannelSection(cfg) ?? {};
86
+ const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
87
+ const account = raw.accounts?.[accountId] ?? {};
88
+ return { ...base, ...account } as {
89
+ apiHost: string;
90
+ checkToken: string;
91
+ encodingAESKey: string;
92
+ appKey: string;
93
+ appSecret: string;
94
+ enabled?: boolean;
95
+ name?: string;
96
+ connectionMode?: InfoflowConnectionMode;
97
+ wsGateway?: string;
98
+ robotName?: string;
99
+ requireMention?: boolean;
100
+ watchMentions?: string[];
101
+ watchRegex?: string;
102
+ appAgentId?: number;
103
+ dmMessageFormat?: "text" | "markdown";
104
+ groupMessageFormat?: "text" | "markdown";
105
+ dmPolicy?: string;
106
+ allowFrom?: string[];
107
+ groupPolicy?: string;
108
+ groupAllowFrom?: string[];
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Account Resolution
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Resolve a complete Infoflow account with merged config.
118
+ */
119
+ export function resolveInfoflowAccount(params: {
120
+ cfg: OpenClawConfig;
121
+ accountId?: string | null;
122
+ }): ResolvedInfoflowAccount {
123
+ const accountId = normalizeAccountId(params.accountId);
124
+ const baseEnabled = getChannelSection(params.cfg)?.enabled !== false;
125
+ const merged = mergeInfoflowAccountConfig(params.cfg, accountId);
126
+ const accountEnabled = merged.enabled !== false;
127
+ const enabled = baseEnabled && accountEnabled;
128
+ const apiHost = merged.apiHost ?? "";
129
+ const checkToken = merged.checkToken ?? "";
130
+ const encodingAESKey = merged.encodingAESKey ?? "";
131
+ const appKey = merged.appKey ?? "";
132
+ const appSecret = merged.appSecret ?? "";
133
+ const configured =
134
+ Boolean(checkToken) && Boolean(encodingAESKey) && Boolean(appKey) && Boolean(appSecret);
135
+
136
+ return {
137
+ accountId,
138
+ name: merged.name?.trim() || undefined,
139
+ enabled,
140
+ configured,
141
+ config: {
142
+ enabled: merged.enabled,
143
+ name: merged.name,
144
+ apiHost,
145
+ checkToken,
146
+ encodingAESKey,
147
+ appKey,
148
+ appSecret,
149
+ connectionMode: merged.connectionMode,
150
+ wsGateway: merged.wsGateway,
151
+ robotName: merged.robotName?.trim() || undefined,
152
+ requireMention: merged.requireMention,
153
+ watchMentions: merged.watchMentions,
154
+ watchRegex: merged.watchRegex,
155
+ appAgentId: merged.appAgentId,
156
+ dmMessageFormat: merged.dmMessageFormat,
157
+ groupMessageFormat: merged.groupMessageFormat,
158
+ dmPolicy: merged.dmPolicy,
159
+ allowFrom: merged.allowFrom,
160
+ groupPolicy: merged.groupPolicy,
161
+ groupAllowFrom: merged.groupAllowFrom,
162
+ },
163
+ };
164
+ }