@yanhaidao/wecom 2.2.28 → 2.3.2
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 +7 -7
- package/assets/register.png +0 -0
- package/changelog/v2.3.2.md +70 -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/config/network.ts +9 -5
- 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 +7 -4
- package/src/monitor.ts +486 -74
- package/src/outbound.ts +6 -0
package/README.md
CHANGED
|
@@ -296,14 +296,14 @@ openclaw channels status
|
|
|
296
296
|
- Bot 无法交付时,只回退到**同组** Agent,不跨账号兜底。
|
|
297
297
|
- 只有在未显式指定 `accountId` 时,才使用 `defaultAccount`。
|
|
298
298
|
|
|
299
|
-
### 2.4 Webhook
|
|
299
|
+
### 2.4 Webhook 路径(优先使用账号路径)
|
|
300
300
|
|
|
301
301
|
| 模式 | 路径 | 说明 |
|
|
302
302
|
|:---|:---|:---|
|
|
303
|
-
| Bot | `/wecom/bot` |
|
|
304
|
-
| Agent | `/wecom/agent` |
|
|
305
|
-
| Bot
|
|
306
|
-
| Agent
|
|
303
|
+
| Bot(推荐,多账号) | `/wecom/bot/{accountId}` | 指定账号回调(例如 `/wecom/bot/default`) |
|
|
304
|
+
| Agent(推荐,多账号) | `/wecom/agent/{accountId}` | 指定账号回调(例如 `/wecom/agent/default`) |
|
|
305
|
+
| Bot(兼容,单账号 legacy) | `/wecom/bot` 或 `/wecom` | 历史路径,仅单账号模式建议保留 |
|
|
306
|
+
| Agent(兼容,单账号 legacy) | `/wecom/agent` | 历史路径,单账号模式可用 |
|
|
307
307
|
|
|
308
308
|
### 2.5 从单账号迁移到多账号(4 步)
|
|
309
309
|
|
|
@@ -335,7 +335,7 @@ openclaw channels status
|
|
|
335
335
|
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#/manageTools)
|
|
336
336
|
2. 进入「安全与管理」→「管理工具」→「智能机器人」
|
|
337
337
|
3. 创建机器人,选择 **API 模式**
|
|
338
|
-
4. 填写回调 URL:`https://your-domain.com/wecom/bot
|
|
338
|
+
4. 填写回调 URL:`https://your-domain.com/wecom/bot/{accountId}`(例如默认账号:`https://your-domain.com/wecom/bot/default`)
|
|
339
339
|
5. 记录 Token 和 EncodingAESKey
|
|
340
340
|
|
|
341
341
|
### 3.2 Agent 模式(自建应用)
|
|
@@ -346,7 +346,7 @@ openclaw channels status
|
|
|
346
346
|
4. **重要:** 进入「企业可信IP」→「配置」→ 添加你服务器的 IP 地址
|
|
347
347
|
- 如果你使用内网穿透/动态 IP,建议配置 `channels.wecom.network.egressProxyUrl` 走固定出口代理,否则可能出现:`60020 not allow to access from your ip`
|
|
348
348
|
5. 在应用详情中设置「接收消息 - 设置API接收」
|
|
349
|
-
6. 填写回调 URL:`https://your-domain.com/wecom/agent
|
|
349
|
+
6. 填写回调 URL:`https://your-domain.com/wecom/agent/{accountId}`(例如默认账号:`https://your-domain.com/wecom/agent/default`)
|
|
350
350
|
7. 记录回调 Token 和 EncodingAESKey
|
|
351
351
|
|
|
352
352
|
<div align="center">
|
|
Binary file
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# 🚀 OpenClaw 企业微信 (WeCom) 插件 v2.3.2 - Bot/Agent 交付收口与文件发送兼容性增强
|
|
2
|
+
|
|
3
|
+
本次 v2.3.2 版本聚焦修复企业微信 Bot 模式在复杂交付场景下的稳定性问题,重点解决:
|
|
4
|
+
- Bot 已收到文件但界面仍停留在“正在搜索相关内容”不结束。
|
|
5
|
+
- 本地文件下发时,`txt`、`docx` 以及更多文件类型发送不稳定的问题。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
### 🌟 版本亮点 (Release Highlights)
|
|
10
|
+
|
|
11
|
+
* ✅ **修复“正在搜索相关内容”不收口**:Agent 链路执行完成后,Bot 会主动推送最终流帧,确保企微思考态正确结束。
|
|
12
|
+
* 🔒 **修复消息工具绕过链路问题**:在 WeCom Bot 会话中正确禁用顶层 `message` 工具,避免模型绕开 Bot 流式交付导致链路错位。
|
|
13
|
+
* 📁 **本地路径下发能力增强**:支持识别 `/root`、`/home` 等 Linux 常见路径,减少文件请求误分流。
|
|
14
|
+
* 📄 **文件类型兼容大幅提升**:补齐 `txt`、`docx`、`xlsx`、`pptx`、`csv`、`zip` 等常见类型 MIME,并增强入站媒体类型推断准确率。
|
|
15
|
+
* 🛟 **未知类型自动兜底**:上传失败时自动回退到 `application/octet-stream` 重试,提高“能发出去”的成功率。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
### 📝 详细更新日志 (Changelog)
|
|
20
|
+
|
|
21
|
+
#### 【交付稳定性】🔄 Bot/Agent 链路收口修复
|
|
22
|
+
- 增加统一“最终流帧”主动推送逻辑,确保 `response_url` 可用时会主动收口。
|
|
23
|
+
- 当回复内容为空时增加最终可见兜底文案,避免企微前端悬空态。
|
|
24
|
+
- 非图片文件、媒体处理失败、超时切换等场景统一走“Bot 提示 + Agent 私信兜底”闭环。
|
|
25
|
+
|
|
26
|
+
#### 【链路一致性】🧭 工具策略修复
|
|
27
|
+
- 在 WeCom Bot 会话中将 `message` 工具写入 `tools.deny`(并同步 sandbox deny),修复错误禁用位置造成的链路绕过。
|
|
28
|
+
- 避免“文件已发出但 Bot 思考流未结束”的链路错位。
|
|
29
|
+
|
|
30
|
+
#### 【文件兼容性】📎 MIME 与上传兜底增强
|
|
31
|
+
- 扩展本地文件 MIME 推断覆盖:`txt/csv/tsv/md/json/xml/yaml/yml/pdf/doc/docx/xls/xlsx/ppt/pptx/zip/rar/7z/tar/gz/tgz/rtf/odt`。
|
|
32
|
+
- 上传 multipart 时增加文件名规范化,降低特殊文件名导致的兼容性问题。
|
|
33
|
+
- 新增“首选 MIME 失败 -> octet-stream 自动重试”机制,提升未知/边缘类型上传成功率。
|
|
34
|
+
|
|
35
|
+
#### 【类型识别优化】🧠 入站文件名与后缀判定更准确
|
|
36
|
+
- 下载并解密媒体时保留源信息(`content-type`、`content-disposition`、最终 URL),用于后续精确判断。
|
|
37
|
+
- 新增三层文件类型判定策略(按优先级):
|
|
38
|
+
- 二进制内容特征(Magic Number / 文件头)
|
|
39
|
+
- 非泛型响应头 `content-type`
|
|
40
|
+
- 文件名后缀映射
|
|
41
|
+
- 文件名确定策略(按优先级):
|
|
42
|
+
- 回调体显式文件名字段
|
|
43
|
+
- 下载响应 `content-disposition` 文件名
|
|
44
|
+
- URL 路径 basename(仅在看起来是有效文件名时采用)
|
|
45
|
+
- 兜底默认名(自动补扩展名)
|
|
46
|
+
- 对 OOXML(`docx/xlsx/pptx`)增加 ZIP 内容探测,降低仅靠 URL 无后缀时误判为 `zip/bin` 的概率。
|
|
47
|
+
|
|
48
|
+
#### 【质量保障】✅ 回归测试补充
|
|
49
|
+
- 新增上传链路单测,覆盖:
|
|
50
|
+
- `.txt` 使用 `text/plain`
|
|
51
|
+
- `.docx` 使用官方 MIME
|
|
52
|
+
- 首次 MIME 失败时自动回退重试
|
|
53
|
+
- 同步通过 WeCom monitor/outbound 相关回归测试,确保无行为回退。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### 💾 安装与升级 (Install & Update)
|
|
58
|
+
|
|
59
|
+
使用 **OpenClaw** CLI 一键升级 **插件**:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
openclaw plugins upgrade wecom
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
或手动更新版本至 `v2.3.2`。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### 📮 联系我们
|
|
70
|
+
如果您在 **企业微信 / 微信** 接入过程中遇到任何问题,欢迎提交 Issue 反馈日志与复现场景。
|
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
|
},
|
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 {
|
package/src/http.ts
CHANGED
|
@@ -57,13 +57,28 @@ export async function wecomFetch(input: string | URL, init?: RequestInit, opts?:
|
|
|
57
57
|
|
|
58
58
|
const initSignal = init?.signal ?? undefined;
|
|
59
59
|
const signal = mergeAbortSignal({ signal: opts?.signal ?? initSignal, timeoutMs: opts?.timeoutMs });
|
|
60
|
+
|
|
61
|
+
const headers = new Headers(init?.headers ?? {});
|
|
62
|
+
if (!headers.has("User-Agent")) {
|
|
63
|
+
headers.set("User-Agent", "OpenClaw/2.0 (WeCom-Agent)");
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
const nextInit: RequestInit & { dispatcher?: Dispatcher } = {
|
|
61
67
|
...(init ?? {}),
|
|
62
68
|
...(signal ? { signal } : {}),
|
|
63
69
|
...(dispatcher ? { dispatcher } : {}),
|
|
70
|
+
headers,
|
|
64
71
|
};
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
try {
|
|
74
|
+
return await undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Response;
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
if (err instanceof Error && err.name === "TypeError" && err.message === "fetch failed") {
|
|
77
|
+
const cause = (err as any).cause;
|
|
78
|
+
console.error(`[wecom-http] fetch failed: ${input} (proxy: ${proxyUrl || "none"})${cause ? ` - cause: ${String(cause)}` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
@@ -99,4 +114,3 @@ export async function readResponseBodyAsBuffer(res: Response, maxBytes?: number)
|
|
|
99
114
|
|
|
100
115
|
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
101
116
|
}
|
|
102
|
-
|
package/src/media.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { decryptWecomMedia } from "./media.js";
|
|
2
|
+
import { decryptWecomMedia, decryptWecomMediaWithMeta } from "./media.js";
|
|
3
3
|
import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
|
|
@@ -52,4 +52,31 @@ describe("decryptWecomMedia", () => {
|
|
|
52
52
|
it("should fail if key is invalid", async () => {
|
|
53
53
|
await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
it("should return source metadata when using decryptWecomMediaWithMeta", async () => {
|
|
57
|
+
const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa=";
|
|
58
|
+
const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
|
|
59
|
+
const iv = aesKey.subarray(0, 16);
|
|
60
|
+
const originalData = Buffer.from("meta test", "utf8");
|
|
61
|
+
const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
|
|
62
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
63
|
+
cipher.setAutoPadding(false);
|
|
64
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
65
|
+
|
|
66
|
+
undiciFetch.mockResolvedValue(
|
|
67
|
+
new Response(encrypted, {
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/octet-stream; charset=binary",
|
|
71
|
+
"content-disposition": "attachment; filename*=UTF-8''report%20v1.docx",
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const decrypted = await decryptWecomMediaWithMeta("http://mock.url/media?id=1", aesKeyBase64);
|
|
77
|
+
expect(decrypted.buffer.toString("utf8")).toBe("meta test");
|
|
78
|
+
expect(decrypted.sourceContentType).toBe("application/octet-stream");
|
|
79
|
+
expect(decrypted.sourceFilename).toBe("report v1.docx");
|
|
80
|
+
expect(decrypted.sourceUrl).toBe("http://mock.url/media?id=1");
|
|
81
|
+
});
|
|
55
82
|
});
|
package/src/media.ts
CHANGED
|
@@ -2,6 +2,41 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
3
3
|
import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
|
|
4
4
|
|
|
5
|
+
export type DecryptedWecomMedia = {
|
|
6
|
+
buffer: Buffer;
|
|
7
|
+
sourceContentType?: string;
|
|
8
|
+
sourceFilename?: string;
|
|
9
|
+
sourceUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function normalizeMime(contentType?: string | null): string | undefined {
|
|
13
|
+
const raw = String(contentType ?? "").trim();
|
|
14
|
+
if (!raw) return undefined;
|
|
15
|
+
return raw.split(";")[0]?.trim().toLowerCase() || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractFilenameFromContentDisposition(disposition?: string | null): string | undefined {
|
|
19
|
+
const raw = String(disposition ?? "").trim();
|
|
20
|
+
if (!raw) return undefined;
|
|
21
|
+
|
|
22
|
+
const star = raw.match(/filename\*\s*=\s*([^;]+)/i);
|
|
23
|
+
if (star?.[1]) {
|
|
24
|
+
const v = star[1].trim().replace(/^UTF-8''/i, "").replace(/^"(.*)"$/, "$1");
|
|
25
|
+
try {
|
|
26
|
+
const decoded = decodeURIComponent(v);
|
|
27
|
+
if (decoded.trim()) return decoded.trim();
|
|
28
|
+
} catch { /* ignore */ }
|
|
29
|
+
if (v.trim()) return v.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plain = raw.match(/filename\s*=\s*([^;]+)/i);
|
|
33
|
+
if (plain?.[1]) {
|
|
34
|
+
const v = plain[1].trim().replace(/^"(.*)"$/, "$1").trim();
|
|
35
|
+
if (v) return v;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
5
40
|
/**
|
|
6
41
|
* **decryptWecomMedia (解密企业微信媒体文件)**
|
|
7
42
|
*
|
|
@@ -28,11 +63,29 @@ export async function decryptWecomMediaWithHttp(
|
|
|
28
63
|
encodingAESKey: string,
|
|
29
64
|
params?: { maxBytes?: number; http?: WecomHttpOptions },
|
|
30
65
|
): Promise<Buffer> {
|
|
66
|
+
const decrypted = await decryptWecomMediaWithMeta(url, encodingAESKey, params);
|
|
67
|
+
return decrypted.buffer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* **decryptWecomMediaWithMeta (解密企业微信媒体并返回源信息)**
|
|
72
|
+
*
|
|
73
|
+
* 在返回解密结果的同时,保留下载响应中的元信息(content-type / filename / final url),
|
|
74
|
+
* 供上层更准确地推断文件后缀和 MIME。
|
|
75
|
+
*/
|
|
76
|
+
export async function decryptWecomMediaWithMeta(
|
|
77
|
+
url: string,
|
|
78
|
+
encodingAESKey: string,
|
|
79
|
+
params?: { maxBytes?: number; http?: WecomHttpOptions },
|
|
80
|
+
): Promise<DecryptedWecomMedia> {
|
|
31
81
|
// 1. Download encrypted content
|
|
32
82
|
const res = await wecomFetch(url, undefined, { ...params?.http, timeoutMs: params?.http?.timeoutMs ?? 15_000 });
|
|
33
83
|
if (!res.ok) {
|
|
34
84
|
throw new Error(`failed to download media: ${res.status}`);
|
|
35
85
|
}
|
|
86
|
+
const sourceContentType = normalizeMime(res.headers.get("content-type"));
|
|
87
|
+
const sourceFilename = extractFilenameFromContentDisposition(res.headers.get("content-disposition"));
|
|
88
|
+
const sourceUrl = res.url || url;
|
|
36
89
|
const encryptedData = await readResponseBodyAsBuffer(res, params?.maxBytes);
|
|
37
90
|
|
|
38
91
|
// 2. Prepare Key and IV
|
|
@@ -51,5 +104,10 @@ export async function decryptWecomMediaWithHttp(
|
|
|
51
104
|
// Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
|
|
52
105
|
// The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
|
|
53
106
|
// Our pkcs7Unpad function does exactly this + validation.
|
|
54
|
-
return
|
|
107
|
+
return {
|
|
108
|
+
buffer: pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE),
|
|
109
|
+
sourceContentType,
|
|
110
|
+
sourceFilename,
|
|
111
|
+
sourceUrl,
|
|
112
|
+
};
|
|
55
113
|
}
|
|
@@ -190,14 +190,17 @@ describe("Monitor Active Features", () => {
|
|
|
190
190
|
undiciFetch.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
191
191
|
await sendActiveMessage(streamId, "Active Hello");
|
|
192
192
|
|
|
193
|
-
expect(undiciFetch).
|
|
194
|
-
|
|
193
|
+
expect(undiciFetch).toHaveBeenCalled();
|
|
194
|
+
const [url, init] = undiciFetch.mock.calls.at(-1)! as [string, RequestInit];
|
|
195
|
+
expect(url).toBe("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key");
|
|
196
|
+
expect(init).toEqual(
|
|
195
197
|
expect.objectContaining({
|
|
196
198
|
method: "POST",
|
|
197
|
-
headers: expect.objectContaining({ "Content-Type": "application/json" }),
|
|
198
199
|
body: JSON.stringify({ msgtype: "text", text: { content: "Active Hello" } }),
|
|
199
200
|
}),
|
|
200
201
|
);
|
|
202
|
+
const headers = new Headers(init.headers);
|
|
203
|
+
expect(headers.get("content-type")).toBe("application/json");
|
|
201
204
|
});
|
|
202
205
|
|
|
203
206
|
it("should fallback non-image media to agent DM (and push a Chinese prompt)", async () => {
|
|
@@ -237,6 +240,6 @@ describe("Monitor Active Features", () => {
|
|
|
237
240
|
expect(undiciFetch).toHaveBeenCalled();
|
|
238
241
|
});
|
|
239
242
|
|
|
240
|
-
// 注:本机路径(/Users
|
|
243
|
+
// 注:本机路径(/Users/...、/tmp/...、/root/...、/home/...)短路发图逻辑属于运行态特性,
|
|
241
244
|
// 单测在 fake timers + module singleton 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。
|
|
242
245
|
});
|