nodus-wechat 0.2.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 +64 -10
- package/bin/nodus-wechat.js +418 -39
- package/package.json +9 -3
- package/templates/wechat-agent-poc/.env.example +22 -0
- package/templates/wechat-agent-poc/README.md +164 -0
- package/templates/wechat-agent-poc/docker-compose.yml +26 -0
- package/templates/wechat-agent-poc/plugins/reply-from-webhook.js +27 -0
- package/templates/wechat-agent-poc/poc-webhook/server.py +118 -0
- package/templates/wechat-agent-poc/scripts/deploy-to-192.sh +17 -0
- package/templates/wechat-agent-poc/scripts/install-prereqs-ubuntu.sh +28 -0
- package/templates/wechat-agent-poc/scripts/logs.sh +5 -0
- package/templates/wechat-agent-poc/scripts/probe-webhook.sh +21 -0
- package/templates/wechat-agent-poc/scripts/start.sh +11 -0
- package/templates/wechat-agent-poc/scripts/status.sh +10 -0
- package/templates/wechat-agent-poc/scripts/stop.sh +5 -0
- package/templates/wechat-agent-poc/sub2api-tools-contract.md +53 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# nodus-wechat
|
|
2
2
|
|
|
3
|
-
CLI
|
|
3
|
+
CLI installer for Nodus WeChat, Hermes common settings, and the local OpeniLink webhook runtime.
|
|
4
4
|
|
|
5
5
|
Run:
|
|
6
6
|
|
|
@@ -11,25 +11,79 @@ npx nodus-wechat
|
|
|
11
11
|
## Commands
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
npx nodus-wechat setup
|
|
14
|
+
npx nodus-wechat setup
|
|
15
|
+
npx nodus-wechat setup --install-hermes
|
|
16
|
+
npx nodus-wechat install-hermes
|
|
15
17
|
npx nodus-wechat doctor
|
|
16
18
|
npx nodus-wechat start
|
|
19
|
+
npx nodus-wechat status
|
|
20
|
+
npx nodus-wechat logs
|
|
21
|
+
npx nodus-wechat stop
|
|
17
22
|
npx nodus-wechat uninstall --yes
|
|
18
23
|
```
|
|
19
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
|
+
|
|
20
38
|
## Current behavior
|
|
21
39
|
|
|
22
40
|
- Creates local configuration at `~/.nodus-wechat/config.json`.
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
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.
|
|
26
49
|
- Removes only files created by this CLI with `uninstall --yes`.
|
|
27
50
|
|
|
28
51
|
## Current non-goals
|
|
29
52
|
|
|
30
|
-
- Does not install
|
|
31
|
-
- Does not
|
|
32
|
-
- Does not
|
|
33
|
-
- Does not
|
|
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
|
+
```
|
|
34
87
|
|
|
35
|
-
The
|
|
88
|
+
The package is configured for public npm access. If npm reports `E401`, run
|
|
89
|
+
`npm login` first.
|
package/bin/nodus-wechat.js
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
const fs = require("node:fs");
|
|
6
6
|
const os = require("node:os");
|
|
7
7
|
const path = require("node:path");
|
|
8
|
+
const childProcess = require("node:child_process");
|
|
8
9
|
|
|
9
|
-
const VERSION = "0.
|
|
10
|
-
const DEFAULT_BASE_URL = "https://api.nodus.sbs/
|
|
10
|
+
const VERSION = "0.5";
|
|
11
|
+
const DEFAULT_BASE_URL = "https://api.nodus.sbs/";
|
|
11
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");
|
|
12
18
|
|
|
13
19
|
function configHome() {
|
|
14
20
|
return process.env.NODUS_WECHAT_HOME || path.join(os.homedir(), ".nodus-wechat");
|
|
@@ -18,24 +24,40 @@ function configPath() {
|
|
|
18
24
|
return path.join(configHome(), "config.json");
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
function hermesHome() {
|
|
28
|
+
return process.env.NODUS_HERMES_HOME || path.join(os.homedir(), ".hermes");
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
function printHelp() {
|
|
22
32
|
console.log(`nodus-wechat ${VERSION}
|
|
23
33
|
|
|
24
|
-
Local CLI
|
|
34
|
+
Local CLI installer for Nodus WeChat, Hermes settings, and the OpeniLink webhook runtime.
|
|
25
35
|
|
|
26
36
|
Usage:
|
|
27
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
|
|
28
42
|
nodus-wechat doctor
|
|
29
43
|
nodus-wechat start
|
|
44
|
+
nodus-wechat status
|
|
45
|
+
nodus-wechat logs
|
|
46
|
+
nodus-wechat stop
|
|
30
47
|
nodus-wechat uninstall --yes
|
|
31
48
|
|
|
32
49
|
Commands:
|
|
33
|
-
setup
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.`);
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
function parseArgs(argv) {
|
|
@@ -49,7 +71,7 @@ function parseArgs(argv) {
|
|
|
49
71
|
}
|
|
50
72
|
|
|
51
73
|
const key = item.slice(2);
|
|
52
|
-
if (key === "help" || key === "yes") {
|
|
74
|
+
if (key === "help" || key === "yes" || key === "install-hermes") {
|
|
53
75
|
result[key] = true;
|
|
54
76
|
continue;
|
|
55
77
|
}
|
|
@@ -77,9 +99,204 @@ function writeConfig(config) {
|
|
|
77
99
|
});
|
|
78
100
|
}
|
|
79
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
|
+
|
|
80
292
|
function createConfig(options) {
|
|
81
293
|
const existing = fs.existsSync(configPath()) ? readConfig() : {};
|
|
82
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);
|
|
83
300
|
|
|
84
301
|
return {
|
|
85
302
|
schemaVersion: 1,
|
|
@@ -87,7 +304,7 @@ function createConfig(options) {
|
|
|
87
304
|
updatedAt: now,
|
|
88
305
|
sub2api: {
|
|
89
306
|
baseUrl: options["base-url"] || existing.sub2api?.baseUrl || DEFAULT_BASE_URL,
|
|
90
|
-
apiKey
|
|
307
|
+
apiKey,
|
|
91
308
|
},
|
|
92
309
|
agent: {
|
|
93
310
|
model: options.model || existing.agent?.model || DEFAULT_MODEL,
|
|
@@ -95,12 +312,30 @@ function createConfig(options) {
|
|
|
95
312
|
approvalMode: existing.agent?.approvalMode || "wechat-confirm",
|
|
96
313
|
},
|
|
97
314
|
wechat: {
|
|
98
|
-
connector: "
|
|
315
|
+
connector: "openilink",
|
|
99
316
|
appPath: existing.wechat?.appPath || null,
|
|
100
317
|
status: "pending",
|
|
101
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
|
+
},
|
|
102
334
|
hermes: {
|
|
103
|
-
status: "
|
|
335
|
+
status: "configured",
|
|
336
|
+
home: hermesDir,
|
|
337
|
+
configPath: path.join(hermesDir, "config.yaml"),
|
|
338
|
+
envPath: path.join(hermesDir, ".env"),
|
|
104
339
|
},
|
|
105
340
|
ilink: {
|
|
106
341
|
status: "not_installed",
|
|
@@ -110,15 +345,34 @@ function createConfig(options) {
|
|
|
110
345
|
|
|
111
346
|
function setup(options) {
|
|
112
347
|
const config = createConfig(options);
|
|
348
|
+
installRuntime(config, options);
|
|
349
|
+
installHermesConfig(config);
|
|
350
|
+
if (options["install-hermes"]) {
|
|
351
|
+
runHermesInstaller(config.hermes.home);
|
|
352
|
+
}
|
|
113
353
|
writeConfig(config);
|
|
114
354
|
|
|
115
355
|
console.log(`Config written: ${configPath()}`);
|
|
116
|
-
console.log(`
|
|
117
|
-
console.log(`
|
|
118
|
-
if (!
|
|
119
|
-
console.log("
|
|
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.");
|
|
120
360
|
}
|
|
121
|
-
console.log(
|
|
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;
|
|
122
376
|
}
|
|
123
377
|
|
|
124
378
|
function doctor() {
|
|
@@ -154,7 +408,35 @@ function doctor() {
|
|
|
154
408
|
ok = false;
|
|
155
409
|
console.log("sub2api: failed (api key missing)");
|
|
156
410
|
}
|
|
157
|
-
|
|
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
|
+
}
|
|
158
440
|
console.log(`ilink: ${config.ilink?.status || "not_installed"}`);
|
|
159
441
|
console.log(`wechat: ${findWeChatApp() || "not detected"}`);
|
|
160
442
|
} catch (error) {
|
|
@@ -170,20 +452,96 @@ function findWeChatApp() {
|
|
|
170
452
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
171
453
|
}
|
|
172
454
|
|
|
173
|
-
function
|
|
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() {
|
|
174
470
|
if (!fs.existsSync(configPath())) {
|
|
175
471
|
console.error(`Config missing: ${configPath()}`);
|
|
176
472
|
console.error("Run: nodus-wechat setup --api-key <key>");
|
|
177
|
-
return
|
|
473
|
+
return null;
|
|
178
474
|
}
|
|
179
475
|
|
|
180
476
|
const config = readConfig();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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"]);
|
|
187
545
|
}
|
|
188
546
|
|
|
189
547
|
function uninstall(options) {
|
|
@@ -212,21 +570,42 @@ function main() {
|
|
|
212
570
|
return 0;
|
|
213
571
|
}
|
|
214
572
|
|
|
215
|
-
|
|
216
|
-
setup
|
|
217
|
-
|
|
218
|
-
|
|
573
|
+
try {
|
|
574
|
+
if (command === "setup") {
|
|
575
|
+
setup(args);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
219
578
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
579
|
+
if (command === "install-hermes") {
|
|
580
|
+
return installHermes();
|
|
581
|
+
}
|
|
223
582
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|
|
227
594
|
|
|
228
|
-
|
|
229
|
-
|
|
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;
|
|
230
609
|
}
|
|
231
610
|
|
|
232
611
|
console.error(`Unknown command: ${command}`);
|
package/package.json
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodus-wechat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI
|
|
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
10
|
"scripts": {
|
|
11
|
-
"test": "node --test"
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"release:check": "npm test && npm pack --dry-run",
|
|
13
|
+
"prepublishOnly": "npm test"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
12
17
|
},
|
|
13
18
|
"files": [
|
|
14
19
|
"bin",
|
|
20
|
+
"templates",
|
|
15
21
|
"README.md",
|
|
16
22
|
"LICENSE"
|
|
17
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,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,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,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
|
+
```
|