@ardennguyen/zalo-agent-cli 2.2.1 → 2.5.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 +6 -6
- package/package.json +2 -2
- package/src/commands/conv.js +8 -1
- package/src/commands/listen.js +144 -42
- package/src/commands/login.js +16 -3
- package/src/commands/mcp.js +370 -70
- package/src/commands/profile.js +6 -2
- package/src/commands/sync.js +3 -1
- package/src/core/accounts.js +20 -0
- package/src/core/daemon-channel.js +419 -2
- package/src/core/daemon-sync.js +13 -2
- package/src/core/db.js +60 -3
- package/src/core/live-store.js +58 -1
- package/src/core/sync-v2/index.js +27 -3
- package/src/index.js +3 -0
- package/src/mcp/mcp-config.js +111 -8
- package/src/mcp/mcp-http-transport.js +569 -14
- package/src/mcp/mcp-tools.js +234 -3
- package/src/mcp/message-buffer.js +62 -1
package/src/commands/mcp.js
CHANGED
|
@@ -5,23 +5,34 @@
|
|
|
5
5
|
* IMPORTANT: All diagnostic output uses console.error() — stdout is the MCP transport channel.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { createHmac } from "crypto";
|
|
9
|
+
import { readFileSync } from "fs";
|
|
8
10
|
import { join } from "path";
|
|
9
11
|
import { getApi, clearSession } from "../core/zalo-client.js";
|
|
10
12
|
import { liveApi } from "../core/live-api.js";
|
|
11
13
|
import { parseAccountId, selectDaemonAccount, loginDaemonAccount } from "../core/daemon-account.js";
|
|
14
|
+
import { updateAccountName } from "../core/accounts.js";
|
|
12
15
|
import { getAccountDir, MEDIA_DIR } from "../core/credentials.js";
|
|
13
16
|
import { acquireLock, releaseLock } from "../core/lock.js";
|
|
14
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createStageLock,
|
|
19
|
+
startDaemonChannel,
|
|
20
|
+
getDaemonStatus,
|
|
21
|
+
stopDaemon,
|
|
22
|
+
spawnDaemonProcess,
|
|
23
|
+
} from "../core/daemon-channel.js";
|
|
24
|
+
import { success, error, info, output } from "../utils/output.js";
|
|
15
25
|
import { createSyncRunners } from "../core/daemon-sync.js";
|
|
16
26
|
import { createSyncJobs } from "../mcp/sync-jobs.js";
|
|
17
27
|
import { createSocketTap } from "../core/socket-tap.js";
|
|
18
28
|
import { createSelfHeal } from "../core/self-heal.js";
|
|
19
29
|
import { createReadStateSync } from "../core/read-state.js";
|
|
20
|
-
import { initDb, getPendingSyncGaps } from "../core/db.js";
|
|
30
|
+
import { initDb, getPendingSyncGaps, getThreadNames } from "../core/db.js";
|
|
21
31
|
import { SyncManager } from "../core/sync.js";
|
|
22
32
|
import { createGapTracker, HEARTBEAT_MS } from "../core/listener-lifecycle.js";
|
|
23
33
|
import {
|
|
24
34
|
storeLiveMessage,
|
|
35
|
+
refreshLiveMessageIdentities,
|
|
25
36
|
storeLiveReaction,
|
|
26
37
|
storeLiveUndo,
|
|
27
38
|
storeGroupEvent,
|
|
@@ -34,7 +45,7 @@ import { classifyLiveMessage } from "../core/sync-v2/message-types.js";
|
|
|
34
45
|
import { createDeliveredReceipts } from "../core/receipts.js";
|
|
35
46
|
import { MessageBuffer } from "../mcp/message-buffer.js";
|
|
36
47
|
import { ThreadFilter } from "../mcp/thread-filter.js";
|
|
37
|
-
import { readMCPConfig, parseDuration } from "../mcp/mcp-config.js";
|
|
48
|
+
import { readMCPConfig, resolveAccountMCPConfig, envForAccount, parseDuration } from "../mcp/mcp-config.js";
|
|
38
49
|
import { createMCPServer } from "../mcp/mcp-server.js";
|
|
39
50
|
import { registerTools } from "../mcp/mcp-tools.js";
|
|
40
51
|
import { createHTTPServer } from "../mcp/mcp-http-transport.js";
|
|
@@ -90,6 +101,84 @@ export function normalizeMessage(msg, info = null) {
|
|
|
90
101
|
};
|
|
91
102
|
}
|
|
92
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Fire-and-forget HTTP POST for an incoming buffered MCP message when a
|
|
106
|
+
* webhook URL is configured (`-w, --webhook <url>` or `webhook` in
|
|
107
|
+
* `mcp-config.json`). Never blocks or throws into the listener loop.
|
|
108
|
+
*
|
|
109
|
+
* @param {string|null} webhookUrl
|
|
110
|
+
* @param {object} message - Normalized message object
|
|
111
|
+
* @param {object} [extra] - Additional top-level fields (e.g. `{ accountId }`)
|
|
112
|
+
* @param {Function} [fetchFn=fetch] - Injectable fetch for unit testing
|
|
113
|
+
* @param {string|null} [webhookSecret=null] - Optional shared secret for Bearer auth & X-Zalo-Signature HMAC-SHA256
|
|
114
|
+
* @returns {Promise<boolean>} True when dispatched, false when skipped or failed
|
|
115
|
+
*/
|
|
116
|
+
export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn = fetch, webhookSecret = null) {
|
|
117
|
+
if (!webhookUrl) return false;
|
|
118
|
+
const payload = {
|
|
119
|
+
event: "message",
|
|
120
|
+
...extra,
|
|
121
|
+
...message,
|
|
122
|
+
};
|
|
123
|
+
const body = JSON.stringify(payload);
|
|
124
|
+
const headers = { "Content-Type": "application/json" };
|
|
125
|
+
if (typeof webhookSecret === "string" && webhookSecret.trim()) {
|
|
126
|
+
const secret = webhookSecret.trim();
|
|
127
|
+
headers["Authorization"] = `Bearer ${secret}`;
|
|
128
|
+
headers["X-Zalo-Signature"] = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await fetchFn(webhookUrl, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers,
|
|
134
|
+
body,
|
|
135
|
+
signal: AbortSignal.timeout(5000),
|
|
136
|
+
});
|
|
137
|
+
return true;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`[mcp] Webhook failed: ${e.message}`);
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate and load TLS certificate & private key buffers from paths.
|
|
146
|
+
* @param {string|null|undefined} rawCert
|
|
147
|
+
* @param {string|null|undefined} rawKey
|
|
148
|
+
* @param {boolean} hasHttpPort
|
|
149
|
+
* @param {(msg: string) => never} fail
|
|
150
|
+
* @returns {{ cert: Buffer, key: Buffer, certPath: string, keyPath: string }|null}
|
|
151
|
+
*/
|
|
152
|
+
function loadTlsOptions(rawCert, rawKey, hasHttpPort, fail) {
|
|
153
|
+
if ((rawCert === undefined || rawCert === null) && (rawKey === undefined || rawKey === null)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const certPath = typeof rawCert === "string" ? rawCert.trim() : "";
|
|
157
|
+
const keyPath = typeof rawKey === "string" ? rawKey.trim() : "";
|
|
158
|
+
if (!certPath || !keyPath) {
|
|
159
|
+
fail("Refusing to start: --cert and --key must both be provided to enable HTTPS.");
|
|
160
|
+
}
|
|
161
|
+
if (!hasHttpPort) {
|
|
162
|
+
fail("Refusing to start: --cert and --key require --http <port>.");
|
|
163
|
+
}
|
|
164
|
+
let cert;
|
|
165
|
+
let key;
|
|
166
|
+
try {
|
|
167
|
+
cert = readFileSync(certPath);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
fail(`Refusing to start: cannot read the TLS certificate file ${certPath} (${e.code || e.message}).`);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
key = readFileSync(keyPath);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
fail(`Refusing to start: cannot read the TLS private key file ${keyPath} (${e.code || e.message}).`);
|
|
175
|
+
}
|
|
176
|
+
if (!cert.length || !key.length) {
|
|
177
|
+
fail("Refusing to start: TLS certificate or private key file is empty.");
|
|
178
|
+
}
|
|
179
|
+
return { cert, key, certPath, keyPath };
|
|
180
|
+
}
|
|
181
|
+
|
|
93
182
|
export function registerMCPCommands(program) {
|
|
94
183
|
const mcp = program.command("mcp").description("MCP server for AI agent integration");
|
|
95
184
|
|
|
@@ -99,11 +188,29 @@ export function registerMCPCommands(program) {
|
|
|
99
188
|
"Pin this server to an exact registered account ID (default: active at startup)",
|
|
100
189
|
parseAccountId,
|
|
101
190
|
)
|
|
102
|
-
.description("Start MCP server (stdio or
|
|
191
|
+
.description("Start MCP server (stdio, HTTP, or HTTPS transport)")
|
|
103
192
|
.option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
|
|
104
|
-
.option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
|
|
105
|
-
.option("--auth <token>", "Bearer token for HTTP auth (
|
|
106
|
-
.option("--host <address>", "HTTP bind address (default: 127.0.0.1, only with --http)")
|
|
193
|
+
.option("--http <port>", "Use HTTP/HTTPS transport on specified port (default: stdio)")
|
|
194
|
+
.option("--auth <token>", "Bearer token for HTTP/HTTPS auth (or set ZALO_MCP_AUTH_TOKEN in .env)")
|
|
195
|
+
.option("--host <address>", "HTTP/HTTPS bind address (default: 127.0.0.1, only with --http)")
|
|
196
|
+
.option("--cert <path>", "TLS certificate file path (PEM) to serve HTTPS (requires --http and --key)")
|
|
197
|
+
.option("--key <path>", "TLS private key file path (PEM) to serve HTTPS (requires --http and --cert)")
|
|
198
|
+
.option(
|
|
199
|
+
"--client-id <id>",
|
|
200
|
+
"OAuth 2.0 Client ID for remote MCP clients like Gemini (default: zalo-mcp, or ZALO_MCP_OAUTH_CLIENT_ID)",
|
|
201
|
+
)
|
|
202
|
+
.option(
|
|
203
|
+
"--client-secret <key>",
|
|
204
|
+
"OAuth 2.0 Client Secret (defaults to --auth / ZALO_MCP_AUTH_TOKEN, or ZALO_MCP_OAUTH_CLIENT_SECRET)",
|
|
205
|
+
)
|
|
206
|
+
.option(
|
|
207
|
+
"--issuer-url <url>",
|
|
208
|
+
"Explicit OAuth 2.0 issuer base URL (e.g. https://mcp.example.com:3100; default: inferred from request Host)",
|
|
209
|
+
)
|
|
210
|
+
.option(
|
|
211
|
+
"-w, --webhook <url>",
|
|
212
|
+
"POST each watched incoming message as JSON to this URL (or set webhook in mcp-config.json)",
|
|
213
|
+
)
|
|
107
214
|
.option(
|
|
108
215
|
"--no-delivered-receipts",
|
|
109
216
|
"Do not acknowledge received messages as delivered (every Zalo client does; seen receipts are never sent)",
|
|
@@ -112,46 +219,165 @@ export function registerMCPCommands(program) {
|
|
|
112
219
|
"--no-self-heal",
|
|
113
220
|
"Do not pull what a dropped socket missed from Zalo's offline queue on reconnect (the default does, as Zalo Web does, on this socket and with no phone tap)",
|
|
114
221
|
)
|
|
222
|
+
.option("-d, --daemon", "Run in the background (requires --http <port>; logs to <accountDir>/daemon.log)")
|
|
223
|
+
.option("--stop", "Stop the running background daemon for this account")
|
|
224
|
+
.option("--status", "Show background daemon status for this account")
|
|
115
225
|
.action(async (opts) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// An empty --auth is a token that went missing -- an unset variable
|
|
122
|
-
// in a unit file -- not a request for no auth, and it used to start
|
|
123
|
-
// the server open, saying nothing. Refused before anything logs in.
|
|
124
|
-
if (opts.http && opts.auth !== undefined && !String(opts.auth).trim()) {
|
|
125
|
-
console.error(
|
|
126
|
-
"[mcp] --auth was given an empty token. Refusing to start: that would serve this account's " +
|
|
127
|
-
"messages with no authentication at all.",
|
|
128
|
-
);
|
|
226
|
+
const jsonMode = program.opts().json;
|
|
227
|
+
const isDaemonCli = Boolean(opts.status || opts.stop || (opts.daemon && !process.env.ZALO_DAEMON_CHILD));
|
|
228
|
+
const failStart = (msg) => {
|
|
229
|
+
if (isDaemonCli) error(msg);
|
|
230
|
+
else console.error(`[mcp] ${msg}`);
|
|
129
231
|
process.exit(1);
|
|
130
|
-
}
|
|
232
|
+
};
|
|
131
233
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
234
|
+
let config = null;
|
|
235
|
+
let tlsOptions = null;
|
|
236
|
+
if (!opts.status && !opts.stop) {
|
|
237
|
+
// An empty --auth is a token that went missing -- an unset variable
|
|
238
|
+
// in a unit file -- not a request for no auth, and it used to start
|
|
239
|
+
// the server open, saying nothing. Refused before anything logs in.
|
|
240
|
+
if (opts.http && opts.auth !== undefined && !String(opts.auth).trim()) {
|
|
241
|
+
failStart(
|
|
242
|
+
"--auth was given an empty token. Refusing to start: that would serve this account's " +
|
|
243
|
+
"messages with no authentication at all.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The config, read before anything logs in. A file it cannot use is
|
|
248
|
+
// refused, not replaced by the defaults: they watch every thread, so
|
|
249
|
+
// a server meant for one group would buffer every DM for its bots.
|
|
250
|
+
const { config: loadedConfig, problem: configProblem } = readMCPConfig(opts.config);
|
|
251
|
+
if (configProblem) {
|
|
252
|
+
failStart(`Refusing to start: ${configProblem}. Without it the defaults would watch every thread.`);
|
|
253
|
+
}
|
|
254
|
+
config = loadedConfig;
|
|
255
|
+
|
|
256
|
+
const rawCert =
|
|
257
|
+
opts.cert !== undefined ? opts.cert : (process.env.ZALO_MCP_TLS_CERT ?? config?.tls?.cert);
|
|
258
|
+
const rawKey = opts.key !== undefined ? opts.key : (process.env.ZALO_MCP_TLS_KEY ?? config?.tls?.key);
|
|
259
|
+
tlsOptions = loadTlsOptions(rawCert, rawKey, Boolean(opts.http || config?.httpPort), failStart);
|
|
141
260
|
}
|
|
142
261
|
|
|
143
|
-
// Select
|
|
262
|
+
// Select account first so --status, --stop, and --daemon can use
|
|
263
|
+
// standard CLI output before redirecting console.log for stdio MCP.
|
|
144
264
|
let activeAcc;
|
|
145
265
|
try {
|
|
146
266
|
const requestedAccount =
|
|
147
267
|
opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
148
268
|
activeAcc = selectDaemonAccount(requestedAccount);
|
|
149
269
|
} catch (e) {
|
|
150
|
-
|
|
270
|
+
if (isDaemonCli) {
|
|
271
|
+
error(`Account selection failed: ${e.message}`);
|
|
272
|
+
} else {
|
|
273
|
+
console.error("[mcp] Account selection failed:", e.message);
|
|
274
|
+
}
|
|
151
275
|
process.exit(1);
|
|
152
276
|
}
|
|
153
277
|
const accountDir = getAccountDir(activeAcc.ownId);
|
|
154
278
|
|
|
279
|
+
if (opts.status) {
|
|
280
|
+
const status = getDaemonStatus(accountDir);
|
|
281
|
+
output({ accountId: activeAcc.ownId, ...status }, jsonMode, () => {
|
|
282
|
+
if (status.running) {
|
|
283
|
+
success(
|
|
284
|
+
`Daemon running for account ${activeAcc.ownId} (PID ${status.pid}${status.channelPort ? `, channel 127.0.0.1:${status.channelPort}` : ""})`,
|
|
285
|
+
);
|
|
286
|
+
info(`Log file: ${status.logPath}`);
|
|
287
|
+
} else {
|
|
288
|
+
info(`No daemon running for account ${activeAcc.ownId}`);
|
|
289
|
+
info(`Log file: ${status.logPath}`);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (opts.stop) {
|
|
296
|
+
const res = await stopDaemon(accountDir);
|
|
297
|
+
output({ accountId: activeAcc.ownId, ...res }, jsonMode, () => {
|
|
298
|
+
if (res.stopped) {
|
|
299
|
+
success(
|
|
300
|
+
`Stopped daemon for account ${activeAcc.ownId} (PID ${res.pid}${res.graceful ? "" : ", forced"})`,
|
|
301
|
+
);
|
|
302
|
+
} else {
|
|
303
|
+
info(`No daemon running for account ${activeAcc.ownId}`);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Merge per-account config (`config.accounts[ownId]` and `<accountDir>/mcp-config.json`)
|
|
310
|
+
const { config: accountConfig, problem: accountProblem } = resolveAccountMCPConfig(config, {
|
|
311
|
+
accountId: activeAcc.ownId,
|
|
312
|
+
accountDir,
|
|
313
|
+
explicitConfigPath: opts.config,
|
|
314
|
+
});
|
|
315
|
+
if (accountProblem) {
|
|
316
|
+
failStart(`Refusing to start: ${accountProblem}.`);
|
|
317
|
+
}
|
|
318
|
+
config = accountConfig;
|
|
319
|
+
|
|
320
|
+
const effectiveHttpPort =
|
|
321
|
+
opts.http ?? envForAccount("ZALO_MCP_HTTP_PORT", activeAcc.ownId) ?? config?.httpPort ?? null;
|
|
322
|
+
|
|
323
|
+
if (!tlsOptions) {
|
|
324
|
+
const acctCert = envForAccount("ZALO_MCP_TLS_CERT", activeAcc.ownId) ?? config?.tls?.cert;
|
|
325
|
+
const acctKey = envForAccount("ZALO_MCP_TLS_KEY", activeAcc.ownId) ?? config?.tls?.key;
|
|
326
|
+
tlsOptions = loadTlsOptions(acctCert, acctKey, Boolean(effectiveHttpPort), failStart);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (opts.daemon && !process.env.ZALO_DAEMON_CHILD) {
|
|
330
|
+
if (!effectiveHttpPort) {
|
|
331
|
+
error(
|
|
332
|
+
"`mcp start --daemon` requires `--http <port>` (stdio transport cannot run detached in the background).",
|
|
333
|
+
);
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
const port = Number(effectiveHttpPort);
|
|
337
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
338
|
+
error(`Invalid port: ${effectiveHttpPort}. Must be 1-65535.`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
const spawned = await spawnDaemonProcess({ accountDir, args: process.argv.slice(1) });
|
|
342
|
+
if (!spawned.ok) {
|
|
343
|
+
if (spawned.alreadyRunning) {
|
|
344
|
+
error(
|
|
345
|
+
`Another listener (listen daemon or MCP server) is already running for account ${activeAcc.ownId} (PID ${spawned.pid}).`,
|
|
346
|
+
);
|
|
347
|
+
info("Stop it first with: zalo-agent mcp start --stop");
|
|
348
|
+
} else {
|
|
349
|
+
error(`Failed to start background MCP server: ${spawned.error}`);
|
|
350
|
+
info(`Log file: ${spawned.logPath}`);
|
|
351
|
+
}
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
const scheme = tlsOptions ? "HTTPS" : "HTTP";
|
|
355
|
+
output(
|
|
356
|
+
{
|
|
357
|
+
accountId: activeAcc.ownId,
|
|
358
|
+
pid: spawned.pid,
|
|
359
|
+
httpPort: port,
|
|
360
|
+
https: Boolean(tlsOptions),
|
|
361
|
+
channelPort: spawned.channelPort,
|
|
362
|
+
logPath: spawned.logPath,
|
|
363
|
+
},
|
|
364
|
+
jsonMode,
|
|
365
|
+
() => {
|
|
366
|
+
success(
|
|
367
|
+
`MCP server started in background for account ${activeAcc.ownId} (PID ${spawned.pid}, ${scheme} port ${port})`,
|
|
368
|
+
);
|
|
369
|
+
info(`Log file: ${spawned.logPath}`);
|
|
370
|
+
info("Stop with: zalo-agent mcp start --stop");
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Safety net: redirect ALL console.log to stderr for the entire MCP process.
|
|
377
|
+
// Stdout is the MCP JSON-RPC transport — any non-JSON output corrupts the stream.
|
|
378
|
+
// This catches rogue prints from dependencies (zca-js, chalk, etc.) that we can't control.
|
|
379
|
+
console.log = (...args) => console.error(...args);
|
|
380
|
+
|
|
155
381
|
// This server now opens the account's WebSocket *and* writes to
|
|
156
382
|
// zalo.db, which is exactly what the `listen` daemon does. Zalo
|
|
157
383
|
// allows one web session per account and the cache allows one
|
|
@@ -219,9 +445,22 @@ export function registerMCPCommands(program) {
|
|
|
219
445
|
// One stage at a time on this socket, shared by the sync routes, the
|
|
220
446
|
// self-heal catch-up and the tools' own history fetch. Created before
|
|
221
447
|
// the server so the tools can hold it too.
|
|
448
|
+
const onSelfName = (name) => updateAccountName(activeAcc.ownId, name);
|
|
222
449
|
const stageLock = createStageLock();
|
|
223
|
-
const syncRunners = createSyncRunners({
|
|
450
|
+
const syncRunners = createSyncRunners({
|
|
451
|
+
getApi,
|
|
452
|
+
accountName: activeAcc.ownId,
|
|
453
|
+
onSelfName,
|
|
454
|
+
onMessagesRestored: () => {
|
|
455
|
+
for (const [tid, info] of getThreadNames()) {
|
|
456
|
+
if (info?.name) {
|
|
457
|
+
nameCache.set(tid, { name: info.name, type: info.type === "group" ? "group" : "dm" });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
});
|
|
224
462
|
const syncJobs = createSyncJobs({ runners: syncRunners, lock: stageLock });
|
|
463
|
+
syncJobs.onSelfName = onSelfName;
|
|
225
464
|
|
|
226
465
|
// What the tools and the notifier call. Not getApi()'s object:
|
|
227
466
|
// a re-login after a dropped connection replaces it, and they
|
|
@@ -231,17 +470,18 @@ export function registerMCPCommands(program) {
|
|
|
231
470
|
"The Zalo session is logging in again after a dropped connection; try again in a few seconds.",
|
|
232
471
|
});
|
|
233
472
|
|
|
234
|
-
// Start MCP server — stdio (default) or HTTP
|
|
473
|
+
// Start MCP server — stdio (default) or HTTP/HTTPS
|
|
235
474
|
let httpServer = null;
|
|
236
475
|
try {
|
|
237
|
-
if (
|
|
238
|
-
const port = Number(
|
|
476
|
+
if (effectiveHttpPort) {
|
|
477
|
+
const port = Number(effectiveHttpPort);
|
|
239
478
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
240
479
|
dropLock();
|
|
241
|
-
console.error(`[mcp] Invalid port: ${
|
|
480
|
+
console.error(`[mcp] Invalid port: ${effectiveHttpPort}. Must be 1-65535.`);
|
|
242
481
|
process.exit(1);
|
|
243
482
|
}
|
|
244
|
-
const host =
|
|
483
|
+
const host =
|
|
484
|
+
opts.host || envForAccount("ZALO_MCP_HOST", activeAcc.ownId) || config?.host || "127.0.0.1";
|
|
245
485
|
const deps = {
|
|
246
486
|
api: sessionApi,
|
|
247
487
|
buffer,
|
|
@@ -252,10 +492,41 @@ export function registerMCPCommands(program) {
|
|
|
252
492
|
stageLock,
|
|
253
493
|
syncJobs,
|
|
254
494
|
};
|
|
255
|
-
const authToken =
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
495
|
+
const authToken =
|
|
496
|
+
opts.auth?.trim() ||
|
|
497
|
+
envForAccount("ZALO_MCP_AUTH_TOKEN", activeAcc.ownId) ||
|
|
498
|
+
envForAccount("ZALO_MCP_OAUTH_CLIENT_SECRET", activeAcc.ownId) ||
|
|
499
|
+
config?.oauth?.clientSecret?.trim() ||
|
|
500
|
+
null;
|
|
501
|
+
const oauthClientId =
|
|
502
|
+
opts.clientId?.trim() ||
|
|
503
|
+
envForAccount("ZALO_MCP_OAUTH_CLIENT_ID", activeAcc.ownId) ||
|
|
504
|
+
config?.oauth?.clientId?.trim() ||
|
|
505
|
+
"zalo-mcp";
|
|
506
|
+
const oauthClientSecret =
|
|
507
|
+
opts.clientSecret?.trim() ||
|
|
508
|
+
envForAccount("ZALO_MCP_OAUTH_CLIENT_SECRET", activeAcc.ownId) ||
|
|
509
|
+
config?.oauth?.clientSecret?.trim() ||
|
|
510
|
+
authToken;
|
|
511
|
+
const oauthIssuerUrl =
|
|
512
|
+
opts.issuerUrl?.trim() ||
|
|
513
|
+
envForAccount("ZALO_MCP_ISSUER_URL", activeAcc.ownId) ||
|
|
514
|
+
config?.oauth?.issuerUrl?.trim() ||
|
|
515
|
+
null;
|
|
516
|
+
const oauthClients = Array.isArray(config?.oauth?.clients) ? config.oauth.clients : [];
|
|
517
|
+
const oauthOptions =
|
|
518
|
+
oauthClientSecret || oauthClients.length > 0
|
|
519
|
+
? {
|
|
520
|
+
clientId: oauthClientId,
|
|
521
|
+
clientSecret: oauthClientSecret,
|
|
522
|
+
issuerUrl: oauthIssuerUrl,
|
|
523
|
+
clients: oauthClients,
|
|
524
|
+
}
|
|
525
|
+
: null;
|
|
526
|
+
const scheme = tlsOptions ? "HTTPS" : "HTTP";
|
|
527
|
+
httpServer = createHTTPServer(registerTools, deps, port, authToken, host, tlsOptions, oauthOptions);
|
|
528
|
+
console.error(`[mcp] ${scheme} server started on port ${port}`);
|
|
529
|
+
if (!authToken && !oauthClientSecret && oauthClients.length === 0) {
|
|
259
530
|
console.error(
|
|
260
531
|
`[mcp] WARNING: no --auth token. Anyone who can reach ${host}:${port} can read this ` +
|
|
261
532
|
"account's messages and send as it.",
|
|
@@ -281,6 +552,16 @@ export function registerMCPCommands(program) {
|
|
|
281
552
|
|
|
282
553
|
// Setup notifier (sends to Zalo group when agent is offline)
|
|
283
554
|
const notifier = new ZaloNotifier(sessionApi, config);
|
|
555
|
+
const webhookUrl =
|
|
556
|
+
opts.webhook?.trim() ||
|
|
557
|
+
envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) ||
|
|
558
|
+
config.webhook?.trim() ||
|
|
559
|
+
null;
|
|
560
|
+
const webhookSecret =
|
|
561
|
+
envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId) || config?.webhookSecret?.trim() || null;
|
|
562
|
+
if (webhookUrl) {
|
|
563
|
+
console.error(`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
|
|
564
|
+
}
|
|
284
565
|
|
|
285
566
|
// Delivered receipts: the same shared implementation `listen` uses
|
|
286
567
|
// (AGENTS.md §13 -- the two listeners must not differ). Diagnostics
|
|
@@ -366,6 +647,7 @@ export function registerMCPCommands(program) {
|
|
|
366
647
|
if (!filter.shouldKeep(normalized)) continue;
|
|
367
648
|
buffer.push(normalized.threadId, normalized);
|
|
368
649
|
notifier.onMessage(normalized);
|
|
650
|
+
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId }, fetch, webhookSecret);
|
|
369
651
|
}
|
|
370
652
|
},
|
|
371
653
|
});
|
|
@@ -404,7 +686,8 @@ export function registerMCPCommands(program) {
|
|
|
404
686
|
// whether a message survives the noise filter. This server
|
|
405
687
|
// used to hold everything in memory only, so a restart lost
|
|
406
688
|
// every message it had ever seen.
|
|
407
|
-
const
|
|
689
|
+
const cachedThread = nameCache?.get(String(msg.threadId));
|
|
690
|
+
const threadName = cachedThread?.type === "group" ? cachedThread.name || undefined : undefined;
|
|
408
691
|
const stored = storeLiveMessage(msg, { threadName });
|
|
409
692
|
// A "delete for me" frame removes a message; it must not be
|
|
410
693
|
// buffered as one for an agent to read.
|
|
@@ -418,6 +701,11 @@ export function registerMCPCommands(program) {
|
|
|
418
701
|
console.error(`[mcp] message not stored: ${stored.reason}`);
|
|
419
702
|
return;
|
|
420
703
|
}
|
|
704
|
+
refreshLiveMessageIdentities(api, msg, {
|
|
705
|
+
onSelfName,
|
|
706
|
+
nameCache,
|
|
707
|
+
log: (line) => console.error(`[mcp] ${line}`),
|
|
708
|
+
}).catch((e) => console.error(`[mcp] identity refresh failed: ${e.message}`));
|
|
421
709
|
if (stored.info.hasAttachment) fetchMedia(String(msg.threadId));
|
|
422
710
|
|
|
423
711
|
// Skip self-sent messages for the agent-facing buffer
|
|
@@ -433,6 +721,7 @@ export function registerMCPCommands(program) {
|
|
|
433
721
|
|
|
434
722
|
buffer.push(normalized.threadId, normalized);
|
|
435
723
|
notifier.onMessage(normalized);
|
|
724
|
+
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId }, fetch, webhookSecret);
|
|
436
725
|
console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
|
|
437
726
|
});
|
|
438
727
|
|
|
@@ -466,6 +755,13 @@ export function registerMCPCommands(program) {
|
|
|
466
755
|
api.listener.on("group_event", (event) => {
|
|
467
756
|
noteBoardChange(event);
|
|
468
757
|
storeGroupEventRow(event);
|
|
758
|
+
const groupName = typeof event?.data?.groupName === "string" ? event.data.groupName.trim() : "";
|
|
759
|
+
if (groupName && event?.threadId !== undefined && event?.threadId !== null) {
|
|
760
|
+
const tid = String(event.threadId);
|
|
761
|
+
if (String(event?.type || "").toLowerCase() === "update" || !nameCache?.get(tid)?.name) {
|
|
762
|
+
nameCache?.set(tid, { name: groupName, type: "group" });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
469
765
|
// Same call as `listen`: our uid tells being removed from
|
|
470
766
|
// removing someone else, and a later event clears the flag.
|
|
471
767
|
const gone = storeGroupEvent(event, { ownId: activeAcc.ownId });
|
|
@@ -528,6 +824,33 @@ export function registerMCPCommands(program) {
|
|
|
528
824
|
});
|
|
529
825
|
}
|
|
530
826
|
|
|
827
|
+
const shutdown = () => {
|
|
828
|
+
// First, before listener.stop() emits closed(1000) -- otherwise
|
|
829
|
+
// the shutdown is indistinguishable from a drop and files a gap
|
|
830
|
+
// for a window nothing was missed in.
|
|
831
|
+
lifecycle.setStopping();
|
|
832
|
+
clearInterval(mcpHeartbeatTimer);
|
|
833
|
+
deliveredReceipts.stop();
|
|
834
|
+
const receipts = deliveredReceipts.stats();
|
|
835
|
+
if (receipts.calls) {
|
|
836
|
+
console.error(`[mcp] Delivered receipts: ${receipts.sent} acknowledged, ${receipts.failed} failed`);
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
syncManager.markConnected();
|
|
840
|
+
} catch (e) {
|
|
841
|
+
console.error(`[mcp] Failed to stamp shutdown state: ${e.message}`);
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
getApi().listener.stop();
|
|
845
|
+
} catch {
|
|
846
|
+
/* already stopped */
|
|
847
|
+
}
|
|
848
|
+
notifier?.destroy();
|
|
849
|
+
httpServer?.close();
|
|
850
|
+
dropLock();
|
|
851
|
+
process.exit(0);
|
|
852
|
+
};
|
|
853
|
+
|
|
531
854
|
// Wire listener and start
|
|
532
855
|
try {
|
|
533
856
|
const api = getApi();
|
|
@@ -550,6 +873,7 @@ export function registerMCPCommands(program) {
|
|
|
550
873
|
runners: syncRunners,
|
|
551
874
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|
|
552
875
|
lock: stageLock,
|
|
876
|
+
onStop: shutdown,
|
|
553
877
|
});
|
|
554
878
|
console.error(`[mcp] Sync & upload channel ready on 127.0.0.1:${channel.port}`);
|
|
555
879
|
} catch (e) {
|
|
@@ -562,33 +886,9 @@ export function registerMCPCommands(program) {
|
|
|
562
886
|
process.exit(1);
|
|
563
887
|
}
|
|
564
888
|
|
|
565
|
-
// Graceful shutdown on SIGINT
|
|
566
|
-
process.on("SIGINT",
|
|
567
|
-
|
|
568
|
-
// the shutdown is indistinguishable from a drop and files a gap
|
|
569
|
-
// for a window nothing was missed in.
|
|
570
|
-
lifecycle.setStopping();
|
|
571
|
-
clearInterval(mcpHeartbeatTimer);
|
|
572
|
-
deliveredReceipts.stop();
|
|
573
|
-
const receipts = deliveredReceipts.stats();
|
|
574
|
-
if (receipts.calls) {
|
|
575
|
-
console.error(`[mcp] Delivered receipts: ${receipts.sent} acknowledged, ${receipts.failed} failed`);
|
|
576
|
-
}
|
|
577
|
-
try {
|
|
578
|
-
syncManager.markConnected();
|
|
579
|
-
} catch (e) {
|
|
580
|
-
console.error(`[mcp] Failed to stamp shutdown state: ${e.message}`);
|
|
581
|
-
}
|
|
582
|
-
try {
|
|
583
|
-
getApi().listener.stop();
|
|
584
|
-
} catch {
|
|
585
|
-
/* already stopped */
|
|
586
|
-
}
|
|
587
|
-
notifier?.destroy();
|
|
588
|
-
httpServer?.close();
|
|
589
|
-
dropLock();
|
|
590
|
-
process.exit(0);
|
|
591
|
-
});
|
|
889
|
+
// Graceful shutdown on SIGINT / SIGTERM
|
|
890
|
+
process.on("SIGINT", shutdown);
|
|
891
|
+
process.on("SIGTERM", shutdown);
|
|
592
892
|
// A lock outliving its process blocks the next launch until the
|
|
593
893
|
// stale-PID check reclaims it, so release it on any exit path.
|
|
594
894
|
process.on("exit", dropLock);
|
package/src/commands/profile.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { resolve } from "path";
|
|
6
|
-
import { getApi } from "../core/zalo-client.js";
|
|
6
|
+
import { getApi, getOwnId } from "../core/zalo-client.js";
|
|
7
|
+
import { updateAccountName } from "../core/accounts.js";
|
|
7
8
|
import { success, error, info, output } from "../utils/output.js";
|
|
8
9
|
import { parseIntOption } from "../utils/parse-options.js";
|
|
9
10
|
|
|
@@ -66,6 +67,7 @@ export function registerProfileCommands(program) {
|
|
|
66
67
|
try {
|
|
67
68
|
const result = await getApi().fetchAccountInfo();
|
|
68
69
|
const p = result?.profile || {};
|
|
70
|
+
updateAccountName(p.userId || getOwnId(), p.displayName || p.zaloName);
|
|
69
71
|
output(result, program.opts().json, () => {
|
|
70
72
|
info(`Name: ${p.displayName || p.zaloName || "?"}`);
|
|
71
73
|
info(`User ID: ${p.userId || "?"}`);
|
|
@@ -144,14 +146,16 @@ export function registerProfileCommands(program) {
|
|
|
144
146
|
const parts = p.sdob.split("/");
|
|
145
147
|
if (parts.length === 3) currentDob = `${parts[2]}-${parts[1]}-${parts[0]}`;
|
|
146
148
|
}
|
|
149
|
+
const nextName = opts.name || p.displayName || p.zaloName;
|
|
147
150
|
const payload = {
|
|
148
151
|
profile: {
|
|
149
|
-
name:
|
|
152
|
+
name: nextName,
|
|
150
153
|
dob: opts.dob || currentDob,
|
|
151
154
|
gender: opts.gender !== undefined ? Number(opts.gender) : (p.gender ?? 0),
|
|
152
155
|
},
|
|
153
156
|
};
|
|
154
157
|
const result = await getApi().updateProfile(payload);
|
|
158
|
+
updateAccountName(p.userId || getOwnId(), nextName);
|
|
155
159
|
output(result, program.opts().json, () => success("Profile updated"));
|
|
156
160
|
} catch (e) {
|
|
157
161
|
error(`Profile update failed: ${e.message}`);
|
package/src/commands/sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { getApi } from "../core/zalo-client.js";
|
|
3
|
-
import { getActive } from "../core/accounts.js";
|
|
3
|
+
import { getActive, updateAccountName } from "../core/accounts.js";
|
|
4
4
|
import { getAccountDir } from "../core/credentials.js";
|
|
5
5
|
import { acquireLock, releaseLock, checkLock } from "../core/lock.js";
|
|
6
6
|
import { error, info, success, warning, output } from "../utils/output.js";
|
|
@@ -1100,6 +1100,7 @@ async function runTransferSync(activeAcc, opts) {
|
|
|
1100
1100
|
// the heartbeat too -- see runUnifiedSync for why.
|
|
1101
1101
|
keepAlive: false,
|
|
1102
1102
|
reconnect: () => reconnectListener(api),
|
|
1103
|
+
onSelfName: (name) => updateAccountName(activeAcc.ownId, name),
|
|
1103
1104
|
onStatus: ({ phase, detail }) => {
|
|
1104
1105
|
if (phase === "confirm") warning(detail);
|
|
1105
1106
|
else if (detail) info(` ${detail}`);
|
|
@@ -1690,6 +1691,7 @@ async function runUnifiedSync(activeAcc, opts, jsonMode) {
|
|
|
1690
1691
|
// The window-level heartbeat above covers this stage.
|
|
1691
1692
|
keepAlive: false,
|
|
1692
1693
|
reconnect: () => reconnectListener(api),
|
|
1694
|
+
onSelfName: (name) => updateAccountName(activeAcc.ownId, name),
|
|
1693
1695
|
onStatus: ({ phase, detail }) => {
|
|
1694
1696
|
if (phase === "confirm") warning(detail);
|
|
1695
1697
|
else if (detail) info(` ${detail}`);
|