@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.
- package/README.md +989 -0
- package/docs/architecture-data-flow.md +429 -0
- package/docs/architecture.md +423 -0
- package/docs/dev-guide.md +611 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +138 -0
- package/package.json +40 -0
- package/scripts/deploy.sh +34 -0
- package/skills/infoflow-dev/SKILL.md +88 -0
- package/skills/infoflow-dev/references/api.md +413 -0
- package/src/adapter/inbound/webhook-parser.ts +433 -0
- package/src/adapter/inbound/ws-receiver.ts +226 -0
- package/src/adapter/outbound/reply-dispatcher.ts +281 -0
- package/src/adapter/outbound/target-resolver.ts +109 -0
- package/src/channel/accounts.ts +164 -0
- package/src/channel/channel.ts +364 -0
- package/src/channel/media.ts +365 -0
- package/src/channel/monitor.ts +184 -0
- package/src/channel/outbound.ts +934 -0
- package/src/events.ts +62 -0
- package/src/handler/message-handler.ts +801 -0
- package/src/logging.ts +123 -0
- package/src/runtime.ts +14 -0
- package/src/security/dm-policy.ts +80 -0
- package/src/security/group-policy.ts +271 -0
- package/src/tools/actions/index.ts +456 -0
- package/src/tools/hooks/index.ts +82 -0
- package/src/tools/index.ts +277 -0
- package/src/types.ts +277 -0
- package/src/utils/store/message-store.ts +295 -0
- package/src/utils/token-adapter.ts +90 -0
|
@@ -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
|
+
}
|