mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Got rid of WAClient, deprecated code. Prep for V3
Layered classes based on hierarchy as well.
This commit is contained in:
@@ -14,6 +14,11 @@ import {
|
||||
AuthenticationCredentialsBrowser,
|
||||
BaileysError,
|
||||
WAConnectionMode,
|
||||
WAMessage,
|
||||
PresenceUpdate,
|
||||
MessageStatusUpdate,
|
||||
WAMetric,
|
||||
WAFlag,
|
||||
} from './Constants'
|
||||
|
||||
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
|
||||
@@ -22,7 +27,7 @@ const generateQRCode = function ([ref, publicKey, clientID]) {
|
||||
QR.generate(str, { small: true })
|
||||
}
|
||||
|
||||
export default class WAConnectionBase {
|
||||
export class WAConnection {
|
||||
/** The version of WhatsApp Web we're telling the servers we are */
|
||||
version: [number, number, number] = [2, 2027, 10]
|
||||
/** The Browser we're telling the WhatsApp Web servers we are */
|
||||
@@ -61,9 +66,7 @@ export default class WAConnectionBase {
|
||||
protected pendingRequests: (() => void)[] = []
|
||||
protected reconnectLoop: () => Promise<void>
|
||||
protected referenceDate = new Date () // used for generating tags
|
||||
protected userAgentString: string
|
||||
constructor () {
|
||||
this.userAgentString = Utils.userAgentString (this.browserDescription[1])
|
||||
this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind))
|
||||
}
|
||||
async unexpectedDisconnect (error: string) {
|
||||
@@ -74,6 +77,46 @@ export default class WAConnectionBase {
|
||||
this.unexpectedDisconnectCallback (error)
|
||||
}
|
||||
}
|
||||
/** Set the callback for message status updates (when a message is delivered, read etc.) */
|
||||
setOnMessageStatusChange(callback: (update: MessageStatusUpdate) => void) {
|
||||
const func = json => {
|
||||
json = json[1]
|
||||
let ids = json.id
|
||||
if (json.cmd === 'ack') {
|
||||
ids = [json.id]
|
||||
}
|
||||
const data: MessageStatusUpdate = {
|
||||
from: json.from,
|
||||
to: json.to,
|
||||
participant: json.participant,
|
||||
timestamp: new Date(json.t * 1000),
|
||||
ids: ids,
|
||||
type: (+json.ack)+1,
|
||||
}
|
||||
callback(data)
|
||||
}
|
||||
this.registerCallback('Msg', func)
|
||||
this.registerCallback('MsgInfo', func)
|
||||
}
|
||||
/**
|
||||
* Set the callback for new/unread messages; if someone sends you a message, this callback will be fired
|
||||
* @param callbackOnMyMessages - should the callback be fired on a message you sent from the phone
|
||||
*/
|
||||
setOnUnreadMessage(callbackOnMyMessages = false, callback: (m: WAMessage) => void) {
|
||||
this.registerCallback(['action', 'add:relay', 'message'], (json) => {
|
||||
const message = json[2][0][2]
|
||||
if (!message.key.fromMe || callbackOnMyMessages) {
|
||||
// if this message was sent to us, notify
|
||||
callback(message as WAMessage)
|
||||
} else {
|
||||
this.log(`[Unhandled] message - ${JSON.stringify(message)}`, MessageLogLevel.unhandled)
|
||||
}
|
||||
})
|
||||
}
|
||||
/** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */
|
||||
setOnPresenceUpdate(callback: (p: PresenceUpdate) => void) {
|
||||
this.registerCallback('Presence', json => callback(json[1]))
|
||||
}
|
||||
/** Set the callback for unexpected disconnects including take over events, log out events etc. */
|
||||
setOnUnexpectedDisconnect(callback: (error: string) => void) {
|
||||
this.unexpectedDisconnectCallback = callback
|
||||
@@ -243,6 +286,12 @@ export default class WAConnectionBase {
|
||||
|
||||
return this.waitForMessage(tag, json, timeoutMs)
|
||||
}
|
||||
/** Generic function for action, set queries */
|
||||
async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) {
|
||||
const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes]
|
||||
const result = await this.queryExpecting200(json, binaryTags, null, tag) as Promise<{status: number}>
|
||||
return result
|
||||
}
|
||||
/**
|
||||
* Send a binary encoded message
|
||||
* @param json the message to encode & send
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as Curve from 'curve25519-js'
|
||||
import * as Utils from './Utils'
|
||||
import WAConnectionBase from './Base'
|
||||
import { MessageLogLevel, WAMetric, WAFlag, BaileysError } from './Constants'
|
||||
import { Presence } from '../WAClient/WAClient'
|
||||
import {WAConnection as Base} from './0.Base'
|
||||
import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence } from './Constants'
|
||||
|
||||
export default class WAConnectionValidator extends WAConnectionBase {
|
||||
export class WAConnection extends Base {
|
||||
|
||||
/** Authenticate the connection */
|
||||
protected async authenticate() {
|
||||
if (!this.authInfo.clientID) {
|
||||
@@ -21,6 +21,7 @@ export default class WAConnectionValidator extends WAConnectionBase {
|
||||
|
||||
this.referenceDate = new Date () // refresh reference date
|
||||
const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true]
|
||||
|
||||
return this.queryExpecting200(data)
|
||||
.then(json => {
|
||||
// we're trying to establish a new connection or are trying to log in
|
||||
@@ -2,10 +2,10 @@ import WS from 'ws'
|
||||
import KeyedDB from '@adiwajshing/keyed-db'
|
||||
import * as Utils from './Utils'
|
||||
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel, WANode, WAConnectionMode } from './Constants'
|
||||
import WAConnectionValidator from './Validation'
|
||||
import {WAConnection as Base} from './1.Validation'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
|
||||
export default class WAConnectionConnector extends WAConnectionValidator {
|
||||
export class WAConnection extends Base {
|
||||
/**
|
||||
* Connect to WhatsAppWeb
|
||||
* @param [authInfo] credentials or path to credentials to log back in
|
||||
170
src/WAConnection/4.User.ts
Normal file
170
src/WAConnection/4.User.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {WAConnection as Base} from './3.Connect'
|
||||
import { Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants'
|
||||
import {
|
||||
WAMessage,
|
||||
WANode,
|
||||
WAMetric,
|
||||
WAFlag,
|
||||
} from '../WAConnection/Constants'
|
||||
import { generateProfilePicture } from './Utils'
|
||||
|
||||
// All user related functions -- get profile picture, set status etc.
|
||||
|
||||
export class WAConnection extends Base {
|
||||
/** Query whether a given number is registered on WhatsApp */
|
||||
isOnWhatsApp = (jid: string) => this.query(['query', 'exist', jid]).then((m) => m.status === 200)
|
||||
/**
|
||||
* Tell someone about your presence -- online, typing, offline etc.
|
||||
* @param jid the ID of the person/group who you are updating
|
||||
* @param type your presence
|
||||
*/
|
||||
async updatePresence(jid: string | null, type: Presence) {
|
||||
const json = [
|
||||
'action',
|
||||
{ epoch: this.msgCount.toString(), type: 'set' },
|
||||
[['presence', { type: type, to: jid }, null]],
|
||||
]
|
||||
return this.queryExpecting200(json, [WAMetric.group, WAFlag.acknowledge]) as Promise<{ status: number }>
|
||||
}
|
||||
/** Request an update on the presence of a user */
|
||||
requestPresenceUpdate = async (jid: string) => this.queryExpecting200(['action', 'presence', 'subscribe', jid])
|
||||
/** Query the status of the person (see groupMetadata() for groups) */
|
||||
async getStatus (jid?: string) {
|
||||
return this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }>
|
||||
}
|
||||
async setStatus (status: string) {
|
||||
return this.setQuery (
|
||||
[
|
||||
[
|
||||
'status',
|
||||
null,
|
||||
Buffer.from (status, 'utf-8')
|
||||
]
|
||||
]
|
||||
)
|
||||
}
|
||||
/** Get the URL to download the profile picture of a person/group */
|
||||
async getProfilePicture(jid: string | null) {
|
||||
const response = await this.queryExpecting200(['query', 'ProfilePicThumb', jid || this.userMetaData.id])
|
||||
return response.eurl as string
|
||||
}
|
||||
/** Get your contacts */
|
||||
async getContacts() {
|
||||
const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null]
|
||||
const response = await this.query(json, [6, WAFlag.ignore]) // this has to be an encrypted query
|
||||
return response
|
||||
}
|
||||
/** Get the stories of your contacts */
|
||||
async getStories() {
|
||||
const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null]
|
||||
const response = await this.queryExpecting200(json, [30, WAFlag.ignore]) as WANode
|
||||
if (Array.isArray(response[2])) {
|
||||
return response[2].map (row => (
|
||||
{
|
||||
unread: row[1]?.unread,
|
||||
count: row[1]?.count,
|
||||
messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : []
|
||||
} as {unread: number, count: number, messages: WAMessage[]}
|
||||
))
|
||||
}
|
||||
return []
|
||||
}
|
||||
/** Fetch your chats */
|
||||
async getChats() {
|
||||
const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null]
|
||||
return this.query(json, [5, WAFlag.ignore]) // this has to be an encrypted query
|
||||
}
|
||||
/** Query broadcast list info */
|
||||
async getBroadcastListInfo(jid: string) { return this.queryExpecting200(['query', 'contact', jid]) as Promise<WABroadcastListInfo> }
|
||||
/** Delete the chat of a given ID */
|
||||
async deleteChat (jid: string) {
|
||||
return this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as Promise<{status: number}>
|
||||
}
|
||||
/**
|
||||
* Check if your phone is connected
|
||||
* @param timeoutMs max time for the phone to respond
|
||||
*/
|
||||
async isPhoneConnected(timeoutMs = 5000) {
|
||||
try {
|
||||
const response = await this.query(['admin', 'test'], null, timeoutMs)
|
||||
return response[1] as boolean
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load the conversation with a group or person
|
||||
* @param count the number of messages to load
|
||||
* @param [indexMessage] the data for which message to offset the query by
|
||||
* @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start
|
||||
*/
|
||||
async loadConversation(
|
||||
jid: string,
|
||||
count: number,
|
||||
indexMessage: { id: string; fromMe: boolean } = null,
|
||||
mostRecentFirst = true,
|
||||
) {
|
||||
const json = [
|
||||
'query',
|
||||
{
|
||||
epoch: this.msgCount.toString(),
|
||||
type: 'message',
|
||||
jid: jid,
|
||||
kind: mostRecentFirst ? 'before' : 'after',
|
||||
count: count.toString(),
|
||||
index: indexMessage?.id,
|
||||
owner: indexMessage?.fromMe === false ? 'false' : 'true',
|
||||
},
|
||||
null,
|
||||
]
|
||||
const response = await this.queryExpecting200(json, [WAMetric.queryMessages, WAFlag.ignore])
|
||||
return response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : []
|
||||
}
|
||||
/**
|
||||
* Load the entire friggin conversation with a group or person
|
||||
* @param onMessage callback for every message retreived
|
||||
* @param [chunkSize] the number of messages to load in a single request
|
||||
* @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start
|
||||
*/
|
||||
loadEntireConversation(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) {
|
||||
let offsetID = null
|
||||
const loadMessage = async () => {
|
||||
const json = await this.loadConversation(jid, chunkSize, offsetID, mostRecentFirst)
|
||||
// callback with most recent message first (descending order of date)
|
||||
let lastMessage
|
||||
if (mostRecentFirst) {
|
||||
for (let i = json.length - 1; i >= 0; i--) {
|
||||
onMessage(json[i])
|
||||
lastMessage = json[i]
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
onMessage(json[i])
|
||||
lastMessage = json[i]
|
||||
}
|
||||
}
|
||||
// if there are still more messages
|
||||
if (json.length >= chunkSize) {
|
||||
offsetID = lastMessage.key // get the last message
|
||||
return new Promise((resolve, reject) => {
|
||||
// send query after 200 ms
|
||||
setTimeout(() => loadMessage().then(resolve).catch(reject), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
return loadMessage() as Promise<void>
|
||||
}
|
||||
async updateProfilePicture (jid: string, img: Buffer) {
|
||||
const data = await generateProfilePicture (img)
|
||||
const tag = this.generateMessageTag ()
|
||||
const query: WANode = [
|
||||
'picture',
|
||||
{ jid: jid, id: tag, type: 'set' },
|
||||
[
|
||||
['image', null, data.img],
|
||||
['preview', null, data.preview]
|
||||
]
|
||||
]
|
||||
return this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise<WAProfilePictureChange>
|
||||
}
|
||||
}
|
||||
408
src/WAConnection/5.Messages.ts
Normal file
408
src/WAConnection/5.Messages.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import {WAConnection as Base} from './4.User'
|
||||
import fetch from 'node-fetch'
|
||||
import fs from 'fs/promises'
|
||||
import {
|
||||
MessageOptions,
|
||||
MessageType,
|
||||
Mimetype,
|
||||
MimetypeMap,
|
||||
MediaPathMap,
|
||||
WALocationMessage,
|
||||
WAContactMessage,
|
||||
WASendMessageResponse,
|
||||
WAMessageKey,
|
||||
ChatModification,
|
||||
MessageInfo,
|
||||
WATextMessage,
|
||||
WAUrlInfo,
|
||||
WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE
|
||||
} from './Constants'
|
||||
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage } from './Utils'
|
||||
|
||||
export class WAConnection extends Base {
|
||||
/** 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]])
|
||||
}
|
||||
/**
|
||||
* 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: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE
|
||||
}
|
||||
}
|
||||
const waMessage = this.generateWAMessage (id, json, {})
|
||||
await this.relayWAMessage (waMessage)
|
||||
return waMessage
|
||||
}
|
||||
/**
|
||||
* Forward a message like WA does
|
||||
* @param id the id to forward the message to
|
||||
* @param message the message to forward
|
||||
* @param forceForward will show the message as forwarded even if it is from you
|
||||
*/
|
||||
async forwardMessage(id: string, message: WAMessage, forceForward: boolean=false) {
|
||||
const content = message.message
|
||||
if (!content) throw new Error ('no content in message')
|
||||
|
||||
let key = Object.keys(content)[0]
|
||||
|
||||
let score = content[key].contextInfo?.forwardingScore || 0
|
||||
score += message.key.fromMe && !forceForward ? 0 : 1
|
||||
if (key === MessageType.text) {
|
||||
content[MessageType.extendedText] = { text: content[key] }
|
||||
delete content[MessageType.text]
|
||||
|
||||
key = MessageType.extendedText
|
||||
}
|
||||
if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true }
|
||||
else content[key].contextInfo = {}
|
||||
|
||||
const waMessage = this.generateWAMessage (id, content, {})
|
||||
await this.relayWAMessage (waMessage)
|
||||
return waMessage
|
||||
}
|
||||
/**
|
||||
* Send a message to the given ID (can be group, single, or broadcast)
|
||||
* @param id
|
||||
* @param message
|
||||
* @param type
|
||||
* @param options
|
||||
*/
|
||||
async sendMessage(
|
||||
id: string,
|
||||
message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer,
|
||||
type: MessageType,
|
||||
options: MessageOptions = {},
|
||||
) {
|
||||
const waMessage = await this.prepareMessage (id, message, type, options)
|
||||
await this.relayWAMessage (waMessage)
|
||||
return waMessage
|
||||
}
|
||||
/** Prepares a message for sending via sendWAMessage () */
|
||||
async prepareMessage(
|
||||
id: string,
|
||||
message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer,
|
||||
type: MessageType,
|
||||
options: MessageOptions = {},
|
||||
) {
|
||||
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.generateWAMessage(id, m, options)
|
||||
}
|
||||
/** Prepare a media message for sending */
|
||||
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,
|
||||
caption: options.caption
|
||||
}
|
||||
return message as WAMessageContent
|
||||
}
|
||||
/** generates a WAMessage from the given content & options */
|
||||
generateWAMessage(id: string, message: WAMessageContent, options: MessageOptions) {
|
||||
if (!options.timestamp) options.timestamp = new Date() // set timestamp to now
|
||||
|
||||
// prevent an annoying bug (WA doesn't accept sending messages with '@c.us')
|
||||
id = id.replace ('@c.us', '@s.whatsapp')
|
||||
|
||||
const key = Object.keys(message)[0]
|
||||
const timestamp = options.timestamp.getTime()/1000
|
||||
const quoted = options.quoted
|
||||
|
||||
if (options.contextInfo) message[key].contextInfo = options.contextInfo
|
||||
|
||||
if (quoted) {
|
||||
const 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 (!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: WA_MESSAGE_STATUS_TYPE.PENDING
|
||||
}
|
||||
return messageJSON as WAMessage
|
||||
}
|
||||
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
|
||||
async relayWAMessage(message: WAMessage) {
|
||||
const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]]
|
||||
const flag = message.key.remoteJid === this.userMetaData.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
|
||||
await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id)
|
||||
}
|
||||
/**
|
||||
* Securely downloads the media from the message.
|
||||
* Renews the download url automatically, if necessary.
|
||||
*/
|
||||
async downloadMediaMessage (message: WAMessage) {
|
||||
const fetchHeaders = { }
|
||||
try {
|
||||
const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders)
|
||||
return buff
|
||||
} catch (error) {
|
||||
if (error instanceof BaileysError && error.status === 404) { // media needs to be updated
|
||||
this.log (`updating media of message: ${message.key.id}`, MessageLogLevel.info)
|
||||
await this.updateMediaMessage (message)
|
||||
const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders)
|
||||
return buff
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
120
src/WAConnection/6.Groups.ts
Normal file
120
src/WAConnection/6.Groups.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {WAConnection as Base} from './5.Messages'
|
||||
import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants'
|
||||
import { GroupSettingChange } from './Constants'
|
||||
import { generateMessageID } from '../WAConnection/Utils'
|
||||
|
||||
export class WAConnection extends Base {
|
||||
/** Generic function for group queries */
|
||||
async groupQuery(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: WANode[]) {
|
||||
const tag = this.generateMessageTag()
|
||||
const json: WANode = [
|
||||
'group',
|
||||
{
|
||||
author: this.userMetaData.id,
|
||||
id: tag,
|
||||
type: type,
|
||||
jid: jid,
|
||||
subject: subject,
|
||||
},
|
||||
participants ? participants.map(str => ['participant', { jid: str }, null]) : additionalNodes,
|
||||
]
|
||||
const result = await this.setQuery ([json], [WAMetric.group, WAFlag.ignore], tag)
|
||||
return result
|
||||
}
|
||||
/** Get the metadata of the group */
|
||||
groupMetadata = (jid: string) => this.queryExpecting200(['query', 'GroupMetadata', jid]) as Promise<WAGroupMetadata>
|
||||
/** Get the metadata (works after you've left the group also) */
|
||||
groupMetadataMinimal = async (jid: string) => {
|
||||
const query = ['query', {type: 'group', jid: jid, epoch: this.msgCount.toString()}, null]
|
||||
const response = await this.queryExpecting200(query, [WAMetric.group, WAFlag.ignore])
|
||||
const json = response[2][0]
|
||||
const creatorDesc = json[1]
|
||||
const participants = json[2] ? json[2].filter (item => item[0] === 'participant') : []
|
||||
const description = json[2] ? json[2].find (item => item[0] === 'description') : null
|
||||
return {
|
||||
id: jid,
|
||||
owner: creatorDesc?.creator,
|
||||
creator: creatorDesc?.creator,
|
||||
creation: parseInt(creatorDesc?.create),
|
||||
subject: null,
|
||||
desc: description ? description[2].toString('utf-8') : null,
|
||||
participants: participants.map (item => ({ id: item[1].jid, isAdmin: item[1].type==='admin' }))
|
||||
} as WAGroupMetadata
|
||||
}
|
||||
/**
|
||||
* Create a group
|
||||
* @param title like, the title of the group
|
||||
* @param participants people to include in the group
|
||||
*/
|
||||
groupCreate = (title: string, participants: string[]) =>
|
||||
this.groupQuery('create', null, title, participants) as Promise<WAGroupCreateResponse>
|
||||
/**
|
||||
* Leave a group
|
||||
* @param jid the ID of the group
|
||||
*/
|
||||
groupLeave = (jid: string) => this.groupQuery('leave', jid) as Promise<{ status: number }>
|
||||
/**
|
||||
* Update the subject of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateSubject = (jid: string, title: string) =>
|
||||
this.groupQuery('subject', jid, title) as Promise<{ status: number }>
|
||||
/**
|
||||
* Update the group description
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateDescription = async (jid: string, description: string) => {
|
||||
const metadata = await this.groupMetadata (jid)
|
||||
const node: WANode = [
|
||||
'description',
|
||||
{id: generateMessageID(), prev: metadata?.descId},
|
||||
Buffer.from (description, 'utf-8')
|
||||
]
|
||||
return this.groupQuery ('description', jid, null, null, [node])
|
||||
}
|
||||
/**
|
||||
* Add somebody to the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to add
|
||||
*/
|
||||
groupAdd = (jid: string, participants: string[]) =>
|
||||
this.groupQuery('add', jid, null, participants) as Promise<WAGroupModification>
|
||||
/**
|
||||
* Remove somebody from the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to remove
|
||||
*/
|
||||
groupRemove = (jid: string, participants: string[]) =>
|
||||
this.groupQuery('remove', jid, null, participants) as Promise<WAGroupModification>
|
||||
/**
|
||||
* Make someone admin on the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to make admin
|
||||
*/
|
||||
groupMakeAdmin = (jid: string, participants: string[]) =>
|
||||
this.groupQuery('promote', jid, null, participants) as Promise<WAGroupModification>
|
||||
/**
|
||||
* Make demote an admin on the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to make admin
|
||||
*/
|
||||
groupDemoteAdmin = (jid: string, participants: string[]) =>
|
||||
this.groupQuery('demote', jid, null, participants) as Promise<WAGroupModification>
|
||||
/**
|
||||
* Make demote an admin on the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to make admin
|
||||
*/
|
||||
groupSettingChange = (jid: string, setting: GroupSettingChange, onlyAdmins: boolean) => {
|
||||
const node: WANode = [ setting, {value: onlyAdmins ? 'true' : 'false'}, null ]
|
||||
return this.groupQuery('prop', jid, null, null, [node]) as Promise<{status: number}>
|
||||
}
|
||||
/** Get the invite link of the given group */
|
||||
async groupInviteCode(jid: string) {
|
||||
const json = ['query', 'inviteCode', jid]
|
||||
const response = await this.queryExpecting200(json)
|
||||
return response.code as string
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import { decryptWA } from './Utils'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
|
||||
interface BrowserMessagesInfo {
|
||||
encKey: string,
|
||||
macKey: string,
|
||||
harFilePath: string
|
||||
}
|
||||
interface WSMessage {
|
||||
type: 'send' | 'receive',
|
||||
data: string
|
||||
}
|
||||
const file = fs.readFileSync ('./browser-messages.json', {encoding: 'utf-8'})
|
||||
const json: BrowserMessagesInfo = JSON.parse (file)
|
||||
|
||||
const encKey = Buffer.from (json.encKey, 'base64')
|
||||
const macKey = Buffer.from (json.macKey, 'base64')
|
||||
|
||||
const harFile = JSON.parse ( fs.readFileSync( json.harFilePath , {encoding: 'utf-8'}))
|
||||
const entries = harFile['log']['entries']
|
||||
let wsMessages: WSMessage[] = []
|
||||
entries.forEach ((e, i) => {
|
||||
if ('_webSocketMessages' in e) {
|
||||
wsMessages.push (...e['_webSocketMessages'])
|
||||
}
|
||||
})
|
||||
const decrypt = buffer => {
|
||||
try {
|
||||
return decryptWA (buffer, macKey, encKey, new Decoder())
|
||||
} catch {
|
||||
return decryptWA (buffer, macKey, encKey, new Decoder(), true)
|
||||
}
|
||||
}
|
||||
|
||||
console.log ('parsing ' + wsMessages.length + ' messages')
|
||||
const list = wsMessages.map ((item, i) => {
|
||||
const buffer = Buffer.from (item.data, 'base64')
|
||||
try {
|
||||
|
||||
const [tag, json, binaryTags] = decrypt (buffer)
|
||||
return {tag, json: JSON.stringify(json), binaryTags}
|
||||
} catch (error) {
|
||||
try {
|
||||
const [tag, json, binaryTags] = decrypt (item.data)
|
||||
return {tag, json: JSON.stringify(json), binaryTags}
|
||||
} catch (error) {
|
||||
console.log ('error in decoding: ' + item.data + ': ' + error)
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
const str = JSON.stringify (list, null, '\t')
|
||||
fs.writeFileSync ('decoded-ws.json', str)
|
||||
@@ -133,6 +133,152 @@ export enum WAFlag {
|
||||
}
|
||||
/** Tag used with binary queries */
|
||||
export type WATag = [WAMetric, WAFlag]
|
||||
export * as WAMessageProto from '../../WAMessage/WAMessage'
|
||||
// export the WAMessage Prototype as well
|
||||
export { proto as WAMessageProto } from '../../WAMessage/WAMessage'
|
||||
|
||||
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
|
||||
export enum Presence {
|
||||
available = 'available', // "online"
|
||||
unavailable = 'unavailable', // "offline"
|
||||
composing = 'composing', // "typing..."
|
||||
recording = 'recording', // "recording..."
|
||||
paused = 'paused', // I have no clue
|
||||
}
|
||||
/** Status of a message sent or received */
|
||||
export enum MessageStatus {
|
||||
sent = 'sent',
|
||||
received = 'received',
|
||||
read = 'read',
|
||||
}
|
||||
/** Set of message types that are supported by the library */
|
||||
export enum MessageType {
|
||||
text = 'conversation',
|
||||
extendedText = 'extendedTextMessage',
|
||||
contact = 'contactMessage',
|
||||
location = 'locationMessage',
|
||||
liveLocation = 'liveLocationMessage',
|
||||
|
||||
image = 'imageMessage',
|
||||
video = 'videoMessage',
|
||||
sticker = 'stickerMessage',
|
||||
document = 'documentMessage',
|
||||
audio = 'audioMessage',
|
||||
product = 'productMessage'
|
||||
}
|
||||
export enum ChatModification {
|
||||
archive='archive',
|
||||
unarchive='unarchive',
|
||||
pin='pin',
|
||||
unpin='unpin',
|
||||
mute='mute',
|
||||
unmute='unmute'
|
||||
}
|
||||
export const HKDFInfoKeys = {
|
||||
[MessageType.image]: 'WhatsApp Image Keys',
|
||||
[MessageType.audio]: 'WhatsApp Audio Keys',
|
||||
[MessageType.video]: 'WhatsApp Video Keys',
|
||||
[MessageType.document]: 'WhatsApp Document Keys',
|
||||
[MessageType.sticker]: 'WhatsApp Image Keys'
|
||||
}
|
||||
export enum Mimetype {
|
||||
jpeg = 'image/jpeg',
|
||||
png = 'image/png',
|
||||
mp4 = 'video/mp4',
|
||||
gif = 'video/gif',
|
||||
pdf = 'application/pdf',
|
||||
ogg = 'audio/ogg; codecs=opus',
|
||||
/** for stickers */
|
||||
webp = 'image/webp',
|
||||
}
|
||||
export interface MessageOptions {
|
||||
quoted?: WAMessage
|
||||
contextInfo?: WAContextInfo
|
||||
timestamp?: Date
|
||||
caption?: string
|
||||
thumbnail?: string
|
||||
mimetype?: Mimetype | string
|
||||
filename?: string
|
||||
}
|
||||
export interface WABroadcastListInfo {
|
||||
status: number
|
||||
name: string
|
||||
recipients?: {id: string}[]
|
||||
}
|
||||
export interface WAUrlInfo {
|
||||
'canonical-url': string
|
||||
'matched-text': string
|
||||
title: string
|
||||
description: string
|
||||
jpegThumbnail?: Buffer
|
||||
}
|
||||
export interface WAProfilePictureChange {
|
||||
status: number
|
||||
tag: string
|
||||
eurl: string
|
||||
}
|
||||
export interface MessageInfo {
|
||||
reads: {jid: string, t: string}[]
|
||||
deliveries: {jid: string, t: string}[]
|
||||
}
|
||||
export interface MessageStatusUpdate {
|
||||
from: string
|
||||
to: string
|
||||
/** Which participant caused the update (only for groups) */
|
||||
participant?: string
|
||||
timestamp: Date
|
||||
/** Message IDs read/delivered */
|
||||
ids: string[]
|
||||
/** Status of the Message IDs */
|
||||
type: WA_MESSAGE_STATUS_TYPE
|
||||
}
|
||||
export enum GroupSettingChange {
|
||||
messageSend = 'announcement',
|
||||
settingsChange = 'locked',
|
||||
}
|
||||
export interface PresenceUpdate {
|
||||
id: string
|
||||
participant?: string
|
||||
t?: string
|
||||
type?: Presence
|
||||
deny?: boolean
|
||||
}
|
||||
// path to upload the media
|
||||
export const MediaPathMap = {
|
||||
imageMessage: '/mms/image',
|
||||
videoMessage: '/mms/video',
|
||||
documentMessage: '/mms/document',
|
||||
audioMessage: '/mms/audio',
|
||||
stickerMessage: '/mms/image',
|
||||
}
|
||||
// gives WhatsApp info to process the media
|
||||
export const MimetypeMap = {
|
||||
imageMessage: Mimetype.jpeg,
|
||||
videoMessage: Mimetype.mp4,
|
||||
documentMessage: Mimetype.pdf,
|
||||
audioMessage: Mimetype.ogg,
|
||||
stickerMessage: Mimetype.webp,
|
||||
}
|
||||
export interface WASendMessageResponse {
|
||||
status: number
|
||||
messageID: string
|
||||
message: WAMessage
|
||||
}
|
||||
export interface WALocationMessage {
|
||||
degreesLatitude: number
|
||||
degreesLongitude: number
|
||||
address?: string
|
||||
}
|
||||
export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE
|
||||
export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS
|
||||
|
||||
/** Reverse stub type dictionary */
|
||||
export const WAMessageType = function () {
|
||||
const types = WA_MESSAGE_STUB_TYPE
|
||||
const dict: Record<number, string> = {}
|
||||
Object.keys(types).forEach(element => dict[ types[element] ] = element)
|
||||
return dict
|
||||
}()
|
||||
export type WAContactMessage = proto.ContactMessage
|
||||
export type WAMessageKey = proto.IMessageKey
|
||||
export type WATextMessage = proto.ExtendedTextMessage
|
||||
export type WAContextInfo = proto.IContextInfo
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import * as assert from 'assert'
|
||||
import * as QR from 'qrcode-terminal'
|
||||
import WAConnection from './WAConnection'
|
||||
import { AuthenticationCredentialsBase64 } from './Constants'
|
||||
import { createTimeout } from './Utils'
|
||||
|
||||
describe('QR generation', () => {
|
||||
it('should generate QR', async () => {
|
||||
const conn = new WAConnection()
|
||||
let calledQR = false
|
||||
conn.onReadyForPhoneAuthentication = ([ref, curveKey, clientID]) => {
|
||||
assert.ok(ref, 'ref nil')
|
||||
assert.ok(curveKey, 'curve key nil')
|
||||
assert.ok(clientID, 'client ID nil')
|
||||
calledQR = true
|
||||
}
|
||||
await assert.rejects(async () => conn.connectSlim(null, 5000), 'should have failed connect')
|
||||
assert.equal(calledQR, true, 'QR not called')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Test Connect', () => {
|
||||
let auth: AuthenticationCredentialsBase64
|
||||
it('should connect', async () => {
|
||||
console.log('please be ready to scan with your phone')
|
||||
const conn = new WAConnection()
|
||||
const user = await conn.connectSlim(null)
|
||||
assert.ok(user)
|
||||
assert.ok(user.id)
|
||||
|
||||
conn.close()
|
||||
auth = conn.base64EncodedAuthInfo()
|
||||
})
|
||||
it('should re-generate QR & connect', async () => {
|
||||
const conn = new WAConnection()
|
||||
conn.onReadyForPhoneAuthentication = async ([ref, publicKey, clientID]) => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
console.log ('called QR ' + i + ' times')
|
||||
await createTimeout (3000)
|
||||
ref = await conn.generateNewQRCode ()
|
||||
}
|
||||
const str = ref + ',' + publicKey + ',' + clientID
|
||||
QR.generate(str, { small: true })
|
||||
}
|
||||
const user = await conn.connectSlim(null)
|
||||
assert.ok(user)
|
||||
assert.ok(user.id)
|
||||
|
||||
conn.close()
|
||||
})
|
||||
it('should reconnect', async () => {
|
||||
const conn = new WAConnection()
|
||||
const [user, chats, contacts] = await conn.connect(auth, 20*1000)
|
||||
|
||||
assert.ok(user)
|
||||
assert.ok(user.id)
|
||||
|
||||
assert.ok(chats)
|
||||
|
||||
const chatArray = chats.all()
|
||||
if (chatArray.length > 0) {
|
||||
assert.ok(chatArray[0].jid)
|
||||
assert.ok(chatArray[0].count !== null)
|
||||
if (chatArray[0].messages.length > 0) {
|
||||
assert.ok(chatArray[0].messages[0])
|
||||
}
|
||||
}
|
||||
assert.ok(contacts)
|
||||
if (contacts.length > 0) {
|
||||
assert.ok(contacts[0].jid)
|
||||
}
|
||||
await conn.logout()
|
||||
await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed')
|
||||
})
|
||||
})
|
||||
describe ('Pending Requests', async () => {
|
||||
it('should queue requests when closed', async () => {
|
||||
const conn = new WAConnection ()
|
||||
conn.pendingRequestTimeoutMs = null
|
||||
|
||||
await conn.connectSlim ()
|
||||
|
||||
await createTimeout (2000)
|
||||
|
||||
conn.close ()
|
||||
|
||||
const task: Promise<any> = new Promise ((resolve, reject) => {
|
||||
conn.query(['query', 'Status', conn.userMetaData.id])
|
||||
.then (json => resolve(json))
|
||||
.catch (error => reject ('should not have failed, got error: ' + error))
|
||||
})
|
||||
|
||||
await createTimeout (2000)
|
||||
|
||||
await conn.connectSlim ()
|
||||
const json = await task
|
||||
|
||||
assert.ok (json.status)
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import * as Crypto from 'crypto'
|
||||
import HKDF from 'futoin-hkdf'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
import Jimp from 'jimp'
|
||||
import fs from 'fs/promises'
|
||||
import fetch from 'node-fetch'
|
||||
import { exec } from 'child_process'
|
||||
import {platform, release} from 'os'
|
||||
import { BaileysError, WAChat } from './Constants'
|
||||
import UserAgent from 'user-agents'
|
||||
|
||||
import Decoder from '../Binary/Decoder'
|
||||
import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageType, WAMessage, WAMessageContent, BaileysError, WAMessageProto } from './Constants'
|
||||
|
||||
const platformMap = {
|
||||
'aix': 'AIX',
|
||||
@@ -25,10 +29,10 @@ function hashCode(s: string) {
|
||||
}
|
||||
export const waChatUniqueKey = (c: WAChat) => ((+c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
|
||||
|
||||
export function userAgentString (browser) {
|
||||
/*export function userAgentString (browser) {
|
||||
const agent = new UserAgent (new RegExp(browser))
|
||||
return agent.toString ()
|
||||
}
|
||||
}*/
|
||||
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
|
||||
export function aesDecrypt(buffer: Buffer, key: Buffer) {
|
||||
return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16))
|
||||
@@ -65,6 +69,7 @@ export function randomBytes(length) {
|
||||
return Crypto.randomBytes(length)
|
||||
}
|
||||
export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout))
|
||||
|
||||
export async function promiseTimeout<T>(ms: number, promise: Promise<T>) {
|
||||
if (!ms) return promise
|
||||
// Create a promise that rejects in <ms> milliseconds
|
||||
@@ -139,4 +144,151 @@ export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buf
|
||||
json = decoder.read(decrypted) // decode the binary message into a JSON array
|
||||
}
|
||||
return [messageTag, json, tags]
|
||||
}
|
||||
/** generates all the keys required to encrypt/decrypt & sign a media message */
|
||||
export function getMediaKeys(buffer, mediaType: MessageType) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64')
|
||||
}
|
||||
// expand using HKDF to 112 bytes, also pass in the relevant app info
|
||||
const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType])
|
||||
return {
|
||||
iv: expandedMediaKey.slice(0, 16),
|
||||
cipherKey: expandedMediaKey.slice(16, 48),
|
||||
macKey: expandedMediaKey.slice(48, 80),
|
||||
}
|
||||
}
|
||||
/** Extracts video thumb using FFMPEG */
|
||||
const extractVideoThumb = async (
|
||||
path: string,
|
||||
destPath: string,
|
||||
time: string,
|
||||
size: { width: number; height: number },
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
|
||||
exec(cmd, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
}) as Promise<void>
|
||||
|
||||
export const compressImage = async (buffer: Buffer) => {
|
||||
const jimp = await Jimp.read (buffer)
|
||||
return jimp.resize(48, 48).getBufferAsync (Jimp.MIME_JPEG)
|
||||
}
|
||||
export const generateProfilePicture = async (buffer: Buffer) => {
|
||||
const jimp = await Jimp.read (buffer)
|
||||
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
|
||||
const cropped = jimp.crop (0, 0, min, min)
|
||||
return {
|
||||
img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG),
|
||||
preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG)
|
||||
}
|
||||
}
|
||||
/** generates a thumbnail for a given media, if required */
|
||||
export async function generateThumbnail(buffer: Buffer, mediaType: MessageType, info: MessageOptions) {
|
||||
if (info.thumbnail === null || info.thumbnail) {
|
||||
// don't do anything if the thumbnail is already provided, or is null
|
||||
if (mediaType === MessageType.audio) {
|
||||
throw new Error('audio messages cannot have thumbnails')
|
||||
}
|
||||
} else if (mediaType === MessageType.image || mediaType === MessageType.sticker) {
|
||||
const buff = await compressImage (buffer)
|
||||
info.thumbnail = buff.toString('base64')
|
||||
} else if (mediaType === MessageType.video) {
|
||||
const filename = './' + randomBytes(5).toString('hex') + '.mp4'
|
||||
const imgFilename = filename + '.jpg'
|
||||
await fs.writeFile(filename, buffer)
|
||||
try {
|
||||
await extractVideoThumb(filename, imgFilename, '00:00:00', { width: 48, height: 48 })
|
||||
const buff = await fs.readFile(imgFilename)
|
||||
info.thumbnail = buff.toString('base64')
|
||||
await fs.unlink(imgFilename)
|
||||
} catch (err) {
|
||||
console.log('could not generate video thumb: ' + err)
|
||||
}
|
||||
await fs.unlink(filename)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Decode a media message (video, image, document, audio) & return decrypted buffer
|
||||
* @param message the media message you want to decode
|
||||
*/
|
||||
export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchHeaders: {[k: string]: string} = {}) {
|
||||
/*
|
||||
One can infer media type from the key in the message
|
||||
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
|
||||
*/
|
||||
const type = Object.keys(message)[0] as MessageType
|
||||
if (!type) {
|
||||
throw new BaileysError('unknown message type', message)
|
||||
}
|
||||
if (type === MessageType.text || type === MessageType.extendedText) {
|
||||
throw new BaileysError('cannot decode text message', message)
|
||||
}
|
||||
if (type === MessageType.location || type === MessageType.liveLocation) {
|
||||
return new Buffer(message[type].jpegThumbnail)
|
||||
}
|
||||
let messageContent: WAMessageProto.IVideoMessage | WAMessageProto.IImageMessage | WAMessageProto.IAudioMessage | WAMessageProto.IDocumentMessage
|
||||
if (message.productMessage) {
|
||||
const product = message.productMessage.product?.productImage
|
||||
if (!product) throw new BaileysError ('product has no image', message)
|
||||
messageContent = product
|
||||
} else {
|
||||
messageContent = message[type]
|
||||
}
|
||||
|
||||
// download the message
|
||||
const headers = { Origin: 'https://web.whatsapp.com' }
|
||||
const fetched = await fetch(messageContent.url, { headers })
|
||||
const buffer = await fetched.buffer()
|
||||
|
||||
if (buffer.length <= 10) {
|
||||
throw new BaileysError ('Empty buffer returned. File has possibly been deleted from WA servers. Run `client.updateMediaMessage()` to refresh the url', {status: 404})
|
||||
}
|
||||
|
||||
const decryptedMedia = (type: MessageType) => {
|
||||
// get the keys to decrypt the message
|
||||
const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type)
|
||||
// first part is actual file
|
||||
const file = buffer.slice(0, buffer.length - 10)
|
||||
// last 10 bytes is HMAC sign of file
|
||||
const mac = buffer.slice(buffer.length - 10, buffer.length)
|
||||
// sign IV+file & check for match with mac
|
||||
const testBuff = Buffer.concat([mediaKeys.iv, file])
|
||||
const sign = hmacSign(testBuff, mediaKeys.macKey).slice(0, 10)
|
||||
// our sign should equal the mac
|
||||
if (!sign.equals(mac)) throw new Error()
|
||||
|
||||
return aesDecryptWithIV(file, mediaKeys.cipherKey, mediaKeys.iv) // decrypt media
|
||||
}
|
||||
const allTypes = [type, ...Object.keys(HKDFInfoKeys)]
|
||||
for (let i = 0; i < allTypes.length;i++) {
|
||||
try {
|
||||
const decrypted = decryptedMedia (allTypes[i] as MessageType)
|
||||
|
||||
if (i > 0) { console.log (`decryption of ${type} media with HKDF key of ${allTypes[i]}`) }
|
||||
return decrypted
|
||||
} catch {
|
||||
if (i === 0) { console.log (`decryption of ${type} media with original HKDF key failed`) }
|
||||
}
|
||||
}
|
||||
throw new BaileysError('Decryption failed, HMAC sign does not match', {status: 400})
|
||||
}
|
||||
export function extensionForMediaMessage(message: WAMessageContent) {
|
||||
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
|
||||
const type = Object.keys(message)[0] as MessageType
|
||||
let extension: string
|
||||
if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) {
|
||||
extension = '.jpeg'
|
||||
} else {
|
||||
const messageContent = message[type] as
|
||||
| WAMessageProto.VideoMessage
|
||||
| WAMessageProto.ImageMessage
|
||||
| WAMessageProto.AudioMessage
|
||||
| WAMessageProto.DocumentMessage
|
||||
extension = getExtension (messageContent.mimetype)
|
||||
}
|
||||
return extension
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
import WAConnection from './Connect'
|
||||
export default WAConnection
|
||||
export * from './6.Groups'
|
||||
export * from './Utils'
|
||||
export * from './Constants'
|
||||
Reference in New Issue
Block a user