@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 +10 -3
- package/package.json +4 -1
- package/wecom/agent-api.js +6 -8
- package/wecom/agent-inbound.js +65 -1
- package/wecom/channel-plugin.js +61 -5
- package/wecom/http-handler.js +51 -15
- package/wecom/http.js +129 -0
- package/wecom/media.js +3 -2
- package/wecom/outbound-delivery.js +3 -2
- package/wecom/webhook-bot.js +3 -4
- package/wecom/workspace-template.js +1 -1
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
|
-
|
|
43
|
-
|
|
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.
|
|
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",
|
package/wecom/agent-api.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
233
|
+
const res = await wecomFetch(url);
|
|
236
234
|
|
|
237
235
|
if (!res.ok) {
|
|
238
236
|
throw new Error(`agent download media failed: ${res.status}`);
|
package/wecom/agent-inbound.js
CHANGED
|
@@ -16,11 +16,15 @@ import {
|
|
|
16
16
|
getDynamicAgentConfig,
|
|
17
17
|
shouldUseDynamicAgent,
|
|
18
18
|
} from "../dynamic-agent.js";
|
|
19
|
-
import {
|
|
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 {
|
package/wecom/channel-plugin.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
package/wecom/http-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
336
|
+
return;
|
|
300
337
|
}
|
|
301
338
|
|
|
302
339
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
303
340
|
res.end("success");
|
|
304
|
-
return
|
|
341
|
+
return;
|
|
305
342
|
}
|
|
306
343
|
|
|
307
344
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
308
345
|
res.end("success");
|
|
309
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/webhook-bot.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|