@yanhaidao/wecom 2.2.3 → 2.2.4
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/GEMINI.md +76 -0
- package/README.md +159 -43
- package/assets/03.agent.page.png +0 -0
- package/assets/03.bot.page.png +0 -0
- package/index.ts +8 -0
- package/package.json +6 -2
- package/src/agent/api-client.ts +87 -25
- package/src/agent/handler.ts +105 -11
- package/src/channel.ts +17 -0
- package/src/config/accounts.ts +5 -3
- package/src/config/index.ts +1 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +50 -5
- package/src/config-schema.ts +2 -0
- package/src/crypto.ts +43 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +15 -9
- package/src/media.ts +28 -12
- package/src/monitor/state.ts +354 -0
- package/src/monitor/types.ts +128 -0
- package/src/monitor.active.test.ts +90 -7
- package/src/monitor.integration.test.ts +15 -5
- package/src/monitor.ts +853 -163
- package/src/onboarding.ts +3 -3
- package/src/outbound.test.ts +39 -17
- package/src/outbound.ts +68 -42
- package/src/shared/xml-parser.ts +8 -0
- package/src/target.ts +80 -0
- package/src/types/account.ts +5 -1
- package/src/types/config.ts +7 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/message.ts +41 -0
package/GEMINI.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# 企业微信 (WeCom) 插件上下文
|
|
2
|
+
|
|
3
|
+
## 平台架构:机器人 (Bot) vs 自建应用 (Agent)
|
|
4
|
+
|
|
5
|
+
本插件采用 **双模 (Dual-Mode)** 架构,结合了企业微信“智能机器人”和“自建应用”的优势。
|
|
6
|
+
|
|
7
|
+
### 1. 定义与边界
|
|
8
|
+
|
|
9
|
+
| 特性 | **Bot (智能机器人)** | **Agent (自建应用)** |
|
|
10
|
+
| :--- | :--- | :--- |
|
|
11
|
+
| **身份** | 虚拟用户/助手。 | 工作台中的服务应用。 |
|
|
12
|
+
| **位置** | 存在于 **会话** 中 (单聊或群聊)。 | 存在于 **工作台** 或应用列表。 |
|
|
13
|
+
| **协议** | **JSON** (加密)。 | **XML** (加密)。 |
|
|
14
|
+
| **交互** | 对话式 (回复用户)。 | 事务式 (通知用户/系统)。 |
|
|
15
|
+
| **流式 (Stream)** | ✅ **支持** (打字机效果)。 | ❌ 不支持。 |
|
|
16
|
+
| **文件能力** | ❌ **受限** (被动回复无法发文件)。 | ✅ **完整支持** (API 发送视频、文件、图片)。 |
|
|
17
|
+
| **群聊支持** | ✅ 原生支持 (可被 @)。 | ⚠️ 受限 (仅能在自建群或通过 API 推送)。 |
|
|
18
|
+
|
|
19
|
+
### 2. 通信协议
|
|
20
|
+
|
|
21
|
+
#### A. Bot (智能机器人)
|
|
22
|
+
* **接收 (回调)**:
|
|
23
|
+
* **格式**: JSON。
|
|
24
|
+
* **单聊**: `chattype: "single"`. 无 `chatid`。`cid` = `from.userid`。
|
|
25
|
+
* **群聊**: `chattype: "group"`. 有 `chatid`。`cid` = `chatid`。
|
|
26
|
+
* **发送 (回复)**:
|
|
27
|
+
* **交互回复**: 使用回调中的 `response_url`。支持 `markdown`, `template_card`。**不支持文件/视频**。
|
|
28
|
+
* **主动推送**: 使用 Webhook Key。
|
|
29
|
+
|
|
30
|
+
#### B. Agent (自建应用)
|
|
31
|
+
* **接收 (回调)**:
|
|
32
|
+
* **格式**: XML。
|
|
33
|
+
* **结构**: `<ToUserName>`, `<FromUserName>`, `<MsgType>`, `<Content>`。
|
|
34
|
+
* **发送 (推送)**:
|
|
35
|
+
* **API**: `message/send`。
|
|
36
|
+
* **能力**: 支持所有媒体类型 (文件, 视频, 语音, 文本, 卡片)。
|
|
37
|
+
* **对象**: 用户 (`touser`), 部门 (`toparty`), 标签 (`totag`)。
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 核心策略:机器人优先,智能体兜底 (接近 6 分钟切换)
|
|
42
|
+
|
|
43
|
+
### 目标
|
|
44
|
+
* **默认**: 用户在 Bot 会话触发,结果在 Bot 会话内完成回复(含流式)。
|
|
45
|
+
* **兜底**: 当 Bot 因超时(接近 6 分钟限制)、异常或流式窗口结束无法完成交付时,若配置了 Agent,则由 Agent 私信触发用户发送最终结果。
|
|
46
|
+
|
|
47
|
+
### 关键约束
|
|
48
|
+
* **6 分钟窗口**: 企业微信智能机器人流式链路最多维持 6 分钟。超过后连接断开。
|
|
49
|
+
|
|
50
|
+
### 流程详解
|
|
51
|
+
|
|
52
|
+
#### 1. 启动阶段 (Bot)
|
|
53
|
+
1. **接收消息**: 解析 JSON 回调。
|
|
54
|
+
2. **生成 StreamID**: 确定性生成 `streamId = hash(accountId + aibotid + msgid)`,防止并发重试导致重复创建流。
|
|
55
|
+
3. **快速响应**: 立即返回“已接收,处理中”或流式首包,建立连接。
|
|
56
|
+
|
|
57
|
+
#### 2. 执行阶段 (Worker)
|
|
58
|
+
4. **异步处理**: 任务入队执行。
|
|
59
|
+
5. **流式刷新**: 当收到企微的“流式消息刷新”回调时,根据 `streamId` 返回最新生成的内容。
|
|
60
|
+
|
|
61
|
+
#### 3. 正常交付 (Bot)
|
|
62
|
+
6. **完成**: 任务在 6 分钟内完成,发送 `finish=true` 信号结束流式消息。
|
|
63
|
+
|
|
64
|
+
#### 4. 异常切换 (Agent 兜底)
|
|
65
|
+
7. **触发条件**:
|
|
66
|
+
* **超时临界**: `now >= createdAt + 6min - 安全阈值(30s)`。
|
|
67
|
+
* **链路中断**: Bot 刷新回调长时间未到达 (如 > 60s)。
|
|
68
|
+
* **发送异常**: Bot 接口报错。
|
|
69
|
+
8. **切换动作**:
|
|
70
|
+
* **状态标记**: 将任务标记为 `agent_fallback`。
|
|
71
|
+
* **用户提示**: (可选) Bot 在群内尝试发送“结果将私信送达”。
|
|
72
|
+
* **私信推送**: Agent 调用 `message/send` 接口,将最终结果(文本/文件)推送给触发者 `userId`。
|
|
73
|
+
* **注意**: 即使原会话是群聊,兜底结果通常通过 Agent **私信** 发送给触发者,因为 Agent 难以直接向普通群推送消息。
|
|
74
|
+
|
|
75
|
+
### 总结
|
|
76
|
+
该策略确保了用户体验(首选流式)和交付可靠性(超时/发文件走 Agent)的平衡。
|
package/README.md
CHANGED
|
@@ -14,21 +14,35 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## 💡 核心价值:为什么选择本插件?
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
### 🏗 独创架构:Bot + Agent 双模融合
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
| 🔥 **双模式并行** | ✅ **完美支持** | ❌ 仅支持单模式 | 同时使用 Bot 的便捷与 Agent 的强大能力 |
|
|
24
|
-
| ⚡ **原生流式回复** | ✅ **Bot/Agent 全支持** | ❌ 伪流式/不支持 | 真实打字机效果,告别长时间转圈等待 |
|
|
25
|
-
| 📡 **主动消息推送** | ✅ **支持** | ❌ 仅被动回复 | 可随时通过 API 发送消息,脱离回调限制 |
|
|
26
|
-
| 🔐 **双协议加密** | ✅ **JSON + XML** | ⚠️ 部分支持 | 完整兼容企微新旧两种加密标准,安全无忧 |
|
|
27
|
-
| 📎 **全媒体处理** | ✅ **图片/语音/文件/视频** | ⚠️ 仅文本/图片 | 自动解密下载媒体文件,语音自动转文字 |
|
|
28
|
-
| 🎴 **交互式卡片** | ✅ **Template Card** | ❌ 不支持 | 支持按钮交互回调,打造复杂业务流 |
|
|
29
|
-
| 🔄 **Token 自运维** | ✅ **自动缓存刷新** | ❌ 需手工处理 | 内置 AccessToken 管理器,故障自动重试 |
|
|
21
|
+
传统的企微插件通常只能在 "只能聊天的机器人 (Bot)" 和 "只能推送的自建应用 (Agent)" 之间二选一。
|
|
22
|
+
本插件采用 **双模并行架构**,同时压榨两种模式的极限能力:
|
|
30
23
|
|
|
24
|
+
* **Bot 通道 (智能体)**:负责 **实时对话**。提供毫秒级流式响应(打字机效果),零延迟交互。
|
|
25
|
+
* **Agent 通道 (自建应用)**:负责 **能力兜底**。当需要发送图片/文件、进行全员广播、或 Bot 对话超时(>6分钟)时,无缝切换到 Agent 通道接管。
|
|
31
26
|
|
|
27
|
+
### 🧩 功能特性全景
|
|
28
|
+
|
|
29
|
+
#### 1. 🗣 **沉浸式交互 (Immersive Interaction)**
|
|
30
|
+
* **原生流式 (Stream)**:基于 HTTP 分块传输,拒绝 "转圈等待",体验如 ChatGPT 网页版般丝滑。
|
|
31
|
+
* **交互式卡片 (Card)**:支持 Button/Menu 交互回传,可构建审批、查询等复杂业务流 (Agent模式)。
|
|
32
|
+
|
|
33
|
+
#### 2. 📎 **全模态支持 (Multi-Modal)**
|
|
34
|
+
* **发什么都能看**:支持接收图片、文件 (PDF/Doc/Zip)、语音 (自动转文字)、视频。
|
|
35
|
+
* **要什么都能给**:AI 生成的图表、代码文件、语音回复,均可自动上传并推送到企微。
|
|
36
|
+
|
|
37
|
+
#### 3. 📢 **企业级触达 (Enterprise Reach)**
|
|
38
|
+
* **精准广播**:支持向 **部门 (Party)**、**标签 (Tag)** 或 **外部群** 批量推送消息。
|
|
39
|
+
* **Cronjob 集成**:通过简单的 JSON 配置实现早报推送、日报提醒、服务器报警。
|
|
40
|
+
|
|
41
|
+
#### 4. 🛡 **生产级稳定 (Production Ready)**
|
|
42
|
+
* **容灾切换**:Bot 模式 6 分钟超时自动熔断,切换 Agent 私信送达,防止长任务回答丢失。
|
|
43
|
+
* **Token 自动运维**:内置 AccessToken 守护进程,自动缓存、提前刷新、过期重试。
|
|
44
|
+
|
|
45
|
+
---
|
|
32
46
|
|
|
33
47
|
<div align="center">
|
|
34
48
|
<img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg" width="45%" />
|
|
@@ -37,26 +51,16 @@
|
|
|
37
51
|
|
|
38
52
|
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
## 模式对比
|
|
43
|
-
|
|
44
|
-
### Bot vs Agent 你该选哪个?
|
|
54
|
+
## 📊 模式能力对比
|
|
45
55
|
|
|
46
|
-
|
|
|
47
|
-
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
| **被动回复图片** | ✅ 已实现 | ✅ 已实现 |
|
|
55
|
-
| **Outbound 发图片** | ❌ API 不支持 | ✅ 已实现 |
|
|
56
|
-
| **Outbound 发文本** | ❌ API 不支持 | ✅ 已实现 |
|
|
57
|
-
| **适用场景** | 快速体验、轻量对话 | 企业级部署、业务集成 |
|
|
58
|
-
|
|
59
|
-
> 💡 **推荐配置**:两种模式可同时启用!Bot 用于日常快速对话,Agent 用于主动通知和媒体发送。
|
|
56
|
+
| 能力维度 | 🤖 Bot 模式 | 🧩 Agent 模式 | ✨ **本插件 (双模)** |
|
|
57
|
+
|:---|:---:|:---:|:---:|
|
|
58
|
+
| **流式响应** | ✅ 原生支持 | ❌ 不支持 | **✅ 完美支持** |
|
|
59
|
+
| **发送文件/图** | ❌ 不支持 | ✅ 支持 | **✅ 自动切换** |
|
|
60
|
+
| **主动推送** | ❌ 仅回调 | ✅ 随时推送 | **✅ 完整 API** |
|
|
61
|
+
| **Cronjob 定时** | ❌ 仅回调 | ✅ 支持 | **✅ 完美集成** |
|
|
62
|
+
| **接收语音** | ✅ 转文字 | ✅ 语音+文字 | **✅ 双路处理** |
|
|
63
|
+
| **群聊支持** | ✅ @即回 | ⚠️ 仅自建群 | **✅ 混合支持** |
|
|
60
64
|
|
|
61
65
|
---
|
|
62
66
|
|
|
@@ -84,8 +88,9 @@ openclaw config set channels.wecom.bot.encodingAESKey "YOUR_BOT_AES_KEY"
|
|
|
84
88
|
openclaw config set channels.wecom.bot.receiveId ""
|
|
85
89
|
openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
|
|
86
90
|
openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
|
|
91
|
+
|
|
87
92
|
# 不配置表示所有人可用,配置则进入白名单模式
|
|
88
|
-
openclaw config set channels.wecom.bot.dm.allowFrom '[
|
|
93
|
+
openclaw config set channels.wecom.bot.dm.allowFrom '[]'
|
|
89
94
|
```
|
|
90
95
|
|
|
91
96
|
### 3. 配置 Agent 模式(自建应用,可选)
|
|
@@ -98,10 +103,19 @@ openclaw config set channels.wecom.agent.agentId 1000001
|
|
|
98
103
|
openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
|
|
99
104
|
openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
|
|
100
105
|
openclaw config set channels.wecom.agent.welcomeText "欢迎使用智能助手"
|
|
101
|
-
|
|
106
|
+
# 不配置表示所有人可用,配置则进入白名单模式
|
|
107
|
+
openclaw config set channels.wecom.agent.dm.allowFrom '[]'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 4. 高级网络配置 (公网出口代理)
|
|
111
|
+
如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,企业微信 API 会因 IP 变动报错 `60020 not allow to access from your ip`。
|
|
112
|
+
此时需配置一个**固定 IP 的正向代理** (如 Squid),让插件通过该代理访问企微 API。
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
|
|
102
116
|
```
|
|
103
117
|
|
|
104
|
-
###
|
|
118
|
+
### 5. 验证
|
|
105
119
|
|
|
106
120
|
```bash
|
|
107
121
|
openclaw gateway restart
|
|
@@ -139,6 +153,11 @@ openclaw channels status
|
|
|
139
153
|
"encodingAESKey": "YOUR_CALLBACK_AES_KEY",
|
|
140
154
|
"welcomeText": "欢迎使用智能助手",
|
|
141
155
|
"dm": { "allowFrom": [] }
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
// 网络配置(可选)
|
|
159
|
+
"network": {
|
|
160
|
+
"egressProxyUrl": "http://proxy.company.local:3128"
|
|
142
161
|
}
|
|
143
162
|
}
|
|
144
163
|
}
|
|
@@ -182,10 +201,16 @@ openclaw channels status
|
|
|
182
201
|
2. 进入「应用管理」→「自建」→ 创建应用
|
|
183
202
|
3. 获取 AgentId、CorpId、Secret
|
|
184
203
|
4. **重要:** 进入「企业可信IP」→「配置」→ 添加你服务器的 IP 地址
|
|
204
|
+
- 如果你使用内网穿透/动态 IP,建议配置 `channels.wecom.network.egressProxyUrl` 走固定出口代理,否则可能出现:`60020 not allow to access from your ip`
|
|
185
205
|
5. 在应用详情中设置「接收消息 - 设置API接收」
|
|
186
206
|
6. 填写回调 URL:`https://your-domain.com/wecom/agent`
|
|
187
207
|
7. 记录回调 Token 和 EncodingAESKey
|
|
188
208
|
|
|
209
|
+
<div align="center">
|
|
210
|
+
<img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/03.bot.page.png" width="45%" alt="Bot Config" />
|
|
211
|
+
<img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/03.agent.page.png" width="45%" alt="Agent Config" />
|
|
212
|
+
</div>
|
|
213
|
+
|
|
189
214
|
---
|
|
190
215
|
|
|
191
216
|
## 高级功能
|
|
@@ -199,19 +224,101 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
199
224
|
- ✅ 自动去重:基于 `msgid` 避免重复处理
|
|
200
225
|
- ⚠️ 群聊降级:自动转为文本描述
|
|
201
226
|
|
|
202
|
-
### 富媒体处理
|
|
203
227
|
|
|
204
|
-
| 类型 | Bot 模式 | Agent 模式 |
|
|
205
|
-
|:---|:---|:---|
|
|
206
|
-
| 图片 | ✅ URL 解密入模 | ✅ media_id 下载 |
|
|
207
|
-
| 文件 | ✅ URL 解密入模 | ✅ media_id 下载 |
|
|
208
|
-
| 语音 | ✅ 转文字入模 | ✅ 识别结果 + 原始音频 |
|
|
209
|
-
| 视频 | ❌ | ✅ media_id 下载 |
|
|
210
228
|
|
|
211
|
-
###
|
|
229
|
+
### ⏰ Cronjob 企业级定时推送
|
|
212
230
|
|
|
213
|
-
|
|
214
|
-
|
|
231
|
+
本插件深度集成了 OpenClaw 的 Cronjob 调度能力,配合 Agent 强大的广播 API,轻松实现企业级通知服务。
|
|
232
|
+
|
|
233
|
+
> **核心场景**:早报推送、服务器报警、日报提醒、节日祝福。
|
|
234
|
+
|
|
235
|
+
#### 1. 目标配置 (Target)
|
|
236
|
+
无需遍历用户列表,直接利用 Agent 强大的组织架构触达能力:
|
|
237
|
+
|
|
238
|
+
| 目标类型 | 格式示例 | 推送范围 | 典型场景 |
|
|
239
|
+
|:---|:---|:---|:---|
|
|
240
|
+
| **部门 (Party)** | `party:1` (或 `1`) | 📢 **全员广播** | 全员通知、技术部周报 |
|
|
241
|
+
| **标签 (Tag)** | `tag:Ops` | 🎯 **精准分组** | 运维报警、管理层汇报 |
|
|
242
|
+
| **外部群 (Group)** | `group:wr...` | 💬 **群聊推送** | 项目组群日报 (需由Agent建群) |
|
|
243
|
+
| **用户 (User)** | `user:zhangsan` | 👤 **即时私信** | 个人待办提醒 |
|
|
244
|
+
|
|
245
|
+
#### 2. 配置示例 (`schedule.json`)
|
|
246
|
+
|
|
247
|
+
只需在工作区根目录创建 `schedule.json` 即可生效:
|
|
248
|
+
|
|
249
|
+
```json
|
|
250
|
+
{
|
|
251
|
+
"tasks": [
|
|
252
|
+
{
|
|
253
|
+
"cron": "0 9 * * 1-5", // 每周一至周五 早上9:00
|
|
254
|
+
"action": "reply.send",
|
|
255
|
+
"params": {
|
|
256
|
+
"channel": "wecom",
|
|
257
|
+
"to": "party:1", // 一键发送给根部门所有人!
|
|
258
|
+
"text": "🌞 早安!请查收[今日行业简报](https://example.com/daily)。"
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"cron": "0 18 * * 5",
|
|
263
|
+
"action": "reply.send",
|
|
264
|
+
"params": {
|
|
265
|
+
"channel": "wecom",
|
|
266
|
+
"to": "tag:Ops", // 仅发送给运维组
|
|
267
|
+
"text": "🔒 周五封网提醒:请检查服务器状态。"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 📖 详细行为说明 (Behavior Detail)
|
|
279
|
+
|
|
280
|
+
### 1. 企业微信群聊交付规则
|
|
281
|
+
|
|
282
|
+
* **默认 (Bot 回复)**:群聊里 @Bot,默认由 Bot 在群内直接回复(优先文本/图片/Markdown)。
|
|
283
|
+
* **例外 (文件兜底)**:如果回复内容包含**非图片文件**(如 PDF/Word/表格/压缩包等),由于企微 Bot 接口不支持,插件会自动:
|
|
284
|
+
1. Bot 在群里提示:"由于格式限制,文件将通过私信发送给您"。
|
|
285
|
+
2. 无缝切换到 **自建应用 (Agent)** 通道,将文件私信发送给触发者。
|
|
286
|
+
* **提示**:若未配置 Agent,Bot 会明确提示“需要管理员配置自建应用通道”。
|
|
287
|
+
|
|
288
|
+
### 2. 长任务可靠性保障
|
|
289
|
+
|
|
290
|
+
* **超时熔断**:企业微信限制 Bot 流式回复窗口约为 6 分钟。
|
|
291
|
+
* **自动接力**:当对话时长接近此阈值时,Monitor 会自动截断 Bot 流,提示 "剩余内容将私信发送",并立即启动 Agent 通道私信发送完整结果。这彻底解决了长思考任务(如深度推理、代码生成)因超时导致用户收不到结果的问题。
|
|
292
|
+
|
|
293
|
+
### 3. 主动发送安全机制
|
|
294
|
+
|
|
295
|
+
* **群发保护**:Agent 主动发送接口不再尝试向普通群 `chatid` (wr/wc...) 发消息(该路径常因权限与归属产生的隐蔽错误)。
|
|
296
|
+
* **引导提示**:系统会明确拦截并通过日志提示中文错误:"请使用 Bot 群内交付或改为私信目标(userid/部门/标签)",帮助管理员快速排查配置。
|
|
297
|
+
|
|
298
|
+
### 4. 管理员友好
|
|
299
|
+
|
|
300
|
+
* 所有兜底逻辑(Fallback)触发时,如果因配置缺失导致失败,Bot 都会给出清晰的**中文提示**,而不是沉默或报代码错误,极大降低了排查难度。
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## 🙋 社区问答 (FAQ)
|
|
305
|
+
|
|
306
|
+
针对社区反馈的高频问题,我们已在 v2.2.4 版本中全部解决:
|
|
307
|
+
|
|
308
|
+
**Q1: 同时使用 Bot 和 Agent 会导致消息重复吗?**
|
|
309
|
+
> **A:** 不会。本插件采用“Bot 优先”策略。用户在哪个通道发消息,就从哪个通道回。只有在 Bot 无法处理(如发文件)时才会智能切换到 Agent 通道作为补充。
|
|
310
|
+
|
|
311
|
+
**Q2: 使用内网穿透时,企业微信报错 60020 (IP 不白名单) 怎么办?**
|
|
312
|
+
> **A:** 新增了 `config.network.egressProxyUrl` 配置。您可以配置一个拥有固定公网 IP 的代理服务器(如 Squid),让插件通过该代理与企微 API 通信,从而绕过动态 IP 限制。
|
|
313
|
+
|
|
314
|
+
**Q3: 原生 Bot 模式支持图片,为什么 Agent 模式不行?**
|
|
315
|
+
> **A:** Agent 模式之前确实存在此短板。但在 v2.2.4 中,我们完整实现了 Agent 端的 XML 媒体解析与 `media_id` 下载逻辑,现在 Agent 模式也能完美看图、听语音了。
|
|
316
|
+
|
|
317
|
+
**Q4: 群里 @机器人 发送文件失败?**
|
|
318
|
+
> **A:** 因为企业微信 Bot 接口本身不支持发送非图片文件。我们的解决方案是:自动检测到文件发送需求后,改为通过 Agent 私信该用户发送文件,并在群里给出 "文件已私信发给您" 的提示。
|
|
319
|
+
|
|
320
|
+
**Q5: Cronjob 定时任务怎么发给群?**
|
|
321
|
+
> **A:** Cronjob 必须走 Agent 通道(Bot 无法主动发消息)。您只需在配置中指定 `to: "party:1"` (部门) 或 `to: "group:wr123..."` (外部群),即可实现定时推送到群。
|
|
215
322
|
|
|
216
323
|
---
|
|
217
324
|
|
|
@@ -227,10 +334,19 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
227
334
|
|
|
228
335
|
## 更新日志
|
|
229
336
|
|
|
337
|
+
### 2026.2.4
|
|
338
|
+
|
|
339
|
+
- 🚀 **架构升级**:实施 "Bot 优先 + Agent 兜底" 策略,兼顾流式体验与长任务稳定性(6分钟切换)。
|
|
340
|
+
- ✨ **全模态支持**:Agent 模式完整支持接收与发送图片、文件、语音、视频。
|
|
341
|
+
- ✨ **Cronjob 增强**:支持向部门 (`party:ID`) 和标签 (`tag:ID`) 广播消息。
|
|
342
|
+
- 🛠 **Monitor 重构**:统一的消息防抖与流状态管理,提升并发稳定性。
|
|
343
|
+
- 🐞 **修复**:Outbound ID 解析逻辑及 API 客户端参数缺失问题。
|
|
344
|
+
|
|
230
345
|
### 2026.2.3
|
|
231
346
|
|
|
232
347
|
- 🎉 **重大更新**:新增 Agent 模式(自建应用)支持
|
|
233
348
|
- ✨ 双模式并行:Bot + Agent 可同时运行
|
|
349
|
+
- ✨ **多模态支持**:Agent 模式支持图片/语音/文件/视频的接收与自动下载
|
|
234
350
|
- ✨ AccessToken 自动管理:缓存 + 智能刷新
|
|
235
351
|
- ✨ Agent 主动推送:脱离回调限制
|
|
236
352
|
- ✨ XML 加解密:完整 Agent 回调支持
|
|
Binary file
|
|
Binary file
|
package/index.ts
CHANGED
|
@@ -13,6 +13,14 @@ const plugin = {
|
|
|
13
13
|
name: "WeCom",
|
|
14
14
|
description: "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
15
15
|
configSchema: emptyPluginConfigSchema(),
|
|
16
|
+
/**
|
|
17
|
+
* **register (注册插件)**
|
|
18
|
+
*
|
|
19
|
+
* OpenClaw 插件入口点。
|
|
20
|
+
* 1. 注入 Runtime 环境 (api.runtime)。
|
|
21
|
+
* 2. 注册 WeCom 渠道插件 (ChannelPlugin)。
|
|
22
|
+
* 3. 注册 Webhook HTTP 处理器 (handleWecomWebhookRequest)。
|
|
23
|
+
*/
|
|
16
24
|
register(api: OpenClawPluginApi) {
|
|
17
25
|
setWecomRuntime(api.runtime);
|
|
18
26
|
api.registerChannel({ plugin: wecomPlugin });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
6
|
"author": "YanHaidao (VX: YanHaidao)",
|
|
@@ -32,11 +32,15 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"axios": "^1.13.4",
|
|
36
35
|
"fast-xml-parser": "5.3.4",
|
|
36
|
+
"undici": "^7.20.0",
|
|
37
37
|
"zod": "^4.3.6"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"openclaw": ">=2026.1.26"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.2.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
41
45
|
}
|
|
42
46
|
}
|
package/src/agent/api-client.ts
CHANGED
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
import crypto from "node:crypto";
|
|
7
7
|
import { API_ENDPOINTS, LIMITS } from "../types/constants.js";
|
|
8
8
|
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
9
|
+
import { readResponseBodyAsBuffer, wecomFetch } from "../http.js";
|
|
10
|
+
import { resolveWecomEgressProxyUrlFromNetwork } from "../config/index.js";
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* **TokenCache (AccessToken 缓存结构)**
|
|
14
|
+
*
|
|
15
|
+
* 用于缓存企业微信 API 调用所需的 AccessToken。
|
|
16
|
+
* @property token 缓存的 Token 字符串
|
|
17
|
+
* @property expiresAt 过期时间戳 (ms)
|
|
18
|
+
* @property refreshPromise 当前正在进行的刷新 Promise (防止并发刷新)
|
|
19
|
+
*/
|
|
10
20
|
type TokenCache = {
|
|
11
21
|
token: string;
|
|
12
22
|
expiresAt: number;
|
|
@@ -16,7 +26,13 @@ type TokenCache = {
|
|
|
16
26
|
const tokenCaches = new Map<string, TokenCache>();
|
|
17
27
|
|
|
18
28
|
/**
|
|
19
|
-
* 获取 AccessToken
|
|
29
|
+
* **getAccessToken (获取 AccessToken)**
|
|
30
|
+
*
|
|
31
|
+
* 获取企业微信 API 调用所需的 AccessToken。
|
|
32
|
+
* 具备自动缓存和过期刷新机制。
|
|
33
|
+
*
|
|
34
|
+
* @param agent Agent 账号信息
|
|
35
|
+
* @returns 有效的 AccessToken
|
|
20
36
|
*/
|
|
21
37
|
export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
|
|
22
38
|
const cacheKey = `${agent.corpId}:${agent.agentId}`;
|
|
@@ -40,7 +56,7 @@ export async function getAccessToken(agent: ResolvedAgentAccount): Promise<strin
|
|
|
40
56
|
cache.refreshPromise = (async () => {
|
|
41
57
|
try {
|
|
42
58
|
const url = `${API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
|
|
43
|
-
const res = await
|
|
59
|
+
const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
44
60
|
const json = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
|
|
45
61
|
|
|
46
62
|
if (!json?.access_token) {
|
|
@@ -59,15 +75,26 @@ export async function getAccessToken(agent: ResolvedAgentAccount): Promise<strin
|
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
/**
|
|
62
|
-
* 发送文本消息
|
|
78
|
+
* **sendText (发送文本消息)**
|
|
79
|
+
*
|
|
80
|
+
* 调用 `message/send` (Agent) 或 `appchat/send` (群聊) 发送文本。
|
|
81
|
+
*
|
|
82
|
+
* @param params.agent 发送方 Agent
|
|
83
|
+
* @param params.toUser 接收用户 ID (单聊可选,可与 toParty/toTag 同时使用)
|
|
84
|
+
* @param params.toParty 接收部门 ID (单聊可选)
|
|
85
|
+
* @param params.toTag 接收标签 ID (单聊可选)
|
|
86
|
+
* @param params.chatId 接收群 ID (群聊模式必填,互斥)
|
|
87
|
+
* @param params.text 消息内容
|
|
63
88
|
*/
|
|
64
89
|
export async function sendText(params: {
|
|
65
90
|
agent: ResolvedAgentAccount;
|
|
66
91
|
toUser?: string;
|
|
92
|
+
toParty?: string;
|
|
93
|
+
toTag?: string;
|
|
67
94
|
chatId?: string;
|
|
68
95
|
text: string;
|
|
69
96
|
}): Promise<void> {
|
|
70
|
-
const { agent, toUser, chatId, text } = params;
|
|
97
|
+
const { agent, toUser, toParty, toTag, chatId, text } = params;
|
|
71
98
|
const token = await getAccessToken(agent);
|
|
72
99
|
|
|
73
100
|
const useChat = Boolean(chatId);
|
|
@@ -77,14 +104,20 @@ export async function sendText(params: {
|
|
|
77
104
|
|
|
78
105
|
const body = useChat
|
|
79
106
|
? { chatid: chatId, msgtype: "text", text: { content: text } }
|
|
80
|
-
: {
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
: {
|
|
108
|
+
touser: toUser,
|
|
109
|
+
toparty: toParty,
|
|
110
|
+
totag: toTag,
|
|
111
|
+
msgtype: "text",
|
|
112
|
+
agentid: agent.agentId,
|
|
113
|
+
text: { content: text }
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const res = await wecomFetch(url, {
|
|
83
117
|
method: "POST",
|
|
84
118
|
headers: { "Content-Type": "application/json" },
|
|
85
119
|
body: JSON.stringify(body),
|
|
86
|
-
|
|
87
|
-
});
|
|
120
|
+
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
88
121
|
const json = await res.json() as { errcode?: number; errmsg?: string };
|
|
89
122
|
|
|
90
123
|
if (json?.errcode !== 0) {
|
|
@@ -93,7 +126,15 @@ export async function sendText(params: {
|
|
|
93
126
|
}
|
|
94
127
|
|
|
95
128
|
/**
|
|
96
|
-
* 上传媒体文件
|
|
129
|
+
* **uploadMedia (上传媒体文件)**
|
|
130
|
+
*
|
|
131
|
+
* 上传临时素材到企业微信。
|
|
132
|
+
* 素材有效期为 3 天。
|
|
133
|
+
*
|
|
134
|
+
* @param params.type 媒体类型 (image, voice, video, file)
|
|
135
|
+
* @param params.buffer 文件二进制数据
|
|
136
|
+
* @param params.filename 文件名 (需包含正确扩展名)
|
|
137
|
+
* @returns 媒体 ID (media_id)
|
|
97
138
|
*/
|
|
98
139
|
export async function uploadMedia(params: {
|
|
99
140
|
agent: ResolvedAgentAccount;
|
|
@@ -132,15 +173,14 @@ export async function uploadMedia(params: {
|
|
|
132
173
|
|
|
133
174
|
console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
|
|
134
175
|
|
|
135
|
-
const res = await
|
|
176
|
+
const res = await wecomFetch(url, {
|
|
136
177
|
method: "POST",
|
|
137
178
|
headers: {
|
|
138
179
|
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
139
180
|
"Content-Length": String(body.length),
|
|
140
181
|
},
|
|
141
182
|
body: body,
|
|
142
|
-
|
|
143
|
-
});
|
|
183
|
+
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
144
184
|
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
|
|
145
185
|
|
|
146
186
|
// DEBUG: 输出完整响应
|
|
@@ -153,18 +193,32 @@ export async function uploadMedia(params: {
|
|
|
153
193
|
}
|
|
154
194
|
|
|
155
195
|
/**
|
|
156
|
-
* 发送媒体消息
|
|
196
|
+
* **sendMedia (发送媒体消息)**
|
|
197
|
+
*
|
|
198
|
+
* 发送图片、音频、视频或文件。需先通过 `uploadMedia` 获取 media_id。
|
|
199
|
+
*
|
|
200
|
+
* @param params.agent 发送方 Agent
|
|
201
|
+
* @param params.toUser 接收用户 ID (单聊可选)
|
|
202
|
+
* @param params.toParty 接收部门 ID (单聊可选)
|
|
203
|
+
* @param params.toTag 接收标签 ID (单聊可选)
|
|
204
|
+
* @param params.chatId 接收群 ID (群聊模式必填)
|
|
205
|
+
* @param params.mediaId 媒体 ID
|
|
206
|
+
* @param params.mediaType 媒体类型
|
|
207
|
+
* @param params.title 视频标题 (可选)
|
|
208
|
+
* @param params.description 视频描述 (可选)
|
|
157
209
|
*/
|
|
158
210
|
export async function sendMedia(params: {
|
|
159
211
|
agent: ResolvedAgentAccount;
|
|
160
212
|
toUser?: string;
|
|
213
|
+
toParty?: string;
|
|
214
|
+
toTag?: string;
|
|
161
215
|
chatId?: string;
|
|
162
216
|
mediaId: string;
|
|
163
217
|
mediaType: "image" | "voice" | "video" | "file";
|
|
164
218
|
title?: string;
|
|
165
219
|
description?: string;
|
|
166
220
|
}): Promise<void> {
|
|
167
|
-
const { agent, toUser, chatId, mediaId, mediaType, title, description } = params;
|
|
221
|
+
const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType, title, description } = params;
|
|
168
222
|
const token = await getAccessToken(agent);
|
|
169
223
|
|
|
170
224
|
const useChat = Boolean(chatId);
|
|
@@ -178,14 +232,20 @@ export async function sendMedia(params: {
|
|
|
178
232
|
|
|
179
233
|
const body = useChat
|
|
180
234
|
? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
|
|
181
|
-
: {
|
|
182
|
-
|
|
183
|
-
|
|
235
|
+
: {
|
|
236
|
+
touser: toUser,
|
|
237
|
+
toparty: toParty,
|
|
238
|
+
totag: toTag,
|
|
239
|
+
msgtype: mediaType,
|
|
240
|
+
agentid: agent.agentId,
|
|
241
|
+
[mediaType]: mediaPayload
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const res = await wecomFetch(url, {
|
|
184
245
|
method: "POST",
|
|
185
246
|
headers: { "Content-Type": "application/json" },
|
|
186
247
|
body: JSON.stringify(body),
|
|
187
|
-
|
|
188
|
-
});
|
|
248
|
+
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
189
249
|
const json = await res.json() as { errcode?: number; errmsg?: string };
|
|
190
250
|
|
|
191
251
|
if (json?.errcode !== 0) {
|
|
@@ -194,7 +254,11 @@ export async function sendMedia(params: {
|
|
|
194
254
|
}
|
|
195
255
|
|
|
196
256
|
/**
|
|
197
|
-
* 下载媒体文件
|
|
257
|
+
* **downloadMedia (下载媒体文件)**
|
|
258
|
+
*
|
|
259
|
+
* 通过 media_id 从企业微信服务器下载临时素材。
|
|
260
|
+
*
|
|
261
|
+
* @returns { buffer, contentType }
|
|
198
262
|
*/
|
|
199
263
|
export async function downloadMedia(params: {
|
|
200
264
|
agent: ResolvedAgentAccount;
|
|
@@ -204,9 +268,7 @@ export async function downloadMedia(params: {
|
|
|
204
268
|
const token = await getAccessToken(agent);
|
|
205
269
|
const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
206
270
|
|
|
207
|
-
const res = await
|
|
208
|
-
signal: AbortSignal.timeout(LIMITS.REQUEST_TIMEOUT_MS),
|
|
209
|
-
});
|
|
271
|
+
const res = await wecomFetch(url, undefined, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
210
272
|
|
|
211
273
|
if (!res.ok) {
|
|
212
274
|
throw new Error(`download failed: ${res.status}`);
|
|
@@ -220,6 +282,6 @@ export async function downloadMedia(params: {
|
|
|
220
282
|
throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
|
|
221
283
|
}
|
|
222
284
|
|
|
223
|
-
const buffer =
|
|
285
|
+
const buffer = await readResponseBodyAsBuffer(res);
|
|
224
286
|
return { buffer, contentType };
|
|
225
287
|
}
|