Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a745f2167 | |||
| a1def8c2c0 |
@@ -1,7 +1,7 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
package proto;
|
package proto;
|
||||||
|
|
||||||
/// WhatsApp Version: 2.3000.1034274421
|
/// WhatsApp Version: 2.3000.1034238531
|
||||||
|
|
||||||
message ADVDeviceIdentity {
|
message ADVDeviceIdentity {
|
||||||
optional uint32 rawId = 1;
|
optional uint32 rawId = 1;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":[2,3000,1034279434]}
|
{"version":[2,3000,1034258243]}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
|||||||
emitOwnEvents: true,
|
emitOwnEvents: true,
|
||||||
defaultQueryTimeoutMs: 30_000,
|
defaultQueryTimeoutMs: 30_000,
|
||||||
customUploadHosts: [],
|
customUploadHosts: [],
|
||||||
retryRequestDelayMs: 150,
|
retryRequestDelayMs: 250,
|
||||||
maxMsgRetryCount: 5,
|
maxMsgRetryCount: 5,
|
||||||
fireInitQueries: true,
|
fireInitQueries: true,
|
||||||
auth: undefined as unknown as AuthenticationState,
|
auth: undefined as unknown as AuthenticationState,
|
||||||
@@ -78,7 +78,7 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
|
|||||||
shouldSyncHistoryMessage: () => true,
|
shouldSyncHistoryMessage: () => true,
|
||||||
shouldIgnoreJid: () => false,
|
shouldIgnoreJid: () => false,
|
||||||
linkPreviewImageThumbnailWidth: 192,
|
linkPreviewImageThumbnailWidth: 192,
|
||||||
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 1000 },
|
transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 },
|
||||||
generateHighQualityLinkPreview: false,
|
generateHighQualityLinkPreview: false,
|
||||||
enableAutoSessionRecreation: true,
|
enableAutoSessionRecreation: true,
|
||||||
enableRecentMessageCache: true,
|
enableRecentMessageCache: true,
|
||||||
|
|||||||
+6
-6
@@ -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 */
|
/** 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()
|
const messageMutex = makeKeyedMutex()
|
||||||
|
|
||||||
/** this mutex ensures that receipts from the same chat are processed in order, while allowing parallel processing across chats */
|
/** this mutex ensures that receipts are processed in order */
|
||||||
const receiptMutex = makeKeyedMutex()
|
const receiptMutex = makeMutex()
|
||||||
|
|
||||||
/** this mutex ensures that app state patches are processed in order */
|
/** this mutex ensures that app state patches are processed in order */
|
||||||
const appStatePatchMutex = makeMutex()
|
const appStatePatchMutex = makeMutex()
|
||||||
|
|
||||||
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
|
/** this mutex ensures that notifications are processed in order */
|
||||||
const notificationMutex = makeKeyedMutex()
|
const notificationMutex = makeMutex()
|
||||||
|
|
||||||
// Timeout for AwaitingInitialSync state
|
// Timeout for AwaitingInitialSync state
|
||||||
let awaitingSyncTimeout: NodeJS.Timeout | undefined
|
let awaitingSyncTimeout: NodeJS.Timeout | undefined
|
||||||
@@ -1495,7 +1495,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
awaitingSyncTimeout = setTimeout(() => {
|
awaitingSyncTimeout = setTimeout(() => {
|
||||||
if (syncState === SyncState.AwaitingInitialSync) {
|
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
|
syncState = SyncState.Online
|
||||||
ev.flush()
|
ev.flush()
|
||||||
|
|
||||||
@@ -1505,7 +1505,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
|
|||||||
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
|
const accountSyncCounter = (authState.creds.accountSyncCounter || 0) + 1
|
||||||
ev.emit('creds.update', { accountSyncCounter })
|
ev.emit('creds.update', { accountSyncCounter })
|
||||||
}
|
}
|
||||||
}, 2_000)
|
}, 4_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
// When an app state sync key arrives (myAppStateKeyId is set) and there are
|
// When an app state sync key arrives (myAppStateKeyId is set) and there are
|
||||||
|
|||||||
+42
-827
@@ -20,8 +20,6 @@ import type {
|
|||||||
PlaceholderMessageData,
|
PlaceholderMessageData,
|
||||||
SocketConfig,
|
SocketConfig,
|
||||||
WACallEvent,
|
WACallEvent,
|
||||||
WACallParticipant,
|
|
||||||
WACallUpdateType,
|
|
||||||
WAMessage,
|
WAMessage,
|
||||||
WAMessageKey,
|
WAMessageKey,
|
||||||
WAPatchName
|
WAPatchName
|
||||||
@@ -538,663 +536,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
await query(stanza)
|
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 sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => {
|
||||||
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
|
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
|
||||||
const { key: msgKey } = fullMessage
|
const { key: msgKey } = fullMessage
|
||||||
@@ -1941,7 +1282,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
receiptMutex.mutex(jidNormalizedUser(remoteJid) || 'unknown', async () => {
|
receiptMutex.mutex(async () => {
|
||||||
const status = getStatusFromReceiptType(attrs.type)
|
const status = getStatusFromReceiptType(attrs.type)
|
||||||
if (
|
if (
|
||||||
typeof status !== 'undefined' &&
|
typeof status !== 'undefined' &&
|
||||||
@@ -2015,7 +1356,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
notificationMutex.mutex(jidNormalizedUser(remoteJid) || 'unknown', async () => {
|
notificationMutex.mutex(async () => {
|
||||||
const msg = await processNotification(node)
|
const msg = await processNotification(node)
|
||||||
if (msg) {
|
if (msg) {
|
||||||
const fromMe = areJidsSameUser(node.attrs.participant || remoteJid, authState.creds.me!.id)
|
const fromMe = areJidsSameUser(node.attrs.participant || remoteJid, authState.creds.me!.id)
|
||||||
@@ -2477,139 +1818,54 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
return pn
|
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 handleCall = async (node: BinaryNode) => {
|
||||||
const { attrs } = node
|
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')
|
throw new Boom('Missing call info in call node')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process ALL children — a <call> node can carry multiple
|
const callId = infoChild.attrs['call-id']!
|
||||||
// sibling stanzas (e.g. <transport> + <mute_v2>)
|
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
|
||||||
for (const infoChild of children) {
|
|
||||||
const status = getCallStatusFromNode(infoChild)
|
|
||||||
|
|
||||||
const callId = infoChild.attrs['call-id']!
|
const call: WACallEvent = {
|
||||||
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
|
chatId: attrs.from!,
|
||||||
|
from,
|
||||||
const call: WACallEvent = {
|
id: callId,
|
||||||
chatId: attrs.from!,
|
date: new Date(+attrs.t! * 1000),
|
||||||
from,
|
offline: !!attrs.offline,
|
||||||
id: callId,
|
status
|
||||||
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)
|
await sendMessageAck(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2729,7 +1985,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
let isProcessing = false
|
let isProcessing = false
|
||||||
|
|
||||||
// Number of nodes to process before yielding to event loop
|
// Number of nodes to process before yielding to event loop
|
||||||
const BATCH_SIZE = 25
|
const BATCH_SIZE = 10
|
||||||
|
|
||||||
const enqueue = (type: MessageType, node: BinaryNode) => {
|
const enqueue = (type: MessageType, node: BinaryNode) => {
|
||||||
nodes.push({ type, node })
|
nodes.push({ type, node })
|
||||||
@@ -2799,33 +2055,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
await processNode('call', node, 'handling call', handleCall)
|
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 => {
|
ws.on('CB:receipt', async node => {
|
||||||
await processNode('receipt', node, 'handling receipt', handleReceipt)
|
await processNode('receipt', node, 'handling receipt', handleReceipt)
|
||||||
})
|
})
|
||||||
@@ -2932,20 +2161,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
sendMessageAck,
|
sendMessageAck,
|
||||||
sendRetryRequest,
|
sendRetryRequest,
|
||||||
rejectCall,
|
rejectCall,
|
||||||
offerCall,
|
|
||||||
acceptCall,
|
|
||||||
preacceptCall,
|
|
||||||
terminateCall,
|
|
||||||
sendRelayLatency,
|
|
||||||
sendTransport,
|
|
||||||
sendCallDuration,
|
|
||||||
muteCall,
|
|
||||||
sendHeartbeat,
|
|
||||||
sendEncRekey,
|
|
||||||
sendVideoState,
|
|
||||||
createCallLink,
|
|
||||||
queryCallLink,
|
|
||||||
joinCallLink,
|
|
||||||
fetchMessageHistory,
|
fetchMessageHistory,
|
||||||
requestPlaceholderResend,
|
requestPlaceholderResend,
|
||||||
messageRetryManager
|
messageRetryManager
|
||||||
|
|||||||
@@ -1667,8 +1667,8 @@ export const makeSocket = (config: SocketConfig) => {
|
|||||||
// already-authenticated session (no QR re-scan needed). In this case we skip
|
// 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
|
// 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).
|
// server to finish flushing the pending-message backlog (CB:ib,,offline).
|
||||||
// For normal restarts (no stale routingInfo) the standard 2 s safety cap applies.
|
// For normal restarts (no stale routingInfo) the standard 5 s safety cap applies.
|
||||||
const OFFLINE_BUFFER_TIMEOUT_MS = 2_000
|
const OFFLINE_BUFFER_TIMEOUT_MS = 5_000
|
||||||
let offlineBufferTimeout: NodeJS.Timeout | undefined
|
let offlineBufferTimeout: NodeJS.Timeout | undefined
|
||||||
|
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
|
|||||||
+1
-43
@@ -1,30 +1,4 @@
|
|||||||
export type WACallUpdateType =
|
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate'
|
||||||
| '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 = {
|
export type WACallEvent = {
|
||||||
chatId: string
|
chatId: string
|
||||||
@@ -39,20 +13,4 @@ export type WACallEvent = {
|
|||||||
latencyMs?: number
|
latencyMs?: number
|
||||||
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */
|
/** Phone number of the caller (sanitized to fix Brazilian landline bug) */
|
||||||
callerPn?: string
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,15 +54,15 @@ export interface BufferConfig {
|
|||||||
*/
|
*/
|
||||||
export function loadBufferConfig(): BufferConfig {
|
export function loadBufferConfig(): BufferConfig {
|
||||||
return {
|
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),
|
// 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.
|
// 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),
|
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),
|
maxHistoryCacheSize: parseInt(process.env.BAILEYS_BUFFER_MAX_HISTORY_CACHE || '10000', 10),
|
||||||
maxBufferSize: parseInt(process.env.BAILEYS_BUFFER_MAX_SIZE || '5000', 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',
|
enableAdaptiveTimeout: process.env.BAILEYS_BUFFER_ADAPTIVE_TIMEOUT !== 'false',
|
||||||
enableMetrics: process.env.BAILEYS_BUFFER_METRICS === 'true' || process.env.BAILEYS_PROMETHEUS_ENABLED === 'true',
|
enableMetrics: process.env.BAILEYS_BUFFER_METRICS === 'true' || process.env.BAILEYS_PROMETHEUS_ENABLED === 'true',
|
||||||
lruCleanupRatio: parseFloat(process.env.BAILEYS_BUFFER_LRU_CLEANUP_RATIO || '0.2'),
|
lruCleanupRatio: parseFloat(process.env.BAILEYS_BUFFER_LRU_CLEANUP_RATIO || '0.2'),
|
||||||
|
|||||||
@@ -429,36 +429,6 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => {
|
|||||||
case 'accept':
|
case 'accept':
|
||||||
status = 'accept'
|
status = 'accept'
|
||||||
break
|
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:
|
default:
|
||||||
status = 'ringing'
|
status = 'ringing'
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -113,14 +113,6 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
|
|||||||
msgKey.remoteJid = message.key.remoteJid
|
msgKey.remoteJid = message.key.remoteJid
|
||||||
// set participant of the message
|
// set participant of the message
|
||||||
msgKey.participant = msgKey.participant || message.key.participant
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
|
|||||||
secondary: config.version[1],
|
secondary: config.version[1],
|
||||||
tertiary: config.version[2]
|
tertiary: config.version[2]
|
||||||
},
|
},
|
||||||
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
|
platform: proto.ClientPayload.UserAgent.Platform.WEB,
|
||||||
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
|
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
|
||||||
osVersion: '0.1',
|
osVersion: '0.1',
|
||||||
device: 'Desktop',
|
device: 'Desktop',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { jest } from '@jest/globals'
|
|||||||
* bad-ack-handling.test.ts.
|
* 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 */
|
/** Mirrors the state variables declared at the top of makeSocket */
|
||||||
interface OfflineBufferState {
|
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)
|
startBuffer(state, mockFlush, mockWarn)
|
||||||
|
|
||||||
expect(mockFlush).not.toHaveBeenCalled()
|
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', () => {
|
it('CB:ib,,offline cancels the timer and flushes exactly once', () => {
|
||||||
startBuffer(state, mockFlush, mockWarn)
|
startBuffer(state, mockFlush, mockWarn)
|
||||||
|
|
||||||
// Server responds before the 2 s timeout
|
// Server responds before the 5 s timeout
|
||||||
jest.advanceTimersByTime(1_000)
|
jest.advanceTimersByTime(1_000)
|
||||||
onOffline(state, mockFlush)
|
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)
|
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
||||||
|
|
||||||
expect(mockFlush).toHaveBeenCalledTimes(1)
|
expect(mockFlush).toHaveBeenCalledTimes(1)
|
||||||
@@ -192,7 +192,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
|||||||
|
|
||||||
onClose(state)
|
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)
|
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
||||||
|
|
||||||
expect(mockFlush).not.toHaveBeenCalled()
|
expect(mockFlush).not.toHaveBeenCalled()
|
||||||
@@ -236,7 +236,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
|||||||
// 4. Boundary / timing edge cases
|
// 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)
|
startBuffer(state, mockFlush, mockWarn)
|
||||||
|
|
||||||
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS - 1)
|
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS - 1)
|
||||||
@@ -244,7 +244,7 @@ describe('offline-buffer safety timer (socket.ts)', () => {
|
|||||||
expect(mockFlush).not.toHaveBeenCalled()
|
expect(mockFlush).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('flushes at exactly the 2 s boundary', () => {
|
it('flushes at exactly the 5 s boundary', () => {
|
||||||
startBuffer(state, mockFlush, mockWarn)
|
startBuffer(state, mockFlush, mockWarn)
|
||||||
|
|
||||||
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
jest.advanceTimersByTime(OFFLINE_BUFFER_TIMEOUT_MS)
|
||||||
|
|||||||
Reference in New Issue
Block a user