zalo-agent-cli 1.6.0 → 1.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
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": {
@@ -18,7 +18,8 @@
18
18
  "lint:fix": "eslint src/ --fix",
19
19
  "format": "prettier --write src/",
20
20
  "format:check": "prettier --check src/",
21
- "release": "npm version patch -m 'release: v%s' && git push origin main --tags"
21
+ "release": "npm version patch -m 'release: v%s' && git push origin main --tags",
22
+ "prepublishOnly": "npm run lint && npm test"
22
23
  },
23
24
  "keywords": [
24
25
  "zalo",
@@ -48,7 +49,8 @@
48
49
  "express": "^5.2.1",
49
50
  "https-proxy-agent": "^8.0.0",
50
51
  "node-fetch": "^3.3.0",
51
- "zca-js": "^2.1.1",
52
+ "undici": "^7.24.6",
53
+ "zca-js": "^2.1.2",
52
54
  "zod": "^4.3.6"
53
55
  },
54
56
  "devDependencies": {
@@ -65,7 +65,10 @@ export function registerListenCommand(program) {
65
65
  method: "POST",
66
66
  headers: { "Content-Type": "application/json" },
67
67
  body: JSON.stringify(data),
68
- }).catch(() => {});
68
+ signal: AbortSignal.timeout(5000),
69
+ }).catch((e) => {
70
+ console.error(`[listen] Webhook failed: ${e.message}`);
71
+ });
69
72
  }
70
73
 
71
74
  // Setup save directory if --save flag provided
@@ -84,7 +87,9 @@ export function registerListenCommand(program) {
84
87
  const line = JSON.stringify({ ...data, savedAt: new Date().toISOString() }) + "\n";
85
88
  try {
86
89
  appendFileSync(filepath, line, "utf-8");
87
- } catch {}
90
+ } catch (e) {
91
+ console.error(`[listen] Save failed: ${e.message}`);
92
+ }
88
93
  }
89
94
 
90
95
  /** Output event as JSON or human-readable, save locally, then post to webhook */
@@ -276,7 +281,9 @@ export function registerListenCommand(program) {
276
281
  process.on("SIGINT", () => {
277
282
  try {
278
283
  getApi().listener.stop();
279
- } catch {}
284
+ } catch (e) {
285
+ console.error(`[listen] Stop failed: ${e.message}`);
286
+ }
280
287
  info(`Stopped. Uptime: ${uptime()}, events: ${eventCount}, reconnects: ${reconnectCount}`);
281
288
  if (saveDir) info(`Messages saved to: ${saveDir}`);
282
289
  resolve();
@@ -56,6 +56,7 @@ export function registerMCPCommands(program) {
56
56
  .option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
57
57
  .option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
58
58
  .option("--auth <token>", "Bearer token for HTTP auth (only with --http)")
59
+ .option("--host <address>", "HTTP bind address (default: 127.0.0.1, only with --http)")
59
60
  .action(async (opts) => {
60
61
  // Perform login explicitly here — preAction hook skips "mcp"
61
62
  try {
@@ -84,11 +85,17 @@ export function registerMCPCommands(program) {
84
85
  }
85
86
 
86
87
  // Start MCP server — stdio (default) or HTTP
88
+ let httpServer = null;
87
89
  try {
88
90
  if (opts.http) {
89
91
  const port = Number(opts.http);
92
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
93
+ console.error(`[mcp] Invalid port: ${opts.http}. Must be 1-65535.`);
94
+ process.exit(1);
95
+ }
90
96
  const deps = { api: getApi(), buffer, filter, config, nameCache };
91
- createHTTPServer(registerTools, deps, port, opts.auth || null);
97
+ const authToken = opts.auth?.trim() || null;
98
+ httpServer = createHTTPServer(registerTools, deps, port, authToken, opts.host || "127.0.0.1");
92
99
  console.error(`[mcp] HTTP server started on port ${port}`);
93
100
  } else {
94
101
  await createMCPServer(getApi(), buffer, filter, config, nameCache);
@@ -195,6 +202,16 @@ export function registerMCPCommands(program) {
195
202
  process.exit(1);
196
203
  }
197
204
 
205
+ // Graceful shutdown on SIGINT
206
+ process.on("SIGINT", () => {
207
+ try {
208
+ getApi().listener.stop();
209
+ } catch {}
210
+ notifier?.destroy();
211
+ httpServer?.close();
212
+ process.exit(0);
213
+ });
214
+
198
215
  // Keep process alive (MCP server runs on stdio — process must not exit)
199
216
  await new Promise(() => {});
200
217
  });
@@ -6,7 +6,7 @@
6
6
  import fs from "fs";
7
7
  import { Zalo, LoginQRCallbackEventType } from "zca-js";
8
8
  import { HttpsProxyAgent } from "https-proxy-agent";
9
- import nodefetch from "node-fetch";
9
+ import { ProxyAgent } from "undici";
10
10
  import { getActive } from "./accounts.js";
11
11
  import { loadCredentials } from "./credentials.js";
12
12
  import { info } from "../utils/output.js";
@@ -91,6 +91,15 @@ export function isLoggedIn() {
91
91
  return _api !== null;
92
92
  }
93
93
 
94
+ /**
95
+ * Create a proxy-aware fetch that uses undici ProxyAgent dispatcher.
96
+ * Native Node.js fetch ignores the `agent` option — must use `dispatcher`.
97
+ */
98
+ function createProxyFetch(proxyUrl) {
99
+ const dispatcher = new ProxyAgent(proxyUrl);
100
+ return (url, init = {}) => fetch(url, { ...init, dispatcher });
101
+ }
102
+
94
103
  /** Create a Zalo instance with optional proxy. Suppress logs in JSON mode. */
95
104
  function createZalo(proxyUrl) {
96
105
  const opts = {
@@ -99,8 +108,9 @@ function createZalo(proxyUrl) {
99
108
  imageMetadataGetter: readImageMetadata,
100
109
  };
101
110
  if (proxyUrl) {
111
+ // HttpsProxyAgent for WebSocket (ws lib), ProxyAgent dispatcher for HTTP fetch
102
112
  opts.agent = new HttpsProxyAgent(proxyUrl);
103
- opts.polyfill = nodefetch;
113
+ opts.polyfill = createProxyFetch(proxyUrl);
104
114
  }
105
115
  return new Zalo(opts);
106
116
  }
@@ -4,9 +4,16 @@
4
4
  */
5
5
 
6
6
  import express from "express";
7
+ import { timingSafeEqual } from "crypto";
8
+ import { readFileSync } from "fs";
9
+ import { fileURLToPath } from "url";
10
+ import { dirname, join } from "path";
7
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
12
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
13
 
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
16
+
10
17
  /**
11
18
  * Create HTTP MCP server with optional bearer token auth.
12
19
  * @param {Function} registerToolsFn - Registers tools on a McpServer instance
@@ -15,7 +22,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
15
22
  * @param {string|null} authToken - Bearer token (null = no auth)
16
23
  * @returns {import("http").Server}
17
24
  */
18
- export function createHTTPServer(registerToolsFn, deps, port, authToken) {
25
+ export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1") {
19
26
  const app = express();
20
27
  app.use(express.json());
21
28
 
@@ -23,8 +30,10 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
23
30
  if (authToken) {
24
31
  app.use((req, res, next) => {
25
32
  if (req.path === "/health") return next();
26
- const token = req.headers.authorization?.slice(7); // strip "Bearer "
27
- if (token !== authToken) {
33
+ const token = req.headers.authorization?.slice(7) || "";
34
+ const expected = Buffer.from(authToken, "utf8");
35
+ const received = Buffer.from(token, "utf8");
36
+ if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
28
37
  return res.status(401).json({ error: "Unauthorized" });
29
38
  }
30
39
  next();
@@ -34,7 +43,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
34
43
  // MCP endpoint — stateless, fresh server+transport per request
35
44
  app.post("/mcp", async (req, res) => {
36
45
  try {
37
- const server = new McpServer({ name: "zalo-agent", version: "1.0.0" });
46
+ const server = new McpServer({ name: "zalo-agent", version: pkg.version });
38
47
  registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config, deps.nameCache);
39
48
 
40
49
  const transport = new StreamableHTTPServerTransport({
@@ -69,7 +78,7 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
69
78
  });
70
79
  });
71
80
 
72
- return app.listen(port, "0.0.0.0", () => {
73
- console.error(`MCP HTTP server listening on port ${port}`);
81
+ return app.listen(port, host, () => {
82
+ console.error(`MCP HTTP server listening on ${host}:${port}`);
74
83
  });
75
84
  }
@@ -3,10 +3,16 @@
3
3
  * Creates and connects the McpServer instance for Claude Code integration.
4
4
  */
5
5
 
6
+ import { readFileSync } from "fs";
7
+ import { fileURLToPath } from "url";
8
+ import { dirname, join } from "path";
6
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
11
  import { registerTools } from "./mcp-tools.js";
9
12
 
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
15
+
10
16
  /**
11
17
  * Create and start MCP server with stdio transport.
12
18
  * All logs MUST use console.error() — stdout is reserved for MCP protocol.
@@ -20,7 +26,7 @@ import { registerTools } from "./mcp-tools.js";
20
26
  export async function createMCPServer(api, buffer, filter, config, nameCache) {
21
27
  const server = new McpServer({
22
28
  name: "zalo-agent",
23
- version: "1.0.0",
29
+ version: pkg.version,
24
30
  });
25
31
 
26
32
  registerTools(server, api, buffer, filter, config, nameCache);
@@ -117,11 +117,9 @@ export function registerTools(server, api, buffer, filter, config, nameCache) {
117
117
  async ({ type }) => {
118
118
  try {
119
119
  const stats = buffer.getStats(0);
120
- // Enrich each stat entry with threadType by peeking at the first buffered message.
121
- // buffer._threads is a Map<threadId, { messages: Array, lastActivity: number }>
120
+ // Enrich each stat entry with threadType and thread name
122
121
  const enriched = stats.map((t) => {
123
- const thread = buffer._threads.get(t.threadId);
124
- const threadType = thread?.messages?.[0]?.threadType ?? "unknown";
122
+ const threadType = buffer.getThreadType(t.threadId) ?? "unknown";
125
123
  const cached = nameCache?.get(t.threadId);
126
124
  return {
127
125
  ...t,
@@ -7,7 +7,7 @@
7
7
  import { mkdirSync, writeFileSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { platform } from "os";
10
- import { exec } from "child_process";
10
+ import { execFile } from "child_process";
11
11
  import { CONFIG_DIR } from "../core/credentials.js";
12
12
 
13
13
  /** Default download directory when not configured */
@@ -123,9 +123,11 @@ function buildFileName(message, ext) {
123
123
  * @param {string} filePath
124
124
  */
125
125
  export function openFile(filePath) {
126
+ const isWin = platform() === "win32";
126
127
  const cmds = { darwin: "open", win32: "start", linux: "xdg-open" };
127
128
  const cmd = cmds[platform()] || "xdg-open";
128
- exec(`${cmd} "${filePath}"`, (err) => {
129
+ // Windows "start" is a cmd.exe builtin — needs shell: true
130
+ execFile(cmd, isWin ? ["", filePath] : [filePath], { shell: isWin }, (err) => {
129
131
  if (err) console.error(`[media-dl] Failed to open viewer: ${err.message}`);
130
132
  });
131
133
  }
@@ -94,6 +94,16 @@ export class MessageBuffer {
94
94
  return stats;
95
95
  }
96
96
 
97
+ /**
98
+ * Get thread type from first buffered message.
99
+ * @param {string} threadId
100
+ * @returns {string|null}
101
+ */
102
+ getThreadType(threadId) {
103
+ const thread = this._threads.get(threadId);
104
+ return thread?.messages?.[0]?.threadType ?? null;
105
+ }
106
+
97
107
  /**
98
108
  * Evict messages that exceed maxSize or maxAge for a given thread.
99
109
  * @param {string} threadId
@@ -265,6 +265,32 @@ describe("MessageBuffer getStats", () => {
265
265
  });
266
266
  });
267
267
 
268
+ describe("MessageBuffer getThreadType", () => {
269
+ it("returns threadType from first buffered message", () => {
270
+ const buf = new MessageBuffer();
271
+ buf.push("t1", { text: "hi", threadType: "dm", timestamp: Date.now() });
272
+ buf.push("t1", { text: "hey", threadType: "dm", timestamp: Date.now() });
273
+ assert.equal(buf.getThreadType("t1"), "dm");
274
+ });
275
+
276
+ it("returns null for unknown thread", () => {
277
+ const buf = new MessageBuffer();
278
+ assert.equal(buf.getThreadType("nonexistent"), null);
279
+ });
280
+
281
+ it("returns null when thread exists but messages have no threadType", () => {
282
+ const buf = new MessageBuffer();
283
+ buf.push("t1", { text: "hi", timestamp: Date.now() });
284
+ assert.equal(buf.getThreadType("t1"), null);
285
+ });
286
+
287
+ it("returns group for group thread", () => {
288
+ const buf = new MessageBuffer();
289
+ buf.push("g1", { text: "hello", threadType: "group", timestamp: Date.now() });
290
+ assert.equal(buf.getThreadType("g1"), "group");
291
+ });
292
+ });
293
+
268
294
  describe("MessageBuffer edge cases", () => {
269
295
  it("empty read on fresh buffer returns empty array and cursor 0", () => {
270
296
  const buf = new MessageBuffer();