d991d0212d
- 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
178 lines
6.1 KiB
TypeScript
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)
|
|
}
|
|
}
|