+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Integration.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Integration.vue
index fd8fb9212..c22f05b16 100644
--- a/app/javascript/dashboard/components/widgets/conversation/bubble/Integration.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Integration.vue
@@ -28,6 +28,12 @@ export default {
return this.$store.getters['inboxes/getInbox'](this.inboxId);
},
},
+ mounted() {
+ // Log integration type for debugging if needed
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('Integration component mounted with type:', this.contentAttributes.type);
+ }
+ },
};
@@ -38,4 +44,13 @@ export default {
:message-id="messageId"
:meeting-data="contentAttributes.data"
/>
+
+ {{ contentAttributes.type || 'Unknown' }} integration
+
+
+
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js
index 9142fa3bc..5ecbb5925 100644
--- a/app/javascript/dashboard/helper/actionCable.js
+++ b/app/javascript/dashboard/helper/actionCable.js
@@ -234,8 +234,13 @@ class ActionCableConnector extends BaseActionCableConnector {
inboxId: data.inbox_id,
};
- // Update store
- this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
+ // Update store with call status change
+ this.app.$store.dispatch('calls/handleCallStatusChanged', normalizedPayload);
+
+ // For non-terminal statuses, update the active call
+ if (!['ended', 'missed', 'completed'].includes(data.status)) {
+ this.app.$store.dispatch('calls/setActiveCall', normalizedPayload);
+ }
};
}
diff --git a/app/javascript/dashboard/store/modules/calls.js b/app/javascript/dashboard/store/modules/calls.js
index 4ca449f7f..6fddf1871 100644
--- a/app/javascript/dashboard/store/modules/calls.js
+++ b/app/javascript/dashboard/store/modules/calls.js
@@ -11,10 +11,29 @@ const getters = {
};
const actions = {
- setActiveCall({ commit }, callData) {
+ // This action will handle both message updates and direct call status changes
+ handleCallStatusChanged({ state, dispatch }, { callSid, status }) {
+ // If this is the active call and it has ended or was missed, close the widget
+ if (callSid === state.activeCall?.callSid &&
+ (status === 'ended' || status === 'missed' || status === 'completed')) {
+ dispatch('clearActiveCall');
+ }
+ },
+
+ setActiveCall({ commit, dispatch, state }, callData) {
if (!callData || !callData.callSid) {
throw new Error('Invalid call data provided');
}
+
+ // If the call has a status, check if it's a terminal status
+ if (callData.status && ['ended', 'missed', 'completed'].includes(callData.status)) {
+ // If the call is already in a terminal state, clear any active call
+ if (callData.callSid === state.activeCall?.callSid) {
+ return dispatch('clearActiveCall');
+ }
+ // Otherwise just ignore it - don't set an already ended call as active
+ return;
+ }
commit('SET_ACTIVE_CALL', callData);
@@ -25,12 +44,18 @@ const actions = {
},
clearActiveCall({ commit }) {
+ // Store the messageId before clearing the call
+ const messageId = state.activeCall?.messageId;
+
commit('CLEAR_ACTIVE_CALL');
// Update app state if in browser environment
if (typeof window !== 'undefined' && window.app?.$data) {
window.app.$data.showCallWidget = false;
}
+
+ // We no longer need to update call widget status as we'll use reactive Vue props
+ // and updates will come through Chatwoot's standard message update events
},
setIncomingCall({ commit, state }, callData) {
@@ -48,16 +73,30 @@ const actions = {
return;
}
- commit('SET_INCOMING_CALL', callData);
+ const enrichedCallData = {
+ ...callData,
+ receivedAt: Date.now(),
+ };
+
+ commit('SET_INCOMING_CALL', enrichedCallData);
// Update app state if in browser environment
if (typeof window !== 'undefined' && window.app && window.app.$data) {
window.app.$data.showCallWidget = true;
}
+
+ // We no longer need to update call widget status as we'll use reactive Vue props
+ // and updates will come through Chatwoot's standard message update events
},
clearIncomingCall({ commit }) {
+ // Store the messageId before clearing the call
+ const messageId = state.incomingCall?.messageId;
+
commit('CLEAR_INCOMING_CALL');
+
+ // We no longer need to update call widget status as we'll use reactive Vue props
+ // and updates will come through Chatwoot's standard message update events
},
acceptIncomingCall({ commit, state }) {
@@ -70,8 +109,12 @@ const actions = {
commit('SET_ACTIVE_CALL', {
...incomingCall,
isJoined: true,
+ startedAt: Date.now(),
});
commit('CLEAR_INCOMING_CALL');
+
+ // We no longer need to update call widget status as we'll use reactive Vue props
+ // and updates will come through Chatwoot's standard message update events
},
};
@@ -88,6 +131,9 @@ const mutations = {
CLEAR_INCOMING_CALL($state) {
$state.incomingCall = null;
},
+ // We no longer need to update call widget status as we'll use reactive Vue props
+
+ // We no longer need subscription mutations
};
export default {
diff --git a/app/jobs/process_conference_status_job.rb b/app/jobs/process_conference_status_job.rb
deleted file mode 100644
index f2d504997..000000000
--- a/app/jobs/process_conference_status_job.rb
+++ /dev/null
@@ -1,146 +0,0 @@
-class ProcessConferenceStatusJob < ApplicationJob
- queue_as :default
-
- def perform(options = {})
- # Extract parameters from options
- conversation_id = options[:conversation_id]
- event = options[:event]
- call_sid = options[:call_sid]
- conference_sid = options[:conference_sid]
- account_id = options[:account_id]
- participant_sid = options[:participant_sid]
- participant_label = options[:participant_label]
- call_sid_ending_with = options[:call_sid_ending_with]
- audio_level = options[:audio_level]
-
- # Set the current account (required for proper routing)
- Current.account = Account.find(account_id)
-
- # Find the conversation
- conversation = Current.account.conversations.find_by(id: conversation_id)
- return unless conversation
-
- # Update conversation with conference info
- conversation.additional_attributes ||= {}
- conversation.additional_attributes['conference_sid'] = conference_sid
-
- # Store more detailed audio diagnostics for speak events
- if event == 'participant-speak'
- conversation.additional_attributes['last_speak_event'] = {
- participant_sid: participant_sid,
- timestamp: Time.now.to_i,
- audio_level: audio_level || 'unknown'
- }
- end
-
- # Process the event
- case event
- when 'conference-start'
- conversation.additional_attributes['conference_status'] = 'started'
- activity_message = 'Conference started'
- when 'conference-end'
- conversation.additional_attributes['conference_status'] = 'ended'
- conversation.additional_attributes['call_status'] = 'completed'
- conversation.additional_attributes['call_ended_at'] = Time.now.to_i
- conversation.status = :resolved
- activity_message = 'Conference ended'
- when 'participant-join'
- # Track participant type for debugging
- participant_type = participant_label || (call_sid_ending_with || '').start_with?('agent') ? 'agent' : 'caller'
- activity_message = "#{participant_type.capitalize} joined the call"
-
- # Track all participants for audio diagnostics
- conversation.additional_attributes['participants'] ||= {}
- conversation.additional_attributes['participants'][participant_sid] = {
- joined_at: Time.now.to_i,
- type: participant_type,
- call_sid: call_sid,
- status: 'joined'
- }
- when 'participant-leave'
- # Update participant status
- if conversation.additional_attributes['participants']&.key?(participant_sid)
- participant_type = conversation.additional_attributes['participants'][participant_sid]['type'] || 'Participant'
- activity_message = "#{participant_type.capitalize} left the call"
-
- # Mark participant as left
- conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
- conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
- else
- activity_message = 'Participant left the call'
- end
- when 'participant-speak'
- # This is critical for diagnosing audio issues
- if conversation.additional_attributes['participants']&.key?(participant_sid)
- participant_type = conversation.additional_attributes['participants'][participant_sid]['type'] || 'Participant'
- activity_message = "#{participant_type} speaking detected"
-
- # Track speaking events
- participant = conversation.additional_attributes['participants'][participant_sid]
- participant['speak_events'] ||= []
- participant['speak_events'] << Time.now.to_i
-
- # Only keep the last 5 events to avoid bloating the database
- participant['speak_events'] = participant['speak_events'].last(5) if participant['speak_events'].size > 5
-
- conversation.additional_attributes['participants'][participant_sid] = participant
- else
- activity_message = 'Speech detected'
- end
- else
- activity_message = "Call event: #{event}"
- end
-
- # Save conversation with enhanced tracking
- begin
- conversation.save!
- Rails.logger.info("✅ Conference status updated: #{event} for conversation_id=#{conversation.id}")
- rescue => e
- Rails.logger.error("❌ Failed to save conversation: #{e.message}")
- end
-
- # Create activity message with enhanced attributes
- begin
- Messages::MessageBuilder.new(
- nil,
- conversation,
- {
- content: activity_message,
- message_type: :activity,
- additional_attributes: {
- call_sid: call_sid,
- event_type: event,
- conference_sid: conference_sid,
- timestamp: Time.now.to_i,
- participant_sid: participant_sid,
- audio_level: audio_level
- }
- }
- ).perform
- rescue => e
- Rails.logger.error("❌ Failed to create activity message: #{e.message}")
- end
-
- # Broadcast call status updates on account-level channel
- begin
- # Include account_id in the data to help with validation
- data_with_account = {
- call_sid: call_sid,
- status: conversation.additional_attributes['call_status'] || 'in-progress',
- conversation_id: conversation.id,
- event: event,
- account_id: conversation.account_id
- }
-
- ActionCable.server.broadcast(
- "account_#{conversation.account_id}",
- {
- event: 'call_status_changed',
- data: data_with_account
- }
- )
- rescue => e
- Rails.logger.error("❌ Failed to broadcast call status: #{e.message}")
- end
- end
-end
\ No newline at end of file
diff --git a/app/models/channel/voice.rb b/app/models/channel/voice.rb
index 79edd7cb2..d8eae4b7f 100644
--- a/app/models/channel/voice.rb
+++ b/app/models/channel/voice.rb
@@ -16,10 +16,10 @@ class Channel::Voice < ApplicationRecord
"#{provider.capitalize} Voice"
end
- def initiate_call(to:, conference_name: nil)
+ def initiate_call(to:, conference_name: nil, agent_id: nil)
case provider
when 'twilio'
- initiate_twilio_call(to, conference_name)
+ initiate_twilio_call(to, conference_name, agent_id)
# Add more providers as needed
# when 'other_provider'
# initiate_other_provider_call(to)
@@ -30,7 +30,7 @@ class Channel::Voice < ApplicationRecord
private
- def initiate_twilio_call(to, conference_name = nil)
+ def initiate_twilio_call(to, conference_name = nil, agent_id = nil)
config = provider_config_hash
# Generate a public URL for Twilio to request TwiML (must set FRONTEND_URL)
@@ -39,12 +39,23 @@ class Channel::Voice < ApplicationRecord
# Use the simplest possible TwiML endpoint
callback_url = "#{host}/twilio/voice/simple"
+ # Start building query parameters
+ query_params = []
+
# Add conference name as a parameter if provided
if conference_name.present?
- callback_url += "?conference_name=#{CGI.escape(conference_name)}"
-
- # Log this for debugging
- Rails.logger.info("🚨 OUTBOUND CALL: Adding conference_name '#{conference_name}' to callback URL: #{callback_url}")
+ query_params << "conference_name=#{CGI.escape(conference_name)}"
+ end
+
+ # Add agent ID as a parameter if provided
+ if agent_id.present?
+ query_params << "agent_id=#{agent_id}"
+ end
+
+ # Append query parameters to URL if any exist
+ if query_params.any?
+ callback_url += "?#{query_params.join('&')}"
+ Rails.logger.info("🚨 OUTBOUND CALL: Using callback URL with params: #{callback_url}")
end
# Parameters including status callbacks for call progress tracking
@@ -66,7 +77,8 @@ class Channel::Voice < ApplicationRecord
call_sid: call.sid,
status: call.status,
call_direction: 'outbound', # CRITICAL: Tag as outbound so webhooks know to prompt agent
- requires_agent_join: true # Flag that agent should join immediately
+ requires_agent_join: true, # Flag that agent should join immediately
+ agent_id: agent_id # Include agent_id for tracking who initiated the call
}
end
diff --git a/app/models/message.rb b/app/models/message.rb
index 20dad7403..3250c4d0b 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -92,7 +92,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
diff --git a/app/services/voice/conference_status_service.rb b/app/services/voice/conference_status_service.rb
new file mode 100644
index 000000000..a833920d5
--- /dev/null
+++ b/app/services/voice/conference_status_service.rb
@@ -0,0 +1,127 @@
+module Voice
+ class ConferenceStatusService
+ pattr_initialize [:account!, :params!]
+
+ def process
+ find_conversation
+ queue_status_processing if @conversation
+ end
+
+ def status_info
+ {
+ call_sid: params['CallSid'],
+ conference_sid: params['ConferenceSid'],
+ event: params['StatusCallbackEvent'],
+ participant_sid: params['ParticipantSid'],
+ participant_label: params['ParticipantLabel'],
+ call_sid_ending_with: params['CallSidEndingWith'],
+ audio_level: params['AudioLevel']
+ }
+ end
+
+ private
+
+ def find_conversation
+ @conversation = nil
+
+ # Try finding by conference_sid
+ if status_info[:conference_sid].present?
+ @conversation = account.conversations
+ .where("additional_attributes->>'conference_sid' = ?", status_info[:conference_sid])
+ .first
+ end
+
+ # If not found and conference_sid looks like our format, extract conversation ID
+ if @conversation.nil? && status_info[:conference_sid].present? && status_info[:conference_sid].start_with?('conf_account_')
+ conference_parts = status_info[:conference_sid].match(/conf_account_\d+_conv_(\d+)/)
+ if conference_parts && conference_parts[1].present?
+ conversation_display_id = conference_parts[1]
+ @conversation = account.conversations.find_by(display_id: conversation_display_id)
+ Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{status_info[:conference_sid]}")
+ end
+ end
+
+ # If still not found, try by call_sid
+ if @conversation.nil? && status_info[:call_sid].present?
+ @conversation = account.conversations
+ .where("additional_attributes->>'call_sid' = ?", status_info[:call_sid])
+ .first
+ end
+
+ # Update participant info if conversation found
+ update_participant_info if @conversation
+ end
+
+ def update_participant_info
+ # Initialize or get current participants list
+ @conversation.additional_attributes ||= {}
+ @conversation.additional_attributes['participants'] ||= []
+
+ # Check if this participant is already in the list
+ existing_participant = @conversation.additional_attributes['participants'].find do |p|
+ p['call_sid'] == status_info[:call_sid]
+ end
+
+ # Update based on event type
+ if status_info[:event] == 'join'
+ # Add participant if not exists
+ unless existing_participant
+ @conversation.additional_attributes['participants'] << {
+ 'call_sid' => status_info[:call_sid],
+ 'label' => status_info[:participant_label],
+ 'joined_at' => Time.now.to_i
+ }
+ end
+ elsif status_info[:event] == 'leave'
+ # Remove participant if exists
+ @conversation.additional_attributes['participants'].reject! { |p| p['call_sid'] == status_info[:call_sid] }
+ end
+
+ # Flag outbound calls that need agent join
+ if @conversation.additional_attributes['call_direction'] == 'outbound' &&
+ status_info[:participant_label]&.start_with?('caller-') &&
+ status_info[:event] == 'join'
+
+ # This is the customer joining an outbound call - flag for agent to join immediately
+ @conversation.additional_attributes['requires_agent_join'] = true
+
+ # Broadcast an immediate "incoming call" notification for the agent
+ broadcast_agent_join_notification
+ end
+
+ # Save the updated conversation
+ @conversation.save!
+ end
+
+ def broadcast_agent_join_notification
+ ActionCable.server.broadcast(
+ "account_#{account.id}",
+ {
+ event: 'incoming_call',
+ data: {
+ call_sid: status_info[:call_sid],
+ conversation_id: @conversation.id,
+ inbox_id: @conversation.inbox_id,
+ inbox_name: @conversation.inbox.name,
+ contact_name: @conversation.contact.name || 'Outbound Call',
+ contact_id: @conversation.contact_id,
+ is_outbound: true,
+ account_id: account.id
+ }
+ }
+ )
+ end
+
+ def queue_status_processing
+ # Process the status update directly using the service
+ Voice::ConferenceStatusUpdateService.new(
+ conversation: @conversation,
+ event: status_info[:event],
+ call_sid: status_info[:call_sid],
+ conference_sid: status_info[:conference_sid],
+ participant_sid: status_info[:participant_sid],
+ participant_label: status_info[:participant_label]
+ ).process
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/services/voice/conference_status_update_service.rb b/app/services/voice/conference_status_update_service.rb
new file mode 100644
index 000000000..3c25b5374
--- /dev/null
+++ b/app/services/voice/conference_status_update_service.rb
@@ -0,0 +1,160 @@
+module Voice
+ class ConferenceStatusUpdateService
+ pattr_initialize [:conversation!, :event!, :call_sid!, :conference_sid, :participant_sid, :participant_label]
+
+ def process
+ update_conversation
+ create_activity_message
+ # We no longer need to explicitly broadcast call status
+ # since the Message model's after_update_commit hook will broadcast updates
+ end
+
+ private
+
+ def update_conversation
+ # No need to track status changes for broadcasting anymore
+
+ # Find the message to update
+ message = find_call_message
+
+ case event
+ when 'conference-start'
+ conversation.additional_attributes['conference_status'] = 'started'
+ update_call_message_widget(message, 'ringing') if message
+ when 'conference-end'
+ conversation.additional_attributes['conference_status'] = 'ended'
+ conversation.additional_attributes['call_status'] = 'completed'
+ conversation.additional_attributes['call_ended_at'] = Time.now.to_i
+ conversation.status = :resolved
+
+ # Calculate call duration if possible
+ if conversation.additional_attributes['call_started_at']
+ call_duration = Time.now.to_i - conversation.additional_attributes['call_started_at']
+ update_call_message_widget(message, 'ended', call_duration) if message
+ else
+ update_call_message_widget(message, 'ended') if message
+ end
+ when 'participant-join'
+ update_participant_info('joined')
+
+ # Is this participant an agent?
+ is_agent = participant_label&.start_with?('agent')
+
+ # If this is an agent joining, update the call status
+ if is_agent && conversation.additional_attributes['call_status'] == 'ringing'
+ conversation.additional_attributes['call_status'] = 'active'
+ conversation.additional_attributes['call_started_at'] = Time.now.to_i
+ update_call_message_widget(message, 'active') if message
+ end
+ when 'participant-leave'
+ update_participant_info('left')
+
+ # Was this participant the caller?
+ is_caller = participant_label&.start_with?('caller')
+
+ # If this is the caller leaving and call is still ringing (no agent joined), mark as missed
+ if is_caller && conversation.additional_attributes['call_status'] == 'ringing'
+ has_agent_joined = conversation.additional_attributes['participants']&.values&.any? do |p|
+ p['type'] == 'agent' && p['status'] == 'joined'
+ end
+
+ unless has_agent_joined
+ conversation.additional_attributes['call_status'] = 'missed'
+ update_call_message_widget(message, 'missed') if message
+ end
+ end
+ end
+
+ # Save the updated conversation
+ conversation.save!
+ end
+
+ def create_activity_message
+ # Determine the message content based on the event
+ content = case event
+ when 'conference-start'
+ 'Conference started'
+ when 'conference-end'
+ 'Conference ended'
+ when 'participant-join'
+ participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller'
+ "#{participant_type} joined the call"
+ when 'participant-leave'
+ participant_type = participant_label&.start_with?('agent') ? 'Agent' : 'Caller'
+ "#{participant_type} left the call"
+ else
+ "Call event: #{event}"
+ end
+
+ # Create an activity message
+ Messages::MessageBuilder.new(
+ nil,
+ conversation,
+ {
+ content: content,
+ message_type: :activity,
+ additional_attributes: {
+ call_sid: call_sid,
+ event_type: event,
+ conference_sid: conference_sid,
+ timestamp: Time.now.to_i,
+ participant_sid: participant_sid
+ }
+ }
+ ).perform
+ end
+
+ def update_participant_info(status)
+ # Initialize participants tracking if not already present
+ conversation.additional_attributes['participants'] ||= {}
+
+ # Update participant info
+ if status == 'joined'
+ conversation.additional_attributes['participants'][participant_sid] = {
+ joined_at: Time.now.to_i,
+ type: participant_label&.start_with?('agent') ? 'agent' : 'caller',
+ call_sid: call_sid,
+ status: 'joined'
+ }
+ elsif status == 'left'
+ # Only update if the participant is in the list
+ if conversation.additional_attributes['participants'].key?(participant_sid)
+ conversation.additional_attributes['participants'][participant_sid]['status'] = 'left'
+ conversation.additional_attributes['participants'][participant_sid]['left_at'] = Time.now.to_i
+ end
+ end
+ end
+
+ # We no longer need a separate broadcasting method
+ # The Message model's after_update_commit hook will handle broadcasting updates
+
+ # This method is no longer needed as we update the call widget directly in the update_conversation method
+ # It was keeping for backward compatibility in case any old calls were processed with this method
+
+ def find_call_message
+ conversation.messages
+ .where(content_type: 'voice_call')
+ .where("content_attributes->'data'->>'call_sid' = ?", call_sid)
+ .first
+ end
+
+ def update_call_message_widget(message, status, duration = nil)
+ return unless message
+
+ # Update the message's content attributes
+ content_attributes = message.content_attributes || {}
+ message_data = content_attributes['data'] || {}
+
+ # Update status and add duration if provided
+ message_data['status'] = status
+ message_data['duration'] = duration if duration
+ message_data['meta'] ||= {}
+ message_data['meta']["#{status}_at"] = Time.now.to_i
+
+ content_attributes['data'] = message_data
+ message.content_attributes = content_attributes
+ message.save!
+ end
+
+ end
+end
\ No newline at end of file
diff --git a/app/services/voice/incoming_call_service.rb b/app/services/voice/incoming_call_service.rb
new file mode 100644
index 000000000..cc81fec88
--- /dev/null
+++ b/app/services/voice/incoming_call_service.rb
@@ -0,0 +1,163 @@
+module Voice
+ class IncomingCallService
+ pattr_initialize [:account!, :params!]
+
+ def process
+ create_contact
+ create_conversation
+ create_conversation_messages
+ generate_twiml_response
+ end
+
+ def caller_info
+ {
+ call_sid: params['CallSid'],
+ from_number: params['From'],
+ to_number: params['To']
+ }
+ end
+
+ private
+
+ def create_contact
+ @contact = account.contacts.find_or_create_by!(phone_number: caller_info[:from_number]) do |c|
+ c.name = "Contact from #{caller_info[:from_number]}"
+ end
+ end
+
+ def create_conversation
+ # Find the inbox for this phone number
+ @inbox = find_voice_inbox
+
+ # Create or update contact inbox
+ contact_inbox = create_contact_inbox
+
+ # Create a new conversation with call details
+ @conversation = account.conversations.create!(
+ contact_inbox_id: contact_inbox.id,
+ inbox_id: @inbox.id,
+ status: :open,
+ contact: @contact,
+ additional_attributes: {
+ 'call_sid' => caller_info[:call_sid],
+ 'call_status' => 'ringing',
+ 'call_direction' => 'inbound',
+ 'call_initiated_at' => Time.now.to_i,
+ 'call_type' => 'inbound'
+ }
+ )
+
+ # Set up conference name
+ conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
+ @conversation.additional_attributes['conference_sid'] = conference_name
+ @conversation.save!
+
+ Rails.logger.info("🎧 Creating conference: #{conference_name} for account: #{account.id}, conversation: #{@conversation.display_id}")
+ end
+
+ def create_conversation_messages
+ # Create a single incoming message from contact for this call
+ Messages::MessageBuilder.new(
+ @contact, # For incoming calls, sender is the contact
+ @conversation,
+ {
+ content: 'Voice Call',
+ message_type: :incoming,
+ content_type: 'voice_call', # Direct content type for voice calls
+ content_attributes: {
+ data: {
+ call_sid: caller_info[:call_sid],
+ status: 'ringing',
+ conversation_id: @conversation.id,
+ call_direction: 'inbound',
+ meta: {
+ created_at: Time.now.to_i
+ }
+ }
+ }
+ }
+ ).perform
+
+ # Create a simple activity message (no sender needed)
+ Messages::MessageBuilder.new(
+ nil, # Activity messages don't need a sender
+ @conversation,
+ {
+ content: "Incoming call from #{@contact.name.presence || caller_info[:from_number]}",
+ message_type: :activity,
+ additional_attributes: {
+ call_sid: caller_info[:call_sid],
+ call_status: 'ringing',
+ call_direction: 'inbound'
+ }
+ }
+ ).perform
+
+ # Broadcast call notification
+ broadcast_call_status
+ end
+
+ def broadcast_call_status
+ ActionCable.server.broadcast(
+ "account_#{account.id}",
+ {
+ event: 'incoming_call',
+ data: {
+ call_sid: caller_info[:call_sid],
+ conversation_id: @conversation.id,
+ inbox_id: @inbox.id,
+ inbox_name: @inbox.name,
+ contact_name: @contact.name || caller_info[:from_number],
+ contact_id: @contact.id,
+ account_id: account.id
+ }
+ }
+ )
+ end
+
+ def generate_twiml_response
+ conference_name = @conversation.additional_attributes['conference_sid']
+
+ response = Twilio::TwiML::VoiceResponse.new
+ response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
+
+ response.dial do |dial|
+ dial.conference(
+ conference_name,
+ startConferenceOnEnter: false,
+ endConferenceOnExit: true,
+ beep: false,
+ muted: false,
+ waitUrl: '',
+ statusCallback: "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{account.id}/channels/voice/webhooks/conference_status",
+ statusCallbackMethod: 'POST',
+ statusCallbackEvent: 'start end join leave',
+ participantLabel: "caller-#{caller_info[:call_sid].last(8)}"
+ )
+ end
+
+ response.to_s
+ end
+
+ def find_voice_inbox
+ account.inboxes
+ .where(channel_type: 'Channel::Voice')
+ .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
+ .where('channel_voice.phone_number = ?', caller_info[:to_number])
+ .first or raise "Inbox not found for phone number #{caller_info[:to_number]}"
+ end
+
+ def create_contact_inbox
+ contact_inbox = ContactInbox.find_or_create_by!(
+ contact_id: @contact.id,
+ inbox_id: @inbox.id
+ )
+ contact_inbox.update!(source_id: caller_info[:from_number]) if contact_inbox.source_id.blank?
+ contact_inbox
+ end
+
+ def base_url
+ ENV.fetch('FRONTEND_URL', "https://#{params['host_with_port']}")
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/services/voice/outgoing_call_service.rb b/app/services/voice/outgoing_call_service.rb
new file mode 100644
index 000000000..a22d321ae
--- /dev/null
+++ b/app/services/voice/outgoing_call_service.rb
@@ -0,0 +1,145 @@
+module Voice
+ class OutgoingCallService
+ pattr_initialize [:account!, :contact!, :user!]
+
+ def process
+ find_voice_inbox
+ create_conversation
+ initiate_call
+ create_conversation_messages
+ broadcast_to_agent
+ @conversation
+ end
+
+ private
+
+ def find_voice_inbox
+ @voice_inbox = account.inboxes.find_by(channel_type: 'Channel::Voice')
+ raise "No Voice channel found" if @voice_inbox.blank?
+ raise "Contact has no phone number" if contact.phone_number.blank?
+ end
+
+ def create_conversation
+ # Find or create contact inbox
+ contact_inbox = ContactInbox.find_or_initialize_by(
+ contact_id: contact.id,
+ inbox_id: @voice_inbox.id
+ )
+
+ # Set phone number as source_id if new
+ if contact_inbox.new_record?
+ contact_inbox.source_id = contact.phone_number
+ end
+
+ contact_inbox.save!
+
+ # Create a new conversation with call details
+ @conversation = account.conversations.create!(
+ account_id: account.id,
+ inbox_id: @voice_inbox.id,
+ contact_id: contact.id,
+ contact_inbox_id: contact_inbox.id,
+ status: :open,
+ additional_attributes: {
+ 'call_initiated_at' => Time.now.to_i,
+ 'call_type' => 'outbound',
+ 'call_direction' => 'outbound'
+ }
+ )
+
+ # Create conference name for outbound call
+ @conference_name = "conf_account_#{account.id}_conv_#{@conversation.display_id}"
+ end
+
+ def initiate_call
+ # Initiate the call using the channel's implementation
+ @call_details = @voice_inbox.channel.initiate_call(
+ to: contact.phone_number,
+ conference_name: @conference_name,
+ agent_id: user.id # Pass the agent ID to track who initiated the call
+ )
+
+ # Add conference details to the conversation
+ @call_details[:conference_sid] = @conference_name
+
+ # Update conversation with call details
+ updated_attributes = (@conversation.additional_attributes || {}).merge(@call_details)
+ updated_attributes[:call_status] = 'in-progress'
+ updated_attributes[:requires_agent_join] = true
+ updated_attributes[:agent_id] = user.id # Store the agent ID who initiated the call
+ @conversation.update!(additional_attributes: updated_attributes)
+ end
+
+ def create_conversation_messages
+ # Create a single outgoing message from agent for this call
+ @widget_message = Messages::MessageBuilder.new(
+ user, # For outgoing calls, sender is the agent
+ @conversation,
+ {
+ content: 'Voice Call',
+ message_type: :outgoing, # Make sure this is 'outgoing' to be sent from the agent
+ content_type: 'voice_call', # Direct content type for voice calls
+ content_attributes: {
+ data: {
+ call_sid: @call_details[:call_sid],
+ status: 'ringing',
+ conversation_id: @conversation.id,
+ call_direction: 'outbound',
+ meta: {
+ created_at: Time.now.to_i
+ }
+ }
+ },
+ sender: user
+ }
+ ).perform
+
+ # Create a simple activity message (no sender needed)
+ Messages::MessageBuilder.new(
+ nil, # Activity messages don't need a sender
+ @conversation,
+ {
+ content: "Outgoing call to #{contact.name || contact.phone_number}",
+ message_type: :activity,
+ additional_attributes: @call_details
+ }
+ ).perform
+
+ # Update last activity timestamp
+ @conversation.update(last_activity_at: Time.current)
+ end
+
+ def broadcast_to_agent
+ # Direct notification that agent needs to join
+ ActionCable.server.broadcast(
+ "account_#{account.id}",
+ {
+ event: 'incoming_call',
+ data: {
+ call_sid: @call_details[:call_sid],
+ conversation_id: @conversation.id,
+ inbox_id: @voice_inbox.id,
+ inbox_name: @voice_inbox.name,
+ contact_name: contact.name || contact.phone_number,
+ contact_id: contact.id,
+ account_id: account.id,
+ is_outbound: true,
+ conference_sid: @conference_name,
+ requires_agent_join: true,
+ call_direction: 'outbound'
+ }
+ }
+ )
+
+ # Broadcast the conversation and message
+ ActionCableBroadcastJob.perform_later(
+ @conversation.account_id,
+ 'conversation.created',
+ @conversation.push_event_data.merge(
+ message: @widget_message.push_event_data,
+ status: 'open'
+ )
+ )
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/services/voice/twilio_validator_service.rb b/app/services/voice/twilio_validator_service.rb
new file mode 100644
index 000000000..2f04db301
--- /dev/null
+++ b/app/services/voice/twilio_validator_service.rb
@@ -0,0 +1,71 @@
+module Voice
+ class TwilioValidatorService
+ pattr_initialize [:account!, :params!, :request!]
+
+ def valid?
+ # Skip for OPTIONS requests
+ return true if request.method == "OPTIONS"
+
+ # Skip validation for local development
+ return true if Rails.env.development?
+
+ # Skip if no To param (happens in some callback scenarios)
+ to_number = params['To']
+ return true if to_number.blank?
+
+ begin
+ inbox = find_voice_inbox(to_number)
+
+ # If inbox not found, allow the request for Twilio callbacks
+ unless inbox
+ Rails.logger.warn("⚠️ No inbox found for phone number #{to_number} - allowing request for Twilio callback")
+ return true
+ end
+
+ # Get Twilio Auth Token from inbox's channel
+ channel = inbox.channel
+ unless channel.is_a?(Channel::Voice)
+ Rails.logger.warn("⚠️ Channel is not a voice channel - allowing request for Twilio callback")
+ return true
+ end
+
+ auth_token = channel.provider_config_hash['auth_token']
+
+ # Validate incoming request signature if present
+ signature = request.headers['X-Twilio-Signature']
+
+ # Allow requests without signature for callbacks
+ unless signature.present?
+ Rails.logger.warn("⚠️ No Twilio signature in request - allowing for callbacks")
+ return true
+ end
+
+ # Validate the signature
+ validator = Twilio::Security::RequestValidator.new(auth_token)
+ url = "#{request.protocol}#{request.host_with_port}#{request.fullpath}"
+ is_valid = validator.validate(url, params.to_unsafe_h, signature)
+
+ unless is_valid
+ Rails.logger.error("⚠️ Invalid Twilio signature detected")
+ return false
+ end
+ rescue => e
+ Rails.logger.error("Error validating Twilio signature: #{e.message}")
+ # Always allow callbacks even if validation fails
+ return true
+ end
+
+ true
+ end
+
+ private
+
+ def find_voice_inbox(to_number)
+ account.inboxes
+ .where(channel_type: 'Channel::Voice')
+ .joins('INNER JOIN channel_voice ON channel_voice.id = inboxes.channel_id')
+ .where('channel_voice.phone_number = ?', to_number)
+ .first
+ end
+ end
+end
\ No newline at end of file
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c3da5728a..86cdec5a4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -60,6 +60,19 @@ en:
CALL_END_ERROR: 'Failed to end call. Please try again.'
AUDIO_NOT_SUPPORTED: 'Your browser does not support audio playback'
TRANSCRIPTION: 'Transcription'
+ VOICE_CALL:
+ RINGING: 'Incoming Call - Join'
+ ACTIVE: 'Call in progress'
+ MISSED: 'Missed Call'
+ ENDED: 'Call Ended'
+ INCOMING_CALL: 'Incoming Call'
+ JOIN_CALL: 'Join'
+ CALL_JOINED: 'Joining call...'
+ JOIN_ERROR: 'Failed to join call. Please try again.'
+ MISSED_CALL: 'Call was not answered'
+ DURATION: 'Duration: %{duration}'
+ INCOMING_FROM: 'Incoming call from %{name}'
+ OUTGOING_FROM: 'Outgoing call from %{name}'
CONTACT_PANEL:
NEW_MESSAGE: 'New Message'
MERGE_CONTACT: 'Merge Contact'