Files
chatwoot/enterprise/app/controllers/twilio/voice_controller.rb
0e122188e9 feat: Add voice calling as a capability on Twilio SMS channel(Enterprise) (#13963)
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>
2026-04-29 11:32:19 +04:00

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