From c803e22e8a143f118477f1dc3802e9bb0ba90496 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Fri, 17 Dec 2021 20:58:33 +0530 Subject: [PATCH] feat: functional legacy socket --- Example/example-legacy.ts | 64 ++++++ package.json | 1 + src/Defaults/index.ts | 18 +- src/LegacySocket/auth.ts | 8 +- src/LegacySocket/chats.ts | 6 +- src/LegacySocket/index.ts | 10 +- src/LegacySocket/messages.ts | 17 +- src/LegacySocket/socket.ts | 14 +- src/Socket/messages-send.ts | 3 +- src/Utils/legacy-msgs.ts | 47 ++++- src/WABinary/Legacy/constants.ts | 198 ++++++++++++++++++ src/WABinary/Legacy/index.ts | 337 +++++++++++++++++++++++++++++++ src/WABinary/index.ts | 19 +- src/WABinary/types.ts | 14 ++ src/index.ts | 5 + 15 files changed, 695 insertions(+), 66 deletions(-) create mode 100644 Example/example-legacy.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 6cb9c8c..dba9ff9 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -18,10 +18,10 @@ export const NOISE_WA_HEADER = new Uint8Array([87, 65, 5, 2]) // last is "DICT_V export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi const BASE_CONNECTION_CONFIG: CommonSocketConfig = { - version: [2, 2146, 9], + 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' }), @@ -33,21 +33,13 @@ const BASE_CONNECTION_CONFIG: CommonSocketConfig = { 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 = { - version: [2, 2146, 9], - browser: Browsers.baileys('Chrome'), - - waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', - connectTimeoutMs: 20_000, - keepAliveIntervalMs: 25_000, - logger: P().child({ class: 'baileys' }), - printQRInTerminal: false, - emitOwnEvents: true, - defaultQueryTimeoutMs: 60_000, - customUploadHosts: [], + ...BASE_CONNECTION_CONFIG, + waWebSocketUrl: 'wss://web.whatsapp.com/ws', phoneResponseTimeMs: 20_000, expectResponseTimeout: 60_000, pendingRequestTimeoutMs: 60_000 diff --git a/src/LegacySocket/auth.ts b/src/LegacySocket/auth.ts index 21b06a5..fbae0b4 100644 --- a/src/LegacySocket/auth.ts +++ b/src/LegacySocket/auth.ts @@ -16,7 +16,7 @@ const makeAuthSocket = (config: LegacySocketConfig) => { } = config const ev = new EventEmitter() as LegacyBaileysEventEmitter - let authInfo = initialAuthInfo || newLegacyAuthCreds() + const authInfo = initialAuthInfo || newLegacyAuthCreds() const state: ConnectionState = { legacy: { @@ -73,7 +73,6 @@ const makeAuthSocket = (config: LegacySocketConfig) => { socket?.end( new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut }) ) - authInfo = undefined } /** Waits for the connection to WA to open up */ const waitForConnection = async(waitInfinitely: boolean = false) => { @@ -221,11 +220,13 @@ const makeAuthSocket = (config: LegacySocketConfig) => { const {user, auth} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection const isNewLogin = user.id !== state.legacy!.user?.id - authInfo = auth + Object.assign(authInfo, auth) updateEncKeys() logger.info({ user }, 'logged in') + ev.emit('creds.update', auth) + updateState({ connection: 'open', legacy: { @@ -235,7 +236,6 @@ const makeAuthSocket = (config: LegacySocketConfig) => { isNewLogin, qr: undefined }) - ev.emit('creds.update', auth) } ws.once('open', async() => { try { diff --git a/src/LegacySocket/chats.ts b/src/LegacySocket/chats.ts index ea67f99..67d8c00 100644 --- a/src/LegacySocket/chats.ts +++ b/src/LegacySocket/chats.ts @@ -152,7 +152,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => { const chats = data.map(({ attrs }): Chat => { return { id: jidNormalizedUser(attrs.jid), - conversationTimestamp: +attrs.t, + conversationTimestamp: attrs.t ? +attrs.t : undefined, unreadCount: +attrs.count, archive: attrs.archive === 'true' ? true : undefined, pin: attrs.pin ? +attrs.pin : undefined, @@ -353,7 +353,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => { * @param jid the ID of the person/group who you are updating * @param type your presence */ - updatePresence: (jid: string | undefined, type: WAPresence) => ( + sendPresenceUpdate: ( type: WAPresence, jid: string | undefined) => ( sendMessage({ binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does json: { @@ -372,7 +372,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => { * Request updates on the presence of a user * this returns nothing, you'll receive updates in chats.update event * */ - requestPresenceUpdate: async (jid: string) => ( + presenceSubscribe: async (jid: string) => ( sendMessage({ json: ['action', 'presence', 'subscribe', jid] }) ), /** Query the status of the person (see groupMetadata() for groups) */ diff --git a/src/LegacySocket/index.ts b/src/LegacySocket/index.ts index 60cd4d4..3f1f7bf 100644 --- a/src/LegacySocket/index.ts +++ b/src/LegacySocket/index.ts @@ -1,14 +1,12 @@ import { LegacySocketConfig } from '../Types' import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults' -import _makeConnection from './groups' +import _makeLegacySocket from './groups' // export the last socket layer -const makeConnection = (config: Partial) => ( - _makeConnection({ +const makeLegacySocket = (config: Partial) => ( + _makeLegacySocket({ ...DEFAULT_LEGACY_CONNECTION_CONFIG, ...config }) ) -export type Connection = ReturnType - -export default makeConnection \ No newline at end of file +export default makeLegacySocket \ No newline at end of file diff --git a/src/LegacySocket/messages.ts b/src/LegacySocket/messages.ts index 026bbc6..83d6f58 100644 --- a/src/LegacySocket/messages.ts +++ b/src/LegacySocket/messages.ts @@ -1,10 +1,9 @@ import { BinaryNode, getBinaryNodeMessages, isJidGroup, jidNormalizedUser, areJidsSameUser } from "../WABinary"; import { Boom } from '@hapi/boom' -import { Chat, WAPresence, WAMessageCursor, WAMessage, LegacySocketConfig, WAMessageKey, ParticipantAction, WAMessageStatus, WAMessageStubType, GroupMetadata, AnyMessageContent, MiscMessageGenerationOptions, WAFlag, WAMetric, WAUrlInfo, MediaConnInfo, MessageUpdateType, MessageInfo, MessageInfoUpdate, WAMediaUploadFunction, MediaType, WAMessageUpdate } from "../Types"; +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 { DEFAULT_ORIGIN, MEDIA_PATH_MAP, WA_DEFAULT_EPHEMERAL } from "../Defaults"; -import got from "got"; +import { WA_DEFAULT_EPHEMERAL } from "../Defaults"; import { proto } from "../../WAProto"; const STATUS_MAP = { @@ -288,8 +287,9 @@ const makeMessagesSocket = (config: LegacySocketConfig) => { .then(() => emitUpdate(finalState)) .catch(() => emitUpdate(WAMessageStatus.ERROR)) } - - onMessage(message, 'append') + if(config.emitOwnEvents) { + onMessage(message, 'append') + } } // messages received @@ -362,7 +362,7 @@ const makeMessagesSocket = (config: LegacySocketConfig) => { const updates = ids.map(id => ({ key: { ...keyPartial, id }, update: { - [updateKey]: { [jidNormalizedUser(attributes.participant)]: new Date(+attributes.t) } + [updateKey]: { [jidNormalizedUser(attributes.participant || attributes.to)]: new Date(+attributes.t) } } })) ev.emit('message-info.update', updates) @@ -489,7 +489,7 @@ const makeMessagesSocket = (config: LegacySocketConfig) => { sendWAMessage: async( jid: string, content: AnyMessageContent, - options: MiscMessageGenerationOptions & { waitForAck?: boolean } + options: MiscMessageGenerationOptions & { waitForAck?: boolean } = { waitForAck: true } ) => { const userJid = getState().legacy.user?.id if( @@ -521,7 +521,8 @@ const makeMessagesSocket = (config: LegacySocketConfig) => { logger, userJid: userJid, getUrlInfo: generateUrlInfo, - upload: waUploadToServer + upload: waUploadToServer, + mediaCache: config.mediaCache } ) diff --git a/src/LegacySocket/socket.ts b/src/LegacySocket/socket.ts index c80fa4e..5f7c3bf 100644 --- a/src/LegacySocket/socket.ts +++ b/src/LegacySocket/socket.ts @@ -2,7 +2,7 @@ import { Boom } from '@hapi/boom' import { STATUS_CODES } from "http" import { promisify } from "util" import WebSocket from "ws" -import { BinaryNode, encodeBinaryNode } from "../WABinary" +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" @@ -78,7 +78,7 @@ export const makeSocket = ({ if(!authInfo) { throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 }) } - const binary = encodeBinaryNode(json) // encode the JSON to the WhatsApp binary format + 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 @@ -115,9 +115,9 @@ export const makeSocket = ({ ws.removeAllListeners('ws-close') } const onMessageRecieved = (message: string | Buffer) => { - if(message[0] === '!') { + 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 ('utf-8') + const timestamp = message.slice(1, message.length).toString() lastDateRecv = new Date(parseInt(timestamp)) ws.emit('received-pong') } else { @@ -142,9 +142,9 @@ export const makeSocket = ({ /* 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.header || json[0] || '' - const l1 = json?.attributes || json?.[1] || { } - const l2 = json?.data?.[0]?.header || json[2]?.[0] || '' + 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 diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index c703773..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, 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" diff --git a/src/Utils/legacy-msgs.ts b/src/Utils/legacy-msgs.ts index 1da5b82..ba60d2b 100644 --- a/src/Utils/legacy-msgs.ts +++ b/src/Utils/legacy-msgs.ts @@ -1,7 +1,8 @@ import { Boom } from '@hapi/boom' import { randomBytes } from 'crypto' -import { decodeBinaryNode, jidNormalizedUser } from "../WABinary" +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 = () => ({ @@ -9,11 +10,10 @@ export const newLegacyAuthCreds = () => ({ }) as LegacyAuthenticationCreds export const decodeWAMessage = ( - message: string | Buffer, + 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 @@ -25,10 +25,12 @@ export const decodeWAMessage = ( const messageTag: string = message.slice(0, commaIndex).toString() let json: any let tags: WATag - if (data.length > 0) { - if (typeof data === 'string') { - json = JSON.parse(data) // parse the JSON + 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 }) @@ -49,7 +51,7 @@ export const decodeWAMessage = ( 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 = decodeBinaryNode(decrypted) // decode the binary message into a JSON array + json = decodeBinaryNodeLegacy(decrypted, { index: 0 }) // decode the binary message into a JSON array } else { throw new Boom('Bad checksum', { data: { @@ -138,5 +140,34 @@ export const validateNewConnection = ( 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 + 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/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 1423573..0633c4f 100644 --- a/src/WABinary/index.ts +++ b/src/WABinary/index.ts @@ -3,6 +3,7 @@ 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']; @@ -210,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 @@ -333,4 +320,6 @@ export const getBinaryNodeMessages = ({ content }: BinaryNode) => { } 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