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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy, bank transfers, QR payments, Official Account API v3.0",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.test.js CHANGED
@@ -83,6 +83,7 @@ describe("CLI interface", () => {
83
83
  assert.match(out, /--proxy/);
84
84
  assert.match(out, /--credentials/);
85
85
  assert.match(out, /--qr-url/);
86
+ assert.match(out, /--qr-port/);
86
87
  });
87
88
 
88
89
  it("logout --help shows --purge", () => {
@@ -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 content = typeof msg.data.content === "string" ? msg.data.content : "[non-text]";
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
- content,
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}] ${content} (msgId: ${msg.data.msgId})`,
142
+ `${dir} [${typeLabel}] [${msg.threadId}] ${displayContent} (msgId: ${msg.data.msgId})`,
126
143
  );
127
144
  });
128
145
  }
@@ -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
- success(`Logged in as ${displayName} (${ownId})`);
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
- error(`Login failed: ${e.message}`);
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();
@@ -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
- // 1. Display Zalo's official QR PNG inline in terminal
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
- // 3. Also fire-and-forget the zca-js built-in save
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
- // 4. Base64 data URL (for coding agents / IDE preview)
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
- info("QR data URL (for IDE preview):");
71
- console.log(` data:image/png;base64,${imageB64.substring(0, 100)}...`);
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
- info(`QR available at: http://localhost:${actualPort}/qr`);
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
- const ip = (await res.text()).trim();
132
- if (ip) info(`On VPS, open: http://${ip}:${actualPort}/qr`);
133
- } catch {
134
- info(`On VPS, open: http://<your-server-ip>:${actualPort}/qr`);
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