@sunnoy/wecom 1.5.0 → 1.5.1

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/index.js CHANGED
@@ -38,9 +38,16 @@ 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 HTTP handler for webhooks.
42
+ // OpenClaw 2026.3.2+ removed registerHttpHandler; the primary route
43
+ // registration now happens in gateway.startAccount via registerPluginHttpRoute.
44
+ // Keep the legacy handler for backward compatibility with older versions.
45
+ if (typeof api.registerHttpHandler === "function") {
46
+ api.registerHttpHandler(wecomHttpHandler);
47
+ logger.info("WeCom HTTP handler registered (legacy wildcard)");
48
+ } else {
49
+ logger.info("WeCom: registerHttpHandler unavailable, routes registered via gateway lifecycle");
50
+ }
44
51
  },
45
52
  };
46
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
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
+ import { createWecomRouteHandler } from "./http-handler.js";
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,34 @@ export const wecomChannelPlugin = {
709
726
  config: ctx.cfg,
710
727
  });
711
728
 
729
+ // Register HTTP route with OpenClaw route framework.
730
+ // Uses registerPluginHttpRoute (new API in OpenClaw 2026.3.2+) for explicit
731
+ // path-based routing. Falls back gracefully when the SDK is unavailable
732
+ // (older OpenClaw uses the legacy wildcard handler registered in index.js).
733
+ let unregisterBotRoute;
734
+ const botPath = account.webhookPath || "/webhooks/wecom";
735
+ try {
736
+ const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk");
737
+ unregisterBotRoute = registerPluginHttpRoute({
738
+ path: botPath,
739
+ pluginId: "wecom",
740
+ accountId: account.accountId,
741
+ log: (msg) => logger.info(msg),
742
+ handler: createWecomRouteHandler(normalizeWebhookPath(botPath)),
743
+ });
744
+ logger.info("WeCom Bot HTTP route registered", { path: botPath });
745
+ } catch {
746
+ // openclaw/plugin-sdk not available — rely on legacy registerHttpHandler.
747
+ logger.debug("registerPluginHttpRoute unavailable, using legacy handler", { path: botPath });
748
+ }
749
+
712
750
  // Register Agent inbound webhook if agent inbound is fully configured.
713
751
  let unregisterAgent;
752
+ let unregisterAgentRoute;
714
753
  // Per-account agent path: /webhooks/app for default, /webhooks/app/{accountId} for others.
715
754
  const agentInboundPath = account.accountId === DEFAULT_ACCOUNT_ID
716
755
  ? "/webhooks/app"
717
756
  : `/webhooks/app/${account.accountId}`;
718
- const botPath = account.webhookPath || "/webhooks/wecom";
719
757
  if (account.agentInboundConfigured) {
720
758
  if (botPath === agentInboundPath) {
721
759
  logger.error("WeCom: Agent inbound path conflicts with Bot webhook path, skipping Agent registration", {
@@ -740,6 +778,22 @@ export const wecomChannelPlugin = {
740
778
  config: ctx.cfg,
741
779
  });
742
780
  logger.info("WeCom Agent inbound webhook registered", { path: agentInboundPath });
781
+
782
+ // Register agent inbound HTTP route (new API).
783
+ try {
784
+ const { registerPluginHttpRoute } = await import("openclaw/plugin-sdk");
785
+ unregisterAgentRoute = registerPluginHttpRoute({
786
+ path: agentInboundPath,
787
+ pluginId: "wecom",
788
+ accountId: account.accountId,
789
+ source: "agent-inbound",
790
+ log: (msg) => logger.info(msg),
791
+ handler: createWecomRouteHandler(normalizeWebhookPath(agentInboundPath)),
792
+ });
793
+ logger.info("WeCom Agent inbound HTTP route registered", { path: agentInboundPath });
794
+ } catch {
795
+ logger.debug("registerPluginHttpRoute unavailable for agent inbound, using legacy handler");
796
+ }
743
797
  }
744
798
  }
745
799
 
@@ -751,7 +805,9 @@ export const wecomChannelPlugin = {
751
805
  }
752
806
  messageBuffers.clear();
753
807
  unregister();
808
+ if (unregisterBotRoute) unregisterBotRoute();
754
809
  if (unregisterAgent) unregisterAgent();
810
+ if (unregisterAgentRoute) unregisterAgentRoute();
755
811
  };
756
812
 
757
813
  // Backward compatibility: older runtime may not pass abortSignal.
@@ -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