@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,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
|
+
}
|
package/src/commands/catalog.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/conv.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/friend.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/commands/group.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
}
|
package/src/commands/listen.js
CHANGED
|
@@ -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";
|
|
@@ -20,6 +21,8 @@ import {
|
|
|
20
21
|
spawnDaemonProcess,
|
|
21
22
|
} from "../core/daemon-channel.js";
|
|
22
23
|
import { createSyncRunners } from "../core/daemon-sync.js";
|
|
24
|
+
import { createSocketContext, startEventLoopMonitor } from "../core/daemon-log.js";
|
|
25
|
+
import { setUserInfoLogger } from "../core/user-info.js";
|
|
23
26
|
import { createSocketTap } from "../core/socket-tap.js";
|
|
24
27
|
import { createSelfHeal } from "../core/self-heal.js";
|
|
25
28
|
import { createReadStateSync, readStateEvent } from "../core/read-state.js";
|
|
@@ -75,11 +78,15 @@ export function registerListenCommand(program) {
|
|
|
75
78
|
)
|
|
76
79
|
.option(
|
|
77
80
|
"-e, --events <types>",
|
|
78
|
-
"Comma-separated event types: message,friend,group,reaction,read (default
|
|
81
|
+
"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
82
|
"message,friend,group,reaction,read",
|
|
80
83
|
)
|
|
81
84
|
.option("-f, --filter <type>", "Message filter: user (DM only), group (groups only), all", "all")
|
|
82
85
|
.option("-w, --webhook <url>", "POST each event as JSON to this URL (for n8n, Make, etc.)")
|
|
86
|
+
.option(
|
|
87
|
+
"--webhook-secret <secret>",
|
|
88
|
+
"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",
|
|
89
|
+
)
|
|
83
90
|
.option("--no-self", "Exclude self-sent messages")
|
|
84
91
|
.option("--auto-accept", "Auto-accept incoming friend requests")
|
|
85
92
|
.option("--save <dir>", "Save messages locally as JSONL files (one file per thread, e.g. --save ./zalo-logs)")
|
|
@@ -96,6 +103,15 @@ export function registerListenCommand(program) {
|
|
|
96
103
|
.option("--status", "Show background daemon status for this account")
|
|
97
104
|
.action(async (opts) => {
|
|
98
105
|
const jsonMode = program.opts().json;
|
|
106
|
+
// An empty secret is refused before anything else: see resolveWebhookSecret().
|
|
107
|
+
let cliWebhookSecret;
|
|
108
|
+
try {
|
|
109
|
+
if (opts.webhookSecret !== undefined)
|
|
110
|
+
cliWebhookSecret = resolveWebhookSecret({ flag: opts.webhookSecret });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
error(e.message);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
99
115
|
let activeAcc;
|
|
100
116
|
try {
|
|
101
117
|
const requestedAccount =
|
|
@@ -384,7 +400,13 @@ export function registerListenCommand(program) {
|
|
|
384
400
|
}
|
|
385
401
|
|
|
386
402
|
const webhookUrl = opts.webhook?.trim() || envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) || null;
|
|
387
|
-
const webhookSecret =
|
|
403
|
+
const webhookSecret = resolveWebhookSecret({
|
|
404
|
+
flag: cliWebhookSecret,
|
|
405
|
+
env: envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId),
|
|
406
|
+
});
|
|
407
|
+
if (webhookSecret && !webhookUrl) {
|
|
408
|
+
warning("A webhook secret is set but no webhook URL: nothing will be signed or sent. Pass -w <url>.");
|
|
409
|
+
}
|
|
388
410
|
|
|
389
411
|
/** Fire-and-forget webhook POST — never blocks event processing */
|
|
390
412
|
function postWebhook(data) {
|
|
@@ -501,6 +523,17 @@ export function registerListenCommand(program) {
|
|
|
501
523
|
// and one raw reader on the socket for the fields zca-js drops.
|
|
502
524
|
// Built once, like the receipter: they outlive every re-login.
|
|
503
525
|
const stageLock = createStageLock();
|
|
526
|
+
// See mcp.js: stage progress, event-loop stalls and refused lookups belong in the log too.
|
|
527
|
+
const daemonLog = (m) => console.error(`[listen] ${m}`);
|
|
528
|
+
const loopMonitor = startEventLoopMonitor({
|
|
529
|
+
log: daemonLog,
|
|
530
|
+
context: () => {
|
|
531
|
+
const held = stageLock.current();
|
|
532
|
+
return held ? `the ${held.stage} stage was running` : "";
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
const socketContext = createSocketContext({ lock: stageLock, monitor: loopMonitor });
|
|
536
|
+
setUserInfoLogger(daemonLog);
|
|
504
537
|
const socketTap = createSocketTap({ log: (line) => console.error(`[listen] ${line}`) });
|
|
505
538
|
// Read state other devices report -- a conversation read on the
|
|
506
539
|
// phone, an unread mark set or cleared there -- which zca-js
|
|
@@ -655,6 +688,35 @@ export function registerListenCommand(program) {
|
|
|
655
688
|
});
|
|
656
689
|
}
|
|
657
690
|
|
|
691
|
+
// --- Typing and upload events (opt-in) ---
|
|
692
|
+
// Not in the default --events: typing is constant noise, and upload
|
|
693
|
+
// only matters to a caller that is uploading. Neither is stored.
|
|
694
|
+
// zca-js also emits `cipher_key`, the session's key material; it is
|
|
695
|
+
// deliberately never subscribed to, so it can reach no log, file or webhook.
|
|
696
|
+
if (enabledEvents.has("typing")) {
|
|
697
|
+
api.listener.on("typing", (t) => {
|
|
698
|
+
emitEvent(
|
|
699
|
+
{
|
|
700
|
+
event: "typing",
|
|
701
|
+
threadId: t.threadId,
|
|
702
|
+
isGroup: t.type === THREAD_GROUP,
|
|
703
|
+
uid: t.data?.uid ?? null,
|
|
704
|
+
isPC: Boolean(t.data?.isPC),
|
|
705
|
+
data: t.data,
|
|
706
|
+
},
|
|
707
|
+
`Typing: ${t.data?.uid ?? "?"} in ${t.threadId}`,
|
|
708
|
+
);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
if (enabledEvents.has("upload")) {
|
|
712
|
+
api.listener.on("upload_attachment", (u) => {
|
|
713
|
+
emitEvent(
|
|
714
|
+
{ event: "upload_attachment", fileId: u?.fileId ?? null, fileUrl: u?.fileUrl ?? null },
|
|
715
|
+
`Upload finished: ${u?.fileId ?? "?"}`,
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
658
720
|
// --- Group events ---
|
|
659
721
|
// Also ungated for storage, for the same reason: with the
|
|
660
722
|
// default --events, being removed from a group was never
|
|
@@ -763,6 +825,7 @@ export function registerListenCommand(program) {
|
|
|
763
825
|
|
|
764
826
|
// --- Lifecycle events (MUST be on same listener for reconnect to work) ---
|
|
765
827
|
api.listener.on("connected", () => {
|
|
828
|
+
socketContext.markUp();
|
|
766
829
|
if (reconnectCount > 0) {
|
|
767
830
|
info(`Reconnected (#${reconnectCount}, uptime: ${uptime()}, events: ${eventCount})`);
|
|
768
831
|
}
|
|
@@ -785,7 +848,7 @@ export function registerListenCommand(program) {
|
|
|
785
848
|
|
|
786
849
|
api.listener.on("disconnected", (code, _reason) => {
|
|
787
850
|
if (stopping) return;
|
|
788
|
-
warning(`Disconnected (code: ${code}). Auto-retrying
|
|
851
|
+
warning(`Disconnected (code: ${code}). Auto-retrying... (${socketContext.describe()})`);
|
|
789
852
|
// First drop wins: a flapping socket that emits several
|
|
790
853
|
// `disconnected` before one `connected` is ONE outage, and the
|
|
791
854
|
// gap must span from the start of it.
|
|
@@ -805,7 +868,9 @@ export function registerListenCommand(program) {
|
|
|
805
868
|
reconnectCount++;
|
|
806
869
|
if (downSince === null) downSince = Date.now();
|
|
807
870
|
syncManager.markDisconnected();
|
|
808
|
-
warning(
|
|
871
|
+
warning(
|
|
872
|
+
`Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()}; ${socketContext.describe()})`,
|
|
873
|
+
);
|
|
809
874
|
await new Promise((r) => setTimeout(r, 5000));
|
|
810
875
|
try {
|
|
811
876
|
clearSession();
|
|
@@ -926,6 +991,8 @@ export function registerListenCommand(program) {
|
|
|
926
991
|
runners: createSyncRunners({
|
|
927
992
|
getApi,
|
|
928
993
|
accountName: activeAcc.ownId,
|
|
994
|
+
log: daemonLog,
|
|
995
|
+
monitor: loopMonitor,
|
|
929
996
|
onSelfName: (name) => updateAccountName(activeAcc.ownId, name),
|
|
930
997
|
}),
|
|
931
998
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|