diff --git a/Example/example.ts b/Example/example.ts index afbd600..683ea6f 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -17,23 +17,29 @@ async function example() { conn.logLevel = MessageLogLevel.info // set to unhandled to see what kind of stuff you can implement // loads the auth file credentials if present - if (fs.existsSync('./auth_info.json')) conn.loadAuthInfo ('./auth_info.json') - conn.on ('qr', qr => console.log (qr)) - // connect or timeout in 30 seconds - await conn.connect({ timeoutMs: 30 * 1000 }) + fs.existsSync('./auth_info.json') && conn.loadAuthInfo ('./auth_info.json') + + /* Called when contacts are received, + * do note, that this method may be called before the connection is done completely because WA is funny sometimes + * */ + conn.on ('contacts-received', contacts => console.log(`received ${Object.keys(contacts).length} contacts`)) + + // connect or timeout in 60 seconds + await conn.connect({ timeoutMs: 60 * 1000, retryOnNetworkErrors: true }) const unread = await conn.loadAllUnreadMessages () console.log('oh hello ' + conn.user.name + ' (' + conn.user.id + ')') - console.log('you have ' + conn.chats.all().length + ' chats & ' + Object.keys(conn.contacts).length + ' contacts') + console.log('you have ' + conn.chats.all().length + ' chats') console.log ('you have ' + unread.length + ' unread messages') const authInfo = conn.base64EncodedAuthInfo() // get all the auth info we need to restore this session fs.writeFileSync('./auth_info.json', JSON.stringify(authInfo, null, '\t')) // save this info to a file + /* Note: one can take this auth_info.json file and login again from any computer without having to scan the QR code, and get full access to one's WhatsApp. Despite the convenience, be careful with this file */ conn.on ('user-presence-update', json => console.log(json.id + ' presence is ' + json.type)) - conn.on ('message-update', json => { + conn.on ('message-status-update', json => { const participant = json.participant ? ' (' + json.participant + ')' : '' // participant exists when the message is from a group console.log(`${json.to}${participant} acknlowledged message(s) ${json.ids} as ${json.type}`) }) diff --git a/src/Tests/Tests.Messages.ts b/src/Tests/Tests.Messages.ts index e421e87..765706a 100644 --- a/src/Tests/Tests.Messages.ts +++ b/src/Tests/Tests.Messages.ts @@ -1,4 +1,4 @@ -import { MessageType, Mimetype, delay, promiseTimeout, WAMessage, WA_MESSAGE_STATUS_TYPE, MessageStatusUpdate } from '../WAConnection/WAConnection' +import { MessageType, Mimetype, delay, promiseTimeout, WAMessage, WA_MESSAGE_STATUS_TYPE, WAMessageStatusUpdate } from '../WAConnection/WAConnection' import {promises as fs} from 'fs' import * as assert from 'assert' import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common' @@ -79,7 +79,7 @@ WAConnectionTest('Message Events', (conn) => { resolve(update) } }) - }) as Promise + }) as Promise const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text) const m = await waitForUpdate assert.ok (m.type >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK) diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts index 63cf06f..2830a06 100644 --- a/src/WAConnection/0.Base.ts +++ b/src/WAConnection/0.Base.ts @@ -45,7 +45,6 @@ export class WAConnection extends EventEmitter { maxCachedMessages = 25 - contacts: {[k: string]: WAContact} = {} chats: KeyedDB = new KeyedDB (Utils.waChatUniqueKey, value => value.jid) /** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */ diff --git a/src/WAConnection/1.Validation.ts b/src/WAConnection/1.Validation.ts index e2ee615..7bd2585 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 { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence } from './Constants' +import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence, WAUser } from './Constants' export class WAConnection extends Base { @@ -53,14 +53,16 @@ export class WAConnection extends Base { // otherwise just chain the promise further return json }) - .then(async json => { - this.validateNewConnection(json[1]) // validate the connection + .then(json => { + this.user = this.validateNewConnection(json[1]) // validate the connection this.log('validated connection successfully', MessageLogLevel.info) this.sendPostConnectQueries () - this.lastSeen = new Date() // set last seen to right now }) + // load profile picture + .then (() => this.query({ json: ['query', 'ProfilePicThumb', this.user.id], waitForOpen: false, expect200: false })) + .then (response => this.user.imgUrl = response?.eurl || '') } /** * Send the same queries WA Web sends after connect @@ -88,25 +90,24 @@ export class WAConnection extends Base { * @param {object} json */ private validateNewConnection(json) { - const onValidationSuccess = () => { - // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone - this.user = { - id: Utils.whatsappID(json.wid), - name: json.pushname, - phone: json.phone, - imgUrl: null - } - return this.user - } + // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone + const onValidationSuccess = () => ({ + id: Utils.whatsappID(json.wid), + name: json.pushname, + phone: json.phone, + imgUrl: null + }) as WAUser if (!json.secret) { // if we didn't get a secret, we don't need it, we're validated return onValidationSuccess() } + const secret = Buffer.from(json.secret, 'base64') if (secret.length !== 144) { throw new Error ('incorrect secret length received: ' + secret.length) } + // generate shared key from our private key & the secret shared by the server const sharedKey = Curve.sharedKey(this.curveKeys.private, secret.slice(0, 32)) // expand the key to 80 bytes using HKDF @@ -118,28 +119,28 @@ export class WAConnection extends Base { const hmac = Utils.hmacSign(hmacValidationMessage, hmacValidationKey) - if (hmac.equals(secret.slice(32, 64))) { - // computed HMAC should equal secret[32:64] - // expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp - // they are encrypted using key: expandedKey[0:32] - const encryptedAESKeys = Buffer.concat([ - expandedKey.slice(64, expandedKey.length), - secret.slice(64, secret.length), - ]) - const decryptedKeys = Utils.aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32)) - // set the credentials - this.authInfo = { - encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages - macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages - clientToken: json.clientToken, - serverToken: json.serverToken, - clientID: this.authInfo.clientID, - } - return onValidationSuccess() - } else { + if (!hmac.equals(secret.slice(32, 64))) { // if the checksums didn't match throw new BaileysError ('HMAC validation failed', json) } + + // computed HMAC should equal secret[32:64] + // expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp + // they are encrypted using key: expandedKey[0:32] + const encryptedAESKeys = Buffer.concat([ + expandedKey.slice(64, expandedKey.length), + secret.slice(64, secret.length), + ]) + const decryptedKeys = Utils.aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32)) + // set the credentials + this.authInfo = { + encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages + macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages + clientToken: json.clientToken, + serverToken: json.serverToken, + clientID: this.authInfo.clientID, + } + return onValidationSuccess() } /** * When logging back in (restoring a previously closed session), WhatsApp may challenge one to check if one still has the encryption keys diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts index 79112fa..087db26 100644 --- a/src/WAConnection/3.Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -47,10 +47,9 @@ export class WAConnection extends Base { this.phoneConnected = true this.state = 'open' - this.user.imgUrl = await this.getProfilePicture (this.user.id).catch (err => '') - this.registerPhoneConnectionPoll () - this.emit ('open') + + this.registerPhoneConnectionPoll () this.releasePendingRequests () this.log ('opened connection to WhatsApp Web', MessageLogLevel.info) @@ -64,21 +63,14 @@ export class WAConnection extends Base { throw error } } - /** Get the URL to download the profile picture of a person/group */ - async getProfilePicture(jid: string | null) { - const response = await this.query({ json: ['query', 'ProfilePicThumb', jid || this.user.id] }) - return response.eurl as string - } /** * Sets up callbacks to receive chats, contacts & messages. * Must be called immediately after connect * @returns [chats, contacts] */ protected async receiveChatsAndContacts(timeoutMs: number = null, stopAfterMostRecentMessage: boolean=false) { - this.contacts = {} this.chats.clear () - let receivedContacts = false let receivedMessages = false let resolveTask: () => void @@ -90,12 +82,7 @@ export class WAConnection extends Base { this.deregisterCallback(['action', 'add:unread']) } this.deregisterCallback(['response', 'type:chat']) - this.deregisterCallback(['response', 'type:contacts']) - } - const checkForResolution = () => { - if (receivedContacts && receivedMessages) resolveTask () - } - + } // wait for messages to load const chatUpdate = json => { receivedMessages = true @@ -110,7 +97,7 @@ export class WAConnection extends Base { }) } // if received contacts before messages - if (isLast && receivedContacts) checkForResolution () + if (isLast) resolveTask () } // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages @@ -121,10 +108,9 @@ export class WAConnection extends Base { } // get chats this.registerCallback(['response', 'type:chat'], json => { - if (json[1].duplicate || !json[2]) return + if (json[1].duplicate) return - json[2] - .forEach(([item, chat]: [any, WAChat]) => { + json[2]?.forEach(([item, chat]: [any, WAChat]) => { if (!chat) { this.log (`unexpectedly got null chat: ${item}, ${chat}`, MessageLogLevel.info) return @@ -140,23 +126,14 @@ export class WAConnection extends Base { this.chats.insert (chat) // chats data (log json to see what it looks like) }) - this.log ('received chats list', MessageLogLevel.info) + this.log (`received ${this.chats.all().length} chats`, MessageLogLevel.info) + // if there are no chats + if (this.chats.all().length === 0) { + receivedMessages = true + resolveTask () + } }) - // get contacts - this.registerCallback(['response', 'type:contacts'], json => { - if (json[1].duplicate) return - receivedContacts = true - - json[2].forEach(([type, contact]: ['user', WAContact]) => { - if (!contact) return this.log (`unexpectedly got null contact: ${type}, ${contact}`, MessageLogLevel.info) - - contact.jid = Utils.whatsappID (contact.jid) - this.contacts[contact.jid] = contact - }) - this.log ('received contacts list', MessageLogLevel.info) - checkForResolution () - }) // wait for the chats & contacts to load await Utils.promiseTimeout (timeoutMs, (resolve, reject) => { resolveTask = resolve @@ -167,13 +144,6 @@ export class WAConnection extends Base { this.on ('close', rejectTask) }) .finally (deregisterCallbacks) - - this.chats - .all () - .forEach (chat => { - const respectiveContact = this.contacts[chat.jid] - chat.name = respectiveContact?.name || respectiveContact?.notify || chat.name - }) } private releasePendingRequests () { this.pendingRequests.forEach (({resolve}) => resolve()) // send off all pending request diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts index d99a32a..e6b3b6c 100644 --- a/src/WAConnection/4.Events.ts +++ b/src/WAConnection/4.Events.ts @@ -1,6 +1,6 @@ import * as QR from 'qrcode-terminal' import { WAConnection as Base } from './3.Connect' -import { MessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent, DisconnectReason, WANode } from './Constants' +import { WAMessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent, DisconnectReason, WANode } from './Constants' import { whatsappID, unixTimestampSeconds, isGroupID } from './Utils' export class WAConnection extends Base { @@ -30,8 +30,7 @@ export class WAConnection extends Base { if (node) { const user = node[1] as WAContact user.jid = whatsappID(user.jid) - this.contacts[user.jid] = user - + const chat = this.chats.get (user.jid) if (chat) { chat.name = user.name || user.notify @@ -104,6 +103,19 @@ export class WAConnection extends Base { this.emit ('chat-update', { jid: chat.jid, count: chat.count }) }) + // get contacts + this.registerCallback(['response', 'type:contacts'], json => { + if (json[1].duplicate || !json[2]) return + + const contacts: {[k: string]: WAContact} = {} + json[2].forEach(([type, contact]: ['user', WAContact]) => { + if (!contact) return this.log (`unexpectedly got null contact: ${type}, ${contact}`, MessageLogLevel.info) + + contact.jid = whatsappID (contact.jid) + contacts[contact.jid] = contact + }) + this.emit ('contacts-received', contacts) + }) /*// genetic chat action this.registerCallback (['Chat', 'cmd:action'], json => { const data = json[1].data as WANode @@ -118,6 +130,11 @@ export class WAConnection extends Base { this.on ('qr', qr => QR.generate(qr, { small: true })) } + /** Get the URL to download the profile picture of a person/group */ + async getProfilePicture(jid: string | null) { + const response = await this.query({ json: ['query', 'ProfilePicThumb', jid || this.user.id] }) + return response.eurl as string + } /** Set the callback for message status updates (when a message is delivered, read etc.) */ protected registerOnMessageStatusChange() { const func = json => { @@ -126,7 +143,7 @@ export class WAConnection extends Base { if (json.cmd === 'ack') ids = [json.id] - const update: MessageStatusUpdate = { + const update: WAMessageStatusUpdate = { from: json.from, to: json.to, participant: json.participant, @@ -138,7 +155,7 @@ export class WAConnection extends Base { const chat = this.chats.get( whatsappID(update.to) ) if (!chat) return - this.emit ('message-update', update) + this.emit ('message-status-update', update) this.chatUpdatedMessage (update.ids, update.type as number, chat) } this.registerCallback('Msg', func) @@ -199,14 +216,8 @@ export class WAConnection extends Base { found.messageStubType = WA_MESSAGE_STUB_TYPE.REVOKE found.message = null - const update: MessageStatusUpdate = { - from: this.user.id, - to: message.key.remoteJid, - ids: [message.key.id], - timestamp: new Date(), - type: 'delete' - } - this.emit ('message-update', update) + + this.emit ('message-update', found) } break default: @@ -294,14 +305,18 @@ export class WAConnection extends Base { on (event: 'user-presence-update', listener: (update: PresenceUpdate) => void): this /** when a user's status is updated */ on (event: 'user-status-update', listener: (update: {jid: string, status?: string}) => void): this + /** when a user receives contacts */ + on (event: 'contacts-received', listener: (contacts: {[k: string]: WAContact}) => void): this /** when a new chat is added */ on (event: 'chat-new', listener: (chat: WAChat) => void): this /** when a chat is updated (archived, deleted, pinned) */ on (event: 'chat-update', listener: (chat: Partial & { jid: string }) => void): this /** when a new message is relayed */ on (event: 'message-new', listener: (message: WAMessage) => void): this - /** when a message is updated (deleted, delivered, read, sent etc.) */ - on (event: 'message-update', listener: (message: MessageStatusUpdate) => void): this + /** when a message object itself is updated (receives its media info or is deleted) */ + on (event: 'message-update', listener: (message: WAMessage) => void): this + /** when a message's status is updated (deleted, delivered, read, sent etc.) */ + on (event: 'message-status-update', listener: (message: WAMessageStatusUpdate) => void): this /** when participants are added to a group */ on (event: 'group-participants-add', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this /** when participants are removed or leave from a group */ diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 52cd89d..9bd778e 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -287,7 +287,7 @@ export interface MessageInfo { reads: {jid: string, t: string}[] deliveries: {jid: string, t: string}[] } -export interface MessageStatusUpdate { +export interface WAMessageStatusUpdate { from: string to: string /** Which participant caused the update (only for groups) */ @@ -296,7 +296,7 @@ export interface MessageStatusUpdate { /** Message IDs read/delivered */ ids: string[] /** Status of the Message IDs */ - type: WA_MESSAGE_STATUS_TYPE | 'delete' + type: WA_MESSAGE_STATUS_TYPE } export enum GroupSettingChange { messageSend = 'announcement', @@ -338,10 +338,12 @@ export type BaileysEvent = 'connection-phone-change' | 'user-presence-update' | 'user-status-update' | + 'contacts-received' | 'chat-new' | 'chat-update' | 'message-new' | 'message-update' | + 'message-status-update' | 'group-participants-add' | 'group-participants-remove' | 'group-participants-promote' |