@sunnoy/wecom 1.5.0 → 1.6.0

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 CHANGED
@@ -37,7 +37,7 @@
37
37
 
38
38
  ## 前置要求
39
39
 
40
- - 已安装 [OpenClaw](https://github.com/openclaw/openclaw) (版本 2026.1.30+)
40
+ - 已安装 [OpenClaw](https://github.com/openclaw/openclaw) (版本 2026.3.2+)
41
41
  - 企业微信管理后台权限,可创建智能机器人应用或自建应用
42
42
  - 可从企业微信访问的服务器地址(HTTP/HTTPS)
43
43
 
@@ -124,6 +124,9 @@ npm run test:e2e
124
124
  "enabled": true,
125
125
  "allowlist": ["/new", "/status", "/help", "/compact"]
126
126
  },
127
+ "network": {
128
+ "egressProxyUrl": "http://your-proxy-host:8080"
129
+ },
127
130
  "agent": {
128
131
  "corpId": "企业 CorpID",
129
132
  "corpSecret": "应用 Secret",
@@ -186,6 +189,15 @@ npm run test:e2e
186
189
  | `channels.wecom.agent.token` | string | 是 | 回调 Token (用于验证签名) |
187
190
  | `channels.wecom.agent.encodingAesKey` | string | 是 | 回调 EncodingAESKey (43 位) |
188
191
 
192
+ #### 网络代理配置 (可选)
193
+
194
+ 用于 Agent / Webhook 等外发请求走固定出口代理(适用于企业微信固定 IP 白名单场景)。
195
+
196
+ | 配置项 | 类型 | 必填 | 说明 |
197
+ |--------|------|------|------|
198
+ | `channels.wecom.network.egressProxyUrl` | string | 否 | 外发 HTTP(S) 代理地址,例如 `http://proxy:8080` |
199
+ | `WECOM_EGRESS_PROXY_URL` | env | 否 | 环境变量方式配置代理,优先级高于 `channels.wecom.network.egressProxyUrl` |
200
+
189
201
  #### Webhook 配置 (可选)
190
202
 
191
203
  配置 Webhook Bot 用于群通知:
package/index.js CHANGED
@@ -38,9 +38,18 @@ const plugin = {
38
38
  api.registerChannel({ plugin: wecomChannelPlugin });
39
39
  logger.info("WeCom channel registered");
40
40
 
41
- // Register HTTP handler for webhooks
42
- api.registerHttpHandler(wecomHttpHandler);
43
- logger.info("WeCom HTTP handler registered");
41
+ // Register webhook HTTP route with auth: "plugin" so gateway does NOT
42
+ // enforce Bearer-token auth. WeCom callbacks use msg_signature verification
43
+ // which the plugin handles internally.
44
+ // OpenClaw 3.2 removed registerHttpHandler; use registerHttpRoute with
45
+ // auth: "plugin" + match: "prefix" to handle all /webhooks/* paths.
46
+ api.registerHttpRoute({
47
+ path: "/webhooks",
48
+ handler: wecomHttpHandler,
49
+ auth: "plugin",
50
+ match: "prefix",
51
+ });
52
+ logger.info("WeCom HTTP route registered (auth: plugin, match: prefix)");
44
53
  },
45
54
  };
46
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -22,6 +22,9 @@
22
22
  "peerDependencies": {
23
23
  "openclaw": "*"
24
24
  },
25
+ "optionalDependencies": {
26
+ "undici": "^7.0.0"
27
+ },
25
28
  "scripts": {
26
29
  "test": "npm run test:unit",
27
30
  "test:unit": "node --test tests/*.test.js",
@@ -10,6 +10,7 @@ import {
10
10
  AGENT_API_REQUEST_TIMEOUT_MS,
11
11
  TOKEN_REFRESH_BUFFER_MS,
12
12
  } from "./constants.js";
13
+ import { wecomFetch } from "./http.js";
13
14
 
14
15
  /**
15
16
  * Token cache: Map<corpId:agentId, { token, expiresAt, refreshPromise }>
@@ -43,7 +44,7 @@ export async function getAccessToken(agent) {
43
44
  cache.refreshPromise = (async () => {
44
45
  try {
45
46
  const url = `${AGENT_API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
46
- const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
47
+ const res = await wecomFetch(url);
47
48
  const json = await res.json();
48
49
 
49
50
  if (!json?.access_token) {
@@ -94,11 +95,10 @@ export async function agentSendText(params) {
94
95
  text: { content: text },
95
96
  };
96
97
 
97
- const res = await fetch(url, {
98
+ const res = await wecomFetch(url, {
98
99
  method: "POST",
99
100
  headers: { "Content-Type": "application/json" },
100
101
  body: JSON.stringify(body),
101
- signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
102
102
  });
103
103
  const json = await res.json();
104
104
 
@@ -156,14 +156,13 @@ export async function agentUploadMedia(params) {
156
156
  const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
157
157
  const body = Buffer.concat([header, buffer, footer]);
158
158
 
159
- const res = await fetch(url, {
159
+ const res = await wecomFetch(url, {
160
160
  method: "POST",
161
161
  headers: {
162
162
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
163
163
  "Content-Length": String(body.length),
164
164
  },
165
165
  body,
166
- signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
167
166
  });
168
167
  const json = await res.json();
169
168
 
@@ -206,11 +205,10 @@ export async function agentSendMedia(params) {
206
205
  [mediaType]: mediaPayload,
207
206
  };
208
207
 
209
- const res = await fetch(url, {
208
+ const res = await wecomFetch(url, {
210
209
  method: "POST",
211
210
  headers: { "Content-Type": "application/json" },
212
211
  body: JSON.stringify(body),
213
- signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
214
212
  });
215
213
  const json = await res.json();
216
214
 
@@ -232,7 +230,7 @@ export async function agentDownloadMedia(params) {
232
230
  const token = await getAccessToken(agent);
233
231
  const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
234
232
 
235
- const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
233
+ const res = await wecomFetch(url);
236
234
 
237
235
  if (!res.ok) {
238
236
  throw new Error(`agent download media failed: ${res.status}`);
@@ -16,11 +16,15 @@ import {
16
16
  getDynamicAgentConfig,
17
17
  shouldUseDynamicAgent,
18
18
  } from "../dynamic-agent.js";
19
- import { agentSendText, agentDownloadMedia } from "./agent-api.js";
19
+ import { readFile } from "node:fs/promises";
20
+ import { basename } from "node:path";
21
+ import { agentSendText, agentUploadMedia, agentSendMedia, agentDownloadMedia } from "./agent-api.js";
20
22
  import { resolveAccount } from "./accounts.js";
21
23
  import { resolveWecomCommandAuthorized } from "./allow-from.js";
22
24
  import { checkCommandAllowlist, getCommandConfig, isWecomAdmin } from "./commands.js";
23
25
  import { MAX_REQUEST_BODY_SIZE } from "./constants.js";
26
+ import { resolveAgentMediaTypeFromFilename } from "./channel-plugin.js";
27
+ import { wecomFetch } from "./http.js";
24
28
  import { getRuntime, resolveAgentConfig } from "./state.js";
25
29
  import { ensureDynamicAgentListed } from "./workspace-template.js";
26
30
  import {
@@ -384,6 +388,66 @@ async function processAgentMessage({
384
388
  dispatcherOptions: {
385
389
  deliver: async (payload, info) => {
386
390
  const text = payload.text ?? "";
391
+
392
+ // ── Handle media (images / files) ──────────────────────
393
+ const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
394
+ for (const rawUrl of mediaUrls) {
395
+ try {
396
+ let absolutePath = rawUrl;
397
+ if (absolutePath.startsWith("sandbox:")) {
398
+ absolutePath = absolutePath.replace(/^sandbox:\/{0,2}/, "");
399
+ if (!absolutePath.startsWith("/")) absolutePath = "/" + absolutePath;
400
+ }
401
+
402
+ let buffer;
403
+ let filename;
404
+
405
+ if (absolutePath.startsWith("/")) {
406
+ buffer = await readFile(absolutePath);
407
+ filename = basename(absolutePath);
408
+ } else {
409
+ // Remote URL: download first
410
+ const dlRes = await wecomFetch(rawUrl);
411
+ if (!dlRes.ok) {
412
+ logger.error("[agent-inbound] media download failed", { url: rawUrl, status: dlRes.status });
413
+ continue;
414
+ }
415
+ buffer = Buffer.from(await dlRes.arrayBuffer());
416
+ try {
417
+ filename = basename(new URL(rawUrl).pathname) || "file";
418
+ } catch {
419
+ filename = "file";
420
+ }
421
+ }
422
+
423
+ const uploadType = resolveAgentMediaTypeFromFilename(filename);
424
+ const mediaId = await agentUploadMedia({
425
+ agent: agentConfig,
426
+ type: uploadType,
427
+ buffer,
428
+ filename,
429
+ });
430
+ await agentSendMedia({
431
+ agent: agentConfig,
432
+ toUser: fromUser,
433
+ mediaId,
434
+ mediaType: uploadType,
435
+ });
436
+ logger.info("[agent-inbound] media delivered", {
437
+ kind: info.kind,
438
+ to: fromUser,
439
+ filename,
440
+ uploadType,
441
+ });
442
+ } catch (err) {
443
+ logger.error("[agent-inbound] media delivery failed", {
444
+ url: rawUrl,
445
+ error: err.message,
446
+ });
447
+ }
448
+ }
449
+
450
+ // ── Handle text ────────────────────────────────────────
387
451
  if (!text.trim()) return;
388
452
 
389
453
  try {
@@ -11,7 +11,9 @@ import { messageBuffers, resolveAgentConfig, resolveWebhookUrl, responseUrls, st
11
11
  import { resolveRecoverableStream, unregisterActiveStream } from "./stream-utils.js";
12
12
  import { resolveWecomTarget } from "./target.js";
13
13
  import { webhookSendImage, webhookSendText, webhookUploadFile, webhookSendFile } from "./webhook-bot.js";
14
- import { registerWebhookTarget } from "./webhook-targets.js";
14
+ import { normalizeWebhookPath, registerWebhookTarget } from "./webhook-targets.js";
15
+ import { wecomFetch, setConfigProxyUrl } from "./http.js";
16
+
15
17
 
16
18
  const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
17
19
 
@@ -148,6 +150,17 @@ export const wecomChannelPlugin = {
148
150
  },
149
151
  },
150
152
  },
153
+ network: {
154
+ type: "object",
155
+ description: "Network configuration (proxy, timeouts)",
156
+ additionalProperties: false,
157
+ properties: {
158
+ egressProxyUrl: {
159
+ type: "string",
160
+ description: "HTTP(S) proxy URL for outbound WeCom API requests (e.g. http://proxy:8080). Env var WECOM_EGRESS_PROXY_URL takes precedence.",
161
+ },
162
+ },
163
+ },
151
164
  webhooks: {
152
165
  type: "object",
153
166
  description: "Webhook bot URLs for group notifications (key: name, value: webhook URL or key)",
@@ -291,7 +304,7 @@ export const wecomChannelPlugin = {
291
304
  const saved = responseUrls.get(ctx?.streamKey ?? userId);
292
305
  if (saved && !saved.used && Date.now() < saved.expiresAt) {
293
306
  try {
294
- const response = await fetch(saved.url, {
307
+ const response = await wecomFetch(saved.url, {
295
308
  method: "POST",
296
309
  headers: { "Content-Type": "application/json" },
297
310
  body: JSON.stringify({ msgtype: "text", text: { content: text } }),
@@ -535,7 +548,7 @@ export const wecomChannelPlugin = {
535
548
  buffer = await readFile(absolutePath);
536
549
  filename = basename(absolutePath);
537
550
  } else {
538
- const res = await fetch(mediaUrl);
551
+ const res = await wecomFetch(mediaUrl);
539
552
  buffer = Buffer.from(await res.arrayBuffer());
540
553
  filename = basename(new URL(mediaUrl).pathname) || "image.png";
541
554
  }
@@ -611,7 +624,7 @@ export const wecomChannelPlugin = {
611
624
  });
612
625
  } else {
613
626
  // For external URLs, download first then upload.
614
- const res = await fetch(mediaUrl);
627
+ const res = await wecomFetch(mediaUrl);
615
628
  if (!res.ok) {
616
629
  throw new Error(`download media failed: ${res.status}`);
617
630
  }
@@ -694,6 +707,10 @@ export const wecomChannelPlugin = {
694
707
  webhookPath: account.webhookPath,
695
708
  });
696
709
 
710
+ // Wire proxy URL from config (env var takes precedence inside http.js).
711
+ const wecomCfg = ctx.cfg?.channels?.wecom ?? {};
712
+ setConfigProxyUrl(wecomCfg.network?.egressProxyUrl ?? "");
713
+
697
714
  // Conflict detection: warn about duplicate tokens / agent IDs.
698
715
  const conflicts = detectAccountConflicts(ctx.cfg);
699
716
  for (const conflict of conflicts) {
@@ -709,13 +726,19 @@ export const wecomChannelPlugin = {
709
726
  config: ctx.cfg,
710
727
  });
711
728
 
729
+ // HTTP routing is handled by the wildcard handler registered in
730
+ // index.js via api.registerHttpHandler. That handler bypasses gateway
731
+ // auth, which is required for WeCom webhook callbacks (they carry
732
+ // msg_signature, not Bearer tokens).
733
+ const botPath = account.webhookPath || "/webhooks/wecom";
734
+ logger.info("WeCom Bot webhook path active", { path: botPath });
735
+
712
736
  // Register Agent inbound webhook if agent inbound is fully configured.
713
737
  let unregisterAgent;
714
738
  // Per-account agent path: /webhooks/app for default, /webhooks/app/{accountId} for others.
715
739
  const agentInboundPath = account.accountId === DEFAULT_ACCOUNT_ID
716
740
  ? "/webhooks/app"
717
741
  : `/webhooks/app/${account.accountId}`;
718
- const botPath = account.webhookPath || "/webhooks/wecom";
719
742
  if (account.agentInboundConfigured) {
720
743
  if (botPath === agentInboundPath) {
721
744
  logger.error("WeCom: Agent inbound path conflicts with Bot webhook path, skipping Agent registration", {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared flag that tracks whether the legacy wildcard HTTP handler was
3
+ * successfully registered via api.registerHttpHandler().
4
+ *
5
+ * When true, gateway.startAccount must NOT also register via
6
+ * registerPluginHttpRoute — the latter places the path into
7
+ * registry.httpRoutes which causes shouldEnforceGatewayAuthForPluginPath
8
+ * → isRegisteredPluginHttpRoutePath to return true → gateway auth
9
+ * enforcement runs → WeCom webhook callbacks (which carry msg_signature,
10
+ * not Bearer tokens) get blocked with 401.
11
+ *
12
+ * This module is intentionally separate from index.js to avoid circular
13
+ * ESM imports (index.js ↔ wecom/channel-plugin.js).
14
+ */
15
+ let _wildcardHttpHandlerRegistered = false;
16
+
17
+ export function markWildcardHttpHandlerRegistered() {
18
+ _wildcardHttpHandlerRegistered = true;
19
+ }
20
+
21
+ export function isWildcardHttpHandlerRegistered() {
22
+ return _wildcardHttpHandlerRegistered;
23
+ }
@@ -14,6 +14,33 @@ import {
14
14
  } from "./stream-utils.js";
15
15
  import { normalizeWebhookPath } from "./webhook-targets.js";
16
16
 
17
+ /**
18
+ * Create a per-route HTTP handler for the new `registerPluginHttpRoute` API.
19
+ *
20
+ * The route framework already matches by path, so this handler resolves
21
+ * targets at call time from the pre-registered in-memory map.
22
+ *
23
+ * @param {string} routePath - Normalized webhook path (e.g. "/webhooks/wecom")
24
+ * @returns {(req: IncomingMessage, res: ServerResponse) => Promise<void>}
25
+ */
26
+ export function createWecomRouteHandler(routePath) {
27
+ return async (req, res) => {
28
+ const targets = webhookTargets.get(routePath);
29
+ if (!targets || targets.length === 0) {
30
+ res.writeHead(503, { "Content-Type": "text/plain" });
31
+ res.end("No webhook target configured");
32
+ return;
33
+ }
34
+ const url = new URL(req.url || "", "http://localhost");
35
+ const query = Object.fromEntries(url.searchParams);
36
+ await handleWecomRequest(req, res, targets, query, routePath);
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Legacy wildcard HTTP handler for older OpenClaw versions that still support
42
+ * `api.registerHttpHandler()`. Returns `false` when the path is not handled.
43
+ */
17
44
  export async function wecomHttpHandler(req, res) {
18
45
  const url = new URL(req.url || "", "http://localhost");
19
46
  const path = normalizeWebhookPath(url.pathname);
@@ -24,17 +51,27 @@ export async function wecomHttpHandler(req, res) {
24
51
  }
25
52
 
26
53
  const query = Object.fromEntries(url.searchParams);
54
+ await handleWecomRequest(req, res, targets, query, path);
55
+ return true;
56
+ }
57
+
58
+ /**
59
+ * Shared request handling logic used by both the legacy wildcard handler and
60
+ * the new per-route handler.
61
+ */
62
+ async function handleWecomRequest(req, res, targets, query, path) {
27
63
  logger.debug("WeCom HTTP request", { method: req.method, path });
28
64
 
29
65
  // ── Agent inbound: route to dedicated handler when target has agentInbound config ──
30
66
  const agentTarget = targets.find((t) => t.account?.agentInbound);
31
67
  if (agentTarget) {
32
- return handleAgentInbound({
68
+ await handleAgentInbound({
33
69
  req,
34
70
  res,
35
71
  agentAccount: agentTarget.account.agentInbound,
36
72
  config: agentTarget.config,
37
73
  });
74
+ return;
38
75
  }
39
76
 
40
77
  // ── Bot mode: JSON-based stream handling ──
@@ -45,7 +82,7 @@ export async function wecomHttpHandler(req, res) {
45
82
  if (!target) {
46
83
  res.writeHead(503, { "Content-Type": "text/plain" });
47
84
  res.end("No webhook target configured");
48
- return true;
85
+ return;
49
86
  }
50
87
 
51
88
  const webhook = new WecomWebhook({
@@ -58,13 +95,13 @@ export async function wecomHttpHandler(req, res) {
58
95
  res.writeHead(200, { "Content-Type": "text/plain" });
59
96
  res.end(echo);
60
97
  logger.info("WeCom URL verification successful");
61
- return true;
98
+ return;
62
99
  }
63
100
 
64
101
  res.writeHead(403, { "Content-Type": "text/plain" });
65
102
  res.end("Verification failed");
66
103
  logger.warn("WeCom URL verification failed");
67
- return true;
104
+ return;
68
105
  }
69
106
 
70
107
  // POST: Message handling
@@ -73,7 +110,7 @@ export async function wecomHttpHandler(req, res) {
73
110
  if (!target) {
74
111
  res.writeHead(503, { "Content-Type": "text/plain" });
75
112
  res.end("No webhook target configured");
76
- return true;
113
+ return;
77
114
  }
78
115
 
79
116
  // Read request body
@@ -94,12 +131,12 @@ export async function wecomHttpHandler(req, res) {
94
131
  // Duplicate message — ACK 200 to prevent platform retry storm.
95
132
  res.writeHead(200, { "Content-Type": "text/plain" });
96
133
  res.end("success");
97
- return true;
134
+ return;
98
135
  }
99
136
  if (!result) {
100
137
  res.writeHead(400, { "Content-Type": "text/plain" });
101
138
  res.end("Bad Request");
102
- return true;
139
+ return;
103
140
  }
104
141
 
105
142
  // Handle text message
@@ -156,7 +193,7 @@ export async function wecomHttpHandler(req, res) {
156
193
  logger.error("WeCom message processing failed", { error: err.message });
157
194
  await handleStreamError(streamId, streamKey, "处理消息时出错,请稍后再试。");
158
195
  });
159
- return true;
196
+ return;
160
197
  }
161
198
 
162
199
  // Debounce: buffer non-command messages per user/group.
@@ -187,7 +224,7 @@ export async function wecomHttpHandler(req, res) {
187
224
  logger.info("WeCom: message buffered (first)", { streamKey, streamId });
188
225
  }
189
226
 
190
- return true;
227
+ return;
191
228
  }
192
229
 
193
230
  // Handle stream refresh - return current stream state
@@ -210,7 +247,7 @@ export async function wecomHttpHandler(req, res) {
210
247
  );
211
248
  res.writeHead(200, { "Content-Type": "application/json" });
212
249
  res.end(streamResponse);
213
- return true;
250
+ return;
214
251
  }
215
252
 
216
253
  // Check if stream should be closed (main response done + idle timeout).
@@ -257,7 +294,7 @@ export async function wecomHttpHandler(req, res) {
257
294
  }, 30 * 1000);
258
295
  }
259
296
 
260
- return true;
297
+ return;
261
298
  }
262
299
 
263
300
  // Handle event
@@ -296,20 +333,19 @@ export async function wecomHttpHandler(req, res) {
296
333
  logger.info("Sending welcome message", { fromUser, streamId });
297
334
  res.writeHead(200, { "Content-Type": "application/json" });
298
335
  res.end(streamResponse);
299
- return true;
336
+ return;
300
337
  }
301
338
 
302
339
  res.writeHead(200, { "Content-Type": "text/plain" });
303
340
  res.end("success");
304
- return true;
341
+ return;
305
342
  }
306
343
 
307
344
  res.writeHead(200, { "Content-Type": "text/plain" });
308
345
  res.end("success");
309
- return true;
346
+ return;
310
347
  }
311
348
 
312
349
  res.writeHead(405, { "Content-Type": "text/plain" });
313
350
  res.end("Method Not Allowed");
314
- return true;
315
351
  }
package/wecom/http.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Unified HTTP client for WeCom API calls.
3
+ *
4
+ * Wraps `undici` fetch with optional proxy support (ProxyAgent) and
5
+ * AbortSignal timeout merging. All outbound requests to qyapi.weixin.qq.com
6
+ * should go through `wecomFetch()` so that proxy / timeout behaviour is
7
+ * consistent across the plugin.
8
+ *
9
+ * Proxy URL resolution order:
10
+ * 1. Explicit `opts.proxyUrl` parameter
11
+ * 2. Environment variable `WECOM_EGRESS_PROXY_URL`
12
+ * 3. Config: `channels.wecom.network.egressProxyUrl`
13
+ */
14
+
15
+ import { AGENT_API_REQUEST_TIMEOUT_MS } from "./constants.js";
16
+
17
+ // ── Lazy-loaded undici (optional dependency) ──────────────────────────
18
+
19
+ let _undici = null;
20
+
21
+ async function getUndici() {
22
+ if (_undici) return _undici;
23
+ try {
24
+ _undici = await import("undici");
25
+ } catch {
26
+ _undici = null;
27
+ }
28
+ return _undici;
29
+ }
30
+
31
+ // ── ProxyAgent cache ──────────────────────────────────────────────────
32
+
33
+ const proxyDispatchers = new Map();
34
+
35
+ async function getProxyDispatcher(proxyUrl) {
36
+ const existing = proxyDispatchers.get(proxyUrl);
37
+ if (existing) return existing;
38
+
39
+ const undici = await getUndici();
40
+ if (!undici?.ProxyAgent) {
41
+ throw new Error(
42
+ "undici is required for proxy support. Install it with: npm install undici",
43
+ );
44
+ }
45
+
46
+ const created = new undici.ProxyAgent(proxyUrl);
47
+ proxyDispatchers.set(proxyUrl, created);
48
+ return created;
49
+ }
50
+
51
+ // ── Signal merge helper ───────────────────────────────────────────────
52
+
53
+ function mergeAbortSignal({ signal, timeoutMs }) {
54
+ const signals = [];
55
+ if (signal) signals.push(signal);
56
+ if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
57
+ signals.push(AbortSignal.timeout(timeoutMs));
58
+ }
59
+ if (!signals.length) return undefined;
60
+ if (signals.length === 1) return signals[0];
61
+ return AbortSignal.any(signals);
62
+ }
63
+
64
+ // ── Proxy URL resolution ──────────────────────────────────────────────
65
+
66
+ let _configProxyUrl = "";
67
+
68
+ /**
69
+ * Set the proxy URL from plugin config (called once during plugin load).
70
+ * @param {string} url
71
+ */
72
+ export function setConfigProxyUrl(url) {
73
+ _configProxyUrl = (url || "").trim();
74
+ }
75
+
76
+ /**
77
+ * Resolve the effective proxy URL.
78
+ * Priority: explicit > env > config.
79
+ * @param {string} [explicit]
80
+ * @returns {string}
81
+ */
82
+ function resolveProxyUrl(explicit) {
83
+ if (explicit?.trim()) return explicit.trim();
84
+ const env = (
85
+ process.env.WECOM_EGRESS_PROXY_URL || ""
86
+ ).trim();
87
+ if (env) return env;
88
+ return _configProxyUrl;
89
+ }
90
+
91
+ // ── Public API ────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Fetch wrapper with proxy and timeout support.
95
+ *
96
+ * @param {string | URL} input
97
+ * @param {RequestInit} [init]
98
+ * @param {{ proxyUrl?: string, timeoutMs?: number, signal?: AbortSignal }} [opts]
99
+ * @returns {Promise<Response>}
100
+ */
101
+ export async function wecomFetch(input, init, opts) {
102
+ const proxyUrl = resolveProxyUrl(opts?.proxyUrl);
103
+ const timeoutMs = opts?.timeoutMs ?? AGENT_API_REQUEST_TIMEOUT_MS;
104
+
105
+ const signal = mergeAbortSignal({
106
+ signal: opts?.signal ?? init?.signal,
107
+ timeoutMs,
108
+ });
109
+
110
+ if (proxyUrl) {
111
+ // Use undici fetch with ProxyAgent dispatcher
112
+ const undici = await getUndici();
113
+ if (undici?.fetch && undici?.ProxyAgent) {
114
+ const dispatcher = await getProxyDispatcher(proxyUrl);
115
+ return undici.fetch(input, {
116
+ ...(init ?? {}),
117
+ ...(signal ? { signal } : {}),
118
+ dispatcher,
119
+ });
120
+ }
121
+ // undici not available — fall through to native fetch (no proxy)
122
+ }
123
+
124
+ // Native fetch (no proxy)
125
+ return fetch(input, {
126
+ ...(init ?? {}),
127
+ ...(signal ? { signal } : {}),
128
+ });
129
+ }
package/wecom/media.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { WecomCrypto } from "../crypto.js";
4
4
  import { logger } from "../logger.js";
5
5
  import { MEDIA_CACHE_DIR } from "./constants.js";
6
+ import { wecomFetch } from "./http.js";
6
7
 
7
8
  // ── Magic-byte signatures for common file formats ───────────────────────────
8
9
  const MAGIC_SIGNATURES = [
@@ -87,7 +88,7 @@ export async function downloadAndDecryptImage(imageUrl, encodingAesKey, token) {
87
88
  }
88
89
 
89
90
  logger.info("Downloading image", { url: imageUrl.substring(0, 80) });
90
- const response = await fetch(imageUrl);
91
+ const response = await wecomFetch(imageUrl);
91
92
  if (!response.ok) {
92
93
  throw new Error(`Failed to download image: ${response.status}`);
93
94
  }
@@ -127,7 +128,7 @@ export async function downloadWecomFile(fileUrl, fileName, encodingAesKey, token
127
128
  }
128
129
 
129
130
  logger.info("Downloading file", { url: fileUrl.substring(0, 80), name: fileName });
130
- const response = await fetch(fileUrl);
131
+ const response = await wecomFetch(fileUrl);
131
132
  if (!response.ok) {
132
133
  throw new Error(`Failed to download file: ${response.status}`);
133
134
  }
@@ -8,6 +8,7 @@ import { resolveAgentConfig, responseUrls, streamContext } from "./state.js";
8
8
  import { resolveActiveStream } from "./stream-utils.js";
9
9
  import { resolveAgentWorkspaceDirLocal } from "./workspace-template.js";
10
10
  import { THINKING_PLACEHOLDER } from "./constants.js";
11
+ import { wecomFetch } from "./http.js";
11
12
 
12
13
  // WeCom upload API rejects files smaller than 5 bytes (error 40006).
13
14
  const WECOM_MIN_FILE_SIZE = 5;
@@ -196,7 +197,7 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
196
197
  if (isLocal) {
197
198
  fileBuf = await readFile(absPath);
198
199
  } else {
199
- const res = await fetch(mediaPath, { signal: AbortSignal.timeout(30_000) });
200
+ const res = await wecomFetch(mediaPath);
200
201
  if (!res.ok) throw new Error(`download failed: ${res.status}`);
201
202
  fileBuf = Buffer.from(await res.arrayBuffer());
202
203
  }
@@ -420,7 +421,7 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
420
421
  const saved = responseUrls.get(senderId);
421
422
  if (saved && !saved.used && Date.now() < saved.expiresAt) {
422
423
  try {
423
- const response = await fetch(saved.url, {
424
+ const response = await wecomFetch(saved.url, {
424
425
  method: "POST",
425
426
  headers: { "Content-Type": "application/json" },
426
427
  body: JSON.stringify({ msgtype: "text", text: { content: processedText } }),
@@ -10,6 +10,7 @@
10
10
  import crypto from "node:crypto";
11
11
  import { logger } from "../logger.js";
12
12
  import { AGENT_API_REQUEST_TIMEOUT_MS } from "./constants.js";
13
+ import { wecomFetch } from "./http.js";
13
14
 
14
15
  /**
15
16
  * Send a text message via Webhook Bot.
@@ -86,14 +87,13 @@ export async function webhookUploadFile({ url, buffer, filename }) {
86
87
  const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
87
88
  const multipartBody = Buffer.concat([header, buffer, footer]);
88
89
 
89
- const res = await fetch(uploadUrl, {
90
+ const res = await wecomFetch(uploadUrl, {
90
91
  method: "POST",
91
92
  headers: {
92
93
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
93
94
  "Content-Length": String(multipartBody.length),
94
95
  },
95
96
  body: multipartBody,
96
- signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
97
97
  });
98
98
  const json = await res.json();
99
99
 
@@ -140,11 +140,10 @@ function extractKey(url) {
140
140
  async function postWebhook(url, body) {
141
141
  logger.debug("Webhook bot POST", { url: url.substring(0, 60), msgtype: body.msgtype });
142
142
 
143
- const res = await fetch(url, {
143
+ const res = await wecomFetch(url, {
144
144
  method: "POST",
145
145
  headers: { "Content-Type": "application/json" },
146
146
  body: JSON.stringify(body),
147
- signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
148
147
  });
149
148
  const json = await res.json();
150
149
 
@@ -103,7 +103,7 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
103
103
  }
104
104
 
105
105
  if (!existingIds.has(normalizedId)) {
106
- nextList.push({ id: normalizedId });
106
+ nextList.push({ id: normalizedId, heartbeat: {} });
107
107
  changed = true;
108
108
  }
109
109