zalo-agent-cli 1.0.11 → 1.0.13
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 +1 -1
- package/src/commands/friend.js +2 -138
- package/src/commands/listen.js +122 -121
- package/src/commands/msg.js +3 -139
package/package.json
CHANGED
package/src/commands/friend.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Friend commands — list, find, info, add, accept, remove, block, unblock, last-online, online.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { getApi
|
|
6
|
-
import { success, error, info,
|
|
5
|
+
import { getApi } from "../core/zalo-client.js";
|
|
6
|
+
import { success, error, info, output } from "../utils/output.js";
|
|
7
7
|
|
|
8
8
|
/** Extract numeric error code from zca-js error message string. */
|
|
9
9
|
function extractErrorCode(msg) {
|
|
@@ -166,140 +166,4 @@ export function registerFriendCommands(program) {
|
|
|
166
166
|
error(e.message);
|
|
167
167
|
}
|
|
168
168
|
});
|
|
169
|
-
|
|
170
|
-
/** Map FriendEventType enum to readable labels */
|
|
171
|
-
const FRIEND_EVENT_LABELS = {
|
|
172
|
-
0: "FRIEND_ADDED",
|
|
173
|
-
1: "FRIEND_REMOVED",
|
|
174
|
-
2: "FRIEND_REQUEST",
|
|
175
|
-
3: "UNDO_REQUEST",
|
|
176
|
-
4: "REJECT_REQUEST",
|
|
177
|
-
5: "SEEN_REQUEST",
|
|
178
|
-
6: "BLOCKED",
|
|
179
|
-
7: "UNBLOCKED",
|
|
180
|
-
8: "BLOCK_CALL",
|
|
181
|
-
9: "UNBLOCK_CALL",
|
|
182
|
-
10: "PIN_UNPIN",
|
|
183
|
-
11: "PIN_CREATE",
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
friend
|
|
187
|
-
.command("listen")
|
|
188
|
-
.description(
|
|
189
|
-
"Listen for friend events: new requests, accepts, removes, blocks. Use --json for machine parsing. Auto-reconnect enabled.",
|
|
190
|
-
)
|
|
191
|
-
.option(
|
|
192
|
-
"-f, --filter <type>",
|
|
193
|
-
"Filter events: request (new requests only), add (accepted only), all (default)",
|
|
194
|
-
"all",
|
|
195
|
-
)
|
|
196
|
-
.option("-w, --webhook <url>", "POST each event as JSON to this URL (for n8n, Make, etc.)")
|
|
197
|
-
.option("--auto-accept", "Automatically accept incoming friend requests")
|
|
198
|
-
.action(async (opts) => {
|
|
199
|
-
const jsonMode = program.opts().json;
|
|
200
|
-
const startTime = Date.now();
|
|
201
|
-
|
|
202
|
-
function uptime() {
|
|
203
|
-
const s = Math.floor((Date.now() - startTime) / 1000);
|
|
204
|
-
const h = Math.floor(s / 3600);
|
|
205
|
-
const m = Math.floor((s % 3600) / 60);
|
|
206
|
-
return h > 0 ? `${h}h${m}m` : `${m}m${s % 60}s`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function startListener() {
|
|
210
|
-
try {
|
|
211
|
-
const api = getApi();
|
|
212
|
-
|
|
213
|
-
api.listener.on("friend_event", async (event) => {
|
|
214
|
-
const label = FRIEND_EVENT_LABELS[event.type] || "UNKNOWN";
|
|
215
|
-
|
|
216
|
-
// Filter
|
|
217
|
-
if (opts.filter === "request" && event.type !== 2) return;
|
|
218
|
-
if (opts.filter === "add" && event.type !== 0) return;
|
|
219
|
-
|
|
220
|
-
const data = {
|
|
221
|
-
event: label,
|
|
222
|
-
type: event.type,
|
|
223
|
-
threadId: event.threadId,
|
|
224
|
-
isSelf: event.isSelf,
|
|
225
|
-
data: event.data,
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
if (jsonMode) {
|
|
229
|
-
console.log(JSON.stringify(data));
|
|
230
|
-
} else {
|
|
231
|
-
const msg =
|
|
232
|
-
event.type === 2
|
|
233
|
-
? `Friend request from ${event.data.fromUid}: "${event.data.message || ""}"`
|
|
234
|
-
: `${label} — ${event.threadId}`;
|
|
235
|
-
info(msg);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Webhook
|
|
239
|
-
if (opts.webhook) {
|
|
240
|
-
try {
|
|
241
|
-
await fetch(opts.webhook, {
|
|
242
|
-
method: "POST",
|
|
243
|
-
headers: { "Content-Type": "application/json" },
|
|
244
|
-
body: JSON.stringify(data),
|
|
245
|
-
});
|
|
246
|
-
} catch {
|
|
247
|
-
// Silent
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Auto-accept friend requests
|
|
252
|
-
if (opts.autoAccept && event.type === 2 && !event.isSelf) {
|
|
253
|
-
try {
|
|
254
|
-
await api.acceptFriendRequest(event.data.fromUid);
|
|
255
|
-
success(`Auto-accepted friend request from ${event.data.fromUid}`);
|
|
256
|
-
} catch (e) {
|
|
257
|
-
error(`Auto-accept failed: ${e.message}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
api.listener.on("closed", async (code, _reason) => {
|
|
263
|
-
if (code === 3000) {
|
|
264
|
-
error("Another Zalo Web session opened. Listener stopped.");
|
|
265
|
-
process.exit(1);
|
|
266
|
-
}
|
|
267
|
-
warning(`Connection closed (code: ${code}). Re-login in 5s...`);
|
|
268
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
269
|
-
try {
|
|
270
|
-
clearSession();
|
|
271
|
-
await autoLogin(jsonMode);
|
|
272
|
-
startListener();
|
|
273
|
-
} catch (e) {
|
|
274
|
-
error(`Re-login failed: ${e.message}. Retrying in 30s...`);
|
|
275
|
-
await new Promise((r) => setTimeout(r, 30000));
|
|
276
|
-
startListener();
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
api.listener.start({ retryOnClose: true });
|
|
281
|
-
|
|
282
|
-
info("Listening for friend events... Press Ctrl+C to stop.");
|
|
283
|
-
info("Auto-reconnect enabled.");
|
|
284
|
-
if (opts.filter !== "all") info(`Filter: ${opts.filter} events only`);
|
|
285
|
-
if (opts.webhook) info(`Webhook: POST to ${opts.webhook}`);
|
|
286
|
-
if (opts.autoAccept) info("Auto-accept: ON — will accept all incoming requests");
|
|
287
|
-
} catch (e) {
|
|
288
|
-
error(`Listen failed: ${e.message}`);
|
|
289
|
-
process.exit(1);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
await startListener();
|
|
294
|
-
|
|
295
|
-
await new Promise((resolve) => {
|
|
296
|
-
process.on("SIGINT", () => {
|
|
297
|
-
try {
|
|
298
|
-
getApi().listener.stop();
|
|
299
|
-
} catch {}
|
|
300
|
-
info(`Listener stopped. Uptime: ${uptime()}`);
|
|
301
|
-
resolve();
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
169
|
}
|
package/src/commands/listen.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
51
|
-
|
|
58
|
+
/** Fire-and-forget webhook POST — never blocks event processing */
|
|
59
|
+
function postWebhook(data) {
|
|
52
60
|
if (!opts.webhook) return;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
65
|
-
function
|
|
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 !==
|
|
70
|
-
if (opts.filter === "group" && msg.type !==
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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",
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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",
|
|
152
|
+
api.listener.on("reaction", (reaction) => {
|
|
156
153
|
if (!opts.self && reaction.isSelf) return;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
174
|
+
api.listener.on("disconnected", (code, _reason) => {
|
|
175
|
+
warning(`Disconnected (code: ${code}). Auto-retrying...`);
|
|
176
|
+
});
|
|
190
177
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 {
|
package/src/commands/msg.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { resolve } from "path";
|
|
7
|
-
import { getApi
|
|
8
|
-
import { success, error, info,
|
|
7
|
+
import { getApi } from "../core/zalo-client.js";
|
|
8
|
+
import { success, error, info, 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");
|
|
@@ -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
|
|
@@ -230,139 +227,6 @@ export function registerMsgCommands(program) {
|
|
|
230
227
|
}
|
|
231
228
|
});
|
|
232
229
|
|
|
233
|
-
msg.command("listen")
|
|
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;
|
|
259
|
-
|
|
260
|
-
const content = typeof msg.data.content === "string" ? msg.data.content : "[non-text]";
|
|
261
|
-
const data = {
|
|
262
|
-
msgId: msg.data.msgId,
|
|
263
|
-
cliMsgId: msg.data.cliMsgId,
|
|
264
|
-
threadId: msg.threadId,
|
|
265
|
-
type: msg.type,
|
|
266
|
-
isSelf: msg.isSelf,
|
|
267
|
-
content,
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
if (jsonMode) {
|
|
271
|
-
console.log(JSON.stringify(data));
|
|
272
|
-
} else {
|
|
273
|
-
const dir = msg.isSelf ? "→" : "←";
|
|
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
|
-
}
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Start listener with auto-reconnect and re-login */
|
|
293
|
-
async function startListener() {
|
|
294
|
-
try {
|
|
295
|
-
const api = getApi();
|
|
296
|
-
|
|
297
|
-
api.listener.on("connected", () => {
|
|
298
|
-
if (reconnectCount > 0) {
|
|
299
|
-
info(`Reconnected (attempt #${reconnectCount}, uptime: ${uptime()})`);
|
|
300
|
-
}
|
|
301
|
-
});
|
|
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
|
-
}
|
|
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
|
-
});
|
|
364
|
-
});
|
|
365
|
-
|
|
366
230
|
msg.command("delete <msgId> <threadId>")
|
|
367
231
|
.description("Delete a message you sent")
|
|
368
232
|
.option("-t, --type <n>", "Thread type: 0=User, 1=Group", "0")
|