Compare commits

...

12 Commits

Author SHA1 Message Date
Renato Alcara cdab2e3c63 fix: address PR review — stale comments, createCallLink compat, relay guard
- Fix call link URL: remove media prefix (Frida shows https://call.whatsapp.com/<token>)
- Make createCallLink() backward compatible with chats.ts signature (event, timeoutMs)
- Guard CB:relay handler — require both callId AND callCreator before emitting
- Extract extractParticipants() helper to eliminate 3x code duplication

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:17:17 -03:00
Renato Alcara 3535a16b32 fix: address audit findings in call signaling implementation
- Fix handleCall to process ALL children of <call> node (not just first)
- Add CB:relay handler for top-level relay stanzas (TURN servers, tokens)
- Add 'video' and 'relay' to WACallUpdateType and getCallStatusFromNode
- Add <device-identity /> to voice call offers (verified via Frida capture)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:33:25 -03:00
Renato Alcara 919bc52c77 feat: complete call signaling — types, status handling, call link parsing
Complete the call signaling implementation with proper type definitions,
status handling, and call link URL construction:

- WACallEvent: add linkToken, linkCreator, participants, media, duration,
  terminateReason, connectedLimit fields
- WACallParticipant: new type for group/link call participants
- WACallUpdateType: add preaccept, transport, relaylatency, group_update,
  reminder, heartbeat, mute_v2, enc_rekey status types
- getCallStatusFromNode: handle all new call node tags
- handleCall: extract group_info, link_info, call_summary, participants
  from incoming call link/group stanzas
- createCallLink: parse server response to return token and full URL
  (https://call.whatsapp.com/video/<token>)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:20:32 -03:00
Renato Alcara 46622bd8c1 feat: implement complete call signaling from Frida captures
Add all remaining call signaling functions verified via Frida capture
on WhatsApp Android v2.26:

- acceptCall(): answer an incoming call with audio/video capabilities
- preacceptCall(): signal device capabilities before accepting
- sendRelayLatency(): report relay server latency measurements
- sendTransport(): send p2p/ICE transport candidates
- sendCallDuration(): log call duration to server after hangup
- muteCall(): mute/unmute during active call (MUTE_V2)
- sendHeartbeat(): keep group call alive
- sendEncRekey(): encryption re-key during call
- sendVideoState(): toggle video on/off during call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:14:39 -03:00
Renato Alcara 664964216c feat: add call link support (create, query, join)
Add createCallLink(), queryCallLink(), and joinCallLink() functions
for WhatsApp call link feature. Stanza structures verified via Frida
capture on WhatsApp Android v2.26.

- createCallLink(media) creates a shareable call link
- queryCallLink(token, media) queries call info before joining
- joinCallLink(token, media) joins a call via its link token
  with proper audio/video/net/capability child nodes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:10:12 -03:00
Renato Alcara 1bbcebc59c feat: add offerCall and terminateCall for call signaling
Add outgoing call support with offerCall() and terminateCall() functions.
Stanza structures verified via Frida captures on WhatsApp Android v2.26,
matching real voice and video call flows.

- offerCall(jid, isVideo?) sends call offer with correct child nodes
  (privacy, audio×2, video if applicable, net, capability, enc, encopt)
- terminateCall(callId, callTo, ...) sends call termination with optional
  reason and duration attributes
- Both functions exported from the socket chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:06:21 -03:00
Renato Alcara 569e43f184 fix: use MACOS platform to resolve 405 connection failure (#244)
fix: use MACOS platform to resolve 405 connection failure
2026-03-01 10:00:20 -03:00
Renato Alcara 79ee40037b fix: add timeout to fetchLatestBaileysVersion to prevent hanging conn… (#243)
* 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.


* 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.
2026-03-01 09:41:43 -03:00
Renato Alcara f032c9b0ea fix: normalise remoteJid in reaction/poll keys for fromMe messages (#240)
In DMs, when the connected user sends a reaction, the inner key's
remoteJid could remain unnormalized (e.g. LID vs PN), preventing
correct matching with the original message. Also normalises participant
in groups for own messages — an improvement over upstream Baileys#2386.
2026-03-01 08:46:09 -03:00
Renato Alcara 3d79b8fc36 chore: update WhatsApp Web version to v2.3000.1034279434 (#242)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-01 06:06:04 -03:00
github-actions[bot] f6e976f182 chore: update proto/version to v2.3000.1034274421 (#241)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-01 00:49:55 -03:00
Renato Alcara 3d9d7bafe4 perf: reduce message delivery latency across all connection scenarios (#239)
perf: reduce message delivery latency across all connection scenarios (#239)
2026-02-28 17:26:40 -03:00
12 changed files with 948 additions and 75 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1034238531
/// WhatsApp Version: 2.3000.1034274421
message ADVDeviceIdentity {
optional uint32 rawId = 1;
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034258243]}
{"version":[2,3000,1034279434]}
+2 -2
View File
@@ -67,7 +67,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
emitOwnEvents: true,
defaultQueryTimeoutMs: 30_000,
customUploadHosts: [],
retryRequestDelayMs: 250,
retryRequestDelayMs: 150,
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: 3000 },
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 1000 },
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 are processed in order */
const receiptMutex = makeMutex()
/** 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 app state patches are processed in order */
const appStatePatchMutex = makeMutex()
/** this mutex ensures that notifications are processed in order */
const notificationMutex = makeMutex()
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
const notificationMutex = makeKeyedMutex()
// 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 (4s), forcing state to Online and flushing buffer')
logger.warn('Timeout in AwaitingInitialSync (2s), 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 })
}
}, 4_000)
}, 2_000)
})
// When an app state sync key arrives (myAppStateKeyId is set) and there are
+827 -42
View File
@@ -20,6 +20,8 @@ import type {
PlaceholderMessageData,
SocketConfig,
WACallEvent,
WACallParticipant,
WACallUpdateType,
WAMessage,
WAMessageKey,
WAPatchName
@@ -536,6 +538,663 @@ 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
@@ -1282,7 +1941,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try {
await Promise.all([
receiptMutex.mutex(async () => {
receiptMutex.mutex(jidNormalizedUser(remoteJid) || 'unknown', async () => {
const status = getStatusFromReceiptType(attrs.type)
if (
typeof status !== 'undefined' &&
@@ -1356,7 +2015,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
try {
await Promise.all([
notificationMutex.mutex(async () => {
notificationMutex.mutex(jidNormalizedUser(remoteJid) || 'unknown', async () => {
const msg = await processNotification(node)
if (msg) {
const fromMe = areJidsSameUser(node.attrs.participant || remoteJid, authState.creds.me!.id)
@@ -1818,54 +2477,139 @@ 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 [infoChild] = getAllBinaryNodeChildren(node)
const status = getCallStatusFromNode(infoChild!)
const children = getAllBinaryNodeChildren(node)
if (!infoChild) {
if (!children.length) {
throw new Boom('Missing call info in call node')
}
const callId = infoChild.attrs['call-id']!
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
// 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 call: WACallEvent = {
chatId: attrs.from!,
from,
id: callId,
date: new Date(+attrs.t! * 1000),
offline: !!attrs.offline,
status
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])
}
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)
}
@@ -1985,7 +2729,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let isProcessing = false
// Number of nodes to process before yielding to event loop
const BATCH_SIZE = 10
const BATCH_SIZE = 25
const enqueue = (type: MessageType, node: BinaryNode) => {
nodes.push({ type, node })
@@ -2055,6 +2799,33 @@ 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)
})
@@ -2161,6 +2932,20 @@ 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 5 s safety cap applies.
const OFFLINE_BUFFER_TIMEOUT_MS = 5_000
// For normal restarts (no stale routingInfo) the standard 2 s safety cap applies.
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
let offlineBufferTimeout: NodeJS.Timeout | undefined
process.nextTick(() => {
+43 -1
View File
@@ -1,4 +1,30 @@
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate'
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 WACallEvent = {
chatId: string
@@ -13,4 +39,20 @@ 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 → 5s so the safety auto-flush fires sooner.
// perf(inbound-latency): reduced from 15s → 3s 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 || '5000', 10),
bufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_TIMEOUT_MS || '3000', 10),
minBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MIN_TIMEOUT_MS || '1000', 10),
maxBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MAX_TIMEOUT_MS || '8000', 10),
maxBufferTimeoutMs: parseInt(process.env.BAILEYS_BUFFER_MAX_TIMEOUT_MS || '3000', 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 || '100', 10),
flushDebounceMs: parseInt(process.env.BAILEYS_BUFFER_FLUSH_DEBOUNCE_MS || '10', 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'),
+44 -6
View File
@@ -230,14 +230,22 @@ export const bindWaitForConnectionUpdate = (ev: BaileysEventEmitter) => bindWait
* utility that fetches latest baileys version from the master branch.
* Use to ensure your WA connection is always on the latest version
*/
export const fetchLatestBaileysVersion = async (options: RequestInit = {}) => {
export const fetchLatestBaileysVersion = async (options: RequestInit & { timeout?: number } = {}) => {
const URL = 'https://raw.githubusercontent.com/WhiskeySockets/Baileys/master/src/Defaults/index.ts'
try {
const response = await fetch(URL, {
dispatcher: options.dispatcher,
method: 'GET',
headers: options.headers
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 5000)
let response: Response
try {
response = await fetch(URL, {
dispatcher: options.dispatcher,
method: 'GET',
headers: options.headers,
signal: controller.signal
})
} finally {
clearTimeout(timeout)
}
if (!response.ok) {
throw new Boom(`Failed to fetch latest Baileys version: ${response.statusText}`, { statusCode: response.status })
}
@@ -421,6 +429,36 @@ 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,6 +113,14 @@ 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.WEB,
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
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 = 5_000
const OFFLINE_BUFFER_TIMEOUT_MS = 2_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 5 s
// 1. Timeout path — CB:ib,,offline never arrives within 2 s
// -------------------------------------------------------------------------
it('fires after 5 s and flushes when CB:ib,,offline is delayed', () => {
it('fires after 2 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 5 s timer fires
// 2. Happy path — CB:ib,,offline arrives before the 2 s timer fires
// -------------------------------------------------------------------------
it('CB:ib,,offline cancels the timer and flushes exactly once', () => {
startBuffer(state, mockFlush, mockWarn)
// Server responds before the 5 s timeout
// Server responds before the 2 s timeout
jest.advanceTimersByTime(1_000)
onOffline(state, mockFlush)
// Timer should be cancelled — advancing past 5 s must not cause a second flush
// Timer should be cancelled — advancing past 2 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 5 s must not trigger any flush
// Timer must be gone — advancing past 2 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 5 s have elapsed', () => {
it('does not flush before exactly 2 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 5 s boundary', () => {
it('flushes at exactly the 2 s boundary', () => {
startBuffer(state, mockFlush, mockWarn)
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)