@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
@@ -20,9 +20,14 @@ import {
20
20
  getDaemonStatus,
21
21
  stopDaemon,
22
22
  spawnDaemonProcess,
23
+ waitViaDaemon,
24
+ markReadViaDaemon,
23
25
  } from "../core/daemon-channel.js";
24
26
  import { success, error, info, output } from "../utils/output.js";
27
+ import { resolveWebhookSecret } from "../utils/secrets.js";
25
28
  import { createSyncRunners } from "../core/daemon-sync.js";
29
+ import { createSocketContext, startEventLoopMonitor } from "../core/daemon-log.js";
30
+ import { setUserInfoLogger } from "../core/user-info.js";
26
31
  import { createSyncJobs } from "../mcp/sync-jobs.js";
27
32
  import { createSocketTap } from "../core/socket-tap.js";
28
33
  import { createSelfHeal } from "../core/self-heal.js";
@@ -211,6 +216,10 @@ export function registerMCPCommands(program) {
211
216
  "-w, --webhook <url>",
212
217
  "POST each watched incoming message as JSON to this URL (or set webhook in mcp-config.json)",
213
218
  )
219
+ .option(
220
+ "--webhook-secret <secret>",
221
+ "Sign webhook POSTs: sent as `Authorization: Bearer <secret>` and `X-Zalo-Signature: sha256=<HMAC of the body>`. Prefer the ZALO_WEBHOOK_SECRET[_<ownId>] environment variable: a flag is visible in the process list (or set webhookSecret in mcp-config.json)",
222
+ )
214
223
  .option(
215
224
  "--no-delivered-receipts",
216
225
  "Do not acknowledge received messages as delivered (every Zalo client does; seen receipts are never sent)",
@@ -237,6 +246,14 @@ export function registerMCPCommands(program) {
237
246
  // An empty --auth is a token that went missing -- an unset variable
238
247
  // in a unit file -- not a request for no auth, and it used to start
239
248
  // the server open, saying nothing. Refused before anything logs in.
249
+ // The same for a webhook secret: empty must not mean "unsigned".
250
+ if (opts.webhookSecret !== undefined) {
251
+ try {
252
+ resolveWebhookSecret({ flag: opts.webhookSecret });
253
+ } catch (e) {
254
+ failStart(e.message);
255
+ }
256
+ }
240
257
  if (opts.http && opts.auth !== undefined && !String(opts.auth).trim()) {
241
258
  failStart(
242
259
  "--auth was given an empty token. Refusing to start: that would serve this account's " +
@@ -447,9 +464,24 @@ export function registerMCPCommands(program) {
447
464
  // the server so the tools can hold it too.
448
465
  const onSelfName = (name) => updateAccountName(activeAcc.ownId, name);
449
466
  const stageLock = createStageLock();
467
+ // What daemon.log learns beyond one-line events: every stage's progress (the restore used to
468
+ // report only to the caller), the event loop's stalls (a blocked loop cannot answer the socket's
469
+ // pings, and a bare close 1006 says nothing else), and what Zalo said when a lookup was refused.
470
+ const daemonLog = (m) => console.error(`[mcp] ${m}`);
471
+ const loopMonitor = startEventLoopMonitor({
472
+ log: daemonLog,
473
+ context: () => {
474
+ const held = stageLock.current();
475
+ return held ? `the ${held.stage} stage was running` : "";
476
+ },
477
+ });
478
+ const socketContext = createSocketContext({ lock: stageLock, monitor: loopMonitor });
479
+ setUserInfoLogger(daemonLog);
450
480
  const syncRunners = createSyncRunners({
451
481
  getApi,
452
482
  accountName: activeAcc.ownId,
483
+ log: daemonLog,
484
+ monitor: loopMonitor,
453
485
  onSelfName,
454
486
  onMessagesRestored: () => {
455
487
  for (const [tid, info] of getThreadNames()) {
@@ -557,8 +589,14 @@ export function registerMCPCommands(program) {
557
589
  envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) ||
558
590
  config.webhook?.trim() ||
559
591
  null;
560
- const webhookSecret =
561
- envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId) || config?.webhookSecret?.trim() || null;
592
+ const webhookSecret = resolveWebhookSecret({
593
+ flag: opts.webhookSecret,
594
+ env: envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId),
595
+ config: config?.webhookSecret,
596
+ });
597
+ if (webhookSecret && !webhookUrl) {
598
+ console.error("[mcp] A webhook secret is set but no webhook URL: nothing will be signed or sent.");
599
+ }
562
600
  if (webhookUrl) {
563
601
  console.error(`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
564
602
  }
@@ -769,6 +807,7 @@ export function registerMCPCommands(program) {
769
807
  });
770
808
 
771
809
  api.listener.on("connected", () => {
810
+ socketContext.markUp();
772
811
  if (reconnectCount > 0) {
773
812
  console.error(`[mcp] Reconnected (#${reconnectCount})`);
774
813
  }
@@ -777,7 +816,7 @@ export function registerMCPCommands(program) {
777
816
 
778
817
  api.listener.on("disconnected", (code) => {
779
818
  if (lifecycle.isStopping()) return;
780
- console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying...`);
819
+ console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying... (${socketContext.describe()})`);
781
820
  lifecycle.noteDown();
782
821
  });
783
822
 
@@ -791,7 +830,7 @@ export function registerMCPCommands(program) {
791
830
  }
792
831
  reconnectCount++;
793
832
  console.error(
794
- `[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount})`,
833
+ `[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount}; ${socketContext.describe()})`,
795
834
  );
796
835
  await new Promise((r) => setTimeout(r, 5000));
797
836
  try {
@@ -874,6 +913,70 @@ export function registerMCPCommands(program) {
874
913
  // The self-heal's lock: a stage and a catch-up never share the socket.
875
914
  lock: stageLock,
876
915
  onStop: shutdown,
916
+ onWait: async (params, signal) => {
917
+ const cleanThreadId =
918
+ typeof params?.threadId === "string" && params.threadId.trim()
919
+ ? params.threadId.trim()
920
+ : null;
921
+ const consumer =
922
+ typeof params?.consumer === "string" && params.consumer.trim()
923
+ ? params.consumer.trim()
924
+ : undefined;
925
+ const clampedSec = Math.max(1, Math.min(300, Number(params?.timeoutSec) || 55));
926
+ const explicitSince =
927
+ typeof params?.since === "number" && Number.isFinite(params.since)
928
+ ? params.since
929
+ : undefined;
930
+ const effectiveSince =
931
+ explicitSince !== undefined
932
+ ? explicitSince
933
+ : consumer
934
+ ? buffer.readCursor(consumer)
935
+ : undefined;
936
+ const msg = await buffer.waitForMessage({
937
+ threadId: cleanThreadId,
938
+ since: effectiveSince,
939
+ timeoutMs: clampedSec * 1000,
940
+ signal,
941
+ });
942
+ if (msg) {
943
+ const tid = String(msg.threadId || "");
944
+ const threadName = nameCache?.get(tid)?.name || null;
945
+ return {
946
+ status: "message",
947
+ message: {
948
+ ...msg,
949
+ ...(threadName ? { threadName } : {}),
950
+ },
951
+ cursor: msg._cursor,
952
+ };
953
+ }
954
+ return {
955
+ status: "timeout",
956
+ message: null,
957
+ cursor: Math.max(
958
+ effectiveSince ?? 0,
959
+ Number(buffer._globalCursor) || 0,
960
+ buffer.readCursor(consumer),
961
+ ),
962
+ };
963
+ },
964
+ onMarkRead: async (params) => {
965
+ const cursorNum = Number(params?.cursor);
966
+ if (!Number.isInteger(cursorNum) || cursorNum < 0) {
967
+ throw new Error("cursor must be a non-negative integer");
968
+ }
969
+ const consumer =
970
+ typeof params?.consumer === "string" && params.consumer.trim()
971
+ ? params.consumer.trim()
972
+ : undefined;
973
+ const marked = buffer.markRead(cursorNum, consumer);
974
+ return {
975
+ marked,
976
+ readCursor: buffer.readCursor(consumer),
977
+ consumer: consumer || "default",
978
+ };
979
+ },
877
980
  });
878
981
  console.error(`[mcp] Sync & upload channel ready on 127.0.0.1:${channel.port}`);
879
982
  } catch (e) {
@@ -896,4 +999,149 @@ export function registerMCPCommands(program) {
896
999
  // Keep process alive (MCP server runs on stdio — process must not exit)
897
1000
  await new Promise(() => {});
898
1001
  });
1002
+
1003
+ mcp.command("wait [since]")
1004
+ .description(
1005
+ "Zero-CPU long-poll on the running MCP server's live message buffer (CLI equivalent of zalo_wait_for_message)",
1006
+ )
1007
+ .option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
1008
+ .option("-s, --since <cursor>", "Return immediately if a buffered message after this cursor already exists")
1009
+ .option("-t, --thread <threadId>", "Only wake on messages from this thread ID")
1010
+ .option("--timeout <seconds>", "Maximum seconds to wait before returning timeout (1-300, default: 55)", "55")
1011
+ .option(
1012
+ "-c, --consumer <name>",
1013
+ "Consumer name whose read cursor is used when --since is omitted (matches zalo_mark_read)",
1014
+ )
1015
+ .action(async (sinceArg, opts) => {
1016
+ const jsonMode = program.opts().json;
1017
+ const rawSince = opts.since !== undefined ? opts.since : sinceArg;
1018
+ let parsedSince;
1019
+ if (rawSince !== undefined) {
1020
+ const n = Number(rawSince);
1021
+ if (!Number.isInteger(n) || n < 0 || !/^\d+$/.test(String(rawSince).trim())) {
1022
+ error(`Invalid cursor "${rawSince}": must be a non-negative integer.`);
1023
+ process.exit(1);
1024
+ }
1025
+ parsedSince = n;
1026
+ }
1027
+ const rawTimeout = Number(opts.timeout);
1028
+ if (!Number.isInteger(rawTimeout) || rawTimeout < 1 || rawTimeout > 300) {
1029
+ error(`Invalid --timeout "${opts.timeout}": must be an integer between 1 and 300 seconds.`);
1030
+ process.exit(1);
1031
+ }
1032
+ const consumer = opts.consumer?.trim() || undefined;
1033
+ if (consumer && !/^[A-Za-z0-9._:@-]{1,64}$/.test(consumer)) {
1034
+ error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
1035
+ process.exit(1);
1036
+ }
1037
+
1038
+ let activeAcc;
1039
+ try {
1040
+ const requestedAccount =
1041
+ opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
1042
+ activeAcc = selectDaemonAccount(requestedAccount);
1043
+ } catch (e) {
1044
+ error(`Account selection failed: ${e.message}`);
1045
+ process.exit(1);
1046
+ }
1047
+ const accountDir = getAccountDir(activeAcc.ownId);
1048
+ const res = await waitViaDaemon(accountDir, {
1049
+ threadId: opts.thread?.trim() || null,
1050
+ since: parsedSince,
1051
+ timeoutSec: rawTimeout,
1052
+ consumer,
1053
+ });
1054
+ if (!res) {
1055
+ error(
1056
+ `No running MCP server found for account ${activeAcc.ownId}. Start one with: zalo-agent mcp start`,
1057
+ );
1058
+ process.exit(1);
1059
+ }
1060
+ if (!res.ok) {
1061
+ error(`Wait failed: ${res.error}`);
1062
+ process.exit(1);
1063
+ }
1064
+ output(
1065
+ {
1066
+ status: res.status,
1067
+ message: res.message,
1068
+ cursor: res.cursor,
1069
+ },
1070
+ jsonMode,
1071
+ () => {
1072
+ if (res.status === "message" && res.message) {
1073
+ const m = res.message;
1074
+ const label = m.threadName ? `${m.threadName} (${m.threadId})` : m.threadId;
1075
+ success(
1076
+ `[cursor ${res.cursor}] ${m.senderName || m.senderId} in ${label}: ${m.text || `(${m.type})`}`,
1077
+ );
1078
+ } else {
1079
+ info(`Timeout after ${rawTimeout}s (cursor: ${res.cursor})`);
1080
+ }
1081
+ },
1082
+ );
1083
+ });
1084
+
1085
+ mcp.command("mark-read <cursor>")
1086
+ .description(
1087
+ "Advance a consumer's read cursor in the running MCP server's live buffer (CLI equivalent of zalo_mark_read)",
1088
+ )
1089
+ .option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
1090
+ .option(
1091
+ "-c, --consumer <name>",
1092
+ 'Consumer name whose read cursor to advance (default: "default"; deletes nothing for other consumers)',
1093
+ )
1094
+ .action(async (cursorArg, opts) => {
1095
+ const jsonMode = program.opts().json;
1096
+ const trimmedCursor = String(cursorArg ?? "").trim();
1097
+ const parsedCursor = Number(trimmedCursor);
1098
+ if (!Number.isInteger(parsedCursor) || parsedCursor < 0 || !/^\d+$/.test(trimmedCursor)) {
1099
+ error(`Invalid cursor "${cursorArg}": must be a non-negative integer.`);
1100
+ process.exit(1);
1101
+ }
1102
+ const consumer = opts.consumer?.trim() || undefined;
1103
+ if (consumer && !/^[A-Za-z0-9._:@-]{1,64}$/.test(consumer)) {
1104
+ error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
1105
+ process.exit(1);
1106
+ }
1107
+
1108
+ let activeAcc;
1109
+ try {
1110
+ const requestedAccount =
1111
+ opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
1112
+ activeAcc = selectDaemonAccount(requestedAccount);
1113
+ } catch (e) {
1114
+ error(`Account selection failed: ${e.message}`);
1115
+ process.exit(1);
1116
+ }
1117
+ const accountDir = getAccountDir(activeAcc.ownId);
1118
+ const res = await markReadViaDaemon(accountDir, {
1119
+ cursor: parsedCursor,
1120
+ consumer,
1121
+ });
1122
+ if (!res) {
1123
+ error(
1124
+ `No running MCP server found for account ${activeAcc.ownId}. Start one with: zalo-agent mcp start`,
1125
+ );
1126
+ process.exit(1);
1127
+ }
1128
+ if (!res.ok) {
1129
+ error(`Mark-read failed: ${res.error}`);
1130
+ process.exit(1);
1131
+ }
1132
+ output(
1133
+ {
1134
+ success: true,
1135
+ marked: res.marked,
1136
+ readCursor: res.readCursor,
1137
+ consumer: res.consumer,
1138
+ },
1139
+ jsonMode,
1140
+ () => {
1141
+ success(
1142
+ `Marked ${res.marked} message(s) read for consumer "${res.consumer}" (readCursor: ${res.readCursor})`,
1143
+ );
1144
+ },
1145
+ );
1146
+ });
899
1147
  }
@@ -10,6 +10,7 @@ import { getApi, getOwnId } from "../core/zalo-client.js";
10
10
  import { withCurrentSenderNames } from "../core/current-user-names.js";
11
11
  import { success, error, info, output, warning } from "../utils/output.js";
12
12
  import { parseIntOption } from "../utils/parse-options.js";
13
+ import { parseThreadTypeArg, parseReportReason } from "../utils/cli-args.js";
13
14
  import { getActive } from "../core/accounts.js";
14
15
  import { getAccountDir, getAccountDbPath } from "../core/credentials.js";
15
16
  import { initDb, getMessages, getMessageById, getDisplayName, getThreadType } from "../core/db.js";
@@ -1753,4 +1754,63 @@ export function registerMsgCommands(program) {
1753
1754
  process.exit(1);
1754
1755
  }
1755
1756
  });
1757
+
1758
+ msg.command("typing <threadId>")
1759
+ .description('Show the "typing…" indicator in a conversation')
1760
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
1761
+ .action(async (threadId, opts) => {
1762
+ try {
1763
+ const result = await getApi().sendTypingEvent(threadId, parseThreadTypeArg(opts.type));
1764
+ output(result, program.opts().json, () => success("Typing indicator sent"));
1765
+ } catch (e) {
1766
+ error(e.message);
1767
+ }
1768
+ });
1769
+
1770
+ msg.command("report <threadId>")
1771
+ .description("Report a user (-t 0) or a group (-t 1) to Zalo")
1772
+ .requiredOption("-r, --reason <reason>", "sensitive, annoy, fraud or other")
1773
+ .option("-c, --content <text>", "What happened (required with --reason other, refused otherwise)")
1774
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
1775
+ .action(async (threadId, opts) => {
1776
+ try {
1777
+ const options = parseReportReason(opts.reason, opts.content);
1778
+ const result = await getApi().sendReport(options, threadId, parseThreadTypeArg(opts.type));
1779
+ output(result, program.opts().json, () => success(`Report sent (id ${result?.reportId ?? "?"})`));
1780
+ } catch (e) {
1781
+ error(e.message);
1782
+ }
1783
+ });
1784
+
1785
+ msg.command("link-preview <url>")
1786
+ .description("Fetch the preview Zalo would show for a link (title, description, thumbnail)")
1787
+ .action(async (url) => {
1788
+ try {
1789
+ const result = await getApi().parseLink(url);
1790
+ output(result, program.opts().json, () => {
1791
+ const d = result?.data ?? {};
1792
+ info(`Title: ${d.title || "-"}`);
1793
+ info(`Description: ${d.desc || "-"}`);
1794
+ info(`Source: ${d.src || d.href || "-"}`);
1795
+ if (d.thumb) info(`Thumbnail: ${d.thumb}`);
1796
+ });
1797
+ } catch (e) {
1798
+ error(`Link preview failed: ${e.message}`);
1799
+ }
1800
+ });
1801
+
1802
+ msg.command("scan-url <url>")
1803
+ .description("Ask Zalo whether a URL is safe to open")
1804
+ .action(async (url) => {
1805
+ try {
1806
+ const result = await getApi().scanURL(url);
1807
+ output(result, program.opts().json, () =>
1808
+ result?.isSafe
1809
+ ? success("Zalo reports this URL as safe")
1810
+ : warning("Zalo flags this URL as unsafe"),
1811
+ );
1812
+ } catch (e) {
1813
+ error(`URL scan failed: ${e.message}`);
1814
+ }
1815
+ });
1756
1816
  }
@@ -7,6 +7,7 @@ import { getApi, getOwnId } from "../core/zalo-client.js";
7
7
  import { updateAccountName } from "../core/accounts.js";
8
8
  import { success, error, info, output } from "../utils/output.js";
9
9
  import { parseIntOption } from "../utils/parse-options.js";
10
+ import { parseOnOff, parseLanguage } from "../utils/cli-args.js";
10
11
 
11
12
  /** Valid privacy setting keys and their human-readable labels + value descriptions. */
12
13
  const SETTING_MAP = {
@@ -276,4 +277,42 @@ export function registerProfileCommands(program) {
276
277
  error(`Setting update failed: ${e.message}`);
277
278
  }
278
279
  });
280
+
281
+ profile
282
+ .command("active <on|off>")
283
+ .description("Turn your online (active) status on or off")
284
+ .action(async (state) => {
285
+ try {
286
+ const active = parseOnOff(state, "active");
287
+ const result = await getApi().updateActiveStatus(active);
288
+ output(result, program.opts().json, () => success(`Active status: ${active ? "on" : "off"}`));
289
+ } catch (e) {
290
+ error(e.message);
291
+ }
292
+ });
293
+
294
+ profile
295
+ .command("lang <language>")
296
+ .description("Set the account language: vi or en")
297
+ .action(async (language) => {
298
+ try {
299
+ const lang = parseLanguage(language);
300
+ const result = await getApi().updateLang(lang);
301
+ output(result, program.opts().json, () => success(`Language set to ${lang}`));
302
+ } catch (e) {
303
+ error(e.message);
304
+ }
305
+ });
306
+
307
+ profile
308
+ .command("lost-focus")
309
+ .description("Tell Zalo this client moved to the background (what Zalo Web sends when its tab loses focus)")
310
+ .action(async () => {
311
+ try {
312
+ const result = await getApi().lostFocus();
313
+ output(result, program.opts().json, () => success("Sent"));
314
+ } catch (e) {
315
+ error(`Lost focus failed: ${e.message}`);
316
+ }
317
+ });
279
318
  }
@@ -119,8 +119,7 @@ export function registerSyncCommands(program) {
119
119
  "Restore message history from your phone into the local cache (zalo.db). " +
120
120
  "This IS the real phone-backed restore (transfer-sync-v2) by default: it sends one " +
121
121
  "sync request your phone confirms, then decrypts and stores your history. " +
122
- "--socket asks for the old server backfill instead (measured empty); --legacy tries an old " +
123
- "endpoint Zalo Web no longer calls (it answers empty).",
122
+ "--socket asks for the old server backfill instead (measured empty).",
124
123
  )
125
124
  .option(
126
125
  "-t, --transfer",
@@ -147,11 +146,6 @@ export function registerSyncCommands(program) {
147
146
  "The default is already everything your phone still holds, so this is for deliberately " +
148
147
  "narrowing a run, not widening it",
149
148
  )
150
- .option(
151
- "-L, --legacy",
152
- "Try the old pull_mobile_msg/get_crossdb phone-transfer endpoint instead, which Zalo Web no longer " +
153
- "calls and which answers empty. One attempt only — it pings your mobile app",
154
- )
155
149
  .option(
156
150
  "-M, --messages-only",
157
151
  "Restore messages but do NOT fetch their media. By default a transfer sync downloads the attachments it " +
@@ -195,10 +189,6 @@ export function registerSyncCommands(program) {
195
189
  process.exit(1);
196
190
  }
197
191
 
198
- if (opts.legacy) {
199
- await runLegacySync(activeAcc, opts);
200
- return;
201
- }
202
192
  if (opts.socket) {
203
193
  await runSocketBackfill(activeAcc, opts);
204
194
  return;
@@ -1995,60 +1985,3 @@ async function runSocketBackfill(activeAcc, opts) {
1995
1985
 
1996
1986
  process.exit(exitCode);
1997
1987
  }
1998
-
1999
- /**
2000
- * The retired path, kept behind --legacy so the behavior stays inspectable.
2001
- *
2002
- * `/api/message/pull_mobile_msg` and `/api/message/get_crossdb` are still
2003
- * present in Zalo Web's own bundle but have ZERO call sites in it — the client
2004
- * moved to a WebSocket protocol (cmd 590/591, "transfer-sync-v2"). The
2005
- * endpoints answer with an empty payload rather than an error, which is why
2006
- * this used to look like "the phone hasn't replied yet" and retry for two
2007
- * minutes, putting a notification on the owner's phone each time.
2008
- *
2009
- * It runs exactly once now.
2010
- *
2011
- * @param {{ownId: string}} activeAcc
2012
- * @param {{force?: boolean}} opts
2013
- */
2014
- async function runLegacySync(activeAcc, opts) {
2015
- warning(
2016
- "--legacy uses an endpoint Zalo Web no longer calls; it answers empty. This pings your mobile app " +
2017
- "and will most likely find nothing.",
2018
- );
2019
- try {
2020
- const syncManager = new SyncManager(getApi(), activeAcc.ownId);
2021
- const res = await syncManager.pollSync(0, 0, { force: !!opts.force });
2022
-
2023
- switch (res.status) {
2024
- case "already-synced":
2025
- success("Already synced — no known missed messages since the last successful sync.");
2026
- info("Pass --force to check anyway.");
2027
- process.exit(0);
2028
- break;
2029
- case "saved":
2030
- success(`Synced ${res.saved}/${res.total} message(s) from mobile into the local cache.`);
2031
- process.exit(0);
2032
- break;
2033
- case "empty-or-unrecognized":
2034
- success("Sync round-trip completed — nothing new to save.");
2035
- process.exit(0);
2036
- break;
2037
- case "legacy-retired":
2038
- error("The legacy mobile-sync endpoint returned nothing, as it now always does.");
2039
- info("Run `zalo-agent sync-mobile` without --legacy to backfill over the WebSocket instead.");
2040
- process.exit(1);
2041
- break;
2042
- case "crossdb-error":
2043
- error(`Sync failed: ${res.error}`);
2044
- process.exit(1);
2045
- break;
2046
- default:
2047
- error(`Unrecognized sync result: ${res.status}`);
2048
- process.exit(1);
2049
- }
2050
- } catch (err) {
2051
- error(`Failed: ${err.message}`);
2052
- process.exit(1);
2053
- }
2054
- }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The account's saved bank accounts (`bank` commands).
3
+ *
4
+ * Pure of side effects except the reads it is handed an api for.
5
+ */
6
+
7
+ /** Page size used when scanning the saved-account list. */
8
+ const SCAN_BATCH = 50;
9
+
10
+ /**
11
+ * Find one saved bank account by its `account_id`.
12
+ *
13
+ * `deleteBankAccount` needs `isDefault`, which only the list knows, so removing
14
+ * an account starts by finding it. Scans page by page while Zalo says there are
15
+ * more, with a cap so a server that never stops saying so cannot loop us.
16
+ *
17
+ * @param {object} api - logged-in zca-js api
18
+ * @param {number} accountId
19
+ * @returns {Promise<object|null>} the saved account, or null when no page has it
20
+ */
21
+ export async function findBankAccount(api, accountId) {
22
+ for (let page = 0; page < 100; page++) {
23
+ const res = await api.getListBankAccount(page, SCAN_BATCH);
24
+ const hit = (res?.myBanks ?? []).find((b) => Number(b.account_id) === Number(accountId));
25
+ if (hit) return hit;
26
+ if (!res?.hasMore) return null;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * The banks whose name, short name, English name or search keywords contain `query`
33
+ * (case-insensitive substring); all of them for an empty query.
34
+ *
35
+ * @param {object[]} banks - `getListBank` entries
36
+ * @param {string} [query]
37
+ * @returns {object[]}
38
+ */
39
+ export function filterBanks(banks, query) {
40
+ const q = String(query ?? "")
41
+ .trim()
42
+ .toLowerCase();
43
+ const list = Array.isArray(banks) ? banks : [];
44
+ if (!q) return list;
45
+ return list.filter((b) =>
46
+ [b.name, b.short_name, b.name_eng, b.search_key_word, String(b.bin)].some((s) =>
47
+ String(s ?? "")
48
+ .toLowerCase()
49
+ .includes(q),
50
+ ),
51
+ );
52
+ }
@@ -1,3 +1,8 @@
1
+ import { runPacedBatches, userInfoLog } from "./user-info.js";
2
+
3
+ /** The default `log`: silent unless a daemon registered a sink with `setUserInfoLogger`. */
4
+ const NO_LOG = () => {};
5
+
1
6
  /**
2
7
  * Read the account's complete alias snapshot. Do not reuse a previous snapshot:
3
8
  * an absent entry means the owner removed that alias. Failed/partial reads do
@@ -57,23 +62,38 @@ export function currentUserName(uid, profile, aliases = new Map()) {
57
62
  * @param {object} [options]
58
63
  * @returns {Promise<Map<string, {name: string, source: string}>>} UID -> current label
59
64
  */
60
- export async function resolveCurrentUserNames(api, uids, { aliases, profiles = new Map(), log = () => {} } = {}) {
65
+ export async function resolveCurrentUserNames(api, uids, { aliases, profiles = new Map(), log = NO_LOG } = {}) {
61
66
  const ids = [...new Set(uids.filter(Boolean).map(String))];
62
67
  if (!ids.length) return new Map();
63
68
  const aliasRequest = aliases ? Promise.resolve(aliases) : readUserAliases(api, log);
64
69
  const fresh = new Map(profiles);
65
70
  const missing = ids.filter((id) => /^[1-9][0-9]*$/.test(id) && !fresh.has(id));
66
- for (let i = 0; i < missing.length; i += 100) {
67
- try {
68
- if (typeof api?.getUserInfo !== "function") break;
69
- const result = await api.getUserInfo(missing.slice(i, i + 100));
71
+ // A live-API proxy throws on any property read while a session is re-logging in, so even the probe is guarded.
72
+ let canLookup = false;
73
+ try {
74
+ canLookup = typeof api?.getUserInfo === "function";
75
+ } catch {
76
+ /* session is reconnecting: fall back to aliases or UIDs */
77
+ }
78
+ if (missing.length && canLookup) {
79
+ // Paced, retried on a rate limit and stopped when Zalo keeps refusing; what Zalo said is logged.
80
+ const lookup = await runPacedBatches(missing, {
81
+ batchSize: 100,
82
+ label: "user-info",
83
+ log: log === NO_LOG ? undefined : log,
84
+ call: (batch) => api.getUserInfo(batch),
85
+ });
86
+ for (const result of lookup.results) {
70
87
  for (const bag of [result?.changed_profiles, result?.unchanged_profiles]) {
71
88
  for (const [uid, profile] of Object.entries(bag || {})) {
72
89
  if (profile) fresh.set(uid, profile);
73
90
  }
74
91
  }
75
- } catch {
76
- log("current profiles unavailable; falling back to aliases or UIDs");
92
+ }
93
+ if (lookup.failed || lookup.skippedIds) {
94
+ (log === NO_LOG ? userInfoLog : log)(
95
+ "current profiles unavailable for some ids; falling back to aliases or UIDs",
96
+ );
77
97
  }
78
98
  }
79
99
  const ownedAliases = await aliasRequest;