@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.
- package/README.md +41 -33
- package/node_modules/zca-js/dist/apis.js +0 -8
- package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
- package/package.json +3 -3
- package/patches/zca-js+2.2.0.patch +4 -236
- package/src/commands/bank.js +126 -0
- package/src/commands/catalog.js +14 -0
- package/src/commands/conv.js +19 -0
- package/src/commands/friend.js +66 -21
- package/src/commands/group.js +38 -2
- package/src/commands/listen.js +71 -4
- package/src/commands/mcp.js +252 -4
- package/src/commands/msg.js +60 -0
- package/src/commands/profile.js +39 -0
- package/src/commands/sync.js +1 -68
- package/src/core/bank-accounts.js +52 -0
- package/src/core/current-user-names.js +27 -7
- package/src/core/daemon-channel.js +191 -2
- package/src/core/daemon-log.js +250 -0
- package/src/core/daemon-sync.js +140 -9
- package/src/core/friend-requests.js +142 -0
- package/src/core/friend-search.js +41 -0
- package/src/core/group-admin.js +116 -0
- package/src/core/group-invite.js +53 -0
- package/src/core/sync-v2/gid.js +20 -20
- package/src/core/sync-v2/index.js +77 -11
- package/src/core/sync.js +4 -235
- package/src/core/user-info.js +170 -0
- package/src/index.js +2 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-tools.js +302 -2
- package/src/utils/cli-args.js +84 -0
- package/src/utils/secrets.js +69 -0
- package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
- package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
- package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
- package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
- package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
- 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
|
+
}
|
package/src/core/sync-v2/gid.js
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
485
|
-
|
|
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
|
-
|
|
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 {
|
|
565
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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" ?
|
|
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 ${
|
|
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
|
}
|