@ardennguyen/zalo-agent-cli 1.0.7

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 (52) hide show
  1. package/DISCLAIMER.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/package.json +64 -0
  5. package/src/cli.test.js +98 -0
  6. package/src/commands/account.js +178 -0
  7. package/src/commands/auto-reply.js +83 -0
  8. package/src/commands/catalog.js +143 -0
  9. package/src/commands/conv.js +264 -0
  10. package/src/commands/friend.js +384 -0
  11. package/src/commands/group.js +606 -0
  12. package/src/commands/label.js +39 -0
  13. package/src/commands/listen.js +293 -0
  14. package/src/commands/login.js +165 -0
  15. package/src/commands/mcp.js +219 -0
  16. package/src/commands/msg.js +608 -0
  17. package/src/commands/oa-init.js +503 -0
  18. package/src/commands/oa-listen.js +215 -0
  19. package/src/commands/oa.js +657 -0
  20. package/src/commands/poll.js +159 -0
  21. package/src/commands/profile.js +274 -0
  22. package/src/commands/quick-msg.js +55 -0
  23. package/src/commands/reminder.js +195 -0
  24. package/src/core/accounts.js +93 -0
  25. package/src/core/credentials.js +63 -0
  26. package/src/core/oa-client.js +407 -0
  27. package/src/core/zalo-client.js +201 -0
  28. package/src/index.js +95 -0
  29. package/src/mcp/mcp-config.js +81 -0
  30. package/src/mcp/mcp-http-transport.js +84 -0
  31. package/src/mcp/mcp-server.js +40 -0
  32. package/src/mcp/mcp-tools.js +353 -0
  33. package/src/mcp/media-downloader.js +191 -0
  34. package/src/mcp/media-downloader.test.js +45 -0
  35. package/src/mcp/message-buffer.js +127 -0
  36. package/src/mcp/message-buffer.test.js +308 -0
  37. package/src/mcp/notifier.js +141 -0
  38. package/src/mcp/notifier.test.js +269 -0
  39. package/src/mcp/thread-filter.js +79 -0
  40. package/src/mcp/thread-filter.test.js +206 -0
  41. package/src/mcp/thread-name-cache.js +162 -0
  42. package/src/mcp/thread-name-cache.test.js +139 -0
  43. package/src/utils/bank-helpers.js +233 -0
  44. package/src/utils/bank-helpers.test.js +66 -0
  45. package/src/utils/extract-message-text.js +21 -0
  46. package/src/utils/output.js +21 -0
  47. package/src/utils/proxy-helpers.js +14 -0
  48. package/src/utils/proxy-helpers.test.js +36 -0
  49. package/src/utils/qr-display.js +91 -0
  50. package/src/utils/qr-display.test.js +97 -0
  51. package/src/utils/qr-http-server.js +190 -0
  52. package/src/utils/update-check.js +56 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Cross-platform QR display utility.
3
+ * Displays Zalo's official QR PNG inline in terminal and saves to file.
4
+ *
5
+ * IMPORTANT: Uses Zalo-server-generated PNG (via event.data.image base64),
6
+ * NOT qrcode-terminal which re-encodes the token text into a different QR
7
+ * that Zalo app cannot recognize as a login request.
8
+ *
9
+ * Display methods:
10
+ * 1. iTerm2/Kitty/WezTerm inline image (renders PNG directly in terminal)
11
+ * 2. Save PNG to config dir
12
+ * 3. Base64 data URL (for IDE/agent preview)
13
+ * 4. File path with platform-specific open hint
14
+ */
15
+
16
+ import { resolve } from "path";
17
+ import { writeFileSync, mkdirSync } from "fs";
18
+ import { platform } from "os";
19
+ import { CONFIG_DIR } from "../core/credentials.js";
20
+ import { info } from "./output.js";
21
+
22
+ const QR_PATH = resolve(CONFIG_DIR, "qr.png");
23
+
24
+ /** Get platform-specific command to open a file. */
25
+ function getOpenCommand() {
26
+ switch (platform()) {
27
+ case "darwin":
28
+ return "open";
29
+ case "win32":
30
+ return "start";
31
+ default:
32
+ return "xdg-open";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Display QR code from a zca-js login QR event.
38
+ * Synchronous — safe to call from zca-js callback.
39
+ * In JSON mode (--json), outputs structured event for AI agents.
40
+ * @param {object} event - zca-js QR callback event
41
+ */
42
+ export function displayQR(event) {
43
+ const imageB64 = event.data?.image || "";
44
+ const jsonMode = process.env.ZALO_JSON_MODE === "1";
45
+
46
+ // Always save PNG to config dir (needed by HTTP server and agents)
47
+ if (imageB64) {
48
+ try {
49
+ mkdirSync(CONFIG_DIR, { recursive: true });
50
+ writeFileSync(QR_PATH, Buffer.from(imageB64, "base64"));
51
+ } catch {}
52
+ }
53
+
54
+ // Also fire-and-forget the zca-js built-in save
55
+ if (event.actions?.saveToFile) {
56
+ event.actions.saveToFile(QR_PATH).catch(() => {});
57
+ }
58
+
59
+ // JSON mode: structured output for AI agents — no terminal escapes, no noise
60
+ if (jsonMode) {
61
+ if (imageB64) {
62
+ console.log(
63
+ JSON.stringify({
64
+ event: "qr",
65
+ image: imageB64,
66
+ file: QR_PATH,
67
+ dataUrl: `data:image/png;base64,${imageB64}`,
68
+ }),
69
+ );
70
+ }
71
+ return;
72
+ }
73
+
74
+ // Human mode: terminal inline image + hints
75
+ if (imageB64) {
76
+ // iTerm2/Kitty/WezTerm inline image protocol
77
+ const b64ForTerm = Buffer.from(imageB64, "base64").toString("base64");
78
+ process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
79
+
80
+ const openCmd = getOpenCommand();
81
+ info(`QR image saved: ${QR_PATH}`);
82
+ info(`To open: ${openCmd} "${QR_PATH}"`);
83
+ info("Copy this URL and paste in any browser to view QR:");
84
+ console.log(`data:image/png;base64,${imageB64}`);
85
+ }
86
+ }
87
+
88
+ /** Get the QR image path. */
89
+ export function getQRPath() {
90
+ return QR_PATH;
91
+ }
@@ -0,0 +1,97 @@
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 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
13
+
14
+ describe("displayQR", () => {
15
+ let originalLog;
16
+ let captured;
17
+
18
+ beforeEach(() => {
19
+ // Capture console.log output
20
+ originalLog = console.log;
21
+ captured = [];
22
+ console.log = (...args) => captured.push(args.join(" "));
23
+
24
+ // Ensure config dir exists for file save
25
+ const qrDir = dirname(getQRPath());
26
+ mkdirSync(qrDir, { recursive: true });
27
+ });
28
+
29
+ afterEach(() => {
30
+ console.log = originalLog;
31
+ delete process.env.ZALO_JSON_MODE;
32
+
33
+ // Clean up saved QR file
34
+ try {
35
+ unlinkSync(getQRPath());
36
+ } catch {}
37
+ });
38
+
39
+ it("JSON mode outputs structured event with all fields", () => {
40
+ process.env.ZALO_JSON_MODE = "1";
41
+ displayQR({ data: { image: TINY_PNG_B64 } });
42
+
43
+ assert.equal(captured.length, 1, "should output exactly one JSON line");
44
+ const parsed = JSON.parse(captured[0]);
45
+ assert.equal(parsed.event, "qr");
46
+ assert.equal(parsed.image, TINY_PNG_B64);
47
+ assert.ok(parsed.file.endsWith("qr.png"), "file path should end with qr.png");
48
+ assert.ok(parsed.dataUrl.startsWith("data:image/png;base64,"), "dataUrl should be a data URL");
49
+ assert.ok(parsed.dataUrl.includes(TINY_PNG_B64), "dataUrl should contain full base64");
50
+ });
51
+
52
+ it("JSON mode does not output terminal escape sequences", () => {
53
+ process.env.ZALO_JSON_MODE = "1";
54
+
55
+ // Also capture stdout.write
56
+ const stdoutWrites = [];
57
+ const originalWrite = process.stdout.write;
58
+ process.stdout.write = (data) => stdoutWrites.push(data);
59
+
60
+ displayQR({ data: { image: TINY_PNG_B64 } });
61
+
62
+ process.stdout.write = originalWrite;
63
+
64
+ // No iTerm2 escape sequences
65
+ const hasEscape = stdoutWrites.some((w) => typeof w === "string" && w.includes("\x1b]1337"));
66
+ assert.ok(!hasEscape, "JSON mode should not output terminal escape sequences");
67
+ });
68
+
69
+ it("saves QR PNG file in both modes", () => {
70
+ process.env.ZALO_JSON_MODE = "1";
71
+ displayQR({ data: { image: TINY_PNG_B64 } });
72
+ assert.ok(existsSync(getQRPath()), "QR PNG should be saved to disk");
73
+ });
74
+
75
+ it("handles empty image gracefully in JSON mode", () => {
76
+ process.env.ZALO_JSON_MODE = "1";
77
+ displayQR({ data: {} });
78
+ assert.equal(captured.length, 0, "should not output anything for empty image");
79
+ });
80
+
81
+ it("human mode outputs data URL with full base64 (not truncated)", () => {
82
+ // No ZALO_JSON_MODE set = human mode
83
+ // Suppress stdout.write (terminal escapes)
84
+ const originalWrite = process.stdout.write;
85
+ process.stdout.write = () => true;
86
+
87
+ displayQR({ data: { image: TINY_PNG_B64 } });
88
+
89
+ process.stdout.write = originalWrite;
90
+
91
+ // Find the data URL line in captured output
92
+ const dataUrlLine = captured.find((line) => line.startsWith("data:image/png;base64,"));
93
+ assert.ok(dataUrlLine, "should output a data URL line");
94
+ assert.ok(dataUrlLine.includes(TINY_PNG_B64), "data URL should contain full base64, not truncated");
95
+ assert.ok(!dataUrlLine.includes("..."), "data URL should not be truncated with ...");
96
+ });
97
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Temporary local HTTP server for QR display on headless/VPS environments.
3
+ * Serves QR image as a simple HTML page on localhost.
4
+ * Auto-closes after login completes.
5
+ */
6
+
7
+ import http from "http";
8
+ import { readFileSync, existsSync } from "fs";
9
+ import nodefetch from "node-fetch";
10
+ import { info } from "./output.js";
11
+
12
+ /**
13
+ * Start a temporary HTTP server that serves the QR image.
14
+ * @param {string} qrImagePath - path to QR PNG file
15
+ * @param {number} port - default 18927
16
+ * @returns {{ url: string, close: () => void }}
17
+ */
18
+ export function startQrServer(qrImagePath, port = 18927, tryPorts = [18927, 8080, 3000, 9000]) {
19
+ const server = http.createServer(async (req, res) => {
20
+ if (req.url === "/qr" || req.url === "/") {
21
+ if (!existsSync(qrImagePath)) {
22
+ res.writeHead(200, { "Content-Type": "text/html" });
23
+ res.end(`<!DOCTYPE html><html><head><meta charset="utf-8">
24
+ <meta http-equiv="refresh" content="2">
25
+ <title>Waiting for QR...</title></head>
26
+ <body style="display:flex;justify-content:center;align-items:center;height:100vh;background:#111;color:#fff;font-family:system-ui">
27
+ <p>QR code generating... auto-refreshing in 2s</p></body></html>`);
28
+ return;
29
+ }
30
+ const img = readFileSync(qrImagePath);
31
+ const b64 = img.toString("base64");
32
+ // Embed mascot inline (read from assets if available, else skip)
33
+ let mascotB64 = "";
34
+ try {
35
+ const { resolve: resolvePath } = await import("path");
36
+ const { fileURLToPath } = await import("url");
37
+ const { dirname } = await import("path");
38
+ const dir = dirname(fileURLToPath(import.meta.url));
39
+ const mascotPath = resolvePath(dir, "../../assets/mascot.png");
40
+ if (existsSync(mascotPath)) {
41
+ mascotB64 = readFileSync(mascotPath).toString("base64");
42
+ }
43
+ } catch {}
44
+ const mascotImg = mascotB64
45
+ ? `<img src="data:image/png;base64,${mascotB64}" class="mascot" alt="zalo-agent"/>`
46
+ : "";
47
+ res.writeHead(200, { "Content-Type": "text/html" });
48
+ res.end(`<!DOCTYPE html>
49
+ <html><head><meta charset="utf-8"><title>Zalo Agent — QR Login</title>
50
+ <meta name="viewport" content="width=device-width,initial-scale=1">
51
+ <style>
52
+ *{box-sizing:border-box}
53
+ body{display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;
54
+ background:linear-gradient(135deg,#0a1628 0%,#111d33 50%,#0d1f3c 100%);font-family:system-ui,-apple-system,sans-serif}
55
+ .card{text-align:center;padding:2.5rem 2rem;background:rgba(17,29,51,0.9);
56
+ border-radius:20px;max-width:420px;width:90%;
57
+ border:1px solid rgba(59,130,246,0.2);backdrop-filter:blur(10px);
58
+ box-shadow:0 20px 60px rgba(0,0,0,0.5),0 0 40px rgba(59,130,246,0.1)}
59
+ .mascot{width:80px;height:80px;border-radius:50%;margin-bottom:0.5rem;
60
+ border:2px solid rgba(59,130,246,0.4);box-shadow:0 0 20px rgba(59,130,246,0.2)}
61
+ h1{color:#e2e8f0;font-size:1.1rem;font-weight:600;margin:0.5rem 0}
62
+ .brand{color:#3b82f6;font-size:0.8rem;margin-bottom:1.5rem;opacity:0.8}
63
+ .qr-img{width:260px;height:260px;border-radius:12px;border:3px solid rgba(59,130,246,0.3);
64
+ box-shadow:0 0 30px rgba(59,130,246,0.15)}
65
+ .hint{color:#94a3b8;font-size:0.85rem;margin-top:1.2rem;line-height:1.5}
66
+ .hint strong{color:#60a5fa}
67
+ .success-text{color:#4ade80;font-size:1.3rem;font-weight:bold}
68
+ .hidden{display:none}
69
+ .footer{color:#475569;font-size:0.7rem;margin-top:1.5rem}
70
+ .footer a{color:#3b82f6;text-decoration:none}
71
+ </style></head>
72
+ <body><div class="card">
73
+ <div id="qr-view">
74
+ ${mascotImg}
75
+ <h1>Zalo Agent CLI</h1>
76
+ <p class="brand">QR Code Login</p>
77
+ <img src="data:image/png;base64,${b64}" class="qr-img" alt="QR Code"/>
78
+ <p class="hint">Open <strong>Zalo app</strong> > <strong>QR Scanner</strong> to scan</p>
79
+ <p class="footer">Powered by <a href="https://github.com/ardennguyen/zalo-agent-cli">zalo-agent-cli</a></p>
80
+ </div>
81
+ <div id="success-view" class="hidden">
82
+ ${mascotImg}
83
+ <h1 style="color:#4ade80;font-size:1.5rem;margin:1rem 0">Login Successful!</h1>
84
+ <p class="success-text">You can close this page now.</p>
85
+ <p class="hint">The CLI is ready to use.</p>
86
+ </div>
87
+ </div>
88
+ <script>
89
+ // Poll /status to detect login success
90
+ setInterval(async()=>{
91
+ try{
92
+ const r=await fetch('/status');
93
+ const d=await r.json();
94
+ if(d.loggedIn){
95
+ document.getElementById('qr-view').classList.add('hidden');
96
+ document.getElementById('success-view').classList.remove('hidden');
97
+ }
98
+ }catch{}
99
+ },2000);
100
+ </script>
101
+ </body></html>`);
102
+ } else if (req.url === "/qr.png") {
103
+ // Raw PNG image endpoint — for agents to send direct image link
104
+ if (!existsSync(qrImagePath)) {
105
+ res.writeHead(404, { "Content-Type": "text/plain" });
106
+ res.end("QR not generated yet");
107
+ return;
108
+ }
109
+ const img = readFileSync(qrImagePath);
110
+ res.writeHead(200, {
111
+ "Content-Type": "image/png",
112
+ "Content-Length": img.length,
113
+ "Cache-Control": "no-cache, no-store",
114
+ });
115
+ res.end(img);
116
+ } else if (req.url === "/status") {
117
+ // Login status endpoint for browser polling
118
+ let loggedIn = false;
119
+ try {
120
+ const { isLoggedIn } = await import("../core/zalo-client.js");
121
+ loggedIn = isLoggedIn();
122
+ } catch {}
123
+ res.writeHead(200, { "Content-Type": "application/json" });
124
+ res.end(JSON.stringify({ loggedIn }));
125
+ } else {
126
+ res.writeHead(404);
127
+ res.end("Not found");
128
+ }
129
+ });
130
+
131
+ // Try ports in order until one works (avoids firewall issues)
132
+ let currentPortIdx = 0;
133
+
134
+ function tryListen() {
135
+ const p = tryPorts[currentPortIdx];
136
+ server.listen(p, "0.0.0.0");
137
+ }
138
+
139
+ server.on("listening", async () => {
140
+ const actualPort = server.address().port;
141
+ const jsonMode = process.env.ZALO_JSON_MODE === "1";
142
+ let publicIp = null;
143
+
144
+ // Auto-detect public IP for VPS users
145
+ try {
146
+ const res = await nodefetch("https://api.ipify.org", { timeout: 3000 });
147
+ publicIp = (await res.text()).trim() || null;
148
+ } catch {}
149
+
150
+ if (jsonMode) {
151
+ console.log(
152
+ JSON.stringify({
153
+ event: "qr_server",
154
+ port: actualPort,
155
+ localUrl: `http://localhost:${actualPort}/qr`,
156
+ imageUrl: `http://localhost:${actualPort}/qr.png`,
157
+ publicUrl: publicIp ? `http://${publicIp}:${actualPort}/qr` : null,
158
+ publicImageUrl: publicIp ? `http://${publicIp}:${actualPort}/qr.png` : null,
159
+ }),
160
+ );
161
+ } else {
162
+ info(`QR available at: http://localhost:${actualPort}/qr`);
163
+ if (publicIp) info(`On VPS, open: http://${publicIp}:${actualPort}/qr`);
164
+ else info(`On VPS, open: http://<your-server-ip>:${actualPort}/qr`);
165
+ info(`If firewall blocks, run: sudo ufw allow ${actualPort}/tcp`);
166
+ info(`Or copy QR file: scp user@vps:~/.zalo-agent/qr.png ./qr.png`);
167
+ }
168
+ });
169
+
170
+ server.on("error", (err) => {
171
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
172
+ currentPortIdx++;
173
+ if (currentPortIdx < tryPorts.length) {
174
+ info(`Port ${tryPorts[currentPortIdx - 1]} unavailable, trying ${tryPorts[currentPortIdx]}...`);
175
+ tryListen();
176
+ }
177
+ }
178
+ });
179
+
180
+ tryListen();
181
+
182
+ return {
183
+ url: `http://localhost:${port}/qr`,
184
+ close: () => {
185
+ try {
186
+ server.close();
187
+ } catch {}
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Non-blocking update check — warns user if a newer version is available on npm.
3
+ * Runs silently in background; never blocks CLI execution.
4
+ */
5
+
6
+ import { execSync } from "node:child_process";
7
+ import { warning } from "./output.js";
8
+
9
+ /**
10
+ * Check npm registry for the latest version, warn if outdated.
11
+ * Uses "npm view @ardennguyen/zalo-agent-cli version" to fetch from the npm registry.
12
+ *
13
+ * Skipped automatically when stdout is piped (e.g. tests, scripts, JSON mode, MCP).
14
+ *
15
+ * @param {string} currentVersion - Current package version (from package.json)
16
+ * @param {boolean} jsonMode - Suppress output in JSON mode
17
+ */
18
+ export function checkForUpdates(currentVersion, jsonMode) {
19
+ if (jsonMode) return;
20
+ if (!process.stdout.isTTY) return; // skip in piped / scripted mode
21
+ if (process.env.ZALO_AGENT_NO_UPDATE_CHECK) return; // explicit opt-out
22
+
23
+ try {
24
+ const latest = execSync("npm view @ardennguyen/zalo-agent-cli version", {
25
+ encoding: "utf8",
26
+ timeout: 5000,
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ windowsHide: true,
29
+ }).trim();
30
+
31
+ if (latest && latest !== currentVersion) {
32
+ warning(
33
+ `Update available: ${currentVersion} → ${latest}. Run: npm install -g @ardennguyen/zalo-agent-cli@latest`,
34
+ );
35
+ }
36
+ } catch {
37
+ // Silent failure — network issues shouldn't block CLI usage
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Self-update by re-installing the latest version from npm registry.
43
+ * @returns {boolean} success
44
+ */
45
+ export function selfUpdate() {
46
+ try {
47
+ execSync("npm install -g @ardennguyen/zalo-agent-cli@latest", {
48
+ encoding: "utf8",
49
+ stdio: "inherit",
50
+ timeout: 60000,
51
+ });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }