mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Moved to src
This commit is contained in:
265
src/WAConnection/Base.ts
Normal file
265
src/WAConnection/Base.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import * as QR from 'qrcode-terminal'
|
||||
import * as fs from 'fs'
|
||||
import WS from 'ws'
|
||||
import * as Utils from './Utils'
|
||||
import Encoder from '../Binary/Encoder'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
import { AuthenticationCredentials, UserMetaData, WANode, AuthenticationCredentialsBase64, WATag, MessageLogLevel } from './Constants'
|
||||
|
||||
|
||||
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
|
||||
const generateQRCode = function ([ref, publicKey, clientID]) {
|
||||
const str = ref + ',' + publicKey + ',' + clientID
|
||||
QR.generate(str, { small: true })
|
||||
}
|
||||
|
||||
export default class WAConnectionBase {
|
||||
/** The version of WhatsApp Web we're telling the servers we are */
|
||||
version: [number, number, number] = [2, 2025, 6]
|
||||
/** The Browser we're telling the WhatsApp Web servers we are */
|
||||
browserDescription: [string, string] = ['Baileys', 'Baileys']
|
||||
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
|
||||
userMetaData: UserMetaData = { id: null, name: null, phone: null }
|
||||
/** Should reconnect automatically after an unexpected disconnect */
|
||||
autoReconnect = true
|
||||
lastSeen: Date = null
|
||||
/** Log messages that are not handled, so you can debug & see what custom stuff you can implement */
|
||||
logLevel: MessageLogLevel = MessageLogLevel.none
|
||||
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
|
||||
protected authInfo: AuthenticationCredentials = {
|
||||
clientID: null,
|
||||
serverToken: null,
|
||||
clientToken: null,
|
||||
encKey: null,
|
||||
macKey: null,
|
||||
}
|
||||
/** Curve keys to initially authenticate */
|
||||
protected curveKeys: { private: Uint8Array; public: Uint8Array }
|
||||
/** The websocket connection */
|
||||
protected conn: WS = null
|
||||
protected msgCount = 0
|
||||
protected keepAliveReq: NodeJS.Timeout
|
||||
protected callbacks = {}
|
||||
protected encoder = new Encoder()
|
||||
protected decoder = new Decoder()
|
||||
/**
|
||||
* What to do when you need the phone to authenticate the connection (generate QR code by default)
|
||||
*/
|
||||
onReadyForPhoneAuthentication = generateQRCode
|
||||
unexpectedDisconnect = (err) => this.close()
|
||||
/**
|
||||
* base 64 encode the authentication credentials and return them
|
||||
* these can then be used to login again by passing the object to the connect () function.
|
||||
* @see connect () in WhatsAppWeb.Session
|
||||
*/
|
||||
base64EncodedAuthInfo() {
|
||||
return {
|
||||
clientID: this.authInfo.clientID,
|
||||
serverToken: this.authInfo.serverToken,
|
||||
clientToken: this.authInfo.clientToken,
|
||||
encKey: this.authInfo.encKey.toString('base64'),
|
||||
macKey: this.authInfo.macKey.toString('base64'),
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load in the authentication credentials
|
||||
* @param authInfo the authentication credentials or path to auth credentials JSON
|
||||
*/
|
||||
loadAuthInfoFromBase64(authInfo: AuthenticationCredentialsBase64 | string) {
|
||||
if (!authInfo) {
|
||||
throw 'given authInfo is null'
|
||||
}
|
||||
if (typeof authInfo === 'string') {
|
||||
this.log(`loading authentication credentials from ${authInfo}`)
|
||||
const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
|
||||
authInfo = JSON.parse(file) as AuthenticationCredentialsBase64
|
||||
}
|
||||
this.authInfo = {
|
||||
clientID: authInfo.clientID,
|
||||
serverToken: authInfo.serverToken,
|
||||
clientToken: authInfo.clientToken,
|
||||
encKey: Buffer.from(authInfo.encKey, 'base64'), // decode from base64
|
||||
macKey: Buffer.from(authInfo.macKey, 'base64'), // decode from base64
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Register for a callback for a certain function, will cancel automatically after one execution
|
||||
* @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
async registerCallbackOneTime(parameters) {
|
||||
const json = await new Promise((resolve, _) => this.registerCallback(parameters, resolve))
|
||||
this.deregisterCallback(parameters)
|
||||
return json
|
||||
}
|
||||
/**
|
||||
* Register for a callback for a certain function
|
||||
* @param parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
registerCallback(parameters: [string, string?, string?] | string, callback) {
|
||||
if (typeof parameters === 'string') {
|
||||
return this.registerCallback([parameters, null, null], callback)
|
||||
}
|
||||
if (!Array.isArray(parameters)) {
|
||||
throw 'parameters (' + parameters + ') must be a string or array'
|
||||
}
|
||||
const func = 'function:' + parameters[0]
|
||||
const key = parameters[1] || ''
|
||||
const key2 = parameters[2] || ''
|
||||
if (!this.callbacks[func]) {
|
||||
this.callbacks[func] = {}
|
||||
}
|
||||
if (!this.callbacks[func][key]) {
|
||||
this.callbacks[func][key] = {}
|
||||
}
|
||||
this.callbacks[func][key][key2] = callback
|
||||
}
|
||||
/**
|
||||
* Cancel all further callback events associated with the given parameters
|
||||
* @param parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
deregisterCallback(parameters: [string, string?, string?] | string) {
|
||||
if (typeof parameters === 'string') {
|
||||
return this.deregisterCallback([parameters])
|
||||
}
|
||||
if (!Array.isArray(parameters)) {
|
||||
throw 'parameters (' + parameters + ') must be a string or array'
|
||||
}
|
||||
const func = 'function:' + parameters[0]
|
||||
const key = parameters[1] || ''
|
||||
const key2 = parameters[2] || ''
|
||||
if (this.callbacks[func] && this.callbacks[func][key] && this.callbacks[func][key][key2]) {
|
||||
delete this.callbacks[func][key][key2]
|
||||
return
|
||||
}
|
||||
this.log('WARNING: could not find ' + JSON.stringify(parameters) + ' to deregister')
|
||||
}
|
||||
/**
|
||||
* Wait for a message with a certain tag to be received
|
||||
* @param tag the message tag to await
|
||||
* @param json query that was sent
|
||||
* @param timeoutMs timeout after which the promise will reject
|
||||
*/
|
||||
async waitForMessage(tag: string, json: Object = null, timeoutMs: number = null) {
|
||||
let promise = new Promise(
|
||||
(resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }),
|
||||
)
|
||||
if (timeoutMs) {
|
||||
promise = Utils.promiseTimeout(timeoutMs, promise).catch((err) => {
|
||||
delete this.callbacks[tag]
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return promise as Promise<any>
|
||||
}
|
||||
/**
|
||||
* Query something from the WhatsApp servers and error on a non-200 status
|
||||
* @param json the query itself
|
||||
* @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary
|
||||
* @param [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout)
|
||||
* @param [tag] the tag to attach to the message
|
||||
* recieved JSON
|
||||
*/
|
||||
async queryExpecting200(
|
||||
json: any[] | WANode,
|
||||
binaryTags: WATag = null,
|
||||
timeoutMs: number = null,
|
||||
tag: string = null,
|
||||
) {
|
||||
return Utils.errorOnNon200Status(this.query(json, binaryTags, timeoutMs, tag))
|
||||
}
|
||||
/**
|
||||
* Query something from the WhatsApp servers
|
||||
* @param json the query itself
|
||||
* @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary
|
||||
* @param [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout)
|
||||
* @param [tag] the tag to attach to the message
|
||||
* recieved JSON
|
||||
*/
|
||||
async query(json: any[] | WANode, binaryTags: WATag = null, timeoutMs: number = null, tag: string = null) {
|
||||
if (binaryTags) {
|
||||
tag = this.sendBinary(json as WANode, binaryTags, tag)
|
||||
} else {
|
||||
tag = this.sendJSON(json, tag)
|
||||
}
|
||||
return this.waitForMessage(tag, json, timeoutMs)
|
||||
}
|
||||
/**
|
||||
* Send a binary encoded message
|
||||
* @param json the message to encode & send
|
||||
* @param {[number, number]} tags the binary tags to tell WhatsApp what the message is all about
|
||||
* @param {string} [tag] the tag to attach to the message
|
||||
* @return {string} the message tag
|
||||
*/
|
||||
private sendBinary(json: WANode, tags: [number, number], tag: string) {
|
||||
const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format
|
||||
|
||||
let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey
|
||||
const sign = Utils.hmacSign(buff, this.authInfo.macKey) // sign the message using HMAC and our macKey
|
||||
tag = tag || Utils.generateMessageTag()
|
||||
buff = Buffer.concat([
|
||||
Buffer.from(tag + ','), // generate & prefix the message tag
|
||||
Buffer.from(tags), // prefix some bytes that tell whatsapp what the message is about
|
||||
sign, // the HMAC sign of the message
|
||||
buff, // the actual encrypted buffer
|
||||
])
|
||||
this.send(buff) // send it off
|
||||
return tag
|
||||
}
|
||||
/**
|
||||
* Send a plain JSON message to the WhatsApp servers
|
||||
* @private
|
||||
* @param json the message to send
|
||||
* @param [tag] the tag to attach to the message
|
||||
* @return the message tag
|
||||
*/
|
||||
private sendJSON(json: any[] | WANode, tag: string = null) {
|
||||
tag = tag || Utils.generateMessageTag()
|
||||
this.send(tag + ',' + JSON.stringify(json))
|
||||
return tag
|
||||
}
|
||||
/** Send some message to the WhatsApp servers */
|
||||
protected send(m) {
|
||||
if (!this.conn) {
|
||||
throw 'cannot send message, disconnected from WhatsApp'
|
||||
}
|
||||
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
|
||||
this.conn.send(m)
|
||||
}
|
||||
/**
|
||||
* Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request.
|
||||
* @see close() if you just want to close the connection
|
||||
*/
|
||||
async logout() {
|
||||
if (!this.conn) {
|
||||
throw "You're not even connected, you can't log out"
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
this.conn.send('goodbye,["admin","Conn","disconnect"]', null, () => {
|
||||
this.authInfo = null
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
this.close()
|
||||
}
|
||||
/** Close the connection to WhatsApp Web */
|
||||
close() {
|
||||
this.msgCount = 0
|
||||
if (this.conn) {
|
||||
this.conn.close()
|
||||
this.conn = null
|
||||
}
|
||||
const keys = Object.keys(this.callbacks)
|
||||
keys.forEach((key) => {
|
||||
if (!key.includes('function:')) {
|
||||
this.callbacks[key].errCallback('connection closed')
|
||||
delete this.callbacks[key]
|
||||
}
|
||||
})
|
||||
if (this.keepAliveReq) {
|
||||
clearInterval(this.keepAliveReq)
|
||||
}
|
||||
}
|
||||
protected log(text) {
|
||||
console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`)
|
||||
}
|
||||
}
|
||||
257
src/WAConnection/Connect.ts
Normal file
257
src/WAConnection/Connect.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import WS from 'ws'
|
||||
import * as Utils from './Utils'
|
||||
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel } from './Constants'
|
||||
import WAConnectionValidator from './Validation'
|
||||
|
||||
export default class WAConnectionConnector extends WAConnectionValidator {
|
||||
/**
|
||||
* Connect to WhatsAppWeb
|
||||
* @param [authInfo] credentials or path to credentials to log back in
|
||||
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
|
||||
* @return returns [userMetaData, chats, contacts, unreadMessages]
|
||||
*/
|
||||
async connect(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
|
||||
const userInfo = await this.connectSlim(authInfo, timeoutMs)
|
||||
const chats = await this.receiveChatsAndContacts(timeoutMs)
|
||||
return [userInfo, ...chats] as [UserMetaData, WAChat[], WAContact[], WAMessage[]]
|
||||
}
|
||||
/**
|
||||
* Connect to WhatsAppWeb, resolves without waiting for chats & contacts
|
||||
* @param [authInfo] credentials to log back in
|
||||
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
|
||||
* @return [userMetaData, chats, contacts, unreadMessages]
|
||||
*/
|
||||
async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
|
||||
// if we're already connected, throw an error
|
||||
if (this.conn) {
|
||||
throw [1, 'already connected or connecting']
|
||||
}
|
||||
// set authentication credentials if required
|
||||
try {
|
||||
this.loadAuthInfoFromBase64(authInfo)
|
||||
} catch {}
|
||||
|
||||
this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' })
|
||||
|
||||
let promise: Promise<UserMetaData> = new Promise((resolve, reject) => {
|
||||
this.conn.on('open', () => {
|
||||
this.log('connected to WhatsApp Web, authenticating...')
|
||||
// start sending keep alive requests (keeps the WebSocket alive & updates our last seen)
|
||||
this.authenticate()
|
||||
.then((user) => {
|
||||
this.startKeepAliveRequest()
|
||||
resolve(user)
|
||||
})
|
||||
.catch(reject)
|
||||
})
|
||||
this.conn.on('message', (m) => this.onMessageRecieved(m)) // in WhatsAppWeb.Recv.js
|
||||
this.conn.on('error', (error) => {
|
||||
// if there was an error in the WebSocket
|
||||
this.close()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
promise = Utils.promiseTimeout(timeoutMs, promise)
|
||||
return promise.catch(err => {
|
||||
this.close()
|
||||
throw err
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Sets up callbacks to receive chats, contacts & unread messages.
|
||||
* Must be called immediately after connect
|
||||
* @returns [chats, contacts, unreadMessages]
|
||||
*/
|
||||
async receiveChatsAndContacts(timeoutMs: number = null) {
|
||||
let chats: Array<WAChat> = []
|
||||
let contacts: Array<WAContact> = []
|
||||
let unreadMessages: Array<WAMessage> = []
|
||||
let unreadMap: Record<string, number> = {}
|
||||
|
||||
let receivedContacts = false
|
||||
let receivedMessages = false
|
||||
let convoResolve
|
||||
|
||||
this.log('waiting for chats & contacts') // wait for the message with chats
|
||||
const waitForConvos = () =>
|
||||
new Promise(resolve => {
|
||||
convoResolve = () => {
|
||||
// de-register the callbacks, so that they don't get called again
|
||||
this.deregisterCallback(['action', 'add:last'])
|
||||
this.deregisterCallback(['action', 'add:before'])
|
||||
this.deregisterCallback(['action', 'add:unread'])
|
||||
resolve()
|
||||
}
|
||||
const chatUpdate = json => {
|
||||
receivedMessages = true
|
||||
const isLast = json[1].last
|
||||
json = json[2]
|
||||
if (json) {
|
||||
for (let k = json.length - 1; k >= 0; k--) {
|
||||
const message = json[k][2]
|
||||
const jid = message.key.remoteJid.replace('@s.whatsapp.net', '@c.us')
|
||||
if (!message.key.fromMe && unreadMap[jid] > 0) {
|
||||
// only forward if the message is from the sender
|
||||
unreadMessages.push(message)
|
||||
unreadMap[jid] -= 1 // reduce
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLast && receivedContacts) { // if received contacts before messages
|
||||
convoResolve ()
|
||||
}
|
||||
}
|
||||
// wait for actual messages to load, "last" is the most recent message, "before" contains prior messages
|
||||
this.registerCallback(['action', 'add:last'], chatUpdate)
|
||||
this.registerCallback(['action', 'add:before'], chatUpdate)
|
||||
this.registerCallback(['action', 'add:unread'], chatUpdate)
|
||||
})
|
||||
const waitForChats = async () => {
|
||||
const json = await this.registerCallbackOneTime(['response', 'type:chat'])
|
||||
json[2].forEach(chat => {
|
||||
chats.push(chat[1]) // chats data (log json to see what it looks like)
|
||||
// store the number of unread messages for each sender
|
||||
unreadMap[chat[1].jid] = chat[1].count
|
||||
})
|
||||
if (chats.length > 0) return waitForConvos()
|
||||
}
|
||||
const waitForContacts = async () => {
|
||||
const json = await this.registerCallbackOneTime(['response', 'type:contacts'])
|
||||
contacts = json[2].map(item => item[1])
|
||||
receivedContacts = true
|
||||
// if you receive contacts after messages
|
||||
// should probably resolve the promise
|
||||
if (receivedMessages) convoResolve()
|
||||
}
|
||||
// wait for the chats & contacts to load
|
||||
const promise = Promise.all([waitForChats(), waitForContacts()])
|
||||
await Utils.promiseTimeout (timeoutMs, promise)
|
||||
return [chats, contacts, unreadMessages] as [WAChat[], WAContact[], WAMessage[]]
|
||||
}
|
||||
private onMessageRecieved(message) {
|
||||
if (message[0] === '!') {
|
||||
// when the first character in the message is an '!', the server is updating the last seen
|
||||
const timestamp = message.slice(1, message.length)
|
||||
this.lastSeen = new Date(parseInt(timestamp))
|
||||
} else {
|
||||
const commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
|
||||
|
||||
if (commaIndex < 0) {
|
||||
// if there was no comma, then this message must be not be valid
|
||||
throw [2, 'invalid message', message]
|
||||
}
|
||||
|
||||
let data = message.slice(commaIndex + 1, message.length)
|
||||
// get the message tag.
|
||||
// If a query was done, the server will respond with the same message tag we sent the query with
|
||||
const messageTag = message.slice(0, commaIndex).toString()
|
||||
if (data.length === 0) {
|
||||
// got an empty message, usually get one after sending a query with the 128 tag
|
||||
return
|
||||
}
|
||||
|
||||
let json
|
||||
if (data[0] === '[' || data[0] === '{') {
|
||||
// if the first character is a "[", then the data must just be plain JSON array or object
|
||||
json = JSON.parse(data) // parse the JSON
|
||||
} else if (this.authInfo.macKey && this.authInfo.encKey) {
|
||||
/*
|
||||
If the data recieved was not a JSON, then it must be an encrypted message.
|
||||
Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
|
||||
*/
|
||||
|
||||
const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message
|
||||
data = data.slice(32, data.length) // the actual message
|
||||
|
||||
const computedChecksum = Utils.hmacSign(data, this.authInfo.macKey) // compute the sign of the message we recieved using our macKey
|
||||
|
||||
if (checksum.equals(computedChecksum)) {
|
||||
// the checksum the server sent, must match the one we computed for the message to be valid
|
||||
const decrypted = Utils.aesDecrypt(data, this.authInfo.encKey) // decrypt using AES
|
||||
json = this.decoder.read(decrypted) // decode the binary message into a JSON array
|
||||
} else {
|
||||
throw [7, "checksums don't match"]
|
||||
}
|
||||
} else {
|
||||
// if we recieved a message that was encrypted but we don't have the keys, then there must be an error
|
||||
throw [3, 'recieved encrypted message when auth creds not available', message]
|
||||
}
|
||||
if (this.logLevel === MessageLogLevel.all) {
|
||||
this.log(messageTag + ', ' + JSON.stringify(json))
|
||||
}
|
||||
/*
|
||||
Check if this is a response to a message we sent
|
||||
*/
|
||||
if (this.callbacks[messageTag]) {
|
||||
const q = this.callbacks[messageTag]
|
||||
q.callback(json)
|
||||
delete this.callbacks[messageTag]
|
||||
return
|
||||
}
|
||||
/*
|
||||
Check if this is a response to a message we are expecting
|
||||
*/
|
||||
if (this.callbacks['function:' + json[0]]) {
|
||||
const callbacks = this.callbacks['function:' + json[0]]
|
||||
let callbacks2
|
||||
let callback
|
||||
for (const key in json[1] || {}) {
|
||||
callbacks2 = callbacks[key + ':' + json[1][key]]
|
||||
if (callbacks2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!callbacks2) {
|
||||
for (const key in json[1] || {}) {
|
||||
callbacks2 = callbacks[key]
|
||||
if (callbacks2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!callbacks2) {
|
||||
callbacks2 = callbacks['']
|
||||
}
|
||||
if (callbacks2) {
|
||||
callback = callbacks2[json[2] && json[2][0][0]]
|
||||
if (!callback) {
|
||||
callback = callbacks2['']
|
||||
}
|
||||
}
|
||||
if (callback) {
|
||||
callback(json)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.logLevel === MessageLogLevel.unhandled) {
|
||||
this.log('[Unhandled] ' + messageTag + ', ' + JSON.stringify(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Send a keep alive request every X seconds, server updates & responds with last seen */
|
||||
private startKeepAliveRequest() {
|
||||
const refreshInterval = 20
|
||||
this.keepAliveReq = setInterval(() => {
|
||||
const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000
|
||||
/*
|
||||
check if it's been a suspicious amount of time since the server responded with our last seen
|
||||
it could be that the network is down, or the phone got unpaired from our connection
|
||||
*/
|
||||
if (diff > refreshInterval + 5) {
|
||||
this.close()
|
||||
|
||||
if (this.autoReconnect) {
|
||||
// attempt reconnecting if the user wants us to
|
||||
this.log('disconnected unexpectedly, reconnecting...')
|
||||
const reconnectLoop = () => this.connect(null, 25 * 1000).catch(reconnectLoop)
|
||||
reconnectLoop() // keep trying to connect
|
||||
} else {
|
||||
this.unexpectedDisconnect('lost connection unexpectedly')
|
||||
}
|
||||
} else {
|
||||
// if its all good, send a keep alive request
|
||||
this.send('?,,')
|
||||
}
|
||||
}, refreshInterval * 1000)
|
||||
}
|
||||
}
|
||||
78
src/WAConnection/Constants.ts
Normal file
78
src/WAConnection/Constants.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { WA } from '../Binary/Constants'
|
||||
import { proto } from '../../WAMessage/WAMessage'
|
||||
|
||||
export enum MessageLogLevel {
|
||||
none=0,
|
||||
unhandled=1,
|
||||
all=2
|
||||
}
|
||||
export interface AuthenticationCredentials {
|
||||
clientID: string
|
||||
serverToken: string
|
||||
clientToken: string
|
||||
encKey: Buffer
|
||||
macKey: Buffer
|
||||
}
|
||||
export interface AuthenticationCredentialsBase64 {
|
||||
clientID: string
|
||||
serverToken: string
|
||||
clientToken: string
|
||||
encKey: string
|
||||
macKey: string
|
||||
}
|
||||
export interface UserMetaData {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
}
|
||||
export type WANode = WA.Node
|
||||
export type WAMessage = proto.WebMessageInfo
|
||||
export type WAMessageContent = proto.IMessage
|
||||
|
||||
export interface WAGroupCreateResponse {
|
||||
status: number
|
||||
gid?: string
|
||||
participants?: { [key: string]: any }
|
||||
}
|
||||
export interface WAGroupMetadata {
|
||||
id: string
|
||||
owner: string
|
||||
subject: string
|
||||
creation: number
|
||||
participants: [{ id: string; isAdmin: boolean; isSuperAdmin: boolean }]
|
||||
}
|
||||
export interface WAGroupModification {
|
||||
status: number
|
||||
participants?: { [key: string]: any }
|
||||
}
|
||||
|
||||
export interface WAContact {
|
||||
notify?: string
|
||||
jid: string
|
||||
name?: string
|
||||
index?: string
|
||||
short?: string
|
||||
}
|
||||
export interface WAChat {
|
||||
t: string
|
||||
count: string
|
||||
spam: 'false' | 'true'
|
||||
jid: string
|
||||
modify_tag: string
|
||||
}
|
||||
export enum WAMetric {
|
||||
liveLocation = 3,
|
||||
group = 10,
|
||||
message = 16,
|
||||
queryLiveLocation = 33,
|
||||
}
|
||||
export enum WAFlag {
|
||||
ignore = 1 << 7,
|
||||
acknowledge = 1 << 6,
|
||||
available = 1 << 5,
|
||||
unavailable = 1 << 4,
|
||||
expires = 1 << 3,
|
||||
skipOffline = 1 << 2,
|
||||
}
|
||||
/** Tag used with binary queries */
|
||||
export type WATag = [WAMetric, WAFlag]
|
||||
57
src/WAConnection/Tests.ts
Normal file
57
src/WAConnection/Tests.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as assert from 'assert'
|
||||
import WAConnection from './WAConnection'
|
||||
import { AuthenticationCredentialsBase64 } from './Constants'
|
||||
|
||||
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 reconnect', async () => {
|
||||
const conn = new WAConnection()
|
||||
const [user, chats, contacts, unread] = await conn.connect(auth, 20*1000)
|
||||
|
||||
assert.ok(user)
|
||||
assert.ok(user.id)
|
||||
|
||||
assert.ok(chats)
|
||||
if (chats.length > 0) {
|
||||
assert.ok(chats[0].jid)
|
||||
assert.ok(chats[0].count)
|
||||
}
|
||||
assert.ok(contacts)
|
||||
if (contacts.length > 0) {
|
||||
assert.ok(contacts[0].jid)
|
||||
}
|
||||
assert.ok(unread)
|
||||
if (unread.length > 0) {
|
||||
assert.ok(unread[0].key)
|
||||
}
|
||||
|
||||
await conn.logout()
|
||||
|
||||
await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed')
|
||||
})
|
||||
})
|
||||
71
src/WAConnection/Utils.ts
Normal file
71
src/WAConnection/Utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as Crypto from 'crypto'
|
||||
import HKDF from 'futoin-hkdf'
|
||||
|
||||
/** 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))
|
||||
}
|
||||
/** decrypt AES 256 CBC */
|
||||
export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
||||
const aes = Crypto.createDecipheriv('aes-256-cbc', key, IV)
|
||||
return Buffer.concat([aes.update(buffer), aes.final()])
|
||||
}
|
||||
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
|
||||
export function aesEncrypt(buffer: Buffer, key: Buffer) {
|
||||
const IV = randomBytes(16)
|
||||
const aes = Crypto.createCipheriv('aes-256-cbc', key, IV)
|
||||
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
||||
}
|
||||
// encrypt AES 256 CBC with a given IV
|
||||
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
||||
const aes = Crypto.createCipheriv('aes-256-cbc', key, IV)
|
||||
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
||||
}
|
||||
// sign HMAC using SHA 256
|
||||
export function hmacSign(buffer: Buffer, key: Buffer) {
|
||||
return Crypto.createHmac('sha256', key).update(buffer).digest()
|
||||
}
|
||||
export function sha256(buffer: Buffer) {
|
||||
return Crypto.createHash('sha256').update(buffer).digest()
|
||||
}
|
||||
// HKDF key expansion
|
||||
export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
|
||||
return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' })
|
||||
}
|
||||
// generate a buffer with random bytes of the specified length
|
||||
export function randomBytes(length) {
|
||||
return Crypto.randomBytes(length)
|
||||
}
|
||||
export function promiseTimeout<T>(ms: number, promise: Promise<T>) {
|
||||
if (!ms) { return promise }
|
||||
// Create a promise that rejects in <ms> milliseconds
|
||||
const timeout = new Promise((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
clearTimeout(id)
|
||||
reject('Timed out')
|
||||
}, ms)
|
||||
})
|
||||
return Promise.race([promise, timeout]) as Promise<T>
|
||||
}
|
||||
// whatsapp requires a message tag for every message, we just use the timestamp as one
|
||||
export function generateMessageTag() {
|
||||
return new Date().getTime().toString()
|
||||
}
|
||||
// generate a random 16 byte client ID
|
||||
export function generateClientID() {
|
||||
return randomBytes(16).toString('base64')
|
||||
}
|
||||
// generate a random 10 byte ID to attach to a message
|
||||
export function generateMessageID() {
|
||||
return randomBytes(10).toString('hex').toUpperCase()
|
||||
}
|
||||
|
||||
export function errorOnNon200Status(p: Promise<any>) {
|
||||
return p.then((json) => {
|
||||
if (json.status && typeof json.status === 'number' && Math.floor(json.status / 100) !== 2) {
|
||||
throw new Error(`Unexpected status code: ${json.status}`)
|
||||
}
|
||||
return json
|
||||
})
|
||||
}
|
||||
164
src/WAConnection/Validation.ts
Normal file
164
src/WAConnection/Validation.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as Curve from 'curve25519-js'
|
||||
import * as Utils from './Utils'
|
||||
import WAConnectionBase from './Base'
|
||||
|
||||
export default class WAConnectionValidator extends WAConnectionBase {
|
||||
/** Authenticate the connection */
|
||||
protected async authenticate() {
|
||||
if (!this.authInfo.clientID) {
|
||||
// if no auth info is present, that is, a new session has to be established
|
||||
// generate a client ID
|
||||
this.authInfo = {
|
||||
clientID: Utils.generateClientID(),
|
||||
clientToken: null,
|
||||
serverToken: null,
|
||||
encKey: null,
|
||||
macKey: null,
|
||||
}
|
||||
}
|
||||
|
||||
const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true]
|
||||
return this.query(data)
|
||||
.then((json) => {
|
||||
// we're trying to establish a new connection or are trying to log in
|
||||
switch (json.status) {
|
||||
case 200: // all good and we can procede to generate a QR code for new connection, or can now login given present auth info
|
||||
if (this.authInfo.encKey && this.authInfo.macKey) {
|
||||
// if we have the info to restore a closed session
|
||||
const data = [
|
||||
'admin',
|
||||
'login',
|
||||
this.authInfo.clientToken,
|
||||
this.authInfo.serverToken,
|
||||
this.authInfo.clientID,
|
||||
'takeover',
|
||||
]
|
||||
return this.query(data, null, null, 's1') // wait for response with tag "s1"
|
||||
} else {
|
||||
return this.generateKeysForAuth(json.ref)
|
||||
}
|
||||
default:
|
||||
throw [json.status, 'unknown error', json]
|
||||
}
|
||||
})
|
||||
.then((json) => {
|
||||
switch (json.status) {
|
||||
case 401: // if the phone was unpaired
|
||||
throw [json.status, 'unpaired from phone', json]
|
||||
case 429: // request to login was denied, don't know why it happens
|
||||
throw [json.status, 'request denied, try reconnecting', json]
|
||||
case 304: // request to generate a new key for a QR code was denied
|
||||
throw [json.status, 'request for new key denied', json]
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (json[1] && json[1].challenge) {
|
||||
// if its a challenge request (we get it when logging in)
|
||||
return this.respondToChallenge(json[1].challenge).then((json) => {
|
||||
if (json.status !== 200) {
|
||||
// throw an error if the challenge failed
|
||||
throw [json.status, 'unknown error', json]
|
||||
}
|
||||
return this.waitForMessage('s2', []) // otherwise wait for the validation message
|
||||
})
|
||||
} else {
|
||||
// otherwise just chain the promise further
|
||||
return json
|
||||
}
|
||||
})
|
||||
.then((json) => {
|
||||
this.validateNewConnection(json[1]) // validate the connection
|
||||
this.log('validated connection successfully')
|
||||
this.lastSeen = new Date() // set last seen to right now
|
||||
return this.userMetaData
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
|
||||
* @private
|
||||
* @param {object} json
|
||||
*/
|
||||
private validateNewConnection(json) {
|
||||
const onValidationSuccess = () => {
|
||||
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
||||
this.userMetaData = {
|
||||
id: json.wid.replace('@c.us', '@s.whatsapp.net'),
|
||||
name: json.pushname,
|
||||
phone: json.phone,
|
||||
}
|
||||
return this.userMetaData
|
||||
}
|
||||
|
||||
if (json.connected) {
|
||||
// only if we're connected
|
||||
if (!json.secret) {
|
||||
// if we didn't get a secret, we don't need it, we're validated
|
||||
return onValidationSuccess()
|
||||
}
|
||||
const secret = Buffer.from(json.secret, 'base64')
|
||||
if (secret.length !== 144) {
|
||||
throw [4, 'incorrect secret length: ' + secret.length]
|
||||
}
|
||||
// generate shared key from our private key & the secret shared by the server
|
||||
const sharedKey = Curve.sharedKey(this.curveKeys.private, secret.slice(0, 32))
|
||||
// expand the key to 80 bytes using HKDF
|
||||
const expandedKey = Utils.hkdf(sharedKey as Buffer, 80)
|
||||
|
||||
// perform HMAC validation.
|
||||
const hmacValidationKey = expandedKey.slice(32, 64)
|
||||
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
|
||||
|
||||
const hmac = Utils.hmacSign(hmacValidationMessage, hmacValidationKey)
|
||||
|
||||
if (hmac.equals(secret.slice(32, 64))) {
|
||||
// computed HMAC should equal secret[32:64]
|
||||
// expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp
|
||||
// they are encrypted using key: expandedKey[0:32]
|
||||
const encryptedAESKeys = Buffer.concat([
|
||||
expandedKey.slice(64, expandedKey.length),
|
||||
secret.slice(64, secret.length),
|
||||
])
|
||||
const decryptedKeys = Utils.aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
|
||||
// set the credentials
|
||||
this.authInfo = {
|
||||
encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages
|
||||
macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages
|
||||
clientToken: json.clientToken,
|
||||
serverToken: json.serverToken,
|
||||
clientID: this.authInfo.clientID,
|
||||
}
|
||||
return onValidationSuccess()
|
||||
} else {
|
||||
// if the checksums didn't match
|
||||
throw [5, 'HMAC validation failed']
|
||||
}
|
||||
} else {
|
||||
// if we didn't get the connected field (usually we get this message when one opens WhatsApp on their phone)
|
||||
throw [6, 'json connection failed', json]
|
||||
}
|
||||
}
|
||||
/**
|
||||
* When logging back in (restoring a previously closed session), WhatsApp may challenge one to check if one still has the encryption keys
|
||||
* WhatsApp does that by asking for us to sign a string it sends with our macKey
|
||||
*/
|
||||
protected respondToChallenge(challenge: string) {
|
||||
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
|
||||
const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey
|
||||
const data = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
|
||||
this.log('resolving login challenge')
|
||||
return this.query(data)
|
||||
}
|
||||
/**
|
||||
* When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends
|
||||
* @private
|
||||
*/
|
||||
protected generateKeysForAuth(ref: string) {
|
||||
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
|
||||
this.onReadyForPhoneAuthentication([
|
||||
ref,
|
||||
Buffer.from(this.curveKeys.public).toString('base64'),
|
||||
this.authInfo.clientID,
|
||||
])
|
||||
return this.waitForMessage('s1', [])
|
||||
}
|
||||
}
|
||||
2
src/WAConnection/WAConnection.ts
Normal file
2
src/WAConnection/WAConnection.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import WAConnection from './Connect'
|
||||
export default WAConnection
|
||||
Reference in New Issue
Block a user