@core-workspace/infoflow-openclaw-plugin 2026.3.35 → 2026.4.10

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.
Files changed (41) hide show
  1. package/README.md +56 -22
  2. package/dist/index.cjs +45821 -0
  3. package/openclaw.plugin.json +647 -140
  4. package/package.json +23 -10
  5. package/CHANGELOG.md +0 -21
  6. package/docs/architecture-data-flow.md +0 -429
  7. package/docs/architecture.md +0 -423
  8. package/docs/dev-guide.md +0 -611
  9. package/docs/qa-feature-list.md +0 -413
  10. package/index.ts +0 -53
  11. package/publish.sh +0 -221
  12. package/scripts/deploy.sh +0 -34
  13. package/scripts/npm-tools/README.md +0 -70
  14. package/scripts/npm-tools/cli.js +0 -262
  15. package/scripts/npm-tools/package.json +0 -21
  16. package/skills/infoflow-dev/SKILL.md +0 -88
  17. package/skills/infoflow-dev/references/api.md +0 -413
  18. package/src/adapter/inbound/webhook-parser.ts +0 -433
  19. package/src/adapter/inbound/ws-receiver.ts +0 -268
  20. package/src/adapter/outbound/reply-dispatcher.ts +0 -274
  21. package/src/adapter/outbound/target-resolver.ts +0 -109
  22. package/src/channel/accounts.ts +0 -184
  23. package/src/channel/channel.ts +0 -365
  24. package/src/channel/media.ts +0 -373
  25. package/src/channel/monitor.ts +0 -184
  26. package/src/channel/outbound.ts +0 -942
  27. package/src/commands/changelog.ts +0 -53
  28. package/src/commands/doctor.ts +0 -391
  29. package/src/commands/logs.ts +0 -212
  30. package/src/events.ts +0 -62
  31. package/src/handler/message-handler.ts +0 -796
  32. package/src/logging.ts +0 -123
  33. package/src/runtime.ts +0 -14
  34. package/src/security/dm-policy.ts +0 -80
  35. package/src/security/group-policy.ts +0 -273
  36. package/src/tools/actions/index.ts +0 -456
  37. package/src/tools/hooks/index.ts +0 -82
  38. package/src/tools/index.ts +0 -277
  39. package/src/types.ts +0 -293
  40. package/src/utils/store/message-store.ts +0 -295
  41. package/src/utils/token-adapter.ts +0 -90
@@ -1,433 +0,0 @@
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
- }
@@ -1,268 +0,0 @@
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
- * - SDK 0.1.1: event.data is normalized with fromUserId, groupId, body, originalMessage, ...
16
- * - SDK 0.1.5+: event.data is { chatType, msgType, raw } — raw is the full original payload
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, getInfoflowWebhookLog } from "../../logging.js";
32
- import { extractIdFromRawJson } from "../../channel/outbound.js";
33
- import type { ResolvedInfoflowAccount } from "../../types.js";
34
-
35
- // ---------------------------------------------------------------------------
36
- // Types
37
- // ---------------------------------------------------------------------------
38
-
39
- export type WSReceiverOptions = {
40
- account: ResolvedInfoflowAccount;
41
- config: OpenClawConfig;
42
- abortSignal: AbortSignal;
43
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
44
- };
45
-
46
- // ---------------------------------------------------------------------------
47
- // WSReceiver
48
- // ---------------------------------------------------------------------------
49
-
50
- /**
51
- * WebSocket message receiver.
52
- * Connects SDK WSClient → receives events → converts format → calls bot handlers.
53
- */
54
- export class InfoflowWSReceiver {
55
- private wsClient: WSClient;
56
- private options: WSReceiverOptions;
57
- private stopped = false;
58
-
59
- constructor(options: WSReceiverOptions) {
60
- this.options = options;
61
- const { appKey, appSecret } = options.account.config;
62
- const wsGateway =
63
- options.account.config.wsGateway ?? "infoflow-open-gateway.baidu.com";
64
-
65
- this.wsClient = new WSClient({
66
- appId: appKey,
67
- appSecret: appSecret,
68
- wsGateway,
69
- });
70
-
71
- // Patch SDK bug: normalizePrivateMessage() drops PicUrl/MsgId.
72
- // Override it to also include originalMessage (the raw payload),
73
- // so handlePrivateEvent can read PicUrl/MsgId from there.
74
- const dispatcher = (this.wsClient as any).eventDispatcher;
75
- if (dispatcher && typeof dispatcher.normalizePrivateMessage === "function") {
76
- const original = dispatcher.normalizePrivateMessage.bind(dispatcher);
77
- dispatcher.normalizePrivateMessage = function (payload: any) {
78
- const normalized = original(payload);
79
- normalized.originalMessage = payload;
80
- return normalized;
81
- };
82
- }
83
-
84
- // Patch frameCodec.parsePayload to attach _rawJson to parsed objects.
85
- // This preserves large integer precision (e.g. messageid) that JSON.parse loses.
86
- const frameCodec = (this.wsClient as any).frameCodec;
87
- if (frameCodec && typeof frameCodec.parsePayload === "function") {
88
- const originalParse = frameCodec.parsePayload.bind(frameCodec);
89
- frameCodec.parsePayload = function (frame: any) {
90
- const result = originalParse(frame);
91
- if (result && typeof result === "object" && frame.payload) {
92
- try {
93
- result._rawJson = frame.payload.toString("utf-8");
94
- } catch { /* ignore */ }
95
- }
96
- return result;
97
- };
98
- }
99
- }
100
-
101
- /** Connect and start receiving messages. */
102
- async start(): Promise<void> {
103
- // Listen for all group messages (group.text, group.mixed, group.image, etc.)
104
- const handleGroupEvent = async (event: any) => {
105
- if (this.stopped) return;
106
- try {
107
- await this.handleGroupEvent(event.data ?? event);
108
- } catch (err) {
109
- console.error(`[infoflow:ws] group handler error: ${formatInfoflowError(err)}`);
110
- }
111
- };
112
- this.wsClient.on("group.*", handleGroupEvent);
113
-
114
- // Listen for all private messages (private.text, private.image, etc.)
115
- const handlePrivateEvent = async (event: any) => {
116
- if (this.stopped) return;
117
- try {
118
- await this.handlePrivateEvent(event.data ?? event);
119
- } catch (err) {
120
- console.error(`[infoflow:ws] private handler error: ${formatInfoflowError(err)}`);
121
- }
122
- };
123
- this.wsClient.on("private.*", handlePrivateEvent);
124
-
125
- // Connect (two-phase: endpoint allocation → WS handshake)
126
- const wsGateway = this.options.account.config.wsGateway ?? "infoflow-open-gateway.baidu.com";
127
- getInfoflowWebhookLog().info(`[ws:connect] connecting to ${wsGateway}`);
128
- await this.wsClient.connect();
129
- getInfoflowWebhookLog().info(`[ws:connect] connected to ${wsGateway}`);
130
-
131
- // Listen for abortSignal to gracefully disconnect
132
- this.options.abortSignal.addEventListener(
133
- "abort",
134
- () => {
135
- this.stop();
136
- },
137
- { once: true },
138
- );
139
- }
140
-
141
- /** Disconnect and stop receiving. */
142
- stop(): void {
143
- if (this.stopped) return;
144
- this.stopped = true;
145
- getInfoflowWebhookLog().info(`[ws:disconnect] stopping ws receiver`);
146
- try {
147
- this.wsClient.disconnect();
148
- } catch {
149
- // ignore disconnect errors during shutdown
150
- }
151
- }
152
-
153
- /**
154
- * Handle a group message event from the new SDK.
155
- *
156
- * SDK 0.1.1: normalizes to { chatType, msgType, fromUserId, groupId, body, originalMessage, ... }
157
- * SDK 0.1.5: normalizes to { chatType, msgType, raw } — raw is the original payload
158
- *
159
- * We reconstruct the raw msgData shape expected by handleGroupChatMessage.
160
- */
161
- private async handleGroupEvent(data: any): Promise<void> {
162
- if (!data) return;
163
-
164
- this.options.statusSink?.({ lastInboundAt: Date.now() });
165
-
166
- // SDK 0.1.5 puts everything in data.raw; 0.1.1 inlines fields directly.
167
- const payload = data.raw ?? data;
168
-
169
- // Reconstruct raw msgData compatible with handleGroupChatMessage expectations:
170
- // { message: { header: { fromuserid, servertime, messageid, ... }, body }, groupid, ... }
171
- const originalMessage = payload.originalMessage ?? payload;
172
- const message = payload.message ?? originalMessage.message ?? {};
173
- const header = message.header ?? originalMessage.header ?? {};
174
-
175
- // Extract messageid with full precision from raw JSON string (avoids JS Number precision loss
176
- // for large int64 IDs like 1859821262633816373 which JSON.parse rounds to ...300).
177
- const rawJson: string | undefined = payload._rawJson;
178
- const preciseMessageId =
179
- (rawJson && extractIdFromRawJson(rawJson, "messageid")) ??
180
- (header.messageid != null ? String(header.messageid) : undefined);
181
- const preciseClientMsgId =
182
- (rawJson && extractIdFromRawJson(rawJson, "clientmsgid")) ??
183
- (header.clientmsgid != null ? String(header.clientmsgid) : undefined);
184
-
185
- const msgData: Record<string, unknown> = {
186
- eventtype: "MESSAGE_RECEIVE",
187
- groupid: payload.groupid ?? payload.groupId ?? data.groupId,
188
- fromid: payload.fromid,
189
- message: {
190
- header: {
191
- fromuserid: header.fromuserid ?? payload.fromUserId ?? data.fromUserId ?? "",
192
- toid: payload.groupid ?? payload.groupId ?? data.groupId,
193
- totype: "GROUP",
194
- msgtype: header.msgtype ?? data.msgType ?? "text",
195
- messageid: preciseMessageId,
196
- clientmsgid: preciseClientMsgId,
197
- servertime: header.servertime,
198
- clienttime: header.clienttime,
199
- at: header.at ?? { atrobotids: [] },
200
- },
201
- body: message.body ?? payload.body ?? data.body ??
202
- (data.content ? [{ type: "TEXT", content: data.content }] : []),
203
- },
204
- };
205
-
206
- // Dedup check using clientmsgid or messageid as key (use precise string IDs)
207
- const dedupKey = preciseClientMsgId ?? preciseMessageId;
208
- if (dedupKey && isDuplicateMessage({ CreateTime: String(dedupKey) })) {
209
- logVerbose("[infoflow:ws] duplicate group message, skipping");
210
- return;
211
- }
212
-
213
- logVerbose(`[infoflow:ws] group message: from=${header.fromuserid}, msgType=${header.msgtype}, groupId=${payload.groupid ?? payload.groupId}`);
214
-
215
- await handleGroupChatMessage({
216
- cfg: this.options.config,
217
- msgData,
218
- accountId: this.options.account.accountId,
219
- statusSink: this.options.statusSink,
220
- });
221
- }
222
-
223
- /**
224
- * Handle a private message event from the new SDK.
225
- *
226
- * SDK 0.1.1: normalizes to { chatType, msgType, fromUserId, content, createTime, originalMessage, ... }
227
- * SDK 0.1.5: normalizes to { chatType, msgType, raw } — raw is the original payload
228
- *
229
- * We reconstruct the raw msgData shape expected by handlePrivateChatMessage.
230
- */
231
- private async handlePrivateEvent(data: any): Promise<void> {
232
- if (!data) return;
233
-
234
- this.options.statusSink?.({ lastInboundAt: Date.now() });
235
-
236
- // SDK 0.1.5 puts everything in data.raw; 0.1.1 inlines fields directly.
237
- const payload = data.raw ?? data;
238
-
239
- // Reconstruct raw msgData compatible with handlePrivateChatMessage expectations:
240
- // { FromUserId, Content, MsgType, CreateTime, PicUrl, MsgId, ... }
241
- const originalMessage = payload.originalMessage ?? payload;
242
- const msgData: Record<string, unknown> = {
243
- FromUserId: payload.FromUserId ?? payload.fromUserId ?? data.fromUserId ?? originalMessage.FromUserId ?? "",
244
- FromUserName: payload.FromUserName ?? payload.fromUserName ?? data.fromUserName ?? originalMessage.FromUserName,
245
- Content: payload.Content ?? payload.content ?? data.content ?? originalMessage.Content ?? "",
246
- MsgType: payload.MsgType ?? payload.msgType ?? data.msgType ?? originalMessage.MsgType ?? "text",
247
- CreateTime: payload.CreateTime ?? payload.createTime ?? data.createTime ?? originalMessage.CreateTime ?? String(Date.now()),
248
- // 图片消息字段
249
- PicUrl: payload.PicUrl ?? payload.picUrl ?? data.picUrl ?? originalMessage.PicUrl ?? "",
250
- MsgId: payload.MsgId ?? payload.msgId ?? data.msgId ?? originalMessage.MsgId,
251
- };
252
-
253
- // Dedup check
254
- if (isDuplicateMessage(msgData)) {
255
- logVerbose("[infoflow:ws] duplicate private message, skipping");
256
- return;
257
- }
258
-
259
- logVerbose(`[infoflow:ws] private message: from=${msgData.FromUserId}, msgType=${msgData.MsgType}`);
260
-
261
- await handlePrivateChatMessage({
262
- cfg: this.options.config,
263
- msgData,
264
- accountId: this.options.account.accountId,
265
- statusSink: this.options.statusSink,
266
- });
267
- }
268
- }