@ardennguyen/zalo-agent-cli 2.5.1 → 2.5.3
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 +41 -33
- package/node_modules/zca-js/dist/apis.js +0 -8
- package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
- package/package.json +3 -3
- package/patches/zca-js+2.2.0.patch +4 -236
- package/src/commands/bank.js +126 -0
- package/src/commands/catalog.js +14 -0
- package/src/commands/conv.js +19 -0
- package/src/commands/friend.js +66 -21
- package/src/commands/group.js +38 -2
- package/src/commands/listen.js +51 -2
- package/src/commands/mcp.js +232 -2
- package/src/commands/msg.js +60 -0
- package/src/commands/profile.js +39 -0
- package/src/commands/sync.js +1 -68
- package/src/core/bank-accounts.js +52 -0
- package/src/core/daemon-channel.js +191 -2
- package/src/core/friend-requests.js +142 -0
- package/src/core/friend-search.js +41 -0
- package/src/core/group-admin.js +116 -0
- package/src/core/group-invite.js +53 -0
- package/src/core/sync.js +4 -235
- package/src/index.js +2 -0
- package/src/mcp/mcp-config.js +1 -0
- package/src/mcp/mcp-tools.js +302 -2
- package/src/utils/cli-args.js +84 -0
- package/src/utils/secrets.js +69 -0
- package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
- package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
- package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
- package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
- package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
- package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
- package/node_modules/zca-js/dist/cjs/apis/requestSyncMessage.cjs +0 -22
package/src/commands/mcp.js
CHANGED
|
@@ -20,8 +20,11 @@ import {
|
|
|
20
20
|
getDaemonStatus,
|
|
21
21
|
stopDaemon,
|
|
22
22
|
spawnDaemonProcess,
|
|
23
|
+
waitViaDaemon,
|
|
24
|
+
markReadViaDaemon,
|
|
23
25
|
} from "../core/daemon-channel.js";
|
|
24
26
|
import { success, error, info, output } from "../utils/output.js";
|
|
27
|
+
import { resolveWebhookSecret } from "../utils/secrets.js";
|
|
25
28
|
import { createSyncRunners } from "../core/daemon-sync.js";
|
|
26
29
|
import { createSyncJobs } from "../mcp/sync-jobs.js";
|
|
27
30
|
import { createSocketTap } from "../core/socket-tap.js";
|
|
@@ -211,6 +214,10 @@ export function registerMCPCommands(program) {
|
|
|
211
214
|
"-w, --webhook <url>",
|
|
212
215
|
"POST each watched incoming message as JSON to this URL (or set webhook in mcp-config.json)",
|
|
213
216
|
)
|
|
217
|
+
.option(
|
|
218
|
+
"--webhook-secret <secret>",
|
|
219
|
+
"Sign webhook POSTs: sent as `Authorization: Bearer <secret>` and `X-Zalo-Signature: sha256=<HMAC of the body>`. Prefer the ZALO_WEBHOOK_SECRET[_<ownId>] environment variable: a flag is visible in the process list (or set webhookSecret in mcp-config.json)",
|
|
220
|
+
)
|
|
214
221
|
.option(
|
|
215
222
|
"--no-delivered-receipts",
|
|
216
223
|
"Do not acknowledge received messages as delivered (every Zalo client does; seen receipts are never sent)",
|
|
@@ -237,6 +244,14 @@ export function registerMCPCommands(program) {
|
|
|
237
244
|
// An empty --auth is a token that went missing -- an unset variable
|
|
238
245
|
// in a unit file -- not a request for no auth, and it used to start
|
|
239
246
|
// the server open, saying nothing. Refused before anything logs in.
|
|
247
|
+
// The same for a webhook secret: empty must not mean "unsigned".
|
|
248
|
+
if (opts.webhookSecret !== undefined) {
|
|
249
|
+
try {
|
|
250
|
+
resolveWebhookSecret({ flag: opts.webhookSecret });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
failStart(e.message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
240
255
|
if (opts.http && opts.auth !== undefined && !String(opts.auth).trim()) {
|
|
241
256
|
failStart(
|
|
242
257
|
"--auth was given an empty token. Refusing to start: that would serve this account's " +
|
|
@@ -557,8 +572,14 @@ export function registerMCPCommands(program) {
|
|
|
557
572
|
envForAccount("ZALO_WEBHOOK_URL", activeAcc.ownId) ||
|
|
558
573
|
config.webhook?.trim() ||
|
|
559
574
|
null;
|
|
560
|
-
const webhookSecret =
|
|
561
|
-
|
|
575
|
+
const webhookSecret = resolveWebhookSecret({
|
|
576
|
+
flag: opts.webhookSecret,
|
|
577
|
+
env: envForAccount("ZALO_WEBHOOK_SECRET", activeAcc.ownId),
|
|
578
|
+
config: config?.webhookSecret,
|
|
579
|
+
});
|
|
580
|
+
if (webhookSecret && !webhookUrl) {
|
|
581
|
+
console.error("[mcp] A webhook secret is set but no webhook URL: nothing will be signed or sent.");
|
|
582
|
+
}
|
|
562
583
|
if (webhookUrl) {
|
|
563
584
|
console.error(`[mcp] Webhook enabled: ${webhookUrl}${webhookSecret ? " (signed)" : ""}`);
|
|
564
585
|
}
|
|
@@ -874,6 +895,70 @@ export function registerMCPCommands(program) {
|
|
|
874
895
|
// The self-heal's lock: a stage and a catch-up never share the socket.
|
|
875
896
|
lock: stageLock,
|
|
876
897
|
onStop: shutdown,
|
|
898
|
+
onWait: async (params, signal) => {
|
|
899
|
+
const cleanThreadId =
|
|
900
|
+
typeof params?.threadId === "string" && params.threadId.trim()
|
|
901
|
+
? params.threadId.trim()
|
|
902
|
+
: null;
|
|
903
|
+
const consumer =
|
|
904
|
+
typeof params?.consumer === "string" && params.consumer.trim()
|
|
905
|
+
? params.consumer.trim()
|
|
906
|
+
: undefined;
|
|
907
|
+
const clampedSec = Math.max(1, Math.min(300, Number(params?.timeoutSec) || 55));
|
|
908
|
+
const explicitSince =
|
|
909
|
+
typeof params?.since === "number" && Number.isFinite(params.since)
|
|
910
|
+
? params.since
|
|
911
|
+
: undefined;
|
|
912
|
+
const effectiveSince =
|
|
913
|
+
explicitSince !== undefined
|
|
914
|
+
? explicitSince
|
|
915
|
+
: consumer
|
|
916
|
+
? buffer.readCursor(consumer)
|
|
917
|
+
: undefined;
|
|
918
|
+
const msg = await buffer.waitForMessage({
|
|
919
|
+
threadId: cleanThreadId,
|
|
920
|
+
since: effectiveSince,
|
|
921
|
+
timeoutMs: clampedSec * 1000,
|
|
922
|
+
signal,
|
|
923
|
+
});
|
|
924
|
+
if (msg) {
|
|
925
|
+
const tid = String(msg.threadId || "");
|
|
926
|
+
const threadName = nameCache?.get(tid)?.name || null;
|
|
927
|
+
return {
|
|
928
|
+
status: "message",
|
|
929
|
+
message: {
|
|
930
|
+
...msg,
|
|
931
|
+
...(threadName ? { threadName } : {}),
|
|
932
|
+
},
|
|
933
|
+
cursor: msg._cursor,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
status: "timeout",
|
|
938
|
+
message: null,
|
|
939
|
+
cursor: Math.max(
|
|
940
|
+
effectiveSince ?? 0,
|
|
941
|
+
Number(buffer._globalCursor) || 0,
|
|
942
|
+
buffer.readCursor(consumer),
|
|
943
|
+
),
|
|
944
|
+
};
|
|
945
|
+
},
|
|
946
|
+
onMarkRead: async (params) => {
|
|
947
|
+
const cursorNum = Number(params?.cursor);
|
|
948
|
+
if (!Number.isInteger(cursorNum) || cursorNum < 0) {
|
|
949
|
+
throw new Error("cursor must be a non-negative integer");
|
|
950
|
+
}
|
|
951
|
+
const consumer =
|
|
952
|
+
typeof params?.consumer === "string" && params.consumer.trim()
|
|
953
|
+
? params.consumer.trim()
|
|
954
|
+
: undefined;
|
|
955
|
+
const marked = buffer.markRead(cursorNum, consumer);
|
|
956
|
+
return {
|
|
957
|
+
marked,
|
|
958
|
+
readCursor: buffer.readCursor(consumer),
|
|
959
|
+
consumer: consumer || "default",
|
|
960
|
+
};
|
|
961
|
+
},
|
|
877
962
|
});
|
|
878
963
|
console.error(`[mcp] Sync & upload channel ready on 127.0.0.1:${channel.port}`);
|
|
879
964
|
} catch (e) {
|
|
@@ -896,4 +981,149 @@ export function registerMCPCommands(program) {
|
|
|
896
981
|
// Keep process alive (MCP server runs on stdio — process must not exit)
|
|
897
982
|
await new Promise(() => {});
|
|
898
983
|
});
|
|
984
|
+
|
|
985
|
+
mcp.command("wait [since]")
|
|
986
|
+
.description(
|
|
987
|
+
"Zero-CPU long-poll on the running MCP server's live message buffer (CLI equivalent of zalo_wait_for_message)",
|
|
988
|
+
)
|
|
989
|
+
.option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
|
|
990
|
+
.option("-s, --since <cursor>", "Return immediately if a buffered message after this cursor already exists")
|
|
991
|
+
.option("-t, --thread <threadId>", "Only wake on messages from this thread ID")
|
|
992
|
+
.option("--timeout <seconds>", "Maximum seconds to wait before returning timeout (1-300, default: 55)", "55")
|
|
993
|
+
.option(
|
|
994
|
+
"-c, --consumer <name>",
|
|
995
|
+
"Consumer name whose read cursor is used when --since is omitted (matches zalo_mark_read)",
|
|
996
|
+
)
|
|
997
|
+
.action(async (sinceArg, opts) => {
|
|
998
|
+
const jsonMode = program.opts().json;
|
|
999
|
+
const rawSince = opts.since !== undefined ? opts.since : sinceArg;
|
|
1000
|
+
let parsedSince;
|
|
1001
|
+
if (rawSince !== undefined) {
|
|
1002
|
+
const n = Number(rawSince);
|
|
1003
|
+
if (!Number.isInteger(n) || n < 0 || !/^\d+$/.test(String(rawSince).trim())) {
|
|
1004
|
+
error(`Invalid cursor "${rawSince}": must be a non-negative integer.`);
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
parsedSince = n;
|
|
1008
|
+
}
|
|
1009
|
+
const rawTimeout = Number(opts.timeout);
|
|
1010
|
+
if (!Number.isInteger(rawTimeout) || rawTimeout < 1 || rawTimeout > 300) {
|
|
1011
|
+
error(`Invalid --timeout "${opts.timeout}": must be an integer between 1 and 300 seconds.`);
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
const consumer = opts.consumer?.trim() || undefined;
|
|
1015
|
+
if (consumer && !/^[A-Za-z0-9._:@-]{1,64}$/.test(consumer)) {
|
|
1016
|
+
error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let activeAcc;
|
|
1021
|
+
try {
|
|
1022
|
+
const requestedAccount =
|
|
1023
|
+
opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
1024
|
+
activeAcc = selectDaemonAccount(requestedAccount);
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
error(`Account selection failed: ${e.message}`);
|
|
1027
|
+
process.exit(1);
|
|
1028
|
+
}
|
|
1029
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1030
|
+
const res = await waitViaDaemon(accountDir, {
|
|
1031
|
+
threadId: opts.thread?.trim() || null,
|
|
1032
|
+
since: parsedSince,
|
|
1033
|
+
timeoutSec: rawTimeout,
|
|
1034
|
+
consumer,
|
|
1035
|
+
});
|
|
1036
|
+
if (!res) {
|
|
1037
|
+
error(
|
|
1038
|
+
`No running MCP server found for account ${activeAcc.ownId}. Start one with: zalo-agent mcp start`,
|
|
1039
|
+
);
|
|
1040
|
+
process.exit(1);
|
|
1041
|
+
}
|
|
1042
|
+
if (!res.ok) {
|
|
1043
|
+
error(`Wait failed: ${res.error}`);
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
output(
|
|
1047
|
+
{
|
|
1048
|
+
status: res.status,
|
|
1049
|
+
message: res.message,
|
|
1050
|
+
cursor: res.cursor,
|
|
1051
|
+
},
|
|
1052
|
+
jsonMode,
|
|
1053
|
+
() => {
|
|
1054
|
+
if (res.status === "message" && res.message) {
|
|
1055
|
+
const m = res.message;
|
|
1056
|
+
const label = m.threadName ? `${m.threadName} (${m.threadId})` : m.threadId;
|
|
1057
|
+
success(
|
|
1058
|
+
`[cursor ${res.cursor}] ${m.senderName || m.senderId} in ${label}: ${m.text || `(${m.type})`}`,
|
|
1059
|
+
);
|
|
1060
|
+
} else {
|
|
1061
|
+
info(`Timeout after ${rawTimeout}s (cursor: ${res.cursor})`);
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
mcp.command("mark-read <cursor>")
|
|
1068
|
+
.description(
|
|
1069
|
+
"Advance a consumer's read cursor in the running MCP server's live buffer (CLI equivalent of zalo_mark_read)",
|
|
1070
|
+
)
|
|
1071
|
+
.option("--account <id>", "Target a specific registered account ID (default: active account)", parseAccountId)
|
|
1072
|
+
.option(
|
|
1073
|
+
"-c, --consumer <name>",
|
|
1074
|
+
'Consumer name whose read cursor to advance (default: "default"; deletes nothing for other consumers)',
|
|
1075
|
+
)
|
|
1076
|
+
.action(async (cursorArg, opts) => {
|
|
1077
|
+
const jsonMode = program.opts().json;
|
|
1078
|
+
const trimmedCursor = String(cursorArg ?? "").trim();
|
|
1079
|
+
const parsedCursor = Number(trimmedCursor);
|
|
1080
|
+
if (!Number.isInteger(parsedCursor) || parsedCursor < 0 || !/^\d+$/.test(trimmedCursor)) {
|
|
1081
|
+
error(`Invalid cursor "${cursorArg}": must be a non-negative integer.`);
|
|
1082
|
+
process.exit(1);
|
|
1083
|
+
}
|
|
1084
|
+
const consumer = opts.consumer?.trim() || undefined;
|
|
1085
|
+
if (consumer && !/^[A-Za-z0-9._:@-]{1,64}$/.test(consumer)) {
|
|
1086
|
+
error(`Invalid --consumer "${consumer}": must be 1-64 characters matching [A-Za-z0-9._:@-].`);
|
|
1087
|
+
process.exit(1);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
let activeAcc;
|
|
1091
|
+
try {
|
|
1092
|
+
const requestedAccount =
|
|
1093
|
+
opts.account ?? program.opts().account ?? (process.env.ZALO_ACCOUNT?.trim() || undefined);
|
|
1094
|
+
activeAcc = selectDaemonAccount(requestedAccount);
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
error(`Account selection failed: ${e.message}`);
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
}
|
|
1099
|
+
const accountDir = getAccountDir(activeAcc.ownId);
|
|
1100
|
+
const res = await markReadViaDaemon(accountDir, {
|
|
1101
|
+
cursor: parsedCursor,
|
|
1102
|
+
consumer,
|
|
1103
|
+
});
|
|
1104
|
+
if (!res) {
|
|
1105
|
+
error(
|
|
1106
|
+
`No running MCP server found for account ${activeAcc.ownId}. Start one with: zalo-agent mcp start`,
|
|
1107
|
+
);
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
if (!res.ok) {
|
|
1111
|
+
error(`Mark-read failed: ${res.error}`);
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
output(
|
|
1115
|
+
{
|
|
1116
|
+
success: true,
|
|
1117
|
+
marked: res.marked,
|
|
1118
|
+
readCursor: res.readCursor,
|
|
1119
|
+
consumer: res.consumer,
|
|
1120
|
+
},
|
|
1121
|
+
jsonMode,
|
|
1122
|
+
() => {
|
|
1123
|
+
success(
|
|
1124
|
+
`Marked ${res.marked} message(s) read for consumer "${res.consumer}" (readCursor: ${res.readCursor})`,
|
|
1125
|
+
);
|
|
1126
|
+
},
|
|
1127
|
+
);
|
|
1128
|
+
});
|
|
899
1129
|
}
|
package/src/commands/msg.js
CHANGED
|
@@ -10,6 +10,7 @@ import { getApi, getOwnId } from "../core/zalo-client.js";
|
|
|
10
10
|
import { withCurrentSenderNames } from "../core/current-user-names.js";
|
|
11
11
|
import { success, error, info, output, warning } from "../utils/output.js";
|
|
12
12
|
import { parseIntOption } from "../utils/parse-options.js";
|
|
13
|
+
import { parseThreadTypeArg, parseReportReason } from "../utils/cli-args.js";
|
|
13
14
|
import { getActive } from "../core/accounts.js";
|
|
14
15
|
import { getAccountDir, getAccountDbPath } from "../core/credentials.js";
|
|
15
16
|
import { initDb, getMessages, getMessageById, getDisplayName, getThreadType } from "../core/db.js";
|
|
@@ -1753,4 +1754,63 @@ export function registerMsgCommands(program) {
|
|
|
1753
1754
|
process.exit(1);
|
|
1754
1755
|
}
|
|
1755
1756
|
});
|
|
1757
|
+
|
|
1758
|
+
msg.command("typing <threadId>")
|
|
1759
|
+
.description('Show the "typing…" indicator in a conversation')
|
|
1760
|
+
.option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
|
|
1761
|
+
.action(async (threadId, opts) => {
|
|
1762
|
+
try {
|
|
1763
|
+
const result = await getApi().sendTypingEvent(threadId, parseThreadTypeArg(opts.type));
|
|
1764
|
+
output(result, program.opts().json, () => success("Typing indicator sent"));
|
|
1765
|
+
} catch (e) {
|
|
1766
|
+
error(e.message);
|
|
1767
|
+
}
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
msg.command("report <threadId>")
|
|
1771
|
+
.description("Report a user (-t 0) or a group (-t 1) to Zalo")
|
|
1772
|
+
.requiredOption("-r, --reason <reason>", "sensitive, annoy, fraud or other")
|
|
1773
|
+
.option("-c, --content <text>", "What happened (required with --reason other, refused otherwise)")
|
|
1774
|
+
.option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
|
|
1775
|
+
.action(async (threadId, opts) => {
|
|
1776
|
+
try {
|
|
1777
|
+
const options = parseReportReason(opts.reason, opts.content);
|
|
1778
|
+
const result = await getApi().sendReport(options, threadId, parseThreadTypeArg(opts.type));
|
|
1779
|
+
output(result, program.opts().json, () => success(`Report sent (id ${result?.reportId ?? "?"})`));
|
|
1780
|
+
} catch (e) {
|
|
1781
|
+
error(e.message);
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
msg.command("link-preview <url>")
|
|
1786
|
+
.description("Fetch the preview Zalo would show for a link (title, description, thumbnail)")
|
|
1787
|
+
.action(async (url) => {
|
|
1788
|
+
try {
|
|
1789
|
+
const result = await getApi().parseLink(url);
|
|
1790
|
+
output(result, program.opts().json, () => {
|
|
1791
|
+
const d = result?.data ?? {};
|
|
1792
|
+
info(`Title: ${d.title || "-"}`);
|
|
1793
|
+
info(`Description: ${d.desc || "-"}`);
|
|
1794
|
+
info(`Source: ${d.src || d.href || "-"}`);
|
|
1795
|
+
if (d.thumb) info(`Thumbnail: ${d.thumb}`);
|
|
1796
|
+
});
|
|
1797
|
+
} catch (e) {
|
|
1798
|
+
error(`Link preview failed: ${e.message}`);
|
|
1799
|
+
}
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
msg.command("scan-url <url>")
|
|
1803
|
+
.description("Ask Zalo whether a URL is safe to open")
|
|
1804
|
+
.action(async (url) => {
|
|
1805
|
+
try {
|
|
1806
|
+
const result = await getApi().scanURL(url);
|
|
1807
|
+
output(result, program.opts().json, () =>
|
|
1808
|
+
result?.isSafe
|
|
1809
|
+
? success("Zalo reports this URL as safe")
|
|
1810
|
+
: warning("Zalo flags this URL as unsafe"),
|
|
1811
|
+
);
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
error(`URL scan failed: ${e.message}`);
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
1756
1816
|
}
|
package/src/commands/profile.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getApi, getOwnId } from "../core/zalo-client.js";
|
|
|
7
7
|
import { updateAccountName } from "../core/accounts.js";
|
|
8
8
|
import { success, error, info, output } from "../utils/output.js";
|
|
9
9
|
import { parseIntOption } from "../utils/parse-options.js";
|
|
10
|
+
import { parseOnOff, parseLanguage } from "../utils/cli-args.js";
|
|
10
11
|
|
|
11
12
|
/** Valid privacy setting keys and their human-readable labels + value descriptions. */
|
|
12
13
|
const SETTING_MAP = {
|
|
@@ -276,4 +277,42 @@ export function registerProfileCommands(program) {
|
|
|
276
277
|
error(`Setting update failed: ${e.message}`);
|
|
277
278
|
}
|
|
278
279
|
});
|
|
280
|
+
|
|
281
|
+
profile
|
|
282
|
+
.command("active <on|off>")
|
|
283
|
+
.description("Turn your online (active) status on or off")
|
|
284
|
+
.action(async (state) => {
|
|
285
|
+
try {
|
|
286
|
+
const active = parseOnOff(state, "active");
|
|
287
|
+
const result = await getApi().updateActiveStatus(active);
|
|
288
|
+
output(result, program.opts().json, () => success(`Active status: ${active ? "on" : "off"}`));
|
|
289
|
+
} catch (e) {
|
|
290
|
+
error(e.message);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
profile
|
|
295
|
+
.command("lang <language>")
|
|
296
|
+
.description("Set the account language: vi or en")
|
|
297
|
+
.action(async (language) => {
|
|
298
|
+
try {
|
|
299
|
+
const lang = parseLanguage(language);
|
|
300
|
+
const result = await getApi().updateLang(lang);
|
|
301
|
+
output(result, program.opts().json, () => success(`Language set to ${lang}`));
|
|
302
|
+
} catch (e) {
|
|
303
|
+
error(e.message);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
profile
|
|
308
|
+
.command("lost-focus")
|
|
309
|
+
.description("Tell Zalo this client moved to the background (what Zalo Web sends when its tab loses focus)")
|
|
310
|
+
.action(async () => {
|
|
311
|
+
try {
|
|
312
|
+
const result = await getApi().lostFocus();
|
|
313
|
+
output(result, program.opts().json, () => success("Sent"));
|
|
314
|
+
} catch (e) {
|
|
315
|
+
error(`Lost focus failed: ${e.message}`);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
279
318
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -119,8 +119,7 @@ export function registerSyncCommands(program) {
|
|
|
119
119
|
"Restore message history from your phone into the local cache (zalo.db). " +
|
|
120
120
|
"This IS the real phone-backed restore (transfer-sync-v2) by default: it sends one " +
|
|
121
121
|
"sync request your phone confirms, then decrypts and stores your history. " +
|
|
122
|
-
"--socket asks for the old server backfill instead (measured empty)
|
|
123
|
-
"endpoint Zalo Web no longer calls (it answers empty).",
|
|
122
|
+
"--socket asks for the old server backfill instead (measured empty).",
|
|
124
123
|
)
|
|
125
124
|
.option(
|
|
126
125
|
"-t, --transfer",
|
|
@@ -147,11 +146,6 @@ export function registerSyncCommands(program) {
|
|
|
147
146
|
"The default is already everything your phone still holds, so this is for deliberately " +
|
|
148
147
|
"narrowing a run, not widening it",
|
|
149
148
|
)
|
|
150
|
-
.option(
|
|
151
|
-
"-L, --legacy",
|
|
152
|
-
"Try the old pull_mobile_msg/get_crossdb phone-transfer endpoint instead, which Zalo Web no longer " +
|
|
153
|
-
"calls and which answers empty. One attempt only — it pings your mobile app",
|
|
154
|
-
)
|
|
155
149
|
.option(
|
|
156
150
|
"-M, --messages-only",
|
|
157
151
|
"Restore messages but do NOT fetch their media. By default a transfer sync downloads the attachments it " +
|
|
@@ -195,10 +189,6 @@ export function registerSyncCommands(program) {
|
|
|
195
189
|
process.exit(1);
|
|
196
190
|
}
|
|
197
191
|
|
|
198
|
-
if (opts.legacy) {
|
|
199
|
-
await runLegacySync(activeAcc, opts);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
192
|
if (opts.socket) {
|
|
203
193
|
await runSocketBackfill(activeAcc, opts);
|
|
204
194
|
return;
|
|
@@ -1995,60 +1985,3 @@ async function runSocketBackfill(activeAcc, opts) {
|
|
|
1995
1985
|
|
|
1996
1986
|
process.exit(exitCode);
|
|
1997
1987
|
}
|
|
1998
|
-
|
|
1999
|
-
/**
|
|
2000
|
-
* The retired path, kept behind --legacy so the behavior stays inspectable.
|
|
2001
|
-
*
|
|
2002
|
-
* `/api/message/pull_mobile_msg` and `/api/message/get_crossdb` are still
|
|
2003
|
-
* present in Zalo Web's own bundle but have ZERO call sites in it — the client
|
|
2004
|
-
* moved to a WebSocket protocol (cmd 590/591, "transfer-sync-v2"). The
|
|
2005
|
-
* endpoints answer with an empty payload rather than an error, which is why
|
|
2006
|
-
* this used to look like "the phone hasn't replied yet" and retry for two
|
|
2007
|
-
* minutes, putting a notification on the owner's phone each time.
|
|
2008
|
-
*
|
|
2009
|
-
* It runs exactly once now.
|
|
2010
|
-
*
|
|
2011
|
-
* @param {{ownId: string}} activeAcc
|
|
2012
|
-
* @param {{force?: boolean}} opts
|
|
2013
|
-
*/
|
|
2014
|
-
async function runLegacySync(activeAcc, opts) {
|
|
2015
|
-
warning(
|
|
2016
|
-
"--legacy uses an endpoint Zalo Web no longer calls; it answers empty. This pings your mobile app " +
|
|
2017
|
-
"and will most likely find nothing.",
|
|
2018
|
-
);
|
|
2019
|
-
try {
|
|
2020
|
-
const syncManager = new SyncManager(getApi(), activeAcc.ownId);
|
|
2021
|
-
const res = await syncManager.pollSync(0, 0, { force: !!opts.force });
|
|
2022
|
-
|
|
2023
|
-
switch (res.status) {
|
|
2024
|
-
case "already-synced":
|
|
2025
|
-
success("Already synced — no known missed messages since the last successful sync.");
|
|
2026
|
-
info("Pass --force to check anyway.");
|
|
2027
|
-
process.exit(0);
|
|
2028
|
-
break;
|
|
2029
|
-
case "saved":
|
|
2030
|
-
success(`Synced ${res.saved}/${res.total} message(s) from mobile into the local cache.`);
|
|
2031
|
-
process.exit(0);
|
|
2032
|
-
break;
|
|
2033
|
-
case "empty-or-unrecognized":
|
|
2034
|
-
success("Sync round-trip completed — nothing new to save.");
|
|
2035
|
-
process.exit(0);
|
|
2036
|
-
break;
|
|
2037
|
-
case "legacy-retired":
|
|
2038
|
-
error("The legacy mobile-sync endpoint returned nothing, as it now always does.");
|
|
2039
|
-
info("Run `zalo-agent sync-mobile` without --legacy to backfill over the WebSocket instead.");
|
|
2040
|
-
process.exit(1);
|
|
2041
|
-
break;
|
|
2042
|
-
case "crossdb-error":
|
|
2043
|
-
error(`Sync failed: ${res.error}`);
|
|
2044
|
-
process.exit(1);
|
|
2045
|
-
break;
|
|
2046
|
-
default:
|
|
2047
|
-
error(`Unrecognized sync result: ${res.status}`);
|
|
2048
|
-
process.exit(1);
|
|
2049
|
-
}
|
|
2050
|
-
} catch (err) {
|
|
2051
|
-
error(`Failed: ${err.message}`);
|
|
2052
|
-
process.exit(1);
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account's saved bank accounts (`bank` commands).
|
|
3
|
+
*
|
|
4
|
+
* Pure of side effects except the reads it is handed an api for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Page size used when scanning the saved-account list. */
|
|
8
|
+
const SCAN_BATCH = 50;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Find one saved bank account by its `account_id`.
|
|
12
|
+
*
|
|
13
|
+
* `deleteBankAccount` needs `isDefault`, which only the list knows, so removing
|
|
14
|
+
* an account starts by finding it. Scans page by page while Zalo says there are
|
|
15
|
+
* more, with a cap so a server that never stops saying so cannot loop us.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} api - logged-in zca-js api
|
|
18
|
+
* @param {number} accountId
|
|
19
|
+
* @returns {Promise<object|null>} the saved account, or null when no page has it
|
|
20
|
+
*/
|
|
21
|
+
export async function findBankAccount(api, accountId) {
|
|
22
|
+
for (let page = 0; page < 100; page++) {
|
|
23
|
+
const res = await api.getListBankAccount(page, SCAN_BATCH);
|
|
24
|
+
const hit = (res?.myBanks ?? []).find((b) => Number(b.account_id) === Number(accountId));
|
|
25
|
+
if (hit) return hit;
|
|
26
|
+
if (!res?.hasMore) return null;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The banks whose name, short name, English name or search keywords contain `query`
|
|
33
|
+
* (case-insensitive substring); all of them for an empty query.
|
|
34
|
+
*
|
|
35
|
+
* @param {object[]} banks - `getListBank` entries
|
|
36
|
+
* @param {string} [query]
|
|
37
|
+
* @returns {object[]}
|
|
38
|
+
*/
|
|
39
|
+
export function filterBanks(banks, query) {
|
|
40
|
+
const q = String(query ?? "")
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
const list = Array.isArray(banks) ? banks : [];
|
|
44
|
+
if (!q) return list;
|
|
45
|
+
return list.filter((b) =>
|
|
46
|
+
[b.name, b.short_name, b.name_eng, b.search_key_word, String(b.bin)].some((s) =>
|
|
47
|
+
String(s ?? "")
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.includes(q),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
}
|