UNPKG

@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

708 lines (707 loc) 24.8 kB
import cryptojs from "crypto-js"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import pako from "pako"; import SparkMD5 from "spark-md5"; import toughCookie from "tough-cookie"; import JSONBig from "json-bigint"; import { isContextSession } from "./context.js"; import { ZaloApiError, ZaloApiMissingImageMetadataGetter } from "./Errors/index.js"; import { FriendEventType } from "./models/FriendEvent.js"; import { GroupEventType } from "./models/GroupEvent.js"; export const isBun = typeof Bun !== "undefined"; export function hasOwn(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); } /** * Get signed key for API requests. * * @param type * @param params * @returns MD5 hash * */ export 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 * */ export 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(); } export 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("getEncryptKey: didn't create encryptKey yet"); return this.encryptKey; } createZcid(type, imei, firstLaunchTime) { if (!type || !imei || !firstLaunchTime) throw new 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("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; } } } export 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); } export function decodeBase64ToBuffer(data) { return Buffer.from(data, "base64"); } export function decodeUnit8Array(data) { try { return new TextDecoder().decode(data); } catch (_a) { return null; } } export 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; } } export 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; } } export async function getDefaultHeaders(ctx, origin = "https://chat.zalo.me") { if (!ctx.cookie) throw new ZaloApiError("Cookie is not available"); if (!ctx.userAgent) throw new 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, }; } export 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; } export async function getImageMetaData(ctx, filePath) { if (!ctx.options.imageMetadataGetter) { throw new ZaloApiMissingImageMetadataGetter(); } const imageData = await ctx.options.imageMetadataGetter(filePath); if (!imageData) { throw new ZaloApiError("Failed to get image metadata"); } const fileName = filePath.split("/").pop(); return { fileName, totalSize: imageData.size, width: imageData.width, height: imageData.height, }; } export async function getFileSize(filePath) { return fs.promises.stat(filePath).then((s) => s.size); } export async function getGifMetaData(ctx, filePath) { if (!ctx.options.imageMetadataGetter) { throw new ZaloApiMissingImageMetadataGetter(); } const gifData = await ctx.options.imageMetadataGetter(filePath); if (!gifData) { throw new ZaloApiError("Failed to get gif metadata"); } const fileName = path.basename(filePath); return { fileName, totalSize: gifData.size, width: gifData.width, height: gifData.height, }; } export async function decodeEventData(parsed, cipherKey) { if (typeof parsed.data !== "string") throw new ZaloApiError(`Invalid data, expected string but got ${typeof parsed.data}`); if (typeof parsed.encrypt !== "number") throw new ZaloApiError(`Invalid encrypt type, expected number but got ${typeof parsed.encrypt}`); if (parsed.encrypt < 0 || parsed.encrypt > 3) throw new 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("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); } export 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(); }); } export 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); }, }); export 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; } export function strPadLeft(e, t, n) { const a = (e = "" + e).length; return a === n ? e : a > n ? e.slice(-n) : t.repeat(n - a) + e; } export 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; } export 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()); } export function getFileExtension(e) { return path.extname(e).slice(1); } export function getFileName(e) { return path.basename(e); } export function removeUndefinedKeys(e) { for (const t in e) if (e[t] === undefined) delete e[t]; return e; } export function getGroupEventType(act) { if (act == "join_request") return GroupEventType.JOIN_REQUEST; if (act == "join") return GroupEventType.JOIN; if (act == "leave") return GroupEventType.LEAVE; if (act == "remove_member") return GroupEventType.REMOVE_MEMBER; if (act == "block_member") return GroupEventType.BLOCK_MEMBER; if (act == "update_setting") return GroupEventType.UPDATE_SETTING; if (act == "update_avatar") return GroupEventType.UPDATE_AVATAR; if (act == "update") return GroupEventType.UPDATE; if (act == "new_link") return GroupEventType.NEW_LINK; if (act == "add_admin") return GroupEventType.ADD_ADMIN; if (act == "remove_admin") return GroupEventType.REMOVE_ADMIN; if (act == "new_pin_topic") return GroupEventType.NEW_PIN_TOPIC; if (act == "update_pin_topic") return GroupEventType.UPDATE_PIN_TOPIC; if (act == "update_topic") return GroupEventType.UPDATE_TOPIC; if (act == "update_board") return GroupEventType.UPDATE_BOARD; if (act == "remove_board") return GroupEventType.REMOVE_BOARD; if (act == "reorder_pin_topic") return GroupEventType.REORDER_PIN_TOPIC; if (act == "unpin_topic") return GroupEventType.UNPIN_TOPIC; if (act == "remove_topic") return GroupEventType.REMOVE_TOPIC; if (act == "accept_remind") return GroupEventType.ACCEPT_REMIND; if (act == "reject_remind") return GroupEventType.REJECT_REMIND; if (act == "remind_topic") return GroupEventType.REMIND_TOPIC; return GroupEventType.UNKNOWN; } export function getFriendEventType(act) { if (act == "add") return FriendEventType.ADD; if (act == "remove") return FriendEventType.REMOVE; if (act == "block") return FriendEventType.BLOCK; if (act == "unblock") return FriendEventType.UNBLOCK; if (act == "block_call") return FriendEventType.BLOCK_CALL; if (act == "unblock_call") return FriendEventType.UNBLOCK_CALL; if (act == "req_v2") return FriendEventType.REQUEST; if (act == "reject") return FriendEventType.REJECT_REQUEST; if (act == "undo_req") return FriendEventType.UNDO_REQUEST; if (act == "seen_fr_req") return FriendEventType.SEEN_FRIEND_REQUEST; if (act == "pin_unpin") return FriendEventType.PIN_UNPIN; if (act == "pin_create") return FriendEventType.PIN_CREATE; return FriendEventType.UNKNOWN; } export 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; } export async function resolveResponse(ctx, res, cb, isEncrypted) { const result = await handleZaloResponse(ctx, res, isEncrypted); if (result.error) throw new ZaloApiError(result.error.message, result.error.code); if (cb) return cb(result); return result.data; } export function apiFactory() { return (callback) => { return (ctx, api) => { if (!isContextSession(ctx)) throw new 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); }; }; } export 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 */ export function encryptPin(pin) { return crypto.createHash("md5").update(pin).digest("hex"); } /** * Decrypts a 32-character hex string back to 4-digit PIN * Note: This is a one-way hash, so we can only verify if a PIN matches the hash * @param encryptedPin 32-character hex string * @param pin 4-digit PIN to verify * @returns true if the PIN matches the hash * * @example * const encryptedPin = (await api.getHiddenConversations()).pin; * checking pin created.. * const isValid = validatePin(encryptedPin, "1234"); // true if pin created is 1234 * const isInvalid = validatePin(encryptedPin, "5678"); // false if not pin created is 5678 */ export function validatePin(encryptedPin, pin) { const hash = crypto.createHash("md5").update(pin).digest("hex"); return hash === encryptedPin; } /** * Converts a hex color code to a negative color number used by Zalo API * @param hex Hex color code (e.g. '#00FF00' or '00FF00') * @returns Negative color number (e.g. -16711936) * * @example * const negativeColor = hexToNegativeColor('#00FF00'); // Result: -16711936 */ export function hexToNegativeColor(hex) { if (!hex.startsWith("#")) { hex = "#" + hex; } // rgb no alpha // const decimal = parseInt(hex.slice(1), 16); // return decimal - 4294967296; // rgb with alpha let hexValue = hex.slice(1); if (hexValue.length === 6) { hexValue = "FF" + hexValue; } const decimal = parseInt(hexValue, 16); return decimal > 0x7fffffff ? decimal - 4294967296 : decimal; } /** * Converts a negative color number from Zalo API to hex color code * @param negativeColor Negative color number (e.g. -16711936) * @returns Hex color code (e.g. '#00FF00') * * @example * const hexColor = negativeColorToHex(-16711936); // Result: '#00FF00' */ export function negativeColorToHex(negativeColor) { // Add 2^32 to get positive number const positiveColor = negativeColor + 4294967296; // return "#" + positiveColor.toString(16).padStart(6, "0"); // rgb no alpha return "#" + positiveColor.toString(16).slice(-6).padStart(6, "0"); // rgb with alpha }