@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.3

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 (35) 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 +51 -2
  12. package/src/commands/mcp.js +232 -2
  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/daemon-channel.js +191 -2
  18. package/src/core/friend-requests.js +142 -0
  19. package/src/core/friend-search.js +41 -0
  20. package/src/core/group-admin.js +116 -0
  21. package/src/core/group-invite.js +53 -0
  22. package/src/core/sync.js +4 -235
  23. package/src/index.js +2 -0
  24. package/src/mcp/mcp-config.js +1 -0
  25. package/src/mcp/mcp-tools.js +302 -2
  26. package/src/utils/cli-args.js +84 -0
  27. package/src/utils/secrets.js +69 -0
  28. package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
  29. package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
  30. package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
  31. package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
  32. package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
  33. package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
  34. package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
  35. package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
@@ -20,8 +20,11 @@ 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";
26
29
  import { createSyncJobs } from "../mcp/sync-jobs.js";
27
30
  import { createSocketTap } from "../core/socket-tap.js";
@@ -211,6 +214,10 @@ export function registerMCPCommands(program) {
211
214
  "-w, --webhook <url>",
212
215
  "POST each watched incoming message as JSON to this URL (or set webhook in mcp-config.json)",
213
216
  )
217
+ .option(
218
+ "--webhook-secret <secret>",
219
+ "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)",
220
+ )
214
221
  .option(
215
222
  "--no-delivered-receipts",
216
223
  "Do not acknowledge received messages as delivered (every Zalo client does; seen receipts are never sent)",
@@ -237,6 +244,14 @@ export function registerMCPCommands(program) {
237
244
  // An empty --auth is a token that went missing -- an unset variable
238
245
  // in a unit file -- not a request for no auth, and it used to start
239
246
  // the server open, saying nothing. Refused before anything logs in.
247
+ // The same for a webhook secret: empty must not mean "unsigned".
248
+ if (opts.webhookSecret !== undefined) {
249
+ try {
250
+ resolveWebhookSecret({ flag: opts.webhookSecret });
251
+ } catch (e) {
252
+ failStart(e.message);
253
+ }
254
+ }
240
255
  if (opts.http && opts.auth !== undefined && !String(opts.auth).trim()) {
241
256
  failStart(
242
257
  "--auth was given an empty token. Refusing to start: that would serve this account's " +
@@ -557,8 +572,14 @@ export function registerMCPCommands(program) {
557
572
  envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) ||
558
573
  config.webhook?.trim() ||
559
574
  null;
560
- const webhookSecret =
561
- envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId) || config?.webhookSecret?.trim() || null;
575
+ const webhookSecret = resolveWebhookSecret({
576
+ flag: opts.webhookSecret,
577
+ env: envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId),
578
+ config: config?.webhookSecret,
579
+ });
580
+ if (webhookSecret && !webhookUrl) {
581
+ console.error("[mcp] A webhook secret is set but no webhook URL: nothing will be signed or sent.");
582
+ }
562
583
  if (webhookUrl) {
563
584
  console.error(`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
564
585
  }
@@ -874,6 +895,70 @@ export function registerMCPCommands(program) {
874
895
  // The self-heal's lock: a stage and a catch-up never share the socket.
875
896
  lock: stageLock,
876
897
  onStop: shutdown,
898
+ onWait: async (params, signal) => {
899
+ const cleanThreadId =
900
+ typeof params?.threadId === "string" && params.threadId.trim()
901
+ ? params.threadId.trim()
902
+ : null;
903
+ const consumer =
904
+ typeof params?.consumer === "string" && params.consumer.trim()
905
+ ? params.consumer.trim()
906
+ : undefined;
907
+ const clampedSec = Math.max(1, Math.min(300, Number(params?.timeoutSec) || 55));
908
+ const explicitSince =
909
+ typeof params?.since === "number" && Number.isFinite(params.since)
910
+ ? params.since
911
+ : undefined;
912
+ const effectiveSince =
913
+ explicitSince !== undefined
914
+ ? explicitSince
915
+ : consumer
916
+ ? buffer.readCursor(consumer)
917
+ : undefined;
918
+ const msg = await buffer.waitForMessage({
919
+ threadId: cleanThreadId,
920
+ since: effectiveSince,
921
+ timeoutMs: clampedSec * 1000,
922
+ signal,
923
+ });
924
+ if (msg) {
925
+ const tid = String(msg.threadId || "");
926
+ const threadName = nameCache?.get(tid)?.name || null;
927
+ return {
928
+ status: "message",
929
+ message: {
930
+ ...msg,
931
+ ...(threadName ? { threadName } : {}),
932
+ },
933
+ cursor: msg._cursor,
934
+ };
935
+ }
936
+ return {
937
+ status: "timeout",
938
+ message: null,
939
+ cursor: Math.max(
940
+ effectiveSince ?? 0,
941
+ Number(buffer._globalCursor) || 0,
942
+ buffer.readCursor(consumer),
943
+ ),
944
+ };
945
+ },
946
+ onMarkRead: async (params) => {
947
+ const cursorNum = Number(params?.cursor);
948
+ if (!Number.isInteger(cursorNum) || cursorNum < 0) {
949
+ throw new Error("cursor must be a non-negative integer");
950
+ }
951
+ const consumer =
952
+ typeof params?.consumer === "string" && params.consumer.trim()
953
+ ? params.consumer.trim()
954
+ : undefined;
955
+ const marked = buffer.markRead(cursorNum, consumer);
956
+ return {
957
+ marked,
958
+ readCursor: buffer.readCursor(consumer),
959
+ consumer: consumer || "default",
960
+ };
961
+ },
877
962
  });
878
963
  console.error(`[mcp] Sync & upload channel ready on 127.0.0.1:${channel.port}`);
879
964
  } catch (e) {
@@ -896,4 +981,149 @@ export function registerMCPCommands(program) {
896
981
  // Keep process alive (MCP server runs on stdio — process must not exit)
897
982
  await new Promise(() => {});
898
983
  });
984
+
985
+ mcp.command("wait [since]")
986
+ .description(
987
+ "Zero-CPU long-poll on the running MCP server's live message buffer (CLI equivalent of zalo_wait_for_message)",
988
+ )
989
+ .option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
990
+ .option("-s, --since <cursor>", "Return immediately if a buffered message after this cursor already exists")
991
+ .option("-t, --thread <threadId>", "Only wake on messages from this thread ID")
992
+ .option("--timeout <seconds>", "Maximum seconds to wait before returning timeout (1-300, default: 55)", "55")
993
+ .option(
994
+ "-c, --consumer <name>",
995
+ "Consumer name whose read cursor is used when --since is omitted (matches zalo_mark_read)",
996
+ )
997
+ .action(async (sinceArg, opts) => {
998
+ const jsonMode = program.opts().json;
999
+ const rawSince = opts.since !== undefined ? opts.since : sinceArg;
1000
+ let parsedSince;
1001
+ if (rawSince !== undefined) {
1002
+ const n = Number(rawSince);
1003
+ if (!Number.isInteger(n) || n < 0 || !/^\d+$/.test(String(rawSince).trim())) {
1004
+ error(`Invalid cursor "${rawSince}": must be a non-negative integer.`);
1005
+ process.exit(1);
1006
+ }
1007
+ parsedSince = n;
1008
+ }
1009
+ const rawTimeout = Number(opts.timeout);
1010
+ if (!Number.isInteger(rawTimeout) || rawTimeout < 1 || rawTimeout > 300) {
1011
+ error(`Invalid --timeout "${opts.timeout}": must be an integer between 1 and 300 seconds.`);
1012
+ process.exit(1);
1013
+ }
1014
+ const consumer = opts.consumer?.trim() || undefined;
1015
+ if (consumer && !/^[A-Za-z0-9._:@-]{1,64}$/.test(consumer)) {
1016
+ error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
1017
+ process.exit(1);
1018
+ }
1019
+
1020
+ let activeAcc;
1021
+ try {
1022
+ const requestedAccount =
1023
+ opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
1024
+ activeAcc = selectDaemonAccount(requestedAccount);
1025
+ } catch (e) {
1026
+ error(`Account selection failed: ${e.message}`);
1027
+ process.exit(1);
1028
+ }
1029
+ const accountDir = getAccountDir(activeAcc.ownId);
1030
+ const res = await waitViaDaemon(accountDir, {
1031
+ threadId: opts.thread?.trim() || null,
1032
+ since: parsedSince,
1033
+ timeoutSec: rawTimeout,
1034
+ consumer,
1035
+ });
1036
+ if (!res) {
1037
+ error(
1038
+ `No running MCP server found for account ${activeAcc.ownId}. Start one with: zalo-agent mcp start`,
1039
+ );
1040
+ process.exit(1);
1041
+ }
1042
+ if (!res.ok) {
1043
+ error(`Wait failed: ${res.error}`);
1044
+ process.exit(1);
1045
+ }
1046
+ output(
1047
+ {
1048
+ status: res.status,
1049
+ message: res.message,
1050
+ cursor: res.cursor,
1051
+ },
1052
+ jsonMode,
1053
+ () => {
1054
+ if (res.status === "message" && res.message) {
1055
+ const m = res.message;
1056
+ const label = m.threadName ? `${m.threadName} (${m.threadId})` : m.threadId;
1057
+ success(
1058
+ `[cursor ${res.cursor}] ${m.senderName || m.senderId} in ${label}: ${m.text || `(${m.type})`}`,
1059
+ );
1060
+ } else {
1061
+ info(`Timeout after ${rawTimeout}s (cursor: ${res.cursor})`);
1062
+ }
1063
+ },
1064
+ );
1065
+ });
1066
+
1067
+ mcp.command("mark-read <cursor>")
1068
+ .description(
1069
+ "Advance a consumer's read cursor in the running MCP server's live buffer (CLI equivalent of zalo_mark_read)",
1070
+ )
1071
+ .option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
1072
+ .option(
1073
+ "-c, --consumer <name>",
1074
+ 'Consumer name whose read cursor to advance (default: "default"; deletes nothing for other consumers)',
1075
+ )
1076
+ .action(async (cursorArg, opts) => {
1077
+ const jsonMode = program.opts().json;
1078
+ const trimmedCursor = String(cursorArg ?? "").trim();
1079
+ const parsedCursor = Number(trimmedCursor);
1080
+ if (!Number.isInteger(parsedCursor) || parsedCursor < 0 || !/^\d+$/.test(trimmedCursor)) {
1081
+ error(`Invalid cursor "${cursorArg}": must be a non-negative integer.`);
1082
+ process.exit(1);
1083
+ }
1084
+ const consumer = opts.consumer?.trim() || undefined;
1085
+ if (consumer && !/^[A-Za-z0-9._:@-]{1,64}$/.test(consumer)) {
1086
+ error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
1087
+ process.exit(1);
1088
+ }
1089
+
1090
+ let activeAcc;
1091
+ try {
1092
+ const requestedAccount =
1093
+ opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
1094
+ activeAcc = selectDaemonAccount(requestedAccount);
1095
+ } catch (e) {
1096
+ error(`Account selection failed: ${e.message}`);
1097
+ process.exit(1);
1098
+ }
1099
+ const accountDir = getAccountDir(activeAcc.ownId);
1100
+ const res = await markReadViaDaemon(accountDir, {
1101
+ cursor: parsedCursor,
1102
+ consumer,
1103
+ });
1104
+ if (!res) {
1105
+ error(
1106
+ `No running MCP server found for account ${activeAcc.ownId}. Start one with: zalo-agent mcp start`,
1107
+ );
1108
+ process.exit(1);
1109
+ }
1110
+ if (!res.ok) {
1111
+ error(`Mark-read failed: ${res.error}`);
1112
+ process.exit(1);
1113
+ }
1114
+ output(
1115
+ {
1116
+ success: true,
1117
+ marked: res.marked,
1118
+ readCursor: res.readCursor,
1119
+ consumer: res.consumer,
1120
+ },
1121
+ jsonMode,
1122
+ () => {
1123
+ success(
1124
+ `Marked ${res.marked} message(s) read for consumer "${res.consumer}" (readCursor: ${res.readCursor})`,
1125
+ );
1126
+ },
1127
+ );
1128
+ });
899
1129
  }
@@ -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
+ }