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
// 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', () => {

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:
``` 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"
}
```

View File

@@ -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')

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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

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_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