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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user