From 7dc083b6e537caa807fd39afceae71d4536c83b2 Mon Sep 17 00:00:00 2001 From: Adhiraj Singh Date: Thu, 26 Nov 2020 17:08:18 +0530 Subject: [PATCH] Update init method to try login multiple times + use WA ttl for QR gen --- Example/example.ts | 1 - README.md | 5 +- src/Tests/Tests.Connect.ts | 13 ++- src/WAConnection/0.Base.ts | 25 ++---- src/WAConnection/1.Validation.ts | 139 ++++++++++++++----------------- src/WAConnection/3.Connect.ts | 17 ++-- src/WAConnection/4.Events.ts | 5 ++ src/WAConnection/Constants.ts | 11 ++- 8 files changed, 107 insertions(+), 109 deletions(-) diff --git a/Example/example.ts b/Example/example.ts index d4fccbb..a1e76de 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -18,7 +18,6 @@ async function example() { conn.logger.level = 'debug' // set to 'debug' to see what kind of stuff you can implement // attempt to reconnect at most 10 times in a row conn.connectOptions.maxRetries = 10 - conn.connectOptions.waitForChats = false conn.chatOrderingKey = waChatKey(true) // order chats such that pinned chats are on top conn.on ('credentials-updated', () => { diff --git a/README.md b/README.md index 0d6f93a..162d6f5 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,6 @@ console.log ("oh hello " + conn.user.name + "! You connected via a proxy") The entire `WAConnectOptions` struct is mentioned here with default values: ``` ts conn.connectOptions = { - /** New QR generation interval, set to null if you don't want to regenerate */ - regenerateQRIntervalMs?: 30_000, /** fails the connection if no data is received for X seconds */ maxIdleTimeMs?: 15_000, /** maximum attempts to connect */ @@ -249,6 +247,8 @@ conn.sendMessage(id, buffer, MessageType.audio, options) To note: - `id` is the WhatsApp ID of the person or group you're sending the message to. - It must be in the format ```[country code][phone number]@s.whatsapp.net```, for example ```+19999999999@s.whatsapp.net``` for people. For groups, it must be in the format ``` 123456789-123345@g.us ```. + - For broadcast lists it's `[timestamp of creation]@broadcast`. + - For stories, the ID is `status@broadcast`. - For media messages, the thumbnail can be generated automatically for images & stickers. Thumbnails for videos can also be generated automatically, though, you need to have `ffmpeg` installed on your system. - **MessageOptions**: some extra info about the message. It can have the following __optional__ values: ``` ts @@ -305,6 +305,7 @@ export enum Presence { available = 'available', // "online" composing = 'composing', // "typing..." recording = 'recording', // "recording..." + paused = 'paused' // stopped typing, back to "online" } ``` diff --git a/src/Tests/Tests.Connect.ts b/src/Tests/Tests.Connect.ts index ce10bfc..74e946d 100644 --- a/src/Tests/Tests.Connect.ts +++ b/src/Tests/Tests.Connect.ts @@ -7,20 +7,17 @@ import { assertChatDBIntegrity, makeConnection, testJid } from './Common' describe('QR Generation', () => { it('should generate QR', async () => { const conn = makeConnection () - conn.connectOptions.regenerateQRIntervalMs = 5000 + conn.connectOptions.maxRetries = 0 let calledQR = 0 conn.removeAllListeners ('qr') - conn.on ('qr', qr => calledQR += 1) + conn.on ('qr', () => calledQR += 1) await conn.connect() .then (() => assert.fail('should not have succeeded')) - .catch (error => { - assert.equal (error.message, 'timed out') - }) - assert.deepEqual (conn['pendingRequests'], []) - assert.deepEqual ( - Object.keys(conn['callbacks']).filter(key => !key.startsWith('function:')), + .catch (error => {}) + assert.deepStrictEqual ( + Object.keys(conn.eventNames()).filter(key => key.startsWith('TAG:')), [] ) assert.ok(calledQR >= 2, 'QR not called') diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts index a745d0c..d3815c9 100644 --- a/src/WAConnection/0.Base.ts +++ b/src/WAConnection/0.Base.ts @@ -41,10 +41,7 @@ export class WAConnection extends EventEmitter { /** The connection state */ state: WAConnectionState = 'close' connectOptions: WAConnectOptions = { - regenerateQRIntervalMs: 30_000, - maxIdleTimeMs: 15_000, - waitOnlyForLastMessage: false, - waitForChats: false, + maxIdleTimeMs: 60_000, maxRetries: 10, connectCooldownMs: 4000, phoneResponseTime: 15_000, @@ -84,7 +81,7 @@ export class WAConnection extends EventEmitter { protected referenceDate = new Date () // used for generating tags protected lastSeen: Date = null // last keep alive received - protected qrTimeout: NodeJS.Timeout + protected initTimeout: NodeJS.Timeout protected lastDisconnectTime: Date = null protected lastDisconnectReason: DisconnectReason @@ -92,13 +89,6 @@ export class WAConnection extends EventEmitter { protected mediaConn: MediaConnInfo protected debounceTimeout: NodeJS.Timeout - constructor () { - super () - this.setMaxListeners (20) - this.on ('CB:Cmd,type:disconnect', json => ( - this.state === 'open' && this.unexpectedDisconnect(json[1].kind || 'unknown') - )) - } /** * Connect to WhatsAppWeb * @param options the connect options @@ -132,6 +122,10 @@ export class WAConnection extends EventEmitter { macKey: this.authInfo.macKey.toString('base64'), } } + /** Can you login to WA without scanning the QR */ + canLogin () { + return !!this.authInfo?.encKey && !!this.authInfo?.macKey + } /** Clear authentication info so a new connection can be created */ clearAuthInfo () { this.authInfo = null @@ -175,7 +169,7 @@ export class WAConnection extends EventEmitter { * @param json query that was sent * @param timeoutMs timeout after which the promise will reject */ - async waitForMessage(tag: string, json: Object, requiresPhoneConnection: boolean, timeoutMs?: number) { + async waitForMessage(tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) { if (!this.phoneCheckInterval && requiresPhoneConnection) { this.startPhoneCheckInterval () } @@ -217,7 +211,7 @@ export class WAConnection extends EventEmitter { if (waitForOpen) await this.waitForConnection() tag = tag || this.generateMessageTag (longTag) - const promise = this.waitForMessage(tag, json, requiresPhoneConnection, timeoutMs) + const promise = this.waitForMessage(tag, requiresPhoneConnection, timeoutMs) if (this.logger.level === 'trace') { this.logger.trace ({ fromMe: true },`${tag},${JSON.stringify(json)}`) @@ -385,13 +379,12 @@ export class WAConnection extends EventEmitter { this.conn?.removeAllListeners ('open') this.conn?.removeAllListeners ('message') - this.qrTimeout && clearTimeout (this.qrTimeout) + this.initTimeout && clearTimeout (this.initTimeout) this.debounceTimeout && clearTimeout (this.debounceTimeout) this.keepAliveReq && clearInterval(this.keepAliveReq) this.clearPhoneCheckInterval () this.emit ('ws-close', { reason: DisconnectReason.close }) - //this.rejectPendingConnection && this.rejectPendingConnection (new Error('close')) try { this.conn?.close() diff --git a/src/WAConnection/1.Validation.ts b/src/WAConnection/1.Validation.ts index 819f7d5..5ff250d 100644 --- a/src/WAConnection/1.Validation.ts +++ b/src/WAConnection/1.Validation.ts @@ -1,7 +1,7 @@ import * as Curve from 'curve25519-js' import * as Utils from './Utils' import {WAConnection as Base} from './0.Base' -import { WAMetric, WAFlag, BaileysError, Presence, WAUser } from './Constants' +import { WAMetric, WAFlag, BaileysError, Presence, WAUser, WAInitResponse } from './Constants' export class WAConnection extends Base { @@ -12,72 +12,73 @@ export class WAConnection extends Base { if (!this.authInfo?.clientID) { this.authInfo = { clientID: Utils.generateClientID() } as any } - const canLogin = this.authInfo?.encKey && this.authInfo?.macKey + const canLogin = this.canLogin() this.referenceDate = new Date () // refresh reference date let isNewUser = false this.startDebouncedTimeout () - const initQueries = [ - (async () => { - const {ref} = await this.query({ - json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true], - expect200: true, - waitForOpen: false, - longTag: true, - requiresPhoneConnection: false, - startDebouncedTimeout: true - }) - if (!canLogin) { - this.stopDebouncedTimeout () // stop the debounced timeout for QR gen - const result = await this.generateKeysForAuth (ref) - this.startDebouncedTimeout () // restart debounced timeout - return result - } - })() - ] + const initQuery = (async () => { + const {ref, ttl} = await this.query({ + json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true], + expect200: true, + waitForOpen: false, + longTag: true, + requiresPhoneConnection: false, + startDebouncedTimeout: true + }) as WAInitResponse + + if (!canLogin) { + this.stopDebouncedTimeout () // stop the debounced timeout for QR gen + this.generateKeysForAuth (ref, ttl) + } + })(); if (canLogin) { // if we have the info to restore a closed session - initQueries.push ( - (async () => { - const json = [ - 'admin', - 'login', - this.authInfo?.clientToken, - this.authInfo?.serverToken, - this.authInfo?.clientID, - ] - if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')]) - else json.push ('takeover') - - let response = await this.query({ - json, - tag: 's1', - waitForOpen: false, - expect200: true, - longTag: true, - requiresPhoneConnection: false, - startDebouncedTimeout: true - }) // wait for response with tag "s1" - // if its a challenge request (we get it when logging in) - if (response[1]?.challenge) { - await this.respondToChallenge(response[1].challenge) - response = await this.waitForMessage('s2', [], true) - } - return response - })() - ) + const json = [ + 'admin', + 'login', + this.authInfo?.clientToken, + this.authInfo?.serverToken, + this.authInfo?.clientID, + ] + const tag = this.generateMessageTag(true) + + if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')]) + else json.push ('takeover') + // send login every 10s + const sendLoginReq = () => { + this.logger.debug('sending login request') + this.sendJSON(json, tag) + this.initTimeout = setTimeout(sendLoginReq, 10_000) + } + sendLoginReq() } + + await initQuery - const validationJSON = (await Promise.all (initQueries)).slice(-1)[0] // get the last result - const newUser = await this.validateNewConnection(validationJSON[1]) // validate the connection + // wait for response with tag "s1" + let response = await this.waitForMessage('s1', false, undefined) + this.startDebouncedTimeout() + this.initTimeout && clearTimeout (this.initTimeout) + this.initTimeout = null + + if (response.status && response.status !== 200) { + throw new BaileysError(`Unexpected error in login`, { response, status: response.status }) + } + // if its a challenge request (we get it when logging in) + if (response[1]?.challenge) { + await this.respondToChallenge(response[1].challenge) + response = await this.waitForMessage('s2', true) + } + + const newUser = await this.validateNewConnection(response[1]) // validate the connection if (newUser.jid !== this.user?.jid) { isNewUser = true // clear out old data this.chats.clear() this.contacts = {} } - this.user = newUser this.logger.info('validated connection successfully') @@ -95,7 +96,6 @@ export class WAConnection extends Base { } this.sendPostConnectQueries () - this.logger.debug('sent init queries') return { isNewUser } @@ -110,22 +110,21 @@ export class WAConnection extends Base { this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ]) this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ]) this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ]) - this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ]) + this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, WAFlag.available ]) } /** * Refresh QR Code * @returns the new ref */ - async generateNewQRCodeRef() { + async requestNewQRCodeRef() { const response = await this.query({ json: ['admin', 'Conn', 'reref'], expect200: true, waitForOpen: false, longTag: true, - timeoutMs: this.connectOptions.maxIdleTimeMs, requiresPhoneConnection: false }) - return response.ref as string + return response as WAInitResponse } /** * Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in @@ -211,41 +210,31 @@ export class WAConnection extends Base { return this.query({json, expect200: true, waitForOpen: false, startDebouncedTimeout: true}) } /** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */ - protected async generateKeysForAuth(ref: string) { + protected generateKeysForAuth(ref: string, ttl?: number) { this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32)) const publicKey = Buffer.from(this.curveKeys.public).toString('base64') - const emitQR = () => { + const qrLoop = ttl => { const qr = [ref, publicKey, this.authInfo.clientID].join(',') this.emit ('qr', qr) - } - const regenQR = () => { - this.qrTimeout = setTimeout (async () => { + this.initTimeout = setTimeout (async () => { if (this.state === 'open') return this.logger.debug ('regenerating QR') try { - const newRef = await this.generateNewQRCodeRef () + const {ref: newRef, ttl} = await this.requestNewQRCodeRef() ref = newRef - emitQR () - regenQR () + + qrLoop (ttl) } catch (error) { this.logger.warn ({ error }, `error in QR gen`) if (error.status === 429) { // too many QR requests this.emit ('ws-close', { reason: error.message }) } } - }, this.connectOptions.regenerateQRIntervalMs) + }, ttl || 20_000) // default is 20s, on the off-chance ttl is not present } - - emitQR () - if (this.connectOptions.regenerateQRIntervalMs) regenQR () - - const json = await this.waitForMessage('s1', [], false) - this.qrTimeout && clearTimeout (this.qrTimeout) - this.qrTimeout = null - - return json + qrLoop (ttl) } } diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts index 60da0af..81a41f4 100644 --- a/src/WAConnection/3.Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -11,7 +11,9 @@ export class WAConnection extends Base { /** Connect to WhatsApp Web */ async connect () { // if we're already connected, throw an error - if (this.state !== 'close') throw new Error('cannot connect when state=' + this.state) + if (this.state !== 'close') { + throw new BaileysError('cannot connect when state=' + this.state, { status: 409 }) + } const options = this.connectOptions const newConnection = !this.authInfo @@ -60,6 +62,7 @@ export class WAConnection extends Base { protected async connectInternal (options: WAConnectOptions, delayMs?: number) { const rejections: ((e?: Error) => void)[] = [] const rejectAll = (e: Error) => rejections.forEach (r => r(e)) + const rejectAllOnWSClose = ({ reason }) => rejectAll(new Error(reason)) // actual connect const connect = () => ( new Promise((resolve, reject) => { @@ -86,10 +89,12 @@ export class WAConnection extends Base { this.conn.addEventListener('message', ({data}) => this.onMessageRecieved(data as any)) this.conn.on ('open', async () => { + this.startKeepAliveRequest() this.logger.info(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`) + let waitForChats: Promise<{ hasNewChats: boolean }> // add wait for chats promise if required - if (typeof options?.waitForChats === 'undefined' ? true : options?.waitForChats) { + if (options?.waitForChats) { const {wait, cancellations} = this.receiveChatsAndContacts(this.connectOptions.waitOnlyForLastMessage) waitForChats = wait rejections.push (...cancellations) @@ -101,7 +106,7 @@ export class WAConnection extends Base { waitForChats || undefined ] ) - this.startKeepAliveRequest() + this.conn .removeAllListeners ('error') .removeAllListeners ('close') @@ -116,7 +121,7 @@ export class WAConnection extends Base { }) as Promise<{ hasNewChats?: boolean, isNewUser: boolean }> ) - this.on ('ws-close', rejectAll) + this.on ('ws-close', rejectAllOnWSClose) try { if (delayMs) { const {delay, cancel} = Utils.delayCancellable (delayMs) @@ -129,7 +134,7 @@ export class WAConnection extends Base { this.endConnection () throw error } finally { - this.off ('ws-close', rejectAll) + this.off ('ws-close', rejectAllOnWSClose) } } /** @@ -186,7 +191,7 @@ export class WAConnection extends Base { if (!json) return if (this.logger.level === 'trace') { - this.logger.trace(messageTag + ', ' + JSON.stringify(json)) + this.logger.trace(messageTag + ',' + JSON.stringify(json)) } let anyTriggered = false diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts index 207130d..1e30d2d 100644 --- a/src/WAConnection/4.Events.ts +++ b/src/WAConnection/4.Events.ts @@ -9,6 +9,11 @@ export class WAConnection extends Base { constructor () { super () + this.setMaxListeners (30) + // on disconnects + this.on ('CB:Cmd,type:disconnect', json => ( + this.state === 'open' && this.unexpectedDisconnect(json[1].kind || 'unknown') + )) // chats received this.on('CB:response,type:chat', json => { if (json[1].duplicate || !json[2]) return diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 6d6c85a..56384e3 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -21,6 +21,12 @@ export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage | export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WebMessageInfoStubType export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WebMessageInfoStatus +export type WAInitResponse = { + ref: string + ttl: number + status: 200 +} + export interface WALocationMessage { degreesLatitude: number degreesLongitude: number @@ -73,7 +79,10 @@ export type WALoadChatOptions = { loadProfilePicture?: boolean } export type WAConnectOptions = { - /** New QR generation interval, set to null if you don't want to regenerate */ + /** + * New QR generation interval, set to null if you don't want to regenerate + * @deprecated no need to set this as we use WA ttl + * */ regenerateQRIntervalMs?: number /** fails the connection if no data is received for X seconds */ maxIdleTimeMs?: number