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/src/cli.js ADDED
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadConfig } from "./config.js";
6
+ import { Store } from "./store.js";
7
+ import { PolicyGuard } from "./policy.js";
8
+ import { BridgeHub, ensureDataLayout } from "./zalo_runtime.js";
9
+ import { createApp } from "./server.js";
10
+ import { sendDigest } from "./digest.js";
11
+ import { redactSecrets } from "./schema.js";
12
+ import { publicBrandMetadata } from "./brand.js";
13
+ import { onboardingFromEnv } from "./onboarding.js";
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = path.resolve(__dirname, "..");
17
+
18
+ function loadEnvFile() {
19
+ const envPath = path.join(ROOT, ".env");
20
+ if (!fs.existsSync(envPath)) return;
21
+ for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
22
+ const t = line.trim();
23
+ if (!t || t.startsWith("#")) continue;
24
+ const i = t.indexOf("=");
25
+ if (i < 0) continue;
26
+ const k = t.slice(0, i).trim();
27
+ let v = t.slice(i + 1).trim();
28
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
29
+ v = v.slice(1, -1);
30
+ }
31
+ if (process.env[k] == null) process.env[k] = v;
32
+ }
33
+ }
34
+
35
+ function boot() {
36
+ loadEnvFile();
37
+ const configPath = process.env.CONFIG_PATH || path.join(ROOT, "config.toml");
38
+ const config = loadConfig(configPath);
39
+ const dataDir = path.resolve(ROOT, process.env.DATA_DIR || "data");
40
+ ensureDataLayout(dataDir);
41
+ const store = new Store(dataDir, {
42
+ retainRawText: process.env.RETAIN_RAW_TEXT !== "false",
43
+ redactPhoneEmail: process.env.REDACT_PII !== "false",
44
+ });
45
+ store.ensureAccount(config.default_account_id);
46
+ store.seedFromConfig(config);
47
+ const policy = new PolicyGuard({ config, store });
48
+ const hub = new BridgeHub({ config, store, policy });
49
+ return { config, store, policy, hub, dataDir, configPath };
50
+ }
51
+
52
+ async function main() {
53
+ const cmd = process.argv[2] || "serve";
54
+ const ctx = boot();
55
+
56
+ if (cmd === "self-check") {
57
+ const issues = [];
58
+ const dbSources = ctx.store.listSources(ctx.config.default_account_id);
59
+ const dest = ctx.store.getDestination(ctx.config.default_account_id);
60
+ if (!dbSources.length) issues.push("allowlist_empty_fail_closed_ok");
61
+ if (!dest.group_id) issues.push("destination_unset_ok");
62
+ if (!fs.existsSync(path.join(ROOT, "public", "index.html"))) issues.push("dashboard_missing");
63
+ const snap = ctx.store.snapshot();
64
+ console.log(
65
+ JSON.stringify(
66
+ {
67
+ ok: issues.every((i) => i.endsWith("_ok") || i.includes("empty") || i.includes("unset")),
68
+ brand: publicBrandMetadata(),
69
+ issues,
70
+ default_account_id: ctx.config.default_account_id,
71
+ source_count: dbSources.length,
72
+ global_paused: ctx.store.isGlobalPaused(),
73
+ snapshot: redactSecrets(snap),
74
+ },
75
+ null,
76
+ 2,
77
+ ),
78
+ );
79
+ ctx.store.close();
80
+ process.exit(0);
81
+ }
82
+
83
+ if (cmd === "status") {
84
+ console.log(
85
+ JSON.stringify(
86
+ redactSecrets({
87
+ brand: publicBrandMetadata(),
88
+ accounts: ctx.hub.listStatus(),
89
+ destination: ctx.store.getDestination(ctx.config.default_account_id),
90
+ sources: ctx.store.listSources().length,
91
+ events: ctx.store.countEvents(),
92
+ global_paused: ctx.store.isGlobalPaused(),
93
+ }),
94
+ null,
95
+ 2,
96
+ ),
97
+ );
98
+ ctx.store.close();
99
+ return;
100
+ }
101
+
102
+ if (cmd === "qr" || cmd === "onboarding") {
103
+ console.log(
104
+ JSON.stringify(
105
+ {
106
+ ok: true,
107
+ ...onboardingFromEnv(process.env),
108
+ note: "Open connect_url in a browser, then press Kết nối / lấy QR. No QR image, token, or session is printed.",
109
+ },
110
+ null,
111
+ 2,
112
+ ),
113
+ );
114
+ ctx.store.close();
115
+ return;
116
+ }
117
+
118
+ if (cmd === "digest") {
119
+ const hours = Number(process.argv[3] || 24);
120
+ const result = await sendDigest({
121
+ config: ctx.config,
122
+ store: ctx.store,
123
+ policy: ctx.policy,
124
+ hub: ctx.hub,
125
+ accountId: ctx.config.default_account_id,
126
+ hours,
127
+ });
128
+ console.log(JSON.stringify(result, null, 2));
129
+ ctx.store.close();
130
+ return;
131
+ }
132
+
133
+ if (cmd === "backfill") {
134
+ const accountId = ctx.config.default_account_id;
135
+ const historyCount = Number(process.argv[3] || 50);
136
+ const runtime = ctx.hub.getRuntime(accountId);
137
+ // ensure connected via session
138
+ if (!runtime.api) {
139
+ try {
140
+ await runtime.connect({ forceQr: false });
141
+ } catch (err) {
142
+ console.error(JSON.stringify({ ok: false, error: String(err?.message || err) }));
143
+ ctx.store.close();
144
+ process.exit(1);
145
+ }
146
+ }
147
+ const { backfillAccountCorpus } = await import("./backfill.js");
148
+ const result = await backfillAccountCorpus({
149
+ api: runtime.api,
150
+ store: ctx.store,
151
+ accountId,
152
+ historyCount,
153
+ maxGroups: Number(process.env.BACKFILL_MAX_GROUPS || 200),
154
+ delayMs: Number(process.env.BACKFILL_DELAY_MS || 250),
155
+ });
156
+ console.log(
157
+ JSON.stringify(
158
+ {
159
+ ok: true,
160
+ result: { ...result, errors: (result.errors || []).slice(0, 40) },
161
+ corpus: {
162
+ messages: ctx.store.countEvents(accountId),
163
+ users: ctx.store.countUsers(accountId),
164
+ member_links: ctx.store.countSourceMembers(accountId),
165
+ sources: ctx.store.listSources(accountId).length,
166
+ },
167
+ },
168
+ null,
169
+ 2,
170
+ ),
171
+ );
172
+ try {
173
+ await runtime.pause();
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ // wait a tick so listener error callbacks don't hit closed db
178
+ await new Promise((r) => setTimeout(r, 300));
179
+ ctx.store.close();
180
+ process.exit(0);
181
+ }
182
+
183
+ if (cmd === "serve" || cmd === "start") {
184
+ const { KeepaliveSupervisor, loadKeepaliveOptions } = await import("./keepalive.js");
185
+ const keepaliveOpts = loadKeepaliveOptions(process.env);
186
+ const keepalive = new KeepaliveSupervisor({
187
+ config: ctx.config,
188
+ store: ctx.store,
189
+ policy: ctx.policy,
190
+ hub: ctx.hub,
191
+ accountId: ctx.config.default_account_id,
192
+ options: keepaliveOpts,
193
+ });
194
+ // Expose for health endpoints
195
+ ctx.keepalive = keepalive;
196
+
197
+ const app = createApp(ctx);
198
+ const host = process.env.HOST || ctx.config.dashboard_bind || "127.0.0.1";
199
+ const port = Number(process.env.PORT || ctx.config.dashboard_port || 3871);
200
+ const server = app.listen(port, host, () => {
201
+ console.log(
202
+ JSON.stringify({
203
+ event: "listening",
204
+ host,
205
+ port,
206
+ account: ctx.config.default_account_id,
207
+ sources: ctx.config.sources.length,
208
+ destination_set: Boolean(ctx.config.destination.group_id),
209
+ keepalive: true,
210
+ }),
211
+ );
212
+ });
213
+
214
+ // 24/7: auto-connect session + reconnect on drop. No deep-scroll.
215
+ keepalive.start();
216
+
217
+ const digestMinutes = Number(process.env.DIGEST_INTERVAL_MINUTES || 0);
218
+ let timer = null;
219
+ if (ctx.config.listener_only === false && digestMinutes > 0) {
220
+ timer = setInterval(() => {
221
+ sendDigest({
222
+ config: ctx.config,
223
+ store: ctx.store,
224
+ policy: ctx.policy,
225
+ hub: ctx.hub,
226
+ accountId: ctx.config.default_account_id,
227
+ hours: Math.max(1, digestMinutes / 60),
228
+ }).catch((err) => {
229
+ ctx.store.setHealth("last_digest_error", String(err?.message || err));
230
+ });
231
+ }, digestMinutes * 60 * 1000);
232
+ }
233
+
234
+ const shutdown = () => {
235
+ try {
236
+ keepalive.stop();
237
+ } catch {
238
+ /* ignore */
239
+ }
240
+ if (timer) clearInterval(timer);
241
+ server.close(() => {
242
+ ctx.store.close();
243
+ process.exit(0);
244
+ });
245
+ };
246
+ process.on("SIGINT", shutdown);
247
+ process.on("SIGTERM", shutdown);
248
+ return;
249
+ }
250
+
251
+ console.error(`unknown command: ${cmd}`);
252
+ process.exit(2);
253
+ }
254
+
255
+ // server.js re-export helper for tests that import createApp only
256
+ export { createApp };
257
+
258
+ const isDirect = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
259
+ if (isDirect || process.argv[1]?.endsWith("cli.js") || process.argv[1]?.endsWith("server.js")) {
260
+ // If invoked as server.js without cmd, serve.
261
+ if (path.basename(process.argv[1] || "") === "server.js" && !process.argv[2]) {
262
+ process.argv[2] = "serve";
263
+ }
264
+ main().catch((err) => {
265
+ console.error(String(err?.stack || err));
266
+ process.exit(1);
267
+ });
268
+ }
@@ -0,0 +1,265 @@
1
+ // Zalo slash-command router. Unauthorized => silent.
2
+ import { isValidMode } from "./policy.js";
3
+ import { sendDigest } from "./digest.js";
4
+
5
+ export function parseCommand(text) {
6
+ const raw = String(text || "").trim();
7
+ if (!raw.startsWith("/")) return null;
8
+ const body = raw.slice(1).trim();
9
+ if (!body) return null;
10
+ const parts = body.split(/\s+/);
11
+ const cmd = parts[0].toLowerCase();
12
+ const args = parts.slice(1);
13
+
14
+ // /digest now
15
+ if (cmd === "digest" && args[0]?.toLowerCase() === "now") {
16
+ return { command: "digest", args: ["now"], raw };
17
+ }
18
+ // /set destination <id>
19
+ if (cmd === "set" && args[0]?.toLowerCase() === "destination") {
20
+ return { command: "set", args: ["destination", ...args.slice(1)], raw };
21
+ }
22
+ // /allow source <id>
23
+ if (cmd === "allow" && args[0]?.toLowerCase() === "source") {
24
+ return { command: "allow", args: ["source", ...args.slice(1)], raw };
25
+ }
26
+ // /add admin <id> | /remove admin <id>
27
+ if ((cmd === "add" || cmd === "remove") && args[0]?.toLowerCase() === "admin") {
28
+ return { command: cmd, args: ["admin", ...args.slice(1)], raw };
29
+ }
30
+ return { command: cmd, args, raw };
31
+ }
32
+
33
+ export async function handleCommand({
34
+ event,
35
+ store,
36
+ policy,
37
+ hub,
38
+ config,
39
+ }) {
40
+ const parsed = parseCommand(event.text);
41
+ if (!parsed) return { handled: false };
42
+
43
+ const accountId = event.account_id;
44
+ const senderId = event.sender_id;
45
+ const gate = policy.evaluateCommand({
46
+ accountId,
47
+ senderId,
48
+ command: parsed.command,
49
+ sourceId: event.source_id,
50
+ });
51
+ if (!gate.allow) {
52
+ store.audit({
53
+ accountId,
54
+ actorId: senderId,
55
+ action: "command_denied",
56
+ detail: parsed.raw,
57
+ });
58
+ return { handled: true, silent: true, reason: gate.reason };
59
+ }
60
+
61
+ const reply = async (text) => {
62
+ // HARD: only destination group. Never source/DM.
63
+ const dest = store.getDestination(accountId);
64
+ if (!dest.group_id) {
65
+ return { ok: false, reason: "destination_unset", text, local_only: true };
66
+ }
67
+ const targetId = dest.group_id;
68
+ const decision = policy.evaluateOutbound({
69
+ accountId,
70
+ targetId,
71
+ text,
72
+ kind: "command_reply",
73
+ });
74
+ if (!decision.allow) {
75
+ return { ok: false, reason: decision.reason, text };
76
+ }
77
+ try {
78
+ const runtime = hub.getRuntime(accountId);
79
+ if (runtime.api || process.env.ALLOW_FAKE_SEND === "true") {
80
+ if (runtime.api) await runtime.sendText(targetId, text, 1);
81
+ }
82
+ store.logOutbound({
83
+ accountId,
84
+ targetId,
85
+ kind: "command_reply",
86
+ textSha: decision.textSha || "cmd",
87
+ ok: true,
88
+ reason: "sent",
89
+ });
90
+ return { ok: true, text, targetId };
91
+ } catch (err) {
92
+ store.logOutbound({
93
+ accountId,
94
+ targetId,
95
+ kind: "command_reply",
96
+ textSha: "cmd",
97
+ ok: false,
98
+ reason: String(err?.message || err),
99
+ });
100
+ return { ok: false, reason: String(err?.message || err), text };
101
+ }
102
+ };
103
+
104
+ let message = "";
105
+ switch (parsed.command) {
106
+ case "help":
107
+ message = [
108
+ "Zalo Personal Intelligence Bridge",
109
+ "/status /pause /resume /digest now /help /whoami",
110
+ "/set destination <group_id>",
111
+ "/allow source <source_id>",
112
+ "/mode <source_id> <mode>",
113
+ "/mute <source_id>",
114
+ "/add admin <user_id> · /remove admin <user_id>",
115
+ "Config nặng nên làm trên dashboard.",
116
+ ].join("\n");
117
+ break;
118
+ case "whoami":
119
+ message = `user=${senderId} role=${policy.roleOf(accountId, senderId) || "none"} account=${accountId}`;
120
+ break;
121
+ case "status": {
122
+ const acc = store.getAccount(accountId);
123
+ const dest = store.getDestination(accountId);
124
+ message = [
125
+ `account=${accountId}`,
126
+ `status=${acc?.status || "unknown"} paused=${Boolean(acc?.paused)}`,
127
+ `global_kill=${store.isGlobalPaused()}`,
128
+ `sources=${store.listSources(accountId).length}`,
129
+ `events=${store.countEvents(accountId)}`,
130
+ `destination=${dest.group_id || "(unset)"}`,
131
+ ].join("\n");
132
+ break;
133
+ }
134
+ case "pause": {
135
+ await hub.getRuntime(accountId).pause();
136
+ store.setGlobalPaused(true);
137
+ store.audit({ accountId, actorId: senderId, action: "pause", detail: "global+account" });
138
+ message = "paused";
139
+ break;
140
+ }
141
+ case "resume": {
142
+ store.setGlobalPaused(false);
143
+ store.setAccountStatus(accountId, "connected");
144
+ store.audit({ accountId, actorId: senderId, action: "resume", detail: "global" });
145
+ message = "resumed (global kill off). Reconnect listener if needed.";
146
+ break;
147
+ }
148
+ case "digest": {
149
+ const result = await sendDigest({
150
+ config,
151
+ store,
152
+ policy,
153
+ hub,
154
+ accountId,
155
+ hours: 24,
156
+ reportType: "manual",
157
+ });
158
+ message = result.ok
159
+ ? `digest sent · events=${result.event_count}`
160
+ : `digest blocked · ${result.reason}`;
161
+ break;
162
+ }
163
+ case "set": {
164
+ if (parsed.args[0] === "destination") {
165
+ const gid = parsed.args[1];
166
+ if (!gid) {
167
+ message = "usage: /set destination <group_id>";
168
+ break;
169
+ }
170
+ store.setDestination(accountId, gid, "");
171
+ store.audit({ accountId, actorId: senderId, action: "set_destination", detail: gid });
172
+ message = `destination=${gid}`;
173
+ } else {
174
+ message = "usage: /set destination <group_id>";
175
+ }
176
+ break;
177
+ }
178
+ case "allow": {
179
+ if (parsed.args[0] === "source") {
180
+ const sid = parsed.args[1];
181
+ if (!sid) {
182
+ message = "usage: /allow source <source_id>";
183
+ break;
184
+ }
185
+ store.upsertSource({
186
+ accountId,
187
+ sourceId: sid,
188
+ mode: "listen_only",
189
+ isAllowed: true,
190
+ });
191
+ store.audit({ accountId, actorId: senderId, action: "allow_source", detail: sid });
192
+ message = `allowed source=${sid} mode=listen_only`;
193
+ } else message = "usage: /allow source <source_id>";
194
+ break;
195
+ }
196
+ case "mode": {
197
+ const sid = parsed.args[0];
198
+ const mode = parsed.args[1];
199
+ if (!sid || !isValidMode(mode)) {
200
+ message =
201
+ "usage: /mode <source_id> <off|listen_only|digest_only|alert_only|mention_only|reply_enabled>";
202
+ break;
203
+ }
204
+ store.setSourceMode(accountId, sid, mode);
205
+ store.audit({ accountId, actorId: senderId, action: "set_mode", detail: `${sid}:${mode}` });
206
+ message = `source=${sid} mode=${mode}`;
207
+ break;
208
+ }
209
+ case "mute": {
210
+ const sid = parsed.args[0];
211
+ if (!sid) {
212
+ message = "usage: /mute <source_id>";
213
+ break;
214
+ }
215
+ store.muteSource(accountId, sid, true);
216
+ store.audit({ accountId, actorId: senderId, action: "mute", detail: sid });
217
+ message = `muted ${sid}`;
218
+ break;
219
+ }
220
+ case "add": {
221
+ if (parsed.args[0] === "admin") {
222
+ const uid = parsed.args[1];
223
+ if (!uid) {
224
+ message = "usage: /add admin <user_id>";
225
+ break;
226
+ }
227
+ store.upsertPermission({ accountId, userId: uid, role: "admin" });
228
+ store.audit({ accountId, actorId: senderId, action: "add_admin", detail: uid });
229
+ message = `admin added ${uid}`;
230
+ } else message = "usage: /add admin <user_id>";
231
+ break;
232
+ }
233
+ case "remove": {
234
+ if (parsed.args[0] === "admin") {
235
+ const uid = parsed.args[1];
236
+ if (!uid) {
237
+ message = "usage: /remove admin <user_id>";
238
+ break;
239
+ }
240
+ const perm = store.getPermission(accountId, uid);
241
+ if (perm?.role === "owner") {
242
+ message = "cannot remove owner via this command";
243
+ break;
244
+ }
245
+ store.removePermission(accountId, uid);
246
+ store.audit({ accountId, actorId: senderId, action: "remove_admin", detail: uid });
247
+ message = `removed ${uid}`;
248
+ } else message = "usage: /remove admin <user_id>";
249
+ break;
250
+ }
251
+ default:
252
+ // unknown command from authorized user: short help, still silent for unauthorized already handled
253
+ message = "unknown command · /help";
254
+ }
255
+
256
+ store.audit({
257
+ accountId,
258
+ actorId: senderId,
259
+ action: "command",
260
+ detail: `${parsed.command} -> ${message.slice(0, 200)}`,
261
+ });
262
+
263
+ const sent = await reply(message);
264
+ return { handled: true, silent: false, message, sent };
265
+ }