@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.
- package/.claude/settings.local.json +11 -0
- package/CLAUDE.md +238 -0
- package/README.md +37 -19
- package/assets/link-me.jpg +0 -0
- package/package.json +2 -2
- package/scripts/test-proxy.ts +70 -0
- package/src/agent/api-client.ts +54 -5
- package/src/agent/handler.ts +209 -23
- package/src/channel.ts +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/media.ts +14 -0
- package/src/config/schema.ts +17 -0
- package/src/dynamic-agent.ts +178 -0
- package/src/monitor/state.queue.test.ts +185 -0
- package/src/monitor/state.ts +179 -19
- package/src/monitor/types.ts +8 -0
- package/src/monitor.ts +227 -58
- package/src/monitor.webhook.test.ts +83 -1
- package/src/outbound.test.ts +43 -0
- package/src/outbound.ts +31 -2
- package/src/shared/command-auth.ts +101 -0
- package/src/shared/xml-parser.test.ts +30 -0
- package/src/shared/xml-parser.ts +105 -7
- package/src/target.ts +1 -1
- package/src/types/config.ts +14 -0
- package/src/types/message.ts +2 -0
- package//345/212/250/346/200/201Agent/350/267/257/347/224/261.md +360 -0
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
|
-
|
|
|
61
|
-
|
|
|
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
|
-
|
|
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:
|
|
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
|
package/assets/link-me.jpg
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.2.
|
|
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();
|
package/src/agent/api-client.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|