Compare commits

..

1 Commits

Author SHA1 Message Date
Renato Alcara ef365246ea feat: add Android browser preset for companion device registration
Adds `Browsers.android(apiLevel)` preset that registers the companion
device as Android in WhatsApp's "Linked Devices" list, enabling
view-once messages and Android-specific features.

Inspired by PR #2201 (WhiskeySockets/Baileys#2201) which introduced
the concept, but with critical fixes discovered through extensive
reverse engineering and real-device testing.

## Usage

  const sock = makeWASocket({
    browser: Browsers.android('14'),
    auth: state
  })

The device appears as "Android (14)" in the phone's Linked Devices list.

## Investigation and Findings

### Two different platform fields

WhatsApp uses two separate platform identifiers that serve different
purposes:

1. `companion_platform_id` (pair code stanza) — validated by the server
   BEFORE showing the confirmation prompt on the phone. Controls whether
   the server accepts the pairing request.

2. `DeviceProps.PlatformType` (registration node) — sent AFTER pairing
   confirmation. Determines the display name in "Linked Devices".

These are independent: you can pair with Chrome (1) as companion but
register with ANDROID_PHONE (16) in DeviceProps, and the device will
correctly appear as "Android" in linked devices.

### Pair code platform testing results

Tested with real WhatsApp server via web protocol (WA\x06\x03):

| companion_platform_id | Result                                    |
|-----------------------|-------------------------------------------|
| Chrome (1)            | WORKS — phone shows "connect device?" prompt |
| ANDROID_PHONE (16)    | TIMEOUT — server silently ignores stanza    |
| UWP (21)              | REJECTED — "cannot connect device" error    |

When we sent `companion_platform_id: "16"` (ANDROID_PHONE), the server
did not respond at all — the request timed out after 30s with no
`link_code_pairing_ref` response. With Chrome ("1"), it responds
immediately.

This confirms: ANDROID_PHONE(16) is a primary device type, not a
companion type. The server expects companions with this type to use
the native Android protocol (WAM\x05 with key_attestation TEE
certificates). Since Baileys uses the web protocol (WA\x06\x03),
the server rejects it.

### UserAgent.Platform testing results

The original PR #2201 used `UserAgent.Platform = SMB_ANDROID` for
Android browser. Testing revealed this breaks pair code registration:

- With `SMB_ANDROID`: pair code generates, phone shows confirmation,
  user clicks "Yes", but registration FAILS with "cannot connect
  device" error. The mismatch between web protocol handshake
  (WA\x06\x03) and Android UserAgent causes the server to reject
  the registration.

- With `MACOS`: pair code works end-to-end. Device connects
  successfully and appears as "Android (14)" in linked devices.

Additionally, the upstream default `UserAgent.Platform = WEB` causes
405 connection failures on some server endpoints. Using `MACOS` (24)
resolves this.

### WhatsApp Desktop Windows investigation (Frida + binary analysis)

To understand how official WhatsApp clients handle platform identity,
we reverse-engineered WhatsApp Desktop for Windows:

- WhatsApp Desktop is a WebView2 wrapper loading
  `web.whatsapp.com?windows=1` — NOT a native client
- Uses the same web protocol (WA\x06\x03) as Chrome
- Registers with `DeviceProps.PlatformType = UWP (21)` to appear as
  "Windows" in linked devices
- Always shows as "Windows" regardless of QR or pair code connection

This confirmed our approach: the display name comes from DeviceProps,
not from the pair code stanza.

### Relevant protocol enums

ClientPayload.UserAgent.Platform (connection identity):
  MACOS = 24 (what we use — web-compatible)
  SMB_ANDROID = 10 (breaks pair code — DO NOT USE)
  WEB = 15 (causes 405 errors)

DeviceProps.PlatformType (linked device display name):
  CHROME = 1, FIREFOX = 2, SAFARI = 5, EDGE = 6
  DESKTOP = 7, CATALINA = 12, ANDROID_PHONE = 16, UWP = 21

## Changes

- Types/index.ts: add `android(apiLevel)` to BrowsersMap type
- browser-utils.ts: add `Browsers.android()` preset,
  `isAndroidBrowser()` helper, fix `getPlatformId()` to resolve
  ANDROID -> ANDROID_PHONE (16) since the enum only has
  ANDROID_PHONE, not ANDROID
- validate-connection.ts: use MACOS platform in UserAgent (fixes 405
  and pair code), map ANDROID -> ANDROID_PHONE in getPlatformType()
- socket.ts: auto-detect Android browser in pair code and fall back
  to Chrome (1) as companion_platform_id. Non-Android browsers are
  unaffected and continue using their native platform ID.

## How it works

With `Browsers.android('14')`:
- `getUserAgent()` -> platform: MACOS, device: Desktop (web identity)
- `getClientPayload()` -> includes webInfo as normal
- `getPlatformType('Android')` -> ANDROID_PHONE (in DeviceProps)
- Pair code -> companion_platform_id: '1' (Chrome fallback)
- QR code -> no override needed, works directly
- Result: device appears as "Android (14)" in Linked Devices
2026-03-02 22:28:01 -03:00
14 changed files with 224 additions and 1589 deletions
+168 -640
View File
@@ -17,8 +17,6 @@ import type {
MessageUserReceipt, MessageUserReceipt,
SocketConfig, SocketConfig,
WACallEvent, WACallEvent,
WACallParticipant,
WACallUpdateType,
WAMessage, WAMessage,
WAMessageKey, WAMessageKey,
WAPatchName WAPatchName
@@ -52,8 +50,6 @@ import {
xmppSignedPreKey xmppSignedPreKey
} from '../Utils' } from '../Utils'
import { makeMutex } from '../Utils/make-mutex' import { makeMutex } from '../Utils/make-mutex'
import { makeOfflineNodeProcessor, type MessageType } from '../Utils/offline-node-processor'
import { buildAckStanza } from '../Utils/stanza-ack'
import { import {
areJidsSameUser, areJidsSameUser,
type BinaryNode, type BinaryNode,
@@ -346,9 +342,40 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
const sendMessageAck = async (node: BinaryNode, errorCode?: number) => { const sendMessageAck = async ({ tag, attrs, content }: BinaryNode, errorCode?: number) => {
const stanza = buildAckStanza(node, errorCode, authState.creds.me!.id) const stanza: BinaryNode = {
logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack') tag: 'ack',
attrs: {
id: attrs.id!,
to: attrs.from!,
class: tag
}
}
if (!!errorCode) {
stanza.attrs.error = errorCode.toString()
}
if (!!attrs.participant) {
stanza.attrs.participant = attrs.participant
}
if (!!attrs.recipient) {
stanza.attrs.recipient = attrs.recipient
}
if (
!!attrs.type &&
(tag !== 'message' || getBinaryNodeChild({ tag, attrs, content }, 'unavailable') || errorCode !== 0)
) {
stanza.attrs.type = attrs.type
}
if (tag === 'message' && getBinaryNodeChild({ tag, attrs, content }, 'unavailable')) {
stanza.attrs.from = authState.creds.me!.id
}
logger.debug({ recv: { tag, attrs }, sent: stanza.attrs }, 'sent ack')
await sendNode(stanza) await sendNode(stanza)
} }
@@ -374,375 +401,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await query(stanza) await query(stanza)
} }
// ====================================================================
// Call signaling functions
// ====================================================================
/**
* Offer (initiate) a call to a JID.
* @param jid - destination JID
* @param isVideo - true for video call, false for voice
*/
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, video calls do not
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. */
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. */
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 (codec capabilities) for an incoming 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. */
const sendRelayLatency = async (
callId: string,
callCreator: string,
relays: Array<{ relayName?: string; latency: number; relayId?: string; dlBw?: number; ulBw?: number }>,
transactionId?: string
) => {
const attrs: Record<string, string> = { 'call-id': callId, 'call-creator': callCreator }
if (transactionId) attrs['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 }
})
await sendNode({
tag: 'call',
attrs: { to: callCreator, id: randomBytes(16).toString('hex').toUpperCase() },
content: [{ tag: 'relaylatency', attrs, content: teChildren }]
})
}
/** Send transport (p2p/ICE) candidates for a call. */
const sendTransport = async (
callId: string,
callCreator: string,
to: string,
candidates: Array<{ priority: string; data?: Uint8Array }>,
round?: number
) => {
const attrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator,
'transport-message-type': '1'
}
if (round !== undefined) attrs['p2p-cand-round'] = String(round)
await sendNode({
tag: 'call',
attrs: { to, id: randomBytes(16).toString('hex').toUpperCase() },
content: [
{
tag: 'transport',
attrs,
content: candidates.map(c => ({ tag: 'te', attrs: { priority: c.priority }, content: c.data }))
}
]
})
}
/** Send call duration log to the server after a call ends. */
const sendCallDuration = async (
callId: string,
callCreator: string,
peer: string,
audioDuration: number,
callType = '1x1'
) => {
await sendNode({
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
}
]
})
}
/** Mute or unmute during a call. */
const muteCall = async (callId: string, callCreator: string, to: string, muted: boolean) => {
await sendNode({
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
}
]
})
}
/** Send heartbeat to keep a group/link call alive. */
const sendHeartbeat = async (callId: string, callCreator: string) => {
await sendNode({
tag: 'call',
attrs: { to: `${callId}@call`, id: randomBytes(16).toString('hex').toUpperCase() },
content: [{ tag: 'heartbeat', attrs: { 'call-id': callId, 'call-creator': callCreator }, content: undefined }]
})
}
/** Send encryption re-key during a call. */
const sendEncRekey = async (callId: string, callCreator: string, to: string, transactionId: string) => {
await sendNode({
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 }
]
}
]
})
}
/** Send video state change during a call. */
const sendVideoState = async (
callId: string,
callCreator: string,
to: string,
enabled: boolean,
orientation = '1'
) => {
await sendNode({
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
}
]
})
}
/** Create a call link that others can join. Returns { token, url, 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)
let token: string | undefined
const linkCreateResp = getBinaryNodeChild(response, 'link_create')
if (linkCreateResp) {
token = linkCreateResp.attrs.token || linkCreateResp.attrs['link-token']
}
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
}
}
}
const url = token ? `https://call.whatsapp.com/${token}` : undefined
return { token, url, response }
}
/** Query info about a call link before joining. */
const queryCallLink = async (token: string, media: 'video' | 'audio' = 'video') => {
return await query({
tag: 'call',
attrs: { to: 'call', id: randomBytes(16).toString('hex').toUpperCase() },
content: [{ tag: 'link_query', attrs: { media, token }, content: undefined }]
})
}
/** Join a call via its link token. */
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
})
}
return await query({
tag: 'call',
attrs: { to: 'call', id: randomBytes(16).toString('hex').toUpperCase() },
content: [{ tag: 'link_join', attrs: { media, token }, content: joinContent }]
})
}
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
@@ -1480,7 +1138,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}) })
]) ])
} finally { } finally {
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack receipt')) await sendMessageAck(node)
} }
} }
@@ -1517,7 +1175,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}) })
]) ])
} finally { } finally {
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack notification')) await sendMessageAck(node)
} }
} }
@@ -1536,43 +1194,46 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return return
} }
let acked = false const {
fullMessage: msg,
category,
author,
decrypt
} = decryptMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '', signalRepository, logger)
const alt = msg.key.participantAlt || msg.key.remoteJidAlt
// store new mappings we didn't have before
if (!!alt) {
const altServer = jidDecode(alt)?.server
const primaryJid = msg.key.participant || msg.key.remoteJid!
if (altServer === 'lid') {
if (!(await signalRepository.lidMapping.getPNForLID(alt))) {
await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
await signalRepository.migrateSession(primaryJid, alt)
}
} else {
await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
await signalRepository.migrateSession(alt, primaryJid)
}
}
if (msg.key?.remoteJid && msg.key?.id && messageRetryManager) {
messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message!)
logger.debug(
{
jid: msg.key.remoteJid,
id: msg.key.id
},
'Added message to recent cache for retry receipts'
)
}
try { try {
const {
fullMessage: msg,
category,
author,
decrypt
} = decryptMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '', signalRepository, logger)
const alt = msg.key.participantAlt || msg.key.remoteJidAlt
// store new mappings we didn't have before
if (!!alt) {
const altServer = jidDecode(alt)?.server
const primaryJid = msg.key.participant || msg.key.remoteJid!
if (altServer === 'lid') {
if (!(await signalRepository.lidMapping.getPNForLID(alt))) {
await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
await signalRepository.migrateSession(primaryJid, alt)
}
} else {
await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
await signalRepository.migrateSession(alt, primaryJid)
}
}
await messageMutex.mutex(async () => { await messageMutex.mutex(async () => {
await decrypt() await decrypt()
if (msg.key?.remoteJid && msg.key?.id && msg.message && messageRetryManager) {
messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message)
}
// message failed to decrypt // message failed to decrypt
if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') { if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) { if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) {
acked = true
return sendMessageAck(node, NACK_REASONS.ParsingError) return sendMessageAck(node, NACK_REASONS.ParsingError)
} }
@@ -1590,14 +1251,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
{ msgId: msg.key.id, unavailableType }, { msgId: msg.key.id, unavailableType },
'skipping placeholder resend for excluded unavailable type' 'skipping placeholder resend for excluded unavailable type'
) )
acked = true
return sendMessageAck(node) return sendMessageAck(node)
} }
const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp) const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) { if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) {
logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message') logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message')
acked = true
return sendMessageAck(node) return sendMessageAck(node)
} }
@@ -1635,7 +1294,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
.catch(err => { .catch(err => {
logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message') logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message')
}) })
acked = true
await sendMessageAck(node) await sendMessageAck(node)
// Don't return — fall through to upsertMessage so the stub is emitted // Don't return — fall through to upsertMessage so the stub is emitted
} else { } else {
@@ -1647,7 +1305,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
{ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid }, { msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid },
'skipping retry for expired status message' 'skipping retry for expired status message'
) )
acked = true
return sendMessageAck(node) return sendMessageAck(node)
} }
} }
@@ -1695,7 +1352,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
acked = true
await sendMessageAck(node, NACK_REASONS.UnhandledError) await sendMessageAck(node, NACK_REASONS.UnhandledError)
}) })
} }
@@ -1723,7 +1379,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
type = 'inactive' type = 'inactive'
} }
acked = true
await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type) await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type)
// send ack for history message // send ack for history message
@@ -1733,7 +1388,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await sendReceipt(jid, undefined, [msg.key.id!], 'hist_sync') // TODO: investigate await sendReceipt(jid, undefined, [msg.key.id!], 'hist_sync') // TODO: investigate
} }
} else { } else {
acked = true
await sendMessageAck(node) await sendMessageAck(node)
logger.debug({ key: msg.key }, 'processed newsletter message without receipts') logger.debug({ key: msg.key }, 'processed newsletter message without receipts')
} }
@@ -1745,201 +1399,55 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}) })
} catch (error) { } catch (error) {
logger.error({ error, node: binaryNodeToString(node) }, 'error in handling message') logger.error({ error, node: binaryNodeToString(node) }, 'error in handling message')
if (!acked) {
await sendMessageAck(node, NACK_REASONS.UnhandledError).catch(ackErr =>
logger.error({ ackErr }, 'failed to ack message after error')
)
}
} }
} }
/**
* Sanitize Brazilian landline phone numbers from caller_pn.
* WhatsApp decoder may append a trailing zero to 12-digit landlines,
* making them 13 digits. Mobile numbers (first digit 6-9) at 13 digits are correct.
*/
const sanitizeCallerPn = (pn: string | undefined): string | undefined => {
if (!pn) return undefined
if (!pn.startsWith('55')) return pn
if (pn.length === 13) {
const firstDigitAfterDDD = pn.charAt(4)
// Landline (2-5): 13 digits is error → remove trailing zero
if (['2', '3', '4', '5'].includes(firstDigitAfterDDD) && pn.endsWith('0')) {
return pn.slice(0, -1)
}
}
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 from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
const call: WACallEvent = { const call: WACallEvent = {
chatId: attrs.from!, chatId: attrs.from!,
from, from,
id: callId, callerPn: infoChild.attrs['caller_pn'],
date: new Date(+attrs.t! * 1000), id: callId,
offline: !!attrs.offline, date: new Date(+attrs.t! * 1000),
status 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']
call.callerPn = sanitizeCallerPn(infoChild.attrs['caller_pn'])
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)
}
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)
}
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)
}
}
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
}
}
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)
}
}
if (status === 'accept' || status === 'preaccept') {
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
}
const existingCall = await callOfferCache.get<WACallEvent>(call.id)
if (existingCall) {
call.isVideo = call.isVideo ?? existingCall.isVideo
call.isGroup = call.isGroup ?? existingCall.isGroup
call.groupJid = call.groupJid ?? existingCall.groupJid
call.callerPn = call.callerPn || existingCall.callerPn
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
}
if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
await callOfferCache.del(call.id)
}
ev.emit('call', [call])
} }
try {
const { attrs } = node
const [infoChild] = getAllBinaryNodeChildren(node)
if (!infoChild) { if (status === 'offer') {
throw new Boom('Missing call info in call node') call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
} call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
call.groupJid = infoChild.attrs['group-jid']
const status = getCallStatusFromNode(infoChild) await callOfferCache.set(call.id, call)
const callId = infoChild.attrs['call-id']!
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
const call: WACallEvent = {
chatId: attrs.from!,
from,
callerPn: infoChild.attrs['caller_pn'],
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']
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.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])
} catch (error) {
logger.error({ error, node: binaryNodeToString(node) }, 'error in handling call')
} finally {
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack 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.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)
} }
const handleBadAck = async ({ attrs }: BinaryNode) => { const handleBadAck = async ({ attrs }: BinaryNode) => {
@@ -2005,19 +1513,74 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
const offlineNodeProcessor = makeOfflineNodeProcessor( type MessageType = 'message' | 'call' | 'receipt' | 'notification'
new Map<MessageType, (node: BinaryNode) => Promise<void>>([
type OfflineNode = {
type: MessageType
node: BinaryNode
}
/** Yields control to the event loop to prevent blocking */
const yieldToEventLoop = (): Promise<void> => {
return new Promise(resolve => setImmediate(resolve))
}
const makeOfflineNodeProcessor = () => {
const nodeProcessorMap: Map<MessageType, (node: BinaryNode) => Promise<void>> = new Map([
['message', handleMessage], ['message', handleMessage],
['call', handleCall], ['call', handleCall],
['receipt', handleReceipt], ['receipt', handleReceipt],
['notification', handleNotification] ['notification', handleNotification]
]), ])
{ const nodes: OfflineNode[] = []
isWsOpen: () => ws.isOpen, let isProcessing = false
onUnexpectedError,
yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve)) // Number of nodes to process before yielding to event loop
const BATCH_SIZE = 10
const enqueue = (type: MessageType, node: BinaryNode) => {
nodes.push({ type, node })
if (isProcessing) {
return
}
isProcessing = true
const promise = async () => {
let processedInBatch = 0
while (nodes.length && ws.isOpen) {
const { type, node } = nodes.shift()!
const nodeProcessor = nodeProcessorMap.get(type)
if (!nodeProcessor) {
onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node')
continue
}
await nodeProcessor(node)
processedInBatch++
// Yield to event loop after processing a batch
// This prevents blocking the event loop for too long when there are many offline nodes
if (processedInBatch >= BATCH_SIZE) {
processedInBatch = 0
await yieldToEventLoop()
}
}
isProcessing = false
}
promise().catch(error => onUnexpectedError(error, 'processing offline nodes'))
} }
)
return { enqueue }
}
const offlineNodeProcessor = makeOfflineNodeProcessor()
const processNode = async ( const processNode = async (
type: MessageType, type: MessageType,
@@ -2043,27 +1606,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']
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)
}) })
@@ -2119,20 +1661,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
+12 -3
View File
@@ -36,7 +36,7 @@ import {
signedKeyPair, signedKeyPair,
xmppSignedPreKey xmppSignedPreKey
} from '../Utils' } from '../Utils'
import { getPlatformId } from '../Utils/browser-utils' import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils'
import { import {
assertNodeErrorFree, assertNodeErrorFree,
type BinaryNode, type BinaryNode,
@@ -758,6 +758,15 @@ export const makeSocket = (config: SocketConfig) => {
id: jidEncode(phoneNumber, 's.whatsapp.net'), id: jidEncode(phoneNumber, 's.whatsapp.net'),
name: '~' name: '~'
} }
// Pair code companion_platform_id must be Chrome (1) when using Android
// browser preset. ANDROID_PHONE (16) causes silent timeout, UWP (21)
// causes rejection. Only Chrome (1) works for pair code via web protocol.
// The device still appears as "Android" in linked devices because
// DeviceProps.platformType=ANDROID_PHONE is set in the registration node.
const isAndroid = isAndroidBrowser(browser)
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
ev.emit('creds.update', authState.creds) ev.emit('creds.update', authState.creds)
await sendNode({ await sendNode({
tag: 'iq', tag: 'iq',
@@ -790,12 +799,12 @@ export const makeSocket = (config: SocketConfig) => {
{ {
tag: 'companion_platform_id', tag: 'companion_platform_id',
attrs: {}, attrs: {},
content: getPlatformId(browser[1]) content: pairPlatformId
}, },
{ {
tag: 'companion_platform_display', tag: 'companion_platform_display',
attrs: {}, attrs: {},
content: `${browser[1]} (${browser[0]})` content: pairPlatformDisplay
}, },
{ {
tag: 'link_code_pairing_nonce', tag: 'link_code_pairing_nonce',
+2 -45
View File
@@ -1,34 +1,9 @@
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
from: string from: string
callerPn?: string
isGroup?: boolean isGroup?: boolean
groupJid?: string groupJid?: string
id: string id: string
@@ -37,22 +12,4 @@ export type WACallEvent = {
status: WACallUpdateType status: WACallUpdateType
offline: boolean offline: boolean
latencyMs?: number latencyMs?: number
/** Phone number of the caller */
callerPn?: string
/** Call link token (forms URL: https://call.whatsapp.com/<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
} }
-2
View File
@@ -113,8 +113,6 @@ export interface WAUrlInfo {
type Mentionable = { type Mentionable = {
/** list of jids that are mentioned in the accompanying text */ /** list of jids that are mentioned in the accompanying text */
mentions?: string[] mentions?: string[]
/** mention all */
mentionAll?: boolean
} }
type Contextable = { type Contextable = {
/** add contextInfo to the message */ /** add contextInfo to the message */
+1
View File
@@ -22,6 +22,7 @@ export type BrowsersMap = {
baileys(browser: string): [string, string, string] baileys(browser: string): [string, string, string]
windows(browser: string): [string, string, string] windows(browser: string): [string, string, string]
appropriate(browser: string): [string, string, string] appropriate(browser: string): [string, string, string]
android(apiLevel: string): [string, string, string]
} }
export enum DisconnectReason { export enum DisconnectReason {
+25 -2
View File
@@ -22,10 +22,33 @@ export const Browsers: BrowsersMap = {
baileys: browser => ['Baileys', browser, '6.5.0'], baileys: browser => ['Baileys', browser, '6.5.0'],
windows: browser => ['Windows', browser, '10.0.22631'], windows: browser => ['Windows', browser, '10.0.22631'],
/** The appropriate browser based on your OS & release */ /** The appropriate browser based on your OS & release */
appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()] appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()],
/** Android companion device. apiLevel is the Android API level (e.g. '14') */
android: (apiLevel: string) => [apiLevel, 'Android', '']
}
/**
* Checks if the browser tuple represents an Android companion device.
* @param browser - Browser tuple [os, platform, version]
* @returns True if platform is 'Android' (case-insensitive)
*/
export const isAndroidBrowser = (browser: [string, string, string]): boolean => {
return browser[1]?.toUpperCase() === 'ANDROID'
} }
export const getPlatformId = (browser: string) => { export const getPlatformId = (browser: string) => {
const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any] const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any]
return platformType ? platformType.toString() : '1' //chrome if (platformType !== undefined) {
return platformType.toString()
}
// 'ANDROID' is not in the PlatformType enum — map to ANDROID_PHONE
if (browser.toUpperCase() === 'ANDROID') {
const androidPhone = proto.DeviceProps.PlatformType['ANDROID_PHONE' as any]
if (androidPhone !== undefined) {
return androidPhone.toString()
}
}
return '1' // Chrome
} }
-30
View File
@@ -397,36 +397,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
-1
View File
@@ -17,4 +17,3 @@ export * from './process-message'
export * from './message-retry-manager' export * from './message-retry-manager'
export * from './browser-utils' export * from './browser-utils'
export * from './identity-change-handler' export * from './identity-change-handler'
export * from './stanza-ack'
+6 -12
View File
@@ -609,20 +609,14 @@ export const generateWAMessageContent = async (
m = { viewOnceMessage: { message: m } } m = { viewOnceMessage: { message: m } }
} }
if ( if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
(hasOptionalProperty(message, 'mentions') && message.mentions?.length) ||
(hasOptionalProperty(message, 'mentionAll') && message.mentionAll)
) {
const messageType = Object.keys(m)[0]! as Extract<keyof proto.IMessage, MessageWithContextInfo> const messageType = Object.keys(m)[0]! as Extract<keyof proto.IMessage, MessageWithContextInfo>
const key = m[messageType] const key = m[messageType]
if (key && 'contextInfo' in key) { if ('contextInfo' in key! && !!key.contextInfo) {
key.contextInfo = key.contextInfo || {} key.contextInfo.mentionedJid = message.mentions
if (message.mentions?.length) { } else if (key!) {
key.contextInfo.mentionedJid = message.mentions key.contextInfo = {
} mentionedJid: message.mentions
if (message.mentionAll) {
key.contextInfo.nonJidMentions = 1
} }
} }
} }
-70
View File
@@ -1,70 +0,0 @@
import type { BinaryNode } from '../WABinary'
export type MessageType = 'message' | 'call' | 'receipt' | 'notification'
type OfflineNode = {
type: MessageType
node: BinaryNode
}
export type OfflineNodeProcessorDeps = {
isWsOpen: () => boolean
onUnexpectedError: (error: Error, msg: string) => void
yieldToEventLoop: () => Promise<void>
}
/**
* Creates a processor for offline stanza nodes that:
* - Queues nodes for sequential processing
* - Yields to the event loop periodically to avoid blocking
* - Catches handler errors to prevent the processing loop from crashing
*/
export function makeOfflineNodeProcessor(
nodeProcessorMap: Map<MessageType, (node: BinaryNode) => Promise<void>>,
deps: OfflineNodeProcessorDeps,
batchSize = 10
) {
const nodes: OfflineNode[] = []
let isProcessing = false
const enqueue = (type: MessageType, node: BinaryNode) => {
nodes.push({ type, node })
if (isProcessing) {
return
}
isProcessing = true
const promise = async () => {
let processedInBatch = 0
while (nodes.length && deps.isWsOpen()) {
const { type, node } = nodes.shift()!
const nodeProcessor = nodeProcessorMap.get(type)
if (!nodeProcessor) {
deps.onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node')
continue
}
await nodeProcessor(node).catch(err => deps.onUnexpectedError(err, `processing offline ${type}`))
processedInBatch++
// Yield to event loop after processing a batch
// This prevents blocking the event loop for too long when there are many offline nodes
if (processedInBatch >= batchSize) {
processedInBatch = 0
await deps.yieldToEventLoop()
}
}
isProcessing = false
}
promise().catch(error => deps.onUnexpectedError(error, 'processing offline nodes'))
}
return { enqueue }
}
-45
View File
@@ -1,45 +0,0 @@
import type { BinaryNode } from '../WABinary'
/**
* Builds an ACK stanza for a received node.
* Pure function -- no I/O, no side effects.
*
* Mirrors WhatsApp Web's ACK construction:
* - WAWebHandleMsgSendAck.sendAck / sendNack
* - WAWebCreateNackFromStanza.createNackFromStanza
*/
export function buildAckStanza(node: BinaryNode, errorCode?: number, meId?: string): BinaryNode {
const { tag, attrs } = node
const stanza: BinaryNode = {
tag: 'ack',
attrs: {
id: attrs.id!,
to: attrs.from!,
class: tag
}
}
if (errorCode) {
stanza.attrs.error = errorCode.toString()
}
if (attrs.participant) {
stanza.attrs.participant = attrs.participant
}
if (attrs.recipient) {
stanza.attrs.recipient = attrs.recipient
}
// WA Web always includes type when present: `n.type || DROP_ATTR`
if (attrs.type) {
stanza.attrs.type = attrs.type
}
// WA Web WAWebHandleMsgSendAck.sendAck/sendNack always include `from` for message-class ACKs
if (tag === 'message' && meId) {
stanza.attrs.from = meId
}
return stanza
}
+10 -1
View File
@@ -14,13 +14,17 @@ import { encodeBigEndian } from './generics'
import { createSignalIdentity } from './signal' import { createSignalIdentity } from './signal'
const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
// Always use MACOS platform for UserAgent — we connect via web protocol
// (WA\x06\x03) so the server expects a web-compatible identity.
// Using WEB causes 405 errors; using SMB_ANDROID breaks pair code.
// Android identity is only set in DeviceProps (registration node).
return { return {
appVersion: { appVersion: {
primary: config.version[0], primary: config.version[0],
secondary: config.version[1], secondary: config.version[1],
tertiary: config.version[2] tertiary: config.version[2]
}, },
platform: proto.ClientPayload.UserAgent.Platform.WEB, platform: proto.ClientPayload.UserAgent.Platform.MACOS,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1', osVersion: '0.1',
device: 'Desktop', device: 'Desktop',
@@ -79,6 +83,11 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto.
const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => { const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => {
const platformType = platform.toUpperCase() const platformType = platform.toUpperCase()
// 'ANDROID' is not in PlatformType enum — map to ANDROID_PHONE
if (platformType === 'ANDROID') {
return proto.DeviceProps.PlatformType.ANDROID_PHONE
}
return ( return (
proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] || proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] ||
proto.DeviceProps.PlatformType.CHROME proto.DeviceProps.PlatformType.CHROME
@@ -1,318 +0,0 @@
import { jest } from '@jest/globals'
import { makeOfflineNodeProcessor, type MessageType } from '../../Utils/offline-node-processor'
import { type BinaryNode } from '../../WABinary'
function makeNode(id: string, tag = 'message'): BinaryNode {
return { tag, attrs: { id, from: 'user@s.whatsapp.net', offline: '1' } }
}
describe('makeOfflineNodeProcessor', () => {
let mockOnUnexpectedError: jest.Mock<(error: Error, msg: string) => void>
let isWsOpen: boolean
let yieldCalls: number
function createProcessor(handlers: Map<MessageType, (node: BinaryNode) => Promise<void>>, batchSize = 10) {
return makeOfflineNodeProcessor(
handlers,
{
isWsOpen: () => isWsOpen,
onUnexpectedError: mockOnUnexpectedError,
yieldToEventLoop: async () => {
yieldCalls++
}
},
batchSize
)
}
beforeEach(() => {
mockOnUnexpectedError = jest.fn()
isWsOpen = true
yieldCalls = 0
})
describe('basic processing', () => {
it('should process a single enqueued node', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
processed.push(node.attrs.id!)
})
const processor = createProcessor(new Map([['message', handler]]))
processor.enqueue('message', makeNode('msg-1'))
// wait for microtask queue to flush
await new Promise(r => setTimeout(r, 10))
expect(processed).toEqual(['msg-1'])
expect(handler).toHaveBeenCalledTimes(1)
})
it('should process multiple nodes in FIFO order', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
processed.push(node.attrs.id!)
})
const processor = createProcessor(new Map([['message', handler]]))
processor.enqueue('message', makeNode('msg-1'))
processor.enqueue('message', makeNode('msg-2'))
processor.enqueue('message', makeNode('msg-3'))
await new Promise(r => setTimeout(r, 10))
expect(processed).toEqual(['msg-1', 'msg-2', 'msg-3'])
})
it('should dispatch nodes to correct handler by type', async () => {
const messageIds: string[] = []
const callIds: string[] = []
const receiptIds: string[] = []
const notificationIds: string[] = []
const handlers = new Map<MessageType, (node: BinaryNode) => Promise<void>>([
[
'message',
async n => {
messageIds.push(n.attrs.id!)
}
],
[
'call',
async n => {
callIds.push(n.attrs.id!)
}
],
[
'receipt',
async n => {
receiptIds.push(n.attrs.id!)
}
],
[
'notification',
async n => {
notificationIds.push(n.attrs.id!)
}
]
])
const processor = createProcessor(handlers)
processor.enqueue('message', makeNode('msg-1'))
processor.enqueue('call', makeNode('call-1', 'call'))
processor.enqueue('receipt', makeNode('rcpt-1', 'receipt'))
processor.enqueue('notification', makeNode('notif-1', 'notification'))
await new Promise(r => setTimeout(r, 10))
expect(messageIds).toEqual(['msg-1'])
expect(callIds).toEqual(['call-1'])
expect(receiptIds).toEqual(['rcpt-1'])
expect(notificationIds).toEqual(['notif-1'])
})
})
describe('error resilience', () => {
it('should continue processing after a handler throws', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
if (node.attrs.id === 'msg-2') {
throw new Error('handler crash')
}
processed.push(node.attrs.id!)
})
const processor = createProcessor(new Map([['message', handler]]))
processor.enqueue('message', makeNode('msg-1'))
processor.enqueue('message', makeNode('msg-2'))
processor.enqueue('message', makeNode('msg-3'))
await new Promise(r => setTimeout(r, 10))
// msg-1 and msg-3 should be processed, msg-2 error should be caught
expect(processed).toEqual(['msg-1', 'msg-3'])
expect(mockOnUnexpectedError).toHaveBeenCalledTimes(1)
expect(mockOnUnexpectedError).toHaveBeenCalledWith(expect.any(Error), 'processing offline message')
})
it('should continue processing after multiple handler errors', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
if (node.attrs.id === 'msg-1' || node.attrs.id === 'msg-3') {
throw new Error('crash')
}
processed.push(node.attrs.id!)
})
const processor = createProcessor(new Map([['message', handler]]))
for (let i = 1; i <= 5; i++) {
processor.enqueue('message', makeNode(`msg-${i}`))
}
await new Promise(r => setTimeout(r, 10))
expect(processed).toEqual(['msg-2', 'msg-4', 'msg-5'])
expect(mockOnUnexpectedError).toHaveBeenCalledTimes(2)
})
it('should report unknown node type and continue', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
processed.push(node.attrs.id!)
})
const processor = createProcessor(new Map([['message', handler]]))
// Enqueue an unknown type
processor.enqueue('unknown-type' as MessageType, makeNode('unknown-1'))
processor.enqueue('message', makeNode('msg-1'))
await new Promise(r => setTimeout(r, 10))
expect(processed).toEqual(['msg-1'])
expect(mockOnUnexpectedError).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('unknown offline node type') }),
'processing offline node'
)
})
})
describe('connection awareness', () => {
it('should stop processing when connection closes', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
processed.push(node.attrs.id!)
if (node.attrs.id === 'msg-2') {
isWsOpen = false
}
})
const processor = createProcessor(new Map([['message', handler]]))
for (let i = 1; i <= 5; i++) {
processor.enqueue('message', makeNode(`msg-${i}`))
}
await new Promise(r => setTimeout(r, 10))
// Should stop after msg-2 closes the connection
expect(processed).toEqual(['msg-1', 'msg-2'])
})
it('should resume processing when new nodes are enqueued after connection reopens', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
processed.push(node.attrs.id!)
})
// Start with closed connection
isWsOpen = false
const processor = createProcessor(new Map([['message', handler]]))
processor.enqueue('message', makeNode('msg-1'))
await new Promise(r => setTimeout(r, 10))
expect(processed).toEqual([])
// Reopen connection and enqueue new node
isWsOpen = true
processor.enqueue('message', makeNode('msg-2'))
await new Promise(r => setTimeout(r, 10))
// Both nodes should now be processed (msg-1 was still in queue)
expect(processed).toEqual(['msg-1', 'msg-2'])
})
})
describe('batch yielding', () => {
it('should yield to event loop after batchSize nodes', async () => {
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockResolvedValue(undefined)
const batchSize = 3
const processor = createProcessor(new Map([['message', handler]]), batchSize)
for (let i = 1; i <= 7; i++) {
processor.enqueue('message', makeNode(`msg-${i}`))
}
await new Promise(r => setTimeout(r, 10))
expect(handler).toHaveBeenCalledTimes(7)
// 7 nodes with batchSize 3 => yields after 3rd and 6th = 2 yields
expect(yieldCalls).toBe(2)
})
it('should NOT yield for fewer nodes than batchSize', async () => {
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockResolvedValue(undefined)
const processor = createProcessor(new Map([['message', handler]]), 10)
for (let i = 1; i <= 5; i++) {
processor.enqueue('message', makeNode(`msg-${i}`))
}
await new Promise(r => setTimeout(r, 10))
expect(handler).toHaveBeenCalledTimes(5)
expect(yieldCalls).toBe(0)
})
it('should yield exactly at batchSize boundary', async () => {
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockResolvedValue(undefined)
const processor = createProcessor(new Map([['message', handler]]), 3)
for (let i = 1; i <= 3; i++) {
processor.enqueue('message', makeNode(`msg-${i}`))
}
await new Promise(r => setTimeout(r, 10))
expect(handler).toHaveBeenCalledTimes(3)
expect(yieldCalls).toBe(1)
})
})
describe('isProcessing guard', () => {
it('should not start a second processing loop for concurrent enqueues', async () => {
let resolveFirst: (() => void) | undefined
const firstPromise = new Promise<void>(r => {
resolveFirst = r
})
let callCount = 0
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async () => {
callCount++
if (callCount === 1) {
// Block on first node to simulate slow processing
await firstPromise
}
})
const processor = createProcessor(new Map([['message', handler]]))
processor.enqueue('message', makeNode('msg-1'))
// Give time for first processing to start
await new Promise(r => setTimeout(r, 5))
// Enqueue while first is still processing
processor.enqueue('message', makeNode('msg-2'))
processor.enqueue('message', makeNode('msg-3'))
// Release the first handler
resolveFirst!()
await new Promise(r => setTimeout(r, 10))
expect(handler).toHaveBeenCalledTimes(3)
})
})
describe('mixed error types', () => {
it('should handle both handler errors and unknown types in sequence', async () => {
const processed: string[] = []
const handler = jest.fn<(node: BinaryNode) => Promise<void>>().mockImplementation(async node => {
if (node.attrs.id === 'msg-2') {
throw new Error('boom')
}
processed.push(node.attrs.id!)
})
const processor = createProcessor(new Map([['message', handler]]))
processor.enqueue('message', makeNode('msg-1'))
processor.enqueue('message', makeNode('msg-2')) // will throw
processor.enqueue('bogus' as MessageType, makeNode('x')) // unknown type
processor.enqueue('message', makeNode('msg-3'))
await new Promise(r => setTimeout(r, 10))
expect(processed).toEqual(['msg-1', 'msg-3'])
expect(mockOnUnexpectedError).toHaveBeenCalledTimes(2)
})
})
})
-420
View File
@@ -1,420 +0,0 @@
import { buildAckStanza } from '../../Utils/stanza-ack'
import { type BinaryNode } from '../../WABinary'
describe('buildAckStanza', () => {
describe('basic stanza construction', () => {
it('should build a minimal ACK stanza from a message node', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack).toEqual({
tag: 'ack',
attrs: {
id: 'msg-001',
to: 'user@s.whatsapp.net',
class: 'message'
}
})
})
it('should build ACK for receipt node', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: 'rcpt-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.class).toBe('receipt')
})
it('should build ACK for notification node', () => {
const node: BinaryNode = {
tag: 'notification',
attrs: {
id: 'notif-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.class).toBe('notification')
})
it('should build ACK for call node', () => {
const node: BinaryNode = {
tag: 'call',
attrs: {
id: 'call-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.class).toBe('call')
})
})
describe('error codes (NACK)', () => {
it('should include error attribute when errorCode is provided and non-zero', () => {
const node: BinaryNode = {
tag: 'message',
attrs: { id: 'msg-001', from: 'user@s.whatsapp.net' }
}
const ack = buildAckStanza(node, 500)
expect(ack.attrs.error).toBe('500')
})
it('should include error for all known NACK codes', () => {
const node: BinaryNode = {
tag: 'message',
attrs: { id: 'msg-001', from: 'user@s.whatsapp.net' }
}
const nackCodes = [487, 488, 491, 495, 496, 500, 552]
for (const code of nackCodes) {
const ack = buildAckStanza(node, code)
expect(ack.attrs.error).toBe(code.toString())
}
})
it('should NOT include error when errorCode is 0', () => {
const node: BinaryNode = {
tag: 'message',
attrs: { id: 'msg-001', from: 'user@s.whatsapp.net' }
}
const ack = buildAckStanza(node, 0)
expect(ack.attrs.error).toBeUndefined()
})
it('should NOT include error when errorCode is undefined', () => {
const node: BinaryNode = {
tag: 'message',
attrs: { id: 'msg-001', from: 'user@s.whatsapp.net' }
}
const ack = buildAckStanza(node)
expect(ack.attrs.error).toBeUndefined()
})
})
describe('participant and recipient forwarding', () => {
it('should include participant when present in source node', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'group@g.us',
participant: 'sender@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.participant).toBe('sender@s.whatsapp.net')
})
it('should NOT include participant when absent', () => {
const node: BinaryNode = {
tag: 'message',
attrs: { id: 'msg-001', from: 'user@s.whatsapp.net' }
}
const ack = buildAckStanza(node)
expect(ack.attrs.participant).toBeUndefined()
})
it('should include recipient when present', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: 'rcpt-001',
from: 'user@s.whatsapp.net',
recipient: 'me@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.recipient).toBe('me@s.whatsapp.net')
})
it('should NOT include recipient when absent', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: { id: 'rcpt-001', from: 'user@s.whatsapp.net' }
}
const ack = buildAckStanza(node)
expect(ack.attrs.recipient).toBeUndefined()
})
it('should forward both participant and recipient when both present', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: 'rcpt-001',
from: 'group@g.us',
participant: 'sender@s.whatsapp.net',
recipient: 'me@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.participant).toBe('sender@s.whatsapp.net')
expect(ack.attrs.recipient).toBe('me@s.whatsapp.net')
})
})
describe('type attribute handling (WA Web: n.type || DROP_ATTR)', () => {
it('should include type for non-message tags', () => {
const node: BinaryNode = {
tag: 'notification',
attrs: {
id: 'notif-001',
from: 'user@s.whatsapp.net',
type: 'encrypt'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.type).toBe('encrypt')
})
it('should always include type for message tag when present', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net',
type: 'text'
}
}
// type is always included when present, regardless of errorCode
const ack = buildAckStanza(node, 0)
expect(ack.attrs.type).toBe('text')
})
it('should include type for message NACK', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net',
type: 'text'
}
}
const ack = buildAckStanza(node, 500)
expect(ack.attrs.type).toBe('text')
})
it('should NOT include type when source node has no type', () => {
const node: BinaryNode = {
tag: 'notification',
attrs: { id: 'notif-001', from: 'user@s.whatsapp.net' }
}
const ack = buildAckStanza(node)
expect(ack.attrs.type).toBeUndefined()
})
it('should include type for receipt tag', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: 'rcpt-001',
from: 'user@s.whatsapp.net',
type: 'read'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.type).toBe('read')
})
})
describe('from field for message ACKs (WA Web: sendAck/sendNack always include from)', () => {
it('should set from=meId for message ACK when meId provided', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node, undefined, 'me@s.whatsapp.net')
expect(ack.attrs.from).toBe('me@s.whatsapp.net')
})
it('should set from=meId for message NACK when meId provided', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node, 500, 'me@s.whatsapp.net')
expect(ack.attrs.from).toBe('me@s.whatsapp.net')
})
it('should set from=meId for unavailable message with meId', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net',
type: 'text'
},
content: [{ tag: 'unavailable', attrs: {} }]
}
const ack = buildAckStanza(node, 0, 'me@s.whatsapp.net')
expect(ack.attrs.from).toBe('me@s.whatsapp.net')
})
it('should NOT set from for message ACK without meId', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node)
expect(ack.attrs.from).toBeUndefined()
})
it('should NOT set from for non-message tags even with meId', () => {
const node: BinaryNode = {
tag: 'notification',
attrs: {
id: 'notif-001',
from: 'user@s.whatsapp.net',
type: 'encrypt'
}
}
const ack = buildAckStanza(node, 0, 'me@s.whatsapp.net')
expect(ack.attrs.from).toBeUndefined()
})
it('should NOT set from for receipt tag even with meId', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: 'rcpt-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node, undefined, 'me@s.whatsapp.net')
expect(ack.attrs.from).toBeUndefined()
})
it('should NOT set from for call tag even with meId', () => {
const node: BinaryNode = {
tag: 'call',
attrs: {
id: 'call-001',
from: 'user@s.whatsapp.net'
}
}
const ack = buildAckStanza(node, undefined, 'me@s.whatsapp.net')
expect(ack.attrs.from).toBeUndefined()
})
})
describe('full stanza construction (combined attributes)', () => {
it('should handle group message NACK with all attributes', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'group@g.us',
participant: 'sender@s.whatsapp.net',
type: 'text'
}
}
const ack = buildAckStanza(node, 500, 'me@s.whatsapp.net')
expect(ack).toEqual({
tag: 'ack',
attrs: {
id: 'msg-001',
to: 'group@g.us',
class: 'message',
error: '500',
participant: 'sender@s.whatsapp.net',
type: 'text',
from: 'me@s.whatsapp.net'
}
})
})
it('should handle group message ACK (no error) with from', () => {
const node: BinaryNode = {
tag: 'message',
attrs: {
id: 'msg-001',
from: 'group@g.us',
participant: 'sender@s.whatsapp.net',
type: 'text'
}
}
const ack = buildAckStanza(node, undefined, 'me@s.whatsapp.net')
expect(ack).toEqual({
tag: 'ack',
attrs: {
id: 'msg-001',
to: 'group@g.us',
class: 'message',
participant: 'sender@s.whatsapp.net',
type: 'text',
from: 'me@s.whatsapp.net'
}
})
})
it('should handle receipt with participant and recipient (no from)', () => {
const node: BinaryNode = {
tag: 'receipt',
attrs: {
id: 'rcpt-001',
from: 'group@g.us',
participant: 'sender@s.whatsapp.net',
recipient: 'me@s.whatsapp.net',
type: 'read'
}
}
const ack = buildAckStanza(node)
expect(ack).toEqual({
tag: 'ack',
attrs: {
id: 'rcpt-001',
to: 'group@g.us',
class: 'receipt',
participant: 'sender@s.whatsapp.net',
recipient: 'me@s.whatsapp.net',
type: 'read'
}
})
})
})
})