zalo-agent-cli 1.0.30 → 1.1.0
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 +122 -676
- package/package.json +6 -3
- package/src/commands/auto-reply.js +83 -0
- package/src/commands/catalog.js +143 -0
- package/src/commands/conv.js +86 -0
- package/src/commands/label.js +39 -0
- package/src/commands/msg.js +47 -3
- package/src/commands/oa-init.js +503 -0
- package/src/commands/oa-listen.js +215 -0
- package/src/commands/oa.js +657 -0
- package/src/commands/profile.js +64 -0
- package/src/commands/quick-msg.js +55 -0
- package/src/core/oa-client.js +391 -0
- package/src/index.js +15 -4
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OA Webhook listener — starts a local HTTP server to receive Zalo OA events.
|
|
3
|
+
* Supports MAC verification, event filtering, and JSON output for piping.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* zalo-agent oa listen --port 3000 --secret <oa-secret-key>
|
|
7
|
+
* zalo-agent oa listen --events follow,user_send_text
|
|
8
|
+
*
|
|
9
|
+
* Then configure your webhook URL at developers.zalo.me to point to:
|
|
10
|
+
* https://your-domain:3000/webhook
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { success, error, info, warning, output } from "../utils/output.js";
|
|
16
|
+
|
|
17
|
+
/** All supported OA webhook event types. */
|
|
18
|
+
const ALL_EVENTS = [
|
|
19
|
+
"follow",
|
|
20
|
+
"unfollow",
|
|
21
|
+
"user_send_text",
|
|
22
|
+
"user_send_image",
|
|
23
|
+
"user_send_file",
|
|
24
|
+
"user_send_location",
|
|
25
|
+
"user_send_sticker",
|
|
26
|
+
"user_send_gif",
|
|
27
|
+
"user_click_button",
|
|
28
|
+
"user_click_link",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** Verify MAC signature from Zalo OA using timing-safe comparison. */
|
|
32
|
+
function verifyMac(body, mac, secretKey) {
|
|
33
|
+
const calculated = createHmac("sha256", secretKey).update(body).digest("hex");
|
|
34
|
+
if (mac.length !== calculated.length) return false;
|
|
35
|
+
return timingSafeEqual(Buffer.from(mac), Buffer.from(calculated));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerOAListenCommand(oaCommand, program) {
|
|
39
|
+
oaCommand
|
|
40
|
+
.command("listen")
|
|
41
|
+
.description("Start webhook listener for OA events (follow, messages, clicks, etc.)")
|
|
42
|
+
.option("-p, --port <port>", "Listen port", "3000")
|
|
43
|
+
.option("-s, --secret <key>", "OA Secret Key for MAC verification (from developers.zalo.me)")
|
|
44
|
+
.option("--no-verify", "Skip MAC verification (not recommended)")
|
|
45
|
+
.option("-e, --events <list>", `Comma-separated event filter: ${ALL_EVENTS.join(",")}`, "all")
|
|
46
|
+
.option("--path <path>", "Webhook URL path", "/webhook")
|
|
47
|
+
.option("--verify-domain <code>", "Zalo domain verification code (serves /zalo_verifier<code>.html)")
|
|
48
|
+
.action(async (opts) => {
|
|
49
|
+
const json = () => program.opts().json;
|
|
50
|
+
const port = Number(opts.port);
|
|
51
|
+
const eventFilter = opts.events === "all" ? null : opts.events.split(",").map((e) => e.trim());
|
|
52
|
+
|
|
53
|
+
if (!opts.secret && opts.verify) {
|
|
54
|
+
warning("No --secret provided. MAC verification disabled. Use --secret for security.");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const server = createServer((req, res) => {
|
|
58
|
+
// GET — webhook verification (hub.challenge)
|
|
59
|
+
if (req.method === "GET" && req.url?.startsWith(opts.path)) {
|
|
60
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
61
|
+
const challenge = url.searchParams.get("hub.challenge");
|
|
62
|
+
if (challenge) {
|
|
63
|
+
if (!json()) info(`Webhook verified (challenge: ${challenge})`);
|
|
64
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
65
|
+
res.end(challenge);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// POST — receive events
|
|
71
|
+
if (req.method === "POST" && req.url?.startsWith(opts.path)) {
|
|
72
|
+
let body = "";
|
|
73
|
+
let bodySize = 0;
|
|
74
|
+
const MAX_BODY = 1024 * 1024; // 1MB limit
|
|
75
|
+
req.on("data", (chunk) => {
|
|
76
|
+
bodySize += chunk.length;
|
|
77
|
+
if (bodySize > MAX_BODY) {
|
|
78
|
+
req.destroy();
|
|
79
|
+
res.writeHead(413);
|
|
80
|
+
res.end('{"error":"Payload too large"}');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
body += chunk;
|
|
84
|
+
});
|
|
85
|
+
req.on("end", () => {
|
|
86
|
+
try {
|
|
87
|
+
// MAC verification
|
|
88
|
+
if (opts.verify && opts.secret) {
|
|
89
|
+
// Zalo sends MAC as "mac=<hex>" in X-Zevent-Signature header or mac header
|
|
90
|
+
const sigHeader = req.headers["x-zevent-signature"] || req.headers.mac || "";
|
|
91
|
+
const mac = sigHeader.startsWith("mac=") ? sigHeader.slice(4) : sigHeader;
|
|
92
|
+
if (!mac || !verifyMac(body, mac, opts.secret)) {
|
|
93
|
+
const errData = {
|
|
94
|
+
event_type: "error",
|
|
95
|
+
error: "Invalid or missing MAC",
|
|
96
|
+
timestamp: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
output(errData, json(), () => warning("Rejected: invalid MAC"));
|
|
99
|
+
res.writeHead(401);
|
|
100
|
+
res.end('{"error":"Invalid MAC"}');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const event = JSON.parse(body);
|
|
106
|
+
const eventName = event.event_name || event.event_type || "unknown";
|
|
107
|
+
|
|
108
|
+
// Filter events
|
|
109
|
+
if (eventFilter && !eventFilter.includes(eventName)) {
|
|
110
|
+
res.writeHead(200);
|
|
111
|
+
res.end('{"status":"filtered"}');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Enrich with timestamp
|
|
116
|
+
event._received_at = new Date().toISOString();
|
|
117
|
+
|
|
118
|
+
// Output event
|
|
119
|
+
output(event, json(), () => {
|
|
120
|
+
const sender = event.sender?.id || event.user_id || "N/A";
|
|
121
|
+
const msg = event.message?.text || "";
|
|
122
|
+
switch (eventName) {
|
|
123
|
+
case "follow":
|
|
124
|
+
success(`[follow] User ${sender} followed OA`);
|
|
125
|
+
break;
|
|
126
|
+
case "unfollow":
|
|
127
|
+
warning(`[unfollow] User ${sender} unfollowed OA`);
|
|
128
|
+
break;
|
|
129
|
+
case "user_send_text":
|
|
130
|
+
info(`[text] ${sender}: ${msg}`);
|
|
131
|
+
break;
|
|
132
|
+
case "user_send_image":
|
|
133
|
+
info(`[image] ${sender} sent an image`);
|
|
134
|
+
break;
|
|
135
|
+
case "user_send_file":
|
|
136
|
+
info(`[file] ${sender} sent a file`);
|
|
137
|
+
break;
|
|
138
|
+
case "user_send_location":
|
|
139
|
+
info(`[location] ${sender} sent location`);
|
|
140
|
+
break;
|
|
141
|
+
case "user_send_sticker":
|
|
142
|
+
info(`[sticker] ${sender} sent a sticker`);
|
|
143
|
+
break;
|
|
144
|
+
case "user_send_gif":
|
|
145
|
+
info(`[gif] ${sender} sent a GIF`);
|
|
146
|
+
break;
|
|
147
|
+
case "user_click_button":
|
|
148
|
+
info(`[click] ${sender} clicked a button`);
|
|
149
|
+
break;
|
|
150
|
+
case "user_click_link":
|
|
151
|
+
info(`[link] ${sender} clicked a link`);
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
info(`[${eventName}] ${JSON.stringify(event).slice(0, 120)}`);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
159
|
+
res.end('{"status":"ok"}');
|
|
160
|
+
} catch (e) {
|
|
161
|
+
error(`Parse error: ${e.message}`);
|
|
162
|
+
res.writeHead(400);
|
|
163
|
+
res.end('{"error":"Invalid JSON"}');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Zalo domain verification file
|
|
170
|
+
if (opts.verifyDomain && req.url?.includes("zalo_verifier")) {
|
|
171
|
+
const html = `<html><head><meta name="zalo-platform-site-verification" content="${opts.verifyDomain}" /></head><body>zalo verification</body></html>`;
|
|
172
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
173
|
+
res.end(html);
|
|
174
|
+
if (!json()) info(`Served domain verification for: ${req.url}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Root page with meta tag (for meta-based verification)
|
|
179
|
+
if (req.url === "/" && opts.verifyDomain) {
|
|
180
|
+
const html = `<html><head><meta name="zalo-platform-site-verification" content="${opts.verifyDomain}" /></head><body>Zalo OA Webhook</body></html>`;
|
|
181
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
182
|
+
res.end(html);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Health check / other routes
|
|
187
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
188
|
+
res.end(JSON.stringify({ status: "ok", webhook: opts.path }));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
server.listen(port, () => {
|
|
192
|
+
if (!json()) {
|
|
193
|
+
success(`OA webhook listener started on port ${port}`);
|
|
194
|
+
info(`Webhook URL: http://localhost:${port}${opts.path}`);
|
|
195
|
+
info(`Events: ${eventFilter ? eventFilter.join(", ") : "all"}`);
|
|
196
|
+
info(`MAC verify: ${opts.verify && opts.secret ? "enabled" : "disabled"}`);
|
|
197
|
+
console.log();
|
|
198
|
+
info("Configure this URL at developers.zalo.me → Webhook settings");
|
|
199
|
+
info("Press Ctrl+C to stop\n");
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Graceful shutdown
|
|
204
|
+
const shutdown = () => {
|
|
205
|
+
if (!json()) info("\nShutting down listener...");
|
|
206
|
+
server.close();
|
|
207
|
+
process.exit(0);
|
|
208
|
+
};
|
|
209
|
+
process.on("SIGINT", shutdown);
|
|
210
|
+
process.on("SIGTERM", shutdown);
|
|
211
|
+
|
|
212
|
+
// Keep process alive
|
|
213
|
+
await new Promise(() => {});
|
|
214
|
+
});
|
|
215
|
+
}
|