feat(labels): modify chat utils

This commit is contained in:
Rafael Garcia
2023-05-14 12:44:19 -05:00
parent 00a7b48749
commit 38f285760d
4 changed files with 362 additions and 208 deletions

View File

@@ -37,13 +37,13 @@ export const makeChatsSocket = (config: SocketConfig) => {
const processingMutex = makeMutex()
/** helper function to fetch the given app state sync key */
const getAppStateSyncKey = async(keyId: string) => {
const getAppStateSyncKey = async (keyId: string) => {
const { [keyId]: key } = await authState.keys.get('app-state-sync-key', [keyId])
return key
}
const fetchPrivacySettings = async(force: boolean = false) => {
if(!privacySettings || force) {
const fetchPrivacySettings = async (force: boolean = false) => {
if (!privacySettings || force) {
const { content } = await query({
tag: 'iq',
attrs: {
@@ -52,7 +52,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
type: 'get'
},
content: [
{ tag: 'privacy', attrs: { } }
{ tag: 'privacy', attrs: {} }
]
})
privacySettings = reduceBinaryNodeToDictionary(content?.[0] as BinaryNode, 'category')
@@ -62,7 +62,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
/** helper function to run a privacy IQ query */
const privacyQuery = async(name: string, value: string) => {
const privacyQuery = async (name: string, value: string) => {
await query({
tag: 'iq',
attrs: {
@@ -83,31 +83,31 @@ export const makeChatsSocket = (config: SocketConfig) => {
})
}
const updateLastSeenPrivacy = async(value: WAPrivacyValue) => {
const updateLastSeenPrivacy = async (value: WAPrivacyValue) => {
await privacyQuery('last', value)
}
const updateOnlinePrivacy = async(value: WAPrivacyOnlineValue) => {
const updateOnlinePrivacy = async (value: WAPrivacyOnlineValue) => {
await privacyQuery('online', value)
}
const updateProfilePicturePrivacy = async(value: WAPrivacyValue) => {
const updateProfilePicturePrivacy = async (value: WAPrivacyValue) => {
await privacyQuery('profile', value)
}
const updateStatusPrivacy = async(value: WAPrivacyValue) => {
const updateStatusPrivacy = async (value: WAPrivacyValue) => {
await privacyQuery('status', value)
}
const updateReadReceiptsPrivacy = async(value: WAReadReceiptsValue) => {
const updateReadReceiptsPrivacy = async (value: WAReadReceiptsValue) => {
await privacyQuery('readreceipts', value)
}
const updateGroupsAddPrivacy = async(value: WAPrivacyValue) => {
const updateGroupsAddPrivacy = async (value: WAPrivacyValue) => {
await privacyQuery('groupadd', value)
}
const updateDefaultDisappearingMode = async(duration: number) => {
const updateDefaultDisappearingMode = async (duration: number) => {
await query({
tag: 'iq',
attrs: {
@@ -118,14 +118,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [{
tag: 'disappearing_mode',
attrs: {
duration : duration.toString()
duration: duration.toString()
}
}]
})
}
/** helper function to run a generic IQ query */
const interactiveQuery = async(userNodes: BinaryNode[], queryNode: BinaryNode) => {
const interactiveQuery = async (userNodes: BinaryNode[], queryNode: BinaryNode) => {
const result = await query({
tag: 'iq',
attrs: {
@@ -146,12 +146,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'query',
attrs: { },
content: [ queryNode ]
attrs: {},
content: [queryNode]
},
{
tag: 'list',
attrs: { },
attrs: {},
content: userNodes
}
]
@@ -166,22 +166,22 @@ export const makeChatsSocket = (config: SocketConfig) => {
return users
}
const onWhatsApp = async(...jids: string[]) => {
const onWhatsApp = async (...jids: string[]) => {
const results = await interactiveQuery(
[
{
tag: 'user',
attrs: { },
attrs: {},
content: jids.map(
jid => ({
tag: 'contact',
attrs: { },
attrs: {},
content: `+${jid}`
})
)
}
],
{ tag: 'contact', attrs: { } }
{ tag: 'contact', attrs: {} }
)
return results.map(user => {
@@ -190,12 +190,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
}).filter(item => item.exists)
}
const fetchStatus = async(jid: string) => {
const fetchStatus = async (jid: string) => {
const [result] = await interactiveQuery(
[{ tag: 'user', attrs: { jid } }],
{ tag: 'status', attrs: { } }
{ tag: 'status', attrs: {} }
)
if(result) {
if (result) {
const status = getBinaryNodeChild(result, 'status')
return {
status: status?.content!.toString(),
@@ -205,7 +205,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
/** update the profile picture for yourself or a group */
const updateProfilePicture = async(jid: string, content: WAMediaUpload) => {
const updateProfilePicture = async (jid: string, content: WAMediaUpload) => {
const { img } = await generateProfilePicture(content)
await query({
tag: 'iq',
@@ -225,7 +225,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
/** remove the profile picture for yourself or a group */
const removeProfilePicture = async(jid: string) => {
const removeProfilePicture = async (jid: string) => {
await query({
tag: 'iq',
attrs: {
@@ -237,7 +237,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
/** update the profile status for yourself */
const updateProfileStatus = async(status: string) => {
const updateProfileStatus = async (status: string) => {
await query({
tag: 'iq',
attrs: {
@@ -248,18 +248,18 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'status',
attrs: { },
attrs: {},
content: Buffer.from(status, 'utf-8')
}
]
})
}
const updateProfileName = async(name: string) => {
const updateProfileName = async (name: string) => {
await chatModify({ pushNameSetting: name }, '')
}
const fetchBlocklist = async() => {
const fetchBlocklist = async () => {
const result = await query({
tag: 'iq',
attrs: {
@@ -274,7 +274,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
.map(n => n.attrs.jid)
}
const updateBlockStatus = async(jid: string, action: 'block' | 'unblock') => {
const updateBlockStatus = async (jid: string, action: 'block' | 'unblock') => {
await query({
tag: 'iq',
attrs: {
@@ -294,7 +294,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
})
}
const getBusinessProfile = async(jid: string): Promise<WABusinessProfile | void> => {
const getBusinessProfile = async (jid: string): Promise<WABusinessProfile | void> => {
const results = await query({
tag: 'iq',
attrs: {
@@ -314,7 +314,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
const profileNode = getBinaryNodeChild(results, 'business_profile')
const profiles = getBinaryNodeChild(profileNode, 'profile')
if(profiles) {
if (profiles) {
const address = getBinaryNodeChild(profiles, 'address')
const description = getBinaryNodeChild(profiles, 'description')
const website = getBinaryNodeChild(profiles, 'website')
@@ -340,7 +340,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
}
const updateAccountSyncTimestamp = async(fromTimestamp: number | string) => {
const updateAccountSyncTimestamp = async (fromTimestamp: number | string) => {
logger.info({ fromTimestamp }, 'requesting account sync')
await sendNode({
tag: 'iq',
@@ -376,30 +376,30 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
}
const resyncAppState = ev.createBufferedFunction(async(collections: readonly WAPatchName[], isInitialSync: boolean) => {
const resyncAppState = ev.createBufferedFunction(async (collections: readonly WAPatchName[], isInitialSync: boolean) => {
// we use this to determine which events to fire
// otherwise when we resync from scratch -- all notifications will fire
const initialVersionMap: { [T in WAPatchName]?: number } = { }
const globalMutationMap: ChatMutationMap = { }
const initialVersionMap: { [T in WAPatchName]?: number } = {}
const globalMutationMap: ChatMutationMap = {}
await authState.keys.transaction(
async() => {
async () => {
const collectionsToHandle = new Set<string>(collections)
// in case something goes wrong -- ensure we don't enter a loop that cannot be exited from
const attemptsMap: { [T in WAPatchName]?: number } = { }
const attemptsMap: { [T in WAPatchName]?: number } = {}
// keep executing till all collections are done
// sometimes a single patch request will not return all the patches (God knows why)
// so we fetch till they're all done (this is determined by the "has_more_patches" flag)
while(collectionsToHandle.size) {
const states = { } as { [T in WAPatchName]: LTHashState }
while (collectionsToHandle.size) {
const states = {} as { [T in WAPatchName]: LTHashState }
const nodes: BinaryNode[] = []
for(const name of collectionsToHandle) {
for (const name of collectionsToHandle) {
const result = await authState.keys.get('app-state-sync-version', [name])
let state = result[name]
if(state) {
if(typeof initialVersionMap[name] === 'undefined') {
if (state) {
if (typeof initialVersionMap[name] === 'undefined') {
initialVersionMap[name] = state.version
}
} else {
@@ -412,7 +412,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
nodes.push({
tag: 'collection',
attrs: {
attrs: {
name,
version: state.version.toString(),
// return snapshot if being synced from scratch
@@ -431,7 +431,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'sync',
attrs: { },
attrs: {},
content: nodes
}
]
@@ -439,11 +439,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
// extract from binary node
const decoded = await extractSyncdPatches(result, config?.options)
for(const key in decoded) {
for (const key in decoded) {
const name = key as WAPatchName
const { patches, hasMorePatches, snapshot } = decoded[name]
try {
if(snapshot) {
if (snapshot) {
const { state: newState, mutationMap } = await decodeSyncdSnapshot(
name,
snapshot,
@@ -460,7 +460,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
// only process if there are syncd patches
if(patches.length) {
if (patches.length) {
const { state: newState, mutationMap } = await decodePatches(
name,
patches,
@@ -480,12 +480,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
Object.assign(globalMutationMap, mutationMap)
}
if(hasMorePatches) {
if (hasMorePatches) {
logger.info(`${name} has more patches...`)
} else { // collection is done with sync
collectionsToHandle.delete(name)
}
} catch(error) {
} catch (error) {
// if retry attempts overshoot
// or key not found
const isIrrecoverableError = attemptsMap[name]! >= MAX_SYNC_ATTEMPTS
@@ -499,7 +499,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
// increment number of retries
attemptsMap[name] = (attemptsMap[name] || 0) + 1
if(isIrrecoverableError) {
if (isIrrecoverableError) {
// stop retrying
collectionsToHandle.delete(name)
}
@@ -510,17 +510,17 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
const { onMutation } = newAppStateChunkHandler(isInitialSync)
for(const key in globalMutationMap) {
for (const key in globalMutationMap) {
onMutation(globalMutationMap[key])
}
})
/**
* fetch the profile picture of a user/group
* type = "preview" for a low res picture
* type = "image for the high res picture"
*/
const profilePictureUrl = async(jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
* fetch the profile picture of a user/group
* type = "preview" for a low res picture
* type = "image for the high res picture"
*/
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
jid = jidNormalizedUser(jid)
const result = await query({
tag: 'iq',
@@ -537,10 +537,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
return child?.attrs?.url
}
const sendPresenceUpdate = async(type: WAPresence, toJid?: string) => {
const sendPresenceUpdate = async (type: WAPresence, toJid?: string) => {
const me = authState.creds.me!
if(type === 'available' || type === 'unavailable') {
if(!me!.name) {
if (type === 'available' || type === 'unavailable') {
if (!me!.name) {
logger.warn('no name present, ignoring presence update request...')
return
}
@@ -564,7 +564,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: type === 'recording' ? 'composing' : type,
attrs: type === 'recording' ? { media : 'audio' } : {}
attrs: type === 'recording' ? { media: 'audio' } : {}
}
]
})
@@ -587,7 +587,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
? [
{
tag: 'tctoken',
attrs: { },
attrs: {},
content: tcToken
}
]
@@ -600,23 +600,23 @@ export const makeChatsSocket = (config: SocketConfig) => {
const jid = attrs.from
const participant = attrs.participant || attrs.from
if(shouldIgnoreJid(jid)) {
if (shouldIgnoreJid(jid)) {
return
}
if(tag === 'presence') {
if (tag === 'presence') {
presence = {
lastKnownPresence: attrs.type === 'unavailable' ? 'unavailable' : 'available',
lastSeen: attrs.last && attrs.last !== 'deny' ? +attrs.last : undefined
}
} else if(Array.isArray(content)) {
} else if (Array.isArray(content)) {
const [firstChild] = content
let type = firstChild.tag as WAPresence
if(type === 'paused') {
if (type === 'paused') {
type = 'available'
}
if(firstChild.attrs?.media === 'audio') {
if (firstChild.attrs?.media === 'audio') {
type = 'recording'
}
@@ -625,15 +625,15 @@ export const makeChatsSocket = (config: SocketConfig) => {
logger.error({ tag, attrs, content }, 'recv invalid presence node')
}
if(presence) {
if (presence) {
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
}
}
const appPatch = async(patchCreate: WAPatchCreate) => {
const appPatch = async (patchCreate: WAPatchCreate) => {
const name = patchCreate.type
const myAppStateKeyId = authState.creds.myAppStateKeyId
if(!myAppStateKeyId) {
if (!myAppStateKeyId) {
throw new Boom('App state key not present!', { statusCode: 400 })
}
@@ -641,9 +641,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
let encodeResult: { patch: proto.ISyncdPatch, state: LTHashState }
await processingMutex.mutex(
async() => {
async () => {
await authState.keys.transaction(
async() => {
async () => {
logger.debug({ patch: patchCreate }, 'applying app patch')
await resyncAppState([name], false)
@@ -669,7 +669,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'sync',
attrs: { },
attrs: {},
content: [
{
tag: 'collection',
@@ -681,7 +681,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
content: [
{
tag: 'patch',
attrs: { },
attrs: {},
content: proto.SyncdPatch.encode(patch).finish()
}
]
@@ -698,7 +698,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
)
if(config.emitOwnEvents) {
if (config.emitOwnEvents) {
const { onMutation } = newAppStateChunkHandler(false)
const { mutationMap } = await decodePatches(
name,
@@ -709,14 +709,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
undefined,
logger,
)
for(const key in mutationMap) {
for (const key in mutationMap) {
onMutation(mutationMap[key])
}
}
}
/** sending abt props may fix QR scan fail if server expects */
const fetchAbt = async() => {
const fetchAbt = async () => {
const abtNode = await query({
tag: 'iq',
attrs: {
@@ -731,8 +731,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
const propsNode = getBinaryNodeChild(abtNode, 'props')
let props: { [_: string]: string } = { }
if(propsNode) {
let props: { [_: string]: string } = {}
if (propsNode) {
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
}
@@ -742,7 +742,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
/** sending non-abt props may fix QR scan fail if server expects */
const fetchProps = async() => {
const fetchProps = async () => {
const resultNode = await query({
tag: 'iq',
attrs: {
@@ -751,14 +751,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
type: 'get',
},
content: [
{ tag: 'props', attrs: { } }
{ tag: 'props', attrs: {} }
]
})
const propsNode = getBinaryNodeChild(resultNode, 'props')
let props: { [_: string]: string } = { }
if(propsNode) {
let props: { [_: string]: string } = {}
if (propsNode) {
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
}
@@ -768,20 +768,66 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
/**
* modify a chat -- mark unread, read etc.
* lastMessages must be sorted in reverse chronologically
* requires the last messages till the last message received; required for archive & unread
*/
* modify a chat -- mark unread, read etc.
* lastMessages must be sorted in reverse chronologically
* requires the last messages till the last message received; required for archive & unread
*/
const chatModify = (mod: ChatModification, jid: string) => {
const patch = chatModificationToAppPatch(mod, jid)
return appPatch(patch)
}
/**
* Adds label for the chats
*/
const addChatLabel = (jid: string, labelId: string) => {
return chatModify({
addChatLabel: {
labelId
}
}, jid)
}
/**
* Removes label for the chat
*/
const removeChatLabel = (jid: string, labelId: string) => {
return chatModify({
removeChatLabel: {
labelId
}
}, jid)
}
/**
* Adds label for the message
*/
const addMessageLabel = (jid: string, messageId: string, labelId: string) => {
return chatModify({
addMessageLabel: {
messageId,
labelId
}
}, jid)
}
/**
* Removes label for the message
*/
const removeMessageLabel = (jid: string, messageId: string, labelId: string) => {
return chatModify({
removeMessageLabel: {
messageId,
labelId
}
}, jid)
}
/**
* queries need to be fired on connection open
* help ensure parity with WA Web
* */
const executeInitQueries = async() => {
const executeInitQueries = async () => {
await Promise.all([
fetchAbt(),
fetchProps(),
@@ -790,19 +836,19 @@ export const makeChatsSocket = (config: SocketConfig) => {
])
}
const upsertMessage = ev.createBufferedFunction(async(msg: WAMessage, type: MessageUpsertType) => {
const upsertMessage = ev.createBufferedFunction(async (msg: WAMessage, type: MessageUpsertType) => {
ev.emit('messages.upsert', { messages: [msg], type })
if(!!msg.pushName) {
if (!!msg.pushName) {
let jid = msg.key.fromMe ? authState.creds.me!.id : (msg.key.participant || msg.key.remoteJid)
jid = jidNormalizedUser(jid!)
if(!msg.key.fromMe) {
if (!msg.key.fromMe) {
ev.emit('contacts.update', [{ id: jid, notify: msg.pushName, verifiedName: msg.verifiedBizName! }])
}
// update our pushname too
if(msg.key.fromMe && msg.pushName && authState.creds.me?.name !== msg.pushName) {
if (msg.key.fromMe && msg.pushName && authState.creds.me?.name !== msg.pushName) {
ev.emit('creds.update', { me: { ...authState.creds.me!, name: msg.pushName! } })
}
}
@@ -815,14 +861,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
: false
if(historyMsg && !authState.creds.myAppStateKeyId) {
if (historyMsg && !authState.creds.myAppStateKeyId) {
logger.warn('skipping app state sync, as myAppStateKeyId is not set')
pendingAppStateSync = true
}
await Promise.all([
(async() => {
if(
(async () => {
if (
historyMsg
&& authState.creds.myAppStateKeyId
) {
@@ -844,7 +890,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
])
if(
if (
msg.message?.protocolMessage?.appStateSyncKeyShare
&& pendingAppStateSync
) {
@@ -853,14 +899,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
async function doAppStateSync() {
if(!authState.creds.accountSyncCounter) {
if (!authState.creds.accountSyncCounter) {
logger.info('doing initial app state sync')
await resyncAppState(ALL_WA_PATCH_NAMES, true)
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
ev.emit('creds.update', { accountSyncCounter })
if(needToFlushWithAppStateSync) {
if (needToFlushWithAppStateSync) {
logger.debug('flushing with app state sync')
ev.flush()
}
@@ -871,31 +917,31 @@ export const makeChatsSocket = (config: SocketConfig) => {
ws.on('CB:presence', handlePresenceUpdate)
ws.on('CB:chatstate', handlePresenceUpdate)
ws.on('CB:ib,,dirty', async(node: BinaryNode) => {
ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')!
const type = attrs.type
switch (type) {
case 'account_sync':
if(attrs.timestamp) {
let { lastAccountSyncTimestamp } = authState.creds
if(lastAccountSyncTimestamp) {
await updateAccountSyncTimestamp(lastAccountSyncTimestamp)
case 'account_sync':
if (attrs.timestamp) {
let { lastAccountSyncTimestamp } = authState.creds
if (lastAccountSyncTimestamp) {
await updateAccountSyncTimestamp(lastAccountSyncTimestamp)
}
lastAccountSyncTimestamp = +attrs.timestamp
ev.emit('creds.update', { lastAccountSyncTimestamp })
}
lastAccountSyncTimestamp = +attrs.timestamp
ev.emit('creds.update', { lastAccountSyncTimestamp })
}
break
default:
logger.info({ node }, 'received unknown sync')
break
break
default:
logger.info({ node }, 'received unknown sync')
break
}
})
ev.on('connection.update', ({ connection, receivedPendingNotifications }) => {
if(connection === 'open') {
if(fireInitQueries) {
if (connection === 'open') {
if (fireInitQueries) {
executeInitQueries()
.catch(
error => onUnexpectedError(error, 'init queries')
@@ -908,11 +954,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
)
}
if(receivedPendingNotifications) {
if (receivedPendingNotifications) {
// if we don't have the app state key
// we keep buffering events until we finally have
// the key and can sync the messages
if(!authState.creds?.myAppStateKeyId) {
if (!authState.creds?.myAppStateKeyId) {
ev.buffer()
needToFlushWithAppStateSync = true
}
@@ -945,6 +991,10 @@ export const makeChatsSocket = (config: SocketConfig) => {
updateDefaultDisappearingMode,
getBusinessProfile,
resyncAppState,
chatModify
chatModify,
addChatLabel,
removeChatLabel,
addMessageLabel,
removeMessageLabel
}
}

View File

@@ -1,6 +1,8 @@
import type { proto } from '../../WAProto'
import type { AccountSettings } from './Auth'
import type { BufferedEventData } from './Events'
import type { ChatLabelAssociationActionBody } from './LabelAssociation'
import type { MessageLabelAssociationActionBody } from './LabelAssociation'
import type { MinimalMessage } from './Message'
/** privacy settings in WhatsApp Web */
@@ -14,11 +16,11 @@ export type WAReadReceiptsValue = 'all' | 'none'
export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused'
export const ALL_WA_PATCH_NAMES = [
'critical_block',
'critical_unblock_low',
'regular_high',
'regular_low',
'regular'
'critical_block',
'critical_unblock_low',
'regular_high',
'regular_low',
'regular'
] as const
export type WAPatchName = typeof ALL_WA_PATCH_NAMES[number]
@@ -77,7 +79,7 @@ export type ChatModification =
mute: number | null
}
| {
clear: 'all' | { messages: {id: string, fromMe?: boolean, timestamp: number}[] }
clear: 'all' | { messages: { id: string, fromMe?: boolean, timestamp: number }[] }
}
| {
star: {
@@ -90,6 +92,11 @@ export type ChatModification =
lastMessages: LastMessageList
}
| { delete: true, lastMessages: LastMessageList }
// Label assosiation
| { addChatLabel: ChatLabelAssociationActionBody }
| { removeChatLabel: ChatLabelAssociationActionBody }
| { addMessageLabel: MessageLabelAssociationActionBody }
| { removeMessageLabel: MessageLabelAssociationActionBody }
export type InitialReceivedChatsState = {
[jid: string]: {

View File

@@ -5,6 +5,8 @@ import { WACallEvent } from './Call'
import { Chat, ChatUpdate, PresenceData } from './Chat'
import { Contact } from './Contact'
import { GroupMetadata, ParticipantAction } from './GroupMetadata'
import { Label } from './Label'
import { LabelAssociation } from './LabelAssociation'
import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
import { ConnectionState } from './State'
@@ -54,6 +56,8 @@ export type BaileysEventMap = {
'blocklist.update': { blocklist: string[], type: 'add' | 'remove' }
/** Receive an update on a call, including when the call was received, rejected, accepted */
'call': WACallEvent[]
'labels.edit': Label
'labels.association': { association: LabelAssociation, type: 'add' | 'remove' }
}
export type BufferedEventData = {

View File

@@ -3,6 +3,7 @@ import { AxiosRequestConfig } from 'axios'
import type { Logger } from 'pino'
import { proto } from '../../WAProto'
import { BaileysEventEmitter, Chat, ChatModification, ChatMutation, ChatUpdate, Contact, InitialAppStateSyncOptions, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types'
import { ChatLabelAssociation, LabelAssociationType, MessageLabelAssociation } from '../Types/LabelAssociation'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary'
import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto'
import { toNumber } from './generics'
@@ -28,24 +29,24 @@ const generateMac = (operation: proto.SyncdMutation.SyncdOperation, data: Buffer
const getKeyData = () => {
let r: number
switch (operation) {
case proto.SyncdMutation.SyncdOperation.SET:
r = 0x01
break
case proto.SyncdMutation.SyncdOperation.REMOVE:
r = 0x02
break
case proto.SyncdMutation.SyncdOperation.SET:
r = 0x01
break
case proto.SyncdMutation.SyncdOperation.REMOVE:
r = 0x02
break
}
const buff = Buffer.from([r])
return Buffer.concat([ buff, Buffer.from(keyId as any, 'base64') ])
return Buffer.concat([buff, Buffer.from(keyId as any, 'base64')])
}
const keyData = getKeyData()
const last = Buffer.alloc(8) // 8 bytes
last.set([ keyData.length ], last.length - 1)
last.set([keyData.length], last.length - 1)
const total = Buffer.concat([ keyData, data, last ])
const total = Buffer.concat([keyData, data, last])
const hmac = hmacSign(total, key, 'sha512')
return hmac.slice(0, 32)
@@ -68,8 +69,8 @@ const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' |
mix: ({ indexMac, valueMac, operation }: Mac) => {
const indexMacBase64 = Buffer.from(indexMac).toString('base64')
const prevOp = indexValueMap[indexMacBase64]
if(operation === proto.SyncdMutation.SyncdOperation.REMOVE) {
if(!prevOp) {
if (operation === proto.SyncdMutation.SyncdOperation.REMOVE) {
if (!prevOp) {
throw new Boom('tried remove, but no previous op', { data: { indexMac, valueMac } })
}
@@ -81,7 +82,7 @@ const makeLtHashGenerator = ({ indexValueMap, hash }: Pick<LTHashState, 'hash' |
indexValueMap[indexMacBase64] = { valueMac }
}
if(prevOp) {
if (prevOp) {
subBuffs.push(new Uint8Array(prevOp.valueMac).buffer)
}
},
@@ -119,14 +120,14 @@ const generatePatchMac = (snapshotMac: Uint8Array, valueMacs: Uint8Array[], vers
export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} })
export const encodeSyncdPatch = async(
export const encodeSyncdPatch = async (
{ type, index, syncAction, apiVersion, operation }: WAPatchCreate,
myAppStateKeyId: string,
state: LTHashState,
getAppStateSyncKey: FetchAppStateSyncKey
) => {
const key = !!myAppStateKeyId ? await getAppStateSyncKey(myAppStateKeyId) : undefined
if(!key) {
if (!key) {
throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { statusCode: 404 })
}
@@ -170,7 +171,7 @@ export const encodeSyncdPatch = async(
blob: indexMac
},
value: {
blob: Buffer.concat([ encValue, valueMac ])
blob: Buffer.concat([encValue, valueMac])
},
keyId: { id: encKeyId }
}
@@ -184,7 +185,7 @@ export const encodeSyncdPatch = async(
return { patch, state }
}
export const decodeSyncdMutations = async(
export const decodeSyncdMutations = async (
msgMutations: (proto.ISyncdMutation | proto.ISyncdRecord)[],
initialState: LTHashState,
getAppStateSyncKey: FetchAppStateSyncKey,
@@ -195,7 +196,7 @@ export const decodeSyncdMutations = async(
// indexKey used to HMAC sign record.index.blob
// valueEncryptionKey used to AES-256-CBC encrypt record.value.blob[0:-32]
// the remaining record.value.blob[0:-32] is the mac, it the HMAC sign of key.keyId + decoded proto data + length of bytes in keyId
for(const msgMutation of msgMutations!) {
for (const msgMutation of msgMutations!) {
// if it's a syncdmutation, get the operation property
// otherwise, if it's only a record -- it'll be a SET mutation
const operation = 'operation' in msgMutation ? msgMutation.operation : proto.SyncdMutation.SyncdOperation.SET
@@ -205,9 +206,9 @@ export const decodeSyncdMutations = async(
const content = Buffer.from(record.value!.blob!)
const encContent = content.slice(0, -32)
const ogValueMac = content.slice(-32)
if(validateMacs) {
if (validateMacs) {
const contentHmac = generateMac(operation!, encContent, record.keyId!.id!, key.valueMacKey)
if(Buffer.compare(contentHmac, ogValueMac) !== 0) {
if (Buffer.compare(contentHmac, ogValueMac) !== 0) {
throw new Boom('HMAC content verification failed')
}
}
@@ -215,9 +216,9 @@ export const decodeSyncdMutations = async(
const result = aesDecrypt(encContent, key.valueEncryptionKey)
const syncAction = proto.SyncActionData.decode(result)
if(validateMacs) {
if (validateMacs) {
const hmac = hmacSign(syncAction.index, key.indexKey)
if(Buffer.compare(hmac, record.index!.blob!) !== 0) {
if (Buffer.compare(hmac, record.index!.blob!) !== 0) {
throw new Boom('HMAC index verification failed')
}
}
@@ -237,7 +238,7 @@ export const decodeSyncdMutations = async(
async function getKey(keyId: Uint8Array) {
const base64Key = Buffer.from(keyId!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if(!keyEnc) {
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { statusCode: 404, data: { msgMutations } })
}
@@ -245,7 +246,7 @@ export const decodeSyncdMutations = async(
}
}
export const decodeSyncdPatch = async(
export const decodeSyncdPatch = async (
msg: proto.ISyncdPatch,
name: WAPatchName,
initialState: LTHashState,
@@ -253,10 +254,10 @@ export const decodeSyncdPatch = async(
onMutation: (mutation: ChatMutation) => void,
validateMacs: boolean
) => {
if(validateMacs) {
if (validateMacs) {
const base64Key = Buffer.from(msg.keyId!.id!).toString('base64')
const mainKeyObj = await getAppStateSyncKey(base64Key)
if(!mainKeyObj) {
if (!mainKeyObj) {
throw new Boom(`failed to find key "${base64Key}" to decode patch`, { statusCode: 404, data: { msg } })
}
@@ -264,7 +265,7 @@ export const decodeSyncdPatch = async(
const mutationmacs = msg.mutations!.map(mutation => mutation.record!.value!.blob!.slice(-32))
const patchMac = generatePatchMac(msg.snapshotMac!, mutationmacs, toNumber(msg.version!.version!), name, mainKey.patchMacKey)
if(Buffer.compare(patchMac, msg.patchMac!) !== 0) {
if (Buffer.compare(patchMac, msg.patchMac!) !== 0) {
throw new Boom('Invalid patch mac')
}
}
@@ -273,14 +274,14 @@ export const decodeSyncdPatch = async(
return result
}
export const extractSyncdPatches = async(
export const extractSyncdPatches = async (
result: BinaryNode,
options: AxiosRequestConfig<any>
) => {
const syncNode = getBinaryNodeChild(result, 'sync')
const collectionNodes = getBinaryNodeChildren(syncNode, 'collection')
const final = { } as { [T in WAPatchName]: { patches: proto.ISyncdPatch[], hasMorePatches: boolean, snapshot?: proto.ISyncdSnapshot } }
const final = {} as { [T in WAPatchName]: { patches: proto.ISyncdPatch[], hasMorePatches: boolean, snapshot?: proto.ISyncdSnapshot } }
await Promise.all(
collectionNodes.map(
async collectionNode => {
@@ -295,26 +296,26 @@ export const extractSyncdPatches = async(
const hasMorePatches = collectionNode.attrs.has_more_patches === 'true'
let snapshot: proto.ISyncdSnapshot | undefined = undefined
if(snapshotNode && !!snapshotNode.content) {
if(!Buffer.isBuffer(snapshotNode)) {
if (snapshotNode && !!snapshotNode.content) {
if (!Buffer.isBuffer(snapshotNode)) {
snapshotNode.content = Buffer.from(Object.values(snapshotNode.content))
}
const blobRef = proto.ExternalBlobReference.decode(
snapshotNode.content! as Buffer
snapshotNode.content! as Buffer
)
const data = await downloadExternalBlob(blobRef, options)
snapshot = proto.SyncdSnapshot.decode(data)
}
for(let { content } of patches) {
if(content) {
if(!Buffer.isBuffer(content)) {
for (let { content } of patches) {
if (content) {
if (!Buffer.isBuffer(content)) {
content = Buffer.from(Object.values(content))
}
const syncd = proto.SyncdPatch.decode(content! as Uint8Array)
if(!syncd.version) {
if (!syncd.version) {
syncd.version = { version: +collectionNode.attrs.version + 1 }
}
@@ -331,7 +332,7 @@ export const extractSyncdPatches = async(
}
export const downloadExternalBlob = async(
export const downloadExternalBlob = async (
blob: proto.IExternalBlobReference,
options: AxiosRequestConfig<any>
) => {
@@ -344,7 +345,7 @@ export const downloadExternalBlob = async(
return Buffer.concat(bufferArray)
}
export const downloadExternalPatch = async(
export const downloadExternalPatch = async (
blob: proto.IExternalBlobReference,
options: AxiosRequestConfig<any>
) => {
@@ -353,7 +354,7 @@ export const downloadExternalPatch = async(
return syncData
}
export const decodeSyncdSnapshot = async(
export const decodeSyncdSnapshot = async (
name: WAPatchName,
snapshot: proto.ISyncdSnapshot,
getAppStateSyncKey: FetchAppStateSyncKey,
@@ -382,16 +383,16 @@ export const decodeSyncdSnapshot = async(
newState.hash = hash
newState.indexValueMap = indexValueMap
if(validateMacs) {
if (validateMacs) {
const base64Key = Buffer.from(snapshot.keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if(!keyEnc) {
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if(Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
if (Buffer.compare(snapshot.mac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`)
}
}
@@ -402,7 +403,7 @@ export const decodeSyncdSnapshot = async(
}
}
export const decodePatches = async(
export const decodePatches = async (
name: WAPatchName,
syncds: proto.ISyncdPatch[],
initial: LTHashState,
@@ -417,12 +418,12 @@ export const decodePatches = async(
indexValueMap: { ...initial.indexValueMap }
}
const mutationMap: ChatMutationMap = { }
const mutationMap: ChatMutationMap = {}
for(let i = 0;i < syncds.length;i++) {
for (let i = 0; i < syncds.length; i++) {
const syncd = syncds[i]
const { version, keyId, snapshotMac } = syncd
if(syncd.externalMutations) {
if (syncd.externalMutations) {
logger?.trace({ name, version }, 'downloading external patch')
const ref = await downloadExternalPatch(syncd.externalMutations, options)
logger?.debug({ name, version, mutations: ref.mutations.length }, 'downloaded external patch')
@@ -451,16 +452,16 @@ export const decodePatches = async(
newState.hash = decodeResult.hash
newState.indexValueMap = decodeResult.indexValueMap
if(validateMacs) {
if (validateMacs) {
const base64Key = Buffer.from(keyId!.id!).toString('base64')
const keyEnc = await getAppStateSyncKey(base64Key)
if(!keyEnc) {
if (!keyEnc) {
throw new Boom(`failed to find key "${base64Key}" to decode mutation`)
}
const result = mutationKeys(keyEnc.keyData!)
const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey)
if(Buffer.compare(snapshotMac!, computedSnapshotMac) !== 0) {
if (Buffer.compare(snapshotMac!, computedSnapshotMac) !== 0) {
throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`)
}
}
@@ -479,25 +480,25 @@ export const chatModificationToAppPatch = (
const OP = proto.SyncdMutation.SyncdOperation
const getMessageRange = (lastMessages: LastMessageList) => {
let messageRange: proto.SyncActionValue.ISyncActionMessageRange
if(Array.isArray(lastMessages)) {
if (Array.isArray(lastMessages)) {
const lastMsg = lastMessages[lastMessages.length - 1]
messageRange = {
lastMessageTimestamp: lastMsg?.messageTimestamp,
messages: lastMessages?.length ? lastMessages.map(
m => {
if(!m.key?.id || !m.key?.remoteJid) {
if (!m.key?.id || !m.key?.remoteJid) {
throw new Boom('Incomplete key', { statusCode: 400, data: m })
}
if(isJidGroup(m.key.remoteJid) && !m.key.fromMe && !m.key.participant) {
if (isJidGroup(m.key.remoteJid) && !m.key.fromMe && !m.key.participant) {
throw new Boom('Expected not from me message to have participant', { statusCode: 400, data: m })
}
if(!m.messageTimestamp || !toNumber(m.messageTimestamp)) {
if (!m.messageTimestamp || !toNumber(m.messageTimestamp)) {
throw new Boom('Missing timestamp in last message list', { statusCode: 400, data: m })
}
if(m.key.participant) {
if (m.key.participant) {
m.key.participant = jidNormalizedUser(m.key.participant)
}
@@ -513,7 +514,7 @@ export const chatModificationToAppPatch = (
}
let patch: WAPatchCreate
if('mute' in mod) {
if ('mute' in mod) {
patch = {
syncAction: {
muteAction: {
@@ -526,7 +527,7 @@ export const chatModificationToAppPatch = (
apiVersion: 2,
operation: OP.SET
}
} else if('archive' in mod) {
} else if ('archive' in mod) {
patch = {
syncAction: {
archiveChatAction: {
@@ -539,7 +540,7 @@ export const chatModificationToAppPatch = (
apiVersion: 3,
operation: OP.SET
}
} else if('markRead' in mod) {
} else if ('markRead' in mod) {
patch = {
syncAction: {
markChatAsReadAction: {
@@ -552,8 +553,8 @@ export const chatModificationToAppPatch = (
apiVersion: 3,
operation: OP.SET
}
} else if('clear' in mod) {
if(mod.clear === 'all') {
} else if ('clear' in mod) {
if (mod.clear === 'all') {
throw new Boom('not supported')
} else {
const key = mod.clear.messages[0]
@@ -570,7 +571,7 @@ export const chatModificationToAppPatch = (
operation: OP.SET
}
}
} else if('pin' in mod) {
} else if ('pin' in mod) {
patch = {
syncAction: {
pinAction: {
@@ -582,7 +583,7 @@ export const chatModificationToAppPatch = (
apiVersion: 5,
operation: OP.SET
}
} else if('delete' in mod) {
} else if ('delete' in mod) {
patch = {
syncAction: {
deleteChatAction: {
@@ -594,7 +595,7 @@ export const chatModificationToAppPatch = (
apiVersion: 6,
operation: OP.SET
}
} else if('pushNameSetting' in mod) {
} else if ('pushNameSetting' in mod) {
patch = {
syncAction: {
pushNameSetting: {
@@ -606,6 +607,68 @@ export const chatModificationToAppPatch = (
apiVersion: 1,
operation: OP.SET,
}
} else if ('addChatLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: true,
}
},
index: [LabelAssociationType.Chat, mod.addChatLabel.labelId, jid],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else if ('removeChatLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: false,
}
},
index: [LabelAssociationType.Chat, mod.removeChatLabel.labelId, jid],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else if ('addMessageLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: true,
}
},
index: [
LabelAssociationType.Message,
mod.addMessageLabel.labelId,
jid,
mod.addMessageLabel.messageId,
'0',
'0'
],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else if ('removeMessageLabel' in mod) {
patch = {
syncAction: {
labelAssociationAction: {
labeled: false,
}
},
index: [
LabelAssociationType.Message,
mod.removeMessageLabel.labelId,
jid,
mod.removeMessageLabel.messageId,
'0',
'0'
],
type: 'regular',
apiVersion: 3,
operation: OP.SET,
}
} else {
throw new Boom('not supported')
}
@@ -632,7 +695,7 @@ export const processSyncAction = (
index: [type, id, msgId, fromMe]
} = syncAction
if(action?.muteAction) {
if (action?.muteAction) {
ev.emit(
'chats.update',
[
@@ -645,7 +708,7 @@ export const processSyncAction = (
}
]
)
} else if(action?.archiveChatAction || type === 'archive' || type === 'unarchive') {
} else if (action?.archiveChatAction || type === 'archive' || type === 'unarchive') {
// okay so we've to do some annoying computation here
// when we're initially syncing the app state
// there are a few cases we need to handle
@@ -659,7 +722,7 @@ export const processSyncAction = (
const archiveAction = action?.archiveChatAction
const isArchived = archiveAction
? archiveAction.archived
: type === 'archive'
: type === 'archive'
// // basically we don't need to fire an "archive" update if the chat is being marked unarchvied
// // this only applies for the initial sync
// if(isInitialSync && !isArchived) {
@@ -674,7 +737,7 @@ export const processSyncAction = (
archived: isArchived,
conditional: getChatUpdateConditional(id, msgRange)
}])
} else if(action?.markChatAsReadAction) {
} else if (action?.markChatAsReadAction) {
const markReadAction = action.markChatAsReadAction
// basically we don't need to fire an "read" update if the chat is being marked as read
// because the chat is read by default
@@ -686,38 +749,40 @@ export const processSyncAction = (
unreadCount: isNullUpdate ? null : !!markReadAction?.read ? 0 : -1,
conditional: getChatUpdateConditional(id, markReadAction?.messageRange)
}])
} else if(action?.deleteMessageForMeAction || type === 'deleteMessageForMe') {
ev.emit('messages.delete', { keys: [
{
remoteJid: id,
id: msgId,
fromMe: fromMe === '1'
}
] })
} else if(action?.contactAction) {
} else if (action?.deleteMessageForMeAction || type === 'deleteMessageForMe') {
ev.emit('messages.delete', {
keys: [
{
remoteJid: id,
id: msgId,
fromMe: fromMe === '1'
}
]
})
} else if (action?.contactAction) {
ev.emit('contacts.upsert', [{ id, name: action.contactAction!.fullName! }])
} else if(action?.pushNameSetting) {
} else if (action?.pushNameSetting) {
const name = action?.pushNameSetting?.name
if(name && me?.name !== name) {
if (name && me?.name !== name) {
ev.emit('creds.update', { me: { ...me, name } })
}
} else if(action?.pinAction) {
} else if (action?.pinAction) {
ev.emit('chats.update', [{
id,
pinned: action.pinAction?.pinned ? toNumber(action.timestamp!) : null,
conditional: getChatUpdateConditional(id, undefined)
}])
} else if(action?.unarchiveChatsSetting) {
} else if (action?.unarchiveChatsSetting) {
const unarchiveChats = !!action.unarchiveChatsSetting.unarchiveChats
ev.emit('creds.update', { accountSettings: { unarchiveChats } })
logger?.info(`archive setting updated => '${action.unarchiveChatsSetting.unarchiveChats}'`)
if(accountSettings) {
if (accountSettings) {
accountSettings.unarchiveChats = unarchiveChats
}
} else if(action?.starAction || type === 'star') {
} else if (action?.starAction || type === 'star') {
let starred = action?.starAction?.starred
if(typeof starred !== 'boolean') {
if (typeof starred !== 'boolean') {
starred = syncAction.index[syncAction.index.length - 1] === '1'
}
@@ -727,10 +792,38 @@ export const processSyncAction = (
update: { starred }
}
])
} else if(action?.deleteChatAction || type === 'deleteChat') {
if(!isInitialSync) {
} else if (action?.deleteChatAction || type === 'deleteChat') {
if (!isInitialSync) {
ev.emit('chats.delete', [id])
}
} else if (action?.labelEditAction) {
const { name, color, deleted, predefinedId } = action.labelEditAction!
ev.emit('labels.edit', {
id,
name: name!,
color: color!,
deleted: deleted!,
predefinedId: predefinedId ? String(predefinedId) : undefined
})
} else if (action?.labelAssociationAction) {
ev.emit('labels.association', {
type: action.labelAssociationAction.labeled
? 'add'
: 'remove',
association: type === LabelAssociationType.Chat
? {
type: LabelAssociationType.Chat,
chatId: syncAction.index[2],
labelId: syncAction.index[1]
} as ChatLabelAssociation
: {
type: LabelAssociationType.Message,
chatId: syncAction.index[2],
messageId: syncAction.index[3],
labelId: syncAction.index[1]
} as MessageLabelAssociation
})
} else {
logger?.debug({ syncAction, id }, 'unprocessable update')
}
@@ -739,7 +832,7 @@ export const processSyncAction = (
return isInitialSync
? (data) => {
const chat = data.historySets.chats[id] || data.chatUpserts[id]
if(chat) {
if (chat) {
return msgRange ? isValidPatchBasedOnMessageRange(chat, msgRange) : true
}
}