From d386f2db8be1118e9e35df77b3c6f5eb44377f9c Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Fri, 17 Dec 2021 20:59:43 +0530 Subject: [PATCH] Implement Legacy Socket Capability in MD Code (#1041) * feat: add legacy connection * fix: merge conflict errors * feat: functional legacy socket --- Example/example-legacy.ts | 64 ++++ package.json | 1 + src/Defaults/index.ts | 21 +- src/LegacySocket/auth.ts | 269 ++++++++++++++++ src/LegacySocket/chats.ts | 503 +++++++++++++++++++++++++++++ src/LegacySocket/groups.ts | 238 ++++++++++++++ src/LegacySocket/index.ts | 12 + src/LegacySocket/messages.ts | 536 +++++++++++++++++++++++++++++++ src/LegacySocket/socket.ts | 393 ++++++++++++++++++++++ src/Socket/chats.ts | 8 +- src/Socket/messages-send.ts | 59 +--- src/Socket/socket.ts | 2 +- src/Types/Auth.ts | 1 - src/Types/Chat.ts | 2 +- src/Types/Events.ts | 56 ++++ src/Types/Legacy.ts | 83 +++++ src/Types/Message.ts | 2 +- src/Types/Socket.ts | 39 +++ src/Types/State.ts | 10 +- src/Types/index.ts | 61 ++-- src/Utils/chat-utils.ts | 2 +- src/Utils/index.ts | 3 +- src/Utils/legacy-msgs.ts | 173 ++++++++++ src/Utils/messages-media.ts | 60 +++- src/WABinary/Legacy/constants.ts | 198 ++++++++++++ src/WABinary/Legacy/index.ts | 337 +++++++++++++++++++ src/WABinary/index.ts | 32 +- src/WABinary/types.ts | 14 + src/index.ts | 5 + 29 files changed, 3056 insertions(+), 128 deletions(-) create mode 100644 Example/example-legacy.ts create mode 100644 src/LegacySocket/auth.ts create mode 100644 src/LegacySocket/chats.ts create mode 100644 src/LegacySocket/groups.ts create mode 100644 src/LegacySocket/index.ts create mode 100644 src/LegacySocket/messages.ts create mode 100644 src/LegacySocket/socket.ts create mode 100644 src/Types/Events.ts create mode 100644 src/Types/Legacy.ts create mode 100644 src/Types/Socket.ts create mode 100644 src/Utils/legacy-msgs.ts create mode 100644 src/WABinary/Legacy/constants.ts create mode 100644 src/WABinary/Legacy/index.ts create mode 100644 src/WABinary/types.ts diff --git a/Example/example-legacy.ts b/Example/example-legacy.ts new file mode 100644 index 0000000..06c6fd0 --- /dev/null +++ b/Example/example-legacy.ts @@ -0,0 +1,64 @@ +import P from "pino" +import { Boom } from "@hapi/boom" +import { makeWALegacySocket, DisconnectReason, AnyMessageContent, delay, useSingleFileLegacyAuthState } from '../src' + +const { state, saveState } = useSingleFileLegacyAuthState('./auth_info.json') + +// start a connection +const startSock = () => { + + const sock = makeWALegacySocket({ + logger: P({ level: 'debug' }), + printQRInTerminal: true, + auth: state + }) + + const sendMessageWTyping = async(msg: AnyMessageContent, jid: string) => { + await sock.presenceSubscribe(jid) + await delay(500) + + await sock.sendPresenceUpdate('composing', jid) + await delay(2000) + + await sock.sendPresenceUpdate('paused', jid) + + await sock.sendWAMessage(jid, msg) + } + + sock.ev.on('messages.upsert', async m => { + console.log(JSON.stringify(m, undefined, 2)) + + const msg = m.messages[0] + if(!msg.key.fromMe && m.type === 'notify') { + console.log('replying to', m.messages[0].key.remoteJid) + await sock!.chatRead(msg.key, 1) + await sendMessageWTyping({ text: 'Hello there!' }, msg.key.remoteJid) + } + + }) + + sock.ev.on('messages.update', m => console.log(m)) + sock.ev.on('presence.update', m => console.log(m)) + sock.ev.on('chats.update', m => console.log(m)) + sock.ev.on('contacts.update', m => console.log(m)) + + sock.ev.on('connection.update', (update) => { + const { connection, lastDisconnect } = update + if(connection === 'close') { + // reconnect if not logged out + if((lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) { + startSock() + } else { + console.log('connection closed') + } + } + + console.log('connection update', update) + }) + // listen for when the auth credentials is updated + sock.ev.on('creds.update', saveState) + + return sock +} + +startSock() \ No newline at end of file diff --git a/package.json b/package.json index ec31289..05b3875 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build:docs": "typedoc", "build:tsc": "tsc", "example": "node --inspect -r ts-node/register Example/example.ts", + "example:legacy": "node --inspect -r ts-node/register Example/example-legacy.ts", "gen-protobuf": "bash src/BinaryNode/GenerateStatics.sh", "browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts" }, diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 844449c..dba9ff9 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -1,5 +1,5 @@ import P from "pino" -import type { MediaType, SocketConfig } from "../Types" +import type { MediaType, SocketConfig, LegacySocketConfig, CommonSocketConfig } from "../Types" import { Browsers } from "../Utils" export const UNAUTHORIZED_CODES = [401, 403, 419] @@ -17,11 +17,11 @@ export const NOISE_WA_HEADER = new Uint8Array([87, 65, 5, 2]) // last is "DICT_V /** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */ export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi -export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { - version: [2, 2146, 9], +const BASE_CONNECTION_CONFIG: CommonSocketConfig = { + version: [2, 2147, 16], browser: Browsers.baileys('Chrome'), - waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', + waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', connectTimeoutMs: 20_000, keepAliveIntervalMs: 25_000, logger: P().child({ class: 'baileys' }), @@ -29,9 +29,22 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { emitOwnEvents: true, defaultQueryTimeoutMs: 60_000, customUploadHosts: [], +} + +export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { + ...BASE_CONNECTION_CONFIG, + waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', getMessage: async() => undefined } +export const DEFAULT_LEGACY_CONNECTION_CONFIG: LegacySocketConfig = { + ...BASE_CONNECTION_CONFIG, + waWebSocketUrl: 'wss://web.whatsapp.com/ws', + phoneResponseTimeMs: 20_000, + expectResponseTimeout: 60_000, + pendingRequestTimeoutMs: 60_000 +} + export const MEDIA_PATH_MAP: { [T in MediaType]: string } = { image: '/mms/image', video: '/mms/video', diff --git a/src/LegacySocket/auth.ts b/src/LegacySocket/auth.ts new file mode 100644 index 0000000..fbae0b4 --- /dev/null +++ b/src/LegacySocket/auth.ts @@ -0,0 +1,269 @@ +import { Boom } from '@hapi/boom' +import EventEmitter from "events" +import { LegacyBaileysEventEmitter, BaileysEventMap, LegacySocketConfig, CurveKeyPair, WAInitResponse, ConnectionState, DisconnectReason, LegacyAuthenticationCreds } from "../Types" +import { newLegacyAuthCreds, promiseTimeout, computeChallengeResponse, validateNewConnection, Curve } from "../Utils" +import { makeSocket } from "./socket" + +const makeAuthSocket = (config: LegacySocketConfig) => { + const { + logger, + version, + browser, + connectTimeoutMs, + pendingRequestTimeoutMs, + printQRInTerminal, + auth: initialAuthInfo + } = config + const ev = new EventEmitter() as LegacyBaileysEventEmitter + + const authInfo = initialAuthInfo || newLegacyAuthCreds() + + const state: ConnectionState = { + legacy: { + phoneConnected: false, + }, + connection: 'connecting', + } + + const socket = makeSocket(config) + const { ws } = socket + let curveKeys: CurveKeyPair + let initTimeout: NodeJS.Timeout + + ws.on('phone-connection', ({ value: phoneConnected }) => { + if(phoneConnected !== state.legacy.phoneConnected) { + updateState({ legacy: { ...state.legacy, phoneConnected } }) + } + }) + // add close listener + ws.on('ws-close', (error: Boom | Error) => { + logger.info({ error }, 'Closed connection to WhatsApp') + initTimeout && clearTimeout(initTimeout) + // if no reconnects occur + // send close event + updateState({ + connection: 'close', + qr: undefined, + lastDisconnect: { + error, + date: new Date() + } + }) + }) + /** Can you login to WA without scanning the QR */ + const canLogin = () => !!authInfo?.encKey && !!authInfo?.macKey + + const updateState = (update: Partial) => { + Object.assign(state, update) + ev.emit('connection.update', update) + } + + /** + * Logs you out from WA + * If connected, invalidates the credentials with the server + */ + const logout = async() => { + if(state.connection === 'open') { + await socket.sendMessage({ + json: ['admin', 'Conn', 'disconnect'], + tag: 'goodbye' + }) + } + // will call state update to close connection + socket?.end( + new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut }) + ) + } + /** Waits for the connection to WA to open up */ + const waitForConnection = async(waitInfinitely: boolean = false) => { + if(state.connection === 'open') return + + let listener: (item: BaileysEventMap['connection.update']) => void + const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs + if(timeout < 0) { + throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) + } + + await ( + promiseTimeout( + timeout, + (resolve, reject) => { + listener = ({ connection, lastDisconnect }) => { + if(connection === 'open') resolve() + else if(connection == 'close') { + reject(lastDisconnect.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })) + } + } + ev.on('connection.update', listener) + } + ) + .finally(() => ( + ev.off('connection.update', listener) + )) + ) + } + + const updateEncKeys = () => { + // update the keys so we can decrypt traffic + socket.updateKeys({ encKey: authInfo!.encKey, macKey: authInfo!.macKey }) + } + + const generateKeysForAuth = async(ref: string, ttl?: number) => { + curveKeys = Curve.generateKeyPair() + const publicKey = Buffer.from(curveKeys.public).toString('base64') + let qrGens = 0 + + const qrLoop = ttl => { + const qr = [ref, publicKey, authInfo.clientID].join(',') + updateState({ qr }) + + initTimeout = setTimeout(async () => { + if(state.connection !== 'connecting') return + + logger.debug('regenerating QR') + try { + // request new QR + const {ref: newRef, ttl: newTTL} = await socket.query({ + json: ['admin', 'Conn', 'reref'], + expect200: true, + longTag: true, + requiresPhoneConnection: false + }) + ttl = newTTL + ref = newRef + } catch (error) { + logger.error({ error }, `error in QR gen`) + if (error.output?.statusCode === 429) { // too many QR requests + socket.end(error) + return + } + } + qrGens += 1 + qrLoop(ttl) + }, ttl || 20_000) // default is 20s, on the off-chance ttl is not present + } + qrLoop(ttl) + } + const onOpen = async() => { + const canDoLogin = canLogin() + const initQuery = (async () => { + const {ref, ttl} = await socket.query({ + json: ['admin', 'init', version, browser, authInfo.clientID, true], + expect200: true, + longTag: true, + requiresPhoneConnection: false + }) as WAInitResponse + + if (!canDoLogin) { + generateKeysForAuth(ref, ttl) + } + })(); + let loginTag: string + if(canDoLogin) { + updateEncKeys() + // if we have the info to restore a closed session + const json = [ + 'admin', + 'login', + authInfo.clientToken, + authInfo.serverToken, + authInfo.clientID, + 'takeover' + ] + loginTag = socket.generateMessageTag(true) + // send login every 10s + const sendLoginReq = () => { + if(state.connection === 'open') { + logger.warn('Received login timeout req when state=open, ignoring...') + return + } + logger.info('sending login request') + socket.sendMessage({ + json, + tag: loginTag + }) + initTimeout = setTimeout(sendLoginReq, 10_000) + } + sendLoginReq() + } + await initQuery + + // wait for response with tag "s1" + let response = await Promise.race( + [ + socket.waitForMessage('s1', false, undefined).promise, + ...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs).promise] : []) + ] + ) + initTimeout && clearTimeout(initTimeout) + initTimeout = undefined + + if(response.status && response.status !== 200) { + throw new Boom(`Unexpected error in login`, { data: response, statusCode: response.status }) + } + // if its a challenge request (we get it when logging in) + if(response[1]?.challenge) { + const json = computeChallengeResponse(response[1].challenge, authInfo) + logger.info('resolving login challenge') + + await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs }) + + response = await socket.waitForMessage('s2', true).promise + } + if(!response || !response[1]) { + throw new Boom('Received unexpected login response', { data: response }) + } + if(response[1].type === 'upgrade_md_prod') { + throw new Boom('Require multi-device edition', { statusCode: DisconnectReason.multideviceMismatch }) + } + // validate the new connection + const {user, auth} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection + const isNewLogin = user.id !== state.legacy!.user?.id + + Object.assign(authInfo, auth) + updateEncKeys() + + logger.info({ user }, 'logged in') + + ev.emit('creds.update', auth) + + updateState({ + connection: 'open', + legacy: { + phoneConnected: true, + user, + }, + isNewLogin, + qr: undefined + }) + } + ws.once('open', async() => { + try { + await onOpen() + } catch(error) { + socket.end(error) + } + }) + + if(printQRInTerminal) { + ev.on('connection.update', async({ qr }) => { + if(qr) { + const QR = await import('qrcode-terminal').catch(err => { + logger.error('QR code terminal not added as dependency') + }) + QR?.generate(qr, { small: true }) + } + }) + } + + return { + ...socket, + ev, + getState: () => state, + getAuthInfo: () => authInfo, + waitForConnection, + canLogin, + logout + } +} +export default makeAuthSocket \ No newline at end of file diff --git a/src/LegacySocket/chats.ts b/src/LegacySocket/chats.ts new file mode 100644 index 0000000..67d8c00 --- /dev/null +++ b/src/LegacySocket/chats.ts @@ -0,0 +1,503 @@ +import { BinaryNode, jidNormalizedUser } from "../WABinary"; +import { Chat, Contact, WAPresence, PresenceData, LegacySocketConfig, WAFlag, WAMetric, WABusinessProfile, ChatModification, WAMessageKey, WAMessageUpdate, BaileysEventMap } from "../Types"; +import { debouncedTimeout, unixTimestampSeconds } from "../Utils/generics"; +import makeAuthSocket from "./auth"; + +const makeChatsSocket = (config: LegacySocketConfig) => { + const { logger } = config + const sock = makeAuthSocket(config) + const { + ev, + ws: socketEvents, + currentEpoch, + setQuery, + query, + sendMessage, + getState + } = sock + + const chatsDebounceTimeout = debouncedTimeout(10_000, () => sendChatsQuery(1)) + + const sendChatsQuery = (epoch: number) => ( + sendMessage({ + json: { + tag: 'query', + attrs: {type: 'chat', epoch: epoch.toString()} + }, + binaryTag: [ WAMetric.queryChat, WAFlag.ignore ] + }) + ) + + const fetchImageUrl = async(jid: string) => { + const response = await query({ + json: ['query', 'ProfilePicThumb', jid], + expect200: false, + requiresPhoneConnection: false + }) + return response.eurl as string | undefined + } + + const executeChatModification = (node: BinaryNode) => { + const { attrs: attributes } = node + const updateType = attributes.type + const jid = jidNormalizedUser(attributes?.jid) + + switch(updateType) { + case 'delete': + ev.emit('chats.delete', [jid]) + break + case 'clear': + if(node.content) { + const ids = (node.content as BinaryNode[]).map( + ({ attrs }) => attrs.index + ) + ev.emit('messages.delete', { keys: ids.map(id => ({ id, remoteJid: jid })) }) + } else { + ev.emit('messages.delete', { jid, all: true }) + } + break + case 'archive': + ev.emit('chats.update', [ { id: jid, archive: true } ]) + break + case 'unarchive': + ev.emit('chats.update', [ { id: jid, archive: false } ]) + break + case 'pin': + ev.emit('chats.update', [ { id: jid, pin: +attributes.pin } ]) + break + case 'star': + case 'unstar': + const starred = updateType === 'star' + const updates: WAMessageUpdate[] = (node.content as BinaryNode[]).map( + ({ attrs }) => ({ + key: { + remoteJid: jid, + id: attrs.index, + fromMe: attrs.owner === 'true' + }, + update: { starred } + }) + ) + ev.emit('messages.update', updates) + break + case 'mute': + if(attributes.mute === '0') { + ev.emit('chats.update', [{ id: jid, mute: null }]) + } else { + ev.emit('chats.update', [{ id: jid, mute: +attributes.mute }]) + } + break + default: + logger.warn({ node }, `received unrecognized chat update`) + break + } + } + + const applyingPresenceUpdate = (update: BinaryNode['attrs']): BaileysEventMap['presence.update'] => { + const id = jidNormalizedUser(update.id) + const participant = jidNormalizedUser(update.participant || update.id) + + const presence: PresenceData = { + lastSeen: update.t ? +update.t : undefined, + lastKnownPresence: update.type as WAPresence + } + return { id, presences: { [participant]: presence } } + } + + ev.on('connection.update', async({ connection }) => { + if(connection !== 'open') return + try { + await Promise.all([ + sendMessage({ + json: { tag: 'query', attrs: {type: 'contacts', epoch: '1'} }, + binaryTag: [ WAMetric.queryContact, WAFlag.ignore ] + }), + sendMessage({ + json: { tag: 'query', attrs: {type: 'status', epoch: '1'} }, + binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ] + }), + sendMessage({ + json: { tag: 'query', attrs: {type: 'quick_reply', epoch: '1'} }, + binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ] + }), + sendMessage({ + json: { tag: 'query', attrs: {type: 'label', epoch: '1'} }, + binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ] + }), + sendMessage({ + json: { tag: 'query', attrs: {type: 'emoji', epoch: '1'} }, + binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ] + }), + sendMessage({ + json: { + tag: 'action', + attrs: { type: 'set', epoch: '1' }, + content: [ + { tag: 'presence', attrs: {type: 'available'} } + ] + }, + binaryTag: [ WAMetric.presence, WAFlag.available ] + }) + ]) + chatsDebounceTimeout.start() + + logger.debug('sent init queries') + } catch(error) { + logger.error(`error in sending init queries: ${error}`) + } + }) + socketEvents.on('CB:response,type:chat', async ({ content: data }: BinaryNode) => { + chatsDebounceTimeout.cancel() + if(Array.isArray(data)) { + const chats = data.map(({ attrs }): Chat => { + return { + id: jidNormalizedUser(attrs.jid), + conversationTimestamp: attrs.t ? +attrs.t : undefined, + unreadCount: +attrs.count, + archive: attrs.archive === 'true' ? true : undefined, + pin: attrs.pin ? +attrs.pin : undefined, + mute: attrs.mute ? +attrs.mute : undefined, + notSpam: !(attrs.spam === 'true'), + name: attrs.name, + ephemeralExpiration: attrs.ephemeral ? +attrs.ephemeral : undefined, + ephemeralSettingTimestamp: attrs.eph_setting_ts ? +attrs.eph_setting_ts : undefined, + readOnly: attrs.read_only === 'true' ? true : undefined, + } + }) + + logger.info(`got ${chats.length} chats`) + ev.emit('chats.set', { chats, messages: [] }) + } + }) + // got all contacts from phone + socketEvents.on('CB:response,type:contacts', async ({ content: data }: BinaryNode) => { + if(Array.isArray(data)) { + const contacts = data.map(({ attrs }): Contact => { + return { + id: jidNormalizedUser(attrs.jid), + name: attrs.name, + notify: attrs.notify, + verifiedName: attrs.vname + } + }) + + logger.info(`got ${contacts.length} contacts`) + ev.emit('contacts.upsert', contacts) + } + }) + // status updates + socketEvents.on('CB:Status,status', json => { + const id = jidNormalizedUser(json[1].id) + ev.emit('contacts.update', [ { id, status: json[1].status } ]) + }) + // User Profile Name Updates + socketEvents.on('CB:Conn,pushname', json => { + const { legacy: { user }, connection } = getState() + if(connection === 'open' && json[1].pushname !== user.name) { + user.name = json[1].pushname + ev.emit('connection.update', { legacy: { ...getState().legacy, user } }) + } + }) + // read updates + socketEvents.on ('CB:action,,read', async ({ content }: BinaryNode) => { + if(Array.isArray(content)) { + const { attrs } = content[0] + + const update: Partial = { + id: jidNormalizedUser(attrs.jid) + } + if (attrs.type === 'false') update.unreadCount = -1 + else update.unreadCount = 0 + + ev.emit('chats.update', [update]) + } + }) + + socketEvents.on('CB:Cmd,type:picture', async json => { + json = json[1] + const id = jidNormalizedUser(json.jid) + const imgUrl = await fetchImageUrl(id).catch(() => '') + + ev.emit('contacts.update', [ { id, imgUrl } ]) + }) + + // chat archive, pin etc. + socketEvents.on('CB:action,,chat', ({ content }: BinaryNode) => { + if(Array.isArray(content)) { + const [node] = content + executeChatModification(node) + } + }) + + socketEvents.on('CB:action,,user', (json: BinaryNode) => { + if(Array.isArray(json.content)) { + const user = json.content[0].attrs + user.id = jidNormalizedUser(user.id) + + //ev.emit('contacts.upsert', [user]) + } + }) + + // presence updates + socketEvents.on('CB:Presence', json => { + const update = applyingPresenceUpdate(json[1]) + ev.emit('presence.update', update) + }) + + // blocklist updates + socketEvents.on('CB:Blocklist', json => { + json = json[1] + const blocklist = json.blocklist + ev.emit('blocklist.set', { blocklist }) + }) + + return { + ...sock, + sendChatsQuery, + fetchImageUrl, + chatRead: async(fromMessage: WAMessageKey, count: number) => { + await setQuery ( + [ + { tag: 'read', + attrs: { + jid: fromMessage.remoteJid, + count: count.toString(), + index: fromMessage.id, + owner: fromMessage.fromMe ? 'true' : 'false' + } + } + ], + [ WAMetric.read, WAFlag.ignore ] + ) + if(config.emitOwnEvents) { + ev.emit ('chats.update', [{ id: fromMessage.remoteJid, unreadCount: count < 0 ? -1 : 0 }]) + } + }, + /** + * Modify a given chat (archive, pin etc.) + * @param jid the ID of the person/group you are modifiying + */ + modifyChat: async(jid: string, modification: ChatModification, chatInfo: Pick, index?: WAMessageKey) => { + let chatAttrs: BinaryNode['attrs'] = { jid: jid } + let data: BinaryNode[] | undefined = undefined + const stamp = unixTimestampSeconds() + + if('archive' in modification) { + chatAttrs.type = modification.archive ? 'archive' : 'unarchive' + } else if('pin' in modification) { + chatAttrs.type = 'pin' + if(modification.pin) { + chatAttrs.pin = stamp.toString() + } else { + chatAttrs.previous = chatInfo.pin!.toString() + } + } else if('mute' in modification) { + chatAttrs.type = 'mute' + if(modification.mute) { + chatAttrs.mute = (stamp + modification.mute).toString() + } else { + chatAttrs.previous = chatInfo.mute!.toString() + } + } else if('clear' in modification) { + chatAttrs.type = 'clear' + chatAttrs.modify_tag = Math.round(Math.random ()*1000000).toString() + if(modification.clear !== 'all') { + data = modification.clear.messages.map(({ id, fromMe }) => ( + { + tag: 'item', + attrs: { owner: (!!fromMe).toString(), index: id } + } + )) + } + } else if('star' in modification) { + chatAttrs.type = modification.star.star ? 'star' : 'unstar' + data = modification.star.messages.map(({ id, fromMe }) => ( + { + tag: 'item', + attrs: { owner: (!!fromMe).toString(), index: id } + } + )) + } + + if(index) { + chatAttrs.index = index.id + chatAttrs.owner = index.fromMe ? 'true' : 'false' + } + + const node = { tag: 'chat', attrs: chatAttrs, content: data } + const response = await setQuery([node], [ WAMetric.chat, WAFlag.ignore ]) + // apply it and emit events + executeChatModification(node) + return response + }, + /** + * Query whether a given number is registered on WhatsApp + * @param str phone number/jid you want to check for + * @returns undefined if the number doesn't exists, otherwise the correctly formatted jid + */ + isOnWhatsApp: async (str: string) => { + const { status, jid, biz } = await query({ + json: ['query', 'exist', str], + requiresPhoneConnection: false + }) + if (status === 200) { + return { + exists: true, + jid: jidNormalizedUser(jid), + isBusiness: biz as boolean + } + } + }, + /** + * Tell someone about your presence -- online, typing, offline etc. + * @param jid the ID of the person/group who you are updating + * @param type your presence + */ + sendPresenceUpdate: ( type: WAPresence, jid: string | undefined) => ( + sendMessage({ + binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does + json: { + tag: 'action', + attrs: { epoch: currentEpoch().toString(), type: 'set' }, + content: [ + { + tag: 'presence', + attrs: { type: type, to: jid } + } + ] + } + }) + ), + /** + * Request updates on the presence of a user + * this returns nothing, you'll receive updates in chats.update event + * */ + presenceSubscribe: async (jid: string) => ( + sendMessage({ json: ['action', 'presence', 'subscribe', jid] }) + ), + /** Query the status of the person (see groupMetadata() for groups) */ + getStatus: async(jid: string) => { + const status: { status: string } = await query({ json: ['query', 'Status', jid], requiresPhoneConnection: false }) + return status + }, + setStatus: async(status: string) => { + const response = await setQuery( + [ + { + tag: 'status', + attrs: {}, + content: Buffer.from (status, 'utf-8') + } + ] + ) + ev.emit('contacts.update', [{ id: getState().legacy!.user!.id, status }]) + return response + }, + /** Updates business profile. */ + updateBusinessProfile: async(profile: WABusinessProfile) => { + if (profile.business_hours?.config) { + profile.business_hours.business_config = profile.business_hours.config + delete profile.business_hours.config + } + const json = ['action', "editBusinessProfile", {...profile, v: 2}] + await query({ json, expect200: true, requiresPhoneConnection: true }) + }, + updateProfileName: async(name: string) => { + const response = (await setQuery( + [ + { + tag: 'profile', + attrs: { name } + } + ] + )) as any as {status: number, pushname: string} + + if(config.emitOwnEvents) { + const user = { ...getState().legacy!.user!, name } + ev.emit('connection.update', { legacy: { + ...getState().legacy, user + } }) + ev.emit('contacts.update', [{ id: user.id, name }]) + } + return response + }, + /** + * Update the profile picture + * @param jid + * @param img + */ + async updateProfilePicture (jid: string, img: Buffer) { + jid = jidNormalizedUser (jid) + const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO + const tag = this.generateMessageTag () + const query: BinaryNode = { + tag: 'picture', + attrs: { jid: jid, id: tag, type: 'set' }, + content: [ + { tag: 'image', attrs: {}, content: data.img }, + { tag: 'preview', attrs: {}, content: data.preview } + ] + } + + const user = getState().legacy?.user + const { eurl } = await this.setQuery ([query], [WAMetric.picture, 136], tag) as { eurl: string, status: number } + + if(config.emitOwnEvents) { + if(jid === user.id) { + user.imgUrl = eurl + ev.emit('connection.update', { + legacy: { + ...getState().legacy, + user + } + }) + } + ev.emit('contacts.update', [ { id: jid, imgUrl: eurl } ]) + } + }, + /** + * Add or remove user from blocklist + * @param jid the ID of the person who you are blocking/unblocking + * @param type type of operation + */ + blockUser: async(jid: string, type: 'add' | 'remove' = 'add') => { + const json = { + tag: 'block', + attrs: { type }, + content: [ { tag: 'user', attrs: { jid } } ] + } + await setQuery([json], [WAMetric.block, WAFlag.ignore]) + if(config.emitOwnEvents) { + ev.emit('blocklist.update', { blocklist: [jid], type }) + } + }, + /** + * Query Business Profile (Useful for VCards) + * @param jid Business Jid + * @returns profile object or undefined if not business account + */ + getBusinessProfile: async(jid: string) => { + jid = jidNormalizedUser(jid) + const { + profiles: [{ + profile, + wid + }] + } = await query({ + json: [ + "query", "businessProfile", + [ { "wid": jid.replace('@s.whatsapp.net', '@c.us') } ], + 84 + ], + expect200: true, + requiresPhoneConnection: false, + }) + + return { + ...profile, + wid: jidNormalizedUser(wid) + } as WABusinessProfile + } + } +} +export default makeChatsSocket \ No newline at end of file diff --git a/src/LegacySocket/groups.ts b/src/LegacySocket/groups.ts new file mode 100644 index 0000000..b4bba8b --- /dev/null +++ b/src/LegacySocket/groups.ts @@ -0,0 +1,238 @@ +import { BinaryNode, jidNormalizedUser } from "../WABinary"; +import { LegacySocketConfig, GroupModificationResponse, ParticipantAction, GroupMetadata, WAFlag, WAMetric, WAGroupCreateResponse, GroupParticipant } from "../Types"; +import { generateMessageID, unixTimestampSeconds } from "../Utils/generics"; +import makeMessagesSocket from "./messages"; + +const makeGroupsSocket = (config: LegacySocketConfig) => { + const { logger } = config + const sock = makeMessagesSocket(config) + const { + ev, + ws: socketEvents, + query, + generateMessageTag, + currentEpoch, + setQuery, + getState + } = sock + + /** Generic function for group queries */ + const groupQuery = async(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: BinaryNode[]) => { + const tag = generateMessageTag() + const result = await setQuery ([ + { + tag: 'group', + attrs: { + author: getState().legacy?.user?.id, + id: tag, + type: type, + jid: jid, + subject: subject, + }, + content: participants ? + participants.map(jid => ( + { tag: 'participant', attrs: { jid } } + )) : + additionalNodes + } + ], [WAMetric.group, 136], tag) + return result + } + + /** Get the metadata of the group from WA */ + const groupMetadataFull = async (jid: string) => { + const metadata = await query({ + json: ['query', 'GroupMetadata', jid], + expect200: true + }) + metadata.participants = metadata.participants.map(p => ( + { ...p, id: undefined, jid: jidNormalizedUser(p.id) } + )) + metadata.owner = jidNormalizedUser(metadata.owner) + return metadata as GroupMetadata + } + /** Get the metadata (works after you've left the group also) */ + const groupMetadataMinimal = async (jid: string) => { + const { attrs, content }:BinaryNode = await query({ + json: { + tag: 'query', + attrs: {type: 'group', jid: jid, epoch: currentEpoch().toString()} + }, + binaryTag: [WAMetric.group, WAFlag.ignore], + expect200: true + }) + const participants: GroupParticipant[] = [] + let desc: string | undefined + if(Array.isArray(content) && Array.isArray(content[0].content)) { + const nodes = content[0].content + for(const item of nodes) { + if(item.tag === 'participant') { + participants.push({ + id: item.attrs.jid, + isAdmin: item.attrs.type === 'admin', + isSuperAdmin: false + }) + } else if(item.tag === 'description') { + desc = (item.content as Buffer).toString('utf-8') + } + } + } + const meta: GroupMetadata = { + id: jid, + owner: attrs?.creator, + creation: +attrs?.create, + subject: null, + desc, + participants + } + return meta + } + + socketEvents.on('CB:Chat,cmd:action', (json: BinaryNode) => { + /*const data = json[1].data + if (data) { + const emitGroupParticipantsUpdate = (action: WAParticipantAction) => this.emitParticipantsUpdate + (json[1].id, data[2].participants.map(whatsappID), action) + const emitGroupUpdate = (data: Partial) => this.emitGroupUpdate(json[1].id, data) + + switch (data[0]) { + case "promote": + emitGroupParticipantsUpdate('promote') + break + case "demote": + emitGroupParticipantsUpdate('demote') + break + case "desc_add": + emitGroupUpdate({ ...data[2], descOwner: data[1] }) + break + default: + this.logger.debug({ unhandled: true }, json) + break + } + }*/ + }) + + return { + ...sock, + groupMetadata: async(jid: string, minimal: boolean) => { + let result: GroupMetadata + + if(minimal) result = await groupMetadataMinimal(jid) + else result = await groupMetadataFull(jid) + + return result + }, + /** + * Create a group + * @param title like, the title of the group + * @param participants people to include in the group + */ + groupCreate: async (title: string, participants: string[]) => { + const response = await groupQuery('create', null, title, participants) as WAGroupCreateResponse + const gid = response.gid + let metadata: GroupMetadata + try { + metadata = await groupMetadataFull(gid) + } catch (error) { + logger.warn (`error in group creation: ${error}, switching gid & checking`) + // if metadata is not available + const comps = gid.replace ('@g.us', '').split ('-') + response.gid = `${comps[0]}-${+comps[1] + 1}@g.us` + + metadata = await groupMetadataFull(gid) + logger.warn (`group ID switched from ${gid} to ${response.gid}`) + } + ev.emit('chats.upsert', [ + { + id: response.gid!, + name: title, + conversationTimestamp: unixTimestampSeconds(), + unreadCount: 0 + } + ]) + return metadata + }, + /** + * Leave a group + * @param jid the ID of the group + */ + groupLeave: async (id: string) => { + await groupQuery('leave', id) + ev.emit('chats.update', [ { id, readOnly: true } ]) + }, + /** + * Update the subject of the group + * @param {string} jid the ID of the group + * @param {string} title the new title of the group + */ + groupUpdateSubject: async (id: string, title: string) => { + await groupQuery('subject', id, title) + ev.emit('chats.update', [ { id, name: title } ]) + ev.emit('contacts.update', [ { id, name: title } ]) + ev.emit('groups.update', [ { id: id, subject: title } ]) + }, + /** + * Update the group description + * @param {string} jid the ID of the group + * @param {string} title the new title of the group + */ + groupUpdateDescription: async (jid: string, description: string) => { + const metadata = await groupMetadataFull(jid) + const node: BinaryNode = { + tag: 'description', + attrs: {id: generateMessageID(), prev: metadata?.descId}, + content: Buffer.from(description, 'utf-8') + } + + const response = await groupQuery ('description', jid, null, null, [node]) + ev.emit('groups.update', [ { id: jid, desc: description } ]) + return response + }, + /** + * Update participants in the group + * @param jid the ID of the group + * @param participants the people to add + */ + groupParticipantsUpdate: async(id: string, participants: string[], action: ParticipantAction) => { + const result: GroupModificationResponse = await groupQuery(action, id, null, participants) + const jids = Object.keys(result.participants || {}) + ev.emit('group-participants.update', { id, participants: jids, action }) + return jids + }, + /** Query broadcast list info */ + getBroadcastListInfo: async(jid: string) => { + interface WABroadcastListInfo { + status: number + name: string + recipients?: {id: string}[] + } + + const result = await query({ + json: ['query', 'contact', jid], + expect200: true, + requiresPhoneConnection: true + }) as WABroadcastListInfo + + const metadata: GroupMetadata = { + subject: result.name, + id: jid, + creation: undefined, + owner: getState().legacy?.user?.id, + participants: result.recipients!.map(({ id }) => ( + { id: jidNormalizedUser(id), isAdmin: false, isSuperAdmin: false } + )) + } + return metadata + }, + inviteCode: async(jid: string) => { + const response = await sock.query({ + json: ['query', 'inviteCode', jid], + expect200: true, + requiresPhoneConnection: false + }) + return response.code as string + } + } + +} +export default makeGroupsSocket \ No newline at end of file diff --git a/src/LegacySocket/index.ts b/src/LegacySocket/index.ts new file mode 100644 index 0000000..3f1f7bf --- /dev/null +++ b/src/LegacySocket/index.ts @@ -0,0 +1,12 @@ +import { LegacySocketConfig } from '../Types' +import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults' +import _makeLegacySocket from './groups' +// export the last socket layer +const makeLegacySocket = (config: Partial) => ( + _makeLegacySocket({ + ...DEFAULT_LEGACY_CONNECTION_CONFIG, + ...config + }) +) + +export default makeLegacySocket \ No newline at end of file diff --git a/src/LegacySocket/messages.ts b/src/LegacySocket/messages.ts new file mode 100644 index 0000000..83d6f58 --- /dev/null +++ b/src/LegacySocket/messages.ts @@ -0,0 +1,536 @@ +import { BinaryNode, getBinaryNodeMessages, isJidGroup, jidNormalizedUser, areJidsSameUser } from "../WABinary"; +import { Boom } from '@hapi/boom' +import { Chat, WAMessageCursor, WAMessage, LegacySocketConfig, WAMessageKey, ParticipantAction, WAMessageStatus, WAMessageStubType, GroupMetadata, AnyMessageContent, MiscMessageGenerationOptions, WAFlag, WAMetric, WAUrlInfo, MediaConnInfo, MessageUpdateType, MessageInfo, MessageInfoUpdate, WAMessageUpdate } from "../Types"; +import { toNumber, generateWAMessage, decryptMediaMessageBuffer, extractMessageContent, getWAUploadToServer } from "../Utils"; +import makeChatsSocket from "./chats"; +import { WA_DEFAULT_EPHEMERAL } from "../Defaults"; +import { proto } from "../../WAProto"; + +const STATUS_MAP = { + read: WAMessageStatus.READ, + message: WAMessageStatus.DELIVERY_ACK, + error: WAMessageStatus.ERROR +} as { [_: string]: WAMessageStatus } + +const makeMessagesSocket = (config: LegacySocketConfig) => { + const { logger } = config + const sock = makeChatsSocket(config) + const { + ev, + ws: socketEvents, + query, + generateMessageTag, + currentEpoch, + setQuery, + getState + } = sock + + let mediaConn: Promise + const refreshMediaConn = async(forceGet = false) => { + let media = await mediaConn + if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) { + mediaConn = (async() => { + const {media_conn} = await query({ + json: ['query', 'mediaConn'], + requiresPhoneConnection: false + }) + media_conn.fetchDate = new Date() + return media_conn as MediaConnInfo + })() + } + return mediaConn + } + + const fetchMessagesFromWA = async( + jid: string, + count: number, + cursor?: WAMessageCursor + ) => { + let key: WAMessageKey + if(cursor) { + key = 'before' in cursor ? cursor.before : cursor.after + } + const { content }:BinaryNode = await query({ + json: { + tag: 'query', + attrs: { + epoch: currentEpoch().toString(), + type: 'message', + jid: jid, + kind: !cursor || 'before' in cursor ? 'before' : 'after', + count: count.toString(), + index: key?.id, + owner: key?.fromMe === false ? 'false' : 'true', + } + }, + binaryTag: [WAMetric.queryMessages, WAFlag.ignore], + expect200: false, + requiresPhoneConnection: true + }) + if(Array.isArray(content)) { + return content.map(data => proto.WebMessageInfo.decode(data.content as Buffer)) + } + return [] + } + + const updateMediaMessage = async(message: WAMessage) => { + const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage + if (!content) throw new Boom( + `given message ${message.key.id} is not a media message`, + { statusCode: 400, data: message } + ) + + const response: BinaryNode = await query ({ + json: { + tag: 'query', + attrs: { + type: 'media', + index: message.key.id, + owner: message.key.fromMe ? 'true' : 'false', + jid: message.key.remoteJid, + epoch: currentEpoch().toString() + } + }, + binaryTag: [WAMetric.queryMedia, WAFlag.ignore], + expect200: true, + requiresPhoneConnection: true + }) + const attrs = response.attrs + Object.assign(content, attrs) // update message + + ev.emit('messages.update', [{ key: message.key, update: { message: message.message } }]) + + return response + } + + const onMessage = (message: WAMessage, type: MessageUpdateType | 'update') => { + const jid = message.key.remoteJid! + // store chat updates in this + const chatUpdate: Partial = { + id: jid, + } + + const emitGroupUpdate = (update: Partial) => { + ev.emit('groups.update', [ { id: jid, ...update } ]) + } + + if(message.message) { + chatUpdate.conversationTimestamp = +toNumber(message.messageTimestamp) + // add to count if the message isn't from me & there exists a message + if(!message.key.fromMe) { + chatUpdate.unreadCount = 1 + const participant = jidNormalizedUser(message.participant || jid) + + ev.emit( + 'presence.update', + { + id: jid, + presences: { [participant]: { lastKnownPresence: 'available' } } + } + ) + } + } + + const ephemeralProtocolMsg = message.message?.ephemeralMessage?.message?.protocolMessage + if ( + ephemeralProtocolMsg && + ephemeralProtocolMsg.type === proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING + ) { + chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp + chatUpdate.ephemeralExpiration = ephemeralProtocolMsg.ephemeralExpiration + + if(isJidGroup(jid)) { + emitGroupUpdate({ ephemeralDuration: ephemeralProtocolMsg.ephemeralExpiration || null }) + } + } + const protocolMessage = message.message?.protocolMessage + // if it's a message to delete another message + if (protocolMessage) { + switch (protocolMessage.type) { + case proto.ProtocolMessage.ProtocolMessageType.REVOKE: + const key = protocolMessage.key + const messageStubType = WAMessageStubType.REVOKE + ev.emit('messages.update', [ + { + // the key of the deleted message is updated + update: { message: null, key: message.key, messageStubType }, + key + } + ]) + return + default: + break + } + } + + // check if the message is an action + if (message.messageStubType) { + const { user } = getState().legacy! + //let actor = jidNormalizedUser (message.participant) + let participants: string[] + const emitParticipantsUpdate = (action: ParticipantAction) => ( + ev.emit('group-participants.update', { id: jid, participants, action }) + ) + + switch (message.messageStubType) { + case WAMessageStubType.CHANGE_EPHEMERAL_SETTING: + chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp + chatUpdate.ephemeralExpiration = +message.messageStubParameters[0] + if(isJidGroup(jid)) { + emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null }) + } + break + case WAMessageStubType.GROUP_PARTICIPANT_LEAVE: + case WAMessageStubType.GROUP_PARTICIPANT_REMOVE: + participants = message.messageStubParameters.map (jidNormalizedUser) + emitParticipantsUpdate('remove') + // mark the chat read only if you left the group + if (participants.includes(user.id)) { + chatUpdate.readOnly = true + } + break + case WAMessageStubType.GROUP_PARTICIPANT_ADD: + case WAMessageStubType.GROUP_PARTICIPANT_INVITE: + case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN: + participants = message.messageStubParameters.map (jidNormalizedUser) + if (participants.includes(user.id)) { + chatUpdate.readOnly = null + } + emitParticipantsUpdate('add') + break + case WAMessageStubType.GROUP_CHANGE_ANNOUNCE: + const announce = message.messageStubParameters[0] === 'on' + emitGroupUpdate({ announce }) + break + case WAMessageStubType.GROUP_CHANGE_RESTRICT: + const restrict = message.messageStubParameters[0] === 'on' + emitGroupUpdate({ restrict }) + break + case WAMessageStubType.GROUP_CHANGE_SUBJECT: + case WAMessageStubType.GROUP_CREATE: + chatUpdate.name = message.messageStubParameters[0] + emitGroupUpdate({ subject: chatUpdate.name }) + break + } + } + + if(Object.keys(chatUpdate).length > 1) { + ev.emit('chats.update', [chatUpdate]) + } + if(type === 'update') { + ev.emit('messages.update', [ { update: message, key: message.key } ]) + } else { + ev.emit('messages.upsert', { messages: [message], type }) + } + } + + const waUploadToServer = getWAUploadToServer(config, refreshMediaConn) + + /** Query a string to check if it has a url, if it does, return WAUrlInfo */ + const generateUrlInfo = async(text: string) => { + const response: BinaryNode = await query({ + json: { + tag: 'query', + attrs: { + type: 'url', + url: text, + epoch: currentEpoch().toString() + } + }, + binaryTag: [26, WAFlag.ignore], + expect200: true, + requiresPhoneConnection: false + }) + const urlInfo = { ...response.attrs } as any as WAUrlInfo + if(response && response.content) { + urlInfo.jpegThumbnail = response.content as Buffer + } + return urlInfo + } + + /** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */ + const relayWAMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => { + const json: BinaryNode = { + tag: 'action', + attrs: { epoch: currentEpoch().toString(), type: 'relay' }, + content: [ + { + tag: 'message', + attrs: {}, + content: proto.WebMessageInfo.encode(message).finish() + } + ] + } + const isMsgToMe = areJidsSameUser(message.key.remoteJid, getState().legacy.user?.id || '') + const flag = isMsgToMe ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself + const mID = message.key.id + const finalState = isMsgToMe ? WAMessageStatus.READ : WAMessageStatus.SERVER_ACK + + message.status = WAMessageStatus.PENDING + const promise = query({ + json, + binaryTag: [WAMetric.message, flag], + tag: mID, + expect200: true, + requiresPhoneConnection: true + }) + + if(waitForAck) { + await promise + message.status = finalState + } else { + const emitUpdate = (status: WAMessageStatus) => { + message.status = status + ev.emit('messages.update', [ { key: message.key, update: { status } } ]) + } + promise + .then(() => emitUpdate(finalState)) + .catch(() => emitUpdate(WAMessageStatus.ERROR)) + } + if(config.emitOwnEvents) { + onMessage(message, 'append') + } + } + + // messages received + const messagesUpdate = (node: BinaryNode, type: 'prepend' | 'last') => { + const messages = getBinaryNodeMessages(node) + messages.reverse() + ev.emit('messages.upsert', { messages, type }) + } + + socketEvents.on('CB:action,add:last', json => messagesUpdate(json, 'last')) + socketEvents.on('CB:action,add:unread', json => messagesUpdate(json, 'prepend')) + socketEvents.on('CB:action,add:before', json => messagesUpdate(json, 'prepend')) + + // new messages + socketEvents.on('CB:action,add:relay,message', (node: BinaryNode) => { + const msgs = getBinaryNodeMessages(node) + for(const msg of msgs) { + onMessage(msg, 'notify') + } + }) + // If a message has been updated (usually called when a video message gets its upload url, or live locations) + socketEvents.on ('CB:action,add:update,message', (node: BinaryNode) => { + const msgs = getBinaryNodeMessages(node) + for(const msg of msgs) { + onMessage(msg, 'update') + } + }) + // message status updates + const onMessageStatusUpdate = ({ content }: BinaryNode) => { + if(Array.isArray(content)) { + const updates: WAMessageUpdate[] = [] + for(const { attrs: json } of content) { + const key: WAMessageKey = { + remoteJid: jidNormalizedUser(json.jid), + id: json.index, + fromMe: json.owner === 'true' + } + const status = STATUS_MAP[json.type] + + if(status) { + updates.push({ key, update: { status } }) + } else { + logger.warn({ content, key }, 'got unknown status update for message') + } + } + ev.emit('messages.update', updates) + } + } + const onMessageInfoUpdate = ([,attributes]: [string,{[_: string]: any}]) => { + let ids = attributes.id as string[] | string + if(typeof ids === 'string') { + ids = [ids] + } + let updateKey: keyof MessageInfoUpdate['update'] + switch(attributes.ack.toString()) { + case '2': + updateKey = 'deliveries' + break + case '3': + updateKey = 'reads' + break + default: + logger.warn({ attributes }, `received unknown message info update`) + return + } + const keyPartial = { + remoteJid: jidNormalizedUser(attributes.to), + fromMe: areJidsSameUser(attributes.from, getState().legacy?.user?.id || ''), + } + const updates = ids.map(id => ({ + key: { ...keyPartial, id }, + update: { + [updateKey]: { [jidNormalizedUser(attributes.participant || attributes.to)]: new Date(+attributes.t) } + } + })) + ev.emit('message-info.update', updates) + // for individual messages + // it means the message is marked read/delivered + if(!isJidGroup(keyPartial.remoteJid)) { + ev.emit('messages.update', ids.map(id => ( + { + key: { ...keyPartial, id }, + update: { + status: updateKey === 'deliveries' ? WAMessageStatus.DELIVERY_ACK : WAMessageStatus.READ + } + } + ))) + } + } + + socketEvents.on('CB:action,add:relay,received', onMessageStatusUpdate) + socketEvents.on('CB:action,,received', onMessageStatusUpdate) + + socketEvents.on('CB:Msg', onMessageInfoUpdate) + socketEvents.on('CB:MsgInfo', onMessageInfoUpdate) + + return { + ...sock, + relayWAMessage, + generateUrlInfo, + messageInfo: async(jid: string, messageID: string) => { + const { content }: BinaryNode = await query({ + json: { + tag: 'query', + attrs: {type: 'message_info', index: messageID, jid: jid, epoch: currentEpoch().toString()} + }, + binaryTag: [WAMetric.queryRead, WAFlag.ignore], + expect200: true, + requiresPhoneConnection: true + }) + const info: MessageInfo = { reads: {}, deliveries: {} } + if(Array.isArray(content)) { + for(const { tag, content: innerData } of content) { + const [{ attrs }] = (innerData as BinaryNode[]) + const jid = jidNormalizedUser(attrs.jid) + const date = new Date(+attrs.t * 1000) + switch(tag) { + case 'read': + info.reads[jid] = date + break + case 'delivery': + info.deliveries[jid] = date + break + } + } + } + return info + }, + downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => { + + const downloadMediaMessage = async () => { + let mContent = extractMessageContent(message.message) + if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message }) + + const stream = await decryptMediaMessageBuffer(mContent) + if(type === 'buffer') { + let buffer = Buffer.from([]) + for await(const chunk of stream) { + buffer = Buffer.concat([buffer, chunk]) + } + return buffer + } + return stream + } + + try { + const result = await downloadMediaMessage() + return result + } catch (error) { + if(error.message.includes('404')) { // media needs to be updated + logger.info (`updating media of message: ${message.key.id}`) + + await updateMediaMessage(message) + + const result = await downloadMediaMessage() + return result + } + throw error + } + }, + updateMediaMessage, + fetchMessagesFromWA, + /** Load a single message specified by the ID */ + loadMessageFromWA: async(jid: string, id: string) => { + let message: WAMessage + + // load the message before the given message + let messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: true} })) + if(!messages[0]) messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: false} })) + // the message after the loaded message is the message required + const [actual] = await fetchMessagesFromWA(jid, 1, { after: messages[0] && messages[0].key }) + message = actual + return message + }, + searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => { + const node: BinaryNode = await query({ + json: { + tag: 'query', + attrs: { + epoch: currentEpoch().toString(), + type: 'search', + search: txt, + count: count.toString(), + page: page.toString(), + jid: inJid + } + }, + binaryTag: [24, WAFlag.ignore], + expect200: true + }) // encrypt and send off + + return { + last: node.attrs?.last === 'true', + messages: getBinaryNodeMessages(node) + } + }, + sendWAMessage: async( + jid: string, + content: AnyMessageContent, + options: MiscMessageGenerationOptions & { waitForAck?: boolean } = { waitForAck: true } + ) => { + const userJid = getState().legacy.user?.id + if( + typeof content === 'object' && + 'disappearingMessagesInChat' in content && + typeof content['disappearingMessagesInChat'] !== 'undefined' && + isJidGroup(jid) + ) { + const { disappearingMessagesInChat } = content + const value = typeof disappearingMessagesInChat === 'boolean' ? + (disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) : + disappearingMessagesInChat + const tag = generateMessageTag(true) + await setQuery([ + { + tag: 'group', + attrs: { id: tag, jid, type: 'prop', author: userJid }, + content: [ + { tag: 'ephemeral', attrs: { value: value.toString() } } + ] + } + ]) + } else { + const msg = await generateWAMessage( + jid, + content, + { + ...options, + logger, + userJid: userJid, + getUrlInfo: generateUrlInfo, + upload: waUploadToServer, + mediaCache: config.mediaCache + } + ) + + await relayWAMessage(msg, { waitForAck: options.waitForAck }) + return msg + } + } + } +} + +export default makeMessagesSocket \ No newline at end of file diff --git a/src/LegacySocket/socket.ts b/src/LegacySocket/socket.ts new file mode 100644 index 0000000..5f7c3bf --- /dev/null +++ b/src/LegacySocket/socket.ts @@ -0,0 +1,393 @@ +import { Boom } from '@hapi/boom' +import { STATUS_CODES } from "http" +import { promisify } from "util" +import WebSocket from "ws" +import { BinaryNode, encodeBinaryNodeLegacy } from "../WABinary" +import { DisconnectReason, LegacySocketConfig, SocketQueryOptions, SocketSendMessageOptions, WAFlag, WAMetric, WATag } from "../Types" +import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds, decodeWAMessage } from "../Utils" +import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults" + +/** + * Connects to WA servers and performs: + * - simple queries (no retry mechanism, wait for connection establishment) + * - listen to messages and emit events + * - query phone connection + */ +export const makeSocket = ({ + waWebSocketUrl, + connectTimeoutMs, + phoneResponseTimeMs, + logger, + agent, + keepAliveIntervalMs, + expectResponseTimeout, +}: LegacySocketConfig) => { + // for generating tags + const referenceDateSeconds = unixTimestampSeconds(new Date()) + const ws = new WebSocket(waWebSocketUrl, undefined, { + origin: DEFAULT_ORIGIN, + timeout: connectTimeoutMs, + agent, + headers: { + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache', + 'Host': 'web.whatsapp.com', + 'Pragma': 'no-cache', + 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', + } + }) + ws.setMaxListeners(0) + let lastDateRecv: Date + let epoch = 0 + let authInfo: { encKey: Buffer, macKey: Buffer } + let keepAliveReq: NodeJS.Timeout + + let phoneCheckInterval: NodeJS.Timeout + let phoneCheckListeners = 0 + + const phoneConnectionChanged = (value: boolean) => { + ws.emit('phone-connection', { value }) + } + + const sendPromise = promisify(ws.send) + /** generate message tag and increment epoch */ + const generateMessageTag = (longTag: boolean = false) => { + const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}` + epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages + return tag + } + const sendRawMessage = (data: Buffer | string) => sendPromise.call(ws, data) as Promise + /** + * Send a message to the WA servers + * @returns the tag attached in the message + * */ + const sendMessage = async( + { json, binaryTag, tag, longTag }: SocketSendMessageOptions + ) => { + tag = tag || generateMessageTag(longTag) + let data: Buffer | string + if(logger.level === 'trace') { + logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication') + } + + if(binaryTag) { + if(Array.isArray(json)) { + throw new Boom('Expected BinaryNode with binary code', { statusCode: 400 }) + } + if(!authInfo) { + throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 }) + } + const binary = encodeBinaryNodeLegacy(json) // encode the JSON to the WhatsApp binary format + + const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey + const sign = hmacSign(buff, authInfo.macKey) // sign the message using HMAC and our macKey + + data = Buffer.concat([ + Buffer.from(tag + ','), // generate & prefix the message tag + Buffer.from(binaryTag), // prefix some bytes that tell whatsapp what the message is about + sign, // the HMAC sign of the message + buff, // the actual encrypted buffer + ]) + } else { + data = `${tag},${JSON.stringify(json)}` + } + await sendRawMessage(data) + return tag + } + const end = (error: Error | undefined) => { + logger.debug({ error }, 'connection closed') + + ws.removeAllListeners('close') + ws.removeAllListeners('error') + ws.removeAllListeners('open') + ws.removeAllListeners('message') + + phoneCheckListeners = 0 + clearInterval(keepAliveReq) + clearPhoneCheckInterval() + + if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) { + try { ws.close() } catch { } + } + + ws.emit('ws-close', error) + ws.removeAllListeners('ws-close') + } + const onMessageRecieved = (message: string | Buffer) => { + if(message[0] === '!' || message[0] === '!'.charCodeAt(0)) { + // when the first character in the message is an '!', the server is sending a pong frame + const timestamp = message.slice(1, message.length).toString() + lastDateRecv = new Date(parseInt(timestamp)) + ws.emit('received-pong') + } else { + let messageTag: string + let json: any + try { + const dec = decodeWAMessage(message, authInfo) + messageTag = dec[0] + json = dec[1] + if (!json) return + } catch (error) { + end(error) + return + } + //if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false }) + + if (logger.level === 'trace') { + logger.trace({ tag: messageTag, fromMe: false, json }, 'communication') + } + + let anyTriggered = false + /* Check if this is a response to a message we sent */ + anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${messageTag}`, json) + /* Check if this is a response to a message we are expecting */ + const l0 = json.tag || json[0] || '' + const l1 = json?.attrs || json?.[1] || { } + const l2 = json?.content?.[0]?.tag || json[2]?.[0] || '' + + Object.keys(l1).forEach(key => { + anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, json) || anyTriggered + anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, json) || anyTriggered + anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, json) || anyTriggered + }) + anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered + anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered + + if (!anyTriggered && logger.level === 'debug') { + logger.debug({ unhandled: true, tag: messageTag, fromMe: false, json }, 'communication recv') + } + } + } + + /** Exits a query if the phone connection is active and no response is still found */ + const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => { + let timeout: NodeJS.Timeout + const listener = ([, connected]) => { + if(connected) { + timeout = setTimeout(() => { + logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`) + cancel(new Boom('Not expecting a response', { statusCode: 422 })) + }, expectResponseTimeout) + ws.off(PHONE_CONNECTION_CB, listener) + } + } + ws.on(PHONE_CONNECTION_CB, listener) + return () => { + ws.off(PHONE_CONNECTION_CB, listener) + timeout && clearTimeout(timeout) + } + } + /** interval is started when a query takes too long to respond */ + const startPhoneCheckInterval = () => { + phoneCheckListeners += 1 + if (!phoneCheckInterval) { + // if its been a long time and we haven't heard back from WA, send a ping + phoneCheckInterval = setInterval(() => { + if(phoneCheckListeners <= 0) { + logger.warn('phone check called without listeners') + return + } + logger.info('checking phone connection...') + sendAdminTest() + + phoneConnectionChanged(false) + }, phoneResponseTimeMs) + } + } + const clearPhoneCheckInterval = () => { + phoneCheckListeners -= 1 + if (phoneCheckListeners <= 0) { + clearInterval(phoneCheckInterval) + phoneCheckInterval = undefined + phoneCheckListeners = 0 + } + } + /** checks for phone connection */ + const sendAdminTest = () => sendMessage({ json: ['admin', 'test'] }) + /** + * Wait for a message with a certain tag to be received + * @param tag the message tag to await + * @param json query that was sent + * @param timeoutMs timeout after which the promise will reject + */ + const waitForMessage = (tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => { + if(ws.readyState !== ws.OPEN) { + throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) + } + + let cancelToken = () => { } + + return { + promise: (async() => { + let onRecv: (json) => void + let onErr: (err) => void + let cancelPhoneChecker: () => void + try { + const result = await promiseTimeout(timeoutMs, + (resolve, reject) => { + onRecv = resolve + onErr = err => { + reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })) + } + cancelToken = () => onErr(new Boom('Cancelled', { statusCode: 500 })) + + if(requiresPhoneConnection) { + startPhoneCheckInterval() + cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr) + } + + ws.on(`TAG:${tag}`, onRecv) + ws.on('ws-close', onErr) // if the socket closes, you'll never receive the message + }, + ) + return result as any + } finally { + requiresPhoneConnection && clearPhoneCheckInterval() + cancelPhoneChecker && cancelPhoneChecker() + + ws.off(`TAG:${tag}`, onRecv) + ws.off('ws-close', onErr) // if the socket closes, you'll never receive the message + } + })(), + cancelToken: () => { cancelToken() } + } + } + /** + * Query something from the WhatsApp servers + * @param json the query itself + * @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary + * @param timeoutMs timeout after which the query will be failed (set to null to disable a timeout) + * @param tag the tag to attach to the message + */ + const query = async( + { json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection }: SocketQueryOptions + ) => { + tag = tag || generateMessageTag(longTag) + const { promise, cancelToken } = waitForMessage(tag, requiresPhoneConnection, timeoutMs) + try { + await sendMessage({ json, tag, binaryTag }) + } catch(error) { + cancelToken() + // swallow error + await promise.catch(() => { }) + // throw back the error + throw error + } + + const response = await promise + const responseStatusCode = +(response.status ? response.status : 200) // default status + // read here: http://getstatuscode.com/599 + if(responseStatusCode === 599) { // the connection has gone bad + end(new Boom('WA server overloaded', { statusCode: 599, data: { query: json, response } })) + } + if(expect200 && Math.floor(responseStatusCode/100) !== 2) { + const message = STATUS_CODES[responseStatusCode] || 'unknown' + throw new Boom( + `Unexpected status in '${Array.isArray(json) ? json[0] : (json?.tag || 'query')}': ${message}(${responseStatusCode})`, + { data: { query: json, message }, statusCode: response.status } + ) + } + return response + } + const startKeepAliveRequest = () => ( + keepAliveReq = setInterval(() => { + if (!lastDateRecv) lastDateRecv = new Date() + const diff = Date.now() - lastDateRecv.getTime() + /* + check if it's been a suspicious amount of time since the server responded with our last seen + it could be that the network is down + */ + if (diff > keepAliveIntervalMs+5000) { + end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) + } else if(ws.readyState === ws.OPEN) { + sendRawMessage('?,,') // if its all good, send a keep alive request + } else { + logger.warn('keep alive called when WS not open') + } + }, keepAliveIntervalMs) + ) + + const waitForSocketOpen = async() => { + if(ws.readyState === ws.OPEN) return + if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) + } + let onOpen: () => void + let onClose: (err: Error) => void + await new Promise((resolve, reject) => { + onOpen = () => resolve(undefined) + onClose = reject + ws.on('open', onOpen) + ws.on('close', onClose) + ws.on('error', onClose) + }) + .finally(() => { + ws.off('open', onOpen) + ws.off('close', onClose) + ws.off('error', onClose) + }) + } + + ws.on('message', onMessageRecieved) + ws.on('open', () => { + startKeepAliveRequest() + logger.info('Opened WS connection to WhatsApp Web') + }) + ws.on('error', end) + ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost }))) + + ws.on(PHONE_CONNECTION_CB, json => { + if (!json[1]) { + end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost })) + logger.info('Connection terminated by phone, closing...') + } else { + phoneConnectionChanged(true) + } + }) + ws.on('CB:Cmd,type:disconnect', json => { + const {kind} = json[1] + let reason: DisconnectReason + switch(kind) { + case 'replaced': + reason = DisconnectReason.connectionReplaced + break + default: + reason = DisconnectReason.connectionLost + break + } + end(new Boom( + `Connection terminated by server: "${kind || 'unknown'}"`, + { statusCode: reason } + )) + }) + + return { + ws, + updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info, + waitForSocketOpen, + sendRawMessage, + sendMessage, + generateMessageTag, + waitForMessage, + query, + /** Generic function for action, set queries */ + setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => { + const json: BinaryNode = { + tag: 'action', + attrs: { epoch: epoch.toString(), type: 'set' }, + content: nodes + } + + return query({ + json, + binaryTag, + tag, + expect200: true, + requiresPhoneConnection: true + }) as Promise<{ status: number }> + }, + currentEpoch: () => epoch, + end + } +} \ No newline at end of file diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index e5781d6..49448e0 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -4,6 +4,7 @@ import { proto } from '../../WAProto' import { generateProfilePicture, toNumber, encodeSyncdPatch, decodePatches, extractSyncdPatches, chatModificationToAppPatch, decodeSyncdSnapshot, newLTHashState } from "../Utils"; import { makeMessagesSocket } from "./messages-send"; import makeMutex from "../Utils/make-mutex"; +import { Boom } from "@hapi/boom"; export const makeChatsSocket = (config: SocketConfig) => { const { logger } = config @@ -425,6 +426,11 @@ export const makeChatsSocket = (config: SocketConfig) => { const appPatch = async(patchCreate: WAPatchCreate) => { const name = patchCreate.type + const myAppStateKeyId = authState.creds.myAppStateKeyId + if(!myAppStateKeyId) { + throw new Boom(`App state key not present!`, { statusCode: 400 }) + } + await mutationMutex.mutex( async() => { logger.debug({ patch: patchCreate }, 'applying app patch') @@ -433,7 +439,7 @@ export const makeChatsSocket = (config: SocketConfig) => { const { [name]: initial } = await authState.keys.get('app-state-sync-version', [name]) const { patch, state } = await encodeSyncdPatch( patchCreate, - authState.creds.myAppStateKeyId!, + myAppStateKeyId, initial, getAppStateSyncKey, ) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index 811d1e7..dcc8a2f 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1,10 +1,9 @@ -import { Boom } from "@hapi/boom" import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction, MessageRelayOptions } from "../Types" -import { encodeWAMessage, generateMessageID, generateWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESessions } from "../Utils" +import { encodeWAMessage, generateMessageID, generateWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESessions, getWAUploadToServer } from "../Utils" import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser, S_WHATSAPP_NET, BinaryNodeAttributes, JidWithDevice, reduceBinaryNodeToDictionary } from '../WABinary' import { proto } from "../../WAProto" -import { WA_DEFAULT_EPHEMERAL, DEFAULT_ORIGIN, MEDIA_PATH_MAP } from "../Defaults" +import { WA_DEFAULT_EPHEMERAL } from "../Defaults" import { makeGroupsSocket } from "./groups" import NodeCache from "node-cache" @@ -419,58 +418,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { return msgId } - const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => { - const { default: got } = await import('got') - // send a query JSON to obtain the url & auth token to upload our media - let uploadInfo = await refreshMediaConn(false) - - let urls: { mediaUrl: string, directPath: string } - const hosts = [ ...config.customUploadHosts, ...uploadInfo.hosts.map(h => h.hostname) ] - for (let hostname of hosts) { - const auth = encodeURIComponent(uploadInfo.auth) // the auth token - const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` - - try { - const {body: responseText} = await got.post( - url, - { - headers: { - 'Content-Type': 'application/octet-stream', - 'Origin': DEFAULT_ORIGIN - }, - agent: { - https: config.agent - }, - body: stream, - timeout: timeoutMs - } - ) - const result = JSON.parse(responseText) - - if(result?.url || result?.directPath) { - urls = { - mediaUrl: result.url, - directPath: result.direct_path - } - break - } else { - uploadInfo = await refreshMediaConn(true) - throw new Error(`upload failed, reason: ${JSON.stringify(result)}`) - } - } catch (error) { - const isLast = hostname === hosts[uploadInfo.hosts.length-1] - logger.debug(`Error in uploading to ${hostname} (${error}) ${isLast ? '' : ', retrying...'}`) - } - } - if (!urls) { - throw new Boom( - 'Media upload failed on all hosts', - { statusCode: 500 } - ) - } - return urls - } - + const waUploadToServer = getWAUploadToServer(config, refreshMediaConn) + return { ...sock, assertSessions, diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 428b103..3aa0073 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -530,7 +530,7 @@ export const makeSocket = ({ }) ws.on('CB:ib,,downgrade_webclient', () => { - end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.notJoinedBeta })) + end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch })) }) process.nextTick(() => { diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts index 1e9eedd..cd88192 100644 --- a/src/Types/Auth.ts +++ b/src/Types/Auth.ts @@ -1,6 +1,5 @@ import type { Contact } from "./Contact" import type { proto } from "../../WAProto" -import type { WAPatchName } from "./Chat" export type KeyPair = { public: Uint8Array, private: Uint8Array } export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number } diff --git a/src/Types/Chat.ts b/src/Types/Chat.ts index e5656cb..a959c91 100644 --- a/src/Types/Chat.ts +++ b/src/Types/Chat.ts @@ -38,7 +38,7 @@ export type ChatModification = mute: number | null } | { - clear: 'all' | { message: {id: string, fromMe?: boolean} } + clear: 'all' | { messages: {id: string, fromMe?: boolean}[] } } | { star: { diff --git a/src/Types/Events.ts b/src/Types/Events.ts new file mode 100644 index 0000000..33889a7 --- /dev/null +++ b/src/Types/Events.ts @@ -0,0 +1,56 @@ +import type EventEmitter from "events" + +import { AuthenticationCreds } from './Auth' +import { Chat, PresenceData } from './Chat' +import { Contact } from './Contact' +import { ConnectionState } from './State' + +import { GroupMetadata, ParticipantAction } from './GroupMetadata' +import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate, WAMessageKey } from './Message' + +export type BaileysEventMap = { + /** connection state has been updated -- WS closed, opened, connecting etc. */ + 'connection.update': Partial + /** credentials updated -- some metadata, keys or something */ + 'creds.update': Partial + /** set chats (history sync), messages are reverse chronologically sorted */ + 'chats.set': { chats: Chat[], messages: WAMessage[] } + /** upsert chats */ + 'chats.upsert': Chat[] + /** update the given chats */ + 'chats.update': Partial[] + /** delete chats with given ID */ + 'chats.delete': string[] + /** presence of contact in a chat updated */ + 'presence.update': { id: string, presences: { [participant: string]: PresenceData } } + + 'contacts.upsert': Contact[] + 'contacts.update': Partial[] + + 'messages.delete': { keys: WAMessageKey[] } | { jid: string, all: true } + 'messages.update': WAMessageUpdate[] + /** + * add/update the given messages. If they were received while the connection was online, + * the update will have type: "notify" + * */ + 'messages.upsert': { messages: WAMessage[], type: MessageUpdateType } + + 'message-info.update': MessageInfoUpdate[] + + 'groups.upsert': GroupMetadata[] + 'groups.update': Partial[] + /** apply an action to participants in a group */ + 'group-participants.update': { id: string, participants: string[], action: ParticipantAction } + + 'blocklist.set': { blocklist: string[] } + 'blocklist.update': { blocklist: string[], type: 'add' | 'remove' } +} + +export interface CommonBaileysEventEmitter extends EventEmitter { + on>(event: T, listener: (arg: BaileysEventMap[T]) => void): this + off>(event: T, listener: (arg: BaileysEventMap[T]) => void): this + removeAllListeners>(event: T): this + emit>(event: T, arg: BaileysEventMap[T]): boolean +} + +export type BaileysEventEmitter = CommonBaileysEventEmitter \ No newline at end of file diff --git a/src/Types/Legacy.ts b/src/Types/Legacy.ts new file mode 100644 index 0000000..688c122 --- /dev/null +++ b/src/Types/Legacy.ts @@ -0,0 +1,83 @@ +import { CommonSocketConfig } from "./Socket" +import { CommonBaileysEventEmitter } from "./Events" +import { BinaryNode } from "../WABinary" + +export interface LegacyAuthenticationCreds { + clientID: string + serverToken: string + clientToken: string + encKey: Buffer + macKey: Buffer +} + +/** used for binary messages */ +export enum WAMetric { + debugLog = 1, + queryResume = 2, + liveLocation = 3, + queryMedia = 4, + queryChat = 5, + queryContact = 6, + queryMessages = 7, + presence = 8, + presenceSubscribe = 9, + group = 10, + read = 11, + chat = 12, + received = 13, + picture = 14, + status = 15, + message = 16, + queryActions = 17, + block = 18, + queryGroup = 19, + queryPreview = 20, + queryEmoji = 21, + queryRead = 22, + queryVCard = 29, + queryStatus = 30, + queryStatusUpdate = 31, + queryLiveLocation = 33, + queryLabel = 36, + queryQuickReply = 39 +} + +/** used for binary messages */ +export enum WAFlag { + available = 160, + other = 136, // don't know this one + ignore = 1 << 7, + acknowledge = 1 << 6, + unavailable = 1 << 4, + expires = 1 << 3, + composing = 1 << 2, + recording = 1 << 2, + paused = 1 << 2 +} + +/** Tag used with binary queries */ +export type WATag = [WAMetric, WAFlag] + +export type SocketSendMessageOptions = { + json: BinaryNode | any[] + binaryTag?: WATag + tag?: string + longTag?: boolean +} + +export type SocketQueryOptions = SocketSendMessageOptions & { + timeoutMs?: number + expect200?: boolean + requiresPhoneConnection?: boolean +} + +export type LegacySocketConfig = CommonSocketConfig & { + /** max time for the phone to respond to a connectivity test */ + phoneResponseTimeMs: number + /** max time for WA server to respond before error with 422 */ + expectResponseTimeout: number + + pendingRequestTimeoutMs: number +} + +export type LegacyBaileysEventEmitter = CommonBaileysEventEmitter \ No newline at end of file diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 2162545..c06d029 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -155,7 +155,7 @@ export type MessageContentGenerationOptions = MediaGenerationOptions & { } export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent -export type MessageUpdateType = 'append' | 'notify' | 'prepend' +export type MessageUpdateType = 'append' | 'notify' | 'prepend' | 'last' export type MessageInfoEventMap = { [jid: string]: Date } export interface MessageInfo { diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts new file mode 100644 index 0000000..b9d8f42 --- /dev/null +++ b/src/Types/Socket.ts @@ -0,0 +1,39 @@ + +import type { Agent } from "https" +import type { Logger } from "pino" +import type { URL } from "url" +import type NodeCache from 'node-cache' + +export type WAVersion = [number, number, number] +export type WABrowserDescription = [string, string, string] + +export type CommonSocketConfig = { + /** provide an auth state object to maintain the auth state */ + auth?: T + /** the WS url to connect to WA */ + waWebSocketUrl: string | URL + /** Fails the connection if the socket times out in this interval */ + connectTimeoutMs: number + /** Default timeout for queries, undefined for no timeout */ + defaultQueryTimeoutMs: number | undefined + /** ping-pong interval for WS connection */ + keepAliveIntervalMs: number + /** proxy agent */ + agent?: Agent + /** pino logger */ + logger: Logger + /** version to connect with */ + version: WAVersion + /** override browser config */ + browser: WABrowserDescription + /** agent used for fetch requests -- uploading/downloading media */ + fetchAgent?: Agent + /** should the QR be printed in the terminal */ + printQRInTerminal: boolean + /** should events be emitted for actions done by this socket connection */ + emitOwnEvents: boolean + /** provide a cache to store media, so does not have to be re-uploaded */ + mediaCache?: NodeCache + + customUploadHosts: string[] +} diff --git a/src/Types/State.ts b/src/Types/State.ts index 754ff32..e7f0cec 100644 --- a/src/Types/State.ts +++ b/src/Types/State.ts @@ -1,3 +1,5 @@ +import { Contact } from "./Contact" + export type WAConnectionState = 'open' | 'connecting' | 'close' export type ConnectionState = { @@ -13,5 +15,11 @@ export type ConnectionState = { /** the current QR code */ qr?: string /** has the device received all pending notifications while it was offline */ - receivedPendingNotifications?: boolean + receivedPendingNotifications?: boolean + /** legacy connection options */ + legacy?: { + phoneConnected: boolean + user?: Contact + } + } \ No newline at end of file diff --git a/src/Types/index.ts b/src/Types/index.ts index 680190f..641bedb 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -4,11 +4,11 @@ export * from './Chat' export * from './Contact' export * from './State' export * from './Message' +export * from './Legacy' +export * from './Socket' +export * from './Events' import type EventEmitter from "events" -import type { Agent } from "https" -import type { Logger } from "pino" -import type { URL } from "url" import type NodeCache from 'node-cache' import { AuthenticationState, AuthenticationCreds } from './Auth' @@ -19,40 +19,11 @@ import { ConnectionState } from './State' import { GroupMetadata, ParticipantAction } from './GroupMetadata' import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate, WAMessageKey } from './Message' import { proto } from '../../WAProto' +import { CommonSocketConfig } from './Socket' -export type WAVersion = [number, number, number] -export type WABrowserDescription = [string, string, string] -export type ReconnectMode = 'no-reconnects' | 'on-any-error' | 'on-connection-error' - -export type SocketConfig = { - /** provide an auth state object to maintain the auth state */ - auth?: AuthenticationState - /** the WS url to connect to WA */ - waWebSocketUrl: string | URL - /** Fails the connection if the socket times out in this interval */ - connectTimeoutMs: number - /** Default timeout for queries, undefined for no timeout */ - defaultQueryTimeoutMs: number | undefined - /** ping-pong interval for WS connection */ - keepAliveIntervalMs: number - /** proxy agent */ - agent?: Agent - /** pino logger */ - logger: Logger - /** version to connect with */ - version: WAVersion - /** override browser config */ - browser: WABrowserDescription - /** agent used for fetch requests -- uploading/downloading media */ - fetchAgent?: Agent - /** should the QR be printed in the terminal */ - printQRInTerminal: boolean - /** should events be emitted for actions done by this socket connection */ - emitOwnEvents: boolean +export type SocketConfig = CommonSocketConfig & { /** provide a cache to store a user's device list */ userDevicesCache?: NodeCache - /** provide a cache to store media, so does not have to be re-uploaded */ - mediaCache?: NodeCache /** map to store the retry counts for failed messages */ msgRetryCounterMap?: { [msgId: string]: number } /** custom domains to push media via */ @@ -67,11 +38,12 @@ export type SocketConfig = { export enum DisconnectReason { connectionClosed = 428, connectionLost = 408, + connectionReplaced = 440, timedOut = 408, loggedOut = 401, badSession = 500, restartRequired = 410, - notJoinedBeta = 403 + multideviceMismatch = 403 } export type WAInitResponse = { @@ -104,11 +76,11 @@ export type WABusinessProfile = { export type CurveKeyPair = { private: Uint8Array; public: Uint8Array } -export type BaileysEventMap = { +export type BaileysEventMap = { /** connection state has been updated -- WS closed, opened, connecting etc. */ 'connection.update': Partial /** credentials updated -- some metadata, keys or something */ - 'creds.update': Partial + 'creds.update': Partial /** set chats (history sync), messages are reverse chronologically sorted */ 'chats.set': { chats: Chat[], messages: WAMessage[] } /** upsert chats */ @@ -141,9 +113,12 @@ export type BaileysEventMap = { 'blocklist.set': { blocklist: string[] } 'blocklist.update': { blocklist: string[], type: 'add' | 'remove' } } -export interface BaileysEventEmitter extends EventEmitter { - on(event: T, listener: (arg: BaileysEventMap[T]) => void): this - off(event: T, listener: (arg: BaileysEventMap[T]) => void): this - removeAllListeners(event: T): this - emit(event: T, arg: BaileysEventMap[T]): boolean -} \ No newline at end of file + +export interface CommonBaileysEventEmitter extends EventEmitter { + on>(event: T, listener: (arg: BaileysEventMap[T]) => void): this + off>(event: T, listener: (arg: BaileysEventMap[T]) => void): this + removeAllListeners>(event: T): this + emit>(event: T, arg: BaileysEventMap[T]): boolean +} + +export type BaileysEventEmitter = CommonBaileysEventEmitter \ No newline at end of file diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index 7d62295..1d324ee 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -480,7 +480,7 @@ export const chatModificationToAppPatch = ( if(mod.clear === 'all') { throw new Boom('not supported') } else { - const key = mod.clear.message + const key = mod.clear.messages[0] patch = { syncAction: { deleteMessageForMeAction: { diff --git a/src/Utils/index.ts b/src/Utils/index.ts index a4c41fe..76448aa 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -9,4 +9,5 @@ export * from './noise-handler' export * from './history' export * from './chat-utils' export * from './lt-hash' -export * from './auth-utils' \ No newline at end of file +export * from './auth-utils' +export * from './legacy-msgs' \ No newline at end of file diff --git a/src/Utils/legacy-msgs.ts b/src/Utils/legacy-msgs.ts new file mode 100644 index 0000000..ba60d2b --- /dev/null +++ b/src/Utils/legacy-msgs.ts @@ -0,0 +1,173 @@ +import { Boom } from '@hapi/boom' +import { randomBytes } from 'crypto' +import { decodeBinaryNodeLegacy, jidNormalizedUser } from "../WABinary" +import { aesDecrypt, hmacSign, hkdf, Curve } from "./crypto" +import { BufferJSON } from './generics' +import { DisconnectReason, WATag, LegacyAuthenticationCreds, CurveKeyPair, Contact } from "../Types" + +export const newLegacyAuthCreds = () => ({ + clientID: randomBytes(16).toString('base64') +}) as LegacyAuthenticationCreds + +export const decodeWAMessage = ( + message: Buffer | string, + auth: { macKey: Buffer, encKey: Buffer }, + fromMe: boolean=false +) => { + let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message + if (commaIndex < 0) throw new Boom('invalid message', { data: message }) // if there was no comma, then this message must be not be valid + + if (message[commaIndex+1] === ',') commaIndex += 1 + let data = message.slice(commaIndex+1, message.length) + + // get the message tag. + // If a query was done, the server will respond with the same message tag we sent the query with + const messageTag: string = message.slice(0, commaIndex).toString() + let json: any + let tags: WATag + if(data.length) { + const possiblyEnc = (data.length > 32 && data.length % 16 === 0) + if(typeof data === 'string' || !possiblyEnc) { + json = JSON.parse(data.toString()) // parse the JSON + } else { + + const { macKey, encKey } = auth || {} + if (!macKey || !encKey) { + throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession }) + } + /* + If the data recieved was not a JSON, then it must be an encrypted message. + Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys + */ + if (fromMe) { + tags = [data[0], data[1]] + data = data.slice(2, data.length) + } + + const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message + data = data.slice(32, data.length) // the actual message + const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey + + if (checksum.equals(computedChecksum)) { + // the checksum the server sent, must match the one we computed for the message to be valid + const decrypted = aesDecrypt(data, encKey) // decrypt using AES + json = decodeBinaryNodeLegacy(decrypted, { index: 0 }) // decode the binary message into a JSON array + } else { + throw new Boom('Bad checksum', { + data: { + received: checksum.toString('hex'), + computed: computedChecksum.toString('hex'), + data: data.slice(0, 80).toString(), + tag: messageTag, + message: message.slice(0, 80).toString() + }, + statusCode: DisconnectReason.badSession + }) + } + } + } + return [messageTag, json, tags] as const +} + +/** +* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in +* @private +* @param json +*/ +export const validateNewConnection = ( + json: { [_: string]: any }, + auth: LegacyAuthenticationCreds, + curveKeys: CurveKeyPair +) => { + // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone + const onValidationSuccess = () => { + const user: Contact = { + id: jidNormalizedUser(json.wid), + name: json.pushname + } + return { user, auth, phone: json.phone } + } + if (!json.secret) { + // if we didn't get a secret, we don't need it, we're validated + if (json.clientToken && json.clientToken !== auth.clientToken) { + auth = { ...auth, clientToken: json.clientToken } + } + if (json.serverToken && json.serverToken !== auth.serverToken) { + auth = { ...auth, serverToken: json.serverToken } + } + return onValidationSuccess() + } + const secret = Buffer.from(json.secret, 'base64') + if (secret.length !== 144) { + throw new Error ('incorrect secret length received: ' + secret.length) + } + + // generate shared key from our private key & the secret shared by the server + const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32)) + // expand the key to 80 bytes using HKDF + const expandedKey = hkdf(sharedKey as Buffer, 80, { }) + + // perform HMAC validation. + const hmacValidationKey = expandedKey.slice(32, 64) + const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)]) + + const hmac = hmacSign(hmacValidationMessage, hmacValidationKey) + + if (!hmac.equals(secret.slice(32, 64))) { + // if the checksums didn't match + throw new Boom('HMAC validation failed', { statusCode: 400 }) + } + + // computed HMAC should equal secret[32:64] + // expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp + // they are encrypted using key: expandedKey[0:32] + const encryptedAESKeys = Buffer.concat([ + expandedKey.slice(64, expandedKey.length), + secret.slice(64, secret.length), + ]) + const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32)) + // set the credentials + auth = { + encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages + macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages + clientToken: json.clientToken, + serverToken: json.serverToken, + clientID: auth.clientID, + } + return onValidationSuccess() +} + +export const computeChallengeResponse = (challenge: string, auth: LegacyAuthenticationCreds) => { + const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string + const signed = hmacSign(bytes, auth.macKey).toString('base64') // sign the challenge string with our macKey + return['admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID +} + +export const useSingleFileLegacyAuthState = (file: string) => { + // require fs here so that in case "fs" is not available -- the app does not crash + const { readFileSync, writeFileSync, existsSync } = require('fs') + let state: LegacyAuthenticationCreds + + if(existsSync(file)) { + state = JSON.parse( + readFileSync(file, { encoding: 'utf-8' }), + BufferJSON.reviver + ) + if(typeof state.encKey === 'string') { + state.encKey = Buffer.from(state.encKey, 'base64') + } + if(typeof state.macKey === 'string') { + state.macKey = Buffer.from(state.macKey, 'base64') + } + } else { + state = newLegacyAuthCreds() + } + + return { + state, + saveState: () => { + const str = JSON.stringify(state, BufferJSON.replacer, 2) + writeFileSync(file, str) + } + } +} \ No newline at end of file diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index ee3ed5c..8a1f077 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -4,16 +4,16 @@ import type { Options, Response } from 'got' import { Boom } from '@hapi/boom' import * as Crypto from 'crypto' import { Readable, Transform } from 'stream' -import { createReadStream, createWriteStream, promises as fs, ReadStream, WriteStream } from 'fs' +import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs' import { exec } from 'child_process' import { tmpdir } from 'os' import { URL } from 'url' import { join } from 'path' import { once } from 'events' -import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage } from '../Types' +import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage, CommonSocketConfig, WAMediaUploadFunction, MediaConnInfo } from '../Types' import { generateMessageID } from './generics' import { hkdf } from './crypto' -import { DEFAULT_ORIGIN } from '../Defaults' +import { DEFAULT_ORIGIN, MEDIA_PATH_MAP } from '../Defaults' const getTmpFilesDirectory = () => tmpdir() @@ -459,4 +459,58 @@ export function extensionForMediaMessage(message: WAMessageContent) { extension = getExtension (messageContent.mimetype) } return extension +} + +export const getWAUploadToServer = ({ customUploadHosts, agent, logger }: CommonSocketConfig, refreshMediaConn: (force: boolean) => Promise): WAMediaUploadFunction => { + return async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => { + const { default: got } = await import('got') + // send a query JSON to obtain the url & auth token to upload our media + let uploadInfo = await refreshMediaConn(false) + + let urls: { mediaUrl: string, directPath: string } + const hosts = [ ...customUploadHosts, ...uploadInfo.hosts.map(h => h.hostname) ] + for (let hostname of hosts) { + const auth = encodeURIComponent(uploadInfo.auth) // the auth token + const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` + + try { + const {body: responseText} = await got.post( + url, + { + headers: { + 'Content-Type': 'application/octet-stream', + 'Origin': DEFAULT_ORIGIN + }, + agent: { + https: agent + }, + body: stream, + timeout: timeoutMs + } + ) + const result = JSON.parse(responseText) + + if(result?.url || result?.directPath) { + urls = { + mediaUrl: result.url, + directPath: result.direct_path + } + break + } else { + uploadInfo = await refreshMediaConn(true) + throw new Error(`upload failed, reason: ${JSON.stringify(result)}`) + } + } catch (error) { + const isLast = hostname === hosts[uploadInfo.hosts.length-1] + logger.debug(`Error in uploading to ${hostname} (${error}) ${isLast ? '' : ', retrying...'}`) + } + } + if (!urls) { + throw new Boom( + 'Media upload failed on all hosts', + { statusCode: 500 } + ) + } + return urls + } } \ No newline at end of file diff --git a/src/WABinary/Legacy/constants.ts b/src/WABinary/Legacy/constants.ts new file mode 100644 index 0000000..a9be6d6 --- /dev/null +++ b/src/WABinary/Legacy/constants.ts @@ -0,0 +1,198 @@ + +export const Tags = { + LIST_EMPTY: 0, + STREAM_END: 2, + DICTIONARY_0: 236, + DICTIONARY_1: 237, + DICTIONARY_2: 238, + DICTIONARY_3: 239, + LIST_8: 248, + LIST_16: 249, + JID_PAIR: 250, + HEX_8: 251, + BINARY_8: 252, + BINARY_20: 253, + BINARY_32: 254, + NIBBLE_8: 255, + SINGLE_BYTE_MAX: 256, + PACKED_MAX: 254, +} +export const DoubleByteTokens = [] +export const SingleByteTokens = [ + null, + null, + null, + '200', + '400', + '404', + '500', + '501', + '502', + 'action', + 'add', + 'after', + 'archive', + 'author', + 'available', + 'battery', + 'before', + 'body', + 'broadcast', + 'chat', + 'clear', + 'code', + 'composing', + 'contacts', + 'count', + 'create', + 'debug', + 'delete', + 'demote', + 'duplicate', + 'encoding', + 'error', + 'false', + 'filehash', + 'from', + 'g.us', + 'group', + 'groups_v2', + 'height', + 'id', + 'image', + 'in', + 'index', + 'invis', + 'item', + 'jid', + 'kind', + 'last', + 'leave', + 'live', + 'log', + 'media', + 'message', + 'mimetype', + 'missing', + 'modify', + 'name', + 'notification', + 'notify', + 'out', + 'owner', + 'participant', + 'paused', + 'picture', + 'played', + 'presence', + 'preview', + 'promote', + 'query', + 'raw', + 'read', + 'receipt', + 'received', + 'recipient', + 'recording', + 'relay', + 'remove', + 'response', + 'resume', + 'retry', + 's.whatsapp.net', + 'seconds', + 'set', + 'size', + 'status', + 'subject', + 'subscribe', + 't', + 'text', + 'to', + 'true', + 'type', + 'unarchive', + 'unavailable', + 'url', + 'user', + 'value', + 'web', + 'width', + 'mute', + 'read_only', + 'admin', + 'creator', + 'short', + 'update', + 'powersave', + 'checksum', + 'epoch', + 'block', + 'previous', + '409', + 'replaced', + 'reason', + 'spam', + 'modify_tag', + 'message_info', + 'delivery', + 'emoji', + 'title', + 'description', + 'canonical-url', + 'matched-text', + 'star', + 'unstar', + 'media_key', + 'filename', + 'identity', + 'unread', + 'page', + 'page_count', + 'search', + 'media_message', + 'security', + 'call_log', + 'profile', + 'ciphertext', + 'invite', + 'gif', + 'vcard', + 'frequent', + 'privacy', + 'blacklist', + 'whitelist', + 'verify', + 'location', + 'document', + 'elapsed', + 'revoke_invite', + 'expiration', + 'unsubscribe', + 'disable', + 'vname', + 'old_jid', + 'new_jid', + 'announcement', + 'locked', + 'prop', + 'label', + 'color', + 'call', + 'offer', + 'call-id', + 'quick_reply', + 'sticker', + 'pay_t', + 'accept', + 'reject', + 'sticker_pack', + 'invalid', + 'canceled', + 'missed', + 'connected', + 'result', + 'audio', + 'video', + 'recent', +] \ No newline at end of file diff --git a/src/WABinary/Legacy/index.ts b/src/WABinary/Legacy/index.ts new file mode 100644 index 0000000..945eed0 --- /dev/null +++ b/src/WABinary/Legacy/index.ts @@ -0,0 +1,337 @@ + +import { BinaryNode } from '../types' +import { DoubleByteTokens, SingleByteTokens, Tags } from './constants' + +export const isLegacyBinaryNode = (buffer: Buffer) => { + switch(buffer[0]) { + case Tags.LIST_EMPTY: + case Tags.LIST_8: + case Tags.LIST_16: + return true + default: + return false + } +} + +function decode(buffer: Buffer, indexRef: { index: number }): BinaryNode { + + const checkEOS = (length: number) => { + if (indexRef.index + length > buffer.length) { + throw new Error('end of stream') + } + } + const next = () => { + const value = buffer[indexRef.index] + indexRef.index += 1 + return value + } + const readByte = () => { + checkEOS(1) + return next() + } + const readStringFromChars = (length: number) => { + checkEOS(length) + const value = buffer.slice(indexRef.index, indexRef.index + length) + + indexRef.index += length + return value.toString('utf-8') + } + const readBytes = (n: number) => { + checkEOS(n) + const value = buffer.slice(indexRef.index, indexRef.index + n) + indexRef.index += n + return value + } + const readInt = (n: number, littleEndian = false) => { + checkEOS(n) + let val = 0 + for (let i = 0; i < n; i++) { + const shift = littleEndian ? i : n - 1 - i + val |= next() << (shift * 8) + } + return val + } + const readInt20 = () => { + checkEOS(3) + return ((next() & 15) << 16) + (next() << 8) + next() + } + const unpackHex = (value: number) => { + if (value >= 0 && value < 16) { + return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10 + } + throw new Error('invalid hex: ' + value) + } + const unpackNibble = (value: number) => { + if (value >= 0 && value <= 9) { + return '0'.charCodeAt(0) + value + } + switch (value) { + case 10: + return '-'.charCodeAt(0) + case 11: + return '.'.charCodeAt(0) + case 15: + return '\0'.charCodeAt(0) + default: + throw new Error('invalid nibble: ' + value) + } + } + const unpackByte = (tag: number, value: number) => { + if (tag === Tags.NIBBLE_8) { + return unpackNibble(value) + } else if (tag === Tags.HEX_8) { + return unpackHex(value) + } else { + throw new Error('unknown tag: ' + tag) + } + } + const readPacked8 = (tag: number) => { + const startByte = readByte() + let value = '' + + for (let i = 0; i < (startByte & 127); i++) { + const curByte = readByte() + value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4)) + value += String.fromCharCode(unpackByte(tag, curByte & 0x0f)) + } + if (startByte >> 7 !== 0) { + value = value.slice(0, -1) + } + return value + } + const isListTag = (tag: number) => { + return tag === Tags.LIST_EMPTY || tag === Tags.LIST_8 || tag === Tags.LIST_16 + } + const readListSize = (tag: number) => { + switch (tag) { + case Tags.LIST_EMPTY: + return 0 + case Tags.LIST_8: + return readByte() + case Tags.LIST_16: + return readInt(2) + default: + throw new Error('invalid tag for list size: ' + tag) + } + } + const getToken = (index: number) => { + if (index < 3 || index >= SingleByteTokens.length) { + throw new Error('invalid token index: ' + index) + } + return SingleByteTokens[index] + } + const readString = (tag: number) => { + if (tag >= 3 && tag <= 235) { + const token = getToken(tag) + return token// === 's.whatsapp.net' ? 'c.us' : token + } + + switch (tag) { + case Tags.DICTIONARY_0: + case Tags.DICTIONARY_1: + case Tags.DICTIONARY_2: + case Tags.DICTIONARY_3: + return getTokenDouble(tag - Tags.DICTIONARY_0, readByte()) + case Tags.LIST_EMPTY: + return null + case Tags.BINARY_8: + return readStringFromChars(readByte()) + case Tags.BINARY_20: + return readStringFromChars(readInt20()) + case Tags.BINARY_32: + return readStringFromChars(readInt(4)) + case Tags.JID_PAIR: + const i = readString(readByte()) + const j = readString(readByte()) + if (typeof i === 'string' && j) { + return i + '@' + j + } + throw new Error('invalid jid pair: ' + i + ', ' + j) + case Tags.HEX_8: + case Tags.NIBBLE_8: + return readPacked8(tag) + default: + throw new Error('invalid string with tag: ' + tag) + } + } + const readList = (tag: number) => ( + [...new Array(readListSize(tag))].map(() => decode(buffer, indexRef)) + ) + const getTokenDouble = (index1: number, index2: number) => { + const n = 256 * index1 + index2 + if (n < 0 || n > DoubleByteTokens.length) { + throw new Error('invalid double token index: ' + n) + } + return DoubleByteTokens[n] + } + + const listSize = readListSize(readByte()) + const descrTag = readByte() + if (descrTag === Tags.STREAM_END) { + throw new Error('unexpected stream end') + } + const header = readString(descrTag) + const attrs: BinaryNode['attrs'] = { } + let data: BinaryNode['content'] + if (listSize === 0 || !header) { + throw new Error('invalid node') + } + // read the attributes in + + const attributesLength = (listSize - 1) >> 1 + for (let i = 0; i < attributesLength; i++) { + const key = readString(readByte()) + const b = readByte() + + attrs[key] = readString(b) + } + + if (listSize % 2 === 0) { + const tag = readByte() + if (isListTag(tag)) { + data = readList(tag) + } else { + let decoded: Buffer | string + switch (tag) { + case Tags.BINARY_8: + decoded = readBytes(readByte()) + break + case Tags.BINARY_20: + decoded = readBytes(readInt20()) + break + case Tags.BINARY_32: + decoded = readBytes(readInt(4)) + break + default: + decoded = readString(tag) + break + } + data = decoded + } + } + + return { + tag: header, + attrs, + content: data + } +} + +const encode = ({ tag, attrs, content }: BinaryNode, buffer: number[] = []) => { + + const pushByte = (value: number) => buffer.push(value & 0xff) + + const pushInt = (value: number, n: number, littleEndian=false) => { + for (let i = 0; i < n; i++) { + const curShift = littleEndian ? i : n - 1 - i + buffer.push((value >> (curShift * 8)) & 0xff) + } + } + const pushBytes = (bytes: Uint8Array | Buffer | number[]) => ( + bytes.forEach (b => buffer.push(b)) + ) + const pushInt20 = (value: number) => ( + pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff]) + ) + const writeByteLength = (length: number) => { + if (length >= 4294967296) throw new Error('string too large to encode: ' + length) + + if (length >= 1 << 20) { + pushByte(Tags.BINARY_32) + pushInt(length, 4) // 32 bit integer + } else if (length >= 256) { + pushByte(Tags.BINARY_20) + pushInt20(length) + } else { + pushByte(Tags.BINARY_8) + pushByte(length) + } + } + const writeStringRaw = (str: string) => { + const bytes = Buffer.from (str, 'utf-8') + writeByteLength(bytes.length) + pushBytes(bytes) + } + const writeToken = (token: number) => { + if (token < 245) { + pushByte(token) + } else if (token <= 500) { + throw new Error('invalid token') + } + } + const writeString = (token: string, i?: boolean) => { + if (token === 'c.us') token = 's.whatsapp.net' + + const tokenIndex = SingleByteTokens.indexOf(token) + if (!i && token === 's.whatsapp.net') { + writeToken(tokenIndex) + } else if (tokenIndex >= 0) { + if (tokenIndex < Tags.SINGLE_BYTE_MAX) { + writeToken(tokenIndex) + } else { + const overflow = tokenIndex - Tags.SINGLE_BYTE_MAX + const dictionaryIndex = overflow >> 8 + if (dictionaryIndex < 0 || dictionaryIndex > 3) { + throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex) + } + writeToken(Tags.DICTIONARY_0 + dictionaryIndex) + writeToken(overflow % 256) + } + } else if (token) { + const jidSepIndex = token.indexOf('@') + if (jidSepIndex <= 0) { + writeStringRaw(token) + } else { + writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length)) + } + } + } + const writeJid = (left: string, right: string) => { + pushByte(Tags.JID_PAIR) + left && left.length > 0 ? writeString(left) : writeToken(Tags.LIST_EMPTY) + writeString(right) + } + const writeListStart = (listSize: number) => { + if (listSize === 0) { + pushByte(Tags.LIST_EMPTY) + } else if (listSize < 256) { + pushBytes([Tags.LIST_8, listSize]) + } else { + pushBytes([Tags.LIST_16, listSize]) + } + } + const validAttributes = Object.keys(attrs).filter(k => ( + typeof attrs[k] !== 'undefined' && attrs[k] !== null + )) + + writeListStart(2*validAttributes.length + 1 + (typeof content !== 'undefined' && content !== null ? 1 : 0)) + writeString(tag) + + validAttributes.forEach((key) => { + if(typeof attrs[key] === 'string') { + writeString(key) + writeString(attrs[key]) + } + }) + + if (typeof content === 'string') { + writeString(content, true) + } else if (Buffer.isBuffer(content)) { + writeByteLength(content.length) + pushBytes(content) + } else if (Array.isArray(content)) { + writeListStart(content.length) + for(const item of content) { + if(item) encode(item, buffer) + } + } else if(typeof content === 'undefined' || content === null) { + + } else { + throw new Error(`invalid children for header "${tag}": ${content} (${typeof content})`) + } + + return Buffer.from(buffer) +} + +export const encodeBinaryNodeLegacy = encode +export const decodeBinaryNodeLegacy = decode diff --git a/src/WABinary/index.ts b/src/WABinary/index.ts index 644a870..0633c4f 100644 --- a/src/WABinary/index.ts +++ b/src/WABinary/index.ts @@ -2,6 +2,8 @@ import { DICTIONARIES_MAP, SINGLE_BYTE_TOKEN, SINGLE_BYTE_TOKEN_MAP, DICTIONARIE import { jidDecode, jidEncode } from './jid-utils'; import { Binary, numUtf8Bytes } from '../../WABinary/Binary'; import { Boom } from '@hapi/boom'; +import { proto } from '../../WAProto'; +import { BinaryNode } from './types'; const LIST1 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '.', '�', '�', '�', '�']; const LIST2 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F']; @@ -209,20 +211,6 @@ function bufferToUInt(e: Uint8Array | Buffer, t: number) { for (let i = 0; i < t; i++) a = 256 * a + e[i] return a } -/** - * the binary node WA uses internally for communication - * - * this is manipulated soley as an object and it does not have any functions. - * This is done for easy serialization, to prevent running into issues with prototypes & - * to maintain functional code structure - * */ -export type BinaryNode = { - tag: string - attrs: { [key: string]: string } - content?: BinaryNode[] | string | Uint8Array -} -export type BinaryNodeAttributes = BinaryNode['attrs'] -export type BinaryNodeData = BinaryNode['content'] export const decodeBinaryNode = (data: Binary): BinaryNode => { //U @@ -319,5 +307,19 @@ export const reduceBinaryNodeToDictionary = (node: BinaryNode, tag: string) => { return dict } +export const getBinaryNodeMessages = ({ content }: BinaryNode) => { + const msgs: proto.WebMessageInfo[] = [] + if(Array.isArray(content)) { + for(const item of content) { + if(item.tag === 'message') { + msgs.push(proto.WebMessageInfo.decode(item.content as Buffer)) + } + } + } + return msgs +} + export * from './jid-utils' -export { Binary } from '../../WABinary/Binary' \ No newline at end of file +export { Binary } from '../../WABinary/Binary' +export * from './types' +export * from './Legacy' \ No newline at end of file diff --git a/src/WABinary/types.ts b/src/WABinary/types.ts new file mode 100644 index 0000000..96e35d4 --- /dev/null +++ b/src/WABinary/types.ts @@ -0,0 +1,14 @@ +/** + * the binary node WA uses internally for communication + * + * this is manipulated soley as an object and it does not have any functions. + * This is done for easy serialization, to prevent running into issues with prototypes & + * to maintain functional code structure + * */ + export type BinaryNode = { + tag: string + attrs: { [key: string]: string } + content?: BinaryNode[] | string | Uint8Array +} +export type BinaryNodeAttributes = BinaryNode['attrs'] +export type BinaryNodeData = BinaryNode['content'] \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fe1c58f..d445a48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import makeWASocket from './Socket' +import makeWALegacySocket from './LegacySocket' export * from '../WAProto' export * from './Utils' @@ -7,6 +8,10 @@ export * from './Types' export * from './Defaults' export * from './WABinary' +export type WALegacySocket = ReturnType + +export { makeWALegacySocket } + export type WASocket = ReturnType export default makeWASocket \ No newline at end of file