@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.
@@ -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 { createStageLock, startDaemonChannel } from "../core/daemon-channel.js";
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 HTTP transport)")
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 (only with --http)")
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
- // Safety net: redirect ALL console.log to stderr for the entire MCP process.
117
- // Stdout is the MCP JSON-RPC transport — any non-JSON output corrupts the stream.
118
- // This catches rogue prints from dependencies (zca-js, chalk, etc.) that we can't control.
119
- console.log = (...args) => console.error(...args);
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
- // The config, read before anything logs in. A file it cannot use is
133
- // refused, not replaced by the defaults: they watch every thread, so
134
- // a server meant for one group would buffer every DM for its bots.
135
- const { config, problem: configProblem } = readMCPConfig(opts.config);
136
- if (configProblem) {
137
- console.error(
138
- `[mcp] Refusing to start: ${configProblem}. Without it the defaults would watch every thread.`,
139
- );
140
- process.exit(1);
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 once; acquire the selected account's lock before authentication.
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
- console.error("[mcp] Account selection failed:", e.message);
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({ getApi, accountName: activeAcc.ownId });
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 (opts.http) {
238
- const port = Number(opts.http);
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: ${opts.http}. Must be 1-65535.`);
480
+ console.error(`[mcp] Invalid port: ${effectiveHttpPort}. Must be 1-65535.`);
242
481
  process.exit(1);
243
482
  }
244
- const host = opts.host || "127.0.0.1";
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 = opts.auth?.trim() || null;
256
- httpServer = createHTTPServer(registerTools, deps, port, authToken, host);
257
- console.error(`[mcp] HTTP server started on port ${port}`);
258
- if (!authToken) {
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 threadName = nameCache?.get(String(msg.threadId))?.name || undefined;
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
- // First, before listener.stop() emits closed(1000) -- otherwise
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);
@@ -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: opts.name || p.displayName || p.zaloName,
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}`);
@@ -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}`);