Wrap up connection + in memory store

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

View File

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

View File

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

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

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

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

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

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

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

View File

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