mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Merge pull request #696 from adiwajshing/multi-device
Multi Device Support
This commit is contained in:
6
.eslintignore
Normal file
6
.eslintignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Ignore artifacts:
|
||||||
|
lib
|
||||||
|
coverage
|
||||||
|
*.lock
|
||||||
|
.eslintrc.json
|
||||||
|
src/WABinary/index.ts
|
||||||
18
.eslintrc.js
18
.eslintrc.js
@@ -1,18 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
parser: "@typescript-eslint/parser", // Specifies the ESLint parser
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
|
|
||||||
sourceType: "module" // Allows for the use of imports
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
|
||||||
"prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
|
|
||||||
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
// Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
|
|
||||||
// e.g. "@typescript-eslint/explicit-function-return-type": "off",
|
|
||||||
"@typescript-eslint/no-namespace": "off",
|
|
||||||
"@typescript-eslint/ban-types": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "@adiwajshing"
|
||||||
|
}
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
auth_info*.json
|
auth_info*.json
|
||||||
|
baileys_store*.json
|
||||||
output.csv
|
output.csv
|
||||||
*/.DS_Store
|
*/.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -10,4 +11,6 @@ lib
|
|||||||
docs
|
docs
|
||||||
browser-token.json
|
browser-token.json
|
||||||
Proxy
|
Proxy
|
||||||
messages*.json
|
messages*.json
|
||||||
|
test.ts
|
||||||
|
TestData
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
semi: false,
|
|
||||||
trailingComma: "all",
|
|
||||||
singleQuote: true,
|
|
||||||
printWidth: 120,
|
|
||||||
tabWidth: 4
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
76
Example/example-legacy.ts
Normal file
76
Example/example-legacy.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import P from 'pino'
|
||||||
|
import { AnyMessageContent, delay, DisconnectReason, makeInMemoryStore, makeWALegacySocket, useSingleFileLegacyAuthState } from '../src'
|
||||||
|
|
||||||
|
// the store maintains the data of the WA connection in memory
|
||||||
|
// can be written out to a file & read from it
|
||||||
|
const store = makeInMemoryStore({ logger: P().child({ level: 'debug', stream: 'store' }) })
|
||||||
|
store.readFromFile('./baileys_store.json')
|
||||||
|
// save every 10s
|
||||||
|
setInterval(() => {
|
||||||
|
store.writeToFile('./baileys_store.json')
|
||||||
|
}, 10_000)
|
||||||
|
|
||||||
|
const { state, saveState } = useSingleFileLegacyAuthState('./auth_info.json')
|
||||||
|
|
||||||
|
// start a connection
|
||||||
|
const startSock = () => {
|
||||||
|
|
||||||
|
const sock = makeWALegacySocket({
|
||||||
|
logger: P({ level: 'debug' }),
|
||||||
|
printQRInTerminal: true,
|
||||||
|
auth: state
|
||||||
|
})
|
||||||
|
store.bind(sock.ev)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
await sock.sendMessage(jid, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
sock.ev.on('messages.upsert', async m => {
|
||||||
|
if(m.type === 'append' || m.type === 'notify') {
|
||||||
|
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)
|
||||||
|
await sock!.chatRead(msg.key, 1)
|
||||||
|
await sendMessageWTyping({ text: 'Hello there!' }, msg.key.remoteJid)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
sock.ev.on('messages.update', m => console.log(JSON.stringify(m, undefined, 2)))
|
||||||
|
sock.ev.on('presence.update', m => console.log(m))
|
||||||
|
sock.ev.on('chats.update', m => console.log(m))
|
||||||
|
sock.ev.on('contacts.update', m => console.log(m))
|
||||||
|
|
||||||
|
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) {
|
||||||
|
startSock()
|
||||||
|
} else {
|
||||||
|
console.log('connection closed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('connection update', update)
|
||||||
|
})
|
||||||
|
// listen for when the auth credentials is updated
|
||||||
|
sock.ev.on('creds.update', saveState)
|
||||||
|
|
||||||
|
return sock
|
||||||
|
}
|
||||||
|
|
||||||
|
startSock()
|
||||||
@@ -1,153 +1,86 @@
|
|||||||
import {
|
import { Boom } from '@hapi/boom'
|
||||||
WAConnection,
|
import P from 'pino'
|
||||||
MessageType,
|
import makeWASocket, { AnyMessageContent, delay, DisconnectReason, makeInMemoryStore, useSingleFileAuthState } from '../src'
|
||||||
Presence,
|
|
||||||
MessageOptions,
|
|
||||||
Mimetype,
|
|
||||||
WALocationMessage,
|
|
||||||
WA_MESSAGE_STUB_TYPES,
|
|
||||||
ReconnectMode,
|
|
||||||
ProxyAgent,
|
|
||||||
waChatKey,
|
|
||||||
} from '../src/WAConnection'
|
|
||||||
import * as fs from 'fs'
|
|
||||||
|
|
||||||
async function example() {
|
// the store maintains the data of the WA connection in memory
|
||||||
const conn = new WAConnection() // instantiate
|
// can be written out to a file & read from it
|
||||||
conn.autoReconnect = ReconnectMode.onConnectionLost // only automatically reconnect when the connection breaks
|
const store = makeInMemoryStore({ logger: P().child({ level: 'debug', stream: 'store' }) })
|
||||||
conn.logger.level = 'debug' // set to 'debug' to see what kind of stuff you can implement
|
store.readFromFile('./baileys_store_multi.json')
|
||||||
// attempt to reconnect at most 10 times in a row
|
// save every 10s
|
||||||
conn.connectOptions.maxRetries = 10
|
setInterval(() => {
|
||||||
conn.chatOrderingKey = waChatKey(true) // order chats such that pinned chats are on top
|
store.writeToFile('./baileys_store_multi.json')
|
||||||
conn.on('chats-received', ({ hasNewChats }) => {
|
}, 10_000)
|
||||||
console.log(`you have ${conn.chats.length} chats, new chats available: ${hasNewChats}`)
|
|
||||||
})
|
|
||||||
conn.on('contacts-received', () => {
|
|
||||||
console.log(`you have ${Object.keys(conn.contacts).length} contacts`)
|
|
||||||
})
|
|
||||||
conn.on('initial-data-received', () => {
|
|
||||||
console.log('received all initial messages')
|
|
||||||
})
|
|
||||||
|
|
||||||
// loads the auth file credentials if present
|
const { state, saveState } = useSingleFileAuthState('./auth_info_multi.json')
|
||||||
/* Note: one can take this auth_info.json file and login again from any computer without having to scan the QR code,
|
|
||||||
and get full access to one's WhatsApp. Despite the convenience, be careful with this file */
|
|
||||||
fs.existsSync('./auth_info.json') && conn.loadAuthInfo ('./auth_info.json')
|
|
||||||
// uncomment the following line to proxy the connection; some random proxy I got off of: https://proxyscrape.com/free-proxy-list
|
|
||||||
//conn.connectOptions.agent = ProxyAgent ('http://1.0.180.120:8080')
|
|
||||||
await conn.connect()
|
|
||||||
// credentials are updated on every connect
|
|
||||||
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
|
|
||||||
|
|
||||||
console.log('oh hello ' + conn.user.name + ' (' + conn.user.jid + ')')
|
// start a connection
|
||||||
// uncomment to load all unread messages
|
const startSock = () => {
|
||||||
//const unread = await conn.loadAllUnreadMessages ()
|
|
||||||
//console.log ('you have ' + unread.length + ' unread messages')
|
const sock = makeWASocket({
|
||||||
|
logger: P({ level: 'trace' }),
|
||||||
|
printQRInTerminal: true,
|
||||||
|
auth: state,
|
||||||
|
// implement to handle retries
|
||||||
|
getMessage: async key => {
|
||||||
|
return {
|
||||||
|
conversation: 'hello'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
store.bind(sock.ev)
|
||||||
* The universal event for anything that happens
|
|
||||||
* New messages, updated messages, read & delivered messages, participants typing etc.
|
const sendMessageWTyping = async(msg: AnyMessageContent, jid: string) => {
|
||||||
*/
|
await sock.presenceSubscribe(jid)
|
||||||
conn.on('chat-update', async chat => {
|
await delay(500)
|
||||||
if (chat.presences) { // receive presence updates -- composing, available, etc.
|
|
||||||
Object.values(chat.presences).forEach(presence => console.log( `${presence.name}'s presence is ${presence.lastKnownPresence} in ${chat.jid}`))
|
await sock.sendPresenceUpdate('composing', jid)
|
||||||
}
|
await delay(2000)
|
||||||
if(chat.imgUrl) {
|
|
||||||
console.log('imgUrl of chat changed ', chat.imgUrl)
|
await sock.sendPresenceUpdate('paused', jid)
|
||||||
return
|
|
||||||
}
|
await sock.sendMessage(jid, msg)
|
||||||
// only do something when a new message is received
|
}
|
||||||
if (!chat.hasNewMessage) {
|
|
||||||
if(chat.messages) {
|
sock.ev.on('chats.set', item => console.log(`recv ${item.chats.length} chats (is latest: ${item.isLatest})`))
|
||||||
console.log('updated message: ', chat.messages.first)
|
sock.ev.on('messages.set', item => console.log(`recv ${item.messages.length} messages (is latest: ${item.isLatest})`))
|
||||||
}
|
sock.ev.on('contacts.set', item => console.log(`recv ${item.contacts.length} contacts`))
|
||||||
return
|
|
||||||
}
|
sock.ev.on('messages.upsert', async m => {
|
||||||
|
console.log(JSON.stringify(m, undefined, 2))
|
||||||
|
|
||||||
const m = chat.messages.all()[0] // pull the new message from the update
|
const msg = m.messages[0]
|
||||||
const messageStubType = WA_MESSAGE_STUB_TYPES[m.messageStubType] || 'MESSAGE'
|
if(!msg.key.fromMe && m.type === 'notify') {
|
||||||
console.log('got notification of type: ' + messageStubType)
|
console.log('replying to', m.messages[0].key.remoteJid)
|
||||||
|
await sock!.sendReadReceipt(msg.key.remoteJid, msg.key.participant, [msg.key.id])
|
||||||
const messageContent = m.message
|
await sendMessageWTyping({ text: 'Hello there!' }, msg.key.remoteJid)
|
||||||
// if it is not a regular text or media message
|
}
|
||||||
if (!messageContent) return
|
|
||||||
|
|
||||||
if (m.key.fromMe) {
|
})
|
||||||
console.log('relayed my own message')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let sender = m.key.remoteJid
|
sock.ev.on('messages.update', m => console.log(m))
|
||||||
if (m.key.participant) {
|
sock.ev.on('message-receipt.update', m => console.log(m))
|
||||||
// participant exists if the message is in a group
|
sock.ev.on('presence.update', m => console.log(m))
|
||||||
sender += ' (' + m.key.participant + ')'
|
sock.ev.on('chats.update', m => console.log(m))
|
||||||
}
|
sock.ev.on('contacts.upsert', m => console.log(m))
|
||||||
const messageType = Object.keys (messageContent)[0] // message will always contain one key signifying what kind of message
|
|
||||||
if (messageType === MessageType.text) {
|
|
||||||
const text = m.message.conversation
|
|
||||||
console.log(sender + ' sent: ' + text)
|
|
||||||
} else if (messageType === MessageType.extendedText) {
|
|
||||||
const text = m.message.extendedTextMessage.text
|
|
||||||
console.log(sender + ' sent: ' + text + ' and quoted message: ' + JSON.stringify(m.message))
|
|
||||||
} else if (messageType === MessageType.contact) {
|
|
||||||
const contact = m.message.contactMessage
|
|
||||||
console.log(sender + ' sent contact (' + contact.displayName + '): ' + contact.vcard)
|
|
||||||
} else if (messageType === MessageType.location || messageType === MessageType.liveLocation) {
|
|
||||||
const locMessage = m.message[messageType] as WALocationMessage
|
|
||||||
console.log(`${sender} sent location (lat: ${locMessage.degreesLatitude}, long: ${locMessage.degreesLongitude})`)
|
|
||||||
|
|
||||||
await conn.downloadAndSaveMediaMessage(m, './Media/media_loc_thumb_in_' + m.key.id) // save location thumbnail
|
|
||||||
|
|
||||||
if (messageType === MessageType.liveLocation) {
|
sock.ev.on('connection.update', (update) => {
|
||||||
console.log(`${sender} sent live location for duration: ${m.duration/60}`)
|
const { connection, lastDisconnect } = update
|
||||||
}
|
if(connection === 'close') {
|
||||||
} else {
|
// reconnect if not logged out
|
||||||
// if it is a media (audio, image, video, sticker) message
|
if((lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) {
|
||||||
// decode, decrypt & save the media.
|
startSock()
|
||||||
// The extension to the is applied automatically based on the media type
|
} else {
|
||||||
try {
|
console.log('connection closed')
|
||||||
const savedFile = await conn.downloadAndSaveMediaMessage(m, './Media/media_in_' + m.key.id)
|
}
|
||||||
console.log(sender + ' sent media, saved at: ' + savedFile)
|
}
|
||||||
} catch (err) {
|
|
||||||
console.log('error in decoding message: ' + err)
|
console.log('connection update', update)
|
||||||
}
|
})
|
||||||
}
|
// listen for when the auth credentials is updated
|
||||||
// send a reply after 3 seconds
|
sock.ev.on('creds.update', saveState)
|
||||||
setTimeout(async () => {
|
|
||||||
await conn.chatRead(m.key.remoteJid) // mark chat read
|
|
||||||
await conn.updatePresence(m.key.remoteJid, Presence.available) // tell them we're available
|
|
||||||
await conn.updatePresence(m.key.remoteJid, Presence.composing) // tell them we're composing
|
|
||||||
|
|
||||||
const options: MessageOptions = { quoted: m }
|
return sock
|
||||||
let content
|
|
||||||
let type: MessageType
|
|
||||||
const rand = Math.random()
|
|
||||||
if (rand > 0.66) { // choose at random
|
|
||||||
content = 'hello!' // send a "hello!" & quote the message recieved
|
|
||||||
type = MessageType.text
|
|
||||||
} else if (rand > 0.33) { // choose at random
|
|
||||||
content = { degreesLatitude: 32.123123, degreesLongitude: 12.12123123 }
|
|
||||||
type = MessageType.location
|
|
||||||
} else {
|
|
||||||
content = fs.readFileSync('./Media/ma_gif.mp4') // load the gif
|
|
||||||
options.mimetype = Mimetype.gif
|
|
||||||
type = MessageType.video
|
|
||||||
}
|
|
||||||
const response = await conn.sendMessage(m.key.remoteJid, content, type, options)
|
|
||||||
console.log("sent message with ID '" + response.key.id + "' successfully")
|
|
||||||
}, 3 * 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
/* example of custom functionality for tracking battery */
|
|
||||||
conn.on('CB:action,,battery', json => {
|
|
||||||
const batteryLevelStr = json[2][0][1].value
|
|
||||||
const batterylevel = parseInt(batteryLevelStr)
|
|
||||||
console.log('battery level: ' + batterylevel)
|
|
||||||
})
|
|
||||||
conn.on('close', ({reason, isReconnecting}) => (
|
|
||||||
console.log ('oh no got disconnected: ' + reason + ', reconnecting: ' + isReconnecting)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
example().catch((err) => console.log(`encountered error: ${err}`))
|
startSock()
|
||||||
21
LICENSE.md
21
LICENSE.md
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2020 Adhiraj Singh
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
587
WABinary/Binary.js
Normal file
587
WABinary/Binary.js
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
const { hexAt, hexLongIsNegative, hexLongToHex, negateHexLong, NUM_HEX_IN_LONG } = require("./HexHelper");
|
||||||
|
const { inflateSync } = require("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;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.numUtf8Bytes = u;
|
||||||
|
module.exports.longFitsInDouble = c;
|
||||||
|
module.exports.parseInt64OrThrow = j;
|
||||||
|
module.exports.parseUint64OrThrow = W;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.Binary = Binary
|
||||||
22
WABinary/Constants.js
Normal file
22
WABinary/Constants.js
Normal file
File diff suppressed because one or more lines are too long
117
WABinary/HexHelper.js
Normal file
117
WABinary/HexHelper.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
const { randomBytes } = require('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);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.NUM_HEX_IN_LONG = 16;
|
||||||
|
module.exports.HEX_LOWER = a;
|
||||||
|
|
||||||
|
module.exports.randomHex = function (e) {
|
||||||
|
var t = new Uint8Array(e);
|
||||||
|
var bytes = randomBytes(t.length);
|
||||||
|
t.set(bytes);
|
||||||
|
return i(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.toHex = i;
|
||||||
|
|
||||||
|
module.exports.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.hexAt = n;
|
||||||
|
module.exports.hexOrThrow = s;
|
||||||
|
module.exports.bytesToBuffer = function (e) {
|
||||||
|
var t = e.buffer;
|
||||||
|
return 0 === e.byteOffset && e.length === t.byteLength
|
||||||
|
? t
|
||||||
|
: t.slice(e.byteOffset, e.byteOffset + e.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.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)}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.createHexLongFrom32Bits = function (e, t, r = !1) {
|
||||||
|
var a = d(e),
|
||||||
|
i = d(t);
|
||||||
|
return `${r ? "-" : ""}0x${a}${i}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.hexLongToHex = function (e) {
|
||||||
|
return e.substring(e.indexOf("0x") + 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.hexLongIsNegative = l;
|
||||||
|
|
||||||
|
module.exports.negateHexLong = function (e) {
|
||||||
|
return l(e) ? e.slice(1) : "-" + e;
|
||||||
|
};
|
||||||
15
WABinary/readme.md
Normal file
15
WABinary/readme.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# WABinary
|
||||||
|
|
||||||
|
Contains the raw JS code to parse WA binary messages. WA uses a tree like structure to encode information, the type for which is written below:
|
||||||
|
|
||||||
|
``` ts
|
||||||
|
export type BinaryNode = {
|
||||||
|
tag: string
|
||||||
|
attrs: Attributes
|
||||||
|
content?: BinaryNode[] | string | Uint8Array
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do note, the multi-device binary format is very similar to the one on WA Web, though they are not backwards compatible.
|
||||||
|
|
||||||
|
Originally from [pokearaujo/multidevice](https://github.com/pokearaujo/multidevice)
|
||||||
4
WAProto/GenerateStatics.sh
Normal file
4
WAProto/GenerateStatics.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
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;
|
||||||
@@ -36,7 +36,10 @@ message UserAgent {
|
|||||||
IGLITE_ANDROID = 22;
|
IGLITE_ANDROID = 22;
|
||||||
PAGE = 23;
|
PAGE = 23;
|
||||||
MACOS = 24;
|
MACOS = 24;
|
||||||
VR = 25;
|
OCULUS_MSG = 25;
|
||||||
|
OCULUS_CALL = 26;
|
||||||
|
MILAN = 27;
|
||||||
|
CAPI = 28;
|
||||||
}
|
}
|
||||||
optional UserAgentPlatform platform = 1;
|
optional UserAgentPlatform platform = 1;
|
||||||
optional AppVersion appVersion = 2;
|
optional AppVersion appVersion = 2;
|
||||||
@@ -113,21 +116,11 @@ message CompanionRegData {
|
|||||||
message ClientPayload {
|
message ClientPayload {
|
||||||
optional uint64 username = 1;
|
optional uint64 username = 1;
|
||||||
optional bool passive = 3;
|
optional bool passive = 3;
|
||||||
enum ClientPayloadClientFeature {
|
|
||||||
NONE = 0;
|
|
||||||
}
|
|
||||||
repeated ClientPayloadClientFeature clientFeatures = 4;
|
|
||||||
optional UserAgent userAgent = 5;
|
optional UserAgent userAgent = 5;
|
||||||
optional WebInfo webInfo = 6;
|
optional WebInfo webInfo = 6;
|
||||||
optional string pushName = 7;
|
optional string pushName = 7;
|
||||||
optional sfixed32 sessionId = 9;
|
optional sfixed32 sessionId = 9;
|
||||||
optional bool shortConnect = 10;
|
optional bool shortConnect = 10;
|
||||||
enum ClientPayloadIOSAppExtension {
|
|
||||||
SHARE_EXTENSION = 0;
|
|
||||||
SERVICE_EXTENSION = 1;
|
|
||||||
INTENTS_EXTENSION = 2;
|
|
||||||
}
|
|
||||||
optional ClientPayloadIOSAppExtension iosAppExtension = 30;
|
|
||||||
enum ClientPayloadConnectType {
|
enum ClientPayloadConnectType {
|
||||||
CELLULAR_UNKNOWN = 0;
|
CELLULAR_UNKNOWN = 0;
|
||||||
WIFI_UNKNOWN = 1;
|
WIFI_UNKNOWN = 1;
|
||||||
@@ -169,15 +162,24 @@ message ClientPayload {
|
|||||||
optional bytes fbCat = 21;
|
optional bytes fbCat = 21;
|
||||||
optional bytes fbUserAgent = 22;
|
optional bytes fbUserAgent = 22;
|
||||||
optional bool oc = 23;
|
optional bool oc = 23;
|
||||||
|
optional uint32 lc = 24;
|
||||||
|
enum ClientPayloadIOSAppExtension {
|
||||||
|
SHARE_EXTENSION = 0;
|
||||||
|
SERVICE_EXTENSION = 1;
|
||||||
|
INTENTS_EXTENSION = 2;
|
||||||
|
}
|
||||||
|
optional ClientPayloadIOSAppExtension iosAppExtension = 30;
|
||||||
|
optional uint64 fbAppId = 31;
|
||||||
|
optional bytes fbDeviceId = 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
//message Details {
|
message Details {
|
||||||
// optional uint32 serial = 1;
|
optional uint32 serial = 1;
|
||||||
// optional string issuer = 2;
|
optional string issuer = 2;
|
||||||
// optional uint64 expires = 3;
|
optional uint64 expires = 3;
|
||||||
// optional string subject = 4;
|
optional string subject = 4;
|
||||||
// optional bytes key = 5;
|
optional bytes key = 5;
|
||||||
//}
|
}
|
||||||
|
|
||||||
message NoiseCertificate {
|
message NoiseCertificate {
|
||||||
optional bytes details = 1;
|
optional bytes details = 1;
|
||||||
@@ -226,8 +228,9 @@ message BizIdentityInfo {
|
|||||||
SELF = 0;
|
SELF = 0;
|
||||||
BSP = 1;
|
BSP = 1;
|
||||||
}
|
}
|
||||||
optional BizIdentityInfoActualActorsType actualActors = 6;
|
optional BizIdentityInfoActualActorsType actualActors = 6;
|
||||||
optional uint64 privacyModeTs = 7;
|
optional uint64 privacyModeTs = 7;
|
||||||
|
optional uint64 featureControls = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message BizAccountLinkInfo {
|
message BizAccountLinkInfo {
|
||||||
@@ -241,7 +244,6 @@ message BizAccountLinkInfo {
|
|||||||
optional BizAccountLinkInfoHostStorageType hostStorage = 4;
|
optional BizAccountLinkInfoHostStorageType hostStorage = 4;
|
||||||
enum BizAccountLinkInfoAccountType {
|
enum BizAccountLinkInfoAccountType {
|
||||||
ENTERPRISE = 0;
|
ENTERPRISE = 0;
|
||||||
PAGE = 1;
|
|
||||||
}
|
}
|
||||||
optional BizAccountLinkInfoAccountType accountType = 5;
|
optional BizAccountLinkInfoAccountType accountType = 5;
|
||||||
}
|
}
|
||||||
@@ -251,14 +253,6 @@ message BizAccountPayload {
|
|||||||
optional bytes bizAcctLinkInfo = 2;
|
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 {
|
message VerifiedNameCertificate {
|
||||||
optional bytes details = 1;
|
optional bytes details = 1;
|
||||||
optional bytes signature = 2;
|
optional bytes signature = 2;
|
||||||
@@ -345,6 +339,17 @@ message RecentEmojiWeightsAction {
|
|||||||
repeated RecentEmojiWeight weights = 1;
|
repeated RecentEmojiWeight weights = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message FavoriteStickerAction {
|
||||||
|
optional string directPath = 1;
|
||||||
|
optional string lastUploadTimestamp = 2;
|
||||||
|
optional string handle = 3;
|
||||||
|
optional string encFilehash = 4;
|
||||||
|
optional string stickerHashWithoutMeta = 5;
|
||||||
|
optional string mediaKey = 6;
|
||||||
|
optional int64 mediaKeyTimestamp = 7;
|
||||||
|
optional bool isFavorite = 8;
|
||||||
|
}
|
||||||
|
|
||||||
message ArchiveChatAction {
|
message ArchiveChatAction {
|
||||||
optional bool archived = 1;
|
optional bool archived = 1;
|
||||||
optional SyncActionMessageRange messageRange = 2;
|
optional SyncActionMessageRange messageRange = 2;
|
||||||
@@ -387,6 +392,14 @@ message KeyExpiration {
|
|||||||
optional int32 expiredKeyEpoch = 1;
|
optional int32 expiredKeyEpoch = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message PrimaryFeature {
|
||||||
|
repeated string flags = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AndroidUnsupportedActions {
|
||||||
|
optional bool allowed = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message SyncActionValue {
|
message SyncActionValue {
|
||||||
optional int64 timestamp = 1;
|
optional int64 timestamp = 1;
|
||||||
optional StarAction starAction = 2;
|
optional StarAction starAction = 2;
|
||||||
@@ -409,6 +422,9 @@ message SyncActionValue {
|
|||||||
optional ClearChatAction clearChatAction = 21;
|
optional ClearChatAction clearChatAction = 21;
|
||||||
optional DeleteChatAction deleteChatAction = 22;
|
optional DeleteChatAction deleteChatAction = 22;
|
||||||
optional UnarchiveChatsSetting unarchiveChatsSetting = 23;
|
optional UnarchiveChatsSetting unarchiveChatsSetting = 23;
|
||||||
|
optional PrimaryFeature primaryFeature = 24;
|
||||||
|
optional FavoriteStickerAction favoriteStickerAction = 25;
|
||||||
|
optional AndroidUnsupportedActions androidUnsupportedActions = 26;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RecentEmojiWeight {
|
message RecentEmojiWeight {
|
||||||
@@ -507,8 +523,6 @@ message MediaRetryNotification {
|
|||||||
message MsgOpaqueData {
|
message MsgOpaqueData {
|
||||||
optional string body = 1;
|
optional string body = 1;
|
||||||
optional string caption = 3;
|
optional string caption = 3;
|
||||||
optional string clientUrl = 4;
|
|
||||||
// optional string loc = 4;
|
|
||||||
optional double lng = 5;
|
optional double lng = 5;
|
||||||
optional double lat = 7;
|
optional double lat = 7;
|
||||||
optional int32 paymentAmount1000 = 8;
|
optional int32 paymentAmount1000 = 8;
|
||||||
@@ -517,6 +531,9 @@ message MsgOpaqueData {
|
|||||||
optional string matchedText = 11;
|
optional string matchedText = 11;
|
||||||
optional string title = 12;
|
optional string title = 12;
|
||||||
optional string description = 13;
|
optional string description = 13;
|
||||||
|
optional bytes futureproofBuffer = 14;
|
||||||
|
optional string clientUrl = 15;
|
||||||
|
optional string loc = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MsgRowOpaqueData {
|
message MsgRowOpaqueData {
|
||||||
@@ -524,6 +541,27 @@ message MsgRowOpaqueData {
|
|||||||
optional MsgOpaqueData quotedMsg = 2;
|
optional MsgOpaqueData quotedMsg = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GlobalSettings {
|
||||||
|
optional WallpaperSettings lightThemeWallpaper = 1;
|
||||||
|
optional MediaVisibility mediaVisibility = 2;
|
||||||
|
optional WallpaperSettings darkThemeWallpaper = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WallpaperSettings {
|
||||||
|
optional string filename = 1;
|
||||||
|
optional uint32 opacity = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GroupParticipant {
|
||||||
|
required string userJid = 1;
|
||||||
|
enum GroupParticipantRank {
|
||||||
|
REGULAR = 0;
|
||||||
|
ADMIN = 1;
|
||||||
|
SUPERADMIN = 2;
|
||||||
|
}
|
||||||
|
optional GroupParticipantRank rank = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message Pushname {
|
message Pushname {
|
||||||
optional string id = 1;
|
optional string id = 1;
|
||||||
optional string pushname = 2;
|
optional string pushname = 2;
|
||||||
@@ -554,6 +592,20 @@ message Conversation {
|
|||||||
optional string name = 13;
|
optional string name = 13;
|
||||||
optional string pHash = 14;
|
optional string pHash = 14;
|
||||||
optional bool notSpam = 15;
|
optional bool notSpam = 15;
|
||||||
|
optional bool archived = 16;
|
||||||
|
optional DisappearingMode disappearingMode = 17;
|
||||||
|
optional uint32 unreadMentionCount = 18;
|
||||||
|
optional bool markedAsUnread = 19;
|
||||||
|
repeated GroupParticipant participant = 20;
|
||||||
|
optional bytes tcToken = 21;
|
||||||
|
optional uint64 tcTokenTimestamp = 22;
|
||||||
|
optional bytes contactPrimaryIdentityKey = 23;
|
||||||
|
optional uint32 pinned = 24;
|
||||||
|
optional uint64 muteEndTime = 25;
|
||||||
|
optional WallpaperSettings wallpaper = 26;
|
||||||
|
optional MediaVisibility mediaVisibility = 27;
|
||||||
|
optional uint64 tcTokenSenderTimestamp = 28;
|
||||||
|
optional bool suspended = 29;
|
||||||
}
|
}
|
||||||
|
|
||||||
message HistorySync {
|
message HistorySync {
|
||||||
@@ -570,91 +622,21 @@ message HistorySync {
|
|||||||
optional uint32 chunkOrder = 5;
|
optional uint32 chunkOrder = 5;
|
||||||
optional uint32 progress = 6;
|
optional uint32 progress = 6;
|
||||||
repeated Pushname pushnames = 7;
|
repeated Pushname pushnames = 7;
|
||||||
|
optional GlobalSettings globalSettings = 8;
|
||||||
|
optional bytes threadIdUserSecret = 9;
|
||||||
|
optional uint32 threadDsTimeframeOffset = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MediaVisibility {
|
||||||
|
DEFAULT = 0;
|
||||||
|
OFF = 1;
|
||||||
|
ON = 2;
|
||||||
|
}
|
||||||
message EphemeralSetting {
|
message EphemeralSetting {
|
||||||
optional sfixed32 duration = 1;
|
optional sfixed32 duration = 1;
|
||||||
optional sfixed64 timestamp = 2;
|
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 {
|
|
||||||
optional HighlyStructuredMessage displayText = 1;
|
|
||||||
optional string id = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message URLButton {
|
|
||||||
optional HighlyStructuredMessage displayText = 1;
|
|
||||||
optional HighlyStructuredMessage url = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message CallButton {
|
|
||||||
optional HighlyStructuredMessage displayText = 1;
|
|
||||||
optional HighlyStructuredMessage phoneNumber = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message TemplateButton {
|
|
||||||
optional uint32 index = 4;
|
|
||||||
oneof button {
|
|
||||||
QuickReplyButton quickReplyButton = 1;
|
|
||||||
URLButton urlButton = 2;
|
|
||||||
CallButton callButton = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message Location {
|
|
||||||
optional double degreesLatitude = 1;
|
|
||||||
optional double degreesLongitude = 2;
|
|
||||||
optional string name = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Point {
|
|
||||||
optional int32 xDeprecated = 1;
|
|
||||||
optional int32 yDeprecated = 2;
|
|
||||||
optional double x = 3;
|
|
||||||
optional double y = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
message InteractiveAnnotation {
|
message InteractiveAnnotation {
|
||||||
repeated Point polygonVertices = 1;
|
repeated Point polygonVertices = 1;
|
||||||
oneof action {
|
oneof action {
|
||||||
@@ -725,6 +707,10 @@ message ContextInfo {
|
|||||||
optional string entryPointConversionSource = 29;
|
optional string entryPointConversionSource = 29;
|
||||||
optional string entryPointConversionApp = 30;
|
optional string entryPointConversionApp = 30;
|
||||||
optional uint32 entryPointConversionDelaySeconds = 31;
|
optional uint32 entryPointConversionDelaySeconds = 31;
|
||||||
|
optional DisappearingMode disappearingMode = 32;
|
||||||
|
optional ActionLink actionLink = 33;
|
||||||
|
optional string groupSubject = 34;
|
||||||
|
optional string parentGroupJid = 35;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SenderKeyDistributionMessage {
|
message SenderKeyDistributionMessage {
|
||||||
@@ -758,6 +744,7 @@ message ImageMessage {
|
|||||||
optional string thumbnailDirectPath = 26;
|
optional string thumbnailDirectPath = 26;
|
||||||
optional bytes thumbnailSha256 = 27;
|
optional bytes thumbnailSha256 = 27;
|
||||||
optional bytes thumbnailEncSha256 = 28;
|
optional bytes thumbnailEncSha256 = 28;
|
||||||
|
optional string staticUrl = 29;
|
||||||
}
|
}
|
||||||
|
|
||||||
message InvoiceMessage {
|
message InvoiceMessage {
|
||||||
@@ -830,6 +817,11 @@ message ExtendedTextMessage {
|
|||||||
optional int64 mediaKeyTimestamp = 23;
|
optional int64 mediaKeyTimestamp = 23;
|
||||||
optional uint32 thumbnailHeight = 24;
|
optional uint32 thumbnailHeight = 24;
|
||||||
optional uint32 thumbnailWidth = 25;
|
optional uint32 thumbnailWidth = 25;
|
||||||
|
enum ExtendedTextMessageInviteLinkGroupType {
|
||||||
|
DEFAULT = 0;
|
||||||
|
PARENT = 1;
|
||||||
|
}
|
||||||
|
optional ExtendedTextMessageInviteLinkGroupType inviteLinkGroupType = 26;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DocumentMessage {
|
message DocumentMessage {
|
||||||
@@ -867,6 +859,7 @@ message AudioMessage {
|
|||||||
optional int64 mediaKeyTimestamp = 10;
|
optional int64 mediaKeyTimestamp = 10;
|
||||||
optional ContextInfo contextInfo = 17;
|
optional ContextInfo contextInfo = 17;
|
||||||
optional bytes streamingSidecar = 18;
|
optional bytes streamingSidecar = 18;
|
||||||
|
optional bytes waveform = 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
message VideoMessage {
|
message VideoMessage {
|
||||||
@@ -897,6 +890,7 @@ message VideoMessage {
|
|||||||
optional string thumbnailDirectPath = 21;
|
optional string thumbnailDirectPath = 21;
|
||||||
optional bytes thumbnailSha256 = 22;
|
optional bytes thumbnailSha256 = 22;
|
||||||
optional bytes thumbnailEncSha256 = 23;
|
optional bytes thumbnailEncSha256 = 23;
|
||||||
|
optional string staticUrl = 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Call {
|
message Call {
|
||||||
@@ -932,6 +926,7 @@ message ProtocolMessage {
|
|||||||
optional AppStateSyncKeyRequest appStateSyncKeyRequest = 8;
|
optional AppStateSyncKeyRequest appStateSyncKeyRequest = 8;
|
||||||
optional InitialSecurityNotificationSettingSync initialSecurityNotificationSettingSync = 9;
|
optional InitialSecurityNotificationSettingSync initialSecurityNotificationSettingSync = 9;
|
||||||
optional AppStateFatalExceptionNotification appStateFatalExceptionNotification = 10;
|
optional AppStateFatalExceptionNotification appStateFatalExceptionNotification = 10;
|
||||||
|
optional DisappearingMode disappearingMode = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
message HistorySyncNotification {
|
message HistorySyncNotification {
|
||||||
@@ -1188,6 +1183,8 @@ message ProductMessage {
|
|||||||
optional ProductSnapshot product = 1;
|
optional ProductSnapshot product = 1;
|
||||||
optional string businessOwnerJid = 2;
|
optional string businessOwnerJid = 2;
|
||||||
optional CatalogSnapshot catalog = 4;
|
optional CatalogSnapshot catalog = 4;
|
||||||
|
optional string body = 5;
|
||||||
|
optional string footer = 6;
|
||||||
optional ContextInfo contextInfo = 17;
|
optional ContextInfo contextInfo = 17;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,6 +1272,67 @@ message ListResponseMessage {
|
|||||||
optional string description = 5;
|
optional string description = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Header {
|
||||||
|
optional string title = 1;
|
||||||
|
optional string subtitle = 2;
|
||||||
|
optional bool hasMediaAttachment = 5;
|
||||||
|
oneof media {
|
||||||
|
DocumentMessage documentMessage = 3;
|
||||||
|
ImageMessage imageMessage = 4;
|
||||||
|
bytes jpegThumbnail = 6;
|
||||||
|
VideoMessage videoMessage = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Body {
|
||||||
|
optional string text = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Footer {
|
||||||
|
optional string text = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ShopMessage {
|
||||||
|
optional string id = 1;
|
||||||
|
enum ShopMessageSurface {
|
||||||
|
UNKNOWN_SURFACE = 0;
|
||||||
|
FB = 1;
|
||||||
|
IG = 2;
|
||||||
|
WA = 3;
|
||||||
|
}
|
||||||
|
optional ShopMessageSurface surface = 2;
|
||||||
|
optional int32 messageVersion = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CollectionMessage {
|
||||||
|
optional string bizJid = 1;
|
||||||
|
optional string id = 2;
|
||||||
|
optional int32 messageVersion = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NativeFlowButton {
|
||||||
|
optional string name = 1;
|
||||||
|
optional string buttonParamsJson = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NativeFlowMessage {
|
||||||
|
repeated NativeFlowButton buttons = 1;
|
||||||
|
optional string messageParamsJson = 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 {
|
||||||
|
ShopMessage shopStorefrontMessage = 4;
|
||||||
|
CollectionMessage collectionMessage = 5;
|
||||||
|
NativeFlowMessage nativeFlowMessage = 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message GroupInviteMessage {
|
message GroupInviteMessage {
|
||||||
optional string groupJid = 1;
|
optional string groupJid = 1;
|
||||||
optional string inviteCode = 2;
|
optional string inviteCode = 2;
|
||||||
@@ -1283,6 +1341,11 @@ message GroupInviteMessage {
|
|||||||
optional bytes jpegThumbnail = 5;
|
optional bytes jpegThumbnail = 5;
|
||||||
optional string caption = 6;
|
optional string caption = 6;
|
||||||
optional ContextInfo contextInfo = 7;
|
optional ContextInfo contextInfo = 7;
|
||||||
|
enum GroupInviteMessageGroupType {
|
||||||
|
DEFAULT = 0;
|
||||||
|
PARENT = 1;
|
||||||
|
}
|
||||||
|
optional GroupInviteMessageGroupType groupType = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message DeviceSentMessage {
|
message DeviceSentMessage {
|
||||||
@@ -1353,6 +1416,19 @@ message ButtonsResponseMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ReactionMessage {
|
||||||
|
optional MessageKey key = 1;
|
||||||
|
optional string text = 2;
|
||||||
|
optional string groupingKey = 3;
|
||||||
|
optional int64 senderTimestampMs = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StickerSyncRMRMessage {
|
||||||
|
repeated string filehash = 1;
|
||||||
|
optional string rmrSource = 2;
|
||||||
|
optional int64 requestTimestamp = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message Message {
|
message Message {
|
||||||
optional string conversation = 1;
|
optional string conversation = 1;
|
||||||
optional SenderKeyDistributionMessage senderKeyDistributionMessage = 2;
|
optional SenderKeyDistributionMessage senderKeyDistributionMessage = 2;
|
||||||
@@ -1390,6 +1466,115 @@ message Message {
|
|||||||
optional ButtonsMessage buttonsMessage = 42;
|
optional ButtonsMessage buttonsMessage = 42;
|
||||||
optional ButtonsResponseMessage buttonsResponseMessage = 43;
|
optional ButtonsResponseMessage buttonsResponseMessage = 43;
|
||||||
optional PaymentInviteMessage paymentInviteMessage = 44;
|
optional PaymentInviteMessage paymentInviteMessage = 44;
|
||||||
|
optional InteractiveMessage interactiveMessage = 45;
|
||||||
|
optional ReactionMessage reactionMessage = 46;
|
||||||
|
optional StickerSyncRMRMessage stickerSyncRmrMessage = 47;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ActionLink {
|
||||||
|
optional string url = 1;
|
||||||
|
optional string buttonTitle = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DisappearingMode {
|
||||||
|
enum DisappearingModeInitiator {
|
||||||
|
CHANGED_IN_CHAT = 0;
|
||||||
|
INITIATED_BY_ME = 1;
|
||||||
|
INITIATED_BY_OTHER = 2;
|
||||||
|
}
|
||||||
|
optional DisappearingModeInitiator initiator = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MediaData {
|
||||||
|
optional bytes mediaKey = 1;
|
||||||
|
optional int64 mediaKeyTimestamp = 2;
|
||||||
|
optional bytes fileSha256 = 3;
|
||||||
|
optional bytes fileEncSha256 = 4;
|
||||||
|
optional string directPath = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
optional MediaData mediaData = 9;
|
||||||
|
enum PaymentBackgroundType {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
DEFAULT = 1;
|
||||||
|
}
|
||||||
|
optional PaymentBackgroundType type = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
optional HighlyStructuredMessage displayText = 1;
|
||||||
|
optional string id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message URLButton {
|
||||||
|
optional HighlyStructuredMessage displayText = 1;
|
||||||
|
optional HighlyStructuredMessage url = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CallButton {
|
||||||
|
optional HighlyStructuredMessage displayText = 1;
|
||||||
|
optional HighlyStructuredMessage phoneNumber = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TemplateButton {
|
||||||
|
optional uint32 index = 4;
|
||||||
|
oneof button {
|
||||||
|
QuickReplyButton quickReplyButton = 1;
|
||||||
|
URLButton urlButton = 2;
|
||||||
|
CallButton callButton = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Location {
|
||||||
|
optional double degreesLatitude = 1;
|
||||||
|
optional double degreesLongitude = 2;
|
||||||
|
optional string name = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Point {
|
||||||
|
optional int32 xDeprecated = 1;
|
||||||
|
optional int32 yDeprecated = 2;
|
||||||
|
optional double x = 3;
|
||||||
|
optional double y = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CompanionProps {
|
message CompanionProps {
|
||||||
@@ -1451,16 +1636,29 @@ message MessageKey {
|
|||||||
optional string participant = 4;
|
optional string participant = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Reaction {
|
||||||
|
optional MessageKey key = 1;
|
||||||
|
optional string text = 2;
|
||||||
|
optional string groupingKey = 3;
|
||||||
|
optional int64 senderTimestampMs = 4;
|
||||||
|
optional bool unread = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 PhotoChange {
|
message PhotoChange {
|
||||||
optional bytes oldPhoto = 1;
|
optional bytes oldPhoto = 1;
|
||||||
optional bytes newPhoto = 2;
|
optional bytes newPhoto = 2;
|
||||||
optional uint32 newPhotoId = 3;
|
optional uint32 newPhotoId = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MediaData {
|
|
||||||
optional string localPath = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message WebFeatures {
|
message WebFeatures {
|
||||||
enum WebFeaturesFlag {
|
enum WebFeaturesFlag {
|
||||||
NOT_STARTED = 0;
|
NOT_STARTED = 0;
|
||||||
@@ -1510,6 +1708,9 @@ message WebFeatures {
|
|||||||
optional WebFeaturesFlag ephemeralAllowGroupMembers = 44;
|
optional WebFeaturesFlag ephemeralAllowGroupMembers = 44;
|
||||||
optional WebFeaturesFlag ephemeral24HDuration = 45;
|
optional WebFeaturesFlag ephemeral24HDuration = 45;
|
||||||
optional WebFeaturesFlag mdForceUpgrade = 46;
|
optional WebFeaturesFlag mdForceUpgrade = 46;
|
||||||
|
optional WebFeaturesFlag disappearingMode = 47;
|
||||||
|
optional WebFeaturesFlag externalMdOptInAvailable = 48;
|
||||||
|
optional WebFeaturesFlag noDeleteMessageTimeLimit = 49;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NotificationMessageInfo {
|
message NotificationMessageInfo {
|
||||||
@@ -1745,6 +1946,8 @@ message WebMessageInfo {
|
|||||||
BIZ_PRIVACY_MODE_INIT_BSP = 127;
|
BIZ_PRIVACY_MODE_INIT_BSP = 127;
|
||||||
BIZ_PRIVACY_MODE_TO_FB = 128;
|
BIZ_PRIVACY_MODE_TO_FB = 128;
|
||||||
BIZ_PRIVACY_MODE_TO_BSP = 129;
|
BIZ_PRIVACY_MODE_TO_BSP = 129;
|
||||||
|
DISAPPEARING_MODE = 130;
|
||||||
|
E2E_DEVICE_FETCH_FAILED = 131;
|
||||||
}
|
}
|
||||||
optional WebMessageInfoStubType messageStubType = 24;
|
optional WebMessageInfoStubType messageStubType = 24;
|
||||||
optional bool clearMedia = 25;
|
optional bool clearMedia = 25;
|
||||||
@@ -1768,4 +1971,11 @@ message WebMessageInfo {
|
|||||||
optional string verifiedBizName = 37;
|
optional string verifiedBizName = 37;
|
||||||
optional MediaData mediaData = 38;
|
optional MediaData mediaData = 38;
|
||||||
optional PhotoChange photoChange = 39;
|
optional PhotoChange photoChange = 39;
|
||||||
}
|
repeated UserReceipt userReceipt = 40;
|
||||||
|
repeated Reaction reactions = 41;
|
||||||
|
optional MediaData quotedStickerData = 42;
|
||||||
|
optional bytes futureproofData = 43;
|
||||||
|
optional string statusPsaCampaignId = 44;
|
||||||
|
optional uint32 statusPsaCampaignDuration = 45;
|
||||||
|
optional uint64 statusPsaCampaignReadTimestamp = 46;
|
||||||
|
}
|
||||||
5319
WAMessage/WAMessage.d.ts → WAProto/index.d.ts
vendored
5319
WAMessage/WAMessage.d.ts → WAProto/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1697
WASignalGroup/GroupProtocol.js
Normal file
1697
WASignalGroup/GroupProtocol.js
Normal file
File diff suppressed because it is too large
Load Diff
16
WASignalGroup/ciphertext_message.js
Normal file
16
WASignalGroup/ciphertext_message.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class CiphertextMessage {
|
||||||
|
UNSUPPORTED_VERSION = 1;
|
||||||
|
|
||||||
|
CURRENT_VERSION = 3;
|
||||||
|
|
||||||
|
WHISPER_TYPE = 2;
|
||||||
|
|
||||||
|
PREKEY_TYPE = 3;
|
||||||
|
|
||||||
|
SENDERKEY_TYPE = 4;
|
||||||
|
|
||||||
|
SENDERKEY_DISTRIBUTION_TYPE = 5;
|
||||||
|
|
||||||
|
ENCRYPTED_MESSAGE_OVERHEAD = 53;
|
||||||
|
}
|
||||||
|
module.exports = CiphertextMessage;
|
||||||
1
WASignalGroup/generate-proto.sh
Normal file
1
WASignalGroup/generate-proto.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
yarn pbjs -t static-module -w commonjs -o ./WASignalGroup/GroupProtocol.js ./WASignalGroup/group.proto
|
||||||
42
WASignalGroup/group.proto
Normal file
42
WASignalGroup/group.proto
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SenderKeyStateStructure {
|
||||||
|
|
||||||
|
|
||||||
|
optional uint32 senderKeyId = 1;
|
||||||
|
optional SenderChainKey senderChainKey = 2;
|
||||||
|
optional SenderSigningKey senderSigningKey = 3;
|
||||||
|
repeated SenderMessageKey senderMessageKeys = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SenderKeyRecordStructure {
|
||||||
|
repeated SenderKeyStateStructure senderKeyStates = 1;
|
||||||
|
}
|
||||||
106
WASignalGroup/group_cipher.js
Normal file
106
WASignalGroup/group_cipher.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const SenderKeyMessage = require('./sender_key_message');
|
||||||
|
const crypto = require('libsignal/src/crypto');
|
||||||
|
|
||||||
|
class GroupCipher {
|
||||||
|
constructor(senderKeyStore, senderKeyName) {
|
||||||
|
this.senderKeyStore = senderKeyStore;
|
||||||
|
this.senderKeyName = senderKeyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async encrypt(paddedPlaintext) {
|
||||||
|
try {
|
||||||
|
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName);
|
||||||
|
const senderKeyState = record.getSenderKeyState();
|
||||||
|
const senderKey = senderKeyState.getSenderChainKey().getSenderMessageKey();
|
||||||
|
|
||||||
|
const ciphertext = await this.getCipherText(
|
||||||
|
senderKey.getIv(),
|
||||||
|
senderKey.getCipherKey(),
|
||||||
|
paddedPlaintext
|
||||||
|
);
|
||||||
|
|
||||||
|
const senderKeyMessage = new SenderKeyMessage(
|
||||||
|
senderKeyState.getKeyId(),
|
||||||
|
senderKey.getIteration(),
|
||||||
|
ciphertext,
|
||||||
|
senderKeyState.getSigningKeyPrivate()
|
||||||
|
);
|
||||||
|
senderKeyState.setSenderChainKey(senderKeyState.getSenderChainKey().getNext());
|
||||||
|
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record);
|
||||||
|
return senderKeyMessage.serialize();
|
||||||
|
} catch (e) {
|
||||||
|
//console.log(e.stack);
|
||||||
|
throw new Error('NoSessionException');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(senderKeyMessageBytes) {
|
||||||
|
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName);
|
||||||
|
if (!record) throw new Error(`No sender key for: ${this.senderKeyName}`);
|
||||||
|
|
||||||
|
const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes);
|
||||||
|
|
||||||
|
const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId());
|
||||||
|
//senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic());
|
||||||
|
const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration());
|
||||||
|
// senderKeyState.senderKeyStateStructure.senderSigningKey.private =
|
||||||
|
|
||||||
|
const plaintext = await this.getPlainText(
|
||||||
|
senderKey.getIv(),
|
||||||
|
senderKey.getCipherKey(),
|
||||||
|
senderKeyMessage.getCipherText()
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record);
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSenderKey(senderKeyState, iteration) {
|
||||||
|
let senderChainKey = senderKeyState.getSenderChainKey();
|
||||||
|
if (senderChainKey.getIteration() > iteration) {
|
||||||
|
if (senderKeyState.hasSenderMessageKey(iteration)) {
|
||||||
|
return senderKeyState.removeSenderMessageKey(iteration);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Received message with old counter: ${senderChainKey.getIteration()}, ${iteration}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderChainKey.getIteration() - iteration > 2000) {
|
||||||
|
throw new Error('Over 2000 messages into the future!');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (senderChainKey.getIteration() < iteration) {
|
||||||
|
senderKeyState.addSenderMessageKey(senderChainKey.getSenderMessageKey());
|
||||||
|
senderChainKey = senderChainKey.getNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
senderKeyState.setSenderChainKey(senderChainKey.getNext());
|
||||||
|
return senderChainKey.getSenderMessageKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlainText(iv, key, ciphertext) {
|
||||||
|
try {
|
||||||
|
const plaintext = crypto.decrypt(key, ciphertext, iv);
|
||||||
|
return plaintext;
|
||||||
|
} catch (e) {
|
||||||
|
//console.log(e.stack);
|
||||||
|
throw new Error('InvalidMessageException');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCipherText(iv, key, plaintext) {
|
||||||
|
try {
|
||||||
|
iv = typeof iv === 'string' ? Buffer.from(iv, 'base64') : iv;
|
||||||
|
key = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
|
||||||
|
const crypted = crypto.encrypt(key, Buffer.from(plaintext), iv);
|
||||||
|
return crypted;
|
||||||
|
} catch (e) {
|
||||||
|
//console.log(e.stack);
|
||||||
|
throw new Error('InvalidMessageException');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GroupCipher;
|
||||||
46
WASignalGroup/group_session_builder.js
Normal file
46
WASignalGroup/group_session_builder.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//const utils = require('../../common/utils');
|
||||||
|
const SenderKeyDistributionMessage = require('./sender_key_distribution_message');
|
||||||
|
|
||||||
|
const keyhelper = require("./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) {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = GroupSessionBuilder;
|
||||||
5
WASignalGroup/index.js
Normal file
5
WASignalGroup/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports.GroupSessionBuilder = require('./group_session_builder')
|
||||||
|
module.exports.SenderKeyDistributionMessage = require('./sender_key_distribution_message')
|
||||||
|
module.exports.SenderKeyRecord = require('./sender_key_record')
|
||||||
|
module.exports.SenderKeyName = require('./sender_key_name')
|
||||||
|
module.exports.GroupCipher = require('./group_cipher')
|
||||||
21
WASignalGroup/keyhelper.js
Normal file
21
WASignalGroup/keyhelper.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const curve = require('libsignal/src/curve');
|
||||||
|
const nodeCrypto = require('crypto');
|
||||||
|
|
||||||
|
exports.generateSenderKey = function() {
|
||||||
|
return nodeCrypto.randomBytes(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.generateSenderKeyId = function() {
|
||||||
|
return nodeCrypto.randomInt(2147483647);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.generateSenderSigningKey = function(key) {
|
||||||
|
if (!key) {
|
||||||
|
key = curve.generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
public: key.pubKey,
|
||||||
|
private: key.privKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
WASignalGroup/protobufs.js
Normal file
3
WASignalGroup/protobufs.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { groupproto } = require('./GroupProtocol')
|
||||||
|
|
||||||
|
module.exports = groupproto
|
||||||
6
WASignalGroup/readme.md
Normal file
6
WASignalGroup/readme.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Signal-Group
|
||||||
|
|
||||||
|
This contains the code to decrypt/encrypt WA group messages.
|
||||||
|
Originally from [pokearaujo/libsignal-node](https://github.com/pokearaujo/libsignal-node)
|
||||||
|
|
||||||
|
The code has been moved outside the signal package as I felt it didn't belong in ths signal package, as it isn't inherently a part of signal but of WA.
|
||||||
50
WASignalGroup/sender_chain_key.js
Normal file
50
WASignalGroup/sender_chain_key.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const SenderMessageKey = require('./sender_message_key');
|
||||||
|
//const HKDF = require('./hkdf');
|
||||||
|
const crypto = require('libsignal/src/crypto');
|
||||||
|
|
||||||
|
class SenderChainKey {
|
||||||
|
MESSAGE_KEY_SEED = Buffer.from([0x01]);
|
||||||
|
|
||||||
|
CHAIN_KEY_SEED = Buffer.from([0x02]);
|
||||||
|
|
||||||
|
iteration = 0;
|
||||||
|
|
||||||
|
chainKey = Buffer.alloc(0);
|
||||||
|
|
||||||
|
constructor(iteration, chainKey) {
|
||||||
|
this.iteration = iteration;
|
||||||
|
this.chainKey = chainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIteration() {
|
||||||
|
return this.iteration;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSenderMessageKey() {
|
||||||
|
return new SenderMessageKey(
|
||||||
|
this.iteration,
|
||||||
|
this.getDerivative(this.MESSAGE_KEY_SEED, this.chainKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNext() {
|
||||||
|
return new SenderChainKey(
|
||||||
|
this.iteration + 1,
|
||||||
|
this.getDerivative(this.CHAIN_KEY_SEED, this.chainKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSeed() {
|
||||||
|
return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDerivative(seed, key) {
|
||||||
|
key = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
|
||||||
|
const hash = crypto.calculateMAC(key, seed);
|
||||||
|
//const hash = new Hash().hmac_hash(key, seed, 'sha256', '');
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SenderChainKey;
|
||||||
78
WASignalGroup/sender_key_distribution_message.js
Normal file
78
WASignalGroup/sender_key_distribution_message.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const CiphertextMessage = require('./ciphertext_message');
|
||||||
|
const protobufs = require('./protobufs');
|
||||||
|
|
||||||
|
class SenderKeyDistributionMessage extends CiphertextMessage {
|
||||||
|
constructor(
|
||||||
|
id = null,
|
||||||
|
iteration = null,
|
||||||
|
chainKey = null,
|
||||||
|
signatureKey = null,
|
||||||
|
serialized = null
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
if (serialized) {
|
||||||
|
try {
|
||||||
|
const version = serialized[0];
|
||||||
|
const message = serialized.slice(1);
|
||||||
|
|
||||||
|
const distributionMessage = protobufs.SenderKeyDistributionMessage.decode(
|
||||||
|
message
|
||||||
|
).toJSON();
|
||||||
|
this.serialized = serialized;
|
||||||
|
this.id = distributionMessage.id;
|
||||||
|
this.iteration = distributionMessage.iteration;
|
||||||
|
this.chainKey = distributionMessage.chainKey;
|
||||||
|
this.signatureKey = distributionMessage.signingKey;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const version = this.intsToByteHighAndLow(this.CURRENT_VERSION, this.CURRENT_VERSION);
|
||||||
|
this.id = id;
|
||||||
|
this.iteration = iteration;
|
||||||
|
this.chainKey = chainKey;
|
||||||
|
this.signatureKey = signatureKey;
|
||||||
|
const message = protobufs.SenderKeyDistributionMessage.encode(
|
||||||
|
protobufs.SenderKeyDistributionMessage.create({
|
||||||
|
id,
|
||||||
|
iteration,
|
||||||
|
chainKey,
|
||||||
|
signingKey: this.signatureKey,
|
||||||
|
})
|
||||||
|
).finish();
|
||||||
|
this.serialized = Buffer.concat([Buffer.from([version]), message]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
intsToByteHighAndLow(highValue, lowValue) {
|
||||||
|
return (((highValue << 4) | lowValue) & 0xff) % 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return this.serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
getType() {
|
||||||
|
return this.SENDERKEY_DISTRIBUTION_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIteration() {
|
||||||
|
return this.iteration;
|
||||||
|
}
|
||||||
|
|
||||||
|
getChainKey() {
|
||||||
|
return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignatureKey() {
|
||||||
|
return typeof this.signatureKey === 'string'
|
||||||
|
? Buffer.from(this.signatureKey, 'base64')
|
||||||
|
: this.signatureKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SenderKeyDistributionMessage;
|
||||||
92
WASignalGroup/sender_key_message.js
Normal file
92
WASignalGroup/sender_key_message.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const CiphertextMessage = require('./ciphertext_message');
|
||||||
|
const curve = require('libsignal/src/curve');
|
||||||
|
const protobufs = require('./protobufs');
|
||||||
|
|
||||||
|
class SenderKeyMessage extends CiphertextMessage {
|
||||||
|
SIGNATURE_LENGTH = 64;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
keyId = null,
|
||||||
|
iteration = null,
|
||||||
|
ciphertext = null,
|
||||||
|
signatureKey = null,
|
||||||
|
serialized = null
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
if (serialized) {
|
||||||
|
const version = serialized[0];
|
||||||
|
const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH);
|
||||||
|
const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH);
|
||||||
|
const senderKeyMessage = protobufs.SenderKeyMessage.decode(message).toJSON();
|
||||||
|
senderKeyMessage.ciphertext = Buffer.from(senderKeyMessage.ciphertext, 'base64');
|
||||||
|
|
||||||
|
this.serialized = serialized;
|
||||||
|
this.messageVersion = (version & 0xff) >> 4;
|
||||||
|
|
||||||
|
this.keyId = senderKeyMessage.id;
|
||||||
|
this.iteration = senderKeyMessage.iteration;
|
||||||
|
this.ciphertext = senderKeyMessage.ciphertext;
|
||||||
|
this.signature = signature;
|
||||||
|
} else {
|
||||||
|
const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256;
|
||||||
|
ciphertext = Buffer.from(ciphertext); // .toString('base64');
|
||||||
|
const message = protobufs.SenderKeyMessage.encode(
|
||||||
|
protobufs.SenderKeyMessage.create({
|
||||||
|
id: keyId,
|
||||||
|
iteration,
|
||||||
|
ciphertext,
|
||||||
|
})
|
||||||
|
).finish();
|
||||||
|
|
||||||
|
const signature = this.getSignature(
|
||||||
|
signatureKey,
|
||||||
|
Buffer.concat([Buffer.from([version]), message])
|
||||||
|
);
|
||||||
|
this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)]);
|
||||||
|
this.messageVersion = this.CURRENT_VERSION;
|
||||||
|
this.keyId = keyId;
|
||||||
|
this.iteration = iteration;
|
||||||
|
this.ciphertext = ciphertext;
|
||||||
|
this.signature = signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyId() {
|
||||||
|
return this.keyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIteration() {
|
||||||
|
return this.iteration;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCipherText() {
|
||||||
|
return this.ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifySignature(signatureKey) {
|
||||||
|
const part1 = this.serialized.slice(0, this.serialized.length - this.SIGNATURE_LENGTH + 1);
|
||||||
|
const part2 = this.serialized.slice(-1 * this.SIGNATURE_LENGTH);
|
||||||
|
const res = curve.verifySignature(signatureKey, part1, part2);
|
||||||
|
if (!res) throw new Error('Invalid signature!');
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignature(signatureKey, serialized) {
|
||||||
|
const signature = Buffer.from(
|
||||||
|
curve.calculateSignature(
|
||||||
|
signatureKey,
|
||||||
|
serialized
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return this.serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
getType() {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SenderKeyMessage;
|
||||||
70
WASignalGroup/sender_key_name.js
Normal file
70
WASignalGroup/sender_key_name.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
function isNull(str) {
|
||||||
|
return str === null || str.value === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* java String hashCode 的实现
|
||||||
|
* @param strKey
|
||||||
|
* @return intValue
|
||||||
|
*/
|
||||||
|
function intValue(num) {
|
||||||
|
const MAX_VALUE = 0x7fffffff;
|
||||||
|
const MIN_VALUE = -0x80000000;
|
||||||
|
if (num > MAX_VALUE || num < MIN_VALUE) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
return (num &= 0xffffffff);
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashCode(strKey) {
|
||||||
|
let hash = 0;
|
||||||
|
if (!isNull(strKey)) {
|
||||||
|
for (let i = 0; i < strKey.length; i++) {
|
||||||
|
hash = hash * 31 + strKey.charCodeAt(i);
|
||||||
|
hash = intValue(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将js页面的number类型转换为java的int类型
|
||||||
|
* @param num
|
||||||
|
* @return intValue
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SenderKeyName {
|
||||||
|
constructor(groupId, sender) {
|
||||||
|
this.groupId = groupId;
|
||||||
|
this.sender = sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGroupId() {
|
||||||
|
return this.groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSender() {
|
||||||
|
return this.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return `${this.groupId}::${this.sender.id}::${this.sender.deviceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other) {
|
||||||
|
if (other === null) return false;
|
||||||
|
if (!(other instanceof SenderKeyName)) return false;
|
||||||
|
return this.groupId === other.groupId && this.sender.toString() === other.sender.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
hashCode() {
|
||||||
|
return hashCode(this.groupId) ^ hashCode(this.sender.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SenderKeyName;
|
||||||
54
WASignalGroup/sender_key_record.js
Normal file
54
WASignalGroup/sender_key_record.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const SenderKeyState = require('./sender_key_state');
|
||||||
|
|
||||||
|
class SenderKeyRecord {
|
||||||
|
MAX_STATES = 5;
|
||||||
|
|
||||||
|
constructor(serialized) {
|
||||||
|
this.senderKeyStates = [];
|
||||||
|
|
||||||
|
if (serialized) {
|
||||||
|
const list = serialized;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const structure = list[i];
|
||||||
|
this.senderKeyStates.push(
|
||||||
|
new SenderKeyState(null, null, null, null, null, null, structure)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isEmpty() {
|
||||||
|
return this.senderKeyStates.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSenderKeyState(keyId) {
|
||||||
|
if (!keyId && this.senderKeyStates.length) return this.senderKeyStates[0];
|
||||||
|
for (let i = 0; i < this.senderKeyStates.length; i++) {
|
||||||
|
const state = this.senderKeyStates[i];
|
||||||
|
if (state.getKeyId() === keyId) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No keys for: ${keyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSenderKeyState(id, iteration, chainKey, signatureKey) {
|
||||||
|
this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, null, signatureKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSenderKeyState(id, iteration, chainKey, keyPair) {
|
||||||
|
this.senderKeyStates.length = 0;
|
||||||
|
this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, keyPair));
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
const recordStructure = [];
|
||||||
|
for (let i = 0; i < this.senderKeyStates.length; i++) {
|
||||||
|
const senderKeyState = this.senderKeyStates[i];
|
||||||
|
recordStructure.push(senderKeyState.getStructure());
|
||||||
|
}
|
||||||
|
return recordStructure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SenderKeyRecord;
|
||||||
129
WASignalGroup/sender_key_state.js
Normal file
129
WASignalGroup/sender_key_state.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const SenderChainKey = require('./sender_chain_key');
|
||||||
|
const SenderMessageKey = require('./sender_message_key');
|
||||||
|
|
||||||
|
const protobufs = require('./protobufs');
|
||||||
|
|
||||||
|
class SenderKeyState {
|
||||||
|
MAX_MESSAGE_KEYS = 2000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
id = null,
|
||||||
|
iteration = null,
|
||||||
|
chainKey = null,
|
||||||
|
signatureKeyPair = null,
|
||||||
|
signatureKeyPublic = null,
|
||||||
|
signatureKeyPrivate = null,
|
||||||
|
senderKeyStateStructure = null
|
||||||
|
) {
|
||||||
|
if (senderKeyStateStructure) {
|
||||||
|
this.senderKeyStateStructure = senderKeyStateStructure;
|
||||||
|
} else {
|
||||||
|
if (signatureKeyPair) {
|
||||||
|
signatureKeyPublic = signatureKeyPair.public;
|
||||||
|
signatureKeyPrivate = signatureKeyPair.private;
|
||||||
|
}
|
||||||
|
|
||||||
|
chainKey = typeof chainKey === 'string' ? Buffer.from(chainKey, 'base64') : chainKey;
|
||||||
|
this.senderKeyStateStructure = protobufs.SenderKeyStateStructure.create();
|
||||||
|
const senderChainKeyStructure = protobufs.SenderChainKey.create();
|
||||||
|
senderChainKeyStructure.iteration = iteration;
|
||||||
|
senderChainKeyStructure.seed = chainKey;
|
||||||
|
this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure;
|
||||||
|
|
||||||
|
const signingKeyStructure = protobufs.SenderSigningKey.create();
|
||||||
|
signingKeyStructure.public =
|
||||||
|
typeof signatureKeyPublic === 'string' ?
|
||||||
|
Buffer.from(signatureKeyPublic, 'base64') :
|
||||||
|
signatureKeyPublic;
|
||||||
|
if (signatureKeyPrivate) {
|
||||||
|
signingKeyStructure.private =
|
||||||
|
typeof signatureKeyPrivate === 'string' ?
|
||||||
|
Buffer.from(signatureKeyPrivate, 'base64') :
|
||||||
|
signatureKeyPrivate;
|
||||||
|
}
|
||||||
|
this.senderKeyStateStructure.senderKeyId = id;
|
||||||
|
this.senderChainKey = senderChainKeyStructure;
|
||||||
|
this.senderKeyStateStructure.senderSigningKey = signingKeyStructure;
|
||||||
|
}
|
||||||
|
this.senderKeyStateStructure.senderMessageKeys =
|
||||||
|
this.senderKeyStateStructure.senderMessageKeys || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
SenderKeyState(senderKeyStateStructure) {
|
||||||
|
this.senderKeyStateStructure = senderKeyStateStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyId() {
|
||||||
|
return this.senderKeyStateStructure.senderKeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSenderChainKey() {
|
||||||
|
return new SenderChainKey(
|
||||||
|
this.senderKeyStateStructure.senderChainKey.iteration,
|
||||||
|
this.senderKeyStateStructure.senderChainKey.seed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSenderChainKey(chainKey) {
|
||||||
|
const senderChainKeyStructure = protobufs.SenderChainKey.create({
|
||||||
|
iteration: chainKey.getIteration(),
|
||||||
|
seed: chainKey.getSeed(),
|
||||||
|
});
|
||||||
|
this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSigningKeyPublic() {
|
||||||
|
return typeof this.senderKeyStateStructure.senderSigningKey.public === 'string' ?
|
||||||
|
Buffer.from(this.senderKeyStateStructure.senderSigningKey.public, 'base64') :
|
||||||
|
this.senderKeyStateStructure.senderSigningKey.public;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSigningKeyPrivate() {
|
||||||
|
return typeof this.senderKeyStateStructure.senderSigningKey.private === 'string' ?
|
||||||
|
Buffer.from(this.senderKeyStateStructure.senderSigningKey.private, 'base64') :
|
||||||
|
this.senderKeyStateStructure.senderSigningKey.private;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSenderMessageKey(iteration) {
|
||||||
|
const list = this.senderKeyStateStructure.senderMessageKeys;
|
||||||
|
for (let o = 0; o < list.length; o++) {
|
||||||
|
const senderMessageKey = list[o];
|
||||||
|
if (senderMessageKey.iteration === iteration) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSenderMessageKey(senderMessageKey) {
|
||||||
|
const senderMessageKeyStructure = protobufs.SenderKeyStateStructure.create({
|
||||||
|
iteration: senderMessageKey.getIteration(),
|
||||||
|
seed: senderMessageKey.getSeed(),
|
||||||
|
});
|
||||||
|
this.senderKeyStateStructure.senderMessageKeys.push(senderMessageKeyStructure);
|
||||||
|
|
||||||
|
if (this.senderKeyStateStructure.senderMessageKeys.length > this.MAX_MESSAGE_KEYS) {
|
||||||
|
this.senderKeyStateStructure.senderMessageKeys.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSenderMessageKey(iteration) {
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
this.senderKeyStateStructure.senderMessageKeys = this.senderKeyStateStructure.senderMessageKeys.filter(
|
||||||
|
senderMessageKey => {
|
||||||
|
if (senderMessageKey.iteration === iteration) result = senderMessageKey;
|
||||||
|
return senderMessageKey.iteration !== iteration;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
return new SenderMessageKey(result.iteration, result.seed);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStructure() {
|
||||||
|
return this.senderKeyStateStructure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SenderKeyState;
|
||||||
39
WASignalGroup/sender_message_key.js
Normal file
39
WASignalGroup/sender_message_key.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const { deriveSecrets } = require('libsignal/src/crypto');
|
||||||
|
class SenderMessageKey {
|
||||||
|
iteration = 0;
|
||||||
|
|
||||||
|
iv = Buffer.alloc(0);
|
||||||
|
|
||||||
|
cipherKey = Buffer.alloc(0);
|
||||||
|
|
||||||
|
seed = Buffer.alloc(0);
|
||||||
|
|
||||||
|
constructor(iteration, seed) {
|
||||||
|
const derivative = deriveSecrets(seed, Buffer.alloc(32), Buffer.from('WhisperGroup'));
|
||||||
|
const keys = new Uint8Array(32);
|
||||||
|
keys.set(new Uint8Array(derivative[0].slice(16)));
|
||||||
|
keys.set(new Uint8Array(derivative[1].slice(0, 16)), 16);
|
||||||
|
this.iv = Buffer.from(derivative[0].slice(0, 16));
|
||||||
|
this.cipherKey = Buffer.from(keys.buffer);
|
||||||
|
|
||||||
|
this.iteration = iteration;
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIteration() {
|
||||||
|
return this.iteration;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIv() {
|
||||||
|
return this.iv;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCipherKey() {
|
||||||
|
return this.cipherKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSeed() {
|
||||||
|
return this.seed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = SenderMessageKey;
|
||||||
11
jest.config.js
Normal file
11
jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
"roots": [
|
||||||
|
"<rootDir>/src"
|
||||||
|
],
|
||||||
|
"testMatch": [
|
||||||
|
"**/Tests/test.*.+(ts|tsx|js)",
|
||||||
|
],
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(ts|tsx)$": "ts-jest"
|
||||||
|
},
|
||||||
|
}
|
||||||
62
package.json
62
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@adiwajshing/baileys",
|
"name": "@adiwajshing/baileys",
|
||||||
"version": "3.5.3",
|
"version": "4.0.0",
|
||||||
"description": "WhatsApp Web API",
|
"description": "WhatsApp API",
|
||||||
"homepage": "https://github.com/adiwajshing/Baileys",
|
"homepage": "https://github.com/adiwajshing/Baileys",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
@@ -13,18 +13,21 @@
|
|||||||
"whatsapp",
|
"whatsapp",
|
||||||
"whatsapp-chat",
|
"whatsapp-chat",
|
||||||
"whatsapp-group",
|
"whatsapp-group",
|
||||||
"automation"
|
"automation",
|
||||||
|
"multi-device"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "mocha --timeout 240000 -r ts-node/register src/Tests/Tests.*.ts",
|
"test": "jest",
|
||||||
"prepack": "tsc",
|
"prepare": "tsc",
|
||||||
"lint": "eslint '*/*.ts' --quiet --fix",
|
|
||||||
"build:all": "tsc && typedoc",
|
"build:all": "tsc && typedoc",
|
||||||
"build:docs": "typedoc",
|
"build:docs": "typedoc",
|
||||||
"build:tsc": "tsc",
|
"build:tsc": "tsc",
|
||||||
"example": "node --inspect -r ts-node/register Example/example.ts",
|
"example": "node --inspect -r ts-node/register Example/example.ts",
|
||||||
"gen-protobuf": "bash src/Binary/GenerateStatics.sh",
|
"example:legacy": "node --inspect -r ts-node/register Example/example-legacy.ts",
|
||||||
"browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts"
|
"gen-protobuf": "bash src/BinaryNode/GenerateStatics.sh",
|
||||||
|
"browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts",
|
||||||
|
"lint": "eslint ./src --ext .js,.ts,.jsx,.tsx",
|
||||||
|
"lint:fix": "eslint ./src --fix --ext .js,.ts,.jsx,.tsx"
|
||||||
},
|
},
|
||||||
"author": "Adhiraj Singh",
|
"author": "Adhiraj Singh",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -32,33 +35,44 @@
|
|||||||
"url": "git@github.com:adiwajshing/baileys.git"
|
"url": "git@github.com:adiwajshing/baileys.git"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adiwajshing/keyed-db": "^0.2.2",
|
"@hapi/boom": "^9.1.3",
|
||||||
|
"axios": "^0.24.0",
|
||||||
"curve25519-js": "^0.0.4",
|
"curve25519-js": "^0.0.4",
|
||||||
"futoin-hkdf": "^1.3.2",
|
"libsignal": "git+https://github.com/adiwajshing/libsignal-node",
|
||||||
"got": "^11.8.1",
|
|
||||||
"https-proxy-agent": "^5.0.0",
|
|
||||||
"jimp": "^0.16.1",
|
|
||||||
"music-metadata": "^7.4.1",
|
"music-metadata": "^7.4.1",
|
||||||
"pino": "^6.7.0",
|
"node-cache": "^5.1.2",
|
||||||
"pino-pretty": "^4.3.0",
|
"pino": "^7.0.0",
|
||||||
"protobufjs": "^6.10.1",
|
"protobufjs": "^6.10.1",
|
||||||
|
"ws": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"jimp": "^0.16.1",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"ws": "^7.3.1"
|
"sharp": "^0.29.3",
|
||||||
|
"@adiwajshing/keyed-db": "^0.2.4"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib/*",
|
"lib/*",
|
||||||
"WAMessage/*"
|
"WAProto/*",
|
||||||
|
"WASignalGroup/*.js",
|
||||||
|
"WABinary/*.js"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@adiwajshing/eslint-config": "git+https://github.com/adiwajshing/eslint-config",
|
||||||
|
"@adiwajshing/keyed-db": "^0.2.4",
|
||||||
"@types/got": "^9.6.11",
|
"@types/got": "^9.6.11",
|
||||||
"@types/mocha": "^7.0.2",
|
"@types/jest": "^26.0.24",
|
||||||
"@types/node": "^14.6.2",
|
"@types/node": "^14.6.2",
|
||||||
"@types/pino": "^6.3.2",
|
"@types/pino": "^7.0.0",
|
||||||
"@types/ws": "^7.2.6",
|
"@types/sharp": "^0.29.4",
|
||||||
"assert": "^2.0.0",
|
"@types/ws": "^8.0.0",
|
||||||
"dotenv": "^8.2.0",
|
"eslint": "^7.0.0",
|
||||||
"mocha": "^8.1.3",
|
"jest": "^27.0.6",
|
||||||
"ts-node-dev": "^1.0.0",
|
"jimp": "^0.16.1",
|
||||||
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
"sharp": "^0.29.3",
|
||||||
|
"ts-jest": "^27.0.3",
|
||||||
|
"ts-node": "^10.0.0",
|
||||||
"typedoc": "^0.20.0-beta.27",
|
"typedoc": "^0.20.0-beta.27",
|
||||||
"typescript": "^4.0.0"
|
"typescript": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
import {proto} from '../../WAMessage/WAMessage'
|
|
||||||
|
|
||||||
export namespace WA {
|
|
||||||
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',
|
|
||||||
]
|
|
||||||
export const Message = proto.WebMessageInfo
|
|
||||||
export type NodeAttributes = { [key: string]: string } | string | null
|
|
||||||
export type NodeData = Array<Node> | any | null
|
|
||||||
export type Node = [string, NodeAttributes, NodeData]
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { WA } from './Constants'
|
|
||||||
|
|
||||||
export default class Decoder {
|
|
||||||
buffer: Buffer = null
|
|
||||||
index = 0
|
|
||||||
|
|
||||||
checkEOS(length: number) {
|
|
||||||
if (this.index + length > this.buffer.length) {
|
|
||||||
throw new Error('end of stream')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next() {
|
|
||||||
const value = this.buffer[this.index]
|
|
||||||
this.index += 1
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
readByte() {
|
|
||||||
this.checkEOS(1)
|
|
||||||
return this.next()
|
|
||||||
}
|
|
||||||
readStringFromChars(length: number) {
|
|
||||||
this.checkEOS(length)
|
|
||||||
const value = this.buffer.slice(this.index, this.index + length)
|
|
||||||
|
|
||||||
this.index += length
|
|
||||||
return value.toString ('utf-8')
|
|
||||||
}
|
|
||||||
readBytes(n: number): Buffer {
|
|
||||||
this.checkEOS(n)
|
|
||||||
const value = this.buffer.slice(this.index, this.index + n)
|
|
||||||
this.index += n
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
readInt(n: number, littleEndian = false) {
|
|
||||||
this.checkEOS(n)
|
|
||||||
let val = 0
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const shift = littleEndian ? i : n - 1 - i
|
|
||||||
val |= this.next() << (shift * 8)
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
readInt20() {
|
|
||||||
this.checkEOS(3)
|
|
||||||
return ((this.next() & 15) << 16) + (this.next() << 8) + this.next()
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unpackByte(tag: number, value: number) {
|
|
||||||
if (tag === WA.Tags.NIBBLE_8) {
|
|
||||||
return this.unpackNibble(value)
|
|
||||||
} else if (tag === WA.Tags.HEX_8) {
|
|
||||||
return this.unpackHex(value)
|
|
||||||
} else {
|
|
||||||
throw new Error('unknown tag: ' + tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readPacked8(tag: number) {
|
|
||||||
const startByte = this.readByte()
|
|
||||||
let value = ''
|
|
||||||
|
|
||||||
for (let i = 0; i < (startByte & 127); i++) {
|
|
||||||
const curByte = this.readByte()
|
|
||||||
value += String.fromCharCode(this.unpackByte(tag, (curByte & 0xf0) >> 4))
|
|
||||||
value += String.fromCharCode(this.unpackByte(tag, curByte & 0x0f))
|
|
||||||
}
|
|
||||||
if (startByte >> 7 !== 0) {
|
|
||||||
value = value.slice(0, -1)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
readRangedVarInt(min, max, description = 'unknown') {
|
|
||||||
// value =
|
|
||||||
throw new Error('WTF; should not be called')
|
|
||||||
}
|
|
||||||
isListTag(tag: number) {
|
|
||||||
return tag === WA.Tags.LIST_EMPTY || tag === WA.Tags.LIST_8 || tag === WA.Tags.LIST_16
|
|
||||||
}
|
|
||||||
readListSize(tag: number) {
|
|
||||||
switch (tag) {
|
|
||||||
case WA.Tags.LIST_EMPTY:
|
|
||||||
return 0
|
|
||||||
case WA.Tags.LIST_8:
|
|
||||||
return this.readByte()
|
|
||||||
case WA.Tags.LIST_16:
|
|
||||||
return this.readInt(2)
|
|
||||||
default:
|
|
||||||
throw new Error('invalid tag for list size: ' + tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readString(tag: number): string {
|
|
||||||
if (tag >= 3 && tag <= 235) {
|
|
||||||
const token = this.getToken(tag)
|
|
||||||
return token// === 's.whatsapp.net' ? 'c.us' : token
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (tag) {
|
|
||||||
case WA.Tags.DICTIONARY_0:
|
|
||||||
case WA.Tags.DICTIONARY_1:
|
|
||||||
case WA.Tags.DICTIONARY_2:
|
|
||||||
case WA.Tags.DICTIONARY_3:
|
|
||||||
return this.getTokenDouble(tag - WA.Tags.DICTIONARY_0, this.readByte())
|
|
||||||
case WA.Tags.LIST_EMPTY:
|
|
||||||
return null
|
|
||||||
case WA.Tags.BINARY_8:
|
|
||||||
return this.readStringFromChars(this.readByte())
|
|
||||||
case WA.Tags.BINARY_20:
|
|
||||||
return this.readStringFromChars(this.readInt20())
|
|
||||||
case WA.Tags.BINARY_32:
|
|
||||||
return this.readStringFromChars(this.readInt(4))
|
|
||||||
case WA.Tags.JID_PAIR:
|
|
||||||
const i = this.readString(this.readByte())
|
|
||||||
const j = this.readString(this.readByte())
|
|
||||||
if (typeof i === 'string' && j) {
|
|
||||||
return i + '@' + j
|
|
||||||
}
|
|
||||||
throw new Error('invalid jid pair: ' + i + ', ' + j)
|
|
||||||
case WA.Tags.HEX_8:
|
|
||||||
case WA.Tags.NIBBLE_8:
|
|
||||||
return this.readPacked8(tag)
|
|
||||||
default:
|
|
||||||
throw new Error('invalid string with tag: ' + tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readAttributes(n: number) {
|
|
||||||
if (n !== 0) {
|
|
||||||
const attributes: WA.NodeAttributes = {}
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const key = this.readString(this.readByte())
|
|
||||||
const b = this.readByte()
|
|
||||||
|
|
||||||
attributes[key] = this.readString(b)
|
|
||||||
}
|
|
||||||
return attributes
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
readList(tag: number) {
|
|
||||||
const arr = [...new Array(this.readListSize(tag))]
|
|
||||||
return arr.map(() => this.readNode())
|
|
||||||
}
|
|
||||||
getToken(index: number) {
|
|
||||||
if (index < 3 || index >= WA.SingleByteTokens.length) {
|
|
||||||
throw new Error('invalid token index: ' + index)
|
|
||||||
}
|
|
||||||
return WA.SingleByteTokens[index]
|
|
||||||
}
|
|
||||||
getTokenDouble(index1, index2): string {
|
|
||||||
const n = 256 * index1 + index2
|
|
||||||
if (n < 0 || n > WA.DoubleByteTokens.length) {
|
|
||||||
throw new Error('invalid double token index: ' + n)
|
|
||||||
}
|
|
||||||
return WA.DoubleByteTokens[n]
|
|
||||||
}
|
|
||||||
readNode(): WA.Node {
|
|
||||||
const listSize = this.readListSize(this.readByte())
|
|
||||||
const descrTag = this.readByte()
|
|
||||||
if (descrTag === WA.Tags.STREAM_END) {
|
|
||||||
throw new Error('unexpected stream end')
|
|
||||||
}
|
|
||||||
|
|
||||||
const descr = this.readString(descrTag)
|
|
||||||
if (listSize === 0 || !descr) {
|
|
||||||
throw new Error('invalid node')
|
|
||||||
}
|
|
||||||
|
|
||||||
const attrs = this.readAttributes((listSize - 1) >> 1)
|
|
||||||
let content: WA.NodeData = null
|
|
||||||
|
|
||||||
if (listSize % 2 === 0) {
|
|
||||||
const tag = this.readByte()
|
|
||||||
if (this.isListTag(tag)) {
|
|
||||||
content = this.readList(tag)
|
|
||||||
} else {
|
|
||||||
let decoded: Buffer | string
|
|
||||||
switch (tag) {
|
|
||||||
case WA.Tags.BINARY_8:
|
|
||||||
decoded = this.readBytes(this.readByte())
|
|
||||||
break
|
|
||||||
case WA.Tags.BINARY_20:
|
|
||||||
decoded = this.readBytes(this.readInt20())
|
|
||||||
break
|
|
||||||
case WA.Tags.BINARY_32:
|
|
||||||
decoded = this.readBytes(this.readInt(4))
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
decoded = this.readString(tag)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (descr === 'message' && Buffer.isBuffer(decoded)) {
|
|
||||||
content = WA.Message.decode(decoded)
|
|
||||||
} else {
|
|
||||||
content = decoded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [descr, attrs, content]
|
|
||||||
}
|
|
||||||
|
|
||||||
read(buffer: Buffer) {
|
|
||||||
this.buffer = buffer
|
|
||||||
this.index = 0
|
|
||||||
return this.readNode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { Message } from 'protobufjs'
|
|
||||||
import { WA } from './Constants'
|
|
||||||
|
|
||||||
export default class Encoder {
|
|
||||||
data: number[] = []
|
|
||||||
|
|
||||||
pushByte(value: number) {
|
|
||||||
this.data.push(value & 0xff)
|
|
||||||
}
|
|
||||||
pushInt(value: number, n: number, littleEndian=false) {
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const curShift = littleEndian ? i : n - 1 - i
|
|
||||||
this.data.push((value >> (curShift * 8)) & 0xff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pushInt20(value: number) {
|
|
||||||
this.pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
|
|
||||||
}
|
|
||||||
pushBytes(bytes: Uint8Array | Buffer | number[]) {
|
|
||||||
bytes.forEach (b => this.data.push(b))
|
|
||||||
}
|
|
||||||
writeByteLength(length: number) {
|
|
||||||
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
|
|
||||||
|
|
||||||
if (length >= 1 << 20) {
|
|
||||||
this.pushByte(WA.Tags.BINARY_32)
|
|
||||||
this.pushInt(length, 4) // 32 bit integer
|
|
||||||
} else if (length >= 256) {
|
|
||||||
this.pushByte(WA.Tags.BINARY_20)
|
|
||||||
this.pushInt20(length)
|
|
||||||
} else {
|
|
||||||
this.pushByte(WA.Tags.BINARY_8)
|
|
||||||
this.pushByte(length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeStringRaw(string: string) {
|
|
||||||
const bytes = Buffer.from (string, 'utf-8')
|
|
||||||
this.writeByteLength(bytes.length)
|
|
||||||
this.pushBytes(bytes)
|
|
||||||
}
|
|
||||||
writeJid(left: string, right: string) {
|
|
||||||
this.pushByte(WA.Tags.JID_PAIR)
|
|
||||||
left && left.length > 0 ? this.writeString(left) : this.writeToken(WA.Tags.LIST_EMPTY)
|
|
||||||
this.writeString(right)
|
|
||||||
}
|
|
||||||
writeToken(token: number) {
|
|
||||||
if (token < 245) {
|
|
||||||
this.pushByte(token)
|
|
||||||
} else if (token <= 500) {
|
|
||||||
throw new Error('invalid token')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeString(token: string, i: boolean = null) {
|
|
||||||
if (token === 'c.us') token = 's.whatsapp.net'
|
|
||||||
|
|
||||||
const tokenIndex = WA.SingleByteTokens.indexOf(token)
|
|
||||||
if (!i && token === 's.whatsapp.net') {
|
|
||||||
this.writeToken(tokenIndex)
|
|
||||||
} else if (tokenIndex >= 0) {
|
|
||||||
if (tokenIndex < WA.Tags.SINGLE_BYTE_MAX) {
|
|
||||||
this.writeToken(tokenIndex)
|
|
||||||
} else {
|
|
||||||
const overflow = tokenIndex - WA.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)
|
|
||||||
}
|
|
||||||
this.writeToken(WA.Tags.DICTIONARY_0 + dictionaryIndex)
|
|
||||||
this.writeToken(overflow % 256)
|
|
||||||
}
|
|
||||||
} else if (token) {
|
|
||||||
const jidSepIndex = token.indexOf('@')
|
|
||||||
if (jidSepIndex <= 0) {
|
|
||||||
this.writeStringRaw(token)
|
|
||||||
} else {
|
|
||||||
this.writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeAttributes(attrs: Record<string, string> | string, keys: string[]) {
|
|
||||||
if (!attrs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keys.forEach((key) => {
|
|
||||||
this.writeString(key)
|
|
||||||
this.writeString(attrs[key])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeListStart(listSize: number) {
|
|
||||||
if (listSize === 0) {
|
|
||||||
this.pushByte(WA.Tags.LIST_EMPTY)
|
|
||||||
} else if (listSize < 256) {
|
|
||||||
this.pushBytes([WA.Tags.LIST_8, listSize])
|
|
||||||
} else {
|
|
||||||
this.pushBytes([WA.Tags.LIST_16, listSize])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeChildren(children: string | Array<WA.Node> | Buffer | Object) {
|
|
||||||
if (!children) return
|
|
||||||
|
|
||||||
if (typeof children === 'string') {
|
|
||||||
this.writeString(children, true)
|
|
||||||
} else if (Buffer.isBuffer(children)) {
|
|
||||||
this.writeByteLength (children.length)
|
|
||||||
this.pushBytes(children)
|
|
||||||
} else if (Array.isArray(children)) {
|
|
||||||
this.writeListStart(children.length)
|
|
||||||
children.forEach(c => c && this.writeNode(c))
|
|
||||||
} else if (typeof children === 'object') {
|
|
||||||
const buffer = WA.Message.encode(children as any).finish()
|
|
||||||
this.writeByteLength(buffer.length)
|
|
||||||
this.pushBytes(buffer)
|
|
||||||
} else {
|
|
||||||
throw new Error('invalid children: ' + children + ' (' + typeof children + ')')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
getValidKeys(obj: Object) {
|
|
||||||
return obj ? Object.keys(obj).filter((key) => obj[key] !== null && obj[key] !== undefined) : []
|
|
||||||
}
|
|
||||||
writeNode(node: WA.Node) {
|
|
||||||
if (!node) {
|
|
||||||
return
|
|
||||||
} else if (node.length !== 3) {
|
|
||||||
throw new Error('invalid node given: ' + node)
|
|
||||||
}
|
|
||||||
const validAttributes = this.getValidKeys(node[1])
|
|
||||||
|
|
||||||
this.writeListStart(2 * validAttributes.length + 1 + (node[2] ? 1 : 0))
|
|
||||||
this.writeString(node[0])
|
|
||||||
this.writeAttributes(node[1], validAttributes)
|
|
||||||
this.writeChildren(node[2])
|
|
||||||
}
|
|
||||||
write(data) {
|
|
||||||
this.data = []
|
|
||||||
this.writeNode(data)
|
|
||||||
|
|
||||||
return Buffer.from(this.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
yarn pbjs -t static-module -w commonjs -o ./WAMessage/WAMessage.js ./src/Binary/WAMessage.proto;
|
|
||||||
yarn pbts -o ./WAMessage/WAMessage.d.ts ./WAMessage/WAMessage.js;
|
|
||||||
|
|
||||||
#protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt=env=node,useOptionals=true,forceLong=long --ts_proto_out=. ./src/Binary/WAMessage.proto;
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import fs from 'fs'
|
|
||||||
import { decryptWA } from './WAConnection'
|
|
||||||
import Decoder from './Binary/Decoder'
|
|
||||||
|
|
||||||
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) => decryptWA (buffer, macKey, encKey, new Decoder(), 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)
|
|
||||||
59
src/Defaults/index.ts
Normal file
59
src/Defaults/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import P from 'pino'
|
||||||
|
import type { CommonSocketConfig, LegacySocketConfig, MediaType, SocketConfig } from '../Types'
|
||||||
|
import { Browsers } from '../Utils'
|
||||||
|
|
||||||
|
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
||||||
|
|
||||||
|
export const DEFAULT_ORIGIN = 'https://web.whatsapp.com'
|
||||||
|
export const DEF_CALLBACK_PREFIX = 'CB:'
|
||||||
|
export const DEF_TAG_PREFIX = 'TAG:'
|
||||||
|
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
|
||||||
|
|
||||||
|
const BASE_CONNECTION_CONFIG: CommonSocketConfig<any> = {
|
||||||
|
version: [2, 2149, 4],
|
||||||
|
browser: Browsers.baileys('Chrome'),
|
||||||
|
|
||||||
|
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||||
|
connectTimeoutMs: 20_000,
|
||||||
|
keepAliveIntervalMs: 25_000,
|
||||||
|
logger: P().child({ class: 'baileys' }),
|
||||||
|
printQRInTerminal: false,
|
||||||
|
emitOwnEvents: true,
|
||||||
|
defaultQueryTimeoutMs: 60_000,
|
||||||
|
customUploadHosts: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||||
|
...BASE_CONNECTION_CONFIG,
|
||||||
|
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||||
|
getMessage: async() => undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LEGACY_CONNECTION_CONFIG: LegacySocketConfig = {
|
||||||
|
...BASE_CONNECTION_CONFIG,
|
||||||
|
waWebSocketUrl: 'wss://web.whatsapp.com/ws',
|
||||||
|
phoneResponseTimeMs: 20_000,
|
||||||
|
expectResponseTimeout: 60_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
'md-app-state': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
|
||||||
|
|
||||||
|
export const KEY_BUNDLE_TYPE = ''
|
||||||
253
src/LegacySocket/auth.ts
Normal file
253
src/LegacySocket/auth.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import EventEmitter from 'events'
|
||||||
|
import { ConnectionState, CurveKeyPair, DisconnectReason, LegacyBaileysEventEmitter, LegacySocketConfig, WAInitResponse } from '../Types'
|
||||||
|
import { bindWaitForConnectionUpdate, computeChallengeResponse, Curve, newLegacyAuthCreds, printQRIfNecessaryListener, validateNewConnection } from '../Utils'
|
||||||
|
import { makeSocket } from './socket'
|
||||||
|
|
||||||
|
const makeAuthSocket = (config: LegacySocketConfig) => {
|
||||||
|
const {
|
||||||
|
logger,
|
||||||
|
version,
|
||||||
|
browser,
|
||||||
|
connectTimeoutMs,
|
||||||
|
printQRInTerminal,
|
||||||
|
auth: initialAuthInfo
|
||||||
|
} = config
|
||||||
|
const ev = new EventEmitter() as LegacyBaileysEventEmitter
|
||||||
|
|
||||||
|
const authInfo = initialAuthInfo || newLegacyAuthCreds()
|
||||||
|
|
||||||
|
const state: ConnectionState = {
|
||||||
|
legacy: {
|
||||||
|
phoneConnected: false,
|
||||||
|
},
|
||||||
|
connection: 'connecting',
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = makeSocket(config)
|
||||||
|
const { ws } = socket
|
||||||
|
let curveKeys: CurveKeyPair
|
||||||
|
let initTimeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
ws.on('phone-connection', ({ value: phoneConnected }) => {
|
||||||
|
updateState({ legacy: { ...state.legacy, phoneConnected } })
|
||||||
|
})
|
||||||
|
// 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,
|
||||||
|
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.sendNode({
|
||||||
|
json: ['admin', 'Conn', 'disconnect'],
|
||||||
|
tag: 'goodbye'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// will call state update to close connection
|
||||||
|
socket?.end(
|
||||||
|
new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
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 {
|
||||||
|
// 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.sendNode({
|
||||||
|
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).promise,
|
||||||
|
...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs).promise] : [])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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).promise
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!response || !response[1]) {
|
||||||
|
throw new Boom('Received unexpected login response', { data: response })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(response[1].type === 'upgrade_md_prod') {
|
||||||
|
throw new Boom('Require multi-device edition', { statusCode: DisconnectReason.multideviceMismatch })
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate the new connection
|
||||||
|
const { user, auth } = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
|
||||||
|
const isNewLogin = user.id !== state.legacy!.user?.id
|
||||||
|
|
||||||
|
Object.assign(authInfo, auth)
|
||||||
|
updateEncKeys()
|
||||||
|
|
||||||
|
logger.info({ user }, 'logged in')
|
||||||
|
|
||||||
|
ev.emit('creds.update', auth)
|
||||||
|
|
||||||
|
updateState({
|
||||||
|
connection: 'open',
|
||||||
|
legacy: {
|
||||||
|
phoneConnected: true,
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
isNewLogin,
|
||||||
|
qr: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.once('open', async() => {
|
||||||
|
try {
|
||||||
|
await onOpen()
|
||||||
|
} catch(error) {
|
||||||
|
socket.end(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if(printQRInTerminal) {
|
||||||
|
printQRIfNecessaryListener(ev, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
ev.emit('connection.update', {
|
||||||
|
...state
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...socket,
|
||||||
|
state,
|
||||||
|
authInfo,
|
||||||
|
ev,
|
||||||
|
canLogin,
|
||||||
|
logout,
|
||||||
|
/** Waits for the connection to WA to reach a state */
|
||||||
|
waitForConnectionUpdate: bindWaitForConnectionUpdate(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default makeAuthSocket
|
||||||
545
src/LegacySocket/chats.ts
Normal file
545
src/LegacySocket/chats.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import { BaileysEventMap, Chat, ChatModification, Contact, LegacySocketConfig, PresenceData, WABusinessProfile, WAFlag, WAMessageKey, WAMessageUpdate, WAMetric, WAPresence } from '../Types'
|
||||||
|
import { debouncedTimeout, unixTimestampSeconds } from '../Utils/generics'
|
||||||
|
import { BinaryNode, jidNormalizedUser } from '../WABinary'
|
||||||
|
import makeAuthSocket from './auth'
|
||||||
|
|
||||||
|
const makeChatsSocket = (config: LegacySocketConfig) => {
|
||||||
|
const { logger } = config
|
||||||
|
const sock = makeAuthSocket(config)
|
||||||
|
const {
|
||||||
|
ev,
|
||||||
|
ws: socketEvents,
|
||||||
|
currentEpoch,
|
||||||
|
setQuery,
|
||||||
|
query,
|
||||||
|
sendNode,
|
||||||
|
state
|
||||||
|
} = sock
|
||||||
|
|
||||||
|
const chatsDebounceTimeout = debouncedTimeout(10_000, () => sendChatsQuery(1))
|
||||||
|
|
||||||
|
const sendChatsQuery = (epoch: number) => (
|
||||||
|
sendNode({
|
||||||
|
json: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: { type: 'chat', epoch: epoch.toString() }
|
||||||
|
},
|
||||||
|
binaryTag: [ WAMetric.queryChat, WAFlag.ignore ]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const profilePictureUrl = async(jid: string, timeoutMs?: number) => {
|
||||||
|
const response = await query({
|
||||||
|
json: ['query', 'ProfilePicThumb', jid],
|
||||||
|
expect200: false,
|
||||||
|
requiresPhoneConnection: false,
|
||||||
|
timeoutMs
|
||||||
|
})
|
||||||
|
return response.eurl as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeChatModification = (node: BinaryNode) => {
|
||||||
|
const { attrs: attributes } = node
|
||||||
|
const updateType = attributes.type
|
||||||
|
const jid = jidNormalizedUser(attributes?.jid)
|
||||||
|
|
||||||
|
switch (updateType) {
|
||||||
|
case 'delete':
|
||||||
|
ev.emit('chats.delete', [jid])
|
||||||
|
break
|
||||||
|
case 'clear':
|
||||||
|
if(node.content) {
|
||||||
|
const ids = (node.content as BinaryNode[]).map(
|
||||||
|
({ attrs }) => attrs.index
|
||||||
|
)
|
||||||
|
ev.emit('messages.delete', { keys: ids.map(id => ({ id, remoteJid: jid })) })
|
||||||
|
} else {
|
||||||
|
ev.emit('messages.delete', { jid, all: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case 'archive':
|
||||||
|
ev.emit('chats.update', [ { id: jid, archive: true } ])
|
||||||
|
break
|
||||||
|
case 'unarchive':
|
||||||
|
ev.emit('chats.update', [ { id: jid, archive: false } ])
|
||||||
|
break
|
||||||
|
case 'pin':
|
||||||
|
ev.emit('chats.update', [ { id: jid, pin: attributes.pin ? +attributes.pin : null } ])
|
||||||
|
break
|
||||||
|
case 'star':
|
||||||
|
case 'unstar':
|
||||||
|
const starred = updateType === 'star'
|
||||||
|
const updates: WAMessageUpdate[] = (node.content as BinaryNode[]).map(
|
||||||
|
({ attrs }) => ({
|
||||||
|
key: {
|
||||||
|
remoteJid: jid,
|
||||||
|
id: attrs.index,
|
||||||
|
fromMe: attrs.owner === 'true'
|
||||||
|
},
|
||||||
|
update: { starred }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
ev.emit('messages.update', updates)
|
||||||
|
break
|
||||||
|
case 'mute':
|
||||||
|
if(attributes.mute === '0') {
|
||||||
|
ev.emit('chats.update', [{ id: jid, mute: null }])
|
||||||
|
} else {
|
||||||
|
ev.emit('chats.update', [{ id: jid, mute: +attributes.mute }])
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn({ node }, 'received unrecognized chat update')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyingPresenceUpdate = (update: BinaryNode['attrs']): BaileysEventMap<any>['presence.update'] => {
|
||||||
|
const id = jidNormalizedUser(update.id)
|
||||||
|
const participant = jidNormalizedUser(update.participant || update.id)
|
||||||
|
|
||||||
|
const presence: PresenceData = {
|
||||||
|
lastSeen: update.t ? +update.t : undefined,
|
||||||
|
lastKnownPresence: update.type as WAPresence
|
||||||
|
}
|
||||||
|
return { id, presences: { [participant]: presence } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatRead = async(fromMessage: WAMessageKey, count: number) => {
|
||||||
|
await setQuery (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag: 'read',
|
||||||
|
attrs: {
|
||||||
|
jid: fromMessage.remoteJid,
|
||||||
|
count: count.toString(),
|
||||||
|
index: fromMessage.id,
|
||||||
|
owner: fromMessage.fromMe ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[ WAMetric.read, WAFlag.ignore ]
|
||||||
|
)
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
ev.emit('chats.update', [{ id: fromMessage.remoteJid, unreadCount: count < 0 ? -1 : 0 }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.on('connection.update', async({ connection }) => {
|
||||||
|
if(connection !== 'open') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
sendNode({
|
||||||
|
json: { tag: 'query', attrs: { type: 'contacts', epoch: '1' } },
|
||||||
|
binaryTag: [ WAMetric.queryContact, WAFlag.ignore ]
|
||||||
|
}),
|
||||||
|
sendNode({
|
||||||
|
json: { tag: 'query', attrs: { type: 'status', epoch: '1' } },
|
||||||
|
binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ]
|
||||||
|
}),
|
||||||
|
sendNode({
|
||||||
|
json: { tag: 'query', attrs: { type: 'quick_reply', epoch: '1' } },
|
||||||
|
binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ]
|
||||||
|
}),
|
||||||
|
sendNode({
|
||||||
|
json: { tag: 'query', attrs: { type: 'label', epoch: '1' } },
|
||||||
|
binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ]
|
||||||
|
}),
|
||||||
|
sendNode({
|
||||||
|
json: { tag: 'query', attrs: { type: 'emoji', epoch: '1' } },
|
||||||
|
binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ]
|
||||||
|
}),
|
||||||
|
sendNode({
|
||||||
|
json: {
|
||||||
|
tag: 'action',
|
||||||
|
attrs: { type: 'set', epoch: '1' },
|
||||||
|
content: [
|
||||||
|
{ tag: 'presence', attrs: { 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({ content: data }: BinaryNode) => {
|
||||||
|
chatsDebounceTimeout.cancel()
|
||||||
|
if(Array.isArray(data)) {
|
||||||
|
const contacts: Contact[] = []
|
||||||
|
const chats = data.map(({ attrs }): Chat => {
|
||||||
|
const id = jidNormalizedUser(attrs.jid)
|
||||||
|
if(attrs.name) {
|
||||||
|
contacts.push({ id, name: attrs.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: jidNormalizedUser(attrs.jid),
|
||||||
|
conversationTimestamp: attrs.t ? +attrs.t : undefined,
|
||||||
|
unreadCount: +attrs.count,
|
||||||
|
archive: attrs.archive === 'true' ? true : undefined,
|
||||||
|
pin: attrs.pin ? +attrs.pin : undefined,
|
||||||
|
mute: attrs.mute ? +attrs.mute : undefined,
|
||||||
|
notSpam: !(attrs.spam === 'true'),
|
||||||
|
name: attrs.name,
|
||||||
|
ephemeralExpiration: attrs.ephemeral ? +attrs.ephemeral : undefined,
|
||||||
|
ephemeralSettingTimestamp: attrs.eph_setting_ts ? +attrs.eph_setting_ts : undefined,
|
||||||
|
readOnly: attrs.read_only === 'true' ? true : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`got ${chats.length} chats, extracted ${contacts.length} contacts with name`)
|
||||||
|
ev.emit('chats.set', { chats, isLatest: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// got all contacts from phone
|
||||||
|
socketEvents.on('CB:response,type:contacts', async({ content: data }: BinaryNode) => {
|
||||||
|
if(Array.isArray(data)) {
|
||||||
|
const contacts = data.map(({ attrs }): Contact => {
|
||||||
|
return {
|
||||||
|
id: jidNormalizedUser(attrs.jid),
|
||||||
|
name: attrs.name,
|
||||||
|
notify: attrs.notify,
|
||||||
|
verifiedName: attrs.vname
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`got ${contacts.length} contacts`)
|
||||||
|
ev.emit('contacts.set', { contacts })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// status updates
|
||||||
|
socketEvents.on('CB:Status,status', json => {
|
||||||
|
const id = jidNormalizedUser(json[1].id)
|
||||||
|
ev.emit('contacts.update', [ { id, status: json[1].status } ])
|
||||||
|
})
|
||||||
|
// User Profile Name Updates
|
||||||
|
socketEvents.on('CB:Conn,pushname', json => {
|
||||||
|
const { legacy: { user }, connection } = state
|
||||||
|
if(connection === 'open' && json[1].pushname !== user.name) {
|
||||||
|
user.name = json[1].pushname
|
||||||
|
ev.emit('connection.update', { legacy: { ...state.legacy, user } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// read updates
|
||||||
|
socketEvents.on ('CB:action,,read', async({ content }: BinaryNode) => {
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
const { attrs } = content[0]
|
||||||
|
|
||||||
|
const update: Partial<Chat> = {
|
||||||
|
id: jidNormalizedUser(attrs.jid)
|
||||||
|
}
|
||||||
|
if(attrs.type === 'false') {
|
||||||
|
update.unreadCount = -1
|
||||||
|
} else {
|
||||||
|
update.unreadCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.emit('chats.update', [update])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socketEvents.on('CB:Cmd,type:picture', async json => {
|
||||||
|
json = json[1]
|
||||||
|
const id = jidNormalizedUser(json.jid)
|
||||||
|
const imgUrl = await profilePictureUrl(id).catch(() => '')
|
||||||
|
|
||||||
|
ev.emit('contacts.update', [ { id, imgUrl } ])
|
||||||
|
})
|
||||||
|
|
||||||
|
// chat archive, pin etc.
|
||||||
|
socketEvents.on('CB:action,,chat', ({ content }: BinaryNode) => {
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
const [node] = content
|
||||||
|
executeChatModification(node)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socketEvents.on('CB:action,,user', (json: BinaryNode) => {
|
||||||
|
if(Array.isArray(json.content)) {
|
||||||
|
const user = json.content[0].attrs
|
||||||
|
if(user.id) {
|
||||||
|
user.id = jidNormalizedUser(user.id)
|
||||||
|
|
||||||
|
//ev.emit('contacts.upsert', [user])
|
||||||
|
} else {
|
||||||
|
logger.warn({ json }, 'recv unknown action')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
socketEvents.on('ws-close', () => {
|
||||||
|
chatsDebounceTimeout.cancel()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sock,
|
||||||
|
sendChatsQuery,
|
||||||
|
profilePictureUrl,
|
||||||
|
chatRead,
|
||||||
|
/**
|
||||||
|
* Modify a given chat (archive, pin etc.)
|
||||||
|
* @param jid the ID of the person/group you are modifiying
|
||||||
|
*/
|
||||||
|
chatModify: async(modification: ChatModification, jid: string, chatInfo: Pick<Chat, 'mute' | 'pin'>, timestampNow?: number) => {
|
||||||
|
const chatAttrs: BinaryNode['attrs'] = { jid: jid }
|
||||||
|
let data: BinaryNode[] | undefined = undefined
|
||||||
|
|
||||||
|
timestampNow = timestampNow || unixTimestampSeconds()
|
||||||
|
|
||||||
|
if('archive' in modification) {
|
||||||
|
chatAttrs.type = modification.archive ? 'archive' : 'unarchive'
|
||||||
|
} else if('pin' in modification) {
|
||||||
|
chatAttrs.type = 'pin'
|
||||||
|
if(modification.pin) {
|
||||||
|
chatAttrs.pin = timestampNow.toString()
|
||||||
|
} else {
|
||||||
|
chatAttrs.previous = chatInfo.pin!.toString()
|
||||||
|
}
|
||||||
|
} else if('mute' in modification) {
|
||||||
|
chatAttrs.type = 'mute'
|
||||||
|
if(modification.mute) {
|
||||||
|
chatAttrs.mute = (timestampNow + modification.mute).toString()
|
||||||
|
} else {
|
||||||
|
chatAttrs.previous = chatInfo.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 }) => (
|
||||||
|
{
|
||||||
|
tag: 'item',
|
||||||
|
attrs: { owner: (!!fromMe).toString(), index: id }
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else if('star' in modification) {
|
||||||
|
chatAttrs.type = modification.star.star ? 'star' : 'unstar'
|
||||||
|
data = modification.star.messages.map(({ id, fromMe }) => (
|
||||||
|
{
|
||||||
|
tag: 'item',
|
||||||
|
attrs: { owner: (!!fromMe).toString(), index: id }
|
||||||
|
}
|
||||||
|
))
|
||||||
|
} else if('markRead' in modification) {
|
||||||
|
const indexKey = modification.lastMessages[modification.lastMessages.length-1].key
|
||||||
|
return chatRead(indexKey, modification.markRead ? 0 : -1)
|
||||||
|
} else if('delete' in modification) {
|
||||||
|
chatAttrs.type = 'delete'
|
||||||
|
}
|
||||||
|
|
||||||
|
if('lastMessages' in modification) {
|
||||||
|
const indexKey = modification.lastMessages[modification.lastMessages.length-1].key
|
||||||
|
if(indexKey) {
|
||||||
|
chatAttrs.index = indexKey.id
|
||||||
|
chatAttrs.owner = indexKey.fromMe ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = { tag: 'chat', attrs: chatAttrs, content: data }
|
||||||
|
const response = await setQuery([node], [ WAMetric.chat, WAFlag.ignore ])
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
onWhatsApp: async(str: string) => {
|
||||||
|
const { status, jid, biz } = await query({
|
||||||
|
json: ['query', 'exist', str],
|
||||||
|
requiresPhoneConnection: false
|
||||||
|
})
|
||||||
|
if(status === 200) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
jid: jidNormalizedUser(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
|
||||||
|
*/
|
||||||
|
sendPresenceUpdate: (type: WAPresence, jid: string | undefined) => (
|
||||||
|
sendNode({
|
||||||
|
binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does
|
||||||
|
json: {
|
||||||
|
tag: 'action',
|
||||||
|
attrs: { epoch: currentEpoch().toString(), type: 'set' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'presence',
|
||||||
|
attrs: { type: type, to: jid }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* Request updates on the presence of a user
|
||||||
|
* this returns nothing, you'll receive updates in chats.update event
|
||||||
|
* */
|
||||||
|
presenceSubscribe: async(jid: string) => (
|
||||||
|
sendNode({ 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(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag: 'status',
|
||||||
|
attrs: {},
|
||||||
|
content: Buffer.from (status, 'utf-8')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ev.emit('contacts.update', [{ id: state.legacy!.user!.id, 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(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag: 'profile',
|
||||||
|
attrs: { name }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)) as any as {status: number, pushname: string}
|
||||||
|
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
const user = { ...state.legacy!.user!, name }
|
||||||
|
ev.emit('connection.update', { legacy: {
|
||||||
|
...state.legacy, user
|
||||||
|
} })
|
||||||
|
ev.emit('contacts.update', [{ id: user.id, name }])
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Update the profile picture
|
||||||
|
* @param jid
|
||||||
|
* @param img
|
||||||
|
*/
|
||||||
|
async updateProfilePicture(jid: string, img: Buffer) {
|
||||||
|
jid = jidNormalizedUser (jid)
|
||||||
|
const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO
|
||||||
|
const tag = this.generateMessageTag ()
|
||||||
|
const query: BinaryNode = {
|
||||||
|
tag: 'picture',
|
||||||
|
attrs: { jid: jid, id: tag, type: 'set' },
|
||||||
|
content: [
|
||||||
|
{ tag: 'image', attrs: {}, content: data.img },
|
||||||
|
{ tag: 'preview', attrs: {}, content: data.preview }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = state.legacy?.user
|
||||||
|
const { eurl } = await this.setQuery ([query], [WAMetric.picture, 136], tag) as { eurl: string, status: number }
|
||||||
|
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
if(jid === user.id) {
|
||||||
|
user.imgUrl = eurl
|
||||||
|
ev.emit('connection.update', {
|
||||||
|
legacy: {
|
||||||
|
...state.legacy,
|
||||||
|
user
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.emit('contacts.update', [ { id: 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 = {
|
||||||
|
tag: 'block',
|
||||||
|
attrs: { type },
|
||||||
|
content: [ { tag: 'user', attrs: { jid } } ]
|
||||||
|
}
|
||||||
|
await setQuery([json], [WAMetric.block, WAFlag.ignore])
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
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 = jidNormalizedUser(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: jidNormalizedUser(wid)
|
||||||
|
} as WABusinessProfile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default makeChatsSocket
|
||||||
257
src/LegacySocket/groups.ts
Normal file
257
src/LegacySocket/groups.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { GroupMetadata, GroupModificationResponse, GroupParticipant, LegacySocketConfig, ParticipantAction, WAFlag, WAGroupCreateResponse, WAMetric } from '../Types'
|
||||||
|
import { generateMessageID, unixTimestampSeconds } from '../Utils/generics'
|
||||||
|
import { BinaryNode, jidNormalizedUser } from '../WABinary'
|
||||||
|
import makeMessagesSocket from './messages'
|
||||||
|
|
||||||
|
const makeGroupsSocket = (config: LegacySocketConfig) => {
|
||||||
|
const { logger } = config
|
||||||
|
const sock = makeMessagesSocket(config)
|
||||||
|
const {
|
||||||
|
ev,
|
||||||
|
ws: socketEvents,
|
||||||
|
query,
|
||||||
|
generateMessageTag,
|
||||||
|
currentEpoch,
|
||||||
|
setQuery,
|
||||||
|
state
|
||||||
|
} = 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 ([
|
||||||
|
{
|
||||||
|
tag: 'group',
|
||||||
|
attrs: {
|
||||||
|
author: state.legacy?.user?.id,
|
||||||
|
id: tag,
|
||||||
|
type: type,
|
||||||
|
jid: jid,
|
||||||
|
subject: subject,
|
||||||
|
},
|
||||||
|
content: participants ?
|
||||||
|
participants.map(jid => (
|
||||||
|
{ tag: 'participant', attrs: { 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
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta: GroupMetadata = {
|
||||||
|
id: metadata.id,
|
||||||
|
subject: metadata.subject,
|
||||||
|
creation: +metadata.creation,
|
||||||
|
owner: jidNormalizedUser(metadata.owner),
|
||||||
|
desc: metadata.desc,
|
||||||
|
descOwner: metadata.descOwner,
|
||||||
|
participants: metadata.participants.map(
|
||||||
|
p => ({
|
||||||
|
id: jidNormalizedUser(p.id),
|
||||||
|
admin: p.isSuperAdmin ? 'super-admin' : p.isAdmin ? 'admin' : undefined
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the metadata (works after you've left the group also) */
|
||||||
|
const groupMetadataMinimal = async(jid: string) => {
|
||||||
|
const { attrs, content }:BinaryNode = await query({
|
||||||
|
json: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: { type: 'group', jid: jid, epoch: currentEpoch().toString() }
|
||||||
|
},
|
||||||
|
binaryTag: [WAMetric.group, WAFlag.ignore],
|
||||||
|
expect200: true
|
||||||
|
})
|
||||||
|
const participants: GroupParticipant[] = []
|
||||||
|
let desc: string | undefined
|
||||||
|
if(Array.isArray(content) && Array.isArray(content[0].content)) {
|
||||||
|
const nodes = content[0].content
|
||||||
|
for(const item of nodes) {
|
||||||
|
if(item.tag === 'participant') {
|
||||||
|
participants.push({
|
||||||
|
id: item.attrs.jid,
|
||||||
|
isAdmin: item.attrs.type === 'admin',
|
||||||
|
isSuperAdmin: false
|
||||||
|
})
|
||||||
|
} else if(item.tag === 'description') {
|
||||||
|
desc = (item.content as Buffer).toString('utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: GroupMetadata = {
|
||||||
|
id: jid,
|
||||||
|
owner: attrs?.creator,
|
||||||
|
creation: +attrs?.create,
|
||||||
|
subject: null,
|
||||||
|
desc,
|
||||||
|
participants
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
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', [
|
||||||
|
{
|
||||||
|
id: response.gid!,
|
||||||
|
name: title,
|
||||||
|
conversationTimestamp: unixTimestampSeconds(),
|
||||||
|
unreadCount: 0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
return metadata
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Leave a group
|
||||||
|
* @param jid the ID of the group
|
||||||
|
*/
|
||||||
|
groupLeave: async(id: string) => {
|
||||||
|
await groupQuery('leave', id)
|
||||||
|
ev.emit('chats.update', [ { id, readOnly: 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(id: string, title: string) => {
|
||||||
|
await groupQuery('subject', id, title)
|
||||||
|
ev.emit('chats.update', [ { id, name: title } ])
|
||||||
|
ev.emit('contacts.update', [ { id, name: title } ])
|
||||||
|
ev.emit('groups.update', [ { id: id, 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: BinaryNode = {
|
||||||
|
tag: 'description',
|
||||||
|
attrs: { id: generateMessageID(), prev: metadata?.descId },
|
||||||
|
content: 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(id: string, participants: string[], action: ParticipantAction) => {
|
||||||
|
const result: GroupModificationResponse = await groupQuery(action, id, null, participants)
|
||||||
|
const jids = Object.keys(result.participants || {})
|
||||||
|
ev.emit('group-participants.update', { id, 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: state.legacy?.user?.id,
|
||||||
|
participants: result.recipients!.map(({ id }) => (
|
||||||
|
{ id: jidNormalizedUser(id), isAdmin: false, isSuperAdmin: false }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
},
|
||||||
|
groupInviteCode: async(jid: string) => {
|
||||||
|
const response = await sock.query({
|
||||||
|
json: ['query', 'inviteCode', jid],
|
||||||
|
expect200: true,
|
||||||
|
requiresPhoneConnection: false
|
||||||
|
})
|
||||||
|
return response.code as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default makeGroupsSocket
|
||||||
12
src/LegacySocket/index.ts
Normal file
12
src/LegacySocket/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults'
|
||||||
|
import { LegacySocketConfig } from '../Types'
|
||||||
|
import _makeLegacySocket from './groups'
|
||||||
|
// export the last socket layer
|
||||||
|
const makeLegacySocket = (config: Partial<LegacySocketConfig>) => (
|
||||||
|
_makeLegacySocket({
|
||||||
|
...DEFAULT_LEGACY_CONNECTION_CONFIG,
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export default makeLegacySocket
|
||||||
568
src/LegacySocket/messages.ts
Normal file
568
src/LegacySocket/messages.ts
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { WA_DEFAULT_EPHEMERAL } from '../Defaults'
|
||||||
|
import { AnyMessageContent, Chat, GroupMetadata, LegacySocketConfig, MediaConnInfo, MessageUpdateType, MessageUserReceipt, MessageUserReceiptUpdate, MiscMessageGenerationOptions, ParticipantAction, WAFlag, WAMessage, WAMessageCursor, WAMessageKey, WAMessageStatus, WAMessageStubType, WAMessageUpdate, WAMetric, WAUrlInfo } from '../Types'
|
||||||
|
import { decryptMediaMessageBuffer, extractMessageContent, generateWAMessage, getWAUploadToServer, toNumber } from '../Utils'
|
||||||
|
import { areJidsSameUser, BinaryNode, getBinaryNodeMessages, isJidGroup, jidNormalizedUser } from '../WABinary'
|
||||||
|
import makeChatsSocket from './chats'
|
||||||
|
|
||||||
|
const STATUS_MAP = {
|
||||||
|
read: WAMessageStatus.READ,
|
||||||
|
message: WAMessageStatus.DELIVERY_ACK,
|
||||||
|
error: WAMessageStatus.ERROR
|
||||||
|
} as { [_: string]: WAMessageStatus }
|
||||||
|
|
||||||
|
const makeMessagesSocket = (config: LegacySocketConfig) => {
|
||||||
|
const { logger } = config
|
||||||
|
const sock = makeChatsSocket(config)
|
||||||
|
const {
|
||||||
|
ev,
|
||||||
|
ws: socketEvents,
|
||||||
|
query,
|
||||||
|
generateMessageTag,
|
||||||
|
currentEpoch,
|
||||||
|
setQuery,
|
||||||
|
state
|
||||||
|
} = sock
|
||||||
|
|
||||||
|
let mediaConn: Promise<MediaConnInfo>
|
||||||
|
const refreshMediaConn = async(forceGet = false) => {
|
||||||
|
const 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,
|
||||||
|
expect200: true
|
||||||
|
})
|
||||||
|
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 { content }:BinaryNode = await query({
|
||||||
|
json: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: {
|
||||||
|
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(content)) {
|
||||||
|
return content.map(data => proto.WebMessageInfo.decode(data.content as Buffer))
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: {
|
||||||
|
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.attrs
|
||||||
|
Object.assign(content, attrs) // update message
|
||||||
|
|
||||||
|
ev.emit('messages.upsert', { messages: [message], type: 'replace' })
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMessage = (message: WAMessage, type: MessageUpdateType) => {
|
||||||
|
const jid = message.key.remoteJid!
|
||||||
|
// store chat updates in this
|
||||||
|
const chatUpdate: Partial<Chat> = {
|
||||||
|
id: jid,
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
|
||||||
|
ev.emit('groups.update', [ { id: jid, ...update } ])
|
||||||
|
}
|
||||||
|
|
||||||
|
if(message.message) {
|
||||||
|
chatUpdate.conversationTimestamp = +toNumber(message.messageTimestamp)
|
||||||
|
// add to count if the message isn't from me & there exists a message
|
||||||
|
if(!message.key.fromMe) {
|
||||||
|
chatUpdate.unreadCount = 1
|
||||||
|
const participant = jidNormalizedUser(message.participant || jid)
|
||||||
|
|
||||||
|
ev.emit(
|
||||||
|
'presence.update',
|
||||||
|
{
|
||||||
|
id: jid,
|
||||||
|
presences: { [participant]: { lastKnownPresence: 'available' } }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolMessage = message.message?.protocolMessage || message.message?.ephemeralMessage?.message?.protocolMessage
|
||||||
|
// if it's a message to delete another message
|
||||||
|
if(protocolMessage) {
|
||||||
|
switch (protocolMessage.type) {
|
||||||
|
case proto.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
|
||||||
|
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
|
||||||
|
chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp
|
||||||
|
chatUpdate.ephemeralExpiration = protocolMessage.ephemeralExpiration
|
||||||
|
|
||||||
|
if(isJidGroup(jid)) {
|
||||||
|
emitGroupUpdate({ ephemeralDuration: protocolMessage.ephemeralExpiration || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the message is an action
|
||||||
|
if(message.messageStubType) {
|
||||||
|
const { user } = state.legacy!
|
||||||
|
//let actor = jidNormalizedUser (message.participant)
|
||||||
|
let participants: string[]
|
||||||
|
const emitParticipantsUpdate = (action: ParticipantAction) => (
|
||||||
|
ev.emit('group-participants.update', { id: jid, participants, action })
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (message.messageStubType) {
|
||||||
|
case WAMessageStubType.CHANGE_EPHEMERAL_SETTING:
|
||||||
|
chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp
|
||||||
|
chatUpdate.ephemeralExpiration = +message.messageStubParameters[0]
|
||||||
|
if(isJidGroup(jid)) {
|
||||||
|
emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
|
||||||
|
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
|
||||||
|
participants = message.messageStubParameters.map (jidNormalizedUser)
|
||||||
|
emitParticipantsUpdate('remove')
|
||||||
|
// mark the chat read only if you left the group
|
||||||
|
if(participants.includes(user.id)) {
|
||||||
|
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 (jidNormalizedUser)
|
||||||
|
if(participants.includes(user.id)) {
|
||||||
|
chatUpdate.readOnly = null
|
||||||
|
}
|
||||||
|
|
||||||
|
emitParticipantsUpdate('add')
|
||||||
|
break
|
||||||
|
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
|
||||||
|
const announce = message.messageStubParameters[0] === 'on'
|
||||||
|
emitGroupUpdate({ announce })
|
||||||
|
break
|
||||||
|
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
|
||||||
|
const restrict = message.messageStubParameters[0] === 'on'
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.emit('messages.upsert', { messages: [message], type })
|
||||||
|
}
|
||||||
|
|
||||||
|
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn)
|
||||||
|
|
||||||
|
/** 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: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: {
|
||||||
|
type: 'url',
|
||||||
|
url: text,
|
||||||
|
epoch: currentEpoch().toString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
binaryTag: [26, WAFlag.ignore],
|
||||||
|
expect200: true,
|
||||||
|
requiresPhoneConnection: false
|
||||||
|
})
|
||||||
|
const urlInfo = { ...response.attrs } as any as WAUrlInfo
|
||||||
|
if(response && response.content) {
|
||||||
|
urlInfo.jpegThumbnail = response.content 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 relayMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => {
|
||||||
|
const json: BinaryNode = {
|
||||||
|
tag: 'action',
|
||||||
|
attrs: { epoch: currentEpoch().toString(), type: 'relay' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'message',
|
||||||
|
attrs: {},
|
||||||
|
content: proto.WebMessageInfo.encode(message).finish()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const isMsgToMe = areJidsSameUser(message.key.remoteJid, state.legacy.user?.id || '')
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
onMessage(message, 'append')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages received
|
||||||
|
const messagesUpdate = (node: BinaryNode, isLatest: boolean) => {
|
||||||
|
const messages = getBinaryNodeMessages(node)
|
||||||
|
messages.reverse()
|
||||||
|
ev.emit('messages.set', { messages, isLatest })
|
||||||
|
}
|
||||||
|
|
||||||
|
socketEvents.on('CB:action,add:last', json => messagesUpdate(json, true))
|
||||||
|
socketEvents.on('CB:action,add:unread', json => messagesUpdate(json, false))
|
||||||
|
socketEvents.on('CB:action,add:before', json => messagesUpdate(json, false))
|
||||||
|
|
||||||
|
// new messages
|
||||||
|
socketEvents.on('CB:action,add:relay,message', (node: BinaryNode) => {
|
||||||
|
const msgs = getBinaryNodeMessages(node)
|
||||||
|
for(const msg of msgs) {
|
||||||
|
onMessage(msg, 'notify')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// If a message has been updated
|
||||||
|
// usually called when a video message gets its upload url, or live locations or ciphertext message gets fixed
|
||||||
|
socketEvents.on ('CB:action,add:update,message', (node: BinaryNode) => {
|
||||||
|
const msgs = getBinaryNodeMessages(node)
|
||||||
|
for(const msg of msgs) {
|
||||||
|
onMessage(msg, 'replace')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// message status updates
|
||||||
|
const onMessageStatusUpdate = ({ content }: BinaryNode) => {
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
const updates: WAMessageUpdate[] = []
|
||||||
|
for(const { attrs: json } of content) {
|
||||||
|
const key: WAMessageKey = {
|
||||||
|
remoteJid: jidNormalizedUser(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({ content, key }, '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 MessageUserReceipt
|
||||||
|
switch (attributes.ack.toString()) {
|
||||||
|
case '2':
|
||||||
|
updateKey = 'receiptTimestamp'
|
||||||
|
break
|
||||||
|
case '3':
|
||||||
|
updateKey = 'readTimestamp'
|
||||||
|
break
|
||||||
|
case '4':
|
||||||
|
updateKey = 'playedTimestamp'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn({ attributes }, 'received unknown message info update')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPartial = {
|
||||||
|
remoteJid: jidNormalizedUser(attributes.to),
|
||||||
|
fromMe: areJidsSameUser(attributes.from, state.legacy?.user?.id || ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
const userJid = jidNormalizedUser(attributes.participant || attributes.to)
|
||||||
|
|
||||||
|
const updates = ids.map<MessageUserReceiptUpdate>(id => ({
|
||||||
|
key: { ...keyPartial, id },
|
||||||
|
receipt: {
|
||||||
|
userJid,
|
||||||
|
[updateKey]: +attributes.t
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
ev.emit('message-receipt.update', updates)
|
||||||
|
// for individual messages
|
||||||
|
// it means the message is marked read/delivered
|
||||||
|
if(!isJidGroup(keyPartial.remoteJid)) {
|
||||||
|
ev.emit('messages.update', ids.map(id => (
|
||||||
|
{
|
||||||
|
key: { ...keyPartial, id },
|
||||||
|
update: {
|
||||||
|
status: updateKey === 'receiptTimestamp' ? 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,
|
||||||
|
relayMessage,
|
||||||
|
generateUrlInfo,
|
||||||
|
messageInfo: async(jid: string, messageID: string) => {
|
||||||
|
const { content }: BinaryNode = await query({
|
||||||
|
json: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: {
|
||||||
|
type: 'message_info',
|
||||||
|
index: messageID,
|
||||||
|
jid: jid,
|
||||||
|
epoch: currentEpoch().toString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
binaryTag: [WAMetric.queryRead, WAFlag.ignore],
|
||||||
|
expect200: true,
|
||||||
|
requiresPhoneConnection: true
|
||||||
|
})
|
||||||
|
const info: { [jid: string]: MessageUserReceipt } = { }
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
for(const { tag, content: innerData } of content) {
|
||||||
|
const [{ attrs }] = (innerData as BinaryNode[])
|
||||||
|
|
||||||
|
const jid = jidNormalizedUser(attrs.jid)
|
||||||
|
const recp = info[jid] || { userJid: jid }
|
||||||
|
const date = +attrs.t
|
||||||
|
switch (tag) {
|
||||||
|
case 'read':
|
||||||
|
recp.readTimestamp = date
|
||||||
|
break
|
||||||
|
case 'delivery':
|
||||||
|
recp.receiptTimestamp = date
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
info[jid] = recp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(info)
|
||||||
|
},
|
||||||
|
downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => {
|
||||||
|
const downloadMediaMessage = async() => {
|
||||||
|
const 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) => {
|
||||||
|
// 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 })
|
||||||
|
return actual
|
||||||
|
},
|
||||||
|
searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => {
|
||||||
|
const node: BinaryNode = await query({
|
||||||
|
json: {
|
||||||
|
tag: 'query',
|
||||||
|
attrs: {
|
||||||
|
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
|
||||||
|
|
||||||
|
return {
|
||||||
|
last: node.attrs?.last === 'true',
|
||||||
|
messages: getBinaryNodeMessages(node)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendMessage: async(
|
||||||
|
jid: string,
|
||||||
|
content: AnyMessageContent,
|
||||||
|
options: MiscMessageGenerationOptions & { waitForAck?: boolean } = { waitForAck: true }
|
||||||
|
) => {
|
||||||
|
const userJid = state.legacy.user?.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
|
||||||
|
const tag = generateMessageTag(true)
|
||||||
|
await setQuery([
|
||||||
|
{
|
||||||
|
tag: 'group',
|
||||||
|
attrs: { id: tag, jid, type: 'prop', author: userJid },
|
||||||
|
content: [
|
||||||
|
{ tag: 'ephemeral', attrs: { value: value.toString() } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
const msg = await generateWAMessage(
|
||||||
|
jid,
|
||||||
|
content,
|
||||||
|
{
|
||||||
|
logger,
|
||||||
|
userJid: userJid,
|
||||||
|
getUrlInfo: generateUrlInfo,
|
||||||
|
upload: waUploadToServer,
|
||||||
|
mediaCache: config.mediaCache,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await relayMessage(msg, { waitForAck: options.waitForAck })
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default makeMessagesSocket
|
||||||
431
src/LegacySocket/socket.ts
Normal file
431
src/LegacySocket/socket.ts
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { STATUS_CODES } from 'http'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, DEFAULT_ORIGIN, PHONE_CONNECTION_CB } from '../Defaults'
|
||||||
|
import { DisconnectReason, LegacySocketConfig, SocketQueryOptions, SocketSendMessageOptions, WAFlag, WAMetric, WATag } from '../Types'
|
||||||
|
import { aesEncrypt, decodeWAMessage, hmacSign, promiseTimeout, unixTimestampSeconds } from '../Utils'
|
||||||
|
import { BinaryNode, encodeBinaryNodeLegacy } from '../WABinary'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}: LegacySocketConfig) => {
|
||||||
|
// 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 phoneConnectionChanged = (value: boolean) => {
|
||||||
|
ws.emit('phone-connection', { value })
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if(ws.readyState !== ws.OPEN) {
|
||||||
|
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendPromise.call(ws, data) as Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to the WA servers
|
||||||
|
* @returns the tag attached in the message
|
||||||
|
* */
|
||||||
|
const sendNode = 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(Array.isArray(json)) {
|
||||||
|
throw new Boom('Expected BinaryNode with binary code', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!authInfo) {
|
||||||
|
throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = encodeBinaryNodeLegacy(json) // 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.info({ 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] === '!' || message[0] === '!'.charCodeAt(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()
|
||||||
|
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.tag || json[0] || ''
|
||||||
|
const l1 = json?.attrs || json?.[1] || { }
|
||||||
|
const l2 = json?.content?.[0]?.tag || 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 = () => sendNode({ 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 = (tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => {
|
||||||
|
if(ws.readyState !== ws.OPEN) {
|
||||||
|
throw new Boom('Connection not open', { statusCode: DisconnectReason.connectionClosed })
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelToken = () => { }
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise: (async() => {
|
||||||
|
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('Intentional Close', { statusCode: DisconnectReason.connectionClosed }))
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelToken = () => onErr(new Boom('Cancelled', { statusCode: 500 }))
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
cancelToken: () => {
|
||||||
|
cancelToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, cancelToken } = waitForMessage(tag, requiresPhoneConnection, timeoutMs)
|
||||||
|
try {
|
||||||
|
await sendNode({ json, tag, binaryTag })
|
||||||
|
} catch(error) {
|
||||||
|
cancelToken()
|
||||||
|
// swallow error
|
||||||
|
await promise.catch(() => { })
|
||||||
|
// throw back the error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.tag || 'query')}': ${message}(${responseStatusCode})`,
|
||||||
|
{ data: { query: json, response }, 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 Already 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 {
|
||||||
|
type: 'legacy' as 'legacy',
|
||||||
|
ws,
|
||||||
|
sendAdminTest,
|
||||||
|
updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info,
|
||||||
|
waitForSocketOpen,
|
||||||
|
sendNode,
|
||||||
|
generateMessageTag,
|
||||||
|
waitForMessage,
|
||||||
|
query,
|
||||||
|
/** Generic function for action, set queries */
|
||||||
|
setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => {
|
||||||
|
const json: BinaryNode = {
|
||||||
|
tag: 'action',
|
||||||
|
attrs: { epoch: epoch.toString(), type: 'set' },
|
||||||
|
content: nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
return query({
|
||||||
|
json,
|
||||||
|
binaryTag,
|
||||||
|
tag,
|
||||||
|
expect200: true,
|
||||||
|
requiresPhoneConnection: true
|
||||||
|
}) as Promise<{ status: number }>
|
||||||
|
},
|
||||||
|
currentEpoch: () => epoch,
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}
|
||||||
701
src/Socket/chats.ts
Normal file
701
src/Socket/chats.ts
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { AppStateChunk, Chat, ChatModification, ChatMutation, Contact, LTHashState, PresenceData, SocketConfig, WABusinessHoursConfig, WABusinessProfile, WAMediaUpload, WAPatchCreate, WAPatchName, WAPresence } from '../Types'
|
||||||
|
import { chatModificationToAppPatch, decodePatches, decodeSyncdSnapshot, encodeSyncdPatch, extractSyncdPatches, generateProfilePicture, newLTHashState, toNumber } from '../Utils'
|
||||||
|
import makeMutex from '../Utils/make-mutex'
|
||||||
|
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary'
|
||||||
|
import { makeMessagesSocket } from './messages-send'
|
||||||
|
|
||||||
|
const MAX_SYNC_ATTEMPTS = 5
|
||||||
|
|
||||||
|
export const makeChatsSocket = (config: SocketConfig) => {
|
||||||
|
const { logger } = config
|
||||||
|
const sock = makeMessagesSocket(config)
|
||||||
|
const {
|
||||||
|
ev,
|
||||||
|
ws,
|
||||||
|
authState,
|
||||||
|
generateMessageTag,
|
||||||
|
sendNode,
|
||||||
|
query,
|
||||||
|
fetchPrivacySettings,
|
||||||
|
} = sock
|
||||||
|
|
||||||
|
const mutationMutex = makeMutex()
|
||||||
|
|
||||||
|
const getAppStateSyncKey = async(keyId: string) => {
|
||||||
|
const { [keyId]: key } = await authState.keys.get('app-state-sync-key', [keyId])
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const child = result.content?.[0] as BinaryNode
|
||||||
|
return (child.content as BinaryNode[])?.map(i => i.attrs.jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBlockStatus = async(jid: string, action: 'block' | 'unblock') => {
|
||||||
|
await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
xmlns: 'blocklist',
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
type: 'set'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'item',
|
||||||
|
attrs: {
|
||||||
|
action,
|
||||||
|
jid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBusinessProfile = async(jid: string): Promise<WABusinessProfile | void> => {
|
||||||
|
const results = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: 's.whatsapp.net',
|
||||||
|
xmlns: 'w:biz',
|
||||||
|
type: 'get'
|
||||||
|
},
|
||||||
|
content: [{
|
||||||
|
tag: 'business_profile',
|
||||||
|
attrs: { v: '244' },
|
||||||
|
content: [{
|
||||||
|
tag: 'profile',
|
||||||
|
attrs: { jid }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
const profiles = getBinaryNodeChild(getBinaryNodeChild(results, 'business_profile'), 'profile')
|
||||||
|
if(!profiles) {
|
||||||
|
// if not bussines
|
||||||
|
if(logger.level === 'trace') {
|
||||||
|
logger.trace({ jid }, 'Not bussines')
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = getBinaryNodeChild(profiles, 'address')
|
||||||
|
const description = getBinaryNodeChild(profiles, 'description')
|
||||||
|
const website = getBinaryNodeChild(profiles, 'website')
|
||||||
|
const email = getBinaryNodeChild(profiles, 'email')
|
||||||
|
const category = getBinaryNodeChild(getBinaryNodeChild(profiles, 'categories'), 'category')
|
||||||
|
const business_hours = getBinaryNodeChild(profiles, 'business_hours')
|
||||||
|
const business_hours_config = business_hours && getBinaryNodeChildren(business_hours, 'business_hours_config')
|
||||||
|
return {
|
||||||
|
wid: profiles.attrs?.jid,
|
||||||
|
address: address?.content.toString(),
|
||||||
|
description: description?.content.toString(),
|
||||||
|
website: [website?.content.toString()],
|
||||||
|
email: email?.content.toString(),
|
||||||
|
category: category?.content.toString(),
|
||||||
|
business_hours: {
|
||||||
|
timezone: business_hours?.attrs?.timezone,
|
||||||
|
business_config: business_hours_config?.map(({ attrs }) => attrs as unknown as WABusinessHoursConfig)
|
||||||
|
}
|
||||||
|
} as unknown as WABusinessProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAccountSyncTimestamp = async(fromTimestamp: number | string) => {
|
||||||
|
logger.info({ fromTimestamp }, 'requesting account sync')
|
||||||
|
await sendNode({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
type: 'set',
|
||||||
|
xmlns: 'urn:xmpp:whatsapp:dirty',
|
||||||
|
id: generateMessageTag(),
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'clean',
|
||||||
|
attrs: {
|
||||||
|
type: 'account_sync',
|
||||||
|
timestamp: fromTimestamp.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resyncAppState = async(collections: WAPatchName[]) => {
|
||||||
|
const appStateChunk: AppStateChunk = { totalMutations: [], collectionsToHandle: [] }
|
||||||
|
// we use this to determine which events to fire
|
||||||
|
// otherwise when we resync from scratch -- all notifications will fire
|
||||||
|
const initialVersionMap: { [T in WAPatchName]?: number } = { }
|
||||||
|
|
||||||
|
await authState.keys.transaction(
|
||||||
|
async() => {
|
||||||
|
const collectionsToHandle = new Set<string>(collections)
|
||||||
|
// in case something goes wrong -- ensure we don't enter a loop that cannot be exited from
|
||||||
|
const attemptsMap = { } as { [T in WAPatchName]: number | undefined }
|
||||||
|
// keep executing till all collections are done
|
||||||
|
// sometimes a single patch request will not return all the patches (God knows why)
|
||||||
|
// so we fetch till they're all done (this is determined by the "has_more_patches" flag)
|
||||||
|
while(collectionsToHandle.size) {
|
||||||
|
const states = { } as { [T in WAPatchName]: LTHashState }
|
||||||
|
const nodes: BinaryNode[] = []
|
||||||
|
|
||||||
|
for(const name of collectionsToHandle) {
|
||||||
|
const result = await authState.keys.get('app-state-sync-version', [name])
|
||||||
|
let state = result[name]
|
||||||
|
|
||||||
|
if(state) {
|
||||||
|
if(typeof initialVersionMap[name] === 'undefined') {
|
||||||
|
initialVersionMap[name] = state.version
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state = newLTHashState()
|
||||||
|
}
|
||||||
|
|
||||||
|
states[name] = state
|
||||||
|
|
||||||
|
logger.info(`resyncing ${name} from v${state.version}`)
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
tag: 'collection',
|
||||||
|
attrs: {
|
||||||
|
name,
|
||||||
|
version: state.version.toString(),
|
||||||
|
// return snapshot if being synced from scratch
|
||||||
|
return_snapshot: (!state.version).toString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
xmlns: 'w:sync:app:state',
|
||||||
|
type: 'set'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'sync',
|
||||||
|
attrs: { },
|
||||||
|
content: nodes
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const decoded = await extractSyncdPatches(result) // extract from binary node
|
||||||
|
for(const key in decoded) {
|
||||||
|
const name = key as WAPatchName
|
||||||
|
const { patches, hasMorePatches, snapshot } = decoded[name]
|
||||||
|
try {
|
||||||
|
if(snapshot) {
|
||||||
|
const { state: newState, mutations } = await decodeSyncdSnapshot(name, snapshot, getAppStateSyncKey, initialVersionMap[name])
|
||||||
|
states[name] = newState
|
||||||
|
|
||||||
|
logger.info(`restored state of ${name} from snapshot to v${newState.version} with ${mutations.length} mutations`)
|
||||||
|
|
||||||
|
await authState.keys.set({ 'app-state-sync-version': { [name]: newState } })
|
||||||
|
|
||||||
|
appStateChunk.totalMutations.push(...mutations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// only process if there are syncd patches
|
||||||
|
if(patches.length) {
|
||||||
|
const { newMutations, state: newState } = await decodePatches(name, patches, states[name], getAppStateSyncKey, initialVersionMap[name])
|
||||||
|
|
||||||
|
await authState.keys.set({ 'app-state-sync-version': { [name]: newState } })
|
||||||
|
|
||||||
|
logger.info(`synced ${name} to v${newState.version}`)
|
||||||
|
if(newMutations.length) {
|
||||||
|
logger.trace({ newMutations, name }, 'recv new mutations')
|
||||||
|
}
|
||||||
|
|
||||||
|
appStateChunk.totalMutations.push(...newMutations)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(hasMorePatches) {
|
||||||
|
logger.info(`${name} has more patches...`)
|
||||||
|
} else { // collection is done with sync
|
||||||
|
collectionsToHandle.delete(name)
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
logger.info({ name, error: error.stack }, 'failed to sync state from version, removing and trying from scratch')
|
||||||
|
await authState.keys.set({ 'app-state-sync-version': { [name]: null } })
|
||||||
|
// increment number of retries
|
||||||
|
attemptsMap[name] = (attemptsMap[name] || 0) + 1
|
||||||
|
// if retry attempts overshoot
|
||||||
|
// or key not found
|
||||||
|
if(attemptsMap[name] >= MAX_SYNC_ATTEMPTS || error.output?.statusCode === 404) {
|
||||||
|
// stop retrying
|
||||||
|
collectionsToHandle.delete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
processSyncActions(appStateChunk.totalMutations)
|
||||||
|
|
||||||
|
return appStateChunk
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch the profile picture of a user/group
|
||||||
|
* type = "preview" for a low res picture
|
||||||
|
* type = "image for the high res picture"
|
||||||
|
*/
|
||||||
|
const profilePictureUrl = async(jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
|
||||||
|
jid = jidNormalizedUser(jid)
|
||||||
|
const result = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: jid,
|
||||||
|
type: 'get',
|
||||||
|
xmlns: 'w:profile:picture'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ tag: 'picture', attrs: { type, query: 'url' } }
|
||||||
|
]
|
||||||
|
}, timeoutMs)
|
||||||
|
const child = getBinaryNodeChild(result, 'picture')
|
||||||
|
return child?.attrs?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendPresenceUpdate = async(type: WAPresence, toJid?: string) => {
|
||||||
|
const me = authState.creds.me!
|
||||||
|
if(type === 'available' || type === 'unavailable') {
|
||||||
|
await sendNode({
|
||||||
|
tag: 'presence',
|
||||||
|
attrs: {
|
||||||
|
name: me!.name,
|
||||||
|
type
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await sendNode({
|
||||||
|
tag: 'chatstate',
|
||||||
|
attrs: {
|
||||||
|
from: 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 resyncMainAppState = async() => {
|
||||||
|
|
||||||
|
logger.debug('resyncing main app state')
|
||||||
|
|
||||||
|
await (
|
||||||
|
mutationMutex.mutex(
|
||||||
|
() => resyncAppState([
|
||||||
|
'critical_block',
|
||||||
|
'critical_unblock_low',
|
||||||
|
'regular_high',
|
||||||
|
'regular_low',
|
||||||
|
'regular'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.catch(err => (
|
||||||
|
logger.warn({ trace: err.stack }, 'failed to sync app state')
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processSyncActions = (actions: ChatMutation[]) => {
|
||||||
|
const updates: { [jid: string]: Partial<Chat> } = {}
|
||||||
|
const contactUpdates: { [jid: string]: Contact } = {}
|
||||||
|
const msgDeletes: proto.IMessageKey[] = []
|
||||||
|
|
||||||
|
for(const { syncAction: { value: action }, index: [_, id, msgId, fromMe] } 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) {
|
||||||
|
msgDeletes.push({
|
||||||
|
remoteJid: id,
|
||||||
|
id: msgId,
|
||||||
|
fromMe: fromMe === '1'
|
||||||
|
})
|
||||||
|
} else if(action?.contactAction) {
|
||||||
|
contactUpdates[id] = {
|
||||||
|
...(contactUpdates[id] || {}),
|
||||||
|
id,
|
||||||
|
name: action.contactAction!.fullName
|
||||||
|
}
|
||||||
|
} else if(action?.pushNameSetting) {
|
||||||
|
const me = {
|
||||||
|
...authState.creds.me!,
|
||||||
|
name: action?.pushNameSetting?.name!
|
||||||
|
}
|
||||||
|
ev.emit('creds.update', { me })
|
||||||
|
} else if(action?.pinAction) {
|
||||||
|
update.pin = action.pinAction?.pinned ? toNumber(action.timestamp) : undefined
|
||||||
|
} else {
|
||||||
|
logger.warn({ action, id }, 'unprocessable update')
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Object.keys(update).length > 1) {
|
||||||
|
updates[update.id] = {
|
||||||
|
...(updates[update.id] || {}),
|
||||||
|
...update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Object.values(updates).length) {
|
||||||
|
ev.emit('chats.update', Object.values(updates))
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Object.values(contactUpdates).length) {
|
||||||
|
ev.emit('contacts.upsert', Object.values(contactUpdates))
|
||||||
|
}
|
||||||
|
|
||||||
|
if(msgDeletes.length) {
|
||||||
|
ev.emit('messages.delete', { keys: msgDeletes })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appPatch = async(patchCreate: WAPatchCreate) => {
|
||||||
|
const name = patchCreate.type
|
||||||
|
const myAppStateKeyId = authState.creds.myAppStateKeyId
|
||||||
|
if(!myAppStateKeyId) {
|
||||||
|
throw new Boom('App state key not present!', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutationMutex.mutex(
|
||||||
|
async() => {
|
||||||
|
logger.debug({ patch: patchCreate }, 'applying app patch')
|
||||||
|
|
||||||
|
await resyncAppState([name])
|
||||||
|
const { [name]: initial } = await authState.keys.get('app-state-sync-version', [name])
|
||||||
|
const { patch, state } = await encodeSyncdPatch(
|
||||||
|
patchCreate,
|
||||||
|
myAppStateKeyId,
|
||||||
|
initial,
|
||||||
|
getAppStateSyncKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
const node: BinaryNode = {
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
type: 'set',
|
||||||
|
xmlns: 'w:sync:app:state'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'sync',
|
||||||
|
attrs: { },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'collection',
|
||||||
|
attrs: {
|
||||||
|
name,
|
||||||
|
version: (state.version-1).toString(),
|
||||||
|
return_snapshot: 'false'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'patch',
|
||||||
|
attrs: { },
|
||||||
|
content: proto.SyncdPatch.encode(patch).finish()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await query(node)
|
||||||
|
|
||||||
|
await authState.keys.set({ 'app-state-sync-version': { [name]: state } })
|
||||||
|
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
const result = await decodePatches(name, [{ ...patch, version: { version: state.version }, }], initial, getAppStateSyncKey)
|
||||||
|
processSyncActions(result.newMutations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sending abt props may fix QR scan fail if server expects */
|
||||||
|
const fetchAbt = async() => {
|
||||||
|
const abtNode = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
xmlns: 'abt',
|
||||||
|
type: 'get',
|
||||||
|
id: generateMessageTag(),
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ tag: 'props', attrs: { protocol: '1' } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const propsNode = getBinaryNodeChild(abtNode, 'props')
|
||||||
|
|
||||||
|
let props: { [_: string]: string } = { }
|
||||||
|
if(propsNode) {
|
||||||
|
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('fetched abt')
|
||||||
|
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sending non-abt props may fix QR scan fail if server expects */
|
||||||
|
const fetchProps = async() => {
|
||||||
|
const resultNode = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
xmlns: 'w',
|
||||||
|
type: 'get',
|
||||||
|
id: generateMessageTag(),
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ tag: 'props', attrs: { } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const propsNode = getBinaryNodeChild(resultNode, 'props')
|
||||||
|
|
||||||
|
let props: { [_: string]: string } = { }
|
||||||
|
if(propsNode) {
|
||||||
|
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('fetched props')
|
||||||
|
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* modify a chat -- mark unread, read etc.
|
||||||
|
* lastMessages must be sorted in reverse chronologically
|
||||||
|
* requires the last messages till the last message received; required for archive & unread
|
||||||
|
*/
|
||||||
|
const chatModify = (mod: ChatModification, jid: string) => {
|
||||||
|
const patch = chatModificationToAppPatch(mod, jid)
|
||||||
|
return appPatch(patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('CB:presence', handlePresenceUpdate)
|
||||||
|
ws.on('CB:chatstate', handlePresenceUpdate)
|
||||||
|
|
||||||
|
ws.on('CB:ib,,dirty', async(node: BinaryNode) => {
|
||||||
|
const { attrs } = getBinaryNodeChild(node, 'dirty')
|
||||||
|
const type = attrs.type
|
||||||
|
switch (type) {
|
||||||
|
case 'account_sync':
|
||||||
|
let { lastAccountSyncTimestamp } = authState.creds
|
||||||
|
if(lastAccountSyncTimestamp) {
|
||||||
|
await updateAccountSyncTimestamp(lastAccountSyncTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastAccountSyncTimestamp = +attrs.timestamp
|
||||||
|
ev.emit('creds.update', { lastAccountSyncTimestamp })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.info({ node }, 'received unknown sync')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('CB:notification,type:server_sync', (node: BinaryNode) => {
|
||||||
|
const update = getBinaryNodeChild(node, 'collection')
|
||||||
|
if(update) {
|
||||||
|
const name = update.attrs.name as WAPatchName
|
||||||
|
mutationMutex.mutex(
|
||||||
|
async() => {
|
||||||
|
await resyncAppState([name])
|
||||||
|
.catch(err => logger.error({ trace: err.stack, node }, 'failed to sync state'))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ev.on('connection.update', ({ connection }) => {
|
||||||
|
if(connection === 'open') {
|
||||||
|
sendPresenceUpdate('available')
|
||||||
|
fetchBlocklist()
|
||||||
|
fetchPrivacySettings()
|
||||||
|
fetchAbt()
|
||||||
|
fetchProps()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sock,
|
||||||
|
appPatch,
|
||||||
|
sendPresenceUpdate,
|
||||||
|
presenceSubscribe,
|
||||||
|
profilePictureUrl,
|
||||||
|
onWhatsApp,
|
||||||
|
fetchBlocklist,
|
||||||
|
fetchStatus,
|
||||||
|
updateProfilePicture,
|
||||||
|
updateBlockStatus,
|
||||||
|
getBusinessProfile,
|
||||||
|
resyncAppState,
|
||||||
|
chatModify,
|
||||||
|
resyncMainAppState,
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src/Socket/groups.ts
Normal file
217
src/Socket/groups.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { GroupMetadata, ParticipantAction, SocketConfig } from '../Types'
|
||||||
|
import { generateMessageID } from '../Utils'
|
||||||
|
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidEncode, jidNormalizedUser } from '../WABinary'
|
||||||
|
import { makeSocket } from './socket'
|
||||||
|
|
||||||
|
export const makeGroupsSocket = (config: SocketConfig) => {
|
||||||
|
const sock = makeSocket(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(id: string) => {
|
||||||
|
await groupQuery(
|
||||||
|
'@g.us',
|
||||||
|
'set',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag: 'leave',
|
||||||
|
attrs: { },
|
||||||
|
content: [
|
||||||
|
{ tag: 'group', attrs: { id } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
groupUpdateDescription: async(jid: string, description?: string) => {
|
||||||
|
const metadata = await groupMetadata(jid)
|
||||||
|
const prev = metadata.descId ?? null
|
||||||
|
|
||||||
|
await groupQuery(
|
||||||
|
jid,
|
||||||
|
'set',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag: 'description',
|
||||||
|
attrs: {
|
||||||
|
...(description ? { id: generateMessageID() } : { delete: 'true' }),
|
||||||
|
...(prev ? { prev } : {})
|
||||||
|
},
|
||||||
|
content: description ? [{ tag: 'body', attrs: {}, content: Buffer.from(description, 'utf-8') }] : null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
groupInviteCode: async(jid: string) => {
|
||||||
|
const result = await groupQuery(jid, 'get', [{ tag: 'invite', attrs: {} }])
|
||||||
|
const inviteNode = getBinaryNodeChild(result, 'invite')
|
||||||
|
return inviteNode.attrs.code
|
||||||
|
},
|
||||||
|
groupRevokeInvite: async(jid: string) => {
|
||||||
|
const result = await groupQuery(jid, 'set', [{ tag: 'invite', attrs: {} }])
|
||||||
|
const inviteNode = getBinaryNodeChild(result, 'invite')
|
||||||
|
return inviteNode.attrs.code
|
||||||
|
},
|
||||||
|
groupAcceptInvite: async(code: string) => {
|
||||||
|
const results = await groupQuery('@g.us', 'set', [{ tag: 'invite', attrs: { code } }])
|
||||||
|
const result = getBinaryNodeChild(results, 'group')
|
||||||
|
return result.attrs.jid
|
||||||
|
},
|
||||||
|
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: { } } ])
|
||||||
|
},
|
||||||
|
groupFetchAllParticipating: async() => {
|
||||||
|
const result = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: '@g.us',
|
||||||
|
xmlns: 'w:g2',
|
||||||
|
type: 'get',
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'participating',
|
||||||
|
attrs: { },
|
||||||
|
content: [
|
||||||
|
{ tag: 'participants', attrs: { } },
|
||||||
|
{ tag: 'description', attrs: { } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const data: { [_: string]: GroupMetadata } = { }
|
||||||
|
const groupsChild = getBinaryNodeChild(result, 'groups')
|
||||||
|
if(groupsChild) {
|
||||||
|
const groups = getBinaryNodeChildren(groupsChild, 'group')
|
||||||
|
for(const groupNode of groups) {
|
||||||
|
const meta = extractGroupMetadata({
|
||||||
|
tag: 'result',
|
||||||
|
attrs: { },
|
||||||
|
content: [groupNode]
|
||||||
|
})
|
||||||
|
data[meta.id] = meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export 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 groupId = group.attrs.id.includes('@') ? group.attrs.id : jidEncode(group.attrs.id, 'g.us')
|
||||||
|
const eph = getBinaryNodeChild(group, 'ephemeral')?.attrs.expiration
|
||||||
|
const metadata: GroupMetadata = {
|
||||||
|
id: groupId,
|
||||||
|
subject: group.attrs.subject,
|
||||||
|
creation: +group.attrs.creation,
|
||||||
|
owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined,
|
||||||
|
desc,
|
||||||
|
descId,
|
||||||
|
restrict: !!getBinaryNodeChild(group, 'locked'),
|
||||||
|
announce: !!getBinaryNodeChild(group, 'announcement'),
|
||||||
|
participants: getBinaryNodeChildren(group, 'participant').map(
|
||||||
|
({ attrs }) => {
|
||||||
|
return {
|
||||||
|
id: attrs.jid,
|
||||||
|
admin: attrs.type || null as any,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ephemeralDuration: eph ? +eph : undefined
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
13
src/Socket/index.ts
Normal file
13
src/Socket/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
|
||||||
|
import { SocketConfig } from '../Types'
|
||||||
|
import { makeMessagesRecvSocket as _makeSocket } from './messages-recv'
|
||||||
|
|
||||||
|
// export the last socket layer
|
||||||
|
const makeWASocket = (config: Partial<SocketConfig>) => (
|
||||||
|
_makeSocket({
|
||||||
|
...DEFAULT_CONNECTION_CONFIG,
|
||||||
|
...config
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export default makeWASocket
|
||||||
545
src/Socket/messages-recv.ts
Normal file
545
src/Socket/messages-recv.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { KEY_BUNDLE_TYPE } from '../Defaults'
|
||||||
|
import { Chat, GroupMetadata, MessageUserReceipt, ParticipantAction, SocketConfig, WAMessageStubType } from '../Types'
|
||||||
|
import { decodeMessageStanza, downloadAndProcessHistorySyncNotification, encodeBigEndian, generateSignalPubKey, toNumber, xmppPreKey, xmppSignedPreKey } from '../Utils'
|
||||||
|
import { areJidsSameUser, BinaryNode, BinaryNodeAttributes, getAllBinaryNodeChildren, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser } from '../WABinary'
|
||||||
|
import { makeChatsSocket } from './chats'
|
||||||
|
import { extractGroupMetadata } from './groups'
|
||||||
|
|
||||||
|
const STATUS_MAP: { [_: string]: proto.WebMessageInfo.WebMessageInfoStatus } = {
|
||||||
|
'played': proto.WebMessageInfo.WebMessageInfoStatus.PLAYED,
|
||||||
|
'read': proto.WebMessageInfo.WebMessageInfoStatus.READ,
|
||||||
|
'read-self': proto.WebMessageInfo.WebMessageInfoStatus.READ
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusFromReceiptType = (type: string | undefined) => {
|
||||||
|
const status = STATUS_MAP[type]
|
||||||
|
if(typeof type === 'undefined') {
|
||||||
|
return proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||||
|
const { logger } = config
|
||||||
|
const sock = makeChatsSocket(config)
|
||||||
|
const {
|
||||||
|
ev,
|
||||||
|
authState,
|
||||||
|
ws,
|
||||||
|
assertSessions,
|
||||||
|
assertingPreKeys,
|
||||||
|
sendNode,
|
||||||
|
relayMessage,
|
||||||
|
sendReceipt,
|
||||||
|
resyncMainAppState,
|
||||||
|
} = sock
|
||||||
|
|
||||||
|
const msgRetryMap = config.msgRetryCounterMap || { }
|
||||||
|
|
||||||
|
const historyCache = new Set<string>()
|
||||||
|
|
||||||
|
const sendMessageAck = async({ tag, attrs }: BinaryNode, extraAttrs: BinaryNodeAttributes) => {
|
||||||
|
const stanza: BinaryNode = {
|
||||||
|
tag: 'ack',
|
||||||
|
attrs: {
|
||||||
|
id: attrs.id,
|
||||||
|
to: attrs.from,
|
||||||
|
...extraAttrs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!!attrs.participant) {
|
||||||
|
stanza.attrs.participant = attrs.participant
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ recv: attrs, sent: stanza.attrs }, `sent "${tag}" ack`)
|
||||||
|
await sendNode(stanza)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRetryRequest = async(node: BinaryNode) => {
|
||||||
|
const msgId = node.attrs.id
|
||||||
|
const retryCount = msgRetryMap[msgId] || 1
|
||||||
|
if(retryCount >= 5) {
|
||||||
|
logger.debug({ retryCount, msgId }, 'reached retry limit, clearing')
|
||||||
|
delete msgRetryMap[msgId]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgRetryMap[msgId] = retryCount+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: msgId,
|
||||||
|
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);
|
||||||
|
|
||||||
|
(receipt.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(receipt)
|
||||||
|
|
||||||
|
logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const processMessage = async(message: proto.IWebMessageInfo, chatUpdate: Partial<Chat>) => {
|
||||||
|
const protocolMsg = message.message?.protocolMessage
|
||||||
|
if(protocolMsg) {
|
||||||
|
switch (protocolMsg.type) {
|
||||||
|
case proto.ProtocolMessage.ProtocolMessageType.HISTORY_SYNC_NOTIFICATION:
|
||||||
|
const histNotification = protocolMsg!.historySyncNotification
|
||||||
|
|
||||||
|
logger.info({ histNotification, id: message.key.id }, 'got history notification')
|
||||||
|
const { chats, contacts, messages, isLatest } = await downloadAndProcessHistorySyncNotification(histNotification, historyCache)
|
||||||
|
|
||||||
|
const meJid = authState.creds.me!.id
|
||||||
|
await sendNode({
|
||||||
|
tag: 'receipt',
|
||||||
|
attrs: {
|
||||||
|
id: message.key.id,
|
||||||
|
type: 'hist_sync',
|
||||||
|
to: jidEncode(jidDecode(meJid).user, 'c.us')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if(chats.length) {
|
||||||
|
ev.emit('chats.set', { chats, isLatest })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(messages.length) {
|
||||||
|
ev.emit('messages.set', { messages, isLatest })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(contacts.length) {
|
||||||
|
ev.emit('contacts.set', { contacts })
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE:
|
||||||
|
const keys = protocolMsg.appStateSyncKeyShare!.keys
|
||||||
|
if(keys?.length) {
|
||||||
|
let newAppStateSyncKeyId = ''
|
||||||
|
for(const { keyData, keyId } of keys) {
|
||||||
|
const strKeyId = Buffer.from(keyId.keyId!).toString('base64')
|
||||||
|
|
||||||
|
logger.info({ strKeyId }, 'injecting new app state sync key')
|
||||||
|
await authState.keys.set({ 'app-state-sync-key': { [strKeyId]: keyData } })
|
||||||
|
|
||||||
|
newAppStateSyncKeyId = strKeyId
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.emit('creds.update', { myAppStateKeyId: newAppStateSyncKeyId })
|
||||||
|
|
||||||
|
resyncMainAppState()
|
||||||
|
} else {
|
||||||
|
[
|
||||||
|
logger.info({ protocolMsg }, 'recv app state sync with 0 keys')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
|
||||||
|
ev.emit('messages.update', [
|
||||||
|
{
|
||||||
|
key: {
|
||||||
|
...message.key,
|
||||||
|
id: protocolMsg.key!.id
|
||||||
|
},
|
||||||
|
update: { message: null, messageStubType: WAMessageStubType.REVOKE, key: message.key }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
break
|
||||||
|
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
|
||||||
|
chatUpdate.ephemeralSettingTimestamp = toNumber(message.messageTimestamp)
|
||||||
|
chatUpdate.ephemeralExpiration = protocolMsg.ephemeralExpiration || null
|
||||||
|
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
|
||||||
|
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
|
||||||
|
if(participants.includes(meJid)) {
|
||||||
|
chatUpdate.readOnly = false
|
||||||
|
}
|
||||||
|
|
||||||
|
emitParticipantsUpdate('add')
|
||||||
|
break
|
||||||
|
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
|
||||||
|
const announceValue = message.messageStubParameters[0]
|
||||||
|
emitGroupUpdate({ announce: announceValue === 'true' || announceValue === 'on' })
|
||||||
|
break
|
||||||
|
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
|
||||||
|
const restrictValue = message.messageStubParameters[0]
|
||||||
|
emitGroupUpdate({ restrict: restrictValue === 'true' || restrictValue === 'on' })
|
||||||
|
break
|
||||||
|
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
|
||||||
|
chatUpdate.name = message.messageStubParameters[0]
|
||||||
|
emitGroupUpdate({ subject: chatUpdate.name })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processNotification = (node: BinaryNode): Partial<proto.IWebMessageInfo> => {
|
||||||
|
const result: Partial<proto.IWebMessageInfo> = { }
|
||||||
|
const [child] = getAllBinaryNodeChildren(node)
|
||||||
|
|
||||||
|
if(node.attrs.type === 'w:gp2') {
|
||||||
|
switch (child?.tag) {
|
||||||
|
case 'create':
|
||||||
|
const metadata = extractGroupMetadata(child)
|
||||||
|
|
||||||
|
result.messageStubType = WAMessageStubType.GROUP_CREATE
|
||||||
|
result.messageStubParameters = [metadata.subject]
|
||||||
|
result.key = { participant: metadata.owner }
|
||||||
|
|
||||||
|
ev.emit('chats.upsert', [{
|
||||||
|
id: metadata.id,
|
||||||
|
name: metadata.subject,
|
||||||
|
conversationTimestamp: metadata.creation,
|
||||||
|
}])
|
||||||
|
ev.emit('groups.upsert', [metadata])
|
||||||
|
break
|
||||||
|
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]
|
||||||
|
|
||||||
|
const participants = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid)
|
||||||
|
if(
|
||||||
|
participants.length === 1 &&
|
||||||
|
// if recv. "remove" message and sender removed themselves
|
||||||
|
// mark as left
|
||||||
|
areJidsSameUser(participants[0], node.attrs.participant) &&
|
||||||
|
child.tag === 'remove'
|
||||||
|
) {
|
||||||
|
result.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_LEAVE
|
||||||
|
}
|
||||||
|
|
||||||
|
result.messageStubParameters = participants
|
||||||
|
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') ? 'on' : 'off' ]
|
||||||
|
break
|
||||||
|
case 'locked':
|
||||||
|
case 'unlocked':
|
||||||
|
result.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
|
||||||
|
result.messageStubParameters = [ (child.tag === 'locked') ? 'on' : 'off' ]
|
||||||
|
break
|
||||||
|
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (child.tag) {
|
||||||
|
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 msg = await decodeMessageStanza(stanza, authState)
|
||||||
|
// message failed to decrypt
|
||||||
|
if(msg.messageStubType === proto.WebMessageInfo.WebMessageInfoStubType.CIPHERTEXT) {
|
||||||
|
logger.error(
|
||||||
|
{ msgId: msg.key.id, params: msg.messageStubParameters },
|
||||||
|
'failure in decrypting message'
|
||||||
|
)
|
||||||
|
await sendRetryRequest(stanza)
|
||||||
|
} else {
|
||||||
|
await sendMessageAck(stanza, { class: 'receipt' })
|
||||||
|
// no type in the receipt => message delivered
|
||||||
|
await sendReceipt(msg.key.remoteJid!, msg.key.participant, [msg.key.id!], undefined)
|
||||||
|
logger.debug({ msg: msg.key }, 'sent delivery receipt')
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.key.remoteJid = jidNormalizedUser(msg.key.remoteJid!)
|
||||||
|
ev.emit('messages.upsert', { messages: [msg], type: stanza.attrs.offline ? 'append' : 'notify' })
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('CB:call', async(node: BinaryNode) => {
|
||||||
|
logger.info({ node }, 'recv call')
|
||||||
|
|
||||||
|
const [child] = getAllBinaryNodeChildren(node)
|
||||||
|
if(!!child?.tag) {
|
||||||
|
await sendMessageAck(node, { class: 'call', type: child.tag })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendMessagesAgain = async(key: proto.IMessageKey, ids: string[]) => {
|
||||||
|
const msgs = await Promise.all(
|
||||||
|
ids.map(id => (
|
||||||
|
config.getMessage({ ...key, id })
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
|
const participant = key.participant || key.remoteJid
|
||||||
|
await assertSessions([participant], true)
|
||||||
|
|
||||||
|
if(isJidGroup(key.remoteJid)) {
|
||||||
|
await authState.keys.set({ 'sender-key-memory': { [key.remoteJid]: null } })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ participant }, 'forced new session for retry recp')
|
||||||
|
|
||||||
|
for(let i = 0; i < msgs.length;i++) {
|
||||||
|
if(msgs[i]) {
|
||||||
|
await relayMessage(key.remoteJid, msgs[i], {
|
||||||
|
messageId: ids[i],
|
||||||
|
participant
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.debug({ jid: key.remoteJid, id: ids[i] }, 'recv retry request, but message not available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReceipt = async(node: BinaryNode) => {
|
||||||
|
let shouldAck = true
|
||||||
|
|
||||||
|
const { attrs, content } = node
|
||||||
|
const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, authState.creds.me?.id)
|
||||||
|
const remoteJid = !isNodeFromMe ? attrs.from : attrs.recipient
|
||||||
|
const fromMe = !attrs.recipient
|
||||||
|
|
||||||
|
const ids = [attrs.id]
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
const items = getBinaryNodeChildren(content[0], 'item')
|
||||||
|
ids.push(...items.map(i => i.attrs.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const key: proto.IMessageKey = {
|
||||||
|
remoteJid,
|
||||||
|
id: '',
|
||||||
|
fromMe,
|
||||||
|
participant: attrs.participant
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getStatusFromReceiptType(attrs.type)
|
||||||
|
if(
|
||||||
|
typeof status !== 'undefined' &&
|
||||||
|
(
|
||||||
|
// basically, we only want to know when a message from us has been delivered to/read by the other person
|
||||||
|
// or another device of ours has read some messages
|
||||||
|
status > proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK ||
|
||||||
|
!isNodeFromMe
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if(isJidGroup(remoteJid)) {
|
||||||
|
const updateKey: keyof MessageUserReceipt = status === proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
|
||||||
|
ev.emit(
|
||||||
|
'message-receipt.update',
|
||||||
|
ids.map(id => ({
|
||||||
|
key: { ...key, id },
|
||||||
|
receipt: {
|
||||||
|
userJid: jidNormalizedUser(attrs.participant),
|
||||||
|
[updateKey]: +attrs.t
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ev.emit(
|
||||||
|
'messages.update',
|
||||||
|
ids.map(id => ({
|
||||||
|
key: { ...key, id },
|
||||||
|
update: { status }
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if(attrs.type === 'retry') {
|
||||||
|
// correctly set who is asking for the retry
|
||||||
|
key.participant = key.participant || attrs.from
|
||||||
|
if(key.fromMe) {
|
||||||
|
try {
|
||||||
|
logger.debug({ attrs }, 'recv retry request')
|
||||||
|
await sendMessagesAgain(key, ids)
|
||||||
|
} catch(error) {
|
||||||
|
logger.error({ key, ids, trace: error.stack }, 'error in sending message again')
|
||||||
|
shouldAck = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info({ attrs, key }, 'recv retry for not fromMe message')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(shouldAck) {
|
||||||
|
await sendMessageAck(node, { class: 'receipt', type: attrs.type })
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('CB:receipt', handleReceipt)
|
||||||
|
|
||||||
|
ws.on('CB:notification', async(node: BinaryNode) => {
|
||||||
|
await sendMessageAck(node, { class: 'notification', type: node.attrs.type })
|
||||||
|
|
||||||
|
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.key || {})
|
||||||
|
}
|
||||||
|
msg.messageTimestamp = +node.attrs.t
|
||||||
|
|
||||||
|
const fullMsg = proto.WebMessageInfo.fromObject(msg)
|
||||||
|
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ev.on('messages.upsert', async({ messages, type }) => {
|
||||||
|
if(type === 'notify' || type === 'append') {
|
||||||
|
const chat: Partial<Chat> = { id: messages[0].key.remoteJid }
|
||||||
|
const contactNameUpdates: { [_: string]: string } = { }
|
||||||
|
for(const msg of messages) {
|
||||||
|
if(!!msg.pushName) {
|
||||||
|
const jid = msg.key.fromMe ? jidNormalizedUser(authState.creds.me!.id) : (msg.key.participant || msg.key.remoteJid)
|
||||||
|
contactNameUpdates[jid] = msg.pushName
|
||||||
|
// update our pushname too
|
||||||
|
if(msg.key.fromMe && authState.creds.me?.name !== msg.pushName) {
|
||||||
|
ev.emit('creds.update', { me: { ...authState.creds.me!, name: msg.pushName! } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await 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 ])
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Object.keys(contactNameUpdates).length) {
|
||||||
|
ev.emit('contacts.update', Object.keys(contactNameUpdates).map(
|
||||||
|
id => ({ id, notify: contactNameUpdates[id] })
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...sock, processMessage, sendMessageAck, sendRetryRequest }
|
||||||
|
}
|
||||||
493
src/Socket/messages-send.ts
Normal file
493
src/Socket/messages-send.ts
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
|
||||||
|
import NodeCache from 'node-cache'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { WA_DEFAULT_EPHEMERAL } from '../Defaults'
|
||||||
|
import { AnyMessageContent, MediaConnInfo, MessageRelayOptions, MiscMessageGenerationOptions, SocketConfig } from '../Types'
|
||||||
|
import { encodeWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, generateMessageID, generateWAMessage, getWAUploadToServer, jidToSignalProtocolAddress, parseAndInjectE2ESessions } from '../Utils'
|
||||||
|
import { BinaryNode, BinaryNodeAttributes, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser, JidWithDevice, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary'
|
||||||
|
import { makeGroupsSocket } from './groups'
|
||||||
|
|
||||||
|
export const makeMessagesSocket = (config: SocketConfig) => {
|
||||||
|
const { logger } = config
|
||||||
|
const sock = makeGroupsSocket(config)
|
||||||
|
const {
|
||||||
|
ev,
|
||||||
|
authState,
|
||||||
|
query,
|
||||||
|
generateMessageTag,
|
||||||
|
sendNode,
|
||||||
|
groupMetadata,
|
||||||
|
groupToggleEphemeral
|
||||||
|
} = sock
|
||||||
|
|
||||||
|
const userDevicesCache = config.userDevicesCache || new NodeCache({
|
||||||
|
stdTTL: 300, // 5 minutes
|
||||||
|
useClones: false
|
||||||
|
})
|
||||||
|
let privacySettings: { [_: string]: string } | undefined
|
||||||
|
|
||||||
|
const fetchPrivacySettings = async(force: boolean = false) => {
|
||||||
|
if(!privacySettings || force) {
|
||||||
|
const { content } = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
xmlns: 'privacy',
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
type: 'get'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{ tag: 'privacy', attrs: { } }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
privacySettings = reduceBinaryNodeToDictionary(content[0] as BinaryNode, 'category')
|
||||||
|
}
|
||||||
|
|
||||||
|
return privacySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaConn: Promise<MediaConnInfo>
|
||||||
|
const refreshMediaConn = async(forceGet = false) => {
|
||||||
|
const 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generic send receipt function
|
||||||
|
* used for receipts of phone call, read, delivery etc.
|
||||||
|
* */
|
||||||
|
const sendReceipt = async(jid: string, participant: string | undefined, messageIds: string[], type: 'read' | 'read-self' | undefined) => {
|
||||||
|
const node: BinaryNode = {
|
||||||
|
tag: 'receipt',
|
||||||
|
attrs: {
|
||||||
|
id: messageIds[0],
|
||||||
|
t: Date.now().toString(),
|
||||||
|
to: jid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if(type) {
|
||||||
|
node.attrs.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
if(participant) {
|
||||||
|
node.attrs.participant = participant
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMessageIds = messageIds.slice(1)
|
||||||
|
if(remainingMessageIds.length) {
|
||||||
|
node.content = [
|
||||||
|
{
|
||||||
|
tag: 'list',
|
||||||
|
attrs: { },
|
||||||
|
content: remainingMessageIds.map(id => ({
|
||||||
|
tag: 'item',
|
||||||
|
attrs: { id }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ jid, messageIds, type }, 'sending receipt for messages')
|
||||||
|
await sendNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendReadReceipt = async(jid: string, participant: string | undefined, messageIds: string[]) => {
|
||||||
|
const privacySettings = await fetchPrivacySettings()
|
||||||
|
// based on privacy settings, we have to change the read type
|
||||||
|
const readType = privacySettings.readreceipts === 'all' ? 'read' : 'read-self'
|
||||||
|
return sendReceipt(jid, participant, messageIds, readType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => {
|
||||||
|
const deviceResults: JidWithDevice[] = []
|
||||||
|
|
||||||
|
const users: BinaryNode[] = []
|
||||||
|
jids = Array.from(new Set(jids))
|
||||||
|
for(let jid of jids) {
|
||||||
|
const user = jidDecode(jid).user
|
||||||
|
jid = jidNormalizedUser(jid)
|
||||||
|
if(userDevicesCache.has(user)) {
|
||||||
|
const devices: JidWithDevice[] = userDevicesCache.get(user)
|
||||||
|
deviceResults.push(...devices)
|
||||||
|
|
||||||
|
logger.trace({ user }, 'using cache for devices')
|
||||||
|
} else {
|
||||||
|
users.push({ 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)
|
||||||
|
const extracted = extractDeviceJids(result, authState.creds.me!.id, ignoreZeroDevices)
|
||||||
|
const deviceMap: { [_: string]: JidWithDevice[] } = {}
|
||||||
|
|
||||||
|
for(const item of extracted) {
|
||||||
|
deviceMap[item.user] = deviceMap[item.user] || []
|
||||||
|
deviceMap[item.user].push(item)
|
||||||
|
|
||||||
|
deviceResults.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const key in deviceMap) {
|
||||||
|
userDevicesCache.set(key, deviceMap[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return deviceResults
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertSessions = async(jids: string[], force: boolean) => {
|
||||||
|
let jidsRequiringFetch: string[] = []
|
||||||
|
if(force) {
|
||||||
|
jidsRequiringFetch = jids
|
||||||
|
} else {
|
||||||
|
const addrs = jids.map(jid => jidToSignalProtocolAddress(jid).toString())
|
||||||
|
const sessions = await authState.keys.get('session', addrs)
|
||||||
|
for(const jid of jids) {
|
||||||
|
const signalId = jidToSignalProtocolAddress(jid).toString()
|
||||||
|
if(!sessions[signalId]) {
|
||||||
|
jidsRequiringFetch.push(jid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(jidsRequiringFetch.length) {
|
||||||
|
logger.debug({ jidsRequiringFetch }, 'fetching sessions')
|
||||||
|
const result = await query({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
xmlns: 'encrypt',
|
||||||
|
type: 'get',
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'key',
|
||||||
|
attrs: { },
|
||||||
|
content: jidsRequiringFetch.map(
|
||||||
|
jid => ({
|
||||||
|
tag: 'user',
|
||||||
|
attrs: { jid, reason: 'identity' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
await parseAndInjectE2ESessions(result, authState)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const createParticipantNodes = async(jids: string[], bytes: Buffer) => {
|
||||||
|
await assertSessions(jids, false)
|
||||||
|
|
||||||
|
if(authState.keys.isInTransaction()) {
|
||||||
|
await authState.keys.prefetch(
|
||||||
|
'session',
|
||||||
|
jids.map(jid => jidToSignalProtocolAddress(jid).toString())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await Promise.all(
|
||||||
|
jids.map(
|
||||||
|
async jid => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayMessage = async(
|
||||||
|
jid: string,
|
||||||
|
message: proto.IMessage,
|
||||||
|
{ messageId: msgId, participant, additionalAttributes, cachedGroupMetadata }: MessageRelayOptions
|
||||||
|
) => {
|
||||||
|
const meId = authState.creds.me!.id
|
||||||
|
|
||||||
|
const { user, server } = jidDecode(jid)
|
||||||
|
const isGroup = server === 'g.us'
|
||||||
|
msgId = msgId || generateMessageID()
|
||||||
|
|
||||||
|
const encodedMsg = encodeWAMessage(message)
|
||||||
|
const participants: BinaryNode[] = []
|
||||||
|
|
||||||
|
const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net')
|
||||||
|
|
||||||
|
const binaryNodeContent: BinaryNode[] = []
|
||||||
|
|
||||||
|
const devices: JidWithDevice[] = []
|
||||||
|
if(participant) {
|
||||||
|
const { user, device } = jidDecode(participant)
|
||||||
|
devices.push({ user, device })
|
||||||
|
}
|
||||||
|
|
||||||
|
await authState.keys.transaction(
|
||||||
|
async() => {
|
||||||
|
if(isGroup) {
|
||||||
|
const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, meId, authState)
|
||||||
|
|
||||||
|
const [groupData, senderKeyMap] = await Promise.all([
|
||||||
|
(async() => {
|
||||||
|
let groupData = cachedGroupMetadata ? await cachedGroupMetadata(jid) : undefined
|
||||||
|
if(!groupData) {
|
||||||
|
groupData = await groupMetadata(jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupData
|
||||||
|
})(),
|
||||||
|
(async() => {
|
||||||
|
const result = await authState.keys.get('sender-key-memory', [jid])
|
||||||
|
return result[jid] || { }
|
||||||
|
})()
|
||||||
|
])
|
||||||
|
|
||||||
|
if(!participant) {
|
||||||
|
const participantsList = groupData.participants.map(p => p.id)
|
||||||
|
const additionalDevices = await getUSyncDevices(participantsList, false)
|
||||||
|
devices.push(...additionalDevices)
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderKeyJids: string[] = []
|
||||||
|
// ensure a connection is established with every device
|
||||||
|
for(const { user, device } of devices) {
|
||||||
|
const jid = jidEncode(user, 's.whatsapp.net', device)
|
||||||
|
if(!senderKeyMap[jid]) {
|
||||||
|
senderKeyJids.push(jid)
|
||||||
|
// store that this person has had the sender keys sent to them
|
||||||
|
senderKeyMap[jid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there are some participants with whom the session has not been established
|
||||||
|
// if there are, we re-send the senderkey
|
||||||
|
if(senderKeyJids.length) {
|
||||||
|
logger.debug({ senderKeyJids }, 'sending new sender key')
|
||||||
|
|
||||||
|
const encSenderKeyMsg = encodeWAMessage({
|
||||||
|
senderKeyDistributionMessage: {
|
||||||
|
axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey,
|
||||||
|
groupId: destinationJid
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
participants.push(
|
||||||
|
...(await createParticipantNodes(senderKeyJids, encSenderKeyMsg))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binaryNodeContent.push({
|
||||||
|
tag: 'enc',
|
||||||
|
attrs: { v: '2', type: 'skmsg' },
|
||||||
|
content: ciphertext
|
||||||
|
})
|
||||||
|
|
||||||
|
await authState.keys.set({ 'sender-key-memory': { [jid]: senderKeyMap } })
|
||||||
|
} else {
|
||||||
|
const { user: meUser } = jidDecode(meId)
|
||||||
|
|
||||||
|
const encodedMeMsg = encodeWAMessage({
|
||||||
|
deviceSentMessage: {
|
||||||
|
destinationJid,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if(!participant) {
|
||||||
|
devices.push({ user })
|
||||||
|
devices.push({ user: meUser })
|
||||||
|
|
||||||
|
const additionalDevices = await getUSyncDevices([ meId, jid ], true)
|
||||||
|
devices.push(...additionalDevices)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meJids: string[] = []
|
||||||
|
const otherJids: string[] = []
|
||||||
|
for(const { user, device } of devices) {
|
||||||
|
const jid = jidEncode(user, 's.whatsapp.net', device)
|
||||||
|
const isMe = user === meUser
|
||||||
|
if(isMe) {
|
||||||
|
meJids.push(jid)
|
||||||
|
} else {
|
||||||
|
otherJids.push(jid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [meNodes, otherNodes] = await Promise.all([
|
||||||
|
createParticipantNodes(meJids, encodedMeMsg),
|
||||||
|
createParticipantNodes(otherJids, encodedMsg)
|
||||||
|
])
|
||||||
|
participants.push(...meNodes)
|
||||||
|
participants.push(...otherNodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(participants.length) {
|
||||||
|
binaryNodeContent.push({
|
||||||
|
tag: 'participants',
|
||||||
|
attrs: { },
|
||||||
|
content: participants
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stanza: BinaryNode = {
|
||||||
|
tag: 'message',
|
||||||
|
attrs: {
|
||||||
|
id: msgId,
|
||||||
|
type: 'text',
|
||||||
|
to: destinationJid,
|
||||||
|
...(additionalAttributes || {})
|
||||||
|
},
|
||||||
|
content: binaryNodeContent
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldHaveIdentity = !!participants.find(
|
||||||
|
participant => (participant.content! as BinaryNode[]).find(n => n.attrs.type === 'pkmsg')
|
||||||
|
)
|
||||||
|
|
||||||
|
if(shouldHaveIdentity) {
|
||||||
|
(stanza.content as BinaryNode[]).push({
|
||||||
|
tag: 'device-identity',
|
||||||
|
attrs: { },
|
||||||
|
content: proto.ADVSignedDeviceIdentity.encode(authState.creds.account).finish()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug({ jid }, 'adding device identity')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ msgId }, `sending message to ${participants.length} devices`)
|
||||||
|
|
||||||
|
await sendNode(stanza)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return msgId
|
||||||
|
}
|
||||||
|
|
||||||
|
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sock,
|
||||||
|
assertSessions,
|
||||||
|
relayMessage,
|
||||||
|
sendReceipt,
|
||||||
|
sendReadReceipt,
|
||||||
|
refreshMediaConn,
|
||||||
|
waUploadToServer,
|
||||||
|
fetchPrivacySettings,
|
||||||
|
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,
|
||||||
|
{
|
||||||
|
logger,
|
||||||
|
userJid,
|
||||||
|
// multi-device does not have this yet
|
||||||
|
//getUrlInfo: generateUrlInfo,
|
||||||
|
upload: waUploadToServer,
|
||||||
|
mediaCache: config.mediaCache,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const isDeleteMsg = 'delete' in content && !!content.delete
|
||||||
|
const additionalAttributes: BinaryNodeAttributes = { }
|
||||||
|
// required for delete
|
||||||
|
if(isDeleteMsg) {
|
||||||
|
additionalAttributes.edit = '7'
|
||||||
|
}
|
||||||
|
|
||||||
|
await relayMessage(jid, fullMsg.message, { messageId: fullMsg.key.id!, additionalAttributes })
|
||||||
|
if(config.emitOwnEvents) {
|
||||||
|
process.nextTick(() => {
|
||||||
|
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
563
src/Socket/socket.ts
Normal file
563
src/Socket/socket.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import EventEmitter from 'events'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, DEFAULT_ORIGIN, KEY_BUNDLE_TYPE } from '../Defaults'
|
||||||
|
import { AuthenticationCreds, BaileysEventEmitter, DisconnectReason, SocketConfig } from '../Types'
|
||||||
|
import { addTransactionCapability, bindWaitForConnectionUpdate, configureSuccessfulPairing, Curve, encodeBigEndian, generateLoginNode, generateOrGetPreKeys, generateRegistrationNode, getPreKeys, makeNoiseHandler, printQRIfNecessaryListener, promiseTimeout, useSingleFileAuthState, xmppPreKey, xmppSignedPreKey } from '../Utils'
|
||||||
|
import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, getBinaryNodeChild, S_WHATSAPP_NET } from '../WABinary'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
printQRInTerminal,
|
||||||
|
defaultQueryTimeoutMs
|
||||||
|
}: 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)
|
||||||
|
const ev = new EventEmitter() as BaileysEventEmitter
|
||||||
|
/** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
|
||||||
|
const ephemeralKeyPair = Curve.generateKeyPair()
|
||||||
|
/** WA noise protocol wrapper */
|
||||||
|
const noise = makeNoiseHandler(ephemeralKeyPair)
|
||||||
|
let authState = initialAuthState
|
||||||
|
if(!authState) {
|
||||||
|
authState = useSingleFileAuthState('./auth-info-multi.json').state
|
||||||
|
|
||||||
|
logger.warn(`
|
||||||
|
Baileys just created a single file state for your credentials.
|
||||||
|
This will not be supported soon.
|
||||||
|
Please pass the credentials in the config itself
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { creds } = authState
|
||||||
|
|
||||||
|
let lastDateRecv: Date
|
||||||
|
let epoch = 0
|
||||||
|
let keepAliveReq: NodeJS.Timeout
|
||||||
|
let qrTimer: 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 = async(data: Buffer | Uint8Array) => {
|
||||||
|
if(ws.readyState !== ws.OPEN) {
|
||||||
|
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = noise.encodeFrame(data)
|
||||||
|
await sendPromise.call(ws, bytes) as Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** send a binary node */
|
||||||
|
const sendNode = (node: BinaryNode) => {
|
||||||
|
const 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 = defaultQueryTimeoutMs) => {
|
||||||
|
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
|
||||||
|
ws.off('error', onErr)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result as any
|
||||||
|
} finally {
|
||||||
|
ws.off(`TAG:${msgId}`, onRecv)
|
||||||
|
ws.off('close', onErr) // if the socket closes, you'll never receive the message
|
||||||
|
ws.off('error', onErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.creds, range)
|
||||||
|
|
||||||
|
const update: Partial<AuthenticationCreds> = {
|
||||||
|
nextPreKeyId: Math.max(lastPreKeyId+1, creds.nextPreKeyId),
|
||||||
|
firstUnuploadedPreKeyId: Math.max(creds.firstUnuploadedPreKeyId, lastPreKeyId+1)
|
||||||
|
}
|
||||||
|
if(!creds.serverHasPreKeys) {
|
||||||
|
update.serverHasPreKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
await authState.keys.set({ 'pre-key': newPreKeys })
|
||||||
|
|
||||||
|
const preKeys = await getPreKeys(authState.keys, preKeysRange[0], preKeysRange[0] + preKeysRange[1])
|
||||||
|
await execute(preKeys)
|
||||||
|
|
||||||
|
ev.emit('creds.update', update)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** generates and uploads a set of pre-keys */
|
||||||
|
const uploadPreKeys = async() => {
|
||||||
|
await assertingPreKeys(30, 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)
|
||||||
|
clearInterval(qrTimer)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ev.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() => {
|
||||||
|
const jid = authState.creds.me?.id
|
||||||
|
if(jid) {
|
||||||
|
await sendNode({
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
type: 'set',
|
||||||
|
id: generateMessageTag(),
|
||||||
|
xmlns: 'md'
|
||||||
|
},
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'remove-companion-device',
|
||||||
|
attrs: {
|
||||||
|
jid: jid,
|
||||||
|
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 iq: BinaryNode = {
|
||||||
|
tag: 'iq',
|
||||||
|
attrs: {
|
||||||
|
to: S_WHATSAPP_NET,
|
||||||
|
type: 'result',
|
||||||
|
id: stanza.attrs.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sendNode(iq)
|
||||||
|
|
||||||
|
const refs = ((stanza.content[0] as BinaryNode).content as BinaryNode[]).map(n => n.content as string)
|
||||||
|
const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64')
|
||||||
|
const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64')
|
||||||
|
const advB64 = creds.advSecretKey
|
||||||
|
|
||||||
|
let qrMs = 60_000 // time to let a QR live
|
||||||
|
const genPairQR = () => {
|
||||||
|
const ref = refs.shift()
|
||||||
|
if(!ref) {
|
||||||
|
end(new Boom('QR refs attempts ended', { statusCode: DisconnectReason.timedOut }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const qr = [ref, noiseKeyB64, identityKeyB64, advB64].join(',')
|
||||||
|
|
||||||
|
ev.emit('connection.update', { qr })
|
||||||
|
|
||||||
|
qrTimer = setTimeout(genPairQR, qrMs)
|
||||||
|
qrMs = 20_000 // shorter subsequent qrs
|
||||||
|
}
|
||||||
|
|
||||||
|
genPairQR()
|
||||||
|
})
|
||||||
|
// 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ jid: updatedCreds.me!.id }, 'registered connection, restart server')
|
||||||
|
|
||||||
|
ev.emit('creds.update', updatedCreds)
|
||||||
|
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')
|
||||||
|
|
||||||
|
logger.info('opened connection to WA')
|
||||||
|
clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try
|
||||||
|
|
||||||
|
ev.emit('connection.update', { connection: 'open' })
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('CB:ib,,offline', (node: BinaryNode) => {
|
||||||
|
const child = getBinaryNodeChild(node, 'offline')
|
||||||
|
const offlineCount = +child.attrs.count
|
||||||
|
|
||||||
|
logger.info(`got ${offlineCount} offline messages/notifications`)
|
||||||
|
|
||||||
|
ev.emit('connection.update', { receivedPendingNotifications: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('CB:stream:error', (node: BinaryNode) => {
|
||||||
|
logger.error({ error: node }, 'stream errored out')
|
||||||
|
|
||||||
|
const statusCode = +(node.attrs.code || DisconnectReason.restartRequired)
|
||||||
|
end(new Boom('Stream Errored', { statusCode, data: node }))
|
||||||
|
})
|
||||||
|
// stream fail, possible logout
|
||||||
|
ws.on('CB:failure', (node: BinaryNode) => {
|
||||||
|
const reason = +(node.attrs.reason || 500)
|
||||||
|
end(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }))
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('CB:ib,,downgrade_webclient', () => {
|
||||||
|
end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }))
|
||||||
|
})
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false, qr: undefined })
|
||||||
|
})
|
||||||
|
// update credentials when required
|
||||||
|
ev.on('creds.update', update => Object.assign(creds, update))
|
||||||
|
|
||||||
|
if(printQRInTerminal) {
|
||||||
|
printQRIfNecessaryListener(ev, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'md' as 'md',
|
||||||
|
ws,
|
||||||
|
ev,
|
||||||
|
authState: {
|
||||||
|
creds,
|
||||||
|
// add capability
|
||||||
|
keys: addTransactionCapability(authState.keys, logger)
|
||||||
|
},
|
||||||
|
get user() {
|
||||||
|
return authState.creds.me
|
||||||
|
},
|
||||||
|
assertingPreKeys,
|
||||||
|
generateMessageTag,
|
||||||
|
query,
|
||||||
|
waitForMessage,
|
||||||
|
waitForSocketOpen,
|
||||||
|
sendRawMessage,
|
||||||
|
sendNode,
|
||||||
|
logout,
|
||||||
|
end,
|
||||||
|
/** Waits for the connection to WA to reach a state */
|
||||||
|
waitForConnectionUpdate: bindWaitForConnectionUpdate(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Socket = ReturnType<typeof makeSocket>
|
||||||
2
src/Store/index.ts
Normal file
2
src/Store/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import makeInMemoryStore from './make-in-memory-store'
|
||||||
|
export { makeInMemoryStore }
|
||||||
381
src/Store/make-in-memory-store.ts
Normal file
381
src/Store/make-in-memory-store.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import type KeyedDB from '@adiwajshing/keyed-db'
|
||||||
|
import type { Comparable } from '@adiwajshing/keyed-db/lib/Types'
|
||||||
|
import type { Logger } from 'pino'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
|
||||||
|
import type makeLegacySocket from '../LegacySocket'
|
||||||
|
import type makeMDSocket from '../Socket'
|
||||||
|
import type { BaileysEventEmitter, Chat, ConnectionState, Contact, GroupMetadata, PresenceData, WAMessage, WAMessageCursor, WAMessageKey } from '../Types'
|
||||||
|
import { toNumber } from '../Utils'
|
||||||
|
import { jidNormalizedUser } from '../WABinary'
|
||||||
|
import makeOrderedDictionary from './make-ordered-dictionary'
|
||||||
|
|
||||||
|
type LegacyWASocket = ReturnType<typeof makeLegacySocket>
|
||||||
|
type AnyWASocket = ReturnType<typeof makeMDSocket>
|
||||||
|
|
||||||
|
export const waChatKey = (pin: boolean) => ({
|
||||||
|
key: (c: Chat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive ? '0' : '1') + c.conversationTimestamp.toString(16).padStart(8, '0') + c.id,
|
||||||
|
compare: (k1: string, k2: string) => k2.localeCompare (k1)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const waMessageID = (m: WAMessage) => m.key.id
|
||||||
|
|
||||||
|
export type BaileysInMemoryStoreConfig = {
|
||||||
|
chatKey?: Comparable<Chat, string>
|
||||||
|
logger?: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeMessagesDictionary = () => makeOrderedDictionary(waMessageID)
|
||||||
|
|
||||||
|
export default (
|
||||||
|
{ logger, chatKey }: BaileysInMemoryStoreConfig
|
||||||
|
) => {
|
||||||
|
logger = logger || DEFAULT_CONNECTION_CONFIG.logger.child({ stream: 'in-mem-store' })
|
||||||
|
chatKey = chatKey || waChatKey(true)
|
||||||
|
const KeyedDB = require('@adiwajshing/keyed-db').default as new (...args: any[]) => KeyedDB<Chat, string>
|
||||||
|
|
||||||
|
const chats = new KeyedDB(chatKey, c => c.id)
|
||||||
|
const messages: { [_: string]: ReturnType<typeof makeMessagesDictionary> } = { }
|
||||||
|
const contacts: { [_: string]: Contact } = { }
|
||||||
|
const groupMetadata: { [_: string]: GroupMetadata } = { }
|
||||||
|
const presences: { [id: string]: { [participant: string]: PresenceData } } = { }
|
||||||
|
const state: ConnectionState = { connection: 'close' }
|
||||||
|
|
||||||
|
const assertMessageList = (jid: string) => {
|
||||||
|
if(!messages[jid]) {
|
||||||
|
messages[jid] = makeMessagesDictionary()
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages[jid]
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactsUpsert = (newContacts: Contact[]) => {
|
||||||
|
const oldContacts = new Set(Object.keys(contacts))
|
||||||
|
for(const contact of newContacts) {
|
||||||
|
oldContacts.delete(contact.id)
|
||||||
|
contacts[contact.id] = Object.assign(
|
||||||
|
contacts[contact.id] || {},
|
||||||
|
contact
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldContacts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* binds to a BaileysEventEmitter.
|
||||||
|
* It listens to all events and constructs a state that you can query accurate data from.
|
||||||
|
* Eg. can use the store to fetch chats, contacts, messages etc.
|
||||||
|
* @param ev typically the event emitter from the socket connection
|
||||||
|
*/
|
||||||
|
const bind = (ev: BaileysEventEmitter) => {
|
||||||
|
ev.on('connection.update', update => {
|
||||||
|
Object.assign(state, update)
|
||||||
|
})
|
||||||
|
ev.on('chats.set', ({ chats: newChats, isLatest }) => {
|
||||||
|
if(isLatest) {
|
||||||
|
chats.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatsAdded = chats.insertIfAbsent(...newChats).length
|
||||||
|
logger.debug({ chatsAdded }, 'synced chats')
|
||||||
|
})
|
||||||
|
ev.on('contacts.set', ({ contacts: newContacts }) => {
|
||||||
|
const oldContacts = contactsUpsert(newContacts)
|
||||||
|
for(const jid of oldContacts) {
|
||||||
|
delete contacts[jid]
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ deletedContacts: oldContacts.size, newContacts }, 'synced contacts')
|
||||||
|
})
|
||||||
|
ev.on('messages.set', ({ messages: newMessages, isLatest }) => {
|
||||||
|
if(isLatest) {
|
||||||
|
for(const id in messages) {
|
||||||
|
delete messages[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const msg of newMessages) {
|
||||||
|
const jid = msg.key.remoteJid!
|
||||||
|
const list = assertMessageList(jid)
|
||||||
|
list.upsert(msg, 'prepend')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({ messages: newMessages.length }, 'synced messages')
|
||||||
|
})
|
||||||
|
ev.on('contacts.update', updates => {
|
||||||
|
for(const update of updates) {
|
||||||
|
if(contacts[update.id!]) {
|
||||||
|
Object.assign(contacts[update.id!], update)
|
||||||
|
} else {
|
||||||
|
logger.debug({ update }, 'got update for non-existant contact')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ev.on('chats.upsert', newChats => {
|
||||||
|
chats.upsert(...newChats)
|
||||||
|
})
|
||||||
|
ev.on('chats.update', updates => {
|
||||||
|
for(let update of updates) {
|
||||||
|
const result = chats.update(update.id!, chat => {
|
||||||
|
if(update.unreadCount > 0) {
|
||||||
|
update = { ...update }
|
||||||
|
update.unreadCount = chat.unreadCount + update.unreadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(chat, update)
|
||||||
|
})
|
||||||
|
if(!result) {
|
||||||
|
logger.debug({ update }, 'got update for non-existant chat')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ev.on('presence.update', ({ id, presences: update }) => {
|
||||||
|
presences[id] = presences[id] || {}
|
||||||
|
Object.assign(presences[id], update)
|
||||||
|
})
|
||||||
|
ev.on('chats.delete', deletions => {
|
||||||
|
for(const item of deletions) {
|
||||||
|
chats.deleteById(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ev.on('messages.upsert', ({ messages: newMessages, type }) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'append':
|
||||||
|
case 'notify':
|
||||||
|
for(const msg of newMessages) {
|
||||||
|
const jid = jidNormalizedUser(msg.key.remoteJid!)
|
||||||
|
const list = assertMessageList(jid)
|
||||||
|
list.upsert(msg, 'append')
|
||||||
|
|
||||||
|
if(type === 'notify') {
|
||||||
|
if(!chats.get(jid)) {
|
||||||
|
ev.emit('chats.upsert', [
|
||||||
|
{
|
||||||
|
id: jid,
|
||||||
|
conversationTimestamp: toNumber(msg.messageTimestamp),
|
||||||
|
unreadCount: 1
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ev.on('messages.update', updates => {
|
||||||
|
for(const { update, key } of updates) {
|
||||||
|
const list = assertMessageList(key.remoteJid)
|
||||||
|
const result = list.updateAssign(key.id, update)
|
||||||
|
if(!result) {
|
||||||
|
logger.debug({ update }, 'got update for non-existent message')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ev.on('messages.delete', item => {
|
||||||
|
if('all' in item) {
|
||||||
|
const list = messages[item.jid]
|
||||||
|
list?.clear()
|
||||||
|
} else {
|
||||||
|
const jid = item.keys[0].remoteJid
|
||||||
|
const list = messages[jid]
|
||||||
|
if(list) {
|
||||||
|
const idSet = new Set(item.keys.map(k => k.id))
|
||||||
|
list.filter(m => !idSet.has(m.key.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ev.on('groups.update', updates => {
|
||||||
|
for(const update of updates) {
|
||||||
|
if(groupMetadata[update.id]) {
|
||||||
|
Object.assign(groupMetadata[update.id!], update)
|
||||||
|
} else {
|
||||||
|
logger.debug({ update }, 'got update for non-existant group metadata')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ev.on('group-participants.update', ({ id, participants, action }) => {
|
||||||
|
const metadata = groupMetadata[id]
|
||||||
|
if(metadata) {
|
||||||
|
switch (action) {
|
||||||
|
case 'add':
|
||||||
|
metadata.participants.push(...participants.map(id => ({ id, isAdmin: false, isSuperAdmin: false })))
|
||||||
|
break
|
||||||
|
case 'demote':
|
||||||
|
case 'promote':
|
||||||
|
for(const participant of metadata.participants) {
|
||||||
|
if(participants.includes(participant.id)) {
|
||||||
|
participant.isAdmin = action === 'promote'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case 'remove':
|
||||||
|
metadata.participants = metadata.participants.filter(p => !participants.includes(p.id))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ev.on('message-receipt.update', updates => {
|
||||||
|
for(const { key, receipt } of updates) {
|
||||||
|
const obj = messages[key.remoteJid!]
|
||||||
|
const msg = obj?.get(key.id)
|
||||||
|
if(msg) {
|
||||||
|
msg.userReceipt = msg.userReceipt || []
|
||||||
|
const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid)
|
||||||
|
if(recp) {
|
||||||
|
Object.assign(recp, receipt)
|
||||||
|
} else {
|
||||||
|
msg.userReceipt.push(receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toJSON = () => ({
|
||||||
|
chats,
|
||||||
|
contacts,
|
||||||
|
messages
|
||||||
|
})
|
||||||
|
|
||||||
|
const fromJSON = (json: { chats: Chat[], contacts: { [id: string]: Contact }, messages: { [id: string]: WAMessage[] } }) => {
|
||||||
|
chats.upsert(...json.chats)
|
||||||
|
contactsUpsert(Object.values(contacts))
|
||||||
|
for(const jid in json.messages) {
|
||||||
|
const list = assertMessageList(jid)
|
||||||
|
for(const msg of json.messages[jid]) {
|
||||||
|
list.upsert(proto.WebMessageInfo.fromObject(msg), 'append')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
chats,
|
||||||
|
contacts,
|
||||||
|
messages,
|
||||||
|
groupMetadata,
|
||||||
|
state,
|
||||||
|
presences,
|
||||||
|
bind,
|
||||||
|
/** loads messages from the store, if not found -- uses the legacy connection */
|
||||||
|
loadMessages: async(jid: string, count: number, cursor: WAMessageCursor, sock: LegacyWASocket | undefined) => {
|
||||||
|
const list = assertMessageList(jid)
|
||||||
|
const retrieve = async(count: number, cursor: WAMessageCursor) => {
|
||||||
|
const result = await sock?.fetchMessagesFromWA(jid, count, cursor)
|
||||||
|
return result || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = !cursor || 'before' in cursor ? 'before' : 'after'
|
||||||
|
const cursorKey = !!cursor ? ('before' in cursor ? cursor.before : cursor.after) : undefined
|
||||||
|
const cursorValue = cursorKey ? list.get(cursorKey.id) : undefined
|
||||||
|
|
||||||
|
let messages: WAMessage[]
|
||||||
|
if(list && mode === 'before' && (!cursorKey || cursorValue)) {
|
||||||
|
if(cursorValue) {
|
||||||
|
const msgIdx = list.array.findIndex(m => m.key.id === cursorKey.id)
|
||||||
|
messages = list.array.slice(0, msgIdx)
|
||||||
|
} else {
|
||||||
|
messages = list.array
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = count - messages.length
|
||||||
|
if(diff < 0) {
|
||||||
|
messages = messages.slice(-count) // get the last X messages
|
||||||
|
} else if(diff > 0) {
|
||||||
|
const [fMessage] = messages
|
||||||
|
const cursor = { before: fMessage?.key || cursorKey }
|
||||||
|
const extra = await retrieve (diff, cursor)
|
||||||
|
// add to DB
|
||||||
|
for(let i = extra.length-1; i >= 0;i--) {
|
||||||
|
list.upsert(extra[i], 'prepend')
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.splice(0, 0, ...extra)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messages = await retrieve(count, cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
},
|
||||||
|
loadMessage: async(jid: string, id: string, sock: LegacyWASocket | undefined) => {
|
||||||
|
let message = messages[jid]?.get(id)
|
||||||
|
if(!message) {
|
||||||
|
message = await sock?.loadMessageFromWA(jid, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
mostRecentMessage: async(jid: string, sock: LegacyWASocket | undefined) => {
|
||||||
|
let message = messages[jid]?.array.slice(-1)[0]
|
||||||
|
if(!message) {
|
||||||
|
const [result] = await sock?.fetchMessagesFromWA(jid, 1, undefined)
|
||||||
|
message = result
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
fetchImageUrl: async(jid: string, sock: AnyWASocket | undefined) => {
|
||||||
|
const contact = contacts[jid]
|
||||||
|
if(!contact) {
|
||||||
|
return sock?.profilePictureUrl(jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof contact.imgUrl === 'undefined') {
|
||||||
|
contact.imgUrl = await sock?.profilePictureUrl(jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact.imgUrl
|
||||||
|
},
|
||||||
|
fetchGroupMetadata: async(jid: string, sock: AnyWASocket | undefined) => {
|
||||||
|
if(!groupMetadata[jid]) {
|
||||||
|
groupMetadata[jid] = await sock?.groupMetadata(jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupMetadata[jid]
|
||||||
|
},
|
||||||
|
fetchBroadcastListInfo: async(jid: string, sock: LegacyWASocket | undefined) => {
|
||||||
|
if(!groupMetadata[jid]) {
|
||||||
|
groupMetadata[jid] = await sock?.getBroadcastListInfo(jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupMetadata[jid]
|
||||||
|
},
|
||||||
|
fetchMessageReceipts: async({ remoteJid, id }: WAMessageKey, sock: LegacyWASocket | undefined) => {
|
||||||
|
const list = messages[remoteJid]
|
||||||
|
const msg = list?.get(id)
|
||||||
|
let receipts = msg.userReceipt
|
||||||
|
if(!receipts) {
|
||||||
|
receipts = await sock?.messageInfo(remoteJid, id)
|
||||||
|
if(msg) {
|
||||||
|
msg.userReceipt = receipts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return receipts
|
||||||
|
},
|
||||||
|
toJSON,
|
||||||
|
fromJSON,
|
||||||
|
writeToFile: (path: string) => {
|
||||||
|
// require fs here so that in case "fs" is not available -- the app does not crash
|
||||||
|
const { writeFileSync } = require('fs')
|
||||||
|
writeFileSync(path, JSON.stringify(toJSON()))
|
||||||
|
},
|
||||||
|
readFromFile: (path: string) => {
|
||||||
|
// require fs here so that in case "fs" is not available -- the app does not crash
|
||||||
|
const { readFileSync, existsSync } = require('fs')
|
||||||
|
if(existsSync(path)) {
|
||||||
|
logger.debug({ path }, 'reading from file')
|
||||||
|
const jsonStr = readFileSync(path, { encoding: 'utf-8' })
|
||||||
|
const json = JSON.parse(jsonStr)
|
||||||
|
fromJSON(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Store/make-ordered-dictionary.ts
Normal file
86
src/Store/make-ordered-dictionary.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
function makeOrderedDictionary<T>(idGetter: (item: T) => string) {
|
||||||
|
const array: T[] = []
|
||||||
|
const dict: { [_: string]: T } = { }
|
||||||
|
|
||||||
|
const get = (id: string) => dict[id]
|
||||||
|
|
||||||
|
const update = (item: T) => {
|
||||||
|
const id = idGetter(item)
|
||||||
|
const idx = array.findIndex(i => idGetter(i) === id)
|
||||||
|
if(idx >= 0) {
|
||||||
|
array[idx] = item
|
||||||
|
dict[id] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsert = (item: T, mode: 'append' | 'prepend') => {
|
||||||
|
const id = idGetter(item)
|
||||||
|
if(get(id)) {
|
||||||
|
update(item)
|
||||||
|
} else {
|
||||||
|
if(mode === 'append') {
|
||||||
|
array.push(item)
|
||||||
|
} else {
|
||||||
|
array.splice(0, 0, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
dict[id] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (item: T) => {
|
||||||
|
const id = idGetter(item)
|
||||||
|
const idx = array.findIndex(i => idGetter(i) === id)
|
||||||
|
if(idx >= 0) {
|
||||||
|
array.splice(idx, 1)
|
||||||
|
delete dict[id]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
array,
|
||||||
|
get,
|
||||||
|
upsert,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
updateAssign: (id: string, update: Partial<T>) => {
|
||||||
|
const item = get(id)
|
||||||
|
if(item) {
|
||||||
|
Object.assign(item, update)
|
||||||
|
delete dict[id]
|
||||||
|
dict[idGetter(item)] = item
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
array.splice(0, array.length)
|
||||||
|
Object.keys(dict).forEach(key => {
|
||||||
|
delete dict[key]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
filter: (contain: (item: T) => boolean) => {
|
||||||
|
let i = 0
|
||||||
|
while(i < array.length) {
|
||||||
|
if(!contain(array[i])) {
|
||||||
|
delete dict[idGetter(array[i])]
|
||||||
|
array.splice(i, 1)
|
||||||
|
} else {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toJSON: () => array,
|
||||||
|
fromJSON: (newItems: T[]) => {
|
||||||
|
array.splice(0, array.length, ...newItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default makeOrderedDictionary
|
||||||
@@ -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), `expected: ${chat.t} > ${(unixTimestampSeconds()-5)}`)
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
|
|
||||||
describe(name, () => {
|
|
||||||
const conn = new WAConnection()
|
|
||||||
conn.connectOptions.maxIdleTimeMs = 30_000
|
|
||||||
conn.logger.level = 'debug'
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
const file = './auth_info.json'
|
|
||||||
await conn.loadAuthInfo(file).connect()
|
|
||||||
await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t'))
|
|
||||||
})
|
|
||||||
after(() => conn.close())
|
|
||||||
|
|
||||||
afterEach (() => assertChatDBIntegrity (conn))
|
|
||||||
|
|
||||||
func(conn)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
export const assertChatDBIntegrity = (conn: WAConnection) => {
|
|
||||||
conn.chats.all ().forEach (chat => (
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
[...chat.messages.all()].sort ((m1, m2) => waMessageKey.compare(waMessageKey.key(m1), waMessageKey.key(m2))),
|
|
||||||
chat.messages.all()
|
|
||||||
)
|
|
||||||
))
|
|
||||||
conn.chats.all ().forEach (chat => (
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
chat.messages.all().filter (m => chat.messages.all().filter(m1 => m1.key.id === m.key.id).length > 1),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { strict as assert } from 'assert'
|
|
||||||
import Encoder from '../Binary/Encoder'
|
|
||||||
import Decoder from '../Binary/Decoder'
|
|
||||||
|
|
||||||
describe('Binary Coding Tests', () => {
|
|
||||||
const testVectors: [string, Object][] = [
|
|
||||||
[
|
|
||||||
'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305',
|
|
||||||
[
|
|
||||||
'action',
|
|
||||||
{ last: 'true', add: 'before' },
|
|
||||||
[
|
|
||||||
[
|
|
||||||
'message',
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB009675E7ED37AABF2' },
|
|
||||||
message: { conversation: '*piano room timings are:*\n 6:00AM-12:00AM' },
|
|
||||||
messageTimestamp: '1584004403',
|
|
||||||
status: 'DELIVERY_ACK',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'message',
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
key: {
|
|
||||||
remoteJid: '917529871117@s.whatsapp.net',
|
|
||||||
fromMe: false,
|
|
||||||
id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD',
|
|
||||||
},
|
|
||||||
messageTimestamp: '1584004413',
|
|
||||||
messageStubType: 'REVOKE',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'message',
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB003C7B539AFD09753' },
|
|
||||||
message: {
|
|
||||||
conversation:
|
|
||||||
"Sorry fren, I couldn't understand 'Libra'. Type 'help' to know what all I can do",
|
|
||||||
},
|
|
||||||
messageTimestamp: '1584004417',
|
|
||||||
status: 'DELIVERY_ACK',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'message',
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
key: {
|
|
||||||
remoteJid: '917529871117@s.whatsapp.net',
|
|
||||||
fromMe: false,
|
|
||||||
id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8',
|
|
||||||
},
|
|
||||||
message: { conversation: 'library' },
|
|
||||||
messageTimestamp: '1584004418',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'f8063f2dfafc0831323334353637385027fc0431323334f801f80228fc0701020304050607',
|
|
||||||
[
|
|
||||||
'picture',
|
|
||||||
{jid: '12345678@c.us', id: '1234'},
|
|
||||||
[['image', null, Buffer.from([1,2,3,4,5,6,7])]]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
const encoder = new Encoder()
|
|
||||||
const decoder = new Decoder()
|
|
||||||
|
|
||||||
it('should decode strings', () => {
|
|
||||||
testVectors.forEach(pair => {
|
|
||||||
const buff = Buffer.from(pair[0], 'hex')
|
|
||||||
const decoded = decoder.read(buff)
|
|
||||||
//console.log((decoded[2][0][2]))
|
|
||||||
assert.deepStrictEqual(decoded, pair[1])
|
|
||||||
|
|
||||||
const encoded = encoder.write(decoded)
|
|
||||||
assert.deepStrictEqual(encoded, buff)
|
|
||||||
})
|
|
||||||
console.log('all coding tests passed')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
import * as assert from 'assert'
|
|
||||||
import {WAConnection} from '../WAConnection'
|
|
||||||
import { AuthenticationCredentialsBase64, BaileysError, ReconnectMode, DisconnectReason, WAChat, WAContact } from '../WAConnection/Constants'
|
|
||||||
import { delay } from '../WAConnection/Utils'
|
|
||||||
import { assertChatDBIntegrity, makeConnection, testJid } from './Common'
|
|
||||||
|
|
||||||
describe('QR Generation', () => {
|
|
||||||
it('should generate QR', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.connectOptions.maxRetries = 0
|
|
||||||
|
|
||||||
let calledQR = 0
|
|
||||||
conn.removeAllListeners ('qr')
|
|
||||||
conn.on ('qr', () => calledQR += 1)
|
|
||||||
|
|
||||||
await conn.connect()
|
|
||||||
.then (() => assert.fail('should not have succeeded'))
|
|
||||||
.catch (error => {})
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
Object.keys(conn.eventNames()).filter(key => key.startsWith('TAG:')),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
assert.ok(calledQR >= 2, 'QR not called')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Test Connect', () => {
|
|
||||||
let auth: AuthenticationCredentialsBase64
|
|
||||||
it('should connect', async () => {
|
|
||||||
console.log('please be ready to scan with your phone')
|
|
||||||
|
|
||||||
const conn = makeConnection ()
|
|
||||||
|
|
||||||
let credentialsUpdateCalled = false
|
|
||||||
conn.on ('credentials-updated', () => credentialsUpdateCalled = true)
|
|
||||||
|
|
||||||
await conn.connect ()
|
|
||||||
assert.ok(conn.user?.jid)
|
|
||||||
assert.ok(conn.user?.phone)
|
|
||||||
assert.ok (conn.user?.imgUrl || conn.user.imgUrl === '')
|
|
||||||
assert.ok (credentialsUpdateCalled)
|
|
||||||
|
|
||||||
assertChatDBIntegrity (conn)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
auth = conn.base64EncodedAuthInfo()
|
|
||||||
})
|
|
||||||
it('should restore session', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
|
|
||||||
let credentialsUpdateCalled = false
|
|
||||||
conn.on ('credentials-updated', () => credentialsUpdateCalled = true)
|
|
||||||
|
|
||||||
await conn.loadAuthInfo (auth).connect ()
|
|
||||||
assert.ok(conn.user)
|
|
||||||
assert.ok(conn.user.jid)
|
|
||||||
assert.ok (credentialsUpdateCalled)
|
|
||||||
|
|
||||||
assertChatDBIntegrity (conn)
|
|
||||||
await conn.logout()
|
|
||||||
conn.loadAuthInfo(auth)
|
|
||||||
|
|
||||||
await conn.connect()
|
|
||||||
.then (() => assert.fail('should not have reconnected'))
|
|
||||||
.catch (err => {
|
|
||||||
assert.ok (err instanceof BaileysError)
|
|
||||||
assert.ok ((err as BaileysError).status >= 400)
|
|
||||||
})
|
|
||||||
conn.close()
|
|
||||||
})
|
|
||||||
it ('should disconnect & reconnect phone', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.logger.level = 'debug'
|
|
||||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
|
||||||
assert.strictEqual (conn.phoneConnected, true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const waitForEvent = expect => new Promise (resolve => {
|
|
||||||
conn.on ('connection-phone-change', ({connected}) => {
|
|
||||||
if (connected === expect) {
|
|
||||||
conn.removeAllListeners ('connection-phone-change')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log ('disconnect your phone from the internet')
|
|
||||||
await delay (10_000)
|
|
||||||
console.log ('phone should be disconnected now, testing...')
|
|
||||||
|
|
||||||
const messagesPromise = Promise.all (
|
|
||||||
[
|
|
||||||
conn.loadMessages (testJid, 50),
|
|
||||||
conn.getStatus (testJid),
|
|
||||||
conn.getProfilePicture (testJid).catch (() => '')
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
await waitForEvent (false)
|
|
||||||
|
|
||||||
console.log ('reconnect your phone to the internet')
|
|
||||||
await waitForEvent (true)
|
|
||||||
|
|
||||||
console.log ('reconnected successfully')
|
|
||||||
|
|
||||||
const final = await messagesPromise
|
|
||||||
assert.ok (final)
|
|
||||||
} finally {
|
|
||||||
conn.close ()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
describe ('Reconnects', () => {
|
|
||||||
const verifyConnectionOpen = async (conn: WAConnection) => {
|
|
||||||
assert.ok (conn.user.jid)
|
|
||||||
let failed = false
|
|
||||||
// check that the connection stays open
|
|
||||||
conn.on ('close', ({reason}) => {
|
|
||||||
if(reason !== DisconnectReason.intentional) failed = true
|
|
||||||
})
|
|
||||||
await delay (60*1000)
|
|
||||||
|
|
||||||
const status = await conn.getStatus ()
|
|
||||||
assert.ok (status)
|
|
||||||
assert.ok (!conn['debounceTimeout']) // this should be null
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
|
|
||||||
if (failed) assert.fail ('should not have closed again')
|
|
||||||
}
|
|
||||||
it('should dispose correctly on bad_session', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.autoReconnect = ReconnectMode.onAllErrors
|
|
||||||
conn.loadAuthInfo ('./auth_info.json')
|
|
||||||
|
|
||||||
let gotClose0 = false
|
|
||||||
let gotClose1 = false
|
|
||||||
|
|
||||||
conn.on ('ws-close', ({ reason }) => {
|
|
||||||
gotClose0 = true
|
|
||||||
})
|
|
||||||
conn.on ('close', ({ reason }) => {
|
|
||||||
if (reason === DisconnectReason.badSession) gotClose1 = true
|
|
||||||
})
|
|
||||||
setTimeout (() => conn['conn'].emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
|
|
||||||
await conn.connect ()
|
|
||||||
|
|
||||||
setTimeout (() => conn['conn'].emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
|
|
||||||
|
|
||||||
await new Promise (resolve => {
|
|
||||||
conn.on ('open', resolve)
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.ok (gotClose0, 'did not receive bad_session close initially')
|
|
||||||
assert.ok (gotClose1, 'did not receive bad_session close')
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
})
|
|
||||||
/**
|
|
||||||
* the idea is to test closing the connection at multiple points in the connection
|
|
||||||
* and see if the library cleans up resources correctly
|
|
||||||
*/
|
|
||||||
it('should cleanup correctly', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.autoReconnect = ReconnectMode.onAllErrors
|
|
||||||
conn.loadAuthInfo ('./auth_info.json')
|
|
||||||
|
|
||||||
let timeout = 0.1
|
|
||||||
while (true) {
|
|
||||||
let tmout = setTimeout (() => conn.close(), timeout*1000)
|
|
||||||
try {
|
|
||||||
await conn.connect ()
|
|
||||||
clearTimeout (tmout)
|
|
||||||
break
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
}
|
|
||||||
// exponentially increase the timeout disconnect
|
|
||||||
timeout *= 2
|
|
||||||
}
|
|
||||||
await verifyConnectionOpen (conn)
|
|
||||||
})
|
|
||||||
/**
|
|
||||||
* the idea is to test closing the connection at multiple points in the connection
|
|
||||||
* and see if the library cleans up resources correctly
|
|
||||||
*/
|
|
||||||
it('should disrupt connect loop', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
|
|
||||||
conn.autoReconnect = ReconnectMode.onAllErrors
|
|
||||||
conn.loadAuthInfo ('./auth_info.json')
|
|
||||||
|
|
||||||
let timeout = 1000
|
|
||||||
let tmout
|
|
||||||
const endConnection = async () => {
|
|
||||||
while (!conn['conn']) {
|
|
||||||
await delay(100)
|
|
||||||
}
|
|
||||||
conn['conn'].close ()
|
|
||||||
|
|
||||||
while (conn['conn']) {
|
|
||||||
await delay(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout *= 2
|
|
||||||
tmout = setTimeout (endConnection, timeout)
|
|
||||||
}
|
|
||||||
tmout = setTimeout (endConnection, timeout)
|
|
||||||
|
|
||||||
await conn.connect ()
|
|
||||||
clearTimeout (tmout)
|
|
||||||
|
|
||||||
await verifyConnectionOpen (conn)
|
|
||||||
})
|
|
||||||
it ('should reconnect on broken connection', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.autoReconnect = ReconnectMode.onConnectionLost
|
|
||||||
|
|
||||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
|
||||||
assert.strictEqual (conn.phoneConnected, true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const closeConn = () => conn['conn']?.terminate ()
|
|
||||||
|
|
||||||
const task = new Promise (resolve => {
|
|
||||||
let closes = 0
|
|
||||||
conn.on ('close', ({reason, isReconnecting}) => {
|
|
||||||
console.log (`closed: ${reason}`)
|
|
||||||
assert.ok (reason)
|
|
||||||
assert.ok (isReconnecting)
|
|
||||||
closes += 1
|
|
||||||
|
|
||||||
// let it fail reconnect a few times
|
|
||||||
if (closes >= 1) {
|
|
||||||
conn.removeAllListeners ('close')
|
|
||||||
conn.removeAllListeners ('connecting')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
conn.on ('connecting', () => {
|
|
||||||
// close again
|
|
||||||
delay (3500).then (closeConn)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
closeConn ()
|
|
||||||
await task
|
|
||||||
|
|
||||||
await new Promise (resolve => {
|
|
||||||
conn.on ('open', () => {
|
|
||||||
conn.removeAllListeners ('open')
|
|
||||||
resolve(undefined)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
|
|
||||||
conn.on ('connecting', () => assert.fail('should not connect'))
|
|
||||||
await delay (2000)
|
|
||||||
} finally {
|
|
||||||
conn.removeAllListeners ('connecting')
|
|
||||||
conn.removeAllListeners ('close')
|
|
||||||
conn.removeAllListeners ('open')
|
|
||||||
conn.close ()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it ('should reconnect & stay connected', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.autoReconnect = ReconnectMode.onConnectionLost
|
|
||||||
|
|
||||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
|
||||||
assert.strictEqual (conn.phoneConnected, true)
|
|
||||||
|
|
||||||
await delay (30*1000)
|
|
||||||
|
|
||||||
conn['conn']?.terminate ()
|
|
||||||
|
|
||||||
conn.on ('close', () => {
|
|
||||||
assert.ok (!conn['lastSeen'])
|
|
||||||
console.log ('connection closed')
|
|
||||||
})
|
|
||||||
await new Promise (resolve => conn.on ('open', resolve))
|
|
||||||
await verifyConnectionOpen (conn)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe ('Pending Requests', () => {
|
|
||||||
it ('should correctly send updates for chats', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.pendingRequestTimeoutMs = null
|
|
||||||
conn.loadAuthInfo('./auth_info.json')
|
|
||||||
|
|
||||||
const task = new Promise(resolve => conn.once('chats-received', resolve))
|
|
||||||
await conn.connect ()
|
|
||||||
await task
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
|
|
||||||
const oldChat = conn.chats.all()[0]
|
|
||||||
oldChat.archive = 'true' // mark the first chat as archived
|
|
||||||
oldChat.modify_tag = '1234' // change modify tag to detect change
|
|
||||||
|
|
||||||
const promise = new Promise(resolve => conn.once('chats-update', resolve))
|
|
||||||
|
|
||||||
const result = await conn.connect ()
|
|
||||||
assert.ok (!result.newConnection)
|
|
||||||
|
|
||||||
const chats = await promise as Partial<WAChat>[]
|
|
||||||
const chat = chats.find (c => c.jid === oldChat.jid)
|
|
||||||
assert.ok (chat)
|
|
||||||
|
|
||||||
assert.ok ('archive' in chat)
|
|
||||||
assert.strictEqual (Object.keys(chat).length, 3)
|
|
||||||
assert.strictEqual (Object.keys(chats).length, 1)
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
})
|
|
||||||
it ('should correctly send updates for contacts', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
conn.pendingRequestTimeoutMs = null
|
|
||||||
conn.loadAuthInfo('./auth_info.json')
|
|
||||||
|
|
||||||
const task: any = new Promise(resolve => conn.once('contacts-received', resolve))
|
|
||||||
await conn.connect ()
|
|
||||||
const initialResult = await task
|
|
||||||
assert.strictEqual(
|
|
||||||
initialResult.updatedContacts.length,
|
|
||||||
Object.keys(conn.contacts).length
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
|
|
||||||
const [jid] = Object.keys(conn.contacts)
|
|
||||||
const oldContact = conn.contacts[jid]
|
|
||||||
oldContact.name = 'Lol'
|
|
||||||
oldContact.index = 'L'
|
|
||||||
|
|
||||||
const promise = new Promise(resolve => conn.once('contacts-received', resolve))
|
|
||||||
|
|
||||||
const result = await conn.connect ()
|
|
||||||
assert.ok (!result.newConnection)
|
|
||||||
|
|
||||||
const {updatedContacts} = await promise as { updatedContacts: Partial<WAContact>[] }
|
|
||||||
const contact = updatedContacts.find (c => c.jid === jid)
|
|
||||||
assert.ok (contact)
|
|
||||||
|
|
||||||
assert.ok ('name' in contact)
|
|
||||||
assert.strictEqual (Object.keys(contact).length, 3)
|
|
||||||
assert.strictEqual (Object.keys(updatedContacts).length, 1)
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
})
|
|
||||||
it('should queue requests when closed', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
//conn.pendingRequestTimeoutMs = null
|
|
||||||
|
|
||||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
|
||||||
|
|
||||||
await delay (2000)
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
|
|
||||||
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.jid]})
|
|
||||||
|
|
||||||
await delay (2000)
|
|
||||||
|
|
||||||
conn.connect ()
|
|
||||||
const json = await task
|
|
||||||
|
|
||||||
assert.ok (json.status)
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
})
|
|
||||||
it('[MANUAL] should receive query response after phone disconnect', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
|
||||||
|
|
||||||
console.log(`disconnect your phone from the internet!`)
|
|
||||||
await delay(5000)
|
|
||||||
const task = conn.loadMessages(testJid, 50)
|
|
||||||
setTimeout(() => console.log('reconnect your phone!'), 20_000)
|
|
||||||
|
|
||||||
const result = await task
|
|
||||||
assert.ok(result.messages[0])
|
|
||||||
assert.ok(!conn['phoneCheckInterval']) // should be undefined
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
})
|
|
||||||
it('should re-execute query on connection closed error', async () => {
|
|
||||||
const conn = makeConnection ()
|
|
||||||
//conn.pendingRequestTimeoutMs = 10_000
|
|
||||||
await conn.loadAuthInfo('./auth_info.json').connect ()
|
|
||||||
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.jid], waitForOpen: true})
|
|
||||||
|
|
||||||
await delay(20)
|
|
||||||
conn['onMessageRecieved']('1234,["Pong",false]') // fake cancel the connection
|
|
||||||
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
const json = await task
|
|
||||||
|
|
||||||
assert.ok (json.status)
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { MessageType, GroupSettingChange, delay, ChatModification, whatsappID } from '../WAConnection'
|
|
||||||
import * as assert from 'assert'
|
|
||||||
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
|
|
||||||
|
|
||||||
WAConnectionTest('Groups', (conn) => {
|
|
||||||
let gid: string
|
|
||||||
it('should create a group', async () => {
|
|
||||||
const response = await conn.groupCreate('Cool Test Group', [testJid])
|
|
||||||
assert.ok (conn.chats.get(response.gid))
|
|
||||||
|
|
||||||
const {chats} = await conn.loadChats(10, null)
|
|
||||||
assert.strictEqual (chats[0].jid, response.gid) // first chat should be new group
|
|
||||||
|
|
||||||
gid = response.gid
|
|
||||||
|
|
||||||
console.log('created group: ' + JSON.stringify(response))
|
|
||||||
})
|
|
||||||
it('should retrieve group invite code', async () => {
|
|
||||||
const code = await conn.groupInviteCode(gid)
|
|
||||||
assert.ok(code)
|
|
||||||
assert.strictEqual(typeof code, 'string')
|
|
||||||
})
|
|
||||||
it('should joined group via invite code', async () => {
|
|
||||||
const response = await conn.acceptInvite(gid)
|
|
||||||
assert.ok(response.status)
|
|
||||||
assert.strictEqual(response.status, response.gid)
|
|
||||||
})
|
|
||||||
it('should retrieve group metadata', async () => {
|
|
||||||
const metadata = await conn.groupMetadata(gid)
|
|
||||||
assert.strictEqual(metadata.id, gid)
|
|
||||||
assert.strictEqual(metadata.participants.filter((obj) => obj.jid.split('@')[0] === testJid.split('@')[0]).length, 1)
|
|
||||||
assert.ok(conn.chats.get(gid))
|
|
||||||
assert.ok(conn.chats.get(gid).metadata)
|
|
||||||
})
|
|
||||||
it('should update the group description', async () => {
|
|
||||||
const newDesc = 'Wow this was set from Baileys'
|
|
||||||
|
|
||||||
const waitForEvent = new Promise (resolve => (
|
|
||||||
conn.once ('group-update', ({jid, desc}) => {
|
|
||||||
if (jid === gid && desc) {
|
|
||||||
assert.strictEqual(desc, newDesc)
|
|
||||||
assert.strictEqual(
|
|
||||||
conn.chats.get(jid).metadata.desc,
|
|
||||||
newDesc
|
|
||||||
)
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
))
|
|
||||||
await conn.groupUpdateDescription (gid, newDesc)
|
|
||||||
await waitForEvent
|
|
||||||
|
|
||||||
const metadata = await conn.groupMetadata(gid)
|
|
||||||
assert.strictEqual(metadata.desc, newDesc)
|
|
||||||
})
|
|
||||||
it('should send a message on the group', async () => {
|
|
||||||
await sendAndRetrieveMessage(conn, 'Hello!', MessageType.text, {}, gid)
|
|
||||||
})
|
|
||||||
it('should delete a message on the group', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(conn, 'Hello!', MessageType.text, {}, gid)
|
|
||||||
await delay(1500)
|
|
||||||
await conn.deleteMessage(message.key)
|
|
||||||
})
|
|
||||||
it('should quote a message on the group', async () => {
|
|
||||||
const {messages} = await conn.loadMessages (gid, 100)
|
|
||||||
const quotableMessage = messages.find (m => m.message)
|
|
||||||
assert.ok (quotableMessage, 'need at least one message')
|
|
||||||
|
|
||||||
const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: quotableMessage})
|
|
||||||
const loaded = await conn.loadMessages(gid, 10)
|
|
||||||
const message = loaded.messages.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage
|
|
||||||
assert.ok(message)
|
|
||||||
assert.strictEqual (message.contextInfo.stanzaId, quotableMessage.key.id)
|
|
||||||
})
|
|
||||||
it('should update the subject', async () => {
|
|
||||||
const subject = 'Baileyz ' + Math.floor(Math.random()*5)
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('chat-update', ({jid, name}) => {
|
|
||||||
if (jid === gid) {
|
|
||||||
assert.strictEqual(name, subject)
|
|
||||||
assert.strictEqual(conn.chats.get(jid).name, subject)
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.groupUpdateSubject(gid, subject)
|
|
||||||
await waitForEvent
|
|
||||||
|
|
||||||
const metadata = await conn.groupMetadata(gid)
|
|
||||||
assert.strictEqual(metadata.subject, subject)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update the group settings', async () => {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('group-update', ({jid, announce}) => {
|
|
||||||
if (jid === gid) {
|
|
||||||
assert.strictEqual (announce, 'true')
|
|
||||||
assert.strictEqual(conn.chats.get(gid).metadata.announce, announce)
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true)
|
|
||||||
|
|
||||||
await waitForEvent
|
|
||||||
conn.removeAllListeners ('group-update')
|
|
||||||
|
|
||||||
await delay (2000)
|
|
||||||
await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should promote someone', async () => {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('group-participants-update', ({ jid, action, participants }) => {
|
|
||||||
if (jid === gid) {
|
|
||||||
assert.strictEqual (action, 'promote')
|
|
||||||
console.log(participants)
|
|
||||||
console.log(conn.chats.get(jid).metadata)
|
|
||||||
assert.ok(
|
|
||||||
conn.chats.get(jid).metadata.participants.find(({ jid, isAdmin }) => (
|
|
||||||
whatsappID(jid) === whatsappID(participants[0]) && isAdmin
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.groupMakeAdmin(gid, [ testJid ])
|
|
||||||
await waitForEvent
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove someone from a group', async () => {
|
|
||||||
const metadata = await conn.groupMetadata (gid)
|
|
||||||
if (metadata.participants.find(({jid}) => whatsappID(jid) === testJid)) {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('group-participants-update', ({jid, participants, action}) => {
|
|
||||||
if (jid === gid) {
|
|
||||||
assert.strictEqual (participants[0], testJid)
|
|
||||||
assert.strictEqual (action, 'remove')
|
|
||||||
assert.deepStrictEqual(
|
|
||||||
conn.chats.get(jid).metadata.participants.find(p => whatsappID(p.jid) === whatsappID(participants[0])),
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await conn.groupRemove(gid, [testJid])
|
|
||||||
await waitForEvent
|
|
||||||
} else console.log(`could not find testJid`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should leave the group', async () => {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('chat-update', ({jid, read_only}) => {
|
|
||||||
if (jid === gid) {
|
|
||||||
assert.strictEqual (read_only, 'true')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.groupLeave(gid)
|
|
||||||
await waitForEvent
|
|
||||||
|
|
||||||
await conn.groupMetadataMinimal (gid)
|
|
||||||
})
|
|
||||||
it('should archive the group', async () => {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('chat-update', ({jid, archive}) => {
|
|
||||||
if (jid === gid) {
|
|
||||||
assert.strictEqual (archive, 'true')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.modifyChat(gid, ChatModification.archive)
|
|
||||||
await waitForEvent
|
|
||||||
})
|
|
||||||
it('should delete the group', async () => {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('chat-update', (chat) => {
|
|
||||||
if (chat.jid === gid) {
|
|
||||||
assert.strictEqual (chat['delete'], 'true')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.modifyChat(gid, 'delete')
|
|
||||||
await waitForEvent
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { deepStrictEqual, strictEqual } from 'assert'
|
|
||||||
import { createWriteStream } from 'fs'
|
|
||||||
import { readFile } from 'fs/promises'
|
|
||||||
import { proto } from '../../WAMessage/WAMessage'
|
|
||||||
import { MessageType } from '../WAConnection'
|
|
||||||
import { aesEncrypWithIV, decryptMediaMessageBuffer, encryptedStream, getMediaKeys, getStream, hmacSign, sha256 } from '../WAConnection/Utils'
|
|
||||||
import { WAConnectionTest } from './Common'
|
|
||||||
|
|
||||||
describe('Media Download Tests', () => {
|
|
||||||
|
|
||||||
it('should encrypt media streams correctly', async function() {
|
|
||||||
const url = './Media/meme.jpeg'
|
|
||||||
const streamValues = await encryptedStream({ url }, MessageType.image)
|
|
||||||
|
|
||||||
const buffer = await readFile(url)
|
|
||||||
const mediaKeys = getMediaKeys(streamValues.mediaKey, MessageType.image)
|
|
||||||
|
|
||||||
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
|
||||||
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
|
||||||
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
|
||||||
const fileSha256 = sha256(buffer)
|
|
||||||
const fileEncSha256 = sha256(body)
|
|
||||||
|
|
||||||
deepStrictEqual(streamValues.fileSha256, fileSha256)
|
|
||||||
strictEqual(streamValues.fileLength, buffer.length)
|
|
||||||
deepStrictEqual(streamValues.mac, mac)
|
|
||||||
deepStrictEqual(await readFile(streamValues.encBodyPath), body)
|
|
||||||
deepStrictEqual(streamValues.fileEncSha256, fileEncSha256)
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
/*
|
|
||||||
WAConnectionTest('Media Upload', conn => {
|
|
||||||
|
|
||||||
it('should upload the same file', async () => {
|
|
||||||
const FILES = [
|
|
||||||
{ url: './Media/meme.jpeg', type: MessageType.image },
|
|
||||||
{ url: './Media/ma_gif.mp4', type: MessageType.video },
|
|
||||||
{ url: './Media/sonata.mp3', type: MessageType.audio },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
})*/
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import { MessageType, Mimetype, delay, promiseTimeout, WA_MESSAGE_STATUS_TYPE, generateMessageID, WAMessage } from '../WAConnection'
|
|
||||||
import { promises as fs } from 'fs'
|
|
||||||
import * as assert from 'assert'
|
|
||||||
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
|
|
||||||
|
|
||||||
WAConnectionTest('Messages', conn => {
|
|
||||||
|
|
||||||
it('should send a text message', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text)
|
|
||||||
assert.strictEqual(message.message.conversation || message.message.extendedTextMessage?.text, 'hello fren')
|
|
||||||
})
|
|
||||||
it('should send a pending message', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text, { waitForAck: false })
|
|
||||||
|
|
||||||
await new Promise(resolve => conn.on('chat-update', update => {
|
|
||||||
if (update.jid === testJid &&
|
|
||||||
update.messages &&
|
|
||||||
update.messages.first.key.id === message.key.id &&
|
|
||||||
update.messages.first.status === WA_MESSAGE_STATUS_TYPE.SERVER_ACK) {
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
})
|
|
||||||
it('should forward a message', async () => {
|
|
||||||
let {messages} = await conn.loadMessages (testJid, 1)
|
|
||||||
await conn.forwardMessage (testJid, messages[0], true)
|
|
||||||
|
|
||||||
messages = (await conn.loadMessages (testJid, 1)).messages
|
|
||||||
const message = messages.slice (-1)[0]
|
|
||||||
const content = message.message[ Object.keys(message.message)[0] ]
|
|
||||||
assert.strictEqual (content?.contextInfo?.isForwarded, true)
|
|
||||||
})
|
|
||||||
it('should send a link preview', async () => {
|
|
||||||
const text = 'hello this is from https://www.github.com/adiwajshing/Baileys'
|
|
||||||
const message = await sendAndRetrieveMessage(conn, text, MessageType.text, { detectLinks: true })
|
|
||||||
const received = message.message.extendedTextMessage
|
|
||||||
|
|
||||||
assert.strictEqual(received.text, text)
|
|
||||||
assert.ok (received.canonicalUrl)
|
|
||||||
assert.ok (received.title)
|
|
||||||
assert.ok (received.description)
|
|
||||||
})
|
|
||||||
it('should quote a message', async () => {
|
|
||||||
const quoted = (await conn.loadMessages(testJid, 2)).messages[0]
|
|
||||||
const message = await sendAndRetrieveMessage(conn, 'hello fren 2', MessageType.extendedText, { quoted })
|
|
||||||
assert.strictEqual(
|
|
||||||
message.message.extendedTextMessage.contextInfo.stanzaId,
|
|
||||||
quoted.key.id
|
|
||||||
)
|
|
||||||
assert.strictEqual(
|
|
||||||
message.message.extendedTextMessage.contextInfo.participant,
|
|
||||||
quoted.key.fromMe ? conn.user.jid : quoted.key.id
|
|
||||||
)
|
|
||||||
})
|
|
||||||
it('should upload media successfully', async () => {
|
|
||||||
const content = await fs.readFile('./Media/sonata.mp3')
|
|
||||||
// run 10 uploads
|
|
||||||
for (let i = 0; i < 10;i++) {
|
|
||||||
await conn.prepareMessageContent (content, MessageType.audio, { filename: 'audio.mp3', mimetype: Mimetype.mp4Audio })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('should send a gif', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(conn, { url: './Media/ma_gif.mp4' }, MessageType.video, { mimetype: Mimetype.gif })
|
|
||||||
|
|
||||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_vid')
|
|
||||||
})
|
|
||||||
it('should send an audio', async () => {
|
|
||||||
const content = await fs.readFile('./Media/sonata.mp3')
|
|
||||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio })
|
|
||||||
// check duration was okay
|
|
||||||
assert.ok (message.message.audioMessage.seconds > 0)
|
|
||||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
|
||||||
})
|
|
||||||
it('should send a voice note', async () => {
|
|
||||||
const content = await fs.readFile('./Media/sonata.mp3')
|
|
||||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true })
|
|
||||||
|
|
||||||
assert.ok (message.message.audioMessage.seconds > 0)
|
|
||||||
assert.strictEqual (message.message?.audioMessage?.ptt, true)
|
|
||||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
|
||||||
})
|
|
||||||
it('should send a jpeg image', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(conn, { url: './Media/meme.jpeg' }, MessageType.image)
|
|
||||||
assert.ok(message.message.imageMessage.jpegThumbnail.length > 0)
|
|
||||||
const msg = await conn.downloadMediaMessage(message)
|
|
||||||
assert.deepStrictEqual(msg, await fs.readFile('./Media/meme.jpeg'))
|
|
||||||
})
|
|
||||||
it('should send a remote jpeg image', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(
|
|
||||||
conn,
|
|
||||||
{ url: 'https://www.memestemplates.com/wp-content/uploads/2020/05/tom-with-phone.jpg' },
|
|
||||||
MessageType.image
|
|
||||||
)
|
|
||||||
assert.ok (message.message?.imageMessage?.jpegThumbnail)
|
|
||||||
await conn.downloadMediaMessage(message)
|
|
||||||
})
|
|
||||||
it('should send a png image', async () => {
|
|
||||||
const content = await fs.readFile('./Media/icon.png')
|
|
||||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.image, { mimetype: 'image/png' })
|
|
||||||
assert.ok (message.message?.imageMessage?.jpegThumbnail)
|
|
||||||
await conn.downloadMediaMessage(message)
|
|
||||||
})
|
|
||||||
it('should send a sticker', async () => {
|
|
||||||
const content = await fs.readFile('./Media/octopus.webp')
|
|
||||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.sticker)
|
|
||||||
|
|
||||||
await conn.downloadMediaMessage(message)
|
|
||||||
})
|
|
||||||
/*it('should send an interactive message', async () => {
|
|
||||||
|
|
||||||
console.log (
|
|
||||||
JSON.stringify(await conn.loadMessages (testJid, 5), null, '\t')
|
|
||||||
)
|
|
||||||
const message = conn.prepareMessageFromContent (
|
|
||||||
testJid,
|
|
||||||
{
|
|
||||||
templateMessage: {
|
|
||||||
fourRowTemplate: {
|
|
||||||
content: {
|
|
||||||
namespace: 'my-namespace',
|
|
||||||
localizableParams: [
|
|
||||||
|
|
||||||
],
|
|
||||||
params: ['hello!']
|
|
||||||
},
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
quickReplyButton: {
|
|
||||||
displayText: {
|
|
||||||
params: ['my name jeff']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
quickReplyButton: {
|
|
||||||
displayText: {
|
|
||||||
params: ['my name NOT jeff'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
await conn.relayWAMessage (message)
|
|
||||||
})*/
|
|
||||||
it('should send an image & quote', async () => {
|
|
||||||
const quoted = (await conn.loadMessages(testJid, 2)).messages[0]
|
|
||||||
const content = await fs.readFile('./Media/meme.jpeg')
|
|
||||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.image, { quoted })
|
|
||||||
|
|
||||||
await conn.downloadMediaMessage(message) // check for successful decoding
|
|
||||||
assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, quoted.key.id)
|
|
||||||
})
|
|
||||||
it('should send a message & delete it', async () => {
|
|
||||||
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text)
|
|
||||||
await delay (2000)
|
|
||||||
await conn.deleteMessage (testJid, message.key)
|
|
||||||
})
|
|
||||||
it('should clear the most recent message', async () => {
|
|
||||||
const {messages} = await conn.loadMessages (testJid, 1)
|
|
||||||
await delay (2000)
|
|
||||||
await conn.clearMessage (messages[0].key)
|
|
||||||
})
|
|
||||||
it('should send media after close', async () => {
|
|
||||||
const content = await fs.readFile('./Media/octopus.webp')
|
|
||||||
await sendAndRetrieveMessage(conn, content, MessageType.sticker)
|
|
||||||
|
|
||||||
conn.close ()
|
|
||||||
|
|
||||||
await conn.connect ()
|
|
||||||
|
|
||||||
const content2 = await fs.readFile('./Media/cat.jpeg')
|
|
||||||
await sendAndRetrieveMessage(conn, content2, MessageType.image)
|
|
||||||
})
|
|
||||||
it('should fail to send a text message', async () => {
|
|
||||||
const JID = '1234-1234@g.us'
|
|
||||||
const messageId = generateMessageID()
|
|
||||||
conn.sendMessage(JID, 'hello', MessageType.text, { messageId })
|
|
||||||
|
|
||||||
await new Promise(resolve => (
|
|
||||||
conn.on ('chat-update', async update => {
|
|
||||||
console.log(messageId, update.messages?.first)
|
|
||||||
if (
|
|
||||||
update.jid === JID &&
|
|
||||||
update.messages?.first.key.id === messageId &&
|
|
||||||
update.messages.first.status === WA_MESSAGE_STATUS_TYPE.ERROR) {
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
))
|
|
||||||
conn.removeAllListeners('chat-update')
|
|
||||||
})
|
|
||||||
it('should maintain message integrity', async () => {
|
|
||||||
// loading twice does not alter the results
|
|
||||||
const results = await Promise.all ([
|
|
||||||
conn.loadMessages (testJid, 50),
|
|
||||||
conn.loadMessages (testJid, 50)
|
|
||||||
])
|
|
||||||
assert.strictEqual (results[0].messages.length, results[1].messages.length)
|
|
||||||
for (let i = 0; i < results[1].messages.length;i++) {
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
results[0].messages[i].key,
|
|
||||||
results[1].messages[i].key,
|
|
||||||
`failed equal at ${i}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assert.ok (results[0].messages.length <= 50)
|
|
||||||
|
|
||||||
// check if messages match server
|
|
||||||
let msgs = await conn.fetchMessagesFromWA (testJid, 50)
|
|
||||||
for (let i = 0; i < results[1].messages.length;i++) {
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
results[0].messages[i].key,
|
|
||||||
msgs[i].key,
|
|
||||||
`failed equal at ${i}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// check with some arbitary cursors
|
|
||||||
let cursor = results[0].messages.slice(-1)[0].key
|
|
||||||
|
|
||||||
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
|
|
||||||
let {messages} = await conn.loadMessages (testJid, 20, cursor)
|
|
||||||
for (let i = 0; i < messages.length;i++) {
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
messages[i].key,
|
|
||||||
msgs[i].key,
|
|
||||||
`failed equal at ${i}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
for (let i = 0; i < 3;i++) {
|
|
||||||
cursor = results[0].messages[i].key
|
|
||||||
|
|
||||||
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
|
|
||||||
messages = (await conn.loadMessages (testJid, 20, cursor)).messages
|
|
||||||
for (let i = 0; i < messages.length;i++) {
|
|
||||||
assert.deepStrictEqual (messages[i].key, msgs[i].key, `failed equal at ${i}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = msgs[0].key
|
|
||||||
|
|
||||||
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
|
|
||||||
messages = (await conn.loadMessages (testJid, 20, cursor)).messages
|
|
||||||
for (let i = 0; i < messages.length;i++) {
|
|
||||||
assert.deepStrictEqual (messages[i].key, msgs[i].key, `failed equal at ${i}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('should deliver a message', async () => {
|
|
||||||
const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text)
|
|
||||||
const waitForUpdate =
|
|
||||||
promiseTimeout(15000, resolve => {
|
|
||||||
conn.on('chat-update', update => {
|
|
||||||
if (update.messages?.first.key.id === response.key.id) {
|
|
||||||
resolve(update.messages.first)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}) as Promise<WAMessage>
|
|
||||||
|
|
||||||
const m = await waitForUpdate
|
|
||||||
assert.ok (m.status >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
import { Presence, ChatModification, delay, newMessagesDB, WA_DEFAULT_EPHEMERAL, MessageType, WAMessage } from '../WAConnection'
|
|
||||||
import { promises as fs } from 'fs'
|
|
||||||
import * as assert from 'assert'
|
|
||||||
import got from 'got'
|
|
||||||
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
|
|
||||||
|
|
||||||
WAConnectionTest('Misc', conn => {
|
|
||||||
|
|
||||||
it('should tell if someone has an account on WhatsApp', async () => {
|
|
||||||
const response = await conn.isOnWhatsApp(testJid)
|
|
||||||
assert.strictEqual(response, true)
|
|
||||||
|
|
||||||
const responseFail = await conn.isOnWhatsApp('abcd@s.whatsapp.net')
|
|
||||||
assert.strictEqual(responseFail, false)
|
|
||||||
})
|
|
||||||
it('should return the status', async () => {
|
|
||||||
const response = await conn.getStatus(testJid)
|
|
||||||
assert.strictEqual(typeof response.status, 'string')
|
|
||||||
})
|
|
||||||
it('should update status', async () => {
|
|
||||||
const newStatus = 'v cool status'
|
|
||||||
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.on ('contact-update', ({jid, status}) => {
|
|
||||||
if (jid === conn.user.jid) {
|
|
||||||
assert.strictEqual (status, newStatus)
|
|
||||||
conn.removeAllListeners ('contact-update')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await conn.getStatus()
|
|
||||||
assert.strictEqual(typeof response.status, 'string')
|
|
||||||
|
|
||||||
await delay (1000)
|
|
||||||
|
|
||||||
await conn.setStatus (newStatus)
|
|
||||||
const response2 = await conn.getStatus()
|
|
||||||
assert.strictEqual (response2.status, newStatus)
|
|
||||||
|
|
||||||
await waitForEvent
|
|
||||||
|
|
||||||
await delay (1000)
|
|
||||||
|
|
||||||
await conn.setStatus (response.status) // update back
|
|
||||||
})
|
|
||||||
it('should update profile name', async () => {
|
|
||||||
const newName = 'v cool name'
|
|
||||||
|
|
||||||
await delay (1000)
|
|
||||||
|
|
||||||
const originalName = conn.user.name!
|
|
||||||
|
|
||||||
const waitForEvent = new Promise<void> (resolve => {
|
|
||||||
conn.on ('contact-update', ({name}) => {
|
|
||||||
assert.strictEqual (name, newName)
|
|
||||||
conn.removeAllListeners ('contact-update')
|
|
||||||
resolve ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await conn.updateProfileName (newName)
|
|
||||||
|
|
||||||
await waitForEvent
|
|
||||||
|
|
||||||
await delay (1000)
|
|
||||||
|
|
||||||
assert.strictEqual (conn.user.name, newName)
|
|
||||||
|
|
||||||
await delay (1000)
|
|
||||||
|
|
||||||
await conn.updateProfileName (originalName) // update back
|
|
||||||
})
|
|
||||||
it('should return the stories', async () => {
|
|
||||||
await conn.getStories()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return the profile picture correctly', async () => {
|
|
||||||
// wait for chats
|
|
||||||
await new Promise(resolve => (
|
|
||||||
conn.once('initial-data-received', resolve)
|
|
||||||
))
|
|
||||||
const pictures = await Promise.all(
|
|
||||||
conn.chats.all().slice(0, 15).map(({ jid }) => (
|
|
||||||
conn.getProfilePicture(jid)
|
|
||||||
.catch(err => '')
|
|
||||||
))
|
|
||||||
)
|
|
||||||
// pictures should return correctly
|
|
||||||
const truePictures = pictures.filter(pp => !!pp)
|
|
||||||
assert.strictEqual(
|
|
||||||
new Set(truePictures).size,
|
|
||||||
truePictures.length
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should change the profile picture', async () => {
|
|
||||||
await delay (5000)
|
|
||||||
|
|
||||||
const ppUrl = await conn.getProfilePicture(conn.user.jid)
|
|
||||||
const {rawBody: oldPP} = await got(ppUrl)
|
|
||||||
|
|
||||||
const newPP = await fs.readFile('./Media/cat.jpeg')
|
|
||||||
await conn.updateProfilePicture(conn.user.jid, newPP)
|
|
||||||
|
|
||||||
await delay (10000)
|
|
||||||
|
|
||||||
await conn.updateProfilePicture (conn.user.jid, oldPP) // revert back
|
|
||||||
})
|
|
||||||
it('should send typing indicator', async () => {
|
|
||||||
const response = await conn.updatePresence(testJid, Presence.composing)
|
|
||||||
assert.ok(response)
|
|
||||||
})
|
|
||||||
it('should change a chat read status', async () => {
|
|
||||||
const jids = conn.chats.all ().map (c => c.jid)
|
|
||||||
for (let jid of jids.slice(0, 5)) {
|
|
||||||
console.log (`changing read status for ${jid}`)
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.once ('chat-update', ({jid: tJid, count}) => {
|
|
||||||
if (jid === tJid) {
|
|
||||||
assert.ok (count < 0)
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.chatRead (jid, 'unread')
|
|
||||||
await waitForEvent
|
|
||||||
|
|
||||||
await delay (5000)
|
|
||||||
|
|
||||||
await conn.chatRead (jid, 'read')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('should archive & unarchive', async () => {
|
|
||||||
// wait for chats
|
|
||||||
await new Promise(resolve => (
|
|
||||||
conn.once('chats-received', ({ }) => resolve(undefined))
|
|
||||||
))
|
|
||||||
|
|
||||||
const idx = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
|
||||||
await conn.modifyChat (testJid, ChatModification.archive)
|
|
||||||
const idx2 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
|
||||||
assert.ok(idx < idx2) // should move further down the array
|
|
||||||
|
|
||||||
await delay (2000)
|
|
||||||
await conn.modifyChat (testJid, ChatModification.unarchive)
|
|
||||||
const idx3 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
|
||||||
assert.strictEqual(idx, idx3) // should be back there
|
|
||||||
})
|
|
||||||
it('should archive & unarchive on new message', async () => {
|
|
||||||
// wait for chats
|
|
||||||
await new Promise(resolve => (
|
|
||||||
conn.once('chats-received', ({ }) => resolve(undefined))
|
|
||||||
))
|
|
||||||
|
|
||||||
const idx = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
|
||||||
await conn.modifyChat (testJid, ChatModification.archive)
|
|
||||||
const idx2 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
|
||||||
assert.ok(idx < idx2) // should move further down the array
|
|
||||||
|
|
||||||
await delay (2000)
|
|
||||||
await sendAndRetrieveMessage(conn, 'test', MessageType.text)
|
|
||||||
// should be unarchived
|
|
||||||
const idx3 = conn.chats.all().findIndex(chat => chat.jid === testJid)
|
|
||||||
assert.strictEqual(idx, idx3) // should be back there
|
|
||||||
})
|
|
||||||
it('should pin & unpin a chat', async () => {
|
|
||||||
await conn.modifyChat (testJid, ChatModification.pin)
|
|
||||||
await delay (2000)
|
|
||||||
await conn.modifyChat (testJid, ChatModification.unpin)
|
|
||||||
})
|
|
||||||
it('should mute & unmute a chat', async () => {
|
|
||||||
const waitForEvent = new Promise (resolve => {
|
|
||||||
conn.on ('chat-update', ({jid, mute}) => {
|
|
||||||
if (jid === testJid ) {
|
|
||||||
assert.ok (mute)
|
|
||||||
conn.removeAllListeners ('chat-update')
|
|
||||||
resolve(undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await conn.modifyChat (testJid, ChatModification.mute, 8*60*60*1000) // 8 hours in the future
|
|
||||||
await waitForEvent
|
|
||||||
await delay (2000)
|
|
||||||
await conn.modifyChat (testJid, ChatModification.unmute)
|
|
||||||
})
|
|
||||||
it('should star/unstar messages', async () => {
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
await conn.sendMessage(testJid, `Message ${i}`, MessageType.text)
|
|
||||||
await delay(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = await conn.loadMessages(testJid, 5)
|
|
||||||
let starred = response.messages.filter(m => m.starred)
|
|
||||||
assert.strictEqual(starred.length, 0)
|
|
||||||
|
|
||||||
conn.starMessage(response.messages[2].key)
|
|
||||||
await delay(2000)
|
|
||||||
conn.starMessage(response.messages[4].key)
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
response = await conn.loadMessages(testJid, 5)
|
|
||||||
starred = response.messages.filter(m => m.starred)
|
|
||||||
assert.strictEqual(starred.length, 2)
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
conn.starMessage(response.messages[2].key, 'unstar')
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
response = await conn.loadMessages(testJid, 5)
|
|
||||||
starred = response.messages.filter(m => m.starred)
|
|
||||||
assert.strictEqual(starred.length, 1)
|
|
||||||
})
|
|
||||||
it('should clear a chat', async () => {
|
|
||||||
// Uses chat with yourself to avoid losing chats
|
|
||||||
const selfJid = conn.user.jid
|
|
||||||
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
await conn.sendMessage(selfJid, `Message ${i}`, MessageType.text)
|
|
||||||
await delay(1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = await conn.loadMessages(selfJid, 50)
|
|
||||||
const initialCount = response.messages.length
|
|
||||||
|
|
||||||
assert.ok(response.messages.length >= 0)
|
|
||||||
|
|
||||||
conn.starMessage(response.messages[2].key)
|
|
||||||
await delay(2000)
|
|
||||||
conn.starMessage(response.messages[4].key)
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
await conn.modifyChat(selfJid, ChatModification.clear)
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
response = await conn.loadMessages(selfJid, 50)
|
|
||||||
await delay(2000)
|
|
||||||
assert.ok(response.messages.length < initialCount)
|
|
||||||
assert.ok(response.messages.length > 1)
|
|
||||||
|
|
||||||
await conn.modifyChat(selfJid, ChatModification.clear, true)
|
|
||||||
await delay(2000)
|
|
||||||
|
|
||||||
response = await conn.loadMessages(selfJid, 50)
|
|
||||||
assert.strictEqual(response.messages.length, 1)
|
|
||||||
})
|
|
||||||
it('should return search results', async () => {
|
|
||||||
const jids = [null, testJid]
|
|
||||||
for (let i in jids) {
|
|
||||||
let response = await conn.searchMessages('Hello', jids[i], 25, 1)
|
|
||||||
assert.ok (response.messages)
|
|
||||||
assert.ok (response.messages.length >= 0)
|
|
||||||
|
|
||||||
response = await conn.searchMessages('剛剛試咗😋一個字', jids[i], 25, 1)
|
|
||||||
assert.ok (response.messages)
|
|
||||||
assert.ok (response.messages.length >= 0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should load a single message', async () => {
|
|
||||||
const {messages} = await conn.loadMessages (testJid, 25)
|
|
||||||
for (var message of messages) {
|
|
||||||
const loaded = await conn.loadMessage (testJid, message.key.id)
|
|
||||||
assert.strictEqual (loaded.key.id, message.key.id, `loaded message ${JSON.stringify(message)} incorrectly`)
|
|
||||||
await delay (500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// open the other phone and look at the updates to really verify stuff
|
|
||||||
it('should send presence updates', async () => {
|
|
||||||
conn.shouldLogMessages = true
|
|
||||||
conn.requestPresenceUpdate(testJid)
|
|
||||||
|
|
||||||
const sequence = [ Presence.available, Presence.composing, Presence.paused, Presence.recording, Presence.paused, Presence.unavailable ]
|
|
||||||
for (const presence of sequence) {
|
|
||||||
await delay(5000)
|
|
||||||
await conn.updatePresence(presence !== Presence.unavailable ? testJid : null, presence)
|
|
||||||
//console.log(conn.messageLog.slice(-1))
|
|
||||||
console.log('sent update ', presence)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
it('should generate link previews correctly', async () => {
|
|
||||||
await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys')
|
|
||||||
// two links should fail
|
|
||||||
await assert.rejects (
|
|
||||||
conn.generateLinkPreview ('I sent links to https://teachyourselfcs.com/ and https://www.fast.ai/')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
// this test requires quite a few messages with the test JID
|
|
||||||
it('should detect overlaps and clear messages accordingly', async () => {
|
|
||||||
// wait for chats
|
|
||||||
await new Promise(resolve => (
|
|
||||||
conn.once('initial-data-received', resolve)
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.maxCachedMessages = 100
|
|
||||||
|
|
||||||
const chat = conn.chats.get(testJid)
|
|
||||||
const oldCount = chat.messages.length
|
|
||||||
console.log(`test chat has ${oldCount} pre-loaded messages`)
|
|
||||||
// load 100 messages
|
|
||||||
await conn.loadMessages(testJid, 100, undefined)
|
|
||||||
assert.strictEqual(chat.messages.length, 100)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
// remove all latest messages
|
|
||||||
chat.messages = newMessagesDB( chat.messages.all().slice(0, 20) )
|
|
||||||
|
|
||||||
const task = new Promise(resolve => (
|
|
||||||
conn.on('initial-data-received', ({ chatsWithMissingMessages }) => {
|
|
||||||
assert.strictEqual(Object.keys(chatsWithMissingMessages).length, 1)
|
|
||||||
const missing = chatsWithMissingMessages.find(({ jid }) => jid === testJid)
|
|
||||||
assert.ok(missing, 'missing message not detected')
|
|
||||||
assert.strictEqual(
|
|
||||||
conn.chats.get(testJid).messages.length,
|
|
||||||
missing.count
|
|
||||||
)
|
|
||||||
assert.strictEqual(missing.count, oldCount)
|
|
||||||
resolve(undefined)
|
|
||||||
})
|
|
||||||
))
|
|
||||||
|
|
||||||
await conn.connect()
|
|
||||||
|
|
||||||
await task
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle disappearing messages', async () => {
|
|
||||||
let chat = conn.chats.get(testJid)
|
|
||||||
if (!chat) {
|
|
||||||
// wait for chats
|
|
||||||
await new Promise(resolve => (
|
|
||||||
conn.once('chats-received', resolve)
|
|
||||||
))
|
|
||||||
chat = conn.chats.get(testJid)
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitForChatUpdate = (ephemeralOn: boolean) => (
|
|
||||||
new Promise(resolve => (
|
|
||||||
conn.on('chat-update', ({ jid, ephemeral }) => {
|
|
||||||
if (jid === testJid && typeof ephemeral !== 'undefined') {
|
|
||||||
assert.strictEqual(!!(+ephemeral), ephemeralOn)
|
|
||||||
assert.strictEqual(!!(+chat.ephemeral), ephemeralOn)
|
|
||||||
resolve(undefined)
|
|
||||||
conn.removeAllListeners('chat-update')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
))
|
|
||||||
)
|
|
||||||
const toggleDisappearingMessages = async (on: boolean) => {
|
|
||||||
const update = waitForChatUpdate(on)
|
|
||||||
await conn.toggleDisappearingMessages(testJid, on ? WA_DEFAULT_EPHEMERAL : 0)
|
|
||||||
await update
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chat.eph_setting_ts) {
|
|
||||||
await toggleDisappearingMessages(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
await delay(1000)
|
|
||||||
|
|
||||||
let msg = await sendAndRetrieveMessage(
|
|
||||||
conn,
|
|
||||||
'This will go poof 😱',
|
|
||||||
MessageType.text
|
|
||||||
)
|
|
||||||
assert.ok(msg.message?.ephemeralMessage)
|
|
||||||
|
|
||||||
const contextInfo = msg.message?.ephemeralMessage?.message?.extendedTextMessage?.contextInfo
|
|
||||||
assert.strictEqual(contextInfo.expiration, chat.ephemeral)
|
|
||||||
assert.strictEqual(+contextInfo.ephemeralSettingTimestamp, +chat.eph_setting_ts)
|
|
||||||
// test message deletion
|
|
||||||
await conn.deleteMessage(testJid, msg.key)
|
|
||||||
|
|
||||||
await delay(1000)
|
|
||||||
|
|
||||||
await toggleDisappearingMessages(false)
|
|
||||||
|
|
||||||
await delay(1000)
|
|
||||||
|
|
||||||
msg = await sendAndRetrieveMessage(
|
|
||||||
conn,
|
|
||||||
'This will not go poof 😔',
|
|
||||||
MessageType.text
|
|
||||||
)
|
|
||||||
assert.ok(msg.message.extendedTextMessage)
|
|
||||||
})
|
|
||||||
it('should block & unblock a user', async () => {
|
|
||||||
const blockedCount = conn.blocklist.length;
|
|
||||||
|
|
||||||
const waitForEventAdded = new Promise<void> (resolve => {
|
|
||||||
conn.once ('blocklist-update', ({added}) => {
|
|
||||||
assert.ok (added.length)
|
|
||||||
resolve ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await conn.blockUser (testJid, 'add')
|
|
||||||
assert.strictEqual(conn.blocklist.length, blockedCount + 1);
|
|
||||||
await waitForEventAdded
|
|
||||||
|
|
||||||
await delay (2000)
|
|
||||||
const waitForEventRemoved = new Promise<void> (resolve => {
|
|
||||||
conn.once ('blocklist-update', ({removed}) => {
|
|
||||||
assert.ok (removed.length)
|
|
||||||
resolve ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await conn.blockUser (testJid, 'remove')
|
|
||||||
assert.strictEqual(conn.blocklist.length, blockedCount);
|
|
||||||
await waitForEventRemoved
|
|
||||||
})
|
|
||||||
it('should exit an invalid query', async () => {
|
|
||||||
// try and send an already sent message
|
|
||||||
let msg: WAMessage
|
|
||||||
await conn.findMessage(testJid, 5, m => {
|
|
||||||
if(m.key.fromMe) {
|
|
||||||
msg = m
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await conn.relayWAMessage(msg)
|
|
||||||
assert.fail('should not have sent')
|
|
||||||
} catch(error) {
|
|
||||||
assert.strictEqual(error.status, 422)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { strict as assert } from 'assert'
|
|
||||||
import { Mutex } from '../WAConnection/Mutex'
|
|
||||||
|
|
||||||
const DEFAULT_WAIT = 1000
|
|
||||||
|
|
||||||
class MyClass {
|
|
||||||
didDoWork = false
|
|
||||||
values: { [k: string]: number } = {}
|
|
||||||
counter = 0
|
|
||||||
|
|
||||||
@Mutex ()
|
|
||||||
async myFunction () {
|
|
||||||
if (this.didDoWork) return
|
|
||||||
|
|
||||||
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
|
|
||||||
if (this.didDoWork) {
|
|
||||||
throw new Error ('work already done')
|
|
||||||
}
|
|
||||||
this.didDoWork = true
|
|
||||||
}
|
|
||||||
@Mutex (key => key)
|
|
||||||
async myKeyedFunction (key: string) {
|
|
||||||
if (!this.values[key]) {
|
|
||||||
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
|
|
||||||
if (this.values[key]) throw new Error ('value already set for ' + key)
|
|
||||||
this.values[key] = Math.floor(Math.random ()*100)
|
|
||||||
}
|
|
||||||
return this.values[key]
|
|
||||||
}
|
|
||||||
@Mutex (key => key)
|
|
||||||
async myQueingFunction (key: string) {
|
|
||||||
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
|
|
||||||
}
|
|
||||||
@Mutex ()
|
|
||||||
async myErrorFunction () {
|
|
||||||
await new Promise (resolve => setTimeout(resolve, 100))
|
|
||||||
this.counter += 1
|
|
||||||
if (this.counter % 2 === 0) {
|
|
||||||
throw new Error ('failed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe ('garbage', () => {
|
|
||||||
it ('should only execute once', async () => {
|
|
||||||
const stuff = new MyClass ()
|
|
||||||
const start = new Date ()
|
|
||||||
await Promise.all ([...Array(1000)].map(() => stuff.myFunction ()))
|
|
||||||
const diff = new Date ().getTime()-start.getTime()
|
|
||||||
assert.ok (diff < DEFAULT_WAIT*1.25)
|
|
||||||
})
|
|
||||||
it ('should only execute once based on the key', async () => {
|
|
||||||
const stuff = new MyClass ()
|
|
||||||
const start = new Date ()
|
|
||||||
/*
|
|
||||||
In this test, the mutex will lock the function based on the key.
|
|
||||||
|
|
||||||
So, if the function with argument `key1` is underway
|
|
||||||
and another function with key `key1` is called,
|
|
||||||
the call is blocked till the first function completes.
|
|
||||||
However, if argument `key2` is passed, the function is allowed to pass.
|
|
||||||
*/
|
|
||||||
const keys = ['key1', 'key2', 'key3']
|
|
||||||
const duplicates = 1000
|
|
||||||
const results = await Promise.all (
|
|
||||||
keys.flatMap (key => (
|
|
||||||
[...Array(duplicates)].map(() => stuff.myKeyedFunction (key))
|
|
||||||
))
|
|
||||||
)
|
|
||||||
assert.deepStrictEqual (
|
|
||||||
results.slice(0, duplicates).filter (r => r !== results[0]),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const diff = new Date ().getTime()-start.getTime()
|
|
||||||
assert.ok (diff < DEFAULT_WAIT*1.25)
|
|
||||||
})
|
|
||||||
it ('should execute operations in a queue', async () => {
|
|
||||||
const stuff = new MyClass ()
|
|
||||||
const start = new Date ()
|
|
||||||
|
|
||||||
const keys = ['key1', 'key2', 'key3']
|
|
||||||
|
|
||||||
await Promise.all (
|
|
||||||
keys.flatMap (key => (
|
|
||||||
[...Array(2)].map(() => stuff.myQueingFunction (key))
|
|
||||||
))
|
|
||||||
)
|
|
||||||
|
|
||||||
const diff = new Date ().getTime()-start.getTime()
|
|
||||||
assert.ok (diff < DEFAULT_WAIT*2.2 && diff > DEFAULT_WAIT*1.5)
|
|
||||||
})
|
|
||||||
it ('should throw an error on selected items', async () => {
|
|
||||||
const stuff = new MyClass ()
|
|
||||||
const start = new Date ()
|
|
||||||
|
|
||||||
const WAIT = 100
|
|
||||||
const FUNCS = 40
|
|
||||||
const results = await Promise.all (
|
|
||||||
[...Array(FUNCS)].map(() => stuff.myErrorFunction ().catch(err => err.message))
|
|
||||||
)
|
|
||||||
|
|
||||||
const diff = new Date ().getTime()-start.getTime()
|
|
||||||
assert.ok (diff < WAIT*FUNCS*1.1)
|
|
||||||
|
|
||||||
assert.strictEqual (
|
|
||||||
results.filter (r => r === 'failed').length,
|
|
||||||
FUNCS/2 // half should fail
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
76
src/Tests/test.media-download.ts
Normal file
76
src/Tests/test.media-download.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { DownloadableMessage, MediaType } from '../Types'
|
||||||
|
import { downloadContentFromMessage } from '../Utils'
|
||||||
|
|
||||||
|
jest.setTimeout(20_000)
|
||||||
|
|
||||||
|
type TestVector = {
|
||||||
|
type: MediaType
|
||||||
|
message: DownloadableMessage
|
||||||
|
plaintext: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEST_VECTORS: TestVector[] = [
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
message: proto.ImageMessage.decode(
|
||||||
|
Buffer.from(
|
||||||
|
'Ck1odHRwczovL21tZy53aGF0c2FwcC5uZXQvZC9mL0FwaHR4WG9fWXZZcDZlUVNSa0tjOHE5d2ozVUpleWdoY3poM3ExX3I0ektnLmVuYxIKaW1hZ2UvanBlZyIgKTuVFyxDc6mTm4GXPlO3Z911Wd8RBeTrPLSWAEdqW8MomcUBQiB7wH5a4nXMKyLOT0A2nFgnnM/DUH8YjQf8QtkCIekaSkogTB+BXKCWDFrmNzozY0DCPn0L4VKd7yG1ZbZwbgRhzVc=',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
plaintext: readFileSync('./Media/cat.jpeg')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
message: proto.ImageMessage.decode(
|
||||||
|
Buffer.from(
|
||||||
|
'Ck1odHRwczovL21tZy53aGF0c2FwcC5uZXQvZC9mL0Ftb2tnWkphNWF6QWZxa3dVRzc0eUNUdTlGeWpjMmd5akpqcXNmMUFpZEU5LmVuYxIKaW1hZ2UvanBlZyIg8IS5TQzdzcuvcR7F8HMhWnXmlsV+GOo9JE1/t2k+o9Yoz6o6QiA7kDk8j5KOEQC0kDFE1qW7lBBDYhm5z06N3SirfUj3CUog/CjYF8e670D5wUJwWv2B2mKzDEo8IJLStDv76YmtPfs=',
|
||||||
|
'base64'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
plaintext: readFileSync('./Media/icon.png')
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('Media Download Tests', () => {
|
||||||
|
|
||||||
|
it('should download a full encrypted media correctly', async() => {
|
||||||
|
for(const { type, message, plaintext } of TEST_VECTORS) {
|
||||||
|
const readPipe = await downloadContentFromMessage(message, type)
|
||||||
|
|
||||||
|
let buffer = Buffer.alloc(0)
|
||||||
|
for await (const read of readPipe) {
|
||||||
|
buffer = Buffer.concat([ buffer, read ])
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(buffer).toEqual(plaintext)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should download an encrypted media correctly piece', async() => {
|
||||||
|
for(const { type, message, plaintext } of TEST_VECTORS) {
|
||||||
|
// check all edge cases
|
||||||
|
const ranges = [
|
||||||
|
{ startByte: 51, endByte: plaintext.length-100 }, // random numbers
|
||||||
|
{ startByte: 1024, endByte: 2038 }, // larger random multiples of 16
|
||||||
|
{ startByte: 1, endByte: plaintext.length-1 } // borders
|
||||||
|
]
|
||||||
|
for(const range of ranges) {
|
||||||
|
const readPipe = await downloadContentFromMessage(message, type, range)
|
||||||
|
|
||||||
|
let buffer = Buffer.alloc(0)
|
||||||
|
for await (const read of readPipe) {
|
||||||
|
buffer = Buffer.concat([ buffer, read ])
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = buffer.toString('hex')
|
||||||
|
const expectedHex = plaintext.slice(range.startByte || 0, range.endByte || undefined).toString('hex')
|
||||||
|
expect(hex).toBe(expectedHex)
|
||||||
|
|
||||||
|
console.log('success on ', range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
77
src/Types/Auth.ts
Normal file
77
src/Types/Auth.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { proto } from '../../WAProto'
|
||||||
|
import type { Contact } from './Contact'
|
||||||
|
|
||||||
|
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 type SignalIdentity = {
|
||||||
|
identifier: ProtocolAddress
|
||||||
|
identifierKey: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LTHashState = {
|
||||||
|
version: number
|
||||||
|
hash: Buffer
|
||||||
|
indexValueMap: {
|
||||||
|
[indexMacBase64: string]: { valueMac: Uint8Array | Buffer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignalCreds = {
|
||||||
|
readonly signedIdentityKey: KeyPair
|
||||||
|
readonly signedPreKey: SignedKeyPair
|
||||||
|
readonly registrationId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthenticationCreds = SignalCreds & {
|
||||||
|
readonly noiseKey: KeyPair
|
||||||
|
readonly advSecretKey: string
|
||||||
|
|
||||||
|
me?: Contact
|
||||||
|
account?: proto.IADVSignedDeviceIdentity
|
||||||
|
signalIdentities?: SignalIdentity[]
|
||||||
|
myAppStateKeyId?: string
|
||||||
|
firstUnuploadedPreKeyId: number
|
||||||
|
serverHasPreKeys: boolean
|
||||||
|
nextPreKeyId: number
|
||||||
|
|
||||||
|
lastAccountSyncTimestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignalDataTypeMap = {
|
||||||
|
'pre-key': KeyPair
|
||||||
|
'session': any
|
||||||
|
'sender-key': any
|
||||||
|
'sender-key-memory': { [jid: string]: boolean }
|
||||||
|
'app-state-sync-key': proto.IAppStateSyncKeyData
|
||||||
|
'app-state-sync-version': LTHashState
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignalDataSet = { [T in keyof SignalDataTypeMap]?: { [id: string]: SignalDataTypeMap[T] | null } }
|
||||||
|
|
||||||
|
type Awaitable<T> = T | Promise<T>
|
||||||
|
|
||||||
|
export type SignalKeyStore = {
|
||||||
|
get<T extends keyof SignalDataTypeMap>(type: T, ids: string[]): Awaitable<{ [id: string]: SignalDataTypeMap[T] }>
|
||||||
|
set(data: SignalDataSet): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignalKeyStoreWithTransaction = SignalKeyStore & {
|
||||||
|
isInTransaction: () => boolean
|
||||||
|
transaction(exec: () => Promise<void>): Promise<void>
|
||||||
|
prefetch<T extends keyof SignalDataTypeMap>(type: T, ids: string[]): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignalAuthState = {
|
||||||
|
creds: SignalCreds
|
||||||
|
keys: SignalKeyStore
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthenticationState = {
|
||||||
|
creds: AuthenticationCreds
|
||||||
|
keys: SignalKeyStore
|
||||||
|
}
|
||||||
66
src/Types/Chat.ts
Normal file
66
src/Types/Chat.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { proto } from '../../WAProto'
|
||||||
|
|
||||||
|
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
|
||||||
|
export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'
|
||||||
|
|
||||||
|
export type WAPatchName = 'critical_block' | 'critical_unblock_low' | 'regular_low' | 'regular_high' | 'regular'
|
||||||
|
|
||||||
|
export interface PresenceData {
|
||||||
|
lastKnownPresence: WAPresence
|
||||||
|
lastSeen?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatMutation = {
|
||||||
|
syncAction: proto.ISyncActionData
|
||||||
|
index: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppStateChunk = { totalMutations : ChatMutation[], collectionsToHandle: WAPatchName[] }
|
||||||
|
|
||||||
|
export type WAPatchCreate = {
|
||||||
|
syncAction: proto.ISyncActionValue
|
||||||
|
index: string[]
|
||||||
|
type: WAPatchName
|
||||||
|
apiVersion: number
|
||||||
|
operation: proto.SyncdMutation.SyncdMutationSyncdOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* the last messages in a chat, sorted reverse-chronologically
|
||||||
|
* for MD modifications, the last message in the array must be the last message recv in the chat
|
||||||
|
* */
|
||||||
|
export type LastMessageList = Pick<proto.IWebMessageInfo, 'key' | 'messageTimestamp'>[]
|
||||||
|
|
||||||
|
export type ChatModification =
|
||||||
|
{
|
||||||
|
archive: boolean
|
||||||
|
lastMessages: LastMessageList
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
pin: boolean
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
/** mute for duration, or provide timestamp of mute to remove*/
|
||||||
|
mute: number | null
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
clear: 'all' | { messages: {id: string, fromMe?: boolean}[] }
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
star: {
|
||||||
|
messages: { id: string, fromMe?: boolean }[],
|
||||||
|
star: boolean
|
||||||
|
}
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
markRead: boolean
|
||||||
|
lastMessages: LastMessageList
|
||||||
|
} |
|
||||||
|
{ delete: true, lastMessages: LastMessageList }
|
||||||
12
src/Types/Contact.ts
Normal file
12
src/Types/Contact.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export interface Contact {
|
||||||
|
id: string
|
||||||
|
/** name of the contact, you have saved on your WA */
|
||||||
|
name?: 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
|
||||||
|
}
|
||||||
58
src/Types/Events.ts
Normal file
58
src/Types/Events.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type EventEmitter from 'events'
|
||||||
|
import { AuthenticationCreds } from './Auth'
|
||||||
|
import { Chat, PresenceData } from './Chat'
|
||||||
|
import { Contact } from './Contact'
|
||||||
|
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
|
||||||
|
import { MessageUpdateType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
|
||||||
|
import { ConnectionState } from './State'
|
||||||
|
|
||||||
|
export type BaileysEventMap<T> = {
|
||||||
|
/** connection state has been updated -- WS closed, opened, connecting etc. */
|
||||||
|
'connection.update': Partial<ConnectionState>
|
||||||
|
/** credentials updated -- some metadata, keys or something */
|
||||||
|
'creds.update': Partial<T>
|
||||||
|
/** set chats (history sync), chats are reverse chronologically sorted */
|
||||||
|
'chats.set': { chats: Chat[], isLatest: boolean }
|
||||||
|
/** set messages (history sync), messages are reverse chronologically sorted */
|
||||||
|
'messages.set': { messages: WAMessage[], isLatest: boolean }
|
||||||
|
/** set contacts (history sync) */
|
||||||
|
'contacts.set': { contacts: Contact[] }
|
||||||
|
/** 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 } }
|
||||||
|
|
||||||
|
'contacts.upsert': Contact[]
|
||||||
|
'contacts.update': Partial<Contact>[]
|
||||||
|
|
||||||
|
'messages.delete': { keys: WAMessageKey[] } | { 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-receipt.update': MessageUserReceiptUpdate[]
|
||||||
|
|
||||||
|
'groups.upsert': GroupMetadata[]
|
||||||
|
'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' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommonBaileysEventEmitter<Creds> extends EventEmitter {
|
||||||
|
on<T extends keyof BaileysEventMap<Creds>>(event: T, listener: (arg: BaileysEventMap<Creds>[T]) => void): this
|
||||||
|
off<T extends keyof BaileysEventMap<Creds>>(event: T, listener: (arg: BaileysEventMap<Creds>[T]) => void): this
|
||||||
|
removeAllListeners<T extends keyof BaileysEventMap<Creds>>(event: T): this
|
||||||
|
emit<T extends keyof BaileysEventMap<Creds>>(event: T, arg: BaileysEventMap<Creds>[T]): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaileysEventEmitter = CommonBaileysEventEmitter<AuthenticationCreds>
|
||||||
34
src/Types/GroupMetadata.ts
Normal file
34
src/Types/GroupMetadata.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Contact } from './Contact'
|
||||||
|
|
||||||
|
export type GroupParticipant = (Contact & { isAdmin?: boolean; isSuperAdmin?: boolean, admin?: 'admin' | 'superadmin' | null })
|
||||||
|
|
||||||
|
export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote'
|
||||||
|
|
||||||
|
export interface GroupMetadata {
|
||||||
|
id: string
|
||||||
|
owner: string | undefined
|
||||||
|
subject: string
|
||||||
|
creation: number
|
||||||
|
desc?: string
|
||||||
|
descOwner?: string
|
||||||
|
descId?: string
|
||||||
|
/** is set when the group only allows admins to change group settings */
|
||||||
|
restrict?: boolean
|
||||||
|
/** is set when the group only allows admins to write messages */
|
||||||
|
announce?: boolean
|
||||||
|
// Baileys modified array
|
||||||
|
participants: GroupParticipant[]
|
||||||
|
ephemeralDuration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface WAGroupCreateResponse {
|
||||||
|
status: number
|
||||||
|
gid?: string
|
||||||
|
participants?: [{ [key: string]: any }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupModificationResponse {
|
||||||
|
status: number
|
||||||
|
participants?: { [key: string]: any }
|
||||||
|
}
|
||||||
81
src/Types/Legacy.ts
Normal file
81
src/Types/Legacy.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BinaryNode } from '../WABinary'
|
||||||
|
import { CommonBaileysEventEmitter } from './Events'
|
||||||
|
import { CommonSocketConfig } from './Socket'
|
||||||
|
|
||||||
|
export interface LegacyAuthenticationCreds {
|
||||||
|
clientID: string
|
||||||
|
serverToken: string
|
||||||
|
clientToken: string
|
||||||
|
encKey: Buffer
|
||||||
|
macKey: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocketQueryOptions = SocketSendMessageOptions & {
|
||||||
|
timeoutMs?: number
|
||||||
|
expect200?: boolean
|
||||||
|
requiresPhoneConnection?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegacySocketConfig = CommonSocketConfig<LegacyAuthenticationCreds> & {
|
||||||
|
/** max time for the phone to respond to a connectivity test */
|
||||||
|
phoneResponseTimeMs: number
|
||||||
|
/** max time for WA server to respond before error with 422 */
|
||||||
|
expectResponseTimeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegacyBaileysEventEmitter = CommonBaileysEventEmitter<LegacyAuthenticationCreds>
|
||||||
175
src/Types/Message.ts
Normal file
175
src/Types/Message.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type NodeCache from 'node-cache'
|
||||||
|
import type { Logger } from 'pino'
|
||||||
|
import type { Readable } from 'stream'
|
||||||
|
import type { URL } from 'url'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import type { GroupMetadata } from './GroupMetadata'
|
||||||
|
|
||||||
|
// export the WAMessage Prototypes
|
||||||
|
export { proto as WAProto }
|
||||||
|
export type WAMessage = proto.IWebMessageInfo
|
||||||
|
export type WAMessageContent = proto.IMessage
|
||||||
|
export type WAContactMessage = proto.IContactMessage
|
||||||
|
export type WAContactsArrayMessage = proto.IContactsArrayMessage
|
||||||
|
export type WAMessageKey = proto.IMessageKey
|
||||||
|
export type WATextMessage = proto.IExtendedTextMessage
|
||||||
|
export type WAContextInfo = proto.IContextInfo
|
||||||
|
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
|
||||||
|
export type WAMediaUpload = Buffer | { url: URL | string } | { stream: Readable }
|
||||||
|
/** Set of message types that are supported by the library */
|
||||||
|
export type MessageType = keyof proto.Message
|
||||||
|
|
||||||
|
export type DownloadableMessage = { mediaKey?: Uint8Array, directPath?: string, url?: string }
|
||||||
|
|
||||||
|
export type MediaConnInfo = {
|
||||||
|
auth: string
|
||||||
|
ttl: number
|
||||||
|
hosts: { hostname: string, maxContentLengthBytes: number }[]
|
||||||
|
fetchDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WAUrlInfo {
|
||||||
|
'canonical-url': string
|
||||||
|
'matched-text': string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
jpegThumbnail?: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// types to generate WA messages
|
||||||
|
type Mentionable = {
|
||||||
|
/** list of jids that are mentioned in the accompanying text */
|
||||||
|
mentions?: string[]
|
||||||
|
}
|
||||||
|
type ViewOnce = {
|
||||||
|
viewOnce?: boolean
|
||||||
|
}
|
||||||
|
type Buttonable = {
|
||||||
|
/** add buttons to the message */
|
||||||
|
buttons?: proto.IButton[]
|
||||||
|
}
|
||||||
|
type Templatable = {
|
||||||
|
/** add buttons to the message (conflicts with normal buttons)*/
|
||||||
|
templateButtons?: proto.IHydratedTemplateButton[]
|
||||||
|
|
||||||
|
footer?: string
|
||||||
|
}
|
||||||
|
type Listable = {
|
||||||
|
/** Sections of the List */
|
||||||
|
sections?: proto.ISection[]
|
||||||
|
|
||||||
|
/** Title of a List Message only */
|
||||||
|
title?: string
|
||||||
|
|
||||||
|
/** Text of the bnutton on the list (required) */
|
||||||
|
buttonText?: string
|
||||||
|
}
|
||||||
|
type WithDimensions = {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document' | 'history' | 'md-app-state'
|
||||||
|
export type AnyMediaMessageContent = (
|
||||||
|
({
|
||||||
|
image: WAMediaUpload
|
||||||
|
caption?: string
|
||||||
|
jpegThumbnail?: string
|
||||||
|
} & Mentionable & Buttonable & Templatable & WithDimensions) |
|
||||||
|
({
|
||||||
|
video: WAMediaUpload
|
||||||
|
caption?: string
|
||||||
|
gifPlayback?: boolean
|
||||||
|
jpegThumbnail?: string
|
||||||
|
} & Mentionable & Buttonable & Templatable & WithDimensions) | {
|
||||||
|
audio: WAMediaUpload
|
||||||
|
/** if set to true, will send as a `voice note` */
|
||||||
|
ptt?: boolean
|
||||||
|
/** optionally tell the duration of the audio */
|
||||||
|
seconds?: number
|
||||||
|
} | ({
|
||||||
|
sticker: WAMediaUpload
|
||||||
|
} & WithDimensions) | ({
|
||||||
|
document: WAMediaUpload
|
||||||
|
mimetype: string
|
||||||
|
fileName?: string
|
||||||
|
} & Buttonable & Templatable)) &
|
||||||
|
{ mimetype?: string }
|
||||||
|
|
||||||
|
export type AnyRegularMessageContent = (
|
||||||
|
({
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
& Mentionable & Buttonable & Templatable & Listable) |
|
||||||
|
AnyMediaMessageContent |
|
||||||
|
{
|
||||||
|
contacts: {
|
||||||
|
displayName?: string
|
||||||
|
contacts: proto.IContactMessage[]
|
||||||
|
}
|
||||||
|
} |
|
||||||
|
{
|
||||||
|
location: WALocationMessage
|
||||||
|
}
|
||||||
|
) & ViewOnce
|
||||||
|
|
||||||
|
export type AnyMessageContent = AnyRegularMessageContent | {
|
||||||
|
forward: WAMessage
|
||||||
|
force?: boolean
|
||||||
|
} | {
|
||||||
|
delete: WAMessageKey
|
||||||
|
} | {
|
||||||
|
disappearingMessagesInChat: boolean | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageRelayOptions = {
|
||||||
|
messageId?: string
|
||||||
|
/** only send to a specific participant */
|
||||||
|
participant?: string
|
||||||
|
additionalAttributes?: { [_: string]: string }
|
||||||
|
cachedGroupMetadata?: (jid: string) => Promise<GroupMetadata | undefined>
|
||||||
|
//cachedDevices?: (jid: string) => Promise<string[] | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MiscMessageGenerationOptions = {
|
||||||
|
/** Force message id */
|
||||||
|
messageId?: string
|
||||||
|
/** optional, if you want to manually set the timestamp of the message */
|
||||||
|
timestamp?: Date
|
||||||
|
/** the message you want to quote */
|
||||||
|
quoted?: WAMessage
|
||||||
|
/** disappearing messages settings */
|
||||||
|
ephemeralExpiration?: number | string
|
||||||
|
|
||||||
|
mediaUploadTimeoutMs?: number
|
||||||
|
}
|
||||||
|
export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & {
|
||||||
|
userJid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WAMediaUploadFunction = (readStream: Readable, opts: { fileEncSha256B64: string, mediaType: MediaType, timeoutMs?: number }) => Promise<{ mediaUrl: string, directPath: string }>
|
||||||
|
|
||||||
|
export type MediaGenerationOptions = {
|
||||||
|
logger?: Logger
|
||||||
|
upload: WAMediaUploadFunction
|
||||||
|
/** cache media so it does not have to be uploaded again */
|
||||||
|
mediaCache?: NodeCache
|
||||||
|
|
||||||
|
mediaUploadTimeoutMs?: number
|
||||||
|
}
|
||||||
|
export type MessageContentGenerationOptions = MediaGenerationOptions & {
|
||||||
|
getUrlInfo?: (text: string) => Promise<WAUrlInfo>
|
||||||
|
}
|
||||||
|
export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent
|
||||||
|
|
||||||
|
export type MessageUpdateType = 'append' | 'notify' | 'replace'
|
||||||
|
|
||||||
|
export type MessageUserReceipt = proto.IUserReceipt
|
||||||
|
|
||||||
|
export type WAMessageUpdate = { update: Partial<WAMessage>, key: proto.IMessageKey }
|
||||||
|
|
||||||
|
export type WAMessageCursor = { before: WAMessageKey | undefined } | { after: WAMessageKey | undefined }
|
||||||
|
|
||||||
|
export type MessageUserReceiptUpdate = { key: proto.IMessageKey, receipt: MessageUserReceipt }
|
||||||
40
src/Types/Socket.ts
Normal file
40
src/Types/Socket.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
import type { Agent } from 'https'
|
||||||
|
import type NodeCache from 'node-cache'
|
||||||
|
import type { Logger } from 'pino'
|
||||||
|
import type { URL } from 'url'
|
||||||
|
import { MediaConnInfo } from './Message'
|
||||||
|
|
||||||
|
export type WAVersion = [number, number, number]
|
||||||
|
export type WABrowserDescription = [string, string, string]
|
||||||
|
|
||||||
|
export type CommonSocketConfig<T> = {
|
||||||
|
/** provide an auth state object to maintain the auth state */
|
||||||
|
auth?: T
|
||||||
|
/** the WS url to connect to WA */
|
||||||
|
waWebSocketUrl: string | URL
|
||||||
|
/** Fails the connection if the socket times out in this interval */
|
||||||
|
connectTimeoutMs: number
|
||||||
|
/** Default timeout for queries, undefined for no timeout */
|
||||||
|
defaultQueryTimeoutMs: number | undefined
|
||||||
|
/** 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
|
||||||
|
/** should events be emitted for actions done by this socket connection */
|
||||||
|
emitOwnEvents: boolean
|
||||||
|
/** provide a cache to store media, so does not have to be re-uploaded */
|
||||||
|
mediaCache?: NodeCache
|
||||||
|
|
||||||
|
customUploadHosts: MediaConnInfo['hosts']
|
||||||
|
}
|
||||||
25
src/Types/State.ts
Normal file
25
src/Types/State.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Contact } from './Contact'
|
||||||
|
|
||||||
|
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
|
||||||
|
/** legacy connection options */
|
||||||
|
legacy?: {
|
||||||
|
phoneConnected: boolean
|
||||||
|
user?: Contact
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
69
src/Types/index.ts
Normal file
69
src/Types/index.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export * from './Auth'
|
||||||
|
export * from './GroupMetadata'
|
||||||
|
export * from './Chat'
|
||||||
|
export * from './Contact'
|
||||||
|
export * from './State'
|
||||||
|
export * from './Message'
|
||||||
|
export * from './Legacy'
|
||||||
|
export * from './Socket'
|
||||||
|
export * from './Events'
|
||||||
|
|
||||||
|
import type NodeCache from 'node-cache'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { AuthenticationState } from './Auth'
|
||||||
|
import { CommonSocketConfig } from './Socket'
|
||||||
|
|
||||||
|
export type SocketConfig = CommonSocketConfig<AuthenticationState> & {
|
||||||
|
/** provide a cache to store a user's device list */
|
||||||
|
userDevicesCache?: NodeCache
|
||||||
|
/** map to store the retry counts for failed messages */
|
||||||
|
msgRetryCounterMap?: { [msgId: string]: number }
|
||||||
|
/**
|
||||||
|
* fetch a message from your store
|
||||||
|
* implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried
|
||||||
|
* */
|
||||||
|
getMessage: (key: proto.IMessageKey) => Promise<proto.IMessage | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DisconnectReason {
|
||||||
|
connectionClosed = 428,
|
||||||
|
connectionLost = 408,
|
||||||
|
connectionReplaced = 440,
|
||||||
|
timedOut = 408,
|
||||||
|
loggedOut = 401,
|
||||||
|
badSession = 500,
|
||||||
|
restartRequired = 410,
|
||||||
|
multideviceMismatch = 411
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WAInitResponse = {
|
||||||
|
ref: string
|
||||||
|
ttl: number
|
||||||
|
status: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WABusinessHoursConfig = {
|
||||||
|
day_of_week: string
|
||||||
|
mode: string
|
||||||
|
open_time?: number
|
||||||
|
close_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WABusinessProfile = {
|
||||||
|
description: string
|
||||||
|
email: string
|
||||||
|
business_hours: {
|
||||||
|
timezone?: string
|
||||||
|
config?: WABusinessHoursConfig[]
|
||||||
|
business_config?: WABusinessHoursConfig[]
|
||||||
|
}
|
||||||
|
website: string[]
|
||||||
|
categories: {
|
||||||
|
id: string
|
||||||
|
localized_display_name: string
|
||||||
|
}[]
|
||||||
|
wid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type CurveKeyPair = { private: Uint8Array; public: Uint8Array }
|
||||||
178
src/Utils/auth-utils.ts
Normal file
178
src/Utils/auth-utils.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import type { Logger } from 'pino'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import type { AuthenticationCreds, AuthenticationState, SignalDataSet, SignalDataTypeMap, SignalKeyStore, SignalKeyStoreWithTransaction } from '../Types'
|
||||||
|
import { Curve, signedKeyPair } from './crypto'
|
||||||
|
import { BufferJSON, generateRegistrationId } from './generics'
|
||||||
|
|
||||||
|
const KEY_MAP: { [T in keyof SignalDataTypeMap]: string } = {
|
||||||
|
'pre-key': 'preKeys',
|
||||||
|
'session': 'sessions',
|
||||||
|
'sender-key': 'senderKeys',
|
||||||
|
'app-state-sync-key': 'appStateSyncKeys',
|
||||||
|
'app-state-sync-version': 'appStateVersions',
|
||||||
|
'sender-key-memory': 'senderKeyMemory'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addTransactionCapability = (state: SignalKeyStore, logger: Logger): SignalKeyStoreWithTransaction => {
|
||||||
|
let inTransaction = false
|
||||||
|
let transactionCache: SignalDataSet = { }
|
||||||
|
let mutations: SignalDataSet = { }
|
||||||
|
|
||||||
|
const prefetch = async(type: keyof SignalDataTypeMap, ids: string[]) => {
|
||||||
|
if(!inTransaction) {
|
||||||
|
throw new Boom('Cannot prefetch without transaction')
|
||||||
|
}
|
||||||
|
|
||||||
|
const dict = transactionCache[type]
|
||||||
|
const idsRequiringFetch = dict ? ids.filter(item => !(item in dict)) : ids
|
||||||
|
// only fetch if there are any items to fetch
|
||||||
|
if(idsRequiringFetch.length) {
|
||||||
|
const result = await state.get(type, idsRequiringFetch)
|
||||||
|
|
||||||
|
transactionCache[type] = transactionCache[type] || { }
|
||||||
|
Object.assign(transactionCache[type], result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: async(type, ids) => {
|
||||||
|
if(inTransaction) {
|
||||||
|
await prefetch(type, ids)
|
||||||
|
return ids.reduce(
|
||||||
|
(dict, id) => {
|
||||||
|
const value = transactionCache[type]?.[id]
|
||||||
|
if(value) {
|
||||||
|
dict[id] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict
|
||||||
|
}, { }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return state.get(type, ids)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: data => {
|
||||||
|
if(inTransaction) {
|
||||||
|
logger.trace({ types: Object.keys(data) }, 'caching in transaction')
|
||||||
|
for(const key in data) {
|
||||||
|
transactionCache[key] = transactionCache[key] || { }
|
||||||
|
Object.assign(transactionCache[key], data[key])
|
||||||
|
|
||||||
|
mutations[key] = mutations[key] || { }
|
||||||
|
Object.assign(mutations[key], data[key])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return state.set(data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isInTransaction: () => inTransaction,
|
||||||
|
prefetch: (type, ids) => {
|
||||||
|
logger.trace({ type, ids }, 'prefetching')
|
||||||
|
return prefetch(type, ids)
|
||||||
|
},
|
||||||
|
transaction: async(work) => {
|
||||||
|
if(inTransaction) {
|
||||||
|
await work()
|
||||||
|
} else {
|
||||||
|
logger.debug('entering transaction')
|
||||||
|
inTransaction = true
|
||||||
|
try {
|
||||||
|
await work()
|
||||||
|
if(Object.keys(mutations).length) {
|
||||||
|
logger.debug('committing transaction')
|
||||||
|
await state.set(mutations)
|
||||||
|
} else {
|
||||||
|
logger.debug('no mutations in transaction')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inTransaction = false
|
||||||
|
transactionCache = { }
|
||||||
|
mutations = { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initAuthCreds = (): AuthenticationCreds => {
|
||||||
|
const identityKey = Curve.generateKeyPair()
|
||||||
|
return {
|
||||||
|
noiseKey: Curve.generateKeyPair(),
|
||||||
|
signedIdentityKey: identityKey,
|
||||||
|
signedPreKey: signedKeyPair(identityKey, 1),
|
||||||
|
registrationId: generateRegistrationId(),
|
||||||
|
advSecretKey: randomBytes(32).toString('base64'),
|
||||||
|
|
||||||
|
nextPreKeyId: 1,
|
||||||
|
firstUnuploadedPreKeyId: 1,
|
||||||
|
serverHasPreKeys: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stores the full authentication state in a single JSON file */
|
||||||
|
export const useSingleFileAuthState = (filename: string, logger?: Logger): { state: AuthenticationState, saveState: () => void } => {
|
||||||
|
// require fs here so that in case "fs" is not available -- the app does not crash
|
||||||
|
const { readFileSync, writeFileSync, existsSync } = require('fs')
|
||||||
|
let creds: AuthenticationCreds
|
||||||
|
let keys: any = { }
|
||||||
|
|
||||||
|
// save the authentication state to a file
|
||||||
|
const saveState = () => {
|
||||||
|
logger && logger.trace('saving auth state')
|
||||||
|
writeFileSync(
|
||||||
|
filename,
|
||||||
|
// BufferJSON replacer utility saves buffers nicely
|
||||||
|
JSON.stringify({ creds, keys }, BufferJSON.replacer, 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(existsSync(filename)) {
|
||||||
|
const result = JSON.parse(
|
||||||
|
readFileSync(filename, { encoding: 'utf-8' }),
|
||||||
|
BufferJSON.reviver
|
||||||
|
)
|
||||||
|
creds = result.creds
|
||||||
|
keys = result.keys
|
||||||
|
} else {
|
||||||
|
creds = initAuthCreds()
|
||||||
|
keys = { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
creds,
|
||||||
|
keys: {
|
||||||
|
get: (type, ids) => {
|
||||||
|
const key = KEY_MAP[type]
|
||||||
|
return ids.reduce(
|
||||||
|
(dict, id) => {
|
||||||
|
let value = keys[key]?.[id]
|
||||||
|
if(value) {
|
||||||
|
if(type === 'app-state-sync-key') {
|
||||||
|
value = proto.AppStateSyncKeyData.fromObject(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
dict[id] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict
|
||||||
|
}, { }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
set: (data) => {
|
||||||
|
for(const _key in data) {
|
||||||
|
const key = KEY_MAP[_key as keyof SignalDataTypeMap]
|
||||||
|
keys[key] = keys[key] || { }
|
||||||
|
Object.assign(keys[key], data[_key])
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveState
|
||||||
|
}
|
||||||
|
}
|
||||||
541
src/Utils/chat-utils.ts
Normal file
541
src/Utils/chat-utils.ts
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { ChatModification, ChatMutation, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types'
|
||||||
|
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren } from '../WABinary'
|
||||||
|
import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto'
|
||||||
|
import { toNumber } from './generics'
|
||||||
|
import { LT_HASH_ANTI_TAMPERING } from './lt-hash'
|
||||||
|
import { downloadContentFromMessage, } from './messages-media'
|
||||||
|
|
||||||
|
type FetchAppStateSyncKey = (keyId: string) => Promise<proto.IAppStateSyncKeyData> | proto.IAppStateSyncKeyData
|
||||||
|
|
||||||
|
const mutationKeys = (keydata: Uint8Array) => {
|
||||||
|
const expanded = hkdf(keydata, 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 = (e: number) => {
|
||||||
|
const t = new ArrayBuffer(8)
|
||||||
|
new DataView(t).setUint32(4, e, !1)
|
||||||
|
return Buffer.from(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mac = { indexMac: Uint8Array, valueMac: Uint8Array, operation: proto.SyncdMutation.SyncdMutationSyncdOperation }
|
||||||
|
|
||||||
|
const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => {
|
||||||
|
indexValueMap = { ...indexValueMap }
|
||||||
|
const addBuffs: ArrayBuffer[] = []
|
||||||
|
const subBuffs: ArrayBuffer[] = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
mix: ({ indexMac, valueMac, operation }: Mac) => {
|
||||||
|
const indexMacBase64 = Buffer.from(indexMac).toString('base64')
|
||||||
|
const prevOp = indexValueMap[indexMacBase64]
|
||||||
|
if(operation === proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE) {
|
||||||
|
if(!prevOp) {
|
||||||
|
throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove from index value mac, since this mutation is erased
|
||||||
|
delete indexValueMap[indexMacBase64]
|
||||||
|
} else {
|
||||||
|
addBuffs.push(new Uint8Array(valueMac).buffer)
|
||||||
|
// add this index into the history map
|
||||||
|
indexValueMap[indexMacBase64] = { valueMac }
|
||||||
|
}
|
||||||
|
|
||||||
|
if(prevOp) {
|
||||||
|
subBuffs.push(new Uint8Array(prevOp.valueMac).buffer)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
finish: () => {
|
||||||
|
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(hash).buffer, addBuffs, subBuffs)
|
||||||
|
const buffer = Buffer.from(result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: buffer,
|
||||||
|
indexValueMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Buffer) => {
|
||||||
|
const total = Buffer.concat([
|
||||||
|
lthash,
|
||||||
|
to64BitNetworkOrder(version),
|
||||||
|
Buffer.from(name, 'utf-8')
|
||||||
|
])
|
||||||
|
return hmacSign(total, key, 'sha256')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: WAPatchName, key: Buffer) => {
|
||||||
|
const total = Buffer.concat([
|
||||||
|
snapshotMac,
|
||||||
|
...valueMacs,
|
||||||
|
to64BitNetworkOrder(version),
|
||||||
|
Buffer.from(type, 'utf-8')
|
||||||
|
])
|
||||||
|
return hmacSign(total, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} })
|
||||||
|
|
||||||
|
export const encodeSyncdPatch = async(
|
||||||
|
{ type, index, syncAction, apiVersion, operation }: WAPatchCreate,
|
||||||
|
myAppStateKeyId: string,
|
||||||
|
state: LTHashState,
|
||||||
|
getAppStateSyncKey: FetchAppStateSyncKey
|
||||||
|
) => {
|
||||||
|
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
|
||||||
|
if(!key) {
|
||||||
|
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const encKeyId = Buffer.from(myAppStateKeyId, 'base64')
|
||||||
|
|
||||||
|
state = { ...state, indexValueMap: { ...state.indexValueMap } }
|
||||||
|
|
||||||
|
const indexBuffer = Buffer.from(JSON.stringify(index))
|
||||||
|
const dataProto = proto.SyncActionData.fromObject({
|
||||||
|
index: indexBuffer,
|
||||||
|
value: syncAction,
|
||||||
|
padding: new Uint8Array(0),
|
||||||
|
version: apiVersion
|
||||||
|
})
|
||||||
|
const encoded = proto.SyncActionData.encode(dataProto).finish()
|
||||||
|
|
||||||
|
const keyValue = mutationKeys(key!.keyData!)
|
||||||
|
|
||||||
|
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
|
||||||
|
const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey)
|
||||||
|
const indexMac = hmacSign(indexBuffer, keyValue.indexKey)
|
||||||
|
|
||||||
|
// update LT hash
|
||||||
|
const generator = makeLtHashGenerator(state)
|
||||||
|
generator.mix({ indexMac, valueMac, operation })
|
||||||
|
Object.assign(state, generator.finish())
|
||||||
|
|
||||||
|
state.version += 1
|
||||||
|
|
||||||
|
const snapshotMac = generateSnapshotMac(state.hash, state.version, type, keyValue.snapshotMacKey)
|
||||||
|
|
||||||
|
const patch: proto.ISyncdPatch = {
|
||||||
|
patchMac: generatePatchMac(snapshotMac, [valueMac], state.version, type, keyValue.patchMacKey),
|
||||||
|
snapshotMac: snapshotMac,
|
||||||
|
keyId: { id: encKeyId },
|
||||||
|
mutations: [
|
||||||
|
{
|
||||||
|
operation: operation,
|
||||||
|
record: {
|
||||||
|
index: {
|
||||||
|
blob: indexMac
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
blob: Buffer.concat([ encValue, valueMac ])
|
||||||
|
},
|
||||||
|
keyId: { id: encKeyId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Index = indexMac.toString('base64')
|
||||||
|
state.indexValueMap[base64Index] = { valueMac }
|
||||||
|
|
||||||
|
return { patch, state }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeSyncdMutations = async(
|
||||||
|
msgMutations: (proto.ISyncdMutation | proto.ISyncdRecord)[],
|
||||||
|
initialState: LTHashState,
|
||||||
|
getAppStateSyncKey: FetchAppStateSyncKey,
|
||||||
|
validateMacs: boolean
|
||||||
|
) => {
|
||||||
|
const keyCache: { [_: string]: ReturnType<typeof mutationKeys> } = { }
|
||||||
|
const getKey = async(keyId: Uint8Array) => {
|
||||||
|
const base64Key = Buffer.from(keyId!).toString('base64')
|
||||||
|
let key = keyCache[base64Key]
|
||||||
|
if(!key) {
|
||||||
|
const keyEnc = await getAppStateSyncKey(base64Key)
|
||||||
|
if(!keyEnc) {
|
||||||
|
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 404, data: { msgMutations } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = mutationKeys(keyEnc.keyData!)
|
||||||
|
keyCache[base64Key] = result
|
||||||
|
key = result
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
const ltGenerator = makeLtHashGenerator(initialState)
|
||||||
|
|
||||||
|
const mutations: ChatMutation[] = []
|
||||||
|
// 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 msgMutation of msgMutations!) {
|
||||||
|
// if it's a syncdmutation, get the operation property
|
||||||
|
// otherwise, if it's only a record -- it'll be a SET mutation
|
||||||
|
const operation = 'operation' in msgMutation ? msgMutation.operation : proto.SyncdMutation.SyncdMutationSyncdOperation.SET
|
||||||
|
const record = ('record' in msgMutation && !!msgMutation.record) ? msgMutation.record : msgMutation as proto.ISyncdRecord
|
||||||
|
|
||||||
|
const key = await getKey(record.keyId!.id!)
|
||||||
|
const content = Buffer.from(record.value!.blob!)
|
||||||
|
const encContent = content.slice(0, -32)
|
||||||
|
const ogValueMac = content.slice(-32)
|
||||||
|
if(validateMacs) {
|
||||||
|
const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey)
|
||||||
|
if(Buffer.compare(contentHmac, ogValueMac) !== 0) {
|
||||||
|
throw new Boom('HMAC content verification failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = aesDecrypt(encContent, key.valueEncryptionKey)
|
||||||
|
const syncAction = proto.SyncActionData.decode(result)
|
||||||
|
|
||||||
|
if(validateMacs) {
|
||||||
|
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({
|
||||||
|
syncAction,
|
||||||
|
index: JSON.parse(indexStr),
|
||||||
|
})
|
||||||
|
ltGenerator.mix({
|
||||||
|
indexMac: record.index!.blob!,
|
||||||
|
valueMac: ogValueMac,
|
||||||
|
operation: operation
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mutations, ...ltGenerator.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeSyncdPatch = async(
|
||||||
|
msg: proto.ISyncdPatch,
|
||||||
|
name: WAPatchName,
|
||||||
|
initialState: LTHashState,
|
||||||
|
getAppStateSyncKey: FetchAppStateSyncKey,
|
||||||
|
validateMacs: boolean
|
||||||
|
) => {
|
||||||
|
if(validateMacs) {
|
||||||
|
const base64Key = Buffer.from(msg.keyId!.id).toString('base64')
|
||||||
|
const mainKeyObj = await getAppStateSyncKey(base64Key)
|
||||||
|
const mainKey = mutationKeys(mainKeyObj.keyData!)
|
||||||
|
const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32))
|
||||||
|
|
||||||
|
const patchMac = generatePatchMac(msg.snapshotMac, mutationmacs, toNumber(msg.version!.version), name, mainKey.patchMacKey)
|
||||||
|
if(Buffer.compare(patchMac, msg.patchMac) !== 0) {
|
||||||
|
throw new Boom('Invalid patch mac')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await decodeSyncdMutations(msg!.mutations!, initialState, getAppStateSyncKey, validateMacs)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractSyncdPatches = async(result: BinaryNode) => {
|
||||||
|
const syncNode = getBinaryNodeChild(result, 'sync')
|
||||||
|
const collectionNodes = getBinaryNodeChildren(syncNode, 'collection')
|
||||||
|
|
||||||
|
const final = { } as { [T in WAPatchName]: { patches: proto.ISyncdPatch[], hasMorePatches: boolean, snapshot?: proto.ISyncdSnapshot } }
|
||||||
|
await Promise.all(
|
||||||
|
collectionNodes.map(
|
||||||
|
async collectionNode => {
|
||||||
|
const patchesNode = getBinaryNodeChild(collectionNode, 'patches')
|
||||||
|
|
||||||
|
const patches = getBinaryNodeChildren(patchesNode || collectionNode, 'patch')
|
||||||
|
const snapshotNode = getBinaryNodeChild(collectionNode, 'snapshot')
|
||||||
|
|
||||||
|
const syncds: proto.ISyncdPatch[] = []
|
||||||
|
const name = collectionNode.attrs.name as WAPatchName
|
||||||
|
|
||||||
|
const hasMorePatches = collectionNode.attrs.has_more_patches === 'true'
|
||||||
|
|
||||||
|
let snapshot: proto.ISyncdSnapshot | undefined = undefined
|
||||||
|
if(snapshotNode && !!snapshotNode.content) {
|
||||||
|
if(!Buffer.isBuffer(snapshotNode)) {
|
||||||
|
snapshotNode.content = Buffer.from(Object.values(snapshotNode.content))
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobRef = proto.ExternalBlobReference.decode(
|
||||||
|
snapshotNode.content! as Buffer
|
||||||
|
)
|
||||||
|
const data = await downloadExternalBlob(blobRef)
|
||||||
|
snapshot = proto.SyncdSnapshot.decode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for(let { content } of patches) {
|
||||||
|
if(content) {
|
||||||
|
if(!Buffer.isBuffer(content)) {
|
||||||
|
content = Buffer.from(Object.values(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
|
||||||
|
if(!syncd.version) {
|
||||||
|
syncd.version = { version: +collectionNode.attrs.version+1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
syncds.push(syncd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final[name] = { patches: syncds, hasMorePatches, snapshot }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const downloadExternalBlob = async(blob: proto.IExternalBlobReference) => {
|
||||||
|
const stream = await downloadContentFromMessage(blob, 'md-app-state')
|
||||||
|
let buffer = Buffer.from([])
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
buffer = Buffer.concat([buffer, chunk])
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadExternalPatch = async(blob: proto.IExternalBlobReference) => {
|
||||||
|
const buffer = await downloadExternalBlob(blob)
|
||||||
|
const syncData = proto.SyncdMutations.decode(buffer)
|
||||||
|
return syncData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeSyncdSnapshot = async(
|
||||||
|
name: WAPatchName,
|
||||||
|
snapshot: proto.ISyncdSnapshot,
|
||||||
|
getAppStateSyncKey: FetchAppStateSyncKey,
|
||||||
|
minimumVersionNumber: number | undefined,
|
||||||
|
validateMacs: boolean = true
|
||||||
|
) => {
|
||||||
|
const newState = newLTHashState()
|
||||||
|
newState.version = toNumber(snapshot.version!.version!)
|
||||||
|
|
||||||
|
const { hash, indexValueMap, mutations } = await decodeSyncdMutations(snapshot.records!, newState, getAppStateSyncKey, validateMacs)
|
||||||
|
newState.hash = hash
|
||||||
|
newState.indexValueMap = indexValueMap
|
||||||
|
|
||||||
|
if(validateMacs) {
|
||||||
|
const base64Key = Buffer.from(snapshot.keyId!.id!).toString('base64')
|
||||||
|
const keyEnc = await getAppStateSyncKey(base64Key)
|
||||||
|
if(!keyEnc) {
|
||||||
|
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = mutationKeys(keyEnc.keyData!)
|
||||||
|
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
|
||||||
|
if(Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
|
||||||
|
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`, { statusCode: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const areMutationsRequired = typeof minimumVersionNumber === 'undefined' || newState.version > minimumVersionNumber
|
||||||
|
if(!areMutationsRequired) {
|
||||||
|
// clear array
|
||||||
|
mutations.splice(0, mutations.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: newState,
|
||||||
|
mutations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodePatches = async(
|
||||||
|
name: WAPatchName,
|
||||||
|
syncds: proto.ISyncdPatch[],
|
||||||
|
initial: LTHashState,
|
||||||
|
getAppStateSyncKey: FetchAppStateSyncKey,
|
||||||
|
minimumVersionNumber?: number,
|
||||||
|
validateMacs: boolean = true
|
||||||
|
) => {
|
||||||
|
const successfulMutations: ChatMutation[] = []
|
||||||
|
|
||||||
|
const newState: LTHashState = {
|
||||||
|
...initial,
|
||||||
|
indexValueMap: { ...initial.indexValueMap }
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const syncd of syncds) {
|
||||||
|
const { version, keyId, snapshotMac } = syncd
|
||||||
|
if(syncd.externalMutations) {
|
||||||
|
const ref = await downloadExternalPatch(syncd.externalMutations)
|
||||||
|
syncd.mutations.push(...ref.mutations)
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchVersion = toNumber(version.version!)
|
||||||
|
|
||||||
|
newState.version = patchVersion
|
||||||
|
|
||||||
|
const decodeResult = await decodeSyncdPatch(syncd, name, newState, getAppStateSyncKey, validateMacs)
|
||||||
|
|
||||||
|
newState.hash = decodeResult.hash
|
||||||
|
newState.indexValueMap = decodeResult.indexValueMap
|
||||||
|
if(typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber) {
|
||||||
|
successfulMutations.push(...decodeResult.mutations)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(validateMacs) {
|
||||||
|
const base64Key = Buffer.from(keyId!.id!).toString('base64')
|
||||||
|
const keyEnc = await getAppStateSyncKey(base64Key)
|
||||||
|
if(!keyEnc) {
|
||||||
|
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = mutationKeys(keyEnc.keyData!)
|
||||||
|
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
|
||||||
|
if(Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) {
|
||||||
|
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newMutations: successfulMutations,
|
||||||
|
state: newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatModificationToAppPatch = (
|
||||||
|
mod: ChatModification,
|
||||||
|
jid: string
|
||||||
|
) => {
|
||||||
|
const OP = proto.SyncdMutation.SyncdMutationSyncdOperation
|
||||||
|
const getMessageRange = (lastMessages: LastMessageList) => {
|
||||||
|
if(!lastMessages?.length) {
|
||||||
|
throw new Boom('Expected last message to be not from me', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMsg = lastMessages[lastMessages.length-1]
|
||||||
|
if(lastMsg.key.fromMe) {
|
||||||
|
throw new Boom('Expected last message in array to be not from me', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageRange: proto.ISyncActionMessageRange = {
|
||||||
|
lastMessageTimestamp: lastMsg?.messageTimestamp,
|
||||||
|
messages: lastMessages
|
||||||
|
}
|
||||||
|
return messageRange
|
||||||
|
}
|
||||||
|
|
||||||
|
let patch: WAPatchCreate
|
||||||
|
if('mute' in mod) {
|
||||||
|
patch = {
|
||||||
|
syncAction: {
|
||||||
|
muteAction: {
|
||||||
|
muted: !!mod.mute,
|
||||||
|
muteEndTimestamp: mod.mute || undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
index: ['mute', jid],
|
||||||
|
type: 'regular_high',
|
||||||
|
apiVersion: 2,
|
||||||
|
operation: OP.SET
|
||||||
|
}
|
||||||
|
} else if('archive' in mod) {
|
||||||
|
patch = {
|
||||||
|
syncAction: {
|
||||||
|
archiveChatAction: {
|
||||||
|
archived: !!mod.archive,
|
||||||
|
messageRange: getMessageRange(mod.lastMessages)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
index: ['archive', jid],
|
||||||
|
type: 'regular_low',
|
||||||
|
apiVersion: 3,
|
||||||
|
operation: OP.SET
|
||||||
|
}
|
||||||
|
} else if('markRead' in mod) {
|
||||||
|
patch = {
|
||||||
|
syncAction: {
|
||||||
|
markChatAsReadAction: {
|
||||||
|
read: mod.markRead,
|
||||||
|
messageRange: getMessageRange(mod.lastMessages)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
index: ['markChatAsRead', jid],
|
||||||
|
type: 'regular_low',
|
||||||
|
apiVersion: 3,
|
||||||
|
operation: OP.SET
|
||||||
|
}
|
||||||
|
} else if('clear' in mod) {
|
||||||
|
if(mod.clear === 'all') {
|
||||||
|
throw new Boom('not supported')
|
||||||
|
} else {
|
||||||
|
const key = mod.clear.messages[0]
|
||||||
|
patch = {
|
||||||
|
syncAction: {
|
||||||
|
deleteMessageForMeAction: {
|
||||||
|
deleteMedia: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
index: ['deleteMessageForMe', jid, key.id, key.fromMe ? '1' : '0', '0'],
|
||||||
|
type: 'regular_high',
|
||||||
|
apiVersion: 3,
|
||||||
|
operation: OP.SET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if('pin' in mod) {
|
||||||
|
patch = {
|
||||||
|
syncAction: {
|
||||||
|
pinAction: {
|
||||||
|
pinned: !!mod.pin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
index: ['pin_v1', jid],
|
||||||
|
type: 'regular_low',
|
||||||
|
apiVersion: 5,
|
||||||
|
operation: OP.SET
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Boom('not supported')
|
||||||
|
}
|
||||||
|
|
||||||
|
patch.syncAction.timestamp = Date.now()
|
||||||
|
|
||||||
|
return patch
|
||||||
|
}
|
||||||
99
src/Utils/crypto.ts
Normal file
99
src/Utils/crypto.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
|
||||||
|
import * as curveJs from 'curve25519-js'
|
||||||
|
import { KeyPair } from '../Types'
|
||||||
|
|
||||||
|
export const Curve = {
|
||||||
|
generateKeyPair: (): KeyPair => {
|
||||||
|
const { public: pubKey, private: privKey } = curveJs.generateKeyPair(randomBytes(32))
|
||||||
|
return {
|
||||||
|
private: Buffer.from(privKey),
|
||||||
|
public: Buffer.from(pubKey)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sharedKey: (privateKey: Uint8Array, publicKey: Uint8Array) => {
|
||||||
|
const shared = curveJs.sharedKey(privateKey, publicKey)
|
||||||
|
return Buffer.from(shared)
|
||||||
|
},
|
||||||
|
sign: (privateKey: Uint8Array, buf: Uint8Array) => (
|
||||||
|
Buffer.from(curveJs.sign(privateKey, buf, null))
|
||||||
|
),
|
||||||
|
verify: (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => {
|
||||||
|
return curveJs.verify(pubKey, message, signature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signedKeyPair = (keyPair: KeyPair, keyId: number) => {
|
||||||
|
const signKeys = Curve.generateKeyPair()
|
||||||
|
const pubKey = new Uint8Array(33)
|
||||||
|
pubKey.set([5], 0)
|
||||||
|
pubKey.set(signKeys.public, 1)
|
||||||
|
|
||||||
|
const signature = Curve.sign(keyPair.private, pubKey)
|
||||||
|
|
||||||
|
return { keyPair: signKeys, signature, keyId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
|
||||||
|
export function aesDecrypt(buffer: Buffer, key: Buffer) {
|
||||||
|
return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** decrypt AES 256 CBC */
|
||||||
|
export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
||||||
|
const aes = createDecipheriv('aes-256-cbc', key, IV)
|
||||||
|
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 | 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt AES 256 CBC with a given IV
|
||||||
|
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
||||||
|
const aes = createCipheriv('aes-256-cbc', key, IV)
|
||||||
|
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign HMAC using SHA 256
|
||||||
|
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
|
||||||
|
// from: https://github.com/benadida/node-hkdf
|
||||||
|
export function hkdf(buffer: Uint8Array, 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)
|
||||||
|
}
|
||||||
128
src/Utils/decode-wa-message.ts
Normal file
128
src/Utils/decode-wa-message.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { AuthenticationState, WAMessageKey } from '../Types'
|
||||||
|
import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary'
|
||||||
|
import { unpadRandomMax16 } from './generics'
|
||||||
|
import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal'
|
||||||
|
|
||||||
|
type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status'
|
||||||
|
|
||||||
|
export const decodeMessageStanza = async(stanza: BinaryNode, 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 {
|
||||||
|
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 fromMe = isMe(stanza.attrs.participant || stanza.attrs.from)
|
||||||
|
const pushname = stanza.attrs.notify
|
||||||
|
|
||||||
|
const key: WAMessageKey = {
|
||||||
|
remoteJid: chatId,
|
||||||
|
fromMe,
|
||||||
|
id: msgId,
|
||||||
|
participant
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessage: proto.IWebMessageInfo = {
|
||||||
|
key,
|
||||||
|
messageTimestamp: +stanza.attrs.t,
|
||||||
|
pushName: pushname
|
||||||
|
}
|
||||||
|
|
||||||
|
if(key.fromMe) {
|
||||||
|
fullMessage.status = proto.WebMessageInfo.WebMessageInfoStatus.SERVER_ACK
|
||||||
|
}
|
||||||
|
|
||||||
|
if(Array.isArray(stanza.content)) {
|
||||||
|
for(const { tag, attrs, content } of stanza.content) {
|
||||||
|
if(tag !== 'enc') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!(content instanceof Uint8Array)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgBuffer: Buffer
|
||||||
|
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg: proto.IMessage = proto.Message.decode(unpadRandomMax16(msgBuffer))
|
||||||
|
msg = msg.deviceSentMessage?.message || msg
|
||||||
|
if(msg.senderKeyDistributionMessage) {
|
||||||
|
await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(fullMessage.message) {
|
||||||
|
Object.assign(fullMessage.message, msg)
|
||||||
|
} else {
|
||||||
|
fullMessage.message = msg
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
fullMessage.messageStubType = proto.WebMessageInfo.WebMessageInfoStubType.CIPHERTEXT
|
||||||
|
fullMessage.messageStubParameters = [error.message]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullMessage
|
||||||
|
}
|
||||||
234
src/Utils/generics.ts
Normal file
234
src/Utils/generics.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { platform, release } from 'os'
|
||||||
|
import { Logger } from 'pino'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { CommonBaileysEventEmitter, DisconnectReason } from '../Types'
|
||||||
|
import { Binary } from '../WABinary'
|
||||||
|
import { ConnectionState } from '..'
|
||||||
|
|
||||||
|
const PLATFORM_MAP = {
|
||||||
|
'aix': 'AIX',
|
||||||
|
'darwin': 'Mac OS',
|
||||||
|
'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],
|
||||||
|
baileys: browser => ['Baileys', browser, '4.0.0'] as [string, string, string],
|
||||||
|
/** 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 = (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 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
|
||||||
|
const 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 === 'object' && 'toNumber' in t) ? t.toNumber() : t)
|
||||||
|
|
||||||
|
export function shallowChanges <T>(old: T, current: T, { lookForDeletedKeys }: {lookForDeletedKeys: boolean}): Partial<T> {
|
||||||
|
const changes: Partial<T> = {}
|
||||||
|
for(const key in current) {
|
||||||
|
if(old[key] !== current[key]) {
|
||||||
|
changes[key] = current[key] || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(lookForDeletedKeys) {
|
||||||
|
for(const key in old) {
|
||||||
|
if(!changes[key] && old[key] !== current[key]) {
|
||||||
|
changes[key] = current[key] || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
start: (newIntervalMs?: number, newTask?: () => void) => {
|
||||||
|
task = newTask || task
|
||||||
|
intervalMs = newIntervalMs || intervalMs
|
||||||
|
timeout && clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(task, intervalMs)
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
timeout && clearTimeout(timeout)
|
||||||
|
timeout = undefined
|
||||||
|
},
|
||||||
|
setTask: (newTask: () => void) => task = newTask,
|
||||||
|
setInterval: (newInterval: number) => intervalMs = newInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delay = (ms: number) => delayCancellable (ms).delay
|
||||||
|
export const delayCancellable = (ms: number) => {
|
||||||
|
const stack = new Error().stack
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
let reject: (error) => void
|
||||||
|
const delay: Promise<void> = new Promise((resolve, _reject) => {
|
||||||
|
timeout = setTimeout(resolve, ms)
|
||||||
|
reject = _reject
|
||||||
|
})
|
||||||
|
const cancel = () => {
|
||||||
|
clearTimeout (timeout)
|
||||||
|
reject(
|
||||||
|
new Boom('Cancelled', {
|
||||||
|
statusCode: 500,
|
||||||
|
data: {
|
||||||
|
stack
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { delay, cancel }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) {
|
||||||
|
if(!ms) {
|
||||||
|
return new Promise (promise)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = new Error().stack
|
||||||
|
// Create a promise that rejects in <ms> milliseconds
|
||||||
|
const { delay, cancel } = delayCancellable (ms)
|
||||||
|
const p = new Promise ((resolve, reject) => {
|
||||||
|
delay
|
||||||
|
.then(() => reject(
|
||||||
|
new Boom('Timed Out', {
|
||||||
|
statusCode: DisconnectReason.timedOut,
|
||||||
|
data: {
|
||||||
|
stack
|
||||||
|
}
|
||||||
|
})
|
||||||
|
))
|
||||||
|
.catch (err => reject(err))
|
||||||
|
|
||||||
|
promise (resolve, reject)
|
||||||
|
})
|
||||||
|
.finally (cancel)
|
||||||
|
return p as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate a random ID to attach to a message
|
||||||
|
export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase()
|
||||||
|
|
||||||
|
export const bindWaitForConnectionUpdate = (ev: CommonBaileysEventEmitter<any>) => (
|
||||||
|
async(check: (u: Partial<ConnectionState>) => boolean, timeoutMs?: number) => {
|
||||||
|
let listener: (item: Partial<ConnectionState>) => void
|
||||||
|
await (
|
||||||
|
promiseTimeout(
|
||||||
|
timeoutMs,
|
||||||
|
(resolve, reject) => {
|
||||||
|
listener = (update) => {
|
||||||
|
if(check(update)) {
|
||||||
|
resolve()
|
||||||
|
} else if(update.connection === 'close') {
|
||||||
|
reject(update.lastDisconnect?.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.on('connection.update', listener)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.finally(() => (
|
||||||
|
ev.off('connection.update', listener)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const printQRIfNecessaryListener = (ev: CommonBaileysEventEmitter<any>, logger: Logger) => {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
83
src/Utils/history.ts
Normal file
83
src/Utils/history.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { promisify } from 'util'
|
||||||
|
import { inflate } from 'zlib'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { Chat, Contact } from '../Types'
|
||||||
|
import { downloadContentFromMessage } from './messages-media'
|
||||||
|
|
||||||
|
const inflatePromise = promisify(inflate)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const processHistoryMessage = (item: proto.IHistorySync, historyCache: Set<string>) => {
|
||||||
|
const isLatest = historyCache.size === 0
|
||||||
|
const messages: proto.IWebMessageInfo[] = []
|
||||||
|
const contacts: Contact[] = []
|
||||||
|
const chats: Chat[] = []
|
||||||
|
switch (item.syncType) {
|
||||||
|
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_BOOTSTRAP:
|
||||||
|
case proto.HistorySync.HistorySyncHistorySyncType.RECENT:
|
||||||
|
for(const chat of item.conversations) {
|
||||||
|
const contactId = `c:${chat.id}`
|
||||||
|
if(chat.name && !historyCache.has(contactId)) {
|
||||||
|
contacts.push({
|
||||||
|
id: chat.id,
|
||||||
|
name: chat.name
|
||||||
|
})
|
||||||
|
historyCache.add(contactId)
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const { message } of chat.messages || []) {
|
||||||
|
const uqId = `${message?.key.remoteJid}:${message.key.id}`
|
||||||
|
if(message && !historyCache.has(uqId)) {
|
||||||
|
messages.push(message)
|
||||||
|
historyCache.add(uqId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete chat.messages
|
||||||
|
if(!historyCache.has(chat.id)) {
|
||||||
|
chats.push(chat)
|
||||||
|
historyCache.add(chat.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case proto.HistorySync.HistorySyncHistorySyncType.PUSH_NAME:
|
||||||
|
for(const c of item.pushnames) {
|
||||||
|
const contactId = `c:${c.id}`
|
||||||
|
if(historyCache.has(contactId)) {
|
||||||
|
contacts.push({ notify: c.pushname, id: c.id })
|
||||||
|
historyCache.add(contactId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_STATUS_V3:
|
||||||
|
// TODO
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chats,
|
||||||
|
contacts,
|
||||||
|
messages,
|
||||||
|
isLatest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadAndProcessHistorySyncNotification = async(msg: proto.IHistorySyncNotification, historyCache: Set<string>) => {
|
||||||
|
const historyMsg = await downloadHistory(msg)
|
||||||
|
return processHistoryMessage(historyMsg, historyCache)
|
||||||
|
}
|
||||||
13
src/Utils/index.ts
Normal file
13
src/Utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export * from './decode-wa-message'
|
||||||
|
export * from './generics'
|
||||||
|
export * from './messages'
|
||||||
|
export * from './messages-media'
|
||||||
|
export * from './validate-connection'
|
||||||
|
export * from './crypto'
|
||||||
|
export * from './signal'
|
||||||
|
export * from './noise-handler'
|
||||||
|
export * from './history'
|
||||||
|
export * from './chat-utils'
|
||||||
|
export * from './lt-hash'
|
||||||
|
export * from './auth-utils'
|
||||||
|
export * from './legacy-msgs'
|
||||||
198
src/Utils/legacy-msgs.ts
Normal file
198
src/Utils/legacy-msgs.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { AuthenticationCreds, Contact, CurveKeyPair, DisconnectReason, LegacyAuthenticationCreds, WATag } from '../Types'
|
||||||
|
import { decodeBinaryNodeLegacy, jidNormalizedUser } from '../WABinary'
|
||||||
|
import { aesDecrypt, Curve, hkdf, hmacSign } from './crypto'
|
||||||
|
import { BufferJSON } from './generics'
|
||||||
|
|
||||||
|
export const newLegacyAuthCreds = () => ({
|
||||||
|
clientID: randomBytes(16).toString('base64')
|
||||||
|
}) as LegacyAuthenticationCreds
|
||||||
|
|
||||||
|
export const decodeWAMessage = (
|
||||||
|
message: Buffer | string,
|
||||||
|
auth: { macKey: Buffer, encKey: Buffer },
|
||||||
|
fromMe: boolean=false
|
||||||
|
) => {
|
||||||
|
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) {
|
||||||
|
const possiblyEnc = (data.length > 32 && data.length % 16 === 0)
|
||||||
|
if(typeof data === 'string' || !possiblyEnc) {
|
||||||
|
json = JSON.parse(data.toString()) // parse the JSON
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
json = JSON.parse(data.toString())
|
||||||
|
} catch{
|
||||||
|
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 = decodeBinaryNodeLegacy(decrypted, { index: 0 }) // 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [messageTag, json, tags] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: LegacyAuthenticationCreds,
|
||||||
|
curveKeys: CurveKeyPair
|
||||||
|
) => {
|
||||||
|
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
||||||
|
const onValidationSuccess = () => {
|
||||||
|
const user: Contact = {
|
||||||
|
id: jidNormalizedUser(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, { })
|
||||||
|
|
||||||
|
// perform HMAC validation.
|
||||||
|
const hmacValidationKey = expandedKey.slice(32, 64)
|
||||||
|
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
|
||||||
|
|
||||||
|
const hmac = hmacSign(hmacValidationMessage, hmacValidationKey)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeChallengeResponse = (challenge: string, auth: LegacyAuthenticationCreds) => {
|
||||||
|
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 useSingleFileLegacyAuthState = (file: string) => {
|
||||||
|
// require fs here so that in case "fs" is not available -- the app does not crash
|
||||||
|
const { readFileSync, writeFileSync, existsSync } = require('fs')
|
||||||
|
let state: LegacyAuthenticationCreds
|
||||||
|
|
||||||
|
if(existsSync(file)) {
|
||||||
|
state = JSON.parse(
|
||||||
|
readFileSync(file, { encoding: 'utf-8' }),
|
||||||
|
BufferJSON.reviver
|
||||||
|
)
|
||||||
|
if(typeof state.encKey === 'string') {
|
||||||
|
state.encKey = Buffer.from(state.encKey, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof state.macKey === 'string') {
|
||||||
|
state.macKey = Buffer.from(state.macKey, 'base64')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state = newLegacyAuthCreds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
saveState: () => {
|
||||||
|
const str = JSON.stringify(state, BufferJSON.replacer, 2)
|
||||||
|
writeFileSync(file, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthenticationCredsType = (creds: LegacyAuthenticationCreds | AuthenticationCreds) => {
|
||||||
|
if('clientID' in creds && !!creds.clientID) {
|
||||||
|
return 'legacy'
|
||||||
|
}
|
||||||
|
|
||||||
|
if('noiseKey' in creds && !!creds.noiseKey) {
|
||||||
|
return 'md'
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/Utils/lt-hash.ts
Normal file
61
src/Utils/lt-hash.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { hkdf } from './crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LT Hash is a summation based hash algorithm that maintains the integrity of a piece of data
|
||||||
|
* over a series of mutations. You can add/remove mutations and it'll return a hash equal to
|
||||||
|
* if the same series of mutations was made sequentially.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const o = 128
|
||||||
|
|
||||||
|
class d {
|
||||||
|
|
||||||
|
salt: string
|
||||||
|
|
||||||
|
constructor(e: string) {
|
||||||
|
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(Buffer.from(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(Buffer.from(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')
|
||||||
22
src/Utils/make-mutex.ts
Normal file
22
src/Utils/make-mutex.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
export default () => {
|
||||||
|
let task = Promise.resolve() as Promise<any>
|
||||||
|
return {
|
||||||
|
mutex<T>(code: () => Promise<T>):Promise<T> {
|
||||||
|
task = (async() => {
|
||||||
|
// wait for the previous task to complete
|
||||||
|
// if there is an error, we swallow so as to not block the queue
|
||||||
|
try {
|
||||||
|
await task
|
||||||
|
} catch{ }
|
||||||
|
|
||||||
|
// execute the current task
|
||||||
|
return code()
|
||||||
|
})()
|
||||||
|
// we replace the existing task, appending the new piece of execution to it
|
||||||
|
// so the next task will have to wait for this one to finish
|
||||||
|
return task
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
588
src/Utils/messages-media.ts
Normal file
588
src/Utils/messages-media.ts
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { AxiosRequestConfig } from 'axios'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import * as Crypto from 'crypto'
|
||||||
|
import { once } from 'events'
|
||||||
|
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
|
||||||
|
import type { IAudioMetadata } from 'music-metadata'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import type { Logger } from 'pino'
|
||||||
|
import { Readable, Transform } from 'stream'
|
||||||
|
import { URL } from 'url'
|
||||||
|
import { DEFAULT_ORIGIN, MEDIA_PATH_MAP } from '../Defaults'
|
||||||
|
import { CommonSocketConfig, DownloadableMessage, MediaConnInfo, MediaType, MessageType, WAGenericMediaMessage, WAMediaUpload, WAMediaUploadFunction, WAMessageContent, WAProto } from '../Types'
|
||||||
|
import { hkdf } from './crypto'
|
||||||
|
import { generateMessageID } from './generics'
|
||||||
|
|
||||||
|
const getTmpFilesDirectory = () => tmpdir()
|
||||||
|
|
||||||
|
const getImageProcessingLibrary = async() => {
|
||||||
|
const [jimp, sharp] = await Promise.all([
|
||||||
|
(async() => {
|
||||||
|
const jimp = await (
|
||||||
|
import('jimp')
|
||||||
|
.catch(() => { })
|
||||||
|
)
|
||||||
|
return jimp
|
||||||
|
})(),
|
||||||
|
(async() => {
|
||||||
|
const sharp = await (
|
||||||
|
import('sharp')
|
||||||
|
.catch(() => { })
|
||||||
|
)
|
||||||
|
return sharp
|
||||||
|
})()
|
||||||
|
])
|
||||||
|
if(sharp) {
|
||||||
|
return { sharp }
|
||||||
|
}
|
||||||
|
|
||||||
|
if(jimp) {
|
||||||
|
return { jimp }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Boom('No image processing library available')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hkdfInfoKey = (type: MediaType) => {
|
||||||
|
let str: string = type
|
||||||
|
if(type === 'sticker') {
|
||||||
|
str = 'image'
|
||||||
|
}
|
||||||
|
|
||||||
|
if(type === 'md-app-state') {
|
||||||
|
str = 'App State'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hkdfInfo = str[0].toUpperCase() + str.slice(1)
|
||||||
|
return `WhatsApp ${hkdfInfo} Keys`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** generates all the keys required to encrypt/decrypt & sign a media message */
|
||||||
|
export function getMediaKeys(buffer, mediaType: MediaType) {
|
||||||
|
if(typeof buffer === 'string') {
|
||||||
|
buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
// expand using HKDF to 112 bytes, also pass in the relevant app info
|
||||||
|
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
|
||||||
|
return {
|
||||||
|
iv: expandedMediaKey.slice(0, 16),
|
||||||
|
cipherKey: expandedMediaKey.slice(16, 48),
|
||||||
|
macKey: expandedMediaKey.slice(48, 80),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts video thumb using FFMPEG */
|
||||||
|
const extractVideoThumb = async(
|
||||||
|
path: string,
|
||||||
|
destPath: string,
|
||||||
|
time: string,
|
||||||
|
size: { width: number; height: number },
|
||||||
|
) => new Promise((resolve, reject) => {
|
||||||
|
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
|
||||||
|
exec(cmd, (err) => {
|
||||||
|
if(err) {
|
||||||
|
reject(err)
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) as Promise<void>
|
||||||
|
|
||||||
|
export const extractImageThumb = async(bufferOrFilePath: Readable | Buffer | string) => {
|
||||||
|
if(bufferOrFilePath instanceof Readable) {
|
||||||
|
bufferOrFilePath = await toBuffer(bufferOrFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lib = await getImageProcessingLibrary()
|
||||||
|
if('sharp' in lib) {
|
||||||
|
const result = await lib.sharp!.default(bufferOrFilePath)
|
||||||
|
.resize(32, 32)
|
||||||
|
.jpeg({ quality: 50 })
|
||||||
|
.toBuffer()
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp
|
||||||
|
|
||||||
|
const jimp = await read(bufferOrFilePath as any)
|
||||||
|
const result = await jimp
|
||||||
|
.quality(50)
|
||||||
|
.resize(32, 32, RESIZE_BILINEAR)
|
||||||
|
.getBufferAsync(MIME_JPEG)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateProfilePicture = async(mediaUpload: WAMediaUpload) => {
|
||||||
|
let bufferOrFilePath: Buffer | string
|
||||||
|
if(Buffer.isBuffer(mediaUpload)) {
|
||||||
|
bufferOrFilePath = mediaUpload
|
||||||
|
} else if('url' in mediaUpload) {
|
||||||
|
bufferOrFilePath = mediaUpload.url.toString()
|
||||||
|
} else {
|
||||||
|
bufferOrFilePath = await toBuffer(mediaUpload.stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lib = await getImageProcessingLibrary()
|
||||||
|
let img: Promise<Buffer>
|
||||||
|
if('sharp' in lib) {
|
||||||
|
img = lib.sharp!.default(bufferOrFilePath)
|
||||||
|
.resize(640, 640)
|
||||||
|
.jpeg({
|
||||||
|
quality: 50,
|
||||||
|
})
|
||||||
|
.toBuffer()
|
||||||
|
} else {
|
||||||
|
const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp
|
||||||
|
const jimp = await read(bufferOrFilePath as any)
|
||||||
|
const min = Math.min(jimp.getWidth(), jimp.getHeight())
|
||||||
|
const cropped = jimp.crop(0, 0, min, min)
|
||||||
|
|
||||||
|
img = cropped
|
||||||
|
.quality(50)
|
||||||
|
.resize(640, 640, RESIZE_BILINEAR)
|
||||||
|
.getBufferAsync(MIME_JPEG)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
img: await img,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** gets the SHA256 of the given media message */
|
||||||
|
export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
|
||||||
|
const media = Object.values(message)[0] as WAGenericMediaMessage
|
||||||
|
return media?.fileSha256 && Buffer.from(media.fileSha256).toString ('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAudioDuration(buffer: Buffer | string | Readable) {
|
||||||
|
const musicMetadata = await import('music-metadata')
|
||||||
|
let metadata: IAudioMetadata
|
||||||
|
if(Buffer.isBuffer(buffer)) {
|
||||||
|
metadata = await musicMetadata.parseBuffer(buffer, null, { duration: true })
|
||||||
|
} else if(typeof buffer === 'string') {
|
||||||
|
const rStream = createReadStream(buffer)
|
||||||
|
metadata = await musicMetadata.parseStream(rStream, null, { duration: true })
|
||||||
|
rStream.close()
|
||||||
|
} else {
|
||||||
|
metadata = await musicMetadata.parseStream(buffer, null, { duration: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.format.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toReadable = (buffer: Buffer) => {
|
||||||
|
const readable = new Readable({ read: () => {} })
|
||||||
|
readable.push(buffer)
|
||||||
|
readable.push(null)
|
||||||
|
return readable
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toBuffer = async(stream: Readable) => {
|
||||||
|
let buff = Buffer.alloc(0)
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
buff = Buffer.concat([ buff, chunk ])
|
||||||
|
}
|
||||||
|
|
||||||
|
return buff
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStream = async(item: WAMediaUpload) => {
|
||||||
|
if(Buffer.isBuffer(item)) {
|
||||||
|
return { stream: toReadable(item), type: 'buffer' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if('stream' in item) {
|
||||||
|
return { stream: item.stream, type: 'readable' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if(item.url.toString().startsWith('http://') || item.url.toString().startsWith('https://')) {
|
||||||
|
return { stream: await getHttpStream(item.url), type: 'remote' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stream: createReadStream(item.url), type: 'file' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** generates a thumbnail for a given media, if required */
|
||||||
|
export async function generateThumbnail(
|
||||||
|
file: string,
|
||||||
|
mediaType: 'video' | 'image',
|
||||||
|
options: {
|
||||||
|
logger?: Logger
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let thumbnail: string
|
||||||
|
if(mediaType === 'image') {
|
||||||
|
const buff = await extractImageThumb(file)
|
||||||
|
thumbnail = buff.toString('base64')
|
||||||
|
} else if(mediaType === 'video') {
|
||||||
|
const imgFilename = join(getTmpFilesDirectory(), generateMessageID() + '.jpg')
|
||||||
|
try {
|
||||||
|
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 })
|
||||||
|
const buff = await fs.readFile(imgFilename)
|
||||||
|
thumbnail = buff.toString('base64')
|
||||||
|
|
||||||
|
await fs.unlink(imgFilename)
|
||||||
|
} catch(err) {
|
||||||
|
options.logger?.debug('could not generate video thumb: ' + err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHttpStream = async(url: string | URL, options: AxiosRequestConfig & { isStream?: true } = {}) => {
|
||||||
|
const { default: axios } = await import('axios')
|
||||||
|
const fetched = await axios.get(url.toString(), { ...options, responseType: 'stream' })
|
||||||
|
return fetched.data as Readable
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptedStream = async(
|
||||||
|
media: WAMediaUpload,
|
||||||
|
mediaType: MediaType,
|
||||||
|
saveOriginalFileIfRequired = true,
|
||||||
|
logger?: Logger
|
||||||
|
) => {
|
||||||
|
const { stream, type } = await getStream(media)
|
||||||
|
|
||||||
|
logger?.debug('fetched media stream')
|
||||||
|
|
||||||
|
const mediaKey = Crypto.randomBytes(32)
|
||||||
|
const { cipherKey, iv, macKey } = getMediaKeys(mediaKey, mediaType)
|
||||||
|
// random name
|
||||||
|
//const encBodyPath = join(getTmpFilesDirectory(), mediaType + generateMessageID() + '.enc')
|
||||||
|
// const encWriteStream = createWriteStream(encBodyPath)
|
||||||
|
const encWriteStream = new Readable({ read: () => {} })
|
||||||
|
|
||||||
|
let bodyPath: string
|
||||||
|
let writeStream: WriteStream
|
||||||
|
let didSaveToTmpPath = false
|
||||||
|
if(type === 'file') {
|
||||||
|
bodyPath = (media as any).url
|
||||||
|
} else if(saveOriginalFileIfRequired) {
|
||||||
|
bodyPath = join(getTmpFilesDirectory(), mediaType + generateMessageID())
|
||||||
|
writeStream = createWriteStream(bodyPath)
|
||||||
|
didSaveToTmpPath = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileLength = 0
|
||||||
|
const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
|
||||||
|
let hmac = Crypto.createHmac('sha256', macKey).update(iv)
|
||||||
|
let sha256Plain = Crypto.createHash('sha256')
|
||||||
|
let sha256Enc = Crypto.createHash('sha256')
|
||||||
|
|
||||||
|
const onChunk = (buff: Buffer) => {
|
||||||
|
sha256Enc = sha256Enc.update(buff)
|
||||||
|
hmac = hmac.update(buff)
|
||||||
|
encWriteStream.push(buff)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const data of stream) {
|
||||||
|
fileLength += data.length
|
||||||
|
sha256Plain = sha256Plain.update(data)
|
||||||
|
if(writeStream) {
|
||||||
|
if(!writeStream.write(data)) {
|
||||||
|
await once(writeStream, 'drain')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChunk(aes.update(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
onChunk(aes.final())
|
||||||
|
|
||||||
|
const mac = hmac.digest().slice(0, 10)
|
||||||
|
sha256Enc = sha256Enc.update(mac)
|
||||||
|
|
||||||
|
const fileSha256 = sha256Plain.digest()
|
||||||
|
const fileEncSha256 = sha256Enc.digest()
|
||||||
|
|
||||||
|
encWriteStream.push(mac)
|
||||||
|
encWriteStream.push(null)
|
||||||
|
|
||||||
|
writeStream && writeStream.end()
|
||||||
|
stream.destroy()
|
||||||
|
|
||||||
|
logger?.debug('encrypted data successfully')
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaKey,
|
||||||
|
encWriteStream,
|
||||||
|
bodyPath,
|
||||||
|
mac,
|
||||||
|
fileEncSha256,
|
||||||
|
fileSha256,
|
||||||
|
fileLength,
|
||||||
|
didSaveToTmpPath
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
encWriteStream.destroy(error)
|
||||||
|
writeStream.destroy(error)
|
||||||
|
aes.destroy(error)
|
||||||
|
hmac.destroy(error)
|
||||||
|
sha256Plain.destroy(error)
|
||||||
|
sha256Enc.destroy(error)
|
||||||
|
stream.destroy(error)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEF_HOST = 'mmg.whatsapp.net'
|
||||||
|
const AES_CHUNK_SIZE = 16
|
||||||
|
|
||||||
|
const toSmallestChunkSize = (num: number) => {
|
||||||
|
return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaDownloadOptions = {
|
||||||
|
startByte?: number
|
||||||
|
endByte?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadContentFromMessage = async(
|
||||||
|
{ mediaKey, directPath, url }: DownloadableMessage,
|
||||||
|
type: MediaType,
|
||||||
|
{ startByte, endByte }: MediaDownloadOptions = { }
|
||||||
|
) => {
|
||||||
|
const downloadUrl = url || `https://${DEF_HOST}${directPath}`
|
||||||
|
let bytesFetched = 0
|
||||||
|
let startChunk = 0
|
||||||
|
let firstBlockIsIV = false
|
||||||
|
// if a start byte is specified -- then we need to fetch the previous chunk as that will form the IV
|
||||||
|
if(startByte) {
|
||||||
|
const chunk = toSmallestChunkSize(startByte || 0)
|
||||||
|
if(chunk) {
|
||||||
|
startChunk = chunk-AES_CHUNK_SIZE
|
||||||
|
bytesFetched = chunk
|
||||||
|
|
||||||
|
firstBlockIsIV = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endChunk = endByte ? toSmallestChunkSize(endByte || 0)+AES_CHUNK_SIZE : undefined
|
||||||
|
|
||||||
|
const headers: { [_: string]: string } = {
|
||||||
|
Origin: DEFAULT_ORIGIN,
|
||||||
|
}
|
||||||
|
if(startChunk || endChunk) {
|
||||||
|
headers.Range = `bytes=${startChunk}-`
|
||||||
|
if(endChunk) {
|
||||||
|
headers.Range += endChunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// download the message
|
||||||
|
const fetched = await getHttpStream(
|
||||||
|
downloadUrl,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let remainingBytes = Buffer.from([])
|
||||||
|
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
|
||||||
|
|
||||||
|
let aes: Crypto.Decipher
|
||||||
|
|
||||||
|
const pushBytes = (bytes: Buffer, push: (bytes: Buffer) => void) => {
|
||||||
|
if(startByte || endByte) {
|
||||||
|
const start = bytesFetched >= startByte ? undefined : Math.max(startByte-bytesFetched, 0)
|
||||||
|
const end = bytesFetched+bytes.length < endByte ? undefined : Math.max(endByte-bytesFetched, 0)
|
||||||
|
|
||||||
|
push(bytes.slice(start, end))
|
||||||
|
|
||||||
|
bytesFetched += bytes.length
|
||||||
|
} else {
|
||||||
|
push(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = new Transform({
|
||||||
|
transform(chunk, _, callback) {
|
||||||
|
let data = Buffer.concat([remainingBytes, chunk])
|
||||||
|
|
||||||
|
const decryptLength = toSmallestChunkSize(data.length)
|
||||||
|
remainingBytes = data.slice(decryptLength)
|
||||||
|
data = data.slice(0, decryptLength)
|
||||||
|
|
||||||
|
if(!aes) {
|
||||||
|
let ivValue = iv
|
||||||
|
if(firstBlockIsIV) {
|
||||||
|
ivValue = data.slice(0, AES_CHUNK_SIZE)
|
||||||
|
data = data.slice(AES_CHUNK_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue)
|
||||||
|
// if an end byte that is not EOF is specified
|
||||||
|
// stop auto padding (PKCS7) -- otherwise throws an error for decryption
|
||||||
|
if(endByte) {
|
||||||
|
aes.setAutoPadding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
pushBytes(aes.update(data), b => this.push(b))
|
||||||
|
callback()
|
||||||
|
} catch(error) {
|
||||||
|
callback(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
final(callback) {
|
||||||
|
try {
|
||||||
|
pushBytes(aes.final(), b => this.push(b))
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export async function decryptMediaMessageBuffer(message: WAMessageContent): Promise<Readable> {
|
||||||
|
/*
|
||||||
|
One can infer media type from the key in the message
|
||||||
|
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
|
||||||
|
*/
|
||||||
|
const type = Object.keys(message)[0] as MessageType
|
||||||
|
if(
|
||||||
|
!type ||
|
||||||
|
type === 'conversation' ||
|
||||||
|
type === 'extendedTextMessage'
|
||||||
|
) {
|
||||||
|
throw new Boom(`no media message for "${type}"`, { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(type === 'locationMessage' || type === 'liveLocationMessage') {
|
||||||
|
const buffer = Buffer.from(message[type].jpegThumbnail)
|
||||||
|
const readable = new Readable({ read: () => {} })
|
||||||
|
readable.push(buffer)
|
||||||
|
readable.push(null)
|
||||||
|
return readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageContent: WAGenericMediaMessage
|
||||||
|
if(message.productMessage) {
|
||||||
|
const product = message.productMessage.product?.productImage
|
||||||
|
if(!product) {
|
||||||
|
throw new Boom('product has no image', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
messageContent = product
|
||||||
|
} else {
|
||||||
|
messageContent = message[type]
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadContentFromMessage(messageContent, type.replace('Message', '') as MediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extensionForMediaMessage(message: WAMessageContent) {
|
||||||
|
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
|
||||||
|
const type = Object.keys(message)[0] as MessageType
|
||||||
|
let extension: string
|
||||||
|
if(
|
||||||
|
type === 'locationMessage' ||
|
||||||
|
type === 'liveLocationMessage' ||
|
||||||
|
type === 'productMessage'
|
||||||
|
) {
|
||||||
|
extension = '.jpeg'
|
||||||
|
} else {
|
||||||
|
const messageContent = message[type] as
|
||||||
|
| WAProto.VideoMessage
|
||||||
|
| WAProto.ImageMessage
|
||||||
|
| WAProto.AudioMessage
|
||||||
|
| WAProto.DocumentMessage
|
||||||
|
extension = getExtension (messageContent.mimetype)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger }: CommonSocketConfig<any>, refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>): WAMediaUploadFunction => {
|
||||||
|
return async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => {
|
||||||
|
const { default: axios } = await import('axios')
|
||||||
|
// send a query JSON to obtain the url & auth token to upload our media
|
||||||
|
let uploadInfo = await refreshMediaConn(false)
|
||||||
|
|
||||||
|
let urls: { mediaUrl: string, directPath: string }
|
||||||
|
const hosts = [ ...customUploadHosts, ...uploadInfo.hosts ]
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reqBody = Buffer.concat(chunks)
|
||||||
|
|
||||||
|
for(const { hostname, maxContentLengthBytes } of hosts) {
|
||||||
|
logger.debug(`uploading to "${hostname}"`)
|
||||||
|
|
||||||
|
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
|
||||||
|
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||||
|
let result: any
|
||||||
|
try {
|
||||||
|
if(maxContentLengthBytes && reqBody.length > maxContentLengthBytes) {
|
||||||
|
throw new Boom(`Body too large for "${hostname}"`, { statusCode: 413 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await axios.post(
|
||||||
|
url,
|
||||||
|
reqBody,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Origin': DEFAULT_ORIGIN
|
||||||
|
},
|
||||||
|
httpsAgent: fetchAgent,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
responseType: 'json',
|
||||||
|
maxBodyLength: Infinity,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = body.data
|
||||||
|
|
||||||
|
if(result?.url || result?.directPath) {
|
||||||
|
urls = {
|
||||||
|
mediaUrl: result.url,
|
||||||
|
directPath: result.direct_path
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
uploadInfo = await refreshMediaConn(true)
|
||||||
|
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
if(axios.isAxiosError(error)) {
|
||||||
|
result = error.response?.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLast = hostname === hosts[uploadInfo.hosts.length-1]?.hostname
|
||||||
|
logger.warn({ trace: error.stack, uploadResult: result }, `Error in uploading to ${hostname} ${isLast ? '' : ', retrying...'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear buffer just to be sure we're releasing the memory
|
||||||
|
reqBody = undefined
|
||||||
|
|
||||||
|
if(!urls) {
|
||||||
|
throw new Boom(
|
||||||
|
'Media upload failed on all hosts',
|
||||||
|
{ statusCode: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
}
|
||||||
495
src/Utils/messages.ts
Normal file
495
src/Utils/messages.ts
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults'
|
||||||
|
import {
|
||||||
|
AnyMediaMessageContent,
|
||||||
|
AnyMessageContent,
|
||||||
|
MediaGenerationOptions,
|
||||||
|
MediaType,
|
||||||
|
MessageContentGenerationOptions,
|
||||||
|
MessageGenerationOptions,
|
||||||
|
MessageGenerationOptionsFromContent,
|
||||||
|
MessageType,
|
||||||
|
WAMediaUpload,
|
||||||
|
WAMessage,
|
||||||
|
WAMessageContent,
|
||||||
|
WAMessageStatus,
|
||||||
|
WAProto,
|
||||||
|
WATextMessage } from '../Types'
|
||||||
|
import { generateMessageID, unixTimestampSeconds } from './generics'
|
||||||
|
import { encryptedStream, generateThumbnail, getAudioDuration } from './messages-media'
|
||||||
|
|
||||||
|
type MediaUploadData = {
|
||||||
|
media: WAMediaUpload
|
||||||
|
caption?: string
|
||||||
|
ptt?: boolean
|
||||||
|
seconds?: number
|
||||||
|
gifPlayback?: boolean
|
||||||
|
fileName?: string
|
||||||
|
jpegThumbnail?: string
|
||||||
|
mimetype?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIMETYPE_MAP: { [T in MediaType]: string } = {
|
||||||
|
image: 'image/jpeg',
|
||||||
|
video: 'video/mp4',
|
||||||
|
document: 'application/pdf',
|
||||||
|
audio: 'audio/ogg; codecs=opus',
|
||||||
|
sticker: 'image/webp',
|
||||||
|
history: 'application/x-protobuf',
|
||||||
|
'md-app-state': 'application/x-protobuf',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageTypeProto = {
|
||||||
|
'image': WAProto.ImageMessage,
|
||||||
|
'video': WAProto.VideoMessage,
|
||||||
|
'audio': WAProto.AudioMessage,
|
||||||
|
'sticker': WAProto.StickerMessage,
|
||||||
|
'document': WAProto.DocumentMessage,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const ButtonType = proto.ButtonsMessage.ButtonsMessageHeaderType
|
||||||
|
|
||||||
|
export const prepareWAMessageMedia = async(
|
||||||
|
message: AnyMediaMessageContent,
|
||||||
|
options: MediaGenerationOptions
|
||||||
|
) => {
|
||||||
|
const logger = options.logger
|
||||||
|
|
||||||
|
let mediaType: typeof MEDIA_KEYS[number]
|
||||||
|
for(const key of MEDIA_KEYS) {
|
||||||
|
if(key in message) {
|
||||||
|
mediaType = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadData: MediaUploadData = {
|
||||||
|
...message,
|
||||||
|
media: message[mediaType]
|
||||||
|
}
|
||||||
|
delete uploadData[mediaType]
|
||||||
|
// check if cacheable + generate cache key
|
||||||
|
const cacheableKey = typeof uploadData.media === 'object' &&
|
||||||
|
('url' in uploadData.media) &&
|
||||||
|
!!uploadData.media.url &&
|
||||||
|
!!options.mediaCache && (
|
||||||
|
// generate the key
|
||||||
|
mediaType + ':' + uploadData.media.url!.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
if(mediaType === 'document' && !uploadData.fileName) {
|
||||||
|
uploadData.fileName = 'file'
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!uploadData.mimetype) {
|
||||||
|
uploadData.mimetype = MIMETYPE_MAP[mediaType]
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for cache hit
|
||||||
|
if(cacheableKey) {
|
||||||
|
const mediaBuff: Buffer = options.mediaCache!.get(cacheableKey)
|
||||||
|
if(mediaBuff) {
|
||||||
|
logger?.debug({ cacheableKey }, 'got media cache hit')
|
||||||
|
|
||||||
|
const obj = WAProto.Message.decode(mediaBuff)
|
||||||
|
const key = `${mediaType}Message`
|
||||||
|
|
||||||
|
delete uploadData.media
|
||||||
|
Object.assign(obj[key], { ...uploadData })
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
|
||||||
|
const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') &&
|
||||||
|
(typeof uploadData['jpegThumbnail'] === 'undefined')
|
||||||
|
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
|
||||||
|
const {
|
||||||
|
mediaKey,
|
||||||
|
encWriteStream,
|
||||||
|
bodyPath,
|
||||||
|
fileEncSha256,
|
||||||
|
fileSha256,
|
||||||
|
fileLength,
|
||||||
|
didSaveToTmpPath
|
||||||
|
} = await encryptedStream(uploadData.media, mediaType, requiresOriginalForSomeProcessing)
|
||||||
|
// url safe Base64 encode the SHA256 hash of the body
|
||||||
|
const fileEncSha256B64 = encodeURIComponent(
|
||||||
|
fileEncSha256.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/\=+$/, '')
|
||||||
|
)
|
||||||
|
|
||||||
|
const [{ mediaUrl, directPath }] = await Promise.all([
|
||||||
|
(async() => {
|
||||||
|
const result = await options.upload(
|
||||||
|
encWriteStream,
|
||||||
|
{ fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs }
|
||||||
|
)
|
||||||
|
logger?.debug('uploaded media')
|
||||||
|
return result
|
||||||
|
})(),
|
||||||
|
(async() => {
|
||||||
|
try {
|
||||||
|
if(requiresThumbnailComputation) {
|
||||||
|
uploadData.jpegThumbnail = await generateThumbnail(bodyPath, mediaType as any, options)
|
||||||
|
logger?.debug('generated thumbnail')
|
||||||
|
}
|
||||||
|
|
||||||
|
if(requiresDurationComputation) {
|
||||||
|
uploadData.seconds = await getAudioDuration(bodyPath)
|
||||||
|
logger?.debug('computed audio duration')
|
||||||
|
}
|
||||||
|
} catch(error) {
|
||||||
|
logger?.warn({ trace: error.stack }, 'failed to obtain extra info')
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
.finally(
|
||||||
|
async() => {
|
||||||
|
encWriteStream.destroy()
|
||||||
|
// remove tmp files
|
||||||
|
if(didSaveToTmpPath && bodyPath) {
|
||||||
|
await fs.unlink(bodyPath)
|
||||||
|
logger?.debug('removed tmp files')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
delete uploadData.media
|
||||||
|
|
||||||
|
const obj = WAProto.Message.fromObject({
|
||||||
|
[`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject(
|
||||||
|
{
|
||||||
|
url: mediaUrl,
|
||||||
|
directPath,
|
||||||
|
mediaKey,
|
||||||
|
fileEncSha256,
|
||||||
|
fileSha256,
|
||||||
|
fileLength,
|
||||||
|
mediaKeyTimestamp: unixTimestampSeconds(),
|
||||||
|
...uploadData
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if(cacheableKey) {
|
||||||
|
logger.debug({ cacheableKey }, 'set cache')
|
||||||
|
options.mediaCache!.set(cacheableKey, WAProto.Message.encode(obj).finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
|
||||||
|
ephemeralExpiration = ephemeralExpiration || 0
|
||||||
|
const content: WAMessageContent = {
|
||||||
|
ephemeralMessage: {
|
||||||
|
message: {
|
||||||
|
protocolMessage: {
|
||||||
|
type: WAProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
|
||||||
|
ephemeralExpiration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WAProto.Message.fromObject(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate forwarded message content like WA does
|
||||||
|
* @param message the message to forward
|
||||||
|
* @param options.forceForward will show the message as forwarded even if it is from you
|
||||||
|
*/
|
||||||
|
export const generateForwardMessageContent = (
|
||||||
|
message: WAMessage,
|
||||||
|
forceForward?: boolean
|
||||||
|
) => {
|
||||||
|
let content = message.message
|
||||||
|
if(!content) {
|
||||||
|
throw new Boom('no content in message', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// hacky copy
|
||||||
|
content = proto.Message.decode(proto.Message.encode(message.message).finish())
|
||||||
|
|
||||||
|
let key = Object.keys(content)[0] as MessageType
|
||||||
|
|
||||||
|
let score = content[key].contextInfo?.forwardingScore || 0
|
||||||
|
score += message.key.fromMe && !forceForward ? 0 : 1
|
||||||
|
if(key === 'conversation') {
|
||||||
|
content.extendedTextMessage = { text: content[key] }
|
||||||
|
delete content.conversation
|
||||||
|
|
||||||
|
key = 'extendedTextMessage'
|
||||||
|
}
|
||||||
|
|
||||||
|
if(score > 0) {
|
||||||
|
content[key].contextInfo = { forwardingScore: score, isForwarded: true }
|
||||||
|
} else {
|
||||||
|
content[key].contextInfo = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateWAMessageContent = async(
|
||||||
|
message: AnyMessageContent,
|
||||||
|
options: MessageContentGenerationOptions
|
||||||
|
) => {
|
||||||
|
let m: WAMessageContent = {}
|
||||||
|
if('text' in message) {
|
||||||
|
const extContent = { ...message } as WATextMessage
|
||||||
|
if(!!options.getUrlInfo && message.text.match(URL_REGEX)) {
|
||||||
|
try {
|
||||||
|
const data = await options.getUrlInfo(message.text)
|
||||||
|
extContent.canonicalUrl = data['canonical-url']
|
||||||
|
extContent.matchedText = data['matched-text']
|
||||||
|
extContent.jpegThumbnail = data.jpegThumbnail
|
||||||
|
extContent.description = data.description
|
||||||
|
extContent.title = data.title
|
||||||
|
extContent.previewType = 0
|
||||||
|
} catch(error) { // ignore if fails
|
||||||
|
options.logger?.warn({ trace: error.stack }, 'url generation failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.extendedTextMessage = extContent
|
||||||
|
} else if('contacts' in message) {
|
||||||
|
const contactLen = message.contacts.contacts.length
|
||||||
|
if(!contactLen) {
|
||||||
|
throw new Boom('require atleast 1 contact', { statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if(contactLen === 1) {
|
||||||
|
m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0])
|
||||||
|
} else {
|
||||||
|
m.contactsArrayMessage = WAProto.ContactsArrayMessage.fromObject(message.contacts)
|
||||||
|
}
|
||||||
|
} else if('location' in message) {
|
||||||
|
m.locationMessage = WAProto.LocationMessage.fromObject(message.location)
|
||||||
|
} else if('delete' in message) {
|
||||||
|
m.protocolMessage = {
|
||||||
|
key: message.delete,
|
||||||
|
type: WAProto.ProtocolMessage.ProtocolMessageType.REVOKE
|
||||||
|
}
|
||||||
|
} else if('forward' in message) {
|
||||||
|
m = generateForwardMessageContent(
|
||||||
|
message.forward,
|
||||||
|
message.force
|
||||||
|
)
|
||||||
|
} else if('disappearingMessagesInChat' in message) {
|
||||||
|
const exp = typeof message.disappearingMessagesInChat === 'boolean' ?
|
||||||
|
(message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
|
||||||
|
message.disappearingMessagesInChat
|
||||||
|
m = prepareDisappearingMessageSettingContent(exp)
|
||||||
|
} else {
|
||||||
|
m = await prepareWAMessageMedia(
|
||||||
|
message,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if('buttons' in message && !!message.buttons) {
|
||||||
|
const buttonsMessage: proto.IButtonsMessage = {
|
||||||
|
buttons: message.buttons!.map(b => ({ ...b, type: proto.Button.ButtonType.RESPONSE }))
|
||||||
|
}
|
||||||
|
if('text' in message) {
|
||||||
|
buttonsMessage.contentText = message.text
|
||||||
|
buttonsMessage.headerType = ButtonType.EMPTY
|
||||||
|
} else {
|
||||||
|
if('caption' in message) {
|
||||||
|
buttonsMessage.contentText = message.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = Object.keys(m)[0].replace('Message', '').toUpperCase()
|
||||||
|
buttonsMessage.headerType = ButtonType[type]
|
||||||
|
|
||||||
|
Object.assign(buttonsMessage, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if('footer' in message && !!message.footer) {
|
||||||
|
buttonsMessage.footerText = message.footer
|
||||||
|
}
|
||||||
|
|
||||||
|
m = { buttonsMessage }
|
||||||
|
} else if('templateButtons' in message && !!message.templateButtons) {
|
||||||
|
const templateMessage: proto.ITemplateMessage = {
|
||||||
|
hydratedTemplate: {
|
||||||
|
hydratedButtons: message.templateButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if('text' in message) {
|
||||||
|
templateMessage.hydratedTemplate.hydratedContentText = message.text
|
||||||
|
} else {
|
||||||
|
|
||||||
|
if('caption' in message) {
|
||||||
|
templateMessage.hydratedTemplate.hydratedContentText = message.caption
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(templateMessage.hydratedTemplate, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if('footer' in message && !!message.footer) {
|
||||||
|
templateMessage.hydratedTemplate.hydratedFooterText = message.footer
|
||||||
|
}
|
||||||
|
|
||||||
|
m = { templateMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
if('sections' in message && !!message.sections) {
|
||||||
|
const listMessage: proto.IListMessage = {
|
||||||
|
sections: message.sections,
|
||||||
|
buttonText: message.buttonText,
|
||||||
|
title: message.title,
|
||||||
|
footerText: message.footer,
|
||||||
|
description: message.text,
|
||||||
|
listType: proto.ListMessage.ListMessageListType['SINGLE_SELECT']
|
||||||
|
}
|
||||||
|
|
||||||
|
m = { listMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
if('viewOnce' in message && !!message.viewOnce) {
|
||||||
|
m = { viewOnceMessage: { message: m } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if('mentions' in message && message.mentions?.length) {
|
||||||
|
const [messageType] = Object.keys(m)
|
||||||
|
m[messageType].contextInfo = m[messageType] || { }
|
||||||
|
m[messageType].contextInfo.mentionedJid = message.mentions
|
||||||
|
}
|
||||||
|
|
||||||
|
return WAProto.Message.fromObject(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateWAMessageFromContent = (
|
||||||
|
jid: string,
|
||||||
|
message: WAMessageContent,
|
||||||
|
options: MessageGenerationOptionsFromContent
|
||||||
|
) => {
|
||||||
|
if(!options.timestamp) {
|
||||||
|
options.timestamp = new Date()
|
||||||
|
} // set timestamp to now
|
||||||
|
|
||||||
|
const key = Object.keys(message)[0]
|
||||||
|
const timestamp = unixTimestampSeconds(options.timestamp)
|
||||||
|
const { quoted, userJid } = options
|
||||||
|
|
||||||
|
if(quoted) {
|
||||||
|
const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)
|
||||||
|
|
||||||
|
message[key].contextInfo = message[key].contextInfo || { }
|
||||||
|
message[key].contextInfo.participant = participant
|
||||||
|
message[key].contextInfo.stanzaId = quoted.key.id
|
||||||
|
message[key].contextInfo.quotedMessage = quoted.message
|
||||||
|
|
||||||
|
// if a participant is quoted, then it must be a group
|
||||||
|
// hence, remoteJid of group must also be entered
|
||||||
|
if(quoted.key.participant || quoted.participant) {
|
||||||
|
message[key].contextInfo.remoteJid = quoted.key.remoteJid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(
|
||||||
|
// if we want to send a disappearing message
|
||||||
|
!!options?.ephemeralExpiration &&
|
||||||
|
// and it's not a protocol message -- delete, toggle disappear message
|
||||||
|
key !== 'protocolMessage' &&
|
||||||
|
// already not converted to disappearing message
|
||||||
|
key !== 'ephemeralMessage'
|
||||||
|
) {
|
||||||
|
message[key].contextInfo = {
|
||||||
|
...(message[key].contextInfo || {}),
|
||||||
|
expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL,
|
||||||
|
//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
|
||||||
|
}
|
||||||
|
message = {
|
||||||
|
ephemeralMessage: {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message = WAProto.Message.fromObject(message)
|
||||||
|
|
||||||
|
const messageJSON = {
|
||||||
|
key: {
|
||||||
|
remoteJid: jid,
|
||||||
|
fromMe: true,
|
||||||
|
id: options?.messageId || generateMessageID(),
|
||||||
|
},
|
||||||
|
message: message,
|
||||||
|
messageTimestamp: timestamp,
|
||||||
|
messageStubParameters: [],
|
||||||
|
participant: jid.includes('@g.us') ? userJid : undefined,
|
||||||
|
status: WAMessageStatus.PENDING
|
||||||
|
}
|
||||||
|
return WAProto.WebMessageInfo.fromObject(messageJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateWAMessage = async(
|
||||||
|
jid: string,
|
||||||
|
content: AnyMessageContent,
|
||||||
|
options: MessageGenerationOptions,
|
||||||
|
) => {
|
||||||
|
// ensure msg ID is with every log
|
||||||
|
options.logger = options?.logger?.child({ msgId: options.messageId })
|
||||||
|
return generateWAMessageFromContent(
|
||||||
|
jid,
|
||||||
|
await generateWAMessageContent(
|
||||||
|
content,
|
||||||
|
options
|
||||||
|
),
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the key to access the true type of content */
|
||||||
|
export const getContentType = (content: WAProto.IMessage | undefined) => {
|
||||||
|
if(content) {
|
||||||
|
const keys = Object.keys(content)
|
||||||
|
const key = keys.find(k => (k === 'conversation' || k.endsWith('Message')) && k !== 'senderKeyDistributionMessage')
|
||||||
|
return key as keyof typeof content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the true message content from a message
|
||||||
|
* Eg. extracts the inner message from a disappearing message/view once message
|
||||||
|
*/
|
||||||
|
export const extractMessageContent = (content: WAMessageContent | undefined | null): WAMessageContent | undefined => {
|
||||||
|
content = content?.ephemeralMessage?.message ||
|
||||||
|
content?.viewOnceMessage?.message ||
|
||||||
|
content ||
|
||||||
|
undefined
|
||||||
|
|
||||||
|
if(content?.buttonsMessage) {
|
||||||
|
const { buttonsMessage } = content
|
||||||
|
if(buttonsMessage.imageMessage) {
|
||||||
|
return { imageMessage: buttonsMessage.imageMessage }
|
||||||
|
} else if(buttonsMessage.documentMessage) {
|
||||||
|
return { documentMessage: buttonsMessage.documentMessage }
|
||||||
|
} else if(buttonsMessage.videoMessage) {
|
||||||
|
return { videoMessage: buttonsMessage.videoMessage }
|
||||||
|
} else if(buttonsMessage.locationMessage) {
|
||||||
|
return { locationMessage: buttonsMessage.locationMessage }
|
||||||
|
} else {
|
||||||
|
return { conversation: buttonsMessage.contentText }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the device predicted by message ID
|
||||||
|
*/
|
||||||
|
export const getDevice = (id: string) => {
|
||||||
|
const deviceType = id.length > 21 ? 'android' : id.substring(0, 2) === '3A' ? 'ios' : 'web'
|
||||||
|
return deviceType
|
||||||
|
}
|
||||||
179
src/Utils/noise-handler.ts
Normal file
179
src/Utils/noise-handler.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { createCipheriv, createDecipheriv } from 'crypto'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { NOISE_MODE, NOISE_WA_HEADER } from '../Defaults'
|
||||||
|
import { KeyPair } from '../Types'
|
||||||
|
import { Binary } from '../WABinary'
|
||||||
|
import { BinaryNode, decodeBinaryNode } from '../WABinary'
|
||||||
|
import { Curve, hkdf, sha256 } from './crypto'
|
||||||
|
|
||||||
|
const generateIV = (counter: number) => {
|
||||||
|
const iv = new ArrayBuffer(12)
|
||||||
|
new DataView(iv).setUint32(8, counter)
|
||||||
|
|
||||||
|
return new Uint8Array(iv)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeNoiseHandler = ({ 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(Curve.sharedKey(privateKey, serverHello.ephemeral!))
|
||||||
|
|
||||||
|
const decStaticContent = decrypt(serverHello!.static!)
|
||||||
|
mixIntoKey(Curve.sharedKey(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(Curve.sharedKey(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/Utils/signal.ts
Normal file
276
src/Utils/signal.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import * as libsignal from 'libsignal'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage, SenderKeyName, SenderKeyRecord } from '../../WASignalGroup'
|
||||||
|
import { AuthenticationCreds, KeyPair, SignalAuthState, SignalIdentity, SignalKeyStore, SignedKeyPair } from '../Types/Auth'
|
||||||
|
import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildUInt, jidDecode, JidWithDevice } from '../WABinary'
|
||||||
|
import { Curve } from './crypto'
|
||||||
|
import { encodeBigEndian } from './generics'
|
||||||
|
|
||||||
|
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({ get }: SignalKeyStore, min: number, limit: number) => {
|
||||||
|
const idList: string[] = []
|
||||||
|
for(let id = min; id < limit;id++) {
|
||||||
|
idList.push(id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return get('pre-key', idList)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateOrGetPreKeys = (creds: AuthenticationCreds, 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] = Curve.generateKeyPair()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: SignalAuthState) => ({
|
||||||
|
loadSession: async(id: string) => {
|
||||||
|
const { [id]: sess } = await keys.get('session', [id])
|
||||||
|
if(sess) {
|
||||||
|
return libsignal.SessionRecord.deserialize(sess)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
storeSession: async(id, session) => {
|
||||||
|
await keys.set({ 'session': { [id]: session.serialize() } })
|
||||||
|
},
|
||||||
|
isTrustedIdentity: () => {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
loadPreKey: async(id: number | string) => {
|
||||||
|
const keyId = id.toString()
|
||||||
|
const { [keyId]: key } = await keys.get('pre-key', [keyId])
|
||||||
|
if(key) {
|
||||||
|
return {
|
||||||
|
privKey: Buffer.from(key.private),
|
||||||
|
pubKey: Buffer.from(key.public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removePreKey: (id: number) => keys.set({ 'pre-key': { [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: string) => {
|
||||||
|
const { [keyId]: key } = await keys.get('sender-key', [keyId])
|
||||||
|
if(key) {
|
||||||
|
return new SenderKeyRecord(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
storeSenderKey: async(keyId, key) => {
|
||||||
|
await keys.set({ 'sender-key': { [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: SignalAuthState) => {
|
||||||
|
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: SignalAuthState
|
||||||
|
) => {
|
||||||
|
const builder = new GroupSessionBuilder(signalStorage(auth))
|
||||||
|
const senderName = jidToSignalSenderKeyName(item.groupId, authorJid)
|
||||||
|
|
||||||
|
const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage)
|
||||||
|
const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName])
|
||||||
|
if(!senderKey) {
|
||||||
|
const record = new SenderKeyRecord()
|
||||||
|
await auth.keys.set({ 'sender-key': { [senderName]: record } })
|
||||||
|
}
|
||||||
|
|
||||||
|
await builder.process(senderName, senderMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg: Buffer | Uint8Array, auth: SignalAuthState) => {
|
||||||
|
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: SignalAuthState) => {
|
||||||
|
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, meId: string, auth: SignalAuthState) => {
|
||||||
|
const storage = signalStorage(auth)
|
||||||
|
const senderName = jidToSignalSenderKeyName(group, meId)
|
||||||
|
const builder = new GroupSessionBuilder(storage)
|
||||||
|
|
||||||
|
const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName])
|
||||||
|
if(!senderKey) {
|
||||||
|
const record = new SenderKeyRecord()
|
||||||
|
await auth.keys.set({ 'sender-key': { [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 parseAndInjectE2ESessions = async(node: BinaryNode, auth: SignalAuthState) => {
|
||||||
|
const extractKey = (key: BinaryNode) => (
|
||||||
|
key ? ({
|
||||||
|
keyId: getBinaryNodeChildUInt(key, 'id', 3),
|
||||||
|
publicKey: generateSignalPubKey(
|
||||||
|
getBinaryNodeChildBuffer(key, 'value')
|
||||||
|
),
|
||||||
|
signature: getBinaryNodeChildBuffer(key, 'signature'),
|
||||||
|
}) : undefined
|
||||||
|
)
|
||||||
|
const nodes = getBinaryNodeChildren(getBinaryNodeChild(node, 'list'), 'user')
|
||||||
|
for(const node of nodes) {
|
||||||
|
assertNodeErrorFree(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
nodes.map(
|
||||||
|
async 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, myJid: string, excludeZeroDevices: boolean) => {
|
||||||
|
const { user: myUser, device: myDevice } = jidDecode(myJid)
|
||||||
|
const extracted: JidWithDevice[] = []
|
||||||
|
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) {
|
||||||
|
const device = +attrs.id
|
||||||
|
if(
|
||||||
|
tag === 'device' && // ensure the "device" tag
|
||||||
|
(!excludeZeroDevices || device !== 0) && // if zero devices are not-excluded, or device is non zero
|
||||||
|
(myUser !== user || myDevice !== device) && // either different user or if me user, not this device
|
||||||
|
(device === 0 || !!attrs['key-index']) // ensure that "key-index" is specified for "non-zero" devices, produces a bad req otherwise
|
||||||
|
) {
|
||||||
|
extracted.push({ user, device })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
152
src/Utils/validate-connection.ts
Normal file
152
src/Utils/validate-connection.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types'
|
||||||
|
import { Binary, BinaryNode, getAllBinaryNodeChildren, jidDecode, S_WHATSAPP_NET } from '../WABinary'
|
||||||
|
import { Curve, hmacSign } from './crypto'
|
||||||
|
import { encodeInt } from './generics'
|
||||||
|
import { createSignalIdentity } from './signal'
|
||||||
|
|
||||||
|
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 generateRegistrationNode = (
|
||||||
|
{ registrationId, signedPreKey, signedIdentityKey }: SignalCreds,
|
||||||
|
config: Pick<SocketConfig, 'version' | 'browser'>
|
||||||
|
) => {
|
||||||
|
const appVersionBuf = new Uint8Array(Buffer.from(ENCODED_VERSION, 'base64'))
|
||||||
|
|
||||||
|
const companion = {
|
||||||
|
os: config.browser[0],
|
||||||
|
version: {
|
||||||
|
primary: 10,
|
||||||
|
secondary: undefined,
|
||||||
|
tertiary: undefined,
|
||||||
|
},
|
||||||
|
platformType: 1,
|
||||||
|
requireFullSync: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const companionProto = proto.CompanionProps.encode(companion).finish()
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return proto.ClientPayload.encode(registerPayload).finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configureSuccessfulPairing = (
|
||||||
|
stanza: BinaryNode,
|
||||||
|
{ advSecretKey, signedIdentityKey, signalIdentities }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
|
||||||
|
) => {
|
||||||
|
const [pair] = getAllBinaryNodeChildren(stanza)
|
||||||
|
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(!Curve.verify(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 = Curve.sign(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
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/WABinary/Legacy/constants.ts
Normal file
198
src/WABinary/Legacy/constants.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
|
||||||
|
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',
|
||||||
|
]
|
||||||
376
src/WABinary/Legacy/index.ts
Normal file
376
src/WABinary/Legacy/index.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
|
||||||
|
import { BinaryNode } from '../types'
|
||||||
|
import { DoubleByteTokens, SingleByteTokens, Tags } from './constants'
|
||||||
|
|
||||||
|
export const isLegacyBinaryNode = (buffer: Buffer) => {
|
||||||
|
switch (buffer[0]) {
|
||||||
|
case Tags.LIST_EMPTY:
|
||||||
|
case Tags.LIST_8:
|
||||||
|
case Tags.LIST_16:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode(buffer: Buffer, indexRef: { index: number }): BinaryNode {
|
||||||
|
|
||||||
|
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, 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 listSize = readListSize(readByte())
|
||||||
|
const descrTag = readByte()
|
||||||
|
if(descrTag === Tags.STREAM_END) {
|
||||||
|
throw new Error('unexpected stream end')
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = readString(descrTag)
|
||||||
|
const attrs: BinaryNode['attrs'] = { }
|
||||||
|
let data: BinaryNode['content']
|
||||||
|
if(listSize === 0 || !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()
|
||||||
|
|
||||||
|
attrs[key] = readString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if(listSize % 2 === 0) {
|
||||||
|
const tag = readByte()
|
||||||
|
if(isListTag(tag)) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
data = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: header,
|
||||||
|
attrs,
|
||||||
|
content: data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encode = ({ tag, attrs, content }: 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(attrs).filter(k => (
|
||||||
|
typeof attrs[k] !== 'undefined' && attrs[k] !== null
|
||||||
|
))
|
||||||
|
|
||||||
|
writeListStart(2*validAttributes.length + 1 + (typeof content !== 'undefined' && content !== null ? 1 : 0))
|
||||||
|
writeString(tag)
|
||||||
|
|
||||||
|
validAttributes.forEach((key) => {
|
||||||
|
if(typeof attrs[key] === 'string') {
|
||||||
|
writeString(key)
|
||||||
|
writeString(attrs[key])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if(typeof content === 'string') {
|
||||||
|
writeString(content, true)
|
||||||
|
} else if(Buffer.isBuffer(content)) {
|
||||||
|
writeByteLength(content.length)
|
||||||
|
pushBytes(content)
|
||||||
|
} else if(Array.isArray(content)) {
|
||||||
|
writeListStart(content.length)
|
||||||
|
for(const item of content) {
|
||||||
|
if(item) {
|
||||||
|
encode(item, buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if(typeof content === 'undefined' || content === null) {
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw new Error(`invalid children for header "${tag}": ${content} (${typeof content})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encodeBinaryNodeLegacy = encode
|
||||||
|
export const decodeBinaryNodeLegacy = decode
|
||||||
81
src/WABinary/generic-utils.ts
Normal file
81
src/WABinary/generic-utils.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Boom } from '@hapi/boom'
|
||||||
|
import { proto } from '../../WAProto'
|
||||||
|
import { BinaryNode } from './types'
|
||||||
|
|
||||||
|
// 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 getAllBinaryNodeChildren = ({ content }: BinaryNode) => {
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
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 const reduceBinaryNodeToDictionary = (node: BinaryNode, tag: string) => {
|
||||||
|
const nodes = getBinaryNodeChildren(node, tag)
|
||||||
|
const dict = nodes.reduce(
|
||||||
|
(dict, { attrs }) => {
|
||||||
|
dict[attrs.name || attrs.config_code] = attrs.value || attrs.config_value
|
||||||
|
return dict
|
||||||
|
}, { } as { [_: string]: string }
|
||||||
|
)
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBinaryNodeMessages = ({ content }: BinaryNode) => {
|
||||||
|
const msgs: proto.WebMessageInfo[] = []
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
for(const item of content) {
|
||||||
|
if(item.tag === 'message') {
|
||||||
|
msgs.push(proto.WebMessageInfo.decode(item.content as Buffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToUInt(e: Uint8Array | Buffer, t: number) {
|
||||||
|
let a = 0
|
||||||
|
for(let i = 0; i < t; i++) {
|
||||||
|
a = 256 * a + e[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
326
src/WABinary/index.ts
Normal file
326
src/WABinary/index.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { DICTIONARIES_MAP, SINGLE_BYTE_TOKEN, SINGLE_BYTE_TOKEN_MAP, DICTIONARIES } from '../../WABinary/Constants';
|
||||||
|
import { jidDecode, jidEncode } from './jid-utils';
|
||||||
|
import { Binary, numUtf8Bytes } from '../../WABinary/Binary';
|
||||||
|
import { Boom } from '@hapi/boom';
|
||||||
|
import { proto } from '../../WAProto';
|
||||||
|
import { BinaryNode } from './types';
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAllBinaryNodeChildren = ({ content }: BinaryNode) => {
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
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 const reduceBinaryNodeToDictionary = (node: BinaryNode, tag: string) => {
|
||||||
|
const nodes = getBinaryNodeChildren(node, tag)
|
||||||
|
const dict = nodes.reduce(
|
||||||
|
(dict, { attrs }) => {
|
||||||
|
dict[attrs.name || attrs.config_code] = attrs.value || attrs.config_value
|
||||||
|
return dict
|
||||||
|
}, { } as { [_: string]: string }
|
||||||
|
)
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBinaryNodeMessages = ({ content }: BinaryNode) => {
|
||||||
|
const msgs: proto.WebMessageInfo[] = []
|
||||||
|
if(Array.isArray(content)) {
|
||||||
|
for(const item of content) {
|
||||||
|
if(item.tag === 'message') {
|
||||||
|
msgs.push(proto.WebMessageInfo.decode(item.content as Buffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './generic-utils'
|
||||||
|
export * from './jid-utils'
|
||||||
|
export { Binary } from '../../WABinary/Binary'
|
||||||
|
export * from './types'
|
||||||
|
export * from './Legacy'
|
||||||
54
src/WABinary/jid-utils.ts
Normal file
54
src/WABinary/jid-utils.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 type JidWithDevice = {
|
||||||
|
user: string
|
||||||
|
device?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const 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)
|
||||||
|
}
|
||||||
14
src/WABinary/types.ts
Normal file
14
src/WABinary/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* 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']
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
import WS from 'ws'
|
|
||||||
import * as fs from 'fs'
|
|
||||||
import * as Utils from './Utils'
|
|
||||||
import Encoder from '../Binary/Encoder'
|
|
||||||
import Decoder from '../Binary/Decoder'
|
|
||||||
import got, { Method } from 'got'
|
|
||||||
import {
|
|
||||||
AuthenticationCredentials,
|
|
||||||
WAUser,
|
|
||||||
WANode,
|
|
||||||
WATag,
|
|
||||||
BaileysError,
|
|
||||||
WAMetric,
|
|
||||||
WAFlag,
|
|
||||||
DisconnectReason,
|
|
||||||
WAConnectionState,
|
|
||||||
AnyAuthenticationCredentials,
|
|
||||||
WAContact,
|
|
||||||
WAQuery,
|
|
||||||
ReconnectMode,
|
|
||||||
WAConnectOptions,
|
|
||||||
MediaConnInfo,
|
|
||||||
DEFAULT_ORIGIN,
|
|
||||||
} from './Constants'
|
|
||||||
import { EventEmitter } from 'events'
|
|
||||||
import KeyedDB from '@adiwajshing/keyed-db'
|
|
||||||
import { STATUS_CODES } from 'http'
|
|
||||||
import { Agent } from 'https'
|
|
||||||
import pino from 'pino'
|
|
||||||
|
|
||||||
const logger = pino({ prettyPrint: { levelFirst: true, ignore: 'hostname', translateTime: true }, prettifier: require('pino-pretty') })
|
|
||||||
|
|
||||||
export class WAConnection extends EventEmitter {
|
|
||||||
/** The version of WhatsApp Web we're telling the servers we are */
|
|
||||||
version: [number, number, number] = [2, 2149, 4]
|
|
||||||
/** The Browser we're telling the WhatsApp Web servers we are */
|
|
||||||
browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome')
|
|
||||||
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
|
|
||||||
user: WAUser
|
|
||||||
/** Should requests be queued when the connection breaks in between; if 0, then an error will be thrown */
|
|
||||||
pendingRequestTimeoutMs: number = null
|
|
||||||
/** The connection state */
|
|
||||||
state: WAConnectionState = 'close'
|
|
||||||
connectOptions: WAConnectOptions = {
|
|
||||||
maxIdleTimeMs: 60_000,
|
|
||||||
maxRetries: 10,
|
|
||||||
connectCooldownMs: 4000,
|
|
||||||
phoneResponseTime: 15_000,
|
|
||||||
maxQueryResponseTime: 10_000,
|
|
||||||
alwaysUseTakeover: true,
|
|
||||||
queryChatsTillReceived: true,
|
|
||||||
logQR: true
|
|
||||||
}
|
|
||||||
/** When to auto-reconnect */
|
|
||||||
autoReconnect = ReconnectMode.onConnectionLost
|
|
||||||
/** Whether the phone is connected */
|
|
||||||
phoneConnected: boolean = false
|
|
||||||
/** key to use to order chats */
|
|
||||||
chatOrderingKey = Utils.waChatKey(false)
|
|
||||||
|
|
||||||
logger = logger.child ({ class: 'Baileys' })
|
|
||||||
|
|
||||||
/** log messages */
|
|
||||||
shouldLogMessages = false
|
|
||||||
messageLog: { tag: string, json: string, fromMe: boolean, binaryTags?: any[] }[] = []
|
|
||||||
|
|
||||||
maxCachedMessages = 50
|
|
||||||
|
|
||||||
lastChatsReceived: Date
|
|
||||||
chats = new KeyedDB (Utils.waChatKey(false), value => value.jid)
|
|
||||||
contacts: { [k: string]: WAContact } = {}
|
|
||||||
blocklist: string[] = []
|
|
||||||
|
|
||||||
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
|
|
||||||
protected authInfo: AuthenticationCredentials
|
|
||||||
/** Curve keys to initially authenticate */
|
|
||||||
protected curveKeys: { private: Uint8Array; public: Uint8Array }
|
|
||||||
/** The websocket connection */
|
|
||||||
protected conn: WS
|
|
||||||
protected msgCount = 0
|
|
||||||
protected keepAliveReq: NodeJS.Timeout
|
|
||||||
protected encoder = new Encoder()
|
|
||||||
protected decoder = new Decoder()
|
|
||||||
protected phoneCheckInterval
|
|
||||||
protected phoneCheckListeners = 0
|
|
||||||
|
|
||||||
protected referenceDate = new Date () // used for generating tags
|
|
||||||
protected lastSeen: Date = null // last keep alive received
|
|
||||||
protected initTimeout: NodeJS.Timeout
|
|
||||||
|
|
||||||
protected lastDisconnectTime: Date = null
|
|
||||||
protected lastDisconnectReason: DisconnectReason
|
|
||||||
|
|
||||||
protected mediaConn: MediaConnInfo
|
|
||||||
protected connectionDebounceTimeout = Utils.debouncedTimeout(
|
|
||||||
1000,
|
|
||||||
() => this.state === 'connecting' && this.endConnection(DisconnectReason.timedOut)
|
|
||||||
)
|
|
||||||
// timeout to know when we're done recieving messages
|
|
||||||
protected messagesDebounceTimeout = Utils.debouncedTimeout(2000)
|
|
||||||
// ping chats till recieved
|
|
||||||
protected chatsDebounceTimeout = Utils.debouncedTimeout(10_000)
|
|
||||||
/**
|
|
||||||
* Connect to WhatsAppWeb
|
|
||||||
* @param options the connect options
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
async unexpectedDisconnect (error: DisconnectReason) {
|
|
||||||
if (this.state === 'open') {
|
|
||||||
const willReconnect =
|
|
||||||
(this.autoReconnect === ReconnectMode.onAllErrors ||
|
|
||||||
(this.autoReconnect === ReconnectMode.onConnectionLost && error !== DisconnectReason.replaced)) &&
|
|
||||||
error !== DisconnectReason.invalidSession // do not reconnect if credentials have been invalidated
|
|
||||||
|
|
||||||
this.closeInternal(error, willReconnect)
|
|
||||||
willReconnect && (
|
|
||||||
this.connect()
|
|
||||||
.catch(err => {}) // prevent unhandled exeception
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.endConnection(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* base 64 encode the authentication credentials and return them
|
|
||||||
* these can then be used to login again by passing the object to the connect () function.
|
|
||||||
* @see connect () in WhatsAppWeb.Session
|
|
||||||
*/
|
|
||||||
base64EncodedAuthInfo() {
|
|
||||||
return {
|
|
||||||
clientID: this.authInfo.clientID,
|
|
||||||
serverToken: this.authInfo.serverToken,
|
|
||||||
clientToken: this.authInfo.clientToken,
|
|
||||||
encKey: this.authInfo.encKey.toString('base64'),
|
|
||||||
macKey: this.authInfo.macKey.toString('base64'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Can you login to WA without scanning the QR */
|
|
||||||
canLogin () {
|
|
||||||
return !!this.authInfo?.encKey && !!this.authInfo?.macKey
|
|
||||||
}
|
|
||||||
/** Clear authentication info so a new connection can be created */
|
|
||||||
clearAuthInfo () {
|
|
||||||
this.authInfo = null
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Load in the authentication credentials
|
|
||||||
* @param authInfo the authentication credentials or file path to auth credentials
|
|
||||||
*/
|
|
||||||
loadAuthInfo(authInfo: AnyAuthenticationCredentials | string) {
|
|
||||||
if (!authInfo) throw new Error('given authInfo is null')
|
|
||||||
|
|
||||||
if (typeof authInfo === 'string') {
|
|
||||||
this.logger.info(`loading authentication credentials from ${authInfo}`)
|
|
||||||
const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
|
|
||||||
authInfo = JSON.parse(file) as AnyAuthenticationCredentials
|
|
||||||
}
|
|
||||||
if ('clientID' in authInfo) {
|
|
||||||
this.authInfo = {
|
|
||||||
clientID: authInfo.clientID,
|
|
||||||
serverToken: authInfo.serverToken,
|
|
||||||
clientToken: authInfo.clientToken,
|
|
||||||
encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'),
|
|
||||||
macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const secretBundle: {encKey: string, macKey: string} = typeof authInfo.WASecretBundle === 'string' ? JSON.parse (authInfo.WASecretBundle): authInfo.WASecretBundle
|
|
||||||
this.authInfo = {
|
|
||||||
clientID: authInfo.WABrowserId.replace(/\"/g, ''),
|
|
||||||
serverToken: authInfo.WAToken2.replace(/\"/g, ''),
|
|
||||||
clientToken: authInfo.WAToken1.replace(/\"/g, ''),
|
|
||||||
encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64
|
|
||||||
macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
async waitForMessage(tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) {
|
|
||||||
let onRecv: (json) => void
|
|
||||||
let onErr: (err) => void
|
|
||||||
let cancelPhoneChecker: () => void
|
|
||||||
if (requiresPhoneConnection) {
|
|
||||||
this.startPhoneCheckInterval()
|
|
||||||
cancelPhoneChecker = this.exitQueryIfResponseNotExpected(tag, err => onErr(err))
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await Utils.promiseTimeout(timeoutMs,
|
|
||||||
(resolve, reject) => {
|
|
||||||
onRecv = resolve
|
|
||||||
onErr = ({ reason, status }) => reject(new BaileysError(reason, { status }))
|
|
||||||
this.on (`TAG:${tag}`, onRecv)
|
|
||||||
this.on ('ws-close', onErr) // if the socket closes, you'll never receive the message
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return result as any
|
|
||||||
} finally {
|
|
||||||
requiresPhoneConnection && this.clearPhoneCheckInterval()
|
|
||||||
this.off (`TAG:${tag}`, onRecv)
|
|
||||||
this.off (`ws-close`, onErr)
|
|
||||||
cancelPhoneChecker && cancelPhoneChecker()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Generic function for action, set queries */
|
|
||||||
async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) {
|
|
||||||
const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes]
|
|
||||||
const result = await this.query({ json, binaryTags, tag, expect200: true, requiresPhoneConnection: true }) as Promise<{status: number}>
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
async query(q: WAQuery): Promise<any> {
|
|
||||||
let {json, binaryTags, tag, timeoutMs, expect200, waitForOpen, longTag, requiresPhoneConnection, startDebouncedTimeout, maxRetries} = q
|
|
||||||
requiresPhoneConnection = requiresPhoneConnection !== false
|
|
||||||
waitForOpen = waitForOpen !== false
|
|
||||||
let triesLeft = maxRetries || 2
|
|
||||||
tag = tag || this.generateMessageTag(longTag)
|
|
||||||
|
|
||||||
while (triesLeft >= 0) {
|
|
||||||
if (waitForOpen) await this.waitForConnection()
|
|
||||||
|
|
||||||
const promise = this.waitForMessage(tag, requiresPhoneConnection, timeoutMs)
|
|
||||||
|
|
||||||
if (this.logger.level === 'trace') {
|
|
||||||
this.logger.trace ({ fromMe: true },`${tag},${JSON.stringify(json)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag)
|
|
||||||
else tag = await this.sendJSON(json, tag)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await promise
|
|
||||||
if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) {
|
|
||||||
const message = STATUS_CODES[response.status] || 'unknown'
|
|
||||||
throw new BaileysError (
|
|
||||||
`Unexpected status in '${json[0] || 'query'}': ${STATUS_CODES[response.status]}(${response.status})`,
|
|
||||||
{query: json, message, status: response.status}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (startDebouncedTimeout) {
|
|
||||||
this.connectionDebounceTimeout.start()
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
if (triesLeft === 0) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
// read here: http://getstatuscode.com/599
|
|
||||||
if (error.status === 599) {
|
|
||||||
this.unexpectedDisconnect (DisconnectReason.badSession)
|
|
||||||
} else if (
|
|
||||||
(error.message === 'close' || error.message === 'lost') &&
|
|
||||||
waitForOpen &&
|
|
||||||
this.state !== 'close' &&
|
|
||||||
(this.pendingRequestTimeoutMs === null ||
|
|
||||||
this.pendingRequestTimeoutMs > 0)) {
|
|
||||||
// nothing here
|
|
||||||
} else throw error
|
|
||||||
|
|
||||||
triesLeft -= 1
|
|
||||||
this.logger.debug(`query failed due to ${error}, retrying...`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
protected exitQueryIfResponseNotExpected(tag: string, cancel: ({ reason, status }) => void) {
|
|
||||||
let timeout: NodeJS.Timeout
|
|
||||||
const listener = ({ connected }) => {
|
|
||||||
if(connected) {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
this.logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`)
|
|
||||||
cancel({ reason: 'Not expecting a response', status: 422 })
|
|
||||||
}, this.connectOptions.maxQueryResponseTime)
|
|
||||||
this.off('connection-phone-change', listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.on('connection-phone-change', listener)
|
|
||||||
return () => {
|
|
||||||
this.off('connection-phone-change', listener)
|
|
||||||
timeout && clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** interval is started when a query takes too long to respond */
|
|
||||||
protected startPhoneCheckInterval () {
|
|
||||||
this.phoneCheckListeners += 1
|
|
||||||
if (!this.phoneCheckInterval) {
|
|
||||||
// if its been a long time and we haven't heard back from WA, send a ping
|
|
||||||
this.phoneCheckInterval = setInterval (() => {
|
|
||||||
if (!this.conn) return // if disconnected, then don't do anything
|
|
||||||
|
|
||||||
this.logger.info('checking phone connection...')
|
|
||||||
this.sendAdminTest ()
|
|
||||||
if(this.phoneConnected !== false) {
|
|
||||||
this.phoneConnected = false
|
|
||||||
this.emit ('connection-phone-change', { connected: false })
|
|
||||||
}
|
|
||||||
}, this.connectOptions.phoneResponseTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
protected clearPhoneCheckInterval () {
|
|
||||||
this.phoneCheckListeners -= 1
|
|
||||||
if (this.phoneCheckListeners <= 0) {
|
|
||||||
this.phoneCheckInterval && clearInterval (this.phoneCheckInterval)
|
|
||||||
this.phoneCheckInterval = undefined
|
|
||||||
this.phoneCheckListeners = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
/** checks for phone connection */
|
|
||||||
protected async sendAdminTest () {
|
|
||||||
return this.sendJSON (['admin', 'test'])
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Send a binary encoded message
|
|
||||||
* @param json the message to encode & send
|
|
||||||
* @param tags the binary tags to tell WhatsApp what the message is all about
|
|
||||||
* @param tag the tag to attach to the message
|
|
||||||
* @return the message tag
|
|
||||||
*/
|
|
||||||
protected async sendBinary(json: WANode, tags: WATag, tag: string = null, longTag: boolean = false) {
|
|
||||||
const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format
|
|
||||||
|
|
||||||
let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey
|
|
||||||
const sign = Utils.hmacSign(buff, this.authInfo.macKey) // sign the message using HMAC and our macKey
|
|
||||||
tag = tag || this.generateMessageTag(longTag)
|
|
||||||
|
|
||||||
if (this.shouldLogMessages) this.messageLog.push ({ tag, json: JSON.stringify(json), fromMe: true, binaryTags: tags })
|
|
||||||
|
|
||||||
buff = Buffer.concat([
|
|
||||||
Buffer.from(tag + ','), // generate & prefix the message tag
|
|
||||||
Buffer.from(tags), // prefix some bytes that tell whatsapp what the message is about
|
|
||||||
sign, // the HMAC sign of the message
|
|
||||||
buff, // the actual encrypted buffer
|
|
||||||
])
|
|
||||||
await this.send(buff) // send it off
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Send a plain JSON message to the WhatsApp servers
|
|
||||||
* @param json the message to send
|
|
||||||
* @param tag the tag to attach to the message
|
|
||||||
* @returns the message tag
|
|
||||||
*/
|
|
||||||
protected async sendJSON(json: any[] | WANode, tag: string = null, longTag: boolean = false) {
|
|
||||||
tag = tag || this.generateMessageTag(longTag)
|
|
||||||
if (this.shouldLogMessages) this.messageLog.push ({ tag, json: JSON.stringify(json), fromMe: true })
|
|
||||||
await this.send(`${tag},${JSON.stringify(json)}`)
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
/** Send some message to the WhatsApp servers */
|
|
||||||
protected async send(m) {
|
|
||||||
this.conn.send(m)
|
|
||||||
}
|
|
||||||
protected async waitForConnection () {
|
|
||||||
if (this.state === 'open') return
|
|
||||||
|
|
||||||
let onOpen: () => void
|
|
||||||
let onClose: ({ reason }) => void
|
|
||||||
|
|
||||||
if (this.pendingRequestTimeoutMs !== null && this.pendingRequestTimeoutMs <= 0) {
|
|
||||||
throw new BaileysError(DisconnectReason.close, { status: 428 })
|
|
||||||
}
|
|
||||||
await (
|
|
||||||
Utils.promiseTimeout (
|
|
||||||
this.pendingRequestTimeoutMs,
|
|
||||||
(resolve, reject) => {
|
|
||||||
onClose = ({ reason }) => {
|
|
||||||
if (reason === DisconnectReason.invalidSession || reason === DisconnectReason.intentional) {
|
|
||||||
reject (new Error(reason))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onOpen = resolve
|
|
||||||
this.on ('close', onClose)
|
|
||||||
this.on ('open', onOpen)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.finally(() => {
|
|
||||||
this.off ('open', onOpen)
|
|
||||||
this.off ('close', onClose)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request.
|
|
||||||
* @see close() if you just want to close the connection
|
|
||||||
*/
|
|
||||||
async logout () {
|
|
||||||
this.authInfo = null
|
|
||||||
if (this.state === 'open') {
|
|
||||||
//throw new Error("You're not even connected, you can't log out")
|
|
||||||
await new Promise(resolve => this.conn.send('goodbye,["admin","Conn","disconnect"]', null, resolve))
|
|
||||||
}
|
|
||||||
this.user = undefined
|
|
||||||
this.chats.clear()
|
|
||||||
this.contacts = {}
|
|
||||||
this.close()
|
|
||||||
}
|
|
||||||
/** Close the connection to WhatsApp Web */
|
|
||||||
close () {
|
|
||||||
this.closeInternal (DisconnectReason.intentional)
|
|
||||||
}
|
|
||||||
protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean=false) {
|
|
||||||
this.logger.info (`closed connection, reason ${reason}${isReconnecting ? ', reconnecting in a few seconds...' : ''}`)
|
|
||||||
|
|
||||||
this.state = 'close'
|
|
||||||
this.phoneConnected = false
|
|
||||||
this.lastDisconnectReason = reason
|
|
||||||
this.lastDisconnectTime = new Date ()
|
|
||||||
|
|
||||||
this.endConnection(reason)
|
|
||||||
// reconnecting if the timeout is active for the reconnect loop
|
|
||||||
this.emit ('close', { reason, isReconnecting })
|
|
||||||
}
|
|
||||||
protected endConnection (reason: DisconnectReason) {
|
|
||||||
this.conn?.removeAllListeners ('close')
|
|
||||||
this.conn?.removeAllListeners ('error')
|
|
||||||
this.conn?.removeAllListeners ('open')
|
|
||||||
this.conn?.removeAllListeners ('message')
|
|
||||||
|
|
||||||
this.initTimeout && clearTimeout (this.initTimeout)
|
|
||||||
this.connectionDebounceTimeout.cancel()
|
|
||||||
this.messagesDebounceTimeout.cancel()
|
|
||||||
this.chatsDebounceTimeout.cancel()
|
|
||||||
this.keepAliveReq && clearInterval(this.keepAliveReq)
|
|
||||||
this.phoneCheckListeners = 0
|
|
||||||
this.clearPhoneCheckInterval ()
|
|
||||||
|
|
||||||
this.emit ('ws-close', { reason })
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.conn?.close()
|
|
||||||
//this.conn?.terminate()
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
}
|
|
||||||
this.conn = undefined
|
|
||||||
this.lastSeen = undefined
|
|
||||||
this.msgCount = 0
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Does a fetch request with the configuration of the connection
|
|
||||||
*/
|
|
||||||
protected fetchRequest = (
|
|
||||||
endpoint: string,
|
|
||||||
method: Method = 'GET',
|
|
||||||
body?: any,
|
|
||||||
agent?: Agent,
|
|
||||||
headers?: {[k: string]: string},
|
|
||||||
followRedirect = true
|
|
||||||
) => (
|
|
||||||
got(endpoint, {
|
|
||||||
method,
|
|
||||||
body,
|
|
||||||
followRedirect,
|
|
||||||
headers: { Origin: DEFAULT_ORIGIN, ...(headers || {}) },
|
|
||||||
agent: { https: agent || this.connectOptions.fetchAgent }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
generateMessageTag (longTag: boolean = false) {
|
|
||||||
const seconds = Utils.unixTimestampSeconds(this.referenceDate)
|
|
||||||
const tag = `${longTag ? seconds : (seconds%1000)}.--${this.msgCount}`
|
|
||||||
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import * as Curve from 'curve25519-js'
|
|
||||||
import * as Utils from './Utils'
|
|
||||||
import {WAConnection as Base} from './0.Base'
|
|
||||||
import { WAMetric, WAFlag, BaileysError, Presence, WAUser, WAInitResponse, WAOpenResult } from './Constants'
|
|
||||||
|
|
||||||
export class WAConnection extends Base {
|
|
||||||
|
|
||||||
/** Authenticate the connection */
|
|
||||||
protected async authenticate (reconnect?: string) {
|
|
||||||
// if no auth info is present, that is, a new session has to be established
|
|
||||||
// generate a client ID
|
|
||||||
if (!this.authInfo?.clientID) {
|
|
||||||
this.authInfo = { clientID: Utils.generateClientID() } as any
|
|
||||||
}
|
|
||||||
const canLogin = this.canLogin()
|
|
||||||
this.referenceDate = new Date () // refresh reference date
|
|
||||||
|
|
||||||
this.connectionDebounceTimeout.start()
|
|
||||||
|
|
||||||
const initQuery = (async () => {
|
|
||||||
const {ref, ttl} = await this.query({
|
|
||||||
json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true],
|
|
||||||
expect200: true,
|
|
||||||
waitForOpen: false,
|
|
||||||
longTag: true,
|
|
||||||
requiresPhoneConnection: false,
|
|
||||||
startDebouncedTimeout: true
|
|
||||||
}) as WAInitResponse
|
|
||||||
|
|
||||||
if (!canLogin) {
|
|
||||||
this.connectionDebounceTimeout.cancel() // stop the debounced timeout for QR gen
|
|
||||||
this.generateKeysForAuth (ref, ttl)
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
let loginTag: string
|
|
||||||
if (canLogin) {
|
|
||||||
// if we have the info to restore a closed session
|
|
||||||
const json = [
|
|
||||||
'admin',
|
|
||||||
'login',
|
|
||||||
this.authInfo?.clientToken,
|
|
||||||
this.authInfo?.serverToken,
|
|
||||||
this.authInfo?.clientID,
|
|
||||||
]
|
|
||||||
loginTag = this.generateMessageTag(true)
|
|
||||||
|
|
||||||
if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')])
|
|
||||||
else json.push ('takeover')
|
|
||||||
// send login every 10s
|
|
||||||
const sendLoginReq = () => {
|
|
||||||
if (!this.conn || this.conn?.readyState !== this.conn.OPEN) {
|
|
||||||
this.logger.warn('Received login timeout req when WS not open, ignoring...')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.state === 'open') {
|
|
||||||
this.logger.warn('Received login timeout req when state=open, ignoring...')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.logger.debug('sending login request')
|
|
||||||
this.sendJSON(json, loginTag)
|
|
||||||
this.initTimeout = setTimeout(sendLoginReq, 10_000)
|
|
||||||
}
|
|
||||||
sendLoginReq()
|
|
||||||
}
|
|
||||||
|
|
||||||
await initQuery
|
|
||||||
|
|
||||||
// wait for response with tag "s1"
|
|
||||||
let response = await Promise.race(
|
|
||||||
[
|
|
||||||
this.waitForMessage('s1', false, undefined),
|
|
||||||
loginTag && this.waitForMessage(loginTag, false, undefined)
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
this.connectionDebounceTimeout.start()
|
|
||||||
this.initTimeout && clearTimeout (this.initTimeout)
|
|
||||||
this.initTimeout = null
|
|
||||||
|
|
||||||
if (response.status && response.status !== 200) {
|
|
||||||
throw new BaileysError(`Unexpected error in login`, { response, status: response.status })
|
|
||||||
}
|
|
||||||
// if its a challenge request (we get it when logging in)
|
|
||||||
if (response[1]?.challenge) {
|
|
||||||
await this.respondToChallenge(response[1].challenge)
|
|
||||||
response = await this.waitForMessage('s2', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = this.validateNewConnection(response[1])// validate the connection
|
|
||||||
if (result.user.jid !== this.user?.jid) {
|
|
||||||
result.isNewUser = true
|
|
||||||
// clear out old data
|
|
||||||
this.chats.clear()
|
|
||||||
this.contacts = {}
|
|
||||||
}
|
|
||||||
this.user = result.user
|
|
||||||
|
|
||||||
this.logger.info('validated connection successfully')
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Refresh QR Code
|
|
||||||
* @returns the new ref
|
|
||||||
*/
|
|
||||||
async requestNewQRCodeRef() {
|
|
||||||
const response = await this.query({
|
|
||||||
json: ['admin', 'Conn', 'reref'],
|
|
||||||
expect200: true,
|
|
||||||
waitForOpen: false,
|
|
||||||
longTag: true,
|
|
||||||
requiresPhoneConnection: false
|
|
||||||
})
|
|
||||||
return response as WAInitResponse
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
|
|
||||||
* @private
|
|
||||||
* @param {object} json
|
|
||||||
*/
|
|
||||||
private validateNewConnection(json) {
|
|
||||||
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
|
||||||
const onValidationSuccess = () => ({
|
|
||||||
user: {
|
|
||||||
jid: Utils.whatsappID(json.wid),
|
|
||||||
name: json.pushname,
|
|
||||||
phone: json.phone,
|
|
||||||
imgUrl: null
|
|
||||||
},
|
|
||||||
auth: this.authInfo
|
|
||||||
}) as WAOpenResult
|
|
||||||
|
|
||||||
if (!json.secret) {
|
|
||||||
// if we didn't get a secret, we don't need it, we're validated
|
|
||||||
if (json.clientToken && json.clientToken !== this.authInfo.clientToken) {
|
|
||||||
this.authInfo = { ...this.authInfo, clientToken: json.clientToken }
|
|
||||||
}
|
|
||||||
if (json.serverToken && json.serverToken !== this.authInfo.serverToken) {
|
|
||||||
this.authInfo = { ...this.authInfo, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate shared key from our private key & the secret shared by the server
|
|
||||||
const sharedKey = Curve.sharedKey(this.curveKeys.private, secret.slice(0, 32))
|
|
||||||
// expand the key to 80 bytes using HKDF
|
|
||||||
const expandedKey = Utils.hkdf(sharedKey as Buffer, 80)
|
|
||||||
|
|
||||||
// perform HMAC validation.
|
|
||||||
const hmacValidationKey = expandedKey.slice(32, 64)
|
|
||||||
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
|
|
||||||
|
|
||||||
const hmac = Utils.hmacSign(hmacValidationMessage, hmacValidationKey)
|
|
||||||
|
|
||||||
if (!hmac.equals(secret.slice(32, 64))) {
|
|
||||||
// if the checksums didn't match
|
|
||||||
throw new BaileysError ('HMAC validation failed', json)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = Utils.aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
|
|
||||||
// set the credentials
|
|
||||||
this.authInfo = {
|
|
||||||
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: this.authInfo.clientID,
|
|
||||||
}
|
|
||||||
return onValidationSuccess()
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* When logging back in (restoring a previously closed session), WhatsApp may challenge one to check if one still has the encryption keys
|
|
||||||
* WhatsApp does that by asking for us to sign a string it sends with our macKey
|
|
||||||
*/
|
|
||||||
protected respondToChallenge(challenge: string) {
|
|
||||||
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
|
|
||||||
const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey
|
|
||||||
const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
|
|
||||||
|
|
||||||
this.logger.info('resolving login challenge')
|
|
||||||
return this.query({json, expect200: true, waitForOpen: false, startDebouncedTimeout: true})
|
|
||||||
}
|
|
||||||
/** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */
|
|
||||||
protected generateKeysForAuth(ref: string, ttl?: number) {
|
|
||||||
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
|
|
||||||
const publicKey = Buffer.from(this.curveKeys.public).toString('base64')
|
|
||||||
|
|
||||||
const qrLoop = ttl => {
|
|
||||||
const qr = [ref, publicKey, this.authInfo.clientID].join(',')
|
|
||||||
this.emit ('qr', qr)
|
|
||||||
|
|
||||||
this.initTimeout = setTimeout (async () => {
|
|
||||||
if (this.state === 'open') return
|
|
||||||
|
|
||||||
this.logger.debug ('regenerating QR')
|
|
||||||
try {
|
|
||||||
const {ref: newRef, ttl: newTTL} = await this.requestNewQRCodeRef()
|
|
||||||
ttl = newTTL
|
|
||||||
ref = newRef
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn ({ error }, `error in QR gen`)
|
|
||||||
// @ts-ignore
|
|
||||||
if (error.status === 429 && this.state !== 'open') { // too many QR requests
|
|
||||||
this.endConnection(error.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
qrLoop (ttl)
|
|
||||||
}, ttl || 20_000) // default is 20s, on the off-chance ttl is not present
|
|
||||||
}
|
|
||||||
qrLoop (ttl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user