zalo-agent-cli 1.0.12 → 1.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy support, bank transfers, QR payments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Group commands — list, create, info, members, add/remove-member, rename, leave, join.
2
+ * Group commands — list, create, info, members, add/remove-member, rename, avatar,
3
+ * admin, owner, block/unblock, settings, leave, join.
3
4
  */
4
5
 
6
+ import { resolve } from "path";
5
7
  import { getApi } from "../core/zalo-client.js";
6
8
  import { success, error, info, output } from "../utils/output.js";
7
9
 
@@ -97,6 +99,78 @@ export function registerGroupCommands(program) {
97
99
  }
98
100
  });
99
101
 
102
+ group
103
+ .command("avatar <groupId> <imagePath>")
104
+ .description("Change group avatar")
105
+ .action(async (groupId, imagePath) => {
106
+ try {
107
+ const result = await getApi().changeGroupAvatar(resolve(imagePath), groupId);
108
+ output(result, program.opts().json, () => success("Group avatar changed"));
109
+ } catch (e) {
110
+ error(`Change avatar failed: ${e.message}`);
111
+ }
112
+ });
113
+
114
+ group
115
+ .command("add-admin <groupId> <userIds...>")
116
+ .description("Promote members to group admin (deputy)")
117
+ .action(async (groupId, userIds) => {
118
+ try {
119
+ const result = await getApi().addGroupDeputy(userIds, groupId);
120
+ output(result, program.opts().json, () => success("Admin(s) added"));
121
+ } catch (e) {
122
+ error(`Add admin failed: ${e.message}`);
123
+ }
124
+ });
125
+
126
+ group
127
+ .command("remove-admin <groupId> <userIds...>")
128
+ .description("Demote admins back to regular members")
129
+ .action(async (groupId, userIds) => {
130
+ try {
131
+ const result = await getApi().removeGroupDeputy(userIds, groupId);
132
+ output(result, program.opts().json, () => success("Admin(s) removed"));
133
+ } catch (e) {
134
+ error(`Remove admin failed: ${e.message}`);
135
+ }
136
+ });
137
+
138
+ group
139
+ .command("transfer-owner <groupId> <userId>")
140
+ .description("Transfer group ownership to another member")
141
+ .action(async (groupId, userId) => {
142
+ try {
143
+ const result = await getApi().changeGroupOwner(userId, groupId);
144
+ output(result, program.opts().json, () => success(`Ownership transferred to ${userId}`));
145
+ } catch (e) {
146
+ error(`Transfer owner failed: ${e.message}`);
147
+ }
148
+ });
149
+
150
+ group
151
+ .command("block-member <groupId> <userIds...>")
152
+ .description("Block members from rejoining the group")
153
+ .action(async (groupId, userIds) => {
154
+ try {
155
+ const result = await getApi().addGroupBlockedMember(userIds, groupId);
156
+ output(result, program.opts().json, () => success("Member(s) blocked"));
157
+ } catch (e) {
158
+ error(`Block member failed: ${e.message}`);
159
+ }
160
+ });
161
+
162
+ group
163
+ .command("unblock-member <groupId> <userIds...>")
164
+ .description("Unblock previously blocked members")
165
+ .action(async (groupId, userIds) => {
166
+ try {
167
+ const result = await getApi().removeGroupBlockedMember(userIds, groupId);
168
+ output(result, program.opts().json, () => success("Member(s) unblocked"));
169
+ } catch (e) {
170
+ error(`Unblock member failed: ${e.message}`);
171
+ }
172
+ });
173
+
100
174
  group
101
175
  .command("leave <groupId>")
102
176
  .description("Leave a group")
@@ -6,7 +6,11 @@
6
6
  import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
7
7
  import { success, error, info, warning } from "../utils/output.js";
8
8
 
9
- /** Friend event type enum readable label */
9
+ /** Thread types matching zca-js ThreadType enum */
10
+ const THREAD_USER = 0;
11
+ const THREAD_GROUP = 1;
12
+
13
+ /** Friend event type → readable label (matches zca-js FriendEventType enum order) */
10
14
  const FRIEND_EVENT_LABELS = {
11
15
  0: "friend_added",
12
16
  1: "friend_removed",
@@ -17,6 +21,10 @@ const FRIEND_EVENT_LABELS = {
17
21
  6: "blocked",
18
22
  7: "unblocked",
19
23
  };
24
+ const FRIEND_REQUEST_TYPE = 2;
25
+
26
+ /** Zalo close code for duplicate web session */
27
+ const CLOSE_DUPLICATE = 3000;
20
28
 
21
29
  export function registerListenCommand(program) {
22
30
  program
@@ -47,30 +55,36 @@ export function registerListenCommand(program) {
47
55
  return h > 0 ? `${h}h${m}m` : `${m}m${s % 60}s`;
48
56
  }
49
57
 
50
- /** Send event data to webhook */
51
- async function postWebhook(data) {
58
+ /** Fire-and-forget webhook POST never blocks event processing */
59
+ function postWebhook(data) {
52
60
  if (!opts.webhook) return;
53
- try {
54
- await fetch(opts.webhook, {
55
- method: "POST",
56
- headers: { "Content-Type": "application/json" },
57
- body: JSON.stringify(data),
58
- });
59
- } catch {
60
- // Silent don't block listener
61
+ fetch(opts.webhook, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(data),
65
+ }).catch(() => {});
66
+ }
67
+
68
+ /** Output event as JSON or human-readable, then post to webhook */
69
+ function emitEvent(data, humanMsg) {
70
+ eventCount++;
71
+ if (jsonMode) {
72
+ console.log(JSON.stringify(data));
73
+ } else {
74
+ info(humanMsg);
61
75
  }
76
+ postWebhook(data);
62
77
  }
63
78
 
64
- /** Attach all event handlers to current API */
65
- function attachHandlers(api) {
79
+ /** Attach ALL handlers (data + lifecycle) to current API listener */
80
+ function attachAllHandlers(api) {
66
81
  // --- Message events ---
67
82
  if (enabledEvents.has("message")) {
68
83
  api.listener.on("message", async (msg) => {
69
- if (opts.filter === "user" && msg.type !== 0) return;
70
- if (opts.filter === "group" && msg.type !== 1) return;
84
+ if (opts.filter === "user" && msg.type !== THREAD_USER) return;
85
+ if (opts.filter === "group" && msg.type !== THREAD_GROUP) return;
71
86
  if (!opts.self && msg.isSelf) return;
72
87
 
73
- eventCount++;
74
88
  const content = typeof msg.data.content === "string" ? msg.data.content : "[non-text]";
75
89
  const data = {
76
90
  event: "message",
@@ -81,24 +95,18 @@ export function registerListenCommand(program) {
81
95
  isSelf: msg.isSelf,
82
96
  content,
83
97
  };
84
-
85
- if (jsonMode) {
86
- console.log(JSON.stringify(data));
87
- } else {
88
- const dir = msg.isSelf ? "→" : "←";
89
- const typeLabel = msg.type === 0 ? "DM" : "GR";
90
- console.log(
91
- ` ${dir} [${typeLabel}] [${msg.threadId}] ${content} (msgId: ${msg.data.msgId})`,
92
- );
93
- }
94
- await postWebhook(data);
98
+ const dir = msg.isSelf ? "→" : "←";
99
+ const typeLabel = msg.type === THREAD_USER ? "DM" : "GR";
100
+ emitEvent(
101
+ data,
102
+ `${dir} [${typeLabel}] [${msg.threadId}] ${content} (msgId: ${msg.data.msgId})`,
103
+ );
95
104
  });
96
105
  }
97
106
 
98
107
  // --- Friend events ---
99
108
  if (enabledEvents.has("friend")) {
100
109
  api.listener.on("friend_event", async (event) => {
101
- eventCount++;
102
110
  const label = FRIEND_EVENT_LABELS[event.type] || "friend_unknown";
103
111
  const data = {
104
112
  event: label,
@@ -106,20 +114,14 @@ export function registerListenCommand(program) {
106
114
  isSelf: event.isSelf,
107
115
  data: event.data,
108
116
  };
109
-
110
- if (jsonMode) {
111
- console.log(JSON.stringify(data));
112
- } else {
113
- const msg =
114
- event.type === 2
115
- ? `Friend request from ${event.data.fromUid}: "${event.data.message || ""}"`
116
- : `${label} ${event.threadId}`;
117
- info(msg);
118
- }
119
- await postWebhook(data);
120
-
121
- // Auto-accept
122
- if (opts.autoAccept && event.type === 2 && !event.isSelf) {
117
+ const humanMsg =
118
+ event.type === FRIEND_REQUEST_TYPE
119
+ ? `Friend request from ${event.data.fromUid}: "${event.data.message || ""}"`
120
+ : `${label} ${event.threadId}`;
121
+ emitEvent(data, humanMsg);
122
+
123
+ // Auto-accept incoming friend requests
124
+ if (opts.autoAccept && event.type === FRIEND_REQUEST_TYPE && !event.isSelf) {
123
125
  try {
124
126
  await api.acceptFriendRequest(event.data.fromUid);
125
127
  success(`Auto-accepted friend request from ${event.data.fromUid}`);
@@ -132,104 +134,103 @@ export function registerListenCommand(program) {
132
134
 
133
135
  // --- Group events ---
134
136
  if (enabledEvents.has("group")) {
135
- api.listener.on("group_event", async (event) => {
136
- eventCount++;
137
- const data = {
138
- event: `group_${event.type}`,
139
- threadId: event.threadId,
140
- isSelf: event.isSelf,
141
- data: event.data,
142
- };
143
-
144
- if (jsonMode) {
145
- console.log(JSON.stringify(data));
146
- } else {
147
- info(`Group: ${event.type} — ${event.threadId}`);
148
- }
149
- await postWebhook(data);
137
+ api.listener.on("group_event", (event) => {
138
+ emitEvent(
139
+ {
140
+ event: `group_${event.type}`,
141
+ threadId: event.threadId,
142
+ isSelf: event.isSelf,
143
+ data: event.data,
144
+ },
145
+ `Group: ${event.type} — ${event.threadId}`,
146
+ );
150
147
  });
151
148
  }
152
149
 
153
150
  // --- Reaction events ---
154
151
  if (enabledEvents.has("reaction")) {
155
- api.listener.on("reaction", async (reaction) => {
152
+ api.listener.on("reaction", (reaction) => {
156
153
  if (!opts.self && reaction.isSelf) return;
157
- eventCount++;
158
- const data = {
159
- event: "reaction",
160
- threadId: reaction.threadId,
161
- isSelf: reaction.isSelf,
162
- isGroup: reaction.isGroup,
163
- data: reaction.data,
164
- };
165
-
166
- if (jsonMode) {
167
- console.log(JSON.stringify(data));
168
- } else {
169
- info(`Reaction in ${reaction.threadId}`);
170
- }
171
- await postWebhook(data);
154
+ emitEvent(
155
+ {
156
+ event: "reaction",
157
+ threadId: reaction.threadId,
158
+ isSelf: reaction.isSelf,
159
+ isGroup: reaction.isGroup,
160
+ data: reaction.data,
161
+ },
162
+ `Reaction in ${reaction.threadId}`,
163
+ );
172
164
  });
173
165
  }
174
- }
175
-
176
- /** Start listener with auto-reconnect */
177
- async function startListener() {
178
- try {
179
- const api = getApi();
180
166
 
181
- api.listener.on("connected", () => {
182
- if (reconnectCount > 0) {
183
- info(`Reconnected (#${reconnectCount}, uptime: ${uptime()}, events: ${eventCount})`);
184
- }
185
- });
167
+ // --- Lifecycle events (MUST be on same listener for reconnect to work) ---
168
+ api.listener.on("connected", () => {
169
+ if (reconnectCount > 0) {
170
+ info(`Reconnected (#${reconnectCount}, uptime: ${uptime()}, events: ${eventCount})`);
171
+ }
172
+ });
186
173
 
187
- api.listener.on("disconnected", (code, _reason) => {
188
- warning(`Disconnected (code: ${code}). Auto-retrying...`);
189
- });
174
+ api.listener.on("disconnected", (code, _reason) => {
175
+ warning(`Disconnected (code: ${code}). Auto-retrying...`);
176
+ });
190
177
 
191
- api.listener.on("closed", async (code, _reason) => {
192
- if (code === 3000) {
193
- error("Another Zalo Web session opened. Listener stopped.");
194
- process.exit(1);
195
- }
196
- reconnectCount++;
197
- warning(`Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()})`);
198
- await new Promise((r) => setTimeout(r, 5000));
178
+ api.listener.on("closed", async (code, _reason) => {
179
+ if (code === CLOSE_DUPLICATE) {
180
+ error("Another Zalo Web session opened. Listener stopped.");
181
+ process.exit(1);
182
+ }
183
+ reconnectCount++;
184
+ warning(`Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()})`);
185
+ await new Promise((r) => setTimeout(r, 5000));
186
+ try {
187
+ clearSession();
188
+ await autoLogin(jsonMode);
189
+ info("Re-login successful. Restarting listener...");
190
+ // Attach ALL handlers to the NEW api (including lifecycle)
191
+ const newApi = getApi();
192
+ attachAllHandlers(newApi);
193
+ newApi.listener.start({ retryOnClose: true });
194
+ } catch (e) {
195
+ error(`Re-login failed: ${e.message}. Retrying in 30s...`);
196
+ await new Promise((r) => setTimeout(r, 30000));
199
197
  try {
200
198
  clearSession();
201
199
  await autoLogin(jsonMode);
202
- info("Re-login successful. Restarting listener...");
203
- attachHandlers(getApi());
204
- getApi().listener.start({ retryOnClose: true });
205
- } catch (e) {
206
- error(`Re-login failed: ${e.message}. Retrying in 30s...`);
207
- await new Promise((r) => setTimeout(r, 30000));
208
- startListener();
200
+ const retryApi = getApi();
201
+ attachAllHandlers(retryApi);
202
+ retryApi.listener.start({ retryOnClose: true });
203
+ info("Re-login successful on retry.");
204
+ } catch (e2) {
205
+ error(`Re-login retry failed: ${e2.message}. Exiting.`);
206
+ process.exit(1);
209
207
  }
210
- });
211
-
212
- api.listener.on("error", (_err) => {
213
- // Log but don't crash
214
- });
208
+ }
209
+ });
215
210
 
216
- attachHandlers(api);
217
- api.listener.start({ retryOnClose: true });
218
-
219
- info("Listening for Zalo events... Press Ctrl+C to stop.");
220
- info(`Events: ${opts.events}`);
221
- info("Auto-reconnect enabled.");
222
- if (opts.filter !== "all") info(`Message filter: ${opts.filter}`);
223
- if (opts.webhook) info(`Webhook: ${opts.webhook}`);
224
- if (opts.autoAccept) info("Auto-accept friend requests: ON");
225
- } catch (e) {
226
- error(`Listen failed: ${e.message}`);
227
- process.exit(1);
228
- }
211
+ api.listener.on("error", (_err) => {
212
+ // WS errors are followed by close/disconnect — don't crash
213
+ });
229
214
  }
230
215
 
231
- await startListener();
216
+ // --- Initial start ---
217
+ try {
218
+ const api = getApi();
219
+ attachAllHandlers(api);
220
+ api.listener.start({ retryOnClose: true });
221
+
222
+ info("Listening for Zalo events... Press Ctrl+C to stop.");
223
+ info(`Events: ${opts.events}`);
224
+ info("Auto-reconnect enabled.");
225
+ if (opts.filter !== "all") info(`Message filter: ${opts.filter}`);
226
+ if (opts.webhook) info(`Webhook: ${opts.webhook}`);
227
+ if (opts.autoAccept) info("Auto-accept friend requests: ON");
228
+ } catch (e) {
229
+ error(`Listen failed: ${e.message}`);
230
+ process.exit(1);
231
+ }
232
232
 
233
+ // Keep alive until Ctrl+C
233
234
  await new Promise((resolve) => {
234
235
  process.on("SIGINT", () => {
235
236
  try {
@@ -211,10 +211,7 @@ export function registerMsgCommands(program) {
211
211
  "React to a message. Reaction codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
212
212
  )
213
213
  .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
214
- .option(
215
- "-c, --cli-msg-id <id>",
216
- "Client message ID (required for reaction to appear, get from msg listen --json)",
217
- )
214
+ .option("-c, --cli-msg-id <id>", "Client message ID (required for reaction to appear, get from listen --json)")
218
215
  .action(async (msgId, threadId, reaction, opts) => {
219
216
  try {
220
217
  // zca-js addReaction(icon, dest) — dest needs msgId + cliMsgId