UNPKG

zca-js

Version:

Unofficial Zalo API for JavaScript

440 lines (436 loc) 21.1 kB
'use strict'; var EventEmitter = require('events'); var WebSocket = require('ws'); var FriendEvent = require('../models/FriendEvent.cjs'); var GroupEvent = require('../models/GroupEvent.cjs'); require('../models/AutoReply.cjs'); require('../models/Board.cjs'); var DeliveredMessage = require('../models/DeliveredMessage.cjs'); var Enum = require('../models/Enum.cjs'); require('../models/Group.cjs'); var Message = require('../models/Message.cjs'); var Reaction = require('../models/Reaction.cjs'); require('../models/Reminder.cjs'); var SeenMessage = require('../models/SeenMessage.cjs'); var Typing = require('../models/Typing.cjs'); var Undo = require('../models/Undo.cjs'); require('../models/ZBusiness.cjs'); var utils = require('../utils.cjs'); var ZaloApiError = require('../Errors/ZaloApiError.cjs'); exports.CloseReason = void 0; (function (CloseReason) { CloseReason[CloseReason["ManualClosure"] = 1000] = "ManualClosure"; CloseReason[CloseReason["AbnormalClosure"] = 1006] = "AbnormalClosure"; CloseReason[CloseReason["DuplicateConnection"] = 3000] = "DuplicateConnection"; CloseReason[CloseReason["KickConnection"] = 3003] = "KickConnection"; })(exports.CloseReason || (exports.CloseReason = {})); class Listener extends EventEmitter { constructor(ctx, urls) { super(); this.ctx = ctx; this.urls = urls; this.id = 0; if (!ctx.cookie) throw new ZaloApiError.ZaloApiError("Cookie is not available"); if (!ctx.userAgent) throw new ZaloApiError.ZaloApiError("User agent is not available"); this.wsURL = utils.makeURL(this.ctx, this.urls[0], { t: Date.now(), }); this.retryCount = {}; this.rotateCount = 0; for (const retry in ctx.settings.features.socket.retries) { const { times, max } = ctx.settings.features.socket.retries[retry]; this.retryCount[retry] = { count: 0, max: max || 0, times: typeof times === "number" ? [times] : times, }; } this.cookie = ctx.cookie.getCookieStringSync("https://chat.zalo.me"); this.userAgent = ctx.userAgent; this.selfListen = ctx.options.selfListen; this.ws = null; this.onConnectedCallback = () => { }; this.onClosedCallback = () => { }; this.onErrorCallback = () => { }; this.onMessageCallback = () => { }; } /** * @deprecated Use `on` method instead */ onConnected(cb) { this.onConnectedCallback = cb; } /** * @deprecated Use `on` method instead */ onClosed(cb) { this.onClosedCallback = cb; } /** * @deprecated Use `on` method instead */ onError(cb) { this.onErrorCallback = cb; } /** * @deprecated Use `on` method instead */ onMessage(cb) { this.onMessageCallback = cb; } canRetry(code) { if (!this.ctx.settings.features.socket.close_and_retry_codes.includes(code)) return false; if (this.retryCount[code.toString()].count >= this.retryCount[code.toString()].max) return false; this.retryCount[code.toString()].count++; const { count, max, times } = this.retryCount[code.toString()]; const retryTime = count - 1 < times.length ? times[count - 1] : times[times.length - 1]; utils.logger(this.ctx).verbose(`Retry for code ${code} in ${retryTime}ms (${count}/${max})`); return retryTime; } shouldRotate(code) { if (!this.ctx.settings.features.socket.rotate_error_codes.includes(code)) return false; if (this.rotateCount >= this.urls.length - 1) return false; return true; } rotateEndpoint() { this.rotateCount++; this.wsURL = utils.makeURL(this.ctx, this.urls[this.rotateCount], { t: Date.now(), }); utils.logger(this.ctx).verbose(`Rotating endpoint to ${this.wsURL}`); } start({ retryOnClose = false } = {}) { if (this.ws) throw new ZaloApiError.ZaloApiError("Already started"); const ws = new WebSocket(this.wsURL, { headers: { "accept-encoding": "gzip, deflate, br, zstd", "accept-language": "en-US,en;q=0.9", "cache-control": "no-cache", connection: "Upgrade", host: new URL(this.wsURL).host, origin: "https://chat.zalo.me", prgama: "no-cache", "sec-websocket-extensions": "permessage-deflate; client_max_window_bits", "sec-websocket-version": "13", upgrade: "websocket", "user-agent": this.userAgent, cookie: this.cookie, }, agent: this.ctx.options.agent }); this.ws = ws; ws.onopen = () => { this.onConnectedCallback(); this.emit("connected"); }; ws.onclose = (event) => { this.reset(); this.emit("disconnected", event.code, event.reason); const retry = retryOnClose && this.canRetry(event.code); if (retry && retryOnClose) { const shouldRotate = this.shouldRotate(event.code); if (shouldRotate) { this.rotateEndpoint(); } setTimeout(() => { this.start({ retryOnClose: true }); }, retry); } else { this.onClosedCallback(event.code, event.reason); this.emit("closed", event.code, event.reason); } }; ws.onerror = (event) => { this.onErrorCallback(event); this.emit("error", event); }; ws.onmessage = async (event) => { const { data } = event; if (!(data instanceof Buffer)) return; const encodedHeader = data.subarray(0, 4); const [version, cmd, subCmd] = getHeader(encodedHeader); try { const dataToDecode = data.subarray(4); const decodedData = new TextDecoder("utf-8").decode(dataToDecode); if (decodedData.length == 0) return; const parsed = JSON.parse(decodedData); if (version == 1 && cmd == 1 && subCmd == 1 && utils.hasOwn(parsed, "key")) { this.cipherKey = parsed.key; this.emit("cipher_key", parsed.key); if (this.pingInterval) clearInterval(this.pingInterval); const ping = () => { const payload = { version: 1, cmd: 2, subCmd: 1, data: { eventId: Date.now() }, }; this.sendWs(payload, false); }; this.pingInterval = setInterval(() => { ping(); }, this.ctx.settings.features.socket.ping_interval); } if (version == 1 && cmd == 501 && subCmd == 0) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { msgs } = parsedData; for (const msg of msgs) { if (typeof msg.content == "object" && utils.hasOwn(msg.content, "deleteMsg")) { const undoObject = new Undo.Undo(this.ctx.uid, msg, false); if (undoObject.isSelf && !this.selfListen) continue; this.emit("undo", undoObject); } else { const messageObject = new Message.UserMessage(this.ctx.uid, msg); if (messageObject.isSelf && !this.selfListen) continue; this.onMessageCallback(messageObject); this.emit("message", messageObject); } } } if (version == 1 && cmd == 521 && subCmd == 0) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { groupMsgs } = parsedData; for (const msg of groupMsgs) { if (typeof msg.content == "object" && utils.hasOwn(msg.content, "deleteMsg")) { const undoObject = new Undo.Undo(this.ctx.uid, msg, true); if (undoObject.isSelf && !this.selfListen) continue; this.emit("undo", undoObject); } else { const messageObject = new Message.GroupMessage(this.ctx.uid, msg); if (messageObject.isSelf && !this.selfListen) continue; this.onMessageCallback(messageObject); this.emit("message", messageObject); } } } if (version == 1 && cmd == 601 && subCmd == 0) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { controls } = parsedData; for (const control of controls) { if (control.content.act_type == "file_done") { const data = { fileUrl: control.content.data.url, fileId: control.content.fileId, }; const uploadCallback = this.ctx.uploadCallbacks.get(String(control.content.fileId)); if (uploadCallback) uploadCallback(data); this.ctx.uploadCallbacks.delete(String(control.content.fileId)); this.emit("upload_attachment", data); } else if (control.content.act_type == "group") { // 31/08/2024 // for some reason, Zalo send both join and join_reject event when admin approve join requests // Zalo itself doesn't seem to handle this properly either, so we gonna ignore the join_reject event if (control.content.act == "join_reject") continue; const groupEventData = typeof control.content.data == "string" ? JSON.parse(control.content.data) : control.content.data; const groupEvent = GroupEvent.initializeGroupEvent(this.ctx.uid, groupEventData, utils.getGroupEventType(control.content.act), control.content.act); if (groupEvent.isSelf && !this.selfListen) continue; this.emit("group_event", groupEvent); } else if (control.content.act_type == "fr") { // 28/02/2025 // Zalo send both req and req_v2 event when user send friend request // Zalo itself doesn't seem to handle this properly either, so we gonna ignore the req event if (control.content.act == "req") continue; const friendEventData = typeof control.content.data == "string" ? JSON.parse(control.content.data) : control.content.data; // Handles the case when act is "pin_create" and params is a string if (typeof friendEventData == "object" && "topic" in friendEventData && typeof friendEventData.topic == "object" && "params" in friendEventData.topic) { friendEventData.topic.params = JSON.parse(`${friendEventData.topic.params}`); } const friendEvent = FriendEvent.initializeFriendEvent(this.ctx.uid, typeof friendEventData == "number" ? control.content.data : friendEventData, utils.getFriendEventType(control.content.act)); if (friendEvent.isSelf && !this.selfListen) continue; this.emit("friend_event", friendEvent); } } } if (cmd == 612) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { reacts, reactGroups } = parsedData; for (const react of reacts) { react.content = JSON.parse(react.content); const reactionObject = new Reaction.Reaction(this.ctx.uid, react, false); if (reactionObject.isSelf && !this.selfListen) continue; this.emit("reaction", reactionObject); } for (const reactGroup of reactGroups) { reactGroup.content = JSON.parse(reactGroup.content); const reactionObject = new Reaction.Reaction(this.ctx.uid, reactGroup, true); if (reactionObject.isSelf && !this.selfListen) continue; this.emit("reaction", reactionObject); } } if (cmd == 610 || cmd == 611) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const isGroup = cmd == 611; const reacts = parsedData[isGroup ? "reactGroups" : "reacts"]; const reactionObjects = reacts.map((react) => new Reaction.Reaction(this.ctx.uid, react, isGroup)); this.emit("old_reactions", reactionObjects, isGroup); } if (cmd == 510 && subCmd == 1) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const msgs = parsedData.msgs; const responseMsgs = msgs.map((msg) => new Message.UserMessage(this.ctx.uid, msg)); this.emit("old_messages", responseMsgs, Enum.ThreadType.User); } if (cmd == 511 && subCmd == 1) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const groupMsgs = parsedData.groupMsgs; const responseMsgs = groupMsgs.map((msg) => new Message.GroupMessage(this.ctx.uid, msg)); this.emit("old_messages", responseMsgs, Enum.ThreadType.Group); } if (cmd == 602 && subCmd == 0) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { actions } = parsedData; for (const action of actions) { if (action.act_type == "typing") { const data = JSON.parse(`{${action.data}}`); if (action.act == "typing") { const typingObject = new Typing.UserTyping(data); this.emit("typing", typingObject); } else if (action.act == "gtyping") { // 26/02/2025 // For a group with only two people, Zalo doesn't send a typing event. const typingObject = new Typing.GroupTyping(data); this.emit("typing", typingObject); } } } } if (cmd == 502 && subCmd == 0) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { delivereds: deliveredMsgs, seens: seenMsgs } = parsedData; if (Array.isArray(deliveredMsgs) && deliveredMsgs.length > 0) { const deliveredObjects = deliveredMsgs.map((delivered) => new DeliveredMessage.UserDeliveredMessage(delivered)); this.emit("delivered_messages", deliveredObjects); } if (Array.isArray(seenMsgs) && seenMsgs.length > 0) { const seenObjects = seenMsgs.map((seen) => new SeenMessage.UserSeenMessage(seen)); this.emit("seen_messages", seenObjects); } } if (cmd == 522 && subCmd == 0) { const parsedData = (await utils.decodeEventData(parsed, this.cipherKey)).data; const { delivereds: deliveredMsgs, groupSeens: groupSeenMsgs } = parsedData; if (Array.isArray(deliveredMsgs) && deliveredMsgs.length > 0) { let deliveredObjects = deliveredMsgs.map((delivered) => new DeliveredMessage.GroupDeliveredMessage(this.ctx.uid, delivered)); if (!this.selfListen) deliveredObjects = deliveredObjects.filter((delivered) => !delivered.isSelf); this.emit("delivered_messages", deliveredObjects); } if (Array.isArray(groupSeenMsgs) && groupSeenMsgs.length > 0) { let seenObjects = groupSeenMsgs.map((seen) => new SeenMessage.GroupSeenMessage(this.ctx.uid, seen)); if (!this.selfListen) seenObjects = seenObjects.filter((seen) => !seen.isSelf); this.emit("seen_messages", seenObjects); } } if (version == 1 && cmd == 3000 && subCmd == 0) { utils.logger(this.ctx).error(); utils.logger(this.ctx).error("Another connection is opened, closing this one"); utils.logger(this.ctx).error(); if (ws.readyState !== WebSocket.CLOSED) ws.close(exports.CloseReason.DuplicateConnection); } } catch (error) { this.onErrorCallback(error); this.emit("error", error); } }; } stop() { if (this.ws) { this.ws.close(exports.CloseReason.ManualClosure); this.reset(); } } sendWs(payload, requireId = true) { if (this.ws) { if (requireId) payload.data["req_id"] = `req_${this.id++}`; const encodedData = new TextEncoder().encode(JSON.stringify(payload.data)); const dataLength = encodedData.length; const data = new DataView(Buffer.alloc(4 + dataLength).buffer); data.setUint8(0, payload.version); data.setInt32(1, payload.cmd, true); data.setInt8(3, payload.subCmd); encodedData.forEach((e, i) => { data.setUint8(4 + i, e); }); this.ws.send(data); } } /** * Request old messages * * @param lastMsgId */ requestOldMessages(threadType, lastMsgId = null) { const payload = { version: 1, cmd: threadType === Enum.ThreadType.User ? 510 : 511, subCmd: 1, data: { first: true, lastId: lastMsgId, preIds: [] }, }; this.sendWs(payload); } /** * Request old messages * * @param lastMsgId */ requestOldReactions(threadType, lastMsgId = null) { const payload = { version: 1, cmd: threadType === Enum.ThreadType.User ? 610 : 611, subCmd: 1, data: { first: true, lastId: lastMsgId, preIds: [] }, }; this.sendWs(payload); } reset() { this.ws = null; this.cipherKey = undefined; if (this.pingInterval) clearInterval(this.pingInterval); } } function getHeader(buffer) { if (buffer.byteLength < 4) { throw new ZaloApiError.ZaloApiError("Invalid header"); } return [buffer[0], buffer.readUInt16LE(1), buffer[3]]; } exports.Listener = Listener;