@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,1407 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { spawn } from "node:child_process";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { listLegacyInlineAccountEntries } from "../src/wecom/account-config.js";
10
+ import { collectWecomEnvAccountIds, normalizeAccountId } from "../src/wecom/account-config-core.js";
11
+ import { resolveWecomProxyConfig } from "../src/core.js";
12
+ import { collectWecomMigrationDiagnostics } from "../src/wecom/migration-diagnostics.js";
13
+ import {
14
+ WECOM_DOCTOR_COMMAND,
15
+ WECOM_QUICKSTART_MIGRATION_COMMAND,
16
+ WECOM_QUICKSTART_SETUP_COMMAND,
17
+ WECOM_QUICKSTART_WIZARD_COMMAND,
18
+ } from "../src/wecom/quickstart-metadata.js";
19
+ import { resolveWecomApiBaseUrl } from "../src/wecom/network-config.js";
20
+
21
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
22
+ const WECOM_PLUGIN_ENTRY_ID = "openclaw-wechat";
23
+ const SCRIPT_PATHS = Object.freeze({
24
+ selfcheck: path.join(SCRIPT_DIR, "wecom-selfcheck.mjs"),
25
+ agentSelfcheck: path.join(SCRIPT_DIR, "wecom-agent-selfcheck.mjs"),
26
+ botSelfcheck: path.join(SCRIPT_DIR, "wecom-bot-selfcheck.mjs"),
27
+ botLongconnProbe: path.join(SCRIPT_DIR, "wecom-bot-longconn-probe.mjs"),
28
+ callbackMatrix: path.join(SCRIPT_DIR, "wecom-callback-matrix.mjs"),
29
+ });
30
+ let sharedNonTtyAnswersPromise = null;
31
+ let sharedNonTtyAnswerIndex = 0;
32
+
33
+ function expandHome(p) {
34
+ if (!p) return p;
35
+ if (p === "~") return os.homedir();
36
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
37
+ return p;
38
+ }
39
+
40
+ function pickFirstNonEmptyString(...values) {
41
+ for (const value of values) {
42
+ const trimmed = String(value ?? "").trim();
43
+ if (trimmed) return trimmed;
44
+ }
45
+ return "";
46
+ }
47
+
48
+ function parseBooleanLike(value, fallback = false) {
49
+ if (typeof value === "boolean") return value;
50
+ const normalized = String(value ?? "")
51
+ .trim()
52
+ .toLowerCase();
53
+ if (!normalized) return fallback;
54
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
55
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
56
+ return fallback;
57
+ }
58
+
59
+ function normalizeYesNo(answer, fallback = false) {
60
+ const normalized = String(answer ?? "")
61
+ .trim()
62
+ .toLowerCase();
63
+ if (!normalized) return fallback;
64
+ if (["y", "yes", "true", "1"].includes(normalized)) return true;
65
+ if (["n", "no", "false", "0"].includes(normalized)) return false;
66
+ return fallback;
67
+ }
68
+
69
+ async function readStdinLines() {
70
+ return new Promise((resolve, reject) => {
71
+ let raw = "";
72
+ process.stdin.setEncoding("utf8");
73
+ process.stdin.on("data", (chunk) => {
74
+ raw += chunk;
75
+ });
76
+ process.stdin.on("end", () => {
77
+ resolve(raw.split(/\r?\n/));
78
+ });
79
+ process.stdin.on("error", reject);
80
+ });
81
+ }
82
+
83
+ async function createPrompt() {
84
+ if (process.stdin.isTTY) {
85
+ return createInterface({
86
+ input: process.stdin,
87
+ output: process.stderr,
88
+ terminal: process.stderr.isTTY === true,
89
+ });
90
+ }
91
+ if (!sharedNonTtyAnswersPromise) {
92
+ sharedNonTtyAnswersPromise = readStdinLines();
93
+ }
94
+ const answers = await sharedNonTtyAnswersPromise;
95
+ return {
96
+ output: process.stderr,
97
+ async question(prompt) {
98
+ this.output.write(prompt);
99
+ const answer = answers[sharedNonTtyAnswerIndex] ?? "";
100
+ sharedNonTtyAnswerIndex += 1;
101
+ this.output.write(answer ? `${answer}\n` : "\n");
102
+ return answer;
103
+ },
104
+ close() {},
105
+ };
106
+ }
107
+
108
+ async function askBoolean(rl, message, defaultValue = false) {
109
+ const label = defaultValue ? "Y/n" : "y/N";
110
+ const answer = await rl.question(`${message} [${label}]: `);
111
+ return normalizeYesNo(answer, defaultValue);
112
+ }
113
+
114
+ function asObject(value) {
115
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
116
+ }
117
+
118
+ function mergeDeep(base, patch) {
119
+ if (Array.isArray(patch)) return patch.slice();
120
+ if (!patch || typeof patch !== "object") return patch;
121
+ const out = { ...asObject(base) };
122
+ for (const [key, value] of Object.entries(patch)) {
123
+ if (value && typeof value === "object" && !Array.isArray(value)) {
124
+ out[key] = mergeDeep(asObject(base?.[key]), value);
125
+ } else if (Array.isArray(value)) {
126
+ out[key] = value.slice();
127
+ } else {
128
+ out[key] = value;
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function valuesEqual(left, right) {
135
+ return JSON.stringify(left) === JSON.stringify(right);
136
+ }
137
+
138
+ function collectChangedPaths(baseValue, patchValue, prefix = "", out = []) {
139
+ if (Array.isArray(patchValue)) {
140
+ if (!valuesEqual(baseValue, patchValue) && prefix) out.push(prefix);
141
+ return out;
142
+ }
143
+ if (!patchValue || typeof patchValue !== "object") {
144
+ if (!valuesEqual(baseValue, patchValue) && prefix) out.push(prefix);
145
+ return out;
146
+ }
147
+ for (const [key, value] of Object.entries(patchValue)) {
148
+ const nextPrefix = prefix ? `${prefix}.${key}` : key;
149
+ const nextBase = baseValue && typeof baseValue === "object" ? baseValue[key] : undefined;
150
+ collectChangedPaths(nextBase, value, nextPrefix, out);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function deleteNestedPath(target, dottedPath = "") {
156
+ const parts = String(dottedPath ?? "").split(".").filter(Boolean);
157
+ if (parts.length === 0) return;
158
+ const stack = [];
159
+ let cursor = target;
160
+ for (const part of parts.slice(0, -1)) {
161
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return;
162
+ stack.push([cursor, part]);
163
+ cursor = cursor[part];
164
+ }
165
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return;
166
+ delete cursor[parts.at(-1)];
167
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
168
+ const [parent, key] = stack[index];
169
+ const child = parent?.[key];
170
+ if (child && typeof child === "object" && !Array.isArray(child) && Object.keys(child).length === 0) {
171
+ delete parent[key];
172
+ }
173
+ }
174
+ }
175
+
176
+ function buildMigratedConfig(baseConfig, patch, legacyFields = []) {
177
+ const merged = mergeDeep(baseConfig, patch);
178
+ for (const field of legacyFields) {
179
+ deleteNestedPath(merged, field?.path);
180
+ }
181
+ return merged;
182
+ }
183
+
184
+ function ensureArrayIncludes(values, item) {
185
+ const out = Array.isArray(values) ? values.slice() : [];
186
+ if (!out.includes(item)) out.push(item);
187
+ return out;
188
+ }
189
+
190
+ function buildDoctorFixPatch(config = {}, diagnostics = {}) {
191
+ const patch = diagnostics?.configPatch ? mergeDeep({}, diagnostics.configPatch) : {};
192
+ const hasWecomConfig = Object.keys(asObject(config?.channels?.wecom)).length > 0;
193
+ if (!hasWecomConfig) {
194
+ return Object.keys(patch).length > 0 ? patch : null;
195
+ }
196
+
197
+ const currentPlugins = asObject(config?.plugins);
198
+ const currentEntries = asObject(currentPlugins?.entries);
199
+ if (currentPlugins.enabled !== true) {
200
+ patch.plugins = mergeDeep(asObject(patch.plugins), { enabled: true });
201
+ }
202
+ const desiredAllow = ensureArrayIncludes(currentPlugins.allow, WECOM_PLUGIN_ENTRY_ID);
203
+ if (!valuesEqual(currentPlugins.allow, desiredAllow)) {
204
+ patch.plugins = mergeDeep(asObject(patch.plugins), { allow: desiredAllow });
205
+ }
206
+ if (currentEntries?.[WECOM_PLUGIN_ENTRY_ID]?.enabled !== true) {
207
+ patch.plugins = mergeDeep(asObject(patch.plugins), {
208
+ entries: {
209
+ [WECOM_PLUGIN_ENTRY_ID]: { enabled: true },
210
+ },
211
+ });
212
+ }
213
+
214
+ return Object.keys(patch).length > 0 ? patch : null;
215
+ }
216
+
217
+ function buildBackupPath(configPath) {
218
+ return `${configPath}.bak-${Date.now()}`;
219
+ }
220
+
221
+ async function loadConfig(configPath) {
222
+ const resolvedPath = path.resolve(expandHome(configPath));
223
+ try {
224
+ const raw = await readFile(resolvedPath, "utf8");
225
+ return {
226
+ exists: true,
227
+ configPath: resolvedPath,
228
+ config: JSON.parse(raw),
229
+ };
230
+ } catch (err) {
231
+ if (err?.code === "ENOENT") {
232
+ return {
233
+ exists: false,
234
+ configPath: resolvedPath,
235
+ config: {},
236
+ };
237
+ }
238
+ throw err;
239
+ }
240
+ }
241
+
242
+ async function writeMergedConfig(configPath, patch) {
243
+ const loaded = await loadConfig(configPath);
244
+ const changedPaths = collectChangedPaths(loaded.config, patch);
245
+ const merged = mergeDeep(loaded.config, patch);
246
+ const backupPath = loaded.exists ? buildBackupPath(loaded.configPath) : null;
247
+ await mkdir(path.dirname(loaded.configPath), { recursive: true });
248
+ if (loaded.exists) {
249
+ await writeFile(backupPath, `${JSON.stringify(loaded.config, null, 2)}\n`, "utf8");
250
+ }
251
+ await writeFile(loaded.configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
252
+ return {
253
+ applied: true,
254
+ configPath: loaded.configPath,
255
+ backupPath,
256
+ existed: loaded.exists,
257
+ changedPaths,
258
+ };
259
+ }
260
+
261
+ async function writeDoctorFixConfig(configPath, patch, legacyFields = []) {
262
+ const loaded = await loadConfig(configPath);
263
+ const merged = buildMigratedConfig(loaded.config, patch, legacyFields);
264
+ const changedPaths = collectChangedPaths(loaded.config, merged);
265
+ const backupPath = loaded.exists ? buildBackupPath(loaded.configPath) : null;
266
+ await mkdir(path.dirname(loaded.configPath), { recursive: true });
267
+ if (loaded.exists) {
268
+ await writeFile(backupPath, `${JSON.stringify(loaded.config, null, 2)}\n`, "utf8");
269
+ }
270
+ await writeFile(loaded.configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
271
+ return {
272
+ applied: true,
273
+ configPath: loaded.configPath,
274
+ backupPath,
275
+ existed: loaded.exists,
276
+ changedPaths,
277
+ };
278
+ }
279
+
280
+ function formatPreviewValue(value) {
281
+ if (typeof value === "string") return JSON.stringify(value);
282
+ if (value === undefined) return "undefined";
283
+ return JSON.stringify(value);
284
+ }
285
+
286
+ function collectPatchPreviewLines(value, prefix = "", out = []) {
287
+ if (Array.isArray(value)) {
288
+ out.push(`${prefix} = ${formatPreviewValue(value)}`);
289
+ return out;
290
+ }
291
+ if (!value || typeof value !== "object") {
292
+ out.push(`${prefix} = ${formatPreviewValue(value)}`);
293
+ return out;
294
+ }
295
+ for (const [key, child] of Object.entries(value)) {
296
+ const childPrefix = prefix ? `${prefix}.${key}` : key;
297
+ collectPatchPreviewLines(child, childPrefix, out);
298
+ }
299
+ return out;
300
+ }
301
+
302
+ function parseArgs(argv) {
303
+ const out = {
304
+ account: "default",
305
+ allAccounts: false,
306
+ configPath: process.env.OPENCLAW_CONFIG_PATH || "~/.openclaw/openclaw.json",
307
+ timeoutMs: 8000,
308
+ skipNetwork: false,
309
+ skipLocalWebhook: false,
310
+ skipAgentE2E: false,
311
+ skipBotE2E: false,
312
+ skipLongconn: false,
313
+ skipCallbackMatrix: false,
314
+ fix: false,
315
+ confirmFix: false,
316
+ agentUrl: "",
317
+ botUrl: "",
318
+ agentLegacyUrl: "",
319
+ botLegacyUrl: "",
320
+ json: false,
321
+ };
322
+
323
+ for (let index = 2; index < argv.length; index += 1) {
324
+ const arg = argv[index];
325
+ const next = argv[index + 1];
326
+ if (arg === "--account" && next) {
327
+ out.account = normalizeAccountId(next);
328
+ index += 1;
329
+ } else if (arg === "--all-accounts") {
330
+ out.allAccounts = true;
331
+ } else if (arg === "--config" && next) {
332
+ out.configPath = next;
333
+ index += 1;
334
+ } else if (arg === "--timeout-ms" && next) {
335
+ const parsed = Number(next);
336
+ if (Number.isFinite(parsed) && parsed > 0) out.timeoutMs = Math.floor(parsed);
337
+ index += 1;
338
+ } else if (arg === "--skip-network") {
339
+ out.skipNetwork = true;
340
+ } else if (arg === "--skip-local-webhook") {
341
+ out.skipLocalWebhook = true;
342
+ } else if (arg === "--skip-agent-e2e") {
343
+ out.skipAgentE2E = true;
344
+ } else if (arg === "--skip-bot-e2e") {
345
+ out.skipBotE2E = true;
346
+ } else if (arg === "--skip-longconn") {
347
+ out.skipLongconn = true;
348
+ } else if (arg === "--skip-callback-matrix") {
349
+ out.skipCallbackMatrix = true;
350
+ } else if (arg === "--fix") {
351
+ out.fix = true;
352
+ } else if (arg === "--confirm-fix") {
353
+ out.confirmFix = true;
354
+ out.fix = true;
355
+ } else if (arg === "--agent-url" && next) {
356
+ out.agentUrl = String(next).trim();
357
+ index += 1;
358
+ } else if (arg === "--bot-url" && next) {
359
+ out.botUrl = String(next).trim();
360
+ index += 1;
361
+ } else if (arg === "--agent-legacy-url" && next) {
362
+ out.agentLegacyUrl = String(next).trim();
363
+ index += 1;
364
+ } else if (arg === "--bot-legacy-url" && next) {
365
+ out.botLegacyUrl = String(next).trim();
366
+ index += 1;
367
+ } else if (arg === "--json") {
368
+ out.json = true;
369
+ } else if (arg === "-h" || arg === "--help") {
370
+ printHelp();
371
+ process.exit(0);
372
+ } else {
373
+ throw new Error(`Unknown argument: ${arg}`);
374
+ }
375
+ }
376
+
377
+ if (out.allAccounts && (out.agentUrl || out.botUrl || out.agentLegacyUrl || out.botLegacyUrl)) {
378
+ throw new Error("--agent-url/--bot-url overrides cannot be used together with --all-accounts");
379
+ }
380
+ return out;
381
+ }
382
+
383
+ function printHelp() {
384
+ console.log(`OpenClaw-Wechat doctor
385
+
386
+ Usage:
387
+ npm run wecom:doctor -- [options]
388
+
389
+ Options:
390
+ --account <id> account id to diagnose (default: default)
391
+ --all-accounts diagnose all discovered accounts
392
+ --config <path> OpenClaw config path (default: ~/.openclaw/openclaw.json)
393
+ --timeout-ms <ms> timeout for each network diagnostic (default: 8000)
394
+ --skip-network skip all remote/e2e diagnostics and keep local migration/selfcheck only
395
+ --skip-local-webhook pass through to wecom:selfcheck
396
+ --skip-agent-e2e skip agent callback e2e checks
397
+ --skip-bot-e2e skip bot callback e2e checks
398
+ --skip-longconn skip Bot long connection probe
399
+ --skip-callback-matrix skip public callback matrix checks
400
+ --fix apply the generated local migration patch before rerunning doctor
401
+ --confirm-fix preview fix patch and ask before applying it
402
+ --agent-url <url> override public Agent callback URL for callback matrix (single-account only)
403
+ --bot-url <url> override public Bot callback URL for callback matrix (single-account only)
404
+ --agent-legacy-url <url> optional legacy Agent alias URL for callback matrix
405
+ --bot-legacy-url <url> optional legacy Bot alias URL for callback matrix
406
+ --json print machine-readable JSON report
407
+ -h, --help show this help
408
+ `);
409
+ }
410
+
411
+ function makeSkippedSection(id, title, reason, command = "") {
412
+ return {
413
+ id,
414
+ title,
415
+ status: "skipped",
416
+ ok: true,
417
+ command: command || undefined,
418
+ summary: reason,
419
+ detail: reason,
420
+ report: null,
421
+ };
422
+ }
423
+
424
+ function buildAccountCommand(baseCommand, args = [], { redact = [] } = {}) {
425
+ const hidden = new Set(redact);
426
+ const parts = [baseCommand];
427
+ for (let index = 0; index < args.length; index += 1) {
428
+ const part = String(args[index] ?? "");
429
+ if (hidden.has(part)) {
430
+ parts.push(part, "<redacted>");
431
+ index += 1;
432
+ continue;
433
+ }
434
+ parts.push(part.includes(" ") ? JSON.stringify(part) : part);
435
+ }
436
+ return parts.join(" ");
437
+ }
438
+
439
+ function buildSection(id, title, ok, summary, detail, command, report) {
440
+ return {
441
+ id,
442
+ title,
443
+ status: ok ? "ok" : "failed",
444
+ ok: Boolean(ok),
445
+ command,
446
+ summary,
447
+ detail,
448
+ report,
449
+ };
450
+ }
451
+
452
+ function summarizeChecks(summary = {}) {
453
+ const passed = Number(summary?.passed ?? 0);
454
+ const total = Number(summary?.total ?? 0);
455
+ return `${passed}/${total} passed`;
456
+ }
457
+
458
+ function sanitizeResultOutput(stdout = "", stderr = "") {
459
+ const stdoutText = String(stdout ?? "").trim();
460
+ const stderrText = String(stderr ?? "").trim();
461
+ return pickFirstNonEmptyString(stderrText, stdoutText);
462
+ }
463
+
464
+ function parseJsonMaybe(text = "") {
465
+ const trimmed = String(text ?? "").trim();
466
+ if (!trimmed) return null;
467
+ try {
468
+ return JSON.parse(trimmed);
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
473
+
474
+ async function runJsonScript(scriptPath, scriptArgs, command) {
475
+ return new Promise((resolve) => {
476
+ const child = spawn(process.execPath, [scriptPath, ...scriptArgs], {
477
+ cwd: process.cwd(),
478
+ stdio: ["ignore", "pipe", "pipe"],
479
+ env: { ...process.env },
480
+ });
481
+
482
+ let stdout = "";
483
+ let stderr = "";
484
+ child.stdout.on("data", (chunk) => {
485
+ stdout += chunk.toString();
486
+ });
487
+ child.stderr.on("data", (chunk) => {
488
+ stderr += chunk.toString();
489
+ });
490
+ child.on("close", (code) => {
491
+ resolve({
492
+ command,
493
+ args: scriptArgs.slice(),
494
+ exitCode: Number.isInteger(code) ? code : -1,
495
+ stdout,
496
+ stderr,
497
+ parsed: parseJsonMaybe(stdout),
498
+ });
499
+ });
500
+ });
501
+ }
502
+
503
+ function discoverConfiguredAccountIds(config = {}) {
504
+ const ids = new Set();
505
+ const channel = config?.channels?.wecom;
506
+ if (channel && typeof channel === "object") ids.add("default");
507
+ if (channel?.accounts && typeof channel.accounts === "object") {
508
+ for (const key of Object.keys(channel.accounts)) ids.add(normalizeAccountId(key));
509
+ }
510
+ for (const [accountId] of listLegacyInlineAccountEntries(channel)) {
511
+ ids.add(normalizeAccountId(accountId));
512
+ }
513
+ for (const accountId of collectWecomEnvAccountIds({
514
+ envVars: config?.env?.vars ?? {},
515
+ processEnv: process.env,
516
+ })) {
517
+ ids.add(normalizeAccountId(accountId));
518
+ }
519
+ return Array.from(ids).sort((left, right) => {
520
+ if (left === "default") return -1;
521
+ if (right === "default") return 1;
522
+ return left.localeCompare(right);
523
+ });
524
+ }
525
+
526
+ function resolveBotConfig(config = {}, accountId = "default") {
527
+ const normalizedAccountId = normalizeAccountId(accountId);
528
+ const channel = config?.channels?.wecom ?? {};
529
+ const accountBlock =
530
+ normalizedAccountId === "default"
531
+ ? channel
532
+ : channel?.accounts && typeof channel.accounts === "object"
533
+ ? channel.accounts[normalizedAccountId] ??
534
+ (listLegacyInlineAccountEntries(channel).find(([id]) => normalizeAccountId(id) === normalizedAccountId)?.[1] ?? {})
535
+ : {};
536
+ const bot =
537
+ normalizedAccountId === "default"
538
+ ? channel?.bot ?? {}
539
+ : accountBlock?.bot && typeof accountBlock.bot === "object"
540
+ ? accountBlock.bot
541
+ : {};
542
+ const envVars = config?.env?.vars ?? {};
543
+ const accountEnvPrefix = normalizedAccountId === "default" ? null : `WECOM_${normalizedAccountId.toUpperCase()}_BOT_`;
544
+ const readBotEnv = (suffix) => {
545
+ const scopedKey = accountEnvPrefix ? `${accountEnvPrefix}${suffix}` : "";
546
+ return pickFirstNonEmptyString(
547
+ scopedKey ? envVars?.[scopedKey] : "",
548
+ scopedKey ? process.env[scopedKey] : "",
549
+ envVars?.[`WECOM_BOT_${suffix}`],
550
+ process.env[`WECOM_BOT_${suffix}`],
551
+ );
552
+ };
553
+ const networkBlock = accountBlock?.network && typeof accountBlock.network === "object" ? accountBlock.network : {};
554
+ const channelNetwork = channel?.network && typeof channel.network === "object" ? channel.network : {};
555
+ const longConnection =
556
+ bot?.longConnection && typeof bot.longConnection === "object"
557
+ ? bot.longConnection
558
+ : {};
559
+ const compatBotId = pickFirstNonEmptyString(accountBlock?.botId, accountBlock?.botid, channel?.botId, channel?.botid);
560
+ const compatSecret = pickFirstNonEmptyString(accountBlock?.secret, channel?.secret);
561
+ const token = pickFirstNonEmptyString(bot.token, bot.callbackToken, readBotEnv("TOKEN"));
562
+ const encodingAesKey = pickFirstNonEmptyString(bot.encodingAesKey, bot.callbackAesKey, readBotEnv("ENCODING_AES_KEY"));
563
+ const botId = pickFirstNonEmptyString(longConnection?.botId, longConnection?.botid, compatBotId);
564
+ const secret = pickFirstNonEmptyString(longConnection?.secret, compatSecret);
565
+ const longConnectionEnabled =
566
+ longConnection?.enabled === true || (Boolean(botId) && Boolean(secret));
567
+ const enabled = parseBooleanLike(bot.enabled, parseBooleanLike(readBotEnv("ENABLED"), longConnectionEnabled));
568
+ const callbackEnabled = enabled && Boolean(token) && Boolean(encodingAesKey);
569
+ const proxyUrl = String(
570
+ resolveWecomProxyConfig({
571
+ channelConfig: channel,
572
+ accountConfig: {
573
+ ...accountBlock,
574
+ outboundProxy: pickFirstNonEmptyString(
575
+ accountBlock?.outboundProxy,
576
+ accountBlock?.proxyUrl,
577
+ accountBlock?.proxy,
578
+ networkBlock?.egressProxyUrl,
579
+ networkBlock?.proxyUrl,
580
+ networkBlock?.proxy,
581
+ ),
582
+ },
583
+ envVars,
584
+ processEnv: process.env,
585
+ accountId: normalizedAccountId,
586
+ }) ??
587
+ pickFirstNonEmptyString(
588
+ bot?.outboundProxy,
589
+ bot?.proxyUrl,
590
+ bot?.proxy,
591
+ channel?.outboundProxy,
592
+ channel?.proxyUrl,
593
+ channel?.proxy,
594
+ networkBlock?.egressProxyUrl,
595
+ channelNetwork?.egressProxyUrl,
596
+ process.env.WECOM_BOT_PROXY,
597
+ process.env.WECOM_PROXY,
598
+ process.env.HTTPS_PROXY,
599
+ process.env.HTTP_PROXY,
600
+ ),
601
+ ).trim();
602
+ const apiBaseUrl = resolveWecomApiBaseUrl({
603
+ channelConfig: channel,
604
+ accountConfig: accountBlock,
605
+ envVars,
606
+ processEnv: process.env,
607
+ accountId: normalizedAccountId,
608
+ });
609
+ return {
610
+ accountId: normalizedAccountId,
611
+ enabled,
612
+ callbackEnabled,
613
+ longConnectionEnabled: enabled && Boolean(botId) && Boolean(secret) && longConnectionEnabled,
614
+ token,
615
+ encodingAesKey,
616
+ proxyUrl,
617
+ apiBaseUrl,
618
+ longConnection: {
619
+ ...longConnection,
620
+ botId,
621
+ secret,
622
+ url: pickFirstNonEmptyString(longConnection?.url),
623
+ },
624
+ };
625
+ }
626
+
627
+ function getAccountReportById(report, accountId, fallbackKeys = ["accountId", "account"]) {
628
+ const accounts = Array.isArray(report?.accounts) ? report.accounts : [];
629
+ for (const item of accounts) {
630
+ for (const key of fallbackKeys) {
631
+ if (normalizeAccountId(item?.[key]) === normalizeAccountId(accountId)) return item;
632
+ }
633
+ }
634
+ return null;
635
+ }
636
+
637
+ function unique(list = []) {
638
+ return Array.from(new Set(list.map((item) => normalizeAccountId(item)).filter(Boolean)));
639
+ }
640
+
641
+ function makeSectionAction(accountId, section) {
642
+ if (!section || section.status !== "failed" || !section.command) return null;
643
+ return {
644
+ id: `${accountId}-${section.id}`,
645
+ accountId,
646
+ kind: "run_check",
647
+ title: `修复 ${accountId} 的 ${section.title}`,
648
+ detail: section.detail,
649
+ command: section.command,
650
+ };
651
+ }
652
+
653
+ function summarizeAccountSections(sections) {
654
+ const items = Object.values(sections);
655
+ const passed = items.filter((item) => item.status === "ok").length;
656
+ const failed = items.filter((item) => item.status === "failed").length;
657
+ const skipped = items.filter((item) => item.status === "skipped").length;
658
+ return {
659
+ ok: failed === 0 && passed >= 1,
660
+ total: items.length,
661
+ passed,
662
+ failed,
663
+ skipped,
664
+ };
665
+ }
666
+
667
+ function buildTopLevelSummary({ migrationSection, accounts }) {
668
+ const accountFailures = accounts.filter((item) => item.summary.ok !== true).length;
669
+ const accountPassed = accounts.length - accountFailures;
670
+ const sectionItems = accounts.flatMap((item) => Object.values(item.sections));
671
+ const sectionsPassed = sectionItems.filter((item) => item.status === "ok").length + (migrationSection.ok ? 1 : 0);
672
+ const sectionsFailed = sectionItems.filter((item) => item.status === "failed").length + (migrationSection.ok ? 0 : 1);
673
+ const sectionsSkipped = sectionItems.filter((item) => item.status === "skipped").length;
674
+ const status =
675
+ migrationSection.ok === true && accounts.length > 0 && accountFailures === 0 ? "ready" : "action_required";
676
+ return {
677
+ ok: status === "ready",
678
+ status,
679
+ accountsTotal: accounts.length,
680
+ accountsPassed: accountPassed,
681
+ accountsFailed: accountFailures,
682
+ sectionsTotal: sectionItems.length + 1,
683
+ sectionsPassed,
684
+ sectionsFailed,
685
+ sectionsSkipped,
686
+ };
687
+ }
688
+
689
+ function buildMigrationSection(diagnostics) {
690
+ const installState = String(diagnostics?.installState ?? "");
691
+ const migrationState = String(diagnostics?.migrationState ?? "");
692
+ const migrationSource = String(diagnostics?.migrationSource ?? "unknown");
693
+ const ok = installState !== "stale_package" && !["legacy_config", "mixed_layout"].includes(migrationState);
694
+ const detail = [
695
+ String(diagnostics?.migrationSourceSummary ?? "").trim(),
696
+ String(diagnostics?.installStateSummary ?? "").trim(),
697
+ String(diagnostics?.migrationStateSummary ?? "").trim(),
698
+ ]
699
+ .filter(Boolean)
700
+ .join(" ");
701
+ return buildSection(
702
+ "migration",
703
+ "migration",
704
+ ok,
705
+ `${migrationSource} ${installState}/${migrationState}`.trim(),
706
+ detail || "migration diagnostics complete",
707
+ WECOM_QUICKSTART_MIGRATION_COMMAND,
708
+ {
709
+ installState,
710
+ migrationState,
711
+ migrationSource,
712
+ migrationSourceSummary: diagnostics?.migrationSourceSummary ?? "",
713
+ migrationSourceSignals: diagnostics?.migrationSourceSignals ?? [],
714
+ detectedLegacyFields: diagnostics?.detectedLegacyFields ?? [],
715
+ recommendedActions: diagnostics?.recommendedActions ?? [],
716
+ },
717
+ );
718
+ }
719
+
720
+ function buildDoctorFixCommand(args = {}) {
721
+ const parts = [
722
+ "npm run wecom:doctor --",
723
+ "--config",
724
+ JSON.stringify(path.resolve(expandHome(args.configPath || "~/.openclaw/openclaw.json"))),
725
+ ];
726
+ if (args.allAccounts) {
727
+ parts.push("--all-accounts");
728
+ } else {
729
+ parts.push("--account", normalizeAccountId(args.account || "default"));
730
+ }
731
+ if (args.skipNetwork) parts.push("--skip-network");
732
+ if (args.skipLocalWebhook) parts.push("--skip-local-webhook");
733
+ if (args.skipAgentE2E) parts.push("--skip-agent-e2e");
734
+ if (args.skipBotE2E) parts.push("--skip-bot-e2e");
735
+ if (args.skipLongconn) parts.push("--skip-longconn");
736
+ if (args.skipCallbackMatrix) parts.push("--skip-callback-matrix");
737
+ if (args.agentUrl) parts.push("--agent-url", JSON.stringify(String(args.agentUrl)));
738
+ if (args.botUrl) parts.push("--bot-url", JSON.stringify(String(args.botUrl)));
739
+ if (args.agentLegacyUrl) parts.push("--agent-legacy-url", JSON.stringify(String(args.agentLegacyUrl)));
740
+ if (args.botLegacyUrl) parts.push("--bot-legacy-url", JSON.stringify(String(args.botLegacyUrl)));
741
+ if (args.timeoutMs) parts.push("--timeout-ms", String(args.timeoutMs));
742
+ parts.push("--fix", "--json");
743
+ return parts.join(" ");
744
+ }
745
+
746
+ function buildDoctorConfirmFixCommand(args = {}) {
747
+ return buildDoctorFixCommand(args).replace("--fix --json", "--confirm-fix --json");
748
+ }
749
+
750
+ async function maybeConfirmDoctorFix(args, fixPatch) {
751
+ if (args?.fix !== true) {
752
+ return {
753
+ ...args,
754
+ fixPrompted: false,
755
+ fixApproved: false,
756
+ };
757
+ }
758
+ if (!fixPatch) {
759
+ return {
760
+ ...args,
761
+ fixPrompted: false,
762
+ fixApproved: false,
763
+ };
764
+ }
765
+ const shouldPrompt =
766
+ args.confirmFix === true ||
767
+ (args.json !== true && process.stdin.isTTY === true);
768
+ if (!shouldPrompt) {
769
+ return {
770
+ ...args,
771
+ fixPrompted: false,
772
+ fixApproved: true,
773
+ };
774
+ }
775
+
776
+ const previewLines = collectPatchPreviewLines(fixPatch, "", []);
777
+ const rl = await createPrompt();
778
+ try {
779
+ rl.output.write("\nDoctor fix preview\n");
780
+ for (const line of previewLines) {
781
+ rl.output.write(` - ${line}\n`);
782
+ }
783
+ const approved = await askBoolean(
784
+ rl,
785
+ `Apply this doctor fix patch to ${path.resolve(expandHome(args.configPath))} now`,
786
+ false,
787
+ );
788
+ return {
789
+ ...args,
790
+ fixPrompted: true,
791
+ fixApproved: approved,
792
+ };
793
+ } finally {
794
+ rl.close();
795
+ }
796
+ }
797
+
798
+ async function maybeApplyDoctorFix(args, fixPatch, diagnostics) {
799
+ const configPath = path.resolve(expandHome(args?.configPath || "~/.openclaw/openclaw.json"));
800
+ if (args?.fix !== true) {
801
+ return {
802
+ requested: false,
803
+ applied: false,
804
+ configPath,
805
+ changedPaths: [],
806
+ };
807
+ }
808
+ if (!fixPatch) {
809
+ return {
810
+ requested: true,
811
+ prompted: args?.fixPrompted === true,
812
+ confirmed: false,
813
+ applied: false,
814
+ configPath,
815
+ changedPaths: [],
816
+ reason: "no local config patch available",
817
+ };
818
+ }
819
+ if (args?.fixApproved !== true) {
820
+ return {
821
+ requested: true,
822
+ prompted: args?.fixPrompted === true,
823
+ confirmed: false,
824
+ applied: false,
825
+ configPath,
826
+ changedPaths: [],
827
+ reason: args?.fixPrompted === true ? "user declined doctor fix patch" : "doctor fix patch not approved",
828
+ };
829
+ }
830
+ const result = await writeDoctorFixConfig(
831
+ args.configPath,
832
+ fixPatch,
833
+ diagnostics.detectedLegacyFields ?? [],
834
+ );
835
+ return {
836
+ requested: true,
837
+ prompted: args?.fixPrompted === true,
838
+ confirmed: true,
839
+ applied: true,
840
+ configPath: result.configPath,
841
+ backupPath: result.backupPath,
842
+ existed: result.existed,
843
+ changedPaths: result.changedPaths,
844
+ };
845
+ }
846
+
847
+ function buildSelfcheckSection(result, accountId) {
848
+ const accountReport = getAccountReportById(result?.parsed, accountId, ["accountId"]);
849
+ if (!accountReport) {
850
+ return makeSkippedSection(
851
+ "selfcheck",
852
+ "selfcheck",
853
+ "account not present in selfcheck report",
854
+ result?.command,
855
+ );
856
+ }
857
+ return buildSection(
858
+ "selfcheck",
859
+ "selfcheck",
860
+ accountReport?.summary?.ok === true,
861
+ summarizeChecks(accountReport?.summary),
862
+ accountReport?.checks?.filter((item) => item?.ok !== true).map((item) => item?.detail).join(" | ") ||
863
+ "selfcheck passed",
864
+ result?.command,
865
+ accountReport,
866
+ );
867
+ }
868
+
869
+ function buildAgentSection(result, accountId) {
870
+ if (!result?.parsed) {
871
+ return buildSection(
872
+ "agentE2E",
873
+ "agent-e2e",
874
+ false,
875
+ "agent selfcheck failed",
876
+ sanitizeResultOutput(result?.stdout, result?.stderr) || "agent selfcheck returned invalid JSON",
877
+ result?.command,
878
+ null,
879
+ );
880
+ }
881
+ const accountReport = getAccountReportById(result?.parsed, accountId, ["accountId"]);
882
+ if (!accountReport) {
883
+ return makeSkippedSection("agentE2E", "agent-e2e", "account has no Agent callback config", result?.command);
884
+ }
885
+ return buildSection(
886
+ "agentE2E",
887
+ "agent-e2e",
888
+ accountReport?.summary?.ok === true,
889
+ summarizeChecks(accountReport?.summary),
890
+ accountReport?.checks?.filter((item) => item?.ok !== true).map((item) => item?.detail).join(" | ") ||
891
+ "agent callback e2e passed",
892
+ result?.command,
893
+ accountReport,
894
+ );
895
+ }
896
+
897
+ function buildBotSection(result, accountId) {
898
+ if (!result?.parsed) {
899
+ return buildSection(
900
+ "botE2E",
901
+ "bot-e2e",
902
+ false,
903
+ "bot selfcheck failed",
904
+ sanitizeResultOutput(result?.stdout, result?.stderr) || "bot selfcheck returned invalid JSON",
905
+ result?.command,
906
+ null,
907
+ );
908
+ }
909
+ const accountReport = getAccountReportById(result?.parsed, accountId, ["account", "accountId"]);
910
+ if (!accountReport) {
911
+ return makeSkippedSection("botE2E", "bot-e2e", "account has no Bot callback config", result?.command);
912
+ }
913
+ return buildSection(
914
+ "botE2E",
915
+ "bot-e2e",
916
+ accountReport?.summary?.ok === true,
917
+ summarizeChecks(accountReport?.summary),
918
+ accountReport?.checks?.filter((item) => item?.ok !== true).map((item) => item?.detail).join(" | ") ||
919
+ "bot callback e2e passed",
920
+ result?.command,
921
+ accountReport,
922
+ );
923
+ }
924
+
925
+ function buildLongconnSection(result, command) {
926
+ const ok = String(result?.parsed?.diagnosis?.code ?? "") === "ok";
927
+ return buildSection(
928
+ "longConnection",
929
+ "long-connection",
930
+ ok,
931
+ String(result?.parsed?.diagnosis?.code ?? "unknown"),
932
+ pickFirstNonEmptyString(result?.parsed?.diagnosis?.summary, sanitizeResultOutput(result?.stdout, result?.stderr), "long connection probe finished"),
933
+ command,
934
+ result?.parsed ?? null,
935
+ );
936
+ }
937
+
938
+ function buildCallbackMatrixSection(result, command) {
939
+ const ok = result?.parsed?.summary?.ok === true;
940
+ return buildSection(
941
+ "callbackMatrix",
942
+ "callback-matrix",
943
+ ok,
944
+ summarizeChecks(result?.parsed?.summary),
945
+ result?.parsed?.entries?.filter((item) => item?.ok !== true).map((item) => item?.detail).join(" | ") ||
946
+ "callback matrix passed",
947
+ command,
948
+ result?.parsed ?? null,
949
+ );
950
+ }
951
+
952
+ function printTextReport(report) {
953
+ console.log("WeCom doctor");
954
+ console.log(`- config: ${report.configPath}`);
955
+ console.log(`- scope: ${report.args.allAccounts ? "all-accounts" : `single-account (${report.args.account})`}`);
956
+ console.log(`- status: ${report.summary.status}`);
957
+ console.log(`- installState: ${report.installState}`);
958
+ console.log(`- installSummary: ${report.installStateSummary}`);
959
+ console.log(`- migrationState: ${report.migrationState}`);
960
+ console.log(`- migrationSummary: ${report.migrationStateSummary}`);
961
+ console.log(`- migrationSource: ${report.migrationSource}`);
962
+ console.log(`- sourceSummary: ${report.migrationSourceSummary}`);
963
+ console.log(`- doctorCommand: ${report.commands.doctor}`);
964
+ console.log(`- migrateCommand: ${report.commands.migrate}`);
965
+ console.log(`- fixCommand: ${report.commands.fix}`);
966
+ console.log(`- confirmFixCommand: ${report.commands.confirmFix}`);
967
+
968
+ if (report.fix?.requested) {
969
+ console.log("- fix:");
970
+ console.log(` - applied: ${report.fix.applied ? "yes" : "no"}`);
971
+ if (report.fix.prompted === true) console.log(` - prompted: yes`);
972
+ if (report.fix.requested) console.log(` - confirmed: ${report.fix.confirmed === true ? "yes" : "no"}`);
973
+ if (report.fix.backupPath) console.log(` - backupPath: ${report.fix.backupPath}`);
974
+ if (Array.isArray(report.fix.changedPaths) && report.fix.changedPaths.length > 0) {
975
+ console.log(` - changedPaths: ${report.fix.changedPaths.join(", ")}`);
976
+ }
977
+ if (report.fix.reason) console.log(` - detail: ${report.fix.reason}`);
978
+ }
979
+
980
+ if (Array.isArray(report.migrationSourceSignals) && report.migrationSourceSignals.length > 0) {
981
+ console.log("- migrationSourceSignals:");
982
+ for (const item of report.migrationSourceSignals) {
983
+ console.log(` - [${item.source}] ${item.path}: ${item.detail}`);
984
+ }
985
+ }
986
+
987
+ if (Array.isArray(report.detectedLegacyFields) && report.detectedLegacyFields.length > 0) {
988
+ console.log("- detectedLegacyFields:");
989
+ for (const item of report.detectedLegacyFields) {
990
+ console.log(` - ${item.path}: ${item.detail}`);
991
+ }
992
+ }
993
+
994
+ for (const account of report.accounts) {
995
+ console.log(`\nAccount: ${account.accountId}`);
996
+ if (account.overview) {
997
+ console.log(
998
+ `- readiness: receive=${account.overview.canReceive ? "yes" : "no"} reply=${account.overview.canReply ? "yes" : "no"} send=${account.overview.canSend ? "yes" : "no"} doc=${account.overview.docEnabled ? "on" : "off"}`,
999
+ );
1000
+ }
1001
+ for (const section of Object.values(account.sections)) {
1002
+ const prefix = section.status === "ok" ? "OK " : section.status === "failed" ? "FAIL" : "SKIP";
1003
+ console.log(`${prefix} ${section.title} :: ${section.detail}`);
1004
+ }
1005
+ console.log(
1006
+ `Account summary: ${account.summary.passed}/${account.summary.total} passed, ${account.summary.skipped} skipped`,
1007
+ );
1008
+ }
1009
+
1010
+ if (Array.isArray(report.recommendedActions) && report.recommendedActions.length > 0) {
1011
+ console.log("\nRecommended actions:");
1012
+ for (const action of report.recommendedActions) {
1013
+ console.log(`- [${action.kind}] ${action.title}: ${action.detail}`);
1014
+ if (action.command) console.log(` command: ${action.command}`);
1015
+ }
1016
+ }
1017
+
1018
+ console.log(
1019
+ `\nSummary: ${report.summary.accountsPassed}/${report.summary.accountsTotal} accounts ready, ${report.summary.sectionsPassed}/${report.summary.sectionsTotal} sections passed, ${report.summary.sectionsSkipped} skipped`,
1020
+ );
1021
+ }
1022
+
1023
+ async function main() {
1024
+ const args = parseArgs(process.argv);
1025
+ const configPath = path.resolve(expandHome(args.configPath));
1026
+ let config = {};
1027
+
1028
+ try {
1029
+ const loaded = await loadConfig(configPath);
1030
+ config = loaded.config;
1031
+ } catch (err) {
1032
+ const report = {
1033
+ args,
1034
+ configPath,
1035
+ commands: {
1036
+ doctor: WECOM_DOCTOR_COMMAND,
1037
+ migrate: WECOM_QUICKSTART_MIGRATION_COMMAND,
1038
+ quickstart: WECOM_QUICKSTART_SETUP_COMMAND,
1039
+ wizard: WECOM_QUICKSTART_WIZARD_COMMAND,
1040
+ fix: buildDoctorFixCommand(args),
1041
+ confirmFix: buildDoctorConfirmFixCommand(args),
1042
+ },
1043
+ installState: "config_error",
1044
+ installStateSummary: `failed to load ${configPath}: ${String(err?.message || err)}`,
1045
+ migrationState: "config_error",
1046
+ migrationStateSummary: "unable to inspect migration state because config could not be parsed",
1047
+ migrationSource: "unknown",
1048
+ migrationSourceSummary: "unable to inspect migration source because config could not be parsed",
1049
+ migrationSourceSignals: [],
1050
+ detectedLegacyFields: [],
1051
+ fix: {
1052
+ requested: args.fix === true,
1053
+ applied: false,
1054
+ configPath,
1055
+ changedPaths: [],
1056
+ reason: "unable to inspect config before applying doctor fix",
1057
+ },
1058
+ migrationSection: buildSection(
1059
+ "migration",
1060
+ "migration",
1061
+ false,
1062
+ "config_error",
1063
+ `failed to load ${configPath}: ${String(err?.message || err)}`,
1064
+ WECOM_QUICKSTART_MIGRATION_COMMAND,
1065
+ null,
1066
+ ),
1067
+ accounts: [],
1068
+ recommendedActions: [
1069
+ {
1070
+ id: "fix-config-path",
1071
+ kind: "fill_config",
1072
+ title: "修复 OpenClaw 配置文件",
1073
+ detail: `先修复 ${configPath} 的路径或 JSON 语法,再重跑 doctor。`,
1074
+ command: WECOM_DOCTOR_COMMAND,
1075
+ },
1076
+ ],
1077
+ summary: {
1078
+ ok: false,
1079
+ status: "action_required",
1080
+ accountsTotal: 0,
1081
+ accountsPassed: 0,
1082
+ accountsFailed: 0,
1083
+ sectionsTotal: 1,
1084
+ sectionsPassed: 0,
1085
+ sectionsFailed: 1,
1086
+ sectionsSkipped: 0,
1087
+ },
1088
+ };
1089
+ if (args.json) {
1090
+ console.log(JSON.stringify(report, null, 2));
1091
+ } else {
1092
+ printTextReport(report);
1093
+ }
1094
+ process.exit(1);
1095
+ return;
1096
+ }
1097
+
1098
+ let diagnostics = collectWecomMigrationDiagnostics({
1099
+ config,
1100
+ accountId: args.account,
1101
+ });
1102
+ const fixPatch = buildDoctorFixPatch(config, diagnostics);
1103
+ const confirmedArgs = await maybeConfirmDoctorFix(args, fixPatch);
1104
+ const fixApply = await maybeApplyDoctorFix(confirmedArgs, fixPatch, diagnostics);
1105
+ if (fixApply.applied === true) {
1106
+ const reloaded = await loadConfig(configPath);
1107
+ config = reloaded.config;
1108
+ diagnostics = collectWecomMigrationDiagnostics({
1109
+ config,
1110
+ accountId: args.account,
1111
+ });
1112
+ }
1113
+ const migrationSection = buildMigrationSection(diagnostics);
1114
+
1115
+ const selfcheckArgs = [
1116
+ "--config",
1117
+ configPath,
1118
+ "--timeout-ms",
1119
+ String(args.timeoutMs),
1120
+ ...(args.allAccounts ? ["--all-accounts"] : ["--account", normalizeAccountId(args.account)]),
1121
+ ...(args.skipNetwork ? ["--skip-network"] : []),
1122
+ ...(args.skipLocalWebhook ? ["--skip-local-webhook"] : []),
1123
+ "--json",
1124
+ ];
1125
+ const selfcheckCommand = buildAccountCommand(
1126
+ "npm run wecom:selfcheck --",
1127
+ selfcheckArgs,
1128
+ );
1129
+ const selfcheckResult = await runJsonScript(SCRIPT_PATHS.selfcheck, selfcheckArgs, selfcheckCommand);
1130
+
1131
+ const discoveredAccountIds = unique([
1132
+ ...(args.allAccounts ? discoverConfiguredAccountIds(config) : [normalizeAccountId(args.account)]),
1133
+ ...((selfcheckResult?.parsed?.accounts ?? []).map((item) => item?.accountId)),
1134
+ ]);
1135
+ const targetAccountIds = discoveredAccountIds.length > 0 ? discoveredAccountIds : [normalizeAccountId(args.account)];
1136
+
1137
+ const agentSelfcheckArgs = [
1138
+ "--config",
1139
+ configPath,
1140
+ "--timeout-ms",
1141
+ String(args.timeoutMs),
1142
+ ...(args.allAccounts ? ["--all-accounts"] : ["--account", normalizeAccountId(args.account)]),
1143
+ "--json",
1144
+ ];
1145
+ const agentSelfcheckCommand = buildAccountCommand("npm run wecom:agent:selfcheck --", agentSelfcheckArgs);
1146
+ const runAgentChecks = args.skipNetwork !== true && args.skipAgentE2E !== true;
1147
+ const agentResult = runAgentChecks
1148
+ ? await runJsonScript(SCRIPT_PATHS.agentSelfcheck, agentSelfcheckArgs, agentSelfcheckCommand)
1149
+ : null;
1150
+
1151
+ const botSelfcheckArgs = [
1152
+ "--config",
1153
+ configPath,
1154
+ "--timeout-ms",
1155
+ String(args.timeoutMs),
1156
+ ...(args.allAccounts ? ["--all-accounts"] : ["--account", normalizeAccountId(args.account)]),
1157
+ "--json",
1158
+ ];
1159
+ const botSelfcheckCommand = buildAccountCommand("npm run wecom:bot:selfcheck --", botSelfcheckArgs);
1160
+ const runBotChecks = args.skipNetwork !== true && args.skipBotE2E !== true;
1161
+ const botResult = runBotChecks
1162
+ ? await runJsonScript(SCRIPT_PATHS.botSelfcheck, botSelfcheckArgs, botSelfcheckCommand)
1163
+ : null;
1164
+
1165
+ const accounts = [];
1166
+
1167
+ for (const accountId of targetAccountIds) {
1168
+ const normalizedId = normalizeAccountId(accountId);
1169
+ const selfSection = selfcheckResult?.parsed
1170
+ ? buildSelfcheckSection(selfcheckResult, normalizedId)
1171
+ : buildSection(
1172
+ "selfcheck",
1173
+ "selfcheck",
1174
+ false,
1175
+ "selfcheck failed",
1176
+ sanitizeResultOutput(selfcheckResult?.stdout, selfcheckResult?.stderr) || "selfcheck returned invalid JSON",
1177
+ selfcheckCommand,
1178
+ null,
1179
+ );
1180
+ const selfReport = getAccountReportById(selfcheckResult?.parsed, normalizedId, ["accountId"]);
1181
+ const overview = selfReport?.overview ?? null;
1182
+ const resolved = selfReport?.resolved ?? null;
1183
+ const botConfig = resolveBotConfig(config, normalizedId);
1184
+
1185
+ let agentSection = makeSkippedSection("agentE2E", "agent-e2e", "skipped by --skip-network", agentSelfcheckCommand);
1186
+ if (args.skipNetwork !== true && args.skipAgentE2E === true) {
1187
+ agentSection = makeSkippedSection("agentE2E", "agent-e2e", "skipped by --skip-agent-e2e", agentSelfcheckCommand);
1188
+ } else if (runAgentChecks) {
1189
+ agentSection = buildAgentSection(agentResult, normalizedId);
1190
+ }
1191
+
1192
+ let botSection = makeSkippedSection("botE2E", "bot-e2e", "skipped by --skip-network", botSelfcheckCommand);
1193
+ if (args.skipNetwork !== true && args.skipBotE2E === true) {
1194
+ botSection = makeSkippedSection("botE2E", "bot-e2e", "skipped by --skip-bot-e2e", botSelfcheckCommand);
1195
+ } else if (runBotChecks) {
1196
+ botSection = buildBotSection(botResult, normalizedId);
1197
+ }
1198
+
1199
+ let longconnSection = makeSkippedSection(
1200
+ "longConnection",
1201
+ "long-connection",
1202
+ "skipped by --skip-network",
1203
+ "npm run wecom:bot:longconn:probe -- --json",
1204
+ );
1205
+ if (args.skipNetwork !== true && args.skipLongconn === true) {
1206
+ longconnSection = makeSkippedSection(
1207
+ "longConnection",
1208
+ "long-connection",
1209
+ "skipped by --skip-longconn",
1210
+ "npm run wecom:bot:longconn:probe -- --json",
1211
+ );
1212
+ } else if (args.skipNetwork !== true) {
1213
+ if (!botConfig.longConnectionEnabled) {
1214
+ longconnSection = makeSkippedSection(
1215
+ "longConnection",
1216
+ "long-connection",
1217
+ "account has no Bot long connection credentials",
1218
+ "npm run wecom:bot:longconn:probe -- --json",
1219
+ );
1220
+ } else {
1221
+ const longconnArgs = [
1222
+ "--config",
1223
+ configPath,
1224
+ "--bot-id",
1225
+ botConfig.longConnection.botId,
1226
+ "--secret",
1227
+ botConfig.longConnection.secret,
1228
+ "--timeout-ms",
1229
+ String(args.timeoutMs),
1230
+ ...(botConfig.longConnection.url ? ["--url", botConfig.longConnection.url] : []),
1231
+ ...(botConfig.proxyUrl ? ["--proxy-url", botConfig.proxyUrl] : []),
1232
+ "--json",
1233
+ ];
1234
+ const longconnCommand = buildAccountCommand(
1235
+ "npm run wecom:bot:longconn:probe --",
1236
+ longconnArgs,
1237
+ { redact: ["--secret"] },
1238
+ );
1239
+ // Keep each account probe explicit so multi-account output stays attributable.
1240
+ // eslint-disable-next-line no-await-in-loop
1241
+ const longconnResult = await runJsonScript(SCRIPT_PATHS.botLongconnProbe, longconnArgs, longconnCommand);
1242
+ longconnSection = buildLongconnSection(longconnResult, longconnCommand);
1243
+ }
1244
+ }
1245
+
1246
+ let callbackSection = makeSkippedSection(
1247
+ "callbackMatrix",
1248
+ "callback-matrix",
1249
+ "skipped by --skip-network",
1250
+ "npm run wecom:callback:matrix -- --json",
1251
+ );
1252
+ if (args.skipNetwork !== true && args.skipCallbackMatrix === true) {
1253
+ callbackSection = makeSkippedSection(
1254
+ "callbackMatrix",
1255
+ "callback-matrix",
1256
+ "skipped by --skip-callback-matrix",
1257
+ "npm run wecom:callback:matrix -- --json",
1258
+ );
1259
+ } else if (args.skipNetwork !== true) {
1260
+ const agentReport = getAccountReportById(agentResult?.parsed, normalizedId, ["accountId"]);
1261
+ const botReport = getAccountReportById(botResult?.parsed, normalizedId, ["account", "accountId"]);
1262
+ const agentUrl = pickFirstNonEmptyString(args.agentUrl, agentReport?.endpoint);
1263
+ const botUrl = pickFirstNonEmptyString(args.botUrl, botReport?.endpoint);
1264
+ if (!agentUrl && !botUrl) {
1265
+ callbackSection = makeSkippedSection(
1266
+ "callbackMatrix",
1267
+ "callback-matrix",
1268
+ "no public callback URL available for this account",
1269
+ "npm run wecom:callback:matrix -- --json",
1270
+ );
1271
+ } else {
1272
+ const callbackArgs = [
1273
+ ...(agentUrl ? ["--agent-url", agentUrl] : []),
1274
+ ...(botUrl ? ["--bot-url", botUrl] : []),
1275
+ ...(args.agentLegacyUrl ? ["--agent-legacy-url", args.agentLegacyUrl] : []),
1276
+ ...(args.botLegacyUrl ? ["--bot-legacy-url", args.botLegacyUrl] : []),
1277
+ "--timeout-ms",
1278
+ String(args.timeoutMs),
1279
+ "--json",
1280
+ ];
1281
+ const callbackCommand = buildAccountCommand("npm run wecom:callback:matrix --", callbackArgs);
1282
+ // Callback URLs can differ per account, so run per-account.
1283
+ // eslint-disable-next-line no-await-in-loop
1284
+ const callbackResult = await runJsonScript(SCRIPT_PATHS.callbackMatrix, callbackArgs, callbackCommand);
1285
+ callbackSection = buildCallbackMatrixSection(callbackResult, callbackCommand);
1286
+ }
1287
+ }
1288
+
1289
+ const sections = {
1290
+ selfcheck: selfSection,
1291
+ agentE2E: agentSection,
1292
+ botE2E: botSection,
1293
+ longConnection: longconnSection,
1294
+ callbackMatrix: callbackSection,
1295
+ };
1296
+ const summary = summarizeAccountSections(sections);
1297
+ const commands = {
1298
+ selfcheck: selfcheckCommand,
1299
+ agentE2E: agentSelfcheckCommand,
1300
+ botE2E: botSelfcheckCommand,
1301
+ longConnection: longconnSection.command,
1302
+ callbackMatrix: callbackSection.command,
1303
+ };
1304
+ const recommendedActions = unique(
1305
+ Object.values(sections)
1306
+ .map((section) => makeSectionAction(normalizedId, section))
1307
+ .filter(Boolean)
1308
+ .map((item) => `${item.id}`),
1309
+ ).map((id) =>
1310
+ Object.values(sections)
1311
+ .map((section) => makeSectionAction(normalizedId, section))
1312
+ .filter(Boolean)
1313
+ .find((item) => item.id === id),
1314
+ );
1315
+
1316
+ accounts.push({
1317
+ accountId: normalizedId,
1318
+ resolved,
1319
+ overview,
1320
+ commands,
1321
+ sections,
1322
+ summary,
1323
+ recommendedActions,
1324
+ });
1325
+ }
1326
+
1327
+ const recommendedActions = [];
1328
+ const seenActionIds = new Set();
1329
+ const pushAction = (action) => {
1330
+ const id = String(action?.id ?? "").trim();
1331
+ if (!id || seenActionIds.has(id)) return;
1332
+ seenActionIds.add(id);
1333
+ recommendedActions.push(action);
1334
+ };
1335
+
1336
+ for (const action of diagnostics?.recommendedActions ?? []) pushAction(action);
1337
+ if (diagnostics?.configPatch && args.fix !== true) {
1338
+ pushAction({
1339
+ id: "apply-doctor-fix",
1340
+ kind: "apply_patch",
1341
+ title: "应用 doctor 本地修复 patch",
1342
+ detail: "先应用当前可落盘的 migration patch,再重跑 doctor/selfcheck。",
1343
+ command: buildDoctorFixCommand(args),
1344
+ recommended: true,
1345
+ blocking: false,
1346
+ });
1347
+ }
1348
+ if (accounts.length === 0) {
1349
+ pushAction({
1350
+ id: "run-wecom-quickstart",
1351
+ kind: "write_patch",
1352
+ title: "生成 WeCom starter config",
1353
+ detail: "当前没有可诊断的 WeCom 账号,先运行 quickstart 或 wizard 生成配置。",
1354
+ command: WECOM_QUICKSTART_WIZARD_COMMAND,
1355
+ });
1356
+ }
1357
+ for (const account of accounts) {
1358
+ for (const action of account.recommendedActions) pushAction(action);
1359
+ }
1360
+
1361
+ const report = {
1362
+ args,
1363
+ configPath,
1364
+ commands: {
1365
+ doctor: WECOM_DOCTOR_COMMAND,
1366
+ migrate: WECOM_QUICKSTART_MIGRATION_COMMAND,
1367
+ quickstart: WECOM_QUICKSTART_SETUP_COMMAND,
1368
+ wizard: WECOM_QUICKSTART_WIZARD_COMMAND,
1369
+ fix: buildDoctorFixCommand(args),
1370
+ confirmFix: buildDoctorConfirmFixCommand(args),
1371
+ selfcheck: selfcheckCommand,
1372
+ agentE2E: agentSelfcheckCommand,
1373
+ botE2E: botSelfcheckCommand,
1374
+ },
1375
+ installState: diagnostics.installState,
1376
+ installStateSummary: diagnostics.installStateSummary,
1377
+ migrationState: diagnostics.migrationState,
1378
+ migrationStateSummary: diagnostics.migrationStateSummary,
1379
+ migrationSource: diagnostics.migrationSource,
1380
+ migrationSourceSummary: diagnostics.migrationSourceSummary,
1381
+ migrationSourceSignals: diagnostics.migrationSourceSignals ?? [],
1382
+ fix: fixApply,
1383
+ installedVersion: diagnostics.installedVersion,
1384
+ expectedVersion: diagnostics.expectedVersion,
1385
+ stalePackage: diagnostics.stalePackage,
1386
+ detectedLegacyFields: diagnostics.detectedLegacyFields ?? [],
1387
+ migrationSection,
1388
+ accounts,
1389
+ recommendedActions,
1390
+ };
1391
+ report.summary = buildTopLevelSummary({
1392
+ migrationSection,
1393
+ accounts,
1394
+ });
1395
+
1396
+ if (args.json) {
1397
+ console.log(JSON.stringify(report, null, 2));
1398
+ } else {
1399
+ printTextReport(report);
1400
+ }
1401
+ process.exit(report.summary.ok ? 0 : 1);
1402
+ }
1403
+
1404
+ main().catch((err) => {
1405
+ console.error(`WeCom doctor failed: ${String(err?.message || err)}`);
1406
+ process.exit(1);
1407
+ });