fix: tctoken 463/479 — peer guard + retry logic

* feat: apply PR #2339 missing commits (ced3305 + fe5a37c)

Apply two Feb-19 commits from WhiskeySockets/Baileys PR #2339 that
were missing from the initial integration:

ced3305 — Prevent tctoken attachment to peer (AppStateSync) messages
- Add isPeerMessage guard: additionalAttributes?.['category'] === 'peer'
- Exclude peer messages from is1on1Send to fix error 479 (SmaxInvalid)
  which was causing hundreds of rejections on multi-device sync JIDs

fe5a37c — Error 463 retry logic + blocking→non-blocking refactor
- Replace blocking await getPrivacyTokens() with fire-and-forget .then/.catch
  so the send path is never delayed by a token fetch
- Add tcTokenRetriedMsgIds Set in messages-recv.ts to track retried msgIds
- On error 463 (MissingTcToken): wait 1.5s then relay the message once,
  allowing the server's tctoken notification to arrive before resend
- Add retry_463_ok log event to baileys-logger for observability
This commit is contained in:
Renato Alcara
2026-02-20 00:39:21 -03:00
committed by GitHub
parent 37e9a1ef39
commit 33cce2a6c2
3 changed files with 84 additions and 65 deletions
+32 -1
View File
@@ -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<string>()
const tcTokenRetriedMsgIds = new Set<string>()
let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | 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 {
+48 -63
View File
@@ -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<string>()
let mediaConn: Promise<MediaConnInfo>
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
+4 -1
View File
@@ -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<string, unknown>,
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
}
}