@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.
Files changed (41) 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 +71 -4
  12. package/src/commands/mcp.js +252 -4
  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/current-user-names.js +27 -7
  18. package/src/core/daemon-channel.js +191 -2
  19. package/src/core/daemon-log.js +250 -0
  20. package/src/core/daemon-sync.js +140 -9
  21. package/src/core/friend-requests.js +142 -0
  22. package/src/core/friend-search.js +41 -0
  23. package/src/core/group-admin.js +116 -0
  24. package/src/core/group-invite.js +53 -0
  25. package/src/core/sync-v2/gid.js +20 -20
  26. package/src/core/sync-v2/index.js +77 -11
  27. package/src/core/sync.js +4 -235
  28. package/src/core/user-info.js +170 -0
  29. package/src/index.js +2 -0
  30. package/src/mcp/mcp-config.js +1 -0
  31. package/src/mcp/mcp-tools.js +302 -2
  32. package/src/utils/cli-args.js +84 -0
  33. package/src/utils/secrets.js +69 -0
  34. package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
  35. package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
  36. package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
  37. package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
  38. package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
  39. package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
  40. package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
  41. 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
  }
@@ -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);
@@ -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
  };