@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.
@@ -83,6 +83,7 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
83
83
 
84
84
  // 2. userId -> display name via getUserInfo (batched). Best-effort.
85
85
  const names = new Map();
86
+ const zaloNames = new Map();
86
87
  const uids = [...new Set([...idToUser.values()])];
87
88
  for (let i = 0; i < uids.length; i += NAME_BATCH) {
88
89
  const batch = uids.slice(i, i + NAME_BATCH);
@@ -90,7 +91,8 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
90
91
  const info = await api.getUserInfo(batch);
91
92
  for (const bag of [info?.changed_profiles, info?.unchanged_profiles]) {
92
93
  for (const [uid, p] of Object.entries(bag || {})) {
93
- if (!names.has(uid) && p) names.set(uid, p.displayName || p.zaloName || "");
94
+ if (!names.has(uid) && p) names.set(uid, p.zaloName || p.displayName || "");
95
+ if (p?.zaloName) zaloNames.set(uid, p.zaloName);
94
96
  }
95
97
  }
96
98
  } catch (e) {
@@ -98,6 +100,7 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
98
100
  }
99
101
  }
100
102
 
101
- for (const [gid, uid] of idToUser) out.set(gid, { id: uid, name: names.get(uid) || "" });
103
+ for (const [gid, uid] of idToUser)
104
+ out.set(gid, { id: uid, name: names.get(uid) || "", zaloName: zaloNames.get(uid) || "" });
102
105
  return out;
103
106
  }
@@ -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, { id: String(f.userId), name: f.displayName || f.zaloName || "", type: "dm" });
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) threadMap.set(gid, { id: v.id, name: v.name, type: "dm" });
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: String(msg.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({
@@ -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);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * MCP tool registrations for Zalo message access and sending.
3
- * Registers 12 tools: zalo_get_messages, zalo_get_history, zalo_send_message, zalo_list_threads,
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) return ok(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: allMessages,
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: t.name || nameCache?.get(String(t.threadId))?.name || null,
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 a person runs it). Read-only: nothing is sent to Zalo.",
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