mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
369 lines
16 KiB
TypeScript
369 lines
16 KiB
TypeScript
import WhatsAppWebGroups from './Groups'
|
|
import fetch from 'node-fetch'
|
|
import { promises as fs } from 'fs'
|
|
import {
|
|
MessageOptions,
|
|
MessageType,
|
|
Mimetype,
|
|
MimetypeMap,
|
|
MediaPathMap,
|
|
WALocationMessage,
|
|
WAContactMessage,
|
|
WASendMessageResponse,
|
|
WAMessageKey,
|
|
ChatModification,
|
|
MessageInfo,
|
|
WATextMessage,
|
|
WAUrlInfo,
|
|
} from './Constants'
|
|
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes } from '../WAConnection/Utils'
|
|
import { WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError } from '../WAConnection/Constants'
|
|
import { validateJIDForSending, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage } from './Utils'
|
|
import { proto } from '../../WAMessage/WAMessage'
|
|
|
|
export default class WhatsAppWebMessages extends WhatsAppWebGroups {
|
|
/** Get the message info, who has read it, who its been delivered to */
|
|
async messageInfo (jid: string, messageID: string) {
|
|
const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null]
|
|
const response = (await this.queryExpecting200 (query, [22, WAFlag.ignore]))[2]
|
|
|
|
const info: MessageInfo = {reads: [], deliveries: []}
|
|
if (response) {
|
|
//console.log (response)
|
|
const reads = response.filter (node => node[0] === 'read')
|
|
if (reads[0]) {
|
|
info.reads = reads[0][2].map (item => item[1])
|
|
}
|
|
const deliveries = response.filter (node => node[0] === 'delivery')
|
|
if (deliveries[0]) {
|
|
info.deliveries = deliveries[0][2].map (item => item[1])
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
/**
|
|
* Send a read receipt to the given ID for a certain message
|
|
* @param jid the ID of the person/group whose message you want to mark read
|
|
* @param messageID optionally, the message ID
|
|
* @param type whether to read or unread the message
|
|
*/
|
|
async sendReadReceipt(jid: string, messageID?: string, type: 'read' | 'unread' = 'read') {
|
|
const attributes = {
|
|
jid: jid,
|
|
count: type === 'read' ? '1' : '-2',
|
|
index: messageID,
|
|
owner: messageID ? 'false' : null
|
|
}
|
|
return this.setQuery ([['read', attributes, null]])
|
|
}
|
|
/**
|
|
* Mark a given chat as unread
|
|
* @deprecated since 2.0.0, use `sendReadReceipt (jid, null, 'unread')` instead
|
|
*/
|
|
async markChatUnread (jid: string) { return this.sendReadReceipt (jid, null, 'unread') }
|
|
/**
|
|
* Archive a chat
|
|
* @deprecated since 2.0.0, use `modifyChat (jid, ChatModification.archive)` instead
|
|
*/
|
|
async archiveChat (jid: string) { return this.modifyChat (jid, ChatModification.archive) }
|
|
/**
|
|
* Modify a given chat (archive, pin etc.)
|
|
* @param jid the ID of the person/group you are modifiying
|
|
* @param options.stamp the timestamp of pinning/muting the chat. Is required when unpinning/unmuting
|
|
*/
|
|
async modifyChat (jid: string, type: ChatModification, options: {stamp: Date | string} = {stamp: new Date()}) {
|
|
let chatAttrs: Record<string, string> = {jid: jid}
|
|
if ((type === ChatModification.unpin || type === ChatModification.unmute) && !options?.stamp) {
|
|
throw new Error('options.stamp must be set to the timestamp of the time of pinning/unpinning of the chat')
|
|
}
|
|
const strStamp = options.stamp &&
|
|
(typeof options.stamp === 'string' ? options.stamp : Math.round(options.stamp.getTime ()/1000).toString ())
|
|
switch (type) {
|
|
case ChatModification.pin:
|
|
case ChatModification.mute:
|
|
chatAttrs.type = type
|
|
chatAttrs[type] = strStamp
|
|
break
|
|
case ChatModification.unpin:
|
|
case ChatModification.unmute:
|
|
chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin'
|
|
chatAttrs.previous = strStamp
|
|
break
|
|
default:
|
|
chatAttrs.type = type
|
|
break
|
|
}
|
|
let response = await this.setQuery ([['chat', chatAttrs, null]]) as any
|
|
response.stamp = strStamp
|
|
return response as {status: number, stamp: string}
|
|
}
|
|
async loadMessage (jid: string, messageID: string) {
|
|
let messages
|
|
try {
|
|
messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: true}, false)
|
|
} catch {
|
|
messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: false}, false)
|
|
}
|
|
var index = null
|
|
if (messages.length > 0) index = messages[0].key
|
|
|
|
const actual = await this.loadConversation (jid, 1, index)
|
|
return actual[0]
|
|
}
|
|
/** Query a string to check if it has a url, if it does, return required extended text message */
|
|
async generateLinkPreview (text: string) {
|
|
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null]
|
|
const response = await this.queryExpecting200 (query, [26, WAFlag.ignore])
|
|
|
|
if (response[1]) response[1].jpegThumbnail = response[2]
|
|
const data = response[1] as WAUrlInfo
|
|
|
|
const content = {text} as WATextMessage
|
|
content.canonicalUrl = data['canonical-url']
|
|
content.matchedText = data['matched-text']
|
|
content.jpegThumbnail = data.jpegThumbnail
|
|
content.description = data.description
|
|
content.title = data.title
|
|
content.previewType = 0
|
|
return content
|
|
}
|
|
/**
|
|
* Search WhatsApp messages with a given text string
|
|
* @param txt the search string
|
|
* @param inJid the ID of the chat to search in, set to null to search all chats
|
|
* @param count number of results to return
|
|
* @param page page number of results (starts from 1)
|
|
*/
|
|
async searchMessages(txt: string, inJid: string | null, count: number, page: number) {
|
|
const json = [
|
|
'query',
|
|
{
|
|
epoch: this.msgCount.toString(),
|
|
type: 'search',
|
|
search: txt,
|
|
count: count.toString(),
|
|
page: page.toString(),
|
|
jid: inJid
|
|
},
|
|
null,
|
|
]
|
|
const response: WANode = await this.queryExpecting200(json, [WAMetric.group, WAFlag.ignore]) // encrypt and send off
|
|
const messages = response[2] ? response[2].map (row => row[2]) : []
|
|
return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] }
|
|
}
|
|
/**
|
|
* Delete a message in a chat for yourself
|
|
* @param messageKey key of the message you want to delete
|
|
*/
|
|
async clearMessage (messageKey: WAMessageKey) {
|
|
const tag = Math.round(Math.random ()*1000000)
|
|
const attrs: WANode = [
|
|
'chat',
|
|
{ jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' },
|
|
[
|
|
['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null]
|
|
]
|
|
]
|
|
return this.setQuery ([attrs])
|
|
}
|
|
/**
|
|
* Fetches the latest url & media key for the given message.
|
|
* You may need to call this when the message is old & the content is deleted off of the WA servers
|
|
* @param message
|
|
*/
|
|
async updateMediaMessage (message: WAMessage) {
|
|
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
|
|
if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message)
|
|
|
|
const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null]
|
|
const response = await this.query (query, [WAMetric.queryMedia, WAFlag.ignore])
|
|
if (parseInt(response[1].code) !== 200) throw new BaileysError ('unexpected status ' + response[1].code, response)
|
|
|
|
Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message
|
|
}
|
|
/**
|
|
* Delete a message in a chat for everyone
|
|
* @param id the person or group where you're trying to delete the message
|
|
* @param messageKey key of the message you want to delete
|
|
*/
|
|
async deleteMessage (id: string, messageKey: WAMessageKey) {
|
|
const json: WAMessageContent = {
|
|
protocolMessage: {
|
|
key: messageKey,
|
|
type: proto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE
|
|
}
|
|
}
|
|
return this.sendGenericMessage (id, json, {})
|
|
}
|
|
async sendMessage(
|
|
id: string,
|
|
message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer,
|
|
type: MessageType,
|
|
options: MessageOptions = {},
|
|
) {
|
|
if (options.validateID === true || !('validateID' in options)) {
|
|
validateJIDForSending (id)
|
|
}
|
|
let m: WAMessageContent = {}
|
|
switch (type) {
|
|
case MessageType.text:
|
|
case MessageType.extendedText:
|
|
if (typeof message === 'string') {
|
|
m.extendedTextMessage = {text: message}
|
|
} else if ('text' in message) {
|
|
m.extendedTextMessage = message as WATextMessage
|
|
} else {
|
|
throw new BaileysError ('message needs to be a string or object with property \'text\'', message)
|
|
}
|
|
break
|
|
case MessageType.location:
|
|
case MessageType.liveLocation:
|
|
m.locationMessage = message as WALocationMessage
|
|
break
|
|
case MessageType.contact:
|
|
m.contactMessage = message as WAContactMessage
|
|
break
|
|
default:
|
|
m = await this.prepareMediaMessage(message as Buffer, type, options)
|
|
break
|
|
}
|
|
return this.sendGenericMessage(id, m, options)
|
|
}
|
|
/** Prepare a media message for sending */
|
|
protected async prepareMediaMessage(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) {
|
|
if (mediaType === MessageType.document && !options.mimetype) {
|
|
throw new Error('mimetype required to send a document')
|
|
}
|
|
if (mediaType === MessageType.sticker && options.caption) {
|
|
throw new Error('cannot send a caption with a sticker')
|
|
}
|
|
if (!options.mimetype) {
|
|
options.mimetype = MimetypeMap[mediaType]
|
|
}
|
|
let isGIF = false
|
|
if (options.mimetype === Mimetype.gif) {
|
|
isGIF = true
|
|
options.mimetype = MimetypeMap[MessageType.video]
|
|
}
|
|
// generate a media key
|
|
const mediaKey = randomBytes(32)
|
|
const mediaKeys = getMediaKeys(mediaKey, mediaType)
|
|
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
|
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
|
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
|
const fileSha256 = sha256(buffer)
|
|
// url safe Base64 encode the SHA256 hash of the body
|
|
const fileEncSha256B64 = sha256(body)
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/\=+$/, '')
|
|
|
|
await generateThumbnail(buffer, mediaType, options)
|
|
// send a query JSON to obtain the url & auth token to upload our media
|
|
const json = (await this.query(['query', 'mediaConn'])).media_conn
|
|
const auth = json.auth // the auth token
|
|
let hostname = 'https://' + json.hosts[0].hostname // first hostname available
|
|
hostname += MediaPathMap[mediaType] + '/' + fileEncSha256B64 // append path
|
|
hostname += '?auth=' + auth // add auth token
|
|
hostname += '&token=' + fileEncSha256B64 // file hash
|
|
|
|
const urlFetch = await fetch(hostname, {
|
|
method: 'POST',
|
|
body: body,
|
|
headers: { Origin: 'https://web.whatsapp.com' },
|
|
})
|
|
const responseJSON = await urlFetch.json()
|
|
if (!responseJSON.url) {
|
|
throw new Error('Upload failed got: ' + JSON.stringify(responseJSON))
|
|
}
|
|
const message = {}
|
|
message[mediaType] = {
|
|
url: responseJSON.url,
|
|
mediaKey: mediaKey.toString('base64'),
|
|
mimetype: options.mimetype,
|
|
fileEncSha256: fileEncSha256B64,
|
|
fileSha256: fileSha256.toString('base64'),
|
|
fileLength: buffer.length,
|
|
fileName: options.filename || 'file',
|
|
gifPlayback: isGIF || null,
|
|
}
|
|
return message as WAMessageContent
|
|
}
|
|
/** Generic send message function */
|
|
async sendGenericMessage(id: string, message: WAMessageContent, options: MessageOptions) {
|
|
if (!options.timestamp) {
|
|
// if no timestamp was provided,
|
|
options.timestamp = new Date() // set timestamp to now
|
|
}
|
|
const key = Object.keys(message)[0]
|
|
const timestamp = options.timestamp.getTime()/1000
|
|
const quoted = options.quoted
|
|
if (quoted) {
|
|
const participant = quoted.key.participant || quoted.key.remoteJid
|
|
message[key].contextInfo = {
|
|
participant: participant,
|
|
stanzaId: quoted.key.id,
|
|
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
|
|
}
|
|
}
|
|
message[key].caption = options?.caption
|
|
if (!message[key].jpegThumbnail) message[key].jpegThumbnail = options?.thumbnail
|
|
|
|
const messageJSON = {
|
|
key: {
|
|
remoteJid: id,
|
|
fromMe: true,
|
|
id: generateMessageID(),
|
|
},
|
|
message: message,
|
|
messageTimestamp: timestamp,
|
|
messageStubParameters: [],
|
|
participant: id.includes('@g.us') ? this.userMetaData.id : null,
|
|
status: WAMessageProto.proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS.PENDING
|
|
}
|
|
const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, messageJSON]]]
|
|
const flag = id === this.userMetaData.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
|
|
const response = await this.queryExpecting200(json, [WAMetric.message, flag], null, messageJSON.key.id)
|
|
return {
|
|
status: response.status as number,
|
|
messageID: messageJSON.key.id,
|
|
message: messageJSON as WAMessage
|
|
} as WASendMessageResponse
|
|
}
|
|
/**
|
|
* Securely downloads the media from the message.
|
|
* Renews the download url automatically, if necessary.
|
|
*/
|
|
async downloadMediaMessage (message: WAMessage) {
|
|
try {
|
|
return decodeMediaMessageBuffer (message.message)
|
|
} catch (error) {
|
|
if (error.toString().includes('Empty buffer returned')) {
|
|
await this.updateMediaMessage (message)
|
|
return decodeMediaMessageBuffer (message.message)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
/**
|
|
* Securely downloads the media from the message and saves to a file.
|
|
* Renews the download url automatically, if necessary.
|
|
* @param message the media message you want to decode
|
|
* @param filename the name of the file where the media will be saved
|
|
* @param attachExtension should the parsed extension be applied automatically to the file
|
|
*/
|
|
async downloadAndSaveMediaMessage (message: WAMessage, filename: string, attachExtension: boolean=true) {
|
|
const buffer = await this.downloadMediaMessage (message)
|
|
const extension = extensionForMediaMessage (message.message)
|
|
const trueFileName = attachExtension ? (filename + '.' + extension) : filename
|
|
await fs.writeFile (trueFileName, buffer)
|
|
return trueFileName
|
|
}
|
|
}
|