This commit is contained in:
Alan Mosko
2023-01-11 23:58:29 -03:00
parent 5041be776e
commit 78f04d8714
18 changed files with 405 additions and 253 deletions

View File

@@ -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) => {

View File

@@ -39,16 +39,15 @@ type BaileysBufferableEventEmitter = BaileysEventEmitter & {
process(handler: (events: BaileysEventData) => void | Promise<void>): (() => 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<A extends any[], T>(work: (...args: A) => Promise<T>): ((...args: A) => Promise<T>)
/**
* 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<void>
/** waits for the task to complete, before releasing the buffer */
processInBuffer(task: Promise<any>)
/** is there an ongoing buffer */
isBuffering(): boolean
}
@@ -63,7 +62,9 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter =
const historyCache = new Set<string>()
let data = makeBufferData()
let buffersInProgress = 0
let isBuffering = false
let preBufferTask: Promise<any> = 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<T extends BaileysEvent>(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()
}
}
}
},

View File

@@ -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'
export * from './process-message'
export * from './messages-poll'

View File

@@ -1,35 +1,16 @@
import logger from './logger'
const MUTEX_TIMEOUT_MS = 60_000
export const makeMutex = () => {
let task = Promise.resolve() as Promise<any>
let taskTimeout: NodeJS.Timeout | undefined
return {
mutex<T>(code: () => Promise<T> | T): Promise<T> {
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

139
src/Utils/messages-poll.ts Normal file
View File

@@ -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<string[]>} the original option, can be empty when none are currently selected
*/
export const comparePollMessage = async(options: string[], pollOptionHashes: string[]): Promise<string[]> => {
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<Uint8Array>}
*/
const decryptPollMessageInternal = async(
encPayload: Uint8Array,
encIv: Uint8Array,
additionalData: Uint8Array,
decryptionKey: Uint8Array,
): Promise<Uint8Array> => {
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<string[]>} 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<string[]> => {
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') || []
}

View File

@@ -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 }
}