Files
Baileys/src/Utils/messages.ts
2021-08-22 14:30:48 +05:30

330 lines
9.6 KiB
TypeScript

import { Boom } from '@hapi/boom'
import { createReadStream, promises as fs } from "fs"
import { proto } from '../../WAMessage'
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from "../Defaults"
import {
AnyMediaMessageContent,
AnyMessageContent,
MediaGenerationOptions,
MessageContentGenerationOptions,
MessageGenerationOptions,
MessageGenerationOptionsFromContent,
MessageType,
WAMediaUpload,
WAMessage,
WAMessageContent,
WAMessageProto,
WATextMessage,
MediaType,
WAMessageStatus
} from "../Types"
import { generateMessageID, unixTimestampSeconds, whatsappID } from "./generics"
import { encryptedStream, generateThumbnail, getAudioDuration } 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',
}
const MessageTypeProto = {
'image': WAMessageProto.ImageMessage,
'video': WAMessageProto.VideoMessage,
'audio': WAMessageProto.AudioMessage,
'sticker': WAMessageProto.StickerMessage,
'document': WAMessageProto.DocumentMessage,
} as const
export const prepareWAMessageMedia = async(
message: AnyMediaMessageContent,
options: MediaGenerationOptions
) => {
let mediaType: typeof MEDIA_KEYS[number]
for(const key of MEDIA_KEYS) {
if(key in message) {
mediaType = key
}
}
const uploadData: MediaUploadData = {
...message,
[mediaType]: undefined,
media: message[mediaType]
}
// check for cache hit
if(typeof uploadData.media === 'object' && 'url' in uploadData.media) {
const result = !!options.mediaCache && await options.mediaCache!(uploadData.media.url?.toString())
if(result) {
return WAMessageProto.Message.fromObject({
[`${mediaType}Message`]: result
})
}
}
if(mediaType === 'document' && !uploadData.fileName) {
uploadData.fileName = 'file'
}
if(!uploadData.mimetype) {
uploadData.mimetype = MIMETYPE_MAP[mediaType]
}
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') &&
(typeof uploadData['jpegThumbnail'] === 'undefined')
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const {
mediaKey,
encBodyPath,
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(/\=+$/, '')
)
try {
if(requiresThumbnailComputation) {
uploadData.jpegThumbnail = await generateThumbnail(bodyPath, mediaType as any, options)
}
if (requiresDurationComputation) {
uploadData.seconds = await getAudioDuration(bodyPath)
}
} catch (error) {
options.logger?.debug ({ error }, 'failed to obtain audio duration: ' + error.message)
}
const {mediaUrl} = await options.upload(
createReadStream(encBodyPath),
{ fileEncSha256B64, mediaType }
)
// remove tmp files
await Promise.all(
[
fs.unlink(encBodyPath),
didSaveToTmpPath && bodyPath && fs.unlink(bodyPath)
]
.filter(Boolean)
)
delete uploadData.media
const content = {
[`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject(
{
url: mediaUrl,
mediaKey,
fileEncSha256,
fileSha256,
fileLength,
...uploadData
}
)
}
return WAMessageProto.Message.fromObject(content)
}
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
ephemeralExpiration = ephemeralExpiration || 0
const content: WAMessageContent = {
ephemeralMessage: {
message: {
protocolMessage: {
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration
}
}
}
}
return WAMessageProto.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 })
content = JSON.parse(JSON.stringify(content)) // hacky copy
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 = { ...message } as WATextMessage
if (!!options.getUrlInfo && message.text.match(URL_REGEX)) {
try {
const data = await options.getUrlInfo(message.text)
extContent.canonicalUrl = data['canonical-url']
extContent.matchedText = data['matched-text']
extContent.jpegThumbnail = data.jpegThumbnail
extContent.description = data.description
extContent.title = data.title
extContent.previewType = 0
} catch (error) { // ignore if fails
}
}
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 = WAMessageProto.ContactMessage.fromObject(message.contacts.contacts[0])
}
} else if('location' in message) {
m.locationMessage = WAMessageProto.LocationMessage.fromObject(message.location)
} else if('delete' in message) {
m.protocolMessage = {
key: message.delete,
type: WAMessageProto.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!,
text: ''
}
if('text' in message) {
buttonsMessage.contentText = message.text
} else {
Object.assign(buttonsMessage, m)
}
m = { buttonsMessage }
}
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 WAMessageProto.Message.fromObject(m)
}
export const generateWAMessageFromContent = (
jid: string,
message: WAMessageContent,
options: MessageGenerationOptionsFromContent
) => {
if (!options.timestamp) options.timestamp = new Date() // set timestamp to now
// prevent an annoying bug (WA doesn't accept sending messages with '@c.us')
jid = whatsappID(jid)
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) {
message[key].contextInfo.remoteJid = quoted.key.remoteJid
}
}
if(
// if we want to send a disappearing message
!!options?.ephemeralOptions &&
// 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.ephemeralOptions.expiration || WA_DEFAULT_EPHEMERAL,
ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
}
message = {
ephemeralMessage: {
message
}
}
}
message = WAMessageProto.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 WAMessageProto.WebMessageInfo.fromObject (messageJSON)
}
export const generateWAMessage = async(
jid: string,
content: AnyMessageContent,
options: MessageGenerationOptions,
) => (
generateWAMessageFromContent(
jid,
await generateWAMessageContent(
content,
options
),
options
)
)