@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/core/accounts.js
CHANGED
|
@@ -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({
|
|
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
|
+
}
|
package/src/core/daemon-sync.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
546
|
+
localPath: inheritedLocalPath,
|
|
502
547
|
has_attachment: msg.has_attachment ? 1 : 0,
|
|
503
|
-
msgStatus:
|
|
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(
|
package/src/core/live-store.js
CHANGED
|
@@ -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
|
-
|
|
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),
|