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
+60
View File
@@ -947,4 +947,64 @@ export function logLidMapping(
}
}
/**
* Log tctoken lifecycle events
*
* @example
* logTcToken('fetch', { jid: '5511999999999@s.whatsapp.net' })
* // Output: [BAILEYS] 🔑 TcToken fetch → 5511999999999@s.whatsapp.net
*
* logTcToken('expired', { jid: '5511999999999@s.whatsapp.net', age: '32d' })
* // 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',
data?: Record<string, unknown>,
sessionName?: string
): void {
if (!isBaileysLogEnabled()) return
const prefix = sessionName ? `[BAILEYS] [${sessionName}]` : '[BAILEYS]'
const jid = data?.jid ? `${data.jid}` : ''
const rest = data ? { ...data } : undefined
if (rest) delete rest.jid
const extraStr = rest && Object.keys(rest).length > 0 ? ' ' + formatLogData(rest) : ''
switch (event) {
case 'stored':
console.log(`${prefix} 🔑 TcToken stored${jid}${extraStr}`)
break
case 'expired':
console.log(`${prefix} 🔑 TcToken expired${jid}${extraStr}`)
break
case 'fetch':
console.log(`${prefix} 🔑 TcToken fetch${jid}${extraStr}`)
break
case 'fetched':
console.log(`${prefix} 🔑 TcToken fetched${jid}${extraStr}`)
break
case 'reissue':
console.log(`${prefix} 🔑 TcToken reissue${jid}${extraStr}`)
break
case 'reissue_ok':
console.log(`${prefix} 🔑 TcToken reissue OK${jid}${extraStr}`)
break
case 'reissue_fail':
console.log(`${prefix} 🔑 TcToken reissue failed${jid}${extraStr}`)
break
case 'prune':
console.log(`${prefix} 🔑 TcToken prune${extraStr}`)
break
case 'attached':
console.log(`${prefix} 🔑 TcToken attached${jid}${extraStr}`)
break
case 'error_463':
console.log(`${prefix} ⚠️ TcToken missing (463)${jid}${extraStr}`)
break
case 'error_479':
console.log(`${prefix} ⚠️ TcToken smax-invalid (479)${jid}${extraStr}`)
break
}
}
export default BaileysLogger
+7
View File
@@ -111,6 +111,13 @@ export const NACK_REASONS = {
CorruptedSession: 553
}
export const SERVER_ERROR_CODES = {
MissingTcToken: '463',
SmaxInvalid: '479',
StaleGroupAddressingMode: '421',
NewChatMessagesCapped: '475'
}
type MessageType =
| 'chat'
| 'peer_broadcast'
+12 -2
View File
@@ -366,12 +366,22 @@ const CODE_MAP: { [_: string]: DisconnectReason } = {
export const getErrorCodeFromStreamError = (node: BinaryNode) => {
const [reasonNode] = getAllBinaryNodeChildren(node)
let reason = reasonNode?.tag || 'unknown'
const statusCode = +(node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession)
if (statusCode === DisconnectReason.restartRequired) {
// device_removed is a specific conflict type that means full logout
if(reason === 'conflict' && reasonNode?.attrs?.type === 'device_removed') {
return { reason: 'device_removed', statusCode: DisconnectReason.loggedOut }
}
const statusCode = +(reasonNode?.attrs?.code || node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession)
if(statusCode === DisconnectReason.restartRequired) {
reason = 'restart required'
}
if(statusCode === DisconnectReason.sessionInvalidated) {
reason = 'session invalidated'
}
return {
reason,
statusCode
+132 -5
View File
@@ -1,5 +1,66 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
*
* Uses Receiver mode constants (tctoken_duration, tctoken_num_buckets).
* NOTE: WA Web distinguishes Sender vs Receiver mode via AB props
* (tctoken_duration_sender / tctoken_num_buckets_sender). Currently both
* use identical values (604800 / 4), so we use a single function for both.
* If WA ever diverges these, add a `mode` parameter here.
*/
export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean {
if(timestamp === null || timestamp === undefined) return true
const ts = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
if(isNaN(ts)) return true
const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1)
const cutoffTimestamp = cutoffBucket * TC_TOKEN_BUCKET_DURATION
return ts < cutoffTimestamp
}
/**
* Check if we should issue a new token to this contact (bucket boundary crossed).
* Reference: WAWebTrustedContactsUtils.shouldSendNewToken
*
* Returns true if senderTimestamp is null/undefined or in a previous bucket.
*/
export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean {
if(senderTimestamp === undefined) return true
const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION)
return currentBucket > senderBucket
}
/**
* Resolve a JID to its LID for tctoken storage, mirroring how Signal sessions
* use LID keys via resolveLIDSignalAddress.
*
* WA Web always resolves to LID before storing/looking up tctokens:
* `senderLid ?? toLid(from)` (WAWebSetTcTokenChatAction.handleIncomingTcToken)
*
* @param jid - The JID to resolve (can be PN or LID)
* @param getLIDForPN - Resolver function (from lidMapping)
* @returns The LID if mapping exists, otherwise the original JID
*/
export async function resolveTcTokenJid(
jid: string,
getLIDForPN: (pn: string) => Promise<string | null>
): Promise<string> {
if(isLidUser(jid)) return jid
const lid = await getLIDForPN(jid)
return lid ?? jid
}
type TcTokenParams = {
jid: string
@@ -7,19 +68,29 @@ type TcTokenParams = {
authState: {
keys: SignalKeyStoreWithTransaction
}
getLIDForPN?: (pn: string) => Promise<string | null>
}
export async function buildTcTokenFromJid({
authState,
jid,
baseContent = []
baseContent = [],
getLIDForPN
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
try {
const tcTokenData = await authState.keys.get('tctoken', [jid])
const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid
const tcTokenData = await authState.keys.get('tctoken', [storageJid])
const entry = tcTokenData?.[storageJid]
const tcTokenBuffer = entry?.token
const tcTokenBuffer = tcTokenData?.[jid]?.token
if(!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
// Opportunistic cleanup: remove expired token from store
if(tcTokenBuffer) {
await authState.keys.set({ tctoken: { [storageJid]: null } })
}
if (!tcTokenBuffer) return baseContent.length > 0 ? baseContent : undefined
return baseContent.length > 0 ? baseContent : undefined
}
baseContent.push({
tag: 'tctoken',
@@ -28,7 +99,63 @@ export async function buildTcTokenFromJid({
})
return baseContent
} catch (error) {
} catch(error) {
return baseContent.length > 0 ? baseContent : undefined
}
}
type StoreTcTokensParams = {
result: BinaryNode
fallbackJid: string
keys: SignalKeyStoreWithTransaction
getLIDForPN: (pn: string) => Promise<string | null>
/** Optional callback when a new JID is stored (for index tracking) */
onNewJidStored?: (jid: string) => void
}
/**
* Parse and store tctoken(s) from an IQ result node.
* Includes timestamp monotonicity guard matching WA Web's handleIncomingTcToken.
* Used by both the blocking fetch (messages-send) and IQ response (messages-recv) paths.
*/
export async function storeTcTokensFromIqResult({
result,
fallbackJid,
keys,
getLIDForPN,
onNewJidStored
}: StoreTcTokensParams) {
const tokensNode = getBinaryNodeChild(result, 'tokens')
if(!tokensNode) return
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
for(const tokenNode of tokenNodes) {
if(tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
continue
}
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]
// Timestamp monotonicity guard — only store if incoming timestamp >= existing
// Matches WA Web handleIncomingTcToken
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
continue
}
await keys.set({
tctoken: {
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t
}
}
})
onNewJidStored?.(storageJid)
}
}