mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Wrap up connection + in memory store
This commit is contained in:
@@ -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))
|
||||
17
package.json
17
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
208
src/Connection/groups.ts
Normal 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
12
src/Connection/index.ts
Normal 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
437
src/Connection/messages.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
163
src/Store/in-memory-store.ts
Normal file
163
src/Store/in-memory-store.ts
Normal 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
2
src/Store/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import inMemoryStore from "./in-memory-store";
|
||||
export default inMemoryStore
|
||||
112
src/Tests/_test.manual_tests.ts
Normal file
112
src/Tests/_test.manual_tests.ts
Normal 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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,5 @@ export interface Contact {
|
||||
short?: string
|
||||
// Baileys Added
|
||||
imgUrl?: string
|
||||
status?: string
|
||||
}
|
||||
@@ -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
135
src/Types/Message.ts
Normal 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}[]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
286
src/Utils/messages-media.ts
Normal 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
355
src/Utils/messages.ts
Normal 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
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
11
src/index.ts
11
src/index.ts
@@ -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
|
||||
Reference in New Issue
Block a user