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 * as assert from 'assert'
import {promises as fs} from 'fs' import {promises as fs} from 'fs'
@@ -21,7 +21,7 @@ export const WAConnectionTest = (name: string, func: (conn: WAConnection) => voi
describe(name, () => { describe(name, () => {
const conn = new WAConnection() const conn = new WAConnection()
conn.connectOptions.maxIdleTimeMs = 30_000 conn.connectOptions.maxIdleTimeMs = 30_000
conn.logLevel = MessageLogLevel.unhandled conn.logger.level = 'debug'
before(async () => { before(async () => {
const file = './auth_info.json' const file = './auth_info.json'

View File

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

View File

@@ -48,7 +48,7 @@ export class WAConnection extends EventEmitter {
waitForChats: true, waitForChats: true,
maxRetries: 5, maxRetries: 5,
connectCooldownMs: 3000, connectCooldownMs: 3000,
phoneResponseTime: 7500, phoneResponseTime: 7_500,
alwaysUseTakeover: false alwaysUseTakeover: false
} }
/** When to auto-reconnect */ /** When to auto-reconnect */
@@ -81,6 +81,7 @@ export class WAConnection extends EventEmitter {
protected encoder = new Encoder() protected encoder = new Encoder()
protected decoder = new Decoder() protected decoder = new Decoder()
protected pendingRequests: {resolve: () => void, reject: (error) => void}[] = [] protected pendingRequests: {resolve: () => void, reject: (error) => void}[] = []
protected phoneCheckInterval = undefined
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
@@ -216,13 +217,17 @@ 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, timeoutMs?: number) { async waitForMessage(tag: string, json: Object, requiresPhoneConnection: boolean, timeoutMs?: number) {
if (!this.phoneCheckInterval && requiresPhoneConnection) {
this.startPhoneCheckInterval ()
}
try { try {
const result = await Utils.promiseTimeout(timeoutMs, const result = await Utils.promiseTimeout(timeoutMs,
(resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }), (resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }),
) )
return result as any return result as any
} finally { } finally {
requiresPhoneConnection && this.clearPhoneCheckInterval ()
delete this.callbacks[tag] 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 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 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 waitForOpen = waitForOpen !== false
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, timeoutMs) const promise = this.waitForMessage(tag, json, requiresPhoneConnection, timeoutMs)
if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag, longTag) if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag)
else tag = await this.sendJSON(json, tag, longTag) else tag = await this.sendJSON(json, tag)
const response = await promise const response = await promise
if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) { if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) {
// read here: http://getstatuscode.com/599 // read here: http://getstatuscode.com/599
if (response.status === 599) { if (response.status === 599) {
this.unexpectedDisconnect (DisconnectReason.badSession) this.unexpectedDisconnect (DisconnectReason.badSession)
const response = await this.query ({json, binaryTags, tag, timeoutMs, expect200, waitForOpen}) const response = await this.query (q)
return response return response
} }
const message = STATUS_CODES[response.status] || 'unknown' 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})`, `Unexpected status in '${json[0] || 'generic query'}': ${STATUS_CODES[response.status]}(${response.status})`,
{query: json, message, status: response.status} {query: json, message, status: response.status}
) )
} }
return response 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 * Send a binary encoded message
* @param json the message to encode & send * @param json the message to encode & send
@@ -335,9 +364,6 @@ export class WAConnection extends EventEmitter {
protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean=false) { protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean=false) {
this.logger.info (`closed connection, reason ${reason}${isReconnecting ? ', reconnecting in a few seconds...' : ''}`) 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.state = 'close'
this.phoneConnected = false this.phoneConnected = false
this.lastDisconnectReason = reason this.lastDisconnectReason = reason
@@ -358,7 +384,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.debounceTimeout && clearTimeout (this.debounceTimeout)
this.keepAliveReq && clearInterval(this.keepAliveReq) this.keepAliveReq && clearInterval(this.keepAliveReq)
this.clearPhoneCheckInterval ()
try { try {
this.conn?.close() this.conn?.close()
this.conn?.terminate() 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], 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
}) })
if (!canLogin) { if (!canLogin) {
stopDebouncedTimeout () // stop the debounced timeout for QR gen 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')]) if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')])
else json.push ('takeover') 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 its a challenge request (we get it when logging in)
if (response[1]?.challenge) { if (response[1]?.challenge) {
await this.respondToChallenge(response[1].challenge) await this.respondToChallenge(response[1].challenge)
response = await this.waitForMessage('s2', []) response = await this.waitForMessage('s2', [], true)
} }
return response return response
})() })()
@@ -64,7 +65,7 @@ export class WAConnection extends Base {
this.logger.info('validated connection successfully') 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.user.imgUrl = response?.eurl || ''
this.sendPostConnectQueries () this.sendPostConnectQueries ()
@@ -93,7 +94,8 @@ export class WAConnection extends Base {
expect200: true, expect200: true,
waitForOpen: false, waitForOpen: false,
longTag: true, longTag: true,
timeoutMs: this.connectOptions.maxIdleTimeMs timeoutMs: this.connectOptions.maxIdleTimeMs,
requiresPhoneConnection: false
}) })
return response.ref as string return response.ref as string
} }
@@ -212,7 +214,7 @@ export class WAConnection extends Base {
emitQR () emitQR ()
if (this.connectOptions.regenerateQRIntervalMs) regenQR () 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 && clearTimeout (this.qrTimeout)
this.qrTimeout = null 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 willReconnect = !loggedOut && (tries <= (options?.maxRetries || 5)) && this.state === 'connecting'
const reason = loggedOut ? DisconnectReason.invalidSession : error.message 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) { if ((this.state as string) !== 'close' && !willReconnect) {
this.closeInternal (reason) this.closeInternal (reason)
@@ -317,10 +317,6 @@ export class WAConnection extends Base {
if (this.logger.level === 'trace') { if (this.logger.level === 'trace') {
this.logger.trace(messageTag + ', ' + JSON.stringify(json)) 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 Check if this is a response to a message we sent
*/ */
@@ -365,11 +361,18 @@ export class WAConnection extends Base {
return 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') { 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) { } 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) if (this.state === 'open') this.unexpectedDisconnect (DisconnectReason.badSession)
else this.endConnection () else this.endConnection ()
@@ -389,29 +392,6 @@ export class WAConnection extends Base {
*/ */
if (diff > KEEP_ALIVE_INTERVAL_MS+5000) this.unexpectedDisconnect (DisconnectReason.lost) 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 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) }, 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 * as QR from 'qrcode-terminal'
import { WAConnection as Base } from './3.Connect' 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 { 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 KeyedDB from '@adiwajshing/keyed-db'
import { Mutex } from './Mutex' 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 */ /** Get the URL to download the profile picture of a person/group */
@Mutex (jid => jid) @Mutex (jid => jid)
async getProfilePicture(jid: string | null) { 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 return response.eurl as string
} }
protected forwardStatusUpdate (update: WAMessageStatusUpdate) { protected forwardStatusUpdate (update: WAMessageStatusUpdate) {

View File

@@ -13,7 +13,7 @@ import { Mutex } from './Mutex'
export class WAConnection extends Base { export class WAConnection extends Base {
/** Query whether a given number is registered on WhatsApp */ /** 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. * Tell someone about your presence -- online, typing, offline etc.
* @param jid the ID of the person/group who you are updating * @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] }) requestPresenceUpdate = async (jid: string) => this.query({ json: ['action', 'presence', 'subscribe', jid] })
/** Query the status of the person (see groupMetadata() for groups) */ /** Query the status of the person (see groupMetadata() for groups) */
async getStatus (jid?: string) { 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 return status
} }
async setStatus (status: string) { async setStatus (status: string) {
@@ -60,7 +60,7 @@ export class WAConnection extends Base {
/** Get the stories of your contacts */ /** Get the stories of your contacts */
async getStories() { async getStories() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null] 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])) { if (Array.isArray(response[2])) {
return response[2].map (row => ( 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 return this.query({ json, binaryTags: [5, WAFlag.ignore], expect200: true }) // this has to be an encrypted query
} }
/** Query broadcast list info */ /** 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 */ /** Delete the chat of a given ID */
async deleteChat (jid: string) { async deleteChat (jid: string) {
const response = await this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as {status: number} 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 */ /** Query a string to check if it has a url, if it does, return required extended text message */
async generateLinkPreview (text: string) { async generateLinkPreview (text: string) {
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null] 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] if (response[1]) response[1].jpegThumbnail = response[2]
const data = response[1] as WAUrlInfo const data = response[1] as WAUrlInfo
@@ -289,7 +289,7 @@ export class WAConnection extends Base {
return this.mediaConn return this.mediaConn
} }
protected async getNewMediaConn () { 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 return media_conn as MediaConnInfo
} }
} }

View File

@@ -145,7 +145,7 @@ export class WAConnection extends Base {
/** Get the invite link of the given group */ /** Get the invite link of the given group */
async groupInviteCode(jid: string) { async groupInviteCode(jid: string) {
const json = ['query', 'inviteCode', jid] 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 return response.code as string
} }
} }

View File

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