@ardennguyen/zalo-agent-cli 2.0.0 → 2.1.0

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,7 +136,7 @@ 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
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>/` (MCP server có thể ghi đè bằng `media.downloadDir`)
138
142
  - Thẻ chuyển khoản (55+ ngân hàng VN) & QR VietQR
@@ -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,7 +264,7 @@ 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
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>/` (the MCP server can override the root with `media.downloadDir`)
254
270
  - Bank cards (55+ VN banks) and VietQR payment images
@@ -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.0",
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": {
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "DISCLAIMER.md",
12
12
  "LICENSE",
13
+ "NOTICE",
13
14
  "patches/",
14
15
  "src/"
15
16
  ],
@@ -45,7 +46,7 @@
45
46
  "url": "https://github.com/ardennguyen/zalo-agent-cli"
46
47
  },
47
48
  "author": "ardennguyen",
48
- "license": "MIT",
49
+ "license": "CC-BY-NC-SA-4.0",
49
50
  "publishConfig": {
50
51
  "access": "public"
51
52
  },
@@ -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.0.1": true
80
81
  }
81
82
  }
@@ -5,9 +5,9 @@
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";
10
+ import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
11
11
  import { CONFIG_DIR } from "../core/credentials.js";
12
12
  import { acquireLock, releaseLock } from "../core/lock.js";
13
13
  import { createStageLock, startDaemonChannel } from "../core/daemon-channel.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,9 +82,11 @@ 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
92
  const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
@@ -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";
11
+ import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
12
12
  import { CONFIG_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,18 +140,12 @@ 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);
143
- process.exit(1);
144
- }
145
-
146
- const activeAcc = getActive();
147
- if (!activeAcc) {
148
- console.error("[mcp] No active account. Run `zalo-agent login` first.");
148
+ console.error("[mcp] Account selection failed:", e.message);
149
149
  process.exit(1);
150
150
  }
151
151
  const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
@@ -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();
@@ -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,6 +7,7 @@ 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";
@@ -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
  }
@@ -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
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Read the account's complete alias snapshot. Do not reuse a previous snapshot:
3
+ * an absent entry means the owner removed that alias. Failed/partial reads do
4
+ * not pretend an old alias is current. Diagnostics never include identities.
5
+ * @param {object} api - current authenticated API
6
+ * @param {Function} [log] - diagnostic sink
7
+ * @returns {Promise<Map<string, string>>} UID -> account-owned alias
8
+ */
9
+ export async function readUserAliases(api, log = () => {}) {
10
+ const aliases = new Map();
11
+ try {
12
+ if (typeof api?.getAliasList !== "function") return aliases;
13
+ for (let page = 1; page <= 1000; page++) {
14
+ const result = await api.getAliasList(100, page);
15
+ if (!Array.isArray(result?.items)) throw new Error("invalid alias page");
16
+ let added = 0;
17
+ for (const item of result.items) {
18
+ if (item.userId && typeof item.alias === "string" && item.alias.trim()) {
19
+ const uid = String(item.userId);
20
+ if (!aliases.has(uid)) added++;
21
+ aliases.set(uid, item.alias);
22
+ }
23
+ }
24
+ if (result.items.length < 100) return aliases;
25
+ if (!added) throw new Error("alias pagination did not advance");
26
+ }
27
+ throw new Error("alias pagination limit reached");
28
+ } catch {
29
+ log("current aliases unavailable; falling back to profile names or UIDs");
30
+ return new Map();
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Select a current label independently of any per-message historical name.
36
+ * @param {string} uid - canonical user ID
37
+ * @param {object} [profile] - fresh Zalo profile
38
+ * @param {Map<string, string>} [aliases] - account-owned aliases
39
+ * @returns {{name: string, source: string}} label and provenance
40
+ */
41
+ export function currentUserName(uid, profile, aliases = new Map()) {
42
+ const id = String(uid || "");
43
+ const alias = aliases.get(id);
44
+ if (alias) return { name: alias, source: "alias" };
45
+ // displayName can itself be an alias. Only zaloName establishes the
46
+ // profile-name fallback independently of the owner's alias snapshot.
47
+ const name = profile?.zaloName;
48
+ return name ? { name, source: "zalo" } : { name: id, source: "uid" };
49
+ }
50
+
51
+ /**
52
+ * Resolve current names in batches, with no cross-request/account name cache.
53
+ * Failures degrade to UID, never to a historical senderName. Profiles can be
54
+ * supplied by a fresh friend-list response to avoid fetching them twice.
55
+ * @param {object} api - current authenticated API
56
+ * @param {string[]} uids - IDs to display
57
+ * @param {object} [options]
58
+ * @returns {Promise<Map<string, {name: string, source: string}>>} UID -> current label
59
+ */
60
+ export async function resolveCurrentUserNames(api, uids, { aliases, profiles = new Map(), log = () => {} } = {}) {
61
+ const ids = [...new Set(uids.filter(Boolean).map(String))];
62
+ if (!ids.length) return new Map();
63
+ const aliasRequest = aliases ? Promise.resolve(aliases) : readUserAliases(api, log);
64
+ const fresh = new Map(profiles);
65
+ const missing = ids.filter((id) => /^[1-9][0-9]*$/.test(id) && !fresh.has(id));
66
+ for (let i = 0; i < missing.length; i += 100) {
67
+ try {
68
+ if (typeof api?.getUserInfo !== "function") break;
69
+ const result = await api.getUserInfo(missing.slice(i, i + 100));
70
+ for (const bag of [result?.changed_profiles, result?.unchanged_profiles]) {
71
+ for (const [uid, profile] of Object.entries(bag || {})) {
72
+ if (profile) fresh.set(uid, profile);
73
+ }
74
+ }
75
+ } catch {
76
+ log("current profiles unavailable; falling back to aliases or UIDs");
77
+ }
78
+ }
79
+ const ownedAliases = await aliasRequest;
80
+ return new Map(ids.map((id) => [id, currentUserName(id, fresh.get(id), ownedAliases)]));
81
+ }
82
+
83
+ /**
84
+ * Decorate copied messages with current labels; senderName stays historical.
85
+ * DM titles use the peer's UID, including for an outgoing message from self.
86
+ * @param {object} api - current authenticated API
87
+ * @param {object[]} messages - cached or live rows
88
+ * @param {string[]} [dmThreadIds] - known DM peer IDs, never group IDs
89
+ * @returns {Promise<object[]>} detached display rows
90
+ */
91
+ export async function withCurrentSenderNames(api, messages, dmThreadIds = []) {
92
+ const peers = new Set(dmThreadIds.map(String));
93
+ const names = await resolveCurrentUserNames(api, [...messages.map((m) => m.senderId), ...peers]);
94
+ return messages.map((m) => {
95
+ const sender = names.get(String(m.senderId)) || { name: null, source: "uid" };
96
+ const peer = peers.has(String(m.threadId)) && names.get(String(m.threadId));
97
+ return {
98
+ ...m,
99
+ senderDisplayName: sender.name,
100
+ senderDisplayNameSource: sender.source,
101
+ ...(peer && { threadName: peer.name }),
102
+ };
103
+ });
104
+ }
@@ -0,0 +1,61 @@
1
+ import { InvalidArgumentError } from "commander";
2
+ import { getActive, getAccount } from "./accounts.js";
3
+ import { loadCredentials } from "./credentials.js";
4
+ import { loginWithCredentials, clearSession } from "./zalo-client.js";
5
+
6
+ /** Validate a full numeric account ID without number coercion, names, or prefix matching. */
7
+ export function parseAccountId(value) {
8
+ if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) {
9
+ throw new InvalidArgumentError("--account requires a full numeric account ID, not a name or partial ID");
10
+ }
11
+ return value;
12
+ }
13
+
14
+ /**
15
+ * Snapshot one daemon's account at startup without changing the shared registry.
16
+ * Even an implicit active-account selection stays pinned for this process.
17
+ * @param {string} [id] - exact registered account ID, never a display name
18
+ * @param {object} [deps] - offline-test collaborators
19
+ * @returns {Readonly<object>} frozen account identity and proxy configuration
20
+ */
21
+ export function selectDaemonAccount(id, { active = getActive, lookup = getAccount } = {}) {
22
+ const selected = id === undefined ? active() : lookup(parseAccountId(id));
23
+ if (!selected) {
24
+ throw new Error(
25
+ id === undefined
26
+ ? "No active account. Please login first. You can also pass --account <ID>."
27
+ : "Account ID is not registered. Use `zalo-agent account list` to find the full ID.",
28
+ );
29
+ }
30
+ const ownId = parseAccountId(selected.ownId);
31
+ if (id !== undefined && ownId !== id) throw new Error("Account lookup returned a different ID; refusing to start");
32
+ return Object.freeze({ ...selected, ownId });
33
+ }
34
+
35
+ /**
36
+ * Authenticate only the daemon's pinned account, including after a disconnect.
37
+ * Never consult the active registry or retain a session for a different user.
38
+ * The caller must hold this account's daemon lock before invoking this function.
39
+ * @param {object} account - selectDaemonAccount() snapshot
40
+ * @param {object} [deps] - offline-test collaborators
41
+ * @returns {Promise<object>} authenticated API/ownId result
42
+ */
43
+ export async function loginDaemonAccount(
44
+ account,
45
+ { read = loadCredentials, login = loginWithCredentials, reset = clearSession } = {},
46
+ ) {
47
+ const id = parseAccountId(account.ownId);
48
+ reset();
49
+ const credentials = read(id);
50
+ if (!credentials) throw new Error("No saved credentials for the pinned account. Log in to that account first.");
51
+ try {
52
+ const session = await login(credentials, account.proxy || null);
53
+ if (String(session?.ownId || "") !== id) {
54
+ throw new Error("Authenticated account does not match the pinned ID; refusing to attach its listener");
55
+ }
56
+ return session;
57
+ } catch (e) {
58
+ reset();
59
+ throw e;
60
+ }
61
+ }
@@ -69,8 +69,8 @@ export function createSyncRunners({ getApi, accountName }) {
69
69
  *
70
70
  * Still prompts the owner's phone exactly once — routing through the
71
71
  * daemon removes the second WebSocket, not the confirmation. The
72
- * daemon never calls this on its own; only a `zalo-agent sync` the
73
- * owner typed reaches it.
72
+ * daemon never calls this on its own; an explicit CLI sync or MCP
73
+ * sync request authorized by the owner reaches it.
74
74
  *
75
75
  * @param {{days?: number|null, from?: string|number, waitMs?: number,
76
76
  * shardSize?: number, waveSize?: number}} params
package/src/core/db.js CHANGED
@@ -446,10 +446,25 @@ export function runInTransaction(fn) {
446
446
  export function insertMessage(msg) {
447
447
  if (!db) throw new Error("Database not initialized");
448
448
 
449
+ // A replay may resolve an opaque sender from an older sync. Never let an
450
+ // unresolved replay replace a canonical live UID, or a different numeric
451
+ // sender overwrite an already established identity.
452
+ const canResolveSender = `(
453
+ COALESCE(messages.senderId, '') = '' OR
454
+ (messages.senderId GLOB '*[^0-9]*' AND
455
+ excluded.senderId <> '' AND excluded.senderId <> '0' AND
456
+ excluded.senderId NOT GLOB '*[^0-9]*')
457
+ )`;
449
458
  const stmt = db.prepare(`
450
459
  INSERT INTO messages (msgId, threadId, senderId, senderName, text, timestamp, type, raw_data, localPath, has_attachment, msgStatus)
451
460
  VALUES (@msgId, @threadId, @senderId, @senderName, @text, @timestamp, @type, @raw_data, @localPath, @has_attachment, @msgStatus)
452
461
  ON CONFLICT(msgId) DO UPDATE SET
462
+ senderId = CASE WHEN ${canResolveSender} THEN excluded.senderId ELSE messages.senderId END,
463
+ senderName = CASE
464
+ WHEN COALESCE(messages.senderName, '') = '' AND
465
+ (messages.senderId = excluded.senderId OR ${canResolveSender})
466
+ THEN COALESCE(NULLIF(excluded.senderName, ''), messages.senderName)
467
+ ELSE messages.senderName END,
453
468
  -- A removal is terminal. Once a row is a tombstone, a later write of the
454
469
  -- same msgId -- a re-sync, a history replay, a backfill -- must not bring
455
470
  -- the message back or replace the tombstone's record of HOW it was