@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.4
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 +71 -4
- package/src/commands/mcp.js +252 -4
- 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/current-user-names.js +27 -7
- package/src/core/daemon-channel.js +191 -2
- package/src/core/daemon-log.js +250 -0
- package/src/core/daemon-sync.js +140 -9
- 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-v2/gid.js +20 -20
- package/src/core/sync-v2/index.js +77 -11
- package/src/core/sync.js +4 -235
- package/src/core/user-info.js +170 -0
- package/src/index.js +2 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-tools.js +302 -2
- 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
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched profile lookups that notice a rate limit instead of hammering through it.
|
|
3
|
+
*
|
|
4
|
+
* `getUserInfo` (names for user ids) and `/api/gid/decrypt` (opaque 1-1 ids to
|
|
5
|
+
* user ids) take at most 100 / 50 ids per request. A restore of a busy account
|
|
6
|
+
* (100,000 messages in 90 days, thousands of non-friend conversations) asks for
|
|
7
|
+
* thousands of ids, so it sends dozens of these requests back to back, and
|
|
8
|
+
* Zalo answers with a "limit reached" error. The old loops swallowed that
|
|
9
|
+
* error with a fixed message and went on to the next batch, so every remaining
|
|
10
|
+
* batch failed the same way, the real error text was never written anywhere,
|
|
11
|
+
* and the next restore started from zero again.
|
|
12
|
+
*
|
|
13
|
+
* This runs the batches one at a time with a short gap, retries a rate-limit
|
|
14
|
+
* answer with a growing wait, stops the whole lookup after a rate limit that
|
|
15
|
+
* persists (or after several failures in a row, whatever their text), and
|
|
16
|
+
* writes what Zalo actually said -- code and message -- through `log`. The
|
|
17
|
+
* callers keep their contract: a lookup that gives up leaves the names it did
|
|
18
|
+
* not get blank or as ids, and nothing here throws.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Wait between two requests of one lookup. */
|
|
22
|
+
export const DEFAULT_GAP_MS = 300;
|
|
23
|
+
/** Waits before each retry of a rate-limited batch. */
|
|
24
|
+
export const DEFAULT_BACKOFF_MS = Object.freeze([2000, 5000, 15000]);
|
|
25
|
+
/** Consecutive failed batches (any error) after which the rest are not attempted. */
|
|
26
|
+
export const MAX_CONSECUTIVE_FAILURES = 3;
|
|
27
|
+
|
|
28
|
+
/** What a rate-limit answer reads like. Zalo's wording was not captured, so this is deliberately loose. */
|
|
29
|
+
const RATE_LIMIT_TEXT = /limit|giới hạn|too many|quá nhiều|frequen|tần suất|rate|throttl|try again later/i;
|
|
30
|
+
/** Codes Zalo uses for "too many requests" elsewhere in this CLI (friend requests: 221). */
|
|
31
|
+
const RATE_LIMIT_CODES = new Set([221, 429]);
|
|
32
|
+
|
|
33
|
+
let sink = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where lookups write their diagnostics when the caller passes no `log`.
|
|
37
|
+
* Daemons point this at their log; one-shot commands leave it unset and stay quiet.
|
|
38
|
+
*
|
|
39
|
+
* @param {((message: string) => void)|null} fn
|
|
40
|
+
*/
|
|
41
|
+
export function setUserInfoLogger(fn) {
|
|
42
|
+
sink = typeof fn === "function" ? fn : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Write one diagnostic line to the registered sink, if any.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} message
|
|
49
|
+
*/
|
|
50
|
+
export function userInfoLog(message) {
|
|
51
|
+
sink?.(message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The error as Zalo reported it: its code and its message.
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} e
|
|
58
|
+
* @returns {string} e.g. `code 221: Quá giới hạn`, or just the message
|
|
59
|
+
*/
|
|
60
|
+
export function describeLookupError(e) {
|
|
61
|
+
const message = e?.message || String(e);
|
|
62
|
+
const code = e?.code ?? message.match(/\((-?\d+)\)\s*$/)?.[1];
|
|
63
|
+
return code !== undefined && code !== null && code !== "" ? `code ${code}: ${message}` : message;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether an error looks like Zalo refusing because too many lookups were made.
|
|
68
|
+
*
|
|
69
|
+
* @param {unknown} e
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isRateLimitError(e) {
|
|
73
|
+
if (RATE_LIMIT_CODES.has(Number(e?.code))) return true;
|
|
74
|
+
return RATE_LIMIT_TEXT.test(String(e?.message || e || ""));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
|
|
79
|
+
const pacing = { gapMs: DEFAULT_GAP_MS, backoffMs: DEFAULT_BACKOFF_MS, sleep: realSleep };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Override the module's pacing defaults (a test seam: the real gaps and back-offs are seconds).
|
|
83
|
+
*
|
|
84
|
+
* @param {{gapMs?: number, backoffMs?: readonly number[], sleep?: (ms: number) => Promise<void>}} [overrides]
|
|
85
|
+
* call with nothing to restore the defaults
|
|
86
|
+
*/
|
|
87
|
+
export function configureUserInfo(overrides = {}) {
|
|
88
|
+
pacing.gapMs = overrides.gapMs ?? DEFAULT_GAP_MS;
|
|
89
|
+
pacing.backoffMs = overrides.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
90
|
+
pacing.sleep = overrides.sleep ?? realSleep;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Call `call(batch)` for each slice of `ids`, paced, retried and bounded.
|
|
95
|
+
*
|
|
96
|
+
* @template T
|
|
97
|
+
* @param {string[]} ids - already de-duplicated by the caller
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {number} opts.batchSize - ids per request
|
|
100
|
+
* @param {(batch: string[]) => Promise<T>} opts.call - one request
|
|
101
|
+
* @param {string} [opts.label] - prefix for the log lines
|
|
102
|
+
* @param {(message: string) => void} [opts.log] - defaults to the daemon sink, else silent
|
|
103
|
+
* @param {number} [opts.gapMs] - wait between requests
|
|
104
|
+
* @param {readonly number[]} [opts.backoffMs] - waits before each retry of a rate-limited batch
|
|
105
|
+
* @param {number} [opts.maxConsecutiveFailures]
|
|
106
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep] - test seam
|
|
107
|
+
* @returns {Promise<{results: T[], batches: number, failed: number, skippedIds: number,
|
|
108
|
+
* stoppedBy: null|"rate-limit"|"repeated-failures", retries: number}>}
|
|
109
|
+
*/
|
|
110
|
+
export async function runPacedBatches(ids, opts) {
|
|
111
|
+
const {
|
|
112
|
+
batchSize,
|
|
113
|
+
call,
|
|
114
|
+
label = "user-info",
|
|
115
|
+
gapMs = pacing.gapMs,
|
|
116
|
+
backoffMs = pacing.backoffMs,
|
|
117
|
+
maxConsecutiveFailures = MAX_CONSECUTIVE_FAILURES,
|
|
118
|
+
sleep = pacing.sleep,
|
|
119
|
+
} = opts;
|
|
120
|
+
const log = typeof opts.log === "function" ? opts.log : (m) => sink?.(m);
|
|
121
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
122
|
+
const total = Math.ceil(list.length / batchSize);
|
|
123
|
+
const out = { results: [], batches: total, failed: 0, skippedIds: 0, stoppedBy: null, retries: 0 };
|
|
124
|
+
let consecutive = 0;
|
|
125
|
+
|
|
126
|
+
for (let b = 0; b < total; b++) {
|
|
127
|
+
const batch = list.slice(b * batchSize, (b + 1) * batchSize);
|
|
128
|
+
if (b > 0 && gapMs > 0) await sleep(gapMs);
|
|
129
|
+
for (let attempt = 0; ; attempt++) {
|
|
130
|
+
try {
|
|
131
|
+
out.results.push(await call(batch));
|
|
132
|
+
consecutive = 0;
|
|
133
|
+
break;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
const limited = isRateLimitError(e);
|
|
136
|
+
log(
|
|
137
|
+
`${label}: batch ${b + 1}/${total} (${batch.length} id(s)) failed: ${describeLookupError(e)}` +
|
|
138
|
+
(limited ? " [looks like a rate limit]" : ""),
|
|
139
|
+
);
|
|
140
|
+
if (limited && attempt < backoffMs.length) {
|
|
141
|
+
out.retries++;
|
|
142
|
+
log(`${label}: waiting ${backoffMs[attempt]}ms, then retrying batch ${b + 1}/${total}`);
|
|
143
|
+
await sleep(backoffMs[attempt]);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
out.failed++;
|
|
147
|
+
consecutive++;
|
|
148
|
+
if (limited) out.stoppedBy = "rate-limit";
|
|
149
|
+
else if (consecutive >= maxConsecutiveFailures) out.stoppedBy = "repeated-failures";
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (out.stoppedBy) {
|
|
154
|
+
out.skippedIds = list.length - (b + 1) * batchSize;
|
|
155
|
+
if (out.skippedIds < 0) out.skippedIds = 0;
|
|
156
|
+
log(
|
|
157
|
+
`${label}: stopped after batch ${b + 1}/${total} (${
|
|
158
|
+
out.stoppedBy === "rate-limit"
|
|
159
|
+
? "Zalo is limiting these lookups"
|
|
160
|
+
: `${consecutive} failed batches in a row`
|
|
161
|
+
}); ${out.skippedIds} id(s) in later batches were not looked up this time`,
|
|
162
|
+
);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (total > 1 && !out.stoppedBy && (out.failed || out.retries)) {
|
|
167
|
+
log(`${label}: finished ${total} batch(es), ${out.failed} failed, ${out.retries} retried`);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
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);
|