@yanhaidao/wecom 2.2.28 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -28
- package/assets/register.png +0 -0
- package/changelog/v2.3.2.md +28 -0
- package/package.json +1 -1
- package/src/agent/api-client.ts +76 -34
- package/src/agent/api-client.upload.test.ts +110 -0
- package/src/agent/handler.ts +4 -4
- package/src/channel.lifecycle.test.ts +24 -6
- package/src/channel.ts +11 -6
- package/src/config/network.ts +9 -5
- package/src/gateway-monitor.ts +51 -20
- package/src/http.ts +16 -2
- package/src/media.test.ts +28 -1
- package/src/media.ts +59 -1
- package/src/monitor.active.test.ts +9 -6
- package/src/monitor.integration.test.ts +4 -2
- package/src/monitor.ts +511 -82
- package/src/monitor.webhook.test.ts +104 -11
- package/src/onboarding.ts +219 -43
- package/src/outbound.ts +6 -0
- package/src/types/constants.ts +7 -3
package/src/gateway-monitor.ts
CHANGED
|
@@ -12,10 +12,11 @@ import {
|
|
|
12
12
|
} from "./config/index.js";
|
|
13
13
|
import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
|
|
14
14
|
import type { ResolvedWecomAccount, WecomConfig } from "./types/index.js";
|
|
15
|
+
import { WEBHOOK_PATHS } from "./types/constants.js";
|
|
15
16
|
|
|
16
17
|
type AccountRouteRegistryItem = {
|
|
17
18
|
botPaths: string[];
|
|
18
|
-
|
|
19
|
+
agentPaths: string[];
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
const accountRouteRegistry = new Map<string, AccountRouteRegistryItem>();
|
|
@@ -41,7 +42,7 @@ function logRegisteredRouteSummary(
|
|
|
41
42
|
const routes = accountRouteRegistry.get(accountId);
|
|
42
43
|
if (!routes) return undefined;
|
|
43
44
|
const botText = routes.botPaths.length > 0 ? routes.botPaths.join(", ") : "未启用";
|
|
44
|
-
const agentText = routes.
|
|
45
|
+
const agentText = routes.agentPaths.length > 0 ? routes.agentPaths.join(", ") : "未启用";
|
|
45
46
|
return `accountId=${accountId}(Bot: ${botText};Agent: ${agentText})`;
|
|
46
47
|
})
|
|
47
48
|
.filter((entry): entry is string => Boolean(entry));
|
|
@@ -74,6 +75,30 @@ function waitForAbortSignal(abortSignal: AbortSignal): Promise<void> {
|
|
|
74
75
|
});
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function uniquePaths(paths: string[]): string[] {
|
|
79
|
+
return Array.from(new Set(paths.map((path) => path.trim()).filter(Boolean)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveBotRegistrationPaths(params: { accountId: string; matrixMode: boolean }): string[] {
|
|
83
|
+
if (params.matrixMode) {
|
|
84
|
+
return uniquePaths([
|
|
85
|
+
`${WEBHOOK_PATHS.BOT_PLUGIN}/${params.accountId}`,
|
|
86
|
+
`${WEBHOOK_PATHS.BOT_ALT}/${params.accountId}`,
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
return uniquePaths([WEBHOOK_PATHS.BOT_PLUGIN, WEBHOOK_PATHS.BOT, WEBHOOK_PATHS.BOT_ALT]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveAgentRegistrationPaths(params: { accountId: string; matrixMode: boolean }): string[] {
|
|
93
|
+
if (params.matrixMode) {
|
|
94
|
+
return uniquePaths([
|
|
95
|
+
`${WEBHOOK_PATHS.AGENT_PLUGIN}/${params.accountId}`,
|
|
96
|
+
`${WEBHOOK_PATHS.AGENT}/${params.accountId}`,
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
return uniquePaths([WEBHOOK_PATHS.AGENT_PLUGIN, WEBHOOK_PATHS.AGENT]);
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
/**
|
|
78
103
|
* Keeps WeCom webhook targets registered for the account lifecycle.
|
|
79
104
|
* The promise only settles after gateway abort/reload signals shutdown.
|
|
@@ -129,12 +154,13 @@ export async function monitorWecomProvider(
|
|
|
129
154
|
|
|
130
155
|
const unregisters: Array<() => void> = [];
|
|
131
156
|
const botPaths: string[] = [];
|
|
132
|
-
|
|
157
|
+
const agentPaths: string[] = [];
|
|
133
158
|
try {
|
|
134
159
|
if (bot && botConfigured) {
|
|
135
|
-
const paths =
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
const paths = resolveBotRegistrationPaths({
|
|
161
|
+
accountId: account.accountId,
|
|
162
|
+
matrixMode,
|
|
163
|
+
});
|
|
138
164
|
for (const path of paths) {
|
|
139
165
|
unregisters.push(
|
|
140
166
|
registerWecomWebhookTarget({
|
|
@@ -154,20 +180,25 @@ export async function monitorWecomProvider(
|
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
if (agent && agentConfigured) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
183
|
+
const paths = resolveAgentRegistrationPaths({
|
|
184
|
+
accountId: account.accountId,
|
|
185
|
+
matrixMode,
|
|
186
|
+
});
|
|
187
|
+
for (const path of paths) {
|
|
188
|
+
unregisters.push(
|
|
189
|
+
registerAgentWebhookTarget({
|
|
190
|
+
agent,
|
|
191
|
+
config: cfg,
|
|
192
|
+
runtime: ctx.runtime,
|
|
193
|
+
path,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
agentPaths.push(...paths);
|
|
198
|
+
ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at ${paths.join(", ")}`);
|
|
168
199
|
}
|
|
169
200
|
|
|
170
|
-
accountRouteRegistry.set(account.accountId, { botPaths,
|
|
201
|
+
accountRouteRegistry.set(account.accountId, { botPaths, agentPaths });
|
|
171
202
|
const shouldLogSummary =
|
|
172
203
|
expectedRouteSummaryAccountIds.length <= 1 ||
|
|
173
204
|
expectedRouteSummaryAccountIds.every((accountId) => accountRouteRegistry.has(accountId));
|
|
@@ -180,8 +211,8 @@ export async function monitorWecomProvider(
|
|
|
180
211
|
running: true,
|
|
181
212
|
configured: true,
|
|
182
213
|
webhookPath: botConfigured
|
|
183
|
-
? (
|
|
184
|
-
: (
|
|
214
|
+
? (botPaths[0] ?? WEBHOOK_PATHS.BOT_PLUGIN)
|
|
215
|
+
: (agentPaths[0] ?? WEBHOOK_PATHS.AGENT_PLUGIN),
|
|
185
216
|
lastStartAt: Date.now(),
|
|
186
217
|
});
|
|
187
218
|
|
package/src/http.ts
CHANGED
|
@@ -57,13 +57,28 @@ export async function wecomFetch(input: string | URL, init?: RequestInit, opts?:
|
|
|
57
57
|
|
|
58
58
|
const initSignal = init?.signal ?? undefined;
|
|
59
59
|
const signal = mergeAbortSignal({ signal: opts?.signal ?? initSignal, timeoutMs: opts?.timeoutMs });
|
|
60
|
+
|
|
61
|
+
const headers = new Headers(init?.headers ?? {});
|
|
62
|
+
if (!headers.has("User-Agent")) {
|
|
63
|
+
headers.set("User-Agent", "OpenClaw/2.0 (WeCom-Agent)");
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
const nextInit: RequestInit & { dispatcher?: Dispatcher } = {
|
|
61
67
|
...(init ?? {}),
|
|
62
68
|
...(signal ? { signal } : {}),
|
|
63
69
|
...(dispatcher ? { dispatcher } : {}),
|
|
70
|
+
headers,
|
|
64
71
|
};
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
try {
|
|
74
|
+
return await undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Response;
|
|
75
|
+
} catch (err: unknown) {
|
|
76
|
+
if (err instanceof Error && err.name === "TypeError" && err.message === "fetch failed") {
|
|
77
|
+
const cause = (err as any).cause;
|
|
78
|
+
console.error(`[wecom-http] fetch failed: ${input} (proxy: ${proxyUrl || "none"})${cause ? ` - cause: ${String(cause)}` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
@@ -99,4 +114,3 @@ export async function readResponseBodyAsBuffer(res: Response, maxBytes?: number)
|
|
|
99
114
|
|
|
100
115
|
return Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
|
101
116
|
}
|
|
102
|
-
|
package/src/media.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { decryptWecomMedia } from "./media.js";
|
|
2
|
+
import { decryptWecomMedia, decryptWecomMediaWithMeta } from "./media.js";
|
|
3
3
|
import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
4
4
|
import crypto from "node:crypto";
|
|
5
5
|
|
|
@@ -52,4 +52,31 @@ describe("decryptWecomMedia", () => {
|
|
|
52
52
|
it("should fail if key is invalid", async () => {
|
|
53
53
|
await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
it("should return source metadata when using decryptWecomMediaWithMeta", async () => {
|
|
57
|
+
const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa=";
|
|
58
|
+
const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
|
|
59
|
+
const iv = aesKey.subarray(0, 16);
|
|
60
|
+
const originalData = Buffer.from("meta test", "utf8");
|
|
61
|
+
const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
|
|
62
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
63
|
+
cipher.setAutoPadding(false);
|
|
64
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
65
|
+
|
|
66
|
+
undiciFetch.mockResolvedValue(
|
|
67
|
+
new Response(encrypted, {
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/octet-stream; charset=binary",
|
|
71
|
+
"content-disposition": "attachment; filename*=UTF-8''report%20v1.docx",
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const decrypted = await decryptWecomMediaWithMeta("http://mock.url/media?id=1", aesKeyBase64);
|
|
77
|
+
expect(decrypted.buffer.toString("utf8")).toBe("meta test");
|
|
78
|
+
expect(decrypted.sourceContentType).toBe("application/octet-stream");
|
|
79
|
+
expect(decrypted.sourceFilename).toBe("report v1.docx");
|
|
80
|
+
expect(decrypted.sourceUrl).toBe("http://mock.url/media?id=1");
|
|
81
|
+
});
|
|
55
82
|
});
|
package/src/media.ts
CHANGED
|
@@ -2,6 +2,41 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
3
3
|
import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
|
|
4
4
|
|
|
5
|
+
export type DecryptedWecomMedia = {
|
|
6
|
+
buffer: Buffer;
|
|
7
|
+
sourceContentType?: string;
|
|
8
|
+
sourceFilename?: string;
|
|
9
|
+
sourceUrl?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function normalizeMime(contentType?: string | null): string | undefined {
|
|
13
|
+
const raw = String(contentType ?? "").trim();
|
|
14
|
+
if (!raw) return undefined;
|
|
15
|
+
return raw.split(";")[0]?.trim().toLowerCase() || undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractFilenameFromContentDisposition(disposition?: string | null): string | undefined {
|
|
19
|
+
const raw = String(disposition ?? "").trim();
|
|
20
|
+
if (!raw) return undefined;
|
|
21
|
+
|
|
22
|
+
const star = raw.match(/filename\*\s*=\s*([^;]+)/i);
|
|
23
|
+
if (star?.[1]) {
|
|
24
|
+
const v = star[1].trim().replace(/^UTF-8''/i, "").replace(/^"(.*)"$/, "$1");
|
|
25
|
+
try {
|
|
26
|
+
const decoded = decodeURIComponent(v);
|
|
27
|
+
if (decoded.trim()) return decoded.trim();
|
|
28
|
+
} catch { /* ignore */ }
|
|
29
|
+
if (v.trim()) return v.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plain = raw.match(/filename\s*=\s*([^;]+)/i);
|
|
33
|
+
if (plain?.[1]) {
|
|
34
|
+
const v = plain[1].trim().replace(/^"(.*)"$/, "$1").trim();
|
|
35
|
+
if (v) return v;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
5
40
|
/**
|
|
6
41
|
* **decryptWecomMedia (解密企业微信媒体文件)**
|
|
7
42
|
*
|
|
@@ -28,11 +63,29 @@ export async function decryptWecomMediaWithHttp(
|
|
|
28
63
|
encodingAESKey: string,
|
|
29
64
|
params?: { maxBytes?: number; http?: WecomHttpOptions },
|
|
30
65
|
): Promise<Buffer> {
|
|
66
|
+
const decrypted = await decryptWecomMediaWithMeta(url, encodingAESKey, params);
|
|
67
|
+
return decrypted.buffer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* **decryptWecomMediaWithMeta (解密企业微信媒体并返回源信息)**
|
|
72
|
+
*
|
|
73
|
+
* 在返回解密结果的同时,保留下载响应中的元信息(content-type / filename / final url),
|
|
74
|
+
* 供上层更准确地推断文件后缀和 MIME。
|
|
75
|
+
*/
|
|
76
|
+
export async function decryptWecomMediaWithMeta(
|
|
77
|
+
url: string,
|
|
78
|
+
encodingAESKey: string,
|
|
79
|
+
params?: { maxBytes?: number; http?: WecomHttpOptions },
|
|
80
|
+
): Promise<DecryptedWecomMedia> {
|
|
31
81
|
// 1. Download encrypted content
|
|
32
82
|
const res = await wecomFetch(url, undefined, { ...params?.http, timeoutMs: params?.http?.timeoutMs ?? 15_000 });
|
|
33
83
|
if (!res.ok) {
|
|
34
84
|
throw new Error(`failed to download media: ${res.status}`);
|
|
35
85
|
}
|
|
86
|
+
const sourceContentType = normalizeMime(res.headers.get("content-type"));
|
|
87
|
+
const sourceFilename = extractFilenameFromContentDisposition(res.headers.get("content-disposition"));
|
|
88
|
+
const sourceUrl = res.url || url;
|
|
36
89
|
const encryptedData = await readResponseBodyAsBuffer(res, params?.maxBytes);
|
|
37
90
|
|
|
38
91
|
// 2. Prepare Key and IV
|
|
@@ -51,5 +104,10 @@ export async function decryptWecomMediaWithHttp(
|
|
|
51
104
|
// Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
|
|
52
105
|
// The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
|
|
53
106
|
// Our pkcs7Unpad function does exactly this + validation.
|
|
54
|
-
return
|
|
107
|
+
return {
|
|
108
|
+
buffer: pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE),
|
|
109
|
+
sourceContentType,
|
|
110
|
+
sourceFilename,
|
|
111
|
+
sourceUrl,
|
|
112
|
+
};
|
|
55
113
|
}
|
|
@@ -28,7 +28,7 @@ function createMockRequest(bodyObj: any): IncomingMessage {
|
|
|
28
28
|
const socket = new Socket();
|
|
29
29
|
const req = new IncomingMessage(socket);
|
|
30
30
|
req.method = "POST";
|
|
31
|
-
req.url = "/wecom?timestamp=123&nonce=456&signature=789";
|
|
31
|
+
req.url = "/plugins/wecom/bot/default?timestamp=123&nonce=456&signature=789";
|
|
32
32
|
req.push(JSON.stringify(bodyObj));
|
|
33
33
|
req.push(null);
|
|
34
34
|
return req;
|
|
@@ -140,7 +140,7 @@ describe("Monitor Active Features", () => {
|
|
|
140
140
|
} as any,
|
|
141
141
|
runtime: { log: () => { } },
|
|
142
142
|
core: mockCore,
|
|
143
|
-
path: "/wecom"
|
|
143
|
+
path: "/plugins/wecom/bot/default"
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
146
|
|
|
@@ -190,14 +190,17 @@ describe("Monitor Active Features", () => {
|
|
|
190
190
|
undiciFetch.mockResolvedValue(new Response("ok", { status: 200 }));
|
|
191
191
|
await sendActiveMessage(streamId, "Active Hello");
|
|
192
192
|
|
|
193
|
-
expect(undiciFetch).
|
|
194
|
-
|
|
193
|
+
expect(undiciFetch).toHaveBeenCalled();
|
|
194
|
+
const [url, init] = undiciFetch.mock.calls.at(-1)! as [string, RequestInit];
|
|
195
|
+
expect(url).toBe("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key");
|
|
196
|
+
expect(init).toEqual(
|
|
195
197
|
expect.objectContaining({
|
|
196
198
|
method: "POST",
|
|
197
|
-
headers: expect.objectContaining({ "Content-Type": "application/json" }),
|
|
198
199
|
body: JSON.stringify({ msgtype: "text", text: { content: "Active Hello" } }),
|
|
199
200
|
}),
|
|
200
201
|
);
|
|
202
|
+
const headers = new Headers(init.headers);
|
|
203
|
+
expect(headers.get("content-type")).toBe("application/json");
|
|
201
204
|
});
|
|
202
205
|
|
|
203
206
|
it("should fallback non-image media to agent DM (and push a Chinese prompt)", async () => {
|
|
@@ -237,6 +240,6 @@ describe("Monitor Active Features", () => {
|
|
|
237
240
|
expect(undiciFetch).toHaveBeenCalled();
|
|
238
241
|
});
|
|
239
242
|
|
|
240
|
-
// 注:本机路径(/Users
|
|
243
|
+
// 注:本机路径(/Users/...、/tmp/...、/root/...、/home/...)短路发图逻辑属于运行态特性,
|
|
241
244
|
// 单测在 fake timers + module singleton 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。
|
|
242
245
|
});
|
|
@@ -21,7 +21,7 @@ function createMockRequest(bodyObj: any, query: URLSearchParams): IncomingMessag
|
|
|
21
21
|
const socket = new Socket();
|
|
22
22
|
const req = new IncomingMessage(socket);
|
|
23
23
|
req.method = "POST";
|
|
24
|
-
req.url = `/wecom?${query.toString()}`;
|
|
24
|
+
req.url = `/plugins/wecom/bot/default?${query.toString()}`;
|
|
25
25
|
req.push(JSON.stringify(bodyObj));
|
|
26
26
|
req.push(null);
|
|
27
27
|
return req;
|
|
@@ -108,7 +108,7 @@ describe("Monitor Integration: Inbound Image", () => {
|
|
|
108
108
|
config: {} as any,
|
|
109
109
|
runtime: { log: console.log, error: console.error },
|
|
110
110
|
core: mockCore as any,
|
|
111
|
-
path: "/wecom"
|
|
111
|
+
path: "/plugins/wecom/bot/default"
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
|
|
@@ -198,6 +198,8 @@ describe("Monitor Integration: Inbound Image", () => {
|
|
|
198
198
|
// Expect Context Injection
|
|
199
199
|
expect(ctx.MediaPath).toBe("/tmp/saved-image.jpg");
|
|
200
200
|
expect(ctx.MediaType).toBe("image/jpeg");
|
|
201
|
+
expect(ctx.Surface).toBe("wecom");
|
|
202
|
+
expect(ctx.OriginatingChannel).toBe("wecom");
|
|
201
203
|
|
|
202
204
|
expect(undiciFetch).toHaveBeenCalledWith(
|
|
203
205
|
imageUrl,
|