feat(labels): modify chat utils

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

View File

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