From 1bbcebc59c27c91af405f6191c0cc653dfa188a2 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Sun, 1 Mar 2026 11:06:21 -0300 Subject: [PATCH] feat: add offerCall and terminateCall for call signaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add outgoing call support with offerCall() and terminateCall() functions. Stanza structures verified via Frida captures on WhatsApp Android v2.26, matching real voice and video call flows. - offerCall(jid, isVideo?) sends call offer with correct child nodes (privacy, audioƗ2, video if applicable, net, capability, enc, encopt) - terminateCall(callId, callTo, ...) sends call termination with optional reason and duration attributes - Both functions exported from the socket chain Co-Authored-By: Claude Opus 4.6 --- src/Socket/messages-recv.ts | 115 ++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/Socket/messages-recv.ts b/src/Socket/messages-recv.ts index 85f51099..a956c71c 100644 --- a/src/Socket/messages-recv.ts +++ b/src/Socket/messages-recv.ts @@ -536,6 +536,119 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { await query(stanza) } + /** + * Offer (initiate) a call to a JID. + * Structure verified via Frida capture on WhatsApp Android v2.26. + * + * @param jid - destination JID (e.g. "5511999999999@s.whatsapp.net" or LID) + * @param isVideo - true for video call, false/undefined for voice + * @returns callId (hex) and stanzaId used in the offer + */ + const offerCall = async (jid: string, isVideo?: boolean) => { + const meId = authState.creds.me?.id + if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) + + const callId = randomBytes(16).toString('hex').toUpperCase() + const stanzaId = randomBytes(16).toString('hex').toUpperCase() + + const offerContent: BinaryNode[] = [ + { tag: 'privacy', attrs: {}, content: undefined }, + { tag: 'audio', attrs: { rate: '8000', enc: 'opus' }, content: undefined }, + { tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined }, + ] + + if (isVideo) { + offerContent.push({ + tag: 'video', + attrs: { + screen_width: '1080', + screen_height: '2400', + dec: 'H264,H265,AV1', + device_orientation: '0', + enc: 'h.264' + }, + content: undefined + }) + } + + offerContent.push( + { tag: 'net', attrs: { medium: '3' }, content: undefined }, + { tag: 'capability', attrs: { ver: '1' }, content: undefined }, + { tag: 'enc', attrs: { v: '2', type: isVideo ? 'msg' : 'pkmsg' }, content: undefined }, + { tag: 'encopt', attrs: { keygen: '2' }, content: undefined }, + ) + + const stanza: BinaryNode = { + tag: 'call', + attrs: { + to: jid, + id: stanzaId, + }, + content: [{ + tag: 'offer', + attrs: { + 'call-creator': meId, + 'call-id': callId, + 'device_class': '2013', + }, + content: offerContent + }] + } + + await query(stanza) + return { callId, stanzaId } + } + + /** + * Terminate (hang up) an active or ringing call. + * Structure verified via Frida capture on WhatsApp Android v2.26. + * + * @param callId - the call-id from the offer + * @param callTo - JID of the other party + * @param callCreator - JID of who created the call (usually meId for outgoing) + * @param reason - terminate reason (omit for normal hangup, or 'timeout', 'busy', etc.) + * @param duration - call duration in ms (included when call was connected) + */ + const terminateCall = async ( + callId: string, + callTo: string, + callCreator?: string, + reason?: string, + duration?: number + ) => { + const meId = authState.creds.me?.id + if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) + + const terminateAttrs: Record = { + '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) + } + const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => { const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '') const { key: msgKey } = fullMessage @@ -2161,6 +2274,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => { sendMessageAck, sendRetryRequest, rejectCall, + offerCall, + terminateCall, fetchMessageHistory, requestPlaceholderResend, messageRetryManager