mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Implement Legacy Socket Capability in MD Code (#1041)
* feat: add legacy connection * fix: merge conflict errors * feat: functional legacy socket
This commit is contained in:
64
Example/example-legacy.ts
Normal file
64
Example/example-legacy.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import P from "pino"
|
||||
import { Boom } from "@hapi/boom"
|
||||
import { makeWALegacySocket, DisconnectReason, AnyMessageContent, delay, useSingleFileLegacyAuthState } from '../src'
|
||||
|
||||
const { state, saveState } = useSingleFileLegacyAuthState('./auth_info.json')
|
||||
|
||||
// start a connection
|
||||
const startSock = () => {
|
||||
|
||||
const sock = makeWALegacySocket({
|
||||
logger: P({ level: 'debug' }),
|
||||
printQRInTerminal: true,
|
||||
auth: state
|
||||
})
|
||||
|
||||
const sendMessageWTyping = async(msg: AnyMessageContent, jid: string) => {
|
||||
await sock.presenceSubscribe(jid)
|
||||
await delay(500)
|
||||
|
||||
await sock.sendPresenceUpdate('composing', jid)
|
||||
await delay(2000)
|
||||
|
||||
await sock.sendPresenceUpdate('paused', jid)
|
||||
|
||||
await sock.sendWAMessage(jid, msg)
|
||||
}
|
||||
|
||||
sock.ev.on('messages.upsert', async m => {
|
||||
console.log(JSON.stringify(m, undefined, 2))
|
||||
|
||||
const msg = m.messages[0]
|
||||
if(!msg.key.fromMe && m.type === 'notify') {
|
||||
console.log('replying to', m.messages[0].key.remoteJid)
|
||||
await sock!.chatRead(msg.key, 1)
|
||||
await sendMessageWTyping({ text: 'Hello there!' }, msg.key.remoteJid)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
sock.ev.on('messages.update', m => console.log(m))
|
||||
sock.ev.on('presence.update', m => console.log(m))
|
||||
sock.ev.on('chats.update', m => console.log(m))
|
||||
sock.ev.on('contacts.update', m => console.log(m))
|
||||
|
||||
sock.ev.on('connection.update', (update) => {
|
||||
const { connection, lastDisconnect } = update
|
||||
if(connection === 'close') {
|
||||
// reconnect if not logged out
|
||||
if((lastDisconnect.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) {
|
||||
startSock()
|
||||
} else {
|
||||
console.log('connection closed')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('connection update', update)
|
||||
})
|
||||
// listen for when the auth credentials is updated
|
||||
sock.ev.on('creds.update', saveState)
|
||||
|
||||
return sock
|
||||
}
|
||||
|
||||
startSock()
|
||||
@@ -24,6 +24,7 @@
|
||||
"build:docs": "typedoc",
|
||||
"build:tsc": "tsc",
|
||||
"example": "node --inspect -r ts-node/register Example/example.ts",
|
||||
"example:legacy": "node --inspect -r ts-node/register Example/example-legacy.ts",
|
||||
"gen-protobuf": "bash src/BinaryNode/GenerateStatics.sh",
|
||||
"browser-decode": "yarn ts-node src/BrowserMessageDecoding.ts"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import P from "pino"
|
||||
import type { MediaType, SocketConfig } from "../Types"
|
||||
import type { MediaType, SocketConfig, LegacySocketConfig, CommonSocketConfig } from "../Types"
|
||||
import { Browsers } from "../Utils"
|
||||
|
||||
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
||||
@@ -17,11 +17,11 @@ export const NOISE_WA_HEADER = new Uint8Array([87, 65, 5, 2]) // last is "DICT_V
|
||||
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
|
||||
export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi
|
||||
|
||||
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
version: [2, 2146, 9],
|
||||
const BASE_CONNECTION_CONFIG: CommonSocketConfig<any> = {
|
||||
version: [2, 2147, 16],
|
||||
browser: Browsers.baileys('Chrome'),
|
||||
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||
connectTimeoutMs: 20_000,
|
||||
keepAliveIntervalMs: 25_000,
|
||||
logger: P().child({ class: 'baileys' }),
|
||||
@@ -29,9 +29,22 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
emitOwnEvents: true,
|
||||
defaultQueryTimeoutMs: 60_000,
|
||||
customUploadHosts: [],
|
||||
}
|
||||
|
||||
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||
...BASE_CONNECTION_CONFIG,
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
|
||||
getMessage: async() => undefined
|
||||
}
|
||||
|
||||
export const DEFAULT_LEGACY_CONNECTION_CONFIG: LegacySocketConfig = {
|
||||
...BASE_CONNECTION_CONFIG,
|
||||
waWebSocketUrl: 'wss://web.whatsapp.com/ws',
|
||||
phoneResponseTimeMs: 20_000,
|
||||
expectResponseTimeout: 60_000,
|
||||
pendingRequestTimeoutMs: 60_000
|
||||
}
|
||||
|
||||
export const MEDIA_PATH_MAP: { [T in MediaType]: string } = {
|
||||
image: '/mms/image',
|
||||
video: '/mms/video',
|
||||
|
||||
269
src/LegacySocket/auth.ts
Normal file
269
src/LegacySocket/auth.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import EventEmitter from "events"
|
||||
import { LegacyBaileysEventEmitter, BaileysEventMap, LegacySocketConfig, CurveKeyPair, WAInitResponse, ConnectionState, DisconnectReason, LegacyAuthenticationCreds } from "../Types"
|
||||
import { newLegacyAuthCreds, promiseTimeout, computeChallengeResponse, validateNewConnection, Curve } from "../Utils"
|
||||
import { makeSocket } from "./socket"
|
||||
|
||||
const makeAuthSocket = (config: LegacySocketConfig) => {
|
||||
const {
|
||||
logger,
|
||||
version,
|
||||
browser,
|
||||
connectTimeoutMs,
|
||||
pendingRequestTimeoutMs,
|
||||
printQRInTerminal,
|
||||
auth: initialAuthInfo
|
||||
} = config
|
||||
const ev = new EventEmitter() as LegacyBaileysEventEmitter
|
||||
|
||||
const authInfo = initialAuthInfo || newLegacyAuthCreds()
|
||||
|
||||
const state: ConnectionState = {
|
||||
legacy: {
|
||||
phoneConnected: false,
|
||||
},
|
||||
connection: 'connecting',
|
||||
}
|
||||
|
||||
const socket = makeSocket(config)
|
||||
const { ws } = socket
|
||||
let curveKeys: CurveKeyPair
|
||||
let initTimeout: NodeJS.Timeout
|
||||
|
||||
ws.on('phone-connection', ({ value: phoneConnected }) => {
|
||||
if(phoneConnected !== state.legacy.phoneConnected) {
|
||||
updateState({ legacy: { ...state.legacy, phoneConnected } })
|
||||
}
|
||||
})
|
||||
// add close listener
|
||||
ws.on('ws-close', (error: Boom | Error) => {
|
||||
logger.info({ error }, 'Closed connection to WhatsApp')
|
||||
initTimeout && clearTimeout(initTimeout)
|
||||
// if no reconnects occur
|
||||
// send close event
|
||||
updateState({
|
||||
connection: 'close',
|
||||
qr: undefined,
|
||||
lastDisconnect: {
|
||||
error,
|
||||
date: new Date()
|
||||
}
|
||||
})
|
||||
})
|
||||
/** Can you login to WA without scanning the QR */
|
||||
const canLogin = () => !!authInfo?.encKey && !!authInfo?.macKey
|
||||
|
||||
const updateState = (update: Partial<ConnectionState>) => {
|
||||
Object.assign(state, update)
|
||||
ev.emit('connection.update', update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs you out from WA
|
||||
* If connected, invalidates the credentials with the server
|
||||
*/
|
||||
const logout = async() => {
|
||||
if(state.connection === 'open') {
|
||||
await socket.sendMessage({
|
||||
json: ['admin', 'Conn', 'disconnect'],
|
||||
tag: 'goodbye'
|
||||
})
|
||||
}
|
||||
// will call state update to close connection
|
||||
socket?.end(
|
||||
new Boom('Logged Out', { statusCode: DisconnectReason.loggedOut })
|
||||
)
|
||||
}
|
||||
/** Waits for the connection to WA to open up */
|
||||
const waitForConnection = async(waitInfinitely: boolean = false) => {
|
||||
if(state.connection === 'open') return
|
||||
|
||||
let listener: (item: BaileysEventMap<LegacyAuthenticationCreds>['connection.update']) => void
|
||||
const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs
|
||||
if(timeout < 0) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
|
||||
await (
|
||||
promiseTimeout(
|
||||
timeout,
|
||||
(resolve, reject) => {
|
||||
listener = ({ connection, lastDisconnect }) => {
|
||||
if(connection === 'open') resolve()
|
||||
else if(connection == 'close') {
|
||||
reject(lastDisconnect.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
|
||||
}
|
||||
}
|
||||
ev.on('connection.update', listener)
|
||||
}
|
||||
)
|
||||
.finally(() => (
|
||||
ev.off('connection.update', listener)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
const updateEncKeys = () => {
|
||||
// update the keys so we can decrypt traffic
|
||||
socket.updateKeys({ encKey: authInfo!.encKey, macKey: authInfo!.macKey })
|
||||
}
|
||||
|
||||
const generateKeysForAuth = async(ref: string, ttl?: number) => {
|
||||
curveKeys = Curve.generateKeyPair()
|
||||
const publicKey = Buffer.from(curveKeys.public).toString('base64')
|
||||
let qrGens = 0
|
||||
|
||||
const qrLoop = ttl => {
|
||||
const qr = [ref, publicKey, authInfo.clientID].join(',')
|
||||
updateState({ qr })
|
||||
|
||||
initTimeout = setTimeout(async () => {
|
||||
if(state.connection !== 'connecting') return
|
||||
|
||||
logger.debug('regenerating QR')
|
||||
try {
|
||||
// request new QR
|
||||
const {ref: newRef, ttl: newTTL} = await socket.query({
|
||||
json: ['admin', 'Conn', 'reref'],
|
||||
expect200: true,
|
||||
longTag: true,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
ttl = newTTL
|
||||
ref = newRef
|
||||
} catch (error) {
|
||||
logger.error({ error }, `error in QR gen`)
|
||||
if (error.output?.statusCode === 429) { // too many QR requests
|
||||
socket.end(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
qrGens += 1
|
||||
qrLoop(ttl)
|
||||
}, ttl || 20_000) // default is 20s, on the off-chance ttl is not present
|
||||
}
|
||||
qrLoop(ttl)
|
||||
}
|
||||
const onOpen = async() => {
|
||||
const canDoLogin = canLogin()
|
||||
const initQuery = (async () => {
|
||||
const {ref, ttl} = await socket.query({
|
||||
json: ['admin', 'init', version, browser, authInfo.clientID, true],
|
||||
expect200: true,
|
||||
longTag: true,
|
||||
requiresPhoneConnection: false
|
||||
}) as WAInitResponse
|
||||
|
||||
if (!canDoLogin) {
|
||||
generateKeysForAuth(ref, ttl)
|
||||
}
|
||||
})();
|
||||
let loginTag: string
|
||||
if(canDoLogin) {
|
||||
updateEncKeys()
|
||||
// if we have the info to restore a closed session
|
||||
const json = [
|
||||
'admin',
|
||||
'login',
|
||||
authInfo.clientToken,
|
||||
authInfo.serverToken,
|
||||
authInfo.clientID,
|
||||
'takeover'
|
||||
]
|
||||
loginTag = socket.generateMessageTag(true)
|
||||
// send login every 10s
|
||||
const sendLoginReq = () => {
|
||||
if(state.connection === 'open') {
|
||||
logger.warn('Received login timeout req when state=open, ignoring...')
|
||||
return
|
||||
}
|
||||
logger.info('sending login request')
|
||||
socket.sendMessage({
|
||||
json,
|
||||
tag: loginTag
|
||||
})
|
||||
initTimeout = setTimeout(sendLoginReq, 10_000)
|
||||
}
|
||||
sendLoginReq()
|
||||
}
|
||||
await initQuery
|
||||
|
||||
// wait for response with tag "s1"
|
||||
let response = await Promise.race(
|
||||
[
|
||||
socket.waitForMessage('s1', false, undefined).promise,
|
||||
...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs).promise] : [])
|
||||
]
|
||||
)
|
||||
initTimeout && clearTimeout(initTimeout)
|
||||
initTimeout = undefined
|
||||
|
||||
if(response.status && response.status !== 200) {
|
||||
throw new Boom(`Unexpected error in login`, { data: response, statusCode: response.status })
|
||||
}
|
||||
// if its a challenge request (we get it when logging in)
|
||||
if(response[1]?.challenge) {
|
||||
const json = computeChallengeResponse(response[1].challenge, authInfo)
|
||||
logger.info('resolving login challenge')
|
||||
|
||||
await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs })
|
||||
|
||||
response = await socket.waitForMessage('s2', true).promise
|
||||
}
|
||||
if(!response || !response[1]) {
|
||||
throw new Boom('Received unexpected login response', { data: response })
|
||||
}
|
||||
if(response[1].type === 'upgrade_md_prod') {
|
||||
throw new Boom('Require multi-device edition', { statusCode: DisconnectReason.multideviceMismatch })
|
||||
}
|
||||
// validate the new connection
|
||||
const {user, auth} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
|
||||
const isNewLogin = user.id !== state.legacy!.user?.id
|
||||
|
||||
Object.assign(authInfo, auth)
|
||||
updateEncKeys()
|
||||
|
||||
logger.info({ user }, 'logged in')
|
||||
|
||||
ev.emit('creds.update', auth)
|
||||
|
||||
updateState({
|
||||
connection: 'open',
|
||||
legacy: {
|
||||
phoneConnected: true,
|
||||
user,
|
||||
},
|
||||
isNewLogin,
|
||||
qr: undefined
|
||||
})
|
||||
}
|
||||
ws.once('open', async() => {
|
||||
try {
|
||||
await onOpen()
|
||||
} catch(error) {
|
||||
socket.end(error)
|
||||
}
|
||||
})
|
||||
|
||||
if(printQRInTerminal) {
|
||||
ev.on('connection.update', async({ qr }) => {
|
||||
if(qr) {
|
||||
const QR = await import('qrcode-terminal').catch(err => {
|
||||
logger.error('QR code terminal not added as dependency')
|
||||
})
|
||||
QR?.generate(qr, { small: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...socket,
|
||||
ev,
|
||||
getState: () => state,
|
||||
getAuthInfo: () => authInfo,
|
||||
waitForConnection,
|
||||
canLogin,
|
||||
logout
|
||||
}
|
||||
}
|
||||
export default makeAuthSocket
|
||||
503
src/LegacySocket/chats.ts
Normal file
503
src/LegacySocket/chats.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import { BinaryNode, jidNormalizedUser } from "../WABinary";
|
||||
import { Chat, Contact, WAPresence, PresenceData, LegacySocketConfig, WAFlag, WAMetric, WABusinessProfile, ChatModification, WAMessageKey, WAMessageUpdate, BaileysEventMap } from "../Types";
|
||||
import { debouncedTimeout, unixTimestampSeconds } from "../Utils/generics";
|
||||
import makeAuthSocket from "./auth";
|
||||
|
||||
const makeChatsSocket = (config: LegacySocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeAuthSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws: socketEvents,
|
||||
currentEpoch,
|
||||
setQuery,
|
||||
query,
|
||||
sendMessage,
|
||||
getState
|
||||
} = sock
|
||||
|
||||
const chatsDebounceTimeout = debouncedTimeout(10_000, () => sendChatsQuery(1))
|
||||
|
||||
const sendChatsQuery = (epoch: number) => (
|
||||
sendMessage({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {type: 'chat', epoch: epoch.toString()}
|
||||
},
|
||||
binaryTag: [ WAMetric.queryChat, WAFlag.ignore ]
|
||||
})
|
||||
)
|
||||
|
||||
const fetchImageUrl = async(jid: string) => {
|
||||
const response = await query({
|
||||
json: ['query', 'ProfilePicThumb', jid],
|
||||
expect200: false,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
return response.eurl as string | undefined
|
||||
}
|
||||
|
||||
const executeChatModification = (node: BinaryNode) => {
|
||||
const { attrs: attributes } = node
|
||||
const updateType = attributes.type
|
||||
const jid = jidNormalizedUser(attributes?.jid)
|
||||
|
||||
switch(updateType) {
|
||||
case 'delete':
|
||||
ev.emit('chats.delete', [jid])
|
||||
break
|
||||
case 'clear':
|
||||
if(node.content) {
|
||||
const ids = (node.content as BinaryNode[]).map(
|
||||
({ attrs }) => attrs.index
|
||||
)
|
||||
ev.emit('messages.delete', { keys: ids.map(id => ({ id, remoteJid: jid })) })
|
||||
} else {
|
||||
ev.emit('messages.delete', { jid, all: true })
|
||||
}
|
||||
break
|
||||
case 'archive':
|
||||
ev.emit('chats.update', [ { id: jid, archive: true } ])
|
||||
break
|
||||
case 'unarchive':
|
||||
ev.emit('chats.update', [ { id: jid, archive: false } ])
|
||||
break
|
||||
case 'pin':
|
||||
ev.emit('chats.update', [ { id: jid, pin: +attributes.pin } ])
|
||||
break
|
||||
case 'star':
|
||||
case 'unstar':
|
||||
const starred = updateType === 'star'
|
||||
const updates: WAMessageUpdate[] = (node.content as BinaryNode[]).map(
|
||||
({ attrs }) => ({
|
||||
key: {
|
||||
remoteJid: jid,
|
||||
id: attrs.index,
|
||||
fromMe: attrs.owner === 'true'
|
||||
},
|
||||
update: { starred }
|
||||
})
|
||||
)
|
||||
ev.emit('messages.update', updates)
|
||||
break
|
||||
case 'mute':
|
||||
if(attributes.mute === '0') {
|
||||
ev.emit('chats.update', [{ id: jid, mute: null }])
|
||||
} else {
|
||||
ev.emit('chats.update', [{ id: jid, mute: +attributes.mute }])
|
||||
}
|
||||
break
|
||||
default:
|
||||
logger.warn({ node }, `received unrecognized chat update`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const applyingPresenceUpdate = (update: BinaryNode['attrs']): BaileysEventMap<any>['presence.update'] => {
|
||||
const id = jidNormalizedUser(update.id)
|
||||
const participant = jidNormalizedUser(update.participant || update.id)
|
||||
|
||||
const presence: PresenceData = {
|
||||
lastSeen: update.t ? +update.t : undefined,
|
||||
lastKnownPresence: update.type as WAPresence
|
||||
}
|
||||
return { id, presences: { [participant]: presence } }
|
||||
}
|
||||
|
||||
ev.on('connection.update', async({ connection }) => {
|
||||
if(connection !== 'open') return
|
||||
try {
|
||||
await Promise.all([
|
||||
sendMessage({
|
||||
json: { tag: 'query', attrs: {type: 'contacts', epoch: '1'} },
|
||||
binaryTag: [ WAMetric.queryContact, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: { tag: 'query', attrs: {type: 'status', epoch: '1'} },
|
||||
binaryTag: [ WAMetric.queryStatus, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: { tag: 'query', attrs: {type: 'quick_reply', epoch: '1'} },
|
||||
binaryTag: [ WAMetric.queryQuickReply, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: { tag: 'query', attrs: {type: 'label', epoch: '1'} },
|
||||
binaryTag: [ WAMetric.queryLabel, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: { tag: 'query', attrs: {type: 'emoji', epoch: '1'} },
|
||||
binaryTag: [ WAMetric.queryEmoji, WAFlag.ignore ]
|
||||
}),
|
||||
sendMessage({
|
||||
json: {
|
||||
tag: 'action',
|
||||
attrs: { type: 'set', epoch: '1' },
|
||||
content: [
|
||||
{ tag: 'presence', attrs: {type: 'available'} }
|
||||
]
|
||||
},
|
||||
binaryTag: [ WAMetric.presence, WAFlag.available ]
|
||||
})
|
||||
])
|
||||
chatsDebounceTimeout.start()
|
||||
|
||||
logger.debug('sent init queries')
|
||||
} catch(error) {
|
||||
logger.error(`error in sending init queries: ${error}`)
|
||||
}
|
||||
})
|
||||
socketEvents.on('CB:response,type:chat', async ({ content: data }: BinaryNode) => {
|
||||
chatsDebounceTimeout.cancel()
|
||||
if(Array.isArray(data)) {
|
||||
const chats = data.map(({ attrs }): Chat => {
|
||||
return {
|
||||
id: jidNormalizedUser(attrs.jid),
|
||||
conversationTimestamp: attrs.t ? +attrs.t : undefined,
|
||||
unreadCount: +attrs.count,
|
||||
archive: attrs.archive === 'true' ? true : undefined,
|
||||
pin: attrs.pin ? +attrs.pin : undefined,
|
||||
mute: attrs.mute ? +attrs.mute : undefined,
|
||||
notSpam: !(attrs.spam === 'true'),
|
||||
name: attrs.name,
|
||||
ephemeralExpiration: attrs.ephemeral ? +attrs.ephemeral : undefined,
|
||||
ephemeralSettingTimestamp: attrs.eph_setting_ts ? +attrs.eph_setting_ts : undefined,
|
||||
readOnly: attrs.read_only === 'true' ? true : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`got ${chats.length} chats`)
|
||||
ev.emit('chats.set', { chats, messages: [] })
|
||||
}
|
||||
})
|
||||
// got all contacts from phone
|
||||
socketEvents.on('CB:response,type:contacts', async ({ content: data }: BinaryNode) => {
|
||||
if(Array.isArray(data)) {
|
||||
const contacts = data.map(({ attrs }): Contact => {
|
||||
return {
|
||||
id: jidNormalizedUser(attrs.jid),
|
||||
name: attrs.name,
|
||||
notify: attrs.notify,
|
||||
verifiedName: attrs.vname
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`got ${contacts.length} contacts`)
|
||||
ev.emit('contacts.upsert', contacts)
|
||||
}
|
||||
})
|
||||
// status updates
|
||||
socketEvents.on('CB:Status,status', json => {
|
||||
const id = jidNormalizedUser(json[1].id)
|
||||
ev.emit('contacts.update', [ { id, status: json[1].status } ])
|
||||
})
|
||||
// User Profile Name Updates
|
||||
socketEvents.on('CB:Conn,pushname', json => {
|
||||
const { legacy: { user }, connection } = getState()
|
||||
if(connection === 'open' && json[1].pushname !== user.name) {
|
||||
user.name = json[1].pushname
|
||||
ev.emit('connection.update', { legacy: { ...getState().legacy, user } })
|
||||
}
|
||||
})
|
||||
// read updates
|
||||
socketEvents.on ('CB:action,,read', async ({ content }: BinaryNode) => {
|
||||
if(Array.isArray(content)) {
|
||||
const { attrs } = content[0]
|
||||
|
||||
const update: Partial<Chat> = {
|
||||
id: jidNormalizedUser(attrs.jid)
|
||||
}
|
||||
if (attrs.type === 'false') update.unreadCount = -1
|
||||
else update.unreadCount = 0
|
||||
|
||||
ev.emit('chats.update', [update])
|
||||
}
|
||||
})
|
||||
|
||||
socketEvents.on('CB:Cmd,type:picture', async json => {
|
||||
json = json[1]
|
||||
const id = jidNormalizedUser(json.jid)
|
||||
const imgUrl = await fetchImageUrl(id).catch(() => '')
|
||||
|
||||
ev.emit('contacts.update', [ { id, imgUrl } ])
|
||||
})
|
||||
|
||||
// chat archive, pin etc.
|
||||
socketEvents.on('CB:action,,chat', ({ content }: BinaryNode) => {
|
||||
if(Array.isArray(content)) {
|
||||
const [node] = content
|
||||
executeChatModification(node)
|
||||
}
|
||||
})
|
||||
|
||||
socketEvents.on('CB:action,,user', (json: BinaryNode) => {
|
||||
if(Array.isArray(json.content)) {
|
||||
const user = json.content[0].attrs
|
||||
user.id = jidNormalizedUser(user.id)
|
||||
|
||||
//ev.emit('contacts.upsert', [user])
|
||||
}
|
||||
})
|
||||
|
||||
// presence updates
|
||||
socketEvents.on('CB:Presence', json => {
|
||||
const update = applyingPresenceUpdate(json[1])
|
||||
ev.emit('presence.update', update)
|
||||
})
|
||||
|
||||
// blocklist updates
|
||||
socketEvents.on('CB:Blocklist', json => {
|
||||
json = json[1]
|
||||
const blocklist = json.blocklist
|
||||
ev.emit('blocklist.set', { blocklist })
|
||||
})
|
||||
|
||||
return {
|
||||
...sock,
|
||||
sendChatsQuery,
|
||||
fetchImageUrl,
|
||||
chatRead: async(fromMessage: WAMessageKey, count: number) => {
|
||||
await setQuery (
|
||||
[
|
||||
{ tag: 'read',
|
||||
attrs: {
|
||||
jid: fromMessage.remoteJid,
|
||||
count: count.toString(),
|
||||
index: fromMessage.id,
|
||||
owner: fromMessage.fromMe ? 'true' : 'false'
|
||||
}
|
||||
}
|
||||
],
|
||||
[ WAMetric.read, WAFlag.ignore ]
|
||||
)
|
||||
if(config.emitOwnEvents) {
|
||||
ev.emit ('chats.update', [{ id: fromMessage.remoteJid, unreadCount: count < 0 ? -1 : 0 }])
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Modify a given chat (archive, pin etc.)
|
||||
* @param jid the ID of the person/group you are modifiying
|
||||
*/
|
||||
modifyChat: async(jid: string, modification: ChatModification, chatInfo: Pick<Chat, 'mute' | 'pin'>, index?: WAMessageKey) => {
|
||||
let chatAttrs: BinaryNode['attrs'] = { jid: jid }
|
||||
let data: BinaryNode[] | undefined = undefined
|
||||
const stamp = unixTimestampSeconds()
|
||||
|
||||
if('archive' in modification) {
|
||||
chatAttrs.type = modification.archive ? 'archive' : 'unarchive'
|
||||
} else if('pin' in modification) {
|
||||
chatAttrs.type = 'pin'
|
||||
if(modification.pin) {
|
||||
chatAttrs.pin = stamp.toString()
|
||||
} else {
|
||||
chatAttrs.previous = chatInfo.pin!.toString()
|
||||
}
|
||||
} else if('mute' in modification) {
|
||||
chatAttrs.type = 'mute'
|
||||
if(modification.mute) {
|
||||
chatAttrs.mute = (stamp + modification.mute).toString()
|
||||
} else {
|
||||
chatAttrs.previous = chatInfo.mute!.toString()
|
||||
}
|
||||
} else if('clear' in modification) {
|
||||
chatAttrs.type = 'clear'
|
||||
chatAttrs.modify_tag = Math.round(Math.random ()*1000000).toString()
|
||||
if(modification.clear !== 'all') {
|
||||
data = modification.clear.messages.map(({ id, fromMe }) => (
|
||||
{
|
||||
tag: 'item',
|
||||
attrs: { owner: (!!fromMe).toString(), index: id }
|
||||
}
|
||||
))
|
||||
}
|
||||
} else if('star' in modification) {
|
||||
chatAttrs.type = modification.star.star ? 'star' : 'unstar'
|
||||
data = modification.star.messages.map(({ id, fromMe }) => (
|
||||
{
|
||||
tag: 'item',
|
||||
attrs: { owner: (!!fromMe).toString(), index: id }
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
if(index) {
|
||||
chatAttrs.index = index.id
|
||||
chatAttrs.owner = index.fromMe ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const node = { tag: 'chat', attrs: chatAttrs, content: data }
|
||||
const response = await setQuery([node], [ WAMetric.chat, WAFlag.ignore ])
|
||||
// apply it and emit events
|
||||
executeChatModification(node)
|
||||
return response
|
||||
},
|
||||
/**
|
||||
* Query whether a given number is registered on WhatsApp
|
||||
* @param str phone number/jid you want to check for
|
||||
* @returns undefined if the number doesn't exists, otherwise the correctly formatted jid
|
||||
*/
|
||||
isOnWhatsApp: async (str: string) => {
|
||||
const { status, jid, biz } = await query({
|
||||
json: ['query', 'exist', str],
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
if (status === 200) {
|
||||
return {
|
||||
exists: true,
|
||||
jid: jidNormalizedUser(jid),
|
||||
isBusiness: biz as boolean
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Tell someone about your presence -- online, typing, offline etc.
|
||||
* @param jid the ID of the person/group who you are updating
|
||||
* @param type your presence
|
||||
*/
|
||||
sendPresenceUpdate: ( type: WAPresence, jid: string | undefined) => (
|
||||
sendMessage({
|
||||
binaryTag: [WAMetric.presence, WAFlag[type]], // weird stuff WA does
|
||||
json: {
|
||||
tag: 'action',
|
||||
attrs: { epoch: currentEpoch().toString(), type: 'set' },
|
||||
content: [
|
||||
{
|
||||
tag: 'presence',
|
||||
attrs: { type: type, to: jid }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
),
|
||||
/**
|
||||
* Request updates on the presence of a user
|
||||
* this returns nothing, you'll receive updates in chats.update event
|
||||
* */
|
||||
presenceSubscribe: async (jid: string) => (
|
||||
sendMessage({ json: ['action', 'presence', 'subscribe', jid] })
|
||||
),
|
||||
/** Query the status of the person (see groupMetadata() for groups) */
|
||||
getStatus: async(jid: string) => {
|
||||
const status: { status: string } = await query({ json: ['query', 'Status', jid], requiresPhoneConnection: false })
|
||||
return status
|
||||
},
|
||||
setStatus: async(status: string) => {
|
||||
const response = await setQuery(
|
||||
[
|
||||
{
|
||||
tag: 'status',
|
||||
attrs: {},
|
||||
content: Buffer.from (status, 'utf-8')
|
||||
}
|
||||
]
|
||||
)
|
||||
ev.emit('contacts.update', [{ id: getState().legacy!.user!.id, status }])
|
||||
return response
|
||||
},
|
||||
/** Updates business profile. */
|
||||
updateBusinessProfile: async(profile: WABusinessProfile) => {
|
||||
if (profile.business_hours?.config) {
|
||||
profile.business_hours.business_config = profile.business_hours.config
|
||||
delete profile.business_hours.config
|
||||
}
|
||||
const json = ['action', "editBusinessProfile", {...profile, v: 2}]
|
||||
await query({ json, expect200: true, requiresPhoneConnection: true })
|
||||
},
|
||||
updateProfileName: async(name: string) => {
|
||||
const response = (await setQuery(
|
||||
[
|
||||
{
|
||||
tag: 'profile',
|
||||
attrs: { name }
|
||||
}
|
||||
]
|
||||
)) as any as {status: number, pushname: string}
|
||||
|
||||
if(config.emitOwnEvents) {
|
||||
const user = { ...getState().legacy!.user!, name }
|
||||
ev.emit('connection.update', { legacy: {
|
||||
...getState().legacy, user
|
||||
} })
|
||||
ev.emit('contacts.update', [{ id: user.id, name }])
|
||||
}
|
||||
return response
|
||||
},
|
||||
/**
|
||||
* Update the profile picture
|
||||
* @param jid
|
||||
* @param img
|
||||
*/
|
||||
async updateProfilePicture (jid: string, img: Buffer) {
|
||||
jid = jidNormalizedUser (jid)
|
||||
const data = { img: Buffer.from([]), preview: Buffer.from([]) } //await generateProfilePicture(img) TODO
|
||||
const tag = this.generateMessageTag ()
|
||||
const query: BinaryNode = {
|
||||
tag: 'picture',
|
||||
attrs: { jid: jid, id: tag, type: 'set' },
|
||||
content: [
|
||||
{ tag: 'image', attrs: {}, content: data.img },
|
||||
{ tag: 'preview', attrs: {}, content: data.preview }
|
||||
]
|
||||
}
|
||||
|
||||
const user = getState().legacy?.user
|
||||
const { eurl } = await this.setQuery ([query], [WAMetric.picture, 136], tag) as { eurl: string, status: number }
|
||||
|
||||
if(config.emitOwnEvents) {
|
||||
if(jid === user.id) {
|
||||
user.imgUrl = eurl
|
||||
ev.emit('connection.update', {
|
||||
legacy: {
|
||||
...getState().legacy,
|
||||
user
|
||||
}
|
||||
})
|
||||
}
|
||||
ev.emit('contacts.update', [ { id: jid, imgUrl: eurl } ])
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Add or remove user from blocklist
|
||||
* @param jid the ID of the person who you are blocking/unblocking
|
||||
* @param type type of operation
|
||||
*/
|
||||
blockUser: async(jid: string, type: 'add' | 'remove' = 'add') => {
|
||||
const json = {
|
||||
tag: 'block',
|
||||
attrs: { type },
|
||||
content: [ { tag: 'user', attrs: { jid } } ]
|
||||
}
|
||||
await setQuery([json], [WAMetric.block, WAFlag.ignore])
|
||||
if(config.emitOwnEvents) {
|
||||
ev.emit('blocklist.update', { blocklist: [jid], type })
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Query Business Profile (Useful for VCards)
|
||||
* @param jid Business Jid
|
||||
* @returns profile object or undefined if not business account
|
||||
*/
|
||||
getBusinessProfile: async(jid: string) => {
|
||||
jid = jidNormalizedUser(jid)
|
||||
const {
|
||||
profiles: [{
|
||||
profile,
|
||||
wid
|
||||
}]
|
||||
} = await query({
|
||||
json: [
|
||||
"query", "businessProfile",
|
||||
[ { "wid": jid.replace('@s.whatsapp.net', '@c.us') } ],
|
||||
84
|
||||
],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: false,
|
||||
})
|
||||
|
||||
return {
|
||||
...profile,
|
||||
wid: jidNormalizedUser(wid)
|
||||
} as WABusinessProfile
|
||||
}
|
||||
}
|
||||
}
|
||||
export default makeChatsSocket
|
||||
238
src/LegacySocket/groups.ts
Normal file
238
src/LegacySocket/groups.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { BinaryNode, jidNormalizedUser } from "../WABinary";
|
||||
import { LegacySocketConfig, GroupModificationResponse, ParticipantAction, GroupMetadata, WAFlag, WAMetric, WAGroupCreateResponse, GroupParticipant } from "../Types";
|
||||
import { generateMessageID, unixTimestampSeconds } from "../Utils/generics";
|
||||
import makeMessagesSocket from "./messages";
|
||||
|
||||
const makeGroupsSocket = (config: LegacySocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeMessagesSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws: socketEvents,
|
||||
query,
|
||||
generateMessageTag,
|
||||
currentEpoch,
|
||||
setQuery,
|
||||
getState
|
||||
} = sock
|
||||
|
||||
/** Generic function for group queries */
|
||||
const groupQuery = async(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: BinaryNode[]) => {
|
||||
const tag = generateMessageTag()
|
||||
const result = await setQuery ([
|
||||
{
|
||||
tag: 'group',
|
||||
attrs: {
|
||||
author: getState().legacy?.user?.id,
|
||||
id: tag,
|
||||
type: type,
|
||||
jid: jid,
|
||||
subject: subject,
|
||||
},
|
||||
content: participants ?
|
||||
participants.map(jid => (
|
||||
{ tag: 'participant', attrs: { jid } }
|
||||
)) :
|
||||
additionalNodes
|
||||
}
|
||||
], [WAMetric.group, 136], tag)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get the metadata of the group from WA */
|
||||
const groupMetadataFull = async (jid: string) => {
|
||||
const metadata = await query({
|
||||
json: ['query', 'GroupMetadata', jid],
|
||||
expect200: true
|
||||
})
|
||||
metadata.participants = metadata.participants.map(p => (
|
||||
{ ...p, id: undefined, jid: jidNormalizedUser(p.id) }
|
||||
))
|
||||
metadata.owner = jidNormalizedUser(metadata.owner)
|
||||
return metadata as GroupMetadata
|
||||
}
|
||||
/** Get the metadata (works after you've left the group also) */
|
||||
const groupMetadataMinimal = async (jid: string) => {
|
||||
const { attrs, content }:BinaryNode = await query({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {type: 'group', jid: jid, epoch: currentEpoch().toString()}
|
||||
},
|
||||
binaryTag: [WAMetric.group, WAFlag.ignore],
|
||||
expect200: true
|
||||
})
|
||||
const participants: GroupParticipant[] = []
|
||||
let desc: string | undefined
|
||||
if(Array.isArray(content) && Array.isArray(content[0].content)) {
|
||||
const nodes = content[0].content
|
||||
for(const item of nodes) {
|
||||
if(item.tag === 'participant') {
|
||||
participants.push({
|
||||
id: item.attrs.jid,
|
||||
isAdmin: item.attrs.type === 'admin',
|
||||
isSuperAdmin: false
|
||||
})
|
||||
} else if(item.tag === 'description') {
|
||||
desc = (item.content as Buffer).toString('utf-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
const meta: GroupMetadata = {
|
||||
id: jid,
|
||||
owner: attrs?.creator,
|
||||
creation: +attrs?.create,
|
||||
subject: null,
|
||||
desc,
|
||||
participants
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
socketEvents.on('CB:Chat,cmd:action', (json: BinaryNode) => {
|
||||
/*const data = json[1].data
|
||||
if (data) {
|
||||
const emitGroupParticipantsUpdate = (action: WAParticipantAction) => this.emitParticipantsUpdate
|
||||
(json[1].id, data[2].participants.map(whatsappID), action)
|
||||
const emitGroupUpdate = (data: Partial<WAGroupMetadata>) => this.emitGroupUpdate(json[1].id, data)
|
||||
|
||||
switch (data[0]) {
|
||||
case "promote":
|
||||
emitGroupParticipantsUpdate('promote')
|
||||
break
|
||||
case "demote":
|
||||
emitGroupParticipantsUpdate('demote')
|
||||
break
|
||||
case "desc_add":
|
||||
emitGroupUpdate({ ...data[2], descOwner: data[1] })
|
||||
break
|
||||
default:
|
||||
this.logger.debug({ unhandled: true }, json)
|
||||
break
|
||||
}
|
||||
}*/
|
||||
})
|
||||
|
||||
return {
|
||||
...sock,
|
||||
groupMetadata: async(jid: string, minimal: boolean) => {
|
||||
let result: GroupMetadata
|
||||
|
||||
if(minimal) result = await groupMetadataMinimal(jid)
|
||||
else result = await groupMetadataFull(jid)
|
||||
|
||||
return result
|
||||
},
|
||||
/**
|
||||
* Create a group
|
||||
* @param title like, the title of the group
|
||||
* @param participants people to include in the group
|
||||
*/
|
||||
groupCreate: async (title: string, participants: string[]) => {
|
||||
const response = await groupQuery('create', null, title, participants) as WAGroupCreateResponse
|
||||
const gid = response.gid
|
||||
let metadata: GroupMetadata
|
||||
try {
|
||||
metadata = await groupMetadataFull(gid)
|
||||
} catch (error) {
|
||||
logger.warn (`error in group creation: ${error}, switching gid & checking`)
|
||||
// if metadata is not available
|
||||
const comps = gid.replace ('@g.us', '').split ('-')
|
||||
response.gid = `${comps[0]}-${+comps[1] + 1}@g.us`
|
||||
|
||||
metadata = await groupMetadataFull(gid)
|
||||
logger.warn (`group ID switched from ${gid} to ${response.gid}`)
|
||||
}
|
||||
ev.emit('chats.upsert', [
|
||||
{
|
||||
id: response.gid!,
|
||||
name: title,
|
||||
conversationTimestamp: unixTimestampSeconds(),
|
||||
unreadCount: 0
|
||||
}
|
||||
])
|
||||
return metadata
|
||||
},
|
||||
/**
|
||||
* Leave a group
|
||||
* @param jid the ID of the group
|
||||
*/
|
||||
groupLeave: async (id: string) => {
|
||||
await groupQuery('leave', id)
|
||||
ev.emit('chats.update', [ { id, readOnly: true } ])
|
||||
},
|
||||
/**
|
||||
* Update the subject of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateSubject: async (id: string, title: string) => {
|
||||
await groupQuery('subject', id, title)
|
||||
ev.emit('chats.update', [ { id, name: title } ])
|
||||
ev.emit('contacts.update', [ { id, name: title } ])
|
||||
ev.emit('groups.update', [ { id: id, subject: title } ])
|
||||
},
|
||||
/**
|
||||
* Update the group description
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateDescription: async (jid: string, description: string) => {
|
||||
const metadata = await groupMetadataFull(jid)
|
||||
const node: BinaryNode = {
|
||||
tag: 'description',
|
||||
attrs: {id: generateMessageID(), prev: metadata?.descId},
|
||||
content: Buffer.from(description, 'utf-8')
|
||||
}
|
||||
|
||||
const response = await groupQuery ('description', jid, null, null, [node])
|
||||
ev.emit('groups.update', [ { id: jid, desc: description } ])
|
||||
return response
|
||||
},
|
||||
/**
|
||||
* Update participants in the group
|
||||
* @param jid the ID of the group
|
||||
* @param participants the people to add
|
||||
*/
|
||||
groupParticipantsUpdate: async(id: string, participants: string[], action: ParticipantAction) => {
|
||||
const result: GroupModificationResponse = await groupQuery(action, id, null, participants)
|
||||
const jids = Object.keys(result.participants || {})
|
||||
ev.emit('group-participants.update', { id, participants: jids, action })
|
||||
return jids
|
||||
},
|
||||
/** Query broadcast list info */
|
||||
getBroadcastListInfo: async(jid: string) => {
|
||||
interface WABroadcastListInfo {
|
||||
status: number
|
||||
name: string
|
||||
recipients?: {id: string}[]
|
||||
}
|
||||
|
||||
const result = await query({
|
||||
json: ['query', 'contact', jid],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
}) as WABroadcastListInfo
|
||||
|
||||
const metadata: GroupMetadata = {
|
||||
subject: result.name,
|
||||
id: jid,
|
||||
creation: undefined,
|
||||
owner: getState().legacy?.user?.id,
|
||||
participants: result.recipients!.map(({ id }) => (
|
||||
{ id: jidNormalizedUser(id), isAdmin: false, isSuperAdmin: false }
|
||||
))
|
||||
}
|
||||
return metadata
|
||||
},
|
||||
inviteCode: async(jid: string) => {
|
||||
const response = await sock.query({
|
||||
json: ['query', 'inviteCode', jid],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
return response.code as string
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
export default makeGroupsSocket
|
||||
12
src/LegacySocket/index.ts
Normal file
12
src/LegacySocket/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { LegacySocketConfig } from '../Types'
|
||||
import { DEFAULT_LEGACY_CONNECTION_CONFIG } from '../Defaults'
|
||||
import _makeLegacySocket from './groups'
|
||||
// export the last socket layer
|
||||
const makeLegacySocket = (config: Partial<LegacySocketConfig>) => (
|
||||
_makeLegacySocket({
|
||||
...DEFAULT_LEGACY_CONNECTION_CONFIG,
|
||||
...config
|
||||
})
|
||||
)
|
||||
|
||||
export default makeLegacySocket
|
||||
536
src/LegacySocket/messages.ts
Normal file
536
src/LegacySocket/messages.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { BinaryNode, getBinaryNodeMessages, isJidGroup, jidNormalizedUser, areJidsSameUser } from "../WABinary";
|
||||
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 { toNumber, generateWAMessage, decryptMediaMessageBuffer, extractMessageContent, getWAUploadToServer } from "../Utils";
|
||||
import makeChatsSocket from "./chats";
|
||||
import { WA_DEFAULT_EPHEMERAL } from "../Defaults";
|
||||
import { proto } from "../../WAProto";
|
||||
|
||||
const STATUS_MAP = {
|
||||
read: WAMessageStatus.READ,
|
||||
message: WAMessageStatus.DELIVERY_ACK,
|
||||
error: WAMessageStatus.ERROR
|
||||
} as { [_: string]: WAMessageStatus }
|
||||
|
||||
const makeMessagesSocket = (config: LegacySocketConfig) => {
|
||||
const { logger } = config
|
||||
const sock = makeChatsSocket(config)
|
||||
const {
|
||||
ev,
|
||||
ws: socketEvents,
|
||||
query,
|
||||
generateMessageTag,
|
||||
currentEpoch,
|
||||
setQuery,
|
||||
getState
|
||||
} = sock
|
||||
|
||||
let mediaConn: Promise<MediaConnInfo>
|
||||
const refreshMediaConn = async(forceGet = false) => {
|
||||
let media = await mediaConn
|
||||
if (!media || forceGet || (new Date().getTime()-media.fetchDate.getTime()) > media.ttl*1000) {
|
||||
mediaConn = (async() => {
|
||||
const {media_conn} = await query({
|
||||
json: ['query', 'mediaConn'],
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
media_conn.fetchDate = new Date()
|
||||
return media_conn as MediaConnInfo
|
||||
})()
|
||||
}
|
||||
return mediaConn
|
||||
}
|
||||
|
||||
const fetchMessagesFromWA = async(
|
||||
jid: string,
|
||||
count: number,
|
||||
cursor?: WAMessageCursor
|
||||
) => {
|
||||
let key: WAMessageKey
|
||||
if(cursor) {
|
||||
key = 'before' in cursor ? cursor.before : cursor.after
|
||||
}
|
||||
const { content }:BinaryNode = await query({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {
|
||||
epoch: currentEpoch().toString(),
|
||||
type: 'message',
|
||||
jid: jid,
|
||||
kind: !cursor || 'before' in cursor ? 'before' : 'after',
|
||||
count: count.toString(),
|
||||
index: key?.id,
|
||||
owner: key?.fromMe === false ? 'false' : 'true',
|
||||
}
|
||||
},
|
||||
binaryTag: [WAMetric.queryMessages, WAFlag.ignore],
|
||||
expect200: false,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
if(Array.isArray(content)) {
|
||||
return content.map(data => proto.WebMessageInfo.decode(data.content as Buffer))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const updateMediaMessage = async(message: WAMessage) => {
|
||||
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
|
||||
if (!content) throw new Boom(
|
||||
`given message ${message.key.id} is not a media message`,
|
||||
{ statusCode: 400, data: message }
|
||||
)
|
||||
|
||||
const response: BinaryNode = await query ({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {
|
||||
type: 'media',
|
||||
index: message.key.id,
|
||||
owner: message.key.fromMe ? 'true' : 'false',
|
||||
jid: message.key.remoteJid,
|
||||
epoch: currentEpoch().toString()
|
||||
}
|
||||
},
|
||||
binaryTag: [WAMetric.queryMedia, WAFlag.ignore],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
const attrs = response.attrs
|
||||
Object.assign(content, attrs) // update message
|
||||
|
||||
ev.emit('messages.update', [{ key: message.key, update: { message: message.message } }])
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const onMessage = (message: WAMessage, type: MessageUpdateType | 'update') => {
|
||||
const jid = message.key.remoteJid!
|
||||
// store chat updates in this
|
||||
const chatUpdate: Partial<Chat> = {
|
||||
id: jid,
|
||||
}
|
||||
|
||||
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
|
||||
ev.emit('groups.update', [ { id: jid, ...update } ])
|
||||
}
|
||||
|
||||
if(message.message) {
|
||||
chatUpdate.conversationTimestamp = +toNumber(message.messageTimestamp)
|
||||
// add to count if the message isn't from me & there exists a message
|
||||
if(!message.key.fromMe) {
|
||||
chatUpdate.unreadCount = 1
|
||||
const participant = jidNormalizedUser(message.participant || jid)
|
||||
|
||||
ev.emit(
|
||||
'presence.update',
|
||||
{
|
||||
id: jid,
|
||||
presences: { [participant]: { lastKnownPresence: 'available' } }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ephemeralProtocolMsg = message.message?.ephemeralMessage?.message?.protocolMessage
|
||||
if (
|
||||
ephemeralProtocolMsg &&
|
||||
ephemeralProtocolMsg.type === proto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING
|
||||
) {
|
||||
chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp
|
||||
chatUpdate.ephemeralExpiration = ephemeralProtocolMsg.ephemeralExpiration
|
||||
|
||||
if(isJidGroup(jid)) {
|
||||
emitGroupUpdate({ ephemeralDuration: ephemeralProtocolMsg.ephemeralExpiration || null })
|
||||
}
|
||||
}
|
||||
const protocolMessage = message.message?.protocolMessage
|
||||
// if it's a message to delete another message
|
||||
if (protocolMessage) {
|
||||
switch (protocolMessage.type) {
|
||||
case proto.ProtocolMessage.ProtocolMessageType.REVOKE:
|
||||
const key = protocolMessage.key
|
||||
const messageStubType = WAMessageStubType.REVOKE
|
||||
ev.emit('messages.update', [
|
||||
{
|
||||
// the key of the deleted message is updated
|
||||
update: { message: null, key: message.key, messageStubType },
|
||||
key
|
||||
}
|
||||
])
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// check if the message is an action
|
||||
if (message.messageStubType) {
|
||||
const { user } = getState().legacy!
|
||||
//let actor = jidNormalizedUser (message.participant)
|
||||
let participants: string[]
|
||||
const emitParticipantsUpdate = (action: ParticipantAction) => (
|
||||
ev.emit('group-participants.update', { id: jid, participants, action })
|
||||
)
|
||||
|
||||
switch (message.messageStubType) {
|
||||
case WAMessageStubType.CHANGE_EPHEMERAL_SETTING:
|
||||
chatUpdate.ephemeralSettingTimestamp = message.messageTimestamp
|
||||
chatUpdate.ephemeralExpiration = +message.messageStubParameters[0]
|
||||
if(isJidGroup(jid)) {
|
||||
emitGroupUpdate({ ephemeralDuration: +message.messageStubParameters[0] || null })
|
||||
}
|
||||
break
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
|
||||
participants = message.messageStubParameters.map (jidNormalizedUser)
|
||||
emitParticipantsUpdate('remove')
|
||||
// mark the chat read only if you left the group
|
||||
if (participants.includes(user.id)) {
|
||||
chatUpdate.readOnly = true
|
||||
}
|
||||
break
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
|
||||
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
|
||||
participants = message.messageStubParameters.map (jidNormalizedUser)
|
||||
if (participants.includes(user.id)) {
|
||||
chatUpdate.readOnly = null
|
||||
}
|
||||
emitParticipantsUpdate('add')
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
|
||||
const announce = message.messageStubParameters[0] === 'on'
|
||||
emitGroupUpdate({ announce })
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
|
||||
const restrict = message.messageStubParameters[0] === 'on'
|
||||
emitGroupUpdate({ restrict })
|
||||
break
|
||||
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
|
||||
case WAMessageStubType.GROUP_CREATE:
|
||||
chatUpdate.name = message.messageStubParameters[0]
|
||||
emitGroupUpdate({ subject: chatUpdate.name })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if(Object.keys(chatUpdate).length > 1) {
|
||||
ev.emit('chats.update', [chatUpdate])
|
||||
}
|
||||
if(type === 'update') {
|
||||
ev.emit('messages.update', [ { update: message, key: message.key } ])
|
||||
} else {
|
||||
ev.emit('messages.upsert', { messages: [message], type })
|
||||
}
|
||||
}
|
||||
|
||||
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn)
|
||||
|
||||
/** Query a string to check if it has a url, if it does, return WAUrlInfo */
|
||||
const generateUrlInfo = async(text: string) => {
|
||||
const response: BinaryNode = await query({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {
|
||||
type: 'url',
|
||||
url: text,
|
||||
epoch: currentEpoch().toString()
|
||||
}
|
||||
},
|
||||
binaryTag: [26, WAFlag.ignore],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: false
|
||||
})
|
||||
const urlInfo = { ...response.attrs } as any as WAUrlInfo
|
||||
if(response && response.content) {
|
||||
urlInfo.jpegThumbnail = response.content as Buffer
|
||||
}
|
||||
return urlInfo
|
||||
}
|
||||
|
||||
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
|
||||
const relayWAMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => {
|
||||
const json: BinaryNode = {
|
||||
tag: 'action',
|
||||
attrs: { epoch: currentEpoch().toString(), type: 'relay' },
|
||||
content: [
|
||||
{
|
||||
tag: 'message',
|
||||
attrs: {},
|
||||
content: proto.WebMessageInfo.encode(message).finish()
|
||||
}
|
||||
]
|
||||
}
|
||||
const isMsgToMe = areJidsSameUser(message.key.remoteJid, getState().legacy.user?.id || '')
|
||||
const flag = isMsgToMe ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
|
||||
const mID = message.key.id
|
||||
const finalState = isMsgToMe ? WAMessageStatus.READ : WAMessageStatus.SERVER_ACK
|
||||
|
||||
message.status = WAMessageStatus.PENDING
|
||||
const promise = query({
|
||||
json,
|
||||
binaryTag: [WAMetric.message, flag],
|
||||
tag: mID,
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
|
||||
if(waitForAck) {
|
||||
await promise
|
||||
message.status = finalState
|
||||
} else {
|
||||
const emitUpdate = (status: WAMessageStatus) => {
|
||||
message.status = status
|
||||
ev.emit('messages.update', [ { key: message.key, update: { status } } ])
|
||||
}
|
||||
promise
|
||||
.then(() => emitUpdate(finalState))
|
||||
.catch(() => emitUpdate(WAMessageStatus.ERROR))
|
||||
}
|
||||
if(config.emitOwnEvents) {
|
||||
onMessage(message, 'append')
|
||||
}
|
||||
}
|
||||
|
||||
// messages received
|
||||
const messagesUpdate = (node: BinaryNode, type: 'prepend' | 'last') => {
|
||||
const messages = getBinaryNodeMessages(node)
|
||||
messages.reverse()
|
||||
ev.emit('messages.upsert', { messages, type })
|
||||
}
|
||||
|
||||
socketEvents.on('CB:action,add:last', json => messagesUpdate(json, 'last'))
|
||||
socketEvents.on('CB:action,add:unread', json => messagesUpdate(json, 'prepend'))
|
||||
socketEvents.on('CB:action,add:before', json => messagesUpdate(json, 'prepend'))
|
||||
|
||||
// new messages
|
||||
socketEvents.on('CB:action,add:relay,message', (node: BinaryNode) => {
|
||||
const msgs = getBinaryNodeMessages(node)
|
||||
for(const msg of msgs) {
|
||||
onMessage(msg, 'notify')
|
||||
}
|
||||
})
|
||||
// If a message has been updated (usually called when a video message gets its upload url, or live locations)
|
||||
socketEvents.on ('CB:action,add:update,message', (node: BinaryNode) => {
|
||||
const msgs = getBinaryNodeMessages(node)
|
||||
for(const msg of msgs) {
|
||||
onMessage(msg, 'update')
|
||||
}
|
||||
})
|
||||
// message status updates
|
||||
const onMessageStatusUpdate = ({ content }: BinaryNode) => {
|
||||
if(Array.isArray(content)) {
|
||||
const updates: WAMessageUpdate[] = []
|
||||
for(const { attrs: json } of content) {
|
||||
const key: WAMessageKey = {
|
||||
remoteJid: jidNormalizedUser(json.jid),
|
||||
id: json.index,
|
||||
fromMe: json.owner === 'true'
|
||||
}
|
||||
const status = STATUS_MAP[json.type]
|
||||
|
||||
if(status) {
|
||||
updates.push({ key, update: { status } })
|
||||
} else {
|
||||
logger.warn({ content, key }, 'got unknown status update for message')
|
||||
}
|
||||
}
|
||||
ev.emit('messages.update', updates)
|
||||
}
|
||||
}
|
||||
const onMessageInfoUpdate = ([,attributes]: [string,{[_: string]: any}]) => {
|
||||
let ids = attributes.id as string[] | string
|
||||
if(typeof ids === 'string') {
|
||||
ids = [ids]
|
||||
}
|
||||
let updateKey: keyof MessageInfoUpdate['update']
|
||||
switch(attributes.ack.toString()) {
|
||||
case '2':
|
||||
updateKey = 'deliveries'
|
||||
break
|
||||
case '3':
|
||||
updateKey = 'reads'
|
||||
break
|
||||
default:
|
||||
logger.warn({ attributes }, `received unknown message info update`)
|
||||
return
|
||||
}
|
||||
const keyPartial = {
|
||||
remoteJid: jidNormalizedUser(attributes.to),
|
||||
fromMe: areJidsSameUser(attributes.from, getState().legacy?.user?.id || ''),
|
||||
}
|
||||
const updates = ids.map<MessageInfoUpdate>(id => ({
|
||||
key: { ...keyPartial, id },
|
||||
update: {
|
||||
[updateKey]: { [jidNormalizedUser(attributes.participant || attributes.to)]: new Date(+attributes.t) }
|
||||
}
|
||||
}))
|
||||
ev.emit('message-info.update', updates)
|
||||
// for individual messages
|
||||
// it means the message is marked read/delivered
|
||||
if(!isJidGroup(keyPartial.remoteJid)) {
|
||||
ev.emit('messages.update', ids.map(id => (
|
||||
{
|
||||
key: { ...keyPartial, id },
|
||||
update: {
|
||||
status: updateKey === 'deliveries' ? WAMessageStatus.DELIVERY_ACK : WAMessageStatus.READ
|
||||
}
|
||||
}
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
socketEvents.on('CB:action,add:relay,received', onMessageStatusUpdate)
|
||||
socketEvents.on('CB:action,,received', onMessageStatusUpdate)
|
||||
|
||||
socketEvents.on('CB:Msg', onMessageInfoUpdate)
|
||||
socketEvents.on('CB:MsgInfo', onMessageInfoUpdate)
|
||||
|
||||
return {
|
||||
...sock,
|
||||
relayWAMessage,
|
||||
generateUrlInfo,
|
||||
messageInfo: async(jid: string, messageID: string) => {
|
||||
const { content }: BinaryNode = await query({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {type: 'message_info', index: messageID, jid: jid, epoch: currentEpoch().toString()}
|
||||
},
|
||||
binaryTag: [WAMetric.queryRead, WAFlag.ignore],
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
})
|
||||
const info: MessageInfo = { reads: {}, deliveries: {} }
|
||||
if(Array.isArray(content)) {
|
||||
for(const { tag, content: innerData } of content) {
|
||||
const [{ attrs }] = (innerData as BinaryNode[])
|
||||
const jid = jidNormalizedUser(attrs.jid)
|
||||
const date = new Date(+attrs.t * 1000)
|
||||
switch(tag) {
|
||||
case 'read':
|
||||
info.reads[jid] = date
|
||||
break
|
||||
case 'delivery':
|
||||
info.deliveries[jid] = date
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return info
|
||||
},
|
||||
downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => {
|
||||
|
||||
const downloadMediaMessage = async () => {
|
||||
let mContent = extractMessageContent(message.message)
|
||||
if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
|
||||
|
||||
const stream = await decryptMediaMessageBuffer(mContent)
|
||||
if(type === 'buffer') {
|
||||
let buffer = Buffer.from([])
|
||||
for await(const chunk of stream) {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await downloadMediaMessage()
|
||||
return result
|
||||
} catch (error) {
|
||||
if(error.message.includes('404')) { // media needs to be updated
|
||||
logger.info (`updating media of message: ${message.key.id}`)
|
||||
|
||||
await updateMediaMessage(message)
|
||||
|
||||
const result = await downloadMediaMessage()
|
||||
return result
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
updateMediaMessage,
|
||||
fetchMessagesFromWA,
|
||||
/** Load a single message specified by the ID */
|
||||
loadMessageFromWA: async(jid: string, id: string) => {
|
||||
let message: WAMessage
|
||||
|
||||
// load the message before the given message
|
||||
let messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: true} }))
|
||||
if(!messages[0]) messages = (await fetchMessagesFromWA(jid, 1, { before: {id, fromMe: false} }))
|
||||
// the message after the loaded message is the message required
|
||||
const [actual] = await fetchMessagesFromWA(jid, 1, { after: messages[0] && messages[0].key })
|
||||
message = actual
|
||||
return message
|
||||
},
|
||||
searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => {
|
||||
const node: BinaryNode = await query({
|
||||
json: {
|
||||
tag: 'query',
|
||||
attrs: {
|
||||
epoch: currentEpoch().toString(),
|
||||
type: 'search',
|
||||
search: txt,
|
||||
count: count.toString(),
|
||||
page: page.toString(),
|
||||
jid: inJid
|
||||
}
|
||||
},
|
||||
binaryTag: [24, WAFlag.ignore],
|
||||
expect200: true
|
||||
}) // encrypt and send off
|
||||
|
||||
return {
|
||||
last: node.attrs?.last === 'true',
|
||||
messages: getBinaryNodeMessages(node)
|
||||
}
|
||||
},
|
||||
sendWAMessage: async(
|
||||
jid: string,
|
||||
content: AnyMessageContent,
|
||||
options: MiscMessageGenerationOptions & { waitForAck?: boolean } = { waitForAck: true }
|
||||
) => {
|
||||
const userJid = getState().legacy.user?.id
|
||||
if(
|
||||
typeof content === 'object' &&
|
||||
'disappearingMessagesInChat' in content &&
|
||||
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
|
||||
isJidGroup(jid)
|
||||
) {
|
||||
const { disappearingMessagesInChat } = content
|
||||
const value = typeof disappearingMessagesInChat === 'boolean' ?
|
||||
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
|
||||
disappearingMessagesInChat
|
||||
const tag = generateMessageTag(true)
|
||||
await setQuery([
|
||||
{
|
||||
tag: 'group',
|
||||
attrs: { id: tag, jid, type: 'prop', author: userJid },
|
||||
content: [
|
||||
{ tag: 'ephemeral', attrs: { value: value.toString() } }
|
||||
]
|
||||
}
|
||||
])
|
||||
} else {
|
||||
const msg = await generateWAMessage(
|
||||
jid,
|
||||
content,
|
||||
{
|
||||
...options,
|
||||
logger,
|
||||
userJid: userJid,
|
||||
getUrlInfo: generateUrlInfo,
|
||||
upload: waUploadToServer,
|
||||
mediaCache: config.mediaCache
|
||||
}
|
||||
)
|
||||
|
||||
await relayWAMessage(msg, { waitForAck: options.waitForAck })
|
||||
return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default makeMessagesSocket
|
||||
393
src/LegacySocket/socket.ts
Normal file
393
src/LegacySocket/socket.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { STATUS_CODES } from "http"
|
||||
import { promisify } from "util"
|
||||
import WebSocket from "ws"
|
||||
import { BinaryNode, encodeBinaryNodeLegacy } from "../WABinary"
|
||||
import { DisconnectReason, LegacySocketConfig, SocketQueryOptions, SocketSendMessageOptions, WAFlag, WAMetric, WATag } from "../Types"
|
||||
import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds, decodeWAMessage } from "../Utils"
|
||||
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults"
|
||||
|
||||
/**
|
||||
* Connects to WA servers and performs:
|
||||
* - simple queries (no retry mechanism, wait for connection establishment)
|
||||
* - listen to messages and emit events
|
||||
* - query phone connection
|
||||
*/
|
||||
export const makeSocket = ({
|
||||
waWebSocketUrl,
|
||||
connectTimeoutMs,
|
||||
phoneResponseTimeMs,
|
||||
logger,
|
||||
agent,
|
||||
keepAliveIntervalMs,
|
||||
expectResponseTimeout,
|
||||
}: LegacySocketConfig) => {
|
||||
// for generating tags
|
||||
const referenceDateSeconds = unixTimestampSeconds(new Date())
|
||||
const ws = new WebSocket(waWebSocketUrl, undefined, {
|
||||
origin: DEFAULT_ORIGIN,
|
||||
timeout: connectTimeoutMs,
|
||||
agent,
|
||||
headers: {
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Host': 'web.whatsapp.com',
|
||||
'Pragma': 'no-cache',
|
||||
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits',
|
||||
}
|
||||
})
|
||||
ws.setMaxListeners(0)
|
||||
let lastDateRecv: Date
|
||||
let epoch = 0
|
||||
let authInfo: { encKey: Buffer, macKey: Buffer }
|
||||
let keepAliveReq: NodeJS.Timeout
|
||||
|
||||
let phoneCheckInterval: NodeJS.Timeout
|
||||
let phoneCheckListeners = 0
|
||||
|
||||
const phoneConnectionChanged = (value: boolean) => {
|
||||
ws.emit('phone-connection', { value })
|
||||
}
|
||||
|
||||
const sendPromise = promisify(ws.send)
|
||||
/** generate message tag and increment epoch */
|
||||
const generateMessageTag = (longTag: boolean = false) => {
|
||||
const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}`
|
||||
epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages
|
||||
return tag
|
||||
}
|
||||
const sendRawMessage = (data: Buffer | string) => sendPromise.call(ws, data) as Promise<void>
|
||||
/**
|
||||
* Send a message to the WA servers
|
||||
* @returns the tag attached in the message
|
||||
* */
|
||||
const sendMessage = async(
|
||||
{ json, binaryTag, tag, longTag }: SocketSendMessageOptions
|
||||
) => {
|
||||
tag = tag || generateMessageTag(longTag)
|
||||
let data: Buffer | string
|
||||
if(logger.level === 'trace') {
|
||||
logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication')
|
||||
}
|
||||
|
||||
if(binaryTag) {
|
||||
if(Array.isArray(json)) {
|
||||
throw new Boom('Expected BinaryNode with binary code', { statusCode: 400 })
|
||||
}
|
||||
if(!authInfo) {
|
||||
throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 })
|
||||
}
|
||||
const binary = encodeBinaryNodeLegacy(json) // encode the JSON to the WhatsApp binary format
|
||||
|
||||
const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey
|
||||
const sign = hmacSign(buff, authInfo.macKey) // sign the message using HMAC and our macKey
|
||||
|
||||
data = Buffer.concat([
|
||||
Buffer.from(tag + ','), // generate & prefix the message tag
|
||||
Buffer.from(binaryTag), // prefix some bytes that tell whatsapp what the message is about
|
||||
sign, // the HMAC sign of the message
|
||||
buff, // the actual encrypted buffer
|
||||
])
|
||||
} else {
|
||||
data = `${tag},${JSON.stringify(json)}`
|
||||
}
|
||||
await sendRawMessage(data)
|
||||
return tag
|
||||
}
|
||||
const end = (error: Error | undefined) => {
|
||||
logger.debug({ error }, 'connection closed')
|
||||
|
||||
ws.removeAllListeners('close')
|
||||
ws.removeAllListeners('error')
|
||||
ws.removeAllListeners('open')
|
||||
ws.removeAllListeners('message')
|
||||
|
||||
phoneCheckListeners = 0
|
||||
clearInterval(keepAliveReq)
|
||||
clearPhoneCheckInterval()
|
||||
|
||||
if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
|
||||
try { ws.close() } catch { }
|
||||
}
|
||||
|
||||
ws.emit('ws-close', error)
|
||||
ws.removeAllListeners('ws-close')
|
||||
}
|
||||
const onMessageRecieved = (message: string | Buffer) => {
|
||||
if(message[0] === '!' || message[0] === '!'.charCodeAt(0)) {
|
||||
// when the first character in the message is an '!', the server is sending a pong frame
|
||||
const timestamp = message.slice(1, message.length).toString()
|
||||
lastDateRecv = new Date(parseInt(timestamp))
|
||||
ws.emit('received-pong')
|
||||
} else {
|
||||
let messageTag: string
|
||||
let json: any
|
||||
try {
|
||||
const dec = decodeWAMessage(message, authInfo)
|
||||
messageTag = dec[0]
|
||||
json = dec[1]
|
||||
if (!json) return
|
||||
} catch (error) {
|
||||
end(error)
|
||||
return
|
||||
}
|
||||
//if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false })
|
||||
|
||||
if (logger.level === 'trace') {
|
||||
logger.trace({ tag: messageTag, fromMe: false, json }, 'communication')
|
||||
}
|
||||
|
||||
let anyTriggered = false
|
||||
/* Check if this is a response to a message we sent */
|
||||
anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${messageTag}`, json)
|
||||
/* Check if this is a response to a message we are expecting */
|
||||
const l0 = json.tag || json[0] || ''
|
||||
const l1 = json?.attrs || json?.[1] || { }
|
||||
const l2 = json?.content?.[0]?.tag || json[2]?.[0] || ''
|
||||
|
||||
Object.keys(l1).forEach(key => {
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, json) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, json) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, json) || anyTriggered
|
||||
})
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered
|
||||
anyTriggered = ws.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered
|
||||
|
||||
if (!anyTriggered && logger.level === 'debug') {
|
||||
logger.debug({ unhandled: true, tag: messageTag, fromMe: false, json }, 'communication recv')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exits a query if the phone connection is active and no response is still found */
|
||||
const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
const listener = ([, connected]) => {
|
||||
if(connected) {
|
||||
timeout = setTimeout(() => {
|
||||
logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`)
|
||||
cancel(new Boom('Not expecting a response', { statusCode: 422 }))
|
||||
}, expectResponseTimeout)
|
||||
ws.off(PHONE_CONNECTION_CB, listener)
|
||||
}
|
||||
}
|
||||
ws.on(PHONE_CONNECTION_CB, listener)
|
||||
return () => {
|
||||
ws.off(PHONE_CONNECTION_CB, listener)
|
||||
timeout && clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
/** interval is started when a query takes too long to respond */
|
||||
const startPhoneCheckInterval = () => {
|
||||
phoneCheckListeners += 1
|
||||
if (!phoneCheckInterval) {
|
||||
// if its been a long time and we haven't heard back from WA, send a ping
|
||||
phoneCheckInterval = setInterval(() => {
|
||||
if(phoneCheckListeners <= 0) {
|
||||
logger.warn('phone check called without listeners')
|
||||
return
|
||||
}
|
||||
logger.info('checking phone connection...')
|
||||
sendAdminTest()
|
||||
|
||||
phoneConnectionChanged(false)
|
||||
}, phoneResponseTimeMs)
|
||||
}
|
||||
}
|
||||
const clearPhoneCheckInterval = () => {
|
||||
phoneCheckListeners -= 1
|
||||
if (phoneCheckListeners <= 0) {
|
||||
clearInterval(phoneCheckInterval)
|
||||
phoneCheckInterval = undefined
|
||||
phoneCheckListeners = 0
|
||||
}
|
||||
}
|
||||
/** checks for phone connection */
|
||||
const sendAdminTest = () => sendMessage({ json: ['admin', 'test'] })
|
||||
/**
|
||||
* Wait for a message with a certain tag to be received
|
||||
* @param tag the message tag to await
|
||||
* @param json query that was sent
|
||||
* @param timeoutMs timeout after which the promise will reject
|
||||
*/
|
||||
const waitForMessage = (tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => {
|
||||
if(ws.readyState !== ws.OPEN) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
|
||||
let cancelToken = () => { }
|
||||
|
||||
return {
|
||||
promise: (async() => {
|
||||
let onRecv: (json) => void
|
||||
let onErr: (err) => void
|
||||
let cancelPhoneChecker: () => void
|
||||
try {
|
||||
const result = await promiseTimeout(timeoutMs,
|
||||
(resolve, reject) => {
|
||||
onRecv = resolve
|
||||
onErr = err => {
|
||||
reject(err || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
|
||||
}
|
||||
cancelToken = () => onErr(new Boom('Cancelled', { statusCode: 500 }))
|
||||
|
||||
if(requiresPhoneConnection) {
|
||||
startPhoneCheckInterval()
|
||||
cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr)
|
||||
}
|
||||
|
||||
ws.on(`TAG:${tag}`, onRecv)
|
||||
ws.on('ws-close', onErr) // if the socket closes, you'll never receive the message
|
||||
},
|
||||
)
|
||||
return result as any
|
||||
} finally {
|
||||
requiresPhoneConnection && clearPhoneCheckInterval()
|
||||
cancelPhoneChecker && cancelPhoneChecker()
|
||||
|
||||
ws.off(`TAG:${tag}`, onRecv)
|
||||
ws.off('ws-close', onErr) // if the socket closes, you'll never receive the message
|
||||
}
|
||||
})(),
|
||||
cancelToken: () => { cancelToken() }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Query something from the WhatsApp servers
|
||||
* @param json the query itself
|
||||
* @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary
|
||||
* @param timeoutMs timeout after which the query will be failed (set to null to disable a timeout)
|
||||
* @param tag the tag to attach to the message
|
||||
*/
|
||||
const query = async(
|
||||
{ json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection }: SocketQueryOptions
|
||||
) => {
|
||||
tag = tag || generateMessageTag(longTag)
|
||||
const { promise, cancelToken } = waitForMessage(tag, requiresPhoneConnection, timeoutMs)
|
||||
try {
|
||||
await sendMessage({ json, tag, binaryTag })
|
||||
} catch(error) {
|
||||
cancelToken()
|
||||
// swallow error
|
||||
await promise.catch(() => { })
|
||||
// throw back the error
|
||||
throw error
|
||||
}
|
||||
|
||||
const response = await promise
|
||||
const responseStatusCode = +(response.status ? response.status : 200) // default status
|
||||
// read here: http://getstatuscode.com/599
|
||||
if(responseStatusCode === 599) { // the connection has gone bad
|
||||
end(new Boom('WA server overloaded', { statusCode: 599, data: { query: json, response } }))
|
||||
}
|
||||
if(expect200 && Math.floor(responseStatusCode/100) !== 2) {
|
||||
const message = STATUS_CODES[responseStatusCode] || 'unknown'
|
||||
throw new Boom(
|
||||
`Unexpected status in '${Array.isArray(json) ? json[0] : (json?.tag || 'query')}': ${message}(${responseStatusCode})`,
|
||||
{ data: { query: json, message }, statusCode: response.status }
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
const startKeepAliveRequest = () => (
|
||||
keepAliveReq = setInterval(() => {
|
||||
if (!lastDateRecv) lastDateRecv = new Date()
|
||||
const diff = Date.now() - lastDateRecv.getTime()
|
||||
/*
|
||||
check if it's been a suspicious amount of time since the server responded with our last seen
|
||||
it could be that the network is down
|
||||
*/
|
||||
if (diff > keepAliveIntervalMs+5000) {
|
||||
end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
|
||||
} else if(ws.readyState === ws.OPEN) {
|
||||
sendRawMessage('?,,') // if its all good, send a keep alive request
|
||||
} else {
|
||||
logger.warn('keep alive called when WS not open')
|
||||
}
|
||||
}, keepAliveIntervalMs)
|
||||
)
|
||||
|
||||
const waitForSocketOpen = async() => {
|
||||
if(ws.readyState === ws.OPEN) return
|
||||
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
|
||||
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||
}
|
||||
let onOpen: () => void
|
||||
let onClose: (err: Error) => void
|
||||
await new Promise((resolve, reject) => {
|
||||
onOpen = () => resolve(undefined)
|
||||
onClose = reject
|
||||
ws.on('open', onOpen)
|
||||
ws.on('close', onClose)
|
||||
ws.on('error', onClose)
|
||||
})
|
||||
.finally(() => {
|
||||
ws.off('open', onOpen)
|
||||
ws.off('close', onClose)
|
||||
ws.off('error', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
ws.on('message', onMessageRecieved)
|
||||
ws.on('open', () => {
|
||||
startKeepAliveRequest()
|
||||
logger.info('Opened WS connection to WhatsApp Web')
|
||||
})
|
||||
ws.on('error', end)
|
||||
ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost })))
|
||||
|
||||
ws.on(PHONE_CONNECTION_CB, json => {
|
||||
if (!json[1]) {
|
||||
end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost }))
|
||||
logger.info('Connection terminated by phone, closing...')
|
||||
} else {
|
||||
phoneConnectionChanged(true)
|
||||
}
|
||||
})
|
||||
ws.on('CB:Cmd,type:disconnect', json => {
|
||||
const {kind} = json[1]
|
||||
let reason: DisconnectReason
|
||||
switch(kind) {
|
||||
case 'replaced':
|
||||
reason = DisconnectReason.connectionReplaced
|
||||
break
|
||||
default:
|
||||
reason = DisconnectReason.connectionLost
|
||||
break
|
||||
}
|
||||
end(new Boom(
|
||||
`Connection terminated by server: "${kind || 'unknown'}"`,
|
||||
{ statusCode: reason }
|
||||
))
|
||||
})
|
||||
|
||||
return {
|
||||
ws,
|
||||
updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info,
|
||||
waitForSocketOpen,
|
||||
sendRawMessage,
|
||||
sendMessage,
|
||||
generateMessageTag,
|
||||
waitForMessage,
|
||||
query,
|
||||
/** Generic function for action, set queries */
|
||||
setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => {
|
||||
const json: BinaryNode = {
|
||||
tag: 'action',
|
||||
attrs: { epoch: epoch.toString(), type: 'set' },
|
||||
content: nodes
|
||||
}
|
||||
|
||||
return query({
|
||||
json,
|
||||
binaryTag,
|
||||
tag,
|
||||
expect200: true,
|
||||
requiresPhoneConnection: true
|
||||
}) as Promise<{ status: number }>
|
||||
},
|
||||
currentEpoch: () => epoch,
|
||||
end
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { proto } from '../../WAProto'
|
||||
import { generateProfilePicture, toNumber, encodeSyncdPatch, decodePatches, extractSyncdPatches, chatModificationToAppPatch, decodeSyncdSnapshot, newLTHashState } from "../Utils";
|
||||
import { makeMessagesSocket } from "./messages-send";
|
||||
import makeMutex from "../Utils/make-mutex";
|
||||
import { Boom } from "@hapi/boom";
|
||||
|
||||
export const makeChatsSocket = (config: SocketConfig) => {
|
||||
const { logger } = config
|
||||
@@ -425,6 +426,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
|
||||
const appPatch = async(patchCreate: WAPatchCreate) => {
|
||||
const name = patchCreate.type
|
||||
const myAppStateKeyId = authState.creds.myAppStateKeyId
|
||||
if(!myAppStateKeyId) {
|
||||
throw new Boom(`App state key not present!`, { statusCode: 400 })
|
||||
}
|
||||
|
||||
await mutationMutex.mutex(
|
||||
async() => {
|
||||
logger.debug({ patch: patchCreate }, 'applying app patch')
|
||||
@@ -433,7 +439,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
const { [name]: initial } = await authState.keys.get('app-state-sync-version', [name])
|
||||
const { patch, state } = await encodeSyncdPatch(
|
||||
patchCreate,
|
||||
authState.creds.myAppStateKeyId!,
|
||||
myAppStateKeyId,
|
||||
initial,
|
||||
getAppStateSyncKey,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
import { Boom } from "@hapi/boom"
|
||||
import { SocketConfig, MediaConnInfo, AnyMessageContent, MiscMessageGenerationOptions, WAMediaUploadFunction, MessageRelayOptions } from "../Types"
|
||||
import { encodeWAMessage, generateMessageID, generateWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESessions } from "../Utils"
|
||||
import { encodeWAMessage, generateMessageID, generateWAMessage, encryptSenderKeyMsgSignalProto, encryptSignalProto, extractDeviceJids, jidToSignalProtocolAddress, parseAndInjectE2ESessions, getWAUploadToServer } from "../Utils"
|
||||
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidDecode, jidEncode, jidNormalizedUser, S_WHATSAPP_NET, BinaryNodeAttributes, JidWithDevice, reduceBinaryNodeToDictionary } from '../WABinary'
|
||||
import { proto } from "../../WAProto"
|
||||
import { WA_DEFAULT_EPHEMERAL, DEFAULT_ORIGIN, MEDIA_PATH_MAP } from "../Defaults"
|
||||
import { WA_DEFAULT_EPHEMERAL } from "../Defaults"
|
||||
import { makeGroupsSocket } from "./groups"
|
||||
import NodeCache from "node-cache"
|
||||
|
||||
@@ -419,58 +418,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
||||
return msgId
|
||||
}
|
||||
|
||||
const waUploadToServer: WAMediaUploadFunction = async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => {
|
||||
const { default: got } = await import('got')
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
let uploadInfo = await refreshMediaConn(false)
|
||||
|
||||
let urls: { mediaUrl: string, directPath: string }
|
||||
const hosts = [ ...config.customUploadHosts, ...uploadInfo.hosts.map(h => h.hostname) ]
|
||||
for (let hostname of hosts) {
|
||||
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
|
||||
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
|
||||
try {
|
||||
const {body: responseText} = await got.post(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Origin': DEFAULT_ORIGIN
|
||||
},
|
||||
agent: {
|
||||
https: config.agent
|
||||
},
|
||||
body: stream,
|
||||
timeout: timeoutMs
|
||||
}
|
||||
)
|
||||
const result = JSON.parse(responseText)
|
||||
|
||||
if(result?.url || result?.directPath) {
|
||||
urls = {
|
||||
mediaUrl: result.url,
|
||||
directPath: result.direct_path
|
||||
}
|
||||
break
|
||||
} else {
|
||||
uploadInfo = await refreshMediaConn(true)
|
||||
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const isLast = hostname === hosts[uploadInfo.hosts.length-1]
|
||||
logger.debug(`Error in uploading to ${hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
|
||||
}
|
||||
}
|
||||
if (!urls) {
|
||||
throw new Boom(
|
||||
'Media upload failed on all hosts',
|
||||
{ statusCode: 500 }
|
||||
)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
const waUploadToServer = getWAUploadToServer(config, refreshMediaConn)
|
||||
|
||||
return {
|
||||
...sock,
|
||||
assertSessions,
|
||||
|
||||
@@ -530,7 +530,7 @@ export const makeSocket = ({
|
||||
})
|
||||
|
||||
ws.on('CB:ib,,downgrade_webclient', () => {
|
||||
end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.notJoinedBeta }))
|
||||
end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }))
|
||||
})
|
||||
|
||||
process.nextTick(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Contact } from "./Contact"
|
||||
import type { proto } from "../../WAProto"
|
||||
import type { WAPatchName } from "./Chat"
|
||||
|
||||
export type KeyPair = { public: Uint8Array, private: Uint8Array }
|
||||
export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number }
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ChatModification =
|
||||
mute: number | null
|
||||
} |
|
||||
{
|
||||
clear: 'all' | { message: {id: string, fromMe?: boolean} }
|
||||
clear: 'all' | { messages: {id: string, fromMe?: boolean}[] }
|
||||
} |
|
||||
{
|
||||
star: {
|
||||
|
||||
56
src/Types/Events.ts
Normal file
56
src/Types/Events.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type EventEmitter from "events"
|
||||
|
||||
import { AuthenticationCreds } from './Auth'
|
||||
import { Chat, PresenceData } from './Chat'
|
||||
import { Contact } from './Contact'
|
||||
import { ConnectionState } from './State'
|
||||
|
||||
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
|
||||
import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate, WAMessageKey } from './Message'
|
||||
|
||||
export type BaileysEventMap<T> = {
|
||||
/** connection state has been updated -- WS closed, opened, connecting etc. */
|
||||
'connection.update': Partial<ConnectionState>
|
||||
/** credentials updated -- some metadata, keys or something */
|
||||
'creds.update': Partial<T>
|
||||
/** set chats (history sync), messages are reverse chronologically sorted */
|
||||
'chats.set': { chats: Chat[], messages: WAMessage[] }
|
||||
/** upsert chats */
|
||||
'chats.upsert': Chat[]
|
||||
/** update the given chats */
|
||||
'chats.update': Partial<Chat>[]
|
||||
/** delete chats with given ID */
|
||||
'chats.delete': string[]
|
||||
/** presence of contact in a chat updated */
|
||||
'presence.update': { id: string, presences: { [participant: string]: PresenceData } }
|
||||
|
||||
'contacts.upsert': Contact[]
|
||||
'contacts.update': Partial<Contact>[]
|
||||
|
||||
'messages.delete': { keys: WAMessageKey[] } | { jid: string, all: true }
|
||||
'messages.update': WAMessageUpdate[]
|
||||
/**
|
||||
* add/update the given messages. If they were received while the connection was online,
|
||||
* the update will have type: "notify"
|
||||
* */
|
||||
'messages.upsert': { messages: WAMessage[], type: MessageUpdateType }
|
||||
|
||||
'message-info.update': MessageInfoUpdate[]
|
||||
|
||||
'groups.upsert': GroupMetadata[]
|
||||
'groups.update': Partial<GroupMetadata>[]
|
||||
/** apply an action to participants in a group */
|
||||
'group-participants.update': { id: string, participants: string[], action: ParticipantAction }
|
||||
|
||||
'blocklist.set': { blocklist: string[] }
|
||||
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }
|
||||
}
|
||||
|
||||
export interface CommonBaileysEventEmitter<Creds> extends EventEmitter {
|
||||
on<T extends keyof BaileysEventMap<Creds>>(event: T, listener: (arg: BaileysEventMap<Creds>[T]) => void): this
|
||||
off<T extends keyof BaileysEventMap<Creds>>(event: T, listener: (arg: BaileysEventMap<Creds>[T]) => void): this
|
||||
removeAllListeners<T extends keyof BaileysEventMap<Creds>>(event: T): this
|
||||
emit<T extends keyof BaileysEventMap<Creds>>(event: T, arg: BaileysEventMap<Creds>[T]): boolean
|
||||
}
|
||||
|
||||
export type BaileysEventEmitter = CommonBaileysEventEmitter<AuthenticationCreds>
|
||||
83
src/Types/Legacy.ts
Normal file
83
src/Types/Legacy.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { CommonSocketConfig } from "./Socket"
|
||||
import { CommonBaileysEventEmitter } from "./Events"
|
||||
import { BinaryNode } from "../WABinary"
|
||||
|
||||
export interface LegacyAuthenticationCreds {
|
||||
clientID: string
|
||||
serverToken: string
|
||||
clientToken: string
|
||||
encKey: Buffer
|
||||
macKey: Buffer
|
||||
}
|
||||
|
||||
/** used for binary messages */
|
||||
export enum WAMetric {
|
||||
debugLog = 1,
|
||||
queryResume = 2,
|
||||
liveLocation = 3,
|
||||
queryMedia = 4,
|
||||
queryChat = 5,
|
||||
queryContact = 6,
|
||||
queryMessages = 7,
|
||||
presence = 8,
|
||||
presenceSubscribe = 9,
|
||||
group = 10,
|
||||
read = 11,
|
||||
chat = 12,
|
||||
received = 13,
|
||||
picture = 14,
|
||||
status = 15,
|
||||
message = 16,
|
||||
queryActions = 17,
|
||||
block = 18,
|
||||
queryGroup = 19,
|
||||
queryPreview = 20,
|
||||
queryEmoji = 21,
|
||||
queryRead = 22,
|
||||
queryVCard = 29,
|
||||
queryStatus = 30,
|
||||
queryStatusUpdate = 31,
|
||||
queryLiveLocation = 33,
|
||||
queryLabel = 36,
|
||||
queryQuickReply = 39
|
||||
}
|
||||
|
||||
/** used for binary messages */
|
||||
export enum WAFlag {
|
||||
available = 160,
|
||||
other = 136, // don't know this one
|
||||
ignore = 1 << 7,
|
||||
acknowledge = 1 << 6,
|
||||
unavailable = 1 << 4,
|
||||
expires = 1 << 3,
|
||||
composing = 1 << 2,
|
||||
recording = 1 << 2,
|
||||
paused = 1 << 2
|
||||
}
|
||||
|
||||
/** Tag used with binary queries */
|
||||
export type WATag = [WAMetric, WAFlag]
|
||||
|
||||
export type SocketSendMessageOptions = {
|
||||
json: BinaryNode | any[]
|
||||
binaryTag?: WATag
|
||||
tag?: string
|
||||
longTag?: boolean
|
||||
}
|
||||
|
||||
export type SocketQueryOptions = SocketSendMessageOptions & {
|
||||
timeoutMs?: number
|
||||
expect200?: boolean
|
||||
requiresPhoneConnection?: boolean
|
||||
}
|
||||
|
||||
export type LegacySocketConfig = CommonSocketConfig<LegacyAuthenticationCreds> & {
|
||||
/** max time for the phone to respond to a connectivity test */
|
||||
phoneResponseTimeMs: number
|
||||
/** max time for WA server to respond before error with 422 */
|
||||
expectResponseTimeout: number
|
||||
|
||||
pendingRequestTimeoutMs: number
|
||||
}
|
||||
|
||||
export type LegacyBaileysEventEmitter = CommonBaileysEventEmitter<LegacyAuthenticationCreds>
|
||||
@@ -155,7 +155,7 @@ export type MessageContentGenerationOptions = MediaGenerationOptions & {
|
||||
}
|
||||
export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent
|
||||
|
||||
export type MessageUpdateType = 'append' | 'notify' | 'prepend'
|
||||
export type MessageUpdateType = 'append' | 'notify' | 'prepend' | 'last'
|
||||
|
||||
export type MessageInfoEventMap = { [jid: string]: Date }
|
||||
export interface MessageInfo {
|
||||
|
||||
39
src/Types/Socket.ts
Normal file
39
src/Types/Socket.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
import type { Agent } from "https"
|
||||
import type { Logger } from "pino"
|
||||
import type { URL } from "url"
|
||||
import type NodeCache from 'node-cache'
|
||||
|
||||
export type WAVersion = [number, number, number]
|
||||
export type WABrowserDescription = [string, string, string]
|
||||
|
||||
export type CommonSocketConfig<T> = {
|
||||
/** provide an auth state object to maintain the auth state */
|
||||
auth?: T
|
||||
/** the WS url to connect to WA */
|
||||
waWebSocketUrl: string | URL
|
||||
/** Fails the connection if the socket times out in this interval */
|
||||
connectTimeoutMs: number
|
||||
/** Default timeout for queries, undefined for no timeout */
|
||||
defaultQueryTimeoutMs: number | undefined
|
||||
/** ping-pong interval for WS connection */
|
||||
keepAliveIntervalMs: number
|
||||
/** proxy agent */
|
||||
agent?: Agent
|
||||
/** pino logger */
|
||||
logger: Logger
|
||||
/** version to connect with */
|
||||
version: WAVersion
|
||||
/** override browser config */
|
||||
browser: WABrowserDescription
|
||||
/** agent used for fetch requests -- uploading/downloading media */
|
||||
fetchAgent?: Agent
|
||||
/** should the QR be printed in the terminal */
|
||||
printQRInTerminal: boolean
|
||||
/** should events be emitted for actions done by this socket connection */
|
||||
emitOwnEvents: boolean
|
||||
/** provide a cache to store media, so does not have to be re-uploaded */
|
||||
mediaCache?: NodeCache
|
||||
|
||||
customUploadHosts: string[]
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Contact } from "./Contact"
|
||||
|
||||
export type WAConnectionState = 'open' | 'connecting' | 'close'
|
||||
|
||||
export type ConnectionState = {
|
||||
@@ -13,5 +15,11 @@ export type ConnectionState = {
|
||||
/** the current QR code */
|
||||
qr?: string
|
||||
/** has the device received all pending notifications while it was offline */
|
||||
receivedPendingNotifications?: boolean
|
||||
receivedPendingNotifications?: boolean
|
||||
/** legacy connection options */
|
||||
legacy?: {
|
||||
phoneConnected: boolean
|
||||
user?: Contact
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,11 +4,11 @@ export * from './Chat'
|
||||
export * from './Contact'
|
||||
export * from './State'
|
||||
export * from './Message'
|
||||
export * from './Legacy'
|
||||
export * from './Socket'
|
||||
export * from './Events'
|
||||
|
||||
import type EventEmitter from "events"
|
||||
import type { Agent } from "https"
|
||||
import type { Logger } from "pino"
|
||||
import type { URL } from "url"
|
||||
import type NodeCache from 'node-cache'
|
||||
|
||||
import { AuthenticationState, AuthenticationCreds } from './Auth'
|
||||
@@ -19,40 +19,11 @@ import { ConnectionState } from './State'
|
||||
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
|
||||
import { MessageInfoUpdate, MessageUpdateType, WAMessage, WAMessageUpdate, WAMessageKey } from './Message'
|
||||
import { proto } from '../../WAProto'
|
||||
import { CommonSocketConfig } from './Socket'
|
||||
|
||||
export type WAVersion = [number, number, number]
|
||||
export type WABrowserDescription = [string, string, string]
|
||||
export type ReconnectMode = 'no-reconnects' | 'on-any-error' | 'on-connection-error'
|
||||
|
||||
export type SocketConfig = {
|
||||
/** provide an auth state object to maintain the auth state */
|
||||
auth?: AuthenticationState
|
||||
/** the WS url to connect to WA */
|
||||
waWebSocketUrl: string | URL
|
||||
/** Fails the connection if the socket times out in this interval */
|
||||
connectTimeoutMs: number
|
||||
/** Default timeout for queries, undefined for no timeout */
|
||||
defaultQueryTimeoutMs: number | undefined
|
||||
/** ping-pong interval for WS connection */
|
||||
keepAliveIntervalMs: number
|
||||
/** proxy agent */
|
||||
agent?: Agent
|
||||
/** pino logger */
|
||||
logger: Logger
|
||||
/** version to connect with */
|
||||
version: WAVersion
|
||||
/** override browser config */
|
||||
browser: WABrowserDescription
|
||||
/** agent used for fetch requests -- uploading/downloading media */
|
||||
fetchAgent?: Agent
|
||||
/** should the QR be printed in the terminal */
|
||||
printQRInTerminal: boolean
|
||||
/** should events be emitted for actions done by this socket connection */
|
||||
emitOwnEvents: boolean
|
||||
export type SocketConfig = CommonSocketConfig<AuthenticationState> & {
|
||||
/** provide a cache to store a user's device list */
|
||||
userDevicesCache?: NodeCache
|
||||
/** provide a cache to store media, so does not have to be re-uploaded */
|
||||
mediaCache?: NodeCache
|
||||
/** map to store the retry counts for failed messages */
|
||||
msgRetryCounterMap?: { [msgId: string]: number }
|
||||
/** custom domains to push media via */
|
||||
@@ -67,11 +38,12 @@ export type SocketConfig = {
|
||||
export enum DisconnectReason {
|
||||
connectionClosed = 428,
|
||||
connectionLost = 408,
|
||||
connectionReplaced = 440,
|
||||
timedOut = 408,
|
||||
loggedOut = 401,
|
||||
badSession = 500,
|
||||
restartRequired = 410,
|
||||
notJoinedBeta = 403
|
||||
multideviceMismatch = 403
|
||||
}
|
||||
|
||||
export type WAInitResponse = {
|
||||
@@ -104,11 +76,11 @@ export type WABusinessProfile = {
|
||||
|
||||
export type CurveKeyPair = { private: Uint8Array; public: Uint8Array }
|
||||
|
||||
export type BaileysEventMap = {
|
||||
export type BaileysEventMap<T> = {
|
||||
/** connection state has been updated -- WS closed, opened, connecting etc. */
|
||||
'connection.update': Partial<ConnectionState>
|
||||
/** credentials updated -- some metadata, keys or something */
|
||||
'creds.update': Partial<AuthenticationCreds>
|
||||
'creds.update': Partial<T>
|
||||
/** set chats (history sync), messages are reverse chronologically sorted */
|
||||
'chats.set': { chats: Chat[], messages: WAMessage[] }
|
||||
/** upsert chats */
|
||||
@@ -141,9 +113,12 @@ export type BaileysEventMap = {
|
||||
'blocklist.set': { blocklist: string[] }
|
||||
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }
|
||||
}
|
||||
export interface BaileysEventEmitter extends EventEmitter {
|
||||
on<T extends keyof BaileysEventMap>(event: T, listener: (arg: BaileysEventMap[T]) => void): this
|
||||
off<T extends keyof BaileysEventMap>(event: T, listener: (arg: BaileysEventMap[T]) => void): this
|
||||
removeAllListeners<T extends keyof BaileysEventMap>(event: T): this
|
||||
emit<T extends keyof BaileysEventMap>(event: T, arg: BaileysEventMap[T]): boolean
|
||||
}
|
||||
|
||||
export interface CommonBaileysEventEmitter<Creds> extends EventEmitter {
|
||||
on<T extends keyof BaileysEventMap<Creds>>(event: T, listener: (arg: BaileysEventMap<Creds>[T]) => void): this
|
||||
off<T extends keyof BaileysEventMap<Creds>>(event: T, listener: (arg: BaileysEventMap<Creds>[T]) => void): this
|
||||
removeAllListeners<T extends keyof BaileysEventMap<Creds>>(event: T): this
|
||||
emit<T extends keyof BaileysEventMap<Creds>>(event: T, arg: BaileysEventMap<Creds>[T]): boolean
|
||||
}
|
||||
|
||||
export type BaileysEventEmitter = CommonBaileysEventEmitter<AuthenticationCreds>
|
||||
@@ -480,7 +480,7 @@ export const chatModificationToAppPatch = (
|
||||
if(mod.clear === 'all') {
|
||||
throw new Boom('not supported')
|
||||
} else {
|
||||
const key = mod.clear.message
|
||||
const key = mod.clear.messages[0]
|
||||
patch = {
|
||||
syncAction: {
|
||||
deleteMessageForMeAction: {
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from './noise-handler'
|
||||
export * from './history'
|
||||
export * from './chat-utils'
|
||||
export * from './lt-hash'
|
||||
export * from './auth-utils'
|
||||
export * from './auth-utils'
|
||||
export * from './legacy-msgs'
|
||||
173
src/Utils/legacy-msgs.ts
Normal file
173
src/Utils/legacy-msgs.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Boom } from '@hapi/boom'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { decodeBinaryNodeLegacy, jidNormalizedUser } from "../WABinary"
|
||||
import { aesDecrypt, hmacSign, hkdf, Curve } from "./crypto"
|
||||
import { BufferJSON } from './generics'
|
||||
import { DisconnectReason, WATag, LegacyAuthenticationCreds, CurveKeyPair, Contact } from "../Types"
|
||||
|
||||
export const newLegacyAuthCreds = () => ({
|
||||
clientID: randomBytes(16).toString('base64')
|
||||
}) as LegacyAuthenticationCreds
|
||||
|
||||
export const decodeWAMessage = (
|
||||
message: Buffer | string,
|
||||
auth: { macKey: Buffer, encKey: Buffer },
|
||||
fromMe: boolean=false
|
||||
) => {
|
||||
let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
|
||||
if (commaIndex < 0) throw new Boom('invalid message', { data: message }) // if there was no comma, then this message must be not be valid
|
||||
|
||||
if (message[commaIndex+1] === ',') commaIndex += 1
|
||||
let data = message.slice(commaIndex+1, message.length)
|
||||
|
||||
// get the message tag.
|
||||
// If a query was done, the server will respond with the same message tag we sent the query with
|
||||
const messageTag: string = message.slice(0, commaIndex).toString()
|
||||
let json: any
|
||||
let tags: WATag
|
||||
if(data.length) {
|
||||
const possiblyEnc = (data.length > 32 && data.length % 16 === 0)
|
||||
if(typeof data === 'string' || !possiblyEnc) {
|
||||
json = JSON.parse(data.toString()) // parse the JSON
|
||||
} else {
|
||||
|
||||
const { macKey, encKey } = auth || {}
|
||||
if (!macKey || !encKey) {
|
||||
throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession })
|
||||
}
|
||||
/*
|
||||
If the data recieved was not a JSON, then it must be an encrypted message.
|
||||
Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
|
||||
*/
|
||||
if (fromMe) {
|
||||
tags = [data[0], data[1]]
|
||||
data = data.slice(2, data.length)
|
||||
}
|
||||
|
||||
const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message
|
||||
data = data.slice(32, data.length) // the actual message
|
||||
const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey
|
||||
|
||||
if (checksum.equals(computedChecksum)) {
|
||||
// the checksum the server sent, must match the one we computed for the message to be valid
|
||||
const decrypted = aesDecrypt(data, encKey) // decrypt using AES
|
||||
json = decodeBinaryNodeLegacy(decrypted, { index: 0 }) // decode the binary message into a JSON array
|
||||
} else {
|
||||
throw new Boom('Bad checksum', {
|
||||
data: {
|
||||
received: checksum.toString('hex'),
|
||||
computed: computedChecksum.toString('hex'),
|
||||
data: data.slice(0, 80).toString(),
|
||||
tag: messageTag,
|
||||
message: message.slice(0, 80).toString()
|
||||
},
|
||||
statusCode: DisconnectReason.badSession
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [messageTag, json, tags] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in
|
||||
* @private
|
||||
* @param json
|
||||
*/
|
||||
export const validateNewConnection = (
|
||||
json: { [_: string]: any },
|
||||
auth: LegacyAuthenticationCreds,
|
||||
curveKeys: CurveKeyPair
|
||||
) => {
|
||||
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
||||
const onValidationSuccess = () => {
|
||||
const user: Contact = {
|
||||
id: jidNormalizedUser(json.wid),
|
||||
name: json.pushname
|
||||
}
|
||||
return { user, auth, phone: json.phone }
|
||||
}
|
||||
if (!json.secret) {
|
||||
// if we didn't get a secret, we don't need it, we're validated
|
||||
if (json.clientToken && json.clientToken !== auth.clientToken) {
|
||||
auth = { ...auth, clientToken: json.clientToken }
|
||||
}
|
||||
if (json.serverToken && json.serverToken !== auth.serverToken) {
|
||||
auth = { ...auth, serverToken: json.serverToken }
|
||||
}
|
||||
return onValidationSuccess()
|
||||
}
|
||||
const secret = Buffer.from(json.secret, 'base64')
|
||||
if (secret.length !== 144) {
|
||||
throw new Error ('incorrect secret length received: ' + secret.length)
|
||||
}
|
||||
|
||||
// generate shared key from our private key & the secret shared by the server
|
||||
const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32))
|
||||
// expand the key to 80 bytes using HKDF
|
||||
const expandedKey = hkdf(sharedKey as Buffer, 80, { })
|
||||
|
||||
// perform HMAC validation.
|
||||
const hmacValidationKey = expandedKey.slice(32, 64)
|
||||
const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)])
|
||||
|
||||
const hmac = hmacSign(hmacValidationMessage, hmacValidationKey)
|
||||
|
||||
if (!hmac.equals(secret.slice(32, 64))) {
|
||||
// if the checksums didn't match
|
||||
throw new Boom('HMAC validation failed', { statusCode: 400 })
|
||||
}
|
||||
|
||||
// computed HMAC should equal secret[32:64]
|
||||
// expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp
|
||||
// they are encrypted using key: expandedKey[0:32]
|
||||
const encryptedAESKeys = Buffer.concat([
|
||||
expandedKey.slice(64, expandedKey.length),
|
||||
secret.slice(64, secret.length),
|
||||
])
|
||||
const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32))
|
||||
// set the credentials
|
||||
auth = {
|
||||
encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages
|
||||
macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages
|
||||
clientToken: json.clientToken,
|
||||
serverToken: json.serverToken,
|
||||
clientID: auth.clientID,
|
||||
}
|
||||
return onValidationSuccess()
|
||||
}
|
||||
|
||||
export const computeChallengeResponse = (challenge: string, auth: LegacyAuthenticationCreds) => {
|
||||
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
|
||||
const signed = hmacSign(bytes, auth.macKey).toString('base64') // sign the challenge string with our macKey
|
||||
return['admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID
|
||||
}
|
||||
|
||||
export const useSingleFileLegacyAuthState = (file: string) => {
|
||||
// require fs here so that in case "fs" is not available -- the app does not crash
|
||||
const { readFileSync, writeFileSync, existsSync } = require('fs')
|
||||
let state: LegacyAuthenticationCreds
|
||||
|
||||
if(existsSync(file)) {
|
||||
state = JSON.parse(
|
||||
readFileSync(file, { encoding: 'utf-8' }),
|
||||
BufferJSON.reviver
|
||||
)
|
||||
if(typeof state.encKey === 'string') {
|
||||
state.encKey = Buffer.from(state.encKey, 'base64')
|
||||
}
|
||||
if(typeof state.macKey === 'string') {
|
||||
state.macKey = Buffer.from(state.macKey, 'base64')
|
||||
}
|
||||
} else {
|
||||
state = newLegacyAuthCreds()
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
saveState: () => {
|
||||
const str = JSON.stringify(state, BufferJSON.replacer, 2)
|
||||
writeFileSync(file, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,16 @@ import type { Options, Response } from 'got'
|
||||
import { Boom } from '@hapi/boom'
|
||||
import * as Crypto from 'crypto'
|
||||
import { Readable, Transform } from 'stream'
|
||||
import { createReadStream, createWriteStream, promises as fs, ReadStream, WriteStream } from 'fs'
|
||||
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 } from '../Types'
|
||||
import { MessageType, WAMessageContent, WAProto, WAGenericMediaMessage, WAMediaUpload, MediaType, DownloadableMessage, CommonSocketConfig, WAMediaUploadFunction, MediaConnInfo } from '../Types'
|
||||
import { generateMessageID } from './generics'
|
||||
import { hkdf } from './crypto'
|
||||
import { DEFAULT_ORIGIN } from '../Defaults'
|
||||
import { DEFAULT_ORIGIN, MEDIA_PATH_MAP } from '../Defaults'
|
||||
|
||||
const getTmpFilesDirectory = () => tmpdir()
|
||||
|
||||
@@ -459,4 +459,58 @@ export function extensionForMediaMessage(message: WAMessageContent) {
|
||||
extension = getExtension (messageContent.mimetype)
|
||||
}
|
||||
return extension
|
||||
}
|
||||
|
||||
export const getWAUploadToServer = ({ customUploadHosts, agent, logger }: CommonSocketConfig<any>, refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>): WAMediaUploadFunction => {
|
||||
return async(stream, { mediaType, fileEncSha256B64, timeoutMs }) => {
|
||||
const { default: got } = await import('got')
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
let uploadInfo = await refreshMediaConn(false)
|
||||
|
||||
let urls: { mediaUrl: string, directPath: string }
|
||||
const hosts = [ ...customUploadHosts, ...uploadInfo.hosts.map(h => h.hostname) ]
|
||||
for (let hostname of hosts) {
|
||||
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
|
||||
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
|
||||
try {
|
||||
const {body: responseText} = await got.post(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Origin': DEFAULT_ORIGIN
|
||||
},
|
||||
agent: {
|
||||
https: agent
|
||||
},
|
||||
body: stream,
|
||||
timeout: timeoutMs
|
||||
}
|
||||
)
|
||||
const result = JSON.parse(responseText)
|
||||
|
||||
if(result?.url || result?.directPath) {
|
||||
urls = {
|
||||
mediaUrl: result.url,
|
||||
directPath: result.direct_path
|
||||
}
|
||||
break
|
||||
} else {
|
||||
uploadInfo = await refreshMediaConn(true)
|
||||
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const isLast = hostname === hosts[uploadInfo.hosts.length-1]
|
||||
logger.debug(`Error in uploading to ${hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
|
||||
}
|
||||
}
|
||||
if (!urls) {
|
||||
throw new Boom(
|
||||
'Media upload failed on all hosts',
|
||||
{ statusCode: 500 }
|
||||
)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
}
|
||||
198
src/WABinary/Legacy/constants.ts
Normal file
198
src/WABinary/Legacy/constants.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
|
||||
export const Tags = {
|
||||
LIST_EMPTY: 0,
|
||||
STREAM_END: 2,
|
||||
DICTIONARY_0: 236,
|
||||
DICTIONARY_1: 237,
|
||||
DICTIONARY_2: 238,
|
||||
DICTIONARY_3: 239,
|
||||
LIST_8: 248,
|
||||
LIST_16: 249,
|
||||
JID_PAIR: 250,
|
||||
HEX_8: 251,
|
||||
BINARY_8: 252,
|
||||
BINARY_20: 253,
|
||||
BINARY_32: 254,
|
||||
NIBBLE_8: 255,
|
||||
SINGLE_BYTE_MAX: 256,
|
||||
PACKED_MAX: 254,
|
||||
}
|
||||
export const DoubleByteTokens = []
|
||||
export const SingleByteTokens = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'200',
|
||||
'400',
|
||||
'404',
|
||||
'500',
|
||||
'501',
|
||||
'502',
|
||||
'action',
|
||||
'add',
|
||||
'after',
|
||||
'archive',
|
||||
'author',
|
||||
'available',
|
||||
'battery',
|
||||
'before',
|
||||
'body',
|
||||
'broadcast',
|
||||
'chat',
|
||||
'clear',
|
||||
'code',
|
||||
'composing',
|
||||
'contacts',
|
||||
'count',
|
||||
'create',
|
||||
'debug',
|
||||
'delete',
|
||||
'demote',
|
||||
'duplicate',
|
||||
'encoding',
|
||||
'error',
|
||||
'false',
|
||||
'filehash',
|
||||
'from',
|
||||
'g.us',
|
||||
'group',
|
||||
'groups_v2',
|
||||
'height',
|
||||
'id',
|
||||
'image',
|
||||
'in',
|
||||
'index',
|
||||
'invis',
|
||||
'item',
|
||||
'jid',
|
||||
'kind',
|
||||
'last',
|
||||
'leave',
|
||||
'live',
|
||||
'log',
|
||||
'media',
|
||||
'message',
|
||||
'mimetype',
|
||||
'missing',
|
||||
'modify',
|
||||
'name',
|
||||
'notification',
|
||||
'notify',
|
||||
'out',
|
||||
'owner',
|
||||
'participant',
|
||||
'paused',
|
||||
'picture',
|
||||
'played',
|
||||
'presence',
|
||||
'preview',
|
||||
'promote',
|
||||
'query',
|
||||
'raw',
|
||||
'read',
|
||||
'receipt',
|
||||
'received',
|
||||
'recipient',
|
||||
'recording',
|
||||
'relay',
|
||||
'remove',
|
||||
'response',
|
||||
'resume',
|
||||
'retry',
|
||||
's.whatsapp.net',
|
||||
'seconds',
|
||||
'set',
|
||||
'size',
|
||||
'status',
|
||||
'subject',
|
||||
'subscribe',
|
||||
't',
|
||||
'text',
|
||||
'to',
|
||||
'true',
|
||||
'type',
|
||||
'unarchive',
|
||||
'unavailable',
|
||||
'url',
|
||||
'user',
|
||||
'value',
|
||||
'web',
|
||||
'width',
|
||||
'mute',
|
||||
'read_only',
|
||||
'admin',
|
||||
'creator',
|
||||
'short',
|
||||
'update',
|
||||
'powersave',
|
||||
'checksum',
|
||||
'epoch',
|
||||
'block',
|
||||
'previous',
|
||||
'409',
|
||||
'replaced',
|
||||
'reason',
|
||||
'spam',
|
||||
'modify_tag',
|
||||
'message_info',
|
||||
'delivery',
|
||||
'emoji',
|
||||
'title',
|
||||
'description',
|
||||
'canonical-url',
|
||||
'matched-text',
|
||||
'star',
|
||||
'unstar',
|
||||
'media_key',
|
||||
'filename',
|
||||
'identity',
|
||||
'unread',
|
||||
'page',
|
||||
'page_count',
|
||||
'search',
|
||||
'media_message',
|
||||
'security',
|
||||
'call_log',
|
||||
'profile',
|
||||
'ciphertext',
|
||||
'invite',
|
||||
'gif',
|
||||
'vcard',
|
||||
'frequent',
|
||||
'privacy',
|
||||
'blacklist',
|
||||
'whitelist',
|
||||
'verify',
|
||||
'location',
|
||||
'document',
|
||||
'elapsed',
|
||||
'revoke_invite',
|
||||
'expiration',
|
||||
'unsubscribe',
|
||||
'disable',
|
||||
'vname',
|
||||
'old_jid',
|
||||
'new_jid',
|
||||
'announcement',
|
||||
'locked',
|
||||
'prop',
|
||||
'label',
|
||||
'color',
|
||||
'call',
|
||||
'offer',
|
||||
'call-id',
|
||||
'quick_reply',
|
||||
'sticker',
|
||||
'pay_t',
|
||||
'accept',
|
||||
'reject',
|
||||
'sticker_pack',
|
||||
'invalid',
|
||||
'canceled',
|
||||
'missed',
|
||||
'connected',
|
||||
'result',
|
||||
'audio',
|
||||
'video',
|
||||
'recent',
|
||||
]
|
||||
337
src/WABinary/Legacy/index.ts
Normal file
337
src/WABinary/Legacy/index.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
|
||||
import { BinaryNode } from '../types'
|
||||
import { DoubleByteTokens, SingleByteTokens, Tags } from './constants'
|
||||
|
||||
export const isLegacyBinaryNode = (buffer: Buffer) => {
|
||||
switch(buffer[0]) {
|
||||
case Tags.LIST_EMPTY:
|
||||
case Tags.LIST_8:
|
||||
case Tags.LIST_16:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function decode(buffer: Buffer, indexRef: { index: number }): BinaryNode {
|
||||
|
||||
const checkEOS = (length: number) => {
|
||||
if (indexRef.index + length > buffer.length) {
|
||||
throw new Error('end of stream')
|
||||
}
|
||||
}
|
||||
const next = () => {
|
||||
const value = buffer[indexRef.index]
|
||||
indexRef.index += 1
|
||||
return value
|
||||
}
|
||||
const readByte = () => {
|
||||
checkEOS(1)
|
||||
return next()
|
||||
}
|
||||
const readStringFromChars = (length: number) => {
|
||||
checkEOS(length)
|
||||
const value = buffer.slice(indexRef.index, indexRef.index + length)
|
||||
|
||||
indexRef.index += length
|
||||
return value.toString('utf-8')
|
||||
}
|
||||
const readBytes = (n: number) => {
|
||||
checkEOS(n)
|
||||
const value = buffer.slice(indexRef.index, indexRef.index + n)
|
||||
indexRef.index += n
|
||||
return value
|
||||
}
|
||||
const readInt = (n: number, littleEndian = false) => {
|
||||
checkEOS(n)
|
||||
let val = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const shift = littleEndian ? i : n - 1 - i
|
||||
val |= next() << (shift * 8)
|
||||
}
|
||||
return val
|
||||
}
|
||||
const readInt20 = () => {
|
||||
checkEOS(3)
|
||||
return ((next() & 15) << 16) + (next() << 8) + next()
|
||||
}
|
||||
const unpackHex = (value: number) => {
|
||||
if (value >= 0 && value < 16) {
|
||||
return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10
|
||||
}
|
||||
throw new Error('invalid hex: ' + value)
|
||||
}
|
||||
const unpackNibble = (value: number) => {
|
||||
if (value >= 0 && value <= 9) {
|
||||
return '0'.charCodeAt(0) + value
|
||||
}
|
||||
switch (value) {
|
||||
case 10:
|
||||
return '-'.charCodeAt(0)
|
||||
case 11:
|
||||
return '.'.charCodeAt(0)
|
||||
case 15:
|
||||
return '\0'.charCodeAt(0)
|
||||
default:
|
||||
throw new Error('invalid nibble: ' + value)
|
||||
}
|
||||
}
|
||||
const unpackByte = (tag: number, value: number) => {
|
||||
if (tag === Tags.NIBBLE_8) {
|
||||
return unpackNibble(value)
|
||||
} else if (tag === Tags.HEX_8) {
|
||||
return unpackHex(value)
|
||||
} else {
|
||||
throw new Error('unknown tag: ' + tag)
|
||||
}
|
||||
}
|
||||
const readPacked8 = (tag: number) => {
|
||||
const startByte = readByte()
|
||||
let value = ''
|
||||
|
||||
for (let i = 0; i < (startByte & 127); i++) {
|
||||
const curByte = readByte()
|
||||
value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4))
|
||||
value += String.fromCharCode(unpackByte(tag, curByte & 0x0f))
|
||||
}
|
||||
if (startByte >> 7 !== 0) {
|
||||
value = value.slice(0, -1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const isListTag = (tag: number) => {
|
||||
return tag === Tags.LIST_EMPTY || tag === Tags.LIST_8 || tag === Tags.LIST_16
|
||||
}
|
||||
const readListSize = (tag: number) => {
|
||||
switch (tag) {
|
||||
case Tags.LIST_EMPTY:
|
||||
return 0
|
||||
case Tags.LIST_8:
|
||||
return readByte()
|
||||
case Tags.LIST_16:
|
||||
return readInt(2)
|
||||
default:
|
||||
throw new Error('invalid tag for list size: ' + tag)
|
||||
}
|
||||
}
|
||||
const getToken = (index: number) => {
|
||||
if (index < 3 || index >= SingleByteTokens.length) {
|
||||
throw new Error('invalid token index: ' + index)
|
||||
}
|
||||
return SingleByteTokens[index]
|
||||
}
|
||||
const readString = (tag: number) => {
|
||||
if (tag >= 3 && tag <= 235) {
|
||||
const token = getToken(tag)
|
||||
return token// === 's.whatsapp.net' ? 'c.us' : token
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case Tags.DICTIONARY_0:
|
||||
case Tags.DICTIONARY_1:
|
||||
case Tags.DICTIONARY_2:
|
||||
case Tags.DICTIONARY_3:
|
||||
return getTokenDouble(tag - Tags.DICTIONARY_0, readByte())
|
||||
case Tags.LIST_EMPTY:
|
||||
return null
|
||||
case Tags.BINARY_8:
|
||||
return readStringFromChars(readByte())
|
||||
case Tags.BINARY_20:
|
||||
return readStringFromChars(readInt20())
|
||||
case Tags.BINARY_32:
|
||||
return readStringFromChars(readInt(4))
|
||||
case Tags.JID_PAIR:
|
||||
const i = readString(readByte())
|
||||
const j = readString(readByte())
|
||||
if (typeof i === 'string' && j) {
|
||||
return i + '@' + j
|
||||
}
|
||||
throw new Error('invalid jid pair: ' + i + ', ' + j)
|
||||
case Tags.HEX_8:
|
||||
case Tags.NIBBLE_8:
|
||||
return readPacked8(tag)
|
||||
default:
|
||||
throw new Error('invalid string with tag: ' + tag)
|
||||
}
|
||||
}
|
||||
const readList = (tag: number) => (
|
||||
[...new Array(readListSize(tag))].map(() => decode(buffer, indexRef))
|
||||
)
|
||||
const getTokenDouble = (index1: number, index2: number) => {
|
||||
const n = 256 * index1 + index2
|
||||
if (n < 0 || n > DoubleByteTokens.length) {
|
||||
throw new Error('invalid double token index: ' + n)
|
||||
}
|
||||
return DoubleByteTokens[n]
|
||||
}
|
||||
|
||||
const listSize = readListSize(readByte())
|
||||
const descrTag = readByte()
|
||||
if (descrTag === Tags.STREAM_END) {
|
||||
throw new Error('unexpected stream end')
|
||||
}
|
||||
const header = readString(descrTag)
|
||||
const attrs: BinaryNode['attrs'] = { }
|
||||
let data: BinaryNode['content']
|
||||
if (listSize === 0 || !header) {
|
||||
throw new Error('invalid node')
|
||||
}
|
||||
// read the attributes in
|
||||
|
||||
const attributesLength = (listSize - 1) >> 1
|
||||
for (let i = 0; i < attributesLength; i++) {
|
||||
const key = readString(readByte())
|
||||
const b = readByte()
|
||||
|
||||
attrs[key] = readString(b)
|
||||
}
|
||||
|
||||
if (listSize % 2 === 0) {
|
||||
const tag = readByte()
|
||||
if (isListTag(tag)) {
|
||||
data = readList(tag)
|
||||
} else {
|
||||
let decoded: Buffer | string
|
||||
switch (tag) {
|
||||
case Tags.BINARY_8:
|
||||
decoded = readBytes(readByte())
|
||||
break
|
||||
case Tags.BINARY_20:
|
||||
decoded = readBytes(readInt20())
|
||||
break
|
||||
case Tags.BINARY_32:
|
||||
decoded = readBytes(readInt(4))
|
||||
break
|
||||
default:
|
||||
decoded = readString(tag)
|
||||
break
|
||||
}
|
||||
data = decoded
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tag: header,
|
||||
attrs,
|
||||
content: data
|
||||
}
|
||||
}
|
||||
|
||||
const encode = ({ tag, attrs, content }: BinaryNode, buffer: number[] = []) => {
|
||||
|
||||
const pushByte = (value: number) => buffer.push(value & 0xff)
|
||||
|
||||
const pushInt = (value: number, n: number, littleEndian=false) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const curShift = littleEndian ? i : n - 1 - i
|
||||
buffer.push((value >> (curShift * 8)) & 0xff)
|
||||
}
|
||||
}
|
||||
const pushBytes = (bytes: Uint8Array | Buffer | number[]) => (
|
||||
bytes.forEach (b => buffer.push(b))
|
||||
)
|
||||
const pushInt20 = (value: number) => (
|
||||
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
|
||||
)
|
||||
const writeByteLength = (length: number) => {
|
||||
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
|
||||
|
||||
if (length >= 1 << 20) {
|
||||
pushByte(Tags.BINARY_32)
|
||||
pushInt(length, 4) // 32 bit integer
|
||||
} else if (length >= 256) {
|
||||
pushByte(Tags.BINARY_20)
|
||||
pushInt20(length)
|
||||
} else {
|
||||
pushByte(Tags.BINARY_8)
|
||||
pushByte(length)
|
||||
}
|
||||
}
|
||||
const writeStringRaw = (str: string) => {
|
||||
const bytes = Buffer.from (str, 'utf-8')
|
||||
writeByteLength(bytes.length)
|
||||
pushBytes(bytes)
|
||||
}
|
||||
const writeToken = (token: number) => {
|
||||
if (token < 245) {
|
||||
pushByte(token)
|
||||
} else if (token <= 500) {
|
||||
throw new Error('invalid token')
|
||||
}
|
||||
}
|
||||
const writeString = (token: string, i?: boolean) => {
|
||||
if (token === 'c.us') token = 's.whatsapp.net'
|
||||
|
||||
const tokenIndex = SingleByteTokens.indexOf(token)
|
||||
if (!i && token === 's.whatsapp.net') {
|
||||
writeToken(tokenIndex)
|
||||
} else if (tokenIndex >= 0) {
|
||||
if (tokenIndex < Tags.SINGLE_BYTE_MAX) {
|
||||
writeToken(tokenIndex)
|
||||
} else {
|
||||
const overflow = tokenIndex - Tags.SINGLE_BYTE_MAX
|
||||
const dictionaryIndex = overflow >> 8
|
||||
if (dictionaryIndex < 0 || dictionaryIndex > 3) {
|
||||
throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex)
|
||||
}
|
||||
writeToken(Tags.DICTIONARY_0 + dictionaryIndex)
|
||||
writeToken(overflow % 256)
|
||||
}
|
||||
} else if (token) {
|
||||
const jidSepIndex = token.indexOf('@')
|
||||
if (jidSepIndex <= 0) {
|
||||
writeStringRaw(token)
|
||||
} else {
|
||||
writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length))
|
||||
}
|
||||
}
|
||||
}
|
||||
const writeJid = (left: string, right: string) => {
|
||||
pushByte(Tags.JID_PAIR)
|
||||
left && left.length > 0 ? writeString(left) : writeToken(Tags.LIST_EMPTY)
|
||||
writeString(right)
|
||||
}
|
||||
const writeListStart = (listSize: number) => {
|
||||
if (listSize === 0) {
|
||||
pushByte(Tags.LIST_EMPTY)
|
||||
} else if (listSize < 256) {
|
||||
pushBytes([Tags.LIST_8, listSize])
|
||||
} else {
|
||||
pushBytes([Tags.LIST_16, listSize])
|
||||
}
|
||||
}
|
||||
const validAttributes = Object.keys(attrs).filter(k => (
|
||||
typeof attrs[k] !== 'undefined' && attrs[k] !== null
|
||||
))
|
||||
|
||||
writeListStart(2*validAttributes.length + 1 + (typeof content !== 'undefined' && content !== null ? 1 : 0))
|
||||
writeString(tag)
|
||||
|
||||
validAttributes.forEach((key) => {
|
||||
if(typeof attrs[key] === 'string') {
|
||||
writeString(key)
|
||||
writeString(attrs[key])
|
||||
}
|
||||
})
|
||||
|
||||
if (typeof content === 'string') {
|
||||
writeString(content, true)
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
writeByteLength(content.length)
|
||||
pushBytes(content)
|
||||
} else if (Array.isArray(content)) {
|
||||
writeListStart(content.length)
|
||||
for(const item of content) {
|
||||
if(item) encode(item, buffer)
|
||||
}
|
||||
} else if(typeof content === 'undefined' || content === null) {
|
||||
|
||||
} else {
|
||||
throw new Error(`invalid children for header "${tag}": ${content} (${typeof content})`)
|
||||
}
|
||||
|
||||
return Buffer.from(buffer)
|
||||
}
|
||||
|
||||
export const encodeBinaryNodeLegacy = encode
|
||||
export const decodeBinaryNodeLegacy = decode
|
||||
@@ -2,6 +2,8 @@ import { DICTIONARIES_MAP, SINGLE_BYTE_TOKEN, SINGLE_BYTE_TOKEN_MAP, DICTIONARIE
|
||||
import { jidDecode, jidEncode } from './jid-utils';
|
||||
import { Binary, numUtf8Bytes } from '../../WABinary/Binary';
|
||||
import { Boom } from '@hapi/boom';
|
||||
import { proto } from '../../WAProto';
|
||||
import { BinaryNode } from './types';
|
||||
|
||||
const LIST1 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '.', '<27>', '<27>', '<27>', '<27>'];
|
||||
const LIST2 = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
|
||||
@@ -209,20 +211,6 @@ function bufferToUInt(e: Uint8Array | Buffer, t: number) {
|
||||
for (let i = 0; i < t; i++) a = 256 * a + e[i]
|
||||
return a
|
||||
}
|
||||
/**
|
||||
* the binary node WA uses internally for communication
|
||||
*
|
||||
* this is manipulated soley as an object and it does not have any functions.
|
||||
* This is done for easy serialization, to prevent running into issues with prototypes &
|
||||
* to maintain functional code structure
|
||||
* */
|
||||
export type BinaryNode = {
|
||||
tag: string
|
||||
attrs: { [key: string]: string }
|
||||
content?: BinaryNode[] | string | Uint8Array
|
||||
}
|
||||
export type BinaryNodeAttributes = BinaryNode['attrs']
|
||||
export type BinaryNodeData = BinaryNode['content']
|
||||
|
||||
export const decodeBinaryNode = (data: Binary): BinaryNode => {
|
||||
//U
|
||||
@@ -319,5 +307,19 @@ export const reduceBinaryNodeToDictionary = (node: BinaryNode, tag: string) => {
|
||||
return dict
|
||||
}
|
||||
|
||||
export const getBinaryNodeMessages = ({ content }: BinaryNode) => {
|
||||
const msgs: proto.WebMessageInfo[] = []
|
||||
if(Array.isArray(content)) {
|
||||
for(const item of content) {
|
||||
if(item.tag === 'message') {
|
||||
msgs.push(proto.WebMessageInfo.decode(item.content as Buffer))
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
export * from './jid-utils'
|
||||
export { Binary } from '../../WABinary/Binary'
|
||||
export { Binary } from '../../WABinary/Binary'
|
||||
export * from './types'
|
||||
export * from './Legacy'
|
||||
14
src/WABinary/types.ts
Normal file
14
src/WABinary/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* the binary node WA uses internally for communication
|
||||
*
|
||||
* this is manipulated soley as an object and it does not have any functions.
|
||||
* This is done for easy serialization, to prevent running into issues with prototypes &
|
||||
* to maintain functional code structure
|
||||
* */
|
||||
export type BinaryNode = {
|
||||
tag: string
|
||||
attrs: { [key: string]: string }
|
||||
content?: BinaryNode[] | string | Uint8Array
|
||||
}
|
||||
export type BinaryNodeAttributes = BinaryNode['attrs']
|
||||
export type BinaryNodeData = BinaryNode['content']
|
||||
@@ -1,4 +1,5 @@
|
||||
import makeWASocket from './Socket'
|
||||
import makeWALegacySocket from './LegacySocket'
|
||||
|
||||
export * from '../WAProto'
|
||||
export * from './Utils'
|
||||
@@ -7,6 +8,10 @@ export * from './Types'
|
||||
export * from './Defaults'
|
||||
export * from './WABinary'
|
||||
|
||||
export type WALegacySocket = ReturnType<typeof makeWALegacySocket>
|
||||
|
||||
export { makeWALegacySocket }
|
||||
|
||||
export type WASocket = ReturnType<typeof makeWASocket>
|
||||
|
||||
export default makeWASocket
|
||||
Reference in New Issue
Block a user