@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.
@@ -171,6 +171,26 @@ export function removeAccount(ownId) {
171
171
  return { removed: true, wiped };
172
172
  }
173
173
 
174
+ /**
175
+ * Update the cached display name for a registered account in accounts.json
176
+ * without changing which account is active or touching proxy settings.
177
+ *
178
+ * @param {string|null|undefined} ownId
179
+ * @param {string|null|undefined} name
180
+ * @returns {boolean} true when an existing account's name was changed
181
+ */
182
+ export function updateAccountName(ownId, name) {
183
+ const id = ownId ? String(ownId).trim() : "";
184
+ const trimmed = typeof name === "string" ? name.trim() : "";
185
+ if (!id || !trimmed) return false;
186
+ const accounts = load();
187
+ const existing = accounts.find((a) => a.ownId === id);
188
+ if (!existing || existing.name === trimmed) return false;
189
+ existing.name = trimmed;
190
+ save(accounts);
191
+ return true;
192
+ }
193
+
174
194
  /** Get account by ownId. */
175
195
  export function getAccount(ownId) {
176
196
  return load().find((a) => a.ownId === ownId) || null;
@@ -79,9 +79,12 @@
79
79
  import http from "node:http";
80
80
  import fs from "node:fs";
81
81
  import path from "node:path";
82
+ import { spawn, spawnSync } from "node:child_process";
82
83
  import { randomBytes, timingSafeEqual } from "node:crypto";
83
84
 
84
85
  const CHANNEL_FILE = "daemon-channel.json";
86
+ const LOCK_FILE = "daemon.lock";
87
+ const LOG_FILE = "daemon.log";
85
88
 
86
89
  /** Sync stages a daemon will run on its own socket, as `POST /sync/<stage>`. */
87
90
  const SYNC_ROUTE = /^\/sync\/(messages|reactions|history)$/;
@@ -285,9 +288,19 @@ function readJsonBody(req, reply) {
285
288
  * @param {ReturnType<typeof createStageLock>} [args.lock] - the daemon's stage
286
289
  * lock, shared with the jobs it runs on its own socket (its offline-queue
287
290
  * catch-up). Omitted, the channel keeps one of its own.
291
+ * @param {() => void} [args.onStop] - graceful shutdown callback invoked by
292
+ * `POST /stop` so `--stop` runs the same teardown as `SIGINT` (stamping
293
+ * `markConnected`, flushing delivered receipts, releasing `daemon.lock`).
288
294
  * @returns {Promise<{port: number, stop: () => void}>}
289
295
  */
290
- export function startDaemonChannel({ getApi, accountDir, onLog, runners = {}, lock = createStageLock() }) {
296
+ export function startDaemonChannel({
297
+ getApi,
298
+ accountDir,
299
+ onLog,
300
+ runners = {},
301
+ lock = createStageLock(),
302
+ onStop = null,
303
+ }) {
291
304
  const token = randomBytes(24).toString("hex");
292
305
 
293
306
  // One stage at a time on this socket. Two stages sharing it is measured to
@@ -386,6 +399,23 @@ export function startDaemonChannel({ getApi, accountDir, onLog, runners = {}, lo
386
399
  }
387
400
  };
388
401
 
402
+ /** Trigger a graceful shutdown over the loopback channel (`--stop`). */
403
+ const serveStop = (body, reply) => {
404
+ if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
405
+ if (typeof onStop !== "function") {
406
+ return reply(503, { error: "this daemon does not support channel stop" });
407
+ }
408
+ onLog?.("received graceful stop request via daemon channel");
409
+ reply(200, { ok: true, pid: process.pid });
410
+ setImmediate(() => {
411
+ try {
412
+ onStop();
413
+ } catch {
414
+ /* shutdown callback handles its own exit */
415
+ }
416
+ });
417
+ };
418
+
389
419
  const server = http.createServer(async (req, res) => {
390
420
  const reply = (code, body) => {
391
421
  res.writeHead(code, { "content-type": "application/json" });
@@ -394,10 +424,11 @@ export function startDaemonChannel({ getApi, accountDir, onLog, runners = {}, lo
394
424
  if (req.method !== "POST") return reply(404, { error: "not found" });
395
425
 
396
426
  const stage = SYNC_ROUTE.exec(req.url || "")?.[1];
397
- if (req.url !== "/send-attachments" && !stage) return reply(404, { error: "not found" });
427
+ if (req.url !== "/send-attachments" && req.url !== "/stop" && !stage) return reply(404, { error: "not found" });
398
428
 
399
429
  const body = await readJsonBody(req, reply);
400
430
  if (!body) return; // already answered, or the request went away
431
+ if (req.url === "/stop") return serveStop(body, reply);
401
432
  return stage ? serveStage(stage, body, res, reply) : serveAttachments(body, reply);
402
433
  });
403
434
 
@@ -636,3 +667,389 @@ export function syncViaDaemon(accountDir, { stage, params = {}, onEvent, idleTim
636
667
  r.end(payload);
637
668
  });
638
669
  }
670
+
671
+ /**
672
+ * Inspect whether a `listen` or `mcp start` daemon is currently running for an
673
+ * account directory.
674
+ *
675
+ * Consults both `daemon-channel.json` (which removes itself if its PID is dead)
676
+ * and `daemon.lock` (so a daemon still logging in before its channel opens is
677
+ * also seen as running).
678
+ *
679
+ * @param {string} accountDir
680
+ * @returns {{
681
+ * running: boolean,
682
+ * pid: number|null,
683
+ * channelPort: number|null,
684
+ * stages: string[],
685
+ * logPath: string,
686
+ * }}
687
+ */
688
+ export function getDaemonStatus(accountDir) {
689
+ const logPath = path.join(accountDir, LOG_FILE);
690
+ const chan = getDaemonChannel(accountDir);
691
+ if (chan) {
692
+ return {
693
+ running: true,
694
+ pid: chan.pid,
695
+ channelPort: chan.port,
696
+ stages: chan.stages,
697
+ logPath,
698
+ };
699
+ }
700
+
701
+ const lockFile = path.join(accountDir, LOCK_FILE);
702
+ try {
703
+ if (fs.existsSync(lockFile)) {
704
+ const pid = parseInt(fs.readFileSync(lockFile, "utf8").trim(), 10);
705
+ if (!Number.isNaN(pid) && isProcessAlive(pid)) {
706
+ return {
707
+ running: true,
708
+ pid,
709
+ channelPort: null,
710
+ stages: [],
711
+ logPath,
712
+ };
713
+ }
714
+ }
715
+ } catch {
716
+ /* unreadable lock is treated as not running */
717
+ }
718
+
719
+ return {
720
+ running: false,
721
+ pid: null,
722
+ channelPort: null,
723
+ stages: [],
724
+ logPath,
725
+ };
726
+ }
727
+
728
+ /**
729
+ * Ask a running daemon to shut down gracefully over its loopback channel.
730
+ *
731
+ * @param {string} accountDir
732
+ * @param {{timeoutMs?: number}} [opts]
733
+ * @returns {Promise<{ok: boolean, pid?: number, status?: number, error?: string}|null>}
734
+ * null when no daemon channel is published
735
+ */
736
+ export function stopViaDaemon(accountDir, { timeoutMs = 3000 } = {}) {
737
+ const chan = getDaemonChannel(accountDir);
738
+ if (!chan) return Promise.resolve(null);
739
+
740
+ const payload = JSON.stringify({ token: chan.token });
741
+ return new Promise((resolve) => {
742
+ const r = http.request(
743
+ {
744
+ host: "127.0.0.1",
745
+ port: chan.port,
746
+ path: "/stop",
747
+ method: "POST",
748
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
749
+ timeout: Number(timeoutMs) || 3000,
750
+ agent: false,
751
+ },
752
+ (res) => {
753
+ let raw = "";
754
+ res.setEncoding("utf8");
755
+ res.on("data", (c) => (raw += c));
756
+ res.on("end", () => {
757
+ let body = {};
758
+ try {
759
+ body = JSON.parse(raw);
760
+ } catch {
761
+ /* non-JSON response */
762
+ }
763
+ if (res.statusCode === 200 && body.ok) {
764
+ resolve({ ok: true, pid: Number(body.pid) || chan.pid });
765
+ } else {
766
+ resolve({
767
+ ok: false,
768
+ status: res.statusCode,
769
+ error: body.error || `HTTP ${res.statusCode}`,
770
+ });
771
+ }
772
+ });
773
+ },
774
+ );
775
+ r.on("timeout", () => {
776
+ r.destroy();
777
+ resolve({ ok: false, error: "timeout" });
778
+ });
779
+ r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
780
+ r.end(payload);
781
+ });
782
+ }
783
+
784
+ /**
785
+ * Stop the running daemon for an account directory, preferring `POST /stop` so
786
+ * the daemon runs its own graceful shutdown handler (`markConnected`, receipt
787
+ * flush, lock/channel cleanup) before falling back to `process.kill(pid)`.
788
+ *
789
+ * @param {string} accountDir
790
+ * @param {{timeoutMs?: number}} [opts]
791
+ * @returns {Promise<{stopped: boolean, pid: number|null, graceful: boolean}>}
792
+ */
793
+ export async function stopDaemon(accountDir, { timeoutMs = 5000 } = {}) {
794
+ const status = getDaemonStatus(accountDir);
795
+ const lockFile = path.join(accountDir, LOCK_FILE);
796
+ const chanFile = channelPath(accountDir);
797
+
798
+ if (!status.running || !status.pid) {
799
+ for (const f of [lockFile, chanFile]) {
800
+ try {
801
+ if (fs.existsSync(f)) fs.unlinkSync(f);
802
+ } catch {
803
+ /* already removed */
804
+ }
805
+ }
806
+ return { stopped: false, pid: null, graceful: false };
807
+ }
808
+
809
+ const pid = status.pid;
810
+ let graceful = false;
811
+ const stopRes = await stopViaDaemon(accountDir, { timeoutMs: Math.min(2500, timeoutMs) });
812
+ if (stopRes?.ok) {
813
+ const deadline = Date.now() + timeoutMs;
814
+ while (Date.now() < deadline) {
815
+ if (!isProcessAlive(pid)) {
816
+ graceful = true;
817
+ break;
818
+ }
819
+ await new Promise((r) => setTimeout(r, 80));
820
+ }
821
+ }
822
+
823
+ if (isProcessAlive(pid)) {
824
+ try {
825
+ process.kill(pid);
826
+ } catch {
827
+ /* exited between check and kill */
828
+ }
829
+ const deadline = Date.now() + 2000;
830
+ while (Date.now() < deadline && isProcessAlive(pid)) {
831
+ await new Promise((r) => setTimeout(r, 80));
832
+ }
833
+ }
834
+
835
+ for (const f of [lockFile, chanFile]) {
836
+ try {
837
+ if (fs.existsSync(f)) fs.unlinkSync(f);
838
+ } catch {
839
+ /* already removed */
840
+ }
841
+ }
842
+
843
+ return { stopped: true, pid, graceful };
844
+ }
845
+
846
+ /**
847
+ * Quote a single argument for a Windows `CommandLineToArgvW` command line.
848
+ *
849
+ * @param {string} arg
850
+ * @returns {string}
851
+ */
852
+ function quoteWinArg(arg) {
853
+ const s = String(arg);
854
+ if (!/[\s"]/.test(s) && s.length > 0) return s;
855
+ let out = '"';
856
+ let backslashes = 0;
857
+ for (const ch of s) {
858
+ if (ch === "\\") {
859
+ backslashes++;
860
+ } else if (ch === '"') {
861
+ out += "\\".repeat(backslashes * 2 + 1) + '"';
862
+ backslashes = 0;
863
+ } else {
864
+ out += "\\".repeat(backslashes) + ch;
865
+ backslashes = 0;
866
+ }
867
+ }
868
+ out += "\\".repeat(backslashes * 2) + '"';
869
+ return out;
870
+ }
871
+
872
+ /**
873
+ * When launched as a detached background child via WMI (`ZALO_DAEMON_LOG` set),
874
+ * redirect `process.stdout.write` and `process.stderr.write` to the daemon log
875
+ * file so every log line and uncaught error is captured without relying on
876
+ * inherited OS file descriptors.
877
+ */
878
+ export function attachDaemonLogIfChild() {
879
+ if (process.env.ZALO_DAEMON_CHILD !== "1" || !process.env.ZALO_DAEMON_LOG) return;
880
+ let logFd;
881
+ try {
882
+ logFd = fs.openSync(process.env.ZALO_DAEMON_LOG, "a", 0o600);
883
+ } catch {
884
+ return;
885
+ }
886
+ const writeToLog = (chunk, encoding, cb) => {
887
+ try {
888
+ const buf = Buffer.isBuffer(chunk)
889
+ ? chunk
890
+ : Buffer.from(String(chunk), typeof encoding === "string" ? encoding : "utf8");
891
+ fs.writeSync(logFd, buf);
892
+ } catch {
893
+ /* ignore logging errors */
894
+ }
895
+ if (typeof encoding === "function") encoding();
896
+ else if (typeof cb === "function") cb();
897
+ return true;
898
+ };
899
+ process.stdout.write = writeToLog;
900
+ process.stderr.write = writeToLog;
901
+ process.on("uncaughtException", (err) => {
902
+ writeToLog(`${err?.stack || err}\n`);
903
+ process.exit(1);
904
+ });
905
+ }
906
+
907
+ /**
908
+ * On Windows, spawn via `Win32_Process.Create` (WMI) so the background daemon
909
+ * is parented to `WmiPrvSE.exe` outside any terminal or agent Job Object
910
+ * (`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`), preventing the daemon from dying when
911
+ * the calling shell or tool session closes. Returns the new PID or null on
912
+ * failure so the caller can fall back to `child_process.spawn`.
913
+ *
914
+ * @param {string} execPath
915
+ * @param {string[]} args
916
+ * @param {string} logPath
917
+ * @returns {number|null}
918
+ */
919
+ function trySpawnWin32JobBreakaway(execPath, args, logPath) {
920
+ if (process.platform !== "win32") return null;
921
+ try {
922
+ const cmdLine = [quoteWinArg(execPath), ...args.map(quoteWinArg)].join(" ");
923
+ const psScript = [
924
+ "$cmd = $env:ZALO_DAEMON_CMD",
925
+ "Remove-Item Env:\\ZALO_DAEMON_CMD -ErrorAction SilentlyContinue",
926
+ '$envList = [string[]]@([System.Environment]::GetEnvironmentVariables().GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" })',
927
+ "$si = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0; EnvironmentVariables = $envList }",
928
+ "$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cmd; CurrentDirectory = (Get-Location).Path; ProcessStartupInformation = $si }",
929
+ "if ($r.ReturnValue -ne 0 -or -not $r.ProcessId) { exit 1 }",
930
+ "Write-Output $r.ProcessId",
931
+ ].join("; ");
932
+ const res = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psScript], {
933
+ windowsHide: true,
934
+ encoding: "utf8",
935
+ timeout: 8000,
936
+ env: {
937
+ ...process.env,
938
+ ZALO_DAEMON_CHILD: "1",
939
+ ZALO_DAEMON_LOG: logPath,
940
+ ZALO_DAEMON_CMD: cmdLine,
941
+ },
942
+ });
943
+ if (res.status === 0 && res.stdout) {
944
+ const pid = parseInt(res.stdout.trim(), 10);
945
+ if (!Number.isNaN(pid) && pid > 0) return pid;
946
+ }
947
+ } catch {
948
+ /* fall back to standard detached spawn */
949
+ }
950
+ return null;
951
+ }
952
+
953
+ /**
954
+ * Spawn a detached background daemon (`listen -d` or `mcp start --http <port> -d`)
955
+ * redirecting stdout and stderr to `<accountDir>/daemon.log`, and wait until its
956
+ * loopback channel is ready (or report startup failure if it exits early).
957
+ *
958
+ * @param {object} opts
959
+ * @param {string} opts.accountDir
960
+ * @param {string[]} [opts.args] - CLI arguments (defaults to `process.argv.slice(1)`)
961
+ * @param {number} [opts.timeoutMs=15000] - how long to wait for the channel to come up
962
+ * @returns {Promise<{
963
+ * ok: boolean,
964
+ * alreadyRunning?: boolean,
965
+ * pid?: number,
966
+ * channelPort?: number|null,
967
+ * logPath: string,
968
+ * error?: string,
969
+ * }>}
970
+ */
971
+ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice(1), timeoutMs = 15_000 }) {
972
+ const status = getDaemonStatus(accountDir);
973
+ if (status.running) {
974
+ return {
975
+ ok: false,
976
+ alreadyRunning: true,
977
+ pid: status.pid,
978
+ channelPort: status.channelPort,
979
+ logPath: status.logPath,
980
+ };
981
+ }
982
+
983
+ fs.mkdirSync(accountDir, { recursive: true });
984
+ const logPath = path.join(accountDir, LOG_FILE);
985
+ const filteredArgs = args.filter((a) => a !== "-d" && a !== "--daemon");
986
+
987
+ const logFd = fs.openSync(logPath, "a", 0o600);
988
+ const header = `\n--- [${new Date().toISOString()}] starting daemon: ${filteredArgs.join(" ")} ---\n`;
989
+ fs.writeSync(logFd, header);
990
+ let startOffset = 0;
991
+ try {
992
+ startOffset = fs.statSync(logPath).size;
993
+ } catch {
994
+ /* ignore */
995
+ }
996
+
997
+ let pid = trySpawnWin32JobBreakaway(process.execPath, filteredArgs, logPath);
998
+ if (pid !== null) {
999
+ try {
1000
+ fs.closeSync(logFd);
1001
+ } catch {
1002
+ /* ignore */
1003
+ }
1004
+ } else {
1005
+ const child = spawn(process.execPath, filteredArgs, {
1006
+ detached: true,
1007
+ windowsHide: true,
1008
+ stdio: ["ignore", logFd, logFd],
1009
+ env: { ...process.env, ZALO_DAEMON_CHILD: "1" },
1010
+ });
1011
+ try {
1012
+ fs.closeSync(logFd);
1013
+ } catch {
1014
+ /* ignore */
1015
+ }
1016
+ child.unref();
1017
+ pid = child.pid;
1018
+ }
1019
+
1020
+ const deadline = Date.now() + timeoutMs;
1021
+ while (Date.now() < deadline) {
1022
+ const chan = getDaemonChannel(accountDir);
1023
+ if (chan && chan.pid === pid) {
1024
+ return { ok: true, pid, channelPort: chan.port, logPath };
1025
+ }
1026
+ if (!isProcessAlive(pid)) {
1027
+ let tail = "";
1028
+ try {
1029
+ const buf = fs.readFileSync(logPath);
1030
+ tail = buf.subarray(startOffset).toString("utf8").trim();
1031
+ } catch {
1032
+ /* ignore */
1033
+ }
1034
+ const lastLine =
1035
+ tail
1036
+ .split(/\r?\n/)
1037
+ .map((l) => l.trim())
1038
+ .filter(Boolean)
1039
+ .pop() || "Daemon process exited before becoming ready.";
1040
+ return { ok: false, pid, logPath, error: lastLine };
1041
+ }
1042
+ await new Promise((r) => setTimeout(r, 150));
1043
+ }
1044
+
1045
+ const finalStatus = getDaemonStatus(accountDir);
1046
+ if (finalStatus.running && finalStatus.pid === pid) {
1047
+ return { ok: true, pid, channelPort: finalStatus.channelPort, logPath };
1048
+ }
1049
+ return {
1050
+ ok: false,
1051
+ pid,
1052
+ logPath,
1053
+ error: "Timed out waiting for background daemon to finish starting.",
1054
+ };
1055
+ }
@@ -53,9 +53,11 @@ function socketAlive(api) {
53
53
  * @param {object} args
54
54
  * @param {() => object} args.getApi - resolves the daemon's CURRENT zca-js api
55
55
  * @param {string} args.accountName - ownId; picks the account data dir
56
+ * @param {(res: object) => void|Promise<void>} [args.onMessagesRestored] - optional hook after messages restore
57
+ * @param {(name: string) => void|Promise<void>} [args.onSelfName] - optional hook when active account's profile name is refreshed
56
58
  * @returns {Record<string, (params: object, onEvent: (e: object) => void) => Promise<object>>}
57
59
  */
58
- export function createSyncRunners({ getApi, accountName }) {
60
+ export function createSyncRunners({ getApi, accountName, onMessagesRestored, onSelfName }) {
59
61
  /** The live api, or a refusal the CLI can print. */
60
62
  const liveApi = (what) => {
61
63
  const api = getApi();
@@ -79,7 +81,7 @@ export function createSyncRunners({ getApi, accountName }) {
79
81
  */
80
82
  async messages(params = {}, onEvent = () => {}) {
81
83
  const api = liveApi("restore");
82
- return new SyncV2(api, accountName).restore({
84
+ const res = await new SyncV2(api, accountName).restore({
83
85
  days: params.days ?? null,
84
86
  from: params.from,
85
87
  waitMs: Number(params.waitMs) || undefined,
@@ -101,7 +103,16 @@ export function createSyncRunners({ getApi, accountName }) {
101
103
  // would race it. A socket lost mid-stage therefore ends as a
102
104
  // partial restore and the daemon recovers the session.
103
105
  onStatus: onEvent,
106
+ onSelfName,
104
107
  });
108
+ if (typeof onMessagesRestored === "function") {
109
+ try {
110
+ await onMessagesRestored(res);
111
+ } catch {
112
+ /* best-effort cache refresh */
113
+ }
114
+ }
115
+ return res;
105
116
  },
106
117
 
107
118
  /**
package/src/core/db.js CHANGED
@@ -486,6 +486,51 @@ export function insertMessage(msg) {
486
486
  ELSE MAX(COALESCE(excluded.msgStatus, 0), COALESCE(messages.msgStatus, 0)) END
487
487
  `);
488
488
 
489
+ const rawObj =
490
+ typeof msg.raw_data === "object" && msg.raw_data !== null
491
+ ? msg.raw_data
492
+ : (() => {
493
+ try {
494
+ return typeof msg.raw_data === "string" && msg.raw_data.startsWith("{")
495
+ ? JSON.parse(msg.raw_data)
496
+ : null;
497
+ } catch {
498
+ return null;
499
+ }
500
+ })();
501
+ const cliMsgId = rawObj?.cliMsgId;
502
+ let inheritedLocalPath = msg.localPath || null;
503
+ let inheritedStatus = Number.isFinite(Number(msg.msgStatus)) ? Number(msg.msgStatus) : null;
504
+ if (
505
+ cliMsgId !== undefined &&
506
+ cliMsgId !== null &&
507
+ String(cliMsgId) !== "" &&
508
+ String(cliMsgId) !== "0" &&
509
+ String(cliMsgId) !== String(msg.msgId) &&
510
+ msg.threadId
511
+ ) {
512
+ const tempRow = db
513
+ .prepare("SELECT localPath, msgStatus, type FROM messages WHERE msgId = ? AND threadId = ?")
514
+ .get(String(cliMsgId), String(msg.threadId));
515
+ if (tempRow && tempRow.type !== "deleted") {
516
+ if (!inheritedLocalPath && tempRow.localPath) inheritedLocalPath = tempRow.localPath;
517
+ if (Number.isFinite(Number(tempRow.msgStatus))) {
518
+ inheritedStatus =
519
+ inheritedStatus === null
520
+ ? Number(tempRow.msgStatus)
521
+ : Math.max(inheritedStatus, Number(tempRow.msgStatus));
522
+ }
523
+ db.prepare("DELETE FROM messages WHERE msgId = ? AND threadId = ?").run(
524
+ String(cliMsgId),
525
+ String(msg.threadId),
526
+ );
527
+ db.prepare("UPDATE OR IGNORE reactions SET msgId = ? WHERE msgId = ?").run(
528
+ String(msg.msgId),
529
+ String(cliMsgId),
530
+ );
531
+ }
532
+ }
533
+
489
534
  const raw_data =
490
535
  typeof msg.raw_data === "object" && msg.raw_data !== null ? JSON.stringify(msg.raw_data) : msg.raw_data;
491
536
 
@@ -498,9 +543,9 @@ export function insertMessage(msg) {
498
543
  timestamp: msg.timestamp,
499
544
  type: msg.type,
500
545
  raw_data: raw_data,
501
- localPath: msg.localPath || null,
546
+ localPath: inheritedLocalPath,
502
547
  has_attachment: msg.has_attachment ? 1 : 0,
503
- msgStatus: Number.isFinite(Number(msg.msgStatus)) ? Number(msg.msgStatus) : null,
548
+ msgStatus: inheritedStatus,
504
549
  });
505
550
  }
506
551
 
@@ -1488,11 +1533,23 @@ export function markMessageRecalled(msgId, at = Date.now(), opts = {}) {
1488
1533
  * query with it.
1489
1534
  *
1490
1535
  * @param {string|number} cliMsgId
1536
+ * @param {string} [threadId] - optional thread scope for index-backed lookup
1491
1537
  * @returns {object|null}
1492
1538
  */
1493
- export function findMessageByClientId(cliMsgId) {
1539
+ export function findMessageByClientId(cliMsgId, threadId = null) {
1494
1540
  if (!db) throw new Error("Database not initialized");
1495
1541
  if (cliMsgId === undefined || cliMsgId === null || cliMsgId === "") return null;
1542
+ if (threadId !== undefined && threadId !== null && threadId !== "") {
1543
+ return (
1544
+ db
1545
+ .prepare(
1546
+ `SELECT * FROM messages
1547
+ WHERE threadId = ? AND json_valid(raw_data) AND json_extract(raw_data, '$.cliMsgId') = ?
1548
+ ORDER BY timestamp DESC LIMIT 1`,
1549
+ )
1550
+ .get(String(threadId), String(cliMsgId)) || null
1551
+ );
1552
+ }
1496
1553
  return (
1497
1554
  db
1498
1555
  .prepare(
@@ -30,6 +30,7 @@ import {
30
30
  LIVE_GROUP_EVENT_PREFIX,
31
31
  } from "./db.js";
32
32
  import { classifyLiveMessage } from "./sync-v2/message-types.js";
33
+ import { resolveCurrentUserNames } from "./current-user-names.js";
33
34
 
34
35
  /** zca-js ThreadType.User */
35
36
  const THREAD_USER = 0;
@@ -209,6 +210,54 @@ export function storeLiveMessage(msg, opts = {}) {
209
210
  return { stored: true, info };
210
211
  }
211
212
 
213
+ /**
214
+ * Refresh durable identity labels observed on a live message:
215
+ * - When the message is an outgoing echo from our own account (`msg.isSelf`),
216
+ * `msg.data.dName` carries our current Zalo profile name, so invoke
217
+ * `opts.onSelfName(name)` to update the account registry if it changed.
218
+ * - When the message is in a 1-1 DM (`msg.type === THREAD_USER`), resolve the
219
+ * peer's current alias / Zalo profile name and update `threads.name` in
220
+ * `zalo.db` (and the MCP `nameCache`, when provided).
221
+ *
222
+ * Best-effort and non-throwing so it never blocks or drops a live message.
223
+ *
224
+ * @param {object} api - authenticated zca-js API instance
225
+ * @param {object} msg - zca-js message event
226
+ * @param {{onSelfName?: (name: string) => boolean|void, nameCache?: object, log?: Function}} [opts]
227
+ * @returns {Promise<{selfUpdated: boolean, dmName: string|null}>}
228
+ */
229
+ export async function refreshLiveMessageIdentities(api, msg, opts = {}) {
230
+ let selfUpdated = false;
231
+ let dmName = null;
232
+ try {
233
+ if (
234
+ msg?.isSelf &&
235
+ typeof opts.onSelfName === "function" &&
236
+ typeof msg?.data?.dName === "string" &&
237
+ msg.data.dName.trim()
238
+ ) {
239
+ selfUpdated = Boolean(await opts.onSelfName(msg.data.dName.trim()));
240
+ }
241
+ } catch {
242
+ /* registry update is best-effort */
243
+ }
244
+ if (msg?.type === THREAD_USER && msg.threadId !== undefined && msg.threadId !== null) {
245
+ const peerId = String(msg.threadId);
246
+ try {
247
+ const names = await resolveCurrentUserNames(api, [peerId], { log: opts.log });
248
+ const current = names.get(peerId);
249
+ if (current && current.source !== "uid" && current.name) {
250
+ dmName = current.name;
251
+ upsertThread({ threadId: peerId, type: "dm", name: current.name, lastUpdate: 0 });
252
+ opts.nameCache?.set?.(peerId, { name: current.name, type: "dm" });
253
+ }
254
+ } catch {
255
+ /* network lookup is best-effort */
256
+ }
257
+ }
258
+ return { selfUpdated, dmName };
259
+ }
260
+
212
261
  /**
213
262
  * Store one message a history fetch returned -- only if it is not stored yet.
214
263
  *
@@ -636,7 +685,15 @@ export function storeGroupEventRow(event) {
636
685
  // A sync that ran after the event already holds the richer row.
637
686
  if (hasSyncedGroupEventNear(String(threadId), ts)) return { stored: false, reason: "already synced" };
638
687
  const msgId = `${LIVE_GROUP_EVENT_PREFIX}${threadId}:${ts}:${type}`;
639
- upsertThread({ threadId: String(threadId), type: "group", name: "", lastUpdate: ts, nameHint: true });
688
+ const groupName = typeof d.groupName === "string" ? d.groupName.trim() : "";
689
+ const isRename = type === "update" && Boolean(groupName);
690
+ upsertThread({
691
+ threadId: String(threadId),
692
+ type: "group",
693
+ name: groupName,
694
+ lastUpdate: ts,
695
+ nameHint: !isRename,
696
+ });
640
697
  insertMessage({
641
698
  msgId,
642
699
  threadId: String(threadId),