@mocrane/wecom 2026.3.4 → 2026.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mocrane/wecom",
3
- "version": "2026.3.4",
3
+ "version": "2026.3.9",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "main": "index.ts",
@@ -42,6 +42,7 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
+ "@wecom/aibot-node-sdk": "^1.0.0",
45
46
  "fast-xml-parser": "5.3.4",
46
47
  "undici": "^7.20.0",
47
48
  "zod": "^4.3.6"
package/src/channel.ts CHANGED
@@ -15,9 +15,9 @@ import {
15
15
  resolveWecomAccount,
16
16
  resolveWecomAccountConflict,
17
17
  } from "./config/index.js";
18
- import type { ResolvedWecomAccount } from "./types/index.js";
18
+ import type { ResolvedWecomAccount, WecomBotConfig } from "./types/index.js";
19
19
  import { monitorWecomProvider } from "./gateway-monitor.js";
20
- import { wecomOnboardingAdapter } from "./onboarding.js";
20
+ import { setWecomBotConfig, wecomOnboardingAdapter } from "./onboarding.js";
21
21
  import { wecomOutbound } from "./outbound.js";
22
22
  import { WEBHOOK_PATHS } from "./types/constants.js";
23
23
 
@@ -43,6 +43,42 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
43
43
  id: "wecom",
44
44
  meta,
45
45
  onboarding: wecomOnboardingAdapter,
46
+ setup: {
47
+ resolveAccountId: ({ cfg, accountId }) => {
48
+ return accountId?.trim() || resolveDefaultWecomAccountId(cfg as OpenClawConfig) || DEFAULT_ACCOUNT_ID;
49
+ },
50
+ applyAccountConfig: ({ cfg, accountId, input }) => {
51
+ const isWsMode = input.url === "ws" || input.url === "websocket";
52
+
53
+ if (isWsMode) {
54
+ // websocket 模式: --bot-token → botId, --token → secret
55
+ const botConfig: WecomBotConfig = {
56
+ connectionMode: "websocket",
57
+ botId: input.botToken?.trim() || undefined,
58
+ secret: input.token?.trim() || undefined,
59
+ };
60
+ return setWecomBotConfig(cfg as OpenClawConfig, botConfig, accountId);
61
+ }
62
+
63
+ // webhook 模式: --token → token, --access-token → encodingAESKey
64
+ const botConfig: WecomBotConfig = {
65
+ connectionMode: "webhook",
66
+ token: input.token?.trim() ?? "",
67
+ encodingAESKey: input.accessToken?.trim() ?? "",
68
+ };
69
+ return setWecomBotConfig(cfg as OpenClawConfig, botConfig, accountId);
70
+ },
71
+ validateInput: ({ input }) => {
72
+ const isWsMode = input.url === "ws" || input.url === "websocket";
73
+ if (isWsMode) {
74
+ if (!input.botToken?.trim()) return "websocket 模式需要 --bot-token <BotID>";
75
+ if (!input.token?.trim()) return "websocket 模式需要 --token <Secret>";
76
+ } else {
77
+ if (!input.token?.trim()) return "webhook 模式需要 --token <Token>";
78
+ }
79
+ return null;
80
+ },
81
+ },
46
82
  capabilities: {
47
83
  chatTypes: ["direct", "group"],
48
84
  media: true,
@@ -46,15 +46,22 @@ export function detectMode(config: WecomConfig | undefined): ResolvedMode {
46
46
  * 解析 Bot 模式账号
47
47
  */
48
48
  function resolveBotAccount(accountId: string, config: WecomBotConfig, network?: WecomNetworkConfig): ResolvedBotAccount {
49
+ const connectionMode = config.connectionMode ?? 'webhook';
50
+ const configured = connectionMode === 'websocket'
51
+ ? Boolean(config.botId && config.secret)
52
+ : Boolean(config.token && config.encodingAESKey);
49
53
  return {
50
54
  accountId,
51
55
  enabled: true,
52
- configured: Boolean(config.token && config.encodingAESKey),
53
- token: config.token,
54
- encodingAESKey: config.encodingAESKey,
56
+ configured,
57
+ token: config.token ?? "",
58
+ encodingAESKey: config.encodingAESKey ?? "",
55
59
  receiveId: config.receiveId?.trim() ?? "",
56
60
  config,
57
61
  network,
62
+ connectionMode,
63
+ botId: config.botId,
64
+ secret: config.secret,
58
65
  };
59
66
  }
60
67
 
@@ -81,13 +81,17 @@ const routingSchema = z.object({
81
81
  */
82
82
  const botSchema = z.object({
83
83
  aibotid: z.string().optional(),
84
- token: z.string(),
85
- encodingAESKey: z.string(),
84
+ token: z.string().optional(),
85
+ encodingAESKey: z.string().optional(),
86
86
  botIds: z.array(z.string()).optional(),
87
87
  receiveId: z.string().optional(),
88
88
  streamPlaceholderContent: z.string().optional(),
89
89
  welcomeText: z.string().optional(),
90
90
  dm: dmSchema,
91
+ // 长链接模式 (WebSocket)
92
+ connectionMode: z.enum(['webhook', 'websocket']).optional(),
93
+ botId: z.string().optional(),
94
+ secret: z.string().optional(),
91
95
  }).optional();
92
96
 
93
97
  /**
@@ -11,6 +11,7 @@ import {
11
11
  resolveWecomAccountConflict,
12
12
  } from "./config/index.js";
13
13
  import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
14
+ import { startWsClient } from "./ws-adapter.js";
14
15
  import type { ResolvedWecomAccount, WecomConfig } from "./types/index.js";
15
16
  import { WEBHOOK_PATHS } from "./types/constants.js";
16
17
 
@@ -164,26 +165,47 @@ export async function monitorWecomProvider(
164
165
  const agentPaths: string[] = [];
165
166
  try {
166
167
  if (bot && botConfigured) {
167
- const paths = resolveBotRegistrationPaths({
168
- accountId: account.accountId,
169
- matrixMode,
170
- });
171
- for (const path of paths) {
168
+ const connectionMode = bot.connectionMode ?? 'webhook';
169
+
170
+ if (connectionMode === 'websocket') {
171
+ // 长链接模式:启动 WSClient
172
172
  unregisters.push(
173
- registerWecomWebhookTarget({
173
+ startWsClient({
174
+ accountId: account.accountId,
175
+ botId: bot.botId!,
176
+ secret: bot.secret!,
174
177
  account: bot,
175
178
  config: cfg,
176
179
  runtime: ctx.runtime,
177
- // The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
178
- // The stored target only needs to be decrypt/verify-capable.
179
180
  core: {} as PluginRuntime,
180
- path,
181
181
  statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
182
+ welcomeText: bot.config.welcomeText,
183
+ network: bot.network,
182
184
  }),
183
185
  );
186
+ botPaths.push(`ws://${account.accountId}`);
187
+ ctx.log?.info(`[${account.accountId}] wecom bot websocket client started (botId=${bot.botId})`);
188
+ } else {
189
+ // Webhook 模式:注册 HTTP 路径(现有逻辑不变)
190
+ const paths = resolveBotRegistrationPaths({
191
+ accountId: account.accountId,
192
+ matrixMode,
193
+ });
194
+ for (const path of paths) {
195
+ unregisters.push(
196
+ registerWecomWebhookTarget({
197
+ account: bot,
198
+ config: cfg,
199
+ runtime: ctx.runtime,
200
+ core: {} as PluginRuntime,
201
+ path,
202
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
203
+ }),
204
+ );
205
+ }
206
+ botPaths.push(...paths);
207
+ ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at ${paths.join(", ")}`);
184
208
  }
185
- botPaths.push(...paths);
186
- ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at ${paths.join(", ")}`);
187
209
  }
188
210
 
189
211
  if (agent && agentConfigured) {
@@ -84,6 +84,8 @@ export type StreamState = {
84
84
  dmContent?: string;
85
85
  /** 已通过 Agent 私信发送过的媒体标识(防重复发送附件) */
86
86
  agentMediaKeys?: string[];
87
+ /** 是否来自 WebSocket 长链接模式(用于跳过 6 分钟超时等 webhook 特有逻辑) */
88
+ wsMode?: boolean;
87
89
  };
88
90
 
89
91
  /**
@@ -114,6 +116,8 @@ export type PendingInbound = {
114
116
  /** 已到达防抖截止时间,但因前序批次仍在处理中而暂存 */
115
117
  readyToFlush?: boolean;
116
118
  createdAt: number;
119
+ /** 是否来自 WebSocket 长链接模式 */
120
+ wsMode?: boolean;
117
121
  };
118
122
 
119
123
  /**
package/src/monitor.ts CHANGED
@@ -931,7 +931,7 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
931
931
  return parsed as WecomInboundMessage;
932
932
  }
933
933
 
934
- type InboundResult = {
934
+ export type InboundResult = {
935
935
  body: string;
936
936
  media?: {
937
937
  buffer: Buffer;
@@ -952,15 +952,16 @@ type InboundResult = {
952
952
  * @param target Webhook 目标配置
953
953
  * @param msg 企业微信原始消息对象
954
954
  */
955
- async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
955
+ export async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
956
956
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
957
- const aesKey = target.account.encodingAESKey;
957
+ const globalAesKey = target.account.encodingAESKey;
958
958
  const maxBytes = resolveWecomMediaMaxBytes(target.config);
959
959
  const proxyUrl = resolveWecomEgressProxyUrl(target.config);
960
960
 
961
961
  // 图片消息处理:如果存在 url 且配置了 aesKey,则尝试解密下载
962
962
  if (msgtype === "image") {
963
963
  const url = String((msg as any).image?.url ?? "").trim();
964
+ const aesKey = globalAesKey || (msg as any).image?.aeskey || "";
964
965
  if (url && aesKey) {
965
966
  try {
966
967
  const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
@@ -995,6 +996,7 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
995
996
 
996
997
  if (msgtype === "file") {
997
998
  const url = String((msg as any).file?.url ?? "").trim();
999
+ const aesKey = globalAesKey || (msg as any).file?.aeskey || "";
998
1000
  if (url && aesKey) {
999
1001
  try {
1000
1002
  const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
@@ -1038,12 +1040,16 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
1038
1040
  if (t === "text") {
1039
1041
  const content = String(item.text?.content ?? "").trim();
1040
1042
  if (content) bodyParts.push(content);
1041
- } else if ((t === "image" || t === "file") && !foundMedia && aesKey) {
1043
+ } else if ((t === "image" || t === "file") && !foundMedia) {
1042
1044
  // Found first media, try to download
1045
+ const itemAesKey = globalAesKey || item[t]?.aeskey || "";
1043
1046
  const url = String(item[t]?.url ?? "").trim();
1044
- if (url) {
1047
+ if (!itemAesKey) {
1048
+ bodyParts.push(`[${t}]`);
1049
+ } else
1050
+ if (url) {
1045
1051
  try {
1046
- const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
1052
+ const decrypted = await decryptWecomMediaWithMeta(url, itemAesKey, { maxBytes, http: { proxyUrl } });
1047
1053
  const inferred = inferInboundMediaMeta({
1048
1054
  kind: t,
1049
1055
  buffer: decrypted.buffer,
@@ -2049,7 +2055,7 @@ function formatQuote(quote: WecomInboundQuote): string {
2049
2055
  return "";
2050
2056
  }
2051
2057
 
2052
- function buildInboundBody(msg: WecomInboundMessage): string {
2058
+ export function buildInboundBody(msg: WecomInboundMessage): string {
2053
2059
  let body = "";
2054
2060
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
2055
2061
 
package/src/onboarding.ts CHANGED
@@ -38,6 +38,39 @@ function setWecomEnabled(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig
38
38
  } as OpenClawConfig;
39
39
  }
40
40
 
41
+ /**
42
+ * 确保 cfg.bindings 中存在一条 wecom 账号到默认 agent 的路由。
43
+ *
44
+ * `openclaw channels add` 流程会在插件 configure() 返回后单独提示用户绑定 agent,
45
+ * 但 `openclaw onboard` 的 quickstart 路径会跳过这一步,导致消息路由缺失。
46
+ * 在插件层面主动补全 binding 可以让两种流程都能正常工作。
47
+ *
48
+ * 如果 bindings 中已存在匹配 channel+accountId 的条目,则不会重复添加。
49
+ */
50
+ function ensureWecomBinding(cfg: OpenClawConfig, accountId: string): OpenClawConfig {
51
+ const existing = cfg.bindings ?? [];
52
+ const alreadyBound = existing.some(
53
+ (b) => b.match.channel === channel && (b.match.accountId === accountId || (!b.match.accountId && accountId === DEFAULT_ACCOUNT_ID)),
54
+ );
55
+ if (alreadyBound) return cfg;
56
+
57
+ // 默认路由到 main agent(OpenClaw 约定 defaultAgentId 为 "main")
58
+ const defaultAgentId = "main";
59
+ return {
60
+ ...cfg,
61
+ bindings: [
62
+ ...existing,
63
+ {
64
+ agentId: defaultAgentId,
65
+ match: {
66
+ channel,
67
+ accountId,
68
+ },
69
+ },
70
+ ],
71
+ };
72
+ }
73
+
41
74
  function setGatewayBindLan(cfg: OpenClawConfig): OpenClawConfig {
42
75
  return {
43
76
  ...cfg,
@@ -48,6 +81,20 @@ function setGatewayBindLan(cfg: OpenClawConfig): OpenClawConfig {
48
81
  } as OpenClawConfig;
49
82
  }
50
83
 
84
+ function setWecomDefaultAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig {
85
+ const wecom = getWecomConfig(cfg) ?? {};
86
+ return {
87
+ ...cfg,
88
+ channels: {
89
+ ...cfg.channels,
90
+ wecom: {
91
+ ...wecom,
92
+ defaultAccount: accountId,
93
+ },
94
+ },
95
+ } as OpenClawConfig;
96
+ }
97
+
51
98
  function shouldUseAccountScopedConfig(wecom: WecomConfig | undefined, accountId: string): boolean {
52
99
  void wecom;
53
100
  void accountId;
@@ -85,7 +132,7 @@ function accountWebhookPath(kind: "bot" | "agent", accountId: string): string {
85
132
  return `${recommendedBase}/${accountId}`;
86
133
  }
87
134
 
88
- function setWecomBotConfig(cfg: OpenClawConfig, bot: WecomBotConfig, accountId: string): OpenClawConfig {
135
+ export function setWecomBotConfig(cfg: OpenClawConfig, bot: WecomBotConfig, accountId: string): OpenClawConfig {
89
136
  const wecom = getWecomConfig(cfg) ?? {};
90
137
  if (!shouldUseAccountScopedConfig(wecom, accountId)) {
91
138
  return {
@@ -190,7 +237,7 @@ function setWecomDmPolicy(
190
237
  agent: {
191
238
  ...existingAccount.agent,
192
239
  dm,
193
- },
240
+ } as WecomAgentConfig,
194
241
  };
195
242
  return {
196
243
  ...cfg,
@@ -315,18 +362,47 @@ async function configureBotMode(
315
362
  cfg: OpenClawConfig,
316
363
  prompter: WizardPrompter,
317
364
  accountId: string,
365
+ ): Promise<OpenClawConfig> {
366
+ // 选择接入方式
367
+ const connectionMode = (await prompter.select({
368
+ message: "请选择 Bot 接入方式:",
369
+ options: [
370
+ {
371
+ value: "websocket",
372
+ label: "WebSocket 长链接模式",
373
+ hint: "无需公网 IP,SDK 主动连接企微服务器,适合内网环境",
374
+ },
375
+ {
376
+ value: "webhook",
377
+ label: "Webhook 回调模式",
378
+ hint: "需要公网 IP + 回调 URL,适合有公网服务器的环境",
379
+ },
380
+ ],
381
+ initialValue: "websocket",
382
+ })) as "webhook" | "websocket";
383
+
384
+ if (connectionMode === "websocket") {
385
+ return configureBotWebsocket(cfg, prompter, accountId);
386
+ }
387
+ return configureBotWebhook(cfg, prompter, accountId);
388
+ }
389
+
390
+ async function configureBotWebhook(
391
+ cfg: OpenClawConfig,
392
+ prompter: WizardPrompter,
393
+ accountId: string,
318
394
  ): Promise<OpenClawConfig> {
319
395
  const recommendedPath = accountWebhookPath("bot", accountId);
320
396
  await prompter.note(
321
397
  [
322
- "正在配置 Bot 模式...",
398
+ "正在配置 Bot 模式(Webhook 回调)...",
323
399
  "",
324
400
  "💡 操作指南: 请在企微后台【管理工具 -> 智能机器人】开启 API 模式。",
325
401
  `🔗 回调 URL (推荐): https://您的域名${recommendedPath}`,
326
402
  "",
327
403
  "请先在后台填入回调 URL,然后获取以下信息。",
328
404
  ].join("\n"),
329
- "Bot 模式配置",
405
+ "Bot 模式配置 — Webhook",
330
406
  );
331
407
 
332
408
  const token = String(
@@ -361,6 +437,7 @@ async function configureBotMode(
361
437
  });
362
438
 
363
439
  const botConfig: WecomBotConfig = {
440
+ connectionMode: "webhook",
364
441
  token,
365
442
  encodingAESKey,
366
443
  streamPlaceholderContent: streamPlaceholder?.trim() || undefined,
@@ -370,6 +447,59 @@ async function configureBotMode(
370
447
  return setWecomBotConfig(cfg, botConfig, accountId);
371
448
  }
372
449
 
450
+ async function configureBotWebsocket(
451
+ cfg: OpenClawConfig,
452
+ prompter: WizardPrompter,
453
+ accountId: string,
454
+ ): Promise<OpenClawConfig> {
455
+ await prompter.note(
456
+ [
457
+ "正在配置 Bot 模式(WebSocket 长链接)...",
458
+ "",
459
+ "💡 操作指南: 请在企微后台【管理工具 -> 智能机器人】获取 BotID 和 Secret。",
460
+ "",
461
+ "长链接模式无需公网 IP 和回调 URL,适合内网环境。",
462
+ ].join("\n"),
463
+ "Bot 模式配置 — WebSocket",
464
+ );
465
+
466
+ const botId = String(
467
+ await prompter.text({
468
+ message: "请输入 BotID (机器人ID):",
469
+ validate: (value: string | undefined) => (value?.trim() ? undefined : "BotID 不能为空"),
470
+ }),
471
+ ).trim();
472
+
473
+ const secret = String(
474
+ await prompter.text({
475
+ message: "请输入 Secret (机器人密钥):",
476
+ validate: (value: string | undefined) => (value?.trim() ? undefined : "Secret 不能为空"),
477
+ }),
478
+ ).trim();
479
+
480
+ const streamPlaceholder = await prompter.text({
481
+ message: "流式占位符 (可选):",
482
+ placeholder: "正在思考...",
483
+ initialValue: "正在思考...",
484
+ });
485
+
486
+ const welcomeText = await prompter.text({
487
+ message: "欢迎语 (可选):",
488
+ placeholder: "你好!我是 AI 助手",
489
+ initialValue: "你好!我是 AI 助手",
490
+ });
491
+
492
+ const botConfig: WecomBotConfig = {
493
+ connectionMode: "websocket",
494
+ botId,
495
+ secret,
496
+ streamPlaceholderContent: streamPlaceholder?.trim() || undefined,
497
+ welcomeText: welcomeText?.trim() || undefined,
498
+ };
499
+
500
+ return setWecomBotConfig(cfg, botConfig, accountId);
501
+ }
502
+
373
503
  // ============================================================
374
504
  // Agent 模式配置
375
505
  // ============================================================
@@ -516,8 +646,13 @@ async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter, accoun
516
646
  const lines: string[] = ["✅ 配置已保存!", ""];
517
647
 
518
648
  if (account.bot?.configured) {
519
- lines.push("📱 Bot 模式: 已配置");
520
- lines.push(` 回调 URL: https://您的域名${accountWebhookPath("bot", accountId)}`);
649
+ if (account.bot.connectionMode === "websocket") {
650
+ lines.push("📱 Bot 模式: 已配置 (WebSocket 长链接)");
651
+ lines.push(" 无需配置回调 URL,SDK 将主动连接企微服务器");
652
+ } else {
653
+ lines.push("📱 Bot 模式: 已配置 (Webhook 回调)");
654
+ lines.push(` 回调 URL: https://您的域名${accountWebhookPath("bot", accountId)}`);
655
+ }
521
656
  }
522
657
 
523
658
  if (account.agent?.configured) {
@@ -527,9 +662,17 @@ async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter, accoun
527
662
 
528
663
  lines.push(` 账号 ID: ${accountId}`);
529
664
 
665
+ const hasWebhook =
666
+ (account.bot?.configured && account.bot.connectionMode !== "websocket") ||
667
+ account.agent?.configured;
668
+
530
669
  lines.push("");
531
- lines.push("⚠️ 请确保您已在企微后台填写了正确的回调 URL,");
532
- lines.push(" 并点击了后台的『保存』按钮完成验证。");
670
+ if (hasWebhook) {
671
+ lines.push("⚠️ 请确保您已在企微后台填写了正确的回调 URL,");
672
+ lines.push(" 并点击了后台的『保存』按钮完成验证。");
673
+ } else {
674
+ lines.push("💡 WebSocket 模式将在服务启动时自动连接企微服务器。");
675
+ }
533
676
 
534
677
  await prompter.note(lines.join("\n"), "配置完成");
535
678
  }
@@ -634,13 +777,19 @@ export const wecomOnboardingAdapter: ChannelOnboardingAdapter = {
634
777
  // 6. DM 策略
635
778
  next = await promptDmPolicy(next, prompter, configuredModes, accountId);
636
779
 
637
- // 7. 启用通道
780
+ // 7. 设置 defaultAccount
781
+ next = setWecomDefaultAccount(next, accountId);
782
+
783
+ // 8. 启用通道
638
784
  next = setWecomEnabled(next, true);
639
785
 
640
- // 8. 设置 gateway.bind 为 lan(允许外部访问回调)
786
+ // 9. 设置 gateway.bind 为 lan(允许外部访问回调)
641
787
  next = setGatewayBindLan(next);
642
788
 
643
- // 9. 汇总
789
+ // 10. 确保 bindings 中有默认路由(onboard quickstart 不会提示绑定)
790
+ next = ensureWecomBinding(next, accountId);
791
+
792
+ // 11. 汇总
644
793
  await showSummary(next, prompter, accountId);
645
794
 
646
795
  return { cfg: next, accountId };
package/src/outbound.ts CHANGED
@@ -3,6 +3,7 @@ import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/pl
3
3
  import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
4
4
  import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
5
5
  import { getWecomRuntime } from "./runtime.js";
6
+ import { getWsClient, waitForWsConnection } from "./ws-adapter.js";
6
7
 
7
8
  import { resolveWecomTarget } from "./target.js";
8
9
 
@@ -61,6 +62,43 @@ export const wecomOutbound: ChannelOutboundAdapter = {
61
62
  sendText: async ({ cfg, to, text, accountId }: ChannelOutboundContext) => {
62
63
  // signal removed - not supported in current SDK
63
64
 
65
+ // ── Bot WebSocket outbound 独立路径 ──
66
+ // WS Bot 完全独立收发,不与 Agent 组成双模,失败时直接抛错而非 fallthrough
67
+ const resolvedAccount = resolveWecomAccount({ cfg, accountId });
68
+ const botAccount = resolvedAccount.bot;
69
+ if (botAccount?.connectionMode === 'websocket' && botAccount.configured) {
70
+ const wsClient = getWsClient(botAccount.accountId);
71
+ const wsTarget = resolveWecomTarget(to);
72
+ const chatid = wsTarget?.touser || wsTarget?.chatid;
73
+
74
+ // 如果目标是 Agent 会话(wecom-agent:),跳过 WS Bot,走 Agent outbound
75
+ const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
76
+ if (!rawTo.startsWith("wecom-agent:")) {
77
+ if (!wsClient?.isConnected) {
78
+ console.log(`[wecom-outbound] Bot WS 未连接,等待重连... (accountId=${botAccount.accountId})`);
79
+ const reconnected = await waitForWsConnection(botAccount.accountId, 10_000);
80
+ if (!reconnected) {
81
+ throw new Error(`[wecom-outbound] Bot WS 等待重连超时,无法发送消息 (accountId=${botAccount.accountId})`);
82
+ }
83
+ }
84
+ if (!chatid) {
85
+ throw new Error(`[wecom-outbound] Bot WS 无法解析目标 chatid (to=${String(to)})`);
86
+ }
87
+ // 重连后重新获取 client(可能是新实例)
88
+ const activeClient = getWsClient(botAccount.accountId);
89
+ if (!activeClient?.isConnected) {
90
+ throw new Error(`[wecom-outbound] Bot WS 重连后仍不可用 (accountId=${botAccount.accountId})`);
91
+ }
92
+ await activeClient.sendMessage(chatid, {
93
+ msgtype: 'markdown',
94
+ markdown: { content: text },
95
+ });
96
+ console.log(`[wecom-outbound] Sent text via Bot WS to chatid=${chatid} (len=${text.length})`);
97
+ return { channel: "wecom", messageId: `ws-bot-${Date.now()}`, timestamp: Date.now() };
98
+ }
99
+ }
100
+
101
+ // ── Agent outbound(Webhook Bot 双模 / Agent 独立)──
64
102
  const agent = resolveAgentConfigOrThrow({ cfg, accountId });
65
103
  const target = resolveWecomTarget(to);
66
104
  if (!target) {
@@ -20,9 +20,9 @@ export type ResolvedBotAccount = {
20
20
  enabled: boolean;
21
21
  /** 是否配置完整 */
22
22
  configured: boolean;
23
- /** 回调 Token */
23
+ /** 回调 Token (webhook 模式) */
24
24
  token: string;
25
- /** 回调加密密钥 */
25
+ /** 回调加密密钥 (webhook 模式) */
26
26
  encodingAESKey: string;
27
27
  /** 接收者 ID */
28
28
  receiveId: string;
@@ -30,6 +30,15 @@ export type ResolvedBotAccount = {
30
30
  config: WecomBotConfig;
31
31
  /** 网络配置(来自 channels.wecom.network) */
32
32
  network?: WecomNetworkConfig;
33
+
34
+ // --- 长链接模式 (WebSocket) ---
35
+
36
+ /** 连接模式 */
37
+ connectionMode: 'webhook' | 'websocket';
38
+ /** 机器人 BotID(websocket 模式下有值) */
39
+ botId?: string;
40
+ /** 机器人 Secret(websocket 模式下有值) */
41
+ secret?: string;
33
42
  };
34
43
 
35
44
  /**
@@ -45,12 +45,12 @@ export type WecomRoutingConfig = {
45
45
  * 用于接收 JSON 格式回调 + 流式回复
46
46
  */
47
47
  export type WecomBotConfig = {
48
- /** 智能机器人 ID(用于 Matrix 模式二次身份确认) */
48
+ /** 智能机器人 ID(用于 Matrix 模式二次身份确认,webhook 模式) */
49
49
  aibotid?: string;
50
- /** 回调 Token (企微后台生成) */
51
- token: string;
52
- /** 回调加密密钥 (企微后台生成) */
53
- encodingAESKey: string;
50
+ /** 回调 Token (企微后台生成,webhook 模式必填) */
51
+ token?: string;
52
+ /** 回调加密密钥 (企微后台生成,webhook 模式必填) */
53
+ encodingAESKey?: string;
54
54
  /**
55
55
  * BotId 列表(可选,用于审计与告警)。
56
56
  * - 回调路由优先由 URL + 签名决定;botIds 不参与强制拦截。
@@ -65,6 +65,15 @@ export type WecomBotConfig = {
65
65
  welcomeText?: string;
66
66
  /** DM 策略 */
67
67
  dm?: WecomDmConfig;
68
+
69
+ // --- 长链接模式 (WebSocket) ---
70
+
71
+ /** 连接模式:webhook(默认)或 websocket */
72
+ connectionMode?: 'webhook' | 'websocket';
73
+ /** 机器人 BotID(websocket 模式必填,企微后台获取) */
74
+ botId?: string;
75
+ /** 机器人 Secret(websocket 模式必填,企微后台获取) */
76
+ secret?: string;
68
77
  };
69
78
 
70
79
  /**
@@ -0,0 +1,481 @@
1
+ /**
2
+ * WeCom WebSocket 长链接模式适配器
3
+ *
4
+ * 职责:管理 WSClient 生命周期,将 SDK 事件桥接到现有 monitor.ts 消息管线。
5
+ *
6
+ * SDK WsFrame 事件
7
+ * ↓
8
+ * ws-adapter 转换为 WecomBotInboundMessage 格式
9
+ * ↓
10
+ * 复用 monitor.ts 中的 shouldProcessBotInboundMessage → buildInboundBody
11
+ * → streamStore.addPendingMessage → flushPending 管线
12
+ */
13
+
14
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
15
+ import { WSClient } from "@wecom/aibot-node-sdk";
16
+ import type {
17
+ WsFrame,
18
+ BaseMessage,
19
+ TextMessage,
20
+ ImageMessage,
21
+ MixedMessage,
22
+ VoiceMessage,
23
+ FileMessage,
24
+ EventMessage,
25
+ EventMessageWith,
26
+ ReplyMsgItem,
27
+ } from "@wecom/aibot-node-sdk";
28
+ import type { EnterChatEvent, TemplateCardEventData } from "@wecom/aibot-node-sdk";
29
+
30
+ import type { ResolvedBotAccount, WecomNetworkConfig, WecomBotInboundMessage } from "./types/index.js";
31
+ import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState } from "./monitor/types.js";
32
+ import { shouldProcessBotInboundMessage, buildInboundBody } from "./monitor.js";
33
+ import { monitorState } from "./monitor/state.js";
34
+ import { getWecomRuntime } from "./runtime.js";
35
+
36
+ // ─── WSClient Instance Registry ────────────────────────────────────────
37
+
38
+ const wsClients = new Map<string, WSClient>();
39
+
40
+ /**
41
+ * 获取指定账号的 WSClient 实例
42
+ */
43
+ export function getWsClient(accountId: string): WSClient | undefined {
44
+ return wsClients.get(accountId);
45
+ }
46
+
47
+ /**
48
+ * 等待 WSClient 连接就绪,最多等待 timeoutMs 毫秒(默认 30 秒)。
49
+ * 如果已连接则立即返回;如果 client 尚未创建,会轮询等待创建后再监听连接事件。
50
+ */
51
+ export async function waitForWsConnection(accountId: string, timeoutMs = 30_000): Promise<boolean> {
52
+ const deadline = Date.now() + timeoutMs;
53
+
54
+ // 等待 client 实例出现(gateway 重启时 client 可能还没注册)
55
+ while (!wsClients.has(accountId)) {
56
+ if (Date.now() >= deadline) return false;
57
+ await new Promise((r) => setTimeout(r, 500));
58
+ }
59
+
60
+ const client = wsClients.get(accountId)!;
61
+ if (client.isConnected) return true;
62
+
63
+ const remaining = deadline - Date.now();
64
+ if (remaining <= 0) return false;
65
+
66
+ return new Promise<boolean>((resolve) => {
67
+ const timer = setTimeout(() => {
68
+ cleanup();
69
+ resolve(false);
70
+ }, remaining);
71
+
72
+ const onConnected = () => {
73
+ cleanup();
74
+ resolve(true);
75
+ };
76
+
77
+ const cleanup = () => {
78
+ clearTimeout(timer);
79
+ client.off("connected", onConnected);
80
+ };
81
+
82
+ client.on("connected", onConnected);
83
+ // 再检查一次,防止在注册监听器之前已连上
84
+ if (client.isConnected) {
85
+ cleanup();
86
+ resolve(true);
87
+ }
88
+ });
89
+ }
90
+
91
+ // ─── Stream Reply Watcher ──────────────────────────────────────────────
92
+
93
+ /**
94
+ * 流式回复监听器:轮询 StreamState 变化并通过 WSClient 推送回复
95
+ */
96
+ function watchStreamReply(params: {
97
+ wsClient: WSClient;
98
+ frame: WsFrame;
99
+ streamId: string;
100
+ log?: (msg: string) => void;
101
+ error?: (msg: string) => void;
102
+ }): void {
103
+ const { wsClient, frame, streamId, log, error } = params;
104
+ const streamStore = monitorState.streamStore;
105
+ let lastSentContent = "";
106
+ let finished = false;
107
+ const POLL_INTERVAL_MS = 200;
108
+
109
+ const tick = async () => {
110
+ if (finished) return;
111
+
112
+ const state = streamStore.getStream(streamId);
113
+ if (!state) {
114
+ finished = true;
115
+ return;
116
+ }
117
+
118
+ const content = state.content ?? "";
119
+ const isFinished = state.finished ?? false;
120
+
121
+ // 有新内容或流结束时发送
122
+ if (content !== lastSentContent || isFinished) {
123
+ try {
124
+ // 构建图片附件(仅在结束时)
125
+ let msgItems: ReplyMsgItem[] | undefined;
126
+ if (isFinished && state.images?.length) {
127
+ msgItems = state.images.map((img) => ({
128
+ msgtype: "image" as const,
129
+ image: { base64: img.base64, md5: img.md5 },
130
+ }));
131
+ }
132
+
133
+ await wsClient.replyStream(
134
+ frame,
135
+ streamId,
136
+ content,
137
+ isFinished,
138
+ msgItems,
139
+ );
140
+ lastSentContent = content;
141
+ log?.(`ws-reply: streamId=${streamId} len=${content.length} finish=${isFinished}`);
142
+ } catch (err) {
143
+ error?.(`ws-reply: replyStream failed streamId=${streamId}: ${String(err)}`);
144
+ }
145
+ }
146
+
147
+ if (isFinished) {
148
+ finished = true;
149
+ return;
150
+ }
151
+
152
+ setTimeout(tick, POLL_INTERVAL_MS);
153
+ };
154
+
155
+ // 初次延迟启动,等待 agent 开始生产内容
156
+ setTimeout(tick, POLL_INTERVAL_MS);
157
+ }
158
+
159
+ // ─── SDK Message → WecomBotInboundMessage Conversion ───────────────────
160
+
161
+ /**
162
+ * 将 SDK 的 WsFrame<BaseMessage> 转换为现有的 WecomBotInboundMessage 格式
163
+ */
164
+ function convertSdkMessageToInbound(body: BaseMessage): WecomBotInboundMessage {
165
+ const base: WecomBotInboundMessage = {
166
+ msgid: body.msgid,
167
+ aibotid: body.aibotid,
168
+ chattype: body.chattype,
169
+ chatid: body.chatid,
170
+ response_url: body.response_url,
171
+ from: body.from ? { userid: body.from.userid } : undefined,
172
+ msgtype: body.msgtype as string,
173
+ };
174
+
175
+ const msgtype = String(body.msgtype ?? "").toLowerCase();
176
+
177
+ if (msgtype === "text") {
178
+ const textBody = body as TextMessage;
179
+ return { ...base, msgtype: "text", text: textBody.text, quote: textBody.quote as any };
180
+ }
181
+ if (msgtype === "voice") {
182
+ const voiceBody = body as VoiceMessage;
183
+ return { ...base, msgtype: "voice", voice: voiceBody.voice, quote: voiceBody.quote as any };
184
+ }
185
+ if (msgtype === "image") {
186
+ const imageBody = body as ImageMessage;
187
+ return { ...base, msgtype: "image" as any, image: imageBody.image, quote: imageBody.quote as any } as any;
188
+ }
189
+ if (msgtype === "file") {
190
+ const fileBody = body as FileMessage;
191
+ return { ...base, msgtype: "file" as any, file: fileBody.file, quote: fileBody.quote as any } as any;
192
+ }
193
+ if (msgtype === "mixed") {
194
+ const mixedBody = body as MixedMessage;
195
+ return { ...base, msgtype: "mixed" as any, mixed: mixedBody.mixed, quote: mixedBody.quote as any } as any;
196
+ }
197
+
198
+ // Fallback: pass through as-is
199
+ return { ...base, ...body };
200
+ }
201
+
202
+ // ─── WS Event Handlers ────────────────────────────────────────────────
203
+
204
+ function setupMessageHandler(params: {
205
+ wsClient: WSClient;
206
+ accountId: string;
207
+ target: WecomWebhookTarget;
208
+ }) {
209
+ const { wsClient, accountId, target } = params;
210
+ const streamStore = monitorState.streamStore;
211
+
212
+ // 监听所有消息类型
213
+ wsClient.on("message", (frame: WsFrame<BaseMessage>) => {
214
+ const body = frame.body;
215
+ if (!body) return;
216
+
217
+ const msgtype = String(body.msgtype ?? "").toLowerCase();
218
+
219
+ // event 类型由专门的 event handler 处理
220
+ if (msgtype === "event") return;
221
+
222
+ const msg = convertSdkMessageToInbound(body);
223
+ const decision = shouldProcessBotInboundMessage(msg);
224
+ if (!decision.shouldProcess) {
225
+ target.runtime.log?.(
226
+ `[${accountId}] ws-inbound: skipped msgtype=${msgtype} reason=${decision.reason}`,
227
+ );
228
+ return;
229
+ }
230
+
231
+ const userid = decision.senderUserId!;
232
+ const chatId = decision.chatId ?? userid;
233
+ const conversationKey = `wecom:${accountId}:${userid}:${chatId}`;
234
+ const msgContent = buildInboundBody(msg);
235
+
236
+ target.runtime.log?.(
237
+ `[${accountId}] ws-inbound: msgtype=${msgtype} chattype=${String(msg.chattype ?? "")} ` +
238
+ `from=${userid} msgid=${String(msg.msgid ?? "")}`,
239
+ );
240
+
241
+ // 消息去重
242
+ if (msg.msgid) {
243
+ const existingStreamId = streamStore.getStreamByMsgId(String(msg.msgid));
244
+ if (existingStreamId) {
245
+ target.runtime.log?.(
246
+ `[${accountId}] ws-inbound: duplicate msgid=${msg.msgid}, skipping`,
247
+ );
248
+ return;
249
+ }
250
+ }
251
+
252
+ // 加入 Pending 队列(复用现有防抖/聚合逻辑)
253
+ const { streamId } = streamStore.addPendingMessage({
254
+ conversationKey,
255
+ target,
256
+ msg,
257
+ msgContent,
258
+ nonce: "",
259
+ timestamp: String(Date.now()),
260
+ debounceMs: (target.account.config as any).debounceMs,
261
+ });
262
+
263
+ // 标记 wsMode
264
+ streamStore.updateStream(streamId, (s: StreamState) => {
265
+ s.wsMode = true;
266
+ });
267
+
268
+ // 注册流式回复监听器
269
+ watchStreamReply({
270
+ wsClient,
271
+ frame,
272
+ streamId,
273
+ log: (msg) => target.runtime.log?.(`[${accountId}] ${msg}`),
274
+ error: (msg) => target.runtime.error?.(`[${accountId}] ${msg}`),
275
+ });
276
+
277
+ target.statusSink?.({ lastInboundAt: Date.now() });
278
+ });
279
+ }
280
+
281
+ function setupEventHandler(params: {
282
+ wsClient: WSClient;
283
+ accountId: string;
284
+ target: WecomWebhookTarget;
285
+ welcomeText?: string;
286
+ }) {
287
+ const { wsClient, accountId, target, welcomeText } = params;
288
+ const streamStore = monitorState.streamStore;
289
+
290
+ // 进入会话事件 → 欢迎语
291
+ wsClient.on("event.enter_chat", async (frame: WsFrame<EventMessageWith<EnterChatEvent>>) => {
292
+ const text = welcomeText?.trim();
293
+ if (!text) return;
294
+
295
+ try {
296
+ await wsClient.replyWelcome(frame, {
297
+ msgtype: "text",
298
+ text: { content: text },
299
+ });
300
+ target.runtime.log?.(`[${accountId}] ws-event: sent welcome text`);
301
+ } catch (err) {
302
+ target.runtime.error?.(`[${accountId}] ws-event: replyWelcome failed: ${String(err)}`);
303
+ }
304
+ });
305
+
306
+ // 模板卡片交互事件 → 转换为文本消息注入管线
307
+ wsClient.on("event.template_card_event", (frame: WsFrame<EventMessageWith<TemplateCardEventData>>) => {
308
+ const body = frame.body;
309
+ if (!body) return;
310
+
311
+ const eventData = body.event;
312
+ let interactionDesc = `[卡片交互] 按钮: ${eventData?.event_key || "unknown"}`;
313
+ if (eventData?.task_id) interactionDesc += ` (任务ID: ${eventData.task_id})`;
314
+
315
+ const msgid = body.msgid ? String(body.msgid) : undefined;
316
+
317
+ // 去重
318
+ if (msgid && streamStore.getStreamByMsgId(msgid)) {
319
+ target.runtime.log?.(`[${accountId}] ws-event: template_card_event already processed msgid=${msgid}`);
320
+ return;
321
+ }
322
+
323
+ const streamId = streamStore.createStream({ msgid });
324
+ streamStore.markStarted(streamId);
325
+ streamStore.updateStream(streamId, (s: StreamState) => {
326
+ s.wsMode = true;
327
+ });
328
+
329
+ const syntheticMsg: WecomBotInboundMessage = {
330
+ msgid,
331
+ aibotid: body.aibotid,
332
+ chattype: body.chattype,
333
+ chatid: body.chatid,
334
+ from: body.from ? { userid: body.from.userid } : undefined,
335
+ msgtype: "text",
336
+ text: { content: interactionDesc },
337
+ };
338
+
339
+ let core: PluginRuntime;
340
+ try {
341
+ core = getWecomRuntime();
342
+ } catch {
343
+ target.runtime.error?.(`[${accountId}] ws-event: runtime not ready for template_card_event`);
344
+ streamStore.markFinished(streamId);
345
+ return;
346
+ }
347
+
348
+ // 由于卡片事件没有经过防抖队列,直接触发 flushPending 的等效操作
349
+ // 需要通过 addPendingMessage 注入,让现有管线处理
350
+ const userid = body.from?.userid ?? "unknown";
351
+ const chatId = body.chatid ?? userid;
352
+ const conversationKey = `wecom:${accountId}:${userid}:${chatId}`;
353
+
354
+ // 先清除之前创建的 stream(addPendingMessage 会创建新的)
355
+ // 直接用 addPendingMessage 复用完整管线
356
+ const enrichedTarget: WecomWebhookTarget = { ...target, core };
357
+ const { streamId: actualStreamId } = streamStore.addPendingMessage({
358
+ conversationKey,
359
+ target: enrichedTarget,
360
+ msg: syntheticMsg,
361
+ msgContent: interactionDesc,
362
+ nonce: "",
363
+ timestamp: String(Date.now()),
364
+ debounceMs: 0, // 卡片事件不防抖
365
+ });
366
+
367
+ streamStore.updateStream(actualStreamId, (s: StreamState) => {
368
+ s.wsMode = true;
369
+ });
370
+
371
+ watchStreamReply({
372
+ wsClient,
373
+ frame,
374
+ streamId: actualStreamId,
375
+ log: (msg) => target.runtime.log?.(`[${accountId}] ${msg}`),
376
+ error: (msg) => target.runtime.error?.(`[${accountId}] ${msg}`),
377
+ });
378
+ });
379
+
380
+ // 反馈事件 → 仅记录日志
381
+ wsClient.on("event.feedback_event", (frame) => {
382
+ target.runtime.log?.(
383
+ `[${accountId}] ws-event: feedback_event received (logged only)`,
384
+ );
385
+ });
386
+ }
387
+
388
+ // ─── WSClient Lifecycle ────────────────────────────────────────────────
389
+
390
+ export type StartWsClientParams = {
391
+ accountId: string;
392
+ botId: string;
393
+ secret: string;
394
+ account: ResolvedBotAccount;
395
+ config: OpenClawConfig;
396
+ runtime: WecomRuntimeEnv;
397
+ core: PluginRuntime;
398
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
399
+ welcomeText?: string;
400
+ network?: WecomNetworkConfig;
401
+ };
402
+
403
+ /**
404
+ * 启动 WebSocket 长链接客户端
405
+ * @returns cleanup 函数(用于注销)
406
+ */
407
+ export function startWsClient(params: StartWsClientParams): () => void {
408
+ const {
409
+ accountId, botId, secret,
410
+ account, config, runtime, core,
411
+ statusSink, welcomeText,
412
+ } = params;
413
+
414
+ // 如果已有实例,先停止
415
+ stopWsClient(accountId);
416
+
417
+ const wsClient = new WSClient({
418
+ botId,
419
+ secret,
420
+ maxReconnectAttempts: -1, // 无限重连
421
+ logger: {
422
+ debug: (msg: string) => runtime.log?.(`[${accountId}][ws-sdk] ${msg}`),
423
+ info: (msg: string) => runtime.log?.(`[${accountId}][ws-sdk] ${msg}`),
424
+ warn: (msg: string) => runtime.log?.(`[${accountId}][ws-sdk] WARN: ${msg}`),
425
+ error: (msg: string) => runtime.error?.(`[${accountId}][ws-sdk] ERROR: ${msg}`),
426
+ },
427
+ });
428
+
429
+ wsClients.set(accountId, wsClient);
430
+
431
+ // 构建 WecomWebhookTarget 以复用 monitor 管线
432
+ const target: WecomWebhookTarget = {
433
+ account,
434
+ config,
435
+ runtime,
436
+ core,
437
+ path: `ws://${accountId}`,
438
+ statusSink,
439
+ };
440
+
441
+ // 设置消息和事件处理
442
+ setupMessageHandler({ wsClient, accountId, target });
443
+ setupEventHandler({ wsClient, accountId, target, welcomeText });
444
+
445
+ // 连接状态日志
446
+ wsClient.on("connected", () => {
447
+ runtime.log?.(`[${accountId}] ws: connected`);
448
+ });
449
+ wsClient.on("authenticated", () => {
450
+ runtime.log?.(`[${accountId}] ws: authenticated successfully`);
451
+ });
452
+ wsClient.on("disconnected", (reason: string) => {
453
+ runtime.log?.(`[${accountId}] ws: disconnected - ${reason}`);
454
+ });
455
+ wsClient.on("reconnecting", (attempt: number) => {
456
+ runtime.log?.(`[${accountId}] ws: reconnecting attempt=${attempt}`);
457
+ });
458
+ wsClient.on("error", (err: Error) => {
459
+ runtime.error?.(`[${accountId}] ws: error - ${err.message}`);
460
+ });
461
+
462
+ // 建立连接
463
+ wsClient.connect();
464
+ runtime.log?.(`[${accountId}] ws: starting connection (botId=${botId})`);
465
+
466
+ // 返回清理函数
467
+ return () => {
468
+ stopWsClient(accountId);
469
+ };
470
+ }
471
+
472
+ /**
473
+ * 停止指定账号的 WSClient
474
+ */
475
+ export function stopWsClient(accountId: string): void {
476
+ const existing = wsClients.get(accountId);
477
+ if (existing) {
478
+ existing.disconnect();
479
+ wsClients.delete(accountId);
480
+ }
481
+ }