@chbo297/infoflow 2026.3.2 → 2026.3.6

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/src/media.ts ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Infoflow native image sending: compress, base64-encode, and POST via Infoflow API.
3
+ */
4
+
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
6
+ import { resolveInfoflowAccount } from "./accounts.js";
7
+ import { recordSentMessageId } from "./infoflow-req-parse.js";
8
+ import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "./logging.js";
9
+ import { getInfoflowRuntime } from "./runtime.js";
10
+ import {
11
+ getAppAccessToken,
12
+ ensureHttps,
13
+ extractIdFromRawJson,
14
+ DEFAULT_TIMEOUT_MS,
15
+ INFOFLOW_PRIVATE_SEND_PATH,
16
+ INFOFLOW_GROUP_SEND_PATH,
17
+ } from "./send.js";
18
+ import { recordSentMessage } from "./sent-message-store.js";
19
+ import type { ResolvedInfoflowAccount, InfoflowOutboundReply } from "./types.js";
20
+
21
+ /** Infoflow API image size limit: 1MB raw bytes */
22
+ const INFOFLOW_IMAGE_MAX_BYTES = 1 * 1024 * 1024;
23
+
24
+ // Compression grid: progressively smaller maxSide and quality
25
+ const COMPRESS_SIDES = [2048, 1536, 1280, 1024, 800];
26
+ const COMPRESS_QUALITIES = [80, 70, 60, 50, 40];
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Image compression
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Compresses an image buffer to fit within the Infoflow 1MB limit.
34
+ * Returns null if compression fails (e.g. GIF > 1MB, or all combos exceed limit).
35
+ */
36
+ export async function compressImageForInfoflow(params: {
37
+ buffer: Buffer;
38
+ contentType?: string;
39
+ }): Promise<Buffer | null> {
40
+ const { buffer, contentType } = params;
41
+
42
+ // Already within limit
43
+ if (buffer.length <= INFOFLOW_IMAGE_MAX_BYTES) {
44
+ return buffer;
45
+ }
46
+
47
+ // GIF cannot be compressed without losing animation
48
+ if (contentType === "image/gif") {
49
+ logVerbose(`[infoflow:media] GIF exceeds 1MB (${buffer.length} bytes), cannot compress`);
50
+ return null;
51
+ }
52
+
53
+ const runtime = getInfoflowRuntime();
54
+ let smallest: { buffer: Buffer; size: number } | null = null;
55
+
56
+ for (const side of COMPRESS_SIDES) {
57
+ for (const quality of COMPRESS_QUALITIES) {
58
+ try {
59
+ const out = await runtime.media.resizeToJpeg({
60
+ buffer,
61
+ maxSide: side,
62
+ quality,
63
+ withoutEnlargement: true,
64
+ });
65
+ const size = out.length;
66
+ if (size <= INFOFLOW_IMAGE_MAX_BYTES) {
67
+ logVerbose(
68
+ `[infoflow:media] compressed ${buffer.length} → ${size} bytes (side≤${side}, q=${quality})`,
69
+ );
70
+ return out;
71
+ }
72
+ if (!smallest || size < smallest.size) {
73
+ smallest = { buffer: out, size };
74
+ }
75
+ } catch {
76
+ // skip failed combo
77
+ }
78
+ }
79
+ }
80
+
81
+ logVerbose(
82
+ `[infoflow:media] all compression combos exceed 1MB (smallest: ${smallest?.size ?? "N/A"} bytes)`,
83
+ );
84
+ return null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Prepare image as base64
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export type PrepareImageResult = { isImage: true; base64: string } | { isImage: false };
92
+
93
+ /**
94
+ * Downloads media, checks if it's an image, compresses to 1MB, and base64-encodes.
95
+ */
96
+ export async function prepareInfoflowImageBase64(params: {
97
+ mediaUrl: string;
98
+ mediaLocalRoots?: readonly string[];
99
+ }): Promise<PrepareImageResult> {
100
+ const { mediaUrl, mediaLocalRoots } = params;
101
+ const runtime = getInfoflowRuntime();
102
+
103
+ // Download media
104
+ const loaded = await runtime.media.loadWebMedia(mediaUrl, {
105
+ maxBytes: 30 * 1024 * 1024, // 30MB download limit
106
+ optimizeImages: false,
107
+ localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
108
+ });
109
+
110
+ // Check if it's an image
111
+ const kind = runtime.media.mediaKindFromMime(loaded.contentType ?? undefined);
112
+ if (kind !== "image") {
113
+ return { isImage: false };
114
+ }
115
+
116
+ // Compress if needed
117
+ const compressed = await compressImageForInfoflow({
118
+ buffer: loaded.buffer,
119
+ contentType: loaded.contentType ?? undefined,
120
+ });
121
+
122
+ if (!compressed) {
123
+ return { isImage: false }; // compression failed, fall back to link
124
+ }
125
+
126
+ return { isImage: true, base64: compressed.toString("base64") };
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Send image messages
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Sends a native image message to a group chat.
135
+ */
136
+ export async function sendInfoflowGroupImage(params: {
137
+ account: ResolvedInfoflowAccount;
138
+ groupId: number;
139
+ base64Image: string;
140
+ replyTo?: InfoflowOutboundReply;
141
+ timeoutMs?: number;
142
+ }): Promise<{ ok: boolean; error?: string; messageid?: string }> {
143
+ const { account, groupId, base64Image, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
144
+ const { apiHost, appKey, appSecret } = account.config;
145
+
146
+ if (!appKey || !appSecret) {
147
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
148
+ }
149
+
150
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
151
+ if (!tokenResult.ok || !tokenResult.token) {
152
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] token error: ${tokenResult.error}`);
153
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
154
+ }
155
+
156
+ let timeout: ReturnType<typeof setTimeout> | undefined;
157
+ try {
158
+ const controller = new AbortController();
159
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
160
+
161
+ const payload = {
162
+ message: {
163
+ header: {
164
+ toid: groupId,
165
+ totype: "GROUP",
166
+ msgtype: "IMAGE",
167
+ clientmsgid: Date.now(),
168
+ role: "robot",
169
+ },
170
+ body: [{ type: "IMAGE", content: base64Image }],
171
+ ...(params.replyTo
172
+ ? {
173
+ reply: {
174
+ messageid: params.replyTo.messageid,
175
+ preview: params.replyTo.preview ?? "",
176
+ replytype: params.replyTo.replytype ?? "1",
177
+ },
178
+ }
179
+ : {}),
180
+ },
181
+ };
182
+
183
+ const headers = {
184
+ Authorization: `Bearer-${tokenResult.token}`,
185
+ "Content-Type": "application/json",
186
+ };
187
+
188
+ logVerbose(
189
+ `[infoflow:sendGroupImage] POST to group ${groupId}, image size: ${base64Image.length} chars`,
190
+ );
191
+
192
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
193
+ method: "POST",
194
+ headers,
195
+ body: JSON.stringify(payload),
196
+ signal: controller.signal,
197
+ });
198
+
199
+ const responseText = await res.text();
200
+ const data = JSON.parse(responseText) as Record<string, unknown>;
201
+ logVerbose(`[infoflow:sendGroupImage] response: status=${res.status}, data=${responseText}`);
202
+
203
+ const code = typeof data.code === "string" ? data.code : "";
204
+ if (code !== "ok") {
205
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
206
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] failed: ${errMsg}`);
207
+ return { ok: false, error: errMsg };
208
+ }
209
+
210
+ const innerData = data.data as Record<string, unknown> | undefined;
211
+ const errcode = innerData?.errcode;
212
+ if (errcode != null && errcode !== 0) {
213
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
214
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] failed: ${errMsg}`);
215
+ return { ok: false, error: errMsg };
216
+ }
217
+
218
+ // Extract IDs from raw text to preserve large integer precision
219
+ const messageid =
220
+ extractIdFromRawJson(responseText, "messageid") ??
221
+ extractIdFromRawJson(responseText, "msgid");
222
+ const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
223
+ if (messageid) {
224
+ recordSentMessageId(messageid);
225
+ try {
226
+ recordSentMessage(account.accountId, {
227
+ target: `group:${groupId}`,
228
+ messageid,
229
+ msgseqid: msgseqid ?? "",
230
+ digest: "image",
231
+ sentAt: Date.now(),
232
+ });
233
+ } catch {
234
+ // Do not block sending
235
+ }
236
+ }
237
+
238
+ return { ok: true, messageid };
239
+ } catch (err) {
240
+ const errMsg = formatInfoflowError(err);
241
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] exception: ${errMsg}`);
242
+ return { ok: false, error: errMsg };
243
+ } finally {
244
+ clearTimeout(timeout);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Sends a native image message to a private (DM) chat.
250
+ */
251
+ export async function sendInfoflowPrivateImage(params: {
252
+ account: ResolvedInfoflowAccount;
253
+ toUser: string;
254
+ base64Image: string;
255
+ timeoutMs?: number;
256
+ }): Promise<{ ok: boolean; error?: string; msgkey?: string }> {
257
+ const { account, toUser, base64Image, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
258
+ const { apiHost, appKey, appSecret } = account.config;
259
+
260
+ if (!appKey || !appSecret) {
261
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
262
+ }
263
+
264
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
265
+ if (!tokenResult.ok || !tokenResult.token) {
266
+ getInfoflowSendLog().error(`[infoflow:sendPrivateImage] token error: ${tokenResult.error}`);
267
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
268
+ }
269
+
270
+ let timeout: ReturnType<typeof setTimeout> | undefined;
271
+ try {
272
+ const controller = new AbortController();
273
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
274
+
275
+ const payload = {
276
+ touser: toUser,
277
+ msgtype: "image",
278
+ image: { content: base64Image },
279
+ };
280
+
281
+ const headers = {
282
+ Authorization: `Bearer-${tokenResult.token}`,
283
+ "Content-Type": "application/json",
284
+ };
285
+
286
+ logVerbose(
287
+ `[infoflow:sendPrivateImage] POST to user ${toUser}, image size: ${base64Image.length} chars`,
288
+ );
289
+
290
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
291
+ method: "POST",
292
+ headers,
293
+ body: JSON.stringify(payload),
294
+ signal: controller.signal,
295
+ });
296
+
297
+ const responseText = await res.text();
298
+ const data = JSON.parse(responseText) as Record<string, unknown>;
299
+ logVerbose(`[infoflow:sendPrivateImage] response: status=${res.status}, data=${responseText}`);
300
+
301
+ if (data.errcode && data.errcode !== 0) {
302
+ const errMsg = String(data.errmsg ?? `errcode ${data.errcode}`);
303
+ getInfoflowSendLog().error(`[infoflow:sendPrivateImage] failed: ${errMsg}`);
304
+ return { ok: false, error: errMsg };
305
+ }
306
+
307
+ // Extract msgkey from raw text to preserve large integer precision
308
+ const msgkey =
309
+ extractIdFromRawJson(responseText, "msgkey") ??
310
+ (data.msgkey != null ? String(data.msgkey) : undefined);
311
+ if (msgkey) {
312
+ recordSentMessageId(msgkey);
313
+ try {
314
+ recordSentMessage(account.accountId, {
315
+ target: toUser,
316
+ messageid: msgkey,
317
+ msgseqid: "",
318
+ digest: "image",
319
+ sentAt: Date.now(),
320
+ });
321
+ } catch {
322
+ // Do not block sending
323
+ }
324
+ }
325
+
326
+ return { ok: true, msgkey };
327
+ } catch (err) {
328
+ const errMsg = formatInfoflowError(err);
329
+ getInfoflowSendLog().error(`[infoflow:sendPrivateImage] exception: ${errMsg}`);
330
+ return { ok: false, error: errMsg };
331
+ } finally {
332
+ clearTimeout(timeout);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Unified image message sender. Parses target and dispatches to group or private.
338
+ */
339
+ export async function sendInfoflowImageMessage(params: {
340
+ cfg: OpenClawConfig;
341
+ to: string;
342
+ base64Image: string;
343
+ accountId?: string;
344
+ replyTo?: InfoflowOutboundReply;
345
+ }): Promise<{ ok: boolean; error?: string; messageId?: string }> {
346
+ const { cfg, to, base64Image, accountId } = params;
347
+ const account = resolveInfoflowAccount({ cfg, accountId });
348
+
349
+ // Parse target: remove "infoflow:" prefix if present
350
+ const target = to.replace(/^infoflow:/i, "");
351
+
352
+ const groupMatch = target.match(/^group:(\d+)/i);
353
+ if (groupMatch) {
354
+ const groupId = Number(groupMatch[1]);
355
+ const result = await sendInfoflowGroupImage({
356
+ account,
357
+ groupId,
358
+ base64Image,
359
+ replyTo: params.replyTo,
360
+ });
361
+ return { ok: result.ok, error: result.error, messageId: result.messageid };
362
+ }
363
+
364
+ // Private message (replyTo not supported)
365
+ const result = await sendInfoflowPrivateImage({ account, toUser: target, base64Image });
366
+ return { ok: result.ok, error: result.error, messageId: result.msgkey };
367
+ }
package/src/monitor.ts CHANGED
@@ -65,7 +65,7 @@ function registerInfoflowWebhookTarget(target: WebhookTarget): () => void {
65
65
  }
66
66
 
67
67
  // ---------------------------------------------------------------------------
68
- // HTTP handler (registered via api.registerHttpHandler)
68
+ // HTTP handler (registered via api.registerHttpRoute)
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
71
  /**
@@ -4,9 +4,23 @@ import {
4
4
  type ReplyPayload,
5
5
  } from "openclaw/plugin-sdk";
6
6
  import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "./logging.js";
7
+ import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
7
8
  import { getInfoflowRuntime } from "./runtime.js";
8
9
  import { sendInfoflowMessage } from "./send.js";
9
- import type { InfoflowAtOptions, InfoflowMentionIds, InfoflowMessageContentItem } from "./types.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
+ }
10
24
 
11
25
  export type CreateInfoflowReplyDispatcherParams = {
12
26
  cfg: OpenClawConfig;
@@ -19,6 +33,10 @@ export type CreateInfoflowReplyDispatcherParams = {
19
33
  atOptions?: InfoflowAtOptions;
20
34
  /** Mention IDs from inbound message for resolving @id in LLM output */
21
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;
22
40
  };
23
41
 
24
42
  /**
@@ -26,7 +44,17 @@ export type CreateInfoflowReplyDispatcherParams = {
26
44
  * Encapsulates prefix options, chunked deliver (send via Infoflow API + statusSink), and onError.
27
45
  */
28
46
  export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatcherParams) {
29
- const { cfg, agentId, accountId, to, statusSink, atOptions, mentionIds } = params;
47
+ const {
48
+ cfg,
49
+ agentId,
50
+ accountId,
51
+ to,
52
+ statusSink,
53
+ atOptions,
54
+ mentionIds,
55
+ replyToMessageId,
56
+ replyToPreview,
57
+ } = params;
30
58
  const core = getInfoflowRuntime();
31
59
 
32
60
  const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
@@ -50,91 +78,153 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
50
78
  }
51
79
  }
52
80
 
81
+ // Build replyTo context (only used for the first outbound message)
82
+ const replyTo: InfoflowOutboundReply | undefined =
83
+ isGroup && replyToMessageId
84
+ ? { messageid: replyToMessageId, preview: truncatePreview(replyToPreview) }
85
+ : undefined;
86
+ let replyApplied = false;
87
+
53
88
  const deliver = async (payload: ReplyPayload) => {
54
89
  const text = payload.text ?? "";
55
90
  logVerbose(`[infoflow] deliver called: to=${to}, text=${text}`);
56
- if (!text.trim()) {
91
+
92
+ // Normalize media URL list (same pattern as Feishu reply-dispatcher)
93
+ const mediaList =
94
+ payload.mediaUrls && payload.mediaUrls.length > 0
95
+ ? payload.mediaUrls
96
+ : payload.mediaUrl
97
+ ? [payload.mediaUrl]
98
+ : [];
99
+
100
+ if (!text.trim() && mediaList.length === 0) {
57
101
  return;
58
102
  }
59
103
 
60
- // Resolve @id patterns in LLM output text to user/agent IDs
61
- const resolvedUserIds: string[] = [];
62
- const resolvedAgentIds: number[] = [];
63
- if (isGroup && mentionIdMap.size > 0) {
64
- const mentionPattern = /@([\w.]+)/g;
65
- let match: RegExpExecArray | null;
66
- while ((match = mentionPattern.exec(text)) !== null) {
67
- const id = match[1];
68
- const type = mentionIdMap.get(id.toLowerCase());
69
- if (type === "user" && !resolvedUserIds.includes(id)) {
70
- resolvedUserIds.push(id);
71
- } else if (type === "agent") {
72
- const numId = Number(id);
73
- if (Number.isFinite(numId) && !resolvedAgentIds.includes(numId)) {
74
- resolvedAgentIds.push(numId);
104
+ // --- Text handling (existing logic) ---
105
+ if (text.trim()) {
106
+ // Resolve @id patterns in LLM output text to user/agent IDs
107
+ const resolvedUserIds: string[] = [];
108
+ const resolvedAgentIds: number[] = [];
109
+ if (isGroup && mentionIdMap.size > 0) {
110
+ const mentionPattern = /@([\w.]+)/g;
111
+ let match: RegExpExecArray | null;
112
+ while ((match = mentionPattern.exec(text)) !== null) {
113
+ const id = match[1];
114
+ const type = mentionIdMap.get(id.toLowerCase());
115
+ if (type === "user" && !resolvedUserIds.includes(id)) {
116
+ resolvedUserIds.push(id);
117
+ } else if (type === "agent") {
118
+ const numId = Number(id);
119
+ if (Number.isFinite(numId) && !resolvedAgentIds.includes(numId)) {
120
+ resolvedAgentIds.push(numId);
121
+ }
75
122
  }
76
123
  }
77
124
  }
78
- }
79
125
 
80
- // Merge atOptions user IDs (sender echo-back) with LLM-resolved user IDs
81
- const atOptionIds = atOptions?.atAll ? [] : (atOptions?.atUserIds ?? []);
82
- const allAtUserIds = [...atOptionIds];
83
- for (const id of resolvedUserIds) {
84
- if (!allAtUserIds.includes(id)) {
85
- allAtUserIds.push(id);
126
+ // Merge atOptions user IDs (sender echo-back) with LLM-resolved user IDs
127
+ const atOptionIds = atOptions?.atAll ? [] : (atOptions?.atUserIds ?? []);
128
+ const allAtUserIds = [...atOptionIds];
129
+ for (const id of resolvedUserIds) {
130
+ if (!allAtUserIds.includes(id)) {
131
+ allAtUserIds.push(id);
132
+ }
86
133
  }
87
- }
88
- const hasAtAll = atOptions?.atAll === true;
89
- const hasAtUsers = allAtUserIds.length > 0;
90
- const hasAtAgents = resolvedAgentIds.length > 0;
91
-
92
- // Prepend AT mentions to the text if needed (group messages only)
93
- // Only prepend for atOptions IDs; LLM text already contains @id for resolved mentions
94
- let messageText = text;
95
- if (isGroup && atOptions) {
96
- let atPrefix = "";
97
- if (hasAtAll) {
98
- atPrefix = "@all ";
99
- } else if (atOptions.atUserIds?.length) {
100
- atPrefix = atOptions.atUserIds.map((id) => `@${id}`).join(" ") + " ";
134
+ const hasAtAll = atOptions?.atAll === true;
135
+ const hasAtUsers = allAtUserIds.length > 0;
136
+ const hasAtAgents = resolvedAgentIds.length > 0;
137
+
138
+ // Prepend AT mentions to the text if needed (group messages only)
139
+ // Only prepend for atOptions IDs; LLM text already contains @id for resolved mentions
140
+ let messageText = text;
141
+ if (isGroup && atOptions) {
142
+ let atPrefix = "";
143
+ if (hasAtAll) {
144
+ atPrefix = "@all ";
145
+ } else if (atOptions.atUserIds?.length) {
146
+ atPrefix = atOptions.atUserIds.map((id) => `@${id}`).join(" ") + " ";
147
+ }
148
+ messageText = atPrefix + text;
101
149
  }
102
- messageText = atPrefix + text;
103
- }
104
150
 
105
- // Chunk text to 4000 chars max (Infoflow limit)
106
- const chunks = core.channel.text.chunkText(messageText, 4000);
107
- // Only include @mentions in the first chunk (avoid duplicate @s)
108
- let isFirstChunk = true;
151
+ // Chunk text to 4000 chars max (Infoflow limit)
152
+ const chunks = core.channel.text.chunkText(messageText, 4000);
153
+ // Only include @mentions in the first chunk (avoid duplicate @s)
154
+ let isFirstChunk = true;
109
155
 
110
- for (const chunk of chunks) {
111
- const contents: InfoflowMessageContentItem[] = [];
156
+ for (const chunk of chunks) {
157
+ const contents: InfoflowMessageContentItem[] = [];
112
158
 
113
- // Add AT content nodes for group messages (first chunk only)
114
- if (isFirstChunk && isGroup) {
115
- if (hasAtAll) {
116
- contents.push({ type: "at", content: "all" });
117
- } else if (hasAtUsers) {
118
- contents.push({ type: "at", content: allAtUserIds.join(",") });
159
+ // Add AT content nodes for group messages (first chunk only)
160
+ if (isFirstChunk && isGroup) {
161
+ if (hasAtAll) {
162
+ contents.push({ type: "at", content: "all" });
163
+ } else if (hasAtUsers) {
164
+ contents.push({ type: "at", content: allAtUserIds.join(",") });
165
+ }
166
+ if (hasAtAgents) {
167
+ contents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
168
+ }
119
169
  }
120
- if (hasAtAgents) {
121
- contents.push({ type: "at-agent", content: resolvedAgentIds.join(",") });
170
+ isFirstChunk = false;
171
+
172
+ // Add markdown content
173
+ contents.push({ type: "markdown", content: chunk });
174
+
175
+ // Only include replyTo on the first outbound message
176
+ const chunkReplyTo = !replyApplied ? replyTo : undefined;
177
+ const result = await sendInfoflowMessage({
178
+ cfg,
179
+ to,
180
+ contents,
181
+ accountId,
182
+ replyTo: chunkReplyTo,
183
+ });
184
+ if (chunkReplyTo) replyApplied = true;
185
+
186
+ if (result.ok) {
187
+ statusSink?.({ lastOutboundAt: Date.now() });
188
+ } else if (result.error) {
189
+ getInfoflowSendLog().error(
190
+ `[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`,
191
+ );
122
192
  }
123
193
  }
124
- isFirstChunk = false;
125
-
126
- // Add markdown content
127
- contents.push({ type: "markdown", content: chunk });
128
-
129
- const result = await sendInfoflowMessage({ cfg, to, contents, accountId });
194
+ }
130
195
 
131
- if (result.ok) {
132
- statusSink?.({ lastOutboundAt: Date.now() });
133
- } else if (result.error) {
134
- getInfoflowSendLog().error(
135
- `[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`,
136
- );
196
+ // --- Media handling: send each media item as native image or fallback link ---
197
+ for (const mediaUrl of mediaList) {
198
+ const mediaReplyTo = !replyApplied ? replyTo : undefined;
199
+ try {
200
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl });
201
+ if (prepared.isImage) {
202
+ const result = await sendInfoflowImageMessage({
203
+ cfg,
204
+ to,
205
+ base64Image: prepared.base64,
206
+ accountId,
207
+ replyTo: mediaReplyTo,
208
+ });
209
+ if (result.ok) {
210
+ if (mediaReplyTo) replyApplied = true;
211
+ statusSink?.({ lastOutboundAt: Date.now() });
212
+ continue;
213
+ }
214
+ logVerbose(`[infoflow] native image send failed: ${result.error}, falling back to link`);
215
+ }
216
+ } catch (err) {
217
+ logVerbose(`[infoflow] image prep failed, falling back to link: ${err}`);
137
218
  }
219
+ // Fallback: send as link
220
+ await sendInfoflowMessage({
221
+ cfg,
222
+ to,
223
+ contents: [{ type: "link", content: mediaUrl }],
224
+ accountId,
225
+ replyTo: mediaReplyTo,
226
+ });
227
+ if (mediaReplyTo) replyApplied = true;
138
228
  }
139
229
  };
140
230