@sunnoy/wecom 1.9.0 → 2.0.0

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,1487 @@
1
+ import { existsSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { WSClient, generateReqId } from "@wecom/aibot-node-sdk";
5
+ import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js";
6
+ import { prepareImageBufferForMsgItem } from "../image-processor.js";
7
+ import { logger } from "../logger.js";
8
+ import { normalizeThinkingTags } from "../think-parser.js";
9
+ import { MessageDeduplicator } from "../utils.js";
10
+ import {
11
+ extractGroupMessageContent,
12
+ generateAgentId,
13
+ getDynamicAgentConfig,
14
+ shouldTriggerGroupResponse,
15
+ shouldUseDynamicAgent,
16
+ } from "../dynamic-agent.js";
17
+ import { resolveWecomCommandAuthorized } from "./allow-from.js";
18
+ import { checkCommandAllowlist, getCommandConfig, isWecomAdmin } from "./commands.js";
19
+ import {
20
+ CHANNEL_ID,
21
+ DEFAULT_MEDIA_MAX_MB,
22
+ DEFAULT_WELCOME_MESSAGE,
23
+ FILE_DOWNLOAD_TIMEOUT_MS,
24
+ IMAGE_DOWNLOAD_TIMEOUT_MS,
25
+ MEDIA_DOCUMENT_PLACEHOLDER,
26
+ MEDIA_IMAGE_PLACEHOLDER,
27
+ MESSAGE_PROCESS_TIMEOUT_MS,
28
+ REPLY_SEND_TIMEOUT_MS,
29
+ THINKING_MESSAGE,
30
+ WS_HEARTBEAT_INTERVAL_MS,
31
+ WS_MAX_RECONNECT_ATTEMPTS,
32
+ setApiBaseUrl,
33
+ } from "./constants.js";
34
+ import { setConfigProxyUrl } from "./http.js";
35
+ import { checkDmPolicy } from "./dm-policy.js";
36
+ import { checkGroupPolicy } from "./group-policy.js";
37
+ import {
38
+ clearAccountDisplaced,
39
+ forecastActiveSendQuota,
40
+ forecastReplyQuota,
41
+ markAccountDisplaced,
42
+ recordActiveSend,
43
+ recordInboundMessage,
44
+ recordOutboundActivity,
45
+ recordPassiveReply,
46
+ } from "./runtime-telemetry.js";
47
+ import { dispatchLocks, getRuntime, setOpenclawConfig, streamContext } from "./state.js";
48
+ import {
49
+ cleanupWsAccount,
50
+ deleteMessageState,
51
+ drainPendingReplies,
52
+ enqueuePendingReply,
53
+ getWsClient,
54
+ hasPendingReplies,
55
+ setMessageState,
56
+ setWsClient,
57
+ startMessageStateCleanup,
58
+ } from "./ws-state.js";
59
+ import { ensureDynamicAgentListed } from "./workspace-template.js";
60
+
61
+ const DEFAULT_AGENT_ID = "main";
62
+ const DEFAULT_STATE_DIRNAME = ".openclaw";
63
+ const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"];
64
+ const MAX_REPLY_MSG_ITEMS = 10;
65
+ const MAX_REPLY_IMAGE_BYTES = 10 * 1024 * 1024;
66
+ const REASONING_STREAM_THROTTLE_MS = 800;
67
+ // Match MEDIA:/FILE: directives at line start, optionally preceded by markdown list markers.
68
+ const REPLY_MEDIA_DIRECTIVE_PATTERN = /^\s*(?:[-*•]\s+|\d+\.\s+)?(?:MEDIA|FILE)\s*:/im;
69
+ const WECOM_REPLY_MEDIA_GUIDANCE_HEADER = "[WeCom reply media rule]";
70
+ const inboundMessageDeduplicator = new MessageDeduplicator();
71
+
72
+ function withTimeout(promise, timeoutMs, message) {
73
+ if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
74
+ return promise;
75
+ }
76
+
77
+ let timer = null;
78
+ const timeout = new Promise((_, reject) => {
79
+ timer = setTimeout(() => reject(new Error(message ?? `Timed out after ${timeoutMs}ms`)), timeoutMs);
80
+ });
81
+
82
+ return Promise.race([promise, timeout]).finally(() => {
83
+ if (timer) {
84
+ clearTimeout(timer);
85
+ }
86
+ });
87
+ }
88
+
89
+ function normalizeReasoningStreamText(text) {
90
+ const source = String(text ?? "").trim();
91
+ if (!source) {
92
+ return "";
93
+ }
94
+
95
+ const withoutPrefix = source.replace(/^Reasoning:\s*/i, "").trim();
96
+ if (!withoutPrefix) {
97
+ return "";
98
+ }
99
+
100
+ const lines = withoutPrefix
101
+ .split(/\r?\n/)
102
+ .map((line) => line.trim())
103
+ .filter(Boolean)
104
+ .map((line) => {
105
+ const match = line.match(/^([_*~`])(.*)\1$/);
106
+ return match ? match[2].trim() : line;
107
+ })
108
+ .filter(Boolean);
109
+
110
+ return lines.join("\n").trim();
111
+ }
112
+
113
+ function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = false }) {
114
+ const normalizedReasoning = String(reasoningText ?? "").trim();
115
+ const normalizedVisible = String(visibleText ?? "").trim();
116
+
117
+ if (!normalizedReasoning) {
118
+ return normalizedVisible;
119
+ }
120
+
121
+ const shouldCloseThink = finish || Boolean(normalizedVisible);
122
+ const thinkBlock = shouldCloseThink
123
+ ? `<think>${normalizedReasoning}</think>`
124
+ : `<think>${normalizedReasoning}`;
125
+
126
+ return normalizedVisible ? `${thinkBlock}\n${normalizedVisible}` : thinkBlock;
127
+ }
128
+
129
+ function createSdkLogger(accountId) {
130
+ return {
131
+ debug: (message, ...args) => logger.debug(`[WS:${accountId}] ${message}`, ...args),
132
+ info: (message, ...args) => logger.info(`[WS:${accountId}] ${message}`, ...args),
133
+ warn: (message, ...args) => logger.warn(`[WS:${accountId}] ${message}`, ...args),
134
+ error: (message, ...args) => logger.error(`[WS:${accountId}] ${message}`, ...args),
135
+ };
136
+ }
137
+
138
+ function getRegisteredRuntimeOrNull() {
139
+ try {
140
+ return getRuntime();
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function resolveChannelCore(runtime) {
147
+ const registeredRuntime = getRegisteredRuntimeOrNull();
148
+ const candidates = [runtime?.channel, runtime, registeredRuntime?.channel, registeredRuntime];
149
+
150
+ for (const candidate of candidates) {
151
+ if (candidate?.routing && candidate?.reply && candidate?.session) {
152
+ return candidate;
153
+ }
154
+ }
155
+
156
+ throw new Error("OpenClaw channel runtime is unavailable");
157
+ }
158
+
159
+ function resolveMediaRuntime(runtime) {
160
+ const registeredRuntime = getRegisteredRuntimeOrNull();
161
+ const candidates = [runtime, registeredRuntime];
162
+
163
+ for (const candidate of candidates) {
164
+ if (typeof candidate?.media?.loadWebMedia === "function") {
165
+ return candidate;
166
+ }
167
+ }
168
+
169
+ throw new Error("OpenClaw media runtime is unavailable");
170
+ }
171
+
172
+ function resolveUserPath(value) {
173
+ const trimmed = String(value ?? "").trim();
174
+ if (!trimmed) {
175
+ return "";
176
+ }
177
+ if (trimmed.startsWith("~")) {
178
+ const homeDir = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || os.homedir();
179
+ return path.resolve(homeDir, trimmed.slice(1).replace(/^\/+/, ""));
180
+ }
181
+ return path.resolve(trimmed);
182
+ }
183
+
184
+ function resolveStateDir() {
185
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
186
+ if (override) {
187
+ return resolveUserPath(override);
188
+ }
189
+
190
+ const homeDir = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || os.homedir();
191
+ const preferred = path.join(homeDir, DEFAULT_STATE_DIRNAME);
192
+ if (existsSync(preferred)) {
193
+ return preferred;
194
+ }
195
+
196
+ for (const legacyName of LEGACY_STATE_DIRNAMES) {
197
+ const candidate = path.join(homeDir, legacyName);
198
+ if (existsSync(candidate)) {
199
+ return candidate;
200
+ }
201
+ }
202
+
203
+ return preferred;
204
+ }
205
+
206
+ function normalizeAgentId(agentId) {
207
+ return String(agentId ?? "")
208
+ .trim()
209
+ .toLowerCase() || DEFAULT_AGENT_ID;
210
+ }
211
+
212
+ function resolveDefaultAgentId(config) {
213
+ const list = Array.isArray(config?.agents?.list) ? config.agents.list.filter(Boolean) : [];
214
+ if (list.length === 0) {
215
+ return DEFAULT_AGENT_ID;
216
+ }
217
+
218
+ const defaults = list.filter((entry) => entry?.default);
219
+ return normalizeAgentId(defaults[0]?.id ?? list[0]?.id ?? DEFAULT_AGENT_ID);
220
+ }
221
+
222
+ function resolveAgentWorkspaceDir(config, agentId) {
223
+ const normalizedAgentId = normalizeAgentId(agentId);
224
+ const list = Array.isArray(config?.agents?.list) ? config.agents.list.filter(Boolean) : [];
225
+ const agentEntry = list.find((entry) => normalizeAgentId(entry?.id) === normalizedAgentId);
226
+ const configuredWorkspace = String(agentEntry?.workspace ?? "").trim();
227
+
228
+ if (configuredWorkspace) {
229
+ return resolveUserPath(configuredWorkspace);
230
+ }
231
+
232
+ const stateDir = resolveStateDir();
233
+ if (normalizedAgentId === resolveDefaultAgentId(config)) {
234
+ const defaultWorkspace = String(config?.agents?.defaults?.workspace ?? "").trim();
235
+ return defaultWorkspace ? resolveUserPath(defaultWorkspace) : path.join(stateDir, "workspace");
236
+ }
237
+
238
+ return path.join(stateDir, `workspace-${normalizedAgentId}`);
239
+ }
240
+
241
+ function resolveReplyMediaLocalRoots(config, agentId) {
242
+ const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
243
+ const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
244
+ return [...new Set([workspaceDir, browserMediaDir].map((entry) => path.resolve(entry)))];
245
+ }
246
+
247
+ function mergeReplyMediaUrls(...lists) {
248
+ const seen = new Set();
249
+ const merged = [];
250
+
251
+ for (const list of lists) {
252
+ if (!Array.isArray(list)) {
253
+ continue;
254
+ }
255
+ for (const entry of list) {
256
+ const normalized = typeof entry === "string" ? entry.trim() : "";
257
+ if (!normalized || seen.has(normalized)) {
258
+ continue;
259
+ }
260
+ seen.add(normalized);
261
+ merged.push(normalized);
262
+ }
263
+ }
264
+
265
+ return merged;
266
+ }
267
+
268
+ function buildReplyMediaGuidance(config, agentId) {
269
+ const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
270
+ const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
271
+ return [
272
+ WECOM_REPLY_MEDIA_GUIDANCE_HEADER,
273
+ `Only use FILE:/abs/path or MEDIA:/abs/path for files inside the current workspace: ${workspaceDir}`,
274
+ `Browser-generated files are also allowed only under: ${browserMediaDir}`,
275
+ "Never reference any other host path.",
276
+ "Do NOT call message.send with local file paths. The sandbox will block it.",
277
+ "Instead, in your final reply put each image on its own line as MEDIA:/abs/path",
278
+ "and each non-image file on its own line as FILE:/abs/path.",
279
+ "Each directive MUST be on its own line with no other text on that line.",
280
+ "The plugin will automatically send the media to the user.",
281
+ ].join("\n");
282
+ }
283
+
284
+ function buildBodyForAgent(body, config, agentId) {
285
+ // Guidance is now injected via before_prompt_build hook into system prompt.
286
+ // Keep buildBodyForAgent as a plain passthrough for the user message body.
287
+ return typeof body === "string" && body.length > 0 ? body : "";
288
+ }
289
+
290
+ function splitReplyMediaFromText(text) {
291
+ if (typeof text !== "string" || !REPLY_MEDIA_DIRECTIVE_PATTERN.test(text)) {
292
+ return {
293
+ text: typeof text === "string" ? text : "",
294
+ mediaUrls: [],
295
+ };
296
+ }
297
+
298
+ const mediaUrls = [];
299
+ const keptLines = [];
300
+
301
+ for (const line of text.split("\n")) {
302
+ const trimmed = line.trimStart();
303
+ if (!REPLY_MEDIA_DIRECTIVE_PATTERN.test(trimmed)) {
304
+ keptLines.push(line);
305
+ continue;
306
+ }
307
+
308
+ // Strip optional markdown list prefix ("- ", "* ", "1. ") then the directive.
309
+ const mediaUrl = trimmed
310
+ .replace(/^(?:[-*•]\s+|\d+\.\s+)?/, "")
311
+ .replace(/^(MEDIA|FILE)\s*:\s*/i, "")
312
+ .trim()
313
+ .replace(/^`(.+)`$/, "$1");
314
+ if (mediaUrl) {
315
+ mediaUrls.push(mediaUrl);
316
+ }
317
+ }
318
+
319
+ return {
320
+ text: keptLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
321
+ mediaUrls,
322
+ };
323
+ }
324
+
325
+ function normalizeReplyPayload(payload) {
326
+ const explicitMediaUrls = Array.isArray(payload?.mediaUrls)
327
+ ? payload.mediaUrls.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
328
+ : [];
329
+ const explicitMediaUrl = typeof payload?.mediaUrl === "string" && payload.mediaUrl.trim()
330
+ ? [payload.mediaUrl.trim()]
331
+ : [];
332
+ const parsed = splitReplyMediaFromText(payload?.text);
333
+
334
+ return {
335
+ text: parsed.text,
336
+ mediaUrls: mergeReplyMediaUrls(explicitMediaUrls, explicitMediaUrl, parsed.mediaUrls),
337
+ };
338
+ }
339
+
340
+ function resolveReplyMediaUrls(payload) {
341
+ return normalizeReplyPayload(payload).mediaUrls;
342
+ }
343
+
344
+ function applyAccountNetworkConfig(account) {
345
+ const network = account?.config?.network ?? {};
346
+ setConfigProxyUrl(network.egressProxyUrl ?? "");
347
+ setApiBaseUrl(network.apiBaseUrl ?? "");
348
+ }
349
+
350
+ function resolveReplyMediaFilename(mediaUrl, loaded) {
351
+ const candidateNames = [
352
+ loaded?.fileName,
353
+ loaded?.filename,
354
+ loaded?.name,
355
+ typeof loaded?.path === "string" ? path.basename(loaded.path) : "",
356
+ ];
357
+
358
+ for (const candidate of candidateNames) {
359
+ const normalized = String(candidate ?? "").trim();
360
+ if (normalized) {
361
+ return normalized;
362
+ }
363
+ }
364
+
365
+ const normalizedUrl = String(mediaUrl ?? "").trim();
366
+ if (!normalizedUrl) {
367
+ return "attachment";
368
+ }
369
+
370
+ if (normalizedUrl.startsWith("/")) {
371
+ return path.basename(normalizedUrl) || "attachment";
372
+ }
373
+
374
+ try {
375
+ return path.basename(new URL(normalizedUrl).pathname) || "attachment";
376
+ } catch {
377
+ return path.basename(normalizedUrl) || "attachment";
378
+ }
379
+ }
380
+
381
+ function resolveReplyMediaAgentType(mediaUrl, loaded) {
382
+ const contentType = String(loaded?.contentType ?? "").toLowerCase();
383
+ if (loaded?.kind === "image" || contentType.startsWith("image/")) {
384
+ return "image";
385
+ }
386
+ const filename = resolveReplyMediaFilename(mediaUrl, loaded).toLowerCase();
387
+ if (/\.(jpg|jpeg|png|gif|bmp|webp)$/.test(filename)) {
388
+ return "image";
389
+ }
390
+ return "file";
391
+ }
392
+
393
+ async function prepareReplyMediaOutputs({ payload, runtime, config, agentId, mirrorImagesToAgent = false }) {
394
+ const mediaUrls = resolveReplyMediaUrls(payload);
395
+ if (mediaUrls.length === 0) {
396
+ return { msgItems: [], agentMedia: [], mirroredAgentMedia: [] };
397
+ }
398
+
399
+ let mediaRuntime;
400
+ try {
401
+ mediaRuntime = resolveMediaRuntime(runtime);
402
+ } catch (error) {
403
+ logger.error(`[WS] Reply media runtime is unavailable: ${error.message}`);
404
+ return { msgItems: [], agentMedia: [], mirroredAgentMedia: [] };
405
+ }
406
+
407
+ const localRoots = resolveReplyMediaLocalRoots(config, agentId);
408
+ const msgItems = [];
409
+ const agentMedia = [];
410
+ const mirroredAgentMedia = [];
411
+
412
+ for (const mediaUrl of mediaUrls) {
413
+ try {
414
+ const loaded = await mediaRuntime.media.loadWebMedia(mediaUrl, {
415
+ maxBytes: MAX_REPLY_IMAGE_BYTES,
416
+ localRoots,
417
+ });
418
+ const mediaType = resolveReplyMediaAgentType(mediaUrl, loaded);
419
+
420
+ if (mediaType === "image") {
421
+ if (msgItems.length >= MAX_REPLY_MSG_ITEMS) {
422
+ logger.warn(`[WS] Reply contains more than ${MAX_REPLY_MSG_ITEMS} images; extra images were skipped`);
423
+ continue;
424
+ }
425
+ const image = prepareImageBufferForMsgItem(loaded.buffer);
426
+ msgItems.push({
427
+ msgtype: "image",
428
+ image: {
429
+ base64: image.base64,
430
+ md5: image.md5,
431
+ },
432
+ });
433
+ if (mirrorImagesToAgent) {
434
+ mirroredAgentMedia.push({
435
+ mediaUrl,
436
+ mediaType,
437
+ buffer: loaded.buffer,
438
+ filename: resolveReplyMediaFilename(mediaUrl, loaded),
439
+ });
440
+ }
441
+ continue;
442
+ }
443
+
444
+ agentMedia.push({
445
+ mediaUrl,
446
+ mediaType,
447
+ buffer: loaded.buffer,
448
+ filename: resolveReplyMediaFilename(mediaUrl, loaded),
449
+ });
450
+ } catch (error) {
451
+ logger.error(`[WS] Failed to prepare reply media ${mediaUrl}: ${error.message}`);
452
+ }
453
+ }
454
+
455
+ return { msgItems, agentMedia, mirroredAgentMedia };
456
+ }
457
+
458
+ function buildPassiveMediaAgentTarget({ senderId, chatId, isGroupChat }) {
459
+ return isGroupChat ? { chatId } : { toUser: senderId };
460
+ }
461
+
462
+ function buildPassiveMediaNotice(mediaType, { deliveredViaAgent = true } = {}) {
463
+ if (mediaType === "file") {
464
+ return deliveredViaAgent
465
+ ? "由于当前企业微信bot不支持给用户发送文件,文件通过自建应用发送。"
466
+ : "由于当前企业微信bot不支持给用户发送文件,且当前未配置自建应用发送渠道。";
467
+ }
468
+ if (mediaType === "image") {
469
+ return deliveredViaAgent
470
+ ? "由于当前企业微信bot不支持直接发送图片,图片通过自建应用发送。"
471
+ : "由于当前企业微信bot不支持直接发送图片,且当前未配置自建应用发送渠道。";
472
+ }
473
+ return deliveredViaAgent
474
+ ? "由于当前企业微信bot不支持直接发送媒体,媒体通过自建应用发送。"
475
+ : "由于当前企业微信bot不支持直接发送媒体,且当前未配置自建应用发送渠道。";
476
+ }
477
+
478
+ function buildPassiveMediaNoticeBlock(mediaEntries, { deliveredViaAgent = true } = {}) {
479
+ if (!Array.isArray(mediaEntries) || mediaEntries.length === 0) {
480
+ return "";
481
+ }
482
+
483
+ const mediaTypes = [...new Set(mediaEntries.map((entry) => entry.mediaType).filter(Boolean))];
484
+ return mediaTypes
485
+ .map((mediaType) => buildPassiveMediaNotice(mediaType, { deliveredViaAgent }))
486
+ .filter(Boolean)
487
+ .join("\n\n");
488
+ }
489
+
490
+ async function deliverPassiveAgentMedia({
491
+ account,
492
+ senderId,
493
+ chatId,
494
+ isGroupChat,
495
+ text,
496
+ includeText = false,
497
+ includeNotice = true,
498
+ mediaEntries,
499
+ }) {
500
+ if (!Array.isArray(mediaEntries) || mediaEntries.length === 0) {
501
+ return;
502
+ }
503
+
504
+ if (!account?.agentCredentials) {
505
+ logger.warn("[WS] Agent API is not configured; skipped passive non-image media delivery");
506
+ return;
507
+ }
508
+
509
+ applyAccountNetworkConfig(account);
510
+
511
+ const target = buildPassiveMediaAgentTarget({ senderId, chatId, isGroupChat });
512
+ const noticeParts = [];
513
+ if (includeText && text) {
514
+ noticeParts.push(text);
515
+ }
516
+ if (includeNotice) {
517
+ const noticeText = buildPassiveMediaNoticeBlock(mediaEntries);
518
+ if (noticeText) {
519
+ noticeParts.push(noticeText);
520
+ }
521
+ }
522
+ if (noticeParts.length > 0) {
523
+ await agentSendText({
524
+ agent: account.agentCredentials,
525
+ ...target,
526
+ text: noticeParts.join("\n\n"),
527
+ });
528
+ }
529
+
530
+ for (const entry of mediaEntries) {
531
+ const mediaId = await agentUploadMedia({
532
+ agent: account.agentCredentials,
533
+ type: entry.mediaType,
534
+ buffer: entry.buffer,
535
+ filename: entry.filename,
536
+ });
537
+ await agentSendMedia({
538
+ agent: account.agentCredentials,
539
+ ...target,
540
+ mediaId,
541
+ mediaType: entry.mediaType,
542
+ });
543
+ }
544
+ }
545
+
546
+ function resolveWelcomeMessage(account) {
547
+ const configured = String(account?.config?.welcomeMessage ?? "").trim();
548
+ return configured || DEFAULT_WELCOME_MESSAGE;
549
+ }
550
+
551
+ function collectMixedMessageItems({ mixed, textParts, imageUrls, imageAesKeys }) {
552
+ let hasImage = false;
553
+
554
+ if (!Array.isArray(mixed?.msg_item)) {
555
+ return { hasImage };
556
+ }
557
+
558
+ for (const item of mixed.msg_item) {
559
+ if (item.msgtype === "text" && item.text?.content) {
560
+ textParts.push(item.text.content);
561
+ } else if (item.msgtype === "image" && item.image?.url) {
562
+ hasImage = true;
563
+ imageUrls.push(item.image.url);
564
+ if (item.image.aeskey) {
565
+ imageAesKeys.set(item.image.url, item.image.aeskey);
566
+ }
567
+ }
568
+ }
569
+
570
+ return { hasImage };
571
+ }
572
+
573
+ function parseMessageContent(body) {
574
+ const textParts = [];
575
+ const imageUrls = [];
576
+ const imageAesKeys = new Map();
577
+ const fileUrls = [];
578
+ const fileAesKeys = new Map();
579
+ let quoteContent;
580
+
581
+ if (body?.msgtype === "mixed") {
582
+ collectMixedMessageItems({
583
+ mixed: body.mixed,
584
+ textParts,
585
+ imageUrls,
586
+ imageAesKeys,
587
+ });
588
+ } else {
589
+ if (body?.text?.content) {
590
+ textParts.push(body.text.content);
591
+ }
592
+ if (body?.msgtype === "voice" && body?.voice?.content) {
593
+ textParts.push(body.voice.content);
594
+ }
595
+ if (body?.image?.url) {
596
+ imageUrls.push(body.image.url);
597
+ if (body.image.aeskey) {
598
+ imageAesKeys.set(body.image.url, body.image.aeskey);
599
+ }
600
+ }
601
+ if (body?.msgtype === "file" && body?.file?.url) {
602
+ fileUrls.push(body.file.url);
603
+ if (body.file.aeskey) {
604
+ fileAesKeys.set(body.file.url, body.file.aeskey);
605
+ }
606
+ }
607
+ }
608
+
609
+ if (body?.quote) {
610
+ if (body.quote.msgtype === "text" && body.quote.text?.content) {
611
+ quoteContent = body.quote.text.content;
612
+ } else if (body.quote.msgtype === "voice" && body.quote.voice?.content) {
613
+ quoteContent = body.quote.voice.content;
614
+ } else if (body.quote.msgtype === "mixed") {
615
+ const quoteTextParts = [];
616
+ const { hasImage } = collectMixedMessageItems({
617
+ mixed: body.quote.mixed,
618
+ textParts: quoteTextParts,
619
+ imageUrls,
620
+ imageAesKeys,
621
+ });
622
+ quoteContent = quoteTextParts.join("\n").trim();
623
+ if (!quoteContent && hasImage) {
624
+ quoteContent = "[引用图文]";
625
+ }
626
+ } else if (body.quote.msgtype === "image" && body.quote.image?.url) {
627
+ imageUrls.push(body.quote.image.url);
628
+ if (body.quote.image.aeskey) {
629
+ imageAesKeys.set(body.quote.image.url, body.quote.image.aeskey);
630
+ }
631
+ } else if (body.quote.msgtype === "file" && body.quote.file?.url) {
632
+ fileUrls.push(body.quote.file.url);
633
+ if (body.quote.file.aeskey) {
634
+ fileAesKeys.set(body.quote.file.url, body.quote.file.aeskey);
635
+ }
636
+ }
637
+ }
638
+
639
+ return { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent };
640
+ }
641
+
642
+ async function downloadAndSaveMedia({ wsClient, urls, aesKeys, type, runtime, config }) {
643
+ const core = resolveChannelCore(runtime);
644
+ const timeoutMs = type === "image" ? IMAGE_DOWNLOAD_TIMEOUT_MS : FILE_DOWNLOAD_TIMEOUT_MS;
645
+ const mediaMaxMb = config?.agents?.defaults?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
646
+ const maxBytes = mediaMaxMb * 1024 * 1024;
647
+ const mediaList = [];
648
+
649
+ for (const url of urls) {
650
+ try {
651
+ let buffer;
652
+ let filename;
653
+ let contentType = type === "image" ? "image/jpeg" : "application/octet-stream";
654
+
655
+ try {
656
+ const result = await withTimeout(
657
+ wsClient.downloadFile(url, aesKeys?.get(url)),
658
+ timeoutMs,
659
+ `${type} download timed out`,
660
+ );
661
+ buffer = result.buffer;
662
+ filename = result.filename;
663
+ } catch (error) {
664
+ logger.debug(`[WS] SDK ${type} download failed, falling back to core media fetch: ${error.message}`);
665
+ const fetched = await withTimeout(
666
+ core.media.fetchRemoteMedia({ url }),
667
+ timeoutMs,
668
+ `${type} fallback download timed out`,
669
+ );
670
+ buffer = fetched.buffer;
671
+ contentType = fetched.contentType ?? contentType;
672
+ }
673
+
674
+ const saved = await core.media.saveMediaBuffer(buffer, contentType, "inbound", maxBytes, filename);
675
+ mediaList.push({ path: saved.path, contentType: saved.contentType });
676
+ } catch (error) {
677
+ logger.error(`[WS] Failed to download ${type}: ${error.message}`);
678
+ }
679
+ }
680
+
681
+ return mediaList;
682
+ }
683
+
684
+ async function sendWsReply({ wsClient, frame, text, finish = true, streamId, msgItem, accountId }) {
685
+ const normalizedText = normalizeThinkingTags(typeof text === "string" ? text : "");
686
+ if (!normalizedText && (!Array.isArray(msgItem) || msgItem.length === 0)) {
687
+ return streamId;
688
+ }
689
+ if (!wsClient?.isConnected) {
690
+ throw new Error("WS client is not connected");
691
+ }
692
+
693
+ const chatId = frame?.body?.chatid || frame?.body?.from?.userid;
694
+ if (finish && accountId && chatId) {
695
+ const quota = forecastReplyQuota({ accountId, chatId });
696
+ if (quota.windowActive && (quota.nearLimit || quota.exhausted)) {
697
+ logger.warn(`[WS:${accountId}] Reply quota is ${quota.exhausted ? "exhausted" : "near limit"}`, {
698
+ chatId,
699
+ used: quota.used,
700
+ limit: quota.limit,
701
+ remaining: quota.remaining,
702
+ });
703
+ }
704
+ }
705
+
706
+ const resolvedStreamId = streamId || generateReqId("stream");
707
+
708
+ await withTimeout(
709
+ wsClient.replyStream(frame, resolvedStreamId, normalizedText, finish, msgItem),
710
+ REPLY_SEND_TIMEOUT_MS,
711
+ `Reply timed out (streamId=${resolvedStreamId})`,
712
+ );
713
+
714
+ if (finish && accountId && chatId) {
715
+ recordPassiveReply({ accountId, chatId });
716
+ }
717
+
718
+ return resolvedStreamId;
719
+ }
720
+
721
+ function resolveOutboundChatId(to) {
722
+ return String(to ?? "")
723
+ .trim()
724
+ .replace(/^wecom:/i, "")
725
+ .replace(/^group:/i, "")
726
+ .replace(/^user:/i, "");
727
+ }
728
+
729
+ export async function sendWsMessage({ to, content, accountId = "default" }) {
730
+ const chatId = resolveOutboundChatId(to);
731
+ const wsClient = getWsClient(accountId);
732
+
733
+ if (!chatId) {
734
+ throw new Error("Missing chat target for WeCom WS send");
735
+ }
736
+ if (!wsClient || !wsClient.isConnected) {
737
+ throw new Error(`WS client is not connected for account ${accountId}`);
738
+ }
739
+
740
+ const quota = forecastActiveSendQuota({ accountId, chatId });
741
+ if (quota.nearLimit || quota.exhausted) {
742
+ logger.warn(`[WS:${accountId}] Active send quota is ${quota.exhausted ? "exhausted" : "near limit"}`, {
743
+ chatId,
744
+ bucket: quota.bucket,
745
+ used: quota.used,
746
+ limit: quota.limit,
747
+ remaining: quota.remaining,
748
+ replyWindowActive: quota.windowActive ?? false,
749
+ });
750
+ }
751
+
752
+ const result = await wsClient.sendMessage(chatId, {
753
+ msgtype: "markdown",
754
+ markdown: { content },
755
+ });
756
+
757
+ recordActiveSend({ accountId, chatId });
758
+
759
+ return {
760
+ channel: CHANNEL_ID,
761
+ messageId: result?.headers?.req_id ?? `wecom-ws-${Date.now()}`,
762
+ chatId,
763
+ };
764
+ }
765
+
766
+ /**
767
+ * Flush pending replies via the Agent API after WS reconnection.
768
+ * Called when the WS authenticates and there are queued unsent final replies.
769
+ */
770
+ async function flushPendingRepliesViaAgentApi(account) {
771
+ const entries = drainPendingReplies(account.accountId);
772
+ if (entries.length === 0) {
773
+ return;
774
+ }
775
+
776
+ logger.info(`[WS:${account.accountId}] Flushing ${entries.length} pending replies via Agent API`);
777
+ applyAccountNetworkConfig(account);
778
+
779
+ for (const entry of entries) {
780
+ try {
781
+ const target = entry.isGroupChat ? { chatId: entry.chatId } : { toUser: entry.senderId };
782
+ await agentSendText({
783
+ agent: account.agentCredentials,
784
+ ...target,
785
+ text: entry.text,
786
+ });
787
+ recordOutboundActivity({ accountId: account.accountId });
788
+ logger.info(`[WS:${account.accountId}] Pending reply delivered via Agent API`, {
789
+ chatId: entry.chatId,
790
+ senderId: entry.senderId,
791
+ textLength: entry.text.length,
792
+ });
793
+ } catch (sendError) {
794
+ logger.error(`[WS:${account.accountId}] Failed to deliver pending reply via Agent API: ${sendError.message}`, {
795
+ chatId: entry.chatId,
796
+ senderId: entry.senderId,
797
+ });
798
+ }
799
+ }
800
+ }
801
+
802
+ async function sendThinkingReply({ wsClient, frame, streamId }) {
803
+ try {
804
+ await sendWsReply({
805
+ wsClient,
806
+ frame,
807
+ streamId,
808
+ text: THINKING_MESSAGE,
809
+ finish: false,
810
+ });
811
+ } catch (error) {
812
+ logger.error(`[WS] Failed to send thinking reply: ${error.message}`);
813
+ }
814
+ }
815
+
816
+ function buildInboundContext({
817
+ runtime,
818
+ config,
819
+ account,
820
+ frame,
821
+ body,
822
+ text,
823
+ mediaList,
824
+ route,
825
+ senderId,
826
+ chatId,
827
+ isGroupChat,
828
+ }) {
829
+ const core = resolveChannelCore(runtime);
830
+ const storePath = core.session.resolveStorePath(config.session?.store, { agentId: route.agentId });
831
+ const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
832
+ const previousTimestamp = core.session.readSessionUpdatedAt({
833
+ storePath,
834
+ sessionKey: route.sessionKey,
835
+ });
836
+
837
+ const senderLabel = isGroupChat ? `[${senderId}]` : senderId;
838
+ const hasImages = mediaList.some((entry) => entry.contentType?.startsWith("image/"));
839
+ const messageBody =
840
+ text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
841
+ const formattedBody = core.reply.formatAgentEnvelope({
842
+ channel: isGroupChat ? "Enterprise WeChat Group" : "Enterprise WeChat",
843
+ from: senderLabel,
844
+ timestamp: Date.now(),
845
+ previousTimestamp,
846
+ envelope: envelopeOptions,
847
+ body: messageBody,
848
+ });
849
+
850
+ const context = {
851
+ Body: formattedBody || messageBody,
852
+ BodyForAgent: buildBodyForAgent(formattedBody || messageBody, config, route.agentId),
853
+ RawBody: text || messageBody,
854
+ CommandBody: text || messageBody,
855
+ MessageSid: body.msgid,
856
+ From: isGroupChat ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${senderId}`,
857
+ To: isGroupChat ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${senderId}`,
858
+ SenderId: senderId,
859
+ SessionKey: route.sessionKey,
860
+ AccountId: account.accountId,
861
+ ChatType: isGroupChat ? "group" : "direct",
862
+ ConversationLabel: isGroupChat ? `Group ${chatId}` : senderId,
863
+ SenderName: senderId,
864
+ GroupId: isGroupChat ? chatId : undefined,
865
+ Timestamp: Date.now(),
866
+ Provider: CHANNEL_ID,
867
+ Surface: CHANNEL_ID,
868
+ OriginatingChannel: CHANNEL_ID,
869
+ OriginatingTo: isGroupChat ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${senderId}`,
870
+ CommandAuthorized: true,
871
+ ReqId: frame.headers.req_id,
872
+ WeComFrame: frame,
873
+ };
874
+
875
+ if (mediaList.length > 0) {
876
+ context.MediaPaths = mediaList.map((entry) => entry.path);
877
+ context.MediaTypes = mediaList.map((entry) => entry.contentType).filter(Boolean);
878
+
879
+ if (!text) {
880
+ const imageCount = mediaList.filter((entry) => entry.contentType?.startsWith("image/")).length;
881
+ context.Body =
882
+ imageCount > 1 ? `[用户发送了${imageCount}张图片]` : imageCount === 1 ? "[用户发送了一张图片]" : "[用户发送了文件]";
883
+ context.RawBody = imageCount > 0 ? "[图片]" : "[文件]";
884
+ context.CommandBody = "";
885
+ }
886
+ }
887
+
888
+ return { ctxPayload: core.reply.finalizeInboundContext(context), storePath };
889
+ }
890
+
891
+ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
892
+ const core = resolveChannelCore(runtime);
893
+ const body = frame?.body ?? {};
894
+ const senderId = body?.from?.userid;
895
+ const chatId = body?.chatid || senderId;
896
+ const messageId = body?.msgid;
897
+ const reqId = frame?.headers?.req_id;
898
+ const isGroupChat = body?.chattype === "group";
899
+
900
+ if (!senderId || !chatId || !messageId || !reqId) {
901
+ logger.warn("[WS] Ignoring malformed frame", {
902
+ hasSender: Boolean(senderId),
903
+ hasChatId: Boolean(chatId),
904
+ hasMessageId: Boolean(messageId),
905
+ hasReqId: Boolean(reqId),
906
+ });
907
+ return;
908
+ }
909
+
910
+ const dedupKey = `${account.accountId}:${messageId}`;
911
+ if (inboundMessageDeduplicator.isDuplicate(dedupKey)) {
912
+ logger.debug(`[WS:${account.accountId}] Ignoring duplicate inbound message`, {
913
+ messageId,
914
+ senderId,
915
+ chatId,
916
+ });
917
+ return;
918
+ }
919
+
920
+ recordInboundMessage({ accountId: account.accountId, chatId });
921
+
922
+ const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
923
+ const originalText = textParts.join("\n").trim();
924
+ let text = originalText;
925
+
926
+ if (!text && quoteContent) {
927
+ text = quoteContent;
928
+ }
929
+
930
+ if (body?.quote && quoteContent && text && quoteContent !== text) {
931
+ const quoteLabel =
932
+ body.quote.msgtype === "image"
933
+ ? "[引用图片]"
934
+ : body.quote.msgtype === "mixed" && quoteContent === "[引用图文]"
935
+ ? "[引用图文]"
936
+ : `> ${quoteContent}`;
937
+ text = `${quoteLabel}\n\n${text}`;
938
+ }
939
+
940
+ if (!text && imageUrls.length === 0 && fileUrls.length === 0) {
941
+ logger.debug("[WS] Ignoring empty message", { chatId, senderId, accountId: account.accountId });
942
+ return;
943
+ }
944
+
945
+ if (isGroupChat) {
946
+ const groupPolicyResult = checkGroupPolicy({ chatId, senderId, account, config });
947
+ if (!groupPolicyResult.allowed) {
948
+ return;
949
+ }
950
+ }
951
+
952
+ const dmPolicyResult = await checkDmPolicy({
953
+ senderId,
954
+ isGroup: isGroupChat,
955
+ account,
956
+ wsClient,
957
+ frame,
958
+ core,
959
+ sendReply: async ({ frame: replyFrame, text: replyText, finish, streamId }) => {
960
+ await sendWsReply({
961
+ wsClient,
962
+ frame: replyFrame,
963
+ text: replyText,
964
+ finish,
965
+ streamId,
966
+ accountId: account.accountId,
967
+ });
968
+ },
969
+ });
970
+ if (!dmPolicyResult.allowed) {
971
+ return;
972
+ }
973
+
974
+ if (isGroupChat) {
975
+ if (!shouldTriggerGroupResponse(originalText, account.config)) {
976
+ logger.debug("[WS] Group message ignored because mention gating was not satisfied", {
977
+ accountId: account.accountId,
978
+ chatId,
979
+ senderId,
980
+ });
981
+ return;
982
+ }
983
+ text = extractGroupMessageContent(originalText, account.config).replace(/@\S+/g, "").trim();
984
+ }
985
+
986
+ const senderIsAdmin = isWecomAdmin(senderId, account.config);
987
+ const commandAuthorized = resolveWecomCommandAuthorized({
988
+ cfg: config,
989
+ accountId: account.accountId,
990
+ senderId,
991
+ });
992
+ const commandCheck = checkCommandAllowlist(text, account.config);
993
+ if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
994
+ await sendWsReply({
995
+ wsClient,
996
+ frame,
997
+ streamId: generateReqId("command"),
998
+ text: getCommandConfig(account.config).blockMessage,
999
+ finish: true,
1000
+ accountId: account.accountId,
1001
+ });
1002
+ return;
1003
+ }
1004
+
1005
+ const [imageMediaList, fileMediaList] = await Promise.all([
1006
+ downloadAndSaveMedia({
1007
+ wsClient,
1008
+ urls: imageUrls,
1009
+ aesKeys: imageAesKeys,
1010
+ type: "image",
1011
+ runtime,
1012
+ config,
1013
+ }),
1014
+ downloadAndSaveMedia({
1015
+ wsClient,
1016
+ urls: fileUrls,
1017
+ aesKeys: fileAesKeys,
1018
+ type: "file",
1019
+ runtime,
1020
+ config,
1021
+ }),
1022
+ ]);
1023
+ const mediaList = [...imageMediaList, ...fileMediaList];
1024
+
1025
+ const streamId = generateReqId("stream");
1026
+ const state = { accumulatedText: "", reasoningText: "", streamId, replyMediaUrls: [] };
1027
+ setMessageState(messageId, state);
1028
+
1029
+ // Throttle reasoning stream updates to avoid exceeding the SDK's per-reqId queue limit (100).
1030
+ let lastReasoningSendAt = 0;
1031
+ let pendingReasoningTimer = null;
1032
+ const sendReasoningUpdate = async () => {
1033
+ lastReasoningSendAt = Date.now();
1034
+ try {
1035
+ await sendWsReply({
1036
+ wsClient,
1037
+ frame,
1038
+ streamId: state.streamId,
1039
+ text: buildWsStreamContent({
1040
+ reasoningText: state.reasoningText,
1041
+ visibleText: state.accumulatedText,
1042
+ finish: false,
1043
+ }),
1044
+ finish: false,
1045
+ accountId: account.accountId,
1046
+ });
1047
+ } catch (error) {
1048
+ logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
1049
+ }
1050
+ };
1051
+
1052
+ const cleanupState = () => {
1053
+ deleteMessageState(messageId);
1054
+ if (pendingReasoningTimer) {
1055
+ clearTimeout(pendingReasoningTimer);
1056
+ pendingReasoningTimer = null;
1057
+ }
1058
+ };
1059
+
1060
+ if (account.sendThinkingMessage !== false) {
1061
+ await sendThinkingReply({ wsClient, frame, streamId });
1062
+ }
1063
+
1064
+ const peerKind = isGroupChat ? "group" : "dm";
1065
+ const peerId = isGroupChat ? chatId : senderId;
1066
+ const dynamicConfig = getDynamicAgentConfig(account.config);
1067
+ const dynamicAgentId =
1068
+ dynamicConfig.enabled &&
1069
+ shouldUseDynamicAgent({ chatType: peerKind, config: account.config, senderIsAdmin })
1070
+ ? generateAgentId(peerKind, peerId, account.accountId)
1071
+ : null;
1072
+
1073
+ if (dynamicAgentId) {
1074
+ await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate);
1075
+ }
1076
+
1077
+ const route = core.routing.resolveAgentRoute({
1078
+ cfg: config,
1079
+ channel: CHANNEL_ID,
1080
+ accountId: account.accountId,
1081
+ peer: { kind: peerKind, id: peerId },
1082
+ });
1083
+
1084
+ const hasExplicitBinding =
1085
+ Array.isArray(config?.bindings) &&
1086
+ config.bindings.some(
1087
+ (binding) => binding.match?.channel === CHANNEL_ID && binding.match?.accountId === account.accountId,
1088
+ );
1089
+
1090
+ if (dynamicAgentId && !hasExplicitBinding) {
1091
+ route.agentId = dynamicAgentId;
1092
+ route.sessionKey = `agent:${dynamicAgentId}:${peerKind}:${peerId}`;
1093
+ }
1094
+
1095
+ const { ctxPayload, storePath } = buildInboundContext({
1096
+ runtime,
1097
+ config,
1098
+ account,
1099
+ frame,
1100
+ body,
1101
+ text,
1102
+ mediaList,
1103
+ route,
1104
+ senderId,
1105
+ chatId,
1106
+ isGroupChat,
1107
+ });
1108
+ ctxPayload.CommandAuthorized = commandAuthorized;
1109
+
1110
+ void core.session
1111
+ .recordSessionMetaFromInbound({
1112
+ storePath,
1113
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1114
+ ctx: ctxPayload,
1115
+ })
1116
+ .catch((error) => logger.error(`[WS] Failed to record session metadata: ${error.message}`));
1117
+
1118
+ const runDispatch = async () => {
1119
+ let cleanedUp = false;
1120
+ const safeCleanup = () => {
1121
+ if (cleanedUp) {
1122
+ return;
1123
+ }
1124
+ cleanedUp = true;
1125
+ cleanupState();
1126
+ };
1127
+
1128
+ try {
1129
+ await streamContext.run(
1130
+ { streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
1131
+ async () => {
1132
+ await core.reply.dispatchReplyWithBufferedBlockDispatcher({
1133
+ ctx: ctxPayload,
1134
+ cfg: config,
1135
+ replyOptions: {
1136
+ disableBlockStreaming: false,
1137
+ onReasoningStream: async (payload) => {
1138
+ const nextReasoning = normalizeReasoningStreamText(payload?.text);
1139
+ if (!nextReasoning) {
1140
+ return;
1141
+ }
1142
+ state.reasoningText = nextReasoning;
1143
+
1144
+ // Throttle: skip if sent recently, schedule a trailing update instead.
1145
+ const elapsed = Date.now() - lastReasoningSendAt;
1146
+ if (elapsed < REASONING_STREAM_THROTTLE_MS) {
1147
+ if (!pendingReasoningTimer) {
1148
+ pendingReasoningTimer = setTimeout(async () => {
1149
+ pendingReasoningTimer = null;
1150
+ await sendReasoningUpdate();
1151
+ }, REASONING_STREAM_THROTTLE_MS - elapsed);
1152
+ }
1153
+ return;
1154
+ }
1155
+ await sendReasoningUpdate();
1156
+ },
1157
+ },
1158
+ dispatcherOptions: {
1159
+ deliver: async (payload, info) => {
1160
+ const normalized = normalizeReplyPayload(payload);
1161
+ const chunk = normalized.text;
1162
+ const mediaUrls = normalized.mediaUrls;
1163
+ for (const mediaUrl of mediaUrls) {
1164
+ if (!state.replyMediaUrls.includes(mediaUrl)) {
1165
+ state.replyMediaUrls.push(mediaUrl);
1166
+ }
1167
+ }
1168
+
1169
+ state.accumulatedText += chunk;
1170
+ if (info.kind !== "final") {
1171
+ await sendWsReply({
1172
+ wsClient,
1173
+ frame,
1174
+ streamId: state.streamId,
1175
+ text: buildWsStreamContent({
1176
+ reasoningText: state.reasoningText,
1177
+ visibleText: state.accumulatedText,
1178
+ finish: false,
1179
+ }),
1180
+ finish: false,
1181
+ accountId: account.accountId,
1182
+ });
1183
+ }
1184
+ },
1185
+ onError: (error, info) => {
1186
+ logger.error(`[WS] ${info.kind} reply failed: ${error.message}`);
1187
+ },
1188
+ },
1189
+ });
1190
+ },
1191
+ );
1192
+
1193
+ const preparedReplyMedia = await prepareReplyMediaOutputs({
1194
+ payload: { mediaUrls: state.replyMediaUrls },
1195
+ runtime,
1196
+ config,
1197
+ agentId: route.agentId,
1198
+ mirrorImagesToAgent: Boolean(account?.agentCredentials),
1199
+ });
1200
+ const msgItem = preparedReplyMedia.msgItems;
1201
+ const deferredMediaDeliveredViaAgent = Boolean(account?.agentCredentials);
1202
+ const finalReplyText = buildWsStreamContent({
1203
+ reasoningText: state.reasoningText,
1204
+ visibleText: state.accumulatedText,
1205
+ finish: true,
1206
+ });
1207
+ const passiveMediaNotice = buildPassiveMediaNoticeBlock(preparedReplyMedia.agentMedia, {
1208
+ deliveredViaAgent: deferredMediaDeliveredViaAgent,
1209
+ });
1210
+ const finalWsText = [finalReplyText, passiveMediaNotice].filter(Boolean).join("\n\n");
1211
+
1212
+ if (preparedReplyMedia.agentMedia.length > 0 && !account?.agentCredentials) {
1213
+ logger.warn("[WS] Agent API is not configured; passive non-image media delivery was skipped");
1214
+ }
1215
+
1216
+ if (finalWsText || msgItem.length > 0) {
1217
+ logger.info("[WS] Sending passive final reply", {
1218
+ accountId: account.accountId,
1219
+ agentId: route.agentId,
1220
+ streamId: state.streamId,
1221
+ textLength: finalWsText.length,
1222
+ imageItemCount: msgItem.length,
1223
+ deferredAgentMediaCount: preparedReplyMedia.agentMedia.length,
1224
+ mirroredAgentImageCount: preparedReplyMedia.mirroredAgentMedia.length,
1225
+ });
1226
+ try {
1227
+ await sendWsReply({
1228
+ wsClient,
1229
+ frame,
1230
+ streamId: state.streamId,
1231
+ text: finalWsText,
1232
+ finish: true,
1233
+ msgItem,
1234
+ accountId: account.accountId,
1235
+ });
1236
+ } catch (sendError) {
1237
+ // WS disconnected or timed out — enqueue for retry via Agent API on reconnect.
1238
+ logger.warn(`[WS] Final reply send failed, enqueuing for retry: ${sendError.message}`, {
1239
+ accountId: account.accountId,
1240
+ chatId,
1241
+ senderId,
1242
+ });
1243
+ enqueuePendingReply(account.accountId, {
1244
+ text: finalWsText,
1245
+ senderId,
1246
+ chatId,
1247
+ isGroupChat,
1248
+ });
1249
+ }
1250
+ }
1251
+
1252
+ if (account?.agentCredentials && preparedReplyMedia.mirroredAgentMedia.length > 0) {
1253
+ await deliverPassiveAgentMedia({
1254
+ account,
1255
+ senderId,
1256
+ chatId,
1257
+ isGroupChat,
1258
+ text: state.accumulatedText,
1259
+ includeText: false,
1260
+ includeNotice: false,
1261
+ mediaEntries: preparedReplyMedia.mirroredAgentMedia,
1262
+ });
1263
+ }
1264
+
1265
+ if (account?.agentCredentials && preparedReplyMedia.agentMedia.length > 0) {
1266
+ await deliverPassiveAgentMedia({
1267
+ account,
1268
+ senderId,
1269
+ chatId,
1270
+ isGroupChat,
1271
+ text: finalWsText,
1272
+ includeText: false,
1273
+ includeNotice: false,
1274
+ mediaEntries: preparedReplyMedia.agentMedia,
1275
+ });
1276
+ }
1277
+ safeCleanup();
1278
+ } catch (error) {
1279
+ logger.error(`[WS] Failed to dispatch reply: ${error.message}`);
1280
+ try {
1281
+ await sendWsReply({
1282
+ wsClient,
1283
+ frame,
1284
+ streamId: state.streamId,
1285
+ text: "处理消息时出错,请稍后再试。",
1286
+ finish: true,
1287
+ accountId: account.accountId,
1288
+ });
1289
+ } catch (retryError) {
1290
+ // If the error reply also fails (WS disconnected), enqueue accumulated text if any.
1291
+ if (state.accumulatedText) {
1292
+ enqueuePendingReply(account.accountId, {
1293
+ text: state.accumulatedText,
1294
+ senderId,
1295
+ chatId,
1296
+ isGroupChat,
1297
+ });
1298
+ }
1299
+ }
1300
+ safeCleanup();
1301
+ }
1302
+ };
1303
+
1304
+ const lockKey = `${account.accountId}:${peerId}`;
1305
+ const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
1306
+ const current = previous.then(runDispatch, runDispatch);
1307
+ dispatchLocks.set(lockKey, current);
1308
+ current.finally(() => {
1309
+ if (dispatchLocks.get(lockKey) === current) {
1310
+ dispatchLocks.delete(lockKey);
1311
+ }
1312
+ });
1313
+ }
1314
+
1315
+ export async function startWsMonitor({ account, config, runtime, abortSignal, wsClientFactory }) {
1316
+ if (!account.botId || !account.secret) {
1317
+ throw new Error(`Missing botId or secret for account ${account.accountId}`);
1318
+ }
1319
+
1320
+ setOpenclawConfig(config);
1321
+ startMessageStateCleanup();
1322
+
1323
+ const wsClient =
1324
+ typeof wsClientFactory === "function"
1325
+ ? wsClientFactory({
1326
+ account,
1327
+ config,
1328
+ runtime,
1329
+ createSdkLogger,
1330
+ })
1331
+ : new WSClient({
1332
+ botId: account.botId,
1333
+ secret: account.secret,
1334
+ wsUrl: account.websocketUrl,
1335
+ logger: createSdkLogger(account.accountId),
1336
+ heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
1337
+ maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
1338
+ });
1339
+
1340
+ return new Promise((resolve, reject) => {
1341
+ let settled = false;
1342
+
1343
+ const cleanup = async () => {
1344
+ await cleanupWsAccount(account.accountId);
1345
+ };
1346
+
1347
+ const settle = async ({ error = null } = {}) => {
1348
+ if (settled) {
1349
+ return;
1350
+ }
1351
+ settled = true;
1352
+ await cleanup();
1353
+ if (error) {
1354
+ reject(error);
1355
+ return;
1356
+ }
1357
+ resolve();
1358
+ };
1359
+
1360
+ if (abortSignal?.aborted) {
1361
+ void settle();
1362
+ return;
1363
+ }
1364
+
1365
+ if (abortSignal) {
1366
+ abortSignal.addEventListener(
1367
+ "abort",
1368
+ async () => {
1369
+ logger.info(`[WS:${account.accountId}] Abort signal received`);
1370
+ await settle();
1371
+ },
1372
+ { once: true },
1373
+ );
1374
+ }
1375
+
1376
+ wsClient.on("connected", () => {
1377
+ logger.info(`[WS:${account.accountId}] Connected`);
1378
+ });
1379
+
1380
+ wsClient.on("authenticated", () => {
1381
+ logger.info(`[WS:${account.accountId}] Authenticated`);
1382
+ clearAccountDisplaced(account.accountId);
1383
+ setWsClient(account.accountId, wsClient);
1384
+
1385
+ // Drain pending replies that failed due to prior WS disconnection.
1386
+ if (account?.agentCredentials && hasPendingReplies(account.accountId)) {
1387
+ void flushPendingRepliesViaAgentApi(account).catch((flushError) => {
1388
+ logger.error(`[WS:${account.accountId}] Failed to flush pending replies: ${flushError.message}`);
1389
+ });
1390
+ }
1391
+ });
1392
+
1393
+ wsClient.on("disconnected", (reason) => {
1394
+ logger.info(`[WS:${account.accountId}] Disconnected: ${reason}`);
1395
+ });
1396
+
1397
+ wsClient.on("reconnecting", (attempt) => {
1398
+ logger.info(`[WS:${account.accountId}] Reconnecting attempt ${attempt}`);
1399
+ });
1400
+
1401
+ wsClient.on("error", (error) => {
1402
+ logger.error(`[WS:${account.accountId}] ${error.message}`);
1403
+ if (error.message.includes("Authentication failed")) {
1404
+ void settle({ error });
1405
+ }
1406
+ });
1407
+
1408
+ wsClient.on("message", async (frame) => {
1409
+ try {
1410
+ await withTimeout(
1411
+ processWsMessage({ frame, account, config, runtime, wsClient }),
1412
+ MESSAGE_PROCESS_TIMEOUT_MS,
1413
+ `Message processing timed out (msgId=${frame?.body?.msgid ?? "unknown"})`,
1414
+ );
1415
+ } catch (error) {
1416
+ logger.error(`[WS:${account.accountId}] Failed to process inbound message: ${error.message}`);
1417
+ }
1418
+ });
1419
+
1420
+ wsClient.on("event.enter_chat", async (frame) => {
1421
+ try {
1422
+ await wsClient.replyWelcome(frame, {
1423
+ msgtype: "text",
1424
+ text: {
1425
+ content: resolveWelcomeMessage(account),
1426
+ },
1427
+ });
1428
+ recordOutboundActivity({ accountId: account.accountId });
1429
+ } catch (error) {
1430
+ logger.error(`[WS:${account.accountId}] Failed to send welcome reply: ${error.message}`);
1431
+ }
1432
+ });
1433
+
1434
+ wsClient.on("event.template_card_event", (frame) => {
1435
+ logger.info(`[WS:${account.accountId}] Template card event received`, {
1436
+ msgId: frame?.body?.msgid,
1437
+ chatId: frame?.body?.chatid,
1438
+ senderId: frame?.body?.from?.userid,
1439
+ event: frame?.body?.event,
1440
+ });
1441
+ });
1442
+
1443
+ wsClient.on("event.feedback_event", (frame) => {
1444
+ logger.info(`[WS:${account.accountId}] Feedback event received`, {
1445
+ msgId: frame?.body?.msgid,
1446
+ chatId: frame?.body?.chatid,
1447
+ senderId: frame?.body?.from?.userid,
1448
+ event: frame?.body?.event,
1449
+ });
1450
+ });
1451
+
1452
+ wsClient.on("event.disconnected_event", (frame) => {
1453
+ const takeoverError = new Error(
1454
+ `WeCom botId "${account.botId}" was taken over by another connection. Only one active connection per botId is allowed.`,
1455
+ );
1456
+ markAccountDisplaced({
1457
+ accountId: account.accountId,
1458
+ reason: takeoverError.message,
1459
+ });
1460
+ logger.error(`[WS:${account.accountId}] Received disconnected_event; stopping reconnects`, {
1461
+ msgId: frame?.body?.msgid,
1462
+ createTime: frame?.body?.create_time,
1463
+ botId: account.botId,
1464
+ });
1465
+ try {
1466
+ wsClient.disconnect();
1467
+ } catch {
1468
+ // Ignore secondary disconnect errors.
1469
+ }
1470
+ void settle({ error: takeoverError });
1471
+ });
1472
+
1473
+ if (!settled) {
1474
+ wsClient.connect();
1475
+ }
1476
+ });
1477
+ }
1478
+
1479
+ export const wsMonitorTesting = {
1480
+ processWsMessage,
1481
+ parseMessageContent,
1482
+ splitReplyMediaFromText,
1483
+ buildBodyForAgent,
1484
+ flushPendingRepliesViaAgentApi,
1485
+ };
1486
+
1487
+ export { buildReplyMediaGuidance };