diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 844449c..6cb9c8c 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,7 +17,26 @@ 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 +const BASE_CONNECTION_CONFIG: CommonSocketConfig = { + 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: [], +} + export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { + ...BASE_CONNECTION_CONFIG, + getMessage: async() => undefined +} + +export const DEFAULT_LEGACY_CONNECTION_CONFIG: LegacySocketConfig = { version: [2, 2146, 9], browser: Browsers.baileys('Chrome'), @@ -29,7 +48,9 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { emitOwnEvents: true, defaultQueryTimeoutMs: 60_000, customUploadHosts: [], - getMessage: async() => undefined + phoneResponseTimeMs: 20_000, + expectResponseTimeout: 60_000, + pendingRequestTimeoutMs: 60_000 } export const MEDIA_PATH_MAP: { [T in MediaType]: string } = { diff --git a/src/LegacySocket/auth.ts b/src/LegacySocket/auth.ts new file mode 100644 index 0000000..21b06a5 --- /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 + + let 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 }) + ) + authInfo = undefined + } + /** 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 + + authInfo = auth + updateEncKeys() + + logger.info({ user }, 'logged in') + + updateState({ + connection: 'open', + legacy: { + phoneConnected: true, + user, + }, + isNewLogin, + qr: undefined + }) + ev.emit('creds.update', auth) + } + 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..ea67f99 --- /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, + 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 + */ + updatePresence: (jid: string | undefined, type: WAPresence) => ( + 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 + * */ + requestPresenceUpdate: 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..60cd4d4 --- /dev/null +++ b/src/LegacySocket/index.ts @@ -0,0 +1,14 @@ +import { LegacySocketConfig } from '../Types' +import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults' +import _makeConnection from './groups' +// export the last socket layer +const makeConnection = (config: Partial) => ( + _makeConnection({ + ...DEFAULT_LEGACY_CONNECTION_CONFIG, + ...config + }) +) + +export type Connection = ReturnType + +export default makeConnection \ No newline at end of file diff --git a/src/LegacySocket/messages.ts b/src/LegacySocket/messages.ts new file mode 100644 index 0000000..026bbc6 --- /dev/null +++ b/src/LegacySocket/messages.ts @@ -0,0 +1,535 @@ +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 { 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 { 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)) + } + + 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)]: 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 } + ) => { + 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 + } + ) + + 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..c80fa4e --- /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, encodeBinaryNode } 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 = encodeBinaryNode(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] === '!') { + // 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') + 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.header || json[0] || '' + const l1 = json?.attributes || json?.[1] || { } + const l2 = json?.data?.[0]?.header || 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 f7d3dd9..0dbf200 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 @@ -424,6 +425,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') @@ -432,7 +438,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 37c51d7..129590b 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -2,7 +2,7 @@ import got from "got" 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" @@ -420,53 +420,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { return msgId } - const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => { - // send a query JSON to obtain the url & auth token to upload our media - let uploadInfo = await refreshMediaConn(false) - - let mediaUrl: 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) - mediaUrl = result?.url - - if (mediaUrl) 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 (!mediaUrl) { - throw new Boom( - 'Media upload failed on all hosts', - { statusCode: 500 } - ) - } - return { mediaUrl } - } - + 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 b7b6380..0906901 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..1da5b82 --- /dev/null +++ b/src/Utils/legacy-msgs.ts @@ -0,0 +1,142 @@ +import { Boom } from '@hapi/boom' +import { randomBytes } from 'crypto' +import { decodeBinaryNode, jidNormalizedUser } from "../WABinary" +import { aesDecrypt, hmacSign, hkdf, Curve } from "./crypto" +import { DisconnectReason, WATag, LegacyAuthenticationCreds, CurveKeyPair, Contact } from "../Types" + +export const newLegacyAuthCreds = () => ({ + clientID: randomBytes(16).toString('base64') +}) as LegacyAuthenticationCreds + +export const decodeWAMessage = ( + message: string | Buffer, + 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 > 0) { + if (typeof data === 'string') { + json = JSON.parse(data) // 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 = decodeBinaryNode(decrypted) // 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 +} \ No newline at end of file diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index e20b847..86c50c9 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -10,10 +10,10 @@ import { URL } from 'url' import { join } from 'path' import { once } from 'events' import got, { Options, Response } from 'got' -import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage } from '../Types' +import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage, WAMediaUploadFunction, MediaConnInfo, CommonSocketConfig } from '../Types' import { generateMessageID } from './generics' import { hkdf } from './crypto' -import { DEFAULT_ORIGIN } from '../Defaults' +import { DEFAULT_ORIGIN, MEDIA_PATH_MAP } from '../Defaults' export const hkdfInfoKey = (type: MediaType) => { let str: string = type @@ -389,4 +389,53 @@ 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 }) => { + // send a query JSON to obtain the url & auth token to upload our media + let uploadInfo = await refreshMediaConn(false) + + let mediaUrl: 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) + mediaUrl = result?.url + + if (mediaUrl) 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 (!mediaUrl) { + throw new Boom( + 'Media upload failed on all hosts', + { statusCode: 500 } + ) + } + return { mediaUrl } + } } \ No newline at end of file diff --git a/src/WABinary/index.ts b/src/WABinary/index.ts index 644a870..1423573 100644 --- a/src/WABinary/index.ts +++ b/src/WABinary/index.ts @@ -2,6 +2,7 @@ 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'; 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']; @@ -319,5 +320,17 @@ 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