@ardennguyen/zalo-agent-cli 1.0.7
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 +27 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +64 -0
- package/src/cli.test.js +98 -0
- package/src/commands/account.js +178 -0
- package/src/commands/auto-reply.js +83 -0
- package/src/commands/catalog.js +143 -0
- package/src/commands/conv.js +264 -0
- package/src/commands/friend.js +384 -0
- package/src/commands/group.js +606 -0
- package/src/commands/label.js +39 -0
- package/src/commands/listen.js +293 -0
- package/src/commands/login.js +165 -0
- package/src/commands/mcp.js +219 -0
- package/src/commands/msg.js +608 -0
- 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/poll.js +159 -0
- package/src/commands/profile.js +274 -0
- package/src/commands/quick-msg.js +55 -0
- package/src/commands/reminder.js +195 -0
- package/src/core/accounts.js +93 -0
- package/src/core/credentials.js +63 -0
- package/src/core/oa-client.js +407 -0
- package/src/core/zalo-client.js +201 -0
- package/src/index.js +95 -0
- package/src/mcp/mcp-config.js +81 -0
- package/src/mcp/mcp-http-transport.js +84 -0
- package/src/mcp/mcp-server.js +40 -0
- package/src/mcp/mcp-tools.js +353 -0
- package/src/mcp/media-downloader.js +191 -0
- package/src/mcp/media-downloader.test.js +45 -0
- package/src/mcp/message-buffer.js +127 -0
- package/src/mcp/message-buffer.test.js +308 -0
- package/src/mcp/notifier.js +141 -0
- package/src/mcp/notifier.test.js +269 -0
- package/src/mcp/thread-filter.js +79 -0
- package/src/mcp/thread-filter.test.js +206 -0
- package/src/mcp/thread-name-cache.js +162 -0
- package/src/mcp/thread-name-cache.test.js +139 -0
- package/src/utils/bank-helpers.js +233 -0
- package/src/utils/bank-helpers.test.js +66 -0
- package/src/utils/extract-message-text.js +21 -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 +91 -0
- package/src/utils/qr-display.test.js +97 -0
- package/src/utils/qr-http-server.js +190 -0
- package/src/utils/update-check.js +56 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache mapping threadId → {name, type, memberCount}.
|
|
3
|
+
* Built on startup by fetching all groups + friends from Zalo API.
|
|
4
|
+
* Provides fuzzy Vietnamese-aware search for thread discovery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize Vietnamese text for fuzzy matching.
|
|
9
|
+
* Strips diacritics and lowercases for accent-insensitive comparison.
|
|
10
|
+
* @param {string} text
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function normalizeVietnamese(text) {
|
|
14
|
+
return text
|
|
15
|
+
.normalize("NFD")
|
|
16
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
17
|
+
.replace(/đ/g, "d")
|
|
18
|
+
.replace(/Đ/g, "D")
|
|
19
|
+
.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ThreadNameCache {
|
|
23
|
+
constructor() {
|
|
24
|
+
/** @type {Map<string, { name: string, type: "group"|"dm", memberCount?: number }>} */
|
|
25
|
+
this._cache = new Map();
|
|
26
|
+
this._ready = false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize cache by fetching all groups and friends from Zalo API.
|
|
31
|
+
* Batches group info requests (50 per batch) to avoid rate limits.
|
|
32
|
+
* @param {object} api - zca-js API instance
|
|
33
|
+
*/
|
|
34
|
+
async init(api) {
|
|
35
|
+
const start = Date.now();
|
|
36
|
+
let groupCount = 0;
|
|
37
|
+
let friendCount = 0;
|
|
38
|
+
|
|
39
|
+
// Load groups: getAllGroups() → IDs → batched getGroupInfo()
|
|
40
|
+
try {
|
|
41
|
+
const groupsResult = await api.getAllGroups();
|
|
42
|
+
const groupIds = Object.keys(groupsResult?.gridVerMap || {});
|
|
43
|
+
const batchSize = 50;
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < groupIds.length; i += batchSize) {
|
|
46
|
+
const batch = groupIds.slice(i, i + batchSize);
|
|
47
|
+
try {
|
|
48
|
+
const info = await api.getGroupInfo(batch);
|
|
49
|
+
const map = info?.gridInfoMap || {};
|
|
50
|
+
for (const [gid, g] of Object.entries(map)) {
|
|
51
|
+
this._cache.set(gid, {
|
|
52
|
+
name: g.name || "?",
|
|
53
|
+
type: "group",
|
|
54
|
+
memberCount: g.totalMember || 0,
|
|
55
|
+
});
|
|
56
|
+
groupCount++;
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error(`[thread-name-cache] Batch getGroupInfo failed (offset ${i}):`, e.message);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error("[thread-name-cache] getAllGroups failed:", e.message);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Load friends (DM threads)
|
|
67
|
+
try {
|
|
68
|
+
const friends = await api.getAllFriends();
|
|
69
|
+
const list = Array.isArray(friends) ? friends : [];
|
|
70
|
+
for (const f of list) {
|
|
71
|
+
if (f.userId) {
|
|
72
|
+
this._cache.set(f.userId, {
|
|
73
|
+
name: f.displayName || f.zaloName || "?",
|
|
74
|
+
type: "dm",
|
|
75
|
+
});
|
|
76
|
+
friendCount++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error("[thread-name-cache] getAllFriends failed:", e.message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this._ready = true;
|
|
84
|
+
const elapsed = Date.now() - start;
|
|
85
|
+
console.error(`[thread-name-cache] Ready: ${groupCount} groups, ${friendCount} friends (${elapsed}ms)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get thread info by ID. Returns null if not cached.
|
|
90
|
+
* @param {string} threadId
|
|
91
|
+
* @returns {{ name: string, type: "group"|"dm", memberCount?: number } | null}
|
|
92
|
+
*/
|
|
93
|
+
get(threadId) {
|
|
94
|
+
return this._cache.get(threadId) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get thread name by ID. Returns null if not cached.
|
|
99
|
+
* @param {string} threadId
|
|
100
|
+
* @returns {string | null}
|
|
101
|
+
*/
|
|
102
|
+
getName(threadId) {
|
|
103
|
+
return this._cache.get(threadId)?.name || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Search threads by name with Vietnamese-aware fuzzy matching.
|
|
108
|
+
* @param {string} query - Search keyword
|
|
109
|
+
* @param {"group"|"dm"|"all"} [type="all"] - Filter by thread type
|
|
110
|
+
* @param {number} [limit=10] - Max results
|
|
111
|
+
* @returns {Array<{ threadId: string, name: string, type: "group"|"dm", memberCount?: number }>}
|
|
112
|
+
*/
|
|
113
|
+
search(query, type = "all", limit = 10) {
|
|
114
|
+
const normalizedQuery = normalizeVietnamese(query);
|
|
115
|
+
const results = [];
|
|
116
|
+
|
|
117
|
+
for (const [threadId, info] of this._cache) {
|
|
118
|
+
if (type !== "all" && info.type !== type) continue;
|
|
119
|
+
|
|
120
|
+
const normalizedName = normalizeVietnamese(info.name);
|
|
121
|
+
if (normalizedName.includes(normalizedQuery)) {
|
|
122
|
+
results.push({ threadId, ...info });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sort: exact prefix match first, then alphabetical
|
|
127
|
+
results.sort((a, b) => {
|
|
128
|
+
const aNorm = normalizeVietnamese(a.name);
|
|
129
|
+
const bNorm = normalizeVietnamese(b.name);
|
|
130
|
+
const aPrefix = aNorm.startsWith(normalizedQuery) ? 0 : 1;
|
|
131
|
+
const bPrefix = bNorm.startsWith(normalizedQuery) ? 0 : 1;
|
|
132
|
+
if (aPrefix !== bPrefix) return aPrefix - bPrefix;
|
|
133
|
+
return aNorm.localeCompare(bNorm);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return results.slice(0, limit);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Update a single thread entry (e.g. on group rename event).
|
|
141
|
+
* @param {string} threadId
|
|
142
|
+
* @param {object} info - Partial update: { name?, type?, memberCount? }
|
|
143
|
+
*/
|
|
144
|
+
set(threadId, info) {
|
|
145
|
+
const existing = this._cache.get(threadId);
|
|
146
|
+
if (existing) {
|
|
147
|
+
this._cache.set(threadId, { ...existing, ...info });
|
|
148
|
+
} else {
|
|
149
|
+
this._cache.set(threadId, { name: "?", type: "group", ...info });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @returns {boolean} Whether cache has been initialized */
|
|
154
|
+
get ready() {
|
|
155
|
+
return this._ready;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @returns {number} Total cached entries */
|
|
159
|
+
get size() {
|
|
160
|
+
return this._cache.size;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { ThreadNameCache } from "./thread-name-cache.js";
|
|
4
|
+
|
|
5
|
+
/** Create a mock API that returns predictable group/friend data */
|
|
6
|
+
function createMockApi(groups = {}, friends = []) {
|
|
7
|
+
return {
|
|
8
|
+
getAllGroups: async () => ({
|
|
9
|
+
gridVerMap: Object.fromEntries(Object.keys(groups).map((id) => [id, 1])),
|
|
10
|
+
}),
|
|
11
|
+
getGroupInfo: async (ids) => ({
|
|
12
|
+
gridInfoMap: Object.fromEntries(ids.filter((id) => groups[id]).map((id) => [id, groups[id]])),
|
|
13
|
+
}),
|
|
14
|
+
getAllFriends: async () => friends,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("ThreadNameCache", () => {
|
|
19
|
+
let cache;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
cache = new ThreadNameCache();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("starts empty and not ready", () => {
|
|
26
|
+
assert.equal(cache.ready, false);
|
|
27
|
+
assert.equal(cache.size, 0);
|
|
28
|
+
assert.equal(cache.get("any"), null);
|
|
29
|
+
assert.equal(cache.getName("any"), null);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("loads groups and friends on init", async () => {
|
|
33
|
+
const api = createMockApi(
|
|
34
|
+
{
|
|
35
|
+
g1: { name: "Nhóm Chờ Báo Giá", totalMember: 20 },
|
|
36
|
+
g2: { name: "Soạn hàng Q. Vũ", totalMember: 15 },
|
|
37
|
+
},
|
|
38
|
+
[
|
|
39
|
+
{ userId: "u1", displayName: "Viet Anh", zaloName: "VA" },
|
|
40
|
+
{ userId: "u2", zaloName: "Bob" },
|
|
41
|
+
],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await cache.init(api);
|
|
45
|
+
|
|
46
|
+
assert.equal(cache.ready, true);
|
|
47
|
+
assert.equal(cache.size, 4);
|
|
48
|
+
assert.deepEqual(cache.get("g1"), { name: "Nhóm Chờ Báo Giá", type: "group", memberCount: 20 });
|
|
49
|
+
assert.equal(cache.getName("g2"), "Soạn hàng Q. Vũ");
|
|
50
|
+
assert.deepEqual(cache.get("u1"), { name: "Viet Anh", type: "dm" });
|
|
51
|
+
assert.equal(cache.getName("u2"), "Bob");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles API failures gracefully", async () => {
|
|
55
|
+
const api = {
|
|
56
|
+
getAllGroups: async () => {
|
|
57
|
+
throw new Error("network error");
|
|
58
|
+
},
|
|
59
|
+
getAllFriends: async () => {
|
|
60
|
+
throw new Error("network error");
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await cache.init(api);
|
|
65
|
+
|
|
66
|
+
assert.equal(cache.ready, true);
|
|
67
|
+
assert.equal(cache.size, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("search", () => {
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
const api = createMockApi(
|
|
73
|
+
{
|
|
74
|
+
g1: { name: "Nhóm Chờ Báo Giá", totalMember: 20 },
|
|
75
|
+
g2: { name: "Soạn hàng Q. Vũ - QV", totalMember: 15 },
|
|
76
|
+
g3: { name: "Soạn hàng kho 2", totalMember: 8 },
|
|
77
|
+
g4: { name: "Admin Team", totalMember: 5 },
|
|
78
|
+
},
|
|
79
|
+
[{ userId: "u1", displayName: "Soạn Văn", zaloName: "SV" }],
|
|
80
|
+
);
|
|
81
|
+
await cache.init(api);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("finds groups by Vietnamese name (accent-insensitive)", () => {
|
|
85
|
+
const results = cache.search("soan hang");
|
|
86
|
+
assert.equal(results.length, 2);
|
|
87
|
+
assert.equal(results[0].name, "Soạn hàng kho 2");
|
|
88
|
+
assert.equal(results[1].name, "Soạn hàng Q. Vũ - QV");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("finds with exact Vietnamese diacritics", () => {
|
|
92
|
+
const results = cache.search("Soạn hàng");
|
|
93
|
+
assert.equal(results.length, 2);
|
|
94
|
+
assert.ok(results.every((r) => r.type === "group"));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("filters by type", () => {
|
|
98
|
+
const groups = cache.search("Soạn", "group");
|
|
99
|
+
assert.equal(groups.length, 2);
|
|
100
|
+
assert.ok(groups.every((r) => r.type === "group"));
|
|
101
|
+
|
|
102
|
+
const dms = cache.search("Soạn", "dm");
|
|
103
|
+
assert.equal(dms.length, 1);
|
|
104
|
+
assert.equal(dms[0].name, "Soạn Văn");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("respects limit parameter", () => {
|
|
108
|
+
const results = cache.search("Soạn", "all", 1);
|
|
109
|
+
assert.equal(results.length, 1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns empty for no match", () => {
|
|
113
|
+
const results = cache.search("xyz_nonexistent");
|
|
114
|
+
assert.equal(results.length, 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("prioritizes prefix matches", () => {
|
|
118
|
+
const results = cache.search("Admin");
|
|
119
|
+
assert.equal(results[0].name, "Admin Team");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("set (update)", () => {
|
|
124
|
+
it("updates existing entry", async () => {
|
|
125
|
+
const api = createMockApi({ g1: { name: "Old Name", totalMember: 5 } }, []);
|
|
126
|
+
await cache.init(api);
|
|
127
|
+
|
|
128
|
+
cache.set("g1", { name: "New Name" });
|
|
129
|
+
assert.equal(cache.getName("g1"), "New Name");
|
|
130
|
+
assert.equal(cache.get("g1").type, "group");
|
|
131
|
+
assert.equal(cache.get("g1").memberCount, 5);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("adds new entry", () => {
|
|
135
|
+
cache.set("new1", { name: "New Group", type: "group", memberCount: 3 });
|
|
136
|
+
assert.deepEqual(cache.get("new1"), { name: "New Group", type: "group", memberCount: 3 });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -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
|
+
* Extract readable text from non-string Zalo message content.
|
|
3
|
+
* Handles reply/quote messages, bubble messages, recommendations, link previews, etc.
|
|
4
|
+
*
|
|
5
|
+
* Field priority: params.message → description → title → text → msg → href → content (string) → JSON fallback
|
|
6
|
+
*/
|
|
7
|
+
export function extractMessageText(content, msgType) {
|
|
8
|
+
if (!content || typeof content !== "object") return `[${msgType || "attachment"}]`;
|
|
9
|
+
if (content.params?.message) return content.params.message;
|
|
10
|
+
if (content.description) return content.description;
|
|
11
|
+
if (content.title) return content.title;
|
|
12
|
+
if (content.text) return content.text;
|
|
13
|
+
if (content.msg) return content.msg;
|
|
14
|
+
if (content.href) return content.href;
|
|
15
|
+
if (content.content && typeof content.content === "string") return content.content;
|
|
16
|
+
try {
|
|
17
|
+
const str = JSON.stringify(content);
|
|
18
|
+
if (str.length < 200) return `[${msgType || "object"}: ${str}]`;
|
|
19
|
+
} catch {}
|
|
20
|
+
return `[${msgType || "attachment"}]`;
|
|
21
|
+
}
|
|
@@ -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
|
+
});
|