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.
@@ -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
+ }