From 22fd5a872b288f5b0559fab4ed09d0cd76203415 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 29 Apr 2026 15:07:21 +0700 Subject: [PATCH] feat(whatsapp-call): direct browser-Meta WebRTC, no media server --- app/javascript/dashboard/api/voiceCalls.js | 68 ++ .../Contacts/VoiceCallButton.vue | 26 +- .../message/bubbles/VoiceCall.vue | 195 +++++- .../components/widgets/WhatsappCallWidget.vue | 244 +++++++ .../conversation/ConversationHeader.vue | 119 +++- .../composables/useCallReconnection.js | 36 + .../composables/useVoiceCallSession.js | 654 ++++++++++++++++++ .../dashboard/helper/actionCable.js | 75 +- .../i18n/locale/en/conversation.json | 6 +- .../dashboard/i18n/locale/en/index.js | 2 + .../i18n/locale/en/whatsappCall.json | 20 + .../dashboard/routes/dashboard/Dashboard.vue | 22 + app/javascript/dashboard/stores/voiceCalls.js | 65 ++ app/jobs/webhooks/whatsapp_events_job.rb | 2 + app/services/base/send_on_channel_service.rb | 5 +- .../providers/whatsapp_cloud_service.rb | 2 + app/views/api/v1/models/_inbox.json.jbuilder | 3 + config/routes.rb | 13 + .../api/v1/accounts/voice_calls_controller.rb | 183 +++++ .../webhooks/whatsapp_events_job.rb | 41 ++ enterprise/app/models/call.rb | 35 +- .../messages/audio_transcription_service.rb | 20 +- .../services/voice/call_message_builder.rb | 113 ++- enterprise/app/services/voice/call_service.rb | 89 +++ .../services/voice/inbound_call_builder.rb | 27 +- .../whatsapp/call_permission_reply_service.rb | 61 ++ .../whatsapp/incoming_call_service.rb | 113 +++ .../providers/whatsapp_cloud_call_methods.rb | 85 +++ enterprise/lib/voice/call_errors.rb | 6 + 29 files changed, 2277 insertions(+), 53 deletions(-) create mode 100644 app/javascript/dashboard/api/voiceCalls.js create mode 100644 app/javascript/dashboard/components/widgets/WhatsappCallWidget.vue create mode 100644 app/javascript/dashboard/composables/useCallReconnection.js create mode 100644 app/javascript/dashboard/composables/useVoiceCallSession.js create mode 100644 app/javascript/dashboard/i18n/locale/en/whatsappCall.json create mode 100644 app/javascript/dashboard/stores/voiceCalls.js create mode 100644 enterprise/app/controllers/api/v1/accounts/voice_calls_controller.rb create mode 100644 enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb create mode 100644 enterprise/app/services/voice/call_service.rb create mode 100644 enterprise/app/services/whatsapp/call_permission_reply_service.rb create mode 100644 enterprise/app/services/whatsapp/incoming_call_service.rb create mode 100644 enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb create mode 100644 enterprise/lib/voice/call_errors.rb diff --git a/app/javascript/dashboard/api/voiceCalls.js b/app/javascript/dashboard/api/voiceCalls.js new file mode 100644 index 000000000..81653cf20 --- /dev/null +++ b/app/javascript/dashboard/api/voiceCalls.js @@ -0,0 +1,68 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class VoiceCallsAPI extends ApiClient { + constructor() { + super('voice_calls', { accountScoped: true }); + } + + show(callId) { + return axios.get(`${this.url}/${callId}`); + } + + // The browser does WebRTC locally and ships the SDP answer up. Rails forwards + // it to Meta via pre_accept_call+accept_call. + accept(callId, { sdpAnswer } = {}) { + return axios.post(`${this.url}/${callId}/accept`, { + sdp_answer: sdpAnswer, + }); + } + + reject(callId) { + return axios.post(`${this.url}/${callId}/reject`); + } + + terminate(callId) { + return axios.post(`${this.url}/${callId}/terminate`); + } + + // Outbound: browser builds the offer first; Rails ships it to Meta and + // creates the Call record. Meta delivers its SDP answer later via the + // connect webhook (broadcast over ActionCable as voice_call.outbound_connected). + initiate(conversationId, provider, { sdpOffer } = {}) { + return axios.post(`${this.url}/initiate`, { + conversation_id: conversationId, + provider, + sdp_offer: sdpOffer, + }); + } + + // Get the current agent's active call (if any). Used on page load to detect + // a stale active session so we can terminate it cleanly. + active() { + return axios.get(`${this.url}/active`); + } + + // Multipart upload of the in-browser MediaRecorder Blob to Rails. The + // controller attaches it to the call's voice_call message; the after-create + // hook on Attachment fires Messages::AudioTranscriptionJob automatically. + uploadRecording(callId, blob, filename) { + const fd = new FormData(); + fd.append('recording', blob, filename); + return axios.post(`${this.url}/${callId}/upload_recording`, fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + + // URL helpers for navigator.sendBeacon (which can't carry custom headers and + // needs the absolute account-scoped path). + uploadRecordingUrl(callId) { + return `${this.url}/${callId}/upload_recording`; + } + + terminateUrl(callId) { + return `${this.url}/${callId}/terminate`; + } +} + +export default new VoiceCallsAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index b258dc763..ba390f173 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,10 +3,10 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; -import { useCallsStore } from 'dashboard/stores/calls'; +import { useVoiceCallsStore } from 'dashboard/stores/voiceCalls'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -34,11 +34,12 @@ const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const voiceInboxes = computed(() => - (inboxesList.value || []).filter(isVoiceCallEnabled) + (inboxesList.value || []).filter( + inbox => inbox.channel_type === INBOX_TYPES.VOICE + ) ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); -// Unified behavior: hide when no phone const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone); const isInitiatingCall = computed(() => { @@ -66,15 +67,16 @@ const startCall = async inboxId => { contactId: props.contactId, inboxId, }); - const { call_sid: callSid, conversation_id: conversationId } = response; - // Add call to store immediately so widget shows - const callsStore = useCallsStore(); - callsStore.addCall({ - callSid, - conversationId, - inboxId, - callDirection: 'outbound', + const voiceCallsStore = useVoiceCallsStore(); + voiceCallsStore.setActiveCall({ + id: response.call_id, + callId: response.call_sid, + provider: response.provider || 'twilio', + direction: 'outbound', + status: 'ringing', + conversationId: response.conversation_id, + caller: null, }); useAlert(t('CONTACT_PANEL.CALL_INITIATED')); diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index a270882ac..871eddc95 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -1,7 +1,10 @@