zalo-agent-cli 1.6.0 → 1.6.1
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 +3 -2
- package/src/commands/listen.js +10 -3
- package/src/commands/mcp.js +16 -1
- package/src/mcp/mcp-http-transport.js +15 -6
- package/src/mcp/mcp-server.js +7 -1
- package/src/mcp/mcp-tools.js +2 -4
- package/src/mcp/media-downloader.js +4 -2
- package/src/mcp/message-buffer.js +10 -0
- package/src/mcp/message-buffer.test.js +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zalo-agent-cli",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
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",
|
package/src/commands/listen.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
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,14 @@ export function registerMCPCommands(program) {
|
|
|
195
202
|
process.exit(1);
|
|
196
203
|
}
|
|
197
204
|
|
|
205
|
+
// Graceful shutdown on SIGINT
|
|
206
|
+
process.on("SIGINT", () => {
|
|
207
|
+
try { getApi().listener.stop(); } catch {}
|
|
208
|
+
notifier?.destroy();
|
|
209
|
+
httpServer?.close();
|
|
210
|
+
process.exit(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
198
213
|
// Keep process alive (MCP server runs on stdio — process must not exit)
|
|
199
214
|
await new Promise(() => {});
|
|
200
215
|
});
|
|
@@ -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)
|
|
27
|
-
|
|
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:
|
|
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,
|
|
73
|
-
console.error(`MCP HTTP server listening on
|
|
81
|
+
return app.listen(port, host, () => {
|
|
82
|
+
console.error(`MCP HTTP server listening on ${host}:${port}`);
|
|
74
83
|
});
|
|
75
84
|
}
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -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:
|
|
29
|
+
version: pkg.version,
|
|
24
30
|
});
|
|
25
31
|
|
|
26
32
|
registerTools(server, api, buffer, filter, config, nameCache);
|
package/src/mcp/mcp-tools.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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();
|