@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.
@@ -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
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { ThreadNameCache } from "./thread-name-cache.js";
4
4
 
5
5
  /** Create a mock API that returns predictable group/friend data */
6
- function createMockApi(groups = {}, friends = []) {
6
+ function createMockApi(groups = {}, friends = [], aliases = []) {
7
7
  return {
8
8
  getAllGroups: async () => ({
9
9
  gridVerMap: Object.fromEntries(Object.keys(groups).map((id) => [id, 1])),
@@ -12,6 +12,7 @@ function createMockApi(groups = {}, friends = []) {
12
12
  gridInfoMap: Object.fromEntries(ids.filter((id) => groups[id]).map((id) => [id, groups[id]])),
13
13
  }),
14
14
  getAllFriends: async () => friends,
15
+ getAliasList: async () => ({ items: aliases }),
15
16
  };
16
17
  }
17
18
 
@@ -39,6 +40,7 @@ describe("ThreadNameCache", () => {
39
40
  { userId: "u1", displayName: "Viet Anh", zaloName: "VA" },
40
41
  { userId: "u2", zaloName: "Bob" },
41
42
  ],
43
+ [{ userId: "u1", alias: "Viet Anh" }],
42
44
  );
43
45
 
44
46
  await cache.init(api);
@@ -77,6 +79,7 @@ describe("ThreadNameCache", () => {
77
79
  g4: { name: "Admin Team", totalMember: 5 },
78
80
  },
79
81
  [{ userId: "u1", displayName: "Soạn Văn", zaloName: "SV" }],
82
+ [{ userId: "u1", alias: "Soạn Văn" }],
80
83
  );
81
84
  await cache.init(api);
82
85
  });
@@ -19,8 +19,6 @@ import { platform } from "os";
19
19
  import { CONFIG_DIR } from "../core/credentials.js";
20
20
  import { info } from "./output.js";
21
21
 
22
- const QR_PATH = resolve(CONFIG_DIR, "qr.png");
23
-
24
22
  /** Get platform-specific command to open a file. */
25
23
  function getOpenCommand() {
26
24
  switch (platform()) {
@@ -42,18 +40,19 @@ function getOpenCommand() {
42
40
  export function displayQR(event) {
43
41
  const imageB64 = event.data?.image || "";
44
42
  const jsonMode = process.env.ZALO_JSON_MODE === "1";
43
+ const qrPath = getQRPath();
45
44
 
46
45
  // Always save PNG to config dir (needed by HTTP server and agents)
47
46
  if (imageB64) {
48
47
  try {
49
48
  mkdirSync(CONFIG_DIR, { recursive: true });
50
- writeFileSync(QR_PATH, Buffer.from(imageB64, "base64"));
49
+ writeFileSync(qrPath, Buffer.from(imageB64, "base64"));
51
50
  } catch {}
52
51
  }
53
52
 
54
53
  // Also fire-and-forget the zca-js built-in save
55
54
  if (event.actions?.saveToFile) {
56
- event.actions.saveToFile(QR_PATH).catch(() => {});
55
+ event.actions.saveToFile(qrPath).catch(() => {});
57
56
  }
58
57
 
59
58
  // JSON mode: structured output for AI agents — no terminal escapes, no noise
@@ -63,7 +62,7 @@ export function displayQR(event) {
63
62
  JSON.stringify({
64
63
  event: "qr",
65
64
  image: imageB64,
66
- file: QR_PATH,
65
+ file: qrPath,
67
66
  dataUrl: `data:image/png;base64,${imageB64}`,
68
67
  }),
69
68
  );
@@ -78,8 +77,8 @@ export function displayQR(event) {
78
77
  process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
79
78
 
80
79
  const openCmd = getOpenCommand();
81
- info(`QR image saved: ${QR_PATH}`);
82
- info(`To open: ${openCmd} "${QR_PATH}"`);
80
+ info(`QR image saved: ${qrPath}`);
81
+ info(`To open: ${openCmd} "${qrPath}"`);
83
82
  info("Copy this URL and paste in any browser to view QR:");
84
83
  console.log(`data:image/png;base64,${imageB64}`);
85
84
  }
@@ -87,5 +86,5 @@ export function displayQR(event) {
87
86
 
88
87
  /** Get the QR image path. */
89
88
  export function getQRPath() {
90
- return QR_PATH;
89
+ return resolve(CONFIG_DIR, "qr.png");
91
90
  }
@@ -6,7 +6,6 @@
6
6
 
7
7
  import http from "http";
8
8
  import { readFileSync, existsSync, statSync } from "fs";
9
- import nodefetch from "node-fetch";
10
9
  import { info } from "./output.js";
11
10
 
12
11
  /**
@@ -180,7 +179,7 @@ setInterval(async()=>{
180
179
 
181
180
  // Auto-detect public IP for VPS users
182
181
  try {
183
- const res = await nodefetch("https://api.ipify.org", { timeout: 3000 });
182
+ const res = await fetch("https://api.ipify.org", { signal: AbortSignal.timeout(3000) });
184
183
  publicIp = (await res.text()).trim() || null;
185
184
  } catch {}
186
185
 
@@ -20,9 +20,8 @@
20
20
  * `upsertThread` refuses to downgrade a group regardless, so this is the
21
21
  * ergonomic half of the fix, not the safety half.
22
22
  */
23
- import { join } from "node:path";
24
23
  import { getActive } from "../core/accounts.js";
25
- import { CONFIG_DIR } from "../core/credentials.js";
24
+ import { getAccountDbPath } from "../core/credentials.js";
26
25
  import { initDb, getThreadType } from "../core/db.js";
27
26
 
28
27
  /**
@@ -34,7 +33,7 @@ import { initDb, getThreadType } from "../core/db.js";
34
33
  function lookupCachedType(threadId) {
35
34
  const acc = getActive();
36
35
  if (!acc) return null;
37
- initDb(join(CONFIG_DIR, "accounts", acc.ownId, "zalo.db"));
36
+ initDb(getAccountDbPath(acc.ownId));
38
37
  return getThreadType(threadId);
39
38
  }
40
39