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:
+27
-4
@@ -50,6 +50,8 @@ import {
|
||||
type BinaryNode,
|
||||
getBinaryNodeChild,
|
||||
getBinaryNodeChildren,
|
||||
isLidUser,
|
||||
isPnUser,
|
||||
jidDecode,
|
||||
jidNormalizedUser,
|
||||
reduceBinaryNodeToDictionary,
|
||||
@@ -695,9 +697,26 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
|
||||
const baseContent: BinaryNode[] = [{ tag: 'picture', attrs: { type, query: 'url' } }]
|
||||
|
||||
const tcTokenContent = await buildTcTokenFromJid({ authState, jid, baseContent })
|
||||
// WA Web only includes tctoken for user JIDs (not groups/newsletters)
|
||||
// and never for own profile pic (Chat model for self has no tcToken).
|
||||
// Including tctoken for own JID causes the server to never respond.
|
||||
const normalizedJid = jidNormalizedUser(jid)
|
||||
const isUserJid = isPnUser(normalizedJid) || isLidUser(normalizedJid)
|
||||
const me = authState.creds.me
|
||||
const isSelf =
|
||||
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
|
||||
let content: BinaryNode[] | undefined = baseContent
|
||||
|
||||
jid = jidNormalizedUser(jid)
|
||||
if(isUserJid && !isSelf) {
|
||||
content = await buildTcTokenFromJid({
|
||||
authState,
|
||||
jid: normalizedJid,
|
||||
baseContent,
|
||||
getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
|
||||
})
|
||||
}
|
||||
|
||||
jid = normalizedJid
|
||||
const result = await query(
|
||||
{
|
||||
tag: 'iq',
|
||||
@@ -707,7 +726,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
type: 'get',
|
||||
xmlns: 'w:profile:picture'
|
||||
},
|
||||
content: tcTokenContent
|
||||
content
|
||||
},
|
||||
timeoutMs
|
||||
)
|
||||
@@ -799,7 +818,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
||||
* @param tcToken token for subscription, use if present
|
||||
*/
|
||||
const presenceSubscribe = async (toJid: string) => {
|
||||
const tcTokenContent = await buildTcTokenFromJid({ authState, jid: toJid })
|
||||
const tcTokenContent = await buildTcTokenFromJid({
|
||||
authState,
|
||||
jid: toJid,
|
||||
getLIDForPN: signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
|
||||
})
|
||||
|
||||
return sendNode({
|
||||
tag: 'presence',
|
||||
|
||||
+148
-32
@@ -47,13 +47,14 @@ import {
|
||||
MISSING_KEYS_ERROR_TEXT,
|
||||
NACK_REASONS,
|
||||
NO_MESSAGE_FOUND_ERROR_TEXT,
|
||||
SERVER_ERROR_CODES,
|
||||
normalizeMessageJids,
|
||||
toNumber,
|
||||
unixTimestampSeconds,
|
||||
xmppPreKey,
|
||||
xmppSignedPreKey
|
||||
} from '../Utils'
|
||||
import { logMessageReceived } from '../Utils/baileys-logger'
|
||||
import { logMessageReceived, logTcToken } from '../Utils/baileys-logger'
|
||||
import { makeMutex } from '../Utils/make-mutex'
|
||||
import {
|
||||
metrics,
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
jidNormalizedUser,
|
||||
S_WHATSAPP_NET
|
||||
} from '../WABinary'
|
||||
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
|
||||
import { extractGroupMetadata } from './groups'
|
||||
import { makeMessagesSocket } from './messages-send'
|
||||
|
||||
@@ -117,7 +119,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
sendReceipt,
|
||||
uploadPreKeys,
|
||||
sendPeerDataOperationMessage,
|
||||
messageRetryManager
|
||||
messageRetryManager,
|
||||
getPrivacyTokens
|
||||
} = sock
|
||||
|
||||
/** this mutex ensures that each retryRequest will wait for the previous one to finish */
|
||||
@@ -148,6 +151,78 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
|
||||
let sendActiveReceipts = false
|
||||
|
||||
// ======= tctoken index tracking for cross-session pruning =======
|
||||
const TC_TOKEN_INDEX_KEY = '__index'
|
||||
const tcTokenKnownJids = new Set<string>()
|
||||
let tcTokenIndexSaveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let lastTcTokenPruneTs = 0
|
||||
|
||||
// Load persisted JID index on startup
|
||||
const tcTokenIndexLoaded = (async () => {
|
||||
try {
|
||||
const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
|
||||
const entry = data[TC_TOKEN_INDEX_KEY]
|
||||
if(entry?.token) {
|
||||
const stored = JSON.parse(Buffer.from(entry.token).toString('utf8'))
|
||||
if(Array.isArray(stored)) {
|
||||
for(const jid of stored) tcTokenKnownJids.add(jid)
|
||||
}
|
||||
}
|
||||
} catch { /* first run or corrupt index — start fresh */ }
|
||||
})()
|
||||
|
||||
/** Debounced save of the tctoken JID index (5s) */
|
||||
const scheduleTcTokenIndexSave = () => {
|
||||
if(tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
|
||||
tcTokenIndexSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const arr = Array.from(tcTokenKnownJids)
|
||||
await authState.keys.set({
|
||||
tctoken: {
|
||||
[TC_TOKEN_INDEX_KEY]: {
|
||||
token: Buffer.from(JSON.stringify(arr), 'utf8'),
|
||||
timestamp: unixTimestampSeconds().toString()
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch(err) {
|
||||
logger.debug({ err }, 'failed to persist tctoken index')
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
/** Delete expired tctokens — runs at most once per 24h when coming online */
|
||||
const pruneExpiredTcTokens = async () => {
|
||||
await tcTokenIndexLoaded
|
||||
const pruneSet: Record<string, null> = {}
|
||||
const survivingJids: string[] = []
|
||||
|
||||
for(const jid of tcTokenKnownJids) {
|
||||
if(jid === TC_TOKEN_INDEX_KEY) continue
|
||||
try {
|
||||
const data = await authState.keys.get('tctoken', [jid])
|
||||
const entry = data[jid]
|
||||
if(!entry?.token || isTcTokenExpired(entry.timestamp)) {
|
||||
pruneSet[jid] = null
|
||||
} else {
|
||||
survivingJids.push(jid)
|
||||
}
|
||||
} catch {
|
||||
pruneSet[jid] = null
|
||||
}
|
||||
}
|
||||
|
||||
const pruneCount = Object.keys(pruneSet).length
|
||||
if(pruneCount > 0) {
|
||||
await authState.keys.set({ tctoken: pruneSet })
|
||||
tcTokenKnownJids.clear()
|
||||
for(const jid of survivingJids) tcTokenKnownJids.add(jid)
|
||||
scheduleTcTokenIndexSave()
|
||||
logTcToken('prune', { pruned: pruneCount, remaining: survivingJids.length })
|
||||
}
|
||||
}
|
||||
// ======= END tctoken index tracking =======
|
||||
|
||||
const fetchMessageHistory = async (
|
||||
count: number,
|
||||
oldestMsgKey: WAMessageKey,
|
||||
@@ -633,7 +708,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
logger
|
||||
})
|
||||
|
||||
if (result.action === 'no_identity_node') {
|
||||
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
|
||||
if(result.action === 'session_refreshed') {
|
||||
const normalizedJid = jidNormalizedUser(from)
|
||||
resolveTcTokenJid(
|
||||
normalizedJid,
|
||||
signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
|
||||
).then(async (tcJid) => {
|
||||
const tcData = await authState.keys.get('tctoken', [tcJid])
|
||||
const entry = tcData[tcJid]
|
||||
if(entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
|
||||
const senderTs = unixTimestampSeconds()
|
||||
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
|
||||
getPrivacyTokens([normalizedJid], senderTs).catch(err => {
|
||||
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
|
||||
})
|
||||
}
|
||||
}).catch(() => { /* ignore resolution errors */ })
|
||||
}
|
||||
|
||||
if(result.action === 'no_identity_node') {
|
||||
logger.info({ node }, 'unknown encrypt notification')
|
||||
}
|
||||
}
|
||||
@@ -958,28 +1052,45 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
const tokensNode = getBinaryNodeChild(node, 'tokens')
|
||||
const from = jidNormalizedUser(node.attrs.from)
|
||||
|
||||
if (!tokensNode) return
|
||||
if(!tokensNode) return
|
||||
|
||||
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
|
||||
const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
|
||||
|
||||
for (const tokenNode of tokenNodes) {
|
||||
for(const tokenNode of tokenNodes) {
|
||||
const { attrs, content } = tokenNode
|
||||
const type = attrs.type
|
||||
const timestamp = attrs.t
|
||||
|
||||
if (type === 'trusted_contact' && content instanceof Buffer) {
|
||||
logger.debug(
|
||||
{
|
||||
from,
|
||||
timestamp,
|
||||
tcToken: content
|
||||
},
|
||||
'received trusted contact token'
|
||||
)
|
||||
if(type === 'trusted_contact' && content instanceof Uint8Array) {
|
||||
// Resolve to LID for consistent storage key
|
||||
const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined
|
||||
const storageJid = senderLid || await resolveTcTokenJid(from, getLIDForPN)
|
||||
|
||||
// Timestamp monotonicity guard — only store if incoming >= existing
|
||||
const existingData = await authState.keys.get('tctoken', [storageJid])
|
||||
const existing = existingData[storageJid]
|
||||
const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0
|
||||
const incomingTs = timestamp ? Number(timestamp) : 0
|
||||
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
|
||||
continue
|
||||
}
|
||||
|
||||
await authState.keys.set({
|
||||
tctoken: { [from]: { token: content, timestamp } }
|
||||
tctoken: {
|
||||
[storageJid]: {
|
||||
...existing,
|
||||
token: Buffer.from(content),
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logTcToken('stored', { jid: storageJid, from })
|
||||
|
||||
// Track JID for cross-session pruning
|
||||
tcTokenKnownJids.add(storageJid)
|
||||
scheduleTcTokenIndexSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1755,8 +1866,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
|
||||
// error in acknowledgement,
|
||||
// device could not display the message
|
||||
if (attrs.error) {
|
||||
logger.warn({ attrs }, 'received error in ack')
|
||||
if(attrs.error) {
|
||||
if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) {
|
||||
logTcToken('error_463', { jid: attrs.from, msgId: attrs.id })
|
||||
} else if(attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
|
||||
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
|
||||
} else {
|
||||
logger.warn({ attrs }, 'received error in ack')
|
||||
}
|
||||
|
||||
ev.emit('messages.update', [
|
||||
{
|
||||
key,
|
||||
@@ -1766,20 +1884,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// resend the message with device_fanout=false, use at your own risk
|
||||
// if (attrs.error === '475') {
|
||||
// const msg = await getMessage(key)
|
||||
// if (msg) {
|
||||
// await relayMessage(key.remoteJid!, msg, {
|
||||
// messageId: key.id!,
|
||||
// useUserDevicesCache: false,
|
||||
// additionalAttributes: {
|
||||
// device_fanout: 'false'
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1936,9 +2040,21 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
||||
})
|
||||
|
||||
ev.on('connection.update', ({ isOnline }) => {
|
||||
if (typeof isOnline !== 'undefined') {
|
||||
if(typeof isOnline !== 'undefined') {
|
||||
sendActiveReceipts = isOnline
|
||||
logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`)
|
||||
|
||||
// Prune expired tctokens when coming online (max once per 24h)
|
||||
if(isOnline) {
|
||||
const now = Date.now()
|
||||
const ONE_DAY_MS = 86400000
|
||||
if(now - lastTcTokenPruneTs > ONE_DAY_MS) {
|
||||
lastTcTokenPruneTs = now
|
||||
pruneExpiredTcTokens().catch(err => {
|
||||
logger.debug({ err: err?.message }, 'tctoken pruning failed')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+107
-8
@@ -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: {
|
||||
|
||||
+10
-2
@@ -1529,10 +1529,18 @@ export const makeSocket = (config: SocketConfig) => {
|
||||
|
||||
ws.on('CB:stream:error', (node: BinaryNode) => {
|
||||
const [reasonNode] = getAllBinaryNodeChildren(node)
|
||||
logger.error({ reasonNode, fullErrorNode: node }, 'stream errored out')
|
||||
|
||||
const { reason, statusCode } = getErrorCodeFromStreamError(node)
|
||||
|
||||
if(reason === 'device_removed') {
|
||||
logger.error({ node }, 'stream error: device removed — logging out')
|
||||
} else if(reason === 'xml-not-well-formed') {
|
||||
logger.warn({ node }, 'stream error: sent malformed stanza (xml-not-well-formed)')
|
||||
} else if(reason === 'ack') {
|
||||
logger.warn({ ackId: reasonNode?.attrs?.id, node }, 'stream error: ack-based error')
|
||||
} else {
|
||||
logger.error({ reason, statusCode, node }, 'stream errored out')
|
||||
}
|
||||
|
||||
void end(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }))
|
||||
})
|
||||
// stream fail, possible logout
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
|
||||
'app-state-sync-version': LTHashState
|
||||
'lid-mapping': string
|
||||
'device-list': string[]
|
||||
tctoken: { token: Buffer; timestamp?: string }
|
||||
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
|
||||
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
|
||||
'identity-key': Uint8Array
|
||||
}
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@ export enum DisconnectReason {
|
||||
restartRequired = 515,
|
||||
multideviceMismatch = 411,
|
||||
forbidden = 403,
|
||||
unavailableService = 503
|
||||
unavailableService = 503,
|
||||
sessionInvalidated = 516
|
||||
}
|
||||
|
||||
export type WAInitResponse = {
|
||||
|
||||
@@ -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