feat(voice): Improved voice call creation flow [EE] (#12268)

This PR improves the voice call creation flow by simplifying
configuration and automating setup with Twilio APIs.

references: #11602 , #11481 

## Key changes

- Removed the requirement for twiml_app_sid – provisioning is now
automated through APIs.
- Auto-configured webhook URLs for:
  - Voice number callbacks
  - Status callbacks
  -  twiML callbacks
- Disabled business hours, help center, and related options until voice
inbox is fully supported.
- Added a configuration tab in the voice inbox to display the required
Twilio URLs (to make verification easier in Twilio console).


## Test Cases
- Provisioning
- Create a new voice inbox → verify that Twilio app provisioning happens
automatically.
  - Verify twiML callback 
- Webhook configuration
- Check that both voice number callback and status callback URLs are
auto-populated in Twilio.
- Disabled features
- Confirm that business hours and help center options are
hidden/disabled for voice inbox.
- Configuration tab
- Open the voice inbox configuration tab → verify that the displayed
Twilio URLs match what’s set in Twilio.
This commit is contained in:
Sojan Jose
2025-08-22 13:38:23 +02:00
committed by GitHub
parent d35b0a9465
commit d48503bdcf
12 changed files with 302 additions and 39 deletions

View File

@@ -30,6 +30,7 @@ class Channel::Voice < ApplicationRecord
# Provider-specific configs stored in JSON
validate :validate_provider_config
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
@@ -41,8 +42,23 @@ class Channel::Voice < ApplicationRecord
false
end
# Public URLs used to configure Twilio webhooks
def voice_call_webhook_url
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
"#{base}/twilio/voice/call/#{phone_number}"
end
def voice_status_webhook_url
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
"#{base}/twilio/voice/status/#{phone_number}"
end
private
def twilio?
provider == 'twilio'
end
def validate_provider_config
return if provider_config.blank?
@@ -54,10 +70,30 @@ class Channel::Voice < ApplicationRecord
def validate_twilio_config
config = provider_config.with_indifferent_access
required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
# Require credentials and provisioned TwiML App SID
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
required_keys.each do |key|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
end
end
def provision_twilio_on_create
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
app_sid = service.perform
return if app_sid.blank?
cfg = provider_config.with_indifferent_access
cfg[:twiml_app_sid] = app_sid
self.provider_config = cfg
rescue StandardError => e
error_details = {
error_class: e.class.to_s,
message: e.message,
phone_number: phone_number,
account_id: account_id,
backtrace: e.backtrace&.first(5)
}
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
errors.add(:base, "Twilio setup failed: #{e.message}")
end
end

View File

@@ -0,0 +1,99 @@
class Twilio::VoiceWebhookSetupService
include Rails.application.routes.url_helpers
pattr_initialize [:channel!]
HTTP_METHOD = 'POST'.freeze
# Returns created TwiML App SID on success.
def perform
validate_token_credentials!
app_sid = create_twiml_app!
configure_number_webhooks!
app_sid
end
private
def validate_token_credentials!
# Only validate Account SID + Auth Token
token_client.incoming_phone_numbers.list(limit: 1)
rescue StandardError => e
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
raise
end
def create_twiml_app!
friendly_name = "Chatwoot Voice #{channel.phone_number}"
app = api_key_client.applications.create(
friendly_name: friendly_name,
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD
)
app.sid
rescue StandardError => e
log_twilio_error('TWIML_APP_CREATE', e)
raise
end
def configure_number_webhooks!
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
if numbers.empty?
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
return
end
api_key_client
.incoming_phone_numbers(numbers.first.sid)
.update(
voice_url: channel.voice_call_webhook_url,
voice_method: HTTP_METHOD,
status_callback: channel.voice_status_webhook_url,
status_callback_method: HTTP_METHOD
)
rescue StandardError => e
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
raise
end
def api_key_client
@api_key_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
end
end
def token_client
@token_client ||= begin
cfg = channel.provider_config.with_indifferent_access
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
end
end
def log_twilio_error(context, error)
details = build_error_details(context, error)
add_twilio_specific_details(details, error)
backtrace = error.backtrace.is_a?(Array) ? error.backtrace.first(5) : []
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{details} backtrace=#{backtrace}")
end
def build_error_details(context, error)
cfg = channel.provider_config.with_indifferent_access
{
context: context,
phone_number: channel.phone_number,
account_sid: cfg[:account_sid],
error_class: error.class.to_s,
message: error.message
}
end
def add_twilio_specific_details(details, error)
details[:status_code] = error.status_code if error.respond_to?(:status_code)
details[:twilio_code] = error.code if error.respond_to?(:code)
details[:more_info] = error.more_info if error.respond_to?(:more_info)
details[:details] = error.details if error.respond_to?(:details)
end
end