From 78f04d87145deb5a54efae194c953136f15b193e Mon Sep 17 00:00:00 2001 From: Alan Mosko Date: Wed, 11 Jan 2023 23:58:29 -0300 Subject: [PATCH 01/12] wip --- package.json | 1 + proto-extract/index.js | 2 +- src/Defaults/baileys-version.json | 2 +- src/Socket/business.ts | 51 +++++------ src/Socket/chats.ts | 24 ++---- src/Socket/messages-recv.ts | 39 +++++---- src/Socket/socket.ts | 25 +----- src/Tests/test.event-buffer.ts | 47 ++++------ src/Tests/test.messages.ts | 37 -------- src/Types/Message.ts | 9 ++ src/Types/Product.ts | 11 --- src/Utils/business.ts | 9 +- src/Utils/event-buffer.ts | 64 ++++++++------ src/Utils/index.ts | 3 +- src/Utils/make-mutex.ts | 23 +---- src/Utils/messages-poll.ts | 139 ++++++++++++++++++++++++++++++ src/Utils/messages.ts | 111 ++++++++++++++++++------ yarn.lock | 61 ++++++++++++- 18 files changed, 405 insertions(+), 253 deletions(-) delete mode 100644 src/Tests/test.messages.ts create mode 100644 src/Utils/messages-poll.ts 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" From 05dd53e2ce2e7d648026dbc8540a3057d73707b9 Mon Sep 17 00:00:00 2001 From: Alan Mosko Date: Tue, 17 Jan 2023 11:29:10 -0300 Subject: [PATCH 02/12] wip --- proto-extract/index.js | 2 +- src/Defaults/baileys-version.json | 2 +- src/Socket/business.ts | 51 ++++++++++++++---------- src/Socket/chats.ts | 24 +++++++++--- src/Socket/messages-recv.ts | 39 +++++++++---------- src/Socket/socket.ts | 25 +++++++++++- src/Tests/test.event-buffer.ts | 47 ++++++++++++++--------- src/Tests/test.messages.ts | 37 ++++++++++++++++++ src/Types/Product.ts | 11 ++++++ src/Utils/business.ts | 9 ++++- src/Utils/event-buffer.ts | 64 ++++++++++++++----------------- src/Utils/make-mutex.ts | 12 ++++++ 12 files changed, 217 insertions(+), 106 deletions(-) create mode 100644 src/Tests/test.messages.ts diff --git a/proto-extract/index.js b/proto-extract/index.js index a3a6365..02dc143 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.replace(/\$/g, '.')}.${typeName}` + typeName = `${indentation.replaceAll('$', '.')}.${typeName}` } // if(info.enumValues) { diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 5697301..71f7efc 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1,3 +1,3 @@ { - "version": [2, 2244, 6] + "version": [2, 2243, 7] } \ No newline at end of file diff --git a/src/Socket/business.ts b/src/Socket/business.ts index 114d4f3..457701e 100644 --- a/src/Socket/business.ts +++ b/src/Socket/business.ts @@ -1,6 +1,6 @@ -import { ProductCreate, ProductUpdate, SocketConfig } from '../Types' +import { GetCatalogOptions, ProductCreate, ProductUpdate, SocketConfig } from '../Types' import { parseCatalogNode, parseCollectionsNode, parseOrderDetailsNode, parseProductNode, toProductNode, uploadingNecessaryImagesOfProduct } from '../Utils/business' -import { jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' +import { BinaryNode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' import { getBinaryNodeChild } from '../WABinary/generic-utils' import { makeMessagesRecvSocket } from './messages-recv' @@ -12,9 +12,36 @@ export const makeBusinessSocket = (config: SocketConfig) => { waUploadToServer } = sock - const getCatalog = async(jid?: string, limit = 10) => { + const getCatalog = async({ jid, limit, cursor }: GetCatalogOptions) => { 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: { @@ -29,23 +56,7 @@ export const makeBusinessSocket = (config: SocketConfig) => { jid, allow_shop_source: 'true' }, - content: [ - { - tag: 'limit', - attrs: { }, - content: Buffer.from(limit.toString()) - }, - { - tag: 'width', - attrs: { }, - content: Buffer.from('100') - }, - { - tag: 'height', - attrs: { }, - content: Buffer.from('100') - } - ] + content: queryParamNodes } ] }) diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 6a2190d..ee34528 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -494,14 +494,27 @@ export const makeChatsSocket = (config: SocketConfig) => { } } - const presenceSubscribe = (toJid: string) => ( + /** + * @param toJid the jid to subscribe to + * @param tcToken token for subscription, use if present + */ + const presenceSubscribe = (toJid: string, tcToken?: Buffer) => ( sendNode({ tag: 'presence', attrs: { to: toJid, id: generateMessageTag(), type: 'subscribe' - } + }, + content: tcToken + ? [ + { + tag: 'tctoken', + attrs: { }, + content: tcToken + } + ] + : undefined }) ) @@ -725,7 +738,7 @@ export const makeChatsSocket = (config: SocketConfig) => { ) : false - if(shouldProcessHistoryMsg && !authState.creds.myAppStateKeyId) { + if(historyMsg && !authState.creds.myAppStateKeyId) { logger.warn('skipping app state sync, as myAppStateKeyId is not set') pendingAppStateSync = true } @@ -733,7 +746,7 @@ export const makeChatsSocket = (config: SocketConfig) => { await Promise.all([ (async() => { if( - shouldProcessHistoryMsg + historyMsg && authState.creds.myAppStateKeyId ) { pendingAppStateSync = false @@ -822,7 +835,8 @@ export const makeChatsSocket = (config: SocketConfig) => { // we keep buffering events until we finally have // the key and can sync the messages if(!authState.creds?.myAppStateKeyId) { - needToFlushWithAppStateSync = ev.buffer() + ev.buffer() + needToFlushWithAppStateSync = true } } }) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index d7118f5..7310b4d 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -268,6 +268,21 @@ 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 @@ -645,16 +660,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { identifier: string, exec: (node: BinaryNode) => Promise ) => { - const started = ev.buffer() - if(started) { - await execTask() - if(started) { - await ev.flush() - } - } else { - const task = execTask() - ev.processInBuffer(task) - } + ev.buffer() + await execTask() + ev.flush() function execTask() { return exec(node) @@ -662,17 +670,6 @@ 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 34b546a..fbbb6e3 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -532,11 +532,32 @@ export const makeSocket = ({ end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch })) }) + let didStartBuffer = false process.nextTick(() => { - // start buffering important events - ev.buffer() + if(creds.me?.id) { + // start buffering important events + // if we're logged in + ev.buffer() + didStartBuffer = true + } + 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 71c98f7..e2453e1 100644 --- a/src/Tests/test.event-buffer.ts +++ b/src/Tests/test.event-buffer.ts @@ -22,16 +22,25 @@ describe('Event Buffer Tests', () => { ev.on('chats.update', () => fail('should not emit update event')) ev.buffer() - 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 }]) - })()) + 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() + })() + ]) - await ev.flush() + const flushed = ev.flush() + expect(flushed).toBeTruthy() expect(chats).toHaveLength(1) expect(chats[0].conversationTimestamp).toEqual(124) @@ -51,7 +60,7 @@ describe('Event Buffer Tests', () => { ev.emit('chats.delete', [chatId]) ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 124, unreadCount: 1 }]) - await ev.flush() + ev.flush() expect(chats).toHaveLength(1) }) @@ -68,7 +77,7 @@ describe('Event Buffer Tests', () => { ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) ev.emit('chats.delete', [chatId]) - await ev.flush() + ev.flush() expect(chatsDeleted).toHaveLength(1) }) @@ -103,7 +112,7 @@ describe('Event Buffer Tests', () => { } }]) - await ev.flush() + ev.flush() ev.buffer() ev.emit('chats.upsert', [{ @@ -123,7 +132,7 @@ describe('Event Buffer Tests', () => { messages: [], isLatest: false }) - await ev.flush() + ev.flush() expect(chatsUpserted).toHaveLength(1) expect(chatsUpserted[0].id).toEqual(chatId) @@ -159,7 +168,7 @@ describe('Event Buffer Tests', () => { muteEndTime: 123 }]) - await ev.flush() + ev.flush() expect(chatsUpserted).toHaveLength(1) expect(chatsUpserted[0].archived).toBeUndefined() @@ -184,7 +193,7 @@ describe('Event Buffer Tests', () => { }) ev.emit('chats.update', [{ id: chatId, archived: true }]) - await ev.flush() + ev.flush() expect(chatRecv).toBeDefined() expect(chatRecv?.archived).toBeTruthy() @@ -218,7 +227,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 } }]) - await ev.flush() + ev.flush() expect(msgs).toHaveLength(1) expect(msgs[0].message).toBeTruthy() @@ -254,7 +263,7 @@ describe('Event Buffer Tests', () => { } ]) - await ev.flush() + ev.flush() expect(msgs).toHaveLength(1) expect(msgs[0].userReceipt).toHaveLength(1) @@ -275,7 +284,7 @@ describe('Event Buffer Tests', () => { ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.DELIVERY_ACK } }]) ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.READ } }]) - await ev.flush() + ev.flush() expect(msgs).toHaveLength(1) expect(msgs[0].update.status).toEqual(WAMessageStatus.READ) @@ -303,7 +312,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 } }]) - await ev.flush() + ev.flush() expect(chats[0].unreadCount).toBeUndefined() }) diff --git a/src/Tests/test.messages.ts b/src/Tests/test.messages.ts new file mode 100644 index 0000000..7f51f39 --- /dev/null +++ b/src/Tests/test.messages.ts @@ -0,0 +1,37 @@ +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/Product.ts b/src/Types/Product.ts index ca4d5d7..4fcb0fc 100644 --- a/src/Types/Product.ts +++ b/src/Types/Product.ts @@ -70,4 +70,15 @@ 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 f49470e..b2fd3f4 100644 --- a/src/Utils/business.ts +++ b/src/Utils/business.ts @@ -7,7 +7,14 @@ 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) - return { products } + const paging = getBinaryNodeChild(catalogNode, 'paging') + + return { + products, + nextPageCursor: paging + ? getBinaryNodeChildString(paging, 'after') + : undefined + } } export const parseCollectionsNode = (node: BinaryNode) => { diff --git a/src/Utils/event-buffer.ts b/src/Utils/event-buffer.ts index f924e9e..63b349d 100644 --- a/src/Utils/event-buffer.ts +++ b/src/Utils/event-buffer.ts @@ -39,15 +39,16 @@ 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(): boolean + buffer(): void /** buffers all events till the promise completes */ createBufferedFunction(work: (...args: A) => Promise): ((...args: A) => Promise) - /** flushes all buffered events */ - flush(): Promise - /** waits for the task to complete, before releasing the buffer */ - processInBuffer(task: 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 /** is there an ongoing buffer */ isBuffering(): boolean } @@ -62,9 +63,7 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = const historyCache = new Set() let data = makeBufferData() - let isBuffering = false - let preBufferTask: Promise = Promise.resolve() - let preBufferTraces: string[] = [] + let buffersInProgress = 0 // take the generic event and fire it as a baileys event ev.on('event', (map: BaileysEventData) => { @@ -74,25 +73,24 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = }) function buffer() { - if(!isBuffering) { - logger.trace('buffering events') - isBuffering = true - return true - } - - return false + buffersInProgress += 1 } - async function flush() { - if(!isBuffering) { - return + function flush(force = false) { + // no buffer going on + if(!buffersInProgress) { + return false } - logger.trace({ preBufferTraces }, 'releasing buffered events...') - await preBufferTask - - preBufferTraces = [] - isBuffering = false + 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 + } + } const newData = makeBufferData() const chatUpdates = Object.values(data.chatUpdates) @@ -117,6 +115,8 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = { conditionalChatUpdatesLeft }, 'released buffered events' ) + + return true } return { @@ -131,34 +131,26 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter = } }, emit(event: BaileysEvent, evData: BaileysEventMap[T]) { - if(isBuffering && BUFFERABLE_EVENT_SET.has(event)) { + if(buffersInProgress && 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 isBuffering + return buffersInProgress > 0 }, buffer, flush, createBufferedFunction(work) { return async(...args) => { - const started = buffer() + buffer() try { const result = await work(...args) return result } finally { - if(started) { - await flush() - } + flush() } } }, diff --git a/src/Utils/make-mutex.ts b/src/Utils/make-mutex.ts index b02878f..a632caa 100644 --- a/src/Utils/make-mutex.ts +++ b/src/Utils/make-mutex.ts @@ -1,8 +1,20 @@ +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 { From 0d53fbe7fae5164f8bf90b9ede2dc08724898d7c Mon Sep 17 00:00:00 2001 From: Alan Mosko Date: Tue, 17 Jan 2023 11:49:49 -0300 Subject: [PATCH 03/12] Update make-mutex.ts --- src/Utils/make-mutex.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Utils/make-mutex.ts b/src/Utils/make-mutex.ts index a632caa..8bb9f54 100644 --- a/src/Utils/make-mutex.ts +++ b/src/Utils/make-mutex.ts @@ -1,7 +1,3 @@ -import logger from './logger' - -const MUTEX_TIMEOUT_MS = 60_000 - export const makeMutex = () => { let task = Promise.resolve() as Promise @@ -10,11 +6,6 @@ export const makeMutex = () => { 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 { @@ -50,4 +41,4 @@ export const makeKeyedMutex = () => { return map[key].mutex(task) } } -} +} \ No newline at end of file From 9c71267e2215eb88e59a6e02984baa9b4b7c4459 Mon Sep 17 00:00:00 2001 From: Alan Mosko Date: Tue, 17 Jan 2023 11:53:13 -0300 Subject: [PATCH 04/12] Update messages.ts --- src/Utils/messages.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 0d66384..d20360e 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -602,14 +602,32 @@ export const getContentType = (content: WAProto.IMessage | undefined) => { * @returns */ export const normalizeMessageContent = (content: WAMessageContent | null | undefined): WAMessageContent | undefined => { - content = content?.ephemeralMessage?.message?.viewOnceMessage?.message || - content?.ephemeralMessage?.message || - content?.viewOnceMessage?.message || - content?.documentWithCaptionMessage?.message || - content || - undefined - return content -} + 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 + ) + } + } /** * Extract the true message content from a message From 06f2d354bfe087d602bdb8132a66bdbfcdbaf054 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 15:22:43 +0530 Subject: [PATCH 05/12] chore: lint files --- src/Types/Chat.ts | 2 +- src/Types/Events.ts | 2 +- src/Types/GroupMetadata.ts | 2 +- src/Types/Message.ts | 2 +- src/Types/Product.ts | 2 +- src/Types/Socket.ts | 2 +- src/Types/index.ts | 2 +- src/Utils/generics.ts | 2 +- src/Utils/messages-media.ts | 4 ++-- src/Utils/messages.ts | 6 +++--- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Types/Chat.ts b/src/Types/Chat.ts index cd29cba..ae21813 100644 --- a/src/Types/Chat.ts +++ b/src/Types/Chat.ts @@ -74,7 +74,7 @@ export type ChatModification = } | { star: { - messages: { id: string, fromMe?: boolean }[], + messages: { id: string, fromMe?: boolean }[] star: boolean } } diff --git a/src/Types/Events.ts b/src/Types/Events.ts index 4e1866e..e4fc942 100644 --- a/src/Types/Events.ts +++ b/src/Types/Events.ts @@ -73,7 +73,7 @@ export type BufferedEventData = { messageUpdates: { [key: string]: WAMessageUpdate } messageDeletes: { [key: string]: WAMessageKey } messageReactions: { [key: string]: { key: WAMessageKey, reactions: proto.IReaction[] } } - messageReceipts: { [key: string]: { key: WAMessageKey, userReceipt: proto.IUserReceipt[] } }, + messageReceipts: { [key: string]: { key: WAMessageKey, userReceipt: proto.IUserReceipt[] } } groupUpdates: { [jid: string]: Partial } } diff --git a/src/Types/GroupMetadata.ts b/src/Types/GroupMetadata.ts index 8a47fc7..15eb69b 100644 --- a/src/Types/GroupMetadata.ts +++ b/src/Types/GroupMetadata.ts @@ -1,6 +1,6 @@ import { Contact } from './Contact' -export type GroupParticipant = (Contact & { isAdmin?: boolean; isSuperAdmin?: boolean, admin?: 'admin' | 'superadmin' | null }) +export type GroupParticipant = (Contact & { isAdmin?: boolean, isSuperAdmin?: boolean, admin?: 'admin' | 'superadmin' | null }) export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote' diff --git a/src/Types/Message.ts b/src/Types/Message.ts index d2a6964..d4eccbb 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -154,7 +154,7 @@ export type AnyRegularMessageContent = ( listReply: Omit } | { - product: WASendableProduct, + product: WASendableProduct businessOwnerJid?: string body?: string footer?: string diff --git a/src/Types/Product.ts b/src/Types/Product.ts index 4fcb0fc..159416a 100644 --- a/src/Types/Product.ts +++ b/src/Types/Product.ts @@ -2,7 +2,7 @@ import { WAMediaUpload } from './Message' export type CatalogResult = { data: { - paging: { cursors: { before: string, after: string } }, + paging: { cursors: { before: string, after: string } } data: any[] } } diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index 5144fdd..9083b5e 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -43,7 +43,7 @@ export type SocketConfig = { /** time to wait between sending new retry requests */ retryRequestDelayMs: number /** time to wait for the generation of the next QR in ms */ - qrTimeout?: number; + qrTimeout?: number /** provide an auth state object to maintain the auth state */ auth: AuthenticationState /** manage history processing with this control; by default will sync up everything */ diff --git a/src/Types/index.ts b/src/Types/index.ts index 78ea6f4..57ca635 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -52,4 +52,4 @@ export type WABusinessProfile = { address?: string } -export type CurveKeyPair = { private: Uint8Array; public: Uint8Array } \ No newline at end of file +export type CurveKeyPair = { private: Uint8Array, public: Uint8Array } \ No newline at end of file diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index ea730b6..16c016a 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -136,7 +136,7 @@ export const delayCancellable = (ms: number) => { return { delay, cancel } } -export async function promiseTimeout(ms: number | undefined, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) { +export async function promiseTimeout(ms: number | undefined, promise: (resolve: (v?: T) => void, reject: (error) => void) => void) { if(!ms) { return new Promise (promise) } diff --git a/src/Utils/messages-media.ts b/src/Utils/messages-media.ts index 26b6bc3..c8eb0cf 100644 --- a/src/Utils/messages-media.ts +++ b/src/Utils/messages-media.ts @@ -78,7 +78,7 @@ const extractVideoThumb = async( path: string, destPath: string, time: string, - size: { width: number; height: number }, + size: { width: number, height: number }, ) => new Promise((resolve, reject) => { const cmd = `ffmpeg -ss ${time} -i ${path} -y -vf scale=${size.width}:-1 -vframes 1 -f image2 ${destPath}` exec(cmd, (err) => { @@ -243,7 +243,7 @@ export async function generateThumbnail( } ) { let thumbnail: string | undefined - let originalImageDimensions: { width: number; height: number } | undefined + let originalImageDimensions: { width: number, height: number } | undefined if(mediaType === 'image') { const { buffer, original } = await extractImageThumb(file) thumbnail = buffer.toString('base64') diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index d20360e..de04bfe 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -627,7 +627,7 @@ export const normalizeMessageContent = (content: WAMessageContent | null | undef || message?.editedMessage ) } - } +} /** * Extract the true message content from a message @@ -833,9 +833,9 @@ export const assertMediaContent = (content: proto.IMessage | null | undefined) = */ export const getPollUpdateMessage = async( msg: WAProto.IWebMessageInfo, - pollCreationData: { encKey: Uint8Array; sender: string; options: string[]; }, + pollCreationData: { encKey: Uint8Array, sender: string, options: string[] }, withSelectedOptions: boolean = false, -): Promise<{ hash: string[] } | { hash: string[]; selectedOptions: string[] }> => { +): Promise<{ hash: string[] } | { hash: string[], selectedOptions: string[] }> => { if(!msg.message?.pollUpdateMessage || !pollCreationData?.encKey) { throw new Boom('Missing pollUpdateMessage, or encKey', { statusCode: 400 }) } From 36d6cfd5fbd9c6e429be20ed820c726f05e37e4f Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 17:41:43 +0530 Subject: [PATCH 06/12] refactor: poll decryption --- package.json | 1 - src/Utils/index.ts | 1 - src/Utils/messages-poll.ts | 139 ----------------------------------- src/Utils/process-message.ts | 50 ++++++++++++- yarn.lock | 65 +--------------- 5 files changed, 52 insertions(+), 204 deletions(-) delete mode 100644 src/Utils/messages-poll.ts diff --git a/package.json b/package.json index 9ba9e52..88ade39 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ }, "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/src/Utils/index.ts b/src/Utils/index.ts index 23329e1..156abe0 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -15,4 +15,3 @@ export * from './use-multi-file-auth-state' export * from './link-preview' export * from './event-buffer' export * from './process-message' -export * from './messages-poll' diff --git a/src/Utils/messages-poll.ts b/src/Utils/messages-poll.ts deleted file mode 100644 index 1ba1e16..0000000 --- a/src/Utils/messages-poll.ts +++ /dev/null @@ -1,139 +0,0 @@ -// 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/process-message.ts b/src/Utils/process-message.ts index 87a88ee..ad4bea8 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -2,8 +2,11 @@ import { AxiosRequestConfig } from 'axios' import type { Logger } from 'pino' import { proto } from '../../WAProto' import { AuthenticationCreds, BaileysEventEmitter, Chat, GroupMetadata, ParticipantAction, SignalKeyStoreWithTransaction, WAMessageStubType } from '../Types' -import { downloadAndProcessHistorySyncNotification, getContentType, normalizeMessageContent, toNumber } from '../Utils' +import { getContentType, normalizeMessageContent } from '../Utils/messages' import { areJidsSameUser, isJidBroadcast, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' +import { aesDecryptGCM, hmacSign } from './crypto' +import { getKeyAuthor, toNumber } from './generics' +import { downloadAndProcessHistorySyncNotification } from './history' type ProcessMessageContext = { shouldProcessHistoryMsg: boolean @@ -88,6 +91,51 @@ export const getChatId = ({ remoteJid, participant, fromMe }: proto.IMessageKey) return remoteJid! } +type PollContext = { + /** normalised jid of the person that created the poll */ + pollCreatorJid: string + /** ID of the poll creation message */ + pollMsgId: string + /** poll creation message enc key */ + pollEncKey: Uint8Array + /** jid of the person that voted */ + voterJid: string +} + +/** + * Decrypt a poll vote + * @param vote encrypted vote + * @param ctx additional info about the poll required for decryption + * @returns list of SHA256 options + */ +export function decryptPollVote( + { encPayload, encIv }: proto.Message.IPollEncValue, + { + pollCreatorJid, + pollMsgId, + pollEncKey, + voterJid, + }: PollContext +) { + const sign = Buffer.concat( + [ + toBinary(pollMsgId), + toBinary(pollCreatorJid), + toBinary(voterJid), + toBinary('Poll Vote'), + new Uint8Array([1]) + ] + ) + + const key0 = hmacSign(pollEncKey, new Uint8Array(32), 'sha256') + const decKey = hmacSign(sign, key0, 'sha256') + const aad = toBinary(`${pollMsgId}\u0000${voterJid}`) + + const decrypted = aesDecryptGCM(encPayload!, decKey, encIv!, aad) + return proto.Message.PollVoteMessage.decode(decrypted) + + function toBinary(txt: string) { + return Buffer.from(txt) const processMessage = async( message: proto.IWebMessageInfo, { diff --git a/yarn.lock b/yarn.lock index 2395efb..43a983d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -897,33 +897,6 @@ "@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.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz" @@ -1424,15 +1397,6 @@ 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.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" @@ -3488,7 +3452,7 @@ json-stable-stringify-without-jsonify@^1.0.1: json5@2.x, json5@^2.2.1: version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== jsonc-parser@^3.0.0: @@ -3532,7 +3496,7 @@ levn@~0.3.0: "libsignal@git+https://github.com/adiwajshing/libsignal-node": version "2.0.1" - resolved "git+https://github.com/adiwajshing/libsignal-node.git#11dbd962ea108187c79a7c46fe4d6f790e23da97" + resolved "git+ssh://git@github.com/adiwajshing/libsignal-node.git#11dbd962ea108187c79a7c46fe4d6f790e23da97" dependencies: curve25519-js "^0.0.4" protobufjs "6.8.8" @@ -4259,18 +4223,6 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" 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.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz" @@ -4906,7 +4858,7 @@ tslib@^1.8.1: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.4.0, tslib@^2.4.1: +tslib@^2.4.0: version "2.4.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz" integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== @@ -5069,17 +5021,6 @@ 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.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz" From 7d5aca54e93dd833736171425e9a820618637b48 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 17:41:56 +0530 Subject: [PATCH 07/12] chore: lint --- src/Socket/chats.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index de74261..40fe368 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -245,8 +245,10 @@ export const makeChatsSocket = (config: SocketConfig) => { const website = getBinaryNodeChild(profiles, 'website') const email = getBinaryNodeChild(profiles, 'email') const category = getBinaryNodeChild(getBinaryNodeChild(profiles, 'categories'), 'category') - const business_hours = getBinaryNodeChild(profiles, 'business_hours') - const business_hours_config = business_hours && getBinaryNodeChildren(business_hours, 'business_hours_config') + const businessHours = getBinaryNodeChild(profiles, 'business_hours') + const businessHoursConfig = businessHours + ? getBinaryNodeChildren(businessHours, 'business_hours_config') + : undefined const websiteStr = website?.content?.toString() return { wid: profiles.attrs?.jid, @@ -255,9 +257,9 @@ export const makeChatsSocket = (config: SocketConfig) => { website: websiteStr ? [websiteStr] : [], email: email?.content?.toString(), category: category?.content?.toString(), - business_hours: { - timezone: business_hours?.attrs?.timezone, - business_config: business_hours_config?.map(({ attrs }) => attrs as unknown as WABusinessHoursConfig) + 'business_hours': { + timezone: businessHours?.attrs?.timezone, + 'business_config': businessHoursConfig?.map(({ attrs }) => attrs as unknown as WABusinessHoursConfig) } } } @@ -599,7 +601,7 @@ export const makeChatsSocket = (config: SocketConfig) => { attrs: { name, version: (state.version - 1).toString(), - return_snapshot: 'false' + 'return_snapshot': 'false' }, content: [ { From cd42881201cc9fdb85a7196270365ed3c8d12b4d Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 17:42:34 +0530 Subject: [PATCH 08/12] feat: allow messageSecret as param to poll create --- src/Types/Message.ts | 4 +- src/Utils/generics.ts | 4 ++ src/Utils/messages.ts | 88 +++++++++---------------------------------- 3 files changed, 24 insertions(+), 72 deletions(-) diff --git a/src/Types/Message.ts b/src/Types/Message.ts index 8a22075..2337eef 100644 --- a/src/Types/Message.ts +++ b/src/Types/Message.ts @@ -84,7 +84,9 @@ type WithDimensions = { export type PollMessageOptions = { name: string selectableCount?: number - values: Array + values: string[] + /** 32 byte message secret to encrypt poll selections */ + messageSecret?: Uint8Array } export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index f195f09..758a30b 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -41,6 +41,10 @@ export const BufferJSON = { } } +export const getKeyAuthor = (key: proto.IMessageKey | undefined | null) => ( + (key?.fromMe ? 'me' : key?.participant || key?.remoteJid) || '' +) + export const writeRandomPadMax16 = (msg: Uint8Array) => { const pad = randomBytes(1) pad[0] &= 0xf diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index 861ba77..bf1624b 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -19,15 +19,13 @@ import { WAMediaUpload, WAMessage, WAMessageContent, - WAMessageKey, WAMessageStatus, WAProto, WATextMessage, } from '../Types' import { isJidGroup, jidNormalizedUser } from '../WABinary' -import { generateMessageID, unixTimestampSeconds } from './generics' +import { generateMessageID, getKeyAuthor, unixTimestampSeconds } from './generics' import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, MediaDownloadOptions } from './messages-media' -import { comparePollMessage, decryptPollMessageRaw } from './messages-poll' type MediaUploadData = { media: WAMediaUpload @@ -174,7 +172,7 @@ export const prepareWAMessageMedia = async( const { thumbnail, originalImageDimensions - } = await generateThumbnail(bodyPath!, mediaType as any, options) + } = await generateThumbnail(bodyPath!, mediaType as 'image' | 'video', options) uploadData.jpegThumbnail = thumbnail if(!uploadData.width && originalImageDimensions) { uploadData.width = originalImageDimensions.width @@ -382,36 +380,32 @@ 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 - } + 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) { + 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 } + `poll.selectableCount in poll should be >= 0 and <= ${message.poll.values.length}`, + { statusCode: 400 } ) } - // link: https://github.com/adiwajshing/Baileys/pull/2290#issuecomment-1304413425 m.messageContextInfo = { - messageSecret: randomBytes(32), // encKey + // encKey + messageSecret: message.poll.messageSecret || randomBytes(32), } - m.pollCreationMessage = WAProto.Message.PollCreationMessage.fromObject({ + m.pollCreationMessage = { name: message.poll.name, selectableOptionsCount: message.poll.selectableCount, - options: message.poll.values.map( - value => WAProto.Message.PollCreationMessage.Option.fromObject({ - optionName: value, - }), - ), - }) + options: message.poll.values.map(optionName => ({ optionName })), + } } else { m = await prepareWAMessageMedia( message, @@ -501,9 +495,11 @@ export const generateWAMessageFromContent = ( message: WAMessageContent, options: MessageGenerationOptionsFromContent ) => { + // set timestamp to now + // if not specified if(!options.timestamp) { options.timestamp = new Date() - } // set timestamp to now + } const key = Object.keys(message)[0] const timestamp = unixTimestampSeconds(options.timestamp) @@ -697,10 +693,6 @@ export const updateMessageWithReceipt = (msg: Pick, re } } -const getKeyAuthor = (key: WAMessageKey | undefined | null) => ( - (key?.fromMe ? 'me' : key?.participant || key?.remoteJid) || '' -) - /** Update the message with a new reaction */ export const updateMessageWithReaction = (msg: Pick, reaction: proto.IReaction) => { const authorID = getKeyAuthor(reaction.key) @@ -826,50 +818,4 @@ 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 From d98d4156fe1e0bcf6f9f3bc4e4de2fe666232d4e Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 18:45:56 +0530 Subject: [PATCH 09/12] feat: utility functions for poll updates --- src/Utils/messages.ts | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index bf1624b..7a4ef9a 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -24,6 +24,7 @@ import { WATextMessage, } from '../Types' import { isJidGroup, jidNormalizedUser } from '../WABinary' +import { sha256 } from './crypto' import { generateMessageID, getKeyAuthor, unixTimestampSeconds } from './generics' import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, MediaDownloadOptions } from './messages-media' @@ -706,6 +707,73 @@ export const updateMessageWithReaction = (msg: Pick, rea msg.reactions = reactions } +/** Update the message with a new poll update */ +export const updateMessageWithPollUpdate = ( + msg: Pick, + update: proto.IPollUpdate +) => { + const authorID = getKeyAuthor(update.pollUpdateMessageKey) + + const reactions = (msg.pollUpdates || []) + .filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID) + if(update.vote?.selectedOptions?.length) { + reactions.push(update) + } + + msg.pollUpdates = reactions +} + +type VoteAggregation = { + name: string + voters: string[] +} + +/** + * Aggregates all poll updates in a poll. + * @param msg the poll creation message + * @param meId your jid + * @returns A list of options & their voters + */ +export function getAggregateVotesInPollMessage( + { message, pollUpdates }: Pick, + meId?: string +) { + const opts = message?.pollCreationMessage?.options || [] + const voteHashMap = opts.reduce((acc, opt) => { + const hash = sha256(Buffer.from(opt.optionName || '')).toString() + acc[hash] = { + name: opt.optionName || '', + voters: [] + } + return acc + }, {} as { [_: string]: VoteAggregation }) + + for(const update of pollUpdates || []) { + const { vote } = update + if(!vote) { + continue + } + + for(const option of vote.selectedOptions || []) { + const hash = option.toString() + let data = voteHashMap[hash] + if(!data) { + voteHashMap[hash] = { + name: 'Unknown', + voters: [] + } + data = voteHashMap[hash] + } + + voteHashMap[hash].voters.push( + getKeyAuthor(update.pollUpdateMessageKey, meId) + ) + } + } + + return Object.values(voteHashMap) +} + /** Given a list of message keys, aggregates them by chat & sender. Useful for sending read receipts in bulk */ export const aggregateMessageKeysNotFromMe = (keys: proto.IMessageKey[]) => { const keyMap: { [id: string]: { jid: string, participant: string | undefined, messageIds: string[] } } = { } From bda2bb471799c5f87b93b8e09f8b36f4b003b757 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 18:46:07 +0530 Subject: [PATCH 10/12] feat: allow meId in getKeyAuthor --- src/Utils/generics.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index 758a30b..d772f46 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -41,8 +41,11 @@ export const BufferJSON = { } } -export const getKeyAuthor = (key: proto.IMessageKey | undefined | null) => ( - (key?.fromMe ? 'me' : key?.participant || key?.remoteJid) || '' +export const getKeyAuthor = ( + key: proto.IMessageKey | undefined | null, + meId: string = 'me' +) => ( + (key?.fromMe ? meId : key?.participant || key?.remoteJid) || '' ) export const writeRandomPadMax16 = (msg: Uint8Array) => { From 9d8aa67f22d5c9e1fba6205b779f9acb41b1eb54 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 22:43:31 +0530 Subject: [PATCH 11/12] feat: process pollupdate automatically --- src/Socket/chats.ts | 1 + src/Utils/process-message.ts | 67 +++++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index 40fe368..da53448 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -764,6 +764,7 @@ export const makeChatsSocket = (config: SocketConfig) => { keyStore: authState.keys, logger, options: config.options, + getMessage: config.getMessage, } ) ]) diff --git a/src/Utils/process-message.ts b/src/Utils/process-message.ts index ad4bea8..294a63f 100644 --- a/src/Utils/process-message.ts +++ b/src/Utils/process-message.ts @@ -1,7 +1,7 @@ import { AxiosRequestConfig } from 'axios' import type { Logger } from 'pino' import { proto } from '../../WAProto' -import { AuthenticationCreds, BaileysEventEmitter, Chat, GroupMetadata, ParticipantAction, SignalKeyStoreWithTransaction, WAMessageStubType } from '../Types' +import { AuthenticationCreds, BaileysEventEmitter, Chat, GroupMetadata, ParticipantAction, SignalKeyStoreWithTransaction, SocketConfig, WAMessageStubType } from '../Types' import { getContentType, normalizeMessageContent } from '../Utils/messages' import { areJidsSameUser, isJidBroadcast, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary' import { aesDecryptGCM, hmacSign } from './crypto' @@ -13,8 +13,9 @@ type ProcessMessageContext = { creds: AuthenticationCreds keyStore: SignalKeyStoreWithTransaction ev: BaileysEventEmitter + getMessage: SocketConfig['getMessage'] logger?: Logger - options: AxiosRequestConfig + options: AxiosRequestConfig<{}> } const REAL_MSG_STUB_TYPES = new Set([ @@ -36,7 +37,14 @@ export const cleanMessage = (message: proto.IWebMessageInfo, meId: string) => { const content = normalizeMessageContent(message.message) // if the message has a reaction, ensure fromMe & remoteJid are from our perspective if(content?.reactionMessage) { - const msgKey = content.reactionMessage.key! + normaliseKey(content.reactionMessage.key!) + } + + if(content?.pollUpdateMessage) { + normaliseKey(content.pollUpdateMessage.pollCreationMessageKey!) + } + + function normaliseKey(msgKey: proto.IMessageKey) { // if the reaction is from another user // we've to correctly map the key to this user's perspective if(!message.key.fromMe) { @@ -69,6 +77,7 @@ export const isRealMessage = (message: proto.IWebMessageInfo, meId: string) => { && hasSomeContent && !normalizedContent?.protocolMessage && !normalizedContent?.reactionMessage + && !normalizedContent?.pollUpdateMessage } export const shouldIncrementChatUnread = (message: proto.IWebMessageInfo) => ( @@ -136,6 +145,9 @@ export function decryptPollVote( function toBinary(txt: string) { return Buffer.from(txt) + } +} + const processMessage = async( message: proto.IWebMessageInfo, { @@ -144,7 +156,8 @@ const processMessage = async( creds, keyStore, logger, - options + options, + getMessage }: ProcessMessageContext ) => { const meId = creds.me!.id @@ -321,6 +334,52 @@ const processMessage = async( emitGroupUpdate({ inviteCode: code }) break } + } else if(content?.pollUpdateMessage) { + const creationMsgKey = content.pollUpdateMessage.pollCreationMessageKey! + // we need to fetch the poll creation message to get the poll enc key + const pollMsg = await getMessage(creationMsgKey) + if(pollMsg) { + const meIdNormalised = jidNormalizedUser(meId) + const pollCreatorJid = getKeyAuthor(creationMsgKey, meIdNormalised) + const voterJid = getKeyAuthor(message.key!, meIdNormalised) + const pollEncKey = pollMsg.messageContextInfo?.messageSecret! + + try { + const voteMsg = decryptPollVote( + content.pollUpdateMessage.vote!, + { + pollEncKey, + pollCreatorJid, + pollMsgId: creationMsgKey.id!, + voterJid, + } + ) + ev.emit('messages.update', [ + { + key: creationMsgKey, + update: { + pollUpdates: [ + { + pollUpdateMessageKey: message.key, + vote: voteMsg, + senderTimestampMs: message.messageTimestamp, + } + ] + } + } + ]) + } catch(err) { + logger?.warn( + { err, creationMsgKey }, + 'failed to decrypt poll vote' + ) + } + } else { + logger?.warn( + { creationMsgKey }, + 'poll creation message not found, cannot decrypt update' + ) + } } if(Object.keys(chat).length > 1) { From 893fb61f05f89fc95ba84485d4f93cdc875dc16c Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 2 Mar 2023 22:44:15 +0530 Subject: [PATCH 12/12] chore: update example --- Example/example.ts | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/Example/example.ts b/Example/example.ts index dcb6eff..eb6fd15 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -1,6 +1,6 @@ import { Boom } from '@hapi/boom' import NodeCache from 'node-cache' -import makeWASocket, { AnyMessageContent, delay, DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, makeInMemoryStore, useMultiFileAuthState } from '../src' +import makeWASocket, { AnyMessageContent, delay, DisconnectReason, fetchLatestBaileysVersion, getAggregateVotesInPollMessage, makeCacheableSignalKeyStore, makeInMemoryStore, proto, useMultiFileAuthState, WAMessageContent, WAMessageKey } from '../src' import MAIN_LOGGER from '../src/Utils/logger' const logger = MAIN_LOGGER.child({ }) @@ -43,18 +43,8 @@ const startSock = async() => { // ignore all broadcast messages -- to receive the same // comment the line below out // shouldIgnoreJid: jid => isJidBroadcast(jid), - // implement to handle retries - getMessage: async key => { - if(store) { - const msg = await store.loadMessage(key.remoteJid!, key.id!) - return msg?.message || undefined - } - - // only if store is present - return { - conversation: 'hello' - } - } + // implement to handle retries & poll updates + getMessage, }) store?.bind(sock.ev) @@ -126,7 +116,24 @@ const startSock = async() => { // messages updated like status delivered, message deleted etc. if(events['messages.update']) { - console.log(events['messages.update']) + console.log( + JSON.stringify(events['messages.update'], undefined, 2) + ) + + for(const { key, update } of events['messages.update']) { + if(update.pollUpdates) { + const pollCreation = await getMessage(key) + if(pollCreation) { + console.log( + 'got poll update, aggregation: ', + getAggregateVotesInPollMessage({ + message: pollCreation, + pollUpdates: update.pollUpdates, + }) + ) + } + } + } } if(events['message-receipt.update']) { @@ -165,6 +172,16 @@ const startSock = async() => { ) return sock + + async function getMessage(key: WAMessageKey): Promise { + if(store) { + const msg = await store.loadMessage(key.remoteJid!, key.id!) + return msg?.message || undefined + } + + // only if store is present + return proto.Message.fromObject({}) + } } startSock() \ No newline at end of file