From 919bc52c776163b3b980a9888b7af10fc5c55563 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 1 Mar 2026 11:20:32 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20call=20signaling=20=E2=80=94?= =?UTF-8?q?=20types,=20status=20handling,=20call=20link=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the call signaling implementation with proper type definitions, status handling, and call link URL construction: - WACallEvent: add linkToken, linkCreator, participants, media, duration, terminateReason, connectedLimit fields - WACallParticipant: new type for group/link call participants - WACallUpdateType: add preaccept, transport, relaylatency, group_update, reminder, heartbeat, mute_v2, enc_rekey status types - getCallStatusFromNode: handle all new call node tags - handleCall: extract group_info, link_info, call_summary, participants from incoming call link/group stanzas - createCallLink: parse server response to return token and full URL (https://call.whatsapp.com/video/) Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-recv.ts | 132 ++++++++++++++++++++++++++++++++++-- src/Types/Call.ts | 42 +++++++++++- src/Utils/generics.ts | 24 +++++++ 3 files changed, 192 insertions(+), 6 deletions(-) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index ff712a8a..f4f5e0b3 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -1063,7 +1063,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { * Structure verified via Frida capture on WhatsApp Android v2.26. * * @param media - 'video' or 'audio' - * @returns the server response (contains the link token) + * @returns object with token, full URL, and raw server response */ const createCallLink = async (media: 'video' | 'audio' = 'video') => { const stanza: BinaryNode = { @@ -1079,7 +1079,36 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { }] } - return await query(stanza) + const response = await query(stanza) + + // Extract token from server response + // Response contains link_create child with token attribute + let token: string | undefined + const linkCreateResp = getBinaryNodeChild(response, 'link_create') + if (linkCreateResp) { + token = linkCreateResp.attrs.token || linkCreateResp.attrs['link-token'] + } + + // Fallback: check response attrs directly + if (!token) { + token = response.attrs?.token || response.attrs?.['link-token'] + } + + // Fallback: search any child with token/link-token + if (!token && Array.isArray(response.content)) { + for (const child of response.content as BinaryNode[]) { + if (child.attrs?.token || child.attrs?.['link-token']) { + token = child.attrs.token || child.attrs['link-token'] + break + } + } + } + + const url = token + ? `https://call.whatsapp.com/${media}/${token}` + : undefined + + return { token, url, response } } /** @@ -2460,18 +2489,111 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { call.groupJid = infoChild.attrs['group-jid'] // Extract and sanitize caller phone number call.callerPn = sanitizeCallerPn(infoChild.attrs['caller_pn']) + + // Extract call link info from group_info child + const groupInfo = getBinaryNodeChild(infoChild, 'group_info') + if (groupInfo) { + call.isGroup = true + call.linkToken = groupInfo.attrs['link-token'] + call.media = groupInfo.attrs.media + call.connectedLimit = groupInfo.attrs['connected-limit'] + ? Number(groupInfo.attrs['connected-limit']) + : undefined + // Extract participants from group_info + const userNodes = getBinaryNodeChildren(groupInfo, 'user') + if (userNodes.length) { + call.participants = userNodes.map(u => ({ + jid: u.attrs.jid, + state: u.attrs.state, + userPn: u.attrs.user_pn, + type: u.attrs.type, + })) + } + } + + // Extract link_info (who created the link) + const linkInfo = getBinaryNodeChild(infoChild, 'link_info') + if (linkInfo) { + call.linkCreator = linkInfo.attrs.link_creator + call.linkCreatorPn = linkInfo.attrs.link_creator_pn + } + await callOfferCache.set(call.id, call) } + // Extract call link data from group_update + if (status === 'group_update') { + const groupInfo = getBinaryNodeChild(infoChild, 'group_info') + if (groupInfo) { + call.isGroup = true + call.linkToken = groupInfo.attrs['link-token'] + call.media = groupInfo.attrs.media + call.connectedLimit = groupInfo.attrs['connected-limit'] + ? Number(groupInfo.attrs['connected-limit']) + : undefined + const userNodes = getBinaryNodeChildren(groupInfo, 'user') + if (userNodes.length) { + call.participants = userNodes.map(u => ({ + jid: u.attrs.jid, + state: u.attrs.state, + userPn: u.attrs.user_pn, + type: u.attrs.type, + })) + } + } + } + + // Extract reminder data (e.g. link_creator_call_started) + if (status === 'reminder') { + const groupInfo = getBinaryNodeChild(infoChild, 'group_info') + if (groupInfo) { + call.isGroup = true + call.linkToken = groupInfo.attrs['link-token'] + call.media = groupInfo.attrs.media + } + } + + // Extract terminate data (reason, duration, call_summary) + if (status === 'terminate') { + call.terminateReason = infoChild.attrs.reason + const callSummary = getBinaryNodeChild(infoChild, 'call_summary') + if (callSummary) { + call.media = callSummary.attrs.media + call.duration = callSummary.attrs.call_duration + ? Number(callSummary.attrs.call_duration) + : undefined + const userNodes = getBinaryNodeChildren(callSummary, 'user') + if (userNodes.length) { + call.participants = userNodes.map(u => ({ + jid: u.attrs.jid, + state: u.attrs.state, + userPn: u.attrs.user_pn, + type: u.attrs.type, + })) + } + } + } + + // Extract video info from accept/preaccept + if (status === 'accept' || status === 'preaccept') { + call.isVideo = !!getBinaryNodeChild(infoChild, 'video') + } + const existingCall = await callOfferCache.get(call.id) // use existing call info to populate this event if (existingCall) { - call.isVideo = existingCall.isVideo - call.isGroup = existingCall.isGroup - call.groupJid = existingCall.groupJid + call.isVideo = call.isVideo ?? existingCall.isVideo + call.isGroup = call.isGroup ?? existingCall.isGroup + call.groupJid = call.groupJid ?? existingCall.groupJid // Preserve callerPn across call state updates call.callerPn = call.callerPn || existingCall.callerPn + // Preserve call link data across updates + call.linkToken = call.linkToken || existingCall.linkToken + call.linkCreator = call.linkCreator || existingCall.linkCreator + call.linkCreatorPn = call.linkCreatorPn || existingCall.linkCreatorPn + call.media = call.media || existingCall.media + call.connectedLimit = call.connectedLimit ?? existingCall.connectedLimit } // delete data once call has ended diff --git a/src/Types/Call.ts b/src/Types/Call.ts index 83fe08b5..f78e7bf0 100644 --- a/src/Types/Call.ts +++ b/src/Types/Call.ts @@ -1,4 +1,28 @@ -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' + +export type WACallParticipant = { + jid?: string + /** 'connected' | 'invited' | 'left' etc. */ + state?: string + /** Phone number in s.whatsapp.net format */ + userPn?: string + /** 'admin' for group call link creator */ + type?: string +} export type WACallEvent = { chatId: string @@ -13,4 +37,20 @@ export type WACallEvent = { latencyMs?: number /** Phone number of the caller (sanitized to fix Brazilian landline bug) */ callerPn?: string + /** Call link token (forms URL: https://call.whatsapp.com/video/) */ + 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 } diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index c4603f47..32cd7dd9 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -429,6 +429,30 @@ export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => { case 'accept': status = 'accept' break + case 'preaccept': + status = 'preaccept' + break + case 'transport': + status = 'transport' + break + case 'relaylatency': + status = 'relaylatency' + break + case 'group_update': + status = 'group_update' + break + case 'reminder': + status = 'reminder' + break + case 'heartbeat': + status = 'heartbeat' + break + case 'mute_v2': + status = 'mute_v2' + break + case 'enc_rekey': + status = 'enc_rekey' + break default: status = 'ringing' break