@yanhaidao/wecom 2.3.10 → 2.3.13
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 +66 -23
- package/changelog/v2.3.11.md +19 -0
- package/changelog/v2.3.12.md +25 -0
- package/changelog/v2.3.13.md +19 -0
- package/package.json +2 -2
- package/src/agent/handler.ts +25 -26
- package/src/app/account-runtime.ts +5 -4
- package/src/app/index.ts +19 -0
- package/src/dynamic-agent.ts +2 -1
- 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/shared/media-service.ts +20 -0
- package/src/target.ts +8 -3
- package/src/transport/bot-ws/inbound.test.ts +50 -0
- package/src/transport/bot-ws/inbound.ts +10 -20
- package/src/transport/bot-ws/reply.test.ts +184 -0
- package/src/transport/bot-ws/reply.ts +112 -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,57 @@
|
|
|
95
96
|
|
|
96
97
|
---
|
|
97
98
|
|
|
99
|
+
<a id="sec-changelog"></a>
|
|
100
|
+
|
|
101
|
+
## 📋 最近更新
|
|
102
|
+
|
|
103
|
+
> 项目保持高频迭代,核心改进一览:
|
|
104
|
+
|
|
105
|
+
#### v2.3.13(2026-03-13)
|
|
106
|
+
|
|
107
|
+
- 🛠 **[重要修复]** `Bot WS` 现在会把“引用 + 提问”中的引用内容一起带入 Agent 上下文,不再只保留用户当前这句提问。
|
|
108
|
+
- 🌊 **[重要修复]** `Bot WS` 流式回复改为按“累计全文刷新”发送,修复企业微信客户端里长回答断断续续、像被拆成多段的问题。
|
|
109
|
+
- 🧩 `Bot WS` 这次显式对齐企业微信 `stream.id` 的刷新语义:后续更新会覆盖为当前完整内容,而不是只发送最新增量片段。
|
|
110
|
+
- ✅ 新增 `bot-ws` 引用上下文与累计流式发送回归测试,避免后续重构回退。
|
|
111
|
+
|
|
112
|
+
#### v2.3.12(2026-03-12)
|
|
113
|
+
|
|
114
|
+
- 🛠 **[重要修复]** Bot WS 流式回复超 6 分钟后的 `846608 stream message update expired` 现在被识别为终态错误,不再导致进程退出。
|
|
115
|
+
- 🛠 **[重要修复]** SDK 5 秒回执超时 (`Reply ack timeout`) 也被识别为终态错误,超时后立即停止占位保活,不再产生 `unhandledRejection`。
|
|
116
|
+
- 🚀 Bot WS 模式下主动文本消息优先走 WS 长连接;Agent 仅兜底文件/媒体或未启用 WS 的场景。
|
|
117
|
+
- 🧯 `sdk-adapter` 为 WebSocket frame 异步处理补上显式兜底捕获,漏网异常记录为 runtime issue 而非崩溃。
|
|
118
|
+
- ⏱ 回复窗口过期时占位符保活立即停止。
|
|
119
|
+
- 🛠 **[重要修复]** Bot WS 模式下接收图片/文件现在使用消息体独立 `aeskey` 解密,修复之前保存密文导致 `Failed to optimize image` 的问题。
|
|
120
|
+
- 🛠 **[重要修复]** 解决 Agent 模式下纯数字 UserID 被误判为部门 ID 导致的 81013 错误。在 `wecom-agent:` 作用域下,纯数字目标现在优先解析为用户。
|
|
121
|
+
|
|
122
|
+
#### v2.3.11(2026-03-11)
|
|
123
|
+
|
|
124
|
+
- `Bot WS` 升级为即时占位 + 持续保活,降低长思考时的 `invalid req_id`。
|
|
125
|
+
- `streamPlaceholderContent` 统一作用于 `Bot WS` 与 `Bot Webhook`。
|
|
126
|
+
- onboarding 在空配置下也会提供 `default` 账号选项。
|
|
127
|
+
|
|
128
|
+
#### v2.3.10(2026-03-10)
|
|
129
|
+
|
|
130
|
+
- onboarding 默认收敛为 `Bot + WS + 开放私聊`。
|
|
131
|
+
- 修复 `Bot WS` 长文本双重回复问题。
|
|
132
|
+
- Agent 新配置统一使用 `agentSecret`。
|
|
133
|
+
|
|
134
|
+
<details>
|
|
135
|
+
<summary>更早版本</summary>
|
|
136
|
+
|
|
137
|
+
#### v2.3.9(2026-03-09)
|
|
138
|
+
|
|
139
|
+
- Bot 默认接入改为 `WebSocket`,无需域名更易上手。
|
|
140
|
+
- 完善中文 onboarding,减少重复提示。
|
|
141
|
+
- 恢复 `Bot WS` 流式输出能力。
|
|
142
|
+
- 增强 Agent 回调与发送日志,排障更直接。
|
|
143
|
+
|
|
144
|
+
</details>
|
|
145
|
+
|
|
146
|
+
详细版本记录见 `changelog/` 目录。
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
98
150
|
<a id="sec-3"></a>
|
|
99
151
|
## 一、🚀 快速开始
|
|
100
152
|
|
|
@@ -194,6 +246,8 @@ openclaw channels add
|
|
|
194
246
|
说明:
|
|
195
247
|
- 新配置推荐使用 `agent.agentSecret`
|
|
196
248
|
- 历史配置里的 `agent.corpSecret` 仍兼容读取,但后续文档统一使用 `agentSecret`
|
|
249
|
+
- `bot.streamPlaceholderContent` 会同时作用于 `Bot WS` 与 `Bot Webhook`;在 `Bot WS` 下,收到用户消息后会立即显示该占位符,并在长思考期间持续保活。
|
|
250
|
+
- 如果你在一个 OpenClaw 实例里挂载多个 `accounts`,并让它们共同路由到同一个静态 Agent,建议在全局配置里加上 `session.dmScope = "per-account-channel-peer"`,让私聊 session key 显式带上 `accountId`。
|
|
197
251
|
|
|
198
252
|
### 1.3 高级网络配置(公网出口代理)
|
|
199
253
|
如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,首先使用Bot模式的ws接入方式。
|
|
@@ -227,6 +281,10 @@ openclaw channels status --deep
|
|
|
227
281
|
最简的流式交互版(无兜底分发功能)。
|
|
228
282
|
关键约束:`bot.primaryTransport = "ws"` 必须包含 `bot.ws` 参数。
|
|
229
283
|
|
|
284
|
+
行为说明:
|
|
285
|
+
- 收到用户消息后立即发送一次 `streamPlaceholderContent` 占位流。
|
|
286
|
+
- 若模型首个真实文本块尚未产出,系统会持续发送保活占位,避免长思考期间 `req_id` 失效。
|
|
287
|
+
|
|
230
288
|
```jsonc
|
|
231
289
|
{
|
|
232
290
|
"accounts": {
|
|
@@ -279,6 +337,8 @@ openclaw channels status --deep
|
|
|
279
337
|
|
|
280
338
|
> **🌟 多账号矩阵下的全局生效与物理隔离**
|
|
281
339
|
> `dynamicAgents` 属于通道级的全局开关,开启后会对配置的所有账号(`accounts`)生效。为了维持账号的绝对隔离,生成的隔离 Agent ID 内置了 Account 维度(例如:`wecom-ops-dm-张三` vs `wecom-sales-dm-张三`),保证跨企业应用依旧安全隔绝。
|
|
340
|
+
>
|
|
341
|
+
> 如果你没有开启 `dynamicAgents`,但多个 `accountId` 共享同一个静态 Agent,请在 OpenClaw 全局配置里显式设置 `session.dmScope = "per-account-channel-peer"`,确保不同账号的私聊上下文不会被收敛到同一个 session key。
|
|
282
342
|
|
|
283
343
|
---
|
|
284
344
|
|
|
@@ -370,7 +430,7 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
370
430
|
|
|
371
431
|
### 5.1 运行约束原则
|
|
372
432
|
- **协议单工限制**:同一账号下,Bot 只能选择一个主传输协议 `primaryTransport` (`ws` 或 `webhook`) 运作。
|
|
373
|
-
- **帧边界不可打破**:Bot WS 是基于官方微信内部通信协议的扩展,它必须携带并原路奉还对应的 `req_id
|
|
433
|
+
- **帧边界不可打破**:Bot WS 是基于官方微信内部通信协议的扩展,它必须携带并原路奉还对应的 `req_id`。插件会在长思考期间自动发送占位/保活帧来维持该回复窗口,但标准化事件不会替代原始数据帧,业务流始终可访问该原始微信底层框架。
|
|
374
434
|
- **媒体沙盒边界**:不论是 `Webhook`,还是 `WS`,涉及企微媒体加解密的处理绝不再跨界干预业务执行层。由内部服务自动在 Transport / Media Service 网关边界卸载 `aeskey` 解密并转换为统一 OpenClaw 媒体类抛出。
|
|
375
435
|
|
|
376
436
|
### 5.2 企业微信群聊交付规则
|
|
@@ -422,32 +482,15 @@ openclaw channels status --deep
|
|
|
422
482
|
**Q5: 为什么发视频给 Bot 没反应?**
|
|
423
483
|
> **A:** 官方 Bot 接口**不支持接收视频**。如果您需要处理视频内容,必须配置 Agent 小微应用,由于 Agent 下行具备富媒体流承接功能,本插件会自动从底层拦截将其解码并传输给底层大模型看。
|
|
424
484
|
|
|
485
|
+
**Q6: 支持个人微信吗?**
|
|
486
|
+
> **A:** 支持企业微信场景下的“微信插件入口”(个人微信扫码进入企业应用对话),这不等同于“个人微信网页版协议”。您可以在个人微信中直接与企业号/应用对话,无需打开企业微信 App。
|
|
487
|
+
|
|
425
488
|
---
|
|
426
489
|
|
|
427
490
|
<a id="sec-9"></a>
|
|
428
491
|
|
|
429
492
|
## 七、📮 联系我 与 版本协议
|
|
430
493
|
|
|
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
494
|
微信交流群(扫码入群):
|
|
452
495
|
|
|
453
496
|

|
|
@@ -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,25 @@
|
|
|
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
|
+
- 【UserID 纯数字解析修复】🛠 **[重要修复]** 解决 Agent 模式下纯数字 UserID 被误判为部门 ID 导致的图片发送失败(81013 错误)。在 `wecom-agent:` 作用域下,纯数字目标现在优先解析为用户。
|
|
11
|
+
- 【未处理拒绝隔离】🧯 `sdk-adapter` 为每个 WebSocket frame 的异步处理补上显式兜底捕获;即使后续再出现漏网异常,也会记录到 runtime issue,而不是变成 `unhandledRejection` 直接带崩 OpenClaw。
|
|
12
|
+
- 【占位保活收敛】⏱ 当回复窗口已经过期时,WS 占位符保活会立即停止,避免过期后继续发送流式更新。
|
|
13
|
+
- 【Ack 超时兜底】⏱ SDK 5 秒回执超时 (`Reply ack timeout`) 现在也被识别为终态错误,超时后立即停止占位保活并走 `onFail` 回调,不再产生 `unhandledRejection`。
|
|
14
|
+
- 【回归测试补齐】✅ 新增针对 `846608` 过期更新和 `frame handler` reject 的回归测试,确保此类异常保持非致命。
|
|
15
|
+
- 【Bot WS 图片/文件解密修复】🛠 **[重要修复]** `Bot WS` 模式下接收到的图片和文件现在会使用消息体中的独立 `aeskey` 进行 AES-256-CBC 解密,修复之前直接保存密文导致 `Failed to optimize image` 的问题。`media-service.ts` 新增 `downloadEncryptedMedia()` 方法,`normalizeFirstAttachment()` 自动检测 `aesKey` 并走解密路径。
|
|
16
|
+
|
|
17
|
+
## 验证结果
|
|
18
|
+
- `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`
|
|
19
|
+
- `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`
|
|
20
|
+
- `pnpm exec tsc -p extensions/wecom/tsconfig.json --noEmit`
|
|
21
|
+
|
|
22
|
+
## 升级提示
|
|
23
|
+
- 无需新增配置,升级到 `v2.3.12` 后自动生效。
|
|
24
|
+
- 如果账号正在使用 `Bot WS`,主动文本消息会优先走 WS 长连接;只有文件/媒体或未启用 WS 的场景才会继续使用 Agent。
|
|
25
|
+
- 如果模型或工具链路存在超长执行,超过企业微信 6 分钟回复窗口时,OpenClaw 现在会保持存活并记录运行时错误,不再因该异常退出。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.13 变更简报
|
|
2
|
+
|
|
3
|
+
> [!TIP]
|
|
4
|
+
> **Bot WS 引用上下文与流式展示修复版本**:`v2.3.13` 重点补齐企业微信 `Bot WS` 对引用消息的上下文注入,并修复长回答在客户端里显示为“断断续续碎片”的问题。
|
|
5
|
+
|
|
6
|
+
## 2026-03-13(v2.3.13)
|
|
7
|
+
- 【引用上下文补齐】🛠 **[重要修复]** `Bot WS` 现在会复用与 `Bot Webhook` 一致的入站正文拼装逻辑。用户通过“引用 + 提问”触发机器人时,引用内容会一并进入 Agent 上下文,不再只保留当前这句提问。
|
|
8
|
+
- 【WS 流式刷新修复】🌊 **[重要修复]** `Bot WS replyStream` 现在按“累计全文刷新”发送,而不是只发送最新增量片段,修复企业微信客户端中长回答显示断断续续、像被拆成多块的问题。
|
|
9
|
+
- 【协议语义对齐】🧩 这次调整显式对齐企业微信 `stream.id` 的刷新语义:同一条流式消息的后续更新会覆盖为“当前完整内容”,从而保持最终展示稳定、可连续阅读。
|
|
10
|
+
- 【回归测试补齐】✅ 新增 `bot-ws` 引用上下文与累计流式发送测试,避免后续重构再次把引用消息或流式展示打回退。
|
|
11
|
+
|
|
12
|
+
## 验证结果
|
|
13
|
+
- `pnpm exec vitest run --config extensions/wecom/vitest.config.ts extensions/wecom/src/transport/bot-ws/inbound.test.ts extensions/wecom/src/transport/bot-ws/reply.test.ts extensions/wecom/src/transport/bot-ws/sdk-adapter.test.ts`
|
|
14
|
+
- `pnpm exec tsc -p extensions/wecom/tsconfig.json --noEmit`
|
|
15
|
+
|
|
16
|
+
## 升级提示
|
|
17
|
+
- 无需新增配置,升级到 `v2.3.13` 后自动生效。
|
|
18
|
+
- 如果企业微信 `WS` 推送里的 `quote.text.content` 仍然是 `[该消息类型暂不能展示]`,说明上游没有下发真实引用原文;OpenClaw 会把该占位文本带入上下文,但无法恢复企业微信未提供的原始内容。
|
|
19
|
+
- 如果你之前观察到长回复在企业微信里呈现为“分段断裂”或“后段覆盖前段”,升级后会改为同一条流式消息持续刷新完整内容。
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.13",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket
|
|
5
|
+
"description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持加密媒体解密、Agent 主动发消息与多账号接入",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/YanHaidao/wecom.git"
|
package/src/agent/handler.ts
CHANGED
|
@@ -566,32 +566,31 @@ async function processAgentMessage(params: {
|
|
|
566
566
|
}
|
|
567
567
|
return;
|
|
568
568
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
});
|
|
569
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
570
|
+
Body: body,
|
|
571
|
+
RawBody: finalContent,
|
|
572
|
+
CommandBody: finalContent,
|
|
573
|
+
Attachments: attachments.length > 0 ? attachments : undefined,
|
|
574
|
+
From: isGroup ? `wecom:group:${peerId}` : `wecom:user:${fromUser}`,
|
|
575
|
+
To: `wecom:user:${peerId}`,
|
|
576
|
+
SessionKey: route.sessionKey,
|
|
577
|
+
AccountId: route.accountId,
|
|
578
|
+
ChatType: isGroup ? "group" : "direct",
|
|
579
|
+
ConversationLabel: fromLabel,
|
|
580
|
+
SenderName: fromUser,
|
|
581
|
+
SenderId: fromUser,
|
|
582
|
+
Provider: "wecom",
|
|
583
|
+
Surface: "webchat",
|
|
584
|
+
OriginatingChannel: "wecom",
|
|
585
|
+
// 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
|
|
586
|
+
// - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
|
|
587
|
+
// - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
|
|
588
|
+
OriginatingTo: buildAgentSessionTarget(fromUser, agent.accountId),
|
|
589
|
+
CommandAuthorized: authz.commandAuthorized ?? true,
|
|
590
|
+
MediaPath: mediaPath,
|
|
591
|
+
MediaType: mediaType,
|
|
592
|
+
MediaUrl: mediaPath,
|
|
593
|
+
});
|
|
595
594
|
|
|
596
595
|
// 记录会话
|
|
597
596
|
await core.channel.session.recordInboundSession({
|
|
@@ -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/dynamic-agent.ts
CHANGED
|
@@ -51,7 +51,8 @@ export function generateAgentId(chatType: "dm" | "group", peerId: string, accoun
|
|
|
51
51
|
export function buildAgentSessionTarget(userId: string, accountId?: string): string {
|
|
52
52
|
const normalizedUserId = String(userId).trim();
|
|
53
53
|
const sanitizedAccountId = sanitizeDynamicIdPart(accountId ?? "default") || "default";
|
|
54
|
-
|
|
54
|
+
// Always use explicit user: prefix to avoid ambiguity with numeric party IDs
|
|
55
|
+
return `wecom-agent:${sanitizedAccountId}:user:${normalizedUserId}`;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/**
|
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");
|