@ardennguyen/zalo-agent-cli 2.5.0 → 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.
Files changed (35) hide show
  1. package/README.md +41 -33
  2. package/node_modules/zca-js/dist/apis.js +0 -8
  3. package/node_modules/zca-js/dist/cjs/apis.cjs +0 -8
  4. package/package.json +3 -3
  5. package/patches/zca-js+2.2.0.patch +4 -236
  6. package/src/commands/bank.js +126 -0
  7. package/src/commands/catalog.js +14 -0
  8. package/src/commands/conv.js +19 -0
  9. package/src/commands/friend.js +66 -21
  10. package/src/commands/group.js +38 -2
  11. package/src/commands/listen.js +51 -2
  12. package/src/commands/mcp.js +232 -2
  13. package/src/commands/msg.js +60 -0
  14. package/src/commands/profile.js +39 -0
  15. package/src/commands/sync.js +1 -68
  16. package/src/core/bank-accounts.js +52 -0
  17. package/src/core/daemon-channel.js +191 -2
  18. package/src/core/friend-requests.js +142 -0
  19. package/src/core/friend-search.js +41 -0
  20. package/src/core/group-admin.js +116 -0
  21. package/src/core/group-invite.js +53 -0
  22. package/src/core/sync.js +4 -235
  23. package/src/index.js +2 -0
  24. package/src/mcp/mcp-config.js +1 -0
  25. package/src/mcp/mcp-tools.js +373 -13
  26. package/src/utils/cli-args.js +84 -0
  27. package/src/utils/secrets.js +69 -0
  28. package/node_modules/zca-js/dist/apis/deleteSnapshotMobileMsg.js +0 -18
  29. package/node_modules/zca-js/dist/apis/getCrossDB.js +0 -18
  30. package/node_modules/zca-js/dist/apis/pullMobileMsg.js +0 -22
  31. package/node_modules/zca-js/dist/apis/requestSyncMessage.js +0 -18
  32. package/node_modules/zca-js/dist/cjs/apis/deleteSnapshotMobileMsg.cjs +0 -22
  33. package/node_modules/zca-js/dist/cjs/apis/getCrossDB.cjs +0 -22
  34. package/node_modules/zca-js/dist/cjs/apis/pullMobileMsg.cjs +0 -26
  35. 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 (req.url !== "/send-attachments" && req.url !== "/stop" && !stage) return reply(404, { error: "not found" });
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
- const header = `\n--- [${new Date().toISOString()}] starting daemon: ${filteredArgs.join(" ")} ---\n`;
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
+ }