@ardennguyen/zalo-agent-cli 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  Công cụ CLI tự động hóa Zalo — đa tài khoản, proxy, chuyển khoản ngân hàng, thanh toán QR, Official Account API v3.0, và MCP server cho AI agent.
8
8
  Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
9
9
 
10
+ [![License: CC BY-NC-SA 4.0](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-sa/4.0/)
11
+
10
12
  **[Tiếng Việt](#bắt-đầu-nhanh)** | **[English](#english)**
11
13
 
12
14
  > [!WARNING]
@@ -47,7 +49,9 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
47
49
  > zalo-agent mcp start # stdio (local Claude Code)
48
50
  > zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
49
51
  > ```
50
- > 12 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage`
52
+ > 14 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage` · `zalo_sync` · `zalo_sync_status`
53
+
54
+ [Sync qua MCP và điều kiện phát hành](docs/mcp-sync.vi.md).
51
55
  > Auto-reconnect · thread filter · noise reduction · tự tải media · thông báo qua nhóm Zalo
52
56
  > Xem [MCP Guide](skill/references/mcp-guide.md) · [INSTALLATION.md](INSTALLATION.md)
53
57
 
@@ -119,7 +123,7 @@ Tất cả lệnh hỗ trợ `--json`. Tài liệu đầy đủ: **[Wiki](https:
119
123
  | `label` | 2 | Nhãn hội thoại | [Nhãn](https://github.com/ardennguyen/zalo-agent-cli/wiki/Nh%C3%A3n) |
120
124
  | `catalog` | 9 | zBusiness — danh mục sản phẩm | [zBusiness](https://github.com/ardennguyen/zalo-agent-cli/wiki/zBusiness) |
121
125
  | `listen` | 1 | Lắng nghe real-time, webhook, JSONL, ghi vào cache cục bộ | [Lắng nghe](https://github.com/ardennguyen/zalo-agent-cli/wiki/L%E1%BA%AFng-Nghe) |
122
- | `mcp` | 1 | MCP server (stdio/HTTP) — 12 tools cho AI agent | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server-(VN)) |
126
+ | `mcp` | 1 | MCP server (stdio/HTTP) — 14 tools cho AI agent | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server-(VN)) |
123
127
  | **`oa`** | **32** | **Zalo Official Account API v3.0 — OAuth, tin nhắn, follower, tag, bài viết, cửa hàng, webhook** | **[Official Account](https://github.com/ardennguyen/zalo-agent-cli/wiki/Official-Account-(VN))** |
124
128
 
125
129
  Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-cli/wiki/%C4%90a-T%C3%A0i-Kho%E1%BA%A3n-&-Proxy) · [Cài đặt VPS](https://github.com/ardennguyen/zalo-agent-cli/wiki/C%C3%A0i-%C4%90%E1%BA%B7t-VPS) · [Thẻ chuyển khoản & QR](https://github.com/ardennguyen/zalo-agent-cli/wiki/Th%E1%BA%BB-Chuy%E1%BB%83n-Kho%E1%BA%A3n-&-QR) · [Bộ nhớ đệm & Đồng bộ](https://github.com/ardennguyen/zalo-agent-cli/wiki/B%E1%BB%99-Nh%E1%BB%9B-%C4%90%E1%BB%87m-&-%C4%90%E1%BB%93ng-B%E1%BB%99) · [Bảo mật](https://github.com/ardennguyen/zalo-agent-cli/wiki/B%E1%BA%A3o-M%E1%BA%ADt)
@@ -132,9 +136,9 @@ Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-
132
136
  - Đ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
133
137
  - **190 lệnh** phủ hết tính năng Zalo cá nhân
134
138
  - **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
135
- - **MCP server** (stdio + HTTP) — 12 tools cho Claude Code và các MCP client: đọc, gửi (kể cả vào My Documents, đánh dấu Quan trọng/Khẩn cấp), thả cảm xúc, thu hồi tin, 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
139
+ - **MCP server** (stdio + HTTP) — 14 tools cho Claude Code và các MCP client: đọc, gửi (kể cả vào My Documents, đánh dấu Quan trọng/Khẩn cấp), thả cảm xúc, thu hồi tin, 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
136
140
  - **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`
137
- - 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>/` (MCP server có thể ghi đè bằng `media.downloadDir`)
141
+ - 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`)
138
142
  - Thẻ chuyển khoản (55+ ngân hàng VN) & QR VietQR
139
143
  - Lắng nghe real-time với webhook & lưu JSONL local
140
144
  - `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ộ
@@ -159,6 +163,16 @@ Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-
159
163
  | [AGENTS.md](AGENTS.md) | Quy tắc dự án cho AI coding agent |
160
164
  | [Wiki](https://github.com/ardennguyen/zalo-agent-cli/wiki) | Tài liệu công khai, song ngữ EN + VN |
161
165
 
166
+ ## Giấy phép
167
+
168
+ zalo-agent-cli © 2026 Arden Nguyen Duc Huy được cấp phép theo [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). Xem [LICENSE](LICENSE) và [NOTICE](NOTICE). Điều khoản đầy đủ và cảnh báo rủi ro nằm trong [DISCLAIMER.md](DISCLAIMER.md).
169
+
170
+ - **Miễn phí cho mục đích phi thương mại**: cá nhân, học thuật, nghiên cứu. **Không cấp phép cho mục đích thương mại** — ví dụ dùng trong sản phẩm thương mại, để cung cấp dịch vụ có thu phí, hoặc trong hoạt động kinh doanh của một công ty. Muốn xin giấy phép thương mại, hãy [mở một issue](https://github.com/ardennguyen/zalo-agent-cli/issues).
171
+ - **Ghi công**: khi chia sẻ dự án này hoặc một phần của nó (nguyên bản hay đã sửa đổi, kể cả bên trong dự án khác), hãy ghi công theo mẫu trong [NOTICE](NOTICE), nêu rõ bạn có sửa đổi hay không, và dẫn link giấy phép.
172
+ - **Chia sẻ tương tự (ShareAlike)**: bản sửa đổi được chia sẻ phải dùng CC BY-NC-SA 4.0 (hoặc phiên bản mới hơn, hoặc giấy phép tương thích do Creative Commons liệt kê).
173
+ - Các phần dẫn xuất từ phần mềm MIT — [zalo-agent-cli của PhucMPham](https://github.com/PhucMPham/zalo-agent-cli) và bản vá của [zca-js](https://github.com/RFS-ADRENO/zca-js) được đóng gói kèm — vẫn thuộc giấy phép MIT; xem [NOTICE](NOTICE).
174
+ - Các phiên bản phát hành trước thay đổi này (2.0.0 trở về trước) được phát hành theo giấy phép MIT và giữ nguyên các điều khoản đó.
175
+
162
176
  ---
163
177
 
164
178
  ## English
@@ -199,7 +213,9 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
199
213
  > zalo-agent mcp start # stdio (local Claude Code)
200
214
  > zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
201
215
  > ```
202
- > 12 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage`
216
+ > 14 tools: `zalo_get_messages` · `zalo_send_message` · `zalo_list_threads` · `zalo_search_threads` · `zalo_mark_read` · `zalo_get_history` · `zalo_view_media` · `zalo_react` · `zalo_undo` · `zalo_get_group_members` · `zalo_list_conversations` · `zalo_coverage` · `zalo_sync` · `zalo_sync_status`
217
+
218
+ [MCP sync and release prerequisites](docs/mcp-sync.md).
203
219
  > Auto-reconnect · thread filter · noise reduction · media auto-download · Zalo-group notifications
204
220
  > See [MCP Guide](skill/references/mcp-guide.md) · [INSTALLATION.md](INSTALLATION.md)
205
221
 
@@ -237,7 +253,7 @@ Full docs: **[Wiki](https://github.com/ardennguyen/zalo-agent-cli/wiki)** · [Fu
237
253
  | `label` | 2 | Conversation labels | [Labels](https://github.com/ardennguyen/zalo-agent-cli/wiki/Labels) |
238
254
  | `catalog` | 9 | zBusiness catalogs & products | [Catalog](https://github.com/ardennguyen/zalo-agent-cli/wiki/Catalog) |
239
255
  | `listen` | 1 | Real-time listener, webhook, JSONL, local cache writes | [Listener](https://github.com/ardennguyen/zalo-agent-cli/wiki/Listener) |
240
- | `mcp` | 1 | MCP server (stdio/HTTP) — 12 tools for AI agents | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server) |
256
+ | `mcp` | 1 | MCP server (stdio/HTTP) — 14 tools for AI agents | [MCP Server](https://github.com/ardennguyen/zalo-agent-cli/wiki/MCP-Server) |
241
257
  | **`oa`** | **32** | **Zalo Official Account API v3.0 — OAuth, messaging, followers, tags, articles, store, webhook** | **[Official Account](https://github.com/ardennguyen/zalo-agent-cli/wiki/Official-Account)** |
242
258
 
243
259
  See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/wiki/Multi-Account-&-Proxy) · [VPS Setup](https://github.com/ardennguyen/zalo-agent-cli/wiki/VPS-Setup) · [Bank Card & QR Payments](https://github.com/ardennguyen/zalo-agent-cli/wiki/Bank-Card-&-QR-Payments) · [Local Cache & Sync](https://github.com/ardennguyen/zalo-agent-cli/wiki/Local-Cache-&-Sync) · [Security](https://github.com/ardennguyen/zalo-agent-cli/wiki/Security)
@@ -248,9 +264,9 @@ See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/
248
264
  - Multi-account with a dedicated proxy (1:1) and a per-account device fingerprint
249
265
  - **190 commands** covering the personal-account Zalo surface
250
266
  - **Zalo Official Account (OA) API v3.0** — OAuth login, messaging, followers, tags, articles, store, webhook listener, multi-OA
251
- - **MCP server** (stdio + HTTP) — 12 tools for Claude Code and other MCP clients: read, send (to My Documents too, marked Important/Urgent), react, recall, list a group's members, list recent conversations, and check how complete the cache is
267
+ - **MCP server** (stdio + HTTP) — 14 tools for Claude Code and other MCP clients: read, send (to My Documents too, marked Important/Urgent), react, recall, list a group's members, list recent conversations, and check how complete the cache is
252
268
  - **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
253
- - 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>/` (the MCP server can override the root with `media.downloadDir`)
269
+ - 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`)
254
270
  - Bank cards (55+ VN banks) and VietQR payment images
255
271
  - Real-time listener with webhook forwarding and local JSONL archival
256
272
  - `logout` performs a real **server-side** session invalidation; `logout --purge` / `account remove` wipe all local data
@@ -279,4 +295,10 @@ See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/
279
295
 
280
296
  ## License
281
297
 
282
- [MIT](LICENSE) · See [DISCLAIMER.md](DISCLAIMER.md) for full terms.
298
+ zalo-agent-cli © 2026 Arden Nguyen Duc Huy is licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). See [LICENSE](LICENSE) and [NOTICE](NOTICE). See [DISCLAIMER.md](DISCLAIMER.md) for full terms and risk warnings.
299
+
300
+ - **Free for non-commercial use** (personal, academic, research). **Commercial use is not licensed** — for example in a commercial product, to provide paid services, or in a company's business operations. To request a commercial license, [open an issue](https://github.com/ardennguyen/zalo-agent-cli/issues).
301
+ - **Credit required:** if you share this project or any part of it, as-is or modified (including inside another project), credit it as shown in [NOTICE](NOTICE), say whether you made changes, and link to the license.
302
+ - **ShareAlike:** shared modifications must be released under CC BY-NC-SA 4.0 (or a later version, or a compatible license listed by Creative Commons).
303
+ - Portions derived from MIT-licensed software — [zalo-agent-cli by PhucMPham](https://github.com/PhucMPham/zalo-agent-cli) and the patched [zca-js](https://github.com/RFS-ADRENO/zca-js) shipped inside the package — remain under the MIT License; see [NOTICE](NOTICE).
304
+ - Versions published before this change (2.0.0 and earlier) were released under the MIT License and keep those terms.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ardennguyen/zalo-agent-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
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": {
@@ -8,8 +8,10 @@
8
8
  },
9
9
  "main": "./src/index.js",
10
10
  "files": [
11
+ ".env.example",
11
12
  "DISCLAIMER.md",
12
13
  "LICENSE",
14
+ "NOTICE",
13
15
  "patches/",
14
16
  "src/"
15
17
  ],
@@ -45,7 +47,7 @@
45
47
  "url": "https://github.com/ardennguyen/zalo-agent-cli"
46
48
  },
47
49
  "author": "ardennguyen",
48
- "license": "MIT",
50
+ "license": "CC-BY-NC-SA-4.0",
49
51
  "publishConfig": {
50
52
  "access": "public"
51
53
  },
@@ -61,7 +63,6 @@
61
63
  "google-protobuf": "^3.21.4",
62
64
  "https-proxy-agent": "^9.1.0",
63
65
  "image-size": "^2.0.4",
64
- "node-fetch": "^3.3.2",
65
66
  "undici": "^8.5.0",
66
67
  "zca-js": "2.2.0",
67
68
  "zod": "^4.4.3"
@@ -76,6 +77,6 @@
76
77
  },
77
78
  "allowScripts": {
78
79
  "better-sqlite3@13.0.3": true,
79
- "@ardennguyen/zalo-agent-cli@2.0.0": true
80
+ "@ardennguyen/zalo-agent-cli@2.1.1": true
80
81
  }
81
82
  }
@@ -3,12 +3,11 @@
3
3
  * mute, unmute, read, unread, delete.
4
4
  */
5
5
 
6
- import { join } from "path";
7
6
  import { MuteAction } from "zca-js";
8
7
  import { getApi } from "../core/zalo-client.js";
9
8
  import { success, error, info, output, warning } from "../utils/output.js";
10
9
  import { getActive } from "../core/accounts.js";
11
- import { CONFIG_DIR } from "../core/credentials.js";
10
+ import { getAccountDbPath } from "../core/credentials.js";
12
11
  import { initDb, getMessages, getReadStates, markThreadGone, getOrphanThreads, forgetThread } from "../core/db.js";
13
12
  import { recentConversations } from "../core/recent-conversations.js";
14
13
  import { describeReadState, readStateLabel } from "../core/read-state.js";
@@ -41,7 +40,7 @@ async function newestMessageAnchor(api, threadId, type) {
41
40
  try {
42
41
  const activeAcc = getActive();
43
42
  if (activeAcc) {
44
- initDb(join(CONFIG_DIR, "accounts", activeAcc.ownId, "zalo.db"));
43
+ initDb(getAccountDbPath(activeAcc.ownId));
45
44
  cacheOpen = true;
46
45
  }
47
46
  } catch {
@@ -231,8 +230,7 @@ export function registerConvCommands(program, deps = {}) {
231
230
  }
232
231
 
233
232
  try {
234
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
235
- initDb(join(accountDir, "zalo.db"));
233
+ initDb(getAccountDbPath(activeAcc.ownId));
236
234
  // `-n` is documented as "max results per type", and the live
237
235
  // path below honors that (up to `limit` friends AND up to
238
236
  // `limit` groups). The cache path used to apply `limit`
@@ -524,7 +522,7 @@ export function registerConvCommands(program, deps = {}) {
524
522
 
525
523
  let result;
526
524
  try {
527
- initDb(join(CONFIG_DIR, "accounts", activeAcc.ownId, "zalo.db"));
525
+ initDb(getAccountDbPath(activeAcc.ownId));
528
526
  const rows = getMessages(String(threadId), SEEN_ANCHOR_SCAN_DEPTH);
529
527
  result = await markConversationRead(zaloApi(), { threadId, type, ownId: activeAcc.ownId, rows });
530
528
  } catch (e) {
@@ -749,8 +747,7 @@ export function registerConvCommands(program, deps = {}) {
749
747
  error("Give a threadId, or --orphans to forget every conversation the account no longer has.");
750
748
  process.exit(1);
751
749
  }
752
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
753
- initDb(join(accountDir, "zalo.db"));
750
+ initDb(getAccountDbPath(activeAcc.ownId));
754
751
 
755
752
  const targets = opts.orphans
756
753
  ? getOrphanThreads().map((t) => ({ threadId: String(t.threadId), name: t.name, ...t }))
@@ -5,10 +5,10 @@
5
5
 
6
6
  import { appendFileSync, mkdirSync, existsSync } from "fs";
7
7
  import { resolve, join } from "path";
8
- import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
8
+ import { getApi, clearSession } from "../core/zalo-client.js";
9
9
  import { success, error, info, warning } from "../utils/output.js";
10
- import { getActive } from "../core/accounts.js";
11
- import { CONFIG_DIR } from "../core/credentials.js";
10
+ import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
11
+ import { getAccountDir } from "../core/credentials.js";
12
12
  import { acquireLock, releaseLock } from "../core/lock.js";
13
13
  import { createStageLock, startDaemonChannel } from "../core/daemon-channel.js";
14
14
  import { createSyncRunners } from "../core/daemon-sync.js";
@@ -55,6 +55,11 @@ const CLOSE_DUPLICATE = 3000;
55
55
  export function registerListenCommand(program) {
56
56
  program
57
57
  .command("listen")
58
+ .option(
59
+ "--account <id>",
60
+ "Pin this daemon to an exact registered account ID (default: active at startup)",
61
+ parseAccountId,
62
+ )
58
63
  .description(
59
64
  "Listen for all Zalo events (messages, friend requests, group events) via one WebSocket. Auto-reconnect enabled.",
60
65
  )
@@ -77,12 +82,14 @@ export function registerListenCommand(program) {
77
82
  "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)",
78
83
  )
79
84
  .action(async (opts) => {
80
- const activeAcc = getActive();
81
- if (!activeAcc) {
82
- error("No active account. Please login first.");
85
+ let activeAcc;
86
+ try {
87
+ activeAcc = selectDaemonAccount(opts.account);
88
+ } catch (e) {
89
+ error(e.message);
83
90
  process.exit(1);
84
91
  }
85
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
92
+ const accountDir = getAccountDir(activeAcc.ownId);
86
93
  if (!acquireLock(accountDir)) {
87
94
  error(`Another listen daemon is already running for account ${activeAcc.ownId}.`);
88
95
  process.exit(1);
@@ -104,6 +111,13 @@ export function registerListenCommand(program) {
104
111
  console.error(`[listen] Failed to release lock: ${e.message}`);
105
112
  }
106
113
  };
114
+ try {
115
+ await loginDaemonAccount(activeAcc);
116
+ } catch (e) {
117
+ releaseLock(accountDir);
118
+ error(`Pinned account login failed: ${e.message}`);
119
+ process.exit(1);
120
+ }
107
121
  try {
108
122
  initDb(join(accountDir, "zalo.db"));
109
123
  info(`Local database initialized at ${accountDir}/zalo.db`);
@@ -703,7 +717,7 @@ export function registerListenCommand(program) {
703
717
  await new Promise((r) => setTimeout(r, 5000));
704
718
  try {
705
719
  clearSession();
706
- await autoLogin(jsonMode);
720
+ await loginDaemonAccount(activeAcc);
707
721
  info("Re-login successful. Restarting listener...");
708
722
  // Attach ALL handlers to the NEW api (including lifecycle),
709
723
  // and repoint the SyncManager at it so its bookkeeping
@@ -717,7 +731,7 @@ export function registerListenCommand(program) {
717
731
  await new Promise((r) => setTimeout(r, 30000));
718
732
  try {
719
733
  clearSession();
720
- await autoLogin(jsonMode);
734
+ await loginDaemonAccount(activeAcc);
721
735
  const retryApi = getApi();
722
736
  syncManager.api = retryApi;
723
737
  attachAllHandlers(retryApi);
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { join } from "path";
9
- import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
9
+ import { getApi, clearSession } from "../core/zalo-client.js";
10
10
  import { liveApi } from "../core/live-api.js";
11
- import { getActive } from "../core/accounts.js";
12
- import { CONFIG_DIR } from "../core/credentials.js";
11
+ import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
12
+ import { getAccountDir, MEDIA_DIR } from "../core/credentials.js";
13
13
  import { acquireLock, releaseLock } from "../core/lock.js";
14
14
  import { createStageLock, startDaemonChannel } from "../core/daemon-channel.js";
15
15
  import { createSyncRunners } from "../core/daemon-sync.js";
16
+ import { createSyncJobs } from "../mcp/sync-jobs.js";
16
17
  import { createSocketTap } from "../core/socket-tap.js";
17
18
  import { createSelfHeal } from "../core/self-heal.js";
18
19
  import { createReadStateSync } from "../core/read-state.js";
@@ -93,6 +94,11 @@ export function registerMCPCommands(program) {
93
94
  const mcp = program.command("mcp").description("MCP server for AI agent integration");
94
95
 
95
96
  mcp.command("start")
97
+ .option(
98
+ "--account <id>",
99
+ "Pin this server to an exact registered account ID (default: active at startup)",
100
+ parseAccountId,
101
+ )
96
102
  .description("Start MCP server (stdio or HTTP transport)")
97
103
  .option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
98
104
  .option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
@@ -134,21 +140,15 @@ export function registerMCPCommands(program) {
134
140
  process.exit(1);
135
141
  }
136
142
 
137
- // Perform login explicitly here — preAction hook skips "mcp"
138
- // Pass jsonMode=true to suppress info() output — stdout is the MCP transport channel
143
+ // Select once; acquire the selected account's lock before authentication.
144
+ let activeAcc;
139
145
  try {
140
- await autoLogin(true);
146
+ activeAcc = selectDaemonAccount(opts.account);
141
147
  } catch (e) {
142
- console.error("[mcp] Auto-login failed:", e.message);
148
+ console.error("[mcp] Account selection failed:", e.message);
143
149
  process.exit(1);
144
150
  }
145
-
146
- const activeAcc = getActive();
147
- if (!activeAcc) {
148
- console.error("[mcp] No active account. Run `zalo-agent login` first.");
149
- process.exit(1);
150
- }
151
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
151
+ const accountDir = getAccountDir(activeAcc.ownId);
152
152
 
153
153
  // This server now opens the account's WebSocket *and* writes to
154
154
  // zalo.db, which is exactly what the `listen` daemon does. Zalo
@@ -182,6 +182,13 @@ export function registerMCPCommands(program) {
182
182
  }
183
183
  };
184
184
 
185
+ try {
186
+ await loginDaemonAccount(activeAcc);
187
+ } catch (e) {
188
+ dropLock();
189
+ console.error("[mcp] Pinned account login failed:", e.message);
190
+ process.exit(1);
191
+ }
185
192
  try {
186
193
  initDb(join(accountDir, "zalo.db"));
187
194
  console.error(`[mcp] Local cache: ${join(accountDir, "zalo.db")}`);
@@ -211,6 +218,8 @@ export function registerMCPCommands(program) {
211
218
  // self-heal catch-up and the tools' own history fetch. Created before
212
219
  // the server so the tools can hold it too.
213
220
  const stageLock = createStageLock();
221
+ const syncRunners = createSyncRunners({ getApi, accountName: activeAcc.ownId });
222
+ const syncJobs = createSyncJobs({ runners: syncRunners, lock: stageLock });
214
223
 
215
224
  // What the tools and the notifier call. Not getApi()'s object:
216
225
  // a re-login after a dropped connection replaces it, and they
@@ -231,7 +240,16 @@ export function registerMCPCommands(program) {
231
240
  process.exit(1);
232
241
  }
233
242
  const host = opts.host || "127.0.0.1";
234
- const deps = { api: sessionApi, buffer, filter, config, nameCache, accountDir, stageLock };
243
+ const deps = {
244
+ api: sessionApi,
245
+ buffer,
246
+ filter,
247
+ config,
248
+ nameCache,
249
+ accountDir,
250
+ stageLock,
251
+ syncJobs,
252
+ };
235
253
  const authToken = opts.auth?.trim() || null;
236
254
  httpServer = createHTTPServer(registerTools, deps, port, authToken, host);
237
255
  console.error(`[mcp] HTTP server started on port ${port}`);
@@ -242,7 +260,16 @@ export function registerMCPCommands(program) {
242
260
  );
243
261
  }
244
262
  } else {
245
- await createMCPServer(sessionApi, buffer, filter, config, nameCache, accountDir, stageLock);
263
+ await createMCPServer(
264
+ sessionApi,
265
+ buffer,
266
+ filter,
267
+ config,
268
+ nameCache,
269
+ accountDir,
270
+ stageLock,
271
+ syncJobs,
272
+ );
246
273
  }
247
274
  } catch (e) {
248
275
  dropLock();
@@ -358,7 +385,7 @@ export function registerMCPCommands(program) {
358
385
  threadId,
359
386
  limit: 5,
360
387
  concurrency: 2,
361
- mediaRoot: config.media?.downloadDir || undefined,
388
+ mediaRoot: MEDIA_DIR || config.media?.downloadDir || undefined,
362
389
  }).catch((e) => console.error(`[mcp] media download failed: ${e.message}`));
363
390
  }
364
391
 
@@ -471,7 +498,7 @@ export function registerMCPCommands(program) {
471
498
  await new Promise((r) => setTimeout(r, 5000));
472
499
  try {
473
500
  clearSession();
474
- await autoLogin(true);
501
+ await loginDaemonAccount(activeAcc);
475
502
  console.error("[mcp] Re-login successful. Restarting listener...");
476
503
  const newApi = getApi();
477
504
  attachListenerHandlers(newApi);
@@ -481,7 +508,7 @@ export function registerMCPCommands(program) {
481
508
  await new Promise((r) => setTimeout(r, 30000));
482
509
  try {
483
510
  clearSession();
484
- await autoLogin(true);
511
+ await loginDaemonAccount(activeAcc);
485
512
  const retryApi = getApi();
486
513
  attachListenerHandlers(retryApi);
487
514
  retryApi.listener.start({ retryOnClose: true });
@@ -518,7 +545,7 @@ export function registerMCPCommands(program) {
518
545
  getApi,
519
546
  accountDir,
520
547
  onLog: (m) => console.error(`[mcp] ${m}`),
521
- runners: createSyncRunners({ getApi, accountName: activeAcc.ownId }),
548
+ runners: syncRunners,
522
549
  // The self-heal's lock: a stage and a catch-up never share the socket.
523
550
  lock: stageLock,
524
551
  });
@@ -7,10 +7,11 @@ import { existsSync } from "node:fs";
7
7
  import { resolve, join } from "path";
8
8
  import { InvalidArgumentError } from "commander";
9
9
  import { getApi, getOwnId } from "../core/zalo-client.js";
10
+ import { withCurrentSenderNames } from "../core/current-user-names.js";
10
11
  import { success, error, info, output, warning } from "../utils/output.js";
11
12
  import { parseIntOption } from "../utils/parse-options.js";
12
13
  import { getActive } from "../core/accounts.js";
13
- import { CONFIG_DIR } from "../core/credentials.js";
14
+ import { getAccountDir, getAccountDbPath } from "../core/credentials.js";
14
15
  import { initDb, getMessages, getMessageById, getDisplayName, getThreadType } from "../core/db.js";
15
16
  import { sendViaDaemon, getDaemonChannel, getSyncChannel, syncViaDaemon } from "../core/daemon-channel.js";
16
17
  import { fetchAndCacheHistory } from "../core/history-fetch.js";
@@ -46,7 +47,7 @@ function openAccountDb() {
46
47
  try {
47
48
  const acc = getActive();
48
49
  if (!acc) return false;
49
- initDb(join(CONFIG_DIR, "accounts", acc.ownId, "zalo.db"));
50
+ initDb(getAccountDbPath(acc.ownId));
50
51
  return true;
51
52
  } catch {
52
53
  return false;
@@ -446,7 +447,7 @@ async function sendAttachments(api, absPaths, threadId, type, opts) {
446
447
  if (needsListener) {
447
448
  const acc = getActive();
448
449
  if (acc) {
449
- const viaDaemon = await sendViaDaemon(join(CONFIG_DIR, "accounts", acc.ownId), {
450
+ const viaDaemon = await sendViaDaemon(getAccountDir(acc.ownId), {
450
451
  paths: absPaths,
451
452
  threadId,
452
453
  type,
@@ -1210,7 +1211,7 @@ export function registerMsgCommands(program) {
1210
1211
 
1211
1212
  let row;
1212
1213
  try {
1213
- initDb(join(CONFIG_DIR, "accounts", activeAcc.ownId, "zalo.db"));
1214
+ initDb(getAccountDbPath(activeAcc.ownId));
1214
1215
  row = getMessageById(msgId);
1215
1216
  } catch (e) {
1216
1217
  error(
@@ -1529,7 +1530,7 @@ export function registerMsgCommands(program) {
1529
1530
  let dbActive = false;
1530
1531
 
1531
1532
  try {
1532
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
1533
+ const accountDir = getAccountDir(activeAcc.ownId);
1533
1534
  initDb(join(accountDir, "zalo.db"));
1534
1535
  dbActive = true;
1535
1536
 
@@ -1556,16 +1557,20 @@ export function registerMsgCommands(program) {
1556
1557
  } catch {
1557
1558
  /* showing history must not fail because media did */
1558
1559
  }
1559
- const messages = localMsgs.map((m) => ({
1560
- msgId: m.msgId,
1561
- threadId: m.threadId,
1562
- senderId: m.senderId,
1563
- senderName: m.senderName,
1564
- text: m.text,
1565
- timestamp: m.timestamp,
1566
- type: m.type,
1567
- localPath: m.localPath,
1568
- }));
1560
+ const messages = await withCurrentSenderNames(
1561
+ api,
1562
+ localMsgs.map((m) => ({
1563
+ msgId: m.msgId,
1564
+ threadId: m.threadId,
1565
+ senderId: m.senderId,
1566
+ senderName: m.senderName,
1567
+ text: m.text,
1568
+ timestamp: m.timestamp,
1569
+ type: m.type,
1570
+ localPath: m.localPath,
1571
+ })),
1572
+ threadType === 0 ? [threadId] : [],
1573
+ );
1569
1574
 
1570
1575
  output(
1571
1576
  {
@@ -1580,7 +1585,7 @@ export function registerMsgCommands(program) {
1580
1585
  success(`${messages.length} message(s) from ${threadId} (Local Cache)`);
1581
1586
  for (const m of messages) {
1582
1587
  const date = m.timestamp ? new Date(m.timestamp).toLocaleString() : "?";
1583
- const name = m.senderName || m.senderId || "?";
1588
+ const name = m.senderDisplayName || m.senderId || "?";
1584
1589
  const mediaInfo = m.localPath ? ` [Media: ${m.localPath}]` : "";
1585
1590
  console.log(` [${date}] ${name}: ${(m.text || "").slice(0, 200)}${mediaInfo}`);
1586
1591
  }
@@ -1620,7 +1625,7 @@ export function registerMsgCommands(program) {
1620
1625
  // session the scan needs and is the one db writer (AGENTS.md
1621
1626
  // §13). With a daemon up that is the daemon, and this command
1622
1627
  // only displays what it returns.
1623
- const daemonDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
1628
+ const daemonDir = getAccountDir(activeAcc.ownId);
1624
1629
  const daemon = getDaemonChannel(daemonDir);
1625
1630
  let fetched = null;
1626
1631
  let handledByDaemon = false;
@@ -1707,11 +1712,15 @@ export function registerMsgCommands(program) {
1707
1712
  const result = mergedArray.slice(0, limit);
1708
1713
 
1709
1714
  // Format final output
1710
- const cleanResult = result.map((m) => {
1711
- const r = { ...m };
1712
- delete r.raw_data;
1713
- return r;
1714
- });
1715
+ const cleanResult = await withCurrentSenderNames(
1716
+ api,
1717
+ result.map((m) => {
1718
+ const r = { ...m };
1719
+ delete r.raw_data;
1720
+ return r;
1721
+ }),
1722
+ threadType === 0 ? [threadId] : [],
1723
+ );
1715
1724
 
1716
1725
  output(
1717
1726
  {
@@ -1728,7 +1737,7 @@ export function registerMsgCommands(program) {
1728
1737
  success(`${cleanResult.length} message(s) from ${threadId}`);
1729
1738
  for (const m of cleanResult) {
1730
1739
  const date = m.timestamp ? new Date(m.timestamp).toLocaleString() : "?";
1731
- const name = m.senderName || m.senderId || "?";
1740
+ const name = m.senderDisplayName || m.senderId || "?";
1732
1741
  const mediaInfo = m.localPath ? ` [Media: ${m.localPath}]` : "";
1733
1742
  console.log(` [${date}] ${name}: ${(m.text || "").slice(0, 200)}${mediaInfo}`);
1734
1743
  }