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:
+44
-40
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user