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,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OA setup wizard — works in both interactive (human) and non-interactive (agent) mode.
|
|
3
|
+
*
|
|
4
|
+
* Interactive (default):
|
|
5
|
+
* zalo-agent oa init
|
|
6
|
+
*
|
|
7
|
+
* Non-interactive (for coding agents like Claude Code, Codex):
|
|
8
|
+
* zalo-agent oa init --app-id <id> --secret <key> --tunnel ngrok --port 3000
|
|
9
|
+
* zalo-agent oa init --app-id <id> --secret <key> --webhook-url https://your-server.com/webhook
|
|
10
|
+
* zalo-agent oa init --app-id <id> --secret <key> --skip-webhook
|
|
11
|
+
* zalo-agent --json oa init --app-id <id> --secret <key> --skip-webhook
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
import { exec } from "node:child_process";
|
|
17
|
+
import { saveOACreds, loadOACreds, getOAuthUrl, exchangeCode, getOAProfile } from "../core/oa-client.js";
|
|
18
|
+
import { success, error, info, warning, output } from "../utils/output.js";
|
|
19
|
+
|
|
20
|
+
/** Prompt user for input. In agent mode, returns the provided default. */
|
|
21
|
+
function ask(question, agentMode, defaultValue = "") {
|
|
22
|
+
if (agentMode) return Promise.resolve(defaultValue);
|
|
23
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
rl.question(question, (answer) => {
|
|
26
|
+
rl.close();
|
|
27
|
+
resolve(answer.trim() || defaultValue);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Wait for Enter in interactive mode, skip in agent mode. */
|
|
33
|
+
async function waitForEnter(msg, agentMode) {
|
|
34
|
+
if (agentMode) return;
|
|
35
|
+
await ask(`\n ${msg}`, false);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Open URL in default browser. */
|
|
39
|
+
function openBrowser(url) {
|
|
40
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
41
|
+
exec(`${cmd} "${url}"`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Check if a CLI tool exists. */
|
|
45
|
+
function hasCli(name) {
|
|
46
|
+
return new Promise((resolve) => exec(`which ${name}`, (err) => resolve(!err)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Run OAuth login flow — starts callback server and waits for browser redirect. */
|
|
50
|
+
async function runOAuthLogin(appId, secretKey, oaId, callbackPort = 3456) {
|
|
51
|
+
const redirectUri = `http://localhost:${callbackPort}/callback`;
|
|
52
|
+
const authUrl = getOAuthUrl(appId, redirectUri);
|
|
53
|
+
openBrowser(authUrl);
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const server = createServer(async (req, res) => {
|
|
57
|
+
if (!req.url?.startsWith("/callback")) {
|
|
58
|
+
res.writeHead(404);
|
|
59
|
+
res.end();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const url = new URL(req.url, `http://localhost:${callbackPort}`);
|
|
63
|
+
const code = url.searchParams.get("code");
|
|
64
|
+
const returnedOaId = url.searchParams.get("oa_id");
|
|
65
|
+
|
|
66
|
+
if (!code) {
|
|
67
|
+
const msg = url.searchParams.get("error_description") || "No authorization code";
|
|
68
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
69
|
+
res.end(`<h2>Login failed</h2><p>${msg}</p>`);
|
|
70
|
+
server.close();
|
|
71
|
+
reject(new Error(msg));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const data = await exchangeCode(code, appId, secretKey, redirectUri);
|
|
77
|
+
if (data.error) {
|
|
78
|
+
throw new Error(`Token error ${data.error}: ${data.error_description || data.error_name}`);
|
|
79
|
+
}
|
|
80
|
+
saveOACreds(
|
|
81
|
+
{
|
|
82
|
+
accessToken: data.access_token,
|
|
83
|
+
refreshToken: data.refresh_token,
|
|
84
|
+
expiresIn: data.expires_in,
|
|
85
|
+
oaId: returnedOaId || oaId,
|
|
86
|
+
},
|
|
87
|
+
oaId,
|
|
88
|
+
);
|
|
89
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
90
|
+
res.end("<h2>Login successful!</h2><p>Return to terminal.</p>");
|
|
91
|
+
server.close();
|
|
92
|
+
resolve(data);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
95
|
+
res.end(`<h2>Login failed</h2><p>${e.message}</p>`);
|
|
96
|
+
server.close();
|
|
97
|
+
reject(e);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
server.listen(callbackPort);
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
server.close();
|
|
103
|
+
reject(new Error("Login timed out (2 min)"));
|
|
104
|
+
}, 120_000);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Create webhook listener HTTP server with domain verification support. */
|
|
109
|
+
function createWebhookServer(oaId) {
|
|
110
|
+
return createServer((req, res) => {
|
|
111
|
+
// Domain verification file
|
|
112
|
+
if (req.url?.includes("zalo_verifier")) {
|
|
113
|
+
const creds = loadOACreds(oaId);
|
|
114
|
+
const code = creds?.verifyCode || "placeholder";
|
|
115
|
+
const html = `<html><head><meta name="zalo-platform-site-verification" content="${code}" /></head><body>zalo verification</body></html>`;
|
|
116
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
117
|
+
res.end(html);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Root with meta tag
|
|
122
|
+
if (req.url === "/") {
|
|
123
|
+
const creds = loadOACreds(oaId);
|
|
124
|
+
const code = creds?.verifyCode || "";
|
|
125
|
+
const html = `<html><head><meta name="zalo-platform-site-verification" content="${code}" /></head><body>Zalo OA Webhook</body></html>`;
|
|
126
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
127
|
+
res.end(html);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Webhook POST
|
|
132
|
+
if (req.method === "POST" && req.url?.startsWith("/webhook")) {
|
|
133
|
+
let body = "";
|
|
134
|
+
req.on("data", (c) => (body += c));
|
|
135
|
+
req.on("end", () => {
|
|
136
|
+
try {
|
|
137
|
+
const event = JSON.parse(body);
|
|
138
|
+
const eventName = event.event_name || "unknown";
|
|
139
|
+
const sender = event.sender?.id || "N/A";
|
|
140
|
+
const msg = event.message?.text || "";
|
|
141
|
+
if (eventName === "user_send_text") info(`[text] ${sender}: ${msg}`);
|
|
142
|
+
else info(`[${eventName}] from ${sender}`);
|
|
143
|
+
} catch (_) {
|
|
144
|
+
/* test ping */
|
|
145
|
+
}
|
|
146
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
147
|
+
res.end('{"status":"ok"}');
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
153
|
+
res.end('{"status":"ok"}');
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Start ngrok or cloudflared tunnel and return the public URL. */
|
|
158
|
+
async function startTunnel(tunnelCmd, port) {
|
|
159
|
+
if (tunnelCmd === "ngrok") {
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const proc = exec(`ngrok http ${port} --log stdout --log-format json`);
|
|
162
|
+
let resolved = false;
|
|
163
|
+
proc.stdout.on("data", (data) => {
|
|
164
|
+
if (resolved) return;
|
|
165
|
+
const match = data.match(/"url":"(https:\/\/[^"]+)"/);
|
|
166
|
+
if (match) {
|
|
167
|
+
resolved = true;
|
|
168
|
+
resolve(match[1]);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
if (!resolved) reject(new Error("ngrok timeout"));
|
|
173
|
+
}, 10000);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// cloudflared
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const proc = exec(`cloudflared tunnel --url http://localhost:${port}`);
|
|
179
|
+
let resolved = false;
|
|
180
|
+
const handler = (data) => {
|
|
181
|
+
if (resolved) return;
|
|
182
|
+
const match = data.toString().match(/(https:\/\/[^\s]+\.trycloudflare\.com)/);
|
|
183
|
+
if (match) {
|
|
184
|
+
resolved = true;
|
|
185
|
+
resolve(match[1]);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
proc.stdout.on("data", handler);
|
|
189
|
+
proc.stderr.on("data", handler);
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
if (!resolved) reject(new Error("cloudflared timeout"));
|
|
192
|
+
}, 15000);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function registerOAInitCommand(oaCommand, program) {
|
|
197
|
+
oaCommand
|
|
198
|
+
.command("init")
|
|
199
|
+
.description("Setup wizard for Zalo OA — works interactive (human) and non-interactive (agent)")
|
|
200
|
+
.option("--oa-id <id>", "OA identifier for multi-OA support", "default")
|
|
201
|
+
.option("--app-id <id>", "Zalo App ID (non-interactive mode)")
|
|
202
|
+
.option("--secret <key>", "Zalo App Secret Key (non-interactive mode)")
|
|
203
|
+
.option("--tunnel <type>", "Tunnel type: ngrok | cloudflared | none (non-interactive mode)")
|
|
204
|
+
.option("--webhook-url <url>", "Pre-existing webhook URL (VPS, n8n, etc.)")
|
|
205
|
+
.option("--verify-code <code>", "Zalo domain verification code")
|
|
206
|
+
.option("-p, --port <port>", "Webhook listener port", "3000")
|
|
207
|
+
.option("--skip-webhook", "Skip webhook setup entirely")
|
|
208
|
+
.option("--skip-login", "Skip OAuth login (use existing token)")
|
|
209
|
+
.action(async (opts) => {
|
|
210
|
+
const json = () => program.opts().json;
|
|
211
|
+
// Agent mode: when --app-id is provided, skip all interactive prompts
|
|
212
|
+
const agentMode = !!opts.appId;
|
|
213
|
+
const result = { steps: [], ok: true };
|
|
214
|
+
|
|
215
|
+
if (!agentMode) {
|
|
216
|
+
console.log();
|
|
217
|
+
console.log(" ╔══════════════════════════════════════════╗");
|
|
218
|
+
console.log(" ║ Zalo Official Account — Setup Wizard ║");
|
|
219
|
+
console.log(" ╚══════════════════════════════════════════╝");
|
|
220
|
+
console.log();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Step 1: Credentials ─────────────────────────────────
|
|
224
|
+
|
|
225
|
+
const existingCreds = loadOACreds(opts.oaId);
|
|
226
|
+
let appId = opts.appId;
|
|
227
|
+
let secretKey = opts.secret;
|
|
228
|
+
|
|
229
|
+
if (!appId && existingCreds?.appId) {
|
|
230
|
+
const reuse = await ask(
|
|
231
|
+
` Found existing App ID: ${existingCreds.appId}. Reuse? (Y/n) `,
|
|
232
|
+
agentMode,
|
|
233
|
+
"y",
|
|
234
|
+
);
|
|
235
|
+
if (!reuse || reuse.toLowerCase() === "y") {
|
|
236
|
+
appId = existingCreds.appId;
|
|
237
|
+
secretKey = secretKey || existingCreds.secretKey;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!appId) {
|
|
242
|
+
if (agentMode) {
|
|
243
|
+
error("--app-id is required in non-interactive mode");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
info("Step 1/5 — App Credentials");
|
|
247
|
+
console.log(" Go to https://developers.zalo.me → select your app\n");
|
|
248
|
+
appId = await ask(" App ID: ", false);
|
|
249
|
+
if (!appId) {
|
|
250
|
+
error("App ID is required.");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!secretKey) {
|
|
255
|
+
if (agentMode) {
|
|
256
|
+
error("--secret is required in non-interactive mode");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
secretKey = await ask(" Secret Key: ", false);
|
|
260
|
+
if (!secretKey) {
|
|
261
|
+
error("Secret Key is required.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
saveOACreds({ appId, secretKey }, opts.oaId);
|
|
267
|
+
result.steps.push("credentials_saved");
|
|
268
|
+
if (!agentMode) success("Credentials saved\n");
|
|
269
|
+
|
|
270
|
+
// ─── Step 2: OAuth Login ─────────────────────────────────
|
|
271
|
+
|
|
272
|
+
const skipLogin = opts.skipLogin || (existingCreds?.accessToken && agentMode);
|
|
273
|
+
let doLogin = !skipLogin;
|
|
274
|
+
|
|
275
|
+
if (!skipLogin && existingCreds?.accessToken && !agentMode) {
|
|
276
|
+
const ans = await ask(" Already logged in. Re-login? (y/N) ", false, "n");
|
|
277
|
+
doLogin = ans.toLowerCase() === "y";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (doLogin) {
|
|
281
|
+
if (!agentMode) {
|
|
282
|
+
info("Step 2/5 — OAuth Login");
|
|
283
|
+
console.log(" Starting OAuth flow — browser will open...\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const tokenData = await runOAuthLogin(appId, secretKey, opts.oaId);
|
|
288
|
+
result.steps.push("oauth_login_ok");
|
|
289
|
+
result.expiresIn = tokenData.expires_in;
|
|
290
|
+
if (!agentMode) {
|
|
291
|
+
success(`Logged in! Token expires in ${Math.round((tokenData.expires_in || 86400) / 3600)}h`);
|
|
292
|
+
}
|
|
293
|
+
} catch (e) {
|
|
294
|
+
result.ok = false;
|
|
295
|
+
result.error = e.message;
|
|
296
|
+
error(`Login failed: ${e.message}`);
|
|
297
|
+
if (json()) output(result, true);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
result.steps.push("login_skipped");
|
|
302
|
+
if (!agentMode) success("Using existing token");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Verify connection
|
|
306
|
+
try {
|
|
307
|
+
const profile = await getOAProfile(opts.oaId);
|
|
308
|
+
const d = profile.data || profile;
|
|
309
|
+
result.oa = { name: d.name, id: d.oa_id, followers: d.num_follower };
|
|
310
|
+
result.steps.push("profile_verified");
|
|
311
|
+
if (!agentMode) success(`Connected to OA: ${d.name} (${d.num_follower} followers)\n`);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
if (!agentMode) warning(`Could not verify OA profile: ${e.message}`);
|
|
314
|
+
result.steps.push("profile_verify_failed");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── Step 3: Prerequisites (interactive only) ────────────
|
|
318
|
+
|
|
319
|
+
if (!agentMode) {
|
|
320
|
+
info("Step 3/5 — Prerequisite Checklist");
|
|
321
|
+
console.log(" Verify these at developers.zalo.me:\n");
|
|
322
|
+
console.log(" ┌──────────────────────────────────────────────────────────┐");
|
|
323
|
+
console.log(" │ □ Official Account → Thiết lập chung → Callback URL │");
|
|
324
|
+
console.log(" │ Set to: http://localhost:3456/callback │");
|
|
325
|
+
console.log(" │ □ Đăng ký sử dụng API → Official Account API → ON │");
|
|
326
|
+
console.log(" │ □ Official Account → Chọn quyền → tick all → Lưu │");
|
|
327
|
+
console.log(" └──────────────────────────────────────────────────────────┘");
|
|
328
|
+
await waitForEnter("Done? Press Enter to continue...", false);
|
|
329
|
+
console.log();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── Step 4: Webhook Setup ──────────────────────────────
|
|
333
|
+
|
|
334
|
+
if (opts.skipWebhook) {
|
|
335
|
+
result.steps.push("webhook_skipped");
|
|
336
|
+
if (!agentMode) info("Webhook setup skipped.\n");
|
|
337
|
+
} else if (opts.webhookUrl) {
|
|
338
|
+
// Pre-existing URL (VPS, n8n, etc.)
|
|
339
|
+
saveOACreds({ webhookUrl: opts.webhookUrl }, opts.oaId);
|
|
340
|
+
if (opts.verifyCode) saveOACreds({ verifyCode: opts.verifyCode }, opts.oaId);
|
|
341
|
+
result.steps.push("webhook_url_saved");
|
|
342
|
+
result.webhookUrl = opts.webhookUrl;
|
|
343
|
+
if (!agentMode) {
|
|
344
|
+
success(`Webhook URL saved: ${opts.webhookUrl}`);
|
|
345
|
+
info("Set this at developers.zalo.me → Webhook\n");
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
// Tunnel mode
|
|
349
|
+
let tunnelType = opts.tunnel;
|
|
350
|
+
|
|
351
|
+
if (!tunnelType && !agentMode) {
|
|
352
|
+
info("Step 4/5 — Webhook Setup");
|
|
353
|
+
console.log(" How will you expose your webhook?\n");
|
|
354
|
+
console.log(" [1] ngrok — auto tunnel (easiest)");
|
|
355
|
+
console.log(" [2] cloudflared — Cloudflare Tunnel");
|
|
356
|
+
console.log(" [3] Own server — VPS with public IP");
|
|
357
|
+
console.log(" [4] n8n — n8n webhook node");
|
|
358
|
+
console.log(" [5] Skip\n");
|
|
359
|
+
|
|
360
|
+
const choice = Number(await ask(" Choose (1-5, default 1): ", false, "1"));
|
|
361
|
+
|
|
362
|
+
if (choice === 5) {
|
|
363
|
+
tunnelType = "none";
|
|
364
|
+
} else if (choice === 4) {
|
|
365
|
+
tunnelType = "n8n";
|
|
366
|
+
} else if (choice === 3) {
|
|
367
|
+
tunnelType = "server";
|
|
368
|
+
} else if (choice === 2) {
|
|
369
|
+
tunnelType = "cloudflared";
|
|
370
|
+
} else {
|
|
371
|
+
tunnelType = "ngrok";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Default to ngrok in agent mode
|
|
376
|
+
tunnelType = tunnelType || "ngrok";
|
|
377
|
+
|
|
378
|
+
if (tunnelType === "none") {
|
|
379
|
+
result.steps.push("webhook_skipped");
|
|
380
|
+
if (!agentMode) info("Skipped. Run 'zalo-agent oa listen' when ready.\n");
|
|
381
|
+
} else if (tunnelType === "n8n") {
|
|
382
|
+
if (!agentMode) {
|
|
383
|
+
info("n8n: Install n8n-nodes-zalo-oa-integration");
|
|
384
|
+
info("Use 'Zalo OA Webhook' trigger node in n8n.");
|
|
385
|
+
const n8nUrl = await ask(" Your n8n webhook URL: ", false);
|
|
386
|
+
if (n8nUrl) {
|
|
387
|
+
saveOACreds({ webhookUrl: n8nUrl }, opts.oaId);
|
|
388
|
+
success(`Saved: ${n8nUrl}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
result.steps.push("n8n_configured");
|
|
392
|
+
} else if (tunnelType === "server") {
|
|
393
|
+
if (!agentMode) {
|
|
394
|
+
info("Deploy on your server:");
|
|
395
|
+
console.log(" npm install -g zalo-agent-cli");
|
|
396
|
+
console.log(" zalo-agent oa setup <access-token>");
|
|
397
|
+
console.log(" zalo-agent oa listen -p 3000 -s <secret>\n");
|
|
398
|
+
const serverUrl = await ask(" Server webhook URL: ", false);
|
|
399
|
+
if (serverUrl) {
|
|
400
|
+
saveOACreds({ webhookUrl: serverUrl }, opts.oaId);
|
|
401
|
+
result.webhookUrl = serverUrl;
|
|
402
|
+
success(`Saved: ${serverUrl}`);
|
|
403
|
+
const wantVerify = await ask(" Domain verification help? (y/N) ", false, "n");
|
|
404
|
+
if (wantVerify.toLowerCase() === "y") {
|
|
405
|
+
const vc = await ask(" Verification code: ", false);
|
|
406
|
+
if (vc) {
|
|
407
|
+
console.log(
|
|
408
|
+
`\n Meta tag: <meta name="zalo-platform-site-verification" content="${vc}" />`,
|
|
409
|
+
);
|
|
410
|
+
console.log(` Or serve at: /zalo_verifier${vc}.html\n`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
result.steps.push("server_configured");
|
|
416
|
+
} else {
|
|
417
|
+
// ngrok or cloudflared
|
|
418
|
+
const tunnelCmd = tunnelType === "cloudflared" ? "cloudflared" : "ngrok";
|
|
419
|
+
if (!(await hasCli(tunnelCmd))) {
|
|
420
|
+
const msg = `${tunnelCmd} not found. Install: brew install ${tunnelCmd}`;
|
|
421
|
+
error(msg);
|
|
422
|
+
result.ok = false;
|
|
423
|
+
result.error = msg;
|
|
424
|
+
if (json()) output(result, true);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const port = Number(opts.port) || 3000;
|
|
429
|
+
|
|
430
|
+
if (!agentMode) console.log(`\n Starting listener + ${tunnelCmd}...\n`);
|
|
431
|
+
|
|
432
|
+
const webhookServer = createWebhookServer(opts.oaId);
|
|
433
|
+
webhookServer.listen(port);
|
|
434
|
+
result.steps.push("listener_started");
|
|
435
|
+
if (!agentMode) success(`Listener on port ${port}`);
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const tunnelUrl = await startTunnel(tunnelCmd, port);
|
|
439
|
+
const webhookUrl = `${tunnelUrl}/webhook`;
|
|
440
|
+
saveOACreds({ webhookUrl }, opts.oaId);
|
|
441
|
+
result.steps.push("tunnel_started");
|
|
442
|
+
result.tunnelUrl = tunnelUrl;
|
|
443
|
+
result.webhookUrl = webhookUrl;
|
|
444
|
+
|
|
445
|
+
if (!agentMode) {
|
|
446
|
+
success(`Tunnel: ${tunnelUrl}`);
|
|
447
|
+
console.log();
|
|
448
|
+
info("At developers.zalo.me:");
|
|
449
|
+
console.log(` 1. Xác thực domain: ${tunnelUrl.replace("https://", "")}`);
|
|
450
|
+
console.log(` 2. Webhook URL: ${webhookUrl}`);
|
|
451
|
+
console.log(" 3. Bật events: user_send_text, follow, etc\n");
|
|
452
|
+
|
|
453
|
+
const verifyCode = await ask(" Verification code: ", false);
|
|
454
|
+
if (verifyCode) {
|
|
455
|
+
saveOACreds({ verifyCode }, opts.oaId);
|
|
456
|
+
success("Code saved — click 'Xác thực' in Zalo");
|
|
457
|
+
}
|
|
458
|
+
await waitForEnter("Webhook setup done? Press Enter...", false);
|
|
459
|
+
} else if (opts.verifyCode) {
|
|
460
|
+
saveOACreds({ verifyCode: opts.verifyCode }, opts.oaId);
|
|
461
|
+
result.steps.push("verify_code_saved");
|
|
462
|
+
}
|
|
463
|
+
} catch (e) {
|
|
464
|
+
error(`Tunnel failed: ${e.message}`);
|
|
465
|
+
result.ok = false;
|
|
466
|
+
result.error = e.message;
|
|
467
|
+
if (json()) output(result, true);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Step 5: Summary ─────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
result.steps.push("complete");
|
|
476
|
+
|
|
477
|
+
if (json()) {
|
|
478
|
+
output(result, true);
|
|
479
|
+
} else {
|
|
480
|
+
console.log();
|
|
481
|
+
info("Setup complete!\n");
|
|
482
|
+
console.log(" ┌─────────────────────────────────────────────────────────┐");
|
|
483
|
+
console.log(" │ Quick Reference: │");
|
|
484
|
+
console.log(" │ │");
|
|
485
|
+
console.log(" │ zalo-agent oa whoami # OA profile │");
|
|
486
|
+
console.log(" │ zalo-agent oa msg text <uid> <text> # Send message │");
|
|
487
|
+
console.log(" │ zalo-agent oa follower list # List followers │");
|
|
488
|
+
console.log(" │ zalo-agent oa listen -p 3000 # Webhook listener │");
|
|
489
|
+
console.log(" │ zalo-agent oa refresh # Refresh token │");
|
|
490
|
+
console.log(" │ zalo-agent --json oa whoami # JSON output │");
|
|
491
|
+
console.log(" │ │");
|
|
492
|
+
console.log(" │ Note: Some APIs need OA tier upgrade. │");
|
|
493
|
+
console.log(" │ See: https://zalo.cloud/oa/pricing │");
|
|
494
|
+
console.log(" └─────────────────────────────────────────────────────────┘");
|
|
495
|
+
console.log();
|
|
496
|
+
|
|
497
|
+
if (result.webhookUrl) {
|
|
498
|
+
info("Webhook listener running. Ctrl+C to stop.\n");
|
|
499
|
+
await new Promise(() => {});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|