diff --git a/package.json b/package.json index c2c24d8..c2e2c01 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "peerDependencies": { "@adiwajshing/keyed-db": "^0.2.4", + "@peculiar/webcrypto": "^1.4.1", "jimp": "^0.16.1", "link-preview-js": "^3.0.0", "qrcode-terminal": "^0.12.0", diff --git a/proto-extract/index.js b/proto-extract/index.js index 02dc143..a3a6365 100644 --- a/proto-extract/index.js +++ b/proto-extract/index.js @@ -285,7 +285,7 @@ async function findAppModules() { const indentation = moduleIndentationMap[info.type]?.indentation let typeName = unnestName(info.type) if(indentation !== parentName && indentation) { - typeName = `${indentation.replaceAll('$', '.')}.${typeName}` + typeName = `${indentation.replace(/\$/g, '.')}.${typeName}` } // if(info.enumValues) { diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 71f7efc..5697301 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1,3 +1,3 @@ { - "version": [2, 2243, 7] + "version": [2, 2244, 6] } \ No newline at end of file diff --git a/src/Socket/business.ts b/src/Socket/business.ts index 457701e..114d4f3 100644 --- a/src/Socket/business.ts +++ b/src/Socket/business.ts @@ -1,6 +1,6 @@ -import { GetCatalogOptions, ProductCreate, ProductUpdate, SocketConfig } from '../Types' +import { ProductCreate, ProductUpdate, SocketConfig } from '../Types' import { parseCatalogNode, parseCollectionsNode, parseOrderDetailsNode, parseProductNode, toProductNode, uploadingNecessaryImagesOfProduct } from '../Utils/business' -import { BinaryNode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' +import { jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' import { getBinaryNodeChild } from '../WABinary/generic-utils' import { makeMessagesRecvSocket } from './messages-recv' @@ -12,36 +12,9 @@ export const makeBusinessSocket = (config: SocketConfig) => { waUploadToServer } = sock - const getCatalog = async({ jid, limit, cursor }: GetCatalogOptions) => { + const getCatalog = async(jid?: string, limit = 10) => { jid = jid || authState.creds.me?.id jid = jidNormalizedUser(jid!) - - const queryParamNodes: BinaryNode[] = [ - { - tag: 'limit', - attrs: { }, - content: Buffer.from((limit || 10).toString()) - }, - { - tag: 'width', - attrs: { }, - content: Buffer.from('100') - }, - { - tag: 'height', - attrs: { }, - content: Buffer.from('100') - }, - ] - - if(cursor) { - queryParamNodes.push({ - tag: 'after', - attrs: { }, - content: cursor - }) - } - const result = await query({ tag: 'iq', attrs: { @@ -56,7 +29,23 @@ export const makeBusinessSocket = (config: SocketConfig) => { jid, allow_shop_source: 'true' }, - content: queryParamNodes + content: [ + { + tag: 'limit', + attrs: { }, + content: Buffer.from(limit.toString()) + }, + { + tag: 'width', + attrs: { }, + content: Buffer.from('100') + }, + { + tag: 'height', + attrs: { }, + content: Buffer.from('100') + } + ] } ] }) diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index ee34528..6a2190d 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -494,27 +494,14 @@ export const makeChatsSocket = (config: SocketConfig) => { } } - /** - * @param toJid the jid to subscribe to - * @param tcToken token for subscription, use if present - */ - const presenceSubscribe = (toJid: string, tcToken?: Buffer) => ( + const presenceSubscribe = (toJid: string) => ( sendNode({ tag: 'presence', attrs: { to: toJid, id: generateMessageTag(), type: 'subscribe' - }, - content: tcToken - ? [ - { - tag: 'tctoken', - attrs: { }, - content: tcToken - } - ] - : undefined + } }) ) @@ -738,7 +725,7 @@ export const makeChatsSocket = (config: SocketConfig) => { ) : false - if(historyMsg && !authState.creds.myAppStateKeyId) { + if(shouldProcessHistoryMsg && !authState.creds.myAppStateKeyId) { logger.warn('skipping app state sync, as myAppStateKeyId is not set') pendingAppStateSync = true } @@ -746,7 +733,7 @@ export const makeChatsSocket = (config: SocketConfig) => { await Promise.all([ (async() => { if( - historyMsg + shouldProcessHistoryMsg && authState.creds.myAppStateKeyId ) { pendingAppStateSync = false @@ -835,8 +822,7 @@ export const makeChatsSocket = (config: SocketConfig) => { // we keep buffering events until we finally have // the key and can sync the messages if(!authState.creds?.myAppStateKeyId) { - ev.buffer() - needToFlushWithAppStateSync = true + needToFlushWithAppStateSync = ev.buffer() } } }) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 7310b4d..d7118f5 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -268,21 +268,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const from = jidNormalizedUser(node.attrs.from) switch (nodeType) { - case 'privacy_token': - const tokenList = getBinaryNodeChildren(child, 'token') - for(const { attrs, content } of tokenList) { - const jid = attrs.jid - ev.emit('chats.update', [ - { - id: jid, - tcToken: content as Buffer - } - ]) - - logger.debug({ jid }, 'got privacy token update') - } - - break case 'w:gp2': handleGroupNotification(node.attrs.participant, child, result) break @@ -660,9 +645,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { identifier: string, exec: (node: BinaryNode) => Promise ) => { - ev.buffer() - await execTask() - ev.flush() + const started = ev.buffer() + if(started) { + await execTask() + if(started) { + await ev.flush() + } + } else { + const task = execTask() + ev.processInBuffer(task) + } function execTask() { return exec(node) @@ -670,6 +662,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } } + // called when all offline notifs are handled + ws.on('CB:ib,,offline', async(node: BinaryNode) => { + const child = getBinaryNodeChild(node, 'offline') + const offlineNotifs = +(child?.attrs.count || 0) + + logger.info(`handled ${offlineNotifs} offline messages/notifications`) + await ev.flush() + + ev.emit('connection.update', { receivedPendingNotifications: true }) + }) + // recv a message ws.on('CB:message', (node: BinaryNode) => { processNodeWithBuffer(node, 'processing message', handleMessage) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index fbbb6e3..34b546a 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -532,32 +532,11 @@ export const makeSocket = ({ end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch })) }) - let didStartBuffer = false process.nextTick(() => { - if(creds.me?.id) { - // start buffering important events - // if we're logged in - ev.buffer() - didStartBuffer = true - } - + // start buffering important events + ev.buffer() ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false, qr: undefined }) }) - - // called when all offline notifs are handled - ws.on('CB:ib,,offline', (node: BinaryNode) => { - const child = getBinaryNodeChild(node, 'offline') - const offlineNotifs = +(child?.attrs.count || 0) - - logger.info(`handled ${offlineNotifs} offline messages/notifications`) - if(didStartBuffer) { - ev.flush() - logger.trace('flushed events for initial buffer') - } - - ev.emit('connection.update', { receivedPendingNotifications: true }) - }) - // update credentials when required ev.on('creds.update', update => { const name = update.me?.name diff --git a/src/Tests/test.event-buffer.ts b/src/Tests/test.event-buffer.ts index e2453e1..71c98f7 100644 --- a/src/Tests/test.event-buffer.ts +++ b/src/Tests/test.event-buffer.ts @@ -22,25 +22,16 @@ describe('Event Buffer Tests', () => { ev.on('chats.update', () => fail('should not emit update event')) ev.buffer() - await Promise.all([ - (async() => { - ev.buffer() - await delay(100) - ev.emit('chats.upsert', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) - const flushed = ev.flush() - expect(flushed).toBeFalsy() - })(), - (async() => { - ev.buffer() - await delay(200) - ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 124, unreadCount: 1 }]) - const flushed = ev.flush() - expect(flushed).toBeFalsy() - })() - ]) + ev.processInBuffer((async() => { + await delay(100) + ev.emit('chats.upsert', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) + })()) + ev.processInBuffer((async() => { + await delay(200) + ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 124, unreadCount: 1 }]) + })()) - const flushed = ev.flush() - expect(flushed).toBeTruthy() + await ev.flush() expect(chats).toHaveLength(1) expect(chats[0].conversationTimestamp).toEqual(124) @@ -60,7 +51,7 @@ describe('Event Buffer Tests', () => { ev.emit('chats.delete', [chatId]) ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 124, unreadCount: 1 }]) - ev.flush() + await ev.flush() expect(chats).toHaveLength(1) }) @@ -77,7 +68,7 @@ describe('Event Buffer Tests', () => { ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) ev.emit('chats.delete', [chatId]) - ev.flush() + await ev.flush() expect(chatsDeleted).toHaveLength(1) }) @@ -112,7 +103,7 @@ describe('Event Buffer Tests', () => { } }]) - ev.flush() + await ev.flush() ev.buffer() ev.emit('chats.upsert', [{ @@ -132,7 +123,7 @@ describe('Event Buffer Tests', () => { messages: [], isLatest: false }) - ev.flush() + await ev.flush() expect(chatsUpserted).toHaveLength(1) expect(chatsUpserted[0].id).toEqual(chatId) @@ -168,7 +159,7 @@ describe('Event Buffer Tests', () => { muteEndTime: 123 }]) - ev.flush() + await ev.flush() expect(chatsUpserted).toHaveLength(1) expect(chatsUpserted[0].archived).toBeUndefined() @@ -193,7 +184,7 @@ describe('Event Buffer Tests', () => { }) ev.emit('chats.update', [{ id: chatId, archived: true }]) - ev.flush() + await ev.flush() expect(chatRecv).toBeDefined() expect(chatRecv?.archived).toBeTruthy() @@ -227,7 +218,7 @@ describe('Event Buffer Tests', () => { ev.emit('messages.upsert', { messages: [proto.WebMessageInfo.fromObject(msg)], type: 'notify' }) ev.emit('messages.update', [{ key: msg.key, update: { status: WAMessageStatus.READ } }]) - ev.flush() + await ev.flush() expect(msgs).toHaveLength(1) expect(msgs[0].message).toBeTruthy() @@ -263,7 +254,7 @@ describe('Event Buffer Tests', () => { } ]) - ev.flush() + await ev.flush() expect(msgs).toHaveLength(1) expect(msgs[0].userReceipt).toHaveLength(1) @@ -284,7 +275,7 @@ describe('Event Buffer Tests', () => { ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.DELIVERY_ACK } }]) ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.READ } }]) - ev.flush() + await ev.flush() expect(msgs).toHaveLength(1) expect(msgs[0].update.status).toEqual(WAMessageStatus.READ) @@ -312,7 +303,7 @@ describe('Event Buffer Tests', () => { ev.emit('chats.update', [{ id: msg.key.remoteJid!, unreadCount: 1, conversationTimestamp: msg.messageTimestamp }]) ev.emit('messages.update', [{ key: msg.key, update: { status: WAMessageStatus.READ } }]) - ev.flush() + await ev.flush() expect(chats[0].unreadCount).toBeUndefined() }) diff --git a/src/Tests/test.messages.ts b/src/Tests/test.messages.ts deleted file mode 100644 index 7f51f39..0000000 --- a/src/Tests/test.messages.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { WAMessageContent } from '../Types' -import { normalizeMessageContent } from '../Utils' - -describe('Messages Tests', () => { - - it('should correctly unwrap messages', () => { - const CONTENT = { imageMessage: { } } - expectRightContent(CONTENT) - expectRightContent({ - ephemeralMessage: { message: CONTENT } - }) - expectRightContent({ - viewOnceMessage: { - message: { - ephemeralMessage: { message: CONTENT } - } - } - }) - expectRightContent({ - viewOnceMessage: { - message: { - viewOnceMessageV2: { - message: { - ephemeralMessage: { message: CONTENT } - } - } - } - } - }) - - function expectRightContent(content: WAMessageContent) { - expect( - normalizeMessageContent(content) - ).toHaveProperty('imageMessage') - } - }) -}) \ No newline at end of file diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 3000e51..d2a6964 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -79,6 +79,12 @@ type WithDimensions = { height?: number } +export type PollMessageOptions = { + name: string + selectableCount?: number + values: Array +} + export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING export type AnyMediaMessageContent = ( ({ @@ -127,6 +133,9 @@ export type AnyRegularMessageContent = ( } & Mentionable & Buttonable & Templatable & Listable) | AnyMediaMessageContent + | ({ + poll: PollMessageOptions + } & Mentionable & Buttonable & Templatable) | { contacts: { displayName?: string diff --git a/src/Types/Product.ts b/src/Types/Product.ts index 4fcb0fc..ca4d5d7 100644 --- a/src/Types/Product.ts +++ b/src/Types/Product.ts @@ -70,15 +70,4 @@ export type OrderProduct = { export type OrderDetails = { price: OrderPrice products: OrderProduct[] -} - -export type CatalogCursor = string - -export type GetCatalogOptions = { - /** cursor to start from */ - cursor?: CatalogCursor - /** number of products to fetch */ - limit?: number - - jid?: string } \ No newline at end of file diff --git a/src/Utils/business.ts b/src/Utils/business.ts index b2fd3f4..f49470e 100644 --- a/src/Utils/business.ts +++ b/src/Utils/business.ts @@ -7,14 +7,7 @@ import { getStream, getUrlFromDirectPath, toReadable } from './messages-media' export const parseCatalogNode = (node: BinaryNode) => { const catalogNode = getBinaryNodeChild(node, 'product_catalog') const products = getBinaryNodeChildren(catalogNode, 'product').map(parseProductNode) - const paging = getBinaryNodeChild(catalogNode, 'paging') - - return { - products, - nextPageCursor: paging - ? getBinaryNodeChildString(paging, 'after') - : undefined - } + return { products } } export const parseCollectionsNode = (node: BinaryNode) => { diff --git a/src/Utils/event-buffer.ts b/src/Utils/event-buffer.ts index 63b349d..f924e9e 100644 --- a/src/Utils/event-buffer.ts +++ b/src/Utils/event-buffer.ts @@ -39,16 +39,15 @@ type BaileysBufferableEventEmitter = BaileysEventEmitter & { process(handler: (events: BaileysEventData) => void | Promise): (() => void) /** * starts buffering events, call flush() to release them + * @returns true if buffering just started, false if it was already buffering * */ - buffer(): void + buffer(): boolean /** buffers all events till the promise completes */ createBufferedFunction(work: (...args: A) => Promise): ((...args: A) => Promise) - /** - * flushes all buffered events - * @param force if true, will flush all data regardless of any pending buffers - * @returns returns true if the flush actually happened, otherwise false - */ - flush(force?: boolean): boolean + /** flushes all buffered events */ + flush(): Promise + /** waits for the task to complete, before releasing the buffer */ + processInBuffer(task: Promise) /** is there an ongoing buffer */ isBuffering(): boolean } @@ -63,7 +62,9 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = const historyCache = new Set() let data = makeBufferData() - let buffersInProgress = 0 + let isBuffering = false + let preBufferTask: Promise = Promise.resolve() + let preBufferTraces: string[] = [] // take the generic event and fire it as a baileys event ev.on('event', (map: BaileysEventData) => { @@ -73,24 +74,25 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = }) function buffer() { - buffersInProgress += 1 + if(!isBuffering) { + logger.trace('buffering events') + isBuffering = true + return true + } + + return false } - function flush(force = false) { - // no buffer going on - if(!buffersInProgress) { - return false + async function flush() { + if(!isBuffering) { + return } - if(!force) { - // reduce the number of buffers in progress - buffersInProgress -= 1 - // if there are still some buffers going on - // then we don't flush now - if(buffersInProgress) { - return false - } - } + logger.trace({ preBufferTraces }, 'releasing buffered events...') + await preBufferTask + + preBufferTraces = [] + isBuffering = false const newData = makeBufferData() const chatUpdates = Object.values(data.chatUpdates) @@ -115,8 +117,6 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = { conditionalChatUpdatesLeft }, 'released buffered events' ) - - return true } return { @@ -131,26 +131,34 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = } }, emit(event: BaileysEvent, evData: BaileysEventMap[T]) { - if(buffersInProgress && BUFFERABLE_EVENT_SET.has(event)) { + if(isBuffering && BUFFERABLE_EVENT_SET.has(event)) { append(data, historyCache, event as any, evData, logger) return true } return ev.emit('event', { [event]: evData }) }, + processInBuffer(task) { + if(isBuffering) { + preBufferTask = Promise.allSettled([ preBufferTask, task ]) + preBufferTraces.push(new Error('').stack!) + } + }, isBuffering() { - return buffersInProgress > 0 + return isBuffering }, buffer, flush, createBufferedFunction(work) { return async(...args) => { - buffer() + const started = buffer() try { const result = await work(...args) return result } finally { - flush() + if(started) { + await flush() + } } } }, diff --git a/src/Utils/index.ts b/src/Utils/index.ts index c4ac689..23329e1 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -14,4 +14,5 @@ export * from './baileys-event-stream' export * from './use-multi-file-auth-state' export * from './link-preview' export * from './event-buffer' -export * from './process-message' \ No newline at end of file +export * from './process-message' +export * from './messages-poll' diff --git a/src/Utils/make-mutex.ts b/src/Utils/make-mutex.ts index 1b1d70b..d897903 100644 --- a/src/Utils/make-mutex.ts +++ b/src/Utils/make-mutex.ts @@ -1,35 +1,16 @@ -import logger from './logger' - -const MUTEX_TIMEOUT_MS = 60_000 - export const makeMutex = () => { let task = Promise.resolve() as Promise - - let taskTimeout: NodeJS.Timeout | undefined - return { mutex(code: () => Promise | T): Promise { task = (async() => { - const stack = new Error('mutex start').stack - let waitOver = false - taskTimeout = setTimeout(() => { - logger.warn({ stack, waitOver }, 'possible mutex deadlock') - }, MUTEX_TIMEOUT_MS) // wait for the previous task to complete // if there is an error, we swallow so as to not block the queue try { await task } catch{ } - waitOver = true - - try { - // execute the current task - const result = await code() - return result - } finally { - clearTimeout(taskTimeout) - } + // execute the current task + return code() })() // we replace the existing task, appending the new piece of execution to it // so the next task will have to wait for this one to finish diff --git a/src/Utils/messages-poll.ts b/src/Utils/messages-poll.ts new file mode 100644 index 0000000..1ba1e16 --- /dev/null +++ b/src/Utils/messages-poll.ts @@ -0,0 +1,139 @@ +// original code: https://gist.github.com/PurpShell/44433d21631ff0aefbea57f7b5e31139 + +/** + * Create crypto instance. + * @description If your nodejs crypto module doesn't have WebCrypto, you must install `@peculiar/webcrypto` first + * @return {Crypto} + */ +export const getCrypto = (): Crypto => { + const c = require('crypto') + + return 'subtle' in (c?.webcrypto || {}) ? c.webcrypto : new (require('@peculiar/webcrypto').Crypto)() +} + +/** + * Compare the SHA-256 hashes of the poll options from the update to find the original choices + * @param options Options from the poll creation message + * @param pollOptionHashes hash from `decryptPollMessageRaw()` + * @return {Promise} the original option, can be empty when none are currently selected + */ +export const comparePollMessage = async(options: string[], pollOptionHashes: string[]): Promise => { + const selectedOptions: string[] = [] + const crypto = getCrypto() + for(const option of options) { + const hash = Buffer + .from( + await crypto.subtle.digest( + 'SHA-256', + (new TextEncoder).encode(option) + ) + ) + .toString('hex').toUpperCase() + + if(pollOptionHashes.findIndex(h => h === hash) > -1) { + selectedOptions.push(option) + } + } + + ; + return selectedOptions +} + +/** + * Raw method to decrypt the message after gathering all information + * @description Use `decryptPollMessage()` instead, only use this if you know what you are doing + * @param encPayload Encryption payload/contents want to decrypt, you can get it from `pollUpdateMessage.vote.encPayload` + * @param encIv Encryption iv (used to decrypt the payload), you can get it from `pollUpdateMessage.vote.encIv` + * @param additionalData poll Additional data to decrypt poll message + * @param decryptionKey Generated decryption key to decrypt the poll message + * @return {Promise} + */ +const decryptPollMessageInternal = async( + encPayload: Uint8Array, + encIv: Uint8Array, + additionalData: Uint8Array, + decryptionKey: Uint8Array, +): Promise => { + const crypto = getCrypto() + + const tagSize_multiplier = 16 + const encoded = encPayload + const key = await crypto.subtle.importKey('raw', decryptionKey, 'AES-GCM', false, ['encrypt', 'decrypt']) + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: encIv, additionalData: additionalData, tagLength: 8 * tagSize_multiplier }, key, encoded) + return new Uint8Array(decrypted).slice(2) // remove 2 bytes (OA20)(space+newline) +} + +/** + * Decode the message from `decryptPollMessageInternal()` + * @param decryptedMessage the message from `decrpytPollMessageInternal()` + * @return {string} + */ +export const decodePollMessage = (decryptedMessage: Uint8Array): string => { + const n = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, 69, 70] + const outarr: number[] = [] + + for(let i = 0; i < decryptedMessage.length; i++) { + const val = decryptedMessage[i] + outarr.push(n[val >> 4], n[15 & val]) + } + + return String.fromCharCode(...outarr) +} + +/** + * raw function to decrypt a poll message update + * @param encPayload Encryption payload/contents want to decrypt, you can get it from `pollUpdateMessage.vote.encPayload` + * @param encIv Encryption iv (used to decrypt the payload), you can get it from `pollUpdateMessage.vote.encIv` + * @param encKey Encryption key (used to decrypt the payload), you need to store/save the encKey. If you want get the encKey, you could get it from `Message.messageContextInfo.messageSecret`, only available on poll creation message. + * @param pollMsgSender sender The sender's jid of poll message, you can use `pollUpdateMessage.pollCreationMessageKey.participant` (Note: you need to normalize the jid first) + * @param pollMsgId The ID of poll message, you can use `pollUpdateMessage.pollMessageCreationKey.id` + * @param voteMsgSender The poll voter's jid, you can use `Message.key.remoteJid`, `Message.key.participant`, or `Message.participant`. (Note: you need to normalize the jid first) + * @return {Promise} The option or empty array if something went wrong OR everything was unticked + */ +export const decryptPollMessageRaw = async( + encKey: Uint8Array, + encPayload: Uint8Array, + encIv: Uint8Array, + pollMsgSender: string, + pollMsgId: string, + voteMsgSender: string +): Promise => { + const enc = new TextEncoder() + const crypto = getCrypto() + + const stanzaId = enc.encode(pollMsgId) + const parentMsgOriginalSender = enc.encode(pollMsgSender) + const modificationSender = enc.encode(voteMsgSender) + const modificationType = enc.encode('Poll Vote') + const pad = new Uint8Array([1]) + + const signMe = new Uint8Array([...stanzaId, ...parentMsgOriginalSender, ...modificationSender, ...modificationType, pad] as any) + + const createSignKey = async(n: Uint8Array = new Uint8Array(32)) => { + return (await crypto.subtle.importKey('raw', n, + { 'name': 'HMAC', 'hash': 'SHA-256' }, false, ['sign'] + )) + } + + const sign = async(n: Uint8Array, key: CryptoKey) => { + return (await crypto.subtle.sign({ 'name': 'HMAC', 'hash': 'SHA-256' }, key, n)) + } + + let key = await createSignKey() + + const temp = await sign(encKey, key) + + key = await createSignKey(new Uint8Array(temp)) + + const decryptionKey = new Uint8Array(await sign(signMe, key)) + + const additionalData = enc.encode(`${pollMsgId}\u0000${voteMsgSender}`) + + const decryptedMessage = await decryptPollMessageInternal(encPayload, encIv, additionalData, decryptionKey) + + const pollOptionHash = decodePollMessage(decryptedMessage) + + // '0A20' in hex represents unicode " " and "\n" thus declaring the end of one option + // we want multiple hashes to make it easier to iterate and understand for your use cases + return pollOptionHash.split('0A20') || [] +} diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index d540b67..0d66384 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -1,5 +1,6 @@ import { Boom } from '@hapi/boom' import axios from 'axios' +import { randomBytes } from 'crypto' import { promises as fs } from 'fs' import { Logger } from 'pino' import { proto } from '../../WAProto' @@ -26,6 +27,7 @@ import { import { isJidGroup, jidNormalizedUser } from '../WABinary' import { generateMessageID, unixTimestampSeconds } from './generics' import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, MediaDownloadOptions } from './messages-media' +import { comparePollMessage, decryptPollMessageRaw } from './messages-poll' type MediaUploadData = { media: WAMediaUpload @@ -375,6 +377,37 @@ export const generateWAMessageContent = async( }) } else if('listReply' in message) { m.listResponseMessage = { ...message.listReply } + } else if('poll' in message) { + if(typeof message.poll.selectableCount !== 'number') { + message.poll.selectableCount = 0 + } + + if(!Array.isArray(message.poll.values)) { + throw new Boom('Invalid poll values', { statusCode: 400 }) + } + + if(message.poll.selectableCount < 0 || message.poll.selectableCount > message.poll.values.length) { + throw new Boom( + `poll.selectableCount in poll should be between 0 and ${ + message.poll.values.length + } or equal to the items length`, { statusCode: 400 } + ) + } + + // link: https://github.com/adiwajshing/Baileys/pull/2290#issuecomment-1304413425 + m.messageContextInfo = { + messageSecret: randomBytes(32), // encKey + } + + m.pollCreationMessage = WAProto.Message.PollCreationMessage.fromObject({ + name: message.poll.name, + selectableOptionsCount: message.poll.selectableCount, + options: message.poll.values.map( + value => WAProto.Message.PollCreationMessage.Option.fromObject({ + optionName: value, + }), + ), + }) } else { m = await prepareWAMessageMedia( message, @@ -569,31 +602,13 @@ export const getContentType = (content: WAProto.IMessage | undefined) => { * @returns */ export const normalizeMessageContent = (content: WAMessageContent | null | undefined): WAMessageContent | undefined => { - if(!content) { - return undefined - } - - // set max iterations to prevent an infinite loop - for(let i = 0;i < 5;i++) { - const inner = getFutureProofMessage(content) - if(!inner) { - break - } - - content = inner.message - } - - return content! - - function getFutureProofMessage(message: typeof content) { - return ( - message?.ephemeralMessage - || message?.viewOnceMessage - || message?.documentWithCaptionMessage - || message?.viewOnceMessageV2 - || message?.editedMessage - ) - } + content = content?.ephemeralMessage?.message?.viewOnceMessage?.message || + content?.ephemeralMessage?.message || + content?.viewOnceMessage?.message || + content?.documentWithCaptionMessage?.message || + content || + undefined + return content } /** @@ -790,3 +805,49 @@ export const assertMediaContent = (content: proto.IMessage | null | undefined) = return mediaContent } + +/** + * Decrypt/Get Poll Update Message Values + * @param msg Full message info contains PollUpdateMessage, you can use `msg` + * @param pollCreationData An object contains `encKey` (used to decrypt the poll message), `sender` (used to create decryption key), and `options` (you should fill it with poll options, e.g. Apple, Orange, etc...) + * @param withSelectedOptions Get user's selected options condition, set it to true if you want get the results. + * @return {Promise<{ hash: string[] } | { hash: string[], selectedOptions: string[] }>} Property `hash` is an array which contains selected options hash, you can use `comparePollMessage` to compare it with original values. Property `selectedOptions` is an array, and the results is from `comparePollMessage` function. + */ +export const getPollUpdateMessage = async( + msg: WAProto.IWebMessageInfo, + pollCreationData: { encKey: Uint8Array; sender: string; options: string[]; }, + withSelectedOptions: boolean = false, +): Promise<{ hash: string[] } | { hash: string[]; selectedOptions: string[] }> => { + if(!msg.message?.pollUpdateMessage || !pollCreationData?.encKey) { + throw new Boom('Missing pollUpdateMessage, or encKey', { statusCode: 400 }) + } + + pollCreationData.sender = msg.message?.pollUpdateMessage?.pollCreationMessageKey?.participant || pollCreationData.sender + if(!pollCreationData.sender?.length) { + throw new Boom('Missing sender', { statusCode: 400 }) + } + + let hash = await decryptPollMessageRaw( + pollCreationData.encKey, // encKey + msg.message?.pollUpdateMessage?.vote?.encPayload!, // enc payload + msg.message?.pollUpdateMessage?.vote?.encIv!, // enc iv + jidNormalizedUser(pollCreationData.sender), // sender + msg.message?.pollUpdateMessage?.pollCreationMessageKey?.id!, // poll id + jidNormalizedUser( + msg.key.remoteJid?.endsWith('@g.us') ? + (msg.key.participant || msg.participant)! : msg.key.remoteJid! + ), // voter + ) + + if(hash.length === 1 && !hash[0].length) { + hash = [] + } + + return withSelectedOptions ? { + hash, + selectedOptions: await comparePollMessage( + pollCreationData.options || [], + hash, + ) + } : { hash } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ee143b1..1228770 100644 --- a/yarn.lock +++ b/yarn.lock @@ -898,6 +898,33 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@peculiar/asn1-schema@^2.1.6", "@peculiar/asn1-schema@^2.3.0": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.3.tgz#21418e1f3819e0b353ceff0c2dad8ccb61acd777" + integrity sha512-6GptMYDMyWBHTUKndHaDsRZUO/XMSgIns2krxcm2L7SEExRHwawFvSwNBhqNPR9HJwv3MruAiF1bhN0we6j6GQ== + dependencies: + asn1js "^3.0.5" + pvtsutils "^1.3.2" + tslib "^2.4.0" + +"@peculiar/json-schema@^1.1.12": + version "1.1.12" + resolved "https://registry.yarnpkg.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz#fe61e85259e3b5ba5ad566cb62ca75b3d3cd5339" + integrity sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w== + dependencies: + tslib "^2.0.0" + +"@peculiar/webcrypto@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.4.1.tgz#821493bd5ad0f05939bd5f53b28536f68158360a" + integrity sha512-eK4C6WTNYxoI7JOabMoZICiyqRRtJB220bh0Mbj5RwRycleZf9BPyZoxsTvpP0FpmVS2aS13NKOuh5/tN3sIRw== + dependencies: + "@peculiar/asn1-schema" "^2.3.0" + "@peculiar/json-schema" "^1.1.12" + pvtsutils "^1.3.2" + tslib "^2.4.1" + webcrypto-core "^1.7.4" + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -1394,6 +1421,15 @@ array.prototype.flatmap@^1.3.0: es-abstract "^1.19.2" es-shim-unscopables "^1.0.0" +asn1js@^3.0.1, asn1js@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" + integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== + dependencies: + pvtsutils "^1.3.2" + pvutils "^1.1.3" + tslib "^2.4.0" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -4206,6 +4242,18 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pvtsutils@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" + integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ== + dependencies: + tslib "^2.4.0" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + qrcode-terminal@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" @@ -4841,7 +4889,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.4.0: +tslib@^2.0.0, tslib@^2.4.0, tslib@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== @@ -5004,6 +5052,17 @@ walker@^1.0.7: dependencies: makeerror "1.0.12" +webcrypto-core@^1.7.4: + version "1.7.5" + resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.7.5.tgz#c02104c953ca7107557f9c165d194c6316587ca4" + integrity sha512-gaExY2/3EHQlRNNNVSrbG2Cg94Rutl7fAaKILS1w8ZDhGxdFOaw6EbCfHIxPy9vt/xwp5o0VQAx9aySPF6hU1A== + dependencies: + "@peculiar/asn1-schema" "^2.1.6" + "@peculiar/json-schema" "^1.1.12" + asn1js "^3.0.1" + pvtsutils "^1.3.2" + tslib "^2.4.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"