@yanhaidao/wecom 2.0.2 → 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 +314 -71
- package/assets/02.image.jpg +0 -0
- package/assets/03.agent.page.png +0 -0
- package/assets/03.bot.page.png +0 -0
- package/index.ts +8 -0
- package/package.json +7 -2
- package/src/agent/api-client.ts +287 -0
- package/src/agent/handler.ts +401 -0
- package/src/agent/index.ts +12 -0
- package/src/channel.ts +111 -64
- package/src/config/accounts.ts +99 -0
- package/src/config/index.ts +11 -0
- package/src/config/network.ts +16 -0
- package/src/config/schema.ts +104 -0
- package/src/config-schema.ts +2 -0
- package/src/crypto/aes.ts +108 -0
- package/src/crypto/index.ts +24 -0
- package/src/crypto/signature.ts +43 -0
- package/src/crypto/xml.ts +49 -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 +109 -7
- package/src/monitor.integration.test.ts +22 -5
- package/src/monitor.ts +964 -147
- package/src/onboarding.ts +463 -0
- package/src/outbound.test.ts +100 -0
- package/src/outbound.ts +171 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/xml-parser.ts +85 -0
- package/src/target.ts +80 -0
- package/src/types/account.ts +76 -0
- package/src/types/config.ts +88 -0
- package/src/types/constants.ts +42 -0
- package/src/types/global.d.ts +9 -0
- package/src/types/index.ts +38 -0
- package/src/types/message.ts +183 -0
|
@@ -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
|
+
}
|
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);
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { StreamState, PendingInbound, ActiveReplyState, WecomWebhookTarget } from "./types.js";
|
|
3
|
+
import type { WecomInboundMessage } from "../types.js";
|
|
4
|
+
|
|
5
|
+
// Constants
|
|
6
|
+
export const LIMITS = {
|
|
7
|
+
STREAM_TTL_MS: 10 * 60 * 1000,
|
|
8
|
+
ACTIVE_REPLY_TTL_MS: 60 * 60 * 1000,
|
|
9
|
+
DEFAULT_DEBOUNCE_MS: 500,
|
|
10
|
+
STREAM_MAX_BYTES: 20_480,
|
|
11
|
+
REQUEST_TIMEOUT_MS: 15_000
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **StreamStore (流状态会话存储)**
|
|
16
|
+
*
|
|
17
|
+
* 管理企业微信回调的流式会话状态、消息去重和防抖聚合逻辑。
|
|
18
|
+
* 负责维护 msgid 到 streamId 的映射,以及临时缓存待处理的 Pending 消息。
|
|
19
|
+
*/
|
|
20
|
+
export class StreamStore {
|
|
21
|
+
private streams = new Map<string, StreamState>();
|
|
22
|
+
private msgidToStreamId = new Map<string, string>();
|
|
23
|
+
private pendingInbounds = new Map<string, PendingInbound>();
|
|
24
|
+
private onFlush?: (pending: PendingInbound) => void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* **setFlushHandler (设置防抖刷新回调)**
|
|
28
|
+
*
|
|
29
|
+
* 当防抖计时器结束时调用的处理函数。通常用于触发 Agent 进行消息处理。
|
|
30
|
+
* @param handler 回调函数,接收聚合后的 PendingInbound 对象
|
|
31
|
+
*/
|
|
32
|
+
public setFlushHandler(handler: (pending: PendingInbound) => void) {
|
|
33
|
+
this.onFlush = handler;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* **createStream (创建流会话)**
|
|
38
|
+
*
|
|
39
|
+
* 初始化一个新的流式会话状态。
|
|
40
|
+
* @param params.msgid (可选) 企业微信消息 ID,用于后续去重映射
|
|
41
|
+
* @returns 生成的 streamId (Hex 字符串)
|
|
42
|
+
*/
|
|
43
|
+
createStream(params: { msgid?: string }): string {
|
|
44
|
+
const streamId = crypto.randomBytes(16).toString("hex");
|
|
45
|
+
|
|
46
|
+
if (params.msgid) {
|
|
47
|
+
this.msgidToStreamId.set(String(params.msgid), streamId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.streams.set(streamId, {
|
|
51
|
+
streamId,
|
|
52
|
+
msgid: params.msgid,
|
|
53
|
+
createdAt: Date.now(),
|
|
54
|
+
updatedAt: Date.now(),
|
|
55
|
+
started: false,
|
|
56
|
+
finished: false,
|
|
57
|
+
content: ""
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return streamId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* **getStream (获取流状态)**
|
|
65
|
+
*
|
|
66
|
+
* 根据 streamId 获取当前的会话状态。
|
|
67
|
+
* @param streamId 流会话 ID
|
|
68
|
+
*/
|
|
69
|
+
getStream(streamId: string): StreamState | undefined {
|
|
70
|
+
return this.streams.get(streamId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* **getStreamByMsgId (通过 msgid 查找流 ID)**
|
|
75
|
+
*
|
|
76
|
+
* 用于消息去重:检查该 msgid 是否已经关联由正在进行或已完成的流会话。
|
|
77
|
+
* @param msgid 企业微信消息 ID
|
|
78
|
+
*/
|
|
79
|
+
getStreamByMsgId(msgid: string): string | undefined {
|
|
80
|
+
return this.msgidToStreamId.get(String(msgid));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* **updateStream (更新流状态)**
|
|
85
|
+
*
|
|
86
|
+
* 原子更新流状态,并自动刷新 updatedAt 时间戳。
|
|
87
|
+
* @param streamId 流会话 ID
|
|
88
|
+
* @param mutator 状态修改函数
|
|
89
|
+
*/
|
|
90
|
+
updateStream(streamId: string, mutator: (state: StreamState) => void): void {
|
|
91
|
+
const state = this.streams.get(streamId);
|
|
92
|
+
if (state) {
|
|
93
|
+
mutator(state);
|
|
94
|
+
state.updatedAt = Date.now();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* **markStarted (标记流开始)**
|
|
100
|
+
*
|
|
101
|
+
* 标记该流会话已经开始处理(通常在 Agent 启动后调用)。
|
|
102
|
+
*/
|
|
103
|
+
markStarted(streamId: string): void {
|
|
104
|
+
this.updateStream(streamId, (s) => { s.started = true; });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* **markFinished (标记流结束)**
|
|
109
|
+
*
|
|
110
|
+
* 标记该流会话已完成,不再接收内容更新。
|
|
111
|
+
*/
|
|
112
|
+
markFinished(streamId: string): void {
|
|
113
|
+
this.updateStream(streamId, (s) => { s.finished = true; });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* **addPendingMessage (添加待处理消息 / 防抖聚合)**
|
|
118
|
+
*
|
|
119
|
+
* 将收到的消息加入待处理队列。如果相同 pendingKey 已存在,则是防抖聚合;否则创建新条目。
|
|
120
|
+
* 会自动设置或重置防抖定时器。
|
|
121
|
+
*
|
|
122
|
+
* @param params 消息参数
|
|
123
|
+
* @returns { streamId, isNew } isNew=true 表示这是新的一组消息,需初始化 ActiveReply
|
|
124
|
+
*/
|
|
125
|
+
addPendingMessage(params: {
|
|
126
|
+
pendingKey: string;
|
|
127
|
+
target: WecomWebhookTarget;
|
|
128
|
+
msg: WecomInboundMessage;
|
|
129
|
+
msgContent: string;
|
|
130
|
+
nonce: string;
|
|
131
|
+
timestamp: string;
|
|
132
|
+
debounceMs?: number;
|
|
133
|
+
}): { streamId: string; isNew: boolean } {
|
|
134
|
+
const { pendingKey, target, msg, msgContent, nonce, timestamp, debounceMs } = params;
|
|
135
|
+
const effectiveDebounceMs = debounceMs ?? LIMITS.DEFAULT_DEBOUNCE_MS;
|
|
136
|
+
const existing = this.pendingInbounds.get(pendingKey);
|
|
137
|
+
|
|
138
|
+
if (existing) {
|
|
139
|
+
existing.contents.push(msgContent);
|
|
140
|
+
if (msg.msgid) existing.msgids.push(msg.msgid);
|
|
141
|
+
if (existing.timeout) clearTimeout(existing.timeout);
|
|
142
|
+
|
|
143
|
+
// 重置定时器 (Debounce)
|
|
144
|
+
existing.timeout = setTimeout(() => {
|
|
145
|
+
this.flushPending(pendingKey);
|
|
146
|
+
}, effectiveDebounceMs);
|
|
147
|
+
|
|
148
|
+
return { streamId: existing.streamId, isNew: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 创建新的聚合分组
|
|
152
|
+
const streamId = this.createStream({ msgid: msg.msgid });
|
|
153
|
+
const pending: PendingInbound = {
|
|
154
|
+
streamId,
|
|
155
|
+
target,
|
|
156
|
+
msg,
|
|
157
|
+
contents: [msgContent],
|
|
158
|
+
msgids: msg.msgid ? [msg.msgid] : [],
|
|
159
|
+
nonce,
|
|
160
|
+
timestamp,
|
|
161
|
+
createdAt: Date.now(),
|
|
162
|
+
timeout: setTimeout(() => {
|
|
163
|
+
this.flushPending(pendingKey);
|
|
164
|
+
}, effectiveDebounceMs)
|
|
165
|
+
};
|
|
166
|
+
this.pendingInbounds.set(pendingKey, pending);
|
|
167
|
+
return { streamId, isNew: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* **flushPending (触发消息处理)**
|
|
172
|
+
*
|
|
173
|
+
* 内部方法:防抖时间结束后,将聚合的消息一次性推送给 flushHandler。
|
|
174
|
+
*/
|
|
175
|
+
private flushPending(pendingKey: string): void {
|
|
176
|
+
const pending = this.pendingInbounds.get(pendingKey);
|
|
177
|
+
if (!pending) return;
|
|
178
|
+
|
|
179
|
+
this.pendingInbounds.delete(pendingKey);
|
|
180
|
+
if (pending.timeout) {
|
|
181
|
+
clearTimeout(pending.timeout);
|
|
182
|
+
pending.timeout = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.onFlush) {
|
|
186
|
+
this.onFlush(pending);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* **prune (清理过期状态)**
|
|
192
|
+
*
|
|
193
|
+
* 清理过期的流会话、msgid 映射以及残留的 Pending 消息。
|
|
194
|
+
* @param now 当前时间戳 (毫秒)
|
|
195
|
+
*/
|
|
196
|
+
prune(now: number = Date.now()): void {
|
|
197
|
+
const streamCutoff = now - LIMITS.STREAM_TTL_MS;
|
|
198
|
+
|
|
199
|
+
// 清理过期的流会话
|
|
200
|
+
for (const [id, state] of this.streams.entries()) {
|
|
201
|
+
if (state.updatedAt < streamCutoff) {
|
|
202
|
+
this.streams.delete(id);
|
|
203
|
+
if (state.msgid) {
|
|
204
|
+
// 如果 msgid 映射仍指向该 stream,则一并移除
|
|
205
|
+
if (this.msgidToStreamId.get(state.msgid) === id) {
|
|
206
|
+
this.msgidToStreamId.delete(state.msgid);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 清理悬空的 msgid 映射 (Double check)
|
|
213
|
+
for (const [msgid, id] of this.msgidToStreamId.entries()) {
|
|
214
|
+
if (!this.streams.has(id)) {
|
|
215
|
+
this.msgidToStreamId.delete(msgid);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 清理超时的 Pending 消息 (通常由 timeout 清理,此处作为兜底)
|
|
220
|
+
for (const [key, pending] of this.pendingInbounds.entries()) {
|
|
221
|
+
if (now - pending.createdAt > LIMITS.STREAM_TTL_MS) {
|
|
222
|
+
if (pending.timeout) clearTimeout(pending.timeout);
|
|
223
|
+
this.pendingInbounds.delete(key);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* **ActiveReplyStore (主动回复地址存储)**
|
|
231
|
+
*
|
|
232
|
+
* 管理企业微信回调中的 `response_url` (用于被动回复转主动推送) 和 `proxyUrl`。
|
|
233
|
+
* 支持 'once' (一次性) 或 'multi' (多次) 使用策略。
|
|
234
|
+
*/
|
|
235
|
+
export class ActiveReplyStore {
|
|
236
|
+
private activeReplies = new Map<string, ActiveReplyState>();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param policy 使用策略: "once" (默认,销毁式) 或 "multi"
|
|
240
|
+
*/
|
|
241
|
+
constructor(private policy: "once" | "multi" = "once") { }
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* **store (存储回复地址)**
|
|
245
|
+
*
|
|
246
|
+
* 关联 streamId 与 response_url。
|
|
247
|
+
*/
|
|
248
|
+
store(streamId: string, responseUrl?: string, proxyUrl?: string): void {
|
|
249
|
+
const url = responseUrl?.trim();
|
|
250
|
+
if (!url) return;
|
|
251
|
+
this.activeReplies.set(streamId, { response_url: url, proxyUrl, createdAt: Date.now() });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* **getUrl (获取回复地址)**
|
|
256
|
+
*
|
|
257
|
+
* 获取指定 streamId 关联的 response_url。
|
|
258
|
+
*/
|
|
259
|
+
getUrl(streamId: string): string | undefined {
|
|
260
|
+
return this.activeReplies.get(streamId)?.response_url;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* **use (消耗回复地址)**
|
|
265
|
+
*
|
|
266
|
+
* 使用存储的 response_url 执行操作。
|
|
267
|
+
* - 如果策略是 "once",第二次调用会抛错。
|
|
268
|
+
* - 自动更新使用时间 (usedAt)。
|
|
269
|
+
*
|
|
270
|
+
* @param streamId 流会话 ID
|
|
271
|
+
* @param fn 执行函数,接收 { responseUrl, proxyUrl }
|
|
272
|
+
*/
|
|
273
|
+
async use(streamId: string, fn: (params: { responseUrl: string; proxyUrl?: string }) => Promise<void>): Promise<void> {
|
|
274
|
+
const state = this.activeReplies.get(streamId);
|
|
275
|
+
if (!state?.response_url) {
|
|
276
|
+
return; // 无 URL 可用,安全跳过
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (this.policy === "once" && state.usedAt) {
|
|
280
|
+
throw new Error(`response_url already used for stream ${streamId} (Policy: once)`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await fn({ responseUrl: state.response_url, proxyUrl: state.proxyUrl });
|
|
285
|
+
state.usedAt = Date.now();
|
|
286
|
+
} catch (err: unknown) {
|
|
287
|
+
state.lastError = err instanceof Error ? err.message : String(err);
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* **prune (清理过期地址)**
|
|
294
|
+
*
|
|
295
|
+
* 清理超过 TTL 的 active reply 记录。
|
|
296
|
+
*/
|
|
297
|
+
prune(now: number = Date.now()): void {
|
|
298
|
+
const cutoff = now - LIMITS.ACTIVE_REPLY_TTL_MS;
|
|
299
|
+
for (const [id, state] of this.activeReplies.entries()) {
|
|
300
|
+
if (state.createdAt < cutoff) {
|
|
301
|
+
this.activeReplies.delete(id);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* **MonitorState (全局监控状态容器)**
|
|
309
|
+
*
|
|
310
|
+
* 模块单例,统一管理 StreamStore 和 ActiveReplyStore 实例。
|
|
311
|
+
* 提供生命周期方法 (startPruning / stopPruning) 以自动清理过期数据。
|
|
312
|
+
*/
|
|
313
|
+
class MonitorState {
|
|
314
|
+
/** 主要的流状态存储 */
|
|
315
|
+
public readonly streamStore = new StreamStore();
|
|
316
|
+
/** 主动回复地址存储 */
|
|
317
|
+
public readonly activeReplyStore = new ActiveReplyStore("multi");
|
|
318
|
+
|
|
319
|
+
private pruneInterval?: NodeJS.Timeout;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* **startPruning (启动自动清理)**
|
|
323
|
+
*
|
|
324
|
+
* 启动定时器,定期清理过期的流和回复地址。应在插件有活跃 Target 时调用。
|
|
325
|
+
* @param intervalMs 清理间隔 (默认 60s)
|
|
326
|
+
*/
|
|
327
|
+
public startPruning(intervalMs: number = 60_000): void {
|
|
328
|
+
if (this.pruneInterval) return;
|
|
329
|
+
this.pruneInterval = setInterval(() => {
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
this.streamStore.prune(now);
|
|
332
|
+
this.activeReplyStore.prune(now);
|
|
333
|
+
}, intervalMs);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* **stopPruning (停止自动清理)**
|
|
338
|
+
*
|
|
339
|
+
* 停止定时器。应在插件无活跃 Target 时调用以释放资源。
|
|
340
|
+
*/
|
|
341
|
+
public stopPruning(): void {
|
|
342
|
+
if (this.pruneInterval) {
|
|
343
|
+
clearInterval(this.pruneInterval);
|
|
344
|
+
this.pruneInterval = undefined;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* **monitorState (全局单例)**
|
|
351
|
+
*
|
|
352
|
+
* 导出全局唯一的 MonitorState 实例,供整个应用共享状态。
|
|
353
|
+
*/
|
|
354
|
+
export const monitorState = new MonitorState();
|