Files
InfiniteAPI/src/Utils/tc-token-utils.ts
T
Renato Alcara d991d0212d fix: add biz node injection and quality_control for carousel WhatsApp Web rendering (#291)
- Add interactive message detection helpers (getButtonType, isCarouselMessage, etc.)
- Inject biz node with interactive/native_flow for non-carousel interactive messages
- Inject biz + quality_control with decision_id for carousel messages
- Skip bot node for native_flow/carousel/catalog (breaks Web rendering)
- Force device-identity inclusion for carousel messages
- Append deferred biz/bot/quality_control nodes after device-identity and tctoken
- Store tctoken under both LID and PN for reliable lookup
2026-03-16 21:25:29 -03:00

178 lines
6.1 KiB
TypeScript

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' ? Number(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> {
const normalized = jidNormalizedUser(jid)
if (isLidUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
type TcTokenParams = {
jid: string
baseContent?: BinaryNode[]
authState: {
keys: SignalKeyStoreWithTransaction
}
getLIDForPN?: (pn: string) => Promise<string | null>
}
export async function buildTcTokenFromJid({
authState,
jid,
baseContent = [],
getLIDForPN
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
try {
const storageJid = getLIDForPN ? await resolveTcTokenJid(jid, getLIDForPN) : jid
const tcTokenData = await authState.keys.get('tctoken', [storageJid])
const entry = tcTokenData?.[storageJid]
const tcTokenBuffer = entry?.token
if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
// Opportunistic cleanup: remove expired token from store
if (tcTokenBuffer) {
await authState.keys.set({ tctoken: { [storageJid]: null } })
}
return baseContent.length > 0 ? baseContent : undefined
}
baseContent.push({
tag: 'tctoken',
attrs: {},
content: tcTokenBuffer
})
return baseContent
} 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
}
// Don't store timestamp-less tokens at all — isTcTokenExpired treats them
// as immediately expired regardless of whether an existing entry is present
if (!incomingTs) {
continue
}
const tokenEntry = {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t,
// Resets real_issue_timestamp to null when storing a new token
realIssueTimestamp: null
}
// Store under resolved storageJid AND under fallbackJid (PN) for reliable lookup
const normalizedFallback = jidNormalizedUser(fallbackJid)
const keysToStore: Record<string, typeof tokenEntry | null> = {
[storageJid]: tokenEntry
}
if (normalizedFallback !== storageJid) {
keysToStore[normalizedFallback] = tokenEntry
}
await keys.set({ tctoken: keysToStore })
onNewJidStored?.(storageJid)
}
}