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