@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.
@@ -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
- agentPath?: string;
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.agentPath ?? "未启用";
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
- let agentPath: string | undefined;
157
+ const agentPaths: string[] = [];
133
158
  try {
134
159
  if (bot && botConfigured) {
135
- const paths = matrixMode
136
- ? [`/wecom/bot/${account.accountId}`]
137
- : ["/wecom", "/wecom/bot"];
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 path = matrixMode ? `/wecom/agent/${account.accountId}` : "/wecom/agent";
158
- unregisters.push(
159
- registerAgentWebhookTarget({
160
- agent,
161
- config: cfg,
162
- runtime: ctx.runtime,
163
- path,
164
- }),
165
- );
166
- agentPath = path;
167
- ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at ${path}`);
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, agentPath });
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
- ? (matrixMode ? `/wecom/bot/${account.accountId}` : "/wecom/bot")
184
- : (matrixMode ? `/wecom/agent/${account.accountId}` : "/wecom/agent"),
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
- return undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
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 pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
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).toHaveBeenCalledWith(
194
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
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/... 或 /tmp/...)短路发图逻辑属于运行态特性,
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,