@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +41 -33
  2. package/node_modules/zca-js/dist/apis.js +0 -8
  3. package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
  4. package/package.json +3 -3
  5. package/patches/zca-js+2.2.0.patch +4 -236
  6. package/src/commands/bank.js +126 -0
  7. package/src/commands/catalog.js +14 -0
  8. package/src/commands/conv.js +19 -0
  9. package/src/commands/friend.js +66 -21
  10. package/src/commands/group.js +38 -2
  11. package/src/commands/listen.js +71 -4
  12. package/src/commands/mcp.js +252 -4
  13. package/src/commands/msg.js +60 -0
  14. package/src/commands/profile.js +39 -0
  15. package/src/commands/sync.js +1 -68
  16. package/src/core/bank-accounts.js +52 -0
  17. package/src/core/current-user-names.js +27 -7
  18. package/src/core/daemon-channel.js +191 -2
  19. package/src/core/daemon-log.js +250 -0
  20. package/src/core/daemon-sync.js +140 -9
  21. package/src/core/friend-requests.js +142 -0
  22. package/src/core/friend-search.js +41 -0
  23. package/src/core/group-admin.js +116 -0
  24. package/src/core/group-invite.js +53 -0
  25. package/src/core/sync-v2/gid.js +20 -20
  26. package/src/core/sync-v2/index.js +77 -11
  27. package/src/core/sync.js +4 -235
  28. package/src/core/user-info.js +170 -0
  29. package/src/index.js +2 -0
  30. package/src/mcp/mcp-config.js +1 -0
  31. package/src/mcp/mcp-tools.js +302 -2
  32. package/src/utils/cli-args.js +84 -0
  33. package/src/utils/secrets.js +69 -0
  34. package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
  35. package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
  36. package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
  37. package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
  38. package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
  39. package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
  40. package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
  41. package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
@@ -81,6 +81,7 @@ import fs from "node:fs";
81
81
  import path from "node:path";
82
82
  import { spawn, spawnSync } from "node:child_process";
83
83
  import { randomBytes, timingSafeEqual } from "node:crypto";
84
+ import { maskSecretArgs } from "../utils/secrets.js";
84
85
 
85
86
  const CHANNEL_FILE = "daemon-channel.json";
86
87
  const LOCK_FILE = "daemon.lock";
@@ -291,6 +292,10 @@ function readJsonBody(req, reply) {
291
292
  * @param {() => void} [args.onStop] - graceful shutdown callback invoked by
292
293
  * `POST /stop` so `--stop` runs the same teardown as `SIGINT` (stamping
293
294
  * `markConnected`, flushing delivered receipts, releasing `daemon.lock`).
295
+ * @param {(params: object, signal: AbortSignal) => Promise<object>} [args.onWait] -
296
+ * long-poll callback invoked by `POST /wait` (`zalo-agent mcp wait`).
297
+ * @param {(params: object) => Promise<object>} [args.onMarkRead] -
298
+ * read-cursor callback invoked by `POST /mark-read` (`zalo-agent mcp mark-read`).
294
299
  * @returns {Promise<{port: number, stop: () => void}>}
295
300
  */
296
301
  export function startDaemonChannel({
@@ -300,6 +305,8 @@ export function startDaemonChannel({
300
305
  runners = {},
301
306
  lock = createStageLock(),
302
307
  onStop = null,
308
+ onWait = null,
309
+ onMarkRead = null,
303
310
  }) {
304
311
  const token = randomBytes(24).toString("hex");
305
312
 
@@ -416,6 +423,40 @@ export function startDaemonChannel({
416
423
  });
417
424
  };
418
425
 
426
+ /** Long-poll the MCP daemon's live message buffer (`zalo-agent mcp wait`). */
427
+ const serveWait = async (body, res, reply) => {
428
+ if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
429
+ if (typeof onWait !== "function") {
430
+ return reply(503, {
431
+ error: "this daemon does not have an MCP message buffer (run `zalo-agent mcp start` instead of `listen`)",
432
+ });
433
+ }
434
+ const ac = new AbortController();
435
+ res.on("close", () => ac.abort());
436
+ try {
437
+ const result = await onWait(body, ac.signal);
438
+ if (!res.writableEnded) reply(200, { ok: true, ...result });
439
+ } catch (e) {
440
+ if (!res.writableEnded) reply(200, { ok: false, error: e?.message || String(e) });
441
+ }
442
+ };
443
+
444
+ /** Advance one consumer's read cursor in the MCP daemon's buffer (`zalo-agent mcp mark-read`). */
445
+ const serveMarkRead = async (body, reply) => {
446
+ if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
447
+ if (typeof onMarkRead !== "function") {
448
+ return reply(503, {
449
+ error: "this daemon does not have an MCP message buffer (run `zalo-agent mcp start` instead of `listen`)",
450
+ });
451
+ }
452
+ try {
453
+ const result = await onMarkRead(body);
454
+ reply(200, { ok: true, ...result });
455
+ } catch (e) {
456
+ reply(200, { ok: false, error: e?.message || String(e) });
457
+ }
458
+ };
459
+
419
460
  const server = http.createServer(async (req, res) => {
420
461
  const reply = (code, body) => {
421
462
  res.writeHead(code, { "content-type": "application/json" });
@@ -424,11 +465,21 @@ export function startDaemonChannel({
424
465
  if (req.method !== "POST") return reply(404, { error: "not found" });
425
466
 
426
467
  const stage = SYNC_ROUTE.exec(req.url || "")?.[1];
427
- if (req.url !== "/send-attachments" && req.url !== "/stop" && !stage) return reply(404, { error: "not found" });
468
+ if (
469
+ req.url !== "/send-attachments" &&
470
+ req.url !== "/stop" &&
471
+ req.url !== "/wait" &&
472
+ req.url !== "/mark-read" &&
473
+ !stage
474
+ ) {
475
+ return reply(404, { error: "not found" });
476
+ }
428
477
 
429
478
  const body = await readJsonBody(req, reply);
430
479
  if (!body) return; // already answered, or the request went away
431
480
  if (req.url === "/stop") return serveStop(body, reply);
481
+ if (req.url === "/wait") return serveWait(body, res, reply);
482
+ if (req.url === "/mark-read") return serveMarkRead(body, reply);
432
483
  return stage ? serveStage(stage, body, res, reply) : serveAttachments(body, reply);
433
484
  });
434
485
 
@@ -781,6 +832,143 @@ export function stopViaDaemon(accountDir, { timeoutMs = 3000 } = {}) {
781
832
  });
782
833
  }
783
834
 
835
+ /**
836
+ * Long-poll the running MCP daemon's live message buffer (`zalo-agent mcp wait`),
837
+ * mirroring the `zalo_wait_for_message` MCP tool over the loopback channel.
838
+ *
839
+ * @param {string} accountDir
840
+ * @param {{threadId?: string|null, since?: number, timeoutSec?: number, consumer?: string}} [opts]
841
+ * @returns {Promise<{ok: boolean, status?: "message"|"timeout"|number, message?: object|null, cursor?: number, error?: string}|null>}
842
+ * null when no daemon channel is published
843
+ */
844
+ export function waitViaDaemon(accountDir, { threadId = null, since = undefined, timeoutSec = 55, consumer } = {}) {
845
+ const chan = getDaemonChannel(accountDir);
846
+ if (!chan) return Promise.resolve(null);
847
+
848
+ const clampedSec = Math.max(1, Math.min(300, Number(timeoutSec) || 55));
849
+ const payload = JSON.stringify({
850
+ token: chan.token,
851
+ ...(threadId ? { threadId: String(threadId) } : {}),
852
+ ...(typeof since === "number" && Number.isFinite(since) ? { since } : {}),
853
+ timeoutSec: clampedSec,
854
+ ...(consumer ? { consumer: String(consumer) } : {}),
855
+ });
856
+
857
+ return new Promise((resolve) => {
858
+ const r = http.request(
859
+ {
860
+ host: "127.0.0.1",
861
+ port: chan.port,
862
+ path: "/wait",
863
+ method: "POST",
864
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
865
+ timeout: (clampedSec + 10) * 1000,
866
+ agent: false,
867
+ },
868
+ (res) => {
869
+ let raw = "";
870
+ res.setEncoding("utf8");
871
+ res.on("data", (c) => (raw += c));
872
+ res.on("end", () => {
873
+ let body = {};
874
+ try {
875
+ body = JSON.parse(raw);
876
+ } catch {
877
+ /* non-JSON response */
878
+ }
879
+ if (res.statusCode === 200 && body.ok) {
880
+ resolve({
881
+ ok: true,
882
+ status: body.status,
883
+ message: body.message ?? null,
884
+ cursor: body.cursor,
885
+ });
886
+ } else {
887
+ resolve({
888
+ ok: false,
889
+ status: res.statusCode,
890
+ error: body.error || `HTTP ${res.statusCode}`,
891
+ });
892
+ }
893
+ });
894
+ },
895
+ );
896
+ r.on("timeout", () => {
897
+ r.destroy();
898
+ resolve({ ok: false, error: "timeout waiting for daemon response" });
899
+ });
900
+ r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
901
+ r.end(payload);
902
+ });
903
+ }
904
+
905
+ /**
906
+ * Advance one consumer's read cursor in the running MCP daemon's live message
907
+ * buffer (`zalo-agent mcp mark-read`), mirroring the `zalo_mark_read` MCP tool.
908
+ *
909
+ * @param {string} accountDir
910
+ * @param {{cursor: number, consumer?: string}} opts
911
+ * @returns {Promise<{ok: boolean, marked?: number, readCursor?: number, consumer?: string, status?: number, error?: string}|null>}
912
+ * null when no daemon channel is published
913
+ */
914
+ export function markReadViaDaemon(accountDir, { cursor, consumer } = {}) {
915
+ const chan = getDaemonChannel(accountDir);
916
+ if (!chan) return Promise.resolve(null);
917
+
918
+ const payload = JSON.stringify({
919
+ token: chan.token,
920
+ cursor: Number(cursor),
921
+ ...(consumer ? { consumer: String(consumer) } : {}),
922
+ });
923
+
924
+ return new Promise((resolve) => {
925
+ const r = http.request(
926
+ {
927
+ host: "127.0.0.1",
928
+ port: chan.port,
929
+ path: "/mark-read",
930
+ method: "POST",
931
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
932
+ timeout: 5000,
933
+ agent: false,
934
+ },
935
+ (res) => {
936
+ let raw = "";
937
+ res.setEncoding("utf8");
938
+ res.on("data", (c) => (raw += c));
939
+ res.on("end", () => {
940
+ let body = {};
941
+ try {
942
+ body = JSON.parse(raw);
943
+ } catch {
944
+ /* non-JSON response */
945
+ }
946
+ if (res.statusCode === 200 && body.ok) {
947
+ resolve({
948
+ ok: true,
949
+ marked: body.marked ?? 0,
950
+ readCursor: body.readCursor ?? Number(cursor),
951
+ consumer: body.consumer || consumer || "default",
952
+ });
953
+ } else {
954
+ resolve({
955
+ ok: false,
956
+ status: res.statusCode,
957
+ error: body.error || `HTTP ${res.statusCode}`,
958
+ });
959
+ }
960
+ });
961
+ },
962
+ );
963
+ r.on("timeout", () => {
964
+ r.destroy();
965
+ resolve({ ok: false, error: "timeout waiting for daemon response" });
966
+ });
967
+ r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
968
+ r.end(payload);
969
+ });
970
+ }
971
+
784
972
  /**
785
973
  * Stop the running daemon for an account directory, preferring `POST /stop` so
786
974
  * the daemon runs its own graceful shutdown handler (`markConnected`, receipt
@@ -985,7 +1173,8 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
985
1173
  const filteredArgs = args.filter((a) => a !== "-d" && a !== "--daemon");
986
1174
 
987
1175
  const logFd = fs.openSync(logPath, "a", 0o600);
988
- const header = `\n--- [${new Date().toISOString()}] starting daemon: ${filteredArgs.join(" ")} ---\n`;
1176
+ // Never the secrets: --auth, --client-secret and --webhook-secret values are masked.
1177
+ const header = `\n--- [${new Date().toISOString()}] starting daemon: ${maskSecretArgs(filteredArgs).join(" ")} ---\n`;
989
1178
  fs.writeSync(logFd, header);
990
1179
  let startOffset = 0;
991
1180
  try {
@@ -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
  }