@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.
@@ -12,7 +12,8 @@ import { getApi, clearSession } from "../core/zalo-client.js";
12
12
  import { liveApi } from "../core/live-api.js";
13
13
  import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
14
14
  import { updateAccountName } from "../core/accounts.js";
15
- import { getAccountDir, MEDIA_DIR } from "../core/credentials.js";
15
+ import { CONFIG_DIR, getAccountDir, MEDIA_DIR } from "../core/credentials.js";
16
+ import { createStartupGate, resolveStartupSettings } from "../core/startup-gate.js";
16
17
  import { acquireLock, releaseLock } from "../core/lock.js";
17
18
  import {
18
19
  createStageLock,
@@ -22,6 +23,8 @@ import {
22
23
  spawnDaemonProcess,
23
24
  waitViaDaemon,
24
25
  markReadViaDaemon,
26
+ statusViaDaemon,
27
+ configureLogging,
25
28
  } from "../core/daemon-channel.js";
26
29
  import { success, error, info, output } from "../utils/output.js";
27
30
  import { resolveWebhookSecret } from "../utils/secrets.js";
@@ -50,13 +53,26 @@ import { classifyLiveMessage } from "../core/sync-v2/message-types.js";
50
53
  import { createDeliveredReceipts } from "../core/receipts.js";
51
54
  import { MessageBuffer } from "../mcp/message-buffer.js";
52
55
  import { ThreadFilter } from "../mcp/thread-filter.js";
53
- import { readMCPConfig, resolveAccountMCPConfig, envForAccount, parseDuration } from "../mcp/mcp-config.js";
56
+ import {
57
+ readMCPConfig,
58
+ resolveAccountMCPConfig,
59
+ envForAccount,
60
+ parseDuration,
61
+ resolveWebhookDelivery,
62
+ } from "../mcp/mcp-config.js";
63
+ import { startWebhookDelivery } from "../core/webhook-outbox.js";
64
+ import { batchParams, waitResult, MAX_BATCH, MAX_SETTLE_MS } from "../mcp/wait-result.js";
65
+ import { createDaemonHealth, describeHealth } from "../core/daemon-health.js";
66
+ import { DEBUG_MARK, LOG_LEVELS } from "../core/log-sink.js";
67
+ import { registerMCPWebhookCommands } from "./mcp-webhook.js";
54
68
  import { createMCPServer } from "../mcp/mcp-server.js";
55
69
  import { registerTools } from "../mcp/mcp-tools.js";
56
70
  import { createHTTPServer } from "../mcp/mcp-http-transport.js";
57
71
  import { ZaloNotifier } from "../mcp/notifier.js";
58
72
  import { ThreadNameCache } from "../mcp/thread-name-cache.js";
59
73
 
74
+ const PKG_VERSION = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8")).version;
75
+
60
76
  /** Zalo close code for duplicate web session — fatal, do not retry */
61
77
  const CLOSE_DUPLICATE = 3000;
62
78
 
@@ -106,6 +122,17 @@ export function normalizeMessage(msg, info = null) {
106
122
  };
107
123
  }
108
124
 
125
+ /**
126
+ * The JSON a webhook receiver gets for one buffered message.
127
+ *
128
+ * @param {object} message - Normalized message object
129
+ * @param {object} [extra] - Additional top-level fields (e.g. `{ accountId }`)
130
+ * @returns {object}
131
+ */
132
+ export function buildMCPWebhookPayload(message, extra = {}) {
133
+ return { event: "message", ...extra, ...message };
134
+ }
135
+
109
136
  /**
110
137
  * Fire-and-forget HTTP POST for an incoming buffered MCP message when a
111
138
  * webhook URL is configured (`-w, --webhook <url>` or `webhook` in
@@ -120,12 +147,7 @@ export function normalizeMessage(msg, info = null) {
120
147
  */
121
148
  export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn = fetch, webhookSecret = null) {
122
149
  if (!webhookUrl) return false;
123
- const payload = {
124
- event: "message",
125
- ...extra,
126
- ...message,
127
- };
128
- const body = JSON.stringify(payload);
150
+ const body = JSON.stringify(buildMCPWebhookPayload(message, extra));
129
151
  const headers = { "Content-Type": "application/json" };
130
152
  if (typeof webhookSecret === "string" && webhookSecret.trim()) {
131
153
  const secret = webhookSecret.trim();
@@ -228,11 +250,19 @@ export function registerMCPCommands(program) {
228
250
  "--no-self-heal",
229
251
  "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)",
230
252
  )
253
+ .option(
254
+ "--log-level <level>",
255
+ "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",
256
+ )
231
257
  .option("-d, --daemon", "Run in the background (requires --http <port>; logs to <accountDir>/daemon.log)")
232
258
  .option("--stop", "Stop the running background daemon for this account")
233
259
  .option("--status", "Show background daemon status for this account")
234
260
  .action(async (opts) => {
235
261
  const jsonMode = program.opts().json;
262
+ if (opts.logLevel !== undefined && !LOG_LEVELS.includes(String(opts.logLevel).trim().toLowerCase())) {
263
+ error(`Invalid --log-level "${opts.logLevel}": use one of ${LOG_LEVELS.join(", ")}.`);
264
+ process.exit(1);
265
+ }
236
266
  const isDaemonCli = Boolean(opts.status || opts.stop || (opts.daemon && !process.env.ZALO_DAEMON_CHILD));
237
267
  const failStart = (msg) => {
238
268
  if (isDaemonCli) error(msg);
@@ -295,12 +325,18 @@ export function registerMCPCommands(program) {
295
325
 
296
326
  if (opts.status) {
297
327
  const status = getDaemonStatus(accountDir);
298
- output({ accountId: activeAcc.ownId, ...status }, jsonMode, () => {
328
+ // What the daemon says about itself; an older daemon has the channel but not the route.
329
+ const reported = status.running ? await statusViaDaemon(accountDir) : null;
330
+ const health = reported?.ok ? reported.health : null;
331
+ output({ accountId: activeAcc.ownId, ...status, health }, jsonMode, () => {
299
332
  if (status.running) {
300
333
  success(
301
334
  `Daemon running for account ${activeAcc.ownId} (PID ${status.pid}${status.channelPort ? `, channel 127.0.0.1:${status.channelPort}` : ""})`,
302
335
  );
303
336
  info(`Log file: ${status.logPath}`);
337
+ if (health) for (const line of describeHealth(health)) info(line);
338
+ else
339
+ info("Health: not reported (this daemon predates the health route; restart it to see it).");
304
340
  } else {
305
341
  info(`No daemon running for account ${activeAcc.ownId}`);
306
342
  info(`Log file: ${status.logPath}`);
@@ -323,6 +359,15 @@ export function registerMCPCommands(program) {
323
359
  return;
324
360
  }
325
361
 
362
+ // Levels and rotation of the log, now that the account is known (src/core/log-sink.js).
363
+ const logging = configureLogging({
364
+ ownId: activeAcc.ownId,
365
+ levelFlag: opts.logLevel?.trim().toLowerCase() || null,
366
+ // The process that launches a background daemon talks to the user; only the daemon's own output is a log.
367
+ filterStderr: !(opts.daemon && !process.env.ZALO_DAEMON_CHILD),
368
+ });
369
+ if (!process.env.ZALO_DAEMON_CHILD) for (const w of logging.warnings) console.error(`[mcp] [logging] ${w}`);
370
+
326
371
  // Merge per-account config (`config.accounts[ownId]` and `<accountDir>/mcp-config.json`)
327
372
  const { config: accountConfig, problem: accountProblem } = resolveAccountMCPConfig(config, {
328
373
  accountId: activeAcc.ownId,
@@ -427,9 +472,22 @@ export function registerMCPCommands(program) {
427
472
  }
428
473
  };
429
474
 
475
+ // Spread a fleet's start-up: a random delay and a cap on daemons logging in at once
476
+ // (ZALO_START_JITTER, ZALO_LOGIN_SLOTS; src/core/startup-gate.js). Both off by default.
477
+ const startup = resolveStartupSettings({ env: process.env, ownId: activeAcc.ownId });
478
+ for (const w of startup.warnings) console.error(`[mcp] ${w}`);
479
+ const gate = createStartupGate({
480
+ configDir: CONFIG_DIR,
481
+ ownId: activeAcc.ownId,
482
+ settings: startup,
483
+ log: (m) => console.error(`[mcp] ${m}`),
484
+ });
485
+ await gate.enter();
486
+
430
487
  try {
431
488
  await loginDaemonAccount(activeAcc);
432
489
  } catch (e) {
490
+ gate.leave();
433
491
  dropLock();
434
492
  console.error("[mcp] Pinned account login failed:", e.message);
435
493
  process.exit(1);
@@ -438,6 +496,7 @@ export function registerMCPCommands(program) {
438
496
  initDb(join(accountDir, "zalo.db"));
439
497
  console.error(`[mcp] Local cache: ${join(accountDir, "zalo.db")}`);
440
498
  } catch (e) {
499
+ gate.leave();
441
500
  dropLock();
442
501
  console.error(`[mcp] Failed to initialize local DB: ${e.message}`);
443
502
  process.exit(1);
@@ -458,6 +517,8 @@ export function registerMCPCommands(program) {
458
517
  } catch (e) {
459
518
  console.error("[mcp] Thread name cache init failed (non-fatal):", e.message);
460
519
  }
520
+ // Logged in and the friend and group lists are loaded: the heavy part of start-up is over.
521
+ gate.leave();
461
522
 
462
523
  // One stage at a time on this socket, shared by the sync routes, the
463
524
  // self-heal catch-up and the tools' own history fetch. Created before
@@ -503,6 +564,17 @@ export function registerMCPCommands(program) {
503
564
  });
504
565
 
505
566
  // Start MCP server — stdio (default) or HTTP/HTTPS
567
+ // What this daemon can say about itself: /health, /ready, /status and /metrics over HTTP, and
568
+ // `mcp start --status` over the loopback channel (src/core/daemon-health.js). The sources that do not
569
+ // exist yet (the webhook outbox, below) are plugged in as they are built.
570
+ const health = createDaemonHealth({ kind: "mcp", accountId: activeAcc.ownId, version: PKG_VERSION });
571
+ health.setSource("buffer", () => {
572
+ const stats = buffer.getStats();
573
+ return { threads: stats.length, messages: stats.reduce((n, t) => n + t.total, 0) };
574
+ });
575
+ health.setSource("gaps", () => getPendingSyncGaps().length);
576
+ health.setSource("stage", () => stageLock.current());
577
+
506
578
  let httpServer = null;
507
579
  try {
508
580
  if (effectiveHttpPort) {
@@ -523,6 +595,7 @@ export function registerMCPCommands(program) {
523
595
  accountDir,
524
596
  stageLock,
525
597
  syncJobs,
598
+ health,
526
599
  };
527
600
  const authToken =
528
601
  opts.auth?.trim() ||
@@ -597,9 +670,33 @@ export function registerMCPCommands(program) {
597
670
  if (webhookSecret && !webhookUrl) {
598
671
  console.error("[mcp] A webhook secret is set but no webhook URL: nothing will be signed or sent.");
599
672
  }
673
+ // Events are written to an on-disk outbox first and delivered from it, so a receiver
674
+ // that is down or slow costs a backlog (mcp webhook status), not messages.
675
+ let webhookDelivery = null;
600
676
  if (webhookUrl) {
601
- console.error(`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
677
+ const delivery = resolveWebhookDelivery(activeAcc.ownId, config);
678
+ for (const w of delivery.warnings) console.error(`[mcp] ${w}`);
679
+ webhookDelivery = startWebhookDelivery({
680
+ accountDir,
681
+ accountId: activeAcc.ownId,
682
+ url: webhookUrl,
683
+ secret: webhookSecret,
684
+ batchSize: delivery.batchSize,
685
+ maxAgeMs: delivery.maxAgeMs,
686
+ log: (line) => console.error(`[mcp] ${line}`),
687
+ });
688
+ console.error(
689
+ `[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}, ` +
690
+ `durable outbox, ${delivery.batchSize === 1 ? "one event per POST" : `up to ${delivery.batchSize} events per POST`}`,
691
+ );
602
692
  }
693
+ health.setSource("webhook", () => webhookDelivery?.status() ?? null);
694
+ /** Queue one buffered message for the webhook, in the shape `postMCPWebhook` always sent. */
695
+ const queueWebhook = (normalized) =>
696
+ webhookDelivery?.enqueue(
697
+ `message:${normalized.id}`,
698
+ buildMCPWebhookPayload(normalized, { accountId: activeAcc.ownId }),
699
+ );
603
700
 
604
701
  // Delivered receipts: the same shared implementation `listen` uses
605
702
  // (AGENTS.md §13 -- the two listeners must not differ). Diagnostics
@@ -685,7 +782,7 @@ export function registerMCPCommands(program) {
685
782
  if (!filter.shouldKeep(normalized)) continue;
686
783
  buffer.push(normalized.threadId, normalized);
687
784
  notifier.onMessage(normalized);
688
- postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId }, fetch, webhookSecret);
785
+ queueWebhook(normalized);
689
786
  }
690
787
  },
691
788
  });
@@ -717,6 +814,7 @@ export function registerMCPCommands(program) {
717
814
  * @param {object} api - zca-js API instance
718
815
  */
719
816
  function attachListenerHandlers(api) {
817
+ health.attach(api.listener);
720
818
  api.listener.on("message", (msg) => {
721
819
  // Persist FIRST and unconditionally. The buffer is what an
722
820
  // agent polls; the cache is the durable record, and it must
@@ -759,8 +857,10 @@ export function registerMCPCommands(program) {
759
857
 
760
858
  buffer.push(normalized.threadId, normalized);
761
859
  notifier.onMessage(normalized);
762
- postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId }, fetch, webhookSecret);
763
- console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
860
+ queueWebhook(normalized);
861
+ console.error(
862
+ `${DEBUG_MARK}[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`,
863
+ );
764
864
  });
765
865
 
766
866
  // After the storing handler, exactly as in `listen`: the write
@@ -868,6 +968,7 @@ export function registerMCPCommands(program) {
868
968
  // the shutdown is indistinguishable from a drop and files a gap
869
969
  // for a window nothing was missed in.
870
970
  lifecycle.setStopping();
971
+ health.noteStopping();
871
972
  clearInterval(mcpHeartbeatTimer);
872
973
  deliveredReceipts.stop();
873
974
  const receipts = deliveredReceipts.stats();
@@ -885,6 +986,7 @@ export function registerMCPCommands(program) {
885
986
  /* already stopped */
886
987
  }
887
988
  notifier?.destroy();
989
+ webhookDelivery?.stop();
888
990
  httpServer?.close();
889
991
  dropLock();
890
992
  process.exit(0);
@@ -913,6 +1015,7 @@ export function registerMCPCommands(program) {
913
1015
  // The self-heal's lock: a stage and a catch-up never share the socket.
914
1016
  lock: stageLock,
915
1017
  onStop: shutdown,
1018
+ onStatus: () => health.snapshot(),
916
1019
  onWait: async (params, signal) => {
917
1020
  const cleanThreadId =
918
1021
  typeof params?.threadId === "string" && params.threadId.trim()
@@ -933,33 +1036,26 @@ export function registerMCPCommands(program) {
933
1036
  : consumer
934
1037
  ? buffer.readCursor(consumer)
935
1038
  : undefined;
936
- const msg = await buffer.waitForMessage({
1039
+ const { messages, hasMore } = await buffer.waitForMessages({
937
1040
  threadId: cleanThreadId,
938
1041
  since: effectiveSince,
939
1042
  timeoutMs: clampedSec * 1000,
940
1043
  signal,
1044
+ ...batchParams({ limit: params?.limit, settleMs: params?.settleMs }),
941
1045
  });
942
- if (msg) {
943
- const tid = String(msg.threadId || "");
944
- const threadName = nameCache?.get(tid)?.name || null;
945
- return {
946
- status: "message",
947
- message: {
948
- ...msg,
949
- ...(threadName ? { threadName } : {}),
950
- },
951
- cursor: msg._cursor,
952
- };
953
- }
954
- return {
955
- status: "timeout",
956
- message: null,
957
- cursor: Math.max(
1046
+ return waitResult({
1047
+ messages,
1048
+ hasMore,
1049
+ cursorWhenEmpty: Math.max(
958
1050
  effectiveSince ?? 0,
959
1051
  Number(buffer._globalCursor) || 0,
960
1052
  buffer.readCursor(consumer),
961
1053
  ),
962
- };
1054
+ decorate: (msg) => {
1055
+ const threadName = nameCache?.get(String(msg.threadId || ""))?.name || null;
1056
+ return { ...msg, ...(threadName ? { threadName } : {}) };
1057
+ },
1058
+ });
963
1059
  },
964
1060
  onMarkRead: async (params) => {
965
1061
  const cursorNum = Number(params?.cursor);
@@ -1008,6 +1104,16 @@ export function registerMCPCommands(program) {
1008
1104
  .option("-s, --since <cursor>", "Return immediately if a buffered message after this cursor already exists")
1009
1105
  .option("-t, --thread <threadId>", "Only wake on messages from this thread ID")
1010
1106
  .option("--timeout <seconds>", "Maximum seconds to wait before returning timeout (1-300, default: 55)", "55")
1107
+ .option(
1108
+ "-n, --limit <count>",
1109
+ `Return up to this many buffered messages in one call, in order (1-${MAX_BATCH}, default: 1)`,
1110
+ "1",
1111
+ )
1112
+ .option(
1113
+ "--settle <ms>",
1114
+ `After the first message, wait this long for the rest of a burst (0-${MAX_SETTLE_MS}, default: 0)`,
1115
+ "0",
1116
+ )
1011
1117
  .option(
1012
1118
  "-c, --consumer <name>",
1013
1119
  "Consumer name whose read cursor is used when --since is omitted (matches zalo_mark_read)",
@@ -1034,6 +1140,16 @@ export function registerMCPCommands(program) {
1034
1140
  error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
1035
1141
  process.exit(1);
1036
1142
  }
1143
+ const limit = Number(opts.limit);
1144
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_BATCH) {
1145
+ error(`Invalid --limit "${opts.limit}": must be an integer between 1 and ${MAX_BATCH}.`);
1146
+ process.exit(1);
1147
+ }
1148
+ const settleMs = Number(opts.settle);
1149
+ if (!Number.isInteger(settleMs) || settleMs < 0 || settleMs > MAX_SETTLE_MS) {
1150
+ error(`Invalid --settle "${opts.settle}": must be an integer between 0 and ${MAX_SETTLE_MS} (ms).`);
1151
+ process.exit(1);
1152
+ }
1037
1153
 
1038
1154
  let activeAcc;
1039
1155
  try {
@@ -1046,6 +1162,8 @@ export function registerMCPCommands(program) {
1046
1162
  }
1047
1163
  const accountDir = getAccountDir(activeAcc.ownId);
1048
1164
  const res = await waitViaDaemon(accountDir, {
1165
+ limit,
1166
+ settleMs,
1049
1167
  threadId: opts.thread?.trim() || null,
1050
1168
  since: parsedSince,
1051
1169
  timeoutSec: rawTimeout,
@@ -1065,16 +1183,21 @@ export function registerMCPCommands(program) {
1065
1183
  {
1066
1184
  status: res.status,
1067
1185
  message: res.message,
1186
+ messages: res.messages,
1187
+ count: res.count,
1188
+ hasMore: res.hasMore,
1068
1189
  cursor: res.cursor,
1069
1190
  },
1070
1191
  jsonMode,
1071
1192
  () => {
1072
- if (res.status === "message" && res.message) {
1073
- const m = res.message;
1074
- const label = m.threadName ? `${m.threadName} (${m.threadId})` : m.threadId;
1075
- success(
1076
- `[cursor ${res.cursor}] ${m.senderName || m.senderId} in ${label}: ${m.text || `(${m.type})`}`,
1077
- );
1193
+ if (res.status === "message" && res.messages?.length) {
1194
+ for (const m of res.messages) {
1195
+ const label = m.threadName ? `${m.threadName} (${m.threadId})` : m.threadId;
1196
+ success(
1197
+ `[cursor ${m._cursor}] ${m.senderName || m.senderId} in ${label}: ${m.text || `(${m.type})`}`,
1198
+ );
1199
+ }
1200
+ if (res.hasMore) info(`More are waiting: run again with since ${res.cursor}`);
1078
1201
  } else {
1079
1202
  info(`Timeout after ${rawTimeout}s (cursor: ${res.cursor})`);
1080
1203
  }
@@ -1144,4 +1267,6 @@ export function registerMCPCommands(program) {
1144
1267
  },
1145
1268
  );
1146
1269
  });
1270
+
1271
+ registerMCPWebhookCommands(mcp, program);
1147
1272
  }
@@ -34,6 +34,7 @@ import { expandMentions, parseMentionSpecs, shiftStyles, ALL_MENTION_UID } from
34
34
  import { applySelfThreadAlias } from "../utils/my-documents.js";
35
35
  import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
36
36
  import { urgencyLevel } from "../utils/urgency.js";
37
+ import { markdownToZalo, resolveSendFormat } from "../utils/markdown.js";
37
38
 
38
39
  /**
39
40
  * Open the active account's SQLite cache.
@@ -574,6 +575,10 @@ export function registerMsgCommands(program) {
574
575
  )
575
576
  .option("--style <specs...>", "Text styles. Format: start:len:style (e.g. 0:5:bold 6:5:italic)")
576
577
  .option("--md", "Parse markdown-like formatting: **bold** *italic* __underline__ ~~strike~~ {red:text}")
578
+ .option(
579
+ "--markdown",
580
+ "Send the message as Markdown, converted to Zalo's own formatting: **bold**, *italic*, ~~strike~~, # headings, - bullet lists, 1. lists, > quotes, `code`, [text](url), {red:text}. (Zalo shows typed Markdown as typed.) ZALO_SEND_FORMAT=markdown makes this the default.",
581
+ )
577
582
  .option(
578
583
  "--react <icon>",
579
584
  "Auto-react to sent message. Codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
@@ -594,7 +599,14 @@ export function registerMsgCommands(program) {
594
599
  let styles = [];
595
600
  let finalMsg = message;
596
601
 
597
- if (opts.md) {
602
+ if (
603
+ resolveSendFormat({ explicit: opts.markdown ? "markdown" : null, fallback: "plain" }) === "markdown"
604
+ ) {
605
+ // Real Markdown: bullets, headings, bold and italic as Markdown means them.
606
+ const converted = markdownToZalo(message);
607
+ finalMsg = converted.text;
608
+ styles = converted.styles;
609
+ } else if (opts.md) {
598
610
  // Markdown-like parsing: **bold** *italic* __underline__ ~~strike~~
599
611
  const parsed = parseMarkdownStyles(message);
600
612
  finalMsg = parsed.plain;