chore: code cleanup

This commit is contained in:
Sojan
2025-05-03 02:09:34 -07:00
parent 3692cde1a9
commit 4c48a565f6
16 changed files with 1143 additions and 581 deletions
@@ -1,6 +1,5 @@
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
# Removed skip_before_action :verify_authenticity_token (it's not defined in BaseController)
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
before_action :validate_twilio_signature, only: [:incoming]
before_action :handle_options_request, only: [:incoming, :conference_status]
@@ -24,312 +23,60 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
# Handle incoming calls from Twilio
def incoming
# Find the corresponding voice channel/inbox for this number
to_number = params['To']
inbox = Current.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
unless inbox
render_error('Inbox not found for this phone number')
return
end
# Get caller information
from_number = params['From']
call_sid = params['CallSid']
# Find or create the contact
contact = Current.account.contacts.find_or_create_by!(phone_number: from_number) do |c|
c.name = "Contact from #{from_number}"
end
# Find or create the contact inbox
contact_inbox = ContactInbox.find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id
)
contact_inbox.update!(source_id: from_number) if contact_inbox.source_id.blank?
# Create a new conversation for this call
conversation = Current.account.conversations.create!(
contact_inbox_id: contact_inbox.id,
inbox_id: inbox.id,
status: :open,
contact: contact,
additional_attributes: {
'call_sid' => call_sid,
'call_status' => 'ringing',
'call_direction' => 'inbound'
}
)
# Process incoming call using service
service = Voice::IncomingCallService.new(account: Current.account, params: params.merge(host_with_port: request.host_with_port))
twiml_response = service.process
# Use format that includes account ID and conversation display ID
conference_name = "conf_account_#{Current.account.id}_conv_#{conversation.display_id}"
# Add conference name to conversation
conversation.additional_attributes['conference_sid'] = conference_name
conversation.save!
# SUPER EXPLICIT DEBUG logging for the conference name
Rails.logger.info("🎧🎧🎧 CREATING INITIAL CONFERENCE: '#{conference_name}' for account_id: #{Current.account.id}, conversation: #{conversation.display_id}")
Rails.logger.info("🎧🎧🎧 SAVED TO conversation.additional_attributes['conference_sid'] = '#{conversation.additional_attributes['conference_sid']}'")
Rails.logger.info("Creating conference: #{conference_name} for account: #{Current.account.id}, conversation: #{conversation.display_id}")
# Create an activity message for the incoming call
Messages::MessageBuilder.new(
nil,
conversation,
{
content: 'Incoming call',
message_type: :activity,
additional_attributes: {
call_sid: call_sid,
call_status: 'ringing',
call_direction: 'inbound'
}
}
).perform
# Broadcast incoming call notification on account-level channel
broadcast_call_status('incoming_call', {
call_sid: call_sid,
conversation_id: conversation.id,
inbox_id: inbox.id,
inbox_name: inbox.name,
contact_name: contact.name || from_number,
contact_id: contact.id
}, account_id: inbox.account_id)
# Generate simplified TwiML response
response = Twilio::TwiML::VoiceResponse.new
response.say(message: 'Thank you for calling. Please wait while we connect you with an agent.')
# Log what we're doing
Rails.logger.info("🎧🎧🎧 CALLER CONNECTING TO CONFERENCE: '#{conference_name}'")
# Simple dialog approach for caller
response.dial do |dial|
dial.conference(
conference_name,
startConferenceOnEnter: false, # Caller waits for agent
endConferenceOnExit: true, # End when agent leaves
beep: false, # No beep sounds
muted: false, # Caller can speak
waitUrl: '', # No hold music
statusCallback: "#{base_url.gsub(/\/$/, '')}/api/v1/accounts/#{Current.account.id}/channels/voice/webhooks/conference_status",
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
participantLabel: "caller-#{call_sid.last(8)}"
)
end
# Simplified logging
Rails.logger.info("🔊 Created simplified conference #{conference_name} for account #{Current.account.id}")
Rails.logger.info("🔊 Conference parameters: startConferenceOnEnter=false, endConferenceOnExit=true")
render xml: response.to_s
# Return TwiML response
render xml: twiml_response
rescue => e
Rails.logger.error("Error processing incoming call: #{e.message}")
render_error("An error occurred while processing your call. Please try again later.")
end
# Handle conference status updates with enhanced logging for audio troubleshooting
# Handle conference status updates
def conference_status
# Set CORS headers first to ensure they're always included
set_cors_headers
# SUPER IMPORTANT: Return a minimal response immediately for OPTIONS requests
# Return immediately for OPTIONS requests
if request.method == "OPTIONS"
return head :ok
end
# Wrap everything in a rescue block to prevent large error responses
# Process conference status updates using service
begin
# Log only essential parameters to avoid large log messages
Rails.logger.info("📞 Conference status webhook: event=#{params['StatusCallbackEvent']}, call_sid=#{params['CallSid']&.truncate(10)}")
call_sid = params['CallSid']
conference_sid = params['ConferenceSid']
event = params['StatusCallbackEvent']
account_id = params[:account_id]
participant_label = params['ParticipantLabel']
# For local development, set Current.account if not set
if !Current.account
Current.account = Account.find(account_id) if account_id
# Set account for local development if needed
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
end
# Try to find the conversation by parsing conference_sid directly or through additional attributes
conversation = nil
# First try to find by exact conference_sid match
if conference_sid.present?
conversation = Current.account.conversations
.where("additional_attributes->>'conference_sid' = ?", conference_sid)
.first
end
# If not found and conference_sid looks like our format, extract conversation ID directly
if conversation.nil? && conference_sid.present? && conference_sid.start_with?('conf_account_')
# Try to parse conversation ID from conference name (conf_account_X_conv_Y)
conference_parts = conference_sid.match(/conf_account_\d+_conv_(\d+)/)
if conference_parts && conference_parts[1].present?
conversation_display_id = conference_parts[1]
conversation = Current.account.conversations.find_by(display_id: conversation_display_id)
Rails.logger.info("🎧 Found conversation by display_id=#{conversation_display_id} from conference_sid=#{conference_sid}")
end
end
# If still not found, try by call_sid
if conversation.nil? && call_sid.present?
conversation = Current.account.conversations
.where("additional_attributes->>'call_sid' = ?", call_sid)
.first
end
# If conversation found, update it
if conversation
# Add participant info to conversation for debugging
begin
# Update participant list directly in conversation for real-time monitoring
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'] == call_sid
end
if event == 'join'
# Add participant if not exists
unless existing_participant
conversation.additional_attributes['participants'] << {
'call_sid' => call_sid,
'label' => participant_label,
'joined_at' => Time.now.to_i
}
end
elsif event == 'leave'
# Remove participant if exists
conversation.additional_attributes['participants'].reject! { |p| p['call_sid'] == call_sid }
end
# Always flag outbound calls that need agent join
if conversation.additional_attributes['call_direction'] == 'outbound' &&
participant_label&.start_with?('caller-') &&
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_call_status('incoming_call', {
call_sid: 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: conversation.account_id)
end
# Save the updated conversation
conversation.save!
rescue => participant_error
Rails.logger.error("Error updating participants: #{participant_error.message}")
end
# Process conversation updates in the background to avoid delaying response
Sidekiq::Client.enqueue_to(
'default',
'ProcessConferenceStatusJob',
conversation_id: conversation.id,
event: event,
call_sid: call_sid,
conference_sid: conference_sid,
account_id: Current.account.id,
participant_sid: params['ParticipantSid'],
participant_label: participant_label,
call_sid_ending_with: params['CallSidEndingWith'],
audio_level: params['AudioLevel']
)
else
Rails.logger.error("⚠️ Conference webhook: Conversation not found for call_sid=#{call_sid}, conference_sid=#{conference_sid}")
end
# Use service to process conference status
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
service.process
rescue => e
# Just log errors but don't let them affect the response
Rails.logger.error("Error in conference_status: #{e.message[0..100]}")
# Log errors but don't affect the response
Rails.logger.error("Error processing conference status: #{e.message[0..100]}")
end
# CRITICAL: Always return a minimal success response - this is what Twilio expects
# Return just a 200 OK header with minimal content
# Always return a successful response for Twilio
head :ok
end
private
def validate_twilio_signature
# Skip for OPTIONS requests
return true if request.method == "OPTIONS"
validator = Voice::TwilioValidatorService.new(
account: Current.account,
params: params,
request: request
)
# Find the inbox for the phone number
to_number = params['To']
# Skip validation for local development
return true if Rails.env.development?
# Skip if no To param (happens in some callback scenarios)
return true if to_number.blank?
begin
inbox = Current.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
# If inbox not found, we'll log it but allow the request for Twilio callbacks
# This is necessary because conference callbacks may not have the original To number
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")
render_error('Invalid Twilio signature')
return false
end
rescue => e
Rails.logger.error("Error validating Twilio signature: #{e.message}")
# Always allow callbacks even if validation fails
return true
if !validator.valid?
render_error('Invalid Twilio signature')
return false
end
true
end
@@ -339,21 +86,4 @@ class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts
response.hangup
render xml: response.to_s
end
def base_url
ENV.fetch('FRONTEND_URL', "https://#{request.host_with_port}")
end
def broadcast_call_status(event_name, data, account_id:)
# Include account_id in the data to help with validation
data_with_account = data.merge(account_id: account_id)
ActionCable.server.broadcast(
"account_#{account_id}",
{
event: event_name,
data: data_with_account
}
)
end
end
@@ -1,98 +1,25 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
require 'securerandom'
before_action :fetch_contact
def create
# Find a Voice channel
voice_inbox = Current.account.inboxes.find_by(channel_type: 'Channel::Voice')
if voice_inbox.blank?
render json: { error: 'No Voice channel found' }, status: :unprocessable_entity
return
end
# Validate that contact has a phone number
if @contact.phone_number.blank?
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
return
end
begin
# Create a new conversation for this call
conversation = find_or_create_conversation(voice_inbox)
# CRITICAL: Create a conference name FIRST to ensure consistency
conference_name = "conf_account_#{Current.account.id}_conv_#{conversation.display_id}"
# Create conference for outbound call
# Initiate the call using the channel's implementation - this returns the call details
call_details = voice_inbox.channel.initiate_call(
to: @contact.phone_number,
conference_name: conference_name
# Use the outgoing call service to handle the entire process
service = Voice::OutgoingCallService.new(
account: Current.account,
contact: @contact,
user: Current.user
)
# Add the conference name to the call details
call_details[:conference_sid] = conference_name
# Create a message for this call with call details
params = {
content: "Outgoing voice call initiated to #{@contact.phone_number}",
message_type: :activity,
additional_attributes: call_details,
source_id: call_details[:call_sid] # Use call SID as source_id
}
message = Messages::MessageBuilder.new(Current.user, conversation, params).perform
# Make sure the conversation has the latest activity timestamp
conversation.update(last_activity_at: Time.current)
# Add the conference_sid to the conversation's additional_attributes
# Ensure we don't lose other attributes that might already be set
updated_attributes = (conversation.additional_attributes || {}).merge(call_details)
# CRITICAL: Add additional attributes needed for immediate agent join notification
updated_attributes[:call_status] = 'in-progress'
updated_attributes[:requires_agent_join] = true
# Now update the conversation with all the attributes
conversation.update!(additional_attributes: updated_attributes)
# Conference created successfully
# DIRECT AGENT NOTIFICATION: Immediately broadcast to ActionCable that agent needs to join
# This bypasses any queueing and directly tells the frontend a call needs agent
ActionCable.server.broadcast(
"account_#{Current.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: Current.account.id,
is_outbound: true,
conference_sid: conference_name,
requires_agent_join: true,
# Send additional information to help with debugging
call_direction: 'outbound'
}
}
)
# Broadcast the conversation and message to the appropriate ActionCable channels
ActionCableBroadcastJob.perform_later(
conversation.account_id,
'conversation.created',
conversation.push_event_data.merge(
message: message.push_event_data,
status: 'open'
)
)
# Process the call - this handles all the steps
conversation = service.process
# Return the conversation
render json: conversation
rescue StandardError => e
Rails.logger.error("Error initiating call: #{e.message}")
@@ -105,43 +32,4 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
def fetch_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
def find_or_create_conversation(inbox)
conversation = inbox.conversations.where(contact_id: @contact.id).last
if conversation.nil? || !conversation.open?
# Find or create a contact_inbox for this contact and inbox
contact_inbox = ContactInbox.find_or_initialize_by(
contact_id: @contact.id,
inbox_id: inbox.id
)
# Set the source_id if it's a new record
if contact_inbox.new_record?
# For voice channels, use the phone number as the source_id
contact_inbox.source_id = @contact.phone_number
end
contact_inbox.save!
conversation = ::Conversation.create!(
account_id: Current.account.id,
inbox_id: inbox.id,
contact_id: @contact.id,
contact_inbox_id: contact_inbox.id,
status: :open
)
# Add a note about the call being initiated
params = {
message_type: :activity,
content: "Voice call initiated to #{@contact.phone_number}",
source_id: "voice_call_#{SecureRandom.uuid}" # Generate a unique source_id
}
Messages::MessageBuilder.new(Current.user, conversation, params).perform
end
conversation
end
end
@@ -9,6 +9,7 @@ import BubbleLocation from './bubble/Location.vue';
import BubbleMailHead from './bubble/MailHead.vue';
import BubbleReplyTo from './bubble/ReplyTo.vue';
import BubbleText from './bubble/Text.vue';
import VoiceCall from './VoiceCall.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import InstagramStory from './bubble/InstagramStory.vue';
import InstagramStoryReply from './bubble/InstagramStoryReply.vue';
@@ -43,6 +44,7 @@ export default {
InstagramStoryReply,
Spinner,
NextButton,
VoiceCall,
},
props: {
data: {
@@ -111,7 +113,8 @@ export default {
this.data.content ||
this.isEmailContentType ||
this.isUnsupported ||
this.isAnIntegrationMessage
this.isAnIntegrationMessage ||
this.isVoiceCall
);
},
emailMessageContent() {
@@ -258,6 +261,9 @@ export default {
isAnIntegrationMessage() {
return this.contentType === 'integrations';
},
isVoiceCall() {
return this.contentType === 'voice_call';
},
emailHeadAttributes() {
return {
email: this.contentAttributes.email,
@@ -490,12 +496,17 @@ export default {
</template>
</div>
<BubbleText
v-else-if="data.content"
v-else-if="data.content && !isVoiceCall"
:message="message"
:is-email="isEmailContentType"
:display-quoted-button="displayQuotedButton"
/>
<VoiceCall
v-else-if="isVoiceCall"
:message="data"
/>
<BubbleIntegration
v-else-if="isAnIntegrationMessage"
:message-id="data.id"
:content-attributes="contentAttributes"
:inbox-id="data.inbox_id"
@@ -0,0 +1,321 @@
<template>
<div class="voice-call-widget" :class="statusClass">
<div class="status-icon">
<span :class="statusIcon"></span>
</div>
<div class="call-info">
<div class="call-status">{{ callStatusText }}</div>
<div v-if="statusSubtext" class="call-subtext">{{ statusSubtext }}</div>
</div>
<NextButton
v-if="showJoinButton"
size="sm"
icon="i-lucide-phone"
variant="success"
:label="$t('CONVERSATION.VOICE_CALL.JOIN_CALL')"
:is-loading="isLoading"
@click="joinCall"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import VoiceAPI from 'dashboard/api/channels/voice';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
message: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
status: this.message.content_attributes?.data?.status || 'ringing',
joinedAt: null,
duration: this.message.content_attributes?.data?.duration || null,
};
},
watch: {
// Watch for changes to message content_attributes to update call status
'message.content_attributes.data': {
handler(newData) {
if (newData) {
if (newData.status && newData.status !== this.status) {
const oldStatus = this.status;
this.status = newData.status;
// If call status changes to 'ended' or 'missed', close the floating call widget
if ((newData.status === 'ended' || newData.status === 'missed') &&
['ringing', 'active'].includes(oldStatus)) {
this.closeCallWidget();
}
}
if (newData.duration && newData.duration !== this.duration) {
this.duration = newData.duration;
}
}
},
deep: true,
},
},
computed: {
...mapGetters({
currentConversationId: 'getSelectedChatConversationId',
}),
hasActiveCall() {
return this.$store.getters['calls/hasActiveCall'];
},
callData() {
return this.message.content_attributes?.data || {};
},
callStatusText() {
switch (this.status) {
case 'ringing':
return this.$t('CONVERSATION.VOICE_CALL.RINGING');
case 'active':
return this.$t('CONVERSATION.VOICE_CALL.ACTIVE');
case 'missed':
return this.$t('CONVERSATION.VOICE_CALL.MISSED');
case 'ended':
return this.$t('CONVERSATION.VOICE_CALL.ENDED');
default:
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
}
},
statusIcon() {
switch (this.status) {
case 'ringing':
return 'i-lucide-phone-incoming';
case 'active':
return 'i-lucide-phone';
case 'missed':
return 'i-lucide-phone-missed';
case 'ended':
return 'i-lucide-phone-off';
default:
return 'i-lucide-phone-incoming';
}
},
statusClass() {
return {
'ringing': this.status === 'ringing',
'active': this.status === 'active',
'missed': this.status === 'missed',
'ended': this.status === 'ended',
};
},
callConversationId() {
return this.callData.conversation_id;
},
showJoinButton() {
// Show join button only if the call is ringing or active and we're on the same conversation
return (['ringing', 'active'].includes(this.status)) &&
(this.callConversationId === this.currentConversationId);
},
formattedDuration() {
if (!this.duration) return '';
const minutes = Math.floor(this.duration / 60);
const seconds = this.duration % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
},
statusSubtext() {
if (this.status === 'ended' && this.formattedDuration) {
return this.$t('CONVERSATION.VOICE_CALL.DURATION', { duration: this.formattedDuration });
}
if (this.status === 'missed') {
return this.$t('CONVERSATION.VOICE_CALL.MISSED_CALL');
}
return '';
},
isIncoming() {
return this.message.message_type === 0; // 0 = incoming
},
isOutgoing() {
return this.message.message_type === 1; // 1 = outgoing
},
senderText() {
if (this.isIncoming) {
return this.$t('CONVERSATION.VOICE_CALL.INCOMING_FROM', { name: this.message.sender?.name || 'Unknown' });
}
if (this.isOutgoing) {
return this.$t('CONVERSATION.VOICE_CALL.OUTGOING_FROM', { name: this.message.sender?.name || 'Unknown' });
}
return '';
},
},
mounted() {
// We don't need custom subscriptions anymore as we'll
// get updates through Chatwoot's standard message update system
// Just check if we have an active call with the same call_sid
this.checkActiveCall();
},
methods: {
async joinCall() {
this.isLoading = true;
try {
// Update UI immediately
this.status = 'active';
this.joinedAt = new Date();
// Get the current conversation
const conversation = await this.$store.dispatch('getConversation', {
conversationId: this.callConversationId,
});
const callSid = this.callData.call_sid;
if (!callSid) {
throw new Error('No call SID found');
}
// Show floating call widget
this.$store.dispatch('calls/setActiveCall', {
callSid,
conversationId: this.callConversationId,
inboxId: this.message.inbox_id,
contactId: conversation.contact_id,
contactName: conversation.contact?.name || 'Unknown',
messageId: this.message.id,
});
// Join the call via API
await VoiceAPI.joinCall({
call_sid: callSid,
conversation_id: this.callConversationId,
});
// Success notification
useAlert(this.$t('CONVERSATION.VOICE_CALL.CALL_JOINED'));
} catch (err) {
console.error('Failed to join call:', err);
useAlert(this.$t('CONVERSATION.VOICE_CALL.JOIN_ERROR'));
// Reset status if join failed
this.status = 'ringing';
} finally {
this.isLoading = false;
}
},
// We don't need custom subscription methods anymore as we'll use
// Chatwoot's standard message update events
checkActiveCall() {
// If there's an active call in the store with the same conversation ID
// update our local state to match
const activeCall = this.$store.getters['calls/getActiveCall'];
if (activeCall && activeCall.conversationId === this.callConversationId) {
this.status = 'active';
this.joinedAt = new Date();
}
},
updateStatus(newStatus, duration = null) {
this.status = newStatus;
if (newStatus === 'ended') {
if (duration) {
this.duration = duration;
} else if (this.joinedAt) {
this.duration = Math.floor((new Date() - this.joinedAt) / 1000);
}
// Close the floating call widget when call ends
this.closeCallWidget();
}
},
closeCallWidget() {
// Get the call SID from the message data
const callSid = this.callData.call_sid;
if (!callSid) return;
// Handle the call status change directly
this.$store.dispatch('calls/handleCallStatusChanged', {
callSid,
status: 'ended'
});
},
},
};
</script>
<style lang="scss" scoped>
.voice-call-widget {
@apply flex items-center gap-2 p-3 rounded-lg my-1;
@apply bg-slate-50 dark:bg-slate-700;
@apply border border-slate-200 dark:border-slate-600;
@apply transition-all duration-200;
&.ringing {
@apply border-green-500 dark:border-green-500;
animation: pulse 1.5s infinite;
}
&.active {
@apply border-woot-500 dark:border-woot-500 bg-woot-50 dark:bg-woot-800;
}
&.missed {
@apply border-red-300 dark:border-slate-600 bg-red-50 dark:bg-slate-700;
}
&.ended {
@apply border-slate-300 dark:border-slate-600;
}
.status-icon {
@apply flex items-center justify-center;
@apply w-10 h-10 rounded-full;
@apply bg-slate-200 dark:bg-slate-600;
@apply text-slate-700 dark:text-slate-200;
.i-lucide-phone-incoming {
@apply text-green-600 dark:text-green-400;
}
.i-lucide-phone {
@apply text-woot-600 dark:text-woot-400;
}
.i-lucide-phone-missed {
@apply text-red-600 dark:text-red-400;
}
.i-lucide-phone-off {
@apply text-slate-600 dark:text-slate-400;
}
}
.call-info {
@apply flex-1;
.call-status {
@apply font-medium text-slate-800 dark:text-slate-200;
}
.call-subtext {
@apply text-xs text-slate-500 dark:text-slate-400;
}
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.3);
}
70% {
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
</style>
@@ -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);
}
},
};
</script>
@@ -38,4 +44,13 @@ export default {
:message-id="messageId"
:meeting-data="contentAttributes.data"
/>
<div v-else class="integration-not-supported">
{{ contentAttributes.type || 'Unknown' }} integration
</div>
</template>
<style lang="scss" scoped>
.integration-not-supported {
@apply text-xs text-slate-500 dark:text-slate-400 p-2 bg-slate-50 dark:bg-slate-700 rounded;
}
</style>
@@ -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);
}
};
}
@@ -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 {
-146
View File
@@ -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
+20 -8
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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
@@ -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
+163
View File
@@ -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
+145
View File
@@ -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
@@ -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
+13
View File
@@ -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'