@ardennguyen/zalo-agent-cli 2.5.4 → 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.
package/.env.example CHANGED
@@ -4,7 +4,7 @@
4
4
  # Recommended location: `~/.zalo-agent-cli/.env` (`<CONFIG_DIR>/.env`), or pass
5
5
  # `--env-file /path/to/.env` (`ZALO_ENV_FILE=/path/to/.env`).
6
6
  # Note: If you also use the `zalo-mcp` deployment wrapper, keep `zalo-mcp/.env`
7
- # for port settings (`ZALO_MCP_HTTP_PORT`, `ZALO_OA_WEBHOOK_PORT`) only, and put
7
+ # for the port (`ZALO_MCP_HTTP_PORT`) only, and put
8
8
  # all storage paths and engine settings in `<CONFIG_DIR>/.env` so `zalo-mcp` and
9
9
  # `zalo-agent` share a single source of truth.
10
10
  # CLI flags and shell environment variables take precedence over `.env` values.
@@ -33,3 +33,25 @@
33
33
  # Protocol version announced to Zalo (default: 691; set 685 to roll back):
34
34
  # ZALO_API_VERSION=691
35
35
 
36
+ # ----------------------------------------------------------------------------
37
+ # MCP server / CRM backend (`zalo-agent mcp start`; all optional)
38
+ # Ready-to-copy files for local, LAN, internet, CRM and many-account setups:
39
+ # docs/configuration.md and docs/config-samples/. Every name below also takes a
40
+ # per-account form, e.g. ZALO_MCP_HTTP_PORT_<ownId>, which beats the plain name.
41
+ # Put secrets here (file mode 0600), never on the command line: a flag shows in `ps`.
42
+ # ----------------------------------------------------------------------------
43
+ # ZALO_MCP_HTTP_PORT=3847 # serve HTTP instead of stdio
44
+ # ZALO_MCP_HOST=127.0.0.1 # bind address; never 0.0.0.0 without a token
45
+ # ZALO_MCP_AUTH_TOKEN= # bearer token: openssl rand -hex 32
46
+ # ZALO_WEBHOOK_URL= # POST each incoming message here
47
+ # ZALO_WEBHOOK_SECRET= # signs it (X-Zalo-Signature: sha256=...)
48
+ # ZALO_WEBHOOK_BATCH_SIZE=1 # events per POST (1-100); the webhook retries and survives restarts
49
+ # ZALO_WEBHOOK_MAX_AGE=24h # an undelivered event older than this is dead-lettered
50
+ # ZALO_LOG_LEVEL=info # daemon.log: error | warn | info | debug (debug adds a line per request and message)
51
+ # ZALO_LOG_FORMAT=text # or json (one object per line)
52
+ # ZALO_LOG_MAX_SIZE=50m # rotate daemon.log at this size ...
53
+ # ZALO_LOG_KEEP=3 # ... keeping this many files
54
+ # ZALO_START_JITTER=0 # seconds: wait 0-N s before logging in (a fleet restarting together)
55
+ # ZALO_LOGIN_SLOTS=0 # at most N daemons on the machine logging in at once; 0 = no cap
56
+ # ZALO_SEND_FORMAT=plain # markdown | plain: how text messages are sent (the MCP tool defaults to markdown)
57
+
package/README.md CHANGED
@@ -28,7 +28,7 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
28
28
  > clawhub install zalo-agent # OpenClaw (từ ClawHub registry)
29
29
  > cp -r skill/ ~/.claude/skills/zalo-agent/ # Claude Code
30
30
  > ```
31
- > 17 nhóm lệnh · 212 lệnh · listen mode + webhook · 55+ ngân hàng VN · đa tài khoản + proxy
31
+ > 18 nhóm lệnh · 222 lệnh · listen mode + webhook · 55+ ngân hàng VN · đa tài khoản + proxy
32
32
  > Xem [skill/SKILL.md](skill/SKILL.md) · [Tham chiếu lệnh đầy đủ](skill/references/command-reference.md) · [Eval scenarios](skill/evals/)
33
33
 
34
34
  > [!NOTE]
@@ -138,13 +138,15 @@ Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-
138
138
 
139
139
  - Đăng nhập QR qua HTTP server tự động (browser + terminal), báo ngay khi bị **từ chối trên điện thoại** thay vì treo tới hết 60s
140
140
  - Đa tài khoản với proxy riêng biệt (1:1), device fingerprint riêng cho từng tài khoản; chạy **nhiều tài khoản live đồng thời** (mỗi tài khoản một tiến trình `mcp start` hoặc `listen` kèm `--account <ID>`, và truyền `--account <ID>` / `ZALO_ACCOUNT` trên mọi lệnh CLI mà không ghi đè `accounts.json`)
141
- - **212 lệnh** phủ hết tính năng Zalo cá nhân
141
+ - **222 lệnh** phủ hết tính năng Zalo cá nhân
142
142
  - **Zalo Official Account (OA) API v3.0** — OAuth login, gửi tin nhắn, follower, tag, bài viết, cửa hàng, webhook listener, multi-OA
143
143
  - **MCP server** (stdio + HTTP/HTTPS + OAuth 2.0) — 25 tools cho Claude Code, Gemini và các MCP client: đọc, chờ tin nhắn đến (`zalo_wait_for_message`), gửi (văn bản, link preview, danh thiếp, vào My Documents, đánh dấu Quan trọng/Khẩn cấp), tìm kiếm tin nhắn trong cache, tra cứu người dùng, gắn/gỡ nhãn hội thoại (`zalo_label`), xem bạn bè và xử lý lời mời kết bạn (`zalo_friend`), xem thông tin nhóm, tạo nhóm, thêm/xóa thành viên và rời nhóm (`zalo_group`), thả cảm xúc, thu hồi, chuyển tiếp, xóa tin, nhắc hẹn, ghi chú/ghim tin, bình chọn, xem thành viên nhóm, liệt kê hội thoại gần đây và kiểm tra độ đầy đủ của cache
144
144
  - **Bộ nhớ đệm cục bộ (SQLite)** — `listen` ghi mọi tin nhắn vào `zalo.db`, `msg history` đọc từ cache, `sync` (hoặc `sync-mobile`) khôi phục toàn bộ lịch sử từ điện thoại vào `zalo.db` (giải mã transfer-sync-v2; xác nhận một lần trên điện thoại), mặc định lấy **toàn bộ** lịch sử (không còn mốc chặn 2024-01-01); thêm `--days <n>` hoặc `--from <YYYY-MM-DD>` nếu chỉ cần một khoảng. Dọn dẹp: `sync-media --prune <days|all>` giải phóng dung lượng nhưng giữ nguyên nội dung tin nhắn, `--prune-orphans` nhắm vào hội thoại tài khoản không còn, và `conv forget` xoá hẳn bản sao cục bộ của một hội thoại — không cái nào gửi gì tới Zalo. Luồng đồng bộ chỉ mang **liên kết** media chứ không mang file, nên sau khi lưu tin nhắn, `sync-mobile` **tự động tải media về** (thêm `--messages-only` nếu chỉ muốn tin nhắn); `sync-media` chạy lại/tiếp tục bước tải này, không cần xác nhận trên điện thoại. Ghi chú, tin ghim, bình chọn và nhắc hẹn nằm ngoài luồng tin nhắn — dùng `sync-boards`
145
145
  - Tự động tải media (ảnh/audio/video) — một downloader duy nhất cho `listen`, `msg history`, `mcp start` và các lệnh sync, lưu vào `~/.zalo-agent-cli/accounts/<id>/media/<threadId>/` (hoặc tách riêng credentials, database `zalo.db`, media và OA sang các ổ đĩa/mount khác nhau qua `--credentials-dir`, `--data-dir`, `--media-dir`, `--oa-dir` / biến môi trường `ZALO_*_DIR` trong `~/.zalo-agent-cli/.env`; nếu dùng kèm `zalo-mcp`, chỉ nên giữ cấu hình PORT trong `zalo-mcp/.env` và đặt toàn bộ đường dẫn lưu trữ vào `<CONFIG_DIR>/.env`)
146
146
  - Thẻ chuyển khoản (55+ ngân hàng VN) & QR VietQR
147
- - Lắng nghe real-time với webhook & lưu JSONL local
147
+ - Lắng nghe real-time với webhook & lưu JSONL local. Webhook **bền vững**: mỗi sự kiện ghi xuống đĩa rồi mới gửi, đúng thứ tự, thử lại khi bên nhận hỏng, sống sót qua khởi động lại (`mcp webhook status`)
148
+ - **Vận hành nhiều tài khoản**: `/health`, `/ready`, `/status` và `/metrics` (Prometheus), `fleet status|start|restart|generate`, mức log + xoay vòng `daemon.log`, `ZALO_START_JITTER` / `ZALO_LOGIN_SLOTS` — xem `docs/deployment.vi.md` §4.7–4.8
149
+ - Tin nhắn **Markdown** được chuyển thành định dạng thật của Zalo (đậm, nghiêng, gạch đầu dòng, tiêu đề) — `zalo_send_message` mặc định, `msg send --markdown`
148
150
  - `logout` hủy phiên **phía server** thật sự; `logout --purge` / `account remove` xóa sạch dữ liệu cục bộ
149
151
  - Output `--json` cho mọi lệnh — scripting & AI agents
150
152
  - Credentials lưu cục bộ với quyền 0600
@@ -197,7 +199,7 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
197
199
  > clawhub install zalo-agent # OpenClaw (from ClawHub registry)
198
200
  > cp -r skill/ ~/.claude/skills/zalo-agent/ # Claude Code
199
201
  > ```
200
- > 17 command groups · 212 commands · listen mode + webhook · 55+ VN banks · multi-account + proxy
202
+ > 18 command groups · 222 commands · listen mode + webhook · 55+ VN banks · multi-account + proxy
201
203
  > See [skill/SKILL.md](skill/SKILL.md) · [Full command reference](skill/references/command-reference.md) · [Eval scenarios](skill/evals/)
202
204
 
203
205
  > [!NOTE]
@@ -271,13 +273,15 @@ See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/
271
273
 
272
274
  - QR login over an auto-started HTTP server (browser + terminal), reporting a **decline on the phone** immediately instead of hanging until the 60s timeout
273
275
  - Multi-account with a dedicated proxy (1:1) and a per-account device fingerprint; run **multiple live accounts simultaneously** (one `mcp start` or `listen` process per account with `--account <ID>`, plus `--account <ID>` / `ZALO_ACCOUNT` on any CLI command without mutating `accounts.json`)
274
- - **212 commands** covering the personal-account Zalo surface
276
+ - **222 commands** covering the personal-account Zalo surface
275
277
  - **Zalo Official Account (OA) API v3.0** — OAuth login, messaging, followers, tags, articles, store, webhook listener, multi-OA
276
278
  - **MCP server** (stdio + HTTP/HTTPS + OAuth 2.0) — 25 tools for Claude Code, Gemini and other MCP clients: read, wait for incoming messages (`zalo_wait_for_message`), send (text, local file/image attachments, link previews, contact cards, to My Documents too, marked Important/Urgent), search cached messages, look up users, manage conversation labels (`zalo_label`), list friends and handle friend requests (`zalo_friend`), inspect, create and manage groups and leave them (`zalo_group`), react, recall, forward, delete, reminders, group notes/pins, group polls, list a group's members, list recent conversations, and check how complete the cache is
277
279
  - **Local SQLite cache** — `listen` writes every message to `zalo.db`, `msg history` reads from it, and `sync` (or `sync-mobile`) restores full history from the phone into `zalo.db` (transfer-sync-v2 decrypt; one confirmation on the phone), defaulting to **all** history (the old 2024-01-01 floor is gone), with `--days <n>` or `--from <YYYY-MM-DD>` to narrow it. Housekeeping: `sync-media --prune <days|all>` reclaims disk while keeping message text, `--prune-orphans` targets conversations the account no longer has, and `conv forget` removes a conversation's local copy entirely — none of which contact Zalo. The sync stream carries media **links**, not files, so a `sync-mobile` run **downloads its attachments automatically** once the messages are stored (`--messages-only` skips it); `sync-media` re-runs or resumes that fetch on its own, with no phone confirmation. Notes, pinned messages, polls and reminders live outside the message stream — `sync-boards` fetches those
278
280
  - Automatic media download — one downloader shared by `listen`, `msg history`, `mcp start` and the sync commands, saving to `~/.zalo-agent-cli/accounts/<id>/media/<threadId>/` (or separate credentials, `zalo.db`, media, and OA credentials onto independent volumes/mounts via `--credentials-dir`, `--data-dir`, `--media-dir`, `--oa-dir` / `ZALO_*_DIR` in `~/.zalo-agent-cli/.env`; when using `zalo-mcp`, keep `zalo-mcp/.env` for PORT settings only and place all storage/engine settings in `<CONFIG_DIR>/.env`)
279
281
  - Bank cards (55+ VN banks) and VietQR payment images
280
- - Real-time listener with webhook forwarding and local JSONL archival
282
+ - Real-time listener with webhook forwarding and local JSONL archival. The webhook is **durable**: each event is written to disk first, then delivered in order, retried while the receiver is down, and kept across restarts (`mcp webhook status`)
283
+ - **Running many accounts**: `/health`, `/ready`, `/status` and `/metrics` (Prometheus), `fleet status|start|restart|generate`, log levels and `daemon.log` rotation, `ZALO_START_JITTER` / `ZALO_LOGIN_SLOTS` — see `docs/deployment.md` §4.7–4.8
284
+ - **Markdown** messages become real Zalo formatting (bold, italic, bullets, headings): the default in `zalo_send_message`, `msg send --markdown` on the CLI
281
285
  - `logout` performs a real **server-side** session invalidation; `logout --purge` / `account remove` wipe all local data
282
286
  - `--json` output on every command — scripting and AI agents
283
287
  - Credentials stored locally with 0600 permissions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.5.4",
3
+ "version": "2.5.6",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,439 @@
1
+ /**
2
+ * `zalo-agent fleet ...` — run many accounts on one machine.
3
+ *
4
+ * One account is one daemon process with its own port, token and lock, so a hundred accounts are a hundred
5
+ * of them. These commands do what an operator would otherwise script by hand: keep a port table, show every
6
+ * daemon's health in one table, start and restart them in shuffled batches so they do not all log in at
7
+ * once, and write the systemd unit, env files and proxy config that supervise them. See src/core/fleet.js.
8
+ */
9
+
10
+ import { readFileSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { parseEnv } from "node:util";
13
+ import { parseAccountId } from "../core/daemon-account.js";
14
+ import { listAccounts } from "../core/accounts.js";
15
+ import { getAccountDir } from "../core/credentials.js";
16
+ import { getDaemonStatus, spawnDaemonProcess, statusViaDaemon, stopDaemon } from "../core/daemon-channel.js";
17
+ import {
18
+ DEFAULT_BASE_PORT,
19
+ FleetError,
20
+ assignPorts,
21
+ classifyAccount,
22
+ generateFleetFiles,
23
+ loadPortTable,
24
+ needsRestart,
25
+ planBatches,
26
+ renderStatusTable,
27
+ } from "../core/fleet.js";
28
+ import { envForAccount } from "../mcp/mcp-config.js";
29
+ import { error, info, output, success, warning } from "../utils/output.js";
30
+
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
+
33
+ /** Run `fn` over `items`, at most `limit` at a time, keeping the order of the results. */
34
+ async function mapLimit(items, limit, fn) {
35
+ const out = new Array(items.length);
36
+ let next = 0;
37
+ await Promise.all(
38
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
39
+ while (next < items.length) {
40
+ const i = next++;
41
+ out[i] = await fn(items[i], i);
42
+ }
43
+ }),
44
+ );
45
+ return out;
46
+ }
47
+
48
+ /** @param {string} label @param {string} value @param {number} min @param {number} max */
49
+ function intOption(label, value, min, max) {
50
+ const n = Number(value);
51
+ if (!Number.isInteger(n) || n < min || n > max) {
52
+ error(`Invalid ${label} "${value}": must be a whole number from ${min} to ${max}.`);
53
+ process.exit(1);
54
+ }
55
+ return n;
56
+ }
57
+
58
+ /**
59
+ * The accounts a command acts on: the ones named, else every registered one.
60
+ * @param {string[]} ids
61
+ * @returns {Array<{ ownId: string, name: string }>}
62
+ */
63
+ function pickAccounts(ids) {
64
+ const all = listAccounts();
65
+ if (!all.length) {
66
+ error("No accounts are registered. Log in first: zalo-agent login");
67
+ process.exit(1);
68
+ }
69
+ if (!ids.length) return all;
70
+ const chosen = [];
71
+ for (const raw of ids) {
72
+ let id;
73
+ try {
74
+ id = parseAccountId(raw);
75
+ } catch (e) {
76
+ error(e.message);
77
+ process.exit(1);
78
+ }
79
+ const acc = all.find((a) => a.ownId === id);
80
+ if (!acc) {
81
+ error(`Account ${id} is not registered. List them with: zalo-agent account list`);
82
+ process.exit(1);
83
+ }
84
+ chosen.push(acc);
85
+ }
86
+ return chosen;
87
+ }
88
+
89
+ /** The port table, with every given account in it. */
90
+ function portsFor(accounts, basePort) {
91
+ try {
92
+ return assignPorts(
93
+ accounts.map((a) => a.ownId),
94
+ { basePort },
95
+ ).ports;
96
+ } catch (e) {
97
+ if (e instanceof FleetError) {
98
+ error(e.message);
99
+ process.exit(1);
100
+ }
101
+ throw e;
102
+ }
103
+ }
104
+
105
+ /** The ports already assigned, without assigning any (status must not write). */
106
+ function readPorts() {
107
+ try {
108
+ return loadPortTable().ports;
109
+ } catch (e) {
110
+ if (e instanceof FleetError) {
111
+ error(e.message);
112
+ process.exit(1);
113
+ }
114
+ throw e;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * What every account's daemon is doing right now, asked over each one's loopback channel (no port or token needed).
120
+ * @param {Array<{ ownId: string, name: string }>} accounts
121
+ * @param {Record<string, number>} ports
122
+ * @param {number} downForSec
123
+ */
124
+ async function collect(accounts, ports, downForSec) {
125
+ return mapLimit(accounts, 16, async (acc) => {
126
+ const accountDir = getAccountDir(acc.ownId);
127
+ const daemon = getDaemonStatus(accountDir);
128
+ const reply = daemon.running ? await statusViaDaemon(accountDir) : null;
129
+ const health = reply?.ok ? reply.health : null;
130
+ return {
131
+ accountId: acc.ownId,
132
+ name: acc.name,
133
+ port: ports[acc.ownId] ?? null,
134
+ state: classifyAccount({ running: daemon.running, health }, { downForSec }),
135
+ daemon: { running: daemon.running, pid: daemon.pid },
136
+ health,
137
+ };
138
+ });
139
+ }
140
+
141
+ /** `<dir>/<ownId>.env` as extra environment for that account's daemon, if the file exists. */
142
+ function envFromDir(dir, ownId) {
143
+ if (!dir) return {};
144
+ const file = join(dir, `${ownId}.env`);
145
+ if (!existsSync(file)) return {};
146
+ try {
147
+ return parseEnv(readFileSync(file, "utf8"));
148
+ } catch (e) {
149
+ error(`Cannot read ${file}: ${e.message}`);
150
+ process.exit(1);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Start daemons in shuffled batches, a pause between batches.
156
+ *
157
+ * @param {Array<{ ownId: string, kind: "mcp"|"listen", port: number }>} targets
158
+ * @param {{ batch: number, staggerSec: number, envDir: string|null, dryRun: boolean, log: (m: string) => void }} o
159
+ * @returns {Promise<Array<{ accountId: string, ok: boolean, detail: string }>>}
160
+ */
161
+ async function startInBatches(targets, { batch, staggerSec, envDir, dryRun, log }) {
162
+ const batches = planBatches(targets, { size: batch });
163
+ const results = [];
164
+ for (let b = 0; b < batches.length; b++) {
165
+ log(`batch ${b + 1}/${batches.length}: ${batches[b].map((t) => t.ownId).join(", ")}`);
166
+ const done = await Promise.all(
167
+ batches[b].map(async (t) => {
168
+ if (dryRun)
169
+ return {
170
+ accountId: t.ownId,
171
+ ok: true,
172
+ detail: `would start (${t.kind}${t.kind === "mcp" ? `, port ${t.port}` : ""})`,
173
+ };
174
+ const env = envFromDir(envDir, t.ownId);
175
+ if (t.kind === "mcp" && !envForAccount("ZALO_MCP_AUTH_TOKEN", t.ownId, { ...process.env, ...env })) {
176
+ warning(
177
+ `Account ${t.ownId} has no ZALO_MCP_AUTH_TOKEN: its port will serve anyone who can reach it.`,
178
+ );
179
+ }
180
+ const args =
181
+ t.kind === "listen"
182
+ ? [process.argv[1], "listen", "--account", t.ownId]
183
+ : [process.argv[1], "mcp", "start", "--account", t.ownId, "--http", String(t.port)];
184
+ const spawned = await spawnDaemonProcess({ accountDir: getAccountDir(t.ownId), args, env });
185
+ if (spawned.ok) {
186
+ return {
187
+ accountId: t.ownId,
188
+ ok: true,
189
+ detail: `started (pid ${spawned.pid}${t.kind === "mcp" ? `, port ${t.port}` : ""})`,
190
+ };
191
+ }
192
+ return {
193
+ accountId: t.ownId,
194
+ ok: Boolean(spawned.alreadyRunning),
195
+ detail: spawned.alreadyRunning
196
+ ? `already running (pid ${spawned.pid})`
197
+ : `failed: ${spawned.error} (log: ${spawned.logPath})`,
198
+ };
199
+ }),
200
+ );
201
+ results.push(...done);
202
+ if (b < batches.length - 1 && !dryRun && staggerSec > 0) {
203
+ log(`waiting ${staggerSec}s before the next batch`);
204
+ await sleep(staggerSec * 1000);
205
+ }
206
+ }
207
+ return results;
208
+ }
209
+
210
+ /**
211
+ * @param {object} program - Commander root
212
+ */
213
+ export function registerFleetCommands(program) {
214
+ const fleet = program
215
+ .command("fleet")
216
+ .description(
217
+ "Run many accounts on one machine: port table, one status table, batched start/restart, supervisor files",
218
+ );
219
+
220
+ const json = () => program.opts().json;
221
+
222
+ fleet
223
+ .command("ports")
224
+ .description(
225
+ "Show the port each account's daemon uses, assigning one to any account that has none (kept in fleet.json)",
226
+ )
227
+ .option(
228
+ "--base <port>",
229
+ `First port of a new table (default ${DEFAULT_BASE_PORT}); ignored once ports are assigned`,
230
+ )
231
+ .action((opts) => {
232
+ const base = opts.base === undefined ? undefined : intOption("--base", opts.base, 1, 65535);
233
+ const accounts = listAccounts();
234
+ if (!accounts.length) {
235
+ error("No accounts are registered. Log in first: zalo-agent login");
236
+ process.exit(1);
237
+ }
238
+ const ports = portsFor(accounts, base);
239
+ const rows = accounts.map((a) => ({ accountId: a.ownId, name: a.name, port: ports[a.ownId] }));
240
+ output({ ports: rows }, json(), () => {
241
+ for (const r of rows) info(`${r.accountId} ${String(r.port).padEnd(5)} ${r.name || ""}`);
242
+ });
243
+ });
244
+
245
+ fleet
246
+ .command("status [accounts...]")
247
+ .description(
248
+ "One table for every account: daemon, socket, last event, webhook backlog, gaps, memory, and a state",
249
+ )
250
+ .option("--check", "Exit 1 unless every account is ok or degraded (for monitoring scripts)")
251
+ .option(
252
+ "--down-for <seconds>",
253
+ "How long a socket may be down before the account counts as `down` (default 180)",
254
+ "180",
255
+ )
256
+ .action(async (ids, opts) => {
257
+ const downFor = intOption("--down-for", opts.downFor, 0, 86400);
258
+ const accounts = pickAccounts(ids);
259
+ const rows = await collect(accounts, readPorts(), downFor);
260
+ const summary = {};
261
+ for (const r of rows) summary[r.state] = (summary[r.state] ?? 0) + 1;
262
+ output({ accounts: rows, summary }, json(), () => {
263
+ for (const line of renderStatusTable(rows)) console.log(line);
264
+ console.log();
265
+ info(
266
+ Object.entries(summary)
267
+ .map(([s, n]) => `${n} ${s}`)
268
+ .join(", "),
269
+ );
270
+ });
271
+ if (opts.check && rows.some((r) => !["ok", "degraded"].includes(r.state))) process.exit(1);
272
+ });
273
+
274
+ fleet
275
+ .command("start [accounts...]")
276
+ .description("Start the daemons that are not running, in shuffled batches with a pause between them")
277
+ .option("--listen", "Start `listen` daemons (webhook only, no HTTP port) instead of MCP servers")
278
+ .option("--batch <n>", "Accounts started at once (default 5)", "5")
279
+ .option("--stagger <seconds>", "Pause between batches (default 15)", "15")
280
+ .option("--env-dir <dir>", "Read <dir>/<ownId>.env into each daemon's environment (its token, port, webhook)")
281
+ .option("--dry-run", "Print the plan and start nothing")
282
+ .action(async (ids, opts) => {
283
+ const batch = intOption("--batch", opts.batch, 1, 100);
284
+ const staggerSec = intOption("--stagger", opts.stagger, 0, 3600);
285
+ const accounts = pickAccounts(ids);
286
+ const ports = portsFor(accounts);
287
+ const targets = accounts
288
+ .filter((a) => !getDaemonStatus(getAccountDir(a.ownId)).running)
289
+ .map((a) => ({ ownId: a.ownId, kind: opts.listen ? "listen" : "mcp", port: ports[a.ownId] }));
290
+ const skipped = accounts.length - targets.length;
291
+ const log = (m) => (json() ? console.error(` ● ${m}`) : info(m));
292
+ if (skipped) log(`${skipped} account(s) already have a daemon running and are left alone`);
293
+ const results = await startInBatches(targets, {
294
+ batch,
295
+ staggerSec,
296
+ envDir: opts.envDir ?? null,
297
+ dryRun: Boolean(opts.dryRun),
298
+ log,
299
+ });
300
+ output({ started: results, alreadyRunning: skipped, dryRun: Boolean(opts.dryRun) }, json(), () => {
301
+ for (const r of results) (r.ok ? success : error)(`${r.accountId}: ${r.detail}`);
302
+ if (!results.length) info("Nothing to start.");
303
+ });
304
+ if (results.some((r) => !r.ok)) process.exit(1);
305
+ });
306
+
307
+ fleet
308
+ .command("stop [accounts...]")
309
+ .description(
310
+ "Stop the daemons of these accounts (all of them if none are named), each with its graceful shutdown",
311
+ )
312
+ .action(async (ids) => {
313
+ const accounts = pickAccounts(ids);
314
+ const results = await mapLimit(accounts, 8, async (a) => {
315
+ const res = await stopDaemon(getAccountDir(a.ownId));
316
+ return { accountId: a.ownId, stopped: res.stopped, graceful: res.graceful, pid: res.pid };
317
+ });
318
+ output({ stopped: results }, json(), () => {
319
+ for (const r of results) {
320
+ if (r.stopped) success(`${r.accountId}: stopped (pid ${r.pid}${r.graceful ? "" : ", forced"})`);
321
+ else info(`${r.accountId}: no daemon was running`);
322
+ }
323
+ });
324
+ });
325
+
326
+ fleet
327
+ .command("restart [accounts...]")
328
+ .description(
329
+ "Stop and start daemons in batches; name accounts, or use --unhealthy (down or unresponsive) or --all",
330
+ )
331
+ .option(
332
+ "--unhealthy",
333
+ "Only the accounts whose daemon is down or not answering (not a backlog: a restart does not fix a receiver)",
334
+ )
335
+ .option("--all", "Every account")
336
+ .option("--down-for <seconds>", "How long a socket may be down before it counts as `down` (default 180)", "180")
337
+ .option(
338
+ "--listen",
339
+ "Start `listen` daemons for accounts whose kind cannot be told (a daemon that does not answer)",
340
+ )
341
+ .option("--batch <n>", "Accounts restarted at once (default 5)", "5")
342
+ .option("--stagger <seconds>", "Pause between batches (default 15)", "15")
343
+ .option("--env-dir <dir>", "Read <dir>/<ownId>.env into each daemon's environment")
344
+ .option("--dry-run", "Print the plan and change nothing")
345
+ .action(async (ids, opts) => {
346
+ if (!ids.length && !opts.unhealthy && !opts.all) {
347
+ error(
348
+ "Name the accounts to restart, or pass --unhealthy or --all (a bare `fleet restart` restarts nothing).",
349
+ );
350
+ process.exit(1);
351
+ }
352
+ const batch = intOption("--batch", opts.batch, 1, 100);
353
+ const staggerSec = intOption("--stagger", opts.stagger, 0, 3600);
354
+ const downFor = intOption("--down-for", opts.downFor, 0, 86400);
355
+ const accounts = pickAccounts(ids);
356
+ const ports = portsFor(accounts);
357
+ const rows = await collect(accounts, ports, downFor);
358
+ const chosen = opts.unhealthy && !ids.length ? rows.filter((r) => needsRestart(r.state)) : rows;
359
+ const log = (m) => (json() ? console.error(` ● ${m}`) : info(m));
360
+ if (!chosen.length) {
361
+ output({ restarted: [], considered: rows.length }, json(), () =>
362
+ info("Every account is serving: nothing to restart."),
363
+ );
364
+ return;
365
+ }
366
+ log(
367
+ `restarting ${chosen.length} of ${rows.length} account(s): ${chosen.map((r) => `${r.accountId} (${r.state})`).join(", ")}`,
368
+ );
369
+ if (!opts.dryRun) {
370
+ await mapLimit(chosen, 8, (r) => stopDaemon(getAccountDir(r.accountId)));
371
+ }
372
+ const targets = chosen.map((r) => ({
373
+ ownId: r.accountId,
374
+ kind: r.health?.kind ?? (opts.listen ? "listen" : "mcp"),
375
+ port: ports[r.accountId],
376
+ }));
377
+ const results = await startInBatches(targets, {
378
+ batch,
379
+ staggerSec,
380
+ envDir: opts.envDir ?? null,
381
+ dryRun: Boolean(opts.dryRun),
382
+ log,
383
+ });
384
+ output({ restarted: results, dryRun: Boolean(opts.dryRun) }, json(), () => {
385
+ for (const r of results) (r.ok ? success : error)(`${r.accountId}: ${r.detail}`);
386
+ });
387
+ if (results.some((r) => !r.ok)) process.exit(1);
388
+ });
389
+
390
+ fleet
391
+ .command("generate")
392
+ .description(
393
+ "Write the systemd unit, per-account env files (with generated tokens), the port table and, optionally, a Caddyfile",
394
+ )
395
+ .requiredOption("--out <dir>", "Directory to write into")
396
+ .option("--base-port <port>", `First port of a new table (default ${DEFAULT_BASE_PORT})`)
397
+ .option("--user <name>", "Unix user the units run as (default zalo)")
398
+ .option("--home <dir>", "That user's home directory (default /home/zalo)")
399
+ .option("--bin <path>", "Path of the zalo-agent binary (default /usr/bin/zalo-agent)")
400
+ .option("--domain <host>", "Also write a Caddyfile that serves every account under https://<host>/a/<ownId>/")
401
+ .option("--force", "Overwrite files that already exist (account env files keep their token)")
402
+ .option("--rotate-tokens", "Give every account a new token, replacing the ones in existing env files")
403
+ .action((opts) => {
404
+ const base = opts.basePort === undefined ? undefined : intOption("--base-port", opts.basePort, 1, 65535);
405
+ const accounts = listAccounts();
406
+ if (!accounts.length) {
407
+ error("No accounts are registered. Log in first: zalo-agent login");
408
+ process.exit(1);
409
+ }
410
+ const ports = portsFor(accounts, base);
411
+ const paths = Object.fromEntries(
412
+ Object.entries({ user: opts.user, home: opts.home, bin: opts.bin }).filter(([, v]) => v !== undefined),
413
+ );
414
+ const result = generateFleetFiles({
415
+ outDir: opts.out,
416
+ accounts: accounts.map((a) => ({ ownId: a.ownId, port: ports[a.ownId] })),
417
+ paths,
418
+ domain: opts.domain ?? null,
419
+ force: Boolean(opts.force),
420
+ rotateTokens: Boolean(opts.rotateTokens),
421
+ });
422
+ output({ outDir: opts.out, accounts: accounts.length, ...result }, json(), () => {
423
+ success(`${result.written.length} file(s) written to ${opts.out} for ${accounts.length} account(s)`);
424
+ for (const f of result.written) info(`wrote ${f}`);
425
+ for (const f of result.kept)
426
+ info(`kept ${f} (already there; --force overwrites, --rotate-tokens renews tokens)`);
427
+ info("Tokens are in accounts/<id>.env (mode 600) and are not printed here.");
428
+ info(
429
+ `Install: sudo install -d /etc/zalo-mcp/accounts && sudo install -m 600 ${opts.out}/common.env /etc/zalo-mcp/ && sudo install -m 600 ${opts.out}/accounts/*.env /etc/zalo-mcp/accounts/`,
430
+ );
431
+ info(
432
+ `Then: sudo install -m 644 ${opts.out}/zalo-mcp@.service /etc/systemd/system/ && sudo systemctl daemon-reload`,
433
+ );
434
+ info(
435
+ "Start: systemctl enable --now zalo-mcp@<ownId> (or: zalo-agent fleet start --env-dir /etc/zalo-mcp/accounts)",
436
+ );
437
+ });
438
+ });
439
+ }