@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.
- package/README.md +24 -528
- package/dist/index.js +21 -0
- package/dist/src/accounts.js +110 -0
- package/dist/src/actions.js +386 -0
- package/dist/src/bot.js +1010 -0
- package/dist/src/channel.js +385 -0
- package/dist/src/infoflow-req-parse.js +394 -0
- package/dist/src/logging.js +102 -0
- package/dist/src/markdown-local-images.js +65 -0
- package/dist/src/media.js +318 -0
- package/dist/src/monitor.js +145 -0
- package/dist/src/reply-dispatcher.js +301 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/send.js +820 -0
- package/dist/src/sent-message-store.js +190 -0
- package/dist/src/targets.js +90 -0
- package/dist/src/types.js +4 -0
- package/dist/src/ws-receiver.js +378 -0
- package/openclaw.plugin.json +194 -0
- package/package.json +18 -3
- package/scripts/deploy.sh +215 -0
- package/src/accounts.ts +25 -3
- package/src/actions.ts +9 -3
- package/src/bot.ts +63 -20
- package/src/channel.ts +64 -45
- package/src/infoflow-req-parse.ts +2 -2
- package/src/infoflow-sdk.d.ts +12 -0
- package/src/monitor.ts +21 -2
- package/src/reply-dispatcher.ts +2 -5
- package/src/types.ts +11 -0
- package/src/ws-receiver.ts +482 -0
- package/tsconfig.build.json +6 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infoflow native image sending: compress, base64-encode, and POST via Infoflow API.
|
|
3
|
+
*/
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
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 { getAppAccessToken, ensureHttps, extractIdFromRawJson, DEFAULT_TIMEOUT_MS, INFOFLOW_PRIVATE_SEND_PATH, INFOFLOW_GROUP_SEND_PATH, } from "./send.js";
|
|
11
|
+
import { recordSentMessage, buildAgentFrom } from "./sent-message-store.js";
|
|
12
|
+
/** Infoflow API image size limit: 1MB raw bytes */
|
|
13
|
+
const INFOFLOW_IMAGE_MAX_BYTES = 1 * 1024 * 1024;
|
|
14
|
+
// Compression grid: progressively smaller maxSide and quality
|
|
15
|
+
const COMPRESS_SIDES = [2048, 1536, 1280, 1024, 800];
|
|
16
|
+
const COMPRESS_QUALITIES = [80, 70, 60, 50, 40];
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Image compression
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Compresses an image buffer to fit within the Infoflow 1MB limit.
|
|
22
|
+
* Returns null if compression fails (e.g. GIF > 1MB, or all combos exceed limit).
|
|
23
|
+
*/
|
|
24
|
+
export async function compressImageForInfoflow(params) {
|
|
25
|
+
const { buffer, contentType } = params;
|
|
26
|
+
// Already within limit
|
|
27
|
+
if (buffer.length <= INFOFLOW_IMAGE_MAX_BYTES) {
|
|
28
|
+
return buffer;
|
|
29
|
+
}
|
|
30
|
+
// GIF cannot be compressed without losing animation
|
|
31
|
+
if (contentType === "image/gif") {
|
|
32
|
+
logVerbose(`[infoflow:media] GIF exceeds 1MB (${buffer.length} bytes), cannot compress`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const runtime = getInfoflowRuntime();
|
|
36
|
+
let smallest = null;
|
|
37
|
+
for (const side of COMPRESS_SIDES) {
|
|
38
|
+
for (const quality of COMPRESS_QUALITIES) {
|
|
39
|
+
try {
|
|
40
|
+
const out = await runtime.media.resizeToJpeg({
|
|
41
|
+
buffer,
|
|
42
|
+
maxSide: side,
|
|
43
|
+
quality,
|
|
44
|
+
withoutEnlargement: true,
|
|
45
|
+
});
|
|
46
|
+
const size = out.length;
|
|
47
|
+
if (size <= INFOFLOW_IMAGE_MAX_BYTES) {
|
|
48
|
+
logVerbose(`[infoflow:media] compressed ${buffer.length} → ${size} bytes (side≤${side}, q=${quality})`);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
if (!smallest || size < smallest.size) {
|
|
52
|
+
smallest = { buffer: out, size };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// skip failed combo
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
logVerbose(`[infoflow:media] all compression combos exceed 1MB (smallest: ${smallest?.size ?? "N/A"} bytes)`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function isLocalMediaUrl(url) {
|
|
64
|
+
const t = url.trim();
|
|
65
|
+
return (t.startsWith("file://") ||
|
|
66
|
+
t.startsWith("/") ||
|
|
67
|
+
t.startsWith("./") ||
|
|
68
|
+
t.startsWith("../") ||
|
|
69
|
+
t.startsWith("~"));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Downloads media, checks if it's an image, compresses to 1MB, and base64-encodes.
|
|
73
|
+
* For local paths, passes the file's parent directory as localRoots so any path is allowed
|
|
74
|
+
* (infoflow-only; does not change shared media layer).
|
|
75
|
+
*/
|
|
76
|
+
export async function prepareInfoflowImageBase64(params) {
|
|
77
|
+
const { mediaUrl, mediaLocalRoots } = params;
|
|
78
|
+
const runtime = getInfoflowRuntime();
|
|
79
|
+
let localRoots;
|
|
80
|
+
if (isLocalMediaUrl(mediaUrl)) {
|
|
81
|
+
let absPath;
|
|
82
|
+
if (mediaUrl.trim().startsWith("file://")) {
|
|
83
|
+
try {
|
|
84
|
+
absPath = fileURLToPath(mediaUrl.trim());
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
absPath = path.resolve(mediaUrl.replace(/^file:\/\//i, ""));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
absPath = path.resolve(mediaUrl.trim());
|
|
92
|
+
}
|
|
93
|
+
const dir = path.dirname(absPath);
|
|
94
|
+
if (dir && dir !== path.parse(dir).root) {
|
|
95
|
+
localRoots = [dir];
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
localRoots = mediaLocalRoots?.length ? [...mediaLocalRoots] : undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
localRoots = mediaLocalRoots?.length ? [...mediaLocalRoots] : undefined;
|
|
103
|
+
}
|
|
104
|
+
const loaded = await runtime.media.loadWebMedia(mediaUrl, {
|
|
105
|
+
maxBytes: 30 * 1024 * 1024, // 30MB download limit
|
|
106
|
+
optimizeImages: false,
|
|
107
|
+
localRoots: localRoots ?? undefined,
|
|
108
|
+
});
|
|
109
|
+
// Check if it's an image
|
|
110
|
+
const kind = runtime.media.mediaKindFromMime(loaded.contentType ?? undefined);
|
|
111
|
+
if (kind !== "image") {
|
|
112
|
+
return { isImage: false };
|
|
113
|
+
}
|
|
114
|
+
// Compress if needed
|
|
115
|
+
const compressed = await compressImageForInfoflow({
|
|
116
|
+
buffer: loaded.buffer,
|
|
117
|
+
contentType: loaded.contentType ?? undefined,
|
|
118
|
+
});
|
|
119
|
+
if (!compressed) {
|
|
120
|
+
return { isImage: false }; // compression failed, fall back to link
|
|
121
|
+
}
|
|
122
|
+
return { isImage: true, base64: compressed.toString("base64") };
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Send image messages
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* Sends a native image message to a group chat.
|
|
129
|
+
*/
|
|
130
|
+
export async function sendInfoflowGroupImage(params) {
|
|
131
|
+
const { account, groupId, base64Image, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
|
|
132
|
+
const { apiHost, appKey, appSecret } = account.config;
|
|
133
|
+
if (!appKey || !appSecret) {
|
|
134
|
+
return { ok: false, error: "Infoflow appKey/appSecret not configured." };
|
|
135
|
+
}
|
|
136
|
+
const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
|
|
137
|
+
if (!tokenResult.ok || !tokenResult.token) {
|
|
138
|
+
getInfoflowSendLog().error(`[infoflow:sendGroupImage] token error: ${tokenResult.error}`);
|
|
139
|
+
return { ok: false, error: tokenResult.error ?? "failed to get token" };
|
|
140
|
+
}
|
|
141
|
+
let timeout;
|
|
142
|
+
try {
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
145
|
+
const payload = {
|
|
146
|
+
message: {
|
|
147
|
+
header: {
|
|
148
|
+
toid: groupId,
|
|
149
|
+
totype: "GROUP",
|
|
150
|
+
msgtype: "IMAGE",
|
|
151
|
+
clientmsgid: Date.now(),
|
|
152
|
+
role: "robot",
|
|
153
|
+
},
|
|
154
|
+
body: [{ type: "IMAGE", content: base64Image }],
|
|
155
|
+
...(params.replyTo
|
|
156
|
+
? {
|
|
157
|
+
reply: {
|
|
158
|
+
messageid: params.replyTo.messageid,
|
|
159
|
+
preview: params.replyTo.preview ?? "",
|
|
160
|
+
replytype: params.replyTo.replytype ?? "1",
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
: {}),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const headers = {
|
|
167
|
+
Authorization: `Bearer-${tokenResult.token}`,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
};
|
|
170
|
+
logVerbose(`[infoflow:sendGroupImage] POST to group ${groupId}, image size: ${base64Image.length} chars`);
|
|
171
|
+
const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers,
|
|
174
|
+
body: JSON.stringify(payload),
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
});
|
|
177
|
+
const responseText = await res.text();
|
|
178
|
+
const data = JSON.parse(responseText);
|
|
179
|
+
logVerbose(`[infoflow:sendGroupImage] response: status=${res.status}, data=${responseText}`);
|
|
180
|
+
const code = typeof data.code === "string" ? data.code : "";
|
|
181
|
+
if (code !== "ok") {
|
|
182
|
+
const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
|
|
183
|
+
getInfoflowSendLog().error(`[infoflow:sendGroupImage] failed: ${errMsg}`);
|
|
184
|
+
return { ok: false, error: errMsg };
|
|
185
|
+
}
|
|
186
|
+
const innerData = data.data;
|
|
187
|
+
const errcode = innerData?.errcode;
|
|
188
|
+
if (errcode != null && errcode !== 0) {
|
|
189
|
+
const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
|
|
190
|
+
getInfoflowSendLog().error(`[infoflow:sendGroupImage] failed: ${errMsg}`);
|
|
191
|
+
return { ok: false, error: errMsg };
|
|
192
|
+
}
|
|
193
|
+
// Extract IDs from raw text to preserve large integer precision
|
|
194
|
+
const messageid = extractIdFromRawJson(responseText, "messageid") ??
|
|
195
|
+
extractIdFromRawJson(responseText, "msgid");
|
|
196
|
+
const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
|
|
197
|
+
if (messageid) {
|
|
198
|
+
recordSentMessageId(messageid);
|
|
199
|
+
try {
|
|
200
|
+
recordSentMessage(account.accountId, {
|
|
201
|
+
target: `group:${groupId}`,
|
|
202
|
+
from: buildAgentFrom(account.config.appAgentId),
|
|
203
|
+
messageid,
|
|
204
|
+
msgseqid: msgseqid ?? "",
|
|
205
|
+
digest: "image",
|
|
206
|
+
sentAt: Date.now(),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Do not block sending
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, messageid };
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
const errMsg = formatInfoflowError(err);
|
|
217
|
+
getInfoflowSendLog().error(`[infoflow:sendGroupImage] exception: ${errMsg}`);
|
|
218
|
+
return { ok: false, error: errMsg };
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Sends a native image message to a private (DM) chat.
|
|
226
|
+
*/
|
|
227
|
+
export async function sendInfoflowPrivateImage(params) {
|
|
228
|
+
const { account, toUser, base64Image, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
|
|
229
|
+
const { apiHost, appKey, appSecret } = account.config;
|
|
230
|
+
if (!appKey || !appSecret) {
|
|
231
|
+
return { ok: false, error: "Infoflow appKey/appSecret not configured." };
|
|
232
|
+
}
|
|
233
|
+
const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
|
|
234
|
+
if (!tokenResult.ok || !tokenResult.token) {
|
|
235
|
+
getInfoflowSendLog().error(`[infoflow:sendPrivateImage] token error: ${tokenResult.error}`);
|
|
236
|
+
return { ok: false, error: tokenResult.error ?? "failed to get token" };
|
|
237
|
+
}
|
|
238
|
+
let timeout;
|
|
239
|
+
try {
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
242
|
+
const payload = {
|
|
243
|
+
touser: toUser,
|
|
244
|
+
msgtype: "image",
|
|
245
|
+
image: { content: base64Image },
|
|
246
|
+
};
|
|
247
|
+
const headers = {
|
|
248
|
+
Authorization: `Bearer-${tokenResult.token}`,
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
};
|
|
251
|
+
logVerbose(`[infoflow:sendPrivateImage] POST to user ${toUser}, image size: ${base64Image.length} chars`);
|
|
252
|
+
const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers,
|
|
255
|
+
body: JSON.stringify(payload),
|
|
256
|
+
signal: controller.signal,
|
|
257
|
+
});
|
|
258
|
+
const responseText = await res.text();
|
|
259
|
+
const data = JSON.parse(responseText);
|
|
260
|
+
logVerbose(`[infoflow:sendPrivateImage] response: status=${res.status}, data=${responseText}`);
|
|
261
|
+
if (data.errcode && data.errcode !== 0) {
|
|
262
|
+
const errMsg = String(data.errmsg ?? `errcode ${data.errcode}`);
|
|
263
|
+
getInfoflowSendLog().error(`[infoflow:sendPrivateImage] failed: ${errMsg}`);
|
|
264
|
+
return { ok: false, error: errMsg };
|
|
265
|
+
}
|
|
266
|
+
// Extract msgkey from raw text to preserve large integer precision
|
|
267
|
+
const msgkey = extractIdFromRawJson(responseText, "msgkey") ??
|
|
268
|
+
(data.msgkey != null ? String(data.msgkey) : undefined);
|
|
269
|
+
if (msgkey) {
|
|
270
|
+
recordSentMessageId(msgkey);
|
|
271
|
+
try {
|
|
272
|
+
recordSentMessage(account.accountId, {
|
|
273
|
+
target: toUser,
|
|
274
|
+
from: buildAgentFrom(account.config.appAgentId),
|
|
275
|
+
messageid: msgkey,
|
|
276
|
+
msgseqid: "",
|
|
277
|
+
digest: "image",
|
|
278
|
+
sentAt: Date.now(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Do not block sending
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { ok: true, msgkey };
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
const errMsg = formatInfoflowError(err);
|
|
289
|
+
getInfoflowSendLog().error(`[infoflow:sendPrivateImage] exception: ${errMsg}`);
|
|
290
|
+
return { ok: false, error: errMsg };
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
clearTimeout(timeout);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Unified image message sender. Parses target and dispatches to group or private.
|
|
298
|
+
*/
|
|
299
|
+
export async function sendInfoflowImageMessage(params) {
|
|
300
|
+
const { cfg, to, base64Image, accountId } = params;
|
|
301
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
302
|
+
// Parse target: remove "infoflow:" prefix if present
|
|
303
|
+
const target = to.replace(/^infoflow:/i, "");
|
|
304
|
+
const groupMatch = target.match(/^group:(\d+)/i);
|
|
305
|
+
if (groupMatch) {
|
|
306
|
+
const groupId = Number(groupMatch[1]);
|
|
307
|
+
const result = await sendInfoflowGroupImage({
|
|
308
|
+
account,
|
|
309
|
+
groupId,
|
|
310
|
+
base64Image,
|
|
311
|
+
replyTo: params.replyTo,
|
|
312
|
+
});
|
|
313
|
+
return { ok: result.ok, error: result.error, messageId: result.messageid };
|
|
314
|
+
}
|
|
315
|
+
// Private message (replyTo not supported)
|
|
316
|
+
const result = await sendInfoflowPrivateImage({ account, toUser: target, base64Image });
|
|
317
|
+
return { ok: result.ok, error: result.error, messageId: result.msgkey };
|
|
318
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { parseAndDispatchInfoflowRequest, loadRawBody, } from "./infoflow-req-parse.js";
|
|
2
|
+
import { getInfoflowWebhookLog, formatInfoflowError, logVerbose } from "./logging.js";
|
|
3
|
+
import { getInfoflowRuntime } from "./runtime.js";
|
|
4
|
+
import { InfoflowWSReceiver } from "./ws-receiver.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Constants
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/** webhook path for Infoflow. */
|
|
9
|
+
const INFOFLOW_WEBHOOK_PATH = "/webhook/infoflow";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Webhook target registry
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const webhookTargets = new Map();
|
|
14
|
+
/** Normalizes a webhook path: trim, ensure leading slash, strip trailing slash (except "/"). */
|
|
15
|
+
function normalizeWebhookPath(raw) {
|
|
16
|
+
const trimmed = raw.trim();
|
|
17
|
+
if (!trimmed) {
|
|
18
|
+
return "/";
|
|
19
|
+
}
|
|
20
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
21
|
+
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
|
22
|
+
return withSlash.slice(0, -1);
|
|
23
|
+
}
|
|
24
|
+
return withSlash;
|
|
25
|
+
}
|
|
26
|
+
/** Registers a webhook target for a path. Returns an unregister function to remove it. */
|
|
27
|
+
function registerInfoflowWebhookTarget(target) {
|
|
28
|
+
const key = normalizeWebhookPath(target.path);
|
|
29
|
+
const normalizedTarget = { ...target, path: key };
|
|
30
|
+
const existing = webhookTargets.get(key) ?? [];
|
|
31
|
+
const next = [...existing, normalizedTarget];
|
|
32
|
+
webhookTargets.set(key, next);
|
|
33
|
+
return () => {
|
|
34
|
+
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
|
35
|
+
if (updated.length > 0) {
|
|
36
|
+
webhookTargets.set(key, updated);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
webhookTargets.delete(key);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// HTTP handler (registered via api.registerHttpRoute)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/**
|
|
47
|
+
* Checks if the request path matches a registered Infoflow webhook path.
|
|
48
|
+
*/
|
|
49
|
+
function isInfoflowPath(requestPath) {
|
|
50
|
+
const normalized = normalizeWebhookPath(requestPath);
|
|
51
|
+
return webhookTargets.has(normalized);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Handles incoming Infoflow webhook HTTP requests.
|
|
55
|
+
*
|
|
56
|
+
* - Routes by path to registered targets (exact match).
|
|
57
|
+
* - Only allows POST.
|
|
58
|
+
* - Delegates body reading, echostr verification, authentication,
|
|
59
|
+
* and message dispatch to infoflow_req_parse.
|
|
60
|
+
*/
|
|
61
|
+
export async function handleInfoflowWebhookRequest(req, res) {
|
|
62
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
63
|
+
const requestPath = normalizeWebhookPath(url.pathname);
|
|
64
|
+
// Log the full request URL
|
|
65
|
+
logVerbose(`[infoflow] request: url=${url}`);
|
|
66
|
+
// Check if path matches Infoflow webhook pattern
|
|
67
|
+
if (!isInfoflowPath(requestPath)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// Get registered targets for the actual request path
|
|
71
|
+
const targets = webhookTargets.get(requestPath);
|
|
72
|
+
if (!targets || targets.length === 0) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (req.method !== "POST") {
|
|
76
|
+
res.statusCode = 405;
|
|
77
|
+
res.setHeader("Allow", "POST");
|
|
78
|
+
res.end("Method Not Allowed");
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
// Load raw body once
|
|
82
|
+
const bodyResult = await loadRawBody(req);
|
|
83
|
+
if (!bodyResult.ok) {
|
|
84
|
+
getInfoflowWebhookLog().error(`[infoflow] failed to read body: ${bodyResult.error}`);
|
|
85
|
+
res.statusCode = bodyResult.error === "payload too large" ? 413 : 400;
|
|
86
|
+
res.end(bodyResult.error);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
let result;
|
|
90
|
+
try {
|
|
91
|
+
result = await parseAndDispatchInfoflowRequest(req, bodyResult.raw, targets);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
getInfoflowWebhookLog().error(`[infoflow] webhook handler error: ${formatInfoflowError(err)}`);
|
|
95
|
+
res.statusCode = 500;
|
|
96
|
+
res.end("internal error");
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
logVerbose(`[infoflow] dispatch result: handled=${result.handled}, status=${result.handled ? result.statusCode : "N/A"}`);
|
|
100
|
+
if (result.handled) {
|
|
101
|
+
const looksLikeJson = result.body.startsWith("{");
|
|
102
|
+
if (looksLikeJson) {
|
|
103
|
+
res.setHeader("Content-Type", "application/json");
|
|
104
|
+
}
|
|
105
|
+
res.statusCode = result.statusCode;
|
|
106
|
+
res.end(result.body);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
res.statusCode = 200;
|
|
110
|
+
res.end("OK");
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Monitor lifecycle
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
/** Registers this account's webhook target and returns an unregister (stop) function. */
|
|
118
|
+
export async function startInfoflowMonitor(options) {
|
|
119
|
+
const core = getInfoflowRuntime();
|
|
120
|
+
const unregister = registerInfoflowWebhookTarget({
|
|
121
|
+
account: options.account,
|
|
122
|
+
config: options.config,
|
|
123
|
+
core,
|
|
124
|
+
path: INFOFLOW_WEBHOOK_PATH,
|
|
125
|
+
statusSink: options.statusSink,
|
|
126
|
+
});
|
|
127
|
+
return unregister;
|
|
128
|
+
}
|
|
129
|
+
/** Starts a WebSocket message receiver and returns a stop function. */
|
|
130
|
+
export async function startInfoflowWSMonitor(options) {
|
|
131
|
+
const receiver = new InfoflowWSReceiver({
|
|
132
|
+
account: options.account,
|
|
133
|
+
config: options.config,
|
|
134
|
+
abortSignal: options.abortSignal,
|
|
135
|
+
statusSink: options.statusSink,
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
await receiver.start();
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
receiver.stop();
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
return () => receiver.stop();
|
|
145
|
+
}
|