Voice calling is now a capability on the existing TwilioSms rather than a separate Voice model. A single Twilio phone number handles both SMS and voice calls through one inbox. Fixes https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms **What changed** - Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms - Added voice_enabled, twiml_app_sid, api_key_secret columns to channel_twilio_sms table - Dropped channel_voice table (no production data) - All voice logic lives in Enterprise layer via prepend_mod_with('Channel::TwilioSms') - Added Voice settings tab on Twilio SMS inbox settings to enable/disable voice - Validates Twilio number voice capability before provisioning - Teardown service cleans up TwiML app and credentials when voice is disabled - Frontend voice detection uses isVoiceCallEnabled() / getVoiceCallProvider() helpers — extensible to future providers - Gated by channel_voice feature flag **How to test** 1. Enable feature flag: Account.find(<id>).enable_features('channel_voice') 2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials → verify incoming/outgoing calls and SMS work 3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox → Voice tab → toggle on → provide API key credentials → verify calls work 4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted, credentials cleared, SMS still works 5. Re-enable voice: Toggle on again → must provide api_key_secret again → new TwiML app provisioned --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
191 lines
5.0 KiB
Ruby
191 lines
5.0 KiB
Ruby
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: 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 = current_account
|
|
Rails.logger.info(
|
|
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
|
)
|
|
|
|
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
|
|
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
|
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: 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!
|
|
digits = params[:phone].to_s.gsub(/\D/, '')
|
|
phone_number = "+#{digits}"
|
|
channel = Channel::TwilioSms.find_by!(phone_number: phone_number)
|
|
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled?
|
|
|
|
@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
|