@sunnoy/wecom 1.4.1 → 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.4.1",
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 {
@@ -8,10 +8,19 @@ import { listAccountIds, resolveAccount, detectAccountConflicts } from "./accoun
8
8
  import { DEFAULT_ACCOUNT_ID, THINKING_PLACEHOLDER } from "./constants.js";
9
9
  import { parseResponseUrlResult } from "./response-url.js";
10
10
  import { messageBuffers, resolveAgentConfig, resolveWebhookUrl, responseUrls, streamContext } from "./state.js";
11
- import { resolveActiveStream } from "./stream-utils.js";
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";
17
+
18
+ const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
19
+
20
+ export function resolveAgentMediaTypeFromFilename(filename) {
21
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
22
+ return AGENT_IMAGE_EXTS.has(ext) ? "image" : "file";
23
+ }
15
24
 
16
25
  export const wecomChannelPlugin = {
17
26
  id: "wecom",
@@ -141,6 +150,17 @@ export const wecomChannelPlugin = {
141
150
  },
142
151
  },
143
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
+ },
144
164
  webhooks: {
145
165
  type: "object",
146
166
  description: "Webhook bot URLs for group notifications (key: name, value: webhook URL or key)",
@@ -259,7 +279,7 @@ export const wecomChannelPlugin = {
259
279
 
260
280
  // Prefer stream from async context (correct for concurrent processing).
261
281
  const ctx = streamContext.getStore();
262
- const streamId = ctx?.streamId ?? resolveActiveStream(userId);
282
+ const streamId = ctx?.streamId ?? resolveRecoverableStream(userId);
263
283
 
264
284
  // Layer 1: Active stream (normal path)
265
285
  if (streamId && streamManager.hasStream(streamId) && !streamManager.getStream(streamId)?.finished) {
@@ -284,7 +304,7 @@ export const wecomChannelPlugin = {
284
304
  const saved = responseUrls.get(ctx?.streamKey ?? userId);
285
305
  if (saved && !saved.used && Date.now() < saved.expiresAt) {
286
306
  try {
287
- const response = await fetch(saved.url, {
307
+ const response = await wecomFetch(saved.url, {
288
308
  method: "POST",
289
309
  headers: { "Content-Type": "application/json" },
290
310
  body: JSON.stringify({ msgtype: "text", text: { content: text } }),
@@ -377,7 +397,7 @@ export const wecomChannelPlugin = {
377
397
 
378
398
  // Prefer stream from async context (correct for concurrent processing).
379
399
  const ctx = streamContext.getStore();
380
- const streamId = ctx?.streamId ?? resolveActiveStream(userId);
400
+ const streamId = ctx?.streamId ?? resolveRecoverableStream(userId);
381
401
 
382
402
  if (streamId && streamManager.hasStream(streamId)) {
383
403
  // Check if mediaUrl is a local path (sandbox: prefix or absolute path)
@@ -528,7 +548,7 @@ export const wecomChannelPlugin = {
528
548
  buffer = await readFile(absolutePath);
529
549
  filename = basename(absolutePath);
530
550
  } else {
531
- const res = await fetch(mediaUrl);
551
+ const res = await wecomFetch(mediaUrl);
532
552
  buffer = Buffer.from(await res.arrayBuffer());
533
553
  filename = basename(new URL(mediaUrl).pathname) || "image.png";
534
554
  }
@@ -575,6 +595,7 @@ export const wecomChannelPlugin = {
575
595
  if (agentConfig) {
576
596
  try {
577
597
  const agentTarget = (target && !target.webhook) ? target : resolveWecomTarget(to) || { toUser: userId };
598
+ let deliveredFilename = "file";
578
599
 
579
600
  // Determine if mediaUrl is a local file path.
580
601
  let absolutePath = mediaUrl;
@@ -587,9 +608,11 @@ export const wecomChannelPlugin = {
587
608
  // Upload local file then send via Agent API.
588
609
  const buffer = await readFile(absolutePath);
589
610
  const filename = basename(absolutePath);
611
+ deliveredFilename = filename;
612
+ const uploadType = resolveAgentMediaTypeFromFilename(filename);
590
613
  const mediaId = await agentUploadMedia({
591
614
  agent: agentConfig,
592
- type: "image",
615
+ type: uploadType,
593
616
  buffer,
594
617
  filename,
595
618
  });
@@ -597,16 +620,25 @@ export const wecomChannelPlugin = {
597
620
  agent: agentConfig,
598
621
  ...agentTarget,
599
622
  mediaId,
600
- mediaType: "image",
623
+ mediaType: uploadType,
601
624
  });
602
625
  } else {
603
626
  // For external URLs, download first then upload.
604
- const res = await fetch(mediaUrl);
627
+ const res = await wecomFetch(mediaUrl);
628
+ if (!res.ok) {
629
+ throw new Error(`download media failed: ${res.status}`);
630
+ }
605
631
  const buffer = Buffer.from(await res.arrayBuffer());
606
- const filename = basename(new URL(mediaUrl).pathname) || "image.png";
632
+ const filename = basename(new URL(mediaUrl).pathname) || "file";
633
+ deliveredFilename = filename;
634
+ let uploadType = resolveAgentMediaTypeFromFilename(filename);
635
+ const contentType = res.headers.get("content-type") || "";
636
+ if (uploadType === "file" && contentType.toLowerCase().startsWith("image/")) {
637
+ uploadType = "image";
638
+ }
607
639
  const mediaId = await agentUploadMedia({
608
640
  agent: agentConfig,
609
- type: "image",
641
+ type: uploadType,
610
642
  buffer,
611
643
  filename,
612
644
  });
@@ -614,7 +646,7 @@ export const wecomChannelPlugin = {
614
646
  agent: agentConfig,
615
647
  ...agentTarget,
616
648
  mediaId,
617
- mediaType: "image",
649
+ mediaType: uploadType,
618
650
  });
619
651
  }
620
652
 
@@ -623,6 +655,30 @@ export const wecomChannelPlugin = {
623
655
  await agentSendText({ agent: agentConfig, ...agentTarget, text });
624
656
  }
625
657
 
658
+ // Best-effort stream recovery: when async context is missing and the
659
+ // active stream mapping was already cleaned, still clear "thinking..."
660
+ // in the most recent stream for this user.
661
+ const recoverStreamId = resolveRecoverableStream(userId);
662
+ if (recoverStreamId && streamManager.hasStream(recoverStreamId)) {
663
+ const recoverStream = streamManager.getStream(recoverStreamId);
664
+ if (recoverStream && !recoverStream.finished) {
665
+ const deliveryHint = text
666
+ ? `${text}\n\n📎 文件已通过私信发送给您:${deliveredFilename}`
667
+ : `📎 文件已通过私信发送给您:${deliveredFilename}`;
668
+ streamManager.replaceIfPlaceholder(
669
+ recoverStreamId,
670
+ deliveryHint,
671
+ THINKING_PLACEHOLDER,
672
+ );
673
+ await streamManager.finishStream(recoverStreamId);
674
+ unregisterActiveStream(userId, recoverStreamId);
675
+ logger.info("WeCom: recovered and finished stream after media fallback", {
676
+ userId,
677
+ streamId: recoverStreamId,
678
+ });
679
+ }
680
+ }
681
+
626
682
  logger.info("WeCom: sent media via Agent API fallback (sendMedia)", {
627
683
  userId,
628
684
  to,
@@ -651,6 +707,10 @@ export const wecomChannelPlugin = {
651
707
  webhookPath: account.webhookPath,
652
708
  });
653
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
+
654
714
  // Conflict detection: warn about duplicate tokens / agent IDs.
655
715
  const conflicts = detectAccountConflicts(ctx.cfg);
656
716
  for (const conflict of conflicts) {
@@ -666,13 +726,34 @@ export const wecomChannelPlugin = {
666
726
  config: ctx.cfg,
667
727
  });
668
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
+
669
750
  // Register Agent inbound webhook if agent inbound is fully configured.
670
751
  let unregisterAgent;
752
+ let unregisterAgentRoute;
671
753
  // Per-account agent path: /webhooks/app for default, /webhooks/app/{accountId} for others.
672
754
  const agentInboundPath = account.accountId === DEFAULT_ACCOUNT_ID
673
755
  ? "/webhooks/app"
674
756
  : `/webhooks/app/${account.accountId}`;
675
- const botPath = account.webhookPath || "/webhooks/wecom";
676
757
  if (account.agentInboundConfigured) {
677
758
  if (botPath === agentInboundPath) {
678
759
  logger.error("WeCom: Agent inbound path conflicts with Bot webhook path, skipping Agent registration", {
@@ -697,6 +778,22 @@ export const wecomChannelPlugin = {
697
778
  config: ctx.cfg,
698
779
  });
699
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
+ }
700
797
  }
701
798
  }
702
799
 
@@ -708,7 +805,9 @@ export const wecomChannelPlugin = {
708
805
  }
709
806
  messageBuffers.clear();
710
807
  unregister();
808
+ if (unregisterBotRoute) unregisterBotRoute();
711
809
  if (unregisterAgent) unregisterAgent();
810
+ if (unregisterAgentRoute) unregisterAgentRoute();
712
811
  };
713
812
 
714
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 } }),
package/wecom/state.js CHANGED
@@ -14,6 +14,7 @@ export const messageBuffers = new Map();
14
14
  export const webhookTargets = new Map();
15
15
  export const activeStreams = new Map();
16
16
  export const activeStreamHistory = new Map();
17
+ export const lastStreamByKey = new Map();
17
18
  export const streamMeta = new Map();
18
19
  export const responseUrls = new Map();
19
20
  export const streamContext = new AsyncLocalStorage();
@@ -1,7 +1,7 @@
1
1
  import { logger } from "../logger.js";
2
2
  import { streamManager } from "../stream-manager.js";
3
3
  import { THINKING_PLACEHOLDER } from "./constants.js";
4
- import { activeStreamHistory, activeStreams, messageBuffers } from "./state.js";
4
+ import { activeStreamHistory, activeStreams, lastStreamByKey, messageBuffers } from "./state.js";
5
5
 
6
6
  export function getMessageStreamKey(message) {
7
7
  if (!message || typeof message !== "object") {
@@ -25,6 +25,7 @@ export function registerActiveStream(streamKey, streamId) {
25
25
  deduped.push(streamId);
26
26
  activeStreamHistory.set(streamKey, deduped);
27
27
  activeStreams.set(streamKey, streamId);
28
+ lastStreamByKey.set(streamKey, streamId);
28
29
  }
29
30
 
30
31
  export function unregisterActiveStream(streamKey, streamId) {
@@ -72,9 +73,33 @@ export function resolveActiveStream(streamKey) {
72
73
  activeStreamHistory.set(streamKey, remaining);
73
74
  const latest = remaining[remaining.length - 1];
74
75
  activeStreams.set(streamKey, latest);
76
+ lastStreamByKey.set(streamKey, latest);
75
77
  return latest;
76
78
  }
77
79
 
80
+ /**
81
+ * Resolve a usable stream id for a sender/group.
82
+ * Prefer active history; if that is temporarily empty, fall back to the latest
83
+ * known stream id for the same key (when it still exists).
84
+ */
85
+ export function resolveRecoverableStream(streamKey) {
86
+ const activeId = resolveActiveStream(streamKey);
87
+ if (activeId) {
88
+ return activeId;
89
+ }
90
+ if (!streamKey) {
91
+ return null;
92
+ }
93
+ const recentId = lastStreamByKey.get(streamKey);
94
+ if (!recentId) {
95
+ return null;
96
+ }
97
+ if (!streamManager.hasStream(recentId)) {
98
+ return null;
99
+ }
100
+ return recentId;
101
+ }
102
+
78
103
  export function clearBufferedMessagesForStream(streamKey, reason) {
79
104
  const buffer = messageBuffers.get(streamKey);
80
105
  if (!buffer) {
@@ -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