@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.
- package/README.md +41 -33
- package/node_modules/zca-js/dist/apis.js +0 -8
- package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
- package/package.json +3 -3
- package/patches/zca-js+2.2.0.patch +4 -236
- package/src/commands/bank.js +126 -0
- package/src/commands/catalog.js +14 -0
- package/src/commands/conv.js +19 -0
- package/src/commands/friend.js +66 -21
- package/src/commands/group.js +38 -2
- package/src/commands/listen.js +51 -2
- package/src/commands/mcp.js +232 -2
- package/src/commands/msg.js +60 -0
- package/src/commands/profile.js +39 -0
- package/src/commands/sync.js +1 -68
- package/src/core/bank-accounts.js +52 -0
- package/src/core/daemon-channel.js +191 -2
- package/src/core/friend-requests.js +142 -0
- package/src/core/friend-search.js +41 -0
- package/src/core/group-admin.js +116 -0
- package/src/core/group-invite.js +53 -0
- package/src/core/sync.js +4 -235
- package/src/index.js +2 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-tools.js +373 -13
- package/src/utils/cli-args.js +84 -0
- package/src/utils/secrets.js +69 -0
- package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
- package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
- package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
- package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
- package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
- 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
|
|
167
|
-
*
|
|
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" | "
|
|
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,
|
|
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);
|