@dingxiang-me/openclaw-wechat 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.en.md +181 -14
  3. package/README.md +201 -16
  4. package/docs/channels/wecom.md +137 -1
  5. package/openclaw.plugin.json +688 -6
  6. package/package.json +204 -4
  7. package/scripts/wecom-agent-selfcheck.mjs +775 -0
  8. package/scripts/wecom-bot-longconn-probe.mjs +582 -0
  9. package/scripts/wecom-bot-selfcheck.mjs +952 -0
  10. package/scripts/wecom-callback-matrix.mjs +224 -0
  11. package/scripts/wecom-doctor.mjs +1407 -0
  12. package/scripts/wecom-e2e-scenario.mjs +333 -0
  13. package/scripts/wecom-migrate.mjs +261 -0
  14. package/scripts/wecom-quickstart.mjs +1824 -0
  15. package/scripts/wecom-release-check.mjs +232 -0
  16. package/scripts/wecom-remote-e2e.mjs +310 -0
  17. package/scripts/wecom-selfcheck.mjs +1255 -0
  18. package/scripts/wecom-smoke.sh +74 -0
  19. package/src/core/delivery-router.js +21 -0
  20. package/src/core.js +619 -30
  21. package/src/wecom/account-config-core.js +27 -1
  22. package/src/wecom/account-config.js +19 -2
  23. package/src/wecom/agent-dispatch-executor.js +11 -0
  24. package/src/wecom/agent-dispatch-handlers.js +61 -8
  25. package/src/wecom/agent-inbound-guards.js +24 -0
  26. package/src/wecom/agent-inbound-processor.js +34 -2
  27. package/src/wecom/agent-late-reply-runtime.js +30 -2
  28. package/src/wecom/agent-text-sender.js +2 -0
  29. package/src/wecom/api-client-core.js +27 -19
  30. package/src/wecom/api-client-media.js +16 -7
  31. package/src/wecom/api-client-send-text.js +4 -0
  32. package/src/wecom/api-client-send-typed.js +4 -1
  33. package/src/wecom/api-client-senders.js +41 -3
  34. package/src/wecom/api-client.js +1 -0
  35. package/src/wecom/bot-dispatch-fallback.js +18 -3
  36. package/src/wecom/bot-dispatch-handlers.js +47 -10
  37. package/src/wecom/bot-inbound-dispatch-runtime.js +3 -0
  38. package/src/wecom/bot-inbound-executor-helpers.js +11 -1
  39. package/src/wecom/bot-inbound-executor.js +24 -0
  40. package/src/wecom/bot-inbound-guards.js +31 -1
  41. package/src/wecom/channel-config-schema.js +132 -0
  42. package/src/wecom/channel-plugin.js +348 -7
  43. package/src/wecom/command-handlers.js +102 -11
  44. package/src/wecom/command-status-text.js +206 -0
  45. package/src/wecom/doc-client.js +7 -1
  46. package/src/wecom/inbound-content-handler-file-video-link.js +4 -0
  47. package/src/wecom/inbound-content-handler-image-voice.js +6 -0
  48. package/src/wecom/inbound-content.js +5 -0
  49. package/src/wecom/installer-api.js +910 -0
  50. package/src/wecom/media-download.js +2 -2
  51. package/src/wecom/migration-diagnostics.js +816 -0
  52. package/src/wecom/network-config.js +91 -0
  53. package/src/wecom/observability-metrics.js +9 -3
  54. package/src/wecom/outbound-agent-delivery.js +313 -0
  55. package/src/wecom/outbound-agent-media-sender.js +37 -7
  56. package/src/wecom/outbound-agent-push.js +1 -0
  57. package/src/wecom/outbound-delivery.js +129 -12
  58. package/src/wecom/outbound-stream-msg-item.js +25 -2
  59. package/src/wecom/outbound-webhook-delivery.js +19 -0
  60. package/src/wecom/outbound-webhook-media.js +30 -6
  61. package/src/wecom/pending-reply-manager.js +143 -0
  62. package/src/wecom/plugin-account-policy-services.js +26 -0
  63. package/src/wecom/plugin-base-services.js +58 -0
  64. package/src/wecom/plugin-constants.js +1 -1
  65. package/src/wecom/plugin-delivery-inbound-services.js +25 -0
  66. package/src/wecom/plugin-processing-deps.js +7 -0
  67. package/src/wecom/plugin-route-runtime-deps.js +1 -0
  68. package/src/wecom/plugin-services.js +87 -0
  69. package/src/wecom/policy-resolvers.js +93 -20
  70. package/src/wecom/quickstart-metadata.js +1247 -0
  71. package/src/wecom/reasoning-visibility.js +104 -0
  72. package/src/wecom/register-runtime.js +10 -0
  73. package/src/wecom/reliable-delivery-persistence.js +138 -0
  74. package/src/wecom/reliable-delivery.js +642 -0
  75. package/src/wecom/reply-output-policy.js +171 -0
  76. package/src/wecom/text-inbound-scheduler.js +6 -1
  77. package/src/wecom/workspace-auto-sender.js +2 -0
@@ -0,0 +1,1255 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { ProxyAgent } from "undici";
7
+ import {
8
+ collectWecomEnvAccountIds as collectSharedWecomEnvAccountIds,
9
+ createRequireEnv as createSharedRequireEnv,
10
+ normalizeAccountConfig as normalizeSharedAccountConfig,
11
+ readAccountConfigFromEnv as readSharedAccountConfigFromEnv,
12
+ } from "../src/wecom/account-config-core.js";
13
+ import { resolveWecomBotModeConfig, resolveWecomGroupChatConfig } from "../src/core.js";
14
+ import { collectWecomMigrationDiagnostics, WECOM_MIGRATION_COMMAND } from "../src/wecom/migration-diagnostics.js";
15
+ import { buildWecomApiUrl, isWecomApiUrl, resolveWecomApiBaseUrl } from "../src/wecom/network-config.js";
16
+ import { PLUGIN_VERSION } from "../src/wecom/plugin-constants.js";
17
+
18
+ const PROXY_DISPATCHER_CACHE = new Map();
19
+ const INVALID_PROXY_CACHE = new Set();
20
+ const LEGACY_INLINE_ACCOUNT_RESERVED_KEYS = new Set([
21
+ "name",
22
+ "enabled",
23
+ "botId",
24
+ "botid",
25
+ "secret",
26
+ "corpId",
27
+ "corpSecret",
28
+ "agentId",
29
+ "callbackToken",
30
+ "token",
31
+ "callbackAesKey",
32
+ "encodingAesKey",
33
+ "webhookPath",
34
+ "outboundProxy",
35
+ "proxyUrl",
36
+ "proxy",
37
+ "network",
38
+ "apiBaseUrl",
39
+ "webhooks",
40
+ "allowFrom",
41
+ "allowFromRejectMessage",
42
+ "groupPolicy",
43
+ "groupAllowFrom",
44
+ "groupAllowFromRejectMessage",
45
+ "rejectUnauthorizedMessage",
46
+ "adminUsers",
47
+ "commandAllowlist",
48
+ "commandBlockMessage",
49
+ "commands",
50
+ "workspaceTemplate",
51
+ "groupChat",
52
+ "groups",
53
+ "dynamicAgent",
54
+ "dynamicAgents",
55
+ "dm",
56
+ "debounce",
57
+ "streaming",
58
+ "bot",
59
+ "delivery",
60
+ "webhookBot",
61
+ "stream",
62
+ "observability",
63
+ "voiceTranscription",
64
+ "defaultAccount",
65
+ "tools",
66
+ "accounts",
67
+ "agent",
68
+ ]);
69
+
70
+ function parseArgs(argv) {
71
+ const out = {
72
+ account: "default",
73
+ allAccounts: false,
74
+ configPath: process.env.OPENCLAW_CONFIG_PATH || "~/.openclaw/openclaw.json",
75
+ skipNetwork: false,
76
+ skipLocalWebhook: false,
77
+ timeoutMs: 8000,
78
+ json: false,
79
+ };
80
+
81
+ for (let i = 2; i < argv.length; i += 1) {
82
+ const arg = argv[i];
83
+ const next = argv[i + 1];
84
+ if (arg === "--account" && next) {
85
+ out.account = next;
86
+ i += 1;
87
+ } else if (arg === "--all-accounts") {
88
+ out.allAccounts = true;
89
+ } else if (arg === "--config" && next) {
90
+ out.configPath = next;
91
+ i += 1;
92
+ } else if (arg === "--timeout-ms" && next) {
93
+ const n = Number(next);
94
+ if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
95
+ i += 1;
96
+ } else if (arg === "--skip-network") {
97
+ out.skipNetwork = true;
98
+ } else if (arg === "--skip-local-webhook") {
99
+ out.skipLocalWebhook = true;
100
+ } else if (arg === "--json") {
101
+ out.json = true;
102
+ } else if (arg === "-h" || arg === "--help") {
103
+ printHelp();
104
+ process.exit(0);
105
+ } else {
106
+ throw new Error(`Unknown argument: ${arg}`);
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function printHelp() {
113
+ console.log(`OpenClaw-Wechat selfcheck
114
+
115
+ Usage:
116
+ npm run wecom:selfcheck -- [options]
117
+
118
+ Options:
119
+ --account <id> Account id to validate (default: default)
120
+ --all-accounts Validate all discovered accounts
121
+ --config <path> OpenClaw config path (default: ~/.openclaw/openclaw.json)
122
+ --timeout-ms <ms> Network timeout for each check (default: 8000)
123
+ --skip-network Skip WeCom API checks
124
+ --skip-local-webhook Skip local webhook health probe
125
+ --json Print machine-readable JSON report
126
+ -h, --help Show this help
127
+ `);
128
+ }
129
+
130
+ function expandHome(p) {
131
+ if (!p) return p;
132
+ if (p === "~") return os.homedir();
133
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
134
+ return p;
135
+ }
136
+
137
+ function normalizeAccountId(accountId) {
138
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
139
+ return normalized || "default";
140
+ }
141
+
142
+ function buildDefaultBotWebhookPath(accountId) {
143
+ const normalized = normalizeAccountId(accountId);
144
+ return normalized === "default" ? "/wecom/bot/callback" : `/wecom/${normalized}/bot/callback`;
145
+ }
146
+
147
+ function asNumber(v, fallback = null) {
148
+ if (v == null) return fallback;
149
+ const n = Number(v);
150
+ return Number.isFinite(n) ? n : fallback;
151
+ }
152
+
153
+ function pickFirstNonEmptyString(...values) {
154
+ for (const value of values) {
155
+ const trimmed = String(value ?? "").trim();
156
+ if (trimmed) return trimmed;
157
+ }
158
+ return "";
159
+ }
160
+
161
+ function parseSemverLike(version) {
162
+ const normalized = String(version ?? "").trim();
163
+ if (!normalized) return null;
164
+ const matched = normalized.match(/^v?(\d+)\.(\d+)\.(\d+)/);
165
+ if (!matched) return null;
166
+ return matched.slice(1).map((value) => Number.parseInt(value, 10));
167
+ }
168
+
169
+ function compareSemverLike(left, right) {
170
+ const a = parseSemverLike(left);
171
+ const b = parseSemverLike(right);
172
+ if (!a || !b) return null;
173
+ for (let index = 0; index < 3; index += 1) {
174
+ if (a[index] === b[index]) continue;
175
+ return a[index] > b[index] ? 1 : -1;
176
+ }
177
+ return 0;
178
+ }
179
+
180
+ function decodeAesKey(aesKey) {
181
+ if (!aesKey) return null;
182
+ const base64 = aesKey.endsWith("=") ? aesKey : `${aesKey}=`;
183
+ return Buffer.from(base64, "base64");
184
+ }
185
+
186
+ function isFalseLike(v) {
187
+ return ["0", "false", "off", "no"].includes(String(v ?? "").trim().toLowerCase());
188
+ }
189
+
190
+ function makeCheck(name, ok, detail, data = null) {
191
+ return { name, ok: Boolean(ok), detail: String(detail ?? ""), data };
192
+ }
193
+
194
+ function summarize(checks) {
195
+ const failCount = checks.filter((c) => !c.ok).length;
196
+ return {
197
+ ok: failCount === 0,
198
+ total: checks.length,
199
+ failed: failCount,
200
+ passed: checks.length - failCount,
201
+ };
202
+ }
203
+
204
+ function summarizeAccounts(accountReports) {
205
+ const checks = accountReports.flatMap((r) => r.checks);
206
+ const accountFailures = accountReports.filter((r) => !r.summary.ok).length;
207
+ return {
208
+ ...summarize(checks),
209
+ accountsTotal: accountReports.length,
210
+ accountsFailed: accountFailures,
211
+ accountsPassed: accountReports.length - accountFailures,
212
+ };
213
+ }
214
+
215
+ function formatGroupSourceLabel(source = "") {
216
+ const normalized = String(source ?? "").trim();
217
+ if (!normalized) return "none";
218
+ if (normalized === "default") return "default";
219
+ if (normalized === "inferred") return "inferred";
220
+ if (normalized.startsWith("env.")) return "env";
221
+ if (normalized.startsWith("account.group")) return "account-group";
222
+ if (normalized.startsWith("account.groupChat")) return "account-group-default";
223
+ if (normalized.startsWith("account.root")) return "account-root";
224
+ if (normalized.startsWith("channel.group")) return "channel-group";
225
+ if (normalized.startsWith("channel.groupChat")) return "channel-group-default";
226
+ if (normalized.startsWith("channel.root")) return "channel-root";
227
+ return normalized;
228
+ }
229
+
230
+ function buildGroupPolicyOverview({ config = {}, accountConfig = {}, accountId = "default" } = {}) {
231
+ const groupPolicy = resolveWecomGroupChatConfig({
232
+ channelConfig: config?.channels?.wecom ?? {},
233
+ accountConfig,
234
+ envVars: config?.env?.vars ?? {},
235
+ processEnv: process.env,
236
+ accountId,
237
+ });
238
+ const allowFrom = Array.isArray(groupPolicy?.allowFrom) ? groupPolicy.allowFrom : [];
239
+ const allowSummary =
240
+ groupPolicy?.policyMode === "allowlist"
241
+ ? String(allowFrom.length)
242
+ : allowFrom.length > 0 && !allowFrom.includes("*")
243
+ ? `${allowFrom.length}(inactive)`
244
+ : "open";
245
+ return {
246
+ mode: String(groupPolicy?.policyMode ?? "open"),
247
+ trigger: String(groupPolicy?.triggerMode ?? "direct"),
248
+ allowSummary,
249
+ groups: Number(groupPolicy?.configuredGroupCount || 0),
250
+ source: formatGroupSourceLabel(groupPolicy?.policySource),
251
+ };
252
+ }
253
+
254
+ function buildAccountOverview({ config, resolved } = {}) {
255
+ const bindingsCount = Array.isArray(config?.bindings) ? config.bindings.length : 0;
256
+ const dynamicAgentEnabled =
257
+ config?.channels?.wecom?.dynamicAgent?.enabled === true || config?.channels?.wecom?.dynamicAgents?.enabled === true;
258
+ const deliveryConfig = config?.channels?.wecom?.delivery ?? {};
259
+ const pendingReplyConfig = deliveryConfig?.pendingReply ?? {};
260
+ const reasoningConfig = deliveryConfig?.reasoning ?? {};
261
+ const pendingReplyEnabled = pendingReplyConfig?.enabled !== false;
262
+ const pendingReplyPersist = pendingReplyEnabled && pendingReplyConfig?.persist !== false;
263
+ const pendingReplyStoreFile = pickFirstNonEmptyString(pendingReplyConfig?.storeFile) || null;
264
+ const quotaTrackingEnabled = deliveryConfig?.quotaTracking?.enabled !== false;
265
+ const reasoningMode = (() => {
266
+ const explicit = pickFirstNonEmptyString(reasoningConfig?.mode).toLowerCase();
267
+ if (explicit === "append" || explicit === "hidden" || explicit === "separate") return explicit;
268
+ if (reasoningConfig?.includeInFinalAnswer === true) return "append";
269
+ if (reasoningConfig?.sendThinkingMessage === false) return "hidden";
270
+ return "separate";
271
+ })();
272
+ const agentCanReceive = Boolean(resolved?.callbackToken && resolved?.callbackAesKey && resolved?.webhookPath);
273
+ const agentCanReply = Boolean(resolved?.corpId && resolved?.corpSecret && resolved?.agentId);
274
+ const botLongConnectionEnabled =
275
+ resolved?.enabled !== false &&
276
+ Boolean(pickFirstNonEmptyString(resolved?.bot?.longConnection?.botId, resolved?.bot?.longConnection?.botid)) &&
277
+ Boolean(pickFirstNonEmptyString(resolved?.bot?.longConnection?.secret));
278
+ const botWebhookEnabled =
279
+ resolved?.enabled !== false &&
280
+ Boolean(pickFirstNonEmptyString(resolved?.bot?.token)) &&
281
+ Boolean(pickFirstNonEmptyString(resolved?.bot?.encodingAesKey));
282
+ const canReceive = agentCanReceive || botLongConnectionEnabled || botWebhookEnabled;
283
+ const canReply = agentCanReply || botLongConnectionEnabled || botWebhookEnabled;
284
+ const docEnabled = resolved?.tools?.doc !== false;
285
+ const groupPolicy = buildGroupPolicyOverview({
286
+ config,
287
+ accountConfig: resolved,
288
+ accountId: resolved?.accountId || "default",
289
+ });
290
+ return {
291
+ canReceive,
292
+ canReply,
293
+ canSend: canReply,
294
+ docEnabled,
295
+ bindingsCount,
296
+ dynamicAgentEnabled,
297
+ pendingReplyEnabled,
298
+ pendingReplyPersist,
299
+ pendingReplyStoreFile,
300
+ quotaTrackingEnabled,
301
+ reasoningMode,
302
+ reasoningTitle: pickFirstNonEmptyString(reasoningConfig?.title, "思考过程"),
303
+ reasoningMaxChars: Math.max(64, asNumber(reasoningConfig?.maxChars, 1200) || 1200),
304
+ groupPolicy,
305
+ botLongConnectionEnabled,
306
+ botWebhookEnabled,
307
+ };
308
+ }
309
+
310
+ function normalizeWebhookPath(raw, fallback = "/wecom/callback") {
311
+ const input = String(raw ?? "").trim();
312
+ if (!input) return fallback;
313
+ return input.startsWith("/") ? input : `/${input}`;
314
+ }
315
+
316
+ function normalizeWebhookAlias(raw) {
317
+ return String(raw ?? "")
318
+ .trim()
319
+ .toLowerCase();
320
+ }
321
+
322
+ function normalizeWebhookTargetMap(...values) {
323
+ const out = {};
324
+ const assign = (rawAlias, rawTarget) => {
325
+ const alias = normalizeWebhookAlias(rawAlias);
326
+ const target = String(rawTarget ?? "").trim();
327
+ if (!alias || !target) return;
328
+ out[alias] = target;
329
+ };
330
+
331
+ for (const value of values) {
332
+ if (!value) continue;
333
+ if (typeof value === "string") {
334
+ const text = value.trim();
335
+ if (!text) continue;
336
+ if (text.startsWith("{") && text.endsWith("}")) {
337
+ try {
338
+ const parsed = JSON.parse(text);
339
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
340
+ for (const [rawAlias, rawTarget] of Object.entries(parsed)) {
341
+ assign(rawAlias, rawTarget);
342
+ }
343
+ continue;
344
+ }
345
+ } catch {
346
+ // fall through to name=value parser
347
+ }
348
+ }
349
+ for (const token of text.split(/[,\n;]/)) {
350
+ const pair = String(token ?? "").trim();
351
+ if (!pair) continue;
352
+ const eqIndex = pair.indexOf("=");
353
+ if (eqIndex <= 0 || eqIndex >= pair.length - 1) continue;
354
+ assign(pair.slice(0, eqIndex), pair.slice(eqIndex + 1));
355
+ }
356
+ continue;
357
+ }
358
+ if (typeof value === "object" && !Array.isArray(value)) {
359
+ for (const [rawAlias, rawTarget] of Object.entries(value)) {
360
+ assign(rawAlias, rawTarget);
361
+ }
362
+ }
363
+ }
364
+ return out;
365
+ }
366
+
367
+ function validateWebhookTargetMap(targetMap = {}) {
368
+ const invalid = [];
369
+ for (const [alias, target] of Object.entries(targetMap)) {
370
+ const value = String(target ?? "").trim();
371
+ if (!value) {
372
+ invalid.push({ alias, reason: "empty-target" });
373
+ continue;
374
+ }
375
+ if (/^https?:\/\//i.test(value)) {
376
+ try {
377
+ const parsed = new URL(value);
378
+ if (!parsed.hostname) invalid.push({ alias, reason: "invalid-url-host" });
379
+ } catch {
380
+ invalid.push({ alias, reason: "invalid-url-format" });
381
+ }
382
+ continue;
383
+ }
384
+ if (/^key:\s*$/i.test(value)) {
385
+ invalid.push({ alias, reason: "empty-key" });
386
+ continue;
387
+ }
388
+ }
389
+ return invalid;
390
+ }
391
+
392
+ function isLikelyHttpProxyUrl(proxyUrl) {
393
+ return /^https?:\/\/\S+$/i.test(String(proxyUrl ?? "").trim());
394
+ }
395
+
396
+ function sanitizeProxyForLog(proxyUrl) {
397
+ const raw = String(proxyUrl ?? "").trim();
398
+ if (!raw) return "";
399
+ try {
400
+ const parsed = new URL(raw);
401
+ if (parsed.username || parsed.password) {
402
+ parsed.username = "***";
403
+ parsed.password = "***";
404
+ }
405
+ return parsed.toString();
406
+ } catch {
407
+ return raw;
408
+ }
409
+ }
410
+
411
+ function readAccountProxyEnv(envVars, accountId) {
412
+ const normalizedId = normalizeAccountId(accountId);
413
+ const scopedKey = normalizedId === "default" ? null : `WECOM_${normalizedId.toUpperCase()}_PROXY`;
414
+ const scopedEgressKey = normalizedId === "default" ? null : `WECOM_${normalizedId.toUpperCase()}_EGRESS_PROXY_URL`;
415
+ return String(
416
+ (scopedEgressKey ? envVars?.[scopedEgressKey] ?? process.env[scopedEgressKey] : undefined) ??
417
+ (scopedKey ? envVars?.[scopedKey] ?? process.env[scopedKey] : undefined) ??
418
+ envVars?.WECOM_EGRESS_PROXY_URL ??
419
+ process.env.WECOM_EGRESS_PROXY_URL ??
420
+ envVars?.WECOM_PROXY ??
421
+ process.env.WECOM_PROXY ??
422
+ process.env.HTTPS_PROXY ??
423
+ process.env.HTTP_PROXY ??
424
+ "",
425
+ ).trim();
426
+ }
427
+
428
+ function resolveAccountProxy(config, resolved) {
429
+ const channelConfig = config?.channels?.wecom ?? {};
430
+ const envVars = config?.env?.vars ?? {};
431
+ const fromAccount = String(resolved?.outboundProxy ?? "").trim();
432
+ if (fromAccount) return fromAccount;
433
+ const fromChannel = String(channelConfig?.outboundProxy ?? "").trim();
434
+ if (fromChannel) return fromChannel;
435
+ const fromEnv = readAccountProxyEnv(envVars, resolved?.accountId ?? "default");
436
+ return fromEnv || "";
437
+ }
438
+
439
+ function resolveAccountApiBaseUrl(config, resolved) {
440
+ const channelConfig = config?.channels?.wecom ?? {};
441
+ const envVars = config?.env?.vars ?? {};
442
+ return resolveWecomApiBaseUrl({
443
+ channelConfig,
444
+ accountConfig: resolved ?? {},
445
+ envVars,
446
+ processEnv: process.env,
447
+ accountId: resolved?.accountId ?? "default",
448
+ });
449
+ }
450
+
451
+ function attachProxyDispatcher(url, fetchOptions = {}, proxyUrl, apiBaseUrl = "") {
452
+ if (!proxyUrl || !isWecomApiUrl(url, { apiBaseUrl }) || fetchOptions.dispatcher) return fetchOptions;
453
+ const printableProxy = sanitizeProxyForLog(proxyUrl);
454
+ if (!isLikelyHttpProxyUrl(proxyUrl)) {
455
+ if (!INVALID_PROXY_CACHE.has(proxyUrl)) {
456
+ INVALID_PROXY_CACHE.add(proxyUrl);
457
+ console.warn(`WARN config.outboundProxy invalid: ${printableProxy}`);
458
+ }
459
+ return fetchOptions;
460
+ }
461
+ if (!PROXY_DISPATCHER_CACHE.has(proxyUrl)) {
462
+ PROXY_DISPATCHER_CACHE.set(proxyUrl, new ProxyAgent(proxyUrl));
463
+ }
464
+ return {
465
+ ...fetchOptions,
466
+ dispatcher: PROXY_DISPATCHER_CACHE.get(proxyUrl),
467
+ };
468
+ }
469
+
470
+ function collectOtherChannelWebhookPaths(config) {
471
+ const rows = [];
472
+ const channels = config?.channels;
473
+ if (!channels || typeof channels !== "object") return rows;
474
+
475
+ for (const [channelId, channelConfig] of Object.entries(channels)) {
476
+ if (channelId === "wecom") continue;
477
+ if (!channelConfig || typeof channelConfig !== "object") continue;
478
+ if (channelConfig.enabled === false) continue;
479
+
480
+ const topLevelPath = channelConfig.webhookPath;
481
+ if (typeof topLevelPath === "string" && topLevelPath.trim()) {
482
+ rows.push({
483
+ channelId,
484
+ accountId: "default",
485
+ webhookPath: normalizeWebhookPath(topLevelPath),
486
+ });
487
+ }
488
+
489
+ const accounts = channelConfig.accounts;
490
+ if (!accounts || typeof accounts !== "object") continue;
491
+ for (const [accountId, accountCfg] of Object.entries(accounts)) {
492
+ if (!accountCfg || typeof accountCfg !== "object") continue;
493
+ if (accountCfg.enabled === false) continue;
494
+ const accountWebhookPath = accountCfg.webhookPath;
495
+ if (typeof accountWebhookPath !== "string" || !accountWebhookPath.trim()) continue;
496
+ rows.push({
497
+ channelId,
498
+ accountId,
499
+ webhookPath: normalizeWebhookPath(accountWebhookPath),
500
+ });
501
+ }
502
+ }
503
+ return rows;
504
+ }
505
+
506
+ function buildPluginChecks(config) {
507
+ const checks = [];
508
+ const plugins = config?.plugins ?? {};
509
+ const entry = plugins?.entries?.["openclaw-wechat"];
510
+ const allow = Array.isArray(plugins?.allow) ? plugins.allow.map((v) => String(v)) : null;
511
+ const allowConfigured = Array.isArray(allow);
512
+ const allowIncludesPlugin = allowConfigured && allow.includes("openclaw-wechat");
513
+ const installMeta = plugins?.installs?.["openclaw-wechat"] ?? {};
514
+ const installedVersion = pickFirstNonEmptyString(installMeta?.resolvedVersion, installMeta?.version);
515
+ const versionCompare = installedVersion ? compareSemverLike(installedVersion, PLUGIN_VERSION) : null;
516
+
517
+ checks.push(
518
+ makeCheck(
519
+ "plugins.enabled",
520
+ plugins.enabled !== false,
521
+ plugins.enabled === false ? "plugins.enabled=false" : "plugins enabled",
522
+ ),
523
+ );
524
+ checks.push(
525
+ makeCheck(
526
+ "plugins.entry.openclaw-wechat",
527
+ entry?.enabled !== false,
528
+ entry?.enabled === false ? "plugins.entries.openclaw-wechat.enabled=false" : "entry enabled or inherited",
529
+ ),
530
+ );
531
+ checks.push(
532
+ makeCheck(
533
+ "plugins.allow",
534
+ allowIncludesPlugin,
535
+ allowConfigured
536
+ ? `allow includes openclaw-wechat=${allowIncludesPlugin}`
537
+ : "plugins.allow missing (should be explicit allowlist)",
538
+ allowConfigured ? { allow } : null,
539
+ ),
540
+ );
541
+ checks.push(
542
+ makeCheck(
543
+ "plugins.install.openclaw-wechat.version",
544
+ !installedVersion || versionCompare == null || versionCompare >= 0,
545
+ installedVersion
546
+ ? `installed=${installedVersion} expected>=${PLUGIN_VERSION}`
547
+ : "no install metadata (source-path load or legacy install)",
548
+ installedVersion ? { installedVersion, expectedVersion: PLUGIN_VERSION } : null,
549
+ ),
550
+ );
551
+
552
+ return checks;
553
+ }
554
+
555
+ function listLegacyInlineAccountIds(channelConfig) {
556
+ if (!channelConfig || typeof channelConfig !== "object") return [];
557
+ const ids = [];
558
+ for (const [rawKey, value] of Object.entries(channelConfig)) {
559
+ const normalizedKey = normalizeAccountId(rawKey);
560
+ if (!normalizedKey || LEGACY_INLINE_ACCOUNT_RESERVED_KEYS.has(normalizedKey)) continue;
561
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
562
+ ids.push(normalizedKey);
563
+ }
564
+ return Array.from(new Set(ids));
565
+ }
566
+
567
+ function readAccountConfigFromEnv(envVars, accountId) {
568
+ const normalized = readSharedAccountConfigFromEnv({
569
+ envVars,
570
+ accountId,
571
+ requireEnv: createSharedRequireEnv(process.env),
572
+ normalizeWecomWebhookTargetMap: normalizeWebhookTargetMap,
573
+ });
574
+ if (!normalized) return null;
575
+ return {
576
+ ...normalized,
577
+ source: "env",
578
+ };
579
+ }
580
+
581
+ function normalizeResolvedAccount(raw, accountId, source) {
582
+ const normalized = normalizeSharedAccountConfig({
583
+ raw,
584
+ accountId,
585
+ normalizeWecomWebhookTargetMap: normalizeWebhookTargetMap,
586
+ });
587
+ if (!normalized) return null;
588
+ return {
589
+ ...normalized,
590
+ source,
591
+ };
592
+ }
593
+
594
+ function hasBotCompatConfig(raw) {
595
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
596
+ const bot = raw?.bot && typeof raw.bot === "object" && !Array.isArray(raw.bot) ? raw.bot : {};
597
+ const longConnection =
598
+ bot?.longConnection && typeof bot.longConnection === "object" && !Array.isArray(bot.longConnection)
599
+ ? bot.longConnection
600
+ : {};
601
+ return Boolean(
602
+ pickFirstNonEmptyString(
603
+ raw?.botId,
604
+ raw?.botid,
605
+ raw?.secret,
606
+ raw?.token,
607
+ raw?.encodingAesKey,
608
+ bot?.token,
609
+ bot?.callbackToken,
610
+ bot?.encodingAesKey,
611
+ bot?.callbackAesKey,
612
+ longConnection?.botId,
613
+ longConnection?.botid,
614
+ longConnection?.secret,
615
+ ),
616
+ );
617
+ }
618
+
619
+ function normalizeResolvedBotAccount({ config, accountId, source, fallbackFor = null } = {}) {
620
+ const bot = resolveWecomBotModeConfig({
621
+ channelConfig: config?.channels?.wecom ?? {},
622
+ envVars: config?.env?.vars ?? {},
623
+ processEnv: process.env,
624
+ accountId,
625
+ });
626
+ const longConnection = bot?.longConnection && typeof bot.longConnection === "object" ? bot.longConnection : {};
627
+ const longConnectionEnabled =
628
+ bot?.enabled !== false &&
629
+ (longConnection?.enabled === true ||
630
+ (Boolean(pickFirstNonEmptyString(longConnection?.botId, longConnection?.botid)) &&
631
+ Boolean(pickFirstNonEmptyString(longConnection?.secret))));
632
+ const webhookEnabled =
633
+ bot?.enabled !== false &&
634
+ Boolean(pickFirstNonEmptyString(bot?.token, bot?.callbackToken)) &&
635
+ Boolean(pickFirstNonEmptyString(bot?.encodingAesKey, bot?.callbackAesKey));
636
+ if (!longConnectionEnabled && !webhookEnabled) return null;
637
+ return {
638
+ accountId: normalizeAccountId(accountId),
639
+ source,
640
+ enabled: bot?.enabled !== false,
641
+ webhookPath: pickFirstNonEmptyString(bot?.webhookPath, buildDefaultBotWebhookPath(accountId)),
642
+ botOnly: true,
643
+ fallbackFor,
644
+ bot: {
645
+ webhookEnabled,
646
+ longConnection: {
647
+ enabled: longConnectionEnabled,
648
+ botId: pickFirstNonEmptyString(longConnection?.botId, longConnection?.botid) || undefined,
649
+ secret: pickFirstNonEmptyString(longConnection?.secret) || undefined,
650
+ },
651
+ token: pickFirstNonEmptyString(bot?.token, bot?.callbackToken) || undefined,
652
+ encodingAesKey: pickFirstNonEmptyString(bot?.encodingAesKey, bot?.callbackAesKey) || undefined,
653
+ webhookPath: pickFirstNonEmptyString(bot?.webhookPath, buildDefaultBotWebhookPath(accountId)),
654
+ },
655
+ };
656
+ }
657
+
658
+ function resolveBotAccountFromConfig(config, accountId, options = {}) {
659
+ const allowFallback = options.allowFallback !== false;
660
+ const normalizedId = normalizeAccountId(accountId);
661
+ const channelConfig = config?.channels?.wecom;
662
+
663
+ if (channelConfig && normalizedId === "default" && hasBotCompatConfig(channelConfig)) {
664
+ const byTop = normalizeResolvedBotAccount({
665
+ config,
666
+ accountId: "default",
667
+ source: "channels.wecom",
668
+ });
669
+ if (byTop) return byTop;
670
+ }
671
+
672
+ const byAccountsRaw = channelConfig?.accounts?.[normalizedId];
673
+ if (hasBotCompatConfig(byAccountsRaw)) {
674
+ const byAccounts = normalizeResolvedBotAccount({
675
+ config,
676
+ accountId: normalizedId,
677
+ source: `channels.wecom.accounts.${normalizedId}`,
678
+ });
679
+ if (byAccounts) return byAccounts;
680
+ }
681
+
682
+ const byLegacyInlineRaw =
683
+ normalizedId !== "default" && !LEGACY_INLINE_ACCOUNT_RESERVED_KEYS.has(normalizedId) ? channelConfig?.[normalizedId] : null;
684
+ if (hasBotCompatConfig(byLegacyInlineRaw)) {
685
+ const byLegacyInline = normalizeResolvedBotAccount({
686
+ config,
687
+ accountId: normalizedId,
688
+ source: `channels.wecom.${normalizedId}`,
689
+ });
690
+ if (byLegacyInline) return byLegacyInline;
691
+ }
692
+
693
+ const byEnv = normalizeResolvedBotAccount({
694
+ config,
695
+ accountId: normalizedId,
696
+ source: "env",
697
+ });
698
+ if (byEnv) return byEnv;
699
+
700
+ if (allowFallback && normalizedId !== "default") {
701
+ const fallbackCandidates = [
702
+ { raw: channelConfig, source: "channels.wecom" },
703
+ { raw: channelConfig?.accounts?.default, source: "channels.wecom.accounts.default" },
704
+ { raw: null, source: "env" },
705
+ ];
706
+ for (const candidate of fallbackCandidates) {
707
+ if (candidate.raw != null && !hasBotCompatConfig(candidate.raw)) continue;
708
+ const fallbackDefault = normalizeResolvedBotAccount({
709
+ config,
710
+ accountId: "default",
711
+ source: candidate.source,
712
+ fallbackFor: normalizedId,
713
+ });
714
+ if (fallbackDefault) return fallbackDefault;
715
+ }
716
+ }
717
+
718
+ return null;
719
+ }
720
+
721
+ function resolveAccountFromConfig(config, accountId, options = {}) {
722
+ const allowFallback = options.allowFallback !== false;
723
+ const normalizedId = normalizeAccountId(accountId);
724
+ const channelConfig = config?.channels?.wecom;
725
+ const envVars = config?.env?.vars ?? {};
726
+ const globalWebhookTargets = normalizeWebhookTargetMap(
727
+ channelConfig?.webhooks,
728
+ envVars?.WECOM_WEBHOOK_TARGETS,
729
+ process.env.WECOM_WEBHOOK_TARGETS,
730
+ );
731
+ const attachWebhookTargets = (resolved) => {
732
+ if (!resolved) return null;
733
+ const mergedWebhookTargets = {
734
+ ...globalWebhookTargets,
735
+ ...normalizeWebhookTargetMap(resolved.webhooks),
736
+ };
737
+ return {
738
+ ...resolved,
739
+ webhooks: Object.keys(mergedWebhookTargets).length > 0 ? mergedWebhookTargets : undefined,
740
+ };
741
+ };
742
+
743
+ if (channelConfig && normalizedId === "default") {
744
+ const byTop = normalizeResolvedAccount(channelConfig, "default", "channels.wecom");
745
+ if (byTop) return attachWebhookTargets(byTop);
746
+ }
747
+
748
+ const byAccounts = normalizeResolvedAccount(
749
+ channelConfig?.accounts?.[normalizedId],
750
+ normalizedId,
751
+ `channels.wecom.accounts.${normalizedId}`,
752
+ );
753
+ if (byAccounts) return attachWebhookTargets(byAccounts);
754
+
755
+ const byLegacyInline =
756
+ normalizedId !== "default" && !LEGACY_INLINE_ACCOUNT_RESERVED_KEYS.has(normalizedId)
757
+ ? normalizeResolvedAccount(channelConfig?.[normalizedId], normalizedId, `channels.wecom.${normalizedId}`)
758
+ : null;
759
+ if (byLegacyInline) return attachWebhookTargets(byLegacyInline);
760
+
761
+ const byEnv = readAccountConfigFromEnv(envVars, normalizedId);
762
+ if (byEnv) return attachWebhookTargets(byEnv);
763
+
764
+ const byBot = resolveBotAccountFromConfig(config, normalizedId, { allowFallback });
765
+ if (byBot) return attachWebhookTargets(byBot);
766
+
767
+ if (allowFallback && normalizedId !== "default") {
768
+ const fallbackDefault =
769
+ normalizeResolvedAccount(channelConfig, "default", "channels.wecom") ||
770
+ normalizeResolvedAccount(channelConfig?.accounts?.default, "default", "channels.wecom.accounts.default") ||
771
+ readAccountConfigFromEnv(envVars, "default");
772
+ if (fallbackDefault) {
773
+ return {
774
+ ...attachWebhookTargets(fallbackDefault),
775
+ accountId: "default",
776
+ fallbackFor: normalizedId,
777
+ };
778
+ }
779
+ }
780
+
781
+ return null;
782
+ }
783
+
784
+ function discoverAccountIds(config) {
785
+ const ids = new Set();
786
+ const channelConfig = config?.channels?.wecom;
787
+ const envVars = config?.env?.vars ?? {};
788
+
789
+ if (normalizeResolvedAccount(channelConfig, "default", "channels.wecom")) ids.add("default");
790
+
791
+ const accountEntries = channelConfig?.accounts;
792
+ if (accountEntries && typeof accountEntries === "object") {
793
+ for (const key of Object.keys(accountEntries)) {
794
+ ids.add(normalizeAccountId(key));
795
+ }
796
+ }
797
+ for (const key of listLegacyInlineAccountIds(channelConfig)) {
798
+ ids.add(key);
799
+ }
800
+ for (const key of collectSharedWecomEnvAccountIds({ envVars, processEnv: process.env })) {
801
+ ids.add(normalizeAccountId(key));
802
+ }
803
+
804
+ if (ids.size === 0) ids.add("default");
805
+
806
+ const ordered = Array.from(ids);
807
+ ordered.sort((a, b) => {
808
+ if (a === "default" && b !== "default") return -1;
809
+ if (a !== "default" && b === "default") return 1;
810
+ return a.localeCompare(b);
811
+ });
812
+ return ordered;
813
+ }
814
+
815
+ async function fetchJsonWithTimeout(url, timeoutMs, proxyUrl = "", apiBaseUrl = "", fetchOptions = {}) {
816
+ const controller = new AbortController();
817
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
818
+ try {
819
+ const res = await fetch(
820
+ url,
821
+ attachProxyDispatcher(
822
+ url,
823
+ {
824
+ ...fetchOptions,
825
+ apiBaseUrl,
826
+ signal: controller.signal,
827
+ },
828
+ proxyUrl,
829
+ apiBaseUrl,
830
+ ),
831
+ );
832
+ const text = await res.text();
833
+ let json = null;
834
+ try {
835
+ json = JSON.parse(text);
836
+ } catch {
837
+ json = { raw: text };
838
+ }
839
+ if (json && typeof json === "object" && !Array.isArray(json)) {
840
+ json.headers = {
841
+ location: res.headers.get("location") || "",
842
+ };
843
+ }
844
+ return { ok: res.ok, status: res.status, json };
845
+ } finally {
846
+ clearTimeout(timer);
847
+ }
848
+ }
849
+
850
+ function diagnoseLocalWebhookHealth({ status, raw, webhookPath, gatewayPort, location = "" }) {
851
+ const body = String(raw ?? "");
852
+ const preview = body.slice(0, 120);
853
+ const normalizedBody = body.trim().toLowerCase();
854
+ const healthy = status === 200 && normalizedBody.includes("wecom webhook");
855
+ if (healthy) {
856
+ return {
857
+ ok: true,
858
+ detail: `status=${status} body=${preview}`,
859
+ data: null,
860
+ };
861
+ }
862
+
863
+ let reason = "unexpected-response";
864
+ const hints = [];
865
+ if (status === 404) {
866
+ reason = "route-not-found";
867
+ hints.push(`路径 ${webhookPath} 未命中插件路由`);
868
+ } else if (status === 401 || status === 403) {
869
+ reason = "gateway-auth";
870
+ hints.push("回调路径被 Gateway Auth / Zero Trust / 反向代理鉴权拦截");
871
+ hints.push("企业微信回调与健康探测必须直达 webhook 路径,不能要求 Authorization、Cookie 或交互登录");
872
+ hints.push("为 /wecom/*(以及 legacy /webhooks/app*)单独放行,或使用独立回调域名/端口");
873
+ } else if ([301, 302, 303, 307, 308].includes(status)) {
874
+ reason = "redirect-auth";
875
+ hints.push("回调路径发生了重定向,通常被登录页、SSO 或前端路由接管");
876
+ if (location) hints.push(`重定向目标:${location}`);
877
+ hints.push("请让 /wecom/*(以及 legacy /webhooks/app*)直接反代到 OpenClaw 网关,不要跳转到登录页或前端应用");
878
+ } else if (status === 502 || status === 503 || status === 504) {
879
+ reason = "gateway-unreachable";
880
+ hints.push(`网关 ${gatewayPort} 端口不可达或反向代理后端异常`);
881
+ } else if (status === 200 && /<!doctype html|<html/i.test(body)) {
882
+ reason = "html-fallback";
883
+ hints.push("返回了 WebUI HTML,通常表示 webhook 路由未注册或 webhookPath 配置不一致");
884
+ hints.push(`请确认 channels.wecom.webhookPath=${webhookPath} 与企业微信后台回调地址完全一致`);
885
+ hints.push("确认插件已加载:plugins.entries.openclaw-wechat.enabled=true 且 plugins.allow 包含 openclaw-wechat");
886
+ }
887
+
888
+ return {
889
+ ok: false,
890
+ detail: `status=${status} body=${preview}${hints.length > 0 ? ` hint=${hints.join(";")}` : ""}`,
891
+ data: {
892
+ status,
893
+ reason,
894
+ webhookPath,
895
+ gatewayPort,
896
+ location: location || null,
897
+ hints,
898
+ },
899
+ };
900
+ }
901
+
902
+ async function runAccountChecks({ config, accountId, args }) {
903
+ const checks = [];
904
+ checks.push(...buildPluginChecks(config));
905
+ const resolved = resolveAccountFromConfig(config, accountId, {
906
+ allowFallback: !args.allAccounts,
907
+ });
908
+
909
+ if (!resolved) {
910
+ checks.push(makeCheck("config.account", false, `account '${accountId}' not found or incomplete`));
911
+ return { accountId, resolved: null, checks, summary: summarize(checks) };
912
+ }
913
+
914
+ checks.push(
915
+ makeCheck(
916
+ "config.account",
917
+ true,
918
+ `resolved account=${resolved.accountId} source=${resolved.source}${resolved.fallbackFor ? ` fallback-for=${resolved.fallbackFor}` : ""}`,
919
+ {
920
+ accountId: resolved.accountId,
921
+ source: resolved.source,
922
+ enabled: resolved.enabled,
923
+ webhookPath: resolved.webhookPath,
924
+ },
925
+ ),
926
+ );
927
+
928
+ checks.push(
929
+ makeCheck(
930
+ "config.enabled",
931
+ resolved.enabled !== false,
932
+ resolved.enabled === false ? "account is disabled" : "account enabled",
933
+ ),
934
+ );
935
+
936
+ const botOnlyAccount = resolved?.botOnly === true && !(resolved?.corpId && resolved?.corpSecret && resolved?.agentId);
937
+
938
+ const required = [
939
+ ["corpId", resolved.corpId],
940
+ ["corpSecret", resolved.corpSecret],
941
+ ["agentId", resolved.agentId],
942
+ ["callbackToken", resolved.callbackToken],
943
+ ["callbackAesKey", resolved.callbackAesKey],
944
+ ];
945
+ for (const [k, v] of required) {
946
+ checks.push(
947
+ makeCheck(
948
+ `config.${k}`,
949
+ botOnlyAccount ? true : Boolean(v),
950
+ botOnlyAccount ? "not required for bot-only account" : v ? "ok" : "missing",
951
+ ),
952
+ );
953
+ }
954
+
955
+ const aes = botOnlyAccount ? null : decodeAesKey(resolved.callbackAesKey || "");
956
+ checks.push(
957
+ makeCheck(
958
+ "config.callbackAesKey.length",
959
+ botOnlyAccount ? true : aes?.length === 32,
960
+ botOnlyAccount ? "not required for bot-only account" : `decoded-bytes=${aes?.length ?? 0} (expected 32)`,
961
+ ),
962
+ );
963
+
964
+ const webhookPath = String(resolved.webhookPath || "/wecom/callback");
965
+ checks.push(
966
+ makeCheck(
967
+ "config.webhookPath",
968
+ webhookPath.startsWith("/"),
969
+ `path=${webhookPath}`,
970
+ ),
971
+ );
972
+
973
+ const normalizedWebhookPath = normalizeWebhookPath(webhookPath);
974
+ const conflicts = collectOtherChannelWebhookPaths(config).filter(
975
+ (row) => row.webhookPath === normalizedWebhookPath,
976
+ );
977
+ checks.push(
978
+ makeCheck(
979
+ "config.webhookPath.conflict",
980
+ conflicts.length === 0,
981
+ conflicts.length === 0
982
+ ? `no cross-channel conflict on ${normalizedWebhookPath}`
983
+ : `conflicts with ${conflicts.map((row) => `${row.channelId}:${row.accountId}`).join(", ")}`,
984
+ ),
985
+ );
986
+
987
+ const webhookTargets = normalizeWebhookTargetMap(resolved.webhooks);
988
+ const webhookAliases = Object.keys(webhookTargets).sort();
989
+ const invalidWebhookTargets = validateWebhookTargetMap(webhookTargets);
990
+ checks.push(
991
+ makeCheck(
992
+ "config.webhooks.targets",
993
+ invalidWebhookTargets.length === 0,
994
+ webhookAliases.length === 0
995
+ ? "no named webhook targets configured"
996
+ : `configured=${webhookAliases.length} invalid=${invalidWebhookTargets.length}`,
997
+ webhookAliases.length > 0
998
+ ? {
999
+ aliases: webhookAliases,
1000
+ invalid: invalidWebhookTargets,
1001
+ }
1002
+ : null,
1003
+ ),
1004
+ );
1005
+
1006
+ const outboundProxy = resolveAccountProxy(config, resolved);
1007
+ const apiBaseUrl = resolveAccountApiBaseUrl(config, resolved);
1008
+ const proxyValid = !outboundProxy || isLikelyHttpProxyUrl(outboundProxy);
1009
+ checks.push(
1010
+ makeCheck(
1011
+ "config.outboundProxy",
1012
+ proxyValid,
1013
+ outboundProxy
1014
+ ? `configured (${sanitizeProxyForLog(outboundProxy)})`
1015
+ : "not configured (direct access to qyapi.weixin.qq.com required)",
1016
+ ),
1017
+ );
1018
+ checks.push(
1019
+ makeCheck(
1020
+ "config.apiBaseUrl",
1021
+ Boolean(apiBaseUrl),
1022
+ `configured (${apiBaseUrl})`,
1023
+ { apiBaseUrl },
1024
+ ),
1025
+ );
1026
+
1027
+ if (!args.skipNetwork && resolved.enabled !== false && resolved.corpId && resolved.corpSecret) {
1028
+ const tokenUrl = buildWecomApiUrl(
1029
+ `/cgi-bin/gettoken?corpid=${encodeURIComponent(resolved.corpId)}` +
1030
+ `&corpsecret=${encodeURIComponent(resolved.corpSecret)}`,
1031
+ { apiBaseUrl },
1032
+ );
1033
+ try {
1034
+ const tokenResp = await fetchJsonWithTimeout(tokenUrl, args.timeoutMs, outboundProxy, apiBaseUrl);
1035
+ const token = tokenResp.json?.access_token;
1036
+ const errcode = Number(tokenResp.json?.errcode ?? -1);
1037
+ checks.push(
1038
+ makeCheck(
1039
+ "network.gettoken",
1040
+ tokenResp.ok && errcode === 0 && Boolean(token),
1041
+ `status=${tokenResp.status} errcode=${errcode} errmsg=${tokenResp.json?.errmsg ?? "n/a"}`,
1042
+ {
1043
+ errcode,
1044
+ errmsg: tokenResp.json?.errmsg ?? "n/a",
1045
+ expires_in: tokenResp.json?.expires_in ?? null,
1046
+ access_token_present: Boolean(token),
1047
+ },
1048
+ ),
1049
+ );
1050
+
1051
+ if (token) {
1052
+ const cbIpUrl = buildWecomApiUrl(
1053
+ `/cgi-bin/getcallbackip?access_token=${encodeURIComponent(token)}`,
1054
+ { apiBaseUrl },
1055
+ );
1056
+ const cbIpResp = await fetchJsonWithTimeout(cbIpUrl, args.timeoutMs, outboundProxy, apiBaseUrl);
1057
+ const cbErr = Number(cbIpResp.json?.errcode ?? -1);
1058
+ checks.push(
1059
+ makeCheck(
1060
+ "network.getcallbackip",
1061
+ cbIpResp.ok && cbErr === 0,
1062
+ `status=${cbIpResp.status} errcode=${cbErr} ip_count=${Array.isArray(cbIpResp.json?.ip_list) ? cbIpResp.json.ip_list.length : 0}`,
1063
+ ),
1064
+ );
1065
+ }
1066
+ } catch (err) {
1067
+ checks.push(makeCheck("network.gettoken", false, `request failed: ${String(err?.message || err)}`));
1068
+ }
1069
+ }
1070
+
1071
+ if (!args.skipLocalWebhook) {
1072
+ const gatewayPort = asNumber(config?.gateway?.port, 8885);
1073
+ const localWebhookUrl = `http://127.0.0.1:${gatewayPort}${webhookPath}`;
1074
+ try {
1075
+ const resp = await fetchJsonWithTimeout(localWebhookUrl, Math.min(args.timeoutMs, 4000), "", {
1076
+ redirect: "manual",
1077
+ });
1078
+ const raw = resp.json?.raw ?? "";
1079
+ const diagnosed = diagnoseLocalWebhookHealth({
1080
+ status: resp.status,
1081
+ raw,
1082
+ webhookPath,
1083
+ gatewayPort,
1084
+ location: typeof resp.json?.headers?.location === "string" ? resp.json.headers.location : "",
1085
+ });
1086
+ checks.push(
1087
+ makeCheck(
1088
+ "local.webhook.health",
1089
+ diagnosed.ok,
1090
+ diagnosed.detail,
1091
+ diagnosed.data,
1092
+ ),
1093
+ );
1094
+ } catch (err) {
1095
+ checks.push(makeCheck("local.webhook.health", false, `probe failed: ${String(err?.message || err)}`));
1096
+ }
1097
+ }
1098
+
1099
+ return {
1100
+ accountId,
1101
+ resolved: {
1102
+ accountId: resolved.accountId,
1103
+ source: resolved.source,
1104
+ enabled: resolved.enabled,
1105
+ webhookPath: resolved.webhookPath,
1106
+ botOnly: resolved.botOnly === true,
1107
+ webhookTargetCount: webhookAliases.length,
1108
+ outboundProxy: outboundProxy ? sanitizeProxyForLog(outboundProxy) : null,
1109
+ fallbackFor: resolved.fallbackFor || null,
1110
+ },
1111
+ overview: buildAccountOverview({ config, resolved }),
1112
+ checks,
1113
+ summary: summarize(checks),
1114
+ };
1115
+ }
1116
+
1117
+ function reportAndExit(report, asJson = false) {
1118
+ if (asJson) {
1119
+ console.log(JSON.stringify(report, null, 2));
1120
+ process.exit(report.summary.ok ? 0 : 1);
1121
+ return;
1122
+ }
1123
+
1124
+ console.log(`WeCom selfcheck`);
1125
+ console.log(`- config: ${report.configPath}`);
1126
+ console.log(
1127
+ `- mode: ${report.args.allAccounts ? "all-accounts" : `single-account (${report.args.account})`}`,
1128
+ );
1129
+ if (report.installState) {
1130
+ console.log(`- installState: ${report.installState}`);
1131
+ }
1132
+ if (report.installStateSummary) {
1133
+ console.log(`- installSummary: ${report.installStateSummary}`);
1134
+ }
1135
+ if (report.migrationState) {
1136
+ console.log(`- migrationState: ${report.migrationState}`);
1137
+ }
1138
+ if (report.migrationStateSummary) {
1139
+ console.log(`- migrationSummary: ${report.migrationStateSummary}`);
1140
+ }
1141
+ if (Array.isArray(report.detectedLegacyFields) && report.detectedLegacyFields.length > 0) {
1142
+ console.log(`- migrationCommand: ${report.migrationCommand || WECOM_MIGRATION_COMMAND}`);
1143
+ }
1144
+
1145
+ for (const accountReport of report.accounts) {
1146
+ console.log(`\nAccount: ${accountReport.accountId}`);
1147
+ if (accountReport.resolved) {
1148
+ const meta = accountReport.resolved;
1149
+ const overview = accountReport.overview ?? buildAccountOverview({ config: report.config, resolved: meta });
1150
+ console.log(
1151
+ `- resolved: ${meta.accountId} source=${meta.source}${meta.fallbackFor ? ` fallback-for=${meta.fallbackFor}` : ""}`,
1152
+ );
1153
+ console.log(`- webhookPath: ${meta.webhookPath}`);
1154
+ console.log(`- namedWebhookTargets: ${meta.webhookTargetCount ?? 0}`);
1155
+ console.log(`- outboundProxy: ${meta.outboundProxy || "(none)"}`);
1156
+ console.log(
1157
+ `- readiness: receive=${overview.canReceive ? "yes" : "no"} reply=${overview.canReply ? "yes" : "no"} send=${overview.canSend ? "yes" : "no"} doc=${overview.docEnabled ? "on" : "off"}`,
1158
+ );
1159
+ console.log(
1160
+ `- routing: bindings=${overview.bindingsCount} dynamicAgent=${overview.dynamicAgentEnabled ? "on" : "off"}`,
1161
+ );
1162
+ console.log(
1163
+ `- reliable-delivery: pendingReply=${overview.pendingReplyEnabled ? "on" : "off"} persist=${overview.pendingReplyPersist ? "on" : "off"} quotaTracking=${overview.quotaTrackingEnabled ? "on" : "off"} store=${overview.pendingReplyPersist ? overview.pendingReplyStoreFile || "(auto)" : "(disabled)"}`,
1164
+ );
1165
+ console.log(
1166
+ `- group-policy: mode=${overview.groupPolicy.mode} trigger=${overview.groupPolicy.trigger} allowFrom=${overview.groupPolicy.allowSummary} groups=${overview.groupPolicy.groups} source=${overview.groupPolicy.source}`,
1167
+ );
1168
+ console.log(
1169
+ `- reasoning: mode=${overview.reasoningMode} title=${overview.reasoningTitle} maxChars=${overview.reasoningMaxChars}`,
1170
+ );
1171
+ }
1172
+ for (const check of accountReport.checks) {
1173
+ console.log(`${check.ok ? "OK " : "FAIL"} ${check.name} :: ${check.detail}`);
1174
+ }
1175
+ console.log(
1176
+ `Account summary: ${accountReport.summary.passed}/${accountReport.summary.total} passed`,
1177
+ );
1178
+ }
1179
+
1180
+ console.log(
1181
+ `\nSummary: accounts ${report.summary.accountsPassed}/${report.summary.accountsTotal} passed, checks ${report.summary.passed}/${report.summary.total} passed`,
1182
+ );
1183
+ process.exit(report.summary.ok ? 0 : 1);
1184
+ }
1185
+
1186
+ async function main() {
1187
+ const args = parseArgs(process.argv);
1188
+ const configPath = path.resolve(expandHome(args.configPath));
1189
+
1190
+ let config = null;
1191
+ try {
1192
+ const raw = await readFile(configPath, "utf8");
1193
+ config = JSON.parse(raw);
1194
+ } catch (err) {
1195
+ const failReport = {
1196
+ args,
1197
+ configPath,
1198
+ accounts: [
1199
+ {
1200
+ accountId: normalizeAccountId(args.account),
1201
+ resolved: null,
1202
+ checks: [
1203
+ makeCheck(
1204
+ "config.load",
1205
+ false,
1206
+ `failed to load ${configPath}: ${String(err?.message || err)}`,
1207
+ ),
1208
+ ],
1209
+ summary: summarize([
1210
+ makeCheck(
1211
+ "config.load",
1212
+ false,
1213
+ `failed to load ${configPath}: ${String(err?.message || err)}`,
1214
+ ),
1215
+ ]),
1216
+ },
1217
+ ],
1218
+ };
1219
+ failReport.summary = summarizeAccounts(failReport.accounts);
1220
+ reportAndExit(failReport, args.json);
1221
+ return;
1222
+ }
1223
+
1224
+ const targetAccounts = args.allAccounts
1225
+ ? discoverAccountIds(config)
1226
+ : [normalizeAccountId(args.account)];
1227
+ const accountReports = [];
1228
+
1229
+ for (const accountId of targetAccounts) {
1230
+ // Keep checks deterministic and easier to read.
1231
+ // eslint-disable-next-line no-await-in-loop
1232
+ const report = await runAccountChecks({ config, accountId, args });
1233
+ report.checks.unshift(makeCheck("config.load", true, `loaded ${configPath}`));
1234
+ report.summary = summarize(report.checks);
1235
+ accountReports.push(report);
1236
+ }
1237
+
1238
+ const finalReport = {
1239
+ args,
1240
+ configPath,
1241
+ config,
1242
+ ...collectWecomMigrationDiagnostics({
1243
+ config,
1244
+ accountId: args.account,
1245
+ }),
1246
+ accounts: accountReports,
1247
+ summary: summarizeAccounts(accountReports),
1248
+ };
1249
+ reportAndExit(finalReport, args.json);
1250
+ }
1251
+
1252
+ main().catch((err) => {
1253
+ console.error(`Selfcheck failed: ${String(err?.message || err)}`);
1254
+ process.exit(1);
1255
+ });