Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bedb21fa1b | |||
| eb84424142 | |||
| 49241af14f | |||
| c4e5d1262a | |||
| 6afde71691 | |||
| 83a166f1cc | |||
| 67b578db33 | |||
| ad5ea817f7 | |||
| 3841abca35 |
@@ -1 +1 @@
|
|||||||
{"version":[2,3000,1033105955]}
|
{"version":[2,3000,1033846690]}
|
||||||
|
|||||||
@@ -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, 1033105955]
|
const version = [2, 3000, 1033846690]
|
||||||
|
|
||||||
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
export const UNAUTHORIZED_CODES = [401, 403, 419]
|
||||||
|
|
||||||
|
|||||||
+586
-114
@@ -17,6 +17,8 @@ import type {
|
|||||||
MessageUserReceipt,
|
MessageUserReceipt,
|
||||||
SocketConfig,
|
SocketConfig,
|
||||||
WACallEvent,
|
WACallEvent,
|
||||||
|
WACallParticipant,
|
||||||
|
WACallUpdateType,
|
||||||
WAMessage,
|
WAMessage,
|
||||||
WAMessageKey,
|
WAMessageKey,
|
||||||
WAPatchName
|
WAPatchName
|
||||||
@@ -50,6 +52,8 @@ 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,
|
||||||
@@ -342,40 +346,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessageAck = async ({ tag, attrs, content }: BinaryNode, errorCode?: number) => {
|
const sendMessageAck = async (node: BinaryNode, errorCode?: number) => {
|
||||||
const stanza: BinaryNode = {
|
const stanza = buildAckStanza(node, errorCode, authState.creds.me!.id)
|
||||||
tag: 'ack',
|
logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +374,375 @@ 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
|
||||||
@@ -1138,7 +1480,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
})
|
})
|
||||||
])
|
])
|
||||||
} finally {
|
} finally {
|
||||||
await sendMessageAck(node)
|
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack receipt'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,7 +1517,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
})
|
})
|
||||||
])
|
])
|
||||||
} finally {
|
} finally {
|
||||||
await sendMessageAck(node)
|
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack notification'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1194,6 +1536,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let acked = false
|
||||||
|
|
||||||
|
try {
|
||||||
const {
|
const {
|
||||||
fullMessage: msg,
|
fullMessage: msg,
|
||||||
category,
|
category,
|
||||||
@@ -1217,23 +1562,17 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,12 +1590,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1294,6 +1635,7 @@ 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 {
|
||||||
@@ -1305,6 +1647,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1352,6 +1695,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
acked = true
|
||||||
await sendMessageAck(node, NACK_REASONS.UnhandledError)
|
await sendMessageAck(node, NACK_REASONS.UnhandledError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1379,6 +1723,7 @@ 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
|
||||||
@@ -1388,6 +1733,7 @@ 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')
|
||||||
}
|
}
|
||||||
@@ -1399,18 +1745,161 @@ 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 children = getAllBinaryNodeChildren(node)
|
||||||
|
|
||||||
|
if (!children.length) {
|
||||||
|
throw new Boom('Missing call info in call node')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process ALL children — a <call> node can carry multiple
|
||||||
|
// sibling stanzas (e.g. <transport> + <mute_v2>)
|
||||||
|
for (const infoChild of children) {
|
||||||
|
const status = getCallStatusFromNode(infoChild)
|
||||||
|
const callId = infoChild.attrs['call-id']!
|
||||||
|
const from = infoChild.attrs.from! || infoChild.attrs['call-creator']!
|
||||||
|
|
||||||
|
const call: WACallEvent = {
|
||||||
|
chatId: attrs.from!,
|
||||||
|
from,
|
||||||
|
id: callId,
|
||||||
|
date: new Date(+attrs.t! * 1000),
|
||||||
|
offline: !!attrs.offline,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'offer') {
|
||||||
|
call.isVideo = !!getBinaryNodeChild(infoChild, 'video')
|
||||||
|
call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
|
||||||
|
call.groupJid = infoChild.attrs['group-jid']
|
||||||
|
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 { 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']!
|
||||||
|
|
||||||
@@ -1446,8 +1935,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ev.emit('call', [call])
|
ev.emit('call', [call])
|
||||||
|
} catch (error) {
|
||||||
await sendMessageAck(node)
|
logger.error({ error, node: binaryNodeToString(node) }, 'error in handling call')
|
||||||
|
} finally {
|
||||||
|
await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack call'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBadAck = async ({ attrs }: BinaryNode) => {
|
const handleBadAck = async ({ attrs }: BinaryNode) => {
|
||||||
@@ -1513,74 +2005,19 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageType = 'message' | 'call' | 'receipt' | 'notification'
|
const offlineNodeProcessor = makeOfflineNodeProcessor(
|
||||||
|
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[] = []
|
{
|
||||||
let isProcessing = false
|
isWsOpen: () => ws.isOpen,
|
||||||
|
onUnexpectedError,
|
||||||
// Number of nodes to process before yielding to event loop
|
yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve))
|
||||||
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,
|
||||||
@@ -1606,6 +2043,27 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -1661,6 +2119,20 @@ 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
|
||||||
|
|||||||
+45
-2
@@ -1,9 +1,34 @@
|
|||||||
export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' | 'terminate'
|
export type WACallUpdateType =
|
||||||
|
| 'offer'
|
||||||
|
| 'ringing'
|
||||||
|
| 'timeout'
|
||||||
|
| 'reject'
|
||||||
|
| 'accept'
|
||||||
|
| 'terminate'
|
||||||
|
| 'preaccept'
|
||||||
|
| 'transport'
|
||||||
|
| 'relaylatency'
|
||||||
|
| 'group_update'
|
||||||
|
| 'reminder'
|
||||||
|
| 'heartbeat'
|
||||||
|
| 'mute_v2'
|
||||||
|
| 'enc_rekey'
|
||||||
|
| 'video'
|
||||||
|
| 'relay'
|
||||||
|
|
||||||
|
export type WACallParticipant = {
|
||||||
|
jid?: string
|
||||||
|
/** 'connected' | 'invited' | 'left' etc. */
|
||||||
|
state?: string
|
||||||
|
/** Phone number in s.whatsapp.net format */
|
||||||
|
userPn?: string
|
||||||
|
/** 'admin' for group call link creator */
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WACallEvent = {
|
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
|
||||||
@@ -12,4 +37,22 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ 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 */
|
||||||
|
|||||||
+31
-1
@@ -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, 1033105955]
|
const baileysVersion = [2, 3000, 1033846690]
|
||||||
import type {
|
import type {
|
||||||
BaileysEventEmitter,
|
BaileysEventEmitter,
|
||||||
BaileysEventMap,
|
BaileysEventMap,
|
||||||
@@ -397,6 +397,36 @@ 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
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ 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'
|
||||||
|
|||||||
+11
-5
@@ -609,14 +609,20 @@ export const generateWAMessageContent = async (
|
|||||||
m = { viewOnceMessage: { message: m } }
|
m = { viewOnceMessage: { message: m } }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
|
if (
|
||||||
|
(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 ('contextInfo' in key! && !!key.contextInfo) {
|
if (key && 'contextInfo' in key) {
|
||||||
|
key.contextInfo = key.contextInfo || {}
|
||||||
|
if (message.mentions?.length) {
|
||||||
key.contextInfo.mentionedJid = message.mentions
|
key.contextInfo.mentionedJid = message.mentions
|
||||||
} else if (key!) {
|
}
|
||||||
key.contextInfo = {
|
|
||||||
mentionedJid: message.mentions
|
if (message.mentionAll) {
|
||||||
|
key.contextInfo.nonJidMentions = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user