@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 +2 -2
- package/src/commands/listen.js +20 -2
- package/src/commands/mcp.js +20 -2
- package/src/core/current-user-names.js +27 -7
- package/src/core/daemon-log.js +250 -0
- package/src/core/daemon-sync.js +140 -9
- package/src/core/sync-v2/gid.js +20 -20
- package/src/core/sync-v2/index.js +77 -11
- package/src/core/user-info.js +170 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ardennguyen/zalo-agent-cli",
|
|
3
|
-
"version": "2.5.
|
|
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.
|
|
80
|
+
"@ardennguyen/zalo-agent-cli@2.5.4": true
|
|
81
81
|
}
|
|
82
82
|
}
|
package/src/commands/listen.js
CHANGED
|
@@ -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(
|
|
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.
|
package/src/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
}
|
|
76
|
-
|
|
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
|
+
}
|
package/src/core/daemon-sync.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
//
|
|
99
|
-
// owns the socket's lifecycle for the
|
|
100
|
-
// the daemon does, and it reconnects on
|
|
101
|
-
// `listen`/`mcp` answer a close by re-logging-in
|
|
102
|
-
// the api
|
|
103
|
-
// would race
|
|
104
|
-
//
|
|
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
|
}
|
package/src/core/sync-v2/gid.js
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
485
|
-
|
|
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
|
-
|
|
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 {
|
|
565
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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" ?
|
|
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 ${
|
|
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
|
+
}
|