From 48627da0f99d2ef166a89046a8c853605363496b Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 24 Nov 2025 17:47:00 -0800 Subject: [PATCH] feat: outbound voice call essentials (#12782) - Enables outbound voice calls in voice channel . We are only caring about wiring the logic to trigger outgoing calls to the call button introduced in previous PRs. We will connect it to call component in subsequent PRs ref: #11602 ## Screens image image --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin Co-authored-by: Vishnu Narayanan --- AGENTS.md | 5 + app/builders/contact_inbox_builder.rb | 2 + app/builders/messages/message_builder.rb | 3 + app/javascript/dashboard/api/contacts.js | 6 + .../Contacts/ContactsDetailsLayout.vue | 1 + .../Contacts/VoiceCallButton.vue | 57 +++++- .../message/bubbles/VoiceCall.vue | 97 ++++++++-- .../components-next/message/constants.js | 13 ++ .../widgets/conversation/ConversationCard.vue | 34 ++-- .../widgets/conversation/VoiceCallStatus.vue | 77 ++++++++ .../composables/useVoiceCallStatus.js | 161 --------------- .../dashboard/i18n/locale/en/contact.json | 3 +- .../conversation/contact/ContactInfo.vue | 1 + .../store/modules/contacts/actions.js | 18 ++ .../dashboard/store/modules/contacts/index.js | 1 + .../modules/specs/contacts/actions.spec.js | 79 ++++++++ .../contacts/contactable_inboxes_service.rb | 2 + config/routes.rb | 2 + .../enterprise/contact_inbox_builder.rb | 21 ++ .../enterprise/messages/message_builder.rb | 9 + .../v1/accounts/contacts/calls_controller.rb | 38 ++++ .../controllers/twilio/voice_controller.rb | 183 ++++++++++++++++-- enterprise/app/models/channel/voice.rb | 22 +++ .../contacts/contactable_inboxes_service.rb | 16 ++ .../services/voice/call_message_builder.rb | 90 +++++++++ .../voice/call_session_sync_service.rb | 94 +++++++++ .../app/services/voice/call_status/manager.rb | 66 +++++++ .../app/services/voice/conference/manager.rb | 71 +++++++ .../app/services/voice/conference/name.rb | 5 + .../services/voice/inbound_call_builder.rb | 125 ++++++------ .../services/voice/outbound_call_builder.rb | 98 ++++++++++ .../services/voice/provider/twilio_adapter.rb | 32 +++ .../services/voice/status_update_service.rb | 59 ++++-- .../twilio/voice_controller_spec.rb | 95 +++++++-- .../voice/inbound_call_builder_spec.rb | 126 +++++++++--- .../voice/outbound_call_builder_spec.rb | 97 ++++++++++ .../voice/status_update_service_spec.rb | 17 ++ spec/jobs/mutex_application_job_spec.rb | 2 +- tailwind.config.js | 1 + 39 files changed, 1485 insertions(+), 344 deletions(-) create mode 100644 app/javascript/dashboard/components/widgets/conversation/VoiceCallStatus.vue delete mode 100644 app/javascript/dashboard/composables/useVoiceCallStatus.js create mode 100644 enterprise/app/builders/enterprise/contact_inbox_builder.rb create mode 100644 enterprise/app/builders/enterprise/messages/message_builder.rb create mode 100644 enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb create mode 100644 enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb create mode 100644 enterprise/app/services/voice/call_message_builder.rb create mode 100644 enterprise/app/services/voice/call_session_sync_service.rb create mode 100644 enterprise/app/services/voice/call_status/manager.rb create mode 100644 enterprise/app/services/voice/conference/manager.rb create mode 100644 enterprise/app/services/voice/conference/name.rb create mode 100644 enterprise/app/services/voice/outbound_call_builder.rb create mode 100644 enterprise/app/services/voice/provider/twilio_adapter.rb create mode 100644 spec/enterprise/services/voice/outbound_call_builder_spec.rb diff --git a/AGENTS.md b/AGENTS.md index e3b022a2e..ef1d3b26d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Run Project**: `overmind start -f Procfile.dev` +- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) +- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) ## Code Style @@ -37,6 +40,8 @@ - MVP focus: Least code change, happy-path only - No unnecessary defensive programming +- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 788ae39d1..40e571f43 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -103,3 +103,5 @@ class ContactInboxBuilder @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? end end + +ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder') diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index af31a0728..7df72e14a 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -138,6 +138,7 @@ class Messages::MessageBuilder private: @private, sender: sender, content_type: @params[:content_type], + content_attributes: content_attributes.presence, items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id], @@ -222,3 +223,5 @@ class Messages::MessageBuilder }) end end + +Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder') diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 025df2122..1e76ac987 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -47,6 +47,12 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/labels`); } + initiateCall(contactId, inboxId) { + return axios.post(`${this.url}/${contactId}/call`, { + inbox_id: inboxId, + }); + } + updateContactLabels(contactId, labels) { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue index 4c7b9249e..fd755022d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue @@ -102,6 +102,7 @@ const closeMobileSidebar = () => { /> diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 98e56ff8f..85738d9de 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -1,15 +1,18 @@ @@ -55,6 +96,8 @@ const onPickInbox = () => { v-if="shouldRender" v-tooltip.top-end="tooltipLabel || null" v-bind="attrs" + :disabled="isInitiatingCall" + :is-loading="isInitiatingCall" :label="label" :icon="icon" :size="size" diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index b7ede029f..5a7d39a4e 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -1,41 +1,102 @@