Compare commits

..

2 Commits

Author SHA1 Message Date
Renato Alcara 3dc7238dfa test(binary): drain AwaitingInitialSync timer & fix stale comment
Addresses 2 review comments on PR #384:

- Codex P2: the fresh-pair test ended the socket immediately after the
  buffer assertion, leaving the 2s setTimeout in chats.ts:1522 alive.
  Its callback fired post-teardown and emitted "Cannot log after tests
  are done" via baileys-logger, making the suite noisy and trippable
  by --detectOpenHandles. Drain the timer with a 2.1s wait before
  cleanup; bump the per-test timeout to 10s to accommodate it.

- Copilot: the file header said "live messages are not held for up to
  4 seconds", but the chats.ts AwaitingInitialSync timeout is 2s.
  Replace the stale literal with "the initial-sync window" so the
  comment doesn't drift again when the timeout is tuned.

Skipped intentionally (per PR review):
- CodeRabbit Major (try/finally cleanup): diverges from the pattern
  used by the only sibling test (connection-deadlock.test.ts).
- CodeRabbit Nitpick (replace 50ms sleeps with deterministic waits):
  not flaky in practice; sibling test uses the same pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:13:52 -03:00
Renato Alcara 7428528d7b test(binary): cover reconnection sync skip for both signals
Ports the upstream reconnection-sync-skip test from Baileys PR #2350
and adapts it to cover InfiniteAPI's dual-signal reconnect detection
in chats.ts:

1. accountSyncCounter > 0 (a previous full sync completed)
2. socketSkippedOfflineBuffer (forwarded from socket.ts when
   hadStaleRoutingInfo is true, e.g. routingInfo discarded on start)

Both are required because they cover different reconnect scenarios
and the absence of the second branch causes a buffer mismatch where
socket.ts skips the offline buffer while chats.ts still waits in
AwaitingInitialSync — stalling live messages for up to 4 seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:45:12 -03:00
14 changed files with 119 additions and 1222 deletions
+18 -26
View File
@@ -18,21 +18,14 @@ try {
'\tif (typeof value === "number") {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\t// BigInt.toString() is a native C++ operation, much faster than Long\'s pure JS division loops\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\t// Normalize high to signed int32 so the sign-bit check works for inputs\n' +
'\t\t// where high is stored unsigned (e.g. raw {low, high} JSON).\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn (combined - (1n << 64n)).toString();\n' +
'\t\t}\n' +
'\t\treturn combined.toString();\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\treturn String(value);\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: normalized;\n' +
'\treturn prepared.toString();\n' +
'}\n\n'
const longToNumberHelper =
'function longToNumber(value, unsigned) {\n' +
@@ -40,20 +33,19 @@ try {
'\t\treturn value;\n' +
'\t}\n' +
'\tif (typeof value === "string") {\n' +
'\t\tconst numeric = Number(value);\n' +
'\t\treturn numeric;\n' +
'\t}\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn Number(value);\n' +
'\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn Number(combined - (1n << 64n));\n' +
'\t\t}\n' +
'\t\treturn Number(combined);\n' +
'\t}\n' +
'\treturn Number(value);\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: typeof normalized.toSigned === "function"\n' +
'\t\t\t? normalized.toSigned()\n' +
'\t\t\t: normalized;\n' +
'\treturn prepared.toNumber();\n' +
'}\n\n'
if (!content.includes('function longToString(')) {
+18 -26
View File
@@ -12,21 +12,14 @@ function longToString(value, unsigned) {
if (typeof value === "number") {
return String(value);
}
// Fast path: convert Long {low, high} directly via native BigInt
// BigInt.toString() is a native C++ operation, much faster than Long's pure JS division loops
if (value && typeof value.low === "number" && typeof value.high === "number") {
// Normalize high to signed int32 so the sign-bit check works for inputs
// where high is stored unsigned (e.g. raw {low, high} JSON).
const high = value.high | 0;
const lo = BigInt(value.low >>> 0);
const hi = BigInt(value.high >>> 0);
const combined = (hi << 32n) | lo;
if (!unsigned && high < 0) {
return (combined - (1n << 64n)).toString();
}
return combined.toString();
if (!$util.Long) {
return String(value);
}
return String(value);
const normalized = $util.Long.fromValue(value);
const prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"
? normalized.toUnsigned()
: normalized;
return prepared.toString();
}
function longToNumber(value, unsigned) {
@@ -34,20 +27,19 @@ function longToNumber(value, unsigned) {
return value;
}
if (typeof value === "string") {
const numeric = Number(value);
return numeric;
}
if (!$util.Long) {
return Number(value);
}
// Fast path: convert Long {low, high} directly via native BigInt
if (value && typeof value.low === "number" && typeof value.high === "number") {
const high = value.high | 0;
const lo = BigInt(value.low >>> 0);
const hi = BigInt(value.high >>> 0);
const combined = (hi << 32n) | lo;
if (!unsigned && high < 0) {
return Number(combined - (1n << 64n));
}
return Number(combined);
}
return Number(value);
const normalized = $util.Long.fromValue(value);
const prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"
? normalized.toUnsigned()
: typeof normalized.toSigned === "function"
? normalized.toSigned()
: normalized;
return prepared.toNumber();
}
export const proto = $root.proto = (() => {
+2 -42
View File
@@ -98,24 +98,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
let privacySettings: { [_: string]: string } | undefined
/**
* Server-assigned AB props that gate tctoken-related protocol behavior.
* Defaults match WA Web (safe — avoids spurious error 463 if the prop never
* arrives). Populated from `fetchProps()` on connection.
*
* - `privacyTokenOn1to1` (AB prop 10518 / `privacy_token_sending_on_all_1_on_1_messages`):
* include tctoken in 1:1 messages.
* - `profilePicPrivacyToken` (AB prop 9666 / `profile_scraping_privacy_token_in_photo_iq`):
* include tctoken in profile picture IQs.
* - `lidTrustedTokenIssueToLid` (AB prop 14303 / `lid_trusted_token_issue_to_lid`):
* issue privacy tokens to the contact's LID instead of the PN.
*/
const serverProps = {
privacyTokenOn1to1: true,
profilePicPrivacyToken: true,
lidTrustedTokenIssueToLid: false
}
let syncState: SyncState = SyncState.Connecting
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
@@ -809,10 +791,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent
// Gate inclusion on AB prop 9666 (profile_scraping_privacy_token_in_photo_iq).
// WA Web defaults to true; if the server flips it off, we mirror that to
// avoid divergence with the spec-compliant client.
if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) {
if (isUserJid && !isSelf) {
content = await buildTcTokenFromJid({
authState,
jid: normalizedJid,
@@ -1114,25 +1093,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
}
// Extract protocol-relevant AB props (defaults match WA Web; see serverProps doc).
// We accept both numeric IDs and human-readable names so the parser is resilient
// to upstream renaming and to future-versioned WA Web servers.
const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']
if (privacyTokenProp !== undefined) {
serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'
}
const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']
if (profilePicProp !== undefined) {
serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'
}
const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']
if (lidIssueProp !== undefined) {
serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'
}
logger.debug({ serverProps }, 'fetched props')
logger.debug('fetched props')
return props
}
@@ -1650,7 +1611,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
return {
...sock,
serverProps,
createCallLink,
getBotListV2,
messageMutex,
+60 -93
View File
@@ -73,14 +73,7 @@ import {
recordMessageReceived,
recordMessageRetry
} from '../Utils/prometheus-metrics.js'
import {
buildMergedTcTokenIndexWrite,
isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../Utils/tc-token-utils'
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils'
import {
areJidsSameUser,
type BinaryNode,
@@ -171,12 +164,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let sendActiveReceipts = false
// ======= tctoken index tracking for cross-session pruning =======
// TC_TOKEN_INDEX_KEY is imported from tc-token-utils so the value stays in sync
// with messages-send/process-message writes (avoids string drift on rename).
// Race note: this prune-driven index write may interleave with
// buildMergedTcTokenIndexWrite calls from issuance/history-sync paths. Worst
// case: a JID resurrected by a stale read gets pruned again on the next 24h
// sweep — no data loss, just one extra cycle.
const TC_TOKEN_INDEX_KEY = '__index'
const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts'
const tcTokenKnownJids = new Set<string>()
const tcTokenRetriedMsgIds = new Set<string>()
@@ -210,25 +198,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
})()
/**
* Debounced save of the tctoken JID index (5s).
*
* Merges with the persisted index instead of overwriting other layers
* (messages-send fire-and-forget issuance, process-message history sync) may
* write JIDs to the index via `buildMergedTcTokenIndexWrite` without updating
* `tcTokenKnownJids`. Without the merge those JIDs would be silently dropped
* the next time this debounced save fires.
*/
/** Debounced save of the tctoken JID index (5s) */
const scheduleTcTokenIndexSave = () => {
if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
tcTokenIndexSaveTimer = setTimeout(async () => {
try {
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
const arr = Array.from(tcTokenKnownJids)
await authState.keys.set({
tctoken: {
...merged,
[TC_TOKEN_INDEX_KEY]: {
...merged[TC_TOKEN_INDEX_KEY],
token: Buffer.from(JSON.stringify(arr), 'utf8'),
timestamp: unixTimestampSeconds().toString()
}
}
@@ -1455,58 +1434,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'sendRetryRequest')
}
/**
* Fire-and-forget tctoken re-issuance after a peer's device identity changed.
* Mirrors WAWebSendTcTokenWhenDeviceIdentityChange runs in PARALLEL with the
* session refresh (not after it).
*
* Why parallel and not sequential:
* - WA Web invokes this BEFORE assertSessions to maximise the chance the contact
* has a fresh tctoken by the time the next outbound send executes.
* - Running after assertSessions (the fork's previous behaviour) races with the
* next send and risks error 463 when the contact reinstalls and the user replies
* immediately afterwards.
*
* Gated on `entry.senderTimestamp` (we previously issued a token to this peer in the
* current bucket window). Preserves the existing senderTimestamp instead of issuing
* a fresh one keeps WA Web's bucket coalescing semantics: same token, same window.
*/
const reissueTcTokenAfterIdentityChange = (from: string): void => {
void (async () => {
const normalizedJid = jidNormalizedUser(from)
const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN)
const tcTokenData = await authState.keys.get('tctoken', [tcJid])
const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
return
}
logTcToken('reissue', { jid: normalizedJid, reason: 'identity_changed', senderTimestamp: senderTs })
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
const issueJid = await resolveIssuanceJid(
normalizedJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
const result = await getPrivacyTokens([issueJid], senderTs)
await storeTcTokensFromIqResult({
result,
fallbackJid: tcJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: storedJid => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'identity_changed' })
})().catch(err => {
logTcToken('reissue_fail', { jid: from, error: err?.message })
})
}
const handleEncryptNotification = async (node: BinaryNode) => {
const from = node.attrs.from
if (from === S_WHATSAPP_NET) {
@@ -1526,12 +1453,57 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
validateSession: signalRepository.validateSession,
assertSessions,
debounceCache: identityAssertDebounce,
logger,
// Fire reissue in parallel with the session refresh, NOT after it.
// See reissueTcTokenAfterIdentityChange doc for the rationale.
onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
logger
})
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
// WABA Android: reissue stores senderTimestamp + realIssueTimestamp after IQ success
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)
.then(async (iqResult) => {
await storeTcTokensFromIqResult({
result: iqResult,
fallbackJid: normalizedJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
// Persist senderTimestamp + realIssueTimestamp after IQ success
const currentData = await authState.keys.get('tctoken', [tcJid])
const currentEntry = currentData[tcJid]
await authState.keys.set({
tctoken: {
[tcJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: senderTs,
realIssueTimestamp: 0
}
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'session_refreshed' })
})
.catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
}
})
.catch(() => {
/* ignore resolution errors */
})
}
if (result.action === 'no_identity_node') {
logger.info({ node }, 'unknown encrypt notification')
}
@@ -3248,24 +3220,19 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Await index load first — prevents overwriting a more complete persisted index
// if the connection closes before the initial load finishes.
tcTokenIndexLoaded
.then(async () => {
try {
// Same merge-with-persisted invariant as scheduleTcTokenIndexSave —
// other layers may have written cross-layer JIDs to the index since
// our in-memory set was last updated.
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
await authState.keys.set({
.then(() => {
Promise.resolve(
authState.keys.set({
tctoken: {
...merged,
[TC_TOKEN_INDEX_KEY]: {
...merged[TC_TOKEN_INDEX_KEY],
token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
timestamp: unixTimestampSeconds().toString()
}
}
})
} catch {
).catch(() => {
/* non-critical */
}
})
})
.catch(() => {
/* non-critical */
+6 -59
View File
@@ -46,10 +46,7 @@ import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import {
buildMergedTcTokenIndexWrite,
isRegularUser,
isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult
@@ -128,15 +125,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches
const tcTokenFetchingJids = new Set<string>()
/**
* Set of tctoken storage JIDs with a fire-and-forget `issuePrivacyTokens` IQ in flight.
* Distinct from `tcTokenFetchingJids` (which dedupes inbound *fetches* of the peer's
* token). Prevents duplicate IQs when a caller fires several rapid back-to-back sends
* to the same contact before `senderTimestamp` persists. Entries are always removed
* in `.finally()`, so the set is bounded by current concurrency.
*/
const inFlightTcTokenIssuance = new Set<string>()
let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async (forceGet = false) => {
const media = await mediaConn
@@ -1727,16 +1715,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// Gate inclusion on AB prop 10518 (privacy_token_sending_on_all_1_on_1_messages).
// WA Web defaults to true; if the server flips it off we mirror that to avoid
// divergence with the spec-compliant client.
//
// CARROUSEL EXCEPTION: Pastorini-validated carousel stanzas REQUIRE tctoken
// (CDP capture confirms it). If the AB prop ever flips off, dropping the
// tctoken from carousel would break rendering on Android. Carousel always
// includes the tctoken when one is available, regardless of the prop —
// matching the fork's existing behaviour pre-PR #2339.
if (tcTokenBuffer?.length && (sock.serverProps.privacyTokenOn1to1 || isCarousel)) {
if (tcTokenBuffer?.length) {
;(stanza.content as BinaryNode[]).push({
tag: 'tctoken',
attrs: {},
@@ -1836,39 +1815,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so
// issuance fires even when we don't yet hold a token (bucket boundary crossed).
// IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463.
//
// WA Web also skips issuance for:
// - protocol messages (revoke, ephemeral settings, etc — see TcTokenChatAction)
// - PSA, bots, MetaAI (isRegularUser filter)
// and dedupes back-to-back issuances with `inFlightTcTokenIssuance` so a burst of
// rapid sends to the same contact only triggers a single IQ before senderTimestamp
// is persisted.
const isProtocolMsg = !!normalizeMessageContent(message)?.protocolMessage
// Use isRegularUser (the same Wid.isRegularUser() port that gates the store
// path) so we filter PSA/bot/MetaAI consistently regardless of JID server
// (@c.us vs @s.whatsapp.net) and device suffix. The previous PSA_WID/isJidBot
// checks only matched @c.us forms — destinationJid arrives normalized.
const isBotOrPSA = !isRegularUser(destinationJid)
if (
is1on1Send &&
!isProtocolMsg &&
!isBotOrPSA &&
shouldSendNewTcToken(existingTokenEntry?.senderTimestamp) &&
!inFlightTcTokenIssuance.has(tcTokenJid)
) {
inFlightTcTokenIssuance.add(tcTokenJid)
if (is1on1Send && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) {
const issueTimestamp = unixTimestampSeconds()
logTcToken('reissue', { jid: destinationJid })
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
resolveIssuanceJid(
destinationJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
.then(issueJid => getPrivacyTokens([issueJid], issueTimestamp))
getPrivacyTokens([destinationJid], issueTimestamp)
.then(async result => {
// Store any tokens received in the IQ response.
// onNewJidStored not passed — pruning index lives in messages-recv (higher layer).
await storeTcTokensFromIqResult({
result,
fallbackJid: tcTokenJid,
@@ -1879,11 +1832,9 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table
// regardless of whether a token exists. Spread preserves token+timestamp if present.
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server.
// Also bump the cross-session prune index so this JID is tracked persistently.
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid])
await authState.keys.set({
tctoken: {
[tcTokenJid]: {
@@ -1891,8 +1842,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp,
realIssueTimestamp: 0
},
...indexWrite
}
}
})
@@ -1901,9 +1851,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
.catch(err => {
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
})
.finally(() => {
inFlightTcTokenIssuance.delete(tcTokenJid)
})
}
// Log with [BAILEYS] prefix
+11 -11
View File
@@ -1,6 +1,5 @@
import { pipeline } from 'stream/promises'
import { promisify } from 'util'
import { createInflate, inflate } from 'zlib'
import { inflate } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { Chat, Contact, LIDMapping, WAMessage } from '../Types'
import { WAMessageStubType } from '../Types'
@@ -30,15 +29,16 @@ const inflatePromise = promisify(inflate)
*/
export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
// Pipe decrypted stream directly through zlib inflate.
// Avoids allocating an intermediate buffer for the compressed payload —
// memory peaks during 50MB history syncs drop ~50% (PR upstream #2333).
const inflater = createInflate()
const chunks: Buffer[] = []
inflater.on('data', (chunk: Buffer) => chunks.push(chunk))
await pipeline(stream, inflater)
const bufferArray: Buffer[] = []
for await (const chunk of stream) {
bufferArray.push(chunk)
}
let buffer: Buffer = Buffer.concat(bufferArray)
// decompress buffer
buffer = await inflatePromise(buffer)
const buffer = Buffer.concat(chunks)
const syncData = proto.HistorySync.decode(buffer)
return syncData
}
@@ -361,7 +361,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
}
}
chats.push(chat)
chats.push({ ...chat })
}
break
-23
View File
@@ -57,16 +57,6 @@ export type IdentityChangeContext = {
debounceCache: NodeCache<boolean>
/** Logger instance for debugging and monitoring */
logger: ILogger
/**
* Invoked right before `assertSessions` is called for an existing-session identity
* change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance)
* in the same order WA Web does i.e. before the E2E session is re-established.
* Must not throw; implementations are responsible for their own error handling.
*
* Skipped when the refresh itself is skipped (no_identity_node, invalid_notification,
* skipped_companion_device, skipped_self_primary, debounced, skipped_offline).
*/
onBeforeSessionRefresh?: (jid: string) => void
}
// ============================================================================
@@ -180,19 +170,6 @@ export async function handleIdentityChange(
// This ensures we don't incorrectly debounce when we exit early (offline, etc.)
ctx.debounceCache.set(from, true)
// Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is
// re-established. WA Web runs these in parallel with the session refresh —
// running afterwards would race with the next outbound send and risk error 463.
//
// Wrapped in try/catch so a misbehaving consumer callback cannot abort identity
// change recovery. We log and continue — assertSessions still runs so the E2E
// session always gets refreshed.
try {
ctx.onBeforeSessionRefresh?.(from)
} catch (error) {
ctx.logger.warn({ error, jid: from }, 'onBeforeSessionRefresh callback threw — continuing with session refresh')
}
// Attempt session refresh/creation
try {
await ctx.assertSessions([from], true)
+1 -1
View File
@@ -618,7 +618,7 @@ export const downloadEncryptedContent = async (
const output = new Transform({
transform(chunk, _, callback) {
let data = remainingBytes.length ? Buffer.concat([remainingBytes, chunk]) : chunk
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength = toSmallestChunkSize(data.length)
remainingBytes = data.slice(decryptLength)
-129
View File
@@ -40,7 +40,6 @@ import { getKeyAuthor, toNumber } from './generics'
import { downloadAndProcessHistorySyncNotification } from './history'
import type { ILogger } from './logger'
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
import { buildMergedTcTokenIndexWrite } from './tc-token-utils'
type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean
@@ -63,122 +62,6 @@ const REAL_MSG_STUB_TYPES = new Set([
const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD])
/**
* Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats
* and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass
* over the chat table during history sync.
*
* Why this matters: when a user logs in on a new device, the multi-device history sync
* is the only way that device learns about tctokens issued/received on the original
* device. Without this pass, the new device sends 1:1 messages with no tctoken until
* the contact triggers a fresh notification which surfaces as error 463 in production.
*
* Monotonicity: we only overwrite an existing entry if the incoming timestamp is
* STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid
* reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a
* reissue that fired between history-sync chunks).
*
* Index hygiene: every JID we write here is added to the persistent prune index
* (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in
* messages-recv picks them up across sessions.
*/
async function storeTcTokensFromHistorySync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
) {
// Cheap filter first — most chats in a sync chunk don't carry tcToken at all,
// and we want to avoid spinning up promises for them.
const tokenChats = chats.filter(chat => {
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
return !!chat.tcToken?.length && ts > 0
})
if (!tokenChats.length) {
return
}
// Pre-normalize so the rest of the pipeline is a synchronous join.
const normalized = tokenChats.map(chat => ({
chat,
ts: toNumber(chat.tcTokenTimestamp!),
jid: jidNormalizedUser(chat.id!)
}))
// BATCHED LID resolution. The previous shape called getLIDForPN once per
// chat (sequential await inside a for-of), which became the bottleneck
// during heavy history sync — every cold-cache hit was a DB round-trip,
// stalling messaging-history.set and spilling into the event-buffer.
// `getLIDsForPNs` resolves a deduped list in ONE batched query (and shares
// USync retry across PNs that miss cache), turning O(N) round-trips into 1.
//
// LID inputs (and `@hosted.lid`) skip the lookup entirely — they're already
// the storage form. Failures degrade gracefully: a missing mapping just
// stores under the original jid, matching `resolveTcTokenJid`'s null branch.
const pnsToResolve = [...new Set(normalized.filter(({ jid }) => !isLidUser(jid)).map(({ jid }) => jid))]
const pnToLid = new Map<string, string>()
if (pnsToResolve.length) {
try {
const mappings = await signalRepository.lidMapping.getLIDsForPNs(pnsToResolve)
// Flat loop (continue-on-skip) keeps max nesting depth at 4 for lint.
for (const { pn, lid } of mappings ?? []) {
if (!pn || !lid) continue
pnToLid.set(jidNormalizedUser(pn), lid)
}
} catch (err) {
// Per-chat fallback below (storageJid := jid). Don't abort the chunk —
// CodeRabbit noted that all-or-nothing rejection here would drop every
// tctoken in the batch AND prevent messaging-history.set from firing.
logger?.warn({ err }, 'storeTcTokensFromHistorySync: getLIDsForPNs batch failed; falling back to per-chat jid')
}
}
const candidates = normalized.map(({ chat, ts, jid }) => ({
storageJid: pnToLid.get(jid) ?? jid,
token: Buffer.from(chat.tcToken!),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
}))
const jids = candidates.map(c => c.storageJid)
const existing = await keyStore.get('tctoken', jids)
const entries: Record<string, { token: Buffer; timestamp?: string; senderTimestamp?: number }> = {}
for (const c of candidates) {
// Same-batch dedup: when two chats resolve to the same storageJid (e.g. PN+LID
// aliases collapsing through resolveTcTokenJid, or duplicate chunks across
// retries), prefer the value already written by an earlier iteration so a
// lower-ts entry can't overwrite a higher-ts one captured from `existing`.
const existingEntry = entries[c.storageJid] ?? existing[c.storageJid]
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
// Strict > guard: equal timestamps are skipped so we never clobber
// senderTimestamp written by other layers (issuance after send, etc).
if (existingTs > 0 && existingTs >= c.ts) {
continue
}
entries[c.storageJid] = {
...existingEntry,
token: c.token,
timestamp: String(c.ts),
...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
}
}
if (Object.keys(entries).length) {
logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync')
try {
// Include updated __index so cross-session pruning picks these JIDs up.
const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries))
await keyStore.set({ tctoken: { ...entries, ...indexWrite } })
} catch (err) {
logger?.warn({ err }, 'failed to store tctokens from history sync')
}
}
}
/** Cleans a received message to further processing */
export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => {
// ensure remoteJid and participant doesn't have device or agent in it
@@ -535,12 +418,6 @@ const processMessage = async (
// Emit LID-PN mappings from history sync
// This is how WhatsApp Web learns mappings for chats with non-contacts
//
// MUST run BEFORE storeTcTokensFromHistorySync — otherwise resolveTcTokenJid()
// can't resolve PN→LID for fresh-device chats (mapping cache is empty), tokens
// get persisted under PN keys, and the send path (which resolves to LID first)
// misses them — exactly the error 463 scenario this whole change is meant to
// prevent. Catches Codex P1 review on PR #386.
if (data.lidPnMappings?.length) {
logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync')
// eslint-disable-next-line max-depth
@@ -565,12 +442,6 @@ const processMessage = async (
}
}
// Persist tctokens carried by history-sync chats BEFORE emitting messaging-history.set
// — listeners may immediately fire outbound sends that need the tctoken, and the store
// has to be populated first to avoid an error 463 on the first multi-device send.
// Runs AFTER storeLIDPNMappings (see comment above) so LID resolution works.
await storeTcTokensFromHistorySync(data.chats, signalRepository, keyStore, logger)
ev.emit('messaging-history.set', {
...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
+2 -119
View File
@@ -1,86 +1,12 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import {
getBinaryNodeChild,
getBinaryNodeChildren,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidMetaAI,
isLidUser,
isPnUser,
jidNormalizedUser
} from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4
/**
* Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs
* for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup.
*
* Exported so other modules (messages-recv, messages-send, process-message) reference
* the same constant. The fork previously inlined this string in messages-recv.ts;
* keep the value identical (`'__index'`) for backward compatibility with persisted state.
*/
export const TC_TOKEN_INDEX_KEY = '__index'
// Phone-number pattern matching WABinary's isJidBot, applied against the user part so
// the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/
/**
* Mirrors WA Web's `Wid.isRegularUser()` (user ¬PSA ¬Bot). Used to gate tctoken
* storage against malformed notifications WA Web filters server-side but we
* defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
* Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
*/
export function isRegularUser(jid: string | undefined): boolean {
if (!jid) return false
const user = jid.split('@')[0] ?? ''
if (user === '0') return false // PSA
if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern
if (isJidMetaAI(jid)) return false // MetaAI (@bot server)
return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'))
}
/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise<string[]> {
const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if (!entry?.token?.length) return []
try {
const parsed = JSON.parse(Buffer.from(entry.token).toString())
if (!Array.isArray(parsed)) return []
return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY)
} catch {
return []
}
}
/**
* Build a SignalDataSet fragment that writes the merged index (persisted added)
* under the sentinel key. Lets callers update the index without clobbering writes
* made by other layers (history sync, concurrent sessions on the same store).
*/
export async function buildMergedTcTokenIndexWrite(
keys: SignalKeyStoreWithTransaction,
addedJids: Iterable<string>
): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> {
const persisted = await readTcTokenIndex(keys)
const merged = new Set(persisted)
for (const jid of addedJids) {
if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid)
}
return {
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
}
}
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
@@ -137,43 +63,6 @@ export async function resolveTcTokenJid(
return lid ?? normalized
}
/**
* Resolve target JID for issuing privacy token based on AB prop 14303
* (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to
* the LID; when off, it goes to the PN. Returns the original JID if no
* mapping is found in either direction.
*
* Normalizes the JID upfront and uses the `isAny*` helpers so callers can pass
* `@c.us`, `@s.whatsapp.net`, `@hosted`, `@hosted.lid`, `@lid` or device-specific
* forms `LIDMappingStore.getLIDForPN` early-returns unless `isAnyPnUser`,
* so unnormalized inputs would silently bypass routing.
*
* Reference: WAWebTrustedContactsManager.issuePrivacyTokens
*/
export async function resolveIssuanceJid(
jid: string,
issueToLid: boolean,
getLIDForPN: (pn: string) => Promise<string | null>,
getPNForLID?: (lid: string) => Promise<string | null>
): Promise<string> {
const normalized = jidNormalizedUser(jid)
if (issueToLid) {
if (isAnyLidUser(normalized)) return normalized
if (!isAnyPnUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
if (!isAnyLidUser(normalized)) return normalized
if (getPNForLID) {
const pn = await getPNForLID(normalized)
return pn ?? normalized
}
return normalized
}
type TcTokenParams = {
jid: string
baseContent?: BinaryNode[]
@@ -246,13 +135,7 @@ export async function storeTcTokensFromIqResult({
continue
}
// In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's.
// Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the
// token is stored under the peer's JID, never under self.
const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid)
// Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web
// filters these server-side; we mirror Wid.isRegularUser() locally.
if (!isRegularUser(rawJid)) continue
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]
@@ -297,52 +297,4 @@ describe('Identity Change Handling', () => {
expect(result.device).toBe(5)
})
})
describe('onBeforeSessionRefresh callback', () => {
it('fires before assertSessions when a session refresh is about to run', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
const callOrder: string[] = []
mockAssertSessions.mockImplementation(async () => {
callOrder.push('assertSessions')
return true
})
const onBeforeSessionRefresh = jest.fn((jid: string) => {
callOrder.push(`before:${jid}`)
})
const node = createIdentityChangeNode('user@s.whatsapp.net')
const ctx = { ...createContext(), onBeforeSessionRefresh }
const result = await handleIdentityChange(node, ctx)
expect(result.action).toBe('session_refreshed')
expect(callOrder).toEqual(['before:user@s.whatsapp.net', 'assertSessions'])
})
it('does not fire when the refresh is skipped (no_identity / offline / self)', async () => {
const onBeforeSessionRefresh = jest.fn()
// no identity node
const noIdentityNode: BinaryNode = {
tag: 'notification',
attrs: { from: 'a@s.whatsapp.net', type: 'encrypt' },
content: []
}
await handleIdentityChange(noIdentityNode, { ...createContext(), onBeforeSessionRefresh })
// offline notification
mockValidateSession.mockResolvedValue({ exists: true })
await handleIdentityChange(createIdentityChangeNode('b@s.whatsapp.net', '0'), {
...createContext(),
onBeforeSessionRefresh
})
// self primary
await handleIdentityChange(createIdentityChangeNode(mockMeId!), {
...createContext(),
onBeforeSessionRefresh
})
expect(onBeforeSessionRefresh).not.toHaveBeenCalled()
})
})
})
+1 -348
View File
@@ -1,17 +1,7 @@
import { jest } from '@jest/globals'
import { DisconnectReason, type SignalKeyStoreWithTransaction } from '../../Types'
import { getErrorCodeFromStreamError, SERVER_ERROR_CODES } from '../../Utils'
import {
buildMergedTcTokenIndexWrite,
buildTcTokenFromJid,
isRegularUser,
isTcTokenExpired,
readTcTokenIndex,
resolveIssuanceJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../../Utils/tc-token-utils'
import { buildTcTokenFromJid, isTcTokenExpired, shouldSendNewTcToken } from '../../Utils/tc-token-utils'
import type { BinaryNode } from '../../WABinary'
/** 7 days in seconds — matches WA Web tctoken_duration */
@@ -858,340 +848,3 @@ describe('tctoken integration scenarios', () => {
})
})
})
// ─── isRegularUser (PSA / bot / MetaAI gating) ─────────────────────────
describe('isRegularUser', () => {
it('rejects undefined / empty', () => {
expect(isRegularUser(undefined)).toBe(false)
expect(isRegularUser('')).toBe(false)
})
it('rejects PSA WID (user "0")', () => {
expect(isRegularUser('0@s.whatsapp.net')).toBe(false)
expect(isRegularUser('0@c.us')).toBe(false)
})
it('rejects bot phone numbers (1313555XXXX, 131655500XX)', () => {
expect(isRegularUser('13135550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13135559999@s.whatsapp.net')).toBe(false)
// /^131655500\d{2}$/ — 11 digits: 131655500 + 2 trailing
expect(isRegularUser('13165550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13165550099@s.whatsapp.net')).toBe(false)
})
it('rejects MetaAI (@bot server)', () => {
expect(isRegularUser('foo@bot')).toBe(false)
})
it('accepts regular PN users (@s.whatsapp.net, @c.us)', () => {
expect(isRegularUser('5511999999999@s.whatsapp.net')).toBe(true)
expect(isRegularUser('5511999999999@c.us')).toBe(true)
})
it('accepts LID users', () => {
expect(isRegularUser('123456@lid')).toBe(true)
})
it('accepts hosted PN/LID users', () => {
expect(isRegularUser('5511999999999@hosted')).toBe(true)
expect(isRegularUser('123456@hosted.lid')).toBe(true)
})
})
// ─── resolveIssuanceJid (AB prop 14303 routing) ────────────────────────
describe('resolveIssuanceJid', () => {
const PN = '5511999999999@s.whatsapp.net'
const LID = '123456@lid'
const PN_NO_LID = '5511888888888@s.whatsapp.net'
const LID_NO_PN = '999999@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN) return LID
return null
})
const getPNForLID = jest.fn<(lid: string) => Promise<string | null>>(async (lid: string) => {
if (lid === LID) return PN
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
getPNForLID.mockClear()
})
describe('AB prop 14303 ON (issueToLid=true)', () => {
it('returns LID unchanged when input is LID', async () => {
expect(await resolveIssuanceJid(LID, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('resolves PN to LID via lidMapping', async () => {
expect(await resolveIssuanceJid(PN, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).toHaveBeenCalledWith(PN)
})
it('falls back to original PN when no LID mapping exists', async () => {
expect(await resolveIssuanceJid(PN_NO_LID, true, getLIDForPN, getPNForLID)).toBe(PN_NO_LID)
})
})
describe('AB prop 14303 OFF (issueToLid=false)', () => {
it('returns PN unchanged when input is PN', async () => {
expect(await resolveIssuanceJid(PN, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).not.toHaveBeenCalled()
})
it('resolves LID to PN via lidMapping', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).toHaveBeenCalledWith(LID)
})
it('falls back to original LID when no PN mapping exists', async () => {
expect(await resolveIssuanceJid(LID_NO_PN, false, getLIDForPN, getPNForLID)).toBe(LID_NO_PN)
})
it('returns LID unchanged when getPNForLID is omitted', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN)).toBe(LID)
})
})
describe('JID normalization (handles @c.us and hosted forms)', () => {
const PN_CUS = '5511999999999@c.us'
const PN_NORMALIZED = '5511999999999@s.whatsapp.net'
const HOSTED_LID = '999999@hosted.lid'
it('normalizes @c.us before resolving (issueToLid=true)', async () => {
const fn = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN_NORMALIZED) return LID
return null
})
const result = await resolveIssuanceJid(PN_CUS, true, fn, getPNForLID)
expect(result).toBe(LID)
// Mapping store called with NORMALIZED form
expect(fn).toHaveBeenCalledWith(PN_NORMALIZED)
})
it('treats hosted LID as LID input (issueToLid=true returns it unchanged)', async () => {
expect(await resolveIssuanceJid(HOSTED_LID, true, getLIDForPN, getPNForLID)).toBe(HOSTED_LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('treats hosted LID as LID input (issueToLid=false converts via getPNForLID)', async () => {
const fn = jest.fn<(lid: string) => Promise<string | null>>(async () => null)
await resolveIssuanceJid(HOSTED_LID, false, getLIDForPN, fn)
expect(fn).toHaveBeenCalledWith(HOSTED_LID)
})
})
})
// ─── readTcTokenIndex / buildMergedTcTokenIndexWrite ───────────────────
describe('tctoken cross-session prune index', () => {
it('exports the sentinel key as "__index"', () => {
// Persisted state compatibility — tests freeze the value to catch unintended renames.
expect(TC_TOKEN_INDEX_KEY).toBe('__index')
})
describe('readTcTokenIndex', () => {
it('returns [] when the index entry is absent', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the index token buffer is empty', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.alloc(0) } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is corrupted', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('not-json') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is not an array', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('{"a":1}') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns the persisted JIDs', async () => {
const keys = createMockKeys()
const stored = ['a@lid', 'b@lid', 'c@s.whatsapp.net']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(stored)
})
it('filters out the sentinel key itself, empty strings, and non-strings', async () => {
const keys = createMockKeys()
const stored = ['a@lid', '', TC_TOKEN_INDEX_KEY, 42, null, 'b@lid']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(['a@lid', 'b@lid'])
})
})
describe('buildMergedTcTokenIndexWrite', () => {
it('merges added JIDs with the persisted set (de-duplicated)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(['a@lid', 'b@lid'])) }
})
const write = await buildMergedTcTokenIndexWrite(keys, ['b@lid', 'c@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded.sort()).toEqual(['a@lid', 'b@lid', 'c@lid'])
})
it('drops the sentinel key from the added set', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [TC_TOKEN_INDEX_KEY, 'a@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual(['a@lid'])
})
it('handles empty inputs', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual([])
})
})
})
// ─── storeTcTokensFromIqResult — isRegularUser gating + monotonicity ───
describe('storeTcTokensFromIqResult — gating and monotonicity', () => {
const PEER_PN = '5511999999999@s.whatsapp.net'
const PEER_LID = '123456@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PEER_PN) return PEER_LID
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
})
const buildIqResult = (jid: string, tokenBytes: Uint8Array, t: string): BinaryNode => ({
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid, type: 'trusted_contact', t },
content: tokenBytes
}
]
}
]
})
it('skips PSA WID (jid="0") even if returned in tokens block', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '0@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips bot phone numbers', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '13135550000@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips MetaAI (@bot server)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: 'meta-ai@bot',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('uses fallbackJid (NOT the token node jid which is own device) as storage source', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const onNewJidStored = jest.fn()
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid:1@s.whatsapp.net', Buffer.from([0xaa, 0xbb]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN,
onNewJidStored
})
expect(keys.set).toHaveBeenCalled()
const setArgs = (keys.set.mock.calls[0]?.[0] as { tctoken: Record<string, unknown> }).tctoken
expect(Object.keys(setArgs)).toContain(PEER_LID)
expect(onNewJidStored).toHaveBeenCalledWith(PEER_LID)
})
it('skips when incoming timestamp is older than existing (monotonicity)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[PEER_LID]: { token: Buffer.from([0x99]), timestamp: '1800000000' }
})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0xaa]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips timestamp-less tokens (would be immediately expired)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: {
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid: 'd@s.whatsapp.net', type: 'trusted_contact' },
content: Buffer.from([0x01])
}
]
}
]
},
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
})
-209
View File
@@ -1,209 +0,0 @@
import Long from 'long'
import $protobuf from 'protobufjs/minimal.js'
const $util = $protobuf.util
// proto implementation
function longToStringOld(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (!$util.Long) return String(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function' ? normalized.toUnsigned() : normalized
return prepared.toString()
}
// bigint implementation
function longToStringNew(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return (combined - (1n << 64n)).toString()
}
return combined.toString()
}
return String(value)
}
function longToNumberOld(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (!$util.Long) return Number(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function'
? normalized.toUnsigned()
: typeof normalized.toSigned === 'function'
? normalized.toSigned()
: normalized
return prepared.toNumber()
}
function longToNumberNew(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return Number(combined - (1n << 64n))
}
return Number(combined)
}
return Number(value)
}
describe('BigInt vs Long equivalence validation', () => {
// Test cases: [description, Long value, unsigned flag]
const testCases: [string, Long, boolean][] = [
// Basic values
['zero unsigned', Long.fromNumber(0, true), true],
['zero signed', Long.fromNumber(0, false), false],
['one unsigned', Long.fromNumber(1, true), true],
['one signed', Long.fromNumber(1, false), false],
// Typical WhatsApp timestamps (seconds since epoch)
['timestamp 2023', Long.fromNumber(1700000000, true), true],
['timestamp 2025', Long.fromNumber(1750000000, true), true],
['timestamp as signed', Long.fromNumber(1700000000, false), false],
// File sizes
['small file', Long.fromNumber(1024, true), true],
['medium file 10MB', Long.fromNumber(10485760, true), true],
['large file 2GB', Long.fromNumber(2147483648, true), true],
// Boundary values - 32-bit
['max int32', Long.fromNumber(2147483647, false), false],
['min int32', Long.fromNumber(-2147483648, false), false],
['max uint32', Long.fromNumber(4294967295, true), true],
// Around MAX_SAFE_INTEGER
['MAX_SAFE_INTEGER', Long.fromString('9007199254740991', false), false],
['MAX_SAFE_INTEGER + 1', Long.fromString('9007199254740992', false), false],
['MAX_SAFE_INTEGER unsigned', Long.fromString('9007199254740991', true), true],
// Large unsigned values
['large unsigned', Long.fromString('9999999999999999999', true), true],
['max uint64', Long.fromString('18446744073709551615', true), true],
['max uint64 - 1', Long.fromString('18446744073709551614', true), true],
// Signed negative values
['negative one', Long.fromNumber(-1, false), false],
['negative small', Long.fromNumber(-100, false), false],
['negative large', Long.fromNumber(-2147483648, false), false],
['min int64', Long.fromString('-9223372036854775808', false), false],
['max int64', Long.fromString('9223372036854775807', false), false],
['-1 as signed Long', Long.fromBits(-1, -1, false), false],
// Tricky bit patterns
['high=1, low=0', Long.fromBits(0, 1, true), true],
['high=0, low=-1 (0xFFFFFFFF)', Long.fromBits(-1, 0, true), true],
['high=-1, low=-1 unsigned (max uint64)', Long.fromBits(-1, -1, true), true],
['high=0x7FFFFFFF, low=0xFFFFFFFF (max int64)', Long.fromBits(-1, 0x7fffffff, false), false],
['high=0x80000000, low=0 (min int64)', Long.fromBits(0, -2147483648, false), false],
// Powers of 2
['2^32', Long.fromString('4294967296', true), true],
['2^32 signed', Long.fromString('4294967296', false), false],
['2^48', Long.fromString('281474976710656', true), true],
['2^63', Long.fromString('9223372036854775808', true), true]
]
describe('longToString equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToStringOld(longVal, unsigned)
const newResult = longToStringNew(longVal, unsigned)
expect(newResult).toBe(oldResult)
})
}
})
describe('longToNumber equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToNumberOld(longVal, unsigned)
const newResult = longToNumberNew(longVal, unsigned)
// For large values, both may lose precision equally (Number limitation)
// So we compare string representations
expect(newResult.toString()).toBe(oldResult.toString())
})
}
})
describe('non-Long inputs (fallback paths)', () => {
it('string passthrough', () => {
expect(longToStringNew('12345')).toBe('12345')
expect(longToStringNew('0')).toBe('0')
})
it('number passthrough', () => {
expect(longToStringNew(42)).toBe('42')
expect(longToStringNew(0)).toBe('0')
expect(longToStringNew(-1)).toBe('-1')
})
it('null/undefined fallback', () => {
expect(longToStringNew(null)).toBe('null')
expect(longToStringNew(undefined)).toBe('undefined')
})
it('object without low/high', () => {
expect(longToStringNew({ foo: 'bar' })).toBe('[object Object]')
})
// Defensive: a raw {low, high} JSON object can carry `high` as an
// unsigned 32-bit value (Long.fromBits would normalize via `| 0`,
// but plain JSON deserialization does not). Without `value.high | 0`
// the sign-bit check fails and the result is interpreted as unsigned.
// Upstream Baileys PR #2333 has this latent bug; InfiniteAPI fixes it.
it('signed sign-bit detection on raw unsigned high (-1 as {low: -1, high: 4294967295})', () => {
const raw = { low: -1, high: 0xffffffff }
// signed: -1
expect(longToStringNew(raw, false)).toBe('-1')
// unsigned: max uint64
expect(longToStringNew(raw, true)).toBe('18446744073709551615')
})
it('signed sign-bit detection on raw unsigned high (-4294967296 as {low: 0, high: 4294967295})', () => {
const raw = { low: 0, high: 0xffffffff }
// signed: -4294967296 (high word = -1 after normalization)
expect(longToStringNew(raw, false)).toBe('-4294967296')
// unsigned: 0xFFFFFFFF00000000 = 18446744069414584320
expect(longToStringNew(raw, true)).toBe('18446744069414584320')
})
})
describe('fuzz: random Long values', () => {
it('100 random unsigned values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, true)
const oldResult = longToStringOld(longVal, true)
const newResult = longToStringNew(longVal, true)
expect(newResult).toBe(oldResult)
}
})
it('100 random signed values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, false)
const oldResult = longToStringOld(longVal, false)
const newResult = longToStringNew(longVal, false)
expect(newResult).toBe(oldResult)
}
})
})
})
-88
View File
@@ -1,4 +1,3 @@
import Long from 'long'
import '../index.js'
import { proto } from '../../WAProto/index.js'
@@ -29,91 +28,4 @@ describe('proto serialization', () => {
const json = message.toJSON()
expect(json.message?.imageMessage?.fileLength).toBe('1234567890123456789')
})
it('converts Long objects to strings correctly via BigInt fast path', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(1700000000, true),
message: {
imageMessage: {
fileLength: Long.fromString('9876543210', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('1700000000')
expect(json.message?.imageMessage?.fileLength).toBe('9876543210')
})
it('handles large unsigned Long values (> MAX_SAFE_INTEGER)', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true), // max uint64
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
it('handles zero and small Long values', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(0, true),
message: {
imageMessage: {
fileLength: Long.fromNumber(1, true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('0')
expect(json.message?.imageMessage?.fileLength).toBe('1')
})
it('roundtrips encode/decode with Long fields preserving values (> MAX_SAFE_INTEGER)', () => {
// Use uint64 values above Number.MAX_SAFE_INTEGER (2^53 - 1) so the
// roundtrip actually exercises the BigInt fast path on decode->toJSON.
// Safe integers would silently work even if longToString fell back to
// Number(value).
const original = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true),
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const encoded = proto.WebMessageInfo.encode(original).finish()
const decoded = proto.WebMessageInfo.decode(encoded)
const json = decoded.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
})