From 6bdd4f0670c530166e55e62083227cf742241991 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 8 Sep 2025 22:35:23 +0530 Subject: [PATCH] feat(voice): Incoming voice calls [EE] (#12361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR delivers the first slice of the voice channel: inbound call handling. When a customer calls a configured voice number, Chatwoot now creates a new conversation and shows a dedicated call bubble in the UI. As the call progresses (ringing, answered, completed), its status updates in real time in both the conversation list and the call bubble, so agents can instantly see what’s happening. This focuses on the inbound flow and is part of breaking the larger voice feature into smaller, functional, and testable units; further enhancements will follow in subsequent PRs. references: #11602 , #11481 ## Testing - Configure a Voice inbox in Chatwoot with your Twilio number. - Place a call to that number. - Verify a new conversation appears in the Voice inbox for the call. - Open it and confirm a dedicated voice call message bubble is shown. - Watch status update live (ringing/answered); hang up and see it change to completed in both the bubble and conversation list. - to test missed call status, make sure to hangup the call before the please wait while we connect you to an agent message plays ## Screens Screenshot 2025-09-03 at 3 11 25 PM Screenshot 2025-09-03 at 3 11 33 PM Screenshot 2025-09-03 at 3 11 57 PM Screenshot 2025-09-04 at 11 55 01 PM --------- Co-authored-by: Muhsin --- .../components-next/message/Message.vue | 5 + .../components-next/message/bubbles/Base.vue | 17 +- .../message/bubbles/VoiceCall.vue | 43 +++++ .../components-next/message/constants.js | 1 + .../widgets/conversation/ConversationCard.vue | 29 +++- .../composables/useVoiceCallStatus.js | 161 ++++++++++++++++++ .../i18n/locale/en/conversation.json | 11 ++ app/models/message.rb | 7 +- config/routes.rb | 9 + .../controllers/twilio/voice_controller.rb | 38 +++++ enterprise/app/models/channel/voice.rb | 8 +- .../app/models/enterprise/conversation.rb | 11 ++ .../services/voice/inbound_call_builder.rb | 82 +++++++++ .../services/voice/status_update_service.rb | 29 ++++ .../twilio/voice_controller_spec.rb | 87 ++++++++++ .../voice/inbound_call_builder_spec.rb | 57 +++++++ .../voice/status_update_service_spec.rb | 63 +++++++ 17 files changed, 648 insertions(+), 10 deletions(-) create mode 100644 app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue create mode 100644 app/javascript/dashboard/composables/useVoiceCallStatus.js create mode 100644 enterprise/app/controllers/twilio/voice_controller.rb create mode 100644 enterprise/app/services/voice/inbound_call_builder.rb create mode 100644 enterprise/app/services/voice/status_update_service.rb create mode 100644 spec/enterprise/controllers/twilio/voice_controller_spec.rb create mode 100644 spec/enterprise/services/voice/inbound_call_builder_spec.rb create mode 100644 spec/enterprise/services/voice/status_update_service_spec.rb diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 28776a8d9..586a4b4cb 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue'; import LocationBubble from './bubbles/Location.vue'; import CSATBubble from './bubbles/CSAT.vue'; import FormBubble from './bubbles/Form.vue'; +import VoiceCallBubble from './bubbles/VoiceCall.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; @@ -280,6 +281,10 @@ const componentToRender = computed(() => { return FormBubble; } + if (props.contentType === CONTENT_TYPES.VOICE_CALL) { + return VoiceCallBubble; + } + if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) { return EmailBubble; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index aaf0cb1ba..f66f272de 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -10,6 +10,10 @@ import { useI18n } from 'vue-i18n'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +const props = defineProps({ + hideMeta: { type: Boolean, default: false }, +}); + const { variant, orientation, inReplyTo, shouldGroupWithNext } = useMessageContext(); const { t } = useI18n(); @@ -64,6 +68,13 @@ const scrollToMessage = () => { }); }; +const shouldShowMeta = computed( + () => + !props.hideMeta && + !shouldGroupWithNext.value && + variant.value !== MESSAGE_VARIANTS.ACTIVITY +); + const replyToPreview = computed(() => { if (!inReplyTo) return ''; @@ -93,16 +104,16 @@ const replyToPreview = computed(() => { >
- + {{ replyToPreview }}
+
+
+
+ +
+ +
+ {{ $t(labelKey) }} + {{ $t(subtextKey) }} +
+
+
+ + diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js index 71257f66a..77ea25533 100644 --- a/app/javascript/dashboard/components-next/message/constants.js +++ b/app/javascript/dashboard/components-next/message/constants.js @@ -64,6 +64,7 @@ export const CONTENT_TYPES = { INPUT_CSAT: 'input_csat', INTEGRATIONS: 'integrations', STICKER: 'sticker', + VOICE_CALL: 'voice_call', }; export const MEDIA_TYPES = [ diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 57958ec0f..5cc59f8c8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -3,6 +3,7 @@ import { computed, ref } from 'vue'; import { useRouter } from 'vue-router'; import { useStore, useMapGetter } from 'dashboard/composables/store'; import { getLastMessage } from 'dashboard/helper/conversationHelper'; +import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import Avatar from 'next/avatar/Avatar.vue'; import MessagePreview from './MessagePreview.vue'; @@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value); const lastMessageInChat = computed(() => getLastMessage(props.chat)); +const callStatus = computed( + () => props.chat.additional_attributes?.call_status +); +const callDirection = computed( + () => props.chat.additional_attributes?.call_direction +); + +const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } = + useVoiceCallStatus(callStatus, callDirection); + const inboxId = computed(() => props.chat.inbox_id); const inbox = computed(() => { @@ -306,14 +317,30 @@ const deleteConversation = () => { > {{ currentContact.name }} +
+ + + {{ $t(voiceLabelKey) }} + +

diff --git a/app/javascript/dashboard/composables/useVoiceCallStatus.js b/app/javascript/dashboard/composables/useVoiceCallStatus.js new file mode 100644 index 000000000..111dab2ca --- /dev/null +++ b/app/javascript/dashboard/composables/useVoiceCallStatus.js @@ -0,0 +1,161 @@ +import { computed, unref } from 'vue'; + +const CALL_STATUSES = { + IN_PROGRESS: 'in-progress', + RINGING: 'ringing', + NO_ANSWER: 'no-answer', + BUSY: 'busy', + FAILED: 'failed', + COMPLETED: 'completed', + CANCELED: 'canceled', +}; + +const CALL_DIRECTIONS = { + INBOUND: 'inbound', + OUTBOUND: 'outbound', +}; + +/** + * Composable for handling voice call status display logic + * @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.) + * @param {Ref|string} directionRef - Call direction (inbound, outbound) + * @returns {Object} UI properties for displaying call status + */ +export function useVoiceCallStatus(statusRef, directionRef) { + const status = computed(() => unref(statusRef)?.toString()); + const direction = computed(() => unref(directionRef)?.toString()); + + // Status group helpers + const isFailedStatus = computed(() => + [ + CALL_STATUSES.NO_ANSWER, + CALL_STATUSES.BUSY, + CALL_STATUSES.FAILED, + ].includes(status.value) + ); + const isEndedStatus = computed(() => + [CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value) + ); + const isOutbound = computed( + () => direction.value === CALL_DIRECTIONS.OUTBOUND + ); + + const labelKey = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' + : 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS'; + } + + if (s === CALL_STATUSES.RINGING) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' + : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + } + + if (s === CALL_STATUSES.NO_ANSWER) { + return 'CONVERSATION.VOICE_CALL.MISSED_CALL'; + } + + if (isFailedStatus.value) { + return 'CONVERSATION.VOICE_CALL.NO_ANSWER'; + } + + if (isEndedStatus.value) { + return 'CONVERSATION.VOICE_CALL.CALL_ENDED'; + } + + return 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + }); + + const subtextKey = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.RINGING) { + return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'; + } + + if (s === CALL_STATUSES.IN_PROGRESS) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED' + : 'CONVERSATION.VOICE_CALL.YOU_ANSWERED'; + } + + if (isFailedStatus.value) { + return 'CONVERSATION.VOICE_CALL.NO_ANSWER'; + } + + if (isEndedStatus.value) { + return 'CONVERSATION.VOICE_CALL.CALL_ENDED'; + } + + return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'; + }); + + const bubbleIconName = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS) { + return isOutbound.value + ? 'i-ph-phone-outgoing-fill' + : 'i-ph-phone-incoming-fill'; + } + + if (isFailedStatus.value) { + return 'i-ph-phone-x-fill'; + } + + // For ringing/completed/canceled show direction when possible + return isOutbound.value + ? 'i-ph-phone-outgoing-fill' + : 'i-ph-phone-incoming-fill'; + }); + + const bubbleIconBg = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS) { + return 'bg-n-teal-9'; + } + + if (isFailedStatus.value) { + return 'bg-n-ruby-9'; + } + + if (isEndedStatus.value) { + return 'bg-n-slate-11'; + } + + // default (e.g., ringing) + return 'bg-n-teal-9 animate-pulse'; + }); + + const listIconColor = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) { + return 'text-n-teal-9'; + } + + if (isFailedStatus.value) { + return 'text-n-ruby-9'; + } + + if (isEndedStatus.value) { + return 'text-n-slate-11'; + } + + return 'text-n-teal-9'; + }); + + return { + status, + labelKey, + subtextKey, + bubbleIconName, + bubbleIconBg, + listIconColor, + }; +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 93f375e7f..9fd39b70f 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -71,6 +71,17 @@ "SHOW_LABELS": "Show labels", "HIDE_LABELS": "Hide labels" }, + "VOICE_CALL": { + "INCOMING_CALL": "Incoming call", + "OUTGOING_CALL": "Outgoing call", + "CALL_IN_PROGRESS": "Call in progress", + "NO_ANSWER": "No answer", + "MISSED_CALL": "Missed call", + "CALL_ENDED": "Call ended", + "NOT_ANSWERED_YET": "Not answered yet", + "THEY_ANSWERED": "They answered", + "YOU_ANSWERED": "You answered" + }, "HEADER": { "RESOLVE_ACTION": "Resolve", "REOPEN_ACTION": "Reopen", diff --git a/app/models/message.rb b/app/models/message.rb index 12b12b205..06be665d8 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -95,7 +95,8 @@ class Message < ApplicationRecord incoming_email: 8, input_csat: 9, integrations: 10, - sticker: 11 + sticker: 11, + voice_call: 12 } enum status: { sent: 0, delivered: 1, read: 2, failed: 3 } # [:submitted_email, :items, :submitted_values] : Used for bot message types @@ -104,9 +105,10 @@ class Message < ApplicationRecord # [:deleted] : Used to denote whether the message was deleted by the agent # [:external_created_at] : Can specify if the message was created at a different timestamp externally # [:external_error : Can specify if the message creation failed due to an error at external API + # [:data] : Used for structured content types such as voice_call store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted, :external_created_at, :story_sender, :story_id, :external_error, - :translations, :in_reply_to_external_id, :is_unsupported], coder: JSON + :translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id @@ -114,6 +116,7 @@ class Message < ApplicationRecord scope :chat, -> { where.not(message_type: :activity).where(private: false) } scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') } scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) } + scope :voice_calls, -> { where(content_type: :voice_call) } # TODO: Get rid of default scope # https://stackoverflow.com/a/1834250/939299 diff --git a/config/routes.rb b/config/routes.rb index 139476706..5fc602e0b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -524,6 +524,15 @@ Rails.application.routes.draw do namespace :twilio do resources :callback, only: [:create] resources :delivery_status, only: [:create] + + if ChatwootApp.enterprise? + resource :voice, only: [], controller: 'voice' do + collection do + post 'call/:phone', action: :call_twiml + post 'status/:phone', action: :status + end + end + end end get 'microsoft/callback', to: 'microsoft/callbacks#show' diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb new file mode 100644 index 000000000..436083f14 --- /dev/null +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -0,0 +1,38 @@ +class Twilio::VoiceController < ApplicationController + before_action :set_inbox! + + def status + Voice::StatusUpdateService.new( + account: @inbox.account, + call_sid: params[:CallSid], + call_status: params[:CallStatus] + ).perform + head :no_content + end + + def call_twiml + account = @inbox.account + call_sid = params[:CallSid] + from_number = params[:From].to_s + to_number = params[:To].to_s + + builder = Voice::InboundCallBuilder.new( + account: account, + inbox: @inbox, + from_number: from_number, + to_number: to_number, + call_sid: call_sid + ).perform + render xml: builder.twiml_response + end + + private + + def set_inbox! + # Resolve from the digits in the route param and look up exact E.164 match + digits = params[:phone].to_s.gsub(/\D/, '') + e164 = "+#{digits}" + channel = Channel::Voice.find_by!(phone_number: e164) + @inbox = channel.inbox + end +end diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb index 40f9070be..2662b7284 100644 --- a/enterprise/app/models/channel/voice.rb +++ b/enterprise/app/models/channel/voice.rb @@ -44,13 +44,13 @@ class Channel::Voice < ApplicationRecord # Public URLs used to configure Twilio webhooks def voice_call_webhook_url - base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '') - "#{base}/twilio/voice/call/#{phone_number}" + digits = phone_number.delete_prefix('+') + "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}" end def voice_status_webhook_url - base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '') - "#{base}/twilio/voice/status/#{phone_number}" + digits = phone_number.delete_prefix('+') + "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}" end private diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb index e137c0929..e653ad0ab 100644 --- a/enterprise/app/models/enterprise/conversation.rb +++ b/enterprise/app/models/enterprise/conversation.rb @@ -2,4 +2,15 @@ module Enterprise::Conversation def list_of_keys super + %w[sla_policy_id] end + + # Include select additional_attributes keys (call related) for update events + def allowed_keys? + return true if super + + attrs_change = previous_changes['additional_attributes'] + return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash) + + changed_attr_keys = attrs_change[1].keys + changed_attr_keys.intersect?(%w[call_status]) + end end diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb new file mode 100644 index 000000000..f5ae18801 --- /dev/null +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -0,0 +1,82 @@ +class Voice::InboundCallBuilder + pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!] + + attr_reader :conversation + + def perform + contact = find_or_create_contact! + contact_inbox = find_or_create_contact_inbox!(contact) + @conversation = find_or_create_conversation!(contact, contact_inbox) + create_call_message_if_needed! + self + end + + def twiml_response + response = Twilio::TwiML::VoiceResponse.new + response.say(message: 'Please wait while we connect you to an agent') + response.to_s + end + + private + + def find_or_create_conversation!(contact, contact_inbox) + account.conversations.find_or_create_by!( + account_id: account.id, + inbox_id: inbox.id, + identifier: call_sid + ) do |conv| + conv.contact_id = contact.id + conv.contact_inbox_id = contact_inbox.id + conv.additional_attributes = { + 'call_direction' => 'inbound', + 'call_status' => 'ringing' + } + end + end + + def create_call_message! + content_attrs = call_message_content_attributes + + @conversation.messages.create!( + account_id: account.id, + inbox_id: inbox.id, + message_type: :incoming, + sender: @conversation.contact, + content: 'Voice Call', + content_type: 'voice_call', + content_attributes: content_attrs + ) + end + + def create_call_message_if_needed! + return if @conversation.messages.voice_calls.exists? + + create_call_message! + end + + def call_message_content_attributes + { + data: { + call_sid: call_sid, + status: 'ringing', + conversation_id: @conversation.display_id, + call_direction: 'inbound', + from_number: from_number, + to_number: to_number, + meta: { + created_at: Time.current.to_i, + ringing_at: Time.current.to_i + } + } + } + end + + def find_or_create_contact! + account.contacts.find_by(phone_number: from_number) || + account.contacts.create!(phone_number: from_number, name: 'Unknown Caller') + end + + def find_or_create_contact_inbox!(contact) + ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create! + end +end diff --git a/enterprise/app/services/voice/status_update_service.rb b/enterprise/app/services/voice/status_update_service.rb new file mode 100644 index 000000000..18152c549 --- /dev/null +++ b/enterprise/app/services/voice/status_update_service.rb @@ -0,0 +1,29 @@ +class Voice::StatusUpdateService + pattr_initialize [:account!, :call_sid!, :call_status] + + def perform + conversation = account.conversations.find_by(identifier: call_sid) + return unless conversation + return if call_status.to_s.strip.empty? + + update_conversation!(conversation) + update_last_call_message!(conversation) + end + + private + + def update_conversation!(conversation) + attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status) + conversation.update!(additional_attributes: attrs) + end + + def update_last_call_message!(conversation) + msg = conversation.messages.voice_calls.order(created_at: :desc).first + return unless msg + + data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {} + data['data'] ||= {} + data['data']['status'] = call_status + msg.update!(content_attributes: data) + end +end diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb new file mode 100644 index 000000000..0f5e4d00a --- /dev/null +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Twilio::VoiceController', type: :request do + let(:account) { create(:account) } + let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') } + let(:inbox) { channel.inbox } + let(:digits) { channel.phone_number.delete_prefix('+') } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + end + + describe 'POST /twilio/voice/call/:phone' do + let(:call_sid) { 'CA_test_call_sid_123' } + let(:from_number) { '+15550003333' } + let(:to_number) { channel.phone_number } + + it 'invokes Voice::InboundCallBuilder with expected params and renders its TwiML' do + builder_double = instance_double(Voice::InboundCallBuilder) + expect(Voice::InboundCallBuilder).to receive(:new).with( + hash_including( + account: account, + inbox: inbox, + from_number: from_number, + to_number: to_number, + call_sid: call_sid + ) + ).and_return(builder_double) + expect(builder_double).to receive(:perform).and_return(builder_double) + expect(builder_double).to receive(:twiml_response).and_return('') + + post "/twilio/voice/call/#{digits}", params: { + 'CallSid' => call_sid, + 'From' => from_number, + 'To' => to_number + } + + expect(response).to have_http_status(:ok) + expect(response.body).to eq('') + end + + it 'raises not found when inbox is not present' do + expect(Voice::InboundCallBuilder).not_to receive(:new) + post '/twilio/voice/call/19998887777', params: { + 'CallSid' => call_sid, + 'From' => from_number, + 'To' => to_number + } + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST /twilio/voice/status/:phone' do + let(:call_sid) { 'CA_status_sid_456' } + + it 'invokes Voice::StatusUpdateService with expected params' do + service_double = instance_double(Voice::StatusUpdateService, perform: nil) + expect(Voice::StatusUpdateService).to receive(:new).with( + hash_including( + account: account, + call_sid: call_sid, + call_status: 'completed' + ) + ).and_return(service_double) + expect(service_double).to receive(:perform) + + post "/twilio/voice/status/#{digits}", params: { + 'CallSid' => call_sid, + 'CallStatus' => 'completed' + } + + expect(response).to have_http_status(:no_content) + end + + it 'raises not found when inbox is not present' do + expect(Voice::StatusUpdateService).not_to receive(:new) + post '/twilio/voice/status/18005550101', params: { + 'CallSid' => call_sid, + 'CallStatus' => 'busy' + } + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb new file mode 100644 index 000000000..12e2d7235 --- /dev/null +++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::InboundCallBuilder do + let(:account) { create(:account) } + let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') } + let(:inbox) { channel.inbox } + + let(:from_number) { '+15550001111' } + let(:to_number) { channel.phone_number } + let(:call_sid) { 'CA1234567890abcdef' } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + end + + def build_and_perform + described_class.new( + account: account, + inbox: inbox, + from_number: from_number, + to_number: to_number, + call_sid: call_sid + ).perform + end + + it 'creates a new conversation with inbound ringing attributes' do + builder = build_and_perform + conversation = builder.conversation + expect(conversation).to be_present + expect(conversation.account_id).to eq(account.id) + expect(conversation.inbox_id).to eq(inbox.id) + expect(conversation.identifier).to eq(call_sid) + expect(conversation.additional_attributes['call_direction']).to eq('inbound') + expect(conversation.additional_attributes['call_status']).to eq('ringing') + end + + it 'creates a voice_call message with ringing status' do + builder = build_and_perform + conversation = builder.conversation + msg = conversation.messages.voice_calls.last + expect(msg).to be_present + expect(msg.message_type).to eq('incoming') + expect(msg.content_type).to eq('voice_call') + expect(msg.content_attributes.dig('data', 'call_sid')).to eq(call_sid) + expect(msg.content_attributes.dig('data', 'status')).to eq('ringing') + end + + it 'returns TwiML that informs the caller we are connecting' do + builder = build_and_perform + xml = builder.twiml_response + expect(xml).to include('Please wait while we connect you to an agent') + expect(xml).to include(' 'inbound', 'call_status' => 'ringing' } + ) + end + let(:message) do + conversation.messages.create!( + account_id: account.id, + inbox_id: inbox.id, + message_type: :incoming, + sender: contact, + content: 'Voice Call', + content_type: 'voice_call', + content_attributes: { data: { call_sid: call_sid, status: 'ringing' } } + ) + end + let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') } + let(:inbox) { channel.inbox } + let(:from_number) { '+15550002222' } + let(:call_sid) { 'CATESTSTATUS123' } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + end + + it 'updates conversation and last voice message with call status' do + # Ensure records are created after stub setup + conversation + message + + described_class.new( + account: account, + call_sid: call_sid, + call_status: 'completed' + ).perform + + conversation.reload + message.reload + + expect(conversation.additional_attributes['call_status']).to eq('completed') + expect(message.content_attributes.dig('data', 'status')).to eq('completed') + end + + it 'no-ops when conversation not found' do + expect do + described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform + end.not_to raise_error + end +end