@yanhaidao/wecom 2.2.3 → 2.2.4

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.
@@ -3,19 +3,33 @@
3
3
  * 处理 XML 格式回调
4
4
  */
5
5
 
6
+ import { pathToFileURL } from "node:url";
6
7
  import type { IncomingMessage, ServerResponse } from "node:http";
7
8
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
8
9
  import type { ResolvedAgentAccount } from "../types/index.js";
9
10
  import { LIMITS } from "../types/constants.js";
10
11
  import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
11
12
  import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
12
- import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId } from "../shared/xml-parser.js";
13
- import { sendText } from "./api-client.js";
13
+ import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId } from "../shared/xml-parser.js";
14
+ import { sendText, downloadMedia } from "./api-client.js";
14
15
  import { getWecomRuntime } from "../runtime.js";
16
+ import type { WecomAgentInboundMessage } from "../types/index.js";
15
17
 
16
18
  /** 错误提示信息 */
17
19
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
18
20
 
21
+ /**
22
+ * **AgentWebhookParams (Webhook 处理器参数)**
23
+ *
24
+ * 传递给 Agent Webhook 处理函数的上下文参数集合。
25
+ * @property req Node.js 原始请求对象
26
+ * @property res Node.js 原始响应对象
27
+ * @property agent 解析后的 Agent 账号信息
28
+ * @property config 全局插件配置
29
+ * @property core OpenClaw 插件运行时
30
+ * @property log 可选日志输出函数
31
+ * @property error 可选错误输出函数
32
+ */
19
33
  export type AgentWebhookParams = {
20
34
  req: IncomingMessage;
21
35
  res: ServerResponse;
@@ -27,7 +41,9 @@ export type AgentWebhookParams = {
27
41
  };
28
42
 
29
43
  /**
30
- * 解析查询参数
44
+ * **resolveQueryParams (解析查询参数)**
45
+ *
46
+ * 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
31
47
  */
32
48
  function resolveQueryParams(req: IncomingMessage): URLSearchParams {
33
49
  const url = new URL(req.url ?? "/", "http://localhost");
@@ -35,7 +51,10 @@ function resolveQueryParams(req: IncomingMessage): URLSearchParams {
35
51
  }
36
52
 
37
53
  /**
38
- * 读取原始请求体
54
+ * **readRawBody (读取原始请求体)**
55
+ *
56
+ * 异步读取 HTTP POST 请求的原始 BODY 数据(XML 字符串)。
57
+ * 包含最大体积限制检查,防止内存溢出攻击。
39
58
  */
40
59
  async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_REQUEST_BODY_SIZE): Promise<string> {
41
60
  return new Promise((resolve, reject) => {
@@ -61,7 +80,13 @@ async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_RE
61
80
  }
62
81
 
63
82
  /**
64
- * 处理 URL 验证 (GET)
83
+ * **handleUrlVerification (处理 URL 验证)**
84
+ *
85
+ * 处理企业微信 Agent 配置时的 GET 请求验证。
86
+ * 流程:
87
+ * 1. 验证 msg_signature 签名。
88
+ * 2. 解密 echostr 参数。
89
+ * 3. 返回解密后的明文 echostr。
65
90
  */
66
91
  async function handleUrlVerification(
67
92
  req: IncomingMessage,
@@ -168,6 +193,7 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
168
193
  chatId,
169
194
  msgType,
170
195
  content,
196
+ msg,
171
197
  log,
172
198
  error,
173
199
  }).catch((err) => {
@@ -185,7 +211,15 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
185
211
  }
186
212
 
187
213
  /**
188
- * 处理 Agent 消息 (调用 OpenClaw Agent)
214
+ * **processAgentMessage (处理 Agent 消息)**
215
+ *
216
+ * 异步处理解密后的消息内容,并触发 OpenClaw Agent。
217
+ * 流程:
218
+ * 1. 路由解析:根据 userid或群ID 确定 Agent 路由。
219
+ * 2. 媒体处理:如果是图片/文件等,下载资源。
220
+ * 3. 上下文构建:创建 Inbound Context。
221
+ * 4. 会话记录:更新 Session 状态。
222
+ * 5. 调度回复:将 Agent 的响应通过 `api-client` 发送回企业微信。
189
223
  */
190
224
  async function processAgentMessage(params: {
191
225
  agent: ResolvedAgentAccount;
@@ -195,14 +229,67 @@ async function processAgentMessage(params: {
195
229
  chatId?: string;
196
230
  msgType: string;
197
231
  content: string;
232
+ msg: WecomAgentInboundMessage;
198
233
  log?: (msg: string) => void;
199
234
  error?: (msg: string) => void;
200
235
  }): Promise<void> {
201
- const { agent, config, core, fromUser, chatId, content, log, error } = params;
236
+ const { agent, config, core, fromUser, chatId, content, msg, msgType, log, error } = params;
202
237
 
203
238
  const isGroup = Boolean(chatId);
204
239
  const peerId = isGroup ? chatId! : fromUser;
205
240
 
241
+ // 处理媒体文件
242
+ const attachments: any[] = []; // TODO: define specific type
243
+ let finalContent = content;
244
+ let mediaPath: string | undefined;
245
+ let mediaType: string | undefined;
246
+
247
+ if (["image", "voice", "video", "file"].includes(msgType)) {
248
+ const mediaId = extractMediaId(msg);
249
+ if (mediaId) {
250
+ try {
251
+ log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
252
+ const { buffer, contentType } = await downloadMedia({ agent, mediaId });
253
+
254
+ // 推断文件名后缀
255
+ const extMap: Record<string, string> = {
256
+ "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
257
+ "audio/amr": "amr", "audio/speex": "speex", "video/mp4": "mp4",
258
+ };
259
+ const ext = extMap[contentType] || "bin";
260
+ const filename = `${mediaId}.${ext}`;
261
+
262
+ // 使用 Core SDK 保存媒体文件
263
+ const saved = await core.channel.media.saveMediaBuffer(
264
+ buffer,
265
+ contentType,
266
+ "inbound", // context/scope
267
+ LIMITS.MAX_REQUEST_BODY_SIZE, // limit
268
+ filename
269
+ );
270
+
271
+ log?.(`[wecom-agent] media saved to: ${saved.path}`);
272
+ mediaPath = saved.path;
273
+ mediaType = contentType;
274
+
275
+ // 构建附件
276
+ attachments.push({
277
+ name: filename,
278
+ mimeType: contentType,
279
+ url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
280
+ });
281
+
282
+ // 更新文本提示
283
+ finalContent = `${content} (已下载 ${buffer.length} 字节)`;
284
+ } catch (err) {
285
+ error?.(`[wecom-agent] media download failed: ${String(err)}`);
286
+ finalContent = `${content} (媒体下载失败)`;
287
+ }
288
+ } else {
289
+ error?.(`[wecom-agent] mediaId not found for ${msgType}`);
290
+ }
291
+ }
292
+
206
293
  // 解析路由
207
294
  const route = core.channel.routing.resolveAgentRoute({
208
295
  cfg: config,
@@ -226,13 +313,14 @@ async function processAgentMessage(params: {
226
313
  from: fromLabel,
227
314
  previousTimestamp,
228
315
  envelope: envelopeOptions,
229
- body: content,
316
+ body: finalContent,
230
317
  });
231
318
 
232
319
  const ctxPayload = core.channel.reply.finalizeInboundContext({
233
320
  Body: body,
234
- RawBody: content,
235
- CommandBody: content,
321
+ RawBody: finalContent,
322
+ CommandBody: finalContent,
323
+ Attachments: attachments.length > 0 ? attachments : undefined,
236
324
  From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
237
325
  To: `wecom:${peerId}`,
238
326
  SessionKey: route.sessionKey,
@@ -246,6 +334,9 @@ async function processAgentMessage(params: {
246
334
  OriginatingChannel: "wecom",
247
335
  OriginatingTo: `wecom:${peerId}`,
248
336
  CommandAuthorized: true, // 已通过 WeCom 签名验证
337
+ MediaPath: mediaPath,
338
+ MediaType: mediaType,
339
+ MediaUrl: mediaPath,
249
340
  });
250
341
 
251
342
  // 记录会话
@@ -290,7 +381,10 @@ async function processAgentMessage(params: {
290
381
  }
291
382
 
292
383
  /**
293
- * 处理 Agent Webhook 请求入口
384
+ * **handleAgentWebhook (Agent Webhook 入口)**
385
+ *
386
+ * 统一处理 Agent 模式的 Webhook 请求。
387
+ * 根据 HTTP 方法分发到 URL 验证 (GET) 或 消息处理 (POST)。
294
388
  */
295
389
  export async function handleAgentWebhook(params: AgentWebhookParams): Promise<boolean> {
296
390
  const { req } = params;
package/src/channel.ts CHANGED
@@ -43,6 +43,12 @@ type ResolvedWecomAccount = {
43
43
  agent?: ResolvedAgentAccount;
44
44
  };
45
45
 
46
+ /**
47
+ * **resolveWecomAccount (解析账号配置)**
48
+ *
49
+ * 从全局配置中解析出 WeCom 渠道的配置状态。
50
+ * 兼容 Bot 和 Agent 两种模式的配置检查。
51
+ */
46
52
  function resolveWecomAccount(cfg: OpenClawConfig): ResolvedWecomAccount {
47
53
  const enabled = (cfg.channels?.wecom as { enabled?: boolean } | undefined)?.enabled !== false;
48
54
  const accounts = resolveWecomAccounts(cfg);
@@ -169,6 +175,17 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
169
175
  }),
170
176
  },
171
177
  gateway: {
178
+ /**
179
+ * **startAccount (启动账号)**
180
+ *
181
+ * 插件生命周期:启动
182
+ * 职责:
183
+ * 1. 检查配置是否有效。
184
+ * 2. 注册 Bot Webhook (`/wecom`, `/wecom/bot`)。
185
+ * 3. 注册 Agent Webhook (`/wecom/agent`)。
186
+ * 4. 更新运行时状态 (Running)。
187
+ * 5. 返回停止回调 (Cleanup)。
188
+ */
172
189
  startAccount: async (ctx) => {
173
190
  const account = ctx.account;
174
191
  const bot = account.bot;
@@ -7,6 +7,7 @@ import type {
7
7
  WecomConfig,
8
8
  WecomBotConfig,
9
9
  WecomAgentConfig,
10
+ WecomNetworkConfig,
10
11
  ResolvedBotAccount,
11
12
  ResolvedAgentAccount,
12
13
  ResolvedMode,
@@ -50,7 +51,7 @@ function resolveBotAccount(config: WecomBotConfig): ResolvedBotAccount {
50
51
  /**
51
52
  * 解析 Agent 模式账号
52
53
  */
53
- function resolveAgentAccount(config: WecomAgentConfig): ResolvedAgentAccount {
54
+ function resolveAgentAccount(config: WecomAgentConfig, network?: WecomNetworkConfig): ResolvedAgentAccount {
54
55
  const agentIdRaw = config.agentId;
55
56
  const agentId = typeof agentIdRaw === "number" ? agentIdRaw : Number(agentIdRaw);
56
57
 
@@ -67,6 +68,7 @@ function resolveAgentAccount(config: WecomAgentConfig): ResolvedAgentAccount {
67
68
  token: config.token,
68
69
  encodingAESKey: config.encodingAESKey,
69
70
  config,
71
+ network,
70
72
  };
71
73
  }
72
74
 
@@ -83,8 +85,8 @@ export function resolveWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccounts
83
85
  const mode = detectMode(wecom);
84
86
 
85
87
  return {
86
- bot: mode.bot && wecom.bot ? resolveBotAccount(wecom.bot) : undefined,
87
- agent: mode.agent && wecom.agent ? resolveAgentAccount(wecom.agent) : undefined,
88
+ bot: mode.bot && wecom.bot ? { ...resolveBotAccount(wecom.bot), network: wecom.network } : undefined,
89
+ agent: mode.agent && wecom.agent ? resolveAgentAccount(wecom.agent, wecom.network) : undefined,
88
90
  };
89
91
  }
90
92
 
@@ -8,3 +8,4 @@ export {
8
8
  resolveWecomAccounts,
9
9
  isWecomEnabled,
10
10
  } from "./accounts.js";
11
+ export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
@@ -0,0 +1,16 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+
3
+ import type { WecomConfig, WecomNetworkConfig } from "../types/index.js";
4
+
5
+ export function resolveWecomEgressProxyUrlFromNetwork(network?: WecomNetworkConfig): string | undefined {
6
+ const env = (process.env.OPENCLAW_WECOM_EGRESS_PROXY_URL ?? process.env.WECOM_EGRESS_PROXY_URL ?? "").trim();
7
+ if (env) return env;
8
+
9
+ const fromCfg = network?.egressProxyUrl?.trim() ?? "";
10
+ return fromCfg || undefined;
11
+ }
12
+
13
+ export function resolveWecomEgressProxyUrl(cfg: OpenClawConfig): string | undefined {
14
+ const wecom = cfg.channels?.wecom as WecomConfig | undefined;
15
+ return resolveWecomEgressProxyUrlFromNetwork(wecom?.network);
16
+ }
@@ -4,14 +4,29 @@
4
4
 
5
5
  import { z } from "zod";
6
6
 
7
- /** DM 策略 Schema */
7
+ /**
8
+ * **dmSchema (单聊配置)**
9
+ *
10
+ * 控制单聊行为(如允许名单、策略)。
11
+ * @property enabled - 是否启用单聊 [默认: true]
12
+ * @property policy - 访问策略: "pairing" (需配对, 默认), "allowlist" (仅在名单), "open" (所有人), "disabled" (禁用)
13
+ * @property allowFrom - 允许的用户ID或群ID列表 (仅当 policy="allowlist" 时生效)
14
+ */
8
15
  const dmSchema = z.object({
9
16
  enabled: z.boolean().optional(),
10
17
  policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
11
18
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
12
19
  }).optional();
13
20
 
14
- /** 媒体配置 Schema */
21
+ /**
22
+ * **mediaSchema (媒体处理配置)**
23
+ *
24
+ * 控制媒体文件的下载和缓存行为。
25
+ * @property tempDir - 临时文件下载目录
26
+ * @property retentionHours - 临时文件保留时间(小时)
27
+ * @property cleanupOnStart - 启动时是否自动清理旧文件
28
+ * @property maxBytes - 允许下载的最大字节数
29
+ */
15
30
  const mediaSchema = z.object({
16
31
  tempDir: z.string().optional(),
17
32
  retentionHours: z.number().optional(),
@@ -19,14 +34,33 @@ const mediaSchema = z.object({
19
34
  maxBytes: z.number().optional(),
20
35
  }).optional();
21
36
 
22
- /** 网络配置 Schema */
37
+ /**
38
+ * **networkSchema (网络配置)**
39
+ *
40
+ * 控制 HTTP 请求行为,特别是出站代理。
41
+ * @property timeoutMs - 请求超时时间 (毫秒)
42
+ * @property retries - 重试次数
43
+ * @property retryDelayMs - 重试间隔 (毫秒)
44
+ * @property egressProxyUrl - 出站 HTTP 代理 (如 "http://127.0.0.1:7890")
45
+ */
23
46
  const networkSchema = z.object({
24
47
  timeoutMs: z.number().optional(),
25
48
  retries: z.number().optional(),
26
49
  retryDelayMs: z.number().optional(),
50
+ egressProxyUrl: z.string().optional(),
27
51
  }).optional();
28
52
 
29
- /** Bot 模式配置 Schema */
53
+ /**
54
+ * **botSchema (Bot 模式配置)**
55
+ *
56
+ * 用于配置企业微信内部机器人 (Webhook 模式)。
57
+ * @property token - 企业微信后台设置的 Token
58
+ * @property encodingAESKey - 企业微信后台设置的 EncodingAESKey
59
+ * @property receiveId - (可选) 接收者ID,通常不用填
60
+ * @property streamPlaceholderContent - (可选) 流式响应中的占位符,默认为 "Thinking..."或空
61
+ * @property welcomeText - (可选) 用户首次对话时的欢迎语
62
+ * @property dm - 单聊策略覆盖配置
63
+ */
30
64
  const botSchema = z.object({
31
65
  token: z.string(),
32
66
  encodingAESKey: z.string(),
@@ -36,7 +70,18 @@ const botSchema = z.object({
36
70
  dm: dmSchema,
37
71
  }).optional();
38
72
 
39
- /** Agent 模式配置 Schema */
73
+ /**
74
+ * **agentSchema (Agent 模式配置)**
75
+ *
76
+ * 用于配置企业微信自建应用 (Agent)。
77
+ * @property corpId - 企业 ID (CorpID)
78
+ * @property corpSecret - 应用 Secret
79
+ * @property agentId - 应用 AgentId (数字)
80
+ * @property token - 回调配置 Token
81
+ * @property encodingAESKey - 回调配置 EncodingAESKey
82
+ * @property welcomeText - (可选) 欢迎语
83
+ * @property dm - 单聊策略覆盖配置
84
+ */
40
85
  const agentSchema = z.object({
41
86
  corpId: z.string(),
42
87
  corpSecret: z.string(),
@@ -20,6 +20,7 @@ export const WecomConfigSchema = z.object({
20
20
  receiveId: z.string().optional(),
21
21
 
22
22
  streamPlaceholderContent: z.string().optional(),
23
+ debounceMs: z.number().optional(),
23
24
 
24
25
  welcomeText: z.string().optional(),
25
26
  dm: dmSchema,
@@ -33,6 +34,7 @@ export const WecomConfigSchema = z.object({
33
34
  encodingAESKey: z.string().optional(),
34
35
  receiveId: z.string().optional(),
35
36
  streamPlaceholderContent: z.string().optional(),
37
+ debounceMs: z.number().optional(),
36
38
  welcomeText: z.string().optional(),
37
39
  dm: dmSchema,
38
40
  })).optional(),
package/src/crypto.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ /**
4
+ * **decodeEncodingAESKey (解码 AES Key)**
5
+ *
6
+ * 将企业微信配置的 Base64 编码的 AES Key 解码为 Buffer。
7
+ * 包含补全 Padding 和长度校验 (必须32字节)。
8
+ */
3
9
  export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
4
10
  const trimmed = encodingAESKey.trim();
5
11
  if (!trimmed) throw new Error("encodingAESKey missing");
@@ -22,6 +28,12 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
22
28
  return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
23
29
  }
24
30
 
31
+ /**
32
+ * **pkcs7Unpad (去除 PKCS#7 填充)**
33
+ *
34
+ * 移除 AES 解密后的 PKCS#7 填充字节。
35
+ * 包含填充合法性校验。
36
+ */
25
37
  export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
26
38
  if (buf.length === 0) throw new Error("invalid pkcs7 payload");
27
39
  const pad = buf[buf.length - 1]!;
@@ -44,6 +56,11 @@ function sha1Hex(input: string): string {
44
56
  return crypto.createHash("sha1").update(input).digest("hex");
45
57
  }
46
58
 
59
+ /**
60
+ * **computeWecomMsgSignature (计算消息签名)**
61
+ *
62
+ * 算法:sha1(sort(token, timestamp, nonce, encrypt_msg))
63
+ */
47
64
  export function computeWecomMsgSignature(params: {
48
65
  token: string;
49
66
  timestamp: string;
@@ -56,6 +73,11 @@ export function computeWecomMsgSignature(params: {
56
73
  return sha1Hex(parts.join(""));
57
74
  }
58
75
 
76
+ /**
77
+ * **verifyWecomSignature (验证消息签名)**
78
+ *
79
+ * 比较计算出的签名与企业微信传入的签名是否一致。
80
+ */
59
81
  export function verifyWecomSignature(params: {
60
82
  token: string;
61
83
  timestamp: string;
@@ -72,6 +94,17 @@ export function verifyWecomSignature(params: {
72
94
  return expected === params.signature;
73
95
  }
74
96
 
97
+ /**
98
+ * **decryptWecomEncrypted (解密企业微信消息)**
99
+ *
100
+ * 将企业微信的 AES 加密包解密为明文。
101
+ * 流程:
102
+ * 1. Base64 解码 AESKey 并获取 IV (前16字节)。
103
+ * 2. AES-CBC 解密。
104
+ * 3. 去除 PKCS#7 填充。
105
+ * 4. 拆解协议包结构: [16字节随机串][4字节长度][消息体][接收者ID]。
106
+ * 5. 校验接收者ID (ReceiveId)。
107
+ */
75
108
  export function decryptWecomEncrypted(params: {
76
109
  encodingAESKey: string;
77
110
  receiveId?: string;
@@ -111,6 +144,16 @@ export function decryptWecomEncrypted(params: {
111
144
  return msg;
112
145
  }
113
146
 
147
+ /**
148
+ * **encryptWecomPlaintext (加密回复消息)**
149
+ *
150
+ * 将明文消息打包为企业微信的加密格式。
151
+ * 流程:
152
+ * 1. 构造协议包: [16字节随机串][4字节长度][消息体][接收者ID]。
153
+ * 2. PKCS#7 填充。
154
+ * 3. AES-CBC 加密。
155
+ * 4. 转 Base64。
156
+ */
114
157
  export function encryptWecomPlaintext(params: {
115
158
  encodingAESKey: string;
116
159
  receiveId?: string;
package/src/http.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { Dispatcher } from "undici";
2
+ import { ProxyAgent, fetch as undiciFetch } from "undici";
3
+
4
+ type ProxyDispatcher = Dispatcher;
5
+
6
+ const proxyDispatchers = new Map<string, ProxyDispatcher>();
7
+
8
+ /**
9
+ * **getProxyDispatcher (获取代理 Dispatcher)**
10
+ *
11
+ * 缓存并复用 ProxyAgent,避免重复创建连接池。
12
+ */
13
+ function getProxyDispatcher(proxyUrl: string): ProxyDispatcher {
14
+ const existing = proxyDispatchers.get(proxyUrl);
15
+ if (existing) return existing;
16
+ const created = new ProxyAgent(proxyUrl);
17
+ proxyDispatchers.set(proxyUrl, created);
18
+ return created;
19
+ }
20
+
21
+ function mergeAbortSignal(params: {
22
+ signal?: AbortSignal;
23
+ timeoutMs?: number;
24
+ }): AbortSignal | undefined {
25
+ const signals: AbortSignal[] = [];
26
+ if (params.signal) signals.push(params.signal);
27
+ if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) {
28
+ signals.push(AbortSignal.timeout(params.timeoutMs));
29
+ }
30
+ if (!signals.length) return undefined;
31
+ if (signals.length === 1) return signals[0];
32
+ return AbortSignal.any(signals);
33
+ }
34
+
35
+ /**
36
+ * **WecomHttpOptions (HTTP 选项)**
37
+ *
38
+ * @property proxyUrl 代理服务器地址
39
+ * @property timeoutMs 请求超时时间 (毫秒)
40
+ * @property signal AbortSignal 信号
41
+ */
42
+ export type WecomHttpOptions = {
43
+ proxyUrl?: string;
44
+ timeoutMs?: number;
45
+ signal?: AbortSignal;
46
+ };
47
+
48
+ /**
49
+ * **wecomFetch (统一 HTTP 请求)**
50
+ *
51
+ * 基于 `undici` 的 fetch 封装,自动处理 ProxyAgent 和 Timeout。
52
+ * 所有对企业微信 API 的调用都应经过此函数。
53
+ */
54
+ export async function wecomFetch(input: string | URL, init?: RequestInit, opts?: WecomHttpOptions): Promise<Response> {
55
+ const proxyUrl = opts?.proxyUrl?.trim() ?? "";
56
+ const dispatcher = proxyUrl ? getProxyDispatcher(proxyUrl) : undefined;
57
+
58
+ const initSignal = init?.signal ?? undefined;
59
+ const signal = mergeAbortSignal({ signal: opts?.signal ?? initSignal, timeoutMs: opts?.timeoutMs });
60
+ const nextInit: RequestInit & { dispatcher?: Dispatcher } = {
61
+ ...(init ?? {}),
62
+ ...(signal ? { signal } : {}),
63
+ ...(dispatcher ? { dispatcher } : {}),
64
+ };
65
+
66
+ return undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
67
+ }
68
+
69
+ /**
70
+ * **readResponseBodyAsBuffer (读取响应 Body)**
71
+ *
72
+ * 将 Response Body 读取为 Buffer,支持最大字节限制以防止内存溢出。
73
+ * 适用于下载媒体文件等场景。
74
+ */
75
+ export async function readResponseBodyAsBuffer(res: Response, maxBytes?: number): Promise<Buffer> {
76
+ if (!res.body) return Buffer.alloc(0);
77
+
78
+ const limit = maxBytes && Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : undefined;
79
+ const chunks: Uint8Array[] = [];
80
+ let total = 0;
81
+
82
+ const reader = res.body.getReader();
83
+ while (true) {
84
+ const { done, value } = await reader.read();
85
+ if (done) break;
86
+ if (!value) continue;
87
+
88
+ total += value.byteLength;
89
+ if (limit && total > limit) {
90
+ try {
91
+ await reader.cancel("body too large");
92
+ } catch {
93
+ // ignore
94
+ }
95
+ throw new Error(`response body too large (>${limit} bytes)`);
96
+ }
97
+ chunks.push(value);
98
+ }
99
+
100
+ return Buffer.concat(chunks.map((c) => Buffer.from(c)));
101
+ }
102
+
package/src/media.test.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
2
  import { decryptWecomMedia } from "./media.js";
3
3
  import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
4
- import axios from "axios";
5
4
  import crypto from "node:crypto";
6
5
 
7
- vi.mock("axios");
6
+ const { undiciFetch } = vi.hoisted(() => {
7
+ const undiciFetch = vi.fn();
8
+ return { undiciFetch };
9
+ });
10
+
11
+ vi.mock("undici", () => ({
12
+ fetch: undiciFetch,
13
+ ProxyAgent: class ProxyAgent { },
14
+ }));
8
15
 
9
16
  function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
10
17
  const mod = buf.length % blockSize;
@@ -28,19 +35,18 @@ describe("decryptWecomMedia", () => {
28
35
  cipher.setAutoPadding(false);
29
36
  const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
30
37
 
31
- // 3. Mock Axios
32
- (axios.get as any).mockResolvedValue({
33
- data: encrypted,
34
- });
38
+ // 3. Mock HTTP fetch
39
+ undiciFetch.mockResolvedValue(new Response(encrypted));
35
40
 
36
41
  // 4. Test
37
42
  const decrypted = await decryptWecomMedia("http://mock.url/image", aesKeyBase64);
38
43
 
39
44
  // 5. Assert
40
45
  expect(decrypted.toString("utf8")).toBe("Hello WeCom Image Data");
41
- expect(axios.get).toHaveBeenCalledWith("http://mock.url/image", expect.objectContaining({
42
- responseType: "arraybuffer"
43
- }));
46
+ expect(undiciFetch).toHaveBeenCalledWith(
47
+ "http://mock.url/image",
48
+ expect.objectContaining({ signal: expect.anything() }),
49
+ );
44
50
  });
45
51
 
46
52
  it("should fail if key is invalid", async () => {
package/src/media.ts CHANGED
@@ -1,23 +1,39 @@
1
1
  import crypto from "node:crypto";
2
- import axios from "axios";
3
2
  import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
3
+ import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
4
4
 
5
5
  /**
6
- * Download and decrypt WeCom media file (e.g. image).
6
+ * **decryptWecomMedia (解密企业微信媒体文件)**
7
7
  *
8
- * WeCom media files are AES-256-CBC encrypted with the same EncodingAESKey.
9
- * The IV is the first 16 bytes of the AES Key.
10
- * The content is PKCS#7 padded.
8
+ * 简易封装:直接传入 URL AES Key 下载并解密。
9
+ * 企业微信媒体文件使用与消息体相同的 AES-256-CBC 加密,IV AES Key 16字节。
10
+ * 解密后需移除 PKCS#7 填充。
11
11
  */
12
12
  export async function decryptWecomMedia(url: string, encodingAESKey: string, maxBytes?: number): Promise<Buffer> {
13
+ return decryptWecomMediaWithHttp(url, encodingAESKey, { maxBytes });
14
+ }
15
+
16
+ /**
17
+ * **decryptWecomMediaWithHttp (解密企业微信媒体 - 高级)**
18
+ *
19
+ * 支持传递 HTTP 选项(如 Proxy、Timeout)。
20
+ * 流程:
21
+ * 1. 下载加密内容。
22
+ * 2. 准备 AES Key 和 IV。
23
+ * 3. AES-CBC 解密。
24
+ * 4. PKCS#7 去除填充。
25
+ */
26
+ export async function decryptWecomMediaWithHttp(
27
+ url: string,
28
+ encodingAESKey: string,
29
+ params?: { maxBytes?: number; http?: WecomHttpOptions },
30
+ ): Promise<Buffer> {
13
31
  // 1. Download encrypted content
14
- const response = await axios.get(url, {
15
- responseType: "arraybuffer", // Important: get raw buffer
16
- timeout: 15000,
17
- maxContentLength: maxBytes || undefined, // Limit download size
18
- maxBodyLength: maxBytes || undefined,
19
- });
20
- const encryptedData = Buffer.from(response.data);
32
+ const res = await wecomFetch(url, undefined, { ...params?.http, timeoutMs: params?.http?.timeoutMs ?? 15_000 });
33
+ if (!res.ok) {
34
+ throw new Error(`failed to download media: ${res.status}`);
35
+ }
36
+ const encryptedData = await readResponseBodyAsBuffer(res, params?.maxBytes);
21
37
 
22
38
  // 2. Prepare Key and IV
23
39
  const aesKey = decodeEncodingAESKey(encodingAESKey);