nodus-wechat 0.1.0 → 0.5.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # nodus-wechat
2
2
 
3
- Placeholder package for the upcoming Nodus WeChat local agent installer.
3
+ CLI installer for Nodus WeChat, Hermes common settings, and the local OpeniLink webhook runtime.
4
4
 
5
5
  Run:
6
6
 
@@ -8,10 +8,82 @@ Run:
8
8
  npx nodus-wechat
9
9
  ```
10
10
 
11
- Current behavior:
11
+ ## Commands
12
12
 
13
- - Prints a placeholder message.
14
- - Does not install Hermes, iLink, or sub2api integrations.
15
- - Does not read, upload, or write user configuration.
13
+ ```sh
14
+ npx nodus-wechat setup
15
+ npx nodus-wechat setup --install-hermes
16
+ npx nodus-wechat install-hermes
17
+ npx nodus-wechat doctor
18
+ npx nodus-wechat start
19
+ npx nodus-wechat status
20
+ npx nodus-wechat logs
21
+ npx nodus-wechat stop
22
+ npx nodus-wechat uninstall --yes
23
+ ```
24
+
25
+ For a server-bound OpeniLink origin:
26
+
27
+ ```sh
28
+ npx nodus-wechat setup \
29
+ --openilink-origin http://192.220.25.138:9800 \
30
+ --openilink-rp-id 192.220.25.138
31
+ ```
32
+
33
+ `setup` prompts for the AstraGate API key when `--api-key` is not supplied.
34
+ The default gateway base URL is `https://api.nodus.sbs/`.
35
+ Use `--install-hermes` or `install-hermes` to run the official Hermes installer
36
+ with `--skip-setup` and the same Hermes home used by this CLI.
37
+
38
+ ## Current behavior
39
+
40
+ - Creates local configuration at `~/.nodus-wechat/config.json`.
41
+ - Can install Hermes Agent CLI through the official NousResearch installer.
42
+ - Installs the OpeniLink + webhook POC runtime at `~/.nodus-wechat/runtime`.
43
+ - Writes Hermes common settings to `~/.hermes/config.yaml`.
44
+ - Writes the AstraGate key to `~/.hermes/.env` as `ASTRAGATE_API_KEY`.
45
+ - Writes runtime `.env`, Docker Compose, webhook server, helper scripts, and the OpeniLink reply plugin.
46
+ - Stores gateway base URL, api key, model, Hermes paths, OpeniLink origin, webhook port, and runtime path.
47
+ - Checks Node.js, local configuration, Hermes files, runtime files, Docker Compose availability, Hermes CLI availability, and WeChat app detection with `doctor`.
48
+ - Starts/stops the local runtime through Docker Compose.
49
+ - Removes only files created by this CLI with `uninstall --yes`.
50
+
51
+ ## Current non-goals
52
+
53
+ - Does not run a third-party installer unless `--install-hermes` or `install-hermes` is explicitly requested.
54
+ - Does not automate, inject into, read, or control WeChat directly.
55
+ - Does not start a daemon, LaunchAgent, or background worker outside Docker Compose.
56
+ - Does not redeem real CDKs or mutate sub2api accounts; the bundled webhook keeps the existing dry-run POC boundary.
57
+
58
+ ## Runtime wiring
59
+
60
+ After `setup` and `start`, open the OpeniLink Hub shown by the CLI. In the Channel
61
+ Webhook settings, use:
62
+
63
+ ```text
64
+ Webhook URL: http://poc-webhook:9811/webhook
65
+ ```
66
+
67
+ If `--webhook-token` was configured, set the Channel Webhook auth header to:
68
+
69
+ ```text
70
+ Authorization: Bearer <the same token>
71
+ ```
72
+
73
+ The bundled webhook responds to:
74
+
75
+ ```text
76
+ /ping
77
+ /status plus
78
+ /add-plus-dry-run
79
+ ```
80
+
81
+ ## Publish
82
+
83
+ ```sh
84
+ npm run release:check
85
+ npm publish
86
+ ```
16
87
 
17
- The real installer will be released in a later version.
88
+ The package is configured for public npm access. If npm reports `E401`, run
89
+ `npm login` first.
@@ -2,7 +2,615 @@
2
2
 
3
3
  "use strict";
4
4
 
5
- console.log("Nodus WeChat installer is coming soon.");
6
- console.log("");
7
- console.log("Future purpose: install and configure a local WeChat Agent runtime.");
8
- console.log("This placeholder package does not read, upload, or write user configuration.");
5
+ const fs = require("node:fs");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const childProcess = require("node:child_process");
9
+
10
+ const VERSION = "0.5";
11
+ const DEFAULT_BASE_URL = "https://api.nodus.sbs/";
12
+ const DEFAULT_MODEL = "gpt-5.5";
13
+ const DEFAULT_OPENILINK_ORIGIN = "http://localhost:9800";
14
+ const DEFAULT_OPENILINK_RP_ID = "localhost";
15
+ const DEFAULT_OPENILINK_PORT = 9800;
16
+ const DEFAULT_WEBHOOK_PORT = 9811;
17
+ const TEMPLATE_DIR = path.join(__dirname, "..", "templates", "wechat-agent-poc");
18
+
19
+ function configHome() {
20
+ return process.env.NODUS_WECHAT_HOME || path.join(os.homedir(), ".nodus-wechat");
21
+ }
22
+
23
+ function configPath() {
24
+ return path.join(configHome(), "config.json");
25
+ }
26
+
27
+ function hermesHome() {
28
+ return process.env.NODUS_HERMES_HOME || path.join(os.homedir(), ".hermes");
29
+ }
30
+
31
+ function printHelp() {
32
+ console.log(`nodus-wechat ${VERSION}
33
+
34
+ Local CLI installer for Nodus WeChat, Hermes settings, and the OpeniLink webhook runtime.
35
+
36
+ Usage:
37
+ nodus-wechat setup [--api-key <key>] [--base-url <url>] [--model <model>]
38
+ [--runtime-dir <path>] [--openilink-origin <url>]
39
+ [--openilink-rp-id <id>] [--webhook-port <port>]
40
+ [--webhook-token <token>] [--install-hermes]
41
+ nodus-wechat install-hermes
42
+ nodus-wechat doctor
43
+ nodus-wechat start
44
+ nodus-wechat status
45
+ nodus-wechat logs
46
+ nodus-wechat stop
47
+ nodus-wechat uninstall --yes
48
+
49
+ Commands:
50
+ setup Create or update local configuration and runtime files.
51
+ install-hermes Install Hermes Agent CLI with the official installer.
52
+ doctor Check local prerequisites and configuration.
53
+ start Start the local OpeniLink + webhook runtime with Docker Compose.
54
+ status Show Docker Compose service status.
55
+ logs Follow webhook logs.
56
+ stop Stop the local runtime.
57
+ uninstall Remove files created by this CLI.
58
+
59
+ This version installs an OpeniLink webhook POC runtime. It does not inject into,
60
+ read, or control WeChat directly.`);
61
+ }
62
+
63
+ function parseArgs(argv) {
64
+ const result = { _: [] };
65
+
66
+ for (let index = 0; index < argv.length; index += 1) {
67
+ const item = argv[index];
68
+ if (!item.startsWith("--")) {
69
+ result._.push(item);
70
+ continue;
71
+ }
72
+
73
+ const key = item.slice(2);
74
+ if (key === "help" || key === "yes" || key === "install-hermes") {
75
+ result[key] = true;
76
+ continue;
77
+ }
78
+
79
+ const value = argv[index + 1];
80
+ if (!value || value.startsWith("--")) {
81
+ throw new Error(`Missing value for --${key}`);
82
+ }
83
+
84
+ result[key] = value;
85
+ index += 1;
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ function readConfig() {
92
+ return JSON.parse(fs.readFileSync(configPath(), "utf8"));
93
+ }
94
+
95
+ function writeConfig(config) {
96
+ fs.mkdirSync(configHome(), { recursive: true, mode: 0o700 });
97
+ fs.writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, {
98
+ mode: 0o600,
99
+ });
100
+ }
101
+
102
+ function parsePositiveInt(value, name) {
103
+ if (value === undefined) {
104
+ return undefined;
105
+ }
106
+
107
+ const parsed = Number.parseInt(value, 10);
108
+ if (!Number.isInteger(parsed) || parsed <= 0) {
109
+ throw new Error(`Invalid value for --${name}: ${value}`);
110
+ }
111
+ return parsed;
112
+ }
113
+
114
+ function parseDotEnv(filePath) {
115
+ if (!fs.existsSync(filePath)) {
116
+ return {};
117
+ }
118
+
119
+ const result = {};
120
+ for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
121
+ if (!line || line.trimStart().startsWith("#") || !line.includes("=")) {
122
+ continue;
123
+ }
124
+ const index = line.indexOf("=");
125
+ result[line.slice(0, index)] = line.slice(index + 1);
126
+ }
127
+ return result;
128
+ }
129
+
130
+ function promptApiKey() {
131
+ if (process.stdin.isTTY && process.platform !== "win32") {
132
+ const result = childProcess.spawnSync(
133
+ "sh",
134
+ [
135
+ "-c",
136
+ [
137
+ 'printf "Paste AstraGate API Key: " > /dev/tty',
138
+ "stty -echo < /dev/tty",
139
+ "IFS= read -r key < /dev/tty",
140
+ "status=$?",
141
+ "stty echo < /dev/tty",
142
+ 'printf "\\n" > /dev/tty',
143
+ 'printf "%s" "$key"',
144
+ "exit $status",
145
+ ].join("; "),
146
+ ],
147
+ { encoding: "utf8" },
148
+ );
149
+ if (!result.error && result.status === 0) {
150
+ return (result.stdout || "").trim();
151
+ }
152
+ }
153
+
154
+ process.stderr.write("Paste AstraGate API Key: ");
155
+ return fs.readFileSync(0, "utf8").split(/\r?\n/)[0].trim();
156
+ }
157
+
158
+ function resolveApiKey(options, existing) {
159
+ const apiKey = options["api-key"] || process.env.NODUS_WECHAT_API_KEY || existing.sub2api?.apiKey || "";
160
+ if (apiKey) {
161
+ return apiKey;
162
+ }
163
+
164
+ const prompted = promptApiKey();
165
+ if (!prompted) {
166
+ throw new Error("AstraGate API Key is required. Rerun setup and paste the key, or pass --api-key <key>.");
167
+ }
168
+ return prompted;
169
+ }
170
+
171
+ function yamlString(value) {
172
+ return JSON.stringify(String(value));
173
+ }
174
+
175
+ function buildHermesConfig(config) {
176
+ return [
177
+ "_config_version: 10",
178
+ "model:",
179
+ ` default: ${yamlString(config.agent.model)}`,
180
+ ' provider: "custom"',
181
+ ` base_url: ${yamlString(config.sub2api.baseUrl)}`,
182
+ ' api_key: "${ASTRAGATE_API_KEY}"',
183
+ "agent:",
184
+ ` reasoning_effort: ${yamlString(config.agent.reasoningEffort)}`,
185
+ "terminal:",
186
+ ' backend: "local"',
187
+ ' cwd: "."',
188
+ "approvals:",
189
+ ' mode: "manual"',
190
+ "toolsets:",
191
+ ' - "all"',
192
+ "display:",
193
+ ' tool_progress: "all"',
194
+ "compression:",
195
+ " enabled: true",
196
+ "",
197
+ ].join("\n");
198
+ }
199
+
200
+ function backupIfExists(filePath) {
201
+ if (!fs.existsSync(filePath)) {
202
+ return;
203
+ }
204
+
205
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
206
+ fs.copyFileSync(filePath, `${filePath}.bak-${stamp}`);
207
+ }
208
+
209
+ function writeHermesEnv(envPath, apiKey) {
210
+ const existingLines = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8").split(/\r?\n/) : [];
211
+ const kept = existingLines.filter((line) => !line.startsWith("ASTRAGATE_API_KEY=") && line.trim() !== "");
212
+ kept.push(`ASTRAGATE_API_KEY=${apiKey}`);
213
+ fs.writeFileSync(envPath, `${kept.join("\n")}\n`, { mode: 0o600 });
214
+ }
215
+
216
+ function installHermesConfig(config) {
217
+ fs.mkdirSync(config.hermes.home, { recursive: true, mode: 0o700 });
218
+ backupIfExists(config.hermes.configPath);
219
+ backupIfExists(config.hermes.envPath);
220
+ fs.writeFileSync(config.hermes.configPath, buildHermesConfig(config), { mode: 0o600 });
221
+ writeHermesEnv(config.hermes.envPath, config.sub2api.apiKey);
222
+ }
223
+
224
+ function shellQuote(value) {
225
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
226
+ }
227
+
228
+ function hermesInstallCommand() {
229
+ return (
230
+ process.env.NODUS_WECHAT_HERMES_INSTALL_COMMAND ||
231
+ "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s --"
232
+ );
233
+ }
234
+
235
+ function runHermesInstaller(hermesDir) {
236
+ const args = ["--skip-setup", "--hermes-home", hermesDir];
237
+ const command = `${hermesInstallCommand()} ${args.map(shellQuote).join(" ")}`;
238
+ const result = childProcess.spawnSync(command, {
239
+ shell: true,
240
+ stdio: "inherit",
241
+ env: { ...process.env, HERMES_HOME: hermesDir },
242
+ });
243
+
244
+ if (result.error) {
245
+ throw result.error;
246
+ }
247
+ if (result.status !== 0) {
248
+ throw new Error(`Hermes installer failed with exit code ${result.status}`);
249
+ }
250
+ }
251
+
252
+ function writeRuntimeEnv(config, options) {
253
+ const envPath = path.join(config.runtime.dir, ".env");
254
+ const existing = parseDotEnv(envPath);
255
+ const webhookToken = options["webhook-token"] ?? existing.POC_WEBHOOK_TOKEN ?? "";
256
+ const lines = [
257
+ "OPENILINK_PORT=" + config.openilink.port,
258
+ "OPENILINK_DATA_DIR=" + path.join(config.runtime.dir, "openilink-hub-data"),
259
+ "POC_WEBHOOK_PORT=" + config.webhook.port,
260
+ "POC_WEBHOOK_BIND=127.0.0.1",
261
+ "",
262
+ "OPENILINK_PUBLIC_ORIGIN=" + config.openilink.publicOrigin,
263
+ "OPENILINK_RP_ID=" + config.openilink.rpId,
264
+ "",
265
+ "POC_WEBHOOK_TOKEN=" + webhookToken,
266
+ "",
267
+ ];
268
+
269
+ fs.writeFileSync(envPath, lines.join("\n"), { mode: 0o600 });
270
+ }
271
+
272
+ function installRuntime(config, options) {
273
+ fs.mkdirSync(config.runtime.dir, { recursive: true, mode: 0o700 });
274
+ fs.cpSync(TEMPLATE_DIR, config.runtime.dir, {
275
+ recursive: true,
276
+ force: true,
277
+ errorOnExist: false,
278
+ });
279
+
280
+ const scriptsDir = path.join(config.runtime.dir, "scripts");
281
+ if (fs.existsSync(scriptsDir)) {
282
+ for (const fileName of fs.readdirSync(scriptsDir)) {
283
+ if (fileName.endsWith(".sh")) {
284
+ fs.chmodSync(path.join(scriptsDir, fileName), 0o755);
285
+ }
286
+ }
287
+ }
288
+
289
+ writeRuntimeEnv(config, options);
290
+ }
291
+
292
+ function createConfig(options) {
293
+ const existing = fs.existsSync(configPath()) ? readConfig() : {};
294
+ const now = new Date().toISOString();
295
+ const runtimeDir = options["runtime-dir"] || existing.runtime?.dir || path.join(configHome(), "runtime");
296
+ const openilinkPort = parsePositiveInt(options["openilink-port"], "openilink-port") || existing.openilink?.port || DEFAULT_OPENILINK_PORT;
297
+ const webhookPort = parsePositiveInt(options["webhook-port"], "webhook-port") || existing.webhook?.port || DEFAULT_WEBHOOK_PORT;
298
+ const hermesDir = options["hermes-home"] || existing.hermes?.home || hermesHome();
299
+ const apiKey = resolveApiKey(options, existing);
300
+
301
+ return {
302
+ schemaVersion: 1,
303
+ createdAt: existing.createdAt || now,
304
+ updatedAt: now,
305
+ sub2api: {
306
+ baseUrl: options["base-url"] || existing.sub2api?.baseUrl || DEFAULT_BASE_URL,
307
+ apiKey,
308
+ },
309
+ agent: {
310
+ model: options.model || existing.agent?.model || DEFAULT_MODEL,
311
+ reasoningEffort: existing.agent?.reasoningEffort || "high",
312
+ approvalMode: existing.agent?.approvalMode || "wechat-confirm",
313
+ },
314
+ wechat: {
315
+ connector: "openilink",
316
+ appPath: existing.wechat?.appPath || null,
317
+ status: "pending",
318
+ },
319
+ openilink: {
320
+ publicOrigin: options["openilink-origin"] || existing.openilink?.publicOrigin || DEFAULT_OPENILINK_ORIGIN,
321
+ rpId: options["openilink-rp-id"] || existing.openilink?.rpId || DEFAULT_OPENILINK_RP_ID,
322
+ port: openilinkPort,
323
+ },
324
+ webhook: {
325
+ port: webhookPort,
326
+ bind: "127.0.0.1",
327
+ tokenConfigured: Boolean(options["webhook-token"] || existing.webhook?.tokenConfigured),
328
+ },
329
+ runtime: {
330
+ status: "installed",
331
+ dir: runtimeDir,
332
+ composeFile: path.join(runtimeDir, "docker-compose.yml"),
333
+ },
334
+ hermes: {
335
+ status: "configured",
336
+ home: hermesDir,
337
+ configPath: path.join(hermesDir, "config.yaml"),
338
+ envPath: path.join(hermesDir, ".env"),
339
+ },
340
+ ilink: {
341
+ status: "not_installed",
342
+ },
343
+ };
344
+ }
345
+
346
+ function setup(options) {
347
+ const config = createConfig(options);
348
+ installRuntime(config, options);
349
+ installHermesConfig(config);
350
+ if (options["install-hermes"]) {
351
+ runHermesInstaller(config.hermes.home);
352
+ }
353
+ writeConfig(config);
354
+
355
+ console.log(`Config written: ${configPath()}`);
356
+ console.log(`Runtime installed: ${config.runtime.dir}`);
357
+ console.log(`Hermes configured: ${config.hermes.configPath}`);
358
+ if (!options["install-hermes"]) {
359
+ console.log("Hermes CLI install skipped. Run `nodus-wechat install-hermes` if `hermes` is not installed.");
360
+ }
361
+ console.log(`Gateway Base URL: ${config.sub2api.baseUrl}`);
362
+ console.log(`Model: ${config.agent.model}`);
363
+ console.log(`OpeniLink Hub: ${config.openilink.publicOrigin}`);
364
+ console.log(`Webhook URL for OpeniLink: http://poc-webhook:${config.webhook.port}/webhook`);
365
+ console.log("Run `nodus-wechat start` to start the local runtime.");
366
+ }
367
+
368
+ function installHermes() {
369
+ const config = fs.existsSync(configPath())
370
+ ? readConfig()
371
+ : { hermes: { home: hermesHome() } };
372
+ const hermesDir = config.hermes?.home || hermesHome();
373
+ runHermesInstaller(hermesDir);
374
+ console.log(`Hermes installer completed for: ${hermesDir}`);
375
+ return 0;
376
+ }
377
+
378
+ function doctor() {
379
+ let ok = true;
380
+ const major = Number.parseInt(process.versions.node.split(".")[0], 10);
381
+ if (major >= 18) {
382
+ console.log(`node: ok (${process.version})`);
383
+ } else {
384
+ ok = false;
385
+ console.log(`node: failed (${process.version}); Node.js >=18 is required`);
386
+ }
387
+
388
+ try {
389
+ fs.mkdirSync(configHome(), { recursive: true, mode: 0o700 });
390
+ fs.accessSync(configHome(), fs.constants.W_OK);
391
+ console.log(`config directory: ok (${configHome()})`);
392
+ } catch (error) {
393
+ ok = false;
394
+ console.log(`config directory: failed (${error.message})`);
395
+ }
396
+
397
+ if (!fs.existsSync(configPath())) {
398
+ console.log(`config: missing (${configPath()})`);
399
+ return 1;
400
+ }
401
+
402
+ try {
403
+ const config = readConfig();
404
+ console.log("config: ok");
405
+ if (config.sub2api?.apiKey) {
406
+ console.log("sub2api: ok (api key configured)");
407
+ } else {
408
+ ok = false;
409
+ console.log("sub2api: failed (api key missing)");
410
+ }
411
+ if (config.runtime?.dir && fs.existsSync(path.join(config.runtime.dir, "docker-compose.yml"))) {
412
+ console.log(`runtime: installed (${config.runtime.dir})`);
413
+ } else {
414
+ ok = false;
415
+ console.log(`runtime: missing (${config.runtime?.dir || path.join(configHome(), "runtime")})`);
416
+ }
417
+ const docker = dockerComposeAvailable();
418
+ if (docker.ok) {
419
+ console.log(`docker compose: ok (${docker.version})`);
420
+ } else {
421
+ console.log("docker compose: missing (needed for `nodus-wechat start`)");
422
+ }
423
+ console.log(`openilink: ${config.openilink?.publicOrigin || DEFAULT_OPENILINK_ORIGIN}`);
424
+ console.log(`webhook: http://127.0.0.1:${config.webhook?.port || DEFAULT_WEBHOOK_PORT}/health`);
425
+ const hermesConfigPath = config.hermes?.configPath || path.join(hermesHome(), "config.yaml");
426
+ const hermesEnvPath = config.hermes?.envPath || path.join(hermesHome(), ".env");
427
+ const hermesEnv = parseDotEnv(hermesEnvPath);
428
+ if (fs.existsSync(hermesConfigPath) && hermesEnv.ASTRAGATE_API_KEY) {
429
+ console.log(`hermes: configured (${hermesConfigPath})`);
430
+ } else {
431
+ ok = false;
432
+ console.log(`hermes: missing (${hermesConfigPath})`);
433
+ }
434
+ const hermesCli = childProcess.spawnSync("hermes", ["--version"], { encoding: "utf8" });
435
+ if (hermesCli.error || hermesCli.status !== 0) {
436
+ console.log("hermes cli: missing (config is ready; install Hermes before using it)");
437
+ } else {
438
+ console.log(`hermes cli: ok (${(hermesCli.stdout || hermesCli.stderr || "").trim()})`);
439
+ }
440
+ console.log(`ilink: ${config.ilink?.status || "not_installed"}`);
441
+ console.log(`wechat: ${findWeChatApp() || "not detected"}`);
442
+ } catch (error) {
443
+ ok = false;
444
+ console.log(`config: failed (${error.message})`);
445
+ }
446
+
447
+ return ok ? 0 : 1;
448
+ }
449
+
450
+ function findWeChatApp() {
451
+ const candidates = ["/Applications/WeChat.app", "/Applications/微信.app"];
452
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
453
+ }
454
+
455
+ function dockerComposeAvailable() {
456
+ const result = childProcess.spawnSync("docker", ["compose", "version"], {
457
+ encoding: "utf8",
458
+ });
459
+ if (result.error || result.status !== 0) {
460
+ return { ok: false };
461
+ }
462
+
463
+ return {
464
+ ok: true,
465
+ version: (result.stdout || result.stderr || "").trim(),
466
+ };
467
+ }
468
+
469
+ function loadRuntimeConfig() {
470
+ if (!fs.existsSync(configPath())) {
471
+ console.error(`Config missing: ${configPath()}`);
472
+ console.error("Run: nodus-wechat setup --api-key <key>");
473
+ return null;
474
+ }
475
+
476
+ const config = readConfig();
477
+ const runtimeDir = config.runtime?.dir || path.join(configHome(), "runtime");
478
+ if (!fs.existsSync(path.join(runtimeDir, "docker-compose.yml"))) {
479
+ console.error(`Runtime missing: ${runtimeDir}`);
480
+ console.error("Run: nodus-wechat setup");
481
+ return null;
482
+ }
483
+
484
+ return { ...config, runtime: { ...config.runtime, dir: runtimeDir } };
485
+ }
486
+
487
+ function runDockerCompose(config, args, stdio = "inherit") {
488
+ const docker = dockerComposeAvailable();
489
+ if (!docker.ok) {
490
+ console.error("Docker Compose is required for this command.");
491
+ console.error("Install Docker Desktop or OrbStack, then rerun `nodus-wechat start`.");
492
+ return 1;
493
+ }
494
+
495
+ const result = childProcess.spawnSync("docker", ["compose", ...args], {
496
+ cwd: config.runtime.dir,
497
+ stdio,
498
+ encoding: stdio === "pipe" ? "utf8" : undefined,
499
+ });
500
+
501
+ if (stdio === "pipe" && result.stdout) {
502
+ process.stdout.write(result.stdout);
503
+ }
504
+ if (stdio === "pipe" && result.stderr) {
505
+ process.stderr.write(result.stderr);
506
+ }
507
+
508
+ return result.status || 0;
509
+ }
510
+
511
+ function start() {
512
+ const config = loadRuntimeConfig();
513
+ if (!config) {
514
+ return 1;
515
+ }
516
+
517
+ return runDockerCompose(config, ["up", "-d"]);
518
+ }
519
+
520
+ function status() {
521
+ const config = loadRuntimeConfig();
522
+ if (!config) {
523
+ return 1;
524
+ }
525
+
526
+ return runDockerCompose(config, ["ps"]);
527
+ }
528
+
529
+ function logs() {
530
+ const config = loadRuntimeConfig();
531
+ if (!config) {
532
+ return 1;
533
+ }
534
+
535
+ return runDockerCompose(config, ["logs", "-f", "poc-webhook"]);
536
+ }
537
+
538
+ function stop() {
539
+ const config = loadRuntimeConfig();
540
+ if (!config) {
541
+ return 1;
542
+ }
543
+
544
+ return runDockerCompose(config, ["down"]);
545
+ }
546
+
547
+ function uninstall(options) {
548
+ if (!options.yes) {
549
+ console.error("Refusing to uninstall without --yes.");
550
+ return 1;
551
+ }
552
+
553
+ fs.rmSync(configHome(), { recursive: true, force: true });
554
+ console.log(`Removed: ${configHome()}`);
555
+ return 0;
556
+ }
557
+
558
+ function main() {
559
+ let args;
560
+ try {
561
+ args = parseArgs(process.argv.slice(2));
562
+ } catch (error) {
563
+ console.error(error.message);
564
+ return 1;
565
+ }
566
+
567
+ const command = args._[0];
568
+ if (!command || args.help || command === "help") {
569
+ printHelp();
570
+ return 0;
571
+ }
572
+
573
+ try {
574
+ if (command === "setup") {
575
+ setup(args);
576
+ return 0;
577
+ }
578
+
579
+ if (command === "install-hermes") {
580
+ return installHermes();
581
+ }
582
+
583
+ if (command === "doctor") {
584
+ return doctor();
585
+ }
586
+
587
+ if (command === "start") {
588
+ return start();
589
+ }
590
+
591
+ if (command === "status") {
592
+ return status();
593
+ }
594
+
595
+ if (command === "logs") {
596
+ return logs();
597
+ }
598
+
599
+ if (command === "stop") {
600
+ return stop();
601
+ }
602
+
603
+ if (command === "uninstall") {
604
+ return uninstall(args);
605
+ }
606
+ } catch (error) {
607
+ console.error(error.message);
608
+ return 1;
609
+ }
610
+
611
+ console.error(`Unknown command: ${command}`);
612
+ printHelp();
613
+ return 1;
614
+ }
615
+
616
+ process.exitCode = main();
package/package.json CHANGED
@@ -1,14 +1,23 @@
1
1
  {
2
2
  "name": "nodus-wechat",
3
- "version": "0.1.0",
4
- "description": "Placeholder CLI for the upcoming Nodus WeChat local agent installer.",
3
+ "version": "0.5.0",
4
+ "description": "CLI installer for Nodus WeChat, Hermes, and the local OpeniLink webhook runtime.",
5
5
  "license": "MIT",
6
6
  "private": false,
7
7
  "bin": {
8
8
  "nodus-wechat": "bin/nodus-wechat.js"
9
9
  },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "release:check": "npm test && npm pack --dry-run",
13
+ "prepublishOnly": "npm test"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
10
18
  "files": [
11
19
  "bin",
20
+ "templates",
12
21
  "README.md",
13
22
  "LICENSE"
14
23
  ],
@@ -0,0 +1,22 @@
1
+ # Copy to .env before starting the POC.
2
+ #
3
+ # For local verification on this Mac:
4
+ # OPENILINK_PUBLIC_ORIGIN=http://localhost:9800
5
+ # OPENILINK_RP_ID=localhost
6
+ #
7
+ # For the 192 server, replace with the server address you open in the browser:
8
+ # OPENILINK_PUBLIC_ORIGIN=http://192.220.25.138:9800
9
+ # OPENILINK_RP_ID=192.220.25.138
10
+
11
+ OPENILINK_PORT=9800
12
+ OPENILINK_DATA_DIR=/opt/sub2api/openilink-hub-data
13
+ POC_WEBHOOK_PORT=9811
14
+ POC_WEBHOOK_BIND=127.0.0.1
15
+
16
+ # WebAuthn / browser origin. For 192 deployment, use the 192 values below.
17
+ OPENILINK_PUBLIC_ORIGIN=http://192.220.25.138:9800
18
+ OPENILINK_RP_ID=192.220.25.138
19
+
20
+ # Optional. If set, configure the OpeniLink Channel Webhook auth as:
21
+ # Authorization: Bearer <this value>
22
+ POC_WEBHOOK_TOKEN=
@@ -0,0 +1,164 @@
1
+ # WeChat Agent POC
2
+
3
+ 这个 POC 只验证三件事:
4
+
5
+ 1. 能不能扫码绑定微信机器人身份。
6
+ 2. 能不能把机器人/微信号拉进普通微信群。
7
+ 3. 群里发消息后,事件能不能进入 webhook,并能不能回复到群里。
8
+
9
+ 先不要接真实 CDK。这里的 `/add-plus-dry-run` 只返回假任务,确认通道可用后再接 `sub2api` 的真实加号工具。
10
+
11
+ ## Start
12
+
13
+ ```bash
14
+ cd "/Users/zyaire/Documents/API Router/sub2api/deploy/wechat-agent-poc"
15
+ cp .env.example .env
16
+ ./scripts/start.sh
17
+ ```
18
+
19
+ 打开:
20
+
21
+ ```text
22
+ http://192.220.25.138:9800
23
+ ```
24
+
25
+ 本机开发时再把 `.env` 改回:
26
+
27
+ ```dotenv
28
+ OPENILINK_PUBLIC_ORIGIN=http://localhost:9800
29
+ OPENILINK_RP_ID=localhost
30
+ ```
31
+
32
+ ## OpeniLink Setup
33
+
34
+ 当前 192 POC 已经完成基础配置:
35
+
36
+ ```text
37
+ Bot ID: 00612ba4-f96b-4f19-ad75-c3913e92cf75
38
+ Channel: Sub2API POC
39
+ Webhook URL: http://poc-webhook:9811/webhook
40
+ Webhook script: plugins/reply-from-webhook.js
41
+ AI auto-reply: disabled
42
+ Data dir: /opt/sub2api/openilink-hub-data
43
+ ```
44
+
45
+ 如果重新从零部署,在 OpeniLink Hub 后台完成:
46
+
47
+ 1. 注册第一个账号,第一个注册用户会成为管理员。
48
+ 2. 进入 Bot 管理,扫码绑定微信机器人身份。
49
+ 3. 在这个 Bot 下创建一个 Channel。
50
+ 4. 在 Channel 设置中启用 Webhook。
51
+ 5. Webhook URL 填 `http://poc-webhook:9811/webhook`。
52
+ 6. 如果 `.env` 设置了 `POC_WEBHOOK_TOKEN`,认证方式选 Bearer Token,并填入同一个 token。
53
+ 7. 在 Channel 的 Webhook 插件里安装 `plugins/reply-from-webhook.js` 的内容。
54
+ 8. 关闭内置 AI 自动回复,先只测 Webhook 通道。
55
+
56
+ OpeniLink 官方文档里,Webhook 会把消息 POST 到 URL;要把 webhook JSON 响应里的 `reply` 发回微信,需要响应后插件调用 `ctx.reply()`。
57
+
58
+ ## Verification
59
+
60
+ 先私聊机器人:
61
+
62
+ ```text
63
+ /ping
64
+ /status plus
65
+ /add-plus-dry-run
66
+ ```
67
+
68
+ 再拉进普通微信群,群里发:
69
+
70
+ ```text
71
+ @机器人 /ping
72
+ @机器人 /status plus
73
+ @机器人 /add-plus-dry-run
74
+ ```
75
+
76
+ 如果群聊不支持 `@机器人`,也测试直接发:
77
+
78
+ ```text
79
+ /ping
80
+ ```
81
+
82
+ ## Logs
83
+
84
+ 本地探测 webhook:
85
+
86
+ ```bash
87
+ ./scripts/probe-webhook.sh
88
+ ```
89
+
90
+ 查看 webhook 是否收到事件:
91
+
92
+ ```bash
93
+ ./scripts/logs.sh
94
+ ```
95
+
96
+ 查看整体状态:
97
+
98
+ ```bash
99
+ ./scripts/status.sh
100
+ ```
101
+
102
+ ## Deploy To 192
103
+
104
+ 如果本机能 SSH 到 192:
105
+
106
+ ```bash
107
+ ./scripts/deploy-to-192.sh
108
+ ```
109
+
110
+ 默认目标:
111
+
112
+ ```text
113
+ root@192.220.25.138:/opt/sub2api/wechat-agent-poc
114
+ ```
115
+
116
+ 如果服务器没有 Docker,并且是 Ubuntu:
117
+
118
+ ```bash
119
+ ssh root@192.220.25.138
120
+ cd /opt/sub2api/wechat-agent-poc
121
+ ./scripts/install-prereqs-ubuntu.sh
122
+ ./scripts/start.sh
123
+ ```
124
+
125
+ 关键字段:
126
+
127
+ ```text
128
+ payload.type
129
+ payload.content
130
+ payload.sender.user_id
131
+ payload.sessionID
132
+ payload.channel_id
133
+ ```
134
+
135
+ 如果私聊能收、群里收不到,说明 Agent 层没有问题,限制在微信/iLink 群事件投递。不同 OpeniLink 版本的群字段可能叫 `group`、`room` 或只体现在 `sessionID` 里,所以先以日志里的实际 payload 为准。
136
+
137
+ ## Pass Criteria
138
+
139
+ 这几项都通过,再接 Hermes 和真实 sub2api 加号流程:
140
+
141
+ ```text
142
+ 私聊 /ping 有回复
143
+ 群里能看到机器人身份
144
+ 群里 @机器人 /ping 有回复
145
+ webhook 日志里出现 group.id
146
+ webhook 日志里 sender.id 稳定
147
+ 重复发送不会多次触发异常回复
148
+ ```
149
+
150
+ ## Next Step
151
+
152
+ 真实加号流程建议只暴露成受控工具:
153
+
154
+ ```text
155
+ validate_cdk
156
+ redeem_cdk_to_plus_account
157
+ import_account_to_pool
158
+ assign_group("plus")
159
+ healthcheck_account
160
+ enable_account
161
+ report_result_to_wechat
162
+ ```
163
+
164
+ CDK 不建议发在群里。群里只触发任务或查询状态,真实 CDK 走私聊或 sub2api 管理页面。
@@ -0,0 +1,26 @@
1
+ services:
2
+ openilink:
3
+ image: ghcr.io/openilink/openilink-hub:latest
4
+ container_name: sub2api-openilink-poc
5
+ restart: unless-stopped
6
+ working_dir: /data
7
+ ports:
8
+ - "${OPENILINK_PORT:-9800}:9800"
9
+ environment:
10
+ RP_ORIGIN: "${OPENILINK_PUBLIC_ORIGIN:-http://localhost:9800}"
11
+ RP_ID: "${OPENILINK_RP_ID:-localhost}"
12
+ volumes:
13
+ - "${OPENILINK_DATA_DIR:-/opt/sub2api/openilink-hub-data}:/var/lib/openilink-hub"
14
+
15
+ poc-webhook:
16
+ image: python:3.12-alpine
17
+ container_name: sub2api-wechat-poc-webhook
18
+ restart: unless-stopped
19
+ working_dir: /app
20
+ command: ["python", "server.py"]
21
+ ports:
22
+ - "${POC_WEBHOOK_BIND:-127.0.0.1}:${POC_WEBHOOK_PORT:-9811}:9811"
23
+ environment:
24
+ POC_WEBHOOK_TOKEN: "${POC_WEBHOOK_TOKEN:-}"
25
+ volumes:
26
+ - ./poc-webhook:/app:ro
@@ -0,0 +1,27 @@
1
+ // ==UserScript==
2
+ // @name Sub2API POC Reply From Webhook
3
+ // @description Replies to WeChat with the JSON reply field returned by the POC webhook.
4
+ // @version 1.0.0
5
+ // @match *
6
+ // @grant none
7
+ // @timeout 3000
8
+ // ==/UserScript==
9
+
10
+ function onResponse(ctx) {
11
+ if (!ctx.res || ctx.res.status < 200 || ctx.res.status >= 300) {
12
+ ctx.reply("POC webhook failed. Check docker compose logs -f poc-webhook.");
13
+ return;
14
+ }
15
+
16
+ let body = {};
17
+ try {
18
+ body = JSON.parse(ctx.res.body || "{}");
19
+ } catch (err) {
20
+ ctx.reply("POC webhook returned non-JSON response.");
21
+ return;
22
+ }
23
+
24
+ if (body.reply) {
25
+ ctx.reply(String(body.reply));
26
+ }
27
+ }
@@ -0,0 +1,118 @@
1
+ from http.server import BaseHTTPRequestHandler, HTTPServer
2
+ import json
3
+ import os
4
+ import time
5
+
6
+
7
+ def _compact(value):
8
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
9
+
10
+
11
+ def _payload_value(payload, *paths):
12
+ for path in paths:
13
+ cur = payload
14
+ ok = True
15
+ for key in path:
16
+ if isinstance(cur, dict) and key in cur:
17
+ cur = cur[key]
18
+ else:
19
+ ok = False
20
+ break
21
+ if ok and cur not in (None, ""):
22
+ return cur
23
+ return None
24
+
25
+
26
+ class Handler(BaseHTTPRequestHandler):
27
+ def _send_json(self, status, payload):
28
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
29
+ self.send_response(status)
30
+ self.send_header("Content-Type", "application/json; charset=utf-8")
31
+ self.send_header("Content-Length", str(len(body)))
32
+ self.end_headers()
33
+ self.wfile.write(body)
34
+
35
+ def do_GET(self):
36
+ if self.path == "/health":
37
+ self._send_json(200, {"ok": True, "service": "sub2api-wechat-poc-webhook"})
38
+ return
39
+ self._send_json(404, {"ok": False, "error": "not_found"})
40
+
41
+ def do_POST(self):
42
+ expected_token = os.environ.get("POC_WEBHOOK_TOKEN", "")
43
+ if expected_token:
44
+ expected = f"Bearer {expected_token}"
45
+ if self.headers.get("Authorization") != expected:
46
+ self._send_json(401, {"ok": False, "error": "unauthorized"})
47
+ return
48
+
49
+ length = int(self.headers.get("Content-Length", "0"))
50
+ raw = self.rfile.read(length)
51
+ try:
52
+ payload = json.loads(raw.decode("utf-8") or "{}")
53
+ except json.JSONDecodeError:
54
+ self._send_json(400, {"ok": False, "error": "invalid_json"})
55
+ return
56
+
57
+ print(_compact({"ts": int(time.time()), "path": self.path, "payload": payload}), flush=True)
58
+
59
+ if payload.get("type") == "url_verification":
60
+ self._send_json(200, {"challenge": payload.get("challenge")})
61
+ return
62
+
63
+ content = (_payload_value(payload, ("content",), ("text",), ("event", "data", "content"), ("event", "data", "text")) or "").strip()
64
+ sender = _payload_value(payload, ("sender",), ("event", "data", "sender")) or {}
65
+ session_id = _payload_value(payload, ("sessionID",), ("session_id",), ("event", "data", "sessionID"))
66
+ channel_id = _payload_value(payload, ("channel_id",), ("channelID",), ("event", "channel_id"))
67
+ group = _payload_value(payload, ("group",), ("room",), ("event", "data", "group"), ("event", "data", "room"))
68
+
69
+ if content.startswith("/ping") or content == "ping":
70
+ self._send_json(200, {"reply": "pong: sub2api wechat POC webhook is alive"})
71
+ return
72
+
73
+ if content.startswith("/status") or "状态" in content:
74
+ group_name = "plus" if "plus" in content.lower() else "all"
75
+ self._send_json(
76
+ 200,
77
+ {
78
+ "reply": (
79
+ f"POC status group={group_name}: total=3 active=2 disabled=1. "
80
+ "This is mock data; WeChat event delivery is working."
81
+ )
82
+ },
83
+ )
84
+ return
85
+
86
+ if content.startswith("/add-plus-dry-run") or "加号" in content:
87
+ self._send_json(
88
+ 200,
89
+ {
90
+ "reply": (
91
+ "dry-run accepted: job=poc_plus_import_001, group=plus, "
92
+ "no real CDK was redeemed."
93
+ )
94
+ },
95
+ )
96
+ return
97
+
98
+ scope = "group" if group else "dm_or_unknown"
99
+ sender_id = sender.get("user_id") or sender.get("id") or "unknown"
100
+ group_id = None
101
+ if isinstance(group, dict):
102
+ group_id = group.get("id") or group.get("room_id") or group.get("user_id")
103
+ self._send_json(
104
+ 200,
105
+ {
106
+ "reply": (
107
+ f"received {scope} message. sender={sender_id}"
108
+ + (f" group={group_id}" if group_id else "")
109
+ + (f" session={session_id}" if session_id else "")
110
+ + (f" channel={channel_id}" if channel_id else "")
111
+ + ". Try /ping, /status plus, or /add-plus-dry-run."
112
+ )
113
+ },
114
+ )
115
+
116
+
117
+ if __name__ == "__main__":
118
+ HTTPServer(("0.0.0.0", 9811), Handler).serve_forever()
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5
+ REMOTE="${WECHAT_POC_REMOTE:-root@192.220.25.138}"
6
+ REMOTE_DIR="${WECHAT_POC_REMOTE_DIR:-/opt/sub2api/wechat-agent-poc}"
7
+
8
+ ssh "$REMOTE" "mkdir -p '$REMOTE_DIR'"
9
+ rsync -az --delete \
10
+ --exclude '__pycache__' \
11
+ --exclude '.pytest_cache' \
12
+ "$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
13
+
14
+ ssh "$REMOTE" "cd '$REMOTE_DIR' && chmod +x scripts/*.sh && ./scripts/start.sh"
15
+
16
+ printf "OpeniLink Hub: http://192.220.25.138:9800\n"
17
+ printf "Remote logs: ssh %s 'cd %s && ./scripts/logs.sh'\n" "$REMOTE" "$REMOTE_DIR"
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
5
+ docker compose version
6
+ exit 0
7
+ fi
8
+
9
+ if ! command -v apt-get >/dev/null 2>&1; then
10
+ echo "This helper only supports apt-based Linux hosts. Install Docker manually, then rerun scripts/start.sh." >&2
11
+ exit 1
12
+ fi
13
+
14
+ sudo apt-get update
15
+ sudo apt-get install -y ca-certificates curl gnupg
16
+ sudo install -m 0755 -d /etc/apt/keyrings
17
+ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
18
+ sudo chmod a+r /etc/apt/keyrings/docker.gpg
19
+
20
+ . /etc/os-release
21
+ echo \
22
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
23
+ ${VERSION_CODENAME} stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
24
+
25
+ sudo apt-get update
26
+ sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
27
+ sudo systemctl enable --now docker
28
+ docker compose version
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+ docker compose logs -f poc-webhook
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ TOKEN=""
7
+ if [ -f .env ]; then
8
+ TOKEN="$(awk -F= '$1=="POC_WEBHOOK_TOKEN"{print $2}' .env | tail -1)"
9
+ fi
10
+
11
+ AUTH_ARGS=()
12
+ if [ -n "$TOKEN" ]; then
13
+ AUTH_ARGS=(-H "Authorization: Bearer $TOKEN")
14
+ fi
15
+
16
+ curl -fsS \
17
+ -H "Content-Type: application/json" \
18
+ "${AUTH_ARGS[@]}" \
19
+ -d '{"type":"message","content":"/ping","sender":{"user_id":"local_probe","user_name":"local probe"},"sessionID":"probe_session","channel_id":"probe_channel"}' \
20
+ "http://127.0.0.1:${POC_WEBHOOK_PORT:-9811}/webhook"
21
+ printf "\n"
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ if [ ! -f .env ]; then
7
+ cp .env.example .env
8
+ fi
9
+
10
+ docker compose up -d
11
+ docker compose ps
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+
6
+ docker compose ps
7
+ printf "\nWebhook health:\n"
8
+ curl -fsS "http://127.0.0.1:${POC_WEBHOOK_PORT:-9811}/health" || true
9
+ printf "\n\nRecent webhook logs:\n"
10
+ docker compose logs --tail=80 poc-webhook
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ cd "$(dirname "$0")/.."
5
+ docker compose down
@@ -0,0 +1,53 @@
1
+ # Sub2API Agent Tools Contract
2
+
3
+ 这个文件定义下一步给 Hermes 或其他 Agent 调用的最小工具边界。当前 POC 只验证微信通道,暂不执行真实 CDK 兑换。
4
+
5
+ ## Tool Boundary
6
+
7
+ Agent 只能调用 HTTP 工具服务,不直接连数据库,不直接读取 CDK 明文日志。
8
+
9
+ 推荐工具:
10
+
11
+ ```text
12
+ GET /agent-tools/pool/status?group=plus
13
+ POST /agent-tools/plus/import-jobs
14
+ GET /agent-tools/plus/import-jobs/{id}
15
+ POST /agent-tools/plus/import-jobs/{id}/retry
16
+ POST /agent-tools/accounts/{id}/disable
17
+ POST /agent-tools/accounts/{id}/enable
18
+ ```
19
+
20
+ ## Import Job
21
+
22
+ ```json
23
+ {
24
+ "requester_wechat_id": "wxid_xxx",
25
+ "group": "plus",
26
+ "cdk": "redacted-at-rest",
27
+ "dry_run": true
28
+ }
29
+ ```
30
+
31
+ 状态机:
32
+
33
+ ```text
34
+ pending
35
+ validating_cdk
36
+ redeeming
37
+ importing_account
38
+ assigning_group
39
+ healthchecking
40
+ enabled
41
+ failed
42
+ ```
43
+
44
+ ## Safety
45
+
46
+ ```text
47
+ 群聊只能查询状态和创建 dry-run job
48
+ 真实 CDK 只允许私聊或 sub2api 管理页提交
49
+ 所有写操作必须校验 requester_wechat_id allowlist
50
+ 所有 CDK 日志必须脱敏
51
+ 同一个 CDK hash 必须幂等
52
+ 失败 job 必须保留 error_code 和 redacted_error
53
+ ```