edc5b317ff
Addresses GitHub Copilot review feedback from PR #148: ## Critical Fix - Cache Key Mismatch (Issue #1 & #2): **Problem:** Metadata was stored using `messageKey.id` but retrieved using PDO response `stanzaId`, causing cache lookups to always fail. This completely broke the metadata preservation system. **Root Cause:** - Store: `cache.set(messageKey.id, metadata)` ← message ID - Retrieve: `cache.get(response.stanzaId)` ← PDO request ID - These are DIFFERENT IDs, so metadata was NEVER recovered **Solution (messages-recv.ts:167-217):** 1. Use message ID temporarily to prevent duplicate requests 2. Send PDO and obtain stanzaId (PDO request ID) 3. Store metadata using stanzaId as key (matches response lookup) 4. Clean up temporary message ID marker 5. Timeout cleanup now uses stanzaId **Flow Now:** ``` 1. Check duplicate: cache.get(messageKey.id) → prevents spam 2. Mark as requested: cache.set(messageKey.id, true) 3. Send PDO → returns stanzaId 4. Store metadata: cache.set(stanzaId, metadata) ← CRITICAL FIX 5. Clean marker: cache.del(messageKey.id) 6. Response arrives: cache.get(stanzaId) ← NOW WORKS! ✅ ``` ## Type Consolidation (Issue #4): **Problem:** `PlaceholderMessageData` defined separately in both messages-recv.ts and process-message.ts (as CachedMessageData). **Solution:** - Consolidated into single shared type in src/Types/Message.ts - Exported via src/Types/index.ts - Both files now import from shared location - Prevents definition drift and improves maintainability ## Files Changed: - src/Socket/messages-recv.ts: * Fixed cache key logic to use stanzaId * Import PlaceholderMessageData from Types * Added detailed logging for cache operations - src/Utils/process-message.ts: * Import PlaceholderMessageData from Types * Removed local CachedMessageData definition - src/Types/Message.ts: * Added shared PlaceholderMessageData type * Added Long import for timestamp type ## Impact: - ✅ Metadata preservation NOW WORKS (was completely broken) - ✅ pushName will be preserved in CTWA messages - ✅ participantAlt (LID) will be maintained in groups - ✅ No more orphaned cache entries - ✅ Type safety improved with shared definition ## Testing Note: Issue #3 (missing test coverage) acknowledged but deferred to separate PR to avoid blocking critical bugfix. https://claude.ai/code/session_01TvSrN9JmHZDdKMZDFWEcUS
839 lines
27 KiB
TypeScript
839 lines
27 KiB
TypeScript
import Long from 'long'
|
|
import { proto } from '../../WAProto/index.js'
|
|
import type {
|
|
AuthenticationCreds,
|
|
BaileysEventEmitter,
|
|
CacheStore,
|
|
Chat,
|
|
GroupMetadata,
|
|
GroupParticipant,
|
|
LIDMapping,
|
|
ParticipantAction,
|
|
PlaceholderMessageData,
|
|
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
|
|
if (isHostedPnUser(message.key.remoteJid!) || isHostedLidUser(message.key.remoteJid!)) {
|
|
message.key.remoteJid = jidEncode(
|
|
jidDecode(message.key?.remoteJid!)?.user!,
|
|
isHostedPnUser(message.key.remoteJid!) ? 's.whatsapp.net' : 'lid'
|
|
)
|
|
} else {
|
|
message.key.remoteJid = jidNormalizedUser(message.key.remoteJid!)
|
|
}
|
|
|
|
if (isHostedPnUser(message.key.participant!) || isHostedLidUser(message.key.participant!)) {
|
|
message.key.participant = jidEncode(
|
|
jidDecode(message.key.participant!)?.user!,
|
|
isHostedPnUser(message.key.participant!) ? 's.whatsapp.net' : 'lid'
|
|
)
|
|
} else {
|
|
message.key.participant = jidNormalizedUser(message.key.participant!)
|
|
}
|
|
|
|
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<void> => {
|
|
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => {
|
|
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) => {
|
|
if (isJidBroadcast(remoteJid!) && !isJidStatusBroadcast(remoteJid!) && !fromMe) {
|
|
return participant!
|
|
}
|
|
|
|
return remoteJid!
|
|
}
|
|
|
|
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) {
|
|
logger?.warn({ messageKey: message.key }, 'processMessage: creds.me not set, skipping message')
|
|
return
|
|
}
|
|
|
|
const meId = meUser.id
|
|
const { accountSettings } = creds
|
|
|
|
const chat: Partial<Chat> = { 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) {
|
|
// Retrieve cached metadata BEFORE deletion
|
|
// This preserves original message details that the phone might not send
|
|
const cachedData = response.stanzaId
|
|
? await placeholderResendCache?.get<PlaceholderMessageData | boolean>(response.stanzaId)
|
|
: undefined
|
|
|
|
// Clean up cache after retrieving data
|
|
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)
|
|
|
|
// Merge cached metadata with decoded message
|
|
// This ensures we don't lose critical information like pushName and LID mappings
|
|
if (cachedData && typeof cachedData === 'object') {
|
|
// Preserve pushName if not present in PDO response
|
|
if (cachedData.pushName && !webMessageInfo.pushName) {
|
|
webMessageInfo.pushName = cachedData.pushName
|
|
logger?.debug(
|
|
{ msgId: webMessageInfo.key?.id },
|
|
'CTWA: Restored pushName from cached metadata'
|
|
)
|
|
}
|
|
|
|
// Preserve participantAlt (LID) if not present in PDO response
|
|
// This is critical for maintaining LID/PN mapping in groups
|
|
if (cachedData.participantAlt && webMessageInfo.key) {
|
|
const msgKey = webMessageInfo.key as WAMessageKey
|
|
if (!msgKey.participantAlt) {
|
|
msgKey.participantAlt = cachedData.participantAlt
|
|
logger?.debug(
|
|
{ msgId: webMessageInfo.key?.id, participantAlt: cachedData.participantAlt },
|
|
'CTWA: Restored participantAlt (LID) from cached metadata'
|
|
)
|
|
}
|
|
}
|
|
|
|
// Preserve original participant if not in PDO response
|
|
if (cachedData.participant && webMessageInfo.key && !webMessageInfo.key.participant) {
|
|
webMessageInfo.key.participant = cachedData.participant
|
|
logger?.debug(
|
|
{ msgId: webMessageInfo.key?.id },
|
|
'CTWA: Restored participant from cached metadata'
|
|
)
|
|
}
|
|
|
|
// Only use cached timestamp if PDO response doesn't have one
|
|
// PDO response timestamp is more authoritative if present
|
|
if (!webMessageInfo.messageTimestamp && cachedData.messageTimestamp) {
|
|
webMessageInfo.messageTimestamp = cachedData.messageTimestamp
|
|
}
|
|
}
|
|
|
|
// Track CTWA message recovery success
|
|
recoveredCount++
|
|
logger?.info(
|
|
{
|
|
msgId: webMessageInfo.key?.id,
|
|
remoteJid: webMessageInfo.key?.remoteJid,
|
|
requestId: response.stanzaId,
|
|
hasMetadata: !!cachedData && typeof cachedData === 'object'
|
|
},
|
|
'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) {
|
|
logger?.warn({ messageKey: message.key }, 'processMessage: reactionMessage.key missing, skipping')
|
|
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) {
|
|
logger?.warn({ messageKey: message.key }, 'processMessage: eventCreationMessageKey missing, skipping')
|
|
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) {
|
|
logger?.warn({ messageKey: message.key, eventCreatorKey }, 'processMessage: eventCreatorPn missing, skipping')
|
|
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<GroupMetadata>) => {
|
|
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
|