@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,301 @@
1
+ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-reply-pipeline";
2
+ import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "./logging.js";
3
+ import { parseMarkdownForLocalImages } from "./markdown-local-images.js";
4
+ import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
5
+ import { getInfoflowRuntime } from "./runtime.js";
6
+ import { sendInfoflowMessage } from "./send.js";
7
+ const PREVIEW_MAX_LENGTH = 100;
8
+ function truncatePreview(text) {
9
+ if (!text)
10
+ return "";
11
+ if (text.length <= PREVIEW_MAX_LENGTH)
12
+ return text;
13
+ return text.slice(0, PREVIEW_MAX_LENGTH) + "...";
14
+ }
15
+ /**
16
+ * Builds dispatcherOptions and replyOptions for dispatchReplyWithBufferedBlockDispatcher.
17
+ * Encapsulates prefix options, chunked deliver (send via Infoflow API + statusSink), and onError.
18
+ */
19
+ export function createInfoflowReplyDispatcher(params) {
20
+ const { cfg, agentId, accountId, to, statusSink, atOptions, mentionIds, replyToMessageId, replyToPreview, mediaLocalRoots, } = params;
21
+ const core = getInfoflowRuntime();
22
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
23
+ cfg,
24
+ agentId,
25
+ channel: "infoflow",
26
+ accountId,
27
+ });
28
+ // Check if target is a group (format: group:<id>)
29
+ const isGroup = /^group:\d+$/i.test(to);
30
+ // Build id→type map for resolving @id in LLM output (distinguishes user vs agent)
31
+ const mentionIdMap = new Map();
32
+ if (mentionIds) {
33
+ for (const id of mentionIds.userIds) {
34
+ mentionIdMap.set(id.toLowerCase(), "user");
35
+ }
36
+ for (const id of mentionIds.agentIds) {
37
+ mentionIdMap.set(String(id).toLowerCase(), "agent");
38
+ }
39
+ }
40
+ // Build replyTo context (only used for the first outbound message)
41
+ const replyTo = isGroup && replyToMessageId
42
+ ? { messageid: replyToMessageId, preview: truncatePreview(replyToPreview) }
43
+ : undefined;
44
+ let replyApplied = false;
45
+ const deliver = async (payload) => {
46
+ const text = payload.text ?? "";
47
+ logVerbose(`[infoflow] deliver called: to=${to}, text=${text}`);
48
+ // Normalize media URL list (same pattern as Feishu reply-dispatcher)
49
+ const mediaList = payload.mediaUrls && payload.mediaUrls.length > 0
50
+ ? payload.mediaUrls
51
+ : payload.mediaUrl
52
+ ? [payload.mediaUrl]
53
+ : [];
54
+ if (!text.trim() && mediaList.length === 0) {
55
+ return;
56
+ }
57
+ // --- Text handling (existing logic) ---
58
+ if (text.trim()) {
59
+ // Resolve @id patterns in LLM output text to user/agent IDs
60
+ const resolvedUserIds = [];
61
+ const resolvedAgentIds = [];
62
+ if (isGroup && mentionIdMap.size > 0) {
63
+ const mentionPattern = /@([\w.]+)/g;
64
+ let match;
65
+ while ((match = mentionPattern.exec(text)) !== null) {
66
+ const id = match[1];
67
+ const type = mentionIdMap.get(id.toLowerCase());
68
+ if (type === "user" && !resolvedUserIds.includes(id)) {
69
+ resolvedUserIds.push(id);
70
+ }
71
+ else if (type === "agent") {
72
+ const numId = Number(id);
73
+ if (Number.isFinite(numId) && !resolvedAgentIds.includes(numId)) {
74
+ resolvedAgentIds.push(numId);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ // Merge atOptions user IDs (sender echo-back) with LLM-resolved user IDs
80
+ const atOptionIds = atOptions?.atAll ? [] : (atOptions?.atUserIds ?? []);
81
+ const allAtUserIds = [...atOptionIds];
82
+ for (const id of resolvedUserIds) {
83
+ if (!allAtUserIds.includes(id)) {
84
+ allAtUserIds.push(id);
85
+ }
86
+ }
87
+ const hasAtAll = atOptions?.atAll === true;
88
+ const hasAtUsers = allAtUserIds.length > 0;
89
+ const hasAtAgents = resolvedAgentIds.length > 0;
90
+ // Prepend AT mentions to the text if needed (group messages only)
91
+ // Only prepend for atOptions IDs; LLM text already contains @id for resolved mentions
92
+ let messageText = text;
93
+ if (isGroup && atOptions) {
94
+ let atPrefix = "";
95
+ if (hasAtAll) {
96
+ atPrefix = "@all ";
97
+ }
98
+ else if (atOptions.atUserIds?.length) {
99
+ atPrefix = atOptions.atUserIds.map((id) => `@${id}`).join(" ") + " ";
100
+ }
101
+ messageText = atPrefix + text;
102
+ }
103
+ // Chunk text to 2048 chars max (Infoflow limit)
104
+ const chunks = core.channel.text.chunkText(messageText, 2048);
105
+ let isFirstChunk = true;
106
+ const textPromises = [];
107
+ for (const chunk of chunks) {
108
+ const segments = parseMarkdownForLocalImages(chunk);
109
+ for (const segment of segments) {
110
+ const chunkReplyTo = !replyApplied ? replyTo : undefined;
111
+ if (segment.type === "text") {
112
+ const contents = [];
113
+ if (isFirstChunk && isGroup) {
114
+ if (hasAtAll) {
115
+ contents.push({ type: "at", content: "all" });
116
+ }
117
+ else if (hasAtUsers) {
118
+ contents.push({ type: "at", content: allAtUserIds.join(",") });
119
+ }
120
+ if (hasAtAgents) {
121
+ contents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
122
+ }
123
+ }
124
+ const trimmed = segment.content.trim();
125
+ if (contents.length > 0 || trimmed) {
126
+ contents.push({ type: "markdown", content: segment.content });
127
+ textPromises.push(sendInfoflowMessage({
128
+ cfg,
129
+ to,
130
+ contents,
131
+ accountId,
132
+ replyTo: chunkReplyTo,
133
+ }));
134
+ if (chunkReplyTo)
135
+ replyApplied = true;
136
+ }
137
+ isFirstChunk = false;
138
+ continue;
139
+ }
140
+ // segment.type === "image"
141
+ if (isFirstChunk && isGroup && (hasAtAll || hasAtUsers || hasAtAgents)) {
142
+ const atContents = [];
143
+ if (hasAtAll)
144
+ atContents.push({ type: "at", content: "all" });
145
+ else if (hasAtUsers)
146
+ atContents.push({ type: "at", content: allAtUserIds.join(",") });
147
+ if (hasAtAgents)
148
+ atContents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
149
+ atContents.push({ type: "markdown", content: "" });
150
+ textPromises.push(sendInfoflowMessage({
151
+ cfg,
152
+ to,
153
+ contents: atContents,
154
+ accountId,
155
+ replyTo: chunkReplyTo,
156
+ }));
157
+ if (chunkReplyTo)
158
+ replyApplied = true;
159
+ }
160
+ isFirstChunk = false;
161
+ try {
162
+ const prepared = await prepareInfoflowImageBase64({
163
+ mediaUrl: segment.content,
164
+ mediaLocalRoots: mediaLocalRoots ?? undefined,
165
+ });
166
+ if (prepared.isImage) {
167
+ const segmentReplyTo = !replyApplied ? replyTo : undefined;
168
+ textPromises.push(sendInfoflowImageMessage({
169
+ cfg,
170
+ to,
171
+ base64Image: prepared.base64,
172
+ accountId,
173
+ replyTo: segmentReplyTo,
174
+ }).then((r) => {
175
+ if (r.ok)
176
+ return r;
177
+ logVerbose(`[infoflow] native image send failed: ${r.error}, falling back to link`);
178
+ return sendInfoflowMessage({
179
+ cfg,
180
+ to,
181
+ contents: [{ type: "link", content: segment.content }],
182
+ accountId,
183
+ replyTo: segmentReplyTo,
184
+ });
185
+ }));
186
+ if (!replyApplied)
187
+ replyApplied = true;
188
+ }
189
+ else {
190
+ textPromises.push(sendInfoflowMessage({
191
+ cfg,
192
+ to,
193
+ contents: [{ type: "link", content: segment.content }],
194
+ accountId,
195
+ replyTo: !replyApplied ? replyTo : undefined,
196
+ }));
197
+ if (!replyApplied)
198
+ replyApplied = true;
199
+ }
200
+ }
201
+ catch (err) {
202
+ logVerbose(`[infoflow] image prep failed in text segment, falling back to link: ${err}`);
203
+ textPromises.push(sendInfoflowMessage({
204
+ cfg,
205
+ to,
206
+ contents: [{ type: "link", content: segment.content }],
207
+ accountId,
208
+ replyTo: !replyApplied ? replyTo : undefined,
209
+ }));
210
+ if (!replyApplied)
211
+ replyApplied = true;
212
+ }
213
+ }
214
+ }
215
+ if (textPromises.length > 0) {
216
+ const results = await Promise.all(textPromises);
217
+ for (const result of results) {
218
+ if (result?.ok) {
219
+ statusSink?.({ lastOutboundAt: Date.now() });
220
+ }
221
+ else if (result?.error) {
222
+ getInfoflowSendLog().error(`[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`);
223
+ }
224
+ }
225
+ }
226
+ }
227
+ // --- Media handling: send each media item as native image or fallback link (b-mode: collect then await) ---
228
+ const mediaPromises = [];
229
+ for (const mediaUrl of mediaList) {
230
+ const mediaReplyTo = !replyApplied ? replyTo : undefined;
231
+ try {
232
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl });
233
+ if (prepared.isImage) {
234
+ mediaPromises.push(sendInfoflowImageMessage({
235
+ cfg,
236
+ to,
237
+ base64Image: prepared.base64,
238
+ accountId,
239
+ replyTo: mediaReplyTo,
240
+ }).then((r) => {
241
+ if (r.ok)
242
+ return r;
243
+ logVerbose(`[infoflow] native image send failed: ${r.error}, falling back to link`);
244
+ return sendInfoflowMessage({
245
+ cfg,
246
+ to,
247
+ contents: [{ type: "link", content: mediaUrl }],
248
+ accountId,
249
+ replyTo: mediaReplyTo,
250
+ });
251
+ }));
252
+ if (mediaReplyTo)
253
+ replyApplied = true;
254
+ }
255
+ else {
256
+ mediaPromises.push(sendInfoflowMessage({
257
+ cfg,
258
+ to,
259
+ contents: [{ type: "link", content: mediaUrl }],
260
+ accountId,
261
+ replyTo: mediaReplyTo,
262
+ }));
263
+ if (mediaReplyTo)
264
+ replyApplied = true;
265
+ }
266
+ }
267
+ catch (err) {
268
+ logVerbose(`[infoflow] image prep failed, falling back to link: ${err}`);
269
+ mediaPromises.push(sendInfoflowMessage({
270
+ cfg,
271
+ to,
272
+ contents: [{ type: "link", content: mediaUrl }],
273
+ accountId,
274
+ replyTo: mediaReplyTo,
275
+ }));
276
+ if (mediaReplyTo)
277
+ replyApplied = true;
278
+ }
279
+ }
280
+ if (mediaPromises.length > 0) {
281
+ const results = await Promise.all(mediaPromises);
282
+ for (const result of results) {
283
+ if (result?.ok)
284
+ statusSink?.({ lastOutboundAt: Date.now() });
285
+ }
286
+ }
287
+ };
288
+ const onError = (err) => {
289
+ getInfoflowSendLog().error(`[infoflow] reply error to=${to}, accountId=${accountId}: ${formatInfoflowError(err)}`);
290
+ };
291
+ return {
292
+ dispatcherOptions: {
293
+ ...prefixOptions,
294
+ deliver,
295
+ onError,
296
+ },
297
+ replyOptions: {
298
+ onModelSelected,
299
+ },
300
+ };
301
+ }
@@ -0,0 +1,10 @@
1
+ let runtime = null;
2
+ export function setInfoflowRuntime(next) {
3
+ runtime = next;
4
+ }
5
+ export function getInfoflowRuntime() {
6
+ if (!runtime) {
7
+ throw new Error("Infoflow runtime not initialized");
8
+ }
9
+ return runtime;
10
+ }