feat: integrate tctoken lifecycle with expiration, pruning and re-issuance (PR #2339)

Surgical integration of WhiskeySockets/Baileys PR #2339 preserving all
InfiniteAPI customizations (biz nodes, DSM skip, carousel, Prometheus,
circuit breaker, LID mapping, CTWA recovery, identity debounce).

Changes:
- tc-token-utils.ts: rolling bucket expiration (28d/4 buckets), LID
  resolution, monotonicity guard, storeTcTokensFromIqResult parser
- messages-send.ts: proactive fetch if missing/expired, fire-and-forget
  re-issuance on bucket boundary, getPrivacyTokens timestamp param
- messages-recv.ts: persistent JID index for cross-session pruning,
  pruneExpiredTcTokens on connect (max 1x/24h), session_refreshed
  re-issuance, error 463/479 handling in handleBadAck
- chats.ts: self-detection in profilePictureUrl, LID resolver in
  presenceSubscribe
- socket.ts: granular stream error logging (device_removed, xml, ack)
- Auth.ts: senderTimestamp field on tctoken type
- Types/index.ts: sessionInvalidated = 516 disconnect reason
- decode-wa-message.ts: SERVER_ERROR_CODES constant (463, 479, 421, 475)
- generics.ts: enhanced getErrorCodeFromStreamError with device_removed
- baileys-logger.ts: logTcToken function following [BAILEYS] prefix pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Renato Alcara
2026-02-15 20:53:27 -03:00
parent 938c6aaa49
commit 08793904e2
10 changed files with 506 additions and 55 deletions
+107 -8
View File
@@ -39,11 +39,17 @@ import {
parseAndInjectE2ESessions,
unixTimestampSeconds
} from '../Utils'
import { logMessageSent } from '../Utils/baileys-logger'
import { logMessageSent, logTcToken } from '../Utils/baileys-logger'
import { getUrlInfo } from '../Utils/link-preview'
import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import {
isTcTokenExpired,
resolveTcTokenJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult
} from '../Utils/tc-token-utils'
import {
areJidsSameUser,
type BinaryNode,
@@ -1475,18 +1481,79 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// Working carousel includes tctoken in stanza
const contactTcTokenData =
!isGroup && !isRetryResend && !isStatus ? await authState.keys.get('tctoken', [destinationJid]) : {}
// tctoken lifecycle: fetch, validate expiry, proactive re-fetch if missing/expired
const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter
let didFetchTcToken = false
const tcTokenBuffer = contactTcTokenData[destinationJid]?.token
// Resolve destination to LID for tctoken storage — matches Signal session key pattern
const tcTokenJid = is1on1Send
? await resolveTcTokenJid(
destinationJid,
signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
)
: destinationJid
const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {}
const existingTokenEntry = contactTcTokenData[tcTokenJid]
let tcTokenBuffer = existingTokenEntry?.token
if (tcTokenBuffer) {
// Treat expired tokens the same as missing — re-fetch from server
if(tcTokenBuffer?.length && isTcTokenExpired(existingTokenEntry?.timestamp)) {
logTcToken('expired', { jid: destinationJid, timestamp: existingTokenEntry?.timestamp })
tcTokenBuffer = undefined
// Opportunistic cleanup: remove expired token from store
try {
await authState.keys.set({ tctoken: { [tcTokenJid]: null } })
} 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)
})
// 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')
}
}
if(tcTokenBuffer?.length) {
;(stanza.content as BinaryNode[]).push({
tag: 'tctoken',
attrs: {},
content: tcTokenBuffer
})
logTcToken('attached', { jid: destinationJid })
}
if (additionalNodes && additionalNodes.length > 0) {
@@ -1568,6 +1635,38 @@ 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)) {
const issueTimestamp = unixTimestampSeconds()
logTcToken('reissue', { jid: destinationJid })
// WA Web writes senderTimestamp only AFTER the IQ succeeds
// (WAWebSendTcTokenChatAction.sendTcToken).
// This ensures failed issuance allows re-issuance on the next message
// rather than blocking it for up to 7 days (one bucket duration).
getPrivacyTokens([destinationJid], issueTimestamp)
.then(async () => {
// Re-read entry to avoid overwriting concurrent notification handler updates
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
if(currentEntry?.token?.length) {
await authState.keys.set({
tctoken: {
[tcTokenJid]: {
...currentEntry,
senderTimestamp: issueTimestamp
}
}
})
}
logTcToken('reissue_ok', { jid: destinationJid })
})
.catch(err => {
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
})
}
// Log with [BAILEYS] prefix
logMessageSent(msgId, destinationJid)
@@ -1677,8 +1776,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return ''
}
const getPrivacyTokens = async (jids: string[]) => {
const t = unixTimestampSeconds().toString()
const getPrivacyTokens = async (jids: string[], timestamp?: number) => {
const t = (timestamp ?? unixTimestampSeconds()).toString()
const result = await query({
tag: 'iq',
attrs: {