@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.4

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.
Files changed (41) hide show
  1. package/README.md +41 -33
  2. package/node_modules/zca-js/dist/apis.js +0 -8
  3. package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
  4. package/package.json +3 -3
  5. package/patches/zca-js+2.2.0.patch +4 -236
  6. package/src/commands/bank.js +126 -0
  7. package/src/commands/catalog.js +14 -0
  8. package/src/commands/conv.js +19 -0
  9. package/src/commands/friend.js +66 -21
  10. package/src/commands/group.js +38 -2
  11. package/src/commands/listen.js +71 -4
  12. package/src/commands/mcp.js +252 -4
  13. package/src/commands/msg.js +60 -0
  14. package/src/commands/profile.js +39 -0
  15. package/src/commands/sync.js +1 -68
  16. package/src/core/bank-accounts.js +52 -0
  17. package/src/core/current-user-names.js +27 -7
  18. package/src/core/daemon-channel.js +191 -2
  19. package/src/core/daemon-log.js +250 -0
  20. package/src/core/daemon-sync.js +140 -9
  21. package/src/core/friend-requests.js +142 -0
  22. package/src/core/friend-search.js +41 -0
  23. package/src/core/group-admin.js +116 -0
  24. package/src/core/group-invite.js +53 -0
  25. package/src/core/sync-v2/gid.js +20 -20
  26. package/src/core/sync-v2/index.js +77 -11
  27. package/src/core/sync.js +4 -235
  28. package/src/core/user-info.js +170 -0
  29. package/src/index.js +2 -0
  30. package/src/mcp/mcp-config.js +1 -0
  31. package/src/mcp/mcp-tools.js +302 -2
  32. package/src/utils/cli-args.js +84 -0
  33. package/src/utils/secrets.js +69 -0
  34. package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
  35. package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
  36. package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
  37. package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
  38. package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
  39. package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
  40. package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
  41. package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Friend requests, as the CLI's `friend` commands and the MCP tool `zalo_friend`
3
+ * both read them.
4
+ *
5
+ * Zalo has no "incoming requests" list of its own. Zalo Web shows them inside
6
+ * the recommendations feed: an item whose `recommType` is 2
7
+ * (`FriendRecommendationsType.ReceivedFriendRequest`) is a request someone
8
+ * sent us, with their message in `recommInfo.message`; type 1 is an ordinary
9
+ * suggestion. `friend recommendations` already labels them `[request]`.
10
+ *
11
+ * Nothing here talks to Zalo or writes zalo.db: these are pure reshapers of
12
+ * what the zca-js calls return, plus the error wording for the writes.
13
+ */
14
+
15
+ /** zca-js FriendRecommendationsType.ReceivedFriendRequest. */
16
+ export const RECEIVED_FRIEND_REQUEST = 2;
17
+
18
+ /**
19
+ * The numeric error code in a zca-js error message such as "... (225)".
20
+ *
21
+ * @param {unknown} msg
22
+ * @returns {number|null}
23
+ */
24
+ export function extractErrorCode(msg) {
25
+ const match = String(msg).match(/\((-?\d+)\)/);
26
+ return match ? Number(match[1]) : null;
27
+ }
28
+
29
+ /**
30
+ * What a failed friend request means, in words that say what to do next.
31
+ *
32
+ * @param {number|string|null|undefined} code - Zalo's error code
33
+ * @param {string} userId
34
+ * @returns {string|undefined} undefined for a code with no specific wording
35
+ */
36
+ export function describeFriendAddError(code, userId) {
37
+ const map = {
38
+ 225: `Already friends with ${userId}. Use "friend list" to verify.`,
39
+ 215: `User ${userId} may have blocked you or is unreachable.`,
40
+ 222: `User ${userId} already sent you a friend request. Use "friend accept ${userId}" instead.`,
41
+ "-1": `Invalid userId "${userId}". Use "friend find <phone>" to get the correct userId first.`,
42
+ // Names from Zalo Web's own error table (TOO_MANY_REQUESTS, FULL_FRIEND_REQUESTING, FULL_FRIEND).
43
+ 221: "Zalo says too many requests were sent: wait a while before sending more.",
44
+ 223: "Zalo says the pending friend-request list is full (223).",
45
+ 224: "Zalo says the friend list is full (224).",
46
+ };
47
+ return map[String(code)];
48
+ }
49
+
50
+ /**
51
+ * An epoch timestamp as ISO text, whether Zalo gave seconds or milliseconds.
52
+ *
53
+ * Which unit a friend-request time carries has not been measured on a live
54
+ * account; anything below 1e11 cannot be a millisecond time of this century,
55
+ * so it is read as seconds.
56
+ *
57
+ * @param {unknown} ts
58
+ * @returns {string|null} null when it is not a usable time
59
+ */
60
+ export function toIso(ts) {
61
+ const n = Number(ts);
62
+ if (!Number.isFinite(n) || n <= 0) return null;
63
+ return new Date(n < 1e11 ? n * 1000 : n).toISOString();
64
+ }
65
+
66
+ const name = (u) => u?.displayName || u?.zaloName || null;
67
+
68
+ /**
69
+ * Requests we sent, from `getSentFriendRequest()` (an object keyed by userId).
70
+ *
71
+ * @param {Record<string, object>|null|undefined} res
72
+ * @returns {Array<{userId: string, displayName: string|null, message: string, requestedAt: string|null}>}
73
+ */
74
+ export function sentRequestsFrom(res) {
75
+ return Object.entries(res && typeof res === "object" ? res : {}).map(([userId, r]) => ({
76
+ userId: String(r?.userId ?? userId),
77
+ displayName: name(r),
78
+ message: r?.fReqInfo?.message ?? "",
79
+ requestedAt: toIso(r?.fReqInfo?.time),
80
+ }));
81
+ }
82
+
83
+ /**
84
+ * Requests sent to us, from `getFriendRecommendations()`: the items of type
85
+ * "received friend request", newest first. Suggestions are left out.
86
+ *
87
+ * @param {{recommItems?: Array<{dataInfo?: object}>}|null|undefined} res
88
+ * @returns {Array<{userId: string, displayName: string|null, message: string, receivedAt: string|null, seen: boolean}>}
89
+ */
90
+ export function receivedRequestsFrom(res) {
91
+ const items = Array.isArray(res?.recommItems) ? res.recommItems : [];
92
+ return items
93
+ .map((i) => i?.dataInfo)
94
+ .filter((d) => d && Number(d.recommType) === RECEIVED_FRIEND_REQUEST && d.userId)
95
+ .map((d) => ({
96
+ userId: String(d.userId),
97
+ displayName: name(d),
98
+ message: d.recommInfo?.message ?? "",
99
+ receivedAt: toIso(d.recommTime),
100
+ seen: Boolean(d.isSeenFriendReq),
101
+ _t: Number(d.recommTime) || 0,
102
+ }))
103
+ .sort((a, b) => b._t - a._t)
104
+ .map(({ _t, ...rest }) => rest);
105
+ }
106
+
107
+ /**
108
+ * The relationship with one user, from `getFriendRequestStatus()`.
109
+ * Zalo answers 0 or 1 for each flag; these are booleans.
110
+ *
111
+ * @param {{is_friend?: number, is_requested?: number, is_requesting?: number}|null|undefined} res
112
+ * @param {string} userId
113
+ * @returns {{userId: string, is_friend: boolean, is_requested: boolean, is_requesting: boolean}}
114
+ */
115
+ export function friendStatusFrom(res, userId) {
116
+ return {
117
+ userId: String(userId),
118
+ is_friend: Boolean(res?.is_friend),
119
+ is_requested: Boolean(res?.is_requested),
120
+ is_requesting: Boolean(res?.is_requesting),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Friends, most recently active first, as the CLI lists them, capped.
126
+ *
127
+ * @param {Array<object>|null|undefined} list - `getAllFriends()`
128
+ * @param {number} limit
129
+ * @returns {{total: number, friends: Array<{userId: string, displayName: string|null, lastActionTime: number|null}>}}
130
+ */
131
+ export function friendsFrom(list, limit) {
132
+ const all = Array.isArray(list) ? list : [];
133
+ const sorted = [...all].sort((a, b) => (b?.lastActionTime || 0) - (a?.lastActionTime || 0));
134
+ return {
135
+ total: all.length,
136
+ friends: sorted.slice(0, limit).map((f) => ({
137
+ userId: String(f.userId),
138
+ displayName: name(f),
139
+ lastActionTime: f.lastActionTime || null,
140
+ })),
141
+ };
142
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Friend search, as `friend search` reads it.
3
+ *
4
+ * A friend can be known by three names: the profile name Zalo shows
5
+ * (`displayName`), the name they gave themselves (`zaloName`), and the alias
6
+ * the account owner set for them. The alias lives in a separate list
7
+ * (`getAliasList`), so `getAllFriends` alone never contains it, and a search
8
+ * on the first two names cannot find a friend the owner saved as "Arden AI".
9
+ * `zalo_search_threads` already honors aliases through the thread-name cache;
10
+ * this gives the CLI the same reach.
11
+ *
12
+ * Pure: nothing here talks to Zalo.
13
+ */
14
+
15
+ /**
16
+ * The friends whose display name, Zalo name or alias contains `query`
17
+ * (case-insensitive substring, as before).
18
+ *
19
+ * Each match is a copy of the friend with an `alias` field added when the
20
+ * owner has set one, so callers can show why it matched.
21
+ *
22
+ * @param {object[]} friends - `getAllFriends` entries
23
+ * @param {Map<string, string>} aliases - uid → the owner's alias for that friend
24
+ * @param {string} query
25
+ * @returns {object[]}
26
+ */
27
+ export function searchFriends(friends, aliases, query) {
28
+ const q = String(query).toLowerCase();
29
+ const hit = (s) =>
30
+ String(s || "")
31
+ .toLowerCase()
32
+ .includes(q);
33
+ const matches = [];
34
+ for (const f of Array.isArray(friends) ? friends : []) {
35
+ const alias = aliases?.get(String(f.userId)) ?? "";
36
+ if (hit(f.displayName) || hit(f.zaloName) || hit(alias)) {
37
+ matches.push(alias ? { ...f, alias } : f);
38
+ }
39
+ }
40
+ return matches;
41
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Group administration, as the MCP tool `zalo_group` reads Zalo's answers.
3
+ *
4
+ * Pure reshapers: nothing here talks to Zalo or writes zalo.db. Members are
5
+ * listed by `src/core/group-members.js`; this file covers what that one does
6
+ * not: a group's own details, and the outcome of creating a group or changing
7
+ * its membership.
8
+ */
9
+ import { toIso } from "./friend-requests.js";
10
+
11
+ /**
12
+ * Zalo's error codes for leaving a group the account is not in. 167 ("Không thể rời nhóm") was measured on a live
13
+ * account by leaving a group a second time; 166 is the code zca-js documents on `leaveGroup` for the same situation
14
+ * and has not been seen.
15
+ */
16
+ export const NOT_A_MEMBER_CODES = Object.freeze([166, 167]);
17
+
18
+ /** Zalo's code ("Nhóm không có thành viên.") for removing someone who is not in the group, measured live. */
19
+ export const REMOVE_NOT_IN_GROUP = 165;
20
+
21
+ /** `zalo_group` actions that cannot be undone from the tool, and so ask for `confirm: true`. */
22
+ export const CONFIRMED_ACTIONS = Object.freeze(["leave", "remove_member"]);
23
+
24
+ /**
25
+ * Whether `zalo_group` asks for `confirm: true` before an irreversible action.
26
+ *
27
+ * The check is a parameter, never a prompt: an automatic or headless caller
28
+ * either sends `confirm: true` or switches the check off, and nothing waits for
29
+ * a person. `ZALO_MCP_GROUP_CONFIRM` (`0`/`false`/`off`/`no` to disable,
30
+ * `1`/`true`/`on`/`yes` to enable) wins over `groupConfirm` in mcp-config.json,
31
+ * so a backend deployment can turn it off without editing the file. On by
32
+ * default; anything unrecognized leaves it on.
33
+ *
34
+ * @param {object} [sources]
35
+ * @param {{groupConfirm?: unknown}|null} [sources.config] - the loaded MCP config
36
+ * @param {NodeJS.ProcessEnv} [sources.env] - environment, process.env by default
37
+ * @returns {boolean}
38
+ */
39
+ export function groupConfirmRequired({ config, env = process.env } = {}) {
40
+ const fromEnv = String(env?.ZALO_MCP_GROUP_CONFIRM ?? "")
41
+ .trim()
42
+ .toLowerCase();
43
+ if (["0", "false", "off", "no"].includes(fromEnv)) return false;
44
+ if (["1", "true", "on", "yes"].includes(fromEnv)) return true;
45
+ return config?.groupConfirm !== false;
46
+ }
47
+
48
+ /**
49
+ * The details of one group out of `getGroupInfo`'s answer.
50
+ *
51
+ * Zalo answers `getGroupInfo([id])` with `gridInfoMap` keyed by group id; a
52
+ * group the account is not in is simply absent. The member list is left out
53
+ * on purpose (`zalo_get_group_members` owns it, with names); only its size is
54
+ * reported.
55
+ *
56
+ * @param {object|null|undefined} result - what `api.getGroupInfo([groupId])` returned
57
+ * @param {string} groupId
58
+ * @returns {object|null} null when Zalo's answer does not include the group
59
+ */
60
+ export function groupInfoFrom(result, groupId) {
61
+ const g = result?.gridInfoMap?.[groupId];
62
+ if (!g) return null;
63
+ return {
64
+ groupId: String(g.groupId ?? groupId),
65
+ name: g.name ?? null,
66
+ description: g.desc || null,
67
+ type: g.type ?? null,
68
+ creatorId: g.creatorId ?? null,
69
+ adminIds: Array.isArray(g.adminIds) ? g.adminIds.map(String) : [],
70
+ totalMember: g.totalMember ?? null,
71
+ maxMember: g.maxMember ?? null,
72
+ createdAt: toIso(g.createdTime),
73
+ avatar: g.fullAvt || g.avt || null,
74
+ visibility: g.visibility ?? null,
75
+ pendingApproval: Array.isArray(g.pendingApprove?.uids) ? g.pendingApprove.uids.length : 0,
76
+ settings: g.setting ?? null,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * The outcome of `createGroup`.
82
+ *
83
+ * zca-js spells the success list `sucessMembers`; both spellings are read so a
84
+ * fixed typo upstream does not empty it. Zalo's list includes the account that
85
+ * created the group (measured live: asking for one member returns two ids), so
86
+ * `added` keeps only the members that were asked for.
87
+ *
88
+ * @param {object|null|undefined} result
89
+ * @param {string[]} requested - the member ids that were asked for
90
+ * @returns {{groupId: string|null, added: string[], failed: string[]}}
91
+ */
92
+ export function createdGroupFrom(result, requested) {
93
+ const failed = (result?.errorMembers ?? []).map(String);
94
+ const given = result?.sucessMembers ?? result?.successMembers;
95
+ const added = Array.isArray(given)
96
+ ? given.map(String).filter((id) => requested.includes(id))
97
+ : requested.filter((id) => !failed.includes(id));
98
+ return { groupId: result?.groupId ? String(result.groupId) : null, added, failed };
99
+ }
100
+
101
+ /**
102
+ * The outcome of `addUserToGroup` / `removeUserFromGroup`: which of the
103
+ * requested members Zalo refused, and why.
104
+ *
105
+ * `error_data` (add only) maps an error code to the ids it applies to.
106
+ *
107
+ * @param {object|null|undefined} result
108
+ * @param {string[]} requested - the member ids that were asked for
109
+ * @returns {{done: string[], failed: string[], errorData: Record<string, string[]>|null}}
110
+ */
111
+ export function memberChangeFrom(result, requested) {
112
+ const failed = (result?.errorMembers ?? []).map(String);
113
+ const data = result?.error_data;
114
+ const errorData = data && typeof data === "object" && Object.keys(data).length > 0 ? data : null;
115
+ return { done: requested.filter((id) => !failed.includes(id)), failed, errorData };
116
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A pending group invitation's details (`group invite-info`).
3
+ *
4
+ * zca-js's `getGroupInviteBoxInfo` reads `data.groupInfo.topic.params` without
5
+ * checking that the group has a topic. Most groups have no pinned message, so
6
+ * for them Zalo's answer carries no `topic` and zca-js throws "Cannot read
7
+ * properties of undefined (reading 'params')" after Zalo had answered fine.
8
+ * This makes the same request (GET {group}/api/group/inv-box/inv-info) on
9
+ * zca-js's own session envelope, and unpacks the topic's JSON-in-JSON params
10
+ * only when there is a topic.
11
+ */
12
+ import { customCall, getWithParams } from "./zca-custom.js";
13
+
14
+ /**
15
+ * Parse a topic's `params`, which Zalo sends as a JSON string whose `extra`
16
+ * field is itself a JSON string. Anything that does not parse is left as it came.
17
+ *
18
+ * @param {object} topic
19
+ * @returns {object} the same topic, with `params` unpacked when it could be
20
+ */
21
+ export function unpackTopic(topic) {
22
+ if (!topic || typeof topic.params !== "string") return topic;
23
+ try {
24
+ const params = JSON.parse(topic.params);
25
+ if (typeof params?.extra === "string") {
26
+ try {
27
+ params.extra = JSON.parse(params.extra);
28
+ } catch {
29
+ /* keep the string */
30
+ }
31
+ }
32
+ return { ...topic, params };
33
+ } catch {
34
+ return topic;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {object} api - logged-in zca-js api
40
+ * @param {{groupId: string, mpage?: number, mcount?: number}} payload
41
+ * @returns {Promise<object>} Zalo's `data`: groupInfo, inviterInfo, grCreatorInfo, expiredTs, type
42
+ */
43
+ export async function getInviteBoxInfo(api, { groupId, mpage = 1, mcount = 10 }) {
44
+ return customCall(api, async (_ctx, utils) => {
45
+ const data = await getWithParams(utils, `${api.zpwServiceMap.group[0]}/api/group/inv-box/inv-info`, {
46
+ grId: groupId,
47
+ mcount,
48
+ mpage,
49
+ });
50
+ if (data?.groupInfo?.topic) data.groupInfo.topic = unpackTopic(data.groupInfo.topic);
51
+ return data;
52
+ });
53
+ }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { createRequire } from "node:module";
21
21
  import { dirname, join } from "node:path";
22
+ import { runPacedBatches } from "../user-info.js";
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
 
@@ -68,15 +69,13 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
68
69
  return out;
69
70
  }
70
71
  const idToUser = new Map();
71
- for (let i = 0; i < globalIds.length; i += GID_BATCH) {
72
- const batch = globalIds.slice(i, i + GID_BATCH);
73
- let map;
74
- try {
75
- map = await gidDecrypt(batch);
76
- } catch (e) {
77
- log(`gid/decrypt batch failed (${e.message})`);
78
- continue;
79
- }
72
+ const decrypted = await runPacedBatches(globalIds, {
73
+ batchSize: GID_BATCH,
74
+ label: "gid/decrypt",
75
+ log,
76
+ call: (batch) => gidDecrypt(batch),
77
+ });
78
+ for (const map of decrypted.results) {
80
79
  for (const [gid, uid] of Object.entries(map || {})) if (uid) idToUser.set(gid, String(uid));
81
80
  }
82
81
  if (!idToUser.size) return out;
@@ -85,20 +84,21 @@ export async function resolveNonFriendDms(api, globalIds, log = () => {}) {
85
84
  const names = new Map();
86
85
  const zaloNames = new Map();
87
86
  const uids = [...new Set([...idToUser.values()])];
88
- for (let i = 0; i < uids.length; i += NAME_BATCH) {
89
- const batch = uids.slice(i, i + NAME_BATCH);
90
- try {
91
- const info = await api.getUserInfo(batch);
92
- for (const bag of [info?.changed_profiles, info?.unchanged_profiles]) {
93
- for (const [uid, p] of Object.entries(bag || {})) {
94
- if (!names.has(uid) && p) names.set(uid, p.zaloName || p.displayName || "");
95
- if (p?.zaloName) zaloNames.set(uid, p.zaloName);
96
- }
87
+ const named = await runPacedBatches(uids, {
88
+ batchSize: NAME_BATCH,
89
+ label: "getUserInfo",
90
+ log,
91
+ call: (batch) => api.getUserInfo(batch),
92
+ });
93
+ for (const info of named.results) {
94
+ for (const bag of [info?.changed_profiles, info?.unchanged_profiles]) {
95
+ for (const [uid, p] of Object.entries(bag || {})) {
96
+ if (!names.has(uid) && p) names.set(uid, p.zaloName || p.displayName || "");
97
+ if (p?.zaloName) zaloNames.set(uid, p.zaloName);
97
98
  }
98
- } catch (e) {
99
- log(`getUserInfo batch failed (${e.message}); some names may be blank`);
100
99
  }
101
100
  }
101
+ if (named.failed || named.skippedIds) log("some names may be blank (getUserInfo did not answer for every batch)");
102
102
 
103
103
  for (const [gid, uid] of idToUser)
104
104
  out.set(gid, { id: uid, name: names.get(uid) || "", zaloName: zaloNames.get(uid) || "" });
@@ -82,6 +82,18 @@ const DEFAULT_WAVE_SIZE = 4;
82
82
  * dying.
83
83
  */
84
84
  const MAX_RESUMES = 1;
85
+ /** The most reconnects a caller may ask a single restore to attempt. */
86
+ const MAX_RESUMES_CAP = 5;
87
+
88
+ /**
89
+ * How many reconnects a restore may spend: the caller's `maxResumes` (a whole number, capped), else one.
90
+ *
91
+ * @param {unknown} requested
92
+ * @returns {number}
93
+ */
94
+ export function resolveMaxResumes(requested) {
95
+ return Number.isInteger(requested) ? Math.min(MAX_RESUMES_CAP, Math.max(0, requested)) : MAX_RESUMES;
96
+ }
85
97
  const DAY_MS = 24 * 60 * 60 * 1000;
86
98
 
87
99
  /**
@@ -277,6 +289,21 @@ export function resolveWaitBudget(budgetMs, fallbackMs) {
277
289
  return 30000;
278
290
  }
279
291
 
292
+ /**
293
+ * The api a `reconnect` callback handed back, if it handed one.
294
+ *
295
+ * `true` means "the same listener is live again" (the CLI re-opens the socket on
296
+ * the api it already has). `{api}` means the caller recovered by building a new
297
+ * api, as a daemon does after logging in again, and the restore has to carry on
298
+ * over that api's listener instead.
299
+ *
300
+ * @param {unknown} back - what `opts.reconnect()` resolved
301
+ * @returns {object|null} the new api, or null when the old one is still the one
302
+ */
303
+ export function reconnectedApi(back) {
304
+ return back && typeof back === "object" && back.api?.listener ? back.api : null;
305
+ }
306
+
280
307
  /**
281
308
  * Which shards still have to go out, and whether a dropped socket is worth one
282
309
  * more reconnect.
@@ -481,17 +508,43 @@ export class SyncV2 {
481
508
  }
482
509
  }
483
510
 
484
- /** Refresh existing DM peers outside the requested message window, including non-friends. */
485
- async _refreshDmNames(threadMap) {
511
+ /**
512
+ * Refresh existing DM peers outside the requested message window, including non-friends.
513
+ *
514
+ * Every restore used to look up every non-friend DM peer in the cache: on a busy account that
515
+ * is thousands of ids, each restore, and Zalo answered with a limit. So the run asks for at most
516
+ * `maxLookups` of them (500 unless ZALO_SYNC_NAME_REFRESH_MAX says otherwise), peers whose
517
+ * cached title is missing or just their id first, because those are the ones a restore can
518
+ * actually improve. The rest keep the titles they have until a later restore reaches them.
519
+ *
520
+ * @param {Map<string, object>} threadMap
521
+ * @param {(message: string) => void} [log]
522
+ */
523
+ async _refreshDmNames(threadMap, log = () => {}) {
486
524
  const profiles = new Map(
487
525
  [...threadMap.values()]
488
526
  .filter((t) => t.type === "dm" && t.zaloName)
489
527
  .map((t) => [t.id, { zaloName: t.zaloName }]),
490
528
  );
529
+ const peers = getRecentThreads(-1, "dm");
530
+ const unknown = (t) => !t.name || String(t.name) === String(t.threadId);
531
+ const wanted = peers.filter((t) => !profiles.has(String(t.threadId)));
532
+ wanted.sort((a, b) => Number(unknown(b)) - Number(unknown(a)));
533
+ const envCap = Number.parseInt(process.env.ZALO_SYNC_NAME_REFRESH_MAX ?? "", 10);
534
+ const maxLookups = Number.isFinite(envCap) && envCap >= 0 ? envCap : 500;
535
+ const chosen = wanted.slice(0, maxLookups);
536
+ if (wanted.length) {
537
+ log(
538
+ `refreshing ${chosen.length} of ${wanted.length} non-friend DM peer name(s)` +
539
+ (chosen.length < wanted.length
540
+ ? ` (cap ${maxLookups}; the rest keep their cached titles until a later restore)`
541
+ : ""),
542
+ );
543
+ }
491
544
  const names = await resolveCurrentUserNames(
492
545
  this.api,
493
- getRecentThreads(-1, "dm").map((t) => String(t.threadId)),
494
- { aliases: this.userAliases, profiles },
546
+ [...profiles.keys(), ...chosen.map((t) => String(t.threadId))],
547
+ { aliases: this.userAliases, profiles, log },
495
548
  );
496
549
  for (const [id, current] of names) {
497
550
  upsertThread({ threadId: id, type: "dm", name: current.name, lastUpdate: 0 });
@@ -561,9 +614,13 @@ export class SyncV2 {
561
614
  * @param {boolean} [opts.liveStore=true] false when the caller already taps live traffic
562
615
  * @param {number|false} [opts.keepAlive] heartbeat interval in ms, or false when the
563
616
  * caller already pings this socket for the whole window it owns.
564
- * @param {() => Promise<boolean>} [opts.reconnect] re-open the listener socket after a
565
- * drop and resolve true once it is live. Supplied by the caller because this class
617
+ * @param {number} [opts.maxResumes] reconnects this run may spend (default 1, at most 5); each one
618
+ * that needs the phone's confirmation again costs the owner a tap.
619
+ * @param {() => Promise<boolean|{api: object}>} [opts.reconnect] re-open the listener socket
620
+ * after a drop and resolve true once it is live. Supplied by the caller because this class
566
621
  * never starts or stops the listener itself. Without it a dropped socket ends the run.
622
+ * A caller that cannot reuse the old listener (a daemon, which re-logs-in and builds a new
623
+ * api) resolves `{api}` instead: the restore carries on over that api's listener.
567
624
  * @returns {Promise<{conversations:number, messagesSaved:number, threadsMapped:number, threadsUnmapped:number, reason:string, days:number|null, from:number, confirmed:boolean, resumes:number, rePrompted:boolean}>}
568
625
  */
569
626
  async restore(opts = {}) {
@@ -586,10 +643,12 @@ export class SyncV2 {
586
643
 
587
644
  initDb(resolve(this.accountDir, "zalo.db"));
588
645
  this._refreshThreadNames(threadMap);
589
- await this._refreshDmNames(threadMap);
646
+ await this._refreshDmNames(threadMap, log);
590
647
  await this._refreshSelfName(opts.onSelfName);
591
648
 
592
- const L = this.api.listener;
649
+ // `let`: a daemon recovers a lost socket by logging in again, which builds a new api and a new
650
+ // listener, and `opts.reconnect` can hand that api back (see `reconnect` below).
651
+ let L = this.api.listener;
593
652
  const sessions = new Map(); // syncId -> {kind, batches:[], statuses:[], done, err}
594
653
  const onMsg = async (buf) => {
595
654
  if (!(buf instanceof Buffer) || buf.length < 4 || buf.readUInt16LE(1) !== 601) return;
@@ -734,7 +793,14 @@ export class SyncV2 {
734
793
  detachSocketTaps();
735
794
  let ok = false;
736
795
  try {
737
- ok = Boolean(await opts.reconnect());
796
+ const back = await opts.reconnect();
797
+ ok = Boolean(back);
798
+ // A new api means a new listener: the old one's socket is gone for good.
799
+ const next = ok ? reconnectedApi(back) : null;
800
+ if (next) {
801
+ this.api = next;
802
+ L = next.listener;
803
+ }
738
804
  } catch (e) {
739
805
  onStatus({ phase: "warn", detail: `reconnect failed: ${e?.message || e}` });
740
806
  return false;
@@ -1072,7 +1138,7 @@ export class SyncV2 {
1072
1138
  // to reconnect -- this class never starts the listener itself.
1073
1139
  const schedule = createShardSchedule(shards.length, {
1074
1140
  waveSize,
1075
- maxResumes: typeof opts.reconnect === "function" ? MAX_RESUMES : 0,
1141
+ maxResumes: typeof opts.reconnect === "function" ? resolveMaxResumes(opts.maxResumes) : 0,
1076
1142
  });
1077
1143
 
1078
1144
  // ---- wave by wave: send, wait, decrypt, decode, store ----
@@ -1228,7 +1294,7 @@ export class SyncV2 {
1228
1294
  runDeadline = Date.now() + shardBudget(schedule.remaining());
1229
1295
  onStatus({
1230
1296
  phase: "reconnect",
1231
- detail: `reconnected — re-requesting the ${schedule.remaining()} batch(es) that never arrived (attempt ${schedule.resumes()} of ${MAX_RESUMES})`,
1297
+ detail: `reconnected — re-requesting the ${schedule.remaining()} batch(es) that never arrived (attempt ${schedule.resumes()} of ${resolveMaxResumes(opts.maxResumes)})`,
1232
1298
  });
1233
1299
  continue;
1234
1300
  }