@yanhaidao/wecom 2.3.9 → 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 +62 -18
- package/changelog/v2.3.10.md +17 -0
- package/changelog/v2.3.11.md +19 -0
- package/changelog/v2.3.12.md +23 -0
- package/compat-single-account.md +2 -2
- package/package.json +1 -1
- package/src/app/account-runtime.ts +5 -4
- package/src/app/index.ts +19 -0
- package/src/config/accounts.resolve.test.ts +39 -2
- package/src/config/accounts.ts +29 -7
- package/src/config/schema.ts +6 -1
- package/src/onboarding.test.ts +102 -3
- package/src/onboarding.ts +18 -15
- package/src/outbound.test.ts +154 -2
- package/src/outbound.ts +105 -10
- package/src/runtime.ts +3 -0
- package/src/transport/bot-ws/inbound.ts +0 -1
- package/src/transport/bot-ws/reply.test.ts +137 -0
- package/src/transport/bot-ws/reply.ts +98 -8
- package/src/transport/bot-ws/sdk-adapter.test.ts +124 -0
- package/src/transport/bot-ws/sdk-adapter.ts +58 -2
- package/src/types/config.ts +6 -1
- 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,8 +31,10 @@
|
|
|
30
31
|
## 💡 核心价值:为什么选择本插件?
|
|
31
32
|
|
|
32
33
|
### 🎉 重大特性一览
|
|
33
|
-
1.
|
|
34
|
-
2.
|
|
34
|
+
1. **防断连黑科技** (v2.3.11 升级):针对 DeepSeek R1 等长时间 <think> 的推理模型,Bot WS 已升级为 **即时占位 + 持续保活 ACK** 机制。收到用户消息后立即展示 `streamPlaceholderContent`,并在首个真实回复块到来前持续保活,显著降低 WebSocket `invalid req_id` 与消息卡死现象。
|
|
35
|
+
2. **无需域名,极低门槛**:全面支持基于 WebSocket 的长连接(Bot WS)模式接入企业微信机器人,**彻底打通无公网 IP、无备案域名的内网服务器**与企微的实时对话桥梁!
|
|
36
|
+
3. **主动发消息,能力全覆盖**:基于 Agent 模式,全面支持**主动触达**,轻松实现早报定时任务、服务器异常报警、自动每日总结。
|
|
37
|
+
4. **向导自动路由自动适配** (v2.3.10 新增):在终端执行 `openclaw channels add` 时,若是单企微账号接入,将**静默触发自动 Agent 路由绑定**,丝滑跳过全局冗杂的路由分配步骤。
|
|
35
38
|
|
|
36
39
|
### 🔧 全新统一运行时架构 (Unified Runtime)
|
|
37
40
|
插件现已采用全新解耦架构:
|
|
@@ -53,7 +56,7 @@
|
|
|
53
56
|
本插件支持 **无限扩展的账号矩阵**,这是本插件区别于普通插件的核心壁垒:
|
|
54
57
|
|
|
55
58
|
* **千人千面 (Dynamic Agents)**:内置自动会话隔离机制,百人同时私聊或群聊自动分摊至专属独立助理,告别上下文串扰。
|
|
56
|
-
*
|
|
59
|
+
* **账号级隔离 (Isolation)**:不同 `accountId` 之间的收发链路、运行时实例与动态 Agent 默认隔离;若多个账号共用同一个静态 Agent,建议额外配合 `session.dmScope = "per-account-channel-peer"`,避免私聊上下文共用。
|
|
57
60
|
* **矩阵绑定 (Binding)**:支持一个 OpenClaw 实例同时挂载多个企业/多个应用,通过 `bindings` 灵活分发流量。
|
|
58
61
|
* **智能路由 (Routing)**:基于入站 `accountId` 自动分拣回复路径,Bot 无法回复时仅回退到**同账号组内**的 Agent,实现闭环的高可用。
|
|
59
62
|
|
|
@@ -93,6 +96,48 @@
|
|
|
93
96
|
|
|
94
97
|
---
|
|
95
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
|
+
|
|
96
141
|
<a id="sec-3"></a>
|
|
97
142
|
## 一、🚀 快速开始
|
|
98
143
|
|
|
@@ -154,7 +199,7 @@ openclaw channels add
|
|
|
154
199
|
},
|
|
155
200
|
"agent": {
|
|
156
201
|
"corpId": "CORP_ID",
|
|
157
|
-
"
|
|
202
|
+
"agentSecret": "AGENT_SECRET",
|
|
158
203
|
"agentId": 1000001,
|
|
159
204
|
"token": "AGENT_TOKEN",
|
|
160
205
|
"encodingAESKey": "AGENT_AES_KEY",
|
|
@@ -189,6 +234,12 @@ openclaw channels add
|
|
|
189
234
|
}
|
|
190
235
|
```
|
|
191
236
|
|
|
237
|
+
说明:
|
|
238
|
+
- 新配置推荐使用 `agent.agentSecret`
|
|
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`。
|
|
242
|
+
|
|
192
243
|
### 1.3 高级网络配置(公网出口代理)
|
|
193
244
|
如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,首先使用Bot模式的ws接入方式。
|
|
194
245
|
|
|
@@ -221,6 +272,10 @@ openclaw channels status --deep
|
|
|
221
272
|
最简的流式交互版(无兜底分发功能)。
|
|
222
273
|
关键约束:`bot.primaryTransport = "ws"` 必须包含 `bot.ws` 参数。
|
|
223
274
|
|
|
275
|
+
行为说明:
|
|
276
|
+
- 收到用户消息后立即发送一次 `streamPlaceholderContent` 占位流。
|
|
277
|
+
- 若模型首个真实文本块尚未产出,系统会持续发送保活占位,避免长思考期间 `req_id` 失效。
|
|
278
|
+
|
|
224
279
|
```jsonc
|
|
225
280
|
{
|
|
226
281
|
"accounts": {
|
|
@@ -273,6 +328,8 @@ openclaw channels status --deep
|
|
|
273
328
|
|
|
274
329
|
> **🌟 多账号矩阵下的全局生效与物理隔离**
|
|
275
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。
|
|
276
333
|
|
|
277
334
|
---
|
|
278
335
|
|
|
@@ -364,7 +421,7 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
364
421
|
|
|
365
422
|
### 5.1 运行约束原则
|
|
366
423
|
- **协议单工限制**:同一账号下,Bot 只能选择一个主传输协议 `primaryTransport` (`ws` 或 `webhook`) 运作。
|
|
367
|
-
- **帧边界不可打破**:Bot WS 是基于官方微信内部通信协议的扩展,它必须携带并原路奉还对应的 `req_id
|
|
424
|
+
- **帧边界不可打破**:Bot WS 是基于官方微信内部通信协议的扩展,它必须携带并原路奉还对应的 `req_id`。插件会在长思考期间自动发送占位/保活帧来维持该回复窗口,但标准化事件不会替代原始数据帧,业务流始终可访问该原始微信底层框架。
|
|
368
425
|
- **媒体沙盒边界**:不论是 `Webhook`,还是 `WS`,涉及企微媒体加解密的处理绝不再跨界干预业务执行层。由内部服务自动在 Transport / Media Service 网关边界卸载 `aeskey` 解密并转换为统一 OpenClaw 媒体类抛出。
|
|
369
426
|
|
|
370
427
|
### 5.2 企业微信群聊交付规则
|
|
@@ -422,19 +479,6 @@ openclaw channels status --deep
|
|
|
422
479
|
|
|
423
480
|
## 七、📮 联系我 与 版本协议
|
|
424
481
|
|
|
425
|
-
### v2.3.9 更新日志(2026-03-09)
|
|
426
|
-
|
|
427
|
-
本次版本重点收敛为“默认更易用、排障更直接、回调更稳定”:
|
|
428
|
-
|
|
429
|
-
- **默认 Bot WebSocket 引导**:onboarding 默认写入 `bot.ws`,新用户无需域名即可完成机器人接入。
|
|
430
|
-
- **中文化配置体验**:企业微信插件自管账号创建与私聊策略提示,尽量避免流程中重复出现英文术语。
|
|
431
|
-
- **Bot WS 流式输出修复**:长连接模式支持块级流式交付,不再只保留最终结果。
|
|
432
|
-
- **Webhook / Agent 回调稳定性增强**:统一 HTTP 分发链路,并补齐默认账号显式路径,便于 Bot 与 Agent 回调稳定命中。
|
|
433
|
-
- **Agent 排障日志增强**:补充原始 callback、解密摘要、发送请求与响应日志,更容易定位“能接收但不能回复”的问题。
|
|
434
|
-
- **配置收敛**:移除 `bot.enabled` / `agent.enabled` 冗余字段,减少误导。
|
|
435
|
-
|
|
436
|
-
详细版本记录见 `changelog/v2.3.9.md`。
|
|
437
|
-
|
|
438
482
|
微信交流群(扫码入群):
|
|
439
483
|
|
|
440
484
|

|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.10 变更简报
|
|
2
|
+
|
|
3
|
+
> [!TIP]
|
|
4
|
+
> **默认更易用、修复更直接的版本。**
|
|
5
|
+
|
|
6
|
+
## 2026-03-10(v2.3.10)
|
|
7
|
+
- 【消息防丢修复】🐛 **[重要修复]** 针对由于模型 API 限速或超长思考(如 DeepSeek R1)导致的企微 WebSocket 5秒超时断连问题,引入了 4 秒前置保活机制(自动下发"⏳ 正在思考中..."),彻底阻断了因为模型响应慢而造成的“消息卡死不再回复”。
|
|
8
|
+
- 【双重回复修复】🐛 **[重要修复]** 修复 `Bot WS` 长文本场景下可能被超时截断并触发兜底通道进行二次重复回复的边界异常。
|
|
9
|
+
- 【向导自动路由】✨ **[体验升级]** 重构了企业微信的渠道交互配置向导。在单账号场景下将静默触发自动路由绑定,丝滑跳过 OpenClaw 全局冗长的 Agent 路由分配询问。
|
|
10
|
+
- 【账号兜底修复】🧩 修复企业微信 onboarding 在首个账号非字面量 `default` 时,后续流程报 `WeCom account "default" not found` 的问题。
|
|
11
|
+
- 【默认选项收敛】🚀 onboarding 的默认回车选项已变更为更普适的 `Bot` 模式、`WS` 接入和 `开放模式` 策略。
|
|
12
|
+
- 【字段命名收敛】📝 Agent 新配置统一推荐使用 `agentSecret`,历史 `corpSecret` 保持兼容读取,保障平滑升级。
|
|
13
|
+
- 【文档与提示精简】📘 README、向导交互文案与示例结构已全面统一为更符合直觉的精简说明。
|
|
14
|
+
|
|
15
|
+
## 验证结果
|
|
16
|
+
- `bunx vitest run extensions/wecom/src/onboarding.test.ts extensions/wecom/src/channel.meta.test.ts`
|
|
17
|
+
- `pnpm build`
|
|
@@ -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/compat-single-account.md
CHANGED
|
@@ -31,7 +31,7 @@ openclaw config set channels.wecom.bot.dm.allowFrom '["*"]'
|
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
openclaw config set channels.wecom.agent.corpId "YOUR_CORP_ID"
|
|
34
|
-
openclaw config set channels.wecom.agent.
|
|
34
|
+
openclaw config set channels.wecom.agent.agentSecret "YOUR_AGENT_SECRET"
|
|
35
35
|
openclaw config set channels.wecom.agent.agentId 1000001
|
|
36
36
|
openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
|
|
37
37
|
openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
|
|
@@ -71,7 +71,7 @@ openclaw channels status
|
|
|
71
71
|
|
|
72
72
|
"agent": {
|
|
73
73
|
"corpId": "YOUR_CORP_ID",
|
|
74
|
-
"
|
|
74
|
+
"agentSecret": "YOUR_AGENT_SECRET",
|
|
75
75
|
"agentId": 1000001,
|
|
76
76
|
"token": "YOUR_CALLBACK_TOKEN",
|
|
77
77
|
"encodingAESKey": "YOUR_CALLBACK_AES_KEY",
|
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
|
}
|
|
@@ -13,8 +13,11 @@ describe("resolveWecomAccount", () => {
|
|
|
13
13
|
"acct-a": {
|
|
14
14
|
enabled: true,
|
|
15
15
|
bot: {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
primaryTransport: "webhook",
|
|
17
|
+
webhook: {
|
|
18
|
+
token: "token-a",
|
|
19
|
+
encodingAESKey: "aes-a",
|
|
20
|
+
},
|
|
18
21
|
},
|
|
19
22
|
},
|
|
20
23
|
},
|
|
@@ -35,4 +38,38 @@ describe("resolveWecomAccount", () => {
|
|
|
35
38
|
expect(account.enabled).toBe(true);
|
|
36
39
|
expect(account.configured).toBe(true);
|
|
37
40
|
});
|
|
41
|
+
|
|
42
|
+
it("treats literal default as an alias for configured default account", () => {
|
|
43
|
+
const account = resolveWecomAccount({ cfg, accountId: "default" });
|
|
44
|
+
expect(account.accountId).toBe("acct-a");
|
|
45
|
+
expect(account.enabled).toBe(true);
|
|
46
|
+
expect(account.configured).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("accepts agentSecret for fresh configs and normalizes it for runtime use", () => {
|
|
50
|
+
const agentCfg: OpenClawConfig = {
|
|
51
|
+
channels: {
|
|
52
|
+
wecom: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
defaultAccount: "acct-agent",
|
|
55
|
+
accounts: {
|
|
56
|
+
"acct-agent": {
|
|
57
|
+
enabled: true,
|
|
58
|
+
agent: {
|
|
59
|
+
corpId: "corp-id",
|
|
60
|
+
agentSecret: "agent-secret",
|
|
61
|
+
agentId: 1000001,
|
|
62
|
+
token: "token",
|
|
63
|
+
encodingAESKey: "1234567890123456789012345678901234567890123",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
} as OpenClawConfig;
|
|
70
|
+
|
|
71
|
+
const account = resolveWecomAccount({ cfg: agentCfg });
|
|
72
|
+
expect(account.agent?.apiConfigured).toBe(true);
|
|
73
|
+
expect(account.agent?.corpSecret).toBe("agent-secret");
|
|
74
|
+
});
|
|
38
75
|
});
|
package/src/config/accounts.ts
CHANGED
|
@@ -73,14 +73,15 @@ function resolveAgentAccount(
|
|
|
73
73
|
): ResolvedAgentAccount {
|
|
74
74
|
const agentId = toNumber(config.agentId);
|
|
75
75
|
const callbackConfigured = Boolean(config.token && config.encodingAESKey);
|
|
76
|
-
const
|
|
76
|
+
const normalizedAgentSecret = config.agentSecret?.trim() || config.corpSecret?.trim() || "";
|
|
77
|
+
const apiConfigured = Boolean(config.corpId && normalizedAgentSecret && agentId);
|
|
77
78
|
return {
|
|
78
79
|
accountId,
|
|
79
80
|
configured: callbackConfigured || apiConfigured,
|
|
80
81
|
callbackConfigured,
|
|
81
82
|
apiConfigured,
|
|
82
83
|
corpId: config.corpId,
|
|
83
|
-
corpSecret:
|
|
84
|
+
corpSecret: normalizedAgentSecret,
|
|
84
85
|
agentId,
|
|
85
86
|
token: config.token,
|
|
86
87
|
encodingAESKey: config.encodingAESKey,
|
|
@@ -113,6 +114,15 @@ function toResolvedAccount(params: {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
function createMissingResolvedAccount(accountId: string): ResolvedWecomAccount {
|
|
118
|
+
return {
|
|
119
|
+
accountId,
|
|
120
|
+
enabled: false,
|
|
121
|
+
configured: false,
|
|
122
|
+
config: {},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
export function detectMode(config: WecomConfig | undefined): ResolvedMode {
|
|
117
127
|
if (!config || config.enabled === false) return "disabled";
|
|
118
128
|
if (config.accounts && Object.keys(config.accounts).length > 0) {
|
|
@@ -260,12 +270,24 @@ export function resolveWecomAccount(params: {
|
|
|
260
270
|
accountId?: string | null;
|
|
261
271
|
}): ResolvedWecomAccount {
|
|
262
272
|
const resolved = resolveWecomAccounts(params.cfg);
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
273
|
+
const explicitAccountId = params.accountId?.trim();
|
|
274
|
+
const accountId = explicitAccountId || resolved.defaultAccountId;
|
|
275
|
+
const direct = resolved.accounts[accountId];
|
|
276
|
+
if (direct) {
|
|
277
|
+
return direct;
|
|
267
278
|
}
|
|
268
|
-
|
|
279
|
+
|
|
280
|
+
// Treat the literal "default" as an alias for the configured default account.
|
|
281
|
+
// This keeps generic onboarding flows working even when the first WeCom account
|
|
282
|
+
// was created under a custom id like "haidao" instead of a literal "default".
|
|
283
|
+
if (explicitAccountId === DEFAULT_ACCOUNT_ID) {
|
|
284
|
+
const fallback = resolved.accounts[resolved.defaultAccountId];
|
|
285
|
+
if (fallback) {
|
|
286
|
+
return fallback;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return createMissingResolvedAccount(accountId);
|
|
269
291
|
}
|
|
270
292
|
|
|
271
293
|
export function isWecomEnabled(cfg: OpenClawConfig): boolean {
|
package/src/config/schema.ts
CHANGED
|
@@ -67,13 +67,18 @@ const botSchema = z
|
|
|
67
67
|
const agentSchema = z
|
|
68
68
|
.object({
|
|
69
69
|
corpId: z.string(),
|
|
70
|
-
|
|
70
|
+
agentSecret: z.string().optional(),
|
|
71
|
+
corpSecret: z.string().optional(),
|
|
71
72
|
agentId: z.union([z.number(), z.string()]).optional(),
|
|
72
73
|
token: z.string(),
|
|
73
74
|
encodingAESKey: z.string(),
|
|
74
75
|
welcomeText: z.string().optional(),
|
|
75
76
|
dm: dmSchema,
|
|
76
77
|
})
|
|
78
|
+
.refine((value) => Boolean(value.agentSecret?.trim() || value.corpSecret?.trim()), {
|
|
79
|
+
path: ["agentSecret"],
|
|
80
|
+
message: "agentSecret 不能为空",
|
|
81
|
+
})
|
|
77
82
|
.optional();
|
|
78
83
|
|
|
79
84
|
const dynamicAgentsSchema = z
|
package/src/onboarding.test.ts
CHANGED
|
@@ -168,7 +168,7 @@ describe("wecom onboarding", () => {
|
|
|
168
168
|
it("uses plugin-owned chinese account selection and no generic dm adapter", async () => {
|
|
169
169
|
const prompter = createPrompter({
|
|
170
170
|
select: vi.fn(async ({ message }: { message: string }) => {
|
|
171
|
-
if (message === "
|
|
171
|
+
if (message === "请选择企业微信接入标识(英文):") {
|
|
172
172
|
return "__new__";
|
|
173
173
|
}
|
|
174
174
|
if (message === "请选择您要配置的接入模式:") {
|
|
@@ -180,7 +180,7 @@ describe("wecom onboarding", () => {
|
|
|
180
180
|
throw new Error(`Unexpected select prompt: ${message}`);
|
|
181
181
|
}) as WizardPrompter["select"],
|
|
182
182
|
text: vi.fn(async ({ message }: { message: string }) => {
|
|
183
|
-
if (message === "
|
|
183
|
+
if (message === "请输入新的企业微信接入标识(英文):") {
|
|
184
184
|
return "HaiDao";
|
|
185
185
|
}
|
|
186
186
|
if (message === "请输入 BotId(机器人 ID):") {
|
|
@@ -213,7 +213,106 @@ describe("wecom onboarding", () => {
|
|
|
213
213
|
const noteText = (prompter.note as ReturnType<typeof vi.fn>).mock.calls
|
|
214
214
|
.map(([message]) => String(message))
|
|
215
215
|
.join("\n");
|
|
216
|
-
expect(noteText).toContain("
|
|
216
|
+
expect(noteText).toContain("接入标识已规范化为:haidao");
|
|
217
217
|
expect(wecomOnboardingAdapter.dmPolicy).toBeUndefined();
|
|
218
218
|
});
|
|
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
|
+
|
|
270
|
+
it("writes agentSecret for fresh agent onboarding", async () => {
|
|
271
|
+
const prompter = createPrompter({
|
|
272
|
+
select: vi.fn(async ({ message }: { message: string }) => {
|
|
273
|
+
if (message === "请选择您要配置的接入模式:") {
|
|
274
|
+
return "agent";
|
|
275
|
+
}
|
|
276
|
+
if (message === "请选择私聊 (DM) 访问策略:") {
|
|
277
|
+
return "open";
|
|
278
|
+
}
|
|
279
|
+
throw new Error(`Unexpected select prompt: ${message}`);
|
|
280
|
+
}) as WizardPrompter["select"],
|
|
281
|
+
text: vi.fn(async ({ message }: { message: string }) => {
|
|
282
|
+
if (message === "请输入 CorpID (企业ID):") {
|
|
283
|
+
return "corp-id";
|
|
284
|
+
}
|
|
285
|
+
if (message === "请输入 AgentID (应用ID):") {
|
|
286
|
+
return "1000001";
|
|
287
|
+
}
|
|
288
|
+
if (message === "请输入应用 Secret:") {
|
|
289
|
+
return "agent-secret";
|
|
290
|
+
}
|
|
291
|
+
if (message === "请输入 Token (回调令牌):") {
|
|
292
|
+
return "callback-token";
|
|
293
|
+
}
|
|
294
|
+
if (message === "请输入 EncodingAESKey (回调加密密钥):") {
|
|
295
|
+
return "1234567890123456789012345678901234567890123";
|
|
296
|
+
}
|
|
297
|
+
if (message === "欢迎语 (可选):") {
|
|
298
|
+
return "欢迎使用智能助手";
|
|
299
|
+
}
|
|
300
|
+
throw new Error(`Unexpected text prompt: ${message}`);
|
|
301
|
+
}) as WizardPrompter["text"],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const result = await wecomOnboardingAdapter.configure({
|
|
305
|
+
cfg: {} as OpenClawConfig,
|
|
306
|
+
runtime: createRuntime(),
|
|
307
|
+
prompter,
|
|
308
|
+
options: {},
|
|
309
|
+
accountOverrides: {},
|
|
310
|
+
shouldPromptAccountIds: false,
|
|
311
|
+
forceAllowFrom: false,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const agent = result.cfg.channels?.wecom?.accounts?.default?.agent;
|
|
315
|
+
expect(agent?.agentSecret).toBe("agent-secret");
|
|
316
|
+
expect(agent?.corpSecret).toBeUndefined();
|
|
317
|
+
});
|
|
219
318
|
});
|
package/src/onboarding.ts
CHANGED
|
@@ -249,27 +249,30 @@ 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
|
-
message: "
|
|
256
|
+
message: "请选择企业微信接入标识(英文):",
|
|
254
257
|
options: [
|
|
255
|
-
...
|
|
258
|
+
...selectableIds.map((id) => ({
|
|
256
259
|
value: id,
|
|
257
|
-
label: id === DEFAULT_ACCOUNT_ID ? "default
|
|
260
|
+
label: id === DEFAULT_ACCOUNT_ID ? "default(默认标识)" : id,
|
|
258
261
|
})),
|
|
259
|
-
{ value: "__new__", label: "
|
|
262
|
+
{ value: "__new__", label: "新增接入标识" },
|
|
260
263
|
],
|
|
261
264
|
initialValue: accountId,
|
|
262
265
|
});
|
|
263
266
|
if (choice === "__new__") {
|
|
264
267
|
const entered = await params.prompter.text({
|
|
265
|
-
message: "
|
|
266
|
-
validate: (value: string | undefined) => (value?.trim() ? undefined : "
|
|
268
|
+
message: "请输入新的企业微信接入标识(英文):",
|
|
269
|
+
validate: (value: string | undefined) => (value?.trim() ? undefined : "接入标识不能为空"),
|
|
267
270
|
});
|
|
268
271
|
const normalized = normalizeAccountId(String(entered));
|
|
269
272
|
if (String(entered).trim() !== normalized) {
|
|
270
273
|
await params.prompter.note(
|
|
271
|
-
|
|
272
|
-
"
|
|
274
|
+
`接入标识已规范化为:${normalized}`,
|
|
275
|
+
"企业微信接入标识",
|
|
273
276
|
);
|
|
274
277
|
}
|
|
275
278
|
accountId = normalized;
|
|
@@ -325,7 +328,7 @@ async function promptMode(prompter: WizardPrompter): Promise<WecomMode> {
|
|
|
325
328
|
hint: "Bot 默认 WS 易上手,Agent 负责应用回调、主动推送和媒体发送",
|
|
326
329
|
},
|
|
327
330
|
],
|
|
328
|
-
initialValue: "
|
|
331
|
+
initialValue: "bot",
|
|
329
332
|
});
|
|
330
333
|
return choice as WecomMode;
|
|
331
334
|
}
|
|
@@ -433,10 +436,10 @@ async function configureAgentMode(
|
|
|
433
436
|
).trim();
|
|
434
437
|
const agentId = Number(agentIdStr);
|
|
435
438
|
|
|
436
|
-
const
|
|
439
|
+
const agentSecret = String(
|
|
437
440
|
await prompter.text({
|
|
438
|
-
message: "
|
|
439
|
-
validate: (value: string | undefined) => (value?.trim() ? undefined : "Secret 不能为空"),
|
|
441
|
+
message: "请输入应用 Secret:",
|
|
442
|
+
validate: (value: string | undefined) => (value?.trim() ? undefined : "应用 Secret 不能为空"),
|
|
440
443
|
}),
|
|
441
444
|
).trim();
|
|
442
445
|
|
|
@@ -478,7 +481,7 @@ async function configureAgentMode(
|
|
|
478
481
|
|
|
479
482
|
const agentConfig: WecomAgentConfig = {
|
|
480
483
|
corpId,
|
|
481
|
-
|
|
484
|
+
agentSecret,
|
|
482
485
|
agentId,
|
|
483
486
|
token,
|
|
484
487
|
encodingAESKey,
|
|
@@ -506,7 +509,7 @@ async function promptDmPolicy(
|
|
|
506
509
|
{ value: "open", label: "开放模式", hint: "任何人可发起" },
|
|
507
510
|
{ value: "disabled", label: "禁用私聊", hint: "不接受私聊消息" },
|
|
508
511
|
],
|
|
509
|
-
initialValue: "
|
|
512
|
+
initialValue: "open",
|
|
510
513
|
});
|
|
511
514
|
|
|
512
515
|
const policy = policyChoice as "pairing" | "allowlist" | "open" | "disabled";
|
|
@@ -560,7 +563,7 @@ async function showSummary(cfg: OpenClawConfig, prompter: WizardPrompter, accoun
|
|
|
560
563
|
lines.push(" 出站能力: Agent API(主动发送 / 补送 / 媒体)");
|
|
561
564
|
}
|
|
562
565
|
|
|
563
|
-
lines.push(`
|
|
566
|
+
lines.push(` 接入标识: ${accountId}`);
|
|
564
567
|
lines.push(" 运维检查: openclaw channels status --deep");
|
|
565
568
|
lines.push(" 关键日志: [wecom-runtime] [wecom-ws] [wecom-http] [wecom-agent-delivery]");
|
|
566
569
|
|