@ardennguyen/zalo-agent-cli 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +456 -21
- package/NOTICE +127 -0
- package/README.md +29 -7
- package/package.json +4 -3
- package/src/commands/listen.js +21 -7
- package/src/commands/mcp.js +44 -17
- package/src/commands/msg.js +26 -17
- 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/sync-v2/gid.js +5 -2
- package/src/core/sync-v2/index.js +57 -6
- package/src/core/sync-v2/senders.js +61 -0
- package/src/index.js +2 -1
- package/src/mcp/mcp-http-transport.js +2 -1
- package/src/mcp/mcp-server.js +3 -2
- package/src/mcp/mcp-tools.js +139 -9
- 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/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
|
}
|
|
@@ -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
|
|
|
@@ -426,12 +430,19 @@ export class SyncV2 {
|
|
|
426
430
|
/** Build opaque-globalId -> {id, name, type} from the friend + group lists. */
|
|
427
431
|
async _buildThreadMap(log) {
|
|
428
432
|
const map = new Map();
|
|
433
|
+
this.userAliases = await readUserAliases(this.api, log);
|
|
429
434
|
try {
|
|
430
435
|
const friends = await this.api.getAllFriends();
|
|
431
436
|
const arr = Array.isArray(friends) ? friends : friends?.data || [];
|
|
432
437
|
for (const f of arr)
|
|
433
438
|
if (f.globalId)
|
|
434
|
-
map.set(f.globalId, {
|
|
439
|
+
map.set(f.globalId, {
|
|
440
|
+
id: String(f.userId),
|
|
441
|
+
name: currentUserName(f.userId, f, this.userAliases).name,
|
|
442
|
+
zaloName: f.zaloName || "",
|
|
443
|
+
snapshotName: f.zaloName || f.displayName || "",
|
|
444
|
+
type: "dm",
|
|
445
|
+
});
|
|
435
446
|
} catch (e) {
|
|
436
447
|
log(`friend list unavailable (${e.message}); DM names/ids may be missing`);
|
|
437
448
|
}
|
|
@@ -451,6 +462,32 @@ export class SyncV2 {
|
|
|
451
462
|
return map;
|
|
452
463
|
}
|
|
453
464
|
|
|
465
|
+
/** Refresh already-cached titles even if this restore window has no messages for them. */
|
|
466
|
+
_refreshThreadNames(threadMap) {
|
|
467
|
+
for (const thread of threadMap.values()) {
|
|
468
|
+
if (getThreadType(thread.id) === thread.type) {
|
|
469
|
+
upsertThread({ threadId: thread.id, type: thread.type, name: thread.name, lastUpdate: 0 });
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Refresh existing DM peers outside the requested message window, including non-friends. */
|
|
475
|
+
async _refreshDmNames(threadMap) {
|
|
476
|
+
const profiles = new Map(
|
|
477
|
+
[...threadMap.values()]
|
|
478
|
+
.filter((t) => t.type === "dm" && t.zaloName)
|
|
479
|
+
.map((t) => [t.id, { zaloName: t.zaloName }]),
|
|
480
|
+
);
|
|
481
|
+
const names = await resolveCurrentUserNames(
|
|
482
|
+
this.api,
|
|
483
|
+
getRecentThreads(-1, "dm").map((t) => String(t.threadId)),
|
|
484
|
+
{ aliases: this.userAliases, profiles },
|
|
485
|
+
);
|
|
486
|
+
for (const [id, current] of names) {
|
|
487
|
+
upsertThread({ threadId: id, type: "dm", name: current.name, lastUpdate: 0 });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
454
491
|
_send590(listener, kind, syncId, ek, ik, req) {
|
|
455
492
|
listener.sendWs(
|
|
456
493
|
{
|
|
@@ -519,8 +556,11 @@ export class SyncV2 {
|
|
|
519
556
|
|
|
520
557
|
onStatus({ phase: "contacts", detail: "reading friend/group lists for thread mapping" });
|
|
521
558
|
const threadMap = await this._buildThreadMap(log);
|
|
559
|
+
const senders = createSenderResolver(this.api, threadMap, { log });
|
|
522
560
|
|
|
523
561
|
initDb(resolve(this.accountDir, "zalo.db"));
|
|
562
|
+
this._refreshThreadNames(threadMap);
|
|
563
|
+
await this._refreshDmNames(threadMap);
|
|
524
564
|
|
|
525
565
|
const L = this.api.listener;
|
|
526
566
|
const sessions = new Map(); // syncId -> {kind, batches:[], statuses:[], done, err}
|
|
@@ -806,7 +846,7 @@ export class SyncV2 {
|
|
|
806
846
|
} catch {
|
|
807
847
|
continue;
|
|
808
848
|
}
|
|
809
|
-
onObj(obj);
|
|
849
|
+
await onObj(obj);
|
|
810
850
|
} catch {
|
|
811
851
|
/* skip bad chunk */
|
|
812
852
|
}
|
|
@@ -916,7 +956,15 @@ export class SyncV2 {
|
|
|
916
956
|
onStatus({ phase: "resolve", detail: `resolving ${unresolved.length} non-friend conversation(s)` });
|
|
917
957
|
try {
|
|
918
958
|
const extra = await resolveNonFriendDms(this.api, unresolved, log);
|
|
919
|
-
for (const [gid, v] of extra)
|
|
959
|
+
for (const [gid, v] of extra)
|
|
960
|
+
threadMap.set(gid, {
|
|
961
|
+
id: v.id,
|
|
962
|
+
name: currentUserName(v.id, { zaloName: v.zaloName }, this.userAliases).name,
|
|
963
|
+
zaloName: v.zaloName || "",
|
|
964
|
+
snapshotName: v.name,
|
|
965
|
+
type: "dm",
|
|
966
|
+
});
|
|
967
|
+
this._refreshThreadNames(threadMap);
|
|
920
968
|
onStatus({
|
|
921
969
|
phase: "resolve",
|
|
922
970
|
detail: `resolved ${extra.size}/${unresolved.length} via gid/decrypt`,
|
|
@@ -1038,7 +1086,10 @@ export class SyncV2 {
|
|
|
1038
1086
|
}
|
|
1039
1087
|
|
|
1040
1088
|
const before = messagesSaved;
|
|
1041
|
-
await eachChunkObj(sessions.get(m.sid), m.ek, M.SyncChunk, (obj) => {
|
|
1089
|
+
await eachChunkObj(sessions.get(m.sid), m.ek, M.SyncChunk, async (obj) => {
|
|
1090
|
+
// Network identity lookups must finish before opening
|
|
1091
|
+
// the synchronous SQLite transaction for this chunk.
|
|
1092
|
+
await senders.prepare((obj.partitionsList || []).flatMap((p) => p.messagesList || []));
|
|
1042
1093
|
// One transaction per decoded chunk rather than one per
|
|
1043
1094
|
// row: thousands of individual WAL commits is both slow
|
|
1044
1095
|
// and a long block on the event loop.
|
|
@@ -1090,8 +1141,8 @@ export class SyncV2 {
|
|
|
1090
1141
|
// gMsgID.
|
|
1091
1142
|
msgId: syncMsgId(msg),
|
|
1092
1143
|
threadId,
|
|
1093
|
-
senderId:
|
|
1094
|
-
senderName:
|
|
1144
|
+
senderId: senders.get(msg.senderId).id,
|
|
1145
|
+
senderName: senders.get(msg.senderId).name,
|
|
1095
1146
|
text: info.text,
|
|
1096
1147
|
timestamp: Number(msg.timestamp) || 0,
|
|
1097
1148
|
type: info.type,
|
|
@@ -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/index.js
CHANGED
|
@@ -59,8 +59,9 @@ program
|
|
|
59
59
|
}
|
|
60
60
|
// Auto-login before any command that needs it (skip for login/account/oa
|
|
61
61
|
// commands, and for a logout that will not call Zalo at all).
|
|
62
|
+
// Daemons authenticate their pinned identity after acquiring its lock.
|
|
62
63
|
const skipAutoLogin =
|
|
63
|
-
["login", "account", "help", "version", "update", "oa", "mcp"].includes(cmdName) ||
|
|
64
|
+
["login", "account", "help", "version", "update", "oa", "mcp", "listen"].includes(cmdName) ||
|
|
64
65
|
(cmdName === "logout" && !logoutNeedsSession(actionCommand.opts(), getActive()));
|
|
65
66
|
if (!skipAutoLogin) {
|
|
66
67
|
await autoLogin(program.opts().json);
|
|
@@ -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,
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local tracking for explicitly requested MCP sync stages. The daemon
|
|
3
|
+
* owns this object, not an HTTP request's short-lived McpServer. The runners
|
|
4
|
+
* own all sync work; this module only tracks bounded progress and the result.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a bounded, account-local job tracker using the daemon's stage lock.
|
|
11
|
+
* Completed jobs are retained until displaced by ten newer jobs or restart.
|
|
12
|
+
* A requestId retry returns the same job while retained, without another tap.
|
|
13
|
+
* @param {{runners: object, lock: object}} deps - daemon runners and shared stage lock
|
|
14
|
+
* @returns {{start: Function, status: Function}}
|
|
15
|
+
*/
|
|
16
|
+
export function createSyncJobs({ runners, lock }) {
|
|
17
|
+
const jobs = new Map();
|
|
18
|
+
const snapshot = (job) => (job ? structuredClone(job) : null);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
start({ requestId, stage, ...params }) {
|
|
22
|
+
const existing = [...jobs.values()].find((job) => job.requestId === requestId);
|
|
23
|
+
if (existing) {
|
|
24
|
+
if (existing.stage !== stage || !isDeepStrictEqual(existing.params, params)) {
|
|
25
|
+
throw new Error("requestId already names a different sync request; use a new requestId.");
|
|
26
|
+
}
|
|
27
|
+
return snapshot(existing);
|
|
28
|
+
}
|
|
29
|
+
if (!["messages", "reactions"].includes(stage) || typeof runners[stage] !== "function") {
|
|
30
|
+
throw new Error("Unsupported sync stage.");
|
|
31
|
+
}
|
|
32
|
+
const release = lock.tryAcquire(`mcp-sync:${stage}`);
|
|
33
|
+
if (!release)
|
|
34
|
+
throw new Error(`Sync/history is busy (${lock.current()?.stage}); check status and retry later.`);
|
|
35
|
+
|
|
36
|
+
const job = {
|
|
37
|
+
jobId: randomUUID(),
|
|
38
|
+
requestId,
|
|
39
|
+
stage,
|
|
40
|
+
params,
|
|
41
|
+
state: "running",
|
|
42
|
+
startedAt: Date.now(),
|
|
43
|
+
finishedAt: null,
|
|
44
|
+
phoneConfirmationRequired: stage === "messages",
|
|
45
|
+
events: [],
|
|
46
|
+
};
|
|
47
|
+
// The lock guarantees no other running job is evicted here.
|
|
48
|
+
while (jobs.size >= 10) jobs.delete(jobs.keys().next().value);
|
|
49
|
+
jobs.set(job.jobId, job);
|
|
50
|
+
const initial = snapshot(job);
|
|
51
|
+
Promise.resolve().then(async () => {
|
|
52
|
+
try {
|
|
53
|
+
job.result = await runners[stage](params, (event) => {
|
|
54
|
+
// Retain recent progress, never an unbounded event stream.
|
|
55
|
+
job.events.push({
|
|
56
|
+
at: Date.now(),
|
|
57
|
+
phase: String(event.phase || "progress").slice(0, 80),
|
|
58
|
+
detail: String(event.detail || "").slice(0, 1000),
|
|
59
|
+
});
|
|
60
|
+
if (job.events.length > 20) job.events.shift();
|
|
61
|
+
});
|
|
62
|
+
job.state = "completed";
|
|
63
|
+
} catch (error) {
|
|
64
|
+
job.state = "failed";
|
|
65
|
+
job.error = error?.message || String(error);
|
|
66
|
+
} finally {
|
|
67
|
+
job.finishedAt = Date.now();
|
|
68
|
+
release();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return initial;
|
|
72
|
+
},
|
|
73
|
+
status(jobId) {
|
|
74
|
+
if (jobId) {
|
|
75
|
+
const job = jobs.get(jobId);
|
|
76
|
+
if (!job) throw new Error("Unknown or expired sync job; jobs are lost when the daemon restarts.");
|
|
77
|
+
return snapshot(job);
|
|
78
|
+
}
|
|
79
|
+
return snapshot([...jobs.values()].at(-1)) || { state: "idle" };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readUserAliases, resolveCurrentUserNames } from "../core/current-user-names.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* In-memory cache mapping threadId → {name, type, memberCount}.
|
|
3
5
|
* Built on startup by fetching all groups + friends from Zalo API.
|
|
@@ -35,6 +37,7 @@ export class ThreadNameCache {
|
|
|
35
37
|
const start = Date.now();
|
|
36
38
|
let groupCount = 0;
|
|
37
39
|
let friendCount = 0;
|
|
40
|
+
const friendProfiles = new Map();
|
|
38
41
|
|
|
39
42
|
// Load groups: getAllGroups() → IDs → batched getGroupInfo()
|
|
40
43
|
try {
|
|
@@ -63,12 +66,13 @@ export class ThreadNameCache {
|
|
|
63
66
|
console.error("[thread-name-cache] getAllGroups failed:", e.message);
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
// Load friends (DM threads)
|
|
69
|
+
// Load friends (DM threads), honoring the owner's aliases explicitly.
|
|
67
70
|
try {
|
|
68
71
|
const friends = await api.getAllFriends();
|
|
69
72
|
const list = Array.isArray(friends) ? friends : [];
|
|
70
73
|
for (const f of list) {
|
|
71
74
|
if (f.userId) {
|
|
75
|
+
friendProfiles.set(String(f.userId), f);
|
|
72
76
|
this._cache.set(f.userId, {
|
|
73
77
|
name: f.displayName || f.zaloName || "?",
|
|
74
78
|
type: "dm",
|
|
@@ -80,6 +84,7 @@ export class ThreadNameCache {
|
|
|
80
84
|
console.error("[thread-name-cache] getAllFriends failed:", e.message);
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
await this.refreshUsers(api, friendProfiles);
|
|
83
88
|
this._ready = true;
|
|
84
89
|
const elapsed = Date.now() - start;
|
|
85
90
|
console.error(`[thread-name-cache] Ready: ${groupCount} groups, ${friendCount} friends (${elapsed}ms)`);
|
|
@@ -94,6 +99,14 @@ export class ThreadNameCache {
|
|
|
94
99
|
return this._cache.get(threadId) || null;
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
/** Refresh current DM labels without modifying per-message names or group titles. */
|
|
103
|
+
async refreshUsers(api, profiles = new Map()) {
|
|
104
|
+
const ids = [...this._cache].filter(([, v]) => v.type === "dm").map(([id]) => id);
|
|
105
|
+
const aliases = await readUserAliases(api);
|
|
106
|
+
const names = await resolveCurrentUserNames(api, ids, { aliases, profiles });
|
|
107
|
+
for (const [id, current] of names) this.set(id, { name: current.name, type: "dm" });
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
/**
|
|
98
111
|
* Get thread name by ID. Returns null if not cached.
|
|
99
112
|
* @param {string} threadId
|