mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
finalize multi-device
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,4 +10,5 @@ lib
|
||||
docs
|
||||
browser-token.json
|
||||
Proxy
|
||||
messages*.json
|
||||
messages*.json
|
||||
test.ts
|
||||
@@ -1,97 +0,0 @@
|
||||
const WhatsAppWeb = require("../WhatsAppWeb")
|
||||
const fs = require("fs")
|
||||
|
||||
/**
|
||||
* Extract all your WhatsApp conversations & save them to a file
|
||||
* produceAnonData => should the Id of the chat be recorded
|
||||
* */
|
||||
function extractChats (authCreds, outputFile, produceAnonData=false, offset=null) {
|
||||
let client = new WhatsAppWeb() // instantiate an instance
|
||||
// internal extract function
|
||||
const extract = function () {
|
||||
let rows = 0
|
||||
let chats = Object.keys(client.chats)
|
||||
let encounteredOffset
|
||||
if (offset) {
|
||||
encounteredOffset = false
|
||||
} else {
|
||||
encounteredOffset = true
|
||||
fs.writeFileSync(outputFile, "chat,input,output\n") // write header to file
|
||||
}
|
||||
|
||||
const extractChat = function (index) {
|
||||
const id = chats[index]
|
||||
if (id.includes("g.us") || !encounteredOffset) { // skip groups
|
||||
if (id === offset) {
|
||||
encounteredOffset = true
|
||||
}
|
||||
if (index+1 < chats.length) {
|
||||
return extractChat(index+1)
|
||||
}
|
||||
return
|
||||
}
|
||||
console.log("extracting for " + id + "...")
|
||||
|
||||
var curInput = ""
|
||||
var curOutput = ""
|
||||
var lastMessage
|
||||
return client.loadEntireConversation (id, m => {
|
||||
var text
|
||||
if (!m.message) { // if message not present, return
|
||||
return
|
||||
} else if (m.message.conversation) { // if its a plain text message
|
||||
text = m.message.conversation
|
||||
} else if (m.message.extendedTextMessage && m.message.extendedTextMessage.contextInfo) { // if its a reply to a previous message
|
||||
const mText = m.message.extendedTextMessage.text
|
||||
const quotedMessage = m.message.extendedTextMessage.contextInfo.quotedMessage
|
||||
// if it's like a '.' and the quoted message has no text, then just forget it
|
||||
if (mText.length <= 2 && !quotedMessage.conversation) {
|
||||
return
|
||||
}
|
||||
// if somebody sent like a '.', then the text should be the quoted message
|
||||
if (mText.length <= 2) {
|
||||
text = quotedMessage.conversation
|
||||
} else { // otherwise just use this text
|
||||
text = mText
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// if the person who sent the message has switched, flush the row
|
||||
if (lastMessage && !m.key.fromMe && lastMessage.key.fromMe) {
|
||||
|
||||
let row = "" + (produceAnonData ? "" : id) + ",\"" + curInput + "\",\"" + curOutput + "\"\n"
|
||||
fs.appendFileSync (outputFile, row)
|
||||
rows += 1
|
||||
curInput = ""
|
||||
curOutput = ""
|
||||
}
|
||||
|
||||
if (m.key.fromMe) {
|
||||
curOutput += curOutput === "" ? text : ("\n"+text)
|
||||
} else {
|
||||
curInput += curInput === "" ? text : ("\n"+text)
|
||||
}
|
||||
|
||||
lastMessage = m
|
||||
}, 50, false) // load from the start, in chunks of 50
|
||||
.then (() => console.log("finished extraction for " + id))
|
||||
.then (() => {
|
||||
if (index+1 < chats.length) {
|
||||
return extractChat(index+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
extractChat(0)
|
||||
.then (() => {
|
||||
console.log("extracted all; total " + rows + " rows")
|
||||
client.logout ()
|
||||
})
|
||||
}
|
||||
client.connect (authCreds)
|
||||
.then (() => extract())
|
||||
.catch (err => console.log("got error: " + error))
|
||||
}
|
||||
let creds = null//JSON.parse(fs.readFileSync("auth_info.json"))
|
||||
extractChats(creds, "output.csv")
|
||||
@@ -1,13 +1,84 @@
|
||||
import makeConnection from '../src'
|
||||
import * as fs from 'fs'
|
||||
import { readFileSync, writeFileSync } from "fs"
|
||||
import P from "pino"
|
||||
import { Boom } from "@hapi/boom"
|
||||
import makeWASocket, { WASocket, AuthenticationState, DisconnectReason, AnyMessageContent, BufferJSON, initInMemoryKeyStore, delay } from '../src'
|
||||
|
||||
async function example() {
|
||||
const conn = makeConnection({
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
conn.ev.on('connection.update', state => {
|
||||
console.log(state)
|
||||
})
|
||||
}
|
||||
(async() => {
|
||||
let sock: WASocket | undefined = undefined
|
||||
// load authentication state from a file
|
||||
const loadState = () => {
|
||||
let state: AuthenticationState | undefined = undefined
|
||||
try {
|
||||
const value = JSON.parse(
|
||||
readFileSync('./auth_info_multi.json', { encoding: 'utf-8' }),
|
||||
BufferJSON.reviver
|
||||
)
|
||||
state = {
|
||||
creds: value.creds,
|
||||
// stores pre-keys, session & other keys in a JSON object
|
||||
// we deserialize it here
|
||||
keys: initInMemoryKeyStore(value.keys)
|
||||
}
|
||||
} catch{ }
|
||||
return state
|
||||
}
|
||||
// save the authentication state to a file
|
||||
const saveState = (state?: any) => {
|
||||
console.log('saving pre-keys')
|
||||
state = state || sock?.authState
|
||||
writeFileSync(
|
||||
'./auth_info_multi.json',
|
||||
// BufferJSON replacer utility saves buffers nicely
|
||||
JSON.stringify(state, BufferJSON.replacer, 2)
|
||||
)
|
||||
}
|
||||
// start a connection
|
||||
const startSock = () => {
|
||||
const sock = makeWASocket({
|
||||
logger: P({ level: 'trace' }),
|
||||
auth: loadState()
|
||||
})
|
||||
sock.ev.on('messages.upsert', m => {
|
||||
console.log(JSON.stringify(m, undefined, 2))
|
||||
|
||||
const msg = m.messages[0]
|
||||
if(!msg.key.fromMe && m.type === 'notify') {
|
||||
console.log('replying to', m.messages[0].key.remoteJid)
|
||||
sendMessageWTyping({ text: 'Hello there!' }, m.messages[0].key.remoteJid!)
|
||||
}
|
||||
|
||||
})
|
||||
sock.ev.on('messages.update', m => console.log(m))
|
||||
sock.ev.on('presence.update', m => console.log(m))
|
||||
sock.ev.on('chats.update', m => console.log(m))
|
||||
return sock
|
||||
}
|
||||
|
||||
example().catch((err) => console.log(`encountered error`, err))
|
||||
const sendMessageWTyping = async(msg: AnyMessageContent, jid: string) => {
|
||||
|
||||
await sock.presenceSubscribe(jid)
|
||||
await delay(500)
|
||||
|
||||
await sock.sendPresenceUpdate('composing', jid)
|
||||
await delay(2000)
|
||||
|
||||
await sock.sendPresenceUpdate('paused', jid)
|
||||
}
|
||||
|
||||
sock = startSock()
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect } = update
|
||||
if(connection === 'close') {
|
||||
// reconnect if not logged out
|
||||
if((lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) {
|
||||
sock = startSock()
|
||||
} else {
|
||||
console.log('connection closed')
|
||||
}
|
||||
}
|
||||
console.log('connection update', update)
|
||||
})
|
||||
// listen for when the auth state is updated
|
||||
// it is imperative you save this data, it affects the signing keys you need to have conversations
|
||||
sock.ev.on('auth-state.update', () => saveState())
|
||||
})()
|
||||
21
LICENSE.md
21
LICENSE.md
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Adhiraj Singh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
558
README.md
558
README.md
@@ -1,8 +1,10 @@
|
||||
# Baileys - Typescript/Javascript WhatsApp Web API
|
||||
# Baileys MD - Typescript/Javascript WhatsApp Web API
|
||||
|
||||
Early Multi-Device Edition. Breaks completely from master.
|
||||
|
||||
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 his observations on the workings of WhatsApp Web and thanks to [@Rhymen](https://github.com/Rhymen/go-whatsapp/) for the __go__ implementation.
|
||||
Thank you to [@pokearaujo](https://github.com/pokearaujo/multidevice) for writing his observations on the workings of WhatsApp Multi-Device.
|
||||
|
||||
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).
|
||||
|
||||
@@ -12,107 +14,102 @@
|
||||
**Join the Discord [here](https://discord.gg/7FYURJyqng)**
|
||||
|
||||
## Example
|
||||
|
||||
Do check out & run [example.ts](Example/example.ts) to see example usage of the library.
|
||||
The script covers most common use cases.
|
||||
To run the example script, download or clone the repo and then type the following in terminal:
|
||||
1. ``` cd path/to/Baileys ```
|
||||
2. ``` npm install ```
|
||||
3. ``` npm run example ```
|
||||
2. ``` yarn```
|
||||
3. ``` yarn example ```
|
||||
|
||||
## Install
|
||||
Create and cd to your NPM project directory and then in terminal, write:
|
||||
1. stable: `npm install @adiwajshing/baileys`
|
||||
2. stabl-ish w quicker fixes & latest features: `npm install github:adiwajshing/baileys`
|
||||
|
||||
Do note, the library will likely vary if you're using the NPM package, read that [here](https://www.npmjs.com/package/@adiwajshing/baileys)
|
||||
Right now, the multi-device branch is only available from GitHub, install using:
|
||||
```
|
||||
yarn add github:adiwajshing/baileys#multi-device
|
||||
```
|
||||
|
||||
Then import in your code using:
|
||||
``` ts
|
||||
import { WAConnection } from '@adiwajshing/baileys'
|
||||
import makeWASocket from '@adiwajshing/baileys'
|
||||
```
|
||||
|
||||
## Unit Tests
|
||||
Baileys also comes with a unit test suite. Simply cd into the Baileys directory & run `npm test`.
|
||||
|
||||
You will require a phone with WhatsApp to test, and a second WhatsApp number to send messages to.
|
||||
Set the phone number you can randomly send messages to in a `.env` file with `TEST_JID=1234@s.whatsapp.net`
|
||||
TODO
|
||||
|
||||
## Connecting
|
||||
|
||||
``` ts
|
||||
import { WAConnection } from '@adiwajshing/baileys'
|
||||
import makeWASocket from '@adiwajshing/baileys'
|
||||
|
||||
async function connectToWhatsApp () {
|
||||
const conn = new WAConnection()
|
||||
// called when WA sends chats
|
||||
// this can take up to a few minutes if you have thousands of chats!
|
||||
conn.on('chats-received', async ({ hasNewChats }) => {
|
||||
console.log(`you have ${conn.chats.length} chats, new chats available: ${hasNewChats}`)
|
||||
|
||||
const unread = await conn.loadAllUnreadMessages ()
|
||||
console.log ("you have " + unread.length + " unread messages")
|
||||
const conn = makeWASocket({
|
||||
// can provide additional config here
|
||||
printQRInTerminal: true
|
||||
})
|
||||
// called when WA sends chats
|
||||
// this can take up to a few minutes if you have thousands of contacts!
|
||||
conn.on('contacts-received', () => {
|
||||
console.log('you have ' + Object.keys(conn.contacts).length + ' contacts')
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect } = update
|
||||
if(connection === 'close') {
|
||||
const shouldReconnect = (lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut
|
||||
console.log('connection closed due to ', lastDisconnect.error, ', reconnecting ', shouldReconnect)
|
||||
// reconnect if not logged out
|
||||
if(shouldReconnect) {
|
||||
sock = startSock()
|
||||
}
|
||||
} else if(connection === 'open') {
|
||||
console.log('opened connection')
|
||||
}
|
||||
})
|
||||
sock.ev.on('messages.upsert', m => {
|
||||
console.log(JSON.stringify(m, undefined, 2))
|
||||
|
||||
await conn.connect ()
|
||||
conn.on('chat-update', chatUpdate => {
|
||||
// `chatUpdate` is a partial object, containing the updated properties of the chat
|
||||
// received a new message
|
||||
if (chatUpdate.messages && chatUpdate.count) {
|
||||
const message = chatUpdate.messages.all()[0]
|
||||
console.log (message)
|
||||
} else console.log (chatUpdate) // see updates (can be archived, pinned etc.)
|
||||
console.log('replying to', m.messages[0].key.remoteJid)
|
||||
sendMessageWTyping({ text: 'Hello there!' }, m.messages[0].key.remoteJid!)
|
||||
})
|
||||
}
|
||||
// run in main file
|
||||
connectToWhatsApp ()
|
||||
.catch (err => console.log("unexpected error: " + err) ) // catch any errors
|
||||
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!
|
||||
|
||||
Do note, the `conn.chats` object is a [KeyedDB](https://github.com/adiwajshing/keyed-db). This is done for the following reasons:
|
||||
- Most applications require chats to be ordered in descending order of time. (`KeyedDB` does this in `log(N)` time)
|
||||
- Most applications require pagination of chats (Use `chats.paginated()`)
|
||||
- Most applications require **O(1)** access to chats via the chat ID. (Use `chats.get(jid)` with `KeyedDB`)
|
||||
**Note:** install `qrcode-terminal` using `yarn add qrcode-terminal` to auto-print the QR to the terminal.
|
||||
|
||||
## Notable Differences Between Baileys Web & MD
|
||||
|
||||
1. Baileys no longer maintains an internal state of chats/contacts/messages. You must take this on your own, simply because your state in MD is its own source of truth & there is no one-size-fits-all way to handle the storage for this.
|
||||
2. A baileys "socket" is meant to be a temporary & disposable object -- this is done to maintain simplicity & prevent bugs. I felt the entire Baileys object became too bloated as it supported too many configurations.
|
||||
3. Moreover, Baileys does not offer an inbuilt reconnect mechanism anymore (though it's super easy to set one up on your own with your own rules, check the example script)
|
||||
|
||||
## Configuring the Connection
|
||||
|
||||
You can configure the connection via the `connectOptions` property. You can even specify an HTTPS proxy. For example:
|
||||
You can configure the connection by passing a `SocketConfig` object.
|
||||
|
||||
The entire `SocketConfig` structure is mentioned here with default values:
|
||||
``` ts
|
||||
import { WAConnection, ProxyAgent } from '@adiwajshing/baileys'
|
||||
|
||||
const conn = new WAConnecion ()
|
||||
conn.connectOptions.agent = ProxyAgent ('http://some-host:1234')
|
||||
|
||||
await conn.connect ()
|
||||
console.log ("oh hello " + conn.user.name + "! You connected via a proxy")
|
||||
```
|
||||
|
||||
The entire `WAConnectOptions` struct is mentioned here with default values:
|
||||
``` ts
|
||||
conn.connectOptions = {
|
||||
/** fails the connection if no data is received for X seconds */
|
||||
maxIdleTimeMs?: 60_000,
|
||||
/** maximum attempts to connect */
|
||||
maxRetries?: 10,
|
||||
/** max time for the phone to respond to a connectivity test */
|
||||
phoneResponseTime?: 15_000,
|
||||
/** minimum time between new connections */
|
||||
connectCooldownMs?: 4000,
|
||||
/** agent used for WS connections (could be a proxy agent) */
|
||||
agent?: Agent = undefined,
|
||||
/** agent used for fetch requests -- uploading/downloading media */
|
||||
fetchAgent?: Agent = undefined,
|
||||
/** always uses takeover for connecting */
|
||||
alwaysUseTakeover: true
|
||||
/** log QR to terminal */
|
||||
logQR: true
|
||||
} as WAConnectOptions
|
||||
type SocketConfig = {
|
||||
/** provide an auth state object to maintain the auth state */
|
||||
auth?: AuthenticationState
|
||||
/** the WS url to connect to WA */
|
||||
waWebSocketUrl: string | URL
|
||||
/** Fails the connection if the connection times out in this time interval or no data is received */
|
||||
connectTimeoutMs: number
|
||||
/** ping-pong interval for WS connection */
|
||||
keepAliveIntervalMs: number
|
||||
/** proxy agent */
|
||||
agent?: Agent
|
||||
/** pino logger */
|
||||
logger: Logger
|
||||
/** version to connect with */
|
||||
version: WAVersion
|
||||
/** override browser config */
|
||||
browser: WABrowserDescription
|
||||
/** agent used for fetch requests -- uploading/downloading media */
|
||||
fetchAgent?: Agent
|
||||
/** should the QR be printed in the terminal */
|
||||
printQRInTerminal: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Saving & Restoring Sessions
|
||||
@@ -121,92 +118,126 @@ You obviously don't want to keep scanning the QR code every time you want to con
|
||||
|
||||
So, you can save the credentials to log back in via:
|
||||
``` ts
|
||||
import makeWASocket, { BufferJSON } from '@adiwajshing/baileys'
|
||||
import * as fs from 'fs'
|
||||
|
||||
const conn = new WAConnection()
|
||||
// will initialize a default in-memory auth session
|
||||
const conn = makeSocket()
|
||||
// this will be called as soon as the credentials are updated
|
||||
conn.on ('open', () => {
|
||||
conn.ev.on ('auth-state.update', () => {
|
||||
// save credentials whenever updated
|
||||
console.log (`credentials updated!`)
|
||||
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
|
||||
const authInfo = conn.authState // get all the auth info we need to restore this session
|
||||
// save this info to a file
|
||||
fs.writeFileSync(
|
||||
'./auth_info.json',
|
||||
JSON.stringify(authInfo, BufferJSON.replacer, 2)
|
||||
)
|
||||
})
|
||||
await conn.connect() // connect
|
||||
```
|
||||
|
||||
Then, to restore a session:
|
||||
``` ts
|
||||
const conn = new WAConnection()
|
||||
conn.loadAuthInfo ('./auth_info.json') // will load JSON credentials from file
|
||||
await conn.connect()
|
||||
// yay connected without scanning QR
|
||||
/*
|
||||
Optionally, you can load the credentials yourself from somewhere
|
||||
& pass in the JSON object to loadAuthInfo () as well.
|
||||
*/
|
||||
```
|
||||
import makeWASocket, { BufferJSON, initInMemoryKeyStore } from '@adiwajshing/baileys'
|
||||
import * as fs from 'fs'
|
||||
|
||||
If you're considering switching from a Chromium/Puppeteer based library, you can use WhatsApp Web's Browser credentials to restore sessions too:
|
||||
``` ts
|
||||
conn.loadAuthInfo ('./auth_info_browser.json')
|
||||
await conn.connect() // works the same
|
||||
```
|
||||
See the browser credentials type in the docs.
|
||||
|
||||
**Note**: Upon every successive connection, WA can update part of the stored credentials. Whenever that happens, the credentials are uploaded, and you should probably update your saved credentials upon receiving the `open` event. Not doing so *may* lead WA to log you out after a few weeks with a 419 error code.
|
||||
|
||||
## QR Callback
|
||||
|
||||
If you want to do some custom processing with the QR code used to authenticate, you can register for the following event:
|
||||
``` ts
|
||||
conn.on('qr', qr => {
|
||||
// Now, use the 'qr' string to display in QR UI or send somewhere
|
||||
const authJSON = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'./auth_info.json',
|
||||
{ encoding: 'utf-8' }
|
||||
),
|
||||
BufferJSON.reviver
|
||||
)
|
||||
const auth = {
|
||||
creds: authJSON.creds,
|
||||
// stores pre-keys, session & other keys in a JSON object
|
||||
// we deserialize it here
|
||||
keys: initInMemoryKeyStore(authJSON.keys)
|
||||
}
|
||||
await conn.connect ()
|
||||
const conn = makeWASocket(auth)
|
||||
// yay will connect without scanning QR
|
||||
```
|
||||
|
||||
**Note**: Upon every successive connection, the auth state can update part of the stored credentials. It will also update when a message is received/sent due to signal sessions needing updating. Whenever that happens, the `auth-state.update` event is fired uploaded, and you must update your saved credentials upon receiving the event. Not doing so will prevent your messages from reaching the recipient & other unexpected consequences.
|
||||
|
||||
## Listening to Connection Updates
|
||||
|
||||
Baileys now fires the `connection.update` event to let you know something has updated in the connection. This data has the following structure:
|
||||
``` ts
|
||||
type ConnectionState = {
|
||||
/** connection is now open, connecting or closed */
|
||||
connection: WAConnectionState
|
||||
/** the error that caused the connection to close */
|
||||
lastDisconnect?: {
|
||||
error: Error
|
||||
date: Date
|
||||
}
|
||||
/** is this a new login */
|
||||
isNewLogin?: boolean
|
||||
/** the current QR code */
|
||||
qr?: string
|
||||
/** has the device received all pending notifications while it was offline */
|
||||
receivedPendingNotifications?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Note: this also offers any updates to the QR
|
||||
|
||||
## Handling Events
|
||||
|
||||
Baileys now uses the EventEmitter syntax for events.
|
||||
Baileys 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.
|
||||
|
||||
Also, these events are fired regardless of whether they are initiated by the Baileys client or are relayed from your phone.
|
||||
The events are typed up in a type map, as mentioned here:
|
||||
|
||||
``` ts
|
||||
/** when the connection has opened successfully */
|
||||
on (event: 'open', listener: (result: WAOpenResult) => void): this
|
||||
/** when the connection is opening */
|
||||
on (event: 'connecting', listener: () => void): this
|
||||
/** when the connection has closed */
|
||||
on (event: 'close', listener: (err: {reason?: DisconnectReason | string, isReconnecting: boolean}) => void): this
|
||||
/** when the socket is closed */
|
||||
on (event: 'ws-close', listener: (err: {reason?: DisconnectReason | string}) => 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 contact is updated */
|
||||
on (event: 'contact-update', listener: (update: WAContactUpdate) => void): this
|
||||
/** when a new chat is added */
|
||||
on (event: 'chat-new', listener: (chat: WAChat) => void): this
|
||||
/** when contacts are sent by WA */
|
||||
on (event: 'contacts-received', listener: (u: { updatedContacts: Partial<WAContact>[] }) => void): this
|
||||
/** when chats are sent by WA, and when all messages are received */
|
||||
on (event: 'chats-received', listener: (update: {hasNewChats?: boolean}) => void): this
|
||||
/** when all initial messages are received from WA */
|
||||
on (event: 'initial-data-received', listener: (update: {chatsWithMissingMessages: { jid: string, count: number }[] }) => void): this
|
||||
/** when multiple chats are updated (new message, updated message, deleted, pinned, etc) */
|
||||
on (event: 'chats-update', listener: (chats: WAChatUpdate[]) => void): this
|
||||
/** when a chat is updated (new message, updated message, read message, deleted, pinned, presence updated etc) */
|
||||
on (event: 'chat-update', listener: (chat: WAChatUpdate) => void): this
|
||||
/** when participants are added to a group */
|
||||
on (event: 'group-participants-update', listener: (update: {jid: string, participants: string[], actor?: string, action: WAParticipantAction}) => void): this
|
||||
/** when the group is updated */
|
||||
on (event: 'group-update', listener: (update: Partial<WAGroupMetadata> & {jid: string, actor?: string}) => void): this
|
||||
/** when WA sends back a pong */
|
||||
on (event: 'received-pong', listener: () => void): this
|
||||
/** when a user is blocked or unblockd */
|
||||
on (event: 'blocklist-update', listener: (update: BlocklistUpdate) => void): this
|
||||
|
||||
export type BaileysEventMap = {
|
||||
/** connection state has been updated -- WS closed, opened, connecting etc. */
|
||||
'connection.update': Partial<ConnectionState>
|
||||
/** auth state updated -- some pre keys, or identity keys etc. */
|
||||
'auth-state.update': AuthenticationState
|
||||
/** set chats (history sync), messages are reverse chronologically sorted */
|
||||
'chats.set': { chats: Chat[], messages: WAMessage[] }
|
||||
/** update/insert chats */
|
||||
'chats.upsert': Chat[]
|
||||
/** update the given chats */
|
||||
'chats.update': Partial<Chat>[]
|
||||
/** delete chats with given ID */
|
||||
'chats.delete': string[]
|
||||
/** presence of contact in a chat updated */
|
||||
'presence.update': { id: string, presences: { [participant: string]: PresenceData } }
|
||||
|
||||
'contacts.upsert': Contact[]
|
||||
'contacts.update': Partial<Contact>[]
|
||||
|
||||
'messages.delete': { jid: string, ids: string[] } | { jid: string, all: true }
|
||||
'messages.update': WAMessageUpdate[]
|
||||
/**
|
||||
* add/update the given messages. If they were received while the connection was online,
|
||||
* the update will have type: "notify"
|
||||
* */
|
||||
'messages.upsert': { messages: WAMessage[], type: MessageUpdateType }
|
||||
|
||||
'message-info.update': MessageInfoUpdate[]
|
||||
|
||||
'groups.update': Partial<GroupMetadata>[]
|
||||
/** apply an action to participants in a group */
|
||||
'group-participants.update': { id: string, participants: string[], action: ParticipantAction }
|
||||
|
||||
'blocklist.set': { blocklist: string[] }
|
||||
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }
|
||||
}
|
||||
```
|
||||
|
||||
You can listen to these events like this:
|
||||
``` ts
|
||||
|
||||
const sock = makeWASocket()
|
||||
sock.ev.on('messages.upsert', ({ messages }) => {
|
||||
console.log('got messages', messages)
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
## Sending Messages
|
||||
@@ -220,9 +251,12 @@ import { MessageType, MessageOptions, Mimetype } from '@adiwajshing/baileys'
|
||||
|
||||
const id = 'abcd@s.whatsapp.net' // the WhatsApp ID
|
||||
// send a simple text!
|
||||
const sentMsg = await conn.sendMessage (id, 'oh hello there', MessageType.text)
|
||||
const sentMsg = await conn.sendMessage(id, { text: 'oh hello there' })
|
||||
// send a location!
|
||||
const sentMsg = await conn.sendMessage(id, {degreesLatitude: 24.121231, degreesLongitude: 55.1121221}, MessageType.location)
|
||||
const sentMsg = await conn.sendMessage(
|
||||
id,
|
||||
{ location: { degreesLatitude: 24.121231, degreesLongitude: 55.1121221 } }
|
||||
)
|
||||
// send a contact!
|
||||
const vcard = 'BEGIN:VCARD\n' // metadata of the contact card
|
||||
+ 'VERSION:3.0\n'
|
||||
@@ -230,7 +264,15 @@ const vcard = 'BEGIN:VCARD\n' // metadata of the contact card
|
||||
+ 'ORG:Ashoka Uni;\n' // the organization of the contact
|
||||
+ 'TEL;type=CELL;type=VOICE;waid=911234567890:+91 12345 67890\n' // WhatsApp ID + phone number
|
||||
+ 'END:VCARD'
|
||||
const sentMsg = await conn.sendMessage(id, {displayname: "Jeff", vcard: vcard}, MessageType.contact)
|
||||
const sentMsg = await conn.sendMessage(
|
||||
id,
|
||||
{
|
||||
contacts: {
|
||||
displayName: 'Jeff',
|
||||
contacts: [{ vcard }]
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Media Messages
|
||||
@@ -244,34 +286,38 @@ import { MessageType, MessageOptions, Mimetype } from '@adiwajshing/baileys'
|
||||
// Sending gifs
|
||||
await conn.sendMessage(
|
||||
id,
|
||||
fs.readFileSync("Media/ma_gif.mp4"), // load a gif and send it
|
||||
MessageType.video,
|
||||
{ mimetype: Mimetype.gif, caption: "hello!" }
|
||||
{
|
||||
video: fs.readFileSync("Media/ma_gif.mp4"),
|
||||
caption: "hello!",
|
||||
gifPlayback: true
|
||||
}
|
||||
)
|
||||
await conn.sendMessage(
|
||||
id,
|
||||
{ url: 'Media/ma_gif.mp4' }, // send directly from local file
|
||||
MessageType.video,
|
||||
{ mimetype: Mimetype.gif, caption: "hello!" }
|
||||
{
|
||||
video: "./Media/ma_gif.mp4",
|
||||
caption: "hello!",
|
||||
gifPlayback: true
|
||||
}
|
||||
)
|
||||
|
||||
await conn.sendMessage(
|
||||
id,
|
||||
{ url: 'https://giphy.com/gifs/11JTxkrmq4bGE0/html5' }, // send directly from remote url!
|
||||
MessageType.video,
|
||||
{ mimetype: Mimetype.gif, caption: "hello!" }
|
||||
{
|
||||
video: "./Media/ma_gif.mp4",
|
||||
caption: "hello!",
|
||||
gifPlayback: true
|
||||
}
|
||||
)
|
||||
|
||||
// send an audio file
|
||||
await conn.sendMessage(
|
||||
id,
|
||||
{ audio: { url: "./Media/audio.mp3" }, mimetype: 'audio/mp4' }
|
||||
{ url: "Media/audio.mp3" }, // can send mp3, mp4, & ogg
|
||||
MessageType.audio,
|
||||
{ mimetype: Mimetype.mp4Audio } // some metadata (can't have caption in audio)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
### Notes
|
||||
|
||||
- `id` is the WhatsApp ID of the person or group you're sending the message to.
|
||||
@@ -279,7 +325,7 @@ await conn.sendMessage(
|
||||
- For broadcast lists it's `[timestamp of creation]@broadcast`.
|
||||
- For stories, the ID is `status@broadcast`.
|
||||
- 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:
|
||||
- **MiscGenerationOptions**: some extra info about the message. It can have the following __optional__ values:
|
||||
``` ts
|
||||
const info: MessageOptions = {
|
||||
quoted: quotedMessage, // the message you want to quote
|
||||
@@ -313,12 +359,16 @@ await conn.forwardMessage ('455@s.whatsapp.net', message) // WA forward the mess
|
||||
|
||||
## Reading Messages
|
||||
|
||||
A set of message IDs must be explicitly marked read now.
|
||||
Cannot mark an entire "chat" read as it were with Baileys Web.
|
||||
This does mean you have to keep track of unread messages.
|
||||
|
||||
``` ts
|
||||
const id = '1234-123@g.us'
|
||||
const messageID = 'AHASHH123123AHGA' // id of the message you want to read
|
||||
const participant = '912121232@s.whatsapp.net' // the ID of the user that sent the message (undefined for individual chats)
|
||||
|
||||
await conn.chatRead (id) // mark all messages in chat as read (equivalent of opening a chat in WA)
|
||||
await conn.chatRead (id, 'unread') // mark the chat as unread
|
||||
await conn.sendReadReceipt(id, participant, [messageID])
|
||||
```
|
||||
|
||||
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```.
|
||||
@@ -326,18 +376,12 @@ The message ID is the unique identifier of the message that you are marking as r
|
||||
## Update Presence
|
||||
|
||||
``` ts
|
||||
import { Presence } from '@adiwajshing/baileys'
|
||||
await conn.updatePresence(id, Presence.available)
|
||||
await conn.updatePresence(id, 'available')
|
||||
|
||||
```
|
||||
This lets the person/group with ``` id ``` know whether you're online, offline, typing etc. where ``` presence ``` can be one of the following:
|
||||
``` ts
|
||||
export enum Presence {
|
||||
available = 'available', // "online"
|
||||
composing = 'composing', // "typing..."
|
||||
recording = 'recording', // "recording..."
|
||||
paused = 'paused' // stopped typing, back to "online"
|
||||
}
|
||||
type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'
|
||||
```
|
||||
|
||||
The presence expires after about 10 seconds.
|
||||
@@ -364,108 +408,74 @@ conn.on ('message-new', async m => {
|
||||
|
||||
``` ts
|
||||
const jid = '1234@s.whatsapp.net' // can also be a group
|
||||
const response = await conn.sendMessage (jid, 'hello!', MessageType.text) // send a message
|
||||
|
||||
await conn.deleteMessage (jid, {id: response.messageID, remoteJid: jid, fromMe: true}) // will delete the sent message for everyone!
|
||||
await conn.clearMessage (jid, {id: response.messageID, remoteJid: jid, fromMe: true}) // will delete the sent message for only you!
|
||||
const response = await conn.sendMessage(jid, { text: 'hello!' }) // send a message
|
||||
// sends a message to delete the given message
|
||||
// this deletes the message for everyone
|
||||
await conn.sendMessage(jid, { delete: response.key })
|
||||
```
|
||||
|
||||
Note: deleting for oneself is not supported yet
|
||||
|
||||
## Modifying Chats
|
||||
|
||||
``` ts
|
||||
const jid = '1234@s.whatsapp.net' // can also be a group
|
||||
await conn.modifyChat (jid, ChatModification.archive) // archive chat
|
||||
await conn.modifyChat (jid, ChatModification.unarchive) // unarchive chat
|
||||
|
||||
const response = await conn.modifyChat (jid, ChatModification.pin) // pin the chat
|
||||
await conn.modifyChat (jid, ChatModification.unpin) // unpin it
|
||||
|
||||
await conn.modifyChat (jid, ChatModification.mute, 8*60*60*1000) // mute for 8 hours
|
||||
setTimeout (() => {
|
||||
conn.modifyChat (jid, ChatModification.unmute)
|
||||
}, 5000) // unmute after 5 seconds
|
||||
|
||||
await conn.modifyChat (jid, ChatModification.delete) // 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.
|
||||
TODO: haven't figured this bit out yet. Can receive chat modifications tho.
|
||||
|
||||
## Disappearing Messages
|
||||
|
||||
``` ts
|
||||
const jid = '1234@s.whatsapp.net' // can also be a group
|
||||
// turn on disappearing messages
|
||||
await conn.toggleDisappearingMessages(
|
||||
await conn.sendMessage(
|
||||
jid,
|
||||
WA_DEFAULT_EPHEMERAL // this is 1 week in seconds -- how long you want messages to appear for
|
||||
)
|
||||
// will automatically send as a disappearing message
|
||||
await conn.sendMessage(jid, 'Hello poof!', MessageType.text)
|
||||
// this is 1 week in seconds -- how long you want messages to appear for
|
||||
{ disappearingMessagesInChat: WA_DEFAULT_EPHEMERAL }
|
||||
)
|
||||
// will send as a disappearing message
|
||||
await conn.sendMessage(jid, { text: 'hello' }, { ephemeralExpiration: WA_DEFAULT_EPHEMERAL })
|
||||
// turn off disappearing messages
|
||||
await conn.toggleDisappearingMessages(jid, 0)
|
||||
await conn.sendMessage(
|
||||
jid,
|
||||
{ disappearingMessagesInChat: false }
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
## 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
|
||||
Note: this method falls back to using `https://wa.me` to determine whether a number is on WhatsApp in case the WebSocket connection is not open yet.
|
||||
``` ts
|
||||
const id = '123456'
|
||||
const exists = await conn.isOnWhatsApp (id)
|
||||
if (exists) console.log (`${id} exists on WhatsApp, as jid: ${exists.jid}`)
|
||||
const [result] = await conn.onWhatsApp(id)
|
||||
if (result.exists) console.log (`${id} exists on WhatsApp, as jid: ${result.jid}`)
|
||||
```
|
||||
- To query chat history on a group or with someone
|
||||
``` ts
|
||||
// query the last 25 messages (replace 25 with the number of messages you want to query)
|
||||
const messages = await conn.loadMessages ("xyz-abc@g.us", 25)
|
||||
console.log("got back " + messages.length + " messages")
|
||||
```
|
||||
You can also load the entire conversation history if you want
|
||||
``` ts
|
||||
await conn.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 retrieved
|
||||
```
|
||||
TODO, if possible
|
||||
- To get the status of some person
|
||||
``` ts
|
||||
const status = await conn.getStatus ("xyz@c.us") // leave empty to get your own status
|
||||
const status = await conn.fetchStatus("xyz@s.whatsapp.net")
|
||||
console.log("status: " + status)
|
||||
```
|
||||
- To get the display picture of some person/group
|
||||
``` ts
|
||||
const ppUrl = await conn.getProfilePicture ("xyz@g.us") // leave empty to get your own
|
||||
const ppUrl = await conn.profilePictureUrl("xyz@g.us")
|
||||
console.log("download profile picture from: " + ppUrl)
|
||||
```
|
||||
- To change your display picture or a group's
|
||||
``` ts
|
||||
const jid = '111234567890-1594482450@g.us' // can be your own too
|
||||
const img = fs.readFileSync ('new-profile-picture.jpeg') // can be PNG also
|
||||
await conn.updateProfilePicture (jid, img)
|
||||
await conn.updateProfilePicture(jid, { url: './new-profile-picture.jpeg' })
|
||||
```
|
||||
- To get someone's presence (if they're typing, online)
|
||||
``` ts
|
||||
// the presence update is fetched and called here
|
||||
conn.on ('CB:Presence', json => console.log(json.id + " presence is " + json.type))
|
||||
await conn.requestPresenceUpdate ("xyz@c.us") // request the update
|
||||
```
|
||||
- To search through messages
|
||||
``` ts
|
||||
const response = await conn.searchMessages ('so cool', null, 25, 1) // search in all chats
|
||||
console.log (`got ${response.messages.length} messages in search`)
|
||||
|
||||
const response2 = await conn.searchMessages ('so cool', '1234@c.us', 25, 1) // search in given chat
|
||||
conn.ev.on('presence-update', json => console.log(json))
|
||||
// request updates for a chat
|
||||
await conn.presenceSubscribe("xyz@s.whatsapp.net")
|
||||
```
|
||||
- To block or unblock user
|
||||
``` ts
|
||||
await conn.blockUser ("xyz@c.us", "add") // Block user
|
||||
await conn.blockUser ("xyz@c.us", "remove") // Unblock user
|
||||
await conn.updateBlockStatus("xyz@s.whatsapp.net", "block") // Block user
|
||||
await conn.updateBlockStatus("xyz@s.whatsapp.net", "unblock") // Unblock user
|
||||
```
|
||||
Of course, replace ``` xyz ``` with an actual ID.
|
||||
|
||||
@@ -473,67 +483,52 @@ Of course, replace ``` xyz ``` with an actual ID.
|
||||
- To create a group
|
||||
``` ts
|
||||
// title & participants
|
||||
const group = await conn.groupCreate ("My Fab Group", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"])
|
||||
const group = await conn.groupCreate("My Fab Group", ["1234@s.whatsapp.net", "4564@s.whatsapp.net"])
|
||||
console.log ("created group with id: " + group.gid)
|
||||
conn.sendMessage(group.gid, "hello everyone", MessageType.extendedText) // say hello to everyone on the group
|
||||
conn.sendMessage(group.id, { text: 'hello there' }) // say hello to everyone on the group
|
||||
```
|
||||
- To add people to a group
|
||||
- To add/remove people to a group or demote/promote people
|
||||
``` ts
|
||||
// id & people to add to the group (will throw error if it fails)
|
||||
const response = await conn.groupAdd ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"])
|
||||
```
|
||||
- To make/demote admins on a group
|
||||
``` ts
|
||||
// id & people to make admin (will throw error if it fails)
|
||||
await conn.groupMakeAdmin ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"])
|
||||
await conn.groupDemoteAdmin ("abcd-xyz@g.us", ["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"]) // demote admins
|
||||
const response = await conn.groupParticipantsUpdate(
|
||||
"abcd-xyz@g.us",
|
||||
["abcd@s.whatsapp.net", "efgh@s.whatsapp.net"],
|
||||
"add" // replace this parameter with "remove", "demote" or "promote"
|
||||
)
|
||||
```
|
||||
- To change the group's subject
|
||||
``` ts
|
||||
await conn.groupUpdateSubject("abcd-xyz@g.us", "New Subject!")
|
||||
```
|
||||
- To change the group's description
|
||||
``` ts
|
||||
await conn.groupUpdateDescription("abcd-xyz@g.us", "This group has a new description")
|
||||
```
|
||||
- To change group settings
|
||||
``` ts
|
||||
import { GroupSettingChange } from '@adiwajshing/baileys'
|
||||
// only allow admins to send messages
|
||||
await conn.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.messageSend, true)
|
||||
await conn.groupSettingUpdate("abcd-xyz@g.us", 'announcement')
|
||||
// allow everyone to modify the group's settings -- like display picture etc.
|
||||
await conn.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.settingChange, false)
|
||||
await conn.groupSettingUpdate("abcd-xyz@g.us", 'unlocked')
|
||||
// only allow admins to modify the group's settings
|
||||
await conn.groupSettingChange ("abcd-xyz@g.us", GroupSettingChange.settingChange, true)
|
||||
await conn.groupSettingUpdate("abcd-xyz@g.us", 'locked')
|
||||
```
|
||||
- To leave a group
|
||||
``` ts
|
||||
await conn.groupLeave ("abcd-xyz@g.us") // (will throw error if it fails)
|
||||
await conn.groupLeave("abcd-xyz@g.us") // (will throw error if it fails)
|
||||
```
|
||||
- To get the invite code for a group
|
||||
``` ts
|
||||
const code = await conn.groupInviteCode ("abcd-xyz@g.us")
|
||||
const code = await conn.groupInviteCode("abcd-xyz@g.us")
|
||||
console.log("group code: " + code)
|
||||
```
|
||||
- To query the metadata of a group
|
||||
``` ts
|
||||
const metadata = await conn.groupMetadata ("abcd-xyz@g.us")
|
||||
const metadata = await conn.groupMetadata("abcd-xyz@g.us")
|
||||
console.log(json.id + ", title: " + json.subject + ", description: " + json.desc)
|
||||
|
||||
// Or if you've left the group -- call this
|
||||
const metadata2 = await conn.groupMetadataMinimal ("abcd-xyz@g.us")
|
||||
```
|
||||
- To join the group using the invitation code
|
||||
``` ts
|
||||
const response = await conn.acceptInvite ("xxx")
|
||||
const response = await conn.acceptInvite("xxx")
|
||||
console.log("joined to: " + response.gid)
|
||||
```
|
||||
Of course, replace ``` xxx ``` with invitation code.
|
||||
- To revokes the current invite link of a group
|
||||
``` ts
|
||||
const response = await conn.revokeInvite ("abcd-xyz@g.us")
|
||||
console.log("new group code: " + response.code)
|
||||
```
|
||||
|
||||
## Broadcast Lists & Stories
|
||||
|
||||
@@ -542,7 +537,7 @@ Of course, replace ``` xyz ``` with an actual ID.
|
||||
- Broadcast IDs are in the format `12345678@broadcast`
|
||||
- To query a broadcast list's recipients & name:
|
||||
``` ts
|
||||
const bList = await conn.getBroadcastListInfo ("1234@broadcast")
|
||||
const bList = await conn.getBroadcastListInfo("1234@broadcast")
|
||||
console.log (`list name: ${bList.name}, recps: ${bList.recipients}`)
|
||||
```
|
||||
|
||||
@@ -551,53 +546,34 @@ Baileys is written, keeping in mind, that you may require other custom functiona
|
||||
|
||||
First, enable the logging of unhandled messages from WhatsApp by setting
|
||||
``` ts
|
||||
conn.logger.level = 'debug'
|
||||
const sock = makeWASocket({
|
||||
logger: P({ level: 'debug' }),
|
||||
})
|
||||
```
|
||||
This will enable you to see all sorts of messages WhatsApp sends in the console. Some examples:
|
||||
|
||||
1. Functionality to track of the battery percentage of your phone.
|
||||
You enable logging and you'll see a message about your battery pop up in the console:
|
||||
```s22, ["action",null,[["battery",{"live":"false","value":"52"},null]]] ```
|
||||
```{"level":10,"fromMe":false,"frame":{"tag":"ib","attrs":{"from":"@s.whatsapp.net"},"content":[{"tag":"edge_routing","attrs":{},"content":[{"tag":"routing_info","attrs":{},"content":{"type":"Buffer","data":[8,2,8,5]}}]}]},"msg":"communication"} ```
|
||||
|
||||
You now know what a battery update looks like. It'll have the following characteristics.
|
||||
- Given ```const bMessage = ["action",null,[["battery",{"live":"false","value":"52"},null]]]```
|
||||
- ```bMessage[0]``` is always ``` "action" ```
|
||||
- ```bMessage[1]``` is always ``` null ```
|
||||
- ```bMessage[2][0][0]``` is always ``` "battery" ```
|
||||
The "frame" is what the message received is, it has three components:
|
||||
- `tag` -- what this frame is about (eg. message will have "message")
|
||||
- `attrs` -- a string key-value pair with some metadata (contains ID of the message usually)
|
||||
- `content` -- the actual data (eg. a message node will have the actual message content in it)
|
||||
- read more about this format [here](/src/WABinary/readme.md)
|
||||
|
||||
Hence, you can register a callback for an event using the following:
|
||||
``` ts
|
||||
conn.on (`CB:action,,battery`, json => {
|
||||
const batteryLevelStr = json[2][0][1].value
|
||||
const batterylevel = parseInt (batteryLevelStr)
|
||||
console.log ("battery level: " + batterylevel + "%")
|
||||
})
|
||||
// for any message with tag 'edge_routing'
|
||||
conn.ws.on(`CB:edge_routing`, (node: BinaryNode) => { })
|
||||
// for any message with tag 'edge_routing' and id attribute = abcd
|
||||
conn.ws.on(`CB:edge_routing,id:abcd`, (node: BinaryNode) => { })
|
||||
// for any message with tag 'edge_routing', id attribute = abcd & first content node routing_info
|
||||
conn.ws.on(`CB:edge_routing,id:abcd,routing_info`, (node: BinaryNode) => { })
|
||||
```
|
||||
This callback will be fired any time a message is received matching the following criteria:
|
||||
``` message [0] === "action" && message [1] === null && message[2][0][0] === "battery" ```
|
||||
2. Functionality to keep track of the pushname changes on your phone.
|
||||
You enable logging and you'll see an unhandled message about your pushanme pop up like this:
|
||||
```s24, ["Conn",{"pushname":"adiwajshing"}]```
|
||||
|
||||
You now know what a pushname update looks like. It'll have the following characteristics.
|
||||
- Given ```const pMessage = ["Conn",{"pushname":"adiwajshing"}] ```
|
||||
- ```pMessage[0]``` is always ``` "Conn" ```
|
||||
- ```pMessage[1]``` always has the key ``` "pushname" ```
|
||||
- ```pMessage[2]``` is always ``` undefined ```
|
||||
|
||||
Following this, one can implement the following callback:
|
||||
``` ts
|
||||
conn.on ('CB:Conn,pushname', json => {
|
||||
const pushname = json[1].pushname
|
||||
conn.user.name = pushname // update on client too
|
||||
console.log ("Name updated: " + pushname)
|
||||
})
|
||||
```
|
||||
This callback will be fired any time a message is received matching the following criteria:
|
||||
``` message [0] === "Conn" && message [1].pushname ```
|
||||
|
||||
A little more testing will reveal that almost all WhatsApp messages are in the format illustrated above.
|
||||
Note: except for the first parameter (in the above cases, ```"action"``` or ```"Conn"```), all the other parameters are optional.
|
||||
|
||||
### Note
|
||||
|
||||
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.
|
||||
|
||||
Also, this repo is now licenced under GPL 3 since it uses [libsignal-node](https://git.questbook.io/backend/service-coderunner/-/merge_requests/1)
|
||||
|
||||
585
WABinary/Binary.js
Normal file
585
WABinary/Binary.js
Normal file
@@ -0,0 +1,585 @@
|
||||
import { hexAt, hexLongIsNegative, hexLongToHex, negateHexLong, NUM_HEX_IN_LONG } from "./HexHelper";
|
||||
import { inflateSync } from 'zlib'
|
||||
|
||||
var l = "",
|
||||
d = 0;
|
||||
|
||||
const i = 65533,
|
||||
n = new Uint8Array(10),
|
||||
s = new Uint8Array(0);
|
||||
|
||||
function u(e) {
|
||||
if (e === l) return d;
|
||||
for (var t = e.length, r = 0, a = 0; a < t; a++) {
|
||||
var i = e.charCodeAt(a);
|
||||
if (i < 128) r++;
|
||||
else if (i < 2048) r += 2;
|
||||
else if (i < 55296 || (57344 <= i && i <= 65535)) r += 3;
|
||||
else if (55296 <= i && i < 56320 && a + 1 !== t) {
|
||||
var n = e.charCodeAt(a + 1);
|
||||
56320 <= n && n < 57344 ? (a++, (r += 4)) : (r += 3);
|
||||
} else r += 3;
|
||||
}
|
||||
return (l = e), (d = r);
|
||||
}
|
||||
function c(e, t, r) {
|
||||
var a = t >> 21;
|
||||
if (e) {
|
||||
var i = Boolean(2097151 & t || r);
|
||||
return 0 === a || (-1 === a && i);
|
||||
}
|
||||
return 0 === a;
|
||||
}
|
||||
function p(e, t, r, a, i = undefined) {
|
||||
return e.readWithViewParser(t, r, a, i);
|
||||
}
|
||||
function f(e, t, r, a = undefined, i = undefined) {
|
||||
return e.readWithBytesParser(t, r, a, i);
|
||||
}
|
||||
function h(e, t, r, a) {
|
||||
return a ? e.getInt8(t) : e.getUint8(t);
|
||||
}
|
||||
function _(e, t, r, a) {
|
||||
return e.getUint16(t, a);
|
||||
}
|
||||
function m(e, t, r, a) {
|
||||
return e.getInt32(t, a);
|
||||
}
|
||||
function g(e, t, r, a) {
|
||||
return e.getUint32(t, a);
|
||||
}
|
||||
function v(e, t, r, a, i) {
|
||||
return a(e.getInt32(i ? t + 4 : t, i), e.getInt32(i ? t : t + 4, i));
|
||||
}
|
||||
function y(e, t, r, a) {
|
||||
return e.getFloat32(t, a);
|
||||
}
|
||||
function E(e, t, r, a) {
|
||||
return e.getFloat64(t, a);
|
||||
}
|
||||
function S(e, t, r, a) {
|
||||
for (var i = Math.min(a, 10), n = 0, s = 128; n < i && 128 & s; )
|
||||
s = e[t + n++];
|
||||
if (10 === n && s > 1) throw new Error("ParseError: varint exceeds 64 bits");
|
||||
return 128 & s ? n + 1 : n;
|
||||
}
|
||||
function T(e, t, r, a) {
|
||||
var i = 0,
|
||||
n = 0,
|
||||
s = r;
|
||||
10 === r && (n = 1 & e[t + --s]);
|
||||
for (var o = s - 1; o >= 0; o--)
|
||||
(i = (i << 7) | (n >>> 25)), (n = (n << 7) | (127 & e[t + o]));
|
||||
return a(i, n);
|
||||
}
|
||||
function A(e, t, r) {
|
||||
var a = t + e.byteOffset,
|
||||
i = e.buffer;
|
||||
return 0 === a && r === i.byteLength ? i : i.slice(a, a + r);
|
||||
}
|
||||
function b(e, t, r) {
|
||||
return e.subarray(t, t + r);
|
||||
}
|
||||
function C(e, t, r) {
|
||||
for (var a = t + r, n = [], s = null, o = t; o < a; o++) {
|
||||
n.length > 5e3 &&
|
||||
(s || (s = []), s.push(String.fromCharCode.apply(String, n)), (n = []));
|
||||
var l = 0 | e[o];
|
||||
if (0 == (128 & l)) n.push(l);
|
||||
else if (192 == (224 & l)) {
|
||||
var d = H(e, o + 1, a);
|
||||
if (d) {
|
||||
o++;
|
||||
var u = ((31 & l) << 6) | (63 & d);
|
||||
u >= 128 ? n.push(u) : n.push(i);
|
||||
} else n.push(i);
|
||||
} else if (224 == (240 & l)) {
|
||||
var c = H(e, o + 1, a),
|
||||
p = H(e, o + 2, a);
|
||||
if (c && p) {
|
||||
o += 2;
|
||||
var f = ((15 & l) << 12) | ((63 & c) << 6) | (63 & p);
|
||||
f >= 2048 && !(55296 <= f && f < 57344) ? n.push(f) : n.push(i);
|
||||
} else c ? (o++, n.push(i)) : n.push(i);
|
||||
} else if (240 == (248 & l)) {
|
||||
var h = H(e, o + 1, a),
|
||||
_ = H(e, o + 2, a),
|
||||
m = H(e, o + 3, a);
|
||||
if (h && _ && m) {
|
||||
o += 3;
|
||||
var g = ((7 & l) << 18) | ((63 & h) << 12) | ((63 & _) << 6) | (63 & m);
|
||||
if (g >= 65536 && g <= 1114111) {
|
||||
var v = g - 65536;
|
||||
n.push(55296 | (v >> 10), 56320 | (1023 & v));
|
||||
} else n.push(i);
|
||||
} else h && _ ? ((o += 2), n.push(i)) : h ? (o++, n.push(i)) : n.push(i);
|
||||
} else n.push(i);
|
||||
}
|
||||
var y = String.fromCharCode.apply(String, n);
|
||||
return s ? (s.push(y), s.join("")) : y;
|
||||
}
|
||||
function P(e, t, r, a, i) {
|
||||
return e.writeToView(t, r, a, i);
|
||||
}
|
||||
function O(e, t, r, a, i = undefined) {
|
||||
return e.writeToBytes(t, r, a, i);
|
||||
}
|
||||
function M(e, t, r, a) {
|
||||
e[t] = a;
|
||||
}
|
||||
function w(e, t, r, a, i) {
|
||||
e.setUint16(t, a, i);
|
||||
}
|
||||
function I(e, t, r, a, i) {
|
||||
e.setInt16(t, a, i);
|
||||
}
|
||||
function R(e, t, r, a, i) {
|
||||
e.setUint32(t, a, i);
|
||||
}
|
||||
function D(e, t, r, a, i) {
|
||||
e.setInt32(t, a, i);
|
||||
}
|
||||
function N(e, t, r, a, i) {
|
||||
var n = a < 0,
|
||||
s = n ? -a : a,
|
||||
o = Math.floor(s / 4294967296),
|
||||
l = s - 4294967296 * o;
|
||||
n && ((o = ~o), 0 === l ? o++ : (l = -l)),
|
||||
e.setUint32(i ? t + 4 : t, o, i),
|
||||
e.setUint32(i ? t : t + 4, l, i);
|
||||
}
|
||||
function L(e, t, r, a, i) {
|
||||
e.setFloat32(t, a, i);
|
||||
}
|
||||
function k(e, t, r, a, i) {
|
||||
e.setFloat64(t, a, i);
|
||||
}
|
||||
function U(e, t, r, a, i) {
|
||||
for (var n = a, s = i, o = t + r - 1, l = t; l < o; l++)
|
||||
(e[l] = 128 | (127 & s)), (s = (n << 25) | (s >>> 7)), (n >>>= 7);
|
||||
e[o] = s;
|
||||
}
|
||||
function G(e, t, r, a) {
|
||||
for (var i = t, n = a.length, s = 0; s < n; s++) {
|
||||
var o = a.charCodeAt(s);
|
||||
if (o < 128) e[i++] = o;
|
||||
else if (o < 2048) (e[i++] = 192 | (o >> 6)), (e[i++] = 128 | (63 & o));
|
||||
else if (o < 55296 || 57344 <= o)
|
||||
(e[i++] = 224 | (o >> 12)),
|
||||
(e[i++] = 128 | ((o >> 6) & 63)),
|
||||
(e[i++] = 128 | (63 & o));
|
||||
else if (55296 <= o && o < 56320 && s + 1 !== n) {
|
||||
var l = a.charCodeAt(s + 1);
|
||||
if (56320 <= l && l < 57344) {
|
||||
s++;
|
||||
var d = 65536 + (((1023 & o) << 10) | (1023 & l));
|
||||
(e[i++] = 240 | (d >> 18)),
|
||||
(e[i++] = 128 | ((d >> 12) & 63)),
|
||||
(e[i++] = 128 | ((d >> 6) & 63)),
|
||||
(e[i++] = 128 | (63 & d));
|
||||
} else (e[i++] = 239), (e[i++] = 191), (e[i++] = 189);
|
||||
} else (e[i++] = 239), (e[i++] = 191), (e[i++] = 189);
|
||||
}
|
||||
}
|
||||
function F(e, t, r, i, n) {
|
||||
for (
|
||||
var s = hexLongIsNegative(i),
|
||||
o = hexLongToHex(i),
|
||||
l = 0,
|
||||
d = 0,
|
||||
u = 0;
|
||||
u < 16;
|
||||
u++
|
||||
)
|
||||
(l = (l << 4) | (d >>> 28)), (d = (d << 4) | hexAt(o, u));
|
||||
s && ((l = ~l), 0 === d ? l++ : (d = -d)),
|
||||
e.setUint32(n ? t + 4 : t, l, n),
|
||||
e.setUint32(n ? t : t + 4, d, n);
|
||||
}
|
||||
function x(e, t, r, a) {
|
||||
for (var i = 0; i < r; i++) e[t + i] = a[i];
|
||||
}
|
||||
function B(e, t) {
|
||||
var r, a;
|
||||
for (e ? ((r = 5), (a = e >>> 3)) : ((r = 1), (a = t >>> 7)); a; )
|
||||
r++, (a >>>= 7);
|
||||
return r;
|
||||
}
|
||||
function Y(e, t, r, a) {
|
||||
if ("number" != typeof e || e != e || Math.floor(e) !== e || e < t || e >= r) {
|
||||
console.trace('here')
|
||||
throw new TypeError(
|
||||
"string" == typeof e
|
||||
? `WriteError: string "${e}" is not a valid ${a}`
|
||||
: `WriteError: ${String(e)} is not a valid ${a}`
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
function K(e, t, r) {
|
||||
var a =
|
||||
4294967296 * (t >= 0 || e ? t : 4294967296 + t) +
|
||||
(r >= 0 ? r : 4294967296 + r);
|
||||
if (!c(e, t, r))
|
||||
throw new Error(`ReadError: integer exceeded 53 bits (${a})`);
|
||||
return a;
|
||||
}
|
||||
function j(e, t) {
|
||||
return K(!0, e, t);
|
||||
}
|
||||
function W(e, t) {
|
||||
return K(!1, e, t);
|
||||
}
|
||||
function H(e, t, r) {
|
||||
if (t >= r) return 0;
|
||||
var a = 0 | e[t];
|
||||
return 128 == (192 & a) ? a : 0;
|
||||
}
|
||||
|
||||
export const numUtf8Bytes = u;
|
||||
export const longFitsInDouble = c;
|
||||
export const parseInt64OrThrow = j;
|
||||
export const parseUint64OrThrow = W;
|
||||
|
||||
export class Binary {
|
||||
/** @type {Uint8Array} */
|
||||
buffer;
|
||||
readEndIndex;
|
||||
writeIndex;
|
||||
bytesTrashed = 0;
|
||||
earliestIndex = 0;
|
||||
readIndex = 0;
|
||||
/** @type {DataView} */
|
||||
view = null;
|
||||
littleEndian = false;
|
||||
hiddenReads = 0;
|
||||
hiddenWrites = 0;
|
||||
|
||||
constructor(data = new Uint8Array(0), littleEndian = false) {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
this.buffer = new Uint8Array(data);
|
||||
this.readEndIndex = data.byteLength;
|
||||
this.writeIndex = data.byteLength;
|
||||
}
|
||||
|
||||
if (data instanceof Uint8Array) {
|
||||
this.buffer = data;
|
||||
this.readEndIndex = data.length;
|
||||
this.writeIndex = data.length;
|
||||
}
|
||||
|
||||
this.littleEndian = littleEndian;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.readEndIndex - this.readIndex;
|
||||
}
|
||||
|
||||
peek(e, t = undefined) {
|
||||
this.hiddenReads++;
|
||||
|
||||
const r = this.readIndex;
|
||||
const a = this.bytesTrashed;
|
||||
|
||||
try {
|
||||
return e(this, t);
|
||||
} finally {
|
||||
this.hiddenReads--, (this.readIndex = r - (this.bytesTrashed - a));
|
||||
}
|
||||
}
|
||||
|
||||
advance(e) {
|
||||
this.shiftReadOrThrow(e);
|
||||
}
|
||||
|
||||
readWithViewParser(e, t, r, a) {
|
||||
return t(this.getView(), this.shiftReadOrThrow(e), e, r, a);
|
||||
}
|
||||
|
||||
readWithBytesParser(e, t, r, a) {
|
||||
return t(this.buffer, this.shiftReadOrThrow(e), e, r, a);
|
||||
}
|
||||
|
||||
readUint8() {
|
||||
//return this.readWithViewParser(1, h, false)
|
||||
return p(this, 1, h, !1);
|
||||
}
|
||||
readInt8() {
|
||||
return p(this, 1, h, !0);
|
||||
}
|
||||
readUint16(e = this.littleEndian) {
|
||||
return p(this, 2, _, e);
|
||||
}
|
||||
readInt32(e = this.littleEndian) {
|
||||
return p(this, 4, m, e);
|
||||
}
|
||||
readUint32(e = this.littleEndian) {
|
||||
return p(this, 4, g, e);
|
||||
}
|
||||
readInt64(e = this.littleEndian) {
|
||||
return p(this, 8, v, j, e);
|
||||
}
|
||||
readUint64(e = this.littleEndian) {
|
||||
return p(this, 8, v, W, e);
|
||||
}
|
||||
readLong(e, t = this.littleEndian) {
|
||||
return p(this, 8, v, e, t);
|
||||
}
|
||||
readFloat32(e = this.littleEndian) {
|
||||
return p(this, 4, y, e);
|
||||
}
|
||||
readFloat64(e = this.littleEndian) {
|
||||
return p(this, 8, E, e);
|
||||
}
|
||||
readVarInt(e) {
|
||||
var t = f(this, 0, S, this.size());
|
||||
return f(this, t, T, e);
|
||||
}
|
||||
readBuffer(e = this.size()) {
|
||||
return 0 === e ? new ArrayBuffer(0) : f(this, e, A);
|
||||
}
|
||||
readByteArray(e = this.size()) {
|
||||
return 0 === e ? new Uint8Array(0) : f(this, e, b);
|
||||
}
|
||||
readBinary(e = this.size(), t = this.littleEndian) {
|
||||
if (0 === e) return new Binary(void 0, t);
|
||||
var r = f(this, e, b);
|
||||
return new Binary(r, t);
|
||||
}
|
||||
indexOf(e) {
|
||||
if (0 === e.length) return 0;
|
||||
for (
|
||||
var t = this.buffer,
|
||||
r = this.readEndIndex,
|
||||
a = this.readIndex,
|
||||
i = 0,
|
||||
n = a,
|
||||
s = a;
|
||||
s < r;
|
||||
s++
|
||||
)
|
||||
if (t[s] === e[i]) {
|
||||
if ((0 === i && (n = s), ++i === e.byteLength))
|
||||
return s - a - e.byteLength + 1;
|
||||
} else i > 0 && ((i = 0), (s = n));
|
||||
return -1;
|
||||
1;
|
||||
}
|
||||
readString(e) {
|
||||
return f(this, e, C);
|
||||
}
|
||||
ensureCapacity(e) {
|
||||
this.maybeReallocate(this.readIndex + e);
|
||||
}
|
||||
ensureAdditionalCapacity(e) {
|
||||
this.maybeReallocate(this.writeIndex + e);
|
||||
}
|
||||
writeToView(e, t, r, a) {
|
||||
var i = this.shiftWriteMaybeReallocate(e);
|
||||
return t(this.getView(), i, e, r, a);
|
||||
}
|
||||
writeToBytes(e, t, r, a) {
|
||||
var i = this.shiftWriteMaybeReallocate(e);
|
||||
return t(this.buffer, i, e, r, a);
|
||||
}
|
||||
write(...e) {
|
||||
for (var t = 0; t < e.length; t++) {
|
||||
var r = e[t];
|
||||
"string" == typeof r
|
||||
? this.writeString(r)
|
||||
: "number" == typeof r
|
||||
? this.writeUint8(r)
|
||||
: r instanceof Binary
|
||||
? this.writeBinary(r)
|
||||
: r instanceof ArrayBuffer
|
||||
? this.writeBuffer(r)
|
||||
: r instanceof Uint8Array && this.writeByteArray(r);
|
||||
}
|
||||
}
|
||||
writeUint8(e) {
|
||||
Y(e, 0, 256, "uint8"), O(this, 1, M, e, !1);
|
||||
}
|
||||
writeInt8(e) {
|
||||
Y(e, -128, 128, "signed int8"), O(this, 1, M, e, !0);
|
||||
}
|
||||
writeUint16(e, t = this.littleEndian) {
|
||||
Y(e, 0, 65536, "uint16"), P(this, 2, w, e, t);
|
||||
}
|
||||
writeInt16(e, t = this.littleEndian) {
|
||||
Y(e, -32768, 32768, "signed int16"), P(this, 2, I, e, t);
|
||||
}
|
||||
writeUint32(e, t = this.littleEndian) {
|
||||
Y(e, 0, 4294967296, "uint32"), P(this, 4, R, e, t);
|
||||
}
|
||||
writeInt32(e, t = this.littleEndian) {
|
||||
Y(e, -2147483648, 2147483648, "signed int32"), P(this, 4, D, e, t);
|
||||
}
|
||||
writeUint64(e, t = this.littleEndian) {
|
||||
Y(e, 0, 0x10000000000000000, "uint64"), P(this, 8, N, e, t);
|
||||
}
|
||||
writeInt64(e, t = this.littleEndian) {
|
||||
Y(e, -0x8000000000000000, 0x8000000000000000, "signed int64"),
|
||||
P(this, 8, N, e, t);
|
||||
}
|
||||
writeFloat32(e, t = this.littleEndian) {
|
||||
P(this, 4, L, e, t);
|
||||
}
|
||||
writeFloat64(e, t = this.littleEndian) {
|
||||
P(this, 8, k, e, t);
|
||||
}
|
||||
writeVarInt(e) {
|
||||
Y(e, -0x8000000000000000, 0x8000000000000000, "varint (signed int64)");
|
||||
var t = e < 0,
|
||||
r = t ? -e : e,
|
||||
a = Math.floor(r / 4294967296),
|
||||
i = r - 4294967296 * a;
|
||||
t && ((a = ~a), 0 === i ? a++ : (i = -i)), O(this, B(a, i), U, a, i);
|
||||
}
|
||||
writeVarIntFromHexLong(e) {
|
||||
for (
|
||||
var t = hexLongIsNegative(e),
|
||||
r = t ? negateHexLong(e) : e,
|
||||
i = hexLongToHex(r),
|
||||
n = 0,
|
||||
s = 0,
|
||||
o = 0;
|
||||
o < NUM_HEX_IN_LONG;
|
||||
o++
|
||||
)
|
||||
(n = (n << 4) | (s >>> 28)), (s = (s << 4) | hexAt(i, o));
|
||||
t && ((n = ~n), 0 === s ? n++ : (s = -s)), O(this, B(n, s), U, n, s);
|
||||
}
|
||||
writeBinary(e) {
|
||||
var t = e.peek((e) => e.readByteArray());
|
||||
if (t.length) {
|
||||
var r = this.shiftWriteMaybeReallocate(t.length);
|
||||
this.buffer.set(t, r);
|
||||
}
|
||||
}
|
||||
writeBuffer(e) {
|
||||
this.writeByteArray(new Uint8Array(e));
|
||||
}
|
||||
writeByteArray(e) {
|
||||
var t = this.shiftWriteMaybeReallocate(e.length);
|
||||
this.buffer.set(e, t);
|
||||
}
|
||||
writeBufferView(e) {
|
||||
this.writeByteArray(new Uint8Array(e.buffer, e.byteOffset, e.byteLength));
|
||||
}
|
||||
writeString(e) {
|
||||
O(this, u(e), G, e);
|
||||
}
|
||||
writeHexLong(e, t = this.littleEndian) {
|
||||
P(this, 8, F, e, t);
|
||||
}
|
||||
writeBytes(...e) {
|
||||
for (var t = 0; t < e.length; t++) Y(e[t], 0, 256, "byte");
|
||||
O(this, e.length, x, e);
|
||||
}
|
||||
writeAtomically(e, t) {
|
||||
this.hiddenWrites++;
|
||||
var r = this.writeIndex,
|
||||
a = this.bytesTrashed;
|
||||
try {
|
||||
var i = e(this, t);
|
||||
return (r = this.writeIndex), (a = this.bytesTrashed), i;
|
||||
} finally {
|
||||
this.hiddenWrites--, (this.writeIndex = r - (this.bytesTrashed - a));
|
||||
}
|
||||
}
|
||||
|
||||
writeWithVarIntLength(e, t) {
|
||||
var r = this.writeIndex,
|
||||
a = this.writeAtomically(e, t),
|
||||
i = this.writeIndex;
|
||||
this.writeVarInt(i - r);
|
||||
for (var s = this.writeIndex - i, o = this.buffer, l = 0; l < s; l++)
|
||||
n[l] = o[i + l];
|
||||
for (var d = i - 1; d >= r; d--) o[d + s] = o[d];
|
||||
for (var u = 0; u < s; u++) o[r + u] = n[u];
|
||||
return a;
|
||||
}
|
||||
|
||||
static build(...e) {
|
||||
let t = 0;
|
||||
let r = 0;
|
||||
for (t = 0, r = 0; r < e.length; r++) {
|
||||
let a = e[r];
|
||||
"string" == typeof a
|
||||
? (t += u(a))
|
||||
: "number" == typeof a
|
||||
? t++
|
||||
: a instanceof Binary
|
||||
? (t += a.size())
|
||||
: a instanceof ArrayBuffer
|
||||
? (t += a.byteLength)
|
||||
: a instanceof Uint8Array && (t += a.length);
|
||||
}
|
||||
|
||||
var i = new Binary();
|
||||
return i.ensureCapacity(t), i.write.apply(i, arguments), i;
|
||||
}
|
||||
|
||||
getView() {
|
||||
return (
|
||||
this.view ||
|
||||
(this.view = new DataView(this.buffer.buffer, this.buffer.byteOffset))
|
||||
);
|
||||
}
|
||||
|
||||
shiftReadOrThrow(e) {
|
||||
if (e < 0)
|
||||
throw new Error("ReadError: given negative number of bytes to read");
|
||||
var t = this.readIndex,
|
||||
r = t + e;
|
||||
if (r > this.readEndIndex)
|
||||
throw new Error(
|
||||
t === this.readEndIndex
|
||||
? "ReadError: tried to read from depleted binary"
|
||||
: "ReadError: tried to read beyond end of binary"
|
||||
);
|
||||
return (
|
||||
(this.readIndex = r), this.hiddenReads || (this.earliestIndex = r), t
|
||||
);
|
||||
}
|
||||
|
||||
maybeReallocate(e) {
|
||||
const t = this.buffer;
|
||||
if (e <= t.length) {
|
||||
return e;
|
||||
}
|
||||
|
||||
const r = this.earliestIndex;
|
||||
const a = e - r;
|
||||
const i = Math.max(a, 2 * (t.length - r), 64);
|
||||
const n = new Uint8Array(i);
|
||||
return (
|
||||
r
|
||||
? (n.set(t.subarray(r)),
|
||||
(this.bytesTrashed += r),
|
||||
(this.readIndex -= r),
|
||||
(this.readEndIndex -= r),
|
||||
(this.writeIndex -= r),
|
||||
(this.earliestIndex = 0))
|
||||
: n.set(t),
|
||||
(this.buffer = n),
|
||||
(this.view = null),
|
||||
a
|
||||
);
|
||||
}
|
||||
|
||||
shiftWriteMaybeReallocate(e) {
|
||||
const t = this.maybeReallocate(this.writeIndex + e);
|
||||
const r = this.writeIndex;
|
||||
return (
|
||||
(this.writeIndex = t), this.hiddenWrites || (this.readEndIndex = t), r
|
||||
);
|
||||
}
|
||||
decompressed = () => {
|
||||
if (2 & this.readUint8()) {
|
||||
const result = inflateSync(this.readByteArray())
|
||||
return new Binary(result)
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
21
WABinary/Constants.js
Normal file
21
WABinary/Constants.js
Normal file
File diff suppressed because one or more lines are too long
117
WABinary/HexHelper.js
Normal file
117
WABinary/HexHelper.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as Crypto from "crypto";
|
||||
|
||||
const r = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, 69, 70],
|
||||
a = [48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102];
|
||||
|
||||
const i = (e) => {
|
||||
for (var t = [], a = 0; a < e.length; a++) {
|
||||
var i = e[a];
|
||||
t.push(r[i >> 4], r[15 & i]);
|
||||
}
|
||||
return String.fromCharCode.apply(String, t);
|
||||
};
|
||||
|
||||
const n = (e, t) => {
|
||||
var r = e.charCodeAt(t);
|
||||
return r <= 57 ? r - 48 : r <= 70 ? 10 + r - 65 : 10 + r - 97;
|
||||
};
|
||||
|
||||
const s = (e) => {
|
||||
if (/[^0-9a-fA-F]/.test(e)) throw new Error(`"${e}" is not a valid hex`);
|
||||
return e;
|
||||
};
|
||||
|
||||
const o = (e, t) => {
|
||||
for (var r = t - e.length, a = e, i = 0; i < r; i++) a = "0" + a;
|
||||
return a;
|
||||
};
|
||||
|
||||
const l = (e) => {
|
||||
return "-" === e[0];
|
||||
};
|
||||
|
||||
const d = (e) => {
|
||||
if (e > 4294967295 || e < -4294967296)
|
||||
throw new Error("uint32ToLowerCaseHex given number over 32 bits");
|
||||
return o((e >= 0 ? e : 4294967296 + e).toString(16), 8);
|
||||
};
|
||||
|
||||
export const NUM_HEX_IN_LONG = 16;
|
||||
export const HEX_LOWER = a;
|
||||
|
||||
export const randomHex = function (e) {
|
||||
var t = new Uint8Array(e);
|
||||
var bytes = Crypto.randomBytes(t.length);
|
||||
t.set(bytes);
|
||||
return i(t);
|
||||
};
|
||||
|
||||
export const toHex = i;
|
||||
|
||||
export const toLowerCaseHex = function (e) {
|
||||
for (var t = [], r = 0; r < e.length; r++) {
|
||||
var i = e[r];
|
||||
t.push(a[i >> 4], a[15 & i]);
|
||||
}
|
||||
return String.fromCharCode.apply(String, t);
|
||||
};
|
||||
|
||||
export const parseHex = function (e) {
|
||||
var t = s(e);
|
||||
if (t.length % 2 != 0)
|
||||
throw new Error(
|
||||
`parseHex given hex "${t}" which is not a multiple of 8-bits.`
|
||||
);
|
||||
for (
|
||||
var r = new Uint8Array(t.length >> 1), a = 0, i = 0;
|
||||
a < t.length;
|
||||
a += 2, i++
|
||||
)
|
||||
r[i] = (n(t, a) << 4) | n(t, a + 1);
|
||||
return r.buffer;
|
||||
};
|
||||
|
||||
export const hexAt = n;
|
||||
export const hexOrThrow = s;
|
||||
export const bytesToBuffer = function (e) {
|
||||
var t = e.buffer;
|
||||
return 0 === e.byteOffset && e.length === t.byteLength
|
||||
? t
|
||||
: t.slice(e.byteOffset, e.byteOffset + e.length);
|
||||
};
|
||||
|
||||
export const bytesToDebugString = function (e) {
|
||||
var t = !0,
|
||||
r = e.length;
|
||||
for (; t && r; ) {
|
||||
var a = e[--r];
|
||||
t = 32 <= a && a < 127;
|
||||
}
|
||||
return t ? JSON.stringify(String.fromCharCode.apply(String, e)) : i(e);
|
||||
};
|
||||
|
||||
export const createHexLong = function (e, t = !1) {
|
||||
var r = s(e);
|
||||
return (
|
||||
(function (e, t) {
|
||||
if (e.length > t) throw new Error(`"${e}" is longer than ${4 * t} bits.`);
|
||||
})(r, 16),
|
||||
`${t ? "-" : ""}0x${o(r, 16)}`
|
||||
);
|
||||
};
|
||||
|
||||
export const createHexLongFrom32Bits = function (e, t, r = !1) {
|
||||
var a = d(e),
|
||||
i = d(t);
|
||||
return `${r ? "-" : ""}0x${a}${i}`;
|
||||
};
|
||||
|
||||
export const hexLongToHex = function (e) {
|
||||
return e.substring(e.indexOf("0x") + 2);
|
||||
};
|
||||
|
||||
export const hexLongIsNegative = l;
|
||||
|
||||
export const negateHexLong = function (e) {
|
||||
return l(e) ? e.slice(1) : "-" + e;
|
||||
};
|
||||
15
WABinary/readme.md
Normal file
15
WABinary/readme.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# WABinary
|
||||
|
||||
Contains the raw JS code to parse WA binary messages. WA uses a tree like structure to encode information, the type for which is written below:
|
||||
|
||||
``` ts
|
||||
export type BinaryNode = {
|
||||
tag: string
|
||||
attrs: Attributes
|
||||
content?: BinaryNode[] | string | Uint8Array
|
||||
}
|
||||
```
|
||||
|
||||
Do note, the multi-device binary format is very similar to the one on WA Web, though they are not backwards compatible.
|
||||
|
||||
Originally from [pokearaujo/multidevice](https://github.com/pokearaujo/multidevice)
|
||||
@@ -1,4 +1,4 @@
|
||||
yarn pbjs -t static-module -w commonjs -o ./WAMessage/index.js ./src/BinaryNode/WAMessage.proto;
|
||||
yarn pbts -o ./WAMessage/index.d.ts ./WAMessage/index.js;
|
||||
yarn pbjs -t static-module -w commonjs -o ./WAProto/index.js ./WAProto/WAProto.proto;
|
||||
yarn pbts -o ./WAProto/index.d.ts ./WAProto/index.js;
|
||||
|
||||
#protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt=env=node,useOptionals=true,forceLong=long --ts_proto_out=. ./src/Binary/WAMessage.proto;
|
||||
@@ -1,11 +1,621 @@
|
||||
syntax = "proto2";
|
||||
package proto;
|
||||
|
||||
message MessageKey {
|
||||
optional string remoteJid = 1;
|
||||
optional bool fromMe = 2;
|
||||
optional string id = 3;
|
||||
optional string participant = 4;
|
||||
message AppVersion {
|
||||
optional uint32 primary = 1;
|
||||
optional uint32 secondary = 2;
|
||||
optional uint32 tertiary = 3;
|
||||
optional uint32 quaternary = 4;
|
||||
optional uint32 quinary = 5;
|
||||
}
|
||||
|
||||
message UserAgent {
|
||||
enum UserAgentPlatform {
|
||||
ANDROID = 0;
|
||||
IOS = 1;
|
||||
WINDOWS_PHONE = 2;
|
||||
BLACKBERRY = 3;
|
||||
BLACKBERRYX = 4;
|
||||
S40 = 5;
|
||||
S60 = 6;
|
||||
PYTHON_CLIENT = 7;
|
||||
TIZEN = 8;
|
||||
ENTERPRISE = 9;
|
||||
SMB_ANDROID = 10;
|
||||
KAIOS = 11;
|
||||
SMB_IOS = 12;
|
||||
WINDOWS = 13;
|
||||
WEB = 14;
|
||||
PORTAL = 15;
|
||||
GREEN_ANDROID = 16;
|
||||
GREEN_IPHONE = 17;
|
||||
BLUE_ANDROID = 18;
|
||||
BLUE_IPHONE = 19;
|
||||
FBLITE_ANDROID = 20;
|
||||
MLITE_ANDROID = 21;
|
||||
IGLITE_ANDROID = 22;
|
||||
PAGE = 23;
|
||||
MACOS = 24;
|
||||
VR = 25;
|
||||
}
|
||||
optional UserAgentPlatform platform = 1;
|
||||
optional AppVersion appVersion = 2;
|
||||
optional string mcc = 3;
|
||||
optional string mnc = 4;
|
||||
optional string osVersion = 5;
|
||||
optional string manufacturer = 6;
|
||||
optional string device = 7;
|
||||
optional string osBuildNumber = 8;
|
||||
optional string phoneId = 9;
|
||||
enum UserAgentReleaseChannel {
|
||||
RELEASE = 0;
|
||||
BETA = 1;
|
||||
ALPHA = 2;
|
||||
DEBUG = 3;
|
||||
}
|
||||
optional UserAgentReleaseChannel releaseChannel = 10;
|
||||
optional string localeLanguageIso6391 = 11;
|
||||
optional string localeCountryIso31661Alpha2 = 12;
|
||||
optional string deviceBoard = 13;
|
||||
}
|
||||
|
||||
message WebdPayload {
|
||||
optional bool usesParticipantInKey = 1;
|
||||
optional bool supportsStarredMessages = 2;
|
||||
optional bool supportsDocumentMessages = 3;
|
||||
optional bool supportsUrlMessages = 4;
|
||||
optional bool supportsMediaRetry = 5;
|
||||
optional bool supportsE2EImage = 6;
|
||||
optional bool supportsE2EVideo = 7;
|
||||
optional bool supportsE2EAudio = 8;
|
||||
optional bool supportsE2EDocument = 9;
|
||||
optional string documentTypes = 10;
|
||||
optional bytes features = 11;
|
||||
}
|
||||
|
||||
message WebInfo {
|
||||
optional string refToken = 1;
|
||||
optional string version = 2;
|
||||
optional WebdPayload webdPayload = 3;
|
||||
enum WebInfoWebSubPlatform {
|
||||
WEB_BROWSER = 0;
|
||||
APP_STORE = 1;
|
||||
WIN_STORE = 2;
|
||||
DARWIN = 3;
|
||||
WIN32 = 4;
|
||||
}
|
||||
optional WebInfoWebSubPlatform webSubPlatform = 4;
|
||||
}
|
||||
|
||||
message DNSSource {
|
||||
enum DNSSourceDNSResolutionMethod {
|
||||
SYSTEM = 0;
|
||||
GOOGLE = 1;
|
||||
HARDCODED = 2;
|
||||
OVERRIDE = 3;
|
||||
FALLBACK = 4;
|
||||
}
|
||||
optional DNSSourceDNSResolutionMethod dnsMethod = 15;
|
||||
optional bool appCached = 16;
|
||||
}
|
||||
|
||||
message CompanionRegData {
|
||||
optional bytes eRegid = 1;
|
||||
optional bytes eKeytype = 2;
|
||||
optional bytes eIdent = 3;
|
||||
optional bytes eSkeyId = 4;
|
||||
optional bytes eSkeyVal = 5;
|
||||
optional bytes eSkeySig = 6;
|
||||
optional bytes buildHash = 7;
|
||||
optional bytes companionProps = 8;
|
||||
}
|
||||
|
||||
message ClientPayload {
|
||||
optional uint64 username = 1;
|
||||
optional bool passive = 3;
|
||||
enum ClientPayloadClientFeature {
|
||||
NONE = 0;
|
||||
}
|
||||
repeated ClientPayloadClientFeature clientFeatures = 4;
|
||||
optional UserAgent userAgent = 5;
|
||||
optional WebInfo webInfo = 6;
|
||||
optional string pushName = 7;
|
||||
optional sfixed32 sessionId = 9;
|
||||
optional bool shortConnect = 10;
|
||||
enum ClientPayloadIOSAppExtension {
|
||||
SHARE_EXTENSION = 0;
|
||||
SERVICE_EXTENSION = 1;
|
||||
INTENTS_EXTENSION = 2;
|
||||
}
|
||||
optional ClientPayloadIOSAppExtension iosAppExtension = 30;
|
||||
enum ClientPayloadConnectType {
|
||||
CELLULAR_UNKNOWN = 0;
|
||||
WIFI_UNKNOWN = 1;
|
||||
CELLULAR_EDGE = 100;
|
||||
CELLULAR_IDEN = 101;
|
||||
CELLULAR_UMTS = 102;
|
||||
CELLULAR_EVDO = 103;
|
||||
CELLULAR_GPRS = 104;
|
||||
CELLULAR_HSDPA = 105;
|
||||
CELLULAR_HSUPA = 106;
|
||||
CELLULAR_HSPA = 107;
|
||||
CELLULAR_CDMA = 108;
|
||||
CELLULAR_1XRTT = 109;
|
||||
CELLULAR_EHRPD = 110;
|
||||
CELLULAR_LTE = 111;
|
||||
CELLULAR_HSPAP = 112;
|
||||
}
|
||||
optional ClientPayloadConnectType connectType = 12;
|
||||
enum ClientPayloadConnectReason {
|
||||
PUSH = 0;
|
||||
USER_ACTIVATED = 1;
|
||||
SCHEDULED = 2;
|
||||
ERROR_RECONNECT = 3;
|
||||
NETWORK_SWITCH = 4;
|
||||
PING_RECONNECT = 5;
|
||||
}
|
||||
optional ClientPayloadConnectReason connectReason = 13;
|
||||
repeated int32 shards = 14;
|
||||
optional DNSSource dnsSource = 15;
|
||||
optional uint32 connectAttemptCount = 16;
|
||||
optional uint32 agent = 17;
|
||||
optional uint32 device = 18;
|
||||
optional CompanionRegData regData = 19;
|
||||
enum ClientPayloadProduct {
|
||||
WHATSAPP = 0;
|
||||
MESSENGER = 1;
|
||||
}
|
||||
optional ClientPayloadProduct product = 20;
|
||||
optional bytes fbCat = 21;
|
||||
optional bytes fbUserAgent = 22;
|
||||
optional bool oc = 23;
|
||||
}
|
||||
|
||||
message Details {
|
||||
optional uint32 serial = 1;
|
||||
optional string issuer = 2;
|
||||
optional uint64 expires = 3;
|
||||
optional string subject = 4;
|
||||
optional bytes key = 5;
|
||||
}
|
||||
|
||||
message NoiseCertificate {
|
||||
optional bytes details = 1;
|
||||
optional bytes signature = 2;
|
||||
}
|
||||
|
||||
message ClientHello {
|
||||
optional bytes ephemeral = 1;
|
||||
optional bytes static = 2;
|
||||
optional bytes payload = 3;
|
||||
}
|
||||
|
||||
message ServerHello {
|
||||
optional bytes ephemeral = 1;
|
||||
optional bytes static = 2;
|
||||
optional bytes payload = 3;
|
||||
}
|
||||
|
||||
message ClientFinish {
|
||||
optional bytes static = 1;
|
||||
optional bytes payload = 2;
|
||||
}
|
||||
|
||||
message HandshakeMessage {
|
||||
optional ClientHello clientHello = 2;
|
||||
optional ServerHello serverHello = 3;
|
||||
optional ClientFinish clientFinish = 4;
|
||||
}
|
||||
|
||||
message BizIdentityInfo {
|
||||
enum BizIdentityInfoVerifiedLevelValue {
|
||||
UNKNOWN = 0;
|
||||
LOW = 1;
|
||||
HIGH = 2;
|
||||
}
|
||||
optional BizIdentityInfoVerifiedLevelValue vlevel = 1;
|
||||
optional VerifiedNameCertificate vnameCert = 2;
|
||||
optional bool signed = 3;
|
||||
optional bool revoked = 4;
|
||||
enum BizIdentityInfoHostStorageType {
|
||||
ON_PREMISE = 0;
|
||||
FACEBOOK = 1;
|
||||
}
|
||||
optional BizIdentityInfoHostStorageType hostStorage = 5;
|
||||
enum BizIdentityInfoActualActorsType {
|
||||
SELF = 0;
|
||||
BSP = 1;
|
||||
}
|
||||
optional BizIdentityInfoActualActorsType actualActors = 6;
|
||||
optional uint64 privacyModeTs = 7;
|
||||
}
|
||||
|
||||
message BizAccountLinkInfo {
|
||||
optional uint64 whatsappBizAcctFbid = 1;
|
||||
optional string whatsappAcctNumber = 2;
|
||||
optional uint64 issueTime = 3;
|
||||
enum BizAccountLinkInfoHostStorageType {
|
||||
ON_PREMISE = 0;
|
||||
FACEBOOK = 1;
|
||||
}
|
||||
optional BizAccountLinkInfoHostStorageType hostStorage = 4;
|
||||
enum BizAccountLinkInfoAccountType {
|
||||
ENTERPRISE = 0;
|
||||
PAGE = 1;
|
||||
}
|
||||
optional BizAccountLinkInfoAccountType accountType = 5;
|
||||
}
|
||||
|
||||
message BizAccountPayload {
|
||||
optional VerifiedNameCertificate vnameCert = 1;
|
||||
optional bytes bizAcctLinkInfo = 2;
|
||||
}
|
||||
|
||||
//message Details {
|
||||
// optional uint64 serial = 1;
|
||||
// optional string issuer = 2;
|
||||
// optional string verifiedName = 4;
|
||||
// repeated LocalizedName localizedNames = 8;
|
||||
// optional uint64 issueTime = 10;
|
||||
//}
|
||||
|
||||
message VerifiedNameCertificate {
|
||||
optional bytes details = 1;
|
||||
optional bytes signature = 2;
|
||||
optional bytes serverSignature = 3;
|
||||
}
|
||||
|
||||
message LocalizedName {
|
||||
optional string lg = 1;
|
||||
optional string lc = 2;
|
||||
optional string verifiedName = 3;
|
||||
}
|
||||
|
||||
message SyncActionData {
|
||||
optional bytes index = 1;
|
||||
optional SyncActionValue value = 2;
|
||||
optional bytes padding = 3;
|
||||
optional int32 version = 4;
|
||||
}
|
||||
|
||||
message StarAction {
|
||||
optional bool starred = 1;
|
||||
}
|
||||
|
||||
message ContactAction {
|
||||
optional string fullName = 1;
|
||||
optional string firstName = 2;
|
||||
}
|
||||
|
||||
message MuteAction {
|
||||
optional bool muted = 1;
|
||||
optional int64 muteEndTimestamp = 2;
|
||||
}
|
||||
|
||||
message PinAction {
|
||||
optional bool pinned = 1;
|
||||
}
|
||||
|
||||
message SecurityNotificationSetting {
|
||||
optional bool showNotification = 1;
|
||||
}
|
||||
|
||||
message PushNameSetting {
|
||||
optional string name = 1;
|
||||
}
|
||||
|
||||
message LocaleSetting {
|
||||
optional string locale = 1;
|
||||
}
|
||||
|
||||
message QuickReplyAction {
|
||||
optional string shortcut = 1;
|
||||
optional string message = 2;
|
||||
repeated string keywords = 3;
|
||||
optional int32 count = 4;
|
||||
optional bool deleted = 5;
|
||||
}
|
||||
|
||||
message LabelAssociationAction {
|
||||
optional bool labeled = 1;
|
||||
}
|
||||
|
||||
message LabelEditAction {
|
||||
optional string name = 1;
|
||||
optional int32 color = 2;
|
||||
optional int32 predefinedId = 3;
|
||||
optional bool deleted = 4;
|
||||
}
|
||||
|
||||
message RecentStickerWeightsAction {
|
||||
repeated RecentStickerWeight weights = 1;
|
||||
}
|
||||
|
||||
message RecentStickerMetadata {
|
||||
optional string directPath = 1;
|
||||
optional string encFilehash = 2;
|
||||
optional string mediaKey = 3;
|
||||
optional string stanzaId = 4;
|
||||
optional string chatJid = 5;
|
||||
optional string participant = 6;
|
||||
optional bool isSentByMe = 7;
|
||||
}
|
||||
|
||||
message RecentEmojiWeightsAction {
|
||||
repeated RecentEmojiWeight weights = 1;
|
||||
}
|
||||
|
||||
message ArchiveChatAction {
|
||||
optional bool archived = 1;
|
||||
optional SyncActionMessageRange messageRange = 2;
|
||||
}
|
||||
|
||||
message DeleteMessageForMeAction {
|
||||
optional bool deleteMedia = 1;
|
||||
optional int64 messageTimestamp = 2;
|
||||
}
|
||||
|
||||
message MarkChatAsReadAction {
|
||||
optional bool read = 1;
|
||||
optional SyncActionMessageRange messageRange = 2;
|
||||
}
|
||||
|
||||
message ClearChatAction {
|
||||
optional SyncActionMessageRange messageRange = 1;
|
||||
}
|
||||
|
||||
message DeleteChatAction {
|
||||
optional SyncActionMessageRange messageRange = 1;
|
||||
}
|
||||
|
||||
message UnarchiveChatsSetting {
|
||||
optional bool unarchiveChats = 1;
|
||||
}
|
||||
|
||||
message SyncActionMessageRange {
|
||||
optional int64 lastMessageTimestamp = 1;
|
||||
optional int64 lastSystemMessageTimestamp = 2;
|
||||
repeated SyncActionMessage messages = 3;
|
||||
}
|
||||
|
||||
message SyncActionMessage {
|
||||
optional MessageKey key = 1;
|
||||
optional int64 timestamp = 2;
|
||||
}
|
||||
|
||||
message KeyExpiration {
|
||||
optional int32 expiredKeyEpoch = 1;
|
||||
}
|
||||
|
||||
message SyncActionValue {
|
||||
optional int64 timestamp = 1;
|
||||
optional StarAction starAction = 2;
|
||||
optional ContactAction contactAction = 3;
|
||||
optional MuteAction muteAction = 4;
|
||||
optional PinAction pinAction = 5;
|
||||
optional SecurityNotificationSetting securityNotificationSetting = 6;
|
||||
optional PushNameSetting pushNameSetting = 7;
|
||||
optional QuickReplyAction quickReplyAction = 8;
|
||||
optional RecentStickerWeightsAction recentStickerWeightsAction = 9;
|
||||
optional RecentStickerMetadata recentStickerMetadata = 10;
|
||||
optional RecentEmojiWeightsAction recentEmojiWeightsAction = 11;
|
||||
optional LabelEditAction labelEditAction = 14;
|
||||
optional LabelAssociationAction labelAssociationAction = 15;
|
||||
optional LocaleSetting localeSetting = 16;
|
||||
optional ArchiveChatAction archiveChatAction = 17;
|
||||
optional DeleteMessageForMeAction deleteMessageForMeAction = 18;
|
||||
optional KeyExpiration keyExpiration = 19;
|
||||
optional MarkChatAsReadAction markChatAsReadAction = 20;
|
||||
optional ClearChatAction clearChatAction = 21;
|
||||
optional DeleteChatAction deleteChatAction = 22;
|
||||
optional UnarchiveChatsSetting unarchiveChatsSetting = 23;
|
||||
}
|
||||
|
||||
message RecentEmojiWeight {
|
||||
optional string emoji = 1;
|
||||
optional float weight = 2;
|
||||
}
|
||||
|
||||
message RecentStickerWeight {
|
||||
optional string filehash = 1;
|
||||
optional float weight = 2;
|
||||
}
|
||||
|
||||
message SyncdPatch {
|
||||
optional SyncdVersion version = 1;
|
||||
repeated SyncdMutation mutations = 2;
|
||||
optional ExternalBlobReference externalMutations = 3;
|
||||
optional bytes snapshotMac = 4;
|
||||
optional bytes patchMac = 5;
|
||||
optional KeyId keyId = 6;
|
||||
optional ExitCode exitCode = 7;
|
||||
optional uint32 deviceIndex = 8;
|
||||
}
|
||||
|
||||
message SyncdMutation {
|
||||
enum SyncdMutationSyncdOperation {
|
||||
SET = 0;
|
||||
REMOVE = 1;
|
||||
}
|
||||
optional SyncdMutationSyncdOperation operation = 1;
|
||||
optional SyncdRecord record = 2;
|
||||
}
|
||||
|
||||
message SyncdMutations {
|
||||
repeated SyncdMutation mutations = 1;
|
||||
}
|
||||
|
||||
message SyncdSnapshot {
|
||||
optional SyncdVersion version = 1;
|
||||
repeated SyncdRecord records = 2;
|
||||
optional bytes mac = 3;
|
||||
optional KeyId keyId = 4;
|
||||
}
|
||||
|
||||
message ExternalBlobReference {
|
||||
optional bytes mediaKey = 1;
|
||||
optional string directPath = 2;
|
||||
optional string handle = 3;
|
||||
optional uint64 fileSizeBytes = 4;
|
||||
optional bytes fileSha256 = 5;
|
||||
optional bytes fileEncSha256 = 6;
|
||||
}
|
||||
|
||||
message SyncdRecord {
|
||||
optional SyncdIndex index = 1;
|
||||
optional SyncdValue value = 2;
|
||||
optional KeyId keyId = 3;
|
||||
}
|
||||
|
||||
message KeyId {
|
||||
optional bytes id = 1;
|
||||
}
|
||||
|
||||
message SyncdValue {
|
||||
optional bytes blob = 1;
|
||||
}
|
||||
|
||||
message SyncdIndex {
|
||||
optional bytes blob = 1;
|
||||
}
|
||||
|
||||
message ExitCode {
|
||||
optional uint64 code = 1;
|
||||
optional string text = 2;
|
||||
}
|
||||
|
||||
message SyncdVersion {
|
||||
optional uint64 version = 1;
|
||||
}
|
||||
|
||||
message ServerErrorReceipt {
|
||||
optional string stanzaId = 1;
|
||||
}
|
||||
|
||||
message MediaRetryNotification {
|
||||
optional string stanzaId = 1;
|
||||
optional string directPath = 2;
|
||||
enum MediaRetryNotificationResultType {
|
||||
GENERAL_ERROR = 0;
|
||||
SUCCESS = 1;
|
||||
NOT_FOUND = 2;
|
||||
DECRYPTION_ERROR = 3;
|
||||
}
|
||||
optional MediaRetryNotificationResultType result = 3;
|
||||
}
|
||||
|
||||
message MsgOpaqueData {
|
||||
optional string body = 1;
|
||||
optional string caption = 3;
|
||||
optional string clientUrl = 4;
|
||||
// optional string loc = 4;
|
||||
optional double lng = 5;
|
||||
optional double lat = 7;
|
||||
optional int32 paymentAmount1000 = 8;
|
||||
optional string paymentNoteMsgBody = 9;
|
||||
optional string canonicalUrl = 10;
|
||||
optional string matchedText = 11;
|
||||
optional string title = 12;
|
||||
optional string description = 13;
|
||||
}
|
||||
|
||||
message MsgRowOpaqueData {
|
||||
optional MsgOpaqueData currentMsg = 1;
|
||||
optional MsgOpaqueData quotedMsg = 2;
|
||||
}
|
||||
|
||||
message Pushname {
|
||||
optional string id = 1;
|
||||
optional string pushname = 2;
|
||||
}
|
||||
|
||||
message HistorySyncMsg {
|
||||
optional WebMessageInfo message = 1;
|
||||
optional uint64 msgOrderId = 2;
|
||||
}
|
||||
|
||||
message Conversation {
|
||||
required string id = 1;
|
||||
repeated HistorySyncMsg messages = 2;
|
||||
optional string newJid = 3;
|
||||
optional string oldJid = 4;
|
||||
optional uint64 lastMsgTimestamp = 5;
|
||||
optional uint32 unreadCount = 6;
|
||||
optional bool readOnly = 7;
|
||||
optional bool endOfHistoryTransfer = 8;
|
||||
optional uint32 ephemeralExpiration = 9;
|
||||
optional int64 ephemeralSettingTimestamp = 10;
|
||||
enum ConversationEndOfHistoryTransferType {
|
||||
COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY = 0;
|
||||
COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY = 1;
|
||||
}
|
||||
optional ConversationEndOfHistoryTransferType endOfHistoryTransferType = 11;
|
||||
optional uint64 conversationTimestamp = 12;
|
||||
optional string name = 13;
|
||||
optional string pHash = 14;
|
||||
optional bool notSpam = 15;
|
||||
}
|
||||
|
||||
message HistorySync {
|
||||
enum HistorySyncHistorySyncType {
|
||||
INITIAL_BOOTSTRAP = 0;
|
||||
INITIAL_STATUS_V3 = 1;
|
||||
FULL = 2;
|
||||
RECENT = 3;
|
||||
PUSH_NAME = 4;
|
||||
}
|
||||
required HistorySyncHistorySyncType syncType = 1;
|
||||
repeated Conversation conversations = 2;
|
||||
repeated WebMessageInfo statusV3Messages = 3;
|
||||
optional uint32 chunkOrder = 5;
|
||||
optional uint32 progress = 6;
|
||||
repeated Pushname pushnames = 7;
|
||||
}
|
||||
|
||||
message EphemeralSetting {
|
||||
optional sfixed32 duration = 1;
|
||||
optional sfixed64 timestamp = 2;
|
||||
}
|
||||
|
||||
message PaymentBackground {
|
||||
optional string id = 1;
|
||||
optional string fileLength = 2;
|
||||
optional uint32 width = 3;
|
||||
optional uint32 height = 4;
|
||||
optional string mimetype = 5;
|
||||
optional fixed32 placeholderArgb = 6;
|
||||
optional fixed32 textArgb = 7;
|
||||
optional fixed32 subtextArgb = 8;
|
||||
}
|
||||
|
||||
message Money {
|
||||
optional int64 value = 1;
|
||||
optional uint32 offset = 2;
|
||||
optional string currencyCode = 3;
|
||||
}
|
||||
|
||||
message HydratedQuickReplyButton {
|
||||
optional string displayText = 1;
|
||||
optional string id = 2;
|
||||
}
|
||||
|
||||
message HydratedURLButton {
|
||||
optional string displayText = 1;
|
||||
optional string url = 2;
|
||||
}
|
||||
|
||||
message HydratedCallButton {
|
||||
optional string displayText = 1;
|
||||
optional string phoneNumber = 2;
|
||||
}
|
||||
|
||||
message HydratedTemplateButton {
|
||||
optional uint32 index = 4;
|
||||
oneof hydratedButton {
|
||||
HydratedQuickReplyButton quickReplyButton = 1;
|
||||
HydratedURLButton urlButton = 2;
|
||||
HydratedCallButton callButton = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message QuickReplyButton {
|
||||
@@ -115,7 +725,6 @@ message ContextInfo {
|
||||
optional string entryPointConversionSource = 29;
|
||||
optional string entryPointConversionApp = 30;
|
||||
optional uint32 entryPointConversionDelaySeconds = 31;
|
||||
optional DisappearingMode disappearingMode = 32;
|
||||
}
|
||||
|
||||
message SenderKeyDistributionMessage {
|
||||
@@ -149,7 +758,6 @@ message ImageMessage {
|
||||
optional string thumbnailDirectPath = 26;
|
||||
optional bytes thumbnailSha256 = 27;
|
||||
optional bytes thumbnailEncSha256 = 28;
|
||||
optional string staticUrl = 29;
|
||||
}
|
||||
|
||||
message InvoiceMessage {
|
||||
@@ -289,7 +897,6 @@ message VideoMessage {
|
||||
optional string thumbnailDirectPath = 21;
|
||||
optional bytes thumbnailSha256 = 22;
|
||||
optional bytes thumbnailEncSha256 = 23;
|
||||
optional string staticUrl = 24;
|
||||
}
|
||||
|
||||
message Call {
|
||||
@@ -325,7 +932,6 @@ message ProtocolMessage {
|
||||
optional AppStateSyncKeyRequest appStateSyncKeyRequest = 8;
|
||||
optional InitialSecurityNotificationSettingSync initialSecurityNotificationSettingSync = 9;
|
||||
optional AppStateFatalExceptionNotification appStateFatalExceptionNotification = 10;
|
||||
optional DisappearingMode disappearingMode = 11;
|
||||
}
|
||||
|
||||
message HistorySyncNotification {
|
||||
@@ -669,59 +1275,6 @@ message ListResponseMessage {
|
||||
optional string description = 5;
|
||||
}
|
||||
|
||||
message Header {
|
||||
optional string title = 1;
|
||||
optional string subtitle = 2;
|
||||
oneof media {
|
||||
DocumentMessage documentMessage = 3;
|
||||
ImageMessage imageMessage = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message Body {
|
||||
optional string text = 1;
|
||||
}
|
||||
|
||||
message Footer {
|
||||
optional string text = 1;
|
||||
}
|
||||
|
||||
message ShopsMessage {
|
||||
optional string id = 1;
|
||||
enum ShopsMessageSurface {
|
||||
UNKNOWN_SURFACE = 0;
|
||||
FB = 1;
|
||||
IG = 2;
|
||||
WA = 3;
|
||||
}
|
||||
optional ShopsMessageSurface surface = 2;
|
||||
enum ShopsMessageType {
|
||||
UNKNOWN_TYPE = 0;
|
||||
PRODUCT = 1;
|
||||
STOREFRONT = 2;
|
||||
COLLECTION = 3;
|
||||
}
|
||||
optional ShopsMessageType type = 3;
|
||||
optional int32 messageVersion = 4;
|
||||
}
|
||||
|
||||
message CollectionMessage {
|
||||
optional string bizJid = 1;
|
||||
optional string id = 2;
|
||||
optional int32 messageVersion = 3;
|
||||
}
|
||||
|
||||
message InteractiveMessage {
|
||||
optional Header header = 1;
|
||||
optional Body body = 2;
|
||||
optional Footer footer = 3;
|
||||
optional ContextInfo contextInfo = 15;
|
||||
oneof interactiveMessage {
|
||||
ShopsMessage shopsMessage = 4;
|
||||
CollectionMessage collectionMessage = 5;
|
||||
}
|
||||
}
|
||||
|
||||
message GroupInviteMessage {
|
||||
optional string groupJid = 1;
|
||||
optional string inviteCode = 2;
|
||||
@@ -837,66 +1390,65 @@ message Message {
|
||||
optional ButtonsMessage buttonsMessage = 42;
|
||||
optional ButtonsResponseMessage buttonsResponseMessage = 43;
|
||||
optional PaymentInviteMessage paymentInviteMessage = 44;
|
||||
optional InteractiveMessage interactiveMessage = 45;
|
||||
}
|
||||
|
||||
message DisappearingMode {
|
||||
enum DisappearingModeInitiator {
|
||||
CHANGED_IN_CHAT = 0;
|
||||
INITIATED_BY_ME = 1;
|
||||
INITIATED_BY_OTHER = 2;
|
||||
message CompanionProps {
|
||||
optional string os = 1;
|
||||
optional AppVersion version = 2;
|
||||
enum CompanionPropsPlatformType {
|
||||
UNKNOWN = 0;
|
||||
CHROME = 1;
|
||||
FIREFOX = 2;
|
||||
IE = 3;
|
||||
OPERA = 4;
|
||||
SAFARI = 5;
|
||||
EDGE = 6;
|
||||
DESKTOP = 7;
|
||||
IPAD = 8;
|
||||
ANDROID_TABLET = 9;
|
||||
OHANA = 10;
|
||||
ALOHA = 11;
|
||||
CATALINA = 12;
|
||||
}
|
||||
optional DisappearingModeInitiator initiator = 1;
|
||||
optional CompanionPropsPlatformType platformType = 3;
|
||||
optional bool requireFullSync = 4;
|
||||
}
|
||||
|
||||
message PaymentBackground {
|
||||
optional string id = 1;
|
||||
optional uint64 fileLength = 2;
|
||||
optional uint32 width = 3;
|
||||
optional uint32 height = 4;
|
||||
optional string mimetype = 5;
|
||||
optional fixed32 placeholderArgb = 6;
|
||||
optional fixed32 textArgb = 7;
|
||||
optional fixed32 subtextArgb = 8;
|
||||
message ADVSignedDeviceIdentityHMAC {
|
||||
optional bytes details = 1;
|
||||
optional bytes hmac = 2;
|
||||
}
|
||||
|
||||
message Money {
|
||||
optional int64 value = 1;
|
||||
optional uint32 offset = 2;
|
||||
optional string currencyCode = 3;
|
||||
message ADVSignedDeviceIdentity {
|
||||
optional bytes details = 1;
|
||||
optional bytes accountSignatureKey = 2;
|
||||
optional bytes accountSignature = 3;
|
||||
optional bytes deviceSignature = 4;
|
||||
}
|
||||
|
||||
message HydratedQuickReplyButton {
|
||||
optional string displayText = 1;
|
||||
optional string id = 2;
|
||||
message ADVDeviceIdentity {
|
||||
optional uint32 rawId = 1;
|
||||
optional uint64 timestamp = 2;
|
||||
optional uint32 keyIndex = 3;
|
||||
}
|
||||
|
||||
message HydratedURLButton {
|
||||
optional string displayText = 1;
|
||||
optional string url = 2;
|
||||
message ADVSignedKeyIndexList {
|
||||
optional bytes details = 1;
|
||||
optional bytes accountSignature = 2;
|
||||
}
|
||||
|
||||
message HydratedCallButton {
|
||||
optional string displayText = 1;
|
||||
optional string phoneNumber = 2;
|
||||
message ADVKeyIndexList {
|
||||
optional uint32 rawId = 1;
|
||||
optional uint64 timestamp = 2;
|
||||
optional uint32 currentIndex = 3;
|
||||
repeated uint32 validIndexes = 4 [packed=true];
|
||||
}
|
||||
|
||||
message HydratedTemplateButton {
|
||||
optional uint32 index = 4;
|
||||
oneof hydratedButton {
|
||||
HydratedQuickReplyButton quickReplyButton = 1;
|
||||
HydratedURLButton urlButton = 2;
|
||||
HydratedCallButton callButton = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message UserReceipt {
|
||||
required string userJid = 1;
|
||||
optional int64 receiptTimestamp = 2;
|
||||
optional int64 readTimestamp = 3;
|
||||
optional int64 playedTimestamp = 4;
|
||||
repeated string pendingDeviceJid = 5;
|
||||
repeated string deliveredDeviceJid = 6;
|
||||
message MessageKey {
|
||||
optional string remoteJid = 1;
|
||||
optional bool fromMe = 2;
|
||||
optional string id = 3;
|
||||
optional string participant = 4;
|
||||
}
|
||||
|
||||
message PhotoChange {
|
||||
@@ -958,7 +1510,6 @@ message WebFeatures {
|
||||
optional WebFeaturesFlag ephemeralAllowGroupMembers = 44;
|
||||
optional WebFeaturesFlag ephemeral24HDuration = 45;
|
||||
optional WebFeaturesFlag mdForceUpgrade = 46;
|
||||
optional WebFeaturesFlag disappearingMode = 47;
|
||||
}
|
||||
|
||||
message NotificationMessageInfo {
|
||||
@@ -1194,7 +1745,6 @@ message WebMessageInfo {
|
||||
BIZ_PRIVACY_MODE_INIT_BSP = 127;
|
||||
BIZ_PRIVACY_MODE_TO_FB = 128;
|
||||
BIZ_PRIVACY_MODE_TO_BSP = 129;
|
||||
DISAPPEARING_MODE = 130;
|
||||
}
|
||||
optional WebMessageInfoStubType messageStubType = 24;
|
||||
optional bool clearMedia = 25;
|
||||
@@ -1218,5 +1768,4 @@ message WebMessageInfo {
|
||||
optional string verifiedBizName = 37;
|
||||
optional MediaData mediaData = 38;
|
||||
optional PhotoChange photoChange = 39;
|
||||
repeated UserReceipt userReceipt = 40;
|
||||
}
|
||||
}
|
||||
9715
WAMessage/index.d.ts → WAProto/index.d.ts
vendored
9715
WAMessage/index.d.ts → WAProto/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
16
WASignalGroup/ciphertext_message.js
Normal file
16
WASignalGroup/ciphertext_message.js
Normal file
@@ -0,0 +1,16 @@
|
||||
class CiphertextMessage {
|
||||
UNSUPPORTED_VERSION = 1;
|
||||
|
||||
CURRENT_VERSION = 3;
|
||||
|
||||
WHISPER_TYPE = 2;
|
||||
|
||||
PREKEY_TYPE = 3;
|
||||
|
||||
SENDERKEY_TYPE = 4;
|
||||
|
||||
SENDERKEY_DISTRIBUTION_TYPE = 5;
|
||||
|
||||
ENCRYPTED_MESSAGE_OVERHEAD = 53;
|
||||
}
|
||||
module.exports = CiphertextMessage;
|
||||
41
WASignalGroup/group.proto
Normal file
41
WASignalGroup/group.proto
Normal file
@@ -0,0 +1,41 @@
|
||||
package groupproto;
|
||||
|
||||
message SenderKeyMessage {
|
||||
optional uint32 id = 1;
|
||||
optional uint32 iteration = 2;
|
||||
optional bytes ciphertext = 3;
|
||||
}
|
||||
|
||||
message SenderKeyDistributionMessage {
|
||||
optional uint32 id = 1;
|
||||
optional uint32 iteration = 2;
|
||||
optional bytes chainKey = 3;
|
||||
optional bytes signingKey = 4;
|
||||
}
|
||||
|
||||
|
||||
message SenderKeyStateStructure {
|
||||
message SenderChainKey {
|
||||
optional uint32 iteration = 1;
|
||||
optional bytes seed = 2;
|
||||
}
|
||||
|
||||
message SenderMessageKey {
|
||||
optional uint32 iteration = 1;
|
||||
optional bytes seed = 2;
|
||||
}
|
||||
|
||||
message SenderSigningKey {
|
||||
optional bytes public = 1;
|
||||
optional bytes private = 2;
|
||||
}
|
||||
|
||||
optional uint32 senderKeyId = 1;
|
||||
optional SenderChainKey senderChainKey = 2;
|
||||
optional SenderSigningKey senderSigningKey = 3;
|
||||
repeated SenderMessageKey senderMessageKeys = 4;
|
||||
}
|
||||
|
||||
message SenderKeyRecordStructure {
|
||||
repeated SenderKeyStateStructure senderKeyStates = 1;
|
||||
}
|
||||
106
WASignalGroup/group_cipher.js
Normal file
106
WASignalGroup/group_cipher.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const SenderKeyMessage = require('./sender_key_message');
|
||||
const crypto = require('libsignal/src/crypto');
|
||||
|
||||
class GroupCipher {
|
||||
constructor(senderKeyStore, senderKeyName) {
|
||||
this.senderKeyStore = senderKeyStore;
|
||||
this.senderKeyName = senderKeyName;
|
||||
}
|
||||
|
||||
async encrypt(paddedPlaintext) {
|
||||
try {
|
||||
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName);
|
||||
const senderKeyState = record.getSenderKeyState();
|
||||
const senderKey = senderKeyState.getSenderChainKey().getSenderMessageKey();
|
||||
|
||||
const ciphertext = await this.getCipherText(
|
||||
senderKey.getIv(),
|
||||
senderKey.getCipherKey(),
|
||||
paddedPlaintext
|
||||
);
|
||||
|
||||
const senderKeyMessage = new SenderKeyMessage(
|
||||
senderKeyState.getKeyId(),
|
||||
senderKey.getIteration(),
|
||||
ciphertext,
|
||||
senderKeyState.getSigningKeyPrivate()
|
||||
);
|
||||
senderKeyState.setSenderChainKey(senderKeyState.getSenderChainKey().getNext());
|
||||
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record);
|
||||
return senderKeyMessage.serialize();
|
||||
} catch (e) {
|
||||
//console.log(e.stack);
|
||||
throw new Error('NoSessionException');
|
||||
}
|
||||
}
|
||||
|
||||
async decrypt(senderKeyMessageBytes) {
|
||||
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName);
|
||||
if (!record) throw new Error(`No sender key for: ${this.senderKeyName}`);
|
||||
|
||||
const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes);
|
||||
|
||||
const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId());
|
||||
//senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic());
|
||||
const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration());
|
||||
// senderKeyState.senderKeyStateStructure.senderSigningKey.private =
|
||||
|
||||
const plaintext = await this.getPlainText(
|
||||
senderKey.getIv(),
|
||||
senderKey.getCipherKey(),
|
||||
senderKeyMessage.getCipherText()
|
||||
);
|
||||
|
||||
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
getSenderKey(senderKeyState, iteration) {
|
||||
let senderChainKey = senderKeyState.getSenderChainKey();
|
||||
if (senderChainKey.getIteration() > iteration) {
|
||||
if (senderKeyState.hasSenderMessageKey(iteration)) {
|
||||
return senderKeyState.removeSenderMessageKey(iteration);
|
||||
}
|
||||
throw new Error(
|
||||
`Received message with old counter: ${senderChainKey.getIteration()}, ${iteration}`
|
||||
);
|
||||
}
|
||||
|
||||
if (senderChainKey.getIteration() - iteration > 2000) {
|
||||
throw new Error('Over 2000 messages into the future!');
|
||||
}
|
||||
|
||||
while (senderChainKey.getIteration() < iteration) {
|
||||
senderKeyState.addSenderMessageKey(senderChainKey.getSenderMessageKey());
|
||||
senderChainKey = senderChainKey.getNext();
|
||||
}
|
||||
|
||||
senderKeyState.setSenderChainKey(senderChainKey.getNext());
|
||||
return senderChainKey.getSenderMessageKey();
|
||||
}
|
||||
|
||||
getPlainText(iv, key, ciphertext) {
|
||||
try {
|
||||
const plaintext = crypto.decrypt(key, ciphertext, iv);
|
||||
return plaintext;
|
||||
} catch (e) {
|
||||
//console.log(e.stack);
|
||||
throw new Error('InvalidMessageException');
|
||||
}
|
||||
}
|
||||
|
||||
getCipherText(iv, key, plaintext) {
|
||||
try {
|
||||
iv = typeof iv === 'string' ? Buffer.from(iv, 'base64') : iv;
|
||||
key = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
|
||||
const crypted = crypto.encrypt(key, Buffer.from(plaintext), iv);
|
||||
return crypted;
|
||||
} catch (e) {
|
||||
//console.log(e.stack);
|
||||
throw new Error('InvalidMessageException');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GroupCipher;
|
||||
51
WASignalGroup/group_session_builder.js
Normal file
51
WASignalGroup/group_session_builder.js
Normal file
@@ -0,0 +1,51 @@
|
||||
//const utils = require('../../common/utils');
|
||||
const SenderKeyDistributionMessage = require('./sender_key_distribution_message');
|
||||
|
||||
const keyhelper = require("libsignal/src/keyhelper");
|
||||
class GroupSessionBuilder {
|
||||
constructor(senderKeyStore) {
|
||||
this.senderKeyStore = senderKeyStore;
|
||||
}
|
||||
|
||||
async process(senderKeyName, senderKeyDistributionMessage) {
|
||||
//console.log('GroupSessionBuilder process', senderKeyName, senderKeyDistributionMessage);
|
||||
const senderKeyRecord = await this.senderKeyStore.loadSenderKey(senderKeyName);
|
||||
senderKeyRecord.addSenderKeyState(
|
||||
senderKeyDistributionMessage.getId(),
|
||||
senderKeyDistributionMessage.getIteration(),
|
||||
senderKeyDistributionMessage.getChainKey(),
|
||||
senderKeyDistributionMessage.getSignatureKey()
|
||||
);
|
||||
await this.senderKeyStore.storeSenderKey(senderKeyName, senderKeyRecord);
|
||||
}
|
||||
|
||||
// [{"senderKeyId":1742199468,"senderChainKey":{"iteration":0,"seed":"yxMY9VFQcXEP34olRAcGCtsgx1XoKsHfDIh+1ea4HAQ="},"senderSigningKey":{"public":""}}]
|
||||
async create(senderKeyName) {
|
||||
try {
|
||||
const senderKeyRecord = await this.senderKeyStore.loadSenderKey(senderKeyName);
|
||||
//console.log('GroupSessionBuilder create session', senderKeyName, senderKeyRecord);
|
||||
|
||||
if (senderKeyRecord.isEmpty()) {
|
||||
const keyId = keyhelper.generateSenderKeyId();
|
||||
const senderKey = keyhelper.generateSenderKey();
|
||||
const signingKey = keyhelper.generateSenderSigningKey();
|
||||
|
||||
senderKeyRecord.setSenderKeyState(keyId, 0, senderKey, signingKey);
|
||||
await this.senderKeyStore.storeSenderKey(senderKeyName, senderKeyRecord);
|
||||
}
|
||||
|
||||
const state = senderKeyRecord.getSenderKeyState();
|
||||
|
||||
return new SenderKeyDistributionMessage(
|
||||
state.getKeyId(),
|
||||
state.getSenderChainKey().getIteration(),
|
||||
state.getSenderChainKey().getSeed(),
|
||||
state.getSigningKeyPublic()
|
||||
);
|
||||
} catch (e) {
|
||||
//console.log(e.stack);
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = GroupSessionBuilder;
|
||||
5
WASignalGroup/index.js
Normal file
5
WASignalGroup/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports.GroupSessionBuilder = require('./group_session_builder')
|
||||
module.exports.SenderKeyDistributionMessage = require('./sender_key_distribution_message')
|
||||
module.exports.SenderKeyRecord = require('./sender_key_record')
|
||||
module.exports.SenderKeyName = require('./sender_key_name')
|
||||
module.exports.GroupCipher = require('./group_cipher')
|
||||
13
WASignalGroup/protobufs.js
Normal file
13
WASignalGroup/protobufs.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const path = require('path');
|
||||
const protobuf = require('protobufjs');
|
||||
|
||||
const protodir = path.resolve(__dirname);
|
||||
const group = protobuf.loadSync(path.join(protodir, 'group.proto')).lookup('groupproto');
|
||||
|
||||
module.exports = {
|
||||
SenderKeyDistributionMessage: group.lookup('SenderKeyDistributionMessage'),
|
||||
SenderKeyMessage: group.lookup('SenderKeyMessage'),
|
||||
SenderKeyStateStructure: group.lookup('SenderKeyStateStructure'),
|
||||
SenderChainKey: group.lookup('SenderChainKey'),
|
||||
SenderSigningKey: group.lookup('SenderSigningKey'),
|
||||
};
|
||||
6
WASignalGroup/readme.md
Normal file
6
WASignalGroup/readme.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Signal-Group
|
||||
|
||||
This contains the code to decrypt/encrypt WA group messages.
|
||||
Originally from [pokearaujo/libsignal-node](https://github.com/pokearaujo/libsignal-node)
|
||||
|
||||
The code has been moved outside the signal package as I felt it didn't belong in ths signal package, as it isn't inherently a part of signal but of WA.
|
||||
50
WASignalGroup/sender_chain_key.js
Normal file
50
WASignalGroup/sender_chain_key.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const SenderMessageKey = require('./sender_message_key');
|
||||
//const HKDF = require('./hkdf');
|
||||
const crypto = require('libsignal/src/crypto');
|
||||
|
||||
class SenderChainKey {
|
||||
MESSAGE_KEY_SEED = Buffer.from([0x01]);
|
||||
|
||||
CHAIN_KEY_SEED = Buffer.from([0x02]);
|
||||
|
||||
iteration = 0;
|
||||
|
||||
chainKey = Buffer.alloc(0);
|
||||
|
||||
constructor(iteration, chainKey) {
|
||||
this.iteration = iteration;
|
||||
this.chainKey = chainKey;
|
||||
}
|
||||
|
||||
getIteration() {
|
||||
return this.iteration;
|
||||
}
|
||||
|
||||
getSenderMessageKey() {
|
||||
return new SenderMessageKey(
|
||||
this.iteration,
|
||||
this.getDerivative(this.MESSAGE_KEY_SEED, this.chainKey)
|
||||
);
|
||||
}
|
||||
|
||||
getNext() {
|
||||
return new SenderChainKey(
|
||||
this.iteration + 1,
|
||||
this.getDerivative(this.CHAIN_KEY_SEED, this.chainKey)
|
||||
);
|
||||
}
|
||||
|
||||
getSeed() {
|
||||
return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey;
|
||||
}
|
||||
|
||||
getDerivative(seed, key) {
|
||||
key = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
|
||||
const hash = crypto.calculateMAC(key, seed);
|
||||
//const hash = new Hash().hmac_hash(key, seed, 'sha256', '');
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SenderChainKey;
|
||||
78
WASignalGroup/sender_key_distribution_message.js
Normal file
78
WASignalGroup/sender_key_distribution_message.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const CiphertextMessage = require('./ciphertext_message');
|
||||
const protobufs = require('./protobufs');
|
||||
|
||||
class SenderKeyDistributionMessage extends CiphertextMessage {
|
||||
constructor(
|
||||
id = null,
|
||||
iteration = null,
|
||||
chainKey = null,
|
||||
signatureKey = null,
|
||||
serialized = null
|
||||
) {
|
||||
super();
|
||||
if (serialized) {
|
||||
try {
|
||||
const version = serialized[0];
|
||||
const message = serialized.slice(1);
|
||||
|
||||
const distributionMessage = protobufs.SenderKeyDistributionMessage.decode(
|
||||
message
|
||||
).toJSON();
|
||||
this.serialized = serialized;
|
||||
this.id = distributionMessage.id;
|
||||
this.iteration = distributionMessage.iteration;
|
||||
this.chainKey = distributionMessage.chainKey;
|
||||
this.signatureKey = distributionMessage.signingKey;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
} else {
|
||||
const version = this.intsToByteHighAndLow(this.CURRENT_VERSION, this.CURRENT_VERSION);
|
||||
this.id = id;
|
||||
this.iteration = iteration;
|
||||
this.chainKey = chainKey;
|
||||
this.signatureKey = signatureKey;
|
||||
const message = protobufs.SenderKeyDistributionMessage.encode(
|
||||
protobufs.SenderKeyDistributionMessage.create({
|
||||
id,
|
||||
iteration,
|
||||
chainKey,
|
||||
signingKey: this.signatureKey,
|
||||
})
|
||||
).finish();
|
||||
this.serialized = Buffer.concat([Buffer.from([version]), message]);
|
||||
}
|
||||
}
|
||||
|
||||
intsToByteHighAndLow(highValue, lowValue) {
|
||||
return (((highValue << 4) | lowValue) & 0xff) % 256;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.serialized;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return this.SENDERKEY_DISTRIBUTION_TYPE;
|
||||
}
|
||||
|
||||
getIteration() {
|
||||
return this.iteration;
|
||||
}
|
||||
|
||||
getChainKey() {
|
||||
return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey;
|
||||
}
|
||||
|
||||
getSignatureKey() {
|
||||
return typeof this.signatureKey === 'string'
|
||||
? Buffer.from(this.signatureKey, 'base64')
|
||||
: this.signatureKey;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SenderKeyDistributionMessage;
|
||||
92
WASignalGroup/sender_key_message.js
Normal file
92
WASignalGroup/sender_key_message.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const CiphertextMessage = require('./ciphertext_message');
|
||||
const curve = require('libsignal/src/curve');
|
||||
const protobufs = require('./protobufs');
|
||||
|
||||
class SenderKeyMessage extends CiphertextMessage {
|
||||
SIGNATURE_LENGTH = 64;
|
||||
|
||||
constructor(
|
||||
keyId = null,
|
||||
iteration = null,
|
||||
ciphertext = null,
|
||||
signatureKey = null,
|
||||
serialized = null
|
||||
) {
|
||||
super();
|
||||
if (serialized) {
|
||||
const version = serialized[0];
|
||||
const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH);
|
||||
const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH);
|
||||
const senderKeyMessage = protobufs.SenderKeyMessage.decode(message).toJSON();
|
||||
senderKeyMessage.ciphertext = Buffer.from(senderKeyMessage.ciphertext, 'base64');
|
||||
|
||||
this.serialized = serialized;
|
||||
this.messageVersion = (version & 0xff) >> 4;
|
||||
|
||||
this.keyId = senderKeyMessage.id;
|
||||
this.iteration = senderKeyMessage.iteration;
|
||||
this.ciphertext = senderKeyMessage.ciphertext;
|
||||
this.signature = signature;
|
||||
} else {
|
||||
const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256;
|
||||
ciphertext = Buffer.from(ciphertext); // .toString('base64');
|
||||
const message = protobufs.SenderKeyMessage.encode(
|
||||
protobufs.SenderKeyMessage.create({
|
||||
id: keyId,
|
||||
iteration,
|
||||
ciphertext,
|
||||
})
|
||||
).finish();
|
||||
|
||||
const signature = this.getSignature(
|
||||
signatureKey,
|
||||
Buffer.concat([Buffer.from([version]), message])
|
||||
);
|
||||
this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)]);
|
||||
this.messageVersion = this.CURRENT_VERSION;
|
||||
this.keyId = keyId;
|
||||
this.iteration = iteration;
|
||||
this.ciphertext = ciphertext;
|
||||
this.signature = signature;
|
||||
}
|
||||
}
|
||||
|
||||
getKeyId() {
|
||||
return this.keyId;
|
||||
}
|
||||
|
||||
getIteration() {
|
||||
return this.iteration;
|
||||
}
|
||||
|
||||
getCipherText() {
|
||||
return this.ciphertext;
|
||||
}
|
||||
|
||||
verifySignature(signatureKey) {
|
||||
const part1 = this.serialized.slice(0, this.serialized.length - this.SIGNATURE_LENGTH + 1);
|
||||
const part2 = this.serialized.slice(-1 * this.SIGNATURE_LENGTH);
|
||||
const res = curve.verifySignature(signatureKey, part1, part2);
|
||||
if (!res) throw new Error('Invalid signature!');
|
||||
}
|
||||
|
||||
getSignature(signatureKey, serialized) {
|
||||
const signature = Buffer.from(
|
||||
curve.calculateSignature(
|
||||
signatureKey,
|
||||
serialized
|
||||
)
|
||||
);
|
||||
return signature;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.serialized;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SenderKeyMessage;
|
||||
70
WASignalGroup/sender_key_name.js
Normal file
70
WASignalGroup/sender_key_name.js
Normal file
@@ -0,0 +1,70 @@
|
||||
function isNull(str) {
|
||||
return str === null || str.value === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* java String hashCode 的实现
|
||||
* @param strKey
|
||||
* @return intValue
|
||||
*/
|
||||
function intValue(num) {
|
||||
const MAX_VALUE = 0x7fffffff;
|
||||
const MIN_VALUE = -0x80000000;
|
||||
if (num > MAX_VALUE || num < MIN_VALUE) {
|
||||
// eslint-disable-next-line
|
||||
return (num &= 0xffffffff);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function hashCode(strKey) {
|
||||
let hash = 0;
|
||||
if (!isNull(strKey)) {
|
||||
for (let i = 0; i < strKey.length; i++) {
|
||||
hash = hash * 31 + strKey.charCodeAt(i);
|
||||
hash = intValue(hash);
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将js页面的number类型转换为java的int类型
|
||||
* @param num
|
||||
* @return intValue
|
||||
*/
|
||||
|
||||
class SenderKeyName {
|
||||
constructor(groupId, sender) {
|
||||
this.groupId = groupId;
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
getGroupId() {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
getSender() {
|
||||
return this.sender;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return `${this.groupId}::${this.sender.id}::${this.sender.deviceId}`;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.serialize();
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
if (other === null) return false;
|
||||
if (!(other instanceof SenderKeyName)) return false;
|
||||
return this.groupId === other.groupId && this.sender.toString() === other.sender.toString();
|
||||
}
|
||||
|
||||
hashCode() {
|
||||
return hashCode(this.groupId) ^ hashCode(this.sender.toString());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SenderKeyName;
|
||||
54
WASignalGroup/sender_key_record.js
Normal file
54
WASignalGroup/sender_key_record.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const SenderKeyState = require('./sender_key_state');
|
||||
|
||||
class SenderKeyRecord {
|
||||
MAX_STATES = 5;
|
||||
|
||||
constructor(serialized) {
|
||||
this.senderKeyStates = [];
|
||||
|
||||
if (serialized) {
|
||||
const list = serialized;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const structure = list[i];
|
||||
this.senderKeyStates.push(
|
||||
new SenderKeyState(null, null, null, null, null, null, structure)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.senderKeyStates.length === 0;
|
||||
}
|
||||
|
||||
getSenderKeyState(keyId) {
|
||||
if (!keyId && this.senderKeyStates.length) return this.senderKeyStates[0];
|
||||
for (let i = 0; i < this.senderKeyStates.length; i++) {
|
||||
const state = this.senderKeyStates[i];
|
||||
if (state.getKeyId() === keyId) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
throw new Error(`No keys for: ${keyId}`);
|
||||
}
|
||||
|
||||
addSenderKeyState(id, iteration, chainKey, signatureKey) {
|
||||
this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, null, signatureKey));
|
||||
}
|
||||
|
||||
setSenderKeyState(id, iteration, chainKey, keyPair) {
|
||||
this.senderKeyStates.length = 0;
|
||||
this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, keyPair));
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const recordStructure = [];
|
||||
for (let i = 0; i < this.senderKeyStates.length; i++) {
|
||||
const senderKeyState = this.senderKeyStates[i];
|
||||
recordStructure.push(senderKeyState.getStructure());
|
||||
}
|
||||
return recordStructure;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SenderKeyRecord;
|
||||
129
WASignalGroup/sender_key_state.js
Normal file
129
WASignalGroup/sender_key_state.js
Normal file
@@ -0,0 +1,129 @@
|
||||
const SenderChainKey = require('./sender_chain_key');
|
||||
const SenderMessageKey = require('./sender_message_key');
|
||||
|
||||
const protobufs = require('./protobufs');
|
||||
|
||||
class SenderKeyState {
|
||||
MAX_MESSAGE_KEYS = 2000;
|
||||
|
||||
constructor(
|
||||
id = null,
|
||||
iteration = null,
|
||||
chainKey = null,
|
||||
signatureKeyPair = null,
|
||||
signatureKeyPublic = null,
|
||||
signatureKeyPrivate = null,
|
||||
senderKeyStateStructure = null
|
||||
) {
|
||||
if (senderKeyStateStructure) {
|
||||
this.senderKeyStateStructure = senderKeyStateStructure;
|
||||
} else {
|
||||
if (signatureKeyPair) {
|
||||
signatureKeyPublic = signatureKeyPair.public;
|
||||
signatureKeyPrivate = signatureKeyPair.private;
|
||||
}
|
||||
|
||||
chainKey = typeof chainKey === 'string' ? Buffer.from(chainKey, 'base64') : chainKey;
|
||||
this.senderKeyStateStructure = protobufs.SenderKeyStateStructure.create();
|
||||
const senderChainKeyStructure = protobufs.SenderChainKey.create();
|
||||
senderChainKeyStructure.iteration = iteration;
|
||||
senderChainKeyStructure.seed = chainKey;
|
||||
this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure;
|
||||
|
||||
const signingKeyStructure = protobufs.SenderSigningKey.create();
|
||||
signingKeyStructure.public =
|
||||
typeof signatureKeyPublic === 'string' ?
|
||||
Buffer.from(signatureKeyPublic, 'base64') :
|
||||
signatureKeyPublic;
|
||||
if (signatureKeyPrivate) {
|
||||
signingKeyStructure.private =
|
||||
typeof signatureKeyPrivate === 'string' ?
|
||||
Buffer.from(signatureKeyPrivate, 'base64') :
|
||||
signatureKeyPrivate;
|
||||
}
|
||||
this.senderKeyStateStructure.senderKeyId = id;
|
||||
this.senderChainKey = senderChainKeyStructure;
|
||||
this.senderKeyStateStructure.senderSigningKey = signingKeyStructure;
|
||||
}
|
||||
this.senderKeyStateStructure.senderMessageKeys =
|
||||
this.senderKeyStateStructure.senderMessageKeys || [];
|
||||
}
|
||||
|
||||
SenderKeyState(senderKeyStateStructure) {
|
||||
this.senderKeyStateStructure = senderKeyStateStructure;
|
||||
}
|
||||
|
||||
getKeyId() {
|
||||
return this.senderKeyStateStructure.senderKeyId;
|
||||
}
|
||||
|
||||
getSenderChainKey() {
|
||||
return new SenderChainKey(
|
||||
this.senderKeyStateStructure.senderChainKey.iteration,
|
||||
this.senderKeyStateStructure.senderChainKey.seed
|
||||
);
|
||||
}
|
||||
|
||||
setSenderChainKey(chainKey) {
|
||||
const senderChainKeyStructure = protobufs.SenderChainKey.create({
|
||||
iteration: chainKey.getIteration(),
|
||||
seed: chainKey.getSeed(),
|
||||
});
|
||||
this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure;
|
||||
}
|
||||
|
||||
getSigningKeyPublic() {
|
||||
return typeof this.senderKeyStateStructure.senderSigningKey.public === 'string' ?
|
||||
Buffer.from(this.senderKeyStateStructure.senderSigningKey.public, 'base64') :
|
||||
this.senderKeyStateStructure.senderSigningKey.public;
|
||||
}
|
||||
|
||||
getSigningKeyPrivate() {
|
||||
return typeof this.senderKeyStateStructure.senderSigningKey.private === 'string' ?
|
||||
Buffer.from(this.senderKeyStateStructure.senderSigningKey.private, 'base64') :
|
||||
this.senderKeyStateStructure.senderSigningKey.private;
|
||||
}
|
||||
|
||||
hasSenderMessageKey(iteration) {
|
||||
const list = this.senderKeyStateStructure.senderMessageKeys;
|
||||
for (let o = 0; o < list.length; o++) {
|
||||
const senderMessageKey = list[o];
|
||||
if (senderMessageKey.iteration === iteration) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
addSenderMessageKey(senderMessageKey) {
|
||||
const senderMessageKeyStructure = protobufs.SenderKeyStateStructure.create({
|
||||
iteration: senderMessageKey.getIteration(),
|
||||
seed: senderMessageKey.getSeed(),
|
||||
});
|
||||
this.senderKeyStateStructure.senderMessageKeys.push(senderMessageKeyStructure);
|
||||
|
||||
if (this.senderKeyStateStructure.senderMessageKeys.length > this.MAX_MESSAGE_KEYS) {
|
||||
this.senderKeyStateStructure.senderMessageKeys.shift();
|
||||
}
|
||||
}
|
||||
|
||||
removeSenderMessageKey(iteration) {
|
||||
let result = null;
|
||||
|
||||
this.senderKeyStateStructure.senderMessageKeys = this.senderKeyStateStructure.senderMessageKeys.filter(
|
||||
senderMessageKey => {
|
||||
if (senderMessageKey.iteration === iteration) result = senderMessageKey;
|
||||
return senderMessageKey.iteration !== iteration;
|
||||
}
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
return new SenderMessageKey(result.iteration, result.seed);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getStructure() {
|
||||
return this.senderKeyStateStructure;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SenderKeyState;
|
||||
39
WASignalGroup/sender_message_key.js
Normal file
39
WASignalGroup/sender_message_key.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const { deriveSecrets } = require('libsignal/src/crypto');
|
||||
class SenderMessageKey {
|
||||
iteration = 0;
|
||||
|
||||
iv = Buffer.alloc(0);
|
||||
|
||||
cipherKey = Buffer.alloc(0);
|
||||
|
||||
seed = Buffer.alloc(0);
|
||||
|
||||
constructor(iteration, seed) {
|
||||
const derivative = deriveSecrets(seed, Buffer.alloc(32), Buffer.from('WhisperGroup'));
|
||||
const keys = new Uint8Array(32);
|
||||
keys.set(new Uint8Array(derivative[0].slice(16)));
|
||||
keys.set(new Uint8Array(derivative[1].slice(0, 16)), 16);
|
||||
this.iv = Buffer.from(derivative[0].slice(0, 16));
|
||||
this.cipherKey = Buffer.from(keys.buffer);
|
||||
|
||||
this.iteration = iteration;
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
getIteration() {
|
||||
return this.iteration;
|
||||
}
|
||||
|
||||
getIv() {
|
||||
return this.iv;
|
||||
}
|
||||
|
||||
getCipherKey() {
|
||||
return this.cipherKey;
|
||||
}
|
||||
|
||||
getSeed() {
|
||||
return this.seed;
|
||||
}
|
||||
}
|
||||
module.exports = SenderMessageKey;
|
||||
@@ -33,10 +33,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/boom": "^9.1.3",
|
||||
"curve25519-js": "^0.0.4",
|
||||
"futoin-hkdf": "^1.3.2",
|
||||
"got": "^11.8.1",
|
||||
"jimp": "^0.16.1",
|
||||
"libsignal": "^2.0.1",
|
||||
"music-metadata": "^7.4.1",
|
||||
"pino": "^6.7.0",
|
||||
"protobufjs": "^6.10.1",
|
||||
@@ -48,7 +47,9 @@
|
||||
},
|
||||
"files": [
|
||||
"lib/*",
|
||||
"WAMessage/*"
|
||||
"WAProto/*",
|
||||
"WASignalGroup/*",
|
||||
"WABinary/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@adiwajshing/keyed-db": "^0.2.4",
|
||||
@@ -57,7 +58,6 @@
|
||||
"@types/node": "^14.6.2",
|
||||
"@types/pino": "^6.3.2",
|
||||
"@types/ws": "^7.2.6",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"jest": "^27.0.6",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"ts-jest": "^27.0.3",
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { proto } from '../../WAMessage'
|
||||
import { BinaryNode, DoubleByteTokens, SingleByteTokens, Tags } from './types'
|
||||
|
||||
function decode<T extends BinaryNode>(buffer: Buffer, makeNode: () => T, indexRef: { index: number }) {
|
||||
|
||||
const checkEOS = (length: number) => {
|
||||
if (indexRef.index + length > buffer.length) {
|
||||
throw new Error('end of stream')
|
||||
}
|
||||
}
|
||||
const next = () => {
|
||||
const value = buffer[indexRef.index]
|
||||
indexRef.index += 1
|
||||
return value
|
||||
}
|
||||
const readByte = () => {
|
||||
checkEOS(1)
|
||||
return next()
|
||||
}
|
||||
const readStringFromChars = (length: number) => {
|
||||
checkEOS(length)
|
||||
const value = buffer.slice(indexRef.index, indexRef.index + length)
|
||||
|
||||
indexRef.index += length
|
||||
return value.toString('utf-8')
|
||||
}
|
||||
const readBytes = (n: number) => {
|
||||
checkEOS(n)
|
||||
const value = buffer.slice(indexRef.index, indexRef.index + n)
|
||||
indexRef.index += n
|
||||
return value
|
||||
}
|
||||
const readInt = (n: number, littleEndian = false) => {
|
||||
checkEOS(n)
|
||||
let val = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const shift = littleEndian ? i : n - 1 - i
|
||||
val |= next() << (shift * 8)
|
||||
}
|
||||
return val
|
||||
}
|
||||
const readInt20 = () => {
|
||||
checkEOS(3)
|
||||
return ((next() & 15) << 16) + (next() << 8) + next()
|
||||
}
|
||||
const unpackHex = (value: number) => {
|
||||
if (value >= 0 && value < 16) {
|
||||
return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10
|
||||
}
|
||||
throw new Error('invalid hex: ' + value)
|
||||
}
|
||||
const unpackNibble = (value: number) => {
|
||||
if (value >= 0 && value <= 9) {
|
||||
return '0'.charCodeAt(0) + value
|
||||
}
|
||||
switch (value) {
|
||||
case 10:
|
||||
return '-'.charCodeAt(0)
|
||||
case 11:
|
||||
return '.'.charCodeAt(0)
|
||||
case 15:
|
||||
return '\0'.charCodeAt(0)
|
||||
default:
|
||||
throw new Error('invalid nibble: ' + value)
|
||||
}
|
||||
}
|
||||
const unpackByte = (tag: number, value: number) => {
|
||||
if (tag === Tags.NIBBLE_8) {
|
||||
return unpackNibble(value)
|
||||
} else if (tag === Tags.HEX_8) {
|
||||
return unpackHex(value)
|
||||
} else {
|
||||
throw new Error('unknown tag: ' + tag)
|
||||
}
|
||||
}
|
||||
const readPacked8 = (tag: number) => {
|
||||
const startByte = readByte()
|
||||
let value = ''
|
||||
|
||||
for (let i = 0; i < (startByte & 127); i++) {
|
||||
const curByte = readByte()
|
||||
value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4))
|
||||
value += String.fromCharCode(unpackByte(tag, curByte & 0x0f))
|
||||
}
|
||||
if (startByte >> 7 !== 0) {
|
||||
value = value.slice(0, -1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const isListTag = (tag: number) => {
|
||||
return tag === Tags.LIST_EMPTY || tag === Tags.LIST_8 || tag === Tags.LIST_16
|
||||
}
|
||||
const readListSize = (tag: number) => {
|
||||
switch (tag) {
|
||||
case Tags.LIST_EMPTY:
|
||||
return 0
|
||||
case Tags.LIST_8:
|
||||
return readByte()
|
||||
case Tags.LIST_16:
|
||||
return readInt(2)
|
||||
default:
|
||||
throw new Error('invalid tag for list size: ' + tag)
|
||||
}
|
||||
}
|
||||
const getToken = (index: number) => {
|
||||
if (index < 3 || index >= SingleByteTokens.length) {
|
||||
throw new Error('invalid token index: ' + index)
|
||||
}
|
||||
return SingleByteTokens[index]
|
||||
}
|
||||
const readString = (tag: number) => {
|
||||
if (tag >= 3 && tag <= 235) {
|
||||
const token = getToken(tag)
|
||||
return token// === 's.whatsapp.net' ? 'c.us' : token
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case Tags.DICTIONARY_0:
|
||||
case Tags.DICTIONARY_1:
|
||||
case Tags.DICTIONARY_2:
|
||||
case Tags.DICTIONARY_3:
|
||||
return getTokenDouble(tag - Tags.DICTIONARY_0, readByte())
|
||||
case Tags.LIST_EMPTY:
|
||||
return null
|
||||
case Tags.BINARY_8:
|
||||
return readStringFromChars(readByte())
|
||||
case Tags.BINARY_20:
|
||||
return readStringFromChars(readInt20())
|
||||
case Tags.BINARY_32:
|
||||
return readStringFromChars(readInt(4))
|
||||
case Tags.JID_PAIR:
|
||||
const i = readString(readByte())
|
||||
const j = readString(readByte())
|
||||
if (typeof i === 'string' && j) {
|
||||
return i + '@' + j
|
||||
}
|
||||
throw new Error('invalid jid pair: ' + i + ', ' + j)
|
||||
case Tags.HEX_8:
|
||||
case Tags.NIBBLE_8:
|
||||
return readPacked8(tag)
|
||||
default:
|
||||
throw new Error('invalid string with tag: ' + tag)
|
||||
}
|
||||
}
|
||||
const readList = (tag: number) => (
|
||||
[...new Array(readListSize(tag))].map(() => decode(buffer, makeNode, indexRef))
|
||||
)
|
||||
const getTokenDouble = (index1: number, index2: number) => {
|
||||
const n = 256 * index1 + index2
|
||||
if (n < 0 || n > DoubleByteTokens.length) {
|
||||
throw new Error('invalid double token index: ' + n)
|
||||
}
|
||||
return DoubleByteTokens[n]
|
||||
}
|
||||
const node = makeNode()
|
||||
const listSize = readListSize(readByte())
|
||||
const descrTag = readByte()
|
||||
if (descrTag === Tags.STREAM_END) {
|
||||
throw new Error('unexpected stream end')
|
||||
}
|
||||
node.header = readString(descrTag)
|
||||
if (listSize === 0 || !node.header) {
|
||||
throw new Error('invalid node')
|
||||
}
|
||||
// read the attributes in
|
||||
const attributesLength = (listSize - 1) >> 1
|
||||
for (let i = 0; i < attributesLength; i++) {
|
||||
const key = readString(readByte())
|
||||
const b = readByte()
|
||||
|
||||
node.attributes[key] = readString(b)
|
||||
}
|
||||
|
||||
if (listSize % 2 === 0) {
|
||||
const tag = readByte()
|
||||
if (isListTag(tag)) {
|
||||
node.data = readList(tag)
|
||||
} else {
|
||||
let decoded: Buffer | string
|
||||
switch (tag) {
|
||||
case Tags.BINARY_8:
|
||||
decoded = readBytes(readByte())
|
||||
break
|
||||
case Tags.BINARY_20:
|
||||
decoded = readBytes(readInt20())
|
||||
break
|
||||
case Tags.BINARY_32:
|
||||
decoded = readBytes(readInt(4))
|
||||
break
|
||||
default:
|
||||
decoded = readString(tag)
|
||||
break
|
||||
}
|
||||
|
||||
if (node.header === 'message' && Buffer.isBuffer(decoded)) {
|
||||
node.data = proto.WebMessageInfo.decode(decoded)
|
||||
} else {
|
||||
node.data = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
export default decode
|
||||
@@ -1,123 +0,0 @@
|
||||
import { proto } from "../../WAMessage";
|
||||
import { BinaryNode, SingleByteTokens, Tags } from "./types";
|
||||
|
||||
const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = []) => {
|
||||
|
||||
const pushByte = (value: number) => buffer.push(value & 0xff)
|
||||
|
||||
const pushInt = (value: number, n: number, littleEndian=false) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const curShift = littleEndian ? i : n - 1 - i
|
||||
buffer.push((value >> (curShift * 8)) & 0xff)
|
||||
}
|
||||
}
|
||||
const pushBytes = (bytes: Uint8Array | Buffer | number[]) => (
|
||||
bytes.forEach (b => buffer.push(b))
|
||||
)
|
||||
const pushInt20 = (value: number) => (
|
||||
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
|
||||
)
|
||||
const writeByteLength = (length: number) => {
|
||||
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
|
||||
|
||||
if (length >= 1 << 20) {
|
||||
pushByte(Tags.BINARY_32)
|
||||
pushInt(length, 4) // 32 bit integer
|
||||
} else if (length >= 256) {
|
||||
pushByte(Tags.BINARY_20)
|
||||
pushInt20(length)
|
||||
} else {
|
||||
pushByte(Tags.BINARY_8)
|
||||
pushByte(length)
|
||||
}
|
||||
}
|
||||
const writeStringRaw = (str: string) => {
|
||||
const bytes = Buffer.from (str, 'utf-8')
|
||||
writeByteLength(bytes.length)
|
||||
pushBytes(bytes)
|
||||
}
|
||||
const writeToken = (token: number) => {
|
||||
if (token < 245) {
|
||||
pushByte(token)
|
||||
} else if (token <= 500) {
|
||||
throw new Error('invalid token')
|
||||
}
|
||||
}
|
||||
const writeString = (token: string, i?: boolean) => {
|
||||
if (token === 'c.us') token = 's.whatsapp.net'
|
||||
|
||||
const tokenIndex = SingleByteTokens.indexOf(token)
|
||||
if (!i && token === 's.whatsapp.net') {
|
||||
writeToken(tokenIndex)
|
||||
} else if (tokenIndex >= 0) {
|
||||
if (tokenIndex < Tags.SINGLE_BYTE_MAX) {
|
||||
writeToken(tokenIndex)
|
||||
} else {
|
||||
const overflow = tokenIndex - Tags.SINGLE_BYTE_MAX
|
||||
const dictionaryIndex = overflow >> 8
|
||||
if (dictionaryIndex < 0 || dictionaryIndex > 3) {
|
||||
throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex)
|
||||
}
|
||||
writeToken(Tags.DICTIONARY_0 + dictionaryIndex)
|
||||
writeToken(overflow % 256)
|
||||
}
|
||||
} else if (token) {
|
||||
const jidSepIndex = token.indexOf('@')
|
||||
if (jidSepIndex <= 0) {
|
||||
writeStringRaw(token)
|
||||
} else {
|
||||
writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
const writeJid = (left: string, right: string) => {
|
||||
pushByte(Tags.JID_PAIR)
|
||||
left && left.length > 0 ? writeString(left) : writeToken(Tags.LIST_EMPTY)
|
||||
writeString(right)
|
||||
}
|
||||
const writeListStart = (listSize: number) => {
|
||||
if (listSize === 0) {
|
||||
pushByte(Tags.LIST_EMPTY)
|
||||
} else if (listSize < 256) {
|
||||
pushBytes([Tags.LIST_8, listSize])
|
||||
} else {
|
||||
pushBytes([Tags.LIST_16, listSize])
|
||||
}
|
||||
}
|
||||
const validAttributes = Object.keys(attributes).filter(k => (
|
||||
typeof attributes[k] !== 'undefined' && attributes[k] !== null
|
||||
))
|
||||
|
||||
writeListStart(2*validAttributes.length + 1 + (typeof data !== 'undefined' && data !== null ? 1 : 0))
|
||||
writeString(header)
|
||||
|
||||
validAttributes.forEach((key) => {
|
||||
if(typeof attributes[key] === 'string') {
|
||||
writeString(key)
|
||||
writeString(attributes[key])
|
||||
}
|
||||
})
|
||||
|
||||
if(data instanceof proto.WebMessageInfo && !Buffer.isBuffer(data)) {
|
||||
data = Buffer.from(proto.WebMessageInfo.encode(data).finish())
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
writeString(data, true)
|
||||
} else if (Buffer.isBuffer(data)) {
|
||||
writeByteLength(data.length)
|
||||
pushBytes(data)
|
||||
} else if (Array.isArray(data)) {
|
||||
writeListStart(data.length)
|
||||
for(const item of data) {
|
||||
if(item) encode(item, buffer)
|
||||
}
|
||||
} else if(typeof data === 'undefined' || data === null) {
|
||||
|
||||
} else {
|
||||
throw new Error(`invalid children for header "${header}": ${data} (${typeof data})`)
|
||||
}
|
||||
|
||||
return Buffer.from(buffer)
|
||||
}
|
||||
export default encode
|
||||
@@ -1,8 +0,0 @@
|
||||
import decode from './decode'
|
||||
import encode from './encode'
|
||||
import { BinaryNode as BinaryNodeType } from './types'
|
||||
|
||||
export default class BinaryNode extends BinaryNodeType {
|
||||
toBuffer = () => encode(this, [])
|
||||
static from = (buffer: Buffer) => decode(buffer, () => new BinaryNode(), { index: 0 })
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { proto } from "../../WAMessage"
|
||||
|
||||
export type Attributes = { [key: string]: string }
|
||||
export type BinaryNodeData = BinaryNode[] | string | Buffer | proto.IWebMessageInfo | undefined
|
||||
export class BinaryNode {
|
||||
header: string
|
||||
attributes: Attributes = {}
|
||||
data?: BinaryNodeData
|
||||
|
||||
constructor(header?: string, attrs?: Attributes, data?: BinaryNodeData) {
|
||||
this.header = header
|
||||
this.attributes = attrs || {}
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
export const Tags = {
|
||||
LIST_EMPTY: 0,
|
||||
STREAM_END: 2,
|
||||
DICTIONARY_0: 236,
|
||||
DICTIONARY_1: 237,
|
||||
DICTIONARY_2: 238,
|
||||
DICTIONARY_3: 239,
|
||||
LIST_8: 248,
|
||||
LIST_16: 249,
|
||||
JID_PAIR: 250,
|
||||
HEX_8: 251,
|
||||
BINARY_8: 252,
|
||||
BINARY_20: 253,
|
||||
BINARY_32: 254,
|
||||
NIBBLE_8: 255,
|
||||
SINGLE_BYTE_MAX: 256,
|
||||
PACKED_MAX: 254,
|
||||
}
|
||||
export const DoubleByteTokens = []
|
||||
export const SingleByteTokens = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'200',
|
||||
'400',
|
||||
'404',
|
||||
'500',
|
||||
'501',
|
||||
'502',
|
||||
'action',
|
||||
'add',
|
||||
'after',
|
||||
'archive',
|
||||
'author',
|
||||
'available',
|
||||
'battery',
|
||||
'before',
|
||||
'body',
|
||||
'broadcast',
|
||||
'chat',
|
||||
'clear',
|
||||
'code',
|
||||
'composing',
|
||||
'contacts',
|
||||
'count',
|
||||
'create',
|
||||
'debug',
|
||||
'delete',
|
||||
'demote',
|
||||
'duplicate',
|
||||
'encoding',
|
||||
'error',
|
||||
'false',
|
||||
'filehash',
|
||||
'from',
|
||||
'g.us',
|
||||
'group',
|
||||
'groups_v2',
|
||||
'height',
|
||||
'id',
|
||||
'image',
|
||||
'in',
|
||||
'index',
|
||||
'invis',
|
||||
'item',
|
||||
'jid',
|
||||
'kind',
|
||||
'last',
|
||||
'leave',
|
||||
'live',
|
||||
'log',
|
||||
'media',
|
||||
'message',
|
||||
'mimetype',
|
||||
'missing',
|
||||
'modify',
|
||||
'name',
|
||||
'notification',
|
||||
'notify',
|
||||
'out',
|
||||
'owner',
|
||||
'participant',
|
||||
'paused',
|
||||
'picture',
|
||||
'played',
|
||||
'presence',
|
||||
'preview',
|
||||
'promote',
|
||||
'query',
|
||||
'raw',
|
||||
'read',
|
||||
'receipt',
|
||||
'received',
|
||||
'recipient',
|
||||
'recording',
|
||||
'relay',
|
||||
'remove',
|
||||
'response',
|
||||
'resume',
|
||||
'retry',
|
||||
's.whatsapp.net',
|
||||
'seconds',
|
||||
'set',
|
||||
'size',
|
||||
'status',
|
||||
'subject',
|
||||
'subscribe',
|
||||
't',
|
||||
'text',
|
||||
'to',
|
||||
'true',
|
||||
'type',
|
||||
'unarchive',
|
||||
'unavailable',
|
||||
'url',
|
||||
'user',
|
||||
'value',
|
||||
'web',
|
||||
'width',
|
||||
'mute',
|
||||
'read_only',
|
||||
'admin',
|
||||
'creator',
|
||||
'short',
|
||||
'update',
|
||||
'powersave',
|
||||
'checksum',
|
||||
'epoch',
|
||||
'block',
|
||||
'previous',
|
||||
'409',
|
||||
'replaced',
|
||||
'reason',
|
||||
'spam',
|
||||
'modify_tag',
|
||||
'message_info',
|
||||
'delivery',
|
||||
'emoji',
|
||||
'title',
|
||||
'description',
|
||||
'canonical-url',
|
||||
'matched-text',
|
||||
'star',
|
||||
'unstar',
|
||||
'media_key',
|
||||
'filename',
|
||||
'identity',
|
||||
'unread',
|
||||
'page',
|
||||
'page_count',
|
||||
'search',
|
||||
'media_message',
|
||||
'security',
|
||||
'call_log',
|
||||
'profile',
|
||||
'ciphertext',
|
||||
'invite',
|
||||
'gif',
|
||||
'vcard',
|
||||
'frequent',
|
||||
'privacy',
|
||||
'blacklist',
|
||||
'whitelist',
|
||||
'verify',
|
||||
'location',
|
||||
'document',
|
||||
'elapsed',
|
||||
'revoke_invite',
|
||||
'expiration',
|
||||
'unsubscribe',
|
||||
'disable',
|
||||
'vname',
|
||||
'old_jid',
|
||||
'new_jid',
|
||||
'announcement',
|
||||
'locked',
|
||||
'prop',
|
||||
'label',
|
||||
'color',
|
||||
'call',
|
||||
'offer',
|
||||
'call-id',
|
||||
'quick_reply',
|
||||
'sticker',
|
||||
'pay_t',
|
||||
'accept',
|
||||
'reject',
|
||||
'sticker_pack',
|
||||
'invalid',
|
||||
'canceled',
|
||||
'missed',
|
||||
'connected',
|
||||
'result',
|
||||
'audio',
|
||||
'video',
|
||||
'recent',
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import { decodeWAMessage } from './Utils/decode-wa-message'
|
||||
|
||||
interface BrowserMessagesInfo {
|
||||
bundle: { encKey: string, macKey: string }
|
||||
harFilePath: string
|
||||
}
|
||||
interface WSMessage {
|
||||
type: 'send' | 'receive',
|
||||
data: string
|
||||
}
|
||||
const file = fs.readFileSync ('./browser-messages.json', {encoding: 'utf-8'})
|
||||
const json: BrowserMessagesInfo = JSON.parse (file)
|
||||
|
||||
const encKey = Buffer.from (json.bundle.encKey, 'base64')
|
||||
const macKey = Buffer.from (json.bundle.macKey, 'base64')
|
||||
|
||||
const harFile = JSON.parse ( fs.readFileSync( json.harFilePath , {encoding: 'utf-8'}))
|
||||
const entries = harFile['log']['entries']
|
||||
let wsMessages: WSMessage[] = []
|
||||
entries.forEach ((e, i) => {
|
||||
if ('_webSocketMessages' in e) {
|
||||
wsMessages.push (...e['_webSocketMessages'])
|
||||
}
|
||||
})
|
||||
const decrypt = (buffer, fromMe) => decodeWAMessage(buffer, { macKey, encKey }, fromMe)
|
||||
|
||||
console.log ('parsing ' + wsMessages.length + ' messages')
|
||||
const list = wsMessages.map ((item, i) => {
|
||||
const buffer = item.data.includes(',') ? item.data : Buffer.from (item.data, 'base64')
|
||||
try {
|
||||
const [tag, json, binaryTags] = decrypt (buffer, item.type === 'send')
|
||||
|
||||
return {tag, json: json && JSON.stringify(json), binaryTags}
|
||||
} catch (error) {
|
||||
return { error: error.message, data: buffer.toString('utf-8') }
|
||||
}
|
||||
})
|
||||
.filter (Boolean)
|
||||
const str = JSON.stringify (list, null, '\t')
|
||||
fs.writeFileSync ('decoded-ws.json', str)
|
||||
@@ -1,275 +0,0 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import EventEmitter from "events"
|
||||
import * as Curve from 'curve25519-js'
|
||||
import { BaileysEventEmitter, BaileysEventMap, SocketConfig, CurveKeyPair, WAInitResponse, ConnectionState, DisconnectReason } from "../Types"
|
||||
import { makeSocket } from "./socket"
|
||||
import { generateClientID, promiseTimeout, normalizedAuthInfo, computeChallengeResponse, validateNewConnection } from "../Utils"
|
||||
import { randomBytes } from "crypto"
|
||||
import { AuthenticationCredentials } from "../Types"
|
||||
|
||||
const makeAuthSocket = (config: SocketConfig) => {
|
||||
const {
|
||||
logger,
|
||||
version,
|
||||
browser,
|
||||
connectTimeoutMs,
|
||||
pendingRequestTimeoutMs,
|
||||
maxQRCodes,
|
||||
printQRInTerminal,
|
||||
credentials: anyAuthInfo
|
||||
} = config
|
||||
const ev = new EventEmitter() as BaileysEventEmitter
|
||||
|
||||
let authInfo = normalizedAuthInfo(anyAuthInfo) ||
|
||||
// generate client id if not there
|
||||
{ clientID: generateClientID() } as AuthenticationCredentials
|
||||
|
||||
const state: ConnectionState = {
|
||||
phoneConnected: false,
|
||||
connection: 'connecting',
|
||||
}
|
||||
|
||||
const socket = makeSocket({
|
||||
...config,
|
||||
phoneConnectionChanged: phoneConnected => {
|
||||
if(phoneConnected !== state.phoneConnected) {
|
||||
updateState({ phoneConnected })
|
||||
}
|
||||
}
|
||||
})
|
||||
const { ws } = socket
|
||||
let curveKeys: CurveKeyPair
|
||||
let initTimeout: NodeJS.Timeout
|
||||
// add close listener
|
||||
ws.on('ws-close', (error: Boom | Error) => {
|
||||
logger.info({ error }, 'Closed connection to WhatsApp')
|
||||
initTimeout && clearTimeout(initTimeout)
|
||||
// if no reconnects occur
|
||||
// send close event
|
||||
updateState({
|
||||
connection: 'close',
|
||||
qr: undefined,
|
||||
connectionTriesLeft: undefined,
|
||||
lastDisconnect: {
|
||||
error,
|
||||
date: new Date()
|
||||
}
|
||||
})
|
||||
})
|
||||
/** Can you login to WA without scanning the QR */
|
||||
const canLogin = () => !!authInfo?.encKey && !!authInfo?.macKey
|
||||
|
||||
const updateState = (update: Partial<ConnectionState>) => {
|
||||
Object.assign(state, update)
|
||||
ev.emit('connection.update', update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs you out from WA
|
||||
* If connected, invalidates the credentials with the server
|
||||
*/
|
||||
const logout = async() => {
|
||||
if(state.connection === 'open') {
|
||||
await socket.sendMessage({
|
||||
json: ['admin', 'Conn', 'disconnect'],
|
||||
tag: 'goodbye'
|
||||
})
|
||||
}
|
||||
// will call state update to close connection
|
||||
socket?.end(
|
||||
new Boom('Logged Out', { statusCode: DisconnectReason.credentialsInvalidated })
|
||||
)
|
||||
authInfo = undefined
|
||||
}
|
||||
/** Waits for the connection to WA to open up */
|
||||
const waitForConnection = async(waitInfinitely: boolean = false) => {
|
||||
if(state.connection === 'open') return
|
||||
|
||||
let listener: (item: BaileysEventMap['connection.update']) => void
|
||||
const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs
|
||||
if(timeout < 0) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
|
||||
await (
|
||||
promiseTimeout(
|
||||
timeout,
|
||||
(resolve, reject) => {
|
||||
listener = ({ connection, lastDisconnect }) => {
|
||||
if(connection === 'open') resolve()
|
||||
else if(connection == 'close') {
|
||||
reject(lastDisconnect.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
|
||||
}
|
||||
}
|
||||
ev.on('connection.update', listener)
|
||||
}
|
||||
)
|
||||
.finally(() => (
|
||||
ev.off('connection.update', listener)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
const updateEncKeys = () => {
|
||||
// update the keys so we can decrypt traffic
|
||||
socket.updateKeys({ encKey: authInfo!.encKey, macKey: authInfo!.macKey })
|
||||
}
|
||||
|
||||
const generateKeysForAuth = async(ref: string, ttl?: number) => {
|
||||
curveKeys = Curve.generateKeyPair(randomBytes(32))
|
||||
const publicKey = Buffer.from(curveKeys.public).toString('base64')
|
||||
let qrGens = 0
|
||||
|
||||
const qrLoop = ttl => {
|
||||
const qr = [ref, publicKey, authInfo.clientID].join(',')
|
||||
updateState({ qr })
|
||||
|
||||
initTimeout = setTimeout(async () => {
|
||||
if(state.connection !== 'connecting') return
|
||||
|
||||
logger.debug('regenerating QR')
|
||||
try {
|
||||
if(qrGens >= maxQRCodes) {
|
||||
throw new Boom(
|
||||
'Too many QR codes',
|
||||
{ statusCode: 429 }
|
||||
)
|
||||
}
|
||||
// request new QR
|
||||
const {ref: newRef, ttl: newTTL} = await socket.query({
|
||||
json: ['admin', 'Conn', 'reref'],
|
||||
expect200: true,
|
||||
longTag: true,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
ttl = newTTL
|
||||
ref = newRef
|
||||
} catch (error) {
|
||||
logger.error({ error }, `error in QR gen`)
|
||||
if (error.output?.statusCode === 429) { // too many QR requests
|
||||
socket.end(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
qrGens += 1
|
||||
qrLoop(ttl)
|
||||
}, ttl || 20_000) // default is 20s, on the off-chance ttl is not present
|
||||
}
|
||||
qrLoop(ttl)
|
||||
}
|
||||
const onOpen = async() => {
|
||||
const canDoLogin = canLogin()
|
||||
const initQuery = (async () => {
|
||||
const {ref, ttl} = await socket.query({
|
||||
json: ['admin', 'init', version, browser, authInfo.clientID, true],
|
||||
expect200: true,
|
||||
longTag: true,
|
||||
requiresPhoneConnection: false
|
||||
}) as WAInitResponse
|
||||
|
||||
if (!canDoLogin) {
|
||||
generateKeysForAuth(ref, ttl)
|
||||
}
|
||||
})();
|
||||
let loginTag: string
|
||||
if(canDoLogin) {
|
||||
updateEncKeys()
|
||||
// if we have the info to restore a closed session
|
||||
const json = [
|
||||
'admin',
|
||||
'login',
|
||||
authInfo.clientToken,
|
||||
authInfo.serverToken,
|
||||
authInfo.clientID,
|
||||
'takeover'
|
||||
]
|
||||
loginTag = socket.generateMessageTag(true)
|
||||
// send login every 10s
|
||||
const sendLoginReq = () => {
|
||||
if(state.connection === 'open') {
|
||||
logger.warn('Received login timeout req when state=open, ignoring...')
|
||||
return
|
||||
}
|
||||
logger.info('sending login request')
|
||||
socket.sendMessage({
|
||||
json,
|
||||
tag: loginTag
|
||||
})
|
||||
initTimeout = setTimeout(sendLoginReq, 10_000)
|
||||
}
|
||||
sendLoginReq()
|
||||
}
|
||||
await initQuery
|
||||
|
||||
// wait for response with tag "s1"
|
||||
let response = await Promise.race(
|
||||
[
|
||||
socket.waitForMessage('s1', false, undefined),
|
||||
...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs)] : [])
|
||||
|
||||
]
|
||||
)
|
||||
initTimeout && clearTimeout(initTimeout)
|
||||
initTimeout = undefined
|
||||
|
||||
if(response.status && response.status !== 200) {
|
||||
throw new Boom(`Unexpected error in login`, { data: response, statusCode: response.status })
|
||||
}
|
||||
// if its a challenge request (we get it when logging in)
|
||||
if(response[1]?.challenge) {
|
||||
const json = computeChallengeResponse(response[1].challenge, authInfo)
|
||||
logger.info('resolving login challenge')
|
||||
|
||||
await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs })
|
||||
|
||||
response = await socket.waitForMessage('s2', true)
|
||||
}
|
||||
// validate the new connection
|
||||
const {user, auth, phone} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
|
||||
const isNewLogin = user.jid !== state.user?.jid
|
||||
|
||||
authInfo = auth
|
||||
updateEncKeys()
|
||||
|
||||
logger.info({ user }, 'logged in')
|
||||
|
||||
updateState({
|
||||
connection: 'open',
|
||||
phoneConnected: true,
|
||||
user,
|
||||
isNewLogin,
|
||||
phoneInfo: phone,
|
||||
connectionTriesLeft: undefined,
|
||||
qr: undefined
|
||||
})
|
||||
ev.emit('credentials.update', auth)
|
||||
}
|
||||
ws.once('open', async() => {
|
||||
try {
|
||||
await onOpen()
|
||||
} catch(error) {
|
||||
socket.end(error)
|
||||
}
|
||||
})
|
||||
|
||||
if(printQRInTerminal) {
|
||||
ev.on('connection.update', async({ qr }) => {
|
||||
if(qr) {
|
||||
const QR = await import('qrcode-terminal').catch(err => {
|
||||
logger.error('QR code terminal not added as dependency')
|
||||
})
|
||||
QR?.generate(qr, { small: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
...socket,
|
||||
ev,
|
||||
getState: () => state,
|
||||
getAuthInfo: () => authInfo,
|
||||
waitForConnection,
|
||||
canLogin,
|
||||
logout
|
||||
}
|
||||
}
|
||||
export default makeAuthSocket
|
||||
@@ -1,478 +0,0 @@
|
||||
import BinaryNode from "../BinaryNode";
|
||||
import { Chat, Contact, Presence, PresenceData, SocketConfig, WAFlag, WAMetric, WABusinessProfile, ChatModification, WAMessageKey, WAMessage, WAMessageUpdate, BaileysEventMap } from "../Types";
|
||||
import { debouncedTimeout, unixTimestampSeconds, whatsappID } from "../Utils/generics";
|
||||
import makeAuthSocket from "./auth";
|
||||
import { Attributes, BinaryNode as BinaryNodeBase } from "../BinaryNode/types";
|
||||
|
||||
const makeChatsSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeAuthSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws: socketEvents,
|
||||
currentEpoch,
|
||||
setQuery,
|
||||
query,
|
||||
sendMessage,
|
||||
getState
|
||||
} = sock
|
||||
|
||||
const chatsDebounceTimeout = debouncedTimeout(10_000, () => sendChatsQuery(1))
|
||||
|
||||
const sendChatsQuery = (epoch: number) => (
|
||||
sendMessage({
|
||||
json: new BinaryNode('query', {type: 'chat', epoch: epoch.toString()}),
|
||||
binaryTag: [ WAMetric.queryChat, WAFlag.ignore ]
|
||||
})
|
||||
)
|
||||
|
||||
const fetchImageUrl = async(jid: string) => {
|
||||
const response = await query({
|
||||
json: ['query', 'ProfilePicThumb', jid],
|
||||
expect200: false,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
return response.eurl as string | undefined
|
||||
}
|
||||
|
||||
const executeChatModification = (node: BinaryNodeBase) => {
|
||||
const { attributes } = node
|
||||
const updateType = attributes.type
|
||||
const jid = whatsappID(attributes?.jid)
|
||||
|
||||
switch(updateType) {
|
||||
case 'delete':
|
||||
ev.emit('chats.delete', [jid])
|
||||
break
|
||||
case 'clear':
|
||||
if(node.data) {
|
||||
const ids = (node.data as BinaryNode[]).map(
|
||||
({ attributes }) => attributes.index
|
||||
)
|
||||
ev.emit('messages.delete', { jid, ids })
|
||||
} else {
|
||||
ev.emit('messages.delete', { jid, all: true })
|
||||
}
|
||||
break
|
||||
case 'archive':
|
||||
ev.emit('chats.update', [ { jid, archive: 'true' } ])
|
||||
break
|
||||
case 'unarchive':
|
||||
ev.emit('chats.update', [ { jid, archive: 'false' } ])
|
||||
break
|
||||
case 'pin':
|
||||
ev.emit('chats.update', [ { jid, pin: attributes.pin } ])
|
||||
break
|
||||
case 'star':
|
||||
case 'unstar':
|
||||
const starred = updateType === 'star'
|
||||
const updates: WAMessageUpdate[] = (node.data as BinaryNode[]).map(
|
||||
({ attributes }) => ({
|
||||
key: {
|
||||
remoteJid: jid,
|
||||
id: attributes.index,
|
||||
fromMe: attributes.owner === 'true'
|
||||
},
|
||||
update: { starred }
|
||||
})
|
||||
)
|
||||
ev.emit('messages.update', updates)
|
||||
break
|
||||
case 'mute':
|
||||
if(attributes.mute === '0') {
|
||||
ev.emit('chats.update', [{ jid, mute: null }])
|
||||
} else {
|
||||
ev.emit('chats.update', [{ jid, mute: attributes.mute }])
|
||||
}
|
||||
break
|
||||
default:
|
||||
logger.warn({ node }, `received unrecognized chat update`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const applyingPresenceUpdate = (update: Attributes): BaileysEventMap['presence.update'] => {
|
||||
const jid = whatsappID(update.id)
|
||||
const participant = whatsappID(update.participant || update.id)
|
||||
|
||||
const presence: PresenceData = {
|
||||
lastSeen: update.t ? +update.t : undefined,
|
||||
lastKnownPresence: update.type as Presence
|
||||
}
|
||||
return { jid, presences: { [participant]: presence } }
|
||||
}
|
||||
|
||||
ev.on('connection.update', async({ connection }) => {
|
||||
if(connection !== 'open') return
|
||||
try {
|
||||
await Promise.all([
|
||||
sendMessage({
|
||||
json: new BinaryNode('query', {type: 'contacts', epoch: '1'}),
|
||||
binaryTag: [ WAMetric.queryContact, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: new BinaryNode('query', {type: 'status', epoch: '1'}),
|
||||
binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: new BinaryNode('query', {type: 'quick_reply', epoch: '1'}),
|
||||
binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: new BinaryNode('query', {type: 'label', epoch: '1'}),
|
||||
binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: new BinaryNode('query', {type: 'emoji', epoch: '1'}),
|
||||
binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: new BinaryNode(
|
||||
'action',
|
||||
{ type: 'set', epoch: '1' },
|
||||
[
|
||||
new BinaryNode('presence', {type: 'available'})
|
||||
]
|
||||
),
|
||||
binaryTag: [ WAMetric.presence, WAFlag.available ]
|
||||
})
|
||||
])
|
||||
chatsDebounceTimeout.start()
|
||||
|
||||
logger.debug('sent init queries')
|
||||
} catch(error) {
|
||||
logger.error(`error in sending init queries: ${error}`)
|
||||
}
|
||||
})
|
||||
socketEvents.on('CB:response,type:chat', async ({ data }: BinaryNode) => {
|
||||
chatsDebounceTimeout.cancel()
|
||||
if(Array.isArray(data)) {
|
||||
const chats = data.map(({ attributes }) => {
|
||||
return {
|
||||
...attributes,
|
||||
jid: whatsappID(attributes.jid),
|
||||
t: +attributes.t,
|
||||
count: +attributes.count
|
||||
} as Chat
|
||||
})
|
||||
|
||||
logger.info(`got ${chats.length} chats`)
|
||||
ev.emit('chats.set', { chats })
|
||||
}
|
||||
})
|
||||
// got all contacts from phone
|
||||
socketEvents.on('CB:response,type:contacts', async ({ data }: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
const contacts = data.map(({ attributes }) => {
|
||||
const contact = attributes as any as Contact
|
||||
contact.jid = whatsappID(contact.jid)
|
||||
return contact
|
||||
})
|
||||
|
||||
logger.info(`got ${contacts.length} contacts`)
|
||||
ev.emit('contacts.set', { contacts })
|
||||
}
|
||||
})
|
||||
// status updates
|
||||
socketEvents.on('CB:Status,status', json => {
|
||||
const jid = whatsappID(json[1].id)
|
||||
ev.emit('contacts.update', [ { jid, status: json[1].status } ])
|
||||
})
|
||||
// User Profile Name Updates
|
||||
socketEvents.on('CB:Conn,pushname', json => {
|
||||
const { user, connection } = getState()
|
||||
if(connection === 'open' && json[1].pushname !== user.name) {
|
||||
user.name = json[1].pushname
|
||||
ev.emit('connection.update', { user })
|
||||
}
|
||||
})
|
||||
// read updates
|
||||
socketEvents.on ('CB:action,,read', async ({ data }: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
const { attributes } = data[0]
|
||||
|
||||
const update: Partial<Chat> = {
|
||||
jid: whatsappID(attributes.jid)
|
||||
}
|
||||
if (attributes.type === 'false') update.count = -1
|
||||
else update.count = 0
|
||||
|
||||
ev.emit('chats.update', [update])
|
||||
}
|
||||
})
|
||||
|
||||
socketEvents.on('CB:Cmd,type:picture', async json => {
|
||||
json = json[1]
|
||||
const jid = whatsappID(json.jid)
|
||||
const imgUrl = await fetchImageUrl(jid).catch(() => '')
|
||||
|
||||
ev.emit('contacts.update', [ { jid, imgUrl } ])
|
||||
})
|
||||
|
||||
// chat archive, pin etc.
|
||||
socketEvents.on('CB:action,,chat', ({ data }: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
const [node] = data
|
||||
executeChatModification(node)
|
||||
}
|
||||
})
|
||||
|
||||
socketEvents.on('CB:action,,user', (json: BinaryNode) => {
|
||||
if(Array.isArray(json.data)) {
|
||||
const user = json.data[0].attributes as any as Contact
|
||||
user.jid = whatsappID(user.jid)
|
||||
|
||||
ev.emit('contacts.upsert', [user])
|
||||
}
|
||||
})
|
||||
|
||||
// presence updates
|
||||
socketEvents.on('CB:Presence', json => {
|
||||
const update = applyingPresenceUpdate(json[1])
|
||||
ev.emit('presence.update', update)
|
||||
})
|
||||
|
||||
// blocklist updates
|
||||
socketEvents.on('CB:Blocklist', json => {
|
||||
json = json[1]
|
||||
const blocklist = json.blocklist
|
||||
ev.emit('blocklist.set', { blocklist })
|
||||
})
|
||||
|
||||
return {
|
||||
...sock,
|
||||
sendChatsQuery,
|
||||
fetchImageUrl,
|
||||
chatRead: async(fromMessage: WAMessageKey, count: number) => {
|
||||
await setQuery (
|
||||
[
|
||||
new BinaryNode(
|
||||
'read',
|
||||
{
|
||||
jid: fromMessage.remoteJid,
|
||||
count: count.toString(),
|
||||
index: fromMessage.id,
|
||||
owner: fromMessage.fromMe ? 'true' : 'false'
|
||||
}
|
||||
)
|
||||
],
|
||||
[ WAMetric.read, WAFlag.ignore ]
|
||||
)
|
||||
ev.emit ('chats.update', [{ jid: fromMessage.remoteJid, count: count < 0 ? -1 : 0 }])
|
||||
},
|
||||
/**
|
||||
* Modify a given chat (archive, pin etc.)
|
||||
* @param jid the ID of the person/group you are modifiying
|
||||
*/
|
||||
modifyChat: async(jid: string, modification: ChatModification, index?: WAMessageKey) => {
|
||||
let chatAttrs: Attributes = { jid: jid }
|
||||
let data: BinaryNode[] | undefined = undefined
|
||||
const stamp = unixTimestampSeconds()
|
||||
|
||||
if('archive' in modification) {
|
||||
chatAttrs.type = modification.archive ? 'archive' : 'unarchive'
|
||||
} else if('pin' in modification) {
|
||||
chatAttrs.type = 'pin'
|
||||
if(typeof modification.pin === 'object') {
|
||||
chatAttrs.previous = modification.pin.remove.toString()
|
||||
} else {
|
||||
chatAttrs.pin = stamp.toString()
|
||||
}
|
||||
} else if('mute' in modification) {
|
||||
chatAttrs.type = 'mute'
|
||||
if(typeof modification.mute === 'object') {
|
||||
chatAttrs.previous = modification.mute.remove.toString()
|
||||
} else {
|
||||
chatAttrs.mute = (stamp + modification.mute).toString()
|
||||
}
|
||||
} else if('clear' in modification) {
|
||||
chatAttrs.type = 'clear'
|
||||
chatAttrs.modify_tag = Math.round(Math.random ()*1000000).toString()
|
||||
if(modification.clear !== 'all') {
|
||||
data = modification.clear.messages.map(({ id, fromMe }) => (
|
||||
new BinaryNode(
|
||||
'item',
|
||||
{ owner: (!!fromMe).toString(), index: id }
|
||||
)
|
||||
))
|
||||
}
|
||||
} else if('star' in modification) {
|
||||
chatAttrs.type = modification.star.star ? 'star' : 'unstar'
|
||||
data = modification.star.messages.map(({ id, fromMe }) => (
|
||||
new BinaryNode(
|
||||
'item',
|
||||
{ owner: (!!fromMe).toString(), index: id }
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
if(index) {
|
||||
chatAttrs.index = index.id
|
||||
chatAttrs.owner = index.fromMe ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const node = new BinaryNode('chat', chatAttrs, data)
|
||||
const response = await setQuery([node], [ WAMetric.chat, WAFlag.ignore ])
|
||||
// apply it and emit events
|
||||
executeChatModification(node)
|
||||
return response
|
||||
},
|
||||
/**
|
||||
* Query whether a given number is registered on WhatsApp
|
||||
* @param str phone number/jid you want to check for
|
||||
* @returns undefined if the number doesn't exists, otherwise the correctly formatted jid
|
||||
*/
|
||||
isOnWhatsApp: async (str: string) => {
|
||||
const { status, jid, biz } = await query({
|
||||
json: ['query', 'exist', str],
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
if (status === 200) {
|
||||
return {
|
||||
exists: true,
|
||||
jid: whatsappID(jid),
|
||||
isBusiness: biz as boolean
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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 | undefined, type: Presence) => (
|
||||
sendMessage({
|
||||
binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does
|
||||
json: new BinaryNode(
|
||||
'action',
|
||||
{ epoch: currentEpoch().toString(), type: 'set' },
|
||||
[
|
||||
new BinaryNode(
|
||||
'presence',
|
||||
{ type: type, to: jid }
|
||||
)
|
||||
]
|
||||
)
|
||||
})
|
||||
),
|
||||
/**
|
||||
* Request updates on the presence of a user
|
||||
* this returns nothing, you'll receive updates in chats.update event
|
||||
* */
|
||||
requestPresenceUpdate: async (jid: string) => (
|
||||
sendMessage({ json: ['action', 'presence', 'subscribe', jid] })
|
||||
),
|
||||
/** Query the status of the person (see groupMetadata() for groups) */
|
||||
getStatus: async(jid: string) => {
|
||||
const status: { status: string } = await query({ json: ['query', 'Status', jid], requiresPhoneConnection: false })
|
||||
return status
|
||||
},
|
||||
setStatus: async(status: string) => {
|
||||
const response = await setQuery(
|
||||
[
|
||||
new BinaryNode(
|
||||
'status',
|
||||
{},
|
||||
Buffer.from (status, 'utf-8')
|
||||
)
|
||||
]
|
||||
)
|
||||
ev.emit('contacts.update', [{ jid: getState().user!.jid, status }])
|
||||
return response
|
||||
},
|
||||
/** Updates business profile. */
|
||||
updateBusinessProfile: async(profile: WABusinessProfile) => {
|
||||
if (profile.business_hours?.config) {
|
||||
profile.business_hours.business_config = profile.business_hours.config
|
||||
delete profile.business_hours.config
|
||||
}
|
||||
const json = ['action', "editBusinessProfile", {...profile, v: 2}]
|
||||
await query({ json, expect200: true, requiresPhoneConnection: true })
|
||||
},
|
||||
updateProfileName: async(name: string) => {
|
||||
const response = (await setQuery(
|
||||
[
|
||||
new BinaryNode(
|
||||
'profile',
|
||||
{ name }
|
||||
)
|
||||
]
|
||||
)) as any as {status: number, pushname: string}
|
||||
|
||||
if (response.status === 200) {
|
||||
const user = { ...getState().user!, name }
|
||||
ev.emit('connection.update', { user })
|
||||
ev.emit('contacts.update', [{ jid: user.jid, name }])
|
||||
}
|
||||
return response
|
||||
},
|
||||
/**
|
||||
* Update the profile picture
|
||||
* @param jid
|
||||
* @param img
|
||||
*/
|
||||
async updateProfilePicture (jid: string, img: Buffer) {
|
||||
jid = whatsappID (jid)
|
||||
const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO
|
||||
const tag = this.generateMessageTag ()
|
||||
const query = new BinaryNode(
|
||||
'picture',
|
||||
{ jid: jid, id: tag, type: 'set' },
|
||||
[
|
||||
new BinaryNode('image', {}, data.img),
|
||||
new BinaryNode('preview', {}, data.preview)
|
||||
]
|
||||
)
|
||||
const user = getState().user
|
||||
const { eurl } = await this.setQuery ([query], [WAMetric.picture, 136], tag) as { eurl: string, status: number }
|
||||
|
||||
if (jid === user.jid) {
|
||||
user.imgUrl = eurl
|
||||
ev.emit('connection.update', { user })
|
||||
}
|
||||
ev.emit('contacts.update', [ { jid, imgUrl: eurl } ])
|
||||
},
|
||||
/**
|
||||
* Add or remove user from blocklist
|
||||
* @param jid the ID of the person who you are blocking/unblocking
|
||||
* @param type type of operation
|
||||
*/
|
||||
blockUser: async(jid: string, type: 'add' | 'remove' = 'add') => {
|
||||
const json = new BinaryNode(
|
||||
'block',
|
||||
{ type },
|
||||
[ new BinaryNode('user', { jid }) ]
|
||||
)
|
||||
await setQuery ([json], [WAMetric.block, WAFlag.ignore])
|
||||
ev.emit('blocklist.update', { blocklist: [jid], type })
|
||||
},
|
||||
/**
|
||||
* Query Business Profile (Useful for VCards)
|
||||
* @param jid Business Jid
|
||||
* @returns profile object or undefined if not business account
|
||||
*/
|
||||
getBusinessProfile: async(jid: string) => {
|
||||
jid = whatsappID(jid)
|
||||
const {
|
||||
profiles: [{
|
||||
profile,
|
||||
wid
|
||||
}]
|
||||
} = await query({
|
||||
json: [
|
||||
"query", "businessProfile",
|
||||
[ { "wid": jid.replace('@s.whatsapp.net', '@c.us') } ],
|
||||
84
|
||||
],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: false,
|
||||
})
|
||||
|
||||
return {
|
||||
...profile,
|
||||
wid: whatsappID(wid)
|
||||
} as WABusinessProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
export default makeChatsSocket
|
||||
@@ -1,237 +0,0 @@
|
||||
import BinaryNode from "../BinaryNode";
|
||||
import { SocketConfig, GroupModificationResponse, ParticipantAction, GroupMetadata, WAFlag, WAMetric, WAGroupCreateResponse, GroupParticipant } from "../Types";
|
||||
import { generateMessageID, unixTimestampSeconds, whatsappID } from "../Utils/generics";
|
||||
import makeMessagesSocket from "./messages";
|
||||
|
||||
const makeGroupsSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeMessagesSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws: socketEvents,
|
||||
query,
|
||||
generateMessageTag,
|
||||
currentEpoch,
|
||||
setQuery,
|
||||
getState
|
||||
} = sock
|
||||
|
||||
/** Generic function for group queries */
|
||||
const groupQuery = async(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: BinaryNode[]) => {
|
||||
const tag = generateMessageTag()
|
||||
const result = await setQuery ([
|
||||
new BinaryNode(
|
||||
'group',
|
||||
{
|
||||
author: getState().user?.jid,
|
||||
id: tag,
|
||||
type: type,
|
||||
jid: jid,
|
||||
subject: subject,
|
||||
},
|
||||
participants ?
|
||||
participants.map(jid => (
|
||||
new BinaryNode('participant', { jid })
|
||||
)) :
|
||||
additionalNodes
|
||||
)
|
||||
], [WAMetric.group, 136], tag)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the metadata of the group from WA */
|
||||
const groupMetadataFull = async (jid: string) => {
|
||||
const metadata = await query({
|
||||
json: ['query', 'GroupMetadata', jid],
|
||||
expect200: true
|
||||
})
|
||||
metadata.participants = metadata.participants.map(p => (
|
||||
{ ...p, id: undefined, jid: whatsappID(p.id) }
|
||||
))
|
||||
metadata.owner = whatsappID(metadata.owner)
|
||||
return metadata as GroupMetadata
|
||||
}
|
||||
/** Get the metadata (works after you've left the group also) */
|
||||
const groupMetadataMinimal = async (jid: string) => {
|
||||
const { attributes, data }:BinaryNode = await query({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{type: 'group', jid: jid, epoch: currentEpoch().toString()}
|
||||
),
|
||||
binaryTag: [WAMetric.group, WAFlag.ignore],
|
||||
expect200: true
|
||||
})
|
||||
const participants: GroupParticipant[] = []
|
||||
let desc: string | undefined
|
||||
if(Array.isArray(data) && Array.isArray(data[0].data)) {
|
||||
const nodes = data[0].data
|
||||
for(const item of nodes) {
|
||||
if(item.header === 'participant') {
|
||||
participants.push({
|
||||
jid: item.attributes.jid,
|
||||
isAdmin: item.attributes.type === 'admin',
|
||||
isSuperAdmin: false
|
||||
})
|
||||
} else if(item.header === 'description') {
|
||||
desc = (item.data as Buffer).toString('utf-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: jid,
|
||||
owner: attributes?.creator,
|
||||
creator: attributes?.creator,
|
||||
creation: +attributes?.create,
|
||||
subject: null,
|
||||
desc,
|
||||
participants
|
||||
} as GroupMetadata
|
||||
}
|
||||
|
||||
socketEvents.on('CB:Chat,cmd:action', (json: BinaryNode) => {
|
||||
/*const data = json[1].data
|
||||
if (data) {
|
||||
const emitGroupParticipantsUpdate = (action: WAParticipantAction) => this.emitParticipantsUpdate
|
||||
(json[1].id, data[2].participants.map(whatsappID), action)
|
||||
const emitGroupUpdate = (data: Partial<WAGroupMetadata>) => this.emitGroupUpdate(json[1].id, data)
|
||||
|
||||
switch (data[0]) {
|
||||
case "promote":
|
||||
emitGroupParticipantsUpdate('promote')
|
||||
break
|
||||
case "demote":
|
||||
emitGroupParticipantsUpdate('demote')
|
||||
break
|
||||
case "desc_add":
|
||||
emitGroupUpdate({ ...data[2], descOwner: data[1] })
|
||||
break
|
||||
default:
|
||||
this.logger.debug({ unhandled: true }, json)
|
||||
break
|
||||
}
|
||||
}*/
|
||||
})
|
||||
|
||||
return {
|
||||
...sock,
|
||||
groupMetadata: async(jid: string, minimal: boolean) => {
|
||||
let result: GroupMetadata
|
||||
|
||||
if(minimal) result = await groupMetadataMinimal(jid)
|
||||
else result = await groupMetadataFull(jid)
|
||||
|
||||
return result
|
||||
},
|
||||
/**
|
||||
* Create a group
|
||||
* @param title like, the title of the group
|
||||
* @param participants people to include in the group
|
||||
*/
|
||||
groupCreate: async (title: string, participants: string[]) => {
|
||||
const response = await groupQuery('create', null, title, participants) as WAGroupCreateResponse
|
||||
const gid = response.gid
|
||||
let metadata: GroupMetadata
|
||||
try {
|
||||
metadata = await groupMetadataFull(gid)
|
||||
} catch (error) {
|
||||
logger.warn (`error in group creation: ${error}, switching gid & checking`)
|
||||
// if metadata is not available
|
||||
const comps = gid.replace ('@g.us', '').split ('-')
|
||||
response.gid = `${comps[0]}-${+comps[1] + 1}@g.us`
|
||||
|
||||
metadata = await groupMetadataFull(gid)
|
||||
logger.warn (`group ID switched from ${gid} to ${response.gid}`)
|
||||
}
|
||||
ev.emit('chats.upsert', [
|
||||
{
|
||||
jid: response.gid,
|
||||
name: title,
|
||||
t: unixTimestampSeconds(),
|
||||
count: 0
|
||||
}
|
||||
])
|
||||
return metadata
|
||||
},
|
||||
/**
|
||||
* Leave a group
|
||||
* @param jid the ID of the group
|
||||
*/
|
||||
groupLeave: async (jid: string) => {
|
||||
await groupQuery('leave', jid)
|
||||
ev.emit('chats.update', [ { jid, read_only: 'true' } ])
|
||||
},
|
||||
/**
|
||||
* Update the subject of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateSubject: async (jid: string, title: string) => {
|
||||
await groupQuery('subject', jid, title)
|
||||
ev.emit('chats.update', [ { jid, name: title } ])
|
||||
ev.emit('contacts.update', [ { jid, name: title } ])
|
||||
ev.emit('groups.update', [ { id: jid, subject: title } ])
|
||||
},
|
||||
/**
|
||||
* Update the group description
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateDescription: async (jid: string, description: string) => {
|
||||
const metadata = await groupMetadataFull(jid)
|
||||
const node = new BinaryNode(
|
||||
'description',
|
||||
{id: generateMessageID(), prev: metadata?.descId},
|
||||
Buffer.from (description, 'utf-8')
|
||||
)
|
||||
const response = await groupQuery ('description', jid, null, null, [node])
|
||||
ev.emit('groups.update', [ { id: jid, desc: description } ])
|
||||
return response
|
||||
},
|
||||
/**
|
||||
* Update participants in the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to add
|
||||
*/
|
||||
groupParticipantsUpdate: async(jid: string, participants: string[], action: ParticipantAction) => {
|
||||
const result: GroupModificationResponse = await groupQuery(action, jid, null, participants)
|
||||
const jids = Object.keys(result.participants || {})
|
||||
ev.emit('group-participants.update', { jid, participants: jids, action })
|
||||
return jids
|
||||
},
|
||||
/** Query broadcast list info */
|
||||
getBroadcastListInfo: async(jid: string) => {
|
||||
interface WABroadcastListInfo {
|
||||
status: number
|
||||
name: string
|
||||
recipients?: {id: string}[]
|
||||
}
|
||||
|
||||
const result = await query({
|
||||
json: ['query', 'contact', jid],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
}) as WABroadcastListInfo
|
||||
|
||||
const metadata: GroupMetadata = {
|
||||
subject: result.name,
|
||||
id: jid,
|
||||
creation: undefined,
|
||||
owner: getState().user?.jid,
|
||||
participants: result.recipients!.map(({id}) => (
|
||||
{ jid: whatsappID(id), isAdmin: false, isSuperAdmin: false }
|
||||
))
|
||||
}
|
||||
return metadata
|
||||
},
|
||||
inviteCode: async(jid: string) => {
|
||||
const response = await sock.query({
|
||||
json: ['query', 'inviteCode', jid],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
return response.code as string
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
export default makeGroupsSocket
|
||||
@@ -1,14 +0,0 @@
|
||||
import { SocketConfig } from '../Types'
|
||||
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
|
||||
import _makeConnection from './groups'
|
||||
// export the last socket layer
|
||||
const makeConnection = (config: Partial<SocketConfig>) => (
|
||||
_makeConnection({
|
||||
...DEFAULT_CONNECTION_CONFIG,
|
||||
...config
|
||||
})
|
||||
)
|
||||
|
||||
export type Connection = ReturnType<typeof makeConnection>
|
||||
|
||||
export default makeConnection
|
||||
@@ -1,577 +0,0 @@
|
||||
import BinaryNode from "../BinaryNode";
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { EventEmitter } from 'events'
|
||||
import { Chat, Presence, WAMessageCursor, SocketConfig, WAMessage, WAMessageKey, ParticipantAction, WAMessageProto, WAMessageStatus, WAMessageStubType, GroupMetadata, AnyMessageContent, MiscMessageGenerationOptions, WAFlag, WAMetric, WAUrlInfo, MediaConnInfo, MessageUpdateType, MessageInfo, MessageInfoUpdate, WAMediaUploadFunction, MediaType, WAMessageUpdate } from "../Types";
|
||||
import { isGroupID, toNumber, whatsappID, generateWAMessage, decryptMediaMessageBuffer, extractMessageContent } from "../Utils";
|
||||
import makeChatsSocket from "./chats";
|
||||
import { DEFAULT_ORIGIN, MEDIA_PATH_MAP, WA_DEFAULT_EPHEMERAL } from "../Defaults";
|
||||
import got from "got";
|
||||
|
||||
const STATUS_MAP = {
|
||||
read: WAMessageStatus.READ,
|
||||
message: WAMessageStatus.DELIVERY_ACK,
|
||||
error: WAMessageStatus.ERROR
|
||||
} as { [_: string]: WAMessageStatus }
|
||||
|
||||
const makeMessagesSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeChatsSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws: socketEvents,
|
||||
query,
|
||||
generateMessageTag,
|
||||
currentEpoch,
|
||||
setQuery,
|
||||
getState
|
||||
} = sock
|
||||
|
||||
let mediaConn: Promise<MediaConnInfo>
|
||||
const refreshMediaConn = async(forceGet = false) => {
|
||||
let media = await mediaConn
|
||||
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
|
||||
mediaConn = (async() => {
|
||||
const {media_conn} = await query({
|
||||
json: ['query', 'mediaConn'],
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
media_conn.fetchDate = new Date()
|
||||
return media_conn as MediaConnInfo
|
||||
})()
|
||||
}
|
||||
return mediaConn
|
||||
}
|
||||
|
||||
const fetchMessagesFromWA = async(
|
||||
jid: string,
|
||||
count: number,
|
||||
cursor?: WAMessageCursor
|
||||
) => {
|
||||
let key: WAMessageKey
|
||||
if(cursor) {
|
||||
key = 'before' in cursor ? cursor.before : cursor.after
|
||||
}
|
||||
const { data }:BinaryNode = await query({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{
|
||||
epoch: currentEpoch().toString(),
|
||||
type: 'message',
|
||||
jid: jid,
|
||||
kind: !cursor || 'before' in cursor ? 'before' : 'after',
|
||||
count: count.toString(),
|
||||
index: key?.id,
|
||||
owner: key?.fromMe === false ? 'false' : 'true',
|
||||
}
|
||||
),
|
||||
binaryTag: [WAMetric.queryMessages, WAFlag.ignore],
|
||||
expect200: false,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
if(Array.isArray(data)) {
|
||||
return data.map(data => data.data as WAMessage)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const updateMediaMessage = async(message: WAMessage) => {
|
||||
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
|
||||
if (!content) throw new Boom(
|
||||
`given message ${message.key.id} is not a media message`,
|
||||
{ statusCode: 400, data: message }
|
||||
)
|
||||
|
||||
const response: BinaryNode = await query ({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{
|
||||
type: 'media',
|
||||
index: message.key.id,
|
||||
owner: message.key.fromMe ? 'true' : 'false',
|
||||
jid: message.key.remoteJid,
|
||||
epoch: currentEpoch().toString()
|
||||
}
|
||||
),
|
||||
binaryTag: [WAMetric.queryMedia, WAFlag.ignore],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
const attrs = response.attributes
|
||||
Object.keys(attrs).forEach (key => content[key] = attrs[key]) // update message
|
||||
|
||||
ev.emit('messages.update', [{ key: message.key, update: { message: message.message } }])
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const onMessage = (message: WAMessage, type: MessageUpdateType | 'update') => {
|
||||
const jid = message.key.remoteJid!
|
||||
// store chat updates in this
|
||||
const chatUpdate: Partial<Chat> = {
|
||||
jid,
|
||||
}
|
||||
|
||||
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
|
||||
ev.emit('groups.update', [ { id: jid, ...update } ])
|
||||
}
|
||||
|
||||
if(message.message) {
|
||||
chatUpdate.t = +toNumber(message.messageTimestamp)
|
||||
// add to count if the message isn't from me & there exists a message
|
||||
if(!message.key.fromMe) {
|
||||
chatUpdate.count = 1
|
||||
const participant = whatsappID(message.participant || jid)
|
||||
|
||||
ev.emit(
|
||||
'presence.update',
|
||||
{
|
||||
jid,
|
||||
presences: { [participant]: { lastKnownPresence: Presence.available } }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ephemeralProtocolMsg = message.message?.ephemeralMessage?.message?.protocolMessage
|
||||
if (
|
||||
ephemeralProtocolMsg &&
|
||||
ephemeralProtocolMsg.type === WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING
|
||||
) {
|
||||
chatUpdate.eph_setting_ts = message.messageTimestamp.toString()
|
||||
chatUpdate.ephemeral = ephemeralProtocolMsg.ephemeralExpiration.toString()
|
||||
|
||||
if(isGroupID(jid)) {
|
||||
emitGroupUpdate({ ephemeralDuration: ephemeralProtocolMsg.ephemeralExpiration || null })
|
||||
}
|
||||
}
|
||||
const protocolMessage = message.message?.protocolMessage
|
||||
// if it's a message to delete another message
|
||||
if (protocolMessage) {
|
||||
switch (protocolMessage.type) {
|
||||
case WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE:
|
||||
const key = protocolMessage.key
|
||||
const messageStubType = WAMessageStubType.REVOKE
|
||||
ev.emit('messages.update', [
|
||||
{
|
||||
// the key of the deleted message is updated
|
||||
update: { message: null, key: message.key, messageStubType },
|
||||
key
|
||||
}
|
||||
])
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check if the message is an action
|
||||
if (message.messageStubType) {
|
||||
const { user } = getState()
|
||||
//let actor = whatsappID (message.participant)
|
||||
let participants: string[]
|
||||
const emitParticipantsUpdate = (action: ParticipantAction) => (
|
||||
ev.emit('group-participants.update', { jid, participants, action })
|
||||
)
|
||||
|
||||
switch (message.messageStubType) {
|
||||
case WAMessageStubType.CHANGE_EPHEMERAL_SETTING:
|
||||
chatUpdate.eph_setting_ts = message.messageTimestamp.toString()
|
||||
chatUpdate.ephemeral = message.messageStubParameters[0]
|
||||
if(isGroupID(jid)) {
|
||||
emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null })
|
||||
}
|
||||
break
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
|
||||
participants = message.messageStubParameters.map (whatsappID)
|
||||
emitParticipantsUpdate('remove')
|
||||
// mark the chat read only if you left the group
|
||||
if (participants.includes(user.jid)) {
|
||||
chatUpdate.read_only = 'true'
|
||||
}
|
||||
break
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
|
||||
participants = message.messageStubParameters.map (whatsappID)
|
||||
if (participants.includes(user.jid)) {
|
||||
chatUpdate.read_only = 'false'
|
||||
}
|
||||
emitParticipantsUpdate('add')
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
|
||||
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
|
||||
emitGroupUpdate({ announce })
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
|
||||
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
|
||||
emitGroupUpdate({ restrict })
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
|
||||
case WAMessageStubType.GROUP_CREATE:
|
||||
chatUpdate.name = message.messageStubParameters[0]
|
||||
emitGroupUpdate({ subject: chatUpdate.name })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if(Object.keys(chatUpdate).length > 1) {
|
||||
ev.emit('chats.update', [chatUpdate])
|
||||
}
|
||||
if(type === 'update') {
|
||||
ev.emit('messages.update', [ { update: message, key: message.key } ])
|
||||
} else {
|
||||
ev.emit('messages.upsert', { messages: [message], type })
|
||||
}
|
||||
}
|
||||
|
||||
const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64 }) => {
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
let uploadInfo = await refreshMediaConn(false)
|
||||
|
||||
let mediaUrl: string
|
||||
for (let host of uploadInfo.hosts) {
|
||||
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
|
||||
const url = `https://${host.hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
|
||||
try {
|
||||
const {body: responseText} = await got.post(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Origin': DEFAULT_ORIGIN
|
||||
},
|
||||
agent: {
|
||||
https: config.agent
|
||||
},
|
||||
body: stream
|
||||
}
|
||||
)
|
||||
const result = JSON.parse(responseText)
|
||||
mediaUrl = result?.url
|
||||
|
||||
if (mediaUrl) break
|
||||
else {
|
||||
uploadInfo = await refreshMediaConn(true)
|
||||
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const isLast = host.hostname === uploadInfo.hosts[uploadInfo.hosts.length-1].hostname
|
||||
logger.debug(`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
|
||||
}
|
||||
}
|
||||
if (!mediaUrl) {
|
||||
throw new Boom(
|
||||
'Media upload failed on all hosts',
|
||||
{ statusCode: 500 }
|
||||
)
|
||||
}
|
||||
return { mediaUrl }
|
||||
}
|
||||
|
||||
/** Query a string to check if it has a url, if it does, return WAUrlInfo */
|
||||
const generateUrlInfo = async(text: string) => {
|
||||
const response: BinaryNode = await query({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{
|
||||
type: 'url',
|
||||
url: text,
|
||||
epoch: currentEpoch().toString()
|
||||
}
|
||||
),
|
||||
binaryTag: [26, WAFlag.ignore],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
const urlInfo = { ...response.attributes } as any as WAUrlInfo
|
||||
if(response && response.data) {
|
||||
urlInfo.jpegThumbnail = response.data as Buffer
|
||||
}
|
||||
return urlInfo
|
||||
}
|
||||
|
||||
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
|
||||
const relayWAMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => {
|
||||
const json = new BinaryNode(
|
||||
'action',
|
||||
{ epoch: currentEpoch().toString(), type: 'relay' },
|
||||
[ new BinaryNode('message', {}, message) ]
|
||||
)
|
||||
const isMsgToMe = message.key.remoteJid === getState().user?.jid
|
||||
const flag = isMsgToMe ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
|
||||
const mID = message.key.id
|
||||
const finalState = isMsgToMe ? WAMessageStatus.READ : WAMessageStatus.SERVER_ACK
|
||||
|
||||
message.status = WAMessageStatus.PENDING
|
||||
const promise = query({
|
||||
json,
|
||||
binaryTag: [WAMetric.message, flag],
|
||||
tag: mID,
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
|
||||
if(waitForAck) {
|
||||
await promise
|
||||
message.status = finalState
|
||||
} else {
|
||||
const emitUpdate = (status: WAMessageStatus) => {
|
||||
message.status = status
|
||||
ev.emit('messages.update', [ { key: message.key, update: { status } } ])
|
||||
}
|
||||
promise
|
||||
.then(() => emitUpdate(finalState))
|
||||
.catch(() => emitUpdate(WAMessageStatus.ERROR))
|
||||
}
|
||||
|
||||
onMessage(message, 'append')
|
||||
}
|
||||
|
||||
// messages received
|
||||
const messagesUpdate = ({ data }: BinaryNode, type: 'prepend' | 'last') => {
|
||||
if(Array.isArray(data)) {
|
||||
const messages: WAMessage[] = []
|
||||
for(let i = data.length-1; i >= 0;i--) {
|
||||
messages.push(data[i].data as WAMessage)
|
||||
}
|
||||
ev.emit('messages.upsert', { messages, type })
|
||||
}
|
||||
}
|
||||
|
||||
socketEvents.on('CB:action,add:last', json => messagesUpdate(json, 'last'))
|
||||
socketEvents.on('CB:action,add:unread', json => messagesUpdate(json, 'prepend'))
|
||||
socketEvents.on('CB:action,add:before', json => messagesUpdate(json, 'prepend'))
|
||||
|
||||
// new messages
|
||||
socketEvents.on('CB:action,add:relay,message', ({data}: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
for(const { data: msg } of data) {
|
||||
onMessage(msg as WAMessage, 'notify')
|
||||
}
|
||||
}
|
||||
})
|
||||
// If a message has been updated (usually called when a video message gets its upload url, or live locations)
|
||||
socketEvents.on ('CB:action,add:update,message', ({ data }: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
for(const { data: msg } of data) {
|
||||
onMessage(msg as WAMessage, 'update')
|
||||
}
|
||||
}
|
||||
})
|
||||
// message status updates
|
||||
const onMessageStatusUpdate = ({ data }: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
const updates: WAMessageUpdate[] = []
|
||||
for(const { attributes: json } of data) {
|
||||
const key: WAMessageKey = {
|
||||
remoteJid: whatsappID(json.jid),
|
||||
id: json.index,
|
||||
fromMe: json.owner === 'true'
|
||||
}
|
||||
const status = STATUS_MAP[json.type]
|
||||
|
||||
if(status) {
|
||||
updates.push({ key, update: { status } })
|
||||
} else {
|
||||
logger.warn({ data }, 'got unknown status update for message')
|
||||
}
|
||||
}
|
||||
ev.emit('messages.update', updates)
|
||||
}
|
||||
}
|
||||
const onMessageInfoUpdate = ([,attributes]: [string,{[_: string]: any}]) => {
|
||||
let ids = attributes.id as string[] | string
|
||||
if(typeof ids === 'string') {
|
||||
ids = [ids]
|
||||
}
|
||||
let updateKey: keyof MessageInfoUpdate['update']
|
||||
switch(attributes.ack.toString()) {
|
||||
case '2':
|
||||
updateKey = 'deliveries'
|
||||
break
|
||||
case '3':
|
||||
updateKey = 'reads'
|
||||
break
|
||||
default:
|
||||
logger.warn({ attributes }, `received unknown message info update`)
|
||||
return
|
||||
}
|
||||
const keyPartial = {
|
||||
remoteJid: whatsappID(attributes.to),
|
||||
fromMe: whatsappID(attributes.from) === getState().user?.jid,
|
||||
}
|
||||
const updates = ids.map<MessageInfoUpdate>(id => ({
|
||||
key: { ...keyPartial, id },
|
||||
update: {
|
||||
[updateKey]: { [whatsappID(attributes.participant)]: new Date(+attributes.t) }
|
||||
}
|
||||
}))
|
||||
ev.emit('message-info.update', updates)
|
||||
// for individual messages
|
||||
// it means the message is marked read/delivered
|
||||
if(!isGroupID(keyPartial.remoteJid)) {
|
||||
ev.emit('messages.update', ids.map(id => (
|
||||
{
|
||||
key: { ...keyPartial, id },
|
||||
update: {
|
||||
status: updateKey === 'deliveries' ? WAMessageStatus.DELIVERY_ACK : WAMessageStatus.READ
|
||||
}
|
||||
}
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
socketEvents.on('CB:action,add:relay,received', onMessageStatusUpdate)
|
||||
socketEvents.on('CB:action,,received', onMessageStatusUpdate)
|
||||
|
||||
socketEvents.on('CB:Msg', onMessageInfoUpdate)
|
||||
socketEvents.on('CB:MsgInfo', onMessageInfoUpdate)
|
||||
|
||||
return {
|
||||
...sock,
|
||||
relayWAMessage,
|
||||
generateUrlInfo,
|
||||
messageInfo: async(jid: string, messageID: string) => {
|
||||
const { data }: BinaryNode = await query({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{type: 'message_info', index: messageID, jid: jid, epoch: currentEpoch().toString()}
|
||||
),
|
||||
binaryTag: [WAMetric.queryRead, WAFlag.ignore],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
const info: MessageInfo = { reads: {}, deliveries: {} }
|
||||
if(Array.isArray(data)) {
|
||||
for(const { header, data: innerData } of data) {
|
||||
const [{ attributes }] = (innerData as BinaryNode[])
|
||||
const jid = whatsappID(attributes.jid)
|
||||
const date = new Date(+attributes.t * 1000)
|
||||
switch(header) {
|
||||
case 'read':
|
||||
info.reads[jid] = date
|
||||
break
|
||||
case 'delivery':
|
||||
info.deliveries[jid] = date
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
},
|
||||
downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => {
|
||||
|
||||
const downloadMediaMessage = async () => {
|
||||
let mContent = extractMessageContent(message.message)
|
||||
if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
|
||||
|
||||
const stream = await decryptMediaMessageBuffer(mContent)
|
||||
if(type === 'buffer') {
|
||||
let buffer = Buffer.from([])
|
||||
for await(const chunk of stream) {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await downloadMediaMessage()
|
||||
return result
|
||||
} catch (error) {
|
||||
if(error.message.includes('404')) { // media needs to be updated
|
||||
logger.info (`updating media of message: ${message.key.id}`)
|
||||
|
||||
await updateMediaMessage(message)
|
||||
|
||||
const result = await downloadMediaMessage()
|
||||
return result
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
updateMediaMessage,
|
||||
fetchMessagesFromWA,
|
||||
/** Load a single message specified by the ID */
|
||||
loadMessageFromWA: async(jid: string, id: string) => {
|
||||
let message: WAMessage
|
||||
|
||||
// load the message before the given message
|
||||
let messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: true} }))
|
||||
if(!messages[0]) messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: false} }))
|
||||
// the message after the loaded message is the message required
|
||||
const [actual] = await fetchMessagesFromWA(jid, 1, { after: messages[0] && messages[0].key })
|
||||
message = actual
|
||||
return message
|
||||
},
|
||||
searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => {
|
||||
const {data, attributes}: BinaryNode = await query({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{
|
||||
epoch: currentEpoch().toString(),
|
||||
type: 'search',
|
||||
search: txt,
|
||||
count: count.toString(),
|
||||
page: page.toString(),
|
||||
jid: inJid
|
||||
}
|
||||
),
|
||||
binaryTag: [24, WAFlag.ignore],
|
||||
expect200: true
|
||||
}) // encrypt and send off
|
||||
|
||||
const messages = Array.isArray(data) ? data.map(item => item.data as WAMessage) : []
|
||||
return {
|
||||
last: attributes?.last === 'true',
|
||||
messages
|
||||
}
|
||||
},
|
||||
sendWAMessage: async(
|
||||
jid: string,
|
||||
content: AnyMessageContent,
|
||||
options: MiscMessageGenerationOptions & { waitForAck?: boolean }
|
||||
) => {
|
||||
const userJid = getState().user?.jid
|
||||
if(
|
||||
typeof content === 'object' &&
|
||||
'disappearingMessagesInChat' in content &&
|
||||
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
|
||||
isGroupID(jid)
|
||||
) {
|
||||
const { disappearingMessagesInChat } = content
|
||||
const value = typeof disappearingMessagesInChat === 'boolean' ?
|
||||
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
|
||||
disappearingMessagesInChat
|
||||
const tag = generateMessageTag(true)
|
||||
await setQuery([
|
||||
new BinaryNode(
|
||||
'group',
|
||||
{ id: tag, jid, type: 'prop', author: userJid },
|
||||
[ new BinaryNode('ephemeral', { value: value.toString() }) ]
|
||||
)
|
||||
], [WAMetric.group, WAFlag.other], tag)
|
||||
} else {
|
||||
const msg = await generateWAMessage(
|
||||
jid,
|
||||
content,
|
||||
{
|
||||
...options,
|
||||
logger,
|
||||
userJid: userJid,
|
||||
getUrlInfo: generateUrlInfo,
|
||||
upload: waUploadToServer
|
||||
}
|
||||
)
|
||||
|
||||
await relayWAMessage(msg, { waitForAck: options.waitForAck })
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default makeMessagesSocket
|
||||
@@ -1,367 +0,0 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { STATUS_CODES } from "http"
|
||||
import { promisify } from "util"
|
||||
import WebSocket from "ws"
|
||||
import BinaryNode from "../BinaryNode"
|
||||
import { DisconnectReason, SocketConfig, SocketQueryOptions, SocketSendMessageOptions } from "../Types"
|
||||
import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds, decodeWAMessage } from "../Utils"
|
||||
import { WAFlag, WAMetric, WATag } from "../Types"
|
||||
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults"
|
||||
|
||||
/**
|
||||
* Connects to WA servers and performs:
|
||||
* - simple queries (no retry mechanism, wait for connection establishment)
|
||||
* - listen to messages and emit events
|
||||
* - query phone connection
|
||||
*/
|
||||
export const makeSocket = ({
|
||||
waWebSocketUrl,
|
||||
connectTimeoutMs,
|
||||
phoneResponseTimeMs,
|
||||
logger,
|
||||
agent,
|
||||
keepAliveIntervalMs,
|
||||
expectResponseTimeout,
|
||||
phoneConnectionChanged
|
||||
}: SocketConfig) => {
|
||||
// for generating tags
|
||||
const referenceDateSeconds = unixTimestampSeconds(new Date())
|
||||
const ws = new WebSocket(waWebSocketUrl, undefined, {
|
||||
origin: DEFAULT_ORIGIN,
|
||||
timeout: connectTimeoutMs,
|
||||
agent,
|
||||
headers: {
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Host': 'web.whatsapp.com',
|
||||
'Pragma': 'no-cache',
|
||||
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
|
||||
}
|
||||
})
|
||||
ws.setMaxListeners(0)
|
||||
let lastDateRecv: Date
|
||||
let epoch = 0
|
||||
let authInfo: { encKey: Buffer, macKey: Buffer }
|
||||
let keepAliveReq: NodeJS.Timeout
|
||||
|
||||
let phoneCheckInterval: NodeJS.Timeout
|
||||
let phoneCheckListeners = 0
|
||||
|
||||
const sendPromise = promisify(ws.send)
|
||||
/** generate message tag and increment epoch */
|
||||
const generateMessageTag = (longTag: boolean = false) => {
|
||||
const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}`
|
||||
epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages
|
||||
return tag
|
||||
}
|
||||
const sendRawMessage = (data: Buffer | string) => sendPromise.call(ws, data) as Promise<void>
|
||||
/**
|
||||
* Send a message to the WA servers
|
||||
* @returns the tag attached in the message
|
||||
* */
|
||||
const sendMessage = async(
|
||||
{ json, binaryTag, tag, longTag }: SocketSendMessageOptions
|
||||
) => {
|
||||
tag = tag || generateMessageTag(longTag)
|
||||
let data: Buffer | string
|
||||
if(logger.level === 'trace') {
|
||||
logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication')
|
||||
}
|
||||
|
||||
if(binaryTag) {
|
||||
if(!(json instanceof BinaryNode)) {
|
||||
throw new Boom(`Invalid binary message of type "${typeof json}". Must be BinaryNode`, { statusCode: 400 })
|
||||
}
|
||||
if(!authInfo) {
|
||||
throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 })
|
||||
}
|
||||
const binary = json.toBuffer() // encode the JSON to the WhatsApp binary format
|
||||
|
||||
const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey
|
||||
const sign = hmacSign(buff, authInfo.macKey) // sign the message using HMAC and our macKey
|
||||
|
||||
data = Buffer.concat([
|
||||
Buffer.from(tag + ','), // generate & prefix the message tag
|
||||
Buffer.from(binaryTag), // prefix some bytes that tell whatsapp what the message is about
|
||||
sign, // the HMAC sign of the message
|
||||
buff, // the actual encrypted buffer
|
||||
])
|
||||
} else {
|
||||
data = `${tag},${JSON.stringify(json)}`
|
||||
}
|
||||
await sendRawMessage(data)
|
||||
return tag
|
||||
}
|
||||
const end = (error: Error | undefined) => {
|
||||
logger.debug({ error }, 'connection closed')
|
||||
|
||||
ws.removeAllListeners('close')
|
||||
ws.removeAllListeners('error')
|
||||
ws.removeAllListeners('open')
|
||||
ws.removeAllListeners('message')
|
||||
|
||||
phoneCheckListeners = 0
|
||||
clearInterval(keepAliveReq)
|
||||
clearPhoneCheckInterval()
|
||||
|
||||
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
|
||||
try { ws.close() } catch { }
|
||||
}
|
||||
|
||||
ws.emit('ws-close', error)
|
||||
ws.removeAllListeners('ws-close')
|
||||
}
|
||||
const onMessageRecieved = (message: string | Buffer) => {
|
||||
if(message[0] === '!') {
|
||||
// when the first character in the message is an '!', the server is sending a pong frame
|
||||
const timestamp = message.slice(1, message.length).toString ('utf-8')
|
||||
lastDateRecv = new Date(parseInt(timestamp))
|
||||
ws.emit('received-pong')
|
||||
} else {
|
||||
let messageTag: string
|
||||
let json: any
|
||||
try {
|
||||
const dec = decodeWAMessage(message, authInfo)
|
||||
messageTag = dec[0]
|
||||
json = dec[1]
|
||||
if (!json) return
|
||||
} catch (error) {
|
||||
end(error)
|
||||
return
|
||||
}
|
||||
//if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false })
|
||||
|
||||
if (logger.level === 'trace') {
|
||||
logger.trace({ tag: messageTag, fromMe: false, json }, 'communication')
|
||||
}
|
||||
|
||||
let anyTriggered = false
|
||||
/* Check if this is a response to a message we sent */
|
||||
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${messageTag}`, json)
|
||||
/* Check if this is a response to a message we are expecting */
|
||||
const l0 = json.header || json[0] || ''
|
||||
const l1 = json?.attributes || json?.[1] || { }
|
||||
const l2 = json?.data?.[0]?.header || json[2]?.[0] || ''
|
||||
|
||||
Object.keys(l1).forEach(key => {
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, json) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, json) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, json) || anyTriggered
|
||||
})
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered
|
||||
|
||||
if (!anyTriggered && logger.level === 'debug') {
|
||||
logger.debug({ unhandled: true, tag: messageTag, fromMe: false, json }, 'communication recv')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exits a query if the phone connection is active and no response is still found */
|
||||
const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
const listener = ([, connected]) => {
|
||||
if(connected) {
|
||||
timeout = setTimeout(() => {
|
||||
logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`)
|
||||
cancel(new Boom('Not expecting a response', { statusCode: 422 }))
|
||||
}, expectResponseTimeout)
|
||||
ws.off(PHONE_CONNECTION_CB, listener)
|
||||
}
|
||||
}
|
||||
ws.on(PHONE_CONNECTION_CB, listener)
|
||||
return () => {
|
||||
ws.off(PHONE_CONNECTION_CB, listener)
|
||||
timeout && clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
/** interval is started when a query takes too long to respond */
|
||||
const startPhoneCheckInterval = () => {
|
||||
phoneCheckListeners += 1
|
||||
if (!phoneCheckInterval) {
|
||||
// if its been a long time and we haven't heard back from WA, send a ping
|
||||
phoneCheckInterval = setInterval(() => {
|
||||
if(phoneCheckListeners <= 0) {
|
||||
logger.warn('phone check called without listeners')
|
||||
return
|
||||
}
|
||||
logger.info('checking phone connection...')
|
||||
sendAdminTest()
|
||||
|
||||
phoneConnectionChanged(false)
|
||||
}, phoneResponseTimeMs)
|
||||
}
|
||||
}
|
||||
const clearPhoneCheckInterval = () => {
|
||||
phoneCheckListeners -= 1
|
||||
if (phoneCheckListeners <= 0) {
|
||||
clearInterval(phoneCheckInterval)
|
||||
phoneCheckInterval = undefined
|
||||
phoneCheckListeners = 0
|
||||
}
|
||||
}
|
||||
/** checks for phone connection */
|
||||
const sendAdminTest = () => sendMessage({ json: ['admin', 'test'] })
|
||||
/**
|
||||
* Wait for a message with a certain tag to be received
|
||||
* @param tag the message tag to await
|
||||
* @param json query that was sent
|
||||
* @param timeoutMs timeout after which the promise will reject
|
||||
*/
|
||||
const waitForMessage = async(tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => {
|
||||
let onRecv: (json) => void
|
||||
let onErr: (err) => void
|
||||
let cancelPhoneChecker: () => void
|
||||
try {
|
||||
const result = await promiseTimeout(timeoutMs,
|
||||
(resolve, reject) => {
|
||||
onRecv = resolve
|
||||
onErr = err => {
|
||||
reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
|
||||
}
|
||||
|
||||
if(requiresPhoneConnection) {
|
||||
startPhoneCheckInterval()
|
||||
cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr)
|
||||
}
|
||||
|
||||
ws.on(`TAG:${tag}`, onRecv)
|
||||
ws.on('ws-close', onErr) // if the socket closes, you'll never receive the message
|
||||
},
|
||||
)
|
||||
return result as any
|
||||
} finally {
|
||||
requiresPhoneConnection && clearPhoneCheckInterval()
|
||||
cancelPhoneChecker && cancelPhoneChecker()
|
||||
|
||||
ws.off(`TAG:${tag}`, onRecv)
|
||||
ws.off('ws-close', onErr) // if the socket closes, you'll never receive the message
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Query something from the WhatsApp servers
|
||||
* @param json the query itself
|
||||
* @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary
|
||||
* @param timeoutMs timeout after which the query will be failed (set to null to disable a timeout)
|
||||
* @param tag the tag to attach to the message
|
||||
*/
|
||||
const query = async(
|
||||
{json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection}: SocketQueryOptions
|
||||
) => {
|
||||
tag = tag || generateMessageTag(longTag)
|
||||
const promise = waitForMessage(tag, requiresPhoneConnection, timeoutMs)
|
||||
|
||||
await sendMessage({ json, tag, binaryTag })
|
||||
const response = await promise
|
||||
const responseStatusCode = +(response.status ? response.status : 200) // default status
|
||||
// read here: http://getstatuscode.com/599
|
||||
if(responseStatusCode === 599) { // the connection has gone bad
|
||||
end(new Boom('WA server overloaded', { statusCode: 599, data: { query: json, response } }))
|
||||
}
|
||||
if(expect200 && Math.floor(responseStatusCode/100) !== 2) {
|
||||
const message = STATUS_CODES[responseStatusCode] || 'unknown'
|
||||
throw new Boom(
|
||||
`Unexpected status in '${Array.isArray(json) ? json[0] : (json?.header || 'query')}': ${message}(${responseStatusCode})`,
|
||||
{ data: { query: json, message }, statusCode: response.status }
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
const startKeepAliveRequest = () => (
|
||||
keepAliveReq = setInterval(() => {
|
||||
if (!lastDateRecv) lastDateRecv = new Date()
|
||||
const diff = Date.now() - lastDateRecv.getTime()
|
||||
/*
|
||||
check if it's been a suspicious amount of time since the server responded with our last seen
|
||||
it could be that the network is down
|
||||
*/
|
||||
if (diff > keepAliveIntervalMs+5000) {
|
||||
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
|
||||
} else if(ws.readyState === ws.OPEN) {
|
||||
sendRawMessage('?,,') // if its all good, send a keep alive request
|
||||
} else {
|
||||
logger.warn('keep alive called when WS not open')
|
||||
}
|
||||
}, keepAliveIntervalMs)
|
||||
)
|
||||
|
||||
const waitForSocketOpen = async() => {
|
||||
if(ws.readyState === ws.OPEN) return
|
||||
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
let onOpen: () => void
|
||||
let onClose: (err: Error) => void
|
||||
await new Promise((resolve, reject) => {
|
||||
onOpen = () => resolve(undefined)
|
||||
onClose = reject
|
||||
ws.on('open', onOpen)
|
||||
ws.on('close', onClose)
|
||||
ws.on('error', onClose)
|
||||
})
|
||||
.finally(() => {
|
||||
ws.off('open', onOpen)
|
||||
ws.off('close', onClose)
|
||||
ws.off('error', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
ws.on('message', onMessageRecieved)
|
||||
ws.on('open', () => {
|
||||
startKeepAliveRequest()
|
||||
logger.info('Opened WS connection to WhatsApp Web')
|
||||
})
|
||||
ws.on('error', end)
|
||||
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost })))
|
||||
|
||||
ws.on(PHONE_CONNECTION_CB, json => {
|
||||
if (!json[1]) {
|
||||
end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost }))
|
||||
logger.info('Connection terminated by phone, closing...')
|
||||
} else {
|
||||
phoneConnectionChanged(true)
|
||||
}
|
||||
})
|
||||
ws.on('CB:Cmd,type:disconnect', json => {
|
||||
const {kind} = json[1]
|
||||
let reason: DisconnectReason
|
||||
switch(kind) {
|
||||
case 'replaced':
|
||||
reason = DisconnectReason.connectionReplaced
|
||||
break
|
||||
default:
|
||||
reason = DisconnectReason.connectionLost
|
||||
break
|
||||
}
|
||||
end(new Boom(
|
||||
`Connection terminated by server: "${kind || 'unknown'}"`,
|
||||
{ statusCode: reason }
|
||||
))
|
||||
})
|
||||
|
||||
return {
|
||||
ws,
|
||||
updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info,
|
||||
waitForSocketOpen,
|
||||
sendRawMessage,
|
||||
sendMessage,
|
||||
generateMessageTag,
|
||||
waitForMessage,
|
||||
query,
|
||||
/** Generic function for action, set queries */
|
||||
setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => {
|
||||
const json = new BinaryNode('action', { epoch: epoch.toString(), type: 'set' }, nodes)
|
||||
return query({
|
||||
json,
|
||||
binaryTag,
|
||||
tag,
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
}) as Promise<{ status: number }>
|
||||
},
|
||||
currentEpoch: () => epoch,
|
||||
end
|
||||
}
|
||||
}
|
||||
export type Socket = ReturnType<typeof makeSocket>
|
||||
@@ -1,6 +1,6 @@
|
||||
import P from "pino"
|
||||
import type { MediaType, SocketConfig } from "../Types"
|
||||
import { Browsers } from "../Utils/generics"
|
||||
import { Browsers } from "../Utils"
|
||||
|
||||
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
||||
|
||||
@@ -13,35 +13,32 @@ export const PHONE_CONNECTION_CB = 'CB:Pong'
|
||||
|
||||
export const WA_DEFAULT_EPHEMERAL = 7*24*60*60
|
||||
|
||||
export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
|
||||
export const NOISE_WA_HEADER = new Uint8Array([87, 65, 5, 2]) // last is "DICT_VERSION"
|
||||
|
||||
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
|
||||
export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi
|
||||
|
||||
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
version: [2, 2130, 9],
|
||||
version: [2, 2136, 9],
|
||||
browser: Browsers.baileys('Chrome'),
|
||||
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws',
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||
connectTimeoutMs: 20_000,
|
||||
keepAliveIntervalMs: 25_000,
|
||||
phoneResponseTimeMs: 15_000,
|
||||
connectTimeoutMs: 30_000,
|
||||
expectResponseTimeout: 12_000,
|
||||
logger: P().child({ class: 'baileys' }),
|
||||
phoneConnectionChanged: () => { },
|
||||
maxRetries: 5,
|
||||
connectCooldownMs: 2500,
|
||||
pendingRequestTimeoutMs: undefined,
|
||||
reconnectMode: 'on-connection-error',
|
||||
maxQRCodes: Infinity,
|
||||
printQRInTerminal: false,
|
||||
}
|
||||
|
||||
|
||||
export const MEDIA_PATH_MAP: { [T in MediaType]: string } = {
|
||||
image: '/mms/image',
|
||||
video: '/mms/video',
|
||||
document: '/mms/document',
|
||||
audio: '/mms/audio',
|
||||
sticker: '/mms/image',
|
||||
history: ''
|
||||
}
|
||||
|
||||
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
|
||||
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
|
||||
|
||||
export const KEY_BUNDLE_TYPE = ''
|
||||
430
src/Socket/chats.ts
Normal file
430
src/Socket/chats.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { decodeSyncdPatch, encodeSyncdPatch } from "../Utils/chat-utils";
|
||||
import { SocketConfig, WAPresence, PresenceData, Chat, ChatModification, WAMediaUpload } from "../Types";
|
||||
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, S_WHATSAPP_NET } from "../WABinary";
|
||||
import { makeSocket } from "./socket";
|
||||
import { proto } from '../../WAProto'
|
||||
import { toNumber } from "../Utils/generics";
|
||||
import { compressImage, generateProfilePicture } from "..";
|
||||
|
||||
export const makeChatsSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws,
|
||||
authState,
|
||||
generateMessageTag,
|
||||
sendNode,
|
||||
query
|
||||
} = sock
|
||||
|
||||
const interactiveQuery = async(userNodes: BinaryNode[], queryNode: BinaryNode) => {
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'get',
|
||||
xmlns: 'usync',
|
||||
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'usync',
|
||||
attrs: {
|
||||
sid: generateMessageTag(),
|
||||
mode: 'query',
|
||||
last: 'true',
|
||||
index: '0',
|
||||
context: 'interactive',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'query',
|
||||
attrs: { },
|
||||
content: [ queryNode ]
|
||||
},
|
||||
{
|
||||
tag: 'list',
|
||||
attrs: { },
|
||||
content: userNodes
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
const usyncNode = getBinaryNodeChild(result, 'usync')
|
||||
const listNode = getBinaryNodeChild(usyncNode, 'list')
|
||||
const users = getBinaryNodeChildren(listNode, 'user')
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
const onWhatsApp = async(...jids: string[]) => {
|
||||
const results = await interactiveQuery(
|
||||
[
|
||||
{
|
||||
tag: 'user',
|
||||
attrs: { },
|
||||
content: jids.map(
|
||||
jid => ({
|
||||
tag: 'contact',
|
||||
attrs: { },
|
||||
content: `+${jid}`
|
||||
})
|
||||
)
|
||||
}
|
||||
],
|
||||
{ tag: 'contact', attrs: { } }
|
||||
)
|
||||
|
||||
return results.map(user => {
|
||||
const contact = getBinaryNodeChild(user, 'contact')
|
||||
return { exists: contact.attrs.type === 'in', jid: user.attrs.jid }
|
||||
}).filter(item => item.exists)
|
||||
}
|
||||
|
||||
const fetchStatus = async(jid: string) => {
|
||||
const [result] = await interactiveQuery(
|
||||
[{ tag: 'user', attrs: { jid } }],
|
||||
{ tag: 'status', attrs: { } }
|
||||
)
|
||||
if(result) {
|
||||
const status = getBinaryNodeChild(result, 'status')
|
||||
return {
|
||||
status: status.content!.toString(),
|
||||
setAt: new Date(+status.attrs.t * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfilePicture = async(jid: string, content: WAMediaUpload) => {
|
||||
const { img } = await generateProfilePicture('url' in content ? content.url.toString() : content)
|
||||
await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: jidNormalizedUser(jid),
|
||||
type: 'set',
|
||||
xmlns: 'w:profile:picture'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'picture',
|
||||
attrs: { type: 'image' },
|
||||
content: img
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const fetchBlocklist = async() => {
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
xmlns: 'blocklist',
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'get'
|
||||
}
|
||||
})
|
||||
console.log('blocklist', result)
|
||||
}
|
||||
|
||||
const updateBlockStatus = async(jid: string, action: 'block' | 'unblock') => {
|
||||
await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'set'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'item',
|
||||
attrs: {
|
||||
action,
|
||||
jid
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const fetchPrivacySettings = async() => {
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
xmlns: 'privacy',
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'get'
|
||||
}
|
||||
})
|
||||
console.log('privacy', result)
|
||||
}
|
||||
|
||||
const updateAccountSyncTimestamp = async() => {
|
||||
await sendNode({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'set',
|
||||
xmlns: 'urn:xmpp:whatsapp:dirty',
|
||||
id: generateMessageTag(),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'clean',
|
||||
attrs: { }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const collectionSync = async() => {
|
||||
const COLLECTIONS = ['critical_block', 'critical_unblock_low', 'regular_low', 'regular_high']
|
||||
await sendNode({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
xmlns: 'w:sync:app:state',
|
||||
type: 'set',
|
||||
id: generateMessageTag(),
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'sync',
|
||||
attrs: { },
|
||||
content: COLLECTIONS.map(
|
||||
name => ({
|
||||
tag: 'collection',
|
||||
attrs: { name, version: '0', return_snapshot: 'true' }
|
||||
})
|
||||
)
|
||||
}
|
||||
]
|
||||
})
|
||||
logger.info('synced collection')
|
||||
}
|
||||
|
||||
const profilePictureUrl = async(jid: string) => {
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: jid,
|
||||
type: 'get',
|
||||
xmlns: 'w:profile:picture'
|
||||
},
|
||||
content: [
|
||||
{ tag: 'picture', attrs: { type: 'preview', query: 'url' } }
|
||||
]
|
||||
})
|
||||
const child = getBinaryNodeChild(result, 'picture')
|
||||
return child?.attrs?.url
|
||||
}
|
||||
|
||||
const sendPresenceUpdate = async(type: WAPresence, toJid?: string) => {
|
||||
if(type === 'available' || type === 'unavailable') {
|
||||
await sendNode({
|
||||
tag: 'presence',
|
||||
attrs: {
|
||||
name: authState.creds.me!.name,
|
||||
type
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await sendNode({
|
||||
tag: 'chatstate',
|
||||
attrs: {
|
||||
from: authState.creds.me!.id!,
|
||||
to: toJid,
|
||||
},
|
||||
content: [
|
||||
{ tag: type, attrs: { } }
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const presenceSubscribe = (toJid: string) => (
|
||||
sendNode({
|
||||
tag: 'presence',
|
||||
attrs: {
|
||||
to: toJid,
|
||||
id: generateMessageTag(),
|
||||
type: 'subscribe'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => {
|
||||
let presence: PresenceData
|
||||
const jid = attrs.from
|
||||
const participant = attrs.participant || attrs.from
|
||||
if(tag === 'presence') {
|
||||
presence = {
|
||||
lastKnownPresence: attrs.type === 'unavailable' ? 'unavailable' : 'available',
|
||||
lastSeen: attrs.t ? +attrs.t : undefined
|
||||
}
|
||||
} else if(Array.isArray(content)) {
|
||||
const [firstChild] = content
|
||||
let type = firstChild.tag as WAPresence
|
||||
if(type === 'paused') {
|
||||
type = 'available'
|
||||
}
|
||||
presence = { lastKnownPresence: type }
|
||||
} else {
|
||||
logger.error({ tag, attrs, content }, 'recv invalid presence node')
|
||||
}
|
||||
if(presence) {
|
||||
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
|
||||
}
|
||||
}
|
||||
|
||||
const processSyncActions = (actions: { action: proto.ISyncActionValue, index: [string, string] }[]) => {
|
||||
const updates: Partial<Chat>[] = []
|
||||
for(const { action, index: [_, id] } of actions) {
|
||||
const update: Partial<Chat> = { id }
|
||||
if(action?.muteAction) {
|
||||
update.mute = action.muteAction?.muted ?
|
||||
toNumber(action.muteAction!.muteEndTimestamp!) :
|
||||
undefined
|
||||
} else if(action?.archiveChatAction) {
|
||||
update.archive = !!action.archiveChatAction?.archived
|
||||
} else if(action?.markChatAsReadAction) {
|
||||
update.unreadCount = !!action.markChatAsReadAction?.read ? 0 : -1
|
||||
} else if(action?.clearChatAction) {
|
||||
console.log(action.clearChatAction)
|
||||
} else if(action?.contactAction) {
|
||||
ev.emit('contacts.update', [{ id, name: action.contactAction!.fullName }])
|
||||
} else if(action?.pushNameSetting) {
|
||||
authState.creds.me!.name = action?.pushNameSetting?.name!
|
||||
ev.emit('auth-state.update', authState)
|
||||
} else {
|
||||
logger.warn({ action, id }, 'unprocessable update')
|
||||
}
|
||||
updates.push(update)
|
||||
}
|
||||
ev.emit('chats.update', updates)
|
||||
}
|
||||
|
||||
const patchChat = async(
|
||||
jid: string,
|
||||
modification: ChatModification
|
||||
) => {
|
||||
const patch = encodeSyncdPatch(modification, { remoteJid: jid }, authState)
|
||||
const type = 'regular_high'
|
||||
const ver = authState.creds.appStateVersion![type] || 0
|
||||
const node: BinaryNode = {
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'set',
|
||||
xmlns: 'w:sync:app:state'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'patch',
|
||||
attrs: {
|
||||
name: type,
|
||||
version: (ver+1).toString(),
|
||||
return_snapshot: 'false'
|
||||
},
|
||||
content: proto.SyncdPatch.encode(patch).finish()
|
||||
}
|
||||
]
|
||||
}
|
||||
await query(node)
|
||||
|
||||
authState.creds.appStateVersion![type] += 1
|
||||
ev.emit('auth-state.update', authState)
|
||||
}
|
||||
|
||||
const resyncState = async(name: 'regular_high' | 'regular_low' = 'regular_high') => {
|
||||
authState.creds.appStateVersion = authState.creds.appStateVersion || {
|
||||
regular_high: 0,
|
||||
regular_low: 0,
|
||||
critical_unblock_low: 0,
|
||||
critical_block: 0
|
||||
}
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
type: 'set',
|
||||
xmlns: 'w:sync:app:state',
|
||||
to: S_WHATSAPP_NET
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'sync',
|
||||
attrs: { },
|
||||
content: [
|
||||
{
|
||||
tag: 'collection',
|
||||
attrs: {
|
||||
name,
|
||||
version: authState.creds.appStateVersion[name].toString(),
|
||||
return_snapshot: 'false'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const syncNode = getBinaryNodeChild(result, 'sync')
|
||||
const collectionNode = getBinaryNodeChild(syncNode, 'collection')
|
||||
const patchesNode = getBinaryNodeChild(collectionNode, 'patches')
|
||||
|
||||
const patches = getBinaryNodeChildren(patchesNode, 'patch')
|
||||
const successfulMutations = patches.flatMap(({ content }) => {
|
||||
if(content) {
|
||||
const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
|
||||
const version = toNumber(syncd.version!.version!)
|
||||
if(version) {
|
||||
authState.creds.appStateVersion[name] = Math.max(version, authState.creds.appStateVersion[name])
|
||||
}
|
||||
const { mutations, failures } = decodeSyncdPatch(syncd, authState)
|
||||
if(failures.length) {
|
||||
logger.info(
|
||||
{ failures: failures.map(f => ({ trace: f.stack, data: f.data })) },
|
||||
'failed to decode'
|
||||
)
|
||||
}
|
||||
return mutations
|
||||
}
|
||||
return []
|
||||
})
|
||||
processSyncActions(successfulMutations)
|
||||
ev.emit('auth-state.update', authState)
|
||||
}
|
||||
|
||||
ws.on('CB:presence', handlePresenceUpdate)
|
||||
ws.on('CB:chatstate', handlePresenceUpdate)
|
||||
|
||||
ws.on('CB:notification,type:server_sync', (node: BinaryNode) => {
|
||||
const update = getBinaryNodeChild(node, 'collection')
|
||||
if(update) {
|
||||
resyncState(update.attrs.name as any)
|
||||
}
|
||||
})
|
||||
|
||||
ev.on('connection.update', ({ connection }) => {
|
||||
if(connection === 'open') {
|
||||
sendPresenceUpdate('available')
|
||||
fetchBlocklist()
|
||||
fetchPrivacySettings()
|
||||
//collectionSync()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...sock,
|
||||
patchChat,
|
||||
sendPresenceUpdate,
|
||||
presenceSubscribe,
|
||||
profilePictureUrl,
|
||||
onWhatsApp,
|
||||
fetchBlocklist,
|
||||
fetchPrivacySettings,
|
||||
fetchStatus,
|
||||
updateProfilePicture,
|
||||
updateBlockStatus
|
||||
}
|
||||
}
|
||||
149
src/Socket/groups.ts
Normal file
149
src/Socket/groups.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { generateMessageID } from "../Utils";
|
||||
import { SocketConfig, GroupMetadata, ParticipantAction } from "../Types";
|
||||
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidDecode, jidEncode } from "../WABinary";
|
||||
import { makeChatsSocket } from "./chats";
|
||||
|
||||
const extractGroupMetadata = (result: BinaryNode) => {
|
||||
const group = getBinaryNodeChild(result, 'group')
|
||||
const descChild = getBinaryNodeChild(group, 'description')
|
||||
let desc: string | undefined
|
||||
let descId: string | undefined
|
||||
if(descChild) {
|
||||
desc = getBinaryNodeChild(descChild, 'body')?.content as string
|
||||
descId = descChild.attrs.id
|
||||
}
|
||||
|
||||
const metadata: GroupMetadata = {
|
||||
id: jidEncode(jidDecode(group.attrs.id).user, 'g.us'),
|
||||
subject: group.attrs.subject,
|
||||
creation: +group.attrs.creation,
|
||||
owner: group.attrs.creator,
|
||||
desc,
|
||||
descId,
|
||||
restrict: !!getBinaryNodeChild(result, 'locked') ? 'true' : 'false',
|
||||
announce: !!getBinaryNodeChild(result, 'announcement') ? 'true' : 'false',
|
||||
participants: getBinaryNodeChildren(group, 'participant').map(
|
||||
({ attrs }) => {
|
||||
return {
|
||||
id: attrs.jid,
|
||||
admin: attrs.type || null as any,
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
export const makeGroupsSocket = (config: SocketConfig) => {
|
||||
const sock = makeChatsSocket(config)
|
||||
const { query } = sock
|
||||
|
||||
const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => (
|
||||
query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
type,
|
||||
xmlns: 'w:g2',
|
||||
to: jid,
|
||||
},
|
||||
content
|
||||
})
|
||||
)
|
||||
|
||||
const groupMetadata = async(jid: string) => {
|
||||
const result = await groupQuery(
|
||||
jid,
|
||||
'get',
|
||||
[ { tag: 'query', attrs: { request: 'interactive' } } ]
|
||||
)
|
||||
return extractGroupMetadata(result)
|
||||
}
|
||||
|
||||
return {
|
||||
...sock,
|
||||
groupMetadata,
|
||||
groupCreate: async(subject: string, participants: string[]) => {
|
||||
const key = generateMessageID()
|
||||
const result = await groupQuery(
|
||||
'@g.us',
|
||||
'set',
|
||||
[
|
||||
{
|
||||
tag: 'create',
|
||||
attrs: {
|
||||
subject,
|
||||
key
|
||||
},
|
||||
content: participants.map(jid => ({
|
||||
tag: 'participant',
|
||||
attrs: { jid }
|
||||
}))
|
||||
}
|
||||
]
|
||||
)
|
||||
return extractGroupMetadata(result)
|
||||
},
|
||||
groupLeave: async(jid: string) => {
|
||||
await groupQuery(
|
||||
'@g.us',
|
||||
'set',
|
||||
[
|
||||
{
|
||||
tag: 'leave',
|
||||
attrs: { },
|
||||
content: [
|
||||
{ tag: 'group', attrs: { jid } }
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
},
|
||||
groupUpdateSubject: async(jid: string, subject: string) => {
|
||||
await groupQuery(
|
||||
jid,
|
||||
'set',
|
||||
[
|
||||
{
|
||||
tag: 'subject',
|
||||
attrs: { },
|
||||
content: Buffer.from(subject, 'utf-8')
|
||||
}
|
||||
]
|
||||
)
|
||||
},
|
||||
groupParticipantsUpdate: async(
|
||||
jid: string,
|
||||
participants: string[],
|
||||
action: ParticipantAction
|
||||
) => {
|
||||
const result = await groupQuery(
|
||||
jid,
|
||||
'set',
|
||||
participants.map(
|
||||
jid => ({
|
||||
tag: action,
|
||||
attrs: { },
|
||||
content: [{ tag: 'participant', attrs: { jid } }]
|
||||
})
|
||||
)
|
||||
)
|
||||
const node = getBinaryNodeChild(result, action)
|
||||
const participantsAffected = getBinaryNodeChildren(node!, 'participant')
|
||||
return participantsAffected.map(p => p.attrs.jid)
|
||||
},
|
||||
groupInviteCode: async(jid: string) => {
|
||||
const result = await groupQuery(jid, 'get', [{ tag: 'invite', attrs: {} }])
|
||||
const inviteNode = getBinaryNodeChild(result, 'invite')
|
||||
return inviteNode.attrs.code
|
||||
},
|
||||
groupToggleEphemeral: async(jid: string, ephemeralExpiration: number) => {
|
||||
const content: BinaryNode = ephemeralExpiration ?
|
||||
{ tag: 'ephemeral', attrs: { ephemeral: ephemeralExpiration.toString() } } :
|
||||
{ tag: 'not_ephemeral', attrs: { } }
|
||||
await groupQuery(jid, 'set', [content])
|
||||
},
|
||||
groupSettingUpdate: async(jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked') => {
|
||||
await groupQuery(jid, 'set', [ { tag: setting, attrs: { } } ])
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Socket/index.ts
Normal file
13
src/Socket/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { SocketConfig } from '../Types'
|
||||
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
|
||||
import { makeMessagesSocket as _makeSocket } from './messages-send'
|
||||
|
||||
// export the last socket layer
|
||||
const makeWASocket = (config: Partial<SocketConfig>) => (
|
||||
_makeSocket({
|
||||
...DEFAULT_CONNECTION_CONFIG,
|
||||
...config
|
||||
})
|
||||
)
|
||||
|
||||
export default makeWASocket
|
||||
437
src/Socket/messages-recv.ts
Normal file
437
src/Socket/messages-recv.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
|
||||
import { makeGroupsSocket } from "./groups"
|
||||
import { SocketConfig, WAMessageStubType, ParticipantAction, Chat, GroupMetadata } from "../Types"
|
||||
import { decodeMessageStanza, encodeBigEndian, toNumber, whatsappID } from "../Utils"
|
||||
import { BinaryNode, jidDecode, jidEncode, isJidStatusBroadcast, S_WHATSAPP_NET, areJidsSameUser, getBinaryNodeChildren, getBinaryNodeChild } from '../WABinary'
|
||||
import { downloadIfHistory } from '../Utils/history'
|
||||
import { proto } from "../../WAProto"
|
||||
import { generateSignalPubKey, xmppPreKey, xmppSignedPreKey } from "../Utils/signal"
|
||||
import { KEY_BUNDLE_TYPE } from "../Defaults"
|
||||
|
||||
export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeGroupsSocket(config)
|
||||
const {
|
||||
ev,
|
||||
authState,
|
||||
ws,
|
||||
assertingPreKeys,
|
||||
sendNode,
|
||||
} = sock
|
||||
|
||||
const sendMessageAck = async({ attrs }: BinaryNode) => {
|
||||
const isGroup = !!attrs.participant
|
||||
const { user: meUser } = jidDecode(authState.creds.me!.id!)
|
||||
const stanza: BinaryNode = {
|
||||
tag: 'ack',
|
||||
attrs: {
|
||||
class: 'receipt',
|
||||
id: attrs.id,
|
||||
to: isGroup ? attrs.from : authState.creds.me!.id,
|
||||
}
|
||||
}
|
||||
if(isGroup) {
|
||||
stanza.attrs.participant = jidEncode(meUser, 's.whatsapp.net')
|
||||
}
|
||||
await sendNode(stanza)
|
||||
}
|
||||
|
||||
const sendRetryRequest = async(node: BinaryNode) => {
|
||||
const retryCount = +(node.attrs.retryCount || 0) + 1
|
||||
const isGroup = !!node.attrs.participant
|
||||
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
|
||||
|
||||
const deviceIdentity = proto.ADVSignedDeviceIdentity.encode(account).finish()
|
||||
await assertingPreKeys(1, async preKeys => {
|
||||
const [keyId] = Object.keys(preKeys)
|
||||
const key = preKeys[+keyId]
|
||||
|
||||
const decFrom = node.attrs.from ? jidDecode(node.attrs.from) : undefined
|
||||
const receipt: BinaryNode = {
|
||||
tag: 'receipt',
|
||||
attrs: {
|
||||
id: node.attrs.id,
|
||||
type: 'retry',
|
||||
to: isGroup ? node.attrs.from : jidEncode(decFrom!.user, 's.whatsapp.net', decFrom!.device, 0)
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'retry',
|
||||
attrs: {
|
||||
count: retryCount.toString(), id: node.attrs.id,
|
||||
t: node.attrs.t,
|
||||
v: '1'
|
||||
}
|
||||
},
|
||||
{
|
||||
tag: 'registration',
|
||||
attrs: { },
|
||||
content: encodeBigEndian(authState.creds.registrationId)
|
||||
}
|
||||
]
|
||||
}
|
||||
if(node.attrs.recipient) {
|
||||
receipt.attrs.recipient = node.attrs.recipient
|
||||
}
|
||||
if(node.attrs.participant) {
|
||||
receipt.attrs.participant = node.attrs.participant
|
||||
}
|
||||
if(retryCount > 1) {
|
||||
const exec = generateSignalPubKey(Buffer.from(KEY_BUNDLE_TYPE)).slice(0, 1);
|
||||
|
||||
(node.content! as BinaryNode[]).push({
|
||||
tag: 'keys',
|
||||
attrs: { },
|
||||
content: [
|
||||
{ tag: 'type', attrs: { }, content: exec },
|
||||
{ tag: 'identity', attrs: { }, content: identityKey.public },
|
||||
xmppPreKey(key, +keyId),
|
||||
xmppSignedPreKey(signedPreKey),
|
||||
{ tag: 'device-identity', attrs: { }, content: deviceIdentity }
|
||||
]
|
||||
})
|
||||
}
|
||||
await sendNode(node)
|
||||
|
||||
logger.info({ msgId: node.attrs.id, retryCount }, 'sent retry receipt')
|
||||
|
||||
ev.emit('auth-state.update', authState)
|
||||
})
|
||||
}
|
||||
|
||||
const processMessage = (message: proto.IWebMessageInfo, chatUpdate: Partial<Chat>) => {
|
||||
const protocolMsg = message.message?.protocolMessage
|
||||
if(protocolMsg) {
|
||||
switch(protocolMsg.type) {
|
||||
case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE:
|
||||
const newKeys = JSON.parse(JSON.stringify(protocolMsg.appStateSyncKeyShare!.keys))
|
||||
authState.creds.appStateSyncKeys = [
|
||||
...(authState.creds.appStateSyncKeys || []),
|
||||
...newKeys
|
||||
]
|
||||
ev.emit('auth-state.update', authState)
|
||||
break
|
||||
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
|
||||
ev.emit('messages.update', [
|
||||
{
|
||||
key: protocolMsg.key,
|
||||
update: { message: null, messageStubType: 1, key: message.key }
|
||||
}
|
||||
])
|
||||
break
|
||||
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
|
||||
chatUpdate.ephemeralSettingTimestamp = toNumber(message.messageTimestamp)
|
||||
chatUpdate.ephemeralExpiration = protocolMsg.ephemeralExpiration
|
||||
break
|
||||
}
|
||||
} else if(message.messageStubType) {
|
||||
const meJid = authState.creds.me!.id
|
||||
const jid = message.key!.remoteJid!
|
||||
//let actor = whatsappID (message.participant)
|
||||
let participants: string[]
|
||||
const emitParticipantsUpdate = (action: ParticipantAction) => (
|
||||
ev.emit('group-participants.update', { id: jid, participants, action })
|
||||
)
|
||||
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
|
||||
ev.emit('groups.update', [ { id: jid, ...update } ])
|
||||
}
|
||||
|
||||
switch (message.messageStubType) {
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
|
||||
participants = message.messageStubParameters.map(whatsappID)
|
||||
emitParticipantsUpdate('remove')
|
||||
// mark the chat read only if you left the group
|
||||
if (participants.includes(meJid)) {
|
||||
chatUpdate.readOnly = true
|
||||
}
|
||||
break
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
|
||||
participants = message.messageStubParameters.map(whatsappID)
|
||||
if (participants.includes(meJid)) {
|
||||
chatUpdate.readOnly = false
|
||||
}
|
||||
emitParticipantsUpdate('add')
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
|
||||
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
|
||||
emitGroupUpdate({ announce })
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
|
||||
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
|
||||
emitGroupUpdate({ restrict })
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
|
||||
case WAMessageStubType.GROUP_CREATE:
|
||||
chatUpdate.name = message.messageStubParameters[0]
|
||||
emitGroupUpdate({ subject: chatUpdate.name })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processHistoryMessage = (item: proto.HistorySync) => {
|
||||
switch(item.syncType) {
|
||||
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_BOOTSTRAP:
|
||||
const messages: proto.IWebMessageInfo[] = []
|
||||
const chats = item.conversations!.map(
|
||||
c => {
|
||||
const chat: Chat = { ...c }
|
||||
//@ts-expect-error
|
||||
delete chat.messages
|
||||
for(const item of c.messages || []) {
|
||||
messages.push(item.message)
|
||||
}
|
||||
return chat
|
||||
}
|
||||
)
|
||||
ev.emit('chats.set', { chats, messages })
|
||||
break
|
||||
case proto.HistorySync.HistorySyncHistorySyncType.PUSH_NAME:
|
||||
const contacts = item.pushnames.map(
|
||||
p => ({ notify: p.pushname, id: p.id })
|
||||
)
|
||||
ev.emit('contacts.upsert', contacts)
|
||||
break
|
||||
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_STATUS_V3:
|
||||
// TODO
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const processNotification = (node: BinaryNode): Partial<proto.IWebMessageInfo> => {
|
||||
const result: Partial<proto.IWebMessageInfo> = { }
|
||||
const child = (node.content as BinaryNode[])?.[0]
|
||||
|
||||
if(node.attrs.type === 'w:gp2') {
|
||||
switch(child?.tag) {
|
||||
case 'ephemeral':
|
||||
case 'not_ephemeral':
|
||||
result.message = {
|
||||
protocolMessage: {
|
||||
type: proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
|
||||
ephemeralExpiration: +(child.attrs.expiration || 0)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'promote':
|
||||
case 'demote':
|
||||
case 'remove':
|
||||
case 'add':
|
||||
case 'leave':
|
||||
const stubType = `GROUP_PARTICIPANT_${child.tag!.toUpperCase()}`
|
||||
result.messageStubType = WAMessageStubType[stubType]
|
||||
result.messageStubParameters = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid)
|
||||
break
|
||||
case 'subject':
|
||||
result.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT
|
||||
result.messageStubParameters = [ child.attrs.subject ]
|
||||
break
|
||||
case 'announcement':
|
||||
case 'not_announcement':
|
||||
result.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE
|
||||
result.messageStubParameters = [ (child.tag === 'announcement').toString() ]
|
||||
break
|
||||
case 'locked':
|
||||
case 'unlocked':
|
||||
result.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
|
||||
result.messageStubParameters = [ (child.tag === 'locked').toString() ]
|
||||
break
|
||||
|
||||
}
|
||||
} else {
|
||||
switch(child.tag) {
|
||||
case 'count':
|
||||
if(child.attrs.value === '0') {
|
||||
logger.info('recv all pending notifications')
|
||||
ev.emit('connection.update', { receivedPendingNotifications: true })
|
||||
}
|
||||
break
|
||||
case 'devices':
|
||||
const devices = getBinaryNodeChildren(child, 'device')
|
||||
if(areJidsSameUser(child.attrs.jid, authState.creds!.me!.id)) {
|
||||
const deviceJids = devices.map(d => d.attrs.jid)
|
||||
logger.info({ deviceJids }, 'got my own devices')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if(Object.keys(result).length) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
// recv a message
|
||||
ws.on('CB:message', async(stanza: BinaryNode) => {
|
||||
const dec = await decodeMessageStanza(stanza, authState)
|
||||
const fullMessages: proto.IWebMessageInfo[] = []
|
||||
for(const msg of dec.successes) {
|
||||
const { attrs } = stanza
|
||||
const isGroup = !!stanza.attrs.participant
|
||||
const sender = (attrs.participant || attrs.from)?.toString()
|
||||
const isMe = areJidsSameUser(sender, authState.creds.me!.id)
|
||||
|
||||
await sendMessageAck(stanza)
|
||||
|
||||
logger.debug({ msgId: dec.msgId, sender }, 'send message ack')
|
||||
|
||||
// send delivery receipt
|
||||
let recpAttrs: { [_: string]: any }
|
||||
if(isMe) {
|
||||
recpAttrs = {
|
||||
type: 'sender',
|
||||
id: stanza.attrs.id,
|
||||
to: stanza.attrs.from,
|
||||
}
|
||||
if(isGroup) {
|
||||
recpAttrs.participant = stanza.attrs.participant
|
||||
} else {
|
||||
recpAttrs.recipient = stanza.attrs.recipient
|
||||
}
|
||||
} else {
|
||||
const isStatus = isJidStatusBroadcast(stanza.attrs.from)
|
||||
recpAttrs = {
|
||||
//type: 'inactive',
|
||||
id: stanza.attrs.id,
|
||||
to: dec.chatId,
|
||||
}
|
||||
if(isGroup || isStatus) {
|
||||
recpAttrs.participant = stanza.attrs.participant
|
||||
}
|
||||
}
|
||||
await sendNode({ tag: 'receipt', attrs: recpAttrs })
|
||||
|
||||
logger.debug({ msgId: dec.msgId }, 'send message receipt')
|
||||
|
||||
const possibleHistory = downloadIfHistory(msg)
|
||||
if(possibleHistory) {
|
||||
const history = await possibleHistory
|
||||
logger.info({ msgId: dec.msgId, type: history.syncType }, 'recv history')
|
||||
|
||||
processHistoryMessage(history)
|
||||
} else {
|
||||
const message = msg.deviceSentMessage?.message || msg
|
||||
fullMessages.push({
|
||||
key: {
|
||||
remoteJid: dec.chatId,
|
||||
fromMe: isMe,
|
||||
id: dec.msgId,
|
||||
participant: dec.participant
|
||||
},
|
||||
message,
|
||||
status: isMe ? proto.WebMessageInfo.WebMessageInfoStatus.SERVER_ACK : null,
|
||||
messageTimestamp: dec.timestamp,
|
||||
pushName: dec.pushname
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if(dec.successes.length) {
|
||||
ev.emit('auth-state.update', authState)
|
||||
if(fullMessages.length) {
|
||||
ev.emit(
|
||||
'messages.upsert',
|
||||
{
|
||||
messages: fullMessages.map(m => proto.WebMessageInfo.fromObject(m)),
|
||||
type: stanza.attrs.offline ? 'append' : 'notify'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for(const { error } of dec.failures) {
|
||||
logger.error(
|
||||
{ msgId: dec.msgId, trace: error.stack, data: error.data },
|
||||
'failure in decrypting message'
|
||||
)
|
||||
await sendRetryRequest(stanza)
|
||||
}
|
||||
})
|
||||
|
||||
ws.on('CB:ack,class:message', async(node: BinaryNode) => {
|
||||
await sendNode({
|
||||
tag: 'ack',
|
||||
attrs: {
|
||||
class: 'receipt',
|
||||
id: node.attrs.id,
|
||||
from: node.attrs.from
|
||||
}
|
||||
})
|
||||
logger.debug({ attrs: node.attrs }, 'sending receipt for ack')
|
||||
})
|
||||
|
||||
const handleReceipt = ({ attrs, content }: BinaryNode) => {
|
||||
const sender = attrs.participant || attrs.from
|
||||
const status = attrs.type === 'read' ? proto.WebMessageInfo.WebMessageInfoStatus.READ : proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK
|
||||
const ids = [attrs.id]
|
||||
if(Array.isArray(content)) {
|
||||
const items = getBinaryNodeChildren(content[0], 'item')
|
||||
ids.push(...items.map(i => i.attrs.id))
|
||||
}
|
||||
|
||||
ev.emit('messages.update', ids.map(id => ({
|
||||
key: {
|
||||
remoteJid: attrs.from,
|
||||
id: id,
|
||||
fromMe: areJidsSameUser(sender, authState.creds.me!.id!),
|
||||
participant: attrs.participant
|
||||
},
|
||||
update: { status }
|
||||
})))
|
||||
}
|
||||
|
||||
ws.on('CB:receipt,type:read', handleReceipt)
|
||||
ws.on('CB:ack,class:receipt', handleReceipt)
|
||||
|
||||
ws.on('CB:notification', async(node: BinaryNode) => {
|
||||
const sendAck = async() => {
|
||||
await sendNode({
|
||||
tag: 'ack',
|
||||
attrs: {
|
||||
class: 'notification',
|
||||
id: node.attrs.id,
|
||||
type: node.attrs.type,
|
||||
to: node.attrs.from
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug({ msgId: node.attrs.id }, 'ack notification')
|
||||
}
|
||||
|
||||
await sendAck()
|
||||
|
||||
const msg = processNotification(node)
|
||||
if(msg) {
|
||||
const fromMe = areJidsSameUser(node.attrs.participant || node.attrs.from, authState.creds.me!.id)
|
||||
msg.key = {
|
||||
remoteJid: node.attrs.from,
|
||||
fromMe,
|
||||
participant: node.attrs.participant,
|
||||
id: node.attrs.id
|
||||
}
|
||||
msg.messageTimestamp = +node.attrs.t
|
||||
|
||||
const fullMsg = proto.WebMessageInfo.fromObject(msg)
|
||||
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
|
||||
}
|
||||
})
|
||||
|
||||
ev.on('messages.upsert', ({ messages }) => {
|
||||
const chat: Partial<Chat> = { id: messages[0].key.remoteJid }
|
||||
for(const msg of messages) {
|
||||
processMessage(msg, chat)
|
||||
if(!!msg.message && !msg.message!.protocolMessage) {
|
||||
chat.conversationTimestamp = toNumber(msg.messageTimestamp)
|
||||
if(!msg.key.fromMe) {
|
||||
chat.unreadCount = (chat.unreadCount || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if(Object.keys(chat).length > 1) {
|
||||
ev.emit('chats.update', [ chat ])
|
||||
}
|
||||
})
|
||||
|
||||
return sock
|
||||
}
|
||||
392
src/Socket/messages-send.ts
Normal file
392
src/Socket/messages-send.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
|
||||
import { makeMessagesRecvSocket } from "./messages-recv"
|
||||
import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction } from "../Types"
|
||||
import { encodeWAMessage, generateMessageID, generateWAMessage } from "../Utils"
|
||||
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, S_WHATSAPP_NET } from '../WABinary'
|
||||
import { proto } from "../../WAProto"
|
||||
import { encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESession } from "../Utils/signal"
|
||||
import { WA_DEFAULT_EPHEMERAL, DEFAULT_ORIGIN, MEDIA_PATH_MAP } from "../Defaults"
|
||||
import got from "got"
|
||||
import { Boom } from "@hapi/boom"
|
||||
|
||||
export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeMessagesRecvSocket(config)
|
||||
const {
|
||||
ev,
|
||||
authState,
|
||||
query,
|
||||
generateMessageTag,
|
||||
sendNode,
|
||||
groupMetadata,
|
||||
groupToggleEphemeral
|
||||
} = sock
|
||||
|
||||
let mediaConn: Promise<MediaConnInfo>
|
||||
const refreshMediaConn = async(forceGet = false) => {
|
||||
let media = await mediaConn
|
||||
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
|
||||
mediaConn = (async() => {
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
type: 'set',
|
||||
xmlns: 'w:m',
|
||||
to: S_WHATSAPP_NET,
|
||||
},
|
||||
content: [ { tag: 'media_conn', attrs: { } } ]
|
||||
})
|
||||
const mediaConnNode = getBinaryNodeChild(result, 'media_conn')
|
||||
const node: MediaConnInfo = {
|
||||
hosts: getBinaryNodeChildren(mediaConnNode, 'host').map(
|
||||
item => item.attrs as any
|
||||
),
|
||||
auth: mediaConnNode.attrs.auth,
|
||||
ttl: +mediaConnNode.attrs.ttl,
|
||||
fetchDate: new Date()
|
||||
}
|
||||
logger.debug('fetched media conn')
|
||||
return node
|
||||
})()
|
||||
}
|
||||
return mediaConn
|
||||
}
|
||||
|
||||
const sendReadReceipt = async(jid: string, participant: string | undefined, messageIds: string[]) => {
|
||||
const node: BinaryNode = {
|
||||
tag: 'receipt',
|
||||
attrs: {
|
||||
id: messageIds[0],
|
||||
t: Date.now().toString(),
|
||||
to: jid,
|
||||
type: 'read'
|
||||
},
|
||||
}
|
||||
if(participant) {
|
||||
node.attrs.participant = participant
|
||||
}
|
||||
messageIds = messageIds.slice(1)
|
||||
if(messageIds.length) {
|
||||
node.content = [
|
||||
{
|
||||
tag: 'list',
|
||||
attrs: { },
|
||||
content: messageIds.map(id => ({
|
||||
tag: 'item',
|
||||
attrs: { id }
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
logger.debug({ jid, messageIds }, 'reading messages')
|
||||
await sendNode(node)
|
||||
}
|
||||
|
||||
const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => {
|
||||
const users = jids.map<BinaryNode>(jid => ({ tag: 'user', attrs: { jid } }))
|
||||
const iq: BinaryNode = {
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'get',
|
||||
xmlns: 'usync',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'usync',
|
||||
attrs: {
|
||||
sid: generateMessageTag(),
|
||||
mode: 'query',
|
||||
last: 'true',
|
||||
index: '0',
|
||||
context: 'message',
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'query',
|
||||
attrs: { },
|
||||
content: [
|
||||
{
|
||||
tag: 'devices',
|
||||
attrs: { version: '2' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ tag: 'list', attrs: { }, content: users }
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = await query(iq)
|
||||
let resultJids = extractDeviceJids(result)
|
||||
if(ignoreZeroDevices) {
|
||||
resultJids = resultJids.filter(item => item.device !== 0)
|
||||
}
|
||||
|
||||
return resultJids
|
||||
}
|
||||
|
||||
const assertSession = async(jid: string, force: boolean) => {
|
||||
const addr = jidToSignalProtocolAddress(jid).toString()
|
||||
const session = await authState.keys.getSession(addr)
|
||||
if(!session || force) {
|
||||
logger.debug({ jid }, `fetching session`)
|
||||
const identity: BinaryNode = {
|
||||
tag: 'user',
|
||||
attrs: { jid, reason: 'identity' },
|
||||
}
|
||||
const result = await query({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
xmlns: 'encrypt',
|
||||
type: 'get',
|
||||
to: S_WHATSAPP_NET,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'key',
|
||||
attrs: { },
|
||||
content: [ identity ]
|
||||
}
|
||||
]
|
||||
})
|
||||
await parseAndInjectE2ESession(result, authState)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const createParticipantNode = async(jid: string, bytes: Buffer) => {
|
||||
await assertSession(jid, false)
|
||||
|
||||
const { type, ciphertext } = await encryptSignalProto(jid, bytes, authState)
|
||||
const node: BinaryNode = {
|
||||
tag: 'to',
|
||||
attrs: { jid },
|
||||
content: [{
|
||||
tag: 'enc',
|
||||
attrs: { v: '2', type },
|
||||
content: ciphertext
|
||||
}]
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
const relayMessage = async(jid: string, message: proto.IMessage, msgId?: string) => {
|
||||
const { user, server } = jidDecode(jid)
|
||||
const isGroup = server === 'g.us'
|
||||
msgId = msgId || generateMessageID()
|
||||
const encodedMsg = encodeWAMessage(message)
|
||||
const participants: BinaryNode[] = []
|
||||
let stanza: BinaryNode
|
||||
|
||||
const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net')
|
||||
|
||||
if(isGroup) {
|
||||
const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, authState)
|
||||
const groupData = await groupMetadata(jid)
|
||||
const participantsList = groupData.participants.map(p => p.id)
|
||||
const devices = await getUSyncDevices(participantsList, false)
|
||||
|
||||
logger.debug(`got ${devices.length} additional devices`)
|
||||
|
||||
const encSenderKeyMsg = encodeWAMessage({
|
||||
senderKeyDistributionMessage: {
|
||||
axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey,
|
||||
groupId: destinationJid
|
||||
}
|
||||
})
|
||||
|
||||
for(const {user, device, agent} of devices) {
|
||||
const jid = jidEncode(user, 's.whatsapp.net', device, agent)
|
||||
const participant = await createParticipantNode(jid, encSenderKeyMsg)
|
||||
participants.push(participant)
|
||||
}
|
||||
|
||||
const binaryNodeContent: BinaryNode[] = []
|
||||
if( // if there are some participants with whom the session has not been established
|
||||
// if there are, we overwrite the senderkey
|
||||
!!participants.find((p) => (
|
||||
!!(p.content as BinaryNode[]).find(({ attrs }) => attrs.type == 'pkmsg')
|
||||
))
|
||||
) {
|
||||
binaryNodeContent.push({
|
||||
tag: 'participants',
|
||||
attrs: { },
|
||||
content: participants
|
||||
})
|
||||
}
|
||||
|
||||
binaryNodeContent.push({
|
||||
tag: 'enc',
|
||||
attrs: { v: '2', type: 'skmsg' },
|
||||
content: ciphertext
|
||||
})
|
||||
|
||||
stanza = {
|
||||
tag: 'message',
|
||||
attrs: {
|
||||
id: msgId,
|
||||
type: 'text',
|
||||
to: destinationJid
|
||||
},
|
||||
content: binaryNodeContent
|
||||
}
|
||||
} else {
|
||||
const { user: meUser } = jidDecode(authState.creds.me!.id!)
|
||||
|
||||
const messageToMyself: proto.IMessage = {
|
||||
deviceSentMessage: {
|
||||
destinationJid,
|
||||
message
|
||||
}
|
||||
}
|
||||
const encodedMeMsg = encodeWAMessage(messageToMyself)
|
||||
|
||||
participants.push(
|
||||
await createParticipantNode(jidEncode(user, 's.whatsapp.net'), encodedMsg)
|
||||
)
|
||||
participants.push(
|
||||
await createParticipantNode(jidEncode(meUser, 's.whatsapp.net'), encodedMeMsg)
|
||||
)
|
||||
const devices = await getUSyncDevices([ authState.creds.me!.id!, jid ], true)
|
||||
|
||||
logger.debug(`got ${devices.length} additional devices`)
|
||||
|
||||
for(const { user, device, agent } of devices) {
|
||||
const isMe = user === meUser
|
||||
participants.push(
|
||||
await createParticipantNode(
|
||||
jidEncode(user, 's.whatsapp.net', device, agent),
|
||||
isMe ? encodedMeMsg : encodedMsg
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
stanza = {
|
||||
tag: 'message',
|
||||
attrs: {
|
||||
id: msgId,
|
||||
type: 'text',
|
||||
to: destinationJid
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'participants',
|
||||
attrs: { },
|
||||
content: participants
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const shouldHaveIdentity = !!participants.find((p) => (
|
||||
!!(p.content as BinaryNode[]).find(({ attrs }) => attrs.type == 'pkmsg')
|
||||
))
|
||||
|
||||
if(shouldHaveIdentity) {
|
||||
(stanza.content as BinaryNode[]).push({
|
||||
tag: 'device-identity',
|
||||
attrs: { },
|
||||
content: proto.ADVSignedDeviceIdentity.encode(authState.creds.account).finish()
|
||||
})
|
||||
}
|
||||
logger.debug({ msgId }, 'sending message')
|
||||
|
||||
await sendNode(stanza)
|
||||
|
||||
ev.emit('auth-state.update', authState)
|
||||
return msgId
|
||||
}
|
||||
|
||||
const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64 }) => {
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
let uploadInfo = await refreshMediaConn(false)
|
||||
|
||||
let mediaUrl: string
|
||||
for (let host of uploadInfo.hosts) {
|
||||
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
|
||||
const url = `https://${host.hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
|
||||
try {
|
||||
const {body: responseText} = await got.post(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Origin': DEFAULT_ORIGIN
|
||||
},
|
||||
agent: {
|
||||
https: config.agent
|
||||
},
|
||||
body: stream
|
||||
}
|
||||
)
|
||||
const result = JSON.parse(responseText)
|
||||
mediaUrl = result?.url
|
||||
|
||||
if (mediaUrl) break
|
||||
else {
|
||||
uploadInfo = await refreshMediaConn(true)
|
||||
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const isLast = host.hostname === uploadInfo.hosts[uploadInfo.hosts.length-1].hostname
|
||||
logger.debug(`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
|
||||
}
|
||||
}
|
||||
if (!mediaUrl) {
|
||||
throw new Boom(
|
||||
'Media upload failed on all hosts',
|
||||
{ statusCode: 500 }
|
||||
)
|
||||
}
|
||||
return { mediaUrl }
|
||||
}
|
||||
|
||||
return {
|
||||
...sock,
|
||||
assertSession,
|
||||
relayMessage,
|
||||
sendReadReceipt,
|
||||
refreshMediaConn,
|
||||
sendMessage: async(
|
||||
jid: string,
|
||||
content: AnyMessageContent,
|
||||
options: MiscMessageGenerationOptions = { }
|
||||
) => {
|
||||
const userJid = authState.creds.me!.id
|
||||
if(
|
||||
typeof content === 'object' &&
|
||||
'disappearingMessagesInChat' in content &&
|
||||
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
|
||||
isJidGroup(jid)
|
||||
) {
|
||||
const { disappearingMessagesInChat } = content
|
||||
const value = typeof disappearingMessagesInChat === 'boolean' ?
|
||||
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
|
||||
disappearingMessagesInChat
|
||||
await groupToggleEphemeral(jid, value)
|
||||
} else {
|
||||
const fullMsg = await generateWAMessage(
|
||||
jid,
|
||||
content,
|
||||
{
|
||||
...options,
|
||||
logger,
|
||||
userJid: userJid,
|
||||
// multi-device does not have this yet
|
||||
//getUrlInfo: generateUrlInfo,
|
||||
upload: waUploadToServer
|
||||
}
|
||||
)
|
||||
await relayMessage(jid, fullMsg.message)
|
||||
process.nextTick(() => {
|
||||
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
|
||||
})
|
||||
return fullMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
469
src/Socket/socket.ts
Normal file
469
src/Socket/socket.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import EventEmitter from 'events'
|
||||
import { promisify } from "util"
|
||||
import WebSocket from "ws"
|
||||
import { randomBytes } from 'crypto'
|
||||
import { proto } from '../../WAProto'
|
||||
import { DisconnectReason, SocketConfig, BaileysEventEmitter } from "../Types"
|
||||
import { generateCurveKeyPair, initAuthState, generateRegistrationNode, configureSuccessfulPairing, generateLoginNode, encodeBigEndian, promiseTimeout } from "../Utils"
|
||||
import { DEFAULT_ORIGIN, DEF_TAG_PREFIX, DEF_CALLBACK_PREFIX, KEY_BUNDLE_TYPE } from "../Defaults"
|
||||
import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, S_WHATSAPP_NET } from '../WABinary'
|
||||
import noiseHandler from '../Utils/noise-handler'
|
||||
import { generateOrGetPreKeys, xmppSignedPreKey, xmppPreKey, getPreKeys } from '../Utils/signal'
|
||||
|
||||
/**
|
||||
* Connects to WA servers and performs:
|
||||
* - simple queries (no retry mechanism, wait for connection establishment)
|
||||
* - listen to messages and emit events
|
||||
* - query phone connection
|
||||
*/
|
||||
export const makeSocket = ({
|
||||
waWebSocketUrl,
|
||||
connectTimeoutMs,
|
||||
logger,
|
||||
agent,
|
||||
keepAliveIntervalMs,
|
||||
version,
|
||||
browser,
|
||||
auth: initialAuthState
|
||||
}: SocketConfig) => {
|
||||
const ws = new WebSocket(waWebSocketUrl, undefined, {
|
||||
origin: DEFAULT_ORIGIN,
|
||||
timeout: connectTimeoutMs,
|
||||
agent,
|
||||
headers: {
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Host': 'web.whatsapp.com',
|
||||
'Pragma': 'no-cache',
|
||||
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits'
|
||||
}
|
||||
})
|
||||
ws.setMaxListeners(0)
|
||||
/** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
|
||||
const ephemeralKeyPair = generateCurveKeyPair()
|
||||
/** WA noise protocol wrapper */
|
||||
const noise = noiseHandler(ephemeralKeyPair)
|
||||
const authState = initialAuthState || initAuthState()
|
||||
const { creds } = authState
|
||||
const ev = new EventEmitter() as BaileysEventEmitter
|
||||
|
||||
let lastDateRecv: Date
|
||||
let epoch = 0
|
||||
let keepAliveReq: NodeJS.Timeout
|
||||
|
||||
const uqTagId = `${randomBytes(1).toString('hex')[0]}.${randomBytes(1).toString('hex')[0]}-`
|
||||
const generateMessageTag = () => `${uqTagId}${epoch++}`
|
||||
|
||||
const sendPromise = promisify<void>(ws.send)
|
||||
/** send a raw buffer */
|
||||
const sendRawMessage = (data: Buffer | Uint8Array) => {
|
||||
const bytes = noise.encodeFrame(data)
|
||||
return sendPromise.call(ws, bytes) as Promise<void>
|
||||
}
|
||||
/** send a binary node */
|
||||
const sendNode = (node: BinaryNode) => {
|
||||
let buff = encodeBinaryNode(node)
|
||||
return sendRawMessage(buff)
|
||||
}
|
||||
/** await the next incoming message */
|
||||
const awaitNextMessage = async(sendMsg?: Uint8Array) => {
|
||||
if(ws.readyState !== ws.OPEN) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
let onOpen: (data: any) => void
|
||||
let onClose: (err: Error) => void
|
||||
|
||||
const result = new Promise<any>((resolve, reject) => {
|
||||
onOpen = (data: any) => resolve(data)
|
||||
onClose = reject
|
||||
ws.on('frame', onOpen)
|
||||
ws.on('close', onClose)
|
||||
ws.on('error', onClose)
|
||||
})
|
||||
.finally(() => {
|
||||
ws.off('frame', onOpen)
|
||||
ws.off('close', onClose)
|
||||
ws.off('error', onClose)
|
||||
})
|
||||
|
||||
if(sendMsg) {
|
||||
sendRawMessage(sendMsg).catch(onClose)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a message with a certain tag to be received
|
||||
* @param tag the message tag to await
|
||||
* @param json query that was sent
|
||||
* @param timeoutMs timeout after which the promise will reject
|
||||
*/
|
||||
const waitForMessage = async(msgId: string, timeoutMs?: number) => {
|
||||
let onRecv: (json) => void
|
||||
let onErr: (err) => void
|
||||
try {
|
||||
const result = await promiseTimeout(timeoutMs,
|
||||
(resolve, reject) => {
|
||||
onRecv = resolve
|
||||
onErr = err => {
|
||||
reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
|
||||
}
|
||||
|
||||
ws.on(`TAG:${msgId}`, onRecv)
|
||||
ws.on('close', onErr) // if the socket closes, you'll never receive the message
|
||||
},
|
||||
)
|
||||
return result as any
|
||||
} finally {
|
||||
ws.off(`TAG:${msgId}`, onRecv)
|
||||
ws.off('close', onErr) // if the socket closes, you'll never receive the message
|
||||
}
|
||||
}
|
||||
/** send a query, and wait for its response. auto-generates message ID if not provided */
|
||||
const query = async(node: BinaryNode, timeoutMs?: number) => {
|
||||
if(!node.attrs.id) node.attrs.id = generateMessageTag()
|
||||
|
||||
const msgId = node.attrs.id
|
||||
const wait = waitForMessage(msgId, timeoutMs)
|
||||
|
||||
await sendNode(node)
|
||||
|
||||
const result = await (wait as Promise<BinaryNode>)
|
||||
if('tag' in result) {
|
||||
assertNodeErrorFree(result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
/** connection handshake */
|
||||
const validateConnection = async () => {
|
||||
logger.info('connected to WA Web')
|
||||
|
||||
const init = proto.HandshakeMessage.encode({
|
||||
clientHello: { ephemeral: ephemeralKeyPair.public }
|
||||
}).finish()
|
||||
|
||||
const result = await awaitNextMessage(init)
|
||||
const handshake = proto.HandshakeMessage.decode(result)
|
||||
|
||||
logger.debug('handshake recv from WA Web')
|
||||
|
||||
const keyEnc = noise.processHandshake(handshake, creds.noiseKey)
|
||||
logger.info('handshake complete')
|
||||
|
||||
let node: Uint8Array
|
||||
if(!creds.me) {
|
||||
logger.info('not logged in, attempting registration...')
|
||||
node = generateRegistrationNode(creds, { version, browser })
|
||||
} else {
|
||||
logger.info('logging in...')
|
||||
node = generateLoginNode(creds.me!.id, { version, browser })
|
||||
}
|
||||
const payloadEnc = noise.encrypt(node)
|
||||
await sendRawMessage(
|
||||
proto.HandshakeMessage.encode({
|
||||
clientFinish: {
|
||||
static: new Uint8Array(keyEnc),
|
||||
payload: new Uint8Array(payloadEnc),
|
||||
},
|
||||
}).finish()
|
||||
)
|
||||
noise.finishInit()
|
||||
startKeepAliveRequest()
|
||||
}
|
||||
/** get some pre-keys and do something with them */
|
||||
const assertingPreKeys = async(range: number, execute: (keys: { [_: number]: any }) => Promise<void>) => {
|
||||
const { newPreKeys, lastPreKeyId, preKeysRange } = generateOrGetPreKeys(authState, range)
|
||||
const preKeys = await getPreKeys(authState.keys, preKeysRange[0], preKeysRange[1])
|
||||
|
||||
await execute(preKeys)
|
||||
|
||||
creds.serverHasPreKeys = true
|
||||
creds.nextPreKeyId = Math.max(lastPreKeyId+1, creds.nextPreKeyId)
|
||||
creds.firstUnuploadedPreKeyId = Math.max(creds.firstUnuploadedPreKeyId, lastPreKeyId+1)
|
||||
await Promise.all(
|
||||
Object.keys(newPreKeys).map(k => authState.keys.setPreKey(+k, newPreKeys[+k]))
|
||||
)
|
||||
|
||||
ev.emit('auth-state.update', authState)
|
||||
}
|
||||
/** generates and uploads a set of pre-keys */
|
||||
const uploadPreKeys = async() => {
|
||||
await assertingPreKeys(50, async preKeys => {
|
||||
const node: BinaryNode = {
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
id: generateMessageTag(),
|
||||
xmlns: 'encrypt',
|
||||
type: 'set',
|
||||
to: S_WHATSAPP_NET,
|
||||
},
|
||||
content: [
|
||||
{ tag: 'registration', attrs: { }, content: encodeBigEndian(creds.registrationId) },
|
||||
{ tag: 'type', attrs: { }, content: KEY_BUNDLE_TYPE },
|
||||
{ tag: 'identity', attrs: { }, content: creds.signedIdentityKey.public },
|
||||
{ tag: 'list', attrs: { }, content: Object.keys(preKeys).map(k => xmppPreKey(preKeys[+k], +k)) },
|
||||
xmppSignedPreKey(creds.signedPreKey)
|
||||
]
|
||||
}
|
||||
await sendNode(node)
|
||||
|
||||
logger.info('uploaded pre-keys')
|
||||
})
|
||||
}
|
||||
|
||||
const onMessageRecieved = (data: Buffer) => {
|
||||
noise.decodeFrame(data, frame => {
|
||||
ws.emit('frame', frame)
|
||||
// if it's a binary node
|
||||
if(!(frame instanceof Uint8Array)) {
|
||||
const msgId = frame.attrs.id
|
||||
|
||||
if(logger.level === 'trace') {
|
||||
logger.trace({ msgId, fromMe: false, frame }, 'communication')
|
||||
}
|
||||
|
||||
let anyTriggered = false
|
||||
/* Check if this is a response to a message we sent */
|
||||
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame)
|
||||
/* Check if this is a response to a message we are expecting */
|
||||
const l0 = frame.tag
|
||||
const l1 = frame.attrs || { }
|
||||
const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ''
|
||||
|
||||
Object.keys(l1).forEach(key => {
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame) || anyTriggered
|
||||
})
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame) || anyTriggered
|
||||
anyTriggered = ws.emit('frame', frame) || anyTriggered
|
||||
|
||||
if (!anyTriggered && logger.level === 'debug') {
|
||||
logger.debug({ unhandled: true, msgId, fromMe: false, frame }, 'communication recv')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const end = (error: Error | undefined) => {
|
||||
logger.info({ error }, 'connection closed')
|
||||
|
||||
clearInterval(keepAliveReq)
|
||||
|
||||
ws.removeAllListeners('close')
|
||||
ws.removeAllListeners('error')
|
||||
ws.removeAllListeners('open')
|
||||
ws.removeAllListeners('message')
|
||||
|
||||
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
|
||||
try { ws.close() } catch { }
|
||||
}
|
||||
|
||||
ev.emit('connection.update', {
|
||||
connection: 'close',
|
||||
lastDisconnect: {
|
||||
error,
|
||||
date: new Date()
|
||||
}
|
||||
})
|
||||
ws.removeAllListeners('connection.update')
|
||||
}
|
||||
|
||||
const waitForSocketOpen = async() => {
|
||||
if(ws.readyState === ws.OPEN) return
|
||||
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
let onOpen: () => void
|
||||
let onClose: (err: Error) => void
|
||||
await new Promise((resolve, reject) => {
|
||||
onOpen = () => resolve(undefined)
|
||||
onClose = reject
|
||||
ws.on('open', onOpen)
|
||||
ws.on('close', onClose)
|
||||
ws.on('error', onClose)
|
||||
})
|
||||
.finally(() => {
|
||||
ws.off('open', onOpen)
|
||||
ws.off('close', onClose)
|
||||
ws.off('error', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
const startKeepAliveRequest = () => (
|
||||
keepAliveReq = setInterval(() => {
|
||||
if (!lastDateRecv) lastDateRecv = new Date()
|
||||
const diff = Date.now() - lastDateRecv.getTime()
|
||||
/*
|
||||
check if it's been a suspicious amount of time since the server responded with our last seen
|
||||
it could be that the network is down
|
||||
*/
|
||||
if (diff > keepAliveIntervalMs+5000) {
|
||||
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
|
||||
} else if(ws.readyState === ws.OPEN) {
|
||||
// if its all good, send a keep alive request
|
||||
query(
|
||||
{
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
id: generateMessageTag(),
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'get',
|
||||
xmlns: 'w:p',
|
||||
},
|
||||
content: [{ tag: 'ping', attrs: { } }]
|
||||
},
|
||||
keepAliveIntervalMs
|
||||
)
|
||||
.then(() => {
|
||||
lastDateRecv = new Date()
|
||||
logger.trace('recv keep alive')
|
||||
})
|
||||
.catch(err => end(err))
|
||||
} else {
|
||||
logger.warn('keep alive called when WS not open')
|
||||
}
|
||||
}, keepAliveIntervalMs)
|
||||
)
|
||||
/** i have no idea why this exists. pls enlighten me */
|
||||
const sendPassiveIq = (tag: 'passive' | 'active') => (
|
||||
sendNode({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
xmlns: 'passive',
|
||||
type: 'set',
|
||||
id: generateMessageTag(),
|
||||
},
|
||||
content: [
|
||||
{ tag, attrs: { } }
|
||||
]
|
||||
})
|
||||
)
|
||||
/** logout & invalidate connection */
|
||||
const logout = async() => {
|
||||
await sendNode({
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'set',
|
||||
id: generateMessageTag(),
|
||||
xmlns: 'md'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'remove-companion-device',
|
||||
attrs: {
|
||||
jid: authState.creds.me!.id,
|
||||
reason: 'user_initiated'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
end(new Boom('Intentional Logout', { statusCode: DisconnectReason.loggedOut }))
|
||||
}
|
||||
|
||||
ws.on('message', onMessageRecieved)
|
||||
ws.on('open', validateConnection)
|
||||
ws.on('error', end)
|
||||
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })))
|
||||
// the server terminated the connection
|
||||
ws.on('CB:xmlstreamend', () => {
|
||||
end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed }))
|
||||
})
|
||||
// QR gen
|
||||
ws.on('CB:iq,type:set,pair-device', async (stanza: BinaryNode) => {
|
||||
const postQR = async() => {
|
||||
const QR = await import('qrcode-terminal').catch(err => {
|
||||
logger.error('add `qrcode-terminal` as a dependency to auto-print QR')
|
||||
})
|
||||
QR?.generate(qr, { small: true })
|
||||
}
|
||||
|
||||
const refs = ((stanza.content[0] as BinaryNode).content as BinaryNode[]).map(n => n.content as string)
|
||||
const iq: BinaryNode = {
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'result',
|
||||
id: stanza.attrs.id,
|
||||
}
|
||||
}
|
||||
const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64');
|
||||
const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64')
|
||||
const advB64 = creds.advSecretKey
|
||||
const qr = [refs[0], noiseKeyB64, identityKeyB64, advB64].join(',');
|
||||
|
||||
ev.emit('connection.update', { qr })
|
||||
await postQR()
|
||||
await sendNode(iq)
|
||||
})
|
||||
// device paired for the first time
|
||||
// if device pairs successfully, the server asks to restart the connection
|
||||
ws.on('CB:iq,,pair-success', async(stanza: BinaryNode) => {
|
||||
logger.debug('pair success recv')
|
||||
try {
|
||||
const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds)
|
||||
|
||||
logger.debug('pairing configured successfully')
|
||||
|
||||
const waiting = awaitNextMessage()
|
||||
await sendNode(reply)
|
||||
|
||||
const value = (await waiting) as BinaryNode
|
||||
if(value.tag === 'stream:error') {
|
||||
if(value.attrs?.code !== '515') {
|
||||
throw new Boom('Authentication failed', { statusCode: +(value.attrs.code || 500) })
|
||||
}
|
||||
}
|
||||
Object.assign(creds, updatedCreds)
|
||||
logger.info({ jid: creds.me!.id }, 'registered connection, restart server')
|
||||
|
||||
ev.emit('auth-state.update', authState)
|
||||
ev.emit('connection.update', { isNewLogin: true, qr: undefined })
|
||||
|
||||
end(new Boom('Restart Required', { statusCode: DisconnectReason.restartRequired }))
|
||||
} catch(error) {
|
||||
logger.info({ trace: error.stack }, 'error in pairing')
|
||||
end(error)
|
||||
}
|
||||
})
|
||||
// login complete
|
||||
ws.on('CB:success', async() => {
|
||||
if(!creds.serverHasPreKeys) {
|
||||
await uploadPreKeys()
|
||||
}
|
||||
await sendPassiveIq('active')
|
||||
ev.emit('connection.update', { connection: 'open' })
|
||||
})
|
||||
// logged out
|
||||
ws.on('CB:failure,reason:401', () => {
|
||||
end(new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut }))
|
||||
})
|
||||
process.nextTick(() => {
|
||||
ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false })
|
||||
})
|
||||
|
||||
return {
|
||||
ws,
|
||||
ev,
|
||||
authState,
|
||||
get user () {
|
||||
return authState.creds.me
|
||||
},
|
||||
assertingPreKeys,
|
||||
generateMessageTag,
|
||||
query,
|
||||
waitForMessage,
|
||||
waitForSocketOpen,
|
||||
sendRawMessage,
|
||||
sendNode,
|
||||
logout,
|
||||
end
|
||||
}
|
||||
}
|
||||
export type Socket = ReturnType<typeof makeSocket>
|
||||
@@ -1,13 +1,13 @@
|
||||
import type KeyedDB from "@adiwajshing/keyed-db"
|
||||
import type { Comparable } from "@adiwajshing/keyed-db/lib/Types"
|
||||
import type { Logger } from "pino"
|
||||
import type { Connection } from "../Connection"
|
||||
import type { Connection } from "../Socket"
|
||||
import type { BaileysEventEmitter, Chat, ConnectionState, Contact, GroupMetadata, MessageInfo, PresenceData, WAMessage, WAMessageCursor, WAMessageKey } from "../Types"
|
||||
import { toNumber } from "../Utils"
|
||||
import makeOrderedDictionary from "./ordered-dictionary"
|
||||
|
||||
export const waChatKey = (pin: boolean) => ({
|
||||
key: (c: Chat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive === 'true' ? '0' : '1') + c.t.toString(16).padStart(8, '0') + c.jid,
|
||||
key: (c: Chat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive ? '0' : '1') + toNumber(c.conversationTimestamp).toString(16).padStart(8, '0') + c.id,
|
||||
compare: (k1: string, k2: string) => k2.localeCompare (k1)
|
||||
})
|
||||
|
||||
@@ -30,10 +30,7 @@ export default(
|
||||
const groupMetadata: { [_: string]: GroupMetadata } = { }
|
||||
const messageInfos: { [id: string]: MessageInfo } = { }
|
||||
const presences: { [id: string]: { [participant: string]: PresenceData } } = { }
|
||||
const state: ConnectionState = {
|
||||
connection: 'close',
|
||||
phoneConnected: false
|
||||
}
|
||||
const state: ConnectionState = { connection: 'close' }
|
||||
|
||||
const assertMessageList = (jid: string) => {
|
||||
if(!messages[jid]) messages[jid] = makeMessagesDictionary()
|
||||
@@ -214,7 +211,7 @@ export default(
|
||||
state,
|
||||
presences,
|
||||
listen,
|
||||
loadMessages: async(jid: string, count: number, cursor: WAMessageCursor, sock: Connection | undefined) => {
|
||||
/*loadMessages: async(jid: string, count: number, cursor: WAMessageCursor, sock: Connection | undefined) => {
|
||||
const list = assertMessageList(jid)
|
||||
const retrieve = async(count: number, cursor: WAMessageCursor) => {
|
||||
const result = await sock?.fetchMessagesFromWA(jid, count, cursor)
|
||||
@@ -291,6 +288,6 @@ export default(
|
||||
messageInfos[id!] = await sock?.messageInfo(remoteJid, id)
|
||||
}
|
||||
return messageInfos[id!]
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { WAConnection, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection'
|
||||
import * as assert from 'assert'
|
||||
import {promises as fs} from 'fs'
|
||||
|
||||
require ('dotenv').config () // dotenv to load test jid
|
||||
export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory
|
||||
|
||||
export const makeConnection = () => {
|
||||
const conn = new WAConnection()
|
||||
conn.connectOptions.maxIdleTimeMs = 15_000
|
||||
conn.logger.level = 'debug'
|
||||
|
||||
let evCounts = {}
|
||||
|
||||
conn.on ('close', ({ isReconnecting }) => {
|
||||
!isReconnecting && console.log ('Events registered: ', evCounts)
|
||||
})
|
||||
|
||||
const onM = conn.on
|
||||
conn.on = (...args: any[]) => {
|
||||
evCounts[args[0]] = (evCounts[args[0]] || 0) + 1
|
||||
return onM.apply (conn, args)
|
||||
}
|
||||
const offM = conn.off
|
||||
conn.off = (...args: any[]) => {
|
||||
evCounts[args[0]] = (evCounts[args[0]] || 0) - 1
|
||||
if (evCounts[args[0]] <= 0) delete evCounts[args[0]]
|
||||
return offM.apply (conn, args)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
export async function sendAndRetrieveMessage(conn: WAConnection, content, type: MessageType, options: MessageOptions = {}, recipientJid = testJid) {
|
||||
const response = await conn.sendMessage(recipientJid, content, type, options)
|
||||
const {messages} = await conn.loadMessages(recipientJid, 10)
|
||||
const message = messages.find (m => m.key.id === response.key.id)
|
||||
assert.ok(message)
|
||||
|
||||
const chat = conn.chats.get(recipientJid)
|
||||
|
||||
assert.ok (chat.messages.get(GET_MESSAGE_ID(message.key)))
|
||||
assert.ok (chat.t >= (unixTimestampSeconds()-5) )
|
||||
return message
|
||||
}
|
||||
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
|
||||
describe(name, () => {
|
||||
const conn = new WAConnection()
|
||||
conn.connectOptions.maxIdleTimeMs = 30_000
|
||||
conn.logger.level = 'debug'
|
||||
|
||||
before(async () => {
|
||||
const file = './auth_info.json'
|
||||
await conn.loadAuthInfo(file).connect()
|
||||
await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t'))
|
||||
})
|
||||
after(() => conn.close())
|
||||
|
||||
afterEach (() => assertChatDBIntegrity (conn))
|
||||
|
||||
func(conn)
|
||||
})
|
||||
)
|
||||
export const assertChatDBIntegrity = (conn: WAConnection) => {
|
||||
conn.chats.all ().forEach (chat => (
|
||||
assert.deepStrictEqual (
|
||||
[...chat.messages.all()].sort ((m1, m2) => waMessageKey.compare(waMessageKey.key(m1), waMessageKey.key(m2))),
|
||||
chat.messages.all()
|
||||
)
|
||||
))
|
||||
conn.chats.all ().forEach (chat => (
|
||||
assert.deepStrictEqual (
|
||||
chat.messages.all().filter (m => chat.messages.all().filter(m1 => m1.key.id === m.key.id).length > 1),
|
||||
[]
|
||||
)
|
||||
))
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { strict as assert } from 'assert'
|
||||
import Encoder from '../Binary/Encoder'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
|
||||
describe('Binary Coding Tests', () => {
|
||||
const testVectors: [string, Object][] = [
|
||||
[
|
||||
'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305',
|
||||
[
|
||||
'action',
|
||||
{ last: 'true', add: 'before' },
|
||||
[
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB009675E7ED37AABF2' },
|
||||
message: { conversation: '*piano room timings are:*\n 6:00AM-12:00AM' },
|
||||
messageTimestamp: '1584004403',
|
||||
status: 'DELIVERY_ACK',
|
||||
},
|
||||
],
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
key: {
|
||||
remoteJid: '917529871117@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD',
|
||||
},
|
||||
messageTimestamp: '1584004413',
|
||||
messageStubType: 'REVOKE',
|
||||
},
|
||||
],
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB003C7B539AFD09753' },
|
||||
message: {
|
||||
conversation:
|
||||
"Sorry fren, I couldn't understand 'Libra'. Type 'help' to know what all I can do",
|
||||
},
|
||||
messageTimestamp: '1584004417',
|
||||
status: 'DELIVERY_ACK',
|
||||
},
|
||||
],
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
key: {
|
||||
remoteJid: '917529871117@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8',
|
||||
},
|
||||
message: { conversation: 'library' },
|
||||
messageTimestamp: '1584004418',
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'f8063f2dfafc0831323334353637385027fc0431323334f801f80228fc0701020304050607',
|
||||
[
|
||||
'picture',
|
||||
{jid: '12345678@c.us', id: '1234'},
|
||||
[['image', null, Buffer.from([1,2,3,4,5,6,7])]]
|
||||
]
|
||||
]
|
||||
]
|
||||
const encoder = new Encoder()
|
||||
const decoder = new Decoder()
|
||||
|
||||
it('should decode strings', () => {
|
||||
testVectors.forEach(pair => {
|
||||
const buff = Buffer.from(pair[0], 'hex')
|
||||
const decoded = decoder.read(buff)
|
||||
//console.log((decoded[2][0][2]))
|
||||
assert.deepStrictEqual(decoded, pair[1])
|
||||
|
||||
const encoded = encoder.write(decoded)
|
||||
assert.deepStrictEqual(encoded, buff)
|
||||
})
|
||||
console.log('all coding tests passed')
|
||||
})
|
||||
})
|
||||
@@ -1,407 +0,0 @@
|
||||
import * as assert from 'assert'
|
||||
import {WAConnection} from '../WAConnection'
|
||||
import { AuthenticationCredentialsBase64, BaileysError, ReconnectMode, DisconnectReason, WAChat, WAContact } from '../WAConnection/Constants'
|
||||
import { delay } from '../WAConnection/Utils'
|
||||
import { assertChatDBIntegrity, makeConnection, testJid } from './Common'
|
||||
|
||||
describe('QR Generation', () => {
|
||||
it('should generate QR', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.connectOptions.maxRetries = 0
|
||||
|
||||
let calledQR = 0
|
||||
conn.removeAllListeners ('qr')
|
||||
conn.on ('qr', () => calledQR += 1)
|
||||
|
||||
await conn.connect()
|
||||
.then (() => assert.fail('should not have succeeded'))
|
||||
.catch (error => {})
|
||||
assert.deepStrictEqual (
|
||||
Object.keys(conn.eventNames()).filter(key => key.startsWith('TAG:')),
|
||||
[]
|
||||
)
|
||||
assert.ok(calledQR >= 2, 'QR not called')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Test Connect', () => {
|
||||
let auth: AuthenticationCredentialsBase64
|
||||
it('should connect', async () => {
|
||||
console.log('please be ready to scan with your phone')
|
||||
|
||||
const conn = makeConnection ()
|
||||
|
||||
let credentialsUpdateCalled = false
|
||||
conn.on ('credentials-updated', () => credentialsUpdateCalled = true)
|
||||
|
||||
await conn.connect ()
|
||||
assert.ok(conn.user?.jid)
|
||||
assert.ok(conn.user?.phone)
|
||||
assert.ok (conn.user?.imgUrl || conn.user.imgUrl === '')
|
||||
assert.ok (credentialsUpdateCalled)
|
||||
|
||||
assertChatDBIntegrity (conn)
|
||||
|
||||
conn.close()
|
||||
auth = conn.base64EncodedAuthInfo()
|
||||
})
|
||||
it('should restore session', async () => {
|
||||
const conn = makeConnection ()
|
||||
|
||||
let credentialsUpdateCalled = false
|
||||
conn.on ('credentials-updated', () => credentialsUpdateCalled = true)
|
||||
|
||||
await conn.loadAuthInfo (auth).connect ()
|
||||
assert.ok(conn.user)
|
||||
assert.ok(conn.user.jid)
|
||||
assert.ok (credentialsUpdateCalled)
|
||||
|
||||
assertChatDBIntegrity (conn)
|
||||
await conn.logout()
|
||||
conn.loadAuthInfo(auth)
|
||||
|
||||
await conn.connect()
|
||||
.then (() => assert.fail('should not have reconnected'))
|
||||
.catch (err => {
|
||||
assert.ok (err instanceof BaileysError)
|
||||
assert.ok ((err as BaileysError).status >= 400)
|
||||
})
|
||||
conn.close()
|
||||
})
|
||||
it ('should disconnect & reconnect phone', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.logger.level = 'debug'
|
||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
||||
assert.strictEqual (conn.phoneConnected, true)
|
||||
|
||||
try {
|
||||
const waitForEvent = expect => new Promise (resolve => {
|
||||
conn.on ('connection-phone-change', ({connected}) => {
|
||||
if (connected === expect) {
|
||||
conn.removeAllListeners ('connection-phone-change')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log ('disconnect your phone from the internet')
|
||||
await delay (10_000)
|
||||
console.log ('phone should be disconnected now, testing...')
|
||||
|
||||
const messagesPromise = Promise.all (
|
||||
[
|
||||
conn.loadMessages (testJid, 50),
|
||||
conn.getStatus (testJid),
|
||||
conn.getProfilePicture (testJid).catch (() => '')
|
||||
]
|
||||
)
|
||||
|
||||
await waitForEvent (false)
|
||||
|
||||
console.log ('reconnect your phone to the internet')
|
||||
await waitForEvent (true)
|
||||
|
||||
console.log ('reconnected successfully')
|
||||
|
||||
const final = await messagesPromise
|
||||
assert.ok (final)
|
||||
} finally {
|
||||
conn.close ()
|
||||
}
|
||||
})
|
||||
})
|
||||
describe ('Reconnects', () => {
|
||||
const verifyConnectionOpen = async (conn: WAConnection) => {
|
||||
assert.ok (conn.user.jid)
|
||||
let failed = false
|
||||
// check that the connection stays open
|
||||
conn.on ('close', ({reason}) => {
|
||||
if(reason !== DisconnectReason.intentional) failed = true
|
||||
})
|
||||
await delay (60*1000)
|
||||
|
||||
const status = await conn.getStatus ()
|
||||
assert.ok (status)
|
||||
assert.ok (!conn['debounceTimeout']) // this should be null
|
||||
|
||||
conn.close ()
|
||||
|
||||
if (failed) assert.fail ('should not have closed again')
|
||||
}
|
||||
it('should dispose correctly on bad_session', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.autoReconnect = ReconnectMode.onAllErrors
|
||||
conn.loadAuthInfo ('./auth_info.json')
|
||||
|
||||
let gotClose0 = false
|
||||
let gotClose1 = false
|
||||
|
||||
conn.on ('ws-close', ({ reason }) => {
|
||||
gotClose0 = true
|
||||
})
|
||||
conn.on ('close', ({ reason }) => {
|
||||
if (reason === DisconnectReason.badSession) gotClose1 = true
|
||||
})
|
||||
setTimeout (() => conn['conn'].emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
|
||||
await conn.connect ()
|
||||
|
||||
setTimeout (() => conn['conn'].emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
|
||||
|
||||
await new Promise (resolve => {
|
||||
conn.on ('open', resolve)
|
||||
})
|
||||
|
||||
assert.ok (gotClose0, 'did not receive bad_session close initially')
|
||||
assert.ok (gotClose1, 'did not receive bad_session close')
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
/**
|
||||
* the idea is to test closing the connection at multiple points in the connection
|
||||
* and see if the library cleans up resources correctly
|
||||
*/
|
||||
it('should cleanup correctly', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.autoReconnect = ReconnectMode.onAllErrors
|
||||
conn.loadAuthInfo ('./auth_info.json')
|
||||
|
||||
let timeout = 0.1
|
||||
while (true) {
|
||||
let tmout = setTimeout (() => conn.close(), timeout*1000)
|
||||
try {
|
||||
await conn.connect ()
|
||||
clearTimeout (tmout)
|
||||
break
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
// exponentially increase the timeout disconnect
|
||||
timeout *= 2
|
||||
}
|
||||
await verifyConnectionOpen (conn)
|
||||
})
|
||||
/**
|
||||
* the idea is to test closing the connection at multiple points in the connection
|
||||
* and see if the library cleans up resources correctly
|
||||
*/
|
||||
it('should disrupt connect loop', async () => {
|
||||
const conn = makeConnection ()
|
||||
|
||||
conn.autoReconnect = ReconnectMode.onAllErrors
|
||||
conn.loadAuthInfo ('./auth_info.json')
|
||||
|
||||
let timeout = 1000
|
||||
let tmout
|
||||
const endConnection = async () => {
|
||||
while (!conn['conn']) {
|
||||
await delay(100)
|
||||
}
|
||||
conn['conn'].close ()
|
||||
|
||||
while (conn['conn']) {
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
timeout *= 2
|
||||
tmout = setTimeout (endConnection, timeout)
|
||||
}
|
||||
tmout = setTimeout (endConnection, timeout)
|
||||
|
||||
await conn.connect ()
|
||||
clearTimeout (tmout)
|
||||
|
||||
await verifyConnectionOpen (conn)
|
||||
})
|
||||
it ('should reconnect on broken connection', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.autoReconnect = ReconnectMode.onConnectionLost
|
||||
|
||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
||||
assert.strictEqual (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 >= 1) {
|
||||
conn.removeAllListeners ('close')
|
||||
conn.removeAllListeners ('connecting')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
conn.on ('connecting', () => {
|
||||
// close again
|
||||
delay (3500).then (closeConn)
|
||||
})
|
||||
})
|
||||
|
||||
closeConn ()
|
||||
await task
|
||||
|
||||
await new Promise (resolve => {
|
||||
conn.on ('open', () => {
|
||||
conn.removeAllListeners ('open')
|
||||
resolve(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
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 ()
|
||||
}
|
||||
})
|
||||
it ('should reconnect & stay connected', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.autoReconnect = ReconnectMode.onConnectionLost
|
||||
|
||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
||||
assert.strictEqual (conn.phoneConnected, true)
|
||||
|
||||
await delay (30*1000)
|
||||
|
||||
conn['conn']?.terminate ()
|
||||
|
||||
conn.on ('close', () => {
|
||||
assert.ok (!conn['lastSeen'])
|
||||
console.log ('connection closed')
|
||||
})
|
||||
await new Promise (resolve => conn.on ('open', resolve))
|
||||
await verifyConnectionOpen (conn)
|
||||
})
|
||||
})
|
||||
|
||||
describe ('Pending Requests', () => {
|
||||
it ('should correctly send updates for chats', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.pendingRequestTimeoutMs = null
|
||||
conn.loadAuthInfo('./auth_info.json')
|
||||
|
||||
const task = new Promise(resolve => conn.once('chats-received', resolve))
|
||||
await conn.connect ()
|
||||
await task
|
||||
|
||||
conn.close ()
|
||||
|
||||
const oldChat = conn.chats.all()[0]
|
||||
oldChat.archive = 'true' // mark the first chat as archived
|
||||
oldChat.modify_tag = '1234' // change modify tag to detect change
|
||||
|
||||
const promise = new Promise(resolve => conn.once('chats-update', resolve))
|
||||
|
||||
const result = await conn.connect ()
|
||||
assert.ok (!result.newConnection)
|
||||
|
||||
const chats = await promise as Partial<WAChat>[]
|
||||
const chat = chats.find (c => c.jid === oldChat.jid)
|
||||
assert.ok (chat)
|
||||
|
||||
assert.ok ('archive' in chat)
|
||||
assert.strictEqual (Object.keys(chat).length, 3)
|
||||
assert.strictEqual (Object.keys(chats).length, 1)
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
it ('should correctly send updates for contacts', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.pendingRequestTimeoutMs = null
|
||||
conn.loadAuthInfo('./auth_info.json')
|
||||
|
||||
const task: any = new Promise(resolve => conn.once('contacts-received', resolve))
|
||||
await conn.connect ()
|
||||
const initialResult = await task
|
||||
assert.strictEqual(
|
||||
initialResult.updatedContacts.length,
|
||||
Object.keys(conn.contacts).length
|
||||
)
|
||||
|
||||
|
||||
conn.close ()
|
||||
|
||||
const [jid] = Object.keys(conn.contacts)
|
||||
const oldContact = conn.contacts[jid]
|
||||
oldContact.name = 'Lol'
|
||||
oldContact.index = 'L'
|
||||
|
||||
const promise = new Promise(resolve => conn.once('contacts-received', resolve))
|
||||
|
||||
const result = await conn.connect ()
|
||||
assert.ok (!result.newConnection)
|
||||
|
||||
const {updatedContacts} = await promise as { updatedContacts: Partial<WAContact>[] }
|
||||
const contact = updatedContacts.find (c => c.jid === jid)
|
||||
assert.ok (contact)
|
||||
|
||||
assert.ok ('name' in contact)
|
||||
assert.strictEqual (Object.keys(contact).length, 3)
|
||||
assert.strictEqual (Object.keys(updatedContacts).length, 1)
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
it('should queue requests when closed', async () => {
|
||||
const conn = makeConnection ()
|
||||
//conn.pendingRequestTimeoutMs = null
|
||||
|
||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
||||
|
||||
await delay (2000)
|
||||
|
||||
conn.close ()
|
||||
|
||||
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.jid]})
|
||||
|
||||
await delay (2000)
|
||||
|
||||
conn.connect ()
|
||||
const json = await task
|
||||
|
||||
assert.ok (json.status)
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
it('[MANUAL] should receive query response after phone disconnect', async () => {
|
||||
const conn = makeConnection ()
|
||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
||||
|
||||
console.log(`disconnect your phone from the internet!`)
|
||||
await delay(5000)
|
||||
const task = conn.loadMessages(testJid, 50)
|
||||
setTimeout(() => console.log('reconnect your phone!'), 20_000)
|
||||
|
||||
const result = await task
|
||||
assert.ok(result.messages[0])
|
||||
assert.ok(!conn['phoneCheckInterval']) // should be undefined
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
it('should re-execute query on connection closed error', async () => {
|
||||
const conn = makeConnection ()
|
||||
//conn.pendingRequestTimeoutMs = 10_000
|
||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
||||
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.jid], waitForOpen: true})
|
||||
|
||||
await delay(20)
|
||||
conn['onMessageRecieved']('1234,["Pong",false]') // fake cancel the connection
|
||||
|
||||
await delay(2000)
|
||||
|
||||
const json = await task
|
||||
|
||||
assert.ok (json.status)
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import { MessageType, GroupSettingChange, delay, ChatModification, whatsappID } from '../WAConnection'
|
||||
import * as assert from 'assert'
|
||||
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
|
||||
|
||||
WAConnectionTest('Groups', (conn) => {
|
||||
let gid: string
|
||||
it('should create a group', async () => {
|
||||
const response = await conn.groupCreate('Cool Test Group', [testJid])
|
||||
assert.ok (conn.chats.get(response.gid))
|
||||
|
||||
const {chats} = await conn.loadChats(10, null)
|
||||
assert.strictEqual (chats[0].jid, response.gid) // first chat should be new group
|
||||
|
||||
gid = response.gid
|
||||
|
||||
console.log('created group: ' + JSON.stringify(response))
|
||||
})
|
||||
it('should retrieve group invite code', async () => {
|
||||
const code = await conn.groupInviteCode(gid)
|
||||
assert.ok(code)
|
||||
assert.strictEqual(typeof code, 'string')
|
||||
})
|
||||
it('should joined group via invite code', async () => {
|
||||
const response = await conn.acceptInvite(gid)
|
||||
assert.ok(response.status)
|
||||
assert.strictEqual(response.status, response.gid)
|
||||
})
|
||||
it('should retrieve group metadata', async () => {
|
||||
const metadata = await conn.groupMetadata(gid)
|
||||
assert.strictEqual(metadata.id, gid)
|
||||
assert.strictEqual(metadata.participants.filter((obj) => obj.jid.split('@')[0] === testJid.split('@')[0]).length, 1)
|
||||
assert.ok(conn.chats.get(gid))
|
||||
assert.ok(conn.chats.get(gid).metadata)
|
||||
})
|
||||
it('should update the group description', async () => {
|
||||
const newDesc = 'Wow this was set from Baileys'
|
||||
|
||||
const waitForEvent = new Promise (resolve => (
|
||||
conn.once ('group-update', ({jid, desc}) => {
|
||||
if (jid === gid && desc) {
|
||||
assert.strictEqual(desc, newDesc)
|
||||
assert.strictEqual(
|
||||
conn.chats.get(jid).metadata.desc,
|
||||
newDesc
|
||||
)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
))
|
||||
await conn.groupUpdateDescription (gid, newDesc)
|
||||
await waitForEvent
|
||||
|
||||
const metadata = await conn.groupMetadata(gid)
|
||||
assert.strictEqual(metadata.desc, newDesc)
|
||||
})
|
||||
it('should send a message on the group', async () => {
|
||||
await sendAndRetrieveMessage(conn, 'Hello!', MessageType.text, {}, gid)
|
||||
})
|
||||
it('should delete a message on the group', async () => {
|
||||
const message = await sendAndRetrieveMessage(conn, 'Hello!', MessageType.text, {}, gid)
|
||||
await delay(1500)
|
||||
await conn.deleteMessage(message.key)
|
||||
})
|
||||
it('should quote a message on the group', async () => {
|
||||
const {messages} = await conn.loadMessages (gid, 100)
|
||||
const quotableMessage = messages.find (m => m.message)
|
||||
assert.ok (quotableMessage, 'need at least one message')
|
||||
|
||||
const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: quotableMessage})
|
||||
const loaded = await conn.loadMessages(gid, 10)
|
||||
const message = loaded.messages.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage
|
||||
assert.ok(message)
|
||||
assert.strictEqual (message.contextInfo.stanzaId, quotableMessage.key.id)
|
||||
})
|
||||
it('should update the subject', async () => {
|
||||
const subject = 'Baileyz ' + Math.floor(Math.random()*5)
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('chat-update', ({jid, name}) => {
|
||||
if (jid === gid) {
|
||||
assert.strictEqual(name, subject)
|
||||
assert.strictEqual(conn.chats.get(jid).name, subject)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await conn.groupUpdateSubject(gid, subject)
|
||||
await waitForEvent
|
||||
|
||||
const metadata = await conn.groupMetadata(gid)
|
||||
assert.strictEqual(metadata.subject, subject)
|
||||
})
|
||||
|
||||
it('should update the group settings', async () => {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('group-update', ({jid, announce}) => {
|
||||
if (jid === gid) {
|
||||
assert.strictEqual (announce, 'true')
|
||||
assert.strictEqual(conn.chats.get(gid).metadata.announce, announce)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true)
|
||||
|
||||
await waitForEvent
|
||||
conn.removeAllListeners ('group-update')
|
||||
|
||||
await delay (2000)
|
||||
await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true)
|
||||
})
|
||||
|
||||
it('should promote someone', async () => {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('group-participants-update', ({ jid, action, participants }) => {
|
||||
if (jid === gid) {
|
||||
assert.strictEqual (action, 'promote')
|
||||
console.log(participants)
|
||||
console.log(conn.chats.get(jid).metadata)
|
||||
assert.ok(
|
||||
conn.chats.get(jid).metadata.participants.find(({ jid, isAdmin }) => (
|
||||
whatsappID(jid) === whatsappID(participants[0]) && isAdmin
|
||||
)),
|
||||
)
|
||||
resolve(undefined)
|
||||
}
|
||||
|
||||
})
|
||||
})
|
||||
await conn.groupMakeAdmin(gid, [ testJid ])
|
||||
await waitForEvent
|
||||
})
|
||||
|
||||
it('should remove someone from a group', async () => {
|
||||
const metadata = await conn.groupMetadata (gid)
|
||||
if (metadata.participants.find(({jid}) => whatsappID(jid) === testJid)) {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('group-participants-update', ({jid, participants, action}) => {
|
||||
if (jid === gid) {
|
||||
assert.strictEqual (participants[0], testJid)
|
||||
assert.strictEqual (action, 'remove')
|
||||
assert.deepStrictEqual(
|
||||
conn.chats.get(jid).metadata.participants.find(p => whatsappID(p.jid) === whatsappID(participants[0])),
|
||||
undefined
|
||||
)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await conn.groupRemove(gid, [testJid])
|
||||
await waitForEvent
|
||||
} else console.log(`could not find testJid`)
|
||||
})
|
||||
|
||||
it('should leave the group', async () => {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('chat-update', ({jid, read_only}) => {
|
||||
if (jid === gid) {
|
||||
assert.strictEqual (read_only, 'true')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await conn.groupLeave(gid)
|
||||
await waitForEvent
|
||||
|
||||
await conn.groupMetadataMinimal (gid)
|
||||
})
|
||||
it('should archive the group', async () => {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('chat-update', ({jid, archive}) => {
|
||||
if (jid === gid) {
|
||||
assert.strictEqual (archive, 'true')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await conn.modifyChat(gid, ChatModification.archive)
|
||||
await waitForEvent
|
||||
})
|
||||
it('should delete the group', async () => {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('chat-update', (chat) => {
|
||||
if (chat.jid === gid) {
|
||||
assert.strictEqual (chat['delete'], 'true')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await conn.modifyChat(gid, 'delete')
|
||||
await waitForEvent
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { deepStrictEqual, strictEqual } from 'assert'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { proto } from '../../WAMessage'
|
||||
import { MessageType } from '../WAConnection'
|
||||
import { aesEncrypWithIV, decryptMediaMessageBuffer, encryptedStream, getMediaKeys, getStream, hmacSign, sha256 } from '../WAConnection/Utils'
|
||||
import { WAConnectionTest } from './Common'
|
||||
|
||||
describe('Media Download Tests', () => {
|
||||
|
||||
it('should encrypt media streams correctly', async function() {
|
||||
const url = './Media/meme.jpeg'
|
||||
const streamValues = await encryptedStream({ url }, MessageType.image)
|
||||
|
||||
const buffer = await readFile(url)
|
||||
const mediaKeys = getMediaKeys(streamValues.mediaKey, MessageType.image)
|
||||
|
||||
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)
|
||||
const fileEncSha256 = sha256(body)
|
||||
|
||||
deepStrictEqual(streamValues.fileSha256, fileSha256)
|
||||
strictEqual(streamValues.fileLength, buffer.length)
|
||||
deepStrictEqual(streamValues.mac, mac)
|
||||
deepStrictEqual(await readFile(streamValues.encBodyPath), body)
|
||||
deepStrictEqual(streamValues.fileEncSha256, fileEncSha256)
|
||||
|
||||
})
|
||||
})
|
||||
/*
|
||||
WAConnectionTest('Media Upload', conn => {
|
||||
|
||||
it('should upload the same file', async () => {
|
||||
const FILES = [
|
||||
{ url: './Media/meme.jpeg', type: MessageType.image },
|
||||
{ url: './Media/ma_gif.mp4', type: MessageType.video },
|
||||
{ url: './Media/sonata.mp3', type: MessageType.audio },
|
||||
]
|
||||
})
|
||||
|
||||
})*/
|
||||
@@ -1,268 +0,0 @@
|
||||
import { MessageType, Mimetype, delay, promiseTimeout, WA_MESSAGE_STATUS_TYPE, generateMessageID, WAMessage } from '../WAConnection'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as assert from 'assert'
|
||||
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
|
||||
|
||||
WAConnectionTest('Messages', conn => {
|
||||
|
||||
it('should send a text message', async () => {
|
||||
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text)
|
||||
assert.strictEqual(message.message.conversation || message.message.extendedTextMessage?.text, 'hello fren')
|
||||
})
|
||||
it('should send a pending message', async () => {
|
||||
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text, { waitForAck: false })
|
||||
|
||||
await new Promise(resolve => conn.on('chat-update', update => {
|
||||
if (update.jid === testJid &&
|
||||
update.messages &&
|
||||
update.messages.first.key.id === message.key.id &&
|
||||
update.messages.first.status === WA_MESSAGE_STATUS_TYPE.SERVER_ACK) {
|
||||
resolve(undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
})
|
||||
it('should forward a message', async () => {
|
||||
let {messages} = await conn.loadMessages (testJid, 1)
|
||||
await conn.forwardMessage (testJid, messages[0], true)
|
||||
|
||||
messages = (await conn.loadMessages (testJid, 1)).messages
|
||||
const message = messages.slice (-1)[0]
|
||||
const content = message.message[ Object.keys(message.message)[0] ]
|
||||
assert.strictEqual (content?.contextInfo?.isForwarded, true)
|
||||
})
|
||||
it('should send a link preview', async () => {
|
||||
const text = 'hello this is from https://www.github.com/adiwajshing/Baileys'
|
||||
const message = await sendAndRetrieveMessage(conn, text, MessageType.text, { detectLinks: true })
|
||||
const received = message.message.extendedTextMessage
|
||||
|
||||
assert.strictEqual(received.text, text)
|
||||
assert.ok (received.canonicalUrl)
|
||||
assert.ok (received.title)
|
||||
assert.ok (received.description)
|
||||
})
|
||||
it('should quote a message', async () => {
|
||||
const quoted = (await conn.loadMessages(testJid, 2)).messages[0]
|
||||
const message = await sendAndRetrieveMessage(conn, 'hello fren 2', MessageType.extendedText, { quoted })
|
||||
assert.strictEqual(
|
||||
message.message.extendedTextMessage.contextInfo.stanzaId,
|
||||
quoted.key.id
|
||||
)
|
||||
assert.strictEqual(
|
||||
message.message.extendedTextMessage.contextInfo.participant,
|
||||
quoted.key.fromMe ? conn.user.jid : quoted.key.id
|
||||
)
|
||||
})
|
||||
it('should upload media successfully', async () => {
|
||||
const content = await fs.readFile('./Media/sonata.mp3')
|
||||
// run 10 uploads
|
||||
for (let i = 0; i < 10;i++) {
|
||||
await conn.prepareMessageContent (content, MessageType.audio, { filename: 'audio.mp3', mimetype: Mimetype.mp4Audio })
|
||||
}
|
||||
})
|
||||
it('should send a gif', async () => {
|
||||
const message = await sendAndRetrieveMessage(conn, { url: './Media/ma_gif.mp4' }, MessageType.video, { mimetype: Mimetype.gif })
|
||||
|
||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_vid')
|
||||
})
|
||||
it('should send an audio', async () => {
|
||||
const content = await fs.readFile('./Media/sonata.mp3')
|
||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio })
|
||||
// check duration was okay
|
||||
assert.ok (message.message.audioMessage.seconds > 0)
|
||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
||||
})
|
||||
it('should send an audio as a voice note', async () => {
|
||||
const content = await fs.readFile('./Media/sonata.mp3')
|
||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true })
|
||||
|
||||
assert.ok (message.message.audioMessage.seconds > 0)
|
||||
assert.strictEqual (message.message?.audioMessage?.ptt, true)
|
||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
||||
})
|
||||
it('should send a jpeg image', async () => {
|
||||
const message = await sendAndRetrieveMessage(conn, { url: './Media/meme.jpeg' }, MessageType.image)
|
||||
assert.ok(message.message.imageMessage.jpegThumbnail.length > 0)
|
||||
const msg = await conn.downloadMediaMessage(message)
|
||||
assert.deepStrictEqual(msg, await fs.readFile('./Media/meme.jpeg'))
|
||||
})
|
||||
it('should send a remote jpeg image', async () => {
|
||||
const message = await sendAndRetrieveMessage(
|
||||
conn,
|
||||
{ url: 'https://www.memestemplates.com/wp-content/uploads/2020/05/tom-with-phone.jpg' },
|
||||
MessageType.image
|
||||
)
|
||||
assert.ok (message.message?.imageMessage?.jpegThumbnail)
|
||||
await conn.downloadMediaMessage(message)
|
||||
})
|
||||
it('should send a png image', async () => {
|
||||
const content = await fs.readFile('./Media/icon.png')
|
||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.image, { mimetype: 'image/png' })
|
||||
assert.ok (message.message?.imageMessage?.jpegThumbnail)
|
||||
await conn.downloadMediaMessage(message)
|
||||
})
|
||||
it('should send a sticker', async () => {
|
||||
const content = await fs.readFile('./Media/octopus.webp')
|
||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.sticker)
|
||||
|
||||
await conn.downloadMediaMessage(message)
|
||||
})
|
||||
/*it('should send an interactive message', async () => {
|
||||
|
||||
console.log (
|
||||
JSON.stringify(await conn.loadMessages (testJid, 5), null, '\t')
|
||||
)
|
||||
const message = conn.prepareMessageFromContent (
|
||||
testJid,
|
||||
{
|
||||
templateMessage: {
|
||||
fourRowTemplate: {
|
||||
content: {
|
||||
namespace: 'my-namespace',
|
||||
localizableParams: [
|
||||
|
||||
],
|
||||
params: ['hello!']
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
index: 0,
|
||||
quickReplyButton: {
|
||||
displayText: {
|
||||
params: ['my name jeff']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
quickReplyButton: {
|
||||
displayText: {
|
||||
params: ['my name NOT jeff'],
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
await conn.relayWAMessage (message)
|
||||
})*/
|
||||
it('should send an image & quote', async () => {
|
||||
const quoted = (await conn.loadMessages(testJid, 2)).messages[0]
|
||||
const content = await fs.readFile('./Media/meme.jpeg')
|
||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.image, { quoted })
|
||||
|
||||
await conn.downloadMediaMessage(message) // check for successful decoding
|
||||
assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, quoted.key.id)
|
||||
})
|
||||
it('should send a message & delete it', async () => {
|
||||
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text)
|
||||
await delay (2000)
|
||||
await conn.deleteMessage (testJid, message.key)
|
||||
})
|
||||
it('should clear the most recent message', async () => {
|
||||
const {messages} = await conn.loadMessages (testJid, 1)
|
||||
await delay (2000)
|
||||
await conn.clearMessage (messages[0].key)
|
||||
})
|
||||
it('should send media after close', async () => {
|
||||
const content = await fs.readFile('./Media/octopus.webp')
|
||||
await sendAndRetrieveMessage(conn, content, MessageType.sticker)
|
||||
|
||||
conn.close ()
|
||||
|
||||
await conn.connect ()
|
||||
|
||||
const content2 = await fs.readFile('./Media/cat.jpeg')
|
||||
await sendAndRetrieveMessage(conn, content2, MessageType.image)
|
||||
})
|
||||
it('should fail to send a text message', async () => {
|
||||
const JID = '1234-1234@g.us'
|
||||
const messageId = generateMessageID()
|
||||
conn.sendMessage(JID, 'hello', MessageType.text, { messageId })
|
||||
|
||||
await new Promise(resolve => (
|
||||
conn.on ('chat-update', async update => {
|
||||
console.log(messageId, update.messages?.first)
|
||||
if (
|
||||
update.jid === JID &&
|
||||
update.messages?.first.key.id === messageId &&
|
||||
update.messages.first.status === WA_MESSAGE_STATUS_TYPE.ERROR) {
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
))
|
||||
conn.removeAllListeners('chat-update')
|
||||
})
|
||||
it('should maintain message integrity', async () => {
|
||||
// loading twice does not alter the results
|
||||
const results = await Promise.all ([
|
||||
conn.loadMessages (testJid, 50),
|
||||
conn.loadMessages (testJid, 50)
|
||||
])
|
||||
assert.strictEqual (results[0].messages.length, results[1].messages.length)
|
||||
for (let i = 0; i < results[1].messages.length;i++) {
|
||||
assert.deepStrictEqual (
|
||||
results[0].messages[i].key,
|
||||
results[1].messages[i].key,
|
||||
`failed equal at ${i}`
|
||||
)
|
||||
}
|
||||
assert.ok (results[0].messages.length <= 50)
|
||||
|
||||
// check if messages match server
|
||||
let msgs = await conn.fetchMessagesFromWA (testJid, 50)
|
||||
for (let i = 0; i < results[1].messages.length;i++) {
|
||||
assert.deepStrictEqual (
|
||||
results[0].messages[i].key,
|
||||
msgs[i].key,
|
||||
`failed equal at ${i}`
|
||||
)
|
||||
}
|
||||
// check with some arbitary cursors
|
||||
let cursor = results[0].messages.slice(-1)[0].key
|
||||
|
||||
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
|
||||
let {messages} = await conn.loadMessages (testJid, 20, cursor)
|
||||
for (let i = 0; i < messages.length;i++) {
|
||||
assert.deepStrictEqual (
|
||||
messages[i].key,
|
||||
msgs[i].key,
|
||||
`failed equal at ${i}`
|
||||
)
|
||||
}
|
||||
for (let i = 0; i < 3;i++) {
|
||||
cursor = results[0].messages[i].key
|
||||
|
||||
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
|
||||
messages = (await conn.loadMessages (testJid, 20, cursor)).messages
|
||||
for (let i = 0; i < messages.length;i++) {
|
||||
assert.deepStrictEqual (messages[i].key, msgs[i].key, `failed equal at ${i}`)
|
||||
}
|
||||
|
||||
cursor = msgs[0].key
|
||||
|
||||
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
|
||||
messages = (await conn.loadMessages (testJid, 20, cursor)).messages
|
||||
for (let i = 0; i < messages.length;i++) {
|
||||
assert.deepStrictEqual (messages[i].key, msgs[i].key, `failed equal at ${i}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
it('should deliver a message', async () => {
|
||||
const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text)
|
||||
const waitForUpdate =
|
||||
promiseTimeout(15000, resolve => {
|
||||
conn.on('chat-update', update => {
|
||||
if (update.messages?.first.key.id === response.key.id) {
|
||||
resolve(update.messages.first)
|
||||
}
|
||||
})
|
||||
}) as Promise<WAMessage>
|
||||
|
||||
const m = await waitForUpdate
|
||||
assert.ok (m.status >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK)
|
||||
})
|
||||
})
|
||||
@@ -1,430 +0,0 @@
|
||||
import { Presence, ChatModification, delay, newMessagesDB, WA_DEFAULT_EPHEMERAL, MessageType, WAMessage } from '../WAConnection'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as assert from 'assert'
|
||||
import got from 'got'
|
||||
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
|
||||
|
||||
WAConnectionTest('Misc', conn => {
|
||||
|
||||
it('should tell if someone has an account on WhatsApp', async () => {
|
||||
const response = await conn.isOnWhatsApp(testJid)
|
||||
assert.strictEqual(response, true)
|
||||
|
||||
const responseFail = await conn.isOnWhatsApp('abcd@s.whatsapp.net')
|
||||
assert.strictEqual(responseFail, false)
|
||||
})
|
||||
it('should return the status', async () => {
|
||||
const response = await conn.getStatus(testJid)
|
||||
assert.strictEqual(typeof response.status, 'string')
|
||||
})
|
||||
it('should update status', async () => {
|
||||
const newStatus = 'v cool status'
|
||||
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.on ('contact-update', ({jid, status}) => {
|
||||
if (jid === conn.user.jid) {
|
||||
assert.strictEqual (status, newStatus)
|
||||
conn.removeAllListeners ('contact-update')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const response = await conn.getStatus()
|
||||
assert.strictEqual(typeof response.status, 'string')
|
||||
|
||||
await delay (1000)
|
||||
|
||||
await conn.setStatus (newStatus)
|
||||
const response2 = await conn.getStatus()
|
||||
assert.strictEqual (response2.status, newStatus)
|
||||
|
||||
await waitForEvent
|
||||
|
||||
await delay (1000)
|
||||
|
||||
await conn.setStatus (response.status) // update back
|
||||
})
|
||||
it('should update profile name', async () => {
|
||||
const newName = 'v cool name'
|
||||
|
||||
await delay (1000)
|
||||
|
||||
const originalName = conn.user.name!
|
||||
|
||||
const waitForEvent = new Promise<void> (resolve => {
|
||||
conn.on ('contact-update', ({name}) => {
|
||||
assert.strictEqual (name, newName)
|
||||
conn.removeAllListeners ('contact-update')
|
||||
resolve ()
|
||||
})
|
||||
})
|
||||
|
||||
await conn.updateProfileName (newName)
|
||||
|
||||
await waitForEvent
|
||||
|
||||
await delay (1000)
|
||||
|
||||
assert.strictEqual (conn.user.name, newName)
|
||||
|
||||
await delay (1000)
|
||||
|
||||
await conn.updateProfileName (originalName) // update back
|
||||
})
|
||||
it('should return the stories', async () => {
|
||||
await conn.getStories()
|
||||
})
|
||||
|
||||
it('should return the profile picture correctly', async () => {
|
||||
// wait for chats
|
||||
await new Promise(resolve => (
|
||||
conn.once('initial-data-received', resolve)
|
||||
))
|
||||
const pictures = await Promise.all(
|
||||
conn.chats.all().slice(0, 15).map(({ jid }) => (
|
||||
conn.getProfilePicture(jid)
|
||||
.catch(err => '')
|
||||
))
|
||||
)
|
||||
// pictures should return correctly
|
||||
const truePictures = pictures.filter(pp => !!pp)
|
||||
assert.strictEqual(
|
||||
new Set(truePictures).size,
|
||||
truePictures.length
|
||||
)
|
||||
})
|
||||
|
||||
it('should change the profile picture', async () => {
|
||||
await delay (5000)
|
||||
|
||||
const ppUrl = await conn.getProfilePicture(conn.user.jid)
|
||||
const {rawBody: oldPP} = await got(ppUrl)
|
||||
|
||||
const newPP = await fs.readFile('./Media/cat.jpeg')
|
||||
await conn.updateProfilePicture(conn.user.jid, newPP)
|
||||
|
||||
await delay (10000)
|
||||
|
||||
await conn.updateProfilePicture (conn.user.jid, oldPP) // revert back
|
||||
})
|
||||
it('should send typing indicator', async () => {
|
||||
const response = await conn.updatePresence(testJid, Presence.composing)
|
||||
assert.ok(response)
|
||||
})
|
||||
it('should change a chat read status', async () => {
|
||||
const jids = conn.chats.all ().map (c => c.jid)
|
||||
for (let jid of jids.slice(0, 5)) {
|
||||
console.log (`changing read status for ${jid}`)
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.once ('chat-update', ({jid: tJid, count}) => {
|
||||
if (jid === tJid) {
|
||||
assert.ok (count < 0)
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
await conn.chatRead (jid, 'unread')
|
||||
await waitForEvent
|
||||
|
||||
await delay (5000)
|
||||
|
||||
await conn.chatRead (jid, 'read')
|
||||
}
|
||||
})
|
||||
it('should archive & unarchive', async () => {
|
||||
// wait for chats
|
||||
await new Promise(resolve => (
|
||||
conn.once('chats-received', ({ }) => resolve(undefined))
|
||||
))
|
||||
|
||||
const idx = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
||||
await conn.modifyChat (testJid, ChatModification.archive)
|
||||
const idx2 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
||||
assert.ok(idx < idx2) // should move further down the array
|
||||
|
||||
await delay (2000)
|
||||
await conn.modifyChat (testJid, ChatModification.unarchive)
|
||||
const idx3 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
||||
assert.strictEqual(idx, idx3) // should be back there
|
||||
})
|
||||
it('should archive & unarchive on new message', async () => {
|
||||
// wait for chats
|
||||
await new Promise(resolve => (
|
||||
conn.once('chats-received', ({ }) => resolve(undefined))
|
||||
))
|
||||
|
||||
const idx = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
||||
await conn.modifyChat (testJid, ChatModification.archive)
|
||||
const idx2 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
||||
assert.ok(idx < idx2) // should move further down the array
|
||||
|
||||
await delay (2000)
|
||||
await sendAndRetrieveMessage(conn, 'test', MessageType.text)
|
||||
// should be unarchived
|
||||
const idx3 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
||||
assert.strictEqual(idx, idx3) // should be back there
|
||||
})
|
||||
it('should pin & unpin a chat', async () => {
|
||||
await conn.modifyChat (testJid, ChatModification.pin)
|
||||
await delay (2000)
|
||||
await conn.modifyChat (testJid, ChatModification.unpin)
|
||||
})
|
||||
it('should mute & unmute a chat', async () => {
|
||||
const waitForEvent = new Promise (resolve => {
|
||||
conn.on ('chat-update', ({jid, mute}) => {
|
||||
if (jid === testJid ) {
|
||||
assert.ok (mute)
|
||||
conn.removeAllListeners ('chat-update')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
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 star/unstar messages', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await conn.sendMessage(testJid, `Message ${i}`, MessageType.text)
|
||||
await delay(1000)
|
||||
}
|
||||
|
||||
let response = await conn.loadMessages(testJid, 5)
|
||||
let starred = response.messages.filter(m => m.starred)
|
||||
assert.strictEqual(starred.length, 0)
|
||||
|
||||
conn.starMessage(response.messages[2].key)
|
||||
await delay(2000)
|
||||
conn.starMessage(response.messages[4].key)
|
||||
await delay(2000)
|
||||
|
||||
response = await conn.loadMessages(testJid, 5)
|
||||
starred = response.messages.filter(m => m.starred)
|
||||
assert.strictEqual(starred.length, 2)
|
||||
await delay(2000)
|
||||
|
||||
conn.starMessage(response.messages[2].key, 'unstar')
|
||||
await delay(2000)
|
||||
|
||||
response = await conn.loadMessages(testJid, 5)
|
||||
starred = response.messages.filter(m => m.starred)
|
||||
assert.strictEqual(starred.length, 1)
|
||||
})
|
||||
it('should clear a chat', async () => {
|
||||
// Uses chat with yourself to avoid losing chats
|
||||
const selfJid = conn.user.jid
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await conn.sendMessage(selfJid, `Message ${i}`, MessageType.text)
|
||||
await delay(1000)
|
||||
}
|
||||
|
||||
let response = await conn.loadMessages(selfJid, 50)
|
||||
const initialCount = response.messages.length
|
||||
|
||||
assert.ok(response.messages.length >= 0)
|
||||
|
||||
conn.starMessage(response.messages[2].key)
|
||||
await delay(2000)
|
||||
conn.starMessage(response.messages[4].key)
|
||||
await delay(2000)
|
||||
|
||||
await conn.modifyChat(selfJid, ChatModification.clear)
|
||||
await delay(2000)
|
||||
|
||||
response = await conn.loadMessages(selfJid, 50)
|
||||
await delay(2000)
|
||||
assert.ok(response.messages.length < initialCount)
|
||||
assert.ok(response.messages.length > 1)
|
||||
|
||||
await conn.modifyChat(selfJid, ChatModification.clear, true)
|
||||
await delay(2000)
|
||||
|
||||
response = await conn.loadMessages(selfJid, 50)
|
||||
assert.strictEqual(response.messages.length, 1)
|
||||
})
|
||||
it('should return search results', async () => {
|
||||
const jids = [null, testJid]
|
||||
for (let i in jids) {
|
||||
let response = await conn.searchMessages('Hello', jids[i], 25, 1)
|
||||
assert.ok (response.messages)
|
||||
assert.ok (response.messages.length >= 0)
|
||||
|
||||
response = await conn.searchMessages('剛剛試咗😋一個字', jids[i], 25, 1)
|
||||
assert.ok (response.messages)
|
||||
assert.ok (response.messages.length >= 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should load a single message', async () => {
|
||||
const {messages} = await conn.loadMessages (testJid, 25)
|
||||
for (var message of messages) {
|
||||
const loaded = await conn.loadMessage (testJid, message.key.id)
|
||||
assert.strictEqual (loaded.key.id, message.key.id, `loaded message ${JSON.stringify(message)} incorrectly`)
|
||||
await delay (500)
|
||||
}
|
||||
})
|
||||
// open the other phone and look at the updates to really verify stuff
|
||||
it('should send presence updates', async () => {
|
||||
conn.shouldLogMessages = true
|
||||
conn.requestPresenceUpdate(testJid)
|
||||
|
||||
const sequence = [ Presence.available, Presence.composing, Presence.paused, Presence.recording, Presence.paused, Presence.unavailable ]
|
||||
for (const presence of sequence) {
|
||||
await delay(5000)
|
||||
await conn.updatePresence(presence !== Presence.unavailable ? testJid : null, presence)
|
||||
//console.log(conn.messageLog.slice(-1))
|
||||
console.log('sent update ', presence)
|
||||
}
|
||||
})
|
||||
it('should generate link previews correctly', async () => {
|
||||
await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys')
|
||||
// two links should fail
|
||||
await assert.rejects (
|
||||
conn.generateLinkPreview ('I sent links to https://teachyourselfcs.com/ and https://www.fast.ai/')
|
||||
)
|
||||
})
|
||||
// this test requires quite a few messages with the test JID
|
||||
it('should detect overlaps and clear messages accordingly', async () => {
|
||||
// wait for chats
|
||||
await new Promise(resolve => (
|
||||
conn.once('initial-data-received', resolve)
|
||||
))
|
||||
|
||||
conn.maxCachedMessages = 100
|
||||
|
||||
const chat = conn.chats.get(testJid)
|
||||
const oldCount = chat.messages.length
|
||||
console.log(`test chat has ${oldCount} pre-loaded messages`)
|
||||
// load 100 messages
|
||||
await conn.loadMessages(testJid, 100, undefined)
|
||||
assert.strictEqual(chat.messages.length, 100)
|
||||
|
||||
conn.close()
|
||||
// remove all latest messages
|
||||
chat.messages = newMessagesDB( chat.messages.all().slice(0, 20) )
|
||||
|
||||
const task = new Promise(resolve => (
|
||||
conn.on('initial-data-received', ({ chatsWithMissingMessages }) => {
|
||||
assert.strictEqual(Object.keys(chatsWithMissingMessages).length, 1)
|
||||
const missing = chatsWithMissingMessages.find(({ jid }) => jid === testJid)
|
||||
assert.ok(missing, 'missing message not detected')
|
||||
assert.strictEqual(
|
||||
conn.chats.get(testJid).messages.length,
|
||||
missing.count
|
||||
)
|
||||
assert.strictEqual(missing.count, oldCount)
|
||||
resolve(undefined)
|
||||
})
|
||||
))
|
||||
|
||||
await conn.connect()
|
||||
|
||||
await task
|
||||
})
|
||||
|
||||
it('should toggle disappearing messages', async () => {
|
||||
let chat = conn.chats.get(testJid)
|
||||
if (!chat) {
|
||||
// wait for chats
|
||||
await new Promise(resolve => (
|
||||
conn.once('chats-received', resolve)
|
||||
))
|
||||
chat = conn.chats.get(testJid)
|
||||
}
|
||||
|
||||
const waitForChatUpdate = (ephemeralOn: boolean) => (
|
||||
new Promise(resolve => (
|
||||
conn.on('chat-update', ({ jid, ephemeral }) => {
|
||||
if (jid === testJid && typeof ephemeral !== 'undefined') {
|
||||
assert.strictEqual(!!(+ephemeral), ephemeralOn)
|
||||
assert.strictEqual(!!(+chat.ephemeral), ephemeralOn)
|
||||
resolve(undefined)
|
||||
conn.removeAllListeners('chat-update')
|
||||
}
|
||||
})
|
||||
))
|
||||
)
|
||||
const toggleDisappearingMessages = async (on: boolean) => {
|
||||
const update = waitForChatUpdate(on)
|
||||
await conn.toggleDisappearingMessages(testJid, on ? WA_DEFAULT_EPHEMERAL : 0)
|
||||
await update
|
||||
}
|
||||
|
||||
if (!chat.eph_setting_ts) {
|
||||
await toggleDisappearingMessages(true)
|
||||
}
|
||||
|
||||
await delay(1000)
|
||||
|
||||
let msg = await sendAndRetrieveMessage(
|
||||
conn,
|
||||
'This will go poof 😱',
|
||||
MessageType.text
|
||||
)
|
||||
assert.ok(msg.message?.ephemeralMessage)
|
||||
|
||||
const contextInfo = msg.message?.ephemeralMessage?.message?.extendedTextMessage?.contextInfo
|
||||
assert.strictEqual(contextInfo.expiration, chat.ephemeral)
|
||||
assert.strictEqual(+contextInfo.ephemeralSettingTimestamp, +chat.eph_setting_ts)
|
||||
// test message deletion
|
||||
await conn.deleteMessage(testJid, msg.key)
|
||||
|
||||
await delay(1000)
|
||||
|
||||
await toggleDisappearingMessages(false)
|
||||
|
||||
await delay(1000)
|
||||
|
||||
msg = await sendAndRetrieveMessage(
|
||||
conn,
|
||||
'This will not go poof 😔',
|
||||
MessageType.text
|
||||
)
|
||||
assert.ok(msg.message.extendedTextMessage)
|
||||
})
|
||||
it('should block & unblock a user', async () => {
|
||||
const blockedCount = conn.blocklist.length;
|
||||
|
||||
const waitForEventAdded = new Promise<void> (resolve => {
|
||||
conn.once ('blocklist-update', ({added}) => {
|
||||
assert.ok (added.length)
|
||||
resolve ()
|
||||
})
|
||||
})
|
||||
|
||||
await conn.blockUser (testJid, 'add')
|
||||
assert.strictEqual(conn.blocklist.length, blockedCount + 1);
|
||||
await waitForEventAdded
|
||||
|
||||
await delay (2000)
|
||||
const waitForEventRemoved = new Promise<void> (resolve => {
|
||||
conn.once ('blocklist-update', ({removed}) => {
|
||||
assert.ok (removed.length)
|
||||
resolve ()
|
||||
})
|
||||
})
|
||||
|
||||
await conn.blockUser (testJid, 'remove')
|
||||
assert.strictEqual(conn.blocklist.length, blockedCount);
|
||||
await waitForEventRemoved
|
||||
})
|
||||
it('should exit an invalid query', async () => {
|
||||
// try and send an already sent message
|
||||
let msg: WAMessage
|
||||
await conn.findMessage(testJid, 5, m => {
|
||||
if(m.key.fromMe) {
|
||||
msg = m
|
||||
return true
|
||||
}
|
||||
})
|
||||
try {
|
||||
await conn.relayWAMessage(msg)
|
||||
assert.fail('should not have sent')
|
||||
} catch(error) {
|
||||
assert.strictEqual(error.status, 422)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
import { strict as assert } from 'assert'
|
||||
import { Mutex } from '../WAConnection/Mutex'
|
||||
|
||||
const DEFAULT_WAIT = 1000
|
||||
|
||||
class MyClass {
|
||||
didDoWork = false
|
||||
values: { [k: string]: number } = {}
|
||||
counter = 0
|
||||
|
||||
@Mutex ()
|
||||
async myFunction () {
|
||||
if (this.didDoWork) return
|
||||
|
||||
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
|
||||
if (this.didDoWork) {
|
||||
throw new Error ('work already done')
|
||||
}
|
||||
this.didDoWork = true
|
||||
}
|
||||
@Mutex (key => key)
|
||||
async myKeyedFunction (key: string) {
|
||||
if (!this.values[key]) {
|
||||
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
|
||||
if (this.values[key]) throw new Error ('value already set for ' + key)
|
||||
this.values[key] = Math.floor(Math.random ()*100)
|
||||
}
|
||||
return this.values[key]
|
||||
}
|
||||
@Mutex (key => key)
|
||||
async myQueingFunction (key: string) {
|
||||
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
|
||||
}
|
||||
@Mutex ()
|
||||
async myErrorFunction () {
|
||||
await new Promise (resolve => setTimeout(resolve, 100))
|
||||
this.counter += 1
|
||||
if (this.counter % 2 === 0) {
|
||||
throw new Error ('failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe ('garbage', () => {
|
||||
it ('should only execute once', async () => {
|
||||
const stuff = new MyClass ()
|
||||
const start = new Date ()
|
||||
await Promise.all ([...Array(1000)].map(() => stuff.myFunction ()))
|
||||
const diff = new Date ().getTime()-start.getTime()
|
||||
assert.ok (diff < DEFAULT_WAIT*1.25)
|
||||
})
|
||||
it ('should only execute once based on the key', async () => {
|
||||
const stuff = new MyClass ()
|
||||
const start = new Date ()
|
||||
/*
|
||||
In this test, the mutex will lock the function based on the key.
|
||||
|
||||
So, if the function with argument `key1` is underway
|
||||
and another function with key `key1` is called,
|
||||
the call is blocked till the first function completes.
|
||||
However, if argument `key2` is passed, the function is allowed to pass.
|
||||
*/
|
||||
const keys = ['key1', 'key2', 'key3']
|
||||
const duplicates = 1000
|
||||
const results = await Promise.all (
|
||||
keys.flatMap (key => (
|
||||
[...Array(duplicates)].map(() => stuff.myKeyedFunction (key))
|
||||
))
|
||||
)
|
||||
assert.deepStrictEqual (
|
||||
results.slice(0, duplicates).filter (r => r !== results[0]),
|
||||
[]
|
||||
)
|
||||
|
||||
const diff = new Date ().getTime()-start.getTime()
|
||||
assert.ok (diff < DEFAULT_WAIT*1.25)
|
||||
})
|
||||
it ('should execute operations in a queue', async () => {
|
||||
const stuff = new MyClass ()
|
||||
const start = new Date ()
|
||||
|
||||
const keys = ['key1', 'key2', 'key3']
|
||||
|
||||
await Promise.all (
|
||||
keys.flatMap (key => (
|
||||
[...Array(2)].map(() => stuff.myQueingFunction (key))
|
||||
))
|
||||
)
|
||||
|
||||
const diff = new Date ().getTime()-start.getTime()
|
||||
assert.ok (diff < DEFAULT_WAIT*2.2 && diff > DEFAULT_WAIT*1.5)
|
||||
})
|
||||
it ('should throw an error on selected items', async () => {
|
||||
const stuff = new MyClass ()
|
||||
const start = new Date ()
|
||||
|
||||
const WAIT = 100
|
||||
const FUNCS = 40
|
||||
const results = await Promise.all (
|
||||
[...Array(FUNCS)].map(() => stuff.myErrorFunction ().catch(err => err.message))
|
||||
)
|
||||
|
||||
const diff = new Date ().getTime()-start.getTime()
|
||||
assert.ok (diff < WAIT*FUNCS*1.1)
|
||||
|
||||
assert.strictEqual (
|
||||
results.filter (r => r === 'failed').length,
|
||||
FUNCS/2 // half should fail
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
|
||||
describe ('Reconnects', () => {
|
||||
const verifyConnectionOpen = async (conn: Connection) => {
|
||||
expect((await conn.getState()).user).toBeDefined()
|
||||
let failed = false
|
||||
// check that the connection stays open
|
||||
conn.ev.on('state.update', ({ connection, lastDisconnect }) => {
|
||||
if(connection === 'close' && !!lastDisconnect.error) {
|
||||
failed = true
|
||||
}
|
||||
})
|
||||
await delay (60*1000)
|
||||
conn.close ()
|
||||
|
||||
expect(failed).toBe(false)
|
||||
}
|
||||
it('should dispose correctly on bad_session', async () => {
|
||||
const conn = makeConnection({
|
||||
reconnectMode: 'on-any-error',
|
||||
credentials: './auth_info.json',
|
||||
maxRetries: 2,
|
||||
connectCooldownMs: 500
|
||||
})
|
||||
let gotClose0 = false
|
||||
let gotClose1 = false
|
||||
|
||||
const openPromise = conn.open()
|
||||
|
||||
conn.getSocket().ev.once('ws-close', () => {
|
||||
gotClose0 = true
|
||||
})
|
||||
conn.ev.on('state.update', ({ lastDisconnect }) => {
|
||||
//@ts-ignore
|
||||
if(lastDisconnect?.error?.output?.statusCode === DisconnectReason.badSession) {
|
||||
gotClose1 = true
|
||||
}
|
||||
})
|
||||
setTimeout (() => conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
|
||||
await openPromise
|
||||
|
||||
console.log('opened connection')
|
||||
|
||||
await delay(1000)
|
||||
conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je'))
|
||||
|
||||
await delay(2000)
|
||||
await conn.waitForConnection()
|
||||
|
||||
conn.close()
|
||||
|
||||
expect(gotClose0).toBe(true)
|
||||
expect(gotClose1).toBe(true)
|
||||
}, 20_000)
|
||||
/**
|
||||
* the idea is to test closing the connection at multiple points in the connection
|
||||
* and see if the library cleans up resources correctly
|
||||
*/
|
||||
it('should cleanup correctly', async () => {
|
||||
const conn = makeConnection({
|
||||
reconnectMode: 'on-any-error',
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
let timeoutMs = 100
|
||||
while (true) {
|
||||
let tmout = setTimeout (() => {
|
||||
conn.close()
|
||||
}, timeoutMs)
|
||||
try {
|
||||
await conn.open()
|
||||
clearTimeout (tmout)
|
||||
break
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
// exponentially increase the timeout disconnect
|
||||
timeoutMs *= 2
|
||||
}
|
||||
await verifyConnectionOpen(conn)
|
||||
}, 120_000)
|
||||
/**
|
||||
* the idea is to test closing the connection at multiple points in the connection
|
||||
* and see if the library cleans up resources correctly
|
||||
*/
|
||||
it('should disrupt connect loop', async () => {
|
||||
const conn = makeConnection({
|
||||
reconnectMode: 'on-any-error',
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
|
||||
let timeout = 1000
|
||||
let tmout
|
||||
const endConnection = async () => {
|
||||
while (!conn.getSocket()) {
|
||||
await delay(100)
|
||||
}
|
||||
conn.getSocket().end(Boom.preconditionRequired('conn close'))
|
||||
|
||||
while (conn.getSocket()) {
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
timeout *= 2
|
||||
tmout = setTimeout (endConnection, timeout)
|
||||
}
|
||||
tmout = setTimeout (endConnection, timeout)
|
||||
|
||||
await conn.open()
|
||||
clearTimeout (tmout)
|
||||
|
||||
await verifyConnectionOpen(conn)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
import BinaryNode from '../BinaryNode'
|
||||
|
||||
describe('Binary Coding Tests', () => {
|
||||
|
||||
const TEST_VECTORS: [string, BinaryNode][] = [
|
||||
[
|
||||
'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305',
|
||||
new BinaryNode(
|
||||
'action',
|
||||
{ last: 'true', add: 'before' },
|
||||
[
|
||||
new BinaryNode(
|
||||
'message',
|
||||
{},
|
||||
{
|
||||
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB009675E7ED37AABF2' },
|
||||
message: { conversation: '*piano room timings are:*\n 6:00AM-12:00AM' },
|
||||
messageTimestamp: '1584004403',
|
||||
status: 'DELIVERY_ACK',
|
||||
} as any
|
||||
),
|
||||
new BinaryNode(
|
||||
'message',
|
||||
{},
|
||||
{
|
||||
key: {
|
||||
remoteJid: '917529871117@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD',
|
||||
},
|
||||
messageTimestamp: '1584004413',
|
||||
messageStubType: 'REVOKE',
|
||||
} as any
|
||||
),
|
||||
new BinaryNode(
|
||||
'message',
|
||||
{},
|
||||
{
|
||||
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB003C7B539AFD09753' },
|
||||
message: {
|
||||
conversation:
|
||||
"Sorry fren, I couldn't understand 'Libra'. Type 'help' to know what all I can do",
|
||||
},
|
||||
messageTimestamp: '1584004417',
|
||||
status: 'DELIVERY_ACK',
|
||||
} as any
|
||||
),
|
||||
new BinaryNode(
|
||||
'message',
|
||||
{},
|
||||
{
|
||||
key: {
|
||||
remoteJid: '917529871117@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8',
|
||||
},
|
||||
message: { conversation: 'library' },
|
||||
messageTimestamp: '1584004418',
|
||||
} as any
|
||||
),
|
||||
]
|
||||
)
|
||||
],
|
||||
[
|
||||
'f8063f2dfafc0831323334353637385027fc0431323334f801f80228fc0701020304050607',
|
||||
new BinaryNode(
|
||||
'picture',
|
||||
{jid: '12345678@s.whatsapp.net', id: '1234'},
|
||||
[
|
||||
new BinaryNode(
|
||||
'image',
|
||||
{},
|
||||
Buffer.from([1,2,3,4,5,6,7])
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
]
|
||||
it('should encode/decode strings', () => {
|
||||
for(const [input, output] of TEST_VECTORS) {
|
||||
const buff = Buffer.from(input, 'hex')
|
||||
const node = BinaryNode.from(buff)
|
||||
expect(
|
||||
JSON.parse(JSON.stringify(node))
|
||||
).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(output))
|
||||
)
|
||||
expect(
|
||||
node.toBuffer().toString('hex')
|
||||
).toStrictEqual(
|
||||
input
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import P from 'pino'
|
||||
import BinaryNode from '../BinaryNode'
|
||||
import makeConnection from '../Connection'
|
||||
import { delay } from '../Utils/generics'
|
||||
|
||||
describe('QR Generation', () => {
|
||||
it('should generate QR', async () => {
|
||||
const QR_GENS = 1
|
||||
const {ev} = makeConnection({
|
||||
maxRetries: 0,
|
||||
maxQRCodes: QR_GENS,
|
||||
logger: P({ level: 'trace' })
|
||||
})
|
||||
let calledQR = 0
|
||||
ev.on('connection.update', ({ qr }) => {
|
||||
if(qr) calledQR += 1
|
||||
})
|
||||
|
||||
await expect(open()).rejects.toThrowError('Too many QR codes')
|
||||
expect(calledQR).toBeGreaterThanOrEqual(QR_GENS)
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
describe('Test Connect', () => {
|
||||
|
||||
const logger = P({ level: 'trace' })
|
||||
|
||||
it('should connect', async () => {
|
||||
|
||||
logger.info('please be ready to scan with your phone')
|
||||
|
||||
const conn = makeConnection({
|
||||
logger,
|
||||
printQRInTerminal: true
|
||||
})
|
||||
await conn.waitForConnection(true)
|
||||
const { user, isNewLogin } = await conn.getState()
|
||||
expect(user).toHaveProperty('jid')
|
||||
expect(user).toHaveProperty('name')
|
||||
expect(isNewLogin).toBe(true)
|
||||
|
||||
conn.end(undefined)
|
||||
}, 65_000)
|
||||
|
||||
it('should restore session', async () => {
|
||||
let conn = makeConnection({
|
||||
printQRInTerminal: true,
|
||||
logger,
|
||||
})
|
||||
await conn.waitForConnection(true)
|
||||
conn.end(undefined)
|
||||
|
||||
await delay(2500)
|
||||
|
||||
conn = makeConnection({
|
||||
printQRInTerminal: true,
|
||||
logger,
|
||||
})
|
||||
await conn.waitForConnection(true)
|
||||
|
||||
const { user, isNewLogin, qr } = await conn.getState()
|
||||
expect(user).toHaveProperty('jid')
|
||||
expect(user).toHaveProperty('name')
|
||||
expect(isNewLogin).toBe(false)
|
||||
expect(qr).toBe(undefined)
|
||||
|
||||
conn.end(undefined)
|
||||
}, 65_000)
|
||||
|
||||
it('should logout', async () => {
|
||||
let conn = makeConnection({
|
||||
printQRInTerminal: true,
|
||||
logger,
|
||||
})
|
||||
await conn.waitForConnection(true)
|
||||
|
||||
const { user, qr } = await conn.getState()
|
||||
expect(user).toHaveProperty('jid')
|
||||
expect(user).toHaveProperty('name')
|
||||
expect(qr).toBe(undefined)
|
||||
|
||||
const credentials = conn.getAuthInfo()
|
||||
await conn.logout()
|
||||
|
||||
conn = makeConnection({
|
||||
credentials,
|
||||
logger
|
||||
})
|
||||
await expect(
|
||||
conn.waitForConnection()
|
||||
).rejects.toThrowError('Unexpected error in login')
|
||||
}, 65_000)
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
describe('Message Generation', () => {
|
||||
|
||||
it('should generate a text message', () => {
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import BinaryNode from '../BinaryNode'
|
||||
import makeConnection from '../makeConnection'
|
||||
import { delay } from '../WAConnection/Utils'
|
||||
|
||||
describe('Queries', () => {
|
||||
/*it ('should correctly send updates for chats', async () => {
|
||||
const conn = makeConnection({
|
||||
pendingRequestTimeoutMs: undefined,
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
const task = new Promise(resolve => conn.once('chats-received', resolve))
|
||||
await conn.connect ()
|
||||
await task
|
||||
|
||||
conn.close ()
|
||||
|
||||
const oldChat = conn.chats.all()[0]
|
||||
oldChat.archive = 'true' // mark the first chat as archived
|
||||
oldChat.modify_tag = '1234' // change modify tag to detect change
|
||||
|
||||
const promise = new Promise(resolve => conn.once('chats-update', resolve))
|
||||
|
||||
const result = await conn.connect ()
|
||||
assert.ok (!result.newConnection)
|
||||
|
||||
const chats = await promise as Partial<WAChat>[]
|
||||
const chat = chats.find (c => c.jid === oldChat.jid)
|
||||
assert.ok (chat)
|
||||
|
||||
assert.ok ('archive' in chat)
|
||||
assert.strictEqual (Object.keys(chat).length, 3)
|
||||
assert.strictEqual (Object.keys(chats).length, 1)
|
||||
|
||||
conn.close ()
|
||||
})
|
||||
it ('should correctly send updates for contacts', async () => {
|
||||
const conn = makeConnection ()
|
||||
conn.pendingRequestTimeoutMs = null
|
||||
conn.loadAuthInfo('./auth_info.json')
|
||||
|
||||
const task: any = new Promise(resolve => conn.once('contacts-received', resolve))
|
||||
await conn.connect ()
|
||||
const initialResult = await task
|
||||
assert.strictEqual(
|
||||
initialResult.updatedContacts.length,
|
||||
Object.keys(conn.contacts).length
|
||||
)
|
||||
|
||||
|
||||
conn.close ()
|
||||
|
||||
const [jid] = Object.keys(conn.contacts)
|
||||
const oldContact = conn.contacts[jid]
|
||||
oldContact.name = 'Lol'
|
||||
oldContact.index = 'L'
|
||||
|
||||
const promise = new Promise(resolve => conn.once('contacts-received', resolve))
|
||||
|
||||
const result = await conn.connect ()
|
||||
assert.ok (!result.newConnection)
|
||||
|
||||
const {updatedContacts} = await promise as { updatedContacts: Partial<WAContact>[] }
|
||||
const contact = updatedContacts.find (c => c.jid === jid)
|
||||
assert.ok (contact)
|
||||
|
||||
assert.ok ('name' in contact)
|
||||
assert.strictEqual (Object.keys(contact).length, 3)
|
||||
assert.strictEqual (Object.keys(updatedContacts).length, 1)
|
||||
|
||||
conn.close ()
|
||||
})*/
|
||||
it('should queue requests when closed', async () => {
|
||||
const conn = makeConnection({
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
await conn.open()
|
||||
await delay(2000)
|
||||
|
||||
conn.close()
|
||||
const { user: { jid } } = await conn.getState()
|
||||
const task: Promise<any> = conn.query({
|
||||
json: ['query', 'Status', jid]
|
||||
})
|
||||
|
||||
await delay(2000)
|
||||
|
||||
conn.open()
|
||||
const json = await task
|
||||
|
||||
expect(json.status).toBeDefined()
|
||||
|
||||
conn.close()
|
||||
}, 65_000)
|
||||
|
||||
it('[MANUAL] should recieve query response after phone disconnect', async () => {
|
||||
const conn = makeConnection ({
|
||||
printQRInTerminal: true,
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
await conn.open()
|
||||
const { phoneConnected } = await conn.getState()
|
||||
expect(phoneConnected).toBe(true)
|
||||
|
||||
try {
|
||||
const waitForEvent = expect => new Promise (resolve => {
|
||||
conn.ev.on('state.update', ({phoneConnected}) => {
|
||||
if (phoneConnected === expect) {
|
||||
conn.ev.removeAllListeners('state.update')
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log('disconnect your phone from the internet')
|
||||
await delay(10_000)
|
||||
console.log('phone should be disconnected now, testing...')
|
||||
|
||||
const query = conn.query({
|
||||
json: new BinaryNode(
|
||||
'query',
|
||||
{
|
||||
epoch: conn.getSocket().currentEpoch().toString(),
|
||||
type: 'message',
|
||||
jid: '1234@s.whatsapp.net',
|
||||
kind: 'before',
|
||||
count: '10',
|
||||
}
|
||||
),
|
||||
requiresPhoneConnection: true,
|
||||
expect200: false
|
||||
})
|
||||
await waitForEvent(false)
|
||||
|
||||
console.log('reconnect your phone to the internet')
|
||||
await waitForEvent(true)
|
||||
|
||||
console.log('reconnected successfully')
|
||||
|
||||
await expect(query).resolves.toBeDefined()
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}, 65_000)
|
||||
|
||||
it('should re-execute query on connection closed error', async () => {
|
||||
const conn = makeConnection({
|
||||
credentials: './auth_info.json'
|
||||
})
|
||||
await conn.open()
|
||||
const { user: { jid } } = await conn.getState()
|
||||
const task: Promise<any> = conn.query({ json: ['query', 'Status', jid], waitForOpen: true })
|
||||
|
||||
await delay(20)
|
||||
// fake cancel the connection
|
||||
conn.getSocket().ev.emit('message', '1234,["Pong",false]')
|
||||
|
||||
await delay(2000)
|
||||
|
||||
const json = await task
|
||||
|
||||
expect(json.status).toBeDefined()
|
||||
|
||||
conn.close()
|
||||
}, 65_000)
|
||||
})
|
||||
@@ -1,22 +1,51 @@
|
||||
import type { Contact } from "./Contact"
|
||||
import type { proto } from "../../WAProto"
|
||||
|
||||
export interface AuthenticationCredentials {
|
||||
clientID: string
|
||||
serverToken: string
|
||||
clientToken: string
|
||||
encKey: Buffer
|
||||
macKey: Buffer
|
||||
export type KeyPair = { public: Uint8Array, private: Uint8Array }
|
||||
export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number }
|
||||
|
||||
export type ProtocolAddress = {
|
||||
name: string // jid
|
||||
deviceId: number
|
||||
}
|
||||
export interface AuthenticationCredentialsBase64 {
|
||||
clientID: string
|
||||
serverToken: string
|
||||
clientToken: string
|
||||
encKey: string
|
||||
macKey: string
|
||||
export type SignalIdentity = {
|
||||
identifier: ProtocolAddress
|
||||
identifierKey: Uint8Array
|
||||
}
|
||||
export interface AuthenticationCredentialsBrowser {
|
||||
WABrowserId: string
|
||||
WASecretBundle: {encKey: string, macKey: string} | string
|
||||
WAToken1: string
|
||||
WAToken2: string
|
||||
|
||||
export type CollectionType = 'regular_high' | 'regular_low' | 'critical_unblock_low' | 'critical_block'
|
||||
|
||||
export type AuthenticationCreds = {
|
||||
noiseKey: KeyPair
|
||||
signedIdentityKey: KeyPair
|
||||
signedPreKey: SignedKeyPair
|
||||
registrationId: number
|
||||
advSecretKey: string
|
||||
me?: Contact
|
||||
account?: proto.ADVSignedDeviceIdentity
|
||||
signalIdentities?: SignalIdentity[]
|
||||
appStateSyncKeys?: proto.IAppStateSyncKey[]
|
||||
appStateVersion?: {
|
||||
[T in CollectionType]: number
|
||||
}
|
||||
|
||||
firstUnuploadedPreKeyId: number
|
||||
serverHasPreKeys: boolean
|
||||
nextPreKeyId: number
|
||||
}
|
||||
export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials
|
||||
type Awaitable<T> = T | Promise<T>
|
||||
export type SignalKeyStore = {
|
||||
getPreKey: (keyId: number) => Awaitable<KeyPair>
|
||||
setPreKey: (keyId: number, pair: KeyPair | null) => Awaitable<void>
|
||||
|
||||
getSession: (sessionId: string) => Awaitable<any>
|
||||
setSession: (sessionId: string, item: any | null) => Awaitable<void>
|
||||
|
||||
getSenderKey: (id: string) => Awaitable<any>
|
||||
setSenderKey: (id: string, item: any | null) => Awaitable<void>
|
||||
}
|
||||
|
||||
export type AuthenticationState = {
|
||||
creds: AuthenticationCreds
|
||||
keys: SignalKeyStore
|
||||
}
|
||||
@@ -1,46 +1,30 @@
|
||||
import type { proto } from "../../WAProto"
|
||||
|
||||
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
|
||||
export enum Presence {
|
||||
unavailable = 'unavailable', // "offline"
|
||||
available = 'available', // "online"
|
||||
composing = 'composing', // "typing..."
|
||||
recording = 'recording', // "recording..."
|
||||
paused = 'paused', // stop typing
|
||||
}
|
||||
export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'
|
||||
|
||||
export interface PresenceData {
|
||||
lastKnownPresence: Presence
|
||||
lastKnownPresence: WAPresence
|
||||
lastSeen?: number
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
jid: string
|
||||
|
||||
t: number
|
||||
/** number of unread messages, is < 0 if the chat is manually marked unread */
|
||||
count: number
|
||||
archive?: 'true' | 'false'
|
||||
clear?: 'true' | 'false'
|
||||
read_only?: 'true' | 'false'
|
||||
mute?: string
|
||||
pin?: string
|
||||
spam?: 'false' | 'true'
|
||||
modify_tag?: string
|
||||
name?: string
|
||||
/** when ephemeral messages were toggled on */
|
||||
eph_setting_ts?: string
|
||||
/** how long each message lasts for */
|
||||
ephemeral?: string
|
||||
export type Chat = Omit<proto.IConversation, 'messages'> & {
|
||||
/** unix timestamp of date when mute ends, if applicable */
|
||||
mute?: number | null
|
||||
/** timestamp of when pinned */
|
||||
pin?: number | null
|
||||
archive?: boolean
|
||||
}
|
||||
|
||||
export type ChatModification =
|
||||
{ archive: boolean } |
|
||||
{
|
||||
/** pin at current timestamp, or provide timestamp of pin to remove */
|
||||
pin: true | { remove: number }
|
||||
pin: number | null
|
||||
} |
|
||||
{
|
||||
/** mute for duration, or provide timestamp of mute to remove*/
|
||||
mute: number | { remove: number }
|
||||
mute: number | null
|
||||
} |
|
||||
{
|
||||
clear: 'all' | { messages: { id: string, fromMe?: boolean }[] }
|
||||
@@ -51,4 +35,7 @@ export type ChatModification =
|
||||
star: boolean
|
||||
}
|
||||
} |
|
||||
{
|
||||
markRead: boolean
|
||||
} |
|
||||
{ delete: true }
|
||||
@@ -1,15 +1,11 @@
|
||||
export interface Contact {
|
||||
verify?: string
|
||||
/** name of the contact, the contact has set on their own on WA */
|
||||
notify?: string
|
||||
jid: string
|
||||
/** I have no idea */
|
||||
vname?: string
|
||||
id: string
|
||||
/** name of the contact, you have saved on your WA */
|
||||
name?: string
|
||||
index?: string
|
||||
/** short name for the contact */
|
||||
short?: string
|
||||
/** name of the contact, the contact has set on their own on WA */
|
||||
notify?: string
|
||||
/** I have no idea */
|
||||
verifiedName?: string
|
||||
// Baileys Added
|
||||
imgUrl?: string
|
||||
status?: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Contact } from "./Contact";
|
||||
|
||||
export type GroupParticipant = (Contact & { isAdmin: boolean; isSuperAdmin: boolean })
|
||||
export type GroupParticipant = (Contact & { isAdmin?: boolean; isSuperAdmin?: boolean, admin?: 'admin' | 'superadmin' | null })
|
||||
|
||||
export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote'
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { ReadStream } from "fs"
|
||||
import type { Logger } from "pino"
|
||||
import type { URL } from "url"
|
||||
import { proto } from '../../WAMessage'
|
||||
import { proto } from '../../WAProto'
|
||||
|
||||
// export the WAMessage Prototypes
|
||||
export { proto as WAMessageProto }
|
||||
export type WAMessage = proto.WebMessageInfo
|
||||
export { proto as WAProto }
|
||||
export type WAMessage = proto.IWebMessageInfo
|
||||
export type WAMessageContent = proto.IMessage
|
||||
export type WAContactMessage = proto.ContactMessage
|
||||
export type WAContactsArrayMessage = proto.ContactsArrayMessage
|
||||
export type WAContactMessage = proto.IContactMessage
|
||||
export type WAContactsArrayMessage = proto.IContactsArrayMessage
|
||||
export type WAMessageKey = proto.IMessageKey
|
||||
export type WATextMessage = proto.ExtendedTextMessage
|
||||
export type WATextMessage = proto.IExtendedTextMessage
|
||||
export type WAContextInfo = proto.IContextInfo
|
||||
export type WALocationMessage = proto.LocationMessage
|
||||
export type WALocationMessage = proto.ILocationMessage
|
||||
export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage | proto.IStickerMessage
|
||||
export import WAMessageStubType = proto.WebMessageInfo.WebMessageInfoStubType
|
||||
export import WAMessageStatus = proto.WebMessageInfo.WebMessageInfoStatus
|
||||
@@ -23,20 +23,10 @@ export type MessageType = keyof proto.Message
|
||||
export type MediaConnInfo = {
|
||||
auth: string
|
||||
ttl: number
|
||||
hosts: {
|
||||
hostname: string
|
||||
}[]
|
||||
hosts: { hostname: string }[]
|
||||
fetchDate: Date
|
||||
}
|
||||
|
||||
/** Reverse stub type dictionary */
|
||||
export const WA_MESSAGE_STUB_TYPES = function () {
|
||||
const types = WAMessageStubType
|
||||
const dict: Record<number, string> = {}
|
||||
Object.keys(types).forEach(element => dict[ types[element] ] = element)
|
||||
return dict
|
||||
}()
|
||||
|
||||
export interface WAUrlInfo {
|
||||
'canonical-url': string
|
||||
'matched-text': string
|
||||
@@ -61,7 +51,7 @@ type WithDimensions = {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document'
|
||||
export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document' | 'history'
|
||||
export type AnyMediaMessageContent = (
|
||||
({
|
||||
image: WAMediaUpload
|
||||
@@ -121,10 +111,7 @@ export type MiscMessageGenerationOptions = {
|
||||
/** the message you want to quote */
|
||||
quoted?: WAMessage
|
||||
/** disappearing messages settings */
|
||||
ephemeralOptions?: {
|
||||
expiration: number | string
|
||||
eph_setting_ts?: number | string
|
||||
}
|
||||
ephemeralExpiration?: number | string
|
||||
}
|
||||
export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & {
|
||||
userJid: string
|
||||
@@ -143,7 +130,7 @@ export type MessageContentGenerationOptions = MediaGenerationOptions & {
|
||||
}
|
||||
export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent
|
||||
|
||||
export type MessageUpdateType = 'prepend' | 'append' | 'notify' | 'last'
|
||||
export type MessageUpdateType = 'append' | 'notify'
|
||||
|
||||
export type MessageInfoEventMap = { [jid: string]: Date }
|
||||
export interface MessageInfo {
|
||||
|
||||
17
src/Types/State.ts
Normal file
17
src/Types/State.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type WAConnectionState = 'open' | 'connecting' | 'close'
|
||||
|
||||
export type ConnectionState = {
|
||||
/** connection is now open, connecting or closed */
|
||||
connection: WAConnectionState
|
||||
/** the error that caused the connection to close */
|
||||
lastDisconnect?: {
|
||||
error: Error
|
||||
date: Date
|
||||
}
|
||||
/** is this a new login */
|
||||
isNewLogin?: boolean
|
||||
/** the current QR code */
|
||||
qr?: string
|
||||
/** has the device received all pending notifications while it was offline */
|
||||
receivedPendingNotifications?: boolean
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type KeyedDB from '@adiwajshing/keyed-db'
|
||||
import type { Chat } from './Chat'
|
||||
import type { Contact } from './Contact'
|
||||
|
||||
export type WAConnectionState = 'open' | 'connecting' | 'close'
|
||||
|
||||
export type ConnectionState = {
|
||||
user?: Contact
|
||||
phoneConnected: boolean
|
||||
phoneInfo?: any
|
||||
connection: WAConnectionState
|
||||
lastDisconnect?: {
|
||||
error: Error,
|
||||
date: Date
|
||||
},
|
||||
isNewLogin?: boolean
|
||||
connectionTriesLeft?: number
|
||||
qr?: string
|
||||
}
|
||||
|
||||
export type BaileysState = {
|
||||
connection: ConnectionState
|
||||
chats: KeyedDB<Chat, string>
|
||||
contacts: { [jid: string]: Contact }
|
||||
}
|
||||
@@ -2,134 +2,56 @@ export * from './Auth'
|
||||
export * from './GroupMetadata'
|
||||
export * from './Chat'
|
||||
export * from './Contact'
|
||||
export * from './Store'
|
||||
export * from './State'
|
||||
export * from './Message'
|
||||
|
||||
import type EventEmitter from "events"
|
||||
import type { Agent } from "https"
|
||||
import type { Logger } from "pino"
|
||||
import type { URL } from "url"
|
||||
import type BinaryNode from "../BinaryNode"
|
||||
import { AnyAuthenticationCredentials, AuthenticationCredentials } from './Auth'
|
||||
|
||||
import { AuthenticationState } from './Auth'
|
||||
import { Chat, PresenceData } from './Chat'
|
||||
import { Contact } from './Contact'
|
||||
import { ConnectionState } from './Store'
|
||||
import { ConnectionState } from './State'
|
||||
|
||||
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
|
||||
import { MessageInfo, MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
|
||||
import { proto } from '../../WAMessage'
|
||||
|
||||
/** used for binary messages */
|
||||
export enum WAMetric {
|
||||
debugLog = 1,
|
||||
queryResume = 2,
|
||||
liveLocation = 3,
|
||||
queryMedia = 4,
|
||||
queryChat = 5,
|
||||
queryContact = 6,
|
||||
queryMessages = 7,
|
||||
presence = 8,
|
||||
presenceSubscribe = 9,
|
||||
group = 10,
|
||||
read = 11,
|
||||
chat = 12,
|
||||
received = 13,
|
||||
picture = 14,
|
||||
status = 15,
|
||||
message = 16,
|
||||
queryActions = 17,
|
||||
block = 18,
|
||||
queryGroup = 19,
|
||||
queryPreview = 20,
|
||||
queryEmoji = 21,
|
||||
queryRead = 22,
|
||||
queryVCard = 29,
|
||||
queryStatus = 30,
|
||||
queryStatusUpdate = 31,
|
||||
queryLiveLocation = 33,
|
||||
queryLabel = 36,
|
||||
queryQuickReply = 39
|
||||
}
|
||||
|
||||
/** used for binary messages */
|
||||
export enum WAFlag {
|
||||
available = 160,
|
||||
other = 136, // don't know this one
|
||||
ignore = 1 << 7,
|
||||
acknowledge = 1 << 6,
|
||||
unavailable = 1 << 4,
|
||||
expires = 1 << 3,
|
||||
composing = 1 << 2,
|
||||
recording = 1 << 2,
|
||||
paused = 1 << 2
|
||||
}
|
||||
|
||||
/** Tag used with binary queries */
|
||||
export type WATag = [WAMetric, WAFlag]
|
||||
|
||||
export type SocketSendMessageOptions = {
|
||||
json: BinaryNode | any[]
|
||||
binaryTag?: WATag
|
||||
tag?: string
|
||||
longTag?: boolean
|
||||
}
|
||||
import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate } from './Message'
|
||||
|
||||
export type WAVersion = [number, number, number]
|
||||
export type WABrowserDescription = [string, string, string]
|
||||
export type ReconnectMode = 'no-reconnects' | 'on-any-error' | 'on-connection-error'
|
||||
|
||||
export type SocketConfig = {
|
||||
/** provide an auth state object to maintain the auth state */
|
||||
auth?: AuthenticationState
|
||||
/** the WS url to connect to WA */
|
||||
waWebSocketUrl: string | URL
|
||||
/** Fails the connection if the connection times out in this time interval or no data is received */
|
||||
/** Fails the connection if the socket times out in this interval */
|
||||
connectTimeoutMs: number
|
||||
/** max time for the phone to respond to a connectivity test */
|
||||
phoneResponseTimeMs: number
|
||||
/** ping-pong interval for WS connection */
|
||||
keepAliveIntervalMs: number
|
||||
|
||||
expectResponseTimeout: number
|
||||
/** proxy agent */
|
||||
agent?: Agent
|
||||
/** pino logger */
|
||||
logger: Logger
|
||||
|
||||
/** version to connect with */
|
||||
version: WAVersion
|
||||
/** override browser config */
|
||||
browser: WABrowserDescription
|
||||
/** maximum attempts to connect */
|
||||
maxRetries: number
|
||||
connectCooldownMs: number
|
||||
/** agent used for fetch requests -- uploading/downloading media */
|
||||
fetchAgent?: Agent
|
||||
/** credentials used to sign back in */
|
||||
credentials?: AnyAuthenticationCredentials | string
|
||||
/**
|
||||
* Sometimes WA does not send the chats,
|
||||
* this keeps pinging the phone to send the chats over
|
||||
* */
|
||||
queryChatsTillReceived?: boolean
|
||||
/** */
|
||||
pendingRequestTimeoutMs: number
|
||||
reconnectMode: ReconnectMode
|
||||
maxQRCodes: number
|
||||
/** should the QR be printed in the terminal */
|
||||
printQRInTerminal: boolean
|
||||
|
||||
phoneConnectionChanged: (connected: boolean) => void
|
||||
}
|
||||
|
||||
export type SocketQueryOptions = SocketSendMessageOptions & {
|
||||
timeoutMs?: number
|
||||
expect200?: boolean
|
||||
requiresPhoneConnection?: boolean
|
||||
}
|
||||
|
||||
export enum DisconnectReason {
|
||||
connectionClosed = 428,
|
||||
connectionReplaced = 440,
|
||||
connectionLost = 408,
|
||||
timedOut = 408,
|
||||
credentialsInvalidated = 401,
|
||||
badSession = 500
|
||||
loggedOut = 401,
|
||||
badSession = 500,
|
||||
restartRequired = 410
|
||||
}
|
||||
|
||||
export type WAInitResponse = {
|
||||
@@ -160,36 +82,40 @@ export type WABusinessProfile = {
|
||||
wid?: string
|
||||
}
|
||||
|
||||
export type QueryOptions = SocketQueryOptions & {
|
||||
waitForOpen?: boolean
|
||||
maxRetries?: number
|
||||
startDebouncedTimeout?: boolean
|
||||
}
|
||||
export type CurveKeyPair = { private: Uint8Array; public: Uint8Array }
|
||||
|
||||
export type BaileysEventMap = {
|
||||
/** connection state has been updated -- WS closed, opened, connecting etc. */
|
||||
'connection.update': Partial<ConnectionState>
|
||||
'credentials.update': AuthenticationCredentials
|
||||
|
||||
'chats.set': { chats: Chat[] }
|
||||
/** auth state updated -- some pre keys, or identity keys etc. */
|
||||
'auth-state.update': AuthenticationState
|
||||
/** set chats (history sync), messages are reverse chronologically sorted */
|
||||
'chats.set': { chats: Chat[], messages: WAMessage[] }
|
||||
/** upsert chats */
|
||||
'chats.upsert': Chat[]
|
||||
/** update the given chats */
|
||||
'chats.update': Partial<Chat>[]
|
||||
/** delete chats with given ID */
|
||||
'chats.delete': string[]
|
||||
/** presence of contact in a chat updated */
|
||||
'presence.update': { id: string, presences: { [participant: string]: PresenceData } }
|
||||
|
||||
'presence.update': { jid: string, presences: { [participant: string]: PresenceData } }
|
||||
|
||||
'contacts.set': { contacts: Contact[] }
|
||||
'contacts.upsert': Contact[]
|
||||
'contacts.update': Partial<Contact>[]
|
||||
|
||||
'messages.delete': { jid: string, ids: string[] } | { jid: string, all: true }
|
||||
'messages.update': WAMessageUpdate[]
|
||||
/**
|
||||
* add/update the given messages. If they were received while the connection was online,
|
||||
* the update will have type: "notify"
|
||||
* */
|
||||
'messages.upsert': { messages: WAMessage[], type: MessageUpdateType }
|
||||
|
||||
'message-info.update': MessageInfoUpdate[]
|
||||
|
||||
'groups.update': Partial<GroupMetadata>[]
|
||||
'group-participants.update': { jid: string, participants: string[], action: ParticipantAction }
|
||||
/** apply an action to participants in a group */
|
||||
'group-participants.update': { id: string, participants: string[], action: ParticipantAction }
|
||||
|
||||
'blocklist.set': { blocklist: string[] }
|
||||
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }
|
||||
|
||||
198
src/Utils/chat-utils.ts
Normal file
198
src/Utils/chat-utils.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { aesDecrypt, hmacSign, aesEncrypt, hkdf } from "./generics"
|
||||
import { AuthenticationState, ChatModification } from "../Types"
|
||||
import { proto } from '../../WAProto'
|
||||
import { LT_HASH_ANTI_TAMPERING } from '../WABinary/LTHash'
|
||||
|
||||
type SyncdType = 'regular_high' | 'regular_low'
|
||||
|
||||
const mutationKeys = (keydata: string) => {
|
||||
const expanded = hkdf(Buffer.from(keydata, 'base64'), 160, { info: 'WhatsApp Mutation Keys' })
|
||||
return {
|
||||
indexKey: expanded.slice(0, 32),
|
||||
valueEncryptionKey: expanded.slice(32, 64),
|
||||
valueMacKey: expanded.slice(64, 96),
|
||||
snapshotMacKey: expanded.slice(96, 128),
|
||||
patchMacKey: expanded.slice(128, 160)
|
||||
}
|
||||
}
|
||||
|
||||
const generateMac = (operation: proto.SyncdMutation.SyncdMutationSyncdOperation, data: Buffer, keyId: Uint8Array | string, key: Buffer) => {
|
||||
const getKeyData = () => {
|
||||
let r: number
|
||||
switch (operation) {
|
||||
case proto.SyncdMutation.SyncdMutationSyncdOperation.SET:
|
||||
r = 0x01
|
||||
break
|
||||
case proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE:
|
||||
r = 0x02
|
||||
break
|
||||
}
|
||||
const buff = Buffer.from([r])
|
||||
return Buffer.concat([ buff, Buffer.from(keyId as any, 'base64') ])
|
||||
}
|
||||
const keyData = getKeyData()
|
||||
|
||||
const last = Buffer.alloc(8) // 8 bytes
|
||||
last.set([ keyData.length ], last.length-1)
|
||||
|
||||
const total = Buffer.concat([ keyData, data, last ])
|
||||
const hmac = hmacSign(total, key, 'sha512')
|
||||
|
||||
return hmac.slice(0, 32)
|
||||
}
|
||||
|
||||
const to64BitNetworkOrder = function(e) {
|
||||
const t = new ArrayBuffer(8)
|
||||
new DataView(t).setUint32(4, e, !1)
|
||||
return Buffer.from(t)
|
||||
}
|
||||
|
||||
const generateSnapshotMac = (version: number, indexMac: Uint8Array, valueMac: Uint8Array, type: SyncdType, key: Buffer) => {
|
||||
|
||||
const ltHash = () => {
|
||||
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(128).buffer, [ new Uint8Array(valueMac).buffer, new Uint8Array(indexMac).buffer ], [])
|
||||
const buff = Buffer.from(result)
|
||||
console.log(buff.toString('hex'))
|
||||
return buff
|
||||
}
|
||||
const total = Buffer.concat([
|
||||
ltHash(),
|
||||
to64BitNetworkOrder(version),
|
||||
Buffer.from(type, 'utf-8')
|
||||
])
|
||||
return hmacSign(total, key)
|
||||
}
|
||||
const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: SyncdType, key: Buffer) => {
|
||||
const total = Buffer.concat([
|
||||
snapshotMac,
|
||||
...valueMacs,
|
||||
to64BitNetworkOrder(version),
|
||||
Buffer.from(type, 'utf-8')
|
||||
])
|
||||
return hmacSign(total, key)
|
||||
}
|
||||
|
||||
export const encodeSyncdPatch = (action: ChatModification, lastMessageKey: proto.IMessageKey, { creds: { appStateSyncKeys: [key], appStateVersion } }: AuthenticationState) => {
|
||||
let syncAction: proto.ISyncActionValue = { }
|
||||
if('archive' in action) {
|
||||
syncAction.archiveChatAction = {
|
||||
archived: action.archive,
|
||||
messageRange: {
|
||||
messages: [
|
||||
{ key: lastMessageKey }
|
||||
]
|
||||
}
|
||||
}
|
||||
} else if('mute' in action) {
|
||||
const value = typeof action.mute === 'number' ? true : false
|
||||
syncAction.muteAction = {
|
||||
muted: value,
|
||||
muteEndTimestamp: typeof action.mute === 'number' ? action.mute : undefined
|
||||
}
|
||||
} else if('delete' in action) {
|
||||
syncAction.deleteChatAction = { }
|
||||
} else if('markRead' in action) {
|
||||
syncAction.markChatAsReadAction = {
|
||||
read: action.markRead
|
||||
}
|
||||
} else if('pin' in action) {
|
||||
throw new Boom('Pin not supported on multi-device yet', { statusCode: 400 })
|
||||
}
|
||||
|
||||
const encoded = proto.SyncActionValue.encode(syncAction).finish()
|
||||
|
||||
const index = JSON.stringify([Object.keys(action)[0], lastMessageKey.remoteJid])
|
||||
const keyValue = mutationKeys(key.keyData!.keyData! as any)
|
||||
|
||||
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
|
||||
const macValue = generateMac(1, encValue, key.keyId!.keyId, keyValue.valueMacKey)
|
||||
const indexMacValue = hmacSign(Buffer.from(index), keyValue.indexKey)
|
||||
|
||||
const type = 'regular_high'
|
||||
const v = appStateVersion[type]+1
|
||||
|
||||
const snapshotMac = generateSnapshotMac(v, indexMacValue, macValue, type, keyValue.snapshotMacKey)
|
||||
|
||||
const patch: proto.ISyncdPatch = {
|
||||
patchMac: generatePatchMac(snapshotMac, [macValue], v, type, keyValue.patchMacKey),
|
||||
snapshotMac: snapshotMac,
|
||||
keyId: { id: key.keyId.keyId },
|
||||
mutations: [
|
||||
{
|
||||
operation: 1,
|
||||
record: {
|
||||
index: {
|
||||
blob: indexMacValue
|
||||
},
|
||||
value: {
|
||||
blob: Buffer.concat([ encValue, macValue ])
|
||||
},
|
||||
keyId: { id: key.keyId.keyId }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
export const decodeSyncdPatch = (msg: proto.ISyncdPatch, {creds}: AuthenticationState) => {
|
||||
const keyCache: { [_: string]: ReturnType<typeof mutationKeys> } = { }
|
||||
const getKey = (keyId: Uint8Array) => {
|
||||
const base64Key = Buffer.from(keyId!).toString('base64')
|
||||
|
||||
let key = keyCache[base64Key]
|
||||
if(!key) {
|
||||
const keyEnc = creds.appStateSyncKeys?.find(k => (
|
||||
(k.keyId!.keyId as any) === base64Key
|
||||
))
|
||||
if(!keyEnc) {
|
||||
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500, data: msg })
|
||||
}
|
||||
const result = mutationKeys(keyEnc.keyData!.keyData as any)
|
||||
keyCache[base64Key] = result
|
||||
key = result
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
const mutations: { action: proto.ISyncActionValue, index: [string, string] }[] = []
|
||||
const failures: Boom[] = []
|
||||
|
||||
/*const mainKey = getKey(msg.keyId!.id)
|
||||
const mutation = msg.mutations![0]!.record
|
||||
|
||||
const patchMac = generatePatchMac(msg.snapshotMac, [ mutation.value!.blob!.slice(-32) ], toNumber(msg.version!.version), 'regular_low', mainKey.patchMacKey)
|
||||
console.log(patchMac)
|
||||
console.log(msg.patchMac)*/
|
||||
|
||||
// indexKey used to HMAC sign record.index.blob
|
||||
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
|
||||
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
|
||||
for(const { operation, record } of msg.mutations!) {
|
||||
try {
|
||||
const key = getKey(record.keyId!.id!)
|
||||
const content = Buffer.from(record.value!.blob!)
|
||||
const encContent = content.slice(0, -32)
|
||||
const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey)
|
||||
if(Buffer.compare(contentHmac, content.slice(-32)) !== 0) {
|
||||
throw new Boom('HMAC content verification failed')
|
||||
}
|
||||
|
||||
const result = aesDecrypt(encContent, key.valueEncryptionKey)
|
||||
const syncAction = proto.SyncActionData.decode(result)
|
||||
|
||||
const hmac = hmacSign(syncAction.index, key.indexKey)
|
||||
if(Buffer.compare(hmac, record.index!.blob) !== 0) {
|
||||
throw new Boom('HMAC index verification failed')
|
||||
}
|
||||
|
||||
const indexStr = Buffer.from(syncAction.index).toString()
|
||||
mutations.push({ action: syncAction.value!, index: JSON.parse(indexStr) })
|
||||
} catch(error) {
|
||||
failures.push(new Boom(error, { data: { operation, record } }))
|
||||
}
|
||||
}
|
||||
|
||||
return { mutations, failures }
|
||||
}
|
||||
@@ -1,63 +1,103 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import BinaryNode from "../BinaryNode"
|
||||
import { aesDecrypt, hmacSign } from "./generics"
|
||||
import { DisconnectReason, WATag } from "../Types"
|
||||
import { unpadRandomMax16 } from "./generics"
|
||||
import { AuthenticationState } from "../Types"
|
||||
import { areJidsSameUser, BinaryNode as BinaryNodeM, encodeBinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary'
|
||||
import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal'
|
||||
import { proto } from '../../WAProto'
|
||||
|
||||
export const decodeWAMessage = (
|
||||
message: string | Buffer,
|
||||
auth: { macKey: Buffer, encKey: Buffer },
|
||||
fromMe: boolean=false
|
||||
) => {
|
||||
type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status'
|
||||
|
||||
let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
|
||||
if (commaIndex < 0) throw new Boom('invalid message', { data: message }) // if there was no comma, then this message must be not be valid
|
||||
|
||||
if (message[commaIndex+1] === ',') commaIndex += 1
|
||||
let data = message.slice(commaIndex+1, message.length)
|
||||
|
||||
// get the message tag.
|
||||
// If a query was done, the server will respond with the same message tag we sent the query with
|
||||
const messageTag: string = message.slice(0, commaIndex).toString()
|
||||
let json: any
|
||||
let tags: WATag
|
||||
if (data.length > 0) {
|
||||
if (typeof data === 'string') {
|
||||
json = JSON.parse(data) // parse the JSON
|
||||
export const decodeMessageStanza = async(stanza: BinaryNodeM, auth: AuthenticationState) => {
|
||||
const deviceIdentity = (stanza.content as BinaryNodeM[])?.find(m => m.tag === 'device-identity')
|
||||
const deviceIdentityBytes = deviceIdentity ? deviceIdentity.content as Buffer : undefined
|
||||
|
||||
let msgType: MessageType
|
||||
let chatId: string
|
||||
let author: string
|
||||
|
||||
const msgId: string = stanza.attrs.id
|
||||
const from: string = stanza.attrs.from
|
||||
const participant: string | undefined = stanza.attrs.participant
|
||||
const recipient: string | undefined = stanza.attrs.recipient
|
||||
|
||||
const isMe = (jid: string) => areJidsSameUser(jid, auth.creds.me!.id)
|
||||
|
||||
if(isJidUser(from)) {
|
||||
if(recipient) {
|
||||
if(!isMe(from)) {
|
||||
throw new Boom('')
|
||||
}
|
||||
chatId = recipient
|
||||
} else {
|
||||
const { macKey, encKey } = auth || {}
|
||||
if (!macKey || !encKey) {
|
||||
throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession })
|
||||
}
|
||||
/*
|
||||
If the data recieved was not a JSON, then it must be an encrypted message.
|
||||
Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
|
||||
*/
|
||||
if (fromMe) {
|
||||
tags = [data[0], data[1]]
|
||||
data = data.slice(2, data.length)
|
||||
}
|
||||
|
||||
const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message
|
||||
data = data.slice(32, data.length) // the actual message
|
||||
const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey
|
||||
|
||||
if (checksum.equals(computedChecksum)) {
|
||||
// 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
|
||||
json = BinaryNode.from(decrypted) // decode the binary message into a JSON array
|
||||
} else {
|
||||
throw new Boom('Bad checksum', {
|
||||
data: {
|
||||
received: checksum.toString('hex'),
|
||||
computed: computedChecksum.toString('hex'),
|
||||
data: data.slice(0, 80).toString(),
|
||||
tag: messageTag,
|
||||
message: message.slice(0, 80).toString()
|
||||
},
|
||||
statusCode: DisconnectReason.badSession
|
||||
})
|
||||
}
|
||||
}
|
||||
chatId = from
|
||||
}
|
||||
msgType = 'chat'
|
||||
author = from
|
||||
} else if(isJidGroup(from)) {
|
||||
if(!participant) {
|
||||
throw new Boom('No participant in group message')
|
||||
}
|
||||
msgType = 'group'
|
||||
author = participant
|
||||
chatId = from
|
||||
} else if(isJidBroadcast(from)) {
|
||||
if(!participant) {
|
||||
throw new Boom('No participant in group message')
|
||||
}
|
||||
const isParticipantMe = isMe(participant)
|
||||
if(isJidStatusBroadcast(from)) {
|
||||
msgType = isParticipantMe ? 'direct_peer_status' : 'other_status'
|
||||
} else {
|
||||
msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast'
|
||||
}
|
||||
chatId = from
|
||||
author = participant
|
||||
}
|
||||
const sender = msgType === 'chat' ? author : chatId
|
||||
|
||||
const successes: proto.Message[] = []
|
||||
const failures: { error: Boom }[] = []
|
||||
if(Array.isArray(stanza.content)) {
|
||||
for(const { tag, attrs, content } of stanza.content as BinaryNodeM[]) {
|
||||
if(tag !== 'enc') continue
|
||||
if(!Buffer.isBuffer(content) && !(content instanceof Uint8Array)) continue
|
||||
|
||||
try {
|
||||
let msgBuffer: Buffer
|
||||
|
||||
const e2eType = attrs.type
|
||||
switch(e2eType) {
|
||||
case 'skmsg':
|
||||
msgBuffer = await decryptGroupSignalProto(sender, author, content, auth)
|
||||
break
|
||||
case 'pkmsg':
|
||||
case 'msg':
|
||||
const user = isJidUser(sender) ? sender : author
|
||||
msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth)
|
||||
break
|
||||
}
|
||||
const msg = proto.Message.decode(unpadRandomMax16(msgBuffer))
|
||||
if(msg.senderKeyDistributionMessage) {
|
||||
await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth)
|
||||
}
|
||||
|
||||
successes.push(msg)
|
||||
} catch(error) {
|
||||
failures.push({ error: new Boom(error, { data: Buffer.from(encodeBinaryNode(stanza)).toString('base64') }) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
msgId,
|
||||
chatId,
|
||||
author,
|
||||
from,
|
||||
timestamp: +stanza.attrs.t,
|
||||
participant,
|
||||
recipient,
|
||||
pushname: stanza.attrs.notify,
|
||||
successes,
|
||||
failures
|
||||
}
|
||||
return [messageTag, json, tags] as const
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import CurveCrypto from 'libsignal/src/curve25519_wrapper'
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
|
||||
import HKDF from 'futoin-hkdf'
|
||||
import { platform, release } from 'os'
|
||||
import { KeyPair } from '../Types'
|
||||
import { proto } from '../../WAProto'
|
||||
import { Binary } from '../WABinary'
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
'aix': 'AIX',
|
||||
@@ -9,6 +12,7 @@ const PLATFORM_MAP = {
|
||||
'win32': 'Windows',
|
||||
'android': 'Android'
|
||||
}
|
||||
|
||||
export const Browsers = {
|
||||
ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string],
|
||||
macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string],
|
||||
@@ -16,6 +20,118 @@ export const Browsers = {
|
||||
/** The appropriate browser based on your OS & release */
|
||||
appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string]
|
||||
}
|
||||
|
||||
export const BufferJSON = {
|
||||
replacer: (k, value: any) => {
|
||||
if(Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === 'Buffer') {
|
||||
return { type: 'Buffer', data: Buffer.from(value?.data || value).toString('base64') }
|
||||
}
|
||||
return value
|
||||
},
|
||||
reviver: (_, value: any) => {
|
||||
if(typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) {
|
||||
const val = value.data || value.value
|
||||
return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const writeRandomPadMax16 = function(e: Binary) {
|
||||
function r(e: Binary, t: number) {
|
||||
for (var r = 0; r < t; r++)
|
||||
e.writeUint8(t)
|
||||
}
|
||||
|
||||
var t = randomBytes(1)
|
||||
r(e, 1 + (15 & t[0]))
|
||||
return e
|
||||
}
|
||||
|
||||
export const unpadRandomMax16 = (e: Uint8Array | Buffer) => {
|
||||
const t = new Uint8Array(e);
|
||||
if (0 === t.length) {
|
||||
throw new Error('unpadPkcs7 given empty bytes');
|
||||
}
|
||||
|
||||
var r = t[t.length - 1];
|
||||
if (r > t.length) {
|
||||
throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`);
|
||||
}
|
||||
|
||||
return new Uint8Array(t.buffer, t.byteOffset, t.length - r);
|
||||
}
|
||||
|
||||
export const encodeWAMessage = (message: proto.IMessage) => (
|
||||
Buffer.from(
|
||||
writeRandomPadMax16(
|
||||
new Binary(proto.Message.encode(message).finish())
|
||||
).readByteArray()
|
||||
)
|
||||
)
|
||||
|
||||
export const generateCurveKeyPair = (): KeyPair => {
|
||||
const { pubKey, privKey } = CurveCrypto.keyPair(randomBytes(32))
|
||||
return {
|
||||
private: Buffer.from(privKey),
|
||||
public: Buffer.from(pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
export const generateSharedKey = (privateKey: Uint8Array, publicKey: Uint8Array) => {
|
||||
const shared = CurveCrypto.sharedSecret(publicKey, privateKey)
|
||||
return Buffer.from(shared)
|
||||
}
|
||||
|
||||
export const curveSign = (privateKey: Uint8Array, buf: Uint8Array) => (
|
||||
Buffer.from(CurveCrypto.sign(privateKey, buf))
|
||||
)
|
||||
|
||||
export const curveVerify = (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => {
|
||||
try {
|
||||
CurveCrypto.verify(pubKey, message, signature)
|
||||
return true
|
||||
} catch(error) {
|
||||
if(error.message.includes('Invalid')) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const signedKeyPair = (keyPair: KeyPair, keyId: number) => {
|
||||
const signKeys = generateCurveKeyPair()
|
||||
const pubKey = new Uint8Array(33)
|
||||
pubKey.set([5], 0)
|
||||
pubKey.set(signKeys.public, 1)
|
||||
|
||||
const signature = curveSign(keyPair.private, pubKey)
|
||||
|
||||
return { keyPair: signKeys, signature, keyId }
|
||||
}
|
||||
|
||||
export const generateRegistrationId = () => (
|
||||
Uint16Array.from(randomBytes(2))[0] & 0x3fff
|
||||
)
|
||||
|
||||
export const encodeInt = (e: number, t: number) => {
|
||||
for (var r = t, a = new Uint8Array(e), i = e - 1; i >= 0; i--) {
|
||||
a[i] = 255 & r
|
||||
r >>>= 8
|
||||
}
|
||||
return a
|
||||
}
|
||||
export const encodeBigEndian = (e: number, t=4) => {
|
||||
let r = e;
|
||||
let a = new Uint8Array(t);
|
||||
for (let i = t - 1; i >= 0; i--) {
|
||||
a[i] = 255 & r
|
||||
r >>>= 8
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
export const toNumber = (t: Long | number) => (typeof t?.['low'] !== 'undefined' ? t['low'] : t) as number
|
||||
|
||||
export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net')
|
||||
@@ -48,7 +164,7 @@ export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
||||
return Buffer.concat([aes.update(buffer), aes.final()])
|
||||
}
|
||||
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
|
||||
export function aesEncrypt(buffer: Buffer, key: Buffer) {
|
||||
export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) {
|
||||
const IV = randomBytes(16)
|
||||
const aes = createCipheriv('aes-256-cbc', key, IV)
|
||||
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
||||
@@ -59,20 +175,47 @@ export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
||||
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
||||
}
|
||||
// sign HMAC using SHA 256
|
||||
export function hmacSign(buffer: Buffer, key: Buffer) {
|
||||
return createHmac('sha256', key).update(buffer).digest()
|
||||
export function hmacSign(buffer: Buffer | Uint8Array, key: Buffer | Uint8Array, variant: 'sha256' | 'sha512' = 'sha256') {
|
||||
return createHmac(variant, key).update(buffer).digest()
|
||||
}
|
||||
export function sha256(buffer: Buffer) {
|
||||
return createHash('sha256').update(buffer).digest()
|
||||
}
|
||||
// HKDF key expansion
|
||||
export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
|
||||
return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' })
|
||||
// from: https://github.com/benadida/node-hkdf
|
||||
export function hkdf(buffer: Buffer, expandedLength: number, { info, salt }: { salt?: Buffer, info?: string }) {
|
||||
const hashAlg = 'sha256'
|
||||
const hashLength = 32
|
||||
salt = salt || Buffer.alloc(hashLength)
|
||||
// now we compute the PRK
|
||||
const prk = createHmac(hashAlg, salt).update(buffer).digest()
|
||||
|
||||
let prev = Buffer.from([])
|
||||
const buffers = []
|
||||
const num_blocks = Math.ceil(expandedLength / hashLength)
|
||||
|
||||
const infoBuff = Buffer.from(info || [])
|
||||
|
||||
for (var i=0; i<num_blocks; i++) {
|
||||
const hmac = createHmac(hashAlg, prk)
|
||||
// XXX is there a more optimal way to build up buffers?
|
||||
const input = Buffer.concat([
|
||||
prev,
|
||||
infoBuff,
|
||||
Buffer.from(String.fromCharCode(i + 1))
|
||||
]);
|
||||
hmac.update(input)
|
||||
|
||||
prev = hmac.digest()
|
||||
buffers.push(prev)
|
||||
}
|
||||
return Buffer.concat(buffers, expandedLength)
|
||||
}
|
||||
/** unix timestamp of a date in seconds */
|
||||
export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000)
|
||||
|
||||
export type DebouncedTimeout = ReturnType<typeof debouncedTimeout>
|
||||
|
||||
export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
return {
|
||||
@@ -135,14 +278,5 @@ export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>
|
||||
.finally (cancel)
|
||||
return p as Promise<T>
|
||||
}
|
||||
// whatsapp requires a message tag for every message, we just use the timestamp as one
|
||||
export function generateMessageTag(epoch?: number) {
|
||||
let tag = unixTimestampSeconds().toString()
|
||||
if (epoch) tag += '.--' + epoch // attach epoch if provided
|
||||
return tag
|
||||
}
|
||||
// generate a random 16 byte client ID
|
||||
export const generateClientID = () => randomBytes(16).toString('base64')
|
||||
// generate a random ID to attach to a message
|
||||
// this is the format used for WA Web 4 byte hex prefixed with 3EB0
|
||||
export const generateMessageID = () => '3EB0' + randomBytes(4).toString('hex').toUpperCase()
|
||||
export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase()
|
||||
25
src/Utils/history.ts
Normal file
25
src/Utils/history.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { downloadContentFromMessage } from "./messages-media";
|
||||
import { proto } from "../../WAProto";
|
||||
import { promisify } from 'util'
|
||||
import { inflate } from "zlib";
|
||||
|
||||
const inflatePromise = promisify(inflate)
|
||||
|
||||
export const downloadIfHistory = (message: proto.IMessage) => {
|
||||
if(message.protocolMessage?.historySyncNotification) {
|
||||
return downloadHistory(message.protocolMessage!.historySyncNotification)
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadHistory = async(msg: proto.IHistorySyncNotification) => {
|
||||
const stream = await downloadContentFromMessage(msg, 'history')
|
||||
let buffer = Buffer.from([])
|
||||
for await(const chunk of stream) {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
}
|
||||
// decompress buffer
|
||||
buffer = await inflatePromise(buffer)
|
||||
|
||||
const syncData = proto.HistorySync.decode(buffer)
|
||||
return syncData
|
||||
}
|
||||
@@ -1,21 +1,18 @@
|
||||
import type { Agent } from 'https'
|
||||
import type { Logger } from 'pino'
|
||||
import type { IAudioMetadata } from 'music-metadata'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import * as Crypto from 'crypto'
|
||||
import { Readable, Transform } from 'stream'
|
||||
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import { tmpdir } from 'os'
|
||||
import HttpsProxyAgent from 'https-proxy-agent'
|
||||
import { URL } from 'url'
|
||||
import { MessageType, WAMessageContent, WAMessageProto, WAGenericMediaMessage, WAMediaUpload } from '../Types'
|
||||
import got, { Options, Response } from 'got'
|
||||
import { join } from 'path'
|
||||
import { generateMessageID, hkdf } from './generics'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { MediaType } from '../Types'
|
||||
import { DEFAULT_ORIGIN } from '../Defaults'
|
||||
import { once } from 'events'
|
||||
import got, { Options, Response } from 'got'
|
||||
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType } from '../Types'
|
||||
import { generateMessageID, hkdf } from './generics'
|
||||
import { DEFAULT_ORIGIN } from '../Defaults'
|
||||
|
||||
export const hkdfInfoKey = (type: MediaType) => {
|
||||
if(type === 'sticker') type = 'image'
|
||||
@@ -29,7 +26,7 @@ export function getMediaKeys(buffer, mediaType: MediaType) {
|
||||
buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
|
||||
}
|
||||
// expand using HKDF to 112 bytes, also pass in the relevant app info
|
||||
const expandedMediaKey = hkdf(buffer, 112, hkdfInfoKey(mediaType))
|
||||
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
|
||||
return {
|
||||
iv: expandedMediaKey.slice(0, 16),
|
||||
cipherKey: expandedMediaKey.slice(16, 48),
|
||||
@@ -54,20 +51,18 @@ const extractVideoThumb = async (
|
||||
export const compressImage = async (bufferOrFilePath: Buffer | string) => {
|
||||
const { read, MIME_JPEG } = await import('jimp')
|
||||
const jimp = await read(bufferOrFilePath as any)
|
||||
const result = await jimp.resize(48, 48).getBufferAsync(MIME_JPEG)
|
||||
const result = await jimp.resize(32, 32).getBufferAsync(MIME_JPEG)
|
||||
return result
|
||||
}
|
||||
export const generateProfilePicture = async (buffer: Buffer) => {
|
||||
export const generateProfilePicture = async (bufferOrFilePath: Buffer | string) => {
|
||||
const { read, MIME_JPEG } = await import('jimp')
|
||||
const jimp = await read (buffer)
|
||||
const jimp = await read(bufferOrFilePath as any)
|
||||
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
|
||||
const cropped = jimp.crop (0, 0, min, min)
|
||||
return {
|
||||
img: await cropped.resize(640, 640).getBufferAsync (MIME_JPEG),
|
||||
preview: await cropped.resize(96, 96).getBufferAsync (MIME_JPEG)
|
||||
img: await cropped.resize(640, 640).getBufferAsync(MIME_JPEG),
|
||||
}
|
||||
}
|
||||
export const ProxyAgent = (host: string | URL) => HttpsProxyAgent(host) as any as Agent
|
||||
/** gets the SHA256 of the given media message */
|
||||
export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
|
||||
const media = Object.values(message)[0] as WAGenericMediaMessage
|
||||
@@ -113,7 +108,7 @@ export async function generateThumbnail(
|
||||
} else if(mediaType === 'video') {
|
||||
const imgFilename = join(tmpdir(), generateMessageID() + '.jpg')
|
||||
try {
|
||||
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 48, height: 48 })
|
||||
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 })
|
||||
const buff = await fs.readFile(imgFilename)
|
||||
thumbnail = buff.toString('base64')
|
||||
|
||||
@@ -205,6 +200,47 @@ export const encryptedStream = async(media: WAMediaUpload, mediaType: MediaType,
|
||||
didSaveToTmpPath
|
||||
}
|
||||
}
|
||||
const DEF_HOST = 'mmg.whatsapp.net'
|
||||
export const downloadContentFromMessage = async(
|
||||
{ mediaKey, directPath, url }: { mediaKey?: Uint8Array, directPath?: string, url?: string },
|
||||
type: MediaType
|
||||
) => {
|
||||
const downloadUrl = url || `https://${DEF_HOST}${directPath}`
|
||||
// download the message
|
||||
const fetched = await getGotStream(downloadUrl, {
|
||||
headers: { Origin: DEFAULT_ORIGIN }
|
||||
})
|
||||
let remainingBytes = Buffer.from([])
|
||||
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
|
||||
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
|
||||
|
||||
const output = new Transform({
|
||||
transform(chunk, _, callback) {
|
||||
let data = Buffer.concat([remainingBytes, chunk])
|
||||
const decryptLength =
|
||||
Math.floor(data.length / 16) * 16
|
||||
remainingBytes = data.slice(decryptLength)
|
||||
data = data.slice(0, decryptLength)
|
||||
|
||||
try {
|
||||
this.push(aes.update(data))
|
||||
callback()
|
||||
} catch(error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
final(callback) {
|
||||
try {
|
||||
this.push(aes.final())
|
||||
callback()
|
||||
} catch(error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
return fetched.pipe(output, { end: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a media message (video, image, document, audio) & return decrypted buffer
|
||||
* @param message the media message you want to decode
|
||||
@@ -237,39 +273,7 @@ export async function decryptMediaMessageBuffer(message: WAMessageContent): Prom
|
||||
} else {
|
||||
messageContent = message[type]
|
||||
}
|
||||
// download the message
|
||||
const fetched = await getGotStream(messageContent.url, {
|
||||
headers: { Origin: DEFAULT_ORIGIN }
|
||||
})
|
||||
let remainingBytes = Buffer.from([])
|
||||
const { cipherKey, iv } = getMediaKeys(messageContent.mediaKey, type.replace('Message', '') as MediaType)
|
||||
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
|
||||
|
||||
const output = new Transform({
|
||||
transform(chunk, _, callback) {
|
||||
let data = Buffer.concat([remainingBytes, chunk])
|
||||
const decryptLength =
|
||||
Math.floor(data.length / 16) * 16
|
||||
remainingBytes = data.slice(decryptLength)
|
||||
data = data.slice(0, decryptLength)
|
||||
|
||||
try {
|
||||
this.push(aes.update(data))
|
||||
callback()
|
||||
} catch(error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
final(callback) {
|
||||
try {
|
||||
this.push(aes.final())
|
||||
callback()
|
||||
} catch(error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
return fetched.pipe(output, { end: true })
|
||||
return downloadContentFromMessage(messageContent, type.replace('Message', '') as MediaType)
|
||||
}
|
||||
export function extensionForMediaMessage(message: WAMessageContent) {
|
||||
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
|
||||
@@ -283,10 +287,10 @@ export function extensionForMediaMessage(message: WAMessageContent) {
|
||||
extension = '.jpeg'
|
||||
} else {
|
||||
const messageContent = message[type] as
|
||||
| WAMessageProto.VideoMessage
|
||||
| WAMessageProto.ImageMessage
|
||||
| WAMessageProto.AudioMessage
|
||||
| WAMessageProto.DocumentMessage
|
||||
| WAProto.VideoMessage
|
||||
| WAProto.ImageMessage
|
||||
| WAProto.AudioMessage
|
||||
| WAProto.DocumentMessage
|
||||
extension = getExtension (messageContent.mimetype)
|
||||
}
|
||||
return extension
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { createReadStream, promises as fs } from "fs"
|
||||
import { proto } from '../../WAMessage'
|
||||
import { proto } from '../../WAProto'
|
||||
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from "../Defaults"
|
||||
import {
|
||||
AnyMediaMessageContent,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
WAMediaUpload,
|
||||
WAMessage,
|
||||
WAMessageContent,
|
||||
WAMessageProto,
|
||||
WAProto,
|
||||
WATextMessage,
|
||||
MediaType,
|
||||
WAMessageStatus
|
||||
@@ -38,14 +38,15 @@ const MIMETYPE_MAP: { [T in MediaType]: string } = {
|
||||
document: 'application/pdf',
|
||||
audio: 'audio/ogg; codecs=opus',
|
||||
sticker: 'image/webp',
|
||||
history: 'application/x-protobuf'
|
||||
}
|
||||
|
||||
const MessageTypeProto = {
|
||||
'image': WAMessageProto.ImageMessage,
|
||||
'video': WAMessageProto.VideoMessage,
|
||||
'audio': WAMessageProto.AudioMessage,
|
||||
'sticker': WAMessageProto.StickerMessage,
|
||||
'document': WAMessageProto.DocumentMessage,
|
||||
'image': WAProto.ImageMessage,
|
||||
'video': WAProto.VideoMessage,
|
||||
'audio': WAProto.AudioMessage,
|
||||
'sticker': WAProto.StickerMessage,
|
||||
'document': WAProto.DocumentMessage,
|
||||
} as const
|
||||
|
||||
const ButtonType = proto.ButtonsMessage.ButtonsMessageHeaderType
|
||||
@@ -69,7 +70,7 @@ export const prepareWAMessageMedia = async(
|
||||
if(typeof uploadData.media === 'object' && 'url' in uploadData.media) {
|
||||
const result = !!options.mediaCache && await options.mediaCache!(uploadData.media.url?.toString())
|
||||
if(result) {
|
||||
return WAMessageProto.Message.fromObject({
|
||||
return WAProto.Message.fromObject({
|
||||
[`${mediaType}Message`]: result
|
||||
})
|
||||
}
|
||||
@@ -136,7 +137,7 @@ export const prepareWAMessageMedia = async(
|
||||
}
|
||||
)
|
||||
}
|
||||
return WAMessageProto.Message.fromObject(content)
|
||||
return WAProto.Message.fromObject(content)
|
||||
}
|
||||
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
|
||||
ephemeralExpiration = ephemeralExpiration || 0
|
||||
@@ -144,13 +145,13 @@ export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: n
|
||||
ephemeralMessage: {
|
||||
message: {
|
||||
protocolMessage: {
|
||||
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
|
||||
type: WAProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
|
||||
ephemeralExpiration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return WAMessageProto.Message.fromObject(content)
|
||||
return WAProto.Message.fromObject(content)
|
||||
}
|
||||
/**
|
||||
* Generate forwarded message content like WA does
|
||||
@@ -207,14 +208,14 @@ export const generateWAMessageContent = async(
|
||||
throw new Boom('require atleast 1 contact', { statusCode: 400 })
|
||||
}
|
||||
if(contactLen === 1) {
|
||||
m.contactMessage = WAMessageProto.ContactMessage.fromObject(message.contacts.contacts[0])
|
||||
m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0])
|
||||
}
|
||||
} else if('location' in message) {
|
||||
m.locationMessage = WAMessageProto.LocationMessage.fromObject(message.location)
|
||||
m.locationMessage = WAProto.LocationMessage.fromObject(message.location)
|
||||
} else if('delete' in message) {
|
||||
m.protocolMessage = {
|
||||
key: message.delete,
|
||||
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE
|
||||
type: WAProto.ProtocolMessage.ProtocolMessageType.REVOKE
|
||||
}
|
||||
} else if('forward' in message) {
|
||||
m = generateForwardMessageContent(
|
||||
@@ -259,7 +260,7 @@ export const generateWAMessageContent = async(
|
||||
m[messageType].contextInfo = m[messageType] || { }
|
||||
m[messageType].contextInfo.mentionedJid = message.mentions
|
||||
}
|
||||
return WAMessageProto.Message.fromObject(m)
|
||||
return WAProto.Message.fromObject(m)
|
||||
}
|
||||
export const generateWAMessageFromContent = (
|
||||
jid: string,
|
||||
@@ -290,7 +291,7 @@ export const generateWAMessageFromContent = (
|
||||
}
|
||||
if(
|
||||
// if we want to send a disappearing message
|
||||
!!options?.ephemeralOptions &&
|
||||
!!options?.ephemeralExpiration &&
|
||||
// and it's not a protocol message -- delete, toggle disappear message
|
||||
key !== 'protocolMessage' &&
|
||||
// already not converted to disappearing message
|
||||
@@ -298,8 +299,8 @@ export const generateWAMessageFromContent = (
|
||||
) {
|
||||
message[key].contextInfo = {
|
||||
...(message[key].contextInfo || {}),
|
||||
expiration: options.ephemeralOptions.expiration || WA_DEFAULT_EPHEMERAL,
|
||||
ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
|
||||
expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL,
|
||||
//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
|
||||
}
|
||||
message = {
|
||||
ephemeralMessage: {
|
||||
@@ -307,7 +308,7 @@ export const generateWAMessageFromContent = (
|
||||
}
|
||||
}
|
||||
}
|
||||
message = WAMessageProto.Message.fromObject (message)
|
||||
message = WAProto.Message.fromObject (message)
|
||||
|
||||
const messageJSON = {
|
||||
key: {
|
||||
@@ -321,7 +322,7 @@ export const generateWAMessageFromContent = (
|
||||
participant: jid.includes('@g.us') ? userJid : undefined,
|
||||
status: WAMessageStatus.PENDING
|
||||
}
|
||||
return WAMessageProto.WebMessageInfo.fromObject (messageJSON)
|
||||
return WAProto.WebMessageInfo.fromObject (messageJSON)
|
||||
}
|
||||
export const generateWAMessage = async(
|
||||
jid: string,
|
||||
|
||||
167
src/Utils/noise-handler.ts
Normal file
167
src/Utils/noise-handler.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { sha256, generateSharedKey, hkdf } from "./generics";
|
||||
import { Binary } from "../WABinary";
|
||||
import { createCipheriv, createDecipheriv } from "crypto";
|
||||
import { NOISE_MODE, NOISE_WA_HEADER } from "../Defaults";
|
||||
import { KeyPair } from "../Types";
|
||||
import { BinaryNode, decodeBinaryNode } from "../WABinary";
|
||||
import { Boom } from "@hapi/boom";
|
||||
import { proto } from '../../WAProto'
|
||||
|
||||
const generateIV = (counter: number) => {
|
||||
const iv = new ArrayBuffer(12);
|
||||
new DataView(iv).setUint32(8, counter);
|
||||
|
||||
return new Uint8Array(iv)
|
||||
}
|
||||
|
||||
export default ({ public: publicKey, private: privateKey }: KeyPair) => {
|
||||
|
||||
const authenticate = (data: Uint8Array) => {
|
||||
if(!isFinished) {
|
||||
hash = sha256( Buffer.from(Binary.build(hash, data).readByteArray()) )
|
||||
}
|
||||
}
|
||||
const encrypt = (plaintext: Uint8Array) => {
|
||||
const authTagLength = 128 >> 3
|
||||
const cipher = createCipheriv('aes-256-gcm', encKey, generateIV(writeCounter), { authTagLength })
|
||||
cipher.setAAD(hash)
|
||||
|
||||
const result = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()])
|
||||
|
||||
writeCounter += 1
|
||||
|
||||
authenticate(result)
|
||||
return result
|
||||
}
|
||||
const decrypt = (ciphertext: Uint8Array) => {
|
||||
// before the handshake is finished, we use the same counter
|
||||
// after handshake, the counters are different
|
||||
const iv = generateIV(isFinished ? readCounter : writeCounter)
|
||||
const cipher = createDecipheriv('aes-256-gcm', decKey, iv)
|
||||
// decrypt additional adata
|
||||
const tagLength = 128 >> 3
|
||||
const enc = ciphertext.slice(0, ciphertext.length-tagLength)
|
||||
const tag = ciphertext.slice(ciphertext.length-tagLength)
|
||||
// set additional data
|
||||
cipher.setAAD(hash)
|
||||
cipher.setAuthTag(tag)
|
||||
|
||||
const result = Buffer.concat([cipher.update(enc), cipher.final()])
|
||||
|
||||
if(isFinished) readCounter += 1
|
||||
else writeCounter += 1
|
||||
|
||||
authenticate(ciphertext)
|
||||
return result
|
||||
}
|
||||
const localHKDF = (data: Uint8Array) => {
|
||||
const key = hkdf(Buffer.from(data), 64, { salt, info: '' })
|
||||
return [key.slice(0, 32), key.slice(32)]
|
||||
}
|
||||
const mixIntoKey = (data: Uint8Array) => {
|
||||
const [write, read] = localHKDF(data)
|
||||
salt = write
|
||||
encKey = read
|
||||
decKey = read
|
||||
readCounter = 0
|
||||
writeCounter = 0
|
||||
}
|
||||
const finishInit = () => {
|
||||
const [write, read] = localHKDF(new Uint8Array(0))
|
||||
encKey = write
|
||||
decKey = read
|
||||
hash = Buffer.from([])
|
||||
readCounter = 0
|
||||
writeCounter = 0
|
||||
isFinished = true
|
||||
}
|
||||
|
||||
const data = Binary.build(NOISE_MODE).readBuffer()
|
||||
let hash = Buffer.from(data.byteLength === 32 ? data : sha256(Buffer.from(data)))
|
||||
let salt = hash
|
||||
let encKey = hash
|
||||
let decKey = hash
|
||||
let readCounter = 0
|
||||
let writeCounter = 0
|
||||
let isFinished = false
|
||||
let sentIntro = false
|
||||
|
||||
const outBinary = new Binary()
|
||||
const inBinary = new Binary()
|
||||
|
||||
authenticate(NOISE_WA_HEADER)
|
||||
authenticate(publicKey)
|
||||
|
||||
return {
|
||||
encrypt,
|
||||
decrypt,
|
||||
authenticate,
|
||||
mixIntoKey,
|
||||
finishInit,
|
||||
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
|
||||
authenticate(serverHello!.ephemeral!)
|
||||
mixIntoKey(generateSharedKey(privateKey, serverHello.ephemeral!))
|
||||
|
||||
const decStaticContent = decrypt(serverHello!.static!)
|
||||
mixIntoKey(generateSharedKey(privateKey, decStaticContent))
|
||||
|
||||
const certDecoded = decrypt(serverHello!.payload!)
|
||||
const { details: certDetails, signature: certSignature } = proto.NoiseCertificate.decode(certDecoded)
|
||||
|
||||
const { issuer: certIssuer, key: certKey } = proto.Details.decode(certDetails)
|
||||
|
||||
if(Buffer.compare(decStaticContent, certKey) !== 0) {
|
||||
throw new Boom('certification match failed', { statusCode: 400 })
|
||||
}
|
||||
|
||||
const keyEnc = encrypt(noiseKey.public)
|
||||
mixIntoKey(generateSharedKey(noiseKey.private, serverHello!.ephemeral!))
|
||||
|
||||
return keyEnc
|
||||
},
|
||||
encodeFrame: (data: Buffer | Uint8Array) => {
|
||||
if(isFinished) {
|
||||
data = encrypt(data)
|
||||
}
|
||||
const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length
|
||||
|
||||
outBinary.ensureAdditionalCapacity(introSize + 3 + data.byteLength)
|
||||
|
||||
if (!sentIntro) {
|
||||
outBinary.writeByteArray(NOISE_WA_HEADER)
|
||||
sentIntro = true
|
||||
}
|
||||
|
||||
outBinary.writeUint8(data.byteLength >> 16)
|
||||
outBinary.writeUint16(65535 & data.byteLength)
|
||||
outBinary.write(data)
|
||||
|
||||
const bytes = outBinary.readByteArray()
|
||||
return bytes as Uint8Array
|
||||
},
|
||||
decodeFrame: (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => {
|
||||
// the binary protocol uses its own framing mechanism
|
||||
// on top of the WS frames
|
||||
// so we get this data and separate out the frames
|
||||
const getBytesSize = () => {
|
||||
return (inBinary.readUint8() << 16) | inBinary.readUint16()
|
||||
}
|
||||
const peekSize = () => {
|
||||
return !(inBinary.size() < 3) && getBytesSize() <= inBinary.size()
|
||||
}
|
||||
|
||||
inBinary.writeByteArray(newData)
|
||||
while(inBinary.peek(peekSize)) {
|
||||
const bytes = getBytesSize()
|
||||
let frame: Uint8Array | BinaryNode = inBinary.readByteArray(bytes)
|
||||
if(isFinished) {
|
||||
const result = decrypt(frame as Uint8Array)
|
||||
const unpacked = new Binary(result).decompressed()
|
||||
frame = decodeBinaryNode(unpacked)
|
||||
}
|
||||
onFrame(frame)
|
||||
}
|
||||
inBinary.peek(peekSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
253
src/Utils/signal.ts
Normal file
253
src/Utils/signal.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import * as libsignal from 'libsignal'
|
||||
import { encodeBigEndian, generateCurveKeyPair } from "./generics"
|
||||
import { SenderKeyDistributionMessage, GroupSessionBuilder, SenderKeyRecord, SenderKeyName, GroupCipher } from '../../WASignalGroup'
|
||||
import { SignalIdentity, SignalKeyStore, SignedKeyPair, KeyPair, AuthenticationState } from "../Types/Auth"
|
||||
import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildUInt, jidDecode } from "../WABinary"
|
||||
import { proto } from "../../WAProto"
|
||||
|
||||
export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => {
|
||||
const newPub = Buffer.alloc(33)
|
||||
newPub.set([5], 0)
|
||||
newPub.set(pubKey, 1)
|
||||
return newPub
|
||||
}
|
||||
|
||||
const jidToSignalAddress = (jid: string) => jid.split('@')[0]
|
||||
|
||||
export const jidToSignalProtocolAddress = (jid: string) => {
|
||||
return new libsignal.ProtocolAddress(jidToSignalAddress(jid), 0)
|
||||
}
|
||||
|
||||
export const jidToSignalSenderKeyName = (group: string, user: string): string => {
|
||||
return new SenderKeyName(group, jidToSignalProtocolAddress(user)).toString()
|
||||
}
|
||||
|
||||
export const createSignalIdentity = (
|
||||
wid: string,
|
||||
accountSignatureKey: Uint8Array
|
||||
): SignalIdentity => {
|
||||
return {
|
||||
identifier: { name: wid, deviceId: 0 },
|
||||
identifierKey: generateSignalPubKey(accountSignatureKey)
|
||||
}
|
||||
}
|
||||
|
||||
export const getPreKeys = async({ getPreKey }: SignalKeyStore, min: number, limit: number) => {
|
||||
const dict: { [id: number]: KeyPair } = { }
|
||||
for(let id = min; id < limit;id++) {
|
||||
const key = await getPreKey(id)
|
||||
if(key) dict[+id] = key
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
export const generateOrGetPreKeys = ({ creds }: AuthenticationState, range: number) => {
|
||||
const avaliable = creds.nextPreKeyId - creds.firstUnuploadedPreKeyId
|
||||
const remaining = range - avaliable
|
||||
const lastPreKeyId = creds.nextPreKeyId + remaining - 1
|
||||
const newPreKeys: { [id: number]: KeyPair } = { }
|
||||
if(remaining > 0) {
|
||||
for(let i = creds.nextPreKeyId;i <= lastPreKeyId;i++) {
|
||||
newPreKeys[i] = generateCurveKeyPair()
|
||||
}
|
||||
}
|
||||
return {
|
||||
newPreKeys,
|
||||
lastPreKeyId,
|
||||
preKeysRange: [creds.firstUnuploadedPreKeyId, range] as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const xmppSignedPreKey = (key: SignedKeyPair): BinaryNode => (
|
||||
{
|
||||
tag: 'skey',
|
||||
attrs: { },
|
||||
content: [
|
||||
{ tag: 'id', attrs: { }, content: encodeBigEndian(key.keyId, 3) },
|
||||
{ tag: 'value', attrs: { }, content: key.keyPair.public },
|
||||
{ tag: 'signature', attrs: { }, content: key.signature }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
export const xmppPreKey = (pair: KeyPair, id: number): BinaryNode => (
|
||||
{
|
||||
tag: 'key',
|
||||
attrs: { },
|
||||
content: [
|
||||
{ tag: 'id', attrs: { }, content: encodeBigEndian(id, 3) },
|
||||
{ tag: 'value', attrs: { }, content: pair.public }
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
export const signalStorage = ({ creds, keys }: AuthenticationState) => ({
|
||||
loadSession: async id => {
|
||||
const sess = await keys.getSession(id)
|
||||
if(sess) {
|
||||
return libsignal.SessionRecord.deserialize(sess)
|
||||
}
|
||||
},
|
||||
storeSession: async(id, session) => {
|
||||
await keys.setSession(id, session.serialize())
|
||||
},
|
||||
isTrustedIdentity: () => {
|
||||
return true
|
||||
},
|
||||
loadPreKey: async(id: number) => {
|
||||
const key = await keys.getPreKey(id)
|
||||
if(key) {
|
||||
return {
|
||||
privKey: Buffer.from(key.private),
|
||||
pubKey: Buffer.from(key.public)
|
||||
}
|
||||
}
|
||||
},
|
||||
removePreKey: (id: number) => keys.setPreKey(id, null),
|
||||
loadSignedPreKey: (keyId: number) => {
|
||||
const key = creds.signedPreKey
|
||||
return {
|
||||
privKey: Buffer.from(key.keyPair.private),
|
||||
pubKey: Buffer.from(key.keyPair.public)
|
||||
}
|
||||
},
|
||||
loadSenderKey: async(keyId) => {
|
||||
const key = await keys.getSenderKey(keyId)
|
||||
if(key) return new SenderKeyRecord(key)
|
||||
},
|
||||
storeSenderKey: async(keyId, key) => {
|
||||
await keys.setSenderKey(keyId, key.serialize())
|
||||
},
|
||||
getOurRegistrationId: () => (
|
||||
creds.registrationId
|
||||
),
|
||||
getOurIdentity: () => {
|
||||
const { signedIdentityKey } = creds
|
||||
return {
|
||||
privKey: Buffer.from(signedIdentityKey.private),
|
||||
pubKey: generateSignalPubKey(signedIdentityKey.public),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const decryptGroupSignalProto = (group: string, user: string, msg: Buffer | Uint8Array, auth: AuthenticationState) => {
|
||||
const senderName = jidToSignalSenderKeyName(group, user)
|
||||
const cipher = new GroupCipher(signalStorage(auth), senderName)
|
||||
|
||||
return cipher.decrypt(Buffer.from(msg))
|
||||
}
|
||||
|
||||
export const processSenderKeyMessage = async(
|
||||
authorJid: string,
|
||||
item: proto.ISenderKeyDistributionMessage,
|
||||
auth: AuthenticationState
|
||||
) => {
|
||||
const builder = new GroupSessionBuilder(signalStorage(auth))
|
||||
const senderName = jidToSignalSenderKeyName(item.groupId, authorJid)
|
||||
|
||||
const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage)
|
||||
const senderKey = await auth.keys.getSenderKey(senderName)
|
||||
if(!senderKey) {
|
||||
const record = new SenderKeyRecord()
|
||||
await auth.keys.setSenderKey(senderName, record)
|
||||
}
|
||||
await builder.process(senderName, senderMsg)
|
||||
}
|
||||
|
||||
export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg: Buffer | Uint8Array, auth: AuthenticationState) => {
|
||||
const addr = jidToSignalProtocolAddress(user)
|
||||
const session = new libsignal.SessionCipher(signalStorage(auth), addr)
|
||||
let result: Buffer
|
||||
switch(type) {
|
||||
case 'pkmsg':
|
||||
result = await session.decryptPreKeyWhisperMessage(msg)
|
||||
break
|
||||
case 'msg':
|
||||
result = await session.decryptWhisperMessage(msg)
|
||||
break
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
export const encryptSignalProto = async(user: string, buffer: Buffer, auth: AuthenticationState) => {
|
||||
const addr = jidToSignalProtocolAddress(user)
|
||||
const cipher = new libsignal.SessionCipher(signalStorage(auth), addr)
|
||||
|
||||
const { type, body } = await cipher.encrypt(buffer)
|
||||
return {
|
||||
type: type === 3 ? 'pkmsg' : 'msg',
|
||||
ciphertext: Buffer.from(body, 'binary')
|
||||
}
|
||||
}
|
||||
|
||||
export const encryptSenderKeyMsgSignalProto = async(group: string, data: Uint8Array | Buffer, auth: AuthenticationState) => {
|
||||
const storage = signalStorage(auth)
|
||||
const senderName = jidToSignalSenderKeyName(group, auth.creds.me!.id)
|
||||
const builder = new GroupSessionBuilder(storage)
|
||||
|
||||
const senderKey = await auth.keys.getSenderKey(senderName)
|
||||
if(!senderKey) {
|
||||
const record = new SenderKeyRecord()
|
||||
await auth.keys.setSenderKey(senderName, record)
|
||||
}
|
||||
|
||||
const senderKeyDistributionMessage = await builder.create(senderName)
|
||||
const session = new GroupCipher(storage, senderName)
|
||||
return {
|
||||
ciphertext: await session.encrypt(data) as Uint8Array,
|
||||
senderKeyDistributionMessageKey: senderKeyDistributionMessage.serialize() as Buffer,
|
||||
}
|
||||
}
|
||||
|
||||
export const parseAndInjectE2ESession = async(node: BinaryNode, auth: AuthenticationState) => {
|
||||
const extractKey = (key: BinaryNode) => (
|
||||
key ? ({
|
||||
keyId: getBinaryNodeChildUInt(key, 'id', 3),
|
||||
publicKey: generateSignalPubKey(
|
||||
getBinaryNodeChildBuffer(key, 'value')
|
||||
),
|
||||
signature: getBinaryNodeChildBuffer(key, 'signature'),
|
||||
}) : undefined
|
||||
)
|
||||
node = getBinaryNodeChild(getBinaryNodeChild(node, 'list'), 'user')
|
||||
assertNodeErrorFree(node)
|
||||
|
||||
const signedKey = getBinaryNodeChild(node, 'skey')
|
||||
const key = getBinaryNodeChild(node, 'key')
|
||||
const identity = getBinaryNodeChildBuffer(node, 'identity')
|
||||
const jid = node.attrs.jid
|
||||
const registrationId = getBinaryNodeChildUInt(node, 'registration', 4)
|
||||
|
||||
const device = {
|
||||
registrationId,
|
||||
identityKey: generateSignalPubKey(identity),
|
||||
signedPreKey: extractKey(signedKey),
|
||||
preKey: extractKey(key)
|
||||
}
|
||||
const cipher = new libsignal.SessionBuilder(signalStorage(auth), jidToSignalProtocolAddress(jid))
|
||||
await cipher.initOutgoing(device)
|
||||
}
|
||||
|
||||
export const extractDeviceJids = (result: BinaryNode) => {
|
||||
const extracted: { user: string, device?: number, agent?: number }[] = []
|
||||
for(const node of result.content as BinaryNode[]) {
|
||||
const list = getBinaryNodeChild(node, 'list')?.content
|
||||
if(list && Array.isArray(list)) {
|
||||
for(const item of list) {
|
||||
const { user } = jidDecode(item.attrs.jid)
|
||||
const devicesNode = getBinaryNodeChild(item, 'devices')
|
||||
const deviceListNode = getBinaryNodeChild(devicesNode, 'device-list')
|
||||
if(Array.isArray(deviceListNode?.content)) {
|
||||
for(const { tag, attrs } of deviceListNode!.content) {
|
||||
if(tag === 'device') {
|
||||
extracted.push({ user, device: +attrs.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return extracted
|
||||
}
|
||||
@@ -1,116 +1,204 @@
|
||||
import {Boom} from '@hapi/boom'
|
||||
import * as Curve from 'curve25519-js'
|
||||
import type { Contact } from '../Types/Contact'
|
||||
import type { AnyAuthenticationCredentials, AuthenticationCredentials, AuthenticationCredentialsBase64, CurveKeyPair } from "../Types"
|
||||
import { aesDecrypt, hkdf, hmacSign, whatsappID } from './generics'
|
||||
import { readFileSync } from 'fs'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { proto } from '../../WAProto'
|
||||
import type { AuthenticationState, SocketConfig, SignalKeyStore, AuthenticationCreds, KeyPair } from "../Types"
|
||||
import { curveSign, hmacSign, curveVerify, encodeInt, generateCurveKeyPair, generateRegistrationId, signedKeyPair } from './generics'
|
||||
import { BinaryNode, S_WHATSAPP_NET, jidDecode, Binary } from '../WABinary'
|
||||
import { createSignalIdentity } from './signal'
|
||||
|
||||
export const normalizedAuthInfo = (authInfo: AnyAuthenticationCredentials | string) => {
|
||||
if (!authInfo) return
|
||||
|
||||
if (typeof authInfo === 'string') {
|
||||
const file = readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
|
||||
authInfo = JSON.parse(file) as AnyAuthenticationCredentials
|
||||
}
|
||||
if ('clientID' in authInfo) {
|
||||
authInfo = {
|
||||
clientID: authInfo.clientID,
|
||||
serverToken: authInfo.serverToken,
|
||||
clientToken: authInfo.clientToken,
|
||||
encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'),
|
||||
macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'),
|
||||
}
|
||||
} else {
|
||||
const secretBundle: {encKey: string, macKey: string} = typeof authInfo.WASecretBundle === 'string' ? JSON.parse (authInfo.WASecretBundle): authInfo.WASecretBundle
|
||||
authInfo = {
|
||||
clientID: authInfo.WABrowserId.replace(/\"/g, ''),
|
||||
serverToken: authInfo.WAToken2.replace(/\"/g, ''),
|
||||
clientToken: authInfo.WAToken1.replace(/\"/g, ''),
|
||||
encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64
|
||||
macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64
|
||||
}
|
||||
}
|
||||
return authInfo as AuthenticationCredentials
|
||||
const ENCODED_VERSION = 'S9Kdc4pc4EJryo21snc5cg=='
|
||||
const getUserAgent = ({ version, browser }: Pick<SocketConfig, 'version' | 'browser'>) => ({
|
||||
appVersion: {
|
||||
primary: version[0],
|
||||
secondary: version[1],
|
||||
tertiary: version[2],
|
||||
},
|
||||
platform: 14,
|
||||
releaseChannel: 0,
|
||||
mcc: "000",
|
||||
mnc: "000",
|
||||
osVersion: browser[2],
|
||||
manufacturer: "",
|
||||
device: browser[1],
|
||||
osBuildNumber: "0.1",
|
||||
localeLanguageIso6391: 'en',
|
||||
localeCountryIso31661Alpha2: 'en',
|
||||
})
|
||||
|
||||
export const generateLoginNode = (userJid: string, config: Pick<SocketConfig, 'version' | 'browser'>) => {
|
||||
const { user, device } = jidDecode(userJid)
|
||||
const payload = {
|
||||
passive: true,
|
||||
connectType: 1,
|
||||
connectReason: 1,
|
||||
userAgent: getUserAgent(config),
|
||||
webInfo: { webSubPlatform: 0 },
|
||||
username: parseInt(user, 10),
|
||||
device: device,
|
||||
}
|
||||
return proto.ClientPayload.encode(payload).finish()
|
||||
}
|
||||
|
||||
export const base64EncodedAuthenticationCredentials = (creds: AnyAuthenticationCredentials) => {
|
||||
const normalized = normalizedAuthInfo(creds)
|
||||
return {
|
||||
...normalized,
|
||||
encKey: normalized.encKey.toString('base64'),
|
||||
macKey: normalized.macKey.toString('base64')
|
||||
} as AuthenticationCredentialsBase64
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
|
||||
* @private
|
||||
* @param json
|
||||
*/
|
||||
export const validateNewConnection = (
|
||||
json: { [_: string]: any },
|
||||
auth: AuthenticationCredentials,
|
||||
curveKeys: CurveKeyPair
|
||||
export const generateRegistrationNode = (
|
||||
{ registrationId, signedPreKey, signedIdentityKey }: Pick<AuthenticationCreds, 'registrationId' | 'signedPreKey' | 'signedIdentityKey'>,
|
||||
config: Pick<SocketConfig, 'version' | 'browser'>
|
||||
) => {
|
||||
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
||||
const onValidationSuccess = () => {
|
||||
const user: Contact = {
|
||||
jid: whatsappID(json.wid),
|
||||
name: json.pushname
|
||||
}
|
||||
return { user, auth, phone: json.phone }
|
||||
}
|
||||
if (!json.secret) {
|
||||
// if we didn't get a secret, we don't need it, we're validated
|
||||
if (json.clientToken && json.clientToken !== auth.clientToken) {
|
||||
auth = { ...auth, clientToken: json.clientToken }
|
||||
}
|
||||
if (json.serverToken && json.serverToken !== auth.serverToken) {
|
||||
auth = { ...auth, serverToken: json.serverToken }
|
||||
}
|
||||
return onValidationSuccess()
|
||||
}
|
||||
const secret = Buffer.from(json.secret, 'base64')
|
||||
if (secret.length !== 144) {
|
||||
throw new Error ('incorrect secret length received: ' + secret.length)
|
||||
}
|
||||
const appVersionBuf = new Uint8Array(Buffer.from(ENCODED_VERSION, "base64"));
|
||||
|
||||
// generate shared key from our private key & the secret shared by the server
|
||||
const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32))
|
||||
// expand the key to 80 bytes using HKDF
|
||||
const expandedKey = hkdf(sharedKey as Buffer, 80)
|
||||
const companion = {
|
||||
os: config.browser[0],
|
||||
version: {
|
||||
primary: 10,
|
||||
secondary: undefined,
|
||||
tertiary: undefined,
|
||||
},
|
||||
platformType: 1,
|
||||
requireFullSync: false,
|
||||
};
|
||||
|
||||
// perform HMAC validation.
|
||||
const hmacValidationKey = expandedKey.slice(32, 64)
|
||||
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
|
||||
const companionProto = proto.CompanionProps.encode(companion).finish()
|
||||
|
||||
const hmac = hmacSign(hmacValidationMessage, hmacValidationKey)
|
||||
const registerPayload = {
|
||||
connectReason: 1,
|
||||
connectType: 1,
|
||||
passive: false,
|
||||
regData: {
|
||||
buildHash: appVersionBuf,
|
||||
companionProps: companionProto,
|
||||
eRegid: encodeInt(4, registrationId),
|
||||
eKeytype: encodeInt(1, 5),
|
||||
eIdent: signedIdentityKey.public,
|
||||
eSkeyId: encodeInt(3, signedPreKey.keyId),
|
||||
eSkeyVal: signedPreKey.keyPair.public,
|
||||
eSkeySig: signedPreKey.signature,
|
||||
},
|
||||
userAgent: getUserAgent(config),
|
||||
webInfo: {
|
||||
webSubPlatform: 0,
|
||||
},
|
||||
}
|
||||
|
||||
if (!hmac.equals(secret.slice(32, 64))) {
|
||||
// if the checksums didn't match
|
||||
throw new Boom('HMAC validation failed', { statusCode: 400 })
|
||||
}
|
||||
|
||||
// computed HMAC should equal secret[32:64]
|
||||
// expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp
|
||||
// they are encrypted using key: expandedKey[0:32]
|
||||
const encryptedAESKeys = Buffer.concat([
|
||||
expandedKey.slice(64, expandedKey.length),
|
||||
secret.slice(64, secret.length),
|
||||
])
|
||||
const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
|
||||
// set the credentials
|
||||
auth = {
|
||||
encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages
|
||||
macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages
|
||||
clientToken: json.clientToken,
|
||||
serverToken: json.serverToken,
|
||||
clientID: auth.clientID,
|
||||
}
|
||||
return onValidationSuccess()
|
||||
return proto.ClientPayload.encode(registerPayload).finish()
|
||||
}
|
||||
export const computeChallengeResponse = (challenge: string, auth: AuthenticationCredentials) => {
|
||||
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
|
||||
const signed = hmacSign(bytes, auth.macKey).toString('base64') // sign the challenge string with our macKey
|
||||
return[ 'admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID
|
||||
|
||||
export const initInMemoryKeyStore = (
|
||||
{ preKeys, sessions, senderKeys }: {
|
||||
preKeys?: { [k: number]: KeyPair },
|
||||
sessions?: { [k: string]: any },
|
||||
senderKeys?: { [k: string]: any }
|
||||
} = { },
|
||||
) => {
|
||||
preKeys = preKeys || { }
|
||||
sessions = sessions || { }
|
||||
senderKeys = senderKeys || { }
|
||||
return {
|
||||
preKeys,
|
||||
sessions,
|
||||
senderKeys,
|
||||
getPreKey: keyId => preKeys[keyId],
|
||||
setPreKey: (keyId, pair) => {
|
||||
if(pair) preKeys[keyId] = pair
|
||||
else delete preKeys[keyId]
|
||||
},
|
||||
getSession: id => sessions[id],
|
||||
setSession: (id, item) => {
|
||||
if(item) sessions[id] = item
|
||||
else delete sessions[id]
|
||||
},
|
||||
getSenderKey: id => {
|
||||
return senderKeys[id]
|
||||
},
|
||||
setSenderKey: (id, item) => {
|
||||
if(item) senderKeys[id] = item
|
||||
else delete senderKeys[id]
|
||||
}
|
||||
} as SignalKeyStore
|
||||
}
|
||||
|
||||
export const initAuthState = (): AuthenticationState => {
|
||||
const identityKey = generateCurveKeyPair()
|
||||
return {
|
||||
creds: {
|
||||
noiseKey: generateCurveKeyPair(),
|
||||
signedIdentityKey: identityKey,
|
||||
signedPreKey: signedKeyPair(identityKey, 1),
|
||||
registrationId: generateRegistrationId(),
|
||||
advSecretKey: randomBytes(32).toString('base64'),
|
||||
|
||||
nextPreKeyId: 1,
|
||||
firstUnuploadedPreKeyId: 1,
|
||||
serverHasPreKeys: false
|
||||
},
|
||||
keys: initInMemoryKeyStore()
|
||||
}
|
||||
}
|
||||
|
||||
export const configureSuccessfulPairing = (
|
||||
stanza: BinaryNode,
|
||||
{ advSecretKey, signedIdentityKey, signalIdentities }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
|
||||
) => {
|
||||
const pair = stanza.content[0] as BinaryNode
|
||||
const pairContent = Array.isArray(pair.content) ? pair.content : []
|
||||
|
||||
const msgId = stanza.attrs.id
|
||||
const deviceIdentity = pairContent.find(m => m.tag === 'device-identity')?.content
|
||||
const businessName = pairContent.find(m => m.tag === 'biz')?.attrs?.name
|
||||
const verifiedName = businessName || ''
|
||||
const jid = pairContent.find(m => m.tag === 'device')?.attrs?.jid
|
||||
|
||||
const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentity as Buffer)
|
||||
|
||||
const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64'))
|
||||
|
||||
if (Buffer.compare(hmac, advSign) !== 0) {
|
||||
throw new Boom('Invalid pairing')
|
||||
}
|
||||
|
||||
const account = proto.ADVSignedDeviceIdentity.decode(details)
|
||||
const { accountSignatureKey, accountSignature } = account
|
||||
|
||||
const accountMsg = Binary.build(new Uint8Array([6, 0]), account.details, signedIdentityKey.public).readByteArray()
|
||||
if (!curveVerify(accountSignatureKey, accountMsg, accountSignature)) {
|
||||
throw new Boom('Failed to verify account signature')
|
||||
}
|
||||
|
||||
const deviceMsg = Binary.build(new Uint8Array([6, 1]), account.details, signedIdentityKey.public, account.accountSignatureKey).readByteArray()
|
||||
account.deviceSignature = curveSign(signedIdentityKey.private, deviceMsg)
|
||||
|
||||
const identity = createSignalIdentity(jid, accountSignatureKey)
|
||||
|
||||
const keyIndex = proto.ADVDeviceIdentity.decode(account.details).keyIndex
|
||||
|
||||
const accountEnc = proto.ADVSignedDeviceIdentity.encode({
|
||||
...account.toJSON(),
|
||||
accountSignatureKey: undefined
|
||||
}).finish()
|
||||
|
||||
const reply: BinaryNode = {
|
||||
tag: 'iq',
|
||||
attrs: {
|
||||
to: S_WHATSAPP_NET,
|
||||
type: 'result',
|
||||
id: msgId,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
tag: 'pair-device-sign',
|
||||
attrs: { },
|
||||
content: [
|
||||
{ tag: 'device-identity', attrs: { 'key-index': `${keyIndex}` }, content: accountEnc }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const authUpdate: Partial<AuthenticationCreds> = {
|
||||
account,
|
||||
me: { id: jid, verifiedName },
|
||||
signalIdentities: [...(signalIdentities || []), identity]
|
||||
}
|
||||
return {
|
||||
creds: authUpdate,
|
||||
reply
|
||||
}
|
||||
}
|
||||
47
src/WABinary/JidUtils.ts
Normal file
47
src/WABinary/JidUtils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const S_WHATSAPP_NET = '@s.whatsapp.net'
|
||||
export const OFFICIAL_BIZ_JID = '16505361212@c.us'
|
||||
export const SERVER_JID = 'server@c.us'
|
||||
export const PSA_WID = '0@c.us';
|
||||
export const STORIES_JID = 'status@broadcast'
|
||||
|
||||
export type JidServer = 'c.us' | 'g.us' | 'broadcast' | 's.whatsapp.net' | 'call'
|
||||
|
||||
export const jidEncode = (user: string | number | null, server: JidServer, device?: number, agent?: number) => {
|
||||
return `${user || ''}${!!agent ? `_${agent}` : ''}${!!device ? `:${device}` : ''}@${server}`
|
||||
}
|
||||
|
||||
export const jidDecode = (jid: string) => {
|
||||
let sepIdx = typeof jid === 'string' ? jid.indexOf('@') : -1
|
||||
if(sepIdx < 0) {
|
||||
return undefined
|
||||
}
|
||||
const server = jid.slice(sepIdx+1)
|
||||
const userCombined = jid.slice(0, sepIdx)
|
||||
|
||||
const [userAgent, device] = userCombined.split(':')
|
||||
const [user, agent] = userAgent.split('_')
|
||||
|
||||
return {
|
||||
server,
|
||||
user,
|
||||
agent: agent ? +agent : undefined,
|
||||
device: device ? +device : undefined
|
||||
}
|
||||
}
|
||||
/** is the jid a user */
|
||||
export const areJidsSameUser = (jid1: string, jid2: string) => (
|
||||
jidDecode(jid1)?.user === jidDecode(jid2)?.user
|
||||
)
|
||||
/** is the jid a user */
|
||||
export const isJidUser = (jid: string) => (jid?.endsWith('@s.whatsapp.net'))
|
||||
/** is the jid a broadcast */
|
||||
export const isJidBroadcast = (jid: string) => (jid?.endsWith('@broadcast'))
|
||||
/** is the jid a broadcast */
|
||||
export const isJidGroup = (jid: string) => (jid?.endsWith('@g.us'))
|
||||
/** is the jid the status broadcast */
|
||||
export const isJidStatusBroadcast = (jid: string) => jid === 'status@broadcast'
|
||||
|
||||
export const jidNormalizedUser = (jid: string) => {
|
||||
const { user, server } = jidDecode(jid)
|
||||
return jidEncode(user, server === 'c.us' ? 's.whatsapp.net' : server as JidServer)
|
||||
}
|
||||
48
src/WABinary/LTHash.js
Normal file
48
src/WABinary/LTHash.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { hkdf } from "../Utils/generics";
|
||||
|
||||
const o = 128;
|
||||
|
||||
class d {
|
||||
constructor(e) {
|
||||
this.salt = e
|
||||
}
|
||||
add(e, t) {
|
||||
var r = this;
|
||||
for(const item of t) {
|
||||
e = r._addSingle(e, item)
|
||||
}
|
||||
return e
|
||||
}
|
||||
subtract(e, t) {
|
||||
var r = this;
|
||||
for(const item of t) {
|
||||
e = r._subtractSingle(e, item)
|
||||
}
|
||||
return e
|
||||
}
|
||||
subtractThenAdd(e, t, r) {
|
||||
var n = this;
|
||||
return n.add(n.subtract(e, r), t)
|
||||
}
|
||||
_addSingle(e, t) {
|
||||
var r = this;
|
||||
const n = new Uint8Array(hkdf(t, o, { info: r.salt })).buffer;
|
||||
return r.performPointwiseWithOverflow(e, n, ((e,t)=>e + t))
|
||||
}
|
||||
_subtractSingle(e, t) {
|
||||
var r = this;
|
||||
|
||||
const n = new Uint8Array(hkdf(t, o, { info: r.salt })).buffer;
|
||||
return r.performPointwiseWithOverflow(e, n, ((e,t)=>e - t))
|
||||
}
|
||||
performPointwiseWithOverflow(e, t, r) {
|
||||
const n = new DataView(e)
|
||||
, i = new DataView(t)
|
||||
, a = new ArrayBuffer(n.byteLength)
|
||||
, s = new DataView(a);
|
||||
for (let e = 0; e < n.byteLength; e += 2)
|
||||
s.setUint16(e, r(n.getUint16(e, !0), i.getUint16(e, !0)), !0);
|
||||
return a
|
||||
}
|
||||
}
|
||||
export const LT_HASH_ANTI_TAMPERING = new d('WhatsApp Patch Integrity')
|
||||
305
src/WABinary/index.ts
Normal file
305
src/WABinary/index.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { DICTIONARIES_MAP, SINGLE_BYTE_TOKEN, SINGLE_BYTE_TOKEN_MAP, DICTIONARIES } from '../../WABinary/Constants';
|
||||
import { jidDecode, jidEncode } from './JidUtils';
|
||||
import { Binary, numUtf8Bytes } from '../../WABinary/Binary';
|
||||
import { Boom } from '@hapi/boom';
|
||||
|
||||
const LIST1 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '.', '<27>', '<27>', '<27>', '<27>'];
|
||||
const LIST2 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
|
||||
|
||||
function k(data: Binary, uint: number) {
|
||||
let arr = [];
|
||||
for (let a = 0; a < uint; a++) {
|
||||
arr.push(decodeBinaryNode(data));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function x(data: Binary, t, r, a) {
|
||||
const arr = new Array(2 * a - r);
|
||||
for (let n = 0; n < arr.length - 1; n += 2) {
|
||||
var s = data.readUint8();
|
||||
(arr[n] = t[s >>> 4]), (arr[n + 1] = t[15 & s]);
|
||||
}
|
||||
|
||||
if (r) {
|
||||
arr[arr.length - 1] = t[data.readUint8() >>> 4];
|
||||
}
|
||||
|
||||
return arr.join('');
|
||||
}
|
||||
|
||||
function D(e, t, r) {
|
||||
var a = e.length % 2 == 1;
|
||||
r.writeUint8(t);
|
||||
var i = Math.ceil(e.length / 2);
|
||||
a && (i |= 128), r.writeUint8(i);
|
||||
for (var n = 0, s = 0; s < e.length; s++) {
|
||||
var o = e.charCodeAt(s),
|
||||
l = null;
|
||||
if ((48 <= o && o <= 57 ? (l = o - 48) : 255 === t ? (45 === o ? (l = 10) : 46 === o && (l = 11)) : 251 === t && 65 <= o && o <= 70 && (l = o - 55), null == l))
|
||||
throw new Error(`Cannot nibble encode ${o}`);
|
||||
s % 2 == 0 ? ((n = l << 4), s === e.length - 1 && ((n |= 15), r.writeUint8(n))) : ((n |= l), r.writeUint8(n));
|
||||
}
|
||||
}
|
||||
|
||||
function N(e, t) {
|
||||
if (e < 256) t.writeUint8(252), t.writeUint8(e);
|
||||
else if (e < 1048576) t.writeUint8(253), t.writeUint8((e >>> 16) & 255), t.writeUint8((e >>> 8) & 255), t.writeUint8(255 & e);
|
||||
else {
|
||||
if (!(e < 4294967296)) throw new Error(`Binary with length ${e} is too big for WAP protocol`);
|
||||
t.writeUint8(254), t.writeUint32(e);
|
||||
}
|
||||
}
|
||||
|
||||
function R(e: any, t: Binary) {
|
||||
var w = null;
|
||||
if ('' === e) return t.writeUint8(252), void t.writeUint8(0);
|
||||
var b = SINGLE_BYTE_TOKEN_MAP;
|
||||
var r = b.get(e);
|
||||
var c = [236, 237, 238, 239];
|
||||
if (null == r) {
|
||||
if (null == w) {
|
||||
w = [];
|
||||
for (var a = 0; a < DICTIONARIES_MAP.length; ++a) w.push(DICTIONARIES_MAP[a]);
|
||||
}
|
||||
for (var n = 0; n < w.length; ++n) {
|
||||
var s = w[n].get(e);
|
||||
if (null != s) return t.writeUint8(c[n]), void t.writeUint8(s);
|
||||
}
|
||||
var o = numUtf8Bytes(e);
|
||||
if (o < 128) {
|
||||
if (!/[^0-9.-]+?/.exec(e)) return void D(e, 255, t);
|
||||
if (!/[^0-9A-F]+?/.exec(e)) return void D(e, 251, t);
|
||||
}
|
||||
N(o, t), t.writeString(e);
|
||||
} else t.writeUint8(r + 1);
|
||||
}
|
||||
|
||||
function M(e: any, t: Binary) {
|
||||
var p = 248;
|
||||
var f = 249;
|
||||
if (void 0 === e.tag) return t.writeUint8(p), void t.writeUint8(0);
|
||||
var r = 1;
|
||||
e.attrs && (r += 2 * Object.keys(e.attrs).length),
|
||||
e.content && r++,
|
||||
r < 256 ? (t.writeUint8(p), t.writeUint8(r)) : r < 65536 && (t.writeUint8(f), t.writeUint16(r)),
|
||||
O(e.tag, t),
|
||||
e.attrs &&
|
||||
Object.keys(e.attrs).forEach((r) => {
|
||||
R(r, t), O(e.attrs[r], t);
|
||||
});
|
||||
var a = e.content;
|
||||
if (Array.isArray(a)) {
|
||||
a.length < 256 ? (t.writeUint8(p), t.writeUint8(a.length)) : a.length < 65536 && (t.writeUint8(f), t.writeUint16(a.length));
|
||||
for (var i = 0; i < a.length; i++) M(a[i], t);
|
||||
} else a && O(a, t);
|
||||
}
|
||||
|
||||
function L(data: Binary, t: boolean) {
|
||||
const n = data.readUint8();
|
||||
if (n === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (n === 248) {
|
||||
return k(data, data.readUint8());
|
||||
}
|
||||
|
||||
if (n === 249) {
|
||||
return k(data, data.readUint16());
|
||||
}
|
||||
|
||||
if (n === 252) {
|
||||
return t ? data.readString(data.readUint8()) : data.readByteArray(data.readUint8());
|
||||
}
|
||||
|
||||
if (n === 253) {
|
||||
const size = ((15 & data.readUint8()) << 16) + (data.readUint8() << 8) + data.readUint8();
|
||||
return t ? data.readString(size) : data.readByteArray(size);
|
||||
}
|
||||
|
||||
if (n === 254) {
|
||||
return t ? data.readString(data.readUint32()) : data.readByteArray(data.readUint32());
|
||||
}
|
||||
|
||||
if (n === 250) {
|
||||
const user = L(data, true);
|
||||
if (null != user && 'string' != typeof user) throw new Error(`Decode string got invalid value ${String(t)}, string expected`);
|
||||
const server = decodeStanzaString(data)
|
||||
return jidEncode(user, server)
|
||||
}
|
||||
|
||||
if (n === 247) {
|
||||
const agent = data.readUint8();
|
||||
const device = data.readUint8();
|
||||
const user = decodeStanzaString(data);
|
||||
|
||||
return jidEncode(user, 's.whatsapp.net', device, agent);
|
||||
}
|
||||
|
||||
if (n === 255) {
|
||||
const number = data.readUint8();
|
||||
return x(data, LIST1, number >>> 7, 127 & number);
|
||||
}
|
||||
|
||||
if (n === 251) {
|
||||
const number = data.readUint8();
|
||||
return x(data, LIST2, number >>> 7, 127 & number);
|
||||
}
|
||||
|
||||
if (n <= 0 || n >= 240) {
|
||||
throw new Error('Unable to decode WAP buffer');
|
||||
}
|
||||
|
||||
if (n >= 236 && n <= 239) {
|
||||
const dict = DICTIONARIES[n - 236];
|
||||
if (!dict) {
|
||||
throw new Error(`Missing WAP dictionary ${n - 236}`);
|
||||
}
|
||||
|
||||
const index = data.readUint8();
|
||||
const value = dict[index];
|
||||
if (!value) {
|
||||
throw new Error(`Invalid value index ${index} in dict ${n - 236}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
const singleToken = SINGLE_BYTE_TOKEN[n - 1];
|
||||
if (!singleToken) throw new Error(`Undefined token with index ${n}`);
|
||||
|
||||
return singleToken;
|
||||
}
|
||||
|
||||
function O(e: any, t: Binary) {
|
||||
if (null == e) t.writeUint8(0);
|
||||
else if (typeof e === 'object' && !(e instanceof Uint8Array) && !Buffer.isBuffer(e) && !Array.isArray(e)) M(e, t);
|
||||
else if ('string' == typeof e) {
|
||||
const jid = jidDecode(e)
|
||||
if(jid) {
|
||||
if(typeof jid.agent !== 'undefined' || typeof jid.device !== 'undefined') {
|
||||
var { user: a, agent: i, device: n } = jid;
|
||||
t.writeUint8(247), t.writeUint8(i || 0), t.writeUint8(n || 0), O(a, t);
|
||||
} else {
|
||||
var { user: s, server: l } = jid;
|
||||
t.writeUint8(250), null != s ? O(s, t) : t.writeUint8(0), O(l, t);
|
||||
}
|
||||
} else {
|
||||
R(e, t);
|
||||
}
|
||||
} else {
|
||||
if (!(e instanceof Uint8Array)) throw new Error('Invalid payload type ' + typeof e);
|
||||
N(e.length, t), t.writeByteArray(e);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeStanzaString(data: Binary) {
|
||||
// G
|
||||
const t = L(data, true);
|
||||
if (typeof t != 'string') {
|
||||
throw new Error(`Decode string got invalid value ${String(t)}, string expected`);
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
function bufferToUInt(e: Uint8Array | Buffer, t: number) {
|
||||
let a = 0
|
||||
for (let i = 0; i < t; i++) a = 256 * a + e[i]
|
||||
return a
|
||||
}
|
||||
/**
|
||||
* the binary node WA uses internally for communication
|
||||
*
|
||||
* this is manipulated soley as an object and it does not have any functions.
|
||||
* This is done for easy serialization, to prevent running into issues with prototypes &
|
||||
* to maintain functional code structure
|
||||
* */
|
||||
export type BinaryNode = {
|
||||
tag: string
|
||||
attrs: { [key: string]: string }
|
||||
content?: BinaryNode[] | string | Uint8Array
|
||||
}
|
||||
export type BinaryNodeAttributes = BinaryNode['attrs']
|
||||
export type BinaryNodeData = BinaryNode['content']
|
||||
|
||||
export const decodeBinaryNode = (data: Binary): BinaryNode => {
|
||||
//U
|
||||
let r = data.readUint8();
|
||||
let t = r === 248 ? data.readUint8() : data.readUint16();
|
||||
|
||||
if (!t) {
|
||||
throw new Error('Failed to decode node, list cannot be empty');
|
||||
}
|
||||
|
||||
const a = {};
|
||||
|
||||
const n = decodeStanzaString(data);
|
||||
for (t -= 1; t > 1; ) {
|
||||
const s = decodeStanzaString(data);
|
||||
const l = L(data, true);
|
||||
a[s] = l;
|
||||
t -= 2;
|
||||
}
|
||||
|
||||
let i = null;
|
||||
1 === t && jidDecode(i = L(data, !1)) && (i = String(i));
|
||||
|
||||
return {
|
||||
tag: n,
|
||||
attrs: a,
|
||||
content: i
|
||||
}
|
||||
}
|
||||
|
||||
export const encodeBinaryNode = (node: BinaryNode) => {
|
||||
const data = new Binary();
|
||||
|
||||
O(node, data);
|
||||
|
||||
const dataArr = data.readByteArray();
|
||||
const result = new Uint8Array(1 + dataArr.length);
|
||||
|
||||
result[0] = 0;
|
||||
result.set(dataArr, 1);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// some extra useful utilities
|
||||
|
||||
export const getBinaryNodeChildren = ({ content }: BinaryNode, childTag: string) => {
|
||||
if(Array.isArray(content)) {
|
||||
return content.filter(item => item.tag == childTag)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const getBinaryNodeChild = ({ content }: BinaryNode, childTag: string) => {
|
||||
if(Array.isArray(content)) {
|
||||
return content.find(item => item.tag == childTag)
|
||||
}
|
||||
}
|
||||
|
||||
export const getBinaryNodeChildBuffer = (node: BinaryNode, childTag: string) => {
|
||||
const child = getBinaryNodeChild(node, childTag)?.content
|
||||
if(Buffer.isBuffer(child) || child instanceof Uint8Array) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
export const getBinaryNodeChildUInt = (node: BinaryNode, childTag: string, length: number) => {
|
||||
const buff = getBinaryNodeChildBuffer(node, childTag)
|
||||
if(buff) return bufferToUInt(buff, length)
|
||||
}
|
||||
|
||||
export const assertNodeErrorFree = (node: BinaryNode) => {
|
||||
const errNode = getBinaryNodeChild(node, 'error')
|
||||
if(errNode) {
|
||||
throw new Boom(errNode.attrs.text || 'Unknown error', { data: +errNode.attrs.code })
|
||||
}
|
||||
}
|
||||
|
||||
export * from './JidUtils'
|
||||
export { Binary } from '../../WABinary/Binary'
|
||||
10
src/index.ts
10
src/index.ts
@@ -1,9 +1,11 @@
|
||||
import makeConnection from './Connection'
|
||||
import makeWASocket from './Socket'
|
||||
|
||||
export * from '../WAMessage'
|
||||
export * from '../WAProto'
|
||||
export * from './Utils'
|
||||
export * from './Types'
|
||||
export * from './Store'
|
||||
//export * from './Store'
|
||||
export * from './Defaults'
|
||||
|
||||
export default makeConnection
|
||||
export type WASocket = ReturnType<typeof makeWASocket>
|
||||
|
||||
export default makeWASocket
|
||||
94
yarn.lock
94
yarn.lock
@@ -314,6 +314,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@cspotcode/source-map-consumer@0.8.0":
|
||||
version "0.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b"
|
||||
integrity sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==
|
||||
|
||||
"@cspotcode/source-map-support@0.6.1":
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.6.1.tgz#118511f316e2e87ee4294761868e254d3da47960"
|
||||
integrity sha512-DX3Z+T5dt1ockmPdobJS/FAsQPW4V4SrWEhD2iYQT2Cb2tQsiMnYxrcUH9By/Z3B+v0S5LMBkQtV/XOBbpLEOg==
|
||||
dependencies:
|
||||
"@cspotcode/source-map-consumer" "0.8.0"
|
||||
|
||||
"@hapi/boom@^9.1.3":
|
||||
version "9.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-9.1.3.tgz#22cad56e39b7a4819161a99b1db19eaaa9b6cc6e"
|
||||
@@ -916,10 +928,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2"
|
||||
integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==
|
||||
|
||||
"@tsconfig/node16@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.1.tgz#a6ca6a9a0ff366af433f42f5f0e124794ff6b8f1"
|
||||
integrity sha512-FTgBI767POY/lKNDNbIzgAX6miIDBs6NTCbdlDb8TrWovHsSvaVIZDlTqym29C6UqhzwcJx4CYr+AlrMywA0cA==
|
||||
"@tsconfig/node16@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
|
||||
integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==
|
||||
|
||||
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14":
|
||||
version "7.1.15"
|
||||
@@ -1019,7 +1031,7 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/long@^4.0.1":
|
||||
"@types/long@^4.0.0", "@types/long@^4.0.1":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
|
||||
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
|
||||
@@ -1029,6 +1041,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.3.3.tgz#0c30adff37bbbc7a50eb9b58fae2a504d0d88038"
|
||||
integrity sha512-8h7k1YgQKxKXWckzFCMfsIwn0Y61UK6tlD6y2lOb3hTOIMlK3t9/QwHOhc81TwU+RMf0As5fj7NPjroERCnejQ==
|
||||
|
||||
"@types/node@^10.1.0":
|
||||
version "10.17.60"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
|
||||
integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
|
||||
|
||||
"@types/node@^14.6.2":
|
||||
version "14.17.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.5.tgz#b59daf6a7ffa461b5648456ca59050ba8e40ed54"
|
||||
@@ -1131,6 +1148,11 @@ acorn-walk@^7.1.1:
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
|
||||
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
|
||||
|
||||
acorn-walk@^8.1.1:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
|
||||
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
|
||||
|
||||
acorn@^7.1.1:
|
||||
version "7.4.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
|
||||
@@ -1141,6 +1163,11 @@ acorn@^8.2.4:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.4.1.tgz#56c36251fc7cabc7096adc18f05afe814321a28c"
|
||||
integrity sha512-asabaBSkEKosYKMITunzX177CXxQ4Q8BSSzMTKD+FefUhipQC70gfW5SiUDhYQ3vk8G+81HqQk7Fv9OXwwn9KA==
|
||||
|
||||
acorn@^8.4.1:
|
||||
version "8.5.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2"
|
||||
integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==
|
||||
|
||||
agent-base@6:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
|
||||
@@ -1345,11 +1372,16 @@ buffer-equal@0.0.1:
|
||||
resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b"
|
||||
integrity sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=
|
||||
|
||||
buffer-from@1.x, buffer-from@^1.0.0:
|
||||
buffer-from@1.x:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
|
||||
integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
|
||||
|
||||
buffer-from@^1.0.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||
|
||||
buffer@^5.2.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
|
||||
@@ -1543,11 +1575,6 @@ cssstyle@^2.3.0:
|
||||
dependencies:
|
||||
cssom "~0.3.6"
|
||||
|
||||
curve25519-js@^0.0.4:
|
||||
version "0.0.4"
|
||||
resolved "https://registry.yarnpkg.com/curve25519-js/-/curve25519-js-0.0.4.tgz#e6ad967e8cd284590d657bbfc90d8b50e49ba060"
|
||||
integrity sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==
|
||||
|
||||
data-urls@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b"
|
||||
@@ -1838,11 +1865,6 @@ function-bind@^1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
|
||||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
|
||||
|
||||
futoin-hkdf@^1.3.2:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/futoin-hkdf/-/futoin-hkdf-1.4.2.tgz#fd534e848e0e50339b8bfbd81250b09cbff10ba3"
|
||||
integrity sha512-2BggwLEJOTfXzKq4Tl2bIT37p0IqqKkblH4e0cMp2sXTdmwg/ADBKMxvxaEytYYcgdxgng8+acsi3WgMVUl6CQ==
|
||||
|
||||
gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
@@ -2681,6 +2703,13 @@ levn@~0.3.0:
|
||||
prelude-ls "~1.1.2"
|
||||
type-check "~0.3.2"
|
||||
|
||||
libsignal@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/libsignal/-/libsignal-2.0.1.tgz#c276025c184ae4ebbd7d75c12c0be9f3b50cc19e"
|
||||
integrity sha512-kqdl/BK5i0WCa4NxhtiBsjSzztB/FtUp3mVVLKBFicWH8rDsq95tEIqNcCaVlflLxOm6T/HRb/zv8IsCe7aopA==
|
||||
dependencies:
|
||||
protobufjs "6.8.8"
|
||||
|
||||
load-bmfont@^1.3.1, load-bmfont@^1.4.0:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.1.tgz#c0f5f4711a1e2ccff725a7b6078087ccfcddd3e9"
|
||||
@@ -3126,6 +3155,25 @@ prompts@^2.0.1:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
protobufjs@6.8.8:
|
||||
version "6.8.8"
|
||||
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"
|
||||
integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==
|
||||
dependencies:
|
||||
"@protobufjs/aspromise" "^1.1.2"
|
||||
"@protobufjs/base64" "^1.1.2"
|
||||
"@protobufjs/codegen" "^2.0.4"
|
||||
"@protobufjs/eventemitter" "^1.1.0"
|
||||
"@protobufjs/fetch" "^1.1.0"
|
||||
"@protobufjs/float" "^1.0.2"
|
||||
"@protobufjs/inquire" "^1.1.0"
|
||||
"@protobufjs/path" "^1.1.2"
|
||||
"@protobufjs/pool" "^1.1.0"
|
||||
"@protobufjs/utf8" "^1.1.0"
|
||||
"@types/long" "^4.0.0"
|
||||
"@types/node" "^10.1.0"
|
||||
long "^4.0.0"
|
||||
|
||||
protobufjs@^6.10.1:
|
||||
version "6.11.2"
|
||||
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.2.tgz#de39fabd4ed32beaa08e9bb1e30d08544c1edf8b"
|
||||
@@ -3347,7 +3395,7 @@ sonic-boom@^1.0.2:
|
||||
atomic-sleep "^1.0.0"
|
||||
flatstr "^1.0.12"
|
||||
|
||||
source-map-support@^0.5.17, source-map-support@^0.5.6:
|
||||
source-map-support@^0.5.6:
|
||||
version "0.5.19"
|
||||
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
|
||||
integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
|
||||
@@ -3554,19 +3602,21 @@ ts-jest@^27.0.3:
|
||||
yargs-parser "20.x"
|
||||
|
||||
ts-node@^10.0.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.1.0.tgz#e656d8ad3b61106938a867f69c39a8ba6efc966e"
|
||||
integrity sha512-6szn3+J9WyG2hE+5W8e0ruZrzyk1uFLYye6IGMBadnOzDh8aP7t8CbFpsfCiEx2+wMixAhjFt7lOZC4+l+WbEA==
|
||||
version "10.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.2.1.tgz#4cc93bea0a7aba2179497e65bb08ddfc198b3ab5"
|
||||
integrity sha512-hCnyOyuGmD5wHleOQX6NIjJtYVIO8bPP8F2acWkB4W06wdlkgyvJtubO/I9NkI88hCFECbsEgoLc0VNkYmcSfw==
|
||||
dependencies:
|
||||
"@cspotcode/source-map-support" "0.6.1"
|
||||
"@tsconfig/node10" "^1.0.7"
|
||||
"@tsconfig/node12" "^1.0.7"
|
||||
"@tsconfig/node14" "^1.0.0"
|
||||
"@tsconfig/node16" "^1.0.1"
|
||||
"@tsconfig/node16" "^1.0.2"
|
||||
acorn "^8.4.1"
|
||||
acorn-walk "^8.1.1"
|
||||
arg "^4.1.0"
|
||||
create-require "^1.1.0"
|
||||
diff "^4.0.1"
|
||||
make-error "^1.1.1"
|
||||
source-map-support "^0.5.17"
|
||||
yn "3.1.1"
|
||||
|
||||
type-check@~0.3.2:
|
||||
|
||||
Reference in New Issue
Block a user