diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 148a8e12..9d34f1d1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,9 +24,6 @@ jobs: corepack enable corepack prepare yarn@4.x --activate - - name: Fix Git config - run: git config --global core.autocrlf input - - name: Configure Git for HTTPS run: | # Reescreve SSH -> HTTPS para repositórios públicos do GitHub (sem token, evita erros de autenticação) @@ -48,6 +45,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GIT_TERMINAL_PROMPT: 0 + YARN_HTTP_TIMEOUT: 600000 + YARN_HTTP_RETRY: 10 run: yarn install --immutable - name: Auto-fix formatting diff --git a/.yarnrc.yml b/.yarnrc.yml index 3186f3f0..58203353 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,3 @@ nodeLinker: node-modules +httpRetry: 10 +httpTimeout: 600000 diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index e2c30b85..0f4a516d 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -517,165 +517,169 @@ export function makeLibSignalRepository( } 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 - // every incoming LID message. Cache is invalidated after 5 minutes. - // 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. - let userDevices: string[] | undefined = deviceListCache.get(user) - if (userDevices === undefined) { - logger.debug({ fromJid }, 'bulk device migration - loading all user devices from DB') - const result = await parsedKeys.get('device-list', [user]) - userDevices = result[user] ?? [] - deviceListCache.set(user, userDevices) - } else { - 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) + // 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. + // 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. + let userDevices: string[] | undefined = deviceListCache.get(user) + if (userDevices === undefined) { + logger.debug({ fromJid }, 'bulk device migration - loading all user devices from DB') + const result = await parsedKeys.get('device-list', [user]) + userDevices = result[user] ?? [] + deviceListCache.set(user, userDevices) + } else { + logger.trace({ fromJid, deviceCount: userDevices.length }, 'bulk device migration - device list from cache') } - } - logger.debug( - { - 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) + if (userDevices.length === 0) { + return { migrated: 0, skipped: 0, total: 0 } } - 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 - } + // Work on a copy so we don't mutate the cached array + userDevices = [...userDevices] - const migrationOps: MigrationOp[] = deviceJids.map(jid => { - 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}`) + 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` } - return { - fromJid: jid, - toJid: lidWithDevice, - pnUser: fromDecoded.user, - lidUser: toDecoded.user, - deviceId: fromDecoded.device || 0, - fromAddr: jidToSignalProtocolAddress(jid), - toAddr: jidToSignalProtocolAddress(lidWithDevice) + deviceJids.push(jid) + } + } + + logger.debug( + { + 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 + 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 - let migratedCount = 0 + const migrationOps: MigrationOp[] = deviceJids.map(jid => { + 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 - // signal-address format (user.device) is identical to deviceSessionKeys, so - // existingSessions already contains every session needed here. - // Avoids a redundant storage round-trip inside the transaction. - const pnSessions = existingSessions + return { + fromJid: jid, + toJid: lidWithDevice, + pnUser: fromDecoded.user, + lidUser: toDecoded.user, + deviceId: fromDecoded.device || 0, + fromAddr: jidToSignalProtocolAddress(jid), + toAddr: jidToSignalProtocolAddress(lidWithDevice) + } + }) - // Prepare bulk session updates (PN → LID migration + deletion) - const sessionUpdates: { [key: string]: Uint8Array | null } = {} + const totalOps = migrationOps.length + let migratedCount = 0 - for (const op of migrationOps) { - const pnAddrStr = op.fromAddr.toString() - const lidAddrStr = op.toAddr.toString() + // Reuse existingSessions fetched above — for PN users on s.whatsapp.net the + // signal-address format (user.device) is identical to deviceSessionKeys, so + // existingSessions already contains every session needed here. + // Avoids a redundant storage round-trip inside the transaction. + const pnSessions = existingSessions - 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 + // Prepare bulk session updates (PN → LID migration + deletion) + const sessionUpdates: { [key: string]: Uint8Array | 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 - if (Object.keys(sessionUpdates).length > 0) { - await parsedKeys.set({ session: sessionUpdates }) - logger.debug({ migratedSessions: migratedCount }, 'bulk session migration complete') - } + // Single bulk session update for all migrations + if (Object.keys(sessionUpdates).length > 0) { + await parsedKeys.set({ session: sessionUpdates }) + logger.debug({ migratedSessions: migratedCount }, 'bulk session migration complete') + } - // Cache ALL processed devices (migrated, skipped, or closed-session) to prevent - // redundant DB lookups on subsequent messages from the same contact. - for (const op of migrationOps) { - migratedSessionCache.set(`${op.pnUser}.${op.deviceId}`, true) - } + // Cache ALL processed devices (migrated, skipped, or closed-session) to prevent + // redundant DB lookups on subsequent messages from the same contact. + for (const op of migrationOps) { + migratedSessionCache.set(`${op.pnUser}.${op.deviceId}`, true) + } - const skippedCount = totalOps - migratedCount - return { migrated: migratedCount, skipped: skippedCount, total: totalOps } - }, - `migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}` - ) + const skippedCount = totalOps - migratedCount + return { migrated: migratedCount, skipped: skippedCount, total: totalOps } + }, + `migrate-${deviceJids.length}-sessions-${jidDecode(toJid)?.user}` + ) })() migrationInFlight.set(user, migrationPromise) diff --git a/src/Socket/chats.ts b/src/Socket/chats.ts index ac9eb6ae..cd89ce0f 100644 --- a/src/Socket/chats.ts +++ b/src/Socket/chats.ts @@ -2,7 +2,12 @@ import NodeCache from '@cacheable/node-cache' import { Boom } from '@hapi/boom' import { LRUCache } from 'lru-cache' 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 { BotListInfo, CacheStore, @@ -777,7 +782,7 @@ export const makeChatsSocket = (config: SocketConfig) => { me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid))) let content: BinaryNode[] | undefined = baseContent - if(isUserJid && !isSelf) { + if (isUserJid && !isSelf) { content = await buildTcTokenFromJid({ authState, jid: normalizedJid, @@ -1438,7 +1443,9 @@ export const makeChatsSocket = (config: SocketConfig) => { // of the state machine phase (see the Syncing → Online path below). const isReconnection = (authState.creds.accountSyncCounter ?? 0) > 0 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() syncState = SyncState.Online const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1 diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index a11e3cdc..6ff06546 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -47,8 +47,8 @@ import { MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, - SERVER_ERROR_CODES, normalizeMessageJids, + SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, xmppPreKey, @@ -63,6 +63,7 @@ import { recordMessageReceived, recordMessageRetry } from '../Utils/prometheus-metrics.js' +import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils' import { areJidsSameUser, type BinaryNode, @@ -81,7 +82,6 @@ import { jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' -import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils' import { extractGroupMetadata } from './groups' import { makeMessagesSocket } from './messages-send' @@ -166,23 +166,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { try { const data = await authState.keys.get('tctoken', [TC_TOKEN_INDEX_KEY, TC_TOKEN_PRUNE_TS_KEY]) const entry = data[TC_TOKEN_INDEX_KEY] - if(entry?.token) { + 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) + if (Array.isArray(stored)) { + for (const jid of stored) tcTokenKnownJids.add(jid) } } const pruneEntry = data[TC_TOKEN_PRUNE_TS_KEY] - if(pruneEntry?.timestamp) { + if (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) */ const scheduleTcTokenIndexSave = () => { - if(tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer) + if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer) tcTokenIndexSaveTimer = setTimeout(async () => { try { 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') } }, 5000) @@ -207,13 +209,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const survivingJids: string[] = [] const jidsToCheck = Array.from(tcTokenKnownJids).filter(j => j !== TC_TOKEN_INDEX_KEY) - if(!jidsToCheck.length) return + if (!jidsToCheck.length) return try { const allData = await authState.keys.get('tctoken', jidsToCheck) - for(const jid of jidsToCheck) { + for (const jid of jidsToCheck) { const entry = allData[jid] - if(!entry?.token || isTcTokenExpired(entry.timestamp)) { + if (!entry?.token || isTcTokenExpired(entry.timestamp)) { pruneSet[jid] = null } else { survivingJids.push(jid) @@ -224,10 +226,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } const pruneCount = Object.keys(pruneSet).length - if(pruneCount > 0) { + if (pruneCount > 0) { await authState.keys.set({ tctoken: pruneSet }) tcTokenKnownJids.clear() - for(const jid of survivingJids) tcTokenKnownJids.add(jid) + for (const jid of survivingJids) tcTokenKnownJids.add(jid) scheduleTcTokenIndexSave() 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 - if(result.action === 'session_refreshed') { + if (result.action === 'session_refreshed') { const normalizedJid = jidNormalizedUser(from) - resolveTcTokenJid(normalizedJid, getLIDForPN).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 */ }) + resolveTcTokenJid(normalizedJid, getLIDForPN) + .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') { + if (result.action === 'no_identity_node') { logger.info({ node }, 'unknown encrypt notification') } } @@ -1060,32 +1066,32 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const tokensNode = getBinaryNodeChild(node, 'tokens') const from = jidNormalizedUser(node.attrs.from) - if(!tokensNode) return + if (!tokensNode) return const tokenNodes = getBinaryNodeChildren(tokensNode, 'token') - for(const tokenNode of tokenNodes) { + for (const tokenNode of tokenNodes) { const { attrs, content } = tokenNode const type = attrs.type const timestamp = attrs.t - if(type === 'trusted_contact' && content instanceof Uint8Array) { + if (type === 'trusted_contact' && content instanceof Uint8Array) { // Resolve to LID for consistent storage key const senderLid = attrs.sender_lid ? jidNormalizedUser(attrs.sender_lid) : undefined - const storageJid = senderLid || await resolveTcTokenJid(from, getLIDForPN) + const storageJid = senderLid || (await resolveTcTokenJid(from, getLIDForPN)) // Timestamp monotonicity guard — only store if incoming >= existing const existingData = await authState.keys.get('tctoken', [storageJid]) const existing = existingData[storageJid] const existingTs = existing?.timestamp ? Number(existing.timestamp) : 0 const incomingTs = timestamp ? Number(timestamp) : 0 - if(existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) { + if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) { continue } // Don't store timestamp-less tokens — they expire immediately and would // corrupt a valid existing entry if one is already present - if(!incomingTs) { + if (!incomingTs) { continue } @@ -1263,7 +1269,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { } if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) { - logger.debug({ remoteJid }, 'ignoring receipt from jid') + logger.trace({ remoteJid }, 'ignoring receipt from jid') await sendMessageAck(node) return } @@ -1343,7 +1349,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const handleNotification = async (node: BinaryNode) => { const remoteJid = node.attrs.from if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) { - logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification') + logger.trace({ remoteJid }, 'ignored notification') await sendMessageAck(node) return } @@ -1379,8 +1385,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { const handleMessage = async (node: BinaryNode) => { if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) { - logger.debug({ key: node.attrs.key }, 'ignored message') - await sendMessageAck(node, NACK_REASONS.UnhandledError) + logger.trace({ from: node.attrs.from }, 'ignored message') + // 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 } @@ -1879,8 +1888,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // error in acknowledgement, // device could not display the message - if(attrs.error) { - if(attrs.error === SERVER_ERROR_CODES.MissingTcToken) { + if (attrs.error) { + if (attrs.error === SERVER_ERROR_CODES.MissingTcToken) { const msgId = attrs.id const jid = jidNormalizedUser(attrs.from) logTcToken('error_463', { jid, msgId }) @@ -1889,11 +1898,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { // then resend. A Set prevents infinite retry loops. // Composite key (jid:msgId) ensures retries are isolated per destination. const retryKey = `${jid}:${msgId}` - if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { + if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { tcTokenRetriedMsgIds.add(retryKey) // Each entry auto-expires after 60s — naturally bounded under normal use setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000) - ;(async () => { try { await delay(1500) @@ -1901,21 +1909,26 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { (await getMessage(key)) ?? // Fallback: ack can arrive <30ms after send, before store persists messageRetryManager?.getRecentMessage(jid, msgId)?.message - if(msg) { + if (msg) { await relayMessage(jid, msg, { messageId: msgId, useUserDevicesCache: true }) logTcToken('retry_463_ok', { jid, msgId }) } else { 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') - ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } }]) + ev.emit('messages.update', [ + { key, update: { status: WAMessageStatus.ERROR, messageStubParameters: ['463'] } } + ]) } })() + 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 }) } else { logger.warn({ attrs }, 'received error in ack') @@ -2087,42 +2100,54 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { ev.on('connection.update', ({ isOnline, connection }) => { // Flush pending tctoken index save on disconnect to avoid writing after close - if(connection === 'close' && tcTokenIndexSaveTimer) { + if (connection === 'close' && tcTokenIndexSaveTimer) { clearTimeout(tcTokenIndexSaveTimer) tcTokenIndexSaveTimer = undefined // Await index load first — prevents overwriting a more complete persisted index // if the connection closes before the initial load finishes. - tcTokenIndexLoaded.then(() => { - Promise.resolve(authState.keys.set({ - tctoken: { - [TC_TOKEN_INDEX_KEY]: { - token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'), - timestamp: unixTimestampSeconds().toString() - } - } - })).catch(() => { /* non-critical */ }) - }).catch(() => { /* non-critical */ }) + tcTokenIndexLoaded + .then(() => { + Promise.resolve( + authState.keys.set({ + tctoken: { + [TC_TOKEN_INDEX_KEY]: { + token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'), + timestamp: unixTimestampSeconds().toString() + } + } + }) + ).catch(() => { + /* non-critical */ + }) + }) + .catch(() => { + /* non-critical */ + }) } - if(typeof isOnline !== 'undefined') { + if (typeof isOnline !== 'undefined') { sendActiveReceipts = isOnline logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`) // Prune expired tctokens when coming online (max once per 24h) - if(isOnline) { + if (isOnline) { const now = Date.now() const ONE_DAY_MS = 86400000 - if(now - lastTcTokenPruneTs > ONE_DAY_MS) { + if (now - lastTcTokenPruneTs > ONE_DAY_MS) { lastTcTokenPruneTs = now // Persist prune timestamp so it survives restarts - Promise.resolve(authState.keys.set({ - tctoken: { - [TC_TOKEN_PRUNE_TS_KEY]: { - token: Buffer.alloc(0), - timestamp: now.toString() + Promise.resolve( + authState.keys.set({ + tctoken: { + [TC_TOKEN_PRUNE_TS_KEY]: { + token: Buffer.alloc(0), + timestamp: now.toString() + } } - } - })).catch(() => { /* non-critical */ }) + }) + ).catch(() => { + /* non-critical */ + }) pruneExpiredTcTokens().catch(err => { logger.debug({ err: err?.message }, 'tctoken pruning failed') }) diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index e0753d53..c638841f 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -1457,9 +1457,7 @@ export const makeMessagesSocket = (config: SocketConfig) => { const is1on1Send = !isGroup && !isRetryResend && !isStatus && !isNewsletter && !isPeerMessage // Resolve destination to LID for tctoken storage — matches Signal session key pattern - const tcTokenJid = is1on1Send - ? await resolveTcTokenJid(destinationJid, getLIDForPN) - : destinationJid + const tcTokenJid = is1on1Send ? await resolveTcTokenJid(destinationJid, getLIDForPN) : destinationJid const contactTcTokenData = is1on1Send ? await authState.keys.get('tctoken', [tcTokenJid]) : {} const existingTokenEntry = contactTcTokenData[tcTokenJid] let tcTokenBuffer = existingTokenEntry?.token diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 8381418d..33e69cab 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -321,7 +321,7 @@ export const makeSocket = (config: SocketConfig) => { } catch (error) { // Catch timeout and return undefined instead of throwing 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 } @@ -1539,6 +1539,7 @@ export const makeSocket = (config: SocketConfig) => { if (passiveResult.status === 'rejected') { logger.warn({ err: passiveResult.reason }, 'failed to send initial passive iq') } + for (const result of results.slice(1)) { if (result.status === 'rejected') { 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. // start() is idempotent (guarded by cleanupInterval check) so deferring is safe. const _cleanupStartTimer = setTimeout(() => sessionCleanup.start(), 5_000) - if (typeof (_cleanupStartTimer as NodeJS.Timeout).unref === 'function') { - ;(_cleanupStartTimer as NodeJS.Timeout).unref() + if (typeof _cleanupStartTimer.unref === 'function') { + _cleanupStartTimer.unref() } // Start session activity tracker immediately (lightweight, no DB scan) diff --git a/src/Utils/baileys-logger.ts b/src/Utils/baileys-logger.ts index 001ce99b..91891ab3 100644 --- a/src/Utils/baileys-logger.ts +++ b/src/Utils/baileys-logger.ts @@ -958,7 +958,19 @@ export function logLidMapping( * // 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' | 'retry_463_ok', + event: + | 'stored' + | 'expired' + | 'fetch' + | 'fetched' + | 'reissue' + | 'reissue_ok' + | 'reissue_fail' + | 'prune' + | 'error_463' + | 'error_479' + | 'attached' + | 'retry_463_ok', data?: Record, sessionName?: string ): void { diff --git a/src/Utils/chat-utils.ts b/src/Utils/chat-utils.ts index ba98aa0e..99e40725 100644 --- a/src/Utils/chat-utils.ts +++ b/src/Utils/chat-utils.ts @@ -138,6 +138,7 @@ export const ensureLTHashStateVersion = (state: LTHashState): LTHashState => { if (typeof state.version !== 'number' || isNaN(state.version)) { state.version = 0 } + return state } diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index ddb8a019..d328a58d 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -376,9 +376,9 @@ export const getErrorCodeFromStreamError = (node: BinaryNode) => { // Conflict child: type attribute determines connectionReplaced vs 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 - if(conflictType === 'replaced') { + if (conflictType === 'replaced') { 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 const statusCode = +(node.attrs.code || reasonNode?.attrs?.code || CODE_MAP[reason] || DisconnectReason.badSession) - if(statusCode === DisconnectReason.restartRequired) { + if (statusCode === DisconnectReason.restartRequired) { reason = 'restart required' - } else if(statusCode === DisconnectReason.sessionInvalidated) { + } else if (statusCode === DisconnectReason.sessionInvalidated) { reason = 'session invalidated' - } else if(node.attrs.code) { + } else if (node.attrs.code) { reason = `code ${statusCode}` } diff --git a/src/Utils/message-retry-manager.ts b/src/Utils/message-retry-manager.ts index b50d62bc..389d6ec0 100644 --- a/src/Utils/message-retry-manager.ts +++ b/src/Utils/message-retry-manager.ts @@ -180,6 +180,7 @@ export class MessageRetryManager { // to prevent repeated futile lookups on subsequent retry receipts. this.messageKeyIndex.delete(id) } + return message } diff --git a/src/Utils/messages.ts b/src/Utils/messages.ts index aea86040..12cdf916 100644 --- a/src/Utils/messages.ts +++ b/src/Utils/messages.ts @@ -1241,7 +1241,7 @@ export const generateWAMessageContent = async ( const generated = await generateCarouselMessage(carouselOptions, options) // Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer m.interactiveMessage = generated.interactiveMessage - return m as proto.IMessage + return m } // Check for nativeList else if (hasNonNullishProperty(message, 'nativeList')) { @@ -1349,7 +1349,10 @@ export const generateWAMessageContent = async ( })) 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')) { // Process carousel/interactive messages with viewOnceMessage wrapper const carousel = (message as any).carousel @@ -1810,7 +1813,8 @@ export const generateWAMessageFromContent = ( } // 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 if ( // 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) // 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 if (!isCarouselMsg) { message = WAProto.Message.create(message) diff --git a/src/Utils/tc-token-utils.ts b/src/Utils/tc-token-utils.ts index 27caa7bd..9f680bcd 100644 --- a/src/Utils/tc-token-utils.ts +++ b/src/Utils/tc-token-utils.ts @@ -18,9 +18,9 @@ const TC_TOKEN_NUM_BUCKETS = 4 * 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 + if (timestamp === null || timestamp === undefined) return true 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 currentBucket = Math.floor(now / TC_TOKEN_BUCKET_DURATION) 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. */ 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 currentBucket = Math.floor(now / 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 ): Promise { const normalized = jidNormalizedUser(jid) - if(isLidUser(normalized)) return normalized + if (isLidUser(normalized)) return normalized const lid = await getLIDForPN(normalized) return lid ?? normalized } @@ -84,9 +84,9 @@ export async function buildTcTokenFromJid({ const entry = tcTokenData?.[storageJid] const tcTokenBuffer = entry?.token - if(!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) { + if (!tcTokenBuffer?.length || isTcTokenExpired(entry?.timestamp)) { // Opportunistic cleanup: remove expired token from store - if(tcTokenBuffer) { + if (tcTokenBuffer) { await authState.keys.set({ tctoken: { [storageJid]: null } }) } @@ -100,7 +100,7 @@ export async function buildTcTokenFromJid({ }) return baseContent - } catch(error) { + } catch (error) { return baseContent.length > 0 ? baseContent : undefined } } @@ -127,11 +127,11 @@ export async function storeTcTokensFromIqResult({ onNewJidStored }: StoreTcTokensParams) { const tokensNode = getBinaryNodeChild(result, 'tokens') - if(!tokensNode) return + if (!tokensNode) return const tokenNodes = getBinaryNodeChildren(tokensNode, 'token') - for(const tokenNode of tokenNodes) { - if(tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) { + for (const tokenNode of tokenNodes) { + if (tokenNode.attrs.type !== 'trusted_contact' || !(tokenNode.content instanceof Uint8Array)) { continue } @@ -144,13 +144,13 @@ export async function storeTcTokensFromIqResult({ // 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) { + if (existingTs > 0 && incomingTs > 0 && existingTs > incomingTs) { continue } // Don't store timestamp-less tokens at all — isTcTokenExpired treats them // as immediately expired regardless of whether an existing entry is present - if(!incomingTs) { + if (!incomingTs) { continue } diff --git a/src/Utils/unified-session.ts b/src/Utils/unified-session.ts index 0ac9e469..cff005f7 100644 --- a/src/Utils/unified-session.ts +++ b/src/Utils/unified-session.ts @@ -178,7 +178,6 @@ export class UnifiedSessionManager { const serverTimeNum = typeof serverTime === 'string' ? parseInt(serverTime, 10) : serverTime if (isNaN(serverTimeNum) || serverTimeNum <= 0) { - this.options.logger.debug?.({ serverTime }, 'Invalid server time received, ignoring') return } @@ -187,12 +186,20 @@ export class UnifiedSessionManager { const localTimeMs = Date.now() 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) if (Math.abs(newOffset - this.state.serverTimeOffset) > 1000) { - const oldOffset = this.state.serverTimeOffset 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 metrics.socketEvents?.inc({ event: 'server_time_sync' }) diff --git a/src/__tests__/Socket/bad-ack-handling.test.ts b/src/__tests__/Socket/bad-ack-handling.test.ts index 05c1d41a..020bebba 100644 --- a/src/__tests__/Socket/bad-ack-handling.test.ts +++ b/src/__tests__/Socket/bad-ack-handling.test.ts @@ -25,7 +25,7 @@ interface MockMessageRetryManager { /** Mirrors jidNormalizedUser: strips device suffix from JID user part */ function jidNormalizedUser(jid: string): string { const atIdx = jid.indexOf('@') - if(atIdx < 0) return jid + if (atIdx < 0) return jid const user = jid.slice(0, atIdx) const server = jid.slice(atIdx + 1) const normalizedUser = user.includes(':') ? user.split(':')[0] : user @@ -46,9 +46,9 @@ async function handleBadAck463( const jid = jidNormalizedUser(attrs.from) const key: MockKey = { remoteJid: attrs.from, fromMe: true, id: msgId } - if(attrs.error === '463') { + if (attrs.error === '463') { const retryKey = `${jid}:${msgId}` - if(msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { + if (msgId && jid && !tcTokenRetriedMsgIds.has(retryKey)) { tcTokenRetriedMsgIds.add(retryKey) // Each entry auto-expires after 60s — naturally bounded under normal use setTimeout(() => tcTokenRetriedMsgIds.delete(retryKey), 60_000) @@ -57,7 +57,7 @@ async function handleBadAck463( (await getMessage(key)) ?? // Fallback: ack can arrive <30ms after send, before store persists messageRetryManager?.getRecentMessage(jid, msgId)?.message - if(msg) { + if (msg) { try { await delayFn(1500) await relayMessage(jid, msg, { @@ -190,7 +190,7 @@ describe('handleBadAck error 463 retry', () => { }) it('should not retry for non-463 errors', async () => { - for(const errorCode of ['479', '421']) { + for (const errorCode of ['479', '421']) { mockGetMessage.mockClear() mockRelayMessage.mockClear() mockEmit.mockClear() @@ -274,7 +274,9 @@ describe('handleBadAck error 463 retry', () => { it('should fall back to messageRetryManager when getMessage returns undefined', async () => { const cachedMsg = { conversation: 'cached' } 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) mockRelayMessage.mockResolvedValue(undefined) diff --git a/src/__tests__/Socket/offline-buffer-timeout.test.ts b/src/__tests__/Socket/offline-buffer-timeout.test.ts index 3b4fc69c..6ad2a05c 100644 --- a/src/__tests__/Socket/offline-buffer-timeout.test.ts +++ b/src/__tests__/Socket/offline-buffer-timeout.test.ts @@ -36,15 +36,11 @@ function makeState(): OfflineBufferState { * Mirrors the process.nextTick block that arms the offline-buffer timer. * Only called when creds.me?.id is set (reconnection path). */ -function startBuffer( - state: OfflineBufferState, - flush: () => void, - warn: () => void -): void { +function startBuffer(state: OfflineBufferState, flush: () => void, warn: () => void): void { state.didStartBuffer = true state.offlineBufferTimeout = setTimeout(() => { state.offlineBufferTimeout = undefined - if(state.didStartBuffer) { + if (state.didStartBuffer) { warn() flush() state.didStartBuffer = false @@ -57,12 +53,12 @@ function startBuffer( * delivers all offline notifications before the safety timer fires. */ function onOffline(state: OfflineBufferState, flush: () => void): void { - if(state.offlineBufferTimeout) { + if (state.offlineBufferTimeout) { clearTimeout(state.offlineBufferTimeout) state.offlineBufferTimeout = undefined } - if(state.didStartBuffer) { + if (state.didStartBuffer) { flush() 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. */ function onClose(state: OfflineBufferState): void { - if(state.offlineBufferTimeout) { + if (state.offlineBufferTimeout) { clearTimeout(state.offlineBufferTimeout) state.offlineBufferTimeout = undefined } @@ -97,7 +93,7 @@ describe('offline-buffer safety timer (socket.ts)', () => { afterEach(() => { // Clean up any remaining timer to avoid cross-test interference - if(state.offlineBufferTimeout) { + if (state.offlineBufferTimeout) { clearTimeout(state.offlineBufferTimeout) } diff --git a/src/__tests__/Utils/tc-token.test.ts b/src/__tests__/Utils/tc-token.test.ts index b08ea96c..8c53ea1f 100644 --- a/src/__tests__/Utils/tc-token.test.ts +++ b/src/__tests__/Utils/tc-token.test.ts @@ -596,8 +596,8 @@ describe('tctoken integration scenarios', () => { const expiredJids: string[] = [] const validJids: string[] = [] - for(const [jid, entry] of Object.entries(entries)) { - if(isTcTokenExpired(entry.timestamp)) { + for (const [jid, entry] of Object.entries(entries)) { + if (isTcTokenExpired(entry.timestamp)) { expiredJids.push(jid) } else { validJids.push(jid) @@ -617,8 +617,8 @@ describe('tctoken integration scenarios', () => { } const deletions: Record = {} - for(const [jid, entry] of Object.entries(entries)) { - if(isTcTokenExpired(entry.timestamp)) { + for (const [jid, entry] of Object.entries(entries)) { + if (isTcTokenExpired(entry.timestamp)) { deletions[jid] = null } } @@ -635,8 +635,8 @@ describe('tctoken integration scenarios', () => { } const deletions: Record = {} - for(const [jid, entry] of Object.entries(entries)) { - if(isTcTokenExpired(entry.timestamp)) { + for (const [jid, entry] of Object.entries(entries)) { + if (isTcTokenExpired(entry.timestamp)) { deletions[jid] = null } } @@ -652,8 +652,8 @@ describe('tctoken integration scenarios', () => { } const deletions: Record = {} - for(const [jid, entry] of Object.entries(entries)) { - if(isTcTokenExpired(entry.timestamp)) { + for (const [jid, entry] of Object.entries(entries)) { + if (isTcTokenExpired(entry.timestamp)) { deletions[jid] = null } } @@ -669,8 +669,8 @@ describe('tctoken integration scenarios', () => { } const deletions: Record = {} - for(const [jid, entry] of Object.entries(entries)) { - if(isTcTokenExpired((entry as any).timestamp)) { + for (const [jid, entry] of Object.entries(entries)) { + if (isTcTokenExpired((entry as any).timestamp)) { deletions[jid] = null } } @@ -769,8 +769,8 @@ describe('tctoken integration scenarios', () => { it('index ignores sentinel key when loading', () => { const jids = [JID_A, INDEX_KEY, JID_B, '', null as any] const loaded = new Set() - for(const jid of jids) { - if(jid && jid !== INDEX_KEY) { + for (const jid of jids) { + if (jid && jid !== INDEX_KEY) { loaded.add(jid) } } @@ -794,9 +794,9 @@ describe('tctoken integration scenarios', () => { } const expiredDeletions: Record = {} - for(const jid of knownJids) { + for (const jid of knownJids) { const entry = allTokens[jid] - if(!entry?.token || isTcTokenExpired(entry.timestamp)) { + if (!entry?.token || isTcTokenExpired(entry.timestamp)) { expiredDeletions[jid] = null knownJids.delete(jid) } @@ -823,7 +823,7 @@ describe('tctoken integration scenarios', () => { } // First add → triggers save - if(!knownJids.has(JID_A)) { + if (!knownJids.has(JID_A)) { knownJids.add(JID_A) scheduleSave() } @@ -831,7 +831,7 @@ describe('tctoken integration scenarios', () => { expect(saveCount).toBe(1) // Duplicate add → no save - if(!knownJids.has(JID_A)) { + if (!knownJids.has(JID_A)) { knownJids.add(JID_A) scheduleSave() } @@ -839,7 +839,7 @@ describe('tctoken integration scenarios', () => { expect(saveCount).toBe(1) // New JID → triggers save - if(!knownJids.has(JID_B)) { + if (!knownJids.has(JID_B)) { knownJids.add(JID_B) scheduleSave() } diff --git a/src/index.ts b/src/index.ts index 2221611c..e997ddfd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,28 @@ // This MUST be at the very top to intercept console before libsignal loads // ============================================ 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) const _errorTimestamps = new Map()