@ardennguyen/zalo-agent-cli 2.2.2 → 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";
@@ -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: { "Content-Type": "application/json" },
115
- body: JSON.stringify(payload),
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 HTTP transport)")
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 (only with --http)")
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
- // Safety net: redirect ALL console.log to stderr for the entire MCP process.
153
- // Stdout is the MCP JSON-RPC transport — any non-JSON output corrupts the stream.
154
- // This catches rogue prints from dependencies (zca-js, chalk, etc.) that we can't control.
155
- console.log = (...args) => console.error(...args);
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
- // The config, read before anything logs in. A file it cannot use is
169
- // refused, not replaced by the defaults: they watch every thread, so
170
- // a server meant for one group would buffer every DM for its bots.
171
- const { config, problem: configProblem } = readMCPConfig(opts.config);
172
- if (configProblem) {
173
- console.error(
174
- `[mcp] Refusing to start: ${configProblem}. Without it the defaults would watch every thread.`,
175
- );
176
- 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);
177
260
  }
178
261
 
179
- // 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.
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
- 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
+ }
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({ 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
+ });
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 (opts.http) {
274
- const port = Number(opts.http);
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: ${opts.http}. Must be 1-65535.`);
480
+ console.error(`[mcp] Invalid port: ${effectiveHttpPort}. Must be 1-65535.`);
278
481
  process.exit(1);
279
482
  }
280
- 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";
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 = opts.auth?.trim() || null;
292
- httpServer = createHTTPServer(registerTools, deps, port, authToken, host);
293
- console.error(`[mcp] HTTP server started on port ${port}`);
294
- 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) {
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 = opts.webhook?.trim() || config.webhook?.trim() || null;
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 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;
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
- // First, before listener.stop() emits closed(1000) -- otherwise
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);
@@ -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}`);