diff --git a/Example/example.ts b/Example/example.ts index 6b105a8..155b3ff 100644 --- a/Example/example.ts +++ b/Example/example.ts @@ -1,40 +1,39 @@ import { - WAClient, + WAConnection, MessageType, - decodeMediaMessage, Presence, MessageOptions, Mimetype, WALocationMessage, MessageLogLevel, WAMessageType, -} from '../src/WAClient/WAClient' +} from '../src/WAConnection/WAConnection' import * as fs from 'fs' async function example() { - const client = new WAClient() // instantiate - client.autoReconnect = true // auto reconnect on disconnect - client.logLevel = MessageLogLevel.info // set to unhandled to see what kind of stuff you can implement + const conn = new WAConnection() // instantiate + conn.autoReconnect = true // auto reconnect on disconnect + 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 client.connect('./auth_info.json', 20 * 1000) + 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)) console.log('oh hello ' + user.name + ' (' + user.id + ')') console.log('you have ' + chats.all().length + ' chats & ' + contacts.length + ' contacts') console.log ('you have ' + unread.length + ' unread messages') - const authInfo = client.base64EncodedAuthInfo() // get all the auth info we need to restore this session + 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 */ - client.setOnPresenceUpdate(json => console.log(json.id + ' presence is ' + json.type)) - client.setOnMessageStatusChange(json => { + 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}`) }) // set to false to NOT relay your own sent messages - client.setOnUnreadMessage(true, async (m) => { + conn.setOnUnreadMessage(true, async (m) => { const messageStubType = WAMessageType[m.messageStubType] || 'MESSAGE' console.log('got notification of type: ' + messageStubType) @@ -65,7 +64,7 @@ async function example() { const locMessage = m.message[messageType] as WALocationMessage console.log(`${sender} sent location (lat: ${locMessage.degreesLatitude}, long: ${locMessage.degreesLongitude})`) - decodeMediaMessage(m.message, './Media/media_loc_thumb_in_' + m.key.id) // save location thumbnail + await conn.downloadAndSaveMediaMessage(m, './Media/media_loc_thumb_in_' + m.key.id) // save location thumbnail if (messageType === MessageType.liveLocation) { console.log(`${sender} sent live location for duration: ${m.duration/60}`) @@ -75,7 +74,7 @@ async function example() { // decode, decrypt & save the media. // The extension to the is applied automatically based on the media type try { - const savedFile = await decodeMediaMessage(m.message, './Media/media_in_' + m.key.id) + const savedFile = await conn.downloadAndSaveMediaMessage(m, './Media/media_in_' + m.key.id) console.log(sender + ' sent media, saved at: ' + savedFile) } catch (err) { console.log('error in decoding message: ' + err) @@ -83,20 +82,18 @@ async function example() { } // send a reply after 3 seconds setTimeout(async () => { - await client.sendReadReceipt(m.key.remoteJid, m.key.id) // send read receipt - await client.updatePresence(m.key.remoteJid, Presence.available) // tell them we're available - await client.updatePresence(m.key.remoteJid, Presence.composing) // tell them we're composing + await conn.sendReadReceipt(m.key.remoteJid, m.key.id) // send read receipt + await conn.updatePresence(m.key.remoteJid, Presence.available) // tell them we're available + await conn.updatePresence(m.key.remoteJid, Presence.composing) // tell them we're composing const options: MessageOptions = { quoted: m } let content let type: MessageType const rand = Math.random() - if (rand > 0.66) { - // choose at random + if (rand > 0.66) { // choose at random content = 'hello!' // send a "hello!" & quote the message recieved type = MessageType.text - } else if (rand > 0.33) { - // choose at random + } else if (rand > 0.33) { // choose at random content = { degreesLatitude: 32.123123, degreesLongitude: 12.12123123 } type = MessageType.location } else { @@ -104,21 +101,21 @@ async function example() { options.mimetype = Mimetype.gif type = MessageType.video } - const response = await client.sendMessage(m.key.remoteJid, content, type, options) - console.log("sent message with ID '" + response.messageID + "' successfully: " + (response.status === 200)) + const response = await conn.sendMessage(m.key.remoteJid, content, type, options) + console.log("sent message with ID '" + response.key.id + "' successfully: " + (response.status === 200)) }, 3 * 1000) }) /* example of custom functionality for tracking battery */ - client.registerCallback(['action', null, 'battery'], json => { + conn.registerCallback(['action', null, 'battery'], json => { const batteryLevelStr = json[2][0][1].value const batterylevel = parseInt(batteryLevelStr) console.log('battery level: ' + batterylevel) }) - client.setOnUnexpectedDisconnect(reason => { + conn.setOnUnexpectedDisconnect(reason => { if (reason === 'replaced') { // uncomment to reconnect whenever the connection gets taken over from somewhere else - // await client.connect () + // await conn.connect () } else { console.log ('oh no got disconnected: ' + reason) } diff --git a/README.md b/README.md index 99471b2..6647ba1 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ 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` +2. stabl-ish w quicker fixes & latest features: `npm install github:adiwajshing/baileys` (major changes incoming right now) Then import in your code using: ``` ts -import { WAClient } from '@adiwajshing/baileys' +import { WAConnection } from '@adiwajshing/baileys' ``` ## Unit Tests @@ -36,11 +36,11 @@ Set the phone number you can randomly send messages to in a `.env` file with `TE ## Connecting ``` ts -import { WAClient } from '@adiwajshing/baileys' +import { WAConnection } from '@adiwajshing/baileys' async function connectToWhatsApp () { - const client = new WAClient() - const [user, chats, contacts] = await client.connect () + 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") @@ -61,14 +61,14 @@ 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 { WAClient } from '@adiwajshing/baileys' +import { WAConnection } from '@adiwajshing/baileys' async function connectToWhatsApp () { - const client = new WAClient() - const user = await client.connectSlim () + const conn = new WAConnection() + const user = await conn.connectSlim () console.log ("oh hello " + user.name + " (" + user.id + ")") - client.receiveChatsAndContacts () // wait for chats & contacts in the background + conn.receiveChatsAndContacts () // wait for chats & contacts in the background .then (([chats, contacts]) => { console.log ("you have " + chats.all().length + " chats and " + contacts.length + " contacts") }) @@ -91,18 +91,18 @@ So, do the following the first time you connect: ``` ts import * as fs from 'fs' -const client = new WAClient() -client.connectSlim() // connect first +const conn = new WAConnection() +conn.connectSlim() // connect first .then (user => { - const creds = client.base64EncodedAuthInfo () // contains all the keys you need to restore a session + 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 client = new WAClient() -client.connectSlim('./auth_info.json') // will load JSON credentials from file +const conn = new WAConnection() +conn.connectSlim('./auth_info.json') // will load JSON credentials from file .then (user => { // yay connected without scanning QR }) @@ -115,8 +115,8 @@ client.connectSlim('./auth_info.json') // will load JSON credentials from file If you're considering switching from a Chromium/Puppeteer based library, you can use WhatsApp Web's Browser credentials to restore sessions too: ``` ts -client.loadAuthInfoFromBrowser ('./auth_info_browser.json') -client.connectSlim(null, 20*1000) // use loaded credentials & timeout in 20s +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 }) @@ -127,25 +127,25 @@ See the browser credentials type [here](/src/WAConnection/Constants.ts). If you want to do some custom processing with the QR code used to authenticate, you can override the following method: ``` ts -client.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { +conn.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { const str = ref + ',' + publicKey + ',' + clientID // the QR string // Now, use 'str' to display in QR UI or send somewhere } -const user = await client.connect () +const user = await conn.connect () ``` If you need to regenerate the QR, you can also do so using: ``` ts let generateQR: async () => void // call generateQR on some timeout or error -client.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { +conn.onReadyForPhoneAuthentication = ([ref, publicKey, clientID]) => { generateQR = async () => { - ref = await client.generateNewQRCode () // returns a new ref code to use for QR generation + 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 client.connect () +const user = await conn.connect () ``` ## Handling Events @@ -155,7 +155,7 @@ Implement the following callbacks in your code: ``` 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 - client.setOnUnreadMessage (false, (m: WAMessage) => { + conn.setOnUnreadMessage (false, (m: WAMessage) => { // get what type of notification it is -- message, group add notification etc. const [notificationType, messageType] = getNotificationType(m) @@ -165,11 +165,11 @@ Implement the following callbacks in your code: ``` - Called when you recieve an update on someone's presence, they went offline or online ``` ts - client.setOnPresenceUpdate ((json: PresenceUpdate) => console.log(json.id + " presence is " + json.type)) + conn.setOnPresenceUpdate ((json: PresenceUpdate) => console.log(json.id + " presence is " + json.type)) ``` - Called when your message gets delivered or read ``` ts - client.setOnMessageStatusChange ((json: MessageStatusUpdate) => { + 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 @@ -179,7 +179,7 @@ Implement the following callbacks in your code: ``` - Called when the connection gets disconnected (either the server loses internet, the phone gets unpaired, or the connection is taken over from somewhere) ``` ts - client.setOnUnexpectedDisconnect (reason => console.log ("disconnected unexpectedly: " + reason) ) + conn.setOnUnexpectedDisconnect (reason => console.log ("disconnected unexpectedly: " + reason) ) ``` ## Sending Messages @@ -189,9 +189,9 @@ import { MessageType, MessageOptions, Mimetype } from '@adiwajshing/baileys' const id = 'abcd@s.whatsapp.net' // the WhatsApp ID // send a simple text! -client.sendMessage (id, 'oh hello there', MessageType.text) +conn.sendMessage (id, 'oh hello there', MessageType.text) // send a location! -client.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' @@ -199,11 +199,11 @@ const vcard = 'BEGIN:VCARD\n' // metadata of the contact card + 'ORG:Ashoka Uni;\n' // the organization of the contact + 'TEL;type=CELL;type=VOICE;waid=911234567890:+91 12345 67890\n' // WhatsApp ID + phone number + 'END:VCARD' -client.sendMessage(id, {displayname: "Jeff", vcard: vcard}, MessageType.contact) +conn.sendMessage(id, {displayname: "Jeff", vcard: vcard}, MessageType.contact) // send a gif const buffer = fs.readFileSync("Media/ma_gif.mp4") // load some gif const options: MessageOptions = {mimetype: Mimetype.gif, caption: "hello!"} // some metadata & caption -client.sendMessage(id, buffer, MessageType.video, options) +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. @@ -233,9 +233,9 @@ To note: ## Forwarding Messages ``` ts -const messages = await client.loadConversation ('1234@s.whatsapp.net', 1) +const messages = await conn.loadConversation ('1234@s.whatsapp.net', 1) const message = messages[0] // get the last message from this conversation -await client.forwardMessage ('455@s.whatsapp.net', message) // WA forward the message! +await conn.forwardMessage ('455@s.whatsapp.net', message) // WA forward the message! ``` ## Reading Messages @@ -243,10 +243,10 @@ await client.forwardMessage ('455@s.whatsapp.net', message) // WA forward the me const id = '1234-123@g.us' const messageID = 'AHASHH123123AHGA' // id of the message you want to read -await client.sendReadReceipt(id, messageID) // mark as read -await client.sendReadReceipt (id) // mark all messages in chat as read +await conn.sendReadReceipt(id, messageID) // mark as read +await conn.sendReadReceipt (id) // mark all messages in chat as read -await client.sendReadReceipt(id, null, 'unread') // mark the chat as unread +await conn.sendReadReceipt(id, null, 'unread') // mark the chat as unread ``` - `id` is in the same format as mentioned earlier. @@ -258,7 +258,7 @@ await client.sendReadReceipt(id, null, 'unread') // mark the chat as unread ``` ts import { Presence } from '@adiwajshing/baileys' -client.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 @@ -275,15 +275,15 @@ export enum Presence { If you want to save the media you received ``` ts import { MessageType, extensionForMediaMessage } from '@adiwajshing/baileys' -client.setOnUnreadMessage (false, async m => { +conn.setOnUnreadMessage (false, 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) { - const buffer = await client.downloadMediaMessage(m) // to decrypt & use as a buffer + const buffer = await conn.downloadMediaMessage(m) // to decrypt & use as a buffer - const savedFilename = await client.downloadAndSaveMediaMessage (m) // to decrypt & save to file + const savedFilename = await conn.downloadAndSaveMediaMessage (m) // to decrypt & save to file console.log(m.key.remoteJid + " sent media, saved at: " + savedFilename) } } @@ -293,29 +293,29 @@ client.setOnUnreadMessage (false, async m => { ``` ts const jid = '1234@s.whatsapp.net' // can also be a group -const response = await client.sendMessage (jid, 'hello!', MessageType.text) // send a message +const response = await conn.sendMessage (jid, 'hello!', MessageType.text) // send a message -await client.deleteMessage (jid, {id: response.messageID, remoteJid: jid, fromMe: true}) // will delete the sent message for everyone! -await client.clearMessage (jid, {id: response.messageID, remoteJid: jid, fromMe: true}) // will delete the sent message for only you! +await conn.deleteMessage (jid, {id: response.messageID, remoteJid: jid, fromMe: true}) // will delete the sent message for everyone! +await conn.clearMessage (jid, {id: response.messageID, remoteJid: jid, fromMe: true}) // will delete the sent message for only you! ``` ## Modifying Chats ``` ts const jid = '1234@s.whatsapp.net' // can also be a group -await client.modifyChat (jid, ChatModification.archive) // archive chat -await client.modifyChat (jid, ChatModification.unarchive) // unarchive chat +await conn.modifyChat (jid, ChatModification.archive) // archive chat +await conn.modifyChat (jid, ChatModification.unarchive) // unarchive chat -const response = await client.modifyChat (jid, ChatModification.pin) // pin the chat -await client.modifyChat (jid, ChatModification.unpin, {stamp: response.stamp}) +const response = await conn.modifyChat (jid, ChatModification.pin) // pin the chat +await conn.modifyChat (jid, ChatModification.unpin, {stamp: response.stamp}) const mutedate = new Date (new Date().getTime() + 8*60*60*1000) // mute for 8 hours in the future -await client.modifyChat (jid, ChatModification.mute, {stamp: mutedate}) // mute +await conn.modifyChat (jid, ChatModification.mute, {stamp: mutedate}) // mute setTimeout (() => { - client.modifyChat (jid, ChatModification.unmute, {stamp: mutedate}) + conn.modifyChat (jid, ChatModification.unmute, {stamp: mutedate}) }, 5000) // unmute after 5 seconds -await client.deleteChat (jid) // will delete the chat (can be a group or broadcast list) +await conn.deleteChat (jid) // will delete the chat (can be a group or broadcast list) ``` **Note:** to unmute or unpin a chat, one must pass the timestamp of the pinning or muting. This is returned by the pin & mute functions. This is also available in the `WAChat` objects of the respective chats, as a `mute` or `pin` property. @@ -325,48 +325,48 @@ await client.deleteChat (jid) // will delete the chat (can be a group or broadca - To check if a given ID is on WhatsApp ``` ts const id = 'xyz@s.whatsapp.net' - const exists = await client.isOnWhatsApp (id) + const exists = await conn.isOnWhatsApp (id) console.log (`${id} ${exists ? " exists " : " does not exist"} on WhatsApp`) ``` - 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 client.loadConversation ("xyz-abc@g.us", 25) + const messages = await conn.loadConversation ("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 client.loadEntireConversation ("xyz@c.us", message => console.log("Loaded message with ID: " + message.key.id)) + await conn.loadEntireConversation ("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 ``` ts - const status = await client.getStatus ("xyz@c.us") // leave empty to get your own status + const status = await conn.getStatus ("xyz@c.us") // leave empty to get your own status console.log("status: " + status) ``` - To get the display picture of some person/group ``` ts - const ppUrl = await client.getProfilePicture ("xyz@g.us") // leave empty to get your own + const ppUrl = await conn.getProfilePicture ("xyz@g.us") // leave empty to get your own console.log("download profile picture from: " + ppUrl) ``` - To change your display picture or a group's ``` 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 client.updateProfilePicture (jid, newPP) + await conn.updateProfilePicture (jid, newPP) ``` - To get someone's presence (if they're typing, online) ``` ts // the presence update is fetched and called here - client.setOnPresenceUpdate (json => console.log(json.id + " presence is " + json.type)) - await client.requestPresenceUpdate ("xyz@c.us") // request the update + conn.setOnPresenceUpdate (json => console.log(json.id + " presence is " + json.type)) + await conn.requestPresenceUpdate ("xyz@c.us") // request the update ``` - To search through messages ``` ts - const response = await client.searchMessages ('so cool', null, 25, 1) // search in all chats + const response = await conn.searchMessages ('so cool', null, 25, 1) // search in all chats console.log (`got ${response.messages.length} messages in search`) - const response2 = await client.searchMessages ('so cool', '1234@c.us', 25, 1) // search in given chat + 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. @@ -375,47 +375,47 @@ Append ``` @s.whatsapp.net ``` for individuals & ``` @g.us ``` for groups. - To create a group ``` ts // title & participants - const group = await client.groupCreate ("My Fab Group", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) + const group = await conn.groupCreate ("My Fab Group", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) console.log ("created group with id: " + group.gid) - client.sendTextMessage(group.gid, "hello everyone") // say hello to everyone on the group + conn.sendMessage(group.gid, "hello everyone", MessageType.extendedText) // say hello to everyone on the group ``` - To add people to a group ``` ts // id & people to add to the group (will throw error if it fails) - const response = await client.groupAdd ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) + const response = await conn.groupAdd ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) ``` - To make/demote admins on a group ``` ts // id & people to make admin (will throw error if it fails) - await client.groupMakeAdmin ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) - await client.groupDemoteAdmin ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) // demote admins + await conn.groupMakeAdmin ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) + await conn.groupDemoteAdmin ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) // demote admins ``` - To change group settings ``` ts import { GroupSettingChange } from '@adiwajshing/baileys' // only allow admins to send messages - await client.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.messageSend, true) + await conn.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.messageSend, true) // allow everyone to modify the group's settings -- like display picture etc. - await client.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.settingChange, false) + await conn.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.settingChange, false) // only allow admins to modify the group's settings - await client.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.settingChange, true) + await conn.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.settingChange, true) ``` - To leave a group ``` ts - await client.groupLeave ("abcd-xyz@g.us") // (will throw error if it fails) + await conn.groupLeave ("abcd-xyz@g.us") // (will throw error if it fails) ``` - To get the invite code for a group ``` ts - const code = await client.groupInviteCode ("abcd-xyz@g.us") + const code = await conn.groupInviteCode ("abcd-xyz@g.us") console.log("group code: " + code) ``` - To query the metadata of a group ``` ts - const metadata = await client.groupMetadata ("abcd-xyz@g.us") + const metadata = await conn.groupMetadata ("abcd-xyz@g.us") console.log(json.id + ", title: " + json.subject + ", description: " + json.desc) // Or if you've left the group -- call this - const metadata2 = await client.groupMetadataMinimal ("abcd-xyz@g.us") + const metadata2 = await conn.groupMetadataMinimal ("abcd-xyz@g.us") ``` ## Broadcast Lists & Stories @@ -425,7 +425,7 @@ Append ``` @s.whatsapp.net ``` for individuals & ``` @g.us ``` for groups. - Broadcast IDs are in the format `12345678@broadcast` - To query a broadcast list's recipients & name: ``` ts - const bList = await client.getBroadcastListInfo ("1234@broadcast") + const bList = await conn.getBroadcastListInfo ("1234@broadcast") console.log (`list name: ${bList.name}, recps: ${bList.recipients}`) ``` @@ -434,7 +434,7 @@ Baileys is written, keeping in mind, that you may require other custom functiona First, enable the logging of unhandled messages from WhatsApp by setting ``` ts -client.logLevel = MessageLogLevel.unhandled // set to MessageLogLevel.all to see all messages received +conn.logLevel = MessageLogLevel.unhandled // set to MessageLogLevel.all to see all messages received ``` This will enable you to see all sorts of messages WhatsApp sends in the console. Some examples: @@ -450,7 +450,7 @@ This will enable you to see all sorts of messages WhatsApp sends in the console. Hence, you can register a callback for an event using the following: ``` ts - client.registerCallback (["action", null, "battery"], json => { + conn.registerCallback (["action", null, "battery"], json => { const batteryLevelStr = json[2][0][1].value const batterylevel = parseInt (batteryLevelStr) console.log ("battery level: " + batterylevel + "%") @@ -470,9 +470,9 @@ This will enable you to see all sorts of messages WhatsApp sends in the console. Following this, one can implement the following callback: ``` ts - client.registerCallback (["Conn", "pushname"], json => { + conn.registerCallback (["Conn", "pushname"], json => { const pushname = json[1].pushname - client.userMetaData.name = pushname // update on client too + conn.userMetaData.name = pushname // update on client too console.log ("Name updated: " + pushname) }) ``` diff --git a/package.json b/package.json index 41970a2..0de1e0b 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@adiwajshing/baileys", - "version": "2.3.1", + "version": "3.0.0", "description": "WhatsApp Web API", "homepage": "https://github.com/adiwajshing/Baileys", - "main": "lib/WAClient/WAClient.js", - "types": "lib/WAClient/WAClient.d.ts", + "main": "lib/WAConnection/WAConnection.js", + "types": "lib/WAConnection/WAConnection.d.ts", "keywords": [ "whatsapp", "js-whatsapp", @@ -18,12 +18,12 @@ ], "scripts": { "prepare": "npm run build", - "test": "mocha --timeout 60000 -r ts-node/register src/*/Tests.ts", + "test": "mocha --timeout 60000 -r ts-node/register src/Tests/Tests.*.ts", "lint": "eslint '*/*.ts' --quiet --fix", "build": "tsc", "build:docs": "typedoc", "example": "npx ts-node Example/example.ts", - "browser-decode": "npx ts-node src/WAConnection/BrowserMessageDecoding.ts" + "browser-decode": "npx ts-node src/BrowserMessageDecoding.ts" }, "author": "Adhiraj Singh", "license": "MIT", diff --git a/src/WAConnection/BrowserMessageDecoding.ts b/src/BrowserMessageDecoding.ts similarity index 94% rename from src/WAConnection/BrowserMessageDecoding.ts rename to src/BrowserMessageDecoding.ts index 30abf84..cac55c7 100644 --- a/src/WAConnection/BrowserMessageDecoding.ts +++ b/src/BrowserMessageDecoding.ts @@ -1,6 +1,6 @@ import fs from 'fs' -import { decryptWA } from './Utils' -import Decoder from '../Binary/Decoder' +import { decryptWA } from './WAConnection/WAConnection' +import Decoder from './Binary/Decoder' interface BrowserMessagesInfo { encKey: string, diff --git a/src/Tests/Common.ts b/src/Tests/Common.ts new file mode 100644 index 0000000..64b6bac --- /dev/null +++ b/src/Tests/Common.ts @@ -0,0 +1,28 @@ +import { WAConnection, MessageLogLevel, MessageOptions, MessageType } from '../WAConnection/WAConnection' +import * as assert from 'assert' +import fs from 'fs/promises' + +require ('dotenv').config () // dotenv to load test jid +export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory + +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 message = messages.find (m => m.key.id === response.key.id) + assert.ok(message) + return message +} +export function WAConnectionTest(name: string, func: (conn: WAConnection) => void) { + describe(name, () => { + const conn = new WAConnection() + conn.logLevel = MessageLogLevel.info + + before(async () => { + const file = './auth_info.json' + await conn.connectSlim(file) + await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t')) + }) + after(() => conn.close()) + func(conn) + }) +} \ No newline at end of file diff --git a/src/Binary/Tests.ts b/src/Tests/Tests.Binary.ts similarity index 98% rename from src/Binary/Tests.ts rename to src/Tests/Tests.Binary.ts index 18669c8..5dc5b79 100644 --- a/src/Binary/Tests.ts +++ b/src/Tests/Tests.Binary.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'assert' -import Encoder from './Encoder' -import Decoder from './Decoder' +import Encoder from '../Binary/Encoder' +import Decoder from '../Binary/Decoder' describe('Binary Coding Tests', () => { const testVectors: [string, Object][] = [ diff --git a/src/WAConnection/Tests.ts b/src/Tests/Tests.Connect.ts similarity index 93% rename from src/WAConnection/Tests.ts rename to src/Tests/Tests.Connect.ts index 9c0f9e8..d03b2a9 100644 --- a/src/WAConnection/Tests.ts +++ b/src/Tests/Tests.Connect.ts @@ -1,10 +1,10 @@ import * as assert from 'assert' import * as QR from 'qrcode-terminal' -import WAConnection from './WAConnection' -import { AuthenticationCredentialsBase64 } from './Constants' -import { createTimeout } from './Utils' +import {WAConnection} from '../WAConnection/WAConnection' +import { AuthenticationCredentialsBase64 } from '../WAConnection/Constants' +import { createTimeout } from '../WAConnection/Utils' -describe('QR generation', () => { +describe('QR Generation', () => { it('should generate QR', async () => { const conn = new WAConnection() let calledQR = false diff --git a/src/Tests/Tests.Groups.ts b/src/Tests/Tests.Groups.ts new file mode 100644 index 0000000..e83dbb9 --- /dev/null +++ b/src/Tests/Tests.Groups.ts @@ -0,0 +1,59 @@ +import { MessageType, GroupSettingChange, createTimeout, ChatModification } from '../WAConnection/WAConnection' +import * as assert from 'assert' +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]) + gid = response.gid + console.log('created group: ' + JSON.stringify(response)) + }) + it('should retreive group invite code', async () => { + const code = await conn.groupInviteCode(gid) + assert.ok(code) + assert.strictEqual(typeof code, 'string') + }) + it('should retreive group metadata', async () => { + const metadata = await conn.groupMetadata(gid) + assert.strictEqual(metadata.id, gid) + assert.strictEqual(metadata.participants.filter((obj) => obj.id.split('@')[0] === testJid.split('@')[0]).length, 1) + }) + it('should update the group description', async () => { + const newDesc = 'Wow this was set from Baileys' + + await conn.groupUpdateDescription (gid, newDesc) + await createTimeout (1000) + + const metadata = await conn.groupMetadata(gid) + assert.strictEqual(metadata.desc, newDesc) + }) + it('should send a message on the group', async () => { + await conn.sendMessage(gid, 'hello', MessageType.text) + }) + it('should update the subject', async () => { + const subject = 'V Cool Title' + await conn.groupUpdateSubject(gid, subject) + + const metadata = await conn.groupMetadata(gid) + assert.strictEqual(metadata.subject, subject) + }) + it('should update the group settings', async () => { + await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true) + await createTimeout (5000) + await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true) + }) + it('should remove someone from a group', async () => { + await conn.groupRemove(gid, [testJid]) + }) + it('should leave the group', async () => { + await conn.groupLeave(gid) + await conn.groupMetadataMinimal (gid) + }) + it('should archive the group', async () => { + await conn.modifyChat(gid, ChatModification.archive) + }) + it('should delete the group', async () => { + await conn.deleteChat(gid) + }) +}) \ No newline at end of file diff --git a/src/Tests/Tests.Messages.ts b/src/Tests/Tests.Messages.ts new file mode 100644 index 0000000..36ce6ad --- /dev/null +++ b/src/Tests/Tests.Messages.ts @@ -0,0 +1,68 @@ +import { MessageType, Mimetype, createTimeout } from '../WAConnection/WAConnection' +import fs from 'fs/promises' +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') + }) + it('should forward a message', async () => { + let messages = await conn.loadConversation (testJid, 1) + await conn.forwardMessage (testJid, messages[0], true) + + messages = await conn.loadConversation (testJid, 1) + const message = messages[0] + const content = message.message[ Object.keys(message.message)[0] ] + assert.equal (content?.contextInfo?.isForwarded, true) + }) + it('should send a link preview', async () => { + const content = await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys') + const message = await sendAndRetreiveMessage(conn, content, MessageType.text) + const received = message.message.extendedTextMessage + + assert.strictEqual(received.text, content.text) + assert.ok (received.canonicalUrl) + assert.ok (received.title) + assert.ok (received.jpegThumbnail) + }) + it('should quote a message', async () => { + const messages = await conn.loadConversation(testJid, 2) + const message = await sendAndRetreiveMessage(conn, 'hello fren 2', MessageType.extendedText, { + quoted: messages[0], + }) + assert.strictEqual(message.message.extendedTextMessage.contextInfo.stanzaId, messages[0].key.id) + }) + it('should send a gif', async () => { + const content = await fs.readFile('./Media/ma_gif.mp4') + const message = await sendAndRetreiveMessage(conn, content, MessageType.video, { mimetype: Mimetype.gif }) + + await conn.downloadAndSaveMediaMessage(message,'./Media/received_vid') + }) + it('should send an image', async () => { + const content = await fs.readFile('./Media/meme.jpeg') + const message = await sendAndRetreiveMessage(conn, content, MessageType.image) + + await conn.downloadMediaMessage(message) + //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 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 () => { + const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text) + await createTimeout (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) + await conn.clearMessage (messages[0].key) + }) +}) \ No newline at end of file diff --git a/src/Tests/Tests.Misc.ts b/src/Tests/Tests.Misc.ts new file mode 100644 index 0000000..3969fd2 --- /dev/null +++ b/src/Tests/Tests.Misc.ts @@ -0,0 +1,113 @@ +import { MessageType, Presence, ChatModification, promiseTimeout, createTimeout } from '../WAConnection/WAConnection' +import fs from 'fs/promises' +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) + assert.strictEqual(response, true) + + const responseFail = await conn.isOnWhatsApp('abcd@s.whatsapp.net') + assert.strictEqual(responseFail, false) + }) + it('should return the status', async () => { + const response = await conn.getStatus(testJid) + assert.strictEqual(typeof response.status, 'string') + }) + it('should update status', async () => { + const newStatus = 'v cool status' + + const response = await conn.getStatus() + assert.strictEqual(typeof response.status, 'string') + + await createTimeout (1000) + + await conn.setStatus (newStatus) + const response2 = await conn.getStatus() + assert.equal (response2.status, newStatus) + + await createTimeout (1000) + + await conn.setStatus (response.status) // update back + }) + it('should return the stories', async () => { + await conn.getStories() + }) + it('should change the profile picture', async () => { + await createTimeout (5000) + + const ppUrl = await conn.getProfilePicture(conn.userMetaData.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) + + await createTimeout (10000) + + await conn.updateProfilePicture (conn.userMetaData.id, buff) // revert back + }) + it('should return the profile picture', async () => { + const response = await conn.getProfilePicture(testJid) + assert.ok(response) + assert.rejects(conn.getProfilePicture('abcd@s.whatsapp.net')) + }) + it('should send typing indicator', async () => { + const response = await conn.updatePresence(testJid, Presence.composing) + assert.ok(response) + }) + it('should mark a chat unread', async () => { + await conn.sendReadReceipt(testJid, null, 'unread') + }) + it('should archive & unarchive', async () => { + await conn.modifyChat (testJid, ChatModification.archive) + await createTimeout (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 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 conn.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate}) + }) + it('should return search results', async () => { + const jids = [null, testJid] + for (let i in jids) { + const response = await conn.searchMessages('Hello', jids[i], 25, 1) + assert.ok (response.messages) + 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()) + }) +}) diff --git a/src/WAClient/Constants.ts b/src/WAClient/Constants.ts deleted file mode 100644 index 77a1302..0000000 --- a/src/WAClient/Constants.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { WAMessage } from '../WAConnection/Constants' -import { proto } from '../../WAMessage/WAMessage' -/** - * set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send - */ -export enum Presence { - available = 'available', // "online" - unavailable = 'unavailable', // "offline" - composing = 'composing', // "typing..." - recording = 'recording', // "recording..." - paused = 'paused', // I have no clue -} -/** - * Status of a message sent or received - */ -export enum MessageStatus { - sent = 'sent', - received = 'received', - read = 'read', -} -/** - * set of message types that are supported by the library - */ -export enum MessageType { - text = 'conversation', - extendedText = 'extendedTextMessage', - contact = 'contactMessage', - location = 'locationMessage', - liveLocation = 'liveLocationMessage', - - image = 'imageMessage', - video = 'videoMessage', - sticker = 'stickerMessage', - document = 'documentMessage', - audio = 'audioMessage', - product = 'productMessage' -} -export enum ChatModification { - archive='archive', - unarchive='unarchive', - pin='pin', - unpin='unpin', - mute='mute', - unmute='unmute' -} -export const HKDFInfoKeys = { - [MessageType.image]: 'WhatsApp Image Keys', - [MessageType.audio]: 'WhatsApp Audio Keys', - [MessageType.video]: 'WhatsApp Video Keys', - [MessageType.document]: 'WhatsApp Document Keys', - [MessageType.sticker]: 'WhatsApp Image Keys' -} -export enum Mimetype { - jpeg = 'image/jpeg', - png = 'image/png', - mp4 = 'video/mp4', - gif = 'video/gif', - pdf = 'application/pdf', - ogg = 'audio/ogg; codecs=opus', - /** for stickers */ - webp = 'image/webp', -} -export interface MessageOptions { - quoted?: WAMessage - contextInfo?: WAContextInfo - timestamp?: Date - caption?: string - thumbnail?: string - mimetype?: Mimetype | string - validateID?: boolean, - filename?: string -} -export interface WABroadcastListInfo { - status: number - name: string - recipients?: {id: string}[] -} -export interface WAUrlInfo { - 'canonical-url': string - 'matched-text': string - title: string - description: string - jpegThumbnail?: Buffer -} -export interface WAProfilePictureChange { - status: number - tag: string - eurl: string -} -export interface MessageInfo { - reads: {jid: string, t: string}[] - deliveries: {jid: string, t: string}[] -} -export interface MessageStatusUpdate { - from: string - to: string - /** Which participant caused the update (only for groups) */ - participant?: string - timestamp: Date - /** Message IDs read/delivered */ - ids: string[] - /** Status of the Message IDs */ - type: WA_MESSAGE_STATUS_TYPE -} -export enum GroupSettingChange { - messageSend = 'announcement', - settingsChange = 'locked', -} -export interface PresenceUpdate { - id: string - participant?: string - t?: string - type?: Presence - deny?: boolean -} -// path to upload the media -export const MediaPathMap = { - imageMessage: '/mms/image', - videoMessage: '/mms/video', - documentMessage: '/mms/document', - audioMessage: '/mms/audio', - stickerMessage: '/mms/image', -} -// gives WhatsApp info to process the media -export const MimetypeMap = { - imageMessage: Mimetype.jpeg, - videoMessage: Mimetype.mp4, - documentMessage: Mimetype.pdf, - audioMessage: Mimetype.ogg, - stickerMessage: Mimetype.webp, -} -export interface WASendMessageResponse { - status: number - 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 diff --git a/src/WAClient/Tests.ts b/src/WAClient/Tests.ts deleted file mode 100644 index ab8c776..0000000 --- a/src/WAClient/Tests.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { WAClient } from './WAClient' -import { MessageType, MessageOptions, Mimetype, Presence, ChatModification, GroupSettingChange } from './Constants' -import * as fs from 'fs' -import * as assert from 'assert' -import fetch from 'node-fetch' - -import { decodeMediaMessage, validateJIDForSending } from './Utils' -import { promiseTimeout, createTimeout, Browsers, generateMessageTag } from '../WAConnection/Utils' -import { MessageLogLevel } from '../WAConnection/Constants' - -require ('dotenv').config () // dotenv to load test jid -const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory - -async function sendAndRetreiveMessage(client: WAClient, content, type: MessageType, options: MessageOptions = {}) { - const response = await client.sendMessage(testJid, content, type, options) - const messages = await client.loadConversation(testJid, 1, null, true) - assert.strictEqual(messages[0].key.id, response.messageID) - return messages[0] -} -function WAClientTest(name: string, func: (client: WAClient) => void) { - describe(name, () => { - const client = new WAClient() - client.logLevel = MessageLogLevel.info - - before(async () => { - const file = './auth_info.json' - await client.connectSlim(file) - fs.writeFileSync(file, JSON.stringify(client.base64EncodedAuthInfo(), null, '\t')) - }) - after(() => client.close()) - func(client) - }) -} -WAClientTest('Messages', (client) => { - it('should send a text message', async () => { - const message = await sendAndRetreiveMessage(client, 'hello fren', MessageType.text) - assert.strictEqual(message.message.conversation, 'hello fren') - }) - it('should forward a message', async () => { - let messages = await client.loadConversation (testJid, 1) - await client.forwardMessage (testJid, messages[0]) - - messages = await client.loadConversation (testJid, 1) - const message = messages[0] - const content = message.message[ Object.keys(message.message)[0] ] - assert.equal (content?.contextInfo?.isForwarded, true) - }) - it('should send a link preview', async () => { - const content = await client.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys') - const message = await sendAndRetreiveMessage(client, content, MessageType.text) - const received = message.message.extendedTextMessage - assert.strictEqual(received.text, content.text) - - fs.writeFileSync ('Media/received-thumb.jpeg', content.jpegThumbnail) - }) - it('should quote a message', async () => { - const messages = await client.loadConversation(testJid, 2) - const message = await sendAndRetreiveMessage(client, 'hello fren 2', MessageType.extendedText, { - quoted: messages[0], - }) - assert.strictEqual(message.message.extendedTextMessage.contextInfo.stanzaId, messages[0].key.id) - }) - it('should send a gif', async () => { - const content = fs.readFileSync('./Media/ma_gif.mp4') - const message = await sendAndRetreiveMessage(client, content, MessageType.video, { mimetype: Mimetype.gif }) - - await client.downloadAndSaveMediaMessage(message,'./Media/received_vid') - }) - it('should send an image', async () => { - const content = fs.readFileSync('./Media/meme.jpeg') - const message = await sendAndRetreiveMessage(client, content, MessageType.image) - const file = await decodeMediaMessage(message.message, './Media/received_img') - //const message2 = await sendAndRetreiveMessage (client, 'this is a quote', MessageType.extendedText) - }) - it('should send an image & quote', async () => { - const messages = await client.loadConversation(testJid, 1) - const content = fs.readFileSync('./Media/meme.jpeg') - const message = await sendAndRetreiveMessage(client, content, MessageType.image, { quoted: messages[0] }) - const file = await decodeMediaMessage(message.message, './Media/received_img') - assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, messages[0].key.id) - }) - it('should send a text message & delete it', async () => { - const message = await sendAndRetreiveMessage(client, 'hello fren', MessageType.text) - await createTimeout (2000) - await client.deleteMessage (testJid, message.key) - }) - it('should clear the most recent message', async () => { - const messages = await client.loadConversation (testJid, 1) - await createTimeout (2000) - await client.clearMessage (messages[0].key) - }) -}) - -describe('Validate WhatsApp IDs', () => { - it ('should correctly validate', () => { - assert.doesNotThrow (() => validateJIDForSending ('12345@s.whatsapp.net')) - assert.doesNotThrow (() => validateJIDForSending ('919999999999@s.whatsapp.net')) - assert.doesNotThrow (() => validateJIDForSending ('10203040506@s.whatsapp.net')) - assert.doesNotThrow (() => validateJIDForSending ('12345-3478@g.us')) - assert.doesNotThrow (() => validateJIDForSending ('1234567890-34712121238@g.us')) - assert.throws (() => validateJIDForSending ('123454677@c.us')) - assert.throws (() => validateJIDForSending ('+123454677@s.whatsapp.net')) - assert.throws (() => validateJIDForSending ('+12345-3478@g.us')) - }) -}) -WAClientTest('Presence', (client) => { - it('should update presence', async () => { - const presences = Object.values(Presence) - for (const i in presences) { - const response = await client.updatePresence(testJid, presences[i]) - assert.strictEqual(response.status, 200) - - await createTimeout(1500) - } - }) -}) -WAClientTest('Misc', (client) => { - it('should tell if someone has an account on WhatsApp', async () => { - const response = await client.isOnWhatsApp(testJid) - assert.strictEqual(response, true) - - const responseFail = await client.isOnWhatsApp('abcd@s.whatsapp.net') - assert.strictEqual(responseFail, false) - }) - it('should return the status', async () => { - const response = await client.getStatus(testJid) - assert.strictEqual(typeof response.status, 'string') - }) - it('should update status', async () => { - const newStatus = 'v cool status' - - const response = await client.getStatus() - assert.strictEqual(typeof response.status, 'string') - - await createTimeout (1000) - - await client.setStatus (newStatus) - const response2 = await client.getStatus() - assert.equal (response2.status, newStatus) - - await createTimeout (1000) - - await client.setStatus (response.status) // update back - }) - it('should return the stories', async () => { - await client.getStories() - }) - it('should change the profile picture', async () => { - await createTimeout (5000) - - const ppUrl = await client.getProfilePicture(client.userMetaData.id) - const fetched = await fetch(ppUrl, { headers: { Origin: 'https://web.whatsapp.com' } }) - const buff = await fetched.buffer () - - const newPP = fs.readFileSync ('./Media/cat.jpeg') - const response = await client.updateProfilePicture (client.userMetaData.id, newPP) - - await createTimeout (10000) - - await client.updateProfilePicture (client.userMetaData.id, buff) // revert back - }) - it('should return the profile picture', async () => { - const response = await client.getProfilePicture(testJid) - assert.ok(response) - assert.rejects(client.getProfilePicture('abcd@s.whatsapp.net')) - }) - it('should send typing indicator', async () => { - const response = await client.updatePresence(testJid, Presence.composing) - assert.ok(response) - }) - it('should mark a chat unread', async () => { - await client.sendReadReceipt(testJid, null, 'unread') - }) - it('should archive & unarchive', async () => { - await client.modifyChat (testJid, ChatModification.archive) - await createTimeout (2000) - await client.modifyChat (testJid, ChatModification.unarchive) - }) - it('should pin & unpin a chat', async () => { - const response = await client.modifyChat (testJid, ChatModification.pin) - await createTimeout (2000) - await client.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 client.modifyChat (testJid, ChatModification.mute, {stamp: mutedate}) - await createTimeout (2000) - await client.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate}) - }) - it('should return search results', async () => { - const jids = [null, testJid] - for (let i in jids) { - const response = await client.searchMessages('Hello', jids[i], 25, 1) - assert.ok (response.messages) - assert.ok (response.messages.length >= 0) - } - }) -}) -WAClientTest('Groups', (client) => { - let gid: string - it('should create a group', async () => { - const response = await client.groupCreate('Cool Test Group', [testJid]) - gid = response.gid - console.log('created group: ' + JSON.stringify(response)) - }) - it('should retreive group invite code', async () => { - const code = await client.groupInviteCode(gid) - assert.ok(code) - assert.strictEqual(typeof code, 'string') - }) - it('should retreive group metadata', async () => { - const metadata = await client.groupMetadata(gid) - assert.strictEqual(metadata.id, gid) - assert.strictEqual(metadata.participants.filter((obj) => obj.id.split('@')[0] === testJid.split('@')[0]).length, 1) - }) - it('should update the group description', async () => { - const newDesc = 'Wow this was set from Baileys' - - await client.groupUpdateDescription (gid, newDesc) - await createTimeout (1000) - - const metadata = await client.groupMetadata(gid) - assert.strictEqual(metadata.desc, newDesc) - }) - it('should send a message on the group', async () => { - await client.sendMessage(gid, 'hello', MessageType.text) - }) - it('should update the subject', async () => { - const subject = 'V Cool Title' - await client.groupUpdateSubject(gid, subject) - - const metadata = await client.groupMetadata(gid) - assert.strictEqual(metadata.subject, subject) - }) - it('should update the group settings', async () => { - await client.groupSettingChange (gid, GroupSettingChange.messageSend, true) - await createTimeout (5000) - await client.groupSettingChange (gid, GroupSettingChange.settingsChange, true) - }) - it('should remove someone from a group', async () => { - await client.groupRemove(gid, [testJid]) - }) - it('should leave the group', async () => { - await client.groupLeave(gid) - await client.groupMetadataMinimal (gid) - }) - it('should archive the group', async () => { - await client.archiveChat(gid) - }) - it('should delete the group', async () => { - await client.deleteChat(gid) - }) -}) -WAClientTest('Events', (client) => { - it('should deliver a message', async () => { - const waitForUpdate = () => - new Promise((resolve) => { - client.setOnMessageStatusChange((update) => { - if (update.ids.includes(response.messageID)) { - resolve() - } - }) - }) - const response = await client.sendMessage(testJid, 'My Name Jeff', MessageType.text) - await promiseTimeout(10000, waitForUpdate()) - }) - /*it('should retreive all conversations', async () => { - const [chats] = await client.receiveChatsAndContacts (10000) - for (let chat of chats.all()) { - console.log ('receiving ' + chat.jid) - const convo = await client.loadConversation (chat.jid.replace('@s.whatsapp.net', '@c.us'), 25) - await createTimeout (200) - } - })*/ -}) diff --git a/src/WAClient/Utils.ts b/src/WAClient/Utils.ts deleted file mode 100644 index ca302cf..0000000 --- a/src/WAClient/Utils.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { MessageType, HKDFInfoKeys, MessageOptions, WAMessageType } from './Constants' -import Jimp from 'jimp' -import * as fs from 'fs' -import fetch from 'node-fetch' -import { WAMessage, WAMessageContent, BaileysError } from '../WAConnection/Constants' -import { hmacSign, aesDecryptWithIV, hkdf } from '../WAConnection/Utils' -import { proto } from '../../WAMessage/WAMessage' -import { randomBytes } from 'crypto' -import { exec } from 'child_process' - -export function validateJIDForSending (jid: string) { - const regexp = /^[0-9]{1,20}(-[0-9]{1,20}@g.us|@s.whatsapp.net)$/ - if (!regexp.test (jid)) { - throw new Error ( - `Invalid WhatsApp id: ${jid} - 1. Please ensure you suffix '@s.whatsapp.net' for individual numbers & '@g.us' for groups - 2. Please do not put any alphabets or special characters like a '+' in the number. A '-' symbol in groups is fine` - ) - } -} -/** - * Type of notification - * @deprecated use WA_MESSAGE_STUB_TYPE instead - * */ -export function getNotificationType(message: WAMessage): [string, MessageType?] { - if (message.message) { - return ['message', Object.keys(message.message)[0] as MessageType] - } else if (message.messageStubType) { - return [WAMessageType[message.messageStubType], null] - } else { - return ['unknown', null] - } -} -/** generates all the keys required to encrypt/decrypt & sign a media message */ -export function getMediaKeys(buffer, mediaType: MessageType) { - if (typeof buffer === 'string') { - buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64') - } - // expand using HKDF to 112 bytes, also pass in the relevant app info - const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType]) - return { - iv: expandedMediaKey.slice(0, 16), - cipherKey: expandedMediaKey.slice(16, 48), - macKey: expandedMediaKey.slice(48, 80), - } -} -/** Extracts video thumb using FFMPEG */ -const extractVideoThumb = async ( - path: string, - destPath: string, - time: string, - size: { width: number; height: number }, -) => - new Promise((resolve, reject) => { - const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}` - exec(cmd, (err) => { - if (err) reject(err) - else resolve() - }) - }) as Promise - -export const compressImage = async (buffer: Buffer) => { - const jimp = await Jimp.read (buffer) - return jimp.resize(48, 48).getBufferAsync (Jimp.MIME_JPEG) -} -export const generateProfilePicture = async (buffer: Buffer) => { - const jimp = await Jimp.read (buffer) - const min = Math.min(jimp.getWidth (), jimp.getHeight ()) - const cropped = jimp.crop (0, 0, min, min) - return { - img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG), - preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG) - } -} -/** generates a thumbnail for a given media, if required */ -export async function generateThumbnail(buffer: Buffer, mediaType: MessageType, info: MessageOptions) { - if (info.thumbnail === null || info.thumbnail) { - // don't do anything if the thumbnail is already provided, or is null - if (mediaType === MessageType.audio) { - throw new Error('audio messages cannot have thumbnails') - } - } else if (mediaType === MessageType.image || mediaType === MessageType.sticker) { - const buff = await compressImage (buffer) - info.thumbnail = buff.toString('base64') - } else if (mediaType === MessageType.video) { - const filename = './' + randomBytes(5).toString('hex') + '.mp4' - const imgFilename = filename + '.jpg' - fs.writeFileSync(filename, buffer) - try { - await extractVideoThumb(filename, imgFilename, '00:00:00', { width: 48, height: 48 }) - const buff = fs.readFileSync(imgFilename) - info.thumbnail = buff.toString('base64') - fs.unlinkSync(imgFilename) - } catch (err) { - console.log('could not generate video thumb: ' + err) - } - fs.unlinkSync(filename) - } -} -/** - * Decode a media message (video, image, document, audio) & return decrypted buffer - * @param message the media message you want to decode - */ -export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchHeaders: {[k: string]: string} = {}) { - /* - One can infer media type from the key in the message - it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc. - */ - const type = Object.keys(message)[0] as MessageType - if (!type) { - throw new BaileysError('unknown message type', message) - } - if (type === MessageType.text || type === MessageType.extendedText) { - throw new BaileysError('cannot decode text message', message) - } - if (type === MessageType.location || type === MessageType.liveLocation) { - return new Buffer(message[type].jpegThumbnail) - } - let messageContent: proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage - if (message.productMessage) { - const product = message.productMessage.product?.productImage - if (!product) throw new BaileysError ('product has no image', message) - messageContent = product - } else { - messageContent = message[type] - } - - // download the message - const headers = { Origin: 'https://web.whatsapp.com' } - const fetched = await fetch(messageContent.url, { headers }) - const buffer = await fetched.buffer() - - if (buffer.length <= 10) { - throw new BaileysError ('Empty buffer returned. File has possibly been deleted from WA servers. Run `client.updateMediaMessage()` to refresh the url', {status: 404}) - } - - const decryptedMedia = (type: MessageType) => { - // get the keys to decrypt the message - const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type) - // first part is actual file - const file = buffer.slice(0, buffer.length - 10) - // last 10 bytes is HMAC sign of file - const mac = buffer.slice(buffer.length - 10, buffer.length) - // sign IV+file & check for match with mac - const testBuff = Buffer.concat([mediaKeys.iv, file]) - const sign = hmacSign(testBuff, mediaKeys.macKey).slice(0, 10) - // our sign should equal the mac - if (!sign.equals(mac)) throw new Error() - - return aesDecryptWithIV(file, mediaKeys.cipherKey, mediaKeys.iv) // decrypt media - } - const allTypes = [type, ...Object.keys(HKDFInfoKeys)] - for (let i = 0; i < allTypes.length;i++) { - try { - const decrypted = decryptedMedia (allTypes[i] as MessageType) - - if (i > 0) { console.log (`decryption of ${type} media with HKDF key of ${allTypes[i]}`) } - return decrypted - } catch { - if (i === 0) { console.log (`decryption of ${type} media with original HKDF key failed`) } - } - } - throw new BaileysError('Decryption failed, HMAC sign does not match', {status: 400}) -} -export function extensionForMediaMessage(message: WAMessageContent) { - const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1] - const type = Object.keys(message)[0] as MessageType - let extension: string - if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) { - extension = '.jpeg' - } else { - const messageContent = message[type] as - | proto.VideoMessage - | proto.ImageMessage - | proto.AudioMessage - | proto.DocumentMessage - extension = getExtension (messageContent.mimetype) - } - return extension -} - -/** - * Decode a media message (video, image, document, audio) & save it to the given file - * @deprecated use `client.downloadAndSaveMediaMessage` - */ -export async function decodeMediaMessage(message: WAMessageContent, filename: string, attachExtension: boolean=true) { - const buffer = await decodeMediaMessageBuffer (message, {}) - const extension = extensionForMediaMessage (message) - const trueFileName = attachExtension ? (filename + '.' + extension) : filename - fs.writeFileSync(trueFileName, buffer) - return trueFileName -} - diff --git a/src/WAClient/WAClient.ts b/src/WAClient/WAClient.ts deleted file mode 100644 index ba34326..0000000 --- a/src/WAClient/WAClient.ts +++ /dev/null @@ -1,7 +0,0 @@ -import WhatsAppWebMessages from './Messages' - -export { WhatsAppWebMessages as WAClient } -export * from './Constants' -export * from './Utils' -export * from '../WAConnection/Constants' -export { Browsers } from '../WAConnection/Utils' diff --git a/src/WAConnection/Base.ts b/src/WAConnection/0.Base.ts similarity index 86% rename from src/WAConnection/Base.ts rename to src/WAConnection/0.Base.ts index b439887..c8acc58 100644 --- a/src/WAConnection/Base.ts +++ b/src/WAConnection/0.Base.ts @@ -14,6 +14,11 @@ import { AuthenticationCredentialsBrowser, BaileysError, WAConnectionMode, + WAMessage, + PresenceUpdate, + MessageStatusUpdate, + WAMetric, + WAFlag, } from './Constants' /** Generate a QR code from the ref & the curve public key. This is scanned by the phone */ @@ -22,7 +27,7 @@ const generateQRCode = function ([ref, publicKey, clientID]) { QR.generate(str, { small: true }) } -export default class WAConnectionBase { +export class WAConnection { /** The version of WhatsApp Web we're telling the servers we are */ version: [number, number, number] = [2, 2027, 10] /** The Browser we're telling the WhatsApp Web servers we are */ @@ -61,9 +66,7 @@ export default class WAConnectionBase { protected pendingRequests: (() => void)[] = [] protected reconnectLoop: () => Promise protected referenceDate = new Date () // used for generating tags - protected userAgentString: string constructor () { - this.userAgentString = Utils.userAgentString (this.browserDescription[1]) this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind)) } async unexpectedDisconnect (error: string) { @@ -74,6 +77,46 @@ export default class WAConnectionBase { 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 @@ -243,6 +286,12 @@ export default class WAConnectionBase { 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 + } /** * Send a binary encoded message * @param json the message to encode & send diff --git a/src/WAConnection/Validation.ts b/src/WAConnection/1.Validation.ts similarity index 97% rename from src/WAConnection/Validation.ts rename to src/WAConnection/1.Validation.ts index 11f8eea..ca5f079 100644 --- a/src/WAConnection/Validation.ts +++ b/src/WAConnection/1.Validation.ts @@ -1,10 +1,10 @@ import * as Curve from 'curve25519-js' import * as Utils from './Utils' -import WAConnectionBase from './Base' -import { MessageLogLevel, WAMetric, WAFlag, BaileysError } from './Constants' -import { Presence } from '../WAClient/WAClient' +import {WAConnection as Base} from './0.Base' +import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence } from './Constants' -export default class WAConnectionValidator extends WAConnectionBase { +export class WAConnection extends Base { + /** Authenticate the connection */ protected async authenticate() { if (!this.authInfo.clientID) { @@ -21,6 +21,7 @@ export default class WAConnectionValidator extends WAConnectionBase { this.referenceDate = new Date () // refresh reference date const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true] + return this.queryExpecting200(data) .then(json => { // we're trying to establish a new connection or are trying to log in diff --git a/src/WAConnection/Connect.ts b/src/WAConnection/3.Connect.ts similarity index 98% rename from src/WAConnection/Connect.ts rename to src/WAConnection/3.Connect.ts index ebd331e..d0953ae 100644 --- a/src/WAConnection/Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -2,10 +2,10 @@ 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 WAConnectionValidator from './Validation' +import {WAConnection as Base} from './1.Validation' import Decoder from '../Binary/Decoder' -export default class WAConnectionConnector extends WAConnectionValidator { +export class WAConnection extends Base { /** * Connect to WhatsAppWeb * @param [authInfo] credentials or path to credentials to log back in diff --git a/src/WAClient/Base.ts b/src/WAConnection/4.User.ts similarity index 69% rename from src/WAClient/Base.ts rename to src/WAConnection/4.User.ts index 5898f81..5c34181 100644 --- a/src/WAClient/Base.ts +++ b/src/WAConnection/4.User.ts @@ -1,58 +1,16 @@ -import WAConnection from '../WAConnection/WAConnection' -import { MessageStatusUpdate, PresenceUpdate, Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants' +import {WAConnection as Base} from './3.Connect' +import { Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants' import { WAMessage, WANode, WAMetric, WAFlag, - MessageLogLevel, - WATag, } from '../WAConnection/Constants' -import { generateProfilePicture } from '../WAClient/Utils' +import { generateProfilePicture } from './Utils' +// All user related functions -- get profile picture, set status etc. -export default class WhatsAppWebBase extends WAConnection { - - /** 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])) - } +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) /** @@ -75,13 +33,15 @@ export default class WhatsAppWebBase extends WAConnection { return this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }> } async setStatus (status: string) { - return this.setQuery ([ + return this.setQuery ( [ - 'status', - null, - Buffer.from (status, 'utf-8') + [ + 'status', + null, + Buffer.from (status, 'utf-8') + ] ] - ]) + ) } /** Get the URL to download the profile picture of a person/group */ async getProfilePicture(jid: string | null) { @@ -157,10 +117,7 @@ export default class WhatsAppWebBase extends WAConnection { }, null, ] - const response = await this.query(json, [WAMetric.queryMessages, WAFlag.ignore]) - - if (response.status) throw new Error(`error in query, got status: ${response.status}`) - + const response = await this.queryExpecting200(json, [WAMetric.queryMessages, WAFlag.ignore]) return response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : [] } /** @@ -210,10 +167,4 @@ export default class WhatsAppWebBase extends WAConnection { ] return this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise } - /** 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 - } } diff --git a/src/WAClient/Messages.ts b/src/WAConnection/5.Messages.ts similarity index 87% rename from src/WAClient/Messages.ts rename to src/WAConnection/5.Messages.ts index 5130214..855afc5 100644 --- a/src/WAClient/Messages.ts +++ b/src/WAConnection/5.Messages.ts @@ -1,6 +1,6 @@ -import WhatsAppWebGroups from './Groups' +import {WAConnection as Base} from './4.User' import fetch from 'node-fetch' -import { promises as fs } from 'fs' +import fs from 'fs/promises' import { MessageOptions, MessageType, @@ -15,13 +15,11 @@ import { MessageInfo, WATextMessage, WAUrlInfo, + WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE } from './Constants' -import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes } from '../WAConnection/Utils' -import { WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel } from '../WAConnection/Constants' -import { validateJIDForSending, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage } from './Utils' -import { proto } from '../../WAMessage/WAMessage' +import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage } from './Utils' -export default class WhatsAppWebMessages extends WhatsAppWebGroups { +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] @@ -56,16 +54,6 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups { } return this.setQuery ([['read', attributes, null]]) } - /** - * Mark a given chat as unread - * @deprecated since 2.0.0, use `sendReadReceipt (jid, null, 'unread')` instead - */ - async markChatUnread (jid: string) { return this.sendReadReceipt (jid, null, 'unread') } - /** - * Archive a chat - * @deprecated since 2.0.0, use `modifyChat (jid, ChatModification.archive)` instead - */ - async archiveChat (jid: string) { return this.modifyChat (jid, ChatModification.archive) } /** * Modify a given chat (archive, pin etc.) * @param jid the ID of the person/group you are modifiying @@ -190,24 +178,27 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups { const json: WAMessageContent = { protocolMessage: { key: messageKey, - type: proto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE + type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE } } - return this.sendMessageContent (id, json, {}) + 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) { + 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 ? 0 : 1 + score += message.key.fromMe && !forceForward ? 0 : 1 if (key === MessageType.text) { content[MessageType.extendedText] = { text: content[key] } delete content[MessageType.text] @@ -216,18 +207,35 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups { } if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true } else content[key].contextInfo = {} - - return this.sendMessageContent (id, content, {}) + + 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 + */ async sendMessage( id: string, message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, type: MessageType, options: MessageOptions = {}, ) { - if (options.validateID === true || !('validateID' in options)) { - validateJIDForSending (id) - } + const waMessage = await this.prepareMessage (id, message, type, options) + await this.relayWAMessage (waMessage) + return waMessage + } + /** Prepares a message for sending via sendWAMessage () */ + async prepareMessage( + id: string, + message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer, + type: MessageType, + options: MessageOptions = {}, + ) { let m: WAMessageContent = {} switch (type) { case MessageType.text: @@ -251,7 +259,7 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups { m = await this.prepareMediaMessage(message as Buffer, type, options) break } - return this.sendMessageContent(id, m, options) + return this.generateWAMessage(id, m, options) } /** Prepare a media message for sending */ async prepareMediaMessage(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) { @@ -315,15 +323,13 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups { } return message as WAMessageContent } - /** Send message content */ - async sendMessageContent(id: string, message: WAMessageContent, options: MessageOptions) { - const messageJSON = this.generateWAMessage (id, message, options) - return this.sendWAMessage (messageJSON) - } /** generates a WAMessage from the given content & options */ generateWAMessage(id: string, message: WAMessageContent, options: MessageOptions) { if (!options.timestamp) options.timestamp = new Date() // set timestamp to now + // prevent an annoying bug (WA doesn't accept sending messages with '@c.us') + id = id.replace ('@c.us', '@s.whatsapp') + const key = Object.keys(message)[0] const timestamp = options.timestamp.getTime()/1000 const quoted = options.quoted @@ -356,29 +362,22 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups { messageTimestamp: timestamp, messageStubParameters: [], participant: id.includes('@g.us') ? this.userMetaData.id : null, - status: WAMessageProto.proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS.PENDING + status: WA_MESSAGE_STATUS_TYPE.PENDING } return messageJSON as WAMessage } - /** - * Send a WAMessage; more advanced functionality, you may want to stick with sendMessage() - * */ - async sendWAMessage(message: WAMessage) { + /** 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 - const response = await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id) - return { - status: response.status as number, - messageID: message.key.id, - message: message as WAMessage - } as WASendMessageResponse + await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id) } /** * Securely downloads the media from the message. * Renews the download url automatically, if necessary. */ async downloadMediaMessage (message: WAMessage) { - const fetchHeaders = { 'User-Agent': this.userAgentString } + const fetchHeaders = { } try { const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders) return buff diff --git a/src/WAClient/Groups.ts b/src/WAConnection/6.Groups.ts similarity index 95% rename from src/WAClient/Groups.ts rename to src/WAConnection/6.Groups.ts index 54cbfcc..c5db5a3 100644 --- a/src/WAClient/Groups.ts +++ b/src/WAConnection/6.Groups.ts @@ -1,9 +1,9 @@ -import WhatsAppWebBase from './Base' -import { WAMessage, WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants' +import {WAConnection as Base} from './5.Messages' +import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants' import { GroupSettingChange } from './Constants' import { generateMessageID } from '../WAConnection/Utils' -export default class WhatsAppWebGroups extends WhatsAppWebBase { +export class WAConnection extends Base { /** Generic function for group queries */ async groupQuery(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: WANode[]) { const tag = this.generateMessageTag() diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 4db2345..6090263 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -133,6 +133,152 @@ export enum WAFlag { } /** Tag used with binary queries */ export type WATag = [WAMetric, WAFlag] -export * as WAMessageProto from '../../WAMessage/WAMessage' +// 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 { + available = 'available', // "online" + unavailable = 'unavailable', // "offline" + composing = 'composing', // "typing..." + recording = 'recording', // "recording..." + paused = 'paused', // I have no clue +} +/** Status of a message sent or received */ +export enum MessageStatus { + sent = 'sent', + received = 'received', + read = 'read', +} +/** Set of message types that are supported by the library */ +export enum MessageType { + text = 'conversation', + extendedText = 'extendedTextMessage', + contact = 'contactMessage', + location = 'locationMessage', + liveLocation = 'liveLocationMessage', + image = 'imageMessage', + video = 'videoMessage', + sticker = 'stickerMessage', + document = 'documentMessage', + audio = 'audioMessage', + product = 'productMessage' +} +export enum ChatModification { + archive='archive', + unarchive='unarchive', + pin='pin', + unpin='unpin', + mute='mute', + unmute='unmute' +} +export const HKDFInfoKeys = { + [MessageType.image]: 'WhatsApp Image Keys', + [MessageType.audio]: 'WhatsApp Audio Keys', + [MessageType.video]: 'WhatsApp Video Keys', + [MessageType.document]: 'WhatsApp Document Keys', + [MessageType.sticker]: 'WhatsApp Image Keys' +} +export enum Mimetype { + jpeg = 'image/jpeg', + png = 'image/png', + mp4 = 'video/mp4', + gif = 'video/gif', + pdf = 'application/pdf', + ogg = 'audio/ogg; codecs=opus', + /** for stickers */ + webp = 'image/webp', +} +export interface MessageOptions { + quoted?: WAMessage + contextInfo?: WAContextInfo + timestamp?: Date + caption?: string + thumbnail?: string + mimetype?: Mimetype | string + filename?: string +} +export interface WABroadcastListInfo { + status: number + name: string + recipients?: {id: string}[] +} +export interface WAUrlInfo { + 'canonical-url': string + 'matched-text': string + title: string + description: string + jpegThumbnail?: Buffer +} +export interface WAProfilePictureChange { + status: number + tag: string + eurl: string +} +export interface MessageInfo { + reads: {jid: string, t: string}[] + deliveries: {jid: string, t: string}[] +} +export interface MessageStatusUpdate { + from: string + to: string + /** Which participant caused the update (only for groups) */ + participant?: string + timestamp: Date + /** Message IDs read/delivered */ + ids: string[] + /** Status of the Message IDs */ + type: WA_MESSAGE_STATUS_TYPE +} +export enum GroupSettingChange { + messageSend = 'announcement', + settingsChange = 'locked', +} +export interface PresenceUpdate { + id: string + participant?: string + t?: string + type?: Presence + deny?: boolean +} +// path to upload the media +export const MediaPathMap = { + imageMessage: '/mms/image', + videoMessage: '/mms/video', + documentMessage: '/mms/document', + audioMessage: '/mms/audio', + stickerMessage: '/mms/image', +} +// gives WhatsApp info to process the media +export const MimetypeMap = { + imageMessage: Mimetype.jpeg, + videoMessage: Mimetype.mp4, + documentMessage: Mimetype.pdf, + audioMessage: Mimetype.ogg, + stickerMessage: Mimetype.webp, +} +export interface WASendMessageResponse { + status: number + 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 diff --git a/src/WAConnection/Utils.ts b/src/WAConnection/Utils.ts index 2b1e553..03479b2 100644 --- a/src/WAConnection/Utils.ts +++ b/src/WAConnection/Utils.ts @@ -1,9 +1,13 @@ import * as Crypto from 'crypto' import HKDF from 'futoin-hkdf' -import Decoder from '../Binary/Decoder' +import Jimp from 'jimp' +import fs from 'fs/promises' +import fetch from 'node-fetch' +import { exec } from 'child_process' import {platform, release} from 'os' -import { BaileysError, WAChat } from './Constants' -import UserAgent from 'user-agents' + +import Decoder from '../Binary/Decoder' +import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageType, WAMessage, WAMessageContent, BaileysError, WAMessageProto } from './Constants' const platformMap = { 'aix': 'AIX', @@ -25,10 +29,10 @@ function hashCode(s: string) { } export const waChatUniqueKey = (c: WAChat) => ((+c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending -export function userAgentString (browser) { +/*export function userAgentString (browser) { const agent = new UserAgent (new RegExp(browser)) return agent.toString () -} +}*/ /** 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)) @@ -65,6 +69,7 @@ export function randomBytes(length) { return Crypto.randomBytes(length) } export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout)) + export async function promiseTimeout(ms: number, promise: Promise) { if (!ms) return promise // Create a promise that rejects in milliseconds @@ -139,4 +144,151 @@ export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buf json = decoder.read(decrypted) // decode the binary message into a JSON array } return [messageTag, json, tags] +} +/** generates all the keys required to encrypt/decrypt & sign a media message */ +export function getMediaKeys(buffer, mediaType: MessageType) { + if (typeof buffer === 'string') { + buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64') + } + // expand using HKDF to 112 bytes, also pass in the relevant app info + const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType]) + return { + iv: expandedMediaKey.slice(0, 16), + cipherKey: expandedMediaKey.slice(16, 48), + macKey: expandedMediaKey.slice(48, 80), + } +} +/** Extracts video thumb using FFMPEG */ +const extractVideoThumb = async ( + path: string, + destPath: string, + time: string, + size: { width: number; height: number }, +) => + new Promise((resolve, reject) => { + const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}` + exec(cmd, (err) => { + if (err) reject(err) + else resolve() + }) + }) as Promise + +export const compressImage = async (buffer: Buffer) => { + const jimp = await Jimp.read (buffer) + return jimp.resize(48, 48).getBufferAsync (Jimp.MIME_JPEG) +} +export const generateProfilePicture = async (buffer: Buffer) => { + const jimp = await Jimp.read (buffer) + const min = Math.min(jimp.getWidth (), jimp.getHeight ()) + const cropped = jimp.crop (0, 0, min, min) + return { + img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG), + preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG) + } +} +/** generates a thumbnail for a given media, if required */ +export async function generateThumbnail(buffer: Buffer, mediaType: MessageType, info: MessageOptions) { + if (info.thumbnail === null || info.thumbnail) { + // don't do anything if the thumbnail is already provided, or is null + if (mediaType === MessageType.audio) { + throw new Error('audio messages cannot have thumbnails') + } + } else if (mediaType === MessageType.image || mediaType === MessageType.sticker) { + const buff = await compressImage (buffer) + info.thumbnail = buff.toString('base64') + } else if (mediaType === MessageType.video) { + const filename = './' + randomBytes(5).toString('hex') + '.mp4' + const imgFilename = filename + '.jpg' + await fs.writeFile(filename, buffer) + try { + await extractVideoThumb(filename, imgFilename, '00:00:00', { width: 48, height: 48 }) + const buff = await fs.readFile(imgFilename) + info.thumbnail = buff.toString('base64') + await fs.unlink(imgFilename) + } catch (err) { + console.log('could not generate video thumb: ' + err) + } + await fs.unlink(filename) + } +} +/** + * Decode a media message (video, image, document, audio) & return decrypted buffer + * @param message the media message you want to decode + */ +export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchHeaders: {[k: string]: string} = {}) { + /* + One can infer media type from the key in the message + it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc. + */ + const type = Object.keys(message)[0] as MessageType + if (!type) { + throw new BaileysError('unknown message type', message) + } + if (type === MessageType.text || type === MessageType.extendedText) { + throw new BaileysError('cannot decode text message', message) + } + if (type === MessageType.location || type === MessageType.liveLocation) { + return new Buffer(message[type].jpegThumbnail) + } + let messageContent: WAMessageProto.IVideoMessage | WAMessageProto.IImageMessage | WAMessageProto.IAudioMessage | WAMessageProto.IDocumentMessage + if (message.productMessage) { + const product = message.productMessage.product?.productImage + if (!product) throw new BaileysError ('product has no image', message) + messageContent = product + } else { + messageContent = message[type] + } + + // download the message + const headers = { Origin: 'https://web.whatsapp.com' } + const fetched = await fetch(messageContent.url, { headers }) + const buffer = await fetched.buffer() + + if (buffer.length <= 10) { + throw new BaileysError ('Empty buffer returned. File has possibly been deleted from WA servers. Run `client.updateMediaMessage()` to refresh the url', {status: 404}) + } + + const decryptedMedia = (type: MessageType) => { + // get the keys to decrypt the message + const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type) + // first part is actual file + const file = buffer.slice(0, buffer.length - 10) + // last 10 bytes is HMAC sign of file + const mac = buffer.slice(buffer.length - 10, buffer.length) + // sign IV+file & check for match with mac + const testBuff = Buffer.concat([mediaKeys.iv, file]) + const sign = hmacSign(testBuff, mediaKeys.macKey).slice(0, 10) + // our sign should equal the mac + if (!sign.equals(mac)) throw new Error() + + return aesDecryptWithIV(file, mediaKeys.cipherKey, mediaKeys.iv) // decrypt media + } + const allTypes = [type, ...Object.keys(HKDFInfoKeys)] + for (let i = 0; i < allTypes.length;i++) { + try { + const decrypted = decryptedMedia (allTypes[i] as MessageType) + + if (i > 0) { console.log (`decryption of ${type} media with HKDF key of ${allTypes[i]}`) } + return decrypted + } catch { + if (i === 0) { console.log (`decryption of ${type} media with original HKDF key failed`) } + } + } + throw new BaileysError('Decryption failed, HMAC sign does not match', {status: 400}) +} +export function extensionForMediaMessage(message: WAMessageContent) { + const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1] + const type = Object.keys(message)[0] as MessageType + let extension: string + if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) { + extension = '.jpeg' + } else { + const messageContent = message[type] as + | WAMessageProto.VideoMessage + | WAMessageProto.ImageMessage + | WAMessageProto.AudioMessage + | WAMessageProto.DocumentMessage + extension = getExtension (messageContent.mimetype) + } + return extension } \ No newline at end of file diff --git a/src/WAConnection/WAConnection.ts b/src/WAConnection/WAConnection.ts index 033fcdd..92d7a98 100644 --- a/src/WAConnection/WAConnection.ts +++ b/src/WAConnection/WAConnection.ts @@ -1,2 +1,3 @@ -import WAConnection from './Connect' -export default WAConnection +export * from './6.Groups' +export * from './Utils' +export * from './Constants' \ No newline at end of file