@warriorteam/zalo-personal
Version:
Unofficial Zalo Personal API for JavaScript - A powerful library for interacting with Zalo personal accounts with URL attachment support, auto-reply, product catalog, and business features
691 lines (687 loc) • 24.5 kB
JavaScript
'use strict';
var cryptojs = require('crypto-js');
var crypto = require('node:crypto');
var fs = require('node:fs');
var path = require('node:path');
var pako = require('pako');
var SparkMD5 = require('spark-md5');
var toughCookie = require('tough-cookie');
var JSONBig = require('json-bigint');
var context = require('./context.cjs');
var ZaloApiError = require('./Errors/ZaloApiError.cjs');
var ZaloApiMissingImageMetadataGetter = require('./Errors/ZaloApiMissingImageMetadataGetter.cjs');
var FriendEvent = require('./models/FriendEvent.cjs');
var GroupEvent = require('./models/GroupEvent.cjs');
const isBun = typeof Bun !== "undefined";
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
/**
* Get signed key for API requests.
*
* @param type
* @param params
* @returns MD5 hash
*
*/
function getSignKey(type, params) {
const n = [];
for (const s in params) {
if (hasOwn(params, s)) {
n.push(s);
}
}
n.sort();
let a = "zsecure" + type;
for (let s = 0; s < n.length; s++)
a += params[n[s]];
return cryptojs.MD5(a).toString();
}
/**
*
* @param baseURL
* @param params
* @param apiVersion automatically add zalo api version to url params
* @returns
*
*/
function makeURL(ctx, baseURL, params = {}, apiVersion = true) {
const url = new URL(baseURL);
for (const key in params) {
if (hasOwn(params, key)) {
url.searchParams.append(key, params[key].toString());
}
}
if (apiVersion) {
if (!url.searchParams.has("zpw_ver"))
url.searchParams.set("zpw_ver", ctx.API_VERSION.toString());
if (!url.searchParams.has("zpw_type"))
url.searchParams.set("zpw_type", ctx.API_TYPE.toString());
}
return url.toString();
}
class ParamsEncryptor {
constructor({ type, imei, firstLaunchTime }) {
this.zcid = null;
this.enc_ver = "v2";
this.zcid = null;
this.encryptKey = null;
this.createZcid(type, imei, firstLaunchTime);
this.zcid_ext = ParamsEncryptor.randomString();
this.createEncryptKey();
}
getEncryptKey() {
if (!this.encryptKey)
throw new ZaloApiError.ZaloApiError("getEncryptKey: didn't create encryptKey yet");
return this.encryptKey;
}
createZcid(type, imei, firstLaunchTime) {
if (!type || !imei || !firstLaunchTime)
throw new ZaloApiError.ZaloApiError("createZcid: missing params");
const msg = `${type},${imei},${firstLaunchTime}`;
const s = ParamsEncryptor.encodeAES("3FC4F0D2AB50057BCE0D90D9187A22B1", msg, "hex", true);
this.zcid = s;
}
createEncryptKey(e = 0) {
const t = (e, t) => {
const { even: n } = ParamsEncryptor.processStr(e), { even: a, odd: s } = ParamsEncryptor.processStr(t);
if (!n || !a || !s)
return !1;
const i = n.slice(0, 8).join("") + a.slice(0, 12).join("") + s.reverse().slice(0, 12).join("");
return (this.encryptKey = i), !0;
};
if (!this.zcid || !this.zcid_ext)
throw new ZaloApiError.ZaloApiError("createEncryptKey: zcid or zcid_ext is null");
try {
const n = cryptojs.MD5(this.zcid_ext).toString().toUpperCase();
if (t(n, this.zcid) || !(e < 3))
return !1;
this.createEncryptKey(e + 1);
}
catch (_a) {
if (e < 3)
this.createEncryptKey(e + 1);
}
return !0;
}
getParams() {
return this.zcid
? {
zcid: this.zcid,
zcid_ext: this.zcid_ext,
enc_ver: this.enc_ver,
}
: null;
}
static processStr(e) {
if (!e || "string" != typeof e)
return {
even: null,
odd: null,
};
const [t, n] = [...e].reduce((e, t, n) => (e[n % 2].push(t), e), [[], []]);
return {
even: t,
odd: n,
};
}
static randomString(e, t) {
const n = e || 6, a = t && e && t > e ? t : 12;
let s = Math.floor(Math.random() * (a - n + 1)) + n;
if (s > 12) {
let e = "";
for (; s > 0;) {
e += Math.random()
.toString(16)
.substr(2, s > 12 ? 12 : s);
s -= 12;
}
return e;
}
return Math.random().toString(16).substr(2, s);
}
static encodeAES(e, message, type, uppercase, s = 0) {
if (!message)
return null;
try {
{
const encoder = "hex" == type ? cryptojs.enc.Hex : cryptojs.enc.Base64;
const key = cryptojs.enc.Utf8.parse(e);
const cfg = {
words: [0, 0, 0, 0],
sigBytes: 16,
};
const encrypted = cryptojs.AES.encrypt(message, key, {
iv: cfg,
mode: cryptojs.mode.CBC,
padding: cryptojs.pad.Pkcs7,
}).ciphertext.toString(encoder);
return uppercase ? encrypted.toUpperCase() : encrypted;
}
}
catch (_a) {
return s < 3 ? ParamsEncryptor.encodeAES(e, message, type, uppercase, s + 1) : null;
}
}
}
function decryptResp(key, data) {
let n = null;
try {
n = decodeRespAES(key, data);
const parsed = JSON.parse(n);
return parsed;
}
catch (_a) {
return n;
}
}
function decodeRespAES(key, data) {
data = decodeURIComponent(data);
const parsedKey = cryptojs.enc.Utf8.parse(key);
const n = {
words: [0, 0, 0, 0],
sigBytes: 16,
};
return cryptojs.AES.decrypt({
ciphertext: cryptojs.enc.Base64.parse(data),
}, parsedKey, {
iv: n,
mode: cryptojs.mode.CBC,
padding: cryptojs.pad.Pkcs7,
}).toString(cryptojs.enc.Utf8);
}
function decodeBase64ToBuffer(data) {
return Buffer.from(data, "base64");
}
function decodeUnit8Array(data) {
try {
return new TextDecoder().decode(data);
}
catch (_a) {
return null;
}
}
function encodeAES(secretKey, data, t = 0) {
try {
const key = cryptojs.enc.Base64.parse(secretKey);
return cryptojs.AES.encrypt(data, key, {
iv: cryptojs.enc.Hex.parse("00000000000000000000000000000000"),
mode: cryptojs.mode.CBC,
padding: cryptojs.pad.Pkcs7,
}).ciphertext.toString(cryptojs.enc.Base64);
}
catch (_a) {
return t < 3 ? encodeAES(secretKey, data, t + 1) : null;
}
}
function decodeAES(secretKey, data, t = 0) {
try {
data = decodeURIComponent(data);
const key = cryptojs.enc.Base64.parse(secretKey);
return cryptojs.AES.decrypt({
ciphertext: cryptojs.enc.Base64.parse(data),
}, key, {
iv: cryptojs.enc.Hex.parse("00000000000000000000000000000000"),
mode: cryptojs.mode.CBC,
padding: cryptojs.pad.Pkcs7,
}).toString(cryptojs.enc.Utf8);
}
catch (_a) {
return t < 3 ? decodeAES(secretKey, data, t + 1) : null;
}
}
async function getDefaultHeaders(ctx, origin = "https://chat.zalo.me") {
if (!ctx.cookie)
throw new ZaloApiError.ZaloApiError("Cookie is not available");
if (!ctx.userAgent)
throw new ZaloApiError.ZaloApiError("User agent is not available");
return {
Accept: "application/json, text/plain, */*",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9",
"content-type": "application/x-www-form-urlencoded",
Cookie: await ctx.cookie.getCookieString(origin),
Origin: "https://chat.zalo.me",
Referer: "https://chat.zalo.me/",
"User-Agent": ctx.userAgent,
};
}
async function request(ctx, url, options, raw = false) {
var _a, _b;
if (!ctx.cookie)
ctx.cookie = new toughCookie.CookieJar();
const origin = new URL(url).origin;
const defaultHeaders = await getDefaultHeaders(ctx, origin);
if (!raw) {
if (options) {
options.headers = Object.assign(defaultHeaders, options.headers || {});
}
else
options = { headers: defaultHeaders };
}
const _options = Object.assign(Object.assign({}, (options !== null && options !== void 0 ? options : {})), (isBun ? {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
proxy: (_b = (_a = ctx.options.agent) === null || _a === void 0 ? void 0 : _a.proxy) === null || _b === void 0 ? void 0 : _b.href
} : { agent: ctx.options.agent }));
const response = await ctx.options.polyfill(url, _options);
const setCookieRaw = response.headers.get("set-cookie");
if (setCookieRaw && !raw) {
// Use getSetCookie() (Node.js 18+) to properly parse multi-cookie headers.
// The split(", ") method breaks cookies containing Expires dates with commas
// (e.g., "Expires=Wed, 18 Mar 2026..."), causing critical cookies like
// zpsid and zpw_sek to be lost during QR login flow.
let cookieStrings;
if (typeof response.headers.getSetCookie === "function") {
cookieStrings = response.headers.getSetCookie();
}
else {
cookieStrings = setCookieRaw.split(", ");
}
for (const cookie of cookieStrings) {
const parsed = toughCookie.Cookie.parse(cookie);
try {
if (parsed)
await ctx.cookie.setCookie(parsed, parsed.domain != "zalo.me" ? `https://${parsed.domain}` : origin);
}
catch (error) {
logger(ctx).error(error);
}
}
}
const redirectURL = response.headers.get("location");
if (redirectURL) {
const redirectOptions = Object.assign({}, options);
redirectOptions.method = "GET";
if (!raw) {
redirectOptions.headers = new Headers(redirectOptions.headers);
redirectOptions.headers.set("Referer", "https://id.zalo.me/");
}
return await request(ctx, redirectURL, redirectOptions);
}
return response;
}
async function getImageMetaData(ctx, filePath) {
if (!ctx.options.imageMetadataGetter) {
throw new ZaloApiMissingImageMetadataGetter.ZaloApiMissingImageMetadataGetter();
}
const imageData = await ctx.options.imageMetadataGetter(filePath);
if (!imageData) {
throw new ZaloApiError.ZaloApiError("Failed to get image metadata");
}
const fileName = filePath.split("/").pop();
return {
fileName,
totalSize: imageData.size,
width: imageData.width,
height: imageData.height,
};
}
async function getFileSize(filePath) {
return fs.promises.stat(filePath).then((s) => s.size);
}
async function getGifMetaData(ctx, filePath) {
if (!ctx.options.imageMetadataGetter) {
throw new ZaloApiMissingImageMetadataGetter.ZaloApiMissingImageMetadataGetter();
}
const gifData = await ctx.options.imageMetadataGetter(filePath);
if (!gifData) {
throw new ZaloApiError.ZaloApiError("Failed to get gif metadata");
}
const fileName = path.basename(filePath);
return {
fileName,
totalSize: gifData.size,
width: gifData.width,
height: gifData.height,
};
}
async function decodeEventData(parsed, cipherKey) {
if (typeof parsed.data !== "string")
throw new ZaloApiError.ZaloApiError(`Invalid data, expected string but got ${typeof parsed.data}`);
if (typeof parsed.encrypt !== "number")
throw new ZaloApiError.ZaloApiError(`Invalid encrypt type, expected number but got ${typeof parsed.encrypt}`);
if (parsed.encrypt < 0 || parsed.encrypt > 3)
throw new ZaloApiError.ZaloApiError(`Invalid encrypt type, expected 0-3 but got ${parsed.encrypt}`);
const rawData = parsed.data;
const encryptType = parsed.encrypt;
if (encryptType === 0)
return JSON.parse(rawData);
const decodedBuffer = decodeBase64ToBuffer(encryptType === 1 ? rawData : decodeURIComponent(rawData));
let decryptedBuffer = decodedBuffer;
if (encryptType !== 1) {
if (cipherKey && decodedBuffer.length >= 48) {
const algorithm = {
name: "AES-GCM",
iv: decodedBuffer.subarray(0, 16),
tagLength: 128,
additionalData: decodedBuffer.subarray(16, 32),
};
const dataSource = decodedBuffer.subarray(32);
const cryptoKey = await crypto.subtle.importKey("raw", decodeBase64ToBuffer(cipherKey), algorithm, false, [
"decrypt",
]);
decryptedBuffer = await crypto.subtle.decrypt(algorithm, cryptoKey, dataSource);
}
else {
throw new ZaloApiError.ZaloApiError("Invalid data length or missing cipher key");
}
}
const decompressedBuffer = encryptType === 3 ? new Uint8Array(decryptedBuffer) : pako.inflate(decryptedBuffer);
const decodedData = decodeUnit8Array(decompressedBuffer);
if (!decodedData)
return;
return JSONBig.parse(decodedData);
}
async function getMd5LargeFileObject(source, fileSize) {
const buffer = typeof source == "string" ? await fs.promises.readFile(source) : source.data;
return new Promise((resolve) => {
let currentChunk = 0;
const chunkSize = 2097152, // Read in chunks of 2MB
chunks = Math.ceil(fileSize / chunkSize), spark = new SparkMD5.ArrayBuffer();
function loadNext() {
const start = currentChunk * chunkSize, end = start + chunkSize >= fileSize ? fileSize : start + chunkSize;
spark.append(new Uint8Array(buffer.subarray(start, end)).buffer);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
}
else {
resolve({
currentChunk,
data: spark.end(),
});
}
}
loadNext();
});
}
const logger = (ctx) => ({
verbose: (...args) => {
if (ctx.options.logging)
console.log("\x1b[35m🚀 VERBOSE\x1b[0m", ...args);
},
info: (...args) => {
if (ctx.options.logging)
console.log("\x1b[34mINFO\x1b[0m", ...args);
},
warn: (...args) => {
if (ctx.options.logging)
console.log("\x1b[33mWARN\x1b[0m", ...args);
},
error: (...args) => {
if (ctx.options.logging)
console.log("\x1b[31mERROR\x1b[0m", ...args);
},
success: (...args) => {
if (ctx.options.logging)
console.log("\x1b[32mSUCCESS\x1b[0m", ...args);
},
timestamp: (...args) => {
const now = new Date().toISOString();
if (ctx.options.logging)
console.log(`\x1b[90m[${now}]\x1b[0m`, ...args);
},
});
function getClientMessageType(msgType) {
if (msgType === "webchat")
return 1;
if (msgType === "chat.voice")
return 31;
if (msgType === "chat.photo")
return 32;
if (msgType === "chat.sticker")
return 36;
if (msgType === "chat.doodle")
return 37;
if (msgType === "chat.recommended")
return 38;
if (msgType === "chat.link")
return 38; // don't know || if (msgType === "chat.link") return 1;
if (msgType === "chat.video.msg")
return 44; // not sure
if (msgType === "share.file")
return 46;
if (msgType === "chat.gif")
return 49;
if (msgType === "chat.location.new")
return 43;
return 1;
}
function strPadLeft(e, t, n) {
const a = (e = "" + e).length;
return a === n ? e : a > n ? e.slice(-n) : t.repeat(n - a) + e;
}
function formatTime(format, timestamp = Date.now()) {
const date = new Date(timestamp);
// using lib Intl
const options = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
// hour12: false, // true or false is same <(")
};
const formatted = new Intl.DateTimeFormat("vi-VN", options).format(date);
if (format.includes("%H") || format.includes("%d")) {
return format
.replace("%H", date.getHours().toString().padStart(2, "0"))
.replace("%M", date.getMinutes().toString().padStart(2, "0"))
.replace("%S", date.getSeconds().toString().padStart(2, "0"))
.replace("%d", date.getDate().toString().padStart(2, "0"))
.replace("%m", (date.getMonth() + 1).toString().padStart(2, "0"))
.replace("%Y", date.getFullYear().toString());
}
return formatted;
}
function getFullTimeFromMillisecond(e) {
const t = new Date(e);
return (strPadLeft(t.getHours(), "0", 2) +
":" +
strPadLeft(t.getMinutes(), "0", 2) +
" " +
strPadLeft(t.getDate(), "0", 2) +
"/" +
strPadLeft(t.getMonth() + 1, "0", 2) +
"/" +
t.getFullYear());
}
function getFileExtension(e) {
return path.extname(e).slice(1);
}
function getFileName(e) {
return path.basename(e);
}
function removeUndefinedKeys(e) {
for (const t in e)
if (e[t] === undefined)
delete e[t];
return e;
}
function getGroupEventType(act) {
if (act == "join_request")
return GroupEvent.GroupEventType.JOIN_REQUEST;
if (act == "join")
return GroupEvent.GroupEventType.JOIN;
if (act == "leave")
return GroupEvent.GroupEventType.LEAVE;
if (act == "remove_member")
return GroupEvent.GroupEventType.REMOVE_MEMBER;
if (act == "block_member")
return GroupEvent.GroupEventType.BLOCK_MEMBER;
if (act == "update_setting")
return GroupEvent.GroupEventType.UPDATE_SETTING;
if (act == "update_avatar")
return GroupEvent.GroupEventType.UPDATE_AVATAR;
if (act == "update")
return GroupEvent.GroupEventType.UPDATE;
if (act == "new_link")
return GroupEvent.GroupEventType.NEW_LINK;
if (act == "add_admin")
return GroupEvent.GroupEventType.ADD_ADMIN;
if (act == "remove_admin")
return GroupEvent.GroupEventType.REMOVE_ADMIN;
if (act == "new_pin_topic")
return GroupEvent.GroupEventType.NEW_PIN_TOPIC;
if (act == "update_pin_topic")
return GroupEvent.GroupEventType.UPDATE_PIN_TOPIC;
if (act == "update_topic")
return GroupEvent.GroupEventType.UPDATE_TOPIC;
if (act == "update_board")
return GroupEvent.GroupEventType.UPDATE_BOARD;
if (act == "remove_board")
return GroupEvent.GroupEventType.REMOVE_BOARD;
if (act == "reorder_pin_topic")
return GroupEvent.GroupEventType.REORDER_PIN_TOPIC;
if (act == "unpin_topic")
return GroupEvent.GroupEventType.UNPIN_TOPIC;
if (act == "remove_topic")
return GroupEvent.GroupEventType.REMOVE_TOPIC;
if (act == "accept_remind")
return GroupEvent.GroupEventType.ACCEPT_REMIND;
if (act == "reject_remind")
return GroupEvent.GroupEventType.REJECT_REMIND;
if (act == "remind_topic")
return GroupEvent.GroupEventType.REMIND_TOPIC;
return GroupEvent.GroupEventType.UNKNOWN;
}
function getFriendEventType(act) {
if (act == "add")
return FriendEvent.FriendEventType.ADD;
if (act == "remove")
return FriendEvent.FriendEventType.REMOVE;
if (act == "block")
return FriendEvent.FriendEventType.BLOCK;
if (act == "unblock")
return FriendEvent.FriendEventType.UNBLOCK;
if (act == "block_call")
return FriendEvent.FriendEventType.BLOCK_CALL;
if (act == "unblock_call")
return FriendEvent.FriendEventType.UNBLOCK_CALL;
if (act == "req_v2")
return FriendEvent.FriendEventType.REQUEST;
if (act == "reject")
return FriendEvent.FriendEventType.REJECT_REQUEST;
if (act == "undo_req")
return FriendEvent.FriendEventType.UNDO_REQUEST;
if (act == "seen_fr_req")
return FriendEvent.FriendEventType.SEEN_FRIEND_REQUEST;
if (act == "pin_unpin")
return FriendEvent.FriendEventType.PIN_UNPIN;
if (act == "pin_create")
return FriendEvent.FriendEventType.PIN_CREATE;
return FriendEvent.FriendEventType.UNKNOWN;
}
async function handleZaloResponse(ctx, response, isEncrypted = true) {
const result = {
data: null,
error: null,
};
if (!response.ok) {
result.error = {
message: "Request failed with status code " + response.status,
};
return result;
}
try {
const jsonData = await response.json();
if (jsonData.error_code != 0) {
result.error = {
message: jsonData.error_message,
code: jsonData.error_code,
};
return result;
}
const decodedData = isEncrypted ? JSON.parse(decodeAES(ctx.secretKey, jsonData.data)) : jsonData;
if (decodedData.error_code != 0) {
result.error = {
message: decodedData.error_message,
code: decodedData.error_code,
};
return result;
}
result.data = decodedData.data;
}
catch (error) {
logger(ctx).error("Failed to parse response data:", error);
result.error = {
message: "Failed to parse response data",
};
}
return result;
}
async function resolveResponse(ctx, res, cb, isEncrypted) {
const result = await handleZaloResponse(ctx, res, isEncrypted);
if (result.error)
throw new ZaloApiError.ZaloApiError(result.error.message, result.error.code);
if (cb)
return cb(result);
return result.data;
}
function apiFactory() {
return (callback) => {
return (ctx, api) => {
if (!context.isContextSession(ctx))
throw new ZaloApiError.ZaloApiError("Invalid context " + JSON.stringify(ctx, null, 2));
const utils = {
makeURL(baseURL, params, apiVersion) {
return makeURL(ctx, baseURL, params, apiVersion);
},
encodeAES(data, t) {
return encodeAES(ctx.secretKey, data, t);
},
request(url, options, raw) {
return request(ctx, url, options, raw);
},
logger: logger(ctx),
resolve: (res, cb, isEncrypted) => resolveResponse(ctx, res, cb, isEncrypted),
};
return callback(api, ctx, utils);
};
};
}
function generateZaloUUID(userAgent) {
return crypto.randomUUID() + "-" + cryptojs.MD5(userAgent).toString();
}
/**
* Encrypts a 4-digit PIN to a 32-character hex string
* @param pin 4-digit PIN number
* @returns 32-character hex string
*/
function encryptPin(pin) {
return crypto.createHash("md5").update(pin).digest("hex");
}
exports.ParamsEncryptor = ParamsEncryptor;
exports.apiFactory = apiFactory;
exports.decodeAES = decodeAES;
exports.decodeBase64ToBuffer = decodeBase64ToBuffer;
exports.decodeEventData = decodeEventData;
exports.decodeUnit8Array = decodeUnit8Array;
exports.decryptResp = decryptResp;
exports.encodeAES = encodeAES;
exports.encryptPin = encryptPin;
exports.formatTime = formatTime;
exports.generateZaloUUID = generateZaloUUID;
exports.getClientMessageType = getClientMessageType;
exports.getDefaultHeaders = getDefaultHeaders;
exports.getFileExtension = getFileExtension;
exports.getFileName = getFileName;
exports.getFileSize = getFileSize;
exports.getFriendEventType = getFriendEventType;
exports.getFullTimeFromMillisecond = getFullTimeFromMillisecond;
exports.getGifMetaData = getGifMetaData;
exports.getGroupEventType = getGroupEventType;
exports.getImageMetaData = getImageMetaData;
exports.getMd5LargeFileObject = getMd5LargeFileObject;
exports.getSignKey = getSignKey;
exports.handleZaloResponse = handleZaloResponse;
exports.hasOwn = hasOwn;
exports.isBun = isBun;
exports.logger = logger;
exports.makeURL = makeURL;
exports.removeUndefinedKeys = removeUndefinedKeys;
exports.request = request;
exports.resolveResponse = resolveResponse;
exports.strPadLeft = strPadLeft;