import { proto } from '../../WAProto/index.js' import type { AuthenticationCreds, BaileysEventEmitter, CacheStore, Chat, GroupMetadata, GroupParticipant, LIDMapping, ParticipantAction, RequestJoinAction, RequestJoinMethod, SignalKeyStoreWithTransaction, SignalRepositoryWithLIDStore, SocketConfig, WAMessage, WAMessageKey } from '../Types' import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js' import { WAMessageStubType } from '../Types' import { getContentType, normalizeMessageContent } from '../Utils/messages' import { areJidsSameUser, isHostedLidUser, isHostedPnUser, isJidBroadcast, isJidStatusBroadcast, isLidUser, jidDecode, jidEncode, jidNormalizedUser } from '../WABinary' import { aesDecryptGCM, hmacSign } from './crypto' import { getKeyAuthor, toNumber } from './generics' import { downloadAndProcessHistorySyncNotification } from './history' import type { ILogger } from './logger' type ProcessMessageContext = { shouldProcessHistoryMsg: boolean placeholderResendCache?: CacheStore creds: AuthenticationCreds keyStore: SignalKeyStoreWithTransaction ev: BaileysEventEmitter logger?: ILogger options: RequestInit signalRepository: SignalRepositoryWithLIDStore getMessage: SocketConfig['getMessage'] } const REAL_MSG_STUB_TYPES = new Set([ WAMessageStubType.CALL_MISSED_GROUP_VIDEO, WAMessageStubType.CALL_MISSED_GROUP_VOICE, WAMessageStubType.CALL_MISSED_VIDEO, WAMessageStubType.CALL_MISSED_VOICE ]) const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD]) /** Cleans a received message to further processing */ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => { // ensure remoteJid and participant doesn't have device or agent in it const remoteJid = message.key.remoteJid ?? '' if (isHostedPnUser(remoteJid) || isHostedLidUser(remoteJid)) { message.key.remoteJid = jidEncode( jidDecode(remoteJid)?.user ?? '', isHostedPnUser(remoteJid) ? 's.whatsapp.net' : 'lid' ) } else { message.key.remoteJid = jidNormalizedUser(remoteJid) } const participantJid = message.key.participant ?? '' if (isHostedPnUser(participantJid) || isHostedLidUser(participantJid)) { message.key.participant = jidEncode( jidDecode(participantJid)?.user ?? '', isHostedPnUser(participantJid) ? 's.whatsapp.net' : 'lid' ) } else { message.key.participant = jidNormalizedUser(participantJid) } const content = normalizeMessageContent(message.message) // if the message has a reaction, ensure fromMe & remoteJid are from our perspective if (content?.reactionMessage) { const reactionKey = content.reactionMessage.key if (reactionKey) { normaliseKey(reactionKey) } } if (content?.pollUpdateMessage) { const pollCreationKey = content.pollUpdateMessage.pollCreationMessageKey if (pollCreationKey) { normaliseKey(pollCreationKey) } } function normaliseKey(msgKey: WAMessageKey) { // if the reaction is from another user // we've to correctly map the key to this user's perspective if (!message.key.fromMe) { // if the sender believed the message being reacted to is not from them // we've to correct the key to be from them, or some other participant msgKey.fromMe = !msgKey.fromMe ? areJidsSameUser(msgKey.participant || (msgKey.remoteJid ?? ''), meId) || areJidsSameUser(msgKey.participant || (msgKey.remoteJid ?? ''), meLid) : // if the message being reacted to, was from them // fromMe automatically becomes false false // set the remoteJid to being the same as the chat the message came from // TODO: investigate inconsistencies msgKey.remoteJid = message.key.remoteJid // set participant of the message msgKey.participant = msgKey.participant || message.key.participant } } } export const normalizeMessageJids = async ( message: WAMessage, signalRepository: SignalRepositoryWithLIDStore, logger?: ILogger ): Promise => { const resolveLidToPn = async (jid: string | undefined | null): Promise => { if (!jid) { return undefined } if (isLidUser(jid) || isHostedLidUser(jid)) { const pn = await signalRepository.lidMapping.getPNForLID(jid) if (pn) { logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message') } else { logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID') } return pn || jid } return jid } // Execute both lookups in parallel instead of sequentially to reduce latency const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([ resolveLidToPn(message.key.remoteJid), resolveLidToPn(message.key.participant) ]) if (resolvedRemoteJid) { message.key.remoteJid = resolvedRemoteJid } if (resolvedParticipant) { message.key.participant = resolvedParticipant } } // TODO: target:audit AUDIT THIS FUNCTION AGAIN export const isRealMessage = (message: WAMessage) => { const normalizedContent = normalizeMessageContent(message.message) const hasSomeContent = !!getContentType(normalizedContent) const stubType = message.messageStubType ?? 0 return ( (!!normalizedContent || REAL_MSG_STUB_TYPES.has(stubType) || REAL_MSG_REQ_ME_STUB_TYPES.has(stubType)) && hasSomeContent && !normalizedContent?.protocolMessage && !normalizedContent?.reactionMessage && !normalizedContent?.pollUpdateMessage ) } export const shouldIncrementChatUnread = (message: WAMessage) => !message.key.fromMe && !message.messageStubType /** * Get the ID of the chat from the given key. * Typically -- that'll be the remoteJid, but for broadcasts, it'll be the participant */ export const getChatId = ({ remoteJid, participant, fromMe }: WAMessageKey) => { const jid = remoteJid ?? '' if (isJidBroadcast(jid) && !isJidStatusBroadcast(jid) && !fromMe) { return participant ?? '' } return jid } type PollContext = { /** normalised jid of the person that created the poll */ pollCreatorJid: string /** ID of the poll creation message */ pollMsgId: string /** poll creation message enc key */ pollEncKey: Uint8Array /** jid of the person that voted */ voterJid: string } type EventContext = { /** normalised jid of the person that created the event */ eventCreatorJid: string /** ID of the event creation message */ eventMsgId: string /** event creation message enc key */ eventEncKey: Uint8Array /** jid of the person that responded */ responderJid: string } /** * Decrypt a poll vote * @param vote encrypted vote * @param ctx additional info about the poll required for decryption * @returns list of SHA256 options */ export function decryptPollVote( { encPayload, encIv }: proto.Message.IPollEncValue, { pollCreatorJid, pollMsgId, pollEncKey, voterJid }: PollContext ) { const sign = Buffer.concat([ toBinary(pollMsgId), toBinary(pollCreatorJid), toBinary(voterJid), toBinary('Poll Vote'), new Uint8Array([1]) ]) const key0 = hmacSign(pollEncKey, new Uint8Array(32), 'sha256') const decKey = hmacSign(sign, key0, 'sha256') const aad = toBinary(`${pollMsgId}\u0000${voterJid}`) if (!encPayload || !encIv) { throw new Error('Missing encPayload or encIv for poll vote decryption') } const decrypted = aesDecryptGCM(encPayload, decKey, encIv, aad) return proto.Message.PollVoteMessage.decode(decrypted) function toBinary(txt: string) { return Buffer.from(txt) } } /** * Decrypt an event response * @param response encrypted event response * @param ctx additional info about the event required for decryption * @returns event response message */ export function decryptEventResponse( { encPayload, encIv }: proto.Message.IPollEncValue, { eventCreatorJid, eventMsgId, eventEncKey, responderJid }: EventContext ) { const sign = Buffer.concat([ toBinary(eventMsgId), toBinary(eventCreatorJid), toBinary(responderJid), toBinary('Event Response'), new Uint8Array([1]) ]) const key0 = hmacSign(eventEncKey, new Uint8Array(32), 'sha256') const decKey = hmacSign(sign, key0, 'sha256') const aad = toBinary(`${eventMsgId}\u0000${responderJid}`) if (!encPayload || !encIv) { throw new Error('Missing encPayload or encIv for event response decryption') } const decrypted = aesDecryptGCM(encPayload, decKey, encIv, aad) return proto.Message.EventResponseMessage.decode(decrypted) function toBinary(txt: string) { return Buffer.from(txt) } } const processMessage = async ( message: WAMessage, { shouldProcessHistoryMsg, placeholderResendCache, ev, creds, signalRepository, keyStore, logger, options, getMessage }: ProcessMessageContext ) => { const meUser = creds.me if (!meUser) { return } const meId = meUser.id const { accountSettings } = creds const chat: Partial = { id: jidNormalizedUser(getChatId(message.key)) } const isRealMsg = isRealMessage(message) if (isRealMsg) { chat.messages = [{ message }] chat.conversationTimestamp = toNumber(message.messageTimestamp) // only increment unread count if not CIPHERTEXT and from another person if (shouldIncrementChatUnread(message)) { chat.unreadCount = (chat.unreadCount || 0) + 1 } } const content = normalizeMessageContent(message.message) // unarchive chat if it's a real message, or someone reacted to our message // and we've the unarchive chats setting on if ((isRealMsg || content?.reactionMessage?.key?.fromMe) && accountSettings?.unarchiveChats) { chat.archived = false chat.readOnly = false } const protocolMsg = content?.protocolMessage if (protocolMsg) { switch (protocolMsg.type) { case proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION: const histNotification = protocolMsg.historySyncNotification if (!histNotification) { break } const process = shouldProcessHistoryMsg const isLatest = !creds.processedHistoryMessages?.length logger?.info( { histNotification, process, id: message.key.id, isLatest }, 'got history notification' ) if (process) { // TODO: investigate if (histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND) { ev.emit('creds.update', { processedHistoryMessages: [ ...(creds.processedHistoryMessages || []), { key: message.key, messageTimestamp: message.messageTimestamp } ] }) } const data = await downloadAndProcessHistorySyncNotification(histNotification, options, logger) // Emit LID-PN mappings from history sync // This is how WhatsApp Web learns mappings for chats with non-contacts if (data.lidPnMappings?.length) { logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync') try { const result = await signalRepository.lidMapping.storeLIDPNMappings(data.lidPnMappings) logger?.debug( { stored: result.stored, skipped: result.skipped, errors: result.errors }, 'stored LID-PN mappings from history sync' ) if (result.stored > 0) { logger?.info( { stored: result.stored }, 'fallback LID mappings are now available from history sync' ) } } catch (error) { logger?.warn({ error }, 'Failed to store LID-PN mappings from history sync') } // Emit all mappings at once for better performance if (data.lidPnMappings.length > 0) { ev.emit('lid-mapping.update', data.lidPnMappings) } } ev.emit('messaging-history.set', { ...data, isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined, peerDataRequestSessionId: histNotification.peerDataRequestSessionId }) // Record history sync metrics if (data.messages?.length) { recordHistorySyncMessages(data.messages.length) } } break case proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_SHARE: const keys = protocolMsg.appStateSyncKeyShare?.keys if (keys?.length) { let newAppStateSyncKeyId = '' await keyStore.transaction(async () => { const newKeys: string[] = [] for (const { keyData, keyId } of keys) { const keyIdValue = keyId?.keyId if (!keyIdValue) { continue } const strKeyId = Buffer.from(keyIdValue).toString('base64') newKeys.push(strKeyId) if (keyData) { await keyStore.set({ 'app-state-sync-key': { [strKeyId]: keyData } }) } newAppStateSyncKeyId = strKeyId } logger?.info({ newAppStateSyncKeyId, newKeys }, 'injecting new app state sync keys') }, meId) ev.emit('creds.update', { myAppStateKeyId: newAppStateSyncKeyId }) } else { logger?.info({ protocolMsg }, 'recv app state sync with 0 keys') } break case proto.Message.ProtocolMessage.Type.REVOKE: ev.emit('messages.update', [ { key: { ...message.key, id: protocolMsg.key?.id }, update: { message: null, messageStubType: WAMessageStubType.REVOKE, key: message.key } } ]) break case proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING: Object.assign(chat, { ephemeralSettingTimestamp: toNumber(message.messageTimestamp), ephemeralExpiration: protocolMsg.ephemeralExpiration || null }) break case proto.Message.ProtocolMessage.Type.PEER_DATA_OPERATION_REQUEST_RESPONSE_MESSAGE: const response = protocolMsg.peerDataOperationRequestResponseMessage if (response) { if (response.stanzaId) { await placeholderResendCache?.del(response.stanzaId) } // TODO: IMPLEMENT HISTORY SYNC ETC (sticker uploads etc.). const { peerDataOperationResult } = response if (!peerDataOperationResult) { break } let recoveredCount = 0 for (const result of peerDataOperationResult) { const { placeholderMessageResendResponse: retryResponse } = result //eslint-disable-next-line max-depth if (retryResponse) { if (!retryResponse.webMessageInfoBytes) { continue } const webMessageInfo = proto.WebMessageInfo.decode(retryResponse.webMessageInfoBytes) // Track CTWA message recovery success recoveredCount++ logger?.info( { msgId: webMessageInfo.key?.id, remoteJid: webMessageInfo.key?.remoteJid, requestId: response.stanzaId }, 'CTWA: Successfully recovered message via placeholder resend' ) // wait till another upsert event is available, don't want it to be part of the PDO response message // TODO: parse through proper message handling utilities (to add relevant key fields) ev.emit('messages.upsert', { messages: [webMessageInfo as WAMessage], type: 'notify', requestId: response.stanzaId ?? '' }) } } // Update metrics for recovered messages if (recoveredCount > 0) { metrics.ctwaMessagesRecovered.inc(recoveredCount) metrics.ctwaRecoveryRequests.inc({ status: 'success' }) logger?.debug( { recoveredCount, requestId: response.stanzaId }, 'CTWA: Placeholder resend response processed' ) } } break case proto.Message.ProtocolMessage.Type.MESSAGE_EDIT: ev.emit('messages.update', [ { // flip the sender / fromMe properties because they're in the perspective of the sender key: { ...message.key, id: protocolMsg.key?.id }, update: { message: { editedMessage: { message: protocolMsg.editedMessage } }, messageTimestamp: protocolMsg.timestampMs ? Math.floor(toNumber(protocolMsg.timestampMs) / 1000) : message.messageTimestamp } } ]) break case proto.Message.ProtocolMessage.Type.GROUP_MEMBER_LABEL_CHANGE: const labelAssociationMsg = protocolMsg.memberLabel if (labelAssociationMsg?.label) { ev.emit('group.member-tag.update', { groupId: chat.id ?? '', label: labelAssociationMsg.label, participant: message.key.participant ?? '', participantAlt: message.key.participantAlt ?? '', messageTimestamp: Number(message.messageTimestamp) }) } break case proto.Message.ProtocolMessage.Type.LID_MIGRATION_MAPPING_SYNC: const encodedPayload = protocolMsg.lidMigrationMappingSyncMessage?.encodedMappingPayload if (!encodedPayload) { break } const { pnToLidMappings, chatDbMigrationTimestamp } = proto.LIDMigrationMappingSyncPayload.decode(encodedPayload) logger?.debug({ pnToLidMappings, chatDbMigrationTimestamp }, 'got lid mappings and chat db migration timestamp') const pairs = [] for (const { pn, latestLid, assignedLid } of pnToLidMappings) { const lid = latestLid || assignedLid pairs.push({ lid: `${lid}@lid`, pn: `${pn}@s.whatsapp.net` }) } await signalRepository.lidMapping.storeLIDPNMappings(pairs) if (pairs.length) { for (const { pn, lid } of pairs) { await signalRepository.migrateSession(pn, lid) } } } } else if (content?.reactionMessage) { const reactionKey = content.reactionMessage.key if (!reactionKey) { return } const reaction: proto.IReaction = { ...content.reactionMessage, key: message.key } ev.emit('messages.reaction', [ { reaction, key: reactionKey } ]) } else if (content?.encEventResponseMessage) { const encEventResponse = content.encEventResponseMessage const creationMsgKey = encEventResponse.eventCreationMessageKey if (!creationMsgKey) { return } // we need to fetch the event creation message to get the event enc key const eventMsg = await getMessage(creationMsgKey) if (eventMsg) { try { const meIdNormalised = jidNormalizedUser(meId) // all jids need to be PN const eventCreatorKey = creationMsgKey.participant || (creationMsgKey.remoteJid ?? '') const eventCreatorPn = isLidUser(eventCreatorKey) ? await signalRepository.lidMapping.getPNForLID(eventCreatorKey) : eventCreatorKey if (!eventCreatorPn) { return } const eventCreatorJid = getKeyAuthor( { remoteJid: jidNormalizedUser(eventCreatorPn), fromMe: meIdNormalised === eventCreatorPn }, meIdNormalised ) const responderJid = getKeyAuthor(message.key, meIdNormalised) const eventEncKey = eventMsg?.messageContextInfo?.messageSecret if (!eventEncKey) { logger?.warn({ creationMsgKey }, 'event response: missing messageSecret for decryption') } else { const responseMsg = decryptEventResponse(encEventResponse, { eventEncKey, eventCreatorJid, eventMsgId: creationMsgKey.id ?? '', responderJid }) const eventResponse = { eventResponseMessageKey: message.key, senderTimestampMs: responseMsg.timestampMs!, response: responseMsg } ev.emit('messages.update', [ { key: creationMsgKey, update: { eventResponses: [eventResponse] } } ]) } } catch (err) { logger?.warn({ err, creationMsgKey }, 'failed to decrypt event response') } } else { logger?.warn({ creationMsgKey }, 'event creation message not found, cannot decrypt response') } } else if (message.messageStubType) { const jid = message.key?.remoteJid! //let actor = whatsappID (message.participant) let participants: GroupParticipant[] const emitParticipantsUpdate = (action: ParticipantAction) => ev.emit('group-participants.update', { id: jid, author: message.key.participant!, authorPn: message.key.participantAlt!, participants, action }) const emitGroupUpdate = (update: Partial) => { ev.emit('groups.update', [ { id: jid, ...update, author: message.key.participant ?? undefined, authorPn: message.key.participantAlt } ]) } const emitGroupRequestJoin = (participant: LIDMapping, action: RequestJoinAction, method: RequestJoinMethod) => { ev.emit('group.join-request', { id: jid, author: message.key.participant!, authorPn: message.key.participantAlt!, participant: participant.lid, participantPn: participant.pn, action, method: method! }) } const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID switch (message.messageStubType) { case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER: participants = message.messageStubParameters.map((a: any) => JSON.parse(a as string)) || [] emitParticipantsUpdate('modify') break case WAMessageStubType.GROUP_PARTICIPANT_LEAVE: case WAMessageStubType.GROUP_PARTICIPANT_REMOVE: participants = message.messageStubParameters.map((a: any) => JSON.parse(a as string)) || [] emitParticipantsUpdate('remove') // mark the chat read only if you left the group if (participantsIncludesMe()) { chat.readOnly = true } break case WAMessageStubType.GROUP_PARTICIPANT_ADD: case WAMessageStubType.GROUP_PARTICIPANT_INVITE: case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN: participants = message.messageStubParameters.map((a: any) => JSON.parse(a as string)) || [] if (participantsIncludesMe()) { chat.readOnly = false } emitParticipantsUpdate('add') break case WAMessageStubType.GROUP_PARTICIPANT_DEMOTE: participants = message.messageStubParameters.map((a: any) => JSON.parse(a as string)) || [] emitParticipantsUpdate('demote') break case WAMessageStubType.GROUP_PARTICIPANT_PROMOTE: participants = message.messageStubParameters.map((a: any) => JSON.parse(a as string)) || [] emitParticipantsUpdate('promote') break case WAMessageStubType.GROUP_CHANGE_ANNOUNCE: const announceValue = message.messageStubParameters?.[0] emitGroupUpdate({ announce: announceValue === 'true' || announceValue === 'on' }) break case WAMessageStubType.GROUP_CHANGE_RESTRICT: const restrictValue = message.messageStubParameters?.[0] emitGroupUpdate({ restrict: restrictValue === 'true' || restrictValue === 'on' }) break case WAMessageStubType.GROUP_CHANGE_SUBJECT: const name = message.messageStubParameters?.[0] chat.name = name emitGroupUpdate({ subject: name }) break case WAMessageStubType.GROUP_CHANGE_DESCRIPTION: const description = message.messageStubParameters?.[0] chat.description = description emitGroupUpdate({ desc: description }) break case WAMessageStubType.GROUP_CHANGE_INVITE_LINK: const code = message.messageStubParameters?.[0] emitGroupUpdate({ inviteCode: code }) break case WAMessageStubType.GROUP_MEMBER_ADD_MODE: const memberAddValue = message.messageStubParameters?.[0] emitGroupUpdate({ memberAddMode: memberAddValue === 'all_member_add' }) break case WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_MODE: const approvalMode = message.messageStubParameters?.[0] emitGroupUpdate({ joinApprovalMode: approvalMode === 'on' }) break case WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD: // TODO: Add other events const participant = JSON.parse(message.messageStubParameters?.[0]) as LIDMapping const action = message.messageStubParameters?.[1] as RequestJoinAction const method = message.messageStubParameters?.[2] as RequestJoinMethod emitGroupRequestJoin(participant, action, method) break } } /* else if(content?.pollUpdateMessage) { const creationMsgKey = content.pollUpdateMessage.pollCreationMessageKey! // we need to fetch the poll creation message to get the poll enc key // TODO: make standalone, remove getMessage reference // TODO: Remove entirely const pollMsg = await getMessage(creationMsgKey) if(pollMsg) { const meIdNormalised = jidNormalizedUser(meId) const pollCreatorJid = getKeyAuthor(creationMsgKey, meIdNormalised) const voterJid = getKeyAuthor(message.key, meIdNormalised) const pollEncKey = pollMsg.messageContextInfo?.messageSecret! try { const voteMsg = decryptPollVote( content.pollUpdateMessage.vote!, { pollEncKey, pollCreatorJid, pollMsgId: creationMsgKey.id!, voterJid, } ) ev.emit('messages.update', [ { key: creationMsgKey, update: { pollUpdates: [ { pollUpdateMessageKey: message.key, vote: voteMsg, senderTimestampMs: (content.pollUpdateMessage.senderTimestampMs! as Long).toNumber(), } ] } } ]) } catch(err) { logger?.warn( { err, creationMsgKey }, 'failed to decrypt poll vote' ) } } else { logger?.warn( { creationMsgKey }, 'poll creation message not found, cannot decrypt update' ) } } */ if (Object.keys(chat).length > 1) { ev.emit('chats.update', [chat]) } } export default processMessage