Pending Requests

This commit is contained in:
Adhiraj
2020-07-15 12:55:45 +05:30
parent eb8e285bc4
commit 92cb5023a6
3 changed files with 93 additions and 59 deletions

View File

@@ -32,6 +32,12 @@ export default class WAConnectionBase {
lastSeen: Date = null lastSeen: Date = null
/** Log messages that are not handled, so you can debug & see what custom stuff you can implement */ /** Log messages that are not handled, so you can debug & see what custom stuff you can implement */
logLevel: MessageLogLevel = MessageLogLevel.none logLevel: MessageLogLevel = MessageLogLevel.none
/** Should requests be queued when the connection breaks in between; if false, then an error will be thrown */
pendingRequestTimeoutMs: number = null
/** What to do when you need the phone to authenticate the connection (generate QR code by default) */
onReadyForPhoneAuthentication = generateQRCode
protected unexpectedDisconnectCallback: (err: string) => any
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */ /** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
protected authInfo: AuthenticationCredentials = { protected authInfo: AuthenticationCredentials = {
clientID: null, clientID: null,
@@ -49,14 +55,22 @@ export default class WAConnectionBase {
protected callbacks = {} protected callbacks = {}
protected encoder = new Encoder() protected encoder = new Encoder()
protected decoder = new Decoder() protected decoder = new Decoder()
/** What to do when you need the phone to authenticate the connection (generate QR code by default) */ protected pendingRequests: (() => void)[] = []
onReadyForPhoneAuthentication = generateQRCode protected reconnectLoop: () => Promise<void>
unexpectedDisconnect = (err: string) => this.close()
constructor () {
this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind))
}
async unexpectedDisconnect (error: string) {
this.close()
if ((error === 'lost' || error === 'closed') && this.autoReconnect) {
await this.reconnectLoop ()
}
if (this.unexpectedDisconnectCallback) this.unexpectedDisconnectCallback (error)
}
/** Set the callback for unexpected disconnects including take over events, log out events etc. */ /** Set the callback for unexpected disconnects including take over events, log out events etc. */
setOnUnexpectedDisconnect(callback: (error: string) => void) { setOnUnexpectedDisconnect(callback: (error: string) => void) {
this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind)) this.unexpectedDisconnectCallback = callback
this.unexpectedDisconnect = err => { this.close(); callback(err) }
} }
/** /**
* base 64 encode the authentication credentials and return them * base 64 encode the authentication credentials and return them
@@ -98,9 +112,8 @@ export default class WAConnectionBase {
* @param authInfo the authentication credentials or path to browser credentials JSON * @param authInfo the authentication credentials or path to browser credentials JSON
*/ */
loadAuthInfoFromBrowser(authInfo: AuthenticationCredentialsBrowser | string) { loadAuthInfoFromBrowser(authInfo: AuthenticationCredentialsBrowser | string) {
if (!authInfo) { if (!authInfo) throw new Error('given authInfo is null')
throw new Error('given authInfo is null')
}
if (typeof authInfo === 'string') { if (typeof authInfo === 'string') {
this.log(`loading authentication credentials from ${authInfo}`) this.log(`loading authentication credentials from ${authInfo}`)
const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
@@ -203,27 +216,25 @@ export default class WAConnectionBase {
/** /**
* Query something from the WhatsApp servers * Query something from the WhatsApp servers
* @param json the query itself * @param json the query itself
* @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary * @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 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 * @param tag the tag to attach to the message
* recieved JSON * recieved JSON
*/ */
async query(json: any[] | WANode, binaryTags: WATag = null, timeoutMs: number = null, tag: string = null) { async query(json: any[] | WANode, binaryTags: WATag = null, timeoutMs: number = null, tag: string = null) {
if (binaryTags) { if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag)
tag = this.sendBinary(json as WANode, binaryTags, tag) else tag = await this.sendJSON(json, tag)
} else {
tag = this.sendJSON(json, tag)
}
return this.waitForMessage(tag, json, timeoutMs) return this.waitForMessage(tag, json, timeoutMs)
} }
/** /**
* Send a binary encoded message * Send a binary encoded message
* @param json the message to encode & send * @param json the message to encode & send
* @param {[number, number]} tags the binary tags to tell WhatsApp what the message is all about * @param tags the binary tags to tell WhatsApp what the message is all about
* @param {string} [tag] the tag to attach to the message * @param tag the tag to attach to the message
* @return {string} the message tag * @return the message tag
*/ */
private sendBinary(json: WANode, tags: [number, number], tag: string) { private async sendBinary(json: WANode, tags: WATag, tag: string) {
const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format 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 let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey
@@ -235,38 +246,42 @@ export default class WAConnectionBase {
sign, // the HMAC sign of the message sign, // the HMAC sign of the message
buff, // the actual encrypted buffer buff, // the actual encrypted buffer
]) ])
this.send(buff) // send it off await this.send(buff) // send it off
return tag return tag
} }
/** /**
* Send a plain JSON message to the WhatsApp servers * Send a plain JSON message to the WhatsApp servers
* @private
* @param json the message to send * @param json the message to send
* @param [tag] the tag to attach to the message * @param tag the tag to attach to the message
* @return the message tag * @return the message tag
*/ */
private sendJSON(json: any[] | WANode, tag: string = null) { private async sendJSON(json: any[] | WANode, tag: string = null) {
tag = tag || Utils.generateMessageTag(this.msgCount) tag = tag || Utils.generateMessageTag(this.msgCount)
this.send(tag + ',' + JSON.stringify(json)) await this.send(tag + ',' + JSON.stringify(json))
return tag return tag
} }
/** Send some message to the WhatsApp servers */ /** Send some message to the WhatsApp servers */
protected send(m) { protected async send(m) {
if (!this.conn) { if (!this.conn) {
throw new Error('cannot send message, disconnected from WhatsApp') const timeout = this.pendingRequestTimeoutMs
try {
const task = new Promise (resolve => this.pendingRequests.push(resolve))
await Utils.promiseTimeout (timeout, task)
} catch {
throw new Error('cannot send message, disconnected from WhatsApp')
}
} }
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
this.conn.send(m) return this.conn.send(m)
} }
/** /**
* Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request. * Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request.
* @see close() if you just want to close the connection * @see close() if you just want to close the connection
*/ */
async logout() { async logout() {
if (!this.conn) { if (!this.conn) throw new Error("You're not even connected, you can't log out")
throw new Error("You're not even connected, you can't log out")
} await new Promise(resolve => {
await new Promise((resolve) => {
this.conn.send('goodbye,["admin","Conn","disconnect"]', null, () => { this.conn.send('goodbye,["admin","Conn","disconnect"]', null, () => {
this.authInfo = null this.authInfo = null
resolve() resolve()
@@ -274,6 +289,7 @@ export default class WAConnectionBase {
}) })
this.close() this.close()
} }
/** Close the connection to WhatsApp Web */ /** Close the connection to WhatsApp Web */
close() { close() {
this.msgCount = 0 this.msgCount = 0

View File

@@ -24,9 +24,7 @@ export default class WAConnectionConnector extends WAConnectionValidator {
*/ */
async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) { async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
// if we're already connected, throw an error // if we're already connected, throw an error
if (this.conn) { if (this.conn) throw new Error('already connected or connecting')
throw new Error('already connected or connecting')
}
// set authentication credentials if required // set authentication credentials if required
try { try {
this.loadAuthInfoFromBase64(authInfo) this.loadAuthInfoFromBase64(authInfo)
@@ -34,7 +32,7 @@ export default class WAConnectionConnector extends WAConnectionValidator {
this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' }) this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' })
let promise: Promise<UserMetaData> = new Promise((resolve, reject) => { const promise: Promise<UserMetaData> = new Promise((resolve, reject) => {
this.conn.on('open', () => { this.conn.on('open', () => {
this.log('connected to WhatsApp Web, authenticating...') this.log('connected to WhatsApp Web, authenticating...')
// start sending keep alive requests (keeps the WebSocket alive & updates our last seen) // start sending keep alive requests (keeps the WebSocket alive & updates our last seen)
@@ -53,11 +51,12 @@ export default class WAConnectionConnector extends WAConnectionValidator {
// if there was an error in the WebSocket // if there was an error in the WebSocket
this.conn.on('error', error => { this.close(); reject(error) }) this.conn.on('error', error => { this.close(); reject(error) })
}) })
promise = Utils.promiseTimeout(timeoutMs, promise) const user = await Utils.promiseTimeout(timeoutMs, promise).catch(err => {this.close(); throw err})
return promise.catch(err => {
this.close() this.pendingRequests.forEach (send => send()) // send off all pending request
throw err this.pendingRequests = []
})
return user
} }
/** /**
* Sets up callbacks to receive chats, contacts & unread messages. * Sets up callbacks to receive chats, contacts & unread messages.
@@ -207,23 +206,16 @@ export default class WAConnectionConnector extends WAConnectionValidator {
const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000 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 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 it could be that the network is down
*/ */
if (diff > refreshInterval + 5) { if (diff > refreshInterval + 5) this.unexpectedDisconnect ('lost')
this.close() else this.send ('?,,') // if its all good, send a keep alive request
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) }, refreshInterval * 1000)
} }
reconnectLoop = async () => {
// attempt reconnecting if the user wants us to
this.log('network is down, reconnecting...')
return this.connectSlim(null, 25*1000).catch(this.reconnectLoop)
}
} }

View File

@@ -70,7 +70,33 @@ describe('Test Connect', () => {
} }
await conn.logout() await conn.logout()
await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed') 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 ()
})
})