From 63ec90e5c8c733c746987063d7abdce05cfd3b35 Mon Sep 17 00:00:00 2001 From: Alper Samur <85391235+alpersamur3@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:06:08 +0300 Subject: [PATCH] feat: add call answering, outgoing calls and audio injection adds the ability to answer incoming calls, place outgoing calls and inject audio into an active call, plus fixes that make call handling work on current WhatsApp Web. call: - accept() answers a call (audio only by default, even for video calls) - end() hangs up an ongoing call - playAudio(media) streams a MessageMedia/base64 audio clip to the other party - isConnected() reports whether the other party has answered client: - call(chatId, { video, waitForAnswer, answerTimeout }) places an outgoing call - getActiveCall() returns the ongoing call, if any fixes: - Incoming "call" event: current WhatsApp Web reports calls through WAWebCallCollection's activeCall attribute; the previous internal-map hook no longer fires. The map hook is kept as a fallback. - Call.reject() now goes through the voip stack, which the previous signaling stanza no longer triggers on current WhatsApp Web. audio is captured through a synthetic Web Audio stream, so no microphone is required and it works headless. Adds the browser flags needed for this. --- example.js | 17 ++++ index.d.ts | 30 +++++- src/Client.js | 113 +++++++++++++++++++---- src/structures/Call.js | 48 ++++++++++ src/util/Injected/Utils.js | 182 +++++++++++++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 21 deletions(-) diff --git a/example.js b/example.js index 7b2af48bff9..cf970b0fea6 100644 --- a/example.js +++ b/example.js @@ -697,8 +697,25 @@ client.on('call', async (call) => { call.from, `[${call.fromMe ? 'Outgoing' : 'Incoming'}] Phone call from ${call.from}, type ${call.isGroup ? 'group' : ''} ${call.isVideo ? 'video' : 'audio'} call. ${rejectCalls ? 'This call was automatically rejected by the script.' : ''}`, ); + + // Instead of rejecting, a call can also be answered and an audio clip + // (e.g. a text-to-speech file you generated) can be played into it: + // await call.accept(); // answers with audio only, even for video calls + // await call.playAudio(MessageMedia.fromFilePath('./welcome.mp3')); + // await call.end(); }); +// Placing an outgoing call and speaking once the other party answers: +// const call = await client.call('1234567890', { waitForAnswer: true }); +// if (await call.isConnected()) { +// await call.playAudio(MessageMedia.fromFilePath('./message.mp3')); +// await call.end(); +// } + +// Checking for an ongoing call and hanging it up: +// const activeCall = await client.getActiveCall(); +// if (activeCall) await activeCall.end(); + client.on('disconnected', (reason) => { console.log('Client was logged out', reason); }); diff --git a/index.d.ts b/index.d.ts index 8127770492b..6907972dbb6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -212,10 +212,7 @@ declare namespace WAWebJS { ): Promise; /** Send a reaction to a specific messageId */ - sendReaction( - messageId: string, - reaction: string, - ): Promise; + sendReaction(messageId: string, reaction: string): Promise; /** Sends a channel admin invitation to a user, allowing them to become an admin of the channel */ sendChannelAdminInvite( @@ -345,6 +342,19 @@ declare namespace WAWebJS { /** Generates a WhatsApp call link (video call or voice call) */ createCallLink(startTime: Date, callType: string): Promise; + /** Places an outgoing call to the provided chat or phone number */ + call( + chatId: string, + options?: { + video?: boolean; + waitForAnswer?: boolean; + answerTimeout?: number; + }, + ): Promise; + + /** Gets the call that is currently ongoing (ringing, being placed or connected), if any */ + getActiveCall(): Promise; + /** * Sends a response to the scheduled event message, indicating whether a user is going to attend the event or not * @param response The response code to the event message. Valid values are: `0` for NONE response (removes a previous response) | `1` for GOING | `2` for NOT GOING | `3` for MAYBE going @@ -2444,6 +2454,18 @@ declare namespace WAWebJS { /** Reject the call */ reject: () => Promise; + + /** Accept the call (audio only by default, even for incoming video calls) */ + accept: (options?: { video?: boolean }) => Promise; + + /** End an ongoing call */ + end: () => Promise; + + /** Play an audio clip into the ongoing call so the other party can hear it */ + playAudio: (media: MessageMedia | string) => Promise; + + /** Indicates whether the call is currently connected (the other party has answered) */ + isConnected: () => Promise; } /** Message type List */ diff --git a/src/Client.js b/src/Client.js index a122a8f336b..46931c9f63e 100644 --- a/src/Client.js +++ b/src/Client.js @@ -499,6 +499,19 @@ class Client extends EventEmitter { // navigator.webdriver fix browserArgs.push('--disable-blink-features=AutomationControlled'); + // Required for the calling feature: allow the audio graph to run + // without a user gesture and auto-grant the microphone permission. + const callArgs = [ + '--autoplay-policy=no-user-gesture-required', + '--use-fake-ui-for-media-stream', + ]; + for (const arg of callArgs) { + const flag = arg.split('=')[0]; + if (!browserArgs.find((a) => a.startsWith(flag))) { + browserArgs.push(arg); + } + } + browser = await puppeteer.launch({ ...puppeteerOpts, args: browserArgs, @@ -1131,26 +1144,52 @@ class Client extends EventEmitter { WAWebCallCollection && typeof WAWebCallCollection.on === 'function' ) { - const mapKey = Object.keys(WAWebCallCollection).find( - (k) => WAWebCallCollection[k] instanceof Map, - ); - const internalCallMap = WAWebCallCollection[mapKey]; - const originalMapSet = - internalCallMap.set.bind(internalCallMap); - - internalCallMap.set = function (key, value) { + const emitCall = (call) => { + if ( + !call || + !call.id || + window._wwjsLastCallId === call.id + ) { + return; + } + window._wwjsLastCallId = call.id; window.onIncomingCall({ - id: value.id, - peerJid: value.peerJid, - isVideo: value.isVideo, - isGroup: value.isGroup, - canHandleLocally: value.canHandleLocally, - outgoing: value.outgoing, - webClientShouldHandle: value.webClientShouldHandle, - participants: value.participants, + id: call.id, + peerJid: call.peerJid, + isVideo: call.isVideo, + isGroup: call.isGroup, + canHandleLocally: call.canHandleLocally, + outgoing: call.outgoing, + webClientShouldHandle: call.webClientShouldHandle, + participants: call.participants, }); - return originalMapSet(key, value); }; + + // Newer WhatsApp Web surfaces calls through the collection's + // activeCall attribute instead of inserting them into the map. + // The changed call is passed as the handler argument; the + // lastActiveCall getter is not yet updated at this point. + if (!window._wwjsCallListener) { + window._wwjsCallListener = true; + WAWebCallCollection.on('change:activeCall', (call) => { + emitCall(call); + }); + } + + // Fallback for versions that still populate the internal map. + const mapKey = Object.keys(WAWebCallCollection).find( + (k) => WAWebCallCollection[k] instanceof Map, + ); + if (mapKey) { + const internalCallMap = WAWebCallCollection[mapKey]; + const originalMapSet = + internalCallMap.set.bind(internalCallMap); + + internalCallMap.set = function (key, value) { + emitCall(value); + return originalMapSet(key, value); + }; + } } Chat.on('remove', async (chat) => { window.onRemoveChatEvent( @@ -3279,6 +3318,46 @@ class Client extends EventEmitter { ); } + /** + * Places an outgoing call to the provided chat or phone number + * @param {string} chatId The chat ID ("@c.us" is automatically appended) or phone number to call + * @param {object} [options] Call options + * @param {boolean} [options.video=false] Whether to place a video call instead of a voice call + * @param {boolean} [options.waitForAnswer=false] If true, waits until the callee answers (or the timeout elapses) before resolving + * @param {number} [options.answerTimeout=60000] Maximum time to wait for an answer, in milliseconds, when waitForAnswer is true + * @returns {Promise} The placed call + */ + async call(chatId, options = {}) { + const callData = await this.pupPage.evaluate( + (id, isVideo, waitForAnswer, answerTimeout) => { + return window.WWebJS.startCall( + id, + isVideo, + waitForAnswer, + answerTimeout, + ); + }, + chatId, + options.video ?? false, + options.waitForAnswer ?? false, + options.answerTimeout ?? 60000, + ); + + return new Call(this, callData); + } + + /** + * Gets the call that is currently ongoing (ringing, being placed or connected), if any + * @returns {Promise} The current call, or null if there is no ongoing call + */ + async getActiveCall() { + const callData = await this.pupPage.evaluate(() => { + return window.WWebJS.getActiveCall(); + }); + + return callData ? new Call(this, callData) : null; + } + /** * Sends a response to the scheduled event message, indicating whether a user is going to attend the event or not * @param {number} response The response code to the scheduled event message. Valid values are: `0` for NONE response (removes a previous response) | `1` for GOING | `2` for NOT GOING | `3` for MAYBE going diff --git a/src/structures/Call.js b/src/structures/Call.js index c76558ac4d9..d30d6a5d2cb 100644 --- a/src/structures/Call.js +++ b/src/structures/Call.js @@ -75,6 +75,54 @@ class Call extends Base { this.id, ); } + + /** + * Accept the call + * @param {object} [options] Accept options + * @param {boolean} [options.video=false] Whether to answer with video (requires a camera). Defaults to audio only, even for incoming video calls + * @returns {Promise} + */ + async accept(options = {}) { + return this.client.pupPage.evaluate( + (id, isVideo) => { + return window.WWebJS.acceptCall(id, isVideo); + }, + this.id, + options.video ?? false, + ); + } + + /** + * End an ongoing call + * @returns {Promise} + */ + async end() { + return this.client.pupPage.evaluate((id) => { + return window.WWebJS.endCall(id); + }, this.id); + } + + /** + * Play an audio clip into the ongoing call so the other party can hear it + * @param {MessageMedia|string} media A MessageMedia instance or a base64 encoded audio string + * @returns {Promise} The duration of the played audio in seconds + */ + async playAudio(media) { + const data = typeof media === 'string' ? media : media.data; + return this.client.pupPage.evaluate((base64) => { + return window.WWebJS.playCallAudio(base64); + }, data); + } + + /** + * Indicates whether the call is currently connected (the other party has answered) + * @returns {Promise} + */ + async isConnected() { + return this.client.pupPage.evaluate((id) => { + return window.WWebJS.isCallConnected(id); + }, this.id); + } } module.exports = Call; diff --git a/src/util/Injected/Utils.js b/src/util/Injected/Utils.js index dfa30a579de..44460e042df 100644 --- a/src/util/Injected/Utils.js +++ b/src/util/Injected/Utils.js @@ -1320,6 +1320,13 @@ exports.LoadUtils = () => { }; window.WWebJS.rejectCall = async (peerJid, id) => { + const stack = await window.WWebJS.getCallStackInterface(); + if (stack && typeof stack.rejectCall === 'function') { + await stack.rejectCall(); + return; + } + + // Fallback signaling stanza for older WhatsApp Web builds. let userId = window .require('WAWebUserPrefsMeUser') .getMaybeMePnUser()._serialized; @@ -1342,6 +1349,181 @@ exports.LoadUtils = () => { await window.require('WADeprecatedSendIq').deprecatedCastStanza(stanza); }; + window.WWebJS.getCallStackInterface = async () => { + return window + .require('WAWebVoipStackInterface') + .getVoipStackInterface(); + }; + + window.WWebJS.setupCallMediaStream = () => { + const store = window.WWebJS; + if (store._callMedia && store._callMedia.context.state !== 'closed') { + return store._callMedia; + } + + const AudioContextClass = + window.AudioContext || window.webkitAudioContext; + const context = new AudioContextClass(); + const master = context.createGain(); + master.gain.value = 1; + + store._callMedia = { context, master, destinations: [] }; + + // WhatsApp acquires the microphone through this single entry point. + // Patch it once so the outgoing audio track is fed from our own graph + // instead of a physical device. A fresh destination is handed out on + // every call because WhatsApp stops the track when the stream is + // disposed, which would permanently silence a shared destination. + const mediaModule = window.require('WAGetUserMedia'); + if (!mediaModule._wwebjsPatched) { + const original = mediaModule.getUserMedia; + mediaModule._wwebjsPatched = true; + mediaModule.getUserMedia = (constraints) => { + const media = window.WWebJS._callMedia; + if ((constraints && constraints.video) || !media) { + return original(constraints); + } + const destination = + media.context.createMediaStreamDestination(); + media.master.connect(destination); + media.destinations.push(destination); + return Promise.resolve(destination.stream); + }; + } + + return store._callMedia; + }; + + window.WWebJS.playCallAudio = async (base64) => { + const { context, master } = window.WWebJS.setupCallMediaStream(); + if (context.state === 'suspended') { + await context.resume(); + } + + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + const buffer = await context.decodeAudioData(bytes.buffer); + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(master); + + return new Promise((resolve) => { + source.onended = () => resolve(buffer.duration); + source.start(); + }); + }; + + window.WWebJS.acceptCall = async (callId, isVideo = false) => { + window.WWebJS.setupCallMediaStream(); + const stack = await window.WWebJS.getCallStackInterface(); + await stack.acceptCall(callId, isVideo); + return true; + }; + + window.WWebJS.endCall = async (callId) => { + const stack = await window.WWebJS.getCallStackInterface(); + const { CALL_TERM_REASON } = window.require( + 'WAWebWamEnumCallTermReason', + ); + await stack.endCall(callId, CALL_TERM_REASON.ENDED_BY_USER); + return true; + }; + + window.WWebJS.startCall = async ( + chatId, + isVideo = false, + waitForAnswer = false, + timeout = 60000, + ) => { + window.WWebJS.setupCallMediaStream(); + + let wid; + const target = String(chatId); + if (target.includes('@')) { + wid = window.require('WAWebWidFactory').createWid(target); + } else { + const result = await window + .require('WAWebQueryExistsJob') + .queryPhoneExists('+' + target.replace(/\D/g, '')); + if (!result || !result.wid) { + throw new Error( + 'The provided phone number is not registered on WhatsApp', + ); + } + wid = result.wid; + } + + const { CALL_FROM_UI } = window.require('WAWebWamEnumCallFromUi'); + await window + .require('WAWebVoipStartCall') + .startWAWebVoipCall(wid, isVideo, CALL_FROM_UI.CONVERSATION); + + // The call is registered in the collection shortly after the offer is + // sent, so wait for it to become available before returning its data. + const collection = window.require('WAWebCallCollection'); + let call = collection.lastActiveCall; + for (let i = 0; i < 30 && !call; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + call = collection.lastActiveCall; + } + + // Optionally block until the callee answers (or the timeout elapses). + if (call && waitForAnswer) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if ( + collection.isInConnectedCall && + collection.lastActiveCall && + collection.lastActiveCall.id === call.id + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + return call + ? { + id: call.id, + peerJid: call.peerJid?._serialized, + isVideo: call.isVideo, + isGroup: call.isGroup, + outgoing: call.outgoing, + } + : null; + }; + + window.WWebJS.isCallConnected = (callId) => { + const collection = window.require('WAWebCallCollection'); + return ( + collection.isInConnectedCall === true && + !!collection.lastActiveCall && + collection.lastActiveCall.id === callId + ); + }; + + window.WWebJS.getActiveCall = () => { + const call = window.require('WAWebCallCollection').lastActiveCall; + if ( + !call || + (typeof call.getState === 'function' && call.getState() === 0) + ) { + return null; + } + return { + id: call.id, + peerJid: call.peerJid?._serialized, + offerTime: call.offerTime, + isVideo: call.isVideo, + isGroup: call.isGroup, + outgoing: call.outgoing, + }; + }; + window.WWebJS.cropAndResizeImage = async (media, options = {}) => { if (!media.mimetype.includes('image')) throw new Error('Media is not an image');