@ardennguyen/zalo-agent-cli 2.2.2 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/package.json +1 -1
- 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 +335 -77
- 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 +110 -8
- package/src/mcp/mcp-http-transport.js +529 -14
- package/src/mcp/mcp-tools.js +305 -14
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";
|
|
@@ -99,20 +110,28 @@ export function normalizeMessage(msg, info = null) {
|
|
|
99
110
|
* @param {object} message - Normalized message object
|
|
100
111
|
* @param {object} [extra] - Additional top-level fields (e.g. `{ accountId }`)
|
|
101
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
|
|
102
114
|
* @returns {Promise<boolean>} True when dispatched, false when skipped or failed
|
|
103
115
|
*/
|
|
104
|
-
export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn = fetch) {
|
|
116
|
+
export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn = fetch, webhookSecret = null) {
|
|
105
117
|
if (!webhookUrl) return false;
|
|
106
118
|
const payload = {
|
|
107
119
|
event: "message",
|
|
108
120
|
...extra,
|
|
109
121
|
...message,
|
|
110
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
|
+
}
|
|
111
130
|
try {
|
|
112
131
|
await fetchFn(webhookUrl, {
|
|
113
132
|
method: "POST",
|
|
114
|
-
headers
|
|
115
|
-
body
|
|
133
|
+
headers,
|
|
134
|
+
body,
|
|
116
135
|
signal: AbortSignal.timeout(5000),
|
|
117
136
|
});
|
|
118
137
|
return true;
|
|
@@ -122,6 +141,44 @@ export async function postMCPWebhook(webhookUrl, message, extra = {}, fetchFn =
|
|
|
122
141
|
}
|
|
123
142
|
}
|
|
124
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
|
+
|
|
125
182
|
export function registerMCPCommands(program) {
|
|
126
183
|
const mcp = program.command("mcp").description("MCP server for AI agent integration");
|
|
127
184
|
|
|
@@ -131,11 +188,25 @@ export function registerMCPCommands(program) {
|
|
|
131
188
|
"Pin this server to an exact registered account ID (default: active at startup)",
|
|
132
189
|
parseAccountId,
|
|
133
190
|
)
|
|
134
|
-
.description("Start MCP server (stdio or
|
|
191
|
+
.description("Start MCP server (stdio, HTTP, or HTTPS transport)")
|
|
135
192
|
.option("--config <path>", "Config file path (default: ~/.zalo-agent-cli/mcp-config.json)")
|
|
136
|
-
.option("--http <port>", "Use HTTP transport on specified port (default: stdio)")
|
|
137
|
-
.option("--auth <token>", "Bearer token for HTTP auth (
|
|
138
|
-
.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
|
+
)
|
|
139
210
|
.option(
|
|
140
211
|
"-w, --webhook <url>",
|
|
141
212
|
"POST each watched incoming message as JSON to this URL (or set webhook in mcp-config.json)",
|
|
@@ -148,46 +219,165 @@ export function registerMCPCommands(program) {
|
|
|
148
219
|
"--no-self-heal",
|
|
149
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)",
|
|
150
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")
|
|
151
225
|
.action(async (opts) => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// An empty --auth is a token that went missing -- an unset variable
|
|
158
|
-
// in a unit file -- not a request for no auth, and it used to start
|
|
159
|
-
// the server open, saying nothing. Refused before anything logs in.
|
|
160
|
-
if (opts.http && opts.auth !== undefined && !String(opts.auth).trim()) {
|
|
161
|
-
console.error(
|
|
162
|
-
"[mcp] --auth was given an empty token. Refusing to start: that would serve this account's " +
|
|
163
|
-
"messages with no authentication at all.",
|
|
164
|
-
);
|
|
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}`);
|
|
165
231
|
process.exit(1);
|
|
166
|
-
}
|
|
232
|
+
};
|
|
167
233
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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);
|
|
177
260
|
}
|
|
178
261
|
|
|
179
|
-
// Select
|
|
262
|
+
// Select account first so --status, --stop, and --daemon can use
|
|
263
|
+
// standard CLI output before redirecting console.log for stdio MCP.
|
|
180
264
|
let activeAcc;
|
|
181
265
|
try {
|
|
182
266
|
const requestedAccount =
|
|
183
267
|
opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
184
268
|
activeAcc = selectDaemonAccount(requestedAccount);
|
|
185
269
|
} catch (e) {
|
|
186
|
-
|
|
270
|
+
if (isDaemonCli) {
|
|
271
|
+
error(`Account selection failed: ${e.message}`);
|
|
272
|
+
} else {
|
|
273
|
+
console.error("[mcp] Account selection failed:", e.message);
|
|
274
|
+
}
|
|
187
275
|
process.exit(1);
|
|
188
276
|
}
|
|
189
277
|
const accountDir = getAccountDir(activeAcc.ownId);
|
|
190
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
|
+
|
|
191
381
|
// This server now opens the account's WebSocket *and* writes to
|
|
192
382
|
// zalo.db, which is exactly what the `listen` daemon does. Zalo
|
|
193
383
|
// allows one web session per account and the cache allows one
|
|
@@ -255,9 +445,22 @@ export function registerMCPCommands(program) {
|
|
|
255
445
|
// One stage at a time on this socket, shared by the sync routes, the
|
|
256
446
|
// self-heal catch-up and the tools' own history fetch. Created before
|
|
257
447
|
// the server so the tools can hold it too.
|
|
448
|
+
const onSelfName = (name) => updateAccountName(activeAcc.ownId, name);
|
|
258
449
|
const stageLock = createStageLock();
|
|
259
|
-
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
|
+
});
|
|
260
462
|
const syncJobs = createSyncJobs({ runners: syncRunners, lock: stageLock });
|
|
463
|
+
syncJobs.onSelfName = onSelfName;
|
|
261
464
|
|
|
262
465
|
// What the tools and the notifier call. Not getApi()'s object:
|
|
263
466
|
// a re-login after a dropped connection replaces it, and they
|
|
@@ -267,17 +470,18 @@ export function registerMCPCommands(program) {
|
|
|
267
470
|
"The Zalo session is logging in again after a dropped connection; try again in a few seconds.",
|
|
268
471
|
});
|
|
269
472
|
|
|
270
|
-
// Start MCP server — stdio (default) or HTTP
|
|
473
|
+
// Start MCP server — stdio (default) or HTTP/HTTPS
|
|
271
474
|
let httpServer = null;
|
|
272
475
|
try {
|
|
273
|
-
if (
|
|
274
|
-
const port = Number(
|
|
476
|
+
if (effectiveHttpPort) {
|
|
477
|
+
const port = Number(effectiveHttpPort);
|
|
275
478
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
276
479
|
dropLock();
|
|
277
|
-
console.error(`[mcp] Invalid port: ${
|
|
480
|
+
console.error(`[mcp] Invalid port: ${effectiveHttpPort}. Must be 1-65535.`);
|
|
278
481
|
process.exit(1);
|
|
279
482
|
}
|
|
280
|
-
const host =
|
|
483
|
+
const host =
|
|
484
|
+
opts.host || envForAccount("ZALO_MCP_HOST", activeAcc.ownId) || config?.host || "127.0.0.1";
|
|
281
485
|
const deps = {
|
|
282
486
|
api: sessionApi,
|
|
283
487
|
buffer,
|
|
@@ -288,10 +492,41 @@ export function registerMCPCommands(program) {
|
|
|
288
492
|
stageLock,
|
|
289
493
|
syncJobs,
|
|
290
494
|
};
|
|
291
|
-
const authToken =
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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) {
|
|
295
530
|
console.error(
|
|
296
531
|
`[mcp] WARNING: no --auth token. Anyone who can reach ${host}:${port} can read this ` +
|
|
297
532
|
"account's messages and send as it.",
|
|
@@ -317,9 +552,15 @@ export function registerMCPCommands(program) {
|
|
|
317
552
|
|
|
318
553
|
// Setup notifier (sends to Zalo group when agent is offline)
|
|
319
554
|
const notifier = new ZaloNotifier(sessionApi, config);
|
|
320
|
-
const webhookUrl =
|
|
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;
|
|
321
562
|
if (webhookUrl) {
|
|
322
|
-
console.error(`[mcp] Webhook enabled: ${webhookUrl}`);
|
|
563
|
+
console.error(`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
|
|
323
564
|
}
|
|
324
565
|
|
|
325
566
|
// Delivered receipts: the same shared implementation `listen` uses
|
|
@@ -406,7 +647,7 @@ export function registerMCPCommands(program) {
|
|
|
406
647
|
if (!filter.shouldKeep(normalized)) continue;
|
|
407
648
|
buffer.push(normalized.threadId, normalized);
|
|
408
649
|
notifier.onMessage(normalized);
|
|
409
|
-
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId });
|
|
650
|
+
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId }, fetch, webhookSecret);
|
|
410
651
|
}
|
|
411
652
|
},
|
|
412
653
|
});
|
|
@@ -445,7 +686,8 @@ export function registerMCPCommands(program) {
|
|
|
445
686
|
// whether a message survives the noise filter. This server
|
|
446
687
|
// used to hold everything in memory only, so a restart lost
|
|
447
688
|
// every message it had ever seen.
|
|
448
|
-
const
|
|
689
|
+
const cachedThread = nameCache?.get(String(msg.threadId));
|
|
690
|
+
const threadName = cachedThread?.type === "group" ? cachedThread.name || undefined : undefined;
|
|
449
691
|
const stored = storeLiveMessage(msg, { threadName });
|
|
450
692
|
// A "delete for me" frame removes a message; it must not be
|
|
451
693
|
// buffered as one for an agent to read.
|
|
@@ -459,6 +701,11 @@ export function registerMCPCommands(program) {
|
|
|
459
701
|
console.error(`[mcp] message not stored: ${stored.reason}`);
|
|
460
702
|
return;
|
|
461
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}`));
|
|
462
709
|
if (stored.info.hasAttachment) fetchMedia(String(msg.threadId));
|
|
463
710
|
|
|
464
711
|
// Skip self-sent messages for the agent-facing buffer
|
|
@@ -474,7 +721,7 @@ export function registerMCPCommands(program) {
|
|
|
474
721
|
|
|
475
722
|
buffer.push(normalized.threadId, normalized);
|
|
476
723
|
notifier.onMessage(normalized);
|
|
477
|
-
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId });
|
|
724
|
+
postMCPWebhook(webhookUrl, normalized, { accountId: activeAcc.ownId }, fetch, webhookSecret);
|
|
478
725
|
console.error(`[mcp] Buffered ${normalized.threadType} msg from ${normalized.threadId}`);
|
|
479
726
|
});
|
|
480
727
|
|
|
@@ -508,6 +755,13 @@ export function registerMCPCommands(program) {
|
|
|
508
755
|
api.listener.on("group_event", (event) => {
|
|
509
756
|
noteBoardChange(event);
|
|
510
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
|
+
}
|
|
511
765
|
// Same call as `listen`: our uid tells being removed from
|
|
512
766
|
// removing someone else, and a later event clears the flag.
|
|
513
767
|
const gone = storeGroupEvent(event, { ownId: activeAcc.ownId });
|
|
@@ -570,6 +824,33 @@ export function registerMCPCommands(program) {
|
|
|
570
824
|
});
|
|
571
825
|
}
|
|
572
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
|
+
|
|
573
854
|
// Wire listener and start
|
|
574
855
|
try {
|
|
575
856
|
const api = getApi();
|
|
@@ -592,6 +873,7 @@ export function registerMCPCommands(program) {
|
|
|
592
873
|
runners: syncRunners,
|
|
593
874
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|
|
594
875
|
lock: stageLock,
|
|
876
|
+
onStop: shutdown,
|
|
595
877
|
});
|
|
596
878
|
console.error(`[mcp] Sync & upload channel ready on 127.0.0.1:${channel.port}`);
|
|
597
879
|
} catch (e) {
|
|
@@ -604,33 +886,9 @@ export function registerMCPCommands(program) {
|
|
|
604
886
|
process.exit(1);
|
|
605
887
|
}
|
|
606
888
|
|
|
607
|
-
// Graceful shutdown on SIGINT
|
|
608
|
-
process.on("SIGINT",
|
|
609
|
-
|
|
610
|
-
// the shutdown is indistinguishable from a drop and files a gap
|
|
611
|
-
// for a window nothing was missed in.
|
|
612
|
-
lifecycle.setStopping();
|
|
613
|
-
clearInterval(mcpHeartbeatTimer);
|
|
614
|
-
deliveredReceipts.stop();
|
|
615
|
-
const receipts = deliveredReceipts.stats();
|
|
616
|
-
if (receipts.calls) {
|
|
617
|
-
console.error(`[mcp] Delivered receipts: ${receipts.sent} acknowledged, ${receipts.failed} failed`);
|
|
618
|
-
}
|
|
619
|
-
try {
|
|
620
|
-
syncManager.markConnected();
|
|
621
|
-
} catch (e) {
|
|
622
|
-
console.error(`[mcp] Failed to stamp shutdown state: ${e.message}`);
|
|
623
|
-
}
|
|
624
|
-
try {
|
|
625
|
-
getApi().listener.stop();
|
|
626
|
-
} catch {
|
|
627
|
-
/* already stopped */
|
|
628
|
-
}
|
|
629
|
-
notifier?.destroy();
|
|
630
|
-
httpServer?.close();
|
|
631
|
-
dropLock();
|
|
632
|
-
process.exit(0);
|
|
633
|
-
});
|
|
889
|
+
// Graceful shutdown on SIGINT / SIGTERM
|
|
890
|
+
process.on("SIGINT", shutdown);
|
|
891
|
+
process.on("SIGTERM", shutdown);
|
|
634
892
|
// A lock outliving its process blocks the next launch until the
|
|
635
893
|
// stale-PID check reclaims it, so release it on any exit path.
|
|
636
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}`);
|