zalo-agent-cli 1.0.0

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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Message commands — send text, images, files, cards, bank cards, QR transfers,
3
+ * stickers, reactions, delete, forward.
4
+ */
5
+
6
+ import { resolve } from "path";
7
+ import { getApi } from "../core/zalo-client.js";
8
+ import { success, error, info, output } from "../utils/output.js";
9
+
10
+ export function registerMsgCommands(program) {
11
+ const msg = program.command("msg").description("Send and manage messages");
12
+
13
+ msg.command("send <threadId> <message>")
14
+ .description("Send a text message")
15
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
16
+ .action(async (threadId, message, opts) => {
17
+ try {
18
+ const result = await getApi().sendMessage(message, threadId, Number(opts.type));
19
+ output(result, program.opts().json, () => success("Message sent"));
20
+ } catch (e) {
21
+ error(e.message);
22
+ }
23
+ });
24
+
25
+ msg.command("send-image <threadId> <paths...>")
26
+ .description("Send one or more images")
27
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
28
+ .option("-m, --caption <text>", "Caption text", "")
29
+ .action(async (threadId, paths, opts) => {
30
+ try {
31
+ const absPaths = paths.map((p) => resolve(p));
32
+ const result = await getApi().sendMessage(
33
+ { msg: opts.caption, attachments: absPaths },
34
+ threadId,
35
+ Number(opts.type),
36
+ );
37
+ output(result, program.opts().json, () => success(`Image(s) sent to ${threadId}`));
38
+ } catch (e) {
39
+ error(e.message);
40
+ }
41
+ });
42
+
43
+ msg.command("send-file <threadId> <paths...>")
44
+ .description("Send files (docx, pdf, zip, etc.)")
45
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
46
+ .option("-m, --caption <text>", "Caption text", "")
47
+ .action(async (threadId, paths, opts) => {
48
+ try {
49
+ const absPaths = paths.map((p) => resolve(p));
50
+ const result = await getApi().sendMessage(
51
+ { msg: opts.caption, attachments: absPaths },
52
+ threadId,
53
+ Number(opts.type),
54
+ );
55
+ output(result, program.opts().json, () => success(`File(s) sent to ${threadId}`));
56
+ } catch (e) {
57
+ error(e.message);
58
+ }
59
+ });
60
+
61
+ msg.command("send-card <threadId> <userId>")
62
+ .description("Send a contact card (danh thiếp)")
63
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
64
+ .option("--phone <num>", "Phone number (auto-fetched if omitted)")
65
+ .action(async (threadId, userId, opts) => {
66
+ try {
67
+ const api = getApi();
68
+ let phone = opts.phone;
69
+ if (!phone) {
70
+ const userInfo = await api.getUserInfo(userId);
71
+ const profiles = userInfo?.changed_profiles || {};
72
+ phone = profiles[userId]?.phoneNumber || "";
73
+ if (phone) info(`Auto-detected phone: ${phone}`);
74
+ }
75
+ const cardOpts = { userId };
76
+ if (phone) cardOpts.phoneNumber = phone;
77
+ const result = await api.sendCard(cardOpts, threadId, Number(opts.type));
78
+ output(result, program.opts().json, () => success("Card sent"));
79
+ } catch (e) {
80
+ error(e.message);
81
+ }
82
+ });
83
+
84
+ msg.command("send-bank <threadId> <accountNumber>")
85
+ .description("Send a bank card (số tài khoản)")
86
+ .requiredOption("-b, --bank <name>", "Bank name (ocb, vcb, bidv) or BIN code")
87
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
88
+ .option("-n, --name <holder>", "Account holder name")
89
+ .action(async (threadId, accountNumber, opts) => {
90
+ try {
91
+ const { resolveBankBin, BIN_TO_DISPLAY } = await import("../utils/bank-helpers.js");
92
+ const bin = resolveBankBin(opts.bank);
93
+ if (!bin) {
94
+ error(`Unknown bank: '${opts.bank}'`);
95
+ return;
96
+ }
97
+ info(`Bank: ${BIN_TO_DISPLAY[bin] || bin} (BIN ${bin})`);
98
+
99
+ const payload = { binBank: bin, numAccBank: accountNumber };
100
+ if (opts.name) payload.nameAccBank = opts.name;
101
+ const result = await getApi().sendBankCard(payload, threadId, Number(opts.type));
102
+ output(result, program.opts().json, () =>
103
+ success(`Bank card sent: ${BIN_TO_DISPLAY[bin]} / ${accountNumber}`),
104
+ );
105
+ } catch (e) {
106
+ error(e.message);
107
+ }
108
+ });
109
+
110
+ msg.command("send-qr-transfer <threadId> <accountNumber>")
111
+ .description("Generate VietQR and send as image")
112
+ .requiredOption("-b, --bank <name>", "Bank name or BIN code")
113
+ .option("-a, --amount <n>", "Transfer amount in VND", parseInt)
114
+ .option("-m, --content <text>", "Transfer content (max 50 chars)")
115
+ .option("--template <tpl>", "QR style: compact, print, qronly", "compact")
116
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
117
+ .action(async (threadId, accountNumber, opts) => {
118
+ try {
119
+ const { resolveBankBin, BIN_TO_DISPLAY, generateQrTransferImage } =
120
+ await import("../utils/bank-helpers.js");
121
+ const bin = resolveBankBin(opts.bank);
122
+ if (!bin) {
123
+ error(`Unknown bank: '${opts.bank}'`);
124
+ return;
125
+ }
126
+ if (opts.content && opts.content.length > 50) {
127
+ error(`Content too long (${opts.content.length} chars). VietQR max is 50.`);
128
+ return;
129
+ }
130
+ info(
131
+ `Generating QR: ${BIN_TO_DISPLAY[bin]} / ${accountNumber}${opts.amount ? ` / ${opts.amount.toLocaleString()}đ` : ""}`,
132
+ );
133
+
134
+ const qrPath = await generateQrTransferImage(
135
+ bin,
136
+ accountNumber,
137
+ opts.amount,
138
+ opts.content,
139
+ opts.template,
140
+ );
141
+ if (!qrPath) {
142
+ error("Failed to generate QR image");
143
+ return;
144
+ }
145
+
146
+ const caption = [
147
+ `QR chuyển khoản ${BIN_TO_DISPLAY[bin]} - ${accountNumber}`,
148
+ opts.amount ? `${opts.amount.toLocaleString()}đ` : null,
149
+ opts.content || null,
150
+ ]
151
+ .filter(Boolean)
152
+ .join(" - ");
153
+
154
+ const result = await getApi().sendMessage(
155
+ { msg: caption, attachments: [qrPath] },
156
+ threadId,
157
+ Number(opts.type),
158
+ );
159
+
160
+ // Cleanup temp file
161
+ try {
162
+ (await import("fs")).unlinkSync(qrPath);
163
+ } catch {}
164
+
165
+ output(result, program.opts().json, () => success(`QR transfer sent to ${threadId}`));
166
+ } catch (e) {
167
+ error(e.message);
168
+ }
169
+ });
170
+
171
+ msg.command("sticker <threadId> <keyword>")
172
+ .description("Search and send a sticker")
173
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
174
+ .action(async (threadId, keyword, opts) => {
175
+ try {
176
+ const api = getApi();
177
+ const search = await api.searchSticker(keyword);
178
+ const stickerId = search?.[0]?.stickerId || "";
179
+ if (!stickerId) {
180
+ error("No sticker found");
181
+ return;
182
+ }
183
+ const result = await api.sendSticker(stickerId, threadId, Number(opts.type));
184
+ output(result, program.opts().json, () => success("Sticker sent"));
185
+ } catch (e) {
186
+ error(e.message);
187
+ }
188
+ });
189
+
190
+ msg.command("react <msgId> <threadId> <reaction>")
191
+ .description("React to a message with an emoji")
192
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
193
+ .action(async (msgId, threadId, reaction, opts) => {
194
+ try {
195
+ const result = await getApi().addReaction(reaction, msgId, threadId, Number(opts.type));
196
+ output(result, program.opts().json, () => success(`Reacted with '${reaction}'`));
197
+ } catch (e) {
198
+ error(e.message);
199
+ }
200
+ });
201
+
202
+ msg.command("delete <msgId> <threadId>")
203
+ .description("Delete a message you sent")
204
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
205
+ .action(async (msgId, threadId, opts) => {
206
+ try {
207
+ const result = await getApi().deleteMessage(msgId, threadId, Number(opts.type));
208
+ output(result, program.opts().json, () => success("Message deleted"));
209
+ } catch (e) {
210
+ error(e.message);
211
+ }
212
+ });
213
+
214
+ msg.command("forward <msgId> <threadId>")
215
+ .description("Forward a message to another thread")
216
+ .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
217
+ .action(async (msgId, threadId, opts) => {
218
+ try {
219
+ const result = await getApi().forwardMessage(msgId, threadId, Number(opts.type));
220
+ output(result, program.opts().json, () => success("Message forwarded"));
221
+ } catch (e) {
222
+ error(e.message);
223
+ }
224
+ });
225
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Multi-account registry at ~/.zalo-agent-cli/accounts.json
3
+ * Maps each account to its own proxy (1:1) and tracks active account.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
7
+ import { CONFIG_DIR, deleteCredentials } from "./credentials.js";
8
+
9
+ const ACCOUNTS_FILE = `${CONFIG_DIR}/accounts.json`;
10
+
11
+ function ensureDir() {
12
+ mkdirSync(CONFIG_DIR, { recursive: true });
13
+ }
14
+
15
+ function load() {
16
+ if (!existsSync(ACCOUNTS_FILE)) return [];
17
+ try {
18
+ return JSON.parse(readFileSync(ACCOUNTS_FILE, "utf-8"));
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function save(accounts) {
25
+ ensureDir();
26
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), "utf-8");
27
+ chmodSync(ACCOUNTS_FILE, 0o600);
28
+ }
29
+
30
+ /** List all registered accounts. */
31
+ export function listAccounts() {
32
+ return load();
33
+ }
34
+
35
+ /** Get currently active account or null. */
36
+ export function getActive() {
37
+ return load().find((a) => a.active) || null;
38
+ }
39
+
40
+ /** Set an account as active (deactivates others). Returns false if not found. */
41
+ export function setActive(ownId) {
42
+ const accounts = load();
43
+ let found = false;
44
+ for (const a of accounts) {
45
+ if (a.ownId === ownId) {
46
+ a.active = true;
47
+ found = true;
48
+ } else {
49
+ a.active = false;
50
+ }
51
+ }
52
+ if (found) save(accounts);
53
+ return found;
54
+ }
55
+
56
+ /** Register a new account or update existing. New account becomes active. */
57
+ export function addAccount(ownId, name = "", proxy = null) {
58
+ const accounts = load();
59
+ const existing = accounts.find((a) => a.ownId === ownId);
60
+ if (existing) {
61
+ existing.name = name || existing.name || "";
62
+ existing.proxy = proxy;
63
+ } else {
64
+ for (const a of accounts) a.active = false;
65
+ accounts.push({ ownId, name, proxy, active: true });
66
+ }
67
+ save(accounts);
68
+ }
69
+
70
+ /** Remove account from registry and delete its credentials. */
71
+ export function removeAccount(ownId) {
72
+ const accounts = load();
73
+ const filtered = accounts.filter((a) => a.ownId !== ownId);
74
+ if (filtered.length === accounts.length) return false;
75
+ // If removed was active, activate first remaining
76
+ if (filtered.length && !filtered.some((a) => a.active)) {
77
+ filtered[0].active = true;
78
+ }
79
+ save(filtered);
80
+ deleteCredentials(ownId);
81
+ return true;
82
+ }
83
+
84
+ /** Get account by ownId. */
85
+ export function getAccount(ownId) {
86
+ return load().find((a) => a.ownId === ownId) || null;
87
+ }
88
+
89
+ /** Get proxy URL for a specific account. */
90
+ export function getProxyFor(ownId) {
91
+ const acc = getAccount(ownId);
92
+ return acc?.proxy || null;
93
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Per-account credential storage at ~/.zalo-agent-cli/credentials/
3
+ * All credential files use 0600 permissions (owner read/write only).
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+
10
+ export const CONFIG_DIR = join(homedir(), ".zalo-agent-cli");
11
+ export const CREDENTIALS_DIR = join(CONFIG_DIR, "credentials");
12
+
13
+ /** Ensure config directories exist. */
14
+ function ensureDirs() {
15
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
16
+ }
17
+
18
+ function credPath(ownId) {
19
+ return join(CREDENTIALS_DIR, `cred_${ownId}.json`);
20
+ }
21
+
22
+ /**
23
+ * Save credentials for a specific account.
24
+ * @param {string} ownId
25
+ * @param {object} creds - {imei, cookie, userAgent, language?}
26
+ * @returns {string} File path
27
+ */
28
+ export function saveCredentials(ownId, creds) {
29
+ ensureDirs();
30
+ const target = credPath(ownId);
31
+ writeFileSync(target, JSON.stringify(creds, null, 2), "utf-8");
32
+ chmodSync(target, 0o600);
33
+ return target;
34
+ }
35
+
36
+ /**
37
+ * Load credentials for a specific account.
38
+ * @param {string} ownId
39
+ * @returns {object|null}
40
+ */
41
+ export function loadCredentials(ownId) {
42
+ const target = credPath(ownId);
43
+ if (!existsSync(target)) return null;
44
+ try {
45
+ return JSON.parse(readFileSync(target, "utf-8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Delete credentials for a specific account.
53
+ * @param {string} ownId
54
+ * @returns {boolean}
55
+ */
56
+ export function deleteCredentials(ownId) {
57
+ const target = credPath(ownId);
58
+ if (existsSync(target)) {
59
+ unlinkSync(target);
60
+ return true;
61
+ }
62
+ return false;
63
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Zalo client wrapper — direct zca-js API calls with proxy support.
3
+ * Manages a single Zalo instance per process. Swap on account switch.
4
+ */
5
+
6
+ import { Zalo, LoginQRCallbackEventType } from "zca-js";
7
+ import { HttpsProxyAgent } from "https-proxy-agent";
8
+ import nodefetch from "node-fetch";
9
+ import { getActive } from "./accounts.js";
10
+ import { loadCredentials } from "./credentials.js";
11
+ import { info } from "../utils/output.js";
12
+
13
+ let _api = null;
14
+ let _ownId = null;
15
+
16
+ /** Get the current API instance or throw. */
17
+ export function getApi() {
18
+ if (!_api) throw new Error("Not logged in. Run: zalo-agent login");
19
+ return _api;
20
+ }
21
+
22
+ /** Get current owner ID. */
23
+ export function getOwnId() {
24
+ return _ownId;
25
+ }
26
+
27
+ /** Check if logged in. */
28
+ export function isLoggedIn() {
29
+ return _api !== null;
30
+ }
31
+
32
+ /** Create a Zalo instance with optional proxy. Suppress logs in JSON mode. */
33
+ function createZalo(proxyUrl) {
34
+ const opts = {
35
+ // Suppress zca-js internal INFO logs when --json to keep stdout clean
36
+ logging: !process.env.ZALO_JSON_MODE,
37
+ };
38
+ if (proxyUrl) {
39
+ opts.agent = new HttpsProxyAgent(proxyUrl);
40
+ opts.polyfill = nodefetch;
41
+ }
42
+ return new Zalo(opts);
43
+ }
44
+
45
+ /** Set the active API + ownId (used after login). */
46
+ function setSession(api, ownId) {
47
+ _api = api;
48
+ _ownId = ownId;
49
+ }
50
+
51
+ /** Clear current session. */
52
+ export function clearSession() {
53
+ _api = null;
54
+ _ownId = null;
55
+ }
56
+
57
+ /**
58
+ * Login with saved credentials + proxy.
59
+ * @param {object} creds - {imei, cookie, userAgent, language?}
60
+ * @param {string|null} proxyUrl
61
+ * @returns {object} - {api, ownId}
62
+ */
63
+ export async function loginWithCredentials(creds, proxyUrl = null) {
64
+ const zalo = createZalo(proxyUrl);
65
+ const api = await zalo.login(creds);
66
+ const ownId = api.getOwnId?.() || null;
67
+ setSession(api, ownId);
68
+ return { api, ownId };
69
+ }
70
+
71
+ /**
72
+ * Login via QR code with optional proxy.
73
+ * @param {string|null} proxyUrl
74
+ * @param {function} onQrGenerated - callback(qrData) when QR is ready
75
+ * @returns {object} - {api, ownId}
76
+ */
77
+ export async function loginWithQR(proxyUrl = null, onQrGenerated = null) {
78
+ const zalo = createZalo(proxyUrl);
79
+
80
+ const api = await zalo.loginQR(null, (event) => {
81
+ if (event.type === LoginQRCallbackEventType.QRCodeGenerated && onQrGenerated) {
82
+ onQrGenerated(event);
83
+ }
84
+ });
85
+
86
+ const ownId = api.getOwnId?.() || null;
87
+ setSession(api, ownId);
88
+ return { api, ownId };
89
+ }
90
+
91
+ /**
92
+ * Extract credentials from current session for saving.
93
+ * @returns {object} - {imei, cookie, userAgent, language}
94
+ */
95
+ export function extractCredentials() {
96
+ const api = getApi();
97
+ const ctx = api.getContext();
98
+ return {
99
+ imei: ctx.imei,
100
+ cookie: ctx.cookie,
101
+ userAgent: ctx.userAgent,
102
+ language: ctx.language,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Auto-login using active account from registry.
108
+ * Called before commands that need authentication.
109
+ * @param {boolean} jsonMode - suppress output in JSON mode
110
+ */
111
+ export async function autoLogin(jsonMode = false) {
112
+ if (_api) return; // Already logged in
113
+
114
+ const active = getActive();
115
+ if (!active) return;
116
+
117
+ const creds = loadCredentials(active.ownId);
118
+ if (!creds) return;
119
+
120
+ try {
121
+ await loginWithCredentials(creds, active.proxy || null);
122
+ if (!jsonMode) {
123
+ info(`Auto-login: ${active.name || active.ownId}`);
124
+ }
125
+ } catch {
126
+ // Silent failure — user can login manually
127
+ }
128
+ }
package/src/index.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * zalo-agent-cli — CLI for Zalo automation with multi-account + proxy support.
5
+ * Entry point: registers all command groups via Commander.js.
6
+ */
7
+
8
+ import { Command } from "commander";
9
+ import { registerLoginCommands } from "./commands/login.js";
10
+ import { registerMsgCommands } from "./commands/msg.js";
11
+ import { registerFriendCommands } from "./commands/friend.js";
12
+ import { registerGroupCommands } from "./commands/group.js";
13
+ import { registerConvCommands } from "./commands/conv.js";
14
+ import { registerAccountCommands } from "./commands/account.js";
15
+ import { autoLogin } from "./core/zalo-client.js";
16
+
17
+ const program = new Command();
18
+
19
+ program
20
+ .name("zalo-agent")
21
+ .description("CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments")
22
+ .version("1.0.0")
23
+ .option("--json", "Output results as JSON (machine-readable)")
24
+ .hook("preAction", async (thisCommand) => {
25
+ // Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
26
+ if (program.opts().json) {
27
+ process.env.ZALO_JSON_MODE = "1";
28
+ }
29
+ // Auto-login before any command that needs it (skip for login/account commands)
30
+ const cmdName = thisCommand.args?.[0] || thisCommand.name();
31
+ const skipAutoLogin = ["login", "account", "help", "version"].includes(cmdName);
32
+ if (!skipAutoLogin) {
33
+ await autoLogin(program.opts().json);
34
+ }
35
+ });
36
+
37
+ // Register all command groups
38
+ registerLoginCommands(program);
39
+ registerMsgCommands(program);
40
+ registerFriendCommands(program);
41
+ registerGroupCommands(program);
42
+ registerConvCommands(program);
43
+ registerAccountCommands(program);
44
+
45
+ program.parse();