abs-zalo-bot 0.2.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/.env.example +48 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/SECURITY.md +22 -0
- package/config/bots.example.json +33 -0
- package/config.toml +48 -0
- package/mcp/run.sh +7 -0
- package/mcp/server.js +255 -0
- package/package.json +76 -0
- package/scripts/battle-smoke.js +118 -0
- package/scripts/corpus-stats.js +23 -0
- package/scripts/probe-history.js +76 -0
- package/scripts/public-gate.js +216 -0
- package/scripts/setup.js +233 -0
- package/src/abs_telemetry.js +60 -0
- package/src/ask.js +7 -0
- package/src/backfill.js +364 -0
- package/src/bot_registry.js +180 -0
- package/src/brand.js +31 -0
- package/src/cli.js +268 -0
- package/src/commands.js +265 -0
- package/src/config.js +223 -0
- package/src/digest.js +330 -0
- package/src/discovery.js +185 -0
- package/src/hermes_client.js +302 -0
- package/src/inbound_router.js +295 -0
- package/src/keepalive.js +375 -0
- package/src/oa_adapter.js +284 -0
- package/src/oa_auto_reply.js +75 -0
- package/src/oa_policy.js +32 -0
- package/src/oa_webhook.js +95 -0
- package/src/onboarding.js +102 -0
- package/src/ops_report.js +135 -0
- package/src/policy.js +320 -0
- package/src/privacy.js +38 -0
- package/src/schema.js +163 -0
- package/src/server.js +604 -0
- package/src/store.js +972 -0
- package/src/zalo_runtime.js +480 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline release gates for the public starter.
|
|
4
|
+
*
|
|
5
|
+
* The scanner intentionally ignores local runtime state (.env, data, sessions,
|
|
6
|
+
* QR images, databases, logs, node_modules). Those paths are checked by the
|
|
7
|
+
* repository ignore rules and must never be staged for a release.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import { loadConfig } from "../src/config.js";
|
|
15
|
+
import { validateBotRegistry } from "../src/bot_registry.js";
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
const IGNORED_DIRS = new Set([".git", "node_modules", "data", "coverage"]);
|
|
19
|
+
const IGNORED_FILES = new Set([".env", "bots.json"]);
|
|
20
|
+
const SAFE_ENV_FILES = new Set([".env.example"]);
|
|
21
|
+
const TEXT_EXTENSIONS = new Set([
|
|
22
|
+
".cjs",
|
|
23
|
+
".css",
|
|
24
|
+
".html",
|
|
25
|
+
".js",
|
|
26
|
+
".json",
|
|
27
|
+
".md",
|
|
28
|
+
".mjs",
|
|
29
|
+
".service",
|
|
30
|
+
".sh",
|
|
31
|
+
".toml",
|
|
32
|
+
".ts",
|
|
33
|
+
".txt",
|
|
34
|
+
".yml",
|
|
35
|
+
".yaml",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function walk(dir, out = []) {
|
|
39
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
40
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
41
|
+
if (entry.isFile() && IGNORED_FILES.has(entry.name) && !SAFE_ENV_FILES.has(entry.name)) continue;
|
|
42
|
+
const absolute = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) walk(absolute, out);
|
|
44
|
+
else out.push(absolute);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function relative(file) {
|
|
50
|
+
return path.relative(ROOT, file) || ".";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function publicFiles() {
|
|
54
|
+
return walk(ROOT).filter((file) => {
|
|
55
|
+
const name = path.basename(file);
|
|
56
|
+
if (IGNORED_FILES.has(name) && !SAFE_ENV_FILES.has(name)) return false;
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readText(file) {
|
|
62
|
+
const ext = path.extname(file).toLowerCase();
|
|
63
|
+
if (!TEXT_EXTENSIONS.has(ext) && path.basename(file) !== ".gitignore") return null;
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(file, "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fail(message, details = []) {
|
|
72
|
+
const payload = { ok: false, error: message };
|
|
73
|
+
if (details.length) payload.details = details;
|
|
74
|
+
console.error(JSON.stringify(payload, null, 2));
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pass(name, details = {}) {
|
|
79
|
+
console.log(JSON.stringify({ ok: true, check: name, ...details }, null, 2));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateConfig() {
|
|
83
|
+
const packagePath = path.join(ROOT, "package.json");
|
|
84
|
+
const registryPath = path.join(ROOT, "config", "bots.example.json");
|
|
85
|
+
let pkg;
|
|
86
|
+
let registry;
|
|
87
|
+
try {
|
|
88
|
+
pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
89
|
+
registry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
fail("public_config_parse_failed", [String(error?.message || error)]);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let normalized;
|
|
96
|
+
let config;
|
|
97
|
+
try {
|
|
98
|
+
normalized = validateBotRegistry(registry);
|
|
99
|
+
config = loadConfig(path.join(ROOT, "config.toml"));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
fail("public_config_validation_failed", [String(error?.message || error)]);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const issues = [];
|
|
106
|
+
// This package is intentionally publishable to npm. Instead of forbidding
|
|
107
|
+
// publish outright, enforce that a publish cannot happen half-configured:
|
|
108
|
+
// metadata must be complete and the tarball allowlist must be explicit,
|
|
109
|
+
// so runtime/secret paths can never be shipped by accident.
|
|
110
|
+
if (pkg.private === true) issues.push("package_marked_private_but_publish_metadata_expected");
|
|
111
|
+
if (!pkg.license) issues.push("package_license_required");
|
|
112
|
+
if (!pkg.repository?.url) issues.push("package_repository_url_required");
|
|
113
|
+
if (!Array.isArray(pkg.files) || pkg.files.length === 0) {
|
|
114
|
+
issues.push("package_files_allowlist_required");
|
|
115
|
+
} else {
|
|
116
|
+
const forbidden = ["data", "data/", ".env", "test", "test/", "config/bots.json"];
|
|
117
|
+
const leaked = pkg.files.filter((entry) =>
|
|
118
|
+
forbidden.some((bad) => entry === bad || entry.startsWith(`${bad}/`)),
|
|
119
|
+
);
|
|
120
|
+
if (leaked.length) issues.push(`package_files_must_not_ship_runtime_paths:${leaked.join(",")}`);
|
|
121
|
+
}
|
|
122
|
+
if (config.read_only_source !== true) issues.push("example_must_enable_read_only_source");
|
|
123
|
+
if (config.destination.group_id) issues.push("example_destination_id_must_be_empty");
|
|
124
|
+
if (normalized.bots.some((bot) => bot.policy.mode !== "draft_first")) {
|
|
125
|
+
issues.push("example_oa_policy_must_be_draft_first");
|
|
126
|
+
}
|
|
127
|
+
if (normalized.bots.some((bot) => bot.adapter === "zalo_oa" && !bot.credential)) {
|
|
128
|
+
issues.push("oa_credential_references_missing");
|
|
129
|
+
}
|
|
130
|
+
if (issues.length) {
|
|
131
|
+
fail("public_config_policy_failed", issues);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pass("validate-config", {
|
|
136
|
+
package: { name: pkg.name, version: pkg.version, node: pkg.engines?.node || "" },
|
|
137
|
+
registry_bots: normalized.bots.length,
|
|
138
|
+
default_account_id: config.default_account_id,
|
|
139
|
+
destination_configured: Boolean(config.destination.group_id),
|
|
140
|
+
read_only_source: config.read_only_source,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function secretScan() {
|
|
145
|
+
const findings = [];
|
|
146
|
+
const files = publicFiles();
|
|
147
|
+
const privateKey = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
|
|
148
|
+
const providerToken = /\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/;
|
|
149
|
+
const longNumericId = /\b\d{15,22}\b/;
|
|
150
|
+
const vnPhoneLiteral = /(?<![\d])(?:\+?84|0)\d{8,10}(?!\d)/;
|
|
151
|
+
const assignment = /^\s*(?:DASHBOARD_TOKEN|HERMES_API_SERVER_KEY|HERMES_WEBHOOK_TOKEN|ZALO_OA_WEBHOOK_SECRET)\s*=\s*([^#\s]+)/;
|
|
152
|
+
const allowedPlaceholder = /^(?:$|change-me|<[^>]+>|\[REDACTED\]|\[redacted\]|[A-Za-z0-9._-]*fixture[A-Za-z0-9._-]*)$/i;
|
|
153
|
+
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
const text = readText(file);
|
|
156
|
+
if (text == null) continue;
|
|
157
|
+
const lines = text.split(/\r?\n/);
|
|
158
|
+
lines.forEach((line, index) => {
|
|
159
|
+
const lineNumber = index + 1;
|
|
160
|
+
if (privateKey.test(line) || providerToken.test(line)) {
|
|
161
|
+
findings.push(`${relative(file)}:${lineNumber}:credential-pattern`);
|
|
162
|
+
}
|
|
163
|
+
if (longNumericId.test(line)) {
|
|
164
|
+
findings.push(`${relative(file)}:${lineNumber}:long-numeric-identifier`);
|
|
165
|
+
}
|
|
166
|
+
if (vnPhoneLiteral.test(line)) {
|
|
167
|
+
findings.push(`${relative(file)}:${lineNumber}:phone-like-literal`);
|
|
168
|
+
}
|
|
169
|
+
const match = line.match(assignment);
|
|
170
|
+
if (match && !allowedPlaceholder.test(match[1])) {
|
|
171
|
+
findings.push(`${relative(file)}:${lineNumber}:non-placeholder-secret-assignment`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const envPath = path.join(ROOT, ".env");
|
|
177
|
+
const ignoredRuntimePresent = [];
|
|
178
|
+
if (fs.existsSync(envPath)) ignoredRuntimePresent.push(".env");
|
|
179
|
+
if (fs.existsSync(path.join(ROOT, "data"))) ignoredRuntimePresent.push("data/");
|
|
180
|
+
|
|
181
|
+
if (findings.length) {
|
|
182
|
+
fail("public_secret_scan_failed", findings);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
pass("secret-scan", {
|
|
186
|
+
files_scanned: files.length,
|
|
187
|
+
runtime_paths_ignored: ignoredRuntimePresent,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function syntaxCheck() {
|
|
192
|
+
const files = publicFiles().filter((file) => path.extname(file) === ".js");
|
|
193
|
+
const failures = [];
|
|
194
|
+
for (const file of files) {
|
|
195
|
+
const result = spawnSync(process.execPath, ["--check", file], {
|
|
196
|
+
cwd: ROOT,
|
|
197
|
+
encoding: "utf8",
|
|
198
|
+
});
|
|
199
|
+
if (result.status !== 0) {
|
|
200
|
+
failures.push({ file: relative(file), output: String(result.stderr || result.stdout || "").trim().slice(0, 500) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (failures.length) {
|
|
204
|
+
fail("javascript_syntax_failed", failures);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
pass("syntax-check", { javascript_files: files.length });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const command = process.argv[2] || "";
|
|
211
|
+
if (command === "validate-config") validateConfig();
|
|
212
|
+
else if (command === "secret-scan") secretScan();
|
|
213
|
+
else if (command === "syntax-check") syntaxCheck();
|
|
214
|
+
else {
|
|
215
|
+
fail("usage", ["node scripts/public-gate.js validate-config|secret-scan|syntax-check"]);
|
|
216
|
+
}
|
package/scripts/setup.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Friendly local setup/doctor entrypoint.
|
|
4
|
+
*
|
|
5
|
+
* Safe by design:
|
|
6
|
+
* - never overwrites .env or config/bots.json;
|
|
7
|
+
* - never asks for or prints secrets;
|
|
8
|
+
* - never logs in, scans QR, enters OTP/PIN, or sends a message;
|
|
9
|
+
* - runs deterministic local verification before telling the operator to start.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { buildOnboardingInfo } from "../src/onboarding.js";
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
const MIN_NODE = { major: 22, minor: 5 };
|
|
19
|
+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const flags = new Set();
|
|
23
|
+
let command = "setup";
|
|
24
|
+
for (const arg of argv) {
|
|
25
|
+
if (arg.startsWith("--")) flags.add(arg);
|
|
26
|
+
else if (!command || command === "setup") command = arg;
|
|
27
|
+
}
|
|
28
|
+
return { command, flags };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nodeVersion() {
|
|
32
|
+
const match = process.versions.node.match(/^(\d+)\.(\d+)/);
|
|
33
|
+
return match ? { major: Number(match[1]), minor: Number(match[2]), raw: process.versions.node } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureNode() {
|
|
37
|
+
const version = nodeVersion();
|
|
38
|
+
if (!version || version.major < MIN_NODE.major || (version.major === MIN_NODE.major && version.minor < MIN_NODE.minor)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Node.js ${MIN_NODE.major}.${MIN_NODE.minor}+ is required; detected ${version?.raw || process.version}. Install a current Node.js LTS and run setup again.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
console.log(`PASS Node.js ${version.raw}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function exists(relativePath) {
|
|
47
|
+
return fs.existsSync(path.join(ROOT, relativePath));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureDir(relativePath, mode = 0o700) {
|
|
51
|
+
const absolute = path.join(ROOT, relativePath);
|
|
52
|
+
fs.mkdirSync(absolute, { recursive: true, mode });
|
|
53
|
+
try {
|
|
54
|
+
fs.chmodSync(absolute, mode);
|
|
55
|
+
} catch {
|
|
56
|
+
// Windows and restricted filesystems may not support chmod; the directory
|
|
57
|
+
// still exists and the operator gets a concrete path to inspect.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function copyIfMissing(sourceRelative, targetRelative, mode = 0o600) {
|
|
62
|
+
const source = path.join(ROOT, sourceRelative);
|
|
63
|
+
const target = path.join(ROOT, targetRelative);
|
|
64
|
+
if (fs.existsSync(target)) {
|
|
65
|
+
console.log(`KEEP ${targetRelative} already exists (not overwritten)`);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (!fs.existsSync(source)) throw new Error(`Missing public template: ${sourceRelative}`);
|
|
69
|
+
fs.copyFileSync(source, target);
|
|
70
|
+
try {
|
|
71
|
+
fs.chmodSync(target, mode);
|
|
72
|
+
} catch {
|
|
73
|
+
// Best effort on platforms without POSIX permissions.
|
|
74
|
+
}
|
|
75
|
+
console.log(`CREATE ${targetRelative}`);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ensureLocalFiles() {
|
|
80
|
+
copyIfMissing(".env.example", ".env", 0o600);
|
|
81
|
+
copyIfMissing("config/bots.example.json", "config/bots.json", 0o600);
|
|
82
|
+
ensureDir("data", 0o700);
|
|
83
|
+
ensureDir("data/sessions", 0o700);
|
|
84
|
+
ensureDir("data/qr", 0o700);
|
|
85
|
+
ensureDir("data/logs", 0o700);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function run(label, command, args, { allowFailure = false } = {}) {
|
|
89
|
+
console.log(`RUN ${label}`);
|
|
90
|
+
const result = spawnSync(command, args, {
|
|
91
|
+
cwd: ROOT,
|
|
92
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
93
|
+
stdio: "inherit",
|
|
94
|
+
});
|
|
95
|
+
if (result.error) throw result.error;
|
|
96
|
+
const code = Number.isInteger(result.status) ? result.status : 1;
|
|
97
|
+
console.log(`${code === 0 ? "PASS" : "FAIL"} ${label} (exit ${code})`);
|
|
98
|
+
if (code !== 0 && !allowFailure) {
|
|
99
|
+
throw new Error(`${label} failed with exit code ${code}`);
|
|
100
|
+
}
|
|
101
|
+
return code;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function npmRun(script, options = {}) {
|
|
105
|
+
return run(`npm run ${script}`, npmCommand, ["run", script], options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkExpectedFiles() {
|
|
109
|
+
const required = [
|
|
110
|
+
".env.example",
|
|
111
|
+
"config/bots.example.json",
|
|
112
|
+
"config.toml",
|
|
113
|
+
"public/index.html",
|
|
114
|
+
"package-lock.json",
|
|
115
|
+
];
|
|
116
|
+
const missing = required.filter((file) => !exists(file));
|
|
117
|
+
if (missing.length) throw new Error(`Missing required files: ${missing.join(", ")}`);
|
|
118
|
+
console.log(`PASS Required public files (${required.length})`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readLocalEnvValue(key) {
|
|
122
|
+
if (!exists(".env")) return "";
|
|
123
|
+
const text = fs.readFileSync(path.join(ROOT, ".env"), "utf8");
|
|
124
|
+
const line = text.split(/\r?\n/).find((item) => item.trim().startsWith(`${key}=`));
|
|
125
|
+
if (!line) return "";
|
|
126
|
+
return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function localStatus() {
|
|
130
|
+
const envPresent = exists(".env");
|
|
131
|
+
const botsPresent = exists("config/bots.json");
|
|
132
|
+
const dataPresent = exists("data");
|
|
133
|
+
const token = readLocalEnvValue("DASHBOARD_TOKEN");
|
|
134
|
+
const tokenConfigured = Boolean(token && token !== "change-me");
|
|
135
|
+
return { envPresent, botsPresent, dataPresent, tokenConfigured };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function printDashboardInfo() {
|
|
139
|
+
const env = exists(".env") ? fs.readFileSync(path.join(ROOT, ".env"), "utf8") : "";
|
|
140
|
+
const readEnv = (key, fallback) => {
|
|
141
|
+
const line = env.split(/\r?\n/).find((item) => item.trim().startsWith(`${key}=`));
|
|
142
|
+
return line ? line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, "") || fallback : fallback;
|
|
143
|
+
};
|
|
144
|
+
const host = readEnv("HOST", "127.0.0.1");
|
|
145
|
+
const port = readEnv("PORT", "3871");
|
|
146
|
+
const token = readEnv("DASHBOARD_TOKEN", "");
|
|
147
|
+
const onboarding = buildOnboardingInfo({
|
|
148
|
+
host,
|
|
149
|
+
port,
|
|
150
|
+
accountId: readEnv("DEFAULT_ACCOUNT_ID", "default"),
|
|
151
|
+
localPort: readEnv("QR_LOCAL_PORT", "13871"),
|
|
152
|
+
sshUser: readEnv("DASHBOARD_SSH_USER", "user"),
|
|
153
|
+
sshHost: readEnv("DASHBOARD_SSH_HOST", "your-vps-host"),
|
|
154
|
+
publicUrl: readEnv("DASHBOARD_PUBLIC_URL", ""),
|
|
155
|
+
});
|
|
156
|
+
console.log(JSON.stringify({
|
|
157
|
+
ok: true,
|
|
158
|
+
bind: `${host}:${port}`,
|
|
159
|
+
dashboard_url: onboarding.dashboard_url,
|
|
160
|
+
connect_url: onboarding.connect_url,
|
|
161
|
+
qr_api_url: onboarding.qr_api_url,
|
|
162
|
+
vps: onboarding.vps,
|
|
163
|
+
dashboard_token_configured: Boolean(token && token !== "change-me"),
|
|
164
|
+
note: "Open connect_url in a browser. URLs only; no QR image or token value is printed.",
|
|
165
|
+
}, null, 2));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function doctor() {
|
|
169
|
+
ensureNode();
|
|
170
|
+
checkExpectedFiles();
|
|
171
|
+
const local = localStatus();
|
|
172
|
+
const issues = [];
|
|
173
|
+
if (!local.envPresent) issues.push(".env_missing_run_setup");
|
|
174
|
+
if (!local.botsPresent) issues.push("config/bots.json_missing_run_setup");
|
|
175
|
+
if (!local.dataPresent) issues.push("data_missing_run_setup");
|
|
176
|
+
if (!local.tokenConfigured) issues.push("dashboard_token_not_configured_local_only_warning");
|
|
177
|
+
console.log(JSON.stringify({
|
|
178
|
+
ok: issues.every((issue) => issue.endsWith("warning")),
|
|
179
|
+
local: {
|
|
180
|
+
env_present: local.envPresent,
|
|
181
|
+
bots_config_present: local.botsPresent,
|
|
182
|
+
data_present: local.dataPresent,
|
|
183
|
+
dashboard_token_configured: local.tokenConfigured,
|
|
184
|
+
},
|
|
185
|
+
issues,
|
|
186
|
+
}, null, 2));
|
|
187
|
+
if (issues.some((issue) => issue.includes("missing"))) return 1;
|
|
188
|
+
|
|
189
|
+
npmRun("validate-config");
|
|
190
|
+
npmRun("secret-scan");
|
|
191
|
+
npmRun("syntax-check");
|
|
192
|
+
npmRun("self-check");
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function setup(flags) {
|
|
197
|
+
ensureNode();
|
|
198
|
+
checkExpectedFiles();
|
|
199
|
+
ensureLocalFiles();
|
|
200
|
+
if (!flags.has("--skip-install")) npmRun("install:locked");
|
|
201
|
+
npmRun("test");
|
|
202
|
+
npmRun("validate-config");
|
|
203
|
+
npmRun("secret-scan");
|
|
204
|
+
npmRun("syntax-check");
|
|
205
|
+
npmRun("self-check");
|
|
206
|
+
printDashboardInfo();
|
|
207
|
+
console.log("\nSetup complete. No login, QR scan, OTP/PIN entry, or message send was performed.");
|
|
208
|
+
console.log("Next: npm start, then open the dashboard URL above and scan QR manually if using Personal QR.");
|
|
209
|
+
if (flags.has("--start")) {
|
|
210
|
+
console.log("\nStarting the foreground server. Press Ctrl+C to stop it.");
|
|
211
|
+
return run("npm start", npmCommand, ["start"]);
|
|
212
|
+
}
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function main() {
|
|
217
|
+
const { command, flags } = parseArgs(process.argv.slice(2));
|
|
218
|
+
try {
|
|
219
|
+
if (command === "setup" || command === "install") process.exitCode = setup(flags);
|
|
220
|
+
else if (command === "doctor") process.exitCode = doctor();
|
|
221
|
+
else if (command === "dashboard-info") printDashboardInfo();
|
|
222
|
+
else {
|
|
223
|
+
console.error("Usage: node scripts/setup.js [setup|doctor|dashboard-info] [--non-interactive] [--skip-install] [--start]");
|
|
224
|
+
process.exitCode = 2;
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error(`\nSETUP BLOCKED: ${String(error?.message || error)}`);
|
|
228
|
+
console.error("No credential, OTP/PIN, QR login, or live message action was attempted.");
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
main();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Privacy-minimal Zalo -> ABS adapter. This is not a reply mechanism and
|
|
2
|
+
// does not read, persist, or forward message content. It records only the
|
|
3
|
+
// outcome of an explicit Zalo-to-brain turn, fail-open.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
// Đường dẫn ledger do người triển khai khai báo qua ABS_LEDGER_PATH.
|
|
9
|
+
// Không hardcode đường dẫn máy chủ nào: repo public phải chạy được ở mọi nơi.
|
|
10
|
+
|
|
11
|
+
function durationBucket(value) {
|
|
12
|
+
const milliseconds = Number(value || 0);
|
|
13
|
+
if (!Number.isFinite(milliseconds) || milliseconds < 0) return "unknown";
|
|
14
|
+
if (milliseconds < 100) return "lt_100ms";
|
|
15
|
+
if (milliseconds < 1_000) return "lt_1s";
|
|
16
|
+
if (milliseconds < 10_000) return "lt_10s";
|
|
17
|
+
return "gte_10s";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ledgerPath() {
|
|
21
|
+
const configured = process.env.ABS_LEDGER_PATH || "";
|
|
22
|
+
for (const candidate of [configured]) {
|
|
23
|
+
if (candidate && fs.existsSync(candidate)) return candidate;
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a single local-only Zalo brain outcome. Raw source_id is provided only
|
|
30
|
+
* through stdin to the Python ledger, which salts it before SQLite insertion.
|
|
31
|
+
*/
|
|
32
|
+
export function recordZaloBrainTurn({ event, outcome = "unknown", durationMs, runner = spawnSync }) {
|
|
33
|
+
try {
|
|
34
|
+
const rawChatId = String(event?.source_id || "");
|
|
35
|
+
const conversationType = String(event?.source_type || "unknown");
|
|
36
|
+
const ledger = ledgerPath();
|
|
37
|
+
if (!ledger || !rawChatId || !["dm", "group"].includes(conversationType)) {
|
|
38
|
+
return { ok: false, reason: "telemetry_unavailable" };
|
|
39
|
+
}
|
|
40
|
+
const input = JSON.stringify({
|
|
41
|
+
event_type: "zalo_brain_turn",
|
|
42
|
+
platform: "zalo",
|
|
43
|
+
raw_chat_id: rawChatId,
|
|
44
|
+
conversation_type: conversationType,
|
|
45
|
+
metadata: {
|
|
46
|
+
outcome: outcome === "success" ? "success" : "non_success",
|
|
47
|
+
duration_bucket: durationBucket(durationMs),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const child = runner("python3", [ledger, "record-stdin"], {
|
|
51
|
+
input,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
54
|
+
timeout: 2_000,
|
|
55
|
+
});
|
|
56
|
+
return { ok: child?.status === 0, reason: child?.status === 0 ? "recorded" : "telemetry_failed" };
|
|
57
|
+
} catch {
|
|
58
|
+
return { ok: false, reason: "telemetry_failed" };
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/ask.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Legacy ask helpers kept for tests/MCP; Phase-1 brain path lives in inbound_router.js
|
|
2
|
+
export {
|
|
3
|
+
isBotCommand,
|
|
4
|
+
isBotCommand as looksLikeAsk,
|
|
5
|
+
handleBotBrainCommand as handleDestinationAsk,
|
|
6
|
+
} from "./inbound_router.js";
|
|
7
|
+
export { resolveDestinationByName } from "./discovery.js";
|