zalo-agent-cli 1.1.0 → 1.2.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.
- package/README.md +24 -0
- package/package.json +1 -1
- package/src/cli.test.js +1 -0
- package/src/commands/listen.js +20 -3
- package/src/commands/login.js +15 -4
- package/src/utils/qr-display.js +27 -16
- package/src/utils/qr-display.test.js +98 -0
- package/src/utils/qr-http-server.js +19 -5
package/README.md
CHANGED
|
@@ -30,6 +30,18 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
|
|
30
30
|
> 15+ nhóm lệnh · listen mode + webhook · 55+ ngân hàng VN · đa tài khoản + proxy
|
|
31
31
|
> Xem [skill/SKILL.md](skill/SKILL.md) · [Eval scenarios](skill/evals/)
|
|
32
32
|
|
|
33
|
+
> [!NOTE]
|
|
34
|
+
> **Zalo Official Account (OA)** — v1.1.0 hỗ trợ Zalo OA API v3.0 chính thức:
|
|
35
|
+
> ```bash
|
|
36
|
+
> zalo-agent oa init # Setup wizard (interactive)
|
|
37
|
+
> zalo-agent oa init --app-id <ID> --secret <KEY> --skip-webhook # Non-interactive (AI agent)
|
|
38
|
+
> zalo-agent oa whoami # Xem thông tin OA
|
|
39
|
+
> zalo-agent oa msg text <user-id> "Xin chào" # Gửi tin nhắn
|
|
40
|
+
> zalo-agent oa listen -p 3000 # Webhook listener
|
|
41
|
+
> ```
|
|
42
|
+
> OAuth login · gửi tin nhắn · quản lý follower · tag · webhook listener · VPS support
|
|
43
|
+
> Xem [docs/official-account.md](docs/official-account.md)
|
|
44
|
+
|
|
33
45
|
---
|
|
34
46
|
|
|
35
47
|
## Cài đặt
|
|
@@ -138,6 +150,18 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
|
|
|
138
150
|
> 15+ command groups · listen mode + webhook · 55+ VN banks · multi-account + proxy
|
|
139
151
|
> See [skill/SKILL.md](skill/SKILL.md) · [Eval scenarios](skill/evals/)
|
|
140
152
|
|
|
153
|
+
> [!NOTE]
|
|
154
|
+
> **Zalo Official Account (OA)** — v1.1.0 adds official Zalo OA API v3.0:
|
|
155
|
+
> ```bash
|
|
156
|
+
> zalo-agent oa init # Setup wizard (interactive)
|
|
157
|
+
> zalo-agent oa init --app-id <ID> --secret <KEY> --skip-webhook # Non-interactive (AI agent)
|
|
158
|
+
> zalo-agent oa whoami # OA profile
|
|
159
|
+
> zalo-agent oa msg text <user-id> "Hello" # Send message
|
|
160
|
+
> zalo-agent oa listen -p 3000 # Webhook listener
|
|
161
|
+
> ```
|
|
162
|
+
> OAuth login · messaging · follower management · tags · webhook listener · VPS support
|
|
163
|
+
> See [docs/official-account.md](docs/official-account.md)
|
|
164
|
+
|
|
141
165
|
### Quick Start
|
|
142
166
|
|
|
143
167
|
```bash
|
package/package.json
CHANGED
package/src/cli.test.js
CHANGED
package/src/commands/listen.js
CHANGED
|
@@ -108,7 +108,21 @@ export function registerListenCommand(program) {
|
|
|
108
108
|
if (opts.filter === "group" && msg.type !== THREAD_GROUP) return;
|
|
109
109
|
if (!opts.self && msg.isSelf) return;
|
|
110
110
|
|
|
111
|
-
const
|
|
111
|
+
const rawContent = msg.data.content;
|
|
112
|
+
const isText = typeof rawContent === "string";
|
|
113
|
+
const msgType = msg.data.msgType || null;
|
|
114
|
+
// Build readable display: show type + title/href for non-text
|
|
115
|
+
let displayContent;
|
|
116
|
+
if (isText) {
|
|
117
|
+
displayContent = rawContent;
|
|
118
|
+
} else if (rawContent && typeof rawContent === "object") {
|
|
119
|
+
const parts = [msgType || "attachment"];
|
|
120
|
+
if (rawContent.title) parts.push(`"${rawContent.title}"`);
|
|
121
|
+
if (rawContent.href) parts.push(rawContent.href);
|
|
122
|
+
displayContent = `[${parts.join(" | ")}]`;
|
|
123
|
+
} else {
|
|
124
|
+
displayContent = `[${msgType || "non-text"}]`;
|
|
125
|
+
}
|
|
112
126
|
const data = {
|
|
113
127
|
event: "message",
|
|
114
128
|
msgId: msg.data.msgId,
|
|
@@ -116,13 +130,16 @@ export function registerListenCommand(program) {
|
|
|
116
130
|
threadId: msg.threadId,
|
|
117
131
|
type: msg.type,
|
|
118
132
|
isSelf: msg.isSelf,
|
|
119
|
-
|
|
133
|
+
uidFrom: msg.data.uidFrom || null,
|
|
134
|
+
dName: msg.data.dName || null,
|
|
135
|
+
msgType,
|
|
136
|
+
content: rawContent,
|
|
120
137
|
};
|
|
121
138
|
const dir = msg.isSelf ? "→" : "←";
|
|
122
139
|
const typeLabel = msg.type === THREAD_USER ? "DM" : "GR";
|
|
123
140
|
emitEvent(
|
|
124
141
|
data,
|
|
125
|
-
`${dir} [${typeLabel}] [${msg.threadId}] ${
|
|
142
|
+
`${dir} [${typeLabel}] [${msg.threadId}] ${displayContent} (msgId: ${msg.data.msgId})`,
|
|
126
143
|
);
|
|
127
144
|
});
|
|
128
145
|
}
|
package/src/commands/login.js
CHANGED
|
@@ -26,6 +26,7 @@ export function registerLoginCommands(program) {
|
|
|
26
26
|
.option("-p, --proxy <url>", "Proxy URL (http/https/socks5://[user:pass@]host:port)")
|
|
27
27
|
.option("-n, --name <label>", "Friendly name for this account", "")
|
|
28
28
|
.option("--qr-url", "Start local HTTP server to view QR in browser (for VPS/headless)")
|
|
29
|
+
.option("-q, --qr-port <port>", "Port for QR HTTP server (default: 18927)", parseInt)
|
|
29
30
|
.option("--credentials <path>", "Login from exported credentials file (skip QR)")
|
|
30
31
|
.action(async (opts) => {
|
|
31
32
|
// Credential-based login (headless/CI)
|
|
@@ -55,8 +56,9 @@ export function registerLoginCommands(program) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
// QR-based login
|
|
59
|
+
const jsonMode = program.opts().json;
|
|
58
60
|
if (opts.proxy) info(`Using proxy: ${maskProxy(opts.proxy)}`);
|
|
59
|
-
info("Generating QR code... Scan with Zalo mobile app.");
|
|
61
|
+
if (!jsonMode) info("Generating QR code... Scan with Zalo mobile app.");
|
|
60
62
|
|
|
61
63
|
let qrServer = null;
|
|
62
64
|
try {
|
|
@@ -65,7 +67,7 @@ export function registerLoginCommands(program) {
|
|
|
65
67
|
|
|
66
68
|
// Always start HTTP server for QR scanning (no flag needed)
|
|
67
69
|
if (!qrServer) {
|
|
68
|
-
qrServer = startQrServer(getQRPath());
|
|
70
|
+
qrServer = startQrServer(getQRPath(), opts.qrPort || 18927);
|
|
69
71
|
}
|
|
70
72
|
});
|
|
71
73
|
|
|
@@ -79,9 +81,18 @@ export function registerLoginCommands(program) {
|
|
|
79
81
|
const creds = extractCredentials();
|
|
80
82
|
saveCredentials(ownId, creds);
|
|
81
83
|
addAccount(ownId, displayName, opts.proxy);
|
|
82
|
-
|
|
84
|
+
|
|
85
|
+
if (jsonMode) {
|
|
86
|
+
console.log(JSON.stringify({ event: "login_success", ownId, name: displayName }));
|
|
87
|
+
} else {
|
|
88
|
+
success(`Logged in as ${displayName} (${ownId})`);
|
|
89
|
+
}
|
|
83
90
|
} catch (e) {
|
|
84
|
-
|
|
91
|
+
if (jsonMode) {
|
|
92
|
+
console.log(JSON.stringify({ event: "login_error", message: e.message }));
|
|
93
|
+
} else {
|
|
94
|
+
error(`Login failed: ${e.message}`);
|
|
95
|
+
}
|
|
85
96
|
process.exit(1);
|
|
86
97
|
} finally {
|
|
87
98
|
if (qrServer) qrServer.close();
|
package/src/utils/qr-display.js
CHANGED
|
@@ -36,39 +36,50 @@ function getOpenCommand() {
|
|
|
36
36
|
/**
|
|
37
37
|
* Display QR code from a zca-js login QR event.
|
|
38
38
|
* Synchronous — safe to call from zca-js callback.
|
|
39
|
+
* In JSON mode (--json), outputs structured event for AI agents.
|
|
39
40
|
* @param {object} event - zca-js QR callback event
|
|
40
41
|
*/
|
|
41
42
|
export function displayQR(event) {
|
|
42
43
|
const imageB64 = event.data?.image || "";
|
|
44
|
+
const jsonMode = process.env.ZALO_JSON_MODE === "1";
|
|
43
45
|
|
|
44
|
-
//
|
|
45
|
-
// Uses iTerm2 inline image protocol (also WezTerm, Hyper, Kitty)
|
|
46
|
-
// Terminals that don't support it simply ignore the escape sequence
|
|
47
|
-
if (imageB64) {
|
|
48
|
-
const b64ForTerm = Buffer.from(imageB64, "base64").toString("base64");
|
|
49
|
-
process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 2. Save PNG to config dir
|
|
46
|
+
// Always save PNG to config dir (needed by HTTP server and agents)
|
|
53
47
|
if (imageB64) {
|
|
54
48
|
try {
|
|
55
49
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
56
50
|
writeFileSync(QR_PATH, Buffer.from(imageB64, "base64"));
|
|
57
|
-
const openCmd = getOpenCommand();
|
|
58
|
-
info(`QR image saved: ${QR_PATH}`);
|
|
59
|
-
info(`To open: ${openCmd} "${QR_PATH}"`);
|
|
60
51
|
} catch {}
|
|
61
52
|
}
|
|
62
53
|
|
|
63
|
-
//
|
|
54
|
+
// Also fire-and-forget the zca-js built-in save
|
|
64
55
|
if (event.actions?.saveToFile) {
|
|
65
56
|
event.actions.saveToFile(QR_PATH).catch(() => {});
|
|
66
57
|
}
|
|
67
58
|
|
|
68
|
-
//
|
|
59
|
+
// JSON mode: structured output for AI agents — no terminal escapes, no noise
|
|
60
|
+
if (jsonMode) {
|
|
61
|
+
if (imageB64) {
|
|
62
|
+
console.log(JSON.stringify({
|
|
63
|
+
event: "qr",
|
|
64
|
+
image: imageB64,
|
|
65
|
+
file: QR_PATH,
|
|
66
|
+
dataUrl: `data:image/png;base64,${imageB64}`,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Human mode: terminal inline image + hints
|
|
69
73
|
if (imageB64) {
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// iTerm2/Kitty/WezTerm inline image protocol
|
|
75
|
+
const b64ForTerm = Buffer.from(imageB64, "base64").toString("base64");
|
|
76
|
+
process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
|
|
77
|
+
|
|
78
|
+
const openCmd = getOpenCommand();
|
|
79
|
+
info(`QR image saved: ${QR_PATH}`);
|
|
80
|
+
info(`To open: ${openCmd} "${QR_PATH}"`);
|
|
81
|
+
info("Copy this URL and paste in any browser to view QR:");
|
|
82
|
+
console.log(`data:image/png;base64,${imageB64}`);
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QR display utility — JSON mode structured output for AI agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { displayQR, getQRPath } from "./qr-display.js";
|
|
8
|
+
import { existsSync, unlinkSync, mkdirSync } from "fs";
|
|
9
|
+
import { dirname } from "path";
|
|
10
|
+
|
|
11
|
+
// Tiny valid 1x1 white PNG as base64 (for testing without real QR)
|
|
12
|
+
const TINY_PNG_B64 =
|
|
13
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
14
|
+
|
|
15
|
+
describe("displayQR", () => {
|
|
16
|
+
let originalLog;
|
|
17
|
+
let captured;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// Capture console.log output
|
|
21
|
+
originalLog = console.log;
|
|
22
|
+
captured = [];
|
|
23
|
+
console.log = (...args) => captured.push(args.join(" "));
|
|
24
|
+
|
|
25
|
+
// Ensure config dir exists for file save
|
|
26
|
+
const qrDir = dirname(getQRPath());
|
|
27
|
+
mkdirSync(qrDir, { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
console.log = originalLog;
|
|
32
|
+
delete process.env.ZALO_JSON_MODE;
|
|
33
|
+
|
|
34
|
+
// Clean up saved QR file
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(getQRPath());
|
|
37
|
+
} catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("JSON mode outputs structured event with all fields", () => {
|
|
41
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
42
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
43
|
+
|
|
44
|
+
assert.equal(captured.length, 1, "should output exactly one JSON line");
|
|
45
|
+
const parsed = JSON.parse(captured[0]);
|
|
46
|
+
assert.equal(parsed.event, "qr");
|
|
47
|
+
assert.equal(parsed.image, TINY_PNG_B64);
|
|
48
|
+
assert.ok(parsed.file.endsWith("qr.png"), "file path should end with qr.png");
|
|
49
|
+
assert.ok(parsed.dataUrl.startsWith("data:image/png;base64,"), "dataUrl should be a data URL");
|
|
50
|
+
assert.ok(parsed.dataUrl.includes(TINY_PNG_B64), "dataUrl should contain full base64");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("JSON mode does not output terminal escape sequences", () => {
|
|
54
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
55
|
+
|
|
56
|
+
// Also capture stdout.write
|
|
57
|
+
const stdoutWrites = [];
|
|
58
|
+
const originalWrite = process.stdout.write;
|
|
59
|
+
process.stdout.write = (data) => stdoutWrites.push(data);
|
|
60
|
+
|
|
61
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
62
|
+
|
|
63
|
+
process.stdout.write = originalWrite;
|
|
64
|
+
|
|
65
|
+
// No iTerm2 escape sequences
|
|
66
|
+
const hasEscape = stdoutWrites.some((w) => typeof w === "string" && w.includes("\x1b]1337"));
|
|
67
|
+
assert.ok(!hasEscape, "JSON mode should not output terminal escape sequences");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("saves QR PNG file in both modes", () => {
|
|
71
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
72
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
73
|
+
assert.ok(existsSync(getQRPath()), "QR PNG should be saved to disk");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles empty image gracefully in JSON mode", () => {
|
|
77
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
78
|
+
displayQR({ data: {} });
|
|
79
|
+
assert.equal(captured.length, 0, "should not output anything for empty image");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("human mode outputs data URL with full base64 (not truncated)", () => {
|
|
83
|
+
// No ZALO_JSON_MODE set = human mode
|
|
84
|
+
// Suppress stdout.write (terminal escapes)
|
|
85
|
+
const originalWrite = process.stdout.write;
|
|
86
|
+
process.stdout.write = () => true;
|
|
87
|
+
|
|
88
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
89
|
+
|
|
90
|
+
process.stdout.write = originalWrite;
|
|
91
|
+
|
|
92
|
+
// Find the data URL line in captured output
|
|
93
|
+
const dataUrlLine = captured.find((line) => line.startsWith("data:image/png;base64,"));
|
|
94
|
+
assert.ok(dataUrlLine, "should output a data URL line");
|
|
95
|
+
assert.ok(dataUrlLine.includes(TINY_PNG_B64), "data URL should contain full base64, not truncated");
|
|
96
|
+
assert.ok(!dataUrlLine.includes("..."), "data URL should not be truncated with ...");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -124,14 +124,28 @@ setInterval(async()=>{
|
|
|
124
124
|
|
|
125
125
|
server.on("listening", async () => {
|
|
126
126
|
const actualPort = server.address().port;
|
|
127
|
-
|
|
127
|
+
const jsonMode = process.env.ZALO_JSON_MODE === "1";
|
|
128
|
+
let publicIp = null;
|
|
129
|
+
|
|
128
130
|
// Auto-detect public IP for VPS users
|
|
129
131
|
try {
|
|
130
132
|
const res = await nodefetch("https://api.ipify.org", { timeout: 3000 });
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
publicIp = (await res.text()).trim() || null;
|
|
134
|
+
} catch {}
|
|
135
|
+
|
|
136
|
+
if (jsonMode) {
|
|
137
|
+
console.log(JSON.stringify({
|
|
138
|
+
event: "qr_server",
|
|
139
|
+
port: actualPort,
|
|
140
|
+
localUrl: `http://localhost:${actualPort}/qr`,
|
|
141
|
+
publicUrl: publicIp ? `http://${publicIp}:${actualPort}/qr` : null,
|
|
142
|
+
}));
|
|
143
|
+
} else {
|
|
144
|
+
info(`QR available at: http://localhost:${actualPort}/qr`);
|
|
145
|
+
if (publicIp) info(`On VPS, open: http://${publicIp}:${actualPort}/qr`);
|
|
146
|
+
else info(`On VPS, open: http://<your-server-ip>:${actualPort}/qr`);
|
|
147
|
+
info(`If firewall blocks, run: sudo ufw allow ${actualPort}/tcp`);
|
|
148
|
+
info(`Or copy QR file: scp user@vps:~/.zalo-agent/qr.png ./qr.png`);
|
|
135
149
|
}
|
|
136
150
|
});
|
|
137
151
|
|