Update init method to try login multiple times + use WA ttl for QR gen

This commit is contained in:
Adhiraj Singh
2020-11-26 17:08:18 +05:30
parent e531a71bde
commit 7dc083b6e5
8 changed files with 107 additions and 109 deletions

View File

@@ -18,7 +18,6 @@ async function example() {
conn.logger.level = 'debug' // set to 'debug' to see what kind of stuff you can implement 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 // attempt to reconnect at most 10 times in a row
conn.connectOptions.maxRetries = 10 conn.connectOptions.maxRetries = 10
conn.connectOptions.waitForChats = false
conn.chatOrderingKey = waChatKey(true) // order chats such that pinned chats are on top conn.chatOrderingKey = waChatKey(true) // order chats such that pinned chats are on top
conn.on ('credentials-updated', () => { conn.on ('credentials-updated', () => {

View File

@@ -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: The entire `WAConnectOptions` struct is mentioned here with default values:
``` ts ``` ts
conn.connectOptions = { 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 */ /** fails the connection if no data is received for X seconds */
maxIdleTimeMs?: 15_000, maxIdleTimeMs?: 15_000,
/** maximum attempts to connect */ /** maximum attempts to connect */
@@ -249,6 +247,8 @@ conn.sendMessage(id, buffer, MessageType.audio, options)
To note: To note:
- `id` is the WhatsApp ID of the person or group you're sending the message to. - `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 ```. - 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. - 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: - **MessageOptions**: some extra info about the message. It can have the following __optional__ values:
``` ts ``` ts
@@ -305,6 +305,7 @@ export enum Presence {
available = 'available', // "online" available = 'available', // "online"
composing = 'composing', // "typing..." composing = 'composing', // "typing..."
recording = 'recording', // "recording..." recording = 'recording', // "recording..."
paused = 'paused' // stopped typing, back to "online"
} }
``` ```

View File

@@ -7,20 +7,17 @@ import { assertChatDBIntegrity, makeConnection, testJid } from './Common'
describe('QR Generation', () => { describe('QR Generation', () => {
it('should generate QR', async () => { it('should generate QR', async () => {
const conn = makeConnection () const conn = makeConnection ()
conn.connectOptions.regenerateQRIntervalMs = 5000 conn.connectOptions.maxRetries = 0
let calledQR = 0 let calledQR = 0
conn.removeAllListeners ('qr') conn.removeAllListeners ('qr')
conn.on ('qr', qr => calledQR += 1) conn.on ('qr', () => calledQR += 1)
await conn.connect() await conn.connect()
.then (() => assert.fail('should not have succeeded')) .then (() => assert.fail('should not have succeeded'))
.catch (error => { .catch (error => {})
assert.equal (error.message, 'timed out') assert.deepStrictEqual (
}) Object.keys(conn.eventNames()).filter(key => key.startsWith('TAG:')),
assert.deepEqual (conn['pendingRequests'], [])
assert.deepEqual (
Object.keys(conn['callbacks']).filter(key => !key.startsWith('function:')),
[] []
) )
assert.ok(calledQR >= 2, 'QR not called') assert.ok(calledQR >= 2, 'QR not called')

View File

@@ -41,10 +41,7 @@ export class WAConnection extends EventEmitter {
/** The connection state */ /** The connection state */
state: WAConnectionState = 'close' state: WAConnectionState = 'close'
connectOptions: WAConnectOptions = { connectOptions: WAConnectOptions = {
regenerateQRIntervalMs: 30_000, maxIdleTimeMs: 60_000,
maxIdleTimeMs: 15_000,
waitOnlyForLastMessage: false,
waitForChats: false,
maxRetries: 10, maxRetries: 10,
connectCooldownMs: 4000, connectCooldownMs: 4000,
phoneResponseTime: 15_000, phoneResponseTime: 15_000,
@@ -84,7 +81,7 @@ export class WAConnection extends EventEmitter {
protected referenceDate = new Date () // used for generating tags protected referenceDate = new Date () // used for generating tags
protected lastSeen: Date = null // last keep alive received protected lastSeen: Date = null // last keep alive received
protected qrTimeout: NodeJS.Timeout protected initTimeout: NodeJS.Timeout
protected lastDisconnectTime: Date = null protected lastDisconnectTime: Date = null
protected lastDisconnectReason: DisconnectReason protected lastDisconnectReason: DisconnectReason
@@ -92,13 +89,6 @@ export class WAConnection extends EventEmitter {
protected mediaConn: MediaConnInfo protected mediaConn: MediaConnInfo
protected debounceTimeout: NodeJS.Timeout 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 * Connect to WhatsAppWeb
* @param options the connect options * @param options the connect options
@@ -132,6 +122,10 @@ export class WAConnection extends EventEmitter {
macKey: this.authInfo.macKey.toString('base64'), 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 */ /** Clear authentication info so a new connection can be created */
clearAuthInfo () { clearAuthInfo () {
this.authInfo = null this.authInfo = null
@@ -175,7 +169,7 @@ export class WAConnection extends EventEmitter {
* @param json query that was sent * @param json query that was sent
* @param timeoutMs timeout after which the promise will reject * @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) { if (!this.phoneCheckInterval && requiresPhoneConnection) {
this.startPhoneCheckInterval () this.startPhoneCheckInterval ()
} }
@@ -217,7 +211,7 @@ export class WAConnection extends EventEmitter {
if (waitForOpen) await this.waitForConnection() if (waitForOpen) await this.waitForConnection()
tag = tag || this.generateMessageTag (longTag) 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') { if (this.logger.level === 'trace') {
this.logger.trace ({ fromMe: true },`${tag},${JSON.stringify(json)}`) 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 ('open')
this.conn?.removeAllListeners ('message') this.conn?.removeAllListeners ('message')
this.qrTimeout && clearTimeout (this.qrTimeout) this.initTimeout && clearTimeout (this.initTimeout)
this.debounceTimeout && clearTimeout (this.debounceTimeout) this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.keepAliveReq && clearInterval(this.keepAliveReq) this.keepAliveReq && clearInterval(this.keepAliveReq)
this.clearPhoneCheckInterval () this.clearPhoneCheckInterval ()
this.emit ('ws-close', { reason: DisconnectReason.close }) this.emit ('ws-close', { reason: DisconnectReason.close })
//this.rejectPendingConnection && this.rejectPendingConnection (new Error('close'))
try { try {
this.conn?.close() this.conn?.close()

View File

@@ -1,7 +1,7 @@
import * as Curve from 'curve25519-js' import * as Curve from 'curve25519-js'
import * as Utils from './Utils' import * as Utils from './Utils'
import {WAConnection as Base} from './0.Base' 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 { export class WAConnection extends Base {
@@ -12,72 +12,73 @@ export class WAConnection extends Base {
if (!this.authInfo?.clientID) { if (!this.authInfo?.clientID) {
this.authInfo = { clientID: Utils.generateClientID() } as any 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 this.referenceDate = new Date () // refresh reference date
let isNewUser = false let isNewUser = false
this.startDebouncedTimeout () this.startDebouncedTimeout ()
const initQueries = [ const initQuery = (async () => {
(async () => { const {ref, ttl} = await this.query({
const {ref} = await this.query({ json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true],
json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true], expect200: true,
expect200: true, waitForOpen: false,
waitForOpen: false, longTag: true,
longTag: true, requiresPhoneConnection: false,
requiresPhoneConnection: false, startDebouncedTimeout: true
startDebouncedTimeout: true }) as WAInitResponse
})
if (!canLogin) { if (!canLogin) {
this.stopDebouncedTimeout () // stop the debounced timeout for QR gen this.stopDebouncedTimeout () // stop the debounced timeout for QR gen
const result = await this.generateKeysForAuth (ref) this.generateKeysForAuth (ref, ttl)
this.startDebouncedTimeout () // restart debounced timeout }
return result })();
}
})()
]
if (canLogin) { if (canLogin) {
// if we have the info to restore a closed session // if we have the info to restore a closed session
initQueries.push ( const json = [
(async () => { 'admin',
const json = [ 'login',
'admin', this.authInfo?.clientToken,
'login', this.authInfo?.serverToken,
this.authInfo?.clientToken, this.authInfo?.clientID,
this.authInfo?.serverToken, ]
this.authInfo?.clientID, const tag = this.generateMessageTag(true)
]
if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')]) if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')])
else json.push ('takeover') else json.push ('takeover')
// send login every 10s
let response = await this.query({ const sendLoginReq = () => {
json, this.logger.debug('sending login request')
tag: 's1', this.sendJSON(json, tag)
waitForOpen: false, this.initTimeout = setTimeout(sendLoginReq, 10_000)
expect200: true, }
longTag: true, sendLoginReq()
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
})()
)
} }
await initQuery
const validationJSON = (await Promise.all (initQueries)).slice(-1)[0] // get the last result // wait for response with tag "s1"
const newUser = await this.validateNewConnection(validationJSON[1]) // validate the connection 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) { if (newUser.jid !== this.user?.jid) {
isNewUser = true isNewUser = true
// clear out old data // clear out old data
this.chats.clear() this.chats.clear()
this.contacts = {} this.contacts = {}
} }
this.user = newUser this.user = newUser
this.logger.info('validated connection successfully') this.logger.info('validated connection successfully')
@@ -95,7 +96,6 @@ export class WAConnection extends Base {
} }
this.sendPostConnectQueries () this.sendPostConnectQueries ()
this.logger.debug('sent init queries') this.logger.debug('sent init queries')
return { isNewUser } 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: '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: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ])
this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, 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 * Refresh QR Code
* @returns the new ref * @returns the new ref
*/ */
async generateNewQRCodeRef() { async requestNewQRCodeRef() {
const response = await this.query({ const response = await this.query({
json: ['admin', 'Conn', 'reref'], json: ['admin', 'Conn', 'reref'],
expect200: true, expect200: true,
waitForOpen: false, waitForOpen: false,
longTag: true, longTag: true,
timeoutMs: this.connectOptions.maxIdleTimeMs,
requiresPhoneConnection: false 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 * 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}) 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 */ /** 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)) this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
const publicKey = Buffer.from(this.curveKeys.public).toString('base64') const publicKey = Buffer.from(this.curveKeys.public).toString('base64')
const emitQR = () => { const qrLoop = ttl => {
const qr = [ref, publicKey, this.authInfo.clientID].join(',') const qr = [ref, publicKey, this.authInfo.clientID].join(',')
this.emit ('qr', qr) this.emit ('qr', qr)
}
const regenQR = () => { this.initTimeout = setTimeout (async () => {
this.qrTimeout = setTimeout (async () => {
if (this.state === 'open') return if (this.state === 'open') return
this.logger.debug ('regenerating QR') this.logger.debug ('regenerating QR')
try { try {
const newRef = await this.generateNewQRCodeRef () const {ref: newRef, ttl} = await this.requestNewQRCodeRef()
ref = newRef ref = newRef
emitQR ()
regenQR () qrLoop (ttl)
} catch (error) { } catch (error) {
this.logger.warn ({ error }, `error in QR gen`) this.logger.warn ({ error }, `error in QR gen`)
if (error.status === 429) { // too many QR requests if (error.status === 429) { // too many QR requests
this.emit ('ws-close', { reason: error.message }) this.emit ('ws-close', { reason: error.message })
} }
} }
}, this.connectOptions.regenerateQRIntervalMs) }, ttl || 20_000) // default is 20s, on the off-chance ttl is not present
} }
qrLoop (ttl)
emitQR ()
if (this.connectOptions.regenerateQRIntervalMs) regenQR ()
const json = await this.waitForMessage('s1', [], false)
this.qrTimeout && clearTimeout (this.qrTimeout)
this.qrTimeout = null
return json
} }
} }

View File

@@ -11,7 +11,9 @@ export class WAConnection extends Base {
/** Connect to WhatsApp Web */ /** Connect to WhatsApp Web */
async connect () { async connect () {
// if we're already connected, throw an error // 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 options = this.connectOptions
const newConnection = !this.authInfo const newConnection = !this.authInfo
@@ -60,6 +62,7 @@ export class WAConnection extends Base {
protected async connectInternal (options: WAConnectOptions, delayMs?: number) { protected async connectInternal (options: WAConnectOptions, delayMs?: number) {
const rejections: ((e?: Error) => void)[] = [] const rejections: ((e?: Error) => void)[] = []
const rejectAll = (e: Error) => rejections.forEach (r => r(e)) const rejectAll = (e: Error) => rejections.forEach (r => r(e))
const rejectAllOnWSClose = ({ reason }) => rejectAll(new Error(reason))
// actual connect // actual connect
const connect = () => ( const connect = () => (
new Promise((resolve, reject) => { 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.addEventListener('message', ({data}) => this.onMessageRecieved(data as any))
this.conn.on ('open', async () => { this.conn.on ('open', async () => {
this.startKeepAliveRequest()
this.logger.info(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`) this.logger.info(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`)
let waitForChats: Promise<{ hasNewChats: boolean }> let waitForChats: Promise<{ hasNewChats: boolean }>
// add wait for chats promise if required // 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) const {wait, cancellations} = this.receiveChatsAndContacts(this.connectOptions.waitOnlyForLastMessage)
waitForChats = wait waitForChats = wait
rejections.push (...cancellations) rejections.push (...cancellations)
@@ -101,7 +106,7 @@ export class WAConnection extends Base {
waitForChats || undefined waitForChats || undefined
] ]
) )
this.startKeepAliveRequest()
this.conn this.conn
.removeAllListeners ('error') .removeAllListeners ('error')
.removeAllListeners ('close') .removeAllListeners ('close')
@@ -116,7 +121,7 @@ export class WAConnection extends Base {
}) as Promise<{ hasNewChats?: boolean, isNewUser: boolean }> }) as Promise<{ hasNewChats?: boolean, isNewUser: boolean }>
) )
this.on ('ws-close', rejectAll) this.on ('ws-close', rejectAllOnWSClose)
try { try {
if (delayMs) { if (delayMs) {
const {delay, cancel} = Utils.delayCancellable (delayMs) const {delay, cancel} = Utils.delayCancellable (delayMs)
@@ -129,7 +134,7 @@ export class WAConnection extends Base {
this.endConnection () this.endConnection ()
throw error throw error
} finally { } finally {
this.off ('ws-close', rejectAll) this.off ('ws-close', rejectAllOnWSClose)
} }
} }
/** /**
@@ -186,7 +191,7 @@ export class WAConnection extends Base {
if (!json) return if (!json) return
if (this.logger.level === 'trace') { if (this.logger.level === 'trace') {
this.logger.trace(messageTag + ', ' + JSON.stringify(json)) this.logger.trace(messageTag + ',' + JSON.stringify(json))
} }
let anyTriggered = false let anyTriggered = false

View File

@@ -9,6 +9,11 @@ export class WAConnection extends Base {
constructor () { constructor () {
super () super ()
this.setMaxListeners (30)
// on disconnects
this.on ('CB:Cmd,type:disconnect', json => (
this.state === 'open' && this.unexpectedDisconnect(json[1].kind || 'unknown')
))
// chats received // chats received
this.on('CB:response,type:chat', json => { this.on('CB:response,type:chat', json => {
if (json[1].duplicate || !json[2]) return if (json[1].duplicate || !json[2]) return

View File

@@ -21,6 +21,12 @@ export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage |
export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WebMessageInfoStubType export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WebMessageInfoStubType
export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WebMessageInfoStatus export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WebMessageInfoStatus
export type WAInitResponse = {
ref: string
ttl: number
status: 200
}
export interface WALocationMessage { export interface WALocationMessage {
degreesLatitude: number degreesLatitude: number
degreesLongitude: number degreesLongitude: number
@@ -73,7 +79,10 @@ export type WALoadChatOptions = {
loadProfilePicture?: boolean loadProfilePicture?: boolean
} }
export type WAConnectOptions = { 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 regenerateQRIntervalMs?: number
/** fails the connection if no data is received for X seconds */ /** fails the connection if no data is received for X seconds */
maxIdleTimeMs?: number maxIdleTimeMs?: number