@yanhaidao/wecom 2.3.10 → 2.3.12
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 +54 -23
- package/changelog/v2.3.11.md +19 -0
- package/changelog/v2.3.12.md +23 -0
- package/package.json +1 -1
- package/src/app/account-runtime.ts +5 -4
- package/src/app/index.ts +19 -0
- package/src/onboarding.test.ts +50 -0
- package/src/onboarding.ts +4 -1
- package/src/outbound.test.ts +153 -1
- package/src/outbound.ts +105 -10
- package/src/runtime.ts +3 -0
- package/src/transport/bot-ws/reply.test.ts +137 -0
- package/src/transport/bot-ws/reply.ts +93 -18
- package/src/transport/bot-ws/sdk-adapter.test.ts +124 -0
- package/src/transport/bot-ws/sdk-adapter.ts +58 -2
- package/CLAUDE.md +0 -238
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<a href="#sec-1">💡 核心价值</a> •
|
|
18
18
|
<a href="#sec-2">📊 模式对比</a> •
|
|
19
|
+
<a href="#sec-changelog">📋 最近更新</a> •
|
|
19
20
|
<a href="#sec-3">一、快速开始</a> •
|
|
20
21
|
<a href="#sec-4">二、配置说明</a> •
|
|
21
22
|
<a href="#sec-5">三、企业微信接入</a> •
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
## 💡 核心价值:为什么选择本插件?
|
|
31
32
|
|
|
32
33
|
### 🎉 重大特性一览
|
|
33
|
-
1. **防断连黑科技** (v2.3.
|
|
34
|
+
1. **防断连黑科技** (v2.3.11 升级):针对 DeepSeek R1 等长时间 <think> 的推理模型,Bot WS 已升级为 **即时占位 + 持续保活 ACK** 机制。收到用户消息后立即展示 `streamPlaceholderContent`,并在首个真实回复块到来前持续保活,显著降低 WebSocket `invalid req_id` 与消息卡死现象。
|
|
34
35
|
2. **无需域名,极低门槛**:全面支持基于 WebSocket 的长连接(Bot WS)模式接入企业微信机器人,**彻底打通无公网 IP、无备案域名的内网服务器**与企微的实时对话桥梁!
|
|
35
36
|
3. **主动发消息,能力全覆盖**:基于 Agent 模式,全面支持**主动触达**,轻松实现早报定时任务、服务器异常报警、自动每日总结。
|
|
36
37
|
4. **向导自动路由自动适配** (v2.3.10 新增):在终端执行 `openclaw channels add` 时,若是单企微账号接入,将**静默触发自动 Agent 路由绑定**,丝滑跳过全局冗杂的路由分配步骤。
|
|
@@ -55,7 +56,7 @@
|
|
|
55
56
|
本插件支持 **无限扩展的账号矩阵**,这是本插件区别于普通插件的核心壁垒:
|
|
56
57
|
|
|
57
58
|
* **千人千面 (Dynamic Agents)**:内置自动会话隔离机制,百人同时私聊或群聊自动分摊至专属独立助理,告别上下文串扰。
|
|
58
|
-
*
|
|
59
|
+
* **账号级隔离 (Isolation)**:不同 `accountId` 之间的收发链路、运行时实例与动态 Agent 默认隔离;若多个账号共用同一个静态 Agent,建议额外配合 `session.dmScope = "per-account-channel-peer"`,避免私聊上下文共用。
|
|
59
60
|
* **矩阵绑定 (Binding)**:支持一个 OpenClaw 实例同时挂载多个企业/多个应用,通过 `bindings` 灵活分发流量。
|
|
60
61
|
* **智能路由 (Routing)**:基于入站 `accountId` 自动分拣回复路径,Bot 无法回复时仅回退到**同账号组内**的 Agent,实现闭环的高可用。
|
|
61
62
|
|
|
@@ -95,6 +96,48 @@
|
|
|
95
96
|
|
|
96
97
|
---
|
|
97
98
|
|
|
99
|
+
<a id="sec-changelog"></a>
|
|
100
|
+
|
|
101
|
+
## 📋 最近更新
|
|
102
|
+
|
|
103
|
+
> 项目保持高频迭代,核心改进一览:
|
|
104
|
+
|
|
105
|
+
#### v2.3.12(2026-03-12)
|
|
106
|
+
|
|
107
|
+
- 🛠 **[重要修复]** Bot WS 流式回复超 6 分钟后的 `846608 stream message update expired` 现在被识别为终态错误,不再导致进程退出。
|
|
108
|
+
- 🛠 **[重要修复]** SDK 5 秒回执超时 (`Reply ack timeout`) 也被识别为终态错误,超时后立即停止占位保活,不再产生 `unhandledRejection`。
|
|
109
|
+
- 🚀 Bot WS 模式下主动文本消息优先走 WS 长连接;Agent 仅兜底文件/媒体或未启用 WS 的场景。
|
|
110
|
+
- 🧯 `sdk-adapter` 为 WebSocket frame 异步处理补上显式兜底捕获,漏网异常记录为 runtime issue 而非崩溃。
|
|
111
|
+
- ⏱ 回复窗口过期时占位符保活立即停止。
|
|
112
|
+
|
|
113
|
+
#### v2.3.11(2026-03-11)
|
|
114
|
+
|
|
115
|
+
- `Bot WS` 升级为即时占位 + 持续保活,降低长思考时的 `invalid req_id`。
|
|
116
|
+
- `streamPlaceholderContent` 统一作用于 `Bot WS` 与 `Bot Webhook`。
|
|
117
|
+
- onboarding 在空配置下也会提供 `default` 账号选项。
|
|
118
|
+
|
|
119
|
+
#### v2.3.10(2026-03-10)
|
|
120
|
+
|
|
121
|
+
- onboarding 默认收敛为 `Bot + WS + 开放私聊`。
|
|
122
|
+
- 修复 `Bot WS` 长文本双重回复问题。
|
|
123
|
+
- Agent 新配置统一使用 `agentSecret`。
|
|
124
|
+
|
|
125
|
+
<details>
|
|
126
|
+
<summary>更早版本</summary>
|
|
127
|
+
|
|
128
|
+
#### v2.3.9(2026-03-09)
|
|
129
|
+
|
|
130
|
+
- Bot 默认接入改为 `WebSocket`,无需域名更易上手。
|
|
131
|
+
- 完善中文 onboarding,减少重复提示。
|
|
132
|
+
- 恢复 `Bot WS` 流式输出能力。
|
|
133
|
+
- 增强 Agent 回调与发送日志,排障更直接。
|
|
134
|
+
|
|
135
|
+
</details>
|
|
136
|
+
|
|
137
|
+
详细版本记录见 `changelog/` 目录。
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
98
141
|
<a id="sec-3"></a>
|
|
99
142
|
## 一、🚀 快速开始
|
|
100
143
|
|
|
@@ -194,6 +237,8 @@ openclaw channels add
|
|
|
194
237
|
说明:
|
|
195
238
|
- 新配置推荐使用 `agent.agentSecret`
|
|
196
239
|
- 历史配置里的 `agent.corpSecret` 仍兼容读取,但后续文档统一使用 `agentSecret`
|
|
240
|
+
- `bot.streamPlaceholderContent` 会同时作用于 `Bot WS` 与 `Bot Webhook`;在 `Bot WS` 下,收到用户消息后会立即显示该占位符,并在长思考期间持续保活。
|
|
241
|
+
- 如果你在一个 OpenClaw 实例里挂载多个 `accounts`,并让它们共同路由到同一个静态 Agent,建议在全局配置里加上 `session.dmScope = "per-account-channel-peer"`,让私聊 session key 显式带上 `accountId`。
|
|
197
242
|
|
|
198
243
|
### 1.3 高级网络配置(公网出口代理)
|
|
199
244
|
如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,首先使用Bot模式的ws接入方式。
|
|
@@ -227,6 +272,10 @@ openclaw channels status --deep
|
|
|
227
272
|
最简的流式交互版(无兜底分发功能)。
|
|
228
273
|
关键约束:`bot.primaryTransport = "ws"` 必须包含 `bot.ws` 参数。
|
|
229
274
|
|
|
275
|
+
行为说明:
|
|
276
|
+
- 收到用户消息后立即发送一次 `streamPlaceholderContent` 占位流。
|
|
277
|
+
- 若模型首个真实文本块尚未产出,系统会持续发送保活占位,避免长思考期间 `req_id` 失效。
|
|
278
|
+
|
|
230
279
|
```jsonc
|
|
231
280
|
{
|
|
232
281
|
"accounts": {
|
|
@@ -279,6 +328,8 @@ openclaw channels status --deep
|
|
|
279
328
|
|
|
280
329
|
> **🌟 多账号矩阵下的全局生效与物理隔离**
|
|
281
330
|
> `dynamicAgents` 属于通道级的全局开关,开启后会对配置的所有账号(`accounts`)生效。为了维持账号的绝对隔离,生成的隔离 Agent ID 内置了 Account 维度(例如:`wecom-ops-dm-张三` vs `wecom-sales-dm-张三`),保证跨企业应用依旧安全隔绝。
|
|
331
|
+
>
|
|
332
|
+
> 如果你没有开启 `dynamicAgents`,但多个 `accountId` 共享同一个静态 Agent,请在 OpenClaw 全局配置里显式设置 `session.dmScope = "per-account-channel-peer"`,确保不同账号的私聊上下文不会被收敛到同一个 session key。
|
|
282
333
|
|
|
283
334
|
---
|
|
284
335
|
|
|
@@ -370,7 +421,7 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
370
421
|
|
|
371
422
|
### 5.1 运行约束原则
|
|
372
423
|
- **协议单工限制**:同一账号下,Bot 只能选择一个主传输协议 `primaryTransport` (`ws` 或 `webhook`) 运作。
|
|
373
|
-
- **帧边界不可打破**:Bot WS 是基于官方微信内部通信协议的扩展,它必须携带并原路奉还对应的 `req_id
|
|
424
|
+
- **帧边界不可打破**:Bot WS 是基于官方微信内部通信协议的扩展,它必须携带并原路奉还对应的 `req_id`。插件会在长思考期间自动发送占位/保活帧来维持该回复窗口,但标准化事件不会替代原始数据帧,业务流始终可访问该原始微信底层框架。
|
|
374
425
|
- **媒体沙盒边界**:不论是 `Webhook`,还是 `WS`,涉及企微媒体加解密的处理绝不再跨界干预业务执行层。由内部服务自动在 Transport / Media Service 网关边界卸载 `aeskey` 解密并转换为统一 OpenClaw 媒体类抛出。
|
|
375
426
|
|
|
376
427
|
### 5.2 企业微信群聊交付规则
|
|
@@ -428,26 +479,6 @@ openclaw channels status --deep
|
|
|
428
479
|
|
|
429
480
|
## 七、📮 联系我 与 版本协议
|
|
430
481
|
|
|
431
|
-
### 最近更新
|
|
432
|
-
|
|
433
|
-
近期保持高频迭代,最近版本如下:
|
|
434
|
-
|
|
435
|
-
#### v2.3.10(2026-03-10)
|
|
436
|
-
|
|
437
|
-
- onboarding 默认收敛为 `Bot + WS + 开放私聊`。
|
|
438
|
-
- 修复 `Bot WS` 长文本双重回复问题。
|
|
439
|
-
- 修复首个自定义接入标识时报 `default not found`。
|
|
440
|
-
- Agent 新配置统一使用 `agentSecret`。
|
|
441
|
-
|
|
442
|
-
#### v2.3.9(2026-03-09)
|
|
443
|
-
|
|
444
|
-
- Bot 默认接入改为 `WebSocket`,无需域名更易上手。
|
|
445
|
-
- 完善中文 onboarding,减少重复提示。
|
|
446
|
-
- 恢复 `Bot WS` 流式输出能力。
|
|
447
|
-
- 增强 Agent 回调与发送日志,排障更直接。
|
|
448
|
-
|
|
449
|
-
详细版本记录见 `changelog/v2.3.10.md` 与 `changelog/v2.3.9.md`。
|
|
450
|
-
|
|
451
482
|
微信交流群(扫码入群):
|
|
452
483
|
|
|
453
484
|

|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.11 变更简报
|
|
2
|
+
|
|
3
|
+
> [!TIP]
|
|
4
|
+
> **稳定性与多账号说明版本**:`v2.3.11` 重点修复 Bot WS 长思考场景下的 `invalid req_id`,同步收敛 onboarding 账号默认值与多账号隔离文档说明。
|
|
5
|
+
|
|
6
|
+
## 2026-03-11(v2.3.11)
|
|
7
|
+
- 【WS 保活升级】🚀 **[重要修复]** Bot WebSocket 从“4 秒后补发占位”升级为“收到用户消息立即下发占位符,并在长思考期间持续保活”,显著降低慢模型或长任务下的 `invalid req_id` 与回复丢失风险。
|
|
8
|
+
- 【占位符配置生效】🌊 `bot.streamPlaceholderContent` 现在对 `Bot WS` 与 `Bot Webhook` 两条链路统一生效,配置一次即可保持体验一致。
|
|
9
|
+
- 【错误兜底收敛】🛡 修复 `req_id` 已失效时仍尝试二次回错消息的问题,避免出现重复报错与未处理 Promise 拒绝。
|
|
10
|
+
- 【账号选择兜底】🧩 onboarding 在“配置里还没有任何账号”时,也会显式提供 `default` 默认账号选项,减少首次接入时的困惑。
|
|
11
|
+
- 【多账号文档补强】📘 README 新增说明:如果多个 `accountId` 复用同一个静态 Agent,建议配合 `session.dmScope = "per-account-channel-peer"`,避免不同账号的私聊上下文共用。
|
|
12
|
+
- 【日志可读性】🔎 WeCom runtime 的 Bot WS 失败日志改为可读错误文本,排查时不再只看到 `[object Object]`。
|
|
13
|
+
|
|
14
|
+
## 验证结果
|
|
15
|
+
- `pnpm exec vitest run extensions/wecom/src/transport/bot-ws/reply.test.ts extensions/wecom/src/onboarding.test.ts`
|
|
16
|
+
|
|
17
|
+
## 升级提示
|
|
18
|
+
- 已有 `bot.streamPlaceholderContent` 配置无需调整,升级后 `Bot WS` 会自动使用它。
|
|
19
|
+
- 如果你在同一个 OpenClaw 实例里挂了多个 WeCom 账号,并让它们指向同一个静态 Agent,建议补上 `session.dmScope = "per-account-channel-peer"` 以隔离私聊上下文。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.12 变更简报
|
|
2
|
+
|
|
3
|
+
> [!TIP]
|
|
4
|
+
> **WS 主动推送与过期更新兜底版本**:`v2.3.12` 重点修复企业微信 Bot WebSocket 在流式回复超过 6 分钟后返回 `846608 stream message update expired`,并同步调整主动消息路由为“WS 文本优先、Agent 文件兜底”。
|
|
5
|
+
|
|
6
|
+
## 2026-03-12(v2.3.12)
|
|
7
|
+
- 【6 分钟过期修复】🛠 **[重要修复]** `Bot WS` 现在会把企业微信 `846608 stream message update expired (>6 minutes)` 识别为回复窗口已结束的终态错误,不再继续把该错误向上抛出导致进程退出。
|
|
8
|
+
- 【主动推送路由收敛】🚀 **[重要修复]** 当账号实际运行在 `Bot WS` 模式时,定时消息、heartbeat 和其他主动文本发送现在优先走 `wsClient.sendMessage()`,不再默认绕去 Agent。
|
|
9
|
+
- 【WS/Agent 职责切分】🧩 `Agent` 现在主要承担两类兜底:一是账号没有启用 `Bot WS` 时的主动消息发送;二是 Bot 两种模式都不支持的文件/媒体发送。
|
|
10
|
+
- 【未处理拒绝隔离】🧯 `sdk-adapter` 为每个 WebSocket frame 的异步处理补上显式兜底捕获;即使后续再出现漏网异常,也会记录到 runtime issue,而不是变成 `unhandledRejection` 直接带崩 OpenClaw。
|
|
11
|
+
- 【占位保活收敛】⏱ 当回复窗口已经过期时,WS 占位符保活会立即停止,避免过期后继续发送流式更新。
|
|
12
|
+
- 【Ack 超时兜底】⏱ SDK 5 秒回执超时 (`Reply ack timeout`) 现在也被识别为终态错误,超时后立即停止占位保活并走 `onFail` 回调,不再产生 `unhandledRejection`。
|
|
13
|
+
- 【回归测试补齐】✅ 新增针对 `846608` 过期更新和 `frame handler` reject 的回归测试,确保此类异常保持非致命。
|
|
14
|
+
|
|
15
|
+
## 验证结果
|
|
16
|
+
- `pnpm exec vitest -c extensions/wecom/vitest.config.ts extensions/wecom/src/transport/bot-ws/reply.test.ts extensions/wecom/src/transport/bot-ws/sdk-adapter.test.ts`
|
|
17
|
+
- `pnpm exec vitest -c extensions/wecom/vitest.config.ts extensions/wecom/src/outbound.test.ts extensions/wecom/src/transport/bot-ws/reply.test.ts extensions/wecom/src/transport/bot-ws/sdk-adapter.test.ts`
|
|
18
|
+
- `pnpm exec tsc -p extensions/wecom/tsconfig.json --noEmit`
|
|
19
|
+
|
|
20
|
+
## 升级提示
|
|
21
|
+
- 无需新增配置,升级到 `v2.3.12` 后自动生效。
|
|
22
|
+
- 如果账号正在使用 `Bot WS`,主动文本消息会优先走 WS 长连接;只有文件/媒体或未启用 WS 的场景才会继续使用 Agent。
|
|
23
|
+
- 如果模型或工具链路存在超长执行,超过企业微信 6 分钟回复窗口时,OpenClaw 现在会保持存活并记录运行时错误,不再因该异常退出。
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { formatErrorMessage, type OpenClawConfig, type PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
2
|
|
|
3
3
|
import { InMemoryRuntimeStore } from "../store/memory-store.js";
|
|
4
4
|
import { WecomMediaService } from "../shared/media-service.js";
|
|
@@ -80,16 +80,17 @@ export class WecomAccountRuntime {
|
|
|
80
80
|
this.emitStatus();
|
|
81
81
|
},
|
|
82
82
|
fail: async (error: unknown) => {
|
|
83
|
+
const formattedError = formatErrorMessage(error);
|
|
83
84
|
this.recordOperationalIssue({
|
|
84
85
|
transport: replyHandle.context.transport,
|
|
85
86
|
category: "runtime-error",
|
|
86
87
|
messageId: event.messageId,
|
|
87
88
|
raw: replyHandle.context.raw,
|
|
88
|
-
summary: `reply-fail ${
|
|
89
|
-
error:
|
|
89
|
+
summary: `reply-fail ${formattedError}`,
|
|
90
|
+
error: formattedError,
|
|
90
91
|
});
|
|
91
92
|
this.log.error?.(
|
|
92
|
-
`[wecom-runtime] reply-fail account=${event.accountId} transport=${replyHandle.context.transport} messageId=${event.messageId} error=${
|
|
93
|
+
`[wecom-runtime] reply-fail account=${event.accountId} transport=${replyHandle.context.transport} messageId=${event.messageId} error=${formattedError}`,
|
|
93
94
|
);
|
|
94
95
|
await replyHandle.fail?.(error);
|
|
95
96
|
},
|
package/src/app/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { WecomAccountRuntime } from "./account-runtime.js";
|
|
|
4
4
|
|
|
5
5
|
let runtime: PluginRuntime | null = null;
|
|
6
6
|
const runtimes = new Map<string, WecomAccountRuntime>();
|
|
7
|
+
const botWsPushHandles = new Map<string, BotWsPushHandle>();
|
|
8
|
+
|
|
9
|
+
export type BotWsPushHandle = {
|
|
10
|
+
isConnected: () => boolean;
|
|
11
|
+
sendMarkdown: (chatId: string, content: string) => Promise<void>;
|
|
12
|
+
};
|
|
7
13
|
|
|
8
14
|
export function setWecomRuntime(next: PluginRuntime): void {
|
|
9
15
|
runtime = next;
|
|
@@ -25,7 +31,20 @@ export function getAccountRuntimeSnapshot(accountId: string) {
|
|
|
25
31
|
return runtimes.get(accountId)?.buildRuntimeStatus();
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
export function registerBotWsPushHandle(accountId: string, handle: BotWsPushHandle): void {
|
|
35
|
+
botWsPushHandles.set(accountId, handle);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getBotWsPushHandle(accountId: string): BotWsPushHandle | undefined {
|
|
39
|
+
return botWsPushHandles.get(accountId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function unregisterBotWsPushHandle(accountId: string): void {
|
|
43
|
+
botWsPushHandles.delete(accountId);
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export function unregisterAccountRuntime(accountId: string): void {
|
|
29
47
|
runtimes.delete(accountId);
|
|
48
|
+
botWsPushHandles.delete(accountId);
|
|
30
49
|
console.log(`[wecom-runtime] unregister account=${accountId}`);
|
|
31
50
|
}
|
package/src/onboarding.test.ts
CHANGED
|
@@ -217,6 +217,56 @@ describe("wecom onboarding", () => {
|
|
|
217
217
|
expect(wecomOnboardingAdapter.dmPolicy).toBeUndefined();
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
it("offers default account selection when config has no accounts", async () => {
|
|
221
|
+
const prompter = createPrompter({
|
|
222
|
+
select: vi.fn(async ({ message, options }: { message: string; options: Array<{ value: string; label: string }> }) => {
|
|
223
|
+
if (message === "请选择企业微信接入标识(英文):") {
|
|
224
|
+
expect(options.map((option) => option.value)).toEqual(["default", "__new__"]);
|
|
225
|
+
expect(options[0]?.label).toBe("default(默认标识)");
|
|
226
|
+
return "default";
|
|
227
|
+
}
|
|
228
|
+
if (message === "请选择您要配置的接入模式:") {
|
|
229
|
+
return "bot";
|
|
230
|
+
}
|
|
231
|
+
if (message === "请选择私聊 (DM) 访问策略:") {
|
|
232
|
+
return "pairing";
|
|
233
|
+
}
|
|
234
|
+
throw new Error(`Unexpected select prompt: ${message}`);
|
|
235
|
+
}) as WizardPrompter["select"],
|
|
236
|
+
text: vi.fn(async ({ message }: { message: string }) => {
|
|
237
|
+
if (message === "请输入 BotId(机器人 ID):") {
|
|
238
|
+
return "bot-id-default";
|
|
239
|
+
}
|
|
240
|
+
if (message === "请输入 Secret(机器人密钥):") {
|
|
241
|
+
return "bot-secret-default";
|
|
242
|
+
}
|
|
243
|
+
if (message === "流式占位符 (可选):") {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
if (message === "欢迎语 (可选):") {
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
249
|
+
throw new Error(`Unexpected text prompt: ${message}`);
|
|
250
|
+
}) as WizardPrompter["text"],
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const result = await wecomOnboardingAdapter.configure({
|
|
254
|
+
cfg: {} as OpenClawConfig,
|
|
255
|
+
runtime: createRuntime(),
|
|
256
|
+
prompter,
|
|
257
|
+
options: {},
|
|
258
|
+
accountOverrides: {},
|
|
259
|
+
shouldPromptAccountIds: true,
|
|
260
|
+
forceAllowFrom: false,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result.accountId).toBe("default");
|
|
264
|
+
expect(result.cfg.channels?.wecom?.accounts?.default?.bot?.ws).toEqual({
|
|
265
|
+
botId: "bot-id-default",
|
|
266
|
+
secret: "bot-secret-default",
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
220
270
|
it("writes agentSecret for fresh agent onboarding", async () => {
|
|
221
271
|
const prompter = createPrompter({
|
|
222
272
|
select: vi.fn(async ({ message }: { message: string }) => {
|
package/src/onboarding.ts
CHANGED
|
@@ -249,10 +249,13 @@ async function resolveOnboardingAccountId(params: {
|
|
|
249
249
|
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
|
250
250
|
if (!override && params.shouldPromptAccountIds) {
|
|
251
251
|
const existingIds = listWecomAccountIds(params.cfg);
|
|
252
|
+
const selectableIds = existingIds.includes(DEFAULT_ACCOUNT_ID)
|
|
253
|
+
? existingIds
|
|
254
|
+
: [DEFAULT_ACCOUNT_ID, ...existingIds];
|
|
252
255
|
const choice = await params.prompter.select({
|
|
253
256
|
message: "请选择企业微信接入标识(英文):",
|
|
254
257
|
options: [
|
|
255
|
-
...
|
|
258
|
+
...selectableIds.map((id) => ({
|
|
256
259
|
value: id,
|
|
257
260
|
label: id === DEFAULT_ACCOUNT_ID ? "default(默认标识)" : id,
|
|
258
261
|
})),
|
package/src/outbound.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("./transport/agent-api/core.js", () => ({
|
|
4
4
|
sendText: vi.fn(),
|
|
@@ -7,6 +7,13 @@ vi.mock("./transport/agent-api/core.js", () => ({
|
|
|
7
7
|
}));
|
|
8
8
|
|
|
9
9
|
describe("wecomOutbound", () => {
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
const runtime = await import("./runtime.js");
|
|
12
|
+
runtime.unregisterBotWsPushHandle("default");
|
|
13
|
+
runtime.unregisterBotWsPushHandle("acct-ws");
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
10
17
|
it("does not crash when called with core outbound params", async () => {
|
|
11
18
|
const { wecomOutbound } = await import("./outbound.js");
|
|
12
19
|
await expect(
|
|
@@ -173,6 +180,151 @@ describe("wecomOutbound", () => {
|
|
|
173
180
|
now.mockRestore();
|
|
174
181
|
});
|
|
175
182
|
|
|
183
|
+
it("prefers Bot WS active push for text when ws is the active bot transport", async () => {
|
|
184
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
185
|
+
const runtime = await import("./runtime.js");
|
|
186
|
+
const api = await import("./transport/agent-api/core.js");
|
|
187
|
+
const sendMarkdown = vi.fn().mockResolvedValue(undefined);
|
|
188
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(789);
|
|
189
|
+
runtime.registerBotWsPushHandle("acct-ws", {
|
|
190
|
+
isConnected: () => true,
|
|
191
|
+
sendMarkdown,
|
|
192
|
+
});
|
|
193
|
+
(api.sendText as any).mockClear();
|
|
194
|
+
|
|
195
|
+
const cfg = {
|
|
196
|
+
channels: {
|
|
197
|
+
wecom: {
|
|
198
|
+
enabled: true,
|
|
199
|
+
defaultAccount: "acct-ws",
|
|
200
|
+
accounts: {
|
|
201
|
+
"acct-ws": {
|
|
202
|
+
enabled: true,
|
|
203
|
+
bot: {
|
|
204
|
+
primaryTransport: "ws",
|
|
205
|
+
ws: {
|
|
206
|
+
botId: "bot-1",
|
|
207
|
+
secret: "secret-1",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
agent: {
|
|
211
|
+
corpId: "corp-ws",
|
|
212
|
+
corpSecret: "agent-secret",
|
|
213
|
+
agentId: 10001,
|
|
214
|
+
token: "token-ws",
|
|
215
|
+
encodingAESKey: "aes-ws",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = await wecomOutbound.sendText({
|
|
224
|
+
cfg,
|
|
225
|
+
accountId: "acct-ws",
|
|
226
|
+
to: "user:lisi",
|
|
227
|
+
text: "hello ws",
|
|
228
|
+
} as any);
|
|
229
|
+
|
|
230
|
+
expect(sendMarkdown).toHaveBeenCalledWith("lisi", "hello ws");
|
|
231
|
+
expect(api.sendText).not.toHaveBeenCalled();
|
|
232
|
+
expect(result.messageId).toBe("bot-ws-789");
|
|
233
|
+
|
|
234
|
+
now.mockRestore();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does not silently fall back to Agent when Bot WS active push is configured but unavailable", async () => {
|
|
238
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
239
|
+
const api = await import("./transport/agent-api/core.js");
|
|
240
|
+
(api.sendText as any).mockClear();
|
|
241
|
+
|
|
242
|
+
const cfg = {
|
|
243
|
+
channels: {
|
|
244
|
+
wecom: {
|
|
245
|
+
enabled: true,
|
|
246
|
+
bot: {
|
|
247
|
+
primaryTransport: "ws",
|
|
248
|
+
ws: {
|
|
249
|
+
botId: "bot-1",
|
|
250
|
+
secret: "secret-1",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
agent: {
|
|
254
|
+
corpId: "corp",
|
|
255
|
+
corpSecret: "secret",
|
|
256
|
+
agentId: 1000002,
|
|
257
|
+
token: "token",
|
|
258
|
+
encodingAESKey: "aes",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
wecomOutbound.sendText({
|
|
266
|
+
cfg,
|
|
267
|
+
to: "user:zhangsan",
|
|
268
|
+
text: "hello",
|
|
269
|
+
} as any),
|
|
270
|
+
).rejects.toThrow(/no live ws runtime is registered/i);
|
|
271
|
+
expect(api.sendText).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("keeps outbound media on Agent even when Bot WS is active", async () => {
|
|
275
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
276
|
+
const runtime = await import("./runtime.js");
|
|
277
|
+
const api = await import("./transport/agent-api/core.js");
|
|
278
|
+
const sendMarkdown = vi.fn().mockResolvedValue(undefined);
|
|
279
|
+
runtime.registerBotWsPushHandle("default", {
|
|
280
|
+
isConnected: () => true,
|
|
281
|
+
sendMarkdown,
|
|
282
|
+
});
|
|
283
|
+
(api.uploadMedia as any).mockResolvedValue("media-1");
|
|
284
|
+
(api.sendMedia as any).mockResolvedValue(undefined);
|
|
285
|
+
(api.sendMedia as any).mockClear();
|
|
286
|
+
vi.stubGlobal(
|
|
287
|
+
"fetch",
|
|
288
|
+
vi.fn().mockResolvedValue({
|
|
289
|
+
ok: true,
|
|
290
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
291
|
+
headers: new Headers({ "content-type": "image/png" }),
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const cfg = {
|
|
296
|
+
channels: {
|
|
297
|
+
wecom: {
|
|
298
|
+
enabled: true,
|
|
299
|
+
bot: {
|
|
300
|
+
primaryTransport: "ws",
|
|
301
|
+
ws: {
|
|
302
|
+
botId: "bot-1",
|
|
303
|
+
secret: "secret-1",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
agent: {
|
|
307
|
+
corpId: "corp",
|
|
308
|
+
corpSecret: "secret",
|
|
309
|
+
agentId: 1000002,
|
|
310
|
+
token: "token",
|
|
311
|
+
encodingAESKey: "aes",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
await wecomOutbound.sendMedia({
|
|
318
|
+
cfg,
|
|
319
|
+
to: "user:zhangsan",
|
|
320
|
+
text: "caption",
|
|
321
|
+
mediaUrl: "https://example.com/media.png",
|
|
322
|
+
} as any);
|
|
323
|
+
|
|
324
|
+
expect(api.sendMedia).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(sendMarkdown).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
176
328
|
it("uses account-scoped agent config in matrix mode", async () => {
|
|
177
329
|
const { wecomOutbound } = await import("./outbound.js");
|
|
178
330
|
const api = await import("./transport/agent-api/core.js");
|
package/src/outbound.ts
CHANGED
|
@@ -2,9 +2,10 @@ import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/pl
|
|
|
2
2
|
|
|
3
3
|
import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
|
|
4
4
|
import { WecomAgentDeliveryService } from "./capability/agent/index.js";
|
|
5
|
-
import { getWecomRuntime } from "./runtime.js";
|
|
5
|
+
import { getBotWsPushHandle, getWecomRuntime } from "./runtime.js";
|
|
6
|
+
import { resolveScopedWecomTarget } from "./target.js";
|
|
6
7
|
|
|
7
|
-
function
|
|
8
|
+
function resolveOutboundAccountOrThrow(params: {
|
|
8
9
|
cfg: ChannelOutboundContext["cfg"];
|
|
9
10
|
accountId?: string | null;
|
|
10
11
|
}) {
|
|
@@ -26,10 +27,17 @@ function resolveAgentConfigOrThrow(params: {
|
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
return resolveWecomAccount({
|
|
30
31
|
cfg: params.cfg,
|
|
31
32
|
accountId: params.accountId,
|
|
32
|
-
})
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveAgentConfigOrThrow(params: {
|
|
37
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
38
|
+
accountId?: string | null;
|
|
39
|
+
}) {
|
|
40
|
+
const account = resolveOutboundAccountOrThrow(params).agent;
|
|
33
41
|
if (!account?.apiConfigured) {
|
|
34
42
|
throw new Error(
|
|
35
43
|
`WeCom outbound requires Agent mode for account=${params.accountId ?? "default"}. Configure channels.wecom.accounts.<accountId>.agent (or legacy channels.wecom.agent).`,
|
|
@@ -45,6 +53,81 @@ function resolveAgentConfigOrThrow(params: {
|
|
|
45
53
|
return account;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
function isExplicitAgentTarget(raw: string | undefined): boolean {
|
|
57
|
+
return /^wecom-agent:/i.test(String(raw ?? "").trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveBotWsChatTarget(params: {
|
|
61
|
+
to: string | undefined;
|
|
62
|
+
accountId: string;
|
|
63
|
+
}): string | undefined {
|
|
64
|
+
const scoped = resolveScopedWecomTarget(params.to, params.accountId);
|
|
65
|
+
if (!scoped) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (scoped.accountId && scoped.accountId !== params.accountId) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`WeCom outbound account mismatch: target belongs to account=${scoped.accountId}, current account=${params.accountId}.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (scoped.target.chatid) {
|
|
74
|
+
return scoped.target.chatid;
|
|
75
|
+
}
|
|
76
|
+
if (scoped.target.touser) {
|
|
77
|
+
return scoped.target.touser;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shouldPreferBotWsOutbound(params: {
|
|
83
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
84
|
+
accountId?: string | null;
|
|
85
|
+
to: string | undefined;
|
|
86
|
+
}): { preferred: boolean; accountId: string } {
|
|
87
|
+
const account = resolveOutboundAccountOrThrow({
|
|
88
|
+
cfg: params.cfg,
|
|
89
|
+
accountId: params.accountId,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
preferred: !isExplicitAgentTarget(params.to) && Boolean(account.bot?.configured && account.bot.primaryTransport === "ws" && account.bot.wsConfigured),
|
|
93
|
+
accountId: account.accountId,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function sendTextViaBotWs(params: {
|
|
98
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
99
|
+
accountId?: string | null;
|
|
100
|
+
to: string | undefined;
|
|
101
|
+
text: string;
|
|
102
|
+
}): Promise<boolean> {
|
|
103
|
+
const { preferred, accountId } = shouldPreferBotWsOutbound(params);
|
|
104
|
+
if (!preferred) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const chatId = resolveBotWsChatTarget({
|
|
108
|
+
to: params.to,
|
|
109
|
+
accountId,
|
|
110
|
+
});
|
|
111
|
+
if (!chatId) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const handle = getBotWsPushHandle(accountId);
|
|
115
|
+
if (!handle) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`WeCom outbound account=${accountId} is configured for Bot WS active push, but no live WS runtime is registered.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!handle.isConnected()) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`WeCom outbound account=${accountId} is configured for Bot WS active push, but the WS transport is not connected.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
console.log(`[wecom-outbound] Sending Bot WS active message to target=${String(params.to ?? "")} chatId=${chatId} (len=${params.text.length})`);
|
|
126
|
+
await handle.sendMarkdown(chatId, params.text);
|
|
127
|
+
console.log(`[wecom-outbound] Successfully sent Bot WS active message to ${chatId}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
48
131
|
export const wecomOutbound: ChannelOutboundAdapter = {
|
|
49
132
|
deliveryMode: "direct",
|
|
50
133
|
chunkerMode: "text",
|
|
@@ -59,9 +142,6 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
59
142
|
sendText: async ({ cfg, to, text, accountId }: ChannelOutboundContext) => {
|
|
60
143
|
// signal removed - not supported in current SDK
|
|
61
144
|
|
|
62
|
-
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
63
|
-
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
64
|
-
|
|
65
145
|
// 体验优化:/new /reset 的“New session started”回执在 OpenClaw 核心里是英文固定文案,
|
|
66
146
|
// 且通过 routeReply 走 wecom outbound(Agent 主动发送)。
|
|
67
147
|
// 在 WeCom“双模式”场景下,这会造成:
|
|
@@ -93,12 +173,23 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
93
173
|
|
|
94
174
|
console.log(`[wecom-outbound] Sending text to target=${String(to ?? "")} (len=${outgoingText.length})`);
|
|
95
175
|
|
|
176
|
+
let sentViaBotWs = false;
|
|
96
177
|
try {
|
|
97
|
-
await
|
|
178
|
+
sentViaBotWs = await sendTextViaBotWs({
|
|
179
|
+
cfg,
|
|
180
|
+
accountId,
|
|
98
181
|
to,
|
|
99
182
|
text: outgoingText,
|
|
100
183
|
});
|
|
101
|
-
|
|
184
|
+
if (!sentViaBotWs) {
|
|
185
|
+
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
186
|
+
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
187
|
+
await deliveryService.sendText({
|
|
188
|
+
to,
|
|
189
|
+
text: outgoingText,
|
|
190
|
+
});
|
|
191
|
+
console.log(`[wecom-outbound] Successfully sent Agent text to ${String(to ?? "")}`);
|
|
192
|
+
}
|
|
102
193
|
} catch (err) {
|
|
103
194
|
console.error(`[wecom-outbound] Failed to send text to ${String(to ?? "")}:`, err);
|
|
104
195
|
throw err;
|
|
@@ -106,13 +197,17 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
106
197
|
|
|
107
198
|
return {
|
|
108
199
|
channel: "wecom",
|
|
109
|
-
messageId:
|
|
200
|
+
messageId: `${sentViaBotWs ? "bot-ws" : "agent"}-${Date.now()}`,
|
|
110
201
|
timestamp: Date.now(),
|
|
111
202
|
};
|
|
112
203
|
},
|
|
113
204
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
|
|
114
205
|
// signal removed - not supported in current SDK
|
|
115
206
|
|
|
207
|
+
const { preferred } = shouldPreferBotWsOutbound({ cfg, accountId, to });
|
|
208
|
+
if (preferred) {
|
|
209
|
+
console.log(`[wecom-outbound] Bot WS active push does not support outbound media; falling back to Agent for target=${String(to ?? "")}`);
|
|
210
|
+
}
|
|
116
211
|
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
117
212
|
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
118
213
|
if (!mediaUrl) {
|