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,
|
type BinaryNode,
|
||||||
getBinaryNodeChild,
|
getBinaryNodeChild,
|
||||||
getBinaryNodeChildren,
|
getBinaryNodeChildren,
|
||||||
|
isLidUser,
|
||||||
|
isPnUser,
|
||||||
jidDecode,
|
jidDecode,
|
||||||
jidNormalizedUser,
|
jidNormalizedUser,
|
||||||
reduceBinaryNodeToDictionary,
|
reduceBinaryNodeToDictionary,
|
||||||
@@ -695,9 +697,26 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
|
const profilePictureUrl = async (jid: string, type: 'preview' | 'image' = 'preview', timeoutMs?: number) => {
|
||||||
const baseContent: BinaryNode[] = [{ tag: 'picture', attrs: { type, query: 'url' } }]
|
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(
|
const result = await query(
|
||||||
{
|
{
|
||||||
tag: 'iq',
|
tag: 'iq',
|
||||||
@@ -707,7 +726,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
type: 'get',
|
type: 'get',
|
||||||
xmlns: 'w:profile:picture'
|
xmlns: 'w:profile:picture'
|
||||||
},
|
},
|
||||||
content: tcTokenContent
|
content
|
||||||
},
|
},
|
||||||
timeoutMs
|
timeoutMs
|
||||||
)
|
)
|
||||||
@@ -799,7 +818,11 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
* @param tcToken token for subscription, use if present
|
* @param tcToken token for subscription, use if present
|
||||||
*/
|
*/
|
||||||
const presenceSubscribe = async (toJid: string) => {
|
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({
|
return sendNode({
|
||||||
tag: 'presence',
|
tag: 'presence',
|
||||||
|
|||||||
+148
-32
@@ -47,13 +47,14 @@ import {
|
|||||||
MISSING_KEYS_ERROR_TEXT,
|
MISSING_KEYS_ERROR_TEXT,
|
||||||
NACK_REASONS,
|
NACK_REASONS,
|
||||||
NO_MESSAGE_FOUND_ERROR_TEXT,
|
NO_MESSAGE_FOUND_ERROR_TEXT,
|
||||||
|
SERVER_ERROR_CODES,
|
||||||
normalizeMessageJids,
|
normalizeMessageJids,
|
||||||
toNumber,
|
toNumber,
|
||||||
unixTimestampSeconds,
|
unixTimestampSeconds,
|
||||||
xmppPreKey,
|
xmppPreKey,
|
||||||
xmppSignedPreKey
|
xmppSignedPreKey
|
||||||
} from '../Utils'
|
} from '../Utils'
|
||||||
import { logMessageReceived } from '../Utils/baileys-logger'
|
import { logMessageReceived, logTcToken } from '../Utils/baileys-logger'
|
||||||
import { makeMutex } from '../Utils/make-mutex'
|
import { makeMutex } from '../Utils/make-mutex'
|
||||||
import {
|
import {
|
||||||
metrics,
|
metrics,
|
||||||
@@ -80,6 +81,7 @@ import {
|
|||||||
jidNormalizedUser,
|
jidNormalizedUser,
|
||||||
S_WHATSAPP_NET
|
S_WHATSAPP_NET
|
||||||
} from '../WABinary'
|
} from '../WABinary'
|
||||||
|
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
|
||||||
import { extractGroupMetadata } from './groups'
|
import { extractGroupMetadata } from './groups'
|
||||||
import { makeMessagesSocket } from './messages-send'
|
import { makeMessagesSocket } from './messages-send'
|
||||||
|
|
||||||
@@ -117,7 +119,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
sendReceipt,
|
sendReceipt,
|
||||||
uploadPreKeys,
|
uploadPreKeys,
|
||||||
sendPeerDataOperationMessage,
|
sendPeerDataOperationMessage,
|
||||||
messageRetryManager
|
messageRetryManager,
|
||||||
|
getPrivacyTokens
|
||||||
} = sock
|
} = sock
|
||||||
|
|
||||||
/** this mutex ensures that each retryRequest will wait for the previous one to finish */
|
/** 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
|
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 (
|
const fetchMessageHistory = async (
|
||||||
count: number,
|
count: number,
|
||||||
oldestMsgKey: WAMessageKey,
|
oldestMsgKey: WAMessageKey,
|
||||||
@@ -633,7 +708,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
logger
|
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')
|
logger.info({ node }, 'unknown encrypt notification')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -958,28 +1052,45 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
const tokensNode = getBinaryNodeChild(node, 'tokens')
|
const tokensNode = getBinaryNodeChild(node, 'tokens')
|
||||||
const from = jidNormalizedUser(node.attrs.from)
|
const from = jidNormalizedUser(node.attrs.from)
|
||||||
|
|
||||||
if (!tokensNode) return
|
if(!tokensNode) return
|
||||||
|
|
||||||
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
|
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 { attrs, content } = tokenNode
|
||||||
const type = attrs.type
|
const type = attrs.type
|
||||||
const timestamp = attrs.t
|
const timestamp = attrs.t
|
||||||
|
|
||||||
if (type === 'trusted_contact' && content instanceof Buffer) {
|
if(type === 'trusted_contact' && content instanceof Uint8Array) {
|
||||||
logger.debug(
|
// Resolve to LID for consistent storage key
|
||||||
{
|
const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined
|
||||||
from,
|
const storageJid = senderLid || await resolveTcTokenJid(from, getLIDForPN)
|
||||||
timestamp,
|
|
||||||
tcToken: content
|
// Timestamp monotonicity guard — only store if incoming >= existing
|
||||||
},
|
const existingData = await authState.keys.get('tctoken', [storageJid])
|
||||||
'received trusted contact token'
|
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({
|
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,
|
// error in acknowledgement,
|
||||||
// device could not display the message
|
// device could not display the message
|
||||||
if (attrs.error) {
|
if(attrs.error) {
|
||||||
logger.warn({ attrs }, 'received error in ack')
|
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', [
|
ev.emit('messages.update', [
|
||||||
{
|
{
|
||||||
key,
|
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 }) => {
|
ev.on('connection.update', ({ isOnline }) => {
|
||||||
if (typeof isOnline !== 'undefined') {
|
if(typeof isOnline !== 'undefined') {
|
||||||
sendActiveReceipts = isOnline
|
sendActiveReceipts = isOnline
|
||||||
logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`)
|
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,
|
parseAndInjectE2ESessions,
|
||||||
unixTimestampSeconds
|
unixTimestampSeconds
|
||||||
} from '../Utils'
|
} from '../Utils'
|
||||||
import { logMessageSent } from '../Utils/baileys-logger'
|
import { logMessageSent, logTcToken } from '../Utils/baileys-logger'
|
||||||
import { getUrlInfo } from '../Utils/link-preview'
|
import { getUrlInfo } from '../Utils/link-preview'
|
||||||
import { makeKeyedMutex } from '../Utils/make-mutex'
|
import { makeKeyedMutex } from '../Utils/make-mutex'
|
||||||
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
|
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
|
||||||
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
|
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
|
||||||
|
import {
|
||||||
|
isTcTokenExpired,
|
||||||
|
resolveTcTokenJid,
|
||||||
|
shouldSendNewTcToken,
|
||||||
|
storeTcTokensFromIqResult
|
||||||
|
} from '../Utils/tc-token-utils'
|
||||||
import {
|
import {
|
||||||
areJidsSameUser,
|
areJidsSameUser,
|
||||||
type BinaryNode,
|
type BinaryNode,
|
||||||
@@ -1475,18 +1481,79 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Working carousel includes tctoken in stanza
|
// tctoken lifecycle: fetch, validate expiry, proactive re-fetch if missing/expired
|
||||||
const contactTcTokenData =
|
const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter
|
||||||
!isGroup && !isRetryResend && !isStatus ? await authState.keys.get('tctoken', [destinationJid]) : {}
|
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({
|
;(stanza.content as BinaryNode[]).push({
|
||||||
tag: 'tctoken',
|
tag: 'tctoken',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
content: tcTokenBuffer
|
content: tcTokenBuffer
|
||||||
})
|
})
|
||||||
|
logTcToken('attached', { jid: destinationJid })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (additionalNodes && additionalNodes.length > 0) {
|
if (additionalNodes && additionalNodes.length > 0) {
|
||||||
@@ -1568,6 +1635,38 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
await sendNode(stanza)
|
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
|
// Log with [BAILEYS] prefix
|
||||||
logMessageSent(msgId, destinationJid)
|
logMessageSent(msgId, destinationJid)
|
||||||
|
|
||||||
@@ -1677,8 +1776,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPrivacyTokens = async (jids: string[]) => {
|
const getPrivacyTokens = async (jids: string[], timestamp?: number) => {
|
||||||
const t = unixTimestampSeconds().toString()
|
const t = (timestamp ?? unixTimestampSeconds()).toString()
|
||||||
const result = await query({
|
const result = await query({
|
||||||
tag: 'iq',
|
tag: 'iq',
|
||||||
attrs: {
|
attrs: {
|
||||||
|
|||||||
+10
-2
@@ -1529,10 +1529,18 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
ws.on('CB:stream:error', (node: BinaryNode) => {
|
ws.on('CB:stream:error', (node: BinaryNode) => {
|
||||||
const [reasonNode] = getAllBinaryNodeChildren(node)
|
const [reasonNode] = getAllBinaryNodeChildren(node)
|
||||||
logger.error({ reasonNode, fullErrorNode: node }, 'stream errored out')
|
|
||||||
|
|
||||||
const { reason, statusCode } = getErrorCodeFromStreamError(node)
|
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 }))
|
void end(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }))
|
||||||
})
|
})
|
||||||
// stream fail, possible logout
|
// stream fail, possible logout
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
|
|||||||
'app-state-sync-version': LTHashState
|
'app-state-sync-version': LTHashState
|
||||||
'lid-mapping': string
|
'lid-mapping': string
|
||||||
'device-list': 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 for Signal Protocol - used for detecting contact reinstalls */
|
||||||
'identity-key': Uint8Array
|
'identity-key': Uint8Array
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -35,7 +35,8 @@ export enum DisconnectReason {
|
|||||||
restartRequired = 515,
|
restartRequired = 515,
|
||||||
multideviceMismatch = 411,
|
multideviceMismatch = 411,
|
||||||
forbidden = 403,
|
forbidden = 403,
|
||||||
unavailableService = 503
|
unavailableService = 503,
|
||||||
|
sessionInvalidated = 516
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WAInitResponse = {
|
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
|
export default BaileysLogger
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ export const NACK_REASONS = {
|
|||||||
CorruptedSession: 553
|
CorruptedSession: 553
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SERVER_ERROR_CODES = {
|
||||||
|
MissingTcToken: '463',
|
||||||
|
SmaxInvalid: '479',
|
||||||
|
StaleGroupAddressingMode: '421',
|
||||||
|
NewChatMessagesCapped: '475'
|
||||||
|
}
|
||||||
|
|
||||||
type MessageType =
|
type MessageType =
|
||||||
| 'chat'
|
| 'chat'
|
||||||
| 'peer_broadcast'
|
| 'peer_broadcast'
|
||||||
|
|||||||
+12
-2
@@ -366,12 +366,22 @@ const CODE_MAP: { [_: string]: DisconnectReason } = {
|
|||||||
export const getErrorCodeFromStreamError = (node: BinaryNode) => {
|
export const getErrorCodeFromStreamError = (node: BinaryNode) => {
|
||||||
const [reasonNode] = getAllBinaryNodeChildren(node)
|
const [reasonNode] = getAllBinaryNodeChildren(node)
|
||||||
let reason = reasonNode?.tag || 'unknown'
|
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'
|
reason = 'restart required'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(statusCode === DisconnectReason.sessionInvalidated) {
|
||||||
|
reason = 'session invalidated'
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reason,
|
reason,
|
||||||
statusCode
|
statusCode
|
||||||
|
|||||||
+132
-5
@@ -1,5 +1,66 @@
|
|||||||
import type { SignalKeyStoreWithTransaction } from '../Types'
|
import type { SignalKeyStoreWithTransaction } from '../Types'
|
||||||
import type { BinaryNode } from '../WABinary'
|
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 = {
|
type TcTokenParams = {
|
||||||
jid: string
|
jid: string
|
||||||
@@ -7,19 +68,29 @@ type TcTokenParams = {
|
|||||||
authState: {
|
authState: {
|
||||||
keys: SignalKeyStoreWithTransaction
|
keys: SignalKeyStoreWithTransaction
|
||||||
}
|
}
|
||||||
|
getLIDForPN?: (pn: string) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildTcTokenFromJid({
|
export async function buildTcTokenFromJid({
|
||||||
authState,
|
authState,
|
||||||
jid,
|
jid,
|
||||||
baseContent = []
|
baseContent = [],
|
||||||
|
getLIDForPN
|
||||||
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
|
}: TcTokenParams): Promise<BinaryNode[] | undefined> {
|
||||||
try {
|
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({
|
baseContent.push({
|
||||||
tag: 'tctoken',
|
tag: 'tctoken',
|
||||||
@@ -28,7 +99,63 @@ export async function buildTcTokenFromJid({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return baseContent
|
return baseContent
|
||||||
} catch (error) {
|
} catch(error) {
|
||||||
return baseContent.length > 0 ? baseContent : undefined
|
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