@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
|
@@ -31,10 +31,14 @@ import {
|
|
|
31
31
|
getPendingSyncGaps,
|
|
32
32
|
resolveSyncGap,
|
|
33
33
|
replaceLiveGroupEventPlaceholder,
|
|
34
|
+
getThreadType,
|
|
35
|
+
getRecentThreads,
|
|
34
36
|
} from "../db.js";
|
|
35
37
|
import { ensureAssets, loadCodecs } from "./assets.js";
|
|
36
38
|
import { classifySyncMessage } from "./message-types.js";
|
|
37
39
|
import { resolveNonFriendDms } from "./gid.js";
|
|
40
|
+
import { createSenderResolver } from "./senders.js";
|
|
41
|
+
import { readUserAliases, currentUserName, resolveCurrentUserNames } from "../current-user-names.js";
|
|
38
42
|
import { startKeepAlive } from "./keepalive.js";
|
|
39
43
|
import { attachLiveStore } from "../live-store.js";
|
|
40
44
|
|
|
@@ -415,23 +419,33 @@ export class SyncV2 {
|
|
|
415
419
|
constructor(api, accountName) {
|
|
416
420
|
this.api = api;
|
|
417
421
|
this.accountName = accountName;
|
|
418
|
-
|
|
419
|
-
process.env.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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);
|
|
424
431
|
}
|
|
425
432
|
|
|
426
433
|
/** Build opaque-globalId -> {id, name, type} from the friend + group lists. */
|
|
427
434
|
async _buildThreadMap(log) {
|
|
428
435
|
const map = new Map();
|
|
436
|
+
this.userAliases = await readUserAliases(this.api, log);
|
|
429
437
|
try {
|
|
430
438
|
const friends = await this.api.getAllFriends();
|
|
431
439
|
const arr = Array.isArray(friends) ? friends : friends?.data || [];
|
|
432
440
|
for (const f of arr)
|
|
433
441
|
if (f.globalId)
|
|
434
|
-
map.set(f.globalId, {
|
|
442
|
+
map.set(f.globalId, {
|
|
443
|
+
id: String(f.userId),
|
|
444
|
+
name: currentUserName(f.userId, f, this.userAliases).name,
|
|
445
|
+
zaloName: f.zaloName || "",
|
|
446
|
+
snapshotName: f.zaloName || f.displayName || "",
|
|
447
|
+
type: "dm",
|
|
448
|
+
});
|
|
435
449
|
} catch (e) {
|
|
436
450
|
log(`friend list unavailable (${e.message}); DM names/ids may be missing`);
|
|
437
451
|
}
|
|
@@ -451,6 +465,32 @@ export class SyncV2 {
|
|
|
451
465
|
return map;
|
|
452
466
|
}
|
|
453
467
|
|
|
468
|
+
/** Refresh already-cached titles even if this restore window has no messages for them. */
|
|
469
|
+
_refreshThreadNames(threadMap) {
|
|
470
|
+
for (const thread of threadMap.values()) {
|
|
471
|
+
if (getThreadType(thread.id) === thread.type) {
|
|
472
|
+
upsertThread({ threadId: thread.id, type: thread.type, name: thread.name, lastUpdate: 0 });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Refresh existing DM peers outside the requested message window, including non-friends. */
|
|
478
|
+
async _refreshDmNames(threadMap) {
|
|
479
|
+
const profiles = new Map(
|
|
480
|
+
[...threadMap.values()]
|
|
481
|
+
.filter((t) => t.type === "dm" && t.zaloName)
|
|
482
|
+
.map((t) => [t.id, { zaloName: t.zaloName }]),
|
|
483
|
+
);
|
|
484
|
+
const names = await resolveCurrentUserNames(
|
|
485
|
+
this.api,
|
|
486
|
+
getRecentThreads(-1, "dm").map((t) => String(t.threadId)),
|
|
487
|
+
{ aliases: this.userAliases, profiles },
|
|
488
|
+
);
|
|
489
|
+
for (const [id, current] of names) {
|
|
490
|
+
upsertThread({ threadId: id, type: "dm", name: current.name, lastUpdate: 0 });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
454
494
|
_send590(listener, kind, syncId, ek, ik, req) {
|
|
455
495
|
listener.sendWs(
|
|
456
496
|
{
|
|
@@ -519,8 +559,11 @@ export class SyncV2 {
|
|
|
519
559
|
|
|
520
560
|
onStatus({ phase: "contacts", detail: "reading friend/group lists for thread mapping" });
|
|
521
561
|
const threadMap = await this._buildThreadMap(log);
|
|
562
|
+
const senders = createSenderResolver(this.api, threadMap, { log });
|
|
522
563
|
|
|
523
564
|
initDb(resolve(this.accountDir, "zalo.db"));
|
|
565
|
+
this._refreshThreadNames(threadMap);
|
|
566
|
+
await this._refreshDmNames(threadMap);
|
|
524
567
|
|
|
525
568
|
const L = this.api.listener;
|
|
526
569
|
const sessions = new Map(); // syncId -> {kind, batches:[], statuses:[], done, err}
|
|
@@ -806,7 +849,7 @@ export class SyncV2 {
|
|
|
806
849
|
} catch {
|
|
807
850
|
continue;
|
|
808
851
|
}
|
|
809
|
-
onObj(obj);
|
|
852
|
+
await onObj(obj);
|
|
810
853
|
} catch {
|
|
811
854
|
/* skip bad chunk */
|
|
812
855
|
}
|
|
@@ -916,7 +959,15 @@ export class SyncV2 {
|
|
|
916
959
|
onStatus({ phase: "resolve", detail: `resolving ${unresolved.length} non-friend conversation(s)` });
|
|
917
960
|
try {
|
|
918
961
|
const extra = await resolveNonFriendDms(this.api, unresolved, log);
|
|
919
|
-
for (const [gid, v] of extra)
|
|
962
|
+
for (const [gid, v] of extra)
|
|
963
|
+
threadMap.set(gid, {
|
|
964
|
+
id: v.id,
|
|
965
|
+
name: currentUserName(v.id, { zaloName: v.zaloName }, this.userAliases).name,
|
|
966
|
+
zaloName: v.zaloName || "",
|
|
967
|
+
snapshotName: v.name,
|
|
968
|
+
type: "dm",
|
|
969
|
+
});
|
|
970
|
+
this._refreshThreadNames(threadMap);
|
|
920
971
|
onStatus({
|
|
921
972
|
phase: "resolve",
|
|
922
973
|
detail: `resolved ${extra.size}/${unresolved.length} via gid/decrypt`,
|
|
@@ -1038,7 +1089,10 @@ export class SyncV2 {
|
|
|
1038
1089
|
}
|
|
1039
1090
|
|
|
1040
1091
|
const before = messagesSaved;
|
|
1041
|
-
await eachChunkObj(sessions.get(m.sid), m.ek, M.SyncChunk, (obj) => {
|
|
1092
|
+
await eachChunkObj(sessions.get(m.sid), m.ek, M.SyncChunk, async (obj) => {
|
|
1093
|
+
// Network identity lookups must finish before opening
|
|
1094
|
+
// the synchronous SQLite transaction for this chunk.
|
|
1095
|
+
await senders.prepare((obj.partitionsList || []).flatMap((p) => p.messagesList || []));
|
|
1042
1096
|
// One transaction per decoded chunk rather than one per
|
|
1043
1097
|
// row: thousands of individual WAL commits is both slow
|
|
1044
1098
|
// and a long block on the event loop.
|
|
@@ -1090,8 +1144,8 @@ export class SyncV2 {
|
|
|
1090
1144
|
// gMsgID.
|
|
1091
1145
|
msgId: syncMsgId(msg),
|
|
1092
1146
|
threadId,
|
|
1093
|
-
senderId:
|
|
1094
|
-
senderName:
|
|
1147
|
+
senderId: senders.get(msg.senderId).id,
|
|
1148
|
+
senderName: senders.get(msg.senderId).name,
|
|
1095
1149
|
text: info.text,
|
|
1096
1150
|
timestamp: Number(msg.timestamp) || 0,
|
|
1097
1151
|
type: info.type,
|
|
@@ -482,9 +482,10 @@ export async function downloadSyncedMedia(opts = {}) {
|
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
const
|
|
485
|
+
// Explicit mediaRootOpt or global ZALO_MEDIA_DIR (--media-dir) overrides the
|
|
486
|
+
// per-account default (<accountDir>/media).
|
|
487
|
+
const configuredRoot = mediaRootOpt || process.env.ZALO_MEDIA_DIR;
|
|
488
|
+
const mediaRoot = configuredRoot ? resolve(configuredRoot) : resolve(accountDir, "media");
|
|
488
489
|
|
|
489
490
|
// Folders are ids, so without this the media tree is unreadable by a human.
|
|
490
491
|
// Written before any download, so an interrupted run still leaves an index.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolveNonFriendDms } from "./gid.js";
|
|
2
|
+
|
|
3
|
+
const NOISED_ID = /^[0-9A-V]{32}$/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a per-restore sender resolver. Only DM entries can seed user identities:
|
|
7
|
+
* a group's globalId identifies the conversation, never one of its senders.
|
|
8
|
+
* Unknown senders use the existing profile gid/decrypt + getUserInfo path.
|
|
9
|
+
* Failures leave the original ID intact and are attempted once per restore.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} api - authenticated zca-js API
|
|
12
|
+
* @param {Map<string, {id: string, name: string, type: string}>} threadMap
|
|
13
|
+
* @param {object} [options]
|
|
14
|
+
* @param {Function} [options.resolveUsers] - batched global-ID resolver
|
|
15
|
+
* @param {Function} [options.log] - diagnostic sink (never stdout in MCP mode)
|
|
16
|
+
* @returns {{prepare: Function, get: Function}} chunk preparation and row lookup
|
|
17
|
+
*/
|
|
18
|
+
export function createSenderResolver(api, threadMap, { resolveUsers = resolveNonFriendDms, log = () => {} } = {}) {
|
|
19
|
+
const users = new Map();
|
|
20
|
+
const attempted = new Set();
|
|
21
|
+
for (const [gid, user] of threadMap) {
|
|
22
|
+
if (user.type === "dm")
|
|
23
|
+
users.set(gid, { id: String(user.id), name: user.snapshotName ?? user.zaloName ?? user.name ?? "" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async prepare(messages) {
|
|
28
|
+
// Conversation resolution may have added non-friend users since
|
|
29
|
+
// this resolver was constructed. Do not repeat those lookups.
|
|
30
|
+
for (const [gid, user] of threadMap) {
|
|
31
|
+
if (user.type === "dm")
|
|
32
|
+
users.set(gid, {
|
|
33
|
+
id: String(user.id),
|
|
34
|
+
name: user.snapshotName ?? user.zaloName ?? user.name ?? "",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const missing = [...new Set(messages.map((m) => String(m.senderId || "")))].filter(
|
|
38
|
+
(id) => NOISED_ID.test(id) && !users.has(id) && !attempted.has(id),
|
|
39
|
+
);
|
|
40
|
+
if (!missing.length) return;
|
|
41
|
+
for (const id of missing) attempted.add(id);
|
|
42
|
+
try {
|
|
43
|
+
const resolved = await resolveUsers(api, missing, log);
|
|
44
|
+
for (const id of missing) {
|
|
45
|
+
const user = resolved.get(id);
|
|
46
|
+
// Never treat a missing mapping, or another opaque ID, as
|
|
47
|
+
// a canonical user. Keep unknown senders explicitly unknown.
|
|
48
|
+
if (user && /^[1-9][0-9]*$/.test(String(user.id))) {
|
|
49
|
+
users.set(id, { id: String(user.id), name: user.name || "" });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
log("sender identity lookup failed; unresolved sender IDs are preserved");
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
get(senderId) {
|
|
57
|
+
const id = String(senderId || "");
|
|
58
|
+
return users.get(id) || { id, name: "" };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/core/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import { resolve } from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { getAccountDir } from "./credentials.js";
|
|
5
5
|
import {
|
|
6
6
|
initDb,
|
|
7
7
|
insertMessage,
|
|
@@ -19,7 +19,7 @@ const THREAD_TYPE_USER = 0;
|
|
|
19
19
|
const THREAD_TYPE_GROUP = 1;
|
|
20
20
|
|
|
21
21
|
function resolveAccountDir(accountName) {
|
|
22
|
-
return resolve(
|
|
22
|
+
return resolve(getAccountDir(accountName));
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Zalo Web's own client only ever seems to backfill about this far on a
|
package/src/index.js
CHANGED
|
@@ -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 (
|
|
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") {
|
|
@@ -59,8 +81,9 @@ program
|
|
|
59
81
|
}
|
|
60
82
|
// Auto-login before any command that needs it (skip for login/account/oa
|
|
61
83
|
// commands, and for a logout that will not call Zalo at all).
|
|
84
|
+
// Daemons authenticate their pinned identity after acquiring its lock.
|
|
62
85
|
const skipAutoLogin =
|
|
63
|
-
["login", "account", "help", "version", "update", "oa", "mcp"].includes(cmdName) ||
|
|
86
|
+
["login", "account", "help", "version", "update", "oa", "mcp", "listen"].includes(cmdName) ||
|
|
64
87
|
(cmdName === "logout" && !logoutNeedsSession(actionCommand.opts(), getActive()));
|
|
65
88
|
if (!skipAutoLogin) {
|
|
66
89
|
await autoLogin(program.opts().json);
|
package/src/mcp/mcp-config.js
CHANGED
|
@@ -6,7 +6,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
import { CONFIG_DIR } from "../core/credentials.js";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
function mcpConfigFile() {
|
|
10
|
+
return join(CONFIG_DIR, "mcp-config.json");
|
|
11
|
+
}
|
|
10
12
|
|
|
11
13
|
/** Default MCP config — sensible defaults for local development */
|
|
12
14
|
export function getDefaultConfig() {
|
|
@@ -47,7 +49,7 @@ export function getDefaultConfig() {
|
|
|
47
49
|
export function loadMCPConfig(configPath) {
|
|
48
50
|
const defaults = getDefaultConfig();
|
|
49
51
|
try {
|
|
50
|
-
const raw = readFileSync(configPath ||
|
|
52
|
+
const raw = readFileSync(configPath || mcpConfigFile(), "utf-8");
|
|
51
53
|
const saved = JSON.parse(raw);
|
|
52
54
|
// Shallow merge: saved values override defaults
|
|
53
55
|
return {
|
|
@@ -77,7 +79,7 @@ export function loadMCPConfig(configPath) {
|
|
|
77
79
|
* @returns {{config: object, problem: string|null}}
|
|
78
80
|
*/
|
|
79
81
|
export function readMCPConfig(configPath) {
|
|
80
|
-
const file = configPath ||
|
|
82
|
+
const file = configPath || mcpConfigFile();
|
|
81
83
|
let raw;
|
|
82
84
|
try {
|
|
83
85
|
raw = readFileSync(file, "utf-8");
|
|
@@ -99,7 +101,7 @@ export function readMCPConfig(configPath) {
|
|
|
99
101
|
*/
|
|
100
102
|
export function saveMCPConfig(config) {
|
|
101
103
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
102
|
-
writeFileSync(
|
|
104
|
+
writeFileSync(mcpConfigFile(), JSON.stringify(config, null, 2), "utf-8");
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/**
|
|
@@ -17,7 +17,7 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8
|
|
|
17
17
|
/**
|
|
18
18
|
* Create HTTP MCP server with optional bearer token auth.
|
|
19
19
|
* @param {Function} registerToolsFn - Registers tools on a McpServer instance
|
|
20
|
-
* @param {object} deps - { api, buffer, filter, config, nameCache, accountDir }
|
|
20
|
+
* @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs }
|
|
21
21
|
* @param {number} port
|
|
22
22
|
* @param {string|null} authToken - Bearer token (null = no auth)
|
|
23
23
|
* @returns {import("http").Server}
|
|
@@ -53,6 +53,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
|
|
|
53
53
|
deps.nameCache,
|
|
54
54
|
deps.accountDir,
|
|
55
55
|
deps.stageLock,
|
|
56
|
+
deps.syncJobs,
|
|
56
57
|
);
|
|
57
58
|
|
|
58
59
|
const transport = new StreamableHTTPServerTransport({
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -23,15 +23,16 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8
|
|
|
23
23
|
* @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
|
|
24
24
|
* @param {string} [accountDir] - account data dir, for cache reads and media paths
|
|
25
25
|
* @param {object} [stageLock] - the daemon's one-stage-at-a-time lock (daemon-channel.js)
|
|
26
|
+
* @param {object} [syncJobs] - account-local sync tracker shared by all MCP requests
|
|
26
27
|
* @returns {Promise<McpServer>}
|
|
27
28
|
*/
|
|
28
|
-
export async function createMCPServer(api, buffer, filter, config, nameCache, accountDir, stageLock) {
|
|
29
|
+
export async function createMCPServer(api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs) {
|
|
29
30
|
const server = new McpServer({
|
|
30
31
|
name: "zalo-agent",
|
|
31
32
|
version: pkg.version,
|
|
32
33
|
});
|
|
33
34
|
|
|
34
|
-
registerTools(server, api, buffer, filter, config, nameCache, accountDir, stageLock);
|
|
35
|
+
registerTools(server, api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs);
|
|
35
36
|
|
|
36
37
|
const transport = new StdioServerTransport();
|
|
37
38
|
await server.connect(transport);
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP tool registrations for Zalo message access and sending.
|
|
3
|
-
* Registers
|
|
3
|
+
* Registers 14 tools: zalo_get_messages, zalo_get_history, zalo_send_message, zalo_list_threads,
|
|
4
4
|
* zalo_search_threads, zalo_mark_read, zalo_view_media, zalo_react, zalo_undo, zalo_get_group_members,
|
|
5
|
-
* zalo_list_conversations, zalo_coverage.
|
|
5
|
+
* zalo_list_conversations, zalo_coverage, zalo_sync, zalo_sync_status.
|
|
6
6
|
*
|
|
7
7
|
* A tool that mirrors a CLI command calls the same code the command does (src/core/cached-message.js,
|
|
8
8
|
* group-members.js, recent-conversations.js, src/utils/my-documents.js, urgency.js), so the two cannot
|
|
@@ -24,6 +24,7 @@ import { expandMentions, ALL_MENTION_UID } from "../utils/mentions.js";
|
|
|
24
24
|
import { resolveSelfThread } from "../utils/my-documents.js";
|
|
25
25
|
import { buildQuote, resolveQuoteSender } from "../utils/quote.js";
|
|
26
26
|
import { urgencyLevel } from "../utils/urgency.js";
|
|
27
|
+
import { resolveCurrentUserNames, withCurrentSenderNames } from "../core/current-user-names.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Resolve a thread's type the way the CLI does.
|
|
@@ -270,8 +271,9 @@ function cacheHistory(threadId, limit, before, nameCache) {
|
|
|
270
271
|
* @param {string} [accountDir] - account data dir; media is fetched into <accountDir>/media
|
|
271
272
|
* @param {ReturnType<import("../core/daemon-channel.js").createStageLock>} [stageLock] - the
|
|
272
273
|
* daemon's one-stage-at-a-time lock; a server-side history fetch takes it, as `listen`'s does
|
|
274
|
+
* @param {object} [syncJobs] - the daemon's account-local sync job tracker
|
|
273
275
|
*/
|
|
274
|
-
export function registerTools(server, api, buffer, filter, config, nameCache, accountDir, stageLock) {
|
|
276
|
+
export function registerTools(server, api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs) {
|
|
275
277
|
const maxPerPoll = config.limits?.maxMessagesPerPoll ?? 20;
|
|
276
278
|
|
|
277
279
|
// Several bots can share one listener, each with its own read cursor
|
|
@@ -297,7 +299,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
297
299
|
"not marked read (see zalo_mark_read). Use 'since' cursor from previous response for incremental polling. " +
|
|
298
300
|
"Each message's `readOnZalo` says whether the account itself has already read it on Zalo -- the " +
|
|
299
301
|
"human, on the phone or Zalo Web -- and is null when the listener has not heard; zalo_mark_read " +
|
|
300
|
-
"does not change it."
|
|
302
|
+
"does not change it. Use senderDisplayName for the current label (your alias, then Zalo profile name, " +
|
|
303
|
+
"then UID); senderDisplayNameSource reports alias/zalo/uid. senderName remains the historical snapshot.",
|
|
301
304
|
inputSchema: z.object({
|
|
302
305
|
threadId: z.string().optional().describe("Thread ID to read from. Omit for all watched threads."),
|
|
303
306
|
since: z
|
|
@@ -322,6 +325,10 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
322
325
|
if (info) msg.threadName = info.name;
|
|
323
326
|
}
|
|
324
327
|
}
|
|
328
|
+
const dmIds = result.messages
|
|
329
|
+
.filter((m) => (nameCache?.get(m.threadId)?.type || buffer.getThreadType?.(m.threadId)) === "dm")
|
|
330
|
+
.map((m) => m.threadId);
|
|
331
|
+
result.messages = await withCurrentSenderNames(api, result.messages, dmIds);
|
|
325
332
|
// Whether the human already read each one, from its
|
|
326
333
|
// conversation's read watermark (src/core/read-state.js).
|
|
327
334
|
const watermarks = new Map();
|
|
@@ -537,6 +544,10 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
537
544
|
try {
|
|
538
545
|
// Unread is per consumer: after what this consumer marked read.
|
|
539
546
|
const stats = buffer.getStats(buffer.readCursor(consumer));
|
|
547
|
+
const dmNames = await resolveCurrentUserNames(
|
|
548
|
+
api,
|
|
549
|
+
stats.filter((t) => buffer.getThreadType(t.threadId) === "dm").map((t) => t.threadId),
|
|
550
|
+
);
|
|
540
551
|
const reads = readStatesOf(
|
|
541
552
|
api,
|
|
542
553
|
stats.map((t) => String(t.threadId)),
|
|
@@ -548,7 +559,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
548
559
|
return {
|
|
549
560
|
...t,
|
|
550
561
|
threadType,
|
|
551
|
-
name: cached?.name ?? null,
|
|
562
|
+
name: dmNames.get(String(t.threadId))?.name ?? cached?.name ?? null,
|
|
552
563
|
...(cached?.memberCount !== undefined && { memberCount: cached.memberCount }),
|
|
553
564
|
readState: describeReadState(reads.get(String(t.threadId))),
|
|
554
565
|
};
|
|
@@ -583,6 +594,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
583
594
|
if (!nameCache?.ready) {
|
|
584
595
|
return err("Thread name cache not initialized yet. Try again shortly.");
|
|
585
596
|
}
|
|
597
|
+
await nameCache.refreshUsers?.(api);
|
|
586
598
|
const results = nameCache.search(query, type, limit);
|
|
587
599
|
return ok({ results, total: results.length });
|
|
588
600
|
} catch (e) {
|
|
@@ -628,6 +640,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
628
640
|
title: "Get Zalo Message History",
|
|
629
641
|
description:
|
|
630
642
|
"Fetch historical messages from a Zalo DM or group conversation. " +
|
|
643
|
+
"senderName preserves the historical snapshot; senderDisplayName resolves the current label by UID " +
|
|
644
|
+
"(your alias, then Zalo profile name, then UID), with senderDisplayNameSource alias/zalo/uid. " +
|
|
631
645
|
"Reads the local cache first (everything the listener and `zalo-agent sync` " +
|
|
632
646
|
"have stored, which can be the full history), and falls back to asking the Zalo server " +
|
|
633
647
|
"when the cache has nothing for the thread: a group's cloud-message store first, then the " +
|
|
@@ -670,7 +684,14 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
670
684
|
// request on current accounts, while the cache holds whatever
|
|
671
685
|
// the listener saw and whatever a transfer sync restored.
|
|
672
686
|
const cached = cacheHistory(threadId, limit, before, nameCache);
|
|
673
|
-
if (cached)
|
|
687
|
+
if (cached) {
|
|
688
|
+
cached.messages = await withCurrentSenderNames(
|
|
689
|
+
api,
|
|
690
|
+
cached.messages,
|
|
691
|
+
threadType === THREAD_USER ? [threadId] : [],
|
|
692
|
+
);
|
|
693
|
+
return ok(cached);
|
|
694
|
+
}
|
|
674
695
|
|
|
675
696
|
// Nothing cached: ask Zalo through the one shared fetch `listen`'s
|
|
676
697
|
// daemon uses for `msg history` (src/core/history-fetch.js): a
|
|
@@ -732,7 +753,11 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
732
753
|
// Which Zalo path answered: a group's cloud-message store, or the socket stream.
|
|
733
754
|
via: fetched.source,
|
|
734
755
|
count: allMessages.length,
|
|
735
|
-
messages:
|
|
756
|
+
messages: await withCurrentSenderNames(
|
|
757
|
+
api,
|
|
758
|
+
allMessages,
|
|
759
|
+
threadType === THREAD_USER ? [threadId] : [],
|
|
760
|
+
),
|
|
736
761
|
cursor,
|
|
737
762
|
hasMore: Boolean(fetched.more) || (fetched.source === "socket" && allMessages.length >= limit),
|
|
738
763
|
filtered: fetched.filtered === true,
|
|
@@ -799,7 +824,7 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
799
824
|
threadId: String(row?.threadId || buffered?.threadId || threadId || ""),
|
|
800
825
|
limit: 50,
|
|
801
826
|
concurrency: 2,
|
|
802
|
-
mediaRoot: mediaConfig.downloadDir || undefined,
|
|
827
|
+
mediaRoot: process.env.ZALO_MEDIA_DIR || mediaConfig.downloadDir || undefined,
|
|
803
828
|
});
|
|
804
829
|
localPath = getMessageById(messageId)?.localPath || null;
|
|
805
830
|
if (!localPath) {
|
|
@@ -1034,6 +1059,10 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
1034
1059
|
async ({ type, limit }) => {
|
|
1035
1060
|
try {
|
|
1036
1061
|
const threads = recentConversations(limit, type);
|
|
1062
|
+
const dmNames = await resolveCurrentUserNames(
|
|
1063
|
+
api,
|
|
1064
|
+
threads.filter((t) => t.type !== "group").map((t) => String(t.threadId)),
|
|
1065
|
+
);
|
|
1037
1066
|
const reads = readStatesOf(
|
|
1038
1067
|
api,
|
|
1039
1068
|
threads.map((t) => String(t.threadId)),
|
|
@@ -1045,7 +1074,11 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
1045
1074
|
threadId: String(t.threadId),
|
|
1046
1075
|
type: isGroup ? "group" : "dm",
|
|
1047
1076
|
threadType: isGroup ? THREAD_GROUP : THREAD_USER,
|
|
1048
|
-
name:
|
|
1077
|
+
name:
|
|
1078
|
+
dmNames.get(String(t.threadId))?.name ||
|
|
1079
|
+
t.name ||
|
|
1080
|
+
nameCache?.get(String(t.threadId))?.name ||
|
|
1081
|
+
null,
|
|
1049
1082
|
lastActivity: ts,
|
|
1050
1083
|
lastActivityAt: ts ? new Date(ts).toISOString() : null,
|
|
1051
1084
|
readState: describeReadState(reads.get(String(t.threadId))),
|
|
@@ -1078,7 +1111,8 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
1078
1111
|
"this account's connection to Zalo was down, so messages from them may be missing — with each " +
|
|
1079
1112
|
"gap's from/to and reason, counts the gaps already resolved, and names the `zalo-agent sync --from " +
|
|
1080
1113
|
"<date>` run that restores the pending ones from the owner's phone (it needs a tap on the phone, " +
|
|
1081
|
-
"so
|
|
1114
|
+
"so the owner must approve it). Use zalo_sync only when the owner asks to restore. " +
|
|
1115
|
+
"Read-only: nothing is sent to Zalo.",
|
|
1082
1116
|
inputSchema: z.object({}),
|
|
1083
1117
|
},
|
|
1084
1118
|
async () => {
|
|
@@ -1090,4 +1124,100 @@ export function registerTools(server, api, buffer, filter, config, nameCache, ac
|
|
|
1090
1124
|
}
|
|
1091
1125
|
},
|
|
1092
1126
|
);
|
|
1127
|
+
|
|
1128
|
+
// An explicit start returns immediately; phone-backed restores can take minutes.
|
|
1129
|
+
server.registerTool(
|
|
1130
|
+
"zalo_sync",
|
|
1131
|
+
{
|
|
1132
|
+
title: "Request Zalo Sync",
|
|
1133
|
+
description:
|
|
1134
|
+
"Start a messages restore or reaction-backlog sync on this MCP account's existing socket. " +
|
|
1135
|
+
"Only call when the owner explicitly requests sync; messages sends a confirmation to their phone. " +
|
|
1136
|
+
"Returns a job immediately: poll zalo_sync_status every few seconds. Reuse requestId for retries. " +
|
|
1137
|
+
"This updates the local cache, not Zalo messages. Messages restores all conversations in the window; " +
|
|
1138
|
+
"it is not a per-thread sync. No media download, boards, cloud index or conversation-state sync.",
|
|
1139
|
+
inputSchema: z
|
|
1140
|
+
.object({
|
|
1141
|
+
requestId: z
|
|
1142
|
+
.string()
|
|
1143
|
+
.min(1)
|
|
1144
|
+
.max(64)
|
|
1145
|
+
.regex(/^[A-Za-z0-9._:-]+$/)
|
|
1146
|
+
.describe(
|
|
1147
|
+
"Unique request key; reuse the same key and arguments after a timeout to avoid duplicate sync.",
|
|
1148
|
+
),
|
|
1149
|
+
stage: z.enum(["messages", "reactions"]).default("messages"),
|
|
1150
|
+
days: z
|
|
1151
|
+
.number()
|
|
1152
|
+
.int()
|
|
1153
|
+
.min(1)
|
|
1154
|
+
.max(36500)
|
|
1155
|
+
.optional()
|
|
1156
|
+
.describe("Restore the last N days; omit both days and from for full phone history."),
|
|
1157
|
+
from: z
|
|
1158
|
+
.string()
|
|
1159
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
1160
|
+
.refine((value) => {
|
|
1161
|
+
const time = Date.parse(value + "T00:00:00Z");
|
|
1162
|
+
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
|
|
1163
|
+
}, "Use a valid YYYY-MM-DD date")
|
|
1164
|
+
.optional()
|
|
1165
|
+
.describe("Restore since this UTC date, e.g. the date from zalo_coverage."),
|
|
1166
|
+
waitMs: z
|
|
1167
|
+
.number()
|
|
1168
|
+
.int()
|
|
1169
|
+
.min(30000)
|
|
1170
|
+
.max(600000)
|
|
1171
|
+
.optional()
|
|
1172
|
+
.describe("Per-phase wait budget in milliseconds."),
|
|
1173
|
+
applyRemovals: z
|
|
1174
|
+
.boolean()
|
|
1175
|
+
.optional()
|
|
1176
|
+
.describe("Reactions only: apply observed removals; defaults to true."),
|
|
1177
|
+
})
|
|
1178
|
+
.strict()
|
|
1179
|
+
.refine(
|
|
1180
|
+
(value) => !(value.days !== undefined && value.from !== undefined),
|
|
1181
|
+
"Choose days or from, not both",
|
|
1182
|
+
)
|
|
1183
|
+
.refine(
|
|
1184
|
+
(value) => value.stage !== "reactions" || (value.days === undefined && value.from === undefined),
|
|
1185
|
+
"Reactions sync has no date window",
|
|
1186
|
+
)
|
|
1187
|
+
.refine(
|
|
1188
|
+
(value) => value.stage !== "messages" || value.applyRemovals === undefined,
|
|
1189
|
+
"applyRemovals is reactions-only",
|
|
1190
|
+
),
|
|
1191
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1192
|
+
},
|
|
1193
|
+
async (args) => {
|
|
1194
|
+
try {
|
|
1195
|
+
if (!syncJobs) throw new Error("Sync is unavailable in this server; restart with a compatible engine.");
|
|
1196
|
+
return ok(syncJobs.start(args));
|
|
1197
|
+
} catch (e) {
|
|
1198
|
+
return err(e.message);
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
server.registerTool(
|
|
1204
|
+
"zalo_sync_status",
|
|
1205
|
+
{
|
|
1206
|
+
title: "Zalo Sync Status",
|
|
1207
|
+
description:
|
|
1208
|
+
"Read sync progress and the final result without starting or repeating sync. Omit jobId for the latest " +
|
|
1209
|
+
"job (or idle). Completed means the runner returned: inspect result.reason for partial/timeout/socket loss, " +
|
|
1210
|
+
"not proof that all history was restored. Last ten jobs are kept in memory until daemon restart.",
|
|
1211
|
+
inputSchema: z.object({ jobId: z.string().uuid().optional() }).strict(),
|
|
1212
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1213
|
+
},
|
|
1214
|
+
async ({ jobId }) => {
|
|
1215
|
+
try {
|
|
1216
|
+
if (!syncJobs) throw new Error("Sync is unavailable in this server; restart with a compatible engine.");
|
|
1217
|
+
return ok(syncJobs.status(jobId));
|
|
1218
|
+
} catch (e) {
|
|
1219
|
+
return err(e.message);
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1222
|
+
);
|
|
1093
1223
|
}
|