fix: eliminate 40s message delivery delay caused by libsignal console dumps

fix: eliminate 40s message delivery delay caused by libsignal console dumps
This commit is contained in:
Renato Alcara
2026-02-27 08:43:54 -03:00
committed by GitHub
parent efc927728b
commit d233a7856f
18 changed files with 359 additions and 277 deletions
+2 -3
View File
@@ -24,9 +24,6 @@ jobs:
corepack enable corepack enable
corepack prepare yarn@4.x --activate corepack prepare yarn@4.x --activate
- name: Fix Git config
run: git config --global core.autocrlf input
- name: Configure Git for HTTPS - name: Configure Git for HTTPS
run: | run: |
# Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação) # Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação)
@@ -48,6 +45,8 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0 GIT_TERMINAL_PROMPT: 0
YARN_HTTP_TIMEOUT: 600000
YARN_HTTP_RETRY: 10
run: yarn install --immutable run: yarn install --immutable
- name: Auto-fix formatting - name: Auto-fix formatting
+2
View File
@@ -1 +1,3 @@
nodeLinker: node-modules nodeLinker: node-modules
httpRetry: 10
httpTimeout: 600000
+144 -140
View File
@@ -517,165 +517,169 @@ export function makeLibSignalRepository(
} }
const migrationPromise = (async (): Promise<{ migrated: number; skipped: number; total: number }> => { const migrationPromise = (async (): Promise<{ migrated: number; skipped: number; total: number }> => {
// Get user's device list — use in-memory cache to avoid a DB round-trip on // Get user's device list — use in-memory cache to avoid a DB round-trip on
// every incoming LID message. Cache is invalidated after 5 minutes. // every incoming LID message. Cache is invalidated after 5 minutes.
// We use undefined to mean "not yet checked" and [] to mean "checked, no devices // We use undefined to mean "not yet checked" and [] to mean "checked, no devices
// found" so that DB misses are also cached and don't cause a per-message lookup. // found" so that DB misses are also cached and don't cause a per-message lookup.
let userDevices: string[] | undefined = deviceListCache.get(user) let userDevices: string[] | undefined = deviceListCache.get(user)
if (userDevices === undefined) { if (userDevices === undefined) {
logger.debug({ fromJid }, 'bulk device migration - loading all user devices from DB') logger.debug({ fromJid }, 'bulk device migration - loading all user devices from DB')
const result = await parsedKeys.get('device-list', [user]) const result = await parsedKeys.get('device-list', [user])
userDevices = result[user] ?? [] userDevices = result[user] ?? []
deviceListCache.set(user, userDevices) deviceListCache.set(user, userDevices)
} else { } else {
logger.trace({ fromJid, deviceCount: userDevices.length }, 'bulk device migration - device list from cache') logger.trace({ fromJid, deviceCount: userDevices.length }, 'bulk device migration - device list from cache')
}
if (userDevices.length === 0) {
return { migrated: 0, skipped: 0, total: 0 }
}
// Work on a copy so we don't mutate the cached array
userDevices = [...userDevices]
const { device: fromDevice } = decoded1
const fromDeviceStr = fromDevice?.toString() || '0'
if (!userDevices.includes(fromDeviceStr)) {
userDevices.push(fromDeviceStr)
}
// Filter out cached devices before database fetch
const uncachedDevices = userDevices.filter(device => {
const deviceKey = `${user}.${device}`
return !migratedSessionCache.has(deviceKey)
})
// All devices already confirmed as migrated — skip DB lookups entirely
if (uncachedDevices.length === 0) {
logger.debug({ fromJid, totalDevices: userDevices.length }, 'bulk device migration - all devices already cached, skipping')
return { migrated: 0, skipped: 0, total: userDevices.length }
}
// Bulk check session existence only for uncached devices
const deviceSessionKeys = uncachedDevices.map(device => `${user}.${device}`)
const existingSessions = await parsedKeys.get('session', deviceSessionKeys)
// Step 3: Convert existing sessions to JIDs (only migrate sessions that exist)
const deviceJids: string[] = []
for (const [sessionKey, sessionData] of Object.entries(existingSessions)) {
if (sessionData) {
// Session exists in storage
const deviceStr = sessionKey.split('.')[1]
if (!deviceStr) continue
const deviceNum = parseInt(deviceStr)
let jid = deviceNum === 0 ? `${user}@s.whatsapp.net` : `${user}:${deviceNum}@s.whatsapp.net`
if (deviceNum === 99) {
jid = `${user}:99@hosted`
}
deviceJids.push(jid)
} }
}
logger.debug( if (userDevices.length === 0) {
{ return { migrated: 0, skipped: 0, total: 0 }
fromJid,
totalDevices: userDevices.length,
devicesWithSessions: deviceJids.length,
devices: deviceJids
},
'bulk device migration complete - all user devices processed'
)
// No PN-format sessions found: all devices are already migrated to LID addressing.
// Cache them now so subsequent messages skip redundant DB lookups entirely.
if (deviceJids.length === 0) {
for (const device of uncachedDevices) {
migratedSessionCache.set(`${user}.${device}`, true)
} }
return { migrated: 0, skipped: 0, total: userDevices.length }
}
// Single transaction for all migrations // Work on a copy so we don't mutate the cached array
return parsedKeys.transaction( userDevices = [...userDevices]
async (): Promise<{ migrated: number; skipped: number; total: number }> => {
// Prepare migration operations with addressing metadata
type MigrationOp = {
fromJid: string
toJid: string
pnUser: string
lidUser: string
deviceId: number
fromAddr: libsignal.ProtocolAddress
toAddr: libsignal.ProtocolAddress
}
const migrationOps: MigrationOp[] = deviceJids.map(jid => { const { device: fromDevice } = decoded1
const lidWithDevice = transferDevice(jid, toJid) const fromDeviceStr = fromDevice?.toString() || '0'
const fromDecoded = jidDecode(jid) if (!userDevices.includes(fromDeviceStr)) {
const toDecoded = jidDecode(lidWithDevice) userDevices.push(fromDeviceStr)
if (!fromDecoded || !toDecoded) { }
throw new Error(`Failed to decode JID during migration: ${jid} -> ${lidWithDevice}`)
// Filter out cached devices before database fetch
const uncachedDevices = userDevices.filter(device => {
const deviceKey = `${user}.${device}`
return !migratedSessionCache.has(deviceKey)
})
// All devices already confirmed as migrated — skip DB lookups entirely
if (uncachedDevices.length === 0) {
logger.debug(
{ fromJid, totalDevices: userDevices.length },
'bulk device migration - all devices already cached, skipping'
)
return { migrated: 0, skipped: 0, total: userDevices.length }
}
// Bulk check session existence only for uncached devices
const deviceSessionKeys = uncachedDevices.map(device => `${user}.${device}`)
const existingSessions = await parsedKeys.get('session', deviceSessionKeys)
// Step 3: Convert existing sessions to JIDs (only migrate sessions that exist)
const deviceJids: string[] = []
for (const [sessionKey, sessionData] of Object.entries(existingSessions)) {
if (sessionData) {
// Session exists in storage
const deviceStr = sessionKey.split('.')[1]
if (!deviceStr) continue
const deviceNum = parseInt(deviceStr)
let jid = deviceNum === 0 ? `${user}@s.whatsapp.net` : `${user}:${deviceNum}@s.whatsapp.net`
if (deviceNum === 99) {
jid = `${user}:99@hosted`
} }
return { deviceJids.push(jid)
fromJid: jid, }
toJid: lidWithDevice, }
pnUser: fromDecoded.user,
lidUser: toDecoded.user, logger.debug(
deviceId: fromDecoded.device || 0, {
fromAddr: jidToSignalProtocolAddress(jid), fromJid,
toAddr: jidToSignalProtocolAddress(lidWithDevice) totalDevices: userDevices.length,
devicesWithSessions: deviceJids.length,
devices: deviceJids
},
'bulk device migration complete - all user devices processed'
)
// No PN-format sessions found: all devices are already migrated to LID addressing.
// Cache them now so subsequent messages skip redundant DB lookups entirely.
if (deviceJids.length === 0) {
for (const device of uncachedDevices) {
migratedSessionCache.set(`${user}.${device}`, true)
}
return { migrated: 0, skipped: 0, total: userDevices.length }
}
// Single transaction for all migrations
return parsedKeys.transaction(
async (): Promise<{ migrated: number; skipped: number; total: number }> => {
// Prepare migration operations with addressing metadata
type MigrationOp = {
fromJid: string
toJid: string
pnUser: string
lidUser: string
deviceId: number
fromAddr: libsignal.ProtocolAddress
toAddr: libsignal.ProtocolAddress
} }
})
const totalOps = migrationOps.length const migrationOps: MigrationOp[] = deviceJids.map(jid => {
let migratedCount = 0 const lidWithDevice = transferDevice(jid, toJid)
const fromDecoded = jidDecode(jid)
const toDecoded = jidDecode(lidWithDevice)
if (!fromDecoded || !toDecoded) {
throw new Error(`Failed to decode JID during migration: ${jid} -> ${lidWithDevice}`)
}
// Reuse existingSessions fetched above — for PN users on s.whatsapp.net the return {
// signal-address format (user.device) is identical to deviceSessionKeys, so fromJid: jid,
// existingSessions already contains every session needed here. toJid: lidWithDevice,
// Avoids a redundant storage round-trip inside the transaction. pnUser: fromDecoded.user,
const pnSessions = existingSessions lidUser: toDecoded.user,
deviceId: fromDecoded.device || 0,
fromAddr: jidToSignalProtocolAddress(jid),
toAddr: jidToSignalProtocolAddress(lidWithDevice)
}
})
// Prepare bulk session updates (PN → LID migration + deletion) const totalOps = migrationOps.length
const sessionUpdates: { [key: string]: Uint8Array | null } = {} let migratedCount = 0
for (const op of migrationOps) { // Reuse existingSessions fetched above — for PN users on s.whatsapp.net the
const pnAddrStr = op.fromAddr.toString() // signal-address format (user.device) is identical to deviceSessionKeys, so
const lidAddrStr = op.toAddr.toString() // existingSessions already contains every session needed here.
// Avoids a redundant storage round-trip inside the transaction.
const pnSessions = existingSessions
const pnSession = pnSessions[pnAddrStr] // Prepare bulk session updates (PN → LID migration + deletion)
if (pnSession) { const sessionUpdates: { [key: string]: Uint8Array | null } = {}
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++ for (const op of migrationOps) {
const pnAddrStr = op.fromAddr.toString()
const lidAddrStr = op.toAddr.toString()
const pnSession = pnSessions[pnAddrStr]
if (pnSession) {
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++
}
} }
} }
}
// Single bulk session update for all migrations // Single bulk session update for all migrations
if (Object.keys(sessionUpdates).length > 0) { if (Object.keys(sessionUpdates).length > 0) {
await parsedKeys.set({ session: sessionUpdates }) await parsedKeys.set({ session: sessionUpdates })
logger.debug({ migratedSessions: migratedCount }, 'bulk session migration complete') logger.debug({ migratedSessions: migratedCount }, 'bulk session migration complete')
} }
// Cache ALL processed devices (migrated, skipped, or closed-session) to prevent // Cache ALL processed devices (migrated, skipped, or closed-session) to prevent
// redundant DB lookups on subsequent messages from the same contact. // redundant DB lookups on subsequent messages from the same contact.
for (const op of migrationOps) { for (const op of migrationOps) {
migratedSessionCache.set(`${op.pnUser}.${op.deviceId}`, true) migratedSessionCache.set(`${op.pnUser}.${op.deviceId}`, true)
} }
const skippedCount = totalOps - migratedCount const skippedCount = totalOps - migratedCount
return { migrated: migratedCount, skipped: skippedCount, total: totalOps } return { migrated: migratedCount, skipped: skippedCount, total: totalOps }
}, },
`migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}` `migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}`
) )
})() })()
migrationInFlight.set(user, migrationPromise) migrationInFlight.set(user, migrationPromise)
+10 -3
View File
@@ -2,7 +2,12 @@ import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import { DEFAULT_CACHE_MAX_KEYS, DEFAULT_CACHE_TTLS, HISTORY_SYNC_PAUSED_TIMEOUT_MS, PROCESSABLE_HISTORY_TYPES } from '../Defaults' import {
DEFAULT_CACHE_MAX_KEYS,
DEFAULT_CACHE_TTLS,
HISTORY_SYNC_PAUSED_TIMEOUT_MS,
PROCESSABLE_HISTORY_TYPES
} from '../Defaults'
import type { import type {
BotListInfo, BotListInfo,
CacheStore, CacheStore,
@@ -777,7 +782,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid))) me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent let content: BinaryNode[] | undefined = baseContent
if(isUserJid && !isSelf) { if (isUserJid && !isSelf) {
content = await buildTcTokenFromJid({ content = await buildTcTokenFromJid({
authState, authState,
jid: normalizedJid, jid: normalizedJid,
@@ -1438,7 +1443,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
// of the state machine phase (see the Syncing → Online path below). // of the state machine phase (see the Syncing → Online path below).
const isReconnection = (authState.creds.accountSyncCounter ?? 0) > 0 const isReconnection = (authState.creds.accountSyncCounter ?? 0) > 0
if (isReconnection) { if (isReconnection) {
logger.info('Reconnection detected (accountSyncCounter > 0), skipping AwaitingInitialSync wait. Transitioning to Online immediately.') logger.info(
'Reconnection detected (accountSyncCounter > 0), skipping AwaitingInitialSync wait. Transitioning to Online immediately.'
)
blockedCollections.clear() blockedCollections.clear()
syncState = SyncState.Online syncState = SyncState.Online
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1 const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
+92 -67
View File
@@ -47,8 +47,8 @@ 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,
SERVER_ERROR_CODES,
toNumber, toNumber,
unixTimestampSeconds, unixTimestampSeconds,
xmppPreKey, xmppPreKey,
@@ -63,6 +63,7 @@ import {
recordMessageReceived, recordMessageReceived,
recordMessageRetry recordMessageRetry
} from '../Utils/prometheus-metrics.js' } from '../Utils/prometheus-metrics.js'
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
import { import {
areJidsSameUser, areJidsSameUser,
type BinaryNode, type BinaryNode,
@@ -81,7 +82,6 @@ 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'
@@ -166,23 +166,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try { try {
const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY, TC_TOKEN_PRUNE_TS_KEY]) const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY, TC_TOKEN_PRUNE_TS_KEY])
const entry = data[TC_TOKEN_INDEX_KEY] const entry = data[TC_TOKEN_INDEX_KEY]
if(entry?.token) { if (entry?.token) {
const stored = JSON.parse(Buffer.from(entry.token).toString('utf8')) const stored = JSON.parse(Buffer.from(entry.token).toString('utf8'))
if(Array.isArray(stored)) { if (Array.isArray(stored)) {
for(const jid of stored) tcTokenKnownJids.add(jid) for (const jid of stored) tcTokenKnownJids.add(jid)
} }
} }
const pruneEntry = data[TC_TOKEN_PRUNE_TS_KEY] const pruneEntry = data[TC_TOKEN_PRUNE_TS_KEY]
if(pruneEntry?.timestamp) { if (pruneEntry?.timestamp) {
lastTcTokenPruneTs = Number(pruneEntry.timestamp) lastTcTokenPruneTs = Number(pruneEntry.timestamp)
} }
} catch { /* first run or corrupt index — start fresh */ } } catch {
/* first run or corrupt index — start fresh */
}
})() })()
/** Debounced save of the tctoken JID index (5s) */ /** Debounced save of the tctoken JID index (5s) */
const scheduleTcTokenIndexSave = () => { const scheduleTcTokenIndexSave = () => {
if(tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer) if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
tcTokenIndexSaveTimer = setTimeout(async () => { tcTokenIndexSaveTimer = setTimeout(async () => {
try { try {
const arr = Array.from(tcTokenKnownJids) const arr = Array.from(tcTokenKnownJids)
@@ -194,7 +196,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
}) })
} catch(err) { } catch (err) {
logger.debug({ err }, 'failed to persist tctoken index') logger.debug({ err }, 'failed to persist tctoken index')
} }
}, 5000) }, 5000)
@@ -207,13 +209,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const survivingJids: string[] = [] const survivingJids: string[] = []
const jidsToCheck = Array.from(tcTokenKnownJids).filter(j => j !== TC_TOKEN_INDEX_KEY) const jidsToCheck = Array.from(tcTokenKnownJids).filter(j => j !== TC_TOKEN_INDEX_KEY)
if(!jidsToCheck.length) return if (!jidsToCheck.length) return
try { try {
const allData = await authState.keys.get('tctoken', jidsToCheck) const allData = await authState.keys.get('tctoken', jidsToCheck)
for(const jid of jidsToCheck) { for (const jid of jidsToCheck) {
const entry = allData[jid] const entry = allData[jid]
if(!entry?.token || isTcTokenExpired(entry.timestamp)) { if (!entry?.token || isTcTokenExpired(entry.timestamp)) {
pruneSet[jid] = null pruneSet[jid] = null
} else { } else {
survivingJids.push(jid) survivingJids.push(jid)
@@ -224,10 +226,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
const pruneCount = Object.keys(pruneSet).length const pruneCount = Object.keys(pruneSet).length
if(pruneCount > 0) { if (pruneCount > 0) {
await authState.keys.set({ tctoken: pruneSet }) await authState.keys.set({ tctoken: pruneSet })
tcTokenKnownJids.clear() tcTokenKnownJids.clear()
for(const jid of survivingJids) tcTokenKnownJids.add(jid) for (const jid of survivingJids) tcTokenKnownJids.add(jid)
scheduleTcTokenIndexSave() scheduleTcTokenIndexSave()
logTcToken('prune', { pruned: pruneCount, remaining: survivingJids.length }) logTcToken('prune', { pruned: pruneCount, remaining: survivingJids.length })
} }
@@ -720,22 +722,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}) })
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget // When a session is refreshed (identity change), re-issue tctoken fire-and-forget
if(result.action === 'session_refreshed') { if (result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from) const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(normalizedJid, getLIDForPN).then(async (tcJid) => { resolveTcTokenJid(normalizedJid, getLIDForPN)
const tcData = await authState.keys.get('tctoken', [tcJid]) .then(async tcJid => {
const entry = tcData[tcJid] const tcData = await authState.keys.get('tctoken', [tcJid])
if(entry?.token?.length && !isTcTokenExpired(entry.timestamp)) { const entry = tcData[tcJid]
const senderTs = unixTimestampSeconds() if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' }) const senderTs = unixTimestampSeconds()
getPrivacyTokens([normalizedJid], senderTs).catch(err => { logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message }) getPrivacyTokens([normalizedJid], senderTs).catch(err => {
}) logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
} })
}).catch(() => { /* ignore resolution errors */ }) }
})
.catch(() => {
/* ignore resolution errors */
})
} }
if(result.action === 'no_identity_node') { if (result.action === 'no_identity_node') {
logger.info({ node }, 'unknown encrypt notification') logger.info({ node }, 'unknown encrypt notification')
} }
} }
@@ -1060,32 +1066,32 @@ 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')
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 Uint8Array) { if (type === 'trusted_contact' && content instanceof Uint8Array) {
// Resolve to LID for consistent storage key // Resolve to LID for consistent storage key
const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined
const storageJid = senderLid || await resolveTcTokenJid(from, getLIDForPN) const storageJid = senderLid || (await resolveTcTokenJid(from, getLIDForPN))
// Timestamp monotonicity guard — only store if incoming >= existing // Timestamp monotonicity guard — only store if incoming >= existing
const existingData = await authState.keys.get('tctoken', [storageJid]) const existingData = await authState.keys.get('tctoken', [storageJid])
const existing = existingData[storageJid] const existing = existingData[storageJid]
const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0 const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0
const incomingTs = timestamp ? Number(timestamp) : 0 const incomingTs = timestamp ? Number(timestamp) : 0
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) { if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
continue continue
} }
// Don't store timestamp-less tokens — they expire immediately and would // Don't store timestamp-less tokens — they expire immediately and would
// corrupt a valid existing entry if one is already present // corrupt a valid existing entry if one is already present
if(!incomingTs) { if (!incomingTs) {
continue continue
} }
@@ -1263,7 +1269,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) { if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.debug({ remoteJid }, 'ignoring receipt from jid') logger.trace({ remoteJid }, 'ignoring receipt from jid')
await sendMessageAck(node) await sendMessageAck(node)
return return
} }
@@ -1343,7 +1349,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleNotification = async (node: BinaryNode) => { const handleNotification = async (node: BinaryNode) => {
const remoteJid = node.attrs.from const remoteJid = node.attrs.from
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) { if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification') logger.trace({ remoteJid }, 'ignored notification')
await sendMessageAck(node) await sendMessageAck(node)
return return
} }
@@ -1379,8 +1385,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleMessage = async (node: BinaryNode) => { const handleMessage = async (node: BinaryNode) => {
if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) { if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) {
logger.debug({ key: node.attrs.key }, 'ignored message') logger.trace({ from: node.attrs.from }, 'ignored message')
await sendMessageAck(node, NACK_REASONS.UnhandledError) // Send a clean ACK (no error code) so the server considers the
// message delivered. Using error 500 (UnhandledError) previously
// caused the server to retry delivery, generating duplicate traffic.
await sendMessageAck(node)
return return
} }
@@ -1879,8 +1888,8 @@ 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) {
if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) { if (attrs.error === SERVER_ERROR_CODES.MissingTcToken) {
const msgId = attrs.id const msgId = attrs.id
const jid = jidNormalizedUser(attrs.from) const jid = jidNormalizedUser(attrs.from)
logTcToken('error_463', { jid, msgId }) logTcToken('error_463', { jid, msgId })
@@ -1889,11 +1898,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// then resend. A Set prevents infinite retry loops. // then resend. A Set prevents infinite retry loops.
// Composite key (jid:msgId) ensures retries are isolated per destination. // Composite key (jid:msgId) ensures retries are isolated per destination.
const retryKey = `${jid}:${msgId}` const retryKey = `${jid}:${msgId}`
if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
tcTokenRetriedMsgIds.add(retryKey) tcTokenRetriedMsgIds.add(retryKey)
// Each entry auto-expires after 60s — naturally bounded under normal use // Each entry auto-expires after 60s — naturally bounded under normal use
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000) setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
;(async () => { ;(async () => {
try { try {
await delay(1500) await delay(1500)
@@ -1901,21 +1909,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
(await getMessage(key)) ?? (await getMessage(key)) ??
// Fallback: ack can arrive <30ms after send, before store persists // Fallback: ack can arrive <30ms after send, before store persists
messageRetryManager?.getRecentMessage(jid, msgId)?.message messageRetryManager?.getRecentMessage(jid, msgId)?.message
if(msg) { if (msg) {
await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true }) await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true })
logTcToken('retry_463_ok', { jid, msgId }) logTcToken('retry_463_ok', { jid, msgId })
} else { } else {
logger.warn({ jid, msgId }, '463 retry: message not found in store') logger.warn({ jid, msgId }, '463 retry: message not found in store')
ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }]) ev.emit('messages.update', [
{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }
])
} }
} catch(err: any) { } catch (err: any) {
logger.warn({ jid, msgId, err: err?.message }, '463 retry failed') logger.warn({ jid, msgId, err: err?.message }, '463 retry failed')
ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }]) ev.emit('messages.update', [
{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }
])
} }
})() })()
return return
} }
} else if(attrs.error === SERVER_ERROR_CODES.SmaxInvalid) { } else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id }) logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
} else { } else {
logger.warn({ attrs }, 'received error in ack') logger.warn({ attrs }, 'received error in ack')
@@ -2087,42 +2100,54 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
ev.on('connection.update', ({ isOnline, connection }) => { ev.on('connection.update', ({ isOnline, connection }) => {
// Flush pending tctoken index save on disconnect to avoid writing after close // Flush pending tctoken index save on disconnect to avoid writing after close
if(connection === 'close' && tcTokenIndexSaveTimer) { if (connection === 'close' && tcTokenIndexSaveTimer) {
clearTimeout(tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
tcTokenIndexSaveTimer = undefined tcTokenIndexSaveTimer = undefined
// Await index load first — prevents overwriting a more complete persisted index // Await index load first — prevents overwriting a more complete persisted index
// if the connection closes before the initial load finishes. // if the connection closes before the initial load finishes.
tcTokenIndexLoaded.then(() => { tcTokenIndexLoaded
Promise.resolve(authState.keys.set({ .then(() => {
tctoken: { Promise.resolve(
[TC_TOKEN_INDEX_KEY]: { authState.keys.set({
token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'), tctoken: {
timestamp: unixTimestampSeconds().toString() [TC_TOKEN_INDEX_KEY]: {
} token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
} timestamp: unixTimestampSeconds().toString()
})).catch(() => { /* non-critical */ }) }
}).catch(() => { /* non-critical */ }) }
})
).catch(() => {
/* non-critical */
})
})
.catch(() => {
/* non-critical */
})
} }
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) // Prune expired tctokens when coming online (max once per 24h)
if(isOnline) { if (isOnline) {
const now = Date.now() const now = Date.now()
const ONE_DAY_MS = 86400000 const ONE_DAY_MS = 86400000
if(now - lastTcTokenPruneTs > ONE_DAY_MS) { if (now - lastTcTokenPruneTs > ONE_DAY_MS) {
lastTcTokenPruneTs = now lastTcTokenPruneTs = now
// Persist prune timestamp so it survives restarts // Persist prune timestamp so it survives restarts
Promise.resolve(authState.keys.set({ Promise.resolve(
tctoken: { authState.keys.set({
[TC_TOKEN_PRUNE_TS_KEY]: { tctoken: {
token: Buffer.alloc(0), [TC_TOKEN_PRUNE_TS_KEY]: {
timestamp: now.toString() token: Buffer.alloc(0),
timestamp: now.toString()
}
} }
} })
})).catch(() => { /* non-critical */ }) ).catch(() => {
/* non-critical */
})
pruneExpiredTcTokens().catch(err => { pruneExpiredTcTokens().catch(err => {
logger.debug({ err: err?.message }, 'tctoken pruning failed') logger.debug({ err: err?.message }, 'tctoken pruning failed')
}) })
+1 -3
View File
@@ -1457,9 +1457,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage
// Resolve destination to LID for tctoken storage — matches Signal session key pattern // Resolve destination to LID for tctoken storage — matches Signal session key pattern
const tcTokenJid = is1on1Send const tcTokenJid = is1on1Send ? await resolveTcTokenJid(destinationJid, getLIDForPN) : destinationJid
? await resolveTcTokenJid(destinationJid, getLIDForPN)
: destinationJid
const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {} const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {}
const existingTokenEntry = contactTcTokenData[tcTokenJid] const existingTokenEntry = contactTcTokenData[tcTokenJid]
let tcTokenBuffer = existingTokenEntry?.token let tcTokenBuffer = existingTokenEntry?.token
+4 -3
View File
@@ -321,7 +321,7 @@ export const makeSocket = (config: SocketConfig) => {
} catch (error) { } catch (error) {
// Catch timeout and return undefined instead of throwing // Catch timeout and return undefined instead of throwing
if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) { if (error instanceof Boom && error.output?.statusCode === DisconnectReason.timedOut) {
logger?.warn?.({ msgId }, 'timed out waiting for message') logger?.debug?.({ msgId }, 'timed out waiting for message')
return undefined return undefined
} }
@@ -1539,6 +1539,7 @@ export const makeSocket = (config: SocketConfig) => {
if (passiveResult.status === 'rejected') { if (passiveResult.status === 'rejected') {
logger.warn({ err: passiveResult.reason }, 'failed to send initial passive iq') logger.warn({ err: passiveResult.reason }, 'failed to send initial passive iq')
} }
for (const result of results.slice(1)) { for (const result of results.slice(1)) {
if (result.status === 'rejected') { if (result.status === 'rejected') {
logger.warn({ err: result.reason }, 'background key operation failed after login (non-critical)') logger.warn({ err: result.reason }, 'background key operation failed after login (non-critical)')
@@ -1559,8 +1560,8 @@ export const makeSocket = (config: SocketConfig) => {
// for the same storage locks while the offline-message backlog is draining. // for the same storage locks while the offline-message backlog is draining.
// start() is idempotent (guarded by cleanupInterval check) so deferring is safe. // start() is idempotent (guarded by cleanupInterval check) so deferring is safe.
const _cleanupStartTimer = setTimeout(() => sessionCleanup.start(), 5_000) const _cleanupStartTimer = setTimeout(() => sessionCleanup.start(), 5_000)
if (typeof (_cleanupStartTimer as NodeJS.Timeout).unref === 'function') { if (typeof _cleanupStartTimer.unref === 'function') {
;(_cleanupStartTimer as NodeJS.Timeout).unref() _cleanupStartTimer.unref()
} }
// Start session activity tracker immediately (lightweight, no DB scan) // Start session activity tracker immediately (lightweight, no DB scan)
+13 -1
View File
@@ -958,7 +958,19 @@ export function logLidMapping(
* // Output: [BAILEYS] 🔑 TcToken expired → 5511999999999@s.whatsapp.net { age: 32d } * // Output: [BAILEYS] 🔑 TcToken expired → 5511999999999@s.whatsapp.net { age: 32d }
*/ */
export function logTcToken( export function logTcToken(
event: 'stored' | 'expired' | 'fetch' | 'fetched' | 'reissue' | 'reissue_ok' | 'reissue_fail' | 'prune' | 'error_463' | 'error_479' | 'attached' | 'retry_463_ok', event:
| 'stored'
| 'expired'
| 'fetch'
| 'fetched'
| 'reissue'
| 'reissue_ok'
| 'reissue_fail'
| 'prune'
| 'error_463'
| 'error_479'
| 'attached'
| 'retry_463_ok',
data?: Record<string, unknown>, data?: Record<string, unknown>,
sessionName?: string sessionName?: string
): void { ): void {
+1
View File
@@ -138,6 +138,7 @@ export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => {
if (typeof state.version !== 'number' || isNaN(state.version)) { if (typeof state.version !== 'number' || isNaN(state.version)) {
state.version = 0 state.version = 0
} }
return state return state
} }
+5 -5
View File
@@ -376,9 +376,9 @@ export const getErrorCodeFromStreamError = (node: BinaryNode) => {
// Conflict child: type attribute determines connectionReplaced vs loggedOut. // Conflict child: type attribute determines connectionReplaced vs loggedOut.
// WA Web default: any type other than 'replaced' is treated as device_removed (loggedOut). // WA Web default: any type other than 'replaced' is treated as device_removed (loggedOut).
if(reason === 'conflict') { if (reason === 'conflict') {
const conflictType = reasonNode!.attrs?.type const conflictType = reasonNode!.attrs?.type
if(conflictType === 'replaced') { if (conflictType === 'replaced') {
return { reason: 'replaced', statusCode: DisconnectReason.connectionReplaced } return { reason: 'replaced', statusCode: DisconnectReason.connectionReplaced }
} }
@@ -388,11 +388,11 @@ export const getErrorCodeFromStreamError = (node: BinaryNode) => {
// Child-level code parsing: parent code attr > child code attr > CODE_MAP from child tag > badSession // Child-level code parsing: parent code attr > child code attr > CODE_MAP from child tag > badSession
const statusCode = +(node.attrs.code || reasonNode?.attrs?.code || CODE_MAP[reason] || DisconnectReason.badSession) const statusCode = +(node.attrs.code || reasonNode?.attrs?.code || CODE_MAP[reason] || DisconnectReason.badSession)
if(statusCode === DisconnectReason.restartRequired) { if (statusCode === DisconnectReason.restartRequired) {
reason = 'restart required' reason = 'restart required'
} else if(statusCode === DisconnectReason.sessionInvalidated) { } else if (statusCode === DisconnectReason.sessionInvalidated) {
reason = 'session invalidated' reason = 'session invalidated'
} else if(node.attrs.code) { } else if (node.attrs.code) {
reason = `code ${statusCode}` reason = `code ${statusCode}`
} }
+1
View File
@@ -180,6 +180,7 @@ export class MessageRetryManager {
// to prevent repeated futile lookups on subsequent retry receipts. // to prevent repeated futile lookups on subsequent retry receipts.
this.messageKeyIndex.delete(id) this.messageKeyIndex.delete(id)
} }
return message return message
} }
+9 -4
View File
@@ -1241,7 +1241,7 @@ export const generateWAMessageContent = async (
const generated = await generateCarouselMessage(carouselOptions, options) const generated = await generateCarouselMessage(carouselOptions, options)
// Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer // Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
m.interactiveMessage = generated.interactiveMessage m.interactiveMessage = generated.interactiveMessage
return m as proto.IMessage return m
} }
// Check for nativeList // Check for nativeList
else if (hasNonNullishProperty(message, 'nativeList')) { else if (hasNonNullishProperty(message, 'nativeList')) {
@@ -1349,7 +1349,10 @@ export const generateWAMessageContent = async (
})) }))
m.listMessage = listMessage m.listMessage = listMessage
options.logger?.info({ sections: listMessage.sections?.length || 0 }, '[Interactive] Sending listMessage (sections: ' + (listMessage.sections?.length || 0) + ')') options.logger?.info(
{ sections: listMessage.sections?.length || 0 },
'[Interactive] Sending listMessage (sections: ' + (listMessage.sections?.length || 0) + ')'
)
} else if (hasNonNullishProperty(message, 'carousel')) { } else if (hasNonNullishProperty(message, 'carousel')) {
// Process carousel/interactive messages with viewOnceMessage wrapper // Process carousel/interactive messages with viewOnceMessage wrapper
const carousel = (message as any).carousel const carousel = (message as any).carousel
@@ -1810,7 +1813,8 @@ export const generateWAMessageFromContent = (
} }
// Skip ephemeral contextInfo for carousel messages // Skip ephemeral contextInfo for carousel messages
const isCarouselEphemeral = !!(message as any)?.interactiveMessage?.carouselMessage || const isCarouselEphemeral =
!!(message as any)?.interactiveMessage?.carouselMessage ||
!!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage
if ( if (
// if we want to send a disappearing message // if we want to send a disappearing message
@@ -1834,7 +1838,8 @@ export const generateWAMessageFromContent = (
// Skip Message.create() for carousel — InteractiveMessage has oneOf (fields 4-7) // Skip Message.create() for carousel — InteractiveMessage has oneOf (fields 4-7)
// and create() may corrupt the carouselMessage/nativeFlowMessage oneOf resolution // and create() may corrupt the carouselMessage/nativeFlowMessage oneOf resolution
const isCarouselMsg = !!(message as any)?.interactiveMessage?.carouselMessage || const isCarouselMsg =
!!(message as any)?.interactiveMessage?.carouselMessage ||
!!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage !!(message as any)?.viewOnceMessage?.message?.interactiveMessage?.carouselMessage
if (!isCarouselMsg) { if (!isCarouselMsg) {
message = WAProto.Message.create(message) message = WAProto.Message.create(message)
+12 -12
View File
@@ -18,9 +18,9 @@ const TC_TOKEN_NUM_BUCKETS = 4
* If WA ever diverges these, add a `mode` parameter here. * If WA ever diverges these, add a `mode` parameter here.
*/ */
export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean { export function isTcTokenExpired(timestamp: number | string | null | undefined): boolean {
if(timestamp === null || timestamp === undefined) return true if (timestamp === null || timestamp === undefined) return true
const ts = typeof timestamp === 'string' ? Number(timestamp) : timestamp const ts = typeof timestamp === 'string' ? Number(timestamp) : timestamp
if(isNaN(ts)) return true if (isNaN(ts)) return true
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION) const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1) const cutoffBucket = currentBucket - (TC_TOKEN_NUM_BUCKETS - 1)
@@ -35,7 +35,7 @@ export function isTcTokenExpired(timestamp: number | string | null | undefined):
* Returns true if senderTimestamp is null/undefined or in a previous bucket. * Returns true if senderTimestamp is null/undefined or in a previous bucket.
*/ */
export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean { export function shouldSendNewTcToken(senderTimestamp: number | undefined): boolean {
if(senderTimestamp === undefined) return true if (senderTimestamp === undefined) return true
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION) const currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION)
const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION) const senderBucket = Math.floor(senderTimestamp / TC_TOKEN_BUCKET_DURATION)
@@ -58,7 +58,7 @@ export async function resolveTcTokenJid(
getLIDForPN: (pn: string) => Promise<string | null> getLIDForPN: (pn: string) => Promise<string | null>
): Promise<string> { ): Promise<string> {
const normalized = jidNormalizedUser(jid) const normalized = jidNormalizedUser(jid)
if(isLidUser(normalized)) return normalized if (isLidUser(normalized)) return normalized
const lid = await getLIDForPN(normalized) const lid = await getLIDForPN(normalized)
return lid ?? normalized return lid ?? normalized
} }
@@ -84,9 +84,9 @@ export async function buildTcTokenFromJid({
const entry = tcTokenData?.[storageJid] const entry = tcTokenData?.[storageJid]
const tcTokenBuffer = entry?.token const tcTokenBuffer = entry?.token
if(!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) { if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) {
// Opportunistic cleanup: remove expired token from store // Opportunistic cleanup: remove expired token from store
if(tcTokenBuffer) { if (tcTokenBuffer) {
await authState.keys.set({ tctoken: { [storageJid]: null } }) await authState.keys.set({ tctoken: { [storageJid]: null } })
} }
@@ -100,7 +100,7 @@ export async function buildTcTokenFromJid({
}) })
return baseContent return baseContent
} catch(error) { } catch (error) {
return baseContent.length > 0 ? baseContent : undefined return baseContent.length > 0 ? baseContent : undefined
} }
} }
@@ -127,11 +127,11 @@ export async function storeTcTokensFromIqResult({
onNewJidStored onNewJidStored
}: StoreTcTokensParams) { }: StoreTcTokensParams) {
const tokensNode = getBinaryNodeChild(result, 'tokens') const tokensNode = getBinaryNodeChild(result, 'tokens')
if(!tokensNode) return if (!tokensNode) return
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token') const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
for(const tokenNode of tokenNodes) { for (const tokenNode of tokenNodes) {
if(tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) { if (tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) {
continue continue
} }
@@ -144,13 +144,13 @@ export async function storeTcTokensFromIqResult({
// Matches WA Web handleIncomingTcToken // Matches WA Web handleIncomingTcToken
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0 const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0 const incomingTs = tokenNode.attrs.t ? Number(tokenNode.attrs.t) : 0
if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) { if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) {
continue continue
} }
// Don't store timestamp-less tokens at all — isTcTokenExpired treats them // Don't store timestamp-less tokens at all — isTcTokenExpired treats them
// as immediately expired regardless of whether an existing entry is present // as immediately expired regardless of whether an existing entry is present
if(!incomingTs) { if (!incomingTs) {
continue continue
} }
+10 -3
View File
@@ -178,7 +178,6 @@ export class UnifiedSessionManager {
const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime
if (isNaN(serverTimeNum) || serverTimeNum <= 0) { if (isNaN(serverTimeNum) || serverTimeNum <= 0) {
this.options.logger.debug?.({ serverTime }, 'Invalid server time received, ignoring')
return return
} }
@@ -187,12 +186,20 @@ export class UnifiedSessionManager {
const localTimeMs = Date.now() const localTimeMs = Date.now()
const newOffset = serverTimeMs - localTimeMs const newOffset = serverTimeMs - localTimeMs
// Reject outliers: if the new offset differs from the current stable
// offset by more than 30 seconds, this timestamp is likely stale
// (e.g. replayed device notification with old 't' value).
// The first sample (offset === 0) is always accepted.
const MAX_DRIFT_MS = 30_000
if (this.state.serverTimeOffset !== 0 && Math.abs(newOffset - this.state.serverTimeOffset) > MAX_DRIFT_MS) {
return
}
// Only update if the offset changed significantly (>1 second) // Only update if the offset changed significantly (>1 second)
if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) { if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) {
const oldOffset = this.state.serverTimeOffset
this.state.serverTimeOffset = newOffset this.state.serverTimeOffset = newOffset
this.options.logger.debug?.({ oldOffset, newOffset, serverTime: serverTimeNum }, 'Server time offset updated') this.options.logger.trace?.({ newOffset, serverTime: serverTimeNum }, 'Server time offset updated')
// Record metric // Record metric
metrics.socketEvents?.inc({ event: 'server_time_sync' }) metrics.socketEvents?.inc({ event: 'server_time_sync' })
@@ -25,7 +25,7 @@ interface MockMessageRetryManager {
/** Mirrors jidNormalizedUser: strips device suffix from JID user part */ /** Mirrors jidNormalizedUser: strips device suffix from JID user part */
function jidNormalizedUser(jid: string): string { function jidNormalizedUser(jid: string): string {
const atIdx = jid.indexOf('@') const atIdx = jid.indexOf('@')
if(atIdx < 0) return jid if (atIdx < 0) return jid
const user = jid.slice(0, atIdx) const user = jid.slice(0, atIdx)
const server = jid.slice(atIdx + 1) const server = jid.slice(atIdx + 1)
const normalizedUser = user.includes(':') ? user.split(':')[0] : user const normalizedUser = user.includes(':') ? user.split(':')[0] : user
@@ -46,9 +46,9 @@ async function handleBadAck463(
const jid = jidNormalizedUser(attrs.from) const jid = jidNormalizedUser(attrs.from)
const key: MockKey = { remoteJid: attrs.from, fromMe: true, id: msgId } const key: MockKey = { remoteJid: attrs.from, fromMe: true, id: msgId }
if(attrs.error === '463') { if (attrs.error === '463') {
const retryKey = `${jid}:${msgId}` const retryKey = `${jid}:${msgId}`
if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) {
tcTokenRetriedMsgIds.add(retryKey) tcTokenRetriedMsgIds.add(retryKey)
// Each entry auto-expires after 60s — naturally bounded under normal use // Each entry auto-expires after 60s — naturally bounded under normal use
setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000) setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000)
@@ -57,7 +57,7 @@ async function handleBadAck463(
(await getMessage(key)) ?? (await getMessage(key)) ??
// Fallback: ack can arrive <30ms after send, before store persists // Fallback: ack can arrive <30ms after send, before store persists
messageRetryManager?.getRecentMessage(jid, msgId)?.message messageRetryManager?.getRecentMessage(jid, msgId)?.message
if(msg) { if (msg) {
try { try {
await delayFn(1500) await delayFn(1500)
await relayMessage(jid, msg, { await relayMessage(jid, msg, {
@@ -190,7 +190,7 @@ describe('handleBadAck error 463 retry', () => {
}) })
it('should not retry for non-463 errors', async () => { it('should not retry for non-463 errors', async () => {
for(const errorCode of ['479', '421']) { for (const errorCode of ['479', '421']) {
mockGetMessage.mockClear() mockGetMessage.mockClear()
mockRelayMessage.mockClear() mockRelayMessage.mockClear()
mockEmit.mockClear() mockEmit.mockClear()
@@ -274,7 +274,9 @@ describe('handleBadAck error 463 retry', () => {
it('should fall back to messageRetryManager when getMessage returns undefined', async () => { it('should fall back to messageRetryManager when getMessage returns undefined', async () => {
const cachedMsg = { conversation: 'cached' } const cachedMsg = { conversation: 'cached' }
const mockRetryManager: MockMessageRetryManager = { const mockRetryManager: MockMessageRetryManager = {
getRecentMessage: jest.fn<(jid: string, msgId: string) => { message: any } | undefined>().mockReturnValue({ message: cachedMsg }) getRecentMessage: jest
.fn<(jid: string, msgId: string) => { message: any } | undefined>()
.mockReturnValue({ message: cachedMsg })
} }
mockGetMessage.mockResolvedValue(undefined) mockGetMessage.mockResolvedValue(undefined)
mockRelayMessage.mockResolvedValue(undefined) mockRelayMessage.mockResolvedValue(undefined)
@@ -36,15 +36,11 @@ function makeState(): OfflineBufferState {
* Mirrors the process.nextTick block that arms the offline-buffer timer. * Mirrors the process.nextTick block that arms the offline-buffer timer.
* Only called when creds.me?.id is set (reconnection path). * Only called when creds.me?.id is set (reconnection path).
*/ */
function startBuffer( function startBuffer(state: OfflineBufferState, flush: () => void, warn: () => void): void {
state: OfflineBufferState,
flush: () => void,
warn: () => void
): void {
state.didStartBuffer = true state.didStartBuffer = true
state.offlineBufferTimeout = setTimeout(() => { state.offlineBufferTimeout = setTimeout(() => {
state.offlineBufferTimeout = undefined state.offlineBufferTimeout = undefined
if(state.didStartBuffer) { if (state.didStartBuffer) {
warn() warn()
flush() flush()
state.didStartBuffer = false state.didStartBuffer = false
@@ -57,12 +53,12 @@ function startBuffer(
* delivers all offline notifications before the safety timer fires. * delivers all offline notifications before the safety timer fires.
*/ */
function onOffline(state: OfflineBufferState, flush: () => void): void { function onOffline(state: OfflineBufferState, flush: () => void): void {
if(state.offlineBufferTimeout) { if (state.offlineBufferTimeout) {
clearTimeout(state.offlineBufferTimeout) clearTimeout(state.offlineBufferTimeout)
state.offlineBufferTimeout = undefined state.offlineBufferTimeout = undefined
} }
if(state.didStartBuffer) { if (state.didStartBuffer) {
flush() flush()
state.didStartBuffer = false state.didStartBuffer = false
} }
@@ -73,7 +69,7 @@ function onOffline(state: OfflineBufferState, flush: () => void): void {
* flag so a closing socket cannot emit stale events after the fact. * flag so a closing socket cannot emit stale events after the fact.
*/ */
function onClose(state: OfflineBufferState): void { function onClose(state: OfflineBufferState): void {
if(state.offlineBufferTimeout) { if (state.offlineBufferTimeout) {
clearTimeout(state.offlineBufferTimeout) clearTimeout(state.offlineBufferTimeout)
state.offlineBufferTimeout = undefined state.offlineBufferTimeout = undefined
} }
@@ -97,7 +93,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
afterEach(() => { afterEach(() => {
// Clean up any remaining timer to avoid cross-test interference // Clean up any remaining timer to avoid cross-test interference
if(state.offlineBufferTimeout) { if (state.offlineBufferTimeout) {
clearTimeout(state.offlineBufferTimeout) clearTimeout(state.offlineBufferTimeout)
} }
+17 -17
View File
@@ -596,8 +596,8 @@ describe('tctoken integration scenarios', () => {
const expiredJids: string[] = [] const expiredJids: string[] = []
const validJids: string[] = [] const validJids: string[] = []
for(const [jid, entry] of Object.entries(entries)) { for (const [jid, entry] of Object.entries(entries)) {
if(isTcTokenExpired(entry.timestamp)) { if (isTcTokenExpired(entry.timestamp)) {
expiredJids.push(jid) expiredJids.push(jid)
} else { } else {
validJids.push(jid) validJids.push(jid)
@@ -617,8 +617,8 @@ describe('tctoken integration scenarios', () => {
} }
const deletions: Record<string, null> = {} const deletions: Record<string, null> = {}
for(const [jid, entry] of Object.entries(entries)) { for (const [jid, entry] of Object.entries(entries)) {
if(isTcTokenExpired(entry.timestamp)) { if (isTcTokenExpired(entry.timestamp)) {
deletions[jid] = null deletions[jid] = null
} }
} }
@@ -635,8 +635,8 @@ describe('tctoken integration scenarios', () => {
} }
const deletions: Record<string, null> = {} const deletions: Record<string, null> = {}
for(const [jid, entry] of Object.entries(entries)) { for (const [jid, entry] of Object.entries(entries)) {
if(isTcTokenExpired(entry.timestamp)) { if (isTcTokenExpired(entry.timestamp)) {
deletions[jid] = null deletions[jid] = null
} }
} }
@@ -652,8 +652,8 @@ describe('tctoken integration scenarios', () => {
} }
const deletions: Record<string, null> = {} const deletions: Record<string, null> = {}
for(const [jid, entry] of Object.entries(entries)) { for (const [jid, entry] of Object.entries(entries)) {
if(isTcTokenExpired(entry.timestamp)) { if (isTcTokenExpired(entry.timestamp)) {
deletions[jid] = null deletions[jid] = null
} }
} }
@@ -669,8 +669,8 @@ describe('tctoken integration scenarios', () => {
} }
const deletions: Record<string, null> = {} const deletions: Record<string, null> = {}
for(const [jid, entry] of Object.entries(entries)) { for (const [jid, entry] of Object.entries(entries)) {
if(isTcTokenExpired((entry as any).timestamp)) { if (isTcTokenExpired((entry as any).timestamp)) {
deletions[jid] = null deletions[jid] = null
} }
} }
@@ -769,8 +769,8 @@ describe('tctoken integration scenarios', () => {
it('index ignores sentinel key when loading', () => { it('index ignores sentinel key when loading', () => {
const jids = [JID_A, INDEX_KEY, JID_B, '', null as any] const jids = [JID_A, INDEX_KEY, JID_B, '', null as any]
const loaded = new Set<string>() const loaded = new Set<string>()
for(const jid of jids) { for (const jid of jids) {
if(jid && jid !== INDEX_KEY) { if (jid && jid !== INDEX_KEY) {
loaded.add(jid) loaded.add(jid)
} }
} }
@@ -794,9 +794,9 @@ describe('tctoken integration scenarios', () => {
} }
const expiredDeletions: Record<string, null> = {} const expiredDeletions: Record<string, null> = {}
for(const jid of knownJids) { for (const jid of knownJids) {
const entry = allTokens[jid] const entry = allTokens[jid]
if(!entry?.token || isTcTokenExpired(entry.timestamp)) { if (!entry?.token || isTcTokenExpired(entry.timestamp)) {
expiredDeletions[jid] = null expiredDeletions[jid] = null
knownJids.delete(jid) knownJids.delete(jid)
} }
@@ -823,7 +823,7 @@ describe('tctoken integration scenarios', () => {
} }
// First add → triggers save // First add → triggers save
if(!knownJids.has(JID_A)) { if (!knownJids.has(JID_A)) {
knownJids.add(JID_A) knownJids.add(JID_A)
scheduleSave() scheduleSave()
} }
@@ -831,7 +831,7 @@ describe('tctoken integration scenarios', () => {
expect(saveCount).toBe(1) expect(saveCount).toBe(1)
// Duplicate add → no save // Duplicate add → no save
if(!knownJids.has(JID_A)) { if (!knownJids.has(JID_A)) {
knownJids.add(JID_A) knownJids.add(JID_A)
scheduleSave() scheduleSave()
} }
@@ -839,7 +839,7 @@ describe('tctoken integration scenarios', () => {
expect(saveCount).toBe(1) expect(saveCount).toBe(1)
// New JID → triggers save // New JID → triggers save
if(!knownJids.has(JID_B)) { if (!knownJids.has(JID_B)) {
knownJids.add(JID_B) knownJids.add(JID_B)
scheduleSave() scheduleSave()
} }
+22
View File
@@ -3,6 +3,28 @@
// This MUST be at the very top to intercept console before libsignal loads // This MUST be at the very top to intercept console before libsignal loads
// ============================================ // ============================================
const _origConsoleError = console.error const _origConsoleError = console.error
const _origConsoleLog = console.log
const _origConsoleInfo = console.info
// Suppress libsignal session lifecycle dumps from console.log / console.info.
// libsignal's session_record.js uses console.info("Removing old closed session:", obj)
// and console.log("Closing session:", obj) which dump full session objects (~500ms I/O each).
const _SESSION_LIFECYCLE_RE = /^(Closing session|Removing old closed session)/
console.log = function (...args: unknown[]) {
if (args.length > 0 && typeof args[0] === 'string' && _SESSION_LIFECYCLE_RE.test(args[0])) {
return
}
_origConsoleLog.apply(console, args)
}
console.info = function (...args: unknown[]) {
if (args.length > 0 && typeof args[0] === 'string' && _SESSION_LIFECYCLE_RE.test(args[0])) {
return
}
_origConsoleInfo.apply(console, args)
}
// Track errors by type + JID to avoid duplicates (using Map for better performance) // Track errors by type + JID to avoid duplicates (using Map for better performance)
const _errorTimestamps = new Map<string, number>() const _errorTimestamps = new Map<string, number>()