Wrap up connection + in memory store

This commit is contained in:
Adhiraj Singh
2021-07-09 20:35:07 +05:30
parent 5be4a9cc2c
commit 89cf8004e9
27 changed files with 4637 additions and 1317 deletions

View File

@@ -1,153 +1,13 @@
import {
WAConnection,
MessageType,
Presence,
MessageOptions,
Mimetype,
WALocationMessage,
WA_MESSAGE_STUB_TYPES,
ReconnectMode,
ProxyAgent,
waChatKey,
} from '../src/WAConnection'
import makeConnection from '../src'
import * as fs from 'fs'
async function example() {
const conn = new WAConnection() // instantiate
conn.autoReconnect = ReconnectMode.onConnectionLost // only automatically reconnect when the connection breaks
conn.logger.level = 'debug' // set to 'debug' to see what kind of stuff you can implement
// attempt to reconnect at most 10 times in a row
conn.connectOptions.maxRetries = 10
conn.chatOrderingKey = waChatKey(true) // order chats such that pinned chats are on top
conn.on('chats-received', ({ hasNewChats }) => {
console.log(`you have ${conn.chats.length} chats, new chats available: ${hasNewChats}`)
const conn = makeConnection({
credentials: './auth_info.json'
})
conn.on('contacts-received', () => {
console.log(`you have ${Object.keys(conn.contacts).length} contacts`)
conn.ev.on('connection.update', state => {
console.log(state)
})
conn.on('initial-data-received', () => {
console.log('received all initial messages')
})
// loads the auth file credentials if present
/* Note: one can take this auth_info.json file and login again from any computer without having to scan the QR code,
and get full access to one's WhatsApp. Despite the convenience, be careful with this file */
fs.existsSync('./auth_info.json') && conn.loadAuthInfo ('./auth_info.json')
// uncomment the following line to proxy the connection; some random proxy I got off of: https://proxyscrape.com/free-proxy-list
//conn.connectOptions.agent = ProxyAgent ('http://1.0.180.120:8080')
await conn.connect()
// credentials are updated on every connect
const authInfo = conn.base64EncodedAuthInfo() // get all the auth info we need to restore this session
fs.writeFileSync('./auth_info.json', JSON.stringify(authInfo, null, '\t')) // save this info to a file
console.log('oh hello ' + conn.user.name + ' (' + conn.user.jid + ')')
// uncomment to load all unread messages
//const unread = await conn.loadAllUnreadMessages ()
//console.log ('you have ' + unread.length + ' unread messages')
/**
* The universal event for anything that happens
* New messages, updated messages, read & delivered messages, participants typing etc.
*/
conn.on('chat-update', async chat => {
if (chat.presences) { // receive presence updates -- composing, available, etc.
Object.values(chat.presences).forEach(presence => console.log( `${presence.name}'s presence is ${presence.lastKnownPresence} in ${chat.jid}`))
}
if(chat.imgUrl) {
console.log('imgUrl of chat changed ', chat.imgUrl)
return
}
// only do something when a new message is received
if (!chat.hasNewMessage) {
if(chat.messages) {
console.log('updated message: ', chat.messages.first)
}
return
}
const m = chat.messages.all()[0] // pull the new message from the update
const messageStubType = WA_MESSAGE_STUB_TYPES[m.messageStubType] || 'MESSAGE'
console.log('got notification of type: ' + messageStubType)
const messageContent = m.message
// if it is not a regular text or media message
if (!messageContent) return
if (m.key.fromMe) {
console.log('relayed my own message')
return
}
let sender = m.key.remoteJid
if (m.key.participant) {
// participant exists if the message is in a group
sender += ' (' + m.key.participant + ')'
}
const messageType = Object.keys (messageContent)[0] // message will always contain one key signifying what kind of message
if (messageType === MessageType.text) {
const text = m.message.conversation
console.log(sender + ' sent: ' + text)
} else if (messageType === MessageType.extendedText) {
const text = m.message.extendedTextMessage.text
console.log(sender + ' sent: ' + text + ' and quoted message: ' + JSON.stringify(m.message))
} else if (messageType === MessageType.contact) {
const contact = m.message.contactMessage
console.log(sender + ' sent contact (' + contact.displayName + '): ' + contact.vcard)
} else if (messageType === MessageType.location || messageType === MessageType.liveLocation) {
const locMessage = m.message[messageType] as WALocationMessage
console.log(`${sender} sent location (lat: ${locMessage.degreesLatitude}, long: ${locMessage.degreesLongitude})`)
await conn.downloadAndSaveMediaMessage(m, './Media/media_loc_thumb_in_' + m.key.id) // save location thumbnail
if (messageType === MessageType.liveLocation) {
console.log(`${sender} sent live location for duration: ${m.duration/60}`)
}
} else {
// if it is a media (audio, image, video, sticker) message
// decode, decrypt & save the media.
// The extension to the is applied automatically based on the media type
try {
const savedFile = await conn.downloadAndSaveMediaMessage(m, './Media/media_in_' + m.key.id)
console.log(sender + ' sent media, saved at: ' + savedFile)
} catch (err) {
console.log('error in decoding message: ' + err)
}
}
// send a reply after 3 seconds
setTimeout(async () => {
await conn.chatRead(m.key.remoteJid) // mark chat read
await conn.updatePresence(m.key.remoteJid, Presence.available) // tell them we're available
await conn.updatePresence(m.key.remoteJid, Presence.composing) // tell them we're composing
const options: MessageOptions = { quoted: m }
let content
let type: MessageType
const rand = Math.random()
if (rand > 0.66) { // choose at random
content = 'hello!' // send a "hello!" & quote the message recieved
type = MessageType.text
} else if (rand > 0.33) { // choose at random
content = { degreesLatitude: 32.123123, degreesLongitude: 12.12123123 }
type = MessageType.location
} else {
content = fs.readFileSync('./Media/ma_gif.mp4') // load the gif
options.mimetype = Mimetype.gif
type = MessageType.video
}
const response = await conn.sendMessage(m.key.remoteJid, content, type, options)
console.log("sent message with ID '" + response.key.id + "' successfully")
}, 3 * 1000)
})
/* example of custom functionality for tracking battery */
conn.on('CB:action,,battery', json => {
const batteryLevelStr = json[2][0][1].value
const batterylevel = parseInt(batteryLevelStr)
console.log('battery level: ' + batterylevel)
})
conn.on('close', ({reason, isReconnecting}) => (
console.log ('oh no got disconnected: ' + reason + ', reconnecting: ' + isReconnecting)
))
}
example().catch((err) => console.log(`encountered error: ${err}`))
example().catch((err) => console.log(`encountered error`, err))

View File

@@ -1,6 +1,6 @@
{
"name": "@adiwajshing/baileys",
"version": "3.5.1",
"version": "4.0.0",
"description": "WhatsApp Web API",
"homepage": "https://github.com/adiwajshing/Baileys",
"main": "lib/index.js",
@@ -33,6 +33,7 @@
},
"dependencies": {
"@adiwajshing/keyed-db": "^0.2.2",
"@hapi/boom": "^9.1.3",
"curve25519-js": "^0.0.4",
"futoin-hkdf": "^1.3.2",
"got": "^11.8.1",
@@ -42,23 +43,25 @@
"pino": "^6.7.0",
"pino-pretty": "^4.3.0",
"protobufjs": "^6.10.1",
"qrcode-terminal": "^0.12.0",
"ws": "^7.3.1"
},
"optionalDependencies": {
"qrcode-terminal": "^0.12.0"
},
"files": [
"lib/*",
"WAMessage/*"
],
"devDependencies": {
"@types/got": "^9.6.11",
"@types/mocha": "^7.0.2",
"@types/jest": "^26.0.24",
"@types/node": "^14.6.2",
"@types/pino": "^6.3.2",
"@types/ws": "^7.2.6",
"assert": "^2.0.0",
"dotenv": "^8.2.0",
"mocha": "^8.1.3",
"ts-node-dev": "^1.0.0",
"jest": "^27.0.6",
"qrcode-terminal": "^0.12.0",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"typedoc": "^0.20.0-beta.27",
"typescript": "^4.0.0"
}

View File

@@ -17,10 +17,6 @@ const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = [])
const pushInt20 = (value: number) => (
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
)
const pushString = (str: string) => {
const bytes = Buffer.from (str, 'utf-8')
pushBytes(bytes)
}
const writeByteLength = (length: number) => {
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
@@ -35,9 +31,10 @@ const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = [])
pushByte(length)
}
}
const writeStringRaw = (string: string) => {
writeByteLength(string.length)
pushString(string)
const writeStringRaw = (str: string) => {
const bytes = Buffer.from (str, 'utf-8')
writeByteLength(bytes.length)
pushBytes(bytes)
}
const writeToken = (token: number) => {
if (token < 245) {

View File

@@ -1,6 +1,5 @@
import fs from 'fs'
import { decryptWA } from './WAConnection'
import Decoder from './Binary/Decoder'
import { decodeWAMessage } from './Utils/decode-wa-message'
interface BrowserMessagesInfo {
bundle: { encKey: string, macKey: string }
@@ -24,7 +23,7 @@ entries.forEach ((e, i) => {
wsMessages.push (...e['_webSocketMessages'])
}
})
const decrypt = (buffer, fromMe) => decryptWA (buffer, macKey, encKey, new Decoder(), fromMe)
const decrypt = (buffer, fromMe) => decodeWAMessage(buffer, { macKey, encKey }, fromMe)
console.log ('parsing ' + wsMessages.length + ' messages')
const list = wsMessages.map ((item, i) => {

View File

@@ -1,10 +1,10 @@
import Boom from "boom"
import { Boom } from '@hapi/boom'
import EventEmitter from "events"
import * as Curve from 'curve25519-js'
import { BaileysEventEmitter, BaileysEventMap, SocketConfig, CurveKeyPair, WAInitResponse, ConnectionState } from "../Types"
import { BaileysEventEmitter, BaileysEventMap, SocketConfig, CurveKeyPair, WAInitResponse, ConnectionState, DisconnectReason } from "../Types"
import { makeSocket } from "./socket"
import { generateClientID, promiseTimeout } from "../Utils/generics"
import { normalizedAuthInfo, computeChallengeResponse, validateNewConnection } from "../Utils/validateConnection"
import { normalizedAuthInfo, computeChallengeResponse, validateNewConnection } from "../Utils/validate-connection"
import { randomBytes } from "crypto"
import { AuthenticationCredentials } from "../Types"
@@ -78,7 +78,7 @@ const makeAuthSocket = (config: SocketConfig) => {
}
// will call state update to close connection
socket?.end(
Boom.unauthorized('Logged Out')
new Boom('Logged Out', { statusCode: DisconnectReason.credentialsInvalidated })
)
authInfo = undefined
}
@@ -89,7 +89,7 @@ const makeAuthSocket = (config: SocketConfig) => {
let listener: (item: BaileysEventMap['connection.update']) => void
const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs
if(timeout < 0) {
throw Boom.preconditionRequired('Connection Closed')
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
await (
@@ -99,7 +99,7 @@ const makeAuthSocket = (config: SocketConfig) => {
listener = ({ connection, lastDisconnect }) => {
if(connection === 'open') resolve()
else if(connection == 'close') {
reject(lastDisconnect.error || Boom.preconditionRequired('Connection Closed'))
reject(lastDisconnect.error || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }))
}
}
ev.on('connection.update', listener)
@@ -153,7 +153,7 @@ const makeAuthSocket = (config: SocketConfig) => {
}
qrLoop(ttl)
}
socketEvents.once('ws-open', async() => {
const onOpen = async() => {
const canDoLogin = canLogin()
const initQuery = (async () => {
const {ref, ttl} = await socket.query({
@@ -185,7 +185,7 @@ const makeAuthSocket = (config: SocketConfig) => {
logger.warn('Received login timeout req when state=open, ignoring...')
return
}
logger.debug('sending login request')
logger.info('sending login request')
socket.sendMessage({
json,
tag: loginTag
@@ -220,21 +220,32 @@ const makeAuthSocket = (config: SocketConfig) => {
response = await socket.waitForMessage('s2', true)
}
// validate the new connection
const {user, auth} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
const {user, auth, phone} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection
const isNewLogin = user.jid !== state.user?.jid
authInfo = auth
// update the keys so we can decrypt traffic
socket.updateKeys({ encKey: auth.encKey, macKey: auth.macKey })
logger.info({ user }, 'logged in')
updateState({
connection: 'open',
phoneConnected: true,
user,
isNewLogin,
phoneInfo: phone,
connectionTriesLeft: undefined,
qr: undefined
})
ev.emit('credentials.update', auth)
}
socketEvents.once('ws-open', async() => {
try {
await onOpen()
} catch(error) {
socket.end(error)
}
})
if(printQRInTerminal) {

View File

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

208
src/Connection/groups.ts Normal file
View File

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

12
src/Connection/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import { SocketConfig } from '../Types'
import { DEFAULT_CONNECTION_CONFIG } from '../Defaults'
import { EventEmitter } from 'events'
import * as Connection from './groups'
// export the last socket layer
const makeConnection = (config: Partial<SocketConfig>) => (
Connection.default({
...DEFAULT_CONNECTION_CONFIG,
...config
})
)
export default makeConnection

437
src/Connection/messages.ts Normal file
View File

@@ -0,0 +1,437 @@
import BinaryNode from "../BinaryNode";
import { Boom } from '@hapi/boom'
import { EventEmitter } from 'events'
import { Chat, Presence, SocketConfig, WAMessage, WAMessageKey, ParticipantAction, WAMessageProto, WAMessageStatus, WAMessageStubType, GroupMetadata, AnyMessageContent, MiscMessageGenerationOptions, WAFlag, WAMetric, WAUrlInfo, MediaConnInfo, MessageUpdateType, MessageInfo } from "../Types";
import { isGroupID, toNumber, whatsappID } from "../Utils/generics";
import makeChatsSocket from "./chats";
import { WA_DEFAULT_EPHEMERAL } from "../Defaults";
import { generateWAMessage } from "../Utils/messages";
import { decryptMediaMessageBuffer } from "../Utils/messages-media";
const STATUS_MAP = {
read: WAMessageStatus.READ,
message: WAMessageStatus.DELIVERY_ACK,
error: WAMessageStatus.ERROR
} as { [_: string]: WAMessageStatus }
const makeMessagesSocket = (config: SocketConfig) => {
const { logger } = config
const sock = makeChatsSocket(config)
const {
ev,
socketEvents,
query,
sendMessage,
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,
indexMessage?: { id?: string; fromMe?: boolean },
mostRecentFirst: boolean = true
) => {
const { data }:BinaryNode = await query({
json: new BinaryNode(
'query',
{
epoch: currentEpoch().toString(),
type: 'message',
jid: jid,
kind: mostRecentFirst ? 'before' : 'after',
count: count.toString(),
index: indexMessage?.id,
owner: indexMessage?.fromMe === false ? 'false' : 'true',
}
),
binaryTag: [WAMetric.queryMessages, WAFlag.ignore],
expect200: false,
requiresPhoneConnection: true
})
if(Array.isArray(data)) {
return data.map(data => data.data as WAMessage)
}
return []
}
const updateMediaMessage = async(message: WAMessage) => {
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
if (!content) throw new Boom(
`given message ${message.key.id} is not a media message`,
{ statusCode: 400, data: message }
)
const response = await query ({
json: new BinaryNode(
'query',
{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: currentEpoch().toString()}
),
binaryTag: [WAMetric.queryMedia, WAFlag.ignore],
expect200: true,
requiresPhoneConnection: true
})
Object.keys(response[1]).forEach (key => content[key] = response[1][key]) // update message
ev.emit('messages.upsert', { messages: [message], type: 'append' })
return response
}
const onMessage = (message: WAMessage, type: MessageUpdateType | 'update') => {
const jid = message.key.remoteJid!
// store chat updates in this
const chatUpdate: Partial<Chat> = {
jid,
t: +toNumber(message.messageTimestamp)
}
// add to count if the message isn't from me & there exists a message
if(!message.key.fromMe && message.message) {
chatUpdate.count = 1
const participant = whatsappID(message.participant || jid)
chatUpdate.presences = {
[participant]: {
lastKnownPresence: Presence.available
}
}
}
const ephemeralProtocolMsg = message.message?.ephemeralMessage?.message?.protocolMessage
if (
ephemeralProtocolMsg &&
ephemeralProtocolMsg.type === WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING
) {
chatUpdate.eph_setting_ts = message.messageTimestamp.toString()
chatUpdate.ephemeral = ephemeralProtocolMsg.ephemeralExpiration.toString()
}
const protocolMessage = message.message?.protocolMessage
// if it's a message to delete another message
if (protocolMessage) {
switch (protocolMessage.type) {
case WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE:
const key = protocolMessage.key
const messageStubType = WAMessageStubType.REVOKE
ev.emit('messages.update', [ { message: null, key, messageStubType } ])
return
default:
break
}
}
// check if the message is an action
if (message.messageStubType) {
const { user } = getState()
//let actor = whatsappID (message.participant)
let participants: string[]
const emitParticipantsUpdate = (action: ParticipantAction) => (
ev.emit('group-participants.update', { jid, participants, action })
)
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ { id: jid, ...update } ])
}
switch (message.messageStubType) {
case WAMessageStubType.CHANGE_EPHEMERAL_SETTING:
chatUpdate.eph_setting_ts = message.messageTimestamp.toString()
chatUpdate.ephemeral = message.messageStubParameters[0]
break
case WAMessageStubType.GROUP_PARTICIPANT_LEAVE:
case WAMessageStubType.GROUP_PARTICIPANT_REMOVE:
participants = message.messageStubParameters.map (whatsappID)
emitParticipantsUpdate('remove')
// mark the chat read only if you left the group
if (participants.includes(user.jid)) {
chatUpdate.read_only = 'true'
}
break
case WAMessageStubType.GROUP_PARTICIPANT_ADD:
case WAMessageStubType.GROUP_PARTICIPANT_INVITE:
case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN:
participants = message.messageStubParameters.map (whatsappID)
if (participants.includes(user.jid)) {
chatUpdate.read_only = 'false'
}
emitParticipantsUpdate('add')
break
case WAMessageStubType.GROUP_CHANGE_ANNOUNCE:
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ announce })
break
case WAMessageStubType.GROUP_CHANGE_RESTRICT:
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
emitGroupUpdate({ restrict })
break
case WAMessageStubType.GROUP_CHANGE_SUBJECT:
case WAMessageStubType.GROUP_CREATE:
chatUpdate.name = message.messageStubParameters[0]
emitGroupUpdate({ subject: chatUpdate.name })
break
}
}
if(Object.keys(chatUpdate).length > 1) {
ev.emit('chats.update', [chatUpdate])
}
if(type === 'update') {
ev.emit('messages.update', [message])
} else {
ev.emit('messages.upsert', { messages: [message], type })
}
}
/** Query a string to check if it has a url, if it does, return WAUrlInfo */
const generateUrlInfo = async(text: string) => {
const response = await query({
json: new BinaryNode(
'query',
{type: 'url', url: text, epoch: currentEpoch().toString()}
),
binaryTag: [26, WAFlag.ignore],
expect200: true,
requiresPhoneConnection: false
})
if(response[1]) {
response[1].jpegThumbnail = response[2]
}
return response[1] as WAUrlInfo
}
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
const relayWAMessage = async(message: WAMessage, { waitForAck } = { waitForAck: true }) => {
const json = new BinaryNode(
'action',
{ epoch: currentEpoch().toString(), type: 'relay' },
[ new BinaryNode('message', {}, message) ]
)
const flag = message.key.remoteJid === getState().user?.jid ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
const mID = message.key.id
message.status = WAMessageStatus.PENDING
const promise = query({
json,
binaryTag: [WAMetric.message, flag],
tag: mID,
expect200: true,
requiresPhoneConnection: true
})
if(waitForAck) {
await promise
message.status = WAMessageStatus.SERVER_ACK
} else {
const emitUpdate = (status: WAMessageStatus) => {
message.status = status
ev.emit('messages.update', [ { key: message.key, status } ])
}
promise
.then(() => emitUpdate(WAMessageStatus.SERVER_ACK))
.catch(() => emitUpdate(WAMessageStatus.ERROR))
}
onMessage(message, 'append')
}
// messages received
const messagesUpdate = ({ data }: BinaryNode, type: 'prepend' | 'last') => {
if(Array.isArray(data)) {
const messages: WAMessage[] = []
for(let i = data.length-1; i >= 0;i--) {
messages.push(data[i].data as WAMessage)
}
ev.emit('messages.upsert', { messages, type })
}
}
socketEvents.on('CB:action,add:last', json => messagesUpdate(json, 'last'))
socketEvents.on('CB:action,add:unread', json => messagesUpdate(json, 'prepend'))
socketEvents.on('CB:action,add:before', json => messagesUpdate(json, 'prepend'))
// new messages
socketEvents.on('CB:action,add:relay,message', ({data}: BinaryNode) => {
if(Array.isArray(data)) {
for(const { data: msg } of data) {
onMessage(msg as WAMessage, 'notify')
}
}
})
// If a message has been updated (usually called when a video message gets its upload url, or live locations)
socketEvents.on ('CB:action,add:update,message', ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
for(const { data: msg } of data) {
onMessage(msg as WAMessage, 'update')
}
}
})
// message status updates
const onMessageStatusUpdate = ({ data }: BinaryNode) => {
if(Array.isArray(data)) {
for(const { attributes: json } of data) {
const key: WAMessageKey = {
remoteJid: whatsappID(json.jid),
id: json.index,
fromMe: json.owner === 'true'
}
const status = STATUS_MAP[json.type]
if(status) {
ev.emit('messages.update', [ { key, status } ])
} else {
logger.warn({ data }, 'got unknown status update for message')
}
}
}
}
socketEvents.on('CB:action,add:relay,received', onMessageStatusUpdate)
socketEvents.on('CB:action,,received', onMessageStatusUpdate)
return {
...sock,
relayWAMessage,
generateUrlInfo,
messageInfo: async(jid: string, messageID: string) => {
const { data }: BinaryNode = await query({
json: new BinaryNode(
'query',
{type: 'message_info', index: messageID, jid: jid, epoch: currentEpoch().toString()}
),
binaryTag: [WAMetric.queryRead, WAFlag.ignore],
expect200: true,
requiresPhoneConnection: true
})
const info: MessageInfo = {reads: [], deliveries: []}
if(Array.isArray(data)) {
for(const { header, attributes } of data) {
switch(header) {
case 'read':
info.reads.push(attributes as any)
break
case 'delivery':
info.deliveries.push(attributes as any)
break
}
}
}
return info
},
downloadMediaMessage: async(message: WAMessage, type: 'buffer' | 'stream' = 'buffer') => {
let mContent = message.message?.ephemeralMessage?.message || message.message
if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
const downloadMediaMessage = async () => {
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 instanceof Boom && error.output?.statusCode === 404) { // media needs to be updated
logger.info (`updating media of message: ${message.key.id}`)
await updateMediaMessage(message)
mContent = message.message?.ephemeralMessage?.message || message.message
const result = await downloadMediaMessage()
return result
}
throw error
}
},
updateMediaMessage,
fetchMessagesFromWA,
searchMessages: async(txt: string, inJid: string | null, count: number, page: number) => {
const {data, attributes}: BinaryNode = await query({
json: new BinaryNode(
'query',
{
epoch: currentEpoch().toString(),
type: 'search',
search: txt,
count: count.toString(),
page: page.toString(),
jid: inJid
}
),
binaryTag: [24, WAFlag.ignore],
expect200: true
}) // encrypt and send off
const messages = Array.isArray(data) ? data.map(item => item.data as WAMessage) : []
return {
last: attributes?.last === 'true',
messages
}
},
sendWAMessage: async(
jid: string,
content: AnyMessageContent,
options: MiscMessageGenerationOptions & { waitForAck?: boolean }
) => {
const userJid = getState().user?.jid
if(
typeof content === 'object' &&
'disappearingMessagesInChat' in content &&
typeof content['disappearingMessagesInChat'] !== 'undefined' &&
isGroupID(jid)
) {
const { disappearingMessagesInChat } = content
const value = typeof disappearingMessagesInChat === 'boolean' ?
(disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
disappearingMessagesInChat
const tag = generateMessageTag(true)
await setQuery([
new BinaryNode(
'group',
{ id: tag, jid, type: 'prop', author: userJid },
[ new BinaryNode('ephemeral', { value: value.toString() }) ]
)
], [WAMetric.group, WAFlag.other], tag)
} else {
const msg = await generateWAMessage(
jid,
content,
{
...options,
userJid: userJid,
/*ephemeralOptions: chat?.ephemeral ? {
expiration: chat.ephemeral,
eph_setting_ts: chat.eph_setting_ts
} : undefined,*/
getUrlInfo: generateUrlInfo,
getMediaOptions: refreshMediaConn
}
)
await relayWAMessage(msg, { waitForAck: options.waitForAck })
return msg
}
}
}
}
export default makeMessagesSocket

View File

@@ -1,4 +1,4 @@
import Boom from "boom"
import { Boom } from '@hapi/boom'
import EventEmitter from "events"
import { STATUS_CODES } from "http"
import { promisify } from "util"
@@ -6,7 +6,7 @@ import WebSocket from "ws"
import BinaryNode from "../BinaryNode"
import { DisconnectReason, SocketConfig, SocketQueryOptions, SocketSendMessageOptions } from "../Types"
import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds } from "../Utils/generics"
import { decodeWAMessage } from "../Utils/decodeWAMessage"
import { decodeWAMessage } from "../Utils/decode-wa-message"
import { WAFlag, WAMetric, WATag } from "../Types"
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults"
@@ -284,7 +284,7 @@ export const makeSocket = ({
const waitForSocketOpen = async() => {
if(ws.readyState === ws.OPEN) return
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
throw Boom.preconditionRequired('Connection Closed')
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
}
let onOpen: () => void
let onClose: (err: Error) => void

View File

@@ -9,6 +9,11 @@ export const DEF_CALLBACK_PREFIX = 'CB:'
export const DEF_TAG_PREFIX = 'TAG:'
export const PHONE_CONNECTION_CB = 'CB:Pong'
export const WA_DEFAULT_EPHEMERAL = 7*24*60*60
/** 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, 2123, 8],
browser: Browsers.baileys('Chrome'),

View File

@@ -0,0 +1,163 @@
import KeyedDB from "@adiwajshing/keyed-db"
import { Logger } from "pino"
import makeConnection from "../Connection"
import { BaileysEventEmitter, Chat, ConnectionState, Contact, WAMessage, WAMessageKey } from "../Types"
export const waChatKey = (pin: boolean) => ({
key: (c: Chat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive === 'true' ? '0' : '1') + c.t.toString(16).padStart(8, '0') + c.jid,
compare: (k1: string, k2: string) => k2.localeCompare (k1)
})
export type BaileysInMemoryStoreConfig = {
logger: Logger
}
export default(
{ logger }: BaileysInMemoryStoreConfig
) => {
const chats = new KeyedDB<Chat, string>(waChatKey(true), c => c.jid)
const messages: { [_: string]: WAMessage[] } = {}
const contacts: { [_: string]: Contact } = {}
const state: ConnectionState = {
connection: 'close',
phoneConnected: false
}
const messageIndex = (key: WAMessageKey) => {
const messageList = messages[key.remoteJid!]
if(messageList) {
const idx = messageList.findIndex(m => m.key.id === key.id)
if(idx >= 0) {
return { messageList, idx }
}
}
}
const listen = (ev: BaileysEventEmitter) => {
ev.on('connection.update', update => {
Object.assign(state, update)
})
ev.on('contacts.upsert', ({ contacts: newContacts, type }) => {
const oldContacts = new Set(Object.keys(contacts))
for(const contact of newContacts) {
oldContacts.delete(contact.jid)
contacts[contact.jid] = Object.assign(
contacts[contact.jid] || {},
contact
)
}
if(type === 'set') {
for(const jid of oldContacts) {
delete contacts[jid]
}
logger.debug({ deletedContacts: oldContacts.size }, 'synced contacts')
}
})
ev.on('contacts.update', updates => {
for(const update of updates) {
if(contacts[update.jid!]) {
Object.assign(contacts[update.jid!], update)
} else {
logger.debug({ update }, `got update for non-existant contact`)
}
}
})
ev.on('chats.upsert', ({ chats: newChats, type }) => {
if(type === 'set') {
chats.clear()
}
for(const chat of newChats) {
chats.upsert(chat)
}
})
ev.on('chats.update', updates => {
for(const update of updates) {
const result = chats.update(update.jid!, chat => {
Object.assign(chat, update)
})
if(!result) {
logger.debug({ update }, `got update for non-existant chat`)
}
}
})
ev.on('chats.delete', deletions => {
for(const item of deletions) {
chats.deleteById(item)
}
})
ev.on('messages.upsert', ({ messages: newMessages, type }) => {
switch(type) {
case 'append':
case 'notify':
for(const msg of newMessages) {
const jid = msg.key.remoteJid!
const result = messageIndex(newMessages[0].key)
if(!result) {
if(!messages[jid]) {
messages[jid] = []
}
messages[jid].push(msg)
} else {
result.messageList[result.idx] = msg
}
}
break
case 'last':
for(const msg of newMessages) {
const jid = msg.key.remoteJid!
if(!messages[jid]) {
messages[jid] = []
}
const [lastItem] = messages[jid].slice(-1)
// reset message list
if(lastItem && lastItem.key.id !== msg.key.id) {
messages[jid] = [msg]
}
}
break
case 'prepend':
break
}
})
ev.on('messages.update', updates => {
for(const update of updates) {
const result = messageIndex(update.key!)
if(result) {
Object.assign(result.messageList[result.idx], update)
} else {
logger.debug({ update }, `got update for non-existant message`)
}
}
})
ev.on('messages.delete', item => {
if('all' in item) {
messages[item.jid] = []
} else {
const idSet = new Set(item.ids)
if(messages[item.jid]) {
messages[item.jid] = messages[item.jid].filter(
m => !idSet.has(m.key.id)
)
}
}
})
}
return {
chats,
contacts,
messages,
listen,
fetchImageUrl: async(jid: string, sock: ReturnType<typeof makeConnection>) => {
const contact = contacts[jid]
if(!contact) {
return sock.fetchImageUrl(jid)
}
if(!contact.imgUrl) {
contact.imgUrl = await sock.fetchImageUrl(jid)
}
return contact.imgUrl
}
}
}

2
src/Store/index.ts Normal file
View File

@@ -0,0 +1,2 @@
import inMemoryStore from "./in-memory-store";
export default inMemoryStore

View File

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

View File

@@ -1,33 +1,31 @@
import Boom from 'boom'
import { Boom } from '@hapi/boom'
import P from 'pino'
import BinaryNode from '../BinaryNode'
import makeConnection, { Connection, DisconnectReason } from '../makeConnection'
import { delay } from '../WAConnection/Utils'
import makeConnection from '../Connection'
import { delay } from '../Utils/generics'
describe('QR Generation', () => {
it('should generate QR', async () => {
const QR_GENS = 1
const {ev, open} = makeConnection({
const {ev} = makeConnection({
maxRetries: 0,
maxQRCodes: QR_GENS,
logger: P({ level: 'trace' })
})
let calledQR = 0
ev.removeAllListeners('qr')
ev.on('state.update', ({ qr }) => {
ev.on('connection.update', ({ qr }) => {
if(qr) calledQR += 1
})
await expect(open()).rejects.toThrowError('Too many QR codes')
expect(
Object.keys(ev.eventNames()).filter(key => key.startsWith('TAG:'))
).toHaveLength(0)
expect(calledQR).toBeGreaterThanOrEqual(QR_GENS)
}, 60_000)
})
describe('Test Connect', () => {
const logger = P({ level: 'trace' })
it('should connect', async () => {
logger.info('please be ready to scan with your phone')
@@ -36,33 +34,38 @@ describe('Test Connect', () => {
logger,
printQRInTerminal: true
})
await conn.open()
await conn.waitForConnection(true)
const { user, isNewLogin } = await conn.getState()
expect(user).toHaveProperty('jid')
expect(user).toHaveProperty('name')
expect(isNewLogin).toBe(true)
conn.close()
conn.end(undefined)
}, 65_000)
it('should restore session', async () => {
const conn = makeConnection({
let conn = makeConnection({
printQRInTerminal: true,
logger,
})
await conn.open()
conn.close()
await conn.waitForConnection(true)
conn.end(undefined)
await delay(2500)
await conn.open()
conn = makeConnection({
printQRInTerminal: true,
logger,
})
await conn.waitForConnection(true)
const { user, isNewLogin, qr } = await conn.getState()
expect(user).toHaveProperty('jid')
expect(user).toHaveProperty('name')
expect(isNewLogin).toBe(false)
expect(qr).toBe(undefined)
conn.close()
conn.end(undefined)
}, 65_000)
it('should logout', async () => {
@@ -70,7 +73,8 @@ describe('Test Connect', () => {
printQRInTerminal: true,
logger,
})
await conn.open()
await conn.waitForConnection(true)
const { user, qr } = await conn.getState()
expect(user).toHaveProperty('jid')
expect(user).toHaveProperty('name')
@@ -83,118 +87,8 @@ describe('Test Connect', () => {
credentials,
logger
})
await expect(conn.open()).rejects.toThrowError('Unexpected error in login')
await expect(
conn.waitForConnection()
).rejects.toThrowError('Unexpected error in login')
}, 65_000)
})
describe ('Reconnects', () => {
const verifyConnectionOpen = async (conn: Connection) => {
expect((await conn.getState()).user).toBeDefined()
let failed = false
// check that the connection stays open
conn.ev.on('state.update', ({ connection, lastDisconnect }) => {
if(connection === 'close' && !!lastDisconnect.error) {
failed = true
}
})
await delay (60*1000)
conn.close ()
expect(failed).toBe(false)
}
it('should dispose correctly on bad_session', async () => {
const conn = makeConnection({
reconnectMode: 'on-any-error',
credentials: './auth_info.json',
maxRetries: 2,
connectCooldownMs: 500
})
let gotClose0 = false
let gotClose1 = false
const openPromise = conn.open()
conn.getSocket().ev.once('ws-close', () => {
gotClose0 = true
})
conn.ev.on('state.update', ({ lastDisconnect }) => {
//@ts-ignore
if(lastDisconnect?.error?.output?.statusCode === DisconnectReason.badSession) {
gotClose1 = true
}
})
setTimeout (() => conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500)
await openPromise
console.log('opened connection')
await delay(1000)
conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je'))
await delay(2000)
await conn.waitForConnection()
conn.close()
expect(gotClose0).toBe(true)
expect(gotClose1).toBe(true)
}, 20_000)
/**
* the idea is to test closing the connection at multiple points in the connection
* and see if the library cleans up resources correctly
*/
it('should cleanup correctly', async () => {
const conn = makeConnection({
reconnectMode: 'on-any-error',
credentials: './auth_info.json'
})
let timeoutMs = 100
while (true) {
let tmout = setTimeout (() => {
conn.close()
}, timeoutMs)
try {
await conn.open()
clearTimeout (tmout)
break
} catch (error) {
}
// exponentially increase the timeout disconnect
timeoutMs *= 2
}
await verifyConnectionOpen(conn)
}, 120_000)
/**
* the idea is to test closing the connection at multiple points in the connection
* and see if the library cleans up resources correctly
*/
it('should disrupt connect loop', async () => {
const conn = makeConnection({
reconnectMode: 'on-any-error',
credentials: './auth_info.json'
})
let timeout = 1000
let tmout
const endConnection = async () => {
while (!conn.getSocket()) {
await delay(100)
}
conn.getSocket().end(Boom.preconditionRequired('conn close'))
while (conn.getSocket()) {
await delay(100)
}
timeout *= 2
tmout = setTimeout (endConnection, timeout)
}
tmout = setTimeout (endConnection, timeout)
await conn.open()
clearTimeout (tmout)
await verifyConnectionOpen(conn)
}, 120_000)
})

View File

@@ -1,7 +1,3 @@
import type KeyedDB from "@adiwajshing/keyed-db";
import type { proto } from '../../WAMessage/WAMessage'
import type { GroupMetadata } from "./GroupMetadata";
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
export enum Presence {
unavailable = 'unavailable', // "offline"
@@ -37,8 +33,25 @@ export interface Chat {
ephemeral?: string
// Baileys added properties
messages: KeyedDB<proto.IWebMessageInfo, string>
imgUrl?: string
presences?: { [k: string]: PresenceData }
metadata?: GroupMetadata
}
}
export type ChatModification =
{ archive: boolean } |
{
/** pin at current timestamp, or provide timestamp of pin to remove */
pin: true | { remove: number }
} |
{
/** mute for duration, or provide timestamp of mute to remove*/
mute: number | { remove: number }
} |
{
clear: 'all' | { messages: { id: string, fromMe?: boolean }[] }
} |
{
star: {
messages: { id: string, fromMe?: boolean }[],
star: boolean
}
}

View File

@@ -12,4 +12,5 @@ export interface Contact {
short?: string
// Baileys Added
imgUrl?: string
status?: string
}

View File

@@ -2,6 +2,8 @@ import { Contact } from "./Contact";
export type GroupParticipant = (Contact & { isAdmin: boolean; isSuperAdmin: boolean })
export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote'
export interface GroupMetadata {
id: string
owner: string
@@ -16,4 +18,16 @@ export interface GroupMetadata {
announce?: 'true' | 'false'
// Baileys modified array
participants: GroupParticipant[]
}
export interface WAGroupCreateResponse {
status: number
gid?: string
participants?: [{ [key: string]: any }]
}
export interface GroupModificationResponse {
status: number
participants?: { [key: string]: any }
}

135
src/Types/Message.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { Agent } from "https"
import type { Logger } from "pino"
import type { URL } from "url"
import { proto } from '../../WAMessage/WAMessage'
// export the WAMessage Prototypes
export { proto as WAMessageProto }
export type WAMessage = proto.WebMessageInfo
export type WAMessageContent = proto.IMessage
export type WAContactMessage = proto.ContactMessage
export type WAContactsArrayMessage = proto.ContactsArrayMessage
export type WAMessageKey = proto.IMessageKey
export type WATextMessage = proto.ExtendedTextMessage
export type WAContextInfo = proto.IContextInfo
export type WALocationMessage = proto.LocationMessage
export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage | proto.IStickerMessage
export import WAMessageStubType = proto.WebMessageInfo.WebMessageInfoStubType
export import WAMessageStatus = proto.WebMessageInfo.WebMessageInfoStatus
export type WAMediaUpload = Buffer | { url: URL | string }
/** Set of message types that are supported by the library */
export type MessageType = keyof proto.Message
export type MediaConnInfo = {
auth: string
ttl: number
hosts: {
hostname: string
}[]
fetchDate: Date
}
/** Reverse stub type dictionary */
export const WA_MESSAGE_STUB_TYPES = function () {
const types = WAMessageStubType
const dict: Record<number, string> = {}
Object.keys(types).forEach(element => dict[ types[element] ] = element)
return dict
}()
export interface WAUrlInfo {
'canonical-url': string
'matched-text': string
title: string
description: string
jpegThumbnail?: Buffer
}
// types to generate WA messages
type Mentionable = {
/** list of jids that are mentioned in the accompanying text */
mentions?: string[]
}
export type MediaType = 'image' | 'video' | 'sticker' | 'audio' | 'document'
export type AnyMediaMessageContent = (
({
image: WAMediaUpload
caption?: string
jpegThumbnail?: string
} & Mentionable) |
({
video: WAMediaUpload
caption?: string
gifPlayback?: boolean
jpegThumbnail?: string
} & Mentionable) | {
audio: WAMediaUpload
/** if set to true, will send as a `voice note` */
pttAudio?: boolean
/** optionally tell the duration of the audio */
seconds?: number
} | {
sticker: WAMediaUpload
} | {
document: WAMediaUpload
mimetype: string
fileName?: string
}) &
{ mimetype?: string }
export type AnyRegularMessageContent =
string |
({
text: string
}
& Mentionable) |
AnyMediaMessageContent |
{
contacts: {
displayName?: string
contacts: WAContactMessage[]
}
} |
{
location: WALocationMessage
}
export type AnyMessageContent = AnyRegularMessageContent | {
forward: WAMessage
force?: boolean
} | {
delete: WAMessageKey
} | {
disappearingMessagesInChat: boolean | number
}
export type MiscMessageGenerationOptions = {
/** Force message id */
messageId?: string
/** optional, if you want to manually set the timestamp of the message */
timestamp?: Date
/** the message you want to quote */
quoted?: WAMessage
}
export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & {
userJid: string
ephemeralOptions?: {
expiration: number | string
eph_setting_ts: number | string
}
}
export type MediaGenerationOptions = {
logger?: Logger
agent?: Agent
getMediaOptions: (refresh: boolean) => Promise<MediaConnInfo>
}
export type MessageContentGenerationOptions = MediaGenerationOptions & {
getUrlInfo?: (text: string) => Promise<WAUrlInfo>
}
export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent
export type MessageUpdateType = 'prepend' | 'append' | 'notify' | 'last'
export interface MessageInfo {
reads: {jid: string, t: string}[]
deliveries: {jid: string, t: string}[]
}

View File

@@ -3,15 +3,21 @@ export * from './GroupMetadata'
export * from './Chat'
export * from './Contact'
export * from './Store'
export * from './Message'
import type EventEmitter from "events"
import type { Agent } from "https"
import type { Logger } from "pino"
import type { URL } from "url"
import type BinaryNode from "../BinaryNode"
import { AnyAuthenticationCredentials } from './Auth'
import { AnyAuthenticationCredentials, AuthenticationCredentials } from './Auth'
import { Chat } from './Chat'
import { Contact } from './Contact'
import { ConnectionState } from './Store'
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
import { MessageUpdateType, WAMessage } from './Message'
/** used for binary messages */
export enum WAMetric {
debugLog = 1,
@@ -117,7 +123,7 @@ export type SocketQueryOptions = SocketSendMessageOptions & {
}
export enum DisconnectReason {
connectionClosedIntentionally = 428,
connectionClosed = 428,
connectionReplaced = 440,
connectionLost = 408,
timedOut = 408,
@@ -131,6 +137,35 @@ export type WAInitResponse = {
status: 200
}
export interface WABroadcastListInfo {
status: number
name: string
recipients?: {id: string}[]
}
type WABusinessHoursConfig = {
day_of_week: string
mode: string
open_time?: number
close_time?: number
}
export type WABusinessProfile = {
description: string
email: string
business_hours: {
timezone: string
config?: WABusinessHoursConfig[]
business_config?: WABusinessHoursConfig[]
}
website: string[]
categories: {
id: string
localized_display_name: string
}[]
wid?: string
}
export type QueryOptions = SocketQueryOptions & {
waitForOpen?: boolean
maxRetries?: number
@@ -140,6 +175,23 @@ export type CurveKeyPair = { private: Uint8Array; public: Uint8Array }
export type BaileysEventMap = {
'connection.update': Partial<ConnectionState>
'credentials.update': AuthenticationCredentials
'chats.upsert': { chats: Chat[], type: 'set' | 'upsert' }
'chats.update': Partial<Chat>[]
'chats.delete': string[]
'contacts.upsert': { contacts: Contact[], type: 'set' | 'upsert' }
'contacts.update': Partial<Contact>[]
'messages.delete': { jid: string, ids: string[] } | { jid: string, all: true }
'messages.update': Partial<WAMessage>[]
'messages.upsert': { messages: WAMessage[], type: MessageUpdateType }
'groups.update': Partial<GroupMetadata>[]
'group-participants.update': { jid: string, participants: string[], action: ParticipantAction }
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' | 'set' }
}
export interface BaileysEventEmitter extends EventEmitter {
on<T extends keyof BaileysEventMap>(event: T, listener: (arg: BaileysEventMap[T]) => void): this

View File

@@ -1,4 +1,4 @@
import Boom from "boom"
import { Boom } from '@hapi/boom'
import BinaryNode from "../BinaryNode"
import { aesDecrypt, hmacSign } from "./generics"
import { DisconnectReason, WATag } from "../Types"

View File

@@ -1,4 +1,4 @@
import Boom from 'boom'
import { Boom } from '@hapi/boom'
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
import HKDF from 'futoin-hkdf'
import { platform, release } from 'os'

286
src/Utils/messages-media.ts Normal file
View File

@@ -0,0 +1,286 @@
import type { Agent } from 'https'
import type { Logger } from 'pino'
import type { IAudioMetadata } from 'music-metadata'
import * as Crypto from 'crypto'
import { Readable, Transform } from 'stream'
import Jimp from 'jimp'
import { createReadStream, createWriteStream, promises as fs, WriteStream } from 'fs'
import { exec } from 'child_process'
import { tmpdir } from 'os'
import HttpsProxyAgent from 'https-proxy-agent'
import { URL } from 'url'
import { MessageType, WAMessageContent, WAMessageProto, WAGenericMediaMessage, WAMediaUpload } from '../Types'
import got, { Options, Response } from 'got'
import { join } from 'path'
import { generateMessageID, hkdf } from './generics'
import { Boom } from '@hapi/boom'
import { MediaType } from '../Types'
import { DEFAULT_ORIGIN } from '../Defaults'
export const hkdfInfoKey = (type: MediaType) => {
let hkdfInfo = type[0].toUpperCase() + type.slice(1)
return `WhatsApp ${hkdfInfo} Keys`
}
/** generates all the keys required to encrypt/decrypt & sign a media message */
export function getMediaKeys(buffer, mediaType: MediaType) {
if (typeof buffer === 'string') {
buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
}
// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, hkdfInfoKey(mediaType))
return {
iv: expandedMediaKey.slice(0, 16),
cipherKey: expandedMediaKey.slice(16, 48),
macKey: expandedMediaKey.slice(48, 80),
}
}
/** Extracts video thumb using FFMPEG */
const extractVideoThumb = async (
path: string,
destPath: string,
time: string,
size: { width: number; height: number },
) =>
new Promise((resolve, reject) => {
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
exec(cmd, (err) => {
if (err) reject(err)
else resolve()
})
}) as Promise<void>
export const compressImage = async (bufferOrFilePath: Buffer | string) => {
const jimp = await Jimp.read(bufferOrFilePath as any)
const result = await jimp.resize(48, 48).getBufferAsync(Jimp.MIME_JPEG)
return result
}
export const generateProfilePicture = async (buffer: Buffer) => {
const jimp = await Jimp.read (buffer)
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
const cropped = jimp.crop (0, 0, min, min)
return {
img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG),
preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG)
}
}
export const ProxyAgent = (host: string | URL) => HttpsProxyAgent(host) as any as Agent
/** gets the SHA256 of the given media message */
export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
const media = Object.values(message)[0] as WAGenericMediaMessage
return media?.fileSha256 && Buffer.from(media.fileSha256).toString ('base64')
}
export async function getAudioDuration (buffer: Buffer | string) {
const musicMetadata = await import ('music-metadata')
let metadata: IAudioMetadata
if(Buffer.isBuffer(buffer)) {
metadata = await musicMetadata.parseBuffer(buffer, null, { duration: true })
} else {
const rStream = createReadStream(buffer)
metadata = await musicMetadata.parseStream(rStream, null, { duration: true })
rStream.close()
}
return metadata.format.duration;
}
export const toReadable = (buffer: Buffer) => {
const readable = new Readable({ read: () => {} })
readable.push(buffer)
readable.push(null)
return readable
}
export const getStream = async (item: WAMediaUpload) => {
if(Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' }
if(item.url.toString().startsWith('http://') || item.url.toString().startsWith('https://')) {
return { stream: await getGotStream(item.url), type: 'remote' }
}
return { stream: createReadStream(item.url), type: 'file' }
}
/** generates a thumbnail for a given media, if required */
export async function generateThumbnail(
file: string,
mediaType: 'video' | 'image',
options: {
logger?: Logger
}
) {
let thumbnail: string
if(mediaType === 'image') {
const buff = await compressImage(file)
thumbnail = buff.toString('base64')
} else if(mediaType === 'video') {
const imgFilename = join(tmpdir(), generateMessageID() + '.jpg')
try {
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 48, height: 48 })
const buff = await fs.readFile(imgFilename)
thumbnail = buff.toString('base64')
await fs.unlink(imgFilename)
} catch (err) {
options.logger?.debug('could not generate video thumb: ' + err)
}
}
return thumbnail
}
export const getGotStream = async(url: string | URL, options: Options & { isStream?: true } = {}) => {
const fetched = got.stream(url, { ...options, isStream: true })
await new Promise((resolve, reject) => {
fetched.once('error', reject)
fetched.once('response', ({ statusCode }: Response) => {
if (statusCode >= 400) {
reject(
new Boom(
'Invalid code (' + statusCode + ') returned',
{ statusCode }
))
} else {
resolve(undefined)
}
})
})
return fetched
}
export const encryptedStream = async(media: WAMediaUpload, mediaType: MediaType, saveOriginalFileIfRequired = true) => {
const { stream, type } = await getStream(media)
const mediaKey = Crypto.randomBytes(32)
const {cipherKey, iv, macKey} = getMediaKeys(mediaKey, mediaType)
// random name
const encBodyPath = join(tmpdir(), mediaType + generateMessageID() + '.enc')
const encWriteStream = createWriteStream(encBodyPath)
let bodyPath: string
let writeStream: WriteStream
let didSaveToTmpPath = false
if(type === 'file') {
bodyPath = (media as any).url
} else if(saveOriginalFileIfRequired) {
bodyPath = join(tmpdir(), mediaType + generateMessageID())
writeStream = createWriteStream(bodyPath)
didSaveToTmpPath = true
}
let fileLength = 0
const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
let hmac = Crypto.createHmac('sha256', macKey).update(iv)
let sha256Plain = Crypto.createHash('sha256')
let sha256Enc = Crypto.createHash('sha256')
const onChunk = (buff: Buffer) => {
sha256Enc = sha256Enc.update(buff)
hmac = hmac.update(buff)
encWriteStream.write(buff)
}
for await(const data of stream) {
fileLength += data.length
sha256Plain = sha256Plain.update(data)
writeStream && writeStream.write(data)
onChunk(aes.update(data))
}
onChunk(aes.final())
const mac = hmac.digest().slice(0, 10)
sha256Enc = sha256Enc.update(mac)
const fileSha256 = sha256Plain.digest()
const fileEncSha256 = sha256Enc.digest()
encWriteStream.write(mac)
encWriteStream.close()
writeStream && writeStream.close()
return {
mediaKey,
encBodyPath,
bodyPath,
mac,
fileEncSha256,
fileSha256,
fileLength,
didSaveToTmpPath
}
}
/**
* Decode a media message (video, image, document, audio) & return decrypted buffer
* @param message the media message you want to decode
*/
export async function decryptMediaMessageBuffer(message: WAMessageContent): Promise<Readable> {
/*
One can infer media type from the key in the message
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
*/
const type = Object.keys(message)[0] as MessageType
if(
!type ||
type === 'conversation' ||
type === 'extendedTextMessage'
) {
throw new Boom(`no media message for "${type}"`, { statusCode: 400 })
}
if (type === 'locationMessage' || type === 'liveLocationMessage') {
const buffer = Buffer.from(message[type].jpegThumbnail)
const readable = new Readable({ read: () => {} })
readable.push(buffer)
readable.push(null)
return readable
}
let messageContent: WAGenericMediaMessage
if (message.productMessage) {
const product = message.productMessage.product?.productImage
if (!product) throw new Boom('product has no image', { statusCode: 400 })
messageContent = product
} else {
messageContent = message[type]
}
// download the message
const fetched = await getGotStream(messageContent.url, {
headers: { Origin: DEFAULT_ORIGIN }
})
let remainingBytes = Buffer.from([])
const { cipherKey, iv } = getMediaKeys(messageContent.mediaKey, type.replace('Message', '') as MediaType)
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
const output = new Transform({
transform(chunk, _, callback) {
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength =
Math.floor(data.length / 16) * 16
remainingBytes = data.slice(decryptLength)
data = data.slice(0, decryptLength)
try {
this.push(aes.update(data))
callback()
} catch(error) {
callback(error)
}
},
final(callback) {
try {
this.push(aes.final())
callback()
} catch(error) {
callback(error)
}
},
})
return fetched.pipe(output, { end: true })
}
export function extensionForMediaMessage(message: WAMessageContent) {
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
const type = Object.keys(message)[0] as MessageType
let extension: string
if(
type === 'locationMessage' ||
type === 'liveLocationMessage' ||
type === 'productMessage'
) {
extension = '.jpeg'
} else {
const messageContent = message[type] as
| WAMessageProto.VideoMessage
| WAMessageProto.ImageMessage
| WAMessageProto.AudioMessage
| WAMessageProto.DocumentMessage
extension = getExtension (messageContent.mimetype)
}
return extension
}

355
src/Utils/messages.ts Normal file
View File

@@ -0,0 +1,355 @@
import { Boom } from '@hapi/boom'
import { createReadStream, promises as fs } from "fs"
import got from "got"
import { DEFAULT_ORIGIN, URL_REGEX, WA_DEFAULT_EPHEMERAL } from "../Defaults"
import {
AnyMediaMessageContent,
AnyMessageContent,
MediaGenerationOptions,
MessageContentGenerationOptions,
MessageGenerationOptions,
MessageGenerationOptionsFromContent,
MessageType,
WAMediaUpload,
WAMessage,
WAMessageContent,
WAMessageProto,
WATextMessage,
MediaType,
WAMessageStatus
} from "../Types"
import { generateMessageID, unixTimestampSeconds, whatsappID } from "./generics"
import { encryptedStream, generateThumbnail, getAudioDuration } from "./messages-media"
type MediaUploadData = {
media: WAMediaUpload
caption?: string
ptt?: boolean
seconds?: number
gifPlayback?: boolean
fileName?: string
jpegThumbnail?: string
mimetype?: string
}
const MEDIA_PATH_MAP: { [T in MediaType]: string } = {
image: '/mms/image',
video: '/mms/video',
document: '/mms/document',
audio: '/mms/audio',
sticker: '/mms/image',
} as const
const MIMETYPE_MAP: { [T in MediaType]: string } = {
image: 'image/jpeg',
video: 'video/mp4',
document: 'application/pdf',
audio: 'audio/ogg; codecs=opus',
sticker: 'image/webp',
}
const MessageTypeProto = {
'imageMessage': WAMessageProto.ImageMessage,
'videoMessage': WAMessageProto.VideoMessage,
'audioMessage': WAMessageProto.AudioMessage,
'stickerMessage': WAMessageProto.StickerMessage,
'documentMessage': WAMessageProto.DocumentMessage,
}
const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
export const prepareWAMessageMedia = async(
message: AnyMediaMessageContent,
options: MediaGenerationOptions
) => {
let mediaType: typeof MEDIA_KEYS[number]
for(const key of MEDIA_KEYS) {
if(key in message) {
mediaType = key
}
}
const uploadData: MediaUploadData = {
...message,
[mediaType]: undefined,
media: message[mediaType]
}
if(mediaType === 'document' && !uploadData.fileName) {
uploadData.fileName = 'file'
}
if(!uploadData.mimetype) {
uploadData.mimetype = MIMETYPE_MAP[mediaType]
}
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') &&
!('jpegThumbnail' in uploadData)
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const {
mediaKey,
encBodyPath,
bodyPath,
fileEncSha256,
fileSha256,
fileLength,
didSaveToTmpPath
} = await encryptedStream(uploadData.media, mediaType, requiresOriginalForSomeProcessing)
// url safe Base64 encode the SHA256 hash of the body
const fileEncSha256B64 = encodeURIComponent(
fileEncSha256.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/\=+$/, '')
)
try {
if(requiresThumbnailComputation) {
uploadData.jpegThumbnail = await generateThumbnail(bodyPath, mediaType as any, options)
}
if (requiresDurationComputation) {
uploadData.seconds = await getAudioDuration(bodyPath)
}
} catch (error) {
options.logger?.debug ({ error }, 'failed to obtain audio duration: ' + error.message)
}
// send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await options.getMediaOptions(false)
let mediaUrl: string
for (let host of uploadInfo.hosts) {
const auth = encodeURIComponent(uploadInfo.auth) // the auth token
const url = `https://${host.hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
try {
const {body: responseText} = await got.post(
url,
{
headers: {
'Content-Type': 'application/octet-stream',
'Origin': DEFAULT_ORIGIN
},
agent: {
https: options.agent
},
body: createReadStream(encBodyPath)
}
)
const result = JSON.parse(responseText)
mediaUrl = result?.url
if (mediaUrl) break
else {
uploadInfo = await options.getMediaOptions(true)
throw new Error(`upload failed, reason: ${JSON.stringify(result)}`)
}
} catch (error) {
const isLast = host.hostname === uploadInfo.hosts[uploadInfo.hosts.length-1].hostname
options.logger?.debug(`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
}
}
if (!mediaUrl) {
throw new Boom(
'Media upload failed on all hosts',
{ statusCode: 500 }
)
}
// remove tmp files
await Promise.all(
[
fs.unlink(encBodyPath),
didSaveToTmpPath && bodyPath && fs.unlink(bodyPath)
]
.filter(Boolean)
)
delete uploadData.media
const content = {
[mediaType]: MessageTypeProto[mediaType].fromObject(
{
url: mediaUrl,
mediaKey,
fileEncSha256,
fileSha256,
fileLength,
...uploadData
}
)
}
return WAMessageProto.Message.fromObject(content)
}
export const prepareDisappearingMessageSettingContent = (ephemeralExpiration?: number) => {
ephemeralExpiration = ephemeralExpiration || 0
const content: WAMessageContent = {
ephemeralMessage: {
message: {
protocolMessage: {
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING,
ephemeralExpiration
}
}
}
}
return WAMessageProto.Message.fromObject(content)
}
/**
* Generate forwarded message content like WA does
* @param message the message to forward
* @param options.forceForward will show the message as forwarded even if it is from you
*/
export const generateForwardMessageContent = (
message: WAMessage,
forceForward?: boolean
) => {
let content = message.message
if (!content) throw new Boom('no content in message', { statusCode: 400 })
content = JSON.parse(JSON.stringify(content)) // hacky copy
let key = Object.keys(content)[0] as MessageType
let score = content[key].contextInfo?.forwardingScore || 0
score += message.key.fromMe && !forceForward ? 0 : 1
if (key === 'conversation') {
content.extendedTextMessage = { text: content[key] }
delete content.conversation
key = 'extendedTextMessage'
}
if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true }
else content[key].contextInfo = {}
return content
}
export const generateWAMessageContent = async(
message: AnyMessageContent,
options: MessageContentGenerationOptions
) => {
let m: WAMessageContent = {}
if(typeof message === 'string') {
message = { text: message }
}
if('text' in message) {
const extContent = { ...message } as WATextMessage
if (!!options.getUrlInfo && message.text.match(URL_REGEX)) {
try {
const data = await options.getUrlInfo(message.text)
extContent.canonicalUrl = data['canonical-url']
extContent.matchedText = data['matched-text']
extContent.jpegThumbnail = data.jpegThumbnail
extContent.description = data.description
extContent.title = data.title
extContent.previewType = 0
} catch (error) { // ignore if fails
}
}
m.extendedTextMessage = WAMessageProto.ExtendedTextMessage.fromObject(extContent)
} else if('contacts' in message) {
const contactLen = message.contacts.contacts.length
if(!contactLen) {
throw new Boom('require atleast 1 contact', { statusCode: 400 })
}
if(contactLen === 1) {
m.contactMessage = WAMessageProto.ContactMessage.fromObject(message.contacts.contacts[0])
}
} else if('location' in message) {
m.locationMessage = WAMessageProto.LocationMessage.fromObject(message.location)
} else if('delete' in message) {
m.protocolMessage = {
key: message.delete,
type: WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE
}
} else if('forward' in message) {
m = generateForwardMessageContent(
message.forward,
message.force
)
} else if('disappearingMessagesInChat' in message) {
const exp = typeof message.disappearingMessagesInChat === 'boolean' ?
(message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) :
message.disappearingMessagesInChat
m = prepareDisappearingMessageSettingContent(exp)
} else {
m = await prepareWAMessageMedia(
message,
options
)
}
if('mentions' in message) {
const [messageType] = Object.keys(message)
message[messageType].contextInfo = message[messageType] || { }
message[messageType].contextInfo.mentionedJid = message.mentions
}
return WAMessageProto.Message.fromObject(m)
}
export const generateWAMessageFromContent = (
jid: string,
message: WAMessageContent,
options: MessageGenerationOptionsFromContent
) => {
if (!options.timestamp) options.timestamp = new Date() // set timestamp to now
// prevent an annoying bug (WA doesn't accept sending messages with '@c.us')
jid = whatsappID(jid)
const key = Object.keys(message)[0]
const timestamp = unixTimestampSeconds(options.timestamp)
const { quoted, userJid } = options
if (quoted) {
const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)
message[key].contextInfo = message[key].contextInfo || { }
message[key].contextInfo.participant = participant
message[key].contextInfo.stanzaId = quoted.key.id
message[key].contextInfo.quotedMessage = quoted.message
// if a participant is quoted, then it must be a group
// hence, remoteJid of group must also be entered
if (quoted.key.participant) {
message[key].contextInfo.remoteJid = quoted.key.remoteJid
}
}
if(
// if we want to send a disappearing message
!!options?.ephemeralOptions &&
// and it's not a protocol message -- delete, toggle disappear message
key !== 'protocolMessage' &&
// already not converted to disappearing message
key !== 'ephemeralMessage'
) {
message[key].contextInfo = {
...(message[key].contextInfo || {}),
expiration: options.ephemeralOptions.expiration || WA_DEFAULT_EPHEMERAL,
ephemeralSettingTimestamp: options.ephemeralOptions.eph_setting_ts.toString()
}
message = {
ephemeralMessage: {
message
}
}
}
message = WAMessageProto.Message.fromObject (message)
const messageJSON = {
key: {
remoteJid: jid,
fromMe: true,
id: options?.messageId || generateMessageID(),
},
message: message,
messageTimestamp: timestamp,
messageStubParameters: [],
participant: jid.includes('@g.us') ? userJid : undefined,
status: WAMessageStatus.PENDING
}
return WAMessageProto.WebMessageInfo.fromObject (messageJSON)
}
export const generateWAMessage = async(
jid: string,
content: AnyMessageContent,
options: MessageGenerationOptions,
) => (
generateWAMessageFromContent(
jid,
await generateWAMessageContent(
content,
options
),
options
)
)

View File

@@ -1,4 +1,4 @@
import Boom from 'boom'
import {Boom} from '@hapi/boom'
import * as Curve from 'curve25519-js'
import type { Contact } from '../Types/Contact'
import type { AnyAuthenticationCredentials, AuthenticationCredentials, CurveKeyPair } from "../Types"

View File

@@ -1,5 +1,8 @@
import makeConnection from './Connection'
export * from '../WAMessage/WAMessage'
export * from './Binary/Constants'
export * from './Binary/Decoder'
export * from './Binary/Encoder'
export * from './WAConnection'
export * from './Utils/messages'
export * from './Types'
export * from './Store'
export default makeConnection

3259
yarn.lock

File diff suppressed because it is too large Load Diff