finalize multi-device

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

View File

@@ -1,4 +0,0 @@
yarn pbjs -t static-module -w commonjs -o ./WAMessage/index.js ./src/BinaryNode/WAMessage.proto;
yarn pbts -o ./WAMessage/index.d.ts ./WAMessage/index.js;
#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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +0,0 @@
import { proto } from '../../WAMessage'
import { BinaryNode, DoubleByteTokens, SingleByteTokens, Tags } from './types'
function decode<T extends BinaryNode>(buffer: Buffer, makeNode: () => T, indexRef: { index: number }) {
const checkEOS = (length: number) => {
if (indexRef.index + length > buffer.length) {
throw new Error('end of stream')
}
}
const next = () => {
const value = buffer[indexRef.index]
indexRef.index += 1
return value
}
const readByte = () => {
checkEOS(1)
return next()
}
const readStringFromChars = (length: number) => {
checkEOS(length)
const value = buffer.slice(indexRef.index, indexRef.index + length)
indexRef.index += length
return value.toString('utf-8')
}
const readBytes = (n: number) => {
checkEOS(n)
const value = buffer.slice(indexRef.index, indexRef.index + n)
indexRef.index += n
return value
}
const readInt = (n: number, littleEndian = false) => {
checkEOS(n)
let val = 0
for (let i = 0; i < n; i++) {
const shift = littleEndian ? i : n - 1 - i
val |= next() << (shift * 8)
}
return val
}
const readInt20 = () => {
checkEOS(3)
return ((next() & 15) << 16) + (next() << 8) + next()
}
const unpackHex = (value: number) => {
if (value >= 0 && value < 16) {
return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10
}
throw new Error('invalid hex: ' + value)
}
const unpackNibble = (value: number) => {
if (value >= 0 && value <= 9) {
return '0'.charCodeAt(0) + value
}
switch (value) {
case 10:
return '-'.charCodeAt(0)
case 11:
return '.'.charCodeAt(0)
case 15:
return '\0'.charCodeAt(0)
default:
throw new Error('invalid nibble: ' + value)
}
}
const unpackByte = (tag: number, value: number) => {
if (tag === Tags.NIBBLE_8) {
return unpackNibble(value)
} else if (tag === Tags.HEX_8) {
return unpackHex(value)
} else {
throw new Error('unknown tag: ' + tag)
}
}
const readPacked8 = (tag: number) => {
const startByte = readByte()
let value = ''
for (let i = 0; i < (startByte & 127); i++) {
const curByte = readByte()
value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4))
value += String.fromCharCode(unpackByte(tag, curByte & 0x0f))
}
if (startByte >> 7 !== 0) {
value = value.slice(0, -1)
}
return value
}
const isListTag = (tag: number) => {
return tag === Tags.LIST_EMPTY || tag === Tags.LIST_8 || tag === Tags.LIST_16
}
const readListSize = (tag: number) => {
switch (tag) {
case Tags.LIST_EMPTY:
return 0
case Tags.LIST_8:
return readByte()
case Tags.LIST_16:
return readInt(2)
default:
throw new Error('invalid tag for list size: ' + tag)
}
}
const getToken = (index: number) => {
if (index < 3 || index >= SingleByteTokens.length) {
throw new Error('invalid token index: ' + index)
}
return SingleByteTokens[index]
}
const readString = (tag: number) => {
if (tag >= 3 && tag <= 235) {
const token = getToken(tag)
return token// === 's.whatsapp.net' ? 'c.us' : token
}
switch (tag) {
case Tags.DICTIONARY_0:
case Tags.DICTIONARY_1:
case Tags.DICTIONARY_2:
case Tags.DICTIONARY_3:
return getTokenDouble(tag - Tags.DICTIONARY_0, readByte())
case Tags.LIST_EMPTY:
return null
case Tags.BINARY_8:
return readStringFromChars(readByte())
case Tags.BINARY_20:
return readStringFromChars(readInt20())
case Tags.BINARY_32:
return readStringFromChars(readInt(4))
case Tags.JID_PAIR:
const i = readString(readByte())
const j = readString(readByte())
if (typeof i === 'string' && j) {
return i + '@' + j
}
throw new Error('invalid jid pair: ' + i + ', ' + j)
case Tags.HEX_8:
case Tags.NIBBLE_8:
return readPacked8(tag)
default:
throw new Error('invalid string with tag: ' + tag)
}
}
const readList = (tag: number) => (
[...new Array(readListSize(tag))].map(() => decode(buffer, makeNode, indexRef))
)
const getTokenDouble = (index1: number, index2: number) => {
const n = 256 * index1 + index2
if (n < 0 || n > DoubleByteTokens.length) {
throw new Error('invalid double token index: ' + n)
}
return DoubleByteTokens[n]
}
const node = makeNode()
const listSize = readListSize(readByte())
const descrTag = readByte()
if (descrTag === Tags.STREAM_END) {
throw new Error('unexpected stream end')
}
node.header = readString(descrTag)
if (listSize === 0 || !node.header) {
throw new Error('invalid node')
}
// read the attributes in
const attributesLength = (listSize - 1) >> 1
for (let i = 0; i < attributesLength; i++) {
const key = readString(readByte())
const b = readByte()
node.attributes[key] = readString(b)
}
if (listSize % 2 === 0) {
const tag = readByte()
if (isListTag(tag)) {
node.data = readList(tag)
} else {
let decoded: Buffer | string
switch (tag) {
case Tags.BINARY_8:
decoded = readBytes(readByte())
break
case Tags.BINARY_20:
decoded = readBytes(readInt20())
break
case Tags.BINARY_32:
decoded = readBytes(readInt(4))
break
default:
decoded = readString(tag)
break
}
if (node.header === 'message' && Buffer.isBuffer(decoded)) {
node.data = proto.WebMessageInfo.decode(decoded)
} else {
node.data = decoded
}
}
}
return node
}
export default decode

View File

@@ -1,123 +0,0 @@
import { proto } from "../../WAMessage";
import { BinaryNode, SingleByteTokens, Tags } from "./types";
const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = []) => {
const pushByte = (value: number) => buffer.push(value & 0xff)
const pushInt = (value: number, n: number, littleEndian=false) => {
for (let i = 0; i < n; i++) {
const curShift = littleEndian ? i : n - 1 - i
buffer.push((value >> (curShift * 8)) & 0xff)
}
}
const pushBytes = (bytes: Uint8Array | Buffer | number[]) => (
bytes.forEach (b => buffer.push(b))
)
const pushInt20 = (value: number) => (
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
)
const writeByteLength = (length: number) => {
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
if (length >= 1 << 20) {
pushByte(Tags.BINARY_32)
pushInt(length, 4) // 32 bit integer
} else if (length >= 256) {
pushByte(Tags.BINARY_20)
pushInt20(length)
} else {
pushByte(Tags.BINARY_8)
pushByte(length)
}
}
const writeStringRaw = (str: string) => {
const bytes = Buffer.from (str, 'utf-8')
writeByteLength(bytes.length)
pushBytes(bytes)
}
const writeToken = (token: number) => {
if (token < 245) {
pushByte(token)
} else if (token <= 500) {
throw new Error('invalid token')
}
}
const writeString = (token: string, i?: boolean) => {
if (token === 'c.us') token = 's.whatsapp.net'
const tokenIndex = SingleByteTokens.indexOf(token)
if (!i && token === 's.whatsapp.net') {
writeToken(tokenIndex)
} else if (tokenIndex >= 0) {
if (tokenIndex < Tags.SINGLE_BYTE_MAX) {
writeToken(tokenIndex)
} else {
const overflow = tokenIndex - Tags.SINGLE_BYTE_MAX
const dictionaryIndex = overflow >> 8
if (dictionaryIndex < 0 || dictionaryIndex > 3) {
throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex)
}
writeToken(Tags.DICTIONARY_0 + dictionaryIndex)
writeToken(overflow % 256)
}
} else if (token) {
const jidSepIndex = token.indexOf('@')
if (jidSepIndex <= 0) {
writeStringRaw(token)
} else {
writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length))
}
}
}
const writeJid = (left: string, right: string) => {
pushByte(Tags.JID_PAIR)
left && left.length > 0 ? writeString(left) : writeToken(Tags.LIST_EMPTY)
writeString(right)
}
const writeListStart = (listSize: number) => {
if (listSize === 0) {
pushByte(Tags.LIST_EMPTY)
} else if (listSize < 256) {
pushBytes([Tags.LIST_8, listSize])
} else {
pushBytes([Tags.LIST_16, listSize])
}
}
const validAttributes = Object.keys(attributes).filter(k => (
typeof attributes[k] !== 'undefined' && attributes[k] !== null
))
writeListStart(2*validAttributes.length + 1 + (typeof data !== 'undefined' && data !== null ? 1 : 0))
writeString(header)
validAttributes.forEach((key) => {
if(typeof attributes[key] === 'string') {
writeString(key)
writeString(attributes[key])
}
})
if(data instanceof proto.WebMessageInfo && !Buffer.isBuffer(data)) {
data = Buffer.from(proto.WebMessageInfo.encode(data).finish())
}
if (typeof data === 'string') {
writeString(data, true)
} else if (Buffer.isBuffer(data)) {
writeByteLength(data.length)
pushBytes(data)
} else if (Array.isArray(data)) {
writeListStart(data.length)
for(const item of data) {
if(item) encode(item, buffer)
}
} else if(typeof data === 'undefined' || data === null) {
} else {
throw new Error(`invalid children for header "${header}": ${data} (${typeof data})`)
}
return Buffer.from(buffer)
}
export default encode

View File

@@ -1,8 +0,0 @@
import decode from './decode'
import encode from './encode'
import { BinaryNode as BinaryNodeType } from './types'
export default class BinaryNode extends BinaryNodeType {
toBuffer = () => encode(this, [])
static from = (buffer: Buffer) => decode(buffer, () => new BinaryNode(), { index: 0 })
}

View File

@@ -1,212 +0,0 @@
import { proto } from "../../WAMessage"
export type Attributes = { [key: string]: string }
export type BinaryNodeData = BinaryNode[] | string | Buffer | proto.IWebMessageInfo | undefined
export class BinaryNode {
header: string
attributes: Attributes = {}
data?: BinaryNodeData
constructor(header?: string, attrs?: Attributes, data?: BinaryNodeData) {
this.header = header
this.attributes = attrs || {}
this.data = data
}
}
export const Tags = {
LIST_EMPTY: 0,
STREAM_END: 2,
DICTIONARY_0: 236,
DICTIONARY_1: 237,
DICTIONARY_2: 238,
DICTIONARY_3: 239,
LIST_8: 248,
LIST_16: 249,
JID_PAIR: 250,
HEX_8: 251,
BINARY_8: 252,
BINARY_20: 253,
BINARY_32: 254,
NIBBLE_8: 255,
SINGLE_BYTE_MAX: 256,
PACKED_MAX: 254,
}
export const DoubleByteTokens = []
export const SingleByteTokens = [
null,
null,
null,
'200',
'400',
'404',
'500',
'501',
'502',
'action',
'add',
'after',
'archive',
'author',
'available',
'battery',
'before',
'body',
'broadcast',
'chat',
'clear',
'code',
'composing',
'contacts',
'count',
'create',
'debug',
'delete',
'demote',
'duplicate',
'encoding',
'error',
'false',
'filehash',
'from',
'g.us',
'group',
'groups_v2',
'height',
'id',
'image',
'in',
'index',
'invis',
'item',
'jid',
'kind',
'last',
'leave',
'live',
'log',
'media',
'message',
'mimetype',
'missing',
'modify',
'name',
'notification',
'notify',
'out',
'owner',
'participant',
'paused',
'picture',
'played',
'presence',
'preview',
'promote',
'query',
'raw',
'read',
'receipt',
'received',
'recipient',
'recording',
'relay',
'remove',
'response',
'resume',
'retry',
's.whatsapp.net',
'seconds',
'set',
'size',
'status',
'subject',
'subscribe',
't',
'text',
'to',
'true',
'type',
'unarchive',
'unavailable',
'url',
'user',
'value',
'web',
'width',
'mute',
'read_only',
'admin',
'creator',
'short',
'update',
'powersave',
'checksum',
'epoch',
'block',
'previous',
'409',
'replaced',
'reason',
'spam',
'modify_tag',
'message_info',
'delivery',
'emoji',
'title',
'description',
'canonical-url',
'matched-text',
'star',
'unstar',
'media_key',
'filename',
'identity',
'unread',
'page',
'page_count',
'search',
'media_message',
'security',
'call_log',
'profile',
'ciphertext',
'invite',
'gif',
'vcard',
'frequent',
'privacy',
'blacklist',
'whitelist',
'verify',
'location',
'document',
'elapsed',
'revoke_invite',
'expiration',
'unsubscribe',
'disable',
'vname',
'old_jid',
'new_jid',
'announcement',
'locked',
'prop',
'label',
'color',
'call',
'offer',
'call-id',
'quick_reply',
'sticker',
'pay_t',
'accept',
'reject',
'sticker_pack',
'invalid',
'canceled',
'missed',
'connected',
'result',
'audio',
'video',
'recent',
]

View File

@@ -1,41 +0,0 @@
import fs from 'fs'
import { decodeWAMessage } from './Utils/decode-wa-message'
interface BrowserMessagesInfo {
bundle: { encKey: string, macKey: string }
harFilePath: string
}
interface WSMessage {
type: 'send' | 'receive',
data: string
}
const file = fs.readFileSync ('./browser-messages.json', {encoding: 'utf-8'})
const json: BrowserMessagesInfo = JSON.parse (file)
const encKey = Buffer.from (json.bundle.encKey, 'base64')
const macKey = Buffer.from (json.bundle.macKey, 'base64')
const harFile = JSON.parse ( fs.readFileSync( json.harFilePath , {encoding: 'utf-8'}))
const entries = harFile['log']['entries']
let wsMessages: WSMessage[] = []
entries.forEach ((e, i) => {
if ('_webSocketMessages' in e) {
wsMessages.push (...e['_webSocketMessages'])
}
})
const decrypt = (buffer, fromMe) => decodeWAMessage(buffer, { macKey, encKey }, fromMe)
console.log ('parsing ' + wsMessages.length + ' messages')
const list = wsMessages.map ((item, i) => {
const buffer = item.data.includes(',') ? item.data : Buffer.from (item.data, 'base64')
try {
const [tag, json, binaryTags] = decrypt (buffer, item.type === 'send')
return {tag, json: json && JSON.stringify(json), binaryTags}
} catch (error) {
return { error: error.message, data: buffer.toString('utf-8') }
}
})
.filter (Boolean)
const str = JSON.stringify (list, null, '\t')
fs.writeFileSync ('decoded-ws.json', str)

View File

@@ -1,275 +0,0 @@
import { Boom } from '@hapi/boom'
import EventEmitter from "events"
import * as Curve from 'curve25519-js'
import { BaileysEventEmitter, BaileysEventMap, SocketConfig, CurveKeyPair, WAInitResponse, ConnectionState, DisconnectReason } from "../Types"
import { makeSocket } from "./socket"
import { generateClientID, promiseTimeout, normalizedAuthInfo, computeChallengeResponse, validateNewConnection } from "../Utils"
import { randomBytes } from "crypto"
import { AuthenticationCredentials } from "../Types"
const makeAuthSocket = (config: SocketConfig) => {
const {
logger,
version,
browser,
connectTimeoutMs,
pendingRequestTimeoutMs,
maxQRCodes,
printQRInTerminal,
credentials: anyAuthInfo
} = config
const ev = new EventEmitter() as BaileysEventEmitter
let authInfo = normalizedAuthInfo(anyAuthInfo) ||
// generate client id if not there
{ clientID: generateClientID() } as AuthenticationCredentials
const state: ConnectionState = {
phoneConnected: false,
connection: 'connecting',
}
const socket = makeSocket({
...config,
phoneConnectionChanged: phoneConnected => {
if(phoneConnected !== state.phoneConnected) {
updateState({ phoneConnected })
}
}
})
const { ws } = socket
let curveKeys: CurveKeyPair
let initTimeout: NodeJS.Timeout
// add close listener
ws.on('ws-close', (error: Boom | Error) => {
logger.info({ error }, 'Closed connection to WhatsApp')
initTimeout && clearTimeout(initTimeout)
// if no reconnects occur
// send close event
updateState({
connection: 'close',
qr: undefined,
connectionTriesLeft: undefined,
lastDisconnect: {
error,
date: new Date()
}
})
})
/** Can you login to WA without scanning the QR */
const canLogin = () => !!authInfo?.encKey && !!authInfo?.macKey
const updateState = (update: Partial<ConnectionState>) => {
Object.assign(state, update)
ev.emit('connection.update', update)
}
/**
* Logs you out from WA
* If connected, invalidates the credentials with the server
*/
const logout = async() => {
if(state.connection === 'open') {
await socket.sendMessage({
json: ['admin', 'Conn', 'disconnect'],
tag: 'goodbye'
})
}
// will call state update to close connection
socket?.end(
new Boom('Logged Out', { statusCode: DisconnectReason.credentialsInvalidated })
)
authInfo = undefined
}
/** Waits for the connection to WA to open up */
const waitForConnection = async(waitInfinitely: boolean = false) => {
if(state.connection === 'open') return
let listener: (item: BaileysEventMap['connection.update']) => void
const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs
if(timeout < 0) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
await (
promiseTimeout(
timeout,
(resolve, reject) => {
listener = ({ connection, lastDisconnect }) => {
if(connection === 'open') resolve()
else if(connection == 'close') {
reject(lastDisconnect.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
}
}
ev.on('connection.update', listener)
}
)
.finally(() => (
ev.off('connection.update', listener)
))
)
}
const updateEncKeys = () => {
// update the keys so we can decrypt traffic
socket.updateKeys({ encKey: authInfo!.encKey, macKey: authInfo!.macKey })
}
const generateKeysForAuth = async(ref: string, ttl?: number) => {
curveKeys = Curve.generateKeyPair(randomBytes(32))
const publicKey = Buffer.from(curveKeys.public).toString('base64')
let qrGens = 0
const qrLoop = ttl => {
const qr = [ref, publicKey, authInfo.clientID].join(',')
updateState({ qr })
initTimeout = setTimeout(async () => {
if(state.connection !== 'connecting') return
logger.debug('regenerating QR')
try {
if(qrGens >= maxQRCodes) {
throw new Boom(
'Too many QR codes',
{ statusCode: 429 }
)
}
// request new QR
const {ref: newRef, ttl: newTTL} = await socket.query({
json: ['admin', 'Conn', 'reref'],
expect200: true,
longTag: true,
requiresPhoneConnection: false
})
ttl = newTTL
ref = newRef
} catch (error) {
logger.error({ error }, `error in QR gen`)
if (error.output?.statusCode === 429) { // too many QR requests
socket.end(error)
return
}
}
qrGens += 1
qrLoop(ttl)
}, ttl || 20_000) // default is 20s, on the off-chance ttl is not present
}
qrLoop(ttl)
}
const onOpen = async() => {
const canDoLogin = canLogin()
const initQuery = (async () => {
const {ref, ttl} = await socket.query({
json: ['admin', 'init', version, browser, authInfo.clientID, true],
expect200: true,
longTag: true,
requiresPhoneConnection: false
}) as WAInitResponse
if (!canDoLogin) {
generateKeysForAuth(ref, ttl)
}
})();
let loginTag: string
if(canDoLogin) {
updateEncKeys()
// if we have the info to restore a closed session
const json = [
'admin',
'login',
authInfo.clientToken,
authInfo.serverToken,
authInfo.clientID,
'takeover'
]
loginTag = socket.generateMessageTag(true)
// send login every 10s
const sendLoginReq = () => {
if(state.connection === 'open') {
logger.warn('Received login timeout req when state=open, ignoring...')
return
}
logger.info('sending login request')
socket.sendMessage({
json,
tag: loginTag
})
initTimeout = setTimeout(sendLoginReq, 10_000)
}
sendLoginReq()
}
await initQuery
// wait for response with tag "s1"
let response = await Promise.race(
[
socket.waitForMessage('s1', false, undefined),
...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs)] : [])
]
)
initTimeout && clearTimeout(initTimeout)
initTimeout = undefined
if(response.status && response.status !== 200) {
throw new Boom(`Unexpected error in login`, { data: response, statusCode: response.status })
}
// if its a challenge request (we get it when logging in)
if(response[1]?.challenge) {
const json = computeChallengeResponse(response[1].challenge, authInfo)
logger.info('resolving login challenge')
await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs })
response = await socket.waitForMessage('s2', true)
}
// validate the new connection
const {user, auth, phone} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
const isNewLogin = user.jid !== state.user?.jid
authInfo = auth
updateEncKeys()
logger.info({ user }, 'logged in')
updateState({
connection: 'open',
phoneConnected: true,
user,
isNewLogin,
phoneInfo: phone,
connectionTriesLeft: undefined,
qr: undefined
})
ev.emit('credentials.update', auth)
}
ws.once('open', async() => {
try {
await onOpen()
} catch(error) {
socket.end(error)
}
})
if(printQRInTerminal) {
ev.on('connection.update', async({ qr }) => {
if(qr) {
const QR = await import('qrcode-terminal').catch(err => {
logger.error('QR code terminal not added as dependency')
})
QR?.generate(qr, { small: true })
}
})
}
return {
...socket,
ev,
getState: () => state,
getAuthInfo: () => authInfo,
waitForConnection,
canLogin,
logout
}
}
export default makeAuthSocket

View File

@@ -1,478 +0,0 @@
import BinaryNode from "../BinaryNode";
import { Chat, Contact, Presence, PresenceData, SocketConfig, WAFlag, WAMetric, WABusinessProfile, ChatModification, WAMessageKey, WAMessage, WAMessageUpdate, BaileysEventMap } from "../Types";
import { debouncedTimeout, unixTimestampSeconds, whatsappID } from "../Utils/generics";
import makeAuthSocket from "./auth";
import { Attributes, BinaryNode as BinaryNodeBase } from "../BinaryNode/types";
const makeChatsSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeAuthSocket(config)
const {
ev,
ws: socketEvents,
currentEpoch,
setQuery,
query,
sendMessage,
getState
} = sock
const chatsDebounceTimeout = debouncedTimeout(10_000, () => sendChatsQuery(1))
const sendChatsQuery = (epoch: number) => (
sendMessage({
json: new BinaryNode('query', {type: 'chat', epoch: epoch.toString()}),
binaryTag: [ WAMetric.queryChat, WAFlag.ignore ]
})
)
const fetchImageUrl = async(jid: string) => {
const response = await query({
json: ['query', 'ProfilePicThumb', jid],
expect200: false,
requiresPhoneConnection: false
})
return response.eurl as string | undefined
}
const executeChatModification = (node: BinaryNodeBase) => {
const { attributes } = node
const updateType = attributes.type
const jid = whatsappID(attributes?.jid)
switch(updateType) {
case 'delete':
ev.emit('chats.delete', [jid])
break
case 'clear':
if(node.data) {
const ids = (node.data as BinaryNode[]).map(
({ attributes }) => attributes.index
)
ev.emit('messages.delete', { jid, ids })
} else {
ev.emit('messages.delete', { jid, all: true })
}
break
case 'archive':
ev.emit('chats.update', [ { jid, archive: 'true' } ])
break
case 'unarchive':
ev.emit('chats.update', [ { jid, archive: 'false' } ])
break
case 'pin':
ev.emit('chats.update', [ { jid, pin: attributes.pin } ])
break
case 'star':
case 'unstar':
const starred = updateType === 'star'
const updates: WAMessageUpdate[] = (node.data as BinaryNode[]).map(
({ attributes }) => ({
key: {
remoteJid: jid,
id: attributes.index,
fromMe: attributes.owner === 'true'
},
update: { starred }
})
)
ev.emit('messages.update', updates)
break
case 'mute':
if(attributes.mute === '0') {
ev.emit('chats.update', [{ jid, mute: null }])
} else {
ev.emit('chats.update', [{ jid, mute: attributes.mute }])
}
break
default:
logger.warn({ node }, `received unrecognized chat update`)
break
}
}
const applyingPresenceUpdate = (update: Attributes): BaileysEventMap['presence.update'] => {
const jid = whatsappID(update.id)
const participant = whatsappID(update.participant || update.id)
const presence: PresenceData = {
lastSeen: update.t ? +update.t : undefined,
lastKnownPresence: update.type as Presence
}
return { jid, presences: { [participant]: presence } }
}
ev.on('connection.update', async({ connection }) => {
if(connection !== 'open') return
try {
await Promise.all([
sendMessage({
json: new BinaryNode('query', {type: 'contacts', epoch: '1'}),
binaryTag: [ WAMetric.queryContact, WAFlag.ignore ]
}),
sendMessage({
json: new BinaryNode('query', {type: 'status', epoch: '1'}),
binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ]
}),
sendMessage({
json: new BinaryNode('query', {type: 'quick_reply', epoch: '1'}),
binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ]
}),
sendMessage({
json: new BinaryNode('query', {type: 'label', epoch: '1'}),
binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ]
}),
sendMessage({
json: new BinaryNode('query', {type: 'emoji', epoch: '1'}),
binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ]
}),
sendMessage({
json: new BinaryNode(
'action',
{ type: 'set', epoch: '1' },
[
new BinaryNode('presence', {type: 'available'})
]
),
binaryTag: [ WAMetric.presence, WAFlag.available ]
})
])
chatsDebounceTimeout.start()
logger.debug('sent init queries')
} catch(error) {
logger.error(`error in sending init queries: ${error}`)
}
})
socketEvents.on('CB:response,type:chat', async ({ data }: BinaryNode) => {
chatsDebounceTimeout.cancel()
if(Array.isArray(data)) {
const chats = data.map(({ attributes }) => {
return {
...attributes,
jid: whatsappID(attributes.jid),
t: +attributes.t,
count: +attributes.count
} as Chat
})
logger.info(`got ${chats.length} chats`)
ev.emit('chats.set', { chats })
}
})
// got all contacts from phone
socketEvents.on('CB:response,type:contacts', async ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
const contacts = data.map(({ attributes }) => {
const contact = attributes as any as Contact
contact.jid = whatsappID(contact.jid)
return contact
})
logger.info(`got ${contacts.length} contacts`)
ev.emit('contacts.set', { contacts })
}
})
// status updates
socketEvents.on('CB:Status,status', json => {
const jid = whatsappID(json[1].id)
ev.emit('contacts.update', [ { jid, status: json[1].status } ])
})
// User Profile Name Updates
socketEvents.on('CB:Conn,pushname', json => {
const { user, connection } = getState()
if(connection === 'open' && json[1].pushname !== user.name) {
user.name = json[1].pushname
ev.emit('connection.update', { user })
}
})
// read updates
socketEvents.on ('CB:action,,read', async ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
const { attributes } = data[0]
const update: Partial<Chat> = {
jid: whatsappID(attributes.jid)
}
if (attributes.type === 'false') update.count = -1
else update.count = 0
ev.emit('chats.update', [update])
}
})
socketEvents.on('CB:Cmd,type:picture', async json => {
json = json[1]
const jid = whatsappID(json.jid)
const imgUrl = await fetchImageUrl(jid).catch(() => '')
ev.emit('contacts.update', [ { jid, imgUrl } ])
})
// chat archive, pin etc.
socketEvents.on('CB:action,,chat', ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
const [node] = data
executeChatModification(node)
}
})
socketEvents.on('CB:action,,user', (json: BinaryNode) => {
if(Array.isArray(json.data)) {
const user = json.data[0].attributes as any as Contact
user.jid = whatsappID(user.jid)
ev.emit('contacts.upsert', [user])
}
})
// presence updates
socketEvents.on('CB:Presence', json => {
const update = applyingPresenceUpdate(json[1])
ev.emit('presence.update', update)
})
// blocklist updates
socketEvents.on('CB:Blocklist', json => {
json = json[1]
const blocklist = json.blocklist
ev.emit('blocklist.set', { blocklist })
})
return {
...sock,
sendChatsQuery,
fetchImageUrl,
chatRead: async(fromMessage: WAMessageKey, count: number) => {
await setQuery (
[
new BinaryNode(
'read',
{
jid: fromMessage.remoteJid,
count: count.toString(),
index: fromMessage.id,
owner: fromMessage.fromMe ? 'true' : 'false'
}
)
],
[ WAMetric.read, WAFlag.ignore ]
)
ev.emit ('chats.update', [{ jid: fromMessage.remoteJid, count: count < 0 ? -1 : 0 }])
},
/**
* Modify a given chat (archive, pin etc.)
* @param jid the ID of the person/group you are modifiying
*/
modifyChat: async(jid: string, modification: ChatModification, index?: WAMessageKey) => {
let chatAttrs: Attributes = { jid: jid }
let data: BinaryNode[] | undefined = undefined
const stamp = unixTimestampSeconds()
if('archive' in modification) {
chatAttrs.type = modification.archive ? 'archive' : 'unarchive'
} else if('pin' in modification) {
chatAttrs.type = 'pin'
if(typeof modification.pin === 'object') {
chatAttrs.previous = modification.pin.remove.toString()
} else {
chatAttrs.pin = stamp.toString()
}
} else if('mute' in modification) {
chatAttrs.type = 'mute'
if(typeof modification.mute === 'object') {
chatAttrs.previous = modification.mute.remove.toString()
} else {
chatAttrs.mute = (stamp + modification.mute).toString()
}
} else if('clear' in modification) {
chatAttrs.type = 'clear'
chatAttrs.modify_tag = Math.round(Math.random ()*1000000).toString()
if(modification.clear !== 'all') {
data = modification.clear.messages.map(({ id, fromMe }) => (
new BinaryNode(
'item',
{ owner: (!!fromMe).toString(), index: id }
)
))
}
} else if('star' in modification) {
chatAttrs.type = modification.star.star ? 'star' : 'unstar'
data = modification.star.messages.map(({ id, fromMe }) => (
new BinaryNode(
'item',
{ owner: (!!fromMe).toString(), index: id }
)
))
}
if(index) {
chatAttrs.index = index.id
chatAttrs.owner = index.fromMe ? 'true' : 'false'
}
const node = new BinaryNode('chat', chatAttrs, data)
const response = await setQuery([node], [ WAMetric.chat, WAFlag.ignore ])
// apply it and emit events
executeChatModification(node)
return response
},
/**
* Query whether a given number is registered on WhatsApp
* @param str phone number/jid you want to check for
* @returns undefined if the number doesn't exists, otherwise the correctly formatted jid
*/
isOnWhatsApp: async (str: string) => {
const { status, jid, biz } = await query({
json: ['query', 'exist', str],
requiresPhoneConnection: false
})
if (status === 200) {
return {
exists: true,
jid: whatsappID(jid),
isBusiness: biz as boolean
}
}
},
/**
* Tell someone about your presence -- online, typing, offline etc.
* @param jid the ID of the person/group who you are updating
* @param type your presence
*/
updatePresence: (jid: string | undefined, type: Presence) => (
sendMessage({
binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does
json: new BinaryNode(
'action',
{ epoch: currentEpoch().toString(), type: 'set' },
[
new BinaryNode(
'presence',
{ type: type, to: jid }
)
]
)
})
),
/**
* Request updates on the presence of a user
* this returns nothing, you'll receive updates in chats.update event
* */
requestPresenceUpdate: async (jid: string) => (
sendMessage({ json: ['action', 'presence', 'subscribe', jid] })
),
/** Query the status of the person (see groupMetadata() for groups) */
getStatus: async(jid: string) => {
const status: { status: string } = await query({ json: ['query', 'Status', jid], requiresPhoneConnection: false })
return status
},
setStatus: async(status: string) => {
const response = await setQuery(
[
new BinaryNode(
'status',
{},
Buffer.from (status, 'utf-8')
)
]
)
ev.emit('contacts.update', [{ jid: getState().user!.jid, status }])
return response
},
/** Updates business profile. */
updateBusinessProfile: async(profile: WABusinessProfile) => {
if (profile.business_hours?.config) {
profile.business_hours.business_config = profile.business_hours.config
delete profile.business_hours.config
}
const json = ['action', "editBusinessProfile", {...profile, v: 2}]
await query({ json, expect200: true, requiresPhoneConnection: true })
},
updateProfileName: async(name: string) => {
const response = (await setQuery(
[
new BinaryNode(
'profile',
{ name }
)
]
)) as any as {status: number, pushname: string}
if (response.status === 200) {
const user = { ...getState().user!, name }
ev.emit('connection.update', { user })
ev.emit('contacts.update', [{ jid: user.jid, name }])
}
return response
},
/**
* Update the profile picture
* @param jid
* @param img
*/
async updateProfilePicture (jid: string, img: Buffer) {
jid = whatsappID (jid)
const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO
const tag = this.generateMessageTag ()
const query = new BinaryNode(
'picture',
{ jid: jid, id: tag, type: 'set' },
[
new BinaryNode('image', {}, data.img),
new BinaryNode('preview', {}, data.preview)
]
)
const user = getState().user
const { eurl } = await this.setQuery ([query], [WAMetric.picture, 136], tag) as { eurl: string, status: number }
if (jid === user.jid) {
user.imgUrl = eurl
ev.emit('connection.update', { user })
}
ev.emit('contacts.update', [ { jid, imgUrl: eurl } ])
},
/**
* Add or remove user from blocklist
* @param jid the ID of the person who you are blocking/unblocking
* @param type type of operation
*/
blockUser: async(jid: string, type: 'add' | 'remove' = 'add') => {
const json = new BinaryNode(
'block',
{ type },
[ new BinaryNode('user', { jid }) ]
)
await setQuery ([json], [WAMetric.block, WAFlag.ignore])
ev.emit('blocklist.update', { blocklist: [jid], type })
},
/**
* Query Business Profile (Useful for VCards)
* @param jid Business Jid
* @returns profile object or undefined if not business account
*/
getBusinessProfile: async(jid: string) => {
jid = whatsappID(jid)
const {
profiles: [{
profile,
wid
}]
} = await query({
json: [
"query", "businessProfile",
[ { "wid": jid.replace('@s.whatsapp.net', '@c.us') } ],
84
],
expect200: true,
requiresPhoneConnection: false,
})
return {
...profile,
wid: whatsappID(wid)
} as WABusinessProfile
}
}
}
export default makeChatsSocket

View File

@@ -1,237 +0,0 @@
import BinaryNode from "../BinaryNode";
import { SocketConfig, GroupModificationResponse, ParticipantAction, GroupMetadata, WAFlag, WAMetric, WAGroupCreateResponse, GroupParticipant } from "../Types";
import { generateMessageID, unixTimestampSeconds, whatsappID } from "../Utils/generics";
import makeMessagesSocket from "./messages";
const makeGroupsSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeMessagesSocket(config)
const {
ev,
ws: socketEvents,
query,
generateMessageTag,
currentEpoch,
setQuery,
getState
} = sock
/** Generic function for group queries */
const groupQuery = async(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: BinaryNode[]) => {
const tag = generateMessageTag()
const result = await setQuery ([
new BinaryNode(
'group',
{
author: getState().user?.jid,
id: tag,
type: type,
jid: jid,
subject: subject,
},
participants ?
participants.map(jid => (
new BinaryNode('participant', { jid })
)) :
additionalNodes
)
], [WAMetric.group, 136], tag)
return result
}
/** Get the metadata of the group from WA */
const groupMetadataFull = async (jid: string) => {
const metadata = await query({
json: ['query', 'GroupMetadata', jid],
expect200: true
})
metadata.participants = metadata.participants.map(p => (
{ ...p, id: undefined, jid: whatsappID(p.id) }
))
metadata.owner = whatsappID(metadata.owner)
return metadata as GroupMetadata
}
/** Get the metadata (works after you've left the group also) */
const groupMetadataMinimal = async (jid: string) => {
const { attributes, data }:BinaryNode = await query({
json: new BinaryNode(
'query',
{type: 'group', jid: jid, epoch: currentEpoch().toString()}
),
binaryTag: [WAMetric.group, WAFlag.ignore],
expect200: true
})
const participants: GroupParticipant[] = []
let desc: string | undefined
if(Array.isArray(data) && Array.isArray(data[0].data)) {
const nodes = data[0].data
for(const item of nodes) {
if(item.header === 'participant') {
participants.push({
jid: item.attributes.jid,
isAdmin: item.attributes.type === 'admin',
isSuperAdmin: false
})
} else if(item.header === 'description') {
desc = (item.data as Buffer).toString('utf-8')
}
}
}
return {
id: jid,
owner: attributes?.creator,
creator: attributes?.creator,
creation: +attributes?.create,
subject: null,
desc,
participants
} as GroupMetadata
}
socketEvents.on('CB:Chat,cmd:action', (json: BinaryNode) => {
/*const data = json[1].data
if (data) {
const emitGroupParticipantsUpdate = (action: WAParticipantAction) => this.emitParticipantsUpdate
(json[1].id, data[2].participants.map(whatsappID), action)
const emitGroupUpdate = (data: Partial<WAGroupMetadata>) => this.emitGroupUpdate(json[1].id, data)
switch (data[0]) {
case "promote":
emitGroupParticipantsUpdate('promote')
break
case "demote":
emitGroupParticipantsUpdate('demote')
break
case "desc_add":
emitGroupUpdate({ ...data[2], descOwner: data[1] })
break
default:
this.logger.debug({ unhandled: true }, json)
break
}
}*/
})
return {
...sock,
groupMetadata: async(jid: string, minimal: boolean) => {
let result: GroupMetadata
if(minimal) result = await groupMetadataMinimal(jid)
else result = await groupMetadataFull(jid)
return result
},
/**
* Create a group
* @param title like, the title of the group
* @param participants people to include in the group
*/
groupCreate: async (title: string, participants: string[]) => {
const response = await groupQuery('create', null, title, participants) as WAGroupCreateResponse
const gid = response.gid
let metadata: GroupMetadata
try {
metadata = await groupMetadataFull(gid)
} catch (error) {
logger.warn (`error in group creation: ${error}, switching gid & checking`)
// if metadata is not available
const comps = gid.replace ('@g.us', '').split ('-')
response.gid = `${comps[0]}-${+comps[1] + 1}@g.us`
metadata = await groupMetadataFull(gid)
logger.warn (`group ID switched from ${gid} to ${response.gid}`)
}
ev.emit('chats.upsert', [
{
jid: response.gid,
name: title,
t: unixTimestampSeconds(),
count: 0
}
])
return metadata
},
/**
* Leave a group
* @param jid the ID of the group
*/
groupLeave: async (jid: string) => {
await groupQuery('leave', jid)
ev.emit('chats.update', [ { jid, read_only: 'true' } ])
},
/**
* Update the subject of the group
* @param {string} jid the ID of the group
* @param {string} title the new title of the group
*/
groupUpdateSubject: async (jid: string, title: string) => {
await groupQuery('subject', jid, title)
ev.emit('chats.update', [ { jid, name: title } ])
ev.emit('contacts.update', [ { jid, name: title } ])
ev.emit('groups.update', [ { id: jid, subject: title } ])
},
/**
* Update the group description
* @param {string} jid the ID of the group
* @param {string} title the new title of the group
*/
groupUpdateDescription: async (jid: string, description: string) => {
const metadata = await groupMetadataFull(jid)
const node = new BinaryNode(
'description',
{id: generateMessageID(), prev: metadata?.descId},
Buffer.from (description, 'utf-8')
)
const response = await groupQuery ('description', jid, null, null, [node])
ev.emit('groups.update', [ { id: jid, desc: description } ])
return response
},
/**
* Update participants in the group
* @param jid the ID of the group
* @param participants the people to add
*/
groupParticipantsUpdate: async(jid: string, participants: string[], action: ParticipantAction) => {
const result: GroupModificationResponse = await groupQuery(action, jid, null, participants)
const jids = Object.keys(result.participants || {})
ev.emit('group-participants.update', { jid, participants: jids, action })
return jids
},
/** Query broadcast list info */
getBroadcastListInfo: async(jid: string) => {
interface WABroadcastListInfo {
status: number
name: string
recipients?: {id: string}[]
}
const result = await query({
json: ['query', 'contact', jid],
expect200: true,
requiresPhoneConnection: true
}) as WABroadcastListInfo
const metadata: GroupMetadata = {
subject: result.name,
id: jid,
creation: undefined,
owner: getState().user?.jid,
participants: result.recipients!.map(({id}) => (
{ jid: whatsappID(id), isAdmin: false, isSuperAdmin: false }
))
}
return metadata
},
inviteCode: async(jid: string) => {
const response = await sock.query({
json: ['query', 'inviteCode', jid],
expect200: true,
requiresPhoneConnection: false
})
return response.code as string
}
}
}
export default makeGroupsSocket

View File

@@ -1,14 +0,0 @@
import { SocketConfig } from '../Types'
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import _makeConnection from './groups'
// export the last socket layer
const makeConnection = (config: Partial<SocketConfig>) => (
_makeConnection({
...DEFAULT_CONNECTION_CONFIG,
...config
})
)
export type Connection = ReturnType<typeof makeConnection>
export default makeConnection

View File

@@ -1,577 +0,0 @@
import BinaryNode from "../BinaryNode";
import { Boom } from '@hapi/boom'
import { EventEmitter } from 'events'
import { Chat, Presence, WAMessageCursor, SocketConfig, WAMessage, WAMessageKey, ParticipantAction, WAMessageProto, WAMessageStatus, WAMessageStubType, GroupMetadata, AnyMessageContent, MiscMessageGenerationOptions, WAFlag, WAMetric, WAUrlInfo, MediaConnInfo, MessageUpdateType, MessageInfo, MessageInfoUpdate, WAMediaUploadFunction, MediaType, WAMessageUpdate } from "../Types";
import { isGroupID, toNumber, whatsappID, generateWAMessage, decryptMediaMessageBuffer, extractMessageContent } from "../Utils";
import makeChatsSocket from "./chats";
import { DEFAULT_ORIGIN, MEDIA_PATH_MAP, WA_DEFAULT_EPHEMERAL } from "../Defaults";
import got from "got";
const STATUS_MAP = {
read: WAMessageStatus.READ,
message: WAMessageStatus.DELIVERY_ACK,
error: WAMessageStatus.ERROR
} as { [_: string]: WAMessageStatus }
const makeMessagesSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeChatsSocket(config)
const {
ev,
ws: socketEvents,
query,
generateMessageTag,
currentEpoch,
setQuery,
getState
} = sock
let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async(forceGet = false) => {
let media = await mediaConn
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
mediaConn = (async() => {
const {media_conn} = await query({
json: ['query', 'mediaConn'],
requiresPhoneConnection: false
})
media_conn.fetchDate = new Date()
return media_conn as MediaConnInfo
})()
}
return mediaConn
}
const fetchMessagesFromWA = async(
jid: string,
count: number,
cursor?: WAMessageCursor
) => {
let key: WAMessageKey
if(cursor) {
key = 'before' in cursor ? cursor.before : cursor.after
}
const { data }:BinaryNode = await query({
json: new BinaryNode(
'query',
{
epoch: currentEpoch().toString(),
type: 'message',
jid: jid,
kind: !cursor || 'before' in cursor ? 'before' : 'after',
count: count.toString(),
index: key?.id,
owner: key?.fromMe === false ? 'false' : 'true',
}
),
binaryTag: [WAMetric.queryMessages, WAFlag.ignore],
expect200: false,
requiresPhoneConnection: true
})
if(Array.isArray(data)) {
return data.map(data => data.data as WAMessage)
}
return []
}
const updateMediaMessage = async(message: WAMessage) => {
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
if (!content) throw new Boom(
`given message ${message.key.id} is not a media message`,
{ statusCode: 400, data: message }
)
const response: BinaryNode = await query ({
json: new BinaryNode(
'query',
{
type: 'media',
index: message.key.id,
owner: message.key.fromMe ? 'true' : 'false',
jid: message.key.remoteJid,
epoch: currentEpoch().toString()
}
),
binaryTag: [WAMetric.queryMedia, WAFlag.ignore],
expect200: true,
requiresPhoneConnection: true
})
const attrs = response.attributes
Object.keys(attrs).forEach (key => content[key] = attrs[key]) // update message
ev.emit('messages.update', [{ key: message.key, update: { message: message.message } }])
return response
}
const onMessage = (message: WAMessage, type: MessageUpdateType | 'update') => {
const jid = message.key.remoteJid!
// store chat updates in this
const chatUpdate: Partial<Chat> = {
jid,
}
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ { id: jid, ...update } ])
}
if(message.message) {
chatUpdate.t = +toNumber(message.messageTimestamp)
// add to count if the message isn't from me & there exists a message
if(!message.key.fromMe) {
chatUpdate.count = 1
const participant = whatsappID(message.participant || jid)
ev.emit(
'presence.update',
{
jid,
presences: { [participant]: { lastKnownPresence: Presence.available } }
}
)
}
}
const ephemeralProtocolMsg = message.message?.ephemeralMessage?.message?.protocolMessage
if (
ephemeralProtocolMsg &&
ephemeralProtocolMsg.type === WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING
) {
chatUpdate.eph_setting_ts = message.messageTimestamp.toString()
chatUpdate.ephemeral = ephemeralProtocolMsg.ephemeralExpiration.toString()
if(isGroupID(jid)) {
emitGroupUpdate({ ephemeralDuration: ephemeralProtocolMsg.ephemeralExpiration || null })
}
}
const protocolMessage = message.message?.protocolMessage
// if it's a message to delete another message
if (protocolMessage) {
switch (protocolMessage.type) {
case WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE:
const key = protocolMessage.key
const messageStubType = WAMessageStubType.REVOKE
ev.emit('messages.update', [
{
// the key of the deleted message is updated
update: { message: null, key: message.key, messageStubType },
key
}
])
return
default:
break
}
}
// check if the message is an action
if (message.messageStubType) {
const { user } = getState()
//let actor = whatsappID (message.participant)
let participants: string[]
const emitParticipantsUpdate = (action: ParticipantAction) => (
ev.emit('group-participants.update', { jid, participants, action })
)
switch (message.messageStubType) {
case WAMessageStubType.CHANGE_EPHEMERAL_SETTING:
chatUpdate.eph_setting_ts = message.messageTimestamp.toString()
chatUpdate.ephemeral = message.messageStubParameters[0]
if(isGroupID(jid)) {
emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null })
}
break
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
participants = message.messageStubParameters.map (whatsappID)
emitParticipantsUpdate('remove')
// mark the chat read only if you left the group
if (participants.includes(user.jid)) {
chatUpdate.read_only = 'true'
}
break
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
participants = message.messageStubParameters.map (whatsappID)
if (participants.includes(user.jid)) {
chatUpdate.read_only = 'false'
}
emitParticipantsUpdate('add')
break
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ announce })
break
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ restrict })
break
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
case WAMessageStubType.GROUP_CREATE:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
}
}
if(Object.keys(chatUpdate).length > 1) {
ev.emit('chats.update', [chatUpdate])
}
if(type === 'update') {
ev.emit('messages.update', [ { update: message, key: message.key } ])
} else {
ev.emit('messages.upsert', { messages: [message], type })
}
}
const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64 }) => {
// send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false)
let mediaUrl: string
for (let host of uploadInfo.hosts) {
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
const url = `https://${host.hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
try {
const {body: responseText} = await got.post(
url,
{
headers: {
'Content-Type': 'application/octet-stream',
'Origin': DEFAULT_ORIGIN
},
agent: {
https: config.agent
},
body: stream
}
)
const result = JSON.parse(responseText)
mediaUrl = result?.url
if (mediaUrl) break
else {
uploadInfo = await refreshMediaConn(true)
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
}
} catch (error) {
const isLast = host.hostname === uploadInfo.hosts[uploadInfo.hosts.length-1].hostname
logger.debug(`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
}
}
if (!mediaUrl) {
throw new Boom(
'Media upload failed on all hosts',
{ statusCode: 500 }
)
}
return { mediaUrl }
}
/** Query a string to check if it has a url, if it does, return WAUrlInfo */
const generateUrlInfo = async(text: string) => {
const response: BinaryNode = await query({
json: new BinaryNode(
'query',
{
type: 'url',
url: text,
epoch: currentEpoch().toString()
}
),
binaryTag: [26, WAFlag.ignore],
expect200: true,
requiresPhoneConnection: false
})
const urlInfo = { ...response.attributes } as any as WAUrlInfo
if(response && response.data) {
urlInfo.jpegThumbnail = response.data as Buffer
}
return urlInfo
}
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
const relayWAMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => {
const json = new BinaryNode(
'action',
{ epoch: currentEpoch().toString(), type: 'relay' },
[ new BinaryNode('message', {}, message) ]
)
const isMsgToMe = message.key.remoteJid === getState().user?.jid
const flag = isMsgToMe ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
const mID = message.key.id
const finalState = isMsgToMe ? WAMessageStatus.READ : WAMessageStatus.SERVER_ACK
message.status = WAMessageStatus.PENDING
const promise = query({
json,
binaryTag: [WAMetric.message, flag],
tag: mID,
expect200: true,
requiresPhoneConnection: true
})
if(waitForAck) {
await promise
message.status = finalState
} else {
const emitUpdate = (status: WAMessageStatus) => {
message.status = status
ev.emit('messages.update', [ { key: message.key, update: { status } } ])
}
promise
.then(() => emitUpdate(finalState))
.catch(() => emitUpdate(WAMessageStatus.ERROR))
}
onMessage(message, 'append')
}
// messages received
const messagesUpdate = ({ data }: BinaryNode, type: 'prepend' | 'last') => {
if(Array.isArray(data)) {
const messages: WAMessage[] = []
for(let i = data.length-1; i >= 0;i--) {
messages.push(data[i].data as WAMessage)
}
ev.emit('messages.upsert', { messages, type })
}
}
socketEvents.on('CB:action,add:last', json => messagesUpdate(json, 'last'))
socketEvents.on('CB:action,add:unread', json => messagesUpdate(json, 'prepend'))
socketEvents.on('CB:action,add:before', json => messagesUpdate(json, 'prepend'))
// new messages
socketEvents.on('CB:action,add:relay,message', ({data}: BinaryNode) => {
if(Array.isArray(data)) {
for(const { data: msg } of data) {
onMessage(msg as WAMessage, 'notify')
}
}
})
// If a message has been updated (usually called when a video message gets its upload url, or live locations)
socketEvents.on ('CB:action,add:update,message', ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
for(const { data: msg } of data) {
onMessage(msg as WAMessage, 'update')
}
}
})
// message status updates
const onMessageStatusUpdate = ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
const updates: WAMessageUpdate[] = []
for(const { attributes: json } of data) {
const key: WAMessageKey = {
remoteJid: whatsappID(json.jid),
id: json.index,
fromMe: json.owner === 'true'
}
const status = STATUS_MAP[json.type]
if(status) {
updates.push({ key, update: { status } })
} else {
logger.warn({ data }, 'got unknown status update for message')
}
}
ev.emit('messages.update', updates)
}
}
const onMessageInfoUpdate = ([,attributes]: [string,{[_: string]: any}]) => {
let ids = attributes.id as string[] | string
if(typeof ids === 'string') {
ids = [ids]
}
let updateKey: keyof MessageInfoUpdate['update']
switch(attributes.ack.toString()) {
case '2':
updateKey = 'deliveries'
break
case '3':
updateKey = 'reads'
break
default:
logger.warn({ attributes }, `received unknown message info update`)
return
}
const keyPartial = {
remoteJid: whatsappID(attributes.to),
fromMe: whatsappID(attributes.from) === getState().user?.jid,
}
const updates = ids.map<MessageInfoUpdate>(id => ({
key: { ...keyPartial, id },
update: {
[updateKey]: { [whatsappID(attributes.participant)]: new Date(+attributes.t) }
}
}))
ev.emit('message-info.update', updates)
// for individual messages
// it means the message is marked read/delivered
if(!isGroupID(keyPartial.remoteJid)) {
ev.emit('messages.update', ids.map(id => (
{
key: { ...keyPartial, id },
update: {
status: updateKey === 'deliveries' ? WAMessageStatus.DELIVERY_ACK : WAMessageStatus.READ
}
}
)))
}
}
socketEvents.on('CB:action,add:relay,received', onMessageStatusUpdate)
socketEvents.on('CB:action,,received', onMessageStatusUpdate)
socketEvents.on('CB:Msg', onMessageInfoUpdate)
socketEvents.on('CB:MsgInfo', onMessageInfoUpdate)
return {
...sock,
relayWAMessage,
generateUrlInfo,
messageInfo: async(jid: string, messageID: string) => {
const { data }: BinaryNode = await query({
json: new BinaryNode(
'query',
{type: 'message_info', index: messageID, jid: jid, epoch: currentEpoch().toString()}
),
binaryTag: [WAMetric.queryRead, WAFlag.ignore],
expect200: true,
requiresPhoneConnection: true
})
const info: MessageInfo = { reads: {}, deliveries: {} }
if(Array.isArray(data)) {
for(const { header, data: innerData } of data) {
const [{ attributes }] = (innerData as BinaryNode[])
const jid = whatsappID(attributes.jid)
const date = new Date(+attributes.t * 1000)
switch(header) {
case 'read':
info.reads[jid] = date
break
case 'delivery':
info.deliveries[jid] = date
break
}
}
}
return info
},
downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => {
const downloadMediaMessage = async () => {
let mContent = extractMessageContent(message.message)
if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
const stream = await decryptMediaMessageBuffer(mContent)
if(type === 'buffer') {
let buffer = Buffer.from([])
for await(const chunk of stream) {
buffer = Buffer.concat([buffer, chunk])
}
return buffer
}
return stream
}
try {
const result = await downloadMediaMessage()
return result
} catch (error) {
if(error.message.includes('404')) { // media needs to be updated
logger.info (`updating media of message: ${message.key.id}`)
await updateMediaMessage(message)
const result = await downloadMediaMessage()
return result
}
throw error
}
},
updateMediaMessage,
fetchMessagesFromWA,
/** Load a single message specified by the ID */
loadMessageFromWA: async(jid: string, id: string) => {
let message: WAMessage
// load the message before the given message
let messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: true} }))
if(!messages[0]) messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: false} }))
// the message after the loaded message is the message required
const [actual] = await fetchMessagesFromWA(jid, 1, { after: messages[0] && messages[0].key })
message = actual
return message
},
searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => {
const {data, attributes}: BinaryNode = await query({
json: new BinaryNode(
'query',
{
epoch: currentEpoch().toString(),
type: 'search',
search: txt,
count: count.toString(),
page: page.toString(),
jid: inJid
}
),
binaryTag: [24, WAFlag.ignore],
expect200: true
}) // encrypt and send off
const messages = Array.isArray(data) ? data.map(item => item.data as WAMessage) : []
return {
last: attributes?.last === 'true',
messages
}
},
sendWAMessage: async(
jid: string,
content: AnyMessageContent,
options: MiscMessageGenerationOptions & { waitForAck?: boolean }
) => {
const userJid = getState().user?.jid
if(
typeof content === 'object' &&
'disappearingMessagesInChat' in content &&
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
isGroupID(jid)
) {
const { disappearingMessagesInChat } = content
const value = typeof disappearingMessagesInChat === 'boolean' ?
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
disappearingMessagesInChat
const tag = generateMessageTag(true)
await setQuery([
new BinaryNode(
'group',
{ id: tag, jid, type: 'prop', author: userJid },
[ new BinaryNode('ephemeral', { value: value.toString() }) ]
)
], [WAMetric.group, WAFlag.other], tag)
} else {
const msg = await generateWAMessage(
jid,
content,
{
...options,
logger,
userJid: userJid,
getUrlInfo: generateUrlInfo,
upload: waUploadToServer
}
)
await relayWAMessage(msg, { waitForAck: options.waitForAck })
return msg
}
}
}
}
export default makeMessagesSocket

View File

@@ -1,367 +0,0 @@
import { Boom } from '@hapi/boom'
import { STATUS_CODES } from "http"
import { promisify } from "util"
import WebSocket from "ws"
import BinaryNode from "../BinaryNode"
import { DisconnectReason, SocketConfig, SocketQueryOptions, SocketSendMessageOptions } from "../Types"
import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds, decodeWAMessage } from "../Utils"
import { WAFlag, WAMetric, WATag } from "../Types"
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults"
/**
* Connects to WA servers and performs:
* - simple queries (no retry mechanism, wait for connection establishment)
* - listen to messages and emit events
* - query phone connection
*/
export const makeSocket = ({
waWebSocketUrl,
connectTimeoutMs,
phoneResponseTimeMs,
logger,
agent,
keepAliveIntervalMs,
expectResponseTimeout,
phoneConnectionChanged
}: SocketConfig) => {
// for generating tags
const referenceDateSeconds = unixTimestampSeconds(new Date())
const ws = new WebSocket(waWebSocketUrl, undefined, {
origin: DEFAULT_ORIGIN,
timeout: connectTimeoutMs,
agent,
headers: {
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
'Host': 'web.whatsapp.com',
'Pragma': 'no-cache',
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
}
})
ws.setMaxListeners(0)
let lastDateRecv: Date
let epoch = 0
let authInfo: { encKey: Buffer, macKey: Buffer }
let keepAliveReq: NodeJS.Timeout
let phoneCheckInterval: NodeJS.Timeout
let phoneCheckListeners = 0
const sendPromise = promisify(ws.send)
/** generate message tag and increment epoch */
const generateMessageTag = (longTag: boolean = false) => {
const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}`
epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages
return tag
}
const sendRawMessage = (data: Buffer | string) => sendPromise.call(ws, data) as Promise<void>
/**
* Send a message to the WA servers
* @returns the tag attached in the message
* */
const sendMessage = async(
{ json, binaryTag, tag, longTag }: SocketSendMessageOptions
) => {
tag = tag || generateMessageTag(longTag)
let data: Buffer | string
if(logger.level === 'trace') {
logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication')
}
if(binaryTag) {
if(!(json instanceof BinaryNode)) {
throw new Boom(`Invalid binary message of type "${typeof json}". Must be BinaryNode`, { statusCode: 400 })
}
if(!authInfo) {
throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 })
}
const binary = json.toBuffer() // encode the JSON to the WhatsApp binary format
const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey
const sign = hmacSign(buff, authInfo.macKey) // sign the message using HMAC and our macKey
data = Buffer.concat([
Buffer.from(tag + ','), // generate & prefix the message tag
Buffer.from(binaryTag), // prefix some bytes that tell whatsapp what the message is about
sign, // the HMAC sign of the message
buff, // the actual encrypted buffer
])
} else {
data = `${tag},${JSON.stringify(json)}`
}
await sendRawMessage(data)
return tag
}
const end = (error: Error | undefined) => {
logger.debug({ error }, 'connection closed')
ws.removeAllListeners('close')
ws.removeAllListeners('error')
ws.removeAllListeners('open')
ws.removeAllListeners('message')
phoneCheckListeners = 0
clearInterval(keepAliveReq)
clearPhoneCheckInterval()
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
try { ws.close() } catch { }
}
ws.emit('ws-close', error)
ws.removeAllListeners('ws-close')
}
const onMessageRecieved = (message: string | Buffer) => {
if(message[0] === '!') {
// when the first character in the message is an '!', the server is sending a pong frame
const timestamp = message.slice(1, message.length).toString ('utf-8')
lastDateRecv = new Date(parseInt(timestamp))
ws.emit('received-pong')
} else {
let messageTag: string
let json: any
try {
const dec = decodeWAMessage(message, authInfo)
messageTag = dec[0]
json = dec[1]
if (!json) return
} catch (error) {
end(error)
return
}
//if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false })
if (logger.level === 'trace') {
logger.trace({ tag: messageTag, fromMe: false, json }, 'communication')
}
let anyTriggered = false
/* Check if this is a response to a message we sent */
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${messageTag}`, json)
/* Check if this is a response to a message we are expecting */
const l0 = json.header || json[0] || ''
const l1 = json?.attributes || json?.[1] || { }
const l2 = json?.data?.[0]?.header || json[2]?.[0] || ''
Object.keys(l1).forEach(key => {
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, json) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, json) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, json) || anyTriggered
})
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered
if (!anyTriggered && logger.level === 'debug') {
logger.debug({ unhandled: true, tag: messageTag, fromMe: false, json }, 'communication recv')
}
}
}
/** Exits a query if the phone connection is active and no response is still found */
const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => {
let timeout: NodeJS.Timeout
const listener = ([, connected]) => {
if(connected) {
timeout = setTimeout(() => {
logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`)
cancel(new Boom('Not expecting a response', { statusCode: 422 }))
}, expectResponseTimeout)
ws.off(PHONE_CONNECTION_CB, listener)
}
}
ws.on(PHONE_CONNECTION_CB, listener)
return () => {
ws.off(PHONE_CONNECTION_CB, listener)
timeout && clearTimeout(timeout)
}
}
/** interval is started when a query takes too long to respond */
const startPhoneCheckInterval = () => {
phoneCheckListeners += 1
if (!phoneCheckInterval) {
// if its been a long time and we haven't heard back from WA, send a ping
phoneCheckInterval = setInterval(() => {
if(phoneCheckListeners <= 0) {
logger.warn('phone check called without listeners')
return
}
logger.info('checking phone connection...')
sendAdminTest()
phoneConnectionChanged(false)
}, phoneResponseTimeMs)
}
}
const clearPhoneCheckInterval = () => {
phoneCheckListeners -= 1
if (phoneCheckListeners <= 0) {
clearInterval(phoneCheckInterval)
phoneCheckInterval = undefined
phoneCheckListeners = 0
}
}
/** checks for phone connection */
const sendAdminTest = () => sendMessage({ json: ['admin', 'test'] })
/**
* Wait for a message with a certain tag to be received
* @param tag the message tag to await
* @param json query that was sent
* @param timeoutMs timeout after which the promise will reject
*/
const waitForMessage = async(tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => {
let onRecv: (json) => void
let onErr: (err) => void
let cancelPhoneChecker: () => void
try {
const result = await promiseTimeout(timeoutMs,
(resolve, reject) => {
onRecv = resolve
onErr = err => {
reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
}
if(requiresPhoneConnection) {
startPhoneCheckInterval()
cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr)
}
ws.on(`TAG:${tag}`, onRecv)
ws.on('ws-close', onErr) // if the socket closes, you'll never receive the message
},
)
return result as any
} finally {
requiresPhoneConnection && clearPhoneCheckInterval()
cancelPhoneChecker && cancelPhoneChecker()
ws.off(`TAG:${tag}`, onRecv)
ws.off('ws-close', onErr) // if the socket closes, you'll never receive the message
}
}
/**
* Query something from the WhatsApp servers
* @param json the query itself
* @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary
* @param timeoutMs timeout after which the query will be failed (set to null to disable a timeout)
* @param tag the tag to attach to the message
*/
const query = async(
{json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection}: SocketQueryOptions
) => {
tag = tag || generateMessageTag(longTag)
const promise = waitForMessage(tag, requiresPhoneConnection, timeoutMs)
await sendMessage({ json, tag, binaryTag })
const response = await promise
const responseStatusCode = +(response.status ? response.status : 200) // default status
// read here: http://getstatuscode.com/599
if(responseStatusCode === 599) { // the connection has gone bad
end(new Boom('WA server overloaded', { statusCode: 599, data: { query: json, response } }))
}
if(expect200 && Math.floor(responseStatusCode/100) !== 2) {
const message = STATUS_CODES[responseStatusCode] || 'unknown'
throw new Boom(
`Unexpected status in '${Array.isArray(json) ? json[0] : (json?.header || 'query')}': ${message}(${responseStatusCode})`,
{ data: { query: json, message }, statusCode: response.status }
)
}
return response
}
const startKeepAliveRequest = () => (
keepAliveReq = setInterval(() => {
if (!lastDateRecv) lastDateRecv = new Date()
const diff = Date.now() - lastDateRecv.getTime()
/*
check if it's been a suspicious amount of time since the server responded with our last seen
it could be that the network is down
*/
if (diff > keepAliveIntervalMs+5000) {
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
} else if(ws.readyState === ws.OPEN) {
sendRawMessage('?,,') // if its all good, send a keep alive request
} else {
logger.warn('keep alive called when WS not open')
}
}, keepAliveIntervalMs)
)
const waitForSocketOpen = async() => {
if(ws.readyState === ws.OPEN) return
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
let onOpen: () => void
let onClose: (err: Error) => void
await new Promise((resolve, reject) => {
onOpen = () => resolve(undefined)
onClose = reject
ws.on('open', onOpen)
ws.on('close', onClose)
ws.on('error', onClose)
})
.finally(() => {
ws.off('open', onOpen)
ws.off('close', onClose)
ws.off('error', onClose)
})
}
ws.on('message', onMessageRecieved)
ws.on('open', () => {
startKeepAliveRequest()
logger.info('Opened WS connection to WhatsApp Web')
})
ws.on('error', end)
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost })))
ws.on(PHONE_CONNECTION_CB, json => {
if (!json[1]) {
end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost }))
logger.info('Connection terminated by phone, closing...')
} else {
phoneConnectionChanged(true)
}
})
ws.on('CB:Cmd,type:disconnect', json => {
const {kind} = json[1]
let reason: DisconnectReason
switch(kind) {
case 'replaced':
reason = DisconnectReason.connectionReplaced
break
default:
reason = DisconnectReason.connectionLost
break
}
end(new Boom(
`Connection terminated by server: "${kind || 'unknown'}"`,
{ statusCode: reason }
))
})
return {
ws,
updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info,
waitForSocketOpen,
sendRawMessage,
sendMessage,
generateMessageTag,
waitForMessage,
query,
/** Generic function for action, set queries */
setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => {
const json = new BinaryNode('action', { epoch: epoch.toString(), type: 'set' }, nodes)
return query({
json,
binaryTag,
tag,
expect200: true,
requiresPhoneConnection: true
}) as Promise<{ status: number }>
},
currentEpoch: () => epoch,
end
}
}
export type Socket = ReturnType<typeof makeSocket>

View File

@@ -1,6 +1,6 @@
import P from "pino"
import type { MediaType, SocketConfig } from "../Types"
import { Browsers } from "../Utils/generics"
import { Browsers } from "../Utils"
export const UNAUTHORIZED_CODES = [401, 403, 419]
@@ -13,35 +13,32 @@ export const PHONE_CONNECTION_CB = 'CB:Pong'
export const WA_DEFAULT_EPHEMERAL = 7*24*60*60
export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
export const NOISE_WA_HEADER = new Uint8Array([87, 65, 5, 2]) // last is "DICT_VERSION"
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
version: [2, 2130, 9],
version: [2, 2136, 9],
browser: Browsers.baileys('Chrome'),
waWebSocketUrl: 'wss://web.whatsapp.com/ws',
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000,
keepAliveIntervalMs: 25_000,
phoneResponseTimeMs: 15_000,
connectTimeoutMs: 30_000,
expectResponseTimeout: 12_000,
logger: P().child({ class: 'baileys' }),
phoneConnectionChanged: () => { },
maxRetries: 5,
connectCooldownMs: 2500,
pendingRequestTimeoutMs: undefined,
reconnectMode: 'on-connection-error',
maxQRCodes: Infinity,
printQRInTerminal: false,
}
export const MEDIA_PATH_MAP: { [T in MediaType]: string } = {
image: '/mms/image',
video: '/mms/video',
document: '/mms/document',
audio: '/mms/audio',
sticker: '/mms/image',
history: ''
}
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
export const KEY_BUNDLE_TYPE = ''

430
src/Socket/chats.ts Normal file
View File

@@ -0,0 +1,430 @@
import { decodeSyncdPatch, encodeSyncdPatch } from "../Utils/chat-utils";
import { SocketConfig, WAPresence, PresenceData, Chat, ChatModification, WAMediaUpload } from "../Types";
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, S_WHATSAPP_NET } from "../WABinary";
import { makeSocket } from "./socket";
import { proto } from '../../WAProto'
import { toNumber } from "../Utils/generics";
import { compressImage, generateProfilePicture } from "..";
export const makeChatsSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeSocket(config)
const {
ev,
ws,
authState,
generateMessageTag,
sendNode,
query
} = sock
const interactiveQuery = async(userNodes: BinaryNode[], queryNode: BinaryNode) => {
const result = await query({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'usync',
},
content: [
{
tag: 'usync',
attrs: {
sid: generateMessageTag(),
mode: 'query',
last: 'true',
index: '0',
context: 'interactive',
},
content: [
{
tag: 'query',
attrs: { },
content: [ queryNode ]
},
{
tag: 'list',
attrs: { },
content: userNodes
}
]
}
],
})
const usyncNode = getBinaryNodeChild(result, 'usync')
const listNode = getBinaryNodeChild(usyncNode, 'list')
const users = getBinaryNodeChildren(listNode, 'user')
return users
}
const onWhatsApp = async(...jids: string[]) => {
const results = await interactiveQuery(
[
{
tag: 'user',
attrs: { },
content: jids.map(
jid => ({
tag: 'contact',
attrs: { },
content: `+${jid}`
})
)
}
],
{ tag: 'contact', attrs: { } }
)
return results.map(user => {
const contact = getBinaryNodeChild(user, 'contact')
return { exists: contact.attrs.type === 'in', jid: user.attrs.jid }
}).filter(item => item.exists)
}
const fetchStatus = async(jid: string) => {
const [result] = await interactiveQuery(
[{ tag: 'user', attrs: { jid } }],
{ tag: 'status', attrs: { } }
)
if(result) {
const status = getBinaryNodeChild(result, 'status')
return {
status: status.content!.toString(),
setAt: new Date(+status.attrs.t * 1000)
}
}
}
const updateProfilePicture = async(jid: string, content: WAMediaUpload) => {
const { img } = await generateProfilePicture('url' in content ? content.url.toString() : content)
await query({
tag: 'iq',
attrs: {
to: jidNormalizedUser(jid),
type: 'set',
xmlns: 'w:profile:picture'
},
content: [
{
tag: 'picture',
attrs: { type: 'image' },
content: img
}
]
})
}
const fetchBlocklist = async() => {
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'blocklist',
to: S_WHATSAPP_NET,
type: 'get'
}
})
console.log('blocklist', result)
}
const updateBlockStatus = async(jid: string, action: 'block' | 'unblock') => {
await query({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set'
},
content: [
{
tag: 'item',
attrs: {
action,
jid
}
}
]
})
}
const fetchPrivacySettings = async() => {
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'privacy',
to: S_WHATSAPP_NET,
type: 'get'
}
})
console.log('privacy', result)
}
const updateAccountSyncTimestamp = async() => {
await sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set',
xmlns: 'urn:xmpp:whatsapp:dirty',
id: generateMessageTag(),
},
content: [
{
tag: 'clean',
attrs: { }
}
]
})
}
const collectionSync = async() => {
const COLLECTIONS = ['critical_block', 'critical_unblock_low', 'regular_low', 'regular_high']
await sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
xmlns: 'w:sync:app:state',
type: 'set',
id: generateMessageTag(),
},
content: [
{
tag: 'sync',
attrs: { },
content: COLLECTIONS.map(
name => ({
tag: 'collection',
attrs: { name, version: '0', return_snapshot: 'true' }
})
)
}
]
})
logger.info('synced collection')
}
const profilePictureUrl = async(jid: string) => {
const result = await query({
tag: 'iq',
attrs: {
to: jid,
type: 'get',
xmlns: 'w:profile:picture'
},
content: [
{ tag: 'picture', attrs: { type: 'preview', query: 'url' } }
]
})
const child = getBinaryNodeChild(result, 'picture')
return child?.attrs?.url
}
const sendPresenceUpdate = async(type: WAPresence, toJid?: string) => {
if(type === 'available' || type === 'unavailable') {
await sendNode({
tag: 'presence',
attrs: {
name: authState.creds.me!.name,
type
}
})
} else {
await sendNode({
tag: 'chatstate',
attrs: {
from: authState.creds.me!.id!,
to: toJid,
},
content: [
{ tag: type, attrs: { } }
]
})
}
}
const presenceSubscribe = (toJid: string) => (
sendNode({
tag: 'presence',
attrs: {
to: toJid,
id: generateMessageTag(),
type: 'subscribe'
}
})
)
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => {
let presence: PresenceData
const jid = attrs.from
const participant = attrs.participant || attrs.from
if(tag === 'presence') {
presence = {
lastKnownPresence: attrs.type === 'unavailable' ? 'unavailable' : 'available',
lastSeen: attrs.t ? +attrs.t : undefined
}
} else if(Array.isArray(content)) {
const [firstChild] = content
let type = firstChild.tag as WAPresence
if(type === 'paused') {
type = 'available'
}
presence = { lastKnownPresence: type }
} else {
logger.error({ tag, attrs, content }, 'recv invalid presence node')
}
if(presence) {
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
}
}
const processSyncActions = (actions: { action: proto.ISyncActionValue, index: [string, string] }[]) => {
const updates: Partial<Chat>[] = []
for(const { action, index: [_, id] } of actions) {
const update: Partial<Chat> = { id }
if(action?.muteAction) {
update.mute = action.muteAction?.muted ?
toNumber(action.muteAction!.muteEndTimestamp!) :
undefined
} else if(action?.archiveChatAction) {
update.archive = !!action.archiveChatAction?.archived
} else if(action?.markChatAsReadAction) {
update.unreadCount = !!action.markChatAsReadAction?.read ? 0 : -1
} else if(action?.clearChatAction) {
console.log(action.clearChatAction)
} else if(action?.contactAction) {
ev.emit('contacts.update', [{ id, name: action.contactAction!.fullName }])
} else if(action?.pushNameSetting) {
authState.creds.me!.name = action?.pushNameSetting?.name!
ev.emit('auth-state.update', authState)
} else {
logger.warn({ action, id }, 'unprocessable update')
}
updates.push(update)
}
ev.emit('chats.update', updates)
}
const patchChat = async(
jid: string,
modification: ChatModification
) => {
const patch = encodeSyncdPatch(modification, { remoteJid: jid }, authState)
const type = 'regular_high'
const ver = authState.creds.appStateVersion![type] || 0
const node: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set',
xmlns: 'w:sync:app:state'
},
content: [
{
tag: 'patch',
attrs: {
name: type,
version: (ver+1).toString(),
return_snapshot: 'false'
},
content: proto.SyncdPatch.encode(patch).finish()
}
]
}
await query(node)
authState.creds.appStateVersion![type] += 1
ev.emit('auth-state.update', authState)
}
const resyncState = async(name: 'regular_high' | 'regular_low' = 'regular_high') => {
authState.creds.appStateVersion = authState.creds.appStateVersion || {
regular_high: 0,
regular_low: 0,
critical_unblock_low: 0,
critical_block: 0
}
const result = await query({
tag: 'iq',
attrs: {
type: 'set',
xmlns: 'w:sync:app:state',
to: S_WHATSAPP_NET
},
content: [
{
tag: 'sync',
attrs: { },
content: [
{
tag: 'collection',
attrs: {
name,
version: authState.creds.appStateVersion[name].toString(),
return_snapshot: 'false'
}
}
]
}
]
})
const syncNode = getBinaryNodeChild(result, 'sync')
const collectionNode = getBinaryNodeChild(syncNode, 'collection')
const patchesNode = getBinaryNodeChild(collectionNode, 'patches')
const patches = getBinaryNodeChildren(patchesNode, 'patch')
const successfulMutations = patches.flatMap(({ content }) => {
if(content) {
const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
const version = toNumber(syncd.version!.version!)
if(version) {
authState.creds.appStateVersion[name] = Math.max(version, authState.creds.appStateVersion[name])
}
const { mutations, failures } = decodeSyncdPatch(syncd, authState)
if(failures.length) {
logger.info(
{ failures: failures.map(f => ({ trace: f.stack, data: f.data })) },
'failed to decode'
)
}
return mutations
}
return []
})
processSyncActions(successfulMutations)
ev.emit('auth-state.update', authState)
}
ws.on('CB:presence', handlePresenceUpdate)
ws.on('CB:chatstate', handlePresenceUpdate)
ws.on('CB:notification,type:server_sync', (node: BinaryNode) => {
const update = getBinaryNodeChild(node, 'collection')
if(update) {
resyncState(update.attrs.name as any)
}
})
ev.on('connection.update', ({ connection }) => {
if(connection === 'open') {
sendPresenceUpdate('available')
fetchBlocklist()
fetchPrivacySettings()
//collectionSync()
}
})
return {
...sock,
patchChat,
sendPresenceUpdate,
presenceSubscribe,
profilePictureUrl,
onWhatsApp,
fetchBlocklist,
fetchPrivacySettings,
fetchStatus,
updateProfilePicture,
updateBlockStatus
}
}

149
src/Socket/groups.ts Normal file
View File

@@ -0,0 +1,149 @@
import { generateMessageID } from "../Utils";
import { SocketConfig, GroupMetadata, ParticipantAction } from "../Types";
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidDecode, jidEncode } from "../WABinary";
import { makeChatsSocket } from "./chats";
const extractGroupMetadata = (result: BinaryNode) => {
const group = getBinaryNodeChild(result, 'group')
const descChild = getBinaryNodeChild(group, 'description')
let desc: string | undefined
let descId: string | undefined
if(descChild) {
desc = getBinaryNodeChild(descChild, 'body')?.content as string
descId = descChild.attrs.id
}
const metadata: GroupMetadata = {
id: jidEncode(jidDecode(group.attrs.id).user, 'g.us'),
subject: group.attrs.subject,
creation: +group.attrs.creation,
owner: group.attrs.creator,
desc,
descId,
restrict: !!getBinaryNodeChild(result, 'locked') ? 'true' : 'false',
announce: !!getBinaryNodeChild(result, 'announcement') ? 'true' : 'false',
participants: getBinaryNodeChildren(group, 'participant').map(
({ attrs }) => {
return {
id: attrs.jid,
admin: attrs.type || null as any,
}
}
)
}
return metadata
}
export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config)
const { query } = sock
const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => (
query({
tag: 'iq',
attrs: {
type,
xmlns: 'w:g2',
to: jid,
},
content
})
)
const groupMetadata = async(jid: string) => {
const result = await groupQuery(
jid,
'get',
[ { tag: 'query', attrs: { request: 'interactive' } } ]
)
return extractGroupMetadata(result)
}
return {
...sock,
groupMetadata,
groupCreate: async(subject: string, participants: string[]) => {
const key = generateMessageID()
const result = await groupQuery(
'@g.us',
'set',
[
{
tag: 'create',
attrs: {
subject,
key
},
content: participants.map(jid => ({
tag: 'participant',
attrs: { jid }
}))
}
]
)
return extractGroupMetadata(result)
},
groupLeave: async(jid: string) => {
await groupQuery(
'@g.us',
'set',
[
{
tag: 'leave',
attrs: { },
content: [
{ tag: 'group', attrs: { jid } }
]
}
]
)
},
groupUpdateSubject: async(jid: string, subject: string) => {
await groupQuery(
jid,
'set',
[
{
tag: 'subject',
attrs: { },
content: Buffer.from(subject, 'utf-8')
}
]
)
},
groupParticipantsUpdate: async(
jid: string,
participants: string[],
action: ParticipantAction
) => {
const result = await groupQuery(
jid,
'set',
participants.map(
jid => ({
tag: action,
attrs: { },
content: [{ tag: 'participant', attrs: { jid } }]
})
)
)
const node = getBinaryNodeChild(result, action)
const participantsAffected = getBinaryNodeChildren(node!, 'participant')
return participantsAffected.map(p => p.attrs.jid)
},
groupInviteCode: async(jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'invite', attrs: {} }])
const inviteNode = getBinaryNodeChild(result, 'invite')
return inviteNode.attrs.code
},
groupToggleEphemeral: async(jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration ?
{ tag: 'ephemeral', attrs: { ephemeral: ephemeralExpiration.toString() } } :
{ tag: 'not_ephemeral', attrs: { } }
await groupQuery(jid, 'set', [content])
},
groupSettingUpdate: async(jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked') => {
await groupQuery(jid, 'set', [ { tag: setting, attrs: { } } ])
}
}
}

13
src/Socket/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { SocketConfig } from '../Types'
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import { makeMessagesSocket as _makeSocket } from './messages-send'
// export the last socket layer
const makeWASocket = (config: Partial<SocketConfig>) => (
_makeSocket({
...DEFAULT_CONNECTION_CONFIG,
...config
})
)
export default makeWASocket

437
src/Socket/messages-recv.ts Normal file
View File

@@ -0,0 +1,437 @@
import { makeGroupsSocket } from "./groups"
import { SocketConfig, WAMessageStubType, ParticipantAction, Chat, GroupMetadata } from "../Types"
import { decodeMessageStanza, encodeBigEndian, toNumber, whatsappID } from "../Utils"
import { BinaryNode, jidDecode, jidEncode, isJidStatusBroadcast, S_WHATSAPP_NET, areJidsSameUser, getBinaryNodeChildren, getBinaryNodeChild } from '../WABinary'
import { downloadIfHistory } from '../Utils/history'
import { proto } from "../../WAProto"
import { generateSignalPubKey, xmppPreKey, xmppSignedPreKey } from "../Utils/signal"
import { KEY_BUNDLE_TYPE } from "../Defaults"
export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeGroupsSocket(config)
const {
ev,
authState,
ws,
assertingPreKeys,
sendNode,
} = sock
const sendMessageAck = async({ attrs }: BinaryNode) => {
const isGroup = !!attrs.participant
const { user: meUser } = jidDecode(authState.creds.me!.id!)
const stanza: BinaryNode = {
tag: 'ack',
attrs: {
class: 'receipt',
id: attrs.id,
to: isGroup ? attrs.from : authState.creds.me!.id,
}
}
if(isGroup) {
stanza.attrs.participant = jidEncode(meUser, 's.whatsapp.net')
}
await sendNode(stanza)
}
const sendRetryRequest = async(node: BinaryNode) => {
const retryCount = +(node.attrs.retryCount || 0) + 1
const isGroup = !!node.attrs.participant
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
const deviceIdentity = proto.ADVSignedDeviceIdentity.encode(account).finish()
await assertingPreKeys(1, async preKeys => {
const [keyId] = Object.keys(preKeys)
const key = preKeys[+keyId]
const decFrom = node.attrs.from ? jidDecode(node.attrs.from) : undefined
const receipt: BinaryNode = {
tag: 'receipt',
attrs: {
id: node.attrs.id,
type: 'retry',
to: isGroup ? node.attrs.from : jidEncode(decFrom!.user, 's.whatsapp.net', decFrom!.device, 0)
},
content: [
{
tag: 'retry',
attrs: {
count: retryCount.toString(), id: node.attrs.id,
t: node.attrs.t,
v: '1'
}
},
{
tag: 'registration',
attrs: { },
content: encodeBigEndian(authState.creds.registrationId)
}
]
}
if(node.attrs.recipient) {
receipt.attrs.recipient = node.attrs.recipient
}
if(node.attrs.participant) {
receipt.attrs.participant = node.attrs.participant
}
if(retryCount > 1) {
const exec = generateSignalPubKey(Buffer.from(KEY_BUNDLE_TYPE)).slice(0, 1);
(node.content! as BinaryNode[]).push({
tag: 'keys',
attrs: { },
content: [
{ tag: 'type', attrs: { }, content: exec },
{ tag: 'identity', attrs: { }, content: identityKey.public },
xmppPreKey(key, +keyId),
xmppSignedPreKey(signedPreKey),
{ tag: 'device-identity', attrs: { }, content: deviceIdentity }
]
})
}
await sendNode(node)
logger.info({ msgId: node.attrs.id, retryCount }, 'sent retry receipt')
ev.emit('auth-state.update', authState)
})
}
const processMessage = (message: proto.IWebMessageInfo, chatUpdate: Partial<Chat>) => {
const protocolMsg = message.message?.protocolMessage
if(protocolMsg) {
switch(protocolMsg.type) {
case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE:
const newKeys = JSON.parse(JSON.stringify(protocolMsg.appStateSyncKeyShare!.keys))
authState.creds.appStateSyncKeys = [
...(authState.creds.appStateSyncKeys || []),
...newKeys
]
ev.emit('auth-state.update', authState)
break
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
ev.emit('messages.update', [
{
key: protocolMsg.key,
update: { message: null, messageStubType: 1, key: message.key }
}
])
break
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
chatUpdate.ephemeralSettingTimestamp = toNumber(message.messageTimestamp)
chatUpdate.ephemeralExpiration = protocolMsg.ephemeralExpiration
break
}
} else if(message.messageStubType) {
const meJid = authState.creds.me!.id
const jid = message.key!.remoteJid!
//let actor = whatsappID (message.participant)
let participants: string[]
const emitParticipantsUpdate = (action: ParticipantAction) => (
ev.emit('group-participants.update', { id: jid, participants, action })
)
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ { id: jid, ...update } ])
}
switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
participants = message.messageStubParameters.map(whatsappID)
emitParticipantsUpdate('remove')
// mark the chat read only if you left the group
if (participants.includes(meJid)) {
chatUpdate.readOnly = true
}
break
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
participants = message.messageStubParameters.map(whatsappID)
if (participants.includes(meJid)) {
chatUpdate.readOnly = false
}
emitParticipantsUpdate('add')
break
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ announce })
break
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ restrict })
break
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
case WAMessageStubType.GROUP_CREATE:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
}
}
}
const processHistoryMessage = (item: proto.HistorySync) => {
switch(item.syncType) {
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_BOOTSTRAP:
const messages: proto.IWebMessageInfo[] = []
const chats = item.conversations!.map(
c => {
const chat: Chat = { ...c }
//@ts-expect-error
delete chat.messages
for(const item of c.messages || []) {
messages.push(item.message)
}
return chat
}
)
ev.emit('chats.set', { chats, messages })
break
case proto.HistorySync.HistorySyncHistorySyncType.PUSH_NAME:
const contacts = item.pushnames.map(
p => ({ notify: p.pushname, id: p.id })
)
ev.emit('contacts.upsert', contacts)
break
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_STATUS_V3:
// TODO
break
}
}
const processNotification = (node: BinaryNode): Partial<proto.IWebMessageInfo> => {
const result: Partial<proto.IWebMessageInfo> = { }
const child = (node.content as BinaryNode[])?.[0]
if(node.attrs.type === 'w:gp2') {
switch(child?.tag) {
case 'ephemeral':
case 'not_ephemeral':
result.message = {
protocolMessage: {
type: proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration: +(child.attrs.expiration || 0)
}
}
break
case 'promote':
case 'demote':
case 'remove':
case 'add':
case 'leave':
const stubType = `GROUP_PARTICIPANT_${child.tag!.toUpperCase()}`
result.messageStubType = WAMessageStubType[stubType]
result.messageStubParameters = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid)
break
case 'subject':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT
result.messageStubParameters = [ child.attrs.subject ]
break
case 'announcement':
case 'not_announcement':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE
result.messageStubParameters = [ (child.tag === 'announcement').toString() ]
break
case 'locked':
case 'unlocked':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
result.messageStubParameters = [ (child.tag === 'locked').toString() ]
break
}
} else {
switch(child.tag) {
case 'count':
if(child.attrs.value === '0') {
logger.info('recv all pending notifications')
ev.emit('connection.update', { receivedPendingNotifications: true })
}
break
case 'devices':
const devices = getBinaryNodeChildren(child, 'device')
if(areJidsSameUser(child.attrs.jid, authState.creds!.me!.id)) {
const deviceJids = devices.map(d => d.attrs.jid)
logger.info({ deviceJids }, 'got my own devices')
}
break
}
}
if(Object.keys(result).length) {
return result
}
}
// recv a message
ws.on('CB:message', async(stanza: BinaryNode) => {
const dec = await decodeMessageStanza(stanza, authState)
const fullMessages: proto.IWebMessageInfo[] = []
for(const msg of dec.successes) {
const { attrs } = stanza
const isGroup = !!stanza.attrs.participant
const sender = (attrs.participant || attrs.from)?.toString()
const isMe = areJidsSameUser(sender, authState.creds.me!.id)
await sendMessageAck(stanza)
logger.debug({ msgId: dec.msgId, sender }, 'send message ack')
// send delivery receipt
let recpAttrs: { [_: string]: any }
if(isMe) {
recpAttrs = {
type: 'sender',
id: stanza.attrs.id,
to: stanza.attrs.from,
}
if(isGroup) {
recpAttrs.participant = stanza.attrs.participant
} else {
recpAttrs.recipient = stanza.attrs.recipient
}
} else {
const isStatus = isJidStatusBroadcast(stanza.attrs.from)
recpAttrs = {
//type: 'inactive',
id: stanza.attrs.id,
to: dec.chatId,
}
if(isGroup || isStatus) {
recpAttrs.participant = stanza.attrs.participant
}
}
await sendNode({ tag: 'receipt', attrs: recpAttrs })
logger.debug({ msgId: dec.msgId }, 'send message receipt')
const possibleHistory = downloadIfHistory(msg)
if(possibleHistory) {
const history = await possibleHistory
logger.info({ msgId: dec.msgId, type: history.syncType }, 'recv history')
processHistoryMessage(history)
} else {
const message = msg.deviceSentMessage?.message || msg
fullMessages.push({
key: {
remoteJid: dec.chatId,
fromMe: isMe,
id: dec.msgId,
participant: dec.participant
},
message,
status: isMe ? proto.WebMessageInfo.WebMessageInfoStatus.SERVER_ACK : null,
messageTimestamp: dec.timestamp,
pushName: dec.pushname
})
}
}
if(dec.successes.length) {
ev.emit('auth-state.update', authState)
if(fullMessages.length) {
ev.emit(
'messages.upsert',
{
messages: fullMessages.map(m => proto.WebMessageInfo.fromObject(m)),
type: stanza.attrs.offline ? 'append' : 'notify'
}
)
}
}
for(const { error } of dec.failures) {
logger.error(
{ msgId: dec.msgId, trace: error.stack, data: error.data },
'failure in decrypting message'
)
await sendRetryRequest(stanza)
}
})
ws.on('CB:ack,class:message', async(node: BinaryNode) => {
await sendNode({
tag: 'ack',
attrs: {
class: 'receipt',
id: node.attrs.id,
from: node.attrs.from
}
})
logger.debug({ attrs: node.attrs }, 'sending receipt for ack')
})
const handleReceipt = ({ attrs, content }: BinaryNode) => {
const sender = attrs.participant || attrs.from
const status = attrs.type === 'read' ? proto.WebMessageInfo.WebMessageInfoStatus.READ : proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK
const ids = [attrs.id]
if(Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item')
ids.push(...items.map(i => i.attrs.id))
}
ev.emit('messages.update', ids.map(id => ({
key: {
remoteJid: attrs.from,
id: id,
fromMe: areJidsSameUser(sender, authState.creds.me!.id!),
participant: attrs.participant
},
update: { status }
})))
}
ws.on('CB:receipt,type:read', handleReceipt)
ws.on('CB:ack,class:receipt', handleReceipt)
ws.on('CB:notification', async(node: BinaryNode) => {
const sendAck = async() => {
await sendNode({
tag: 'ack',
attrs: {
class: 'notification',
id: node.attrs.id,
type: node.attrs.type,
to: node.attrs.from
}
})
logger.debug({ msgId: node.attrs.id }, 'ack notification')
}
await sendAck()
const msg = processNotification(node)
if(msg) {
const fromMe = areJidsSameUser(node.attrs.participant || node.attrs.from, authState.creds.me!.id)
msg.key = {
remoteJid: node.attrs.from,
fromMe,
participant: node.attrs.participant,
id: node.attrs.id
}
msg.messageTimestamp = +node.attrs.t
const fullMsg = proto.WebMessageInfo.fromObject(msg)
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
}
})
ev.on('messages.upsert', ({ messages }) => {
const chat: Partial<Chat> = { id: messages[0].key.remoteJid }
for(const msg of messages) {
processMessage(msg, chat)
if(!!msg.message && !msg.message!.protocolMessage) {
chat.conversationTimestamp = toNumber(msg.messageTimestamp)
if(!msg.key.fromMe) {
chat.unreadCount = (chat.unreadCount || 0) + 1
}
}
}
if(Object.keys(chat).length > 1) {
ev.emit('chats.update', [ chat ])
}
})
return sock
}

392
src/Socket/messages-send.ts Normal file
View File

@@ -0,0 +1,392 @@
import { makeMessagesRecvSocket } from "./messages-recv"
import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction } from "../Types"
import { encodeWAMessage, generateMessageID, generateWAMessage } from "../Utils"
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, S_WHATSAPP_NET } from '../WABinary'
import { proto } from "../../WAProto"
import { encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESession } from "../Utils/signal"
import { WA_DEFAULT_EPHEMERAL, DEFAULT_ORIGIN, MEDIA_PATH_MAP } from "../Defaults"
import got from "got"
import { Boom } from "@hapi/boom"
export const makeMessagesSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeMessagesRecvSocket(config)
const {
ev,
authState,
query,
generateMessageTag,
sendNode,
groupMetadata,
groupToggleEphemeral
} = sock
let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async(forceGet = false) => {
let media = await mediaConn
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
mediaConn = (async() => {
const result = await query({
tag: 'iq',
attrs: {
type: 'set',
xmlns: 'w:m',
to: S_WHATSAPP_NET,
},
content: [ { tag: 'media_conn', attrs: { } } ]
})
const mediaConnNode = getBinaryNodeChild(result, 'media_conn')
const node: MediaConnInfo = {
hosts: getBinaryNodeChildren(mediaConnNode, 'host').map(
item => item.attrs as any
),
auth: mediaConnNode.attrs.auth,
ttl: +mediaConnNode.attrs.ttl,
fetchDate: new Date()
}
logger.debug('fetched media conn')
return node
})()
}
return mediaConn
}
const sendReadReceipt = async(jid: string, participant: string | undefined, messageIds: string[]) => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: messageIds[0],
t: Date.now().toString(),
to: jid,
type: 'read'
},
}
if(participant) {
node.attrs.participant = participant
}
messageIds = messageIds.slice(1)
if(messageIds.length) {
node.content = [
{
tag: 'list',
attrs: { },
content: messageIds.map(id => ({
tag: 'item',
attrs: { id }
}))
}
]
}
logger.debug({ jid, messageIds }, 'reading messages')
await sendNode(node)
}
const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => {
const users = jids.map<BinaryNode>(jid => ({ tag: 'user', attrs: { jid } }))
const iq: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'usync',
},
content: [
{
tag: 'usync',
attrs: {
sid: generateMessageTag(),
mode: 'query',
last: 'true',
index: '0',
context: 'message',
},
content: [
{
tag: 'query',
attrs: { },
content: [
{
tag: 'devices',
attrs: { version: '2' }
}
]
},
{ tag: 'list', attrs: { }, content: users }
]
},
],
}
const result = await query(iq)
let resultJids = extractDeviceJids(result)
if(ignoreZeroDevices) {
resultJids = resultJids.filter(item => item.device !== 0)
}
return resultJids
}
const assertSession = async(jid: string, force: boolean) => {
const addr = jidToSignalProtocolAddress(jid).toString()
const session = await authState.keys.getSession(addr)
if(!session || force) {
logger.debug({ jid }, `fetching session`)
const identity: BinaryNode = {
tag: 'user',
attrs: { jid, reason: 'identity' },
}
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'encrypt',
type: 'get',
to: S_WHATSAPP_NET,
},
content: [
{
tag: 'key',
attrs: { },
content: [ identity ]
}
]
})
await parseAndInjectE2ESession(result, authState)
return true
}
return false
}
const createParticipantNode = async(jid: string, bytes: Buffer) => {
await assertSession(jid, false)
const { type, ciphertext } = await encryptSignalProto(jid, bytes, authState)
const node: BinaryNode = {
tag: 'to',
attrs: { jid },
content: [{
tag: 'enc',
attrs: { v: '2', type },
content: ciphertext
}]
}
return node
}
const relayMessage = async(jid: string, message: proto.IMessage, msgId?: string) => {
const { user, server } = jidDecode(jid)
const isGroup = server === 'g.us'
msgId = msgId || generateMessageID()
const encodedMsg = encodeWAMessage(message)
const participants: BinaryNode[] = []
let stanza: BinaryNode
const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net')
if(isGroup) {
const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, authState)
const groupData = await groupMetadata(jid)
const participantsList = groupData.participants.map(p => p.id)
const devices = await getUSyncDevices(participantsList, false)
logger.debug(`got ${devices.length} additional devices`)
const encSenderKeyMsg = encodeWAMessage({
senderKeyDistributionMessage: {
axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey,
groupId: destinationJid
}
})
for(const {user, device, agent} of devices) {
const jid = jidEncode(user, 's.whatsapp.net', device, agent)
const participant = await createParticipantNode(jid, encSenderKeyMsg)
participants.push(participant)
}
const binaryNodeContent: BinaryNode[] = []
if( // if there are some participants with whom the session has not been established
// if there are, we overwrite the senderkey
!!participants.find((p) => (
!!(p.content as BinaryNode[]).find(({ attrs }) => attrs.type == 'pkmsg')
))
) {
binaryNodeContent.push({
tag: 'participants',
attrs: { },
content: participants
})
}
binaryNodeContent.push({
tag: 'enc',
attrs: { v: '2', type: 'skmsg' },
content: ciphertext
})
stanza = {
tag: 'message',
attrs: {
id: msgId,
type: 'text',
to: destinationJid
},
content: binaryNodeContent
}
} else {
const { user: meUser } = jidDecode(authState.creds.me!.id!)
const messageToMyself: proto.IMessage = {
deviceSentMessage: {
destinationJid,
message
}
}
const encodedMeMsg = encodeWAMessage(messageToMyself)
participants.push(
await createParticipantNode(jidEncode(user, 's.whatsapp.net'), encodedMsg)
)
participants.push(
await createParticipantNode(jidEncode(meUser, 's.whatsapp.net'), encodedMeMsg)
)
const devices = await getUSyncDevices([ authState.creds.me!.id!, jid ], true)
logger.debug(`got ${devices.length} additional devices`)
for(const { user, device, agent } of devices) {
const isMe = user === meUser
participants.push(
await createParticipantNode(
jidEncode(user, 's.whatsapp.net', device, agent),
isMe ? encodedMeMsg : encodedMsg
)
)
}
stanza = {
tag: 'message',
attrs: {
id: msgId,
type: 'text',
to: destinationJid
},
content: [
{
tag: 'participants',
attrs: { },
content: participants
},
]
}
}
const shouldHaveIdentity = !!participants.find((p) => (
!!(p.content as BinaryNode[]).find(({ attrs }) => attrs.type == 'pkmsg')
))
if(shouldHaveIdentity) {
(stanza.content as BinaryNode[]).push({
tag: 'device-identity',
attrs: { },
content: proto.ADVSignedDeviceIdentity.encode(authState.creds.account).finish()
})
}
logger.debug({ msgId }, 'sending message')
await sendNode(stanza)
ev.emit('auth-state.update', authState)
return msgId
}
const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64 }) => {
// send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false)
let mediaUrl: string
for (let host of uploadInfo.hosts) {
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
const url = `https://${host.hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
try {
const {body: responseText} = await got.post(
url,
{
headers: {
'Content-Type': 'application/octet-stream',
'Origin': DEFAULT_ORIGIN
},
agent: {
https: config.agent
},
body: stream
}
)
const result = JSON.parse(responseText)
mediaUrl = result?.url
if (mediaUrl) break
else {
uploadInfo = await refreshMediaConn(true)
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
}
} catch (error) {
const isLast = host.hostname === uploadInfo.hosts[uploadInfo.hosts.length-1].hostname
logger.debug(`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
}
}
if (!mediaUrl) {
throw new Boom(
'Media upload failed on all hosts',
{ statusCode: 500 }
)
}
return { mediaUrl }
}
return {
...sock,
assertSession,
relayMessage,
sendReadReceipt,
refreshMediaConn,
sendMessage: async(
jid: string,
content: AnyMessageContent,
options: MiscMessageGenerationOptions = { }
) => {
const userJid = authState.creds.me!.id
if(
typeof content === 'object' &&
'disappearingMessagesInChat' in content &&
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
isJidGroup(jid)
) {
const { disappearingMessagesInChat } = content
const value = typeof disappearingMessagesInChat === 'boolean' ?
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
disappearingMessagesInChat
await groupToggleEphemeral(jid, value)
} else {
const fullMsg = await generateWAMessage(
jid,
content,
{
...options,
logger,
userJid: userJid,
// multi-device does not have this yet
//getUrlInfo: generateUrlInfo,
upload: waUploadToServer
}
)
await relayMessage(jid, fullMsg.message)
process.nextTick(() => {
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
})
return fullMsg
}
}
}
}

469
src/Socket/socket.ts Normal file
View File

@@ -0,0 +1,469 @@
import { Boom } from '@hapi/boom'
import EventEmitter from 'events'
import { promisify } from "util"
import WebSocket from "ws"
import { randomBytes } from 'crypto'
import { proto } from '../../WAProto'
import { DisconnectReason, SocketConfig, BaileysEventEmitter } from "../Types"
import { generateCurveKeyPair, initAuthState, generateRegistrationNode, configureSuccessfulPairing, generateLoginNode, encodeBigEndian, promiseTimeout } from "../Utils"
import { DEFAULT_ORIGIN, DEF_TAG_PREFIX, DEF_CALLBACK_PREFIX, KEY_BUNDLE_TYPE } from "../Defaults"
import { assertNodeErrorFree, BinaryNode, encodeBinaryNode, S_WHATSAPP_NET } from '../WABinary'
import noiseHandler from '../Utils/noise-handler'
import { generateOrGetPreKeys, xmppSignedPreKey, xmppPreKey, getPreKeys } from '../Utils/signal'
/**
* Connects to WA servers and performs:
* - simple queries (no retry mechanism, wait for connection establishment)
* - listen to messages and emit events
* - query phone connection
*/
export const makeSocket = ({
waWebSocketUrl,
connectTimeoutMs,
logger,
agent,
keepAliveIntervalMs,
version,
browser,
auth: initialAuthState
}: SocketConfig) => {
const ws = new WebSocket(waWebSocketUrl, undefined, {
origin: DEFAULT_ORIGIN,
timeout: connectTimeoutMs,
agent,
headers: {
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
'Host': 'web.whatsapp.com',
'Pragma': 'no-cache',
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits'
}
})
ws.setMaxListeners(0)
/** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
const ephemeralKeyPair = generateCurveKeyPair()
/** WA noise protocol wrapper */
const noise = noiseHandler(ephemeralKeyPair)
const authState = initialAuthState || initAuthState()
const { creds } = authState
const ev = new EventEmitter() as BaileysEventEmitter
let lastDateRecv: Date
let epoch = 0
let keepAliveReq: NodeJS.Timeout
const uqTagId = `${randomBytes(1).toString('hex')[0]}.${randomBytes(1).toString('hex')[0]}-`
const generateMessageTag = () => `${uqTagId}${epoch++}`
const sendPromise = promisify<void>(ws.send)
/** send a raw buffer */
const sendRawMessage = (data: Buffer | Uint8Array) => {
const bytes = noise.encodeFrame(data)
return sendPromise.call(ws, bytes) as Promise<void>
}
/** send a binary node */
const sendNode = (node: BinaryNode) => {
let buff = encodeBinaryNode(node)
return sendRawMessage(buff)
}
/** await the next incoming message */
const awaitNextMessage = async(sendMsg?: Uint8Array) => {
if(ws.readyState !== ws.OPEN) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
let onOpen: (data: any) => void
let onClose: (err: Error) => void
const result = new Promise<any>((resolve, reject) => {
onOpen = (data: any) => resolve(data)
onClose = reject
ws.on('frame', onOpen)
ws.on('close', onClose)
ws.on('error', onClose)
})
.finally(() => {
ws.off('frame', onOpen)
ws.off('close', onClose)
ws.off('error', onClose)
})
if(sendMsg) {
sendRawMessage(sendMsg).catch(onClose)
}
return result
}
/**
* Wait for a message with a certain tag to be received
* @param tag the message tag to await
* @param json query that was sent
* @param timeoutMs timeout after which the promise will reject
*/
const waitForMessage = async(msgId: string, timeoutMs?: number) => {
let onRecv: (json) => void
let onErr: (err) => void
try {
const result = await promiseTimeout(timeoutMs,
(resolve, reject) => {
onRecv = resolve
onErr = err => {
reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
}
ws.on(`TAG:${msgId}`, onRecv)
ws.on('close', onErr) // if the socket closes, you'll never receive the message
},
)
return result as any
} finally {
ws.off(`TAG:${msgId}`, onRecv)
ws.off('close', onErr) // if the socket closes, you'll never receive the message
}
}
/** send a query, and wait for its response. auto-generates message ID if not provided */
const query = async(node: BinaryNode, timeoutMs?: number) => {
if(!node.attrs.id) node.attrs.id = generateMessageTag()
const msgId = node.attrs.id
const wait = waitForMessage(msgId, timeoutMs)
await sendNode(node)
const result = await (wait as Promise<BinaryNode>)
if('tag' in result) {
assertNodeErrorFree(result)
}
return result
}
/** connection handshake */
const validateConnection = async () => {
logger.info('connected to WA Web')
const init = proto.HandshakeMessage.encode({
clientHello: { ephemeral: ephemeralKeyPair.public }
}).finish()
const result = await awaitNextMessage(init)
const handshake = proto.HandshakeMessage.decode(result)
logger.debug('handshake recv from WA Web')
const keyEnc = noise.processHandshake(handshake, creds.noiseKey)
logger.info('handshake complete')
let node: Uint8Array
if(!creds.me) {
logger.info('not logged in, attempting registration...')
node = generateRegistrationNode(creds, { version, browser })
} else {
logger.info('logging in...')
node = generateLoginNode(creds.me!.id, { version, browser })
}
const payloadEnc = noise.encrypt(node)
await sendRawMessage(
proto.HandshakeMessage.encode({
clientFinish: {
static: new Uint8Array(keyEnc),
payload: new Uint8Array(payloadEnc),
},
}).finish()
)
noise.finishInit()
startKeepAliveRequest()
}
/** get some pre-keys and do something with them */
const assertingPreKeys = async(range: number, execute: (keys: { [_: number]: any }) => Promise<void>) => {
const { newPreKeys, lastPreKeyId, preKeysRange } = generateOrGetPreKeys(authState, range)
const preKeys = await getPreKeys(authState.keys, preKeysRange[0], preKeysRange[1])
await execute(preKeys)
creds.serverHasPreKeys = true
creds.nextPreKeyId = Math.max(lastPreKeyId+1, creds.nextPreKeyId)
creds.firstUnuploadedPreKeyId = Math.max(creds.firstUnuploadedPreKeyId, lastPreKeyId+1)
await Promise.all(
Object.keys(newPreKeys).map(k => authState.keys.setPreKey(+k, newPreKeys[+k]))
)
ev.emit('auth-state.update', authState)
}
/** generates and uploads a set of pre-keys */
const uploadPreKeys = async() => {
await assertingPreKeys(50, async preKeys => {
const node: BinaryNode = {
tag: 'iq',
attrs: {
id: generateMessageTag(),
xmlns: 'encrypt',
type: 'set',
to: S_WHATSAPP_NET,
},
content: [
{ tag: 'registration', attrs: { }, content: encodeBigEndian(creds.registrationId) },
{ tag: 'type', attrs: { }, content: KEY_BUNDLE_TYPE },
{ tag: 'identity', attrs: { }, content: creds.signedIdentityKey.public },
{ tag: 'list', attrs: { }, content: Object.keys(preKeys).map(k => xmppPreKey(preKeys[+k], +k)) },
xmppSignedPreKey(creds.signedPreKey)
]
}
await sendNode(node)
logger.info('uploaded pre-keys')
})
}
const onMessageRecieved = (data: Buffer) => {
noise.decodeFrame(data, frame => {
ws.emit('frame', frame)
// if it's a binary node
if(!(frame instanceof Uint8Array)) {
const msgId = frame.attrs.id
if(logger.level === 'trace') {
logger.trace({ msgId, fromMe: false, frame }, 'communication')
}
let anyTriggered = false
/* Check if this is a response to a message we sent */
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame)
/* Check if this is a response to a message we are expecting */
const l0 = frame.tag
const l1 = frame.attrs || { }
const l2 = Array.isArray(frame.content) ? frame.content[0]?.tag : ''
Object.keys(l1).forEach(key => {
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, frame) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, frame) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, frame) || anyTriggered
})
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, frame) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, frame) || anyTriggered
anyTriggered = ws.emit('frame', frame) || anyTriggered
if (!anyTriggered && logger.level === 'debug') {
logger.debug({ unhandled: true, msgId, fromMe: false, frame }, 'communication recv')
}
}
})
}
const end = (error: Error | undefined) => {
logger.info({ error }, 'connection closed')
clearInterval(keepAliveReq)
ws.removeAllListeners('close')
ws.removeAllListeners('error')
ws.removeAllListeners('open')
ws.removeAllListeners('message')
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
try { ws.close() } catch { }
}
ev.emit('connection.update', {
connection: 'close',
lastDisconnect: {
error,
date: new Date()
}
})
ws.removeAllListeners('connection.update')
}
const waitForSocketOpen = async() => {
if(ws.readyState === ws.OPEN) return
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
let onOpen: () => void
let onClose: (err: Error) => void
await new Promise((resolve, reject) => {
onOpen = () => resolve(undefined)
onClose = reject
ws.on('open', onOpen)
ws.on('close', onClose)
ws.on('error', onClose)
})
.finally(() => {
ws.off('open', onOpen)
ws.off('close', onClose)
ws.off('error', onClose)
})
}
const startKeepAliveRequest = () => (
keepAliveReq = setInterval(() => {
if (!lastDateRecv) lastDateRecv = new Date()
const diff = Date.now() - lastDateRecv.getTime()
/*
check if it's been a suspicious amount of time since the server responded with our last seen
it could be that the network is down
*/
if (diff > keepAliveIntervalMs+5000) {
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
} else if(ws.readyState === ws.OPEN) {
// if its all good, send a keep alive request
query(
{
tag: 'iq',
attrs: {
id: generateMessageTag(),
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'w:p',
},
content: [{ tag: 'ping', attrs: { } }]
},
keepAliveIntervalMs
)
.then(() => {
lastDateRecv = new Date()
logger.trace('recv keep alive')
})
.catch(err => end(err))
} else {
logger.warn('keep alive called when WS not open')
}
}, keepAliveIntervalMs)
)
/** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') => (
sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
xmlns: 'passive',
type: 'set',
id: generateMessageTag(),
},
content: [
{ tag, attrs: { } }
]
})
)
/** logout & invalidate connection */
const logout = async() => {
await sendNode({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'set',
id: generateMessageTag(),
xmlns: 'md'
},
content: [
{
tag: 'remove-companion-device',
attrs: {
jid: authState.creds.me!.id,
reason: 'user_initiated'
}
}
]
})
end(new Boom('Intentional Logout', { statusCode: DisconnectReason.loggedOut }))
}
ws.on('message', onMessageRecieved)
ws.on('open', validateConnection)
ws.on('error', end)
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })))
// the server terminated the connection
ws.on('CB:xmlstreamend', () => {
end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed }))
})
// QR gen
ws.on('CB:iq,type:set,pair-device', async (stanza: BinaryNode) => {
const postQR = async() => {
const QR = await import('qrcode-terminal').catch(err => {
logger.error('add `qrcode-terminal` as a dependency to auto-print QR')
})
QR?.generate(qr, { small: true })
}
const refs = ((stanza.content[0] as BinaryNode).content as BinaryNode[]).map(n => n.content as string)
const iq: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: stanza.attrs.id,
}
}
const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64');
const identityKeyB64 = Buffer.from(creds.signedIdentityKey.public).toString('base64')
const advB64 = creds.advSecretKey
const qr = [refs[0], noiseKeyB64, identityKeyB64, advB64].join(',');
ev.emit('connection.update', { qr })
await postQR()
await sendNode(iq)
})
// device paired for the first time
// if device pairs successfully, the server asks to restart the connection
ws.on('CB:iq,,pair-success', async(stanza: BinaryNode) => {
logger.debug('pair success recv')
try {
const { reply, creds: updatedCreds } = configureSuccessfulPairing(stanza, creds)
logger.debug('pairing configured successfully')
const waiting = awaitNextMessage()
await sendNode(reply)
const value = (await waiting) as BinaryNode
if(value.tag === 'stream:error') {
if(value.attrs?.code !== '515') {
throw new Boom('Authentication failed', { statusCode: +(value.attrs.code || 500) })
}
}
Object.assign(creds, updatedCreds)
logger.info({ jid: creds.me!.id }, 'registered connection, restart server')
ev.emit('auth-state.update', authState)
ev.emit('connection.update', { isNewLogin: true, qr: undefined })
end(new Boom('Restart Required', { statusCode: DisconnectReason.restartRequired }))
} catch(error) {
logger.info({ trace: error.stack }, 'error in pairing')
end(error)
}
})
// login complete
ws.on('CB:success', async() => {
if(!creds.serverHasPreKeys) {
await uploadPreKeys()
}
await sendPassiveIq('active')
ev.emit('connection.update', { connection: 'open' })
})
// logged out
ws.on('CB:failure,reason:401', () => {
end(new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut }))
})
process.nextTick(() => {
ev.emit('connection.update', { connection: 'connecting', receivedPendingNotifications: false })
})
return {
ws,
ev,
authState,
get user () {
return authState.creds.me
},
assertingPreKeys,
generateMessageTag,
query,
waitForMessage,
waitForSocketOpen,
sendRawMessage,
sendNode,
logout,
end
}
}
export type Socket = ReturnType<typeof makeSocket>

View File

@@ -1,13 +1,13 @@
import type KeyedDB from "@adiwajshing/keyed-db"
import type { Comparable } from "@adiwajshing/keyed-db/lib/Types"
import type { Logger } from "pino"
import type { Connection } from "../Connection"
import type { Connection } from "../Socket"
import type { BaileysEventEmitter, Chat, ConnectionState, Contact, GroupMetadata, MessageInfo, PresenceData, WAMessage, WAMessageCursor, WAMessageKey } from "../Types"
import { toNumber } from "../Utils"
import makeOrderedDictionary from "./ordered-dictionary"
export const waChatKey = (pin: boolean) => ({
key: (c: Chat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive === 'true' ? '0' : '1') + c.t.toString(16).padStart(8, '0') + c.jid,
key: (c: Chat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive ? '0' : '1') + toNumber(c.conversationTimestamp).toString(16).padStart(8, '0') + c.id,
compare: (k1: string, k2: string) => k2.localeCompare (k1)
})
@@ -30,10 +30,7 @@ export default(
const groupMetadata: { [_: string]: GroupMetadata } = { }
const messageInfos: { [id: string]: MessageInfo } = { }
const presences: { [id: string]: { [participant: string]: PresenceData } } = { }
const state: ConnectionState = {
connection: 'close',
phoneConnected: false
}
const state: ConnectionState = { connection: 'close' }
const assertMessageList = (jid: string) => {
if(!messages[jid]) messages[jid] = makeMessagesDictionary()
@@ -214,7 +211,7 @@ export default(
state,
presences,
listen,
loadMessages: async(jid: string, count: number, cursor: WAMessageCursor, sock: Connection | undefined) => {
/*loadMessages: async(jid: string, count: number, cursor: WAMessageCursor, sock: Connection | undefined) => {
const list = assertMessageList(jid)
const retrieve = async(count: number, cursor: WAMessageCursor) => {
const result = await sock?.fetchMessagesFromWA(jid, count, cursor)
@@ -291,6 +288,6 @@ export default(
messageInfos[id!] = await sock?.messageInfo(remoteJid, id)
}
return messageInfos[id!]
}
}*/
}
}

View File

@@ -1,76 +0,0 @@
import { WAConnection, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection'
import * as assert from 'assert'
import {promises as fs} from 'fs'
require ('dotenv').config () // dotenv to load test jid
export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory
export const makeConnection = () => {
const conn = new WAConnection()
conn.connectOptions.maxIdleTimeMs = 15_000
conn.logger.level = 'debug'
let evCounts = {}
conn.on ('close', ({ isReconnecting }) => {
!isReconnecting && console.log ('Events registered: ', evCounts)
})
const onM = conn.on
conn.on = (...args: any[]) => {
evCounts[args[0]] = (evCounts[args[0]] || 0) + 1
return onM.apply (conn, args)
}
const offM = conn.off
conn.off = (...args: any[]) => {
evCounts[args[0]] = (evCounts[args[0]] || 0) - 1
if (evCounts[args[0]] <= 0) delete evCounts[args[0]]
return offM.apply (conn, args)
}
return conn
}
export async function sendAndRetrieveMessage(conn: WAConnection, content, type: MessageType, options: MessageOptions = {}, recipientJid = testJid) {
const response = await conn.sendMessage(recipientJid, content, type, options)
const {messages} = await conn.loadMessages(recipientJid, 10)
const message = messages.find (m => m.key.id === response.key.id)
assert.ok(message)
const chat = conn.chats.get(recipientJid)
assert.ok (chat.messages.get(GET_MESSAGE_ID(message.key)))
assert.ok (chat.t >= (unixTimestampSeconds()-5) )
return message
}
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
describe(name, () => {
const conn = new WAConnection()
conn.connectOptions.maxIdleTimeMs = 30_000
conn.logger.level = 'debug'
before(async () => {
const file = './auth_info.json'
await conn.loadAuthInfo(file).connect()
await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t'))
})
after(() => conn.close())
afterEach (() => assertChatDBIntegrity (conn))
func(conn)
})
)
export const assertChatDBIntegrity = (conn: WAConnection) => {
conn.chats.all ().forEach (chat => (
assert.deepStrictEqual (
[...chat.messages.all()].sort ((m1, m2) => waMessageKey.compare(waMessageKey.key(m1), waMessageKey.key(m2))),
chat.messages.all()
)
))
conn.chats.all ().forEach (chat => (
assert.deepStrictEqual (
chat.messages.all().filter (m => chat.messages.all().filter(m1 => m1.key.id === m.key.id).length > 1),
[]
)
))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,112 +0,0 @@
describe ('Reconnects', () => {
const verifyConnectionOpen = async (conn: Connection) => {
expect((await conn.getState()).user).toBeDefined()
let failed = false
// check that the connection stays open
conn.ev.on('state.update', ({ connection, lastDisconnect }) => {
if(connection === 'close' && !!lastDisconnect.error) {
failed = true
}
})
await delay (60*1000)
conn.close ()
expect(failed).toBe(false)
}
it('should dispose correctly on bad_session', async () => {
const conn = makeConnection({
reconnectMode: 'on-any-error',
credentials: './auth_info.json',
maxRetries: 2,
connectCooldownMs: 500
})
let gotClose0 = false
let gotClose1 = false
const openPromise = conn.open()
conn.getSocket().ev.once('ws-close', () => {
gotClose0 = true
})
conn.ev.on('state.update', ({ lastDisconnect }) => {
//@ts-ignore
if(lastDisconnect?.error?.output?.statusCode === DisconnectReason.badSession) {
gotClose1 = true
}
})
setTimeout (() => conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
await openPromise
console.log('opened connection')
await delay(1000)
conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je'))
await delay(2000)
await conn.waitForConnection()
conn.close()
expect(gotClose0).toBe(true)
expect(gotClose1).toBe(true)
}, 20_000)
/**
* the idea is to test closing the connection at multiple points in the connection
* and see if the library cleans up resources correctly
*/
it('should cleanup correctly', async () => {
const conn = makeConnection({
reconnectMode: 'on-any-error',
credentials: './auth_info.json'
})
let timeoutMs = 100
while (true) {
let tmout = setTimeout (() => {
conn.close()
}, timeoutMs)
try {
await conn.open()
clearTimeout (tmout)
break
} catch (error) {
}
// exponentially increase the timeout disconnect
timeoutMs *= 2
}
await verifyConnectionOpen(conn)
}, 120_000)
/**
* the idea is to test closing the connection at multiple points in the connection
* and see if the library cleans up resources correctly
*/
it('should disrupt connect loop', async () => {
const conn = makeConnection({
reconnectMode: 'on-any-error',
credentials: './auth_info.json'
})
let timeout = 1000
let tmout
const endConnection = async () => {
while (!conn.getSocket()) {
await delay(100)
}
conn.getSocket().end(Boom.preconditionRequired('conn close'))
while (conn.getSocket()) {
await delay(100)
}
timeout *= 2
tmout = setTimeout (endConnection, timeout)
}
tmout = setTimeout (endConnection, timeout)
await conn.open()
clearTimeout (tmout)
await verifyConnectionOpen(conn)
}, 120_000)
})

View File

@@ -1,95 +0,0 @@
import BinaryNode from '../BinaryNode'
describe('Binary Coding Tests', () => {
const TEST_VECTORS: [string, BinaryNode][] = [
[
'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305',
new BinaryNode(
'action',
{ last: 'true', add: 'before' },
[
new BinaryNode(
'message',
{},
{
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB009675E7ED37AABF2' },
message: { conversation: '*piano room timings are:*\n 6:00AM-12:00AM' },
messageTimestamp: '1584004403',
status: 'DELIVERY_ACK',
} as any
),
new BinaryNode(
'message',
{},
{
key: {
remoteJid: '917529871117@s.whatsapp.net',
fromMe: false,
id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD',
},
messageTimestamp: '1584004413',
messageStubType: 'REVOKE',
} as any
),
new BinaryNode(
'message',
{},
{
key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB003C7B539AFD09753' },
message: {
conversation:
"Sorry fren, I couldn't understand 'Libra'. Type 'help' to know what all I can do",
},
messageTimestamp: '1584004417',
status: 'DELIVERY_ACK',
} as any
),
new BinaryNode(
'message',
{},
{
key: {
remoteJid: '917529871117@s.whatsapp.net',
fromMe: false,
id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8',
},
message: { conversation: 'library' },
messageTimestamp: '1584004418',
} as any
),
]
)
],
[
'f8063f2dfafc0831323334353637385027fc0431323334f801f80228fc0701020304050607',
new BinaryNode(
'picture',
{jid: '12345678@s.whatsapp.net', id: '1234'},
[
new BinaryNode(
'image',
{},
Buffer.from([1,2,3,4,5,6,7])
)
]
)
]
]
it('should encode/decode strings', () => {
for(const [input, output] of TEST_VECTORS) {
const buff = Buffer.from(input, 'hex')
const node = BinaryNode.from(buff)
expect(
JSON.parse(JSON.stringify(node))
).toStrictEqual(
JSON.parse(JSON.stringify(output))
)
expect(
node.toBuffer().toString('hex')
).toStrictEqual(
input
)
}
})
})

View File

@@ -1,94 +0,0 @@
import { Boom } from '@hapi/boom'
import P from 'pino'
import BinaryNode from '../BinaryNode'
import makeConnection from '../Connection'
import { delay } from '../Utils/generics'
describe('QR Generation', () => {
it('should generate QR', async () => {
const QR_GENS = 1
const {ev} = makeConnection({
maxRetries: 0,
maxQRCodes: QR_GENS,
logger: P({ level: 'trace' })
})
let calledQR = 0
ev.on('connection.update', ({ qr }) => {
if(qr) calledQR += 1
})
await expect(open()).rejects.toThrowError('Too many QR codes')
expect(calledQR).toBeGreaterThanOrEqual(QR_GENS)
}, 60_000)
})
describe('Test Connect', () => {
const logger = P({ level: 'trace' })
it('should connect', async () => {
logger.info('please be ready to scan with your phone')
const conn = makeConnection({
logger,
printQRInTerminal: true
})
await conn.waitForConnection(true)
const { user, isNewLogin } = await conn.getState()
expect(user).toHaveProperty('jid')
expect(user).toHaveProperty('name')
expect(isNewLogin).toBe(true)
conn.end(undefined)
}, 65_000)
it('should restore session', async () => {
let conn = makeConnection({
printQRInTerminal: true,
logger,
})
await conn.waitForConnection(true)
conn.end(undefined)
await delay(2500)
conn = makeConnection({
printQRInTerminal: true,
logger,
})
await conn.waitForConnection(true)
const { user, isNewLogin, qr } = await conn.getState()
expect(user).toHaveProperty('jid')
expect(user).toHaveProperty('name')
expect(isNewLogin).toBe(false)
expect(qr).toBe(undefined)
conn.end(undefined)
}, 65_000)
it('should logout', async () => {
let conn = makeConnection({
printQRInTerminal: true,
logger,
})
await conn.waitForConnection(true)
const { user, qr } = await conn.getState()
expect(user).toHaveProperty('jid')
expect(user).toHaveProperty('name')
expect(qr).toBe(undefined)
const credentials = conn.getAuthInfo()
await conn.logout()
conn = makeConnection({
credentials,
logger
})
await expect(
conn.waitForConnection()
).rejects.toThrowError('Unexpected error in login')
}, 65_000)
})

View File

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

View File

@@ -1,165 +0,0 @@
import BinaryNode from '../BinaryNode'
import makeConnection from '../makeConnection'
import { delay } from '../WAConnection/Utils'
describe('Queries', () => {
/*it ('should correctly send updates for chats', async () => {
const conn = makeConnection({
pendingRequestTimeoutMs: undefined,
credentials: './auth_info.json'
})
const task = new Promise(resolve => conn.once('chats-received', resolve))
await conn.connect ()
await task
conn.close ()
const oldChat = conn.chats.all()[0]
oldChat.archive = 'true' // mark the first chat as archived
oldChat.modify_tag = '1234' // change modify tag to detect change
const promise = new Promise(resolve => conn.once('chats-update', resolve))
const result = await conn.connect ()
assert.ok (!result.newConnection)
const chats = await promise as Partial<WAChat>[]
const chat = chats.find (c => c.jid === oldChat.jid)
assert.ok (chat)
assert.ok ('archive' in chat)
assert.strictEqual (Object.keys(chat).length, 3)
assert.strictEqual (Object.keys(chats).length, 1)
conn.close ()
})
it ('should correctly send updates for contacts', async () => {
const conn = makeConnection ()
conn.pendingRequestTimeoutMs = null
conn.loadAuthInfo('./auth_info.json')
const task: any = new Promise(resolve => conn.once('contacts-received', resolve))
await conn.connect ()
const initialResult = await task
assert.strictEqual(
initialResult.updatedContacts.length,
Object.keys(conn.contacts).length
)
conn.close ()
const [jid] = Object.keys(conn.contacts)
const oldContact = conn.contacts[jid]
oldContact.name = 'Lol'
oldContact.index = 'L'
const promise = new Promise(resolve => conn.once('contacts-received', resolve))
const result = await conn.connect ()
assert.ok (!result.newConnection)
const {updatedContacts} = await promise as { updatedContacts: Partial<WAContact>[] }
const contact = updatedContacts.find (c => c.jid === jid)
assert.ok (contact)
assert.ok ('name' in contact)
assert.strictEqual (Object.keys(contact).length, 3)
assert.strictEqual (Object.keys(updatedContacts).length, 1)
conn.close ()
})*/
it('should queue requests when closed', async () => {
const conn = makeConnection({
credentials: './auth_info.json'
})
await conn.open()
await delay(2000)
conn.close()
const { user: { jid } } = await conn.getState()
const task: Promise<any> = conn.query({
json: ['query', 'Status', jid]
})
await delay(2000)
conn.open()
const json = await task
expect(json.status).toBeDefined()
conn.close()
}, 65_000)
it('[MANUAL] should recieve query response after phone disconnect', async () => {
const conn = makeConnection ({
printQRInTerminal: true,
credentials: './auth_info.json'
})
await conn.open()
const { phoneConnected } = await conn.getState()
expect(phoneConnected).toBe(true)
try {
const waitForEvent = expect => new Promise (resolve => {
conn.ev.on('state.update', ({phoneConnected}) => {
if (phoneConnected === expect) {
conn.ev.removeAllListeners('state.update')
resolve(undefined)
}
})
})
console.log('disconnect your phone from the internet')
await delay(10_000)
console.log('phone should be disconnected now, testing...')
const query = conn.query({
json: new BinaryNode(
'query',
{
epoch: conn.getSocket().currentEpoch().toString(),
type: 'message',
jid: '1234@s.whatsapp.net',
kind: 'before',
count: '10',
}
),
requiresPhoneConnection: true,
expect200: false
})
await waitForEvent(false)
console.log('reconnect your phone to the internet')
await waitForEvent(true)
console.log('reconnected successfully')
await expect(query).resolves.toBeDefined()
} finally {
conn.close()
}
}, 65_000)
it('should re-execute query on connection closed error', async () => {
const conn = makeConnection({
credentials: './auth_info.json'
})
await conn.open()
const { user: { jid } } = await conn.getState()
const task: Promise<any> = conn.query({ json: ['query', 'Status', jid], waitForOpen: true })
await delay(20)
// fake cancel the connection
conn.getSocket().ev.emit('message', '1234,["Pong",false]')
await delay(2000)
const json = await task
expect(json.status).toBeDefined()
conn.close()
}, 65_000)
})

View File

@@ -1,22 +1,51 @@
import type { Contact } from "./Contact"
import type { proto } from "../../WAProto"
export interface AuthenticationCredentials {
clientID: string
serverToken: string
clientToken: string
encKey: Buffer
macKey: Buffer
export type KeyPair = { public: Uint8Array, private: Uint8Array }
export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number }
export type ProtocolAddress = {
name: string // jid
deviceId: number
}
export interface AuthenticationCredentialsBase64 {
clientID: string
serverToken: string
clientToken: string
encKey: string
macKey: string
export type SignalIdentity = {
identifier: ProtocolAddress
identifierKey: Uint8Array
}
export interface AuthenticationCredentialsBrowser {
WABrowserId: string
WASecretBundle: {encKey: string, macKey: string} | string
WAToken1: string
WAToken2: string
export type CollectionType = 'regular_high' | 'regular_low' | 'critical_unblock_low' | 'critical_block'
export type AuthenticationCreds = {
noiseKey: KeyPair
signedIdentityKey: KeyPair
signedPreKey: SignedKeyPair
registrationId: number
advSecretKey: string
me?: Contact
account?: proto.ADVSignedDeviceIdentity
signalIdentities?: SignalIdentity[]
appStateSyncKeys?: proto.IAppStateSyncKey[]
appStateVersion?: {
[T in CollectionType]: number
}
firstUnuploadedPreKeyId: number
serverHasPreKeys: boolean
nextPreKeyId: number
}
export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials
type Awaitable<T> = T | Promise<T>
export type SignalKeyStore = {
getPreKey: (keyId: number) => Awaitable<KeyPair>
setPreKey: (keyId: number, pair: KeyPair | null) => Awaitable<void>
getSession: (sessionId: string) => Awaitable<any>
setSession: (sessionId: string, item: any | null) => Awaitable<void>
getSenderKey: (id: string) => Awaitable<any>
setSenderKey: (id: string, item: any | null) => Awaitable<void>
}
export type AuthenticationState = {
creds: AuthenticationCreds
keys: SignalKeyStore
}

View File

@@ -1,46 +1,30 @@
import type { proto } from "../../WAProto"
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
export enum Presence {
unavailable = 'unavailable', // "offline"
available = 'available', // "online"
composing = 'composing', // "typing..."
recording = 'recording', // "recording..."
paused = 'paused', // stop typing
}
export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'
export interface PresenceData {
lastKnownPresence: Presence
lastKnownPresence: WAPresence
lastSeen?: number
}
export interface Chat {
jid: string
t: number
/** number of unread messages, is < 0 if the chat is manually marked unread */
count: number
archive?: 'true' | 'false'
clear?: 'true' | 'false'
read_only?: 'true' | 'false'
mute?: string
pin?: string
spam?: 'false' | 'true'
modify_tag?: string
name?: string
/** when ephemeral messages were toggled on */
eph_setting_ts?: string
/** how long each message lasts for */
ephemeral?: string
export type Chat = Omit<proto.IConversation, 'messages'> & {
/** unix timestamp of date when mute ends, if applicable */
mute?: number | null
/** timestamp of when pinned */
pin?: number | null
archive?: boolean
}
export type ChatModification =
{ archive: boolean } |
{
/** pin at current timestamp, or provide timestamp of pin to remove */
pin: true | { remove: number }
pin: number | null
} |
{
/** mute for duration, or provide timestamp of mute to remove*/
mute: number | { remove: number }
mute: number | null
} |
{
clear: 'all' | { messages: { id: string, fromMe?: boolean }[] }
@@ -51,4 +35,7 @@ export type ChatModification =
star: boolean
}
} |
{
markRead: boolean
} |
{ delete: true }

View File

@@ -1,15 +1,11 @@
export interface Contact {
verify?: string
/** name of the contact, the contact has set on their own on WA */
notify?: string
jid: string
/** I have no idea */
vname?: string
id: string
/** name of the contact, you have saved on your WA */
name?: string
index?: string
/** short name for the contact */
short?: string
/** name of the contact, the contact has set on their own on WA */
notify?: string
/** I have no idea */
verifiedName?: string
// Baileys Added
imgUrl?: string
status?: string

View File

@@ -1,6 +1,6 @@
import { Contact } from "./Contact";
export type GroupParticipant = (Contact & { isAdmin: boolean; isSuperAdmin: boolean })
export type GroupParticipant = (Contact & { isAdmin?: boolean; isSuperAdmin?: boolean, admin?: 'admin' | 'superadmin' | null })
export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote'

View File

@@ -1,18 +1,18 @@
import type { ReadStream } from "fs"
import type { Logger } from "pino"
import type { URL } from "url"
import { proto } from '../../WAMessage'
import { proto } from '../../WAProto'
// export the WAMessage Prototypes
export { proto as WAMessageProto }
export type WAMessage = proto.WebMessageInfo
export { proto as WAProto }
export type WAMessage = proto.IWebMessageInfo
export type WAMessageContent = proto.IMessage
export type WAContactMessage = proto.ContactMessage
export type WAContactsArrayMessage = proto.ContactsArrayMessage
export type WAContactMessage = proto.IContactMessage
export type WAContactsArrayMessage = proto.IContactsArrayMessage
export type WAMessageKey = proto.IMessageKey
export type WATextMessage = proto.ExtendedTextMessage
export type WATextMessage = proto.IExtendedTextMessage
export type WAContextInfo = proto.IContextInfo
export type WALocationMessage = proto.LocationMessage
export type WALocationMessage = proto.ILocationMessage
export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage | proto.IStickerMessage
export import WAMessageStubType = proto.WebMessageInfo.WebMessageInfoStubType
export import WAMessageStatus = proto.WebMessageInfo.WebMessageInfoStatus
@@ -23,20 +23,10 @@ export type MessageType = keyof proto.Message
export type MediaConnInfo = {
auth: string
ttl: number
hosts: {
hostname: string
}[]
hosts: { hostname: string }[]
fetchDate: Date
}
/** Reverse stub type dictionary */
export const WA_MESSAGE_STUB_TYPES = function () {
const types = WAMessageStubType
const dict: Record<number, string> = {}
Object.keys(types).forEach(element => dict[ types[element] ] = element)
return dict
}()
export interface WAUrlInfo {
'canonical-url': string
'matched-text': string
@@ -61,7 +51,7 @@ type WithDimensions = {
width?: number
height?: number
}
export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document'
export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document' | 'history'
export type AnyMediaMessageContent = (
({
image: WAMediaUpload
@@ -121,10 +111,7 @@ export type MiscMessageGenerationOptions = {
/** the message you want to quote */
quoted?: WAMessage
/** disappearing messages settings */
ephemeralOptions?: {
expiration: number | string
eph_setting_ts?: number | string
}
ephemeralExpiration?: number | string
}
export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & {
userJid: string
@@ -143,7 +130,7 @@ export type MessageContentGenerationOptions = MediaGenerationOptions & {
}
export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent
export type MessageUpdateType = 'prepend' | 'append' | 'notify' | 'last'
export type MessageUpdateType = 'append' | 'notify'
export type MessageInfoEventMap = { [jid: string]: Date }
export interface MessageInfo {

17
src/Types/State.ts Normal file
View File

@@ -0,0 +1,17 @@
export type WAConnectionState = 'open' | 'connecting' | 'close'
export type ConnectionState = {
/** connection is now open, connecting or closed */
connection: WAConnectionState
/** the error that caused the connection to close */
lastDisconnect?: {
error: Error
date: Date
}
/** is this a new login */
isNewLogin?: boolean
/** the current QR code */
qr?: string
/** has the device received all pending notifications while it was offline */
receivedPendingNotifications?: boolean
}

View File

@@ -1,25 +0,0 @@
import type KeyedDB from '@adiwajshing/keyed-db'
import type { Chat } from './Chat'
import type { Contact } from './Contact'
export type WAConnectionState = 'open' | 'connecting' | 'close'
export type ConnectionState = {
user?: Contact
phoneConnected: boolean
phoneInfo?: any
connection: WAConnectionState
lastDisconnect?: {
error: Error,
date: Date
},
isNewLogin?: boolean
connectionTriesLeft?: number
qr?: string
}
export type BaileysState = {
connection: ConnectionState
chats: KeyedDB<Chat, string>
contacts: { [jid: string]: Contact }
}

View File

@@ -2,134 +2,56 @@ export * from './Auth'
export * from './GroupMetadata'
export * from './Chat'
export * from './Contact'
export * from './Store'
export * from './State'
export * from './Message'
import type EventEmitter from "events"
import type { Agent } from "https"
import type { Logger } from "pino"
import type { URL } from "url"
import type BinaryNode from "../BinaryNode"
import { AnyAuthenticationCredentials, AuthenticationCredentials } from './Auth'
import { AuthenticationState } from './Auth'
import { Chat, PresenceData } from './Chat'
import { Contact } from './Contact'
import { ConnectionState } from './Store'
import { ConnectionState } from './State'
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
import { MessageInfo, MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
import { proto } from '../../WAMessage'
/** used for binary messages */
export enum WAMetric {
debugLog = 1,
queryResume = 2,
liveLocation = 3,
queryMedia = 4,
queryChat = 5,
queryContact = 6,
queryMessages = 7,
presence = 8,
presenceSubscribe = 9,
group = 10,
read = 11,
chat = 12,
received = 13,
picture = 14,
status = 15,
message = 16,
queryActions = 17,
block = 18,
queryGroup = 19,
queryPreview = 20,
queryEmoji = 21,
queryRead = 22,
queryVCard = 29,
queryStatus = 30,
queryStatusUpdate = 31,
queryLiveLocation = 33,
queryLabel = 36,
queryQuickReply = 39
}
/** used for binary messages */
export enum WAFlag {
available = 160,
other = 136, // don't know this one
ignore = 1 << 7,
acknowledge = 1 << 6,
unavailable = 1 << 4,
expires = 1 << 3,
composing = 1 << 2,
recording = 1 << 2,
paused = 1 << 2
}
/** Tag used with binary queries */
export type WATag = [WAMetric, WAFlag]
export type SocketSendMessageOptions = {
json: BinaryNode | any[]
binaryTag?: WATag
tag?: string
longTag?: boolean
}
import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate } from './Message'
export type WAVersion = [number, number, number]
export type WABrowserDescription = [string, string, string]
export type ReconnectMode = 'no-reconnects' | 'on-any-error' | 'on-connection-error'
export type SocketConfig = {
/** provide an auth state object to maintain the auth state */
auth?: AuthenticationState
/** the WS url to connect to WA */
waWebSocketUrl: string | URL
/** Fails the connection if the connection times out in this time interval or no data is received */
/** Fails the connection if the socket times out in this interval */
connectTimeoutMs: number
/** max time for the phone to respond to a connectivity test */
phoneResponseTimeMs: number
/** ping-pong interval for WS connection */
keepAliveIntervalMs: number
expectResponseTimeout: number
/** proxy agent */
agent?: Agent
/** pino logger */
logger: Logger
/** version to connect with */
version: WAVersion
/** override browser config */
browser: WABrowserDescription
/** maximum attempts to connect */
maxRetries: number
connectCooldownMs: number
/** agent used for fetch requests -- uploading/downloading media */
fetchAgent?: Agent
/** credentials used to sign back in */
credentials?: AnyAuthenticationCredentials | string
/**
* Sometimes WA does not send the chats,
* this keeps pinging the phone to send the chats over
* */
queryChatsTillReceived?: boolean
/** */
pendingRequestTimeoutMs: number
reconnectMode: ReconnectMode
maxQRCodes: number
/** should the QR be printed in the terminal */
printQRInTerminal: boolean
phoneConnectionChanged: (connected: boolean) => void
}
export type SocketQueryOptions = SocketSendMessageOptions & {
timeoutMs?: number
expect200?: boolean
requiresPhoneConnection?: boolean
}
export enum DisconnectReason {
connectionClosed = 428,
connectionReplaced = 440,
connectionLost = 408,
timedOut = 408,
credentialsInvalidated = 401,
badSession = 500
loggedOut = 401,
badSession = 500,
restartRequired = 410
}
export type WAInitResponse = {
@@ -160,36 +82,40 @@ export type WABusinessProfile = {
wid?: string
}
export type QueryOptions = SocketQueryOptions & {
waitForOpen?: boolean
maxRetries?: number
startDebouncedTimeout?: boolean
}
export type CurveKeyPair = { private: Uint8Array; public: Uint8Array }
export type BaileysEventMap = {
/** connection state has been updated -- WS closed, opened, connecting etc. */
'connection.update': Partial<ConnectionState>
'credentials.update': AuthenticationCredentials
'chats.set': { chats: Chat[] }
/** auth state updated -- some pre keys, or identity keys etc. */
'auth-state.update': AuthenticationState
/** set chats (history sync), messages are reverse chronologically sorted */
'chats.set': { chats: Chat[], messages: WAMessage[] }
/** upsert chats */
'chats.upsert': Chat[]
/** update the given chats */
'chats.update': Partial<Chat>[]
/** delete chats with given ID */
'chats.delete': string[]
/** presence of contact in a chat updated */
'presence.update': { id: string, presences: { [participant: string]: PresenceData } }
'presence.update': { jid: string, presences: { [participant: string]: PresenceData } }
'contacts.set': { contacts: Contact[] }
'contacts.upsert': Contact[]
'contacts.update': Partial<Contact>[]
'messages.delete': { jid: string, ids: string[] } | { jid: string, all: true }
'messages.update': WAMessageUpdate[]
/**
* add/update the given messages. If they were received while the connection was online,
* the update will have type: "notify"
* */
'messages.upsert': { messages: WAMessage[], type: MessageUpdateType }
'message-info.update': MessageInfoUpdate[]
'groups.update': Partial<GroupMetadata>[]
'group-participants.update': { jid: string, participants: string[], action: ParticipantAction }
/** apply an action to participants in a group */
'group-participants.update': { id: string, participants: string[], action: ParticipantAction }
'blocklist.set': { blocklist: string[] }
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }

198
src/Utils/chat-utils.ts Normal file
View File

@@ -0,0 +1,198 @@
import { Boom } from '@hapi/boom'
import { aesDecrypt, hmacSign, aesEncrypt, hkdf } from "./generics"
import { AuthenticationState, ChatModification } from "../Types"
import { proto } from '../../WAProto'
import { LT_HASH_ANTI_TAMPERING } from '../WABinary/LTHash'
type SyncdType = 'regular_high' | 'regular_low'
const mutationKeys = (keydata: string) => {
const expanded = hkdf(Buffer.from(keydata, 'base64'), 160, { info: 'WhatsApp Mutation Keys' })
return {
indexKey: expanded.slice(0, 32),
valueEncryptionKey: expanded.slice(32, 64),
valueMacKey: expanded.slice(64, 96),
snapshotMacKey: expanded.slice(96, 128),
patchMacKey: expanded.slice(128, 160)
}
}
const generateMac = (operation: proto.SyncdMutation.SyncdMutationSyncdOperation, data: Buffer, keyId: Uint8Array | string, key: Buffer) => {
const getKeyData = () => {
let r: number
switch (operation) {
case proto.SyncdMutation.SyncdMutationSyncdOperation.SET:
r = 0x01
break
case proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE:
r = 0x02
break
}
const buff = Buffer.from([r])
return Buffer.concat([ buff, Buffer.from(keyId as any, 'base64') ])
}
const keyData = getKeyData()
const last = Buffer.alloc(8) // 8 bytes
last.set([ keyData.length ], last.length-1)
const total = Buffer.concat([ keyData, data, last ])
const hmac = hmacSign(total, key, 'sha512')
return hmac.slice(0, 32)
}
const to64BitNetworkOrder = function(e) {
const t = new ArrayBuffer(8)
new DataView(t).setUint32(4, e, !1)
return Buffer.from(t)
}
const generateSnapshotMac = (version: number, indexMac: Uint8Array, valueMac: Uint8Array, type: SyncdType, key: Buffer) => {
const ltHash = () => {
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(128).buffer, [ new Uint8Array(valueMac).buffer, new Uint8Array(indexMac).buffer ], [])
const buff = Buffer.from(result)
console.log(buff.toString('hex'))
return buff
}
const total = Buffer.concat([
ltHash(),
to64BitNetworkOrder(version),
Buffer.from(type, 'utf-8')
])
return hmacSign(total, key)
}
const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: SyncdType, key: Buffer) => {
const total = Buffer.concat([
snapshotMac,
...valueMacs,
to64BitNetworkOrder(version),
Buffer.from(type, 'utf-8')
])
return hmacSign(total, key)
}
export const encodeSyncdPatch = (action: ChatModification, lastMessageKey: proto.IMessageKey, { creds: { appStateSyncKeys: [key], appStateVersion } }: AuthenticationState) => {
let syncAction: proto.ISyncActionValue = { }
if('archive' in action) {
syncAction.archiveChatAction = {
archived: action.archive,
messageRange: {
messages: [
{ key: lastMessageKey }
]
}
}
} else if('mute' in action) {
const value = typeof action.mute === 'number' ? true : false
syncAction.muteAction = {
muted: value,
muteEndTimestamp: typeof action.mute === 'number' ? action.mute : undefined
}
} else if('delete' in action) {
syncAction.deleteChatAction = { }
} else if('markRead' in action) {
syncAction.markChatAsReadAction = {
read: action.markRead
}
} else if('pin' in action) {
throw new Boom('Pin not supported on multi-device yet', { statusCode: 400 })
}
const encoded = proto.SyncActionValue.encode(syncAction).finish()
const index = JSON.stringify([Object.keys(action)[0], lastMessageKey.remoteJid])
const keyValue = mutationKeys(key.keyData!.keyData! as any)
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
const macValue = generateMac(1, encValue, key.keyId!.keyId, keyValue.valueMacKey)
const indexMacValue = hmacSign(Buffer.from(index), keyValue.indexKey)
const type = 'regular_high'
const v = appStateVersion[type]+1
const snapshotMac = generateSnapshotMac(v, indexMacValue, macValue, type, keyValue.snapshotMacKey)
const patch: proto.ISyncdPatch = {
patchMac: generatePatchMac(snapshotMac, [macValue], v, type, keyValue.patchMacKey),
snapshotMac: snapshotMac,
keyId: { id: key.keyId.keyId },
mutations: [
{
operation: 1,
record: {
index: {
blob: indexMacValue
},
value: {
blob: Buffer.concat([ encValue, macValue ])
},
keyId: { id: key.keyId.keyId }
}
}
]
}
return patch
}
export const decodeSyncdPatch = (msg: proto.ISyncdPatch, {creds}: AuthenticationState) => {
const keyCache: { [_: string]: ReturnType<typeof mutationKeys> } = { }
const getKey = (keyId: Uint8Array) => {
const base64Key = Buffer.from(keyId!).toString('base64')
let key = keyCache[base64Key]
if(!key) {
const keyEnc = creds.appStateSyncKeys?.find(k => (
(k.keyId!.keyId as any) === base64Key
))
if(!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500, data: msg })
}
const result = mutationKeys(keyEnc.keyData!.keyData as any)
keyCache[base64Key] = result
key = result
}
return key
}
const mutations: { action: proto.ISyncActionValue, index: [string, string] }[] = []
const failures: Boom[] = []
/*const mainKey = getKey(msg.keyId!.id)
const mutation = msg.mutations![0]!.record
const patchMac = generatePatchMac(msg.snapshotMac, [ mutation.value!.blob!.slice(-32) ], toNumber(msg.version!.version), 'regular_low', mainKey.patchMacKey)
console.log(patchMac)
console.log(msg.patchMac)*/
// indexKey used to HMAC sign record.index.blob
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
for(const { operation, record } of msg.mutations!) {
try {
const key = getKey(record.keyId!.id!)
const content = Buffer.from(record.value!.blob!)
const encContent = content.slice(0, -32)
const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey)
if(Buffer.compare(contentHmac, content.slice(-32)) !== 0) {
throw new Boom('HMAC content verification failed')
}
const result = aesDecrypt(encContent, key.valueEncryptionKey)
const syncAction = proto.SyncActionData.decode(result)
const hmac = hmacSign(syncAction.index, key.indexKey)
if(Buffer.compare(hmac, record.index!.blob) !== 0) {
throw new Boom('HMAC index verification failed')
}
const indexStr = Buffer.from(syncAction.index).toString()
mutations.push({ action: syncAction.value!, index: JSON.parse(indexStr) })
} catch(error) {
failures.push(new Boom(error, { data: { operation, record } }))
}
}
return { mutations, failures }
}

View File

@@ -1,63 +1,103 @@
import { Boom } from '@hapi/boom'
import BinaryNode from "../BinaryNode"
import { aesDecrypt, hmacSign } from "./generics"
import { DisconnectReason, WATag } from "../Types"
import { unpadRandomMax16 } from "./generics"
import { AuthenticationState } from "../Types"
import { areJidsSameUser, BinaryNode as BinaryNodeM, encodeBinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary'
import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal'
import { proto } from '../../WAProto'
export const decodeWAMessage = (
message: string | Buffer,
auth: { macKey: Buffer, encKey: Buffer },
fromMe: boolean=false
) => {
type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status'
let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
if (commaIndex < 0) throw new Boom('invalid message', { data: message }) // if there was no comma, then this message must be not be valid
if (message[commaIndex+1] === ',') commaIndex += 1
let data = message.slice(commaIndex+1, message.length)
// get the message tag.
// If a query was done, the server will respond with the same message tag we sent the query with
const messageTag: string = message.slice(0, commaIndex).toString()
let json: any
let tags: WATag
if (data.length > 0) {
if (typeof data === 'string') {
json = JSON.parse(data) // parse the JSON
export const decodeMessageStanza = async(stanza: BinaryNodeM, auth: AuthenticationState) => {
const deviceIdentity = (stanza.content as BinaryNodeM[])?.find(m => m.tag === 'device-identity')
const deviceIdentityBytes = deviceIdentity ? deviceIdentity.content as Buffer : undefined
let msgType: MessageType
let chatId: string
let author: string
const msgId: string = stanza.attrs.id
const from: string = stanza.attrs.from
const participant: string | undefined = stanza.attrs.participant
const recipient: string | undefined = stanza.attrs.recipient
const isMe = (jid: string) => areJidsSameUser(jid, auth.creds.me!.id)
if(isJidUser(from)) {
if(recipient) {
if(!isMe(from)) {
throw new Boom('')
}
chatId = recipient
} else {
const { macKey, encKey } = auth || {}
if (!macKey || !encKey) {
throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession })
}
/*
If the data recieved was not a JSON, then it must be an encrypted message.
Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
*/
if (fromMe) {
tags = [data[0], data[1]]
data = data.slice(2, data.length)
}
const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message
data = data.slice(32, data.length) // the actual message
const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey
if (checksum.equals(computedChecksum)) {
// the checksum the server sent, must match the one we computed for the message to be valid
const decrypted = aesDecrypt(data, encKey) // decrypt using AES
json = BinaryNode.from(decrypted) // decode the binary message into a JSON array
} else {
throw new Boom('Bad checksum', {
data: {
received: checksum.toString('hex'),
computed: computedChecksum.toString('hex'),
data: data.slice(0, 80).toString(),
tag: messageTag,
message: message.slice(0, 80).toString()
},
statusCode: DisconnectReason.badSession
})
}
}
chatId = from
}
msgType = 'chat'
author = from
} else if(isJidGroup(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
msgType = 'group'
author = participant
chatId = from
} else if(isJidBroadcast(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
const isParticipantMe = isMe(participant)
if(isJidStatusBroadcast(from)) {
msgType = isParticipantMe ? 'direct_peer_status' : 'other_status'
} else {
msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast'
}
chatId = from
author = participant
}
const sender = msgType === 'chat' ? author : chatId
const successes: proto.Message[] = []
const failures: { error: Boom }[] = []
if(Array.isArray(stanza.content)) {
for(const { tag, attrs, content } of stanza.content as BinaryNodeM[]) {
if(tag !== 'enc') continue
if(!Buffer.isBuffer(content) && !(content instanceof Uint8Array)) continue
try {
let msgBuffer: Buffer
const e2eType = attrs.type
switch(e2eType) {
case 'skmsg':
msgBuffer = await decryptGroupSignalProto(sender, author, content, auth)
break
case 'pkmsg':
case 'msg':
const user = isJidUser(sender) ? sender : author
msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth)
break
}
const msg = proto.Message.decode(unpadRandomMax16(msgBuffer))
if(msg.senderKeyDistributionMessage) {
await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth)
}
successes.push(msg)
} catch(error) {
failures.push({ error: new Boom(error, { data: Buffer.from(encodeBinaryNode(stanza)).toString('base64') }) })
}
}
}
return {
msgId,
chatId,
author,
from,
timestamp: +stanza.attrs.t,
participant,
recipient,
pushname: stanza.attrs.notify,
successes,
failures
}
return [messageTag, json, tags] as const
}

View File

@@ -1,7 +1,10 @@
import { Boom } from '@hapi/boom'
import CurveCrypto from 'libsignal/src/curve25519_wrapper'
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
import HKDF from 'futoin-hkdf'
import { platform, release } from 'os'
import { KeyPair } from '../Types'
import { proto } from '../../WAProto'
import { Binary } from '../WABinary'
const PLATFORM_MAP = {
'aix': 'AIX',
@@ -9,6 +12,7 @@ const PLATFORM_MAP = {
'win32': 'Windows',
'android': 'Android'
}
export const Browsers = {
ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string],
macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string],
@@ -16,6 +20,118 @@ export const Browsers = {
/** The appropriate browser based on your OS & release */
appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string]
}
export const BufferJSON = {
replacer: (k, value: any) => {
if(Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === 'Buffer') {
return { type: 'Buffer', data: Buffer.from(value?.data || value).toString('base64') }
}
return value
},
reviver: (_, value: any) => {
if(typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) {
const val = value.data || value.value
return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val)
}
return value
}
}
export const writeRandomPadMax16 = function(e: Binary) {
function r(e: Binary, t: number) {
for (var r = 0; r < t; r++)
e.writeUint8(t)
}
var t = randomBytes(1)
r(e, 1 + (15 & t[0]))
return e
}
export const unpadRandomMax16 = (e: Uint8Array | Buffer) => {
const t = new Uint8Array(e);
if (0 === t.length) {
throw new Error('unpadPkcs7 given empty bytes');
}
var r = t[t.length - 1];
if (r > t.length) {
throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`);
}
return new Uint8Array(t.buffer, t.byteOffset, t.length - r);
}
export const encodeWAMessage = (message: proto.IMessage) => (
Buffer.from(
writeRandomPadMax16(
new Binary(proto.Message.encode(message).finish())
).readByteArray()
)
)
export const generateCurveKeyPair = (): KeyPair => {
const { pubKey, privKey } = CurveCrypto.keyPair(randomBytes(32))
return {
private: Buffer.from(privKey),
public: Buffer.from(pubKey)
}
}
export const generateSharedKey = (privateKey: Uint8Array, publicKey: Uint8Array) => {
const shared = CurveCrypto.sharedSecret(publicKey, privateKey)
return Buffer.from(shared)
}
export const curveSign = (privateKey: Uint8Array, buf: Uint8Array) => (
Buffer.from(CurveCrypto.sign(privateKey, buf))
)
export const curveVerify = (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => {
try {
CurveCrypto.verify(pubKey, message, signature)
return true
} catch(error) {
if(error.message.includes('Invalid')) {
return false
}
throw error
}
}
export const signedKeyPair = (keyPair: KeyPair, keyId: number) => {
const signKeys = generateCurveKeyPair()
const pubKey = new Uint8Array(33)
pubKey.set([5], 0)
pubKey.set(signKeys.public, 1)
const signature = curveSign(keyPair.private, pubKey)
return { keyPair: signKeys, signature, keyId }
}
export const generateRegistrationId = () => (
Uint16Array.from(randomBytes(2))[0] & 0x3fff
)
export const encodeInt = (e: number, t: number) => {
for (var r = t, a = new Uint8Array(e), i = e - 1; i >= 0; i--) {
a[i] = 255 & r
r >>>= 8
}
return a
}
export const encodeBigEndian = (e: number, t=4) => {
let r = e;
let a = new Uint8Array(t);
for (let i = t - 1; i >= 0; i--) {
a[i] = 255 & r
r >>>= 8
}
return a
}
export const toNumber = (t: Long | number) => (typeof t?.['low'] !== 'undefined' ? t['low'] : t) as number
export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net')
@@ -48,7 +164,7 @@ export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
return Buffer.concat([aes.update(buffer), aes.final()])
}
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
export function aesEncrypt(buffer: Buffer, key: Buffer) {
export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) {
const IV = randomBytes(16)
const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
@@ -59,20 +175,47 @@ export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
}
// sign HMAC using SHA 256
export function hmacSign(buffer: Buffer, key: Buffer) {
return createHmac('sha256', key).update(buffer).digest()
export function hmacSign(buffer: Buffer | Uint8Array, key: Buffer | Uint8Array, variant: 'sha256' | 'sha512' = 'sha256') {
return createHmac(variant, key).update(buffer).digest()
}
export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest()
}
// HKDF key expansion
export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' })
// from: https://github.com/benadida/node-hkdf
export function hkdf(buffer: Buffer, expandedLength: number, { info, salt }: { salt?: Buffer, info?: string }) {
const hashAlg = 'sha256'
const hashLength = 32
salt = salt || Buffer.alloc(hashLength)
// now we compute the PRK
const prk = createHmac(hashAlg, salt).update(buffer).digest()
let prev = Buffer.from([])
const buffers = []
const num_blocks = Math.ceil(expandedLength / hashLength)
const infoBuff = Buffer.from(info || [])
for (var i=0; i<num_blocks; i++) {
const hmac = createHmac(hashAlg, prk)
// XXX is there a more optimal way to build up buffers?
const input = Buffer.concat([
prev,
infoBuff,
Buffer.from(String.fromCharCode(i + 1))
]);
hmac.update(input)
prev = hmac.digest()
buffers.push(prev)
}
return Buffer.concat(buffers, expandedLength)
}
/** unix timestamp of a date in seconds */
export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000)
export type DebouncedTimeout = ReturnType<typeof debouncedTimeout>
export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => {
let timeout: NodeJS.Timeout
return {
@@ -135,14 +278,5 @@ export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>
.finally (cancel)
return p as Promise<T>
}
// whatsapp requires a message tag for every message, we just use the timestamp as one
export function generateMessageTag(epoch?: number) {
let tag = unixTimestampSeconds().toString()
if (epoch) tag += '.--' + epoch // attach epoch if provided
return tag
}
// generate a random 16 byte client ID
export const generateClientID = () => randomBytes(16).toString('base64')
// generate a random ID to attach to a message
// this is the format used for WA Web 4 byte hex prefixed with 3EB0
export const generateMessageID = () => '3EB0' + randomBytes(4).toString('hex').toUpperCase()
export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase()

25
src/Utils/history.ts Normal file
View File

@@ -0,0 +1,25 @@
import { downloadContentFromMessage } from "./messages-media";
import { proto } from "../../WAProto";
import { promisify } from 'util'
import { inflate } from "zlib";
const inflatePromise = promisify(inflate)
export const downloadIfHistory = (message: proto.IMessage) => {
if(message.protocolMessage?.historySyncNotification) {
return downloadHistory(message.protocolMessage!.historySyncNotification)
}
}
export const downloadHistory = async(msg: proto.IHistorySyncNotification) => {
const stream = await downloadContentFromMessage(msg, 'history')
let buffer = Buffer.from([])
for await(const chunk of stream) {
buffer = Buffer.concat([buffer, chunk])
}
// decompress buffer
buffer = await inflatePromise(buffer)
const syncData = proto.HistorySync.decode(buffer)
return syncData
}

View File

@@ -1,21 +1,18 @@
import type { Agent } from 'https'
import type { Logger } from 'pino'
import type { IAudioMetadata } from 'music-metadata'
import { Boom } from '@hapi/boom'
import * as Crypto from 'crypto'
import { Readable, Transform } from 'stream'
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
import { exec } from 'child_process'
import { tmpdir } from 'os'
import HttpsProxyAgent from 'https-proxy-agent'
import { URL } from 'url'
import { MessageType, WAMessageContent, WAMessageProto, WAGenericMediaMessage, WAMediaUpload } from '../Types'
import got, { Options, Response } from 'got'
import { join } from 'path'
import { generateMessageID, hkdf } from './generics'
import { Boom } from '@hapi/boom'
import { MediaType } from '../Types'
import { DEFAULT_ORIGIN } from '../Defaults'
import { once } from 'events'
import got, { Options, Response } from 'got'
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType } from '../Types'
import { generateMessageID, hkdf } from './generics'
import { DEFAULT_ORIGIN } from '../Defaults'
export const hkdfInfoKey = (type: MediaType) => {
if(type === 'sticker') type = 'image'
@@ -29,7 +26,7 @@ export function getMediaKeys(buffer, mediaType: MediaType) {
buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
}
// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, hkdfInfoKey(mediaType))
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
return {
iv: expandedMediaKey.slice(0, 16),
cipherKey: expandedMediaKey.slice(16, 48),
@@ -54,20 +51,18 @@ const extractVideoThumb = async (
export const compressImage = async (bufferOrFilePath: Buffer | string) => {
const { read, MIME_JPEG } = await import('jimp')
const jimp = await read(bufferOrFilePath as any)
const result = await jimp.resize(48, 48).getBufferAsync(MIME_JPEG)
const result = await jimp.resize(32, 32).getBufferAsync(MIME_JPEG)
return result
}
export const generateProfilePicture = async (buffer: Buffer) => {
export const generateProfilePicture = async (bufferOrFilePath: Buffer | string) => {
const { read, MIME_JPEG } = await import('jimp')
const jimp = await read (buffer)
const jimp = await read(bufferOrFilePath as any)
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
const cropped = jimp.crop (0, 0, min, min)
return {
img: await cropped.resize(640, 640).getBufferAsync (MIME_JPEG),
preview: await cropped.resize(96, 96).getBufferAsync (MIME_JPEG)
img: await cropped.resize(640, 640).getBufferAsync(MIME_JPEG),
}
}
export const ProxyAgent = (host: string | URL) => HttpsProxyAgent(host) as any as Agent
/** gets the SHA256 of the given media message */
export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
const media = Object.values(message)[0] as WAGenericMediaMessage
@@ -113,7 +108,7 @@ export async function generateThumbnail(
} else if(mediaType === 'video') {
const imgFilename = join(tmpdir(), generateMessageID() + '.jpg')
try {
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 48, height: 48 })
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 })
const buff = await fs.readFile(imgFilename)
thumbnail = buff.toString('base64')
@@ -205,6 +200,47 @@ export const encryptedStream = async(media: WAMediaUpload, mediaType: MediaType,
didSaveToTmpPath
}
}
const DEF_HOST = 'mmg.whatsapp.net'
export const downloadContentFromMessage = async(
{ mediaKey, directPath, url }: { mediaKey?: Uint8Array, directPath?: string, url?: string },
type: MediaType
) => {
const downloadUrl = url || `https://${DEF_HOST}${directPath}`
// download the message
const fetched = await getGotStream(downloadUrl, {
headers: { Origin: DEFAULT_ORIGIN }
})
let remainingBytes = Buffer.from([])
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
const output = new Transform({
transform(chunk, _, callback) {
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength =
Math.floor(data.length / 16) * 16
remainingBytes = data.slice(decryptLength)
data = data.slice(0, decryptLength)
try {
this.push(aes.update(data))
callback()
} catch(error) {
callback(error)
}
},
final(callback) {
try {
this.push(aes.final())
callback()
} catch(error) {
callback(error)
}
},
})
return fetched.pipe(output, { end: true })
}
/**
* Decode a media message (video, image, document, audio) & return decrypted buffer
* @param message the media message you want to decode
@@ -237,39 +273,7 @@ export async function decryptMediaMessageBuffer(message: WAMessageContent): Prom
} else {
messageContent = message[type]
}
// download the message
const fetched = await getGotStream(messageContent.url, {
headers: { Origin: DEFAULT_ORIGIN }
})
let remainingBytes = Buffer.from([])
const { cipherKey, iv } = getMediaKeys(messageContent.mediaKey, type.replace('Message', '') as MediaType)
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
const output = new Transform({
transform(chunk, _, callback) {
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength =
Math.floor(data.length / 16) * 16
remainingBytes = data.slice(decryptLength)
data = data.slice(0, decryptLength)
try {
this.push(aes.update(data))
callback()
} catch(error) {
callback(error)
}
},
final(callback) {
try {
this.push(aes.final())
callback()
} catch(error) {
callback(error)
}
},
})
return fetched.pipe(output, { end: true })
return downloadContentFromMessage(messageContent, type.replace('Message', '') as MediaType)
}
export function extensionForMediaMessage(message: WAMessageContent) {
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
@@ -283,10 +287,10 @@ export function extensionForMediaMessage(message: WAMessageContent) {
extension = '.jpeg'
} else {
const messageContent = message[type] as
| WAMessageProto.VideoMessage
| WAMessageProto.ImageMessage
| WAMessageProto.AudioMessage
| WAMessageProto.DocumentMessage
| WAProto.VideoMessage
| WAProto.ImageMessage
| WAProto.AudioMessage
| WAProto.DocumentMessage
extension = getExtension (messageContent.mimetype)
}
return extension

View File

@@ -1,6 +1,6 @@
import { Boom } from '@hapi/boom'
import { createReadStream, promises as fs } from "fs"
import { proto } from '../../WAMessage'
import { proto } from '../../WAProto'
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from "../Defaults"
import {
AnyMediaMessageContent,
@@ -13,7 +13,7 @@ import {
WAMediaUpload,
WAMessage,
WAMessageContent,
WAMessageProto,
WAProto,
WATextMessage,
MediaType,
WAMessageStatus
@@ -38,14 +38,15 @@ const MIMETYPE_MAP: { [T in MediaType]: string } = {
document: 'application/pdf',
audio: 'audio/ogg; codecs=opus',
sticker: 'image/webp',
history: 'application/x-protobuf'
}
const MessageTypeProto = {
'image': WAMessageProto.ImageMessage,
'video': WAMessageProto.VideoMessage,
'audio': WAMessageProto.AudioMessage,
'sticker': WAMessageProto.StickerMessage,
'document': WAMessageProto.DocumentMessage,
'image': WAProto.ImageMessage,
'video': WAProto.VideoMessage,
'audio': WAProto.AudioMessage,
'sticker': WAProto.StickerMessage,
'document': WAProto.DocumentMessage,
} as const
const ButtonType = proto.ButtonsMessage.ButtonsMessageHeaderType
@@ -69,7 +70,7 @@ export const prepareWAMessageMedia = async(
if(typeof uploadData.media === 'object' && 'url' in uploadData.media) {
const result = !!options.mediaCache && await options.mediaCache!(uploadData.media.url?.toString())
if(result) {
return WAMessageProto.Message.fromObject({
return WAProto.Message.fromObject({
[`${mediaType}Message`]: result
})
}
@@ -136,7 +137,7 @@ export const prepareWAMessageMedia = async(
}
)
}
return WAMessageProto.Message.fromObject(content)
return WAProto.Message.fromObject(content)
}
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
ephemeralExpiration = ephemeralExpiration || 0
@@ -144,13 +145,13 @@ export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: n
ephemeralMessage: {
message: {
protocolMessage: {
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
type: WAProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration
}
}
}
}
return WAMessageProto.Message.fromObject(content)
return WAProto.Message.fromObject(content)
}
/**
* Generate forwarded message content like WA does
@@ -207,14 +208,14 @@ export const generateWAMessageContent = async(
throw new Boom('require atleast 1 contact', { statusCode: 400 })
}
if(contactLen === 1) {
m.contactMessage = WAMessageProto.ContactMessage.fromObject(message.contacts.contacts[0])
m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0])
}
} else if('location' in message) {
m.locationMessage = WAMessageProto.LocationMessage.fromObject(message.location)
m.locationMessage = WAProto.LocationMessage.fromObject(message.location)
} else if('delete' in message) {
m.protocolMessage = {
key: message.delete,
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE
type: WAProto.ProtocolMessage.ProtocolMessageType.REVOKE
}
} else if('forward' in message) {
m = generateForwardMessageContent(
@@ -259,7 +260,7 @@ export const generateWAMessageContent = async(
m[messageType].contextInfo = m[messageType] || { }
m[messageType].contextInfo.mentionedJid = message.mentions
}
return WAMessageProto.Message.fromObject(m)
return WAProto.Message.fromObject(m)
}
export const generateWAMessageFromContent = (
jid: string,
@@ -290,7 +291,7 @@ export const generateWAMessageFromContent = (
}
if(
// if we want to send a disappearing message
!!options?.ephemeralOptions &&
!!options?.ephemeralExpiration &&
// and it's not a protocol message -- delete, toggle disappear message
key !== 'protocolMessage' &&
// already not converted to disappearing message
@@ -298,8 +299,8 @@ export const generateWAMessageFromContent = (
) {
message[key].contextInfo = {
...(message[key].contextInfo || {}),
expiration: options.ephemeralOptions.expiration || WA_DEFAULT_EPHEMERAL,
ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL,
//ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts?.toString()
}
message = {
ephemeralMessage: {
@@ -307,7 +308,7 @@ export const generateWAMessageFromContent = (
}
}
}
message = WAMessageProto.Message.fromObject (message)
message = WAProto.Message.fromObject (message)
const messageJSON = {
key: {
@@ -321,7 +322,7 @@ export const generateWAMessageFromContent = (
participant: jid.includes('@g.us') ? userJid : undefined,
status: WAMessageStatus.PENDING
}
return WAMessageProto.WebMessageInfo.fromObject (messageJSON)
return WAProto.WebMessageInfo.fromObject (messageJSON)
}
export const generateWAMessage = async(
jid: string,

167
src/Utils/noise-handler.ts Normal file
View File

@@ -0,0 +1,167 @@
import { sha256, generateSharedKey, hkdf } from "./generics";
import { Binary } from "../WABinary";
import { createCipheriv, createDecipheriv } from "crypto";
import { NOISE_MODE, NOISE_WA_HEADER } from "../Defaults";
import { KeyPair } from "../Types";
import { BinaryNode, decodeBinaryNode } from "../WABinary";
import { Boom } from "@hapi/boom";
import { proto } from '../../WAProto'
const generateIV = (counter: number) => {
const iv = new ArrayBuffer(12);
new DataView(iv).setUint32(8, counter);
return new Uint8Array(iv)
}
export default ({ public: publicKey, private: privateKey }: KeyPair) => {
const authenticate = (data: Uint8Array) => {
if(!isFinished) {
hash = sha256( Buffer.from(Binary.build(hash, data).readByteArray()) )
}
}
const encrypt = (plaintext: Uint8Array) => {
const authTagLength = 128 >> 3
const cipher = createCipheriv('aes-256-gcm', encKey, generateIV(writeCounter), { authTagLength })
cipher.setAAD(hash)
const result = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()])
writeCounter += 1
authenticate(result)
return result
}
const decrypt = (ciphertext: Uint8Array) => {
// before the handshake is finished, we use the same counter
// after handshake, the counters are different
const iv = generateIV(isFinished ? readCounter : writeCounter)
const cipher = createDecipheriv('aes-256-gcm', decKey, iv)
// decrypt additional adata
const tagLength = 128 >> 3
const enc = ciphertext.slice(0, ciphertext.length-tagLength)
const tag = ciphertext.slice(ciphertext.length-tagLength)
// set additional data
cipher.setAAD(hash)
cipher.setAuthTag(tag)
const result = Buffer.concat([cipher.update(enc), cipher.final()])
if(isFinished) readCounter += 1
else writeCounter += 1
authenticate(ciphertext)
return result
}
const localHKDF = (data: Uint8Array) => {
const key = hkdf(Buffer.from(data), 64, { salt, info: '' })
return [key.slice(0, 32), key.slice(32)]
}
const mixIntoKey = (data: Uint8Array) => {
const [write, read] = localHKDF(data)
salt = write
encKey = read
decKey = read
readCounter = 0
writeCounter = 0
}
const finishInit = () => {
const [write, read] = localHKDF(new Uint8Array(0))
encKey = write
decKey = read
hash = Buffer.from([])
readCounter = 0
writeCounter = 0
isFinished = true
}
const data = Binary.build(NOISE_MODE).readBuffer()
let hash = Buffer.from(data.byteLength === 32 ? data : sha256(Buffer.from(data)))
let salt = hash
let encKey = hash
let decKey = hash
let readCounter = 0
let writeCounter = 0
let isFinished = false
let sentIntro = false
const outBinary = new Binary()
const inBinary = new Binary()
authenticate(NOISE_WA_HEADER)
authenticate(publicKey)
return {
encrypt,
decrypt,
authenticate,
mixIntoKey,
finishInit,
processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => {
authenticate(serverHello!.ephemeral!)
mixIntoKey(generateSharedKey(privateKey, serverHello.ephemeral!))
const decStaticContent = decrypt(serverHello!.static!)
mixIntoKey(generateSharedKey(privateKey, decStaticContent))
const certDecoded = decrypt(serverHello!.payload!)
const { details: certDetails, signature: certSignature } = proto.NoiseCertificate.decode(certDecoded)
const { issuer: certIssuer, key: certKey } = proto.Details.decode(certDetails)
if(Buffer.compare(decStaticContent, certKey) !== 0) {
throw new Boom('certification match failed', { statusCode: 400 })
}
const keyEnc = encrypt(noiseKey.public)
mixIntoKey(generateSharedKey(noiseKey.private, serverHello!.ephemeral!))
return keyEnc
},
encodeFrame: (data: Buffer | Uint8Array) => {
if(isFinished) {
data = encrypt(data)
}
const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length
outBinary.ensureAdditionalCapacity(introSize + 3 + data.byteLength)
if (!sentIntro) {
outBinary.writeByteArray(NOISE_WA_HEADER)
sentIntro = true
}
outBinary.writeUint8(data.byteLength >> 16)
outBinary.writeUint16(65535 & data.byteLength)
outBinary.write(data)
const bytes = outBinary.readByteArray()
return bytes as Uint8Array
},
decodeFrame: (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => {
// the binary protocol uses its own framing mechanism
// on top of the WS frames
// so we get this data and separate out the frames
const getBytesSize = () => {
return (inBinary.readUint8() << 16) | inBinary.readUint16()
}
const peekSize = () => {
return !(inBinary.size() < 3) && getBytesSize() <= inBinary.size()
}
inBinary.writeByteArray(newData)
while(inBinary.peek(peekSize)) {
const bytes = getBytesSize()
let frame: Uint8Array | BinaryNode = inBinary.readByteArray(bytes)
if(isFinished) {
const result = decrypt(frame as Uint8Array)
const unpacked = new Binary(result).decompressed()
frame = decodeBinaryNode(unpacked)
}
onFrame(frame)
}
inBinary.peek(peekSize)
}
}
}

253
src/Utils/signal.ts Normal file
View File

@@ -0,0 +1,253 @@
import * as libsignal from 'libsignal'
import { encodeBigEndian, generateCurveKeyPair } from "./generics"
import { SenderKeyDistributionMessage, GroupSessionBuilder, SenderKeyRecord, SenderKeyName, GroupCipher } from '../../WASignalGroup'
import { SignalIdentity, SignalKeyStore, SignedKeyPair, KeyPair, AuthenticationState } from "../Types/Auth"
import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildUInt, jidDecode } from "../WABinary"
import { proto } from "../../WAProto"
export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => {
const newPub = Buffer.alloc(33)
newPub.set([5], 0)
newPub.set(pubKey, 1)
return newPub
}
const jidToSignalAddress = (jid: string) => jid.split('@')[0]
export const jidToSignalProtocolAddress = (jid: string) => {
return new libsignal.ProtocolAddress(jidToSignalAddress(jid), 0)
}
export const jidToSignalSenderKeyName = (group: string, user: string): string => {
return new SenderKeyName(group, jidToSignalProtocolAddress(user)).toString()
}
export const createSignalIdentity = (
wid: string,
accountSignatureKey: Uint8Array
): SignalIdentity => {
return {
identifier: { name: wid, deviceId: 0 },
identifierKey: generateSignalPubKey(accountSignatureKey)
}
}
export const getPreKeys = async({ getPreKey }: SignalKeyStore, min: number, limit: number) => {
const dict: { [id: number]: KeyPair } = { }
for(let id = min; id < limit;id++) {
const key = await getPreKey(id)
if(key) dict[+id] = key
}
return dict
}
export const generateOrGetPreKeys = ({ creds }: AuthenticationState, range: number) => {
const avaliable = creds.nextPreKeyId - creds.firstUnuploadedPreKeyId
const remaining = range - avaliable
const lastPreKeyId = creds.nextPreKeyId + remaining - 1
const newPreKeys: { [id: number]: KeyPair } = { }
if(remaining > 0) {
for(let i = creds.nextPreKeyId;i <= lastPreKeyId;i++) {
newPreKeys[i] = generateCurveKeyPair()
}
}
return {
newPreKeys,
lastPreKeyId,
preKeysRange: [creds.firstUnuploadedPreKeyId, range] as const,
}
}
export const xmppSignedPreKey = (key: SignedKeyPair): BinaryNode => (
{
tag: 'skey',
attrs: { },
content: [
{ tag: 'id', attrs: { }, content: encodeBigEndian(key.keyId, 3) },
{ tag: 'value', attrs: { }, content: key.keyPair.public },
{ tag: 'signature', attrs: { }, content: key.signature }
]
}
)
export const xmppPreKey = (pair: KeyPair, id: number): BinaryNode => (
{
tag: 'key',
attrs: { },
content: [
{ tag: 'id', attrs: { }, content: encodeBigEndian(id, 3) },
{ tag: 'value', attrs: { }, content: pair.public }
]
}
)
export const signalStorage = ({ creds, keys }: AuthenticationState) => ({
loadSession: async id => {
const sess = await keys.getSession(id)
if(sess) {
return libsignal.SessionRecord.deserialize(sess)
}
},
storeSession: async(id, session) => {
await keys.setSession(id, session.serialize())
},
isTrustedIdentity: () => {
return true
},
loadPreKey: async(id: number) => {
const key = await keys.getPreKey(id)
if(key) {
return {
privKey: Buffer.from(key.private),
pubKey: Buffer.from(key.public)
}
}
},
removePreKey: (id: number) => keys.setPreKey(id, null),
loadSignedPreKey: (keyId: number) => {
const key = creds.signedPreKey
return {
privKey: Buffer.from(key.keyPair.private),
pubKey: Buffer.from(key.keyPair.public)
}
},
loadSenderKey: async(keyId) => {
const key = await keys.getSenderKey(keyId)
if(key) return new SenderKeyRecord(key)
},
storeSenderKey: async(keyId, key) => {
await keys.setSenderKey(keyId, key.serialize())
},
getOurRegistrationId: () => (
creds.registrationId
),
getOurIdentity: () => {
const { signedIdentityKey } = creds
return {
privKey: Buffer.from(signedIdentityKey.private),
pubKey: generateSignalPubKey(signedIdentityKey.public),
}
}
})
export const decryptGroupSignalProto = (group: string, user: string, msg: Buffer | Uint8Array, auth: AuthenticationState) => {
const senderName = jidToSignalSenderKeyName(group, user)
const cipher = new GroupCipher(signalStorage(auth), senderName)
return cipher.decrypt(Buffer.from(msg))
}
export const processSenderKeyMessage = async(
authorJid: string,
item: proto.ISenderKeyDistributionMessage,
auth: AuthenticationState
) => {
const builder = new GroupSessionBuilder(signalStorage(auth))
const senderName = jidToSignalSenderKeyName(item.groupId, authorJid)
const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage)
const senderKey = await auth.keys.getSenderKey(senderName)
if(!senderKey) {
const record = new SenderKeyRecord()
await auth.keys.setSenderKey(senderName, record)
}
await builder.process(senderName, senderMsg)
}
export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg: Buffer | Uint8Array, auth: AuthenticationState) => {
const addr = jidToSignalProtocolAddress(user)
const session = new libsignal.SessionCipher(signalStorage(auth), addr)
let result: Buffer
switch(type) {
case 'pkmsg':
result = await session.decryptPreKeyWhisperMessage(msg)
break
case 'msg':
result = await session.decryptWhisperMessage(msg)
break
}
return result
}
export const encryptSignalProto = async(user: string, buffer: Buffer, auth: AuthenticationState) => {
const addr = jidToSignalProtocolAddress(user)
const cipher = new libsignal.SessionCipher(signalStorage(auth), addr)
const { type, body } = await cipher.encrypt(buffer)
return {
type: type === 3 ? 'pkmsg' : 'msg',
ciphertext: Buffer.from(body, 'binary')
}
}
export const encryptSenderKeyMsgSignalProto = async(group: string, data: Uint8Array | Buffer, auth: AuthenticationState) => {
const storage = signalStorage(auth)
const senderName = jidToSignalSenderKeyName(group, auth.creds.me!.id)
const builder = new GroupSessionBuilder(storage)
const senderKey = await auth.keys.getSenderKey(senderName)
if(!senderKey) {
const record = new SenderKeyRecord()
await auth.keys.setSenderKey(senderName, record)
}
const senderKeyDistributionMessage = await builder.create(senderName)
const session = new GroupCipher(storage, senderName)
return {
ciphertext: await session.encrypt(data) as Uint8Array,
senderKeyDistributionMessageKey: senderKeyDistributionMessage.serialize() as Buffer,
}
}
export const parseAndInjectE2ESession = async(node: BinaryNode, auth: AuthenticationState) => {
const extractKey = (key: BinaryNode) => (
key ? ({
keyId: getBinaryNodeChildUInt(key, 'id', 3),
publicKey: generateSignalPubKey(
getBinaryNodeChildBuffer(key, 'value')
),
signature: getBinaryNodeChildBuffer(key, 'signature'),
}) : undefined
)
node = getBinaryNodeChild(getBinaryNodeChild(node, 'list'), 'user')
assertNodeErrorFree(node)
const signedKey = getBinaryNodeChild(node, 'skey')
const key = getBinaryNodeChild(node, 'key')
const identity = getBinaryNodeChildBuffer(node, 'identity')
const jid = node.attrs.jid
const registrationId = getBinaryNodeChildUInt(node, 'registration', 4)
const device = {
registrationId,
identityKey: generateSignalPubKey(identity),
signedPreKey: extractKey(signedKey),
preKey: extractKey(key)
}
const cipher = new libsignal.SessionBuilder(signalStorage(auth), jidToSignalProtocolAddress(jid))
await cipher.initOutgoing(device)
}
export const extractDeviceJids = (result: BinaryNode) => {
const extracted: { user: string, device?: number, agent?: number }[] = []
for(const node of result.content as BinaryNode[]) {
const list = getBinaryNodeChild(node, 'list')?.content
if(list && Array.isArray(list)) {
for(const item of list) {
const { user } = jidDecode(item.attrs.jid)
const devicesNode = getBinaryNodeChild(item, 'devices')
const deviceListNode = getBinaryNodeChild(devicesNode, 'device-list')
if(Array.isArray(deviceListNode?.content)) {
for(const { tag, attrs } of deviceListNode!.content) {
if(tag === 'device') {
extracted.push({ user, device: +attrs.id })
}
}
}
}
}
}
return extracted
}

View File

@@ -1,116 +1,204 @@
import {Boom} from '@hapi/boom'
import * as Curve from 'curve25519-js'
import type { Contact } from '../Types/Contact'
import type { AnyAuthenticationCredentials, AuthenticationCredentials, AuthenticationCredentialsBase64, CurveKeyPair } from "../Types"
import { aesDecrypt, hkdf, hmacSign, whatsappID } from './generics'
import { readFileSync } from 'fs'
import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto'
import { proto } from '../../WAProto'
import type { AuthenticationState, SocketConfig, SignalKeyStore, AuthenticationCreds, KeyPair } from "../Types"
import { curveSign, hmacSign, curveVerify, encodeInt, generateCurveKeyPair, generateRegistrationId, signedKeyPair } from './generics'
import { BinaryNode, S_WHATSAPP_NET, jidDecode, Binary } from '../WABinary'
import { createSignalIdentity } from './signal'
export const normalizedAuthInfo = (authInfo: AnyAuthenticationCredentials | string) => {
if (!authInfo) return
if (typeof authInfo === 'string') {
const file = readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
authInfo = JSON.parse(file) as AnyAuthenticationCredentials
}
if ('clientID' in authInfo) {
authInfo = {
clientID: authInfo.clientID,
serverToken: authInfo.serverToken,
clientToken: authInfo.clientToken,
encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'),
macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'),
}
} else {
const secretBundle: {encKey: string, macKey: string} = typeof authInfo.WASecretBundle === 'string' ? JSON.parse (authInfo.WASecretBundle): authInfo.WASecretBundle
authInfo = {
clientID: authInfo.WABrowserId.replace(/\"/g, ''),
serverToken: authInfo.WAToken2.replace(/\"/g, ''),
clientToken: authInfo.WAToken1.replace(/\"/g, ''),
encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64
macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64
}
}
return authInfo as AuthenticationCredentials
const ENCODED_VERSION = 'S9Kdc4pc4EJryo21snc5cg=='
const getUserAgent = ({ version, browser }: Pick<SocketConfig, 'version' | 'browser'>) => ({
appVersion: {
primary: version[0],
secondary: version[1],
tertiary: version[2],
},
platform: 14,
releaseChannel: 0,
mcc: "000",
mnc: "000",
osVersion: browser[2],
manufacturer: "",
device: browser[1],
osBuildNumber: "0.1",
localeLanguageIso6391: 'en',
localeCountryIso31661Alpha2: 'en',
})
export const generateLoginNode = (userJid: string, config: Pick<SocketConfig, 'version' | 'browser'>) => {
const { user, device } = jidDecode(userJid)
const payload = {
passive: true,
connectType: 1,
connectReason: 1,
userAgent: getUserAgent(config),
webInfo: { webSubPlatform: 0 },
username: parseInt(user, 10),
device: device,
}
return proto.ClientPayload.encode(payload).finish()
}
export const base64EncodedAuthenticationCredentials = (creds: AnyAuthenticationCredentials) => {
const normalized = normalizedAuthInfo(creds)
return {
...normalized,
encKey: normalized.encKey.toString('base64'),
macKey: normalized.macKey.toString('base64')
} as AuthenticationCredentialsBase64
}
/**
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
* @private
* @param json
*/
export const validateNewConnection = (
json: { [_: string]: any },
auth: AuthenticationCredentials,
curveKeys: CurveKeyPair
export const generateRegistrationNode = (
{ registrationId, signedPreKey, signedIdentityKey }: Pick<AuthenticationCreds, 'registrationId' | 'signedPreKey' | 'signedIdentityKey'>,
config: Pick<SocketConfig, 'version' | 'browser'>
) => {
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
const onValidationSuccess = () => {
const user: Contact = {
jid: whatsappID(json.wid),
name: json.pushname
}
return { user, auth, phone: json.phone }
}
if (!json.secret) {
// if we didn't get a secret, we don't need it, we're validated
if (json.clientToken && json.clientToken !== auth.clientToken) {
auth = { ...auth, clientToken: json.clientToken }
}
if (json.serverToken && json.serverToken !== auth.serverToken) {
auth = { ...auth, serverToken: json.serverToken }
}
return onValidationSuccess()
}
const secret = Buffer.from(json.secret, 'base64')
if (secret.length !== 144) {
throw new Error ('incorrect secret length received: ' + secret.length)
}
const appVersionBuf = new Uint8Array(Buffer.from(ENCODED_VERSION, "base64"));
// generate shared key from our private key & the secret shared by the server
const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32))
// expand the key to 80 bytes using HKDF
const expandedKey = hkdf(sharedKey as Buffer, 80)
const companion = {
os: config.browser[0],
version: {
primary: 10,
secondary: undefined,
tertiary: undefined,
},
platformType: 1,
requireFullSync: false,
};
// perform HMAC validation.
const hmacValidationKey = expandedKey.slice(32, 64)
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
const companionProto = proto.CompanionProps.encode(companion).finish()
const hmac = hmacSign(hmacValidationMessage, hmacValidationKey)
const registerPayload = {
connectReason: 1,
connectType: 1,
passive: false,
regData: {
buildHash: appVersionBuf,
companionProps: companionProto,
eRegid: encodeInt(4, registrationId),
eKeytype: encodeInt(1, 5),
eIdent: signedIdentityKey.public,
eSkeyId: encodeInt(3, signedPreKey.keyId),
eSkeyVal: signedPreKey.keyPair.public,
eSkeySig: signedPreKey.signature,
},
userAgent: getUserAgent(config),
webInfo: {
webSubPlatform: 0,
},
}
if (!hmac.equals(secret.slice(32, 64))) {
// if the checksums didn't match
throw new Boom('HMAC validation failed', { statusCode: 400 })
}
// computed HMAC should equal secret[32:64]
// expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp
// they are encrypted using key: expandedKey[0:32]
const encryptedAESKeys = Buffer.concat([
expandedKey.slice(64, expandedKey.length),
secret.slice(64, secret.length),
])
const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
// set the credentials
auth = {
encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages
macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages
clientToken: json.clientToken,
serverToken: json.serverToken,
clientID: auth.clientID,
}
return onValidationSuccess()
return proto.ClientPayload.encode(registerPayload).finish()
}
export const computeChallengeResponse = (challenge: string, auth: AuthenticationCredentials) => {
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
const signed = hmacSign(bytes, auth.macKey).toString('base64') // sign the challenge string with our macKey
return[ 'admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID
export const initInMemoryKeyStore = (
{ preKeys, sessions, senderKeys }: {
preKeys?: { [k: number]: KeyPair },
sessions?: { [k: string]: any },
senderKeys?: { [k: string]: any }
} = { },
) => {
preKeys = preKeys || { }
sessions = sessions || { }
senderKeys = senderKeys || { }
return {
preKeys,
sessions,
senderKeys,
getPreKey: keyId => preKeys[keyId],
setPreKey: (keyId, pair) => {
if(pair) preKeys[keyId] = pair
else delete preKeys[keyId]
},
getSession: id => sessions[id],
setSession: (id, item) => {
if(item) sessions[id] = item
else delete sessions[id]
},
getSenderKey: id => {
return senderKeys[id]
},
setSenderKey: (id, item) => {
if(item) senderKeys[id] = item
else delete senderKeys[id]
}
} as SignalKeyStore
}
export const initAuthState = (): AuthenticationState => {
const identityKey = generateCurveKeyPair()
return {
creds: {
noiseKey: generateCurveKeyPair(),
signedIdentityKey: identityKey,
signedPreKey: signedKeyPair(identityKey, 1),
registrationId: generateRegistrationId(),
advSecretKey: randomBytes(32).toString('base64'),
nextPreKeyId: 1,
firstUnuploadedPreKeyId: 1,
serverHasPreKeys: false
},
keys: initInMemoryKeyStore()
}
}
export const configureSuccessfulPairing = (
stanza: BinaryNode,
{ advSecretKey, signedIdentityKey, signalIdentities }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => {
const pair = stanza.content[0] as BinaryNode
const pairContent = Array.isArray(pair.content) ? pair.content : []
const msgId = stanza.attrs.id
const deviceIdentity = pairContent.find(m => m.tag === 'device-identity')?.content
const businessName = pairContent.find(m => m.tag === 'biz')?.attrs?.name
const verifiedName = businessName || ''
const jid = pairContent.find(m => m.tag === 'device')?.attrs?.jid
const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentity as Buffer)
const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac, advSign) !== 0) {
throw new Boom('Invalid pairing')
}
const account = proto.ADVSignedDeviceIdentity.decode(details)
const { accountSignatureKey, accountSignature } = account
const accountMsg = Binary.build(new Uint8Array([6, 0]), account.details, signedIdentityKey.public).readByteArray()
if (!curveVerify(accountSignatureKey, accountMsg, accountSignature)) {
throw new Boom('Failed to verify account signature')
}
const deviceMsg = Binary.build(new Uint8Array([6, 1]), account.details, signedIdentityKey.public, account.accountSignatureKey).readByteArray()
account.deviceSignature = curveSign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(jid, accountSignatureKey)
const keyIndex = proto.ADVDeviceIdentity.decode(account.details).keyIndex
const accountEnc = proto.ADVSignedDeviceIdentity.encode({
...account.toJSON(),
accountSignatureKey: undefined
}).finish()
const reply: BinaryNode = {
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: msgId,
},
content: [
{
tag: 'pair-device-sign',
attrs: { },
content: [
{ tag: 'device-identity', attrs: { 'key-index': `${keyIndex}` }, content: accountEnc }
]
}
]
}
const authUpdate: Partial<AuthenticationCreds> = {
account,
me: { id: jid, verifiedName },
signalIdentities: [...(signalIdentities || []), identity]
}
return {
creds: authUpdate,
reply
}
}

47
src/WABinary/JidUtils.ts Normal file
View File

@@ -0,0 +1,47 @@
export const S_WHATSAPP_NET = '@s.whatsapp.net'
export const OFFICIAL_BIZ_JID = '16505361212@c.us'
export const SERVER_JID = 'server@c.us'
export const PSA_WID = '0@c.us';
export const STORIES_JID = 'status@broadcast'
export type JidServer = 'c.us' | 'g.us' | 'broadcast' | 's.whatsapp.net' | 'call'
export const jidEncode = (user: string | number | null, server: JidServer, device?: number, agent?: number) => {
return `${user || ''}${!!agent ? `_${agent}` : ''}${!!device ? `:${device}` : ''}@${server}`
}
export const jidDecode = (jid: string) => {
let sepIdx = typeof jid === 'string' ? jid.indexOf('@') : -1
if(sepIdx < 0) {
return undefined
}
const server = jid.slice(sepIdx+1)
const userCombined = jid.slice(0, sepIdx)
const [userAgent, device] = userCombined.split(':')
const [user, agent] = userAgent.split('_')
return {
server,
user,
agent: agent ? +agent : undefined,
device: device ? +device : undefined
}
}
/** is the jid a user */
export const areJidsSameUser = (jid1: string, jid2: string) => (
jidDecode(jid1)?.user === jidDecode(jid2)?.user
)
/** is the jid a user */
export const isJidUser = (jid: string) => (jid?.endsWith('@s.whatsapp.net'))
/** is the jid a broadcast */
export const isJidBroadcast = (jid: string) => (jid?.endsWith('@broadcast'))
/** is the jid a broadcast */
export const isJidGroup = (jid: string) => (jid?.endsWith('@g.us'))
/** is the jid the status broadcast */
export const isJidStatusBroadcast = (jid: string) => jid === 'status@broadcast'
export const jidNormalizedUser = (jid: string) => {
const { user, server } = jidDecode(jid)
return jidEncode(user, server === 'c.us' ? 's.whatsapp.net' : server as JidServer)
}

48
src/WABinary/LTHash.js Normal file
View File

@@ -0,0 +1,48 @@
import { hkdf } from "../Utils/generics";
const o = 128;
class d {
constructor(e) {
this.salt = e
}
add(e, t) {
var r = this;
for(const item of t) {
e = r._addSingle(e, item)
}
return e
}
subtract(e, t) {
var r = this;
for(const item of t) {
e = r._subtractSingle(e, item)
}
return e
}
subtractThenAdd(e, t, r) {
var n = this;
return n.add(n.subtract(e, r), t)
}
_addSingle(e, t) {
var r = this;
const n = new Uint8Array(hkdf(t, o, { info: r.salt })).buffer;
return r.performPointwiseWithOverflow(e, n, ((e,t)=>e + t))
}
_subtractSingle(e, t) {
var r = this;
const n = new Uint8Array(hkdf(t, o, { info: r.salt })).buffer;
return r.performPointwiseWithOverflow(e, n, ((e,t)=>e - t))
}
performPointwiseWithOverflow(e, t, r) {
const n = new DataView(e)
, i = new DataView(t)
, a = new ArrayBuffer(n.byteLength)
, s = new DataView(a);
for (let e = 0; e < n.byteLength; e += 2)
s.setUint16(e, r(n.getUint16(e, !0), i.getUint16(e, !0)), !0);
return a
}
}
export const LT_HASH_ANTI_TAMPERING = new d('WhatsApp Patch Integrity')

305
src/WABinary/index.ts Normal file
View File

@@ -0,0 +1,305 @@
import { DICTIONARIES_MAP, SINGLE_BYTE_TOKEN, SINGLE_BYTE_TOKEN_MAP, DICTIONARIES } from '../../WABinary/Constants';
import { jidDecode, jidEncode } from './JidUtils';
import { Binary, numUtf8Bytes } from '../../WABinary/Binary';
import { Boom } from '@hapi/boom';
const LIST1 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '.', '<27>', '<27>', '<27>', '<27>'];
const LIST2 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
function k(data: Binary, uint: number) {
let arr = [];
for (let a = 0; a < uint; a++) {
arr.push(decodeBinaryNode(data));
}
return arr;
}
function x(data: Binary, t, r, a) {
const arr = new Array(2 * a - r);
for (let n = 0; n < arr.length - 1; n += 2) {
var s = data.readUint8();
(arr[n] = t[s >>> 4]), (arr[n + 1] = t[15 & s]);
}
if (r) {
arr[arr.length - 1] = t[data.readUint8() >>> 4];
}
return arr.join('');
}
function D(e, t, r) {
var a = e.length % 2 == 1;
r.writeUint8(t);
var i = Math.ceil(e.length / 2);
a && (i |= 128), r.writeUint8(i);
for (var n = 0, s = 0; s < e.length; s++) {
var o = e.charCodeAt(s),
l = null;
if ((48 <= o && o <= 57 ? (l = o - 48) : 255 === t ? (45 === o ? (l = 10) : 46 === o && (l = 11)) : 251 === t && 65 <= o && o <= 70 && (l = o - 55), null == l))
throw new Error(`Cannot nibble encode ${o}`);
s % 2 == 0 ? ((n = l << 4), s === e.length - 1 && ((n |= 15), r.writeUint8(n))) : ((n |= l), r.writeUint8(n));
}
}
function N(e, t) {
if (e < 256) t.writeUint8(252), t.writeUint8(e);
else if (e < 1048576) t.writeUint8(253), t.writeUint8((e >>> 16) & 255), t.writeUint8((e >>> 8) & 255), t.writeUint8(255 & e);
else {
if (!(e < 4294967296)) throw new Error(`Binary with length ${e} is too big for WAP protocol`);
t.writeUint8(254), t.writeUint32(e);
}
}
function R(e: any, t: Binary) {
var w = null;
if ('' === e) return t.writeUint8(252), void t.writeUint8(0);
var b = SINGLE_BYTE_TOKEN_MAP;
var r = b.get(e);
var c = [236, 237, 238, 239];
if (null == r) {
if (null == w) {
w = [];
for (var a = 0; a < DICTIONARIES_MAP.length; ++a) w.push(DICTIONARIES_MAP[a]);
}
for (var n = 0; n < w.length; ++n) {
var s = w[n].get(e);
if (null != s) return t.writeUint8(c[n]), void t.writeUint8(s);
}
var o = numUtf8Bytes(e);
if (o < 128) {
if (!/[^0-9.-]+?/.exec(e)) return void D(e, 255, t);
if (!/[^0-9A-F]+?/.exec(e)) return void D(e, 251, t);
}
N(o, t), t.writeString(e);
} else t.writeUint8(r + 1);
}
function M(e: any, t: Binary) {
var p = 248;
var f = 249;
if (void 0 === e.tag) return t.writeUint8(p), void t.writeUint8(0);
var r = 1;
e.attrs && (r += 2 * Object.keys(e.attrs).length),
e.content && r++,
r < 256 ? (t.writeUint8(p), t.writeUint8(r)) : r < 65536 && (t.writeUint8(f), t.writeUint16(r)),
O(e.tag, t),
e.attrs &&
Object.keys(e.attrs).forEach((r) => {
R(r, t), O(e.attrs[r], t);
});
var a = e.content;
if (Array.isArray(a)) {
a.length < 256 ? (t.writeUint8(p), t.writeUint8(a.length)) : a.length < 65536 && (t.writeUint8(f), t.writeUint16(a.length));
for (var i = 0; i < a.length; i++) M(a[i], t);
} else a && O(a, t);
}
function L(data: Binary, t: boolean) {
const n = data.readUint8();
if (n === 0) {
return null;
}
if (n === 248) {
return k(data, data.readUint8());
}
if (n === 249) {
return k(data, data.readUint16());
}
if (n === 252) {
return t ? data.readString(data.readUint8()) : data.readByteArray(data.readUint8());
}
if (n === 253) {
const size = ((15 & data.readUint8()) << 16) + (data.readUint8() << 8) + data.readUint8();
return t ? data.readString(size) : data.readByteArray(size);
}
if (n === 254) {
return t ? data.readString(data.readUint32()) : data.readByteArray(data.readUint32());
}
if (n === 250) {
const user = L(data, true);
if (null != user && 'string' != typeof user) throw new Error(`Decode string got invalid value ${String(t)}, string expected`);
const server = decodeStanzaString(data)
return jidEncode(user, server)
}
if (n === 247) {
const agent = data.readUint8();
const device = data.readUint8();
const user = decodeStanzaString(data);
return jidEncode(user, 's.whatsapp.net', device, agent);
}
if (n === 255) {
const number = data.readUint8();
return x(data, LIST1, number >>> 7, 127 & number);
}
if (n === 251) {
const number = data.readUint8();
return x(data, LIST2, number >>> 7, 127 & number);
}
if (n <= 0 || n >= 240) {
throw new Error('Unable to decode WAP buffer');
}
if (n >= 236 && n <= 239) {
const dict = DICTIONARIES[n - 236];
if (!dict) {
throw new Error(`Missing WAP dictionary ${n - 236}`);
}
const index = data.readUint8();
const value = dict[index];
if (!value) {
throw new Error(`Invalid value index ${index} in dict ${n - 236}`);
}
return value;
}
const singleToken = SINGLE_BYTE_TOKEN[n - 1];
if (!singleToken) throw new Error(`Undefined token with index ${n}`);
return singleToken;
}
function O(e: any, t: Binary) {
if (null == e) t.writeUint8(0);
else if (typeof e === 'object' && !(e instanceof Uint8Array) && !Buffer.isBuffer(e) && !Array.isArray(e)) M(e, t);
else if ('string' == typeof e) {
const jid = jidDecode(e)
if(jid) {
if(typeof jid.agent !== 'undefined' || typeof jid.device !== 'undefined') {
var { user: a, agent: i, device: n } = jid;
t.writeUint8(247), t.writeUint8(i || 0), t.writeUint8(n || 0), O(a, t);
} else {
var { user: s, server: l } = jid;
t.writeUint8(250), null != s ? O(s, t) : t.writeUint8(0), O(l, t);
}
} else {
R(e, t);
}
} else {
if (!(e instanceof Uint8Array)) throw new Error('Invalid payload type ' + typeof e);
N(e.length, t), t.writeByteArray(e);
}
}
function decodeStanzaString(data: Binary) {
// G
const t = L(data, true);
if (typeof t != 'string') {
throw new Error(`Decode string got invalid value ${String(t)}, string expected`);
}
return t;
}
function bufferToUInt(e: Uint8Array | Buffer, t: number) {
let a = 0
for (let i = 0; i < t; i++) a = 256 * a + e[i]
return a
}
/**
* the binary node WA uses internally for communication
*
* this is manipulated soley as an object and it does not have any functions.
* This is done for easy serialization, to prevent running into issues with prototypes &
* to maintain functional code structure
* */
export type BinaryNode = {
tag: string
attrs: { [key: string]: string }
content?: BinaryNode[] | string | Uint8Array
}
export type BinaryNodeAttributes = BinaryNode['attrs']
export type BinaryNodeData = BinaryNode['content']
export const decodeBinaryNode = (data: Binary): BinaryNode => {
//U
let r = data.readUint8();
let t = r === 248 ? data.readUint8() : data.readUint16();
if (!t) {
throw new Error('Failed to decode node, list cannot be empty');
}
const a = {};
const n = decodeStanzaString(data);
for (t -= 1; t > 1; ) {
const s = decodeStanzaString(data);
const l = L(data, true);
a[s] = l;
t -= 2;
}
let i = null;
1 === t && jidDecode(i = L(data, !1)) && (i = String(i));
return {
tag: n,
attrs: a,
content: i
}
}
export const encodeBinaryNode = (node: BinaryNode) => {
const data = new Binary();
O(node, data);
const dataArr = data.readByteArray();
const result = new Uint8Array(1 + dataArr.length);
result[0] = 0;
result.set(dataArr, 1);
return result;
}
// some extra useful utilities
export const getBinaryNodeChildren = ({ content }: BinaryNode, childTag: string) => {
if(Array.isArray(content)) {
return content.filter(item => item.tag == childTag)
}
return []
}
export const getBinaryNodeChild = ({ content }: BinaryNode, childTag: string) => {
if(Array.isArray(content)) {
return content.find(item => item.tag == childTag)
}
}
export const getBinaryNodeChildBuffer = (node: BinaryNode, childTag: string) => {
const child = getBinaryNodeChild(node, childTag)?.content
if(Buffer.isBuffer(child) || child instanceof Uint8Array) {
return child
}
}
export const getBinaryNodeChildUInt = (node: BinaryNode, childTag: string, length: number) => {
const buff = getBinaryNodeChildBuffer(node, childTag)
if(buff) return bufferToUInt(buff, length)
}
export const assertNodeErrorFree = (node: BinaryNode) => {
const errNode = getBinaryNodeChild(node, 'error')
if(errNode) {
throw new Boom(errNode.attrs.text || 'Unknown error', { data: +errNode.attrs.code })
}
}
export * from './JidUtils'
export { Binary } from '../../WABinary/Binary'

View File

@@ -1,9 +1,11 @@
import makeConnection from './Connection'
import makeWASocket from './Socket'
export * from '../WAMessage'
export * from '../WAProto'
export * from './Utils'
export * from './Types'
export * from './Store'
//export * from './Store'
export * from './Defaults'
export default makeConnection
export type WASocket = ReturnType<typeof makeWASocket>
export default makeWASocket