7421f55daa
!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
705 lines
20 KiB
TypeScript
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
|
|
} |