@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -33
- package/node_modules/zca-js/dist/apis.js +0 -8
- package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
- package/package.json +3 -3
- package/patches/zca-js+2.2.0.patch +4 -236
- package/src/commands/bank.js +126 -0
- package/src/commands/catalog.js +14 -0
- package/src/commands/conv.js +19 -0
- package/src/commands/friend.js +66 -21
- package/src/commands/group.js +38 -2
- package/src/commands/listen.js +71 -4
- package/src/commands/mcp.js +252 -4
- package/src/commands/msg.js +60 -0
- package/src/commands/profile.js +39 -0
- package/src/commands/sync.js +1 -68
- package/src/core/bank-accounts.js +52 -0
- package/src/core/current-user-names.js +27 -7
- package/src/core/daemon-channel.js +191 -2
- package/src/core/daemon-log.js +250 -0
- package/src/core/daemon-sync.js +140 -9
- package/src/core/friend-requests.js +142 -0
- package/src/core/friend-search.js +41 -0
- package/src/core/group-admin.js +116 -0
- package/src/core/group-invite.js +53 -0
- package/src/core/sync-v2/gid.js +20 -20
- package/src/core/sync-v2/index.js +77 -11
- package/src/core/sync.js +4 -235
- package/src/core/user-info.js +170 -0
- package/src/index.js +2 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-tools.js +302 -2
- package/src/utils/cli-args.js +84 -0
- package/src/utils/secrets.js +69 -0
- package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
- package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
- package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
- package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
- package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
- package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
|
@@ -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 (
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|