@ardennguyen/zalo-agent-cli 2.5.5 → 2.5.7
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 +9 -0
- package/README.md +10 -6
- package/package.json +1 -1
- package/src/commands/fleet.js +439 -0
- package/src/commands/listen.js +87 -24
- package/src/commands/mcp-webhook.js +197 -0
- package/src/commands/mcp.js +162 -37
- package/src/commands/msg.js +13 -1
- package/src/commands/sync.js +12 -1
- package/src/core/accounts.js +182 -40
- package/src/core/credentials.js +5 -3
- package/src/core/daemon-channel.js +145 -19
- package/src/core/daemon-health.js +403 -0
- package/src/core/fleet-templates.js +177 -0
- package/src/core/fleet.js +255 -0
- package/src/core/log-sink.js +311 -0
- package/src/core/oa-client.js +10 -6
- package/src/core/startup-gate.js +206 -0
- package/src/core/sync-v2/index.js +16 -3
- package/src/core/webhook-outbox.js +575 -0
- package/src/core/zalo-client.js +7 -3
- package/src/index.js +13 -3
- package/src/mcp/mcp-config.js +42 -2
- package/src/mcp/mcp-http-transport.js +37 -18
- package/src/mcp/mcp-tools.js +82 -36
- package/src/mcp/message-buffer.js +94 -4
- package/src/mcp/wait-result.js +52 -0
- package/src/utils/atomic-file.js +209 -0
- package/src/utils/markdown.js +339 -0
package/.env.example
CHANGED
|
@@ -45,4 +45,13 @@
|
|
|
45
45
|
# ZALO_MCP_AUTH_TOKEN= # bearer token: openssl rand -hex 32
|
|
46
46
|
# ZALO_WEBHOOK_URL= # POST each incoming message here
|
|
47
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)
|
|
48
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
|
-
>
|
|
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
|
-
- **
|
|
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
|
-
>
|
|
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
|
-
- **
|
|
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
|
@@ -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
|
+
}
|