refactor!: cleaner message history sync

This is a breaking change,
1. three events (chats.set, contacts.set, messages.set) are now just one `messaging-history.set` event
2. no need to debounce for app state sync
3. added a new "conditional" chat update to allow for correct app state sync despite not having the chat available on hand
This commit is contained in:
Adhiraj Singh
2022-09-29 16:32:57 +05:30
parent e08dd10198
commit d0330d1863
16 changed files with 600 additions and 309 deletions

View File

@@ -98,21 +98,10 @@ const startSock = async() => {
console.log('recv call event', events.call) console.log('recv call event', events.call)
} }
// chat history received // history received
if(events['chats.set']) { if(events['messaging-history.set']) {
const { chats, isLatest } = events['chats.set'] const { chats, contacts, messages, isLatest } = events['messaging-history.set']
console.log(`recv ${chats.length} chats (is latest: ${isLatest})`) console.log(`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest})`)
}
// message history received
if(events['messages.set']) {
const { messages, isLatest } = events['messages.set']
console.log(`recv ${messages.length} messages (is latest: ${isLatest})`)
}
if(events['contacts.set']) {
const { contacts, isLatest } = events['contacts.set']
console.log(`recv ${contacts.length} contacts (is latest: ${isLatest})`)
} }
// received a new message // received a new message

View File

@@ -91,10 +91,40 @@ You can configure the connection by passing a `SocketConfig` object.
The entire `SocketConfig` structure is mentioned here with default values: The entire `SocketConfig` structure is mentioned here with default values:
``` ts ``` ts
type SocketConfig = { type SocketConfig = {
/** the WS url to connect to WA */
waWebSocketUrl: string | URL
/** Fails the connection if the socket times out in this interval */
connectTimeoutMs: number
/** Default timeout for queries, undefined for no timeout */
defaultQueryTimeoutMs: number | undefined
/** ping-pong interval for WS connection */
keepAliveIntervalMs: number
/** proxy agent */
agent?: Agent
/** pino logger */
logger: Logger
/** version to connect with */
version: WAVersion
/** override browser config */
browser: WABrowserDescription
/** agent used for fetch requests -- uploading/downloading media */
fetchAgent?: Agent
/** should the QR be printed in the terminal */
printQRInTerminal: boolean
/** should events be emitted for actions done by this socket connection */
emitOwnEvents: boolean
/** provide a cache to store media, so does not have to be re-uploaded */
mediaCache?: NodeCache
/** custom upload hosts to upload media to */
customUploadHosts: MediaConnInfo['hosts']
/** time to wait between sending new retry requests */
retryRequestDelayMs: number
/** time to wait for the generation of the next QR in ms */
qrTimeout?: number;
/** provide an auth state object to maintain the auth state */ /** provide an auth state object to maintain the auth state */
auth: AuthenticationState auth: AuthenticationState
/** By default true, should history messages be downloaded and processed */ /** manage history processing with this control; by default will sync up everything */
downloadHistory: boolean shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => boolean
/** transaction capability options for SignalKeyStore */ /** transaction capability options for SignalKeyStore */
transactionOpts: TransactionCapabilityOptions transactionOpts: TransactionCapabilityOptions
/** provide a cache to store a user's device list */ /** provide a cache to store a user's device list */
@@ -107,13 +137,18 @@ type SocketConfig = {
msgRetryCounterMap?: MessageRetryMap msgRetryCounterMap?: MessageRetryMap
/** width for link preview images */ /** width for link preview images */
linkPreviewImageThumbnailWidth: number linkPreviewImageThumbnailWidth: number
/** Should Baileys ask the phone for full history, will be received async */
syncFullHistory: boolean
/** Should baileys fire init queries automatically, default true */
fireInitQueries: boolean
/** /**
* generate a high quality link preview, * generate a high quality link preview,
* entails uploading the jpegThumbnail to WA * entails uploading the jpegThumbnail to WA
* */ * */
generateHighQualityLinkPreview: boolean generateHighQualityLinkPreview: boolean
/** Should Baileys ask the phone for full history, will be received async */
syncFullHistory: boolean /** options for axios */
options: AxiosRequestConfig<any>
/** /**
* fetch a message from your store * fetch a message from your store
* implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried * implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried
@@ -184,21 +219,22 @@ type ConnectionState = {
Baileys uses the EventEmitter syntax for events. Baileys uses the EventEmitter syntax for events.
They're all nicely typed up, so you shouldn't have any issues with an Intellisense editor like VS Code. They're all nicely typed up, so you shouldn't have any issues with an Intellisense editor like VS Code.
The events are typed up in a type map, as mentioned here: The events are typed as mentioned here:
``` ts ``` ts
export type BaileysEventMap<T> = { export type BaileysEventMap = {
/** connection state has been updated -- WS closed, opened, connecting etc. */ /** connection state has been updated -- WS closed, opened, connecting etc. */
'connection.update': Partial<ConnectionState> 'connection.update': Partial<ConnectionState>
/** credentials updated -- some metadata, keys or something */ /** credentials updated -- some metadata, keys or something */
'creds.update': Partial<T> 'creds.update': Partial<AuthenticationCreds>
/** set chats (history sync), chats are reverse chronologically sorted */ /** history sync, everything is reverse chronologically sorted */
'chats.set': { chats: Chat[], isLatest: boolean } 'messaging-history.set': {
/** set messages (history sync), messages are reverse chronologically sorted */ chats: Chat[]
'messages.set': { messages: WAMessage[], isLatest: boolean } contacts: Contact[]
/** set contacts (history sync) */ messages: WAMessage[]
'contacts.set': { contacts: Contact[], isLatest: boolean } isLatest: boolean
}
/** upsert chats */ /** upsert chats */
'chats.upsert': Chat[] 'chats.upsert': Chat[]
/** update the given chats */ /** update the given chats */

View File

@@ -1,3 +1,4 @@
import { proto } from '../../WAProto'
import type { MediaType, SocketConfig } from '../Types' import type { MediaType, SocketConfig } from '../Types'
import { Browsers } from '../Utils' import { Browsers } from '../Utils'
import logger from '../Utils/logger' import logger from '../Utils/logger'
@@ -26,6 +27,13 @@ export const WA_CERT_DETAILS = {
SERIAL: 0, SERIAL: 0,
} }
export const PROCESSABLE_HISTORY_TYPES = [
proto.Message.HistorySyncNotification.HistorySyncType.INITIAL_BOOTSTRAP,
proto.Message.HistorySyncNotification.HistorySyncType.PUSH_NAME,
proto.Message.HistorySyncNotification.HistorySyncType.RECENT,
proto.Message.HistorySyncNotification.HistorySyncType.FULL
]
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
version: version as any, version: version as any,
browser: Browsers.baileys('Chrome'), browser: Browsers.baileys('Chrome'),
@@ -40,9 +48,9 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
retryRequestDelayMs: 250, retryRequestDelayMs: 250,
fireInitQueries: true, fireInitQueries: true,
auth: undefined as any, auth: undefined as any,
downloadHistory: true,
markOnlineOnConnect: true, markOnlineOnConnect: true,
syncFullHistory: false, syncFullHistory: false,
shouldSyncHistoryMessage: () => true,
linkPreviewImageThumbnailWidth: 192, linkPreviewImageThumbnailWidth: 192,
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 }, transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
generateHighQualityLinkPreview: false, generateHighQualityLinkPreview: false,

View File

@@ -1,7 +1,8 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { ALL_WA_PATCH_NAMES, ChatModification, ChatMutation, InitialReceivedChatsState, LTHashState, MessageUpsertType, PresenceData, SocketConfig, WABusinessHoursConfig, WABusinessProfile, WAMediaUpload, WAMessage, WAPatchCreate, WAPatchName, WAPresence } from '../Types' import { PROCESSABLE_HISTORY_TYPES } from '../Defaults'
import { chatModificationToAppPatch, debouncedTimeout, decodePatches, decodeSyncdSnapshot, encodeSyncdPatch, extractSyncdPatches, generateProfilePicture, isHistoryMsg, newLTHashState, processSyncAction } from '../Utils' import { ALL_WA_PATCH_NAMES, ChatModification, ChatMutation, LTHashState, MessageUpsertType, PresenceData, SocketConfig, WABusinessHoursConfig, WABusinessProfile, WAMediaUpload, WAMessage, WAPatchCreate, WAPatchName, WAPresence } from '../Types'
import { chatModificationToAppPatch, decodePatches, decodeSyncdSnapshot, encodeSyncdPatch, extractSyncdPatches, generateProfilePicture, getHistoryMsg, newLTHashState, processSyncAction } from '../Utils'
import { makeMutex } from '../Utils/make-mutex' import { makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message' import processMessage from '../Utils/process-message'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary' import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, jidNormalizedUser, reduceBinaryNodeToDictionary, S_WHATSAPP_NET } from '../WABinary'
@@ -9,10 +10,8 @@ import { makeSocket } from './socket'
const MAX_SYNC_ATTEMPTS = 5 const MAX_SYNC_ATTEMPTS = 5
const APP_STATE_SYNC_TIMEOUT_MS = 10_000
export const makeChatsSocket = (config: SocketConfig) => { export const makeChatsSocket = (config: SocketConfig) => {
const { logger, markOnlineOnConnect, downloadHistory, fireInitQueries } = config const { logger, markOnlineOnConnect, shouldSyncHistoryMessage, fireInitQueries } = config
const sock = makeSocket(config) const sock = makeSocket(config)
const { const {
ev, ev,
@@ -26,40 +25,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
} = sock } = sock
let privacySettings: { [_: string]: string } | undefined let privacySettings: { [_: string]: string } | undefined
/** this mutex ensures that the notifications (receipts, messages etc.) are processed in order */ /** this mutex ensures that the notifications (receipts, messages etc.) are processed in order */
const processingMutex = makeMutex() const processingMutex = makeMutex()
/** cache to ensure new history sync events do not have duplicate items */
const historyCache = new Set<string>()
let recvChats: InitialReceivedChatsState = { }
const appStateSyncTimeout = debouncedTimeout(
APP_STATE_SYNC_TIMEOUT_MS,
async() => {
if(!authState.creds.myAppStateKeyId) {
logger.warn('myAppStateKeyId not synced, bad link')
await logout('Incomplete app state key sync')
return
}
if(ws.readyState === ws.OPEN) {
logger.info(
{ recvChats: Object.keys(recvChats).length },
'doing initial app state sync'
)
await resyncMainAppState(recvChats)
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
ev.emit('creds.update', { accountSyncCounter })
} else {
logger.warn('connection closed before app state sync')
}
historyCache.clear()
recvChats = { }
}
)
/** helper function to fetch the given app state sync key */ /** helper function to fetch the given app state sync key */
const getAppStateSyncKey = async(keyId: string) => { const getAppStateSyncKey = async(keyId: string) => {
@@ -310,22 +277,22 @@ export const makeChatsSocket = (config: SocketConfig) => {
}) })
} }
const newAppStateChunkHandler = (recvChats: InitialReceivedChatsState | undefined) => { const newAppStateChunkHandler = (isInitialSync: boolean) => {
return { return {
onMutation(mutation: ChatMutation) { onMutation(mutation: ChatMutation) {
processSyncAction( processSyncAction(
mutation, mutation,
ev, ev,
authState.creds.me!, authState.creds.me!,
recvChats ? { recvChats, accountSettings: authState.creds.accountSettings } : undefined, isInitialSync ? { accountSettings: authState.creds.accountSettings } : undefined,
logger logger
) )
} }
} }
} }
const resyncAppState = ev.createBufferedFunction(async(collections: readonly WAPatchName[], recvChats: InitialReceivedChatsState | undefined) => { const resyncAppState = ev.createBufferedFunction(async(collections: readonly WAPatchName[], isInitialSync: boolean) => {
const { onMutation } = newAppStateChunkHandler(recvChats) const { onMutation } = newAppStateChunkHandler(isInitialSync)
// we use this to determine which events to fire // we use this to determine which events to fire
// otherwise when we resync from scratch -- all notifications will fire // otherwise when we resync from scratch -- all notifications will fire
const initialVersionMap: { [T in WAPatchName]?: number } = { } const initialVersionMap: { [T in WAPatchName]?: number } = { }
@@ -543,19 +510,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
} }
const resyncMainAppState = async(ctx?: InitialReceivedChatsState) => {
logger.debug('resyncing main app state')
await (
processingMutex.mutex(
() => resyncAppState(ALL_WA_PATCH_NAMES, ctx)
)
.catch(err => (
onUnexpectedError(err, 'main app sync')
))
)
}
const appPatch = async(patchCreate: WAPatchCreate) => { const appPatch = async(patchCreate: WAPatchCreate) => {
const name = patchCreate.type const name = patchCreate.type
const myAppStateKeyId = authState.creds.myAppStateKeyId const myAppStateKeyId = authState.creds.myAppStateKeyId
@@ -572,7 +526,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
async() => { async() => {
logger.debug({ patch: patchCreate }, 'applying app patch') logger.debug({ patch: patchCreate }, 'applying app patch')
await resyncAppState([name], undefined) await resyncAppState([name], false)
const { [name]: currentSyncVersion } = await authState.keys.get('app-state-sync-version', [name]) const { [name]: currentSyncVersion } = await authState.keys.get('app-state-sync-version', [name])
initial = currentSyncVersion || newLTHashState() initial = currentSyncVersion || newLTHashState()
@@ -625,7 +579,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
) )
if(config.emitOwnEvents) { if(config.emitOwnEvents) {
const { onMutation } = newAppStateChunkHandler(undefined) const { onMutation } = newAppStateChunkHandler(false)
await decodePatches( await decodePatches(
name, name,
[{ ...encodeResult!.patch, version: { version: encodeResult!.state.version }, }], [{ ...encodeResult!.patch, version: { version: encodeResult!.state.version }, }],
@@ -726,33 +680,49 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
// update our pushname too // update our pushname too
if(msg.key.fromMe && 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! } }) ev.emit('creds.update', { me: { ...authState.creds.me!, name: msg.pushName! } })
} }
} }
// process message and emit events const historyMsg = getHistoryMsg(msg.message!)
await processMessage( const shouldProcessHistoryMsg = historyMsg
msg, ? (
{ shouldSyncHistoryMessage(historyMsg)
downloadHistory, && PROCESSABLE_HISTORY_TYPES.includes(historyMsg.syncType!)
ev, )
historyCache, : false
recvChats, // we should have app state keys before we process any history
creds: authState.creds, if(shouldProcessHistoryMsg) {
keyStore: authState.keys, if(!authState.creds.myAppStateKeyId) {
logger, logger.warn('myAppStateKeyId not synced, bad link')
options: config.options, await logout('Incomplete app state key sync')
return
} }
)
const isAnyHistoryMsg = isHistoryMsg(msg.message!)
if(isAnyHistoryMsg) {
// we only want to sync app state once we've all the history
// restart the app state sync timeout
logger.debug('restarting app sync timeout')
appStateSyncTimeout.start()
} }
await Promise.all([
(async() => {
if(shouldProcessHistoryMsg && !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 })
}
})(),
processMessage(
msg,
{
shouldProcessHistoryMsg,
ev,
creds: authState.creds,
keyStore: authState.keys,
logger,
options: config.options,
}
)
])
}) })
ws.on('CB:presence', handlePresenceUpdate) ws.on('CB:presence', handlePresenceUpdate)
@@ -814,7 +784,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
updateBlockStatus, updateBlockStatus,
getBusinessProfile, getBusinessProfile,
resyncAppState, resyncAppState,
chatModify, chatModify
resyncMainAppState,
} }
} }

View File

@@ -2,7 +2,7 @@
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults' import { KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT } from '../Defaults'
import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, SocketConfig, WACallEvent, WAMessageKey, WAMessageStubType, WAPatchName } from '../Types' import { MessageReceiptType, MessageRelayOptions, MessageUserReceipt, SocketConfig, WACallEvent, WAMessageKey, WAMessageStubType, WAPatchName } from '../Types'
import { decodeMediaRetryNode, decodeMessageStanza, delay, encodeBigEndian, encodeSignedDeviceIdentity, getCallStatusFromNode, getNextPreKeys, getStatusFromReceiptType, isHistoryMsg, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils' import { decodeMediaRetryNode, decodeMessageStanza, delay, encodeBigEndian, encodeSignedDeviceIdentity, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils'
import { makeMutex } from '../Utils/make-mutex' import { makeMutex } from '../Utils/make-mutex'
import { cleanMessage } from '../Utils/process-message' import { cleanMessage } from '../Utils/process-message'
import { areJidsSameUser, BinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, isJidUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' import { areJidsSameUser, BinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, isJidUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary'
@@ -286,7 +286,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const update = getBinaryNodeChild(node, 'collection') const update = getBinaryNodeChild(node, 'collection')
if(update) { if(update) {
const name = update.attrs.name as WAPatchName const name = update.attrs.name as WAPatchName
await resyncAppState([name], undefined) await resyncAppState([name], false)
} }
break break
@@ -529,7 +529,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// send ack for history message // send ack for history message
const isAnyHistoryMsg = isHistoryMsg(msg.message!) const isAnyHistoryMsg = getHistoryMsg(msg.message!)
if(isAnyHistoryMsg) { if(isAnyHistoryMsg) {
const jid = jidNormalizedUser(msg.key.remoteJid!) const jid = jidNormalizedUser(msg.key.remoteJid!)
await sendReceipt(jid, undefined, [msg.key.id!], 'hist_sync') await sendReceipt(jid, undefined, [msg.key.id!], 'hist_sync')
@@ -618,6 +618,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.info(`handled ${offlineNotifs} offline messages/notifications`) logger.info(`handled ${offlineNotifs} offline messages/notifications`)
await ev.flush() await ev.flush()
ev.emit('connection.update', { receivedPendingNotifications: true }) ev.emit('connection.update', { receivedPendingNotifications: true })
}) })

View File

@@ -535,7 +535,7 @@ export const makeSocket = ({
const name = update.me?.name const name = update.me?.name
// if name has just been received // if name has just been received
if(creds.me?.name !== name) { if(creds.me?.name !== name) {
logger.info({ name }, 'updated pushName') logger.debug({ name }, 'updated pushName')
sendNode({ sendNode({
tag: 'presence', tag: 'presence',
attrs: { name: name! } attrs: { name: name! }

View File

@@ -70,28 +70,30 @@ export default (
ev.on('connection.update', update => { ev.on('connection.update', update => {
Object.assign(state, update) Object.assign(state, update)
}) })
ev.on('chats.set', ({ chats: newChats, isLatest }) => {
ev.on('messaging-history.set', ({
chats: newChats,
contacts: newContacts,
messages: newMessages,
isLatest
}) => {
if(isLatest) { if(isLatest) {
chats.clear() chats.clear()
for(const id in messages) {
delete messages[id]
}
} }
const chatsAdded = chats.insertIfAbsent(...newChats).length const chatsAdded = chats.insertIfAbsent(...newChats).length
logger.debug({ chatsAdded }, 'synced chats') logger.debug({ chatsAdded }, 'synced chats')
})
ev.on('contacts.set', ({ contacts: newContacts }) => {
const oldContacts = contactsUpsert(newContacts) const oldContacts = contactsUpsert(newContacts)
for(const jid of oldContacts) { for(const jid of oldContacts) {
delete contacts[jid] delete contacts[jid]
} }
logger.debug({ deletedContacts: oldContacts.size, newContacts }, 'synced contacts') logger.debug({ deletedContacts: oldContacts.size, newContacts }, 'synced contacts')
})
ev.on('messages.set', ({ messages: newMessages, isLatest }) => {
if(isLatest) {
for(const id in messages) {
delete messages[id]
}
}
for(const msg of newMessages) { for(const msg of newMessages) {
const jid = msg.key.remoteJid! const jid = msg.key.remoteJid!
@@ -101,6 +103,7 @@ export default (
logger.debug({ messages: newMessages.length }, 'synced messages') logger.debug({ messages: newMessages.length }, 'synced messages')
}) })
ev.on('contacts.update', updates => { ev.on('contacts.update', updates => {
for(const update of updates) { for(const update of updates) {
if(contacts[update.id!]) { if(contacts[update.id!]) {

View File

@@ -8,7 +8,9 @@ describe('Event Buffer Tests', () => {
let ev: ReturnType<typeof makeEventBuffer> let ev: ReturnType<typeof makeEventBuffer>
beforeEach(() => { beforeEach(() => {
ev = makeEventBuffer(logger) const _logger = logger.child({ })
_logger.level = 'trace'
ev = makeEventBuffer(_logger)
}) })
it('should buffer a chat upsert & update event', async() => { it('should buffer a chat upsert & update event', async() => {
@@ -71,6 +73,123 @@ describe('Event Buffer Tests', () => {
expect(chatsDeleted).toHaveLength(1) expect(chatsDeleted).toHaveLength(1)
}) })
it('should release a conditional update at the right time', async() => {
const chatId = randomJid()
const chatId2 = randomJid()
const chatsUpserted: Chat[] = []
const chatsSynced: Chat[] = []
ev.on('chats.upsert', c => chatsUpserted.push(...c))
ev.on('messaging-history.set', c => chatsSynced.push(...c.chats))
ev.on('chats.update', () => fail('not should have emitted'))
ev.buffer()
ev.emit('chats.update', [{
id: chatId,
archived: true,
conditional(buff) {
if(buff.chatUpserts[chatId]) {
return true
}
}
}])
ev.emit('chats.update', [{
id: chatId2,
archived: true,
conditional(buff) {
if(buff.historySets.chats[chatId2]) {
return true
}
}
}])
await ev.flush()
ev.buffer()
ev.emit('chats.upsert', [{
id: chatId,
conversationTimestamp: 123,
unreadCount: 1,
muteEndTime: 123
}])
ev.emit('messaging-history.set', {
chats: [{
id: chatId2,
conversationTimestamp: 123,
unreadCount: 1,
muteEndTime: 123
}],
contacts: [],
messages: [],
isLatest: false
})
await ev.flush()
expect(chatsUpserted).toHaveLength(1)
expect(chatsUpserted[0].id).toEqual(chatId)
expect(chatsUpserted[0].archived).toEqual(true)
expect(chatsUpserted[0].muteEndTime).toEqual(123)
expect(chatsSynced).toHaveLength(1)
expect(chatsSynced[0].id).toEqual(chatId2)
expect(chatsSynced[0].archived).toEqual(true)
})
it('should discard a conditional update', async() => {
const chatId = randomJid()
const chatsUpserted: Chat[] = []
ev.on('chats.upsert', c => chatsUpserted.push(...c))
ev.on('chats.update', () => fail('not should have emitted'))
ev.buffer()
ev.emit('chats.update', [{
id: chatId,
archived: true,
conditional(buff) {
if(buff.chatUpserts[chatId]) {
return false
}
}
}])
ev.emit('chats.upsert', [{
id: chatId,
conversationTimestamp: 123,
unreadCount: 1,
muteEndTime: 123
}])
await ev.flush()
expect(chatsUpserted).toHaveLength(1)
expect(chatsUpserted[0].archived).toBeUndefined()
})
it('should overwrite a chats.update event with a history event', async() => {
const chatId = randomJid()
let chatRecv: Chat | undefined
ev.on('messaging-history.set', ({ chats }) => {
chatRecv = chats[0]
})
ev.on('chats.update', () => fail('not should have emitted'))
ev.buffer()
ev.emit('messaging-history.set', {
chats: [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }],
messages: [],
contacts: [],
isLatest: true
})
ev.emit('chats.update', [{ id: chatId, archived: true }])
await ev.flush()
expect(chatRecv).toBeDefined()
expect(chatRecv?.archived).toBeTruthy()
})
it('should buffer message upsert events', async() => { it('should buffer message upsert events', async() => {
const messageTimestamp = unixTimestampSeconds() const messageTimestamp = unixTimestampSeconds()
const msg: proto.IWebMessageInfo = { const msg: proto.IWebMessageInfo = {

View File

@@ -1,5 +1,6 @@
import type { proto } from '../../WAProto' import type { proto } from '../../WAProto'
import type { AccountSettings } from './Auth' import type { AccountSettings } from './Auth'
import type { BufferedEventData } from './Events'
import type { MinimalMessage } from './Message' import type { MinimalMessage } from './Message'
/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */
@@ -33,7 +34,23 @@ export type WAPatchCreate = {
operation: proto.SyncdMutation.SyncdOperation operation: proto.SyncdMutation.SyncdOperation
} }
export type Chat = proto.IConversation export type Chat = proto.IConversation & {
/** unix timestamp of when the last message was received in the chat */
lastMessageRecvTimestamp?: number
}
export type ChatUpdate = Partial<Chat & {
/**
* if specified in the update,
* the EV buffer will check if the condition gets fulfilled before applying the update
* Right now, used to determine when to release an app state sync event
*
* @returns true, if the update should be applied;
* false if it can be discarded;
* undefined if the condition is not yet fulfilled
* */
conditional: (bufferedData: BufferedEventData) => boolean | undefined
}>
/** /**
* the last messages in a chat, sorted reverse-chronologically. That is, the latest message should be first in the chat * the last messages in a chat, sorted reverse-chronologically. That is, the latest message should be first in the chat
@@ -77,6 +94,5 @@ export type InitialReceivedChatsState = {
} }
export type InitialAppStateSyncOptions = { export type InitialAppStateSyncOptions = {
recvChats: InitialReceivedChatsState
accountSettings: AccountSettings accountSettings: AccountSettings
} }

View File

@@ -2,7 +2,7 @@ import type { Boom } from '@hapi/boom'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { AuthenticationCreds } from './Auth' import { AuthenticationCreds } from './Auth'
import { WACallEvent } from './Call' import { WACallEvent } from './Call'
import { Chat, PresenceData } from './Chat' import { Chat, ChatUpdate, PresenceData } from './Chat'
import { Contact } from './Contact' import { Contact } from './Contact'
import { GroupMetadata, ParticipantAction } from './GroupMetadata' import { GroupMetadata, ParticipantAction } from './GroupMetadata'
import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message' import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message'
@@ -13,16 +13,17 @@ export type BaileysEventMap = {
'connection.update': Partial<ConnectionState> 'connection.update': Partial<ConnectionState>
/** credentials updated -- some metadata, keys or something */ /** credentials updated -- some metadata, keys or something */
'creds.update': Partial<AuthenticationCreds> 'creds.update': Partial<AuthenticationCreds>
/** set chats (history sync), chats are reverse chronologically sorted */ /** set chats (history sync), everything is reverse chronologically sorted */
'chats.set': { chats: Chat[], isLatest: boolean } 'messaging-history.set': {
/** set messages (history sync), messages are reverse chronologically sorted */ chats: Chat[]
'messages.set': { messages: WAMessage[], isLatest: boolean } contacts: Contact[]
/** set contacts (history sync) */ messages: WAMessage[]
'contacts.set': { contacts: Contact[], isLatest: boolean } isLatest: boolean
}
/** upsert chats */ /** upsert chats */
'chats.upsert': Chat[] 'chats.upsert': Chat[]
/** update the given chats */ /** update the given chats */
'chats.update': Partial<Chat>[] 'chats.update': ChatUpdate[]
/** delete chats with given ID */ /** delete chats with given ID */
'chats.delete': string[] 'chats.delete': string[]
/** presence of contact in a chat updated */ /** presence of contact in a chat updated */
@@ -56,8 +57,15 @@ export type BaileysEventMap = {
} }
export type BufferedEventData = { export type BufferedEventData = {
historySets: {
chats: { [jid: string]: Chat }
contacts: { [jid: string]: Contact }
messages: { [uqId: string]: WAMessage }
empty: boolean
isLatest: boolean
}
chatUpserts: { [jid: string]: Chat } chatUpserts: { [jid: string]: Chat }
chatUpdates: { [jid: string]: Partial<Chat> } chatUpdates: { [jid: string]: ChatUpdate }
chatDeletes: Set<string> chatDeletes: Set<string>
contactUpserts: { [jid: string]: Contact } contactUpserts: { [jid: string]: Contact }
contactUpdates: { [jid: string]: Partial<Contact> } contactUpdates: { [jid: string]: Partial<Contact> }

View File

@@ -46,8 +46,8 @@ export type SocketConfig = {
qrTimeout?: number; qrTimeout?: number;
/** provide an auth state object to maintain the auth state */ /** provide an auth state object to maintain the auth state */
auth: AuthenticationState auth: AuthenticationState
/** By default true, should history messages be downloaded and processed */ /** manage history processing with this control; by default will sync up everything */
downloadHistory: boolean shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => boolean
/** transaction capability options for SignalKeyStore */ /** transaction capability options for SignalKeyStore */
transactionOpts: TransactionCapabilityOptions transactionOpts: TransactionCapabilityOptions
/** provide a cache to store a user's device list */ /** provide a cache to store a user's device list */

View File

@@ -2,7 +2,7 @@ import { Boom } from '@hapi/boom'
import { AxiosRequestConfig } from 'axios' import { AxiosRequestConfig } from 'axios'
import type { Logger } from 'pino' import type { Logger } from 'pino'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { BaileysEventEmitter, ChatModification, ChatMutation, Contact, InitialAppStateSyncOptions, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types' import { BaileysEventEmitter, Chat, ChatModification, ChatMutation, ChatUpdate, Contact, InitialAppStateSyncOptions, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types'
import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary' import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary'
import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto' import { aesDecrypt, aesEncrypt, hkdf, hmacSign } from './crypto'
import { toNumber } from './generics' import { toNumber } from './generics'
@@ -618,7 +618,6 @@ export const processSyncAction = (
logger?: Logger, logger?: Logger,
) => { ) => {
const isInitialSync = !!initialSyncOpts const isInitialSync = !!initialSyncOpts
const recvChats = initialSyncOpts?.recvChats
const accountSettings = initialSyncOpts?.accountSettings const accountSettings = initialSyncOpts?.accountSettings
const { const {
@@ -631,13 +630,14 @@ export const processSyncAction = (
[ [
{ {
id, id,
muteEndTime: action.muteAction?.muted ? muteEndTime: action.muteAction?.muted
toNumber(action.muteAction!.muteEndTimestamp!) : ? toNumber(action.muteAction!.muteEndTimestamp!)
null : null,
conditional: getChatUpdateConditional(id, undefined)
} }
] ]
) )
} else if(action?.archiveChatAction) { } else if(action?.archiveChatAction || type === 'archive' || type === 'unarchive') {
// okay so we've to do some annoying computation here // okay so we've to do some annoying computation here
// when we're initially syncing the app state // when we're initially syncing the app state
// there are a few cases we need to handle // there are a few cases we need to handle
@@ -648,35 +648,36 @@ export const processSyncAction = (
// we compare the timestamp of latest message from the other person to determine this // we compare the timestamp of latest message from the other person to determine this
// 2. if the account unarchiveChats setting is false -- then it doesn't matter, // 2. if the account unarchiveChats setting is false -- then it doesn't matter,
// it'll always take an app state action to mark in unarchived -- which we'll get anyway // it'll always take an app state action to mark in unarchived -- which we'll get anyway
const archiveAction = action.archiveChatAction const archiveAction = action?.archiveChatAction
if( const isArchived = archiveAction
isValidPatchBasedOnMessageRange(id, archiveAction.messageRange) ? archiveAction.archived
|| !isInitialSync : type === 'archive'
|| !accountSettings?.unarchiveChats // // basically we don't need to fire an "archive" update if the chat is being marked unarchvied
) { // // this only applies for the initial sync
// basically we don't need to fire an "archive" update if the chat is being marked unarchvied // if(isInitialSync && !isArchived) {
// this only applies for the initial sync // isArchived = false
if(isInitialSync && !archiveAction.archived) { // }
ev.emit('chats.update', [{ id, archived: false }])
} else { const msgRange = !accountSettings?.unarchiveChats ? undefined : archiveAction?.messageRange
ev.emit('chats.update', [{ id, archived: !!archiveAction?.archived }]) // logger?.debug({ chat: id, syncAction }, 'message range archive')
}
} ev.emit('chats.update', [{
id,
archived: isArchived,
conditional: getChatUpdateConditional(id, msgRange)
}])
} else if(action?.markChatAsReadAction) { } else if(action?.markChatAsReadAction) {
const markReadAction = action.markChatAsReadAction const markReadAction = action.markChatAsReadAction
if( // basically we don't need to fire an "read" update if the chat is being marked as read
isValidPatchBasedOnMessageRange(id, markReadAction.messageRange) // because the chat is read by default
|| !isInitialSync // this only applies for the initial sync
) { const isNullUpdate = isInitialSync && markReadAction.read
// 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 ev.emit('chats.update', [{
// this only applies for the initial sync id,
if(isInitialSync && markReadAction.read) { unreadCount: isNullUpdate ? null : !!markReadAction?.read ? 0 : -1,
ev.emit('chats.update', [{ id, unreadCount: null }]) conditional: getChatUpdateConditional(id, markReadAction?.messageRange)
} else { }])
ev.emit('chats.update', [{ id, unreadCount: !!markReadAction?.read ? 0 : -1 }])
}
}
} else if(action?.deleteMessageForMeAction || type === 'deleteMessageForMe') { } else if(action?.deleteMessageForMeAction || type === 'deleteMessageForMe') {
ev.emit('messages.delete', { keys: [ ev.emit('messages.delete', { keys: [
{ {
@@ -688,11 +689,16 @@ export const processSyncAction = (
} else if(action?.contactAction) { } else if(action?.contactAction) {
ev.emit('contacts.upsert', [{ id, name: action.contactAction!.fullName! }]) ev.emit('contacts.upsert', [{ id, name: action.contactAction!.fullName! }])
} else if(action?.pushNameSetting) { } else if(action?.pushNameSetting) {
if(me?.name !== action?.pushNameSetting) { const name = action?.pushNameSetting?.name
ev.emit('creds.update', { me: { ...me, name: action?.pushNameSetting?.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 }]) 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 const unarchiveChats = !!action.unarchiveChatsSetting.unarchiveChats
ev.emit('creds.update', { accountSettings: { unarchiveChats } }) ev.emit('creds.update', { accountSettings: { unarchiveChats } })
@@ -714,23 +720,27 @@ export const processSyncAction = (
} }
]) ])
} else if(action?.deleteChatAction || type === 'deleteChat') { } else if(action?.deleteChatAction || type === 'deleteChat') {
if( if(!isInitialSync) {
(
action?.deleteChatAction?.messageRange
&& isValidPatchBasedOnMessageRange(id, action?.deleteChatAction?.messageRange)
)
|| !isInitialSync
) {
ev.emit('chats.delete', [id]) ev.emit('chats.delete', [id])
} }
} else { } else {
logger?.debug({ syncAction, id }, 'unprocessable update') logger?.debug({ syncAction, id }, 'unprocessable update')
} }
function isValidPatchBasedOnMessageRange(id: string, msgRange: proto.SyncActionValue.ISyncActionMessageRange | null | undefined) { function getChatUpdateConditional(id: string, msgRange: proto.SyncActionValue.ISyncActionMessageRange | null | undefined): ChatUpdate['conditional'] {
const chat = recvChats?.[id] return isInitialSync
? (data) => {
const chat = data.historySets.chats[id] || data.chatUpserts[id]
if(chat) {
return msgRange ? isValidPatchBasedOnMessageRange(chat, msgRange) : true
}
}
: undefined
}
function isValidPatchBasedOnMessageRange(chat: Chat, msgRange: proto.SyncActionValue.ISyncActionMessageRange | null | undefined) {
const lastMsgTimestamp = msgRange?.lastMessageTimestamp || msgRange?.lastSystemMessageTimestamp || 0 const lastMsgTimestamp = msgRange?.lastMessageTimestamp || msgRange?.lastSystemMessageTimestamp || 0
const chatLastMsgTimestamp = chat?.lastMsgRecvTimestamp || 0 const chatLastMsgTimestamp = chat?.lastMessageRecvTimestamp || 0
return lastMsgTimestamp >= chatLastMsgTimestamp return lastMsgTimestamp >= chatLastMsgTimestamp
} }
} }

View File

@@ -1,11 +1,13 @@
import EventEmitter from 'events' import EventEmitter from 'events'
import { Logger } from 'pino' import { Logger } from 'pino'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { BaileysEvent, BaileysEventEmitter, BaileysEventMap, BufferedEventData, Chat, Contact, WAMessage, WAMessageStatus } from '../Types' import { BaileysEvent, BaileysEventEmitter, BaileysEventMap, BufferedEventData, Chat, ChatUpdate, Contact, WAMessage, WAMessageStatus } from '../Types'
import { trimUndefineds } from './generics'
import { updateMessageWithReaction, updateMessageWithReceipt } from './messages' import { updateMessageWithReaction, updateMessageWithReceipt } from './messages'
import { isRealMessage, shouldIncrementChatUnread } from './process-message' import { isRealMessage, shouldIncrementChatUnread } from './process-message'
const BUFFERABLE_EVENT = [ const BUFFERABLE_EVENT = [
'messaging-history.set',
'chats.upsert', 'chats.upsert',
'chats.update', 'chats.update',
'chats.delete', 'chats.delete',
@@ -55,10 +57,10 @@ type BaileysBufferableEventEmitter = BaileysEventEmitter & {
*/ */
export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter => { export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter => {
const ev = new EventEmitter() const ev = new EventEmitter()
const historyCache = new Set<string>()
let data = makeBufferData() let data = makeBufferData()
let isBuffering = false let isBuffering = false
let preBufferTask: Promise<any> = Promise.resolve() let preBufferTask: Promise<any> = Promise.resolve()
// take the generic event and fire it as a baileys event // take the generic event and fire it as a baileys event
@@ -88,14 +90,29 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter =
isBuffering = false isBuffering = false
const newData = makeBufferData()
const chatUpdates = Object.values(data.chatUpdates)
// gather the remaining conditional events so we re-queue them
let conditionalChatUpdatesLeft = 0
for(const update of chatUpdates) {
if(update.conditional) {
conditionalChatUpdatesLeft += 1
newData.chatUpdates[update.id!] = update
delete data.chatUpdates[update.id!]
}
}
const consolidatedData = consolidateEvents(data) const consolidatedData = consolidateEvents(data)
if(Object.keys(consolidatedData).length) { if(Object.keys(consolidatedData).length) {
ev.emit('event', consolidatedData) ev.emit('event', consolidatedData)
} }
data = makeBufferData() data = newData
logger.trace('released buffered events') logger.trace(
{ conditionalChatUpdatesLeft },
'released buffered events'
)
} }
return { return {
@@ -111,7 +128,7 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter =
}, },
emit<T extends BaileysEvent>(event: BaileysEvent, evData: BaileysEventMap[T]) { emit<T extends BaileysEvent>(event: BaileysEvent, evData: BaileysEventMap[T]) {
if(isBuffering && BUFFERABLE_EVENT_SET.has(event)) { if(isBuffering && BUFFERABLE_EVENT_SET.has(event)) {
append(data, event as any, evData, logger) append(data, historyCache, event as any, evData, logger)
return true return true
} }
@@ -145,6 +162,13 @@ export const makeEventBuffer = (logger: Logger): BaileysBufferableEventEmitter =
const makeBufferData = (): BufferedEventData => { const makeBufferData = (): BufferedEventData => {
return { return {
historySets: {
chats: { },
messages: { },
contacts: { },
isLatest: false,
empty: true
},
chatUpserts: { }, chatUpserts: { },
chatUpdates: { }, chatUpdates: { },
chatDeletes: new Set(), chatDeletes: new Set(),
@@ -161,41 +185,100 @@ const makeBufferData = (): BufferedEventData => {
function append<E extends BufferableEvent>( function append<E extends BufferableEvent>(
data: BufferedEventData, data: BufferedEventData,
historyCache: Set<string>,
event: E, event: E,
eventData: any, eventData: any,
logger: Logger logger: Logger
) { ) {
switch (event) { switch (event) {
case 'messaging-history.set':
for(const chat of eventData.chats as Chat[]) {
const existingChat = data.historySets.chats[chat.id]
if(existingChat) {
existingChat.endOfHistoryTransferType = chat.endOfHistoryTransferType
}
if(!existingChat && !historyCache.has(chat.id)) {
data.historySets.chats[chat.id] = chat
historyCache.add(chat.id)
absorbingChatUpdate(chat)
}
}
for(const contact of eventData.contacts as Contact[]) {
const existingContact = data.historySets.contacts[contact.id]
if(existingContact) {
Object.assign(existingContact, trimUndefineds(contact))
} else {
const historyContactId = `c:${contact.id}`
const hasAnyName = contact.notify || contact.name || contact.verifiedName
if(!historyCache.has(historyContactId) || hasAnyName) {
data.historySets.contacts[contact.id] = contact
historyCache.add(historyContactId)
}
}
}
for(const message of eventData.messages as WAMessage[]) {
const key = stringifyMessageKey(message.key)
const existingMsg = data.historySets.messages[key]
if(!existingMsg && !historyCache.has(key)) {
data.historySets.messages[key] = message
historyCache.add(key)
}
}
data.historySets.empty = false
data.historySets.isLatest = eventData.isLatest || data.historySets.isLatest
break
case 'chats.upsert': case 'chats.upsert':
for(const chat of eventData as Chat[]) { for(const chat of eventData as Chat[]) {
let upsert = data.chatUpserts[chat.id] || { } as Chat let upsert = data.chatUpserts[chat.id]
upsert = concatChats(upsert, chat) if(!upsert) {
if(data.chatUpdates[chat.id]) { upsert = data.historySets[chat.id]
logger.debug({ chatId: chat.id }, 'absorbed chat update in chat upsert') if(upsert) {
upsert = concatChats(data.chatUpdates[chat.id] as Chat, upsert) logger.debug({ chatId: chat.id }, 'absorbed chat upsert in chat set')
delete data.chatUpdates[chat.id] }
} }
if(upsert) {
upsert = concatChats(upsert, chat)
} else {
upsert = chat
data.chatUpserts[chat.id] = upsert
}
absorbingChatUpdate(upsert)
if(data.chatDeletes.has(chat.id)) { if(data.chatDeletes.has(chat.id)) {
data.chatDeletes.delete(chat.id) data.chatDeletes.delete(chat.id)
} }
data.chatUpserts[chat.id] = upsert
} }
break break
case 'chats.update': case 'chats.update':
for(const update of eventData as Partial<Chat>[]) { for(const update of eventData as ChatUpdate[]) {
const chatId = update.id! const chatId = update.id!
// if there is an existing upsert, merge the update into it const conditionMatches = update.conditional ? update.conditional(data) : true
const upsert = data.chatUpserts[chatId] if(conditionMatches) {
if(upsert) { delete update.conditional
concatChats(upsert, update)
} else { // if there is an existing upsert, merge the update into it
// merge the update into the existing update const upsert = data.historySets.chats[chatId] || data.chatUpserts[chatId]
const chatUpdate = data.chatUpdates[chatId] || { } if(upsert) {
data.chatUpdates[chatId] = concatChats(chatUpdate, update) concatChats(upsert, update)
} else {
// merge the update into the existing update
const chatUpdate = data.chatUpdates[chatId] || { }
data.chatUpdates[chatId] = concatChats(chatUpdate, update)
}
} else if(conditionMatches === undefined) {
// condition yet to be fulfilled
data.chatUpdates[chatId] = update
} }
// otherwise -- condition not met, update is invalid
// if the chat has been updated // if the chat has been updated
// ignore any existing chat delete // ignore any existing chat delete
@@ -207,7 +290,10 @@ function append<E extends BufferableEvent>(
break break
case 'chats.delete': case 'chats.delete':
for(const chatId of eventData as string[]) { for(const chatId of eventData as string[]) {
data.chatDeletes.add(chatId) if(!data.chatDeletes.has(chatId)) {
data.chatDeletes.add(chatId)
}
// remove any prior updates & upserts // remove any prior updates & upserts
if(data.chatUpdates[chatId]) { if(data.chatUpdates[chatId]) {
delete data.chatUpdates[chatId] delete data.chatUpdates[chatId]
@@ -215,20 +301,36 @@ function append<E extends BufferableEvent>(
if(data.chatUpserts[chatId]) { if(data.chatUpserts[chatId]) {
delete data.chatUpserts[chatId] delete data.chatUpserts[chatId]
}
if(data.historySets.chats[chatId]) {
delete data.historySets.chats[chatId]
} }
} }
break break
case 'contacts.upsert': case 'contacts.upsert':
for(const contact of eventData as Contact[]) { for(const contact of eventData as Contact[]) {
let upsert = data.contactUpserts[contact.id] || { } as Contact let upsert = data.contactUpserts[contact.id]
upsert = Object.assign(upsert, contact) if(!upsert) {
if(data.contactUpdates[contact.id]) { upsert = data.historySets.contacts[contact.id]
upsert = Object.assign(data.contactUpdates[contact.id], upsert) if(upsert) {
delete data.contactUpdates[contact.id] logger.debug({ contactId: contact.id }, 'absorbed contact upsert in contact set')
}
} }
data.contactUpserts[contact.id] = upsert if(upsert) {
upsert = Object.assign(upsert, trimUndefineds(contact))
} else {
upsert = contact
data.contactUpserts[contact.id] = upsert
}
if(data.contactUpdates[contact.id]) {
upsert = Object.assign(data.contactUpdates[contact.id], trimUndefineds(contact))
delete data.contactUpdates[contact.id]
}
} }
break break
@@ -237,7 +339,7 @@ function append<E extends BufferableEvent>(
for(const update of contactUpdates) { for(const update of contactUpdates) {
const id = update.id! const id = update.id!
// merge into prior upsert // merge into prior upsert
const upsert = data.contactUpserts[update.id!] const upsert = data.historySets.contacts[id] || data.contactUpserts[id]
if(upsert) { if(upsert) {
Object.assign(upsert, update) Object.assign(upsert, update)
} else { } else {
@@ -252,9 +354,16 @@ function append<E extends BufferableEvent>(
const { messages, type } = eventData as BaileysEventMap['messages.upsert'] const { messages, type } = eventData as BaileysEventMap['messages.upsert']
for(const message of messages) { for(const message of messages) {
const key = stringifyMessageKey(message.key) const key = stringifyMessageKey(message.key)
const existing = data.messageUpserts[key] let existing = data.messageUpserts[key]?.message
if(!existing) {
existing = data.historySets.messages[key]
if(existing) {
logger.debug({ messageId: key }, 'absorbed message upsert in message set')
}
}
if(existing) { if(existing) {
message.messageTimestamp = existing.message.messageTimestamp message.messageTimestamp = existing.messageTimestamp
} }
if(data.messageUpdates[key]) { if(data.messageUpdates[key]) {
@@ -263,11 +372,15 @@ function append<E extends BufferableEvent>(
delete data.messageUpdates[key] delete data.messageUpdates[key]
} }
data.messageUpserts[key] = { if(data.historySets.messages[key]) {
message, data.historySets.messages[key] = message
type: type === 'notify' || existing?.type === 'notify' } else {
? 'notify' data.messageUpserts[key] = {
: type message,
type: type === 'notify' || data.messageUpserts[key]?.type === 'notify'
? 'notify'
: type
}
} }
} }
@@ -276,14 +389,14 @@ function append<E extends BufferableEvent>(
const msgUpdates = eventData as BaileysEventMap['messages.update'] const msgUpdates = eventData as BaileysEventMap['messages.update']
for(const { key, update } of msgUpdates) { for(const { key, update } of msgUpdates) {
const keyStr = stringifyMessageKey(key) const keyStr = stringifyMessageKey(key)
const existing = data.messageUpserts[keyStr] const existing = data.historySets.messages[keyStr] || data.messageUpserts[keyStr]?.message
if(existing) { if(existing) {
Object.assign(existing.message, update) Object.assign(existing, update)
// if the message was received & read by us // if the message was received & read by us
// the chat counter must have been incremented // the chat counter must have been incremented
// so we need to decrement it // so we need to decrement it
if(update.status === WAMessageStatus.READ && !key.fromMe) { if(update.status === WAMessageStatus.READ && !key.fromMe) {
decrementChatReadCounterIfMsgDidUnread(existing.message) decrementChatReadCounterIfMsgDidUnread(existing)
} }
} else { } else {
const msgUpdate = data.messageUpdates[keyStr] || { key, update: { } } const msgUpdate = data.messageUpdates[keyStr] || { key, update: { } }
@@ -299,7 +412,10 @@ function append<E extends BufferableEvent>(
const { keys } = deleteData const { keys } = deleteData
for(const key of keys) { for(const key of keys) {
const keyStr = stringifyMessageKey(key) const keyStr = stringifyMessageKey(key)
data.messageDeletes[keyStr] = key if(!data.messageDeletes[keyStr]) {
data.messageDeletes[keyStr] = key
}
if(data.messageUpserts[keyStr]) { if(data.messageUpserts[keyStr]) {
delete data.messageUpserts[keyStr] delete data.messageUpserts[keyStr]
@@ -349,7 +465,10 @@ function append<E extends BufferableEvent>(
for(const update of groupUpdates) { for(const update of groupUpdates) {
const id = update.id! const id = update.id!
const groupUpdate = data.groupUpdates[id] || { } const groupUpdate = data.groupUpdates[id] || { }
data.groupUpdates[id] = Object.assign(groupUpdate, update) if(!data.groupUpdates[id]) {
data.groupUpdates[id] = Object.assign(groupUpdate, update)
}
} }
break break
@@ -357,6 +476,23 @@ function append<E extends BufferableEvent>(
throw new Error(`"${event}" cannot be buffered`) throw new Error(`"${event}" cannot be buffered`)
} }
function absorbingChatUpdate(existing: Chat) {
const chatId = existing.id
const update = data.chatUpdates[chatId]
if(update) {
const conditionMatches = update.conditional ? update.conditional(data) : true
if(conditionMatches) {
delete update.conditional
logger.debug({ chatId }, 'absorbed chat update in existing chat')
Object.assign(existing, concatChats(update as Chat, existing))
delete data.chatUpdates[chatId]
} else if(conditionMatches === false) {
logger.debug({ chatId }, 'chat update condition fail, removing')
delete data.chatUpdates[chatId]
}
}
}
function decrementChatReadCounterIfMsgDidUnread(message: WAMessage) { function decrementChatReadCounterIfMsgDidUnread(message: WAMessage) {
// decrement chat unread counter // decrement chat unread counter
// if the message has already been marked read by us // if the message has already been marked read by us
@@ -380,6 +516,15 @@ function append<E extends BufferableEvent>(
function consolidateEvents(data: BufferedEventData) { function consolidateEvents(data: BufferedEventData) {
const map: BaileysEventData = { } const map: BaileysEventData = { }
if(!data.historySets.empty) {
map['messaging-history.set'] = {
chats: Object.values(data.historySets.chats),
messages: Object.values(data.historySets.messages),
contacts: Object.values(data.historySets.contacts),
isLatest: data.historySets.isLatest
}
}
const chatUpsertList = Object.values(data.chatUpserts) const chatUpsertList = Object.values(data.chatUpserts)
if(chatUpsertList.length) { if(chatUpsertList.length) {
map['chats.upsert'] = chatUpsertList map['chats.upsert'] = chatUpsertList
@@ -446,7 +591,7 @@ function consolidateEvents(data: BufferedEventData) {
return map return map
} }
function concatChats<C extends Partial<Chat>>(a: C, b: C) { function concatChats<C extends Partial<Chat>>(a: C, b: Partial<Chat>) {
if(b.unreadCount === null) { if(b.unreadCount === null) {
// neutralize unread counter // neutralize unread counter
if(a.unreadCount! < 0) { if(a.unreadCount! < 0) {

View File

@@ -366,4 +366,14 @@ export const getCodeFromWSError = (error: Error) => {
*/ */
export const isWABusinessPlatform = (platform: string) => { export const isWABusinessPlatform = (platform: string) => {
return platform === 'smbi' || platform === 'smba' return platform === 'smbi' || platform === 'smba'
}
export function trimUndefineds(obj: any) {
for(const key in obj) {
if(typeof obj[key] === 'undefined') {
delete obj[key]
}
}
return obj
} }

View File

@@ -2,7 +2,7 @@ import { AxiosRequestConfig } from 'axios'
import { promisify } from 'util' import { promisify } from 'util'
import { inflate } from 'zlib' import { inflate } from 'zlib'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { Chat, Contact, InitialReceivedChatsState } from '../Types' import { Chat, Contact, WAMessageStubType } from '../Types'
import { isJidUser } from '../WABinary' import { isJidUser } from '../WABinary'
import { toNumber } from './generics' import { toNumber } from './generics'
import { normalizeMessageContent } from './messages' import { normalizeMessageContent } from './messages'
@@ -29,11 +29,7 @@ export const downloadHistory = async(
return syncData return syncData
} }
export const processHistoryMessage = ( export const processHistoryMessage = (item: proto.IHistorySync) => {
item: proto.IHistorySync,
historyCache: Set<string>,
recvChats: InitialReceivedChatsState
) => {
const messages: proto.IWebMessageInfo[] = [] const messages: proto.IWebMessageInfo[] = []
const contacts: Contact[] = [] const contacts: Contact[] = []
const chats: Chat[] = [] const chats: Chat[] = []
@@ -41,91 +37,75 @@ export const processHistoryMessage = (
switch (item.syncType) { switch (item.syncType) {
case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP: case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP:
case proto.HistorySync.HistorySyncType.RECENT: case proto.HistorySync.HistorySyncType.RECENT:
for(const chat of item.conversations!) { case proto.HistorySync.HistorySyncType.FULL:
const contactId = `c:${chat.id}` for(const chat of item.conversations! as Chat[]) {
if(chat.name && !historyCache.has(contactId)) { contacts.push({ id: chat.id, name: chat.name || undefined })
contacts.push({ id: chat.id, name: chat.name })
historyCache.add(contactId)
}
const msgs = chat.messages || [] const msgs = chat.messages || []
delete chat.messages delete chat.messages
delete chat.archived
delete chat.muteEndTime
delete chat.pinned
for(const item of msgs) { for(const item of msgs) {
const message = item.message! const message = item.message!
const uqId = `${message.key.remoteJid}:${message.key.id}` messages.push(message)
if(!historyCache.has(uqId)) {
messages.push(message)
let curItem = recvChats[message.key.remoteJid!] if(!chat.messages) {
const timestamp = toNumber(message.messageTimestamp) // keep only the most recent message in the chat array
if(!curItem || timestamp > curItem.lastMsgTimestamp) { chat.messages = [{ message }]
curItem = { lastMsgTimestamp: timestamp } }
recvChats[chat.id] = curItem
// keep only the most recent message in the chat array
chat.messages = [{ message }]
}
if( if(!message.key.fromMe && !chat.lastMessageRecvTimestamp) {
!message.key.fromMe chat.lastMessageRecvTimestamp = toNumber(message.messageTimestamp)
&& (!curItem?.lastMsgRecvTimestamp || timestamp > curItem.lastMsgRecvTimestamp) }
) {
curItem.lastMsgRecvTimestamp = timestamp
}
historyCache.add(uqId) if(
!message.key.fromMe
&& message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_BSP
&& message.messageStubParameters?.[0]
) {
contacts.push({
id: message.key.participant || message.key.remoteJid!,
verifiedName: message.messageStubParameters?.[0],
})
} }
} }
if(!historyCache.has(chat.id)) { if(isJidUser(chat.id) && chat.readOnly && chat.archived) {
if(isJidUser(chat.id) && chat.readOnly && chat.archived) { delete chat.readOnly
chat.readOnly = false
}
chats.push(chat)
historyCache.add(chat.id)
} }
chats.push({ ...chat })
} }
break break
case proto.HistorySync.HistorySyncType.PUSH_NAME: case proto.HistorySync.HistorySyncType.PUSH_NAME:
for(const c of item.pushnames!) { for(const c of item.pushnames!) {
const contactId = `c:${c.id}` contacts.push({ notify: c.pushname!, id: c.id! })
if(!historyCache.has(contactId)) {
contacts.push({ notify: c.pushname!, id: c.id! })
historyCache.add(contactId)
}
} }
break
case proto.HistorySync.HistorySyncType.INITIAL_STATUS_V3:
// TODO
break break
} }
const didProcess = !!(chats.length || messages.length || contacts.length)
return { return {
chats, chats,
contacts, contacts,
messages, messages,
didProcess,
} }
} }
export const downloadAndProcessHistorySyncNotification = async( export const downloadAndProcessHistorySyncNotification = async(
msg: proto.Message.IHistorySyncNotification, msg: proto.Message.IHistorySyncNotification,
historyCache: Set<string>,
recvChats: InitialReceivedChatsState,
options: AxiosRequestConfig<any> options: AxiosRequestConfig<any>
) => { ) => {
const historyMsg = await downloadHistory(msg, options) const historyMsg = await downloadHistory(msg, options)
return processHistoryMessage(historyMsg, historyCache, recvChats) return processHistoryMessage(historyMsg)
} }
export const isHistoryMsg = (message: proto.IMessage) => { export const getHistoryMsg = (message: proto.IMessage) => {
const normalizedContent = !!message ? normalizeMessageContent(message) : undefined const normalizedContent = !!message ? normalizeMessageContent(message) : undefined
const isAnyHistoryMsg = !!normalizedContent?.protocolMessage?.historySyncNotification const anyHistoryMsg = normalizedContent?.protocolMessage?.historySyncNotification
return isAnyHistoryMsg return anyHistoryMsg
} }

View File

@@ -1,14 +1,12 @@
import { AxiosRequestConfig } from 'axios' import { AxiosRequestConfig } from 'axios'
import type { Logger } from 'pino' import type { Logger } from 'pino'
import { proto } from '../../WAProto' import { proto } from '../../WAProto'
import { AuthenticationCreds, BaileysEventEmitter, Chat, GroupMetadata, InitialReceivedChatsState, ParticipantAction, SignalKeyStoreWithTransaction, WAMessageStubType } from '../Types' import { AuthenticationCreds, BaileysEventEmitter, Chat, GroupMetadata, ParticipantAction, SignalKeyStoreWithTransaction, WAMessageStubType } from '../Types'
import { downloadAndProcessHistorySyncNotification, normalizeMessageContent, toNumber } from '../Utils' import { downloadAndProcessHistorySyncNotification, normalizeMessageContent, toNumber } from '../Utils'
import { areJidsSameUser, jidNormalizedUser } from '../WABinary' import { areJidsSameUser, jidNormalizedUser } from '../WABinary'
type ProcessMessageContext = { type ProcessMessageContext = {
historyCache: Set<string> shouldProcessHistoryMsg: boolean
recvChats: InitialReceivedChatsState
downloadHistory: boolean
creds: AuthenticationCreds creds: AuthenticationCreds
keyStore: SignalKeyStoreWithTransaction keyStore: SignalKeyStoreWithTransaction
ev: BaileysEventEmitter ev: BaileysEventEmitter
@@ -66,7 +64,14 @@ export const shouldIncrementChatUnread = (message: proto.IWebMessageInfo) => (
const processMessage = async( const processMessage = async(
message: proto.IWebMessageInfo, message: proto.IWebMessageInfo,
{ downloadHistory, ev, historyCache, recvChats, creds, keyStore, logger, options }: ProcessMessageContext {
shouldProcessHistoryMsg,
ev,
creds,
keyStore,
logger,
options
}: ProcessMessageContext
) => { ) => {
const meId = creds.me!.id const meId = creds.me!.id
const { accountSettings } = creds const { accountSettings } = creds
@@ -92,38 +97,30 @@ const processMessage = async(
switch (protocolMsg.type) { switch (protocolMsg.type) {
case proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION: case proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION:
const histNotification = protocolMsg!.historySyncNotification! const histNotification = protocolMsg!.historySyncNotification!
const process = shouldProcessHistoryMsg
const isLatest = !creds.processedHistoryMessages?.length
logger?.info({ histNotification, id: message.key.id }, 'got history notification') logger?.info({
histNotification,
process,
id: message.key.id,
isLatest,
}, 'got history notification')
if(downloadHistory) { if(process) {
const isLatest = !creds.processedHistoryMessages?.length const data = await downloadAndProcessHistorySyncNotification(
const { chats, contacts, messages, didProcess } = await downloadAndProcessHistorySyncNotification(
histNotification, histNotification,
historyCache,
recvChats,
options options
) )
if(chats.length) { ev.emit('messaging-history.set', { ...data, isLatest })
ev.emit('chats.set', { chats, isLatest })
}
if(messages.length) { ev.emit('creds.update', {
ev.emit('messages.set', { messages, isLatest }) processedHistoryMessages: [
} ...(creds.processedHistoryMessages || []),
{ key: message.key, messageTimestamp: message.messageTimestamp }
if(contacts.length) { ]
ev.emit('contacts.set', { contacts, isLatest }) })
}
if(didProcess) {
ev.emit('creds.update', {
processedHistoryMessages: [
...(creds.processedHistoryMessages || []),
{ key: message.key, messageTimestamp: message.messageTimestamp }
]
})
}
} }
break break