@wecom/wecom-openclaw-plugin 2026.3.20-beta.6 → 2026.3.20

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/dist/index.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readJsonFileWithFallback, withFileLock, writeJsonFileAtomically, DEFAULT_ACCOUNT_ID, addWildcardAllowFrom, formatPairingApproveHint, emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
2
2
  import * as os$1 from 'os';
3
3
  import * as path$1 from 'path';
4
- import { generateReqId, WSClient } from '@wecom/aibot-node-sdk';
4
+ import { generateReqId, WSClient, WSAuthFailureError, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
5
5
  import * as fs from 'node:fs/promises';
6
6
  import * as path from 'node:path';
7
7
  import path__default, { dirname, resolve } from 'node:path';
@@ -360,8 +360,10 @@ const REPLY_SEND_TIMEOUT_MS = 15000;
360
360
  const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
361
361
  /** WebSocket 心跳间隔(毫秒) */
362
362
  const WS_HEARTBEAT_INTERVAL_MS = 30000;
363
- /** WebSocket 最大重连次数 */
364
- const WS_MAX_RECONNECT_ATTEMPTS = 100;
363
+ /** WebSocket 连接断开时的最大重连次数 */
364
+ const WS_MAX_RECONNECT_ATTEMPTS = 10;
365
+ /** WebSocket 认证失败时的最大重试次数 */
366
+ const WS_MAX_AUTH_FAILURE_ATTEMPTS = 5;
365
367
  // ============================================================================
366
368
  // 消息状态管理配置
367
369
  // ============================================================================
@@ -2069,8 +2071,8 @@ function createSdkLogger(runtime, accountId) {
2069
2071
  * 使用 aibot-node-sdk 简化连接管理
2070
2072
  */
2071
2073
  async function monitorWeComProvider(options) {
2072
- const { account, config, runtime, abortSignal } = options;
2073
- runtime.log?.(`[${account.accountId}] Initializing WSClient with SDK...`);
2074
+ const { account, config, runtime, abortSignal, setStatus } = options;
2075
+ runtime.log?.(`[${account.accountId}] [${PLUGIN_VERSION}] Initializing WSClient with SDK...`);
2074
2076
  // 启动消息状态定期清理
2075
2077
  startMessageStateCleanup();
2076
2078
  return new Promise((resolve, reject) => {
@@ -2082,18 +2084,26 @@ async function monitorWeComProvider(options) {
2082
2084
  logger,
2083
2085
  heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
2084
2086
  maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
2087
+ maxAuthFailureAttempts: WS_MAX_AUTH_FAILURE_ATTEMPTS,
2085
2088
  scene: SCENE_WECOM_OPENCLAW,
2086
2089
  plug_version: PLUGIN_VERSION,
2087
2090
  });
2088
- // 清理函数:确保所有资源被释放
2091
+ // 防止 cleanup 被多次调用(abort handler、error handler、disconnected_event 可能竞态触发)
2092
+ let cleanedUp = false;
2093
+ // 清理函数:确保所有资源被释放(幂等)
2089
2094
  const cleanup = async () => {
2095
+ if (cleanedUp)
2096
+ return;
2097
+ cleanedUp = true;
2090
2098
  stopMessageStateCleanup();
2091
2099
  await cleanupAccount(account.accountId);
2092
2100
  };
2093
- // 处理中止信号
2101
+ // 处理中止信号(框架 stopChannel 会触发 abort)
2102
+ // resolve() 让 Promise settle → 框架清理 store.tasks/store.aborts
2094
2103
  if (abortSignal) {
2095
2104
  abortSignal.addEventListener("abort", async () => {
2096
2105
  runtime.log?.(`[${account.accountId}] Connection aborted`);
2106
+ wsClient.disconnect();
2097
2107
  await cleanup();
2098
2108
  resolve();
2099
2109
  });
@@ -2111,16 +2121,84 @@ async function monitorWeComProvider(options) {
2111
2121
  wsClient.on("disconnected", (reason) => {
2112
2122
  runtime.log?.(`[${account.accountId}] WebSocket disconnected: ${reason}`);
2113
2123
  });
2124
+ // 监听被踢下线事件(服务端因新连接建立而主动断开旧连接)
2125
+ //
2126
+ // SDK 内部已设置 isManualClose=true 阻止 SDK 层自动重连,连接不会自行恢复。
2127
+ // **不 reject/resolve Promise**——保持 pending 以阻止框架层 auto-restart。
2128
+ //
2129
+ // 为什么不能 reject/resolve:
2130
+ // - reject → 框架 auto-restart 介入 → 新连接建立 → 又被踢 → 两个实例互踢无限循环
2131
+ // - resolve → 同上,框架 .then() 中的 auto-restart 也会触发
2132
+ //
2133
+ // Promise pending 的安全性:
2134
+ // - store.tasks.has(id) = true → 阻止 Health Monitor 直接 startChannel(startChannel 检查 tasks.has)
2135
+ // - 框架 stopChannel → abort() → abort handler 中 resolve() → tasks 正常清理
2136
+ // - 用户修改配置 → config reload → stopChannel + startChannel → 正常恢复
2137
+ //
2138
+ // 显式调用 wsClient.disconnect() 确保 SDK 内部资源(定时器、队列等)完全释放。
2139
+ wsClient.on("event.disconnected_event", async () => {
2140
+ const errorMsg = `Kicked by server: a new connection was established elsewhere. Auto-restart is suppressed to avoid mutual kicking. Please check for duplicate instances.`;
2141
+ runtime.error?.(`[${account.accountId}] ${errorMsg}`);
2142
+ wsClient.disconnect();
2143
+ await cleanup();
2144
+ setStatus?.({
2145
+ accountId: account.accountId,
2146
+ running: false,
2147
+ lastError: errorMsg,
2148
+ lastStopAt: Date.now(),
2149
+ });
2150
+ // Promise 保持 pending,不触发 auto-restart
2151
+ });
2114
2152
  // 监听重连事件
2115
2153
  wsClient.on("reconnecting", (attempt) => {
2116
2154
  runtime.log?.(`[${account.accountId}] Reconnecting attempt ${attempt}...`);
2117
2155
  });
2118
2156
  // 监听错误事件
2119
- wsClient.on("error", (error) => {
2157
+ wsClient.on("error", async (error) => {
2120
2158
  runtime.error?.(`[${account.accountId}] WebSocket error: ${error.message}`);
2121
- // 认证失败时拒绝 Promise
2122
- if (error.message.includes("Authentication failed")) {
2159
+ if (error instanceof WSAuthFailureError) {
2160
+ // 认证失败重试次数用尽(SDK 层已重试 WS_MAX_AUTH_FAILURE_ATTEMPTS 次)。
2161
+ // 配置错误(如 botId/secret 无效),框架 auto-restart 也无法恢复。
2162
+ //
2163
+ // **不 reject/resolve Promise**——保持 pending 以阻止框架层 auto-restart。
2164
+ //
2165
+ // 为什么不能 reject/resolve:
2166
+ // - reject/resolve → 框架 auto-restart(最多 10 次)× SDK 重试(5 次)= 60 次无意义尝试
2167
+ // - 且 Health Monitor 每小时还会 resetRestartAttempts 再来一轮
2168
+ //
2169
+ // Promise pending 的安全性:同被踢下线场景
2170
+ // - store.tasks.has(id) = true → 阻止 Health Monitor 直接 startChannel
2171
+ // - 框架 stopChannel / config reload → abort handler 中 resolve() → 正常清理
2172
+ // - 用户修改配置后框架通过 reload 机制重新启动
2173
+ const errorMsg = `Auth failure attempts exhausted (${WS_MAX_AUTH_FAILURE_ATTEMPTS} attempts). Please check botId/secret configuration.`;
2174
+ runtime.error?.(`[${account.accountId}] ${errorMsg}`);
2175
+ wsClient.disconnect();
2176
+ await cleanup();
2177
+ setStatus?.({
2178
+ accountId: account.accountId,
2179
+ running: false,
2180
+ lastError: errorMsg,
2181
+ lastStopAt: Date.now(),
2182
+ });
2183
+ return;
2184
+ }
2185
+ if (error instanceof WSReconnectExhaustedError) {
2186
+ // 网络断线重连次数用尽(SDK 层已重试 WS_MAX_RECONNECT_ATTEMPTS 次)。
2187
+ // 通常是网络/服务端问题,框架 auto-restart 可能恢复。
2188
+ //
2189
+ // reject Promise → 框架 auto-restart 介入(最多 MAX_RESTART_ATTEMPTS=10 次)
2190
+ // 总连接尝试次数 = (1 首次 + WS_MAX_RECONNECT_ATTEMPTS 重连) × (1 首轮 + 10 auto-restart)
2191
+ // = 11 × 11 = 121 次
2192
+ //
2193
+ // 如果 Health Monitor 介入(每 5 分钟检查),会 resetRestartAttempts 重新计数,
2194
+ // 受限于 DEFAULT_MAX_RESTARTS_PER_HOUR=10,每小时最多额外 10 × 121 = 1210 次。
2195
+ // 但因网络断线通常是暂时性的,auto-restart + Health Monitor 的兜底机制是合理的。
2196
+ //
2197
+ // 显式调用 wsClient.disconnect() 确保 SDK 内部资源完全释放,
2198
+ // 避免旧实例的定时器/队列残留。
2199
+ wsClient.disconnect();
2123
2200
  cleanup().finally(() => reject(error));
2201
+ return;
2124
2202
  }
2125
2203
  });
2126
2204
  // 监听版本检查事件:收到 enter_check_update 时回复当前插件版本
@@ -2613,6 +2691,7 @@ const wecomPlugin = {
2613
2691
  config: ctx.cfg,
2614
2692
  runtime: ctx.runtime,
2615
2693
  abortSignal: ctx.abortSignal,
2694
+ setStatus: ctx.setStatus,
2616
2695
  });
2617
2696
  },
2618
2697
  logoutAccount: async ({ cfg }) => {
@@ -3293,8 +3372,12 @@ async function interceptMediaResponse(result) {
3293
3372
  // 5. 检测 contentType,并通过 saveMediaBuffer 保存到本地媒体目录
3294
3373
  const contentType = await detectMime({ buffer, filePath: mediaName }) ?? "application/octet-stream";
3295
3374
  const tMimeDetected = performance.now();
3375
+ // 企业微信聊天记录附件可达 20MB(文件消息上限),
3376
+ // 而 saveMediaBuffer 默认 maxBytes 为 5MB(针对 outbound 场景),
3377
+ // 此处显式放宽到 20MB 以支持大文件下载。
3378
+ const INBOUND_MAX_BYTES = 20 * 1024 * 1024; // 20MB
3296
3379
  const core = getWeComRuntime();
3297
- const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", undefined, // maxBytes: 使用 saveMediaBuffer 内部默认值
3380
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", INBOUND_MAX_BYTES, // maxBytes: 放宽到 20MB,匹配企业微信文件消息上限
3298
3381
  mediaName);
3299
3382
  // 5.1 补偿:核心库 EXT_BY_MIME 可能缺少某些格式映射(如 audio/amr),
3300
3383
  // 导致保存的文件没有后缀。此处检测并修复。
@@ -3460,7 +3543,6 @@ function createWeComMcpTool() {
3460
3543
  };
3461
3544
  }
3462
3545
 
3463
- console.log(`[wecom] v${PLUGIN_VERSION} loaded`);
3464
3546
  const plugin = {
3465
3547
  id: "wecom-openclaw-plugin",
3466
3548
  name: "企业微信",