Files
InfiniteAPI/src/Utils/messages.ts
T
Adhiraj Singh 7421f55daa feat: implement event buffer for offline msgs
!BREAKING_CHANGE

1. this allows all offline notifications to be combined into a batch -- to reduce the number of events being sent out
2. to enable the above, the "message.reaction" event has been made an array. Also removes the need for the "operation" field
3. have also now started processing all events under a single queue to prevent state sync problems
2022-06-27 13:38:29 +05:30

705 lines
20 KiB
TypeScript

import { Boom } from '@hapi/boom'
import axios from 'axios'
import { promises as fs } from 'fs'
import { Logger } from 'pino'
import { proto } from '../../WAProto'
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults'
import {
AnyMediaMessageContent,
AnyMessageContent,
MediaGenerationOptions,
MediaType,
MessageContentGenerationOptions,
MessageGenerationOptions,
MessageGenerationOptionsFromContent,
MessageType,
MessageUserReceipt,
WAMediaUpload,
WAMessage,
WAMessageContent,
WAMessageKey,
WAMessageStatus,
WAProto,
WATextMessage,
} from '../Types'
import { jidNormalizedUser } from '../WABinary'
import { generateMessageID, unixTimestampSeconds } from './generics'
import { downloadContentFromMessage, encryptedStream, generateThumbnail, getAudioDuration, MediaDownloadOptions } from './messages-media'
type MediaUploadData = {
media: WAMediaUpload
caption?: string
ptt?: boolean
seconds?: number
gifPlayback?: boolean
fileName?: string
jpegThumbnail?: string
mimetype?: string
}
const MIMETYPE_MAP: { [T in MediaType]: string } = {
image: 'image/jpeg',
video: 'video/mp4',
document: 'application/pdf',
audio: 'audio/ogg; codecs=opus',
sticker: 'image/webp',
history: 'application/x-protobuf',
'md-app-state': 'application/x-protobuf',
}
const MessageTypeProto = {
'image': WAProto.ImageMessage,
'video': WAProto.VideoMessage,
'audio': WAProto.AudioMessage,
'sticker': WAProto.StickerMessage,
'document': WAProto.DocumentMessage,
} as const
const ButtonType = proto.ButtonsMessage.ButtonsMessageHeaderType
export const generateLinkPreviewIfRequired = async(text: string, getUrlInfo: MessageGenerationOptions['getUrlInfo'], logger: MessageGenerationOptions['logger']) => {
const matchedUrls = text.match(URL_REGEX)
if(!!getUrlInfo && matchedUrls) {
try {
const urlInfo = await getUrlInfo(matchedUrls[0])
return urlInfo
} catch(error) { // ignore if fails
logger?.warn({ trace: error.stack }, 'url generation failed')
}
}
}
export const prepareWAMessageMedia = async(
message: AnyMediaMessageContent,
options: MediaGenerationOptions
) => {
const logger = options.logger
let mediaType: typeof MEDIA_KEYS[number]
for(const key of MEDIA_KEYS) {
if(key in message) {
mediaType = key
}
}
const uploadData: MediaUploadData = {
...message,
media: message[mediaType]
}
delete uploadData[mediaType]
// check if cacheable + generate cache key
const cacheableKey = typeof uploadData.media === 'object' &&
('url' in uploadData.media) &&
!!uploadData.media.url &&
!!options.mediaCache && (
// generate the key
mediaType + ':' + uploadData.media.url!.toString()
)
if(mediaType === 'document' && !uploadData.fileName) {
uploadData.fileName = 'file'
}
if(!uploadData.mimetype) {
uploadData.mimetype = MIMETYPE_MAP[mediaType]
}
// check for cache hit
if(cacheableKey) {
const mediaBuff: Buffer = options.mediaCache!.get(cacheableKey)
if(mediaBuff) {
logger?.debug({ cacheableKey }, 'got media cache hit')
const obj = WAProto.Message.decode(mediaBuff)
const key = `${mediaType}Message`
delete uploadData.media
Object.assign(obj[key], { ...uploadData })
return obj
}
}
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') &&
(typeof uploadData['jpegThumbnail'] === 'undefined')
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const {
mediaKey,
encWriteStream,
bodyPath,
fileEncSha256,
fileSha256,
fileLength,
didSaveToTmpPath
} = await encryptedStream(uploadData.media, mediaType, requiresOriginalForSomeProcessing)
// url safe Base64 encode the SHA256 hash of the body
const fileEncSha256B64 = encodeURIComponent(
fileEncSha256.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/\=+$/, '')
)
const [{ mediaUrl, directPath }] = await Promise.all([
(async() => {
const result = await options.upload(
encWriteStream,
{ fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs }
)
logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
return result
})(),
(async() => {
try {
if(requiresThumbnailComputation) {
uploadData.jpegThumbnail = await generateThumbnail(bodyPath, mediaType as any, options)
logger?.debug('generated thumbnail')
}
if(requiresDurationComputation) {
uploadData.seconds = await getAudioDuration(bodyPath)
logger?.debug('computed audio duration')
}
} catch(error) {
logger?.warn({ trace: error.stack }, 'failed to obtain extra info')
}
})(),
])
.finally(
async() => {
encWriteStream.destroy()
// remove tmp files
if(didSaveToTmpPath && bodyPath) {
await fs.unlink(bodyPath)
logger?.debug('removed tmp files')
}
}
)
delete uploadData.media
const obj = WAProto.Message.fromObject({
[`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject(
{
url: mediaUrl,
directPath,
mediaKey,
fileEncSha256,
fileSha256,
fileLength,
mediaKeyTimestamp: unixTimestampSeconds(),
...uploadData
}
)
})
if(cacheableKey) {
logger.debug({ cacheableKey }, 'set cache')
options.mediaCache!.set(cacheableKey, WAProto.Message.encode(obj).finish())
}
return obj
}
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
ephemeralExpiration = ephemeralExpiration || 0
const content: WAMessageContent = {
ephemeralMessage: {
message: {
protocolMessage: {
type: WAProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration
}
}
}
}
return WAProto.Message.fromObject(content)
}
/**
* Generate forwarded message content like WA does
* @param message the message to forward
* @param options.forceForward will show the message as forwarded even if it is from you
*/
export const generateForwardMessageContent = (
message: WAMessage,
forceForward?: boolean
) => {
let content = message.message
if(!content) {
throw new Boom('no content in message', { statusCode: 400 })
}
// hacky copy
content = normalizeMessageContent(message.message)
content = proto.Message.decode(proto.Message.encode(content).finish())
let key = Object.keys(content)[0] as MessageType
let score = content[key].contextInfo?.forwardingScore || 0
score += message.key.fromMe && !forceForward ? 0 : 1
if(key === 'conversation') {
content.extendedTextMessage = { text: content[key] }
delete content.conversation
key = 'extendedTextMessage'
}
if(score > 0) {
content[key].contextInfo = { forwardingScore: score, isForwarded: true }
} else {
content[key].contextInfo = {}
}
return content
}
export const generateWAMessageContent = async(
message: AnyMessageContent,
options: MessageContentGenerationOptions
) => {
let m: WAMessageContent = {}
if('text' in message) {
const extContent = { text: message.text } as WATextMessage
let urlInfo = message.linkPreview
if(typeof urlInfo === 'undefined') {
urlInfo = await generateLinkPreviewIfRequired(message.text, options.getUrlInfo, options.logger)
}
if(urlInfo) {
extContent.canonicalUrl = urlInfo['canonical-url']
extContent.matchedText = urlInfo['matched-text']
extContent.jpegThumbnail = urlInfo.jpegThumbnail
extContent.description = urlInfo.description
extContent.title = urlInfo.title
extContent.previewType = 0
}
m.extendedTextMessage = extContent
} else if('contacts' in message) {
const contactLen = message.contacts.contacts.length
if(!contactLen) {
throw new Boom('require atleast 1 contact', { statusCode: 400 })
}
if(contactLen === 1) {
m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0])
} else {
m.contactsArrayMessage = WAProto.ContactsArrayMessage.fromObject(message.contacts)
}
} else if('location' in message) {
m.locationMessage = WAProto.LocationMessage.fromObject(message.location)
} else if('react' in message) {
m.reactionMessage = WAProto.ReactionMessage.fromObject(message.react)
} else if('delete' in message) {
m.protocolMessage = {
key: message.delete,
type: WAProto.ProtocolMessage.ProtocolMessageType.REVOKE
}
} else if('forward' in message) {
m = generateForwardMessageContent(
message.forward,
message.force
)
} else if('disappearingMessagesInChat' in message) {
const exp = typeof message.disappearingMessagesInChat === 'boolean' ?
(message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
message.disappearingMessagesInChat
m = prepareDisappearingMessageSettingContent(exp)
} else if('buttonReply' in message) {
switch (message.type) {
case 'template':
m.templateButtonReplyMessage = {
selectedDisplayText: message.buttonReply.displayText,
selectedId: message.buttonReply.id,
selectedIndex: message.buttonReply.index,
}
break
case 'plain':
m.buttonsResponseMessage = {
selectedButtonId: message.buttonReply.id,
selectedDisplayText: message.buttonReply.displayText,
type: proto.ButtonsResponseMessage.ButtonsResponseMessageType.DISPLAY_TEXT,
}
break
}
} else {
m = await prepareWAMessageMedia(
message,
options
)
}
if('buttons' in message && !!message.buttons) {
const buttonsMessage: proto.IButtonsMessage = {
buttons: message.buttons!.map(b => ({ ...b, type: proto.Button.ButtonType.RESPONSE }))
}
if('text' in message) {
buttonsMessage.contentText = message.text
buttonsMessage.headerType = ButtonType.EMPTY
} else {
if('caption' in message) {
buttonsMessage.contentText = message.caption
}
const type = Object.keys(m)[0].replace('Message', '').toUpperCase()
buttonsMessage.headerType = ButtonType[type]
Object.assign(buttonsMessage, m)
}
if('footer' in message && !!message.footer) {
buttonsMessage.footerText = message.footer
}
m = { buttonsMessage }
} else if('templateButtons' in message && !!message.templateButtons) {
const msg: proto.IHydratedFourRowTemplate = {
hydratedButtons: message.templateButtons
}
if('text' in message) {
msg.hydratedContentText = message.text
} else {
if('caption' in message) {
msg.hydratedContentText = message.caption
}
Object.assign(msg, m)
}
if('footer' in message && !!message.footer) {
msg.hydratedFooterText = message.footer
}
m = {
templateMessage: {
hydratedTemplate: msg
}
}
}
if('sections' in message && !!message.sections) {
const listMessage: proto.IListMessage = {
sections: message.sections,
buttonText: message.buttonText,
title: message.title,
footerText: message.footer,
description: message.text,
listType: proto.ListMessage.ListMessageListType['SINGLE_SELECT']
}
m = { listMessage }
}
if('viewOnce' in message && !!message.viewOnce) {
m = { viewOnceMessage: { message: m } }
}
if('mentions' in message && message.mentions?.length) {
const [messageType] = Object.keys(m)
m[messageType].contextInfo = m[messageType] || { }
m[messageType].contextInfo.mentionedJid = message.mentions
}
return WAProto.Message.fromObject(m)
}
export const generateWAMessageFromContent = (
jid: string,
message: WAMessageContent,
options: MessageGenerationOptionsFromContent
) => {
if(!options.timestamp) {
options.timestamp = new Date()
} // set timestamp to now
const key = Object.keys(message)[0]
const timestamp = unixTimestampSeconds(options.timestamp)
const { quoted, userJid } = options
if(quoted) {
const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)
let quotedMsg = normalizeMessageContent(quoted.message)
const msgType = getContentType(quotedMsg)
// strip any redundant properties
quotedMsg = proto.Message.fromObject({ [msgType]: quotedMsg[msgType] })
const quotedContent = quotedMsg[msgType]
if(typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) {
delete quotedContent.contextInfo
}
const contextInfo: proto.IContextInfo = message[key].contextInfo || { }
contextInfo.participant = jidNormalizedUser(participant)
contextInfo.stanzaId = quoted.key.id
contextInfo.quotedMessage = quotedMsg
// if a participant is quoted, then it must be a group
// hence, remoteJid of group must also be entered
if(quoted.key.participant || quoted.participant) {
contextInfo.remoteJid = quoted.key.remoteJid
}
message[key].contextInfo = contextInfo
}
if(
// if we want to send a disappearing message
!!options?.ephemeralExpiration &&
// and it's not a protocol message -- delete, toggle disappear message
key !== 'protocolMessage' &&
// already not converted to disappearing message
key !== 'ephemeralMessage'
) {
message[key].contextInfo = {
...(message[key].contextInfo || {}),
expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL,
//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
}
message = {
ephemeralMessage: {
message
}
}
}
message = WAProto.Message.fromObject(message)
const messageJSON = {
key: {
remoteJid: jid,
fromMe: true,
id: options?.messageId || generateMessageID(),
},
message: message,
messageTimestamp: timestamp,
messageStubParameters: [],
participant: jid.includes('@g.us') ? userJid : undefined,
status: WAMessageStatus.PENDING
}
return WAProto.WebMessageInfo.fromObject(messageJSON)
}
export const generateWAMessage = async(
jid: string,
content: AnyMessageContent,
options: MessageGenerationOptions,
) => {
// ensure msg ID is with every log
options.logger = options?.logger?.child({ msgId: options.messageId })
return generateWAMessageFromContent(
jid,
await generateWAMessageContent(
content,
options
),
options
)
}
/** Get the key to access the true type of content */
export const getContentType = (content: WAProto.IMessage | undefined) => {
if(content) {
const keys = Object.keys(content)
const key = keys.find(k => (k === 'conversation' || k.endsWith('Message')) && k !== 'senderKeyDistributionMessage')
return key as keyof typeof content
}
}
/**
* Normalizes ephemeral, view once messages to regular message content
* Eg. image messages in ephemeral messages, in view once messages etc.
* @param content
* @returns
*/
export const normalizeMessageContent = (content: WAMessageContent | undefined): WAMessageContent => {
content = content?.ephemeralMessage?.message?.viewOnceMessage?.message ||
content?.ephemeralMessage?.message ||
content?.viewOnceMessage?.message ||
content ||
undefined
return content
}
/**
* Extract the true message content from a message
* Eg. extracts the inner message from a disappearing message/view once message
*/
export const extractMessageContent = (content: WAMessageContent | undefined | null): WAMessageContent | undefined => {
const extractFromTemplateMessage = (msg: proto.IHydratedFourRowTemplate | proto.IButtonsMessage) => {
if(msg.imageMessage) {
return { imageMessage: msg.imageMessage }
} else if(msg.documentMessage) {
return { documentMessage: msg.documentMessage }
} else if(msg.videoMessage) {
return { videoMessage: msg.videoMessage }
} else if(msg.locationMessage) {
return { locationMessage: msg.locationMessage }
} else {
return { conversation: 'contentText' in msg ? msg.contentText : ('hydratedContentText' in msg ? msg.hydratedContentText : '') }
}
}
content = normalizeMessageContent(content)
if(content?.buttonsMessage) {
return extractFromTemplateMessage(content.buttonsMessage!)
}
if(content?.templateMessage?.hydratedFourRowTemplate) {
return extractFromTemplateMessage(content?.templateMessage?.hydratedFourRowTemplate)
}
if(content?.templateMessage?.hydratedTemplate) {
return extractFromTemplateMessage(content?.templateMessage?.hydratedTemplate)
}
if(content?.templateMessage?.fourRowTemplate) {
return extractFromTemplateMessage(content?.templateMessage?.fourRowTemplate)
}
return content
}
/**
* Returns the device predicted by message ID
*/
export const getDevice = (id: string) => {
const deviceType = id.length > 21 ? 'android' : id.substring(0, 2) === '3A' ? 'ios' : 'web'
return deviceType
}
/** Upserts a receipt in the message */
export const updateMessageWithReceipt = (msg: Pick<WAMessage, 'userReceipt'>, receipt: MessageUserReceipt) => {
msg.userReceipt = msg.userReceipt || []
const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid)
if(recp) {
Object.assign(recp, receipt)
} else {
msg.userReceipt.push(receipt)
}
}
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<WAMessage, 'reactions'>, reaction: proto.IReaction) => {
const authorID = getKeyAuthor(reaction.key)
const reactions = (msg.reactions || [])
.filter(r => getKeyAuthor(r.key) !== authorID)
if(reaction.text) {
reactions.push(reaction)
}
msg.reactions = reactions
}
/** 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[] } } = { }
for(const { remoteJid, id, participant, fromMe } of keys) {
if(!fromMe) {
const uqKey = `${remoteJid}:${participant || ''}`
if(!keyMap[uqKey]) {
keyMap[uqKey] = {
jid: remoteJid,
participant,
messageIds: []
}
}
keyMap[uqKey].messageIds.push(id)
}
}
return Object.values(keyMap)
}
type DownloadMediaMessageContext = {
reuploadRequest: (msg: WAMessage) => Promise<WAMessage>
logger: Logger
}
const REUPLOAD_REQUIRED_STATUS = [410, 404]
/**
* Downloads the given message. Throws an error if it's not a media message
*/
export const downloadMediaMessage = async(
message: WAMessage,
type: 'buffer' | 'stream',
options: MediaDownloadOptions,
ctx?: DownloadMediaMessageContext
) => {
try {
const result = await downloadMsg()
return result
} catch(error) {
if(ctx) {
if(axios.isAxiosError(error)) {
// check if the message requires a reupload
if(REUPLOAD_REQUIRED_STATUS.includes(error.response?.status)) {
ctx.logger.info({ key: message.key }, 'sending reupload media request...')
// request reupload
message = await ctx.reuploadRequest(message)
const result = await downloadMsg()
return result
}
}
}
throw error
}
async function downloadMsg() {
const mContent = extractMessageContent(message.message)
if(!mContent) {
throw new Boom('No message present', { statusCode: 400, data: message })
}
const contentType = getContentType(mContent)
const mediaType = contentType.replace('Message', '') as MediaType
const media = mContent[contentType]
if(typeof media !== 'object' || !('url' in media)) {
throw new Boom(`"${contentType}" message is not a media message`)
}
const stream = await downloadContentFromMessage(media, mediaType, options)
if(type === 'buffer') {
let buffer = Buffer.from([])
for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk])
}
return buffer
}
return stream
}
}
/** Checks whether the given message is a media message; if it is returns the inner content */
export const assertMediaContent = (content: proto.IMessage) => {
content = extractMessageContent(content)
const mediaContent = content?.documentMessage
|| content?.imageMessage
|| content?.videoMessage
|| content?.audioMessage
|| content?.stickerMessage
if(!mediaContent) {
throw new Boom(
'given message is not a media message',
{ statusCode: 400, data: content }
)
}
return mediaContent
}