@dingxiang-me/openclaw-wechat 2.0.1 → 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.
- package/CHANGELOG.md +85 -0
- package/README.en.md +204 -32
- package/README.md +234 -63
- package/docs/channels/wecom.md +137 -1
- package/openclaw.plugin.json +694 -10
- package/package.json +207 -4
- package/scripts/wecom-agent-selfcheck.mjs +775 -0
- package/scripts/wecom-bot-longconn-probe.mjs +582 -0
- package/scripts/wecom-bot-selfcheck.mjs +952 -0
- package/scripts/wecom-callback-matrix.mjs +224 -0
- package/scripts/wecom-doctor.mjs +1407 -0
- package/scripts/wecom-e2e-scenario.mjs +333 -0
- package/scripts/wecom-migrate.mjs +261 -0
- package/scripts/wecom-quickstart.mjs +1824 -0
- package/scripts/wecom-release-check.mjs +232 -0
- package/scripts/wecom-remote-e2e.mjs +310 -0
- package/scripts/wecom-selfcheck.mjs +1255 -0
- package/scripts/wecom-smoke.sh +74 -0
- package/src/core/delivery-router.js +21 -0
- package/src/core.js +631 -34
- package/src/wecom/account-config-core.js +27 -1
- package/src/wecom/account-config.js +19 -2
- package/src/wecom/agent-dispatch-executor.js +11 -0
- package/src/wecom/agent-dispatch-handlers.js +61 -8
- package/src/wecom/agent-inbound-guards.js +63 -16
- package/src/wecom/agent-inbound-processor.js +34 -2
- package/src/wecom/agent-late-reply-runtime.js +30 -2
- package/src/wecom/agent-text-sender.js +2 -0
- package/src/wecom/api-client-core.js +27 -19
- package/src/wecom/api-client-media.js +16 -7
- package/src/wecom/api-client-send-text.js +4 -0
- package/src/wecom/api-client-send-typed.js +4 -1
- package/src/wecom/api-client-senders.js +41 -3
- package/src/wecom/api-client.js +1 -0
- package/src/wecom/bot-dispatch-fallback.js +18 -3
- package/src/wecom/bot-dispatch-handlers.js +47 -10
- package/src/wecom/bot-inbound-dispatch-runtime.js +3 -0
- package/src/wecom/bot-inbound-executor-helpers.js +11 -1
- package/src/wecom/bot-inbound-executor.js +25 -1
- package/src/wecom/bot-inbound-guards.js +78 -23
- package/src/wecom/bot-long-connection-manager.js +4 -4
- package/src/wecom/channel-config-schema.js +132 -0
- package/src/wecom/channel-plugin.js +370 -7
- package/src/wecom/command-handlers.js +107 -10
- package/src/wecom/command-status-text.js +275 -1
- package/src/wecom/doc-client.js +7 -1
- package/src/wecom/inbound-content-handler-file-video-link.js +4 -0
- package/src/wecom/inbound-content-handler-image-voice.js +6 -0
- package/src/wecom/inbound-content.js +5 -0
- package/src/wecom/installer-api.js +910 -0
- package/src/wecom/media-download.js +2 -2
- package/src/wecom/migration-diagnostics.js +816 -0
- package/src/wecom/network-config.js +91 -0
- package/src/wecom/observability-metrics.js +9 -3
- package/src/wecom/outbound-agent-delivery.js +313 -0
- package/src/wecom/outbound-agent-media-sender.js +37 -7
- package/src/wecom/outbound-agent-push.js +1 -0
- package/src/wecom/outbound-delivery.js +129 -12
- package/src/wecom/outbound-stream-msg-item.js +25 -2
- package/src/wecom/outbound-webhook-delivery.js +19 -0
- package/src/wecom/outbound-webhook-media.js +30 -6
- package/src/wecom/pairing.js +188 -0
- package/src/wecom/pending-reply-manager.js +143 -0
- package/src/wecom/plugin-account-policy-services.js +26 -0
- package/src/wecom/plugin-base-services.js +58 -0
- package/src/wecom/plugin-constants.js +1 -1
- package/src/wecom/plugin-delivery-inbound-services.js +25 -0
- package/src/wecom/plugin-processing-deps.js +7 -0
- package/src/wecom/plugin-route-runtime-deps.js +1 -0
- package/src/wecom/plugin-services.js +87 -0
- package/src/wecom/policy-resolvers.js +93 -20
- package/src/wecom/quickstart-metadata.js +1247 -0
- package/src/wecom/reasoning-visibility.js +104 -0
- package/src/wecom/register-runtime.js +10 -0
- package/src/wecom/reliable-delivery-persistence.js +138 -0
- package/src/wecom/reliable-delivery.js +642 -0
- package/src/wecom/reply-output-policy.js +171 -0
- package/src/wecom/text-inbound-scheduler.js +6 -1
- package/src/wecom/workspace-auto-sender.js +2 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { resolveWecomGroupChatConfig } from "../src/core.js";
|
|
8
|
+
import { normalizeWecomBotGroupChatPolicy } from "../src/wecom/bot-inbound-executor-helpers.js";
|
|
9
|
+
import { PLUGIN_VERSION } from "../src/wecom/plugin-constants.js";
|
|
10
|
+
import { diagnoseWecomCallbackHealth } from "../src/wecom/callback-health-diagnostics.js";
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const out = {
|
|
14
|
+
account: "default",
|
|
15
|
+
allAccounts: false,
|
|
16
|
+
configPath: process.env.OPENCLAW_CONFIG_PATH || "~/.openclaw/openclaw.json",
|
|
17
|
+
url: "",
|
|
18
|
+
fromUser: "",
|
|
19
|
+
content: "/status",
|
|
20
|
+
timeoutMs: 8000,
|
|
21
|
+
pollCount: 12,
|
|
22
|
+
pollIntervalMs: 700,
|
|
23
|
+
json: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
const next = argv[i + 1];
|
|
29
|
+
if (arg === "--account" && next) {
|
|
30
|
+
out.account = normalizeAccountId(next);
|
|
31
|
+
i += 1;
|
|
32
|
+
} else if (arg === "--all-accounts") {
|
|
33
|
+
out.allAccounts = true;
|
|
34
|
+
} else if (arg === "--config" && next) {
|
|
35
|
+
out.configPath = next;
|
|
36
|
+
i += 1;
|
|
37
|
+
} else if (arg === "--url" && next) {
|
|
38
|
+
out.url = next;
|
|
39
|
+
i += 1;
|
|
40
|
+
} else if (arg === "--from-user" && next) {
|
|
41
|
+
out.fromUser = next;
|
|
42
|
+
i += 1;
|
|
43
|
+
} else if (arg === "--content" && next) {
|
|
44
|
+
out.content = next;
|
|
45
|
+
i += 1;
|
|
46
|
+
} else if (arg === "--timeout-ms" && next) {
|
|
47
|
+
const n = Number(next);
|
|
48
|
+
if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
|
|
49
|
+
i += 1;
|
|
50
|
+
} else if (arg === "--poll-count" && next) {
|
|
51
|
+
const n = Number(next);
|
|
52
|
+
if (Number.isFinite(n) && n > 0) out.pollCount = Math.floor(n);
|
|
53
|
+
i += 1;
|
|
54
|
+
} else if (arg === "--poll-interval-ms" && next) {
|
|
55
|
+
const n = Number(next);
|
|
56
|
+
if (Number.isFinite(n) && n > 0) out.pollIntervalMs = Math.floor(n);
|
|
57
|
+
i += 1;
|
|
58
|
+
} else if (arg === "--json") {
|
|
59
|
+
out.json = true;
|
|
60
|
+
} else if (arg === "-h" || arg === "--help") {
|
|
61
|
+
printHelp();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
} else {
|
|
64
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printHelp() {
|
|
72
|
+
console.log(`OpenClaw-Wechat Bot selfcheck (E2E)
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
npm run wecom:bot:selfcheck -- [options]
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--account <id> Bot account id (default: default)
|
|
79
|
+
--all-accounts Run checks for all discovered Bot accounts
|
|
80
|
+
--config <path> OpenClaw config path (default: ~/.openclaw/openclaw.json)
|
|
81
|
+
--url <http-url> Override Bot callback URL
|
|
82
|
+
--from-user <userid> Simulated sender (default: auto-generated)
|
|
83
|
+
--content <text> Message text to send (default: /status)
|
|
84
|
+
--timeout-ms <ms> HTTP timeout (default: 8000)
|
|
85
|
+
--poll-count <n> stream-refresh poll attempts (default: 12)
|
|
86
|
+
--poll-interval-ms <ms> stream-refresh interval (default: 700)
|
|
87
|
+
--json Print JSON report
|
|
88
|
+
-h, --help Show this help
|
|
89
|
+
`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expandHome(p) {
|
|
93
|
+
if (!p) return p;
|
|
94
|
+
if (p === "~") return os.homedir();
|
|
95
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeAccountId(accountId) {
|
|
100
|
+
const normalized = String(accountId ?? "default").trim().toLowerCase();
|
|
101
|
+
return normalized || "default";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildDefaultBotWebhookPath(accountId) {
|
|
105
|
+
const normalized = normalizeAccountId(accountId);
|
|
106
|
+
if (normalized === "default") return "/wecom/bot/callback";
|
|
107
|
+
return `/wecom/${normalized}/bot/callback`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeWebhookPath(raw, fallback = "/wecom/bot/callback") {
|
|
111
|
+
const input = String(raw ?? "").trim();
|
|
112
|
+
if (!input) return fallback;
|
|
113
|
+
return input.startsWith("/") ? input : `/${input}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pickFirstNonEmptyString(...values) {
|
|
117
|
+
for (const value of values) {
|
|
118
|
+
const text = String(value ?? "").trim();
|
|
119
|
+
if (text) return text;
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseSemverLike(version) {
|
|
125
|
+
const normalized = String(version ?? "").trim();
|
|
126
|
+
if (!normalized) return null;
|
|
127
|
+
const matched = normalized.match(/^v?(\d+)\.(\d+)\.(\d+)/);
|
|
128
|
+
if (!matched) return null;
|
|
129
|
+
return matched.slice(1).map((value) => Number.parseInt(value, 10));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function compareSemverLike(left, right) {
|
|
133
|
+
const a = parseSemverLike(left);
|
|
134
|
+
const b = parseSemverLike(right);
|
|
135
|
+
if (!a || !b) return null;
|
|
136
|
+
for (let index = 0; index < 3; index += 1) {
|
|
137
|
+
if (a[index] === b[index]) continue;
|
|
138
|
+
return a[index] > b[index] ? 1 : -1;
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatGroupSourceLabel(source = "") {
|
|
144
|
+
const normalized = String(source ?? "").trim();
|
|
145
|
+
if (!normalized) return "none";
|
|
146
|
+
if (normalized === "default") return "default";
|
|
147
|
+
if (normalized === "inferred") return "inferred";
|
|
148
|
+
if (normalized.startsWith("env.")) return "env";
|
|
149
|
+
if (normalized.startsWith("account.group")) return "account-group";
|
|
150
|
+
if (normalized.startsWith("account.groupChat")) return "account-group-default";
|
|
151
|
+
if (normalized.startsWith("account.root")) return "account-root";
|
|
152
|
+
if (normalized.startsWith("channel.group")) return "channel-group";
|
|
153
|
+
if (normalized.startsWith("channel.groupChat")) return "channel-group-default";
|
|
154
|
+
if (normalized.startsWith("channel.root")) return "channel-root";
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseBooleanLike(value, fallback = false) {
|
|
159
|
+
if (typeof value === "boolean") return value;
|
|
160
|
+
const normalized = String(value ?? "")
|
|
161
|
+
.trim()
|
|
162
|
+
.toLowerCase();
|
|
163
|
+
if (!normalized) return fallback;
|
|
164
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
165
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
166
|
+
return fallback;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function asNumber(value, fallback = null) {
|
|
170
|
+
const n = Number(value);
|
|
171
|
+
return Number.isFinite(n) ? n : fallback;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function decodeAesKey(aesKey) {
|
|
175
|
+
const keyBase64 = aesKey.endsWith("=") ? aesKey : `${aesKey}=`;
|
|
176
|
+
const key = Buffer.from(keyBase64, "base64");
|
|
177
|
+
if (key.length !== 32) {
|
|
178
|
+
throw new Error(`invalid encodingAesKey length: decoded ${key.length} bytes, expected 32`);
|
|
179
|
+
}
|
|
180
|
+
return key;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function pkcs7Pad(buf, blockSize = 32) {
|
|
184
|
+
const amountToPad = blockSize - (buf.length % blockSize || blockSize);
|
|
185
|
+
const pad = Buffer.alloc(amountToPad === 0 ? blockSize : amountToPad, amountToPad === 0 ? blockSize : amountToPad);
|
|
186
|
+
return Buffer.concat([buf, pad]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function pkcs7Unpad(buf) {
|
|
190
|
+
const pad = buf[buf.length - 1];
|
|
191
|
+
if (pad < 1 || pad > 32) return buf;
|
|
192
|
+
return buf.subarray(0, buf.length - pad);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function encryptWecom({ aesKey, plainText, corpId = "" }) {
|
|
196
|
+
const key = decodeAesKey(aesKey);
|
|
197
|
+
const iv = key.subarray(0, 16);
|
|
198
|
+
const random16 = crypto.randomBytes(16);
|
|
199
|
+
const msgBuffer = Buffer.from(String(plainText ?? ""), "utf8");
|
|
200
|
+
const lenBuffer = Buffer.alloc(4);
|
|
201
|
+
lenBuffer.writeUInt32BE(msgBuffer.length, 0);
|
|
202
|
+
const corpBuffer = Buffer.from(String(corpId ?? ""), "utf8");
|
|
203
|
+
const raw = Buffer.concat([random16, lenBuffer, msgBuffer, corpBuffer]);
|
|
204
|
+
const padded = pkcs7Pad(raw, 32);
|
|
205
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
206
|
+
cipher.setAutoPadding(false);
|
|
207
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
208
|
+
return encrypted.toString("base64");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function decryptWecom({ aesKey, cipherTextBase64 }) {
|
|
212
|
+
const key = decodeAesKey(aesKey);
|
|
213
|
+
const iv = key.subarray(0, 16);
|
|
214
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
215
|
+
decipher.setAutoPadding(false);
|
|
216
|
+
const plain = Buffer.concat([decipher.update(Buffer.from(cipherTextBase64, "base64")), decipher.final()]);
|
|
217
|
+
const unpadded = pkcs7Unpad(plain);
|
|
218
|
+
const msgLen = unpadded.readUInt32BE(16);
|
|
219
|
+
const msgStart = 20;
|
|
220
|
+
const msgEnd = msgStart + msgLen;
|
|
221
|
+
const msg = unpadded.subarray(msgStart, msgEnd).toString("utf8");
|
|
222
|
+
return msg;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function computeMsgSignature({ token, timestamp, nonce, encrypt }) {
|
|
226
|
+
const arr = [token, timestamp, nonce, encrypt].map(String).sort();
|
|
227
|
+
return crypto.createHash("sha1").update(arr.join("")).digest("hex");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
|
|
231
|
+
const timeout = Math.max(1000, Number(timeoutMs) || 8000);
|
|
232
|
+
const requestOptions = {
|
|
233
|
+
...options,
|
|
234
|
+
signal: AbortSignal.timeout(timeout),
|
|
235
|
+
};
|
|
236
|
+
return fetch(url, requestOptions);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function makeCheck(name, ok, detail, data = null) {
|
|
240
|
+
return { name, ok: Boolean(ok), detail: String(detail ?? ""), data };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function summarize(checks) {
|
|
244
|
+
const failed = checks.filter((c) => !c.ok).length;
|
|
245
|
+
return {
|
|
246
|
+
ok: failed === 0,
|
|
247
|
+
total: checks.length,
|
|
248
|
+
passed: checks.length - failed,
|
|
249
|
+
failed,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function summarizeAccountReports(accountReports = []) {
|
|
254
|
+
const checks = accountReports.flatMap((report) => (Array.isArray(report?.checks) ? report.checks : []));
|
|
255
|
+
const accountsFailed = accountReports.filter((report) => report?.summary?.ok !== true).length;
|
|
256
|
+
return {
|
|
257
|
+
...summarize(checks),
|
|
258
|
+
accountsTotal: accountReports.length,
|
|
259
|
+
accountsPassed: accountReports.length - accountsFailed,
|
|
260
|
+
accountsFailed,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildBotOverview({ config = {}, botConfig = {} } = {}) {
|
|
265
|
+
const bindingsCount = Array.isArray(config?.bindings) ? config.bindings.length : 0;
|
|
266
|
+
const dynamicAgentEnabled =
|
|
267
|
+
config?.channels?.wecom?.dynamicAgent?.enabled === true || config?.channels?.wecom?.dynamicAgents?.enabled === true;
|
|
268
|
+
const deliveryConfig = config?.channels?.wecom?.delivery ?? {};
|
|
269
|
+
const pendingReplyConfig = deliveryConfig?.pendingReply ?? {};
|
|
270
|
+
const reasoningConfig = deliveryConfig?.reasoning ?? {};
|
|
271
|
+
const pendingReplyEnabled = pendingReplyConfig?.enabled !== false;
|
|
272
|
+
const pendingReplyPersist = pendingReplyEnabled && pendingReplyConfig?.persist !== false;
|
|
273
|
+
const pendingReplyStoreFile = pickFirstNonEmptyString(pendingReplyConfig?.storeFile) || null;
|
|
274
|
+
const quotaTrackingEnabled = deliveryConfig?.quotaTracking?.enabled !== false;
|
|
275
|
+
const reasoningMode = (() => {
|
|
276
|
+
const explicit = pickFirstNonEmptyString(reasoningConfig?.mode).toLowerCase();
|
|
277
|
+
if (explicit === "append" || explicit === "hidden" || explicit === "separate") return explicit;
|
|
278
|
+
if (reasoningConfig?.includeInFinalAnswer === true) return "append";
|
|
279
|
+
if (reasoningConfig?.sendThinkingMessage === false) return "hidden";
|
|
280
|
+
return "separate";
|
|
281
|
+
})();
|
|
282
|
+
const longConnectionEnabled =
|
|
283
|
+
botConfig?.enabled === true &&
|
|
284
|
+
botConfig?.longConnection?.enabled === true &&
|
|
285
|
+
Boolean(String(botConfig?.longConnection?.botId ?? botConfig?.longConnection?.botid ?? "").trim()) &&
|
|
286
|
+
Boolean(String(botConfig?.longConnection?.secret ?? "").trim());
|
|
287
|
+
const webhookEnabled =
|
|
288
|
+
botConfig?.enabled === true &&
|
|
289
|
+
Boolean(String(botConfig?.token ?? "").trim()) &&
|
|
290
|
+
Boolean(String(botConfig?.encodingAesKey ?? "").trim()) &&
|
|
291
|
+
Boolean(String(botConfig?.webhookPath ?? "").trim());
|
|
292
|
+
const canReceive = longConnectionEnabled || webhookEnabled;
|
|
293
|
+
const channel = config?.channels?.wecom ?? {};
|
|
294
|
+
const accountBlock =
|
|
295
|
+
botConfig?.accountId === "default"
|
|
296
|
+
? channel
|
|
297
|
+
: channel?.accounts && typeof channel.accounts === "object"
|
|
298
|
+
? channel.accounts[botConfig.accountId] ?? {}
|
|
299
|
+
: {};
|
|
300
|
+
const normalizedGroupPolicy = normalizeWecomBotGroupChatPolicy(
|
|
301
|
+
resolveWecomGroupChatConfig({
|
|
302
|
+
channelConfig: channel,
|
|
303
|
+
accountConfig: accountBlock,
|
|
304
|
+
envVars: config?.env?.vars ?? {},
|
|
305
|
+
processEnv: process.env,
|
|
306
|
+
accountId: botConfig?.accountId || "default",
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
const allowFrom = Array.isArray(normalizedGroupPolicy?.allowFrom) ? normalizedGroupPolicy.allowFrom : [];
|
|
310
|
+
return {
|
|
311
|
+
canReceive,
|
|
312
|
+
canReply: canReceive,
|
|
313
|
+
canSend: canReceive,
|
|
314
|
+
longConnectionEnabled,
|
|
315
|
+
webhookEnabled,
|
|
316
|
+
bindingsCount,
|
|
317
|
+
dynamicAgentEnabled,
|
|
318
|
+
pendingReplyEnabled,
|
|
319
|
+
pendingReplyPersist,
|
|
320
|
+
pendingReplyStoreFile,
|
|
321
|
+
quotaTrackingEnabled,
|
|
322
|
+
reasoningMode,
|
|
323
|
+
reasoningTitle: pickFirstNonEmptyString(reasoningConfig?.title, "思考过程"),
|
|
324
|
+
reasoningMaxChars: Math.max(64, asNumber(reasoningConfig?.maxChars, 1200) || 1200),
|
|
325
|
+
groupPolicy: {
|
|
326
|
+
mode: String(normalizedGroupPolicy?.policyMode ?? "open"),
|
|
327
|
+
trigger: String(normalizedGroupPolicy?.triggerMode ?? "mention"),
|
|
328
|
+
allowSummary:
|
|
329
|
+
normalizedGroupPolicy?.policyMode === "allowlist"
|
|
330
|
+
? String(allowFrom.length)
|
|
331
|
+
: allowFrom.length > 0 && !allowFrom.includes("*")
|
|
332
|
+
? `${allowFrom.length}(inactive)`
|
|
333
|
+
: "open",
|
|
334
|
+
groups: Number(normalizedGroupPolicy?.configuredGroupCount || 0),
|
|
335
|
+
source: formatGroupSourceLabel(normalizedGroupPolicy?.policySource),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function sleep(ms) {
|
|
341
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function discoverBotAccountIds(config) {
|
|
345
|
+
const accountIds = new Set(["default"]);
|
|
346
|
+
const channelConfig = config?.channels?.wecom;
|
|
347
|
+
const accountEntries = channelConfig?.accounts;
|
|
348
|
+
if (accountEntries && typeof accountEntries === "object") {
|
|
349
|
+
for (const accountId of Object.keys(accountEntries)) {
|
|
350
|
+
accountIds.add(normalizeAccountId(accountId));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const scopedBotRegex =
|
|
355
|
+
/^WECOM_([A-Z0-9]+)_BOT_(ENABLED|TOKEN|ENCODING_AES_KEY|WEBHOOK_PATH|PLACEHOLDER_TEXT|STREAM_EXPIRE_MS|REPLY_TIMEOUT_MS|LATE_REPLY_WATCH_MS|LATE_REPLY_POLL_MS)$/;
|
|
356
|
+
const collectFromEnv = (obj) => {
|
|
357
|
+
if (!obj || typeof obj !== "object") return;
|
|
358
|
+
for (const key of Object.keys(obj)) {
|
|
359
|
+
const match = String(key ?? "").match(scopedBotRegex);
|
|
360
|
+
if (!match) continue;
|
|
361
|
+
const accountId = normalizeAccountId(match[1]);
|
|
362
|
+
if (accountId) accountIds.add(accountId);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
collectFromEnv(config?.env?.vars);
|
|
366
|
+
collectFromEnv(process.env);
|
|
367
|
+
|
|
368
|
+
return Array.from(accountIds).sort((a, b) => {
|
|
369
|
+
if (a === "default" && b !== "default") return -1;
|
|
370
|
+
if (a !== "default" && b === "default") return 1;
|
|
371
|
+
return a.localeCompare(b);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function buildPluginChecks(config) {
|
|
376
|
+
const checks = [];
|
|
377
|
+
const plugins = config?.plugins ?? {};
|
|
378
|
+
const entry = plugins?.entries?.["openclaw-wechat"];
|
|
379
|
+
const allow = Array.isArray(plugins?.allow) ? plugins.allow.map((value) => String(value)) : null;
|
|
380
|
+
const allowConfigured = Array.isArray(allow);
|
|
381
|
+
const allowIncludesPlugin = allowConfigured && allow.includes("openclaw-wechat");
|
|
382
|
+
const installMeta = plugins?.installs?.["openclaw-wechat"] ?? {};
|
|
383
|
+
const installedVersion = pickFirstNonEmptyString(installMeta?.resolvedVersion, installMeta?.version);
|
|
384
|
+
const versionCompare = installedVersion ? compareSemverLike(installedVersion, PLUGIN_VERSION) : null;
|
|
385
|
+
|
|
386
|
+
checks.push(
|
|
387
|
+
makeCheck(
|
|
388
|
+
"plugins.enabled",
|
|
389
|
+
plugins.enabled !== false,
|
|
390
|
+
plugins.enabled === false ? "plugins.enabled=false" : "plugins enabled",
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
checks.push(
|
|
394
|
+
makeCheck(
|
|
395
|
+
"plugins.entry.openclaw-wechat",
|
|
396
|
+
entry?.enabled !== false,
|
|
397
|
+
entry?.enabled === false ? "plugins.entries.openclaw-wechat.enabled=false" : "entry enabled or inherited",
|
|
398
|
+
),
|
|
399
|
+
);
|
|
400
|
+
checks.push(
|
|
401
|
+
makeCheck(
|
|
402
|
+
"plugins.allow",
|
|
403
|
+
allowIncludesPlugin,
|
|
404
|
+
allowConfigured
|
|
405
|
+
? `allow includes openclaw-wechat=${allowIncludesPlugin}`
|
|
406
|
+
: "plugins.allow missing (should be explicit allowlist)",
|
|
407
|
+
allowConfigured ? { allow } : null,
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
checks.push(
|
|
411
|
+
makeCheck(
|
|
412
|
+
"plugins.install.openclaw-wechat.version",
|
|
413
|
+
!installedVersion || versionCompare == null || versionCompare >= 0,
|
|
414
|
+
installedVersion
|
|
415
|
+
? `installed=${installedVersion} expected>=${PLUGIN_VERSION}`
|
|
416
|
+
: "no install metadata (source-path load or legacy install)",
|
|
417
|
+
installedVersion ? { installedVersion, expectedVersion: PLUGIN_VERSION } : null,
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
return checks;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function resolveBotConfig(config) {
|
|
425
|
+
const accountId = normalizeAccountId(config?.accountId ?? "default");
|
|
426
|
+
const channel = config?.channels?.wecom ?? {};
|
|
427
|
+
const accountBlock =
|
|
428
|
+
accountId === "default"
|
|
429
|
+
? channel
|
|
430
|
+
: channel?.accounts && typeof channel.accounts === "object"
|
|
431
|
+
? channel.accounts[accountId] ?? {}
|
|
432
|
+
: {};
|
|
433
|
+
const bot =
|
|
434
|
+
accountId === "default"
|
|
435
|
+
? channel?.bot ?? {}
|
|
436
|
+
: accountBlock?.bot && typeof accountBlock.bot === "object"
|
|
437
|
+
? accountBlock.bot
|
|
438
|
+
: {};
|
|
439
|
+
const envVars = config?.env?.vars ?? {};
|
|
440
|
+
const accountEnvPrefix = accountId === "default" ? null : `WECOM_${accountId.toUpperCase()}_BOT_`;
|
|
441
|
+
const readBotEnv = (suffix) => {
|
|
442
|
+
const scopedKey = accountEnvPrefix ? `${accountEnvPrefix}${suffix}` : "";
|
|
443
|
+
return pickFirstNonEmptyString(
|
|
444
|
+
scopedKey ? envVars?.[scopedKey] : "",
|
|
445
|
+
scopedKey ? process.env[scopedKey] : "",
|
|
446
|
+
envVars?.[`WECOM_BOT_${suffix}`],
|
|
447
|
+
process.env[`WECOM_BOT_${suffix}`],
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
const token = pickFirstNonEmptyString(bot.token, bot.callbackToken, readBotEnv("TOKEN"));
|
|
451
|
+
const encodingAesKey = pickFirstNonEmptyString(bot.encodingAesKey, bot.callbackAesKey, readBotEnv("ENCODING_AES_KEY"));
|
|
452
|
+
const webhookPath = normalizeWebhookPath(
|
|
453
|
+
pickFirstNonEmptyString(bot.webhookPath, readBotEnv("WEBHOOK_PATH")),
|
|
454
|
+
buildDefaultBotWebhookPath(accountId),
|
|
455
|
+
);
|
|
456
|
+
const gatewayPort = asNumber(config?.gateway?.port, 8885);
|
|
457
|
+
const longConnection =
|
|
458
|
+
bot?.longConnection && typeof bot.longConnection === "object"
|
|
459
|
+
? bot.longConnection
|
|
460
|
+
: {};
|
|
461
|
+
const compatBotId = pickFirstNonEmptyString(accountBlock?.botId, accountBlock?.botid, channel?.botId, channel?.botid);
|
|
462
|
+
const compatSecret = pickFirstNonEmptyString(accountBlock?.secret, channel?.secret);
|
|
463
|
+
const compatLongConnectionEnabled =
|
|
464
|
+
Boolean(pickFirstNonEmptyString(longConnection?.botId, longConnection?.botid, compatBotId)) &&
|
|
465
|
+
Boolean(pickFirstNonEmptyString(longConnection?.secret, compatSecret));
|
|
466
|
+
const enabled = parseBooleanLike(
|
|
467
|
+
bot.enabled,
|
|
468
|
+
parseBooleanLike(readBotEnv("ENABLED"), compatLongConnectionEnabled),
|
|
469
|
+
);
|
|
470
|
+
return {
|
|
471
|
+
accountId,
|
|
472
|
+
enabled,
|
|
473
|
+
token,
|
|
474
|
+
encodingAesKey,
|
|
475
|
+
webhookPath,
|
|
476
|
+
gatewayPort: Math.max(1, gatewayPort || 8885),
|
|
477
|
+
longConnection: {
|
|
478
|
+
...longConnection,
|
|
479
|
+
enabled:
|
|
480
|
+
longConnection?.enabled === true ||
|
|
481
|
+
(Boolean(pickFirstNonEmptyString(longConnection?.botId, longConnection?.botid, compatBotId)) &&
|
|
482
|
+
Boolean(pickFirstNonEmptyString(longConnection?.secret, compatSecret))),
|
|
483
|
+
botId: pickFirstNonEmptyString(longConnection?.botId, longConnection?.botid, compatBotId),
|
|
484
|
+
secret: pickFirstNonEmptyString(longConnection?.secret, compatSecret),
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function buildCallbackUrl({ args, botConfig }) {
|
|
490
|
+
if (String(args.url ?? "").trim()) return String(args.url).trim();
|
|
491
|
+
return `http://127.0.0.1:${botConfig.gatewayPort}${botConfig.webhookPath}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildSignedEncryptedRequest({ endpoint, token, aesKey, payload }) {
|
|
495
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
496
|
+
const nonce = crypto.randomBytes(8).toString("hex");
|
|
497
|
+
const encrypt = encryptWecom({
|
|
498
|
+
aesKey,
|
|
499
|
+
plainText: JSON.stringify(payload ?? {}),
|
|
500
|
+
corpId: "",
|
|
501
|
+
});
|
|
502
|
+
const msgSignature = computeMsgSignature({
|
|
503
|
+
token,
|
|
504
|
+
timestamp,
|
|
505
|
+
nonce,
|
|
506
|
+
encrypt,
|
|
507
|
+
});
|
|
508
|
+
const url = new URL(endpoint);
|
|
509
|
+
url.searchParams.set("msg_signature", msgSignature);
|
|
510
|
+
url.searchParams.set("timestamp", timestamp);
|
|
511
|
+
url.searchParams.set("nonce", nonce);
|
|
512
|
+
return {
|
|
513
|
+
requestUrl: url.toString(),
|
|
514
|
+
body: JSON.stringify({ encrypt }),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function buildSignedVerifyRequest({ endpoint, token, aesKey, plainEchostr }) {
|
|
519
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
520
|
+
const nonce = crypto.randomBytes(8).toString("hex");
|
|
521
|
+
const encryptedEchostr = encryptWecom({
|
|
522
|
+
aesKey,
|
|
523
|
+
plainText: String(plainEchostr ?? ""),
|
|
524
|
+
corpId: "",
|
|
525
|
+
});
|
|
526
|
+
const msgSignature = computeMsgSignature({
|
|
527
|
+
token,
|
|
528
|
+
timestamp,
|
|
529
|
+
nonce,
|
|
530
|
+
encrypt: encryptedEchostr,
|
|
531
|
+
});
|
|
532
|
+
const url = new URL(endpoint);
|
|
533
|
+
url.searchParams.set("msg_signature", msgSignature);
|
|
534
|
+
url.searchParams.set("timestamp", timestamp);
|
|
535
|
+
url.searchParams.set("nonce", nonce);
|
|
536
|
+
url.searchParams.set("echostr", encryptedEchostr);
|
|
537
|
+
return {
|
|
538
|
+
requestUrl: url.toString(),
|
|
539
|
+
encryptedEchostr,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function parseEncryptedCallbackResponse(rawBody) {
|
|
544
|
+
const body = JSON.parse(rawBody);
|
|
545
|
+
const encrypt = String(body?.encrypt ?? "").trim();
|
|
546
|
+
const msgsignature = String(body?.msgsignature ?? body?.msg_signature ?? "").trim();
|
|
547
|
+
const timestamp = String(body?.timestamp ?? "").trim();
|
|
548
|
+
const nonce = String(body?.nonce ?? "").trim();
|
|
549
|
+
if (!encrypt || !msgsignature || !timestamp || !nonce) {
|
|
550
|
+
throw new Error("missing response encrypt/msgsignature/timestamp/nonce");
|
|
551
|
+
}
|
|
552
|
+
return { encrypt, msgsignature, timestamp, nonce };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function verifyEncryptedCallbackResponse({ token, aesKey, rawBody }) {
|
|
556
|
+
const parsed = parseEncryptedCallbackResponse(rawBody);
|
|
557
|
+
const expected = computeMsgSignature({
|
|
558
|
+
token,
|
|
559
|
+
timestamp: parsed.timestamp,
|
|
560
|
+
nonce: parsed.nonce,
|
|
561
|
+
encrypt: parsed.encrypt,
|
|
562
|
+
});
|
|
563
|
+
if (expected !== parsed.msgsignature) {
|
|
564
|
+
throw new Error("response signature mismatch");
|
|
565
|
+
}
|
|
566
|
+
const decryptedText = decryptWecom({
|
|
567
|
+
aesKey,
|
|
568
|
+
cipherTextBase64: parsed.encrypt,
|
|
569
|
+
});
|
|
570
|
+
const payload = JSON.parse(decryptedText);
|
|
571
|
+
return { payload, meta: parsed };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function postEncryptedPayload({ endpoint, token, aesKey, payload, timeoutMs }) {
|
|
575
|
+
const signed = buildSignedEncryptedRequest({
|
|
576
|
+
endpoint,
|
|
577
|
+
token,
|
|
578
|
+
aesKey,
|
|
579
|
+
payload,
|
|
580
|
+
});
|
|
581
|
+
const response = await fetchWithTimeout(
|
|
582
|
+
signed.requestUrl,
|
|
583
|
+
{
|
|
584
|
+
method: "POST",
|
|
585
|
+
headers: {
|
|
586
|
+
"Content-Type": "application/json",
|
|
587
|
+
},
|
|
588
|
+
body: signed.body,
|
|
589
|
+
},
|
|
590
|
+
timeoutMs,
|
|
591
|
+
);
|
|
592
|
+
const rawBody = await response.text();
|
|
593
|
+
return { response, rawBody };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function reportAndExit(report, asJson = false) {
|
|
597
|
+
if (asJson) {
|
|
598
|
+
console.log(JSON.stringify(report, null, 2));
|
|
599
|
+
process.exit(report.summary.ok ? 0 : 1);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const accountReports =
|
|
604
|
+
Array.isArray(report?.accounts) && report.accounts.length > 0 ? report.accounts : [report];
|
|
605
|
+
|
|
606
|
+
console.log("WeCom Bot E2E selfcheck");
|
|
607
|
+
console.log(`- config: ${report.configPath}`);
|
|
608
|
+
console.log(`- mode: ${report.args?.allAccounts ? "all-accounts" : `single-account (${report.args?.account || "default"})`}`);
|
|
609
|
+
for (const accountReport of accountReports) {
|
|
610
|
+
const overview = buildBotOverview({ config: report.config, botConfig: accountReport.config });
|
|
611
|
+
console.log(`\nAccount: ${accountReport.account}`);
|
|
612
|
+
console.log(`- endpoint: ${accountReport.endpoint}`);
|
|
613
|
+
console.log(`- fromUser: ${accountReport.fromUser}`);
|
|
614
|
+
console.log(`- content: ${accountReport.content}`);
|
|
615
|
+
console.log(
|
|
616
|
+
`- readiness: receive=${overview.canReceive ? "yes" : "no"} reply=${overview.canReply ? "yes" : "no"} send=${overview.canSend ? "yes" : "no"} longConnection=${overview.longConnectionEnabled ? "on" : "off"} webhook=${overview.webhookEnabled ? "on" : "off"}`,
|
|
617
|
+
);
|
|
618
|
+
console.log(
|
|
619
|
+
`- routing: bindings=${overview.bindingsCount} dynamicAgent=${overview.dynamicAgentEnabled ? "on" : "off"}`,
|
|
620
|
+
);
|
|
621
|
+
console.log(
|
|
622
|
+
`- reliable-delivery: pendingReply=${overview.pendingReplyEnabled ? "on" : "off"} persist=${overview.pendingReplyPersist ? "on" : "off"} quotaTracking=${overview.quotaTrackingEnabled ? "on" : "off"} store=${overview.pendingReplyPersist ? overview.pendingReplyStoreFile || "(auto)" : "(disabled)"}`,
|
|
623
|
+
);
|
|
624
|
+
console.log(
|
|
625
|
+
`- group-policy: mode=${overview.groupPolicy.mode} trigger=${overview.groupPolicy.trigger} allowFrom=${overview.groupPolicy.allowSummary} groups=${overview.groupPolicy.groups} source=${overview.groupPolicy.source}`,
|
|
626
|
+
);
|
|
627
|
+
console.log(`- reasoning: mode=${overview.reasoningMode} title=${overview.reasoningTitle} maxChars=${overview.reasoningMaxChars}`);
|
|
628
|
+
for (const check of accountReport.checks) {
|
|
629
|
+
console.log(`${check.ok ? "OK " : "FAIL"} ${check.name} :: ${check.detail}`);
|
|
630
|
+
}
|
|
631
|
+
console.log(`Account summary: ${accountReport.summary.passed}/${accountReport.summary.total} passed`);
|
|
632
|
+
}
|
|
633
|
+
if (report.summary?.accountsTotal != null) {
|
|
634
|
+
console.log(
|
|
635
|
+
`\nSummary: accounts ${report.summary.accountsPassed}/${report.summary.accountsTotal} passed, checks ${report.summary.passed}/${report.summary.total} passed`,
|
|
636
|
+
);
|
|
637
|
+
} else {
|
|
638
|
+
console.log(`\nSummary: ${report.summary.passed}/${report.summary.total} passed`);
|
|
639
|
+
}
|
|
640
|
+
process.exit(report.summary.ok ? 0 : 1);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function runBotE2E({ config, args, configPath, accountId }) {
|
|
644
|
+
const checks = [];
|
|
645
|
+
checks.push(...buildPluginChecks(config));
|
|
646
|
+
const botConfig = resolveBotConfig({
|
|
647
|
+
...config,
|
|
648
|
+
accountId,
|
|
649
|
+
});
|
|
650
|
+
const endpoint = buildCallbackUrl({ args, botConfig });
|
|
651
|
+
const fromUser =
|
|
652
|
+
String(args.fromUser ?? "").trim() ||
|
|
653
|
+
`DxBotSelfCheck${botConfig.accountId.replace(/[^a-z0-9]/gi, "").slice(0, 12)}${Date.now().toString(36).slice(-6)}`;
|
|
654
|
+
const content = String(args.content ?? "").trim() || "/status";
|
|
655
|
+
|
|
656
|
+
checks.push(
|
|
657
|
+
makeCheck(
|
|
658
|
+
"config.account",
|
|
659
|
+
true,
|
|
660
|
+
`account=${botConfig.accountId}`,
|
|
661
|
+
),
|
|
662
|
+
);
|
|
663
|
+
checks.push(makeCheck("config.bot.enabled", botConfig.enabled, botConfig.enabled ? "enabled" : "disabled"));
|
|
664
|
+
checks.push(makeCheck("config.bot.token", Boolean(botConfig.token), botConfig.token ? "present" : "missing"));
|
|
665
|
+
checks.push(
|
|
666
|
+
makeCheck(
|
|
667
|
+
"config.bot.encodingAesKey",
|
|
668
|
+
Boolean(botConfig.encodingAesKey),
|
|
669
|
+
botConfig.encodingAesKey ? "present" : "missing",
|
|
670
|
+
),
|
|
671
|
+
);
|
|
672
|
+
checks.push(makeCheck("config.bot.webhookPath", Boolean(botConfig.webhookPath), `path=${botConfig.webhookPath}`));
|
|
673
|
+
checks.push(
|
|
674
|
+
makeCheck(
|
|
675
|
+
"bot.entry.visibility",
|
|
676
|
+
true,
|
|
677
|
+
"Bot 模式在“微信插件入口”通常不会显示为联系人;建议通过机器人会话入口或群聊触发。",
|
|
678
|
+
),
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
let aesKeyValid = false;
|
|
682
|
+
if (botConfig.encodingAesKey) {
|
|
683
|
+
try {
|
|
684
|
+
decodeAesKey(botConfig.encodingAesKey);
|
|
685
|
+
aesKeyValid = true;
|
|
686
|
+
checks.push(makeCheck("config.bot.encodingAesKey.length", true, "decoded-bytes=32"));
|
|
687
|
+
} catch (err) {
|
|
688
|
+
checks.push(
|
|
689
|
+
makeCheck("config.bot.encodingAesKey.length", false, String(err?.message || err)),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
checks.push(makeCheck("config.bot.encodingAesKey.length", false, "missing"));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (!botConfig.enabled || !botConfig.token || !botConfig.encodingAesKey || !aesKeyValid) {
|
|
697
|
+
const report = {
|
|
698
|
+
configPath,
|
|
699
|
+
account: botConfig.accountId,
|
|
700
|
+
endpoint,
|
|
701
|
+
fromUser,
|
|
702
|
+
content,
|
|
703
|
+
config: botConfig,
|
|
704
|
+
checks,
|
|
705
|
+
summary: summarize(checks),
|
|
706
|
+
};
|
|
707
|
+
return report;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
const healthResponse = await fetchWithTimeout(
|
|
712
|
+
endpoint,
|
|
713
|
+
{ method: "GET", redirect: "manual" },
|
|
714
|
+
Math.min(args.timeoutMs, 4000),
|
|
715
|
+
);
|
|
716
|
+
const healthBody = await healthResponse.text();
|
|
717
|
+
const diagnosed = diagnoseWecomCallbackHealth({
|
|
718
|
+
status: healthResponse.status,
|
|
719
|
+
body: healthBody,
|
|
720
|
+
mode: "bot",
|
|
721
|
+
endpoint,
|
|
722
|
+
webhookPath: botConfig.webhookPath,
|
|
723
|
+
gatewayPort: botConfig.gatewayPort,
|
|
724
|
+
location: healthResponse.headers.get("location") || "",
|
|
725
|
+
});
|
|
726
|
+
checks.push(
|
|
727
|
+
makeCheck(
|
|
728
|
+
"local.webhook.health",
|
|
729
|
+
diagnosed.ok,
|
|
730
|
+
diagnosed.detail,
|
|
731
|
+
diagnosed.data,
|
|
732
|
+
),
|
|
733
|
+
);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
checks.push(makeCheck("local.webhook.health", false, `probe failed: ${String(err?.message || err)}`));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const verifyPlain = `bot_selfcheck_verify_${Date.now().toString(36)}`;
|
|
740
|
+
const signedVerify = buildSignedVerifyRequest({
|
|
741
|
+
endpoint,
|
|
742
|
+
token: botConfig.token,
|
|
743
|
+
aesKey: botConfig.encodingAesKey,
|
|
744
|
+
plainEchostr: verifyPlain,
|
|
745
|
+
});
|
|
746
|
+
const verifyResp = await fetchWithTimeout(
|
|
747
|
+
signedVerify.requestUrl,
|
|
748
|
+
{ method: "GET" },
|
|
749
|
+
Math.min(args.timeoutMs, 4000),
|
|
750
|
+
);
|
|
751
|
+
const verifyBody = await verifyResp.text();
|
|
752
|
+
const verifyOk = verifyResp.status === 200 && verifyBody === verifyPlain;
|
|
753
|
+
checks.push(
|
|
754
|
+
makeCheck(
|
|
755
|
+
"e2e.url.verify",
|
|
756
|
+
verifyOk,
|
|
757
|
+
verifyOk
|
|
758
|
+
? "callback verify succeeded"
|
|
759
|
+
: `status=${verifyResp.status} body=${String(verifyBody ?? "").slice(0, 120)}`,
|
|
760
|
+
),
|
|
761
|
+
);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
checks.push(makeCheck("e2e.url.verify", false, `request failed: ${String(err?.message || err)}`));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const messagePayload = {
|
|
767
|
+
msgid: `selfcheck_msg_${Date.now()}`,
|
|
768
|
+
msgtype: "text",
|
|
769
|
+
from: {
|
|
770
|
+
userid: fromUser,
|
|
771
|
+
},
|
|
772
|
+
chattype: "single",
|
|
773
|
+
text: {
|
|
774
|
+
content,
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
let streamId = "";
|
|
779
|
+
try {
|
|
780
|
+
const first = await postEncryptedPayload({
|
|
781
|
+
endpoint,
|
|
782
|
+
token: botConfig.token,
|
|
783
|
+
aesKey: botConfig.encodingAesKey,
|
|
784
|
+
payload: messagePayload,
|
|
785
|
+
timeoutMs: args.timeoutMs,
|
|
786
|
+
});
|
|
787
|
+
checks.push(makeCheck("e2e.message.post", first.response.status === 200, `status=${first.response.status}`));
|
|
788
|
+
if (first.response.status === 200) {
|
|
789
|
+
const verified = verifyEncryptedCallbackResponse({
|
|
790
|
+
token: botConfig.token,
|
|
791
|
+
aesKey: botConfig.encodingAesKey,
|
|
792
|
+
rawBody: first.rawBody,
|
|
793
|
+
});
|
|
794
|
+
const stream = verified.payload?.stream ?? {};
|
|
795
|
+
streamId = String(stream.id ?? "").trim();
|
|
796
|
+
const isStream = String(verified.payload?.msgtype ?? "").trim() === "stream";
|
|
797
|
+
checks.push(
|
|
798
|
+
makeCheck(
|
|
799
|
+
"e2e.message.response.stream",
|
|
800
|
+
isStream && Boolean(streamId),
|
|
801
|
+
`msgtype=${verified.payload?.msgtype ?? "n/a"} streamId=${streamId || "missing"} finish=${String(stream.finish ?? "n/a")}`,
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
} else {
|
|
805
|
+
checks.push(makeCheck("e2e.message.response.stream", false, `unexpected status=${first.response.status}`));
|
|
806
|
+
}
|
|
807
|
+
} catch (err) {
|
|
808
|
+
checks.push(makeCheck("e2e.message.post", false, `request failed: ${String(err?.message || err)}`));
|
|
809
|
+
checks.push(makeCheck("e2e.message.response.stream", false, "request failed"));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let finalPayload = null;
|
|
813
|
+
if (streamId) {
|
|
814
|
+
for (let i = 0; i < args.pollCount; i += 1) {
|
|
815
|
+
await sleep(args.pollIntervalMs);
|
|
816
|
+
try {
|
|
817
|
+
const refreshPayload = {
|
|
818
|
+
msgid: `selfcheck_refresh_${Date.now()}_${i}`,
|
|
819
|
+
msgtype: "stream",
|
|
820
|
+
stream: {
|
|
821
|
+
id: streamId,
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
const refreshResp = await postEncryptedPayload({
|
|
825
|
+
endpoint,
|
|
826
|
+
token: botConfig.token,
|
|
827
|
+
aesKey: botConfig.encodingAesKey,
|
|
828
|
+
payload: refreshPayload,
|
|
829
|
+
timeoutMs: args.timeoutMs,
|
|
830
|
+
});
|
|
831
|
+
if (refreshResp.response.status !== 200) continue;
|
|
832
|
+
const verified = verifyEncryptedCallbackResponse({
|
|
833
|
+
token: botConfig.token,
|
|
834
|
+
aesKey: botConfig.encodingAesKey,
|
|
835
|
+
rawBody: refreshResp.rawBody,
|
|
836
|
+
});
|
|
837
|
+
const stream = verified.payload?.stream ?? {};
|
|
838
|
+
if (String(stream.id ?? "").trim() !== streamId) continue;
|
|
839
|
+
if (stream.finish === true) {
|
|
840
|
+
finalPayload = verified.payload;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
} catch {
|
|
844
|
+
// Keep polling until budget is exhausted.
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (!streamId) {
|
|
850
|
+
checks.push(makeCheck("e2e.stream.refresh", false, "streamId missing"));
|
|
851
|
+
} else if (!finalPayload) {
|
|
852
|
+
checks.push(
|
|
853
|
+
makeCheck(
|
|
854
|
+
"e2e.stream.refresh",
|
|
855
|
+
false,
|
|
856
|
+
`did not observe finish=true within ${args.pollCount} polls`,
|
|
857
|
+
),
|
|
858
|
+
);
|
|
859
|
+
} else {
|
|
860
|
+
const contentText = String(finalPayload?.stream?.content ?? "");
|
|
861
|
+
const expectedSessionLower = `会话ID:wecom-bot:${fromUser.toLowerCase()}`;
|
|
862
|
+
const expectedSessionRaw = `会话ID:wecom-bot:${fromUser}`;
|
|
863
|
+
const sessionMatched = contentText.includes(expectedSessionLower) || contentText.includes(expectedSessionRaw);
|
|
864
|
+
checks.push(
|
|
865
|
+
makeCheck(
|
|
866
|
+
"e2e.stream.refresh",
|
|
867
|
+
true,
|
|
868
|
+
`finish=true contentBytes=${Buffer.byteLength(contentText, "utf8")}`,
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
checks.push(
|
|
872
|
+
makeCheck(
|
|
873
|
+
"e2e.stream.content",
|
|
874
|
+
sessionMatched,
|
|
875
|
+
sessionMatched
|
|
876
|
+
? `contains expected session marker (${expectedSessionRaw} / ${expectedSessionLower})`
|
|
877
|
+
: `missing expected session marker (${expectedSessionRaw} / ${expectedSessionLower})`,
|
|
878
|
+
),
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
configPath,
|
|
884
|
+
account: botConfig.accountId,
|
|
885
|
+
endpoint,
|
|
886
|
+
fromUser,
|
|
887
|
+
content,
|
|
888
|
+
config: botConfig,
|
|
889
|
+
overview: buildBotOverview({ config, botConfig }),
|
|
890
|
+
checks,
|
|
891
|
+
summary: summarize(checks),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function main() {
|
|
896
|
+
const args = parseArgs(process.argv);
|
|
897
|
+
const configPath = path.resolve(expandHome(args.configPath));
|
|
898
|
+
if (args.allAccounts && String(args.url ?? "").trim()) {
|
|
899
|
+
throw new Error("--url cannot be used with --all-accounts (each account has its own webhookPath)");
|
|
900
|
+
}
|
|
901
|
+
let config;
|
|
902
|
+
try {
|
|
903
|
+
const raw = await readFile(configPath, "utf8");
|
|
904
|
+
config = JSON.parse(raw);
|
|
905
|
+
} catch (err) {
|
|
906
|
+
const accountReport = {
|
|
907
|
+
configPath,
|
|
908
|
+
account: normalizeAccountId(args.account),
|
|
909
|
+
endpoint: "",
|
|
910
|
+
fromUser: args.fromUser || "",
|
|
911
|
+
content: args.content || "",
|
|
912
|
+
config: null,
|
|
913
|
+
checks: [
|
|
914
|
+
makeCheck("config.load", false, `failed to load ${configPath}: ${String(err?.message || err)}`),
|
|
915
|
+
],
|
|
916
|
+
};
|
|
917
|
+
accountReport.summary = summarize(accountReport.checks);
|
|
918
|
+
const report = {
|
|
919
|
+
args,
|
|
920
|
+
configPath,
|
|
921
|
+
accounts: [accountReport],
|
|
922
|
+
summary: summarizeAccountReports([accountReport]),
|
|
923
|
+
};
|
|
924
|
+
reportAndExit(report, args.json);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const targetAccounts = args.allAccounts ? discoverBotAccountIds(config) : [normalizeAccountId(args.account)];
|
|
929
|
+
const accountReports = [];
|
|
930
|
+
for (const accountId of targetAccounts) {
|
|
931
|
+
// Keep checks deterministic and easier to read.
|
|
932
|
+
// eslint-disable-next-line no-await-in-loop
|
|
933
|
+
const report = await runBotE2E({ config, args, configPath, accountId });
|
|
934
|
+
report.checks.unshift(makeCheck("config.load", true, `loaded ${configPath}`));
|
|
935
|
+
report.summary = summarize(report.checks);
|
|
936
|
+
accountReports.push(report);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const report = {
|
|
940
|
+
args,
|
|
941
|
+
configPath,
|
|
942
|
+
config,
|
|
943
|
+
accounts: accountReports,
|
|
944
|
+
summary: summarizeAccountReports(accountReports),
|
|
945
|
+
};
|
|
946
|
+
reportAndExit(report, args.json);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
main().catch((err) => {
|
|
950
|
+
console.error(`Bot selfcheck failed: ${String(err?.message || err)}`);
|
|
951
|
+
process.exit(1);
|
|
952
|
+
});
|