@ardennguyen/zalo-agent-cli 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,30 @@
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 & Protocol Options (Optional)
25
+ # ----------------------------------------------------------------------------
26
+ # Set to any value (e.g. 1) to disable the background npm update check:
27
+ # ZALO_AGENT_NO_UPDATE_CHECK=1
28
+
29
+ # Protocol version announced to Zalo (default: 691; set 685 to roll back):
30
+ # ZALO_API_VERSION=691
package/README.md CHANGED
@@ -138,7 +138,7 @@ Xem thêm: [Đa tài khoản & Proxy](https://github.com/ardennguyen/zalo-agent-
138
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
139
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
140
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`
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`)
141
+ - Tự động tải media (ảnh/audio/video) — một downloader duy nhất cho `listen`, `msg history`, `mcp start` và các lệnh sync, lưu vào `~/.zalo-agent-cli/accounts/<id>/media/<threadId>/` (hoặc tách riêng credentials, database `zalo.db`, media và OA sang các ổ đĩa/mount khác nhau qua `--credentials-dir`, `--data-dir`, `--media-dir`, `--oa-dir` / biến môi trường `ZALO_*_DIR` trong `~/.zalo-agent-cli/.env`; nếu dùng kèm `zalo-mcp`, chỉ nên giữ cấu hình PORT trong `zalo-mcp/.env` và đặt toàn bộ đường dẫn lưu trữ vào `<CONFIG_DIR>/.env`)
142
142
  - Thẻ chuyển khoản (55+ ngân hàng VN) & QR VietQR
143
143
  - Lắng nghe real-time với webhook & lưu JSONL local
144
144
  - `logout` hủy phiên **phía server** thật sự; `logout --purge` / `account remove` xóa sạch dữ liệu cục bộ
@@ -266,7 +266,7 @@ See also: [Multi-Account & Proxy](https://github.com/ardennguyen/zalo-agent-cli/
266
266
  - **Zalo Official Account (OA) API v3.0** — OAuth login, messaging, followers, tags, articles, store, webhook listener, multi-OA
267
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
268
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
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`)
269
+ - Automatic media download — one downloader shared by `listen`, `msg history`, `mcp start` and the sync commands, saving to `~/.zalo-agent-cli/accounts/<id>/media/<threadId>/` (or separate credentials, `zalo.db`, media, and OA credentials onto independent volumes/mounts via `--credentials-dir`, `--data-dir`, `--media-dir`, `--oa-dir` / `ZALO_*_DIR` in `~/.zalo-agent-cli/.env`; when using `zalo-mcp`, keep `zalo-mcp/.env` for PORT settings only and place all storage/engine settings in `<CONFIG_DIR>/.env`)
270
270
  - Bank cards (55+ VN banks) and VietQR payment images
271
271
  - Real-time listener with webhook forwarding and local JSONL archival
272
272
  - `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.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,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.0.1": true
80
+ "@ardennguyen/zalo-agent-cli@2.1.1": true
81
81
  }
82
82
  }
@@ -3,12 +3,11 @@
3
3
  * mute, unmute, read, unread, delete.
4
4
  */
5
5
 
6
- import { join } from "path";
7
6
  import { MuteAction } from "zca-js";
8
7
  import { getApi } from "../core/zalo-client.js";
9
8
  import { success, error, info, output, warning } from "../utils/output.js";
10
9
  import { getActive } from "../core/accounts.js";
11
- import { CONFIG_DIR } from "../core/credentials.js";
10
+ import { getAccountDbPath } from "../core/credentials.js";
12
11
  import { initDb, getMessages, getReadStates, markThreadGone, getOrphanThreads, forgetThread } from "../core/db.js";
13
12
  import { recentConversations } from "../core/recent-conversations.js";
14
13
  import { describeReadState, readStateLabel } from "../core/read-state.js";
@@ -41,7 +40,7 @@ async function newestMessageAnchor(api, threadId, type) {
41
40
  try {
42
41
  const activeAcc = getActive();
43
42
  if (activeAcc) {
44
- initDb(join(CONFIG_DIR, "accounts", activeAcc.ownId, "zalo.db"));
43
+ initDb(getAccountDbPath(activeAcc.ownId));
45
44
  cacheOpen = true;
46
45
  }
47
46
  } catch {
@@ -231,8 +230,7 @@ export function registerConvCommands(program, deps = {}) {
231
230
  }
232
231
 
233
232
  try {
234
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
235
- initDb(join(accountDir, "zalo.db"));
233
+ initDb(getAccountDbPath(activeAcc.ownId));
236
234
  // `-n` is documented as "max results per type", and the live
237
235
  // path below honors that (up to `limit` friends AND up to
238
236
  // `limit` groups). The cache path used to apply `limit`
@@ -524,7 +522,7 @@ export function registerConvCommands(program, deps = {}) {
524
522
 
525
523
  let result;
526
524
  try {
527
- initDb(join(CONFIG_DIR, "accounts", activeAcc.ownId, "zalo.db"));
525
+ initDb(getAccountDbPath(activeAcc.ownId));
528
526
  const rows = getMessages(String(threadId), SEEN_ANCHOR_SCAN_DEPTH);
529
527
  result = await markConversationRead(zaloApi(), { threadId, type, ownId: activeAcc.ownId, rows });
530
528
  } catch (e) {
@@ -749,8 +747,7 @@ export function registerConvCommands(program, deps = {}) {
749
747
  error("Give a threadId, or --orphans to forget every conversation the account no longer has.");
750
748
  process.exit(1);
751
749
  }
752
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
753
- initDb(join(accountDir, "zalo.db"));
750
+ initDb(getAccountDbPath(activeAcc.ownId));
754
751
 
755
752
  const targets = opts.orphans
756
753
  ? getOrphanThreads().map((t) => ({ threadId: String(t.threadId), name: t.name, ...t }))
@@ -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 { CONFIG_DIR } from "../core/credentials.js";
11
+ import { getAccountDir } from "../core/credentials.js";
12
12
  import { acquireLock, releaseLock } from "../core/lock.js";
13
13
  import { createStageLock, startDaemonChannel } from "../core/daemon-channel.js";
14
14
  import { createSyncRunners } from "../core/daemon-sync.js";
@@ -89,7 +89,7 @@ export function registerListenCommand(program) {
89
89
  error(e.message);
90
90
  process.exit(1);
91
91
  }
92
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
92
+ const accountDir = getAccountDir(activeAcc.ownId);
93
93
  if (!acquireLock(accountDir)) {
94
94
  error(`Another listen daemon is already running for account ${activeAcc.ownId}.`);
95
95
  process.exit(1);
@@ -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 { CONFIG_DIR } from "../core/credentials.js";
12
+ import { getAccountDir, MEDIA_DIR } from "../core/credentials.js";
13
13
  import { acquireLock, releaseLock } from "../core/lock.js";
14
14
  import { createStageLock, startDaemonChannel } from "../core/daemon-channel.js";
15
15
  import { createSyncRunners } from "../core/daemon-sync.js";
@@ -148,7 +148,7 @@ export function registerMCPCommands(program) {
148
148
  console.error("[mcp] Account selection failed:", e.message);
149
149
  process.exit(1);
150
150
  }
151
- const accountDir = join(CONFIG_DIR, "accounts", activeAcc.ownId);
151
+ const accountDir = getAccountDir(activeAcc.ownId);
152
152
 
153
153
  // This server now opens the account's WebSocket *and* writes to
154
154
  // zalo.db, which is exactly what the `listen` daemon does. Zalo
@@ -385,7 +385,7 @@ export function registerMCPCommands(program) {
385
385
  threadId,
386
386
  limit: 5,
387
387
  concurrency: 2,
388
- mediaRoot: config.media?.downloadDir || undefined,
388
+ mediaRoot: MEDIA_DIR || config.media?.downloadDir || undefined,
389
389
  }).catch((e) => console.error(`[mcp] media download failed: ${e.message}`));
390
390
  }
391
391
 
@@ -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 { CONFIG_DIR } from "../core/credentials.js";
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(join(CONFIG_DIR, "accounts", acc.ownId, "zalo.db"));
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(join(CONFIG_DIR, "accounts", acc.ownId), {
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(join(CONFIG_DIR, "accounts", activeAcc.ownId, "zalo.db"));
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
1628
+ const daemonDir = getAccountDir(activeAcc.ownId);
1629
1629
  const daemon = getDaemonChannel(daemonDir);
1630
1630
  let fetched = null;
1631
1631
  let handledByDaemon = false;
@@ -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 { CONFIG_DIR } from "../core/credentials.js";
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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 = join(CONFIG_DIR, "accounts", activeAcc.ownId);
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
@@ -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
- const ACCOUNTS_FILE = `${CONFIG_DIR}/accounts.json`;
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
- if (!existsSync(ACCOUNTS_FILE)) return [];
20
+ const file = accountsFile();
21
+ if (!existsSync(file)) return [];
19
22
  try {
20
- return JSON.parse(readFileSync(ACCOUNTS_FILE, "utf-8"));
23
+ return JSON.parse(readFileSync(file, "utf-8"));
21
24
  } catch {
22
25
  return [];
23
26
  }
@@ -25,8 +28,9 @@ function load() {
25
28
 
26
29
  function save(accounts) {
27
30
  ensureDir();
28
- writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), "utf-8");
29
- chmodSync(ACCOUNTS_FILE, 0o600);
31
+ const file = accountsFile();
32
+ writeFileSync(file, JSON.stringify(accounts, null, 2), "utf-8");
33
+ chmodSync(file, 0o600);
30
34
  }
31
35
 
32
36
  /** List all registered accounts. */
@@ -102,7 +106,7 @@ export function addAccount(ownId, name = "", proxy = null) {
102
106
  * @returns {{wiped: boolean, skippedLocked?: {pid: number}}}
103
107
  */
104
108
  export function wipeAccountDir(ownId) {
105
- const accountDir = join(CONFIG_DIR, "accounts", ownId);
109
+ const accountDir = getAccountDir(ownId);
106
110
  if (!existsSync(accountDir)) return { wiped: false };
107
111
 
108
112
  const lockStatus = checkLock(accountDir);
@@ -1,14 +1,162 @@
1
1
  /**
2
- * Per-account credential storage at ~/.zalo-agent-cli/credentials/
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
- export const CONFIG_DIR = join(homedir(), ".zalo-agent-cli");
11
- export const CREDENTIALS_DIR = join(CONFIG_DIR, "credentials");
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() {
@@ -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 { CONFIG_DIR, deleteCredentials } from "./credentials.js";
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 = join(CONFIG_DIR, "accounts", ownId);
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(join(CONFIG_DIR, "accounts", ownId));
197
+ const lock = checkLock(getAccountDir(ownId));
198
198
  return lock.locked ? lock.pid : null;
199
199
  }
200
200
 
@@ -1,22 +1,32 @@
1
1
  /**
2
2
  * Zalo Official Account API v3.0 client.
3
- * Wraps OA REST endpoints with node-fetch. No dependency on zca-js.
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
- const DATA_DIR = join(homedir(), ".zalo-agent");
15
- const OA_CREDS_FILE = join(DATA_DIR, "oa-credentials.json");
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
- if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
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(OA_CREDS_FILE)) {
30
- creds = JSON.parse(fs.readFileSync(OA_CREDS_FILE, "utf8"));
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(OA_CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
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
- if (!fs.existsSync(OA_CREDS_FILE)) return null;
44
- const creds = JSON.parse(fs.readFileSync(OA_CREDS_FILE, "utf8"));
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 nodefetch(TOKEN_URL, {
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 nodefetch(TOKEN_URL, {
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 nodefetch(url, opts);
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 FormData-like via node-fetch's Blob
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
- this.accountDir = resolve(
423
- process.env.USERPROFILE || process.env.HOME || "",
424
- ".zalo-agent-cli",
425
- "accounts",
426
- accountName,
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
- // The MCP server honours a configured download dir; everything else files
486
- // media under the account's own data dir.
487
- const mediaRoot = mediaRootOpt ? resolve(mediaRootOpt) : resolve(accountDir, "media");
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 { CONFIG_DIR } from "./credentials.js";
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(CONFIG_DIR, "accounts", accountName);
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
@@ -31,6 +31,7 @@ import { registerMCPCommands } from "./commands/mcp.js";
31
31
  import { registerSyncCommands } from "./commands/sync.js";
32
32
  import { autoLogin } from "./core/zalo-client.js";
33
33
  import { getActive } from "./core/accounts.js";
34
+ import { configureStoragePaths } from "./core/credentials.js";
34
35
  import { logoutNeedsSession } from "./core/logout.js";
35
36
  import { checkForUpdates, selfUpdate } from "./utils/update-check.js";
36
37
  import { applyCachedThreadType } from "./utils/thread-type.js";
@@ -46,10 +47,31 @@ program
46
47
  .description("CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments")
47
48
  .version(pkg.version)
48
49
  .option("--json", "Output results as JSON (machine-readable)")
50
+ .option("--env-file <path>", "Load environment variables from a .env file (env: ZALO_ENV_FILE)")
51
+ .option("--config-dir <dir>", "Config directory (default: ~/.zalo-agent-cli, env: ZALO_CONFIG_DIR)")
52
+ .option(
53
+ "--credentials-dir <dir>",
54
+ "Credentials directory (default: <config-dir>/credentials, env: ZALO_CREDENTIALS_DIR)",
55
+ )
56
+ .option(
57
+ "--data-dir <dir>",
58
+ "Per-account database and runtime directory (default: <config-dir>/accounts, env: ZALO_DATA_DIR)",
59
+ )
60
+ .option("--media-dir <dir>", "Downloaded media directory (default: <data-dir>/<ownId>/media, env: ZALO_MEDIA_DIR)")
61
+ .option("--oa-dir <dir>", "Official Account credentials directory (default: ~/.zalo-agent, env: ZALO_OA_DIR)")
49
62
  .hook("preAction", async (thisCommand, actionCommand) => {
63
+ const globalOpts = program.opts();
64
+ configureStoragePaths({
65
+ envFile: globalOpts.envFile,
66
+ configDir: globalOpts.configDir,
67
+ credentialsDir: globalOpts.credentialsDir,
68
+ dataDir: globalOpts.dataDir,
69
+ mediaDir: globalOpts.mediaDir,
70
+ oaDir: globalOpts.oaDir,
71
+ });
50
72
  const cmdName = thisCommand.args?.[0] || thisCommand.name();
51
73
  // Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
52
- if (program.opts().json || cmdName === "mcp") {
74
+ if (globalOpts.json || cmdName === "mcp") {
53
75
  // Suppress zca-js stdout logs: JSON mode needs clean output, MCP uses stdout as transport
54
76
  process.env.ZALO_JSON_MODE = "1";
55
77
  } else if (cmdName !== "oa") {
@@ -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
- const MCP_CONFIG_FILE = join(CONFIG_DIR, "mcp-config.json");
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 || MCP_CONFIG_FILE, "utf-8");
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 || MCP_CONFIG_FILE;
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(MCP_CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
104
+ writeFileSync(mcpConfigFile(), JSON.stringify(config, null, 2), "utf-8");
103
105
  }
104
106
 
105
107
  /**
@@ -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) {
@@ -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(QR_PATH, Buffer.from(imageB64, "base64"));
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(QR_PATH).catch(() => {});
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: QR_PATH,
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: ${QR_PATH}`);
82
- info(`To open: ${openCmd} "${QR_PATH}"`);
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 QR_PATH;
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 nodefetch("https://api.ipify.org", { timeout: 3000 });
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
 
@@ -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 { CONFIG_DIR } from "../core/credentials.js";
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(join(CONFIG_DIR, "accounts", acc.ownId, "zalo.db"));
36
+ initDb(getAccountDbPath(acc.ownId));
38
37
  return getThreadType(threadId);
39
38
  }
40
39