Compare commits

..

2 Commits

Author SHA1 Message Date
Renato Alcara 4a745f2167 fix: move clearTimeout to finally block in fetchLatestBaileysVersion
Ensures timer cleanup on both success and fetch rejection paths,
preventing dangling timers when DNS/network fails before abort fires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:37:48 -03:00
Renato Alcara a1def8c2c0 fix: add timeout to fetchLatestBaileysVersion to prevent hanging connections
AbortController with 5s default timeout prevents indefinite blocking
when GitHub is unreachable (DNS failure, network issues). Falls back
to bundled version via existing catch block. Ref: Baileys#2385.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:27:16 -03:00
12 changed files with 69 additions and 934 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1034274421
/// WhatsApp Version: 2.3000.1034238531
message ADVDeviceIdentity {
optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034279434]}
{"version":[2,3000,1034258243]}
+2 -2
View File
@@ -67,7 +67,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
emitOwnEvents: true,
defaultQueryTimeoutMs: 30_000,
customUploadHosts: [],
retryRequestDelayMs: 150,
retryRequestDelayMs: 250,
maxMsgRetryCount: 5,
fireInitQueries: true,
auth: undefined as unknown as AuthenticationState,
@@ -78,7 +78,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
shouldSyncHistoryMessage: () => true,
shouldIgnoreJid: () => false,
linkPreviewImageThumbnailWidth: 192,
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 1000 },
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
generateHighQualityLinkPreview: false,
enableAutoSessionRecreation: true,
enableRecentMessageCache: true,
+6 -6
View File
@@ -102,14 +102,14 @@ export const makeChatsSocket = (config: SocketConfig) => {
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
const messageMutex = makeKeyedMutex()
/** this mutex ensures that receipts from the same chat are processed in order, while allowing parallel processing across chats */
const receiptMutex = makeKeyedMutex()
/** this mutex ensures that receipts are processed in order */
const receiptMutex = makeMutex()
/** this mutex ensures that app state patches are processed in order */
const appStatePatchMutex = makeMutex()
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
const notificationMutex = makeKeyedMutex()
/** this mutex ensures that notifications are processed in order */
const notificationMutex = makeMutex()
// Timeout for AwaitingInitialSync state
let awaitingSyncTimeout: NodeJS.Timeout | undefined
@@ -1495,7 +1495,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
awaitingSyncTimeout = setTimeout(() => {
if (syncState === SyncState.AwaitingInitialSync) {
logger.warn('Timeout in AwaitingInitialSync (2s), forcing state to Online and flushing buffer')
logger.warn('Timeout in AwaitingInitialSync (4s), forcing state to Online and flushing buffer')
syncState = SyncState.Online
ev.flush()
@@ -1505,7 +1505,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
ev.emit('creds.update', { accountSyncCounter })
}
}, 2_000)
}, 4_000)
})
// When an app state sync key arrives (myAppStateKeyId is set) and there are
+42 -827
View File
@@ -20,8 +20,6 @@ import type {
PlaceholderMessageData,
SocketConfig,
WACallEvent,
WACallParticipant,
WACallUpdateType,
WAMessage,
WAMessageKey,
WAPatchName
@@ -538,663 +536,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await query(stanza)
}
/**
* Offer (initiate) a call to a JID.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param jid - destination JID (e.g. "5511999999999@s.whatsapp.net" or LID)
* @param isVideo - true for video call, false/undefined for voice
* @returns callId (hex) and stanzaId used in the offer
*/
const offerCall = async (jid: string, isVideo?: boolean) => {
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const callId = randomBytes(16).toString('hex').toUpperCase()
const stanzaId = randomBytes(16).toString('hex').toUpperCase()
const offerContent: BinaryNode[] = [
{ tag: 'privacy', attrs: {}, content: undefined },
{ tag: 'audio', attrs: { rate: '8000', enc: 'opus' }, content: undefined },
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
]
if (isVideo) {
offerContent.push({
tag: 'video',
attrs: {
screen_width: '1080',
screen_height: '2400',
dec: 'H264,H265,AV1',
device_orientation: '0',
enc: 'h.264'
},
content: undefined
})
}
offerContent.push(
{ tag: 'net', attrs: { medium: '3' }, content: undefined },
{ tag: 'capability', attrs: { ver: '1' }, content: undefined },
{ tag: 'enc', attrs: { v: '2', type: isVideo ? 'msg' : 'pkmsg' }, content: undefined },
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
)
// Voice calls include device-identity (verified via Frida capture)
if (!isVideo) {
offerContent.push(
{ tag: 'device-identity', attrs: {}, content: undefined },
)
}
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: jid,
id: stanzaId,
},
content: [{
tag: 'offer',
attrs: {
'call-creator': meId,
'call-id': callId,
'device_class': '2013',
},
content: offerContent
}]
}
await query(stanza)
return { callId, stanzaId }
}
/**
* Terminate (hang up) an active or ringing call.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id from the offer
* @param callTo - JID of the other party
* @param callCreator - JID of who created the call (usually meId for outgoing)
* @param reason - terminate reason (omit for normal hangup, or 'timeout', 'busy', etc.)
* @param duration - call duration in ms (included when call was connected)
*/
const terminateCall = async (
callId: string,
callTo: string,
callCreator?: string,
reason?: string,
duration?: number
) => {
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const terminateAttrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator || meId,
}
if (reason) {
terminateAttrs.reason = reason
}
if (typeof duration === 'number') {
terminateAttrs.duration = String(duration)
terminateAttrs.audio_duration = String(duration)
}
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: callTo,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'terminate',
attrs: terminateAttrs,
content: undefined
}]
}
await query(stanza)
}
/**
* Accept (answer) an incoming call.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id from the incoming offer
* @param callFrom - JID of the caller (call-creator)
* @param isVideo - true for video call
*/
const acceptCall = async (
callId: string,
callFrom: string,
isVideo?: boolean,
) => {
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const acceptContent: BinaryNode[] = [
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
]
if (isVideo) {
acceptContent.push({
tag: 'video',
attrs: {
dec: 'H264,AV1',
device_orientation: '1',
},
content: undefined
})
}
acceptContent.push(
{ tag: 'net', attrs: { medium: '2' }, content: undefined },
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
)
const stanza: BinaryNode = {
tag: 'call',
attrs: {
from: meId,
to: callFrom,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'accept',
attrs: {
'call-id': callId,
'call-creator': callFrom,
},
content: acceptContent
}]
}
await query(stanza)
}
/**
* Send preaccept signal for an incoming call (indicates device capabilities).
* Sent before accept to communicate supported codecs.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id from the incoming offer
* @param callCreator - JID of the caller
* @param isVideo - true for video call
*/
const preacceptCall = async (
callId: string,
callCreator: string,
isVideo?: boolean,
) => {
const preacceptContent: BinaryNode[] = [
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
]
if (isVideo) {
preacceptContent.push({
tag: 'video',
attrs: {
screen_width: '1080',
screen_height: '2400',
dec: 'H264,H265,AV1',
device_orientation: '0',
},
content: undefined
})
}
preacceptContent.push(
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
{ tag: 'capability', attrs: { ver: '1' }, content: undefined },
)
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: callCreator,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'preaccept',
attrs: {
'call-id': callId,
'call-creator': callCreator,
},
content: preacceptContent
}]
}
await query(stanza)
}
/**
* Report relay latency measurements to the server.
* Sent after receiving relay info to report measured latency per relay server.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id
* @param callCreator - JID of the call creator
* @param relays - array of relay measurements
* @param transactionId - optional transaction ID for group calls
*/
const sendRelayLatency = async (
callId: string,
callCreator: string,
relays: Array<{
relayName?: string
latency: number
relayId?: string
dlBw?: number
ulBw?: number
}>,
transactionId?: string,
) => {
const relayLatencyAttrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator,
}
if (transactionId) {
relayLatencyAttrs['transaction-id'] = transactionId
}
const teChildren: BinaryNode[] = relays.map(r => {
const teAttrs: Record<string, string> = {}
if (r.relayName) {
teAttrs.relay_name = r.relayName
}
teAttrs.latency = String(r.latency)
if (r.relayId) {
teAttrs.relay_id = r.relayId
}
if (r.dlBw !== undefined) {
teAttrs.dl_bw = String(r.dlBw)
}
if (r.ulBw !== undefined) {
teAttrs.ul_bw = String(r.ulBw)
}
return { tag: 'te', attrs: teAttrs, content: undefined }
})
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: callCreator,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'relaylatency',
attrs: relayLatencyAttrs,
content: teChildren
}]
}
await sendNode(stanza)
}
/**
* Send transport (p2p/ICE) candidates for a call.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id
* @param callCreator - JID of the call creator
* @param to - destination JID
* @param candidates - array of candidate entries with priority
* @param round - p2p candidate round number
*/
const sendTransport = async (
callId: string,
callCreator: string,
to: string,
candidates: Array<{ priority: string; data?: Uint8Array }>,
round?: number,
) => {
const transportAttrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator,
'transport-message-type': '1',
}
if (round !== undefined) {
transportAttrs['p2p-cand-round'] = String(round)
}
const teChildren: BinaryNode[] = candidates.map(c => ({
tag: 'te',
attrs: { priority: c.priority },
content: c.data,
}))
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'transport',
attrs: transportAttrs,
content: teChildren
}]
}
await sendNode(stanza)
}
/**
* Send call duration log to the server after a call ends.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id
* @param callCreator - JID of the call creator
* @param peer - JID of the other party
* @param audioDuration - audio duration in ms
* @param callType - call type, defaults to '1x1'
*/
const sendCallDuration = async (
callId: string,
callCreator: string,
peer: string,
audioDuration: number,
callType: string = '1x1',
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'duration',
attrs: {
'call-id': callId,
'call-creator': callCreator,
peer,
audio_duration: String(audioDuration),
type: callType,
},
content: undefined
}]
}
await sendNode(stanza)
}
/**
* Mute or unmute during a call (MUTE_V2).
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id
* @param callCreator - JID of the call creator
* @param to - destination JID
* @param muted - true to mute, false to unmute
*/
const muteCall = async (
callId: string,
callCreator: string,
to: string,
muted: boolean,
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'mute_v2',
attrs: {
'mute-state': muted ? '1' : '0',
'call-id': callId,
'call-creator': callCreator,
},
content: undefined
}]
}
await sendNode(stanza)
}
/**
* Send heartbeat to keep a group call alive.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id (also used as JID with @call)
* @param callCreator - JID of the call creator
*/
const sendHeartbeat = async (
callId: string,
callCreator: string,
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: `${callId}@call`,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'heartbeat',
attrs: {
'call-id': callId,
'call-creator': callCreator,
},
content: undefined
}]
}
await sendNode(stanza)
}
/**
* Send encryption re-key during a call.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id
* @param callCreator - JID of the call creator
* @param to - destination JID
* @param transactionId - transaction ID for the rekey
*/
const sendEncRekey = async (
callId: string,
callCreator: string,
to: string,
transactionId: string,
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'enc_rekey',
attrs: {
'transaction-id': transactionId,
'call-id': callId,
'call-creator': callCreator,
},
content: [
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
{ tag: 'enc', attrs: { v: '2', type: 'msg' }, content: undefined },
]
}]
}
await sendNode(stanza)
}
/**
* Send video state change during a call.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param callId - the call-id
* @param callCreator - JID of the call creator
* @param to - destination JID
* @param enabled - true = video on (state=1), false = video off (state=0)
* @param orientation - device orientation (0=portrait, 1=landscape)
*/
const sendVideoState = async (
callId: string,
callCreator: string,
to: string,
enabled: boolean,
orientation: string = '1',
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'video',
attrs: {
'call-id': callId,
'call-creator': callCreator,
state: enabled ? '1' : '0',
device_orientation: orientation,
},
content: undefined
}]
}
await sendNode(stanza)
}
/**
* Create a call link that others can join.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param media - 'video' or 'audio'
* @returns object with token, full URL, and raw server response
*/
const createCallLink = async (
media: 'video' | 'audio' = 'video',
event?: { startTime: number },
timeoutMs?: number
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'link_create',
attrs: { media },
content: event
? [{ tag: 'event', attrs: { start_time: String(event.startTime) }, content: undefined }]
: undefined
}]
}
const response = await query(stanza, timeoutMs)
// Extract token from server response
let token: string | undefined
const linkCreateResp = getBinaryNodeChild(response, 'link_create')
if (linkCreateResp) {
token = linkCreateResp.attrs.token || linkCreateResp.attrs['link-token']
}
// Fallback: check response attrs directly
if (!token) {
token = response.attrs?.token || response.attrs?.['link-token']
}
// Fallback: search any child with token/link-token
if (!token && Array.isArray(response.content)) {
for (const child of response.content as BinaryNode[]) {
if (child.attrs?.token || child.attrs?.['link-token']) {
token = child.attrs.token || child.attrs['link-token']
break
}
}
}
// URL format verified via Frida capture: https://call.whatsapp.com/<token>
const url = token
? `https://call.whatsapp.com/${token}`
: undefined
return { token, url, response }
}
/**
* Query info about a call link before joining.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param token - the call link token (from URL: https://call.whatsapp.com/<token>)
* @param media - 'video' or 'audio'
* @returns server response with call info
*/
const queryCallLink = async (token: string, media: 'video' | 'audio' = 'video') => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'link_query',
attrs: { media, token },
content: undefined
}]
}
return await query(stanza)
}
/**
* Join a call via its link token.
* Structure verified via Frida capture on WhatsApp Android v2.26.
*
* @param token - the call link token
* @param media - 'video' or 'audio'
* @returns server response with relay/group info
*/
const joinCallLink = async (token: string, media: 'video' | 'audio' = 'video') => {
const joinContent: BinaryNode[] = [
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
{ tag: 'net', attrs: { medium: '2' }, content: undefined },
{ tag: 'capability', attrs: { ver: '1' }, content: undefined },
]
if (media === 'video') {
joinContent.splice(1, 0, {
tag: 'video',
attrs: {
screen_width: '1080',
screen_height: '2400',
dec: 'H264,H265,AV1',
device_orientation: '0',
},
content: undefined
})
}
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [{
tag: 'link_join',
attrs: { media, token },
content: joinContent
}]
}
return await query(stanza)
}
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => {
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
const { key: msgKey } = fullMessage
@@ -1941,7 +1282,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try {
await Promise.all([
receiptMutex.mutex(jidNormalizedUser(remoteJid) || 'unknown', async () => {
receiptMutex.mutex(async () => {
const status = getStatusFromReceiptType(attrs.type)
if (
typeof status !== 'undefined' &&
@@ -2015,7 +1356,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try {
await Promise.all([
notificationMutex.mutex(jidNormalizedUser(remoteJid) || 'unknown', async () => {
notificationMutex.mutex(async () => {
const msg = await processNotification(node)
if (msg) {
const fromMe = areJidsSameUser(node.attrs.participant || remoteJid, authState.creds.me!.id)
@@ -2477,139 +1818,54 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return pn
}
/** Extract participants from a node containing <user> children */
const extractParticipants = (parentNode: BinaryNode): WACallParticipant[] | undefined => {
const userNodes = getBinaryNodeChildren(parentNode, 'user')
if (!userNodes.length) return undefined
return userNodes.map(u => ({
jid: u.attrs.jid,
state: u.attrs.state,
userPn: u.attrs.user_pn,
type: u.attrs.type,
}))
}
const handleCall = async (node: BinaryNode) => {
const { attrs } = node
const children = getAllBinaryNodeChildren(node)
const [infoChild] = getAllBinaryNodeChildren(node)
const status = getCallStatusFromNode(infoChild!)
if (!children.length) {
if (!infoChild) {
throw new Boom('Missing call info in call node')
}
// Process ALL children — a <call> node can carry multiple
// sibling stanzas (e.g. <transport> + <mute_v2>)
for (const infoChild of children) {
const status = getCallStatusFromNode(infoChild)
const callId = infoChild.attrs['call-id']!
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
const callId = infoChild.attrs['call-id']!
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
const call: WACallEvent = {
chatId: attrs.from!,
from,
id: callId,
date: new Date(+attrs.t! * 1000),
offline: !!attrs.offline,
status
}
if (status === 'offer') {
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
call.groupJid = infoChild.attrs['group-jid']
// Extract and sanitize caller phone number
call.callerPn = sanitizeCallerPn(infoChild.attrs['caller_pn'])
// Extract call link info from group_info child
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
if (groupInfo) {
call.isGroup = true
call.linkToken = groupInfo.attrs['link-token']
call.media = groupInfo.attrs.media
call.connectedLimit = groupInfo.attrs['connected-limit']
? Number(groupInfo.attrs['connected-limit'])
: undefined
call.participants = extractParticipants(groupInfo)
}
// Extract link_info (who created the link)
const linkInfo = getBinaryNodeChild(infoChild, 'link_info')
if (linkInfo) {
call.linkCreator = linkInfo.attrs.link_creator
call.linkCreatorPn = linkInfo.attrs.link_creator_pn
}
await callOfferCache.set(call.id, call)
}
// Extract call link data from group_update
if (status === 'group_update') {
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
if (groupInfo) {
call.isGroup = true
call.linkToken = groupInfo.attrs['link-token']
call.media = groupInfo.attrs.media
call.connectedLimit = groupInfo.attrs['connected-limit']
? Number(groupInfo.attrs['connected-limit'])
: undefined
call.participants = extractParticipants(groupInfo)
}
}
// Extract reminder data (e.g. link_creator_call_started)
if (status === 'reminder') {
const groupInfo = getBinaryNodeChild(infoChild, 'group_info')
if (groupInfo) {
call.isGroup = true
call.linkToken = groupInfo.attrs['link-token']
call.media = groupInfo.attrs.media
}
}
// Extract terminate data (reason, duration, call_summary)
if (status === 'terminate') {
call.terminateReason = infoChild.attrs.reason
const callSummary = getBinaryNodeChild(infoChild, 'call_summary')
if (callSummary) {
call.media = callSummary.attrs.media
call.duration = callSummary.attrs.call_duration
? Number(callSummary.attrs.call_duration)
: undefined
call.participants = extractParticipants(callSummary)
}
}
// Extract video info from accept/preaccept
if (status === 'accept' || status === 'preaccept') {
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
}
const existingCall = await callOfferCache.get<WACallEvent>(call.id)
// use existing call info to populate this event
if (existingCall) {
call.isVideo = call.isVideo ?? existingCall.isVideo
call.isGroup = call.isGroup ?? existingCall.isGroup
call.groupJid = call.groupJid ?? existingCall.groupJid
// Preserve callerPn across call state updates
call.callerPn = call.callerPn || existingCall.callerPn
// Preserve call link data across updates
call.linkToken = call.linkToken || existingCall.linkToken
call.linkCreator = call.linkCreator || existingCall.linkCreator
call.linkCreatorPn = call.linkCreatorPn || existingCall.linkCreatorPn
call.media = call.media || existingCall.media
call.connectedLimit = call.connectedLimit ?? existingCall.connectedLimit
}
// delete data once call has ended
if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
await callOfferCache.del(call.id)
}
ev.emit('call', [call])
const call: WACallEvent = {
chatId: attrs.from!,
from,
id: callId,
date: new Date(+attrs.t! * 1000),
offline: !!attrs.offline,
status
}
if (status === 'offer') {
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
call.groupJid = infoChild.attrs['group-jid']
// Extract and sanitize caller phone number
call.callerPn = sanitizeCallerPn(infoChild.attrs['caller_pn'])
await callOfferCache.set(call.id, call)
}
const existingCall = await callOfferCache.get<WACallEvent>(call.id)
// use existing call info to populate this event
if (existingCall) {
call.isVideo = existingCall.isVideo
call.isGroup = existingCall.isGroup
call.groupJid = existingCall.groupJid
// Preserve callerPn across call state updates
call.callerPn = call.callerPn || existingCall.callerPn
}
// delete data once call has ended
if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
await callOfferCache.del(call.id)
}
ev.emit('call', [call])
await sendMessageAck(node)
}
@@ -2729,7 +1985,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let isProcessing = false
// Number of nodes to process before yielding to event loop
const BATCH_SIZE = 25
const BATCH_SIZE = 10
const enqueue = (type: MessageType, node: BinaryNode) => {
nodes.push({ type, node })
@@ -2799,33 +2055,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await processNode('call', node, 'handling call', handleCall)
})
// Top-level <relay> stanzas carry TURN server info, tokens and crypto keys
ws.on('CB:relay', async (node: BinaryNode) => {
const callId = node.attrs['call-id']
const callCreator = node.attrs['call-creator']
// Both callId and callCreator must be present to emit a valid event
// (call link relays may arrive without these attrs — just log them)
if (callId && callCreator) {
logger.debug(
{ callId, callCreator, uuid: node.attrs.uuid },
'received relay info'
)
ev.emit('call', [{
chatId: callCreator,
from: callCreator,
id: callId,
date: new Date(),
offline: false,
status: 'relay' as WACallUpdateType,
}])
} else {
logger.debug(
{ attrs: node.attrs },
'received relay stanza without call-id/call-creator'
)
}
})
ws.on('CB:receipt', async node => {
await processNode('receipt', node, 'handling receipt', handleReceipt)
})
@@ -2932,20 +2161,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
sendMessageAck,
sendRetryRequest,
rejectCall,
offerCall,
acceptCall,
preacceptCall,
terminateCall,
sendRelayLatency,
sendTransport,
sendCallDuration,
muteCall,
sendHeartbeat,
sendEncRekey,
sendVideoState,
createCallLink,
queryCallLink,
joinCallLink,
fetchMessageHistory,
requestPlaceholderResend,
messageRetryManager
+2 -2
View File
@@ -1667,8 +1667,8 @@ export const makeSocket = (config: SocketConfig) => {
// already-authenticated session (no QR re-scan needed). In this case we skip
// the offline buffer entirely so live messages are not held hostage waiting for the
// server to finish flushing the pending-message backlog (CB:ib,,offline).
// For normal restarts (no stale routingInfo) the standard 2 s safety cap applies.
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
// For normal restarts (no stale routingInfo) the standard 5 s safety cap applies.
const OFFLINE_BUFFER_TIMEOUT_MS = 5_000
let offlineBufferTimeout: NodeJS.Timeout | undefined
process.nextTick(() => {
+1 -43
View File
@@ -1,30 +1,4 @@
export type WACallUpdateType =
| 'offer'
| 'ringing'
| 'timeout'
| 'reject'
| 'accept'
| 'terminate'
| 'preaccept'
| 'transport'
| 'relaylatency'
| 'group_update'
| 'reminder'
| 'heartbeat'
| 'mute_v2'
| 'enc_rekey'
| 'video'
| 'relay'
export type WACallParticipant = {
jid?: string
/** 'connected' | 'invited' | 'left' etc. */
state?: string
/** Phone number in s.whatsapp.net format */
userPn?: string
/** 'admin' for group call link creator */
type?: string
}
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate'
export type WACallEvent = {
chatId: string
@@ -39,20 +13,4 @@ export type WACallEvent = {
latencyMs?: number
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */
callerPn?: string
/** Call link token (forms URL: https://call.whatsapp.com/video/<token>) */
linkToken?: string
/** JID of who created the call link */
linkCreator?: string
/** Phone number of link creator */
linkCreatorPn?: string
/** Media type: 'video' or 'audio' */
media?: string
/** Max participants for group/link calls */
connectedLimit?: number
/** Participants in a group/link call */
participants?: WACallParticipant[]
/** Call duration in ms (from terminate/call_summary) */
duration?: number
/** Terminate reason (e.g. 'group_call_ended', 'accepted_elsewhere', 'timeout') */
terminateReason?: string
}
+4 -4
View File
@@ -54,15 +54,15 @@ export interface BufferConfig {
*/
export function loadBufferConfig(): BufferConfig {
return {
// perf(inbound-latency): reduced from 15s → 3s so the safety auto-flush fires sooner.
// perf(inbound-latency): reduced from 15s → 5s so the safety auto-flush fires sooner.
// processNodeWithBuffer always calls ev.flush() explicitly (no-op for this timer),
// but socket.ts's offline-phase buffer and any stalled buffer benefit from the lower cap.
bufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_TIMEOUT_MS || '3000', 10),
bufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_TIMEOUT_MS || '5000', 10),
minBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MIN_TIMEOUT_MS || '1000', 10),
maxBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MAX_TIMEOUT_MS || '3000', 10),
maxBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MAX_TIMEOUT_MS || '8000', 10),
maxHistoryCacheSize: parseInt(process.env.BAILEYS_BUFFER_MAX_HISTORY_CACHE || '10000', 10),
maxBufferSize: parseInt(process.env.BAILEYS_BUFFER_MAX_SIZE || '5000', 10),
flushDebounceMs: parseInt(process.env.BAILEYS_BUFFER_FLUSH_DEBOUNCE_MS || '10', 10),
flushDebounceMs: parseInt(process.env.BAILEYS_BUFFER_FLUSH_DEBOUNCE_MS || '100', 10),
enableAdaptiveTimeout: process.env.BAILEYS_BUFFER_ADAPTIVE_TIMEOUT !== 'false',
enableMetrics: process.env.BAILEYS_BUFFER_METRICS === 'true' || process.env.BAILEYS_PROMETHEUS_ENABLED === 'true',
lruCleanupRatio: parseFloat(process.env.BAILEYS_BUFFER_LRU_CLEANUP_RATIO || '0.2'),
-30
View File
@@ -429,36 +429,6 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
case 'accept':
status = 'accept'
break
case 'preaccept':
status = 'preaccept'
break
case 'transport':
status = 'transport'
break
case 'relaylatency':
status = 'relaylatency'
break
case 'group_update':
status = 'group_update'
break
case 'reminder':
status = 'reminder'
break
case 'heartbeat':
status = 'heartbeat'
break
case 'mute_v2':
status = 'mute_v2'
break
case 'enc_rekey':
status = 'enc_rekey'
break
case 'video':
status = 'video'
break
case 'relay':
status = 'relay'
break
default:
status = 'ringing'
break
-8
View File
@@ -113,14 +113,6 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
msgKey.remoteJid = message.key.remoteJid
// set participant of the message
msgKey.participant = msgKey.participant || message.key.participant
} else {
// fromMe reactions/polls: normalise remoteJid to match the chat JID
// ensures DM reaction keys are consistent with group behavior
msgKey.remoteJid = message.key.remoteJid
// in groups, normalise participant for own messages too
if (message.key.participant) {
msgKey.participant = msgKey.participant || message.key.participant
}
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
secondary: config.version[1],
tertiary: config.version[2]
},
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
platform: proto.ClientPayload.UserAgent.Platform.WEB,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1',
device: 'Desktop',
@@ -20,7 +20,7 @@ import { jest } from '@jest/globals'
* bad-ack-handling.test.ts.
*/
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
const OFFLINE_BUFFER_TIMEOUT_MS = 5_000
/** Mirrors the state variables declared at the top of makeSocket */
interface OfflineBufferState {
@@ -101,10 +101,10 @@ describe('offline-buffer safety timer (socket.ts)', () => {
})
// -------------------------------------------------------------------------
// 1. Timeout path — CB:ib,,offline never arrives within 2 s
// 1. Timeout path — CB:ib,,offline never arrives within 5 s
// -------------------------------------------------------------------------
it('fires after 2 s and flushes when CB:ib,,offline is delayed', () => {
it('fires after 5 s and flushes when CB:ib,,offline is delayed', () => {
startBuffer(state, mockFlush, mockWarn)
expect(mockFlush).not.toHaveBeenCalled()
@@ -144,17 +144,17 @@ describe('offline-buffer safety timer (socket.ts)', () => {
})
// -------------------------------------------------------------------------
// 2. Happy path — CB:ib,,offline arrives before the 2 s timer fires
// 2. Happy path — CB:ib,,offline arrives before the 5 s timer fires
// -------------------------------------------------------------------------
it('CB:ib,,offline cancels the timer and flushes exactly once', () => {
startBuffer(state, mockFlush, mockWarn)
// Server responds before the 2 s timeout
// Server responds before the 5 s timeout
jest.advanceTimersByTime(1_000)
onOffline(state, mockFlush)
// Timer should be cancelled — advancing past 2 s must not cause a second flush
// Timer should be cancelled — advancing past 5 s must not cause a second flush
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockFlush).toHaveBeenCalledTimes(1)
@@ -192,7 +192,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
onClose(state)
// Timer must be gone — advancing past 2 s must not trigger any flush
// Timer must be gone — advancing past 5 s must not trigger any flush
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
expect(mockFlush).not.toHaveBeenCalled()
@@ -236,7 +236,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
// 4. Boundary / timing edge cases
// -------------------------------------------------------------------------
it('does not flush before exactly 2 s have elapsed', () => {
it('does not flush before exactly 5 s have elapsed', () => {
startBuffer(state, mockFlush, mockWarn)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS - 1)
@@ -244,7 +244,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
expect(mockFlush).not.toHaveBeenCalled()
})
it('flushes at exactly the 2 s boundary', () => {
it('flushes at exactly the 5 s boundary', () => {
startBuffer(state, mockFlush, mockWarn)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)