More accurate phone connection detection

This commit is contained in:
Adhiraj Singh
2020-10-15 16:50:52 +05:30
parent 16b138c759
commit e2d5fb1a25
10 changed files with 95 additions and 62 deletions

View File

@@ -1,4 +1,4 @@
import { WAConnection, MessageLogLevel, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection/WAConnection'
import { WAConnection, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection/WAConnection'
import * as assert from 'assert'
import {promises as fs} from 'fs'
@@ -21,7 +21,7 @@ export const WAConnectionTest = (name: string, func: (conn: WAConnection) => voi
describe(name, () => {
const conn = new WAConnection()
conn.connectOptions.maxIdleTimeMs = 30_000
conn.logLevel = MessageLogLevel.unhandled
conn.logger.level = 'debug'
before(async () => {
const file = './auth_info.json'

View File

@@ -2,7 +2,7 @@ import * as assert from 'assert'
import {WAConnection} from '../WAConnection/WAConnection'
import { AuthenticationCredentialsBase64, BaileysError, ReconnectMode, DisconnectReason } from '../WAConnection/Constants'
import { delay } from '../WAConnection/Utils'
import { assertChatDBIntegrity } from './Common'
import { assertChatDBIntegrity, testJid } from './Common'
describe('QR Generation', () => {
it('should generate QR', async () => {
@@ -73,22 +73,41 @@ describe('Test Connect', () => {
})
it ('should disconnect & reconnect phone', async () => {
const conn = new WAConnection ()
conn.logger.level = 'debug'
await conn.loadAuthInfo('./auth_info.json').connect ()
assert.equal (conn.phoneConnected, true)
try {
const waitForEvent = expect => new Promise (resolve => {
conn.on ('connection-phone-change', ({connected}) => {
assert.equal (connected, expect)
conn.removeAllListeners ('connection-phone-change')
resolve ()
if (connected === expect) {
conn.removeAllListeners ('connection-phone-change')
resolve ()
}
})
})
console.log ('disconnect your phone from the internet')
await delay (10_000)
console.log ('phone should be disconnected now, testing...')
const messagesPromise = Promise.all (
[
conn.loadMessages (testJid, 50),
conn.getStatus (testJid),
conn.getProfilePicture (testJid).catch (() => '')
]
)
await waitForEvent (false)
console.log ('reconnect your phone to the internet')
await waitForEvent (true)
console.log ('reconnected successfully')
const final = await messagesPromise
assert.ok (final)
} finally {
conn.close ()
}

View File

@@ -48,7 +48,7 @@ export class WAConnection extends EventEmitter {
waitForChats: true,
maxRetries: 5,
connectCooldownMs: 3000,
phoneResponseTime: 7500,
phoneResponseTime: 7_500,
alwaysUseTakeover: false
}
/** When to auto-reconnect */
@@ -81,6 +81,7 @@ export class WAConnection extends EventEmitter {
protected encoder = new Encoder()
protected decoder = new Decoder()
protected pendingRequests: {resolve: () => void, reject: (error) => void}[] = []
protected phoneCheckInterval = undefined
protected referenceDate = new Date () // used for generating tags
protected lastSeen: Date = null // last keep alive received
@@ -216,13 +217,17 @@ 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, timeoutMs?: number) {
async waitForMessage(tag: string, json: Object, requiresPhoneConnection: boolean, timeoutMs?: number) {
if (!this.phoneCheckInterval && requiresPhoneConnection) {
this.startPhoneCheckInterval ()
}
try {
const result = await Utils.promiseTimeout(timeoutMs,
(resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }),
)
return result as any
} finally {
requiresPhoneConnection && this.clearPhoneCheckInterval ()
delete this.callbacks[tag]
}
}
@@ -239,32 +244,56 @@ export class WAConnection extends EventEmitter {
* @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
*/
async query({json, binaryTags, tag, timeoutMs, expect200, waitForOpen, longTag}: WAQuery) {
async query(q: WAQuery) {
let {json, binaryTags, tag, timeoutMs, expect200, waitForOpen, longTag, requiresPhoneConnection} = q
requiresPhoneConnection = requiresPhoneConnection !== false
waitForOpen = waitForOpen !== false
if (waitForOpen) await this.waitForConnection()
tag = tag || this.generateMessageTag (longTag)
const promise = this.waitForMessage(tag, json, timeoutMs)
const promise = this.waitForMessage(tag, json, requiresPhoneConnection, timeoutMs)
if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag, longTag)
else tag = await this.sendJSON(json, tag, longTag)
if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag)
else tag = await this.sendJSON(json, tag)
const response = await promise
if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) {
// read here: http://getstatuscode.com/599
if (response.status === 599) {
this.unexpectedDisconnect (DisconnectReason.badSession)
const response = await this.query ({json, binaryTags, tag, timeoutMs, expect200, waitForOpen})
const response = await this.query (q)
return response
}
const message = STATUS_CODES[response.status] || 'unknown'
throw new BaileysError(
throw new BaileysError (
`Unexpected status in '${json[0] || 'generic query'}': ${STATUS_CODES[response.status]}(${response.status})`,
{query: json, message, status: response.status}
)
}
return response
}
/** interval is started when a query takes too long to respond */
protected startPhoneCheckInterval () {
// if its been a long time and we haven't heard back from WA, send a ping
this.phoneCheckInterval = setInterval (() => {
if (!this.conn) return // if disconnected, then don't do anything
this.logger.debug ('checking phone connection...')
this.sendAdminTest ()
this.phoneConnected = false
this.emit ('connection-phone-change', { connected: false })
}, this.connectOptions.phoneResponseTime)
}
protected clearPhoneCheckInterval () {
this.phoneCheckInterval && clearInterval (this.phoneCheckInterval)
this.phoneCheckInterval = undefined
}
protected async sendAdminTest () {
return this.sendJSON (['admin', 'test'])
}
/**
* Send a binary encoded message
* @param json the message to encode & send
@@ -335,9 +364,6 @@ export class WAConnection extends EventEmitter {
protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean=false) {
this.logger.info (`closed connection, reason ${reason}${isReconnecting ? ', reconnecting in a few seconds...' : ''}`)
this.qrTimeout && clearTimeout (this.qrTimeout)
this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.state = 'close'
this.phoneConnected = false
this.lastDisconnectReason = reason
@@ -358,7 +384,12 @@ export class WAConnection extends EventEmitter {
this.conn?.removeAllListeners ('open')
this.conn?.removeAllListeners ('message')
this.qrTimeout && clearTimeout (this.qrTimeout)
this.debounceTimeout && clearTimeout (this.debounceTimeout)
this.keepAliveReq && clearInterval(this.keepAliveReq)
this.clearPhoneCheckInterval ()
try {
this.conn?.close()
this.conn?.terminate()

View File

@@ -24,7 +24,8 @@ export class WAConnection extends Base {
json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true],
expect200: true,
waitForOpen: false,
longTag: true
longTag: true,
requiresPhoneConnection: false
})
if (!canLogin) {
stopDebouncedTimeout () // stop the debounced timeout for QR gen
@@ -48,11 +49,11 @@ export class WAConnection extends Base {
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 }) // wait for response with tag "s1"
let response = await this.query({ json, tag: 's1', waitForOpen: false, expect200: true, longTag: true, requiresPhoneConnection: false }) // 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', [])
response = await this.waitForMessage('s2', [], true)
}
return response
})()
@@ -64,7 +65,7 @@ export class WAConnection extends Base {
this.logger.info('validated connection successfully')
const response = await this.query({ json: ['query', 'ProfilePicThumb', this.user.jid], waitForOpen: false, expect200: false })
const response = await this.query({ json: ['query', 'ProfilePicThumb', this.user.jid], waitForOpen: false, expect200: false, requiresPhoneConnection: false })
this.user.imgUrl = response?.eurl || ''
this.sendPostConnectQueries ()
@@ -93,7 +94,8 @@ export class WAConnection extends Base {
expect200: true,
waitForOpen: false,
longTag: true,
timeoutMs: this.connectOptions.maxIdleTimeMs
timeoutMs: this.connectOptions.maxIdleTimeMs,
requiresPhoneConnection: false
})
return response.ref as string
}
@@ -212,7 +214,7 @@ export class WAConnection extends Base {
emitQR ()
if (this.connectOptions.regenerateQRIntervalMs) regenQR ()
const json = await this.waitForMessage('s1', [])
const json = await this.waitForMessage('s1', [], false)
this.qrTimeout && clearTimeout (this.qrTimeout)
this.qrTimeout = null

View File

@@ -37,7 +37,7 @@ export class WAConnection extends Base {
const willReconnect = !loggedOut && (tries <= (options?.maxRetries || 5)) && this.state === 'connecting'
const reason = loggedOut ? DisconnectReason.invalidSession : error.message
this.logger.warn (`connect attempt ${tries} failed${ willReconnect ? ', retrying...' : ''}`, error)
this.logger.warn ({ error }, `connect attempt ${tries} failed${ willReconnect ? ', retrying...' : ''}`)
if ((this.state as string) !== 'close' && !willReconnect) {
this.closeInternal (reason)
@@ -317,10 +317,6 @@ export class WAConnection extends Base {
if (this.logger.level === 'trace') {
this.logger.trace(messageTag + ', ' + JSON.stringify(json))
}
if (!this.phoneConnected && this.state === 'open') {
this.phoneConnected = true
this.emit ('connection-phone-change', { connected: true })
}
/*
Check if this is a response to a message we sent
*/
@@ -365,11 +361,18 @@ export class WAConnection extends Base {
return
}
}
if (this.state === 'open' && json[0] === 'Pong') {
if (this.phoneConnected !== json[1]) {
this.phoneConnected = json[1]
this.emit ('connection-phone-change', { connected: this.phoneConnected })
return
}
}
if (this.logger.level === 'debug') {
this.logger.debug({ unhandled: true }, messageTag + ', ' + JSON.stringify(json))
this.logger.debug({ unhandled: true }, messageTag + ',' + JSON.stringify(json))
}
} catch (error) {
this.logger.error (`encountered error in decrypting message, closing`, error)
this.logger.error ({ error }, `encountered error in decrypting message, closing`)
if (this.state === 'open') this.unexpectedDisconnect (DisconnectReason.badSession)
else this.endConnection ()
@@ -389,29 +392,6 @@ export class WAConnection extends Base {
*/
if (diff > KEEP_ALIVE_INTERVAL_MS+5000) this.unexpectedDisconnect (DisconnectReason.lost)
else if (this.conn) this.send ('?,,') // if its all good, send a keep alive request
// poll phone connection as well,
// 5000 ms for timeout
this.checkPhoneConnection (this.connectOptions.phoneResponseTime || 7500)
.then (connected => {
this.phoneConnected !== connected && this.emit ('connection-phone-change', {connected})
this.phoneConnected = connected
})
}, KEEP_ALIVE_INTERVAL_MS)
}
/**
* Check if your phone is connected
* @param timeoutMs max time for the phone to respond
*/
async checkPhoneConnection(timeoutMs = 5000) {
if (this.state !== 'open') return false
try {
const response = await this.query({json: ['admin', 'test'], timeoutMs, waitForOpen: false})
return response[1] as boolean
} catch (error) {
return false
}
}
}

View File

@@ -1,7 +1,7 @@
import * as QR from 'qrcode-terminal'
import { WAConnection as Base } from './3.Connect'
import { WAMessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, PresenceUpdate, BaileysEvent, DisconnectReason, WANode, WAOpenResult, Presence, AuthenticationCredentials } from './Constants'
import { whatsappID, unixTimestampSeconds, isGroupID, toNumber, GET_MESSAGE_ID, WA_MESSAGE_ID, waMessageKey } from './Utils'
import { whatsappID, unixTimestampSeconds, isGroupID, GET_MESSAGE_ID, WA_MESSAGE_ID, waMessageKey } from './Utils'
import KeyedDB from '@adiwajshing/keyed-db'
import { Mutex } from './Mutex'
@@ -175,7 +175,7 @@ export class WAConnection extends Base {
/** Get the URL to download the profile picture of a person/group */
@Mutex (jid => jid)
async getProfilePicture(jid: string | null) {
const response = await this.query({ json: ['query', 'ProfilePicThumb', jid || this.user.jid], expect200: true })
const response = await this.query({ json: ['query', 'ProfilePicThumb', jid || this.user.jid], expect200: true, requiresPhoneConnection: false })
return response.eurl as string
}
protected forwardStatusUpdate (update: WAMessageStatusUpdate) {

View File

@@ -13,7 +13,7 @@ import { Mutex } from './Mutex'
export class WAConnection extends Base {
/** Query whether a given number is registered on WhatsApp */
isOnWhatsApp = (jid: string) => this.query({json: ['query', 'exist', jid]}).then((m) => m.status === 200)
isOnWhatsApp = (jid: string) => this.query({json: ['query', 'exist', jid], requiresPhoneConnection: false}).then((m) => m.status === 200)
/**
* Tell someone about your presence -- online, typing, offline etc.
* @param jid the ID of the person/group who you are updating
@@ -35,7 +35,7 @@ export class WAConnection extends Base {
requestPresenceUpdate = async (jid: string) => this.query({ json: ['action', 'presence', 'subscribe', jid] })
/** Query the status of the person (see groupMetadata() for groups) */
async getStatus (jid?: string) {
const status: { status: string } = await this.query({ json: ['query', 'Status', jid || this.user.jid] })
const status: { status: string } = await this.query({ json: ['query', 'Status', jid || this.user.jid], requiresPhoneConnection: false })
return status
}
async setStatus (status: string) {
@@ -60,7 +60,7 @@ export class WAConnection extends Base {
/** Get the stories of your contacts */
async getStories() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null]
const response = await this.query({json, binaryTags: [30, WAFlag.ignore], expect200: true}) as WANode
const response = await this.query({json, binaryTags: [30, WAFlag.ignore], expect200: true }) as WANode
if (Array.isArray(response[2])) {
return response[2].map (row => (
{
@@ -78,7 +78,7 @@ export class WAConnection extends Base {
return this.query({ json, binaryTags: [5, WAFlag.ignore], expect200: true }) // this has to be an encrypted query
}
/** Query broadcast list info */
async getBroadcastListInfo(jid: string) { return this.query({json: ['query', 'contact', jid], expect200: true}) as Promise<WABroadcastListInfo> }
async getBroadcastListInfo(jid: string) { return this.query({json: ['query', 'contact', jid], expect200: true }) as Promise<WABroadcastListInfo> }
/** Delete the chat of a given ID */
async deleteChat (jid: string) {
const response = await this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as {status: number}

View File

@@ -266,7 +266,7 @@ export class WAConnection extends Base {
/** Query a string to check if it has a url, if it does, return required extended text message */
async generateLinkPreview (text: string) {
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null]
const response = await this.query ({json: query, binaryTags: [26, WAFlag.ignore], expect200: true})
const response = await this.query ({json: query, binaryTags: [26, WAFlag.ignore], expect200: true, requiresPhoneConnection: false})
if (response[1]) response[1].jpegThumbnail = response[2]
const data = response[1] as WAUrlInfo
@@ -289,7 +289,7 @@ export class WAConnection extends Base {
return this.mediaConn
}
protected async getNewMediaConn () {
const {media_conn} = await this.query({json: ['query', 'mediaConn']})
const {media_conn} = await this.query({json: ['query', 'mediaConn'], requiresPhoneConnection: false})
return media_conn as MediaConnInfo
}
}

View File

@@ -145,7 +145,7 @@ export class WAConnection extends Base {
/** Get the invite link of the given group */
async groupInviteCode(jid: string) {
const json = ['query', 'inviteCode', jid]
const response = await this.query({json, expect200: true})
const response = await this.query({json, expect200: true, requiresPhoneConnection: false})
return response.code as string
}
}

View File

@@ -56,6 +56,7 @@ export interface WAQuery {
expect200?: boolean
waitForOpen?: boolean
longTag?: boolean
requiresPhoneConnection?: boolean
}
export enum ReconnectMode {
/** does not reconnect */