+
-
+
- {{ $t(labelKey) }}
- {{ $t(subtextKey) }}
+
+ {{ $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 77ea25533..0998174f5 100644
--- a/app/javascript/dashboard/components-next/message/constants.js
+++ b/app/javascript/dashboard/components-next/message/constants.js
@@ -73,3 +73,16 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.AUDIO,
ATTACHMENT_TYPES.IG_REEL,
];
+
+export const VOICE_CALL_STATUS = {
+ IN_PROGRESS: 'in-progress',
+ RINGING: 'ringing',
+ COMPLETED: 'completed',
+ NO_ANSWER: 'no-answer',
+ FAILED: 'failed',
+};
+
+export const VOICE_CALL_DIRECTION = {
+ INBOUND: 'inbound',
+ OUTBOUND: 'outbound',
+};
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
index 5cc59f8c8..1040ff1af 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue
@@ -3,7 +3,6 @@ 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';
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
+import VoiceCallStatus from './VoiceCallStatus.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -83,15 +83,10 @@ 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 voiceCallData = computed(() => ({
+ status: props.chat.additional_attributes?.call_status,
+ direction: props.chat.additional_attributes?.call_direction,
+}));
const inboxId = computed(() => props.chat.inbox_id);
@@ -317,20 +312,13 @@ const deleteConversation = () => {
>
{{ currentContact.name }}
-
-
-
- {{ $t(voiceLabelKey) }}
-
-
+ :status="voiceCallData.status"
+ :direction="voiceCallData.direction"
+ :message-preview-class="messagePreviewClass"
+ />
+import { computed } from 'vue';
+import {
+ VOICE_CALL_STATUS,
+ VOICE_CALL_DIRECTION,
+} from 'dashboard/components-next/message/constants';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
+
+const props = defineProps({
+ status: { type: String, default: '' },
+ direction: { type: String, default: '' },
+ messagePreviewClass: { type: [String, Array, Object], default: '' },
+});
+
+const LABEL_KEYS = {
+ [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
+ [VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
+};
+
+const ICON_MAP = {
+ [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
+ [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
+ [VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
+};
+
+const COLOR_MAP = {
+ [VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
+ [VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
+ [VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
+ [VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
+ [VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
+};
+
+const isOutbound = computed(
+ () => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
+);
+const isFailed = computed(() =>
+ [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
+);
+
+const labelKey = computed(() => {
+ if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
+ if (props.status === VOICE_CALL_STATUS.RINGING) {
+ return isOutbound.value
+ ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
+ : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
+ }
+ return isFailed.value
+ ? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
+ : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
+});
+
+const iconName = computed(() => {
+ if (ICON_MAP[props.status]) return ICON_MAP[props.status];
+ return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
+});
+
+const statusColor = computed(
+ () => COLOR_MAP[props.status] || 'text-n-slate-11'
+);
+
+
+
+
+
+
+ {{ $t(labelKey) }}
+
+
+
diff --git a/app/javascript/dashboard/composables/useVoiceCallStatus.js b/app/javascript/dashboard/composables/useVoiceCallStatus.js
deleted file mode 100644
index 111dab2ca..000000000
--- a/app/javascript/dashboard/composables/useVoiceCallStatus.js
+++ /dev/null
@@ -1,161 +0,0 @@
-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/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json
index a711a05af..4de5f51cd 100644
--- a/app/javascript/dashboard/i18n/locale/en/contact.json
+++ b/app/javascript/dashboard/i18n/locale/en/contact.json
@@ -18,7 +18,8 @@
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "New message",
"CALL": "Call",
- "CALL_UNDER_DEVELOPMENT": "Calling is under development",
+ "CALL_INITIATED": "Calling the contact…",
+ "CALL_FAILED": "Unable to start the call. Please try again.",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
index bfdb5d68a..d1c43b3f2 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
@@ -282,6 +282,7 @@ export default {
{
+ commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
+ try {
+ const response = await ContactAPI.initiateCall(contactId, inboxId);
+ commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
+ return response.data;
+ } catch (error) {
+ commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
+ if (error.response?.data?.message) {
+ throw new ExceptionWithMessage(error.response.data.message);
+ } else if (error.response?.data?.error) {
+ throw new ExceptionWithMessage(error.response.data.error);
+ } else {
+ throw new Error(error);
+ }
+ }
+ },
};
diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js
index eb1e92434..c8b476e24 100644
--- a/app/javascript/dashboard/store/modules/contacts/index.js
+++ b/app/javascript/dashboard/store/modules/contacts/index.js
@@ -17,6 +17,7 @@ const state = {
isDeleting: false,
isExporting: false,
isImporting: false,
+ isInitiatingCall: false,
},
sortOrder: [],
appliedFilters: [],
diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
index 79852a42e..00b7bb83b 100644
--- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js
@@ -359,4 +359,83 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
+
+ describe('#initiateCall', () => {
+ const contactId = 123;
+ const inboxId = 456;
+
+ it('sends correct mutations if API is success', async () => {
+ const mockResponse = {
+ data: {
+ conversation_id: 789,
+ status: 'initiated',
+ },
+ };
+ axios.post.mockResolvedValue(mockResponse);
+
+ const result = await actions.initiateCall(
+ { commit },
+ { contactId, inboxId }
+ );
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ expect(result).toEqual(mockResponse.data);
+ });
+
+ it('sends correct actions if API returns error with message', async () => {
+ const errorMessage = 'Failed to initiate call';
+ axios.post.mockRejectedValue({
+ response: {
+ data: {
+ message: errorMessage,
+ },
+ },
+ });
+
+ await expect(
+ actions.initiateCall({ commit }, { contactId, inboxId })
+ ).rejects.toThrow(ExceptionWithMessage);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ });
+
+ it('sends correct actions if API returns error with error field', async () => {
+ const errorMessage = 'Call initiation error';
+ axios.post.mockRejectedValue({
+ response: {
+ data: {
+ error: errorMessage,
+ },
+ },
+ });
+
+ await expect(
+ actions.initiateCall({ commit }, { contactId, inboxId })
+ ).rejects.toThrow(ExceptionWithMessage);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ });
+
+ it('sends correct actions if API returns generic error', async () => {
+ axios.post.mockRejectedValue({ message: 'Network error' });
+
+ await expect(
+ actions.initiateCall({ commit }, { contactId, inboxId })
+ ).rejects.toThrow(Error);
+
+ expect(commit.mock.calls).toEqual([
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
+ [types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
+ ]);
+ });
+ });
});
diff --git a/app/services/contacts/contactable_inboxes_service.rb b/app/services/contacts/contactable_inboxes_service.rb
index 92d4160fe..c86b9529a 100644
--- a/app/services/contacts/contactable_inboxes_service.rb
+++ b/app/services/contacts/contactable_inboxes_service.rb
@@ -71,3 +71,5 @@ class Contacts::ContactableInboxesService
end
end
end
+
+Contacts::ContactableInboxesService.prepend_mod_with('Contacts::ContactableInboxesService')
diff --git a/config/routes.rb b/config/routes.rb
index ea0228085..a8141531a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -177,6 +177,7 @@ Rails.application.routes.draw do
resources :contact_inboxes, only: [:create]
resources :labels, only: [:create, :index]
resources :notes
+ post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
end
end
resources :csat_survey_responses, only: [:index] do
@@ -550,6 +551,7 @@ Rails.application.routes.draw do
collection do
post 'call/:phone', action: :call_twiml
post 'status/:phone', action: :status
+ post 'conference_status/:phone', action: :conference_status
end
end
end
diff --git a/enterprise/app/builders/enterprise/contact_inbox_builder.rb b/enterprise/app/builders/enterprise/contact_inbox_builder.rb
new file mode 100644
index 000000000..043505f7f
--- /dev/null
+++ b/enterprise/app/builders/enterprise/contact_inbox_builder.rb
@@ -0,0 +1,21 @@
+module Enterprise::ContactInboxBuilder
+ private
+
+ def generate_source_id
+ return super unless @inbox.channel_type == 'Channel::Voice'
+
+ phone_source_id
+ end
+
+ def phone_source_id
+ return super unless @inbox.channel_type == 'Channel::Voice'
+
+ return SecureRandom.uuid if @contact.phone_number.blank?
+
+ @contact.phone_number
+ end
+
+ def allowed_channels?
+ super || @inbox.channel_type == 'Channel::Voice'
+ end
+end
diff --git a/enterprise/app/builders/enterprise/messages/message_builder.rb b/enterprise/app/builders/enterprise/messages/message_builder.rb
new file mode 100644
index 000000000..727248956
--- /dev/null
+++ b/enterprise/app/builders/enterprise/messages/message_builder.rb
@@ -0,0 +1,9 @@
+module Enterprise::Messages::MessageBuilder
+ private
+
+ def message_type
+ return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
+
+ super
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb
new file mode 100644
index 000000000..bcdd178fe
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb
@@ -0,0 +1,38 @@
+class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
+ before_action :contact
+ before_action :voice_inbox
+
+ def create
+ authorize contact, :show?
+ authorize voice_inbox, :show?
+
+ result = Voice::OutboundCallBuilder.perform!(
+ account: Current.account,
+ inbox: voice_inbox,
+ user: Current.user,
+ contact: contact
+ )
+
+ conversation = result[:conversation]
+
+ render json: {
+ conversation_id: conversation.display_id,
+ inbox_id: voice_inbox.id,
+ call_sid: result[:call_sid],
+ conference_sid: conversation.additional_attributes['conference_sid']
+ }
+ end
+
+ private
+
+ def contact
+ @contact ||= Current.account.contacts.find(params[:id])
+ end
+
+ def voice_inbox
+ @voice_inbox ||= Current.user.assigned_inboxes.where(
+ account_id: Current.account.id,
+ channel_type: 'Channel::Voice'
+ ).find(params.require(:inbox_id))
+ end
+end
diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb
index 436083f14..f25e819c2 100644
--- a/enterprise/app/controllers/twilio/voice_controller.rb
+++ b/enterprise/app/controllers/twilio/voice_controller.rb
@@ -1,38 +1,189 @@
class Twilio::VoiceController < ApplicationController
+ CONFERENCE_EVENT_PATTERNS = {
+ /conference-start/i => 'start',
+ /participant-join/i => 'join',
+ /participant-leave/i => 'leave',
+ /conference-end/i => 'end'
+ }.freeze
+
before_action :set_inbox!
def status
Voice::StatusUpdateService.new(
- account: @inbox.account,
- call_sid: params[:CallSid],
- call_status: params[:CallStatus]
+ account: current_account,
+ call_sid: twilio_call_sid,
+ call_status: params[:CallStatus],
+ payload: params.to_unsafe_h
).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
+ account = current_account
+ Rails.logger.info(
+ "TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
+ )
- 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
+ conversation = resolve_conversation
+ conference_sid = ensure_conference_sid!(conversation)
+
+ render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
+ end
+
+ def conference_status
+ event = mapped_conference_event
+ return head :no_content unless event
+
+ conversation = find_conversation_for_conference!(
+ friendly_name: params[:FriendlyName],
+ call_sid: twilio_call_sid
+ )
+
+ Voice::Conference::Manager.new(
+ conversation: conversation,
+ event: event,
+ call_sid: twilio_call_sid,
+ participant_label: participant_label
+ ).process
+
+ head :no_content
end
private
+ def twilio_call_sid
+ params[:CallSid]
+ end
+
+ def twilio_from
+ params[:From].to_s
+ end
+
+ def twilio_to
+ params[:To]
+ end
+
+ def twilio_direction
+ @twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
+ end
+
+ def mapped_conference_event
+ event = params[:StatusCallbackEvent].to_s
+ CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
+ return mapped if event.match?(pattern)
+ end
+ nil
+ end
+
+ def agent_leg?(from_number)
+ from_number.start_with?('client:')
+ end
+
+ def resolve_conversation
+ return find_conversation_for_agent if agent_leg?(twilio_from)
+
+ case twilio_direction
+ when 'inbound'
+ Voice::InboundCallBuilder.perform!(
+ account: current_account,
+ inbox: inbox,
+ from_number: twilio_from,
+ call_sid: twilio_call_sid
+ )
+ when 'outbound-api', 'outbound-dial'
+ sync_outbound_leg(
+ call_sid: twilio_call_sid,
+ from_number: twilio_from,
+ direction: twilio_direction
+ )
+ else
+ raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
+ end
+ end
+
+ def find_conversation_for_agent
+ if params[:conversation_id].present?
+ current_account.conversations.find_by!(display_id: params[:conversation_id])
+ else
+ current_account.conversations.find_by!(identifier: twilio_call_sid)
+ end
+ end
+
+ def sync_outbound_leg(call_sid:, from_number:, direction:)
+ parent_sid = params['ParentCallSid'].presence
+ lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
+ conversation = current_account.conversations.find_by!(identifier: lookup_sid)
+
+ Voice::CallSessionSyncService.new(
+ conversation: conversation,
+ call_sid: call_sid,
+ message_call_sid: conversation.identifier,
+ leg: {
+ from_number: from_number,
+ to_number: twilio_to,
+ direction: 'outbound'
+ }
+ ).perform
+ end
+
+ def ensure_conference_sid!(conversation)
+ attrs = conversation.additional_attributes || {}
+ attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
+ conversation.update!(additional_attributes: attrs)
+ attrs['conference_sid']
+ end
+
+ def conference_twiml(conference_sid, agent_leg)
+ Twilio::TwiML::VoiceResponse.new.tap do |response|
+ response.dial do |dial|
+ dial.conference(
+ conference_sid,
+ start_conference_on_enter: agent_leg,
+ end_conference_on_exit: false,
+ status_callback: conference_status_callback_url,
+ status_callback_event: 'start end join leave',
+ status_callback_method: 'POST',
+ participant_label: agent_leg ? 'agent' : 'contact'
+ )
+ end
+ end.to_s
+ end
+
+ def conference_status_callback_url
+ host = ENV.fetch('FRONTEND_URL', '')
+ phone_digits = inbox_channel.phone_number.delete_prefix('+')
+ "#{host}/twilio/voice/conference_status/#{phone_digits}"
+ end
+
+ def find_conversation_for_conference!(friendly_name:, call_sid:)
+ name = friendly_name.to_s
+ scope = current_account.conversations
+
+ if name.present?
+ conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
+ return conversation if conversation
+ end
+
+ scope.find_by!(identifier: call_sid)
+ end
+
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
+
+ def current_account
+ @current_account ||= inbox_account
+ end
+
+ def participant_label
+ params[:ParticipantLabel].to_s
+ end
+
+ attr_reader :inbox
+
+ delegate :account, :channel, to: :inbox, prefix: true
end
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
index 2662b7284..1f00e74d1 100644
--- a/enterprise/app/models/channel/voice.rb
+++ b/enterprise/app/models/channel/voice.rb
@@ -42,6 +42,17 @@ class Channel::Voice < ApplicationRecord
false
end
+ def initiate_call(to:)
+ case provider
+ when 'twilio'
+ Voice::Provider::TwilioAdapter.new(self).initiate_call(
+ to: to
+ )
+ else
+ raise "Unsupported voice provider: #{provider}"
+ end
+ end
+
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
digits = phone_number.delete_prefix('+')
@@ -76,6 +87,15 @@ class Channel::Voice < ApplicationRecord
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
+ # twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
+
+ def provider_config_hash
+ if provider_config.is_a?(Hash)
+ provider_config
+ else
+ JSON.parse(provider_config.to_s)
+ end
+ end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
@@ -96,4 +116,6 @@ class Channel::Voice < ApplicationRecord
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
+
+ public :provider_config_hash
end
diff --git a/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb b/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb
new file mode 100644
index 000000000..546d5f4bc
--- /dev/null
+++ b/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb
@@ -0,0 +1,16 @@
+module Enterprise::Contacts::ContactableInboxesService
+ private
+
+ # Extend base selection to include Voice inboxes
+ def get_contactable_inbox(inbox)
+ return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
+
+ super
+ end
+
+ def voice_contactable_inbox(inbox)
+ return if @contact.phone_number.blank?
+
+ { source_id: @contact.phone_number, inbox: inbox }
+ end
+end
diff --git a/enterprise/app/services/voice/call_message_builder.rb b/enterprise/app/services/voice/call_message_builder.rb
new file mode 100644
index 000000000..adf72f67c
--- /dev/null
+++ b/enterprise/app/services/voice/call_message_builder.rb
@@ -0,0 +1,90 @@
+class Voice::CallMessageBuilder
+ def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
+ new(
+ conversation: conversation,
+ direction: direction,
+ payload: payload,
+ user: user,
+ timestamps: timestamps
+ ).perform!
+ end
+
+ def initialize(conversation:, direction:, payload:, user:, timestamps:)
+ @conversation = conversation
+ @direction = direction
+ @payload = payload
+ @user = user
+ @timestamps = timestamps
+ end
+
+ def perform!
+ validate_sender!
+ message = latest_message
+ message ? update_message!(message) : create_message!
+ end
+
+ private
+
+ attr_reader :conversation, :direction, :payload, :user, :timestamps
+
+ def latest_message
+ conversation.messages.voice_calls.order(created_at: :desc).first
+ end
+
+ def update_message!(message)
+ message.update!(
+ message_type: message_type,
+ content_attributes: { 'data' => base_payload },
+ sender: sender
+ )
+ end
+
+ def create_message!
+ params = {
+ content: 'Voice Call',
+ message_type: message_type,
+ content_type: 'voice_call',
+ content_attributes: { 'data' => base_payload }
+ }
+ Messages::MessageBuilder.new(sender, conversation, params).perform
+ end
+
+ def base_payload
+ @base_payload ||= begin
+ data = payload.slice(
+ :call_sid,
+ :status,
+ :call_direction,
+ :conference_sid,
+ :from_number,
+ :to_number
+ ).stringify_keys
+ data['call_direction'] = direction
+ data['meta'] = {
+ 'created_at' => timestamps[:created_at] || current_timestamp,
+ 'ringing_at' => timestamps[:ringing_at] || current_timestamp
+ }.compact
+ data
+ end
+ end
+
+ def message_type
+ direction == 'outbound' ? 'outgoing' : 'incoming'
+ end
+
+ def sender
+ return user if direction == 'outbound'
+
+ conversation.contact
+ end
+
+ def validate_sender!
+ return unless direction == 'outbound'
+
+ raise ArgumentError, 'Agent sender required for outbound calls' unless user
+ end
+
+ def current_timestamp
+ @current_timestamp ||= Time.zone.now.to_i
+ end
+end
diff --git a/enterprise/app/services/voice/call_session_sync_service.rb b/enterprise/app/services/voice/call_session_sync_service.rb
new file mode 100644
index 000000000..8ce80ae0e
--- /dev/null
+++ b/enterprise/app/services/voice/call_session_sync_service.rb
@@ -0,0 +1,94 @@
+class Voice::CallSessionSyncService
+ attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
+
+ def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
+ @conversation = conversation
+ @call_sid = call_sid
+ @message_call_sid = message_call_sid || call_sid
+ @from_number = leg[:from_number]
+ @to_number = leg[:to_number]
+ @direction = leg[:direction]
+ end
+
+ def perform
+ ActiveRecord::Base.transaction do
+ attrs = refreshed_attributes
+ conversation.update!(
+ additional_attributes: attrs,
+ last_activity_at: current_time
+ )
+ sync_voice_call_message!(attrs)
+ end
+
+ conversation
+ end
+
+ private
+
+ def refreshed_attributes
+ attrs = (conversation.additional_attributes || {}).dup
+ attrs['call_direction'] = direction
+ attrs['call_status'] ||= 'ringing'
+ attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
+ attrs['meta'] ||= {}
+ attrs['meta']['initiated_at'] ||= current_timestamp
+ attrs
+ end
+
+ def sync_voice_call_message!(attrs)
+ Voice::CallMessageBuilder.perform!(
+ conversation: conversation,
+ direction: direction,
+ payload: {
+ call_sid: message_call_sid,
+ status: attrs['call_status'],
+ conference_sid: attrs['conference_sid'],
+ from_number: origin_number_for(direction),
+ to_number: target_number_for(direction)
+ },
+ user: agent_for(attrs),
+ timestamps: {
+ created_at: attrs.dig('meta', 'initiated_at'),
+ ringing_at: attrs.dig('meta', 'ringing_at')
+ }
+ )
+ end
+
+ def origin_number_for(current_direction)
+ return outbound_origin if current_direction == 'outbound'
+
+ from_number.presence || inbox_number
+ end
+
+ def target_number_for(current_direction)
+ return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
+
+ to_number || conversation.contact&.phone_number
+ end
+
+ def agent_for(attrs)
+ agent_id = attrs['agent_id']
+ return nil unless agent_id
+
+ agent = conversation.account.users.find_by(id: agent_id)
+ raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
+
+ agent
+ end
+
+ def current_timestamp
+ @current_timestamp ||= current_time.to_i
+ end
+
+ def current_time
+ @current_time ||= Time.zone.now
+ end
+
+ def outbound_origin
+ inbox_number || from_number
+ end
+
+ def inbox_number
+ conversation.inbox&.channel&.phone_number
+ end
+end
diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb
new file mode 100644
index 000000000..82d7efde7
--- /dev/null
+++ b/enterprise/app/services/voice/call_status/manager.rb
@@ -0,0 +1,66 @@
+class Voice::CallStatus::Manager
+ pattr_initialize [:conversation!, :call_sid]
+
+ ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
+ TERMINAL_STATUSES = %w[completed no-answer failed].freeze
+
+ def process_status_update(status, duration: nil, timestamp: nil)
+ return unless ALLOWED_STATUSES.include?(status)
+
+ current_status = conversation.additional_attributes&.dig('call_status')
+ return if current_status == status
+
+ apply_status(status, duration: duration, timestamp: timestamp)
+ update_message(status)
+ end
+
+ private
+
+ def apply_status(status, duration:, timestamp:)
+ attrs = (conversation.additional_attributes || {}).dup
+ attrs['call_status'] = status
+
+ if status == 'in-progress'
+ attrs['call_started_at'] ||= timestamp || now_seconds
+ elsif TERMINAL_STATUSES.include?(status)
+ attrs['call_ended_at'] = timestamp || now_seconds
+ attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
+ end
+
+ conversation.update!(
+ additional_attributes: attrs,
+ last_activity_at: current_time
+ )
+ end
+
+ def resolved_duration(attrs, provided_duration, timestamp)
+ return provided_duration if provided_duration
+
+ started_at = attrs['call_started_at']
+ return unless started_at && timestamp
+
+ [timestamp - started_at.to_i, 0].max
+ end
+
+ def update_message(status)
+ message = conversation.messages
+ .where(content_type: 'voice_call')
+ .order(created_at: :desc)
+ .first
+ return unless message
+
+ data = (message.content_attributes || {}).dup
+ data['data'] ||= {}
+ data['data']['status'] = status
+
+ message.update!(content_attributes: data)
+ end
+
+ def now_seconds
+ current_time.to_i
+ end
+
+ def current_time
+ @current_time ||= Time.zone.now
+ end
+end
diff --git a/enterprise/app/services/voice/conference/manager.rb b/enterprise/app/services/voice/conference/manager.rb
new file mode 100644
index 000000000..0b54f6ae8
--- /dev/null
+++ b/enterprise/app/services/voice/conference/manager.rb
@@ -0,0 +1,71 @@
+class Voice::Conference::Manager
+ pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
+
+ def process
+ case event
+ when 'start'
+ ensure_conference_sid!
+ mark_ringing!
+ when 'join'
+ mark_in_progress! if agent_participant?
+ when 'leave'
+ handle_leave!
+ when 'end'
+ finalize_conference!
+ end
+ end
+
+ private
+
+ def status_manager
+ @status_manager ||= Voice::CallStatus::Manager.new(
+ conversation: conversation,
+ call_sid: call_sid
+ )
+ end
+
+ def ensure_conference_sid!
+ attrs = conversation.additional_attributes || {}
+ return if attrs['conference_sid'].present?
+
+ attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
+ conversation.update!(additional_attributes: attrs)
+ end
+
+ def mark_ringing!
+ return if current_status
+
+ status_manager.process_status_update('ringing')
+ end
+
+ def mark_in_progress!
+ status_manager.process_status_update('in-progress', timestamp: current_timestamp)
+ end
+
+ def handle_leave!
+ case current_status
+ when 'ringing'
+ status_manager.process_status_update('no-answer', timestamp: current_timestamp)
+ when 'in-progress'
+ status_manager.process_status_update('completed', timestamp: current_timestamp)
+ end
+ end
+
+ def finalize_conference!
+ return if %w[completed no-answer failed].include?(current_status)
+
+ status_manager.process_status_update('completed', timestamp: current_timestamp)
+ end
+
+ def current_status
+ conversation.additional_attributes&.dig('call_status')
+ end
+
+ def agent_participant?
+ participant_label.to_s.start_with?('agent')
+ end
+
+ def current_timestamp
+ Time.zone.now.to_i
+ end
+end
diff --git a/enterprise/app/services/voice/conference/name.rb b/enterprise/app/services/voice/conference/name.rb
new file mode 100644
index 000000000..027937b4c
--- /dev/null
+++ b/enterprise/app/services/voice/conference/name.rb
@@ -0,0 +1,5 @@
+module Voice::Conference::Name
+ def self.for(conversation)
+ "conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
+ end
+end
diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb
index f5ae18801..03981ac58 100644
--- a/enterprise/app/services/voice/inbound_call_builder.rb
+++ b/enterprise/app/services/voice/inbound_call_builder.rb
@@ -1,82 +1,99 @@
class Voice::InboundCallBuilder
- pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!]
+ attr_reader :account, :inbox, :from_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
+ def self.perform!(account:, inbox:, from_number:, call_sid:)
+ new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
end
- def twiml_response
- response = Twilio::TwiML::VoiceResponse.new
- response.say(message: 'Please wait while we connect you to an agent')
- response.to_s
+ def initialize(account:, inbox:, from_number:, call_sid:)
+ @account = account
+ @inbox = inbox
+ @from_number = from_number
+ @call_sid = call_sid
+ end
+
+ def perform!
+ timestamp = current_timestamp
+
+ ActiveRecord::Base.transaction do
+ contact = ensure_contact!
+ contact_inbox = ensure_contact_inbox!(contact)
+ conversation = find_conversation || create_conversation!(contact, contact_inbox)
+ conversation.reload
+ update_conversation!(conversation, timestamp)
+ build_voice_message!(conversation, timestamp)
+ conversation
+ end
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'
- }
+ def ensure_contact!
+ account.contacts.find_or_create_by!(phone_number: from_number) do |record|
+ record.name = from_number if record.name.blank?
end
end
- def create_call_message!
- content_attrs = call_message_content_attributes
+ def ensure_contact_inbox!(contact)
+ ContactInbox.find_or_create_by!(
+ contact_id: contact.id,
+ inbox_id: inbox.id
+ ) do |record|
+ record.source_id = from_number
+ end
+ end
- @conversation.messages.create!(
- account_id: account.id,
+ def find_conversation
+ return if call_sid.blank?
+
+ account.conversations.includes(:contact).find_by(identifier: call_sid)
+ end
+
+ def create_conversation!(contact, contact_inbox)
+ account.conversations.create!(
+ contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
- message_type: :incoming,
- sender: @conversation.contact,
- content: 'Voice Call',
- content_type: 'voice_call',
- content_attributes: content_attrs
+ contact_id: contact.id,
+ status: :open,
+ identifier: call_sid
)
end
- def create_call_message_if_needed!
- return if @conversation.messages.voice_calls.exists?
+ def update_conversation!(conversation, timestamp)
+ attrs = {
+ 'call_direction' => 'inbound',
+ 'call_status' => 'ringing',
+ 'conference_sid' => Voice::Conference::Name.for(conversation),
+ 'meta' => { 'initiated_at' => timestamp }
+ }
- create_call_message!
+ conversation.update!(
+ identifier: call_sid,
+ additional_attributes: attrs,
+ last_activity_at: current_time
+ )
end
- def call_message_content_attributes
- {
- data: {
+ def build_voice_message!(conversation, timestamp)
+ Voice::CallMessageBuilder.perform!(
+ conversation: conversation,
+ direction: 'inbound',
+ payload: {
call_sid: call_sid,
status: 'ringing',
- conversation_id: @conversation.display_id,
- call_direction: 'inbound',
+ conference_sid: conversation.additional_attributes['conference_sid'],
from_number: from_number,
- to_number: to_number,
- meta: {
- created_at: Time.current.to_i,
- ringing_at: Time.current.to_i
- }
- }
- }
+ to_number: inbox.channel&.phone_number
+ },
+ timestamps: { created_at: timestamp, ringing_at: timestamp }
+ )
end
- def find_or_create_contact!
- account.contacts.find_by(phone_number: from_number) ||
- account.contacts.create!(phone_number: from_number, name: 'Unknown Caller')
+ def current_timestamp
+ @current_timestamp ||= current_time.to_i
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!
+ def current_time
+ @current_time ||= Time.zone.now
end
end
diff --git a/enterprise/app/services/voice/outbound_call_builder.rb b/enterprise/app/services/voice/outbound_call_builder.rb
new file mode 100644
index 000000000..1ebcade8a
--- /dev/null
+++ b/enterprise/app/services/voice/outbound_call_builder.rb
@@ -0,0 +1,98 @@
+class Voice::OutboundCallBuilder
+ attr_reader :account, :inbox, :user, :contact
+
+ def self.perform!(account:, inbox:, user:, contact:)
+ new(account: account, inbox: inbox, user: user, contact: contact).perform!
+ end
+
+ def initialize(account:, inbox:, user:, contact:)
+ @account = account
+ @inbox = inbox
+ @user = user
+ @contact = contact
+ end
+
+ def perform!
+ raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
+ raise ArgumentError, 'Agent required' if user.blank?
+
+ timestamp = current_timestamp
+
+ ActiveRecord::Base.transaction do
+ contact_inbox = ensure_contact_inbox!
+ conversation = create_conversation!(contact_inbox)
+ conversation.reload
+ conference_sid = Voice::Conference::Name.for(conversation)
+ call_sid = initiate_call!
+ update_conversation!(conversation, call_sid, conference_sid, timestamp)
+ build_voice_message!(conversation, call_sid, conference_sid, timestamp)
+ { conversation: conversation, call_sid: call_sid }
+ end
+ end
+
+ private
+
+ def ensure_contact_inbox!
+ ContactInbox.find_or_create_by!(
+ contact_id: contact.id,
+ inbox_id: inbox.id
+ ) do |record|
+ record.source_id = contact.phone_number
+ end
+ end
+
+ def create_conversation!(contact_inbox)
+ account.conversations.create!(
+ contact_inbox_id: contact_inbox.id,
+ inbox_id: inbox.id,
+ contact_id: contact.id,
+ status: :open
+ )
+ end
+
+ def initiate_call!
+ inbox.channel.initiate_call(
+ to: contact.phone_number
+ )[:call_sid]
+ end
+
+ def update_conversation!(conversation, call_sid, conference_sid, timestamp)
+ attrs = {
+ 'call_direction' => 'outbound',
+ 'call_status' => 'ringing',
+ 'agent_id' => user.id,
+ 'conference_sid' => conference_sid,
+ 'meta' => { 'initiated_at' => timestamp }
+ }
+
+ conversation.update!(
+ identifier: call_sid,
+ additional_attributes: attrs,
+ last_activity_at: current_time
+ )
+ end
+
+ def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
+ Voice::CallMessageBuilder.perform!(
+ conversation: conversation,
+ direction: 'outbound',
+ payload: {
+ call_sid: call_sid,
+ status: 'ringing',
+ conference_sid: conference_sid,
+ from_number: inbox.channel&.phone_number,
+ to_number: contact.phone_number
+ },
+ user: user,
+ timestamps: { created_at: timestamp, ringing_at: timestamp }
+ )
+ end
+
+ def current_timestamp
+ @current_timestamp ||= current_time.to_i
+ end
+
+ def current_time
+ @current_time ||= Time.zone.now
+ end
+end
diff --git a/enterprise/app/services/voice/provider/twilio_adapter.rb b/enterprise/app/services/voice/provider/twilio_adapter.rb
new file mode 100644
index 000000000..2a73cd960
--- /dev/null
+++ b/enterprise/app/services/voice/provider/twilio_adapter.rb
@@ -0,0 +1,32 @@
+class Voice::Provider::TwilioAdapter
+ def initialize(channel)
+ @channel = channel
+ end
+
+ def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
+ cfg = @channel.provider_config_hash
+
+ host = ENV.fetch('FRONTEND_URL')
+ phone_digits = @channel.phone_number.delete_prefix('+')
+ callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
+
+ params = {
+ from: @channel.phone_number,
+ to: to,
+ url: callback_url,
+ status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
+ status_callback_event: %w[initiated ringing answered completed],
+ status_callback_method: 'POST'
+ }
+
+ call = twilio_client(cfg).calls.create(**params)
+
+ { call_sid: call.sid }
+ end
+
+ private
+
+ def twilio_client(config)
+ Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
+ end
+end
diff --git a/enterprise/app/services/voice/status_update_service.rb b/enterprise/app/services/voice/status_update_service.rb
index 18152c549..8503d8d3b 100644
--- a/enterprise/app/services/voice/status_update_service.rb
+++ b/enterprise/app/services/voice/status_update_service.rb
@@ -1,29 +1,60 @@
class Voice::StatusUpdateService
- pattr_initialize [:account!, :call_sid!, :call_status]
+ pattr_initialize [:account!, :call_sid!, :call_status, { payload: {} }]
+
+ TWILIO_STATUS_MAP = {
+ 'queued' => 'ringing',
+ 'initiated' => 'ringing',
+ 'ringing' => 'ringing',
+ 'in-progress' => 'in-progress',
+ 'inprogress' => 'in-progress',
+ 'answered' => 'in-progress',
+ 'completed' => 'completed',
+ 'busy' => 'no-answer',
+ 'no-answer' => 'no-answer',
+ 'failed' => 'failed',
+ 'canceled' => 'failed'
+ }.freeze
def perform
+ normalized_status = normalize_status(call_status)
+ return if normalized_status.blank?
+
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)
+ Voice::CallStatus::Manager.new(
+ conversation: conversation,
+ call_sid: call_sid
+ ).process_status_update(
+ normalized_status,
+ duration: payload_duration,
+ timestamp: payload_timestamp
+ )
end
private
- def update_conversation!(conversation)
- attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status)
- conversation.update!(additional_attributes: attrs)
+ def normalize_status(status)
+ return if status.to_s.strip.empty?
+
+ TWILIO_STATUS_MAP[status.to_s.downcase]
end
- def update_last_call_message!(conversation)
- msg = conversation.messages.voice_calls.order(created_at: :desc).first
- return unless msg
+ def payload_duration
+ return unless payload.is_a?(Hash)
- data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {}
- data['data'] ||= {}
- data['data']['status'] = call_status
- msg.update!(content_attributes: data)
+ duration = payload['CallDuration'] || payload['call_duration']
+ duration&.to_i
+ end
+
+ def payload_timestamp
+ return unless payload.is_a?(Hash)
+
+ ts = payload['Timestamp'] || payload['timestamp']
+ return unless ts
+
+ Time.zone.parse(ts).to_i
+ rescue ArgumentError
+ nil
end
end
diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
index 0f5e4d00a..43141414e 100644
--- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb
+++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
@@ -18,36 +18,94 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
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('')
+ it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
+ instance_double(Voice::InboundCallBuilder)
+ conversation = create(:conversation, account: account, inbox: inbox)
+
+ expect(Voice::InboundCallBuilder).to receive(:perform!).with(
+ account: account,
+ inbox: inbox,
+ from_number: from_number,
+ call_sid: call_sid
+ ).and_return(conversation)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
- 'To' => to_number
+ 'To' => to_number,
+ 'Direction' => 'inbound'
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('')
+ expect(response.body).to include('')
+ end
+
+ it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
+ conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
+ sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
+
+ expect(Voice::CallSessionSyncService).to receive(:new).with(
+ hash_including(
+ conversation: conversation,
+ call_sid: call_sid,
+ message_call_sid: conversation.identifier,
+ leg: {
+ from_number: from_number,
+ to_number: to_number,
+ direction: 'outbound'
+ }
+ )
+ ).and_return(sync_double)
+
+ post "/twilio/voice/call/#{digits}", params: {
+ 'CallSid' => call_sid,
+ 'From' => from_number,
+ 'To' => to_number,
+ 'Direction' => 'outbound-api'
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('')
+ end
+
+ it 'uses the parent call SID when syncing outbound-dial legs' do
+ parent_sid = 'CA_parent'
+ child_sid = 'CA_child'
+ conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
+ sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
+
+ expect(Voice::CallSessionSyncService).to receive(:new).with(
+ hash_including(
+ conversation: conversation,
+ call_sid: child_sid,
+ message_call_sid: parent_sid,
+ leg: {
+ from_number: from_number,
+ to_number: to_number,
+ direction: 'outbound'
+ }
+ )
+ ).and_return(sync_double)
+
+ post "/twilio/voice/call/#{digits}", params: {
+ 'CallSid' => child_sid,
+ 'ParentCallSid' => parent_sid,
+ 'From' => from_number,
+ 'To' => to_number,
+ 'Direction' => 'outbound-dial'
}
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)
+ expect(Voice::InboundCallBuilder).not_to receive(:perform!)
post '/twilio/voice/call/19998887777', params: {
'CallSid' => call_sid,
'From' => from_number,
- 'To' => to_number
+ 'To' => to_number,
+ 'Direction' => 'inbound'
}
expect(response).to have_http_status(:not_found)
end
@@ -62,7 +120,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
hash_including(
account: account,
call_sid: call_sid,
- call_status: 'completed'
+ call_status: 'completed',
+ payload: hash_including('CallSid' => call_sid, 'CallStatus' => 'completed')
)
).and_return(service_double)
expect(service_double).to receive(:perform)
diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb
index 12e2d7235..e8953da3c 100644
--- a/spec/enterprise/services/voice/inbound_call_builder_spec.rb
+++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb
@@ -4,54 +4,118 @@ require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
- let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') }
+ let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
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)}"))
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
end
- def build_and_perform
- described_class.new(
+ def perform_builder
+ described_class.perform!(
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')
+ context 'when no existing conversation matches call_sid' do
+ it 'creates a new inbound conversation with ringing status' do
+ conversation = nil
+ expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
+
+ attrs = conversation.additional_attributes
+ expect(conversation.identifier).to eq(call_sid)
+ expect(attrs['call_direction']).to eq('inbound')
+ expect(attrs['call_status']).to eq('ringing')
+ expect(attrs['conference_sid']).to be_present
+ expect(attrs.dig('meta', 'initiated_at')).to be_present
+ expect(conversation.contact.phone_number).to eq(from_number)
+ end
+
+ it 'creates a single voice_call message marked as incoming' do
+ conversation = perform_builder
+ voice_message = conversation.messages.voice_calls.last
+
+ expect(voice_message).to be_present
+ expect(voice_message.message_type).to eq('incoming')
+ data = voice_message.content_attributes['data']
+ expect(data).to include(
+ 'call_sid' => call_sid,
+ 'status' => 'ringing',
+ 'call_direction' => 'inbound',
+ 'conference_sid' => conversation.additional_attributes['conference_sid'],
+ 'from_number' => from_number,
+ 'to_number' => inbox.channel.phone_number
+ )
+ expect(data['meta']['created_at']).to be_present
+ expect(data['meta']['ringing_at']).to be_present
+ end
+
+ it 'sets the contact name to the phone number for new callers' do
+ conversation = perform_builder
+
+ expect(conversation.contact.name).to eq(from_number)
+ end
+
+ it 'ensures the conversation has a display_id before building the conference SID' do
+ allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
+ expect(conversation.display_id).to be_present
+ original.call(conversation)
+ end
+
+ perform_builder
+ end
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
+ context 'when a conversation already exists for the call_sid' do
+ let(:contact) { create(:contact, account: account, phone_number: from_number) }
+ let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
+ let!(:existing_conversation) do
+ create(
+ :conversation,
+ account: account,
+ inbox: inbox,
+ contact: contact,
+ contact_inbox: contact_inbox,
+ identifier: call_sid,
+ additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
+ )
+ end
+ let(:existing_message) do
+ create(
+ :message,
+ account: account,
+ inbox: inbox,
+ conversation: existing_conversation,
+ message_type: :incoming,
+ content_type: :voice_call,
+ sender: contact,
+ content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
+ )
+ 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(' 'outbound', 'call_status' => 'ringing')
+ expect(attrs['agent_id']).to eq(user.id)
+ expect(attrs['conference_sid']).to be_present
+
+ voice_message = conversation.messages.voice_calls.last
+ expect(voice_message.message_type).to eq('outgoing')
+
+ message_data = voice_message.content_attributes['data']
+ expect(message_data).to include(
+ 'call_sid' => call_sid,
+ 'conference_sid' => attrs['conference_sid'],
+ 'from_number' => channel.phone_number,
+ 'to_number' => contact.phone_number
+ )
+ end
+ end
+
+ it 'raises an error when contact is missing a phone number' do
+ contact.update!(phone_number: nil)
+
+ expect do
+ described_class.perform!(
+ account: account,
+ inbox: inbox,
+ user: user,
+ contact: contact
+ )
+ end.to raise_error(ArgumentError, 'Contact phone number required')
+ end
+
+ it 'raises an error when user is nil' do
+ expect do
+ described_class.perform!(
+ account: account,
+ inbox: inbox,
+ user: nil,
+ contact: contact
+ )
+ end.to raise_error(ArgumentError, 'Agent required')
+ end
+
+ it 'ensures the conversation has a display_id before building the conference SID' do
+ allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
+ expect(conversation.display_id).to be_present
+ original.call(conversation)
+ end
+
+ described_class.perform!(
+ account: account,
+ inbox: inbox,
+ user: user,
+ contact: contact
+ )
+ end
+ end
+end
diff --git a/spec/enterprise/services/voice/status_update_service_spec.rb b/spec/enterprise/services/voice/status_update_service_spec.rb
index 61596e35c..fab626fb6 100644
--- a/spec/enterprise/services/voice/status_update_service_spec.rb
+++ b/spec/enterprise/services/voice/status_update_service_spec.rb
@@ -55,6 +55,23 @@ RSpec.describe Voice::StatusUpdateService do
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
end
+ it 'normalizes busy to no-answer' do
+ conversation
+ message
+
+ described_class.new(
+ account: account,
+ call_sid: call_sid,
+ call_status: 'busy'
+ ).perform
+
+ conversation.reload
+ message.reload
+
+ expect(conversation.additional_attributes['call_status']).to eq('no-answer')
+ expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
+ end
+
it 'no-ops when conversation not found' do
expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb
index 6b98324ec..91a56407d 100644
--- a/spec/jobs/mutex_application_job_spec.rb
+++ b/spec/jobs/mutex_application_job_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe MutexApplicationJob do
described_class.new.send(:with_lock, lock_key) do
# Do nothing
end
- end.to raise_error(MutexApplicationJob::LockAcquisitionError)
+ end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
end
it 'raises StandardError if it execution raises it' do
diff --git a/tailwind.config.js b/tailwind.config.js
index 82a6c2cbe..18bd54948 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -31,6 +31,7 @@ const tailwindConfig = {
'./app/javascript/survey/**/*.vue',
'./app/javascript/dashboard/components-next/**/*.vue',
'./app/javascript/dashboard/helper/**/*.js',
+ './app/javascript/dashboard/composables/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
'./app/views/**/*.html.erb',