chore: add linting

This commit is contained in:
Adhiraj Singh
2022-01-19 15:54:02 +05:30
parent f7f86e69d6
commit 8f11f0be76
49 changed files with 5800 additions and 4314 deletions

6
.eslintignore Normal file
View File

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

3
.eslintrc.json Normal file
View File

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

View File

@@ -31,6 +31,10 @@ const startSock = () => {
await sock.sendMessage(jid, msg) await sock.sendMessage(jid, msg)
} }
sock.ev.on('chats.set', item => console.log(`recv ${item.chats.length} chats (is latest: ${item.isLatest})`))
sock.ev.on('messages.set', item => console.log(`recv ${item.messages.length} messages (is latest: ${item.isLatest})`))
sock.ev.on('contacts.set', item => console.log(`recv ${item.contacts.length} contacts`))
sock.ev.on('messages.upsert', async m => { sock.ev.on('messages.upsert', async m => {
console.log(JSON.stringify(m, undefined, 2)) console.log(JSON.stringify(m, undefined, 2))
@@ -46,7 +50,7 @@ const startSock = () => {
sock.ev.on('messages.update', m => console.log(m)) sock.ev.on('messages.update', m => console.log(m))
sock.ev.on('presence.update', m => console.log(m)) sock.ev.on('presence.update', m => console.log(m))
sock.ev.on('chats.update', m => console.log(m)) sock.ev.on('chats.update', m => console.log(m))
sock.ev.on('contacts.update', m => console.log(m)) sock.ev.on('contacts.upsert', m => console.log(m))
sock.ev.on('connection.update', (update) => { sock.ev.on('connection.update', (update) => {
const { connection, lastDisconnect } = update const { connection, lastDisconnect } = update

View File

@@ -19,14 +19,15 @@
"scripts": { "scripts": {
"test": "jest", "test": "jest",
"prepare": "tsc", "prepare": "tsc",
"lint": "eslint '*/*.ts' --quiet --fix",
"build:all": "tsc && typedoc", "build:all": "tsc && typedoc",
"build:docs": "typedoc", "build:docs": "typedoc",
"build:tsc": "tsc", "build:tsc": "tsc",
"example": "node --inspect -r ts-node/register Example/example.ts", "example": "node --inspect -r ts-node/register Example/example.ts",
"example:legacy": "node --inspect -r ts-node/register Example/example-legacy.ts", "example:legacy": "node --inspect -r ts-node/register Example/example-legacy.ts",
"gen-protobuf": "bash src/BinaryNode/GenerateStatics.sh", "gen-protobuf": "bash src/BinaryNode/GenerateStatics.sh",
"browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts" "browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts",
"lint": "eslint ./src --ext .js,.ts,.jsx,.tsx",
"lint:fix": "eslint ./src --fix --ext .js,.ts,.jsx,.tsx"
}, },
"author": "Adhiraj Singh", "author": "Adhiraj Singh",
"license": "MIT", "license": "MIT",
@@ -56,12 +57,14 @@
"WABinary/*.js" "WABinary/*.js"
], ],
"devDependencies": { "devDependencies": {
"@adiwajshing/eslint-config": "git+https://github.com/adiwajshing/eslint-config",
"@types/got": "^9.6.11", "@types/got": "^9.6.11",
"@types/jest": "^26.0.24", "@types/jest": "^26.0.24",
"@types/node": "^14.6.2", "@types/node": "^14.6.2",
"@types/pino": "^6.3.2", "@types/pino": "^6.3.2",
"@types/sharp": "^0.29.4", "@types/sharp": "^0.29.4",
"@types/ws": "^8.0.0", "@types/ws": "^8.0.0",
"eslint": "^7.0.0",
"jest": "^27.0.6", "jest": "^27.0.6",
"jimp": "^0.16.1", "jimp": "^0.16.1",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",

View File

@@ -1,6 +1,6 @@
import P from "pino" import P from 'pino'
import type { MediaType, SocketConfig, LegacySocketConfig, CommonSocketConfig } from "../Types" import type { CommonSocketConfig, LegacySocketConfig, MediaType, SocketConfig } from '../Types'
import { Browsers } from "../Utils" import { Browsers } from '../Utils'
export const UNAUTHORIZED_CODES = [401, 403, 419] export const UNAUTHORIZED_CODES = [401, 403, 419]
@@ -18,40 +18,40 @@ export const NOISE_WA_HEADER = new Uint8Array([87, 65, 5, 2]) // last is "DICT_V
export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi
const BASE_CONNECTION_CONFIG: CommonSocketConfig<any> = { const BASE_CONNECTION_CONFIG: CommonSocketConfig<any> = {
version: [2, 2147, 16], version: [2, 2147, 16],
browser: Browsers.baileys('Chrome'), browser: Browsers.baileys('Chrome'),
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000, connectTimeoutMs: 20_000,
keepAliveIntervalMs: 25_000, keepAliveIntervalMs: 25_000,
logger: P().child({ class: 'baileys' }), logger: P().child({ class: 'baileys' }),
printQRInTerminal: false, printQRInTerminal: false,
emitOwnEvents: true, emitOwnEvents: true,
defaultQueryTimeoutMs: 60_000, defaultQueryTimeoutMs: 60_000,
customUploadHosts: [], customUploadHosts: [],
} }
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
...BASE_CONNECTION_CONFIG, ...BASE_CONNECTION_CONFIG,
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
getMessage: async() => undefined getMessage: async() => undefined
} }
export const DEFAULT_LEGACY_CONNECTION_CONFIG: LegacySocketConfig = { export const DEFAULT_LEGACY_CONNECTION_CONFIG: LegacySocketConfig = {
...BASE_CONNECTION_CONFIG, ...BASE_CONNECTION_CONFIG,
waWebSocketUrl: 'wss://web.whatsapp.com/ws', waWebSocketUrl: 'wss://web.whatsapp.com/ws',
phoneResponseTimeMs: 20_000, phoneResponseTimeMs: 20_000,
expectResponseTimeout: 60_000, expectResponseTimeout: 60_000,
} }
export const MEDIA_PATH_MAP: { [T in MediaType]: string } = { export const MEDIA_PATH_MAP: { [T in MediaType]: string } = {
image: '/mms/image', image: '/mms/image',
video: '/mms/video', video: '/mms/video',
document: '/mms/document', document: '/mms/document',
audio: '/mms/audio', audio: '/mms/audio',
sticker: '/mms/image', sticker: '/mms/image',
history: '', history: '',
'md-app-state': '' 'md-app-state': ''
} }
export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]

View File

@@ -1,8 +1,8 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import EventEmitter from "events" import EventEmitter from 'events'
import { LegacyBaileysEventEmitter, LegacySocketConfig, CurveKeyPair, WAInitResponse, ConnectionState, DisconnectReason } from "../Types" import { ConnectionState, CurveKeyPair, DisconnectReason, LegacyBaileysEventEmitter, LegacySocketConfig, WAInitResponse } from '../Types'
import { newLegacyAuthCreds, bindWaitForConnectionUpdate, computeChallengeResponse, validateNewConnection, Curve, printQRIfNecessaryListener } from "../Utils" import { bindWaitForConnectionUpdate, computeChallengeResponse, Curve, newLegacyAuthCreds, printQRIfNecessaryListener, validateNewConnection } from '../Utils'
import { makeSocket } from "./socket" import { makeSocket } from './socket'
const makeAuthSocket = (config: LegacySocketConfig) => { const makeAuthSocket = (config: LegacySocketConfig) => {
const { const {
@@ -60,14 +60,15 @@ const makeAuthSocket = (config: LegacySocketConfig) => {
* If connected, invalidates the credentials with the server * If connected, invalidates the credentials with the server
*/ */
const logout = async() => { const logout = async() => {
if(state.connection === 'open') { if(state.connection === 'open') {
await socket.sendNode({ await socket.sendNode({
json: ['admin', 'Conn', 'disconnect'], json: ['admin', 'Conn', 'disconnect'],
tag: 'goodbye' tag: 'goodbye'
}) })
} }
// will call state update to close connection // will call state update to close connection
socket?.end( socket?.end(
new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut }) new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut })
) )
} }
@@ -86,13 +87,15 @@ const makeAuthSocket = (config: LegacySocketConfig) => {
const qr = [ref, publicKey, authInfo.clientID].join(',') const qr = [ref, publicKey, authInfo.clientID].join(',')
updateState({ qr }) updateState({ qr })
initTimeout = setTimeout(async () => { initTimeout = setTimeout(async() => {
if(state.connection !== 'connecting') return if(state.connection !== 'connecting') {
return
}
logger.debug('regenerating QR') logger.debug('regenerating QR')
try { try {
// request new QR // request new QR
const {ref: newRef, ttl: newTTL} = await socket.query({ const { ref: newRef, ttl: newTTL } = await socket.query({
json: ['admin', 'Conn', 'reref'], json: ['admin', 'Conn', 'reref'],
expect200: true, expect200: true,
longTag: true, longTag: true,
@@ -100,94 +103,104 @@ const makeAuthSocket = (config: LegacySocketConfig) => {
}) })
ttl = newTTL ttl = newTTL
ref = newRef ref = newRef
} catch (error) { } catch(error) {
logger.error({ error }, `error in QR gen`) logger.error({ error }, 'error in QR gen')
if (error.output?.statusCode === 429) { // too many QR requests if(error.output?.statusCode === 429) { // too many QR requests
socket.end(error) socket.end(error)
return return
} }
} }
qrGens += 1 qrGens += 1
qrLoop(ttl) qrLoop(ttl)
}, ttl || 20_000) // default is 20s, on the off-chance ttl is not present }, ttl || 20_000) // default is 20s, on the off-chance ttl is not present
} }
qrLoop(ttl) qrLoop(ttl)
} }
const onOpen = async() => { const onOpen = async() => {
const canDoLogin = canLogin() const canDoLogin = canLogin()
const initQuery = (async () => { const initQuery = (async() => {
const {ref, ttl} = await socket.query({ const { ref, ttl } = await socket.query({
json: ['admin', 'init', version, browser, authInfo.clientID, true], json: ['admin', 'init', version, browser, authInfo.clientID, true],
expect200: true, expect200: true,
longTag: true, longTag: true,
requiresPhoneConnection: false requiresPhoneConnection: false
}) as WAInitResponse }) as WAInitResponse
if (!canDoLogin) { if(!canDoLogin) {
generateKeysForAuth(ref, ttl) generateKeysForAuth(ref, ttl)
} }
})(); })()
let loginTag: string let loginTag: string
if(canDoLogin) { if(canDoLogin) {
updateEncKeys() updateEncKeys()
// if we have the info to restore a closed session // if we have the info to restore a closed session
const json = [ const json = [
'admin', 'admin',
'login', 'login',
authInfo.clientToken, authInfo.clientToken,
authInfo.serverToken, authInfo.serverToken,
authInfo.clientID, authInfo.clientID,
'takeover' 'takeover'
] ]
loginTag = socket.generateMessageTag(true) loginTag = socket.generateMessageTag(true)
// send login every 10s // send login every 10s
const sendLoginReq = () => { const sendLoginReq = () => {
if(state.connection === 'open') { if(state.connection === 'open') {
logger.warn('Received login timeout req when state=open, ignoring...') logger.warn('Received login timeout req when state=open, ignoring...')
return return
} }
logger.info('sending login request')
socket.sendNode({ logger.info('sending login request')
socket.sendNode({
json, json,
tag: loginTag tag: loginTag
}) })
initTimeout = setTimeout(sendLoginReq, 10_000) initTimeout = setTimeout(sendLoginReq, 10_000)
} }
sendLoginReq()
}
await initQuery
// wait for response with tag "s1" sendLoginReq()
let response = await Promise.race( }
[
await initQuery
// wait for response with tag "s1"
let response = await Promise.race(
[
socket.waitForMessage('s1', false, undefined).promise, socket.waitForMessage('s1', false, undefined).promise,
...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs).promise] : []) ...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs).promise] : [])
] ]
) )
initTimeout && clearTimeout(initTimeout) initTimeout && clearTimeout(initTimeout)
initTimeout = undefined initTimeout = undefined
if(response.status && response.status !== 200) { if(response.status && response.status !== 200) {
throw new Boom(`Unexpected error in login`, { data: response, statusCode: response.status }) 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) { // if its a challenge request (we get it when logging in)
if(response[1]?.challenge) {
const json = computeChallengeResponse(response[1].challenge, authInfo) const json = computeChallengeResponse(response[1].challenge, authInfo)
logger.info('resolving login challenge') logger.info('resolving login challenge')
await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs }) await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs })
response = await socket.waitForMessage('s2', true).promise response = await socket.waitForMessage('s2', true).promise
} }
if(!response || !response[1]) { if(!response || !response[1]) {
throw new Boom('Received unexpected login response', { data: response }) throw new Boom('Received unexpected login response', { data: response })
} }
if(response[1].type === 'upgrade_md_prod') { if(response[1].type === 'upgrade_md_prod') {
throw new Boom('Require multi-device edition', { statusCode: DisconnectReason.multideviceMismatch }) throw new Boom('Require multi-device edition', { statusCode: DisconnectReason.multideviceMismatch })
} }
// validate the new connection
const {user, auth} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection // validate the new connection
const isNewLogin = user.id !== state.legacy!.user?.id const { user, auth } = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
const isNewLogin = user.id !== state.legacy!.user?.id
Object.assign(authInfo, auth) Object.assign(authInfo, auth)
updateEncKeys() updateEncKeys()
@@ -206,6 +219,7 @@ const makeAuthSocket = (config: LegacySocketConfig) => {
qr: undefined qr: undefined
}) })
} }
ws.once('open', async() => { ws.once('open', async() => {
try { try {
await onOpen() await onOpen()
@@ -219,10 +233,10 @@ const makeAuthSocket = (config: LegacySocketConfig) => {
} }
process.nextTick(() => { process.nextTick(() => {
ev.emit('connection.update', { ev.emit('connection.update', {
...state ...state
}) })
}) })
return { return {
...socket, ...socket,
@@ -231,8 +245,9 @@ const makeAuthSocket = (config: LegacySocketConfig) => {
ev, ev,
canLogin, canLogin,
logout, logout,
/** Waits for the connection to WA to reach a state */ /** Waits for the connection to WA to reach a state */
waitForConnectionUpdate: bindWaitForConnectionUpdate(ev) waitForConnectionUpdate: bindWaitForConnectionUpdate(ev)
} }
} }
export default makeAuthSocket export default makeAuthSocket

View File

@@ -1,7 +1,7 @@
import { BinaryNode, jidNormalizedUser } from "../WABinary"; import { BaileysEventMap, Chat, ChatModification, Contact, LegacySocketConfig, PresenceData, WABusinessProfile, WAFlag, WAMessageKey, WAMessageUpdate, WAMetric, WAPresence } from '../Types'
import { Chat, Contact, WAPresence, PresenceData, LegacySocketConfig, WAFlag, WAMetric, WABusinessProfile, ChatModification, WAMessageKey, WAMessageUpdate, BaileysEventMap } from "../Types"; import { debouncedTimeout, unixTimestampSeconds } from '../Utils/generics'
import { debouncedTimeout, unixTimestampSeconds } from "../Utils/generics"; import { BinaryNode, jidNormalizedUser } from '../WABinary'
import makeAuthSocket from "./auth"; import makeAuthSocket from './auth'
const makeChatsSocket = (config: LegacySocketConfig) => { const makeChatsSocket = (config: LegacySocketConfig) => {
const { logger } = config const { logger } = config
@@ -22,7 +22,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
sendNode({ sendNode({
json: { json: {
tag: 'query', tag: 'query',
attrs: {type: 'chat', epoch: epoch.toString()} attrs: { type: 'chat', epoch: epoch.toString() }
}, },
binaryTag: [ WAMetric.queryChat, WAFlag.ignore ] binaryTag: [ WAMetric.queryChat, WAFlag.ignore ]
}) })
@@ -43,62 +43,64 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
const updateType = attributes.type const updateType = attributes.type
const jid = jidNormalizedUser(attributes?.jid) const jid = jidNormalizedUser(attributes?.jid)
switch(updateType) { switch (updateType) {
case 'delete': case 'delete':
ev.emit('chats.delete', [jid]) ev.emit('chats.delete', [jid])
break break
case 'clear': case 'clear':
if(node.content) { if(node.content) {
const ids = (node.content as BinaryNode[]).map( const ids = (node.content as BinaryNode[]).map(
({ attrs }) => attrs.index ({ attrs }) => attrs.index
)
ev.emit('messages.delete', { keys: ids.map(id => ({ id, remoteJid: jid })) })
} else {
ev.emit('messages.delete', { jid, all: true })
}
break
case 'archive':
ev.emit('chats.update', [ { id: jid, archive: true } ])
break
case 'unarchive':
ev.emit('chats.update', [ { id: jid, archive: false } ])
break
case 'pin':
ev.emit('chats.update', [ { id: jid, pin: attributes.pin ? +attributes.pin : null } ])
break
case 'star':
case 'unstar':
const starred = updateType === 'star'
const updates: WAMessageUpdate[] = (node.content as BinaryNode[]).map(
({ attrs }) => ({
key: {
remoteJid: jid,
id: attrs.index,
fromMe: attrs.owner === 'true'
},
update: { starred }
})
) )
ev.emit('messages.update', updates) ev.emit('messages.delete', { keys: ids.map(id => ({ id, remoteJid: jid })) })
break } else {
case 'mute': ev.emit('messages.delete', { jid, all: true })
if(attributes.mute === '0') { }
ev.emit('chats.update', [{ id: jid, mute: null }])
} else { break
ev.emit('chats.update', [{ id: jid, mute: +attributes.mute }]) case 'archive':
} ev.emit('chats.update', [ { id: jid, archive: true } ])
break break
default: case 'unarchive':
logger.warn({ node }, `received unrecognized chat update`) ev.emit('chats.update', [ { id: jid, archive: false } ])
break break
case 'pin':
ev.emit('chats.update', [ { id: jid, pin: attributes.pin ? +attributes.pin : null } ])
break
case 'star':
case 'unstar':
const starred = updateType === 'star'
const updates: WAMessageUpdate[] = (node.content as BinaryNode[]).map(
({ attrs }) => ({
key: {
remoteJid: jid,
id: attrs.index,
fromMe: attrs.owner === 'true'
},
update: { starred }
})
)
ev.emit('messages.update', updates)
break
case 'mute':
if(attributes.mute === '0') {
ev.emit('chats.update', [{ id: jid, mute: null }])
} else {
ev.emit('chats.update', [{ id: jid, mute: +attributes.mute }])
}
break
default:
logger.warn({ node }, 'received unrecognized chat update')
break
} }
} }
const applyingPresenceUpdate = (update: BinaryNode['attrs']): BaileysEventMap<any>['presence.update'] => { const applyingPresenceUpdate = (update: BinaryNode['attrs']): BaileysEventMap<any>['presence.update'] => {
const id = jidNormalizedUser(update.id) const id = jidNormalizedUser(update.id)
const participant = jidNormalizedUser(update.participant || update.id) const participant = jidNormalizedUser(update.participant || update.id)
const presence: PresenceData = { const presence: PresenceData = {
lastSeen: update.t ? +update.t : undefined, lastSeen: update.t ? +update.t : undefined,
lastKnownPresence: update.type as WAPresence lastKnownPresence: update.type as WAPresence
} }
@@ -126,27 +128,30 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
} }
ev.on('connection.update', async({ connection }) => { ev.on('connection.update', async({ connection }) => {
if(connection !== 'open') return if(connection !== 'open') {
return
}
try { try {
await Promise.all([ await Promise.all([
sendNode({ sendNode({
json: { tag: 'query', attrs: {type: 'contacts', epoch: '1'} }, json: { tag: 'query', attrs: { type: 'contacts', epoch: '1' } },
binaryTag: [ WAMetric.queryContact, WAFlag.ignore ] binaryTag: [ WAMetric.queryContact, WAFlag.ignore ]
}), }),
sendNode({ sendNode({
json: { tag: 'query', attrs: {type: 'status', epoch: '1'} }, json: { tag: 'query', attrs: { type: 'status', epoch: '1' } },
binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ] binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ]
}), }),
sendNode({ sendNode({
json: { tag: 'query', attrs: {type: 'quick_reply', epoch: '1'} }, json: { tag: 'query', attrs: { type: 'quick_reply', epoch: '1' } },
binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ] binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ]
}), }),
sendNode({ sendNode({
json: { tag: 'query', attrs: {type: 'label', epoch: '1'} }, json: { tag: 'query', attrs: { type: 'label', epoch: '1' } },
binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ] binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ]
}), }),
sendNode({ sendNode({
json: { tag: 'query', attrs: {type: 'emoji', epoch: '1'} }, json: { tag: 'query', attrs: { type: 'emoji', epoch: '1' } },
binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ] binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ]
}), }),
sendNode({ sendNode({
@@ -154,7 +159,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
tag: 'action', tag: 'action',
attrs: { type: 'set', epoch: '1' }, attrs: { type: 'set', epoch: '1' },
content: [ content: [
{ tag: 'presence', attrs: {type: 'available'} } { tag: 'presence', attrs: { type: 'available' } }
] ]
}, },
binaryTag: [ WAMetric.presence, WAFlag.available ] binaryTag: [ WAMetric.presence, WAFlag.available ]
@@ -167,7 +172,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
logger.error(`error in sending init queries: ${error}`) logger.error(`error in sending init queries: ${error}`)
} }
}) })
socketEvents.on('CB:response,type:chat', async ({ content: data }: BinaryNode) => { socketEvents.on('CB:response,type:chat', async({ content: data }: BinaryNode) => {
chatsDebounceTimeout.cancel() chatsDebounceTimeout.cancel()
if(Array.isArray(data)) { if(Array.isArray(data)) {
const contacts: Contact[] = [] const contacts: Contact[] = []
@@ -176,6 +181,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
if(attrs.name) { if(attrs.name) {
contacts.push({ id, name: attrs.name }) contacts.push({ id, name: attrs.name })
} }
return { return {
id: jidNormalizedUser(attrs.jid), id: jidNormalizedUser(attrs.jid),
conversationTimestamp: attrs.t ? +attrs.t : undefined, conversationTimestamp: attrs.t ? +attrs.t : undefined,
@@ -196,7 +202,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
} }
}) })
// got all contacts from phone // got all contacts from phone
socketEvents.on('CB:response,type:contacts', async ({ content: data }: BinaryNode) => { socketEvents.on('CB:response,type:contacts', async({ content: data }: BinaryNode) => {
if(Array.isArray(data)) { if(Array.isArray(data)) {
const contacts = data.map(({ attrs }): Contact => { const contacts = data.map(({ attrs }): Contact => {
return { return {
@@ -225,15 +231,18 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
} }
}) })
// read updates // read updates
socketEvents.on ('CB:action,,read', async ({ content }: BinaryNode) => { socketEvents.on ('CB:action,,read', async({ content }: BinaryNode) => {
if(Array.isArray(content)) { if(Array.isArray(content)) {
const { attrs } = content[0] const { attrs } = content[0]
const update: Partial<Chat> = { const update: Partial<Chat> = {
id: jidNormalizedUser(attrs.jid) id: jidNormalizedUser(attrs.jid)
} }
if(attrs.type === 'false') update.unreadCount = -1 if(attrs.type === 'false') {
else update.unreadCount = 0 update.unreadCount = -1
} else {
update.unreadCount = 0
}
ev.emit('chats.update', [update]) ev.emit('chats.update', [update])
} }
@@ -295,7 +304,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
* @param jid the ID of the person/group you are modifiying * @param jid the ID of the person/group you are modifiying
*/ */
chatModify: async(modification: ChatModification, jid: string, chatInfo: Pick<Chat, 'mute' | 'pin'>, timestampNow?: number) => { chatModify: async(modification: ChatModification, jid: string, chatInfo: Pick<Chat, 'mute' | 'pin'>, timestampNow?: number) => {
let chatAttrs: BinaryNode['attrs'] = { jid: jid } const chatAttrs: BinaryNode['attrs'] = { jid: jid }
let data: BinaryNode[] | undefined = undefined let data: BinaryNode[] | undefined = undefined
timestampNow = timestampNow || unixTimestampSeconds() timestampNow = timestampNow || unixTimestampSeconds()
@@ -356,6 +365,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
// apply it and emit events // apply it and emit events
executeChatModification(node) executeChatModification(node)
} }
return response return response
}, },
/** /**
@@ -381,7 +391,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
* @param jid the ID of the person/group who you are updating * @param jid the ID of the person/group who you are updating
* @param type your presence * @param type your presence
*/ */
sendPresenceUpdate: ( type: WAPresence, jid: string | undefined) => ( sendPresenceUpdate: (type: WAPresence, jid: string | undefined) => (
sendNode({ sendNode({
binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does
json: { json: {
@@ -400,7 +410,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
* Request updates on the presence of a user * Request updates on the presence of a user
* this returns nothing, you'll receive updates in chats.update event * this returns nothing, you'll receive updates in chats.update event
* */ * */
presenceSubscribe: async (jid: string) => ( presenceSubscribe: async(jid: string) => (
sendNode({ json: ['action', 'presence', 'subscribe', jid] }) sendNode({ json: ['action', 'presence', 'subscribe', jid] })
), ),
/** Query the status of the person (see groupMetadata() for groups) */ /** Query the status of the person (see groupMetadata() for groups) */
@@ -423,11 +433,12 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
}, },
/** Updates business profile. */ /** Updates business profile. */
updateBusinessProfile: async(profile: WABusinessProfile) => { updateBusinessProfile: async(profile: WABusinessProfile) => {
if (profile.business_hours?.config) { if(profile.business_hours?.config) {
profile.business_hours.business_config = profile.business_hours.config profile.business_hours.business_config = profile.business_hours.config
delete profile.business_hours.config delete profile.business_hours.config
} }
const json = ['action', "editBusinessProfile", {...profile, v: 2}]
const json = ['action', 'editBusinessProfile', { ...profile, v: 2 }]
await query({ json, expect200: true, requiresPhoneConnection: true }) await query({ json, expect200: true, requiresPhoneConnection: true })
}, },
updateProfileName: async(name: string) => { updateProfileName: async(name: string) => {
@@ -447,6 +458,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
} }) } })
ev.emit('contacts.update', [{ id: user.id, name }]) ev.emit('contacts.update', [{ id: user.id, name }])
} }
return response return response
}, },
/** /**
@@ -454,7 +466,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
* @param jid * @param jid
* @param img * @param img
*/ */
async updateProfilePicture (jid: string, img: Buffer) { async updateProfilePicture(jid: string, img: Buffer) {
jid = jidNormalizedUser (jid) jid = jidNormalizedUser (jid)
const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO
const tag = this.generateMessageTag () const tag = this.generateMessageTag ()
@@ -480,6 +492,7 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
} }
}) })
} }
ev.emit('contacts.update', [ { id: jid, imgUrl: eurl } ]) ev.emit('contacts.update', [ { id: jid, imgUrl: eurl } ])
} }
}, },
@@ -513,8 +526,8 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
}] }]
} = await query({ } = await query({
json: [ json: [
"query", "businessProfile", 'query', 'businessProfile',
[ { "wid": jid.replace('@s.whatsapp.net', '@c.us') } ], [ { 'wid': jid.replace('@s.whatsapp.net', '@c.us') } ],
84 84
], ],
expect200: true, expect200: true,
@@ -528,4 +541,5 @@ const makeChatsSocket = (config: LegacySocketConfig) => {
} }
} }
} }
export default makeChatsSocket export default makeChatsSocket

View File

@@ -1,7 +1,7 @@
import { BinaryNode, jidNormalizedUser } from "../WABinary"; import { GroupMetadata, GroupModificationResponse, GroupParticipant, LegacySocketConfig, ParticipantAction, WAFlag, WAGroupCreateResponse, WAMetric } from '../Types'
import { LegacySocketConfig, GroupModificationResponse, ParticipantAction, GroupMetadata, WAFlag, WAMetric, WAGroupCreateResponse, GroupParticipant } from "../Types"; import { generateMessageID, unixTimestampSeconds } from '../Utils/generics'
import { generateMessageID, unixTimestampSeconds } from "../Utils/generics"; import { BinaryNode, jidNormalizedUser } from '../WABinary'
import makeMessagesSocket from "./messages"; import makeMessagesSocket from './messages'
const makeGroupsSocket = (config: LegacySocketConfig) => { const makeGroupsSocket = (config: LegacySocketConfig) => {
const { logger } = config const { logger } = config
@@ -17,9 +17,9 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
} = sock } = sock
/** Generic function for group queries */ /** Generic function for group queries */
const groupQuery = async(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: BinaryNode[]) => { const groupQuery = async(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: BinaryNode[]) => {
const tag = generateMessageTag() const tag = generateMessageTag()
const result = await setQuery ([ const result = await setQuery ([
{ {
tag: 'group', tag: 'group',
attrs: { attrs: {
@@ -36,12 +36,12 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
additionalNodes additionalNodes
} }
], [WAMetric.group, 136], tag) ], [WAMetric.group, 136], tag)
return result return result
} }
/** Get the metadata of the group from WA */ /** Get the metadata of the group from WA */
const groupMetadataFull = async (jid: string) => { const groupMetadataFull = async(jid: string) => {
const metadata = await query({ const metadata = await query({
json: ['query', 'GroupMetadata', jid], json: ['query', 'GroupMetadata', jid],
expect200: true expect200: true
}) })
@@ -62,13 +62,14 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
} }
return meta return meta
} }
/** Get the metadata (works after you've left the group also) */
const groupMetadataMinimal = async (jid: string) => { /** Get the metadata (works after you've left the group also) */
const { attrs, content }:BinaryNode = await query({ const groupMetadataMinimal = async(jid: string) => {
const { attrs, content }:BinaryNode = await query({
json: { json: {
tag: 'query', tag: 'query',
attrs: {type: 'group', jid: jid, epoch: currentEpoch().toString()} attrs: { type: 'group', jid: jid, epoch: currentEpoch().toString() }
}, },
binaryTag: [WAMetric.group, WAFlag.ignore], binaryTag: [WAMetric.group, WAFlag.ignore],
expect200: true expect200: true
@@ -89,16 +90,17 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
} }
} }
} }
const meta: GroupMetadata = {
id: jid, const meta: GroupMetadata = {
owner: attrs?.creator, id: jid,
creation: +attrs?.create, owner: attrs?.creator,
subject: null, creation: +attrs?.create,
desc, subject: null,
participants desc,
} participants
}
return meta return meta
} }
socketEvents.on('CB:Chat,cmd:action', (json: BinaryNode) => { socketEvents.on('CB:Chat,cmd:action', (json: BinaryNode) => {
/*const data = json[1].data /*const data = json[1].data
@@ -129,8 +131,11 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
groupMetadata: async(jid: string, minimal: boolean) => { groupMetadata: async(jid: string, minimal: boolean) => {
let result: GroupMetadata let result: GroupMetadata
if(minimal) result = await groupMetadataMinimal(jid) if(minimal) {
else result = await groupMetadataFull(jid) result = await groupMetadataMinimal(jid)
} else {
result = await groupMetadataFull(jid)
}
return result return result
}, },
@@ -139,13 +144,13 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
* @param title like, the title of the group * @param title like, the title of the group
* @param participants people to include in the group * @param participants people to include in the group
*/ */
groupCreate: async (title: string, participants: string[]) => { groupCreate: async(title: string, participants: string[]) => {
const response = await groupQuery('create', null, title, participants) as WAGroupCreateResponse const response = await groupQuery('create', null, title, participants) as WAGroupCreateResponse
const gid = response.gid const gid = response.gid
let metadata: GroupMetadata let metadata: GroupMetadata
try { try {
metadata = await groupMetadataFull(gid) metadata = await groupMetadataFull(gid)
} catch (error) { } catch(error) {
logger.warn (`error in group creation: ${error}, switching gid & checking`) logger.warn (`error in group creation: ${error}, switching gid & checking`)
// if metadata is not available // if metadata is not available
const comps = gid.replace ('@g.us', '').split ('-') const comps = gid.replace ('@g.us', '').split ('-')
@@ -154,6 +159,7 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
metadata = await groupMetadataFull(gid) metadata = await groupMetadataFull(gid)
logger.warn (`group ID switched from ${gid} to ${response.gid}`) logger.warn (`group ID switched from ${gid} to ${response.gid}`)
} }
ev.emit('chats.upsert', [ ev.emit('chats.upsert', [
{ {
id: response.gid!, id: response.gid!,
@@ -168,7 +174,7 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
* Leave a group * Leave a group
* @param jid the ID of the group * @param jid the ID of the group
*/ */
groupLeave: async (id: string) => { groupLeave: async(id: string) => {
await groupQuery('leave', id) await groupQuery('leave', id)
ev.emit('chats.update', [ { id, readOnly: true } ]) ev.emit('chats.update', [ { id, readOnly: true } ])
}, },
@@ -177,7 +183,7 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
* @param {string} jid the ID of the group * @param {string} jid the ID of the group
* @param {string} title the new title of the group * @param {string} title the new title of the group
*/ */
groupUpdateSubject: async (id: string, title: string) => { groupUpdateSubject: async(id: string, title: string) => {
await groupQuery('subject', id, title) await groupQuery('subject', id, title)
ev.emit('chats.update', [ { id, name: title } ]) ev.emit('chats.update', [ { id, name: title } ])
ev.emit('contacts.update', [ { id, name: title } ]) ev.emit('contacts.update', [ { id, name: title } ])
@@ -188,11 +194,11 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
* @param {string} jid the ID of the group * @param {string} jid the ID of the group
* @param {string} title the new title of the group * @param {string} title the new title of the group
*/ */
groupUpdateDescription: async (jid: string, description: string) => { groupUpdateDescription: async(jid: string, description: string) => {
const metadata = await groupMetadataFull(jid) const metadata = await groupMetadataFull(jid)
const node: BinaryNode = { const node: BinaryNode = {
tag: 'description', tag: 'description',
attrs: {id: generateMessageID(), prev: metadata?.descId}, attrs: { id: generateMessageID(), prev: metadata?.descId },
content: Buffer.from(description, 'utf-8') content: Buffer.from(description, 'utf-8')
} }
@@ -247,4 +253,5 @@ const makeGroupsSocket = (config: LegacySocketConfig) => {
} }
} }
export default makeGroupsSocket export default makeGroupsSocket

View File

@@ -1,5 +1,5 @@
import { LegacySocketConfig } from '../Types'
import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults' import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults'
import { LegacySocketConfig } from '../Types'
import _makeLegacySocket from './groups' import _makeLegacySocket from './groups'
// export the last socket layer // export the last socket layer
const makeLegacySocket = (config: Partial<LegacySocketConfig>) => ( const makeLegacySocket = (config: Partial<LegacySocketConfig>) => (

View File

@@ -1,15 +1,15 @@
import { BinaryNode, getBinaryNodeMessages, isJidGroup, jidNormalizedUser, areJidsSameUser } from "../WABinary";
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { Chat, WAMessageCursor, WAMessage, LegacySocketConfig, WAMessageKey, ParticipantAction, WAMessageStatus, WAMessageStubType, GroupMetadata, AnyMessageContent, MiscMessageGenerationOptions, WAFlag, WAMetric, WAUrlInfo, MediaConnInfo, MessageUpdateType, MessageInfo, MessageInfoUpdate, WAMessageUpdate } from "../Types"; import { proto } from '../../WAProto'
import { toNumber, generateWAMessage, decryptMediaMessageBuffer, extractMessageContent, getWAUploadToServer } from "../Utils"; import { WA_DEFAULT_EPHEMERAL } from '../Defaults'
import makeChatsSocket from "./chats"; import { AnyMessageContent, Chat, GroupMetadata, LegacySocketConfig, MediaConnInfo, MessageInfo, MessageInfoUpdate, MessageUpdateType, MiscMessageGenerationOptions, ParticipantAction, WAFlag, WAMessage, WAMessageCursor, WAMessageKey, WAMessageStatus, WAMessageStubType, WAMessageUpdate, WAMetric, WAUrlInfo } from '../Types'
import { WA_DEFAULT_EPHEMERAL } from "../Defaults"; import { decryptMediaMessageBuffer, extractMessageContent, generateWAMessage, getWAUploadToServer, toNumber } from '../Utils'
import { proto } from "../../WAProto"; import { areJidsSameUser, BinaryNode, getBinaryNodeMessages, isJidGroup, jidNormalizedUser } from '../WABinary'
import makeChatsSocket from './chats'
const STATUS_MAP = { const STATUS_MAP = {
read: WAMessageStatus.READ, read: WAMessageStatus.READ,
message: WAMessageStatus.DELIVERY_ACK, message: WAMessageStatus.DELIVERY_ACK,
error: WAMessageStatus.ERROR error: WAMessageStatus.ERROR
} as { [_: string]: WAMessageStatus } } as { [_: string]: WAMessageStatus }
const makeMessagesSocket = (config: LegacySocketConfig) => { const makeMessagesSocket = (config: LegacySocketConfig) => {
@@ -27,10 +27,10 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
let mediaConn: Promise<MediaConnInfo> let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async(forceGet = false) => { const refreshMediaConn = async(forceGet = false) => {
let media = await mediaConn const media = await mediaConn
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) { if(!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
mediaConn = (async() => { mediaConn = (async() => {
const {media_conn} = await query({ const { media_conn } = await query({
json: ['query', 'mediaConn'], json: ['query', 'mediaConn'],
requiresPhoneConnection: false, requiresPhoneConnection: false,
expect200: true expect200: true
@@ -38,9 +38,10 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
media_conn.fetchDate = new Date() media_conn.fetchDate = new Date()
return media_conn as MediaConnInfo return media_conn as MediaConnInfo
})() })()
} }
return mediaConn
} return mediaConn
}
const fetchMessagesFromWA = async( const fetchMessagesFromWA = async(
jid: string, jid: string,
@@ -51,7 +52,8 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
if(cursor) { if(cursor) {
key = 'before' in cursor ? cursor.before : cursor.after key = 'before' in cursor ? cursor.before : cursor.after
} }
const { content }:BinaryNode = await query({
const { content }:BinaryNode = await query({
json: { json: {
tag: 'query', tag: 'query',
attrs: { attrs: {
@@ -71,15 +73,18 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
if(Array.isArray(content)) { if(Array.isArray(content)) {
return content.map(data => proto.WebMessageInfo.decode(data.content as Buffer)) return content.map(data => proto.WebMessageInfo.decode(data.content as Buffer))
} }
return [] return []
} }
const updateMediaMessage = async(message: WAMessage) => { const updateMediaMessage = async(message: WAMessage) => {
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
if (!content) throw new Boom( if(!content) {
`given message ${message.key.id} is not a media message`, throw new Boom(
{ statusCode: 400, data: message } `given message ${message.key.id} is not a media message`,
) { statusCode: 400, data: message }
)
}
const response: BinaryNode = await query ({ const response: BinaryNode = await query ({
json: { json: {
@@ -133,35 +138,36 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
} }
const protocolMessage = message.message?.protocolMessage || message.message?.ephemeralMessage?.message?.protocolMessage const protocolMessage = message.message?.protocolMessage || message.message?.ephemeralMessage?.message?.protocolMessage
// if it's a message to delete another message // if it's a message to delete another message
if (protocolMessage) { if(protocolMessage) {
switch (protocolMessage.type) { switch (protocolMessage.type) {
case proto.ProtocolMessage.ProtocolMessageType.REVOKE: case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
const key = protocolMessage.key const key = protocolMessage.key
const messageStubType = WAMessageStubType.REVOKE const messageStubType = WAMessageStubType.REVOKE
ev.emit('messages.update', [ ev.emit('messages.update', [
{ {
// the key of the deleted message is updated // the key of the deleted message is updated
update: { message: null, key: message.key, messageStubType }, update: { message: null, key: message.key, messageStubType },
key key
} }
]) ])
return return
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING: case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp
chatUpdate.ephemeralExpiration = protocolMessage.ephemeralExpiration chatUpdate.ephemeralExpiration = protocolMessage.ephemeralExpiration
if(isJidGroup(jid)) { if(isJidGroup(jid)) {
emitGroupUpdate({ ephemeralDuration: protocolMessage.ephemeralExpiration || null }) emitGroupUpdate({ ephemeralDuration: protocolMessage.ephemeralExpiration || null })
} }
break
default: break
break default:
} break
} }
}
// check if the message is an action // check if the message is an action
if (message.messageStubType) { if(message.messageStubType) {
const { user } = state.legacy! const { user } = state.legacy!
//let actor = jidNormalizedUser (message.participant) //let actor = jidNormalizedUser (message.participant)
let participants: string[] let participants: string[]
@@ -170,44 +176,47 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
) )
switch (message.messageStubType) { switch (message.messageStubType) {
case WAMessageStubType.CHANGE_EPHEMERAL_SETTING: case WAMessageStubType.CHANGE_EPHEMERAL_SETTING:
chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp
chatUpdate.ephemeralExpiration = +message.messageStubParameters[0] chatUpdate.ephemeralExpiration = +message.messageStubParameters[0]
if(isJidGroup(jid)) { if(isJidGroup(jid)) {
emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null }) emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null })
} }
break
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE: break
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE: case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
participants = message.messageStubParameters.map (jidNormalizedUser) case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
emitParticipantsUpdate('remove') participants = message.messageStubParameters.map (jidNormalizedUser)
// mark the chat read only if you left the group emitParticipantsUpdate('remove')
if (participants.includes(user.id)) { // mark the chat read only if you left the group
chatUpdate.readOnly = true if(participants.includes(user.id)) {
} chatUpdate.readOnly = true
break }
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE: break
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN: case WAMessageStubType.GROUP_PARTICIPANT_ADD:
participants = message.messageStubParameters.map (jidNormalizedUser) case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
if (participants.includes(user.id)) { case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
chatUpdate.readOnly = null participants = message.messageStubParameters.map (jidNormalizedUser)
} if(participants.includes(user.id)) {
emitParticipantsUpdate('add') chatUpdate.readOnly = null
break }
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announce = message.messageStubParameters[0] === 'on' emitParticipantsUpdate('add')
emitGroupUpdate({ announce }) break
break case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
case WAMessageStubType.GROUP_CHANGE_RESTRICT: const announce = message.messageStubParameters[0] === 'on'
const restrict = message.messageStubParameters[0] === 'on' emitGroupUpdate({ announce })
emitGroupUpdate({ restrict }) break
break case WAMessageStubType.GROUP_CHANGE_RESTRICT:
case WAMessageStubType.GROUP_CHANGE_SUBJECT: const restrict = message.messageStubParameters[0] === 'on'
case WAMessageStubType.GROUP_CREATE: emitGroupUpdate({ restrict })
chatUpdate.name = message.messageStubParameters[0] break
emitGroupUpdate({ subject: chatUpdate.name }) case WAMessageStubType.GROUP_CHANGE_SUBJECT:
break case WAMessageStubType.GROUP_CREATE:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
} }
} }
@@ -221,8 +230,8 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn) const waUploadToServer = getWAUploadToServer(config, refreshMediaConn)
/** Query a string to check if it has a url, if it does, return WAUrlInfo */ /** Query a string to check if it has a url, if it does, return WAUrlInfo */
const generateUrlInfo = async(text: string) => { const generateUrlInfo = async(text: string) => {
const response: BinaryNode = await query({ const response: BinaryNode = await query({
json: { json: {
tag: 'query', tag: 'query',
attrs: { attrs: {
@@ -236,14 +245,15 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
requiresPhoneConnection: false requiresPhoneConnection: false
}) })
const urlInfo = { ...response.attrs } as any as WAUrlInfo const urlInfo = { ...response.attrs } as any as WAUrlInfo
if(response && response.content) { if(response && response.content) {
urlInfo.jpegThumbnail = response.content as Buffer urlInfo.jpegThumbnail = response.content as Buffer
} }
return urlInfo
} return urlInfo
}
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */ /** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
const relayMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => { const relayMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => {
const json: BinaryNode = { const json: BinaryNode = {
tag: 'action', tag: 'action',
attrs: { epoch: currentEpoch().toString(), type: 'relay' }, attrs: { epoch: currentEpoch().toString(), type: 'relay' },
@@ -256,35 +266,37 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
] ]
} }
const isMsgToMe = areJidsSameUser(message.key.remoteJid, state.legacy.user?.id || '') const isMsgToMe = areJidsSameUser(message.key.remoteJid, state.legacy.user?.id || '')
const flag = isMsgToMe ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself const flag = isMsgToMe ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
const mID = message.key.id const mID = message.key.id
const finalState = isMsgToMe ? WAMessageStatus.READ : WAMessageStatus.SERVER_ACK const finalState = isMsgToMe ? WAMessageStatus.READ : WAMessageStatus.SERVER_ACK
message.status = WAMessageStatus.PENDING message.status = WAMessageStatus.PENDING
const promise = query({ const promise = query({
json, json,
binaryTag: [WAMetric.message, flag], binaryTag: [WAMetric.message, flag],
tag: mID, tag: mID,
expect200: true, expect200: true,
requiresPhoneConnection: true requiresPhoneConnection: true
}) })
if(waitForAck) { if(waitForAck) {
await promise await promise
message.status = finalState message.status = finalState
} else { } else {
const emitUpdate = (status: WAMessageStatus) => { const emitUpdate = (status: WAMessageStatus) => {
message.status = status message.status = status
ev.emit('messages.update', [ { key: message.key, update: { status } } ]) ev.emit('messages.update', [ { key: message.key, update: { status } } ])
} }
promise
promise
.then(() => emitUpdate(finalState)) .then(() => emitUpdate(finalState))
.catch(() => emitUpdate(WAMessageStatus.ERROR)) .catch(() => emitUpdate(WAMessageStatus.ERROR))
} }
if(config.emitOwnEvents) { if(config.emitOwnEvents) {
onMessage(message, 'append') onMessage(message, 'append')
} }
} }
// messages received // messages received
const messagesUpdate = (node: BinaryNode, isLatest: boolean) => { const messagesUpdate = (node: BinaryNode, isLatest: boolean) => {
@@ -330,26 +342,30 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
logger.warn({ content, key }, 'got unknown status update for message') logger.warn({ content, key }, 'got unknown status update for message')
} }
} }
ev.emit('messages.update', updates) ev.emit('messages.update', updates)
} }
} }
const onMessageInfoUpdate = ([,attributes]: [string,{[_: string]: any}]) => {
const onMessageInfoUpdate = ([, attributes]: [string, {[_: string]: any}]) => {
let ids = attributes.id as string[] | string let ids = attributes.id as string[] | string
if(typeof ids === 'string') { if(typeof ids === 'string') {
ids = [ids] ids = [ids]
} }
let updateKey: keyof MessageInfoUpdate['update'] let updateKey: keyof MessageInfoUpdate['update']
switch(attributes.ack.toString()) { switch (attributes.ack.toString()) {
case '2': case '2':
updateKey = 'deliveries' updateKey = 'deliveries'
break break
case '3': case '3':
updateKey = 'reads' updateKey = 'reads'
break break
default: default:
logger.warn({ attributes }, `received unknown message info update`) logger.warn({ attributes }, 'received unknown message info update')
return return
} }
const keyPartial = { const keyPartial = {
remoteJid: jidNormalizedUser(attributes.to), remoteJid: jidNormalizedUser(attributes.to),
fromMe: areJidsSameUser(attributes.from, state.legacy?.user?.id || ''), fromMe: areJidsSameUser(attributes.from, state.legacy?.user?.id || ''),
@@ -406,38 +422,43 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
const [{ attrs }] = (innerData as BinaryNode[]) const [{ attrs }] = (innerData as BinaryNode[])
const jid = jidNormalizedUser(attrs.jid) const jid = jidNormalizedUser(attrs.jid)
const date = new Date(+attrs.t * 1000) const date = new Date(+attrs.t * 1000)
switch(tag) { switch (tag) {
case 'read': case 'read':
info.reads[jid] = date info.reads[jid] = date
break break
case 'delivery': case 'delivery':
info.deliveries[jid] = date info.deliveries[jid] = date
break break
} }
} }
} }
return info return info
}, },
downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => { downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => {
const downloadMediaMessage = async () => { const downloadMediaMessage = async() => {
let mContent = extractMessageContent(message.message) const mContent = extractMessageContent(message.message)
if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message }) if(!mContent) {
throw new Boom('No message present', { statusCode: 400, data: message })
}
const stream = await decryptMediaMessageBuffer(mContent) const stream = await decryptMediaMessageBuffer(mContent)
if(type === 'buffer') { if(type === 'buffer') {
let buffer = Buffer.from([]) let buffer = Buffer.from([])
for await(const chunk of stream) { for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk]) buffer = Buffer.concat([buffer, chunk])
} }
return buffer return buffer
} }
return stream return stream
} }
try { try {
const result = await downloadMediaMessage() const result = await downloadMediaMessage()
return result return result
} catch (error) { } catch(error) {
if(error.message.includes('404')) { // media needs to be updated if(error.message.includes('404')) { // media needs to be updated
logger.info (`updating media of message: ${message.key.id}`) logger.info (`updating media of message: ${message.key.id}`)
@@ -446,6 +467,7 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
const result = await downloadMediaMessage() const result = await downloadMediaMessage()
return result return result
} }
throw error throw error
} }
}, },
@@ -453,15 +475,15 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
fetchMessagesFromWA, fetchMessagesFromWA,
/** Load a single message specified by the ID */ /** Load a single message specified by the ID */
loadMessageFromWA: async(jid: string, id: string) => { loadMessageFromWA: async(jid: string, id: string) => {
let message: WAMessage
// load the message before the given message // load the message before the given message
let messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: true} })) let messages = (await fetchMessagesFromWA(jid, 1, { before: { id, fromMe: true } }))
if(!messages[0]) messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: false} })) if(!messages[0]) {
messages = (await fetchMessagesFromWA(jid, 1, { before: { id, fromMe: false } }))
}
// the message after the loaded message is the message required // the message after the loaded message is the message required
const [actual] = await fetchMessagesFromWA(jid, 1, { after: messages[0] && messages[0].key }) const [actual] = await fetchMessagesFromWA(jid, 1, { after: messages[0] && messages[0].key })
message = actual return actual
return message
}, },
searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => { searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => {
const node: BinaryNode = await query({ const node: BinaryNode = await query({
@@ -499,8 +521,8 @@ const makeMessagesSocket = (config: LegacySocketConfig) => {
) { ) {
const { disappearingMessagesInChat } = content const { disappearingMessagesInChat } = content
const value = typeof disappearingMessagesInChat === 'boolean' ? const value = typeof disappearingMessagesInChat === 'boolean' ?
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) : (disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
disappearingMessagesInChat disappearingMessagesInChat
const tag = generateMessageTag(true) const tag = generateMessageTag(true)
await setQuery([ await setQuery([
{ {

View File

@@ -1,11 +1,11 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { STATUS_CODES } from "http" import { STATUS_CODES } from 'http'
import { promisify } from "util" import { promisify } from 'util'
import WebSocket from "ws" import WebSocket from 'ws'
import { BinaryNode, encodeBinaryNodeLegacy } from "../WABinary" import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, DEFAULT_ORIGIN, PHONE_CONNECTION_CB } from '../Defaults'
import { DisconnectReason, LegacySocketConfig, SocketQueryOptions, SocketSendMessageOptions, WAFlag, WAMetric, WATag } from "../Types" import { DisconnectReason, LegacySocketConfig, SocketQueryOptions, SocketSendMessageOptions, WAFlag, WAMetric, WATag } from '../Types'
import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds, decodeWAMessage } from "../Utils" import { aesEncrypt, decodeWAMessage, hmacSign, promiseTimeout, unixTimestampSeconds } from '../Utils'
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults" import { BinaryNode, encodeBinaryNodeLegacy } from '../WABinary'
/** /**
* Connects to WA servers and performs: * Connects to WA servers and performs:
@@ -14,13 +14,13 @@ import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_C
* - query phone connection * - query phone connection
*/ */
export const makeSocket = ({ export const makeSocket = ({
waWebSocketUrl, waWebSocketUrl,
connectTimeoutMs, connectTimeoutMs,
phoneResponseTimeMs, phoneResponseTimeMs,
logger, logger,
agent, agent,
keepAliveIntervalMs, keepAliveIntervalMs,
expectResponseTimeout, expectResponseTimeout,
}: LegacySocketConfig) => { }: LegacySocketConfig) => {
// for generating tags // for generating tags
const referenceDateSeconds = unixTimestampSeconds(new Date()) const referenceDateSeconds = unixTimestampSeconds(new Date())
@@ -37,33 +37,35 @@ export const makeSocket = ({
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
} }
}) })
ws.setMaxListeners(0) ws.setMaxListeners(0)
let lastDateRecv: Date let lastDateRecv: Date
let epoch = 0 let epoch = 0
let authInfo: { encKey: Buffer, macKey: Buffer } let authInfo: { encKey: Buffer, macKey: Buffer }
let keepAliveReq: NodeJS.Timeout let keepAliveReq: NodeJS.Timeout
let phoneCheckInterval: NodeJS.Timeout let phoneCheckInterval: NodeJS.Timeout
let phoneCheckListeners = 0 let phoneCheckListeners = 0
const phoneConnectionChanged = (value: boolean) => { const phoneConnectionChanged = (value: boolean) => {
ws.emit('phone-connection', { value }) ws.emit('phone-connection', { value })
} }
const sendPromise = promisify(ws.send) const sendPromise = promisify(ws.send)
/** generate message tag and increment epoch */ /** generate message tag and increment epoch */
const generateMessageTag = (longTag: boolean = false) => { const generateMessageTag = (longTag: boolean = false) => {
const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}` const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}`
epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages
return tag return tag
} }
const sendRawMessage = (data: Buffer | string) => {
if(ws.readyState !== ws.OPEN) { const sendRawMessage = (data: Buffer | string) => {
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) if(ws.readyState !== ws.OPEN) {
} throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
return sendPromise.call(ws, data) as Promise<void>
}
return sendPromise.call(ws, data) as Promise<void>
}
/** /**
* Send a message to the WA servers * Send a message to the WA servers
* @returns the tag attached in the message * @returns the tag attached in the message
@@ -73,17 +75,19 @@ export const makeSocket = ({
) => { ) => {
tag = tag || generateMessageTag(longTag) tag = tag || generateMessageTag(longTag)
let data: Buffer | string let data: Buffer | string
if(logger.level === 'trace') { if(logger.level === 'trace') {
logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication') logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication')
} }
if(binaryTag) { if(binaryTag) {
if(Array.isArray(json)) { if(Array.isArray(json)) {
throw new Boom('Expected BinaryNode with binary code', { statusCode: 400 }) throw new Boom('Expected BinaryNode with binary code', { statusCode: 400 })
} }
if(!authInfo) { if(!authInfo) {
throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 }) throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 })
} }
const binary = encodeBinaryNodeLegacy(json) // encode the JSON to the WhatsApp binary format const binary = encodeBinaryNodeLegacy(json) // encode the JSON to the WhatsApp binary format
const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey
@@ -98,242 +102,268 @@ export const makeSocket = ({
} else { } else {
data = `${tag},${JSON.stringify(json)}` data = `${tag},${JSON.stringify(json)}`
} }
await sendRawMessage(data) await sendRawMessage(data)
return tag return tag
} }
const end = (error: Error | undefined) => { const end = (error: Error | undefined) => {
logger.info({ error }, 'connection closed') logger.info({ error }, 'connection closed')
ws.removeAllListeners('close') ws.removeAllListeners('close')
ws.removeAllListeners('error') ws.removeAllListeners('error')
ws.removeAllListeners('open') ws.removeAllListeners('open')
ws.removeAllListeners('message') ws.removeAllListeners('message')
phoneCheckListeners = 0 phoneCheckListeners = 0
clearInterval(keepAliveReq) clearInterval(keepAliveReq)
clearPhoneCheckInterval() clearPhoneCheckInterval()
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) { if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
try { ws.close() } catch { } try {
} ws.close()
} catch{ }
}
ws.emit('ws-close', error) ws.emit('ws-close', error)
ws.removeAllListeners('ws-close') ws.removeAllListeners('ws-close')
} }
const onMessageRecieved = (message: string | Buffer) => { const onMessageRecieved = (message: string | Buffer) => {
if(message[0] === '!' || message[0] === '!'.charCodeAt(0)) { if(message[0] === '!' || message[0] === '!'.charCodeAt(0)) {
// when the first character in the message is an '!', the server is sending a pong frame // when the first character in the message is an '!', the server is sending a pong frame
const timestamp = message.slice(1, message.length).toString() const timestamp = message.slice(1, message.length).toString()
lastDateRecv = new Date(parseInt(timestamp)) lastDateRecv = new Date(parseInt(timestamp))
ws.emit('received-pong') ws.emit('received-pong')
} else { } else {
let messageTag: string let messageTag: string
let json: any let json: any
try { try {
const dec = decodeWAMessage(message, authInfo) const dec = decodeWAMessage(message, authInfo)
messageTag = dec[0] messageTag = dec[0]
json = dec[1] json = dec[1]
if (!json) return if(!json) {
} catch (error) { return
end(error) }
} catch(error) {
end(error)
return return
} }
//if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false }) //if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false })
if (logger.level === 'trace') { if(logger.level === 'trace') {
logger.trace({ tag: messageTag, fromMe: false, json }, 'communication') logger.trace({ tag: messageTag, fromMe: false, json }, 'communication')
} }
let anyTriggered = false let anyTriggered = false
/* Check if this is a response to a message we sent */ /* Check if this is a response to a message we sent */
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${messageTag}`, json) anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${messageTag}`, json)
/* Check if this is a response to a message we are expecting */ /* Check if this is a response to a message we are expecting */
const l0 = json.tag || json[0] || '' const l0 = json.tag || json[0] || ''
const l1 = json?.attrs || json?.[1] || { } const l1 = json?.attrs || json?.[1] || { }
const l2 = json?.content?.[0]?.tag || json[2]?.[0] || '' const l2 = json?.content?.[0]?.tag || json[2]?.[0] || ''
Object.keys(l1).forEach(key => { 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]},${l2}`, json) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, 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},${key}`, json) || anyTriggered
}) })
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered
if (!anyTriggered && logger.level === 'debug') { if(!anyTriggered && logger.level === 'debug') {
logger.debug({ unhandled: true, tag: messageTag, fromMe: false, json }, 'communication recv') 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 */ /** Exits a query if the phone connection is active and no response is still found */
const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => { const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => {
let timeout: NodeJS.Timeout let timeout: NodeJS.Timeout
const listener = ([, connected]) => { const listener = ([, connected]) => {
if(connected) { if(connected) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`) 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 })) cancel(new Boom('Not expecting a response', { statusCode: 422 }))
}, expectResponseTimeout) }, expectResponseTimeout)
ws.off(PHONE_CONNECTION_CB, listener) ws.off(PHONE_CONNECTION_CB, listener)
} }
} }
ws.on(PHONE_CONNECTION_CB, listener)
return () => { ws.on(PHONE_CONNECTION_CB, listener)
ws.off(PHONE_CONNECTION_CB, listener) return () => {
timeout && clearTimeout(timeout) ws.off(PHONE_CONNECTION_CB, listener)
} timeout && clearTimeout(timeout)
} }
/** interval is started when a query takes too long to respond */ }
const startPhoneCheckInterval = () => {
phoneCheckListeners += 1 /** interval is started when a query takes too long to respond */
if (!phoneCheckInterval) { const startPhoneCheckInterval = () => {
// if its been a long time and we haven't heard back from WA, send a ping phoneCheckListeners += 1
phoneCheckInterval = setInterval(() => { if(!phoneCheckInterval) {
if(phoneCheckListeners <= 0) { // if its been a long time and we haven't heard back from WA, send a ping
logger.warn('phone check called without listeners') phoneCheckInterval = setInterval(() => {
return if(phoneCheckListeners <= 0) {
} logger.warn('phone check called without listeners')
logger.info('checking phone connection...') return
sendAdminTest() }
logger.info('checking phone connection...')
sendAdminTest()
phoneConnectionChanged(false)
}, phoneResponseTimeMs)
}
}
const clearPhoneCheckInterval = () => {
phoneCheckListeners -= 1
if(phoneCheckListeners <= 0) {
clearInterval(phoneCheckInterval)
phoneCheckInterval = undefined
phoneCheckListeners = 0
}
}
phoneConnectionChanged(false)
}, phoneResponseTimeMs)
}
}
const clearPhoneCheckInterval = () => {
phoneCheckListeners -= 1
if (phoneCheckListeners <= 0) {
clearInterval(phoneCheckInterval)
phoneCheckInterval = undefined
phoneCheckListeners = 0
}
}
/** checks for phone connection */ /** checks for phone connection */
const sendAdminTest = () => sendNode({ json: ['admin', 'test'] }) const sendAdminTest = () => sendNode({ json: ['admin', 'test'] })
/** /**
* Wait for a message with a certain tag to be received * Wait for a message with a certain tag to be received
* @param tag the message tag to await * @param tag the message tag to await
* @param json query that was sent * @param json query that was sent
* @param timeoutMs timeout after which the promise will reject * @param timeoutMs timeout after which the promise will reject
*/ */
const waitForMessage = (tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => { const waitForMessage = (tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => {
if(ws.readyState !== ws.OPEN) { if(ws.readyState !== ws.OPEN) {
throw new Boom('Connection not open', { statusCode: DisconnectReason.connectionClosed }) throw new Boom('Connection not open', { statusCode: DisconnectReason.connectionClosed })
} }
let cancelToken = () => { } let cancelToken = () => { }
return { return {
promise: (async() => { promise: (async() => {
let onRecv: (json) => void let onRecv: (json) => void
let onErr: (err) => void let onErr: (err) => void
let cancelPhoneChecker: () => void let cancelPhoneChecker: () => void
try { try {
const result = await promiseTimeout(timeoutMs, const result = await promiseTimeout(timeoutMs,
(resolve, reject) => { (resolve, reject) => {
onRecv = resolve onRecv = resolve
onErr = err => { onErr = err => {
reject(err || new Boom('Intentional Close', { statusCode: DisconnectReason.connectionClosed })) reject(err || new Boom('Intentional Close', { statusCode: DisconnectReason.connectionClosed }))
} }
cancelToken = () => onErr(new Boom('Cancelled', { statusCode: 500 }))
if(requiresPhoneConnection) { cancelToken = () => onErr(new Boom('Cancelled', { statusCode: 500 }))
startPhoneCheckInterval()
cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr)
}
ws.on(`TAG:${tag}`, onRecv) if(requiresPhoneConnection) {
ws.on('ws-close', onErr) // if the socket closes, you'll never receive the message startPhoneCheckInterval()
}, cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr)
) }
return result as any
} finally {
requiresPhoneConnection && clearPhoneCheckInterval()
cancelPhoneChecker && cancelPhoneChecker()
ws.off(`TAG:${tag}`, onRecv) ws.on(`TAG:${tag}`, onRecv)
ws.off('ws-close', onErr) // if the socket closes, you'll never receive the message ws.on('ws-close', onErr) // if the socket closes, you'll never receive the message
} },
})(), )
cancelToken: () => { cancelToken() } return result as any
} } finally {
} requiresPhoneConnection && clearPhoneCheckInterval()
/** cancelPhoneChecker && cancelPhoneChecker()
ws.off(`TAG:${tag}`, onRecv)
ws.off('ws-close', onErr) // if the socket closes, you'll never receive the message
}
})(),
cancelToken: () => {
cancelToken()
}
}
}
/**
* Query something from the WhatsApp servers * Query something from the WhatsApp servers
* @param json the query itself * @param json the query itself
* @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary * @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 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 * @param tag the tag to attach to the message
*/ */
const query = async( const query = async(
{ json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection }: SocketQueryOptions { json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection }: SocketQueryOptions
) => { ) => {
tag = tag || generateMessageTag(longTag) tag = tag || generateMessageTag(longTag)
const { promise, cancelToken } = waitForMessage(tag, requiresPhoneConnection, timeoutMs) const { promise, cancelToken } = waitForMessage(tag, requiresPhoneConnection, timeoutMs)
try { try {
await sendNode({ json, tag, binaryTag }) await sendNode({ json, tag, binaryTag })
} catch(error) { } catch(error) {
cancelToken() cancelToken()
// swallow error // swallow error
await promise.catch(() => { }) await promise.catch(() => { })
// throw back the error // throw back the error
throw error throw error
} }
const response = await promise
const responseStatusCode = +(response.status ? response.status : 200) // default status
// read here: http://getstatuscode.com/599
if(responseStatusCode === 599) { // the connection has gone bad
end(new Boom('WA server overloaded', { statusCode: 599, data: { query: json, response } }))
}
if(expect200 && Math.floor(responseStatusCode/100) !== 2) {
const message = STATUS_CODES[responseStatusCode] || 'unknown'
throw new Boom(
`Unexpected status in '${Array.isArray(json) ? json[0] : (json?.tag || 'query')}': ${message}(${responseStatusCode})`,
{ data: { query: json, response }, statusCode: response.status }
)
}
return response
}
const response = await promise
const responseStatusCode = +(response.status ? response.status : 200) // default status
// read here: http://getstatuscode.com/599
if(responseStatusCode === 599) { // the connection has gone bad
end(new Boom('WA server overloaded', { statusCode: 599, data: { query: json, response } }))
}
if(expect200 && Math.floor(responseStatusCode/100) !== 2) {
const message = STATUS_CODES[responseStatusCode] || 'unknown'
throw new Boom(
`Unexpected status in '${Array.isArray(json) ? json[0] : (json?.tag || 'query')}': ${message}(${responseStatusCode})`,
{ data: { query: json, response }, statusCode: response.status }
)
}
return response
}
const startKeepAliveRequest = () => ( const startKeepAliveRequest = () => (
keepAliveReq = setInterval(() => { keepAliveReq = setInterval(() => {
if (!lastDateRecv) lastDateRecv = new Date() if(!lastDateRecv) {
const diff = Date.now() - lastDateRecv.getTime() 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 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 it could be that the network is down
*/ */
if (diff > keepAliveIntervalMs+5000) { if(diff > keepAliveIntervalMs+5000) {
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
} else if(ws.readyState === ws.OPEN) { } else if(ws.readyState === ws.OPEN) {
sendRawMessage('?,,') // if its all good, send a keep alive request sendRawMessage('?,,') // if its all good, send a keep alive request
} else { } else {
logger.warn('keep alive called when WS not open') logger.warn('keep alive called when WS not open')
} }
}, keepAliveIntervalMs) }, keepAliveIntervalMs)
) )
const waitForSocketOpen = async() => { const waitForSocketOpen = async() => {
if(ws.readyState === ws.OPEN) return if(ws.readyState === ws.OPEN) {
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { return
throw new Boom('Connection Already Closed', { statusCode: DisconnectReason.connectionClosed }) }
}
let onOpen: () => void if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
let onClose: (err: Error) => void throw new Boom('Connection Already Closed', { statusCode: DisconnectReason.connectionClosed })
await new Promise((resolve, reject) => { }
onOpen = () => resolve(undefined)
onClose = reject let onOpen: () => void
ws.on('open', onOpen) let onClose: (err: Error) => void
ws.on('close', onClose) await new Promise((resolve, reject) => {
ws.on('error', onClose) onOpen = () => resolve(undefined)
}) onClose = reject
.finally(() => { ws.on('open', onOpen)
ws.off('open', onOpen) ws.on('close', onClose)
ws.off('close', onClose) ws.on('error', onClose)
ws.off('error', onClose) })
}) .finally(() => {
} ws.off('open', onOpen)
ws.off('close', onClose)
ws.off('error', onClose)
})
}
ws.on('message', onMessageRecieved) ws.on('message', onMessageRecieved)
ws.on('open', () => { ws.on('open', () => {
@@ -343,57 +373,58 @@ export const makeSocket = ({
ws.on('error', end) ws.on('error', end)
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost }))) ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost })))
ws.on(PHONE_CONNECTION_CB, json => { ws.on(PHONE_CONNECTION_CB, json => {
if (!json[1]) { if(!json[1]) {
end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost })) end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost }))
logger.info('Connection terminated by phone, closing...') logger.info('Connection terminated by phone, closing...')
} else { } else {
phoneConnectionChanged(true) phoneConnectionChanged(true)
} }
}) })
ws.on('CB:Cmd,type:disconnect', json => { ws.on('CB:Cmd,type:disconnect', json => {
const {kind} = json[1] const { kind } = json[1]
let reason: DisconnectReason let reason: DisconnectReason
switch(kind) { switch (kind) {
case 'replaced': case 'replaced':
reason = DisconnectReason.connectionReplaced reason = DisconnectReason.connectionReplaced
break break
default: default:
reason = DisconnectReason.connectionLost reason = DisconnectReason.connectionLost
break break
} }
end(new Boom(
`Connection terminated by server: "${kind || 'unknown'}"`, end(new Boom(
{ statusCode: reason } `Connection terminated by server: "${kind || 'unknown'}"`,
)) { statusCode: reason }
}) ))
})
return { return {
type: 'legacy' as 'legacy', type: 'legacy' as 'legacy',
ws, ws,
sendAdminTest, sendAdminTest,
updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info, updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info,
waitForSocketOpen, waitForSocketOpen,
sendNode, sendNode,
generateMessageTag, generateMessageTag,
waitForMessage, waitForMessage,
query, query,
/** Generic function for action, set queries */ /** Generic function for action, set queries */
setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => { setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => {
const json: BinaryNode = { const json: BinaryNode = {
tag: 'action', tag: 'action',
attrs: { epoch: epoch.toString(), type: 'set' }, attrs: { epoch: epoch.toString(), type: 'set' },
content: nodes content: nodes
} }
return query({ return query({
json, json,
binaryTag, binaryTag,
tag, tag,
expect200: true, expect200: true,
requiresPhoneConnection: true requiresPhoneConnection: true
}) as Promise<{ status: number }> }) as Promise<{ status: number }>
}, },
currentEpoch: () => epoch, currentEpoch: () => epoch,
end end
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { generateMessageID } from "../Utils"; import { GroupMetadata, ParticipantAction, SocketConfig } from '../Types'
import { SocketConfig, GroupMetadata, ParticipantAction } from "../Types"; import { generateMessageID } from '../Utils'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidEncode, jidNormalizedUser } from "../WABinary"; import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidEncode, jidNormalizedUser } from '../WABinary'
import { makeSocket } from "./socket"; import { makeSocket } from './socket'
export const makeGroupsSocket = (config: SocketConfig) => { export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeSocket(config) const sock = makeSocket(config)
@@ -9,24 +9,24 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => ( const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => (
query({ query({
tag: 'iq', tag: 'iq',
attrs: { attrs: {
type, type,
xmlns: 'w:g2', xmlns: 'w:g2',
to: jid, to: jid,
}, },
content content
}) })
) )
const groupMetadata = async(jid: string) => { const groupMetadata = async(jid: string) => {
const result = await groupQuery( const result = await groupQuery(
jid, jid,
'get', 'get',
[ { tag: 'query', attrs: { request: 'interactive' } } ] [ { tag: 'query', attrs: { request: 'interactive' } } ]
) )
return extractGroupMetadata(result) return extractGroupMetadata(result)
} }
return { return {
...sock, ...sock,
@@ -101,8 +101,8 @@ export const makeGroupsSocket = (config: SocketConfig) => {
return participantsAffected.map(p => p.attrs.jid) return participantsAffected.map(p => p.attrs.jid)
}, },
groupUpdateDescription: async(jid: string, description?: string) => { groupUpdateDescription: async(jid: string, description?: string) => {
const metadata = await groupMetadata(jid); const metadata = await groupMetadata(jid)
const prev = metadata.descId ?? null; const prev = metadata.descId ?? null
await groupQuery( await groupQuery(
jid, jid,
@@ -111,10 +111,10 @@ export const makeGroupsSocket = (config: SocketConfig) => {
{ {
tag: 'description', tag: 'description',
attrs: { attrs: {
...( description ? { id: generateMessageID() } : { delete: 'true' } ), ...(description ? { id: generateMessageID() } : { delete: 'true' }),
...(prev ? { prev } : {}) ...(prev ? { prev } : {})
}, },
content: description ? [{tag: 'body', attrs: {}, content: Buffer.from(description, 'utf-8')}] : null content: description ? [{ tag: 'body', attrs: {}, content: Buffer.from(description, 'utf-8') }] : null
} }
] ]
) )
@@ -124,12 +124,12 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const inviteNode = getBinaryNodeChild(result, 'invite') const inviteNode = getBinaryNodeChild(result, 'invite')
return inviteNode.attrs.code return inviteNode.attrs.code
}, },
groupRevokeInvite: async (jid: string) => { groupRevokeInvite: async(jid: string) => {
const result = await groupQuery(jid, 'set', [{ tag: 'invite', attrs: {} }]) const result = await groupQuery(jid, 'set', [{ tag: 'invite', attrs: {} }])
const inviteNode = getBinaryNodeChild(result, 'invite') const inviteNode = getBinaryNodeChild(result, 'invite')
return inviteNode.attrs.code return inviteNode.attrs.code
}, },
groupAcceptInvite: async (code: string) => { groupAcceptInvite: async(code: string) => {
const results = await groupQuery('@g.us', 'set', [{ tag: 'invite', attrs: { code } }]) const results = await groupQuery('@g.us', 'set', [{ tag: 'invite', attrs: { code } }])
const result = getBinaryNodeChild(results, 'group') const result = getBinaryNodeChild(results, 'group')
return result.attrs.jid return result.attrs.jid
@@ -148,8 +148,8 @@ export const makeGroupsSocket = (config: SocketConfig) => {
tag: 'iq', tag: 'iq',
attrs: { attrs: {
to: '@g.us', to: '@g.us',
xmlns: 'w:g2', xmlns: 'w:g2',
type: 'get', type: 'get',
}, },
content: [ content: [
{ {
@@ -175,6 +175,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
data[meta.id] = meta data[meta.id] = meta
} }
} }
return data return data
} }
} }
@@ -190,6 +191,7 @@ export const extractGroupMetadata = (result: BinaryNode) => {
desc = getBinaryNodeChild(descChild, 'body')?.content as string desc = getBinaryNodeChild(descChild, 'body')?.content as string
descId = descChild.attrs.id descId = descChild.attrs.id
} }
const groupId = group.attrs.id.includes('@') ? group.attrs.id : jidEncode(group.attrs.id, 'g.us') const groupId = group.attrs.id.includes('@') ? group.attrs.id : jidEncode(group.attrs.id, 'g.us')
const eph = getBinaryNodeChild(group, 'ephemeral')?.attrs.expiration const eph = getBinaryNodeChild(group, 'ephemeral')?.attrs.expiration
const metadata: GroupMetadata = { const metadata: GroupMetadata = {

View File

@@ -1,5 +1,5 @@
import { SocketConfig } from '../Types'
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import { SocketConfig } from '../Types'
import { makeMessagesRecvSocket as _makeSocket } from './messages-recv' import { makeMessagesRecvSocket as _makeSocket } from './messages-recv'
// export the last socket layer // export the last socket layer

View File

@@ -1,24 +1,25 @@
import { SocketConfig, WAMessageStubType, ParticipantAction, Chat, GroupMetadata } from "../Types" import { proto } from '../../WAProto'
import { decodeMessageStanza, encodeBigEndian, toNumber, downloadAndProcessHistorySyncNotification, generateSignalPubKey, xmppPreKey, xmppSignedPreKey } from "../Utils" import { KEY_BUNDLE_TYPE } from '../Defaults'
import { BinaryNode, jidDecode, jidEncode, areJidsSameUser, getBinaryNodeChildren, jidNormalizedUser, getAllBinaryNodeChildren, BinaryNodeAttributes, isJidGroup } from '../WABinary' import { Chat, GroupMetadata, ParticipantAction, SocketConfig, WAMessageStubType } from '../Types'
import { proto } from "../../WAProto" import { decodeMessageStanza, downloadAndProcessHistorySyncNotification, encodeBigEndian, generateSignalPubKey, toNumber, xmppPreKey, xmppSignedPreKey } from '../Utils'
import { KEY_BUNDLE_TYPE } from "../Defaults" import { areJidsSameUser, BinaryNode, BinaryNodeAttributes, getAllBinaryNodeChildren, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser } from '../WABinary'
import { makeChatsSocket } from "./chats" import { makeChatsSocket } from './chats'
import { extractGroupMetadata } from "./groups" import { extractGroupMetadata } from './groups'
const STATUS_MAP: { [_: string]: proto.WebMessageInfo.WebMessageInfoStatus } = { const STATUS_MAP: { [_: string]: proto.WebMessageInfo.WebMessageInfoStatus } = {
'played': proto.WebMessageInfo.WebMessageInfoStatus.PLAYED, 'played': proto.WebMessageInfo.WebMessageInfoStatus.PLAYED,
'read': proto.WebMessageInfo.WebMessageInfoStatus.READ, 'read': proto.WebMessageInfo.WebMessageInfoStatus.READ,
'read-self': proto.WebMessageInfo.WebMessageInfoStatus.READ 'read-self': proto.WebMessageInfo.WebMessageInfoStatus.READ
} }
const getStatusFromReceiptType = (type: string | undefined) => { const getStatusFromReceiptType = (type: string | undefined) => {
const status = STATUS_MAP[type] const status = STATUS_MAP[type]
if(typeof type === 'undefined') { if(typeof type === 'undefined') {
return proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK return proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK
} }
return status
return status
} }
export const makeMessagesRecvSocket = (config: SocketConfig) => { export const makeMessagesRecvSocket = (config: SocketConfig) => {
@@ -26,477 +27,501 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config) const sock = makeChatsSocket(config)
const { const {
ev, ev,
authState, authState,
ws, ws,
assertSessions, assertSessions,
assertingPreKeys, assertingPreKeys,
sendNode, sendNode,
relayMessage, relayMessage,
sendReceipt, sendReceipt,
resyncMainAppState, resyncMainAppState,
} = sock } = sock
const msgRetryMap = config.msgRetryCounterMap || { } const msgRetryMap = config.msgRetryCounterMap || { }
const historyCache = new Set<string>() const historyCache = new Set<string>()
const sendMessageAck = async({ tag, attrs }: BinaryNode, extraAttrs: BinaryNodeAttributes) => { const sendMessageAck = async({ tag, attrs }: BinaryNode, extraAttrs: BinaryNodeAttributes) => {
const stanza: BinaryNode = { const stanza: BinaryNode = {
tag: 'ack', tag: 'ack',
attrs: { attrs: {
id: attrs.id, id: attrs.id,
to: attrs.from, to: attrs.from,
...extraAttrs, ...extraAttrs,
} }
} }
if(!!attrs.participant) { if(!!attrs.participant) {
stanza.attrs.participant = attrs.participant stanza.attrs.participant = attrs.participant
} }
logger.debug({ recv: attrs, sent: stanza.attrs }, `sent "${tag}" ack`)
await sendNode(stanza)
}
const sendRetryRequest = async(node: BinaryNode) => { logger.debug({ recv: attrs, sent: stanza.attrs }, `sent "${tag}" ack`)
const msgId = node.attrs.id await sendNode(stanza)
const retryCount = msgRetryMap[msgId] || 1 }
if(retryCount >= 5) {
logger.debug({ retryCount, msgId }, 'reached retry limit, clearing')
delete msgRetryMap[msgId]
return
}
msgRetryMap[msgId] = retryCount+1
const isGroup = !!node.attrs.participant const sendRetryRequest = async(node: BinaryNode) => {
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds const msgId = node.attrs.id
const retryCount = msgRetryMap[msgId] || 1
if(retryCount >= 5) {
logger.debug({ retryCount, msgId }, 'reached retry limit, clearing')
delete msgRetryMap[msgId]
return
}
const deviceIdentity = proto.ADVSignedDeviceIdentity.encode(account).finish() msgRetryMap[msgId] = retryCount+1
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 isGroup = !!node.attrs.participant
const receipt: BinaryNode = { const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
tag: 'receipt',
attrs: {
id: msgId,
type: 'retry',
to: isGroup ? node.attrs.from : jidEncode(decFrom!.user, 's.whatsapp.net', decFrom!.device, 0)
},
content: [
{
tag: 'retry',
attrs: {
count: retryCount.toString(),
id: node.attrs.id,
t: node.attrs.t,
v: '1'
}
},
{
tag: 'registration',
attrs: { },
content: encodeBigEndian(authState.creds.registrationId)
}
]
}
if(node.attrs.recipient) {
receipt.attrs.recipient = node.attrs.recipient
}
if(node.attrs.participant) {
receipt.attrs.participant = node.attrs.participant
}
if(retryCount > 1) {
const exec = generateSignalPubKey(Buffer.from(KEY_BUNDLE_TYPE)).slice(0, 1);
(receipt.content! as BinaryNode[]).push({ const deviceIdentity = proto.ADVSignedDeviceIdentity.encode(account).finish()
tag: 'keys', await assertingPreKeys(1, async preKeys => {
attrs: { }, const [keyId] = Object.keys(preKeys)
content: [ const key = preKeys[+keyId]
{ tag: 'type', attrs: { }, content: exec },
{ tag: 'identity', attrs: { }, content: identityKey.public },
xmppPreKey(key, +keyId),
xmppSignedPreKey(signedPreKey),
{ tag: 'device-identity', attrs: { }, content: deviceIdentity }
]
})
}
await sendNode(receipt)
logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt') const decFrom = node.attrs.from ? jidDecode(node.attrs.from) : undefined
}) const receipt: BinaryNode = {
} tag: 'receipt',
attrs: {
id: msgId,
type: 'retry',
to: isGroup ? node.attrs.from : jidEncode(decFrom!.user, 's.whatsapp.net', decFrom!.device, 0)
},
content: [
{
tag: 'retry',
attrs: {
count: retryCount.toString(),
id: node.attrs.id,
t: node.attrs.t,
v: '1'
}
},
{
tag: 'registration',
attrs: { },
content: encodeBigEndian(authState.creds.registrationId)
}
]
}
if(node.attrs.recipient) {
receipt.attrs.recipient = node.attrs.recipient
}
const processMessage = async(message: proto.IWebMessageInfo, chatUpdate: Partial<Chat>) => { if(node.attrs.participant) {
const protocolMsg = message.message?.protocolMessage receipt.attrs.participant = node.attrs.participant
if(protocolMsg) { }
switch(protocolMsg.type) {
case proto.ProtocolMessage.ProtocolMessageType.HISTORY_SYNC_NOTIFICATION:
const histNotification = protocolMsg!.historySyncNotification
logger.info({ histNotification, id: message.key.id }, 'got history notification') if(retryCount > 1) {
const { chats, contacts, messages, isLatest } = await downloadAndProcessHistorySyncNotification(histNotification, historyCache) const exec = generateSignalPubKey(Buffer.from(KEY_BUNDLE_TYPE)).slice(0, 1);
const meJid = authState.creds.me!.id (receipt.content! as BinaryNode[]).push({
await sendNode({ tag: 'keys',
tag: 'receipt', attrs: { },
attrs: { content: [
id: message.key.id, { tag: 'type', attrs: { }, content: exec },
type: 'hist_sync', { tag: 'identity', attrs: { }, content: identityKey.public },
to: jidEncode(jidDecode(meJid).user, 'c.us') xmppPreKey(key, +keyId),
} xmppSignedPreKey(signedPreKey),
}) { tag: 'device-identity', attrs: { }, content: deviceIdentity }
]
})
}
if(chats.length) ev.emit('chats.set', { chats, isLatest }) await sendNode(receipt)
if(messages.length) ev.emit('messages.set', { messages, isLatest })
if(contacts.length) ev.emit('contacts.set', { contacts })
break logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt')
case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE: })
const keys = protocolMsg.appStateSyncKeyShare!.keys }
if(keys?.length) {
let newAppStateSyncKeyId = ''
for(const { keyData, keyId } of keys) {
const strKeyId = Buffer.from(keyId.keyId!).toString('base64')
logger.info({ strKeyId }, 'injecting new app state sync key') const processMessage = async(message: proto.IWebMessageInfo, chatUpdate: Partial<Chat>) => {
await authState.keys.set({ 'app-state-sync-key': { [strKeyId]: keyData } }) const protocolMsg = message.message?.protocolMessage
if(protocolMsg) {
switch (protocolMsg.type) {
case proto.ProtocolMessage.ProtocolMessageType.HISTORY_SYNC_NOTIFICATION:
const histNotification = protocolMsg!.historySyncNotification
newAppStateSyncKeyId = strKeyId logger.info({ histNotification, id: message.key.id }, 'got history notification')
} const { chats, contacts, messages, isLatest } = await downloadAndProcessHistorySyncNotification(histNotification, historyCache)
ev.emit('creds.update', { myAppStateKeyId: newAppStateSyncKeyId }) const meJid = authState.creds.me!.id
await sendNode({
tag: 'receipt',
attrs: {
id: message.key.id,
type: 'hist_sync',
to: jidEncode(jidDecode(meJid).user, 'c.us')
}
})
resyncMainAppState() if(chats.length) {
} else [ ev.emit('chats.set', { chats, isLatest })
logger.info({ protocolMsg }, 'recv app state sync with 0 keys') }
]
break
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
ev.emit('messages.update', [
{
key: {
...message.key,
id: protocolMsg.key!.id
},
update: { message: null, messageStubType: WAMessageStubType.REVOKE, key: message.key }
}
])
break
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
chatUpdate.ephemeralSettingTimestamp = toNumber(message.messageTimestamp)
chatUpdate.ephemeralExpiration = protocolMsg.ephemeralExpiration || null
break
}
} else if(message.messageStubType) {
const meJid = authState.creds.me!.id
const jid = message.key!.remoteJid!
//let actor = whatsappID (message.participant)
let participants: string[]
const emitParticipantsUpdate = (action: ParticipantAction) => (
ev.emit('group-participants.update', { id: jid, participants, action })
)
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ { id: jid, ...update } ])
}
switch (message.messageStubType) { if(messages.length) {
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE: ev.emit('messages.set', { messages, isLatest })
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE: }
participants = message.messageStubParameters
emitParticipantsUpdate('remove')
// mark the chat read only if you left the group
if(participants.includes(meJid)) {
chatUpdate.readOnly = true
}
break
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
participants = message.messageStubParameters
if (participants.includes(meJid)) {
chatUpdate.readOnly = false
}
emitParticipantsUpdate('add')
break
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announceValue = message.messageStubParameters[0]
emitGroupUpdate({ announce: announceValue === 'true' || announceValue === 'on' })
break
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
const restrictValue = message.messageStubParameters[0]
emitGroupUpdate({ restrict: restrictValue === 'true' || restrictValue === 'on' })
break
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
}
}
}
const processNotification = (node: BinaryNode): Partial<proto.IWebMessageInfo> => { if(contacts.length) {
const result: Partial<proto.IWebMessageInfo> = { } ev.emit('contacts.set', { contacts })
const [child] = getAllBinaryNodeChildren(node) }
if(node.attrs.type === 'w:gp2') { break
switch(child?.tag) { case proto.ProtocolMessage.ProtocolMessageType.APP_STATE_SYNC_KEY_SHARE:
case 'create': const keys = protocolMsg.appStateSyncKeyShare!.keys
const metadata = extractGroupMetadata(child) if(keys?.length) {
let newAppStateSyncKeyId = ''
for(const { keyData, keyId } of keys) {
const strKeyId = Buffer.from(keyId.keyId!).toString('base64')
result.messageStubType = WAMessageStubType.GROUP_CREATE logger.info({ strKeyId }, 'injecting new app state sync key')
result.messageStubParameters = [metadata.subject] await authState.keys.set({ 'app-state-sync-key': { [strKeyId]: keyData } })
result.key = { participant: metadata.owner }
ev.emit('chats.upsert', [{ newAppStateSyncKeyId = strKeyId
id: metadata.id, }
name: metadata.subject,
conversationTimestamp: metadata.creation,
}])
ev.emit('groups.upsert', [metadata])
break
case 'ephemeral':
case 'not_ephemeral':
result.message = {
protocolMessage: {
type: proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration: +(child.attrs.expiration || 0)
}
}
break
case 'promote':
case 'demote':
case 'remove':
case 'add':
case 'leave':
const stubType = `GROUP_PARTICIPANT_${child.tag!.toUpperCase()}`
result.messageStubType = WAMessageStubType[stubType]
const participants = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid) ev.emit('creds.update', { myAppStateKeyId: newAppStateSyncKeyId })
if(
participants.length === 1 && resyncMainAppState()
} else {
[
logger.info({ protocolMsg }, 'recv app state sync with 0 keys')
]
}
break
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
ev.emit('messages.update', [
{
key: {
...message.key,
id: protocolMsg.key!.id
},
update: { message: null, messageStubType: WAMessageStubType.REVOKE, key: message.key }
}
])
break
case proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING:
chatUpdate.ephemeralSettingTimestamp = toNumber(message.messageTimestamp)
chatUpdate.ephemeralExpiration = protocolMsg.ephemeralExpiration || null
break
}
} else if(message.messageStubType) {
const meJid = authState.creds.me!.id
const jid = message.key!.remoteJid!
//let actor = whatsappID (message.participant)
let participants: string[]
const emitParticipantsUpdate = (action: ParticipantAction) => (
ev.emit('group-participants.update', { id: jid, participants, action })
)
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ { id: jid, ...update } ])
}
switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
participants = message.messageStubParameters
emitParticipantsUpdate('remove')
// mark the chat read only if you left the group
if(participants.includes(meJid)) {
chatUpdate.readOnly = true
}
break
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
participants = message.messageStubParameters
if(participants.includes(meJid)) {
chatUpdate.readOnly = false
}
emitParticipantsUpdate('add')
break
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announceValue = message.messageStubParameters[0]
emitGroupUpdate({ announce: announceValue === 'true' || announceValue === 'on' })
break
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
const restrictValue = message.messageStubParameters[0]
emitGroupUpdate({ restrict: restrictValue === 'true' || restrictValue === 'on' })
break
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
}
}
}
const processNotification = (node: BinaryNode): Partial<proto.IWebMessageInfo> => {
const result: Partial<proto.IWebMessageInfo> = { }
const [child] = getAllBinaryNodeChildren(node)
if(node.attrs.type === 'w:gp2') {
switch (child?.tag) {
case 'create':
const metadata = extractGroupMetadata(child)
result.messageStubType = WAMessageStubType.GROUP_CREATE
result.messageStubParameters = [metadata.subject]
result.key = { participant: metadata.owner }
ev.emit('chats.upsert', [{
id: metadata.id,
name: metadata.subject,
conversationTimestamp: metadata.creation,
}])
ev.emit('groups.upsert', [metadata])
break
case 'ephemeral':
case 'not_ephemeral':
result.message = {
protocolMessage: {
type: proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration: +(child.attrs.expiration || 0)
}
}
break
case 'promote':
case 'demote':
case 'remove':
case 'add':
case 'leave':
const stubType = `GROUP_PARTICIPANT_${child.tag!.toUpperCase()}`
result.messageStubType = WAMessageStubType[stubType]
const participants = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid)
if(
participants.length === 1 &&
// if recv. "remove" message and sender removed themselves // if recv. "remove" message and sender removed themselves
// mark as left // mark as left
areJidsSameUser(participants[0], node.attrs.participant) && areJidsSameUser(participants[0], node.attrs.participant) &&
child.tag === 'remove' child.tag === 'remove'
) { ) {
result.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_LEAVE result.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_LEAVE
} }
result.messageStubParameters = participants
break
case 'subject':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT
result.messageStubParameters = [ child.attrs.subject ]
break
case 'announcement':
case 'not_announcement':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE
result.messageStubParameters = [ (child.tag === 'announcement') ? 'on' : 'off' ]
break
case 'locked':
case 'unlocked':
result.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
result.messageStubParameters = [ (child.tag === 'locked') ? 'on' : 'off' ]
break
} result.messageStubParameters = participants
} else { break
switch(child.tag) { case 'subject':
case 'devices': result.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT
const devices = getBinaryNodeChildren(child, 'device') result.messageStubParameters = [ child.attrs.subject ]
if(areJidsSameUser(child.attrs.jid, authState.creds!.me!.id)) { break
const deviceJids = devices.map(d => d.attrs.jid) case 'announcement':
logger.info({ deviceJids }, 'got my own devices') case 'not_announcement':
} result.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE
break result.messageStubParameters = [ (child.tag === 'announcement') ? 'on' : 'off' ]
} break
} case 'locked':
if(Object.keys(result).length) { case 'unlocked':
return result result.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
} result.messageStubParameters = [ (child.tag === 'locked') ? 'on' : 'off' ]
} break
// recv a message
ws.on('CB:message', async(stanza: BinaryNode) => {
const msg = await decodeMessageStanza(stanza, authState)
// message failed to decrypt
if(msg.messageStubType === proto.WebMessageInfo.WebMessageInfoStubType.CIPHERTEXT) {
logger.error(
{ msgId: msg.key.id, params: msg.messageStubParameters },
'failure in decrypting message'
)
await sendRetryRequest(stanza)
} else {
await sendMessageAck(stanza, { class: 'receipt' })
// no type in the receipt => message delivered
await sendReceipt(msg.key.remoteJid!, msg.key.participant, [msg.key.id!], undefined)
logger.debug({ msg: msg.key }, 'sent delivery receipt')
}
msg.key.remoteJid = jidNormalizedUser(msg.key.remoteJid!) }
ev.emit('messages.upsert', { messages: [msg], type: stanza.attrs.offline ? 'append' : 'notify' }) } else {
}) switch (child.tag) {
case 'devices':
const devices = getBinaryNodeChildren(child, 'device')
if(areJidsSameUser(child.attrs.jid, authState.creds!.me!.id)) {
const deviceJids = devices.map(d => d.attrs.jid)
logger.info({ deviceJids }, 'got my own devices')
}
ws.on('CB:ack,class:message', async(node: BinaryNode) => { break
await sendNode({ }
tag: 'ack', }
attrs: {
class: 'receipt',
id: node.attrs.id,
from: node.attrs.from
}
})
logger.debug({ attrs: node.attrs }, 'sending receipt for ack')
})
ws.on('CB:call', async(node: BinaryNode) => { if(Object.keys(result).length) {
logger.info({ node }, 'recv call') return result
}
}
const [child] = getAllBinaryNodeChildren(node) // recv a message
if(!!child?.tag) { ws.on('CB:message', async(stanza: BinaryNode) => {
await sendMessageAck(node, { class: 'call', type: child.tag }) const msg = await decodeMessageStanza(stanza, authState)
} // message failed to decrypt
}) if(msg.messageStubType === proto.WebMessageInfo.WebMessageInfoStubType.CIPHERTEXT) {
logger.error(
{ msgId: msg.key.id, params: msg.messageStubParameters },
'failure in decrypting message'
)
await sendRetryRequest(stanza)
} else {
await sendMessageAck(stanza, { class: 'receipt' })
// no type in the receipt => message delivered
await sendReceipt(msg.key.remoteJid!, msg.key.participant, [msg.key.id!], undefined)
logger.debug({ msg: msg.key }, 'sent delivery receipt')
}
const sendMessagesAgain = async(key: proto.IMessageKey, ids: string[]) => { msg.key.remoteJid = jidNormalizedUser(msg.key.remoteJid!)
const msgs = await Promise.all( ev.emit('messages.upsert', { messages: [msg], type: stanza.attrs.offline ? 'append' : 'notify' })
ids.map(id => ( })
config.getMessage({ ...key, id })
))
)
const participant = key.participant || key.remoteJid ws.on('CB:ack,class:message', async(node: BinaryNode) => {
await assertSessions([participant], true) await sendNode({
tag: 'ack',
attrs: {
class: 'receipt',
id: node.attrs.id,
from: node.attrs.from
}
})
logger.debug({ attrs: node.attrs }, 'sending receipt for ack')
})
if(isJidGroup(key.remoteJid)) { ws.on('CB:call', async(node: BinaryNode) => {
await authState.keys.set({ 'sender-key-memory': { [key.remoteJid]: null } }) logger.info({ node }, 'recv call')
}
logger.debug({ participant }, 'forced new session for retry recp') const [child] = getAllBinaryNodeChildren(node)
if(!!child?.tag) {
await sendMessageAck(node, { class: 'call', type: child.tag })
}
})
for(let i = 0; i < msgs.length;i++) { const sendMessagesAgain = async(key: proto.IMessageKey, ids: string[]) => {
if(msgs[i]) { const msgs = await Promise.all(
await relayMessage(key.remoteJid, msgs[i], { ids.map(id => (
messageId: ids[i], config.getMessage({ ...key, id })
participant ))
}) )
} else {
logger.debug({ jid: key.remoteJid, id: ids[i] }, 'recv retry request, but message not available')
}
}
}
const handleReceipt = async(node: BinaryNode) => { const participant = key.participant || key.remoteJid
let shouldAck = true await assertSessions([participant], true)
const { attrs, content } = node if(isJidGroup(key.remoteJid)) {
const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, authState.creds.me?.id) await authState.keys.set({ 'sender-key-memory': { [key.remoteJid]: null } })
const remoteJid = !isNodeFromMe ? attrs.from : attrs.recipient }
const fromMe = !attrs.recipient
const ids = [attrs.id] logger.debug({ participant }, 'forced new session for retry recp')
if(Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item')
ids.push(...items.map(i => i.attrs.id))
}
const key: proto.IMessageKey = { for(let i = 0; i < msgs.length;i++) {
remoteJid, if(msgs[i]) {
id: '', await relayMessage(key.remoteJid, msgs[i], {
fromMe, messageId: ids[i],
participant: attrs.participant participant
} })
} else {
logger.debug({ jid: key.remoteJid, id: ids[i] }, 'recv retry request, but message not available')
}
}
}
const status = getStatusFromReceiptType(attrs.type) const handleReceipt = async(node: BinaryNode) => {
if( let shouldAck = true
typeof status !== 'undefined' &&
const { attrs, content } = node
const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, authState.creds.me?.id)
const remoteJid = !isNodeFromMe ? attrs.from : attrs.recipient
const fromMe = !attrs.recipient
const ids = [attrs.id]
if(Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item')
ids.push(...items.map(i => i.attrs.id))
}
const key: proto.IMessageKey = {
remoteJid,
id: '',
fromMe,
participant: attrs.participant
}
const status = getStatusFromReceiptType(attrs.type)
if(
typeof status !== 'undefined' &&
( (
// basically, we only want to know when a message from us has been delivered to/read by the other person // basically, we only want to know when a message from us has been delivered to/read by the other person
// or another device of ours has read some messages // or another device of ours has read some messages
status > proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK || status > proto.WebMessageInfo.WebMessageInfoStatus.DELIVERY_ACK ||
!isNodeFromMe !isNodeFromMe
) )
) { ) {
ev.emit('messages.update', ids.map(id => ({ ev.emit('messages.update', ids.map(id => ({
key: { ...key, id }, key: { ...key, id },
update: { status } update: { status }
}))) })))
} }
if(attrs.type === 'retry') { if(attrs.type === 'retry') {
// correctly set who is asking for the retry // correctly set who is asking for the retry
key.participant = key.participant || attrs.from key.participant = key.participant || attrs.from
if(key.fromMe) { if(key.fromMe) {
try { try {
logger.debug({ attrs }, 'recv retry request') logger.debug({ attrs }, 'recv retry request')
await sendMessagesAgain(key, ids) await sendMessagesAgain(key, ids)
} catch(error) { } catch(error) {
logger.error({ key, ids, trace: error.stack }, 'error in sending message again') logger.error({ key, ids, trace: error.stack }, 'error in sending message again')
shouldAck = false shouldAck = false
} }
} else { } else {
logger.info({ attrs, key }, 'recv retry for not fromMe message') logger.info({ attrs, key }, 'recv retry for not fromMe message')
} }
} }
if(shouldAck) { if(shouldAck) {
await sendMessageAck(node, { class: 'receipt', type: attrs.type }) await sendMessageAck(node, { class: 'receipt', type: attrs.type })
} }
} }
ws.on('CB:receipt', handleReceipt) ws.on('CB:receipt', handleReceipt)
ws.on('CB:notification', async(node: BinaryNode) => { ws.on('CB:notification', async(node: BinaryNode) => {
await sendMessageAck(node, { class: 'notification', type: node.attrs.type }) await sendMessageAck(node, { class: 'notification', type: node.attrs.type })
const msg = processNotification(node) const msg = processNotification(node)
if(msg) { if(msg) {
const fromMe = areJidsSameUser(node.attrs.participant || node.attrs.from, authState.creds.me!.id) const fromMe = areJidsSameUser(node.attrs.participant || node.attrs.from, authState.creds.me!.id)
msg.key = { msg.key = {
remoteJid: node.attrs.from, remoteJid: node.attrs.from,
fromMe, fromMe,
participant: node.attrs.participant, participant: node.attrs.participant,
id: node.attrs.id, id: node.attrs.id,
...(msg.key || {}) ...(msg.key || {})
} }
msg.messageTimestamp = +node.attrs.t msg.messageTimestamp = +node.attrs.t
const fullMsg = proto.WebMessageInfo.fromObject(msg) const fullMsg = proto.WebMessageInfo.fromObject(msg)
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' }) ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
} }
}) })
ev.on('messages.upsert', async({ messages, type }) => { ev.on('messages.upsert', async({ messages, type }) => {
if(type === 'notify' || type === 'append') { if(type === 'notify' || type === 'append') {
const chat: Partial<Chat> = { id: messages[0].key.remoteJid } const chat: Partial<Chat> = { id: messages[0].key.remoteJid }
const contactNameUpdates: { [_: string]: string } = { } const contactNameUpdates: { [_: string]: string } = { }
for(const msg of messages) { for(const msg of messages) {
if(!!msg.pushName) { if(!!msg.pushName) {
const jid = msg.key.fromMe ? jidNormalizedUser(authState.creds.me!.id) : (msg.key.participant || msg.key.remoteJid) const jid = msg.key.fromMe ? jidNormalizedUser(authState.creds.me!.id) : (msg.key.participant || msg.key.remoteJid)
contactNameUpdates[jid] = msg.pushName contactNameUpdates[jid] = msg.pushName
// update our pushname too // update our pushname too
if(msg.key.fromMe && authState.creds.me?.name !== msg.pushName) { if(msg.key.fromMe && authState.creds.me?.name !== msg.pushName) {
ev.emit('creds.update', { me: { ...authState.creds.me!, name: msg.pushName! } }) ev.emit('creds.update', { me: { ...authState.creds.me!, name: msg.pushName! } })
} }
} }
await processMessage(msg, chat) await processMessage(msg, chat)
if(!!msg.message && !msg.message!.protocolMessage) { if(!!msg.message && !msg.message!.protocolMessage) {
chat.conversationTimestamp = toNumber(msg.messageTimestamp) chat.conversationTimestamp = toNumber(msg.messageTimestamp)
if(!msg.key.fromMe) { if(!msg.key.fromMe) {
chat.unreadCount = (chat.unreadCount || 0) + 1 chat.unreadCount = (chat.unreadCount || 0) + 1
} }
} }
} }
if(Object.keys(chat).length > 1) {
ev.emit('chats.update', [ chat ]) if(Object.keys(chat).length > 1) {
} ev.emit('chats.update', [ chat ])
if(Object.keys(contactNameUpdates).length) { }
ev.emit('contacts.update', Object.keys(contactNameUpdates).map(
id => ({ id, notify: contactNameUpdates[id] }) if(Object.keys(contactNameUpdates).length) {
)) ev.emit('contacts.update', Object.keys(contactNameUpdates).map(
} id => ({ id, notify: contactNameUpdates[id] })
} ))
}) }
}
})
return { ...sock, processMessage, sendMessageAck, sendRetryRequest } return { ...sock, processMessage, sendMessageAck, sendRetryRequest }
} }

View File

@@ -1,439 +1,452 @@
import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction, MessageRelayOptions } from "../Types" import NodeCache from 'node-cache'
import { encodeWAMessage, generateMessageID, generateWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESessions, getWAUploadToServer } from "../Utils" import { proto } from '../../WAProto'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser, S_WHATSAPP_NET, BinaryNodeAttributes, JidWithDevice, reduceBinaryNodeToDictionary } from '../WABinary' import { WA_DEFAULT_EPHEMERAL } from '../Defaults'
import { proto } from "../../WAProto" import { AnyMessageContent, MediaConnInfo, MessageRelayOptions, MiscMessageGenerationOptions, SocketConfig } from '../Types'
import { WA_DEFAULT_EPHEMERAL } from "../Defaults" import { encodeWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, generateMessageID, generateWAMessage, getWAUploadToServer, jidToSignalProtocolAddress, parseAndInjectE2ESessions } from '../Utils'
import { makeGroupsSocket } from "./groups" import { BinaryNode, BinaryNodeAttributes, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser, JidWithDevice, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary'
import NodeCache from "node-cache" import { makeGroupsSocket } from './groups'
export const makeMessagesSocket = (config: SocketConfig) => { export const makeMessagesSocket = (config: SocketConfig) => {
const { logger } = config const { logger } = config
const sock = makeGroupsSocket(config) const sock = makeGroupsSocket(config)
const { const {
ev, ev,
authState, authState,
query, query,
generateMessageTag, generateMessageTag,
sendNode, sendNode,
groupMetadata, groupMetadata,
groupToggleEphemeral groupToggleEphemeral
} = sock } = sock
const userDevicesCache = config.userDevicesCache || new NodeCache({ const userDevicesCache = config.userDevicesCache || new NodeCache({
stdTTL: 300, // 5 minutes stdTTL: 300, // 5 minutes
useClones: false useClones: false
}) })
let privacySettings: { [_: string]: string } | undefined let privacySettings: { [_: string]: string } | undefined
const fetchPrivacySettings = async(force: boolean = false) => { const fetchPrivacySettings = async(force: boolean = false) => {
if(!privacySettings || force) { if(!privacySettings || force) {
const { content } = await query({ const { content } = await query({
tag: 'iq', tag: 'iq',
attrs: { attrs: {
xmlns: 'privacy', xmlns: 'privacy',
to: S_WHATSAPP_NET, to: S_WHATSAPP_NET,
type: 'get' type: 'get'
}, },
content: [ content: [
{ tag: 'privacy', attrs: { } } { tag: 'privacy', attrs: { } }
] ]
}) })
privacySettings = reduceBinaryNodeToDictionary(content[0] as BinaryNode, 'category') privacySettings = reduceBinaryNodeToDictionary(content[0] as BinaryNode, 'category')
} }
return privacySettings
}
let mediaConn: Promise<MediaConnInfo> return privacySettings
const refreshMediaConn = async(forceGet = false) => { }
let media = await mediaConn
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) { let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async(forceGet = false) => {
const media = await mediaConn
if(!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
mediaConn = (async() => { mediaConn = (async() => {
const result = await query({ const result = await query({
tag: 'iq', tag: 'iq',
attrs: { attrs: {
type: 'set', type: 'set',
xmlns: 'w:m', xmlns: 'w:m',
to: S_WHATSAPP_NET, to: S_WHATSAPP_NET,
}, },
content: [ { tag: 'media_conn', attrs: { } } ] content: [ { tag: 'media_conn', attrs: { } } ]
}) })
const mediaConnNode = getBinaryNodeChild(result, 'media_conn') const mediaConnNode = getBinaryNodeChild(result, 'media_conn')
const node: MediaConnInfo = { const node: MediaConnInfo = {
hosts: getBinaryNodeChildren(mediaConnNode, 'host').map( hosts: getBinaryNodeChildren(mediaConnNode, 'host').map(
item => item.attrs as any item => item.attrs as any
), ),
auth: mediaConnNode.attrs.auth, auth: mediaConnNode.attrs.auth,
ttl: +mediaConnNode.attrs.ttl, ttl: +mediaConnNode.attrs.ttl,
fetchDate: new Date() fetchDate: new Date()
} }
logger.debug('fetched media conn') logger.debug('fetched media conn')
return node return node
})() })()
} }
return mediaConn
} return mediaConn
/** }
/**
* generic send receipt function * generic send receipt function
* used for receipts of phone call, read, delivery etc. * used for receipts of phone call, read, delivery etc.
* */ * */
const sendReceipt = async(jid: string, participant: string | undefined, messageIds: string[], type: 'read' | 'read-self' | undefined) => { const sendReceipt = async(jid: string, participant: string | undefined, messageIds: string[], type: 'read' | 'read-self' | undefined) => {
const node: BinaryNode = { const node: BinaryNode = {
tag: 'receipt', tag: 'receipt',
attrs: { attrs: {
id: messageIds[0], id: messageIds[0],
t: Date.now().toString(), t: Date.now().toString(),
to: jid, to: jid,
}, },
} }
if(type) { if(type) {
node.attrs.type = type node.attrs.type = type
} }
if(participant) {
node.attrs.participant = participant
}
const remainingMessageIds = messageIds.slice(1)
if(remainingMessageIds.length) {
node.content = [
{
tag: 'list',
attrs: { },
content: remainingMessageIds.map(id => ({
tag: 'item',
attrs: { id }
}))
}
]
}
logger.debug({ jid, messageIds, type }, 'sending receipt for messages') if(participant) {
await sendNode(node) node.attrs.participant = participant
} }
const sendReadReceipt = async(jid: string, participant: string | undefined, messageIds: string[]) => { const remainingMessageIds = messageIds.slice(1)
const privacySettings = await fetchPrivacySettings() if(remainingMessageIds.length) {
// based on privacy settings, we have to change the read type node.content = [
const readType = privacySettings.readreceipts === 'all' ? 'read' : 'read-self' {
return sendReceipt(jid, participant, messageIds, readType) tag: 'list',
} attrs: { },
content: remainingMessageIds.map(id => ({
tag: 'item',
attrs: { id }
}))
}
]
}
const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => { logger.debug({ jid, messageIds, type }, 'sending receipt for messages')
const deviceResults: JidWithDevice[] = [] await sendNode(node)
}
const users: BinaryNode[] = [] const sendReadReceipt = async(jid: string, participant: string | undefined, messageIds: string[]) => {
jids = Array.from(new Set(jids)) const privacySettings = await fetchPrivacySettings()
for(let jid of jids) { // based on privacy settings, we have to change the read type
const user = jidDecode(jid).user const readType = privacySettings.readreceipts === 'all' ? 'read' : 'read-self'
jid = jidNormalizedUser(jid) return sendReceipt(jid, participant, messageIds, readType)
if(userDevicesCache.has(user)) { }
const devices: JidWithDevice[] = userDevicesCache.get(user)
deviceResults.push(...devices)
logger.trace({ user }, 'using cache for devices') const getUSyncDevices = async(jids: string[], ignoreZeroDevices: boolean) => {
} else { const deviceResults: JidWithDevice[] = []
users.push({ tag: 'user', attrs: { jid } })
}
}
const iq: BinaryNode = { const users: BinaryNode[] = []
tag: 'iq', jids = Array.from(new Set(jids))
attrs: { for(let jid of jids) {
to: S_WHATSAPP_NET, const user = jidDecode(jid).user
type: 'get', jid = jidNormalizedUser(jid)
xmlns: 'usync', if(userDevicesCache.has(user)) {
}, const devices: JidWithDevice[] = userDevicesCache.get(user)
content: [ deviceResults.push(...devices)
{
tag: 'usync',
attrs: {
sid: generateMessageTag(),
mode: 'query',
last: 'true',
index: '0',
context: 'message',
},
content: [
{
tag: 'query',
attrs: { },
content: [
{
tag: 'devices',
attrs: { version: '2' }
}
]
},
{ tag: 'list', attrs: { }, content: users }
]
},
],
}
const result = await query(iq)
const extracted = extractDeviceJids(result, authState.creds.me!.id, ignoreZeroDevices)
const deviceMap: { [_: string]: JidWithDevice[] } = {}
for(const item of extracted) { logger.trace({ user }, 'using cache for devices')
deviceMap[item.user] = deviceMap[item.user] || [] } else {
deviceMap[item.user].push(item) users.push({ tag: 'user', attrs: { jid } })
}
}
deviceResults.push(item) const iq: BinaryNode = {
} tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
type: 'get',
xmlns: 'usync',
},
content: [
{
tag: 'usync',
attrs: {
sid: generateMessageTag(),
mode: 'query',
last: 'true',
index: '0',
context: 'message',
},
content: [
{
tag: 'query',
attrs: { },
content: [
{
tag: 'devices',
attrs: { version: '2' }
}
]
},
{ tag: 'list', attrs: { }, content: users }
]
},
],
}
const result = await query(iq)
const extracted = extractDeviceJids(result, authState.creds.me!.id, ignoreZeroDevices)
const deviceMap: { [_: string]: JidWithDevice[] } = {}
for(const key in deviceMap) { for(const item of extracted) {
userDevicesCache.set(key, deviceMap[key]) deviceMap[item.user] = deviceMap[item.user] || []
} deviceMap[item.user].push(item)
return deviceResults deviceResults.push(item)
} }
const assertSessions = async(jids: string[], force: boolean) => { for(const key in deviceMap) {
let jidsRequiringFetch: string[] = [] userDevicesCache.set(key, deviceMap[key])
if(force) { }
jidsRequiringFetch = jids
} else {
const addrs = jids.map(jid => jidToSignalProtocolAddress(jid).toString())
const sessions = await authState.keys.get('session', addrs)
for(const jid of jids) {
const signalId = jidToSignalProtocolAddress(jid).toString()
if(!sessions[signalId]) {
jidsRequiringFetch.push(jid)
}
}
}
if(jidsRequiringFetch.length) { return deviceResults
logger.debug({ jidsRequiringFetch }, `fetching sessions`) }
const result = await query({
tag: 'iq',
attrs: {
xmlns: 'encrypt',
type: 'get',
to: S_WHATSAPP_NET,
},
content: [
{
tag: 'key',
attrs: { },
content: jidsRequiringFetch.map(
jid => ({
tag: 'user',
attrs: { jid, reason: 'identity' },
})
)
}
]
})
await parseAndInjectE2ESessions(result, authState)
return true
}
return false
}
const createParticipantNodes = async(jids: string[], bytes: Buffer) => { const assertSessions = async(jids: string[], force: boolean) => {
await assertSessions(jids, false) let jidsRequiringFetch: string[] = []
if(force) {
jidsRequiringFetch = jids
} else {
const addrs = jids.map(jid => jidToSignalProtocolAddress(jid).toString())
const sessions = await authState.keys.get('session', addrs)
for(const jid of jids) {
const signalId = jidToSignalProtocolAddress(jid).toString()
if(!sessions[signalId]) {
jidsRequiringFetch.push(jid)
}
}
}
if(authState.keys.isInTransaction()) { if(jidsRequiringFetch.length) {
await authState.keys.prefetch( logger.debug({ jidsRequiringFetch }, 'fetching sessions')
'session', const result = await query({
jids.map(jid => jidToSignalProtocolAddress(jid).toString()) tag: 'iq',
) attrs: {
} xmlns: 'encrypt',
type: 'get',
to: S_WHATSAPP_NET,
},
content: [
{
tag: 'key',
attrs: { },
content: jidsRequiringFetch.map(
jid => ({
tag: 'user',
attrs: { jid, reason: 'identity' },
})
)
}
]
})
await parseAndInjectE2ESessions(result, authState)
return true
}
const nodes = await Promise.all( return false
jids.map( }
async jid => {
const { type, ciphertext } = await encryptSignalProto(jid, bytes, authState)
const node: BinaryNode = {
tag: 'to',
attrs: { jid },
content: [{
tag: 'enc',
attrs: { v: '2', type },
content: ciphertext
}]
}
return node
}
)
)
return nodes
}
const relayMessage = async( const createParticipantNodes = async(jids: string[], bytes: Buffer) => {
jid: string, await assertSessions(jids, false)
message: proto.IMessage,
{ messageId: msgId, participant, additionalAttributes, cachedGroupMetadata }: MessageRelayOptions
) => {
const meId = authState.creds.me!.id
const { user, server } = jidDecode(jid) if(authState.keys.isInTransaction()) {
const isGroup = server === 'g.us' await authState.keys.prefetch(
msgId = msgId || generateMessageID() 'session',
jids.map(jid => jidToSignalProtocolAddress(jid).toString())
)
}
const encodedMsg = encodeWAMessage(message) const nodes = await Promise.all(
const participants: BinaryNode[] = [] jids.map(
async jid => {
const { type, ciphertext } = await encryptSignalProto(jid, bytes, authState)
const node: BinaryNode = {
tag: 'to',
attrs: { jid },
content: [{
tag: 'enc',
attrs: { v: '2', type },
content: ciphertext
}]
}
return node
}
)
)
return nodes
}
const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net') const relayMessage = async(
jid: string,
message: proto.IMessage,
{ messageId: msgId, participant, additionalAttributes, cachedGroupMetadata }: MessageRelayOptions
) => {
const meId = authState.creds.me!.id
const binaryNodeContent: BinaryNode[] = [] const { user, server } = jidDecode(jid)
const isGroup = server === 'g.us'
msgId = msgId || generateMessageID()
const devices: JidWithDevice[] = [] const encodedMsg = encodeWAMessage(message)
if(participant) { const participants: BinaryNode[] = []
const { user, device } = jidDecode(participant)
devices.push({ user, device })
}
await authState.keys.transaction( const destinationJid = jidEncode(user, isGroup ? 'g.us' : 's.whatsapp.net')
async() => {
if(isGroup) {
const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, meId, authState)
const [groupData, senderKeyMap] = await Promise.all([ const binaryNodeContent: BinaryNode[] = []
(async() => {
let groupData = cachedGroupMetadata ? await cachedGroupMetadata(jid) : undefined
if(!groupData) groupData = await groupMetadata(jid)
return groupData
})(),
(async() => {
const result = await authState.keys.get('sender-key-memory', [jid])
return result[jid] || { }
})()
])
if(!participant) { const devices: JidWithDevice[] = []
const participantsList = groupData.participants.map(p => p.id) if(participant) {
const additionalDevices = await getUSyncDevices(participantsList, false) const { user, device } = jidDecode(participant)
devices.push(...additionalDevices) devices.push({ user, device })
} }
const senderKeyJids: string[] = [] await authState.keys.transaction(
// ensure a connection is established with every device async() => {
for(const {user, device} of devices) { if(isGroup) {
const jid = jidEncode(user, 's.whatsapp.net', device) const { ciphertext, senderKeyDistributionMessageKey } = await encryptSenderKeyMsgSignalProto(destinationJid, encodedMsg, meId, authState)
if(!senderKeyMap[jid]) {
senderKeyJids.push(jid)
// store that this person has had the sender keys sent to them
senderKeyMap[jid] = true
}
}
// if there are some participants with whom the session has not been established
// if there are, we re-send the senderkey
if(senderKeyJids.length) {
logger.debug({ senderKeyJids }, 'sending new sender key')
const encSenderKeyMsg = encodeWAMessage({ const [groupData, senderKeyMap] = await Promise.all([
senderKeyDistributionMessage: { (async() => {
axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey, let groupData = cachedGroupMetadata ? await cachedGroupMetadata(jid) : undefined
groupId: destinationJid if(!groupData) {
} groupData = await groupMetadata(jid)
}) }
participants.push( return groupData
...(await createParticipantNodes(senderKeyJids, encSenderKeyMsg)) })(),
) (async() => {
} const result = await authState.keys.get('sender-key-memory', [jid])
return result[jid] || { }
})()
])
binaryNodeContent.push({ if(!participant) {
tag: 'enc', const participantsList = groupData.participants.map(p => p.id)
attrs: { v: '2', type: 'skmsg' }, const additionalDevices = await getUSyncDevices(participantsList, false)
content: ciphertext devices.push(...additionalDevices)
}) }
await authState.keys.set({ 'sender-key-memory': { [jid]: senderKeyMap } }) const senderKeyJids: string[] = []
} else { // ensure a connection is established with every device
const { user: meUser } = jidDecode(meId) for(const { user, device } of devices) {
const jid = jidEncode(user, 's.whatsapp.net', device)
if(!senderKeyMap[jid]) {
senderKeyJids.push(jid)
// store that this person has had the sender keys sent to them
senderKeyMap[jid] = true
}
}
const encodedMeMsg = encodeWAMessage({ // if there are some participants with whom the session has not been established
deviceSentMessage: { // if there are, we re-send the senderkey
destinationJid, if(senderKeyJids.length) {
message logger.debug({ senderKeyJids }, 'sending new sender key')
}
})
if(!participant) { const encSenderKeyMsg = encodeWAMessage({
devices.push({ user }) senderKeyDistributionMessage: {
devices.push({ user: meUser }) axolotlSenderKeyDistributionMessage: senderKeyDistributionMessageKey,
groupId: destinationJid
}
})
const additionalDevices = await getUSyncDevices([ meId, jid ], true) participants.push(
devices.push(...additionalDevices) ...(await createParticipantNodes(senderKeyJids, encSenderKeyMsg))
} )
}
const meJids: string[] = [] binaryNodeContent.push({
const otherJids: string[] = [] tag: 'enc',
for(const { user, device } of devices) { attrs: { v: '2', type: 'skmsg' },
const jid = jidEncode(user, 's.whatsapp.net', device) content: ciphertext
const isMe = user === meUser })
if(isMe) meJids.push(jid)
else otherJids.push(jid)
}
const [meNodes, otherNodes] = await Promise.all([ await authState.keys.set({ 'sender-key-memory': { [jid]: senderKeyMap } })
createParticipantNodes(meJids, encodedMeMsg), } else {
createParticipantNodes(otherJids, encodedMsg) const { user: meUser } = jidDecode(meId)
])
participants.push(...meNodes)
participants.push(...otherNodes)
}
if(participants.length) { const encodedMeMsg = encodeWAMessage({
binaryNodeContent.push({ deviceSentMessage: {
tag: 'participants', destinationJid,
attrs: { }, message
content: participants }
}) })
}
const stanza: BinaryNode = { if(!participant) {
tag: 'message', devices.push({ user })
attrs: { devices.push({ user: meUser })
id: msgId,
type: 'text',
to: destinationJid,
...(additionalAttributes || {})
},
content: binaryNodeContent
}
const shouldHaveIdentity = !!participants.find( const additionalDevices = await getUSyncDevices([ meId, jid ], true)
participant => (participant.content! as BinaryNode[]).find(n => n.attrs.type === 'pkmsg') devices.push(...additionalDevices)
) }
if(shouldHaveIdentity) { const meJids: string[] = []
(stanza.content as BinaryNode[]).push({ const otherJids: string[] = []
tag: 'device-identity', for(const { user, device } of devices) {
attrs: { }, const jid = jidEncode(user, 's.whatsapp.net', device)
content: proto.ADVSignedDeviceIdentity.encode(authState.creds.account).finish() const isMe = user === meUser
}) if(isMe) {
meJids.push(jid)
} else {
otherJids.push(jid)
}
}
logger.debug({ jid }, 'adding device identity') const [meNodes, otherNodes] = await Promise.all([
} createParticipantNodes(meJids, encodedMeMsg),
createParticipantNodes(otherJids, encodedMsg)
])
participants.push(...meNodes)
participants.push(...otherNodes)
}
logger.debug({ msgId }, `sending message to ${participants.length} devices`) if(participants.length) {
binaryNodeContent.push({
tag: 'participants',
attrs: { },
content: participants
})
}
await sendNode(stanza) const stanza: BinaryNode = {
} tag: 'message',
) attrs: {
id: msgId,
type: 'text',
to: destinationJid,
...(additionalAttributes || {})
},
content: binaryNodeContent
}
return msgId const shouldHaveIdentity = !!participants.find(
} participant => (participant.content! as BinaryNode[]).find(n => n.attrs.type === 'pkmsg')
)
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn) if(shouldHaveIdentity) {
(stanza.content as BinaryNode[]).push({
tag: 'device-identity',
attrs: { },
content: proto.ADVSignedDeviceIdentity.encode(authState.creds.account).finish()
})
logger.debug({ jid }, 'adding device identity')
}
logger.debug({ msgId }, `sending message to ${participants.length} devices`)
await sendNode(stanza)
}
)
return msgId
}
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn)
return { return {
...sock, ...sock,
assertSessions, assertSessions,
relayMessage, relayMessage,
sendReceipt, sendReceipt,
sendReadReceipt, sendReadReceipt,
refreshMediaConn, refreshMediaConn,
waUploadToServer, waUploadToServer,
fetchPrivacySettings, fetchPrivacySettings,
sendMessage: async( sendMessage: async(
jid: string, jid: string,
content: AnyMessageContent, content: AnyMessageContent,
options: MiscMessageGenerationOptions = { } options: MiscMessageGenerationOptions = { }
) => { ) => {
const userJid = authState.creds.me!.id const userJid = authState.creds.me!.id
if( if(
typeof content === 'object' && typeof content === 'object' &&
'disappearingMessagesInChat' in content && 'disappearingMessagesInChat' in content &&
@@ -442,9 +455,9 @@ export const makeMessagesSocket = (config: SocketConfig) => {
) { ) {
const { disappearingMessagesInChat } = content const { disappearingMessagesInChat } = content
const value = typeof disappearingMessagesInChat === 'boolean' ? const value = typeof disappearingMessagesInChat === 'boolean' ?
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) : (disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
disappearingMessagesInChat disappearingMessagesInChat
await groupToggleEphemeral(jid, value) await groupToggleEphemeral(jid, value)
} else { } else {
const fullMsg = await generateWAMessage( const fullMsg = await generateWAMessage(
jid, jid,
@@ -452,28 +465,29 @@ export const makeMessagesSocket = (config: SocketConfig) => {
{ {
logger, logger,
userJid, userJid,
// multi-device does not have this yet // multi-device does not have this yet
//getUrlInfo: generateUrlInfo, //getUrlInfo: generateUrlInfo,
upload: waUploadToServer, upload: waUploadToServer,
mediaCache: config.mediaCache, mediaCache: config.mediaCache,
...options, ...options,
} }
) )
const isDeleteMsg = 'delete' in content && !!content.delete const isDeleteMsg = 'delete' in content && !!content.delete
const additionalAttributes: BinaryNodeAttributes = { } const additionalAttributes: BinaryNodeAttributes = { }
// required for delete // required for delete
if(isDeleteMsg) { if(isDeleteMsg) {
additionalAttributes.edit = '7' additionalAttributes.edit = '7'
} }
await relayMessage(jid, fullMsg.message, { messageId: fullMsg.key.id!, additionalAttributes }) await relayMessage(jid, fullMsg.message, { messageId: fullMsg.key.id!, additionalAttributes })
if(config.emitOwnEvents) { if(config.emitOwnEvents) {
process.nextTick(() => { process.nextTick(() => {
ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' }) ev.emit('messages.upsert', { messages: [fullMsg], type: 'append' })
}) })
} }
return fullMsg return fullMsg
} }
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

View File

@@ -1,7 +1,7 @@
import { MediaType, DownloadableMessage } from '../Types'
import { downloadContentFromMessage } from '../Utils'
import { proto } from '../../WAProto'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { proto } from '../../WAProto'
import { DownloadableMessage, MediaType } from '../Types'
import { downloadContentFromMessage } from '../Utils'
jest.setTimeout(20_000) jest.setTimeout(20_000)
@@ -41,7 +41,7 @@ describe('Media Download Tests', () => {
const readPipe = await downloadContentFromMessage(message, type) const readPipe = await downloadContentFromMessage(message, type)
let buffer = Buffer.alloc(0) let buffer = Buffer.alloc(0)
for await(const read of readPipe) { for await (const read of readPipe) {
buffer = Buffer.concat([ buffer, read ]) buffer = Buffer.concat([ buffer, read ])
} }
@@ -61,7 +61,7 @@ describe('Media Download Tests', () => {
const readPipe = await downloadContentFromMessage(message, type, range) const readPipe = await downloadContentFromMessage(message, type, range)
let buffer = Buffer.alloc(0) let buffer = Buffer.alloc(0)
for await(const read of readPipe) { for await (const read of readPipe) {
buffer = Buffer.concat([ buffer, read ]) buffer = Buffer.concat([ buffer, read ])
} }

View File

@@ -1,5 +1,5 @@
import type { Contact } from "./Contact" import type { proto } from '../../WAProto'
import type { proto } from "../../WAProto" import type { Contact } from './Contact'
export type KeyPair = { public: Uint8Array, private: Uint8Array } export type KeyPair = { public: Uint8Array, private: Uint8Array }
export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number } export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number }

View File

@@ -1,4 +1,4 @@
import type { proto } from "../../WAProto" import type { proto } from '../../WAProto'
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused' export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'

View File

@@ -1,12 +1,10 @@
import type EventEmitter from "events" import type EventEmitter from 'events'
import { AuthenticationCreds } from './Auth' import { AuthenticationCreds } from './Auth'
import { Chat, PresenceData } from './Chat' import { Chat, PresenceData } from './Chat'
import { Contact } from './Contact' import { Contact } from './Contact'
import { ConnectionState } from './State'
import { GroupMetadata, ParticipantAction } from './GroupMetadata' import { GroupMetadata, ParticipantAction } from './GroupMetadata'
import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate, WAMessageKey } from './Message' import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
import { ConnectionState } from './State'
export type BaileysEventMap<T> = { export type BaileysEventMap<T> = {
/** connection state has been updated -- WS closed, opened, connecting etc. */ /** connection state has been updated -- WS closed, opened, connecting etc. */

View File

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

View File

@@ -1,6 +1,6 @@
import { CommonSocketConfig } from "./Socket" import { BinaryNode } from '../WABinary'
import { CommonBaileysEventEmitter } from "./Events" import { CommonBaileysEventEmitter } from './Events'
import { BinaryNode } from "../WABinary" import { CommonSocketConfig } from './Socket'
export interface LegacyAuthenticationCreds { export interface LegacyAuthenticationCreds {
clientID: string clientID: string

View File

@@ -1,10 +1,9 @@
import type { ReadStream } from "fs" import type NodeCache from 'node-cache'
import type { Logger } from "pino" import type { Logger } from 'pino'
import type { URL } from "url" import type { Readable } from 'stream'
import type NodeCache from "node-cache" import type { URL } from 'url'
import type { GroupMetadata } from "./GroupMetadata"
import type { Readable } from "stream"
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import type { GroupMetadata } from './GroupMetadata'
// export the WAMessage Prototypes // export the WAMessage Prototypes
export { proto as WAProto } export { proto as WAProto }

View File

@@ -1,9 +1,9 @@
import type { Agent } from "https" import type { Agent } from 'https'
import type { Logger } from "pino"
import type { URL } from "url"
import type NodeCache from 'node-cache' import type NodeCache from 'node-cache'
import { MediaConnInfo } from "./Message" import type { Logger } from 'pino'
import type { URL } from 'url'
import { MediaConnInfo } from './Message'
export type WAVersion = [number, number, number] export type WAVersion = [number, number, number]
export type WABrowserDescription = [string, string, string] export type WABrowserDescription = [string, string, string]

View File

@@ -1,4 +1,4 @@
import { Contact } from "./Contact" import { Contact } from './Contact'
export type WAConnectionState = 'open' | 'connecting' | 'close' export type WAConnectionState = 'open' | 'connecting' | 'close'

View File

@@ -9,9 +9,8 @@ export * from './Socket'
export * from './Events' export * from './Events'
import type NodeCache from 'node-cache' import type NodeCache from 'node-cache'
import { AuthenticationState } from './Auth'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { AuthenticationState } from './Auth'
import { CommonSocketConfig } from './Socket' import { CommonSocketConfig } from './Socket'
export type SocketConfig = CommonSocketConfig<AuthenticationState> & { export type SocketConfig = CommonSocketConfig<AuthenticationState> & {

View File

@@ -2,9 +2,9 @@ import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import type { Logger } from 'pino' import type { Logger } from 'pino'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import type { AuthenticationCreds, AuthenticationState, SignalDataTypeMap, SignalDataSet, SignalKeyStore, SignalKeyStoreWithTransaction } from "../Types" import type { AuthenticationCreds, AuthenticationState, SignalDataSet, SignalDataTypeMap, SignalKeyStore, SignalKeyStoreWithTransaction } from '../Types'
import { Curve, signedKeyPair } from './crypto' import { Curve, signedKeyPair } from './crypto'
import { generateRegistrationId, BufferJSON } from './generics' import { BufferJSON, generateRegistrationId } from './generics'
const KEY_MAP: { [T in keyof SignalDataTypeMap]: string } = { const KEY_MAP: { [T in keyof SignalDataTypeMap]: string } = {
'pre-key': 'preKeys', 'pre-key': 'preKeys',
@@ -46,6 +46,7 @@ export const addTransactionCapability = (state: SignalKeyStore, logger: Logger):
if(value) { if(value) {
dict[id] = value dict[id] = value
} }
return dict return dict
}, { } }, { }
) )
@@ -55,7 +56,7 @@ export const addTransactionCapability = (state: SignalKeyStore, logger: Logger):
}, },
set: data => { set: data => {
if(inTransaction) { if(inTransaction) {
logger.trace({ types: Object.keys(data) }, `caching in transaction`) logger.trace({ types: Object.keys(data) }, 'caching in transaction')
for(const key in data) { for(const key in data) {
transactionCache[key] = transactionCache[key] || { } transactionCache[key] = transactionCache[key] || { }
Object.assign(transactionCache[key], data[key]) Object.assign(transactionCache[key], data[key])
@@ -69,7 +70,7 @@ export const addTransactionCapability = (state: SignalKeyStore, logger: Logger):
}, },
isInTransaction: () => inTransaction, isInTransaction: () => inTransaction,
prefetch: (type, ids) => { prefetch: (type, ids) => {
logger.trace({ type, ids }, `prefetching`) logger.trace({ type, ids }, 'prefetching')
return prefetch(type, ids) return prefetch(type, ids)
}, },
transaction: async(work) => { transaction: async(work) => {
@@ -128,17 +129,17 @@ export const useSingleFileAuthState = (filename: string, logger?: Logger): { sta
) )
} }
if(existsSync(filename)) { if(existsSync(filename)) {
const result = JSON.parse( const result = JSON.parse(
readFileSync(filename, { encoding: 'utf-8' }), readFileSync(filename, { encoding: 'utf-8' }),
BufferJSON.reviver BufferJSON.reviver
) )
creds = result.creds creds = result.creds
keys = result.keys keys = result.keys
} else { } else {
creds = initAuthCreds() creds = initAuthCreds()
keys = { } keys = { }
} }
return { return {
state: { state: {
@@ -153,8 +154,10 @@ export const useSingleFileAuthState = (filename: string, logger?: Logger): { sta
if(type === 'app-state-sync-key') { if(type === 'app-state-sync-key') {
value = proto.AppStateSyncKeyData.fromObject(value) value = proto.AppStateSyncKeyData.fromObject(value)
} }
dict[id] = value dict[id] = value
} }
return dict return dict
}, { } }, { }
) )
@@ -165,6 +168,7 @@ export const useSingleFileAuthState = (filename: string, logger?: Logger): { sta
keys[key] = keys[key] || { } keys[key] = keys[key] || { }
Object.assign(keys[key], data[_key]) Object.assign(keys[key], data[_key])
} }
saveState() saveState()
} }
} }

View File

@@ -1,523 +1,541 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { aesDecrypt, hmacSign, aesEncrypt, hkdf } from "./crypto"
import { WAPatchCreate, ChatMutation, WAPatchName, LTHashState, ChatModification, LastMessageList } from "../Types"
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { LT_HASH_ANTI_TAMPERING } from './lt-hash' import { ChatModification, ChatMutation, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren } from '../WABinary' import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren } from '../WABinary'
import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto'
import { toNumber } from './generics' import { toNumber } from './generics'
import { LT_HASH_ANTI_TAMPERING } from './lt-hash'
import { downloadContentFromMessage, } from './messages-media' import { downloadContentFromMessage, } from './messages-media'
type FetchAppStateSyncKey = (keyId: string) => Promise<proto.IAppStateSyncKeyData> | proto.IAppStateSyncKeyData type FetchAppStateSyncKey = (keyId: string) => Promise<proto.IAppStateSyncKeyData> | proto.IAppStateSyncKeyData
const mutationKeys = (keydata: Uint8Array) => { const mutationKeys = (keydata: Uint8Array) => {
const expanded = hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' }) const expanded = hkdf(keydata, 160, { info: 'WhatsApp Mutation Keys' })
return { return {
indexKey: expanded.slice(0, 32), indexKey: expanded.slice(0, 32),
valueEncryptionKey: expanded.slice(32, 64), valueEncryptionKey: expanded.slice(32, 64),
valueMacKey: expanded.slice(64, 96), valueMacKey: expanded.slice(64, 96),
snapshotMacKey: expanded.slice(96, 128), snapshotMacKey: expanded.slice(96, 128),
patchMacKey: expanded.slice(128, 160) patchMacKey: expanded.slice(128, 160)
} }
} }
const generateMac = (operation: proto.SyncdMutation.SyncdMutationSyncdOperation, data: Buffer, keyId: Uint8Array | string, key: Buffer) => { const generateMac = (operation: proto.SyncdMutation.SyncdMutationSyncdOperation, data: Buffer, keyId: Uint8Array | string, key: Buffer) => {
const getKeyData = () => { const getKeyData = () => {
let r: number let r: number
switch (operation) { switch (operation) {
case proto.SyncdMutation.SyncdMutationSyncdOperation.SET: case proto.SyncdMutation.SyncdMutationSyncdOperation.SET:
r = 0x01 r = 0x01
break break
case proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE: case proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE:
r = 0x02 r = 0x02
break 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 const buff = Buffer.from([r])
last.set([ keyData.length ], last.length-1) return Buffer.concat([ buff, Buffer.from(keyId as any, 'base64') ])
}
const total = Buffer.concat([ keyData, data, last ]) const keyData = getKeyData()
const hmac = hmacSign(total, key, 'sha512')
return hmac.slice(0, 32) 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 to64BitNetworkOrder = (e: number) => {
const t = new ArrayBuffer(8) const t = new ArrayBuffer(8)
new DataView(t).setUint32(4, e, !1) new DataView(t).setUint32(4, e, !1)
return Buffer.from(t) return Buffer.from(t)
} }
type Mac = { indexMac: Uint8Array, valueMac: Uint8Array, operation: proto.SyncdMutation.SyncdMutationSyncdOperation } type Mac = { indexMac: Uint8Array, valueMac: Uint8Array, operation: proto.SyncdMutation.SyncdMutationSyncdOperation }
const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => { const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' | 'indexValueMap'>) => {
indexValueMap = { ...indexValueMap } indexValueMap = { ...indexValueMap }
const addBuffs: ArrayBuffer[] = [] const addBuffs: ArrayBuffer[] = []
const subBuffs: ArrayBuffer[] = [] const subBuffs: ArrayBuffer[] = []
return { return {
mix: ({ indexMac, valueMac, operation }: Mac) => { mix: ({ indexMac, valueMac, operation }: Mac) => {
const indexMacBase64 = Buffer.from(indexMac).toString('base64') const indexMacBase64 = Buffer.from(indexMac).toString('base64')
const prevOp = indexValueMap[indexMacBase64] const prevOp = indexValueMap[indexMacBase64]
if(operation === proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE) { if(operation === proto.SyncdMutation.SyncdMutationSyncdOperation.REMOVE) {
if(!prevOp) { if(!prevOp) {
throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } }) throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } })
} }
// remove from index value mac, since this mutation is erased
delete indexValueMap[indexMacBase64]
} else {
addBuffs.push(new Uint8Array(valueMac).buffer)
// add this index into the history map
indexValueMap[indexMacBase64] = { valueMac }
}
if(prevOp) {
subBuffs.push(new Uint8Array(prevOp.valueMac).buffer)
}
},
finish: () => {
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(hash).buffer, addBuffs, subBuffs)
const buffer = Buffer.from(result)
return { // remove from index value mac, since this mutation is erased
hash: buffer, delete indexValueMap[indexMacBase64]
indexValueMap } else {
} addBuffs.push(new Uint8Array(valueMac).buffer)
} // add this index into the history map
} indexValueMap[indexMacBase64] = { valueMac }
}
if(prevOp) {
subBuffs.push(new Uint8Array(prevOp.valueMac).buffer)
}
},
finish: () => {
const result = LT_HASH_ANTI_TAMPERING.subtractThenAdd(new Uint8Array(hash).buffer, addBuffs, subBuffs)
const buffer = Buffer.from(result)
return {
hash: buffer,
indexValueMap
}
}
}
} }
const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Buffer) => { const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Buffer) => {
const total = Buffer.concat([ const total = Buffer.concat([
lthash, lthash,
to64BitNetworkOrder(version), to64BitNetworkOrder(version),
Buffer.from(name, 'utf-8') Buffer.from(name, 'utf-8')
]) ])
return hmacSign(total, key, 'sha256') return hmacSign(total, key, 'sha256')
} }
const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: WAPatchName, key: Buffer) => { const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: WAPatchName, key: Buffer) => {
const total = Buffer.concat([ const total = Buffer.concat([
snapshotMac, snapshotMac,
...valueMacs, ...valueMacs,
to64BitNetworkOrder(version), to64BitNetworkOrder(version),
Buffer.from(type, 'utf-8') Buffer.from(type, 'utf-8')
]) ])
return hmacSign(total, key) return hmacSign(total, key)
} }
export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} }) export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} })
export const encodeSyncdPatch = async( export const encodeSyncdPatch = async(
{ type, index, syncAction, apiVersion, operation }: WAPatchCreate, { type, index, syncAction, apiVersion, operation }: WAPatchCreate,
myAppStateKeyId: string, myAppStateKeyId: string,
state: LTHashState, state: LTHashState,
getAppStateSyncKey: FetchAppStateSyncKey getAppStateSyncKey: FetchAppStateSyncKey
) => { ) => {
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
if(!key) { if(!key) {
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 }) throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 })
} }
const encKeyId = Buffer.from(myAppStateKeyId, 'base64')
state = { ...state, indexValueMap: { ...state.indexValueMap } } const encKeyId = Buffer.from(myAppStateKeyId, 'base64')
const indexBuffer = Buffer.from(JSON.stringify(index)) state = { ...state, indexValueMap: { ...state.indexValueMap } }
const dataProto = proto.SyncActionData.fromObject({
index: indexBuffer,
value: syncAction,
padding: new Uint8Array(0),
version: apiVersion
})
const encoded = proto.SyncActionData.encode(dataProto).finish()
const keyValue = mutationKeys(key!.keyData!) const indexBuffer = Buffer.from(JSON.stringify(index))
const dataProto = proto.SyncActionData.fromObject({
index: indexBuffer,
value: syncAction,
padding: new Uint8Array(0),
version: apiVersion
})
const encoded = proto.SyncActionData.encode(dataProto).finish()
const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey) const keyValue = mutationKeys(key!.keyData!)
const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey)
const indexMac = hmacSign(indexBuffer, keyValue.indexKey)
// update LT hash const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey)
const generator = makeLtHashGenerator(state) const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey)
generator.mix({ indexMac, valueMac, operation }) const indexMac = hmacSign(indexBuffer, keyValue.indexKey)
Object.assign(state, generator.finish())
state.version += 1 // update LT hash
const generator = makeLtHashGenerator(state)
generator.mix({ indexMac, valueMac, operation })
Object.assign(state, generator.finish())
const snapshotMac = generateSnapshotMac(state.hash, state.version, type, keyValue.snapshotMacKey) state.version += 1
const patch: proto.ISyncdPatch = { const snapshotMac = generateSnapshotMac(state.hash, state.version, type, keyValue.snapshotMacKey)
patchMac: generatePatchMac(snapshotMac, [valueMac], state.version, type, keyValue.patchMacKey),
snapshotMac: snapshotMac,
keyId: { id: encKeyId },
mutations: [
{
operation: operation,
record: {
index: {
blob: indexMac
},
value: {
blob: Buffer.concat([ encValue, valueMac ])
},
keyId: { id: encKeyId }
}
}
]
}
const base64Index = indexMac.toString('base64') const patch: proto.ISyncdPatch = {
state.indexValueMap[base64Index] = { valueMac } patchMac: generatePatchMac(snapshotMac, [valueMac], state.version, type, keyValue.patchMacKey),
snapshotMac: snapshotMac,
keyId: { id: encKeyId },
mutations: [
{
operation: operation,
record: {
index: {
blob: indexMac
},
value: {
blob: Buffer.concat([ encValue, valueMac ])
},
keyId: { id: encKeyId }
}
}
]
}
return { patch, state } const base64Index = indexMac.toString('base64')
state.indexValueMap[base64Index] = { valueMac }
return { patch, state }
} }
export const decodeSyncdMutations = async( export const decodeSyncdMutations = async(
msgMutations: (proto.ISyncdMutation | proto.ISyncdRecord)[], msgMutations: (proto.ISyncdMutation | proto.ISyncdRecord)[],
initialState: LTHashState, initialState: LTHashState,
getAppStateSyncKey: FetchAppStateSyncKey, getAppStateSyncKey: FetchAppStateSyncKey,
validateMacs: boolean validateMacs: boolean
) => { ) => {
const keyCache: { [_: string]: ReturnType<typeof mutationKeys> } = { } const keyCache: { [_: string]: ReturnType<typeof mutationKeys> } = { }
const getKey = async(keyId: Uint8Array) => { const getKey = async(keyId: Uint8Array) => {
const base64Key = Buffer.from(keyId!).toString('base64') const base64Key = Buffer.from(keyId!).toString('base64')
let key = keyCache[base64Key] let key = keyCache[base64Key]
if(!key) { if(!key) {
const keyEnc = await getAppStateSyncKey(base64Key) const keyEnc = await getAppStateSyncKey(base64Key)
if(!keyEnc) { if(!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 404, data: { msgMutations } }) throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 404, data: { msgMutations } })
} }
const result = mutationKeys(keyEnc.keyData!)
keyCache[base64Key] = result
key = result
}
return key
}
const ltGenerator = makeLtHashGenerator(initialState) const result = mutationKeys(keyEnc.keyData!)
keyCache[base64Key] = result
key = result
}
const mutations: ChatMutation[] = [] return key
// indexKey used to HMAC sign record.index.blob }
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
for(const msgMutation of msgMutations!) {
// if it's a syncdmutation, get the operation property
// otherwise, if it's only a record -- it'll be a SET mutation
const operation = 'operation' in msgMutation ? msgMutation.operation : proto.SyncdMutation.SyncdMutationSyncdOperation.SET
const record = ('record' in msgMutation && !!msgMutation.record) ? msgMutation.record : msgMutation as proto.ISyncdRecord
const key = await getKey(record.keyId!.id!) const ltGenerator = makeLtHashGenerator(initialState)
const content = Buffer.from(record.value!.blob!)
const encContent = content.slice(0, -32)
const ogValueMac = content.slice(-32)
if(validateMacs) {
const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey)
if(Buffer.compare(contentHmac, ogValueMac) !== 0) {
throw new Boom('HMAC content verification failed')
}
}
const result = aesDecrypt(encContent, key.valueEncryptionKey) const mutations: ChatMutation[] = []
const syncAction = proto.SyncActionData.decode(result) // indexKey used to HMAC sign record.index.blob
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
for(const msgMutation of msgMutations!) {
// if it's a syncdmutation, get the operation property
// otherwise, if it's only a record -- it'll be a SET mutation
const operation = 'operation' in msgMutation ? msgMutation.operation : proto.SyncdMutation.SyncdMutationSyncdOperation.SET
const record = ('record' in msgMutation && !!msgMutation.record) ? msgMutation.record : msgMutation as proto.ISyncdRecord
if(validateMacs) { const key = await getKey(record.keyId!.id!)
const hmac = hmacSign(syncAction.index, key.indexKey) const content = Buffer.from(record.value!.blob!)
if(Buffer.compare(hmac, record.index!.blob) !== 0) { const encContent = content.slice(0, -32)
throw new Boom('HMAC index verification failed') const ogValueMac = content.slice(-32)
} if(validateMacs) {
} const contentHmac = generateMac(operation, encContent, record.keyId!.id!, key.valueMacKey)
if(Buffer.compare(contentHmac, ogValueMac) !== 0) {
throw new Boom('HMAC content verification failed')
}
}
const indexStr = Buffer.from(syncAction.index).toString() const result = aesDecrypt(encContent, key.valueEncryptionKey)
mutations.push({ const syncAction = proto.SyncActionData.decode(result)
syncAction,
index: JSON.parse(indexStr),
})
ltGenerator.mix({
indexMac: record.index!.blob!,
valueMac: ogValueMac,
operation: operation
})
}
return { mutations, ...ltGenerator.finish() } if(validateMacs) {
const hmac = hmacSign(syncAction.index, key.indexKey)
if(Buffer.compare(hmac, record.index!.blob) !== 0) {
throw new Boom('HMAC index verification failed')
}
}
const indexStr = Buffer.from(syncAction.index).toString()
mutations.push({
syncAction,
index: JSON.parse(indexStr),
})
ltGenerator.mix({
indexMac: record.index!.blob!,
valueMac: ogValueMac,
operation: operation
})
}
return { mutations, ...ltGenerator.finish() }
} }
export const decodeSyncdPatch = async( export const decodeSyncdPatch = async(
msg: proto.ISyncdPatch, msg: proto.ISyncdPatch,
name: WAPatchName, name: WAPatchName,
initialState: LTHashState, initialState: LTHashState,
getAppStateSyncKey: FetchAppStateSyncKey, getAppStateSyncKey: FetchAppStateSyncKey,
validateMacs: boolean validateMacs: boolean
) => { ) => {
if(validateMacs) { if(validateMacs) {
const base64Key = Buffer.from(msg.keyId!.id).toString('base64') const base64Key = Buffer.from(msg.keyId!.id).toString('base64')
const mainKeyObj = await getAppStateSyncKey(base64Key) const mainKeyObj = await getAppStateSyncKey(base64Key)
const mainKey = mutationKeys(mainKeyObj.keyData!) const mainKey = mutationKeys(mainKeyObj.keyData!)
const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32)) const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32))
const patchMac = generatePatchMac(msg.snapshotMac, mutationmacs, toNumber(msg.version!.version), name, mainKey.patchMacKey) const patchMac = generatePatchMac(msg.snapshotMac, mutationmacs, toNumber(msg.version!.version), name, mainKey.patchMacKey)
if(Buffer.compare(patchMac, msg.patchMac) !== 0) { if(Buffer.compare(patchMac, msg.patchMac) !== 0) {
throw new Boom('Invalid patch mac') throw new Boom('Invalid patch mac')
} }
} }
const result = await decodeSyncdMutations(msg!.mutations!, initialState, getAppStateSyncKey, validateMacs) const result = await decodeSyncdMutations(msg!.mutations!, initialState, getAppStateSyncKey, validateMacs)
return result return result
} }
export const extractSyncdPatches = async(result: BinaryNode) => { export const extractSyncdPatches = async(result: BinaryNode) => {
const syncNode = getBinaryNodeChild(result, 'sync') const syncNode = getBinaryNodeChild(result, 'sync')
const collectionNodes = getBinaryNodeChildren(syncNode, 'collection') const collectionNodes = getBinaryNodeChildren(syncNode, 'collection')
const final = { } as { [T in WAPatchName]: { patches: proto.ISyncdPatch[], hasMorePatches: boolean, snapshot?: proto.ISyncdSnapshot } } const final = { } as { [T in WAPatchName]: { patches: proto.ISyncdPatch[], hasMorePatches: boolean, snapshot?: proto.ISyncdSnapshot } }
await Promise.all( await Promise.all(
collectionNodes.map( collectionNodes.map(
async collectionNode => { async collectionNode => {
const patchesNode = getBinaryNodeChild(collectionNode, 'patches') const patchesNode = getBinaryNodeChild(collectionNode, 'patches')
const patches = getBinaryNodeChildren(patchesNode || collectionNode, 'patch') const patches = getBinaryNodeChildren(patchesNode || collectionNode, 'patch')
const snapshotNode = getBinaryNodeChild(collectionNode, 'snapshot') const snapshotNode = getBinaryNodeChild(collectionNode, 'snapshot')
const syncds: proto.ISyncdPatch[] = [] const syncds: proto.ISyncdPatch[] = []
const name = collectionNode.attrs.name as WAPatchName const name = collectionNode.attrs.name as WAPatchName
const hasMorePatches = collectionNode.attrs.has_more_patches == 'true' const hasMorePatches = collectionNode.attrs.has_more_patches === 'true'
let snapshot: proto.ISyncdSnapshot | undefined = undefined let snapshot: proto.ISyncdSnapshot | undefined = undefined
if(snapshotNode && !!snapshotNode.content) { if(snapshotNode && !!snapshotNode.content) {
if(!Buffer.isBuffer(snapshotNode)) { if(!Buffer.isBuffer(snapshotNode)) {
snapshotNode.content = Buffer.from(Object.values(snapshotNode.content)) snapshotNode.content = Buffer.from(Object.values(snapshotNode.content))
} }
const blobRef = proto.ExternalBlobReference.decode(
const blobRef = proto.ExternalBlobReference.decode(
snapshotNode.content! as Buffer snapshotNode.content! as Buffer
) )
const data = await downloadExternalBlob(blobRef) const data = await downloadExternalBlob(blobRef)
snapshot = proto.SyncdSnapshot.decode(data) snapshot = proto.SyncdSnapshot.decode(data)
} }
for(let { content } of patches) { for(let { content } of patches) {
if(content) { if(content) {
if(!Buffer.isBuffer(content)) { if(!Buffer.isBuffer(content)) {
content = Buffer.from(Object.values(content)) content = Buffer.from(Object.values(content))
} }
const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
if(!syncd.version) {
syncd.version = { version: +collectionNode.attrs.version+1 }
}
syncds.push(syncd)
}
}
final[name] = { patches: syncds, hasMorePatches, snapshot } const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
} if(!syncd.version) {
) syncd.version = { version: +collectionNode.attrs.version+1 }
) }
return final syncds.push(syncd)
}
}
final[name] = { patches: syncds, hasMorePatches, snapshot }
}
)
)
return final
} }
export const downloadExternalBlob = async(blob: proto.IExternalBlobReference) => { export const downloadExternalBlob = async(blob: proto.IExternalBlobReference) => {
const stream = await downloadContentFromMessage(blob, 'md-app-state') const stream = await downloadContentFromMessage(blob, 'md-app-state')
let buffer = Buffer.from([]) let buffer = Buffer.from([])
for await(const chunk of stream) { for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk]) buffer = Buffer.concat([buffer, chunk])
} }
return buffer
return buffer
} }
export const downloadExternalPatch = async(blob: proto.IExternalBlobReference) => { export const downloadExternalPatch = async(blob: proto.IExternalBlobReference) => {
const buffer = await downloadExternalBlob(blob) const buffer = await downloadExternalBlob(blob)
const syncData = proto.SyncdMutations.decode(buffer) const syncData = proto.SyncdMutations.decode(buffer)
return syncData return syncData
} }
export const decodeSyncdSnapshot = async( export const decodeSyncdSnapshot = async(
name: WAPatchName, name: WAPatchName,
snapshot: proto.ISyncdSnapshot, snapshot: proto.ISyncdSnapshot,
getAppStateSyncKey: FetchAppStateSyncKey, getAppStateSyncKey: FetchAppStateSyncKey,
minimumVersionNumber: number | undefined, minimumVersionNumber: number | undefined,
validateMacs: boolean = true validateMacs: boolean = true
) => { ) => {
const newState = newLTHashState() const newState = newLTHashState()
newState.version = toNumber(snapshot.version!.version!) newState.version = toNumber(snapshot.version!.version!)
let { hash, indexValueMap, mutations } = await decodeSyncdMutations(snapshot.records!, newState, getAppStateSyncKey, validateMacs) const { hash, indexValueMap, mutations } = await decodeSyncdMutations(snapshot.records!, newState, getAppStateSyncKey, validateMacs)
newState.hash = hash newState.hash = hash
newState.indexValueMap = indexValueMap newState.indexValueMap = indexValueMap
if(validateMacs) { if(validateMacs) {
const base64Key = Buffer.from(snapshot.keyId!.id!).toString('base64') const base64Key = Buffer.from(snapshot.keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key) const keyEnc = await getAppStateSyncKey(base64Key)
if(!keyEnc) { if(!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500 }) throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 500 })
} }
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if(Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`, { statusCode: 500 })
}
}
const areMutationsRequired = typeof minimumVersionNumber === 'undefined' || newState.version > minimumVersionNumber const result = mutationKeys(keyEnc.keyData!)
if(!areMutationsRequired) { const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
mutations = [] if(Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
} throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`, { statusCode: 500 })
}
}
return { const areMutationsRequired = typeof minimumVersionNumber === 'undefined' || newState.version > minimumVersionNumber
state: newState, if(!areMutationsRequired) {
mutations // clear array
} mutations.splice(0, mutations.length)
}
return {
state: newState,
mutations
}
} }
export const decodePatches = async( export const decodePatches = async(
name: WAPatchName, name: WAPatchName,
syncds: proto.ISyncdPatch[], syncds: proto.ISyncdPatch[],
initial: LTHashState, initial: LTHashState,
getAppStateSyncKey: FetchAppStateSyncKey, getAppStateSyncKey: FetchAppStateSyncKey,
minimumVersionNumber?: number, minimumVersionNumber?: number,
validateMacs: boolean = true validateMacs: boolean = true
) => { ) => {
const successfulMutations: ChatMutation[] = [] const successfulMutations: ChatMutation[] = []
const newState: LTHashState = { const newState: LTHashState = {
...initial, ...initial,
indexValueMap: { ...initial.indexValueMap } indexValueMap: { ...initial.indexValueMap }
} }
for(const syncd of syncds) { for(const syncd of syncds) {
const { version, keyId, snapshotMac } = syncd const { version, keyId, snapshotMac } = syncd
if(syncd.externalMutations) { if(syncd.externalMutations) {
const ref = await downloadExternalPatch(syncd.externalMutations) const ref = await downloadExternalPatch(syncd.externalMutations)
syncd.mutations.push(...ref.mutations) syncd.mutations.push(...ref.mutations)
} }
const patchVersion = toNumber(version.version!) const patchVersion = toNumber(version.version!)
newState.version = patchVersion newState.version = patchVersion
const decodeResult = await decodeSyncdPatch(syncd, name, newState, getAppStateSyncKey, validateMacs) const decodeResult = await decodeSyncdPatch(syncd, name, newState, getAppStateSyncKey, validateMacs)
newState.hash = decodeResult.hash newState.hash = decodeResult.hash
newState.indexValueMap = decodeResult.indexValueMap newState.indexValueMap = decodeResult.indexValueMap
if(typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber) { if(typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber) {
successfulMutations.push(...decodeResult.mutations) successfulMutations.push(...decodeResult.mutations)
} }
if(validateMacs) { if(validateMacs) {
const base64Key = Buffer.from(keyId!.id!).toString('base64') const base64Key = Buffer.from(keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key) const keyEnc = await getAppStateSyncKey(base64Key)
if(!keyEnc) { if(!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`) throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
} }
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey) const result = mutationKeys(keyEnc.keyData!)
if(Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) { const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`) if(Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) {
} throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`)
} }
} }
return { }
newMutations: successfulMutations,
state: newState return {
} newMutations: successfulMutations,
state: newState
}
} }
export const chatModificationToAppPatch = ( export const chatModificationToAppPatch = (
mod: ChatModification, mod: ChatModification,
jid: string jid: string
) => { ) => {
const OP = proto.SyncdMutation.SyncdMutationSyncdOperation const OP = proto.SyncdMutation.SyncdMutationSyncdOperation
const getMessageRange = (lastMessages: LastMessageList) => { const getMessageRange = (lastMessages: LastMessageList) => {
if(!lastMessages?.length) { if(!lastMessages?.length) {
throw new Boom('Expected last message to be not from me', { statusCode: 400 }) throw new Boom('Expected last message to be not from me', { statusCode: 400 })
} }
const lastMsg = lastMessages[lastMessages.length-1]
if(lastMsg.key.fromMe) {
throw new Boom('Expected last message in array to be not from me', { statusCode: 400 })
}
const messageRange: proto.ISyncActionMessageRange = {
lastMessageTimestamp: lastMsg?.messageTimestamp,
messages: lastMessages
}
return messageRange
}
let patch: WAPatchCreate
if('mute' in mod) {
patch = {
syncAction: {
muteAction: {
muted: !!mod.mute,
muteEndTimestamp: mod.mute || undefined
}
},
index: ['mute', jid],
type: 'regular_high',
apiVersion: 2,
operation: OP.SET
}
} else if('archive' in mod) {
patch = {
syncAction: {
archiveChatAction: {
archived: !!mod.archive,
messageRange: getMessageRange(mod.lastMessages)
}
},
index: ['archive', jid],
type: 'regular_low',
apiVersion: 3,
operation: OP.SET
}
} else if('markRead' in mod) {
patch = {
syncAction: {
markChatAsReadAction: {
read: mod.markRead,
messageRange: getMessageRange(mod.lastMessages)
}
},
index: ['markChatAsRead', jid],
type: 'regular_low',
apiVersion: 3,
operation: OP.SET
}
} else if('clear' in mod) {
if(mod.clear === 'all') {
throw new Boom('not supported')
} else {
const key = mod.clear.messages[0]
patch = {
syncAction: {
deleteMessageForMeAction: {
deleteMedia: false
}
},
index: ['deleteMessageForMe', jid, key.id, key.fromMe ? '1' : '0', '0'],
type: 'regular_high',
apiVersion: 3,
operation: OP.SET
}
}
} else if('pin' in mod) {
patch = {
syncAction: {
pinAction: {
pinned: !!mod.pin
}
},
index: ['pin_v1', jid],
type: 'regular_low',
apiVersion: 5,
operation: OP.SET
}
} else {
throw new Boom('not supported')
}
patch.syncAction.timestamp = Date.now() const lastMsg = lastMessages[lastMessages.length-1]
if(lastMsg.key.fromMe) {
throw new Boom('Expected last message in array to be not from me', { statusCode: 400 })
}
return patch const messageRange: proto.ISyncActionMessageRange = {
lastMessageTimestamp: lastMsg?.messageTimestamp,
messages: lastMessages
}
return messageRange
}
let patch: WAPatchCreate
if('mute' in mod) {
patch = {
syncAction: {
muteAction: {
muted: !!mod.mute,
muteEndTimestamp: mod.mute || undefined
}
},
index: ['mute', jid],
type: 'regular_high',
apiVersion: 2,
operation: OP.SET
}
} else if('archive' in mod) {
patch = {
syncAction: {
archiveChatAction: {
archived: !!mod.archive,
messageRange: getMessageRange(mod.lastMessages)
}
},
index: ['archive', jid],
type: 'regular_low',
apiVersion: 3,
operation: OP.SET
}
} else if('markRead' in mod) {
patch = {
syncAction: {
markChatAsReadAction: {
read: mod.markRead,
messageRange: getMessageRange(mod.lastMessages)
}
},
index: ['markChatAsRead', jid],
type: 'regular_low',
apiVersion: 3,
operation: OP.SET
}
} else if('clear' in mod) {
if(mod.clear === 'all') {
throw new Boom('not supported')
} else {
const key = mod.clear.messages[0]
patch = {
syncAction: {
deleteMessageForMeAction: {
deleteMedia: false
}
},
index: ['deleteMessageForMe', jid, key.id, key.fromMe ? '1' : '0', '0'],
type: 'regular_high',
apiVersion: 3,
operation: OP.SET
}
}
} else if('pin' in mod) {
patch = {
syncAction: {
pinAction: {
pinned: !!mod.pin
}
},
index: ['pin_v1', jid],
type: 'regular_low',
apiVersion: 5,
operation: OP.SET
}
} else {
throw new Boom('not supported')
}
patch.syncAction.timestamp = Date.now()
return patch
} }

View File

@@ -1,5 +1,5 @@
import * as curveJs from 'curve25519-js'
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto' import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
import * as curveJs from 'curve25519-js'
import { KeyPair } from '../Types' import { KeyPair } from '../Types'
export const Curve = { export const Curve = {
@@ -23,70 +23,77 @@ export const Curve = {
} }
export const signedKeyPair = (keyPair: KeyPair, keyId: number) => { export const signedKeyPair = (keyPair: KeyPair, keyId: number) => {
const signKeys = Curve.generateKeyPair() const signKeys = Curve.generateKeyPair()
const pubKey = new Uint8Array(33) const pubKey = new Uint8Array(33)
pubKey.set([5], 0) pubKey.set([5], 0)
pubKey.set(signKeys.public, 1) pubKey.set(signKeys.public, 1)
const signature = Curve.sign(keyPair.private, pubKey) const signature = Curve.sign(keyPair.private, pubKey)
return { keyPair: signKeys, signature, keyId } return { keyPair: signKeys, signature, keyId }
} }
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ /** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
export function aesDecrypt(buffer: Buffer, key: Buffer) { export function aesDecrypt(buffer: Buffer, key: Buffer) {
return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16))
} }
/** decrypt AES 256 CBC */ /** decrypt AES 256 CBC */
export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
const aes = createDecipheriv('aes-256-cbc', key, IV) const aes = createDecipheriv('aes-256-cbc', key, IV)
return Buffer.concat([aes.update(buffer), aes.final()]) return Buffer.concat([aes.update(buffer), aes.final()])
} }
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer // encrypt AES 256 CBC; where a random IV is prefixed to the buffer
export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) { export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) {
const IV = randomBytes(16) const IV = randomBytes(16)
const aes = createCipheriv('aes-256-cbc', key, IV) const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
} }
// encrypt AES 256 CBC with a given IV // encrypt AES 256 CBC with a given IV
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
const aes = createCipheriv('aes-256-cbc', key, IV) const aes = createCipheriv('aes-256-cbc', key, IV)
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
} }
// sign HMAC using SHA 256 // sign HMAC using SHA 256
export function hmacSign(buffer: Buffer | Uint8Array, key: Buffer | Uint8Array, variant: 'sha256' | 'sha512' = 'sha256') { export function hmacSign(buffer: Buffer | Uint8Array, key: Buffer | Uint8Array, variant: 'sha256' | 'sha512' = 'sha256') {
return createHmac(variant, key).update(buffer).digest() return createHmac(variant, key).update(buffer).digest()
} }
export function sha256(buffer: Buffer) { export function sha256(buffer: Buffer) {
return createHash('sha256').update(buffer).digest() return createHash('sha256').update(buffer).digest()
} }
// HKDF key expansion // HKDF key expansion
// from: https://github.com/benadida/node-hkdf // from: https://github.com/benadida/node-hkdf
export function hkdf(buffer: Uint8Array, expandedLength: number, { info, salt }: { salt?: Buffer, info?: string }) { export function hkdf(buffer: Uint8Array, expandedLength: number, { info, salt }: { salt?: Buffer, info?: string }) {
const hashAlg = 'sha256' const hashAlg = 'sha256'
const hashLength = 32 const hashLength = 32
salt = salt || Buffer.alloc(hashLength) salt = salt || Buffer.alloc(hashLength)
// now we compute the PRK // now we compute the PRK
const prk = createHmac(hashAlg, salt).update(buffer).digest() const prk = createHmac(hashAlg, salt).update(buffer).digest()
let prev = Buffer.from([]) let prev = Buffer.from([])
const buffers = [] const buffers = []
const num_blocks = Math.ceil(expandedLength / hashLength) const num_blocks = Math.ceil(expandedLength / hashLength)
const infoBuff = Buffer.from(info || []) const infoBuff = Buffer.from(info || [])
for (var i=0; i<num_blocks; i++) { for(var i=0; i<num_blocks; i++) {
const hmac = createHmac(hashAlg, prk) const hmac = createHmac(hashAlg, prk)
// XXX is there a more optimal way to build up buffers? // XXX is there a more optimal way to build up buffers?
const input = Buffer.concat([ const input = Buffer.concat([
prev, prev,
infoBuff, infoBuff,
Buffer.from(String.fromCharCode(i + 1)) Buffer.from(String.fromCharCode(i + 1))
]); ])
hmac.update(input) hmac.update(input)
prev = hmac.digest() prev = hmac.digest()
buffers.push(prev) buffers.push(prev)
} }
return Buffer.concat(buffers, expandedLength)
return Buffer.concat(buffers, expandedLength)
} }

View File

@@ -1,114 +1,128 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { unpadRandomMax16 } from "./generics"
import { WAMessageKey, AuthenticationState } from '../Types'
import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser, jidNormalizedUser } from '../WABinary'
import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { AuthenticationState, WAMessageKey } from '../Types'
import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary'
import { unpadRandomMax16 } from './generics'
import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal'
type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status' type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status'
export const decodeMessageStanza = async(stanza: BinaryNode, auth: AuthenticationState) => { export const decodeMessageStanza = async(stanza: BinaryNode, auth: AuthenticationState) => {
//const deviceIdentity = (stanza.content as BinaryNodeM[])?.find(m => m.tag === 'device-identity') //const deviceIdentity = (stanza.content as BinaryNodeM[])?.find(m => m.tag === 'device-identity')
//const deviceIdentityBytes = deviceIdentity ? deviceIdentity.content as Buffer : undefined //const deviceIdentityBytes = deviceIdentity ? deviceIdentity.content as Buffer : undefined
let msgType: MessageType let msgType: MessageType
let chatId: string let chatId: string
let author: string let author: string
const msgId: string = stanza.attrs.id const msgId: string = stanza.attrs.id
const from: string = stanza.attrs.from const from: string = stanza.attrs.from
const participant: string | undefined = stanza.attrs.participant const participant: string | undefined = stanza.attrs.participant
const recipient: string | undefined = stanza.attrs.recipient const recipient: string | undefined = stanza.attrs.recipient
const isMe = (jid: string) => areJidsSameUser(jid, auth.creds.me!.id) const isMe = (jid: string) => areJidsSameUser(jid, auth.creds.me!.id)
if(isJidUser(from)) { if(isJidUser(from)) {
if(recipient) { if(recipient) {
if(!isMe(from)) { if(!isMe(from)) {
throw new Boom('') throw new Boom('')
} }
chatId = recipient
} else {
chatId = from
}
msgType = 'chat'
author = from
} else if(isJidGroup(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
msgType = 'group'
author = participant
chatId = from
} else if(isJidBroadcast(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
const isParticipantMe = isMe(participant)
if(isJidStatusBroadcast(from)) {
msgType = isParticipantMe ? 'direct_peer_status' : 'other_status'
} else {
msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast'
}
chatId = from
author = participant
}
const sender = msgType === 'chat' ? author : chatId chatId = recipient
} else {
chatId = from
}
const fromMe = isMe(stanza.attrs.participant || stanza.attrs.from) msgType = 'chat'
const pushname = stanza.attrs.notify author = from
} else if(isJidGroup(from)) {
if(!participant) {
throw new Boom('No participant in group message')
}
const key: WAMessageKey = { msgType = 'group'
remoteJid: chatId, author = participant
fromMe, chatId = from
id: msgId, } else if(isJidBroadcast(from)) {
participant if(!participant) {
} throw new Boom('No participant in group message')
}
const fullMessage: proto.IWebMessageInfo = { const isParticipantMe = isMe(participant)
key, if(isJidStatusBroadcast(from)) {
messageTimestamp: +stanza.attrs.t, msgType = isParticipantMe ? 'direct_peer_status' : 'other_status'
pushName: pushname } else {
} msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast'
}
if(key.fromMe) { chatId = from
fullMessage.status = proto.WebMessageInfo.WebMessageInfoStatus.SERVER_ACK author = participant
} }
if(Array.isArray(stanza.content)) { const sender = msgType === 'chat' ? author : chatId
for(const { tag, attrs, content } of stanza.content) {
if(tag !== 'enc') continue
if(!(content instanceof Uint8Array)) continue
let msgBuffer: Buffer const fromMe = isMe(stanza.attrs.participant || stanza.attrs.from)
const pushname = stanza.attrs.notify
try { const key: WAMessageKey = {
const e2eType = attrs.type remoteJid: chatId,
switch(e2eType) { fromMe,
case 'skmsg': id: msgId,
msgBuffer = await decryptGroupSignalProto(sender, author, content, auth) participant
break }
case 'pkmsg':
case 'msg':
const user = isJidUser(sender) ? sender : author
msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth)
break
}
let msg: proto.IMessage = proto.Message.decode(unpadRandomMax16(msgBuffer))
msg = msg.deviceSentMessage?.message || msg
if(msg.senderKeyDistributionMessage) {
await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth)
}
if(fullMessage.message) Object.assign(fullMessage.message, msg) const fullMessage: proto.IWebMessageInfo = {
else fullMessage.message = msg key,
} catch(error) { messageTimestamp: +stanza.attrs.t,
fullMessage.messageStubType = proto.WebMessageInfo.WebMessageInfoStubType.CIPHERTEXT pushName: pushname
fullMessage.messageStubParameters = [error.message] }
}
}
}
return fullMessage if(key.fromMe) {
fullMessage.status = proto.WebMessageInfo.WebMessageInfoStatus.SERVER_ACK
}
if(Array.isArray(stanza.content)) {
for(const { tag, attrs, content } of stanza.content) {
if(tag !== 'enc') {
continue
}
if(!(content instanceof Uint8Array)) {
continue
}
let msgBuffer: Buffer
try {
const e2eType = attrs.type
switch (e2eType) {
case 'skmsg':
msgBuffer = await decryptGroupSignalProto(sender, author, content, auth)
break
case 'pkmsg':
case 'msg':
const user = isJidUser(sender) ? sender : author
msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth)
break
}
let msg: proto.IMessage = proto.Message.decode(unpadRandomMax16(msgBuffer))
msg = msg.deviceSentMessage?.message || msg
if(msg.senderKeyDistributionMessage) {
await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth)
}
if(fullMessage.message) {
Object.assign(fullMessage.message, msg)
} else {
fullMessage.message = msg
}
} catch(error) {
fullMessage.messageStubType = proto.WebMessageInfo.WebMessageInfoStubType.CIPHERTEXT
fullMessage.messageStubParameters = [error.message]
}
}
}
return fullMessage
} }

View File

@@ -2,217 +2,233 @@ import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { platform, release } from 'os' import { platform, release } from 'os'
import { Logger } from 'pino' import { Logger } from 'pino'
import { ConnectionState } from '..'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { CommonBaileysEventEmitter, DisconnectReason } from '../Types' import { CommonBaileysEventEmitter, DisconnectReason } from '../Types'
import { Binary } from '../WABinary' import { Binary } from '../WABinary'
import { ConnectionState } from '..'
const PLATFORM_MAP = { const PLATFORM_MAP = {
'aix': 'AIX', 'aix': 'AIX',
'darwin': 'Mac OS', 'darwin': 'Mac OS',
'win32': 'Windows', 'win32': 'Windows',
'android': 'Android' 'android': 'Android'
} }
export const Browsers = { export const Browsers = {
ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string], ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string],
macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string], macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string],
baileys: browser => ['Baileys', browser, '4.0.0'] as [string, string, string], baileys: browser => ['Baileys', browser, '4.0.0'] as [string, string, string],
/** The appropriate browser based on your OS & release */ /** The appropriate browser based on your OS & release */
appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string] appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string]
} }
export const BufferJSON = { export const BufferJSON = {
replacer: (k, value: any) => { replacer: (k, value: any) => {
if(Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === 'Buffer') { if(Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === 'Buffer') {
return { type: 'Buffer', data: Buffer.from(value?.data || value).toString('base64') } return { type: 'Buffer', data: Buffer.from(value?.data || value).toString('base64') }
} }
return value
}, return value
reviver: (_, value: any) => { },
if(typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) { reviver: (_, value: any) => {
const val = value.data || value.value if(typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) {
return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val) const val = value.data || value.value
} return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val)
return value }
}
return value
}
} }
export const writeRandomPadMax16 = function(e: Binary) { export const writeRandomPadMax16 = (e: Binary) => {
function r(e: Binary, t: number) { function r(e: Binary, t: number) {
for (var r = 0; r < t; r++) for(var r = 0; r < t; r++) {
e.writeUint8(t) e.writeUint8(t)
} }
}
var t = randomBytes(1) var t = randomBytes(1)
r(e, 1 + (15 & t[0])) r(e, 1 + (15 & t[0]))
return e return e
} }
export const unpadRandomMax16 = (e: Uint8Array | Buffer) => { export const unpadRandomMax16 = (e: Uint8Array | Buffer) => {
const t = new Uint8Array(e); const t = new Uint8Array(e)
if (0 === t.length) { if(0 === t.length) {
throw new Error('unpadPkcs7 given empty bytes'); throw new Error('unpadPkcs7 given empty bytes')
} }
var r = t[t.length - 1]; var r = t[t.length - 1]
if (r > t.length) { if(r > t.length) {
throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`); throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`)
} }
return new Uint8Array(t.buffer, t.byteOffset, t.length - r); return new Uint8Array(t.buffer, t.byteOffset, t.length - r)
} }
export const encodeWAMessage = (message: proto.IMessage) => ( export const encodeWAMessage = (message: proto.IMessage) => (
Buffer.from( Buffer.from(
writeRandomPadMax16( writeRandomPadMax16(
new Binary(proto.Message.encode(message).finish()) new Binary(proto.Message.encode(message).finish())
).readByteArray() ).readByteArray()
) )
) )
export const generateRegistrationId = () => ( export const generateRegistrationId = () => (
Uint16Array.from(randomBytes(2))[0] & 0x3fff Uint16Array.from(randomBytes(2))[0] & 0x3fff
) )
export const encodeInt = (e: number, t: number) => { export const encodeInt = (e: number, t: number) => {
for (var r = t, a = new Uint8Array(e), i = e - 1; i >= 0; i--) { for(var r = t, a = new Uint8Array(e), i = e - 1; i >= 0; i--) {
a[i] = 255 & r a[i] = 255 & r
r >>>= 8 r >>>= 8
} }
return a
return a
} }
export const encodeBigEndian = (e: number, t=4) => { export const encodeBigEndian = (e: number, t=4) => {
let r = e; let r = e
let a = new Uint8Array(t); const a = new Uint8Array(t)
for (let i = t - 1; i >= 0; i--) { for(let i = t - 1; i >= 0; i--) {
a[i] = 255 & r a[i] = 255 & r
r >>>= 8 r >>>= 8
} }
return a
return a
} }
export const toNumber = (t: Long | number) => ((typeof t === 'object' && 'toNumber' in t) ? t.toNumber() : t) export const toNumber = (t: Long | number) => ((typeof t === 'object' && 'toNumber' in t) ? t.toNumber() : t)
export function shallowChanges <T> (old: T, current: T, {lookForDeletedKeys}: {lookForDeletedKeys: boolean}): Partial<T> { export function shallowChanges <T>(old: T, current: T, { lookForDeletedKeys }: {lookForDeletedKeys: boolean}): Partial<T> {
let changes: Partial<T> = {} const changes: Partial<T> = {}
for (let key in current) { for(const key in current) {
if (old[key] !== current[key]) { if(old[key] !== current[key]) {
changes[key] = current[key] || null changes[key] = current[key] || null
} }
} }
if (lookForDeletedKeys) {
for (let key in old) { if(lookForDeletedKeys) {
if (!changes[key] && old[key] !== current[key]) { for(const key in old) {
changes[key] = current[key] || null if(!changes[key] && old[key] !== current[key]) {
} changes[key] = current[key] || null
} }
} }
return changes }
return changes
} }
/** unix timestamp of a date in seconds */ /** unix timestamp of a date in seconds */
export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000) export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000)
export type DebouncedTimeout = ReturnType<typeof debouncedTimeout> export type DebouncedTimeout = ReturnType<typeof debouncedTimeout>
export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => { export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => {
let timeout: NodeJS.Timeout let timeout: NodeJS.Timeout
return { return {
start: (newIntervalMs?: number, newTask?: () => void) => { start: (newIntervalMs?: number, newTask?: () => void) => {
task = newTask || task task = newTask || task
intervalMs = newIntervalMs || intervalMs intervalMs = newIntervalMs || intervalMs
timeout && clearTimeout(timeout) timeout && clearTimeout(timeout)
timeout = setTimeout(task, intervalMs) timeout = setTimeout(task, intervalMs)
}, },
cancel: () => { cancel: () => {
timeout && clearTimeout(timeout) timeout && clearTimeout(timeout)
timeout = undefined timeout = undefined
}, },
setTask: (newTask: () => void) => task = newTask, setTask: (newTask: () => void) => task = newTask,
setInterval: (newInterval: number) => intervalMs = newInterval setInterval: (newInterval: number) => intervalMs = newInterval
} }
} }
export const delay = (ms: number) => delayCancellable (ms).delay export const delay = (ms: number) => delayCancellable (ms).delay
export const delayCancellable = (ms: number) => { export const delayCancellable = (ms: number) => {
const stack = new Error().stack const stack = new Error().stack
let timeout: NodeJS.Timeout let timeout: NodeJS.Timeout
let reject: (error) => void let reject: (error) => void
const delay: Promise<void> = new Promise((resolve, _reject) => { const delay: Promise<void> = new Promise((resolve, _reject) => {
timeout = setTimeout(resolve, ms) timeout = setTimeout(resolve, ms)
reject = _reject reject = _reject
}) })
const cancel = () => { const cancel = () => {
clearTimeout (timeout) clearTimeout (timeout)
reject( reject(
new Boom('Cancelled', { new Boom('Cancelled', {
statusCode: 500, statusCode: 500,
data: { data: {
stack stack
} }
}) })
) )
} }
return { delay, cancel }
}
export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) {
if (!ms) return new Promise (promise)
const stack = new Error().stack
// Create a promise that rejects in <ms> milliseconds
let {delay, cancel} = delayCancellable (ms)
const p = new Promise ((resolve, reject) => {
delay
.then(() => reject(
new Boom('Timed Out', {
statusCode: DisconnectReason.timedOut,
data: {
stack
}
})
))
.catch (err => reject(err))
promise (resolve, reject) return { delay, cancel }
})
.finally (cancel)
return p as Promise<T>
} }
export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) {
if(!ms) {
return new Promise (promise)
}
const stack = new Error().stack
// Create a promise that rejects in <ms> milliseconds
const { delay, cancel } = delayCancellable (ms)
const p = new Promise ((resolve, reject) => {
delay
.then(() => reject(
new Boom('Timed Out', {
statusCode: DisconnectReason.timedOut,
data: {
stack
}
})
))
.catch (err => reject(err))
promise (resolve, reject)
})
.finally (cancel)
return p as Promise<T>
}
// generate a random ID to attach to a message // generate a random ID to attach to a message
export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase() export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase()
export const bindWaitForConnectionUpdate = (ev: CommonBaileysEventEmitter<any>) => ( export const bindWaitForConnectionUpdate = (ev: CommonBaileysEventEmitter<any>) => (
async(check: (u: Partial<ConnectionState>) => boolean, timeoutMs?: number) => { async(check: (u: Partial<ConnectionState>) => boolean, timeoutMs?: number) => {
let listener: (item: Partial<ConnectionState>) => void let listener: (item: Partial<ConnectionState>) => void
await ( await (
promiseTimeout( promiseTimeout(
timeoutMs, timeoutMs,
(resolve, reject) => { (resolve, reject) => {
listener = (update) => { listener = (update) => {
if(check(update)) { if(check(update)) {
resolve() resolve()
} else if(update.connection == 'close') { } else if(update.connection === 'close') {
reject(update.lastDisconnect?.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })) reject(update.lastDisconnect?.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
} }
} }
ev.on('connection.update', listener)
} ev.on('connection.update', listener)
) }
.finally(() => ( )
ev.off('connection.update', listener) .finally(() => (
)) ev.off('connection.update', listener)
) ))
} )
}
) )
export const printQRIfNecessaryListener = (ev: CommonBaileysEventEmitter<any>, logger: Logger) => { export const printQRIfNecessaryListener = (ev: CommonBaileysEventEmitter<any>, logger: Logger) => {
ev.on('connection.update', async({ qr }) => { ev.on('connection.update', async({ qr }) => {
if(qr) { if(qr) {
const QR = await import('qrcode-terminal') const QR = await import('qrcode-terminal')
.catch(err => { .catch(err => {
logger.error('QR code terminal not added as dependency') logger.error('QR code terminal not added as dependency')
}) })
QR?.generate(qr, { small: true }) QR?.generate(qr, { small: true })
} }
}) })
} }

View File

@@ -1,17 +1,18 @@
import { downloadContentFromMessage } from "./messages-media"
import { proto } from "../../WAProto"
import { promisify } from 'util' import { promisify } from 'util'
import { inflate } from "zlib" import { inflate } from 'zlib'
import { Chat, Contact } from "../Types" import { proto } from '../../WAProto'
import { Chat, Contact } from '../Types'
import { downloadContentFromMessage } from './messages-media'
const inflatePromise = promisify(inflate) const inflatePromise = promisify(inflate)
export const downloadHistory = async(msg: proto.IHistorySyncNotification) => { export const downloadHistory = async(msg: proto.IHistorySyncNotification) => {
const stream = await downloadContentFromMessage(msg, 'history') const stream = await downloadContentFromMessage(msg, 'history')
let buffer = Buffer.from([]) let buffer = Buffer.from([])
for await(const chunk of stream) { for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk]) buffer = Buffer.concat([buffer, chunk])
} }
// decompress buffer // decompress buffer
buffer = await inflatePromise(buffer) buffer = await inflatePromise(buffer)
@@ -24,45 +25,47 @@ export const processHistoryMessage = (item: proto.IHistorySync, historyCache: Se
const messages: proto.IWebMessageInfo[] = [] const messages: proto.IWebMessageInfo[] = []
const contacts: Contact[] = [] const contacts: Contact[] = []
const chats: Chat[] = [] const chats: Chat[] = []
switch(item.syncType) { switch (item.syncType) {
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_BOOTSTRAP: case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_BOOTSTRAP:
case proto.HistorySync.HistorySyncHistorySyncType.RECENT: case proto.HistorySync.HistorySyncHistorySyncType.RECENT:
for(const chat of item.conversations) { for(const chat of item.conversations) {
const contactId = `c:${chat.id}` const contactId = `c:${chat.id}`
if(chat.name && !historyCache.has(contactId)) { if(chat.name && !historyCache.has(contactId)) {
contacts.push({ contacts.push({
id: chat.id, id: chat.id,
name: chat.name name: chat.name
}) })
historyCache.add(contactId) historyCache.add(contactId)
} }
for(const { message } of chat.messages || []) { for(const { message } of chat.messages || []) {
const uqId = `${message?.key.remoteJid}:${message.key.id}` const uqId = `${message?.key.remoteJid}:${message.key.id}`
if(message && !historyCache.has(uqId)) { if(message && !historyCache.has(uqId)) {
messages.push(message) messages.push(message)
historyCache.add(uqId) historyCache.add(uqId)
}
}
delete chat.messages
if(!historyCache.has(chat.id)) {
chats.push(chat)
historyCache.add(chat.id)
} }
} }
break
case proto.HistorySync.HistorySyncHistorySyncType.PUSH_NAME: delete chat.messages
for(const c of item.pushnames) { if(!historyCache.has(chat.id)) {
const contactId = `c:${c.id}` chats.push(chat)
if(historyCache.has(contactId)) { historyCache.add(chat.id)
contacts.push({ notify: c.pushname, id: c.id })
historyCache.add(contactId)
}
} }
}
break break
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_STATUS_V3: case proto.HistorySync.HistorySyncHistorySyncType.PUSH_NAME:
// TODO for(const c of item.pushnames) {
const contactId = `c:${c.id}`
if(historyCache.has(contactId)) {
contacts.push({ notify: c.pushname, id: c.id })
historyCache.add(contactId)
}
}
break
case proto.HistorySync.HistorySyncHistorySyncType.INITIAL_STATUS_V3:
// TODO
break break
} }

View File

@@ -1,75 +1,82 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { decodeBinaryNodeLegacy, jidNormalizedUser } from "../WABinary" import { AuthenticationCreds, Contact, CurveKeyPair, DisconnectReason, LegacyAuthenticationCreds, WATag } from '../Types'
import { aesDecrypt, hmacSign, hkdf, Curve } from "./crypto" import { decodeBinaryNodeLegacy, jidNormalizedUser } from '../WABinary'
import { aesDecrypt, Curve, hkdf, hmacSign } from './crypto'
import { BufferJSON } from './generics' import { BufferJSON } from './generics'
import { DisconnectReason, WATag, LegacyAuthenticationCreds, AuthenticationCreds, CurveKeyPair, Contact } from "../Types"
export const newLegacyAuthCreds = () => ({ export const newLegacyAuthCreds = () => ({
clientID: randomBytes(16).toString('base64') clientID: randomBytes(16).toString('base64')
}) as LegacyAuthenticationCreds }) as LegacyAuthenticationCreds
export const decodeWAMessage = ( export const decodeWAMessage = (
message: Buffer | string, message: Buffer | string,
auth: { macKey: Buffer, encKey: Buffer }, auth: { macKey: Buffer, encKey: Buffer },
fromMe: boolean=false fromMe: boolean=false
) => { ) => {
let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message 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(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 if(message[commaIndex+1] === ',') {
let data = message.slice(commaIndex+1, message.length) commaIndex += 1
}
// get the message tag. let data = message.slice(commaIndex+1, message.length)
// 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() // get the message tag.
let json: any // If a query was done, the server will respond with the same message tag we sent the query with
let tags: WATag const messageTag: string = message.slice(0, commaIndex).toString()
if(data.length) { let json: any
const possiblyEnc = (data.length > 32 && data.length % 16 === 0) let tags: WATag
if(typeof data === 'string' || !possiblyEnc) { if(data.length) {
json = JSON.parse(data.toString()) // parse the JSON const possiblyEnc = (data.length > 32 && data.length % 16 === 0)
} else { if(typeof data === 'string' || !possiblyEnc) {
try { json = JSON.parse(data.toString()) // parse the JSON
json = JSON.parse(data.toString()) } else {
} catch { try {
const { macKey, encKey } = auth || {} json = JSON.parse(data.toString())
if (!macKey || !encKey) { } catch{
throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession }) 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. 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 Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
*/ */
if (fromMe) { if(fromMe) {
tags = [data[0], data[1]] tags = [data[0], data[1]]
data = data.slice(2, data.length) 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 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 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 const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey
if (checksum.equals(computedChecksum)) { if(checksum.equals(computedChecksum)) {
// the checksum the server sent, must match the one we computed for the message to be valid // 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 const decrypted = aesDecrypt(data, encKey) // decrypt using AES
json = decodeBinaryNodeLegacy(decrypted, { index: 0 }) // decode the binary message into a JSON array json = decodeBinaryNodeLegacy(decrypted, { index: 0 }) // decode the binary message into a JSON array
} else { } else {
throw new Boom('Bad checksum', { throw new Boom('Bad checksum', {
data: { data: {
received: checksum.toString('hex'), received: checksum.toString('hex'),
computed: computedChecksum.toString('hex'), computed: computedChecksum.toString('hex'),
data: data.slice(0, 80).toString(), data: data.slice(0, 80).toString(),
tag: messageTag, tag: messageTag,
message: message.slice(0, 80).toString() message: message.slice(0, 80).toString()
}, },
statusCode: DisconnectReason.badSession statusCode: DisconnectReason.badSession
}) })
} }
} }
} }
} }
return [messageTag, json, tags] as const
return [messageTag, json, tags] as const
} }
/** /**
@@ -82,104 +89,110 @@ export const validateNewConnection = (
auth: LegacyAuthenticationCreds, auth: LegacyAuthenticationCreds,
curveKeys: CurveKeyPair curveKeys: CurveKeyPair
) => { ) => {
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
const onValidationSuccess = () => { const onValidationSuccess = () => {
const user: Contact = { const user: Contact = {
id: jidNormalizedUser(json.wid), id: jidNormalizedUser(json.wid),
name: json.pushname name: json.pushname
} }
return { user, auth, phone: json.phone } return { user, auth, phone: json.phone }
} }
if (!json.secret) {
if(!json.secret) {
// if we didn't get a secret, we don't need it, we're validated // if we didn't get a secret, we don't need it, we're validated
if (json.clientToken && json.clientToken !== auth.clientToken) { if(json.clientToken && json.clientToken !== auth.clientToken) {
auth = { ...auth, clientToken: json.clientToken } auth = { ...auth, clientToken: json.clientToken }
} }
if (json.serverToken && json.serverToken !== auth.serverToken) {
if(json.serverToken && json.serverToken !== auth.serverToken) {
auth = { ...auth, serverToken: json.serverToken } auth = { ...auth, serverToken: json.serverToken }
} }
return onValidationSuccess() return onValidationSuccess()
} }
const secret = Buffer.from(json.secret, 'base64')
if (secret.length !== 144) { const secret = Buffer.from(json.secret, 'base64')
if(secret.length !== 144) {
throw new Error ('incorrect secret length received: ' + secret.length) throw new Error ('incorrect secret length received: ' + secret.length)
} }
// generate shared key from our private key & the secret shared by the server // generate shared key from our private key & the secret shared by the server
const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32)) const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32))
// expand the key to 80 bytes using HKDF // expand the key to 80 bytes using HKDF
const expandedKey = hkdf(sharedKey as Buffer, 80, { }) const expandedKey = hkdf(sharedKey as Buffer, 80, { })
// perform HMAC validation. // perform HMAC validation.
const hmacValidationKey = expandedKey.slice(32, 64) const hmacValidationKey = expandedKey.slice(32, 64)
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)]) const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
const hmac = hmacSign(hmacValidationMessage, hmacValidationKey) const hmac = hmacSign(hmacValidationMessage, hmacValidationKey)
if (!hmac.equals(secret.slice(32, 64))) { if(!hmac.equals(secret.slice(32, 64))) {
// if the checksums didn't match // if the checksums didn't match
throw new Boom('HMAC validation failed', { statusCode: 400 }) throw new Boom('HMAC validation failed', { statusCode: 400 })
} }
// computed HMAC should equal secret[32:64] // 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 // 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] // they are encrypted using key: expandedKey[0:32]
const encryptedAESKeys = Buffer.concat([ const encryptedAESKeys = Buffer.concat([
expandedKey.slice(64, expandedKey.length), expandedKey.slice(64, expandedKey.length),
secret.slice(64, secret.length), secret.slice(64, secret.length),
]) ])
const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32)) const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
// set the credentials // set the credentials
auth = { auth = {
encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages 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 macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages
clientToken: json.clientToken, clientToken: json.clientToken,
serverToken: json.serverToken, serverToken: json.serverToken,
clientID: auth.clientID, clientID: auth.clientID,
} }
return onValidationSuccess() return onValidationSuccess()
} }
export const computeChallengeResponse = (challenge: string, auth: LegacyAuthenticationCreds) => { export const computeChallengeResponse = (challenge: string, auth: LegacyAuthenticationCreds) => {
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string 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 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 return ['admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID
} }
export const useSingleFileLegacyAuthState = (file: string) => { export const useSingleFileLegacyAuthState = (file: string) => {
// require fs here so that in case "fs" is not available -- the app does not crash // require fs here so that in case "fs" is not available -- the app does not crash
const { readFileSync, writeFileSync, existsSync } = require('fs') const { readFileSync, writeFileSync, existsSync } = require('fs')
let state: LegacyAuthenticationCreds let state: LegacyAuthenticationCreds
if(existsSync(file)) { if(existsSync(file)) {
state = JSON.parse( state = JSON.parse(
readFileSync(file, { encoding: 'utf-8' }), readFileSync(file, { encoding: 'utf-8' }),
BufferJSON.reviver BufferJSON.reviver
) )
if(typeof state.encKey === 'string') { if(typeof state.encKey === 'string') {
state.encKey = Buffer.from(state.encKey, 'base64') state.encKey = Buffer.from(state.encKey, 'base64')
} }
if(typeof state.macKey === 'string') {
state.macKey = Buffer.from(state.macKey, 'base64')
}
} else {
state = newLegacyAuthCreds()
}
return { if(typeof state.macKey === 'string') {
state, state.macKey = Buffer.from(state.macKey, 'base64')
saveState: () => { }
const str = JSON.stringify(state, BufferJSON.replacer, 2) } else {
writeFileSync(file, str) state = newLegacyAuthCreds()
} }
}
return {
state,
saveState: () => {
const str = JSON.stringify(state, BufferJSON.replacer, 2)
writeFileSync(file, str)
}
}
} }
export const getAuthenticationCredsType = (creds: LegacyAuthenticationCreds | AuthenticationCreds) => { export const getAuthenticationCredsType = (creds: LegacyAuthenticationCreds | AuthenticationCreds) => {
if('clientID' in creds && !!creds.clientID) { if('clientID' in creds && !!creds.clientID) {
return 'legacy' return 'legacy'
} }
if('noiseKey' in creds && !!creds.noiseKey) {
return 'md' if('noiseKey' in creds && !!creds.noiseKey) {
} return 'md'
}
} }

View File

@@ -6,7 +6,7 @@ import { hkdf } from './crypto'
* if the same series of mutations was made sequentially. * if the same series of mutations was made sequentially.
*/ */
const o = 128; const o = 128
class d { class d {
@@ -16,41 +16,45 @@ class d {
this.salt = e this.salt = e
} }
add(e, t) { add(e, t) {
var r = this; var r = this
for(const item of t) { for(const item of t) {
e = r._addSingle(e, item) e = r._addSingle(e, item)
} }
return e return e
} }
subtract(e, t) { subtract(e, t) {
var r = this; var r = this
for(const item of t) { for(const item of t) {
e = r._subtractSingle(e, item) e = r._subtractSingle(e, item)
} }
return e return e
} }
subtractThenAdd(e, t, r) { subtractThenAdd(e, t, r) {
var n = this; var n = this
return n.add(n.subtract(e, r), t) return n.add(n.subtract(e, r), t)
} }
_addSingle(e, t) { _addSingle(e, t) {
var r = this; var r = this
const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer; const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer
return r.performPointwiseWithOverflow(e, n, ((e,t)=>e + t)) return r.performPointwiseWithOverflow(e, n, ((e, t) => e + t))
} }
_subtractSingle(e, t) { _subtractSingle(e, t) {
var r = this; var r = this
const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer; const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer
return r.performPointwiseWithOverflow(e, n, ((e,t)=>e - t)) return r.performPointwiseWithOverflow(e, n, ((e, t) => e - t))
} }
performPointwiseWithOverflow(e, t, r) { performPointwiseWithOverflow(e, t, r) {
const n = new DataView(e) const n = new DataView(e)
, i = new DataView(t) , i = new DataView(t)
, a = new ArrayBuffer(n.byteLength) , a = new ArrayBuffer(n.byteLength)
, s = new DataView(a); , s = new DataView(a)
for (let e = 0; e < n.byteLength; e += 2) for(let e = 0; e < n.byteLength; e += 2) {
s.setUint16(e, r(n.getUint16(e, !0), i.getUint16(e, !0)), !0); s.setUint16(e, r(n.getUint16(e, !0), i.getUint16(e, !0)), !0)
}
return a return a
} }
} }

View File

@@ -3,17 +3,20 @@ export default () => {
let task = Promise.resolve() as Promise<any> let task = Promise.resolve() as Promise<any>
return { return {
mutex<T>(code: () => Promise<T>):Promise<T> { mutex<T>(code: () => Promise<T>):Promise<T> {
task = (async () => { task = (async() => {
// wait for the previous task to complete // wait for the previous task to complete
// if there is an error, we swallow so as to not block the queue // if there is an error, we swallow so as to not block the queue
try { await task } catch { } try {
await task
} catch{ }
// execute the current task // execute the current task
return code() return code()
})() })()
// we replace the existing task, appending the new piece of execution to it // we replace the existing task, appending the new piece of execution to it
// so the next task will have to wait for this one to finish // so the next task will have to wait for this one to finish
return task return task
}, },
} }
} }

View File

@@ -1,303 +1,341 @@
import type { Logger } from 'pino'
import type { IAudioMetadata } from 'music-metadata'
import { Boom } from '@hapi/boom' 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 { URL } from 'url'
import { join } from 'path'
import { once } from 'events'
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage, CommonSocketConfig, WAMediaUploadFunction, MediaConnInfo } from '../Types'
import { generateMessageID } from './generics'
import { hkdf } from './crypto'
import { DEFAULT_ORIGIN, MEDIA_PATH_MAP } from '../Defaults'
import { AxiosRequestConfig } from 'axios' import { AxiosRequestConfig } from 'axios'
import { exec } from 'child_process'
import * as Crypto from 'crypto'
import { once } from 'events'
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
import type { IAudioMetadata } from 'music-metadata'
import { tmpdir } from 'os'
import { join } from 'path'
import type { Logger } from 'pino'
import { Readable, Transform } from 'stream'
import { URL } from 'url'
import { DEFAULT_ORIGIN, MEDIA_PATH_MAP } from '../Defaults'
import { CommonSocketConfig, DownloadableMessage, MediaConnInfo, MediaType, MessageType, WAGenericMediaMessage, WAMediaUpload, WAMediaUploadFunction, WAMessageContent, WAProto } from '../Types'
import { hkdf } from './crypto'
import { generateMessageID } from './generics'
const getTmpFilesDirectory = () => tmpdir() const getTmpFilesDirectory = () => tmpdir()
const getImageProcessingLibrary = async() => { const getImageProcessingLibrary = async() => {
const [jimp, sharp] = await Promise.all([ const [jimp, sharp] = await Promise.all([
(async() => { (async() => {
const jimp = await ( const jimp = await (
import('jimp') import('jimp')
.catch(() => { }) .catch(() => { })
) )
return jimp return jimp
})(), })(),
(async() => { (async() => {
const sharp = await ( const sharp = await (
import('sharp') import('sharp')
.catch(() => { }) .catch(() => { })
) )
return sharp return sharp
})() })()
]) ])
if(sharp) return { sharp } if(sharp) {
if(jimp) return { jimp } return { sharp }
}
throw new Boom('No image processing library available') if(jimp) {
return { jimp }
}
throw new Boom('No image processing library available')
} }
export const hkdfInfoKey = (type: MediaType) => { export const hkdfInfoKey = (type: MediaType) => {
let str: string = type let str: string = type
if(type === 'sticker') str = 'image' if(type === 'sticker') {
if(type === 'md-app-state') str = 'App State' str = 'image'
}
let hkdfInfo = str[0].toUpperCase() + str.slice(1) if(type === 'md-app-state') {
str = 'App State'
}
const hkdfInfo = str[0].toUpperCase() + str.slice(1)
return `WhatsApp ${hkdfInfo} Keys` return `WhatsApp ${hkdfInfo} Keys`
} }
/** generates all the keys required to encrypt/decrypt & sign a media message */ /** generates all the keys required to encrypt/decrypt & sign a media message */
export function getMediaKeys(buffer, mediaType: MediaType) { export function getMediaKeys(buffer, mediaType: MediaType) {
if (typeof buffer === 'string') { if(typeof buffer === 'string') {
buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64') buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
} }
// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) }) // expand using HKDF to 112 bytes, also pass in the relevant app info
return { const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
iv: expandedMediaKey.slice(0, 16), return {
cipherKey: expandedMediaKey.slice(16, 48), iv: expandedMediaKey.slice(0, 16),
macKey: expandedMediaKey.slice(48, 80), cipherKey: expandedMediaKey.slice(16, 48),
} macKey: expandedMediaKey.slice(48, 80),
}
} }
/** Extracts video thumb using FFMPEG */ /** Extracts video thumb using FFMPEG */
const extractVideoThumb = async ( const extractVideoThumb = async(
path: string, path: string,
destPath: string, destPath: string,
time: string, time: string,
size: { width: number; height: number }, size: { width: number; height: number },
) => ) => new Promise((resolve, reject) => {
new Promise((resolve, reject) => { const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}` exec(cmd, (err) => {
exec(cmd, (err) => { if(err) {
if (err) reject(err) reject(err)
else resolve() } else {
}) resolve()
}) as Promise<void> }
})
}) as Promise<void>
export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | string) => { export const extractImageThumb = async(bufferOrFilePath: Readable | Buffer | string) => {
if(bufferOrFilePath instanceof Readable) { if(bufferOrFilePath instanceof Readable) {
bufferOrFilePath = await toBuffer(bufferOrFilePath) bufferOrFilePath = await toBuffer(bufferOrFilePath)
} }
const lib = await getImageProcessingLibrary()
if('sharp' in lib) {
const result = await lib.sharp!.default(bufferOrFilePath)
.resize(32, 32)
.jpeg({ quality: 50 })
.toBuffer()
return result
} else {
const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp
const jimp = await read(bufferOrFilePath as any) const lib = await getImageProcessingLibrary()
const result = await jimp if('sharp' in lib) {
.quality(50) const result = await lib.sharp!.default(bufferOrFilePath)
.resize(32, 32, RESIZE_BILINEAR) .resize(32, 32)
.getBufferAsync(MIME_JPEG) .jpeg({ quality: 50 })
return result .toBuffer()
} return result
} else {
const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp
const jimp = await read(bufferOrFilePath as any)
const result = await jimp
.quality(50)
.resize(32, 32, RESIZE_BILINEAR)
.getBufferAsync(MIME_JPEG)
return result
}
} }
export const generateProfilePicture = async (mediaUpload: WAMediaUpload) => {
let bufferOrFilePath: Buffer | string
if(Buffer.isBuffer(mediaUpload)) {
bufferOrFilePath = mediaUpload
} else if('url' in mediaUpload) {
bufferOrFilePath = mediaUpload.url.toString()
} else {
bufferOrFilePath = await toBuffer(mediaUpload.stream)
}
const lib = await getImageProcessingLibrary() export const generateProfilePicture = async(mediaUpload: WAMediaUpload) => {
let img: Promise<Buffer> let bufferOrFilePath: Buffer | string
if('sharp' in lib) { if(Buffer.isBuffer(mediaUpload)) {
img = lib.sharp!.default(bufferOrFilePath) bufferOrFilePath = mediaUpload
.resize(640, 640) } else if('url' in mediaUpload) {
.jpeg({ bufferOrFilePath = mediaUpload.url.toString()
quality: 50, } else {
}) bufferOrFilePath = await toBuffer(mediaUpload.stream)
.toBuffer() }
} else {
const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp
const jimp = await read(bufferOrFilePath as any)
const min = Math.min(jimp.getWidth(), jimp.getHeight())
const cropped = jimp.crop(0, 0, min, min)
img = cropped const lib = await getImageProcessingLibrary()
.quality(50) let img: Promise<Buffer>
.resize(640, 640, RESIZE_BILINEAR) if('sharp' in lib) {
.getBufferAsync(MIME_JPEG) img = lib.sharp!.default(bufferOrFilePath)
} .resize(640, 640)
.jpeg({
quality: 50,
})
.toBuffer()
} else {
const { read, MIME_JPEG, RESIZE_BILINEAR } = lib.jimp
const jimp = await read(bufferOrFilePath as any)
const min = Math.min(jimp.getWidth(), jimp.getHeight())
const cropped = jimp.crop(0, 0, min, min)
return { img = cropped
img: await img, .quality(50)
} .resize(640, 640, RESIZE_BILINEAR)
.getBufferAsync(MIME_JPEG)
}
return {
img: await img,
}
} }
/** gets the SHA256 of the given media message */ /** gets the SHA256 of the given media message */
export const mediaMessageSHA256B64 = (message: WAMessageContent) => { export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
const media = Object.values(message)[0] as WAGenericMediaMessage const media = Object.values(message)[0] as WAGenericMediaMessage
return media?.fileSha256 && Buffer.from(media.fileSha256).toString ('base64') return media?.fileSha256 && Buffer.from(media.fileSha256).toString ('base64')
} }
export async function getAudioDuration (buffer: Buffer | string | Readable) {
const musicMetadata = await import('music-metadata') export async function getAudioDuration(buffer: Buffer | string | Readable) {
let metadata: IAudioMetadata const musicMetadata = await import('music-metadata')
if(Buffer.isBuffer(buffer)) { let metadata: IAudioMetadata
metadata = await musicMetadata.parseBuffer(buffer, null, { duration: true }) if(Buffer.isBuffer(buffer)) {
} else if(typeof buffer === 'string') { metadata = await musicMetadata.parseBuffer(buffer, null, { duration: true })
const rStream = createReadStream(buffer) } else if(typeof buffer === 'string') {
metadata = await musicMetadata.parseStream(rStream, null, { duration: true }) const rStream = createReadStream(buffer)
rStream.close() metadata = await musicMetadata.parseStream(rStream, null, { duration: true })
} else { rStream.close()
metadata = await musicMetadata.parseStream(buffer, null, { duration: true }) } else {
} metadata = await musicMetadata.parseStream(buffer, null, { duration: true })
return metadata.format.duration; }
return metadata.format.duration
} }
export const toReadable = (buffer: Buffer) => { export const toReadable = (buffer: Buffer) => {
const readable = new Readable({ read: () => {} }) const readable = new Readable({ read: () => {} })
readable.push(buffer) readable.push(buffer)
readable.push(null) readable.push(null)
return readable return readable
} }
export const toBuffer = async(stream: Readable) => { export const toBuffer = async(stream: Readable) => {
let buff = Buffer.alloc(0) let buff = Buffer.alloc(0)
for await(const chunk of stream) { for await (const chunk of stream) {
buff = Buffer.concat([ buff, chunk ]) buff = Buffer.concat([ buff, chunk ])
} }
return buff
return buff
} }
export const getStream = async (item: WAMediaUpload) => {
if(Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' } export const getStream = async(item: WAMediaUpload) => {
if('stream' in item) return { stream: item.stream, type: 'readable' } if(Buffer.isBuffer(item)) {
if(item.url.toString().startsWith('http://') || item.url.toString().startsWith('https://')) { return { stream: toReadable(item), type: 'buffer' }
return { stream: await getHttpStream(item.url), type: 'remote' } }
}
return { stream: createReadStream(item.url), type: 'file' } if('stream' in item) {
return { stream: item.stream, type: 'readable' }
}
if(item.url.toString().startsWith('http://') || item.url.toString().startsWith('https://')) {
return { stream: await getHttpStream(item.url), type: 'remote' }
}
return { stream: createReadStream(item.url), type: 'file' }
} }
/** generates a thumbnail for a given media, if required */ /** generates a thumbnail for a given media, if required */
export async function generateThumbnail( export async function generateThumbnail(
file: string, file: string,
mediaType: 'video' | 'image', mediaType: 'video' | 'image',
options: { options: {
logger?: Logger logger?: Logger
} }
) { ) {
let thumbnail: string let thumbnail: string
if(mediaType === 'image') { if(mediaType === 'image') {
const buff = await extractImageThumb(file) const buff = await extractImageThumb(file)
thumbnail = buff.toString('base64') thumbnail = buff.toString('base64')
} else if(mediaType === 'video') { } else if(mediaType === 'video') {
const imgFilename = join(getTmpFilesDirectory(), generateMessageID() + '.jpg') const imgFilename = join(getTmpFilesDirectory(), generateMessageID() + '.jpg')
try { try {
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 }) await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 })
const buff = await fs.readFile(imgFilename) const buff = await fs.readFile(imgFilename)
thumbnail = buff.toString('base64') thumbnail = buff.toString('base64')
await fs.unlink(imgFilename) await fs.unlink(imgFilename)
} catch (err) { } catch(err) {
options.logger?.debug('could not generate video thumb: ' + err) options.logger?.debug('could not generate video thumb: ' + err)
} }
} }
return thumbnail return thumbnail
} }
export const getHttpStream = async(url: string | URL, options: AxiosRequestConfig & { isStream?: true } = {}) => { export const getHttpStream = async(url: string | URL, options: AxiosRequestConfig & { isStream?: true } = {}) => {
const { default: axios } = await import('axios') const { default: axios } = await import('axios')
const fetched = await axios.get(url.toString(), { ...options, responseType: 'stream' }) const fetched = await axios.get(url.toString(), { ...options, responseType: 'stream' })
return fetched.data as Readable return fetched.data as Readable
} }
export const encryptedStream = async( export const encryptedStream = async(
media: WAMediaUpload, media: WAMediaUpload,
mediaType: MediaType, mediaType: MediaType,
saveOriginalFileIfRequired = true, saveOriginalFileIfRequired = true,
logger?: Logger logger?: Logger
) => { ) => {
const { stream, type } = await getStream(media) const { stream, type } = await getStream(media)
logger?.debug('fetched media stream') logger?.debug('fetched media stream')
const mediaKey = Crypto.randomBytes(32) const mediaKey = Crypto.randomBytes(32)
const {cipherKey, iv, macKey} = getMediaKeys(mediaKey, mediaType) const { cipherKey, iv, macKey } = getMediaKeys(mediaKey, mediaType)
// random name // random name
//const encBodyPath = join(getTmpFilesDirectory(), mediaType + generateMessageID() + '.enc') //const encBodyPath = join(getTmpFilesDirectory(), mediaType + generateMessageID() + '.enc')
// const encWriteStream = createWriteStream(encBodyPath) // const encWriteStream = createWriteStream(encBodyPath)
const encWriteStream = new Readable({ read: () => {} }) const encWriteStream = new Readable({ read: () => {} })
let bodyPath: string let bodyPath: string
let writeStream: WriteStream let writeStream: WriteStream
let didSaveToTmpPath = false let didSaveToTmpPath = false
if(type === 'file') { if(type === 'file') {
bodyPath = (media as any).url bodyPath = (media as any).url
} else if(saveOriginalFileIfRequired) { } else if(saveOriginalFileIfRequired) {
bodyPath = join(getTmpFilesDirectory(), mediaType + generateMessageID()) bodyPath = join(getTmpFilesDirectory(), mediaType + generateMessageID())
writeStream = createWriteStream(bodyPath) writeStream = createWriteStream(bodyPath)
didSaveToTmpPath = true didSaveToTmpPath = true
} }
let fileLength = 0 let fileLength = 0
const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv) const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
let hmac = Crypto.createHmac('sha256', macKey).update(iv) let hmac = Crypto.createHmac('sha256', macKey).update(iv)
let sha256Plain = Crypto.createHash('sha256') let sha256Plain = Crypto.createHash('sha256')
let sha256Enc = Crypto.createHash('sha256') let sha256Enc = Crypto.createHash('sha256')
const onChunk = (buff: Buffer) => { const onChunk = (buff: Buffer) => {
sha256Enc = sha256Enc.update(buff) sha256Enc = sha256Enc.update(buff)
hmac = hmac.update(buff) hmac = hmac.update(buff)
encWriteStream.push(buff) encWriteStream.push(buff)
} }
try { try {
for await(const data of stream) { for await (const data of stream) {
fileLength += data.length fileLength += data.length
sha256Plain = sha256Plain.update(data) sha256Plain = sha256Plain.update(data)
if(writeStream) { if(writeStream) {
if(!writeStream.write(data)) await once(writeStream, 'drain') if(!writeStream.write(data)) {
} await once(writeStream, 'drain')
onChunk(aes.update(data)) }
} }
onChunk(aes.final())
const mac = hmac.digest().slice(0, 10) onChunk(aes.update(data))
sha256Enc = sha256Enc.update(mac) }
const fileSha256 = sha256Plain.digest() onChunk(aes.final())
const fileEncSha256 = sha256Enc.digest()
encWriteStream.push(mac) const mac = hmac.digest().slice(0, 10)
encWriteStream.push(null) sha256Enc = sha256Enc.update(mac)
writeStream && writeStream.end() const fileSha256 = sha256Plain.digest()
stream.destroy() const fileEncSha256 = sha256Enc.digest()
logger?.debug('encrypted data successfully') encWriteStream.push(mac)
encWriteStream.push(null)
return { writeStream && writeStream.end()
mediaKey, stream.destroy()
encWriteStream,
bodyPath,
mac,
fileEncSha256,
fileSha256,
fileLength,
didSaveToTmpPath
}
} catch(error) {
encWriteStream.destroy(error)
writeStream.destroy(error)
aes.destroy(error)
hmac.destroy(error)
sha256Plain.destroy(error)
sha256Enc.destroy(error)
stream.destroy(error)
throw error logger?.debug('encrypted data successfully')
}
return {
mediaKey,
encWriteStream,
bodyPath,
mac,
fileEncSha256,
fileSha256,
fileLength,
didSaveToTmpPath
}
} catch(error) {
encWriteStream.destroy(error)
writeStream.destroy(error)
aes.destroy(error)
hmac.destroy(error)
sha256Plain.destroy(error)
sha256Enc.destroy(error)
stream.destroy(error)
throw error
}
} }
const DEF_HOST = 'mmg.whatsapp.net' const DEF_HOST = 'mmg.whatsapp.net'
const AES_CHUNK_SIZE = 16 const AES_CHUNK_SIZE = 16
const toSmallestChunkSize = (num: number) => { const toSmallestChunkSize = (num: number) => {
return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE
} }
type MediaDownloadOptions = { type MediaDownloadOptions = {
@@ -306,103 +344,106 @@ type MediaDownloadOptions = {
} }
export const downloadContentFromMessage = async( export const downloadContentFromMessage = async(
{ mediaKey, directPath, url }: DownloadableMessage, { mediaKey, directPath, url }: DownloadableMessage,
type: MediaType, type: MediaType,
{ startByte, endByte }: MediaDownloadOptions = { } { startByte, endByte }: MediaDownloadOptions = { }
) => { ) => {
const downloadUrl = url || `https://${DEF_HOST}${directPath}` const downloadUrl = url || `https://${DEF_HOST}${directPath}`
let bytesFetched = 0 let bytesFetched = 0
let startChunk = 0 let startChunk = 0
let firstBlockIsIV = false let firstBlockIsIV = false
// if a start byte is specified -- then we need to fetch the previous chunk as that will form the IV // if a start byte is specified -- then we need to fetch the previous chunk as that will form the IV
if(startByte) { if(startByte) {
const chunk = toSmallestChunkSize(startByte || 0) const chunk = toSmallestChunkSize(startByte || 0)
if(chunk) { if(chunk) {
startChunk = chunk-AES_CHUNK_SIZE startChunk = chunk-AES_CHUNK_SIZE
bytesFetched = chunk bytesFetched = chunk
firstBlockIsIV = true firstBlockIsIV = true
} }
} }
const endChunk = endByte ? toSmallestChunkSize(endByte || 0)+AES_CHUNK_SIZE : undefined
const headers: { [_: string]: string } = { const endChunk = endByte ? toSmallestChunkSize(endByte || 0)+AES_CHUNK_SIZE : undefined
Origin: DEFAULT_ORIGIN,
}
if(startChunk || endChunk) {
headers.Range = `bytes=${startChunk}-`
if(endChunk) headers.Range += endChunk
}
// download the message const headers: { [_: string]: string } = {
const fetched = await getHttpStream( Origin: DEFAULT_ORIGIN,
downloadUrl, }
{ if(startChunk || endChunk) {
headers, headers.Range = `bytes=${startChunk}-`
maxBodyLength: Infinity, if(endChunk) {
maxContentLength: Infinity, headers.Range += endChunk
} }
) }
let remainingBytes = Buffer.from([]) // download the message
const { cipherKey, iv } = getMediaKeys(mediaKey, type) const fetched = await getHttpStream(
downloadUrl,
{
headers,
maxBodyLength: Infinity,
maxContentLength: Infinity,
}
)
let aes: Crypto.Decipher let remainingBytes = Buffer.from([])
const { cipherKey, iv } = getMediaKeys(mediaKey, type)
const pushBytes = (bytes: Buffer, push: (bytes: Buffer) => void) => { let aes: Crypto.Decipher
if(startByte || endByte) {
const start = bytesFetched >= startByte ? undefined : Math.max(startByte-bytesFetched, 0)
const end = bytesFetched+bytes.length < endByte ? undefined : Math.max(endByte-bytesFetched, 0)
push(bytes.slice(start, end)) const pushBytes = (bytes: Buffer, push: (bytes: Buffer) => void) => {
if(startByte || endByte) {
const start = bytesFetched >= startByte ? undefined : Math.max(startByte-bytesFetched, 0)
const end = bytesFetched+bytes.length < endByte ? undefined : Math.max(endByte-bytesFetched, 0)
bytesFetched += bytes.length push(bytes.slice(start, end))
} else {
push(bytes)
}
}
const output = new Transform({ bytesFetched += bytes.length
transform(chunk, _, callback) { } else {
let data = Buffer.concat([remainingBytes, chunk]) push(bytes)
}
}
const decryptLength = toSmallestChunkSize(data.length) const output = new Transform({
remainingBytes = data.slice(decryptLength) transform(chunk, _, callback) {
data = data.slice(0, decryptLength) let data = Buffer.concat([remainingBytes, chunk])
if(!aes) { const decryptLength = toSmallestChunkSize(data.length)
let ivValue = iv remainingBytes = data.slice(decryptLength)
if(firstBlockIsIV) { data = data.slice(0, decryptLength)
ivValue = data.slice(0, AES_CHUNK_SIZE)
data = data.slice(AES_CHUNK_SIZE)
}
aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, ivValue) if(!aes) {
// if an end byte that is not EOF is specified let ivValue = iv
// stop auto padding (PKCS7) -- otherwise throws an error for decryption if(firstBlockIsIV) {
if(endByte) { ivValue = data.slice(0, AES_CHUNK_SIZE)
aes.setAutoPadding(false) data = data.slice(AES_CHUNK_SIZE)
} }
} aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue)
// if an end byte that is not EOF is specified
// stop auto padding (PKCS7) -- otherwise throws an error for decryption
if(endByte) {
aes.setAutoPadding(false)
}
try { }
pushBytes(aes.update(data), b => this.push(b))
callback() try {
} catch(error) { pushBytes(aes.update(data), b => this.push(b))
callback(error) callback()
} } catch(error) {
}, callback(error)
final(callback) { }
try { },
pushBytes(aes.final(), b => this.push(b)) final(callback) {
callback() try {
} catch(error) { pushBytes(aes.final(), b => this.push(b))
callback(error) callback()
} } catch(error) {
}, callback(error)
}) }
return fetched.pipe(output, { end: true }) },
})
return fetched.pipe(output, { end: true })
} }
/** /**
@@ -410,121 +451,130 @@ export const downloadContentFromMessage = async(
* @param message the media message you want to decode * @param message the media message you want to decode
*/ */
export async function decryptMediaMessageBuffer(message: WAMessageContent): Promise<Readable> { export async function decryptMediaMessageBuffer(message: WAMessageContent): Promise<Readable> {
/* /*
One can infer media type from the key in the message One can infer media type from the key in the message
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc. it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
*/ */
const type = Object.keys(message)[0] as MessageType const type = Object.keys(message)[0] as MessageType
if( if(
!type || !type ||
type === 'conversation' || type === 'conversation' ||
type === 'extendedTextMessage' type === 'extendedTextMessage'
) { ) {
throw new Boom(`no media message for "${type}"`, { statusCode: 400 }) throw new Boom(`no media message for "${type}"`, { statusCode: 400 })
} }
if (type === 'locationMessage' || type === 'liveLocationMessage') {
const buffer = Buffer.from(message[type].jpegThumbnail) if(type === 'locationMessage' || type === 'liveLocationMessage') {
const readable = new Readable({ read: () => {} }) const buffer = Buffer.from(message[type].jpegThumbnail)
readable.push(buffer) const readable = new Readable({ read: () => {} })
readable.push(null) readable.push(buffer)
return readable readable.push(null)
} return readable
let messageContent: WAGenericMediaMessage }
if (message.productMessage) {
const product = message.productMessage.product?.productImage let messageContent: WAGenericMediaMessage
if (!product) throw new Boom('product has no image', { statusCode: 400 }) if(message.productMessage) {
messageContent = product const product = message.productMessage.product?.productImage
} else { if(!product) {
messageContent = message[type] throw new Boom('product has no image', { statusCode: 400 })
} }
return downloadContentFromMessage(messageContent, type.replace('Message', '') as MediaType)
messageContent = product
} else {
messageContent = message[type]
}
return downloadContentFromMessage(messageContent, type.replace('Message', '') as MediaType)
} }
export function extensionForMediaMessage(message: WAMessageContent) { export function extensionForMediaMessage(message: WAMessageContent) {
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1] const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
const type = Object.keys(message)[0] as MessageType const type = Object.keys(message)[0] as MessageType
let extension: string let extension: string
if( if(
type === 'locationMessage' || type === 'locationMessage' ||
type === 'liveLocationMessage' || type === 'liveLocationMessage' ||
type === 'productMessage' type === 'productMessage'
) { ) {
extension = '.jpeg' extension = '.jpeg'
} else { } else {
const messageContent = message[type] as const messageContent = message[type] as
| WAProto.VideoMessage | WAProto.VideoMessage
| WAProto.ImageMessage | WAProto.ImageMessage
| WAProto.AudioMessage | WAProto.AudioMessage
| WAProto.DocumentMessage | WAProto.DocumentMessage
extension = getExtension (messageContent.mimetype) extension = getExtension (messageContent.mimetype)
} }
return extension
return extension
} }
export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger }: CommonSocketConfig<any>, refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>): WAMediaUploadFunction => { export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger }: CommonSocketConfig<any>, refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>): WAMediaUploadFunction => {
return async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => { return async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => {
const { default: axios } = await import('axios') const { default: axios } = await import('axios')
// send a query JSON to obtain the url & auth token to upload our media // send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false) let uploadInfo = await refreshMediaConn(false)
let urls: { mediaUrl: string, directPath: string } let urls: { mediaUrl: string, directPath: string }
const hosts = [ ...customUploadHosts, ...uploadInfo.hosts ] const hosts = [ ...customUploadHosts, ...uploadInfo.hosts ]
let chunks: Buffer[] = [] const chunks: Buffer[] = []
for await(const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk) chunks.push(chunk)
} }
let reqBody = Buffer.concat(chunks) let reqBody = Buffer.concat(chunks)
for (let { hostname, maxContentLengthBytes } of hosts) { for(const { hostname, maxContentLengthBytes } of hosts) {
logger.debug(`uploading to "${hostname}"`) logger.debug(`uploading to "${hostname}"`)
const auth = encodeURIComponent(uploadInfo.auth) // the auth token const auth = encodeURIComponent(uploadInfo.auth) // the auth token
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
let result: any let result: any
try { try {
if(maxContentLengthBytes && reqBody.length > maxContentLengthBytes) { if(maxContentLengthBytes && reqBody.length > maxContentLengthBytes) {
throw new Boom(`Body too large for "${hostname}"`, { statusCode: 413 }) throw new Boom(`Body too large for "${hostname}"`, { statusCode: 413 })
} }
const body = await axios.post( const body = await axios.post(
url, url,
reqBody, reqBody,
{ {
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'Origin': DEFAULT_ORIGIN 'Origin': DEFAULT_ORIGIN
}, },
httpsAgent: fetchAgent, httpsAgent: fetchAgent,
timeout: timeoutMs, timeout: timeoutMs,
responseType: 'json', responseType: 'json',
maxBodyLength: Infinity, maxBodyLength: Infinity,
maxContentLength: Infinity, maxContentLength: Infinity,
} }
) )
result = body.data result = body.data
if(result?.url || result?.directPath) { if(result?.url || result?.directPath) {
urls = { urls = {
mediaUrl: result.url, mediaUrl: result.url,
directPath: result.direct_path directPath: result.direct_path
} }
break break
} else { } else {
uploadInfo = await refreshMediaConn(true) uploadInfo = await refreshMediaConn(true)
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`) throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
} }
} catch (error) { } catch(error) {
if(axios.isAxiosError(error)) { if(axios.isAxiosError(error)) {
result = error.response?.data result = error.response?.data
} }
const isLast = hostname === hosts[uploadInfo.hosts.length-1]?.hostname const isLast = hostname === hosts[uploadInfo.hosts.length-1]?.hostname
logger.warn({ trace: error.stack, uploadResult: result }, `Error in uploading to ${hostname} ${isLast ? '' : ', retrying...'}`) logger.warn({ trace: error.stack, uploadResult: result }, `Error in uploading to ${hostname} ${isLast ? '' : ', retrying...'}`)
} }
} }
// clear buffer just to be sure we're releasing the memory
reqBody = undefined // clear buffer just to be sure we're releasing the memory
reqBody = undefined
if(!urls) { if(!urls) {
throw new Boom( throw new Boom(

View File

@@ -1,11 +1,12 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { promises as fs } from "fs" import { promises as fs } from 'fs'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from "../Defaults" import { MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults'
import { import {
AnyMediaMessageContent, AnyMediaMessageContent,
AnyMessageContent, AnyMessageContent,
MediaGenerationOptions, MediaGenerationOptions,
MediaType,
MessageContentGenerationOptions, MessageContentGenerationOptions,
MessageGenerationOptions, MessageGenerationOptions,
MessageGenerationOptionsFromContent, MessageGenerationOptionsFromContent,
@@ -13,13 +14,11 @@ import {
WAMediaUpload, WAMediaUpload,
WAMessage, WAMessage,
WAMessageContent, WAMessageContent,
WAMessageStatus,
WAProto, WAProto,
WATextMessage, WATextMessage } from '../Types'
MediaType, import { generateMessageID, unixTimestampSeconds } from './generics'
WAMessageStatus import { encryptedStream, generateThumbnail, getAudioDuration } from './messages-media'
} from "../Types"
import { generateMessageID, unixTimestampSeconds } from "./generics"
import { encryptedStream, generateThumbnail, getAudioDuration } from "./messages-media"
type MediaUploadData = { type MediaUploadData = {
media: WAMediaUpload media: WAMediaUpload
@@ -33,20 +32,20 @@ type MediaUploadData = {
} }
const MIMETYPE_MAP: { [T in MediaType]: string } = { const MIMETYPE_MAP: { [T in MediaType]: string } = {
image: 'image/jpeg', image: 'image/jpeg',
video: 'video/mp4', video: 'video/mp4',
document: 'application/pdf', document: 'application/pdf',
audio: 'audio/ogg; codecs=opus', audio: 'audio/ogg; codecs=opus',
sticker: 'image/webp', sticker: 'image/webp',
history: 'application/x-protobuf', history: 'application/x-protobuf',
"md-app-state": 'application/x-protobuf', 'md-app-state': 'application/x-protobuf',
} }
const MessageTypeProto = { const MessageTypeProto = {
'image': WAProto.ImageMessage, 'image': WAProto.ImageMessage,
'video': WAProto.VideoMessage, 'video': WAProto.VideoMessage,
'audio': WAProto.AudioMessage, 'audio': WAProto.AudioMessage,
'sticker': WAProto.StickerMessage, 'sticker': WAProto.StickerMessage,
'document': WAProto.DocumentMessage, 'document': WAProto.DocumentMessage,
} as const } as const
@@ -64,6 +63,7 @@ export const prepareWAMessageMedia = async(
mediaType = key mediaType = key
} }
} }
const uploadData: MediaUploadData = { const uploadData: MediaUploadData = {
...message, ...message,
media: message[mediaType] media: message[mediaType]
@@ -74,13 +74,14 @@ export const prepareWAMessageMedia = async(
('url' in uploadData.media) && ('url' in uploadData.media) &&
!!uploadData.media.url && !!uploadData.media.url &&
!!options.mediaCache && ( !!options.mediaCache && (
// generate the key // generate the key
mediaType + ':' + uploadData.media.url!.toString() mediaType + ':' + uploadData.media.url!.toString()
) )
if(mediaType === 'document' && !uploadData.fileName) { if(mediaType === 'document' && !uploadData.fileName) {
uploadData.fileName = 'file' uploadData.fileName = 'file'
} }
if(!uploadData.mimetype) { if(!uploadData.mimetype) {
uploadData.mimetype = MIMETYPE_MAP[mediaType] uploadData.mimetype = MIMETYPE_MAP[mediaType]
} }
@@ -89,7 +90,7 @@ export const prepareWAMessageMedia = async(
if(cacheableKey) { if(cacheableKey) {
const mediaBuff: Buffer = options.mediaCache!.get(cacheableKey) const mediaBuff: Buffer = options.mediaCache!.get(cacheableKey)
if(mediaBuff) { if(mediaBuff) {
logger?.debug({ cacheableKey }, `got media cache hit`) logger?.debug({ cacheableKey }, 'got media cache hit')
const obj = WAProto.Message.decode(mediaBuff) const obj = WAProto.Message.decode(mediaBuff)
const key = `${mediaType}Message` const key = `${mediaType}Message`
@@ -117,9 +118,9 @@ export const prepareWAMessageMedia = async(
// url safe Base64 encode the SHA256 hash of the body // url safe Base64 encode the SHA256 hash of the body
const fileEncSha256B64 = encodeURIComponent( const fileEncSha256B64 = encodeURIComponent(
fileEncSha256.toString('base64') fileEncSha256.toString('base64')
.replace(/\+/g, '-') .replace(/\+/g, '-')
.replace(/\//g, '_') .replace(/\//g, '_')
.replace(/\=+$/, '') .replace(/\=+$/, '')
) )
const [{ mediaUrl, directPath }] = await Promise.all([ const [{ mediaUrl, directPath }] = await Promise.all([
@@ -128,34 +129,35 @@ export const prepareWAMessageMedia = async(
encWriteStream, encWriteStream,
{ fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs } { fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs }
) )
logger?.debug(`uploaded media`) logger?.debug('uploaded media')
return result return result
})(), })(),
(async() => { (async() => {
try { try {
if(requiresThumbnailComputation) { if(requiresThumbnailComputation) {
uploadData.jpegThumbnail = await generateThumbnail(bodyPath, mediaType as any, options) uploadData.jpegThumbnail = await generateThumbnail(bodyPath, mediaType as any, options)
logger?.debug(`generated thumbnail`) logger?.debug('generated thumbnail')
} }
if (requiresDurationComputation) {
if(requiresDurationComputation) {
uploadData.seconds = await getAudioDuration(bodyPath) uploadData.seconds = await getAudioDuration(bodyPath)
logger?.debug(`computed audio duration`) logger?.debug('computed audio duration')
} }
} catch (error) { } catch(error) {
logger?.warn({ trace: error.stack }, 'failed to obtain extra info') logger?.warn({ trace: error.stack }, 'failed to obtain extra info')
} }
})(), })(),
]) ])
.finally( .finally(
async() => { async() => {
encWriteStream.destroy() encWriteStream.destroy()
// remove tmp files // remove tmp files
if(didSaveToTmpPath && bodyPath) { if(didSaveToTmpPath && bodyPath) {
await fs.unlink(bodyPath) await fs.unlink(bodyPath)
logger?.debug('removed tmp files') logger?.debug('removed tmp files')
}
} }
} )
)
delete uploadData.media delete uploadData.media
@@ -175,12 +177,13 @@ export const prepareWAMessageMedia = async(
}) })
if(cacheableKey) { if(cacheableKey) {
logger.debug({ cacheableKey }, `set cache`) logger.debug({ cacheableKey }, 'set cache')
options.mediaCache!.set(cacheableKey, WAProto.Message.encode(obj).finish()) options.mediaCache!.set(cacheableKey, WAProto.Message.encode(obj).finish())
} }
return obj return obj
} }
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => { export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
ephemeralExpiration = ephemeralExpiration || 0 ephemeralExpiration = ephemeralExpiration || 0
const content: WAMessageContent = { const content: WAMessageContent = {
@@ -195,6 +198,7 @@ export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: n
} }
return WAProto.Message.fromObject(content) return WAProto.Message.fromObject(content)
} }
/** /**
* Generate forwarded message content like WA does * Generate forwarded message content like WA does
* @param message the message to forward * @param message the message to forward
@@ -205,7 +209,10 @@ export const generateForwardMessageContent = (
forceForward?: boolean forceForward?: boolean
) => { ) => {
let content = message.message let content = message.message
if (!content) throw new Boom('no content in message', { statusCode: 400 }) if(!content) {
throw new Boom('no content in message', { statusCode: 400 })
}
// hacky copy // hacky copy
content = proto.Message.decode(proto.Message.encode(message.message).finish()) content = proto.Message.decode(proto.Message.encode(message.message).finish())
@@ -213,17 +220,22 @@ export const generateForwardMessageContent = (
let score = content[key].contextInfo?.forwardingScore || 0 let score = content[key].contextInfo?.forwardingScore || 0
score += message.key.fromMe && !forceForward ? 0 : 1 score += message.key.fromMe && !forceForward ? 0 : 1
if (key === 'conversation') { if(key === 'conversation') {
content.extendedTextMessage = { text: content[key] } content.extendedTextMessage = { text: content[key] }
delete content.conversation delete content.conversation
key = 'extendedTextMessage' key = 'extendedTextMessage'
} }
if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true }
else content[key].contextInfo = {} if(score > 0) {
content[key].contextInfo = { forwardingScore: score, isForwarded: true }
} else {
content[key].contextInfo = {}
}
return content return content
} }
export const generateWAMessageContent = async( export const generateWAMessageContent = async(
message: AnyMessageContent, message: AnyMessageContent,
options: MessageContentGenerationOptions options: MessageContentGenerationOptions
@@ -231,7 +243,7 @@ export const generateWAMessageContent = async(
let m: WAMessageContent = {} let m: WAMessageContent = {}
if('text' in message) { if('text' in message) {
const extContent = { ...message } as WATextMessage const extContent = { ...message } as WATextMessage
if (!!options.getUrlInfo && message.text.match(URL_REGEX)) { if(!!options.getUrlInfo && message.text.match(URL_REGEX)) {
try { try {
const data = await options.getUrlInfo(message.text) const data = await options.getUrlInfo(message.text)
extContent.canonicalUrl = data['canonical-url'] extContent.canonicalUrl = data['canonical-url']
@@ -240,16 +252,18 @@ export const generateWAMessageContent = async(
extContent.description = data.description extContent.description = data.description
extContent.title = data.title extContent.title = data.title
extContent.previewType = 0 extContent.previewType = 0
} catch (error) { // ignore if fails } catch(error) { // ignore if fails
options.logger?.warn({ trace: error.stack }, 'url generation failed') options.logger?.warn({ trace: error.stack }, 'url generation failed')
} }
} }
m.extendedTextMessage = extContent m.extendedTextMessage = extContent
} else if('contacts' in message) { } else if('contacts' in message) {
const contactLen = message.contacts.contacts.length const contactLen = message.contacts.contacts.length
if(!contactLen) { if(!contactLen) {
throw new Boom('require atleast 1 contact', { statusCode: 400 }) throw new Boom('require atleast 1 contact', { statusCode: 400 })
} }
if(contactLen === 1) { if(contactLen === 1) {
m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0]) m.contactMessage = WAProto.ContactMessage.fromObject(message.contacts.contacts[0])
} else { } else {
@@ -269,8 +283,8 @@ export const generateWAMessageContent = async(
) )
} else if('disappearingMessagesInChat' in message) { } else if('disappearingMessagesInChat' in message) {
const exp = typeof message.disappearingMessagesInChat === 'boolean' ? const exp = typeof message.disappearingMessagesInChat === 'boolean' ?
(message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) : (message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
message.disappearingMessagesInChat message.disappearingMessagesInChat
m = prepareDisappearingMessageSettingContent(exp) m = prepareDisappearingMessageSettingContent(exp)
} else { } else {
m = await prepareWAMessageMedia( m = await prepareWAMessageMedia(
@@ -278,6 +292,7 @@ export const generateWAMessageContent = async(
options options
) )
} }
if('buttons' in message && !!message.buttons) { if('buttons' in message && !!message.buttons) {
const buttonsMessage: proto.IButtonsMessage = { const buttonsMessage: proto.IButtonsMessage = {
buttons: message.buttons!.map(b => ({ ...b, type: proto.Button.ButtonType.RESPONSE })) buttons: message.buttons!.map(b => ({ ...b, type: proto.Button.ButtonType.RESPONSE }))
@@ -289,13 +304,14 @@ export const generateWAMessageContent = async(
if('caption' in message) { if('caption' in message) {
buttonsMessage.contentText = message.caption buttonsMessage.contentText = message.caption
} }
const type = Object.keys(m)[0].replace('Message', '').toUpperCase() const type = Object.keys(m)[0].replace('Message', '').toUpperCase()
buttonsMessage.headerType = ButtonType[type] buttonsMessage.headerType = ButtonType[type]
Object.assign(buttonsMessage, m) Object.assign(buttonsMessage, m)
} }
if ('footer' in message && !!message.footer) { if('footer' in message && !!message.footer) {
buttonsMessage.footerText = message.footer buttonsMessage.footerText = message.footer
} }
@@ -325,7 +341,7 @@ export const generateWAMessageContent = async(
m = { templateMessage } m = { templateMessage }
} }
if ('sections' in message && !!message.sections) { if('sections' in message && !!message.sections) {
const listMessage: proto.IListMessage = { const listMessage: proto.IListMessage = {
sections: message.sections, sections: message.sections,
buttonText: message.buttonText, buttonText: message.buttonText,
@@ -341,19 +357,24 @@ export const generateWAMessageContent = async(
if('viewOnce' in message && !!message.viewOnce) { if('viewOnce' in message && !!message.viewOnce) {
m = { viewOnceMessage: { message: m } } m = { viewOnceMessage: { message: m } }
} }
if('mentions' in message && message.mentions?.length) { if('mentions' in message && message.mentions?.length) {
const [messageType] = Object.keys(m) const [messageType] = Object.keys(m)
m[messageType].contextInfo = m[messageType] || { } m[messageType].contextInfo = m[messageType] || { }
m[messageType].contextInfo.mentionedJid = message.mentions m[messageType].contextInfo.mentionedJid = message.mentions
} }
return WAProto.Message.fromObject(m) return WAProto.Message.fromObject(m)
} }
export const generateWAMessageFromContent = ( export const generateWAMessageFromContent = (
jid: string, jid: string,
message: WAMessageContent, message: WAMessageContent,
options: MessageGenerationOptionsFromContent options: MessageGenerationOptionsFromContent
) => { ) => {
if(!options.timestamp) options.timestamp = new Date() // set timestamp to now if(!options.timestamp) {
options.timestamp = new Date()
} // set timestamp to now
const key = Object.keys(message)[0] const key = Object.keys(message)[0]
const timestamp = unixTimestampSeconds(options.timestamp) const timestamp = unixTimestampSeconds(options.timestamp)
@@ -373,6 +394,7 @@ export const generateWAMessageFromContent = (
message[key].contextInfo.remoteJid = quoted.key.remoteJid message[key].contextInfo.remoteJid = quoted.key.remoteJid
} }
} }
if( if(
// if we want to send a disappearing message // if we want to send a disappearing message
!!options?.ephemeralExpiration && !!options?.ephemeralExpiration &&
@@ -409,6 +431,7 @@ export const generateWAMessageFromContent = (
} }
return WAProto.WebMessageInfo.fromObject(messageJSON) return WAProto.WebMessageInfo.fromObject(messageJSON)
} }
export const generateWAMessage = async( export const generateWAMessage = async(
jid: string, jid: string,
content: AnyMessageContent, content: AnyMessageContent,
@@ -434,6 +457,7 @@ export const getContentType = (content: WAProto.IMessage | undefined) => {
return key as keyof typeof content return key as keyof typeof content
} }
} }
/** /**
* Extract the true message content from a message * Extract the true message content from a message
* Eg. extracts the inner message from a disappearing message/view once message * Eg. extracts the inner message from a disappearing message/view once message
@@ -447,17 +471,18 @@ export const extractMessageContent = (content: WAMessageContent | undefined | nu
if(content?.buttonsMessage) { if(content?.buttonsMessage) {
const { buttonsMessage } = content const { buttonsMessage } = content
if(buttonsMessage.imageMessage) { if(buttonsMessage.imageMessage) {
return { imageMessage: buttonsMessage.imageMessage } return { imageMessage: buttonsMessage.imageMessage }
} else if(buttonsMessage.documentMessage) { } else if(buttonsMessage.documentMessage) {
return { documentMessage: buttonsMessage.documentMessage } return { documentMessage: buttonsMessage.documentMessage }
} else if(buttonsMessage.videoMessage) { } else if(buttonsMessage.videoMessage) {
return { videoMessage: buttonsMessage.videoMessage } return { videoMessage: buttonsMessage.videoMessage }
} else if(buttonsMessage.locationMessage) { } else if(buttonsMessage.locationMessage) {
return { locationMessage: buttonsMessage.locationMessage } return { locationMessage: buttonsMessage.locationMessage }
} else { } else {
return { conversation: buttonsMessage.contentText } return { conversation: buttonsMessage.contentText }
} }
} }
return content return content
} }
@@ -465,6 +490,6 @@ export const extractMessageContent = (content: WAMessageContent | undefined | nu
* Returns the device predicted by message ID * Returns the device predicted by message ID
*/ */
export const getDevice = (id: string) => { export const getDevice = (id: string) => {
const deviceType = id.length > 21 ? 'android' : id.substring(0, 2) == '3A' ? 'ios' : 'web' const deviceType = id.length > 21 ? 'android' : id.substring(0, 2) === '3A' ? 'ios' : 'web'
return deviceType return deviceType
} }

View File

@@ -1,15 +1,15 @@
import { sha256, Curve, hkdf } from "./crypto"; import { Boom } from '@hapi/boom'
import { Binary } from "../WABinary"; import { createCipheriv, createDecipheriv } from 'crypto'
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' import { proto } from '../../WAProto'
import { NOISE_MODE, NOISE_WA_HEADER } from '../Defaults'
import { KeyPair } from '../Types'
import { Binary } from '../WABinary'
import { BinaryNode, decodeBinaryNode } from '../WABinary'
import { Curve, hkdf, sha256 } from './crypto'
const generateIV = (counter: number) => { const generateIV = (counter: number) => {
const iv = new ArrayBuffer(12); const iv = new ArrayBuffer(12)
new DataView(iv).setUint32(8, counter); new DataView(iv).setUint32(8, counter)
return new Uint8Array(iv) return new Uint8Array(iv)
} }
@@ -18,9 +18,10 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
const authenticate = (data: Uint8Array) => { const authenticate = (data: Uint8Array) => {
if(!isFinished) { if(!isFinished) {
hash = sha256( Buffer.from(Binary.build(hash, data).readByteArray()) ) hash = sha256(Buffer.from(Binary.build(hash, data).readByteArray()))
} }
} }
const encrypt = (plaintext: Uint8Array) => { const encrypt = (plaintext: Uint8Array) => {
const authTagLength = 128 >> 3 const authTagLength = 128 >> 3
const cipher = createCipheriv('aes-256-gcm', encKey, generateIV(writeCounter), { authTagLength }) const cipher = createCipheriv('aes-256-gcm', encKey, generateIV(writeCounter), { authTagLength })
@@ -33,6 +34,7 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
authenticate(result) authenticate(result)
return result return result
} }
const decrypt = (ciphertext: Uint8Array) => { const decrypt = (ciphertext: Uint8Array) => {
// before the handshake is finished, we use the same counter // before the handshake is finished, we use the same counter
// after handshake, the counters are different // after handshake, the counters are different
@@ -48,16 +50,21 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
const result = Buffer.concat([cipher.update(enc), cipher.final()]) const result = Buffer.concat([cipher.update(enc), cipher.final()])
if(isFinished) readCounter += 1 if(isFinished) {
else writeCounter += 1 readCounter += 1
} else {
writeCounter += 1
}
authenticate(ciphertext) authenticate(ciphertext)
return result return result
} }
const localHKDF = (data: Uint8Array) => { const localHKDF = (data: Uint8Array) => {
const key = hkdf(Buffer.from(data), 64, { salt, info: '' }) const key = hkdf(Buffer.from(data), 64, { salt, info: '' })
return [key.slice(0, 32), key.slice(32)] return [key.slice(0, 32), key.slice(32)]
} }
const mixIntoKey = (data: Uint8Array) => { const mixIntoKey = (data: Uint8Array) => {
const [write, read] = localHKDF(data) const [write, read] = localHKDF(data)
salt = write salt = write
@@ -66,15 +73,16 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
readCounter = 0 readCounter = 0
writeCounter = 0 writeCounter = 0
} }
const finishInit = () => { const finishInit = () => {
const [write, read] = localHKDF(new Uint8Array(0)) const [write, read] = localHKDF(new Uint8Array(0))
encKey = write encKey = write
decKey = read decKey = read
hash = Buffer.from([]) hash = Buffer.from([])
readCounter = 0 readCounter = 0
writeCounter = 0 writeCounter = 0
isFinished = true isFinished = true
} }
const data = Binary.build(NOISE_MODE).readBuffer() const data = Binary.build(NOISE_MODE).readBuffer()
let hash = Buffer.from(data.byteLength === 32 ? data : sha256(Buffer.from(data))) let hash = Buffer.from(data.byteLength === 32 ? data : sha256(Buffer.from(data)))
@@ -123,11 +131,12 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
if(isFinished) { if(isFinished) {
data = encrypt(data) data = encrypt(data)
} }
const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length
outBinary.ensureAdditionalCapacity(introSize + 3 + data.byteLength) outBinary.ensureAdditionalCapacity(introSize + 3 + data.byteLength)
if (!sentIntro) { if(!sentIntro) {
outBinary.writeByteArray(NOISE_WA_HEADER) outBinary.writeByteArray(NOISE_WA_HEADER)
sentIntro = true sentIntro = true
} }
@@ -146,6 +155,7 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
const getBytesSize = () => { const getBytesSize = () => {
return (inBinary.readUint8() << 16) | inBinary.readUint16() return (inBinary.readUint8() << 16) | inBinary.readUint16()
} }
const peekSize = () => { const peekSize = () => {
return !(inBinary.size() < 3) && getBytesSize() <= inBinary.size() return !(inBinary.size() < 3) && getBytesSize() <= inBinary.size()
} }
@@ -159,8 +169,10 @@ export const makeNoiseHandler = ({ public: publicKey, private: privateKey }: Key
const unpacked = new Binary(result).decompressed() const unpacked = new Binary(result).decompressed()
frame = decodeBinaryNode(unpacked) frame = decodeBinaryNode(unpacked)
} }
onFrame(frame) onFrame(frame)
} }
inBinary.peek(peekSize) inBinary.peek(peekSize)
} }
} }

View File

@@ -1,10 +1,10 @@
import * as libsignal from 'libsignal' import * as libsignal from 'libsignal'
import { encodeBigEndian } from "./generics" import { proto } from '../../WAProto'
import { Curve } from "./crypto" import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage, SenderKeyName, SenderKeyRecord } from '../../WASignalGroup'
import { SenderKeyDistributionMessage, GroupSessionBuilder, SenderKeyRecord, SenderKeyName, GroupCipher } from '../../WASignalGroup' import { AuthenticationCreds, KeyPair, SignalAuthState, SignalIdentity, SignalKeyStore, SignedKeyPair } from '../Types/Auth'
import { SignalIdentity, SignalKeyStore, SignedKeyPair, KeyPair, SignalAuthState, AuthenticationCreds } from "../Types/Auth" import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildUInt, jidDecode, JidWithDevice } from '../WABinary'
import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildUInt, jidDecode, JidWithDevice, getBinaryNodeChildren } from "../WABinary" import { Curve } from './crypto'
import { proto } from "../../WAProto" import { encodeBigEndian } from './generics'
export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => { export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => {
const newPub = Buffer.alloc(33) const newPub = Buffer.alloc(33)
@@ -38,12 +38,13 @@ export const getPreKeys = async({ get }: SignalKeyStore, min: number, limit: num
for(let id = min; id < limit;id++) { for(let id = min; id < limit;id++) {
idList.push(id.toString()) idList.push(id.toString())
} }
return get('pre-key', idList) return get('pre-key', idList)
} }
export const generateOrGetPreKeys = (creds: AuthenticationCreds, range: number) => { export const generateOrGetPreKeys = (creds: AuthenticationCreds, range: number) => {
const avaliable = creds.nextPreKeyId - creds.firstUnuploadedPreKeyId const avaliable = creds.nextPreKeyId - creds.firstUnuploadedPreKeyId
const remaining = range - avaliable const remaining = range - avaliable
const lastPreKeyId = creds.nextPreKeyId + remaining - 1 const lastPreKeyId = creds.nextPreKeyId + remaining - 1
const newPreKeys: { [id: number]: KeyPair } = { } const newPreKeys: { [id: number]: KeyPair } = { }
if(remaining > 0) { if(remaining > 0) {
@@ -51,6 +52,7 @@ export const generateOrGetPreKeys = (creds: AuthenticationCreds, range: number)
newPreKeys[i] = Curve.generateKeyPair() newPreKeys[i] = Curve.generateKeyPair()
} }
} }
return { return {
newPreKeys, newPreKeys,
lastPreKeyId, lastPreKeyId,
@@ -83,7 +85,7 @@ export const xmppPreKey = (pair: KeyPair, id: number): BinaryNode => (
) )
export const signalStorage = ({ creds, keys }: SignalAuthState) => ({ export const signalStorage = ({ creds, keys }: SignalAuthState) => ({
loadSession: async (id: string) => { loadSession: async(id: string) => {
const { [id]: sess } = await keys.get('session', [id]) const { [id]: sess } = await keys.get('session', [id])
if(sess) { if(sess) {
return libsignal.SessionRecord.deserialize(sess) return libsignal.SessionRecord.deserialize(sess)
@@ -115,7 +117,9 @@ export const signalStorage = ({ creds, keys }: SignalAuthState) => ({
}, },
loadSenderKey: async(keyId: string) => { loadSenderKey: async(keyId: string) => {
const { [keyId]: key } = await keys.get('sender-key', [keyId]) const { [keyId]: key } = await keys.get('sender-key', [keyId])
if(key) return new SenderKeyRecord(key) if(key) {
return new SenderKeyRecord(key)
}
}, },
storeSenderKey: async(keyId, key) => { storeSenderKey: async(keyId, key) => {
await keys.set({ 'sender-key': { [keyId]: key.serialize() } }) await keys.set({ 'sender-key': { [keyId]: key.serialize() } })
@@ -144,7 +148,7 @@ export const processSenderKeyMessage = async(
item: proto.ISenderKeyDistributionMessage, item: proto.ISenderKeyDistributionMessage,
auth: SignalAuthState auth: SignalAuthState
) => { ) => {
const builder = new GroupSessionBuilder(signalStorage(auth)) const builder = new GroupSessionBuilder(signalStorage(auth))
const senderName = jidToSignalSenderKeyName(item.groupId, authorJid) const senderName = jidToSignalSenderKeyName(item.groupId, authorJid)
const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage) const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage)
@@ -153,6 +157,7 @@ export const processSenderKeyMessage = async(
const record = new SenderKeyRecord() const record = new SenderKeyRecord()
await auth.keys.set({ 'sender-key': { [senderName]: record } }) await auth.keys.set({ 'sender-key': { [senderName]: record } })
} }
await builder.process(senderName, senderMsg) await builder.process(senderName, senderMsg)
} }
@@ -160,14 +165,15 @@ export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg
const addr = jidToSignalProtocolAddress(user) const addr = jidToSignalProtocolAddress(user)
const session = new libsignal.SessionCipher(signalStorage(auth), addr) const session = new libsignal.SessionCipher(signalStorage(auth), addr)
let result: Buffer let result: Buffer
switch(type) { switch (type) {
case 'pkmsg': case 'pkmsg':
result = await session.decryptPreKeyWhisperMessage(msg) result = await session.decryptPreKeyWhisperMessage(msg)
break break
case 'msg': case 'msg':
result = await session.decryptWhisperMessage(msg) result = await session.decryptWhisperMessage(msg)
break break
} }
return result return result
} }
@@ -205,17 +211,18 @@ export const encryptSenderKeyMsgSignalProto = async(group: string, data: Uint8Ar
export const parseAndInjectE2ESessions = async(node: BinaryNode, auth: SignalAuthState) => { export const parseAndInjectE2ESessions = async(node: BinaryNode, auth: SignalAuthState) => {
const extractKey = (key: BinaryNode) => ( const extractKey = (key: BinaryNode) => (
key ? ({ key ? ({
keyId: getBinaryNodeChildUInt(key, 'id', 3), keyId: getBinaryNodeChildUInt(key, 'id', 3),
publicKey: generateSignalPubKey( publicKey: generateSignalPubKey(
getBinaryNodeChildBuffer(key, 'value') getBinaryNodeChildBuffer(key, 'value')
), ),
signature: getBinaryNodeChildBuffer(key, 'signature'), signature: getBinaryNodeChildBuffer(key, 'signature'),
}) : undefined }) : undefined
) )
const nodes = getBinaryNodeChildren(getBinaryNodeChild(node, 'list'), 'user') const nodes = getBinaryNodeChildren(getBinaryNodeChild(node, 'list'), 'user')
for(const node of nodes) { for(const node of nodes) {
assertNodeErrorFree(node) assertNodeErrorFree(node)
} }
await Promise.all( await Promise.all(
nodes.map( nodes.map(
async node => { async node => {
@@ -264,5 +271,6 @@ export const extractDeviceJids = (result: BinaryNode, myJid: string, excludeZero
} }
} }
} }
return extracted return extracted
} }

View File

@@ -1,152 +1,152 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import type { SocketConfig, AuthenticationCreds, SignalCreds } from "../Types" import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types'
import { Binary, BinaryNode, getAllBinaryNodeChildren, jidDecode, S_WHATSAPP_NET } from '../WABinary'
import { Curve, hmacSign } from './crypto' import { Curve, hmacSign } from './crypto'
import { encodeInt } from './generics' import { encodeInt } from './generics'
import { BinaryNode, S_WHATSAPP_NET, jidDecode, Binary, getAllBinaryNodeChildren } from '../WABinary'
import { createSignalIdentity } from './signal' import { createSignalIdentity } from './signal'
const ENCODED_VERSION = 'S9Kdc4pc4EJryo21snc5cg==' const ENCODED_VERSION = 'S9Kdc4pc4EJryo21snc5cg=='
const getUserAgent = ({ version, browser }: Pick<SocketConfig, 'version' | 'browser'>) => ({ const getUserAgent = ({ version, browser }: Pick<SocketConfig, 'version' | 'browser'>) => ({
appVersion: { appVersion: {
primary: version[0], primary: version[0],
secondary: version[1], secondary: version[1],
tertiary: version[2], tertiary: version[2],
}, },
platform: 14, platform: 14,
releaseChannel: 0, releaseChannel: 0,
mcc: "000", mcc: '000',
mnc: "000", mnc: '000',
osVersion: browser[2], osVersion: browser[2],
manufacturer: "", manufacturer: '',
device: browser[1], device: browser[1],
osBuildNumber: "0.1", osBuildNumber: '0.1',
localeLanguageIso6391: 'en', localeLanguageIso6391: 'en',
localeCountryIso31661Alpha2: 'en', localeCountryIso31661Alpha2: 'en',
}) })
export const generateLoginNode = (userJid: string, config: Pick<SocketConfig, 'version' | 'browser'>) => { export const generateLoginNode = (userJid: string, config: Pick<SocketConfig, 'version' | 'browser'>) => {
const { user, device } = jidDecode(userJid) const { user, device } = jidDecode(userJid)
const payload = { const payload = {
passive: true, passive: true,
connectType: 1, connectType: 1,
connectReason: 1, connectReason: 1,
userAgent: getUserAgent(config), userAgent: getUserAgent(config),
webInfo: { webSubPlatform: 0 }, webInfo: { webSubPlatform: 0 },
username: parseInt(user, 10), username: parseInt(user, 10),
device: device, device: device,
} }
return proto.ClientPayload.encode(payload).finish() return proto.ClientPayload.encode(payload).finish()
} }
export const generateRegistrationNode = ( export const generateRegistrationNode = (
{ registrationId, signedPreKey, signedIdentityKey }: SignalCreds, { registrationId, signedPreKey, signedIdentityKey }: SignalCreds,
config: Pick<SocketConfig, 'version' | 'browser'> config: Pick<SocketConfig, 'version' | 'browser'>
) => { ) => {
const appVersionBuf = new Uint8Array(Buffer.from(ENCODED_VERSION, "base64")); const appVersionBuf = new Uint8Array(Buffer.from(ENCODED_VERSION, 'base64'))
const companion = { const companion = {
os: config.browser[0], os: config.browser[0],
version: { version: {
primary: 10, primary: 10,
secondary: undefined, secondary: undefined,
tertiary: undefined, tertiary: undefined,
}, },
platformType: 1, platformType: 1,
requireFullSync: false, requireFullSync: false,
}; }
const companionProto = proto.CompanionProps.encode(companion).finish() const companionProto = proto.CompanionProps.encode(companion).finish()
const registerPayload = { const registerPayload = {
connectReason: 1, connectReason: 1,
connectType: 1, connectType: 1,
passive: false, passive: false,
regData: { regData: {
buildHash: appVersionBuf, buildHash: appVersionBuf,
companionProps: companionProto, companionProps: companionProto,
eRegid: encodeInt(4, registrationId), eRegid: encodeInt(4, registrationId),
eKeytype: encodeInt(1, 5), eKeytype: encodeInt(1, 5),
eIdent: signedIdentityKey.public, eIdent: signedIdentityKey.public,
eSkeyId: encodeInt(3, signedPreKey.keyId), eSkeyId: encodeInt(3, signedPreKey.keyId),
eSkeyVal: signedPreKey.keyPair.public, eSkeyVal: signedPreKey.keyPair.public,
eSkeySig: signedPreKey.signature, eSkeySig: signedPreKey.signature,
}, },
userAgent: getUserAgent(config), userAgent: getUserAgent(config),
webInfo: { webInfo: {
webSubPlatform: 0, webSubPlatform: 0,
}, },
} }
return proto.ClientPayload.encode(registerPayload).finish() return proto.ClientPayload.encode(registerPayload).finish()
} }
export const configureSuccessfulPairing = ( export const configureSuccessfulPairing = (
stanza: BinaryNode, stanza: BinaryNode,
{ advSecretKey, signedIdentityKey, signalIdentities }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'> { advSecretKey, signedIdentityKey, signalIdentities }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => { ) => {
const [pair] = getAllBinaryNodeChildren(stanza) const [pair] = getAllBinaryNodeChildren(stanza)
const pairContent = Array.isArray(pair.content) ? pair.content : [] const pairContent = Array.isArray(pair.content) ? pair.content : []
const msgId = stanza.attrs.id const msgId = stanza.attrs.id
const deviceIdentity = pairContent.find(m => m.tag === 'device-identity')?.content const deviceIdentity = pairContent.find(m => m.tag === 'device-identity')?.content
const businessName = pairContent.find(m => m.tag === 'biz')?.attrs?.name const businessName = pairContent.find(m => m.tag === 'biz')?.attrs?.name
const verifiedName = businessName || '' const verifiedName = businessName || ''
const jid = pairContent.find(m => m.tag === 'device')?.attrs?.jid const jid = pairContent.find(m => m.tag === 'device')?.attrs?.jid
const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentity as Buffer) const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentity as Buffer)
const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64')) const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac, advSign) !== 0) { if(Buffer.compare(hmac, advSign) !== 0) {
throw new Boom('Invalid pairing') throw new Boom('Invalid pairing')
} }
const account = proto.ADVSignedDeviceIdentity.decode(details) const account = proto.ADVSignedDeviceIdentity.decode(details)
const { accountSignatureKey, accountSignature } = account const { accountSignatureKey, accountSignature } = account
const accountMsg = Binary.build(new Uint8Array([6, 0]), account.details, signedIdentityKey.public).readByteArray() const accountMsg = Binary.build(new Uint8Array([6, 0]), account.details, signedIdentityKey.public).readByteArray()
if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) { if(!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) {
throw new Boom('Failed to verify account signature') throw new Boom('Failed to verify account signature')
} }
const deviceMsg = Binary.build(new Uint8Array([6, 1]), account.details, signedIdentityKey.public, account.accountSignatureKey).readByteArray() const deviceMsg = Binary.build(new Uint8Array([6, 1]), account.details, signedIdentityKey.public, account.accountSignatureKey).readByteArray()
account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg) account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(jid, accountSignatureKey) const identity = createSignalIdentity(jid, accountSignatureKey)
const keyIndex = proto.ADVDeviceIdentity.decode(account.details).keyIndex const keyIndex = proto.ADVDeviceIdentity.decode(account.details).keyIndex
const accountEnc = proto.ADVSignedDeviceIdentity.encode({ const accountEnc = proto.ADVSignedDeviceIdentity.encode({
...account.toJSON(), ...account.toJSON(),
accountSignatureKey: undefined accountSignatureKey: undefined
}).finish() }).finish()
const reply: BinaryNode = { const reply: BinaryNode = {
tag: 'iq', tag: 'iq',
attrs: { attrs: {
to: S_WHATSAPP_NET, to: S_WHATSAPP_NET,
type: 'result', type: 'result',
id: msgId, id: msgId,
}, },
content: [ content: [
{ {
tag: 'pair-device-sign', tag: 'pair-device-sign',
attrs: { }, attrs: { },
content: [ content: [
{ tag: 'device-identity', attrs: { 'key-index': `${keyIndex}` }, content: accountEnc } { tag: 'device-identity', attrs: { 'key-index': `${keyIndex}` }, content: accountEnc }
] ]
} }
] ]
} }
const authUpdate: Partial<AuthenticationCreds> = { const authUpdate: Partial<AuthenticationCreds> = {
account, account,
me: { id: jid, verifiedName }, me: { id: jid, verifiedName },
signalIdentities: [...(signalIdentities || []), identity] signalIdentities: [...(signalIdentities || []), identity]
} }
return { return {
creds: authUpdate, creds: authUpdate,
reply reply
} }
} }

View File

@@ -3,214 +3,239 @@ import { BinaryNode } from '../types'
import { DoubleByteTokens, SingleByteTokens, Tags } from './constants' import { DoubleByteTokens, SingleByteTokens, Tags } from './constants'
export const isLegacyBinaryNode = (buffer: Buffer) => { export const isLegacyBinaryNode = (buffer: Buffer) => {
switch(buffer[0]) { switch (buffer[0]) {
case Tags.LIST_EMPTY: case Tags.LIST_EMPTY:
case Tags.LIST_8: case Tags.LIST_8:
case Tags.LIST_16: case Tags.LIST_16:
return true return true
default: default:
return false return false
} }
} }
function decode(buffer: Buffer, indexRef: { index: number }): BinaryNode { function decode(buffer: Buffer, indexRef: { index: number }): BinaryNode {
const checkEOS = (length: number) => { const checkEOS = (length: number) => {
if (indexRef.index + length > buffer.length) { if(indexRef.index + length > buffer.length) {
throw new Error('end of stream') 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 const next = () => {
return value.toString('utf-8') const value = buffer[indexRef.index]
} indexRef.index += 1
const readBytes = (n: number) => { return value
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 readByte = () => {
const curByte = readByte() checkEOS(1)
value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4)) return next()
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) { const readStringFromChars = (length: number) => {
case Tags.DICTIONARY_0: checkEOS(length)
case Tags.DICTIONARY_1: const value = buffer.slice(indexRef.index, indexRef.index + length)
case Tags.DICTIONARY_2:
case Tags.DICTIONARY_3:
return getTokenDouble(tag - Tags.DICTIONARY_0, readByte())
case Tags.LIST_EMPTY:
return null
case Tags.BINARY_8:
return readStringFromChars(readByte())
case Tags.BINARY_20:
return readStringFromChars(readInt20())
case Tags.BINARY_32:
return readStringFromChars(readInt(4))
case Tags.JID_PAIR:
const i = readString(readByte())
const j = readString(readByte())
if (typeof i === 'string' && j) {
return i + '@' + j
}
throw new Error('invalid jid pair: ' + i + ', ' + j)
case Tags.HEX_8:
case Tags.NIBBLE_8:
return readPacked8(tag)
default:
throw new Error('invalid string with tag: ' + tag)
}
}
const readList = (tag: number) => (
[...new Array(readListSize(tag))].map(() => decode(buffer, indexRef))
)
const getTokenDouble = (index1: number, index2: number) => {
const n = 256 * index1 + index2
if (n < 0 || n > DoubleByteTokens.length) {
throw new Error('invalid double token index: ' + n)
}
return DoubleByteTokens[n]
}
const listSize = readListSize(readByte()) indexRef.index += length
const descrTag = readByte() return value.toString('utf-8')
if (descrTag === Tags.STREAM_END) { }
throw new Error('unexpected stream end')
} const readBytes = (n: number) => {
const header = readString(descrTag) checkEOS(n)
const value = buffer.slice(indexRef.index, indexRef.index + n)
indexRef.index += n
return value
}
const readInt = (n: number, littleEndian = false) => {
checkEOS(n)
let val = 0
for(let i = 0; i < n; i++) {
const shift = littleEndian ? i : n - 1 - i
val |= next() << (shift * 8)
}
return val
}
const readInt20 = () => {
checkEOS(3)
return ((next() & 15) << 16) + (next() << 8) + next()
}
const unpackHex = (value: number) => {
if(value >= 0 && value < 16) {
return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10
}
throw new Error('invalid hex: ' + value)
}
const unpackNibble = (value: number) => {
if(value >= 0 && value <= 9) {
return '0'.charCodeAt(0) + value
}
switch (value) {
case 10:
return '-'.charCodeAt(0)
case 11:
return '.'.charCodeAt(0)
case 15:
return '\0'.charCodeAt(0)
default:
throw new Error('invalid nibble: ' + value)
}
}
const unpackByte = (tag: number, value: number) => {
if(tag === Tags.NIBBLE_8) {
return unpackNibble(value)
} else if(tag === Tags.HEX_8) {
return unpackHex(value)
} else {
throw new Error('unknown tag: ' + tag)
}
}
const readPacked8 = (tag: number) => {
const startByte = readByte()
let value = ''
for(let i = 0; i < (startByte & 127); i++) {
const curByte = readByte()
value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4))
value += String.fromCharCode(unpackByte(tag, curByte & 0x0f))
}
if(startByte >> 7 !== 0) {
value = value.slice(0, -1)
}
return value
}
const isListTag = (tag: number) => {
return tag === Tags.LIST_EMPTY || tag === Tags.LIST_8 || tag === Tags.LIST_16
}
const readListSize = (tag: number) => {
switch (tag) {
case Tags.LIST_EMPTY:
return 0
case Tags.LIST_8:
return readByte()
case Tags.LIST_16:
return readInt(2)
default:
throw new Error('invalid tag for list size: ' + tag)
}
}
const getToken = (index: number) => {
if(index < 3 || index >= SingleByteTokens.length) {
throw new Error('invalid token index: ' + index)
}
return SingleByteTokens[index]
}
const readString = (tag: number) => {
if(tag >= 3 && tag <= 235) {
const token = getToken(tag)
return token// === 's.whatsapp.net' ? 'c.us' : token
}
switch (tag) {
case Tags.DICTIONARY_0:
case Tags.DICTIONARY_1:
case Tags.DICTIONARY_2:
case Tags.DICTIONARY_3:
return getTokenDouble(tag - Tags.DICTIONARY_0, readByte())
case Tags.LIST_EMPTY:
return null
case Tags.BINARY_8:
return readStringFromChars(readByte())
case Tags.BINARY_20:
return readStringFromChars(readInt20())
case Tags.BINARY_32:
return readStringFromChars(readInt(4))
case Tags.JID_PAIR:
const i = readString(readByte())
const j = readString(readByte())
if(typeof i === 'string' && j) {
return i + '@' + j
}
throw new Error('invalid jid pair: ' + i + ', ' + j)
case Tags.HEX_8:
case Tags.NIBBLE_8:
return readPacked8(tag)
default:
throw new Error('invalid string with tag: ' + tag)
}
}
const readList = (tag: number) => (
[...new Array(readListSize(tag))].map(() => decode(buffer, indexRef))
)
const getTokenDouble = (index1: number, index2: number) => {
const n = 256 * index1 + index2
if(n < 0 || n > DoubleByteTokens.length) {
throw new Error('invalid double token index: ' + n)
}
return DoubleByteTokens[n]
}
const listSize = readListSize(readByte())
const descrTag = readByte()
if(descrTag === Tags.STREAM_END) {
throw new Error('unexpected stream end')
}
const header = readString(descrTag)
const attrs: BinaryNode['attrs'] = { } const attrs: BinaryNode['attrs'] = { }
let data: BinaryNode['content'] let data: BinaryNode['content']
if (listSize === 0 || !header) { if(listSize === 0 || !header) {
throw new Error('invalid node') throw new Error('invalid node')
} }
// read the attributes in // read the attributes in
const attributesLength = (listSize - 1) >> 1 const attributesLength = (listSize - 1) >> 1
for (let i = 0; i < attributesLength; i++) { for(let i = 0; i < attributesLength; i++) {
const key = readString(readByte()) const key = readString(readByte())
const b = readByte() const b = readByte()
attrs[key] = readString(b) attrs[key] = readString(b)
} }
if(listSize % 2 === 0) {
const tag = readByte()
if(isListTag(tag)) {
data = readList(tag)
} else {
let decoded: Buffer | string
switch (tag) {
case Tags.BINARY_8:
decoded = readBytes(readByte())
break
case Tags.BINARY_20:
decoded = readBytes(readInt20())
break
case Tags.BINARY_32:
decoded = readBytes(readInt(4))
break
default:
decoded = readString(tag)
break
}
if (listSize % 2 === 0) {
const tag = readByte()
if (isListTag(tag)) {
data = readList(tag)
} else {
let decoded: Buffer | string
switch (tag) {
case Tags.BINARY_8:
decoded = readBytes(readByte())
break
case Tags.BINARY_20:
decoded = readBytes(readInt20())
break
case Tags.BINARY_32:
decoded = readBytes(readInt(4))
break
default:
decoded = readString(tag)
break
}
data = decoded data = decoded
} }
} }
return { return {
tag: header, tag: header,
attrs, attrs,
content: data content: data
@@ -221,85 +246,97 @@ const encode = ({ tag, attrs, content }: BinaryNode, buffer: number[] = []) => {
const pushByte = (value: number) => buffer.push(value & 0xff) const pushByte = (value: number) => buffer.push(value & 0xff)
const pushInt = (value: number, n: number, littleEndian=false) => { const pushInt = (value: number, n: number, littleEndian=false) => {
for (let i = 0; i < n; i++) { for(let i = 0; i < n; i++) {
const curShift = littleEndian ? i : n - 1 - i const curShift = littleEndian ? i : n - 1 - i
buffer.push((value >> (curShift * 8)) & 0xff) buffer.push((value >> (curShift * 8)) & 0xff)
} }
} }
const pushBytes = (bytes: Uint8Array | Buffer | number[]) => (
const pushBytes = (bytes: Uint8Array | Buffer | number[]) => (
bytes.forEach (b => buffer.push(b)) bytes.forEach (b => buffer.push(b))
) )
const pushInt20 = (value: number) => ( const pushInt20 = (value: number) => (
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff]) pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
) )
const writeByteLength = (length: number) => { const writeByteLength = (length: number) => {
if (length >= 4294967296) throw new Error('string too large to encode: ' + length) if(length >= 4294967296) {
throw new Error('string too large to encode: ' + length)
}
if (length >= 1 << 20) { if(length >= 1 << 20) {
pushByte(Tags.BINARY_32) pushByte(Tags.BINARY_32)
pushInt(length, 4) // 32 bit integer pushInt(length, 4) // 32 bit integer
} else if (length >= 256) { } else if(length >= 256) {
pushByte(Tags.BINARY_20) pushByte(Tags.BINARY_20)
pushInt20(length) pushInt20(length)
} else { } else {
pushByte(Tags.BINARY_8) pushByte(Tags.BINARY_8)
pushByte(length) pushByte(length)
} }
} }
const writeStringRaw = (str: string) => {
const bytes = Buffer.from (str, 'utf-8') const writeStringRaw = (str: string) => {
writeByteLength(bytes.length) const bytes = Buffer.from (str, 'utf-8')
pushBytes(bytes) writeByteLength(bytes.length)
} pushBytes(bytes)
const writeToken = (token: number) => { }
if (token < 245) {
pushByte(token) const writeToken = (token: number) => {
} else if (token <= 500) { if(token < 245) {
throw new Error('invalid token') 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 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 tokenIndex = SingleByteTokens.indexOf(token)
if (!i && token === 's.whatsapp.net') {
writeToken(tokenIndex)
} else if (tokenIndex >= 0) {
if (tokenIndex < Tags.SINGLE_BYTE_MAX) {
writeToken(tokenIndex)
} else {
const overflow = tokenIndex - Tags.SINGLE_BYTE_MAX
const dictionaryIndex = overflow >> 8
if (dictionaryIndex < 0 || dictionaryIndex > 3) {
throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex)
}
writeToken(Tags.DICTIONARY_0 + dictionaryIndex)
writeToken(overflow % 256)
}
} else if (token) {
const jidSepIndex = token.indexOf('@')
if (jidSepIndex <= 0) {
writeStringRaw(token)
} else {
writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length))
}
}
}
const writeJid = (left: string, right: string) => {
pushByte(Tags.JID_PAIR)
left && left.length > 0 ? writeString(left) : writeToken(Tags.LIST_EMPTY)
writeString(right)
}
const writeListStart = (listSize: number) => {
if (listSize === 0) {
pushByte(Tags.LIST_EMPTY)
} else if (listSize < 256) {
pushBytes([Tags.LIST_8, listSize])
} else {
pushBytes([Tags.LIST_16, listSize])
}
}
const validAttributes = Object.keys(attrs).filter(k => ( const validAttributes = Object.keys(attrs).filter(k => (
typeof attrs[k] !== 'undefined' && attrs[k] !== null typeof attrs[k] !== 'undefined' && attrs[k] !== null
)) ))
@@ -308,25 +345,27 @@ const encode = ({ tag, attrs, content }: BinaryNode, buffer: number[] = []) => {
writeString(tag) writeString(tag)
validAttributes.forEach((key) => { validAttributes.forEach((key) => {
if(typeof attrs[key] === 'string') { if(typeof attrs[key] === 'string') {
writeString(key) writeString(key)
writeString(attrs[key]) writeString(attrs[key])
} }
}) })
if (typeof content === 'string') { if(typeof content === 'string') {
writeString(content, true) writeString(content, true)
} else if (Buffer.isBuffer(content)) { } else if(Buffer.isBuffer(content)) {
writeByteLength(content.length) writeByteLength(content.length)
pushBytes(content) pushBytes(content)
} else if (Array.isArray(content)) { } else if(Array.isArray(content)) {
writeListStart(content.length) writeListStart(content.length)
for(const item of content) { for(const item of content) {
if(item) encode(item, buffer) if(item) {
encode(item, buffer)
}
} }
} else if(typeof content === 'undefined' || content === null) { } else if(typeof content === 'undefined' || content === null) {
} else { } else {
throw new Error(`invalid children for header "${tag}": ${content} (${typeof content})`) throw new Error(`invalid children for header "${tag}": ${content} (${typeof content})`)
} }

View File

@@ -0,0 +1,81 @@
import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto'
import { BinaryNode } from './types'
// some extra useful utilities
export const getBinaryNodeChildren = ({ content }: BinaryNode, childTag: string) => {
if(Array.isArray(content)) {
return content.filter(item => item.tag === childTag)
}
return []
}
export const getAllBinaryNodeChildren = ({ content }: BinaryNode) => {
if(Array.isArray(content)) {
return content
}
return []
}
export const getBinaryNodeChild = ({ content }: BinaryNode, childTag: string) => {
if(Array.isArray(content)) {
return content.find(item => item.tag === childTag)
}
}
export const getBinaryNodeChildBuffer = (node: BinaryNode, childTag: string) => {
const child = getBinaryNodeChild(node, childTag)?.content
if(Buffer.isBuffer(child) || child instanceof Uint8Array) {
return child
}
}
export const getBinaryNodeChildUInt = (node: BinaryNode, childTag: string, length: number) => {
const buff = getBinaryNodeChildBuffer(node, childTag)
if(buff) {
return bufferToUInt(buff, length)
}
}
export const assertNodeErrorFree = (node: BinaryNode) => {
const errNode = getBinaryNodeChild(node, 'error')
if(errNode) {
throw new Boom(errNode.attrs.text || 'Unknown error', { data: +errNode.attrs.code })
}
}
export const reduceBinaryNodeToDictionary = (node: BinaryNode, tag: string) => {
const nodes = getBinaryNodeChildren(node, tag)
const dict = nodes.reduce(
(dict, { attrs }) => {
dict[attrs.name || attrs.config_code] = attrs.value || attrs.config_value
return dict
}, { } as { [_: string]: string }
)
return dict
}
export const getBinaryNodeMessages = ({ content }: BinaryNode) => {
const msgs: proto.WebMessageInfo[] = []
if(Array.isArray(content)) {
for(const item of content) {
if(item.tag === 'message') {
msgs.push(proto.WebMessageInfo.decode(item.content as Buffer))
}
}
}
return msgs
}
function bufferToUInt(e: Uint8Array | Buffer, t: number) {
let a = 0
for(let i = 0; i < t; i++) {
a = 256 * a + e[i]
}
return a
}

View File

@@ -319,6 +319,7 @@ export const getBinaryNodeMessages = ({ content }: BinaryNode) => {
return msgs return msgs
} }
export * from './generic-utils'
export * from './jid-utils' export * from './jid-utils'
export { Binary } from '../../WABinary/Binary' export { Binary } from '../../WABinary/Binary'
export * from './types' export * from './types'

View File

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

View File

@@ -5,7 +5,7 @@
* This is done for easy serialization, to prevent running into issues with prototypes & * This is done for easy serialization, to prevent running into issues with prototypes &
* to maintain functional code structure * to maintain functional code structure
* */ * */
export type BinaryNode = { export type BinaryNode = {
tag: string tag: string
attrs: { [key: string]: string } attrs: { [key: string]: string }
content?: BinaryNode[] | string | Uint8Array content?: BinaryNode[] | string | Uint8Array

View File

@@ -1,5 +1,5 @@
import makeWASocket from './Socket'
import makeWALegacySocket from './LegacySocket' import makeWALegacySocket from './LegacySocket'
import makeWASocket from './Socket'
export * from '../WAProto' export * from '../WAProto'
export * from './Utils' export * from './Utils'

1026
yarn.lock

File diff suppressed because it is too large Load Diff