@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,394 @@
|
|
|
1
|
+
import { createHash, createDecipheriv, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createDedupeCache } from "openclaw/plugin-sdk/core";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Message deduplication
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
import { handlePrivateChatMessage, handleGroupChatMessage } from "./bot.js";
|
|
7
|
+
import { getInfoflowParseLog, formatInfoflowError, logVerbose } from "./logging.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Large-integer precision protection
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Infoflow message IDs (e.g. 1859713223686736431) exceed Number.MAX_SAFE_INTEGER (2^53-1).
|
|
12
|
+
// JSON.parse silently truncates these to imprecise values.
|
|
13
|
+
// We extract precise strings from raw JSON text via regex and patch the parsed object.
|
|
14
|
+
const ID_KEYS = ["messageid", "msgid", "MsgId", "msgkey"];
|
|
15
|
+
/**
|
|
16
|
+
* Patches large integer ID fields in `obj` with precise string values
|
|
17
|
+
* extracted from `rawText` via regex, bypassing JSON.parse precision loss.
|
|
18
|
+
* Only patches values with 16+ digits (smaller integers are safe).
|
|
19
|
+
*/
|
|
20
|
+
function patchPreciseIds(rawText, obj) {
|
|
21
|
+
for (const key of ID_KEYS) {
|
|
22
|
+
const re = new RegExp(`"${key}"\\s*:\\s*(\\d{16,})`, "g");
|
|
23
|
+
const preciseValues = [];
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = re.exec(rawText)) !== null) {
|
|
26
|
+
preciseValues.push(m[1]);
|
|
27
|
+
}
|
|
28
|
+
if (preciseValues.length > 0) {
|
|
29
|
+
patchField(obj, key, preciseValues, 0);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Recursively walks `obj` and replaces numeric fields named `key` with precise strings (in order). */
|
|
34
|
+
function patchField(obj, key, values, idx) {
|
|
35
|
+
if (obj == null || typeof obj !== "object")
|
|
36
|
+
return idx;
|
|
37
|
+
if (Array.isArray(obj)) {
|
|
38
|
+
for (const item of obj) {
|
|
39
|
+
idx = patchField(item, key, values, idx);
|
|
40
|
+
}
|
|
41
|
+
return idx;
|
|
42
|
+
}
|
|
43
|
+
const rec = obj;
|
|
44
|
+
if (key in rec && typeof rec[key] === "number" && idx < values.length) {
|
|
45
|
+
rec[key] = values[idx++];
|
|
46
|
+
}
|
|
47
|
+
for (const v of Object.values(rec)) {
|
|
48
|
+
if (v != null && typeof v === "object") {
|
|
49
|
+
idx = patchField(v, key, values, idx);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return idx;
|
|
53
|
+
}
|
|
54
|
+
const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
55
|
+
const DEDUP_MAX_SIZE = 1000;
|
|
56
|
+
const messageCache = createDedupeCache({
|
|
57
|
+
ttlMs: DEDUP_TTL_MS,
|
|
58
|
+
maxSize: DEDUP_MAX_SIZE,
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Extracts a dedup key from the decrypted message data.
|
|
62
|
+
*
|
|
63
|
+
* Priority:
|
|
64
|
+
* 1. message.header.messageid || message.header.msgid || MsgId
|
|
65
|
+
* 2. fallback: "{fromuserid}_{groupid}_{ctime}"
|
|
66
|
+
*/
|
|
67
|
+
function extractDedupeKey(msgData) {
|
|
68
|
+
const message = msgData.message;
|
|
69
|
+
const header = (message?.header ?? {});
|
|
70
|
+
// Priority 1: explicit message ID
|
|
71
|
+
const msgId = header.messageid ?? header.msgid ?? msgData.MsgId;
|
|
72
|
+
if (msgId != null) {
|
|
73
|
+
return String(msgId);
|
|
74
|
+
}
|
|
75
|
+
// Priority 2: composite key
|
|
76
|
+
const fromuserid = header.fromuserid ?? msgData.FromUserId ?? msgData.fromuserid;
|
|
77
|
+
const groupid = msgData.groupid ?? header.groupid;
|
|
78
|
+
const ctime = header.ctime ?? Date.now();
|
|
79
|
+
if (fromuserid != null) {
|
|
80
|
+
return `${fromuserid}_${groupid ?? "dm"}_${ctime}`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Returns true if the message is a duplicate (already seen within TTL).
|
|
86
|
+
* Uses shared dedupe cache implementation.
|
|
87
|
+
*/
|
|
88
|
+
export function isDuplicateMessage(msgData) {
|
|
89
|
+
const key = extractDedupeKey(msgData);
|
|
90
|
+
if (!key)
|
|
91
|
+
return false; // Cannot extract key, allow through
|
|
92
|
+
return messageCache.check(key);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Records a sent message ID in the dedup cache.
|
|
96
|
+
* Called after successfully sending a message to prevent
|
|
97
|
+
* the bot from processing its own outbound messages as inbound.
|
|
98
|
+
*/
|
|
99
|
+
export function recordSentMessageId(messageId) {
|
|
100
|
+
if (messageId == null)
|
|
101
|
+
return;
|
|
102
|
+
messageCache.check(messageId);
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// AES-ECB Decryption Utilities
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/**
|
|
108
|
+
* Decodes a Base64 URLSafe encoded string to a Buffer.
|
|
109
|
+
* Handles the URL-safe alphabet (- → +, _ → /) and auto-pads with '='.
|
|
110
|
+
*/
|
|
111
|
+
function base64UrlSafeDecode(s) {
|
|
112
|
+
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
113
|
+
const padLen = (4 - (base64.length % 4)) % 4;
|
|
114
|
+
return Buffer.from(base64 + "=".repeat(padLen), "base64");
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Decrypts an AES-ECB encrypted message.
|
|
118
|
+
* @param encryptedMsg - Base64 URLSafe encoded ciphertext
|
|
119
|
+
* @param encodingAESKey - Base64 URLSafe encoded AES key (supports 16/24/32 byte keys)
|
|
120
|
+
* @returns Decrypted UTF-8 string
|
|
121
|
+
*/
|
|
122
|
+
function decryptMessage(encryptedMsg, encodingAESKey) {
|
|
123
|
+
const aesKey = base64UrlSafeDecode(encodingAESKey);
|
|
124
|
+
const cipherText = base64UrlSafeDecode(encryptedMsg);
|
|
125
|
+
// Select AES algorithm based on key length
|
|
126
|
+
let algorithm;
|
|
127
|
+
switch (aesKey.length) {
|
|
128
|
+
case 16:
|
|
129
|
+
algorithm = "aes-128-ecb";
|
|
130
|
+
break;
|
|
131
|
+
case 24:
|
|
132
|
+
algorithm = "aes-192-ecb";
|
|
133
|
+
break;
|
|
134
|
+
case 32:
|
|
135
|
+
algorithm = "aes-256-ecb";
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
throw new Error(`Invalid AES key length: ${aesKey.length} bytes (expected 16, 24, or 32)`);
|
|
139
|
+
}
|
|
140
|
+
// ECB mode does not use an IV (pass null)
|
|
141
|
+
const decipher = createDecipheriv(algorithm, aesKey, null);
|
|
142
|
+
const decrypted = Buffer.concat([decipher.update(cipherText), decipher.final()]);
|
|
143
|
+
return decrypted.toString("utf8");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parses an XML message string into a key-value object.
|
|
147
|
+
* Handles simple XML structures like <xml><Tag>value</Tag></xml>.
|
|
148
|
+
*/
|
|
149
|
+
function parseXmlMessage(xmlString) {
|
|
150
|
+
try {
|
|
151
|
+
const result = {};
|
|
152
|
+
// Match <TagName>content</TagName> patterns
|
|
153
|
+
const tagRegex = /<(\w+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
|
|
154
|
+
let match;
|
|
155
|
+
while ((match = tagRegex.exec(xmlString)) !== null) {
|
|
156
|
+
const tagName = match[1];
|
|
157
|
+
// CDATA content or plain text content
|
|
158
|
+
const content = match[2] ?? match[3] ?? "";
|
|
159
|
+
result[tagName] = content.trim();
|
|
160
|
+
}
|
|
161
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Body readers
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
const MAX_BODY_SIZE = 20 * 1024 * 1024; // 20 MB
|
|
171
|
+
/** Load raw body as a string from the request stream; enforces max size. */
|
|
172
|
+
export async function loadRawBody(req, maxBytes = MAX_BODY_SIZE) {
|
|
173
|
+
const chunks = [];
|
|
174
|
+
let total = 0;
|
|
175
|
+
let done = false;
|
|
176
|
+
return await new Promise((resolve) => {
|
|
177
|
+
req.on("data", (chunk) => {
|
|
178
|
+
if (done)
|
|
179
|
+
return;
|
|
180
|
+
total += chunk.length;
|
|
181
|
+
if (total > maxBytes) {
|
|
182
|
+
done = true;
|
|
183
|
+
resolve({ ok: false, error: "payload too large" });
|
|
184
|
+
req.destroy();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
chunks.push(chunk);
|
|
188
|
+
});
|
|
189
|
+
req.on("end", () => {
|
|
190
|
+
if (done)
|
|
191
|
+
return;
|
|
192
|
+
done = true;
|
|
193
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
194
|
+
resolve({ ok: true, raw });
|
|
195
|
+
});
|
|
196
|
+
req.on("error", (err) => {
|
|
197
|
+
if (done)
|
|
198
|
+
return;
|
|
199
|
+
done = true;
|
|
200
|
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Public API
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
/**
|
|
208
|
+
* Parses and dispatches an incoming Infoflow webhook request.
|
|
209
|
+
*
|
|
210
|
+
* 1. Parses Content-Type header once.
|
|
211
|
+
* 2. form-urlencoded:
|
|
212
|
+
* - echostr present → signature verification → 200/403
|
|
213
|
+
* - messageJson present → private chat (dm) handling
|
|
214
|
+
* 3. text/plain → group chat handling (raw body is encrypted ciphertext)
|
|
215
|
+
* 4. Other Content-Type → 400
|
|
216
|
+
*
|
|
217
|
+
* Returns a ParseResult indicating whether the request was handled and the response to send.
|
|
218
|
+
*
|
|
219
|
+
* NOTE: Only echostr has signature verification; message webhooks use AES-ECB mode.
|
|
220
|
+
* This is an Infoflow API constraint. This mode will not be modified until the service is upgraded.
|
|
221
|
+
*/
|
|
222
|
+
export async function parseAndDispatchInfoflowRequest(req, rawBody, targets) {
|
|
223
|
+
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
|
|
224
|
+
logVerbose(`[infoflow] parseAndDispatch: contentType=${contentType}, bodyLen=${rawBody.length}`);
|
|
225
|
+
// --- form-urlencoded: echostr verification + private chat ---
|
|
226
|
+
if (contentType.startsWith("application/x-www-form-urlencoded")) {
|
|
227
|
+
const form = new URLSearchParams(rawBody);
|
|
228
|
+
// echostr signature verification (try all accounts' tokens for multi-account support)
|
|
229
|
+
const echostr = form.get("echostr") ?? "";
|
|
230
|
+
if (echostr) {
|
|
231
|
+
const signature = form.get("signature") ?? "";
|
|
232
|
+
const timestamp = form.get("timestamp") ?? "";
|
|
233
|
+
const rn = form.get("rn") ?? "";
|
|
234
|
+
for (const target of targets) {
|
|
235
|
+
const checkToken = target.account.config.checkToken ?? "";
|
|
236
|
+
if (!checkToken)
|
|
237
|
+
continue;
|
|
238
|
+
const expectedSig = createHash("md5")
|
|
239
|
+
.update(`${rn}${timestamp}${checkToken}`)
|
|
240
|
+
.digest("hex");
|
|
241
|
+
if (signature.length === expectedSig.length &&
|
|
242
|
+
timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
|
|
243
|
+
return { handled: true, statusCode: 200, body: echostr };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
getInfoflowParseLog().error(`[infoflow] echostr signature mismatch (tried ${targets.length} account(s))`);
|
|
247
|
+
return { handled: true, statusCode: 403, body: "Invalid signature" };
|
|
248
|
+
}
|
|
249
|
+
// private chat message (messageJson field in form)
|
|
250
|
+
const messageJsonStr = form.get("messageJson") ?? "";
|
|
251
|
+
if (messageJsonStr) {
|
|
252
|
+
return handlePrivateMessage(messageJsonStr, targets);
|
|
253
|
+
}
|
|
254
|
+
getInfoflowParseLog().error(`[infoflow] form-urlencoded but missing echostr or messageJson`);
|
|
255
|
+
return { handled: true, statusCode: 400, body: "missing echostr or messageJson" };
|
|
256
|
+
}
|
|
257
|
+
// --- text/plain: group chat ---
|
|
258
|
+
if (contentType.startsWith("text/plain")) {
|
|
259
|
+
return handleGroupMessage(rawBody, targets);
|
|
260
|
+
}
|
|
261
|
+
// --- unsupported Content-Type ---
|
|
262
|
+
getInfoflowParseLog().error(`[infoflow] unsupported contentType: ${contentType}`);
|
|
263
|
+
return { handled: true, statusCode: 400, body: "unsupported content type" };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Shared helper to decrypt message content and dispatch to handler.
|
|
267
|
+
* Iterates through accounts, attempts decryption, parses content, checks for duplicates.
|
|
268
|
+
* Dispatches asynchronously (fire-and-forget) with centralized error logging.
|
|
269
|
+
*/
|
|
270
|
+
function tryDecryptAndDispatch(params) {
|
|
271
|
+
const { encryptedContent, targets, chatType, fallbackParser, dispatchFn } = params;
|
|
272
|
+
if (targets.length === 0) {
|
|
273
|
+
getInfoflowParseLog().error(`[infoflow] ${chatType}: no target configured`);
|
|
274
|
+
return { handled: true, statusCode: 500, body: "no target configured" };
|
|
275
|
+
}
|
|
276
|
+
if (!encryptedContent.trim()) {
|
|
277
|
+
getInfoflowParseLog().error(`[infoflow] ${chatType}: empty encrypted content`);
|
|
278
|
+
return { handled: true, statusCode: 400, body: "empty content" };
|
|
279
|
+
}
|
|
280
|
+
for (const target of targets) {
|
|
281
|
+
const { encodingAESKey } = target.account.config;
|
|
282
|
+
if (!encodingAESKey)
|
|
283
|
+
continue;
|
|
284
|
+
let decryptedContent;
|
|
285
|
+
try {
|
|
286
|
+
decryptedContent = decryptMessage(encryptedContent, encodingAESKey);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
continue; // Try next account
|
|
290
|
+
}
|
|
291
|
+
logVerbose(`[infoflow] ${chatType}: decryptedContent=(${decryptedContent})`);
|
|
292
|
+
// Parse as JSON first, then try fallback parser (XML for private)
|
|
293
|
+
let msgData = null;
|
|
294
|
+
try {
|
|
295
|
+
msgData = JSON.parse(decryptedContent);
|
|
296
|
+
patchPreciseIds(decryptedContent, msgData);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
if (fallbackParser) {
|
|
300
|
+
msgData = fallbackParser(decryptedContent);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (msgData && Object.keys(msgData).length > 0) {
|
|
304
|
+
if (isDuplicateMessage(msgData)) {
|
|
305
|
+
logVerbose(`[infoflow] ${chatType}: duplicate message, skipping`);
|
|
306
|
+
return { handled: true, statusCode: 200, body: "success" };
|
|
307
|
+
}
|
|
308
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
309
|
+
// Fire-and-forget with centralized error handling
|
|
310
|
+
void dispatchFn(target, msgData).catch((err) => {
|
|
311
|
+
getInfoflowParseLog().error(`[infoflow] ${chatType} handler error: ${formatInfoflowError(err)}`);
|
|
312
|
+
});
|
|
313
|
+
return { handled: true, statusCode: 200, body: "success" };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
getInfoflowParseLog().error(`[infoflow] ${chatType}: decryption failed for all ${targets.length} account(s)`);
|
|
317
|
+
return { handled: true, statusCode: 500, body: "decryption failed for all accounts" };
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Private chat handler
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
/**
|
|
323
|
+
* Handles a private (dm) chat message.
|
|
324
|
+
* Decrypts the Encrypt field with encodingAESKey (AES-ECB),
|
|
325
|
+
* parses the decrypted content, then dispatches to bot.ts.
|
|
326
|
+
*/
|
|
327
|
+
function handlePrivateMessage(messageJsonStr, targets) {
|
|
328
|
+
let messageJson;
|
|
329
|
+
try {
|
|
330
|
+
messageJson = JSON.parse(messageJsonStr);
|
|
331
|
+
patchPreciseIds(messageJsonStr, messageJson);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
getInfoflowParseLog().error(`[infoflow] private: invalid messageJson`);
|
|
335
|
+
return { handled: true, statusCode: 400, body: "invalid messageJson" };
|
|
336
|
+
}
|
|
337
|
+
const encrypt = typeof messageJson.Encrypt === "string" ? messageJson.Encrypt : "";
|
|
338
|
+
if (!encrypt) {
|
|
339
|
+
getInfoflowParseLog().error(`[infoflow] private: missing Encrypt field`);
|
|
340
|
+
return { handled: true, statusCode: 400, body: "missing Encrypt field in messageJson" };
|
|
341
|
+
}
|
|
342
|
+
return tryDecryptAndDispatch({
|
|
343
|
+
encryptedContent: encrypt,
|
|
344
|
+
targets,
|
|
345
|
+
chatType: "direct",
|
|
346
|
+
fallbackParser: parseXmlMessage,
|
|
347
|
+
dispatchFn: (target, msgData) => handlePrivateChatMessage({
|
|
348
|
+
cfg: target.config,
|
|
349
|
+
msgData,
|
|
350
|
+
accountId: target.account.accountId,
|
|
351
|
+
statusSink: target.statusSink,
|
|
352
|
+
}),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Group chat handler
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
/**
|
|
359
|
+
* Handles a group chat message.
|
|
360
|
+
* The rawBody itself is an AES-encrypted ciphertext (Base64URLSafe encoded).
|
|
361
|
+
* Decrypts and dispatches to bot.ts.
|
|
362
|
+
*/
|
|
363
|
+
function handleGroupMessage(rawBody, targets) {
|
|
364
|
+
return tryDecryptAndDispatch({
|
|
365
|
+
encryptedContent: rawBody,
|
|
366
|
+
targets,
|
|
367
|
+
chatType: "group",
|
|
368
|
+
dispatchFn: (target, msgData) => handleGroupChatMessage({
|
|
369
|
+
cfg: target.config,
|
|
370
|
+
msgData,
|
|
371
|
+
accountId: target.account.accountId,
|
|
372
|
+
statusSink: target.statusSink,
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Test-only exports (@internal — not part of the public API)
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
/** @internal */
|
|
380
|
+
export const _extractDedupeKey = extractDedupeKey;
|
|
381
|
+
/** @internal */
|
|
382
|
+
export const _isDuplicateMessage = isDuplicateMessage;
|
|
383
|
+
/** @internal */
|
|
384
|
+
export const _base64UrlSafeDecode = base64UrlSafeDecode;
|
|
385
|
+
/** @internal */
|
|
386
|
+
export const _decryptMessage = decryptMessage;
|
|
387
|
+
/** @internal */
|
|
388
|
+
export const _parseXmlMessage = parseXmlMessage;
|
|
389
|
+
/** @internal — Clears the message dedup cache. Only use in tests. */
|
|
390
|
+
export function _resetMessageCache() {
|
|
391
|
+
messageCache.clear();
|
|
392
|
+
}
|
|
393
|
+
/** @internal */
|
|
394
|
+
export const _patchPreciseIds = patchPreciseIds;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging module for Infoflow extension.
|
|
3
|
+
* Provides consistent logging interface across all Infoflow modules.
|
|
4
|
+
*/
|
|
5
|
+
import { getInfoflowRuntime } from "./runtime.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Logger Factory
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Creates a child logger with infoflow-specific bindings.
|
|
11
|
+
* Uses the PluginRuntime logging system for structured output.
|
|
12
|
+
*/
|
|
13
|
+
function createInfoflowLogger(module) {
|
|
14
|
+
const runtime = getInfoflowRuntime();
|
|
15
|
+
const bindings = { subsystem: "gateway/channels/infoflow" };
|
|
16
|
+
if (module) {
|
|
17
|
+
bindings.module = module;
|
|
18
|
+
}
|
|
19
|
+
return runtime.logging.getChildLogger(bindings);
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Module-specific Loggers (lazy initialization)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
let _sendLog = null;
|
|
25
|
+
let _webhookLog = null;
|
|
26
|
+
let _botLog = null;
|
|
27
|
+
let _parseLog = null;
|
|
28
|
+
/**
|
|
29
|
+
* Logger for send operations (private/group message sending).
|
|
30
|
+
*/
|
|
31
|
+
export function getInfoflowSendLog() {
|
|
32
|
+
if (!_sendLog) {
|
|
33
|
+
_sendLog = createInfoflowLogger("send");
|
|
34
|
+
}
|
|
35
|
+
return _sendLog;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Logger for webhook/monitor operations.
|
|
39
|
+
*/
|
|
40
|
+
export function getInfoflowWebhookLog() {
|
|
41
|
+
if (!_webhookLog) {
|
|
42
|
+
_webhookLog = createInfoflowLogger("webhook");
|
|
43
|
+
}
|
|
44
|
+
return _webhookLog;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Logger for bot/message processing operations.
|
|
48
|
+
*/
|
|
49
|
+
export function getInfoflowBotLog() {
|
|
50
|
+
if (!_botLog) {
|
|
51
|
+
_botLog = createInfoflowLogger("bot");
|
|
52
|
+
}
|
|
53
|
+
return _botLog;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Logger for request parsing operations.
|
|
57
|
+
*/
|
|
58
|
+
export function getInfoflowParseLog() {
|
|
59
|
+
if (!_parseLog) {
|
|
60
|
+
_parseLog = createInfoflowLogger("parse");
|
|
61
|
+
}
|
|
62
|
+
return _parseLog;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Format error message for logging.
|
|
66
|
+
* @param err - The error to format
|
|
67
|
+
* @param options - Formatting options
|
|
68
|
+
*/
|
|
69
|
+
export function formatInfoflowError(err, options) {
|
|
70
|
+
if (err instanceof Error) {
|
|
71
|
+
if (options?.includeStack && err.stack) {
|
|
72
|
+
return err.stack;
|
|
73
|
+
}
|
|
74
|
+
return err.message;
|
|
75
|
+
}
|
|
76
|
+
return String(err);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Log a message when verbose mode is enabled.
|
|
80
|
+
* Checks shouldLogVerbose() via PluginRuntime, then writes to console for
|
|
81
|
+
* --verbose terminal output. Safe to call before runtime is initialized.
|
|
82
|
+
*/
|
|
83
|
+
export function logVerbose(message) {
|
|
84
|
+
try {
|
|
85
|
+
if (!getInfoflowRuntime().logging.shouldLogVerbose())
|
|
86
|
+
return;
|
|
87
|
+
console.log(message);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// runtime not available, skip verbose logging
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Test-only exports (@internal)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
/** @internal — Reset all cached loggers. Only use in tests. */
|
|
97
|
+
export function _resetLoggers() {
|
|
98
|
+
_sendLog = null;
|
|
99
|
+
_webhookLog = null;
|
|
100
|
+
_botLog = null;
|
|
101
|
+
_parseLog = null;
|
|
102
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses markdown for local image links and splits content into ordered segments
|
|
3
|
+
* (text or image URL) so the channel can send text + image + text as separate messages.
|
|
4
|
+
*/
|
|
5
|
+
function isLocalPath(url) {
|
|
6
|
+
const trimmed = url.trim();
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
return false;
|
|
9
|
+
return (trimmed.startsWith("/") ||
|
|
10
|
+
trimmed.startsWith("./") ||
|
|
11
|
+
trimmed.startsWith("../") ||
|
|
12
|
+
trimmed.startsWith("~") ||
|
|
13
|
+
trimmed.startsWith("file://"));
|
|
14
|
+
}
|
|
15
|
+
/** Markdown image  and link [label](url) – capture URL from both */
|
|
16
|
+
const MARKDOWN_IMAGE_OR_LINK_RE = /!?\[[^\]]*\]\(([^)]+)\)/g;
|
|
17
|
+
/**
|
|
18
|
+
* Splits markdown into ordered segments. Local image URLs (including file://) are
|
|
19
|
+
* extracted so they can be sent as native image messages; surrounding text is kept in order.
|
|
20
|
+
* - If the whole input is a single line that looks like a local path, returns one image segment.
|
|
21
|
+
* - Otherwise finds  and [label](url); when url is local, produces text + image + text segments.
|
|
22
|
+
*/
|
|
23
|
+
export function parseMarkdownForLocalImages(text) {
|
|
24
|
+
const trimmed = text.trimEnd();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return [{ type: "text", content: text }];
|
|
27
|
+
}
|
|
28
|
+
// Single line that is a local path: treat entire content as one image
|
|
29
|
+
if (!trimmed.includes("\n")) {
|
|
30
|
+
if (isLocalPath(trimmed)) {
|
|
31
|
+
return [{ type: "image", content: trimmed }];
|
|
32
|
+
}
|
|
33
|
+
// Backtick-wrapped path e.g. `/tmp/foo.png` → treat as image
|
|
34
|
+
const backtickMatch = trimmed.match(/^`([^`]+)`$/);
|
|
35
|
+
if (backtickMatch && isLocalPath(backtickMatch[1].trim())) {
|
|
36
|
+
return [{ type: "image", content: backtickMatch[1].trim() }];
|
|
37
|
+
}
|
|
38
|
+
// Angle-bracket-wrapped path e.g. <file:///tmp/foo.png> → treat as image
|
|
39
|
+
const angleMatch = trimmed.match(/^<([^>]+)>$/);
|
|
40
|
+
if (angleMatch && isLocalPath(angleMatch[1].trim())) {
|
|
41
|
+
return [{ type: "image", content: angleMatch[1].trim() }];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const segments = [];
|
|
45
|
+
let lastIndex = 0;
|
|
46
|
+
const re = new RegExp(MARKDOWN_IMAGE_OR_LINK_RE.source, "g");
|
|
47
|
+
let match;
|
|
48
|
+
while ((match = re.exec(text)) !== null) {
|
|
49
|
+
const url = match[1].trim();
|
|
50
|
+
if (!isLocalPath(url))
|
|
51
|
+
continue;
|
|
52
|
+
if (match.index > lastIndex) {
|
|
53
|
+
segments.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
|
54
|
+
}
|
|
55
|
+
segments.push({ type: "image", content: url });
|
|
56
|
+
lastIndex = re.lastIndex;
|
|
57
|
+
}
|
|
58
|
+
if (segments.length === 0) {
|
|
59
|
+
return [{ type: "text", content: text }];
|
|
60
|
+
}
|
|
61
|
+
if (lastIndex < text.length) {
|
|
62
|
+
segments.push({ type: "text", content: text.slice(lastIndex) });
|
|
63
|
+
}
|
|
64
|
+
return segments;
|
|
65
|
+
}
|