@ardennguyen/zalo-agent-cli 2.0.0 → 2.1.1

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