@ardennguyen/zalo-agent-cli 2.1.0 → 2.2.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/.env.example +35 -0
- package/README.md +10 -8
- package/package.json +3 -3
- package/src/commands/conv.js +5 -8
- package/src/commands/listen.js +5 -3
- package/src/commands/mcp.js +6 -4
- package/src/commands/msg.js +6 -6
- package/src/commands/sync.js +10 -10
- package/src/core/accounts.js +36 -9
- package/src/core/credentials.js +152 -4
- package/src/core/logout.js +3 -3
- package/src/core/oa-client.js +27 -15
- package/src/core/sync-v2/index.js +9 -6
- package/src/core/sync-v2/media.js +4 -3
- package/src/core/sync.js +2 -2
- package/src/index.js +47 -2
- package/src/mcp/mcp-config.js +6 -4
- package/src/mcp/mcp-tools.js +1 -1
- package/src/utils/qr-display.js +7 -8
- package/src/utils/qr-http-server.js +1 -2
- package/src/utils/thread-type.js +2 -3
package/.env.example
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# zalo-agent-cli — Environment Configuration Template (.env.example)
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# Recommended location: `~/.zalo-agent-cli/.env` (`<CONFIG_DIR>/.env`), or pass
|
|
5
|
+
# `--env-file /path/to/.env` (`ZALO_ENV_FILE=/path/to/.env`).
|
|
6
|
+
# Note: If you also use the `zalo-mcp` deployment wrapper, keep `zalo-mcp/.env`
|
|
7
|
+
# for port settings (`ZALO_MCP_HTTP_PORT`, `ZALO_OA_WEBHOOK_PORT`) only, and put
|
|
8
|
+
# all storage paths and engine settings in `<CONFIG_DIR>/.env` so `zalo-mcp` and
|
|
9
|
+
# `zalo-agent` share a single source of truth.
|
|
10
|
+
# CLI flags and shell environment variables take precedence over `.env` values.
|
|
11
|
+
|
|
12
|
+
# ----------------------------------------------------------------------------
|
|
13
|
+
# Storage Directories (Optional)
|
|
14
|
+
# Uncomment to separate config, credentials, SQLite database, and media onto
|
|
15
|
+
# different volumes or mounts in production:
|
|
16
|
+
# ----------------------------------------------------------------------------
|
|
17
|
+
# ZALO_CONFIG_DIR=
|
|
18
|
+
# ZALO_CREDENTIALS_DIR=
|
|
19
|
+
# ZALO_DATA_DIR=
|
|
20
|
+
# ZALO_MEDIA_DIR=
|
|
21
|
+
# ZALO_OA_DIR=
|
|
22
|
+
|
|
23
|
+
# ----------------------------------------------------------------------------
|
|
24
|
+
# Runtime & Account Options (Optional)
|
|
25
|
+
# ----------------------------------------------------------------------------
|
|
26
|
+
# Pin a default registered account ID for this environment without changing
|
|
27
|
+
# the global active entry in accounts.json (equivalent to --account <ID>):
|
|
28
|
+
# ZALO_ACCOUNT=
|
|
29
|
+
|
|
30
|
+
# Set to any value (e.g. 1) to disable the background npm update check:
|
|
31
|
+
# ZALO_AGENT_NO_UPDATE_CHECK=1
|
|
32
|
+
|
|
33
|
+
# Protocol version announced to Zalo (default: 691; set 685 to roll back):
|
|
34
|
+
# ZALO_API_VERSION=691
|
|
35
|
+
|
package/README.md
CHANGED
|
@@ -47,12 +47,13 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
|
|
47
47
|
> **MCP Server (AI Agent Integration)** — Model Context Protocol cho Claude Code và các MCP client:
|
|
48
48
|
> ```bash
|
|
49
49
|
> zalo-agent mcp start # stdio (local Claude Code)
|
|
50
|
+
> zalo-agent mcp start --account <ID> # Ghim tài khoản (chạy nhiều tài khoản live cùng lúc)
|
|
50
51
|
> zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
|
|
51
52
|
> ```
|
|
52
53
|
> 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
|
|
|
54
|
-
[Sync qua MCP
|
|
55
|
-
> Auto-reconnect · thread filter · noise reduction · tự tải media · thông báo qua nhóm Zalo
|
|
55
|
+
[Sync qua MCP](docs/mcp-sync.vi.md) · [Ghim đa tài khoản live](docs/account-pinning.vi.md).
|
|
56
|
+
> Auto-reconnect · thread filter · noise reduction · tự tải media · thông báo qua nhóm Zalo · chạy song song nhiều tài khoản (`--account <ID>`)
|
|
56
57
|
> Xem [MCP Guide](skill/references/mcp-guide.md) · [INSTALLATION.md](INSTALLATION.md)
|
|
57
58
|
|
|
58
59
|
---
|
|
@@ -133,12 +134,12 @@ Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-
|
|
|
133
134
|
## Tính năng
|
|
134
135
|
|
|
135
136
|
- Đă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
|
|
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
|
|
137
|
+
- Đ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`)
|
|
137
138
|
- **190 lệnh** phủ hết tính năng Zalo cá nhân
|
|
138
139
|
- **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
|
|
139
140
|
- **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
|
|
140
141
|
- **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`
|
|
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>/` (
|
|
142
|
+
- 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`)
|
|
142
143
|
- Thẻ chuyển khoản (55+ ngân hàng VN) & QR VietQR
|
|
143
144
|
- Lắng nghe real-time với webhook & lưu JSONL local
|
|
144
145
|
- `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ộ
|
|
@@ -211,12 +212,13 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
|
|
|
211
212
|
> **MCP Server (AI Agent Integration)** — Model Context Protocol support for Claude Code and MCP clients:
|
|
212
213
|
> ```bash
|
|
213
214
|
> zalo-agent mcp start # stdio (local Claude Code)
|
|
215
|
+
> zalo-agent mcp start --account <ID> # Pin account (run multiple live accounts at once)
|
|
214
216
|
> zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
|
|
215
217
|
> ```
|
|
216
218
|
> 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
219
|
|
|
218
|
-
[MCP sync
|
|
219
|
-
> Auto-reconnect · thread filter · noise reduction · media auto-download · Zalo-group notifications
|
|
220
|
+
[MCP sync](docs/mcp-sync.md) · [Multi-account pinning](docs/account-pinning.md).
|
|
221
|
+
> Auto-reconnect · thread filter · noise reduction · media auto-download · Zalo-group notifications · concurrent multi-account (`--account <ID>`)
|
|
220
222
|
> See [MCP Guide](skill/references/mcp-guide.md) · [INSTALLATION.md](INSTALLATION.md)
|
|
221
223
|
|
|
222
224
|
### Quick Start
|
|
@@ -261,12 +263,12 @@ See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/
|
|
|
261
263
|
### Features
|
|
262
264
|
|
|
263
265
|
- QR login over an auto-started HTTP server (browser + terminal), reporting a **decline on the phone** immediately instead of hanging until the 60s timeout
|
|
264
|
-
- Multi-account with a dedicated proxy (1:1) and a per-account device fingerprint
|
|
266
|
+
- 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`)
|
|
265
267
|
- **190 commands** covering the personal-account Zalo surface
|
|
266
268
|
- **Zalo Official Account (OA) API v3.0** — OAuth login, messaging, followers, tags, articles, store, webhook listener, multi-OA
|
|
267
269
|
- **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
|
|
268
270
|
- **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
|
|
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>/` (
|
|
271
|
+
- 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`)
|
|
270
272
|
- Bank cards (55+ VN banks) and VietQR payment images
|
|
271
273
|
- Real-time listener with webhook forwarding and local JSONL archival
|
|
272
274
|
- `logout` performs a real **server-side** session invalidation; `logout --purge` / `account remove` wipe all local data
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ardennguyen/zalo-agent-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "./src/index.js",
|
|
10
10
|
"files": [
|
|
11
|
+
".env.example",
|
|
11
12
|
"DISCLAIMER.md",
|
|
12
13
|
"LICENSE",
|
|
13
14
|
"NOTICE",
|
|
@@ -62,7 +63,6 @@
|
|
|
62
63
|
"google-protobuf": "^3.21.4",
|
|
63
64
|
"https-proxy-agent": "^9.1.0",
|
|
64
65
|
"image-size": "^2.0.4",
|
|
65
|
-
"node-fetch": "^3.3.2",
|
|
66
66
|
"undici": "^8.5.0",
|
|
67
67
|
"zca-js": "2.2.0",
|
|
68
68
|
"zod": "^4.4.3"
|
|
@@ -77,6 +77,6 @@
|
|
|
77
77
|
},
|
|
78
78
|
"allowScripts": {
|
|
79
79
|
"better-sqlite3@13.0.3": true,
|
|
80
|
-
"@ardennguyen/zalo-agent-cli@2.
|
|
80
|
+
"@ardennguyen/zalo-agent-cli@2.1.1": true
|
|
81
81
|
}
|
|
82
82
|
}
|
package/src/commands/conv.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 }))
|
package/src/commands/listen.js
CHANGED
|
@@ -8,7 +8,7 @@ import { resolve, join } from "path";
|
|
|
8
8
|
import { getApi, clearSession } from "../core/zalo-client.js";
|
|
9
9
|
import { success, error, info, warning } from "../utils/output.js";
|
|
10
10
|
import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
|
|
11
|
-
import {
|
|
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";
|
|
@@ -84,12 +84,14 @@ export function registerListenCommand(program) {
|
|
|
84
84
|
.action(async (opts) => {
|
|
85
85
|
let activeAcc;
|
|
86
86
|
try {
|
|
87
|
-
|
|
87
|
+
const requestedAccount =
|
|
88
|
+
opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
89
|
+
activeAcc = selectDaemonAccount(requestedAccount);
|
|
88
90
|
} catch (e) {
|
|
89
91
|
error(e.message);
|
|
90
92
|
process.exit(1);
|
|
91
93
|
}
|
|
92
|
-
const accountDir =
|
|
94
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
93
95
|
if (!acquireLock(accountDir)) {
|
|
94
96
|
error(`Another listen daemon is already running for account ${activeAcc.ownId}.`);
|
|
95
97
|
process.exit(1);
|
package/src/commands/mcp.js
CHANGED
|
@@ -9,7 +9,7 @@ import { join } from "path";
|
|
|
9
9
|
import { getApi, clearSession } from "../core/zalo-client.js";
|
|
10
10
|
import { liveApi } from "../core/live-api.js";
|
|
11
11
|
import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
|
|
12
|
-
import {
|
|
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";
|
|
@@ -143,12 +143,14 @@ export function registerMCPCommands(program) {
|
|
|
143
143
|
// Select once; acquire the selected account's lock before authentication.
|
|
144
144
|
let activeAcc;
|
|
145
145
|
try {
|
|
146
|
-
|
|
146
|
+
const requestedAccount =
|
|
147
|
+
opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
148
|
+
activeAcc = selectDaemonAccount(requestedAccount);
|
|
147
149
|
} catch (e) {
|
|
148
150
|
console.error("[mcp] Account selection failed:", e.message);
|
|
149
151
|
process.exit(1);
|
|
150
152
|
}
|
|
151
|
-
const accountDir =
|
|
153
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
152
154
|
|
|
153
155
|
// This server now opens the account's WebSocket *and* writes to
|
|
154
156
|
// zalo.db, which is exactly what the `listen` daemon does. Zalo
|
|
@@ -385,7 +387,7 @@ export function registerMCPCommands(program) {
|
|
|
385
387
|
threadId,
|
|
386
388
|
limit: 5,
|
|
387
389
|
concurrency: 2,
|
|
388
|
-
mediaRoot: config.media?.downloadDir || undefined,
|
|
390
|
+
mediaRoot: MEDIA_DIR || config.media?.downloadDir || undefined,
|
|
389
391
|
}).catch((e) => console.error(`[mcp] media download failed: ${e.message}`));
|
|
390
392
|
}
|
|
391
393
|
|
package/src/commands/msg.js
CHANGED
|
@@ -11,7 +11,7 @@ import { withCurrentSenderNames } from "../core/current-user-names.js";
|
|
|
11
11
|
import { success, error, info, output, warning } from "../utils/output.js";
|
|
12
12
|
import { parseIntOption } from "../utils/parse-options.js";
|
|
13
13
|
import { getActive } from "../core/accounts.js";
|
|
14
|
-
import {
|
|
14
|
+
import { getAccountDir, getAccountDbPath } from "../core/credentials.js";
|
|
15
15
|
import { initDb, getMessages, getMessageById, getDisplayName, getThreadType } from "../core/db.js";
|
|
16
16
|
import { sendViaDaemon, getDaemonChannel, getSyncChannel, syncViaDaemon } from "../core/daemon-channel.js";
|
|
17
17
|
import { fetchAndCacheHistory } from "../core/history-fetch.js";
|
|
@@ -47,7 +47,7 @@ function openAccountDb() {
|
|
|
47
47
|
try {
|
|
48
48
|
const acc = getActive();
|
|
49
49
|
if (!acc) return false;
|
|
50
|
-
initDb(
|
|
50
|
+
initDb(getAccountDbPath(acc.ownId));
|
|
51
51
|
return true;
|
|
52
52
|
} catch {
|
|
53
53
|
return false;
|
|
@@ -447,7 +447,7 @@ async function sendAttachments(api, absPaths, threadId, type, opts) {
|
|
|
447
447
|
if (needsListener) {
|
|
448
448
|
const acc = getActive();
|
|
449
449
|
if (acc) {
|
|
450
|
-
const viaDaemon = await sendViaDaemon(
|
|
450
|
+
const viaDaemon = await sendViaDaemon(getAccountDir(acc.ownId), {
|
|
451
451
|
paths: absPaths,
|
|
452
452
|
threadId,
|
|
453
453
|
type,
|
|
@@ -1211,7 +1211,7 @@ export function registerMsgCommands(program) {
|
|
|
1211
1211
|
|
|
1212
1212
|
let row;
|
|
1213
1213
|
try {
|
|
1214
|
-
initDb(
|
|
1214
|
+
initDb(getAccountDbPath(activeAcc.ownId));
|
|
1215
1215
|
row = getMessageById(msgId);
|
|
1216
1216
|
} catch (e) {
|
|
1217
1217
|
error(
|
|
@@ -1530,7 +1530,7 @@ export function registerMsgCommands(program) {
|
|
|
1530
1530
|
let dbActive = false;
|
|
1531
1531
|
|
|
1532
1532
|
try {
|
|
1533
|
-
const accountDir =
|
|
1533
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1534
1534
|
initDb(join(accountDir, "zalo.db"));
|
|
1535
1535
|
dbActive = true;
|
|
1536
1536
|
|
|
@@ -1625,7 +1625,7 @@ export function registerMsgCommands(program) {
|
|
|
1625
1625
|
// session the scan needs and is the one db writer (AGENTS.md
|
|
1626
1626
|
// §13). With a daemon up that is the daemon, and this command
|
|
1627
1627
|
// only displays what it returns.
|
|
1628
|
-
const daemonDir =
|
|
1628
|
+
const daemonDir = getAccountDir(activeAcc.ownId);
|
|
1629
1629
|
const daemon = getDaemonChannel(daemonDir);
|
|
1630
1630
|
let fetched = null;
|
|
1631
1631
|
let handledByDaemon = false;
|
package/src/commands/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { getApi } from "../core/zalo-client.js";
|
|
3
3
|
import { getActive } from "../core/accounts.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getAccountDir } from "../core/credentials.js";
|
|
5
5
|
import { acquireLock, releaseLock, checkLock } from "../core/lock.js";
|
|
6
6
|
import { error, info, success, warning, output } from "../utils/output.js";
|
|
7
7
|
import { parseIntAtLeast, parseIntOption } from "../utils/parse-options.js";
|
|
@@ -344,7 +344,7 @@ const MB = 1024 * 1024;
|
|
|
344
344
|
|
|
345
345
|
/** Download attachments recorded by an earlier sync. No phone, no socket. */
|
|
346
346
|
async function runMediaDownload(activeAcc, opts) {
|
|
347
|
-
const accountDir =
|
|
347
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
348
348
|
initDb(join(accountDir, "zalo.db"));
|
|
349
349
|
|
|
350
350
|
let kinds;
|
|
@@ -464,7 +464,7 @@ async function runMediaDownload(activeAcc, opts) {
|
|
|
464
464
|
|
|
465
465
|
/** Reclaim media held by conversations the account no longer has. */
|
|
466
466
|
async function runPruneOrphans(activeAcc, opts) {
|
|
467
|
-
const accountDir =
|
|
467
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
468
468
|
initDb(join(accountDir, "zalo.db"));
|
|
469
469
|
|
|
470
470
|
// The recorded definition: the same set `sync`'s warning counts and
|
|
@@ -521,7 +521,7 @@ async function runPruneOrphans(activeAcc, opts) {
|
|
|
521
521
|
|
|
522
522
|
/** Delete downloaded media older than N days. Never removes message rows. */
|
|
523
523
|
async function runMediaPrune(activeAcc, opts) {
|
|
524
|
-
const accountDir =
|
|
524
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
525
525
|
initDb(join(accountDir, "zalo.db"));
|
|
526
526
|
|
|
527
527
|
// "all" is a mode, not a very large number of days: a cutoff computed from
|
|
@@ -570,7 +570,7 @@ async function runMediaPrune(activeAcc, opts) {
|
|
|
570
570
|
|
|
571
571
|
/** Sync board items + reminders for the cached threads. No phone, no socket. */
|
|
572
572
|
async function runBoardSync(activeAcc, opts) {
|
|
573
|
-
const accountDir =
|
|
573
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
574
574
|
initDb(join(accountDir, "zalo.db"));
|
|
575
575
|
|
|
576
576
|
let api;
|
|
@@ -664,7 +664,7 @@ async function boardPass(api, opts) {
|
|
|
664
664
|
* @param {{wait?: number, pages?: number, applyRemovals?: boolean}} opts
|
|
665
665
|
*/
|
|
666
666
|
async function runReactionSync(activeAcc, opts) {
|
|
667
|
-
const accountDir =
|
|
667
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
668
668
|
initDb(join(accountDir, "zalo.db"));
|
|
669
669
|
|
|
670
670
|
let api;
|
|
@@ -862,7 +862,7 @@ function reportReactionDrain(stats, opts) {
|
|
|
862
862
|
|
|
863
863
|
/** Walk the zCloud index. No phone, no socket. */
|
|
864
864
|
async function runCloudSync(activeAcc, opts) {
|
|
865
|
-
const accountDir =
|
|
865
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
866
866
|
initDb(join(accountDir, "zalo.db"));
|
|
867
867
|
|
|
868
868
|
let api;
|
|
@@ -998,7 +998,7 @@ async function reconnectListener(api, waitMs = 30000) {
|
|
|
998
998
|
* @param {{force?: boolean, wait?: number, days?: number}} opts
|
|
999
999
|
*/
|
|
1000
1000
|
async function runTransferSync(activeAcc, opts) {
|
|
1001
|
-
const accountDir =
|
|
1001
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1002
1002
|
|
|
1003
1003
|
let api;
|
|
1004
1004
|
let syncManager;
|
|
@@ -1501,7 +1501,7 @@ function printPlan(plan, win, jsonMode) {
|
|
|
1501
1501
|
* @param {boolean} jsonMode
|
|
1502
1502
|
*/
|
|
1503
1503
|
async function runUnifiedSync(activeAcc, opts, jsonMode) {
|
|
1504
|
-
const accountDir =
|
|
1504
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1505
1505
|
initDb(join(accountDir, "zalo.db"));
|
|
1506
1506
|
|
|
1507
1507
|
let api;
|
|
@@ -1890,7 +1890,7 @@ async function runUnifiedSync(activeAcc, opts, jsonMode) {
|
|
|
1890
1890
|
* @param {{wait?: number}} opts
|
|
1891
1891
|
*/
|
|
1892
1892
|
async function runSocketBackfill(activeAcc, opts) {
|
|
1893
|
-
const accountDir =
|
|
1893
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1894
1894
|
|
|
1895
1895
|
// Build the SyncManager first so we can consult the local sync-freshness
|
|
1896
1896
|
// marker BEFORE touching the network. A redundant run should neither open
|
package/src/core/accounts.js
CHANGED
|
@@ -5,19 +5,22 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
|
-
import { CONFIG_DIR, deleteCredentials } from "./credentials.js";
|
|
8
|
+
import { CONFIG_DIR, getAccountDir, deleteCredentials } from "./credentials.js";
|
|
9
9
|
import { checkLock } from "./lock.js";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
function accountsFile() {
|
|
12
|
+
return join(CONFIG_DIR, "accounts.json");
|
|
13
|
+
}
|
|
12
14
|
|
|
13
15
|
function ensureDir() {
|
|
14
16
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function load() {
|
|
18
|
-
|
|
20
|
+
const file = accountsFile();
|
|
21
|
+
if (!existsSync(file)) return [];
|
|
19
22
|
try {
|
|
20
|
-
return JSON.parse(readFileSync(
|
|
23
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
21
24
|
} catch {
|
|
22
25
|
return [];
|
|
23
26
|
}
|
|
@@ -25,8 +28,28 @@ function load() {
|
|
|
25
28
|
|
|
26
29
|
function save(accounts) {
|
|
27
30
|
ensureDir();
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const file = accountsFile();
|
|
32
|
+
writeFileSync(file, JSON.stringify(accounts, null, 2), "utf-8");
|
|
33
|
+
chmodSync(file, 0o600);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let _pinnedAccountId = null;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pin this process's target account ID without modifying accounts.json on disk.
|
|
40
|
+
* Used when `--account <id>` or `ZALO_ACCOUNT` is passed to a CLI command.
|
|
41
|
+
* @param {string|null|undefined} ownId
|
|
42
|
+
*/
|
|
43
|
+
export function setPinnedAccount(ownId) {
|
|
44
|
+
_pinnedAccountId = ownId ? String(ownId) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return the process-pinned account ID if set, or null.
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
export function getPinnedAccountId() {
|
|
52
|
+
return _pinnedAccountId;
|
|
30
53
|
}
|
|
31
54
|
|
|
32
55
|
/** List all registered accounts. */
|
|
@@ -34,9 +57,13 @@ export function listAccounts() {
|
|
|
34
57
|
return load();
|
|
35
58
|
}
|
|
36
59
|
|
|
37
|
-
/** Get currently active account or null. */
|
|
60
|
+
/** Get currently active (or process-pinned) account or null. */
|
|
38
61
|
export function getActive() {
|
|
39
|
-
|
|
62
|
+
const accounts = load();
|
|
63
|
+
if (_pinnedAccountId !== null) {
|
|
64
|
+
return accounts.find((a) => a.ownId === _pinnedAccountId) || null;
|
|
65
|
+
}
|
|
66
|
+
return accounts.find((a) => a.active) || null;
|
|
40
67
|
}
|
|
41
68
|
|
|
42
69
|
/** Set an account as active (deactivates others). Returns false if not found. */
|
|
@@ -102,7 +129,7 @@ export function addAccount(ownId, name = "", proxy = null) {
|
|
|
102
129
|
* @returns {{wiped: boolean, skippedLocked?: {pid: number}}}
|
|
103
130
|
*/
|
|
104
131
|
export function wipeAccountDir(ownId) {
|
|
105
|
-
const accountDir =
|
|
132
|
+
const accountDir = getAccountDir(ownId);
|
|
106
133
|
if (!existsSync(accountDir)) return { wiped: false };
|
|
107
134
|
|
|
108
135
|
const lockStatus = checkLock(accountDir);
|
package/src/core/credentials.js
CHANGED
|
@@ -1,14 +1,162 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-account credential storage
|
|
2
|
+
* Per-account credential storage and configurable runtime storage paths.
|
|
3
|
+
*
|
|
4
|
+
* Defaults:
|
|
5
|
+
* CONFIG_DIR = ~/.zalo-agent-cli (env: ZALO_CONFIG_DIR, flag: --config-dir)
|
|
6
|
+
* CREDENTIALS_DIR = <CONFIG_DIR>/credentials (env: ZALO_CREDENTIALS_DIR, flag: --credentials-dir)
|
|
7
|
+
* DATA_DIR = <CONFIG_DIR>/accounts (env: ZALO_DATA_DIR, flag: --data-dir)
|
|
8
|
+
* MEDIA_DIR = null (<DATA_DIR>/<id>/media) (env: ZALO_MEDIA_DIR, flag: --media-dir)
|
|
9
|
+
*
|
|
3
10
|
* All credential files use 0600 permissions (owner read/write only).
|
|
4
11
|
*/
|
|
5
12
|
|
|
6
13
|
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from "fs";
|
|
7
|
-
import { join } from "path";
|
|
14
|
+
import { join, resolve } from "path";
|
|
8
15
|
import { homedir } from "os";
|
|
16
|
+
import { parseEnv } from "node:util";
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
const defaultConfigDir = join(homedir(), ".zalo-agent-cli");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load environment variables from `.env` files without overwriting existing `process.env` keys.
|
|
22
|
+
* Search order (first defined value wins):
|
|
23
|
+
* 1. Explicit `envFile` (`--env-file <path>` or `ZALO_ENV_FILE`)
|
|
24
|
+
* 2. `<cwd>/.env` (skipped when `ZALO_SANDBOX_ACTIVE=1` so offline tests stay isolated)
|
|
25
|
+
* 3. `<configDir>/.env` (default `~/.zalo-agent-cli/.env`)
|
|
26
|
+
*
|
|
27
|
+
* @param {object} [opts]
|
|
28
|
+
* @param {string} [opts.envFile]
|
|
29
|
+
* @param {string} [opts.configDir]
|
|
30
|
+
* @returns {string[]} Paths of `.env` files that were loaded
|
|
31
|
+
*/
|
|
32
|
+
export function loadDotEnvFiles(opts = {}) {
|
|
33
|
+
const candidates = [];
|
|
34
|
+
const explicit = opts.envFile || process.env.ZALO_ENV_FILE;
|
|
35
|
+
if (explicit) candidates.push(resolve(explicit));
|
|
36
|
+
if (process.env.ZALO_SANDBOX_ACTIVE !== "1") {
|
|
37
|
+
candidates.push(resolve(process.cwd(), ".env"));
|
|
38
|
+
}
|
|
39
|
+
const cfgRoot = opts.configDir
|
|
40
|
+
? resolve(opts.configDir)
|
|
41
|
+
: process.env.ZALO_CONFIG_DIR
|
|
42
|
+
? resolve(process.env.ZALO_CONFIG_DIR)
|
|
43
|
+
: defaultConfigDir;
|
|
44
|
+
candidates.push(join(cfgRoot, ".env"));
|
|
45
|
+
|
|
46
|
+
const loaded = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const filePath of candidates) {
|
|
49
|
+
if (seen.has(filePath)) continue;
|
|
50
|
+
seen.add(filePath);
|
|
51
|
+
if (!existsSync(filePath)) continue;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = parseEnv(readFileSync(filePath, "utf-8"));
|
|
54
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
55
|
+
if (process.env[k] === undefined) {
|
|
56
|
+
process.env[k] = v;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
loaded.push(filePath);
|
|
60
|
+
} catch {
|
|
61
|
+
/* ignore unreadable .env file */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return loaded;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export let CONFIG_DIR = process.env.ZALO_CONFIG_DIR ? resolve(process.env.ZALO_CONFIG_DIR) : defaultConfigDir;
|
|
68
|
+
export let CREDENTIALS_DIR = process.env.ZALO_CREDENTIALS_DIR
|
|
69
|
+
? resolve(process.env.ZALO_CREDENTIALS_DIR)
|
|
70
|
+
: join(CONFIG_DIR, "credentials");
|
|
71
|
+
export let DATA_DIR =
|
|
72
|
+
process.env.ZALO_DATA_DIR || process.env.ZALO_DB_DIR
|
|
73
|
+
? resolve(process.env.ZALO_DATA_DIR || process.env.ZALO_DB_DIR)
|
|
74
|
+
: join(CONFIG_DIR, "accounts");
|
|
75
|
+
export let MEDIA_DIR = process.env.ZALO_MEDIA_DIR ? resolve(process.env.ZALO_MEDIA_DIR) : null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Override storage directories from CLI options, `.env` files, or runtime config.
|
|
79
|
+
* Updates live ESM bindings (CONFIG_DIR, CREDENTIALS_DIR, DATA_DIR, MEDIA_DIR)
|
|
80
|
+
* and synchronizes process.env so child helpers see the same paths.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} [opts]
|
|
83
|
+
* @param {string} [opts.envFile] - Optional explicit `.env` file path (`--env-file` / `ZALO_ENV_FILE`)
|
|
84
|
+
* @param {boolean} [opts.loadEnv=true] - Whether to load `.env` files before resolving paths
|
|
85
|
+
* @param {string} [opts.configDir] - Root config directory (accounts.json, mcp-config.json, qr.png)
|
|
86
|
+
* @param {string} [opts.credentialsDir] - Credentials directory (cred_<ownId>.json)
|
|
87
|
+
* @param {string} [opts.dataDir] - Per-account runtime/database root (<dataDir>/<ownId>/zalo.db)
|
|
88
|
+
* @param {string} [opts.mediaDir] - Downloaded media root (<mediaDir>/<threadId>/)
|
|
89
|
+
* @param {string} [opts.oaDir] - Official Account credentials directory (oa-credentials.json)
|
|
90
|
+
*/
|
|
91
|
+
export function configureStoragePaths(opts = {}) {
|
|
92
|
+
if (opts.loadEnv !== false) {
|
|
93
|
+
loadDotEnvFiles({ envFile: opts.envFile, configDir: opts.configDir });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nextConfig = opts.configDir || process.env.ZALO_CONFIG_DIR;
|
|
97
|
+
if (nextConfig) {
|
|
98
|
+
CONFIG_DIR = resolve(nextConfig);
|
|
99
|
+
if (opts.configDir) process.env.ZALO_CONFIG_DIR = CONFIG_DIR;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nextCreds = opts.credentialsDir || process.env.ZALO_CREDENTIALS_DIR;
|
|
103
|
+
if (nextCreds) {
|
|
104
|
+
CREDENTIALS_DIR = resolve(nextCreds);
|
|
105
|
+
if (opts.credentialsDir) process.env.ZALO_CREDENTIALS_DIR = CREDENTIALS_DIR;
|
|
106
|
+
} else if (nextConfig) {
|
|
107
|
+
CREDENTIALS_DIR = join(CONFIG_DIR, "credentials");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const nextData = opts.dataDir || process.env.ZALO_DATA_DIR || process.env.ZALO_DB_DIR;
|
|
111
|
+
if (nextData) {
|
|
112
|
+
DATA_DIR = resolve(nextData);
|
|
113
|
+
if (opts.dataDir) process.env.ZALO_DATA_DIR = DATA_DIR;
|
|
114
|
+
} else if (nextConfig) {
|
|
115
|
+
DATA_DIR = join(CONFIG_DIR, "accounts");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (opts.mediaDir !== undefined) {
|
|
119
|
+
MEDIA_DIR = opts.mediaDir ? resolve(opts.mediaDir) : null;
|
|
120
|
+
if (MEDIA_DIR) process.env.ZALO_MEDIA_DIR = MEDIA_DIR;
|
|
121
|
+
else delete process.env.ZALO_MEDIA_DIR;
|
|
122
|
+
} else if (process.env.ZALO_MEDIA_DIR) {
|
|
123
|
+
MEDIA_DIR = resolve(process.env.ZALO_MEDIA_DIR);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (opts.oaDir) {
|
|
127
|
+
process.env.ZALO_OA_DIR = resolve(opts.oaDir);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get the per-account runtime/database directory (<DATA_DIR>/<ownId>).
|
|
133
|
+
* @param {string} ownId
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function getAccountDir(ownId) {
|
|
137
|
+
return join(DATA_DIR, String(ownId));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get the per-account SQLite database path (<DATA_DIR>/<ownId>/zalo.db).
|
|
142
|
+
* @param {string} ownId
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
export function getAccountDbPath(ownId) {
|
|
146
|
+
return join(getAccountDir(ownId), "zalo.db");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the media root directory for an account.
|
|
151
|
+
* Precedence: explicit override -> MEDIA_DIR (--media-dir / ZALO_MEDIA_DIR) -> <accountDir>/media.
|
|
152
|
+
* @param {string} accountDir
|
|
153
|
+
* @param {string|null} [overrideRoot]
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function resolveMediaRoot(accountDir, overrideRoot = null) {
|
|
157
|
+
const custom = overrideRoot || MEDIA_DIR;
|
|
158
|
+
return custom ? resolve(custom) : resolve(accountDir, "media");
|
|
159
|
+
}
|
|
12
160
|
|
|
13
161
|
/** Ensure config directories exist. */
|
|
14
162
|
function ensureDirs() {
|
package/src/core/logout.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { createRequire } from "node:module";
|
|
22
22
|
import { dirname, join } from "node:path";
|
|
23
23
|
import { existsSync, rmSync } from "node:fs";
|
|
24
|
-
import {
|
|
24
|
+
import { getAccountDir, deleteCredentials } from "./credentials.js";
|
|
25
25
|
import { checkLock } from "./lock.js";
|
|
26
26
|
|
|
27
27
|
const require = createRequire(import.meta.url);
|
|
@@ -164,7 +164,7 @@ const EMPTY_REPLY = /Failed to parse response data/;
|
|
|
164
164
|
* @returns {{dbDeleted: boolean, mediaDeleted: boolean}}
|
|
165
165
|
*/
|
|
166
166
|
export function deleteLocalHistory(ownId) {
|
|
167
|
-
const accountDir =
|
|
167
|
+
const accountDir = getAccountDir(ownId);
|
|
168
168
|
let dbDeleted = false;
|
|
169
169
|
let mediaDeleted = false;
|
|
170
170
|
if (existsSync(accountDir)) {
|
|
@@ -194,7 +194,7 @@ export function deleteLocalHistory(ownId) {
|
|
|
194
194
|
* @returns {number|null}
|
|
195
195
|
*/
|
|
196
196
|
export function lockHolder(ownId) {
|
|
197
|
-
const lock = checkLock(
|
|
197
|
+
const lock = checkLock(getAccountDir(ownId));
|
|
198
198
|
return lock.locked ? lock.pid : null;
|
|
199
199
|
}
|
|
200
200
|
|
package/src/core/oa-client.js
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zalo Official Account API v3.0 client.
|
|
3
|
-
* Wraps OA REST endpoints with
|
|
3
|
+
* Wraps OA REST endpoints with native fetch. No dependency on zca-js.
|
|
4
4
|
* Credentials stored in ~/.zalo-agent/oa-credentials.json.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import nodefetch from "node-fetch";
|
|
11
10
|
|
|
12
11
|
const V3_BASE = "https://openapi.zalo.me/v3.0/oa";
|
|
13
12
|
const V2_BASE = "https://openapi.zalo.me/v2.0/oa";
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the Official Account data directory (defaults to ~/.zalo-agent, or ZALO_OA_DIR).
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function getOADataDir() {
|
|
19
|
+
return process.env.ZALO_OA_DIR ? resolve(process.env.ZALO_OA_DIR) : join(homedir(), ".zalo-agent");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getOACredsFile() {
|
|
23
|
+
return join(getOADataDir(), "oa-credentials.json");
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
/** Ensure data directory exists. */
|
|
18
27
|
function ensureDir() {
|
|
19
|
-
|
|
28
|
+
const dir = getOADataDir();
|
|
29
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
const OAUTH_URL = "https://oauth.zaloapp.com/v4/oa/permission";
|
|
@@ -25,12 +35,13 @@ const TOKEN_URL = "https://oauth.zaloapp.com/v4/oa/access_token";
|
|
|
25
35
|
/** Save full OA credentials to disk with restricted file permissions. */
|
|
26
36
|
export function saveOACreds(data, oaId = "default") {
|
|
27
37
|
ensureDir();
|
|
38
|
+
const credsFile = getOACredsFile();
|
|
28
39
|
let creds = {};
|
|
29
|
-
if (fs.existsSync(
|
|
30
|
-
creds = JSON.parse(fs.readFileSync(
|
|
40
|
+
if (fs.existsSync(credsFile)) {
|
|
41
|
+
creds = JSON.parse(fs.readFileSync(credsFile, "utf8"));
|
|
31
42
|
}
|
|
32
43
|
creds[oaId] = { ...creds[oaId], ...data, updatedAt: new Date().toISOString() };
|
|
33
|
-
fs.writeFileSync(
|
|
44
|
+
fs.writeFileSync(credsFile, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
/** Save OA access token to disk (backward compat). */
|
|
@@ -40,8 +51,9 @@ export function saveOAToken(accessToken, oaId = "default") {
|
|
|
40
51
|
|
|
41
52
|
/** Load full OA credentials from disk. */
|
|
42
53
|
export function loadOACreds(oaId = "default") {
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
const credsFile = getOACredsFile();
|
|
55
|
+
if (!fs.existsSync(credsFile)) return null;
|
|
56
|
+
const creds = JSON.parse(fs.readFileSync(credsFile, "utf8"));
|
|
45
57
|
return creds[oaId] || null;
|
|
46
58
|
}
|
|
47
59
|
|
|
@@ -70,7 +82,7 @@ export function getOAuthUrl(appId, redirectUri = "http://localhost:3456/callback
|
|
|
70
82
|
|
|
71
83
|
/** Exchange authorization code for access + refresh tokens. */
|
|
72
84
|
export async function exchangeCode(code, appId, secretKey, redirectUri = "http://localhost:3456/callback") {
|
|
73
|
-
const res = await
|
|
85
|
+
const res = await fetch(TOKEN_URL, {
|
|
74
86
|
method: "POST",
|
|
75
87
|
headers: { "Content-Type": "application/x-www-form-urlencoded", secret_key: secretKey },
|
|
76
88
|
body: new URLSearchParams({
|
|
@@ -85,7 +97,7 @@ export async function exchangeCode(code, appId, secretKey, redirectUri = "http:/
|
|
|
85
97
|
|
|
86
98
|
/** Refresh access token using refresh token. */
|
|
87
99
|
export async function refreshAccessToken(refreshToken, appId, secretKey) {
|
|
88
|
-
const res = await
|
|
100
|
+
const res = await fetch(TOKEN_URL, {
|
|
89
101
|
method: "POST",
|
|
90
102
|
headers: { "Content-Type": "application/x-www-form-urlencoded", secret_key: secretKey },
|
|
91
103
|
body: new URLSearchParams({
|
|
@@ -106,7 +118,7 @@ async function oaFetch(url, { method = "GET", body, token, isFormData = false }
|
|
|
106
118
|
if (body && !isFormData) opts.body = JSON.stringify(body);
|
|
107
119
|
if (body && isFormData) opts.body = body;
|
|
108
120
|
|
|
109
|
-
const res = await
|
|
121
|
+
const res = await fetch(url, opts);
|
|
110
122
|
const data = await res.json();
|
|
111
123
|
if (data.error && data.error !== 0) {
|
|
112
124
|
throw new Error(`OA API error ${data.error}: ${data.message || JSON.stringify(data)}`);
|
|
@@ -281,7 +293,7 @@ export async function removeFollowerFromTag(userId, tagName, oaId = "default") {
|
|
|
281
293
|
/** Upload image to OA (returns attachment_id). */
|
|
282
294
|
export async function uploadImage(filePath, oaId = "default") {
|
|
283
295
|
const token = getToken(oaId);
|
|
284
|
-
// Use native
|
|
296
|
+
// Use native Blob and FormData
|
|
285
297
|
const fileData = fs.readFileSync(filePath);
|
|
286
298
|
const blob = new (await import("node:buffer")).Blob([fileData]);
|
|
287
299
|
const form = new globalThis.FormData();
|
|
@@ -419,12 +419,15 @@ export class SyncV2 {
|
|
|
419
419
|
constructor(api, accountName) {
|
|
420
420
|
this.api = api;
|
|
421
421
|
this.accountName = accountName;
|
|
422
|
-
|
|
423
|
-
process.env.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
422
|
+
const dataRoot =
|
|
423
|
+
process.env.ZALO_DATA_DIR ||
|
|
424
|
+
process.env.ZALO_DB_DIR ||
|
|
425
|
+
resolve(
|
|
426
|
+
process.env.ZALO_CONFIG_DIR ||
|
|
427
|
+
resolve(process.env.USERPROFILE || process.env.HOME || "", ".zalo-agent-cli"),
|
|
428
|
+
"accounts",
|
|
429
|
+
);
|
|
430
|
+
this.accountDir = resolve(dataRoot, accountName);
|
|
428
431
|
}
|
|
429
432
|
|
|
430
433
|
/** Build opaque-globalId -> {id, name, type} from the friend + group lists. */
|
|
@@ -482,9 +482,10 @@ export async function downloadSyncedMedia(opts = {}) {
|
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
const
|
|
485
|
+
// Explicit mediaRootOpt or global ZALO_MEDIA_DIR (--media-dir) overrides the
|
|
486
|
+
// per-account default (<accountDir>/media).
|
|
487
|
+
const configuredRoot = mediaRootOpt || process.env.ZALO_MEDIA_DIR;
|
|
488
|
+
const mediaRoot = configuredRoot ? resolve(configuredRoot) : resolve(accountDir, "media");
|
|
488
489
|
|
|
489
490
|
// Folders are ids, so without this the media tree is unreadable by a human.
|
|
490
491
|
// Written before any download, so an interrupted run still leaves an index.
|
package/src/core/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { getAccountDir } from "./credentials.js";
|
|
5
5
|
import {
|
|
6
6
|
initDb,
|
|
7
7
|
insertMessage,
|
|
@@ -19,7 +19,7 @@ const THREAD_TYPE_USER = 0;
|
|
|
19
19
|
const THREAD_TYPE_GROUP = 1;
|
|
20
20
|
|
|
21
21
|
function resolveAccountDir(accountName) {
|
|
22
|
-
return resolve(
|
|
22
|
+
return resolve(getAccountDir(accountName));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Zalo Web's own client only ever seems to backfill about this far on a
|
package/src/index.js
CHANGED
|
@@ -30,7 +30,9 @@ import { registerOACommands } from "./commands/oa.js";
|
|
|
30
30
|
import { registerMCPCommands } from "./commands/mcp.js";
|
|
31
31
|
import { registerSyncCommands } from "./commands/sync.js";
|
|
32
32
|
import { autoLogin } from "./core/zalo-client.js";
|
|
33
|
-
import { getActive } from "./core/accounts.js";
|
|
33
|
+
import { getActive, setPinnedAccount } from "./core/accounts.js";
|
|
34
|
+
import { parseAccountId, selectDaemonAccount } from "./core/daemon-account.js";
|
|
35
|
+
import { configureStoragePaths } from "./core/credentials.js";
|
|
34
36
|
import { logoutNeedsSession } from "./core/logout.js";
|
|
35
37
|
import { checkForUpdates, selfUpdate } from "./utils/update-check.js";
|
|
36
38
|
import { applyCachedThreadType } from "./utils/thread-type.js";
|
|
@@ -46,10 +48,36 @@ program
|
|
|
46
48
|
.description("CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments")
|
|
47
49
|
.version(pkg.version)
|
|
48
50
|
.option("--json", "Output results as JSON (machine-readable)")
|
|
51
|
+
.option(
|
|
52
|
+
"--account <id>",
|
|
53
|
+
"Pin this command to an exact registered account ID without changing the active account (env: ZALO_ACCOUNT)",
|
|
54
|
+
parseAccountId,
|
|
55
|
+
)
|
|
56
|
+
.option("--env-file <path>", "Load environment variables from a .env file (env: ZALO_ENV_FILE)")
|
|
57
|
+
.option("--config-dir <dir>", "Config directory (default: ~/.zalo-agent-cli, env: ZALO_CONFIG_DIR)")
|
|
58
|
+
.option(
|
|
59
|
+
"--credentials-dir <dir>",
|
|
60
|
+
"Credentials directory (default: <config-dir>/credentials, env: ZALO_CREDENTIALS_DIR)",
|
|
61
|
+
)
|
|
62
|
+
.option(
|
|
63
|
+
"--data-dir <dir>",
|
|
64
|
+
"Per-account database and runtime directory (default: <config-dir>/accounts, env: ZALO_DATA_DIR)",
|
|
65
|
+
)
|
|
66
|
+
.option("--media-dir <dir>", "Downloaded media directory (default: <data-dir>/<ownId>/media, env: ZALO_MEDIA_DIR)")
|
|
67
|
+
.option("--oa-dir <dir>", "Official Account credentials directory (default: ~/.zalo-agent, env: ZALO_OA_DIR)")
|
|
49
68
|
.hook("preAction", async (thisCommand, actionCommand) => {
|
|
69
|
+
const globalOpts = program.opts();
|
|
70
|
+
configureStoragePaths({
|
|
71
|
+
envFile: globalOpts.envFile,
|
|
72
|
+
configDir: globalOpts.configDir,
|
|
73
|
+
credentialsDir: globalOpts.credentialsDir,
|
|
74
|
+
dataDir: globalOpts.dataDir,
|
|
75
|
+
mediaDir: globalOpts.mediaDir,
|
|
76
|
+
oaDir: globalOpts.oaDir,
|
|
77
|
+
});
|
|
50
78
|
const cmdName = thisCommand.args?.[0] || thisCommand.name();
|
|
51
79
|
// Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
|
|
52
|
-
if (
|
|
80
|
+
if (globalOpts.json || cmdName === "mcp") {
|
|
53
81
|
// Suppress zca-js stdout logs: JSON mode needs clean output, MCP uses stdout as transport
|
|
54
82
|
process.env.ZALO_JSON_MODE = "1";
|
|
55
83
|
} else if (cmdName !== "oa") {
|
|
@@ -57,6 +85,23 @@ program
|
|
|
57
85
|
warning(DISCLAIMER);
|
|
58
86
|
console.log();
|
|
59
87
|
}
|
|
88
|
+
// Pin the target account for this invocation when --account or ZALO_ACCOUNT is set,
|
|
89
|
+
// without changing the global active entry in accounts.json. Daemons (`listen`, `mcp`)
|
|
90
|
+
// perform their own selection and lock acquisition inside their command actions.
|
|
91
|
+
const envAccount = process.env.ZALO_ACCOUNT?.trim() || undefined;
|
|
92
|
+
const requestedAccount = actionCommand.opts()?.account ?? globalOpts.account ?? envAccount;
|
|
93
|
+
if (
|
|
94
|
+
requestedAccount !== undefined &&
|
|
95
|
+
!["mcp", "listen", "oa", "update", "help", "version", "login"].includes(cmdName)
|
|
96
|
+
) {
|
|
97
|
+
try {
|
|
98
|
+
const pinned = selectDaemonAccount(requestedAccount);
|
|
99
|
+
setPinnedAccount(pinned.ownId);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
error(e.message);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
60
105
|
// Auto-login before any command that needs it (skip for login/account/oa
|
|
61
106
|
// commands, and for a logout that will not call Zalo at all).
|
|
62
107
|
// Daemons authenticate their pinned identity after acquiring its lock.
|
package/src/mcp/mcp-config.js
CHANGED
|
@@ -6,7 +6,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
import { CONFIG_DIR } from "../core/credentials.js";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
function mcpConfigFile() {
|
|
10
|
+
return join(CONFIG_DIR, "mcp-config.json");
|
|
11
|
+
}
|
|
10
12
|
|
|
11
13
|
/** Default MCP config — sensible defaults for local development */
|
|
12
14
|
export function getDefaultConfig() {
|
|
@@ -47,7 +49,7 @@ export function getDefaultConfig() {
|
|
|
47
49
|
export function loadMCPConfig(configPath) {
|
|
48
50
|
const defaults = getDefaultConfig();
|
|
49
51
|
try {
|
|
50
|
-
const raw = readFileSync(configPath ||
|
|
52
|
+
const raw = readFileSync(configPath || mcpConfigFile(), "utf-8");
|
|
51
53
|
const saved = JSON.parse(raw);
|
|
52
54
|
// Shallow merge: saved values override defaults
|
|
53
55
|
return {
|
|
@@ -77,7 +79,7 @@ export function loadMCPConfig(configPath) {
|
|
|
77
79
|
* @returns {{config: object, problem: string|null}}
|
|
78
80
|
*/
|
|
79
81
|
export function readMCPConfig(configPath) {
|
|
80
|
-
const file = configPath ||
|
|
82
|
+
const file = configPath || mcpConfigFile();
|
|
81
83
|
let raw;
|
|
82
84
|
try {
|
|
83
85
|
raw = readFileSync(file, "utf-8");
|
|
@@ -99,7 +101,7 @@ export function readMCPConfig(configPath) {
|
|
|
99
101
|
*/
|
|
100
102
|
export function saveMCPConfig(config) {
|
|
101
103
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
102
|
-
writeFileSync(
|
|
104
|
+
writeFileSync(mcpConfigFile(), JSON.stringify(config, null, 2), "utf-8");
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/**
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -824,7 +824,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
824
824
|
threadId: String(row?.threadId || buffered?.threadId || threadId || ""),
|
|
825
825
|
limit: 50,
|
|
826
826
|
concurrency: 2,
|
|
827
|
-
mediaRoot: mediaConfig.downloadDir || undefined,
|
|
827
|
+
mediaRoot: process.env.ZALO_MEDIA_DIR || mediaConfig.downloadDir || undefined,
|
|
828
828
|
});
|
|
829
829
|
localPath = getMessageById(messageId)?.localPath || null;
|
|
830
830
|
if (!localPath) {
|
package/src/utils/qr-display.js
CHANGED
|
@@ -19,8 +19,6 @@ import { platform } from "os";
|
|
|
19
19
|
import { CONFIG_DIR } from "../core/credentials.js";
|
|
20
20
|
import { info } from "./output.js";
|
|
21
21
|
|
|
22
|
-
const QR_PATH = resolve(CONFIG_DIR, "qr.png");
|
|
23
|
-
|
|
24
22
|
/** Get platform-specific command to open a file. */
|
|
25
23
|
function getOpenCommand() {
|
|
26
24
|
switch (platform()) {
|
|
@@ -42,18 +40,19 @@ function getOpenCommand() {
|
|
|
42
40
|
export function displayQR(event) {
|
|
43
41
|
const imageB64 = event.data?.image || "";
|
|
44
42
|
const jsonMode = process.env.ZALO_JSON_MODE === "1";
|
|
43
|
+
const qrPath = getQRPath();
|
|
45
44
|
|
|
46
45
|
// Always save PNG to config dir (needed by HTTP server and agents)
|
|
47
46
|
if (imageB64) {
|
|
48
47
|
try {
|
|
49
48
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
50
|
-
writeFileSync(
|
|
49
|
+
writeFileSync(qrPath, Buffer.from(imageB64, "base64"));
|
|
51
50
|
} catch {}
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
// Also fire-and-forget the zca-js built-in save
|
|
55
54
|
if (event.actions?.saveToFile) {
|
|
56
|
-
event.actions.saveToFile(
|
|
55
|
+
event.actions.saveToFile(qrPath).catch(() => {});
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
// JSON mode: structured output for AI agents — no terminal escapes, no noise
|
|
@@ -63,7 +62,7 @@ export function displayQR(event) {
|
|
|
63
62
|
JSON.stringify({
|
|
64
63
|
event: "qr",
|
|
65
64
|
image: imageB64,
|
|
66
|
-
file:
|
|
65
|
+
file: qrPath,
|
|
67
66
|
dataUrl: `data:image/png;base64,${imageB64}`,
|
|
68
67
|
}),
|
|
69
68
|
);
|
|
@@ -78,8 +77,8 @@ export function displayQR(event) {
|
|
|
78
77
|
process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
|
|
79
78
|
|
|
80
79
|
const openCmd = getOpenCommand();
|
|
81
|
-
info(`QR image saved: ${
|
|
82
|
-
info(`To open: ${openCmd} "${
|
|
80
|
+
info(`QR image saved: ${qrPath}`);
|
|
81
|
+
info(`To open: ${openCmd} "${qrPath}"`);
|
|
83
82
|
info("Copy this URL and paste in any browser to view QR:");
|
|
84
83
|
console.log(`data:image/png;base64,${imageB64}`);
|
|
85
84
|
}
|
|
@@ -87,5 +86,5 @@ export function displayQR(event) {
|
|
|
87
86
|
|
|
88
87
|
/** Get the QR image path. */
|
|
89
88
|
export function getQRPath() {
|
|
90
|
-
return
|
|
89
|
+
return resolve(CONFIG_DIR, "qr.png");
|
|
91
90
|
}
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import http from "http";
|
|
8
8
|
import { readFileSync, existsSync, statSync } from "fs";
|
|
9
|
-
import nodefetch from "node-fetch";
|
|
10
9
|
import { info } from "./output.js";
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -180,7 +179,7 @@ setInterval(async()=>{
|
|
|
180
179
|
|
|
181
180
|
// Auto-detect public IP for VPS users
|
|
182
181
|
try {
|
|
183
|
-
const res = await
|
|
182
|
+
const res = await fetch("https://api.ipify.org", { signal: AbortSignal.timeout(3000) });
|
|
184
183
|
publicIp = (await res.text()).trim() || null;
|
|
185
184
|
} catch {}
|
|
186
185
|
|
package/src/utils/thread-type.js
CHANGED
|
@@ -20,9 +20,8 @@
|
|
|
20
20
|
* `upsertThread` refuses to downgrade a group regardless, so this is the
|
|
21
21
|
* ergonomic half of the fix, not the safety half.
|
|
22
22
|
*/
|
|
23
|
-
import { join } from "node:path";
|
|
24
23
|
import { getActive } from "../core/accounts.js";
|
|
25
|
-
import {
|
|
24
|
+
import { getAccountDbPath } from "../core/credentials.js";
|
|
26
25
|
import { initDb, getThreadType } from "../core/db.js";
|
|
27
26
|
|
|
28
27
|
/**
|
|
@@ -34,7 +33,7 @@ import { initDb, getThreadType } from "../core/db.js";
|
|
|
34
33
|
function lookupCachedType(threadId) {
|
|
35
34
|
const acc = getActive();
|
|
36
35
|
if (!acc) return null;
|
|
37
|
-
initDb(
|
|
36
|
+
initDb(getAccountDbPath(acc.ownId));
|
|
38
37
|
return getThreadType(threadId);
|
|
39
38
|
}
|
|
40
39
|
|