@yanhaidao/wecom 2.2.28 → 2.3.3
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 +33 -28
- package/assets/register.png +0 -0
- package/changelog/v2.3.2.md +28 -0
- package/package.json +1 -1
- package/src/agent/api-client.ts +76 -34
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.ts +4 -4
- package/src/channel.lifecycle.test.ts +24 -6
- package/src/channel.ts +11 -6
- package/src/config/network.ts +9 -5
- package/src/gateway-monitor.ts +51 -20
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor.active.test.ts +9 -6
- package/src/monitor.integration.test.ts +4 -2
- package/src/monitor.ts +511 -82
- package/src/monitor.webhook.test.ts +104 -11
- package/src/onboarding.ts +219 -43
- package/src/outbound.ts +6 -0
- package/src/types/constants.ts +7 -3
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# OpenClaw 企业微信(WeCom)Channel 插件
|
|
2
2
|
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **OpenClaw 3.1+ 升级必读**:升级到 OpenClaw `3.1` 及以上版本的用户务必同步升级本插件,并将企业微信回调 URL 更新为 OpenClaw 推荐路径:`/plugins/wecom/bot/{accountId}` 与 `/plugins/wecom/agent/{accountId}`(旧 `/wecom/*` 仍兼容但不再维护)。
|
|
5
|
+
|
|
3
6
|
<p align="center">
|
|
4
7
|
<img src="https://img.shields.io/badge/Original%20Project-YanHaidao-orange?style=for-the-badge&logo=github" alt="Original Project" />
|
|
5
8
|
<img src="https://img.shields.io/badge/License-ISC-blue?style=for-the-badge" alt="License" />
|
|
@@ -80,7 +83,6 @@
|
|
|
80
83
|
## 一、🚀 快速开始
|
|
81
84
|
|
|
82
85
|
> 默认推荐:**多账号 + 多 Agent(matrix)**。
|
|
83
|
-
> 单账号 Bot/Agent 配置仍然支持,但建议仅用于兼容或小规模场景。
|
|
84
86
|
> 建议 OpenClaw 使用 **2026.2.24+** 版本以获得完整生命周期与多账号行为修复。
|
|
85
87
|
|
|
86
88
|
### 1.1 安装插件
|
|
@@ -152,19 +154,12 @@ openclaw channels status
|
|
|
152
154
|
```
|
|
153
155
|
|
|
154
156
|
Webhook 回调建议按账号分别配置:
|
|
155
|
-
- Bot
|
|
156
|
-
- Agent
|
|
157
|
+
- Bot(推荐):`/plugins/wecom/bot/{accountId}`
|
|
158
|
+
- Agent(推荐):`/plugins/wecom/agent/{accountId}`
|
|
157
159
|
|
|
158
160
|
> 提示:如果你已有 `bindings`,请先备份并按需合并,避免覆盖其它通道绑定。
|
|
159
161
|
|
|
160
|
-
### 1.3
|
|
161
|
-
|
|
162
|
-
为降低主线认知负担,README 默认仅展示多账号配置。
|
|
163
|
-
如果你在维护历史部署或只需单账号,请查看兼容文档:
|
|
164
|
-
|
|
165
|
-
- [单账号兼容模式配置指南](./compat-single-account.md)
|
|
166
|
-
|
|
167
|
-
### 1.4 高级网络配置(公网出口代理)
|
|
162
|
+
### 1.3 高级网络配置(公网出口代理)
|
|
168
163
|
如果您的服务器使用 **动态 IP** (如家庭宽带、内网穿透) 或 **无公网 IP**,企业微信 API 会因 IP 变动报错 `60020 not allow to access from your ip`。
|
|
169
164
|
此时需配置一个**固定 IP 的正向代理** (如 Squid),让插件通过该代理访问企微 API。
|
|
170
165
|
|
|
@@ -172,7 +167,7 @@ Webhook 回调建议按账号分别配置:
|
|
|
172
167
|
openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
|
|
173
168
|
```
|
|
174
169
|
|
|
175
|
-
### 1.
|
|
170
|
+
### 1.4 验证
|
|
176
171
|
|
|
177
172
|
```bash
|
|
178
173
|
openclaw config set gateway.bind lan
|
|
@@ -286,38 +281,32 @@ openclaw channels status
|
|
|
286
281
|
}
|
|
287
282
|
```
|
|
288
283
|
|
|
289
|
-
### 2.2
|
|
290
|
-
|
|
291
|
-
- [单账号兼容模式配置指南](./compat-single-account.md)
|
|
292
|
-
|
|
293
|
-
### 2.3 路由第一性原则
|
|
284
|
+
### 2.2 路由第一性原则
|
|
294
285
|
|
|
295
286
|
- `accountId` 是会话隔离边界:不同账号不共享会话、不共享动态 Agent。
|
|
296
287
|
- Bot 无法交付时,只回退到**同组** Agent,不跨账号兜底。
|
|
297
288
|
- 只有在未显式指定 `accountId` 时,才使用 `defaultAccount`。
|
|
298
289
|
|
|
299
|
-
### 2.
|
|
290
|
+
### 2.3 Webhook 路径(必须使用账号路径)
|
|
300
291
|
|
|
301
292
|
| 模式 | 路径 | 说明 |
|
|
302
293
|
|:---|:---|:---|
|
|
303
|
-
| Bot | `/wecom/bot` |
|
|
304
|
-
| Agent | `/wecom/agent` |
|
|
305
|
-
| Bot(多账号) | `/wecom/bot/{accountId}` | 指定账号回调 |
|
|
306
|
-
| Agent(多账号) | `/wecom/agent/{accountId}` | 指定账号回调 |
|
|
294
|
+
| Bot(推荐,多账号) | `/plugins/wecom/bot/{accountId}` | 指定账号回调(例如 `/plugins/wecom/bot/default`) |
|
|
295
|
+
| Agent(推荐,多账号) | `/plugins/wecom/agent/{accountId}` | 指定账号回调(例如 `/plugins/wecom/agent/default`) |
|
|
307
296
|
|
|
308
|
-
### 2.
|
|
297
|
+
### 2.4 从单账号迁移到多账号(4 步)
|
|
309
298
|
|
|
310
299
|
1. 把原来的 `channels.wecom.bot` / `channels.wecom.agent` 拆到 `channels.wecom.accounts.default.bot/agent`。
|
|
311
300
|
2. 按业务继续新增 `channels.wecom.accounts.<accountId>`(例如 `ops`、`sales`)。
|
|
312
301
|
3. 为每个账号增加 `bindings[].match.accountId`,映射到对应 OpenClaw agent。
|
|
313
|
-
4. 企业微信后台把回调 URL 改成账号路径:`/wecom/bot/{accountId}`、`/wecom/agent/{accountId}`,然后执行 `openclaw channels status` 验证。
|
|
302
|
+
4. 企业微信后台把回调 URL 改成账号路径:`/plugins/wecom/bot/{accountId}`、`/plugins/wecom/agent/{accountId}`,然后执行 `openclaw channels status` 验证。
|
|
314
303
|
|
|
315
|
-
### 2.
|
|
304
|
+
### 2.5 DM 策略
|
|
316
305
|
|
|
317
306
|
- **不配置 `dm.allowFrom`** → 所有人可用(默认)
|
|
318
307
|
- **配置 `dm.allowFrom: ["user1", "user2"]`** → 白名单模式,仅列表内用户可私聊
|
|
319
308
|
|
|
320
|
-
### 2.
|
|
309
|
+
### 2.6 常用指令
|
|
321
310
|
|
|
322
311
|
| 指令 | 说明 | 示例 |
|
|
323
312
|
|:---|:---|:---|
|
|
@@ -335,7 +324,7 @@ openclaw channels status
|
|
|
335
324
|
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#/manageTools)
|
|
336
325
|
2. 进入「安全与管理」→「管理工具」→「智能机器人」
|
|
337
326
|
3. 创建机器人,选择 **API 模式**
|
|
338
|
-
4. 填写回调 URL:`https://your-domain.com/wecom/bot
|
|
327
|
+
4. 填写回调 URL:`https://your-domain.com/plugins/wecom/bot/{accountId}`(例如默认账号:`https://your-domain.com/plugins/wecom/bot/default`)
|
|
339
328
|
5. 记录 Token 和 EncodingAESKey
|
|
340
329
|
|
|
341
330
|
### 3.2 Agent 模式(自建应用)
|
|
@@ -346,7 +335,7 @@ openclaw channels status
|
|
|
346
335
|
4. **重要:** 进入「企业可信IP」→「配置」→ 添加你服务器的 IP 地址
|
|
347
336
|
- 如果你使用内网穿透/动态 IP,建议配置 `channels.wecom.network.egressProxyUrl` 走固定出口代理,否则可能出现:`60020 not allow to access from your ip`
|
|
348
337
|
5. 在应用详情中设置「接收消息 - 设置API接收」
|
|
349
|
-
6. 填写回调 URL:`https://your-domain.com/wecom/agent
|
|
338
|
+
6. 填写回调 URL:`https://your-domain.com/plugins/wecom/agent/{accountId}`(例如默认账号:`https://your-domain.com/plugins/wecom/agent/default`)
|
|
350
339
|
7. 记录回调 Token 和 EncodingAESKey
|
|
351
340
|
|
|
352
341
|
<div align="center">
|
|
@@ -511,6 +500,22 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
511
500
|
<a id="sec-10"></a>
|
|
512
501
|
## 八、📝 更新日志
|
|
513
502
|
|
|
503
|
+
### 2026.3.3(今日更新简报)
|
|
504
|
+
|
|
505
|
+
- 【兼容性修复】🧩 **OpenClaw 3.1 路由抢占问题修复**:推荐回调地址升级为 `/plugins/wecom/bot/{accountId}`、`/plugins/wecom/agent/{accountId}`,规避根路径 Control UI fallback 抢占 webhook。
|
|
506
|
+
- 【引导收敛】🧭 **Onboarding 仅支持账号化配置**:配置向导统一写入 `channels.wecom.accounts.<accountId>`,不再引导单账号旧结构。
|
|
507
|
+
- 【兼容策略】🔁 **旧路径兼容保留**:`/wecom/*` 历史回调路径保留兼容能力,但不再作为维护主路径。
|
|
508
|
+
- 【分流稳定性】🧭 **路由识别增强**:monitor 按插件命名空间账号路径识别,确保 Bot/Agent 分支稳定命中。
|
|
509
|
+
- 【链路一致性】🔒 **Bot 回复不再误走 Agent**:修复 Bot 上下文通道标识,避免 `routeReply` 误触发到 outbound 主动发送链路。
|
|
510
|
+
- 【验证结果】✅ WeCom 插件测试通过:`10` files / `41` tests。
|
|
511
|
+
|
|
512
|
+
### 2026.3.2(版本更新简报)
|
|
513
|
+
|
|
514
|
+
- 【交付收口】🔄 修复 Bot 会话“正在搜索相关内容”不结束的问题,并在可用时推送最终流帧结束状态。
|
|
515
|
+
- 【媒体兜底】📎 统一非图片文件、媒体失败和超时场景为“Bot 提示 + Agent 私信兜底”闭环,确保结果可达。
|
|
516
|
+
- 【类型兼容】🧠 扩展 `txt/docx/xlsx/pptx/csv/zip` 等常见文件类型识别,并保留 `application/octet-stream` 自动重试。
|
|
517
|
+
- 【工具治理】🛡 修复 Bot 会话 `message` 工具禁用策略,避免绕过 Bot 交付链路导致会话错位。
|
|
518
|
+
|
|
514
519
|
### 2026.2.28
|
|
515
520
|
|
|
516
521
|
- 【重磅更新】🎯 **多账号/多智能体可用性增强**:支持按 `accountId` 做组内隔离(Bot + Agent + 路由绑定同组生效),动态 Agent 与会话键增加 `accountId` 维度,避免跨账号串会话。
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.2 变更简报
|
|
2
|
+
|
|
3
|
+
> [!WARNING]
|
|
4
|
+
> **OpenClaw 3.1+ 升级必读**:升级到 OpenClaw `3.1` 及以上版本的用户务必同步升级本插件,并将企业微信回调 URL 更新为 OpenClaw 推荐路径:`/plugins/wecom/bot/{accountId}` 与 `/plugins/wecom/agent/{accountId}`(旧 `/wecom/*` 仍兼容但不再维护)。
|
|
5
|
+
|
|
6
|
+
## 2026-03-03(今日)
|
|
7
|
+
- 【路由兼容】🧩 修复 OpenClaw 3.1 下 Control UI fallback 可能抢占 `/wecom/*` webhook 的路由冲突问题。
|
|
8
|
+
- 【引导收敛】🧭 将 WeCom onboarding 统一为账号化配置写入 `channels.wecom.accounts.<accountId>`,不再引导单账号旧结构。
|
|
9
|
+
- 【回调路径】🔁 将 WeCom 回调路径的推荐方案统一为 `/plugins/wecom/bot/{accountId}` 与 `/plugins/wecom/agent/{accountId}`。
|
|
10
|
+
- 【兼容策略】🔁 保留 `/wecom/*` 历史回调路径兼容能力,但不再维护旧路径分支。
|
|
11
|
+
- 【分流稳定】🧭 将 monitor 分流升级为按插件命名空间账号路径识别,确保 Bot/Agent 稳定命中。
|
|
12
|
+
- 【链路一致】🔒 将 Bot 上下文 `Surface` 对齐为 `wecom`,避免核心误判后错误走到 Agent outbound。
|
|
13
|
+
- 【账号必填】🧱 在 matrix 模式下对无 accountId 的基础路径返回 `wecom_matrix_path_required`,强制使用账号化回调路径。
|
|
14
|
+
- 【文档同步】📘 将回调地址文档与 onboarding 提示统一为 `/plugins/wecom/*/{accountId}` 唯一推荐路径。
|
|
15
|
+
|
|
16
|
+
## 2026-03-02(v2.3.2 主体)
|
|
17
|
+
- 【交付收口】🔄 修复 Bot 结果回写后“正在搜索相关内容”不收口的问题,并在可用时推送最终流帧结束思考态。
|
|
18
|
+
- 【媒体兜底】📎 统一非图片文件、媒体失败和超时场景为“Bot 提示 + Agent 私信兜底”闭环,保证结果可达。
|
|
19
|
+
- 【工具治理】🛡 修复 WeCom Bot 会话中 `message` 工具禁用位置,避免模型绕过 Bot 交付链路直接主动发送。
|
|
20
|
+
- 【类型兼容】🧠 扩展本地与远端文件 MIME 识别覆盖 `txt/docx/xlsx/pptx/csv/zip` 等常见类型,并保留 `octet-stream` 重试兜底。
|
|
21
|
+
- 【判定增强】🔍 将入站文件类型推断升级为“文件头特征 + 响应头 + 文件名后缀”多层判定,提升无后缀和异常 URL 的识别准确率。
|
|
22
|
+
|
|
23
|
+
## 验证结果
|
|
24
|
+
- WeCom 插件测试通过 `10` 个测试文件共 `41` 条用例,覆盖 webhook 生命周期、路径分流、媒体兜底与回归场景。
|
|
25
|
+
|
|
26
|
+
## 升级提示
|
|
27
|
+
- 推荐在企业微信后台使用 `https://<your-domain>/plugins/wecom/bot/{accountId}` 与 `https://<your-domain>/plugins/wecom/agent/{accountId}` 作为回调地址。
|
|
28
|
+
- 旧地址 `/wecom/bot/{accountId}` 与 `/wecom/agent/{accountId}` 仍兼容但不再维护,建议尽快迁移到 `/plugins/wecom/*/{accountId}`。
|
package/package.json
CHANGED
package/src/agent/api-client.ts
CHANGED
|
@@ -25,6 +25,43 @@ type TokenCache = {
|
|
|
25
25
|
|
|
26
26
|
const tokenCaches = new Map<string, TokenCache>();
|
|
27
27
|
|
|
28
|
+
function normalizeUploadFilename(filename: string): string {
|
|
29
|
+
const trimmed = filename.trim();
|
|
30
|
+
if (!trimmed) return "file.bin";
|
|
31
|
+
const ext = trimmed.includes(".") ? `.${trimmed.split(".").pop()!.toLowerCase()}` : "";
|
|
32
|
+
const base = ext ? trimmed.slice(0, -ext.length) : trimmed;
|
|
33
|
+
const sanitizedBase = base
|
|
34
|
+
.replace(/[^\x20-\x7e]/g, "_")
|
|
35
|
+
.replace(/["\\\/;=]/g, "_")
|
|
36
|
+
.replace(/\s+/g, "_")
|
|
37
|
+
.replace(/_+/g, "_")
|
|
38
|
+
.replace(/^_+|_+$/g, "");
|
|
39
|
+
const safeBase = sanitizedBase || "file";
|
|
40
|
+
const safeExt = ext.replace(/[^a-z0-9.]/g, "");
|
|
41
|
+
return `${safeBase}${safeExt || ".bin"}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function guessUploadContentType(filename: string): string {
|
|
45
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
46
|
+
const contentTypeMap: Record<string, string> = {
|
|
47
|
+
// image
|
|
48
|
+
jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp",
|
|
49
|
+
// audio / video
|
|
50
|
+
amr: "voice/amr", mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", ogg: "audio/ogg", mp4: "video/mp4", mov: "video/quicktime",
|
|
51
|
+
// documents
|
|
52
|
+
txt: "text/plain", md: "text/markdown", csv: "text/csv", tsv: "text/tab-separated-values", json: "application/json",
|
|
53
|
+
xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
|
|
54
|
+
pdf: "application/pdf", doc: "application/msword", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
55
|
+
xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
56
|
+
ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
57
|
+
rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
|
|
58
|
+
// archives
|
|
59
|
+
zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
|
|
60
|
+
gz: "application/gzip", tgz: "application/gzip", tar: "application/x-tar",
|
|
61
|
+
};
|
|
62
|
+
return contentTypeMap[ext] || "application/octet-stream";
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
function requireAgentId(agent: ResolvedAgentAccount): number {
|
|
29
66
|
if (typeof agent.agentId === "number" && Number.isFinite(agent.agentId)) return agent.agentId;
|
|
30
67
|
throw new Error(`wecom agent account=${agent.accountId} missing agentId; sending via cgi-bin/message/send requires agentId`);
|
|
@@ -163,48 +200,53 @@ export async function uploadMedia(params: {
|
|
|
163
200
|
filename: string;
|
|
164
201
|
}): Promise<string> {
|
|
165
202
|
const { agent, type, buffer, filename } = params;
|
|
203
|
+
const safeFilename = normalizeUploadFilename(filename);
|
|
166
204
|
const token = await getAccessToken(agent);
|
|
205
|
+
const proxyUrl = resolveWecomEgressProxyUrlFromNetwork(agent.network);
|
|
167
206
|
// 添加 debug=1 参数获取更多错误信息
|
|
168
207
|
const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
|
|
169
208
|
|
|
170
209
|
// DEBUG: 输出上传信息
|
|
171
|
-
console.log(`[wecom-upload] Uploading media: type=${type}, filename=${
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
210
|
+
console.log(`[wecom-upload] Uploading media: type=${type}, filename=${safeFilename}, size=${buffer.length} bytes`);
|
|
211
|
+
|
|
212
|
+
const uploadOnce = async (fileContentType: string) => {
|
|
213
|
+
// 手动构造 multipart/form-data 请求体
|
|
214
|
+
// 企业微信要求包含 filename 和 filelength
|
|
215
|
+
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
|
|
216
|
+
|
|
217
|
+
const header = Buffer.from(
|
|
218
|
+
`--${boundary}\r\n` +
|
|
219
|
+
`Content-Disposition: form-data; name="media"; filename="${safeFilename}"; filelength=${buffer.length}\r\n` +
|
|
220
|
+
`Content-Type: ${fileContentType}\r\n\r\n`
|
|
221
|
+
);
|
|
222
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
223
|
+
const body = Buffer.concat([header, buffer, footer]);
|
|
224
|
+
|
|
225
|
+
console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
|
|
226
|
+
|
|
227
|
+
const res = await wecomFetch(url, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
231
|
+
"Content-Length": String(body.length),
|
|
232
|
+
},
|
|
233
|
+
body: body,
|
|
234
|
+
}, { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
235
|
+
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
|
|
236
|
+
console.log(`[wecom-upload] Response:`, JSON.stringify(json));
|
|
237
|
+
return json;
|
|
181
238
|
};
|
|
182
|
-
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
183
|
-
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
|
|
184
|
-
|
|
185
|
-
// 构造 multipart body
|
|
186
|
-
const header = Buffer.from(
|
|
187
|
-
`--${boundary}\r\n` +
|
|
188
|
-
`Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
|
|
189
|
-
`Content-Type: ${fileContentType}\r\n\r\n`
|
|
190
|
-
);
|
|
191
|
-
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
192
|
-
const body = Buffer.concat([header, buffer, footer]);
|
|
193
|
-
|
|
194
|
-
console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
|
|
195
239
|
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
headers: {
|
|
199
|
-
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
200
|
-
"Content-Length": String(body.length),
|
|
201
|
-
},
|
|
202
|
-
body: body,
|
|
203
|
-
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
204
|
-
const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
|
|
240
|
+
const preferredContentType = guessUploadContentType(safeFilename);
|
|
241
|
+
let json = await uploadOnce(preferredContentType);
|
|
205
242
|
|
|
206
|
-
//
|
|
207
|
-
|
|
243
|
+
// 某些文件类型在严格网关/企业微信校验下可能失败,回退到通用类型再试一次。
|
|
244
|
+
if (!json?.media_id && preferredContentType !== "application/octet-stream") {
|
|
245
|
+
console.warn(
|
|
246
|
+
`[wecom-upload] Upload failed with ${preferredContentType}, retrying as application/octet-stream: ${json?.errcode} ${json?.errmsg}`,
|
|
247
|
+
);
|
|
248
|
+
json = await uploadOnce("application/octet-stream");
|
|
249
|
+
}
|
|
208
250
|
|
|
209
251
|
if (!json?.media_id) {
|
|
210
252
|
throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
const { wecomFetchMock, resolveProxyMock } = vi.hoisted(() => ({
|
|
5
|
+
wecomFetchMock: vi.fn(),
|
|
6
|
+
resolveProxyMock: vi.fn(() => undefined),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("../http.js", () => ({
|
|
10
|
+
wecomFetch: wecomFetchMock,
|
|
11
|
+
readResponseBodyAsBuffer: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../config/index.js", () => ({
|
|
15
|
+
resolveWecomEgressProxyUrlFromNetwork: resolveProxyMock,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { uploadMedia } from "./api-client.js";
|
|
19
|
+
|
|
20
|
+
function createAgent(agentId: number): ResolvedAgentAccount {
|
|
21
|
+
return {
|
|
22
|
+
accountId: `acct-${agentId}`,
|
|
23
|
+
enabled: true,
|
|
24
|
+
configured: true,
|
|
25
|
+
corpId: "corp",
|
|
26
|
+
corpSecret: "secret",
|
|
27
|
+
agentId,
|
|
28
|
+
token: "token",
|
|
29
|
+
encodingAESKey: "aes",
|
|
30
|
+
config: {} as any,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function jsonResponse(body: unknown): Response {
|
|
35
|
+
return new Response(JSON.stringify(body), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("wecom agent uploadMedia", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
wecomFetchMock.mockReset();
|
|
44
|
+
resolveProxyMock.mockReset();
|
|
45
|
+
resolveProxyMock.mockReturnValue(undefined);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses text/plain for .txt uploads", async () => {
|
|
49
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ access_token: "token-1", expires_in: 7200 }));
|
|
50
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 0, errmsg: "ok", media_id: "m-1" }));
|
|
51
|
+
|
|
52
|
+
const mediaId = await uploadMedia({
|
|
53
|
+
agent: createAgent(10001),
|
|
54
|
+
type: "file",
|
|
55
|
+
buffer: Buffer.from("hello txt"),
|
|
56
|
+
filename: "note.txt",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(mediaId).toBe("m-1");
|
|
60
|
+
const [, init] = wecomFetchMock.mock.calls[1] as [string, RequestInit];
|
|
61
|
+
const body = init.body as Buffer;
|
|
62
|
+
const bodyText = body.toString("utf8");
|
|
63
|
+
expect(bodyText).toContain('filename="note.txt"');
|
|
64
|
+
expect(bodyText).toContain("Content-Type: text/plain");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("uses docx mime and normalizes non-ascii filename", async () => {
|
|
68
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ access_token: "token-2", expires_in: 7200 }));
|
|
69
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 0, errmsg: "ok", media_id: "m-2" }));
|
|
70
|
+
|
|
71
|
+
const mediaId = await uploadMedia({
|
|
72
|
+
agent: createAgent(10002),
|
|
73
|
+
type: "file",
|
|
74
|
+
buffer: Buffer.from("docx bytes"),
|
|
75
|
+
filename: "需求文档.docx",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(mediaId).toBe("m-2");
|
|
79
|
+
const [, init] = wecomFetchMock.mock.calls[1] as [string, RequestInit];
|
|
80
|
+
const body = init.body as Buffer;
|
|
81
|
+
const bodyText = body.toString("utf8");
|
|
82
|
+
expect(bodyText).toContain('filename="file.docx"');
|
|
83
|
+
expect(bodyText).toContain(
|
|
84
|
+
"Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("retries with octet-stream when preferred mime upload fails", async () => {
|
|
89
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ access_token: "token-3", expires_in: 7200 }));
|
|
90
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 40005, errmsg: "invalid media type" }));
|
|
91
|
+
wecomFetchMock.mockResolvedValueOnce(jsonResponse({ errcode: 0, errmsg: "ok", media_id: "m-3" }));
|
|
92
|
+
|
|
93
|
+
const mediaId = await uploadMedia({
|
|
94
|
+
agent: createAgent(10003),
|
|
95
|
+
type: "file",
|
|
96
|
+
buffer: Buffer.from("yaml bytes"),
|
|
97
|
+
filename: "config.yaml",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(mediaId).toBe("m-3");
|
|
101
|
+
expect(wecomFetchMock).toHaveBeenCalledTimes(3);
|
|
102
|
+
|
|
103
|
+
const [, firstUploadInit] = wecomFetchMock.mock.calls[1] as [string, RequestInit];
|
|
104
|
+
const [, retryUploadInit] = wecomFetchMock.mock.calls[2] as [string, RequestInit];
|
|
105
|
+
const firstUploadBody = (firstUploadInit.body as Buffer).toString("utf8");
|
|
106
|
+
const retryUploadBody = (retryUploadInit.body as Buffer).toString("utf8");
|
|
107
|
+
expect(firstUploadBody).toContain("Content-Type: application/yaml");
|
|
108
|
+
expect(retryUploadBody).toContain("Content-Type: application/octet-stream");
|
|
109
|
+
});
|
|
110
|
+
});
|
package/src/agent/handler.ts
CHANGED
|
@@ -526,7 +526,7 @@ async function processAgentMessage(params: {
|
|
|
526
526
|
SenderName: fromUser,
|
|
527
527
|
SenderId: fromUser,
|
|
528
528
|
Provider: "wecom",
|
|
529
|
-
Surface: "
|
|
529
|
+
Surface: "webchat",
|
|
530
530
|
OriginatingChannel: "wecom",
|
|
531
531
|
// 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
|
|
532
532
|
// - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
|
|
@@ -562,9 +562,9 @@ async function processAgentMessage(params: {
|
|
|
562
562
|
await sendText({ agent, toUser: fromUser, chatId: undefined, text });
|
|
563
563
|
log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
|
|
564
564
|
} catch (err: unknown) {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
},
|
|
565
|
+
const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
|
|
566
|
+
error?.(`[wecom-agent] reply failed: ${message}`);
|
|
567
|
+
} },
|
|
568
568
|
onError: (err: unknown, info: { kind: string }) => {
|
|
569
569
|
error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
|
|
570
570
|
},
|
|
@@ -178,26 +178,44 @@ describe("wecomPlugin gateway lifecycle", () => {
|
|
|
178
178
|
const startPromise = wecomPlugin.gateway!.startAccount!(ctx);
|
|
179
179
|
await Promise.resolve();
|
|
180
180
|
|
|
181
|
-
const
|
|
181
|
+
const activeLegacyRoute = await sendWecomGetVerify({
|
|
182
182
|
path: "/wecom/bot",
|
|
183
183
|
token,
|
|
184
184
|
encodingAESKey,
|
|
185
185
|
receiveId,
|
|
186
186
|
});
|
|
187
|
-
expect(
|
|
188
|
-
expect(
|
|
189
|
-
expect(
|
|
187
|
+
expect(activeLegacyRoute.handled).toBe(true);
|
|
188
|
+
expect(activeLegacyRoute.status).toBe(200);
|
|
189
|
+
expect(activeLegacyRoute.body).toBe("ping");
|
|
190
|
+
|
|
191
|
+
const activePluginRoute = await sendWecomGetVerify({
|
|
192
|
+
path: "/plugins/wecom/bot",
|
|
193
|
+
token,
|
|
194
|
+
encodingAESKey,
|
|
195
|
+
receiveId,
|
|
196
|
+
});
|
|
197
|
+
expect(activePluginRoute.handled).toBe(true);
|
|
198
|
+
expect(activePluginRoute.status).toBe(200);
|
|
199
|
+
expect(activePluginRoute.body).toBe("ping");
|
|
190
200
|
|
|
191
201
|
abortController.abort();
|
|
192
202
|
await startPromise;
|
|
193
203
|
|
|
194
|
-
const
|
|
204
|
+
const inactiveLegacyRoute = await sendWecomGetVerify({
|
|
195
205
|
path: "/wecom/bot",
|
|
196
206
|
token,
|
|
197
207
|
encodingAESKey,
|
|
198
208
|
receiveId,
|
|
199
209
|
});
|
|
200
|
-
expect(
|
|
210
|
+
expect(inactiveLegacyRoute.handled).toBe(false);
|
|
211
|
+
|
|
212
|
+
const inactivePluginRoute = await sendWecomGetVerify({
|
|
213
|
+
path: "/plugins/wecom/bot",
|
|
214
|
+
token,
|
|
215
|
+
encodingAESKey,
|
|
216
|
+
receiveId,
|
|
217
|
+
});
|
|
218
|
+
expect(inactivePluginRoute.handled).toBe(false);
|
|
201
219
|
});
|
|
202
220
|
|
|
203
221
|
it("rejects startup when matrix account credentials conflict", async () => {
|
package/src/channel.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { ResolvedWecomAccount } from "./types/index.js";
|
|
|
19
19
|
import { monitorWecomProvider } from "./gateway-monitor.js";
|
|
20
20
|
import { wecomOnboardingAdapter } from "./onboarding.js";
|
|
21
21
|
import { wecomOutbound } from "./outbound.js";
|
|
22
|
+
import { WEBHOOK_PATHS } from "./types/constants.js";
|
|
22
23
|
|
|
23
24
|
const meta = {
|
|
24
25
|
id: "wecom",
|
|
@@ -108,10 +109,10 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
108
109
|
enabled: account.enabled,
|
|
109
110
|
configured: account.configured && !conflict,
|
|
110
111
|
webhookPath: account.bot?.config
|
|
111
|
-
? (matrixMode ?
|
|
112
|
+
? (matrixMode ? `${WEBHOOK_PATHS.BOT_PLUGIN}/${account.accountId}` : WEBHOOK_PATHS.BOT_PLUGIN)
|
|
112
113
|
: account.agent?.config
|
|
113
|
-
? (matrixMode ?
|
|
114
|
-
:
|
|
114
|
+
? (matrixMode ? `${WEBHOOK_PATHS.AGENT_PLUGIN}/${account.accountId}` : WEBHOOK_PATHS.AGENT_PLUGIN)
|
|
115
|
+
: WEBHOOK_PATHS.BOT_PLUGIN,
|
|
115
116
|
};
|
|
116
117
|
},
|
|
117
118
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
@@ -176,10 +177,14 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
176
177
|
enabled: account.enabled,
|
|
177
178
|
configured: account.configured && !conflict,
|
|
178
179
|
webhookPath: account.bot?.config
|
|
179
|
-
? (account.accountId === DEFAULT_ACCOUNT_ID
|
|
180
|
+
? (account.accountId === DEFAULT_ACCOUNT_ID
|
|
181
|
+
? WEBHOOK_PATHS.BOT_PLUGIN
|
|
182
|
+
: `${WEBHOOK_PATHS.BOT_PLUGIN}/${account.accountId}`)
|
|
180
183
|
: account.agent?.config
|
|
181
|
-
? (account.accountId === DEFAULT_ACCOUNT_ID
|
|
182
|
-
|
|
184
|
+
? (account.accountId === DEFAULT_ACCOUNT_ID
|
|
185
|
+
? WEBHOOK_PATHS.AGENT_PLUGIN
|
|
186
|
+
: `${WEBHOOK_PATHS.AGENT_PLUGIN}/${account.accountId}`)
|
|
187
|
+
: WEBHOOK_PATHS.BOT_PLUGIN,
|
|
183
188
|
running: runtime?.running ?? false,
|
|
184
189
|
lastStartAt: runtime?.lastStartAt ?? null,
|
|
185
190
|
lastStopAt: runtime?.lastStopAt ?? null,
|
package/src/config/network.ts
CHANGED
|
@@ -3,11 +3,15 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
3
3
|
import type { WecomConfig, WecomNetworkConfig } from "../types/index.js";
|
|
4
4
|
|
|
5
5
|
export function resolveWecomEgressProxyUrlFromNetwork(network?: WecomNetworkConfig): string | undefined {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
const proxyUrl = network?.egressProxyUrl ??
|
|
7
|
+
process.env.OPENCLAW_WECOM_EGRESS_PROXY_URL ??
|
|
8
|
+
process.env.WECOM_EGRESS_PROXY_URL ??
|
|
9
|
+
process.env.HTTPS_PROXY ??
|
|
10
|
+
process.env.ALL_PROXY ??
|
|
11
|
+
process.env.HTTP_PROXY ??
|
|
12
|
+
"";
|
|
13
|
+
|
|
14
|
+
return proxyUrl.trim() || undefined;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export function resolveWecomEgressProxyUrl(cfg: OpenClawConfig): string | undefined {
|