diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 89c46805..1d584c3c 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -155,6 +155,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const TC_TOKEN_INDEX_KEY = '__index' const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts' const tcTokenKnownJids = new Set() + const tcTokenRetriedMsgIds = new Set() let tcTokenIndexSaveTimer: ReturnType | undefined let lastTcTokenPruneTs = 0 @@ -1876,7 +1877,37 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // device could not display the message if(attrs.error) { if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) { - logTcToken('error_463', { jid: attrs.from, msgId: attrs.id }) + const msgId = attrs.id + const jid = attrs.from + logTcToken('error_463', { jid, msgId }) + + // Single-retry: wait 1.5s for the server's tctoken notification to arrive, + // then resend. A Set prevents infinite retry loops. + if(msgId && jid && !tcTokenRetriedMsgIds.has(msgId)) { + tcTokenRetriedMsgIds.add(msgId) + // Safety cap — prevent unbounded memory growth + if(tcTokenRetriedMsgIds.size > 500) { + tcTokenRetriedMsgIds.clear() + } + + ;(async () => { + try { + await delay(1500) + const msg = await getMessage(key) + if(msg) { + await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true }) + logTcToken('retry_463_ok', { jid, msgId }) + } else { + logger.warn({ jid, msgId }, '463 retry: message not found in store') + ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }]) + } + } catch(err: any) { + logger.warn({ jid, msgId, err: err?.message }, '463 retry failed') + ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }]) + } + })() + return + } } else if(attrs.error === SERVER_ERROR_CODES.SmaxInvalid) { logTcToken('error_479', { jid: attrs.from, msgId: attrs.id }) } else { diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index edf340b1..303ccb8d 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -119,6 +119,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { // Prevent race conditions in Signal session encryption by user const encryptionMutex = makeKeyedMutex() + // Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches + const tcTokenFetchingJids = new Set() + let mediaConn: Promise const refreshMediaConn = async (forceGet = false) => { const media = await mediaConn @@ -1482,8 +1485,9 @@ export const makeMessagesSocket = (config: SocketConfig) => { } // tctoken lifecycle: fetch, validate expiry, proactive re-fetch if missing/expired - const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter - let didFetchTcToken = false + // WA Web never attaches tctoken to peer (AppStateSync) messages — server + const isPeerMessage = additionalAttributes?.['category'] === 'peer' + const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage // Resolve destination to LID for tctoken storage — matches Signal session key pattern const tcTokenJid = is1on1Send @@ -1506,69 +1510,50 @@ export const makeMessagesSocket = (config: SocketConfig) => { } catch { /* ignore cleanup errors */ } } - // If tctoken is missing or expired for a 1:1 send, proactively fetch it from the server - if(!tcTokenBuffer?.length && is1on1Send) { - try { - logTcToken('fetch', { jid: destinationJid }) - didFetchTcToken = true - const fetchResult = await getPrivacyTokens([destinationJid]) - - // Parse inline tokens from IQ result using the shared parser - // (includes monotonicity guard) - await storeTcTokensFromIqResult({ - result: fetchResult, - fallbackJid: destinationJid, - keys: authState.keys, - getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping), - onNewJidStored: (storedJid) => { - // Fire-and-forget: persist JID into tctoken index for pruning - (async () => { - try { - const TC_IDX = '__index' - const idxData = await authState.keys.get('tctoken', [TC_IDX]) - const idxEntry = idxData[TC_IDX] - const existing: string[] = idxEntry?.token - ? JSON.parse(Buffer.from(idxEntry.token).toString('utf8')) - : [] - if(!existing.includes(storedJid)) { - existing.push(storedJid) - await authState.keys.set({ - tctoken: { - [TC_IDX]: { - token: Buffer.from(JSON.stringify(existing), 'utf8'), - timestamp: unixTimestampSeconds().toString() + // If tctoken is missing for a 1:1 send, fire-and-forget fetch so the + // retry path (error 463 → handleBadAck) can pick it up on resend + if(!tcTokenBuffer?.length && is1on1Send && !tcTokenFetchingJids.has(tcTokenJid)) { + tcTokenFetchingJids.add(tcTokenJid) + logTcToken('fetch', { jid: destinationJid }) + getPrivacyTokens([destinationJid]) + .then(async fetchResult => { + await storeTcTokensFromIqResult({ + result: fetchResult, + fallbackJid: destinationJid, + keys: authState.keys, + getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping), + onNewJidStored: (storedJid) => { + // Fire-and-forget: persist JID into tctoken index for pruning + (async () => { + try { + const TC_IDX = '__index' + const idxData = await authState.keys.get('tctoken', [TC_IDX]) + const idxEntry = idxData[TC_IDX] + const existing: string[] = idxEntry?.token + ? JSON.parse(Buffer.from(idxEntry.token).toString('utf8')) + : [] + if(!existing.includes(storedJid)) { + existing.push(storedJid) + await authState.keys.set({ + tctoken: { + [TC_IDX]: { + token: Buffer.from(JSON.stringify(existing), 'utf8'), + timestamp: unixTimestampSeconds().toString() + } } - } - }) - } + }) + } } catch { /* non-critical — index rebuilt on next pruning cycle */ } - })() - } - }) - - // Re-read from key store — the notification handler or inline - // parsing above may have stored the token - const refreshed = await authState.keys.get('tctoken', [tcTokenJid]) - const refreshedEntry = refreshed[tcTokenJid] - tcTokenBuffer = refreshedEntry?.token - - // The getPrivacyTokens IQ (type='set') also acts as issuance, - // so record senderTimestamp to prevent redundant fire-and-forget - // on the next message to this contact. - if(refreshedEntry?.token?.length) { - logTcToken('fetched', { jid: destinationJid }) - await authState.keys.set({ - tctoken: { - [tcTokenJid]: { - ...refreshedEntry, - senderTimestamp: unixTimestampSeconds() - } + })() } }) - } - } catch(err: any) { - logger.warn({ jid: destinationJid, trace: err?.stack }, 'failed to fetch privacy token before send') - } + }) + .catch(err => { + logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed') + }) + .finally(() => { + tcTokenFetchingJids.delete(tcTokenJid) + }) } if(tcTokenBuffer?.length) { @@ -1660,8 +1645,8 @@ export const makeMessagesSocket = (config: SocketConfig) => { await sendNode(stanza) // Fire-and-forget: issue our token to the contact (like WA Web's sendTcToken) - // Only for 1:1 sends where we didn't already fetch, and only when bucket boundary crossed - if(is1on1Send && !didFetchTcToken && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) { + // Only for 1:1 sends where we already have a token, and when bucket boundary crossed + if(is1on1Send && tcTokenBuffer?.length && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) { const issueTimestamp = unixTimestampSeconds() logTcToken('reissue', { jid: destinationJid }) // WA Web writes senderTimestamp only AFTER the IQ succeeds diff --git a/src/Utils/baileys-logger.ts b/src/Utils/baileys-logger.ts index 80eb2a1a..001ce99b 100644 --- a/src/Utils/baileys-logger.ts +++ b/src/Utils/baileys-logger.ts @@ -958,7 +958,7 @@ export function logLidMapping( * // Output: [BAILEYS] 🔑 TcToken expired → 5511999999999@s.whatsapp.net { age: 32d } */ export function logTcToken( - event: 'stored' | 'expired' | 'fetch' | 'fetched' | 'reissue' | 'reissue_ok' | 'reissue_fail' | 'prune' | 'error_463' | 'error_479' | 'attached', + event: 'stored' | 'expired' | 'fetch' | 'fetched' | 'reissue' | 'reissue_ok' | 'reissue_fail' | 'prune' | 'error_463' | 'error_479' | 'attached' | 'retry_463_ok', data?: Record, sessionName?: string ): void { @@ -1004,6 +1004,9 @@ export function logTcToken( case 'error_479': console.log(`${prefix} ⚠️ TcToken smax-invalid (479)${jid}${extraStr}`) break + case 'retry_463_ok': + console.log(`${prefix} 🔄 TcToken retry 463 OK${jid}${extraStr}`) + break } }