chore: resolve merge conflict in messages-send.ts (comment-only)

Conflict was between Portuguese and English comment for phash/DSM block.
Kept HEAD (our) version.

Co-Authored-By: Renato Alcara
This commit is contained in:
Renato Alcara
2026-03-23 01:54:46 -03:00
18 changed files with 1868 additions and 526 deletions
+44 -40
View File
@@ -7,6 +7,7 @@ import { proto } from '../../WAProto/index.js'
import {
DEFAULT_CACHE_TTLS,
DEFAULT_SESSION_CLEANUP_CONFIG,
INITIAL_PREKEY_COUNT,
KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS,
@@ -48,9 +49,12 @@ import {
getStatusFromReceiptType,
handleIdentityChange,
hkdf,
BAD_MAC_ERROR_TEXT,
DECRYPTION_RETRY_CONFIG,
MISSING_KEYS_ERROR_TEXT,
NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT,
RetryReason,
normalizeKeyLidToPn,
normalizeMessageJids,
resolveLidToPn,
@@ -1212,7 +1216,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return await query(stanza)
}
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => {
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false, decryptionError?: string) => {
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
const { key: msgKey } = fullMessage
const msgId = msgKey.id!
@@ -1310,39 +1314,39 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
const fromJid = node.attrs.from!
// Check if we should recreate the session
let shouldRecreateSession = false
let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
try {
// Check if we have a session with this JID
const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
const hasSession = await signalRepository.validateSession(fromJid)
// Extract error code from retry node if present (for MAC error detection)
const retryNode = getBinaryNodeChild(node, 'retry')
const errorAttr = retryNode?.attrs?.error
const errorCode = messageRetryManager.parseRetryErrorCode(errorAttr)
const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists, errorCode)
shouldRecreateSession = result.recreate
recreateReason = result.reason
if (shouldRecreateSession) {
logger.debug({ fromJid, retryCount, reason: recreateReason, errorCode }, 'recreating session for retry')
// Delete existing session to force recreation
// CRITICAL: Use same transaction key as encrypt/decrypt operations to prevent race
// Using meId ensures this delete serializes with sendMessage() and other session operations
await authState.keys.transaction(async () => {
await authState.keys.set({ session: { [sessionId]: null } })
}, authState.creds.me?.id || 'session-operation')
forceIncludeKeys = true
}
} catch (error) {
logger.warn({ error, fromJid }, 'failed to check session recreation')
}
}
// Derive the Signal error code from the actual decryption failure message.
// Sent in the retry receipt so the peer (even another InfiniteAPI instance)
// knows the exact failure type and can recreate the session immediately
// instead of falling back to the 1-hour timeout.
//
// Codes mirror RetryReason enum in message-retry-manager.ts:
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey
// 3 = InvalidKeyId | 4 = InvalidMessage | 7 = BadMac
//
// Uses DECRYPTION_RETRY_CONFIG error lists (single source of truth in
// decode-wa-message.ts) so additions to those lists are picked up here
// automatically.
//
// NOTE: We do NOT delete the session here (receiver side). The Signal Protocol
// recovers automatically when the sender's pkmsg arrives — it overwrites the
// corrupted session. Deleting prematurely creates a race window where no session
// exists, which can cause "No Session" errors on concurrent messages.
const retryErrorCode = (() => {
if (!decryptionError) return RetryReason.UnknownError
// Bad MAC must be checked first — it is also in corruptedSessionErrors
// but warrants the more specific code 7 over the generic code 4.
if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac
// MessageCounterError and other corrupted-session variants
if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return RetryReason.SignalErrorInvalidMessage
// Missing / invalid session record
if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) ||
/no\s+(open\s+)?sessions?/i.test(decryptionError)) return RetryReason.SignalErrorNoSession
// PreKey / key-id errors
if (/pre\s*key/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKeyId
// Identity / key errors
if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKey
return RetryReason.UnknownError
})()
if (retryCount <= 2) {
// Use new retry manager for phone requests if available
@@ -1383,8 +1387,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
id: node.attrs.id!,
t: node.attrs.t!,
v: '1',
// ADD ERROR FIELD
error: '0'
error: retryErrorCode.toString()
}
},
{
@@ -1403,7 +1406,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
receipt.attrs.participant = node.attrs.participant
}
if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
if (retryCount > 1 || forceIncludeKeys) {
const { update, preKeys } = await getNextPreKeys(authState, 1)
const [keyId] = Object.keys(preKeys)
@@ -1440,7 +1443,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count')
if (shouldUploadMorePreKeys) {
await uploadPreKeys()
// Top-up back to INITIAL_PREKEY_COUNT so the pool is always restored to full size
await uploadPreKeys(Math.max(1, INITIAL_PREKEY_COUNT - count))
}
} else {
const result = await handleIdentityChange(node, {
@@ -2516,7 +2520,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
await sendRetryRequest(node, !encNode, errorMessage)
if (retryRequestDelayMs) {
await delay(retryRequestDelayMs)
}
@@ -2525,7 +2529,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Still attempt retry even if pre-key upload failed
try {
const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode)
await sendRetryRequest(node, !encNode, errorMessage)
} catch (retryErr) {
logger.error({ retryErr }, 'Failed to send retry after error handling')
}
+63 -2
View File
@@ -9,6 +9,7 @@ import type {
AlbumMessageOptions,
AlbumSendResult,
AnyMessageContent,
LIDMapping,
MediaConnInfo,
MessageReceiptType,
MessageRelayOptions,
@@ -338,6 +339,33 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// 4th LID→PN source: device-list entries sharing the same raw_id
// WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings
const allEntries = [...result.list, ...result.sideList]
const rawIdMap = new Map<number, { pn?: string; lid?: string }>()
for (const item of allEntries) {
if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue
const decoded = jidDecode(item.id)
if (!decoded) continue
const entry = rawIdMap.get(item.rawId) || {}
if (decoded.server === 'lid' || decoded.server === 'hosted.lid') {
entry.lid = item.id
} else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') {
entry.pn = item.id
}
rawIdMap.set(item.rawId, entry)
}
const rawIdMappings: LIDMapping[] = []
for (const { pn, lid } of rawIdMap.values()) {
if (pn && lid) {
rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) })
}
}
if (rawIdMappings.length > 0) {
await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings)
logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing')
}
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || ''
@@ -1024,7 +1052,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const bytes = encodeNewsletterMessage(patched as proto.IMessage)
binaryNodeContent.push({
tag: 'plaintext',
attrs: {},
attrs: mediaType ? { mediatype: mediaType } : {},
content: bytes
})
const stanza: BinaryNode = {
@@ -1252,7 +1280,31 @@ export const makeMessagesSocket = (config: SocketConfig) => {
: otherRecipients
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
await assertSessions(effectiveAllRecipients)
// P2: detect actual view-once media by checking inner message's viewOnce flag.
// viewOnceMessage wrapper is also used for interactive messages (buttons, lists, etc)
// which do NOT carry viewOnce=true on the media — those must NOT be filtered.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// For view-once: only send DSM to primary phone (device=0).
// Companion devices (device>0) are omitted — WA server generates
// <unavailable type="view_once"/> for them automatically.
// Sending explicit <unavailable> from a companion is rejected by the server.
const viewOnceMeRecipients = isViewOnceMsg
? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
: effectiveMeRecipients
// P3: assert sessions only for recipients we actually encrypt for.
// For view-once, companions are omitted — asserting their sessions is wasteful
// and could block the send if a companion session is corrupted.
await assertSessions([...viewOnceMeRecipients, ...effectiveOtherRecipients])
// Para view-once: separar dispositivos próprios por tipo.
// - device=0 (celular principal): recebe DSM normalmente — mostra "você enviou" na conversa.
@@ -1891,6 +1943,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
const getMediaType = (message: proto.IMessage) => {
// For view-once media, unwrap the viewOnceMessage wrapper before checking media type
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) {
return 'image'
} else if (message.videoMessage) {
+28 -8
View File
@@ -701,10 +701,12 @@ export const makeSocket = (config: SocketConfig) => {
}
}
// Prevent multiple concurrent uploads
// Prevent multiple concurrent uploads — if one is already running, wait for it and return:
// the concurrent upload already replenished the pool, so there is nothing left to do.
if (uploadPreKeysPromise) {
logger.debug('Pre-key upload already in progress, waiting for completion')
await uploadPreKeysPromise
return
}
const uploadLogic = async () => {
@@ -784,14 +786,15 @@ export const makeSocket = (config: SocketConfig) => {
try {
let count = 0
const preKeyCount = await getAvailablePreKeysOnServer()
if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT
else count = MIN_PREKEY_COUNT
// How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server
count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount)
const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
logger.info(`${preKeyCount} pre-keys found on server`)
logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`)
const lowServerCount = preKeyCount <= count
// Trigger upload when below the replenishment threshold, not when count < topUp amount
const lowServerCount = preKeyCount < MIN_PREKEY_COUNT
const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
const shouldUpload = lowServerCount || missingCurrentPreKey
@@ -1151,7 +1154,7 @@ export const makeSocket = (config: SocketConfig) => {
// Decrement active connections
decrementActiveConnections()
clearInterval(keepAliveReq)
clearTimeout(keepAliveReq)
clearTimeout(qrTimer)
// Clear offline-buffer safety timer so its callback cannot call ev.flush()
@@ -1262,8 +1265,16 @@ export const makeSocket = (config: SocketConfig) => {
})
}
const startKeepAliveRequest = () =>
(keepAliveReq = setInterval(() => {
const startKeepAliveRequest = () => {
// Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour
// (WA Business Desktop: ~25-30s intervals with natural variance)
const scheduleNextKeepAlive = () => {
const jitter = keepAliveIntervalMs * 0.15
const delay = keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter)
keepAliveReq = setTimeout(onKeepAliveTick, delay)
}
const onKeepAliveTick = () => {
if (!lastDateRecv) {
lastDateRecv = new Date()
}
@@ -1275,6 +1286,7 @@ export const makeSocket = (config: SocketConfig) => {
*/
if (diff > keepAliveIntervalMs + 5000) {
void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
return // connection closing — do not reschedule
} else if (ws.isOpen) {
// Send keep-alive ping via sendNode() (fire-and-forget) instead of query().
// query() wraps the ping in the query circuit breaker — when that breaker is
@@ -1297,7 +1309,15 @@ export const makeSocket = (config: SocketConfig) => {
} else {
logger.warn('keep alive called when WS not open')
}
}, keepAliveIntervalMs))
// Do not reschedule once shutdown has started (closed set by end() on any concurrent path)
if (!closed) {
scheduleNextKeepAlive()
}
}
scheduleNextKeepAlive()
}
/** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') =>
query({