@ardennguyen/zalo-agent-cli 2.5.5 → 2.5.6

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.
@@ -3,18 +3,20 @@
3
3
  * Production-ready with auto-reconnect and re-login.
4
4
  */
5
5
 
6
- import { createHmac } from "crypto";
7
- import { appendFileSync, mkdirSync, existsSync } from "fs";
6
+ import { appendFileSync, mkdirSync, existsSync, readFileSync } from "fs";
8
7
  import { resolve, join } from "path";
9
8
  import { getApi, clearSession } from "../core/zalo-client.js";
10
9
  import { success, error, info, warning, output } from "../utils/output.js";
11
10
  import { resolveWebhookSecret } from "../utils/secrets.js";
12
11
  import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
13
12
  import { updateAccountName } from "../core/accounts.js";
14
- import { getAccountDir } from "../core/credentials.js";
13
+ import { CONFIG_DIR, getAccountDir } from "../core/credentials.js";
14
+ import { createStartupGate, resolveStartupSettings } from "../core/startup-gate.js";
15
15
  import { acquireLock, releaseLock } from "../core/lock.js";
16
16
  import {
17
17
  createStageLock,
18
+ statusViaDaemon,
19
+ configureLogging,
18
20
  startDaemonChannel,
19
21
  getDaemonStatus,
20
22
  stopDaemon,
@@ -43,7 +45,12 @@ import { downloadSyncedMedia } from "../core/sync-v2/media.js";
43
45
  import { SyncManager } from "../core/sync.js";
44
46
  import { describeGap } from "../core/sync-v2/gap-advice.js";
45
47
  import { createDeliveredReceipts } from "../core/receipts.js";
46
- import { envForAccount } from "../mcp/mcp-config.js";
48
+ import { envForAccount, resolveWebhookDelivery } from "../mcp/mcp-config.js";
49
+ import { startWebhookDelivery } from "../core/webhook-outbox.js";
50
+ import { createDaemonHealth, describeHealth } from "../core/daemon-health.js";
51
+ import { DEBUG_MARK, LOG_LEVELS } from "../core/log-sink.js";
52
+
53
+ const PKG_VERSION = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8")).version;
47
54
 
48
55
  /** Thread types matching zca-js ThreadType enum */
49
56
  const THREAD_USER = 0;
@@ -98,11 +105,19 @@ export function registerListenCommand(program) {
98
105
  "--no-self-heal",
99
106
  "Do not pull what a dropped socket missed from Zalo's offline queue on reconnect (the default does, as Zalo Web does, on this socket and with no phone tap)",
100
107
  )
108
+ .option(
109
+ "--log-level <level>",
110
+ "Log level of daemon.log (and of stderr in the foreground): error, warn, info (default) or debug, which adds a line per HTTP request and per message. Env: ZALO_LOG_LEVEL[_<ownId>]; also ZALO_LOG_FORMAT=json, ZALO_LOG_MAX_SIZE (default 50m) and ZALO_LOG_KEEP (default 3) for rotation",
111
+ )
101
112
  .option("-d, --daemon", "Run in the background (logs to <accountDir>/daemon.log)")
102
113
  .option("--stop", "Stop the running background daemon for this account")
103
114
  .option("--status", "Show background daemon status for this account")
104
115
  .action(async (opts) => {
105
116
  const jsonMode = program.opts().json;
117
+ if (opts.logLevel !== undefined && !LOG_LEVELS.includes(String(opts.logLevel).trim().toLowerCase())) {
118
+ error(`Invalid --log-level "${opts.logLevel}": use one of ${LOG_LEVELS.join(", ")}.`);
119
+ process.exit(1);
120
+ }
106
121
  // An empty secret is refused before anything else: see resolveWebhookSecret().
107
122
  let cliWebhookSecret;
108
123
  try {
@@ -125,12 +140,18 @@ export function registerListenCommand(program) {
125
140
 
126
141
  if (opts.status) {
127
142
  const status = getDaemonStatus(accountDir);
128
- output({ accountId: activeAcc.ownId, ...status }, jsonMode, () => {
143
+ // What the daemon says about itself; an older daemon has the channel but not the route.
144
+ const reported = status.running ? await statusViaDaemon(accountDir) : null;
145
+ const health = reported?.ok ? reported.health : null;
146
+ output({ accountId: activeAcc.ownId, ...status, health }, jsonMode, () => {
129
147
  if (status.running) {
130
148
  success(
131
149
  `Daemon running for account ${activeAcc.ownId} (PID ${status.pid}${status.channelPort ? `, channel 127.0.0.1:${status.channelPort}` : ""})`,
132
150
  );
133
151
  info(`Log file: ${status.logPath}`);
152
+ if (health) for (const line of describeHealth(health)) info(line);
153
+ else
154
+ info("Health: not reported (this daemon predates the health route; restart it to see it).");
134
155
  } else {
135
156
  info(`No daemon running for account ${activeAcc.ownId}`);
136
157
  info(`Log file: ${status.logPath}`);
@@ -153,6 +174,16 @@ export function registerListenCommand(program) {
153
174
  return;
154
175
  }
155
176
 
177
+ // Levels and rotation of the log, now that the account is known (src/core/log-sink.js).
178
+ const logging = configureLogging({
179
+ ownId: activeAcc.ownId,
180
+ levelFlag: opts.logLevel?.trim().toLowerCase() || null,
181
+ // The process that launches a background daemon talks to the user; only the daemon's own output is a log.
182
+ filterStderr: !(opts.daemon && !process.env.ZALO_DAEMON_CHILD),
183
+ });
184
+ if (!process.env.ZALO_DAEMON_CHILD)
185
+ for (const w of logging.warnings) console.error(`[listen] [logging] ${w}`);
186
+
156
187
  if (opts.daemon && !process.env.ZALO_DAEMON_CHILD) {
157
188
  const spawned = await spawnDaemonProcess({ accountDir, args: process.argv.slice(1) });
158
189
  if (!spawned.ok) {
@@ -205,9 +236,21 @@ export function registerListenCommand(program) {
205
236
  console.error(`[listen] Failed to release lock: ${e.message}`);
206
237
  }
207
238
  };
239
+ // Spread a fleet's start-up: a random delay and a cap on daemons logging in at once
240
+ // (ZALO_START_JITTER, ZALO_LOGIN_SLOTS; src/core/startup-gate.js). Both off by default.
241
+ const startup = resolveStartupSettings({ env: process.env, ownId: activeAcc.ownId });
242
+ for (const w of startup.warnings) console.error(`[listen] ${w}`);
243
+ const gate = createStartupGate({
244
+ configDir: CONFIG_DIR,
245
+ ownId: activeAcc.ownId,
246
+ settings: startup,
247
+ log: (m) => console.error(`[listen] ${m}`),
248
+ });
249
+ await gate.enter();
208
250
  try {
209
251
  await loginDaemonAccount(activeAcc);
210
252
  } catch (e) {
253
+ gate.leave();
211
254
  releaseLock(accountDir);
212
255
  error(`Pinned account login failed: ${e.message}`);
213
256
  process.exit(1);
@@ -215,7 +258,10 @@ export function registerListenCommand(program) {
215
258
  try {
216
259
  initDb(join(accountDir, "zalo.db"));
217
260
  info(`Local database initialized at ${accountDir}/zalo.db`);
261
+ // Logged in and the cache open: the heavy part of start-up is over.
262
+ gate.leave();
218
263
  } catch (err) {
264
+ gate.leave();
219
265
  releaseLock(accountDir);
220
266
  error(`Failed to initialize local DB: ${err.message}`);
221
267
  process.exit(1);
@@ -408,27 +454,32 @@ export function registerListenCommand(program) {
408
454
  warning("A webhook secret is set but no webhook URL: nothing will be signed or sent. Pass -w <url>.");
409
455
  }
410
456
 
411
- /** Fire-and-forget webhook POST — never blocks event processing */
412
- function postWebhook(data) {
413
- if (!webhookUrl) return;
414
- const payload = { accountId: activeAcc.ownId, ...data };
415
- const body = JSON.stringify(payload);
416
- const headers = { "Content-Type": "application/json" };
417
- if (webhookSecret) {
418
- headers["Authorization"] = `Bearer ${webhookSecret}`;
419
- headers["X-Zalo-Signature"] =
420
- `sha256=${createHmac("sha256", webhookSecret).update(body).digest("hex")}`;
421
- }
422
- fetch(webhookUrl, {
423
- method: "POST",
424
- headers,
425
- body,
426
- signal: AbortSignal.timeout(5000),
427
- }).catch((e) => {
428
- console.error(`[listen] Webhook failed: ${e.message}`);
457
+ // The same durable outbox `mcp start` uses (src/core/webhook-outbox.js): an event is
458
+ // written to disk first and delivered from there, so a receiver that is down costs a
459
+ // backlog (zalo-agent mcp webhook status), not events.
460
+ let webhookDelivery = null;
461
+ if (webhookUrl) {
462
+ const delivery = resolveWebhookDelivery(activeAcc.ownId);
463
+ for (const w of delivery.warnings) warning(w);
464
+ webhookDelivery = startWebhookDelivery({
465
+ accountDir,
466
+ accountId: activeAcc.ownId,
467
+ url: webhookUrl,
468
+ secret: webhookSecret,
469
+ batchSize: delivery.batchSize,
470
+ maxAgeMs: delivery.maxAgeMs,
471
+ log: (line) => console.error(`[listen] ${line}`),
429
472
  });
430
473
  }
431
474
 
475
+ /** Queue an event for the webhook — never blocks event processing */
476
+ function postWebhook(data) {
477
+ // Only a message has an id that stays the same on a repeat (live, then catch-up);
478
+ // two reactions to one message must both be delivered.
479
+ const key = data.event === "message" && data.msgId ? `message:${data.msgId}` : null;
480
+ webhookDelivery?.enqueue(key, { accountId: activeAcc.ownId, ...data });
481
+ }
482
+
432
483
  // Setup save directory if --save flag provided
433
484
  let saveDir = null;
434
485
  if (opts.save) {
@@ -453,7 +504,10 @@ export function registerListenCommand(program) {
453
504
  /** Output event as JSON or human-readable, save locally, then post to webhook */
454
505
  function emitEvent(data, humanMsg) {
455
506
  eventCount++;
456
- if (jsonMode) {
507
+ if (process.env.ZALO_DAEMON_CHILD === "1") {
508
+ // In daemon.log an event line is a per-message line: debug (it carries the message text).
509
+ console.error(`${DEBUG_MARK}${jsonMode ? JSON.stringify(data) : humanMsg}`);
510
+ } else if (jsonMode) {
457
511
  console.log(JSON.stringify(data));
458
512
  } else {
459
513
  info(humanMsg);
@@ -534,6 +588,11 @@ export function registerListenCommand(program) {
534
588
  });
535
589
  const socketContext = createSocketContext({ lock: stageLock, monitor: loopMonitor });
536
590
  setUserInfoLogger(daemonLog);
591
+ // Same health object `mcp start` builds (src/core/daemon-health.js): `listen --status` prints it.
592
+ const health = createDaemonHealth({ kind: "listen", accountId: activeAcc.ownId, version: PKG_VERSION });
593
+ health.setSource("gaps", () => getPendingSyncGaps().length);
594
+ health.setSource("stage", () => stageLock.current());
595
+ health.setSource("webhook", () => webhookDelivery?.status() ?? null);
537
596
  const socketTap = createSocketTap({ log: (line) => console.error(`[listen] ${line}`) });
538
597
  // Read state other devices report -- a conversation read on the
539
598
  // phone, an unread mark set or cleared there -- which zca-js
@@ -567,6 +626,7 @@ export function registerListenCommand(program) {
567
626
 
568
627
  /** Attach ALL handlers (data + lifecycle) to current API listener */
569
628
  function attachAllHandlers(api) {
629
+ health.attach(api.listener);
570
630
  // --- Message events ---
571
631
  // Registered unconditionally. --events, --filter and --no-self
572
632
  // decide what is PRINTED, forwarded and saved as JSONL -- never
@@ -952,7 +1012,9 @@ export function registerListenCommand(program) {
952
1012
  const shutdown = () => {
953
1013
  // First, before anything can emit a close event.
954
1014
  stopping = true;
1015
+ health.noteStopping();
955
1016
  channel?.stop();
1017
+ webhookDelivery?.stop();
956
1018
  deliveredReceipts.stop();
957
1019
  try {
958
1020
  getApi().listener.stop();
@@ -998,6 +1060,7 @@ export function registerListenCommand(program) {
998
1060
  // The self-heal's lock: a stage and a catch-up never share the socket.
999
1061
  lock: stageLock,
1000
1062
  onStop: shutdown,
1063
+ onStatus: () => health.snapshot(),
1001
1064
  });
1002
1065
  info(`Sync & upload channel ready on 127.0.0.1:${channel.port} — this socket is reused for both.`);
1003
1066
  info("`zalo-agent sync` will run its socket stages here; it still needs your phone tap.");
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `zalo-agent mcp webhook ...` — look at, and act on, the durable webhook outbox
3
+ * (src/core/webhook-outbox.js) of an account: how many events wait for the receiver, which were
4
+ * dead-lettered and why. These read and write `accounts/<ownId>/webhook-outbox.db` directly; SQLite
5
+ * lets them run beside the daemon that is delivering from it.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { parseAccountId, selectDaemonAccount } from "../core/daemon-account.js";
10
+ import { getAccountDir } from "../core/credentials.js";
11
+ import { getDaemonStatus } from "../core/daemon-channel.js";
12
+ import { openOutbox, outboxPath } from "../core/webhook-outbox.js";
13
+ import { error, info, output, success } from "../utils/output.js";
14
+
15
+ const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
16
+
17
+ /** @param {number} ms */
18
+ function age(ms) {
19
+ const s = Math.round(ms / 1000);
20
+ if (s < 90) return `${s}s`;
21
+ if (s < 5400) return `${Math.round(s / 60)}m`;
22
+ return `${(s / 3600).toFixed(1)}h`;
23
+ }
24
+
25
+ /**
26
+ * @param {object} program - Commander root
27
+ * @param {object} opts - The subcommand's options
28
+ * @returns {{ ownId: string, accountDir: string }}
29
+ */
30
+ function pickAccount(program, opts) {
31
+ try {
32
+ const requested = opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
33
+ const acc = selectDaemonAccount(requested);
34
+ return { ownId: acc.ownId, accountDir: getAccountDir(acc.ownId) };
35
+ } catch (e) {
36
+ error(`Account selection failed: ${e.message}`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Open the outbox, or say plainly that this account has none yet.
43
+ * @param {string} accountDir
44
+ * @param {boolean} readonly
45
+ * @returns {object|null}
46
+ */
47
+ function openOrNone(accountDir, readonly) {
48
+ if (!existsSync(outboxPath(accountDir))) return null;
49
+ try {
50
+ return openOutbox(accountDir, { readonly });
51
+ } catch (e) {
52
+ error(`Could not open the webhook outbox: ${e.message}`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ const NO_OUTBOX =
58
+ "This account has no webhook outbox: no event has been queued for a webhook yet (-w / ZALO_WEBHOOK_URL).";
59
+
60
+ /**
61
+ * @param {object} mcp - The `mcp` Commander command
62
+ * @param {object} program - Commander root, for the global --json / --account
63
+ */
64
+ export function registerMCPWebhookCommands(mcp, program) {
65
+ const webhook = mcp
66
+ .command("webhook")
67
+ .description("Inspect and manage the durable webhook outbox: events waiting for the receiver and dead letters");
68
+
69
+ webhook
70
+ .command("status")
71
+ .description("Backlog of the webhook outbox: queued, dead-lettered, oldest wait, last success and error")
72
+ .option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
73
+ .action((opts) => {
74
+ const { ownId, accountDir } = pickAccount(program, opts);
75
+ const daemon = getDaemonStatus(accountDir);
76
+ const outbox = openOrNone(accountDir, true);
77
+ const stats = outbox?.stats() ?? null;
78
+ outbox?.close();
79
+ const result = {
80
+ accountId: ownId,
81
+ daemon: { running: daemon.running, pid: daemon.pid },
82
+ outbox: stats && {
83
+ path: outboxPath(accountDir),
84
+ pending: stats.pending,
85
+ dead: stats.dead,
86
+ oldestPendingAt: iso(stats.oldestPendingAt),
87
+ oldestPendingAgeMs: stats.oldestPendingAt ? Math.max(0, Date.now() - stats.oldestPendingAt) : 0,
88
+ deliveredTotal: stats.deliveredTotal,
89
+ lastSuccessAt: iso(stats.lastSuccessAt),
90
+ lastError: stats.lastError,
91
+ lastErrorAt: iso(stats.lastErrorAt),
92
+ lastStatus: stats.lastStatus,
93
+ },
94
+ };
95
+ output(result, program.opts().json, () => {
96
+ if (!stats) return info(NO_OUTBOX);
97
+ const o = result.outbox;
98
+ info(`Account ${ownId} daemon: ${daemon.running ? `running (pid ${daemon.pid})` : "not running"}`);
99
+ info(`Queued: ${o.pending}${o.pending ? ` (oldest waiting ${age(o.oldestPendingAgeMs)})` : ""}`);
100
+ info(`Dead-lettered: ${o.dead}${o.dead ? " (zalo-agent mcp webhook list --dead)" : ""}`);
101
+ info(`Delivered since the outbox was created: ${o.deliveredTotal}`);
102
+ info(`Last delivery: ${o.lastSuccessAt ?? "never"}`);
103
+ if (o.lastError) info(`Last error: ${o.lastError}${o.lastErrorAt ? ` at ${o.lastErrorAt}` : ""}`);
104
+ if (o.pending && !daemon.running)
105
+ info("Queued events are sent when `mcp start` / `listen` runs again.");
106
+ });
107
+ });
108
+
109
+ webhook
110
+ .command("list")
111
+ .description("List queued events, or the dead letters, oldest first")
112
+ .option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
113
+ .option("--dead", "Only dead-lettered events")
114
+ .option("--pending", "Only events still queued")
115
+ .option("-n, --limit <n>", "Maximum rows (default: 50)", "50")
116
+ .action((opts) => {
117
+ const limit = Number(opts.limit);
118
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
119
+ error(`Invalid --limit "${opts.limit}": must be an integer between 1 and 1000.`);
120
+ process.exit(1);
121
+ }
122
+ if (opts.dead && opts.pending) {
123
+ error("Pass --dead or --pending, not both.");
124
+ process.exit(1);
125
+ }
126
+ const { ownId, accountDir } = pickAccount(program, opts);
127
+ const outbox = openOrNone(accountDir, true);
128
+ const rows = (
129
+ outbox?.list({ status: opts.dead ? "dead" : opts.pending ? "pending" : undefined, limit }) ?? []
130
+ ).map((r) => {
131
+ let event = {};
132
+ try {
133
+ event = JSON.parse(r.body);
134
+ } catch {
135
+ /* a row the receiver gets as-is; shown without fields */
136
+ }
137
+ return {
138
+ seq: r.seq,
139
+ status: r.status,
140
+ attempts: r.attempts,
141
+ queuedAt: iso(r.created_at),
142
+ lastAttemptAt: iso(r.last_attempt_at),
143
+ lastError: r.last_error,
144
+ event: event.event ?? null,
145
+ id: event.id ?? event.msgId ?? null,
146
+ threadId: event.threadId ?? null,
147
+ };
148
+ });
149
+ outbox?.close();
150
+ output({ accountId: ownId, count: rows.length, events: rows }, program.opts().json, () => {
151
+ if (!outbox) return info(NO_OUTBOX);
152
+ if (!rows.length) return info("Nothing queued.");
153
+ for (const r of rows) {
154
+ info(
155
+ `#${r.seq} ${r.status} attempts=${r.attempts} ${r.event ?? "?"} id=${r.id ?? "-"} thread=${r.threadId ?? "-"} queued ${r.queuedAt}${r.lastError ? ` — ${r.lastError}` : ""}`,
156
+ );
157
+ }
158
+ });
159
+ });
160
+
161
+ for (const [name, verb, description] of [
162
+ [
163
+ "retry",
164
+ "requeued",
165
+ "Put dead-lettered events back in the queue (all, or one by number); the daemon sends them within 30 seconds",
166
+ ],
167
+ ["purge", "deleted", "Delete dead-lettered events for good (all, or one by number)"],
168
+ ]) {
169
+ webhook
170
+ .command(`${name} [seq]`)
171
+ .description(description)
172
+ .option(
173
+ "--account <id>",
174
+ "Target a specific registered account ID (default: active account)",
175
+ parseAccountId,
176
+ )
177
+ .action((seqArg, opts) => {
178
+ let seq;
179
+ if (seqArg !== undefined) {
180
+ seq = Number(seqArg);
181
+ if (!Number.isInteger(seq) || seq < 1) {
182
+ error(`Invalid event number "${seqArg}": use the # from \`mcp webhook list --dead\`.`);
183
+ process.exit(1);
184
+ }
185
+ }
186
+ const { ownId, accountDir } = pickAccount(program, opts);
187
+ const outbox = openOrNone(accountDir, false);
188
+ const n = !outbox ? 0 : name === "retry" ? outbox.retryDead({ seq }) : outbox.purgeDead({ seq });
189
+ outbox?.close();
190
+ output({ accountId: ownId, [verb]: n }, program.opts().json, () => {
191
+ if (!outbox) info(NO_OUTBOX);
192
+ else if (n) success(`${n} dead-lettered event(s) ${verb}.`);
193
+ else info(seq ? `No dead-lettered event #${seq}.` : "No dead-lettered events.");
194
+ });
195
+ });
196
+ }
197
+ }