zalo-agent-cli 1.2.0 → 1.4.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
@@ -42,6 +42,16 @@ Xây dựng trên [zca-js](https://github.com/RFS-ADRENO/zca-js).
42
42
  > OAuth login · gửi tin nhắn · quản lý follower · tag · webhook listener · VPS support
43
43
  > Xem [docs/official-account.md](docs/official-account.md)
44
44
 
45
+ > [!TIP]
46
+ > **MCP Server (AI Agent Integration)** — v1.2.0 hỗ trợ Model Context Protocol cho Claude Code và các MCP client:
47
+ > ```bash
48
+ > zalo-agent mcp start # stdio (local Claude Code)
49
+ > zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
50
+ > ```
51
+ > 4 tools: get_messages · send_message · list_threads · mark_read
52
+ > Auto-reconnect · thread filter · noise reduction · group notifications
53
+ > Xem [MCP Guide](skill/references/mcp-guide.md)
54
+
45
55
  ---
46
56
 
47
57
  ## Cài đặt
@@ -162,6 +172,16 @@ CLI tool for Zalo automation — multi-account, proxy support, bank transfers, Q
162
172
  > OAuth login · messaging · follower management · tags · webhook listener · VPS support
163
173
  > See [docs/official-account.md](docs/official-account.md)
164
174
 
175
+ > [!TIP]
176
+ > **MCP Server (AI Agent Integration)** — v1.2.0 adds Model Context Protocol support for Claude Code and MCP clients:
177
+ > ```bash
178
+ > zalo-agent mcp start # stdio (local Claude Code)
179
+ > zalo-agent mcp start --http 3847 --auth your-secret # HTTP (VPS)
180
+ > ```
181
+ > 4 tools: get_messages · send_message · list_threads · mark_read
182
+ > Auto-reconnect · thread filter · noise reduction · group notifications
183
+ > See [MCP Guide](skill/references/mcp-guide.md)
184
+
165
185
  ### Quick Start
166
186
 
167
187
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.2.0",
3
+ "version": "1.4.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": {
@@ -42,11 +42,14 @@
42
42
  "node": ">=20"
43
43
  },
44
44
  "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.27.1",
45
46
  "chalk": "^5.3.0",
46
47
  "commander": "^14.0.3",
48
+ "express": "^5.2.1",
47
49
  "https-proxy-agent": "^8.0.0",
48
50
  "node-fetch": "^3.3.0",
49
- "zca-js": "^2.1.1"
51
+ "zca-js": "^2.1.1",
52
+ "zod": "^4.3.6"
50
53
  },
51
54
  "devDependencies": {
52
55
  "eslint": "^10.0.3",
@@ -12,11 +12,75 @@ export function registerGroupCommands(program) {
12
12
 
13
13
  group
14
14
  .command("list")
15
- .description("List all groups")
16
- .action(async () => {
17
- try {
18
- const result = await getApi().getAllGroups();
19
- output(result, program.opts().json);
15
+ .description("List all groups with names and member counts")
16
+ .option("-q, --query <text>", "Filter groups by name (case-insensitive, accent-insensitive)")
17
+ .action(async (opts) => {
18
+ try {
19
+ const api = getApi();
20
+ const groupsResult = await api.getAllGroups();
21
+ const groupIds = Object.keys(groupsResult?.gridVerMap || {});
22
+
23
+ if (groupIds.length === 0) {
24
+ info("No groups found.");
25
+ return;
26
+ }
27
+
28
+ // Batch fetch group info (50 per batch) to get names
29
+ const groups = [];
30
+ const batchSize = 50;
31
+ for (let i = 0; i < groupIds.length; i += batchSize) {
32
+ const batch = groupIds.slice(i, i + batchSize);
33
+ try {
34
+ const groupInfo = await api.getGroupInfo(batch);
35
+ const map = groupInfo?.gridInfoMap || {};
36
+ for (const [gid, g] of Object.entries(map)) {
37
+ groups.push({
38
+ threadId: gid,
39
+ name: g.name || "?",
40
+ memberCount: g.totalMember || 0,
41
+ });
42
+ }
43
+ } catch {
44
+ // Skip failed batch, add IDs without names
45
+ for (const id of batch) {
46
+ groups.push({ threadId: id, name: "?", memberCount: 0 });
47
+ }
48
+ }
49
+ }
50
+
51
+ // Apply name filter if --query provided
52
+ let filtered = groups;
53
+ if (opts.query) {
54
+ const q = opts.query
55
+ .normalize("NFD")
56
+ .replace(/[\u0300-\u036f]/g, "")
57
+ .replace(/đ/g, "d")
58
+ .replace(/Đ/g, "D")
59
+ .toLowerCase();
60
+ filtered = groups.filter((g) => {
61
+ const normalized = g.name
62
+ .normalize("NFD")
63
+ .replace(/[\u0300-\u036f]/g, "")
64
+ .replace(/đ/g, "d")
65
+ .replace(/Đ/g, "D")
66
+ .toLowerCase();
67
+ return normalized.includes(q);
68
+ });
69
+ }
70
+
71
+ output(filtered, program.opts().json, () => {
72
+ info(
73
+ `${filtered.length} group(s)${opts.query ? ` matching "${opts.query}"` : ""} (${groupIds.length} total)`,
74
+ );
75
+ console.log();
76
+ console.log(" THREAD_ID MEMBERS NAME");
77
+ console.log(" " + "-".repeat(65));
78
+ for (const g of filtered) {
79
+ const id = g.threadId.padEnd(22);
80
+ const members = String(g.memberCount).padStart(5);
81
+ console.log(` ${id} ${members} ${g.name}`);
82
+ }
83
+ });
20
84
  } catch (e) {
21
85
  error(e.message);
22
86
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * MCP server command — starts a Model Context Protocol server over stdio transport.
3
+ * Allows Claude Code and other MCP clients to read/send Zalo messages via tool calls.
4
+ *
5
+ * IMPORTANT: All diagnostic output uses console.error() — stdout is the MCP transport channel.
6
+ */
7
+
8
+ import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
9
+ import { MessageBuffer } from "../mcp/message-buffer.js";
10
+ import { ThreadFilter } from "../mcp/thread-filter.js";
11
+ import { loadMCPConfig, parseDuration } from "../mcp/mcp-config.js";
12
+ import { createMCPServer } from "../mcp/mcp-server.js";
13
+ import { registerTools } from "../mcp/mcp-tools.js";
14
+ import { createHTTPServer } from "../mcp/mcp-http-transport.js";
15
+ import { ZaloNotifier } from "../mcp/notifier.js";
16
+ import { ThreadNameCache } from "../mcp/thread-name-cache.js";
17
+
18
+ /** Zalo close code for duplicate web session — fatal, do not retry */
19
+ const CLOSE_DUPLICATE = 3000;
20
+
21
+ /**
22
+ * Normalize a raw zca-js message event into the buffer's message shape.
23
+ * @param {object} msg - Raw zca-js message event
24
+ * @returns {object} Normalized message
25
+ */
26
+ function normalizeMessage(msg) {
27
+ const rawContent = msg.data.content;
28
+ const isText = typeof rawContent === "string";
29
+ return {
30
+ id: msg.data.msgId,
31
+ threadId: msg.threadId,
32
+ threadType: msg.type === 0 ? "dm" : "group",
33
+ senderId: msg.data.uidFrom || null,
34
+ senderName: msg.data.dName || null,
35
+ text: isText ? rawContent : rawContent?.title || rawContent?.href || `[${msg.data.msgType || "attachment"}]`,
36
+ timestamp: Date.now(),
37
+ type: isText ? "text" : msg.data.msgType || "attachment",
38
+ attachment:
39
+ !isText && rawContent
40
+ ? {
41
+ type: msg.data.msgType,
42
+ url: rawContent.href || null,
43
+ description: rawContent.title || null,
44
+ }
45
+ : null,
46
+ replyTo: null,
47
+ };
48
+ }
49
+
50
+ export function registerMCPCommands(program) {
51
+ const mcp = program.command("mcp").description("MCP server for AI agent integration");
52
+
53
+ mcp.command("start")
54
+ .description("Start MCP server (stdio or HTTP transport)")
55
+ .option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
56
+ .option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
57
+ .option("--auth <token>", "Bearer token for HTTP auth (only with --http)")
58
+ .action(async (opts) => {
59
+ // Perform login explicitly here — preAction hook skips "mcp"
60
+ try {
61
+ await autoLogin(false);
62
+ } catch (e) {
63
+ console.error("[mcp] Auto-login failed:", e.message);
64
+ process.exit(1);
65
+ }
66
+
67
+ // Load MCP config (config path option reserved for future use)
68
+ const config = loadMCPConfig();
69
+ console.error("[mcp] Config loaded:", JSON.stringify(config.limits));
70
+
71
+ // Build buffer + filter from config
72
+ const maxAge = parseDuration(config.limits?.bufferMaxAge ?? "2h");
73
+ const maxSize = config.limits?.bufferMaxSize ?? 500;
74
+ const buffer = new MessageBuffer(maxSize, maxAge);
75
+ const filter = new ThreadFilter(config);
76
+
77
+ // Build thread name cache (groups + friends → in-memory index)
78
+ const nameCache = new ThreadNameCache();
79
+ try {
80
+ await nameCache.init(getApi());
81
+ } catch (e) {
82
+ console.error("[mcp] Thread name cache init failed (non-fatal):", e.message);
83
+ }
84
+
85
+ // Start MCP server — stdio (default) or HTTP
86
+ try {
87
+ if (opts.http) {
88
+ const port = Number(opts.http);
89
+ const deps = { api: getApi(), buffer, filter, config, nameCache };
90
+ createHTTPServer(registerTools, deps, port, opts.auth || null);
91
+ console.error(`[mcp] HTTP server started on port ${port}`);
92
+ } else {
93
+ await createMCPServer(getApi(), buffer, filter, config, nameCache);
94
+ }
95
+ } catch (e) {
96
+ console.error("[mcp] Failed to start MCP server:", e.message);
97
+ process.exit(1);
98
+ }
99
+
100
+ // Setup notifier (sends to Zalo group when agent is offline)
101
+ const notifier = new ZaloNotifier(getApi(), config);
102
+
103
+ let reconnectCount = 0;
104
+
105
+ /**
106
+ * Attach Zalo listener handlers to the current API instance.
107
+ * Must be called again after each re-login with the new API instance.
108
+ * @param {object} api - zca-js API instance
109
+ */
110
+ function attachListenerHandlers(api) {
111
+ api.listener.on("message", (msg) => {
112
+ // Skip self-sent messages
113
+ if (msg.isSelf) return;
114
+
115
+ const normalized = normalizeMessage(msg);
116
+
117
+ // Apply thread watch filter
118
+ if (!filter.shouldWatch(normalized.threadId, normalized.threadType)) return;
119
+
120
+ // Apply noise filter (stickers, system msgs, short emoji)
121
+ if (!filter.shouldKeep(normalized)) return;
122
+
123
+ buffer.push(normalized.threadId, normalized);
124
+ notifier.onMessage(normalized);
125
+ console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
126
+ });
127
+
128
+ api.listener.on("connected", () => {
129
+ if (reconnectCount > 0) {
130
+ console.error(`[mcp] Reconnected (#${reconnectCount})`);
131
+ }
132
+ });
133
+
134
+ api.listener.on("disconnected", (code) => {
135
+ console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying...`);
136
+ });
137
+
138
+ api.listener.on("closed", async (code) => {
139
+ if (code === CLOSE_DUPLICATE) {
140
+ console.error("[mcp] Duplicate Zalo Web session detected. Exiting.");
141
+ process.exit(1);
142
+ }
143
+ reconnectCount++;
144
+ console.error(
145
+ `[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount})`,
146
+ );
147
+ await new Promise((r) => setTimeout(r, 5000));
148
+ try {
149
+ clearSession();
150
+ await autoLogin(false);
151
+ console.error("[mcp] Re-login successful. Restarting listener...");
152
+ const newApi = getApi();
153
+ attachListenerHandlers(newApi);
154
+ newApi.listener.start({ retryOnClose: true });
155
+ } catch (e) {
156
+ console.error(`[mcp] Re-login failed: ${e.message}. Retrying in 30s...`);
157
+ await new Promise((r) => setTimeout(r, 30000));
158
+ try {
159
+ clearSession();
160
+ await autoLogin(false);
161
+ const retryApi = getApi();
162
+ attachListenerHandlers(retryApi);
163
+ retryApi.listener.start({ retryOnClose: true });
164
+ console.error("[mcp] Re-login successful on retry.");
165
+ } catch (e2) {
166
+ console.error(`[mcp] Re-login retry failed: ${e2.message}. Exiting.`);
167
+ process.exit(1);
168
+ }
169
+ }
170
+ });
171
+
172
+ api.listener.on("error", () => {
173
+ // WS errors are followed by close/disconnect — suppress to avoid noise
174
+ });
175
+ }
176
+
177
+ // Wire listener and start
178
+ try {
179
+ const api = getApi();
180
+ attachListenerHandlers(api);
181
+ api.listener.start({ retryOnClose: true });
182
+ console.error("[mcp] Zalo listener started. MCP server ready.");
183
+ } catch (e) {
184
+ console.error("[mcp] Failed to start listener:", e.message);
185
+ process.exit(1);
186
+ }
187
+
188
+ // Keep process alive (MCP server runs on stdio — process must not exit)
189
+ await new Promise(() => {});
190
+ });
191
+ }
package/src/index.js CHANGED
@@ -27,6 +27,7 @@ import { registerLabelCommands } from "./commands/label.js";
27
27
  import { registerCatalogCommands } from "./commands/catalog.js";
28
28
  import { registerListenCommand } from "./commands/listen.js";
29
29
  import { registerOACommands } from "./commands/oa.js";
30
+ import { registerMCPCommands } from "./commands/mcp.js";
30
31
  import { autoLogin } from "./core/zalo-client.js";
31
32
  import { checkForUpdates, selfUpdate } from "./utils/update-check.js";
32
33
  import { success, error, warning } from "./utils/output.js";
@@ -44,7 +45,8 @@ program
44
45
  .hook("preAction", async (thisCommand) => {
45
46
  const cmdName = thisCommand.args?.[0] || thisCommand.name();
46
47
  // Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
47
- if (program.opts().json) {
48
+ if (program.opts().json || cmdName === "mcp") {
49
+ // Suppress zca-js stdout logs: JSON mode needs clean output, MCP uses stdout as transport
48
50
  process.env.ZALO_JSON_MODE = "1";
49
51
  } else if (cmdName !== "oa") {
50
52
  // OA commands use official Zalo API — no disclaimer needed
@@ -52,7 +54,7 @@ program
52
54
  console.log();
53
55
  }
54
56
  // Auto-login before any command that needs it (skip for login/account/oa commands)
55
- const skipAutoLogin = ["login", "account", "help", "version", "update", "oa"].includes(cmdName);
57
+ const skipAutoLogin = ["login", "account", "help", "version", "update", "oa", "mcp"].includes(cmdName);
56
58
  if (!skipAutoLogin) {
57
59
  await autoLogin(program.opts().json);
58
60
  }
@@ -88,5 +90,6 @@ registerLabelCommands(program);
88
90
  registerCatalogCommands(program);
89
91
  registerListenCommand(program);
90
92
  registerOACommands(program);
93
+ registerMCPCommands(program);
91
94
 
92
95
  program.parse();
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Load/save MCP-specific config from ~/.zalo-agent-cli/mcp-config.json.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
6
+ import { join } from "path";
7
+ import { CONFIG_DIR } from "../core/credentials.js";
8
+
9
+ const MCP_CONFIG_FILE = join(CONFIG_DIR, "mcp-config.json");
10
+
11
+ /** Default MCP config — sensible defaults for local development */
12
+ export function getDefaultConfig() {
13
+ return {
14
+ watchThreads: ["dm:*", "group:*"],
15
+ mode: "manual",
16
+ triggerKeywords: ["@bot"],
17
+ notify: {
18
+ enabled: false,
19
+ thread: null,
20
+ on: ["dm"],
21
+ cooldown: "5m",
22
+ },
23
+ limits: {
24
+ maxMessagesPerPoll: 20,
25
+ autoDigestThreshold: 50,
26
+ bufferMaxAge: "2h",
27
+ bufferMaxSize: 500,
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Load MCP config from disk, merged with defaults.
34
+ * @returns {object} MCP config
35
+ */
36
+ export function loadMCPConfig() {
37
+ const defaults = getDefaultConfig();
38
+ try {
39
+ const raw = readFileSync(MCP_CONFIG_FILE, "utf-8");
40
+ const saved = JSON.parse(raw);
41
+ // Shallow merge: saved values override defaults
42
+ return {
43
+ ...defaults,
44
+ ...saved,
45
+ notify: { ...defaults.notify, ...saved.notify },
46
+ limits: { ...defaults.limits, ...saved.limits },
47
+ };
48
+ } catch {
49
+ // File doesn't exist or invalid JSON — use defaults
50
+ return defaults;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Save MCP config to disk.
56
+ * @param {object} config
57
+ */
58
+ export function saveMCPConfig(config) {
59
+ mkdirSync(CONFIG_DIR, { recursive: true });
60
+ writeFileSync(MCP_CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
61
+ }
62
+
63
+ /**
64
+ * Parse duration string (e.g. "2h", "5m", "30s") to milliseconds.
65
+ * @param {string|number} duration
66
+ * @returns {number} Milliseconds
67
+ */
68
+ export function parseDuration(duration) {
69
+ if (typeof duration === "number") return duration;
70
+ const match = String(duration).match(/^(\d+)\s*(h|m|s|ms)?$/i);
71
+ if (!match) return 0;
72
+ const value = parseInt(match[1], 10);
73
+ const unit = (match[2] || "ms").toLowerCase();
74
+ const multipliers = { h: 3600000, m: 60000, s: 1000, ms: 1 };
75
+ return value * (multipliers[unit] || 1);
76
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Express server wrapping MCP StreamableHTTP transport with bearer token auth.
3
+ * Uses stateless mode — each POST /mcp request creates a fresh server+transport.
4
+ */
5
+
6
+ import express from "express";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
+
10
+ /**
11
+ * Create HTTP MCP server with optional bearer token auth.
12
+ * @param {Function} registerToolsFn - Registers tools on a McpServer instance
13
+ * @param {object} deps - { api, buffer, filter, config }
14
+ * @param {number} port
15
+ * @param {string|null} authToken - Bearer token (null = no auth)
16
+ * @returns {import("http").Server}
17
+ */
18
+ export function createHTTPServer(registerToolsFn, deps, port, authToken) {
19
+ const app = express();
20
+ app.use(express.json());
21
+
22
+ // Auth middleware — skips /health
23
+ if (authToken) {
24
+ app.use((req, res, next) => {
25
+ if (req.path === "/health") return next();
26
+ const token = req.headers.authorization?.slice(7); // strip "Bearer "
27
+ if (token !== authToken) {
28
+ return res.status(401).json({ error: "Unauthorized" });
29
+ }
30
+ next();
31
+ });
32
+ }
33
+
34
+ // MCP endpoint — stateless, fresh server+transport per request
35
+ app.post("/mcp", async (req, res) => {
36
+ try {
37
+ const server = new McpServer({ name: "zalo-agent", version: "1.0.0" });
38
+ registerToolsFn(server, deps.api, deps.buffer, deps.filter, deps.config, deps.nameCache);
39
+
40
+ const transport = new StreamableHTTPServerTransport({
41
+ sessionIdGenerator: undefined, // stateless mode
42
+ });
43
+
44
+ res.on("close", () => {
45
+ transport.close();
46
+ server.close();
47
+ });
48
+
49
+ await server.connect(transport);
50
+ await transport.handleRequest(req, res, req.body);
51
+ } catch (err) {
52
+ console.error("MCP HTTP error:", err);
53
+ if (!res.headersSent) {
54
+ res.status(500).json({
55
+ jsonrpc: "2.0",
56
+ error: { code: -32603, message: "Internal server error" },
57
+ id: null,
58
+ });
59
+ }
60
+ }
61
+ });
62
+
63
+ // Health check — no auth required
64
+ app.get("/health", (req, res) => {
65
+ res.json({
66
+ status: "ok",
67
+ uptime: Math.floor(process.uptime()),
68
+ threads: deps.buffer.getStats().length,
69
+ });
70
+ });
71
+
72
+ return app.listen(port, "0.0.0.0", () => {
73
+ console.error(`MCP HTTP server listening on port ${port}`);
74
+ });
75
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * MCP server setup with stdio transport.
3
+ * Creates and connects the McpServer instance for Claude Code integration.
4
+ */
5
+
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { registerTools } from "./mcp-tools.js";
9
+
10
+ /**
11
+ * Create and start MCP server with stdio transport.
12
+ * All logs MUST use console.error() — stdout is reserved for MCP protocol.
13
+ * @param {object} api - zca-js API instance
14
+ * @param {import("./message-buffer.js").MessageBuffer} buffer
15
+ * @param {import("./thread-filter.js").ThreadFilter} filter
16
+ * @param {object} config - MCP config
17
+ * @param {import("./thread-name-cache.js").ThreadNameCache} [nameCache] - Thread name cache
18
+ * @returns {Promise<McpServer>}
19
+ */
20
+ export async function createMCPServer(api, buffer, filter, config, nameCache) {
21
+ const server = new McpServer({
22
+ name: "zalo-agent",
23
+ version: "1.0.0",
24
+ });
25
+
26
+ registerTools(server, api, buffer, filter, config, nameCache);
27
+
28
+ const transport = new StdioServerTransport();
29
+ await server.connect(transport);
30
+
31
+ console.error("[mcp-server] MCP server connected via stdio transport");
32
+
33
+ return server;
34
+ }