@ardennguyen/zalo-agent-cli 2.5.0 → 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 +373 -13
  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
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Bank commands — the account's saved bank accounts, and the banks Zalo supports.
3
+ *
4
+ * `msg send-bank` and `msg send-qr-transfer` send a card or a QR to a thread;
5
+ * these manage the accounts saved in Zalo's own "bank" list and read its bank
6
+ * directory.
7
+ */
8
+
9
+ import { getApi } from "../core/zalo-client.js";
10
+ import { success, error, info, output } from "../utils/output.js";
11
+ import { parseIntOption, parseIntAtLeast } from "../utils/parse-options.js";
12
+ import { resolveBankBin, BIN_TO_DISPLAY } from "../utils/bank-helpers.js";
13
+ import { findBankAccount, filterBanks } from "../core/bank-accounts.js";
14
+
15
+ /**
16
+ * A bank name or BIN as the BIN Zalo wants.
17
+ *
18
+ * @param {string} input
19
+ * @returns {number}
20
+ * @throws {Error} for an unknown bank
21
+ */
22
+ function binOrThrow(input) {
23
+ const bin = resolveBankBin(String(input));
24
+ if (!bin) throw new Error(`Unknown bank: '${input}'. "bank list" shows the banks Zalo supports.`);
25
+ return bin;
26
+ }
27
+
28
+ /** One saved account as a line of text. */
29
+ function accountLine(a) {
30
+ const bank = BIN_TO_DISPLAY[a.bin] || a.bin;
31
+ return ` ${a.account_id} ${bank} ${a.bank_number} ${a.holder_name}${a.default ? " (default)" : ""}`;
32
+ }
33
+
34
+ export function registerBankCommands(program) {
35
+ const bank = program.command("bank").description("Manage your saved bank accounts and list supported banks");
36
+
37
+ bank.command("list")
38
+ .description("List the banks Zalo supports (name, short name and BIN)")
39
+ .option("-q, --query <text>", "Only banks whose name, short name or BIN contains this text")
40
+ .action(async (opts) => {
41
+ try {
42
+ const result = await getApi().getListBank();
43
+ const banks = filterBanks(result?.banks, opts.query);
44
+ output({ ...result, banks }, program.opts().json, () => {
45
+ info(`${banks.length} bank(s)`);
46
+ for (const b of banks) console.log(` ${b.bin} ${b.short_name || "?"} ${b.name}`);
47
+ });
48
+ } catch (e) {
49
+ error(`Get banks failed: ${e.message}`);
50
+ }
51
+ });
52
+
53
+ bank.command("accounts")
54
+ .description("List your saved bank accounts")
55
+ .option("-p, --page <n>", "Page number (starts at 0)", parseIntAtLeast(0), 0)
56
+ .option("-l, --limit <n>", "Accounts per page", parseIntAtLeast(1), 20)
57
+ .action(async (opts) => {
58
+ try {
59
+ const result = await getApi().getListBankAccount(opts.page, opts.limit);
60
+ output(result, program.opts().json, () => {
61
+ const accounts = result?.myBanks ?? [];
62
+ info(`${accounts.length} account(s) on this page (total: ${result?.total ?? accounts.length})`);
63
+ for (const a of accounts) console.log(accountLine(a));
64
+ if (result?.hasMore) info("More accounts exist: use --page to see them.");
65
+ });
66
+ } catch (e) {
67
+ error(`Get bank accounts failed: ${e.message}`);
68
+ }
69
+ });
70
+
71
+ bank.command("add <accountNumber>")
72
+ .description("Save a bank account to your Zalo bank list")
73
+ .requiredOption("-b, --bank <name>", "Bank name (ocb, vcb, bidv, ...) or BIN code")
74
+ .requiredOption("-n, --name <holder>", "Account holder name")
75
+ .action(async (accountNumber, opts) => {
76
+ try {
77
+ const bin = binOrThrow(opts.bank);
78
+ const result = await getApi().createBankAccount({
79
+ binBank: bin,
80
+ numAccBank: accountNumber,
81
+ nameAccBank: opts.name,
82
+ });
83
+ output(result, program.opts().json, () =>
84
+ success(`Bank account saved: ${BIN_TO_DISPLAY[bin] || bin} / ${accountNumber}`),
85
+ );
86
+ } catch (e) {
87
+ error(`Add bank account failed: ${e.message}`);
88
+ }
89
+ });
90
+
91
+ bank.command("update <accountId> <accountNumber>")
92
+ .description('Change a saved bank account (get <accountId> from "bank accounts")')
93
+ .requiredOption("-b, --bank <name>", "Bank name or BIN code")
94
+ .requiredOption("-n, --name <holder>", "Account holder name")
95
+ .action(async (accountId, accountNumber, opts) => {
96
+ try {
97
+ const result = await getApi().updateBankAccount({
98
+ accountId: parseIntOption(accountId),
99
+ binBank: binOrThrow(opts.bank),
100
+ numAccBank: accountNumber,
101
+ nameAccBank: opts.name,
102
+ });
103
+ output(result, program.opts().json, () => success("Bank account updated"));
104
+ } catch (e) {
105
+ error(`Update bank account failed: ${e.message}`);
106
+ }
107
+ });
108
+
109
+ bank.command("remove <accountId>")
110
+ .description('Delete a saved bank account (get <accountId> from "bank accounts")')
111
+ .action(async (accountId) => {
112
+ try {
113
+ const id = parseIntOption(accountId);
114
+ // Zalo wants to know whether the account is the default one, and only the list knows.
115
+ const found = await findBankAccount(getApi(), id);
116
+ if (!found) {
117
+ error(`No saved bank account with id ${id}. "bank accounts" lists them.`);
118
+ return;
119
+ }
120
+ const result = await getApi().deleteBankAccount({ accountId: id, isDefault: Boolean(found.default) });
121
+ output(result, program.opts().json, () => success(`Bank account ${id} deleted`));
122
+ } catch (e) {
123
+ error(`Delete bank account failed: ${e.message}`);
124
+ }
125
+ });
126
+ }
@@ -6,6 +6,7 @@ import { resolve } from "path";
6
6
  import { getApi } from "../core/zalo-client.js";
7
7
  import { success, error, info, output } from "../utils/output.js";
8
8
  import { parseIntOption } from "../utils/parse-options.js";
9
+ import { parseOnOff } from "../utils/cli-args.js";
9
10
 
10
11
  export function registerCatalogCommands(program) {
11
12
  const catalog = program.command("catalog").description("Manage Zalo Shop catalogs and products");
@@ -141,4 +142,17 @@ export function registerCatalogCommands(program) {
141
142
  error(`Upload photo failed: ${e.message}`);
142
143
  }
143
144
  });
145
+
146
+ catalog
147
+ .command("register <on|off>")
148
+ .description("Turn the Zalo Shop catalog feature on or off for this account")
149
+ .action(async (state) => {
150
+ try {
151
+ const enable = parseOnOff(state, "catalog");
152
+ const result = await getApi().registerCatalog(enable);
153
+ output(result, program.opts().json, () => success(`Catalog ${enable ? "enabled" : "disabled"}`));
154
+ } catch (e) {
155
+ error(e.message);
156
+ }
157
+ });
144
158
  }
@@ -802,4 +802,23 @@ export function registerConvCommands(program, deps = {}) {
802
802
  info("Nothing was sent to Zalo; this only cleared the local cache.");
803
803
  process.exit(0);
804
804
  });
805
+
806
+ conv.command("muted")
807
+ .description("List the conversations and groups you have muted")
808
+ .action(async () => {
809
+ try {
810
+ const result = await getApi().getMute();
811
+ output(result, program.opts().json, () => {
812
+ for (const [label, entries] of [
813
+ ["chat", result?.chatEntries ?? []],
814
+ ["group", result?.groupChatEntries ?? []],
815
+ ]) {
816
+ info(`${entries.length} muted ${label}(s)`);
817
+ for (const m of entries) console.log(` ${m.id} mode=${m.muteMode} duration=${m.duration}`);
818
+ }
819
+ });
820
+ } catch (e) {
821
+ error(`Get muted conversations failed: ${e.message}`);
822
+ }
823
+ });
805
824
  }
@@ -5,12 +5,9 @@
5
5
  import { getApi } from "../core/zalo-client.js";
6
6
  import { success, error, info, output } from "../utils/output.js";
7
7
  import { parseIntOption } from "../utils/parse-options.js";
8
-
9
- /** Extract numeric error code from zca-js error message string. */
10
- function extractErrorCode(msg) {
11
- const match = String(msg).match(/\((\-?\d+)\)/);
12
- return match ? Number(match[1]) : null;
13
- }
8
+ import { describeFriendAddError, extractErrorCode } from "../core/friend-requests.js";
9
+ import { readUserAliases } from "../core/current-user-names.js";
10
+ import { searchFriends } from "../core/friend-search.js";
14
11
 
15
12
  export function registerFriendCommands(program) {
16
13
  const friend = program.command("friend").description("Manage friends and contacts");
@@ -59,17 +56,14 @@ export function registerFriendCommands(program) {
59
56
 
60
57
  friend
61
58
  .command("search <name>")
62
- .description("Search friends by name (returns thread_id for messaging)")
59
+ .description("Search friends by name or alias (returns thread_id for messaging)")
63
60
  .action(async (name) => {
64
61
  try {
65
62
  const result = await getApi().getAllFriends();
66
63
  const friends = Array.isArray(result) ? result : [];
67
- const query = name.toLowerCase();
68
- const matches = friends.filter((f) => {
69
- const dn = (f.displayName || "").toLowerCase();
70
- const zn = (f.zaloName || "").toLowerCase();
71
- return dn.includes(query) || zn.includes(query);
72
- });
64
+ // The alias you set for a friend is not in the friend list; it comes from the alias list.
65
+ const aliases = await readUserAliases(getApi(), (m) => info(`${m}; matching names only`));
66
+ const matches = searchFriends(friends, aliases, name);
73
67
  output(matches, program.opts().json, () => {
74
68
  if (matches.length === 0) {
75
69
  error(`No friends matching "${name}". Use "friend list" to see all.`);
@@ -79,7 +73,8 @@ export function registerFriendCommands(program) {
79
73
  console.log();
80
74
  for (const f of matches) {
81
75
  const display = f.displayName || f.zaloName || "?";
82
- console.log(` ${f.userId} ${display}`);
76
+ const aka = f.alias && f.alias !== display ? ` (alias: ${f.alias})` : "";
77
+ console.log(` ${f.userId} ${display}${aka}`);
83
78
  }
84
79
  console.log();
85
80
  info("Use the ID above as thread_id for messaging commands.");
@@ -142,13 +137,7 @@ export function registerFriendCommands(program) {
142
137
  } catch (e) {
143
138
  // Map Zalo error codes to actionable messages
144
139
  const code = e.code || extractErrorCode(e.message);
145
- const errMap = {
146
- 225: `Already friends with ${userId}. Use "friend list" to verify.`,
147
- 215: `User ${userId} may have blocked you or is unreachable.`,
148
- 222: `User ${userId} already sent you a friend request. Use "friend accept ${userId}" instead.`,
149
- "-1": `Invalid userId "${userId}". Use "friend find <phone>" to get the correct userId first.`,
150
- };
151
- error(errMap[code] || `Friend request failed (code ${code}): ${e.message}`);
140
+ error(describeFriendAddError(code, userId) || `Friend request failed (code ${code}): ${e.message}`);
152
141
  }
153
142
  });
154
143
 
@@ -393,4 +382,60 @@ export function registerFriendCommands(program) {
393
382
  error(`Find by phone failed: ${e.message}`);
394
383
  }
395
384
  });
385
+
386
+ friend
387
+ .command("qr <userIds...>")
388
+ .description("Get the QR code image URL of one or more users")
389
+ .action(async (userIds) => {
390
+ try {
391
+ const result = await getApi().getQR(userIds);
392
+ output(result, program.opts().json, () => {
393
+ for (const [uid, url] of Object.entries(result ?? {})) console.log(` ${uid} ${url}`);
394
+ });
395
+ } catch (e) {
396
+ error(`Get QR failed: ${e.message}`);
397
+ }
398
+ });
399
+
400
+ friend
401
+ .command("common-groups <friendIds...>")
402
+ .description("List the groups you share with one or more friends")
403
+ .action(async (friendIds) => {
404
+ try {
405
+ const result = await getApi().getRelatedFriendGroup(friendIds);
406
+ output(result, program.opts().json, () => {
407
+ for (const [fid, groups] of Object.entries(result?.groupRelateds ?? {})) {
408
+ info(`${fid}: ${groups.length} group(s)`);
409
+ for (const g of groups) console.log(` ${g}`);
410
+ }
411
+ });
412
+ } catch (e) {
413
+ error(`Get shared groups failed: ${e.message}`);
414
+ }
415
+ });
416
+
417
+ friend
418
+ .command("biz <friendId>")
419
+ .description("Show the business account details of a user (category, address, website, email)")
420
+ .action(async (friendId) => {
421
+ try {
422
+ const result = await getApi().getBizAccount(friendId);
423
+ output(result, program.opts().json);
424
+ } catch (e) {
425
+ error(`Get business account failed: ${e.message}`);
426
+ }
427
+ });
428
+
429
+ friend
430
+ .command("block-feed <userId>")
431
+ .description("Hide a user's posts from your feed (or show them again with --unblock)")
432
+ .option("--unblock", "Show this user's feed again")
433
+ .action(async (userId, opts) => {
434
+ try {
435
+ const result = await getApi().blockViewFeed(!opts.unblock, userId);
436
+ output(result, program.opts().json, () => success(opts.unblock ? "Feed shown again" : "Feed hidden"));
437
+ } catch (e) {
438
+ error(`Block feed failed: ${e.message}`);
439
+ }
440
+ });
396
441
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { resolve } from "path";
7
7
  import { getApi } from "../core/zalo-client.js";
8
+ import { getInviteBoxInfo } from "../core/group-invite.js";
8
9
  import { getGroupHistory } from "../core/group-history.js";
9
10
  import { groupMemberUids } from "../core/group-members.js";
10
11
  import { success, error, info, warning, output } from "../utils/output.js";
@@ -336,9 +337,10 @@ export function registerGroupCommands(program) {
336
337
  group
337
338
  .command("leave <groupId>")
338
339
  .description("Leave a group")
339
- .action(async (groupId) => {
340
+ .option("-s, --silent", "Leave without the usual notice to ordinary members (the owner still sees it)")
341
+ .action(async (groupId, opts) => {
340
342
  try {
341
- const result = await getApi().leaveGroup(groupId);
343
+ const result = await getApi().leaveGroup(groupId, Boolean(opts.silent));
342
344
  output(result, program.opts().json, () => success("Left group"));
343
345
  } catch (e) {
344
346
  error(e.message);
@@ -654,4 +656,38 @@ export function registerGroupCommands(program) {
654
656
  error(`Disperse group failed: ${e.message}`);
655
657
  }
656
658
  });
659
+
660
+ group
661
+ .command("link-preview <link>")
662
+ .description("Show a group's details from its invite link, before joining (does not join)")
663
+ .option("-p, --member-page <n>", "Member page to show", parseIntAtLeast(1), 1)
664
+ .action(async (link, opts) => {
665
+ try {
666
+ const result = await getApi().getGroupLinkInfo({ link, memberPage: opts.memberPage });
667
+ output(result, program.opts().json);
668
+ } catch (e) {
669
+ error(`Group link preview failed: ${e.message}`);
670
+ }
671
+ });
672
+
673
+ group
674
+ .command("invite-info <groupId>")
675
+ .description(
676
+ 'Show a pending group invitation: the group, who invited you and when it expires (see "invite-boxes")',
677
+ )
678
+ .option("--member-page <n>", "Member page to show", parseIntAtLeast(1), 1)
679
+ .option("--member-count <n>", "Members per page", parseIntAtLeast(1), 10)
680
+ .action(async (groupId, opts) => {
681
+ try {
682
+ // zca-js's own call throws when the group has no topic; see core/group-invite.js.
683
+ const result = await getInviteBoxInfo(getApi(), {
684
+ groupId,
685
+ mpage: opts.memberPage,
686
+ mcount: opts.memberCount,
687
+ });
688
+ output(result, program.opts().json);
689
+ } catch (e) {
690
+ error(`Group invite info failed: ${e.message}`);
691
+ }
692
+ });
657
693
  }
@@ -8,6 +8,7 @@ import { appendFileSync, mkdirSync, existsSync } from "fs";
8
8
  import { resolve, join } from "path";
9
9
  import { getApi, clearSession } from "../core/zalo-client.js";
10
10
  import { success, error, info, warning, output } from "../utils/output.js";
11
+ import { resolveWebhookSecret } from "../utils/secrets.js";
11
12
  import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
12
13
  import { updateAccountName } from "../core/accounts.js";
13
14
  import { getAccountDir } from "../core/credentials.js";
@@ -75,11 +76,15 @@ export function registerListenCommand(program) {
75
76
  )
76
77
  .option(
77
78
  "-e, --events <types>",
78
- "Comma-separated event types: message,friend,group,reaction,read (default: message,friend,group,reaction,read)",
79
+ "Comma-separated event types: message,friend,group,reaction,read (default), plus the opt-in typing (someone is typing) and upload (Zalo finished processing a video or file you uploaded)",
79
80
  "message,friend,group,reaction,read",
80
81
  )
81
82
  .option("-f, --filter <type>", "Message filter: user (DM only), group (groups only), all", "all")
82
83
  .option("-w, --webhook <url>", "POST each event as JSON to this URL (for n8n, Make, etc.)")
84
+ .option(
85
+ "--webhook-secret <secret>",
86
+ "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",
87
+ )
83
88
  .option("--no-self", "Exclude self-sent messages")
84
89
  .option("--auto-accept", "Auto-accept incoming friend requests")
85
90
  .option("--save <dir>", "Save messages locally as JSONL files (one file per thread, e.g. --save ./zalo-logs)")
@@ -96,6 +101,15 @@ export function registerListenCommand(program) {
96
101
  .option("--status", "Show background daemon status for this account")
97
102
  .action(async (opts) => {
98
103
  const jsonMode = program.opts().json;
104
+ // An empty secret is refused before anything else: see resolveWebhookSecret().
105
+ let cliWebhookSecret;
106
+ try {
107
+ if (opts.webhookSecret !== undefined)
108
+ cliWebhookSecret = resolveWebhookSecret({ flag: opts.webhookSecret });
109
+ } catch (e) {
110
+ error(e.message);
111
+ process.exit(1);
112
+ }
99
113
  let activeAcc;
100
114
  try {
101
115
  const requestedAccount =
@@ -384,7 +398,13 @@ export function registerListenCommand(program) {
384
398
  }
385
399
 
386
400
  const webhookUrl = opts.webhook?.trim() || envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) || null;
387
- const webhookSecret = envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId) || null;
401
+ const webhookSecret = resolveWebhookSecret({
402
+ flag: cliWebhookSecret,
403
+ env: envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId),
404
+ });
405
+ if (webhookSecret && !webhookUrl) {
406
+ warning("A webhook secret is set but no webhook URL: nothing will be signed or sent. Pass -w <url>.");
407
+ }
388
408
 
389
409
  /** Fire-and-forget webhook POST — never blocks event processing */
390
410
  function postWebhook(data) {
@@ -655,6 +675,35 @@ export function registerListenCommand(program) {
655
675
  });
656
676
  }
657
677
 
678
+ // --- Typing and upload events (opt-in) ---
679
+ // Not in the default --events: typing is constant noise, and upload
680
+ // only matters to a caller that is uploading. Neither is stored.
681
+ // zca-js also emits `cipher_key`, the session's key material; it is
682
+ // deliberately never subscribed to, so it can reach no log, file or webhook.
683
+ if (enabledEvents.has("typing")) {
684
+ api.listener.on("typing", (t) => {
685
+ emitEvent(
686
+ {
687
+ event: "typing",
688
+ threadId: t.threadId,
689
+ isGroup: t.type === THREAD_GROUP,
690
+ uid: t.data?.uid ?? null,
691
+ isPC: Boolean(t.data?.isPC),
692
+ data: t.data,
693
+ },
694
+ `Typing: ${t.data?.uid ?? "?"} in ${t.threadId}`,
695
+ );
696
+ });
697
+ }
698
+ if (enabledEvents.has("upload")) {
699
+ api.listener.on("upload_attachment", (u) => {
700
+ emitEvent(
701
+ { event: "upload_attachment", fileId: u?.fileId ?? null, fileUrl: u?.fileUrl ?? null },
702
+ `Upload finished: ${u?.fileId ?? "?"}`,
703
+ );
704
+ });
705
+ }
706
+
658
707
  // --- Group events ---
659
708
  // Also ungated for storage, for the same reason: with the
660
709
  // default --events, being removed from a group was never