@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.
@@ -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
- this.accountDir = resolve(
419
- process.env.USERPROFILE || process.env.HOME || "",
420
- ".zalo-agent-cli",
421
- "accounts",
422
- accountName,
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, { id: String(f.userId), name: f.displayName || f.zaloName || "", type: "dm" });
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) threadMap.set(gid, { id: v.id, name: v.name, type: "dm" });
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: String(msg.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
- // The MCP server honours a configured download dir; everything else files
486
- // media under the account's own data dir.
487
- const mediaRoot = mediaRootOpt ? resolve(mediaRootOpt) : resolve(accountDir, "media");
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 { CONFIG_DIR } from "./credentials.js";
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(CONFIG_DIR, "accounts", accountName);
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 (program.opts().json || cmdName === "mcp") {
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);
@@ -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
- const MCP_CONFIG_FILE = join(CONFIG_DIR, "mcp-config.json");
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 || MCP_CONFIG_FILE, "utf-8");
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 || MCP_CONFIG_FILE;
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(MCP_CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
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({
@@ -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,
@@ -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: 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
  }