From 92cb5023a60807a0e1161e124d7e30e0c4aa535c Mon Sep 17 00:00:00 2001 From: Adhiraj Date: Wed, 15 Jul 2020 12:55:45 +0530 Subject: [PATCH] Pending Requests --- src/WAConnection/Base.ts | 82 ++++++++++++++++++++++--------------- src/WAConnection/Connect.ts | 42 ++++++++----------- src/WAConnection/Tests.ts | 28 ++++++++++++- 3 files changed, 93 insertions(+), 59 deletions(-) diff --git a/src/WAConnection/Base.ts b/src/WAConnection/Base.ts index 43f0dee..ac817f8 100644 --- a/src/WAConnection/Base.ts +++ b/src/WAConnection/Base.ts @@ -32,6 +32,12 @@ export default class WAConnectionBase { lastSeen: Date = null /** Log messages that are not handled, so you can debug & see what custom stuff you can implement */ 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 */ protected authInfo: AuthenticationCredentials = { clientID: null, @@ -49,14 +55,22 @@ export default class WAConnectionBase { 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: string) => this.close() - + protected pendingRequests: (() => void)[] = [] + protected reconnectLoop: () => Promise + + 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. */ setOnUnexpectedDisconnect(callback: (error: string) => void) { - this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind)) - this.unexpectedDisconnect = err => { this.close(); callback(err) } + this.unexpectedDisconnectCallback = callback } /** * 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 */ loadAuthInfoFromBrowser(authInfo: AuthenticationCredentialsBrowser | string) { - if (!authInfo) { - throw new Error('given authInfo is null') - } + if (!authInfo) throw new Error('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 @@ -203,27 +216,25 @@ export default class WAConnectionBase { /** * 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 + * @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) - } + if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag) + else tag = await 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 + * @param tags the binary tags to tell WhatsApp what the message is all about + * @param tag the tag to attach to the message + * @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 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 buff, // the actual encrypted buffer ]) - this.send(buff) // send it off + await 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 + * @param tag the tag to attach to the message * @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) - this.send(tag + ',' + JSON.stringify(json)) + await this.send(tag + ',' + JSON.stringify(json)) return tag } /** Send some message to the WhatsApp servers */ - protected send(m) { + protected async send(m) { 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.conn.send(m) + return 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 new Error("You're not even connected, you can't log out") - } - await new Promise((resolve) => { + if (!this.conn) throw new Error("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() @@ -274,6 +289,7 @@ export default class WAConnectionBase { }) this.close() } + /** Close the connection to WhatsApp Web */ close() { this.msgCount = 0 diff --git a/src/WAConnection/Connect.ts b/src/WAConnection/Connect.ts index bfd4971..d2be8c3 100644 --- a/src/WAConnection/Connect.ts +++ b/src/WAConnection/Connect.ts @@ -24,9 +24,7 @@ export default class WAConnectionConnector extends WAConnectionValidator { */ async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) { // if we're already connected, throw an error - if (this.conn) { - throw new Error('already connected or connecting') - } + if (this.conn) throw new Error('already connected or connecting') // set authentication credentials if required try { 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' }) - let promise: Promise = new Promise((resolve, reject) => { + const promise: Promise = 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) @@ -53,11 +51,12 @@ export default class WAConnectionConnector extends WAConnectionValidator { // if there was an error in the WebSocket this.conn.on('error', error => { this.close(); reject(error) }) }) - promise = Utils.promiseTimeout(timeoutMs, promise) - return promise.catch(err => { - this.close() - throw err - }) + const user = await Utils.promiseTimeout(timeoutMs, promise).catch(err => {this.close(); throw err}) + + this.pendingRequests.forEach (send => send()) // send off all pending request + this.pendingRequests = [] + + return user } /** * 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 /* 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) { - 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('?,,') - } + if (diff > refreshInterval + 5) this.unexpectedDisconnect ('lost') + else this.send ('?,,') // if its all good, send a keep alive request }, 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) + } } diff --git a/src/WAConnection/Tests.ts b/src/WAConnection/Tests.ts index ceab536..0c1f05e 100644 --- a/src/WAConnection/Tests.ts +++ b/src/WAConnection/Tests.ts @@ -70,7 +70,33 @@ describe('Test Connect', () => { } 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 = 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 () + }) }) \ No newline at end of file