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 {
|
import makeConnection from '../src'
|
||||||
WAConnection,
|
|
||||||
MessageType,
|
|
||||||
Presence,
|
|
||||||
MessageOptions,
|
|
||||||
Mimetype,
|
|
||||||
WALocationMessage,
|
|
||||||
WA_MESSAGE_STUB_TYPES,
|
|
||||||
ReconnectMode,
|
|
||||||
ProxyAgent,
|
|
||||||
waChatKey,
|
|
||||||
} from '../src/WAConnection'
|
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
|
|
||||||
async function example() {
|
async function example() {
|
||||||
const conn = new WAConnection() // instantiate
|
const conn = makeConnection({
|
||||||
conn.autoReconnect = ReconnectMode.onConnectionLost // only automatically reconnect when the connection breaks
|
credentials: './auth_info.json'
|
||||||
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}`)
|
|
||||||
})
|
})
|
||||||
conn.on('contacts-received', () => {
|
conn.ev.on('connection.update', state => {
|
||||||
console.log(`you have ${Object.keys(conn.contacts).length} contacts`)
|
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",
|
"name": "@adiwajshing/baileys",
|
||||||
"version": "3.5.1",
|
"version": "4.0.0",
|
||||||
"description": "WhatsApp Web API",
|
"description": "WhatsApp Web API",
|
||||||
"homepage": "https://github.com/adiwajshing/Baileys",
|
"homepage": "https://github.com/adiwajshing/Baileys",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adiwajshing/keyed-db": "^0.2.2",
|
"@adiwajshing/keyed-db": "^0.2.2",
|
||||||
|
"@hapi/boom": "^9.1.3",
|
||||||
"curve25519-js": "^0.0.4",
|
"curve25519-js": "^0.0.4",
|
||||||
"futoin-hkdf": "^1.3.2",
|
"futoin-hkdf": "^1.3.2",
|
||||||
"got": "^11.8.1",
|
"got": "^11.8.1",
|
||||||
@@ -42,23 +43,25 @@
|
|||||||
"pino": "^6.7.0",
|
"pino": "^6.7.0",
|
||||||
"pino-pretty": "^4.3.0",
|
"pino-pretty": "^4.3.0",
|
||||||
"protobufjs": "^6.10.1",
|
"protobufjs": "^6.10.1",
|
||||||
"qrcode-terminal": "^0.12.0",
|
|
||||||
"ws": "^7.3.1"
|
"ws": "^7.3.1"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"qrcode-terminal": "^0.12.0"
|
||||||
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib/*",
|
"lib/*",
|
||||||
"WAMessage/*"
|
"WAMessage/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/got": "^9.6.11",
|
"@types/got": "^9.6.11",
|
||||||
"@types/mocha": "^7.0.2",
|
"@types/jest": "^26.0.24",
|
||||||
"@types/node": "^14.6.2",
|
"@types/node": "^14.6.2",
|
||||||
"@types/pino": "^6.3.2",
|
"@types/pino": "^6.3.2",
|
||||||
"@types/ws": "^7.2.6",
|
"@types/ws": "^7.2.6",
|
||||||
"assert": "^2.0.0",
|
"jest": "^27.0.6",
|
||||||
"dotenv": "^8.2.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"mocha": "^8.1.3",
|
"ts-jest": "^27.0.3",
|
||||||
"ts-node-dev": "^1.0.0",
|
"ts-node": "^10.0.0",
|
||||||
"typedoc": "^0.20.0-beta.27",
|
"typedoc": "^0.20.0-beta.27",
|
||||||
"typescript": "^4.0.0"
|
"typescript": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = [])
|
|||||||
const pushInt20 = (value: number) => (
|
const pushInt20 = (value: number) => (
|
||||||
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
|
pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff])
|
||||||
)
|
)
|
||||||
const pushString = (str: string) => {
|
|
||||||
const bytes = Buffer.from (str, 'utf-8')
|
|
||||||
pushBytes(bytes)
|
|
||||||
}
|
|
||||||
const writeByteLength = (length: number) => {
|
const writeByteLength = (length: number) => {
|
||||||
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
|
if (length >= 4294967296) throw new Error('string too large to encode: ' + length)
|
||||||
|
|
||||||
@@ -35,9 +31,10 @@ const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = [])
|
|||||||
pushByte(length)
|
pushByte(length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const writeStringRaw = (string: string) => {
|
const writeStringRaw = (str: string) => {
|
||||||
writeByteLength(string.length)
|
const bytes = Buffer.from (str, 'utf-8')
|
||||||
pushString(string)
|
writeByteLength(bytes.length)
|
||||||
|
pushBytes(bytes)
|
||||||
}
|
}
|
||||||
const writeToken = (token: number) => {
|
const writeToken = (token: number) => {
|
||||||
if (token < 245) {
|
if (token < 245) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { decryptWA } from './WAConnection'
|
import { decodeWAMessage } from './Utils/decode-wa-message'
|
||||||
import Decoder from './Binary/Decoder'
|
|
||||||
|
|
||||||
interface BrowserMessagesInfo {
|
interface BrowserMessagesInfo {
|
||||||
bundle: { encKey: string, macKey: string }
|
bundle: { encKey: string, macKey: string }
|
||||||
@@ -24,7 +23,7 @@ entries.forEach ((e, i) => {
|
|||||||
wsMessages.push (...e['_webSocketMessages'])
|
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')
|
console.log ('parsing ' + wsMessages.length + ' messages')
|
||||||
const list = wsMessages.map ((item, i) => {
|
const list = wsMessages.map ((item, i) => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Boom from "boom"
|
import { Boom } from '@hapi/boom'
|
||||||
import EventEmitter from "events"
|
import EventEmitter from "events"
|
||||||
import * as Curve from 'curve25519-js'
|
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 { makeSocket } from "./socket"
|
||||||
import { generateClientID, promiseTimeout } from "../Utils/generics"
|
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 { randomBytes } from "crypto"
|
||||||
import { AuthenticationCredentials } from "../Types"
|
import { AuthenticationCredentials } from "../Types"
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ const makeAuthSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
// will call state update to close connection
|
// will call state update to close connection
|
||||||
socket?.end(
|
socket?.end(
|
||||||
Boom.unauthorized('Logged Out')
|
new Boom('Logged Out', { statusCode: DisconnectReason.credentialsInvalidated })
|
||||||
)
|
)
|
||||||
authInfo = undefined
|
authInfo = undefined
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ const makeAuthSocket = (config: SocketConfig) => {
|
|||||||
let listener: (item: BaileysEventMap['connection.update']) => void
|
let listener: (item: BaileysEventMap['connection.update']) => void
|
||||||
const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs
|
const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs
|
||||||
if(timeout < 0) {
|
if(timeout < 0) {
|
||||||
throw Boom.preconditionRequired('Connection Closed')
|
throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed })
|
||||||
}
|
}
|
||||||
|
|
||||||
await (
|
await (
|
||||||
@@ -99,7 +99,7 @@ const makeAuthSocket = (config: SocketConfig) => {
|
|||||||
listener = ({ connection, lastDisconnect }) => {
|
listener = ({ connection, lastDisconnect }) => {
|
||||||
if(connection === 'open') resolve()
|
if(connection === 'open') resolve()
|
||||||
else if(connection == 'close') {
|
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)
|
ev.on('connection.update', listener)
|
||||||
@@ -153,7 +153,7 @@ const makeAuthSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
qrLoop(ttl)
|
qrLoop(ttl)
|
||||||
}
|
}
|
||||||
socketEvents.once('ws-open', async() => {
|
const onOpen = async() => {
|
||||||
const canDoLogin = canLogin()
|
const canDoLogin = canLogin()
|
||||||
const initQuery = (async () => {
|
const initQuery = (async () => {
|
||||||
const {ref, ttl} = await socket.query({
|
const {ref, ttl} = await socket.query({
|
||||||
@@ -185,7 +185,7 @@ const makeAuthSocket = (config: SocketConfig) => {
|
|||||||
logger.warn('Received login timeout req when state=open, ignoring...')
|
logger.warn('Received login timeout req when state=open, ignoring...')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.debug('sending login request')
|
logger.info('sending login request')
|
||||||
socket.sendMessage({
|
socket.sendMessage({
|
||||||
json,
|
json,
|
||||||
tag: loginTag
|
tag: loginTag
|
||||||
@@ -220,21 +220,32 @@ const makeAuthSocket = (config: SocketConfig) => {
|
|||||||
response = await socket.waitForMessage('s2', true)
|
response = await socket.waitForMessage('s2', true)
|
||||||
}
|
}
|
||||||
// validate the new connection
|
// 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
|
const isNewLogin = user.jid !== state.user?.jid
|
||||||
|
|
||||||
authInfo = auth
|
authInfo = auth
|
||||||
// update the keys so we can decrypt traffic
|
// update the keys so we can decrypt traffic
|
||||||
socket.updateKeys({ encKey: auth.encKey, macKey: auth.macKey })
|
socket.updateKeys({ encKey: auth.encKey, macKey: auth.macKey })
|
||||||
|
|
||||||
|
logger.info({ user }, 'logged in')
|
||||||
|
|
||||||
updateState({
|
updateState({
|
||||||
connection: 'open',
|
connection: 'open',
|
||||||
phoneConnected: true,
|
phoneConnected: true,
|
||||||
user,
|
user,
|
||||||
isNewLogin,
|
isNewLogin,
|
||||||
|
phoneInfo: phone,
|
||||||
connectionTriesLeft: undefined,
|
connectionTriesLeft: undefined,
|
||||||
qr: undefined
|
qr: undefined
|
||||||
})
|
})
|
||||||
|
ev.emit('credentials.update', auth)
|
||||||
|
}
|
||||||
|
socketEvents.once('ws-open', async() => {
|
||||||
|
try {
|
||||||
|
await onOpen()
|
||||||
|
} catch(error) {
|
||||||
|
socket.end(error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if(printQRInTerminal) {
|
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 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
|
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 EventEmitter from "events"
|
||||||
import { STATUS_CODES } from "http"
|
import { STATUS_CODES } from "http"
|
||||||
import { promisify } from "util"
|
import { promisify } from "util"
|
||||||
@@ -6,7 +6,7 @@ import WebSocket from "ws"
|
|||||||
import BinaryNode from "../BinaryNode"
|
import BinaryNode from "../BinaryNode"
|
||||||
import { DisconnectReason, SocketConfig, SocketQueryOptions, SocketSendMessageOptions } from "../Types"
|
import { DisconnectReason, SocketConfig, SocketQueryOptions, SocketSendMessageOptions } from "../Types"
|
||||||
import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds } from "../Utils/generics"
|
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 { WAFlag, WAMetric, WATag } from "../Types"
|
||||||
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults"
|
import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults"
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ export const makeSocket = ({
|
|||||||
const waitForSocketOpen = async() => {
|
const waitForSocketOpen = async() => {
|
||||||
if(ws.readyState === ws.OPEN) return
|
if(ws.readyState === ws.OPEN) return
|
||||||
if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
|
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 onOpen: () => void
|
||||||
let onClose: (err: Error) => void
|
let onClose: (err: Error) => void
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const DEF_CALLBACK_PREFIX = 'CB:'
|
|||||||
export const DEF_TAG_PREFIX = 'TAG:'
|
export const DEF_TAG_PREFIX = 'TAG:'
|
||||||
export const PHONE_CONNECTION_CB = 'CB:Pong'
|
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 = {
|
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
||||||
version: [2, 2123, 8],
|
version: [2, 2123, 8],
|
||||||
browser: Browsers.baileys('Chrome'),
|
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 P from 'pino'
|
||||||
import BinaryNode from '../BinaryNode'
|
import BinaryNode from '../BinaryNode'
|
||||||
import makeConnection, { Connection, DisconnectReason } from '../makeConnection'
|
import makeConnection from '../Connection'
|
||||||
import { delay } from '../WAConnection/Utils'
|
import { delay } from '../Utils/generics'
|
||||||
|
|
||||||
describe('QR Generation', () => {
|
describe('QR Generation', () => {
|
||||||
it('should generate QR', async () => {
|
it('should generate QR', async () => {
|
||||||
const QR_GENS = 1
|
const QR_GENS = 1
|
||||||
const {ev, open} = makeConnection({
|
const {ev} = makeConnection({
|
||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
maxQRCodes: QR_GENS,
|
maxQRCodes: QR_GENS,
|
||||||
logger: P({ level: 'trace' })
|
logger: P({ level: 'trace' })
|
||||||
})
|
})
|
||||||
let calledQR = 0
|
let calledQR = 0
|
||||||
ev.removeAllListeners('qr')
|
ev.on('connection.update', ({ qr }) => {
|
||||||
ev.on('state.update', ({ qr }) => {
|
|
||||||
if(qr) calledQR += 1
|
if(qr) calledQR += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(open()).rejects.toThrowError('Too many QR codes')
|
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)
|
expect(calledQR).toBeGreaterThanOrEqual(QR_GENS)
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Test Connect', () => {
|
describe('Test Connect', () => {
|
||||||
|
|
||||||
const logger = P({ level: 'trace' })
|
const logger = P({ level: 'trace' })
|
||||||
|
|
||||||
it('should connect', async () => {
|
it('should connect', async () => {
|
||||||
|
|
||||||
logger.info('please be ready to scan with your phone')
|
logger.info('please be ready to scan with your phone')
|
||||||
@@ -36,33 +34,38 @@ describe('Test Connect', () => {
|
|||||||
logger,
|
logger,
|
||||||
printQRInTerminal: true
|
printQRInTerminal: true
|
||||||
})
|
})
|
||||||
await conn.open()
|
await conn.waitForConnection(true)
|
||||||
const { user, isNewLogin } = await conn.getState()
|
const { user, isNewLogin } = await conn.getState()
|
||||||
expect(user).toHaveProperty('jid')
|
expect(user).toHaveProperty('jid')
|
||||||
expect(user).toHaveProperty('name')
|
expect(user).toHaveProperty('name')
|
||||||
expect(isNewLogin).toBe(true)
|
expect(isNewLogin).toBe(true)
|
||||||
|
|
||||||
conn.close()
|
conn.end(undefined)
|
||||||
}, 65_000)
|
}, 65_000)
|
||||||
|
|
||||||
it('should restore session', async () => {
|
it('should restore session', async () => {
|
||||||
const conn = makeConnection({
|
let conn = makeConnection({
|
||||||
printQRInTerminal: true,
|
printQRInTerminal: true,
|
||||||
logger,
|
logger,
|
||||||
})
|
})
|
||||||
await conn.open()
|
await conn.waitForConnection(true)
|
||||||
conn.close()
|
conn.end(undefined)
|
||||||
|
|
||||||
await delay(2500)
|
await delay(2500)
|
||||||
|
|
||||||
await conn.open()
|
conn = makeConnection({
|
||||||
|
printQRInTerminal: true,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
await conn.waitForConnection(true)
|
||||||
|
|
||||||
const { user, isNewLogin, qr } = await conn.getState()
|
const { user, isNewLogin, qr } = await conn.getState()
|
||||||
expect(user).toHaveProperty('jid')
|
expect(user).toHaveProperty('jid')
|
||||||
expect(user).toHaveProperty('name')
|
expect(user).toHaveProperty('name')
|
||||||
expect(isNewLogin).toBe(false)
|
expect(isNewLogin).toBe(false)
|
||||||
expect(qr).toBe(undefined)
|
expect(qr).toBe(undefined)
|
||||||
|
|
||||||
conn.close()
|
conn.end(undefined)
|
||||||
}, 65_000)
|
}, 65_000)
|
||||||
|
|
||||||
it('should logout', async () => {
|
it('should logout', async () => {
|
||||||
@@ -70,7 +73,8 @@ describe('Test Connect', () => {
|
|||||||
printQRInTerminal: true,
|
printQRInTerminal: true,
|
||||||
logger,
|
logger,
|
||||||
})
|
})
|
||||||
await conn.open()
|
await conn.waitForConnection(true)
|
||||||
|
|
||||||
const { user, qr } = await conn.getState()
|
const { user, qr } = await conn.getState()
|
||||||
expect(user).toHaveProperty('jid')
|
expect(user).toHaveProperty('jid')
|
||||||
expect(user).toHaveProperty('name')
|
expect(user).toHaveProperty('name')
|
||||||
@@ -83,118 +87,8 @@ describe('Test Connect', () => {
|
|||||||
credentials,
|
credentials,
|
||||||
logger
|
logger
|
||||||
})
|
})
|
||||||
await expect(conn.open()).rejects.toThrowError('Unexpected error in login')
|
await expect(
|
||||||
|
conn.waitForConnection()
|
||||||
|
).rejects.toThrowError('Unexpected error in login')
|
||||||
}, 65_000)
|
}, 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 */
|
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
|
||||||
export enum Presence {
|
export enum Presence {
|
||||||
unavailable = 'unavailable', // "offline"
|
unavailable = 'unavailable', // "offline"
|
||||||
@@ -37,8 +33,25 @@ export interface Chat {
|
|||||||
ephemeral?: string
|
ephemeral?: string
|
||||||
|
|
||||||
// Baileys added properties
|
// Baileys added properties
|
||||||
messages: KeyedDB<proto.IWebMessageInfo, string>
|
|
||||||
imgUrl?: string
|
|
||||||
presences?: { [k: string]: PresenceData }
|
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
|
short?: string
|
||||||
// Baileys Added
|
// Baileys Added
|
||||||
imgUrl?: string
|
imgUrl?: string
|
||||||
|
status?: string
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Contact } from "./Contact";
|
|||||||
|
|
||||||
export type GroupParticipant = (Contact & { isAdmin: boolean; isSuperAdmin: boolean })
|
export type GroupParticipant = (Contact & { isAdmin: boolean; isSuperAdmin: boolean })
|
||||||
|
|
||||||
|
export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote'
|
||||||
|
|
||||||
export interface GroupMetadata {
|
export interface GroupMetadata {
|
||||||
id: string
|
id: string
|
||||||
owner: string
|
owner: string
|
||||||
@@ -16,4 +18,16 @@ export interface GroupMetadata {
|
|||||||
announce?: 'true' | 'false'
|
announce?: 'true' | 'false'
|
||||||
// Baileys modified array
|
// Baileys modified array
|
||||||
participants: GroupParticipant[]
|
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 './Chat'
|
||||||
export * from './Contact'
|
export * from './Contact'
|
||||||
export * from './Store'
|
export * from './Store'
|
||||||
|
export * from './Message'
|
||||||
|
|
||||||
import type EventEmitter from "events"
|
import type EventEmitter from "events"
|
||||||
import type { Agent } from "https"
|
import type { Agent } from "https"
|
||||||
import type { Logger } from "pino"
|
import type { Logger } from "pino"
|
||||||
import type { URL } from "url"
|
import type { URL } from "url"
|
||||||
import type BinaryNode from "../BinaryNode"
|
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 { ConnectionState } from './Store'
|
||||||
|
|
||||||
|
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
|
||||||
|
import { MessageUpdateType, WAMessage } from './Message'
|
||||||
|
|
||||||
/** used for binary messages */
|
/** used for binary messages */
|
||||||
export enum WAMetric {
|
export enum WAMetric {
|
||||||
debugLog = 1,
|
debugLog = 1,
|
||||||
@@ -117,7 +123,7 @@ export type SocketQueryOptions = SocketSendMessageOptions & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum DisconnectReason {
|
export enum DisconnectReason {
|
||||||
connectionClosedIntentionally = 428,
|
connectionClosed = 428,
|
||||||
connectionReplaced = 440,
|
connectionReplaced = 440,
|
||||||
connectionLost = 408,
|
connectionLost = 408,
|
||||||
timedOut = 408,
|
timedOut = 408,
|
||||||
@@ -131,6 +137,35 @@ export type WAInitResponse = {
|
|||||||
status: 200
|
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 & {
|
export type QueryOptions = SocketQueryOptions & {
|
||||||
waitForOpen?: boolean
|
waitForOpen?: boolean
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
@@ -140,6 +175,23 @@ export type CurveKeyPair = { private: Uint8Array; public: Uint8Array }
|
|||||||
|
|
||||||
export type BaileysEventMap = {
|
export type BaileysEventMap = {
|
||||||
'connection.update': Partial<ConnectionState>
|
'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 {
|
export interface BaileysEventEmitter extends EventEmitter {
|
||||||
on<T extends keyof BaileysEventMap>(event: T, listener: (arg: BaileysEventMap[T]) => void): this
|
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 BinaryNode from "../BinaryNode"
|
||||||
import { aesDecrypt, hmacSign } from "./generics"
|
import { aesDecrypt, hmacSign } from "./generics"
|
||||||
import { DisconnectReason, WATag } from "../Types"
|
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 { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto'
|
||||||
import HKDF from 'futoin-hkdf'
|
import HKDF from 'futoin-hkdf'
|
||||||
import { platform, release } from 'os'
|
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 * as Curve from 'curve25519-js'
|
||||||
import type { Contact } from '../Types/Contact'
|
import type { Contact } from '../Types/Contact'
|
||||||
import type { AnyAuthenticationCredentials, AuthenticationCredentials, CurveKeyPair } from "../Types"
|
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 '../WAMessage/WAMessage'
|
||||||
export * from './Binary/Constants'
|
export * from './Utils/messages'
|
||||||
export * from './Binary/Decoder'
|
export * from './Types'
|
||||||
export * from './Binary/Encoder'
|
export * from './Store'
|
||||||
export * from './WAConnection'
|
|
||||||
|
export default makeConnection
|
||||||
Reference in New Issue
Block a user