Merge pull request #696 from adiwajshing/multi-device

Multi Device Support
This commit is contained in:
Adhiraj Singh
2022-01-22 11:40:12 +05:30
committed by GitHub
113 changed files with 32419 additions and 12793 deletions

6
.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
# Ignore artifacts:
lib
coverage
*.lock
.eslintrc.json
src/WABinary/index.ts

View File

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

@@ -0,0 +1,3 @@
{
"extends": "@adiwajshing"
}

5
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules
auth_info*.json
baileys_store*.json
output.csv
*/.DS_Store
.DS_Store
@@ -10,4 +11,6 @@ lib
docs
browser-token.json
Proxy
messages*.json
messages*.json
test.ts
TestData

View File

@@ -1,7 +0,0 @@
module.exports = {
semi: false,
trailingComma: "all",
singleQuote: true,
printWidth: 120,
tabWidth: 4
}

View File

@@ -1,97 +0,0 @@
const WhatsAppWeb = require("../WhatsAppWeb")
const fs = require("fs")
/**
* Extract all your WhatsApp conversations & save them to a file
* produceAnonData => should the Id of the chat be recorded
* */
function extractChats (authCreds, outputFile, produceAnonData=false, offset=null) {
let client = new WhatsAppWeb() // instantiate an instance
// internal extract function
const extract = function () {
let rows = 0
let chats = Object.keys(client.chats)
let encounteredOffset
if (offset) {
encounteredOffset = false
} else {
encounteredOffset = true
fs.writeFileSync(outputFile, "chat,input,output\n") // write header to file
}
const extractChat = function (index) {
const id = chats[index]
if (id.includes("g.us") || !encounteredOffset) { // skip groups
if (id === offset) {
encounteredOffset = true
}
if (index+1 < chats.length) {
return extractChat(index+1)
}
return
}
console.log("extracting for " + id + "...")
var curInput = ""
var curOutput = ""
var lastMessage
return client.loadEntireConversation (id, m => {
var text
if (!m.message) { // if message not present, return
return
} else if (m.message.conversation) { // if its a plain text message
text = m.message.conversation
} else if (m.message.extendedTextMessage && m.message.extendedTextMessage.contextInfo) { // if its a reply to a previous message
const mText = m.message.extendedTextMessage.text
const quotedMessage = m.message.extendedTextMessage.contextInfo.quotedMessage
// if it's like a '.' and the quoted message has no text, then just forget it
if (mText.length <= 2 && !quotedMessage.conversation) {
return
}
// if somebody sent like a '.', then the text should be the quoted message
if (mText.length <= 2) {
text = quotedMessage.conversation
} else { // otherwise just use this text
text = mText
}
} else {
return
}
// if the person who sent the message has switched, flush the row
if (lastMessage && !m.key.fromMe && lastMessage.key.fromMe) {
let row = "" + (produceAnonData ? "" : id) + ",\"" + curInput + "\",\"" + curOutput + "\"\n"
fs.appendFileSync (outputFile, row)
rows += 1
curInput = ""
curOutput = ""
}
if (m.key.fromMe) {
curOutput += curOutput === "" ? text : ("\n"+text)
} else {
curInput += curInput === "" ? text : ("\n"+text)
}
lastMessage = m
}, 50, false) // load from the start, in chunks of 50
.then (() => console.log("finished extraction for " + id))
.then (() => {
if (index+1 < chats.length) {
return extractChat(index+1)
}
})
}
extractChat(0)
.then (() => {
console.log("extracted all; total " + rows + " rows")
client.logout ()
})
}
client.connect (authCreds)
.then (() => extract())
.catch (err => console.log("got error: " + error))
}
let creds = null//JSON.parse(fs.readFileSync("auth_info.json"))
extractChats(creds, "output.csv")

76
Example/example-legacy.ts Normal file
View 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()

View File

@@ -1,153 +1,86 @@
import {
WAConnection,
MessageType,
Presence,
MessageOptions,
Mimetype,
WALocationMessage,
WA_MESSAGE_STUB_TYPES,
ReconnectMode,
ProxyAgent,
waChatKey,
} from '../src/WAConnection'
import * as fs from 'fs'
import { Boom } from '@hapi/boom'
import P from 'pino'
import makeWASocket, { AnyMessageContent, delay, DisconnectReason, makeInMemoryStore, useSingleFileAuthState } from '../src'
async function example() {
const conn = new WAConnection() // instantiate
conn.autoReconnect = ReconnectMode.onConnectionLost // only automatically reconnect when the connection breaks
conn.logger.level = 'debug' // set to 'debug' to see what kind of stuff you can implement
// attempt to reconnect at most 10 times in a row
conn.connectOptions.maxRetries = 10
conn.chatOrderingKey = waChatKey(true) // order chats such that pinned chats are on top
conn.on('chats-received', ({ hasNewChats }) => {
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')
})
// 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_multi.json')
// save every 10s
setInterval(() => {
store.writeToFile('./baileys_store_multi.json')
}, 10_000)
// loads the auth file credentials if present
/* 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
const { state, saveState } = useSingleFileAuthState('./auth_info_multi.json')
console.log('oh hello ' + conn.user.name + ' (' + conn.user.jid + ')')
// uncomment to load all unread messages
//const unread = await conn.loadAllUnreadMessages ()
//console.log ('you have ' + unread.length + ' unread messages')
// start a connection
const startSock = () => {
const sock = makeWASocket({
logger: P({ level: 'trace' }),
printQRInTerminal: true,
auth: state,
// implement to handle retries
getMessage: async key => {
return {
conversation: 'hello'
}
}
})
/**
* The universal event for anything that happens
* New messages, updated messages, read & delivered messages, participants typing etc.
*/
conn.on('chat-update', async chat => {
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}`))
}
if(chat.imgUrl) {
console.log('imgUrl of chat changed ', chat.imgUrl)
return
}
// only do something when a new message is received
if (!chat.hasNewMessage) {
if(chat.messages) {
console.log('updated message: ', chat.messages.first)
}
return
}
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('chats.set', item => console.log(`recv ${item.chats.length} chats (is latest: ${item.isLatest})`))
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`))
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 messageStubType = WA_MESSAGE_STUB_TYPES[m.messageStubType] || 'MESSAGE'
console.log('got notification of type: ' + messageStubType)
const messageContent = m.message
// if it is not a regular text or media message
if (!messageContent) return
const msg = m.messages[0]
if(!msg.key.fromMe && m.type === 'notify') {
console.log('replying to', m.messages[0].key.remoteJid)
await sock!.sendReadReceipt(msg.key.remoteJid, msg.key.participant, [msg.key.id])
await sendMessageWTyping({ text: 'Hello there!' }, msg.key.remoteJid)
}
if (m.key.fromMe) {
console.log('relayed my own message')
return
}
})
let sender = m.key.remoteJid
if (m.key.participant) {
// participant exists if the message is in a group
sender += ' (' + m.key.participant + ')'
}
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
sock.ev.on('messages.update', m => console.log(m))
sock.ev.on('message-receipt.update', m => console.log(m))
sock.ev.on('presence.update', m => console.log(m))
sock.ev.on('chats.update', m => console.log(m))
sock.ev.on('contacts.upsert', m => console.log(m))
if (messageType === MessageType.liveLocation) {
console.log(`${sender} sent live location for duration: ${m.duration/60}`)
}
} else {
// if it is a media (audio, image, video, sticker) message
// decode, decrypt & save the media.
// The extension to the is applied automatically based on the media type
try {
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)
}
}
// send a reply after 3 seconds
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
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)
const options: MessageOptions = { quoted: m }
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)
))
return sock
}
example().catch((err) => console.log(`encountered error: ${err}`))
startSock()

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Adhiraj Singh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

818
README.md

File diff suppressed because it is too large Load Diff

587
WABinary/Binary.js Normal file
View 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

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,15 @@
# WABinary
Contains the raw JS code to parse WA binary messages. WA uses a tree like structure to encode information, the type for which is written below:
``` ts
export type BinaryNode = {
tag: string
attrs: Attributes
content?: BinaryNode[] | string | Uint8Array
}
```
Do note, the multi-device binary format is very similar to the one on WA Web, though they are not backwards compatible.
Originally from [pokearaujo/multidevice](https://github.com/pokearaujo/multidevice)

View File

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

View File

@@ -36,7 +36,10 @@ message UserAgent {
IGLITE_ANDROID = 22;
PAGE = 23;
MACOS = 24;
VR = 25;
OCULUS_MSG = 25;
OCULUS_CALL = 26;
MILAN = 27;
CAPI = 28;
}
optional UserAgentPlatform platform = 1;
optional AppVersion appVersion = 2;
@@ -113,21 +116,11 @@ message CompanionRegData {
message ClientPayload {
optional uint64 username = 1;
optional bool passive = 3;
enum ClientPayloadClientFeature {
NONE = 0;
}
repeated ClientPayloadClientFeature clientFeatures = 4;
optional UserAgent userAgent = 5;
optional WebInfo webInfo = 6;
optional string pushName = 7;
optional sfixed32 sessionId = 9;
optional bool shortConnect = 10;
enum ClientPayloadIOSAppExtension {
SHARE_EXTENSION = 0;
SERVICE_EXTENSION = 1;
INTENTS_EXTENSION = 2;
}
optional ClientPayloadIOSAppExtension iosAppExtension = 30;
enum ClientPayloadConnectType {
CELLULAR_UNKNOWN = 0;
WIFI_UNKNOWN = 1;
@@ -169,15 +162,24 @@ message ClientPayload {
optional bytes fbCat = 21;
optional bytes fbUserAgent = 22;
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 {
// optional uint32 serial = 1;
// optional string issuer = 2;
// optional uint64 expires = 3;
// optional string subject = 4;
// optional bytes key = 5;
//}
message Details {
optional uint32 serial = 1;
optional string issuer = 2;
optional uint64 expires = 3;
optional string subject = 4;
optional bytes key = 5;
}
message NoiseCertificate {
optional bytes details = 1;
@@ -226,8 +228,9 @@ message BizIdentityInfo {
SELF = 0;
BSP = 1;
}
optional BizIdentityInfoActualActorsType actualActors = 6;
optional BizIdentityInfoActualActorsType actualActors = 6;
optional uint64 privacyModeTs = 7;
optional uint64 featureControls = 8;
}
message BizAccountLinkInfo {
@@ -241,7 +244,6 @@ message BizAccountLinkInfo {
optional BizAccountLinkInfoHostStorageType hostStorage = 4;
enum BizAccountLinkInfoAccountType {
ENTERPRISE = 0;
PAGE = 1;
}
optional BizAccountLinkInfoAccountType accountType = 5;
}
@@ -251,14 +253,6 @@ message BizAccountPayload {
optional bytes bizAcctLinkInfo = 2;
}
//message Details {
// optional uint64 serial = 1;
// optional string issuer = 2;
// optional string verifiedName = 4;
// repeated LocalizedName localizedNames = 8;
// optional uint64 issueTime = 10;
//}
message VerifiedNameCertificate {
optional bytes details = 1;
optional bytes signature = 2;
@@ -345,6 +339,17 @@ message RecentEmojiWeightsAction {
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 {
optional bool archived = 1;
optional SyncActionMessageRange messageRange = 2;
@@ -387,6 +392,14 @@ message KeyExpiration {
optional int32 expiredKeyEpoch = 1;
}
message PrimaryFeature {
repeated string flags = 1;
}
message AndroidUnsupportedActions {
optional bool allowed = 1;
}
message SyncActionValue {
optional int64 timestamp = 1;
optional StarAction starAction = 2;
@@ -409,6 +422,9 @@ message SyncActionValue {
optional ClearChatAction clearChatAction = 21;
optional DeleteChatAction deleteChatAction = 22;
optional UnarchiveChatsSetting unarchiveChatsSetting = 23;
optional PrimaryFeature primaryFeature = 24;
optional FavoriteStickerAction favoriteStickerAction = 25;
optional AndroidUnsupportedActions androidUnsupportedActions = 26;
}
message RecentEmojiWeight {
@@ -507,8 +523,6 @@ message MediaRetryNotification {
message MsgOpaqueData {
optional string body = 1;
optional string caption = 3;
optional string clientUrl = 4;
// optional string loc = 4;
optional double lng = 5;
optional double lat = 7;
optional int32 paymentAmount1000 = 8;
@@ -517,6 +531,9 @@ message MsgOpaqueData {
optional string matchedText = 11;
optional string title = 12;
optional string description = 13;
optional bytes futureproofBuffer = 14;
optional string clientUrl = 15;
optional string loc = 16;
}
message MsgRowOpaqueData {
@@ -524,6 +541,27 @@ message MsgRowOpaqueData {
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 {
optional string id = 1;
optional string pushname = 2;
@@ -554,6 +592,20 @@ message Conversation {
optional string name = 13;
optional string pHash = 14;
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 {
@@ -570,91 +622,21 @@ message HistorySync {
optional uint32 chunkOrder = 5;
optional uint32 progress = 6;
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 {
optional sfixed32 duration = 1;
optional sfixed64 timestamp = 2;
}
message PaymentBackground {
optional string id = 1;
optional string fileLength = 2;
optional uint32 width = 3;
optional uint32 height = 4;
optional string mimetype = 5;
optional fixed32 placeholderArgb = 6;
optional fixed32 textArgb = 7;
optional fixed32 subtextArgb = 8;
}
message Money {
optional int64 value = 1;
optional uint32 offset = 2;
optional string currencyCode = 3;
}
message HydratedQuickReplyButton {
optional string displayText = 1;
optional string id = 2;
}
message HydratedURLButton {
optional string displayText = 1;
optional string url = 2;
}
message HydratedCallButton {
optional string displayText = 1;
optional string phoneNumber = 2;
}
message HydratedTemplateButton {
optional uint32 index = 4;
oneof hydratedButton {
HydratedQuickReplyButton quickReplyButton = 1;
HydratedURLButton urlButton = 2;
HydratedCallButton callButton = 3;
}
}
message QuickReplyButton {
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 {
repeated Point polygonVertices = 1;
oneof action {
@@ -725,6 +707,10 @@ message ContextInfo {
optional string entryPointConversionSource = 29;
optional string entryPointConversionApp = 30;
optional uint32 entryPointConversionDelaySeconds = 31;
optional DisappearingMode disappearingMode = 32;
optional ActionLink actionLink = 33;
optional string groupSubject = 34;
optional string parentGroupJid = 35;
}
message SenderKeyDistributionMessage {
@@ -758,6 +744,7 @@ message ImageMessage {
optional string thumbnailDirectPath = 26;
optional bytes thumbnailSha256 = 27;
optional bytes thumbnailEncSha256 = 28;
optional string staticUrl = 29;
}
message InvoiceMessage {
@@ -830,6 +817,11 @@ message ExtendedTextMessage {
optional int64 mediaKeyTimestamp = 23;
optional uint32 thumbnailHeight = 24;
optional uint32 thumbnailWidth = 25;
enum ExtendedTextMessageInviteLinkGroupType {
DEFAULT = 0;
PARENT = 1;
}
optional ExtendedTextMessageInviteLinkGroupType inviteLinkGroupType = 26;
}
message DocumentMessage {
@@ -867,6 +859,7 @@ message AudioMessage {
optional int64 mediaKeyTimestamp = 10;
optional ContextInfo contextInfo = 17;
optional bytes streamingSidecar = 18;
optional bytes waveform = 19;
}
message VideoMessage {
@@ -897,6 +890,7 @@ message VideoMessage {
optional string thumbnailDirectPath = 21;
optional bytes thumbnailSha256 = 22;
optional bytes thumbnailEncSha256 = 23;
optional string staticUrl = 24;
}
message Call {
@@ -932,6 +926,7 @@ message ProtocolMessage {
optional AppStateSyncKeyRequest appStateSyncKeyRequest = 8;
optional InitialSecurityNotificationSettingSync initialSecurityNotificationSettingSync = 9;
optional AppStateFatalExceptionNotification appStateFatalExceptionNotification = 10;
optional DisappearingMode disappearingMode = 11;
}
message HistorySyncNotification {
@@ -1188,6 +1183,8 @@ message ProductMessage {
optional ProductSnapshot product = 1;
optional string businessOwnerJid = 2;
optional CatalogSnapshot catalog = 4;
optional string body = 5;
optional string footer = 6;
optional ContextInfo contextInfo = 17;
}
@@ -1275,6 +1272,67 @@ message ListResponseMessage {
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 {
optional string groupJid = 1;
optional string inviteCode = 2;
@@ -1283,6 +1341,11 @@ message GroupInviteMessage {
optional bytes jpegThumbnail = 5;
optional string caption = 6;
optional ContextInfo contextInfo = 7;
enum GroupInviteMessageGroupType {
DEFAULT = 0;
PARENT = 1;
}
optional GroupInviteMessageGroupType groupType = 8;
}
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 {
optional string conversation = 1;
optional SenderKeyDistributionMessage senderKeyDistributionMessage = 2;
@@ -1390,6 +1466,115 @@ message Message {
optional ButtonsMessage buttonsMessage = 42;
optional ButtonsResponseMessage buttonsResponseMessage = 43;
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 {
@@ -1451,16 +1636,29 @@ message MessageKey {
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 {
optional bytes oldPhoto = 1;
optional bytes newPhoto = 2;
optional uint32 newPhotoId = 3;
}
message MediaData {
optional string localPath = 1;
}
message WebFeatures {
enum WebFeaturesFlag {
NOT_STARTED = 0;
@@ -1510,6 +1708,9 @@ message WebFeatures {
optional WebFeaturesFlag ephemeralAllowGroupMembers = 44;
optional WebFeaturesFlag ephemeral24HDuration = 45;
optional WebFeaturesFlag mdForceUpgrade = 46;
optional WebFeaturesFlag disappearingMode = 47;
optional WebFeaturesFlag externalMdOptInAvailable = 48;
optional WebFeaturesFlag noDeleteMessageTimeLimit = 49;
}
message NotificationMessageInfo {
@@ -1745,6 +1946,8 @@ message WebMessageInfo {
BIZ_PRIVACY_MODE_INIT_BSP = 127;
BIZ_PRIVACY_MODE_TO_FB = 128;
BIZ_PRIVACY_MODE_TO_BSP = 129;
DISAPPEARING_MODE = 130;
E2E_DEVICE_FETCH_FAILED = 131;
}
optional WebMessageInfoStubType messageStubType = 24;
optional bool clearMedia = 25;
@@ -1768,4 +1971,11 @@ message WebMessageInfo {
optional string verifiedBizName = 37;
optional MediaData mediaData = 38;
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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
class CiphertextMessage {
UNSUPPORTED_VERSION = 1;
CURRENT_VERSION = 3;
WHISPER_TYPE = 2;
PREKEY_TYPE = 3;
SENDERKEY_TYPE = 4;
SENDERKEY_DISTRIBUTION_TYPE = 5;
ENCRYPTED_MESSAGE_OVERHEAD = 53;
}
module.exports = CiphertextMessage;

View File

@@ -0,0 +1 @@
yarn pbjs -t static-module -w commonjs -o ./WASignalGroup/GroupProtocol.js ./WASignalGroup/group.proto

42
WASignalGroup/group.proto Normal file
View 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;
}

View File

@@ -0,0 +1,106 @@
const SenderKeyMessage = require('./sender_key_message');
const crypto = require('libsignal/src/crypto');
class GroupCipher {
constructor(senderKeyStore, senderKeyName) {
this.senderKeyStore = senderKeyStore;
this.senderKeyName = senderKeyName;
}
async encrypt(paddedPlaintext) {
try {
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName);
const senderKeyState = record.getSenderKeyState();
const senderKey = senderKeyState.getSenderChainKey().getSenderMessageKey();
const ciphertext = await this.getCipherText(
senderKey.getIv(),
senderKey.getCipherKey(),
paddedPlaintext
);
const senderKeyMessage = new SenderKeyMessage(
senderKeyState.getKeyId(),
senderKey.getIteration(),
ciphertext,
senderKeyState.getSigningKeyPrivate()
);
senderKeyState.setSenderChainKey(senderKeyState.getSenderChainKey().getNext());
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record);
return senderKeyMessage.serialize();
} catch (e) {
//console.log(e.stack);
throw new Error('NoSessionException');
}
}
async decrypt(senderKeyMessageBytes) {
const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName);
if (!record) throw new Error(`No sender key for: ${this.senderKeyName}`);
const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes);
const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId());
//senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic());
const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration());
// senderKeyState.senderKeyStateStructure.senderSigningKey.private =
const plaintext = await this.getPlainText(
senderKey.getIv(),
senderKey.getCipherKey(),
senderKeyMessage.getCipherText()
);
await this.senderKeyStore.storeSenderKey(this.senderKeyName, record);
return plaintext;
}
getSenderKey(senderKeyState, iteration) {
let senderChainKey = senderKeyState.getSenderChainKey();
if (senderChainKey.getIteration() > iteration) {
if (senderKeyState.hasSenderMessageKey(iteration)) {
return senderKeyState.removeSenderMessageKey(iteration);
}
throw new Error(
`Received message with old counter: ${senderChainKey.getIteration()}, ${iteration}`
);
}
if (senderChainKey.getIteration() - iteration > 2000) {
throw new Error('Over 2000 messages into the future!');
}
while (senderChainKey.getIteration() < iteration) {
senderKeyState.addSenderMessageKey(senderChainKey.getSenderMessageKey());
senderChainKey = senderChainKey.getNext();
}
senderKeyState.setSenderChainKey(senderChainKey.getNext());
return senderChainKey.getSenderMessageKey();
}
getPlainText(iv, key, ciphertext) {
try {
const plaintext = crypto.decrypt(key, ciphertext, iv);
return plaintext;
} catch (e) {
//console.log(e.stack);
throw new Error('InvalidMessageException');
}
}
getCipherText(iv, key, plaintext) {
try {
iv = typeof iv === 'string' ? Buffer.from(iv, 'base64') : iv;
key = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
const crypted = crypto.encrypt(key, Buffer.from(plaintext), iv);
return crypted;
} catch (e) {
//console.log(e.stack);
throw new Error('InvalidMessageException');
}
}
}
module.exports = GroupCipher;

View File

@@ -0,0 +1,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
View File

@@ -0,0 +1,5 @@
module.exports.GroupSessionBuilder = require('./group_session_builder')
module.exports.SenderKeyDistributionMessage = require('./sender_key_distribution_message')
module.exports.SenderKeyRecord = require('./sender_key_record')
module.exports.SenderKeyName = require('./sender_key_name')
module.exports.GroupCipher = require('./group_cipher')

View File

@@ -0,0 +1,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,
};
}

View File

@@ -0,0 +1,3 @@
const { groupproto } = require('./GroupProtocol')
module.exports = groupproto

6
WASignalGroup/readme.md Normal file
View File

@@ -0,0 +1,6 @@
# Signal-Group
This contains the code to decrypt/encrypt WA group messages.
Originally from [pokearaujo/libsignal-node](https://github.com/pokearaujo/libsignal-node)
The code has been moved outside the signal package as I felt it didn't belong in ths signal package, as it isn't inherently a part of signal but of WA.

View File

@@ -0,0 +1,50 @@
const SenderMessageKey = require('./sender_message_key');
//const HKDF = require('./hkdf');
const crypto = require('libsignal/src/crypto');
class SenderChainKey {
MESSAGE_KEY_SEED = Buffer.from([0x01]);
CHAIN_KEY_SEED = Buffer.from([0x02]);
iteration = 0;
chainKey = Buffer.alloc(0);
constructor(iteration, chainKey) {
this.iteration = iteration;
this.chainKey = chainKey;
}
getIteration() {
return this.iteration;
}
getSenderMessageKey() {
return new SenderMessageKey(
this.iteration,
this.getDerivative(this.MESSAGE_KEY_SEED, this.chainKey)
);
}
getNext() {
return new SenderChainKey(
this.iteration + 1,
this.getDerivative(this.CHAIN_KEY_SEED, this.chainKey)
);
}
getSeed() {
return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey;
}
getDerivative(seed, key) {
key = typeof key === 'string' ? Buffer.from(key, 'base64') : key;
const hash = crypto.calculateMAC(key, seed);
//const hash = new Hash().hmac_hash(key, seed, 'sha256', '');
return hash;
}
}
module.exports = SenderChainKey;

View File

@@ -0,0 +1,78 @@
const CiphertextMessage = require('./ciphertext_message');
const protobufs = require('./protobufs');
class SenderKeyDistributionMessage extends CiphertextMessage {
constructor(
id = null,
iteration = null,
chainKey = null,
signatureKey = null,
serialized = null
) {
super();
if (serialized) {
try {
const version = serialized[0];
const message = serialized.slice(1);
const distributionMessage = protobufs.SenderKeyDistributionMessage.decode(
message
).toJSON();
this.serialized = serialized;
this.id = distributionMessage.id;
this.iteration = distributionMessage.iteration;
this.chainKey = distributionMessage.chainKey;
this.signatureKey = distributionMessage.signingKey;
} catch (e) {
throw new Error(e);
}
} else {
const version = this.intsToByteHighAndLow(this.CURRENT_VERSION, this.CURRENT_VERSION);
this.id = id;
this.iteration = iteration;
this.chainKey = chainKey;
this.signatureKey = signatureKey;
const message = protobufs.SenderKeyDistributionMessage.encode(
protobufs.SenderKeyDistributionMessage.create({
id,
iteration,
chainKey,
signingKey: this.signatureKey,
})
).finish();
this.serialized = Buffer.concat([Buffer.from([version]), message]);
}
}
intsToByteHighAndLow(highValue, lowValue) {
return (((highValue << 4) | lowValue) & 0xff) % 256;
}
serialize() {
return this.serialized;
}
getType() {
return this.SENDERKEY_DISTRIBUTION_TYPE;
}
getIteration() {
return this.iteration;
}
getChainKey() {
return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey;
}
getSignatureKey() {
return typeof this.signatureKey === 'string'
? Buffer.from(this.signatureKey, 'base64')
: this.signatureKey;
}
getId() {
return this.id;
}
}
module.exports = SenderKeyDistributionMessage;

View File

@@ -0,0 +1,92 @@
const CiphertextMessage = require('./ciphertext_message');
const curve = require('libsignal/src/curve');
const protobufs = require('./protobufs');
class SenderKeyMessage extends CiphertextMessage {
SIGNATURE_LENGTH = 64;
constructor(
keyId = null,
iteration = null,
ciphertext = null,
signatureKey = null,
serialized = null
) {
super();
if (serialized) {
const version = serialized[0];
const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH);
const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH);
const senderKeyMessage = protobufs.SenderKeyMessage.decode(message).toJSON();
senderKeyMessage.ciphertext = Buffer.from(senderKeyMessage.ciphertext, 'base64');
this.serialized = serialized;
this.messageVersion = (version & 0xff) >> 4;
this.keyId = senderKeyMessage.id;
this.iteration = senderKeyMessage.iteration;
this.ciphertext = senderKeyMessage.ciphertext;
this.signature = signature;
} else {
const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256;
ciphertext = Buffer.from(ciphertext); // .toString('base64');
const message = protobufs.SenderKeyMessage.encode(
protobufs.SenderKeyMessage.create({
id: keyId,
iteration,
ciphertext,
})
).finish();
const signature = this.getSignature(
signatureKey,
Buffer.concat([Buffer.from([version]), message])
);
this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)]);
this.messageVersion = this.CURRENT_VERSION;
this.keyId = keyId;
this.iteration = iteration;
this.ciphertext = ciphertext;
this.signature = signature;
}
}
getKeyId() {
return this.keyId;
}
getIteration() {
return this.iteration;
}
getCipherText() {
return this.ciphertext;
}
verifySignature(signatureKey) {
const part1 = this.serialized.slice(0, this.serialized.length - this.SIGNATURE_LENGTH + 1);
const part2 = this.serialized.slice(-1 * this.SIGNATURE_LENGTH);
const res = curve.verifySignature(signatureKey, part1, part2);
if (!res) throw new Error('Invalid signature!');
}
getSignature(signatureKey, serialized) {
const signature = Buffer.from(
curve.calculateSignature(
signatureKey,
serialized
)
);
return signature;
}
serialize() {
return this.serialized;
}
getType() {
return 4;
}
}
module.exports = SenderKeyMessage;

View File

@@ -0,0 +1,70 @@
function isNull(str) {
return str === null || str.value === '';
}
/**
* java String hashCode 的实现
* @param strKey
* @return intValue
*/
function intValue(num) {
const MAX_VALUE = 0x7fffffff;
const MIN_VALUE = -0x80000000;
if (num > MAX_VALUE || num < MIN_VALUE) {
// eslint-disable-next-line
return (num &= 0xffffffff);
}
return num;
}
function hashCode(strKey) {
let hash = 0;
if (!isNull(strKey)) {
for (let i = 0; i < strKey.length; i++) {
hash = hash * 31 + strKey.charCodeAt(i);
hash = intValue(hash);
}
}
return hash;
}
/**
* 将js页面的number类型转换为java的int类型
* @param num
* @return intValue
*/
class SenderKeyName {
constructor(groupId, sender) {
this.groupId = groupId;
this.sender = sender;
}
getGroupId() {
return this.groupId;
}
getSender() {
return this.sender;
}
serialize() {
return `${this.groupId}::${this.sender.id}::${this.sender.deviceId}`;
}
toString() {
return this.serialize();
}
equals(other) {
if (other === null) return false;
if (!(other instanceof SenderKeyName)) return false;
return this.groupId === other.groupId && this.sender.toString() === other.sender.toString();
}
hashCode() {
return hashCode(this.groupId) ^ hashCode(this.sender.toString());
}
}
module.exports = SenderKeyName;

View File

@@ -0,0 +1,54 @@
const SenderKeyState = require('./sender_key_state');
class SenderKeyRecord {
MAX_STATES = 5;
constructor(serialized) {
this.senderKeyStates = [];
if (serialized) {
const list = serialized;
for (let i = 0; i < list.length; i++) {
const structure = list[i];
this.senderKeyStates.push(
new SenderKeyState(null, null, null, null, null, null, structure)
);
}
}
}
isEmpty() {
return this.senderKeyStates.length === 0;
}
getSenderKeyState(keyId) {
if (!keyId && this.senderKeyStates.length) return this.senderKeyStates[0];
for (let i = 0; i < this.senderKeyStates.length; i++) {
const state = this.senderKeyStates[i];
if (state.getKeyId() === keyId) {
return state;
}
}
throw new Error(`No keys for: ${keyId}`);
}
addSenderKeyState(id, iteration, chainKey, signatureKey) {
this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, null, signatureKey));
}
setSenderKeyState(id, iteration, chainKey, keyPair) {
this.senderKeyStates.length = 0;
this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, keyPair));
}
serialize() {
const recordStructure = [];
for (let i = 0; i < this.senderKeyStates.length; i++) {
const senderKeyState = this.senderKeyStates[i];
recordStructure.push(senderKeyState.getStructure());
}
return recordStructure;
}
}
module.exports = SenderKeyRecord;

View File

@@ -0,0 +1,129 @@
const SenderChainKey = require('./sender_chain_key');
const SenderMessageKey = require('./sender_message_key');
const protobufs = require('./protobufs');
class SenderKeyState {
MAX_MESSAGE_KEYS = 2000;
constructor(
id = null,
iteration = null,
chainKey = null,
signatureKeyPair = null,
signatureKeyPublic = null,
signatureKeyPrivate = null,
senderKeyStateStructure = null
) {
if (senderKeyStateStructure) {
this.senderKeyStateStructure = senderKeyStateStructure;
} else {
if (signatureKeyPair) {
signatureKeyPublic = signatureKeyPair.public;
signatureKeyPrivate = signatureKeyPair.private;
}
chainKey = typeof chainKey === 'string' ? Buffer.from(chainKey, 'base64') : chainKey;
this.senderKeyStateStructure = protobufs.SenderKeyStateStructure.create();
const senderChainKeyStructure = protobufs.SenderChainKey.create();
senderChainKeyStructure.iteration = iteration;
senderChainKeyStructure.seed = chainKey;
this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure;
const signingKeyStructure = protobufs.SenderSigningKey.create();
signingKeyStructure.public =
typeof signatureKeyPublic === 'string' ?
Buffer.from(signatureKeyPublic, 'base64') :
signatureKeyPublic;
if (signatureKeyPrivate) {
signingKeyStructure.private =
typeof signatureKeyPrivate === 'string' ?
Buffer.from(signatureKeyPrivate, 'base64') :
signatureKeyPrivate;
}
this.senderKeyStateStructure.senderKeyId = id;
this.senderChainKey = senderChainKeyStructure;
this.senderKeyStateStructure.senderSigningKey = signingKeyStructure;
}
this.senderKeyStateStructure.senderMessageKeys =
this.senderKeyStateStructure.senderMessageKeys || [];
}
SenderKeyState(senderKeyStateStructure) {
this.senderKeyStateStructure = senderKeyStateStructure;
}
getKeyId() {
return this.senderKeyStateStructure.senderKeyId;
}
getSenderChainKey() {
return new SenderChainKey(
this.senderKeyStateStructure.senderChainKey.iteration,
this.senderKeyStateStructure.senderChainKey.seed
);
}
setSenderChainKey(chainKey) {
const senderChainKeyStructure = protobufs.SenderChainKey.create({
iteration: chainKey.getIteration(),
seed: chainKey.getSeed(),
});
this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure;
}
getSigningKeyPublic() {
return typeof this.senderKeyStateStructure.senderSigningKey.public === 'string' ?
Buffer.from(this.senderKeyStateStructure.senderSigningKey.public, 'base64') :
this.senderKeyStateStructure.senderSigningKey.public;
}
getSigningKeyPrivate() {
return typeof this.senderKeyStateStructure.senderSigningKey.private === 'string' ?
Buffer.from(this.senderKeyStateStructure.senderSigningKey.private, 'base64') :
this.senderKeyStateStructure.senderSigningKey.private;
}
hasSenderMessageKey(iteration) {
const list = this.senderKeyStateStructure.senderMessageKeys;
for (let o = 0; o < list.length; o++) {
const senderMessageKey = list[o];
if (senderMessageKey.iteration === iteration) return true;
}
return false;
}
addSenderMessageKey(senderMessageKey) {
const senderMessageKeyStructure = protobufs.SenderKeyStateStructure.create({
iteration: senderMessageKey.getIteration(),
seed: senderMessageKey.getSeed(),
});
this.senderKeyStateStructure.senderMessageKeys.push(senderMessageKeyStructure);
if (this.senderKeyStateStructure.senderMessageKeys.length > this.MAX_MESSAGE_KEYS) {
this.senderKeyStateStructure.senderMessageKeys.shift();
}
}
removeSenderMessageKey(iteration) {
let result = null;
this.senderKeyStateStructure.senderMessageKeys = this.senderKeyStateStructure.senderMessageKeys.filter(
senderMessageKey => {
if (senderMessageKey.iteration === iteration) result = senderMessageKey;
return senderMessageKey.iteration !== iteration;
}
);
if (result != null) {
return new SenderMessageKey(result.iteration, result.seed);
}
return null;
}
getStructure() {
return this.senderKeyStateStructure;
}
}
module.exports = SenderKeyState;

View File

@@ -0,0 +1,39 @@
const { deriveSecrets } = require('libsignal/src/crypto');
class SenderMessageKey {
iteration = 0;
iv = Buffer.alloc(0);
cipherKey = Buffer.alloc(0);
seed = Buffer.alloc(0);
constructor(iteration, seed) {
const derivative = deriveSecrets(seed, Buffer.alloc(32), Buffer.from('WhisperGroup'));
const keys = new Uint8Array(32);
keys.set(new Uint8Array(derivative[0].slice(16)));
keys.set(new Uint8Array(derivative[1].slice(0, 16)), 16);
this.iv = Buffer.from(derivative[0].slice(0, 16));
this.cipherKey = Buffer.from(keys.buffer);
this.iteration = iteration;
this.seed = seed;
}
getIteration() {
return this.iteration;
}
getIv() {
return this.iv;
}
getCipherKey() {
return this.cipherKey;
}
getSeed() {
return this.seed;
}
}
module.exports = SenderMessageKey;

11
jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
"roots": [
"<rootDir>/src"
],
"testMatch": [
"**/Tests/test.*.+(ts|tsx|js)",
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
}

View File

@@ -1,7 +1,7 @@
{
"name": "@adiwajshing/baileys",
"version": "3.5.3",
"description": "WhatsApp Web API",
"version": "4.0.0",
"description": "WhatsApp API",
"homepage": "https://github.com/adiwajshing/Baileys",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -13,18 +13,21 @@
"whatsapp",
"whatsapp-chat",
"whatsapp-group",
"automation"
"automation",
"multi-device"
],
"scripts": {
"test": "mocha --timeout 240000 -r ts-node/register src/Tests/Tests.*.ts",
"prepack": "tsc",
"lint": "eslint '*/*.ts' --quiet --fix",
"test": "jest",
"prepare": "tsc",
"build:all": "tsc && typedoc",
"build:docs": "typedoc",
"build:tsc": "tsc",
"example": "node --inspect -r ts-node/register Example/example.ts",
"gen-protobuf": "bash src/Binary/GenerateStatics.sh",
"browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts"
"example:legacy": "node --inspect -r ts-node/register Example/example-legacy.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",
"license": "MIT",
@@ -32,33 +35,44 @@
"url": "git@github.com:adiwajshing/baileys.git"
},
"dependencies": {
"@adiwajshing/keyed-db": "^0.2.2",
"@hapi/boom": "^9.1.3",
"axios": "^0.24.0",
"curve25519-js": "^0.0.4",
"futoin-hkdf": "^1.3.2",
"got": "^11.8.1",
"https-proxy-agent": "^5.0.0",
"jimp": "^0.16.1",
"libsignal": "git+https://github.com/adiwajshing/libsignal-node",
"music-metadata": "^7.4.1",
"pino": "^6.7.0",
"pino-pretty": "^4.3.0",
"node-cache": "^5.1.2",
"pino": "^7.0.0",
"protobufjs": "^6.10.1",
"ws": "^8.0.0"
},
"peerDependencies": {
"jimp": "^0.16.1",
"qrcode-terminal": "^0.12.0",
"ws": "^7.3.1"
"sharp": "^0.29.3",
"@adiwajshing/keyed-db": "^0.2.4"
},
"files": [
"lib/*",
"WAMessage/*"
"WAProto/*",
"WASignalGroup/*.js",
"WABinary/*.js"
],
"devDependencies": {
"@adiwajshing/eslint-config": "git+https://github.com/adiwajshing/eslint-config",
"@adiwajshing/keyed-db": "^0.2.4",
"@types/got": "^9.6.11",
"@types/mocha": "^7.0.2",
"@types/jest": "^26.0.24",
"@types/node": "^14.6.2",
"@types/pino": "^6.3.2",
"@types/ws": "^7.2.6",
"assert": "^2.0.0",
"dotenv": "^8.2.0",
"mocha": "^8.1.3",
"ts-node-dev": "^1.0.0",
"@types/pino": "^7.0.0",
"@types/sharp": "^0.29.4",
"@types/ws": "^8.0.0",
"eslint": "^7.0.0",
"jest": "^27.0.6",
"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",
"typescript": "^4.0.0"
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,2 @@
import makeInMemoryStore from './make-in-memory-store'
export { makeInMemoryStore }

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

View 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

View File

@@ -1,76 +0,0 @@
import { WAConnection, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection'
import * as assert from 'assert'
import {promises as fs} from 'fs'
require ('dotenv').config () // dotenv to load test jid
export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory
export const makeConnection = () => {
const conn = new WAConnection()
conn.connectOptions.maxIdleTimeMs = 15_000
conn.logger.level = 'debug'
let evCounts = {}
conn.on ('close', ({ isReconnecting }) => {
!isReconnecting && console.log ('Events registered: ', evCounts)
})
const onM = conn.on
conn.on = (...args: any[]) => {
evCounts[args[0]] = (evCounts[args[0]] || 0) + 1
return onM.apply (conn, args)
}
const offM = conn.off
conn.off = (...args: any[]) => {
evCounts[args[0]] = (evCounts[args[0]] || 0) - 1
if (evCounts[args[0]] <= 0) delete evCounts[args[0]]
return offM.apply (conn, args)
}
return conn
}
export async function sendAndRetrieveMessage(conn: WAConnection, content, type: MessageType, options: MessageOptions = {}, recipientJid = testJid) {
const response = await conn.sendMessage(recipientJid, content, type, options)
const {messages} = await conn.loadMessages(recipientJid, 10)
const message = messages.find (m => m.key.id === response.key.id)
assert.ok(message)
const chat = conn.chats.get(recipientJid)
assert.ok (chat.messages.get(GET_MESSAGE_ID(message.key)))
assert.ok (chat.t >= (unixTimestampSeconds()-5), `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),
[]
)
))
}

View File

@@ -1,89 +0,0 @@
import { strict as assert } from 'assert'
import Encoder from '../Binary/Encoder'
import Decoder from '../Binary/Decoder'
describe('Binary Coding Tests', () => {
const testVectors: [string, Object][] = [
[
'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305',
[
'action',
{ last: 'true', add: 'before' },
[
[
'message',
null,
{
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB009675E7ED37AABF2' },
message: { conversation: '*piano room timings are:*\n 6:00AM-12:00AM' },
messageTimestamp: '1584004403',
status: 'DELIVERY_ACK',
},
],
[
'message',
null,
{
key: {
remoteJid: '917529871117@s.whatsapp.net',
fromMe: false,
id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD',
},
messageTimestamp: '1584004413',
messageStubType: 'REVOKE',
},
],
[
'message',
null,
{
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB003C7B539AFD09753' },
message: {
conversation:
"Sorry fren, I couldn't understand 'Libra'. Type 'help' to know what all I can do",
},
messageTimestamp: '1584004417',
status: 'DELIVERY_ACK',
},
],
[
'message',
null,
{
key: {
remoteJid: '917529871117@s.whatsapp.net',
fromMe: false,
id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8',
},
message: { conversation: 'library' },
messageTimestamp: '1584004418',
},
],
],
],
],
[
'f8063f2dfafc0831323334353637385027fc0431323334f801f80228fc0701020304050607',
[
'picture',
{jid: '12345678@c.us', id: '1234'},
[['image', null, Buffer.from([1,2,3,4,5,6,7])]]
]
]
]
const encoder = new Encoder()
const decoder = new Decoder()
it('should decode strings', () => {
testVectors.forEach(pair => {
const buff = Buffer.from(pair[0], 'hex')
const decoded = decoder.read(buff)
//console.log((decoded[2][0][2]))
assert.deepStrictEqual(decoded, pair[1])
const encoded = encoder.write(decoded)
assert.deepStrictEqual(encoded, buff)
})
console.log('all coding tests passed')
})
})

View File

@@ -1,407 +0,0 @@
import * as assert from 'assert'
import {WAConnection} from '../WAConnection'
import { AuthenticationCredentialsBase64, BaileysError, ReconnectMode, DisconnectReason, WAChat, WAContact } from '../WAConnection/Constants'
import { delay } from '../WAConnection/Utils'
import { assertChatDBIntegrity, makeConnection, testJid } from './Common'
describe('QR Generation', () => {
it('should generate QR', async () => {
const conn = makeConnection ()
conn.connectOptions.maxRetries = 0
let calledQR = 0
conn.removeAllListeners ('qr')
conn.on ('qr', () => calledQR += 1)
await conn.connect()
.then (() => assert.fail('should not have succeeded'))
.catch (error => {})
assert.deepStrictEqual (
Object.keys(conn.eventNames()).filter(key => key.startsWith('TAG:')),
[]
)
assert.ok(calledQR >= 2, 'QR not called')
})
})
describe('Test Connect', () => {
let auth: AuthenticationCredentialsBase64
it('should connect', async () => {
console.log('please be ready to scan with your phone')
const conn = makeConnection ()
let credentialsUpdateCalled = false
conn.on ('credentials-updated', () => credentialsUpdateCalled = true)
await conn.connect ()
assert.ok(conn.user?.jid)
assert.ok(conn.user?.phone)
assert.ok (conn.user?.imgUrl || conn.user.imgUrl === '')
assert.ok (credentialsUpdateCalled)
assertChatDBIntegrity (conn)
conn.close()
auth = conn.base64EncodedAuthInfo()
})
it('should restore session', async () => {
const conn = makeConnection ()
let credentialsUpdateCalled = false
conn.on ('credentials-updated', () => credentialsUpdateCalled = true)
await conn.loadAuthInfo (auth).connect ()
assert.ok(conn.user)
assert.ok(conn.user.jid)
assert.ok (credentialsUpdateCalled)
assertChatDBIntegrity (conn)
await conn.logout()
conn.loadAuthInfo(auth)
await conn.connect()
.then (() => assert.fail('should not have reconnected'))
.catch (err => {
assert.ok (err instanceof BaileysError)
assert.ok ((err as BaileysError).status >= 400)
})
conn.close()
})
it ('should disconnect & reconnect phone', async () => {
const conn = makeConnection ()
conn.logger.level = 'debug'
await conn.loadAuthInfo('./auth_info.json').connect ()
assert.strictEqual (conn.phoneConnected, true)
try {
const waitForEvent = expect => new Promise (resolve => {
conn.on ('connection-phone-change', ({connected}) => {
if (connected === expect) {
conn.removeAllListeners ('connection-phone-change')
resolve(undefined)
}
})
})
console.log ('disconnect your phone from the internet')
await delay (10_000)
console.log ('phone should be disconnected now, testing...')
const messagesPromise = Promise.all (
[
conn.loadMessages (testJid, 50),
conn.getStatus (testJid),
conn.getProfilePicture (testJid).catch (() => '')
]
)
await waitForEvent (false)
console.log ('reconnect your phone to the internet')
await waitForEvent (true)
console.log ('reconnected successfully')
const final = await messagesPromise
assert.ok (final)
} finally {
conn.close ()
}
})
})
describe ('Reconnects', () => {
const verifyConnectionOpen = async (conn: WAConnection) => {
assert.ok (conn.user.jid)
let failed = false
// check that the connection stays open
conn.on ('close', ({reason}) => {
if(reason !== DisconnectReason.intentional) failed = true
})
await delay (60*1000)
const status = await conn.getStatus ()
assert.ok (status)
assert.ok (!conn['debounceTimeout']) // this should be null
conn.close ()
if (failed) assert.fail ('should not have closed again')
}
it('should dispose correctly on bad_session', async () => {
const conn = makeConnection ()
conn.autoReconnect = ReconnectMode.onAllErrors
conn.loadAuthInfo ('./auth_info.json')
let gotClose0 = false
let gotClose1 = false
conn.on ('ws-close', ({ reason }) => {
gotClose0 = true
})
conn.on ('close', ({ reason }) => {
if (reason === DisconnectReason.badSession) gotClose1 = true
})
setTimeout (() => conn['conn'].emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
await conn.connect ()
setTimeout (() => conn['conn'].emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
await new Promise (resolve => {
conn.on ('open', resolve)
})
assert.ok (gotClose0, 'did not receive bad_session close initially')
assert.ok (gotClose1, 'did not receive bad_session close')
conn.close ()
})
/**
* the idea is to test closing the connection at multiple points in the connection
* and see if the library cleans up resources correctly
*/
it('should cleanup correctly', async () => {
const conn = makeConnection ()
conn.autoReconnect = ReconnectMode.onAllErrors
conn.loadAuthInfo ('./auth_info.json')
let timeout = 0.1
while (true) {
let tmout = setTimeout (() => conn.close(), timeout*1000)
try {
await conn.connect ()
clearTimeout (tmout)
break
} catch (error) {
}
// exponentially increase the timeout disconnect
timeout *= 2
}
await verifyConnectionOpen (conn)
})
/**
* the idea is to test closing the connection at multiple points in the connection
* and see if the library cleans up resources correctly
*/
it('should disrupt connect loop', async () => {
const conn = makeConnection ()
conn.autoReconnect = ReconnectMode.onAllErrors
conn.loadAuthInfo ('./auth_info.json')
let timeout = 1000
let tmout
const endConnection = async () => {
while (!conn['conn']) {
await delay(100)
}
conn['conn'].close ()
while (conn['conn']) {
await delay(100)
}
timeout *= 2
tmout = setTimeout (endConnection, timeout)
}
tmout = setTimeout (endConnection, timeout)
await conn.connect ()
clearTimeout (tmout)
await verifyConnectionOpen (conn)
})
it ('should reconnect on broken connection', async () => {
const conn = makeConnection ()
conn.autoReconnect = ReconnectMode.onConnectionLost
await conn.loadAuthInfo('./auth_info.json').connect ()
assert.strictEqual (conn.phoneConnected, true)
try {
const closeConn = () => conn['conn']?.terminate ()
const task = new Promise (resolve => {
let closes = 0
conn.on ('close', ({reason, isReconnecting}) => {
console.log (`closed: ${reason}`)
assert.ok (reason)
assert.ok (isReconnecting)
closes += 1
// let it fail reconnect a few times
if (closes >= 1) {
conn.removeAllListeners ('close')
conn.removeAllListeners ('connecting')
resolve(undefined)
}
})
conn.on ('connecting', () => {
// close again
delay (3500).then (closeConn)
})
})
closeConn ()
await task
await new Promise (resolve => {
conn.on ('open', () => {
conn.removeAllListeners ('open')
resolve(undefined)
})
})
conn.close ()
conn.on ('connecting', () => assert.fail('should not connect'))
await delay (2000)
} finally {
conn.removeAllListeners ('connecting')
conn.removeAllListeners ('close')
conn.removeAllListeners ('open')
conn.close ()
}
})
it ('should reconnect & stay connected', async () => {
const conn = makeConnection ()
conn.autoReconnect = ReconnectMode.onConnectionLost
await conn.loadAuthInfo('./auth_info.json').connect ()
assert.strictEqual (conn.phoneConnected, true)
await delay (30*1000)
conn['conn']?.terminate ()
conn.on ('close', () => {
assert.ok (!conn['lastSeen'])
console.log ('connection closed')
})
await new Promise (resolve => conn.on ('open', resolve))
await verifyConnectionOpen (conn)
})
})
describe ('Pending Requests', () => {
it ('should correctly send updates for chats', async () => {
const conn = makeConnection ()
conn.pendingRequestTimeoutMs = null
conn.loadAuthInfo('./auth_info.json')
const task = new Promise(resolve => conn.once('chats-received', resolve))
await conn.connect ()
await task
conn.close ()
const oldChat = conn.chats.all()[0]
oldChat.archive = 'true' // mark the first chat as archived
oldChat.modify_tag = '1234' // change modify tag to detect change
const promise = new Promise(resolve => conn.once('chats-update', resolve))
const result = await conn.connect ()
assert.ok (!result.newConnection)
const chats = await promise as Partial<WAChat>[]
const chat = chats.find (c => c.jid === oldChat.jid)
assert.ok (chat)
assert.ok ('archive' in chat)
assert.strictEqual (Object.keys(chat).length, 3)
assert.strictEqual (Object.keys(chats).length, 1)
conn.close ()
})
it ('should correctly send updates for contacts', async () => {
const conn = makeConnection ()
conn.pendingRequestTimeoutMs = null
conn.loadAuthInfo('./auth_info.json')
const task: any = new Promise(resolve => conn.once('contacts-received', resolve))
await conn.connect ()
const initialResult = await task
assert.strictEqual(
initialResult.updatedContacts.length,
Object.keys(conn.contacts).length
)
conn.close ()
const [jid] = Object.keys(conn.contacts)
const oldContact = conn.contacts[jid]
oldContact.name = 'Lol'
oldContact.index = 'L'
const promise = new Promise(resolve => conn.once('contacts-received', resolve))
const result = await conn.connect ()
assert.ok (!result.newConnection)
const {updatedContacts} = await promise as { updatedContacts: Partial<WAContact>[] }
const contact = updatedContacts.find (c => c.jid === jid)
assert.ok (contact)
assert.ok ('name' in contact)
assert.strictEqual (Object.keys(contact).length, 3)
assert.strictEqual (Object.keys(updatedContacts).length, 1)
conn.close ()
})
it('should queue requests when closed', async () => {
const conn = makeConnection ()
//conn.pendingRequestTimeoutMs = null
await conn.loadAuthInfo('./auth_info.json').connect ()
await delay (2000)
conn.close ()
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.jid]})
await delay (2000)
conn.connect ()
const json = await task
assert.ok (json.status)
conn.close ()
})
it('[MANUAL] should receive query response after phone disconnect', async () => {
const conn = makeConnection ()
await conn.loadAuthInfo('./auth_info.json').connect ()
console.log(`disconnect your phone from the internet!`)
await delay(5000)
const task = conn.loadMessages(testJid, 50)
setTimeout(() => console.log('reconnect your phone!'), 20_000)
const result = await task
assert.ok(result.messages[0])
assert.ok(!conn['phoneCheckInterval']) // should be undefined
conn.close ()
})
it('should re-execute query on connection closed error', async () => {
const conn = makeConnection ()
//conn.pendingRequestTimeoutMs = 10_000
await conn.loadAuthInfo('./auth_info.json').connect ()
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.jid], waitForOpen: true})
await delay(20)
conn['onMessageRecieved']('1234,["Pong",false]') // fake cancel the connection
await delay(2000)
const json = await task
assert.ok (json.status)
conn.close ()
})
})

View File

@@ -1,193 +0,0 @@
import { MessageType, GroupSettingChange, delay, ChatModification, whatsappID } from '../WAConnection'
import * as assert from 'assert'
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
WAConnectionTest('Groups', (conn) => {
let gid: string
it('should create a group', async () => {
const response = await conn.groupCreate('Cool Test Group', [testJid])
assert.ok (conn.chats.get(response.gid))
const {chats} = await conn.loadChats(10, null)
assert.strictEqual (chats[0].jid, response.gid) // first chat should be new group
gid = response.gid
console.log('created group: ' + JSON.stringify(response))
})
it('should retrieve group invite code', async () => {
const code = await conn.groupInviteCode(gid)
assert.ok(code)
assert.strictEqual(typeof code, 'string')
})
it('should joined group via invite code', async () => {
const response = await conn.acceptInvite(gid)
assert.ok(response.status)
assert.strictEqual(response.status, response.gid)
})
it('should retrieve group metadata', async () => {
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.id, gid)
assert.strictEqual(metadata.participants.filter((obj) => obj.jid.split('@')[0] === testJid.split('@')[0]).length, 1)
assert.ok(conn.chats.get(gid))
assert.ok(conn.chats.get(gid).metadata)
})
it('should update the group description', async () => {
const newDesc = 'Wow this was set from Baileys'
const waitForEvent = new Promise (resolve => (
conn.once ('group-update', ({jid, desc}) => {
if (jid === gid && desc) {
assert.strictEqual(desc, newDesc)
assert.strictEqual(
conn.chats.get(jid).metadata.desc,
newDesc
)
resolve(undefined)
}
})
))
await conn.groupUpdateDescription (gid, newDesc)
await waitForEvent
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.desc, newDesc)
})
it('should send a message on the group', async () => {
await sendAndRetrieveMessage(conn, 'Hello!', MessageType.text, {}, gid)
})
it('should delete a message on the group', async () => {
const message = await sendAndRetrieveMessage(conn, 'Hello!', MessageType.text, {}, gid)
await delay(1500)
await conn.deleteMessage(message.key)
})
it('should quote a message on the group', async () => {
const {messages} = await conn.loadMessages (gid, 100)
const quotableMessage = messages.find (m => m.message)
assert.ok (quotableMessage, 'need at least one message')
const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: quotableMessage})
const loaded = await conn.loadMessages(gid, 10)
const message = loaded.messages.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage
assert.ok(message)
assert.strictEqual (message.contextInfo.stanzaId, quotableMessage.key.id)
})
it('should update the subject', async () => {
const subject = 'Baileyz ' + Math.floor(Math.random()*5)
const waitForEvent = new Promise (resolve => {
conn.once ('chat-update', ({jid, name}) => {
if (jid === gid) {
assert.strictEqual(name, subject)
assert.strictEqual(conn.chats.get(jid).name, subject)
resolve(undefined)
}
})
})
await conn.groupUpdateSubject(gid, subject)
await waitForEvent
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.subject, subject)
})
it('should update the group settings', async () => {
const waitForEvent = new Promise (resolve => {
conn.once ('group-update', ({jid, announce}) => {
if (jid === gid) {
assert.strictEqual (announce, 'true')
assert.strictEqual(conn.chats.get(gid).metadata.announce, announce)
resolve(undefined)
}
})
})
await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true)
await waitForEvent
conn.removeAllListeners ('group-update')
await delay (2000)
await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true)
})
it('should promote someone', async () => {
const waitForEvent = new Promise (resolve => {
conn.once ('group-participants-update', ({ jid, action, participants }) => {
if (jid === gid) {
assert.strictEqual (action, 'promote')
console.log(participants)
console.log(conn.chats.get(jid).metadata)
assert.ok(
conn.chats.get(jid).metadata.participants.find(({ jid, isAdmin }) => (
whatsappID(jid) === whatsappID(participants[0]) && isAdmin
)),
)
resolve(undefined)
}
})
})
await conn.groupMakeAdmin(gid, [ testJid ])
await waitForEvent
})
it('should remove someone from a group', async () => {
const metadata = await conn.groupMetadata (gid)
if (metadata.participants.find(({jid}) => whatsappID(jid) === testJid)) {
const waitForEvent = new Promise (resolve => {
conn.once ('group-participants-update', ({jid, participants, action}) => {
if (jid === gid) {
assert.strictEqual (participants[0], testJid)
assert.strictEqual (action, 'remove')
assert.deepStrictEqual(
conn.chats.get(jid).metadata.participants.find(p => whatsappID(p.jid) === whatsappID(participants[0])),
undefined
)
resolve(undefined)
}
})
})
await conn.groupRemove(gid, [testJid])
await waitForEvent
} else console.log(`could not find testJid`)
})
it('should leave the group', async () => {
const waitForEvent = new Promise (resolve => {
conn.once ('chat-update', ({jid, read_only}) => {
if (jid === gid) {
assert.strictEqual (read_only, 'true')
resolve(undefined)
}
})
})
await conn.groupLeave(gid)
await waitForEvent
await conn.groupMetadataMinimal (gid)
})
it('should archive the group', async () => {
const waitForEvent = new Promise (resolve => {
conn.once ('chat-update', ({jid, archive}) => {
if (jid === gid) {
assert.strictEqual (archive, 'true')
resolve(undefined)
}
})
})
await conn.modifyChat(gid, ChatModification.archive)
await waitForEvent
})
it('should delete the group', async () => {
const waitForEvent = new Promise (resolve => {
conn.once ('chat-update', (chat) => {
if (chat.jid === gid) {
assert.strictEqual (chat['delete'], 'true')
resolve(undefined)
}
})
})
await conn.modifyChat(gid, 'delete')
await waitForEvent
})
})

View File

@@ -1,43 +0,0 @@
import { deepStrictEqual, strictEqual } from 'assert'
import { createWriteStream } from 'fs'
import { readFile } from 'fs/promises'
import { proto } from '../../WAMessage/WAMessage'
import { MessageType } from '../WAConnection'
import { aesEncrypWithIV, decryptMediaMessageBuffer, encryptedStream, getMediaKeys, getStream, hmacSign, sha256 } from '../WAConnection/Utils'
import { WAConnectionTest } from './Common'
describe('Media Download Tests', () => {
it('should encrypt media streams correctly', async function() {
const url = './Media/meme.jpeg'
const streamValues = await encryptedStream({ url }, MessageType.image)
const buffer = await readFile(url)
const mediaKeys = getMediaKeys(streamValues.mediaKey, MessageType.image)
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
const body = Buffer.concat([enc, mac]) // body is enc + mac
const fileSha256 = sha256(buffer)
const fileEncSha256 = sha256(body)
deepStrictEqual(streamValues.fileSha256, fileSha256)
strictEqual(streamValues.fileLength, buffer.length)
deepStrictEqual(streamValues.mac, mac)
deepStrictEqual(await readFile(streamValues.encBodyPath), body)
deepStrictEqual(streamValues.fileEncSha256, fileEncSha256)
})
})
/*
WAConnectionTest('Media Upload', conn => {
it('should upload the same file', async () => {
const FILES = [
{ url: './Media/meme.jpeg', type: MessageType.image },
{ url: './Media/ma_gif.mp4', type: MessageType.video },
{ url: './Media/sonata.mp3', type: MessageType.audio },
]
})
})*/

View File

@@ -1,268 +0,0 @@
import { MessageType, Mimetype, delay, promiseTimeout, WA_MESSAGE_STATUS_TYPE, generateMessageID, WAMessage } from '../WAConnection'
import { promises as fs } from 'fs'
import * as assert from 'assert'
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
WAConnectionTest('Messages', conn => {
it('should send a text message', async () => {
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text)
assert.strictEqual(message.message.conversation || message.message.extendedTextMessage?.text, 'hello fren')
})
it('should send a pending message', async () => {
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text, { waitForAck: false })
await new Promise(resolve => conn.on('chat-update', update => {
if (update.jid === testJid &&
update.messages &&
update.messages.first.key.id === message.key.id &&
update.messages.first.status === WA_MESSAGE_STATUS_TYPE.SERVER_ACK) {
resolve(undefined)
}
}))
})
it('should forward a message', async () => {
let {messages} = await conn.loadMessages (testJid, 1)
await conn.forwardMessage (testJid, messages[0], true)
messages = (await conn.loadMessages (testJid, 1)).messages
const message = messages.slice (-1)[0]
const content = message.message[ Object.keys(message.message)[0] ]
assert.strictEqual (content?.contextInfo?.isForwarded, true)
})
it('should send a link preview', async () => {
const text = 'hello this is from https://www.github.com/adiwajshing/Baileys'
const message = await sendAndRetrieveMessage(conn, text, MessageType.text, { detectLinks: true })
const received = message.message.extendedTextMessage
assert.strictEqual(received.text, text)
assert.ok (received.canonicalUrl)
assert.ok (received.title)
assert.ok (received.description)
})
it('should quote a message', async () => {
const quoted = (await conn.loadMessages(testJid, 2)).messages[0]
const message = await sendAndRetrieveMessage(conn, 'hello fren 2', MessageType.extendedText, { quoted })
assert.strictEqual(
message.message.extendedTextMessage.contextInfo.stanzaId,
quoted.key.id
)
assert.strictEqual(
message.message.extendedTextMessage.contextInfo.participant,
quoted.key.fromMe ? conn.user.jid : quoted.key.id
)
})
it('should upload media successfully', async () => {
const content = await fs.readFile('./Media/sonata.mp3')
// run 10 uploads
for (let i = 0; i < 10;i++) {
await conn.prepareMessageContent (content, MessageType.audio, { filename: 'audio.mp3', mimetype: Mimetype.mp4Audio })
}
})
it('should send a gif', async () => {
const message = await sendAndRetrieveMessage(conn, { url: './Media/ma_gif.mp4' }, MessageType.video, { mimetype: Mimetype.gif })
await conn.downloadAndSaveMediaMessage(message,'./Media/received_vid')
})
it('should send an audio', async () => {
const content = await fs.readFile('./Media/sonata.mp3')
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio })
// check duration was okay
assert.ok (message.message.audioMessage.seconds > 0)
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
})
it('should send a voice note', async () => {
const content = await fs.readFile('./Media/sonata.mp3')
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true })
assert.ok (message.message.audioMessage.seconds > 0)
assert.strictEqual (message.message?.audioMessage?.ptt, true)
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
})
it('should send a jpeg image', async () => {
const message = await sendAndRetrieveMessage(conn, { url: './Media/meme.jpeg' }, MessageType.image)
assert.ok(message.message.imageMessage.jpegThumbnail.length > 0)
const msg = await conn.downloadMediaMessage(message)
assert.deepStrictEqual(msg, await fs.readFile('./Media/meme.jpeg'))
})
it('should send a remote jpeg image', async () => {
const message = await sendAndRetrieveMessage(
conn,
{ url: 'https://www.memestemplates.com/wp-content/uploads/2020/05/tom-with-phone.jpg' },
MessageType.image
)
assert.ok (message.message?.imageMessage?.jpegThumbnail)
await conn.downloadMediaMessage(message)
})
it('should send a png image', async () => {
const content = await fs.readFile('./Media/icon.png')
const message = await sendAndRetrieveMessage(conn, content, MessageType.image, { mimetype: 'image/png' })
assert.ok (message.message?.imageMessage?.jpegThumbnail)
await conn.downloadMediaMessage(message)
})
it('should send a sticker', async () => {
const content = await fs.readFile('./Media/octopus.webp')
const message = await sendAndRetrieveMessage(conn, content, MessageType.sticker)
await conn.downloadMediaMessage(message)
})
/*it('should send an interactive message', async () => {
console.log (
JSON.stringify(await conn.loadMessages (testJid, 5), null, '\t')
)
const message = conn.prepareMessageFromContent (
testJid,
{
templateMessage: {
fourRowTemplate: {
content: {
namespace: 'my-namespace',
localizableParams: [
],
params: ['hello!']
},
buttons: [
{
index: 0,
quickReplyButton: {
displayText: {
params: ['my name jeff']
}
}
},
{
index: 1,
quickReplyButton: {
displayText: {
params: ['my name NOT jeff'],
}
}
}
]
}
}
},
{}
)
await conn.relayWAMessage (message)
})*/
it('should send an image & quote', async () => {
const quoted = (await conn.loadMessages(testJid, 2)).messages[0]
const content = await fs.readFile('./Media/meme.jpeg')
const message = await sendAndRetrieveMessage(conn, content, MessageType.image, { quoted })
await conn.downloadMediaMessage(message) // check for successful decoding
assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, quoted.key.id)
})
it('should send a message & delete it', async () => {
const message = await sendAndRetrieveMessage(conn, 'hello fren', MessageType.text)
await delay (2000)
await conn.deleteMessage (testJid, message.key)
})
it('should clear the most recent message', async () => {
const {messages} = await conn.loadMessages (testJid, 1)
await delay (2000)
await conn.clearMessage (messages[0].key)
})
it('should send media after close', async () => {
const content = await fs.readFile('./Media/octopus.webp')
await sendAndRetrieveMessage(conn, content, MessageType.sticker)
conn.close ()
await conn.connect ()
const content2 = await fs.readFile('./Media/cat.jpeg')
await sendAndRetrieveMessage(conn, content2, MessageType.image)
})
it('should fail to send a text message', async () => {
const JID = '1234-1234@g.us'
const messageId = generateMessageID()
conn.sendMessage(JID, 'hello', MessageType.text, { messageId })
await new Promise(resolve => (
conn.on ('chat-update', async update => {
console.log(messageId, update.messages?.first)
if (
update.jid === JID &&
update.messages?.first.key.id === messageId &&
update.messages.first.status === WA_MESSAGE_STATUS_TYPE.ERROR) {
resolve(undefined)
}
})
))
conn.removeAllListeners('chat-update')
})
it('should maintain message integrity', async () => {
// loading twice does not alter the results
const results = await Promise.all ([
conn.loadMessages (testJid, 50),
conn.loadMessages (testJid, 50)
])
assert.strictEqual (results[0].messages.length, results[1].messages.length)
for (let i = 0; i < results[1].messages.length;i++) {
assert.deepStrictEqual (
results[0].messages[i].key,
results[1].messages[i].key,
`failed equal at ${i}`
)
}
assert.ok (results[0].messages.length <= 50)
// check if messages match server
let msgs = await conn.fetchMessagesFromWA (testJid, 50)
for (let i = 0; i < results[1].messages.length;i++) {
assert.deepStrictEqual (
results[0].messages[i].key,
msgs[i].key,
`failed equal at ${i}`
)
}
// check with some arbitary cursors
let cursor = results[0].messages.slice(-1)[0].key
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
let {messages} = await conn.loadMessages (testJid, 20, cursor)
for (let i = 0; i < messages.length;i++) {
assert.deepStrictEqual (
messages[i].key,
msgs[i].key,
`failed equal at ${i}`
)
}
for (let i = 0; i < 3;i++) {
cursor = results[0].messages[i].key
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
messages = (await conn.loadMessages (testJid, 20, cursor)).messages
for (let i = 0; i < messages.length;i++) {
assert.deepStrictEqual (messages[i].key, msgs[i].key, `failed equal at ${i}`)
}
cursor = msgs[0].key
msgs = await conn.fetchMessagesFromWA (testJid, 20, cursor)
messages = (await conn.loadMessages (testJid, 20, cursor)).messages
for (let i = 0; i < messages.length;i++) {
assert.deepStrictEqual (messages[i].key, msgs[i].key, `failed equal at ${i}`)
}
}
})
it('should deliver a message', async () => {
const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text)
const waitForUpdate =
promiseTimeout(15000, resolve => {
conn.on('chat-update', update => {
if (update.messages?.first.key.id === response.key.id) {
resolve(update.messages.first)
}
})
}) as Promise<WAMessage>
const m = await waitForUpdate
assert.ok (m.status >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK)
})
})

View File

@@ -1,430 +0,0 @@
import { Presence, ChatModification, delay, newMessagesDB, WA_DEFAULT_EPHEMERAL, MessageType, WAMessage } from '../WAConnection'
import { promises as fs } from 'fs'
import * as assert from 'assert'
import got from 'got'
import { WAConnectionTest, testJid, sendAndRetrieveMessage } from './Common'
WAConnectionTest('Misc', conn => {
it('should tell if someone has an account on WhatsApp', async () => {
const response = await conn.isOnWhatsApp(testJid)
assert.strictEqual(response, true)
const responseFail = await conn.isOnWhatsApp('abcd@s.whatsapp.net')
assert.strictEqual(responseFail, false)
})
it('should return the status', async () => {
const response = await conn.getStatus(testJid)
assert.strictEqual(typeof response.status, 'string')
})
it('should update status', async () => {
const newStatus = 'v cool status'
const waitForEvent = new Promise (resolve => {
conn.on ('contact-update', ({jid, status}) => {
if (jid === conn.user.jid) {
assert.strictEqual (status, newStatus)
conn.removeAllListeners ('contact-update')
resolve(undefined)
}
})
})
const response = await conn.getStatus()
assert.strictEqual(typeof response.status, 'string')
await delay (1000)
await conn.setStatus (newStatus)
const response2 = await conn.getStatus()
assert.strictEqual (response2.status, newStatus)
await waitForEvent
await delay (1000)
await conn.setStatus (response.status) // update back
})
it('should update profile name', async () => {
const newName = 'v cool name'
await delay (1000)
const originalName = conn.user.name!
const waitForEvent = new Promise<void> (resolve => {
conn.on ('contact-update', ({name}) => {
assert.strictEqual (name, newName)
conn.removeAllListeners ('contact-update')
resolve ()
})
})
await conn.updateProfileName (newName)
await waitForEvent
await delay (1000)
assert.strictEqual (conn.user.name, newName)
await delay (1000)
await conn.updateProfileName (originalName) // update back
})
it('should return the stories', async () => {
await conn.getStories()
})
it('should return the profile picture correctly', async () => {
// wait for chats
await new Promise(resolve => (
conn.once('initial-data-received', resolve)
))
const pictures = await Promise.all(
conn.chats.all().slice(0, 15).map(({ jid }) => (
conn.getProfilePicture(jid)
.catch(err => '')
))
)
// pictures should return correctly
const truePictures = pictures.filter(pp => !!pp)
assert.strictEqual(
new Set(truePictures).size,
truePictures.length
)
})
it('should change the profile picture', async () => {
await delay (5000)
const ppUrl = await conn.getProfilePicture(conn.user.jid)
const {rawBody: oldPP} = await got(ppUrl)
const newPP = await fs.readFile('./Media/cat.jpeg')
await conn.updateProfilePicture(conn.user.jid, newPP)
await delay (10000)
await conn.updateProfilePicture (conn.user.jid, oldPP) // revert back
})
it('should send typing indicator', async () => {
const response = await conn.updatePresence(testJid, Presence.composing)
assert.ok(response)
})
it('should change a chat read status', async () => {
const jids = conn.chats.all ().map (c => c.jid)
for (let jid of jids.slice(0, 5)) {
console.log (`changing read status for ${jid}`)
const waitForEvent = new Promise (resolve => {
conn.once ('chat-update', ({jid: tJid, count}) => {
if (jid === tJid) {
assert.ok (count < 0)
resolve(undefined)
}
})
})
await conn.chatRead (jid, 'unread')
await waitForEvent
await delay (5000)
await conn.chatRead (jid, 'read')
}
})
it('should archive & unarchive', async () => {
// wait for chats
await new Promise(resolve => (
conn.once('chats-received', ({ }) => resolve(undefined))
))
const idx = conn.chats.all().findIndex(chat => chat.jid === testJid)
await conn.modifyChat (testJid, ChatModification.archive)
const idx2 = conn.chats.all().findIndex(chat => chat.jid === testJid)
assert.ok(idx < idx2) // should move further down the array
await delay (2000)
await conn.modifyChat (testJid, ChatModification.unarchive)
const idx3 = conn.chats.all().findIndex(chat => chat.jid === testJid)
assert.strictEqual(idx, idx3) // should be back there
})
it('should archive & unarchive on new message', async () => {
// wait for chats
await new Promise(resolve => (
conn.once('chats-received', ({ }) => resolve(undefined))
))
const idx = conn.chats.all().findIndex(chat => chat.jid === testJid)
await conn.modifyChat (testJid, ChatModification.archive)
const idx2 = conn.chats.all().findIndex(chat => chat.jid === testJid)
assert.ok(idx < idx2) // should move further down the array
await delay (2000)
await sendAndRetrieveMessage(conn, 'test', MessageType.text)
// should be unarchived
const idx3 = conn.chats.all().findIndex(chat => chat.jid === testJid)
assert.strictEqual(idx, idx3) // should be back there
})
it('should pin & unpin a chat', async () => {
await conn.modifyChat (testJid, ChatModification.pin)
await delay (2000)
await conn.modifyChat (testJid, ChatModification.unpin)
})
it('should mute & unmute a chat', async () => {
const waitForEvent = new Promise (resolve => {
conn.on ('chat-update', ({jid, mute}) => {
if (jid === testJid ) {
assert.ok (mute)
conn.removeAllListeners ('chat-update')
resolve(undefined)
}
})
})
await conn.modifyChat (testJid, ChatModification.mute, 8*60*60*1000) // 8 hours in the future
await waitForEvent
await delay (2000)
await conn.modifyChat (testJid, ChatModification.unmute)
})
it('should star/unstar messages', async () => {
for (let i = 1; i <= 5; i++) {
await conn.sendMessage(testJid, `Message ${i}`, MessageType.text)
await delay(1000)
}
let response = await conn.loadMessages(testJid, 5)
let starred = response.messages.filter(m => m.starred)
assert.strictEqual(starred.length, 0)
conn.starMessage(response.messages[2].key)
await delay(2000)
conn.starMessage(response.messages[4].key)
await delay(2000)
response = await conn.loadMessages(testJid, 5)
starred = response.messages.filter(m => m.starred)
assert.strictEqual(starred.length, 2)
await delay(2000)
conn.starMessage(response.messages[2].key, 'unstar')
await delay(2000)
response = await conn.loadMessages(testJid, 5)
starred = response.messages.filter(m => m.starred)
assert.strictEqual(starred.length, 1)
})
it('should clear a chat', async () => {
// Uses chat with yourself to avoid losing chats
const selfJid = conn.user.jid
for (let i = 1; i <= 5; i++) {
await conn.sendMessage(selfJid, `Message ${i}`, MessageType.text)
await delay(1000)
}
let response = await conn.loadMessages(selfJid, 50)
const initialCount = response.messages.length
assert.ok(response.messages.length >= 0)
conn.starMessage(response.messages[2].key)
await delay(2000)
conn.starMessage(response.messages[4].key)
await delay(2000)
await conn.modifyChat(selfJid, ChatModification.clear)
await delay(2000)
response = await conn.loadMessages(selfJid, 50)
await delay(2000)
assert.ok(response.messages.length < initialCount)
assert.ok(response.messages.length > 1)
await conn.modifyChat(selfJid, ChatModification.clear, true)
await delay(2000)
response = await conn.loadMessages(selfJid, 50)
assert.strictEqual(response.messages.length, 1)
})
it('should return search results', async () => {
const jids = [null, testJid]
for (let i in jids) {
let response = await conn.searchMessages('Hello', jids[i], 25, 1)
assert.ok (response.messages)
assert.ok (response.messages.length >= 0)
response = await conn.searchMessages('剛剛試咗😋一個字', jids[i], 25, 1)
assert.ok (response.messages)
assert.ok (response.messages.length >= 0)
}
})
it('should load a single message', async () => {
const {messages} = await conn.loadMessages (testJid, 25)
for (var message of messages) {
const loaded = await conn.loadMessage (testJid, message.key.id)
assert.strictEqual (loaded.key.id, message.key.id, `loaded message ${JSON.stringify(message)} incorrectly`)
await delay (500)
}
})
// open the other phone and look at the updates to really verify stuff
it('should send presence updates', async () => {
conn.shouldLogMessages = true
conn.requestPresenceUpdate(testJid)
const sequence = [ Presence.available, Presence.composing, Presence.paused, Presence.recording, Presence.paused, Presence.unavailable ]
for (const presence of sequence) {
await delay(5000)
await conn.updatePresence(presence !== Presence.unavailable ? testJid : null, presence)
//console.log(conn.messageLog.slice(-1))
console.log('sent update ', presence)
}
})
it('should generate link previews correctly', async () => {
await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys')
// two links should fail
await assert.rejects (
conn.generateLinkPreview ('I sent links to https://teachyourselfcs.com/ and https://www.fast.ai/')
)
})
// this test requires quite a few messages with the test JID
it('should detect overlaps and clear messages accordingly', async () => {
// wait for chats
await new Promise(resolve => (
conn.once('initial-data-received', resolve)
))
conn.maxCachedMessages = 100
const chat = conn.chats.get(testJid)
const oldCount = chat.messages.length
console.log(`test chat has ${oldCount} pre-loaded messages`)
// load 100 messages
await conn.loadMessages(testJid, 100, undefined)
assert.strictEqual(chat.messages.length, 100)
conn.close()
// remove all latest messages
chat.messages = newMessagesDB( chat.messages.all().slice(0, 20) )
const task = new Promise(resolve => (
conn.on('initial-data-received', ({ chatsWithMissingMessages }) => {
assert.strictEqual(Object.keys(chatsWithMissingMessages).length, 1)
const missing = chatsWithMissingMessages.find(({ jid }) => jid === testJid)
assert.ok(missing, 'missing message not detected')
assert.strictEqual(
conn.chats.get(testJid).messages.length,
missing.count
)
assert.strictEqual(missing.count, oldCount)
resolve(undefined)
})
))
await conn.connect()
await task
})
it('should toggle disappearing messages', async () => {
let chat = conn.chats.get(testJid)
if (!chat) {
// wait for chats
await new Promise(resolve => (
conn.once('chats-received', resolve)
))
chat = conn.chats.get(testJid)
}
const waitForChatUpdate = (ephemeralOn: boolean) => (
new Promise(resolve => (
conn.on('chat-update', ({ jid, ephemeral }) => {
if (jid === testJid && typeof ephemeral !== 'undefined') {
assert.strictEqual(!!(+ephemeral), ephemeralOn)
assert.strictEqual(!!(+chat.ephemeral), ephemeralOn)
resolve(undefined)
conn.removeAllListeners('chat-update')
}
})
))
)
const toggleDisappearingMessages = async (on: boolean) => {
const update = waitForChatUpdate(on)
await conn.toggleDisappearingMessages(testJid, on ? WA_DEFAULT_EPHEMERAL : 0)
await update
}
if (!chat.eph_setting_ts) {
await toggleDisappearingMessages(true)
}
await delay(1000)
let msg = await sendAndRetrieveMessage(
conn,
'This will go poof 😱',
MessageType.text
)
assert.ok(msg.message?.ephemeralMessage)
const contextInfo = msg.message?.ephemeralMessage?.message?.extendedTextMessage?.contextInfo
assert.strictEqual(contextInfo.expiration, chat.ephemeral)
assert.strictEqual(+contextInfo.ephemeralSettingTimestamp, +chat.eph_setting_ts)
// test message deletion
await conn.deleteMessage(testJid, msg.key)
await delay(1000)
await toggleDisappearingMessages(false)
await delay(1000)
msg = await sendAndRetrieveMessage(
conn,
'This will not go poof 😔',
MessageType.text
)
assert.ok(msg.message.extendedTextMessage)
})
it('should block & unblock a user', async () => {
const blockedCount = conn.blocklist.length;
const waitForEventAdded = new Promise<void> (resolve => {
conn.once ('blocklist-update', ({added}) => {
assert.ok (added.length)
resolve ()
})
})
await conn.blockUser (testJid, 'add')
assert.strictEqual(conn.blocklist.length, blockedCount + 1);
await waitForEventAdded
await delay (2000)
const waitForEventRemoved = new Promise<void> (resolve => {
conn.once ('blocklist-update', ({removed}) => {
assert.ok (removed.length)
resolve ()
})
})
await conn.blockUser (testJid, 'remove')
assert.strictEqual(conn.blocklist.length, blockedCount);
await waitForEventRemoved
})
it('should exit an invalid query', async () => {
// try and send an already sent message
let msg: WAMessage
await conn.findMessage(testJid, 5, m => {
if(m.key.fromMe) {
msg = m
return true
}
})
try {
await conn.relayWAMessage(msg)
assert.fail('should not have sent')
} catch(error) {
assert.strictEqual(error.status, 422)
}
})
})

View File

@@ -1,111 +0,0 @@
import { strict as assert } from 'assert'
import { Mutex } from '../WAConnection/Mutex'
const DEFAULT_WAIT = 1000
class MyClass {
didDoWork = false
values: { [k: string]: number } = {}
counter = 0
@Mutex ()
async myFunction () {
if (this.didDoWork) return
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
if (this.didDoWork) {
throw new Error ('work already done')
}
this.didDoWork = true
}
@Mutex (key => key)
async myKeyedFunction (key: string) {
if (!this.values[key]) {
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
if (this.values[key]) throw new Error ('value already set for ' + key)
this.values[key] = Math.floor(Math.random ()*100)
}
return this.values[key]
}
@Mutex (key => key)
async myQueingFunction (key: string) {
await new Promise (resolve => setTimeout(resolve, DEFAULT_WAIT))
}
@Mutex ()
async myErrorFunction () {
await new Promise (resolve => setTimeout(resolve, 100))
this.counter += 1
if (this.counter % 2 === 0) {
throw new Error ('failed')
}
}
}
describe ('garbage', () => {
it ('should only execute once', async () => {
const stuff = new MyClass ()
const start = new Date ()
await Promise.all ([...Array(1000)].map(() => stuff.myFunction ()))
const diff = new Date ().getTime()-start.getTime()
assert.ok (diff < DEFAULT_WAIT*1.25)
})
it ('should only execute once based on the key', async () => {
const stuff = new MyClass ()
const start = new Date ()
/*
In this test, the mutex will lock the function based on the key.
So, if the function with argument `key1` is underway
and another function with key `key1` is called,
the call is blocked till the first function completes.
However, if argument `key2` is passed, the function is allowed to pass.
*/
const keys = ['key1', 'key2', 'key3']
const duplicates = 1000
const results = await Promise.all (
keys.flatMap (key => (
[...Array(duplicates)].map(() => stuff.myKeyedFunction (key))
))
)
assert.deepStrictEqual (
results.slice(0, duplicates).filter (r => r !== results[0]),
[]
)
const diff = new Date ().getTime()-start.getTime()
assert.ok (diff < DEFAULT_WAIT*1.25)
})
it ('should execute operations in a queue', async () => {
const stuff = new MyClass ()
const start = new Date ()
const keys = ['key1', 'key2', 'key3']
await Promise.all (
keys.flatMap (key => (
[...Array(2)].map(() => stuff.myQueingFunction (key))
))
)
const diff = new Date ().getTime()-start.getTime()
assert.ok (diff < DEFAULT_WAIT*2.2 && diff > DEFAULT_WAIT*1.5)
})
it ('should throw an error on selected items', async () => {
const stuff = new MyClass ()
const start = new Date ()
const WAIT = 100
const FUNCS = 40
const results = await Promise.all (
[...Array(FUNCS)].map(() => stuff.myErrorFunction ().catch(err => err.message))
)
const diff = new Date ().getTime()-start.getTime()
assert.ok (diff < WAIT*FUNCS*1.1)
assert.strictEqual (
results.filter (r => r === 'failed').length,
FUNCS/2 // half should fail
)
})
})

View File

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

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

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

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

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

View 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

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

View File

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

View File

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