@ardennguyen/zalo-agent-cli 2.5.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.5.3",
3
+ "version": "2.5.4",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,6 @@
77
77
  },
78
78
  "allowScripts": {
79
79
  "better-sqlite3@13.0.3": true,
80
- "@ardennguyen/zalo-agent-cli@2.5.3": true
80
+ "@ardennguyen/zalo-agent-cli@2.5.4": true
81
81
  }
82
82
  }
@@ -21,6 +21,8 @@ import {
21
21
  spawnDaemonProcess,
22
22
  } from "../core/daemon-channel.js";
23
23
  import { createSyncRunners } from "../core/daemon-sync.js";
24
+ import { createSocketContext, startEventLoopMonitor } from "../core/daemon-log.js";
25
+ import { setUserInfoLogger } from "../core/user-info.js";
24
26
  import { createSocketTap } from "../core/socket-tap.js";
25
27
  import { createSelfHeal } from "../core/self-heal.js";
26
28
  import { createReadStateSync, readStateEvent } from "../core/read-state.js";
@@ -521,6 +523,17 @@ export function registerListenCommand(program) {
521
523
  // and one raw reader on the socket for the fields zca-js drops.
522
524
  // Built once, like the receipter: they outlive every re-login.
523
525
  const stageLock = createStageLock();
526
+ // See mcp.js: stage progress, event-loop stalls and refused lookups belong in the log too.
527
+ const daemonLog = (m) => console.error(`[listen] ${m}`);
528
+ const loopMonitor = startEventLoopMonitor({
529
+ log: daemonLog,
530
+ context: () => {
531
+ const held = stageLock.current();
532
+ return held ? `the ${held.stage} stage was running` : "";
533
+ },
534
+ });
535
+ const socketContext = createSocketContext({ lock: stageLock, monitor: loopMonitor });
536
+ setUserInfoLogger(daemonLog);
524
537
  const socketTap = createSocketTap({ log: (line) => console.error(`[listen] ${line}`) });
525
538
  // Read state other devices report -- a conversation read on the
526
539
  // phone, an unread mark set or cleared there -- which zca-js
@@ -812,6 +825,7 @@ export function registerListenCommand(program) {
812
825
 
813
826
  // --- Lifecycle events (MUST be on same listener for reconnect to work) ---
814
827
  api.listener.on("connected", () => {
828
+ socketContext.markUp();
815
829
  if (reconnectCount > 0) {
816
830
  info(`Reconnected (#${reconnectCount}, uptime: ${uptime()}, events: ${eventCount})`);
817
831
  }
@@ -834,7 +848,7 @@ export function registerListenCommand(program) {
834
848
 
835
849
  api.listener.on("disconnected", (code, _reason) => {
836
850
  if (stopping) return;
837
- warning(`Disconnected (code: ${code}). Auto-retrying...`);
851
+ warning(`Disconnected (code: ${code}). Auto-retrying... (${socketContext.describe()})`);
838
852
  // First drop wins: a flapping socket that emits several
839
853
  // `disconnected` before one `connected` is ONE outage, and the
840
854
  // gap must span from the start of it.
@@ -854,7 +868,9 @@ export function registerListenCommand(program) {
854
868
  reconnectCount++;
855
869
  if (downSince === null) downSince = Date.now();
856
870
  syncManager.markDisconnected();
857
- warning(`Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()})`);
871
+ warning(
872
+ `Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()}; ${socketContext.describe()})`,
873
+ );
858
874
  await new Promise((r) => setTimeout(r, 5000));
859
875
  try {
860
876
  clearSession();
@@ -975,6 +991,8 @@ export function registerListenCommand(program) {
975
991
  runners: createSyncRunners({
976
992
  getApi,
977
993
  accountName: activeAcc.ownId,
994
+ log: daemonLog,
995
+ monitor: loopMonitor,
978
996
  onSelfName: (name) => updateAccountName(activeAcc.ownId, name),
979
997
  }),
980
998
  // The self-heal's lock: a stage and a catch-up never share the socket.
@@ -26,6 +26,8 @@ import {
26
26
  import { success, error, info, output } from "../utils/output.js";
27
27
  import { resolveWebhookSecret } from "../utils/secrets.js";
28
28
  import { createSyncRunners } from "../core/daemon-sync.js";
29
+ import { createSocketContext, startEventLoopMonitor } from "../core/daemon-log.js";
30
+ import { setUserInfoLogger } from "../core/user-info.js";
29
31
  import { createSyncJobs } from "../mcp/sync-jobs.js";
30
32
  import { createSocketTap } from "../core/socket-tap.js";
31
33
  import { createSelfHeal } from "../core/self-heal.js";
@@ -462,9 +464,24 @@ export function registerMCPCommands(program) {
462
464
  // the server so the tools can hold it too.
463
465
  const onSelfName = (name) => updateAccountName(activeAcc.ownId, name);
464
466
  const stageLock = createStageLock();
467
+ // What daemon.log learns beyond one-line events: every stage's progress (the restore used to
468
+ // report only to the caller), the event loop's stalls (a blocked loop cannot answer the socket's
469
+ // pings, and a bare close 1006 says nothing else), and what Zalo said when a lookup was refused.
470
+ const daemonLog = (m) => console.error(`[mcp] ${m}`);
471
+ const loopMonitor = startEventLoopMonitor({
472
+ log: daemonLog,
473
+ context: () => {
474
+ const held = stageLock.current();
475
+ return held ? `the ${held.stage} stage was running` : "";
476
+ },
477
+ });
478
+ const socketContext = createSocketContext({ lock: stageLock, monitor: loopMonitor });
479
+ setUserInfoLogger(daemonLog);
465
480
  const syncRunners = createSyncRunners({
466
481
  getApi,
467
482
  accountName: activeAcc.ownId,
483
+ log: daemonLog,
484
+ monitor: loopMonitor,
468
485
  onSelfName,
469
486
  onMessagesRestored: () => {
470
487
  for (const [tid, info] of getThreadNames()) {
@@ -790,6 +807,7 @@ export function registerMCPCommands(program) {
790
807
  });
791
808
 
792
809
  api.listener.on("connected", () => {
810
+ socketContext.markUp();
793
811
  if (reconnectCount > 0) {
794
812
  console.error(`[mcp] Reconnected (#${reconnectCount})`);
795
813
  }
@@ -798,7 +816,7 @@ export function registerMCPCommands(program) {
798
816
 
799
817
  api.listener.on("disconnected", (code) => {
800
818
  if (lifecycle.isStopping()) return;
801
- console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying...`);
819
+ console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying... (${socketContext.describe()})`);
802
820
  lifecycle.noteDown();
803
821
  });
804
822
 
@@ -812,7 +830,7 @@ export function registerMCPCommands(program) {
812
830
  }
813
831
  reconnectCount++;
814
832
  console.error(
815
- `[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount})`,
833
+ `[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount}; ${socketContext.describe()})`,
816
834
  );
817
835
  await new Promise((r) => setTimeout(r, 5000));
818
836
  try {
@@ -1,3 +1,8 @@
1
+ import { runPacedBatches, userInfoLog } from "./user-info.js";
2
+
3
+ /** The default `log`: silent unless a daemon registered a sink with `setUserInfoLogger`. */
4
+ const NO_LOG = () => {};
5
+
1
6
  /**
2
7
  * Read the account's complete alias snapshot. Do not reuse a previous snapshot:
3
8
  * an absent entry means the owner removed that alias. Failed/partial reads do
@@ -57,23 +62,38 @@ export function currentUserName(uid, profile, aliases = new Map()) {
57
62
  * @param {object} [options]
58
63
  * @returns {Promise<Map<string, {name: string, source: string}>>} UID -> current label
59
64
  */
60
- export async function resolveCurrentUserNames(api, uids, { aliases, profiles = new Map(), log = () => {} } = {}) {
65
+ export async function resolveCurrentUserNames(api, uids, { aliases, profiles = new Map(), log = NO_LOG } = {}) {
61
66
  const ids = [...new Set(uids.filter(Boolean).map(String))];
62
67
  if (!ids.length) return new Map();
63
68
  const aliasRequest = aliases ? Promise.resolve(aliases) : readUserAliases(api, log);
64
69
  const fresh = new Map(profiles);
65
70
  const missing = ids.filter((id) => /^[1-9][0-9]*$/.test(id) && !fresh.has(id));
66
- for (let i = 0; i < missing.length; i += 100) {
67
- try {
68
- if (typeof api?.getUserInfo !== "function") break;
69
- const result = await api.getUserInfo(missing.slice(i, i + 100));
71
+ // A live-API proxy throws on any property read while a session is re-logging in, so even the probe is guarded.
72
+ let canLookup = false;
73
+ try {
74
+ canLookup = typeof api?.getUserInfo === "function";
75
+ } catch {
76
+ /* session is reconnecting: fall back to aliases or UIDs */
77
+ }
78
+ if (missing.length && canLookup) {
79
+ // Paced, retried on a rate limit and stopped when Zalo keeps refusing; what Zalo said is logged.
80
+ const lookup = await runPacedBatches(missing, {
81
+ batchSize: 100,
82
+ label: "user-info",
83
+ log: log === NO_LOG ? undefined : log,
84
+ call: (batch) => api.getUserInfo(batch),
85
+ });
86
+ for (const result of lookup.results) {
70
87
  for (const bag of [result?.changed_profiles, result?.unchanged_profiles]) {
71
88
  for (const [uid, profile] of Object.entries(bag || {})) {
72
89
  if (profile) fresh.set(uid, profile);
73
90
  }
74
91
  }
75
- } catch {
76
- log("current profiles unavailable; falling back to aliases or UIDs");
92
+ }
93
+ if (lookup.failed || lookup.skippedIds) {
94
+ (log === NO_LOG ? userInfoLog : log)(
95
+ "current profiles unavailable for some ids; falling back to aliases or UIDs",
96
+ );
77
97
  }
78
98
  }
79
99
  const ownedAliases = await aliasRequest;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * What a daemon writes to daemon.log while it works, beyond the one-line events.
3
+ *
4
+ * Run directly, `zalo-agent sync` prints every phase as it happens. Run by a
5
+ * daemon -- the same restore, started by `zalo-agent sync` over the loopback
6
+ * channel or by the MCP tool `zalo_sync` -- the progress went to the CLI that
7
+ * asked, or into a 20-event ring behind `zalo_sync_status`, and daemon.log got
8
+ * "running the messages stage" and, much later, "finished" or "failed: <one
9
+ * line>". When a 100,000-message restore died with close code 1006 or hit a
10
+ * lookup limit, the log held nothing that said what the restore was doing, how
11
+ * long it had been going, or whether the process had been too busy to answer
12
+ * the socket's pings.
13
+ *
14
+ * Three small pieces fix that, each taking its `log` as an argument so a
15
+ * daemon decides where lines go and tests can read them:
16
+ *
17
+ * - createStageLogger: one stage's parameters, every progress phase with the
18
+ * time since it started, a periodic "still running" line, and the result
19
+ * or the failure with its code and cause.
20
+ * - startEventLoopMonitor: a stall of the event loop, in seconds. The restore
21
+ * decodes and stores on the same thread that answers the socket, and a
22
+ * blocked loop is the one cause of a bare close 1006 that nothing else
23
+ * records.
24
+ * - createSocketContext: what to say about the socket when it closes -- how
25
+ * long it was up, what stage was running, how bad the worst stall was.
26
+ */
27
+
28
+ /** A stall shorter than this is not worth a line. */
29
+ export const DEFAULT_STALL_WARN_MS = 3000;
30
+
31
+ const seconds = (ms) => (ms / 1000).toFixed(1);
32
+
33
+ /** `1h02m03s`, `4m05s` or `7s`. */
34
+ export function formatDuration(ms) {
35
+ const total = Math.max(0, Math.round(ms / 1000));
36
+ const h = Math.floor(total / 3600);
37
+ const m = Math.floor((total % 3600) / 60);
38
+ const s = total % 60;
39
+ if (h) return `${h}h${String(m).padStart(2, "0")}m${String(s).padStart(2, "0")}s`;
40
+ if (m) return `${m}m${String(s).padStart(2, "0")}s`;
41
+ return `${s}s`;
42
+ }
43
+
44
+ /**
45
+ * An error with everything that helps: its type, message, Zalo's code, the
46
+ * cause chain and the top of the stack.
47
+ *
48
+ * @param {unknown} e
49
+ * @returns {string}
50
+ */
51
+ export function describeError(e) {
52
+ if (!e || typeof e !== "object") return String(e);
53
+ const parts = [`${e.name && e.name !== "Error" ? `${e.name}: ` : ""}${e.message ?? String(e)}`];
54
+ if (e.code !== undefined && e.code !== null) parts.push(`(code ${e.code})`);
55
+ let cause = e.cause;
56
+ for (let depth = 0; cause && depth < 3; depth++, cause = cause.cause) {
57
+ parts.push(
58
+ `<- ${cause.name && cause.name !== "Error" ? `${cause.name}: ` : ""}${cause.message ?? String(cause)}`,
59
+ );
60
+ }
61
+ const stack = String(e.stack || "")
62
+ .split("\n")
63
+ .slice(1, 4)
64
+ .map((l) => l.trim())
65
+ .filter(Boolean);
66
+ return stack.length ? `${parts.join(" ")} [${stack.join(" | ")}]` : parts.join(" ");
67
+ }
68
+
69
+ /**
70
+ * Scalar fields of a result as `key=value`, for the "finished" line.
71
+ *
72
+ * @param {unknown} result
73
+ * @returns {string}
74
+ */
75
+ export function summarizeResult(result) {
76
+ if (!result || typeof result !== "object") return result === undefined ? "" : String(result);
77
+ return Object.entries(result)
78
+ .filter(([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).length <= 80)
79
+ .slice(0, 14)
80
+ .map(([k, v]) => `${k}=${v}`)
81
+ .join(" ");
82
+ }
83
+
84
+ /**
85
+ * Start watching the event loop for stalls.
86
+ *
87
+ * @param {object} args
88
+ * @param {(message: string) => void} args.log
89
+ * @param {number} [args.warnMs] - stalls at least this long are logged
90
+ * @param {number} [args.intervalMs]
91
+ * @param {() => string} [args.context] - what to add to a stall line, e.g. the running stage
92
+ * @param {() => number} [args.now] - test seam
93
+ * @param {{setInterval: Function, clearInterval: Function}} [args.timers] - test seam
94
+ * @returns {{stop: () => void, takeMax: () => number, tick: () => void}} `takeMax` returns
95
+ * the worst stall in ms since the last call and resets it; `tick` is exposed for tests
96
+ */
97
+ export function startEventLoopMonitor({
98
+ log,
99
+ warnMs = DEFAULT_STALL_WARN_MS,
100
+ intervalMs = 1000,
101
+ context = () => "",
102
+ now = Date.now,
103
+ timers = { setInterval, clearInterval },
104
+ }) {
105
+ let last = now();
106
+ let max = 0;
107
+ const tick = () => {
108
+ const t = now();
109
+ const stall = Math.max(0, t - last - intervalMs);
110
+ last = t;
111
+ if (stall > max) max = stall;
112
+ if (stall >= warnMs) {
113
+ const where = context();
114
+ log(
115
+ `event loop was blocked for ${seconds(stall)}s${where ? ` while ${where}` : ""}: ` +
116
+ "socket pings and reads were not served in that time",
117
+ );
118
+ }
119
+ };
120
+ const id = timers.setInterval(tick, intervalMs);
121
+ id?.unref?.();
122
+ return {
123
+ tick,
124
+ stop: () => timers.clearInterval(id),
125
+ takeMax: () => {
126
+ const m = max;
127
+ max = 0;
128
+ return m;
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Logging for one sync stage run.
135
+ *
136
+ * `live` events (a live message stored while the stage runs) can be one per
137
+ * message, so they are counted and summarized, not printed one by one.
138
+ *
139
+ * @param {object} args
140
+ * @param {string} args.stage - "messages", "reactions", "history"
141
+ * @param {(message: string) => void} args.log
142
+ * @param {number} [args.heartbeatMs] - how often a "still running" line is written
143
+ * @param {number} [args.liveFlushMs] - how often the live-event count is written
144
+ * @param {() => number} [args.now] - test seam
145
+ * @param {{setInterval: Function, clearInterval: Function}} [args.timers] - test seam
146
+ * @param {{takeMax: () => number}} [args.monitor] - event-loop monitor, for the worst stall per interval
147
+ * @returns {{start: (params?: object) => void, event: (e: object) => void,
148
+ * finish: (result?: unknown) => void, fail: (error: unknown) => void, stop: () => void}}
149
+ */
150
+ export function createStageLogger({
151
+ stage,
152
+ log,
153
+ heartbeatMs = 60000,
154
+ liveFlushMs = 30000,
155
+ now = Date.now,
156
+ timers = { setInterval, clearInterval },
157
+ monitor = null,
158
+ }) {
159
+ const tag = `[sync:${stage}]`;
160
+ let startedAt = now();
161
+ let lastEventAt = startedAt;
162
+ let lastEvent = "";
163
+ let liveCount = 0;
164
+ let liveFlushedAt = startedAt;
165
+ let timer = null;
166
+
167
+ const elapsed = () => formatDuration(now() - startedAt);
168
+ const flushLive = () => {
169
+ if (!liveCount) return;
170
+ log(`${tag} +${elapsed()} stored ${liveCount} live event(s) while running`);
171
+ liveCount = 0;
172
+ liveFlushedAt = now();
173
+ };
174
+ const heartbeat = () => {
175
+ const quiet = now() - lastEventAt;
176
+ const stall = monitor?.takeMax?.() ?? 0;
177
+ log(
178
+ `${tag} still running: +${elapsed()}, last progress ${formatDuration(quiet)} ago` +
179
+ (lastEvent ? ` (${lastEvent})` : "") +
180
+ (stall >= 1000 ? `, worst event-loop stall ${seconds(stall)}s` : ""),
181
+ );
182
+ };
183
+
184
+ return {
185
+ start(params = {}) {
186
+ startedAt = now();
187
+ lastEventAt = startedAt;
188
+ liveFlushedAt = startedAt;
189
+ const shown = Object.entries(params || {})
190
+ .filter(([, v]) => v !== undefined && v !== null && typeof v !== "object")
191
+ .map(([k, v]) => `${k}=${v}`)
192
+ .join(" ");
193
+ log(`${tag} started${shown ? ` (${shown})` : ""}`);
194
+ timer = timers.setInterval(heartbeat, heartbeatMs);
195
+ timer?.unref?.();
196
+ },
197
+ event(e) {
198
+ const phase = String(e?.phase ?? "progress");
199
+ lastEventAt = now();
200
+ if (phase === "live") {
201
+ liveCount++;
202
+ if (now() - liveFlushedAt >= liveFlushMs) flushLive();
203
+ return;
204
+ }
205
+ const detail = String(e?.detail ?? "");
206
+ lastEvent = `${phase}${detail ? `: ${detail.slice(0, 80)}` : ""}`;
207
+ log(`${tag} +${elapsed()} ${phase}${detail ? `: ${detail}` : ""}`);
208
+ },
209
+ finish(result) {
210
+ flushLive();
211
+ const summary = summarizeResult(result);
212
+ log(`${tag} finished in ${elapsed()}${summary ? `: ${summary}` : ""}`);
213
+ },
214
+ fail(error) {
215
+ flushLive();
216
+ log(`${tag} failed after ${elapsed()}: ${describeError(error)}`);
217
+ },
218
+ stop() {
219
+ if (timer) timers.clearInterval(timer);
220
+ timer = null;
221
+ },
222
+ };
223
+ }
224
+
225
+ /**
226
+ * What to say about the socket when it drops or closes.
227
+ *
228
+ * @param {object} args
229
+ * @param {{current: () => ({stage: string, startedAt: number}|null)}} [args.lock] - the daemon's stage lock
230
+ * @param {{takeMax: () => number}} [args.monitor] - event-loop monitor
231
+ * @param {() => number} [args.now] - test seam
232
+ * @returns {{markUp: () => void, describe: () => string}}
233
+ */
234
+ export function createSocketContext({ lock = null, monitor = null, now = Date.now } = {}) {
235
+ let upSince = null;
236
+ return {
237
+ markUp() {
238
+ upSince = now();
239
+ },
240
+ describe() {
241
+ const parts = [];
242
+ parts.push(upSince === null ? "socket age unknown" : `socket was up ${formatDuration(now() - upSince)}`);
243
+ const held = lock?.current?.();
244
+ if (held) parts.push(`stage in progress: ${held.stage} for ${formatDuration(now() - held.startedAt)}`);
245
+ const stall = monitor?.takeMax?.() ?? 0;
246
+ if (stall >= 1000) parts.push(`worst event-loop stall since the last check ${seconds(stall)}s`);
247
+ return parts.join("; ");
248
+ },
249
+ };
250
+ }
@@ -30,6 +30,7 @@
30
30
  import { SyncV2 } from "./sync-v2/index.js";
31
31
  import { drainReactions, placeUnresolvedReactions } from "./sync-v2/reactions.js";
32
32
  import { fetchAndCacheHistory } from "./history-fetch.js";
33
+ import { createStageLogger } from "./daemon-log.js";
33
34
 
34
35
  /**
35
36
  * True while the listener's socket is connecting or open.
@@ -47,6 +48,103 @@ function socketAlive(api) {
47
48
  return Boolean(ws) && ws.readyState <= 1;
48
49
  }
49
50
 
51
+ /**
52
+ * Wait for the daemon's own recovery to put a live socket back.
53
+ *
54
+ * A stage that loses its socket must not open one itself: `listen` and `mcp`
55
+ * answer a close by logging in again and rebuilding the api, and a stage
56
+ * tearing the old socket down or starting a new one would race that. So this
57
+ * only WATCHES `getApi()` -- it never starts, stops or logs in anything -- and
58
+ * returns once the daemon's api has an OPEN socket that is not the one the
59
+ * stage lost.
60
+ *
61
+ * @param {object} args
62
+ * @param {() => object} args.getApi - the daemon's current api
63
+ * @param {object|null} args.lostWs - the socket object the stage was using
64
+ * @param {object|null} args.lostApi - the api object the stage was using
65
+ * @param {number} [args.timeoutMs]
66
+ * @param {number} [args.pollMs]
67
+ * @param {(message: string) => void} [args.log]
68
+ * @param {(ms: number) => Promise<void>} [args.sleep] - test seam
69
+ * @param {() => number} [args.now] - test seam
70
+ * @returns {Promise<{api: object}|null>} the api to continue on, or null when none came back in time
71
+ */
72
+ export async function waitForDaemonSocket({
73
+ getApi,
74
+ lostWs,
75
+ lostApi,
76
+ timeoutMs = 120000,
77
+ pollMs = 500,
78
+ log = () => {},
79
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
80
+ now = Date.now,
81
+ }) {
82
+ const startedAt = now();
83
+ log(`the socket was lost mid-stage; waiting up to ${Math.round(timeoutMs / 1000)}s for the daemon to reconnect`);
84
+ for (;;) {
85
+ let api = null;
86
+ try {
87
+ api = getApi();
88
+ } catch {
89
+ /* the daemon is between sessions */
90
+ }
91
+ const ws = api?.listener?.ws;
92
+ if (ws && ws.readyState === 1 && (api !== lostApi || ws !== lostWs)) {
93
+ log(`the daemon reconnected after ${((now() - startedAt) / 1000).toFixed(1)}s; resuming the stage`);
94
+ return { api };
95
+ }
96
+ if (now() - startedAt >= timeoutMs) {
97
+ log(
98
+ `the daemon did not reconnect within ${Math.round(timeoutMs / 1000)}s; the stage ends with what it has`,
99
+ );
100
+ return null;
101
+ }
102
+ await sleep(pollMs);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Wrap each runner so it writes to the daemon's log.
108
+ *
109
+ * Here, rather than in the channel route or the MCP job tracker, because both
110
+ * of those call the same runners: the channel streams events to the CLI that
111
+ * asked and the job tracker keeps the last twenty, and neither wrote anything
112
+ * to daemon.log. Wrapping the runners covers `zalo-agent sync`, `msg history`
113
+ * and the `zalo_sync` tool alike.
114
+ *
115
+ * @param {Record<string, Function>} runners
116
+ * @param {{log: (message: string) => void, monitor: object|null}} deps
117
+ * @returns {Record<string, Function>}
118
+ */
119
+ function traceRunners(runners, { log, monitor }) {
120
+ return Object.fromEntries(
121
+ Object.entries(runners).map(([stage, run]) => [
122
+ stage,
123
+ async (params = {}, onEvent = () => {}) => {
124
+ const trace = createStageLogger({ stage, log, monitor });
125
+ trace.start(params);
126
+ try {
127
+ const result = await run(params, (event) => {
128
+ try {
129
+ trace.event(event);
130
+ } catch {
131
+ /* a logging problem never reaches the stage */
132
+ }
133
+ onEvent(event);
134
+ });
135
+ trace.finish(result);
136
+ return result;
137
+ } catch (error) {
138
+ trace.fail(error);
139
+ throw error;
140
+ } finally {
141
+ trace.stop();
142
+ }
143
+ },
144
+ ]),
145
+ );
146
+ }
147
+
50
148
  /**
51
149
  * Build the stage runners for `startDaemonChannel({ runners })`.
52
150
  *
@@ -55,9 +153,20 @@ function socketAlive(api) {
55
153
  * @param {string} args.accountName - ownId; picks the account data dir
56
154
  * @param {(res: object) => void|Promise<void>} [args.onMessagesRestored] - optional hook after messages restore
57
155
  * @param {(name: string) => void|Promise<void>} [args.onSelfName] - optional hook when active account's profile name is refreshed
156
+ * @param {(message: string) => void} [args.log] - where each stage writes its parameters, every progress
157
+ * phase, a periodic "still running" line and its result or failure (daemon.log); silent without it
158
+ * @param {{takeMax: () => number}} [args.monitor] - event-loop monitor; its worst stall is added to the
159
+ * "still running" lines
58
160
  * @returns {Record<string, (params: object, onEvent: (e: object) => void) => Promise<object>>}
59
161
  */
60
- export function createSyncRunners({ getApi, accountName, onMessagesRestored, onSelfName }) {
162
+ export function createSyncRunners({
163
+ getApi,
164
+ accountName,
165
+ onMessagesRestored,
166
+ onSelfName,
167
+ log = () => {},
168
+ monitor = null,
169
+ }) {
61
170
  /** The live api, or a refusal the CLI can print. */
62
171
  const liveApi = (what) => {
63
172
  const api = getApi();
@@ -65,7 +174,7 @@ export function createSyncRunners({ getApi, accountName, onMessagesRestored, onS
65
174
  return api;
66
175
  };
67
176
 
68
- return {
177
+ const runners = {
69
178
  /**
70
179
  * transfer-sync-v2 message restore (socket cmd 590/591).
71
180
  *
@@ -81,6 +190,8 @@ export function createSyncRunners({ getApi, accountName, onMessagesRestored, onS
81
190
  */
82
191
  async messages(params = {}, onEvent = () => {}) {
83
192
  const api = liveApi("restore");
193
+ // The api and socket the stage is on, kept current across reconnects.
194
+ let current = { api, ws: api.listener?.ws ?? null };
84
195
  const res = await new SyncV2(api, accountName).restore({
85
196
  days: params.days ?? null,
86
197
  from: params.from,
@@ -95,13 +206,31 @@ export function createSyncRunners({ getApi, accountName, onMessagesRestored, onS
95
206
  // the server's own 180s interval, which is the entire width of
96
207
  // the phone wait. So the restore keeps its own.
97
208
  keepAlive: true,
98
- // Deliberately NO `reconnect`. The CLI passes one because it
99
- // owns the socket's lifecycle for the length of the run; here
100
- // the daemon does, and it reconnects on its own terms --
101
- // `listen`/`mcp` answer a close by re-logging-in and rebuilding
102
- // the api. A stage tearing the socket down underneath that
103
- // would race it. A socket lost mid-stage therefore ends as a
104
- // partial restore and the daemon recovers the session.
209
+ // A `reconnect` that only WAITS. The CLI passes one that opens the
210
+ // socket itself, because it owns the socket's lifecycle for the
211
+ // length of the run; here the daemon does, and it reconnects on
212
+ // its own terms -- `listen`/`mcp` answer a close by re-logging-in
213
+ // and rebuilding the api, and a stage tearing the socket down or
214
+ // starting one would race that. So this never touches the
215
+ // listener: it watches `getApi()` until the daemon's recovery has
216
+ // an open socket, then hands that api back and the restore
217
+ // re-requests the batches that never arrived. Before this, any
218
+ // lost socket (close 1006, which Zalo does not offer to retry) ended
219
+ // a long restore as partial and the next one asked the phone again.
220
+ // A long restore on a busy account can lose its socket more than once, so a daemon allows
221
+ // three resumes (the CLI's default is one); `params.maxResumes` overrides.
222
+ maxResumes: Number.isInteger(params.maxResumes) ? params.maxResumes : 3,
223
+ reconnect: async () => {
224
+ const back = await waitForDaemonSocket({
225
+ getApi,
226
+ lostWs: current.ws,
227
+ lostApi: current.api,
228
+ timeoutMs: Number(params.reconnectWaitMs) || undefined,
229
+ log: (m) => log(`[sync:messages] ${m}`),
230
+ });
231
+ if (back) current = { api: back.api, ws: back.api.listener?.ws ?? null };
232
+ return back;
233
+ },
105
234
  onStatus: onEvent,
106
235
  onSelfName,
107
236
  });
@@ -189,4 +318,6 @@ export function createSyncRunners({ getApi, accountName, onMessagesRestored, onS
189
318
  };
190
319
  },
191
320
  };
321
+
322
+ return traceRunners(runners, { log, monitor });
192
323
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { createRequire } from "node:module";
21
21
  import { dirname, join } from "node:path";
22
+ import { runPacedBatches } from "../user-info.js";
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
 
@@ -68,15 +69,13 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
68
69
  return out;
69
70
  }
70
71
  const idToUser = new Map();
71
- for (let i = 0; i < globalIds.length; i += GID_BATCH) {
72
- const batch = globalIds.slice(i, i + GID_BATCH);
73
- let map;
74
- try {
75
- map = await gidDecrypt(batch);
76
- } catch (e) {
77
- log(`gid/decrypt batch failed (${e.message})`);
78
- continue;
79
- }
72
+ const decrypted = await runPacedBatches(globalIds, {
73
+ batchSize: GID_BATCH,
74
+ label: "gid/decrypt",
75
+ log,
76
+ call: (batch) => gidDecrypt(batch),
77
+ });
78
+ for (const map of decrypted.results) {
80
79
  for (const [gid, uid] of Object.entries(map || {})) if (uid) idToUser.set(gid, String(uid));
81
80
  }
82
81
  if (!idToUser.size) return out;
@@ -85,20 +84,21 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
85
84
  const names = new Map();
86
85
  const zaloNames = new Map();
87
86
  const uids = [...new Set([...idToUser.values()])];
88
- for (let i = 0; i < uids.length; i += NAME_BATCH) {
89
- const batch = uids.slice(i, i + NAME_BATCH);
90
- try {
91
- const info = await api.getUserInfo(batch);
92
- for (const bag of [info?.changed_profiles, info?.unchanged_profiles]) {
93
- for (const [uid, p] of Object.entries(bag || {})) {
94
- if (!names.has(uid) && p) names.set(uid, p.zaloName || p.displayName || "");
95
- if (p?.zaloName) zaloNames.set(uid, p.zaloName);
96
- }
87
+ const named = await runPacedBatches(uids, {
88
+ batchSize: NAME_BATCH,
89
+ label: "getUserInfo",
90
+ log,
91
+ call: (batch) => api.getUserInfo(batch),
92
+ });
93
+ for (const info of named.results) {
94
+ for (const bag of [info?.changed_profiles, info?.unchanged_profiles]) {
95
+ for (const [uid, p] of Object.entries(bag || {})) {
96
+ if (!names.has(uid) && p) names.set(uid, p.zaloName || p.displayName || "");
97
+ if (p?.zaloName) zaloNames.set(uid, p.zaloName);
97
98
  }
98
- } catch (e) {
99
- log(`getUserInfo batch failed (${e.message}); some names may be blank`);
100
99
  }
101
100
  }
101
+ if (named.failed || named.skippedIds) log("some names may be blank (getUserInfo did not answer for every batch)");
102
102
 
103
103
  for (const [gid, uid] of idToUser)
104
104
  out.set(gid, { id: uid, name: names.get(uid) || "", zaloName: zaloNames.get(uid) || "" });
@@ -82,6 +82,18 @@ const DEFAULT_WAVE_SIZE = 4;
82
82
  * dying.
83
83
  */
84
84
  const MAX_RESUMES = 1;
85
+ /** The most reconnects a caller may ask a single restore to attempt. */
86
+ const MAX_RESUMES_CAP = 5;
87
+
88
+ /**
89
+ * How many reconnects a restore may spend: the caller's `maxResumes` (a whole number, capped), else one.
90
+ *
91
+ * @param {unknown} requested
92
+ * @returns {number}
93
+ */
94
+ export function resolveMaxResumes(requested) {
95
+ return Number.isInteger(requested) ? Math.min(MAX_RESUMES_CAP, Math.max(0, requested)) : MAX_RESUMES;
96
+ }
85
97
  const DAY_MS = 24 * 60 * 60 * 1000;
86
98
 
87
99
  /**
@@ -277,6 +289,21 @@ export function resolveWaitBudget(budgetMs, fallbackMs) {
277
289
  return 30000;
278
290
  }
279
291
 
292
+ /**
293
+ * The api a `reconnect` callback handed back, if it handed one.
294
+ *
295
+ * `true` means "the same listener is live again" (the CLI re-opens the socket on
296
+ * the api it already has). `{api}` means the caller recovered by building a new
297
+ * api, as a daemon does after logging in again, and the restore has to carry on
298
+ * over that api's listener instead.
299
+ *
300
+ * @param {unknown} back - what `opts.reconnect()` resolved
301
+ * @returns {object|null} the new api, or null when the old one is still the one
302
+ */
303
+ export function reconnectedApi(back) {
304
+ return back && typeof back === "object" && back.api?.listener ? back.api : null;
305
+ }
306
+
280
307
  /**
281
308
  * Which shards still have to go out, and whether a dropped socket is worth one
282
309
  * more reconnect.
@@ -481,17 +508,43 @@ export class SyncV2 {
481
508
  }
482
509
  }
483
510
 
484
- /** Refresh existing DM peers outside the requested message window, including non-friends. */
485
- async _refreshDmNames(threadMap) {
511
+ /**
512
+ * Refresh existing DM peers outside the requested message window, including non-friends.
513
+ *
514
+ * Every restore used to look up every non-friend DM peer in the cache: on a busy account that
515
+ * is thousands of ids, each restore, and Zalo answered with a limit. So the run asks for at most
516
+ * `maxLookups` of them (500 unless ZALO_SYNC_NAME_REFRESH_MAX says otherwise), peers whose
517
+ * cached title is missing or just their id first, because those are the ones a restore can
518
+ * actually improve. The rest keep the titles they have until a later restore reaches them.
519
+ *
520
+ * @param {Map<string, object>} threadMap
521
+ * @param {(message: string) => void} [log]
522
+ */
523
+ async _refreshDmNames(threadMap, log = () => {}) {
486
524
  const profiles = new Map(
487
525
  [...threadMap.values()]
488
526
  .filter((t) => t.type === "dm" && t.zaloName)
489
527
  .map((t) => [t.id, { zaloName: t.zaloName }]),
490
528
  );
529
+ const peers = getRecentThreads(-1, "dm");
530
+ const unknown = (t) => !t.name || String(t.name) === String(t.threadId);
531
+ const wanted = peers.filter((t) => !profiles.has(String(t.threadId)));
532
+ wanted.sort((a, b) => Number(unknown(b)) - Number(unknown(a)));
533
+ const envCap = Number.parseInt(process.env.ZALO_SYNC_NAME_REFRESH_MAX ?? "", 10);
534
+ const maxLookups = Number.isFinite(envCap) && envCap >= 0 ? envCap : 500;
535
+ const chosen = wanted.slice(0, maxLookups);
536
+ if (wanted.length) {
537
+ log(
538
+ `refreshing ${chosen.length} of ${wanted.length} non-friend DM peer name(s)` +
539
+ (chosen.length < wanted.length
540
+ ? ` (cap ${maxLookups}; the rest keep their cached titles until a later restore)`
541
+ : ""),
542
+ );
543
+ }
491
544
  const names = await resolveCurrentUserNames(
492
545
  this.api,
493
- getRecentThreads(-1, "dm").map((t) => String(t.threadId)),
494
- { aliases: this.userAliases, profiles },
546
+ [...profiles.keys(), ...chosen.map((t) => String(t.threadId))],
547
+ { aliases: this.userAliases, profiles, log },
495
548
  );
496
549
  for (const [id, current] of names) {
497
550
  upsertThread({ threadId: id, type: "dm", name: current.name, lastUpdate: 0 });
@@ -561,9 +614,13 @@ export class SyncV2 {
561
614
  * @param {boolean} [opts.liveStore=true] false when the caller already taps live traffic
562
615
  * @param {number|false} [opts.keepAlive] heartbeat interval in ms, or false when the
563
616
  * caller already pings this socket for the whole window it owns.
564
- * @param {() => Promise<boolean>} [opts.reconnect] re-open the listener socket after a
565
- * drop and resolve true once it is live. Supplied by the caller because this class
617
+ * @param {number} [opts.maxResumes] reconnects this run may spend (default 1, at most 5); each one
618
+ * that needs the phone's confirmation again costs the owner a tap.
619
+ * @param {() => Promise<boolean|{api: object}>} [opts.reconnect] re-open the listener socket
620
+ * after a drop and resolve true once it is live. Supplied by the caller because this class
566
621
  * never starts or stops the listener itself. Without it a dropped socket ends the run.
622
+ * A caller that cannot reuse the old listener (a daemon, which re-logs-in and builds a new
623
+ * api) resolves `{api}` instead: the restore carries on over that api's listener.
567
624
  * @returns {Promise<{conversations:number, messagesSaved:number, threadsMapped:number, threadsUnmapped:number, reason:string, days:number|null, from:number, confirmed:boolean, resumes:number, rePrompted:boolean}>}
568
625
  */
569
626
  async restore(opts = {}) {
@@ -586,10 +643,12 @@ export class SyncV2 {
586
643
 
587
644
  initDb(resolve(this.accountDir, "zalo.db"));
588
645
  this._refreshThreadNames(threadMap);
589
- await this._refreshDmNames(threadMap);
646
+ await this._refreshDmNames(threadMap, log);
590
647
  await this._refreshSelfName(opts.onSelfName);
591
648
 
592
- const L = this.api.listener;
649
+ // `let`: a daemon recovers a lost socket by logging in again, which builds a new api and a new
650
+ // listener, and `opts.reconnect` can hand that api back (see `reconnect` below).
651
+ let L = this.api.listener;
593
652
  const sessions = new Map(); // syncId -> {kind, batches:[], statuses:[], done, err}
594
653
  const onMsg = async (buf) => {
595
654
  if (!(buf instanceof Buffer) || buf.length < 4 || buf.readUInt16LE(1) !== 601) return;
@@ -734,7 +793,14 @@ export class SyncV2 {
734
793
  detachSocketTaps();
735
794
  let ok = false;
736
795
  try {
737
- ok = Boolean(await opts.reconnect());
796
+ const back = await opts.reconnect();
797
+ ok = Boolean(back);
798
+ // A new api means a new listener: the old one's socket is gone for good.
799
+ const next = ok ? reconnectedApi(back) : null;
800
+ if (next) {
801
+ this.api = next;
802
+ L = next.listener;
803
+ }
738
804
  } catch (e) {
739
805
  onStatus({ phase: "warn", detail: `reconnect failed: ${e?.message || e}` });
740
806
  return false;
@@ -1072,7 +1138,7 @@ export class SyncV2 {
1072
1138
  // to reconnect -- this class never starts the listener itself.
1073
1139
  const schedule = createShardSchedule(shards.length, {
1074
1140
  waveSize,
1075
- maxResumes: typeof opts.reconnect === "function" ? MAX_RESUMES : 0,
1141
+ maxResumes: typeof opts.reconnect === "function" ? resolveMaxResumes(opts.maxResumes) : 0,
1076
1142
  });
1077
1143
 
1078
1144
  // ---- wave by wave: send, wait, decrypt, decode, store ----
@@ -1228,7 +1294,7 @@ export class SyncV2 {
1228
1294
  runDeadline = Date.now() + shardBudget(schedule.remaining());
1229
1295
  onStatus({
1230
1296
  phase: "reconnect",
1231
- detail: `reconnected — re-requesting the ${schedule.remaining()} batch(es) that never arrived (attempt ${schedule.resumes()} of ${MAX_RESUMES})`,
1297
+ detail: `reconnected — re-requesting the ${schedule.remaining()} batch(es) that never arrived (attempt ${schedule.resumes()} of ${resolveMaxResumes(opts.maxResumes)})`,
1232
1298
  });
1233
1299
  continue;
1234
1300
  }
@@ -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
+ }