@ardennguyen/zalo-agent-cli 2.5.0 → 2.5.3

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.
Files changed (35) hide show
  1. package/README.md +41 -33
  2. package/node_modules/zca-js/dist/apis.js +0 -8
  3. package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
  4. package/package.json +3 -3
  5. package/patches/zca-js+2.2.0.patch +4 -236
  6. package/src/commands/bank.js +126 -0
  7. package/src/commands/catalog.js +14 -0
  8. package/src/commands/conv.js +19 -0
  9. package/src/commands/friend.js +66 -21
  10. package/src/commands/group.js +38 -2
  11. package/src/commands/listen.js +51 -2
  12. package/src/commands/mcp.js +232 -2
  13. package/src/commands/msg.js +60 -0
  14. package/src/commands/profile.js +39 -0
  15. package/src/commands/sync.js +1 -68
  16. package/src/core/bank-accounts.js +52 -0
  17. package/src/core/daemon-channel.js +191 -2
  18. package/src/core/friend-requests.js +142 -0
  19. package/src/core/friend-search.js +41 -0
  20. package/src/core/group-admin.js +116 -0
  21. package/src/core/group-invite.js +53 -0
  22. package/src/core/sync.js +4 -235
  23. package/src/index.js +2 -0
  24. package/src/mcp/mcp-config.js +1 -0
  25. package/src/mcp/mcp-tools.js +373 -13
  26. package/src/utils/cli-args.js +84 -0
  27. package/src/utils/secrets.js +69 -0
  28. package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
  29. package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
  30. package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
  31. package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
  32. package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
  33. package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
  34. package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
  35. package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
package/src/core/sync.js CHANGED
@@ -1,11 +1,7 @@
1
- import fs from "fs";
2
1
  import { resolve } from "path";
3
- import crypto from "crypto";
4
2
  import { getAccountDir } from "./credentials.js";
5
3
  import {
6
4
  initDb,
7
- insertMessage,
8
- upsertThread,
9
5
  getSyncState,
10
6
  setSyncState,
11
7
  recordSyncGap,
@@ -42,95 +38,14 @@ export const MAX_GAP_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
42
38
  */
43
39
  export const SYNC_FRESHNESS_MS = 60 * 60 * 1000; // 1 hour
44
40
 
45
- /**
46
- * Best-effort field aliases for a single synced message, mirroring the shape
47
- * `src/commands/listen.js` already normalizes live WS messages into before
48
- * calling insertMessage()/upsertThread(). The exact shape of a `get_crossdb`
49
- * message record hasn't been confirmed against real data yet (see
50
- * agent/docs/mobile-sync-live-test-results.md) — this stays defensive and
51
- * falls back to the raw dump file whenever a record doesn't look recognizable,
52
- * rather than silently dropping or mis-storing it.
53
- */
54
- function normalizeSyncedMessage(raw) {
55
- if (!raw || typeof raw !== "object") return null;
56
-
57
- const msgId = raw.msgId ?? raw.msg_id ?? raw.cliMsgId ?? raw.cli_msg_id;
58
- const threadId = raw.threadId ?? raw.toId ?? raw.to_uid ?? raw.groupId ?? raw.uidTo;
59
- if (!msgId || !threadId) return null;
60
-
61
- const senderId = raw.uidFrom ?? raw.fromUid ?? raw.senderId ?? raw.from_uid ?? "";
62
- const senderName = raw.dName ?? raw.displayName ?? raw.senderName ?? "";
63
- const rawContent = raw.content ?? raw.text ?? raw.msg ?? null;
64
- const isText = typeof rawContent === "string";
65
- const msgType = raw.msgType ?? raw.msg_type ?? null;
66
- const timestamp = raw.ts ? Number(raw.ts) : raw.timestamp ? Number(raw.timestamp) : Date.now();
67
- const isGroup = raw.type === 1 || raw.isGroup === true || !!raw.groupId;
68
-
69
- return {
70
- msgId: String(msgId),
71
- threadId: String(threadId),
72
- senderId: String(senderId),
73
- senderName: String(senderName),
74
- text: isText ? rawContent : rawContent ? JSON.stringify(rawContent) : "",
75
- timestamp,
76
- type: isText ? "text" : msgType || "attachment",
77
- raw_data: rawContent,
78
- threadType: isGroup ? "group" : "dm",
79
- };
80
- }
81
-
82
- /** Pull out a plausible array of message-like records from whatever shape
83
- * getCrossDB's decrypted payload turns out to have (object/array/JSON string). */
84
- function extractMessageRecords(payload) {
85
- let parsed = payload;
86
- if (typeof parsed === "string") {
87
- try {
88
- parsed = JSON.parse(parsed);
89
- } catch {
90
- return []; // not JSON — nothing we can map into rows
91
- }
92
- }
93
- if (Array.isArray(parsed)) return parsed;
94
- if (parsed && typeof parsed === "object") {
95
- for (const key of ["msgs", "messages", "data", "list", "items"]) {
96
- if (Array.isArray(parsed[key])) return parsed[key];
97
- }
98
- }
99
- return [];
100
- }
101
-
102
41
  export class SyncManager {
103
42
  constructor(api, accountName) {
104
43
  this.api = api;
105
44
  this.accountName = accountName;
106
45
  this.accountDir = resolveAccountDir(accountName);
107
- this.syncSessionDir = resolve(this.accountDir, "sync");
108
- if (!fs.existsSync(this.syncSessionDir)) {
109
- fs.mkdirSync(this.syncSessionDir, { recursive: true });
110
- }
111
- this.keys = this._loadOrGenerateKeys();
112
46
  this.dbInitialized = false;
113
47
  }
114
48
 
115
- _loadOrGenerateKeys() {
116
- const keyFile = resolve(this.syncSessionDir, "rsa_keys.json");
117
- if (fs.existsSync(keyFile)) {
118
- return JSON.parse(fs.readFileSync(keyFile, "utf8"));
119
- }
120
- const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
121
- modulusLength: 2048,
122
- publicKeyEncoding: { type: "spki", format: "pem" },
123
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
124
- });
125
- const pubKeyRaw = publicKey
126
- .replace(/-----BEGIN PUBLIC KEY-----/g, "")
127
- .replace(/-----END PUBLIC KEY-----/g, "")
128
- .replace(/\n/g, "");
129
- const keys = { publicKey: pubKeyRaw, privateKey };
130
- fs.writeFileSync(keyFile, JSON.stringify(keys), "utf8");
131
- return keys;
132
- }
133
-
134
49
  /** Lazily open the same zalo.db the `listen` daemon uses, so synced
135
50
  * messages land in the exact same queryable cache as live-listened ones,
136
51
  * and so gap/state bookkeeping is shared between `listen` and `sync-mobile`
@@ -163,8 +78,8 @@ export class SyncManager {
163
78
  }
164
79
 
165
80
  /** Called whenever we're confidently caught up (e.g. right after the WS
166
- * reports "connected", or right after startup with no prior gap) so the
167
- * debounce check in pollSync() has an accurate picture. */
81
+ * reports "connected", or right after startup with no prior gap) so
82
+ * sync freshness checks have an accurate picture. */
168
83
  markConnected() {
169
84
  this._ensureDb();
170
85
  setSyncState("lastConnectedAt", Date.now());
@@ -197,7 +112,7 @@ export class SyncManager {
197
112
  * This single marker is what {@link checkSyncFreshness} reads, so every
198
113
  * sync path that reaches the server should call it on success.
199
114
  *
200
- * @param {string} [kind] - which path completed ("backfill" | "legacy" | "transfer").
115
+ * @param {string} [kind] - which path completed ("backfill" | "transfer").
201
116
  * @param {{coveredFrom?: number}} [opts] - ms epoch this run reached back to,
202
117
  * for the paths that know their window (transfer-sync does; the socket
203
118
  * backfill does not). Recorded so a later, WIDER sync is not suppressed
@@ -213,7 +128,7 @@ export class SyncManager {
213
128
 
214
129
  /**
215
130
  * ms epoch the last successful sync reached back to, or null when that
216
- * path never recorded a window (socket backfill, legacy, or a db written
131
+ * path never recorded a window (socket backfill, or a db written
217
132
  * before this marker existed).
218
133
  *
219
134
  * @returns {number|null}
@@ -279,90 +194,6 @@ export class SyncManager {
279
194
  return out(false, "stale");
280
195
  }
281
196
 
282
- /**
283
- * One full sync cycle:
284
- * 1. pullMobileMsg() — nudges the phone and gets back a short opaque
285
- * token (confirmed live: 64 raw bytes, NOT message content — see
286
- * agent/docs/mobile-sync-live-test-results.md). Earlier code treated
287
- * this token itself as "the sync data" and stopped here, which meant
288
- * it never actually retrieved anything.
289
- * 2. getCrossDB(token) — the actual retrieval call. Only call this once
290
- * we have a token from step 1.
291
- * 3. Parse + store whatever comes back into zalo.db via the same
292
- * insertMessage()/upsertThread() helpers the `listen` daemon uses.
293
- * 4. deleteSnapshotMobileMsg() to ack completion, regardless of whether
294
- * step 3 found anything, so the pending server-side request is
295
- * cleaned up either way.
296
- *
297
- * By default this is gap-aware and debounced (task #3): if we have no
298
- * recorded pending gap and our last known connection state was healthy,
299
- * it returns `{status: "already-synced", cached: true}` immediately
300
- * instead of calling the mobile API — mirroring what Zalo Web itself does
301
- * (confirmed live: its own IndexedDB `missing_message_range` table is
302
- * what gates whether clicking "Sync" re-pings the phone or just reports
303
- * success instantly). Pass `{force: true}` to bypass this and always hit
304
- * the API, e.g. for the explicit `sync-mobile` CLI command.
305
- */
306
- async pollSync(fromSeqId = 0, isRetry = 0, opts = {}) {
307
- const force = opts === true || opts?.force === true; // tolerate old boolean-ish callers
308
- this._ensureDb();
309
-
310
- if (!force) {
311
- const pending = getPendingSyncGaps();
312
- const connectionOk = getSyncState("lastConnectionOk");
313
- if (pending.length === 0 && connectionOk === "true") {
314
- console.log(`[Sync] No known coverage gap and connection state is healthy — skipping API call.`);
315
- return { status: "already-synced", cached: true };
316
- }
317
- }
318
-
319
- console.log(`[Sync] Requesting mobile push (pullMobileMsg)...`);
320
- const pullRes = await this.api.pullMobileMsg(this.keys.publicKey, fromSeqId, isRetry, "");
321
- const token = this._unwrap(pullRes);
322
- if (!token) {
323
- // MEASURED 2026-09-20 against the live Zalo Web client: this is
324
- // not "the phone hasn't answered yet", it is the endpoint being
325
- // retired. `/api/message/pull_mobile_msg` and `get_crossdb` still
326
- // exist in Zalo Web's bundle but NOTHING calls them — 0 call sites
327
- // across all 4,642 loaded modules. Current Zalo syncs over the
328
- // WebSocket instead (cmd 590/591, "transfer-sync-v2"). See
329
- // agent/work/transfer-sync-v2/NOTES.md § Mobile sync.
330
- //
331
- // Retrying therefore cannot help, and every retry used to push a
332
- // notification to a real person's phone. One attempt, then stop.
333
- console.log(`[Sync] pullMobileMsg returned no session token — the legacy mobile-sync endpoint is retired.`);
334
- return { status: "legacy-retired", raw: pullRes };
335
- }
336
-
337
- console.log(`[Sync] Got sync session token, calling getCrossDB...`);
338
- let crossDbRes;
339
- try {
340
- crossDbRes = await this.api.getCrossDB(token);
341
- } catch (e) {
342
- console.error(`[Sync] getCrossDB failed:`, e.message);
343
- return { status: "crossdb-error", error: e.message, token };
344
- }
345
-
346
- const payload = this._unwrap(crossDbRes);
347
- const result = await this.processSyncData(payload, token);
348
-
349
- try {
350
- await this.api.deleteSnapshotMobileMsg(this.keys.publicKey);
351
- } catch (e) {
352
- console.error(`[Sync] deleteSnapshotMobileMsg failed (non-fatal):`, e.message);
353
- }
354
-
355
- // A completed round-trip (even one that found nothing new) means
356
- // we've genuinely asked the server "what did I miss?" and gotten an
357
- // answer, so whatever gaps we knew about are covered now.
358
- resolveAllPendingSyncGaps();
359
- setSyncState("lastFullSyncAt", Date.now());
360
- setSyncState("lastConnectionOk", "true");
361
- this.markSyncSuccess("legacy");
362
-
363
- return result;
364
- }
365
-
366
197
  /**
367
198
  * Backfill recent history straight from Zalo's servers over an already-open
368
199
  * WebSocket, writing it into the same zalo.db the `listen` daemon uses.
@@ -475,66 +306,4 @@ export class SyncManager {
475
306
  return false;
476
307
  }
477
308
  }
478
-
479
- /** zca-js's apiFactory convention isn't 100% consistent about whether a
480
- * raw method return is the payload itself or `{data: payload, ...}` —
481
- * handle both without guessing wrong silently. */
482
- _unwrap(res) {
483
- if (res && typeof res === "object" && "data" in res && Object.keys(res).length <= 2) {
484
- return res.data;
485
- }
486
- return res;
487
- }
488
-
489
- async processSyncData(payload, token) {
490
- // Always keep the raw dump — cheap forensic safety net regardless of
491
- // whether structured parsing below succeeds.
492
- const dumpFile = resolve(this.syncSessionDir, `sync_dump_${Date.now()}.json`);
493
- fs.writeFileSync(dumpFile, JSON.stringify({ token, payload }), "utf8");
494
-
495
- const records = extractMessageRecords(payload);
496
- if (records.length === 0) {
497
- console.log(
498
- `[Sync] getCrossDB returned no recognizable message records (dumped raw to ${dumpFile}). ` +
499
- `This is expected if there's no real offline gap right now, or means the payload shape ` +
500
- `needs re-checking against agent/docs/mobile-sync-live-test-results.md.`,
501
- );
502
- return { status: "empty-or-unrecognized", dumpFile };
503
- }
504
-
505
- const threadsTouched = new Map();
506
- let saved = 0;
507
- for (const raw of records) {
508
- const msg = normalizeSyncedMessage(raw);
509
- if (!msg) continue;
510
- try {
511
- insertMessage({
512
- msgId: msg.msgId,
513
- threadId: msg.threadId,
514
- senderId: msg.senderId,
515
- senderName: msg.senderName,
516
- text: msg.text,
517
- timestamp: msg.timestamp,
518
- type: msg.type,
519
- raw_data: msg.raw_data,
520
- });
521
- if (!threadsTouched.has(msg.threadId) || threadsTouched.get(msg.threadId) < msg.timestamp) {
522
- threadsTouched.set(msg.threadId, msg.timestamp);
523
- upsertThread({
524
- threadId: msg.threadId,
525
- type: msg.threadType,
526
- name: msg.senderName,
527
- lastUpdate: msg.timestamp,
528
- sync_timestamp: Date.now(),
529
- });
530
- }
531
- saved++;
532
- } catch (e) {
533
- console.error(`[Sync] Failed to save message ${msg.msgId}:`, e.message);
534
- }
535
- }
536
-
537
- console.log(`[Sync] Saved ${saved}/${records.length} synced messages to zalo.db (dump: ${dumpFile}).`);
538
- return { status: "saved", saved, total: records.length, dumpFile };
539
- }
540
309
  }
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ import { registerAutoReplyCommands } from "./commands/auto-reply.js";
25
25
  import { registerQuickMsgCommands } from "./commands/quick-msg.js";
26
26
  import { registerLabelCommands } from "./commands/label.js";
27
27
  import { registerCatalogCommands } from "./commands/catalog.js";
28
+ import { registerBankCommands } from "./commands/bank.js";
28
29
  import { registerListenCommand } from "./commands/listen.js";
29
30
  import { registerOACommands } from "./commands/oa.js";
30
31
  import { registerMCPCommands } from "./commands/mcp.js";
@@ -147,6 +148,7 @@ registerAutoReplyCommands(program);
147
148
  registerQuickMsgCommands(program);
148
149
  registerLabelCommands(program);
149
150
  registerCatalogCommands(program);
151
+ registerBankCommands(program);
150
152
  registerListenCommand(program);
151
153
  registerOACommands(program);
152
154
  registerMCPCommands(program);
@@ -44,6 +44,7 @@ export function getDefaultConfig() {
44
44
  clients: [],
45
45
  },
46
46
  webhookSecret: null,
47
+ groupConfirm: true, // zalo_group asks for confirm: true before leave / remove_member
47
48
  httpPort: null,
48
49
  host: null,
49
50
  };