@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.
- package/GEMINI.md +76 -0
- package/README.md +159 -43
- package/assets/03.agent.page.png +0 -0
- package/assets/03.bot.page.png +0 -0
- package/index.ts +8 -0
- package/package.json +6 -2
- package/src/agent/api-client.ts +87 -25
- package/src/agent/handler.ts +105 -11
- package/src/channel.ts +17 -0
- package/src/config/accounts.ts +5 -3
- package/src/config/index.ts +1 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +50 -5
- package/src/config-schema.ts +2 -0
- package/src/crypto.ts +43 -0
- package/src/http.ts +102 -0
- package/src/media.test.ts +15 -9
- package/src/media.ts +28 -12
- package/src/monitor/state.ts +354 -0
- package/src/monitor/types.ts +128 -0
- package/src/monitor.active.test.ts +90 -7
- package/src/monitor.integration.test.ts +15 -5
- package/src/monitor.ts +853 -163
- package/src/onboarding.ts +3 -3
- package/src/outbound.test.ts +39 -17
- package/src/outbound.ts +68 -42
- package/src/shared/xml-parser.ts +8 -0
- package/src/target.ts +80 -0
- package/src/types/account.ts +5 -1
- package/src/types/config.ts +7 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/message.ts +41 -0
package/src/agent/handler.ts
CHANGED
|
@@ -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 验证
|
|
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 消息
|
|
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:
|
|
316
|
+
body: finalContent,
|
|
230
317
|
});
|
|
231
318
|
|
|
232
319
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
233
320
|
Body: body,
|
|
234
|
-
RawBody:
|
|
235
|
-
CommandBody:
|
|
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
|
-
*
|
|
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;
|
package/src/config/accounts.ts
CHANGED
|
@@ -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
|
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -4,14 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(),
|
package/src/config-schema.ts
CHANGED
|
@@ -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.
|
|
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
|
|
32
|
-
(
|
|
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(
|
|
42
|
-
|
|
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
|
-
*
|
|
6
|
+
* **decryptWecomMedia (解密企业微信媒体文件)**
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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);
|