@yanhaidao/wecom 2.0.1 → 2.2.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 CHANGED
@@ -1,121 +1,247 @@
1
1
  # OpenClaw 企业微信(WeCom)Channel 插件
2
2
 
3
- 维护者:YanHaidao(VX:YanHaidao)
3
+ <p align="center">
4
+ <strong>🚀 企业级双模式 AI 助手接入方案</strong>
5
+ </p>
4
6
 
5
- 状态:支持企业微信智能机器人(API 模式)加密回调 + 被动回复(stream)。
7
+ <p align="center">
8
+ <a href="#功能亮点">功能亮点</a> •
9
+ <a href="#模式对比">模式对比</a> •
10
+ <a href="#快速开始">快速开始</a> •
11
+ <a href="#配置说明">配置说明</a> •
12
+ <a href="#联系我">联系我</a>
13
+ </p>
6
14
 
7
- ## 联系我
15
+ ---
8
16
 
9
- 微信交流群(扫码入群):
17
+ ## 🚀 全网首发 · 功能全面 —— 功能亮点
10
18
 
11
- ![企业微信交流群](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/link-me.jpg)
19
+ 本插件提供**完整支持企业微信双模式(Bot + Agent)**的深度集成方案。相比目前其他的开源方案,我们提供企业级生产环境所需的全部特性:
20
+
21
+ | 核心特性 | 本插件 | 其他开源方案 | 优势说明 |
22
+ |:---|:---:|:---:|:---|
23
+ | 🔥 **双模式并行** | ✅ **完美支持** | ❌ 仅支持单模式 | 同时使用 Bot 的便捷与 Agent 的强大能力 |
24
+ | ⚡ **原生流式回复** | ✅ **Bot/Agent 全支持** | ❌ 伪流式/不支持 | 真实打字机效果,告别长时间转圈等待 |
25
+ | 📡 **主动消息推送** | ✅ **支持** | ❌ 仅被动回复 | 可随时通过 API 发送消息,脱离回调限制 |
26
+ | 🔐 **双协议加密** | ✅ **JSON + XML** | ⚠️ 部分支持 | 完整兼容企微新旧两种加密标准,安全无忧 |
27
+ | 📎 **全媒体处理** | ✅ **图片/语音/文件/视频** | ⚠️ 仅文本/图片 | 自动解密下载媒体文件,语音自动转文字 |
28
+ | 🎴 **交互式卡片** | ✅ **Template Card** | ❌ 不支持 | 支持按钮交互回调,打造复杂业务流 |
29
+ | 🔄 **Token 自运维** | ✅ **自动缓存刷新** | ❌ 需手工处理 | 内置 AccessToken 管理器,故障自动重试 |
12
30
 
13
- ## 文件与图片入模(说明)
14
31
 
15
- 图片/文件 URL 下载内容为加密数据,需使用 `EncodingAESKey` 解密后再解析并入模。
16
32
 
17
- ## 测试页截图(文件上传 / 解析)
33
+ <div align="center">
34
+ <img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg" width="45%" />
35
+ <img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/02.image.jpg" width="45%" />
36
+ </div>
18
37
 
19
- > 图片过大可替换为压缩版(保持文件名不变即可)。
20
38
 
21
- ![WeCom 测试页截图(文件上传 / 解析)](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg)
22
39
 
23
- ## A2UI 交互卡片(template_card)
40
+ ---
24
41
 
25
- - Agent 输出 `{"template_card": ...}`(JSON)时:单聊且有 `response_url` 会发送交互卡片;群聊或无 `response_url` 自动降级为文本说明(不透出原始 JSON)。
26
- - 收到 `template_card_event` 时:会转换为伪文本消息触发 Agent,并基于 `msgid` 去重避免重复处理。
27
- - 卡片相关的示例/skill:加群获取(见上方交流群二维码)。
42
+ ## 模式对比
28
43
 
29
- ## 安装
44
+ ### Bot vs Agent 你该选哪个?
45
+
46
+ | 维度 | Bot 模式(智能体) | Agent 模式(自建应用) |
47
+ |:---|:---|:---|
48
+ | **接入方式** | 企微后台「智能机器人」 | 企微后台「自建应用」 |
49
+ | **回调格式** | JSON 加密 | XML 加密 |
50
+ | **回复机制** | response_url 被动回复 | API 主动发送 |
51
+ | **流式支持** | ✅ 原生 stream 刷新 | ❌ 模拟分段 |
52
+ | **主动推送** | ❌ 无法脱离回调 | ✅ 任意时机发送 |
53
+ | **媒体能力** | 受限(URL 方式) | 完整(media_id) |
54
+ | **被动回复图片** | ✅ 已实现 | ✅ 已实现 |
55
+ | **Outbound 发图片** | ❌ API 不支持 | ✅ 已实现 |
56
+ | **Outbound 发文本** | ❌ API 不支持 | ✅ 已实现 |
57
+ | **适用场景** | 快速体验、轻量对话 | 企业级部署、业务集成 |
58
+
59
+ > 💡 **推荐配置**:两种模式可同时启用!Bot 用于日常快速对话,Agent 用于主动通知和媒体发送。
60
+
61
+ ---
62
+
63
+ ## 快速开始
64
+
65
+ ### 1. 安装插件
30
66
 
31
- ### 从 npm 安装
32
67
  ```bash
33
68
  openclaw plugins install @yanhaidao/wecom
34
69
  openclaw plugins enable wecom
70
+ ```
71
+
72
+ 也可以通过命令行向导快速配置:
73
+
74
+ ```bash
75
+ openclaw config --section channels
76
+ ```
77
+
78
+ ### 2. 配置 Bot 模式(智能体)
79
+
80
+ ```bash
81
+ openclaw config set channels.wecom.enabled true
82
+ openclaw config set channels.wecom.bot.token "YOUR_BOT_TOKEN"
83
+ openclaw config set channels.wecom.bot.encodingAESKey "YOUR_BOT_AES_KEY"
84
+ openclaw config set channels.wecom.bot.receiveId ""
85
+ openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
86
+ openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
87
+ # 不配置表示所有人可用,配置则进入白名单模式
88
+ openclaw config set channels.wecom.bot.dm.allowFrom '["user1", "user2"]'
89
+ ```
90
+
91
+ ### 3. 配置 Agent 模式(自建应用,可选)
92
+
93
+ ```bash
94
+ openclaw config set channels.wecom.enabled true
95
+ openclaw config set channels.wecom.agent.corpId "YOUR_CORP_ID"
96
+ openclaw config set channels.wecom.agent.corpSecret "YOUR_CORP_SECRET"
97
+ openclaw config set channels.wecom.agent.agentId 1000001
98
+ openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
99
+ openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
100
+ openclaw config set channels.wecom.agent.welcomeText "欢迎使用智能助手"
101
+ openclaw config set channels.wecom.agent.dm.allowFrom '["user1", "user2"]'
102
+ ```
103
+
104
+ ### 4. 验证
105
+
106
+ ```bash
35
107
  openclaw gateway restart
108
+ openclaw channels status
36
109
  ```
37
110
 
38
- ## 配置结构参考
111
+ ---
112
+
113
+ ## 配置说明
39
114
 
40
- ```json
115
+ ### 完整配置结构
116
+
117
+ ```jsonc
41
118
  {
42
119
  "channels": {
43
120
  "wecom": {
44
121
  "enabled": true,
45
- "webhookPath": "/wecom",
46
- "token": "YOUR_TOKEN",
47
- "encodingAESKey": "YOUR_ENCODING_AES_KEY",
48
- "receiveId": "",
49
- "streamPlaceholderContent": "正在思考...",
50
- "dm": { "policy": "pairing" }
122
+
123
+ // Bot 模式配置(智能体)
124
+ "bot": {
125
+ "token": "YOUR_BOT_TOKEN",
126
+ "encodingAESKey": "YOUR_BOT_AES_KEY",
127
+ "receiveId": "", // 可选,用于解密校验
128
+ "streamPlaceholderContent": "正在思考...",
129
+ "welcomeText": "你好!我是 AI 助手",
130
+ "dm": { "allowFrom": [] } // 私聊限制
131
+ },
132
+
133
+ // Agent 模式配置(自建应用)
134
+ "agent": {
135
+ "corpId": "YOUR_CORP_ID",
136
+ "corpSecret": "YOUR_CORP_SECRET",
137
+ "agentId": 1000001,
138
+ "token": "YOUR_CALLBACK_TOKEN", // 企微后台「设置API接收」
139
+ "encodingAESKey": "YOUR_CALLBACK_AES_KEY",
140
+ "welcomeText": "欢迎使用智能助手",
141
+ "dm": { "allowFrom": [] }
142
+ }
51
143
  }
52
144
  }
53
145
  }
54
146
  ```
55
147
 
56
- ## 接入企业微信
148
+ ### Webhook 路径(固定)
57
149
 
58
- ### 搭建企微 Bot(API 模式)
150
+ | 模式 | 路径 | 说明 |
151
+ |:---|:---|:---|
152
+ | Bot | `/wecom/bot` | 智能体回调 |
153
+ | Agent | `/wecom/agent` | 自建应用回调 |
59
154
 
60
- 1. 登录企业微信管理后台
61
- 进入「安全与管理」→「管理工具」→「智能机器人」:`https://work.weixin.qq.com/wework_admin/frame#/manageTools`
155
+ ### DM 策略
62
156
 
63
- 2. 创建机器人(务必选择 API 模式)
64
- 创建机器人时需要填写回调 URL(公网可访问的 HTTPS 地址),例如:`https://example.com/wecom`
157
+ - **不配置 `dm.allowFrom`** 所有人可用(默认)
158
+ - **配置 `dm.allowFrom: ["user1", "user2"]`** → 白名单模式,仅列表内用户可私聊
65
159
 
66
- 3. 记录机器人配置
67
- 在机器人详情里找到并保存以下信息,后续会写入 OpenClaw 配置:
68
- - Token
69
- - EncodingAESKey
70
- - ReceiveId(如果你的机器人/回调配置需要校验的话)
160
+ ### 常用指令
71
161
 
72
- ## 快速开始
162
+ | 指令 | 说明 | 示例 |
163
+ |:---|:---|:---|
164
+ | `/new` | 🆕 开启新会话 (重置上下文) | `/new` 或 `/new GPT-4` |
165
+ | `/reset` | 🔄 重置会话 (同 /new) | `/reset` |
73
166
 
74
- 1. 启用插件
75
- ```bash
76
- openclaw plugins enable wecom
77
- ```
167
+ ---
78
168
 
79
- 2. 配置企业微信机器人(必需)
80
- ```bash
81
- openclaw config set channels.wecom.enabled true
82
- openclaw config set channels.wecom.webhookPath "/wecom"
83
- openclaw config set channels.wecom.token "YOUR_TOKEN"
84
- openclaw config set channels.wecom.encodingAESKey "YOUR_ENCODING_AES_KEY"
85
- openclaw config set channels.wecom.receiveId ""
86
- ```
169
+ ## 企业微信接入指南
87
170
 
88
- 3. 配置 Gateway(示例)
89
- ```bash
90
- openclaw config set gateway.mode "local"
91
- openclaw config set gateway.bind "0.0.0.0"
92
- openclaw config set gateway.port 18789
93
- ```
171
+ ### Bot 模式(智能机器人)
94
172
 
95
- 4. 重启 Gateway
96
- ```bash
97
- openclaw gateway restart
98
- ```
173
+ 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#/manageTools)
174
+ 2. 进入「安全与管理」→「管理工具」→「智能机器人」
175
+ 3. 创建机器人,选择 **API 模式**
176
+ 4. 填写回调 URL:`https://your-domain.com/wecom/bot`
177
+ 5. 记录 Token 和 EncodingAESKey
99
178
 
100
- 5. 验证
101
- ```bash
102
- openclaw channels status
103
- ```
179
+ ### Agent 模式(自建应用)
180
+
181
+ 1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/frame#/apps)
182
+ 2. 进入「应用管理」→「自建」→ 创建应用
183
+ 3. 获取 AgentId、CorpId、Secret
184
+ 4. **重要:** 进入「企业可信IP」→「配置」→ 添加你服务器的 IP 地址
185
+ 5. 在应用详情中设置「接收消息 - 设置API接收」
186
+ 6. 填写回调 URL:`https://your-domain.com/wecom/agent`
187
+ 7. 记录回调 Token 和 EncodingAESKey
188
+
189
+ ---
104
190
 
105
- ## 说明
191
+ ## 高级功能
192
+
193
+ ### A2UI 交互卡片
194
+
195
+ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
196
+
197
+ - ✅ 单聊场景:发送真实交互卡片
198
+ - ✅ 按钮点击:触发 `template_card_event` 回调
199
+ - ✅ 自动去重:基于 `msgid` 避免重复处理
200
+ - ⚠️ 群聊降级:自动转为文本描述
201
+
202
+ ### 富媒体处理
203
+
204
+ | 类型 | Bot 模式 | Agent 模式 |
205
+ |:---|:---|:---|
206
+ | 图片 | ✅ URL 解密入模 | ✅ media_id 下载 |
207
+ | 文件 | ✅ URL 解密入模 | ✅ media_id 下载 |
208
+ | 语音 | ✅ 转文字入模 | ✅ 识别结果 + 原始音频 |
209
+ | 视频 | ❌ | ✅ media_id 下载 |
210
+
211
+ ### DM 策略
212
+
213
+ - **不配置 `dm.allowFrom`** → 所有人可用(默认)
214
+ - **配置 `dm.allowFrom: ["user1", "user2"]`** → 白名单模式,仅列表内用户可私聊
215
+
216
+ ---
217
+
218
+ ## 联系我
219
+
220
+ 微信交流群(扫码入群):
221
+
222
+ ![企业微信交流群](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/link-me.jpg)
223
+
224
+ 维护者:YanHaidao(VX:YanHaidao)
106
225
 
107
- - webhook 必须是公网 HTTPS。出于安全考虑,建议只对外暴露 `/wecom` 路径。
108
- - stream 模式:第一次回包可能是占位符;随后 WeCom 会以 `msgtype=stream` 回调刷新拉取完整内容。
109
- - 限制:仅支持被动回复,不支持脱离回调的主动发送。
226
+ ---
110
227
 
228
+ ## 更新日志
111
229
 
230
+ ### 2026.2.3
112
231
 
113
- # 更新日志
232
+ - 🎉 **重大更新**:新增 Agent 模式(自建应用)支持
233
+ - ✨ 双模式并行:Bot + Agent 可同时运行
234
+ - ✨ AccessToken 自动管理:缓存 + 智能刷新
235
+ - ✨ Agent 主动推送:脱离回调限制
236
+ - ✨ XML 加解密:完整 Agent 回调支持
237
+ - 📁 代码重构:模块化解耦设计
114
238
 
115
- ## 2026.1.31
239
+ ### 2026.1.31
116
240
 
117
- - 文档:补充入模与测试截图说明。
241
+ - 文档:补充入模与测试截图说明
242
+ - 新增文件支持
243
+ - 新增卡片支持
118
244
 
119
- ## 2026.1.30
245
+ ### 2026.1.30
120
246
 
121
- - 项目更名:Clawdbot → OpenClaw(CLI:`clawdbot` → `openclaw`)。
247
+ - 项目更名:Clawdbot → OpenClaw
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.0.1",
3
+ "version": "2.2.3",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "author": "YanHaidao (VX: YanHaidao)",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "axios": "^1.13.4",
36
+ "fast-xml-parser": "5.3.4",
36
37
  "zod": "^4.3.6"
37
38
  },
38
39
  "peerDependencies": {
@@ -0,0 +1,225 @@
1
+ /**
2
+ * WeCom Agent API 客户端
3
+ * 管理 AccessToken 缓存和 API 调用
4
+ */
5
+
6
+ import crypto from "node:crypto";
7
+ import { API_ENDPOINTS, LIMITS } from "../types/constants.js";
8
+ import type { ResolvedAgentAccount } from "../types/index.js";
9
+
10
+ type TokenCache = {
11
+ token: string;
12
+ expiresAt: number;
13
+ refreshPromise: Promise<string> | null;
14
+ };
15
+
16
+ const tokenCaches = new Map<string, TokenCache>();
17
+
18
+ /**
19
+ * 获取 AccessToken (带缓存)
20
+ */
21
+ export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
22
+ const cacheKey = `${agent.corpId}:${agent.agentId}`;
23
+ let cache = tokenCaches.get(cacheKey);
24
+
25
+ if (!cache) {
26
+ cache = { token: "", expiresAt: 0, refreshPromise: null };
27
+ tokenCaches.set(cacheKey, cache);
28
+ }
29
+
30
+ const now = Date.now();
31
+ if (cache.token && cache.expiresAt > now + LIMITS.TOKEN_REFRESH_BUFFER_MS) {
32
+ return cache.token;
33
+ }
34
+
35
+ // 防止并发刷新
36
+ if (cache.refreshPromise) {
37
+ return cache.refreshPromise;
38
+ }
39
+
40
+ cache.refreshPromise = (async () => {
41
+ try {
42
+ const url = `${API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
43
+ const res = await fetch(url, { signal: AbortSignal.timeout(LIMITS.REQUEST_TIMEOUT_MS) });
44
+ const json = await res.json() as { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string };
45
+
46
+ if (!json?.access_token) {
47
+ throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`);
48
+ }
49
+
50
+ cache!.token = json.access_token;
51
+ cache!.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
52
+ return cache!.token;
53
+ } finally {
54
+ cache!.refreshPromise = null;
55
+ }
56
+ })();
57
+
58
+ return cache.refreshPromise;
59
+ }
60
+
61
+ /**
62
+ * 发送文本消息
63
+ */
64
+ export async function sendText(params: {
65
+ agent: ResolvedAgentAccount;
66
+ toUser?: string;
67
+ chatId?: string;
68
+ text: string;
69
+ }): Promise<void> {
70
+ const { agent, toUser, chatId, text } = params;
71
+ const token = await getAccessToken(agent);
72
+
73
+ const useChat = Boolean(chatId);
74
+ const url = useChat
75
+ ? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
76
+ : `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
77
+
78
+ const body = useChat
79
+ ? { chatid: chatId, msgtype: "text", text: { content: text } }
80
+ : { touser: toUser, msgtype: "text", agentid: agent.agentId, text: { content: text } };
81
+
82
+ const res = await fetch(url, {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify(body),
86
+ signal: AbortSignal.timeout(LIMITS.REQUEST_TIMEOUT_MS),
87
+ });
88
+ const json = await res.json() as { errcode?: number; errmsg?: string };
89
+
90
+ if (json?.errcode !== 0) {
91
+ throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 上传媒体文件
97
+ */
98
+ export async function uploadMedia(params: {
99
+ agent: ResolvedAgentAccount;
100
+ type: "image" | "voice" | "video" | "file";
101
+ buffer: Buffer;
102
+ filename: string;
103
+ }): Promise<string> {
104
+ const { agent, type, buffer, filename } = params;
105
+ const token = await getAccessToken(agent);
106
+ // 添加 debug=1 参数获取更多错误信息
107
+ const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
108
+
109
+ // DEBUG: 输出上传信息
110
+ console.log(`[wecom-upload] Uploading media: type=${type}, filename=${filename}, size=${buffer.length} bytes`);
111
+
112
+ // 手动构造 multipart/form-data 请求体
113
+ // 企业微信要求包含 filename 和 filelength
114
+ const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
115
+
116
+ // 根据文件类型设置 Content-Type
117
+ const contentTypeMap: Record<string, string> = {
118
+ jpg: "image/jpg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
119
+ bmp: "image/bmp", amr: "voice/amr", mp4: "video/mp4",
120
+ };
121
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
122
+ const fileContentType = contentTypeMap[ext] || "application/octet-stream";
123
+
124
+ // 构造 multipart body
125
+ const header = Buffer.from(
126
+ `--${boundary}\r\n` +
127
+ `Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
128
+ `Content-Type: ${fileContentType}\r\n\r\n`
129
+ );
130
+ const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
131
+ const body = Buffer.concat([header, buffer, footer]);
132
+
133
+ console.log(`[wecom-upload] Multipart body size=${body.length}, boundary=${boundary}, fileContentType=${fileContentType}`);
134
+
135
+ const res = await fetch(url, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
139
+ "Content-Length": String(body.length),
140
+ },
141
+ body: body,
142
+ signal: AbortSignal.timeout(LIMITS.REQUEST_TIMEOUT_MS),
143
+ });
144
+ const json = await res.json() as { media_id?: string; errcode?: number; errmsg?: string };
145
+
146
+ // DEBUG: 输出完整响应
147
+ console.log(`[wecom-upload] Response:`, JSON.stringify(json));
148
+
149
+ if (!json?.media_id) {
150
+ throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`);
151
+ }
152
+ return json.media_id;
153
+ }
154
+
155
+ /**
156
+ * 发送媒体消息
157
+ */
158
+ export async function sendMedia(params: {
159
+ agent: ResolvedAgentAccount;
160
+ toUser?: string;
161
+ chatId?: string;
162
+ mediaId: string;
163
+ mediaType: "image" | "voice" | "video" | "file";
164
+ title?: string;
165
+ description?: string;
166
+ }): Promise<void> {
167
+ const { agent, toUser, chatId, mediaId, mediaType, title, description } = params;
168
+ const token = await getAccessToken(agent);
169
+
170
+ const useChat = Boolean(chatId);
171
+ const url = useChat
172
+ ? `${API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
173
+ : `${API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
174
+
175
+ const mediaPayload = mediaType === "video"
176
+ ? { media_id: mediaId, title: title ?? "Video", description: description ?? "" }
177
+ : { media_id: mediaId };
178
+
179
+ const body = useChat
180
+ ? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
181
+ : { touser: toUser, msgtype: mediaType, agentid: agent.agentId, [mediaType]: mediaPayload };
182
+
183
+ const res = await fetch(url, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify(body),
187
+ signal: AbortSignal.timeout(LIMITS.REQUEST_TIMEOUT_MS),
188
+ });
189
+ const json = await res.json() as { errcode?: number; errmsg?: string };
190
+
191
+ if (json?.errcode !== 0) {
192
+ throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 下载媒体文件
198
+ */
199
+ export async function downloadMedia(params: {
200
+ agent: ResolvedAgentAccount;
201
+ mediaId: string;
202
+ }): Promise<{ buffer: Buffer; contentType: string }> {
203
+ const { agent, mediaId } = params;
204
+ const token = await getAccessToken(agent);
205
+ const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
206
+
207
+ const res = await fetch(url, {
208
+ signal: AbortSignal.timeout(LIMITS.REQUEST_TIMEOUT_MS),
209
+ });
210
+
211
+ if (!res.ok) {
212
+ throw new Error(`download failed: ${res.status}`);
213
+ }
214
+
215
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
216
+
217
+ // 检查是否返回了错误 JSON
218
+ if (contentType.includes("application/json")) {
219
+ const json = await res.json() as { errcode?: number; errmsg?: string };
220
+ throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
221
+ }
222
+
223
+ const buffer = Buffer.from(await res.arrayBuffer());
224
+ return { buffer, contentType };
225
+ }