@yanhaidao/wecom 2.2.4 → 2.2.7

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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx tsc:*)",
5
+ "Bash(./node_modules/.bin/tsc:*)",
6
+ "Bash(npm install)",
7
+ "Bash(npx vitest:*)",
8
+ "Bash(node --input-type=module -e:*)"
9
+ ]
10
+ }
11
+ }
package/CLAUDE.md ADDED
@@ -0,0 +1,238 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ This is an **OpenClaw Channel Plugin** for WeCom (企业微信 / WeChat Work). It enables AI bot integration with enterprise WeChat through a dual-mode architecture.
8
+
9
+ - **Package**: `@yanhaidao/wecom`
10
+ - **Type**: ES Module (NodeNext)
11
+ - **Entry**: `index.ts`
12
+
13
+ ## Architecture
14
+
15
+ ### Dual-Mode Design (Bot + Agent)
16
+
17
+ The plugin implements a unique dual-mode architecture:
18
+
19
+ | Mode | Purpose | Webhook Path | Capabilities |
20
+ |------|---------|--------------|--------------|
21
+ | **Bot** (智能体) | Real-time streaming chat | `/wecom`, `/wecom/bot` | Streaming responses, low latency, text/image only |
22
+ | **Agent** (自建应用) | Fallback & broadcast | `/wecom/agent` | File sending, broadcasts, long tasks (>6min) |
23
+
24
+ **Key Design Principle**: Bot is preferred for conversations; Agent is used as fallback when Bot cannot deliver (files, timeouts) or for proactive broadcasts.
25
+
26
+ ### Core Components
27
+
28
+ ```
29
+ index.ts # Plugin entry - registers channel and HTTP handlers
30
+ src/
31
+ channel.ts # ChannelPlugin implementation, lifecycle management
32
+ monitor.ts # Core webhook handler, message flow, stream state
33
+ runtime.ts # Runtime state singleton
34
+ http.ts # HTTP client with undici + proxy support
35
+ crypto.ts # AES-CBC encryption/decryption for webhooks
36
+ media.ts # Media file download/decryption
37
+ outbound.ts # Outbound message adapter
38
+ target.ts # Target resolution (user/party/tag/chat)
39
+ dynamic-agent.ts # Dynamic agent routing (per-user/per-group isolation)
40
+ agent/
41
+ api-client.ts # WeCom API client with AccessToken caching
42
+ handler.ts # XML webhook handler for Agent mode
43
+ config/
44
+ schema.ts # Zod schemas for configuration
45
+ monitor/
46
+ state.ts # StreamStore and ActiveReplyStore with TTL pruning
47
+ types/constants.ts # API endpoints and limits
48
+ ```
49
+
50
+ ### Stream State Management
51
+
52
+ The plugin uses a sophisticated stream state system (`src/monitor/state.ts`):
53
+
54
+ - **StreamStore**: Manages message streams with 6-minute timeout window
55
+ - **ActiveReplyStore**: Tracks `response_url` for proactive pushes
56
+ - **Pending Queue**: Debounces rapid messages (500ms default)
57
+ - **Message Deduplication**: Uses `msgid` to prevent duplicate processing
58
+
59
+ ### Token Management
60
+
61
+ Agent mode uses automatic AccessToken caching (`src/agent/api-client.ts`):
62
+ - Token cached with 60-second refresh buffer
63
+ - Automatic retry on expiration
64
+ - Thread-safe refresh deduplication
65
+
66
+ ## Development Commands
67
+
68
+ ### Testing
69
+
70
+ This project uses **Vitest**. Tests extend from a base config at `../../vitest.config.ts`:
71
+
72
+ ```bash
73
+ # Run all tests
74
+ npx vitest --config vitest.config.ts
75
+
76
+ # Run specific test file
77
+ npx vitest --config vitest.config.ts src/crypto.test.ts
78
+
79
+ # Run tests matching pattern
80
+ npx vitest --config vitest.config.ts --testNamePattern="should encrypt"
81
+
82
+ # Watch mode
83
+ npx vitest --config vitest.config.ts --watch
84
+ ```
85
+
86
+ Test files are located alongside source files with `.test.ts` suffix:
87
+ - `src/crypto.test.ts`
88
+ - `src/monitor.integration.test.ts`
89
+ - `src/monitor/state.queue.test.ts`
90
+ - etc.
91
+
92
+ ### Type Checking
93
+
94
+ ```bash
95
+ npx tsc --noEmit
96
+ ```
97
+
98
+ ### Build
99
+
100
+ The plugin is loaded directly as TypeScript by OpenClaw. No build step is required for development, but type checking is recommended.
101
+
102
+ ## Configuration Schema
103
+
104
+ Configuration is validated via Zod (`src/config/schema.ts`):
105
+
106
+ ```typescript
107
+ {
108
+ enabled: boolean,
109
+ bot: {
110
+ token: string, // Bot webhook token
111
+ encodingAESKey: string, // AES encryption key
112
+ receiveId: string?, // Optional receive ID
113
+ streamPlaceholderContent: string?, // "Thinking..."
114
+ welcomeText: string?,
115
+ dm: { policy, allowFrom }
116
+ },
117
+ agent: {
118
+ corpId: string,
119
+ corpSecret: string,
120
+ agentId: number,
121
+ token: string, // Callback token
122
+ encodingAESKey: string, // Callback AES key
123
+ welcomeText: string?,
124
+ dm: { policy, allowFrom }
125
+ },
126
+ network: {
127
+ egressProxyUrl: string? // For dynamic IP scenarios
128
+ },
129
+ media: {
130
+ maxBytes: number? // Default 25MB
131
+ },
132
+ dynamicAgents: {
133
+ enabled: boolean? // Enable per-user/per-group agents
134
+ dmCreateAgent: boolean? // Create agent per DM user
135
+ groupEnabled: boolean? // Enable for group chats
136
+ adminUsers: string[]? // Admin users (bypass dynamic routing)
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Dynamic Agent Routing
142
+
143
+ When `dynamicAgents.enabled` is `true`, the plugin automatically creates isolated Agent instances for each user/group:
144
+
145
+ ```bash
146
+ # Enable dynamic agents
147
+ openclaw config set channels.wecom.dynamicAgents.enabled true
148
+
149
+ # Configure admin users (use main agent)
150
+ openclaw config set channels.wecom.dynamicAgents.adminUsers '["admin1","admin2"]'
151
+ ```
152
+
153
+ **Generated Agent ID format**: `wecom-{type}-{peerId}`
154
+ - DM: `wecom-dm-zhangsan`
155
+ - Group: `wecom-group-wr123456`
156
+
157
+ Dynamic agents are automatically added to `agents.list` in the config file.
158
+
159
+ ## Key Technical Details
160
+
161
+ ### Webhook Security
162
+
163
+ - **Signature Verification**: HMAC-SHA256 with token
164
+ - **Encryption**: AES-CBC with PKCS#7 padding (32-byte blocks)
165
+ - **Paths**: `/wecom` (legacy), `/wecom/bot`, `/wecom/agent`
166
+
167
+ ### Timeout Handling
168
+
169
+ Bot mode has a 6-minute window (360s) for streaming responses. The plugin:
170
+ - Tracks deadline: `createdAt + 6 * 60 * 1000`
171
+ - Switches to Agent fallback at `deadline - 30s` margin
172
+ - Sends DM via Agent for remaining content
173
+
174
+ ### Media Handling
175
+
176
+ - **Inbound**: Decrypts WeCom encrypted media URLs
177
+ - **Outbound Images**: Base64 encoded via `msg_item` in stream
178
+ - **Outbound Files**: Requires Agent mode, sent via `media/upload` + `message/send`
179
+ - **Max Size**: 25MB default (configurable via `channels.wecom.media.maxBytes`)
180
+
181
+ ### Proxy Support
182
+
183
+ For servers with dynamic IPs (common error: `60020 not allow to access from your ip`):
184
+
185
+ ```bash
186
+ openclaw config set channels.wecom.network.egressProxyUrl "http://proxy.company.local:3128"
187
+ ```
188
+
189
+ ## Testing Notes
190
+
191
+ - Tests use Vitest with `../../vitest.config.ts` as base
192
+ - Integration tests mock WeCom API responses
193
+ - Crypto tests verify AES encryption round-trips
194
+ - Monitor tests cover stream state transitions and queue behavior
195
+
196
+ ## Common Patterns
197
+
198
+ ### Adding a New Message Type Handler
199
+
200
+ 1. Update `buildInboundBody()` in `src/monitor.ts` to parse the message
201
+ 2. Add type definitions in `src/types/message.ts`
202
+ 3. Update `processInboundMessage()` if media handling is needed
203
+
204
+ ### Agent API Calls
205
+
206
+ Always use `api-client.ts` methods which handle token management:
207
+
208
+ ```typescript
209
+ import { sendText, uploadMedia } from "./agent/api-client.js";
210
+
211
+ // Token is automatically cached and refreshed
212
+ await sendText({ agent, toUser: "userid", text: "Hello" });
213
+ ```
214
+
215
+ ### Stream Content Updates
216
+
217
+ Use `streamStore.updateStream()` for thread-safe updates:
218
+
219
+ ```typescript
220
+ streamStore.updateStream(streamId, (state) => {
221
+ state.content = "new content";
222
+ state.finished = true;
223
+ });
224
+ ```
225
+
226
+ ## Dependencies
227
+
228
+ - `undici`: HTTP client with proxy support
229
+ - `fast-xml-parser`: XML parsing for Agent callbacks
230
+ - `zod`: Configuration validation
231
+ - `openclaw`: Peer dependency (>=2026.1.26)
232
+
233
+ ## WeCom API Endpoints Used
234
+
235
+ - `GET_TOKEN`: `https://qyapi.weixin.qq.com/cgi-bin/gettoken`
236
+ - `SEND_MESSAGE`: `https://qyapi.weixin.qq.com/cgi-bin/message/send`
237
+ - `UPLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/upload`
238
+ - `DOWNLOAD_MEDIA`: `https://qyapi.weixin.qq.com/cgi-bin/media/get`
package/README.md CHANGED
@@ -44,23 +44,16 @@
44
44
 
45
45
  ---
46
46
 
47
- <div align="center">
48
- <img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg" width="45%" />
49
- <img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/02.image.jpg" width="45%" />
50
- </div>
51
-
52
-
53
47
 
54
48
  ## 📊 模式能力对比
55
49
 
56
50
  | 能力维度 | 🤖 Bot 模式 | 🧩 Agent 模式 | ✨ **本插件 (双模)** |
57
- |:---|:---:|:---:|:---:|
58
- | **流式响应** | ✅ 原生支持 | 不支持 | **✅ 完美支持** |
59
- | **发送文件/图** | 不支持 | 支持 | **✅ 自动切换** |
60
- | **主动推送** | ❌ 仅回调 | ✅ 随时推送 | **✅ 完整 API** |
61
- | **Cronjob 定时** | 仅回调 | 支持 | **✅ 完美集成** |
62
- | **接收语音** | 转文字 | ✅ 语音+文字 | **✅ 双路处理** |
63
- | **群聊支持** | ✅ @即回 | ⚠️ 仅自建群 | **✅ 混合支持** |
51
+ |:---|:---|:---|:---|
52
+ | **接收消息 (单聊)** | ✅ 文本/图片/语音/文件 | 文本/图片/语音/视频/位置/链接 | **✅ 全能互补** (覆盖所有类型) |
53
+ | **接收消息 (群聊)** | 文本/引用 | 不支持 (无回调) | **✅ 文本/引用** |
54
+ | **发送消息** | ❌ 仅支持文本/图片/Markdown | ✅ **全格式支持** (文本/图片/视频/文件等) | **✅ 智能路由** (自动切换) |
55
+ | **流式响应** | **支持** (打字机效果) | 不支持 | **✅ 完美支持** |
56
+ | **主动推送** | 仅被动回复 | ✅ **支持** (指定用户/部门/标签) | **✅ 完整 API** |
64
57
 
65
58
  ---
66
59
 
@@ -89,8 +82,13 @@ openclaw config set channels.wecom.bot.receiveId ""
89
82
  openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
90
83
  openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
91
84
 
92
- # 不配置表示所有人可用,配置则进入白名单模式
93
- openclaw config set channels.wecom.bot.dm.allowFrom '[]'
85
+ # DM 门禁(推荐显式设置 policy)
86
+ # - open: 默认放开(所有人可用)
87
+ # - disabled: 全部禁用
88
+ # - allowlist: 仅 allowFrom 允许的人可用
89
+ openclaw config set channels.wecom.bot.dm.policy "open"
90
+ # policy=allowlist 时生效(例如只允许某些 userid;"*" 表示允许所有人)
91
+ openclaw config set channels.wecom.bot.dm.allowFrom '["*"]'
94
92
  ```
95
93
 
96
94
  ### 3. 配置 Agent 模式(自建应用,可选)
@@ -103,8 +101,8 @@ openclaw config set channels.wecom.agent.agentId 1000001
103
101
  openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
104
102
  openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
105
103
  openclaw config set channels.wecom.agent.welcomeText "欢迎使用智能助手"
106
- # 不配置表示所有人可用,配置则进入白名单模式
107
- openclaw config set channels.wecom.agent.dm.allowFrom '[]'
104
+ openclaw config set channels.wecom.agent.dm.policy "open"
105
+ openclaw config set channels.wecom.agent.dm.allowFrom '["*"]'
108
106
  ```
109
107
 
110
108
  ### 4. 高级网络配置 (公网出口代理)
@@ -317,9 +315,15 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
317
315
  **Q4: 群里 @机器人 发送文件失败?**
318
316
  > **A:** 因为企业微信 Bot 接口本身不支持发送非图片文件。我们的解决方案是:自动检测到文件发送需求后,改为通过 Agent 私信该用户发送文件,并在群里给出 "文件已私信发给您" 的提示。
319
317
 
320
- **Q5: Cronjob 定时任务怎么发给群?**
318
+ **Q5: 为什么在 Agent 模式下发送文件(如 PDF、Word)给机器人没有反应?**
319
+ > **A:** 这是由于企业微信官方接口限制。自建应用(Agent)的消息回调接口仅支持:文本、图片、语音、视频、位置和链接信息。**不支持**通用文件(File)类型的回调,因此插件无法感知您发送的文件。
320
+
321
+ **Q6: Cronjob 定时任务怎么发给群?**
321
322
  > **A:** Cronjob 必须走 Agent 通道(Bot 无法主动发消息)。您只需在配置中指定 `to: "party:1"` (部门) 或 `to: "group:wr123..."` (外部群),即可实现定时推送到群。
322
323
 
324
+ **Q7: 为什么发视频给 Bot 没反应?**
325
+ > **A:** 官方 Bot 接口**不支持接收视频**。如果您需要处理视频内容,请配置并使用 Agent 模式(Agent 支持接收视频)。
326
+
323
327
  ---
324
328
 
325
329
  ## 联系我
@@ -334,12 +338,26 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
334
338
 
335
339
  ## 更新日志
336
340
 
341
+ ### 2026.2.7
342
+
343
+ - ✨ **动态 Agent 路由**:新增按用户/群组隔离的动态路由功能,支持为每个对话主体自动分配独立的 Agent 实例(如 `wecom-dm-xxx`)。
344
+ - 🛠 **自动注册机制**:动态生成的 Agent 会自动异步注册到 `agents.list` 配置中,实现多租户 Agent 的无感扩容与管理。
345
+ - 🛡 **权限与兼容性**:支持 `adminUsers` 白名单绕过机制以使用主 Agent;功能默认关闭,确保对现有配置完全兼容。
346
+ - ⚙️ **精细化配置**:新增 `channels.wecom.dynamicAgents` 配置项,可独立控制私聊和群聊的动态路由行为。
347
+ - 🙏 **特别致谢**:感谢 [WhyMeta](https://github.com/WhyMeta) 开发者对动态路由实施方案的贡献。
348
+
349
+ ### 2026.2.5
350
+
351
+ - 🛠 **体验优化**:WeCom 媒体(图片/语音/视频/文件)处理的默认大小上限提升到 25MB,减少大文件因超限导致的“下载/保存失败”。
352
+ - 📌 **可配置提示**:若仍遇到 Media exceeds ... limit,日志/回复会提示通过 channels.wecom.media.maxBytes 调整上限,并给出可直接执行的 openclaw config set 示例命令。
353
+
337
354
  ### 2026.2.4
338
355
 
339
356
  - 🚀 **架构升级**:实施 "Bot 优先 + Agent 兜底" 策略,兼顾流式体验与长任务稳定性(6分钟切换)。
340
- - ✨ **全模态支持**:Agent 模式完整支持接收与发送图片、文件、语音、视频。
357
+ - ✨ **全模态支持**:Agent 模式完整支持接收图片/语音/视频(文件仅支持发送)。
341
358
  - ✨ **Cronjob 增强**:支持向部门 (`party:ID`) 和标签 (`tag:ID`) 广播消息。
342
359
  - 🛠 **Monitor 重构**:统一的消息防抖与流状态管理,提升并发稳定性。
360
+ - 🛠 **体验优化**:修复企微重试导致的重复回复(Bot/Agent 均做 `msgid` 去重);优化 Bot 连续多条消息的排队/合并回执,避免“重复同一答案”或“消息失败提示”。
343
361
  - 🐞 **修复**:Outbound ID 解析逻辑及 API 客户端参数缺失问题。
344
362
 
345
363
  ### 2026.2.3
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.2.4",
3
+ "version": "2.2.7",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "author": "YanHaidao (VX: YanHaidao)",
@@ -43,4 +43,4 @@
43
43
  "@types/node": "^25.2.0",
44
44
  "typescript": "^5.9.3"
45
45
  }
46
- }
46
+ }
@@ -0,0 +1,70 @@
1
+ import { wecomFetch } from "../src/http.js";
2
+
3
+ const args = process.argv.slice(2);
4
+ let proxyUrl = "";
5
+
6
+ if (args.includes("--host")) {
7
+ const hostIndex = args.indexOf("--host");
8
+ const portIndex = args.indexOf("--port");
9
+ const userIndex = args.indexOf("--user");
10
+ const passIndex = args.indexOf("--pass");
11
+
12
+ if (hostIndex === -1 || portIndex === -1) {
13
+ console.error("Error: --host and --port are required when using specific params.");
14
+ process.exit(1);
15
+ }
16
+
17
+ const host = args[hostIndex + 1];
18
+ const port = args[portIndex + 1];
19
+ const user = userIndex !== -1 ? args[userIndex + 1] : "";
20
+ const pass = passIndex !== -1 ? args[passIndex + 1] : "";
21
+
22
+ if (user && pass) {
23
+ // Safe encoding
24
+ proxyUrl = `http://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}`;
25
+ } else {
26
+ proxyUrl = `http://${host}:${port}`;
27
+ }
28
+ } else {
29
+ proxyUrl = args[0] || process.env.PROXY_URL || "";
30
+ }
31
+
32
+ if (!proxyUrl) {
33
+ console.error("Usage: npx tsx extensions/wecom/scripts/test-proxy.ts <proxy_url>");
34
+ console.error(" OR: npx tsx extensions/wecom/scripts/test-proxy.ts --host <ip> --port <port> --user <u?> --pass <p?>");
35
+ process.exit(1);
36
+ }
37
+
38
+ console.log(`Testing proxy: ${proxyUrl.replace(/:([^:@]+)@/, ":***@")}`); // Mask password in log
39
+
40
+ async function run() {
41
+ try {
42
+ // 1. Test IP echo to verify traffic goes through proxy
43
+ console.log("1. Checking IP via httpbin.org...");
44
+ const ipRes = await wecomFetch("https://httpbin.org/ip", {}, { proxyUrl, timeoutMs: 10000 });
45
+ if (!ipRes.ok) {
46
+ throw new Error(`IP check failed: ${ipRes.status} ${ipRes.statusText}`);
47
+ }
48
+ const ipJson = await ipRes.json();
49
+ console.log(" Result:", ipJson);
50
+
51
+ // 2. Test WeCom API connectivity
52
+ console.log("2. Checking WeCom connectivity...");
53
+ const wecomRes = await wecomFetch("https://qyapi.weixin.qq.com/cgi-bin/gettoken", {}, { proxyUrl, timeoutMs: 10000 });
54
+ const wecomJson = await wecomRes.json();
55
+ console.log(" Result:", wecomJson);
56
+ console.log("✅ Proxy works!");
57
+
58
+ } catch (err) {
59
+ // Extract cause for better debugging
60
+ const cause = (err as any).cause;
61
+ if (cause) {
62
+ console.error("❌ Proxy test failed (Cause):", cause);
63
+ } else {
64
+ console.error("❌ Proxy test failed:", err);
65
+ }
66
+ process.exit(1);
67
+ }
68
+ }
69
+
70
+ run();
@@ -118,11 +118,26 @@ export async function sendText(params: {
118
118
  headers: { "Content-Type": "application/json" },
119
119
  body: JSON.stringify(body),
120
120
  }, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
121
- const json = await res.json() as { errcode?: number; errmsg?: string };
121
+ const json = await res.json() as {
122
+ errcode?: number;
123
+ errmsg?: string;
124
+ invaliduser?: string;
125
+ invalidparty?: string;
126
+ invalidtag?: string;
127
+ };
122
128
 
123
129
  if (json?.errcode !== 0) {
124
130
  throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`);
125
131
  }
132
+
133
+ if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
134
+ const details = [
135
+ json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
136
+ json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
137
+ json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
138
+ ].filter(Boolean).join(", ");
139
+ throw new Error(`send partial failure: ${details}`);
140
+ }
126
141
  }
127
142
 
128
143
  /**
@@ -246,11 +261,26 @@ export async function sendMedia(params: {
246
261
  headers: { "Content-Type": "application/json" },
247
262
  body: JSON.stringify(body),
248
263
  }, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
249
- const json = await res.json() as { errcode?: number; errmsg?: string };
264
+ const json = await res.json() as {
265
+ errcode?: number;
266
+ errmsg?: string;
267
+ invaliduser?: string;
268
+ invalidparty?: string;
269
+ invalidtag?: string;
270
+ };
250
271
 
251
272
  if (json?.errcode !== 0) {
252
273
  throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
253
274
  }
275
+
276
+ if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
277
+ const details = [
278
+ json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
279
+ json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
280
+ json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
281
+ ].filter(Boolean).join(", ");
282
+ throw new Error(`send ${mediaType} partial failure: ${details}`);
283
+ }
254
284
  }
255
285
 
256
286
  /**
@@ -263,7 +293,8 @@ export async function sendMedia(params: {
263
293
  export async function downloadMedia(params: {
264
294
  agent: ResolvedAgentAccount;
265
295
  mediaId: string;
266
- }): Promise<{ buffer: Buffer; contentType: string }> {
296
+ maxBytes?: number;
297
+ }): Promise<{ buffer: Buffer; contentType: string; filename?: string }> {
267
298
  const { agent, mediaId } = params;
268
299
  const token = await getAccessToken(agent);
269
300
  const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
@@ -275,6 +306,24 @@ export async function downloadMedia(params: {
275
306
  }
276
307
 
277
308
  const contentType = res.headers.get("content-type") || "application/octet-stream";
309
+ const disposition = res.headers.get("content-disposition") || "";
310
+ const filename = (() => {
311
+ // 兼容:filename="a.md" / filename=a.md / filename*=UTF-8''a%2Eb.md
312
+ const mStar = disposition.match(/filename\*\s*=\s*([^;]+)/i);
313
+ if (mStar) {
314
+ const raw = mStar[1]!.trim().replace(/^"(.*)"$/, "$1");
315
+ const parts = raw.split("''");
316
+ const encoded = parts.length === 2 ? parts[1]! : raw;
317
+ try {
318
+ return decodeURIComponent(encoded);
319
+ } catch {
320
+ return encoded;
321
+ }
322
+ }
323
+ const m = disposition.match(/filename\s*=\s*([^;]+)/i);
324
+ if (!m) return undefined;
325
+ return m[1]!.trim().replace(/^"(.*)"$/, "$1") || undefined;
326
+ })();
278
327
 
279
328
  // 检查是否返回了错误 JSON
280
329
  if (contentType.includes("application/json")) {
@@ -282,6 +331,6 @@ export async function downloadMedia(params: {
282
331
  throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
283
332
  }
284
333
 
285
- const buffer = await readResponseBodyAsBuffer(res);
286
- return { buffer, contentType };
334
+ const buffer = await readResponseBodyAsBuffer(res, params.maxBytes);
335
+ return { buffer, contentType, filename };
287
336
  }