@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,365 @@
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 "../adapter/inbound/webhook-parser.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 "./outbound.js";
18
+ import { coreEvents } from "../events.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
+ logVerbose(`[infoflow:media] not image, contentType=${loaded.contentType}, kind=${kind}`);
114
+ return { isImage: false };
115
+ }
116
+
117
+ // Compress if needed
118
+ const compressed = await compressImageForInfoflow({
119
+ buffer: loaded.buffer,
120
+ contentType: loaded.contentType ?? undefined,
121
+ });
122
+
123
+ if (!compressed) {
124
+ logVerbose("[infoflow:media] compression failed");
125
+ return { isImage: false }; // compression failed, fall back to link
126
+ }
127
+
128
+ return { isImage: true, base64: compressed.toString("base64") };
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Send image messages
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Sends a native image message to a group chat.
137
+ */
138
+ export async function sendInfoflowGroupImage(params: {
139
+ account: ResolvedInfoflowAccount;
140
+ groupId: number;
141
+ base64Image: string;
142
+ replyTo?: InfoflowOutboundReply;
143
+ timeoutMs?: number;
144
+ }): Promise<{ ok: boolean; error?: string; messageid?: string }> {
145
+ const { account, groupId, base64Image, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
146
+ const { apiHost, appKey, appSecret } = account.config;
147
+
148
+ if (!appKey || !appSecret) {
149
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
150
+ }
151
+
152
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
153
+ if (!tokenResult.ok || !tokenResult.token) {
154
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] token error: ${tokenResult.error}`);
155
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
156
+ }
157
+
158
+ let timeout: ReturnType<typeof setTimeout> | undefined;
159
+ try {
160
+ const controller = new AbortController();
161
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
162
+
163
+ const payload = {
164
+ message: {
165
+ header: {
166
+ toid: groupId,
167
+ totype: "GROUP",
168
+ msgtype: "IMAGE",
169
+ clientmsgid: Date.now(),
170
+ role: "robot",
171
+ },
172
+ body: [{ type: "IMAGE", content: base64Image }],
173
+ ...(params.replyTo
174
+ ? {
175
+ reply: {
176
+ messageid: params.replyTo.messageid,
177
+ preview: params.replyTo.preview ?? "",
178
+ replytype: params.replyTo.replytype ?? "1",
179
+ },
180
+ }
181
+ : {}),
182
+ },
183
+ };
184
+
185
+ const headers = {
186
+ Authorization: `Bearer-${tokenResult.token}`,
187
+ "Content-Type": "application/json",
188
+ };
189
+
190
+ logVerbose(
191
+ `[infoflow:sendGroupImage] POST to group ${groupId}, image size: ${base64Image.length} chars`,
192
+ );
193
+
194
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
195
+ method: "POST",
196
+ headers,
197
+ body: JSON.stringify(payload),
198
+ signal: controller.signal,
199
+ });
200
+
201
+ const responseText = await res.text();
202
+ const data = JSON.parse(responseText) as Record<string, unknown>;
203
+ logVerbose(`[infoflow:sendGroupImage] response: status=${res.status}, data=${responseText}`);
204
+
205
+ const code = typeof data.code === "string" ? data.code : "";
206
+ if (code !== "ok") {
207
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
208
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] failed: ${errMsg}`);
209
+ return { ok: false, error: errMsg };
210
+ }
211
+
212
+ const innerData = data.data as Record<string, unknown> | undefined;
213
+ const errcode = innerData?.errcode;
214
+ if (errcode != null && errcode !== 0) {
215
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
216
+ getInfoflowSendLog().error(`[infoflow:sendGroupImage] failed: ${errMsg}`);
217
+ return { ok: false, error: errMsg };
218
+ }
219
+
220
+ // Extract IDs from raw text to preserve large integer precision
221
+ const messageid =
222
+ extractIdFromRawJson(responseText, "messageid") ??
223
+ extractIdFromRawJson(responseText, "msgid");
224
+ const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
225
+ if (messageid) {
226
+ recordSentMessageId(messageid);
227
+ coreEvents.emit("message:sent", {
228
+ accountId: account.accountId,
229
+ target: `group:${groupId}`,
230
+ from: account.config.appAgentId != null ? `agent:${account.config.appAgentId}` : "agent:unknown",
231
+ messageid,
232
+ msgseqid: msgseqid ?? "",
233
+ contents: [{ type: "image", content: "image" }],
234
+ sentAt: Date.now(),
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
+ coreEvents.emit("message:sent", {
314
+ accountId: account.accountId,
315
+ target: toUser,
316
+ from: account.config.appAgentId != null ? `agent:${account.config.appAgentId}` : "agent:unknown",
317
+ messageid: msgkey,
318
+ msgseqid: "",
319
+ contents: [{ type: "image", content: "image" }],
320
+ sentAt: Date.now(),
321
+ });
322
+ }
323
+
324
+ return { ok: true, msgkey };
325
+ } catch (err) {
326
+ const errMsg = formatInfoflowError(err);
327
+ getInfoflowSendLog().error(`[infoflow:sendPrivateImage] exception: ${errMsg}`);
328
+ return { ok: false, error: errMsg };
329
+ } finally {
330
+ clearTimeout(timeout);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Unified image message sender. Parses target and dispatches to group or private.
336
+ */
337
+ export async function sendInfoflowImageMessage(params: {
338
+ cfg: OpenClawConfig;
339
+ to: string;
340
+ base64Image: string;
341
+ accountId?: string;
342
+ replyTo?: InfoflowOutboundReply;
343
+ }): Promise<{ ok: boolean; error?: string; messageId?: string }> {
344
+ const { cfg, to, base64Image, accountId } = params;
345
+ const account = resolveInfoflowAccount({ cfg, accountId });
346
+
347
+ // Parse target: remove "infoflow:" prefix if present
348
+ const target = to.replace(/^infoflow:/i, "");
349
+
350
+ const groupMatch = target.match(/^group:(\d+)/i);
351
+ if (groupMatch) {
352
+ const groupId = Number(groupMatch[1]);
353
+ const result = await sendInfoflowGroupImage({
354
+ account,
355
+ groupId,
356
+ base64Image,
357
+ replyTo: params.replyTo,
358
+ });
359
+ return { ok: result.ok, error: result.error, messageId: result.messageid };
360
+ }
361
+
362
+ // Private message (replyTo not supported)
363
+ const result = await sendInfoflowPrivateImage({ account, toUser: target, base64Image });
364
+ return { ok: result.ok, error: result.error, messageId: result.msgkey };
365
+ }
@@ -0,0 +1,184 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { ResolvedInfoflowAccount } from "../types.js";
4
+ import {
5
+ parseAndDispatchInfoflowRequest,
6
+ loadRawBody,
7
+ type WebhookTarget,
8
+ } from "../adapter/inbound/webhook-parser.js";
9
+ import { getInfoflowWebhookLog, formatInfoflowError, logVerbose } from "../logging.js";
10
+ import { getInfoflowRuntime } from "../runtime.js";
11
+ import { InfoflowWSReceiver } from "../adapter/inbound/ws-receiver.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type InfoflowMonitorOptions = {
18
+ account: ResolvedInfoflowAccount;
19
+ config: OpenClawConfig;
20
+ runtime: unknown;
21
+ abortSignal: AbortSignal;
22
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** webhook path for Infoflow. */
30
+ const INFOFLOW_WEBHOOK_PATH = "/webhook/infoflow";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Webhook target registry
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const webhookTargets = new Map<string, WebhookTarget[]>();
37
+
38
+ /** Normalizes a webhook path: trim, ensure leading slash, strip trailing slash (except "/"). */
39
+ function normalizeWebhookPath(raw: string): string {
40
+ const trimmed = raw.trim();
41
+ if (!trimmed) {
42
+ return "/";
43
+ }
44
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
45
+ if (withSlash.length > 1 && withSlash.endsWith("/")) {
46
+ return withSlash.slice(0, -1);
47
+ }
48
+ return withSlash;
49
+ }
50
+
51
+ /** Registers a webhook target for a path. Returns an unregister function to remove it. */
52
+ function registerInfoflowWebhookTarget(target: WebhookTarget): () => void {
53
+ const key = normalizeWebhookPath(target.path);
54
+ const normalizedTarget = { ...target, path: key };
55
+ const existing = webhookTargets.get(key) ?? [];
56
+ const next = [...existing, normalizedTarget];
57
+ webhookTargets.set(key, next);
58
+ return () => {
59
+ const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
60
+ if (updated.length > 0) {
61
+ webhookTargets.set(key, updated);
62
+ } else {
63
+ webhookTargets.delete(key);
64
+ }
65
+ };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // HTTP handler (registered via api.registerHttpRoute)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Checks if the request path matches a registered Infoflow webhook path.
74
+ */
75
+ function isInfoflowPath(requestPath: string): boolean {
76
+ const normalized = normalizeWebhookPath(requestPath);
77
+ return webhookTargets.has(normalized);
78
+ }
79
+
80
+ /**
81
+ * Handles incoming Infoflow webhook HTTP requests.
82
+ *
83
+ * - Routes by path to registered targets (supports exact and suffix match).
84
+ * - Only allows POST.
85
+ * - Delegates body reading, echostr verification, authentication,
86
+ * and message dispatch to infoflow_req_parse.
87
+ */
88
+ export async function handleInfoflowWebhookRequest(
89
+ req: IncomingMessage,
90
+ res: ServerResponse,
91
+ ): Promise<boolean> {
92
+ const url = new URL(req.url ?? "/", "http://localhost");
93
+ const requestPath = normalizeWebhookPath(url.pathname);
94
+
95
+ // Log the full request URL
96
+ logVerbose(`[infoflow] request: url=${url}`);
97
+
98
+ // Check if path matches Infoflow webhook pattern
99
+ if (!isInfoflowPath(requestPath)) {
100
+ return false;
101
+ }
102
+
103
+ // Get registered targets for the actual request path
104
+ const targets = webhookTargets.get(requestPath);
105
+ if (!targets || targets.length === 0) {
106
+ return false;
107
+ }
108
+
109
+ if (req.method !== "POST") {
110
+ res.statusCode = 405;
111
+ res.setHeader("Allow", "POST");
112
+ res.end("Method Not Allowed");
113
+ return true;
114
+ }
115
+
116
+ // Load raw body once
117
+ const bodyResult = await loadRawBody(req);
118
+ if (!bodyResult.ok) {
119
+ getInfoflowWebhookLog().error(`[infoflow] failed to read body: ${bodyResult.error}`);
120
+ res.statusCode = bodyResult.error === "payload too large" ? 413 : 400;
121
+ res.end(bodyResult.error);
122
+ return true;
123
+ }
124
+
125
+ let result;
126
+ try {
127
+ result = await parseAndDispatchInfoflowRequest(req, bodyResult.raw, targets);
128
+ } catch (err) {
129
+ getInfoflowWebhookLog().error(`[infoflow] webhook handler error: ${formatInfoflowError(err)}`);
130
+ res.statusCode = 500;
131
+ res.end("internal error");
132
+ return true;
133
+ }
134
+
135
+ logVerbose(
136
+ `[infoflow] dispatch result: handled=${result.handled}, status=${result.handled ? result.statusCode : "N/A"}`,
137
+ );
138
+
139
+ if (result.handled) {
140
+ const looksLikeJson = result.body.startsWith("{");
141
+ if (looksLikeJson) {
142
+ res.setHeader("Content-Type", "application/json");
143
+ }
144
+ res.statusCode = result.statusCode;
145
+ res.end(result.body);
146
+ } else {
147
+ res.statusCode = 200;
148
+ res.end("OK");
149
+ }
150
+ return true;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Monitor lifecycle
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Registers this account's webhook target and returns an unregister (stop) function. */
158
+ export async function startInfoflowMonitor(options: InfoflowMonitorOptions): Promise<() => void> {
159
+ const core = getInfoflowRuntime();
160
+
161
+ const unregister = registerInfoflowWebhookTarget({
162
+ account: options.account,
163
+ config: options.config,
164
+ core,
165
+ path: INFOFLOW_WEBHOOK_PATH,
166
+ statusSink: options.statusSink,
167
+ });
168
+
169
+ return unregister;
170
+ }
171
+
172
+ /** Starts a WebSocket message receiver and returns a stop function. */
173
+ export async function startInfoflowWSMonitor(options: InfoflowMonitorOptions): Promise<() => void> {
174
+ const receiver = new InfoflowWSReceiver({
175
+ account: options.account,
176
+ config: options.config,
177
+ abortSignal: options.abortSignal,
178
+ statusSink: options.statusSink,
179
+ });
180
+
181
+ await receiver.start();
182
+
183
+ return () => receiver.stop();
184
+ }