@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,293 @@
1
+ /**
2
+ * Unified listener — combines message, friend, and group events in one WebSocket connection.
3
+ * Production-ready with auto-reconnect and re-login.
4
+ */
5
+
6
+ import { appendFileSync, mkdirSync, existsSync } from "fs";
7
+ import { resolve, join } from "path";
8
+ import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
9
+ import { success, error, info, warning } from "../utils/output.js";
10
+
11
+ /** Thread types matching zca-js ThreadType enum */
12
+ const THREAD_USER = 0;
13
+ const THREAD_GROUP = 1;
14
+
15
+ /** Friend event type → readable label (matches zca-js FriendEventType enum order) */
16
+ const FRIEND_EVENT_LABELS = {
17
+ 0: "friend_added",
18
+ 1: "friend_removed",
19
+ 2: "friend_request",
20
+ 3: "undo_request",
21
+ 4: "reject_request",
22
+ 5: "seen_request",
23
+ 6: "blocked",
24
+ 7: "unblocked",
25
+ };
26
+ const FRIEND_REQUEST_TYPE = 2;
27
+
28
+ /** Zalo close code for duplicate web session */
29
+ const CLOSE_DUPLICATE = 3000;
30
+
31
+ export function registerListenCommand(program) {
32
+ program
33
+ .command("listen")
34
+ .description(
35
+ "Listen for all Zalo events (messages, friend requests, group events) via one WebSocket. Auto-reconnect enabled.",
36
+ )
37
+ .option(
38
+ "-e, --events <types>",
39
+ "Comma-separated event types: message,friend,group,reaction (default: message,friend)",
40
+ "message,friend",
41
+ )
42
+ .option("-f, --filter <type>", "Message filter: user (DM only), group (groups only), all", "all")
43
+ .option("-w, --webhook <url>", "POST each event as JSON to this URL (for n8n, Make, etc.)")
44
+ .option("--no-self", "Exclude self-sent messages")
45
+ .option("--auto-accept", "Auto-accept incoming friend requests")
46
+ .option("--save <dir>", "Save messages locally as JSONL files (one file per thread, e.g. --save ./zalo-logs)")
47
+ .action(async (opts) => {
48
+ const jsonMode = program.opts().json;
49
+ const startTime = Date.now();
50
+ let reconnectCount = 0;
51
+ let eventCount = 0;
52
+ const enabledEvents = new Set(opts.events.split(",").map((e) => e.trim()));
53
+
54
+ function uptime() {
55
+ const s = Math.floor((Date.now() - startTime) / 1000);
56
+ const h = Math.floor(s / 3600);
57
+ const m = Math.floor((s % 3600) / 60);
58
+ return h > 0 ? `${h}h${m}m` : `${m}m${s % 60}s`;
59
+ }
60
+
61
+ /** Fire-and-forget webhook POST — never blocks event processing */
62
+ function postWebhook(data) {
63
+ if (!opts.webhook) return;
64
+ fetch(opts.webhook, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify(data),
68
+ signal: AbortSignal.timeout(5000),
69
+ }).catch((e) => {
70
+ console.error(`[listen] Webhook failed: ${e.message}`);
71
+ });
72
+ }
73
+
74
+ // Setup save directory if --save flag provided
75
+ let saveDir = null;
76
+ if (opts.save) {
77
+ saveDir = resolve(opts.save);
78
+ if (!existsSync(saveDir)) mkdirSync(saveDir, { recursive: true });
79
+ info(`Saving messages to: ${saveDir}`);
80
+ }
81
+
82
+ /** Append event to JSONL file (one file per threadId) */
83
+ function saveEvent(data) {
84
+ if (!saveDir || !data.threadId) return;
85
+ const filename = `${data.threadId}.jsonl`;
86
+ const filepath = join(saveDir, filename);
87
+ const line = JSON.stringify({ ...data, savedAt: new Date().toISOString() }) + "\n";
88
+ try {
89
+ appendFileSync(filepath, line, "utf-8");
90
+ } catch (e) {
91
+ console.error(`[listen] Save failed: ${e.message}`);
92
+ }
93
+ }
94
+
95
+ /** Output event as JSON or human-readable, save locally, then post to webhook */
96
+ function emitEvent(data, humanMsg) {
97
+ eventCount++;
98
+ if (jsonMode) {
99
+ console.log(JSON.stringify(data));
100
+ } else {
101
+ info(humanMsg);
102
+ }
103
+ saveEvent(data);
104
+ postWebhook(data);
105
+ }
106
+
107
+ /** Attach ALL handlers (data + lifecycle) to current API listener */
108
+ function attachAllHandlers(api) {
109
+ // --- Message events ---
110
+ if (enabledEvents.has("message")) {
111
+ api.listener.on("message", async (msg) => {
112
+ if (opts.filter === "user" && msg.type !== THREAD_USER) return;
113
+ if (opts.filter === "group" && msg.type !== THREAD_GROUP) return;
114
+ if (!opts.self && msg.isSelf) return;
115
+
116
+ const rawContent = msg.data.content;
117
+ const isText = typeof rawContent === "string";
118
+ const msgType = msg.data.msgType || null;
119
+ // Build readable display: show type + title/href for non-text
120
+ let displayContent;
121
+ if (isText) {
122
+ displayContent = rawContent;
123
+ } else if (rawContent && typeof rawContent === "object") {
124
+ const parts = [msgType || "attachment"];
125
+ if (rawContent.title) parts.push(`"${rawContent.title}"`);
126
+ if (rawContent.href) parts.push(rawContent.href);
127
+ displayContent = `[${parts.join(" | ")}]`;
128
+ } else {
129
+ displayContent = `[${msgType || "non-text"}]`;
130
+ }
131
+ const data = {
132
+ event: "message",
133
+ msgId: msg.data.msgId,
134
+ cliMsgId: msg.data.cliMsgId,
135
+ threadId: msg.threadId,
136
+ type: msg.type,
137
+ isSelf: msg.isSelf,
138
+ uidFrom: msg.data.uidFrom || null,
139
+ dName: msg.data.dName || null,
140
+ msgType,
141
+ content: rawContent,
142
+ };
143
+ const dir = msg.isSelf ? "→" : "←";
144
+ const typeLabel = msg.type === THREAD_USER ? "DM" : "GR";
145
+ emitEvent(
146
+ data,
147
+ `${dir} [${typeLabel}] [${msg.threadId}] ${displayContent} (msgId: ${msg.data.msgId})`,
148
+ );
149
+ });
150
+ }
151
+
152
+ // --- Friend events ---
153
+ if (enabledEvents.has("friend")) {
154
+ api.listener.on("friend_event", async (event) => {
155
+ const label = FRIEND_EVENT_LABELS[event.type] || "friend_unknown";
156
+ const data = {
157
+ event: label,
158
+ threadId: event.threadId,
159
+ isSelf: event.isSelf,
160
+ data: event.data,
161
+ };
162
+ const humanMsg =
163
+ event.type === FRIEND_REQUEST_TYPE
164
+ ? `Friend request from ${event.data.fromUid}: "${event.data.message || ""}"`
165
+ : `${label} — ${event.threadId}`;
166
+ emitEvent(data, humanMsg);
167
+
168
+ // Auto-accept incoming friend requests
169
+ if (opts.autoAccept && event.type === FRIEND_REQUEST_TYPE && !event.isSelf) {
170
+ try {
171
+ await api.acceptFriendRequest(event.data.fromUid);
172
+ success(`Auto-accepted friend request from ${event.data.fromUid}`);
173
+ } catch (e) {
174
+ error(`Auto-accept failed: ${e.message}`);
175
+ }
176
+ }
177
+ });
178
+ }
179
+
180
+ // --- Group events ---
181
+ if (enabledEvents.has("group")) {
182
+ api.listener.on("group_event", (event) => {
183
+ emitEvent(
184
+ {
185
+ event: `group_${event.type}`,
186
+ threadId: event.threadId,
187
+ isSelf: event.isSelf,
188
+ data: event.data,
189
+ },
190
+ `Group: ${event.type} — ${event.threadId}`,
191
+ );
192
+ });
193
+ }
194
+
195
+ // --- Reaction events ---
196
+ if (enabledEvents.has("reaction")) {
197
+ api.listener.on("reaction", (reaction) => {
198
+ if (!opts.self && reaction.isSelf) return;
199
+ emitEvent(
200
+ {
201
+ event: "reaction",
202
+ threadId: reaction.threadId,
203
+ isSelf: reaction.isSelf,
204
+ isGroup: reaction.isGroup,
205
+ data: reaction.data,
206
+ },
207
+ `Reaction in ${reaction.threadId}`,
208
+ );
209
+ });
210
+ }
211
+
212
+ // --- Lifecycle events (MUST be on same listener for reconnect to work) ---
213
+ api.listener.on("connected", () => {
214
+ if (reconnectCount > 0) {
215
+ info(`Reconnected (#${reconnectCount}, uptime: ${uptime()}, events: ${eventCount})`);
216
+ }
217
+ });
218
+
219
+ api.listener.on("disconnected", (code, _reason) => {
220
+ warning(`Disconnected (code: ${code}). Auto-retrying...`);
221
+ });
222
+
223
+ api.listener.on("closed", async (code, _reason) => {
224
+ if (code === CLOSE_DUPLICATE) {
225
+ error("Another Zalo Web session opened. Listener stopped.");
226
+ process.exit(1);
227
+ }
228
+ reconnectCount++;
229
+ warning(`Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()})`);
230
+ await new Promise((r) => setTimeout(r, 5000));
231
+ try {
232
+ clearSession();
233
+ await autoLogin(jsonMode);
234
+ info("Re-login successful. Restarting listener...");
235
+ // Attach ALL handlers to the NEW api (including lifecycle)
236
+ const newApi = getApi();
237
+ attachAllHandlers(newApi);
238
+ newApi.listener.start({ retryOnClose: true });
239
+ } catch (e) {
240
+ error(`Re-login failed: ${e.message}. Retrying in 30s...`);
241
+ await new Promise((r) => setTimeout(r, 30000));
242
+ try {
243
+ clearSession();
244
+ await autoLogin(jsonMode);
245
+ const retryApi = getApi();
246
+ attachAllHandlers(retryApi);
247
+ retryApi.listener.start({ retryOnClose: true });
248
+ info("Re-login successful on retry.");
249
+ } catch (e2) {
250
+ error(`Re-login retry failed: ${e2.message}. Exiting.`);
251
+ process.exit(1);
252
+ }
253
+ }
254
+ });
255
+
256
+ api.listener.on("error", (_err) => {
257
+ // WS errors are followed by close/disconnect — don't crash
258
+ });
259
+ }
260
+
261
+ // --- Initial start ---
262
+ try {
263
+ const api = getApi();
264
+ attachAllHandlers(api);
265
+ api.listener.start({ retryOnClose: true });
266
+
267
+ info("Listening for Zalo events... Press Ctrl+C to stop.");
268
+ info(`Events: ${opts.events}`);
269
+ info("Auto-reconnect enabled.");
270
+ if (opts.filter !== "all") info(`Message filter: ${opts.filter}`);
271
+ if (opts.webhook) info(`Webhook: ${opts.webhook}`);
272
+ if (saveDir) info(`Save dir: ${saveDir} (JSONL per thread)`);
273
+ if (opts.autoAccept) info("Auto-accept friend requests: ON");
274
+ } catch (e) {
275
+ error(`Listen failed: ${e.message}`);
276
+ process.exit(1);
277
+ }
278
+
279
+ // Keep alive until Ctrl+C
280
+ await new Promise((resolve) => {
281
+ process.on("SIGINT", () => {
282
+ try {
283
+ getApi().listener.stop();
284
+ } catch (e) {
285
+ console.error(`[listen] Stop failed: ${e.message}`);
286
+ }
287
+ info(`Stopped. Uptime: ${uptime()}, events: ${eventCount}, reconnects: ${reconnectCount}`);
288
+ if (saveDir) info(`Messages saved to: ${saveDir}`);
289
+ resolve();
290
+ });
291
+ });
292
+ });
293
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Login commands — QR login, credential login, logout, status, whoami.
3
+ */
4
+
5
+ import { readFileSync, unlinkSync } from "fs";
6
+ import {
7
+ loginWithQR,
8
+ loginWithCredentials,
9
+ extractCredentials,
10
+ clearSession,
11
+ isLoggedIn,
12
+ getApi,
13
+ getOwnId,
14
+ } from "../core/zalo-client.js";
15
+ import { saveCredentials, deleteCredentials } from "../core/credentials.js";
16
+ import { addAccount, getActive, removeAccount } from "../core/accounts.js";
17
+ import { maskProxy } from "../utils/proxy-helpers.js";
18
+ import { displayQR, getQRPath } from "../utils/qr-display.js";
19
+ import { startQrServer } from "../utils/qr-http-server.js";
20
+ import { success, error, info, output } from "../utils/output.js";
21
+
22
+ export function registerLoginCommands(program) {
23
+ program
24
+ .command("login")
25
+ .description("Login to Zalo via QR code scan or from exported credentials")
26
+ .option("-p, --proxy <url>", "Proxy URL (http/https/socks5://[user:pass@]host:port)")
27
+ .option("-n, --name <label>", "Friendly name for this account", "")
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)
30
+ .option("--credentials <path>", "Login from exported credentials file (skip QR)")
31
+ .action(async (opts) => {
32
+ // Credential-based login (headless/CI)
33
+ if (opts.credentials) {
34
+ try {
35
+ const raw = JSON.parse(readFileSync(opts.credentials, "utf-8"));
36
+ const proxy = opts.proxy || raw.proxy || null;
37
+ if (proxy) info(`Using proxy: ${maskProxy(proxy)}`);
38
+
39
+ const { ownId } = await loginWithCredentials(raw, proxy);
40
+
41
+ let displayName = opts.name || raw.name || "";
42
+ try {
43
+ const accountInfo = await getApi().fetchAccountInfo();
44
+ displayName = accountInfo?.profile?.displayName || displayName || ownId;
45
+ } catch {}
46
+
47
+ const creds = extractCredentials();
48
+ saveCredentials(ownId, creds);
49
+ addAccount(ownId, displayName, proxy);
50
+ success(`Logged in as ${displayName} (${ownId})`);
51
+ } catch (e) {
52
+ error(`Login from credentials failed: ${e.message}`);
53
+ process.exit(1);
54
+ }
55
+ return;
56
+ }
57
+
58
+ // QR-based login
59
+ const jsonMode = program.opts().json;
60
+ if (opts.proxy) info(`Using proxy: ${maskProxy(opts.proxy)}`);
61
+ if (!jsonMode) info("Generating QR code... Scan with Zalo mobile app.");
62
+
63
+ let qrServer = null;
64
+ try {
65
+ const { ownId } = await loginWithQR(opts.proxy, (event) => {
66
+ displayQR(event);
67
+
68
+ // Always start HTTP server for QR scanning (no flag needed)
69
+ if (!qrServer) {
70
+ qrServer = startQrServer(getQRPath(), opts.qrPort || 18927);
71
+ }
72
+ });
73
+
74
+ // Fetch display name from Zalo profile
75
+ let displayName = opts.name || "";
76
+ try {
77
+ const accountInfo = await getApi().fetchAccountInfo();
78
+ displayName = accountInfo?.profile?.displayName || displayName || ownId;
79
+ } catch {}
80
+
81
+ const creds = extractCredentials();
82
+ saveCredentials(ownId, creds);
83
+ addAccount(ownId, displayName, opts.proxy);
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
+ }
90
+ } catch (e) {
91
+ if (jsonMode) {
92
+ console.log(JSON.stringify({ event: "login_error", message: e.message }));
93
+ } else {
94
+ error(`Login failed: ${e.message}`);
95
+ }
96
+ process.exit(1);
97
+ } finally {
98
+ if (qrServer) qrServer.close();
99
+ }
100
+ });
101
+
102
+ program
103
+ .command("logout")
104
+ .description("Logout current account (keeps credentials for auto-login)")
105
+ .option("--purge", "Also delete saved credentials (must QR login again)")
106
+ .action((opts) => {
107
+ const active = getActive();
108
+ clearSession();
109
+
110
+ if (opts.purge && active) {
111
+ deleteCredentials(active.ownId);
112
+ removeAccount(active.ownId);
113
+ // Also remove QR image
114
+ try {
115
+ unlinkSync(getQRPath());
116
+ } catch {}
117
+ success(`Logged out and purged credentials for ${active.name || active.ownId}`);
118
+ } else {
119
+ success("Logged out (credentials kept — will auto-login on next command)");
120
+ if (active) info(`To fully remove: zalo-agent account remove ${active.ownId}`);
121
+ }
122
+ });
123
+
124
+ program
125
+ .command("status")
126
+ .description("Show current login status")
127
+ .action(() => {
128
+ const active = getActive();
129
+ const data = {
130
+ loggedIn: isLoggedIn(),
131
+ ownId: getOwnId(),
132
+ activeAccount: active
133
+ ? { ownId: active.ownId, name: active.name, proxy: maskProxy(active.proxy) }
134
+ : null,
135
+ };
136
+ output(data, program.opts().json, () => {
137
+ if (data.loggedIn) {
138
+ success(`Logged in as ${data.ownId}`);
139
+ if (active) info(`Account: ${active.name || active.ownId} | Proxy: ${maskProxy(active.proxy)}`);
140
+ } else {
141
+ info("Not logged in");
142
+ if (active)
143
+ info(`Active account: ${active.name || active.ownId} (will auto-login on next command)`);
144
+ }
145
+ });
146
+ });
147
+
148
+ program
149
+ .command("whoami")
150
+ .description("Show current user profile")
151
+ .action(async () => {
152
+ try {
153
+ const api = getApi();
154
+ const accountInfo = await api.fetchAccountInfo();
155
+ output(accountInfo, program.opts().json, () => {
156
+ const p = accountInfo?.profile || {};
157
+ info(`Name: ${p.displayName || "?"}`);
158
+ info(`ID: ${p.userId || getOwnId()}`);
159
+ info(`Phone: ${p.phoneNumber || "?"}`);
160
+ });
161
+ } catch (e) {
162
+ error(e.message);
163
+ }
164
+ });
165
+ }
@@ -0,0 +1,219 @@
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 { extractMessageText } from "../utils/extract-message-text.js";
13
+ import { createMCPServer } from "../mcp/mcp-server.js";
14
+ import { registerTools } from "../mcp/mcp-tools.js";
15
+ import { createHTTPServer } from "../mcp/mcp-http-transport.js";
16
+ import { ZaloNotifier } from "../mcp/notifier.js";
17
+ import { ThreadNameCache } from "../mcp/thread-name-cache.js";
18
+ import { autoDownloadMedia, isDownloadableMedia } from "../mcp/media-downloader.js";
19
+
20
+ /** Zalo close code for duplicate web session — fatal, do not retry */
21
+ const CLOSE_DUPLICATE = 3000;
22
+
23
+ /**
24
+ * Normalize a raw zca-js message event into the buffer's message shape.
25
+ * @param {object} msg - Raw zca-js message event
26
+ * @returns {object} Normalized message
27
+ */
28
+ function normalizeMessage(msg) {
29
+ const rawContent = msg.data.content;
30
+ const isText = typeof rawContent === "string";
31
+ return {
32
+ id: msg.data.msgId,
33
+ threadId: msg.threadId,
34
+ threadType: msg.type === 0 ? "dm" : "group",
35
+ senderId: msg.data.uidFrom || null,
36
+ senderName: msg.data.dName || null,
37
+ text: isText ? rawContent : extractMessageText(rawContent, msg.data.msgType),
38
+ timestamp: Date.now(),
39
+ type: isText ? "text" : msg.data.msgType || "attachment",
40
+ attachment:
41
+ !isText && rawContent
42
+ ? {
43
+ type: msg.data.msgType,
44
+ url: rawContent.href || null,
45
+ description: rawContent.title || null,
46
+ }
47
+ : null,
48
+ replyTo: null,
49
+ };
50
+ }
51
+
52
+ export function registerMCPCommands(program) {
53
+ const mcp = program.command("mcp").description("MCP server for AI agent integration");
54
+
55
+ mcp.command("start")
56
+ .description("Start MCP server (stdio or HTTP transport)")
57
+ .option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
58
+ .option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
59
+ .option("--auth <token>", "Bearer token for HTTP auth (only with --http)")
60
+ .option("--host <address>", "HTTP bind address (default: 127.0.0.1, only with --http)")
61
+ .action(async (opts) => {
62
+ // Perform login explicitly here — preAction hook skips "mcp"
63
+ try {
64
+ await autoLogin(false);
65
+ } catch (e) {
66
+ console.error("[mcp] Auto-login failed:", e.message);
67
+ process.exit(1);
68
+ }
69
+
70
+ // Load MCP config (config path option reserved for future use)
71
+ const config = loadMCPConfig();
72
+ console.error("[mcp] Config loaded:", JSON.stringify(config.limits));
73
+
74
+ // Build buffer + filter from config
75
+ const maxAge = parseDuration(config.limits?.bufferMaxAge ?? "2h");
76
+ const maxSize = config.limits?.bufferMaxSize ?? 500;
77
+ const buffer = new MessageBuffer(maxSize, maxAge);
78
+ const filter = new ThreadFilter(config);
79
+
80
+ // Build thread name cache (groups + friends → in-memory index)
81
+ const nameCache = new ThreadNameCache();
82
+ try {
83
+ await nameCache.init(getApi());
84
+ } catch (e) {
85
+ console.error("[mcp] Thread name cache init failed (non-fatal):", e.message);
86
+ }
87
+
88
+ // Start MCP server — stdio (default) or HTTP
89
+ let httpServer = null;
90
+ try {
91
+ if (opts.http) {
92
+ const port = Number(opts.http);
93
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
94
+ console.error(`[mcp] Invalid port: ${opts.http}. Must be 1-65535.`);
95
+ process.exit(1);
96
+ }
97
+ const deps = { api: getApi(), buffer, filter, config, nameCache };
98
+ const authToken = opts.auth?.trim() || null;
99
+ httpServer = createHTTPServer(registerTools, deps, port, authToken, opts.host || "127.0.0.1");
100
+ console.error(`[mcp] HTTP server started on port ${port}`);
101
+ } else {
102
+ await createMCPServer(getApi(), buffer, filter, config, nameCache);
103
+ }
104
+ } catch (e) {
105
+ console.error("[mcp] Failed to start MCP server:", e.message);
106
+ process.exit(1);
107
+ }
108
+
109
+ // Setup notifier (sends to Zalo group when agent is offline)
110
+ const notifier = new ZaloNotifier(getApi(), config);
111
+
112
+ let reconnectCount = 0;
113
+
114
+ /**
115
+ * Attach Zalo listener handlers to the current API instance.
116
+ * Must be called again after each re-login with the new API instance.
117
+ * @param {object} api - zca-js API instance
118
+ */
119
+ function attachListenerHandlers(api) {
120
+ api.listener.on("message", (msg) => {
121
+ // Skip self-sent messages
122
+ if (msg.isSelf) return;
123
+
124
+ const normalized = normalizeMessage(msg);
125
+
126
+ // Apply thread watch filter
127
+ if (!filter.shouldWatch(normalized.threadId, normalized.threadType)) return;
128
+
129
+ // Apply noise filter (stickers, system msgs, short emoji)
130
+ if (!filter.shouldKeep(normalized)) return;
131
+
132
+ // Auto-download media (images, audio, video) in background
133
+ if (normalized.attachment?.url && isDownloadableMedia(normalized.type)) {
134
+ const threadName = nameCache?.get(normalized.threadId)?.name || null;
135
+ autoDownloadMedia(normalized, {
136
+ downloadDir: config.media?.downloadDir || undefined,
137
+ threadName,
138
+ });
139
+ }
140
+
141
+ buffer.push(normalized.threadId, normalized);
142
+ notifier.onMessage(normalized);
143
+ console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
144
+ });
145
+
146
+ api.listener.on("connected", () => {
147
+ if (reconnectCount > 0) {
148
+ console.error(`[mcp] Reconnected (#${reconnectCount})`);
149
+ }
150
+ });
151
+
152
+ api.listener.on("disconnected", (code) => {
153
+ console.error(`[mcp] Disconnected (code: ${code}). Auto-retrying...`);
154
+ });
155
+
156
+ api.listener.on("closed", async (code) => {
157
+ if (code === CLOSE_DUPLICATE) {
158
+ console.error("[mcp] Duplicate Zalo Web session detected. Exiting.");
159
+ process.exit(1);
160
+ }
161
+ reconnectCount++;
162
+ console.error(
163
+ `[mcp] Connection closed (code: ${code}). Re-login in 5s... (reconnect #${reconnectCount})`,
164
+ );
165
+ await new Promise((r) => setTimeout(r, 5000));
166
+ try {
167
+ clearSession();
168
+ await autoLogin(false);
169
+ console.error("[mcp] Re-login successful. Restarting listener...");
170
+ const newApi = getApi();
171
+ attachListenerHandlers(newApi);
172
+ newApi.listener.start({ retryOnClose: true });
173
+ } catch (e) {
174
+ console.error(`[mcp] Re-login failed: ${e.message}. Retrying in 30s...`);
175
+ await new Promise((r) => setTimeout(r, 30000));
176
+ try {
177
+ clearSession();
178
+ await autoLogin(false);
179
+ const retryApi = getApi();
180
+ attachListenerHandlers(retryApi);
181
+ retryApi.listener.start({ retryOnClose: true });
182
+ console.error("[mcp] Re-login successful on retry.");
183
+ } catch (e2) {
184
+ console.error(`[mcp] Re-login retry failed: ${e2.message}. Exiting.`);
185
+ process.exit(1);
186
+ }
187
+ }
188
+ });
189
+
190
+ api.listener.on("error", () => {
191
+ // WS errors are followed by close/disconnect — suppress to avoid noise
192
+ });
193
+ }
194
+
195
+ // Wire listener and start
196
+ try {
197
+ const api = getApi();
198
+ attachListenerHandlers(api);
199
+ api.listener.start({ retryOnClose: true });
200
+ console.error("[mcp] Zalo listener started. MCP server ready.");
201
+ } catch (e) {
202
+ console.error("[mcp] Failed to start listener:", e.message);
203
+ process.exit(1);
204
+ }
205
+
206
+ // Graceful shutdown on SIGINT
207
+ process.on("SIGINT", () => {
208
+ try {
209
+ getApi().listener.stop();
210
+ } catch {}
211
+ notifier?.destroy();
212
+ httpServer?.close();
213
+ process.exit(0);
214
+ });
215
+
216
+ // Keep process alive (MCP server runs on stdio — process must not exit)
217
+ await new Promise(() => {});
218
+ });
219
+ }