@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.
- package/DISCLAIMER.md +27 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +64 -0
- package/src/cli.test.js +98 -0
- package/src/commands/account.js +178 -0
- package/src/commands/auto-reply.js +83 -0
- package/src/commands/catalog.js +143 -0
- package/src/commands/conv.js +264 -0
- package/src/commands/friend.js +384 -0
- package/src/commands/group.js +606 -0
- package/src/commands/label.js +39 -0
- package/src/commands/listen.js +293 -0
- package/src/commands/login.js +165 -0
- package/src/commands/mcp.js +219 -0
- package/src/commands/msg.js +608 -0
- package/src/commands/oa-init.js +503 -0
- package/src/commands/oa-listen.js +215 -0
- package/src/commands/oa.js +657 -0
- package/src/commands/poll.js +159 -0
- package/src/commands/profile.js +274 -0
- package/src/commands/quick-msg.js +55 -0
- package/src/commands/reminder.js +195 -0
- package/src/core/accounts.js +93 -0
- package/src/core/credentials.js +63 -0
- package/src/core/oa-client.js +407 -0
- package/src/core/zalo-client.js +201 -0
- package/src/index.js +95 -0
- package/src/mcp/mcp-config.js +81 -0
- package/src/mcp/mcp-http-transport.js +84 -0
- package/src/mcp/mcp-server.js +40 -0
- package/src/mcp/mcp-tools.js +353 -0
- package/src/mcp/media-downloader.js +191 -0
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/message-buffer.js +127 -0
- package/src/mcp/message-buffer.test.js +308 -0
- package/src/mcp/notifier.js +141 -0
- package/src/mcp/notifier.test.js +269 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/bank-helpers.js +233 -0
- package/src/utils/bank-helpers.test.js +66 -0
- package/src/utils/extract-message-text.js +21 -0
- package/src/utils/output.js +21 -0
- package/src/utils/proxy-helpers.js +14 -0
- package/src/utils/proxy-helpers.test.js +36 -0
- package/src/utils/qr-display.js +91 -0
- package/src/utils/qr-display.test.js +97 -0
- package/src/utils/qr-http-server.js +190 -0
- 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
|
+
}
|