@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
|
@@ -81,6 +81,7 @@ import fs from "node:fs";
|
|
|
81
81
|
import path from "node:path";
|
|
82
82
|
import { spawn, spawnSync } from "node:child_process";
|
|
83
83
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
84
|
+
import { maskSecretArgs } from "../utils/secrets.js";
|
|
84
85
|
|
|
85
86
|
const CHANNEL_FILE = "daemon-channel.json";
|
|
86
87
|
const LOCK_FILE = "daemon.lock";
|
|
@@ -291,6 +292,10 @@ function readJsonBody(req, reply) {
|
|
|
291
292
|
* @param {() => void} [args.onStop] - graceful shutdown callback invoked by
|
|
292
293
|
* `POST /stop` so `--stop` runs the same teardown as `SIGINT` (stamping
|
|
293
294
|
* `markConnected`, flushing delivered receipts, releasing `daemon.lock`).
|
|
295
|
+
* @param {(params: object, signal: AbortSignal) => Promise<object>} [args.onWait] -
|
|
296
|
+
* long-poll callback invoked by `POST /wait` (`zalo-agent mcp wait`).
|
|
297
|
+
* @param {(params: object) => Promise<object>} [args.onMarkRead] -
|
|
298
|
+
* read-cursor callback invoked by `POST /mark-read` (`zalo-agent mcp mark-read`).
|
|
294
299
|
* @returns {Promise<{port: number, stop: () => void}>}
|
|
295
300
|
*/
|
|
296
301
|
export function startDaemonChannel({
|
|
@@ -300,6 +305,8 @@ export function startDaemonChannel({
|
|
|
300
305
|
runners = {},
|
|
301
306
|
lock = createStageLock(),
|
|
302
307
|
onStop = null,
|
|
308
|
+
onWait = null,
|
|
309
|
+
onMarkRead = null,
|
|
303
310
|
}) {
|
|
304
311
|
const token = randomBytes(24).toString("hex");
|
|
305
312
|
|
|
@@ -416,6 +423,40 @@ export function startDaemonChannel({
|
|
|
416
423
|
});
|
|
417
424
|
};
|
|
418
425
|
|
|
426
|
+
/** Long-poll the MCP daemon's live message buffer (`zalo-agent mcp wait`). */
|
|
427
|
+
const serveWait = async (body, res, reply) => {
|
|
428
|
+
if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
|
|
429
|
+
if (typeof onWait !== "function") {
|
|
430
|
+
return reply(503, {
|
|
431
|
+
error: "this daemon does not have an MCP message buffer (run `zalo-agent mcp start` instead of `listen`)",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const ac = new AbortController();
|
|
435
|
+
res.on("close", () => ac.abort());
|
|
436
|
+
try {
|
|
437
|
+
const result = await onWait(body, ac.signal);
|
|
438
|
+
if (!res.writableEnded) reply(200, { ok: true, ...result });
|
|
439
|
+
} catch (e) {
|
|
440
|
+
if (!res.writableEnded) reply(200, { ok: false, error: e?.message || String(e) });
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** Advance one consumer's read cursor in the MCP daemon's buffer (`zalo-agent mcp mark-read`). */
|
|
445
|
+
const serveMarkRead = async (body, reply) => {
|
|
446
|
+
if (!tokenMatches(body.token, token)) return reply(403, { error: "bad token" });
|
|
447
|
+
if (typeof onMarkRead !== "function") {
|
|
448
|
+
return reply(503, {
|
|
449
|
+
error: "this daemon does not have an MCP message buffer (run `zalo-agent mcp start` instead of `listen`)",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const result = await onMarkRead(body);
|
|
454
|
+
reply(200, { ok: true, ...result });
|
|
455
|
+
} catch (e) {
|
|
456
|
+
reply(200, { ok: false, error: e?.message || String(e) });
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
419
460
|
const server = http.createServer(async (req, res) => {
|
|
420
461
|
const reply = (code, body) => {
|
|
421
462
|
res.writeHead(code, { "content-type": "application/json" });
|
|
@@ -424,11 +465,21 @@ export function startDaemonChannel({
|
|
|
424
465
|
if (req.method !== "POST") return reply(404, { error: "not found" });
|
|
425
466
|
|
|
426
467
|
const stage = SYNC_ROUTE.exec(req.url || "")?.[1];
|
|
427
|
-
if (
|
|
468
|
+
if (
|
|
469
|
+
req.url !== "/send-attachments" &&
|
|
470
|
+
req.url !== "/stop" &&
|
|
471
|
+
req.url !== "/wait" &&
|
|
472
|
+
req.url !== "/mark-read" &&
|
|
473
|
+
!stage
|
|
474
|
+
) {
|
|
475
|
+
return reply(404, { error: "not found" });
|
|
476
|
+
}
|
|
428
477
|
|
|
429
478
|
const body = await readJsonBody(req, reply);
|
|
430
479
|
if (!body) return; // already answered, or the request went away
|
|
431
480
|
if (req.url === "/stop") return serveStop(body, reply);
|
|
481
|
+
if (req.url === "/wait") return serveWait(body, res, reply);
|
|
482
|
+
if (req.url === "/mark-read") return serveMarkRead(body, reply);
|
|
432
483
|
return stage ? serveStage(stage, body, res, reply) : serveAttachments(body, reply);
|
|
433
484
|
});
|
|
434
485
|
|
|
@@ -781,6 +832,143 @@ export function stopViaDaemon(accountDir, { timeoutMs = 3000 } = {}) {
|
|
|
781
832
|
});
|
|
782
833
|
}
|
|
783
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Long-poll the running MCP daemon's live message buffer (`zalo-agent mcp wait`),
|
|
837
|
+
* mirroring the `zalo_wait_for_message` MCP tool over the loopback channel.
|
|
838
|
+
*
|
|
839
|
+
* @param {string} accountDir
|
|
840
|
+
* @param {{threadId?: string|null, since?: number, timeoutSec?: number, consumer?: string}} [opts]
|
|
841
|
+
* @returns {Promise<{ok: boolean, status?: "message"|"timeout"|number, message?: object|null, cursor?: number, error?: string}|null>}
|
|
842
|
+
* null when no daemon channel is published
|
|
843
|
+
*/
|
|
844
|
+
export function waitViaDaemon(accountDir, { threadId = null, since = undefined, timeoutSec = 55, consumer } = {}) {
|
|
845
|
+
const chan = getDaemonChannel(accountDir);
|
|
846
|
+
if (!chan) return Promise.resolve(null);
|
|
847
|
+
|
|
848
|
+
const clampedSec = Math.max(1, Math.min(300, Number(timeoutSec) || 55));
|
|
849
|
+
const payload = JSON.stringify({
|
|
850
|
+
token: chan.token,
|
|
851
|
+
...(threadId ? { threadId: String(threadId) } : {}),
|
|
852
|
+
...(typeof since === "number" && Number.isFinite(since) ? { since } : {}),
|
|
853
|
+
timeoutSec: clampedSec,
|
|
854
|
+
...(consumer ? { consumer: String(consumer) } : {}),
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
return new Promise((resolve) => {
|
|
858
|
+
const r = http.request(
|
|
859
|
+
{
|
|
860
|
+
host: "127.0.0.1",
|
|
861
|
+
port: chan.port,
|
|
862
|
+
path: "/wait",
|
|
863
|
+
method: "POST",
|
|
864
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
|
|
865
|
+
timeout: (clampedSec + 10) * 1000,
|
|
866
|
+
agent: false,
|
|
867
|
+
},
|
|
868
|
+
(res) => {
|
|
869
|
+
let raw = "";
|
|
870
|
+
res.setEncoding("utf8");
|
|
871
|
+
res.on("data", (c) => (raw += c));
|
|
872
|
+
res.on("end", () => {
|
|
873
|
+
let body = {};
|
|
874
|
+
try {
|
|
875
|
+
body = JSON.parse(raw);
|
|
876
|
+
} catch {
|
|
877
|
+
/* non-JSON response */
|
|
878
|
+
}
|
|
879
|
+
if (res.statusCode === 200 && body.ok) {
|
|
880
|
+
resolve({
|
|
881
|
+
ok: true,
|
|
882
|
+
status: body.status,
|
|
883
|
+
message: body.message ?? null,
|
|
884
|
+
cursor: body.cursor,
|
|
885
|
+
});
|
|
886
|
+
} else {
|
|
887
|
+
resolve({
|
|
888
|
+
ok: false,
|
|
889
|
+
status: res.statusCode,
|
|
890
|
+
error: body.error || `HTTP ${res.statusCode}`,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
},
|
|
895
|
+
);
|
|
896
|
+
r.on("timeout", () => {
|
|
897
|
+
r.destroy();
|
|
898
|
+
resolve({ ok: false, error: "timeout waiting for daemon response" });
|
|
899
|
+
});
|
|
900
|
+
r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
|
|
901
|
+
r.end(payload);
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Advance one consumer's read cursor in the running MCP daemon's live message
|
|
907
|
+
* buffer (`zalo-agent mcp mark-read`), mirroring the `zalo_mark_read` MCP tool.
|
|
908
|
+
*
|
|
909
|
+
* @param {string} accountDir
|
|
910
|
+
* @param {{cursor: number, consumer?: string}} opts
|
|
911
|
+
* @returns {Promise<{ok: boolean, marked?: number, readCursor?: number, consumer?: string, status?: number, error?: string}|null>}
|
|
912
|
+
* null when no daemon channel is published
|
|
913
|
+
*/
|
|
914
|
+
export function markReadViaDaemon(accountDir, { cursor, consumer } = {}) {
|
|
915
|
+
const chan = getDaemonChannel(accountDir);
|
|
916
|
+
if (!chan) return Promise.resolve(null);
|
|
917
|
+
|
|
918
|
+
const payload = JSON.stringify({
|
|
919
|
+
token: chan.token,
|
|
920
|
+
cursor: Number(cursor),
|
|
921
|
+
...(consumer ? { consumer: String(consumer) } : {}),
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
return new Promise((resolve) => {
|
|
925
|
+
const r = http.request(
|
|
926
|
+
{
|
|
927
|
+
host: "127.0.0.1",
|
|
928
|
+
port: chan.port,
|
|
929
|
+
path: "/mark-read",
|
|
930
|
+
method: "POST",
|
|
931
|
+
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) },
|
|
932
|
+
timeout: 5000,
|
|
933
|
+
agent: false,
|
|
934
|
+
},
|
|
935
|
+
(res) => {
|
|
936
|
+
let raw = "";
|
|
937
|
+
res.setEncoding("utf8");
|
|
938
|
+
res.on("data", (c) => (raw += c));
|
|
939
|
+
res.on("end", () => {
|
|
940
|
+
let body = {};
|
|
941
|
+
try {
|
|
942
|
+
body = JSON.parse(raw);
|
|
943
|
+
} catch {
|
|
944
|
+
/* non-JSON response */
|
|
945
|
+
}
|
|
946
|
+
if (res.statusCode === 200 && body.ok) {
|
|
947
|
+
resolve({
|
|
948
|
+
ok: true,
|
|
949
|
+
marked: body.marked ?? 0,
|
|
950
|
+
readCursor: body.readCursor ?? Number(cursor),
|
|
951
|
+
consumer: body.consumer || consumer || "default",
|
|
952
|
+
});
|
|
953
|
+
} else {
|
|
954
|
+
resolve({
|
|
955
|
+
ok: false,
|
|
956
|
+
status: res.statusCode,
|
|
957
|
+
error: body.error || `HTTP ${res.statusCode}`,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
},
|
|
962
|
+
);
|
|
963
|
+
r.on("timeout", () => {
|
|
964
|
+
r.destroy();
|
|
965
|
+
resolve({ ok: false, error: "timeout waiting for daemon response" });
|
|
966
|
+
});
|
|
967
|
+
r.on("error", (e) => resolve({ ok: false, error: e?.message || String(e) }));
|
|
968
|
+
r.end(payload);
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
784
972
|
/**
|
|
785
973
|
* Stop the running daemon for an account directory, preferring `POST /stop` so
|
|
786
974
|
* the daemon runs its own graceful shutdown handler (`markConnected`, receipt
|
|
@@ -985,7 +1173,8 @@ export async function spawnDaemonProcess({ accountDir, args = process.argv.slice
|
|
|
985
1173
|
const filteredArgs = args.filter((a) => a !== "-d" && a !== "--daemon");
|
|
986
1174
|
|
|
987
1175
|
const logFd = fs.openSync(logPath, "a", 0o600);
|
|
988
|
-
|
|
1176
|
+
// Never the secrets: --auth, --client-secret and --webhook-secret values are masked.
|
|
1177
|
+
const header = `\n--- [${new Date().toISOString()}] starting daemon: ${maskSecretArgs(filteredArgs).join(" ")} ---\n`;
|
|
989
1178
|
fs.writeSync(logFd, header);
|
|
990
1179
|
let startOffset = 0;
|
|
991
1180
|
try {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friend requests, as the CLI's `friend` commands and the MCP tool `zalo_friend`
|
|
3
|
+
* both read them.
|
|
4
|
+
*
|
|
5
|
+
* Zalo has no "incoming requests" list of its own. Zalo Web shows them inside
|
|
6
|
+
* the recommendations feed: an item whose `recommType` is 2
|
|
7
|
+
* (`FriendRecommendationsType.ReceivedFriendRequest`) is a request someone
|
|
8
|
+
* sent us, with their message in `recommInfo.message`; type 1 is an ordinary
|
|
9
|
+
* suggestion. `friend recommendations` already labels them `[request]`.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here talks to Zalo or writes zalo.db: these are pure reshapers of
|
|
12
|
+
* what the zca-js calls return, plus the error wording for the writes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** zca-js FriendRecommendationsType.ReceivedFriendRequest. */
|
|
16
|
+
export const RECEIVED_FRIEND_REQUEST = 2;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The numeric error code in a zca-js error message such as "... (225)".
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} msg
|
|
22
|
+
* @returns {number|null}
|
|
23
|
+
*/
|
|
24
|
+
export function extractErrorCode(msg) {
|
|
25
|
+
const match = String(msg).match(/\((-?\d+)\)/);
|
|
26
|
+
return match ? Number(match[1]) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a failed friend request means, in words that say what to do next.
|
|
31
|
+
*
|
|
32
|
+
* @param {number|string|null|undefined} code - Zalo's error code
|
|
33
|
+
* @param {string} userId
|
|
34
|
+
* @returns {string|undefined} undefined for a code with no specific wording
|
|
35
|
+
*/
|
|
36
|
+
export function describeFriendAddError(code, userId) {
|
|
37
|
+
const map = {
|
|
38
|
+
225: `Already friends with ${userId}. Use "friend list" to verify.`,
|
|
39
|
+
215: `User ${userId} may have blocked you or is unreachable.`,
|
|
40
|
+
222: `User ${userId} already sent you a friend request. Use "friend accept ${userId}" instead.`,
|
|
41
|
+
"-1": `Invalid userId "${userId}". Use "friend find <phone>" to get the correct userId first.`,
|
|
42
|
+
// Names from Zalo Web's own error table (TOO_MANY_REQUESTS, FULL_FRIEND_REQUESTING, FULL_FRIEND).
|
|
43
|
+
221: "Zalo says too many requests were sent: wait a while before sending more.",
|
|
44
|
+
223: "Zalo says the pending friend-request list is full (223).",
|
|
45
|
+
224: "Zalo says the friend list is full (224).",
|
|
46
|
+
};
|
|
47
|
+
return map[String(code)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An epoch timestamp as ISO text, whether Zalo gave seconds or milliseconds.
|
|
52
|
+
*
|
|
53
|
+
* Which unit a friend-request time carries has not been measured on a live
|
|
54
|
+
* account; anything below 1e11 cannot be a millisecond time of this century,
|
|
55
|
+
* so it is read as seconds.
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} ts
|
|
58
|
+
* @returns {string|null} null when it is not a usable time
|
|
59
|
+
*/
|
|
60
|
+
export function toIso(ts) {
|
|
61
|
+
const n = Number(ts);
|
|
62
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
63
|
+
return new Date(n < 1e11 ? n * 1000 : n).toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const name = (u) => u?.displayName || u?.zaloName || null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Requests we sent, from `getSentFriendRequest()` (an object keyed by userId).
|
|
70
|
+
*
|
|
71
|
+
* @param {Record<string, object>|null|undefined} res
|
|
72
|
+
* @returns {Array<{userId: string, displayName: string|null, message: string, requestedAt: string|null}>}
|
|
73
|
+
*/
|
|
74
|
+
export function sentRequestsFrom(res) {
|
|
75
|
+
return Object.entries(res && typeof res === "object" ? res : {}).map(([userId, r]) => ({
|
|
76
|
+
userId: String(r?.userId ?? userId),
|
|
77
|
+
displayName: name(r),
|
|
78
|
+
message: r?.fReqInfo?.message ?? "",
|
|
79
|
+
requestedAt: toIso(r?.fReqInfo?.time),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Requests sent to us, from `getFriendRecommendations()`: the items of type
|
|
85
|
+
* "received friend request", newest first. Suggestions are left out.
|
|
86
|
+
*
|
|
87
|
+
* @param {{recommItems?: Array<{dataInfo?: object}>}|null|undefined} res
|
|
88
|
+
* @returns {Array<{userId: string, displayName: string|null, message: string, receivedAt: string|null, seen: boolean}>}
|
|
89
|
+
*/
|
|
90
|
+
export function receivedRequestsFrom(res) {
|
|
91
|
+
const items = Array.isArray(res?.recommItems) ? res.recommItems : [];
|
|
92
|
+
return items
|
|
93
|
+
.map((i) => i?.dataInfo)
|
|
94
|
+
.filter((d) => d && Number(d.recommType) === RECEIVED_FRIEND_REQUEST && d.userId)
|
|
95
|
+
.map((d) => ({
|
|
96
|
+
userId: String(d.userId),
|
|
97
|
+
displayName: name(d),
|
|
98
|
+
message: d.recommInfo?.message ?? "",
|
|
99
|
+
receivedAt: toIso(d.recommTime),
|
|
100
|
+
seen: Boolean(d.isSeenFriendReq),
|
|
101
|
+
_t: Number(d.recommTime) || 0,
|
|
102
|
+
}))
|
|
103
|
+
.sort((a, b) => b._t - a._t)
|
|
104
|
+
.map(({ _t, ...rest }) => rest);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The relationship with one user, from `getFriendRequestStatus()`.
|
|
109
|
+
* Zalo answers 0 or 1 for each flag; these are booleans.
|
|
110
|
+
*
|
|
111
|
+
* @param {{is_friend?: number, is_requested?: number, is_requesting?: number}|null|undefined} res
|
|
112
|
+
* @param {string} userId
|
|
113
|
+
* @returns {{userId: string, is_friend: boolean, is_requested: boolean, is_requesting: boolean}}
|
|
114
|
+
*/
|
|
115
|
+
export function friendStatusFrom(res, userId) {
|
|
116
|
+
return {
|
|
117
|
+
userId: String(userId),
|
|
118
|
+
is_friend: Boolean(res?.is_friend),
|
|
119
|
+
is_requested: Boolean(res?.is_requested),
|
|
120
|
+
is_requesting: Boolean(res?.is_requesting),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Friends, most recently active first, as the CLI lists them, capped.
|
|
126
|
+
*
|
|
127
|
+
* @param {Array<object>|null|undefined} list - `getAllFriends()`
|
|
128
|
+
* @param {number} limit
|
|
129
|
+
* @returns {{total: number, friends: Array<{userId: string, displayName: string|null, lastActionTime: number|null}>}}
|
|
130
|
+
*/
|
|
131
|
+
export function friendsFrom(list, limit) {
|
|
132
|
+
const all = Array.isArray(list) ? list : [];
|
|
133
|
+
const sorted = [...all].sort((a, b) => (b?.lastActionTime || 0) - (a?.lastActionTime || 0));
|
|
134
|
+
return {
|
|
135
|
+
total: all.length,
|
|
136
|
+
friends: sorted.slice(0, limit).map((f) => ({
|
|
137
|
+
userId: String(f.userId),
|
|
138
|
+
displayName: name(f),
|
|
139
|
+
lastActionTime: f.lastActionTime || null,
|
|
140
|
+
})),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friend search, as `friend search` reads it.
|
|
3
|
+
*
|
|
4
|
+
* A friend can be known by three names: the profile name Zalo shows
|
|
5
|
+
* (`displayName`), the name they gave themselves (`zaloName`), and the alias
|
|
6
|
+
* the account owner set for them. The alias lives in a separate list
|
|
7
|
+
* (`getAliasList`), so `getAllFriends` alone never contains it, and a search
|
|
8
|
+
* on the first two names cannot find a friend the owner saved as "Arden AI".
|
|
9
|
+
* `zalo_search_threads` already honors aliases through the thread-name cache;
|
|
10
|
+
* this gives the CLI the same reach.
|
|
11
|
+
*
|
|
12
|
+
* Pure: nothing here talks to Zalo.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The friends whose display name, Zalo name or alias contains `query`
|
|
17
|
+
* (case-insensitive substring, as before).
|
|
18
|
+
*
|
|
19
|
+
* Each match is a copy of the friend with an `alias` field added when the
|
|
20
|
+
* owner has set one, so callers can show why it matched.
|
|
21
|
+
*
|
|
22
|
+
* @param {object[]} friends - `getAllFriends` entries
|
|
23
|
+
* @param {Map<string, string>} aliases - uid → the owner's alias for that friend
|
|
24
|
+
* @param {string} query
|
|
25
|
+
* @returns {object[]}
|
|
26
|
+
*/
|
|
27
|
+
export function searchFriends(friends, aliases, query) {
|
|
28
|
+
const q = String(query).toLowerCase();
|
|
29
|
+
const hit = (s) =>
|
|
30
|
+
String(s || "")
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.includes(q);
|
|
33
|
+
const matches = [];
|
|
34
|
+
for (const f of Array.isArray(friends) ? friends : []) {
|
|
35
|
+
const alias = aliases?.get(String(f.userId)) ?? "";
|
|
36
|
+
if (hit(f.displayName) || hit(f.zaloName) || hit(alias)) {
|
|
37
|
+
matches.push(alias ? { ...f, alias } : f);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return matches;
|
|
41
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group administration, as the MCP tool `zalo_group` reads Zalo's answers.
|
|
3
|
+
*
|
|
4
|
+
* Pure reshapers: nothing here talks to Zalo or writes zalo.db. Members are
|
|
5
|
+
* listed by `src/core/group-members.js`; this file covers what that one does
|
|
6
|
+
* not: a group's own details, and the outcome of creating a group or changing
|
|
7
|
+
* its membership.
|
|
8
|
+
*/
|
|
9
|
+
import { toIso } from "./friend-requests.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Zalo's error codes for leaving a group the account is not in. 167 ("Không thể rời nhóm") was measured on a live
|
|
13
|
+
* account by leaving a group a second time; 166 is the code zca-js documents on `leaveGroup` for the same situation
|
|
14
|
+
* and has not been seen.
|
|
15
|
+
*/
|
|
16
|
+
export const NOT_A_MEMBER_CODES = Object.freeze([166, 167]);
|
|
17
|
+
|
|
18
|
+
/** Zalo's code ("Nhóm không có thành viên.") for removing someone who is not in the group, measured live. */
|
|
19
|
+
export const REMOVE_NOT_IN_GROUP = 165;
|
|
20
|
+
|
|
21
|
+
/** `zalo_group` actions that cannot be undone from the tool, and so ask for `confirm: true`. */
|
|
22
|
+
export const CONFIRMED_ACTIONS = Object.freeze(["leave", "remove_member"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether `zalo_group` asks for `confirm: true` before an irreversible action.
|
|
26
|
+
*
|
|
27
|
+
* The check is a parameter, never a prompt: an automatic or headless caller
|
|
28
|
+
* either sends `confirm: true` or switches the check off, and nothing waits for
|
|
29
|
+
* a person. `ZALO_MCP_GROUP_CONFIRM` (`0`/`false`/`off`/`no` to disable,
|
|
30
|
+
* `1`/`true`/`on`/`yes` to enable) wins over `groupConfirm` in mcp-config.json,
|
|
31
|
+
* so a backend deployment can turn it off without editing the file. On by
|
|
32
|
+
* default; anything unrecognized leaves it on.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} [sources]
|
|
35
|
+
* @param {{groupConfirm?: unknown}|null} [sources.config] - the loaded MCP config
|
|
36
|
+
* @param {NodeJS.ProcessEnv} [sources.env] - environment, process.env by default
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function groupConfirmRequired({ config, env = process.env } = {}) {
|
|
40
|
+
const fromEnv = String(env?.ZALO_MCP_GROUP_CONFIRM ?? "")
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase();
|
|
43
|
+
if (["0", "false", "off", "no"].includes(fromEnv)) return false;
|
|
44
|
+
if (["1", "true", "on", "yes"].includes(fromEnv)) return true;
|
|
45
|
+
return config?.groupConfirm !== false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The details of one group out of `getGroupInfo`'s answer.
|
|
50
|
+
*
|
|
51
|
+
* Zalo answers `getGroupInfo([id])` with `gridInfoMap` keyed by group id; a
|
|
52
|
+
* group the account is not in is simply absent. The member list is left out
|
|
53
|
+
* on purpose (`zalo_get_group_members` owns it, with names); only its size is
|
|
54
|
+
* reported.
|
|
55
|
+
*
|
|
56
|
+
* @param {object|null|undefined} result - what `api.getGroupInfo([groupId])` returned
|
|
57
|
+
* @param {string} groupId
|
|
58
|
+
* @returns {object|null} null when Zalo's answer does not include the group
|
|
59
|
+
*/
|
|
60
|
+
export function groupInfoFrom(result, groupId) {
|
|
61
|
+
const g = result?.gridInfoMap?.[groupId];
|
|
62
|
+
if (!g) return null;
|
|
63
|
+
return {
|
|
64
|
+
groupId: String(g.groupId ?? groupId),
|
|
65
|
+
name: g.name ?? null,
|
|
66
|
+
description: g.desc || null,
|
|
67
|
+
type: g.type ?? null,
|
|
68
|
+
creatorId: g.creatorId ?? null,
|
|
69
|
+
adminIds: Array.isArray(g.adminIds) ? g.adminIds.map(String) : [],
|
|
70
|
+
totalMember: g.totalMember ?? null,
|
|
71
|
+
maxMember: g.maxMember ?? null,
|
|
72
|
+
createdAt: toIso(g.createdTime),
|
|
73
|
+
avatar: g.fullAvt || g.avt || null,
|
|
74
|
+
visibility: g.visibility ?? null,
|
|
75
|
+
pendingApproval: Array.isArray(g.pendingApprove?.uids) ? g.pendingApprove.uids.length : 0,
|
|
76
|
+
settings: g.setting ?? null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The outcome of `createGroup`.
|
|
82
|
+
*
|
|
83
|
+
* zca-js spells the success list `sucessMembers`; both spellings are read so a
|
|
84
|
+
* fixed typo upstream does not empty it. Zalo's list includes the account that
|
|
85
|
+
* created the group (measured live: asking for one member returns two ids), so
|
|
86
|
+
* `added` keeps only the members that were asked for.
|
|
87
|
+
*
|
|
88
|
+
* @param {object|null|undefined} result
|
|
89
|
+
* @param {string[]} requested - the member ids that were asked for
|
|
90
|
+
* @returns {{groupId: string|null, added: string[], failed: string[]}}
|
|
91
|
+
*/
|
|
92
|
+
export function createdGroupFrom(result, requested) {
|
|
93
|
+
const failed = (result?.errorMembers ?? []).map(String);
|
|
94
|
+
const given = result?.sucessMembers ?? result?.successMembers;
|
|
95
|
+
const added = Array.isArray(given)
|
|
96
|
+
? given.map(String).filter((id) => requested.includes(id))
|
|
97
|
+
: requested.filter((id) => !failed.includes(id));
|
|
98
|
+
return { groupId: result?.groupId ? String(result.groupId) : null, added, failed };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The outcome of `addUserToGroup` / `removeUserFromGroup`: which of the
|
|
103
|
+
* requested members Zalo refused, and why.
|
|
104
|
+
*
|
|
105
|
+
* `error_data` (add only) maps an error code to the ids it applies to.
|
|
106
|
+
*
|
|
107
|
+
* @param {object|null|undefined} result
|
|
108
|
+
* @param {string[]} requested - the member ids that were asked for
|
|
109
|
+
* @returns {{done: string[], failed: string[], errorData: Record<string, string[]>|null}}
|
|
110
|
+
*/
|
|
111
|
+
export function memberChangeFrom(result, requested) {
|
|
112
|
+
const failed = (result?.errorMembers ?? []).map(String);
|
|
113
|
+
const data = result?.error_data;
|
|
114
|
+
const errorData = data && typeof data === "object" && Object.keys(data).length > 0 ? data : null;
|
|
115
|
+
return { done: requested.filter((id) => !failed.includes(id)), failed, errorData };
|
|
116
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pending group invitation's details (`group invite-info`).
|
|
3
|
+
*
|
|
4
|
+
* zca-js's `getGroupInviteBoxInfo` reads `data.groupInfo.topic.params` without
|
|
5
|
+
* checking that the group has a topic. Most groups have no pinned message, so
|
|
6
|
+
* for them Zalo's answer carries no `topic` and zca-js throws "Cannot read
|
|
7
|
+
* properties of undefined (reading 'params')" after Zalo had answered fine.
|
|
8
|
+
* This makes the same request (GET {group}/api/group/inv-box/inv-info) on
|
|
9
|
+
* zca-js's own session envelope, and unpacks the topic's JSON-in-JSON params
|
|
10
|
+
* only when there is a topic.
|
|
11
|
+
*/
|
|
12
|
+
import { customCall, getWithParams } from "./zca-custom.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a topic's `params`, which Zalo sends as a JSON string whose `extra`
|
|
16
|
+
* field is itself a JSON string. Anything that does not parse is left as it came.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} topic
|
|
19
|
+
* @returns {object} the same topic, with `params` unpacked when it could be
|
|
20
|
+
*/
|
|
21
|
+
export function unpackTopic(topic) {
|
|
22
|
+
if (!topic || typeof topic.params !== "string") return topic;
|
|
23
|
+
try {
|
|
24
|
+
const params = JSON.parse(topic.params);
|
|
25
|
+
if (typeof params?.extra === "string") {
|
|
26
|
+
try {
|
|
27
|
+
params.extra = JSON.parse(params.extra);
|
|
28
|
+
} catch {
|
|
29
|
+
/* keep the string */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { ...topic, params };
|
|
33
|
+
} catch {
|
|
34
|
+
return topic;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} api - logged-in zca-js api
|
|
40
|
+
* @param {{groupId: string, mpage?: number, mcount?: number}} payload
|
|
41
|
+
* @returns {Promise<object>} Zalo's `data`: groupInfo, inviterInfo, grCreatorInfo, expiredTs, type
|
|
42
|
+
*/
|
|
43
|
+
export async function getInviteBoxInfo(api, { groupId, mpage = 1, mcount = 10 }) {
|
|
44
|
+
return customCall(api, async (_ctx, utils) => {
|
|
45
|
+
const data = await getWithParams(utils, `${api.zpwServiceMap.group[0]}/api/group/inv-box/inv-info`, {
|
|
46
|
+
grId: groupId,
|
|
47
|
+
mcount,
|
|
48
|
+
mpage,
|
|
49
|
+
});
|
|
50
|
+
if (data?.groupInfo?.topic) data.groupInfo.topic = unpackTopic(data.groupInfo.topic);
|
|
51
|
+
return data;
|
|
52
|
+
});
|
|
53
|
+
}
|