diff --git a/Example/example.ts b/Example/example.ts index 155b3ff..92323ea 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -7,39 +7,44 @@ import { WALocationMessage, MessageLogLevel, WAMessageType, + ReconnectMode, } from '../src/WAConnection/WAConnection' import * as fs from 'fs' async function example() { const conn = new WAConnection() // instantiate - conn.autoReconnect = true // auto reconnect on disconnect + conn.autoReconnect = ReconnectMode.onConnectionLost // only automatically reconnect when the connection breaks conn.logLevel = MessageLogLevel.info // set to unhandled to see what kind of stuff you can implement - // connect or timeout in 20 seconds (loads the auth file credentials if present) - const [user, chats, contacts] = await conn.connect('./auth_info.json', 20 * 1000) - const unread = chats.all().flatMap (chat => chat.messages.slice(chat.messages.length-chat.count)) + // loads the auth file credentials if present + if (fs.existsSync('./auth_info.json')) conn.loadAuthInfo ('./auth_info.json') + // connect or timeout in 20 seconds + await conn.connect(20 * 1000) - console.log('oh hello ' + user.name + ' (' + user.id + ')') - console.log('you have ' + chats.all().length + ' chats & ' + contacts.length + ' contacts') + 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 ' + 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.setOnPresenceUpdate(json => console.log(json.id + ' presence is ' + json.type)) - conn.setOnMessageStatusChange(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}`) + conn.on ('user-presence-update', json => console.log(json.id + ' presence is ' + json.type)) + conn.on ('message-update', message => { + //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}`) }) // set to false to NOT relay your own sent messages - conn.setOnUnreadMessage(true, async (m) => { + conn.on('message-new', async (m) => { const messageStubType = WAMessageType[m.messageStubType] || 'MESSAGE' console.log('got notification of type: ' + messageStubType) const messageContent = m.message // if it is not a regular text or media message if (!messageContent) return + if (m.key.fromMe) { console.log('relayed my own message') return @@ -112,14 +117,9 @@ async function example() { const batterylevel = parseInt(batteryLevelStr) console.log('battery level: ' + batterylevel) }) - conn.setOnUnexpectedDisconnect(reason => { - if (reason === 'replaced') { - // uncomment to reconnect whenever the connection gets taken over from somewhere else - // await conn.connect () - } else { - console.log ('oh no got disconnected: ' + reason) - } - }) + conn.on('closed', ({reason, isReconnecting}) => ( + console.log ('oh no got disconnected: ' + reason + ', reconnecting: ' + isReconnecting) + )) } example().catch((err) => console.log(`encountered error: ${err}`)) diff --git a/README.md b/README.md index 6647ba1..ddc6918 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Baileys does not require Selenium or any other browser to be interface with WhatsApp Web, it does so directly using a **WebSocket**. Not running Selenium or Chromimum saves you like **half a gig** of ram :/ - Thank you to [@Sigalor](https://github.com/sigalor/whatsapp-web-reveng) for writing the guide to reverse engineering WhatsApp Web and thanks to [@Rhymen](https://github.com/Rhymen/go-whatsapp/) for the __go__ implementation. + Thank you to [@Sigalor](https://github.com/sigalor/whatsapp-web-reveng) for writing his observations on the workings of WhatsApp Web and thanks to [@Rhymen](https://github.com/Rhymen/go-whatsapp/) for the __go__ implementation. Baileys is type-safe, extensible and simple to use. If you require more functionality than provided, it'll super easy for you to write an extension. More on this [here](#WritingCustomFunctionality). @@ -21,7 +21,9 @@ To run the example script, download or clone the repo and then type the followin ## Install Create and cd to your NPM project directory and then in terminal, write: 1. stable: `npm install @adiwajshing/baileys` -2. stabl-ish w quicker fixes & latest features: `npm install github:adiwajshing/baileys` (major changes incoming right now) +2. stabl-ish w quicker fixes & latest features: `npm install github:adiwajshing/baileys` + +Do note, the library will most likely vary if you're using the NPM package, read that [here](https://www.npmjs.com/package/@adiwajshing/baileys) Then import in your code using: ``` ts @@ -40,16 +42,14 @@ import { WAConnection } from '@adiwajshing/baileys' async function connectToWhatsApp () { const conn = new WAConnection() - const [user, chats, contacts] = await conn.connect () - console.log ("oh hello " + user.name + " (" + user.id + ")") - console.log ("you have " + chats.length + " chats") - + + // 20 second timeout + await conn.connect (20*1000) + console.log ("oh hello " + conn.user.name + " (" + conn.user.id + ")") // every chat object has a list of most recent messages - // can use that to retreive all your pending unread messages - // the 'count' property a chat object reflects the number of unread messages - // the 'count' property is -1 if the entire thread has been marked unread - const unread = chats.all().flatMap (chat => chat.messages.slice(chat.messages.length-chat.count)) + console.log ("you have " + conn.chats.all().length + " chats") + const unread = await conn.loadAllUnreadMessages () console.log ("you have " + unread.length + " unread messages") } @@ -59,23 +59,10 @@ connectToWhatsApp () ``` If the connection is successful, you will see a QR code printed on your terminal screen, scan it with WhatsApp on your phone and you'll be logged in! + If you don't want to wait for WhatsApp to send all your chats while connecting, you can use the following function: ``` ts -import { WAConnection } from '@adiwajshing/baileys' - -async function connectToWhatsApp () { - const conn = new WAConnection() - const user = await conn.connectSlim () - console.log ("oh hello " + user.name + " (" + user.id + ")") - - conn.receiveChatsAndContacts () // wait for chats & contacts in the background - .then (([chats, contacts]) => { - console.log ("you have " + chats.all().length + " chats and " + contacts.length + " contacts") - }) -} -// run in main file -connectToWhatsApp () -.catch (err => console.log("unexpected error: " + err) ) // catch any errors +await conn.connect (20*1000, false) ``` Do note, the `chats` object returned is now a [KeyedDB](https://github.com/adiwajshing/keyed-db). This is done for the following reasons: @@ -87,100 +74,94 @@ Do note, the `chats` object returned is now a [KeyedDB](https://github.com/adiwa You obviously don't want to keep scanning the QR code every time you want to connect. -So, do the following the first time you connect: +So, do the following every time you open a new connection: ``` ts import * as fs from 'fs' const conn = new WAConnection() -conn.connectSlim() // connect first -.then (user => { - const creds = conn.base64EncodedAuthInfo () // contains all the keys you need to restore a session - fs.writeFileSync('./auth_info.json', JSON.stringify(creds, null, '\t')) // save JSON to file -}) +await conn.connect() // connect first +const creds = conn.base64EncodedAuthInfo () // contains all the keys you need to restore a session +fs.writeFileSync('./auth_info.json', JSON.stringify(creds, null, '\t')) // save JSON to file ``` Then, to restore a session: ``` ts const conn = new WAConnection() -conn.connectSlim('./auth_info.json') // will load JSON credentials from file -.then (user => { - // yay connected without scanning QR -}) - +conn.loadAuthInfo ('./auth_info.json') // will load JSON credentials from file +await conn.connect() +// yay connected without scanning QR /* Optionally, you can load the credentials yourself from somewhere - & pass in the JSON object to connectSlim () as well. + & pass in the JSON object to loadAuthInfo () as well. */ ``` If you're considering switching from a Chromium/Puppeteer based library, you can use WhatsApp Web's Browser credentials to restore sessions too: ``` ts -conn.loadAuthInfoFromBrowser ('./auth_info_browser.json') -conn.connectSlim(null, 20*1000) // use loaded credentials & timeout in 20s -.then (user => { - // yay! connected using browser keys & without scanning QR -}) +conn.loadAuthInfo ('./auth_info_browser.json') // use loaded credentials & timeout in 20s +await conn.connect() // works the same ``` -See the browser credentials type [here](/src/WAConnection/Constants.ts). +See the browser credentials type in the docs. ## QR Overriding If you want to do some custom processing with the QR code used to authenticate, you can override the following method: ``` ts -conn.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { - const str = ref + ',' + publicKey + ',' + clientID // the QR string - // Now, use 'str' to display in QR UI or send somewhere +conn.on('qr', qr => { + // Now, use the 'qr' string to display in QR UI or send somewhere } -const user = await conn.connect () +await conn.connect () ``` -If you need to regenerate the QR, you can also do so using: +The QR will auto-regenerate and will fire a new `qr` event after 30 seconds, if you don't want to regenerate or want to change the re-gen interval: ``` ts -let generateQR: async () => void // call generateQR on some timeout or error -conn.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { - generateQR = async () => { - ref = await conn.generateNewQRCode () // returns a new ref code to use for QR generation - const str = ref + ',' + publicKey + ',' + clientID // the QR string - // re-print str as QR or update in UI or send somewhere - //QR.generate(str, { small: true }) - } -} -const user = await conn.connect () +conn.regenerateQRIntervalMs = null // no QR regen +conn.regenerateQRIntervalMs = 20000 // QR regen every 20 seconds ``` + ## Handling Events -Implement the following callbacks in your code: +Baileys now uses the EventEmitter syntax for events. +They're all nicely typed up, so you shouldn't have any issues with an Intellisense editor like VS Code. -- Called when you have a pending unread message or recieve a new message - ``` ts - import { getNotificationType } from '@adiwajshing/baileys' - // set first param to `true` if you want to receive outgoing messages that may be sent from your phone - conn.setOnUnreadMessage (false, (m: WAMessage) => { - // get what type of notification it is -- message, group add notification etc. - const [notificationType, messageType] = getNotificationType(m) +``` ts + +/** when the connection has opened successfully */ +on (event: 'open', listener: () => void): this +/** when the connection is opening */ +on (event: 'connecting', listener: () => void): this +/** when the connection has closed */ +on (event: 'closed', listener: (err: {reason?: string, isReconnecting: boolean}) => void): this +/** when a new QR is generated, ready for scanning */ +on (event: 'qr', listener: (qr: string) => void): this +/** when the connection to the phone changes */ +on (event: 'connection-phone-change', listener: (state: {connected: boolean}) => void): this +/** when a user's presence is updated */ +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 new chat is added */ +on (event: 'chat-new', listener: (chat: WAChat) => void): this +/** when a chat is updated (archived, deleted, pinned, read, unread, name changed) */ +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) */ +on (event: 'message-update', listener: (message: WAMessage) => 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 */ +on (event: 'group-participants-remove', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when participants are promoted in a group */ +on (event: 'group-participants-promote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when participants are demoted in a group */ +on (event: 'group-participants-demote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this +/** when the group settings is updated */ +on (event: 'group-settings-update', listener: (update: {jid: string, restrict?: string, announce?: string, actor?: string}) => void): this +/** when the group description is updated */ +on (event: 'group-description-update', listener: (update: {jid: string, description?: string, actor?: string}) => void): this +``` - console.log("got notification of type: " + notificationType) // message, groupAdd, groupLeave - console.log("message type: " + messageType) // conversation, imageMessage, videoMessage, contactMessage etc. - }) - ``` -- Called when you recieve an update on someone's presence, they went offline or online - ``` ts - conn.setOnPresenceUpdate ((json: PresenceUpdate) => console.log(json.id + " presence is " + json.type)) - ``` -- Called when your message gets delivered or read - ``` ts - conn.setOnMessageStatusChange ((json: MessageStatusUpdate) => { - let sent = json.to - if (json.participant) // participant exists when the message is from a group - sent += " ("+json.participant+")" // mention as the one sent to - // log that they acknowledged the message - console.log(sent + " acknowledged message(s) " + json.ids + " as " + json.type + " at " + json.timestamp) - }) - ``` -- Called when the connection gets disconnected (either the server loses internet, the phone gets unpaired, or the connection is taken over from somewhere) - ``` ts - conn.setOnUnexpectedDisconnect (reason => console.log ("disconnected unexpectedly: " + reason) ) - ``` ## Sending Messages Send like, all types of messages with a single function: @@ -191,7 +172,7 @@ const id = 'abcd@s.whatsapp.net' // the WhatsApp ID // send a simple text! conn.sendMessage (id, 'oh hello there', MessageType.text) // send a location! -conn.sendMessage(id, {degreeslatitude: 24.121231, degreesLongitude: 55.1121221}, MessageType.location) +conn.sendMessage(id, {degreesLatitude: 24.121231, degreesLongitude: 55.1121221}, MessageType.location) // send a contact! const vcard = 'BEGIN:VCARD\n' // metadata of the contact card + 'VERSION:3.0\n' @@ -205,11 +186,10 @@ const buffer = fs.readFileSync("Media/ma_gif.mp4") // load some gif const options: MessageOptions = {mimetype: Mimetype.gif, caption: "hello!"} // some metadata & caption conn.sendMessage(id, buffer, MessageType.video, 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 ```. - - **Do not attach** `@c.us` for individual people IDs, It won't work. - - Please do not explicitly disable ID validation (in `MessageOptions`) because then your messages may fail for no apparent reason. - 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 @@ -218,7 +198,6 @@ To note: contextInfo: { forwardingScore: 2, isForwarded: true }, // some random context info // (can show a forwarded message with this too) timestamp: Date(), // optional, if you want to manually set the timestamp of the message - validateID: true, // if you want to validate the ID before sending the message, true by default caption: "hello there!", // (for media messages) the caption to send with the media (cannot be sent with stickers though) thumbnail: "23GD#4/==", /* (for location & media messages) has to be a base 64 encoded JPEG if you want to send a custom thumb, or set to null if you don't want to send a thumbnail. @@ -239,45 +218,44 @@ await conn.forwardMessage ('455@s.whatsapp.net', message) // WA forward the mess ``` ## Reading Messages + ``` ts const id = '1234-123@g.us' const messageID = 'AHASHH123123AHGA' // id of the message you want to read -await conn.sendReadReceipt(id, messageID) // mark as read await conn.sendReadReceipt (id) // mark all messages in chat as read +await conn.sendReadReceipt(id, messageID, 1) // mark the mentioned message as read -await conn.sendReadReceipt(id, null, 'unread') // mark the chat as unread +await conn.sendReadReceipt(id, null, -2) // mark the chat as unread ``` -- `id` is in the same format as mentioned earlier. -- The message ID is the unique identifier of the message that you are marking as read. -- On a `WAMessage`, the `messageID` can be accessed using ```messageID = message.key.id```. +The message ID is the unique identifier of the message that you are marking as read. On a `WAMessage`, the `messageID` can be accessed using ```messageID = message.key.id```. ## Update Presence ``` ts import { Presence } from '@adiwajshing/baileys' +await conn.updatePresence(id, Presence.available) -conn.updatePresence(id, Presence.available) ``` This lets the person/group with ``` id ``` know whether you're online, offline, typing etc. where ``` presence ``` can be one of the following: ``` ts export enum Presence { available = 'available', // "online" - unavailable = 'unavailable', // "offline" composing = 'composing', // "typing..." recording = 'recording', // "recording..." - paused = 'paused', // I have no clue } ``` -## Downloading Media +The presence expires after about 10 seconds. + +## Downloading Media Messages + If you want to save the media you received ``` ts -import { MessageType, extensionForMediaMessage } from '@adiwajshing/baileys' -conn.setOnUnreadMessage (false, async m => { +import { MessageType } from '@adiwajshing/baileys' +conn.on ('message-new', async m => { if (!m.message) return // if there is no text or media message - const messageType = Object.keys (m.message)[0]// get what type of message it is -- text, image, video // if the message is not a text message if (messageType !== MessageType.text && messageType !== MessageType.extendedText) { @@ -322,6 +300,13 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast ## Misc +- To load chats in a paginated manner + ``` ts + const {chats, cursor} = await conn.loadChats (25) + if (cursor) { + const moreChats = await conn.loadChats (25, cursor) // load the next 25 chats + } + ``` - To check if a given ID is on WhatsApp ``` ts const id = 'xyz@s.whatsapp.net' @@ -331,12 +316,12 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast - To query chat history on a group or with someone ``` ts // query the last 25 messages (replace 25 with the number of messages you want to query) - const messages = await conn.loadConversation ("xyz-abc@g.us", 25) + const messages = await conn.loadMessages ("xyz-abc@g.us", 25) console.log("got back " + messages.length + " messages") ``` You can also load the entire conversation history if you want ``` ts - await conn.loadEntireConversation ("xyz@c.us", message => console.log("Loaded message with ID: " + message.key.id)) + await conn.loadAllMessages ("xyz@c.us", message => console.log("Loaded message with ID: " + message.key.id)) console.log("queried all messages") // promise resolves once all messages are retreived ``` - To get the status of some person @@ -353,12 +338,12 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast ``` ts const jid = '111234567890-1594482450@g.us' // can be your own too const img = fs.readFileSync ('new-profile-picture.jpeg') // can be PNG also - await conn.updateProfilePicture (jid, newPP) + await conn.updateProfilePicture (jid, img) ``` - To get someone's presence (if they're typing, online) ``` ts // the presence update is fetched and called here - conn.setOnPresenceUpdate (json => console.log(json.id + " presence is " + json.type)) + conn.on ('user-presence-update', json => console.log(json.id + " presence is " + json.type)) await conn.requestPresenceUpdate ("xyz@c.us") // request the update ``` - To search through messages @@ -369,7 +354,6 @@ await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast const response2 = await conn.searchMessages ('so cool', '1234@c.us', 25, 1) // search in given chat ``` Of course, replace ``` xyz ``` with an actual ID. -Append ``` @s.whatsapp.net ``` for individuals & ``` @g.us ``` for groups. ## Groups - To create a group @@ -472,7 +456,7 @@ This will enable you to see all sorts of messages WhatsApp sends in the console. ``` ts conn.registerCallback (["Conn", "pushname"], json => { const pushname = json[1].pushname - conn.userMetaData.name = pushname // update on client too + conn.user.name = pushname // update on client too console.log ("Name updated: " + pushname) }) ``` @@ -483,4 +467,4 @@ A little more testing will reveal that almost all WhatsApp messages are in the f Note: except for the first parameter (in the above cases, ```"action"``` or ```"Conn"```), all the other parameters are optional. ### Note - This library is in no way affiliated with WhatsApp. Use at your own discretion. Do not spam people with this. + This library was originally a project for **CS-2362 at Ashoka University** and is in no way affiliated with WhatsApp. Use at your own discretion. Do not spam people with this. diff --git a/package.json b/package.json index d64e2ef..a159fe1 100644 --- a/package.json +++ b/package.json @@ -31,24 +31,24 @@ "url": "git@github.com:adiwajshing/baileys.git" }, "dependencies": { - "@adiwajshing/keyed-db": "^0.1.1", + "@adiwajshing/keyed-db": "^0.1.2", "curve25519-js": "0.0.4", "futoin-hkdf": "^1.3.2", "jimp": "^0.14.0", "node-fetch": "^2.6.0", - "protobufjs": "^6.9.0", + "protobufjs": "^6.10.1", "qrcode-terminal": "^0.12.0", - "ws": "^7.3.0" + "ws": "^7.3.1" }, "devDependencies": { "@types/mocha": "^7.0.2", - "@types/node": "^14.0.14", + "@types/node": "^14.0.27", "@types/ws": "^7.2.6", "assert": "^2.0.0", "dotenv": "^8.2.0", - "mocha": "^8.0.1", - "ts-node-dev": "^1.0.0-pre.49", + "mocha": "^8.1.1", + "ts-node-dev": "^1.0.0-pre.57", "typedoc": "^0.18.0", - "typescript": "^3.9.5" + "typescript": "^3.9.7" } } diff --git a/src/Tests/Common.ts b/src/Tests/Common.ts index e206e42..3323c26 100644 --- a/src/Tests/Common.ts +++ b/src/Tests/Common.ts @@ -1,4 +1,4 @@ -import { WAConnection, MessageLogLevel, MessageOptions, MessageType } from '../WAConnection/WAConnection' +import { WAConnection, MessageLogLevel, MessageOptions, MessageType, unixTimestampSeconds } from '../WAConnection/WAConnection' import * as assert from 'assert' import {promises as fs} from 'fs' @@ -7,22 +7,29 @@ export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST export async function sendAndRetreiveMessage(conn: WAConnection, content, type: MessageType, options: MessageOptions = {}) { const response = await conn.sendMessage(testJid, content, type, options) - const messages = await conn.loadConversation(testJid, 10, null, true) + const {messages} = await conn.loadMessages(testJid, 10) const message = messages.find (m => m.key.id === response.key.id) assert.ok(message) + + const chat = conn.chats.get(testJid) + + assert.ok (chat.messages.find(m => m.key.id === response.key.id)) + assert.ok (chat.t >= (unixTimestampSeconds()-5) ) return message } -export function WAConnectionTest(name: string, func: (conn: WAConnection) => void) { +export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => ( describe(name, () => { const conn = new WAConnection() conn.logLevel = MessageLogLevel.info before(async () => { + //conn.logLevel = MessageLogLevel.unhandled const file = './auth_info.json' - await conn.connectSlim(file) + await conn.loadAuthInfo(file).connect() await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t')) }) after(() => conn.close()) + func(conn) }) -} \ No newline at end of file +) \ No newline at end of file diff --git a/src/Tests/Tests.Connect.ts b/src/Tests/Tests.Connect.ts index d03b2a9..2c8bdf5 100644 --- a/src/Tests/Tests.Connect.ts +++ b/src/Tests/Tests.Connect.ts @@ -1,21 +1,25 @@ import * as assert from 'assert' -import * as QR from 'qrcode-terminal' import {WAConnection} from '../WAConnection/WAConnection' -import { AuthenticationCredentialsBase64 } from '../WAConnection/Constants' -import { createTimeout } from '../WAConnection/Utils' +import { AuthenticationCredentialsBase64, BaileysError, MessageLogLevel } from '../WAConnection/Constants' +import { delay, promiseTimeout } from '../WAConnection/Utils' describe('QR Generation', () => { it('should generate QR', async () => { + const conn = new WAConnection() - let calledQR = false - conn.onReadyForPhoneAuthentication = ([ref, curveKey, clientID]) => { - assert.ok(ref, 'ref nil') - assert.ok(curveKey, 'curve key nil') - assert.ok(clientID, 'client ID nil') - calledQR = true - } - await assert.rejects(async () => conn.connectSlim(null, 5000), 'should have failed connect') - assert.equal(calledQR, true, 'QR not called') + conn.regenerateQRIntervalMs = 5000 + let calledQR = 0 + conn.removeAllListeners ('qr') + conn.on ('qr', qr => calledQR += 1) + + await conn.connect(15000) + .then (() => assert.fail('should not have succeeded')) + .catch (error => { + assert.equal (error.message, 'timed out') + }) + assert.equal (conn['pendingRequests'].length, 0) + assert.equal (Object.keys(conn['callbacks']).filter(key => !key.startsWith('function:')).length, 0) + assert.ok(calledQR >= 2, 'QR not called') }) }) @@ -23,54 +27,49 @@ describe('Test Connect', () => { let auth: AuthenticationCredentialsBase64 it('should connect', async () => { console.log('please be ready to scan with your phone') + const conn = new WAConnection() - const user = await conn.connectSlim(null) - assert.ok(user) - assert.ok(user.id) + await conn.connect (null) + assert.ok(conn.user?.id) + assert.ok(conn.user?.phone) + assert.ok (conn.user?.imgUrl || conn.user.imgUrl === '') conn.close() auth = conn.base64EncodedAuthInfo() }) - it('should re-generate QR & connect', async () => { - const conn = new WAConnection() - conn.onReadyForPhoneAuthentication = async ([ref, publicKey, clientID]) => { - for (let i = 0; i < 2; i++) { - console.log ('called QR ' + i + ' times') - await createTimeout (3000) - ref = await conn.generateNewQRCode () - } - const str = ref + ',' + publicKey + ',' + clientID - QR.generate(str, { small: true }) - } - const user = await conn.connectSlim(null) - assert.ok(user) - assert.ok(user.id) - - conn.close() - }) it('should reconnect', async () => { const conn = new WAConnection() - const [user, chats, contacts] = await conn.connect(auth, 20*1000) + await conn + .loadAuthInfo (auth) + .connect (20*1000) + .then (conn => { + assert.ok(conn.user) + assert.ok(conn.user.id) - assert.ok(user) - assert.ok(user.id) - - assert.ok(chats) - - const chatArray = chats.all() - if (chatArray.length > 0) { - assert.ok(chatArray[0].jid) - assert.ok(chatArray[0].count !== null) - if (chatArray[0].messages.length > 0) { - assert.ok(chatArray[0].messages[0]) - } - } - assert.ok(contacts) - if (contacts.length > 0) { - assert.ok(contacts[0].jid) - } - await conn.logout() - await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed') + const chatArray = conn.chats.all() + if (chatArray.length > 0) { + assert.ok(chatArray[0].jid) + assert.ok(chatArray[0].count !== null) + if (chatArray[0].messages.length > 0) { + assert.ok(chatArray[0].messages[0]) + } + } + const contactValues = Object.values(conn.contacts) + if (contactValues[0]) { + assert.ok(contactValues[0].jid) + } + }) + .then (() => conn.logout()) + .then (() => conn.loadAuthInfo(auth)) + .then (() => ( + conn.connect() + .then (() => assert.fail('should not have reconnected')) + .catch (err => { + assert.ok (err instanceof BaileysError) + assert.ok ((err as BaileysError).status >= 400) + }) + )) + .finally (() => conn.close()) }) }) describe ('Pending Requests', async () => { @@ -78,21 +77,17 @@ describe ('Pending Requests', async () => { const conn = new WAConnection () conn.pendingRequestTimeoutMs = null - await conn.connectSlim () + await conn.loadAuthInfo('./auth_info.json').connect () - await createTimeout (2000) + await delay (2000) conn.close () - const task: Promise = new Promise ((resolve, reject) => { - conn.query(['query', 'Status', conn.userMetaData.id]) - .then (json => resolve(json)) - .catch (error => reject ('should not have failed, got error: ' + error)) - }) + const task: Promise = conn.query({json: ['query', 'Status', conn.user.id]}) - await createTimeout (2000) + await delay (2000) - await conn.connectSlim () + conn.connect () const json = await task assert.ok (json.status) diff --git a/src/Tests/Tests.Groups.ts b/src/Tests/Tests.Groups.ts index a71a604..9139cfa 100644 --- a/src/Tests/Tests.Groups.ts +++ b/src/Tests/Tests.Groups.ts @@ -1,12 +1,18 @@ -import { MessageType, GroupSettingChange, createTimeout, ChatModification, whatsappID } from '../WAConnection/WAConnection' +import { MessageType, GroupSettingChange, delay, ChatModification } from '../WAConnection/WAConnection' import * as assert from 'assert' -import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common' +import { WAConnectionTest, testJid } from './Common' WAConnectionTest('Groups', (conn) => { let gid: string it('should create a group', async () => { const response = await conn.groupCreate('Cool Test Group', [testJid]) + assert.ok (conn.chats.get(response.gid)) + + const {chats} = await conn.loadChats(10, null) + assert.equal (chats[0].jid, response.gid) // first chat should be new group + gid = response.gid + console.log('created group: ' + JSON.stringify(response)) }) it('should retreive group invite code', async () => { @@ -22,8 +28,18 @@ WAConnectionTest('Groups', (conn) => { it('should update the group description', async () => { const newDesc = 'Wow this was set from Baileys' + const waitForEvent = new Promise (resolve => { + conn.on ('group-description-update', ({jid, actor}) => { + if (jid === gid) { + assert.ok (actor, conn.user.id) + resolve () + } + }) + }) await conn.groupUpdateDescription (gid, newDesc) - await createTimeout (1000) + await waitForEvent + + conn.removeAllListeners ('group-description-update') const metadata = await conn.groupMetadata(gid) assert.strictEqual(metadata.desc, newDesc) @@ -32,39 +48,102 @@ WAConnectionTest('Groups', (conn) => { await conn.sendMessage(gid, 'hello', MessageType.text) }) it('should quote a message on the group', async () => { - const messages = await conn.loadConversation (gid, 20) + const {messages} = await conn.loadMessages (gid, 100) const quotableMessage = messages.find (m => m.message) assert.ok (quotableMessage, 'need at least one message') - const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: messages[0]}) - const messagesNew = await conn.loadConversation(gid, 10, null, true) - const message = messagesNew.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage + const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: quotableMessage}) + const loaded = await conn.loadMessages(gid, 10) + const message = loaded.messages.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage assert.ok(message) assert.equal (message.contextInfo.stanzaId, quotableMessage.key.id) }) it('should update the subject', async () => { - const subject = 'V Cool Title' + const subject = 'Baileyz ' + Math.floor(Math.random()*5) + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, title}) => { + if (jid === gid) { + assert.equal (title, subject) + resolve () + } + }) + }) await conn.groupUpdateSubject(gid, subject) + await waitForEvent + conn.removeAllListeners ('chat-update') const metadata = await conn.groupMetadata(gid) assert.strictEqual(metadata.subject, subject) }) it('should update the group settings', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('group-settings-update', ({jid, announce}) => { + if (jid === gid) { + assert.equal (announce, 'true') + resolve () + } + }) + }) await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true) - await createTimeout (5000) + + await waitForEvent + conn.removeAllListeners ('group-settings-update') + + await delay (2000) await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true) }) it('should remove someone from a group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('group-participants-remove', ({jid, participants}) => { + if (jid === gid) { + assert.equal (participants[0], testJid) + resolve () + } + }) + }) await conn.groupRemove(gid, [testJid]) + await waitForEvent + conn.removeAllListeners ('group-participants-remove') }) it('should leave the group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, read_only}) => { + if (jid === gid) { + assert.equal (read_only, 'true') + resolve () + } + }) + }) await conn.groupLeave(gid) + await waitForEvent + conn.removeAllListeners ('chat-update') + await conn.groupMetadataMinimal (gid) }) it('should archive the group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, archive}) => { + if (jid === gid) { + assert.equal (archive, 'true') + resolve () + } + }) + }) await conn.modifyChat(gid, ChatModification.archive) + await waitForEvent + conn.removeAllListeners ('chat-update') }) it('should delete the group', async () => { + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', (chat) => { + if (chat.jid === gid) { + assert.equal (chat['delete'], 'true') + resolve () + } + }) + }) await conn.deleteChat(gid) + await waitForEvent + conn.removeAllListeners ('chat-update') }) }) \ No newline at end of file diff --git a/src/Tests/Tests.Messages.ts b/src/Tests/Tests.Messages.ts index c513833..cc15420 100644 --- a/src/Tests/Tests.Messages.ts +++ b/src/Tests/Tests.Messages.ts @@ -1,18 +1,18 @@ -import { MessageType, Mimetype, createTimeout } from '../WAConnection/WAConnection' +import { MessageType, Mimetype, delay, promiseTimeout, WAMessage, WA_MESSAGE_STATUS_TYPE } from '../WAConnection/WAConnection' import {promises as fs} from 'fs' import * as assert from 'assert' import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common' WAConnectionTest('Messages', (conn) => { it('should send a text message', async () => { - const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) - assert.strictEqual(message.message.conversation, 'hello fren') + //const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) + //assert.strictEqual(message.message.conversation || message.message.extendedTextMessage?.text, 'hello fren') }) it('should forward a message', async () => { - let messages = await conn.loadConversation (testJid, 1) + let messages = await conn.loadMessages (testJid, 1) await conn.forwardMessage (testJid, messages[0], true) - messages = await conn.loadConversation (testJid, 1) + messages = await conn.loadMessages (testJid, 1) const message = messages[0] const content = message.message[ Object.keys(message.message)[0] ] assert.equal (content?.contextInfo?.isForwarded, true) @@ -28,7 +28,7 @@ WAConnectionTest('Messages', (conn) => { assert.ok (received.jpegThumbnail) }) it('should quote a message', async () => { - const messages = await conn.loadConversation(testJid, 2) + const messages = await conn.loadMessages(testJid, 2) const message = await sendAndRetreiveMessage(conn, 'hello fren 2', MessageType.extendedText, { quoted: messages[0], }) @@ -48,21 +48,36 @@ WAConnectionTest('Messages', (conn) => { //const message2 = await sendAndRetreiveMessage (conn, 'this is a quote', MessageType.extendedText) }) it('should send an image & quote', async () => { - const messages = await conn.loadConversation(testJid, 1) + const messages = await conn.loadMessages(testJid, 1) const content = await fs.readFile('./Media/meme.jpeg') const message = await sendAndRetreiveMessage(conn, content, MessageType.image, { quoted: messages[0] }) await conn.downloadMediaMessage(message) // check for successful decoding assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, messages[0].key.id) }) - it('should send a text message & delete it', async () => { + it('should send a message & delete it', async () => { const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) - await createTimeout (2000) + await delay (2000) await conn.deleteMessage (testJid, message.key) }) it('should clear the most recent message', async () => { - const messages = await conn.loadConversation (testJid, 1) - await createTimeout (2000) + const messages = await conn.loadMessages (testJid, 1) + await delay (2000) await conn.clearMessage (messages[0].key) }) -}) \ No newline at end of file +}) +WAConnectionTest('Message Events', (conn) => { + it('should deliver a message', async () => { + const waitForUpdate = + promiseTimeout(15000, resolve => { + conn.on('message-update', message => { + if (message.key.id === response.key.id) { + resolve(message) + } + }) + }) as Promise + const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text) + const m = await waitForUpdate + assert.ok (m.status >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK) + }) +}) diff --git a/src/Tests/Tests.Misc.ts b/src/Tests/Tests.Misc.ts index 9404858..94b8bf0 100644 --- a/src/Tests/Tests.Misc.ts +++ b/src/Tests/Tests.Misc.ts @@ -1,20 +1,9 @@ -import { MessageType, Presence, ChatModification, promiseTimeout, createTimeout } from '../WAConnection/WAConnection' +import { Presence, ChatModification, delay } from '../WAConnection/WAConnection' import {promises as fs} from 'fs' import * as assert from 'assert' import fetch from 'node-fetch' import { WAConnectionTest, testJid } from './Common' -WAConnectionTest('Presence', (conn) => { - it('should update presence', async () => { - const presences = Object.values(Presence) - for (const i in presences) { - const response = await conn.updatePresence(testJid, presences[i]) - assert.strictEqual(response.status, 200) - - await createTimeout(1500) - } - }) -}) WAConnectionTest('Misc', (conn) => { it('should tell if someone has an account on WhatsApp', async () => { const response = await conn.isOnWhatsApp(testJid) @@ -30,16 +19,28 @@ WAConnectionTest('Misc', (conn) => { it('should update status', async () => { const newStatus = 'v cool status' + const waitForEvent = new Promise (resolve => { + conn.on ('user-status-update', ({jid, status}) => { + if (jid === conn.user.id) { + assert.equal (status, newStatus) + conn.removeAllListeners ('user-status-update') + resolve () + } + }) + }) + const response = await conn.getStatus() assert.strictEqual(typeof response.status, 'string') - await createTimeout (1000) + await delay (1000) await conn.setStatus (newStatus) const response2 = await conn.getStatus() assert.equal (response2.status, newStatus) - await createTimeout (1000) + await waitForEvent + + await delay (1000) await conn.setStatus (response.status) // update back }) @@ -47,18 +48,18 @@ WAConnectionTest('Misc', (conn) => { await conn.getStories() }) it('should change the profile picture', async () => { - await createTimeout (5000) + await delay (5000) - const ppUrl = await conn.getProfilePicture(conn.userMetaData.id) + const ppUrl = await conn.getProfilePicture(conn.user.id) const fetched = await fetch(ppUrl, { headers: { Origin: 'https://web.whatsapp.com' } }) const buff = await fetched.buffer () const newPP = await fs.readFile ('./Media/cat.jpeg') - const response = await conn.updateProfilePicture (conn.userMetaData.id, newPP) + const response = await conn.updateProfilePicture (conn.user.id, newPP) - await createTimeout (10000) + await delay (10000) - await conn.updateProfilePicture (conn.userMetaData.id, buff) // revert back + await conn.updateProfilePicture (conn.user.id, buff) // revert back }) it('should return the profile picture', async () => { const response = await conn.getProfilePicture(testJid) @@ -70,22 +71,32 @@ WAConnectionTest('Misc', (conn) => { assert.ok(response) }) it('should mark a chat unread', async () => { - await conn.sendReadReceipt(testJid, null, 'unread') + const waitForEvent = new Promise (resolve => { + conn.on ('chat-update', ({jid, count}) => { + if (jid === testJid) { + assert.ok (count < 0) + conn.removeAllListeners ('chat-update') + resolve () + } + }) + }) + await conn.sendReadReceipt(testJid, null, -2) + await waitForEvent }) it('should archive & unarchive', async () => { await conn.modifyChat (testJid, ChatModification.archive) - await createTimeout (2000) + await delay (2000) await conn.modifyChat (testJid, ChatModification.unarchive) }) it('should pin & unpin a chat', async () => { const response = await conn.modifyChat (testJid, ChatModification.pin) - await createTimeout (2000) + await delay (2000) await conn.modifyChat (testJid, ChatModification.unpin, {stamp: response.stamp}) }) it('should mute & unmute a chat', async () => { const mutedate = new Date (new Date().getTime() + 8*60*60*1000) // 8 hours in the future await conn.modifyChat (testJid, ChatModification.mute, {stamp: mutedate}) - await createTimeout (2000) + await delay (2000) await conn.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate}) }) it('should return search results', async () => { @@ -96,18 +107,14 @@ WAConnectionTest('Misc', (conn) => { assert.ok (response.messages.length >= 0) } }) -}) -WAConnectionTest('Events', (conn) => { - it('should deliver a message', async () => { - const waitForUpdate = () => - new Promise((resolve) => { - conn.setOnMessageStatusChange((update) => { - if (update.ids.includes(response.key.id)) { - resolve() - } - }) - }) - const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text) - await promiseTimeout(15000, waitForUpdate()) + + it('should update presence', async () => { + const presences = Object.values(Presence) + for (const i in presences) { + const response = await conn.updatePresence(testJid, presences[i]) + assert.strictEqual(response.status, 200) + + await delay(1500) + } }) -}) +}) \ No newline at end of file diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts index c8acc58..6861837 100644 --- a/src/WAConnection/0.Base.ts +++ b/src/WAConnection/0.Base.ts @@ -1,4 +1,3 @@ -import * as QR from 'qrcode-terminal' import * as fs from 'fs' import WS from 'ws' import * as Utils from './Utils' @@ -6,120 +5,80 @@ import Encoder from '../Binary/Encoder' import Decoder from '../Binary/Decoder' import { AuthenticationCredentials, - UserMetaData, + WAUser, WANode, - AuthenticationCredentialsBase64, WATag, MessageLogLevel, - AuthenticationCredentialsBrowser, BaileysError, - WAConnectionMode, - WAMessage, - PresenceUpdate, - MessageStatusUpdate, WAMetric, WAFlag, + DisconnectReason, + WAConnectionState, + AnyAuthenticationCredentials, + WAContact, + WAChat, + WAQuery, + ReconnectMode, } from './Constants' +import { EventEmitter } from 'events' +import KeyedDB from '@adiwajshing/keyed-db' -/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */ -const generateQRCode = function ([ref, publicKey, clientID]) { - const str = ref + ',' + publicKey + ',' + clientID - QR.generate(str, { small: true }) -} - -export class WAConnection { +export class WAConnection extends EventEmitter { /** The version of WhatsApp Web we're telling the servers we are */ - version: [number, number, number] = [2, 2027, 10] + version: [number, number, number] = [2, 2033, 7] /** The Browser we're telling the WhatsApp Web servers we are */ browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome') /** Metadata like WhatsApp id, name set on WhatsApp etc. */ - userMetaData: UserMetaData = { id: null, name: null, phone: null } - /** Should reconnect automatically after an unexpected disconnect */ - autoReconnect = true - lastSeen: Date = null + user: WAUser /** What level of messages to log to the console */ logLevel: MessageLogLevel = MessageLogLevel.info /** Should requests be queued when the connection breaks in between; if false, then an error will be thrown */ pendingRequestTimeoutMs: number = null - connectionMode: WAConnectionMode = WAConnectionMode.onlyRequireValidation - /** What to do when you need the phone to authenticate the connection (generate QR code by default) */ - onReadyForPhoneAuthentication = generateQRCode - - protected unexpectedDisconnectCallback: (err: string) => any + /** The connection state */ + state: WAConnectionState = 'closed' + /** New QR generation interval, set to null if you don't want to regenerate */ + regenerateQRIntervalMs = 30*1000 + + autoReconnect = ReconnectMode.onConnectionLost + /** Whether the phone is connected */ + phoneConnected: boolean = false + + 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 */ - protected authInfo: AuthenticationCredentials = { - clientID: null, - serverToken: null, - clientToken: null, - encKey: null, - macKey: null, - } + protected authInfo: AuthenticationCredentials = null /** Curve keys to initially authenticate */ protected curveKeys: { private: Uint8Array; public: Uint8Array } /** The websocket connection */ protected conn: WS = null protected msgCount = 0 protected keepAliveReq: NodeJS.Timeout - protected callbacks = {} + protected callbacks: {[k: string]: any} = {} protected encoder = new Encoder() protected decoder = new Decoder() - protected pendingRequests: (() => void)[] = [] - protected reconnectLoop: () => Promise + protected pendingRequests: {resolve: () => void, reject: (error) => void}[] = [] protected referenceDate = new Date () // used for generating tags + protected lastSeen: Date = null // last keep alive received + protected qrTimeout: NodeJS.Timeout + protected phoneCheck: NodeJS.Timeout + + protected cancelledReconnect = false + protected cancelReconnect: () => void + constructor () { + super () this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind)) } - async unexpectedDisconnect (error: string) { - this.close() - if ((error === 'lost' || error === 'closed') && this.autoReconnect) { - await this.reconnectLoop () - } else if (this.unexpectedDisconnectCallback) { - this.unexpectedDisconnectCallback (error) - } - } - /** Set the callback for message status updates (when a message is delivered, read etc.) */ - setOnMessageStatusChange(callback: (update: MessageStatusUpdate) => void) { - const func = json => { - json = json[1] - let ids = json.id - if (json.cmd === 'ack') { - ids = [json.id] - } - const data: MessageStatusUpdate = { - from: json.from, - to: json.to, - participant: json.participant, - timestamp: new Date(json.t * 1000), - ids: ids, - type: (+json.ack)+1, - } - callback(data) - } - this.registerCallback('Msg', func) - this.registerCallback('MsgInfo', func) - } - /** - * Set the callback for new/unread messages; if someone sends you a message, this callback will be fired - * @param callbackOnMyMessages - should the callback be fired on a message you sent from the phone - */ - setOnUnreadMessage(callbackOnMyMessages = false, callback: (m: WAMessage) => void) { - this.registerCallback(['action', 'add:relay', 'message'], (json) => { - const message = json[2][0][2] - if (!message.key.fromMe || callbackOnMyMessages) { - // if this message was sent to us, notify - callback(message as WAMessage) - } else { - this.log(`[Unhandled] message - ${JSON.stringify(message)}`, MessageLogLevel.unhandled) - } - }) - } - /** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */ - setOnPresenceUpdate(callback: (p: PresenceUpdate) => void) { - this.registerCallback('Presence', json => callback(json[1])) - } - /** Set the callback for unexpected disconnects including take over events, log out events etc. */ - setOnUnexpectedDisconnect(callback: (error: string) => void) { - this.unexpectedDisconnectCallback = callback + async unexpectedDisconnect (error?: DisconnectReason) { + const willReconnect = this.autoReconnect === ReconnectMode.onAllErrors || (this.autoReconnect === ReconnectMode.onConnectionLost && (error === 'lost' || error === 'closed')) + + this.log (`got disconnected, reason ${error || 'unknown'}${willReconnect ? ', reconnecting in a few seconds...' : ''}`, MessageLogLevel.info) + + this.closeInternal(error, willReconnect) + willReconnect && this.reconnectLoop () } /** * base 64 encode the authentication credentials and return them @@ -135,68 +94,42 @@ export class WAConnection { macKey: this.authInfo.macKey.toString('base64'), } } - /** - * Clear authentication info so a new connection can be created - */ + /** Clear authentication info so a new connection can be created */ clearAuthInfo () { - this.authInfo = { - clientID: null, - serverToken: null, - clientToken: null, - encKey: null, - macKey: null, - } + this.authInfo = null + return this } /** * Load in the authentication credentials - * @param authInfo the authentication credentials or path to auth credentials JSON + * @param authInfo the authentication credentials or file path to auth credentials */ - loadAuthInfoFromBase64(authInfo: AuthenticationCredentialsBase64 | string) { - if (!authInfo) { - throw new Error('given authInfo is null') - } - if (typeof authInfo === 'string') { - this.log(`loading authentication credentials from ${authInfo}`, MessageLogLevel.info) - const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists - authInfo = JSON.parse(file) as AuthenticationCredentialsBase64 - } - this.authInfo = { - clientID: authInfo.clientID, - serverToken: authInfo.serverToken, - clientToken: authInfo.clientToken, - encKey: Buffer.from(authInfo.encKey, 'base64'), // decode from base64 - macKey: Buffer.from(authInfo.macKey, 'base64'), // decode from base64 - } - } - /** - * Load in the authentication credentials - * @param authInfo the authentication credentials or path to browser credentials JSON - */ - loadAuthInfoFromBrowser(authInfo: AuthenticationCredentialsBrowser | string) { + loadAuthInfo(authInfo: AnyAuthenticationCredentials | string) { if (!authInfo) throw new Error('given authInfo is null') if (typeof authInfo === 'string') { this.log(`loading authentication credentials from ${authInfo}`, MessageLogLevel.info) const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists - authInfo = JSON.parse(file) as AuthenticationCredentialsBrowser + authInfo = JSON.parse(file) as AnyAuthenticationCredentials } - const secretBundle: {encKey: string, macKey: string} = typeof authInfo === 'string' ? JSON.parse (authInfo): authInfo - this.authInfo = { - clientID: authInfo.WABrowserId.replace(/\"/g, ''), - serverToken: authInfo.WAToken2.replace(/\"/g, ''), - clientToken: authInfo.WAToken1.replace(/\"/g, ''), - encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64 - macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64 - } - } - /** - * Register for a callback for a certain function, will cancel automatically after one execution - * @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters - */ - async registerCallbackOneTime(parameters) { - const json = await new Promise((resolve, _) => this.registerCallback(parameters, resolve)) - this.deregisterCallback(parameters) - return json + if ('clientID' in authInfo) { + this.authInfo = { + clientID: authInfo.clientID, + serverToken: authInfo.serverToken, + clientToken: authInfo.clientToken, + encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'), + macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'), + } + } else { + const secretBundle: {encKey: string, macKey: string} = typeof authInfo === 'string' ? JSON.parse (authInfo): authInfo + this.authInfo = { + clientID: authInfo.WABrowserId.replace(/\"/g, ''), + serverToken: authInfo.WAToken2.replace(/\"/g, ''), + clientToken: authInfo.WAToken1.replace(/\"/g, ''), + encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64 + macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64 + } + } + return this } /** * Register for a callback for a certain function @@ -247,30 +180,20 @@ export class WAConnection { * @param timeoutMs timeout after which the promise will reject */ async waitForMessage(tag: string, json: Object = null, timeoutMs: number = null) { - let promise = new Promise( + let promise = Utils.promiseTimeout(timeoutMs, (resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }), ) - if (timeoutMs) { - promise = Utils.promiseTimeout(timeoutMs, promise).catch((err) => { - delete this.callbacks[tag] - throw err - }) - } + .catch((err) => { + delete this.callbacks[tag] + throw err + }) return promise as Promise } - /** - * Query something from the WhatsApp servers and error on a non-200 status - * @param json the query itself - * @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary - * @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 queryExpecting200(json: any[] | WANode, binaryTags?: WATag, timeoutMs?: number, tag?: string) { - const response = await this.query(json, binaryTags, timeoutMs, tag) - if (response.status && Math.floor(+response.status / 100) !== 2) { - throw new BaileysError(`Unexpected status code in '${json[0] || 'generic query'}': ${response.status}`, {query: json}) - } - return response + /** Generic function for action, set queries */ + async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) { + const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes] + const result = await this.query({ json, binaryTags, tag, expect200: true }) as Promise<{status: number}> + return result } /** * Query something from the WhatsApp servers @@ -280,17 +203,18 @@ export class WAConnection { * @param tag the tag to attach to the message * recieved JSON */ - async query(json: any[] | WANode, binaryTags?: WATag, timeoutMs?: number, tag?: string) { + async query({json, binaryTags, tag, timeoutMs, expect200, waitForOpen}: WAQuery) { + waitForOpen = typeof waitForOpen === 'undefined' ? true : waitForOpen + await this.waitForConnection (waitForOpen) + if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag) else tag = await this.sendJSON(json, tag) - return this.waitForMessage(tag, json, timeoutMs) - } - /** Generic function for action, set queries */ - async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) { - const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes] - const result = await this.queryExpecting200(json, binaryTags, null, tag) as Promise<{status: number}> - return result + const response = await this.waitForMessage(tag, json, timeoutMs) + if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) { + throw new BaileysError(`Unexpected status code in '${json[0] || 'generic query'}': ${response.status}`, {query: json}) + } + return response } /** * Send a binary encoded message @@ -299,9 +223,7 @@ export class WAConnection { * @param tag the tag to attach to the message * @return the message tag */ - protected async sendBinary(json: WANode, tags: WATag, tag?: string) { - if (!this.conn || this.conn.readyState !== this.conn.OPEN) await this.waitForConnection () - + protected sendBinary(json: WANode, tags: WATag, tag: string = null) { const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey @@ -313,7 +235,7 @@ export class WAConnection { sign, // the HMAC sign of the message buff, // the actual encrypted buffer ]) - await this.send(buff) // send it off + this.send(buff) // send it off return tag } /** @@ -322,23 +244,22 @@ export class WAConnection { * @param tag the tag to attach to the message * @return the message tag */ - protected async sendJSON(json: any[] | WANode, tag: string = null) { + protected sendJSON(json: any[] | WANode, tag: string = null) { tag = tag || this.generateMessageTag() - await this.send(tag + ',' + JSON.stringify(json)) + this.send(`${tag},${JSON.stringify(json)}`) return tag } /** Send some message to the WhatsApp servers */ - protected async send(m) { - if (!this.conn || this.conn.readyState !== this.conn.OPEN) await this.waitForConnection () - + protected send(m) { this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages return this.conn.send(m) } - protected async waitForConnection () { + protected async waitForConnection (waitForOpen: boolean) { + if (!waitForOpen || this.state === 'open') return + const timeout = this.pendingRequestTimeoutMs try { - const task = new Promise (resolve => this.pendingRequests.push(resolve)) - await Utils.promiseTimeout (timeout, task) + await Utils.promiseTimeout (timeout, (resolve, reject) => this.pendingRequests.push({resolve, reject})) } catch { throw new Error('cannot send message, disconnected from WhatsApp') } @@ -347,38 +268,51 @@ export class WAConnection { * Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request. * @see close() if you just want to close the connection */ - async logout() { - if (!this.conn) throw new Error("You're not even connected, you can't log out") + async logout () { + if (this.state !== 'open') throw new Error("You're not even connected, you can't log out") await new Promise(resolve => this.conn.send('goodbye,["admin","Conn","disconnect"]', null, resolve)) this.authInfo = null this.close() } - /** Close the connection to WhatsApp Web */ - close() { + close () { + this.closeInternal ('intentional') + + this.cancelReconnect && this.cancelReconnect () + this.cancelledReconnect = true + + this.pendingRequests.forEach (({reject}) => reject(new Error('closed'))) + this.pendingRequests = [] + } + protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean = false) { + this.qrTimeout && clearTimeout (this.qrTimeout) + this.phoneCheck && clearTimeout (this.phoneCheck) + + this.state = 'closed' this.msgCount = 0 - if (this.conn) { - this.conn.removeAllListeners ('close') - this.conn.close() - this.conn = null - } - const keys = Object.keys(this.callbacks) - keys.forEach(key => { + this.conn?.removeAllListeners ('close') + this.conn?.close() + this.conn = null + this.phoneConnected = false + + Object.keys(this.callbacks).forEach(key => { if (!key.includes('function:')) { - this.callbacks[key].errCallback('connection closed') + this.callbacks[key].errCallback(new Error('closed')) delete this.callbacks[key] } }) - if (this.keepAliveReq) { - clearInterval(this.keepAliveReq) - } + if (this.keepAliveReq) clearInterval(this.keepAliveReq) + + this.emit ('closed', { reason, isReconnecting }) + } + protected async reconnectLoop () { + } generateMessageTag () { - return `${Math.round(this.referenceDate.getTime())/1000}.--${this.msgCount}` + return `${Utils.unixTimestampSeconds(this.referenceDate)}.--${this.msgCount}` } protected log(text, level: MessageLogLevel) { - if (this.logLevel >= level) - console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`) + (this.logLevel >= level) && console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`) } } diff --git a/src/WAConnection/1.Validation.ts b/src/WAConnection/1.Validation.ts index ca5f079..be01923 100644 --- a/src/WAConnection/1.Validation.ts +++ b/src/WAConnection/1.Validation.ts @@ -7,35 +7,29 @@ export class WAConnection extends Base { /** Authenticate the connection */ protected async authenticate() { - if (!this.authInfo.clientID) { - // if no auth info is present, that is, a new session has to be established - // generate a client ID - this.authInfo = { - clientID: Utils.generateClientID(), - clientToken: null, - serverToken: null, - encKey: null, - macKey: null, - } + // if no auth info is present, that is, a new session has to be established + // generate a client ID + if (!this.authInfo?.clientID) { + this.authInfo = { clientID: Utils.generateClientID() } as any } this.referenceDate = new Date () // refresh reference date - const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true] + const json = ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true] - return this.queryExpecting200(data) + return this.query({json, expect200: true, waitForOpen: false}) .then(json => { // we're trying to establish a new connection or are trying to log in - if (this.authInfo.encKey && this.authInfo.macKey) { + if (this.authInfo?.encKey && this.authInfo?.macKey) { // if we have the info to restore a closed session - const data = [ + const json = [ 'admin', 'login', - this.authInfo.clientToken, - this.authInfo.serverToken, - this.authInfo.clientID, + this.authInfo?.clientToken, + this.authInfo?.serverToken, + this.authInfo?.clientID, 'takeover', ] - return this.query(data, null, null, 's1') // wait for response with tag "s1" + return this.query({ json, tag: 's1', waitForOpen: false }) // wait for response with tag "s1" } return this.generateKeysForAuth(json.ref) // generate keys which will in turn be the QR }) @@ -62,31 +56,29 @@ export class WAConnection extends Base { this.validateNewConnection(json[1]) // validate the connection this.log('validated connection successfully', MessageLogLevel.info) - await this.sendPostConnectQueries () + this.sendPostConnectQueries () this.lastSeen = new Date() // set last seen to right now - return this.userMetaData }) } /** * Send the same queries WA Web sends after connect */ - async sendPostConnectQueries () { - await this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'chat', epoch: '1'}, null], [ WAMetric.queryChat, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ]) - await this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ]) - await this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ]) + sendPostConnectQueries () { + this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'chat', epoch: '1'}, null], [ WAMetric.queryChat, WAFlag.ignore ]) + this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, 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: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ]) + this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ]) } /** * Refresh QR Code * @returns the new ref */ - async generateNewQRCode() { - const data = ['admin', 'Conn', 'reref'] - const response = await this.query(data) + async generateNewQRCodeRef() { + const response = await this.query({json: ['admin', 'Conn', 'reref'], expect200: true, waitForOpen: false}) return response.ref as string } /** @@ -97,12 +89,13 @@ export class WAConnection extends Base { private validateNewConnection(json) { const onValidationSuccess = () => { // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone - this.userMetaData = { - id: json.wid.replace('@c.us', '@s.whatsapp.net'), + this.user = { + id: Utils.whatsappID(json.wid), name: json.pushname, phone: json.phone, + imgUrl: null } - return this.userMetaData + return this.user } if (!json.secret) { @@ -154,18 +147,40 @@ export class WAConnection extends Base { protected respondToChallenge(challenge: string) { const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey - const data = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID + const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID this.log('resolving login challenge', MessageLogLevel.info) - return this.queryExpecting200(data) + return this.query({json, expect200: true, waitForOpen: false}) } /** 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) { this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32)) - this.onReadyForPhoneAuthentication([ - ref, - Buffer.from(this.curveKeys.public).toString('base64'), - this.authInfo.clientID, - ]) - return this.waitForMessage('s1', []) + const publicKey = Buffer.from(this.curveKeys.public).toString('base64') + + const emitQR = () => { + const qr = [ref, publicKey, this.authInfo.clientID].join(',') + this.emit ('qr', qr) + } + const regenQR = () => { + this.qrTimeout = setTimeout (() => { + if (this.state === 'open') return + + this.log ('regenerated QR', MessageLogLevel.info) + + this.generateNewQRCodeRef () + .then (newRef => ref = newRef) + .then (emitQR) + .then (regenQR) + .catch (err => this.log (`error in QR gen: ${err}`, MessageLogLevel.info)) + }, this.regenerateQRIntervalMs) + } + if (this.regenerateQRIntervalMs) { + regenQR () + } + + const json = await this.waitForMessage('s1', []) + this.qrTimeout && clearTimeout (this.qrTimeout) + this.qrTimeout = null + + return json } } diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts index f91c2b6..67f67b8 100644 --- a/src/WAConnection/3.Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -1,74 +1,77 @@ import WS from 'ws' -import KeyedDB from '@adiwajshing/keyed-db' import * as Utils from './Utils' -import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel, WANode, WAConnectionMode } from './Constants' +import { WAMessage, WAChat, WAContact, MessageLogLevel, WANode, KEEP_ALIVE_INTERVAL_MS } from './Constants' import {WAConnection as Base} from './1.Validation' import Decoder from '../Binary/Decoder' export class WAConnection extends Base { /** * Connect to WhatsAppWeb - * @param [authInfo] credentials or path to credentials to log back in - * @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout - * @return returns [userMetaData, chats, contacts] + * @param timeoutMs timeout after which the connect will fail, set to null for an infinite timeout + * @param waitForChats should the chats be waited for */ - async connect(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) { - try { - const userInfo = await this.connectSlim(authInfo, timeoutMs) - const chats = await this.receiveChatsAndContacts(timeoutMs) - return [userInfo, ...chats] as [UserMetaData, KeyedDB, WAContact[]] - } catch (error) { - this.close () - throw error - } - } - /** - * Connect to WhatsAppWeb, resolves without waiting for chats & contacts - * @param [authInfo] credentials to log back in - * @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout - * @return [userMetaData, chats, contacts, unreadMessages] - */ - async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) { + async connect(timeoutMs: number = null, waitForChats: boolean = true) { // if we're already connected, throw an error - if (this.conn) throw new Error('already connected or connecting') - // set authentication credentials if required - try { - this.loadAuthInfoFromBase64(authInfo) - } catch {} + if (this.state !== 'closed') throw new Error('cannot connect when state=' + this.state) + + this.state = 'connecting' + this.emit ('connecting') this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' }) - const promise: Promise = new Promise((resolve, reject) => { + const promise: Promise = Utils.promiseTimeout(timeoutMs, (resolve, reject) => { this.conn.on('open', () => { - this.log('connected to WhatsApp Web, authenticating...', MessageLogLevel.info) + this.log('connected to WhatsApp Web server, authenticating...', MessageLogLevel.info) // start sending keep alive requests (keeps the WebSocket alive & updates our last seen) this.authenticate() - .then(user => { + .then(() => { this.startKeepAliveRequest() this.conn.removeAllListeners ('error') this.conn.on ('close', () => this.unexpectedDisconnect ('closed')) - resolve(user) + this.state = 'open' + resolve() }) .catch(reject) }) this.conn.on('message', m => this.onMessageRecieved(m)) // if there was an error in the WebSocket - this.conn.on('error', error => { this.close(); reject(error) }) + this.conn.on('error', error => { this.closeInternal(error.message as any); reject(error) }) }) - const user = await Utils.promiseTimeout(timeoutMs, promise).catch(err => {this.close(); throw err}) - if (this.connectionMode === WAConnectionMode.onlyRequireValidation) this.releasePendingRequests () - return user + + try { + await promise + waitForChats && await this.receiveChatsAndContacts(timeoutMs, true) + + this.phoneConnected = true + this.state = 'open' + + this.user.imgUrl = await this.getProfilePicture (this.user.id).catch (err => '') + + this.emit ('open') + + this.releasePendingRequests () + this.log ('opened connection to WhatsApp Web', MessageLogLevel.info) + return this + } catch (error) { + this.closeInternal (error.message) + 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 & unread messages. + * Sets up callbacks to receive chats, contacts & messages. * Must be called immediately after connect * @returns [chats, contacts] */ - async receiveChatsAndContacts(timeoutMs: number = null) { - let contacts: WAContact[] = [] - const chats: KeyedDB = new KeyedDB (Utils.waChatUniqueKey, value => value.jid) + protected async receiveChatsAndContacts(timeoutMs: number = null, stopAfterMostRecentMessage: boolean=false) { + this.contacts = {} + this.chats.clear () let receivedContacts = false let receivedMessages = false @@ -76,75 +79,97 @@ export class WAConnection extends Base { this.log('waiting for chats & contacts', MessageLogLevel.info) // wait for the message with chats const waitForConvos = () => - new Promise(resolve => { + Utils.promiseTimeout(timeoutMs, resolve => { convoResolve = () => { // de-register the callbacks, so that they don't get called again this.deregisterCallback(['action', 'add:last']) - this.deregisterCallback(['action', 'add:before']) - this.deregisterCallback(['action', 'add:unread']) + if (!stopAfterMostRecentMessage) { + this.deregisterCallback(['action', 'add:before']) + this.deregisterCallback(['action', 'add:unread']) + } resolve() } const chatUpdate = json => { receivedMessages = true - const isLast = json[1].last + const isLast = json[1].last || (json[1].add === 'last' && stopAfterMostRecentMessage) const messages = json[2] as WANode[] if (messages) { - messages.reverse().forEach (([, __, message]: ['message', null, WAMessage]) => { + messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => { const jid = message.key.remoteJid - const chat = chats.get(jid) + const chat = this.chats.get(jid) chat?.messages.unshift (message) }) } // if received contacts before messages if (isLast && receivedContacts) convoResolve () } + // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages this.registerCallback(['action', 'add:last'], chatUpdate) - this.registerCallback(['action', 'add:before'], chatUpdate) - this.registerCallback(['action', 'add:unread'], chatUpdate) - }) - const waitForChats = async () => { - let json = await this.registerCallbackOneTime(['response', 'type:chat']) - if (json[1].duplicate) json = await this.registerCallbackOneTime (['response', 'type:chat']) - - if (!json[2]) return - - json[2] - .map(([item, chat]: [any, WAChat]) => { - if (!chat) { - this.log (`unexpectedly got null chat: ${item}, ${chat}`, MessageLogLevel.info) - return + + if (!stopAfterMostRecentMessage) { + this.registerCallback(['action', 'add:before'], chatUpdate) + this.registerCallback(['action', 'add:unread'], chatUpdate) } - chat.jid = Utils.whatsappID (chat.jid) - chat.count = +chat.count - chat.messages = [] - chats.insert (chat) // chats data (log json to see what it looks like) }) - .filter (Boolean) + const waitForChats = async () => ( + Utils.promiseTimeout (timeoutMs, resolve => { + this.registerCallback(['response', 'type:chat'], json => { + if (json[1].duplicate || !json[2]) return + + json[2] + .forEach(([item, chat]: [any, WAChat]) => { + if (!chat) { + this.log (`unexpectedly got null chat: ${item}, ${chat}`, MessageLogLevel.info) + return + } + chat.jid = Utils.whatsappID (chat.jid) + chat.t = +chat.t + chat.count = +chat.count + chat.messages = [] + this.chats.insert (chat) // chats data (log json to see what it looks like) + }) + + this.deregisterCallback(['response', 'type:chat']) + + if (this.chats.all().length > 0) waitForConvos().then (resolve) + else resolve () + }) + }) + ) + const waitForContacts = async () => ( + new Promise (resolve => { + this.registerCallback(['response', 'type:contacts'], json => { + if (json[1].duplicate) return - if (chats.all().length > 0) return waitForConvos() - } - const waitForContacts = async () => { - let json = await this.registerCallbackOneTime(['response', 'type:contacts']) - if (json[1].duplicate) json = await this.registerCallbackOneTime (['response', 'type:contacts']) + 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 + }) + // if you receive contacts after messages + // should probably resolve the promise + if (receivedMessages) convoResolve() + resolve () - contacts = json[2].map(item => item[1]) - receivedContacts = true - // if you receive contacts after messages - // should probably resolve the promise - if (receivedMessages) convoResolve() - } + this.deregisterCallback(['response', 'type:contacts']) + }) + }) + ) // wait for the chats & contacts to load - const promise = Promise.all([waitForChats(), waitForContacts()]) - await Utils.promiseTimeout (timeoutMs, promise) + await Promise.all( [waitForChats(), waitForContacts()] ) - if (this.connectionMode === WAConnectionMode.requireChatsAndContacts) this.releasePendingRequests () - - return [chats, contacts] as [KeyedDB, WAContact[]] + this.chats.all ().forEach (chat => { + const respectiveContact = this.contacts[chat.jid] + chat.title = respectiveContact?.name || respectiveContact?.notify + }) } private releasePendingRequests () { - this.pendingRequests.forEach (send => send()) // send off all pending request + this.pendingRequests.forEach (({resolve}) => resolve()) // send off all pending request this.pendingRequests = [] } private onMessageRecieved(message) { @@ -213,21 +238,45 @@ export class WAConnection extends Base { } /** Send a keep alive request every X seconds, server updates & responds with last seen */ private startKeepAliveRequest() { - const refreshInterval = 20 this.keepAliveReq = setInterval(() => { - const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000 + const diff = (new Date().getTime() - this.lastSeen.getTime()) /* check if it's been a suspicious amount of time since the server responded with our last seen it could be that the network is down */ - if (diff > refreshInterval + 5) this.unexpectedDisconnect ('lost') + if (diff > KEEP_ALIVE_INTERVAL_MS+5000) this.unexpectedDisconnect ('lost') else this.send ('?,,') // if its all good, send a keep alive request - }, refreshInterval * 1000) + }, KEEP_ALIVE_INTERVAL_MS) } + protected async reconnectLoop () { + this.cancelledReconnect = false + try { + while (true) { + const {delay, cancel} = Utils.delayCancellable (5000) + this.cancelReconnect = cancel + + await delay + try { + await this.connect () + this.cancelReconnect = null + } catch (error) { + this.log (`error in reconnecting: ${error}, reconnecting...`, MessageLogLevel.info) + } + } + } catch { - reconnectLoop = async () => { - // attempt reconnecting if the user wants us to - this.log('network is down, reconnecting...', MessageLogLevel.info) - return this.connectSlim(null, 25*1000).catch(this.reconnectLoop) + } + } + /** + * Check if your phone is connected + * @param timeoutMs max time for the phone to respond + */ + async checkPhoneConnection(timeoutMs = 5000) { + try { + const response = await this.query({json: ['admin', 'test'], timeoutMs}) + return response[1] as boolean + } catch (error) { + return false + } } } diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts new file mode 100644 index 0000000..7d19ab2 --- /dev/null +++ b/src/WAConnection/4.Events.ts @@ -0,0 +1,314 @@ +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 } from './Constants' +import { whatsappID, unixTimestampSeconds, isGroupID } from './Utils' + +export class WAConnection extends Base { + + constructor () { + super () + + this.registerOnMessageStatusChange () + this.registerOnUnreadMessage () + this.registerOnPresenceUpdate () + this.registerPhoneConnectionPoll () + + // If a message has been updated (usually called when a video message gets its upload url) + this.registerCallback (['action', 'add:update', 'message'], json => { + const message: WAMessage = json[2][0][2] + const jid = whatsappID(message.key.remoteJid) + const chat = this.chats.get(jid) + if (!chat) return + + const messageIndex = chat.messages.findIndex(m => m.key.id === message.key.id) + if (messageIndex >= 0) chat.messages[messageIndex] = message + + this.emit ('message-update', message) + }) + // If a user's contact has changed + this.registerCallback (['action', null, 'user'], json => { + const node = json[2][0] + 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.title = user.name || user.notify + this.emit ('chat-update', { jid: chat.jid, title: chat.title }) + } + } + }) + // chat archive, pin etc. + this.registerCallback(['action', null, 'chat'], json => { + json = json[2][0] + + const updateType = json[1].type + const jid = whatsappID(json[1]?.jid) + + const chat = this.chats.get(jid) + if (!chat) return + + const FUNCTIONS = { + 'delete': () => { + chat['delete'] = 'true' + this.chats.delete(chat) + return 'delete' + }, + 'clear': () => { + json[2].forEach(item => chat.messages.filter(m => m.key.id !== item[1].index)) + return 'clear' + }, + 'archive': () => { + chat.archive = 'true' + return 'archive' + }, + 'unarchive': () => { + delete chat.archive + return 'archive' + }, + 'pin': () => { + chat.pin = json[1].pin + return 'pin' + } + } + const func = FUNCTIONS [updateType] + + if (func) { + const property = func () + this.emit ('chat-update', { jid, [property]: chat[property] || null }) + } + }) + // profile picture updates + this.registerCallback(['Cmd', 'type:picture'], async json => { + const jid = whatsappID(json[1].jid) + const chat = this.chats.get(jid) + if (!chat) return + + await this.setProfilePicture (chat) + this.emit ('chat-update', { jid, imgUrl: chat.imgUrl }) + }) + // status updates + this.registerCallback(['Status'], async json => { + const jid = whatsappID(json[1].id) + this.emit ('user-status-update', { jid, status: json[1].status }) + }) + // read updates + this.registerCallback (['action', null, 'read'], async json => { + const update = json[2][0][1] + + const chat = this.chats.get ( whatsappID(update.jid) ) + + if (update.type === 'false') chat.count = -1 + else chat.count = 0 + + this.emit ('chat-update', { jid: chat.jid, count: chat.count }) + }) + + this.on ('qr', qr => QR.generate(qr, { small: true })) + } + /** Set the callback for message status updates (when a message is delivered, read etc.) */ + protected registerOnMessageStatusChange() { + const func = json => { + json = json[1] + let ids = json.id + + if (json.cmd === 'ack') ids = [json.id] + + const update: MessageStatusUpdate = { + from: json.from, + to: json.to, + participant: json.participant, + timestamp: new Date(json.t * 1000), + ids: ids, + type: (+json.ack)+1, + } + + const chat = this.chats.get( whatsappID(update.to) ) + if (!chat) return + + this.chatUpdatedMessage (update.ids, update.type, chat) + } + this.registerCallback('Msg', func) + this.registerCallback('MsgInfo', func) + } + protected registerOnUnreadMessage() { + this.registerCallback(['action', 'add:relay', 'message'], json => { + const message = json[2][0][2] as WAMessage + this.chatAddMessageAppropriate (message) + }) + } + /** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */ + protected registerOnPresenceUpdate() { + this.registerCallback('Presence', json => this.emit('user-presence-update', json[1])) + } + /** inserts an empty chat into the DB */ + protected async chatAdd (jid: string, title?: string) { + const chat: WAChat = { + jid: jid, + t: unixTimestampSeconds(), + messages: [], + count: 0, + modify_tag: '', + spam: 'false', + title + } + await this.setProfilePicture (chat) + this.chats.insert (chat) + this.emit ('chat-new', chat) + return chat + } + /** find a chat or return an error */ + protected assertChatGet = jid => { + const chat = this.chats.get (jid) + if (!chat) throw new Error (`chat '${jid}' not found`) + return chat + } + /** Adds the given message to the appropriate chat, if the chat doesn't exist, it is created */ + protected async chatAddMessageAppropriate (message: WAMessage) { + const jid = whatsappID (message.key.remoteJid) + const chat = this.chats.get(jid) || await this.chatAdd (jid) + this.chatAddMessage (message, chat) + } + protected chatAddMessage (message: WAMessage, chat: WAChat) { + // add to count if the message isn't from me & there exists a message + if (!message.key.fromMe && message.message) chat.count += 1 + + const protocolMessage = message.message?.protocolMessage + + // if it's a message to delete another message + if (protocolMessage) { + switch (protocolMessage.type) { + case WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE: + const found = chat.messages.find(m => m.key.id === protocolMessage.key.id) + if (found && found.message) { + //this.log ('deleting message: ' + protocolMessage.key.id + ' in chat: ' + protocolMessage.key.remoteJid) + found.messageStubType = WA_MESSAGE_STUB_TYPE.REVOKE + found.message = null + + this.emit ('message-update', found) + } + break + default: + break + } + } else if (!chat.messages.find(m => m.key.id === message.key.id)) { + // this.log ('adding new message from ' + chat.jid) + chat.messages.push(message) + chat.messages = chat.messages.slice (-5) // only keep the last 5 messages + + // only update if it's an actual message + if (message.message) this.chatUpdateTime (chat) + + this.emit ('message-new', message) + + // check if the message is an action + if (message.messageStubType) { + const jid = chat.jid + let actor = whatsappID (message.participant) + let participants: string[] + switch (message.messageStubType) { + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_LEAVE: + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_REMOVE: + participants = message.messageStubParameters.map (whatsappID) + this.emit ('group-participants-remove', { jid, actor, participants}) + + // mark the chat read only if you left the group + if (participants.includes(this.user.id)) { + chat.read_only = 'true' + this.emit ('chat-update', { jid, read_only: chat.read_only }) + } + break + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_ADD: + case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_INVITE: + participants = message.messageStubParameters.map (whatsappID) + this.emit ('group-participants-add', { jid, participants, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE: + const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false' + this.emit ('group-settings-update', { jid, announce, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE: + const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false' + this.emit ('group-settings-update', { jid, restrict, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_DESCRIPTION: + this.emit ('group-description-update', { jid, actor }) + break + case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_SUBJECT: + chat.title = message.messageStubParameters[0] + this.emit ('chat-update', { jid, title: chat.title }) + break + } + } + } + } + protected chatUpdatedMessage (messageIDs: string[], status: number, chat: WAChat) { + for (let msg of chat.messages) { + if (messageIDs.includes(msg.key.id)) { + if (isGroupID(chat.jid)) msg.status = WA_MESSAGE_STATUS_TYPE.SERVER_ACK + else msg.status = status + + this.emit ('message-update', msg) + } + } + } + protected chatUpdateTime = chat => this.chats.updateKey (chat, c => c.t = unixTimestampSeconds()) + /** sets the profile picture of a chat */ + protected async setProfilePicture (chat: WAChat) { + chat.imgUrl = await this.getProfilePicture (chat.jid).catch (err => '') + } + protected registerPhoneConnectionPoll () { + this.phoneCheck = setInterval (() => { + this.checkPhoneConnection (7500) // 7500 ms for timeout + .then (connected => { + if (this.phoneConnected != connected) { + this.emit ('connection-phone-change', {connected}) + } + this.phoneConnected = connected + }) + .catch (error => this.log(`error in getting phone connection: ${error}`, MessageLogLevel.info)) + }, 20000) + } + + // Add all event types + + /** when the connection has opened successfully */ + on (event: 'open', listener: () => void): this + /** when the connection is opening */ + on (event: 'connecting', listener: () => void): this + /** when the connection has closed */ + on (event: 'closed', listener: (err: {reason?: string, isReconnecting: boolean}) => void): this + /** when a new QR is generated, ready for scanning */ + on (event: 'qr', listener: (qr: string) => void): this + /** when the connection to the phone changes */ + on (event: 'connection-phone-change', listener: (state: {connected: boolean}) => void): this + /** when a user's presence is updated */ + 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 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) */ + on (event: 'message-update', listener: (message: WAMessage) => 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 */ + on (event: 'group-participants-remove', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when participants are promoted in a group */ + on (event: 'group-participants-promote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when participants are demoted in a group */ + on (event: 'group-participants-demote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this + /** when the group settings is updated */ + on (event: 'group-settings-update', listener: (update: {jid: string, restrict?: string, announce?: string, actor?: string}) => void): this + /** when the group description is updated */ + on (event: 'group-description-update', listener: (update: {jid: string, description?: string, actor?: string}) => void): this + + on (event: BaileysEvent, listener: (...args: any[]) => void) { return super.on (event, listener) } + emit (event: BaileysEvent, ...args: any[]) { return super.emit (event, ...args) } +} diff --git a/src/WAConnection/4.User.ts b/src/WAConnection/4.User.ts deleted file mode 100644 index 5c34181..0000000 --- a/src/WAConnection/4.User.ts +++ /dev/null @@ -1,170 +0,0 @@ -import {WAConnection as Base} from './3.Connect' -import { Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants' -import { - WAMessage, - WANode, - WAMetric, - WAFlag, -} from '../WAConnection/Constants' -import { generateProfilePicture } from './Utils' - -// All user related functions -- get profile picture, set status etc. - -export class WAConnection extends Base { - /** Query whether a given number is registered on WhatsApp */ - isOnWhatsApp = (jid: string) => this.query(['query', 'exist', jid]).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 - * @param type your presence - */ - async updatePresence(jid: string | null, type: Presence) { - const json = [ - 'action', - { epoch: this.msgCount.toString(), type: 'set' }, - [['presence', { type: type, to: jid }, null]], - ] - return this.queryExpecting200(json, [WAMetric.group, WAFlag.acknowledge]) as Promise<{ status: number }> - } - /** Request an update on the presence of a user */ - requestPresenceUpdate = async (jid: string) => this.queryExpecting200(['action', 'presence', 'subscribe', jid]) - /** Query the status of the person (see groupMetadata() for groups) */ - async getStatus (jid?: string) { - return this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }> - } - async setStatus (status: string) { - return this.setQuery ( - [ - [ - 'status', - null, - Buffer.from (status, 'utf-8') - ] - ] - ) - } - /** Get the URL to download the profile picture of a person/group */ - async getProfilePicture(jid: string | null) { - const response = await this.queryExpecting200(['query', 'ProfilePicThumb', jid || this.userMetaData.id]) - return response.eurl as string - } - /** Get your contacts */ - async getContacts() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null] - const response = await this.query(json, [6, WAFlag.ignore]) // this has to be an encrypted query - return response - } - /** Get the stories of your contacts */ - async getStories() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null] - const response = await this.queryExpecting200(json, [30, WAFlag.ignore]) as WANode - if (Array.isArray(response[2])) { - return response[2].map (row => ( - { - unread: row[1]?.unread, - count: row[1]?.count, - messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : [] - } as {unread: number, count: number, messages: WAMessage[]} - )) - } - return [] - } - /** Fetch your chats */ - async getChats() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null] - return this.query(json, [5, WAFlag.ignore]) // this has to be an encrypted query - } - /** Query broadcast list info */ - async getBroadcastListInfo(jid: string) { return this.queryExpecting200(['query', 'contact', jid]) as Promise } - /** Delete the chat of a given ID */ - async deleteChat (jid: string) { - return this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as Promise<{status: number}> - } - /** - * Check if your phone is connected - * @param timeoutMs max time for the phone to respond - */ - async isPhoneConnected(timeoutMs = 5000) { - try { - const response = await this.query(['admin', 'test'], null, timeoutMs) - return response[1] as boolean - } catch (error) { - return false - } - } - /** - * Load the conversation with a group or person - * @param count the number of messages to load - * @param [indexMessage] the data for which message to offset the query by - * @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start - */ - async loadConversation( - jid: string, - count: number, - indexMessage: { id: string; fromMe: boolean } = null, - mostRecentFirst = true, - ) { - const json = [ - 'query', - { - epoch: this.msgCount.toString(), - type: 'message', - jid: jid, - kind: mostRecentFirst ? 'before' : 'after', - count: count.toString(), - index: indexMessage?.id, - owner: indexMessage?.fromMe === false ? 'false' : 'true', - }, - null, - ] - const response = await this.queryExpecting200(json, [WAMetric.queryMessages, WAFlag.ignore]) - return response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : [] - } - /** - * Load the entire friggin conversation with a group or person - * @param onMessage callback for every message retreived - * @param [chunkSize] the number of messages to load in a single request - * @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start - */ - loadEntireConversation(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) { - let offsetID = null - const loadMessage = async () => { - const json = await this.loadConversation(jid, chunkSize, offsetID, mostRecentFirst) - // callback with most recent message first (descending order of date) - let lastMessage - if (mostRecentFirst) { - for (let i = json.length - 1; i >= 0; i--) { - onMessage(json[i]) - lastMessage = json[i] - } - } else { - for (let i = 0; i < json.length; i++) { - onMessage(json[i]) - lastMessage = json[i] - } - } - // if there are still more messages - if (json.length >= chunkSize) { - offsetID = lastMessage.key // get the last message - return new Promise((resolve, reject) => { - // send query after 200 ms - setTimeout(() => loadMessage().then(resolve).catch(reject), 200) - }) - } - } - return loadMessage() as Promise - } - async updateProfilePicture (jid: string, img: Buffer) { - const data = await generateProfilePicture (img) - const tag = this.generateMessageTag () - const query: WANode = [ - 'picture', - { jid: jid, id: tag, type: 'set' }, - [ - ['image', null, data.img], - ['preview', null, data.preview] - ] - ] - return this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise - } -} diff --git a/src/WAConnection/5.User.ts b/src/WAConnection/5.User.ts new file mode 100644 index 0000000..5b0fc71 --- /dev/null +++ b/src/WAConnection/5.User.ts @@ -0,0 +1,172 @@ +import {WAConnection as Base} from './4.Events' +import { Presence, WABroadcastListInfo, WAProfilePictureChange, WAChat, ChatModification } from './Constants' +import { + WAMessage, + WANode, + WAMetric, + WAFlag, +} from '../WAConnection/Constants' +import { generateProfilePicture, waChatUniqueKey, whatsappID, unixTimestampSeconds } from './Utils' + +// All user related functions -- get profile picture, set status etc. + +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) + /** + * Tell someone about your presence -- online, typing, offline etc. + * @param jid the ID of the person/group who you are updating + * @param type your presence + */ + async updatePresence(jid: string | null, type: Presence) { + const json = [ + 'action', + { epoch: this.msgCount.toString(), type: 'set' }, + [['presence', { type: type, to: jid }, null]], + ] + return this.query({json, binaryTags: [WAMetric.group, WAFlag.acknowledge]}) as Promise<{ status: number }> + } + /** Request an update on the presence of a user */ + 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.id]}) + return status + } + async setStatus (status: string) { + const response = await this.setQuery ( + [ + [ + 'status', + null, + Buffer.from (status, 'utf-8') + ] + ] + ) + this.emit ('user-status-update', { jid: this.user.id, status }) + return response + } + /** Get your contacts */ + async getContacts() { + const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null] + const response = await this.query({ json, binaryTags: [6, WAFlag.ignore] }) // this has to be an encrypted query + return response + } + /** 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 + if (Array.isArray(response[2])) { + return response[2].map (row => ( + { + unread: row[1]?.unread, + count: row[1]?.count, + messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : [] + } as {unread: number, count: number, messages: WAMessage[]} + )) + } + return [] + } + /** Fetch your chats */ + async getChats() { + const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null] + return this.query({ json, binaryTags: [5, WAFlag.ignore]}) // 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 } + /** 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} + const chat = this.chats.get (jid) + if (chat) { + this.chats.delete (chat) + this.emit ('chat-update', { jid, delete: 'true' }) + } + return response + } + /** + * Load chats in a paginated manner + gets the profile picture + * @param before chats before the given cursor + * @param count number of results to return + * @param searchString optionally search for users + * @returns the chats & the cursor to fetch the next page + */ + async loadChats (count: number, before: number | null, searchString?: string) { + let db = this.chats + if (searchString) { + db = db.filter (value => value.title?.includes (searchString) || value.jid?.startsWith(searchString)) + } + const chats = db.paginated (before, count) + await Promise.all ( + chats.map (async chat => ( + chat.imgUrl === undefined && await this.setProfilePicture (chat) + )) + ) + const cursor = (chats[chats.length-1] && chats.length >= count) ? waChatUniqueKey (chats[chats.length-1]) : null + return { chats, cursor } + } + async updateProfilePicture (jid: string, img: Buffer) { + jid = whatsappID (jid) + const data = await generateProfilePicture (img) + const tag = this.generateMessageTag () + const query: WANode = [ + 'picture', + { jid: jid, id: tag, type: 'set' }, + [ + ['image', null, data.img], + ['preview', null, data.preview] + ] + ] + const response = await (this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise) + if (jid === this.user.id) this.user.imgUrl = response.eurl + else if (this.chats.get(jid)) { + this.chats.get(jid).imgUrl = response.eurl + this.emit ('chat-update', { jid, imgUrl: response.eurl }) + } + return response + } + /** + * Modify a given chat (archive, pin etc.) + * @param jid the ID of the person/group you are modifiying + * @param options.stamp the timestamp of pinning/muting the chat. Is required when unpinning/unmuting + */ + async modifyChat (jid: string, type: ChatModification, options: {stamp: Date | string} = {stamp: new Date()}) { + jid = whatsappID (jid) + let chatAttrs: Record = {jid: jid} + if ((type === ChatModification.unpin || type === ChatModification.unmute) && !options?.stamp) { + throw new Error('options.stamp must be set to the timestamp of the time of pinning/unpinning of the chat') + } + const strStamp = options.stamp && + (typeof options.stamp === 'string' ? options.stamp : unixTimestampSeconds(options.stamp).toString ()) + switch (type) { + case ChatModification.pin: + case ChatModification.mute: + chatAttrs.type = type + chatAttrs[type] = strStamp + break + case ChatModification.unpin: + case ChatModification.unmute: + chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin' + chatAttrs.previous = strStamp + break + default: + chatAttrs.type = type + break + } + let response = await this.setQuery ([['chat', chatAttrs, null]]) as {status: number, stamp: string} + response.stamp = strStamp + + const chat = this.chats.get (jid) + if (chat) { + if (type.includes('un')) { + type = type.replace ('un', '') as ChatModification + delete chat[type.replace('un','')] + this.emit ('chat-update', { jid, [type]: false }) + } else { + chat[type] = chatAttrs[type] || 'true' + this.emit ('chat-update', { jid, [type]: chat[type] }) + } + } + return response + } +} diff --git a/src/WAConnection/5.Messages.ts b/src/WAConnection/6.MessagesSend.ts similarity index 51% rename from src/WAConnection/5.Messages.ts rename to src/WAConnection/6.MessagesSend.ts index 016cd03..7e972f2 100644 --- a/src/WAConnection/5.Messages.ts +++ b/src/WAConnection/6.MessagesSend.ts @@ -1,4 +1,4 @@ -import {WAConnection as Base} from './4.User' +import {WAConnection as Base} from './5.User' import fetch from 'node-fetch' import {promises as fs} from 'fs' import { @@ -9,215 +9,18 @@ import { MediaPathMap, WALocationMessage, WAContactMessage, - WASendMessageResponse, - WAMessageKey, - ChatModification, - MessageInfo, WATextMessage, - WAUrlInfo, - WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE + WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE } from './Constants' -import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID } from './Utils' +import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds } from './Utils' export class WAConnection extends Base { - /** Get the message info, who has read it, who its been delivered to */ - async messageInfo (jid: string, messageID: string) { - const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null] - const response = (await this.queryExpecting200 (query, [22, WAFlag.ignore]))[2] - - const info: MessageInfo = {reads: [], deliveries: []} - if (response) { - //console.log (response) - const reads = response.filter (node => node[0] === 'read') - if (reads[0]) { - info.reads = reads[0][2].map (item => item[1]) - } - const deliveries = response.filter (node => node[0] === 'delivery') - if (deliveries[0]) { - info.deliveries = deliveries[0][2].map (item => item[1]) - } - } - return info - } - /** - * Send a read receipt to the given ID for a certain message - * @param jid the ID of the person/group whose message you want to mark read - * @param messageID optionally, the message ID - * @param type whether to read or unread the message - */ - async sendReadReceipt(jid: string, messageID?: string, type: 'read' | 'unread' = 'read') { - const attributes = { - jid: jid, - count: type === 'read' ? '1' : '-2', - index: messageID, - owner: messageID ? 'false' : null - } - return this.setQuery ([['read', attributes, null]]) - } - /** - * Modify a given chat (archive, pin etc.) - * @param jid the ID of the person/group you are modifiying - * @param options.stamp the timestamp of pinning/muting the chat. Is required when unpinning/unmuting - */ - async modifyChat (jid: string, type: ChatModification, options: {stamp: Date | string} = {stamp: new Date()}) { - let chatAttrs: Record = {jid: jid} - if ((type === ChatModification.unpin || type === ChatModification.unmute) && !options?.stamp) { - throw new Error('options.stamp must be set to the timestamp of the time of pinning/unpinning of the chat') - } - const strStamp = options.stamp && - (typeof options.stamp === 'string' ? options.stamp : Math.round(options.stamp.getTime ()/1000).toString ()) - switch (type) { - case ChatModification.pin: - case ChatModification.mute: - chatAttrs.type = type - chatAttrs[type] = strStamp - break - case ChatModification.unpin: - case ChatModification.unmute: - chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin' - chatAttrs.previous = strStamp - break - default: - chatAttrs.type = type - break - } - let response = await this.setQuery ([['chat', chatAttrs, null]]) as any - response.stamp = strStamp - return response as {status: number, stamp: string} - } - async loadMessage (jid: string, messageID: string) { - let messages - try { - messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: true}, false) - } catch { - messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: false}, false) - } - var index = null - if (messages.length > 0) index = messages[0].key - - const actual = await this.loadConversation (jid, 1, index) - return actual[0] - } - /** 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.queryExpecting200 (query, [26, WAFlag.ignore]) - - if (response[1]) response[1].jpegThumbnail = response[2] - const data = response[1] as WAUrlInfo - - const content = {text} as WATextMessage - content.canonicalUrl = data['canonical-url'] - content.matchedText = data['matched-text'] - content.jpegThumbnail = data.jpegThumbnail - content.description = data.description - content.title = data.title - content.previewType = 0 - return content - } - /** - * Search WhatsApp messages with a given text string - * @param txt the search string - * @param inJid the ID of the chat to search in, set to null to search all chats - * @param count number of results to return - * @param page page number of results (starts from 1) - */ - async searchMessages(txt: string, inJid: string | null, count: number, page: number) { - const json = [ - 'query', - { - epoch: this.msgCount.toString(), - type: 'search', - search: txt, - count: count.toString(), - page: page.toString(), - jid: inJid - }, - null, - ] - const response: WANode = await this.queryExpecting200(json, [WAMetric.group, WAFlag.ignore]) // encrypt and send off - const messages = response[2] ? response[2].map (row => row[2]) : [] - return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] } - } - /** - * Delete a message in a chat for yourself - * @param messageKey key of the message you want to delete - */ - async clearMessage (messageKey: WAMessageKey) { - const tag = Math.round(Math.random ()*1000000) - const attrs: WANode = [ - 'chat', - { jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' }, - [ - ['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null] - ] - ] - return this.setQuery ([attrs]) - } - /** - * Fetches the latest url & media key for the given message. - * You may need to call this when the message is old & the content is deleted off of the WA servers - * @param message - */ - async updateMediaMessage (message: WAMessage) { - const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage - if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message) - - const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null] - const response = await this.query (query, [WAMetric.queryMedia, WAFlag.ignore]) - if (parseInt(response[1].code) !== 200) throw new BaileysError ('unexpected status ' + response[1].code, response) - - Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message - } - /** - * Delete a message in a chat for everyone - * @param id the person or group where you're trying to delete the message - * @param messageKey key of the message you want to delete - */ - async deleteMessage (id: string, messageKey: WAMessageKey) { - const json: WAMessageContent = { - protocolMessage: { - key: messageKey, - type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE - } - } - const waMessage = this.generateWAMessage (id, json, {}) - await this.relayWAMessage (waMessage) - return waMessage - } - /** - * Forward a message like WA does - * @param id the id to forward the message to - * @param message the message to forward - * @param forceForward will show the message as forwarded even if it is from you - */ - async forwardMessage(id: string, message: WAMessage, forceForward: boolean=false) { - const content = message.message - if (!content) throw new Error ('no content in message') - - let key = Object.keys(content)[0] - - let score = content[key].contextInfo?.forwardingScore || 0 - score += message.key.fromMe && !forceForward ? 0 : 1 - if (key === MessageType.text) { - content[MessageType.extendedText] = { text: content[key] } - delete content[MessageType.text] - - key = MessageType.extendedText - } - if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true } - else content[key].contextInfo = {} - - const waMessage = this.generateWAMessage (id, content, {}) - await this.relayWAMessage (waMessage) - return waMessage - } /** * Send a message to the given ID (can be group, single, or broadcast) - * @param id - * @param message - * @param type - * @param options + * @param id the id to send to + * @param message the message can be a buffer, plain string, location message, extended text message + * @param type type of message + * @param options Extra options */ async sendMessage( id: string, @@ -293,7 +96,7 @@ export class WAConnection extends Base { await generateThumbnail(buffer, mediaType, options) // send a query JSON to obtain the url & auth token to upload our media - const json = (await this.query(['query', 'mediaConn'])).media_conn + const json = (await this.query({json: ['query', 'mediaConn']})).media_conn const auth = json.auth // the auth token let hostname = 'https://' + json.hosts[0].hostname // first hostname available hostname += MediaPathMap[mediaType] + '/' + fileEncSha256B64 // append path @@ -331,7 +134,7 @@ export class WAConnection extends Base { id = whatsappID (id) const key = Object.keys(message)[0] - const timestamp = options.timestamp.getTime()/1000 + const timestamp = unixTimestampSeconds(options.timestamp) const quoted = options.quoted if (options.contextInfo) message[key].contextInfo = options.contextInfo @@ -361,7 +164,7 @@ export class WAConnection extends Base { message: message, messageTimestamp: timestamp, messageStubParameters: [], - participant: id.includes('@g.us') ? this.userMetaData.id : null, + participant: id.includes('@g.us') ? this.user.id : null, status: WA_MESSAGE_STATUS_TYPE.PENDING } return messageJSON as WAMessage @@ -369,8 +172,22 @@ export class WAConnection extends Base { /** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */ async relayWAMessage(message: WAMessage) { const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]] - const flag = message.key.remoteJid === this.userMetaData.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself - await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id) + const flag = message.key.remoteJid === this.user.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself + await this.query({json, binaryTags: [WAMetric.message, flag], tag: message.key.id}) + await this.chatAddMessageAppropriate (message) + } + /** + * Fetches the latest url & media key for the given message. + * You may need to call this when the message is old & the content is deleted off of the WA servers + * @param message + */ + async updateMediaMessage (message: WAMessage) { + const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage + if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message) + + const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null] + const response = await this.query ({json: query, binaryTags: [WAMetric.queryMedia, WAFlag.ignore], expect200: true}) + Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message } /** * Securely downloads the media from the message. diff --git a/src/WAConnection/7.MessagesExtra.ts b/src/WAConnection/7.MessagesExtra.ts new file mode 100644 index 0000000..b07283d --- /dev/null +++ b/src/WAConnection/7.MessagesExtra.ts @@ -0,0 +1,261 @@ +import {WAConnection as Base} from './6.MessagesSend' +import { + MessageType, + WAMessageKey, + MessageInfo, + WATextMessage, + WAUrlInfo, + WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE +} from './Constants' +import { whatsappID } from './Utils' + +export class WAConnection extends Base { + + async loadAllUnreadMessages () { + const tasks = this.chats.all() + .filter(chat => chat.count > 0) + .map (chat => this.loadMessages(chat.jid, chat.count)) + const list = await Promise.all (tasks) + const combined: WAMessage[] = [] + list.forEach (({messages}) => combined.push(...messages)) + return combined + } + + /** Get the message info, who has read it, who its been delivered to */ + async messageInfo (jid: string, messageID: string) { + const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null] + const response = (await this.query ({json: query, binaryTags: [22, WAFlag.ignore], expect200: true}))[2] + + const info: MessageInfo = {reads: [], deliveries: []} + if (response) { + //console.log (response) + const reads = response.filter (node => node[0] === 'read') + if (reads[0]) { + info.reads = reads[0][2].map (item => item[1]) + } + const deliveries = response.filter (node => node[0] === 'delivery') + if (deliveries[0]) { + info.deliveries = deliveries[0][2].map (item => item[1]) + } + } + return info + } + /** + * Read/unread messages of a chat; will mark the entire chat read by default + * @param jid the ID of the person/group whose message you want to mark read + * @param messageID optionally, the message ID + * @param count number of messages to read, set to < 0 to unread a message + */ + async sendReadReceipt(jid: string, messageID?: string, count?: number) { + jid = whatsappID (jid) + const chat = this.chats.get(jid) + count = count || Math.abs(chat?.count || 1) + + const attributes = { + jid: jid, + count: count.toString(), + index: messageID, + owner: messageID ? 'false' : null + } + const read = await this.setQuery ([['read', attributes, null]]) + if (chat) { + chat.count = count < 0 ? -1 : chat.count-count + this.emit ('chat-update', {jid, count: chat.count}) + } + return read + } + /** + * Load the conversation with a group or person + * @param count the number of messages to load + * @param before the data for which message to offset the query by + * @param mostRecentFirst retreive the most recent message first or retreive from the converation start + */ + async loadMessages ( + jid: string, + count: number, + before: { id?: string; fromMe?: boolean } = null, + mostRecentFirst = true + ) { + jid = whatsappID(jid) + + const retreive = async (count: number, indexMessage: any) => { + const json = [ + 'query', + { + epoch: this.msgCount.toString(), + type: 'message', + jid: jid, + kind: mostRecentFirst ? 'before' : 'after', + count: count.toString(), + index: indexMessage?.id, + owner: indexMessage?.fromMe === false ? 'false' : 'true', + }, + null, + ] + const response = await this.query({json, binaryTags: [WAMetric.queryMessages, WAFlag.ignore], expect200: true}) + const messages = response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : [] + + return messages + } + const chat = this.chats.get (jid) + + let messages: WAMessage[] + if (!before && chat && mostRecentFirst) { + messages = chat.messages + if (messages.length < count) { + const extra = await retreive (count-messages.length, messages[0]?.key) + messages.unshift (...extra) + } + } else messages = await retreive (count, before) + + const cursor = messages[0] && messages[0].key + return {messages, cursor} + } + /** + * Load the entire friggin conversation with a group or person + * @param onMessage callback for every message retreived + * @param chunkSize the number of messages to load in a single request + * @param mostRecentFirst retreive the most recent message first or retreive from the converation start + */ + loadAllMessages(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) { + let offsetID = null + const loadMessage = async () => { + const {messages} = await this.loadMessages(jid, chunkSize, offsetID, mostRecentFirst) + // callback with most recent message first (descending order of date) + let lastMessage + if (mostRecentFirst) { + for (let i = messages.length - 1; i >= 0; i--) { + onMessage(messages[i]) + lastMessage = messages[i] + } + } else { + for (let i = 0; i < messages.length; i++) { + onMessage(messages[i]) + lastMessage = messages[i] + } + } + // if there are still more messages + if (messages.length >= chunkSize) { + offsetID = lastMessage.key // get the last message + return new Promise((resolve, reject) => { + // send query after 200 ms + setTimeout(() => loadMessage().then(resolve).catch(reject), 200) + }) + } + } + return loadMessage() as Promise + } + /** Load a single message specified by the ID */ + async loadMessage (jid: string, messageID: string) { + let messages: WAMessage[] + try { + messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: true}, false)).messages + } catch { + messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: false}, false)).messages + } + var index = null + if (messages.length > 0) index = messages[0].key + + const actual = await this.loadMessages (jid, 1, index) + return actual.messages[0] + } + /** 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}) + + if (response[1]) response[1].jpegThumbnail = response[2] + const data = response[1] as WAUrlInfo + + const content = {text} as WATextMessage + content.canonicalUrl = data['canonical-url'] + content.matchedText = data['matched-text'] + content.jpegThumbnail = data.jpegThumbnail + content.description = data.description + content.title = data.title + content.previewType = 0 + return content + } + /** + * Search WhatsApp messages with a given text string + * @param txt the search string + * @param inJid the ID of the chat to search in, set to null to search all chats + * @param count number of results to return + * @param page page number of results (starts from 1) + */ + async searchMessages(txt: string, inJid: string | null, count: number, page: number) { + const json = [ + 'query', + { + epoch: this.msgCount.toString(), + type: 'search', + search: txt, + count: count.toString(), + page: page.toString(), + jid: inJid + }, + null, + ] + const response: WANode = await this.query({json, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true}) // encrypt and send off + const messages = response[2] ? response[2].map (row => row[2]) : [] + return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] } + } + /** + * Delete a message in a chat for yourself + * @param messageKey key of the message you want to delete + */ + async clearMessage (messageKey: WAMessageKey) { + const tag = Math.round(Math.random ()*1000000) + const attrs: WANode = [ + 'chat', + { jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' }, + [ + ['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null] + ] + ] + return this.setQuery ([attrs]) + } + /** + * Delete a message in a chat for everyone + * @param id the person or group where you're trying to delete the message + * @param messageKey key of the message you want to delete + */ + async deleteMessage (id: string, messageKey: WAMessageKey) { + const json: WAMessageContent = { + protocolMessage: { + key: messageKey, + type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE + } + } + const waMessage = this.generateWAMessage (id, json, {}) + await this.relayWAMessage (waMessage) + return waMessage + } + /** + * Forward a message like WA does + * @param id the id to forward the message to + * @param message the message to forward + * @param forceForward will show the message as forwarded even if it is from you + */ + async forwardMessage(id: string, message: WAMessage, forceForward: boolean=false) { + const content = message.message + if (!content) throw new Error ('no content in message') + + let key = Object.keys(content)[0] + + let score = content[key].contextInfo?.forwardingScore || 0 + score += message.key.fromMe && !forceForward ? 0 : 1 + if (key === MessageType.text) { + content[MessageType.extendedText] = { text: content[key] } + delete content[MessageType.text] + + key = MessageType.extendedText + } + if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true } + else content[key].contextInfo = {} + + const waMessage = this.generateWAMessage (id, content, {}) + await this.relayWAMessage (waMessage) + return waMessage + } +} \ No newline at end of file diff --git a/src/WAConnection/6.Groups.ts b/src/WAConnection/8.Groups.ts similarity index 74% rename from src/WAConnection/6.Groups.ts rename to src/WAConnection/8.Groups.ts index c5db5a3..610aa2a 100644 --- a/src/WAConnection/6.Groups.ts +++ b/src/WAConnection/8.Groups.ts @@ -1,4 +1,4 @@ -import {WAConnection as Base} from './5.Messages' +import {WAConnection as Base} from './7.MessagesExtra' import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants' import { GroupSettingChange } from './Constants' import { generateMessageID } from '../WAConnection/Utils' @@ -10,23 +10,23 @@ export class WAConnection extends Base { const json: WANode = [ 'group', { - author: this.userMetaData.id, + author: this.user.id, id: tag, type: type, jid: jid, subject: subject, }, - participants ? participants.map(str => ['participant', { jid: str }, null]) : additionalNodes, + participants ? participants.map(jid => ['participant', { jid }, null]) : additionalNodes, ] - const result = await this.setQuery ([json], [WAMetric.group, WAFlag.ignore], tag) + const result = await this.setQuery ([json], [WAMetric.group, 136], tag) return result } /** Get the metadata of the group */ - groupMetadata = (jid: string) => this.queryExpecting200(['query', 'GroupMetadata', jid]) as Promise + groupMetadata = (jid: string) => this.query({json: ['query', 'GroupMetadata', jid], expect200: true}) as Promise /** Get the metadata (works after you've left the group also) */ groupMetadataMinimal = async (jid: string) => { const query = ['query', {type: 'group', jid: jid, epoch: this.msgCount.toString()}, null] - const response = await this.queryExpecting200(query, [WAMetric.group, WAFlag.ignore]) + const response = await this.query({json: query, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true}) const json = response[2][0] const creatorDesc = json[1] const participants = json[2] ? json[2].filter (item => item[0] === 'participant') : [] @@ -46,20 +46,39 @@ export class WAConnection extends Base { * @param title like, the title of the group * @param participants people to include in the group */ - groupCreate = (title: string, participants: string[]) => - this.groupQuery('create', null, title, participants) as Promise + groupCreate = async (title: string, participants: string[]) => { + const response = await this.groupQuery('create', null, title, participants) as WAGroupCreateResponse + await this.chatAdd (response.gid, title) + return response + } /** * Leave a group * @param jid the ID of the group */ - groupLeave = (jid: string) => this.groupQuery('leave', jid) as Promise<{ status: number }> + groupLeave = async (jid: string) => { + const response = await this.groupQuery('leave', jid) + + const chat = this.chats.get (jid) + if (chat) chat.read_only = 'true' + + return response + } /** * Update the subject of the group * @param {string} jid the ID of the group * @param {string} title the new title of the group */ - groupUpdateSubject = (jid: string, title: string) => - this.groupQuery('subject', jid, title) as Promise<{ status: number }> + groupUpdateSubject = async (jid: string, title: string) => { + const chat = this.chats.get (jid) + if (chat?.title === title) throw new Error ('redundant change') + const response = await this.groupQuery('subject', jid, title) + if (chat) { + chat.title = title + //this.emit ('chat-update', {jid, title}) + } + return response + } + /** * Update the group description * @param {string} jid the ID of the group @@ -72,7 +91,8 @@ export class WAConnection extends Base { {id: generateMessageID(), prev: metadata?.descId}, Buffer.from (description, 'utf-8') ] - return this.groupQuery ('description', jid, null, null, [node]) + const response = await this.groupQuery ('description', jid, null, null, [node]) + return response } /** * Add somebody to the group @@ -114,7 +134,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.queryExpecting200(json) + const response = await this.query({json}) return response.code as string } } \ No newline at end of file diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 6090263..4a4b6f1 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -1,6 +1,32 @@ import { WA } from '../Binary/Constants' import { proto } from '../../WAMessage/WAMessage' +export const KEEP_ALIVE_INTERVAL_MS = 20*1000 + +// export the WAMessage Prototypes +export { proto as WAMessageProto } +export type WANode = WA.Node +export type WAMessage = proto.WebMessageInfo +export type WAMessageContent = proto.IMessage +export type WAContactMessage = proto.ContactMessage +export type WAMessageKey = proto.IMessageKey +export type WATextMessage = proto.ExtendedTextMessage +export type WAContextInfo = proto.IContextInfo +export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE +export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS + +export interface WALocationMessage { + degreesLatitude: number + degreesLongitude: number + address?: string +} +/** Reverse stub type dictionary */ +export const WAMessageType = function () { + const types = WA_MESSAGE_STUB_TYPE + const dict: Record = {} + Object.keys(types).forEach(element => dict[ types[element] ] = element) + return dict +}() export class BaileysError extends Error { status?: number @@ -13,7 +39,25 @@ export class BaileysError extends Error { this.context = context } } +export interface WAQuery { + json: any[] | WANode + binaryTags?: WATag + timeoutMs?: number + tag?: string + expect200?: boolean + waitForOpen?: boolean +} +export enum ReconnectMode { + /** does not reconnect */ + off = 0, + /** reconnects only when the connection is 'lost' or 'closed' */ + onConnectionLost = 1, + /** reconnects on all disconnects, including take overs */ + onAllErrors = 2 +} +export type WAConnectionState = 'open' | 'connecting' | 'closed' +export type DisconnectReason = 'closed' | 'lost' | 'replaced' | 'intentional' export enum MessageLogLevel { none=0, info=1, @@ -40,21 +84,14 @@ export interface AuthenticationCredentialsBrowser { WAToken1: string WAToken2: string } -export interface UserMetaData { +export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials +export interface WAUser { id: string name: string phone: string + imgUrl: string } -export type WANode = WA.Node -export type WAMessage = proto.WebMessageInfo -export type WAMessageContent = proto.IMessage -export enum WAConnectionMode { - /** Baileys will let requests through after a simple connect */ - onlyRequireValidation = 0, - /** Baileys will let requests through only after chats & contacts are received */ - requireChatsAndContacts = 1 -} export interface WAGroupCreateResponse { status: number gid?: string @@ -68,6 +105,10 @@ export interface WAGroupMetadata { desc?: string descOwner?: string descId?: string + /** is set when the group only allows admins to change group settings */ + restrict?: 'true' + /** is set when the group only allows admins to write messages */ + announce?: 'true' participants: [{ id: string; isAdmin: boolean; isSuperAdmin: boolean }] } export interface WAGroupModification { @@ -83,16 +124,22 @@ export interface WAContact { short?: string } export interface WAChat { - t: string + jid: string + + t: number + /** number of unread messages, is < 0 if the chat is manually marked unread */ count: number archive?: 'true' | 'false' read_only?: 'true' | 'false' mute?: string pin?: string spam: 'false' | 'true' - jid: string modify_tag: string + + // Baileys added properties messages: WAMessage[] + title?: string + imgUrl?: string } export enum WAMetric { debugLog = 1, @@ -133,8 +180,6 @@ export enum WAFlag { } /** Tag used with binary queries */ export type WATag = [WAMetric, WAFlag] -// export the WAMessage Prototype as well -export { proto as WAMessageProto } from '../../WAMessage/WAMessage' /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ export enum Presence { @@ -263,22 +308,21 @@ export interface WASendMessageResponse { messageID: string message: WAMessage } -export interface WALocationMessage { - degreesLatitude: number - degreesLongitude: number - address?: string -} -export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE -export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS - -/** Reverse stub type dictionary */ -export const WAMessageType = function () { - const types = WA_MESSAGE_STUB_TYPE - const dict: Record = {} - Object.keys(types).forEach(element => dict[ types[element] ] = element) - return dict -}() -export type WAContactMessage = proto.ContactMessage -export type WAMessageKey = proto.IMessageKey -export type WATextMessage = proto.ExtendedTextMessage -export type WAContextInfo = proto.IContextInfo +export type BaileysEvent = + 'open' | + 'connecting' | + 'closed' | + 'qr' | + 'connection-phone-change' | + 'user-presence-update' | + 'user-status-update' | + 'chat-new' | + 'chat-update' | + 'message-new' | + 'message-update' | + 'group-participants-add' | + 'group-participants-remove' | + 'group-participants-promote' | + 'group-participants-demote' | + 'group-settings-update' | + 'group-description-update' \ No newline at end of file diff --git a/src/WAConnection/Utils.ts b/src/WAConnection/Utils.ts index a1fb053..2b69ffd 100644 --- a/src/WAConnection/Utils.ts +++ b/src/WAConnection/Utils.ts @@ -27,11 +27,10 @@ function hashCode(s: string) { h = Math.imul(31, h) + s.charCodeAt(i) | 0; return h; } -export const waChatUniqueKey = (c: WAChat) => ((+c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending +export const waChatUniqueKey = (c: WAChat) => ((c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending +export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net') +export const isGroupID = (jid: string) => jid?.includes ('@g.us') -export function whatsappID (jid: string) { - return jid.replace ('@c.us', '@s.whatsapp.net') -} /** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ export function aesDecrypt(buffer: Buffer, key: Buffer) { return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) @@ -67,25 +66,49 @@ export function hkdf(buffer: Buffer, expandedLength: number, info = null) { export function randomBytes(length) { return Crypto.randomBytes(length) } -export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout)) +/** unix timestamp of a date in seconds */ +export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000) + +export const delay = (ms: number) => delayCancellable (ms).delay +export const delayCancellable = (ms: number) => { + let timeout: NodeJS.Timeout + let reject: (error) => void + const delay: Promise = new Promise((resolve, _reject) => { + timeout = setTimeout(resolve, ms) + reject = _reject + }) + const cancel = () => { + clearTimeout (timeout) + reject (new Error('cancelled')) + } + return { delay, cancel } +} +export async function promiseTimeout(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) { + if (!ms) return new Promise (promise) -export async function promiseTimeout(ms: number, promise: Promise) { - if (!ms) return promise // Create a promise that rejects in milliseconds - let timeoutI - const timeout = new Promise( - (_, reject) => timeoutI = setTimeout(() => reject(new BaileysError ('Timed out', promise)), ms) - ) + const {delay, cancel} = delayCancellable (ms) + + let pReject: (error) => void + const p = new Promise ((resolve, reject) => { + promise (resolve, reject) + pReject = reject + }) + try { - const content = await Promise.race([promise, timeout]) + const content = await Promise.race([ + p, + delay.then(() => pReject(new BaileysError('timed out', p))) + ]) + cancel () return content as T } finally { - clearTimeout (timeoutI) + cancel () } } // whatsapp requires a message tag for every message, we just use the timestamp as one export function generateMessageTag(epoch?: number) { - let tag = Math.round(new Date().getTime()/1000).toString() + let tag = unixTimestampSeconds().toString() if (epoch) tag += '.--' + epoch // attach epoch if provided return tag } diff --git a/src/WAConnection/WAConnection.ts b/src/WAConnection/WAConnection.ts index 92d7a98..08502c1 100644 --- a/src/WAConnection/WAConnection.ts +++ b/src/WAConnection/WAConnection.ts @@ -1,3 +1,3 @@ -export * from './6.Groups' +export * from './8.Groups' export * from './Utils' export * from './Constants' \ No newline at end of file