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
17 changed files with 232 additions and 1006 deletions
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1035194821]} {"version":[2,3000,1033846690]}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { AuthenticationState, SocketConfig, WAVersion } from '../Types'
import { Browsers } from '../Utils/browser-utils' import { Browsers } from '../Utils/browser-utils'
import logger from '../Utils/logger' import logger from '../Utils/logger'
const version = [2, 3000, 1035194821] const version = [2, 3000, 1033846690]
export const UNAUTHORIZED_CODES = [401, 403, 419] export const UNAUTHORIZED_CODES = [401, 403, 419]
+114 -44
View File
@@ -50,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,
@@ -344,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)
} }
@@ -1109,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)
} }
} }
@@ -1146,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)
} }
} }
@@ -1165,9 +1194,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return return
} }
let acked = false
try {
const { const {
fullMessage: msg, fullMessage: msg,
category, category,
@@ -1191,17 +1217,23 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
await messageMutex.mutex(async () => { if (msg.key?.remoteJid && msg.key?.id && messageRetryManager) {
await decrypt() messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message!)
logger.debug(
if (msg.key?.remoteJid && msg.key?.id && msg.message && messageRetryManager) { {
messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message) jid: msg.key.remoteJid,
id: msg.key.id
},
'Added message to recent cache for retry receipts'
)
} }
try {
await messageMutex.mutex(async () => {
await decrypt()
// 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)
} }
@@ -1219,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)
} }
@@ -1264,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 {
@@ -1276,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)
} }
} }
@@ -1324,7 +1352,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
} }
acked = true
await sendMessageAck(node, NACK_REASONS.UnhandledError) await sendMessageAck(node, NACK_REASONS.UnhandledError)
}) })
} }
@@ -1352,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
@@ -1362,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')
} }
@@ -1374,25 +1399,18 @@ 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')
)
}
} }
} }
const handleCall = async (node: BinaryNode) => { const handleCall = async (node: BinaryNode) => {
try {
const { attrs } = node const { attrs } = node
const [infoChild] = getAllBinaryNodeChildren(node) const [infoChild] = getAllBinaryNodeChildren(node)
const status = getCallStatusFromNode(infoChild!)
if (!infoChild) { if (!infoChild) {
throw new Boom('Missing call info in call node') throw new Boom('Missing call info in call node')
} }
const status = getCallStatusFromNode(infoChild)
const callId = infoChild.attrs['call-id']! const callId = infoChild.attrs['call-id']!
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']! const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
@@ -1428,11 +1446,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
ev.emit('call', [call]) ev.emit('call', [call])
} catch (error) {
logger.error({ error, node: binaryNodeToString(node) }, 'error in handling call') await sendMessageAck(node)
} finally {
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack call'))
}
} }
const handleBadAck = async ({ attrs }: BinaryNode) => { const handleBadAck = async ({ attrs }: BinaryNode) => {
@@ -1498,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,
-9
View File
@@ -1072,15 +1072,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
const getMediaType = (message: proto.IMessage) => { const getMediaType = (message: proto.IMessage) => {
// For view-once media, unwrap the viewOnceMessage wrapper before checking media type
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) { if (message.imageMessage) {
return 'image' return 'image'
} else if (message.videoMessage) { } else if (message.videoMessage) {
+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
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
} }
-15
View File
@@ -319,21 +319,6 @@ export const decryptMessageNode = (
} else { } else {
fullMessage.message = msg fullMessage.message = msg
} }
// Detect view-once media on stanza 1 received by linked device.
// viewOnceMessage wrapper is also used for interactive messages
// (interactiveMessage, listMessage, nativeFlowMessage) -- those do NOT have
// imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce = true.
// Only real view-once media carries viewOnce: true on the inner media message.
const viewOnceInner =
msg.viewOnceMessage?.message || msg.viewOnceMessageV2?.message || msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
} catch (err: any) { } catch (err: any) {
const errorContext = { const errorContext = {
key: fullMessage.key, key: fullMessage.key,
+1 -1
View File
@@ -1,7 +1,7 @@
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { createHash, randomBytes } from 'crypto' import { createHash, randomBytes } from 'crypto'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
const baileysVersion = [2, 3000, 1035194821] const baileysVersion = [2, 3000, 1033846690]
import type { import type {
BaileysEventEmitter, BaileysEventEmitter,
BaileysEventMap, BaileysEventMap,
-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'
+5 -11
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 || {}
if (message.mentions?.length) {
key.contextInfo.mentionedJid = message.mentions key.contextInfo.mentionedJid = message.mentions
} } else if (key!) {
key.contextInfo = {
if (message.mentionAll) { mentionedJid: message.mentions
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'
}
})
})
})
})