zalo-agent-cli 1.0.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/DISCLAIMER.md +13 -0
- package/LICENSE +21 -0
- package/README.md +461 -0
- package/package.json +47 -0
- package/src/cli.test.js +97 -0
- package/src/commands/account.js +178 -0
- package/src/commands/conv.js +93 -0
- package/src/commands/friend.js +148 -0
- package/src/commands/group.js +123 -0
- package/src/commands/login.js +154 -0
- package/src/commands/msg.js +225 -0
- package/src/core/accounts.js +93 -0
- package/src/core/credentials.js +63 -0
- package/src/core/zalo-client.js +128 -0
- package/src/index.js +45 -0
- package/src/utils/bank-helpers.js +233 -0
- package/src/utils/bank-helpers.test.js +66 -0
- package/src/utils/output.js +21 -0
- package/src/utils/proxy-helpers.js +14 -0
- package/src/utils/proxy-helpers.test.js +36 -0
- package/src/utils/qr-display.js +78 -0
- package/src/utils/qr-http-server.js +126 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vietnamese bank BIN mapping + VietQR image generation via qr.sepay.vn.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdtempSync, writeFileSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import nodefetch from "node-fetch";
|
|
9
|
+
|
|
10
|
+
/** Bank name aliases (lowercase, no spaces) → BIN codes. */
|
|
11
|
+
export const BANK_NAME_TO_BIN = {
|
|
12
|
+
abbank: 970425,
|
|
13
|
+
acb: 970416,
|
|
14
|
+
agribank: 970405,
|
|
15
|
+
bidv: 970418,
|
|
16
|
+
bvbank: 970454,
|
|
17
|
+
bacabank: 970409,
|
|
18
|
+
baoviet: 970438,
|
|
19
|
+
cake: 546034,
|
|
20
|
+
cbbank: 970444,
|
|
21
|
+
cimb: 422589,
|
|
22
|
+
coopbank: 970446,
|
|
23
|
+
dbs: 796500,
|
|
24
|
+
dongabank: 970406,
|
|
25
|
+
eximbank: 970431,
|
|
26
|
+
gpbank: 970408,
|
|
27
|
+
hdbank: 970437,
|
|
28
|
+
hsbc: 458761,
|
|
29
|
+
hongleong: 970442,
|
|
30
|
+
ibkhcm: 970456,
|
|
31
|
+
ibkhn: 970455,
|
|
32
|
+
indovina: 970434,
|
|
33
|
+
kbank: 668888,
|
|
34
|
+
kienlongbank: 970452,
|
|
35
|
+
kookminhcm: 970463,
|
|
36
|
+
kookminhn: 970462,
|
|
37
|
+
mbbank: 970422,
|
|
38
|
+
mb: 970422,
|
|
39
|
+
msb: 970426,
|
|
40
|
+
ncb: 970419,
|
|
41
|
+
namabank: 970428,
|
|
42
|
+
nonghyup: 801011,
|
|
43
|
+
ocb: 970448,
|
|
44
|
+
oceanbank: 970414,
|
|
45
|
+
pgbank: 970430,
|
|
46
|
+
pvcombank: 970412,
|
|
47
|
+
publicbank: 970439,
|
|
48
|
+
scb: 970429,
|
|
49
|
+
shb: 970443,
|
|
50
|
+
sacombank: 970403,
|
|
51
|
+
saigonbank: 970400,
|
|
52
|
+
seabank: 970440,
|
|
53
|
+
shinhan: 970424,
|
|
54
|
+
standardchartered: 970410,
|
|
55
|
+
tnex: 9704261,
|
|
56
|
+
tpbank: 970423,
|
|
57
|
+
techcombank: 970407,
|
|
58
|
+
timo: 963388,
|
|
59
|
+
ubank: 546035,
|
|
60
|
+
uob: 970458,
|
|
61
|
+
vib: 970441,
|
|
62
|
+
vpbank: 970432,
|
|
63
|
+
vrb: 970421,
|
|
64
|
+
vietabank: 970427,
|
|
65
|
+
vietbank: 970433,
|
|
66
|
+
vietcombank: 970436,
|
|
67
|
+
vcb: 970436,
|
|
68
|
+
vietinbank: 970415,
|
|
69
|
+
ctg: 970415,
|
|
70
|
+
woori: 970457,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** BIN → display name. */
|
|
74
|
+
export const BIN_TO_DISPLAY = {
|
|
75
|
+
970425: "ABBank",
|
|
76
|
+
970416: "ACB",
|
|
77
|
+
970405: "Agribank",
|
|
78
|
+
970418: "BIDV",
|
|
79
|
+
970454: "BVBank",
|
|
80
|
+
970409: "BacA Bank",
|
|
81
|
+
970438: "BaoViet Bank",
|
|
82
|
+
546034: "CAKE",
|
|
83
|
+
970444: "CB Bank",
|
|
84
|
+
422589: "CIMB",
|
|
85
|
+
970446: "Co-op Bank",
|
|
86
|
+
796500: "DBS",
|
|
87
|
+
970406: "DongA Bank",
|
|
88
|
+
970431: "Eximbank",
|
|
89
|
+
970408: "GPBank",
|
|
90
|
+
970437: "HDBank",
|
|
91
|
+
458761: "HSBC",
|
|
92
|
+
970442: "Hong Leong",
|
|
93
|
+
970456: "IBK HCM",
|
|
94
|
+
970455: "IBK HN",
|
|
95
|
+
970434: "Indovina",
|
|
96
|
+
668888: "KBank",
|
|
97
|
+
970452: "KienlongBank",
|
|
98
|
+
970463: "Kookmin HCM",
|
|
99
|
+
970462: "Kookmin HN",
|
|
100
|
+
970422: "MB Bank",
|
|
101
|
+
970426: "MSB",
|
|
102
|
+
970419: "NCB",
|
|
103
|
+
970428: "Nam A Bank",
|
|
104
|
+
801011: "NongHyup",
|
|
105
|
+
970448: "OCB",
|
|
106
|
+
970414: "OceanBank",
|
|
107
|
+
970430: "PGBank",
|
|
108
|
+
970412: "PVcomBank",
|
|
109
|
+
970439: "Public Bank",
|
|
110
|
+
970429: "SCB",
|
|
111
|
+
970443: "SHB",
|
|
112
|
+
970403: "Sacombank",
|
|
113
|
+
970400: "Saigon Bank",
|
|
114
|
+
970440: "SeABank",
|
|
115
|
+
970424: "Shinhan",
|
|
116
|
+
970410: "Standard Chartered",
|
|
117
|
+
9704261: "TNEX",
|
|
118
|
+
970423: "TPBank",
|
|
119
|
+
970407: "Techcombank",
|
|
120
|
+
963388: "Timo",
|
|
121
|
+
546035: "UBank",
|
|
122
|
+
970458: "UOB",
|
|
123
|
+
970441: "VIB",
|
|
124
|
+
970432: "VPBank",
|
|
125
|
+
970421: "VRB",
|
|
126
|
+
970427: "VietABank",
|
|
127
|
+
970433: "VietBank",
|
|
128
|
+
970436: "Vietcombank",
|
|
129
|
+
970415: "VietinBank",
|
|
130
|
+
970457: "Woori",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** BIN → SePay short_name for qr.sepay.vn URL. */
|
|
134
|
+
const BIN_TO_SEPAY = {
|
|
135
|
+
970415: "VietinBank",
|
|
136
|
+
970436: "Vietcombank",
|
|
137
|
+
970422: "MBBank",
|
|
138
|
+
970416: "ACB",
|
|
139
|
+
970432: "VPBank",
|
|
140
|
+
970423: "TPBank",
|
|
141
|
+
970426: "MSB",
|
|
142
|
+
970428: "NamABank",
|
|
143
|
+
970449: "LienVietPostBank",
|
|
144
|
+
970454: "VietCapitalBank",
|
|
145
|
+
970418: "BIDV",
|
|
146
|
+
970403: "Sacombank",
|
|
147
|
+
970441: "VIB",
|
|
148
|
+
970437: "HDBank",
|
|
149
|
+
970440: "SeABank",
|
|
150
|
+
970408: "GPBank",
|
|
151
|
+
970412: "PVcomBank",
|
|
152
|
+
970419: "NCB",
|
|
153
|
+
970424: "ShinhanBank",
|
|
154
|
+
970429: "SCB",
|
|
155
|
+
970430: "PGBank",
|
|
156
|
+
970405: "Agribank",
|
|
157
|
+
970407: "Techcombank",
|
|
158
|
+
970400: "SaigonBank",
|
|
159
|
+
970406: "DongABank",
|
|
160
|
+
970409: "BacABank",
|
|
161
|
+
970410: "StandardChartered",
|
|
162
|
+
970414: "Oceanbank",
|
|
163
|
+
970421: "VRB",
|
|
164
|
+
970425: "ABBANK",
|
|
165
|
+
970427: "VietABank",
|
|
166
|
+
970431: "Eximbank",
|
|
167
|
+
970433: "VietBank",
|
|
168
|
+
970434: "IndovinaBank",
|
|
169
|
+
970438: "BaoVietBank",
|
|
170
|
+
970439: "PublicBank",
|
|
171
|
+
970443: "SHB",
|
|
172
|
+
970444: "CBBank",
|
|
173
|
+
970448: "OCB",
|
|
174
|
+
970452: "KienLongBank",
|
|
175
|
+
422589: "CIMB",
|
|
176
|
+
458761: "HSBC",
|
|
177
|
+
796500: "DBSBank",
|
|
178
|
+
801011: "Nonghyup",
|
|
179
|
+
970442: "HongLeong",
|
|
180
|
+
970457: "Woori",
|
|
181
|
+
970458: "UnitedOverseas",
|
|
182
|
+
970462: "KookminHN",
|
|
183
|
+
970463: "KookminHCM",
|
|
184
|
+
970446: "COOPBANK",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve bank name or BIN string to numeric BIN code.
|
|
189
|
+
* @param {string} input - Bank name alias or BIN number
|
|
190
|
+
* @returns {number|null}
|
|
191
|
+
*/
|
|
192
|
+
export function resolveBankBin(input) {
|
|
193
|
+
if (/^\d+$/.test(input)) {
|
|
194
|
+
const n = Number(input);
|
|
195
|
+
return BIN_TO_DISPLAY[n] ? n : null;
|
|
196
|
+
}
|
|
197
|
+
return BANK_NAME_TO_BIN[input.toLowerCase().replace(/[\s_]/g, "")] || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Download VietQR transfer image from qr.sepay.vn.
|
|
202
|
+
* @param {number} bin
|
|
203
|
+
* @param {string} accountNumber
|
|
204
|
+
* @param {number|null} amount
|
|
205
|
+
* @param {string|null} content - Max 50 chars, SePay param name is "des"
|
|
206
|
+
* @param {string} template - compact | print | qronly
|
|
207
|
+
* @returns {Promise<string|null>} Temp file path or null on failure
|
|
208
|
+
*/
|
|
209
|
+
export async function generateQrTransferImage(bin, accountNumber, amount = null, content = null, template = "compact") {
|
|
210
|
+
const sepayShort = BIN_TO_SEPAY[bin];
|
|
211
|
+
if (!sepayShort) return null;
|
|
212
|
+
|
|
213
|
+
const params = new URLSearchParams({ bank: sepayShort, acc: accountNumber, template });
|
|
214
|
+
if (amount !== null && amount !== undefined) params.set("amount", String(amount));
|
|
215
|
+
if (content) params.set("des", content);
|
|
216
|
+
|
|
217
|
+
const url = `https://qr.sepay.vn/img?${params}`;
|
|
218
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "qr_transfer_"));
|
|
219
|
+
const outPath = join(tmpDir, "qr.png");
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const resp = await nodefetch(url, {
|
|
223
|
+
headers: { "User-Agent": "Mozilla/5.0" },
|
|
224
|
+
timeout: 15000,
|
|
225
|
+
});
|
|
226
|
+
if (!resp.ok) return null;
|
|
227
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
228
|
+
writeFileSync(outPath, buffer);
|
|
229
|
+
return outPath;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveBankBin, BIN_TO_DISPLAY, BANK_NAME_TO_BIN } from "./bank-helpers.js";
|
|
4
|
+
|
|
5
|
+
describe("resolveBankBin", () => {
|
|
6
|
+
it("resolves OCB by name", () => {
|
|
7
|
+
assert.equal(resolveBankBin("ocb"), 970448);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("resolves VCB alias", () => {
|
|
11
|
+
assert.equal(resolveBankBin("vcb"), 970436);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("resolves Vietcombank full name", () => {
|
|
15
|
+
assert.equal(resolveBankBin("vietcombank"), 970436);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("resolves BIDV by BIN string", () => {
|
|
19
|
+
assert.equal(resolveBankBin("970418"), 970418);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns null for unknown name", () => {
|
|
23
|
+
assert.equal(resolveBankBin("unknown"), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns null for unknown BIN", () => {
|
|
27
|
+
assert.equal(resolveBankBin("999999"), null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("is case insensitive", () => {
|
|
31
|
+
assert.equal(resolveBankBin("OCB"), 970448);
|
|
32
|
+
assert.equal(resolveBankBin("Vietcombank"), 970436);
|
|
33
|
+
assert.equal(resolveBankBin("BIDV"), 970418);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles spaces and underscores", () => {
|
|
37
|
+
assert.equal(resolveBankBin("mb bank"), 970422);
|
|
38
|
+
assert.equal(resolveBankBin("mb_bank"), 970422);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("resolves MB shorthand", () => {
|
|
42
|
+
assert.equal(resolveBankBin("mb"), 970422);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("resolves CTG alias for VietinBank", () => {
|
|
46
|
+
assert.equal(resolveBankBin("ctg"), 970415);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("BIN_TO_DISPLAY", () => {
|
|
51
|
+
it("has at least 50 banks", () => {
|
|
52
|
+
assert.ok(Object.keys(BIN_TO_DISPLAY).length >= 50);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("maps known BINs correctly", () => {
|
|
56
|
+
assert.equal(BIN_TO_DISPLAY[970448], "OCB");
|
|
57
|
+
assert.equal(BIN_TO_DISPLAY[970436], "Vietcombank");
|
|
58
|
+
assert.equal(BIN_TO_DISPLAY[970418], "BIDV");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("BANK_NAME_TO_BIN", () => {
|
|
63
|
+
it("has at least 50 aliases", () => {
|
|
64
|
+
assert.ok(Object.keys(BANK_NAME_TO_BIN).length >= 50);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output formatting utilities — JSON mode and colored human-readable output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
|
|
7
|
+
/** Emit JSON or call human formatter based on --json flag. */
|
|
8
|
+
export function output(data, jsonMode, humanFormatter) {
|
|
9
|
+
if (jsonMode) {
|
|
10
|
+
console.log(JSON.stringify(data, null, 2));
|
|
11
|
+
} else if (humanFormatter) {
|
|
12
|
+
humanFormatter(data);
|
|
13
|
+
} else {
|
|
14
|
+
console.log(JSON.stringify(data, null, 2));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const success = (msg) => console.log(chalk.green(" ✓ " + msg));
|
|
19
|
+
export const error = (msg) => console.log(chalk.red(" ✗ " + msg));
|
|
20
|
+
export const info = (msg) => console.log(chalk.cyan(" ● " + msg));
|
|
21
|
+
export const warning = (msg) => console.log(chalk.yellow(" ⚠ " + msg));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy URL helpers — masking passwords for safe display.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mask password in proxy URL for safe display.
|
|
7
|
+
* Example: http://user:secret@host:8080 → http://user:***@host:8080
|
|
8
|
+
* @param {string|null} proxyUrl
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function maskProxy(proxyUrl) {
|
|
12
|
+
if (!proxyUrl) return "none";
|
|
13
|
+
return proxyUrl.replace(/(\/\/[^:]+:)[^@]+(@)/, "$1***$2");
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { maskProxy } from "./proxy-helpers.js";
|
|
4
|
+
|
|
5
|
+
describe("maskProxy", () => {
|
|
6
|
+
it("masks password in http URL", () => {
|
|
7
|
+
assert.equal(maskProxy("http://user:secret@host:8080"), "http://user:***@host:8080");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("masks password in socks5 URL", () => {
|
|
11
|
+
assert.equal(maskProxy("socks5://admin:p4ss@1.2.3.4:1080"), "socks5://admin:***@1.2.3.4:1080");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("masks long password", () => {
|
|
15
|
+
assert.equal(
|
|
16
|
+
maskProxy("http://3AccFfq:pkLbHLRjhqids_session-Ntekoom2@geo.iproyal.com:12321"),
|
|
17
|
+
"http://3AccFfq:***@geo.iproyal.com:12321",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns URL unchanged when no password", () => {
|
|
22
|
+
assert.equal(maskProxy("http://host:8080"), "http://host:8080");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns "none" for null', () => {
|
|
26
|
+
assert.equal(maskProxy(null), "none");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns "none" for empty string', () => {
|
|
30
|
+
assert.equal(maskProxy(""), "none");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns "none" for undefined', () => {
|
|
34
|
+
assert.equal(maskProxy(undefined), "none");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform QR display utility.
|
|
3
|
+
* Displays Zalo's official QR PNG inline in terminal and saves to file.
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: Uses Zalo-server-generated PNG (via event.data.image base64),
|
|
6
|
+
* NOT qrcode-terminal which re-encodes the token text into a different QR
|
|
7
|
+
* that Zalo app cannot recognize as a login request.
|
|
8
|
+
*
|
|
9
|
+
* Display methods:
|
|
10
|
+
* 1. iTerm2/Kitty/WezTerm inline image (renders PNG directly in terminal)
|
|
11
|
+
* 2. Save PNG to config dir
|
|
12
|
+
* 3. Base64 data URL (for IDE/agent preview)
|
|
13
|
+
* 4. File path with platform-specific open hint
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolve } from "path";
|
|
17
|
+
import { writeFileSync, mkdirSync } from "fs";
|
|
18
|
+
import { platform } from "os";
|
|
19
|
+
import { CONFIG_DIR } from "../core/credentials.js";
|
|
20
|
+
import { info } from "./output.js";
|
|
21
|
+
|
|
22
|
+
const QR_PATH = resolve(CONFIG_DIR, "qr.png");
|
|
23
|
+
|
|
24
|
+
/** Get platform-specific command to open a file. */
|
|
25
|
+
function getOpenCommand() {
|
|
26
|
+
switch (platform()) {
|
|
27
|
+
case "darwin":
|
|
28
|
+
return "open";
|
|
29
|
+
case "win32":
|
|
30
|
+
return "start";
|
|
31
|
+
default:
|
|
32
|
+
return "xdg-open";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Display QR code from a zca-js login QR event.
|
|
38
|
+
* Synchronous — safe to call from zca-js callback.
|
|
39
|
+
* @param {object} event - zca-js QR callback event
|
|
40
|
+
*/
|
|
41
|
+
export function displayQR(event) {
|
|
42
|
+
const imageB64 = event.data?.image || "";
|
|
43
|
+
|
|
44
|
+
// 1. Display Zalo's official QR PNG inline in terminal
|
|
45
|
+
// Uses iTerm2 inline image protocol (also WezTerm, Hyper, Kitty)
|
|
46
|
+
// Terminals that don't support it simply ignore the escape sequence
|
|
47
|
+
if (imageB64) {
|
|
48
|
+
const b64ForTerm = Buffer.from(imageB64, "base64").toString("base64");
|
|
49
|
+
process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Save PNG to config dir
|
|
53
|
+
if (imageB64) {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
56
|
+
writeFileSync(QR_PATH, Buffer.from(imageB64, "base64"));
|
|
57
|
+
const openCmd = getOpenCommand();
|
|
58
|
+
info(`QR image saved: ${QR_PATH}`);
|
|
59
|
+
info(`To open: ${openCmd} "${QR_PATH}"`);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 3. Also fire-and-forget the zca-js built-in save
|
|
64
|
+
if (event.actions?.saveToFile) {
|
|
65
|
+
event.actions.saveToFile(QR_PATH).catch(() => {});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 4. Base64 data URL (for coding agents / IDE preview)
|
|
69
|
+
if (imageB64) {
|
|
70
|
+
info("QR data URL (for IDE preview):");
|
|
71
|
+
console.log(` data:image/png;base64,${imageB64.substring(0, 100)}...`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Get the QR image path. */
|
|
76
|
+
export function getQRPath() {
|
|
77
|
+
return QR_PATH;
|
|
78
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temporary local HTTP server for QR display on headless/VPS environments.
|
|
3
|
+
* Serves QR image as a simple HTML page on localhost.
|
|
4
|
+
* Auto-closes after login completes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import http from "http";
|
|
8
|
+
import { readFileSync, existsSync } from "fs";
|
|
9
|
+
import nodefetch from "node-fetch";
|
|
10
|
+
import { info } from "./output.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Start a temporary HTTP server that serves the QR image.
|
|
14
|
+
* @param {string} qrImagePath - path to QR PNG file
|
|
15
|
+
* @param {number} port - default 18927
|
|
16
|
+
* @returns {{ url: string, close: () => void }}
|
|
17
|
+
*/
|
|
18
|
+
export function startQrServer(qrImagePath, port = 18927, tryPorts = [18927, 8080, 3000, 9000]) {
|
|
19
|
+
const server = http.createServer(async (req, res) => {
|
|
20
|
+
if (req.url === "/qr" || req.url === "/") {
|
|
21
|
+
if (!existsSync(qrImagePath)) {
|
|
22
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
23
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
24
|
+
<meta http-equiv="refresh" content="2">
|
|
25
|
+
<title>Waiting for QR...</title></head>
|
|
26
|
+
<body style="display:flex;justify-content:center;align-items:center;height:100vh;background:#111;color:#fff;font-family:system-ui">
|
|
27
|
+
<p>QR code generating... auto-refreshing in 2s</p></body></html>`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const img = readFileSync(qrImagePath);
|
|
31
|
+
const b64 = img.toString("base64");
|
|
32
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
33
|
+
res.end(`<!DOCTYPE html>
|
|
34
|
+
<html><head><meta charset="utf-8"><title>Zalo QR Login</title>
|
|
35
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
36
|
+
<style>
|
|
37
|
+
body{display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#111;font-family:system-ui}
|
|
38
|
+
.card{text-align:center;padding:2rem;background:#1a1a2e;border-radius:16px;max-width:400px}
|
|
39
|
+
h2{color:#fff;margin-bottom:1rem}img{width:280px;border-radius:8px}
|
|
40
|
+
p{color:#888;font-size:0.9rem;margin-top:1rem}
|
|
41
|
+
.success{color:#4ade80;font-size:1.2rem;font-weight:bold}
|
|
42
|
+
.hidden{display:none}
|
|
43
|
+
</style></head>
|
|
44
|
+
<body><div class="card">
|
|
45
|
+
<div id="qr-view">
|
|
46
|
+
<h2>Scan with Zalo app</h2>
|
|
47
|
+
<img src="data:image/png;base64,${b64}" alt="QR Code"/>
|
|
48
|
+
<p>Open Zalo > QR Scanner to scan this code</p>
|
|
49
|
+
</div>
|
|
50
|
+
<div id="success-view" class="hidden">
|
|
51
|
+
<h2 style="color:#4ade80">Login Successful!</h2>
|
|
52
|
+
<p class="success">You can close this page now.</p>
|
|
53
|
+
<p>The CLI is ready to use.</p>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
<script>
|
|
57
|
+
// Poll /status to detect login success
|
|
58
|
+
setInterval(async()=>{
|
|
59
|
+
try{
|
|
60
|
+
const r=await fetch('/status');
|
|
61
|
+
const d=await r.json();
|
|
62
|
+
if(d.loggedIn){
|
|
63
|
+
document.getElementById('qr-view').classList.add('hidden');
|
|
64
|
+
document.getElementById('success-view').classList.remove('hidden');
|
|
65
|
+
}
|
|
66
|
+
}catch{}
|
|
67
|
+
},2000);
|
|
68
|
+
</script>
|
|
69
|
+
</body></html>`);
|
|
70
|
+
} else if (req.url === "/status") {
|
|
71
|
+
// Login status endpoint for browser polling
|
|
72
|
+
let loggedIn = false;
|
|
73
|
+
try {
|
|
74
|
+
const { isLoggedIn } = await import("../core/zalo-client.js");
|
|
75
|
+
loggedIn = isLoggedIn();
|
|
76
|
+
} catch {}
|
|
77
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
78
|
+
res.end(JSON.stringify({ loggedIn }));
|
|
79
|
+
} else {
|
|
80
|
+
res.writeHead(404);
|
|
81
|
+
res.end("Not found");
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Try ports in order until one works (avoids firewall issues)
|
|
86
|
+
let currentPortIdx = 0;
|
|
87
|
+
|
|
88
|
+
function tryListen() {
|
|
89
|
+
const p = tryPorts[currentPortIdx];
|
|
90
|
+
server.listen(p, "0.0.0.0");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
server.on("listening", async () => {
|
|
94
|
+
const actualPort = server.address().port;
|
|
95
|
+
info(`QR available at: http://localhost:${actualPort}/qr`);
|
|
96
|
+
// Auto-detect public IP for VPS users
|
|
97
|
+
try {
|
|
98
|
+
const res = await nodefetch("https://api.ipify.org", { timeout: 3000 });
|
|
99
|
+
const ip = (await res.text()).trim();
|
|
100
|
+
if (ip) info(`On VPS, open: http://${ip}:${actualPort}/qr`);
|
|
101
|
+
} catch {
|
|
102
|
+
info(`On VPS, open: http://<your-server-ip>:${actualPort}/qr`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
server.on("error", (err) => {
|
|
107
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
|
|
108
|
+
currentPortIdx++;
|
|
109
|
+
if (currentPortIdx < tryPorts.length) {
|
|
110
|
+
info(`Port ${tryPorts[currentPortIdx - 1]} unavailable, trying ${tryPorts[currentPortIdx]}...`);
|
|
111
|
+
tryListen();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
tryListen();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
url: `http://localhost:${port}/qr`,
|
|
120
|
+
close: () => {
|
|
121
|
+
try {
|
|
122
|
+
server.close();
|
|
123
|
+
} catch {}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|