@ardennguyen/zalo-agent-cli 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -0
- package/LICENSE +456 -21
- package/NOTICE +127 -0
- package/README.md +31 -9
- package/package.json +5 -4
- package/src/commands/conv.js +5 -8
- package/src/commands/listen.js +23 -9
- package/src/commands/mcp.js +47 -20
- package/src/commands/msg.js +32 -23
- package/src/commands/sync.js +10 -10
- package/src/core/accounts.js +11 -7
- package/src/core/credentials.js +152 -4
- package/src/core/current-user-names.js +104 -0
- package/src/core/daemon-account.js +61 -0
- package/src/core/daemon-sync.js +2 -2
- package/src/core/db.js +15 -0
- package/src/core/logout.js +3 -3
- package/src/core/oa-client.js +27 -15
- package/src/core/sync-v2/gid.js +5 -2
- package/src/core/sync-v2/index.js +66 -12
- package/src/core/sync-v2/media.js +4 -3
- package/src/core/sync-v2/senders.js +61 -0
- package/src/core/sync.js +2 -2
- package/src/index.js +25 -2
- package/src/mcp/mcp-config.js +6 -4
- package/src/mcp/mcp-http-transport.js +2 -1
- package/src/mcp/mcp-server.js +3 -2
- package/src/mcp/mcp-tools.js +140 -10
- package/src/mcp/sync-jobs.js +82 -0
- package/src/mcp/thread-name-cache.js +14 -1
- package/src/mcp/thread-name-cache.test.js +4 -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/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,9 @@ 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);
|
|
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 =
|
|
109
|
+
const accountDir = getAccountDir(ownId);
|
|
106
110
|
if (!existsSync(accountDir)) return { wiped: false };
|
|
107
111
|
|
|
108
112
|
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() {
|
|
@@ -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
|
+
}
|
package/src/core/daemon-sync.js
CHANGED
|
@@ -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;
|
|
73
|
-
* owner
|
|
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
|
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();
|
package/src/core/sync-v2/gid.js
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|
}
|