/* eslint-disable eqeqeq */ import { Boom } from '@hapi/boom' import { proto } from '../../WAProto/index.js' import type { BaileysEventEmitter, Chat, ChatModification, ChatMutation, ChatUpdate, Contact, InitialAppStateSyncOptions, LastMessageList, LTHashState, WAPatchCreate, WAPatchName } from '../Types' import { type ChatLabelAssociation, LabelAssociationType, type MessageLabelAssociation } from '../Types/LabelAssociation' import { type BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, isJidGroup, jidNormalizedUser } from '../WABinary' import { aesDecrypt, aesEncrypt, hmacSign } from './crypto' import { toNumber } from './generics' import type { ILogger } from './logger' import { LT_HASH_ANTI_TAMPERING } from './lt-hash' import { downloadContentFromMessage } from './messages-media' import { emitSyncActionResults, processContactAction } from './sync-action-utils' import { expandAppStateKeys } from './wasm-bridge' type FetchAppStateSyncKey = (keyId: string) => Promise export type ChatMutationMap = { [index: string]: ChatMutation } const mutationKeys = (keydata: Uint8Array) => { return expandAppStateKeys(keydata) } const generateMac = ( operation: proto.SyncdMutation.SyncdOperation, data: Uint8Array, keyId: Uint8Array | string, key: Uint8Array ) => { const getKeyData = () => { let r: number switch (operation) { 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 string, 'base64')]) } const keyData = getKeyData() const last = Buffer.alloc(8) // 8 bytes last.set([keyData.length], last.length - 1) const total = Buffer.concat([keyData, data, last]) const hmac = hmacSign(total, key, 'sha512') return hmac.slice(0, 32) } const to64BitNetworkOrder = (e: number) => { const buff = Buffer.alloc(8) buff.writeUint32BE(e, 4) return buff } type Mac = { indexMac: Uint8Array; valueMac: Uint8Array; operation: proto.SyncdMutation.SyncdOperation } export const makeLtHashGenerator = ({ indexValueMap, hash }: Pick) => { indexValueMap = { ...indexValueMap } const addBuffs: Uint8Array[] = [] const subBuffs: Uint8Array[] = [] return { mix: ({ indexMac, valueMac, operation }: Mac) => { const indexMacBase64 = Buffer.from(indexMac).toString('base64') const prevOp = indexValueMap[indexMacBase64] if (operation === proto.SyncdMutation.SyncdOperation.REMOVE) { if (!prevOp) { // WA Web does not throw here — it logs a warning and skips the subtract. // The missing REMOVE will cause an LTHash mismatch, which is handled // by the MAC validation layer (snapshot recovery or retry). return } // remove from index value mac, since this mutation is erased delete indexValueMap[indexMacBase64] } else { addBuffs.push(new Uint8Array(valueMac)) // add this index into the history map indexValueMap[indexMacBase64] = { valueMac } } if (prevOp) { subBuffs.push(new Uint8Array(prevOp.valueMac)) } }, finish: () => { const result = LT_HASH_ANTI_TAMPERING().subtractThenAdd(new Uint8Array(hash), addBuffs, subBuffs) return { hash: Buffer.from(result), indexValueMap } } } } const generateSnapshotMac = (lthash: Uint8Array, version: number, name: WAPatchName, key: Uint8Array) => { const total = Buffer.concat([lthash, to64BitNetworkOrder(version), Buffer.from(name, 'utf-8')]) return hmacSign(total, key, 'sha256') } const generatePatchMac = ( snapshotMac: Uint8Array, valueMacs: Uint8Array[], version: number, type: WAPatchName, key: Uint8Array ) => { const total = Buffer.concat([snapshotMac, ...valueMacs, to64BitNetworkOrder(version), Buffer.from(type, 'utf-8')]) return hmacSign(total, key) } export const newLTHashState = (): LTHashState => ({ version: 0, hash: Buffer.alloc(128), indexValueMap: {} }) export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => { if (typeof state.version !== 'number' || isNaN(state.version)) { state.version = 0 } return state } export const MAX_SYNC_ATTEMPTS = 2 /** * Check if an error is a missing app state sync key. * WA Web treats these as "Blocked" (waits for key arrival), not fatal. * In Baileys we retry with a snapshot which may use a different key. */ export const isMissingKeyError = (error: any): boolean => { return error?.data?.isMissingKey === true } /** * Determines if an app state sync error is unrecoverable. * TypeError indicates a WASM crash; otherwise we give up after MAX_SYNC_ATTEMPTS. * Missing keys are NOT checked here — they are handled separately as "Blocked". */ export const isAppStateSyncIrrecoverable = (error: any, attempts: number): boolean => { return attempts >= MAX_SYNC_ATTEMPTS || error?.name === 'TypeError' } 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) { throw new Boom(`myAppStateKey ("${myAppStateKeyId}") not present`, { data: { isMissingKey: true } }) } const encKeyId = Buffer.from(myAppStateKeyId, 'base64') state = { ...state, indexValueMap: { ...state.indexValueMap } } const indexBuffer = Buffer.from(JSON.stringify(index)) const dataProto = proto.SyncActionData.fromObject({ index: indexBuffer, value: syncAction, padding: new Uint8Array(0), version: apiVersion }) const encoded = proto.SyncActionData.encode(dataProto).finish() const keyData = key.keyData if (!keyData) { throw new Boom('Missing keyData for encoding', { statusCode: 500 }) } const keyValue = mutationKeys(keyData) const encValue = aesEncrypt(encoded, keyValue.valueEncryptionKey) const valueMac = generateMac(operation, encValue, encKeyId, keyValue.valueMacKey) const indexMac = hmacSign(indexBuffer, keyValue.indexKey) // update LT hash const generator = makeLtHashGenerator(state) generator.mix({ indexMac, valueMac, operation }) Object.assign(state, generator.finish()) state.version += 1 const snapshotMac = generateSnapshotMac(state.hash, state.version, type, keyValue.snapshotMacKey) const patch: proto.ISyncdPatch = { patchMac: generatePatchMac(snapshotMac, [valueMac], state.version, type, keyValue.patchMacKey), snapshotMac: snapshotMac, keyId: { id: encKeyId }, mutations: [ { operation: operation, record: { index: { blob: indexMac }, value: { blob: Buffer.concat([encValue, valueMac]) }, keyId: { id: encKeyId } } } ] } const base64Index = indexMac.toString('base64') state.indexValueMap[base64Index] = { valueMac } return { patch, state } } export const decodeSyncdMutations = async ( msgMutations: (proto.ISyncdMutation | proto.ISyncdRecord)[], initialState: LTHashState, getAppStateSyncKey: FetchAppStateSyncKey, onMutation: (mutation: ChatMutation) => void, validateMacs: boolean ) => { const ltGenerator = makeLtHashGenerator(initialState) // 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) { // 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 if (operation == null) { throw new Boom('Missing operation in mutation', { statusCode: 500 }) } const record = 'record' in msgMutation && !!msgMutation.record ? msgMutation.record : (msgMutation as proto.ISyncdRecord) const keyIdBuf = record.keyId?.id if (!keyIdBuf) { throw new Boom('Missing keyId in record', { statusCode: 500 }) } const recordBlob = record.value?.blob if (!recordBlob) { throw new Boom('Missing record blob', { statusCode: 500 }) } const indexBlob = record.index?.blob if (!indexBlob) { throw new Boom('Missing index blob in record', { statusCode: 500 }) } const key = await getKey(keyIdBuf) const content = Buffer.from(recordBlob) const encContent = content.slice(0, -32) const ogValueMac = content.slice(-32) if (validateMacs) { const contentHmac = generateMac(operation, encContent, keyIdBuf, key.valueMacKey) if (Buffer.compare(contentHmac, ogValueMac) !== 0) { throw new Boom('HMAC content verification failed') } } const result = aesDecrypt(encContent, key.valueEncryptionKey) const syncAction = proto.SyncActionData.decode(result) const syncActionIndex = syncAction.index if (!syncActionIndex) { throw new Boom('Missing index in sync action data', { statusCode: 500 }) } if (validateMacs) { const hmac = hmacSign(syncActionIndex, key.indexKey) if (Buffer.compare(hmac, indexBlob) !== 0) { throw new Boom('HMAC index verification failed') } } const indexStr = Buffer.from(syncActionIndex).toString() onMutation({ syncAction, index: JSON.parse(indexStr) }) ltGenerator.mix({ indexMac: indexBlob, valueMac: ogValueMac, operation: operation }) } return ltGenerator.finish() async function getKey(keyId: Uint8Array) { const base64Key = Buffer.from(keyId).toString('base64') const keyEnc = await getAppStateSyncKey(base64Key) if (!keyEnc) { throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true, msgMutations } }) } const keyData = keyEnc.keyData if (!keyData) { throw new Boom('Missing keyData in app state sync key', { statusCode: 500 }) } return mutationKeys(keyData) } } export const decodeSyncdPatch = async ( msg: proto.ISyncdPatch, name: WAPatchName, initialState: LTHashState, getAppStateSyncKey: FetchAppStateSyncKey, onMutation: (mutation: ChatMutation) => void, validateMacs: boolean ) => { if (validateMacs) { const msgKeyId = msg.keyId?.id if (!msgKeyId) { throw new Boom('Missing keyId in patch message', { statusCode: 500 }) } const base64Key = Buffer.from(msgKeyId).toString('base64') const mainKeyObj = await getAppStateSyncKey(base64Key) if (!mainKeyObj) { throw new Boom(`failed to find key "${base64Key}" to decode patch`, { data: { isMissingKey: true, msg } }) } const mainKeyData = mainKeyObj.keyData if (!mainKeyData) { throw new Boom('Missing keyData in main key object', { statusCode: 500 }) } const mainKey = mutationKeys(mainKeyData) const msgMutations = msg.mutations if (!msgMutations) { throw new Boom('Missing mutations in patch message', { statusCode: 500 }) } const mutationmacs = msgMutations.map(mutation => { const mutBlob = mutation.record?.value?.blob if (!mutBlob) { throw new Boom('Missing blob in mutation record', { statusCode: 500 }) } return mutBlob.slice(-32) }) const msgSnapshotMac = msg.snapshotMac if (!msgSnapshotMac) { throw new Boom('Missing snapshotMac in patch message', { statusCode: 500 }) } const msgVersion = msg.version?.version if (msgVersion == null) { throw new Boom('Missing version in patch message', { statusCode: 500 }) } const msgPatchMac = msg.patchMac if (!msgPatchMac) { throw new Boom('Missing patchMac in patch message', { statusCode: 500 }) } const patchMac = generatePatchMac(msgSnapshotMac, mutationmacs, toNumber(msgVersion), name, mainKey.patchMacKey) if (Buffer.compare(patchMac, msgPatchMac) !== 0) { throw new Boom('Invalid patch mac') } } const patchMutations = msg.mutations if (!patchMutations) { throw new Boom('Missing mutations in patch message', { statusCode: 500 }) } const result = await decodeSyncdMutations(patchMutations, initialState, getAppStateSyncKey, onMutation, validateMacs) return result } export const extractSyncdPatches = async (result: BinaryNode, options: RequestInit) => { const syncNode = getBinaryNodeChild(result, 'sync') const collectionNodes = getBinaryNodeChildren(syncNode, 'collection') const final = {} as { [T in WAPatchName]: { patches: proto.ISyncdPatch[]; hasMorePatches: boolean; snapshot?: proto.ISyncdSnapshot } } await Promise.all( collectionNodes.map(async collectionNode => { const patchesNode = getBinaryNodeChild(collectionNode, 'patches') const patches = getBinaryNodeChildren(patchesNode || collectionNode, 'patch') const snapshotNode = getBinaryNodeChild(collectionNode, 'snapshot') const syncds: proto.ISyncdPatch[] = [] const name = collectionNode.attrs.name as WAPatchName const hasMorePatches = collectionNode.attrs.has_more_patches === 'true' let snapshot: proto.ISyncdSnapshot | undefined = undefined 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) const data = await downloadExternalBlob(blobRef, options) snapshot = proto.SyncdSnapshot.decode(data) } 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) { syncd.version = { version: +(collectionNode.attrs.version ?? 0) + 1 } } syncds.push(syncd) } } final[name] = { patches: syncds, hasMorePatches, snapshot } }) ) return final } export const downloadExternalBlob = async (blob: proto.IExternalBlobReference, options: RequestInit) => { const stream = await downloadContentFromMessage(blob, 'md-app-state', { options }) const bufferArray: Buffer[] = [] for await (const chunk of stream) { bufferArray.push(chunk) } return Buffer.concat(bufferArray) } export const downloadExternalPatch = async (blob: proto.IExternalBlobReference, options: RequestInit) => { const buffer = await downloadExternalBlob(blob, options) const syncData = proto.SyncdMutations.decode(buffer) return syncData } export const decodeSyncdSnapshot = async ( name: WAPatchName, snapshot: proto.ISyncdSnapshot, getAppStateSyncKey: FetchAppStateSyncKey, minimumVersionNumber: number | undefined, validateMacs = true ) => { const newState = newLTHashState() const snapshotVersion = snapshot.version?.version if (snapshotVersion == null) { throw new Boom('Missing version in snapshot', { statusCode: 500 }) } newState.version = toNumber(snapshotVersion) const mutationMap: ChatMutationMap = {} const areMutationsRequired = typeof minimumVersionNumber === 'undefined' || newState.version > minimumVersionNumber const snapshotRecords = snapshot.records if (!snapshotRecords) { throw new Boom('Missing records in snapshot', { statusCode: 500 }) } const { hash, indexValueMap } = await decodeSyncdMutations( snapshotRecords, newState, getAppStateSyncKey, areMutationsRequired ? mutation => { const index = mutation.syncAction.index?.toString() mutationMap[index!] = mutation } : () => {}, validateMacs ) newState.hash = hash newState.indexValueMap = indexValueMap if (validateMacs) { const snapKeyId = snapshot.keyId?.id if (!snapKeyId) { throw new Boom('Missing keyId in snapshot', { statusCode: 500 }) } const base64Key = Buffer.from(snapKeyId).toString('base64') const keyEnc = await getAppStateSyncKey(base64Key) if (!keyEnc) { throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } }) } const snapKeyData = keyEnc.keyData if (!snapKeyData) { throw new Boom('Missing keyData in snapshot key', { statusCode: 500 }) } const result = mutationKeys(snapKeyData) const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey) const snapshotMac = snapshot.mac if (!snapshotMac) { throw new Boom('Missing mac in snapshot', { statusCode: 500 }) } if (Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) { throw new Boom(`failed to verify LTHash at ${newState.version} of ${name} from snapshot`) } } return { state: newState, mutationMap } } export const decodePatches = async ( name: WAPatchName, syncds: proto.ISyncdPatch[], initial: LTHashState, getAppStateSyncKey: FetchAppStateSyncKey, options: RequestInit, minimumVersionNumber?: number, logger?: ILogger, validateMacs = true ) => { const newState: LTHashState = { ...initial, indexValueMap: { ...initial.indexValueMap } } const mutationMap: ChatMutationMap = {} for (const syncd of syncds) { const { version, keyId, snapshotMac } = syncd 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') syncd.mutations?.push(...ref.mutations) } const ver = version?.version if (ver == null) { throw new Boom('Missing version in patch', { statusCode: 500 }) } const patchVersion = toNumber(ver) newState.version = patchVersion const shouldMutate = typeof minimumVersionNumber === 'undefined' || patchVersion > minimumVersionNumber const decodeResult = await decodeSyncdPatch( syncd, name, newState, getAppStateSyncKey, shouldMutate ? mutation => { const index = mutation.syncAction.index?.toString() mutationMap[index!] = mutation } : () => {}, true ) newState.hash = decodeResult.hash newState.indexValueMap = decodeResult.indexValueMap if (validateMacs) { const patchKeyId = keyId?.id if (!patchKeyId) { throw new Boom('Missing keyId in patch', { statusCode: 500 }) } const base64Key = Buffer.from(patchKeyId).toString('base64') const keyEnc = await getAppStateSyncKey(base64Key) if (!keyEnc) { throw new Boom(`failed to find key "${base64Key}" to decode mutation`, { data: { isMissingKey: true } }) } const patchKeyData = keyEnc.keyData if (!patchKeyData) { throw new Boom('Missing keyData in patch key', { statusCode: 500 }) } const result = mutationKeys(patchKeyData) const computedSnapshotMac = generateSnapshotMac(newState.hash, newState.version, name, result.snapshotMacKey) if (!snapshotMac) { throw new Boom('Missing snapshotMac in patch', { statusCode: 500 }) } if (Buffer.compare(snapshotMac, computedSnapshotMac) !== 0) { throw new Boom(`failed to verify LTHash at ${newState.version} of ${name}`) } } // clear memory used up by the mutations syncd.mutations = [] } return { state: newState, mutationMap } } export const chatModificationToAppPatch = (mod: ChatModification, jid: string) => { const OP = proto.SyncdMutation.SyncdOperation const getMessageRange = (lastMessages: LastMessageList) => { let messageRange: proto.SyncActionValue.ISyncActionMessageRange 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) { throw new Boom('Incomplete key', { statusCode: 400, data: m }) } 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)) { throw new Boom('Missing timestamp in last message list', { statusCode: 400, data: m }) } if (m.key.participant) { m.key.participant = jidNormalizedUser(m.key.participant) } return m }) : undefined } } else { messageRange = lastMessages } return messageRange } let patch: WAPatchCreate if ('mute' in mod) { patch = { syncAction: { muteAction: { muted: !!mod.mute, muteEndTimestamp: mod.mute || undefined } }, index: ['mute', jid], type: 'regular_high', apiVersion: 2, operation: OP.SET } } else if ('archive' in mod) { patch = { syncAction: { archiveChatAction: { archived: !!mod.archive, messageRange: getMessageRange(mod.lastMessages) } }, index: ['archive', jid], type: 'regular_low', apiVersion: 3, operation: OP.SET } } else if ('markRead' in mod) { patch = { syncAction: { markChatAsReadAction: { read: mod.markRead, messageRange: getMessageRange(mod.lastMessages) } }, index: ['markChatAsRead', jid], type: 'regular_low', apiVersion: 3, operation: OP.SET } } else if ('deleteForMe' in mod) { const { timestamp, key, deleteMedia } = mod.deleteForMe patch = { syncAction: { deleteMessageForMeAction: { deleteMedia, messageTimestamp: timestamp } }, index: ['deleteMessageForMe', jid, key.id!, key.fromMe ? '1' : '0', '0'], type: 'regular_high', apiVersion: 3, operation: OP.SET } } else if ('clear' in mod) { patch = { syncAction: { clearChatAction: { messageRange: getMessageRange(mod.lastMessages) } }, index: ['clearChat', jid, '1' /*the option here is 0 when keep starred messages is enabled*/, '0'], type: 'regular_high', apiVersion: 6, operation: OP.SET } } else if ('pin' in mod) { patch = { syncAction: { pinAction: { pinned: !!mod.pin } }, index: ['pin_v1', jid], type: 'regular_low', apiVersion: 5, operation: OP.SET } } else if ('contact' in mod) { patch = { syncAction: { contactAction: mod.contact || {} }, index: ['contact', jid], type: 'critical_unblock_low', apiVersion: 2, operation: mod.contact ? OP.SET : OP.REMOVE } } else if ('disableLinkPreviews' in mod) { patch = { syncAction: { privacySettingDisableLinkPreviewsAction: mod.disableLinkPreviews || {} }, index: ['setting_disableLinkPreviews'], type: 'regular', apiVersion: 8, operation: OP.SET } } else if ('star' in mod) { const key = mod.star.messages[0] if (!key) { throw new Boom('Missing star message key', { statusCode: 400 }) } patch = { syncAction: { starAction: { starred: !!mod.star.star } }, index: ['star', jid, key.id, key.fromMe ? '1' : '0', '0'], type: 'regular_low', apiVersion: 2, operation: OP.SET } } else if ('delete' in mod) { patch = { syncAction: { deleteChatAction: { messageRange: getMessageRange(mod.lastMessages) } }, index: ['deleteChat', jid, '1'], type: 'regular_high', apiVersion: 6, operation: OP.SET } } else if ('pushNameSetting' in mod) { patch = { syncAction: { pushNameSetting: { name: mod.pushNameSetting } }, index: ['setting_pushName'], type: 'critical_block', apiVersion: 1, operation: OP.SET } } else if ('quickReply' in mod) { patch = { syncAction: { quickReplyAction: { count: 0, deleted: mod.quickReply.deleted || false, keywords: [], message: mod.quickReply.message || '', shortcut: mod.quickReply.shortcut || '' } }, index: ['quick_reply', mod.quickReply.timestamp || String(Math.floor(Date.now() / 1000))], type: 'regular', apiVersion: 2, operation: OP.SET } } else if ('addLabel' in mod) { patch = { syncAction: { labelEditAction: { name: mod.addLabel.name, color: mod.addLabel.color, predefinedId: mod.addLabel.predefinedId, deleted: mod.addLabel.deleted } }, index: ['label_edit', mod.addLabel.id], type: 'regular', apiVersion: 3, 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') } patch.syncAction.timestamp = Date.now() return patch } export const processSyncAction = ( syncAction: ChatMutation, ev: BaileysEventEmitter, me: Contact, initialSyncOpts?: InitialAppStateSyncOptions, logger?: ILogger ) => { const isInitialSync = !!initialSyncOpts const accountSettings = initialSyncOpts?.accountSettings logger?.trace({ syncAction, initialSync: !!initialSyncOpts }, 'processing sync action') const { syncAction: { value: action }, index: [type, id, msgId, fromMe] } = syncAction if (action?.muteAction) { ev.emit('chats.update', [ { id, muteEndTime: action.muteAction?.muted ? toNumber(action.muteAction.muteEndTimestamp) : null, conditional: getChatUpdateConditional(id!, undefined) } ]) } 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 // 1. if the account unarchiveChats setting is true // a. if the chat is archived, and no further messages have been received -- simple, keep archived // b. if the chat was archived, and the user received messages from the other person afterwards // then the chat should be marked unarchved -- // 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, // it'll always take an app state action to mark in unarchived -- which we'll get anyway const archiveAction = action?.archiveChatAction const isArchived = archiveAction ? archiveAction.archived : 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) { // isArchived = false // } const msgRange = !accountSettings?.unarchiveChats ? undefined : archiveAction?.messageRange // logger?.debug({ chat: id, syncAction }, 'message range archive') ev.emit('chats.update', [ { id, archived: isArchived, conditional: getChatUpdateConditional(id!, msgRange) } ]) } 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 // this only applies for the initial sync const isNullUpdate = isInitialSync && markReadAction.read ev.emit('chats.update', [ { id, 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) { const results = processContactAction(action.contactAction, id, logger) emitSyncActionResults(ev, results) } else if (action?.pushNameSetting) { const name = action?.pushNameSetting?.name if (name && me?.name !== name) { ev.emit('creds.update', { me: { ...me, name } }) } } 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) { const unarchiveChats = !!action.unarchiveChatsSetting.unarchiveChats ev.emit('creds.update', { accountSettings: { unarchiveChats } }) logger?.info(`archive setting updated => '${action.unarchiveChatsSetting.unarchiveChats}'`) if (accountSettings) { accountSettings.unarchiveChats = unarchiveChats } } else if (action?.starAction || type === 'star') { let starred = action?.starAction?.starred if (typeof starred !== 'boolean') { starred = syncAction.index[syncAction.index.length - 1] === '1' } ev.emit('messages.update', [ { key: { remoteJid: id, id: msgId, fromMe: fromMe === '1' }, update: { starred } } ]) } 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: 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 if (action?.localeSetting?.locale) { ev.emit('settings.update', { setting: 'locale', value: action.localeSetting.locale }) } else if (action?.timeFormatAction) { ev.emit('settings.update', { setting: 'timeFormat', value: action.timeFormatAction }) } else if (action?.pnForLidChatAction) { if (action.pnForLidChatAction.pnJid) { ev.emit('lid-mapping.update', [{ lid: id!, pn: action.pnForLidChatAction.pnJid }]) } } else if (action?.privacySettingRelayAllCalls) { ev.emit('settings.update', { setting: 'privacySettingRelayAllCalls', value: action.privacySettingRelayAllCalls }) } else if (action?.statusPrivacy) { ev.emit('settings.update', { setting: 'statusPrivacy', value: action.statusPrivacy }) } else if (action?.lockChatAction) { ev.emit('chats.lock', { id: id!, locked: !!action.lockChatAction.locked }) } else if (action?.privacySettingDisableLinkPreviewsAction) { ev.emit('settings.update', { setting: 'disableLinkPreviews', value: action.privacySettingDisableLinkPreviewsAction }) } else if (action?.notificationActivitySettingAction?.notificationActivitySetting) { ev.emit('settings.update', { setting: 'notificationActivitySetting', value: action.notificationActivitySettingAction.notificationActivitySetting }) } else if (action?.lidContactAction) { ev.emit('contacts.upsert', [ { id: id!, name: action.lidContactAction.fullName || action.lidContactAction.firstName || action.lidContactAction.username || undefined, lid: id!, phoneNumber: undefined } ]) } else if (action?.privacySettingChannelsPersonalisedRecommendationAction) { ev.emit('settings.update', { setting: 'channelsPersonalisedRecommendation', value: action.privacySettingChannelsPersonalisedRecommendationAction }) } else { logger?.debug({ syncAction, id }, 'unprocessable update') } function getChatUpdateConditional( id: string, msgRange: proto.SyncActionValue.ISyncActionMessageRange | null | undefined ): ChatUpdate['conditional'] { 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 = Number(msgRange?.lastMessageTimestamp || msgRange?.lastSystemMessageTimestamp || 0) const chatLastMsgTimestamp = Number(chat?.lastMessageRecvTimestamp || 0) return lastMsgTimestamp >= chatLastMsgTimestamp } }