@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,433 @@
|
|
|
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 "../../handler/message-handler.js";
|
|
9
|
+
import type { ResolvedInfoflowAccount } from "../../types.js";
|
|
10
|
+
import { getInfoflowParseLog, formatInfoflowError, logVerbose } 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
|
+
* Exported for use by ws-receiver (WebSocket mode also needs dedup).
|
|
52
|
+
*/
|
|
53
|
+
export function isDuplicateMessage(msgData: Record<string, unknown>): boolean {
|
|
54
|
+
const key = extractDedupeKey(msgData);
|
|
55
|
+
if (!key) return false; // Cannot extract key, allow through
|
|
56
|
+
|
|
57
|
+
return messageCache.check(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Records a sent message ID in the dedup cache.
|
|
62
|
+
* Called after successfully sending a message to prevent
|
|
63
|
+
* the bot from processing its own outbound messages as inbound.
|
|
64
|
+
*/
|
|
65
|
+
export function recordSentMessageId(messageId: string | null): void {
|
|
66
|
+
if (messageId == null) return;
|
|
67
|
+
messageCache.check(messageId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// AES-ECB Decryption Utilities
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decodes a Base64 URLSafe encoded string to a Buffer.
|
|
76
|
+
* Handles the URL-safe alphabet (- → +, _ → /) and auto-pads with '='.
|
|
77
|
+
*/
|
|
78
|
+
function base64UrlSafeDecode(s: string): Buffer {
|
|
79
|
+
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
80
|
+
const padLen = (4 - (base64.length % 4)) % 4;
|
|
81
|
+
return Buffer.from(base64 + "=".repeat(padLen), "base64");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decrypts an AES-ECB encrypted message.
|
|
86
|
+
* @param encryptedMsg - Base64 URLSafe encoded ciphertext
|
|
87
|
+
* @param encodingAESKey - Base64 URLSafe encoded AES key (supports 16/24/32 byte keys)
|
|
88
|
+
* @returns Decrypted UTF-8 string
|
|
89
|
+
*/
|
|
90
|
+
function decryptMessage(encryptedMsg: string, encodingAESKey: string): string {
|
|
91
|
+
const aesKey = base64UrlSafeDecode(encodingAESKey);
|
|
92
|
+
const cipherText = base64UrlSafeDecode(encryptedMsg);
|
|
93
|
+
|
|
94
|
+
// Select AES algorithm based on key length
|
|
95
|
+
let algorithm: string;
|
|
96
|
+
switch (aesKey.length) {
|
|
97
|
+
case 16:
|
|
98
|
+
algorithm = "aes-128-ecb";
|
|
99
|
+
break;
|
|
100
|
+
case 24:
|
|
101
|
+
algorithm = "aes-192-ecb";
|
|
102
|
+
break;
|
|
103
|
+
case 32:
|
|
104
|
+
algorithm = "aes-256-ecb";
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
throw new Error(`Invalid AES key length: ${aesKey.length} bytes (expected 16, 24, or 32)`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ECB mode does not use an IV (pass null)
|
|
111
|
+
const decipher = createDecipheriv(algorithm, aesKey, null);
|
|
112
|
+
const decrypted = Buffer.concat([decipher.update(cipherText), decipher.final()]);
|
|
113
|
+
return decrypted.toString("utf8");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parses an XML message string into a key-value object.
|
|
118
|
+
* Handles simple XML structures like <xml><Tag>value</Tag></xml>.
|
|
119
|
+
*/
|
|
120
|
+
function parseXmlMessage(xmlString: string): Record<string, string> | null {
|
|
121
|
+
try {
|
|
122
|
+
const result: Record<string, string> = {};
|
|
123
|
+
// Match <TagName>content</TagName> patterns
|
|
124
|
+
const tagRegex = /<(\w+)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
|
|
125
|
+
let match;
|
|
126
|
+
while ((match = tagRegex.exec(xmlString)) !== null) {
|
|
127
|
+
const tagName = match[1];
|
|
128
|
+
// CDATA content or plain text content
|
|
129
|
+
const content = match[2] ?? match[3] ?? "";
|
|
130
|
+
result[tagName] = content.trim();
|
|
131
|
+
}
|
|
132
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Types
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
type InfoflowCoreRuntime = Record<string, unknown>;
|
|
143
|
+
|
|
144
|
+
export type WebhookTarget = {
|
|
145
|
+
account: ResolvedInfoflowAccount;
|
|
146
|
+
config: OpenClawConfig;
|
|
147
|
+
core: InfoflowCoreRuntime;
|
|
148
|
+
path: string;
|
|
149
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type ParseResult = { handled: true; statusCode: number; body: string } | { handled: false };
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Body readers
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const MAX_BODY_SIZE = 20 * 1024 * 1024; // 20 MB
|
|
159
|
+
|
|
160
|
+
/** Load raw body as a string from the request stream; enforces max size. */
|
|
161
|
+
export async function loadRawBody(
|
|
162
|
+
req: IncomingMessage,
|
|
163
|
+
maxBytes = MAX_BODY_SIZE,
|
|
164
|
+
): Promise<{ ok: true; raw: string } | { ok: false; error: string }> {
|
|
165
|
+
const chunks: Buffer[] = [];
|
|
166
|
+
let total = 0;
|
|
167
|
+
let done = false;
|
|
168
|
+
return await new Promise((resolve) => {
|
|
169
|
+
req.on("data", (chunk: Buffer) => {
|
|
170
|
+
if (done) return;
|
|
171
|
+
total += chunk.length;
|
|
172
|
+
if (total > maxBytes) {
|
|
173
|
+
done = true;
|
|
174
|
+
resolve({ ok: false, error: "payload too large" });
|
|
175
|
+
req.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
chunks.push(chunk);
|
|
179
|
+
});
|
|
180
|
+
req.on("end", () => {
|
|
181
|
+
if (done) return;
|
|
182
|
+
done = true;
|
|
183
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
184
|
+
resolve({ ok: true, raw });
|
|
185
|
+
});
|
|
186
|
+
req.on("error", (err) => {
|
|
187
|
+
if (done) return;
|
|
188
|
+
done = true;
|
|
189
|
+
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Public API
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Parses and dispatches an incoming Infoflow webhook request.
|
|
200
|
+
*
|
|
201
|
+
* 1. Parses Content-Type header once.
|
|
202
|
+
* 2. form-urlencoded:
|
|
203
|
+
* - echostr present → signature verification → 200/403
|
|
204
|
+
* - messageJson present → private chat (dm) handling
|
|
205
|
+
* 3. text/plain → group chat handling (raw body is encrypted ciphertext)
|
|
206
|
+
* 4. Other Content-Type → 400
|
|
207
|
+
*
|
|
208
|
+
* Returns a ParseResult indicating whether the request was handled and the response to send.
|
|
209
|
+
*
|
|
210
|
+
* NOTE: Only echostr has signature verification; message webhooks use AES-ECB mode.
|
|
211
|
+
* This is an Infoflow API constraint. This mode will not be modified until the service is upgraded.
|
|
212
|
+
*/
|
|
213
|
+
export async function parseAndDispatchInfoflowRequest(
|
|
214
|
+
req: IncomingMessage,
|
|
215
|
+
rawBody: string,
|
|
216
|
+
targets: WebhookTarget[],
|
|
217
|
+
): Promise<ParseResult> {
|
|
218
|
+
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
|
|
219
|
+
|
|
220
|
+
logVerbose(`[infoflow] parseAndDispatch: contentType=${contentType}, bodyLen=${rawBody.length}`);
|
|
221
|
+
|
|
222
|
+
// --- form-urlencoded: echostr verification + private chat ---
|
|
223
|
+
if (contentType.startsWith("application/x-www-form-urlencoded")) {
|
|
224
|
+
const form = new URLSearchParams(rawBody);
|
|
225
|
+
|
|
226
|
+
// echostr signature verification (try all accounts' tokens for multi-account support)
|
|
227
|
+
const echostr = form.get("echostr") ?? "";
|
|
228
|
+
if (echostr) {
|
|
229
|
+
const signature = form.get("signature") ?? "";
|
|
230
|
+
const timestamp = form.get("timestamp") ?? "";
|
|
231
|
+
const rn = form.get("rn") ?? "";
|
|
232
|
+
for (const target of targets) {
|
|
233
|
+
const checkToken = target.account.config.checkToken ?? "";
|
|
234
|
+
if (!checkToken) continue;
|
|
235
|
+
const expectedSig = createHash("md5")
|
|
236
|
+
.update(`${rn}${timestamp}${checkToken}`)
|
|
237
|
+
.digest("hex");
|
|
238
|
+
if (
|
|
239
|
+
signature.length === expectedSig.length &&
|
|
240
|
+
timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))
|
|
241
|
+
) {
|
|
242
|
+
return { handled: true, statusCode: 200, body: echostr };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
getInfoflowParseLog().error(
|
|
246
|
+
`[infoflow] echostr signature mismatch (tried ${targets.length} account(s))`,
|
|
247
|
+
);
|
|
248
|
+
return { handled: true, statusCode: 403, body: "Invalid signature" };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// private chat message (messageJson field in form)
|
|
252
|
+
const messageJsonStr = form.get("messageJson") ?? "";
|
|
253
|
+
if (messageJsonStr) {
|
|
254
|
+
return handlePrivateMessage(messageJsonStr, targets);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
getInfoflowParseLog().error(`[infoflow] form-urlencoded but missing echostr or messageJson`);
|
|
258
|
+
return { handled: true, statusCode: 400, body: "missing echostr or messageJson" };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- text/plain: group chat ---
|
|
262
|
+
if (contentType.startsWith("text/plain")) {
|
|
263
|
+
return handleGroupMessage(rawBody, targets);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- unsupported Content-Type ---
|
|
267
|
+
getInfoflowParseLog().error(`[infoflow] unsupported contentType: ${contentType}`);
|
|
268
|
+
return { handled: true, statusCode: 400, body: "unsupported content type" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Shared decrypt-and-dispatch helper
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
type DecryptDispatchParams = {
|
|
276
|
+
encryptedContent: string;
|
|
277
|
+
targets: WebhookTarget[];
|
|
278
|
+
chatType: "direct" | "group";
|
|
279
|
+
fallbackParser?: (content: string) => Record<string, unknown> | null;
|
|
280
|
+
/** Async handler to process the decrypted message. Errors are caught internally. */
|
|
281
|
+
dispatchFn: (target: WebhookTarget, msgData: Record<string, unknown>) => Promise<void>;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Shared helper to decrypt message content and dispatch to handler.
|
|
286
|
+
* Iterates through accounts, attempts decryption, parses content, checks for duplicates.
|
|
287
|
+
* Dispatches asynchronously (fire-and-forget) with centralized error logging.
|
|
288
|
+
*/
|
|
289
|
+
function tryDecryptAndDispatch(params: DecryptDispatchParams): ParseResult {
|
|
290
|
+
const { encryptedContent, targets, chatType, fallbackParser, dispatchFn } = params;
|
|
291
|
+
|
|
292
|
+
if (targets.length === 0) {
|
|
293
|
+
getInfoflowParseLog().error(`[infoflow] ${chatType}: no target configured`);
|
|
294
|
+
return { handled: true, statusCode: 500, body: "no target configured" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!encryptedContent.trim()) {
|
|
298
|
+
getInfoflowParseLog().error(`[infoflow] ${chatType}: empty encrypted content`);
|
|
299
|
+
return { handled: true, statusCode: 400, body: "empty content" };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const target of targets) {
|
|
303
|
+
const { encodingAESKey } = target.account.config;
|
|
304
|
+
if (!encodingAESKey) continue;
|
|
305
|
+
|
|
306
|
+
let decryptedContent: string;
|
|
307
|
+
try {
|
|
308
|
+
decryptedContent = decryptMessage(encryptedContent, encodingAESKey);
|
|
309
|
+
} catch {
|
|
310
|
+
continue; // Try next account
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Parse as JSON first, then try fallback parser (XML for private)
|
|
314
|
+
let msgData: Record<string, unknown> | null = null;
|
|
315
|
+
try {
|
|
316
|
+
msgData = JSON.parse(decryptedContent) as Record<string, unknown>;
|
|
317
|
+
} catch {
|
|
318
|
+
if (fallbackParser) {
|
|
319
|
+
msgData = fallbackParser(decryptedContent);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (msgData && Object.keys(msgData).length > 0) {
|
|
324
|
+
if (isDuplicateMessage(msgData)) {
|
|
325
|
+
logVerbose(`[infoflow] ${chatType}: duplicate message, skipping`);
|
|
326
|
+
return { handled: true, statusCode: 200, body: "success" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
330
|
+
|
|
331
|
+
// Fire-and-forget with centralized error handling
|
|
332
|
+
void dispatchFn(target, msgData).catch((err) => {
|
|
333
|
+
getInfoflowParseLog().error(
|
|
334
|
+
`[infoflow] ${chatType} handler error: ${formatInfoflowError(err)}`,
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
return { handled: true, statusCode: 200, body: "success" };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
getInfoflowParseLog().error(
|
|
343
|
+
`[infoflow] ${chatType}: decryption failed for all ${targets.length} account(s)`,
|
|
344
|
+
);
|
|
345
|
+
return { handled: true, statusCode: 500, body: "decryption failed for all accounts" };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Private chat handler
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Handles a private (dm) chat message.
|
|
354
|
+
* Decrypts the Encrypt field with encodingAESKey (AES-ECB),
|
|
355
|
+
* parses the decrypted content, then dispatches to bot.ts.
|
|
356
|
+
*/
|
|
357
|
+
function handlePrivateMessage(messageJsonStr: string, targets: WebhookTarget[]): ParseResult {
|
|
358
|
+
let messageJson: Record<string, unknown>;
|
|
359
|
+
try {
|
|
360
|
+
messageJson = JSON.parse(messageJsonStr) as Record<string, unknown>;
|
|
361
|
+
} catch {
|
|
362
|
+
getInfoflowParseLog().error(`[infoflow] private: invalid messageJson`);
|
|
363
|
+
return { handled: true, statusCode: 400, body: "invalid messageJson" };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const encrypt = typeof messageJson.Encrypt === "string" ? messageJson.Encrypt : "";
|
|
367
|
+
if (!encrypt) {
|
|
368
|
+
getInfoflowParseLog().error(`[infoflow] private: missing Encrypt field`);
|
|
369
|
+
return { handled: true, statusCode: 400, body: "missing Encrypt field in messageJson" };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return tryDecryptAndDispatch({
|
|
373
|
+
encryptedContent: encrypt,
|
|
374
|
+
targets,
|
|
375
|
+
chatType: "direct",
|
|
376
|
+
fallbackParser: parseXmlMessage,
|
|
377
|
+
dispatchFn: (target, msgData) =>
|
|
378
|
+
handlePrivateChatMessage({
|
|
379
|
+
cfg: target.config,
|
|
380
|
+
msgData,
|
|
381
|
+
accountId: target.account.accountId,
|
|
382
|
+
statusSink: target.statusSink,
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Group chat handler
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Handles a group chat message.
|
|
393
|
+
* The rawBody itself is an AES-encrypted ciphertext (Base64URLSafe encoded).
|
|
394
|
+
* Decrypts and dispatches to bot.ts.
|
|
395
|
+
*/
|
|
396
|
+
function handleGroupMessage(rawBody: string, targets: WebhookTarget[]): ParseResult {
|
|
397
|
+
return tryDecryptAndDispatch({
|
|
398
|
+
encryptedContent: rawBody,
|
|
399
|
+
targets,
|
|
400
|
+
chatType: "group",
|
|
401
|
+
dispatchFn: (target, msgData) =>
|
|
402
|
+
handleGroupChatMessage({
|
|
403
|
+
cfg: target.config,
|
|
404
|
+
msgData,
|
|
405
|
+
accountId: target.account.accountId,
|
|
406
|
+
statusSink: target.statusSink,
|
|
407
|
+
}),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Test-only exports (@internal — not part of the public API)
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/** @internal */
|
|
416
|
+
export const _extractDedupeKey = extractDedupeKey;
|
|
417
|
+
|
|
418
|
+
/** @internal */
|
|
419
|
+
export const _isDuplicateMessage = isDuplicateMessage;
|
|
420
|
+
|
|
421
|
+
/** @internal */
|
|
422
|
+
export const _base64UrlSafeDecode = base64UrlSafeDecode;
|
|
423
|
+
|
|
424
|
+
/** @internal */
|
|
425
|
+
export const _decryptMessage = decryptMessage;
|
|
426
|
+
|
|
427
|
+
/** @internal */
|
|
428
|
+
export const _parseXmlMessage = parseXmlMessage;
|
|
429
|
+
|
|
430
|
+
/** @internal — Clears the message dedup cache. Only use in tests. */
|
|
431
|
+
export function _resetMessageCache(): void {
|
|
432
|
+
messageCache.clear();
|
|
433
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Message Receiver: wraps SDK WSClient for long-connection message receiving.
|
|
3
|
+
*
|
|
4
|
+
* Converts WebSocket events into the same handler interface used by webhook mode,
|
|
5
|
+
* so the downstream bot logic (handleGroupChatMessage / handlePrivateChatMessage)
|
|
6
|
+
* is shared between both connection modes.
|
|
7
|
+
*
|
|
8
|
+
* Key difference from webhook: WebSocket messages arrive already decrypted
|
|
9
|
+
* (the connection itself is authenticated), so we skip AES-ECB decryption
|
|
10
|
+
* and feed the payload directly to bot handlers.
|
|
11
|
+
*
|
|
12
|
+
* SDK event system (@core-workspace/infoflow-sdk-nodejs):
|
|
13
|
+
* - Group messages → "group.*" (covers group.text, group.mixed, group.image, etc.)
|
|
14
|
+
* - Private messages → "private.*" (covers private.text, private.image, etc.)
|
|
15
|
+
* - event.data is normalized by SDK: { chatType, msgType, fromUserId, groupId, body,
|
|
16
|
+
* content, originalMessage, ... }
|
|
17
|
+
*
|
|
18
|
+
* SDK Bug Workaround:
|
|
19
|
+
* SDK's normalizePrivateMessage() drops PicUrl/MsgId fields from private messages.
|
|
20
|
+
* We patch eventDispatcher.normalizePrivateMessage to preserve the raw payload as
|
|
21
|
+
* originalMessage, so handlePrivateEvent can fall back to originalMessage.PicUrl.
|
|
22
|
+
*
|
|
23
|
+
* Note: SDK's JSON.parse has been patched to preserve large integer precision for
|
|
24
|
+
* messageid / clientmsgid fields (values > 2^53 lose precision in JS Number).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { WSClient } from "@core-workspace/infoflow-sdk-nodejs";
|
|
28
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
29
|
+
import { handleGroupChatMessage, handlePrivateChatMessage } from "../../handler/message-handler.js";
|
|
30
|
+
import { isDuplicateMessage } from "./webhook-parser.js";
|
|
31
|
+
import { formatInfoflowError, logVerbose } from "../../logging.js";
|
|
32
|
+
import type { ResolvedInfoflowAccount } from "../../types.js";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export type WSReceiverOptions = {
|
|
39
|
+
account: ResolvedInfoflowAccount;
|
|
40
|
+
config: OpenClawConfig;
|
|
41
|
+
abortSignal: AbortSignal;
|
|
42
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// WSReceiver
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* WebSocket message receiver.
|
|
51
|
+
* Connects SDK WSClient → receives events → converts format → calls bot handlers.
|
|
52
|
+
*/
|
|
53
|
+
export class InfoflowWSReceiver {
|
|
54
|
+
private wsClient: WSClient;
|
|
55
|
+
private options: WSReceiverOptions;
|
|
56
|
+
private stopped = false;
|
|
57
|
+
|
|
58
|
+
constructor(options: WSReceiverOptions) {
|
|
59
|
+
this.options = options;
|
|
60
|
+
const { appKey, appSecret } = options.account.config;
|
|
61
|
+
const wsGateway =
|
|
62
|
+
options.account.config.wsGateway ?? "infoflow-open-gateway.weiyun.baidu.com";
|
|
63
|
+
|
|
64
|
+
this.wsClient = new WSClient({
|
|
65
|
+
appId: appKey,
|
|
66
|
+
appSecret: appSecret,
|
|
67
|
+
wsGateway,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Patch SDK bug: normalizePrivateMessage() drops PicUrl/MsgId.
|
|
71
|
+
// Override it to also include originalMessage (the raw payload),
|
|
72
|
+
// so handlePrivateEvent can read PicUrl/MsgId from there.
|
|
73
|
+
const dispatcher = (this.wsClient as any).eventDispatcher;
|
|
74
|
+
if (dispatcher && typeof dispatcher.normalizePrivateMessage === "function") {
|
|
75
|
+
const original = dispatcher.normalizePrivateMessage.bind(dispatcher);
|
|
76
|
+
dispatcher.normalizePrivateMessage = function (payload: any) {
|
|
77
|
+
const normalized = original(payload);
|
|
78
|
+
normalized.originalMessage = payload;
|
|
79
|
+
return normalized;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Connect and start receiving messages. */
|
|
85
|
+
async start(): Promise<void> {
|
|
86
|
+
// Listen for all group messages (group.text, group.mixed, group.image, etc.)
|
|
87
|
+
const handleGroupEvent = async (event: any) => {
|
|
88
|
+
if (this.stopped) return;
|
|
89
|
+
try {
|
|
90
|
+
await this.handleGroupEvent(event.data ?? event);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(`[infoflow:ws] group handler error: ${formatInfoflowError(err)}`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
this.wsClient.on("group.*", handleGroupEvent);
|
|
96
|
+
|
|
97
|
+
// Listen for all private messages (private.text, private.image, etc.)
|
|
98
|
+
const handlePrivateEvent = async (event: any) => {
|
|
99
|
+
if (this.stopped) return;
|
|
100
|
+
try {
|
|
101
|
+
await this.handlePrivateEvent(event.data ?? event);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[infoflow:ws] private handler error: ${formatInfoflowError(err)}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
this.wsClient.on("private.*", handlePrivateEvent);
|
|
107
|
+
|
|
108
|
+
// Connect (two-phase: endpoint allocation → WS handshake)
|
|
109
|
+
await this.wsClient.connect();
|
|
110
|
+
|
|
111
|
+
// Listen for abortSignal to gracefully disconnect
|
|
112
|
+
this.options.abortSignal.addEventListener(
|
|
113
|
+
"abort",
|
|
114
|
+
() => {
|
|
115
|
+
this.stop();
|
|
116
|
+
},
|
|
117
|
+
{ once: true },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Disconnect and stop receiving. */
|
|
122
|
+
stop(): void {
|
|
123
|
+
if (this.stopped) return;
|
|
124
|
+
this.stopped = true;
|
|
125
|
+
try {
|
|
126
|
+
this.wsClient.disconnect();
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore disconnect errors during shutdown
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle a group message event from the new SDK.
|
|
134
|
+
* SDK normalizes to: { chatType, msgType, fromUserId, groupId, body, content,
|
|
135
|
+
* originalMessage, createTime, ... }
|
|
136
|
+
* We reconstruct the raw msgData shape expected by handleGroupChatMessage.
|
|
137
|
+
*/
|
|
138
|
+
private async handleGroupEvent(data: any): Promise<void> {
|
|
139
|
+
if (!data) return;
|
|
140
|
+
|
|
141
|
+
this.options.statusSink?.({ lastInboundAt: Date.now() });
|
|
142
|
+
|
|
143
|
+
// Reconstruct raw msgData compatible with handleGroupChatMessage expectations:
|
|
144
|
+
// { message: { header: { fromuserid, servertime, messageid, ... }, body }, groupid, ... }
|
|
145
|
+
const originalMessage = data.originalMessage ?? {};
|
|
146
|
+
const header = originalMessage.header ?? {};
|
|
147
|
+
const msgData: Record<string, unknown> = {
|
|
148
|
+
eventtype: "MESSAGE_RECEIVE",
|
|
149
|
+
groupid: data.groupId ?? data.groupid,
|
|
150
|
+
message: {
|
|
151
|
+
header: {
|
|
152
|
+
fromuserid: data.fromUserId ?? header.fromuserid ?? "",
|
|
153
|
+
toid: data.groupId ?? data.groupid,
|
|
154
|
+
totype: "GROUP",
|
|
155
|
+
msgtype: data.msgType ?? header.msgtype ?? "text",
|
|
156
|
+
messageid: header.messageid ?? header.clientmsgid,
|
|
157
|
+
clientmsgid: header.clientmsgid,
|
|
158
|
+
servertime: header.servertime,
|
|
159
|
+
clienttime: header.clienttime,
|
|
160
|
+
at: header.at ?? { atrobotids: [] },
|
|
161
|
+
},
|
|
162
|
+
body: data.body ?? (data.content ? [{ type: "TEXT", content: data.content }] : []),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Dedup check using clientmsgid or messageid as key
|
|
167
|
+
const dedupKey = header.clientmsgid ?? header.messageid;
|
|
168
|
+
if (dedupKey && isDuplicateMessage({ CreateTime: String(dedupKey) })) {
|
|
169
|
+
logVerbose("[infoflow:ws] duplicate group message, skipping");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
logVerbose(`[infoflow:ws] group message: from=${data.fromUserId}, msgType=${data.msgType}, groupId=${data.groupId}`);
|
|
174
|
+
|
|
175
|
+
await handleGroupChatMessage({
|
|
176
|
+
cfg: this.options.config,
|
|
177
|
+
msgData,
|
|
178
|
+
accountId: this.options.account.accountId,
|
|
179
|
+
statusSink: this.options.statusSink,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Handle a private message event from the new SDK.
|
|
185
|
+
* SDK normalizes to: { chatType, msgType, fromUserId, content, createTime, ... }
|
|
186
|
+
* We reconstruct the raw msgData shape expected by handlePrivateChatMessage.
|
|
187
|
+
*/
|
|
188
|
+
private async handlePrivateEvent(data: any): Promise<void> {
|
|
189
|
+
if (!data) return;
|
|
190
|
+
|
|
191
|
+
this.options.statusSink?.({ lastInboundAt: Date.now() });
|
|
192
|
+
|
|
193
|
+
logVerbose(`[DEBUG ws.private] SDK event data 完整结构: ${JSON.stringify(data, null, 2)}`);
|
|
194
|
+
|
|
195
|
+
// Reconstruct raw msgData compatible with handlePrivateChatMessage expectations:
|
|
196
|
+
// { FromUserId, Content, MsgType, CreateTime, PicUrl, MsgId, ... }
|
|
197
|
+
const originalMessage = data.originalMessage ?? {};
|
|
198
|
+
const msgData: Record<string, unknown> = {
|
|
199
|
+
FromUserId: data.fromUserId ?? data.FromUserId ?? originalMessage.FromUserId ?? "",
|
|
200
|
+
FromUserName: data.fromUserName ?? data.FromUserName ?? originalMessage.FromUserName,
|
|
201
|
+
Content: data.content ?? data.Content ?? originalMessage.Content ?? "",
|
|
202
|
+
MsgType: data.msgType ?? data.MsgType ?? originalMessage.MsgType ?? "text",
|
|
203
|
+
CreateTime: data.createTime ?? data.CreateTime ?? originalMessage.CreateTime ?? String(Date.now()),
|
|
204
|
+
// 图片消息字段
|
|
205
|
+
PicUrl: data.picUrl ?? data.PicUrl ?? originalMessage.PicUrl ?? "",
|
|
206
|
+
MsgId: data.msgId ?? data.MsgId ?? originalMessage.MsgId,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Dedup check
|
|
210
|
+
if (isDuplicateMessage(msgData)) {
|
|
211
|
+
logVerbose("[infoflow:ws] duplicate private message, skipping");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
logVerbose(`[infoflow:ws] private message: from=${msgData.FromUserId}, msgType=${msgData.MsgType}`);
|
|
216
|
+
|
|
217
|
+
await handlePrivateChatMessage({
|
|
218
|
+
cfg: this.options.config,
|
|
219
|
+
msgData,
|
|
220
|
+
accountId: this.options.account.accountId,
|
|
221
|
+
statusSink: this.options.statusSink,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|