@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/README.md CHANGED
@@ -1,3 +1,11 @@
1
+ > 💡 **快速上手指引 & 交流群**
2
+ >
3
+ > 📖 [点击查看完整接入指引文档](https://doc.weixin.qq.com/doc/w3_AFYA1wY6ACoCNRxfnyGRJQaSa6jjJ?scode=AJEAIQdfAAo0RJmzxLAFYA1wY6ACo) — 包含配置步骤、产品介绍、常见问题解答等。
4
+ >
5
+ > 💬 扫码加入企业微信交流群:
6
+ >
7
+ > <img src="https://wwcdn.weixin.qq.com/node/wework/images/202603181801.a77d987fb8.png" alt="扫码入群交流" width="200" />
8
+
1
9
  # 🤖 WeCom OpenClaw Plugin
2
10
 
3
11
  **WeCom channel plugin for [OpenClaw](https://github.com/openclaw)** — by the Tencent WeCom team.
package/dist/index.cjs.js CHANGED
@@ -386,8 +386,10 @@ const REPLY_SEND_TIMEOUT_MS = 15000;
386
386
  const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
387
387
  /** WebSocket 心跳间隔(毫秒) */
388
388
  const WS_HEARTBEAT_INTERVAL_MS = 30000;
389
- /** WebSocket 最大重连次数 */
390
- const WS_MAX_RECONNECT_ATTEMPTS = 100;
389
+ /** WebSocket 连接断开时的最大重连次数 */
390
+ const WS_MAX_RECONNECT_ATTEMPTS = 10;
391
+ /** WebSocket 认证失败时的最大重试次数 */
392
+ const WS_MAX_AUTH_FAILURE_ATTEMPTS = 5;
391
393
  // ============================================================================
392
394
  // 消息状态管理配置
393
395
  // ============================================================================
@@ -2095,8 +2097,8 @@ function createSdkLogger(runtime, accountId) {
2095
2097
  * 使用 aibot-node-sdk 简化连接管理
2096
2098
  */
2097
2099
  async function monitorWeComProvider(options) {
2098
- const { account, config, runtime, abortSignal } = options;
2099
- runtime.log?.(`[${account.accountId}] Initializing WSClient with SDK...`);
2100
+ const { account, config, runtime, abortSignal, setStatus } = options;
2101
+ runtime.log?.(`[${account.accountId}] [${PLUGIN_VERSION}] Initializing WSClient with SDK...`);
2100
2102
  // 启动消息状态定期清理
2101
2103
  startMessageStateCleanup();
2102
2104
  return new Promise((resolve, reject) => {
@@ -2108,18 +2110,26 @@ async function monitorWeComProvider(options) {
2108
2110
  logger,
2109
2111
  heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
2110
2112
  maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
2113
+ maxAuthFailureAttempts: WS_MAX_AUTH_FAILURE_ATTEMPTS,
2111
2114
  scene: SCENE_WECOM_OPENCLAW,
2112
2115
  plug_version: PLUGIN_VERSION,
2113
2116
  });
2114
- // 清理函数:确保所有资源被释放
2117
+ // 防止 cleanup 被多次调用(abort handler、error handler、disconnected_event 可能竞态触发)
2118
+ let cleanedUp = false;
2119
+ // 清理函数:确保所有资源被释放(幂等)
2115
2120
  const cleanup = async () => {
2121
+ if (cleanedUp)
2122
+ return;
2123
+ cleanedUp = true;
2116
2124
  stopMessageStateCleanup();
2117
2125
  await cleanupAccount(account.accountId);
2118
2126
  };
2119
- // 处理中止信号
2127
+ // 处理中止信号(框架 stopChannel 会触发 abort)
2128
+ // resolve() 让 Promise settle → 框架清理 store.tasks/store.aborts
2120
2129
  if (abortSignal) {
2121
2130
  abortSignal.addEventListener("abort", async () => {
2122
2131
  runtime.log?.(`[${account.accountId}] Connection aborted`);
2132
+ wsClient.disconnect();
2123
2133
  await cleanup();
2124
2134
  resolve();
2125
2135
  });
@@ -2137,16 +2147,84 @@ async function monitorWeComProvider(options) {
2137
2147
  wsClient.on("disconnected", (reason) => {
2138
2148
  runtime.log?.(`[${account.accountId}] WebSocket disconnected: ${reason}`);
2139
2149
  });
2150
+ // 监听被踢下线事件(服务端因新连接建立而主动断开旧连接)
2151
+ //
2152
+ // SDK 内部已设置 isManualClose=true 阻止 SDK 层自动重连,连接不会自行恢复。
2153
+ // **不 reject/resolve Promise**——保持 pending 以阻止框架层 auto-restart。
2154
+ //
2155
+ // 为什么不能 reject/resolve:
2156
+ // - reject → 框架 auto-restart 介入 → 新连接建立 → 又被踢 → 两个实例互踢无限循环
2157
+ // - resolve → 同上,框架 .then() 中的 auto-restart 也会触发
2158
+ //
2159
+ // Promise pending 的安全性:
2160
+ // - store.tasks.has(id) = true → 阻止 Health Monitor 直接 startChannel(startChannel 检查 tasks.has)
2161
+ // - 框架 stopChannel → abort() → abort handler 中 resolve() → tasks 正常清理
2162
+ // - 用户修改配置 → config reload → stopChannel + startChannel → 正常恢复
2163
+ //
2164
+ // 显式调用 wsClient.disconnect() 确保 SDK 内部资源(定时器、队列等)完全释放。
2165
+ wsClient.on("event.disconnected_event", async () => {
2166
+ const errorMsg = `Kicked by server: a new connection was established elsewhere. Auto-restart is suppressed to avoid mutual kicking. Please check for duplicate instances.`;
2167
+ runtime.error?.(`[${account.accountId}] ${errorMsg}`);
2168
+ wsClient.disconnect();
2169
+ await cleanup();
2170
+ setStatus?.({
2171
+ accountId: account.accountId,
2172
+ running: false,
2173
+ lastError: errorMsg,
2174
+ lastStopAt: Date.now(),
2175
+ });
2176
+ // Promise 保持 pending,不触发 auto-restart
2177
+ });
2140
2178
  // 监听重连事件
2141
2179
  wsClient.on("reconnecting", (attempt) => {
2142
2180
  runtime.log?.(`[${account.accountId}] Reconnecting attempt ${attempt}...`);
2143
2181
  });
2144
2182
  // 监听错误事件
2145
- wsClient.on("error", (error) => {
2183
+ wsClient.on("error", async (error) => {
2146
2184
  runtime.error?.(`[${account.accountId}] WebSocket error: ${error.message}`);
2147
- // 认证失败时拒绝 Promise
2148
- if (error.message.includes("Authentication failed")) {
2185
+ if (error instanceof aibotNodeSdk.WSAuthFailureError) {
2186
+ // 认证失败重试次数用尽(SDK 层已重试 WS_MAX_AUTH_FAILURE_ATTEMPTS 次)。
2187
+ // 配置错误(如 botId/secret 无效),框架 auto-restart 也无法恢复。
2188
+ //
2189
+ // **不 reject/resolve Promise**——保持 pending 以阻止框架层 auto-restart。
2190
+ //
2191
+ // 为什么不能 reject/resolve:
2192
+ // - reject/resolve → 框架 auto-restart(最多 10 次)× SDK 重试(5 次)= 60 次无意义尝试
2193
+ // - 且 Health Monitor 每小时还会 resetRestartAttempts 再来一轮
2194
+ //
2195
+ // Promise pending 的安全性:同被踢下线场景
2196
+ // - store.tasks.has(id) = true → 阻止 Health Monitor 直接 startChannel
2197
+ // - 框架 stopChannel / config reload → abort handler 中 resolve() → 正常清理
2198
+ // - 用户修改配置后框架通过 reload 机制重新启动
2199
+ const errorMsg = `Auth failure attempts exhausted (${WS_MAX_AUTH_FAILURE_ATTEMPTS} attempts). Please check botId/secret configuration.`;
2200
+ runtime.error?.(`[${account.accountId}] ${errorMsg}`);
2201
+ wsClient.disconnect();
2202
+ await cleanup();
2203
+ setStatus?.({
2204
+ accountId: account.accountId,
2205
+ running: false,
2206
+ lastError: errorMsg,
2207
+ lastStopAt: Date.now(),
2208
+ });
2209
+ return;
2210
+ }
2211
+ if (error instanceof aibotNodeSdk.WSReconnectExhaustedError) {
2212
+ // 网络断线重连次数用尽(SDK 层已重试 WS_MAX_RECONNECT_ATTEMPTS 次)。
2213
+ // 通常是网络/服务端问题,框架 auto-restart 可能恢复。
2214
+ //
2215
+ // reject Promise → 框架 auto-restart 介入(最多 MAX_RESTART_ATTEMPTS=10 次)
2216
+ // 总连接尝试次数 = (1 首次 + WS_MAX_RECONNECT_ATTEMPTS 重连) × (1 首轮 + 10 auto-restart)
2217
+ // = 11 × 11 = 121 次
2218
+ //
2219
+ // 如果 Health Monitor 介入(每 5 分钟检查),会 resetRestartAttempts 重新计数,
2220
+ // 受限于 DEFAULT_MAX_RESTARTS_PER_HOUR=10,每小时最多额外 10 × 121 = 1210 次。
2221
+ // 但因网络断线通常是暂时性的,auto-restart + Health Monitor 的兜底机制是合理的。
2222
+ //
2223
+ // 显式调用 wsClient.disconnect() 确保 SDK 内部资源完全释放,
2224
+ // 避免旧实例的定时器/队列残留。
2225
+ wsClient.disconnect();
2149
2226
  cleanup().finally(() => reject(error));
2227
+ return;
2150
2228
  }
2151
2229
  });
2152
2230
  // 监听版本检查事件:收到 enter_check_update 时回复当前插件版本
@@ -2639,6 +2717,7 @@ const wecomPlugin = {
2639
2717
  config: ctx.cfg,
2640
2718
  runtime: ctx.runtime,
2641
2719
  abortSignal: ctx.abortSignal,
2720
+ setStatus: ctx.setStatus,
2642
2721
  });
2643
2722
  },
2644
2723
  logoutAccount: async ({ cfg }) => {
@@ -3319,8 +3398,12 @@ async function interceptMediaResponse(result) {
3319
3398
  // 5. 检测 contentType,并通过 saveMediaBuffer 保存到本地媒体目录
3320
3399
  const contentType = await detectMime({ buffer, filePath: mediaName }) ?? "application/octet-stream";
3321
3400
  const tMimeDetected = performance.now();
3401
+ // 企业微信聊天记录附件可达 20MB(文件消息上限),
3402
+ // 而 saveMediaBuffer 默认 maxBytes 为 5MB(针对 outbound 场景),
3403
+ // 此处显式放宽到 20MB 以支持大文件下载。
3404
+ const INBOUND_MAX_BYTES = 20 * 1024 * 1024; // 20MB
3322
3405
  const core = getWeComRuntime();
3323
- const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", undefined, // maxBytes: 使用 saveMediaBuffer 内部默认值
3406
+ const saved = await core.channel.media.saveMediaBuffer(buffer, contentType, "inbound", INBOUND_MAX_BYTES, // maxBytes: 放宽到 20MB,匹配企业微信文件消息上限
3324
3407
  mediaName);
3325
3408
  // 5.1 补偿:核心库 EXT_BY_MIME 可能缺少某些格式映射(如 audio/amr),
3326
3409
  // 导致保存的文件没有后缀。此处检测并修复。
@@ -3486,7 +3569,6 @@ function createWeComMcpTool() {
3486
3569
  };
3487
3570
  }
3488
3571
 
3489
- console.log(`[wecom] v${PLUGIN_VERSION} loaded`);
3490
3572
  const plugin = {
3491
3573
  id: "wecom-openclaw-plugin",
3492
3574
  name: "企业微信",