zalo-agent-cli 1.0.7 → 1.0.9

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/msg.js +124 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zalo-agent-cli",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "CLI tool for Zalo automation — multi-account, proxy support, bank transfers, QR payments",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,8 +4,8 @@
4
4
  */
5
5
 
6
6
  import { resolve } from "path";
7
- import { getApi } from "../core/zalo-client.js";
8
- import { success, error, info, output } from "../utils/output.js";
7
+ import { getApi, autoLogin, clearSession } from "../core/zalo-client.js";
8
+ import { success, error, info, warning, output } from "../utils/output.js";
9
9
 
10
10
  export function registerMsgCommands(program) {
11
11
  const msg = program.command("msg").description("Send and manage messages");
@@ -13,7 +13,10 @@ export function registerMsgCommands(program) {
13
13
  msg.command("send <threadId> <message>")
14
14
  .description("Send a text message")
15
15
  .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
16
- .option("--react <icon>", "Auto-react to the sent message (e.g. :> for haha)")
16
+ .option(
17
+ "--react <icon>",
18
+ "Auto-react to sent message. Codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
19
+ )
17
20
  .action(async (threadId, message, opts) => {
18
21
  try {
19
22
  // Capture clientId before send — zca-js uses Date.now() internally
@@ -204,9 +207,14 @@ export function registerMsgCommands(program) {
204
207
  });
205
208
 
206
209
  msg.command("react <msgId> <threadId> <reaction>")
207
- .description("React to a message with an emoji")
210
+ .description(
211
+ "React to a message. Reaction codes: :> (haha), /-heart (heart), /-strong (like), :o (wow), :-(( (cry), :-h (angry)",
212
+ )
208
213
  .option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
209
- .option("-c, --cli-msg-id <id>", "Client message ID (defaults to msgId)")
214
+ .option(
215
+ "-c, --cli-msg-id <id>",
216
+ "Client message ID (required for reaction to appear, get from msg listen --json)",
217
+ )
210
218
  .action(async (msgId, threadId, reaction, opts) => {
211
219
  try {
212
220
  // zca-js addReaction(icon, dest) — dest needs msgId + cliMsgId
@@ -223,14 +231,32 @@ export function registerMsgCommands(program) {
223
231
  });
224
232
 
225
233
  msg.command("listen")
226
- .description("Listen for incoming messages (Ctrl+C to stop)")
227
- .action(async () => {
228
- try {
229
- const api = getApi();
230
- info("Listening for messages... Press Ctrl+C to stop.");
231
- info("Note: Only one web listener per account. Browser Zalo will disconnect.");
234
+ .description(
235
+ "Listen for incoming messages in real-time via WebSocket. Outputs msgId + cliMsgId needed for react. Use --json for machine parsing.",
236
+ )
237
+ .option("-f, --filter <type>", "Filter messages: user (DM only), group (groups only), all (default)", "all")
238
+ .option("-w, --webhook <url>", "POST each message as JSON to this URL (for n8n, Make, etc.)")
239
+ .option("--no-self", "Exclude messages sent by this account")
240
+ .action(async (opts) => {
241
+ const jsonMode = program.opts().json;
242
+ const startTime = Date.now();
243
+ let reconnectCount = 0;
244
+
245
+ /** Format uptime as human-readable string */
246
+ function uptime() {
247
+ const s = Math.floor((Date.now() - startTime) / 1000);
248
+ const h = Math.floor(s / 3600);
249
+ const m = Math.floor((s % 3600) / 60);
250
+ return h > 0 ? `${h}h${m}m` : `${m}m${s % 60}s`;
251
+ }
252
+
253
+ /** Attach message handler to current API listener */
254
+ function attachMessageHandler(api) {
255
+ api.listener.on("message", async (msg) => {
256
+ if (opts.filter === "user" && msg.type !== 0) return;
257
+ if (opts.filter === "group" && msg.type !== 1) return;
258
+ if (!opts.self && msg.isSelf) return;
232
259
 
233
- api.listener.on("message", (msg) => {
234
260
  const content = typeof msg.data.content === "string" ? msg.data.content : "[non-text]";
235
261
  const data = {
236
262
  msgId: msg.data.msgId,
@@ -240,27 +266,101 @@ export function registerMsgCommands(program) {
240
266
  isSelf: msg.isSelf,
241
267
  content,
242
268
  };
243
- if (program.opts().json) {
269
+
270
+ if (jsonMode) {
244
271
  console.log(JSON.stringify(data));
245
272
  } else {
246
273
  const dir = msg.isSelf ? "→" : "←";
247
- console.log(` ${dir} [${msg.threadId}] ${content} (msgId: ${msg.data.msgId})`);
274
+ const typeLabel = msg.type === 0 ? "DM" : "GR";
275
+ console.log(` ${dir} [${typeLabel}] [${msg.threadId}] ${content} (msgId: ${msg.data.msgId})`);
276
+ }
277
+
278
+ if (opts.webhook) {
279
+ try {
280
+ await fetch(opts.webhook, {
281
+ method: "POST",
282
+ headers: { "Content-Type": "application/json" },
283
+ body: JSON.stringify(data),
284
+ });
285
+ } catch {
286
+ // Silent webhook failure
287
+ }
248
288
  }
249
289
  });
290
+ }
250
291
 
251
- api.listener.start();
292
+ /** Start listener with auto-reconnect and re-login */
293
+ async function startListener() {
294
+ try {
295
+ const api = getApi();
252
296
 
253
- // Keep alive until Ctrl+C
254
- await new Promise((resolve) => {
255
- process.on("SIGINT", () => {
256
- api.listener.stop();
257
- info("Listener stopped.");
258
- resolve();
297
+ api.listener.on("connected", () => {
298
+ if (reconnectCount > 0) {
299
+ info(`Reconnected (attempt #${reconnectCount}, uptime: ${uptime()})`);
300
+ }
259
301
  });
260
- });
261
- } catch (e) {
262
- error(`Listen failed: ${e.message}`);
302
+
303
+ api.listener.on("disconnected", (code, _reason) => {
304
+ warning(`Disconnected (code: ${code}). Auto-retrying...`);
305
+ });
306
+
307
+ // "closed" = permanent close (retries exhausted or duplicate connection)
308
+ api.listener.on("closed", async (code, _reason) => {
309
+ if (code === 3000) {
310
+ // Duplicate connection — someone opened Zalo Web
311
+ error("Another Zalo Web session opened. Listener stopped.");
312
+ error("Close the other session and restart listener.");
313
+ process.exit(1);
314
+ }
315
+
316
+ reconnectCount++;
317
+ warning(`Connection closed (code: ${code}). Re-login in 5s... (uptime: ${uptime()})`);
318
+
319
+ // Re-login and restart
320
+ await new Promise((r) => setTimeout(r, 5000));
321
+ try {
322
+ clearSession();
323
+ await autoLogin(jsonMode);
324
+ info(`Re-login successful. Restarting listener...`);
325
+ attachMessageHandler(getApi());
326
+ getApi().listener.start({ retryOnClose: true });
327
+ } catch (e) {
328
+ error(`Re-login failed: ${e.message}. Retrying in 30s...`);
329
+ await new Promise((r) => setTimeout(r, 30000));
330
+ startListener();
331
+ }
332
+ });
333
+
334
+ api.listener.on("error", (_err) => {
335
+ // Log but don't crash — WS errors are usually followed by close/disconnect
336
+ });
337
+
338
+ attachMessageHandler(api);
339
+ // retryOnClose: true = zca-js auto-retries on temporary disconnects
340
+ api.listener.start({ retryOnClose: true });
341
+
342
+ info("Listening for messages... Press Ctrl+C to stop.");
343
+ info("Auto-reconnect enabled. Will survive network drops.");
344
+ if (opts.filter !== "all") info(`Filter: ${opts.filter} messages only`);
345
+ if (opts.webhook) info(`Webhook: POST to ${opts.webhook}`);
346
+ } catch (e) {
347
+ error(`Listen failed: ${e.message}`);
348
+ process.exit(1);
349
+ }
263
350
  }
351
+
352
+ await startListener();
353
+
354
+ // Keep alive until Ctrl+C
355
+ await new Promise((resolve) => {
356
+ process.on("SIGINT", () => {
357
+ try {
358
+ getApi().listener.stop();
359
+ } catch {}
360
+ info(`Listener stopped. Uptime: ${uptime()}, reconnects: ${reconnectCount}`);
361
+ resolve();
362
+ });
363
+ });
264
364
  });
265
365
 
266
366
  msg.command("delete <msgId> <threadId>")