finalize multi-device

This commit is contained in:
Adhiraj Singh
2021-09-15 13:40:02 +05:30
parent 9cba28e891
commit f267f27ada
82 changed files with 35228 additions and 10644 deletions

3
.gitignore vendored
View File

@@ -10,4 +10,5 @@ lib
docs
browser-token.json
Proxy
messages*.json
messages*.json
test.ts

View File

@@ -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")

View File

@@ -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())
})()

View File

@@ -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
View File

@@ -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
View 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

File diff suppressed because one or more lines are too long

117
WABinary/HexHelper.js Normal file
View 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
View 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)

View File

@@ -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;

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
View 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;
}

View 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;

View 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
View 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')

View 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
View 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.

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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 })
}

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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>

View File

@@ -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!]
}
}*/
}
}

View File

@@ -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),
[]
)
))
}

View File

@@ -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')
})
})

View File

@@ -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 ()
})
})

View File

@@ -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
})
})

View File

@@ -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 },
]
})
})*/

View File

@@ -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)
})
})

View File

@@ -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)
}
})
})

View File

@@ -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
)
})
})

View File

@@ -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)
})

View File

@@ -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
)
}
})
})

View File

@@ -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)
})

View File

@@ -1,8 +0,0 @@
describe('Message Generation', () => {
it('should generate a text message', () => {
})
})

View File

@@ -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)
})

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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

View File

@@ -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'

View File

@@ -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
View 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
}

View File

@@ -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 }
}

View File

@@ -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
View 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 }
}

View File

@@ -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
}

View File

@@ -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
View 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
}

View File

@@ -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

View File

@@ -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
View 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
View 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
}

View File

@@ -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
View 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
View 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
View 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'

View File

@@ -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

View File

@@ -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: