@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/README.md +426 -15
- package/index.ts +6 -1
- package/openclaw.plugin.json +14 -2
- package/package.json +2 -2
- package/src/accounts.ts +3 -0
- package/src/actions.ts +350 -4
- package/src/bot.ts +357 -14
- package/src/channel.ts +64 -23
- package/src/infoflow-req-parse.ts +0 -1
- package/src/media.ts +367 -0
- package/src/monitor.ts +1 -1
- package/src/reply-dispatcher.ts +157 -67
- package/src/send.ts +497 -57
- package/src/sent-message-store.ts +238 -0
- package/src/types.ts +27 -2
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.
|
|
68
|
+
// HTTP handler (registered via api.registerHttpRoute)
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
70
|
|
|
71
71
|
/**
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
156
|
+
for (const chunk of chunks) {
|
|
157
|
+
const contents: InfoflowMessageContentItem[] = [];
|
|
112
158
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|