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.
@@ -0,0 +1,480 @@
1
+ // Zalo adapter. Live zca-js is lazy-loaded. Tests inject a fake client.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { EventEmitter } from "node:events";
5
+ import { normalizeInboundMessage, utcNow } from "./schema.js";
6
+
7
+ export class AccountRuntime extends EventEmitter {
8
+ constructor({ accountId, store, policy, onEvent, clientFactory = null }) {
9
+ super();
10
+ this.accountId = String(accountId);
11
+ this.store = store;
12
+ this.policy = policy;
13
+ this.onEvent = onEvent;
14
+ this.clientFactory = clientFactory;
15
+ this.api = null;
16
+ this.qr = null; // { image, code, updated_at }
17
+ this.loginPromise = null;
18
+ this.lastMessageAt = null;
19
+ this.listenerWired = false;
20
+ }
21
+
22
+ status() {
23
+ const row = this.store.getAccount(this.accountId) || { status: "disconnected" };
24
+ return {
25
+ account_id: this.accountId,
26
+ status: row.status,
27
+ display_name: row.display_name,
28
+ zalo_user_id: row.zalo_user_id,
29
+ last_error: row.last_error,
30
+ has_session: this.store.hasSession(this.accountId),
31
+ qr_available: Boolean(this.qr?.image),
32
+ updated_at: row.updated_at,
33
+ };
34
+ }
35
+
36
+ async connect({ forceQr = false } = {}) {
37
+ if (forceQr) {
38
+ try {
39
+ this.qrAbort?.();
40
+ } catch {
41
+ /* ignore */
42
+ }
43
+ this.qrAbort = null;
44
+ this.api = null;
45
+ this.qr = null;
46
+ // wait prior login attempt to settle if any
47
+ if (this.loginPromise) {
48
+ try {
49
+ await Promise.race([
50
+ this.loginPromise,
51
+ new Promise((r) => setTimeout(r, 500)),
52
+ ]);
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ this.loginPromise = null;
57
+ }
58
+ }
59
+ if (this.loginPromise) return this.loginPromise;
60
+ this.loginPromise = this.#connectInner({ forceQr }).finally(() => {
61
+ this.loginPromise = null;
62
+ });
63
+ return this.loginPromise;
64
+ }
65
+
66
+ async #connectInner({ forceQr }) {
67
+ this.store.ensureAccount(this.accountId);
68
+ if (this.api && !forceQr) {
69
+ return this.status();
70
+ }
71
+
72
+ const factory = this.clientFactory || defaultClientFactory;
73
+ const session = forceQr ? null : this.store.loadSession(this.accountId);
74
+
75
+ try {
76
+ if (session) {
77
+ this.store.setAccountStatus(this.accountId, "reconnecting");
78
+ this.api = await factory.loginWithSession(session);
79
+ } else {
80
+ this.store.setAccountStatus(this.accountId, "need_scan");
81
+ this.api = await factory.loginWithQr({
82
+ onQr: (payload) => {
83
+ this.qr = { ...payload, updated_at: utcNow() };
84
+ this.#persistQrImage(payload?.image);
85
+ this.emit("qr", this.qr);
86
+ },
87
+ onScanned: (info) => {
88
+ this.store.setAccountStatus(this.accountId, "need_scan", {
89
+ display_name: info?.display_name || "",
90
+ });
91
+ },
92
+ onCredentials: (creds) => {
93
+ this.store.saveSession(this.accountId, creds);
94
+ },
95
+ onAbortHandle: (abortFn) => {
96
+ this.qrAbort = abortFn;
97
+ },
98
+ });
99
+ }
100
+
101
+ const info = await safeAccountInfo(this.api);
102
+ this.store.setAccountStatus(this.accountId, "connected", {
103
+ display_name: info.display_name || "",
104
+ zalo_user_id: info.user_id || "",
105
+ last_error: "",
106
+ });
107
+ this.qr = null;
108
+
109
+ try {
110
+ const { bootstrapAccount } = await import("./discovery.js");
111
+ const dest = this.store.getDestination(this.accountId);
112
+ const wantName =
113
+ dest.group_name || process.env.DESTINATION_GROUP_NAME || "configured destination";
114
+ await bootstrapAccount({
115
+ api: this.api,
116
+ store: this.store,
117
+ accountId: this.accountId,
118
+ destinationName: wantName,
119
+ });
120
+ } catch (err) {
121
+ this.store.setHealth(
122
+ `bootstrap_error_${this.accountId}`,
123
+ String(err?.message || err),
124
+ );
125
+ }
126
+
127
+ this.#wireListener();
128
+ return this.status();
129
+ } catch (err) {
130
+ const msg = String(err?.message || err);
131
+ this.store.setAccountStatus(this.accountId, "disconnected", { last_error: msg.slice(0, 300) });
132
+ this.api = null;
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ #wireListener() {
138
+ if (!this.api?.listener) return;
139
+ // Avoid double-binding handlers when reconnect reuses same listener object.
140
+ if (this.listenerWired && this.api.listener === this._wiredListener) {
141
+ try {
142
+ this.api.listener.start?.({ retryOnClose: true });
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ return;
147
+ }
148
+ this._wiredListener = this.api.listener;
149
+ this.listenerWired = true;
150
+
151
+ this.api.listener.on("message", (message) => {
152
+ this.lastMessageAt = utcNow();
153
+ try {
154
+ this.store.setHealth(`last_message_at_${this.accountId}`, this.lastMessageAt);
155
+ } catch {
156
+ /* ignore */
157
+ }
158
+ try {
159
+ const event = normalizeInboundMessage({
160
+ accountId: this.accountId,
161
+ message,
162
+ });
163
+ Promise.resolve(this.onEvent?.(event, this)).catch((err) => {
164
+ try {
165
+ this.store.setHealth(
166
+ `last_listener_error_${this.accountId}`,
167
+ String(err?.message || err),
168
+ );
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ });
173
+ } catch (err) {
174
+ try {
175
+ this.store.setHealth(
176
+ `last_listener_error_${this.accountId}`,
177
+ String(err?.message || err),
178
+ );
179
+ } catch {
180
+ /* ignore */
181
+ }
182
+ }
183
+ });
184
+ this.api.listener.on("disconnected", () => {
185
+ try {
186
+ this.store.setAccountStatus(this.accountId, "reconnecting", {
187
+ last_error: "listener_disconnected",
188
+ });
189
+ this.store.setHealth(`listener_disconnected_${this.accountId}`, utcNow());
190
+ this.emit("listener_down", { reason: "disconnected", at: utcNow() });
191
+ } catch {
192
+ /* ignore closed store */
193
+ }
194
+ });
195
+ this.api.listener.on("error", (error) => {
196
+ try {
197
+ this.store.setAccountStatus(this.accountId, "reconnecting", {
198
+ last_error: String(error?.message || error).slice(0, 300),
199
+ });
200
+ this.store.setHealth(
201
+ `listener_error_${this.accountId}`,
202
+ String(error?.message || error).slice(0, 300),
203
+ );
204
+ this.emit("listener_down", {
205
+ reason: String(error?.message || error).slice(0, 200),
206
+ at: utcNow(),
207
+ });
208
+ } catch {
209
+ /* ignore closed store */
210
+ }
211
+ });
212
+ this.api.listener.start({ retryOnClose: true });
213
+ }
214
+
215
+ async sendText(targetId, text, threadType = 1) {
216
+ if (!this.api?.sendMessage) throw new Error("not_connected");
217
+ // Final defense-in-depth gate; no caller can bypass PolicyGuard merely by
218
+ // holding a runtime reference.
219
+ const outbound = this.policy.evaluateOutbound({
220
+ accountId: this.accountId,
221
+ targetId,
222
+ text,
223
+ kind: "runtime_send",
224
+ });
225
+ if (!outbound.allow) throw new Error(outbound.reason || "outbound_disabled");
226
+ // Final hard gate: never send outside destination when READ_ONLY_SOURCE.
227
+ const dest = this.store.getDestination(this.accountId);
228
+ if (!dest.group_id || String(targetId) !== String(dest.group_id)) {
229
+ throw new Error("blocked_non_destination_send");
230
+ }
231
+ // Never send to source groups even if mis-called with group thread type.
232
+ return this.api.sendMessage(String(text), String(targetId), 1);
233
+ }
234
+
235
+ async pause() {
236
+ try {
237
+ this.api?.listener?.stop?.();
238
+ } catch {
239
+ /* ignore */
240
+ }
241
+ this.api = null;
242
+ this.listenerWired = false;
243
+ this._wiredListener = null;
244
+ try {
245
+ this.store.setAccountStatus(this.accountId, "paused");
246
+ } catch {
247
+ /* store may be closed in short-lived scripts */
248
+ }
249
+ return this.status();
250
+ }
251
+
252
+ async disconnect({ wipeSession = false } = {}) {
253
+ await this.pause();
254
+ if (wipeSession) this.store.deleteSession(this.accountId);
255
+ this.store.setAccountStatus(this.accountId, "disconnected");
256
+ return this.status();
257
+ }
258
+
259
+ getQr() {
260
+ return this.qr;
261
+ }
262
+
263
+ #persistQrImage(image) {
264
+ try {
265
+ if (!image) return;
266
+ const raw = String(image);
267
+ const b64 = raw.startsWith("data:") ? raw.split(",", 2)[1] : raw;
268
+ const buf = Buffer.from(b64, "base64");
269
+ const dir = path.join(this.store.dataDir, "qr");
270
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
271
+ fs.chmodSync(dir, 0o700);
272
+ const file = path.join(dir, `${this.accountId}_login_qr.png`);
273
+ fs.writeFileSync(file, buf, { mode: 0o600 });
274
+ fs.chmodSync(file, 0o600);
275
+ // Keep the persisted image private and expose only the browser-safe data
276
+ // URL/code. Never return a local filesystem path or create a shared /tmp
277
+ // handoff artifact.
278
+ this.qr = { ...(this.qr || {}), image, updated_at: utcNow() };
279
+ } catch (err) {
280
+ this.store.setHealth(`qr_persist_error_${this.accountId}`, String(err?.message || err));
281
+ }
282
+ }
283
+ }
284
+
285
+ async function safeAccountInfo(api) {
286
+ try {
287
+ if (typeof api.fetchAccountInfo === "function") {
288
+ const info = await api.fetchAccountInfo();
289
+ const { extractAccountIdentity } = await import("./discovery.js");
290
+ const id = extractAccountIdentity(info);
291
+ return { display_name: id.display_name, user_id: id.user_id };
292
+ }
293
+ } catch {
294
+ /* optional */
295
+ }
296
+ return { display_name: "", user_id: "" };
297
+ }
298
+
299
+ async function defaultClientFactory() {
300
+ // placeholder so tests don't import zca-js accidentally via call shape
301
+ return null;
302
+ }
303
+
304
+ defaultClientFactory.loginWithSession = async function loginWithSession(session) {
305
+ const { Zalo } = await import("zca-js");
306
+ const zalo = new Zalo();
307
+ return zalo.login({
308
+ cookie: session.cookie,
309
+ imei: session.imei,
310
+ userAgent: session.userAgent,
311
+ });
312
+ };
313
+
314
+ defaultClientFactory.loginWithQr = async function loginWithQr({ onQr, onScanned, onCredentials }) {
315
+ const { Zalo, LoginQRCallbackEventType } = await import("zca-js");
316
+ const zalo = new Zalo();
317
+ let savedCreds = null;
318
+
319
+ const api = await zalo.loginQR({}, (event) => {
320
+ switch (event.type) {
321
+ case LoginQRCallbackEventType.QRCodeGenerated:
322
+ onQr?.({
323
+ image: event.data.image, // base64 or data url depending on lib
324
+ code: event.data.code,
325
+ });
326
+ break;
327
+ case LoginQRCallbackEventType.QRCodeScanned:
328
+ onScanned?.(event.data);
329
+ break;
330
+ case LoginQRCallbackEventType.GotLoginInfo:
331
+ savedCreds = {
332
+ cookie: event.data.cookie,
333
+ imei: event.data.imei,
334
+ userAgent: event.data.userAgent,
335
+ };
336
+ onCredentials?.(savedCreds);
337
+ break;
338
+ default:
339
+ break;
340
+ }
341
+ });
342
+
343
+ // Some versions only expose credentials via GotLoginInfo; if missing, leave session save to caller later.
344
+ if (savedCreds) onCredentials?.(savedCreds);
345
+ return api;
346
+ };
347
+
348
+ export class BridgeHub {
349
+ constructor({ config, store, policy, clientFactory = null }) {
350
+ this.config = config;
351
+ this.store = store;
352
+ this.policy = policy;
353
+ this.clientFactory = clientFactory;
354
+ this.runtimes = new Map();
355
+ }
356
+
357
+ getRuntime(accountId = this.config.default_account_id) {
358
+ const id = String(accountId || this.config.default_account_id);
359
+ if (!this.runtimes.has(id)) {
360
+ this.store.ensureAccount(id);
361
+ this.runtimes.set(
362
+ id,
363
+ new AccountRuntime({
364
+ accountId: id,
365
+ store: this.store,
366
+ policy: this.policy,
367
+ clientFactory: this.clientFactory,
368
+ onEvent: (event, runtime) => this.handleEvent(event, runtime),
369
+ }),
370
+ );
371
+ }
372
+ return this.runtimes.get(id);
373
+ }
374
+
375
+ async handleEvent(event, runtime) {
376
+ const decision = this.policy.evaluateInbound(event);
377
+ this.store.setHealth("last_event_decision", {
378
+ event_id: event.event_id,
379
+ allow: decision.allow,
380
+ reason: decision.reason,
381
+ at: utcNow(),
382
+ });
383
+ if (!decision.allow) return { stored: false, decision };
384
+
385
+ if (decision.actions.includes("command")) {
386
+ const { handleCommand } = await import("./commands.js");
387
+ const result = await handleCommand({
388
+ event,
389
+ store: this.store,
390
+ policy: this.policy,
391
+ hub: this,
392
+ config: this.config,
393
+ });
394
+ return { stored: false, decision, command: result, runtime: runtime.accountId };
395
+ }
396
+
397
+ if (decision.actions.includes("destination_ask")) {
398
+ // Phase 1 inbound router: "bot ..." → Hermes brain → outbound guard.
399
+ this.store.putEvent({ ...event, source_name: event.source_name || "destination" });
400
+ const { isBotCommand, handleBotBrainCommand } = await import("./inbound_router.js");
401
+ if (!isBotCommand(event.text)) {
402
+ return {
403
+ stored: true,
404
+ decision,
405
+ ask: { ok: false, reason: "missing_bot_prefix" },
406
+ runtime: runtime.accountId,
407
+ };
408
+ }
409
+ const result = await handleBotBrainCommand({
410
+ event,
411
+ store: this.store,
412
+ policy: this.policy,
413
+ hub: this,
414
+ config: this.config,
415
+ });
416
+ return { stored: true, decision, ask: result, runtime: runtime.accountId };
417
+ }
418
+
419
+ const stored = this.store.putEvent(event);
420
+ // Auto-learn source names in listen-all mode (metadata only)
421
+ if (stored && event.source_type === "group" && this.config.listen_all_groups) {
422
+ const existing = this.store.getSource(event.account_id, event.source_id);
423
+ if (!existing) {
424
+ this.store.upsertSource({
425
+ accountId: event.account_id,
426
+ sourceId: event.source_id,
427
+ sourceType: "group",
428
+ sourceName: event.source_name || "",
429
+ mode: "listen_only",
430
+ isAllowed: true,
431
+ });
432
+ }
433
+ }
434
+ if (decision.actions.includes("alert_candidate") && stored && this.config.auto_alert) {
435
+ const text = String(event.text || "");
436
+ const high = /gấp|khẩn|scam|lừa|lead|chốt|book|ban|phốt/i.test(text);
437
+ if (high) {
438
+ this.store.putEnrichment({
439
+ message_id: event.event_id,
440
+ account_id: event.account_id,
441
+ priority: "high",
442
+ lead_flag: /lead|chốt|book/i.test(text),
443
+ risk_flag: /scam|lừa|ban|phốt/i.test(text),
444
+ summary: text.slice(0, 200),
445
+ });
446
+ const { maybeSendAlert } = await import("./digest.js");
447
+ maybeSendAlert({
448
+ config: this.config,
449
+ store: this.store,
450
+ policy: this.policy,
451
+ hub: this,
452
+ event,
453
+ enrichment: {
454
+ priority: "high",
455
+ lead_flag: /lead|chốt|book/i.test(text),
456
+ risk_flag: /scam|lừa|ban|phốt/i.test(text),
457
+ },
458
+ }).catch((err) => {
459
+ this.store.setHealth("last_alert_error", String(err?.message || err));
460
+ });
461
+ }
462
+ }
463
+ return { stored, decision, runtime: runtime.accountId };
464
+ }
465
+
466
+ listStatus() {
467
+ const ids = new Set([
468
+ this.config.default_account_id,
469
+ ...this.store.listAccounts().map((a) => a.account_id),
470
+ ...this.runtimes.keys(),
471
+ ]);
472
+ return [...ids].map((id) => this.getRuntime(id).status());
473
+ }
474
+ }
475
+
476
+ export function ensureDataLayout(dataDir) {
477
+ fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
478
+ fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true, mode: 0o700 });
479
+ fs.mkdirSync(path.join(dataDir, "qr"), { recursive: true, mode: 0o700 });
480
+ }