diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 7f7a140d..7898c4a6 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -188,10 +188,12 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] /** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */ export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000 -export const MIN_PREKEY_COUNT = 5 +// Replenishment threshold: when server count drops below this, top-up back to INITIAL_PREKEY_COUNT +export const MIN_PREKEY_COUNT = 200 -// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability) -export const INITIAL_PREKEY_COUNT = 200 +// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration) +// Rounded to 800 for cleanliness; replenishment always tops up to this value +export const INITIAL_PREKEY_COUNT = 800 export const UPLOAD_TIMEOUT = 30000 // 30 seconds // Moderate upload interval to balance rate limiting and responsiveness (was 5000) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 1a08ea88..22267b4b 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -7,6 +7,7 @@ import { proto } from '../../WAProto/index.js' import { DEFAULT_CACHE_TTLS, DEFAULT_SESSION_CLEANUP_CONFIG, + INITIAL_PREKEY_COUNT, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, @@ -1440,7 +1441,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count') if (shouldUploadMorePreKeys) { - await uploadPreKeys() + // Top-up back to INITIAL_PREKEY_COUNT so the pool is always restored to full size + await uploadPreKeys(Math.max(1, INITIAL_PREKEY_COUNT - count)) } } else { const result = await handleIdentityChange(node, { diff --git a/src/Socket/messages-send.ts b/src/Socket/messages-send.ts index cb2ec727..2fe9ae8e 100644 --- a/src/Socket/messages-send.ts +++ b/src/Socket/messages-send.ts @@ -9,6 +9,7 @@ import type { AlbumMessageOptions, AlbumSendResult, AnyMessageContent, + LIDMapping, MediaConnInfo, MessageReceiptType, MessageRelayOptions, @@ -338,6 +339,33 @@ export const makeMessagesSocket = (config: SocketConfig) => { } } + // 4th LID→PN source: device-list entries sharing the same raw_id + // WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings + const allEntries = [...result.list, ...result.sideList] + const rawIdMap = new Map() + for (const item of allEntries) { + if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue + const decoded = jidDecode(item.id) + if (!decoded) continue + const entry = rawIdMap.get(item.rawId) || {} + if (decoded.server === 'lid' || decoded.server === 'hosted.lid') { + entry.lid = item.id + } else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') { + entry.pn = item.id + } + rawIdMap.set(item.rawId, entry) + } + const rawIdMappings: LIDMapping[] = [] + for (const { pn, lid } of rawIdMap.values()) { + if (pn && lid) { + rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) }) + } + } + if (rawIdMappings.length > 0) { + await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings) + logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing') + } + const meId = authState.creds.me?.id if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) const meLid = authState.creds.me?.lid || '' diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 0fdc80d6..a4fc06f7 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -785,7 +785,7 @@ export const makeSocket = (config: SocketConfig) => { let count = 0 const preKeyCount = await getAvailablePreKeysOnServer() if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT - else count = MIN_PREKEY_COUNT + else count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists() logger.info(`${preKeyCount} pre-keys found on server`) @@ -1151,7 +1151,7 @@ export const makeSocket = (config: SocketConfig) => { // Decrement active connections decrementActiveConnections() - clearInterval(keepAliveReq) + clearTimeout(keepAliveReq) clearTimeout(qrTimer) // Clear offline-buffer safety timer so its callback cannot call ev.flush() @@ -1262,8 +1262,16 @@ export const makeSocket = (config: SocketConfig) => { }) } - const startKeepAliveRequest = () => - (keepAliveReq = setInterval(() => { + const startKeepAliveRequest = () => { + // Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour + // (WA Business Desktop: ~25-30s intervals with natural variance) + const scheduleNextKeepAlive = () => { + const jitter = keepAliveIntervalMs * 0.15 + const delay = keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter) + keepAliveReq = setTimeout(onKeepAliveTick, delay) + } + + const onKeepAliveTick = () => { if (!lastDateRecv) { lastDateRecv = new Date() } @@ -1275,6 +1283,7 @@ export const makeSocket = (config: SocketConfig) => { */ if (diff > keepAliveIntervalMs + 5000) { void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) + return // connection closing — do not reschedule } else if (ws.isOpen) { // Send keep-alive ping via sendNode() (fire-and-forget) instead of query(). // query() wraps the ping in the query circuit breaker — when that breaker is @@ -1297,7 +1306,12 @@ export const makeSocket = (config: SocketConfig) => { } else { logger.warn('keep alive called when WS not open') } - }, keepAliveIntervalMs)) + + scheduleNextKeepAlive() + } + + scheduleNextKeepAlive() + } /** i have no idea why this exists. pls enlighten me */ const sendPassiveIq = (tag: 'passive' | 'active') => query({ diff --git a/src/Utils/history.ts b/src/Utils/history.ts index 58ff7bb4..61b20871 100644 --- a/src/Utils/history.ts +++ b/src/Utils/history.ts @@ -15,7 +15,7 @@ import { import { toNumber } from './generics' import type { ILogger } from './logger.js' import { normalizeMessageContent } from './messages' -import { downloadContentFromMessage } from './messages-media' +import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media' const inflatePromise = promisify(inflate) @@ -423,7 +423,19 @@ export const downloadAndProcessHistorySyncNotification = async ( historyMsg = await downloadHistory(msg, options) } - return processHistoryMessage(historyMsg, logger) + const result = processHistoryMessage(historyMsg, logger) + + // Mirror WA Desktop behaviour: DELETE the CDN blob only after processing succeeds. + // Doing this earlier (e.g. inside downloadHistory) risks permanent history loss if + // processing throws — the server copy would be gone and retry after reconnect would fail. + if (msg.directPath) { + const cdnUrl = getUrlFromDirectPath(msg.directPath) + fetch(cdnUrl, { ...options, method: 'DELETE' }).catch(() => { + // non-fatal — server will expire it anyway + }) + } + + return result } /** diff --git a/src/WAUSync/USyncQuery.ts b/src/WAUSync/USyncQuery.ts index 15df485f..39b8b9e2 100644 --- a/src/WAUSync/USyncQuery.ts +++ b/src/WAUSync/USyncQuery.ts @@ -10,7 +10,7 @@ import { } from './Protocols' import { USyncUser } from './USyncUser' -export type USyncQueryResultList = { [protocol: string]: unknown; id: string } +export type USyncQueryResultList = { [protocol: string]: unknown; id: string; rawId?: number } export type USyncQueryResult = { list: USyncQueryResultList[] @@ -68,10 +68,8 @@ export class USyncQuery { //TODO: see if there are any errors in the result node //const resultNode = getBinaryNodeChild(usyncNode, 'result') - const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined - - if (listNode?.content && Array.isArray(listNode.content)) { - queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => { + const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] => + content.reduce((acc: USyncQueryResultList[], node) => { const id = node?.attrs.jid if (id) { const data = Array.isArray(node?.content) @@ -89,15 +87,24 @@ export class USyncQuery { .filter(([, b]) => b !== null) as [string, unknown][] ) : {} - acc.push({ ...data, id }) + const rawIdAttr = node?.attrs?.['raw_id'] + const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined + acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) }) } return acc }, []) + + const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined + if (listNode?.content && Array.isArray(listNode.content)) { + queryResult.list = parseNodeList(listNode.content) + } + + const sideListNode = usyncNode ? getBinaryNodeChild(usyncNode, 'side_list') : undefined + if (sideListNode?.content && Array.isArray(sideListNode.content)) { + queryResult.sideList = parseNodeList(sideListNode.content) } - //TODO: implement side list - //const sideListNode = getBinaryNodeChild(usyncNode, 'side_list') return queryResult }