@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.
@@ -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
+