@mocrane/wecom 2026.2.5

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/clawdbot.plugin.json +10 -0
  4. package/index.ts +28 -0
  5. package/openclaw.plugin.json +10 -0
  6. package/package.json +81 -0
  7. package/src/accounts.ts +72 -0
  8. package/src/agent/api-client.ts +336 -0
  9. package/src/agent/handler.ts +566 -0
  10. package/src/agent/index.ts +12 -0
  11. package/src/channel.ts +259 -0
  12. package/src/config/accounts.ts +99 -0
  13. package/src/config/index.ts +12 -0
  14. package/src/config/media.ts +14 -0
  15. package/src/config/network.ts +16 -0
  16. package/src/config/schema.ts +104 -0
  17. package/src/config-schema.ts +41 -0
  18. package/src/crypto/aes.ts +108 -0
  19. package/src/crypto/index.ts +24 -0
  20. package/src/crypto/signature.ts +43 -0
  21. package/src/crypto/xml.ts +49 -0
  22. package/src/crypto.test.ts +32 -0
  23. package/src/crypto.ts +176 -0
  24. package/src/http.ts +102 -0
  25. package/src/media.test.ts +55 -0
  26. package/src/media.ts +55 -0
  27. package/src/monitor/state.queue.test.ts +185 -0
  28. package/src/monitor/state.ts +514 -0
  29. package/src/monitor/types.ts +136 -0
  30. package/src/monitor.active.test.ts +239 -0
  31. package/src/monitor.integration.test.ts +207 -0
  32. package/src/monitor.ts +1802 -0
  33. package/src/monitor.webhook.test.ts +311 -0
  34. package/src/onboarding.ts +472 -0
  35. package/src/outbound.test.ts +143 -0
  36. package/src/outbound.ts +200 -0
  37. package/src/runtime.ts +14 -0
  38. package/src/shared/command-auth.ts +101 -0
  39. package/src/shared/index.ts +5 -0
  40. package/src/shared/xml-parser.test.ts +30 -0
  41. package/src/shared/xml-parser.ts +183 -0
  42. package/src/target.ts +80 -0
  43. package/src/types/account.ts +76 -0
  44. package/src/types/config.ts +88 -0
  45. package/src/types/constants.ts +42 -0
  46. package/src/types/global.d.ts +9 -0
  47. package/src/types/index.ts +38 -0
  48. package/src/types/message.ts +185 -0
  49. package/src/types.ts +159 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WeCom 签名计算与验证
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+
7
+ function sha1Hex(input: string): string {
8
+ return crypto.createHash("sha1").update(input).digest("hex");
9
+ }
10
+
11
+ /**
12
+ * 计算 WeCom 消息签名
13
+ */
14
+ export function computeWecomMsgSignature(params: {
15
+ token: string;
16
+ timestamp: string;
17
+ nonce: string;
18
+ encrypt: string;
19
+ }): string {
20
+ const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
21
+ .map((v) => String(v ?? ""))
22
+ .sort();
23
+ return sha1Hex(parts.join(""));
24
+ }
25
+
26
+ /**
27
+ * 验证 WeCom 消息签名
28
+ */
29
+ export function verifyWecomSignature(params: {
30
+ token: string;
31
+ timestamp: string;
32
+ nonce: string;
33
+ encrypt: string;
34
+ signature: string;
35
+ }): boolean {
36
+ const expected = computeWecomMsgSignature({
37
+ token: params.token,
38
+ timestamp: params.timestamp,
39
+ nonce: params.nonce,
40
+ encrypt: params.encrypt,
41
+ });
42
+ return expected === params.signature;
43
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * WeCom XML 加解密辅助函数
3
+ * 用于 Agent 模式处理 XML 格式回调
4
+ */
5
+
6
+ /**
7
+ * 从 XML 密文中提取 Encrypt 字段
8
+ */
9
+ export function extractEncryptFromXml(xml: string): string {
10
+ const match = /<Encrypt><!\[CDATA\[(.*?)\]\]><\/Encrypt>/s.exec(xml);
11
+ if (!match?.[1]) {
12
+ // 尝试不带 CDATA 的格式
13
+ const altMatch = /<Encrypt>(.*?)<\/Encrypt>/s.exec(xml);
14
+ if (!altMatch?.[1]) {
15
+ throw new Error("Invalid XML: missing Encrypt field");
16
+ }
17
+ return altMatch[1];
18
+ }
19
+ return match[1];
20
+ }
21
+
22
+ /**
23
+ * 从 XML 中提取 ToUserName (CorpID)
24
+ */
25
+ export function extractToUserNameFromXml(xml: string): string {
26
+ const match = /<ToUserName><!\[CDATA\[(.*?)\]\]><\/ToUserName>/s.exec(xml);
27
+ if (!match?.[1]) {
28
+ const altMatch = /<ToUserName>(.*?)<\/ToUserName>/s.exec(xml);
29
+ return altMatch?.[1] ?? "";
30
+ }
31
+ return match[1];
32
+ }
33
+
34
+ /**
35
+ * 构建加密 XML 响应
36
+ */
37
+ export function buildEncryptedXmlResponse(params: {
38
+ encrypt: string;
39
+ signature: string;
40
+ timestamp: string;
41
+ nonce: string;
42
+ }): string {
43
+ return `<xml>
44
+ <Encrypt><![CDATA[${params.encrypt}]]></Encrypt>
45
+ <MsgSignature><![CDATA[${params.signature}]]></MsgSignature>
46
+ <TimeStamp>${params.timestamp}</TimeStamp>
47
+ <Nonce><![CDATA[${params.nonce}]]></Nonce>
48
+ </xml>`;
49
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js";
4
+
5
+ describe("wecom crypto", () => {
6
+ it("round-trips plaintext", () => {
7
+ const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars base64 (plus '=' padding)
8
+ const plaintext = JSON.stringify({ hello: "world" });
9
+ const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext });
10
+ const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt });
11
+ expect(decrypted).toBe(plaintext);
12
+ });
13
+
14
+ it("pads correctly when raw length is a multiple of 32", () => {
15
+ const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
16
+ // raw length = 20 + plaintext.length + receiveId.length; choose plaintext length % 32 === 12
17
+ const plaintext = "x".repeat(12);
18
+ const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext });
19
+ const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt });
20
+ expect(decrypted).toBe(plaintext);
21
+ });
22
+
23
+ it("computes sha1 msg signature", () => {
24
+ const sig = computeWecomMsgSignature({
25
+ token: "token",
26
+ timestamp: "123",
27
+ nonce: "456",
28
+ encrypt: "ENCRYPT",
29
+ });
30
+ expect(sig).toMatch(/^[a-f0-9]{40}$/);
31
+ });
32
+ });
package/src/crypto.ts ADDED
@@ -0,0 +1,176 @@
1
+ import crypto from "node:crypto";
2
+
3
+ /**
4
+ * **decodeEncodingAESKey (解码 AES Key)**
5
+ *
6
+ * 将企业微信配置的 Base64 编码的 AES Key 解码为 Buffer。
7
+ * 包含补全 Padding 和长度校验 (必须32字节)。
8
+ */
9
+ export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
10
+ const trimmed = encodingAESKey.trim();
11
+ if (!trimmed) throw new Error("encodingAESKey missing");
12
+ const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
13
+ const key = Buffer.from(withPadding, "base64");
14
+ if (key.length !== 32) {
15
+ throw new Error(`invalid encodingAESKey (expected 32 bytes after base64 decode, got ${key.length})`);
16
+ }
17
+ return key;
18
+ }
19
+
20
+ // WeCom uses PKCS#7 padding with a block size of 32 bytes (not AES's 16-byte block).
21
+ // This is compatible with AES-CBC as 32 is a multiple of 16, but it requires manual padding/unpadding.
22
+ export const WECOM_PKCS7_BLOCK_SIZE = 32;
23
+
24
+ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
25
+ const mod = buf.length % blockSize;
26
+ const pad = mod === 0 ? blockSize : blockSize - mod;
27
+ const padByte = Buffer.from([pad]);
28
+ return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
29
+ }
30
+
31
+ /**
32
+ * **pkcs7Unpad (去除 PKCS#7 填充)**
33
+ *
34
+ * 移除 AES 解密后的 PKCS#7 填充字节。
35
+ * 包含填充合法性校验。
36
+ */
37
+ export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
38
+ if (buf.length === 0) throw new Error("invalid pkcs7 payload");
39
+ const pad = buf[buf.length - 1]!;
40
+ if (pad < 1 || pad > blockSize) {
41
+ throw new Error("invalid pkcs7 padding");
42
+ }
43
+ if (pad > buf.length) {
44
+ throw new Error("invalid pkcs7 payload");
45
+ }
46
+ // Best-effort validation (all padding bytes equal).
47
+ for (let i = 0; i < pad; i += 1) {
48
+ if (buf[buf.length - 1 - i] !== pad) {
49
+ throw new Error("invalid pkcs7 padding");
50
+ }
51
+ }
52
+ return buf.subarray(0, buf.length - pad);
53
+ }
54
+
55
+ function sha1Hex(input: string): string {
56
+ return crypto.createHash("sha1").update(input).digest("hex");
57
+ }
58
+
59
+ /**
60
+ * **computeWecomMsgSignature (计算消息签名)**
61
+ *
62
+ * 算法:sha1(sort(token, timestamp, nonce, encrypt_msg))
63
+ */
64
+ export function computeWecomMsgSignature(params: {
65
+ token: string;
66
+ timestamp: string;
67
+ nonce: string;
68
+ encrypt: string;
69
+ }): string {
70
+ const parts = [params.token, params.timestamp, params.nonce, params.encrypt]
71
+ .map((v) => String(v ?? ""))
72
+ .sort();
73
+ return sha1Hex(parts.join(""));
74
+ }
75
+
76
+ /**
77
+ * **verifyWecomSignature (验证消息签名)**
78
+ *
79
+ * 比较计算出的签名与企业微信传入的签名是否一致。
80
+ */
81
+ export function verifyWecomSignature(params: {
82
+ token: string;
83
+ timestamp: string;
84
+ nonce: string;
85
+ encrypt: string;
86
+ signature: string;
87
+ }): boolean {
88
+ const expected = computeWecomMsgSignature({
89
+ token: params.token,
90
+ timestamp: params.timestamp,
91
+ nonce: params.nonce,
92
+ encrypt: params.encrypt,
93
+ });
94
+ return expected === params.signature;
95
+ }
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
+ */
108
+ export function decryptWecomEncrypted(params: {
109
+ encodingAESKey: string;
110
+ receiveId?: string;
111
+ encrypt: string;
112
+ }): string {
113
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
114
+ const iv = aesKey.subarray(0, 16);
115
+ const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
116
+ decipher.setAutoPadding(false);
117
+ const decryptedPadded = Buffer.concat([
118
+ decipher.update(Buffer.from(params.encrypt, "base64")),
119
+ decipher.final(),
120
+ ]);
121
+ const decrypted = pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
122
+
123
+ if (decrypted.length < 20) {
124
+ throw new Error(`invalid decrypted payload (expected at least 20 bytes, got ${decrypted.length})`);
125
+ }
126
+
127
+ // 16 bytes random + 4 bytes network-order length + msg + receiveId (optional)
128
+ const msgLen = decrypted.readUInt32BE(16);
129
+ const msgStart = 20;
130
+ const msgEnd = msgStart + msgLen;
131
+ if (msgEnd > decrypted.length) {
132
+ throw new Error(`invalid decrypted msg length (msgEnd=${msgEnd}, payloadLength=${decrypted.length})`);
133
+ }
134
+ const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8");
135
+
136
+ const receiveId = params.receiveId ?? "";
137
+ if (receiveId) {
138
+ const trailing = decrypted.subarray(msgEnd).toString("utf8");
139
+ if (trailing !== receiveId) {
140
+ throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`);
141
+ }
142
+ }
143
+
144
+ return msg;
145
+ }
146
+
147
+ /**
148
+ * **encryptWecomPlaintext (加密回复消息)**
149
+ *
150
+ * 将明文消息打包为企业微信的加密格式。
151
+ * 流程:
152
+ * 1. 构造协议包: [16字节随机串][4字节长度][消息体][接收者ID]。
153
+ * 2. PKCS#7 填充。
154
+ * 3. AES-CBC 加密。
155
+ * 4. 转 Base64。
156
+ */
157
+ export function encryptWecomPlaintext(params: {
158
+ encodingAESKey: string;
159
+ receiveId?: string;
160
+ plaintext: string;
161
+ }): string {
162
+ const aesKey = decodeEncodingAESKey(params.encodingAESKey);
163
+ const iv = aesKey.subarray(0, 16);
164
+ const random16 = crypto.randomBytes(16);
165
+ const msg = Buffer.from(params.plaintext ?? "", "utf8");
166
+ const msgLen = Buffer.alloc(4);
167
+ msgLen.writeUInt32BE(msg.length, 0);
168
+ const receiveId = Buffer.from(params.receiveId ?? "", "utf8");
169
+
170
+ const raw = Buffer.concat([random16, msgLen, msg, receiveId]);
171
+ const padded = pkcs7Pad(raw, WECOM_PKCS7_BLOCK_SIZE);
172
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
173
+ cipher.setAutoPadding(false);
174
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
175
+ return encrypted.toString("base64");
176
+ }
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
+
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { decryptWecomMedia } from "./media.js";
3
+ import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
4
+ import crypto from "node:crypto";
5
+
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
+ }));
15
+
16
+ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
17
+ const mod = buf.length % blockSize;
18
+ const pad = mod === 0 ? blockSize : blockSize - mod;
19
+ const padByte = Buffer.from([pad]);
20
+ return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
21
+ }
22
+
23
+ describe("decryptWecomMedia", () => {
24
+ it("should download and decrypt media successfully", async () => {
25
+ // 1. Setup Key and Data
26
+ const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes when decoded + padding
27
+ const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
28
+ const iv = aesKey.subarray(0, 16);
29
+
30
+ const originalData = Buffer.from("Hello WeCom Image Data", "utf8");
31
+
32
+ // 2. Encrypt manually (AES-256-CBC + PKCS7)
33
+ const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
34
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
35
+ cipher.setAutoPadding(false);
36
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
37
+
38
+ // 3. Mock HTTP fetch
39
+ undiciFetch.mockResolvedValue(new Response(encrypted));
40
+
41
+ // 4. Test
42
+ const decrypted = await decryptWecomMedia("http://mock.url/image", aesKeyBase64);
43
+
44
+ // 5. Assert
45
+ expect(decrypted.toString("utf8")).toBe("Hello WeCom Image Data");
46
+ expect(undiciFetch).toHaveBeenCalledWith(
47
+ "http://mock.url/image",
48
+ expect.objectContaining({ signal: expect.anything() }),
49
+ );
50
+ });
51
+
52
+ it("should fail if key is invalid", async () => {
53
+ await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
54
+ });
55
+ });
package/src/media.ts ADDED
@@ -0,0 +1,55 @@
1
+ import crypto from "node:crypto";
2
+ import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
3
+ import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
4
+
5
+ /**
6
+ * **decryptWecomMedia (解密企业微信媒体文件)**
7
+ *
8
+ * 简易封装:直接传入 URL 和 AES Key 下载并解密。
9
+ * 企业微信媒体文件使用与消息体相同的 AES-256-CBC 加密,IV 为 AES Key 前16字节。
10
+ * 解密后需移除 PKCS#7 填充。
11
+ */
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> {
31
+ // 1. Download encrypted content
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);
37
+
38
+ // 2. Prepare Key and IV
39
+ const aesKey = decodeEncodingAESKey(encodingAESKey);
40
+ const iv = aesKey.subarray(0, 16);
41
+
42
+ // 3. Decrypt
43
+ const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
44
+ decipher.setAutoPadding(false); // We handle padding manually
45
+ const decryptedPadded = Buffer.concat([
46
+ decipher.update(encryptedData),
47
+ decipher.final(),
48
+ ]);
49
+
50
+ // 4. Unpad
51
+ // Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
52
+ // The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
53
+ // Our pkcs7Unpad function does exactly this + validation.
54
+ return pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
55
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+
3
+ import type { WecomInboundMessage } from "../types.js";
4
+ import type { WecomWebhookTarget } from "./types.js";
5
+ import { StreamStore } from "./state.js";
6
+
7
+ describe("wecom StreamStore queue", () => {
8
+ test("does not merge into active batch; flushes queued batch after active finishes", async () => {
9
+ vi.useFakeTimers();
10
+ try {
11
+ const store = new StreamStore();
12
+ const flushed: string[] = [];
13
+ store.setFlushHandler((pending) => flushed.push(pending.streamId));
14
+
15
+ const target = {
16
+ account: {} as any,
17
+ config: {} as any,
18
+ runtime: {},
19
+ core: {} as any,
20
+ path: "/wecom",
21
+ } satisfies WecomWebhookTarget;
22
+
23
+ const conversationKey = "wecom:default:U:C";
24
+
25
+ const msg1 = { msgid: "M1" } satisfies WecomInboundMessage;
26
+ const msg2 = { msgid: "M2" } satisfies WecomInboundMessage;
27
+
28
+ const r1 = store.addPendingMessage({
29
+ conversationKey,
30
+ target,
31
+ msg: msg1,
32
+ msgContent: "1",
33
+ nonce: "n",
34
+ timestamp: "t",
35
+ debounceMs: 10,
36
+ });
37
+ const r2 = store.addPendingMessage({
38
+ conversationKey,
39
+ target,
40
+ msg: msg2,
41
+ msgContent: "2",
42
+ nonce: "n",
43
+ timestamp: "t",
44
+ debounceMs: 10,
45
+ });
46
+
47
+ expect(r1.status).toBe("active_new");
48
+ // 初始批次不接收合并:第二条进入 queued
49
+ expect(r2.status).toBe("queued_new");
50
+ expect(r2.streamId).not.toBe(r1.streamId);
51
+
52
+ // Follow-ups within queued should merge into queued (status queued_merged).
53
+ const r3 = store.addPendingMessage({
54
+ conversationKey,
55
+ target,
56
+ msg: { msgid: "M3" } as any,
57
+ msgContent: "3",
58
+ nonce: "n",
59
+ timestamp: "t",
60
+ debounceMs: 10,
61
+ });
62
+ expect(r3.status).toBe("queued_merged");
63
+ expect(r3.streamId).toBe(r2.streamId);
64
+
65
+ // Active batch flushes at debounce time.
66
+ await vi.advanceTimersByTimeAsync(11);
67
+ expect(flushed).toEqual([r1.streamId]);
68
+
69
+ // Queued batch timer also fires, but cannot flush until active finishes.
70
+ await vi.advanceTimersByTimeAsync(11);
71
+ expect(flushed).toEqual([r1.streamId]);
72
+
73
+ // Once the active stream finishes, queued batch is promoted and flushes immediately.
74
+ store.onStreamFinished(r1.streamId);
75
+ expect(flushed).toEqual([r1.streamId, r2.streamId]);
76
+ } finally {
77
+ vi.useRealTimers();
78
+ }
79
+ });
80
+
81
+ test("merges into active batch when it has not started yet (even after promotion)", async () => {
82
+ vi.useFakeTimers();
83
+ try {
84
+ const store = new StreamStore();
85
+ const flushed: string[] = [];
86
+ store.setFlushHandler((pending) => flushed.push(pending.streamId));
87
+
88
+ const target = {
89
+ account: {} as any,
90
+ config: {} as any,
91
+ runtime: {},
92
+ core: {} as any,
93
+ path: "/wecom",
94
+ } satisfies WecomWebhookTarget;
95
+
96
+ const conversationKey = "wecom:default:U:C2";
97
+
98
+ // 1 becomes active and flushes; mark as started to simulate "processing started".
99
+ const r1 = store.addPendingMessage({
100
+ conversationKey,
101
+ target,
102
+ msg: { msgid: "M1" } as any,
103
+ msgContent: "1",
104
+ nonce: "n",
105
+ timestamp: "t",
106
+ debounceMs: 10,
107
+ });
108
+ store.markStarted(r1.streamId);
109
+ await vi.advanceTimersByTimeAsync(11);
110
+ expect(flushed).toEqual([r1.streamId]);
111
+
112
+ // 2 enters queued with a longer debounce; it should NOT become readyToFlush yet.
113
+ const r2 = store.addPendingMessage({
114
+ conversationKey,
115
+ target,
116
+ msg: { msgid: "M2" } as any,
117
+ msgContent: "2",
118
+ nonce: "n",
119
+ timestamp: "t",
120
+ debounceMs: 100,
121
+ });
122
+ expect(flushed).toEqual([r1.streamId]);
123
+
124
+ // Finish 1, promote 2 to active (but do NOT flush immediately since it's not readyToFlush).
125
+ store.onStreamFinished(r1.streamId);
126
+ expect(flushed).toEqual([r1.streamId]);
127
+
128
+ // Now 2 is active, but (in real monitor) it may still be in debounce before markStarted.
129
+ // We simulate that by NOT calling markStarted. Follow-up should merge into active (same streamId).
130
+ const r3 = store.addPendingMessage({
131
+ conversationKey,
132
+ target,
133
+ msg: { msgid: "M3" } as any,
134
+ msgContent: "3",
135
+ nonce: "n",
136
+ timestamp: "t",
137
+ debounceMs: 10,
138
+ });
139
+ expect(r3.streamId).toBe(r2.streamId);
140
+ expect(r3.status).toBe("active_merged");
141
+ } finally {
142
+ vi.useRealTimers();
143
+ }
144
+ });
145
+
146
+ test("clears conversation state when idle so next message becomes active", async () => {
147
+ const store = new StreamStore();
148
+ store.setFlushHandler(() => { });
149
+
150
+ const target = {
151
+ account: {} as any,
152
+ config: {} as any,
153
+ runtime: {},
154
+ core: {} as any,
155
+ path: "/wecom",
156
+ } satisfies WecomWebhookTarget;
157
+
158
+ const conversationKey = "wecom:default:U:idle";
159
+
160
+ const r1 = store.addPendingMessage({
161
+ conversationKey,
162
+ target,
163
+ msg: { msgid: "M1" } as any,
164
+ msgContent: "1",
165
+ nonce: "n",
166
+ timestamp: "t",
167
+ debounceMs: 10,
168
+ });
169
+ store.markStarted(r1.streamId);
170
+ store.markFinished(r1.streamId);
171
+ store.onStreamFinished(r1.streamId);
172
+
173
+ const r2 = store.addPendingMessage({
174
+ conversationKey,
175
+ target,
176
+ msg: { msgid: "M2" } as any,
177
+ msgContent: "2",
178
+ nonce: "n",
179
+ timestamp: "t",
180
+ debounceMs: 10,
181
+ });
182
+ expect(r2.status).toBe("active_new");
183
+ expect(r2.streamId).not.toBe(r1.streamId);
184
+ });
185
+ });