@chbo297/infoflow 2026.2.23

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,451 @@
1
+ import { createHash, createDecipheriv, timingSafeEqual } from "node:crypto";
2
+ import type { IncomingMessage } from "node:http";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
+ import { createDedupeCache } from "openclaw/plugin-sdk";
5
+ // ---------------------------------------------------------------------------
6
+ // Message deduplication
7
+ // ---------------------------------------------------------------------------
8
+ import { handlePrivateChatMessage, handleGroupChatMessage } from "./bot.js";
9
+ import type { ResolvedInfoflowAccount } from "./channel.js";
10
+ import { getInfoflowParseLog } from "./logging.js";
11
+
12
+ const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
13
+ const DEDUP_MAX_SIZE = 1000;
14
+
15
+ const messageCache = createDedupeCache({
16
+ ttlMs: DEDUP_TTL_MS,
17
+ maxSize: DEDUP_MAX_SIZE,
18
+ });
19
+
20
+ /**
21
+ * Extracts a dedup key from the decrypted message data.
22
+ *
23
+ * Priority:
24
+ * 1. message.header.messageid || message.header.msgid || MsgId
25
+ * 2. fallback: "{fromuserid}_{groupid}_{ctime}"
26
+ */
27
+ function extractDedupeKey(msgData: Record<string, unknown>): string | null {
28
+ const message = msgData.message as Record<string, unknown> | undefined;
29
+ const header = (message?.header ?? {}) as Record<string, unknown>;
30
+
31
+ // Priority 1: explicit message ID
32
+ const msgId = header.messageid ?? header.msgid ?? msgData.MsgId;
33
+ if (msgId != null) {
34
+ return String(msgId);
35
+ }
36
+
37
+ // Priority 2: composite key
38
+ const fromuserid = header.fromuserid ?? msgData.FromUserId ?? msgData.fromuserid;
39
+ const groupid = msgData.groupid ?? header.groupid;
40
+ const ctime = header.ctime ?? Date.now();
41
+ if (fromuserid != null) {
42
+ return `${fromuserid}_${groupid ?? "dm"}_${ctime}`;
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Returns true if the message is a duplicate (already seen within TTL).
50
+ * Uses shared dedupe cache implementation.
51
+ */
52
+ function isDuplicateMessage(msgData: Record<string, unknown>): boolean {
53
+ const key = extractDedupeKey(msgData);
54
+ if (!key) return false; // Cannot extract key, allow through
55
+
56
+ return messageCache.check(key);
57
+ }
58
+
59
+ /**
60
+ * Records a sent message ID in the dedup cache.
61
+ * Called after successfully sending a message to prevent
62
+ * the bot from processing its own outbound messages as inbound.
63
+ */
64
+ export function recordSentMessageId(messageId: string | number): void {
65
+ if (messageId == null) return;
66
+ messageCache.check(String(messageId)); // Will record if not duplicate
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // AES-ECB Decryption Utilities
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Decodes a Base64 URLSafe encoded string to a Buffer.
75
+ * Handles the URL-safe alphabet (- → +, _ → /) and auto-pads with '='.
76
+ */
77
+ function base64UrlSafeDecode(s: string): Buffer {
78
+ const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
79
+ const padLen = (4 - (base64.length % 4)) % 4;
80
+ return Buffer.from(base64 + "=".repeat(padLen), "base64");
81
+ }
82
+
83
+ /**
84
+ * Decrypts an AES-ECB encrypted message.
85
+ * @param encryptedMsg - Base64 URLSafe encoded ciphertext
86
+ * @param encodingAESKey - Base64 URLSafe encoded AES key (supports 16/24/32 byte keys)
87
+ * @returns Decrypted UTF-8 string
88
+ */
89
+ function decryptMessage(encryptedMsg: string, encodingAESKey: string): string {
90
+ const aesKey = base64UrlSafeDecode(encodingAESKey);
91
+ const cipherText = base64UrlSafeDecode(encryptedMsg);
92
+
93
+ // Select AES algorithm based on key length
94
+ let algorithm: string;
95
+ switch (aesKey.length) {
96
+ case 16:
97
+ algorithm = "aes-128-ecb";
98
+ break;
99
+ case 24:
100
+ algorithm = "aes-192-ecb";
101
+ break;
102
+ case 32:
103
+ algorithm = "aes-256-ecb";
104
+ break;
105
+ default:
106
+ throw new Error(`Invalid AES key length: ${aesKey.length} bytes (expected 16, 24, or 32)`);
107
+ }
108
+
109
+ // ECB mode does not use an IV (pass null)
110
+ const decipher = createDecipheriv(algorithm, aesKey, null);
111
+ const decrypted = Buffer.concat([decipher.update(cipherText), decipher.final()]);
112
+ return decrypted.toString("utf8");
113
+ }
114
+
115
+ /**
116
+ * Parses an XML message string into a key-value object.
117
+ * Handles simple XML structures like <xml><Tag>value</Tag></xml>.
118
+ */
119
+ function parseXmlMessage(xmlString: string): Record<string, string> | null {
120
+ try {
121
+ const result: Record<string, string> = {};
122
+ // Match <TagName>content</TagName> patterns
123
+ const tagRegex = /<(\w+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
124
+ let match;
125
+ while ((match = tagRegex.exec(xmlString)) !== null) {
126
+ const tagName = match[1];
127
+ // CDATA content or plain text content
128
+ const content = match[2] ?? match[3] ?? "";
129
+ result[tagName] = content.trim();
130
+ }
131
+ return Object.keys(result).length > 0 ? result : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Types
139
+ // ---------------------------------------------------------------------------
140
+
141
+ type InfoflowCoreRuntime = {
142
+ logging: { shouldLogVerbose(): boolean };
143
+ };
144
+
145
+ export type WebhookTarget = {
146
+ account: ResolvedInfoflowAccount;
147
+ config: OpenClawConfig;
148
+ core: InfoflowCoreRuntime;
149
+ path: string;
150
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
151
+ };
152
+
153
+ export type ParseResult = { handled: true; statusCode: number; body: string } | { handled: false };
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Body readers
157
+ // ---------------------------------------------------------------------------
158
+
159
+ const MAX_BODY_SIZE = 20 * 1024 * 1024; // 20 MB
160
+
161
+ /** Load raw body as a string from the request stream; enforces max size. */
162
+ export async function loadRawBody(
163
+ req: IncomingMessage,
164
+ maxBytes = MAX_BODY_SIZE,
165
+ ): Promise<{ ok: true; raw: string } | { ok: false; error: string }> {
166
+ const chunks: Buffer[] = [];
167
+ let total = 0;
168
+ let done = false;
169
+ return await new Promise((resolve) => {
170
+ req.on("data", (chunk: Buffer) => {
171
+ if (done) return;
172
+ total += chunk.length;
173
+ if (total > maxBytes) {
174
+ done = true;
175
+ resolve({ ok: false, error: "payload too large" });
176
+ req.destroy();
177
+ return;
178
+ }
179
+ chunks.push(chunk);
180
+ });
181
+ req.on("end", () => {
182
+ if (done) return;
183
+ done = true;
184
+ const raw = Buffer.concat(chunks).toString("utf8");
185
+ resolve({ ok: true, raw });
186
+ });
187
+ req.on("error", (err) => {
188
+ if (done) return;
189
+ done = true;
190
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
191
+ });
192
+ });
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Public API
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Parses and dispatches an incoming Infoflow webhook request.
201
+ *
202
+ * 1. Parses Content-Type header once.
203
+ * 2. form-urlencoded:
204
+ * - echostr present → signature verification → 200/403
205
+ * - messageJson present → private chat (dm) handling
206
+ * 3. text/plain → group chat handling (raw body is encrypted ciphertext)
207
+ * 4. Other Content-Type → 400
208
+ *
209
+ * Returns a ParseResult indicating whether the request was handled and the response to send.
210
+ *
211
+ * NOTE: Only echostr has signature verification; message webhooks use AES-ECB mode.
212
+ * This is an Infoflow API constraint. This mode will not be modified until the service is upgraded.
213
+ */
214
+ export async function parseAndDispatchInfoflowRequest(
215
+ req: IncomingMessage,
216
+ rawBody: string,
217
+ targets: WebhookTarget[],
218
+ ): Promise<ParseResult> {
219
+ const verbose = targets[0]?.core?.logging?.shouldLogVerbose?.() ?? false;
220
+ const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
221
+
222
+ if (verbose) {
223
+ getInfoflowParseLog().debug?.(
224
+ `[infoflow] parseAndDispatch: contentType=${contentType}, bodyLen=${rawBody.length}`,
225
+ );
226
+ }
227
+
228
+ // --- form-urlencoded: echostr verification + private chat ---
229
+ if (contentType.startsWith("application/x-www-form-urlencoded")) {
230
+ const form = new URLSearchParams(rawBody);
231
+
232
+ // echostr signature verification (try all accounts' tokens for multi-account support)
233
+ const echostr = form.get("echostr") ?? "";
234
+ if (echostr) {
235
+ const signature = form.get("signature") ?? "";
236
+ const timestamp = form.get("timestamp") ?? "";
237
+ const rn = form.get("rn") ?? "";
238
+ for (const target of targets) {
239
+ const checkToken = target.account.config.checkToken ?? "";
240
+ if (!checkToken) continue;
241
+ const expectedSig = createHash("md5")
242
+ .update(`${rn}${timestamp}${checkToken}`)
243
+ .digest("hex");
244
+ if (
245
+ signature.length === expectedSig.length &&
246
+ timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))
247
+ ) {
248
+ return { handled: true, statusCode: 200, body: echostr };
249
+ }
250
+ }
251
+ getInfoflowParseLog().error(`[infoflow] echostr signature mismatch`);
252
+ return { handled: true, statusCode: 403, body: "Invalid signature" };
253
+ }
254
+
255
+ // private chat message (messageJson field in form)
256
+ const messageJsonStr = form.get("messageJson") ?? "";
257
+ if (messageJsonStr) {
258
+ return handlePrivateMessage(messageJsonStr, targets, verbose);
259
+ }
260
+
261
+ getInfoflowParseLog().error(`[infoflow] form-urlencoded but missing echostr or messageJson`);
262
+ return { handled: true, statusCode: 400, body: "missing echostr or messageJson" };
263
+ }
264
+
265
+ // --- text/plain: group chat ---
266
+ if (contentType.startsWith("text/plain")) {
267
+ return handleGroupMessage(rawBody, targets, verbose);
268
+ }
269
+
270
+ // --- unsupported Content-Type ---
271
+ getInfoflowParseLog().error(`[infoflow] unsupported contentType: ${contentType}`);
272
+ return { handled: true, statusCode: 400, body: "unsupported content type" };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Shared decrypt-and-dispatch helper
277
+ // ---------------------------------------------------------------------------
278
+
279
+ type DecryptDispatchParams = {
280
+ encryptedContent: string;
281
+ targets: WebhookTarget[];
282
+ chatType: "direct" | "group";
283
+ verbose: boolean;
284
+ fallbackParser?: (content: string) => Record<string, unknown> | null;
285
+ /** Async handler to process the decrypted message. Errors are caught internally. */
286
+ dispatchFn: (target: WebhookTarget, msgData: Record<string, unknown>) => Promise<void>;
287
+ };
288
+
289
+ /**
290
+ * Shared helper to decrypt message content and dispatch to handler.
291
+ * Iterates through accounts, attempts decryption, parses content, checks for duplicates.
292
+ * Dispatches asynchronously (fire-and-forget) with centralized error logging.
293
+ */
294
+ function tryDecryptAndDispatch(params: DecryptDispatchParams): ParseResult {
295
+ const { encryptedContent, targets, chatType, verbose, fallbackParser, dispatchFn } = params;
296
+
297
+ if (targets.length === 0) {
298
+ getInfoflowParseLog().error(`[infoflow] ${chatType}: no target configured`);
299
+ return { handled: true, statusCode: 500, body: "no target configured" };
300
+ }
301
+
302
+ if (!encryptedContent.trim()) {
303
+ getInfoflowParseLog().error(`[infoflow] ${chatType}: empty encrypted content`);
304
+ return { handled: true, statusCode: 400, body: "empty content" };
305
+ }
306
+
307
+ for (const target of targets) {
308
+ const { encodingAESKey } = target.account.config;
309
+ if (!encodingAESKey) continue;
310
+
311
+ let decryptedContent: string;
312
+ try {
313
+ decryptedContent = decryptMessage(encryptedContent, encodingAESKey);
314
+ } catch {
315
+ continue; // Try next account
316
+ }
317
+
318
+ // Parse as JSON first, then try fallback parser (XML for private)
319
+ let msgData: Record<string, unknown> | null = null;
320
+ try {
321
+ msgData = JSON.parse(decryptedContent) as Record<string, unknown>;
322
+ } catch {
323
+ if (fallbackParser) {
324
+ msgData = fallbackParser(decryptedContent);
325
+ }
326
+ }
327
+
328
+ if (msgData && Object.keys(msgData).length > 0) {
329
+ if (isDuplicateMessage(msgData)) {
330
+ if (verbose) {
331
+ getInfoflowParseLog().debug?.(`[infoflow] ${chatType}: duplicate message, skipping`);
332
+ }
333
+ return { handled: true, statusCode: 200, body: "success" };
334
+ }
335
+
336
+ target.statusSink?.({ lastInboundAt: Date.now() });
337
+
338
+ // Fire-and-forget with centralized error handling
339
+ void dispatchFn(target, msgData).catch((err) => {
340
+ getInfoflowParseLog().error(
341
+ `[infoflow] ${chatType} handler error: ${err instanceof Error ? err.message : String(err)}`,
342
+ );
343
+ });
344
+
345
+ if (verbose) {
346
+ getInfoflowParseLog().debug?.(`[infoflow] ${chatType}: message dispatched successfully`);
347
+ }
348
+ return { handled: true, statusCode: 200, body: "success" };
349
+ }
350
+ }
351
+
352
+ getInfoflowParseLog().error(`[infoflow] ${chatType}: decryption failed for all accounts`);
353
+ return { handled: true, statusCode: 500, body: "decryption failed for all accounts" };
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Private chat handler
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /**
361
+ * Handles a private (dm) chat message.
362
+ * Decrypts the Encrypt field with encodingAESKey (AES-ECB),
363
+ * parses the decrypted content, then dispatches to bot.ts.
364
+ */
365
+ function handlePrivateMessage(
366
+ messageJsonStr: string,
367
+ targets: WebhookTarget[],
368
+ verbose: boolean,
369
+ ): ParseResult {
370
+ let messageJson: Record<string, unknown>;
371
+ try {
372
+ messageJson = JSON.parse(messageJsonStr) as Record<string, unknown>;
373
+ } catch {
374
+ getInfoflowParseLog().error(`[infoflow] private: invalid messageJson`);
375
+ return { handled: true, statusCode: 400, body: "invalid messageJson" };
376
+ }
377
+
378
+ const encrypt = typeof messageJson.Encrypt === "string" ? messageJson.Encrypt : "";
379
+ if (!encrypt) {
380
+ getInfoflowParseLog().error(`[infoflow] private: missing Encrypt field`);
381
+ return { handled: true, statusCode: 400, body: "missing Encrypt field in messageJson" };
382
+ }
383
+
384
+ return tryDecryptAndDispatch({
385
+ encryptedContent: encrypt,
386
+ targets,
387
+ chatType: "direct",
388
+ verbose,
389
+ fallbackParser: parseXmlMessage,
390
+ dispatchFn: (target, msgData) =>
391
+ handlePrivateChatMessage({
392
+ cfg: target.config,
393
+ msgData,
394
+ accountId: target.account.accountId,
395
+ statusSink: target.statusSink,
396
+ }),
397
+ });
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // Group chat handler
402
+ // ---------------------------------------------------------------------------
403
+
404
+ /**
405
+ * Handles a group chat message.
406
+ * The rawBody itself is an AES-encrypted ciphertext (Base64URLSafe encoded).
407
+ * Decrypts and dispatches to bot.ts.
408
+ */
409
+ function handleGroupMessage(
410
+ rawBody: string,
411
+ targets: WebhookTarget[],
412
+ verbose: boolean,
413
+ ): ParseResult {
414
+ return tryDecryptAndDispatch({
415
+ encryptedContent: rawBody,
416
+ targets,
417
+ chatType: "group",
418
+ verbose,
419
+ dispatchFn: (target, msgData) =>
420
+ handleGroupChatMessage({
421
+ cfg: target.config,
422
+ msgData,
423
+ accountId: target.account.accountId,
424
+ statusSink: target.statusSink,
425
+ }),
426
+ });
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Test-only exports (@internal — not part of the public API)
431
+ // ---------------------------------------------------------------------------
432
+
433
+ /** @internal */
434
+ export const _extractDedupeKey = extractDedupeKey;
435
+
436
+ /** @internal */
437
+ export const _isDuplicateMessage = isDuplicateMessage;
438
+
439
+ /** @internal */
440
+ export const _base64UrlSafeDecode = base64UrlSafeDecode;
441
+
442
+ /** @internal */
443
+ export const _decryptMessage = decryptMessage;
444
+
445
+ /** @internal */
446
+ export const _parseXmlMessage = parseXmlMessage;
447
+
448
+ /** @internal — Clears the message dedup cache. Only use in tests. */
449
+ export function _resetMessageCache(): void {
450
+ messageCache.clear();
451
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Structured logging module for Infoflow extension.
3
+ * Provides consistent logging interface across all Infoflow modules.
4
+ */
5
+
6
+ import type { RuntimeLogger } from "openclaw/plugin-sdk";
7
+ import { getInfoflowRuntime } from "./runtime.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Logger Factory
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Creates a child logger with infoflow-specific bindings.
15
+ * Uses the PluginRuntime logging system for structured output.
16
+ */
17
+ function createInfoflowLogger(module?: string): RuntimeLogger {
18
+ const runtime = getInfoflowRuntime();
19
+ const bindings: Record<string, unknown> = { subsystem: "gateway/channels/infoflow" };
20
+ if (module) {
21
+ bindings.module = module;
22
+ }
23
+ return runtime.logging.getChildLogger(bindings);
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Module-specific Loggers (lazy initialization)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let _sendLog: RuntimeLogger | null = null;
31
+ let _webhookLog: RuntimeLogger | null = null;
32
+ let _botLog: RuntimeLogger | null = null;
33
+ let _parseLog: RuntimeLogger | null = null;
34
+
35
+ /**
36
+ * Logger for send operations (private/group message sending).
37
+ */
38
+ export function getInfoflowSendLog(): RuntimeLogger {
39
+ if (!_sendLog) {
40
+ _sendLog = createInfoflowLogger("send");
41
+ }
42
+ return _sendLog;
43
+ }
44
+
45
+ /**
46
+ * Logger for webhook/monitor operations.
47
+ */
48
+ export function getInfoflowWebhookLog(): RuntimeLogger {
49
+ if (!_webhookLog) {
50
+ _webhookLog = createInfoflowLogger("webhook");
51
+ }
52
+ return _webhookLog;
53
+ }
54
+
55
+ /**
56
+ * Logger for bot/message processing operations.
57
+ */
58
+ export function getInfoflowBotLog(): RuntimeLogger {
59
+ if (!_botLog) {
60
+ _botLog = createInfoflowLogger("bot");
61
+ }
62
+ return _botLog;
63
+ }
64
+
65
+ /**
66
+ * Logger for request parsing operations.
67
+ */
68
+ export function getInfoflowParseLog(): RuntimeLogger {
69
+ if (!_parseLog) {
70
+ _parseLog = createInfoflowLogger("parse");
71
+ }
72
+ return _parseLog;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Utility Functions
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export type FormatErrorOptions = {
80
+ /** Include stack trace in the output (default: false) */
81
+ includeStack?: boolean;
82
+ };
83
+
84
+ /**
85
+ * Format error message for logging.
86
+ * @param err - The error to format
87
+ * @param options - Formatting options
88
+ */
89
+ export function formatInfoflowError(err: unknown, options?: FormatErrorOptions): string {
90
+ if (err instanceof Error) {
91
+ if (options?.includeStack && err.stack) {
92
+ return err.stack;
93
+ }
94
+ return err.message;
95
+ }
96
+ return String(err);
97
+ }
98
+
99
+ export type LogApiErrorOptions = {
100
+ /** Logger to use (defaults to send logger) */
101
+ logger?: RuntimeLogger;
102
+ /** Include stack trace in the log (default: false) */
103
+ includeStack?: boolean;
104
+ };
105
+
106
+ /**
107
+ * Log an API error with operation context and structured metadata.
108
+ * @param operation - The API operation name (e.g., "sendPrivate", "getToken")
109
+ * @param error - The error to log
110
+ * @param options - Logging options
111
+ */
112
+ export function logInfoflowApiError(
113
+ operation: string,
114
+ error: unknown,
115
+ options?: LogApiErrorOptions | RuntimeLogger,
116
+ ): void {
117
+ // Support legacy signature: logInfoflowApiError(op, err, logger)
118
+ const opts: LogApiErrorOptions =
119
+ options && "error" in options ? { logger: options as RuntimeLogger } : (options ?? {});
120
+
121
+ const log = opts.logger ?? getInfoflowSendLog();
122
+ const errMsg = formatInfoflowError(error, { includeStack: opts.includeStack });
123
+
124
+ // Use structured meta for better log aggregation and filtering
125
+ log.error(`[infoflow:${operation}] ${errMsg}`, {
126
+ operation,
127
+ errorType: error instanceof Error ? error.constructor.name : typeof error,
128
+ });
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Test-only exports (@internal)
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /** @internal — Reset all cached loggers. Only use in tests. */
136
+ export function _resetLoggers(): void {
137
+ _sendLog = null;
138
+ _webhookLog = null;
139
+ _botLog = null;
140
+ _parseLog = null;
141
+ }