import { Boom } from '@hapi/boom' import { promises as fs } from 'fs' 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, WAMessageStatus, WAProto, WATextMessage, } from '../Types' 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 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('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(!urlInfo && !!options.getUrlInfo && message.text.match(URL_REGEX)) { try { urlInfo = await options.getUrlInfo(message.text) } catch(error) { // ignore if fails options.logger?.warn({ trace: error.stack }, 'url generation failed') } } 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 { 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) message[key].contextInfo = message[key].contextInfo || { } message[key].contextInfo.participant = participant message[key].contextInfo.stanzaId = quoted.key.id message[key].contextInfo.quotedMessage = quoted.message // 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) { message[key].contextInfo.remoteJid = quoted.key.remoteJid } } 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): 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: WAMessage, 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) } } /** 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) } /** * 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) => { 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 }