From d48503bdcf5fb3e3dd0b5f30b05fa68dc994f28f Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 22 Aug 2025 13:38:23 +0200 Subject: [PATCH] feat(voice): Improved voice call creation flow [EE] (#12268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../dashboard/i18n/locale/en/inboxMgmt.json | 11 ++- .../dashboard/settings/inbox/Settings.vue | 25 +++-- .../settings/inbox/channels/Voice.vue | 17 ---- .../inbox/settingsPage/ConfigurationPage.vue | 19 ++++ app/javascript/shared/mixins/inboxMixin.js | 6 +- app/views/api/v1/models/_inbox.json.jbuilder | 6 ++ enterprise/app/models/channel/voice.rb | 40 +++++++- .../twilio/voice_webhook_setup_service.rb | 99 +++++++++++++++++++ .../v1/accounts/inboxes_controller_spec.rb | 6 +- spec/enterprise/models/channel/voice_spec.rb | 21 +++- .../voice_webhook_setup_service_spec.rb | 88 +++++++++++++++++ spec/factories/channel/channel_voice.rb | 3 +- 12 files changed, 302 insertions(+), 39 deletions(-) create mode 100644 enterprise/app/services/twilio/voice_webhook_setup_service.rb create mode 100644 spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 4006212a3..6f44ec046 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -330,13 +330,14 @@ "LABEL": "API Key Secret", "PLACEHOLDER": "Enter your Twilio API Key Secret", "REQUIRED": "API Key Secret is required" - }, - "TWIML_APP_SID": { - "LABEL": "TwiML App SID", - "PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)", - "REQUIRED": "TwiML App SID is required" } }, + "CONFIGURATION": { + "TWILIO_VOICE_URL_TITLE": "Twilio Voice URL", + "TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.", + "TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL", + "TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number." + }, "SUBMIT_BUTTON": "Create Voice Channel", "API": { "ERROR_MESSAGE": "We were not able to create the voice channel" diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 1db35af03..609fd3fd4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -116,16 +116,22 @@ export default { key: 'collaborators', name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'), }, - { - key: 'businesshours', - name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), - }, - { - key: 'csat', - name: this.$t('INBOX_MGMT.TABS.CSAT'), - }, ]; + if (!this.isAVoiceChannel) { + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'businesshours', + name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), + }, + { + key: 'csat', + name: this.$t('INBOX_MGMT.TABS.CSAT'), + }, + ]; + } + if (this.isAWebWidgetInbox) { visibleToAllChannelTabs = [ ...visibleToAllChannelTabs, @@ -144,6 +150,7 @@ export default { this.isATwilioChannel || this.isALineChannel || this.isAPIInbox || + this.isAVoiceChannel || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || this.isAWebWidgetInbox @@ -676,7 +683,7 @@ export default { }}

-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue index e19ad23bb..7559f7c3e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Voice.vue @@ -22,7 +22,6 @@ const state = reactive({ authToken: '', apiKeySid: '', apiKeySecret: '', - twimlAppSid: '', }); const uiFlags = useMapGetter('inboxes/getUIFlags'); @@ -33,7 +32,6 @@ const validationRules = { authToken: { required }, apiKeySid: { required }, apiKeySecret: { required }, - twimlAppSid: { required }, }; const v$ = useVuelidate(validationRules, state); @@ -55,9 +53,6 @@ const formErrors = computed(() => ({ apiKeySecret: v$.value.apiKeySecret?.$error ? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED') : '', - twimlAppSid: v$.value.twimlAppSid?.$error - ? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED') - : '', })); function getProviderConfig() { @@ -67,7 +62,6 @@ function getProviderConfig() { api_key_sid: state.apiKeySid, api_key_secret: state.apiKeySecret, }; - if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid; return config; } @@ -160,17 +154,6 @@ async function createChannel() { @blur="v$.apiKeySecret?.$touch" /> - -
+
+ + + + + + +
+
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 diff --git a/enterprise/app/services/twilio/voice_webhook_setup_service.rb b/enterprise/app/services/twilio/voice_webhook_setup_service.rb new file mode 100644 index 000000000..c1da2f24a --- /dev/null +++ b/enterprise/app/services/twilio/voice_webhook_setup_service.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb index 498c42abd..4291d5091 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb @@ -24,6 +24,9 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do end it 'creates a voice inbox when administrator' do + allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, + perform: "AP#{SecureRandom.hex(16)}")) + post "/api/v1/accounts/#{account.id}/inboxes", headers: admin.create_new_auth_token, params: { name: 'Voice Inbox', @@ -31,7 +34,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do provider_config: { account_sid: "AC#{SecureRandom.hex(16)}", auth_token: SecureRandom.hex(16), api_key_sid: SecureRandom.hex(8), - api_key_secret: SecureRandom.hex(16) } } }, + api_key_secret: SecureRandom.hex(16), + twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } }, as: :json expect(response).to have_http_status(:success) diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb index 2e52807b0..01f4b4c5b 100644 --- a/spec/enterprise/models/channel/voice_spec.rb +++ b/spec/enterprise/models/channel/voice_spec.rb @@ -3,8 +3,13 @@ require 'rails_helper' RSpec.describe Channel::Voice do + let(:twiml_app_sid) { 'AP1234567890abcdef' } let(:channel) { create(:channel_voice) } + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid)) + end + it 'has a valid factory' do expect(channel).to be_valid end @@ -40,12 +45,19 @@ RSpec.describe Channel::Voice do expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider') end + it 'validates presence of twiml_app_sid in provider_config' do + channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' } + expect(channel).not_to be_valid + expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider') + end + it 'is valid with all required provider_config fields' do channel.provider_config = { account_sid: 'test_sid', auth_token: 'test_token', api_key_sid: 'test_key', - api_key_secret: 'test_secret' + api_key_secret: 'test_secret', + twiml_app_sid: 'test_app_sid' } expect(channel).to be_valid end @@ -57,4 +69,11 @@ RSpec.describe Channel::Voice do expect(channel.name).to include(channel.phone_number) end end + + describe 'provisioning on create' do + it 'stores twiml_app_sid in provider_config' do + ch = create(:channel_voice) + expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid) + end + end end diff --git a/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb b/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb new file mode 100644 index 000000000..e31dfeb20 --- /dev/null +++ b/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Twilio::VoiceWebhookSetupService do + let(:account_sid) { 'ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' } + let(:auth_token) { 'auth_token_123' } + let(:api_key_sid) { 'SKaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' } + let(:api_key_secret) { 'api_key_secret_123' } + let(:phone_number) { '+15551230001' } + let(:frontend_url) { 'https://app.chatwoot.test' } + + let(:channel) do + build(:channel_voice, phone_number: phone_number, provider_config: { + account_sid: account_sid, + auth_token: auth_token, + api_key_sid: api_key_sid, + api_key_secret: api_key_secret + }) + end + + let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" } + let(:incoming_numbers_url) { "#{twilio_base_url}/IncomingPhoneNumbers.json" } + let(:applications_url) { "#{twilio_base_url}/Applications.json" } + let(:phone_number_sid) { 'PN123' } + let(:phone_number_url) { "#{twilio_base_url}/IncomingPhoneNumbers/#{phone_number_sid}.json" } + + before do + # Token validation using Account SID + Auth Token + stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/) + .with(basic_auth: [account_sid, auth_token]) + .to_return(status: 200, + body: { incoming_phone_numbers: [], meta: { key: 'incoming_phone_numbers' } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + # Number lookup using API Key SID/Secret + stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/) + .with(basic_auth: [api_key_sid, api_key_secret]) + .to_return(status: 200, + body: { incoming_phone_numbers: [{ sid: phone_number_sid }], meta: { key: 'incoming_phone_numbers' } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + # TwiML App create (voice only) + stub_request(:post, applications_url) + .with(basic_auth: [api_key_sid, api_key_secret]) + .to_return(status: 201, + body: { sid: 'AP123' }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + # Incoming Phone Number webhook update + stub_request(:post, phone_number_url) + .with(basic_auth: [api_key_sid, api_key_secret]) + .to_return(status: 200, + body: { sid: phone_number_sid }.to_json, + headers: { 'Content-Type' => 'application/json' }) + end + + it 'creates a TwiML App and configures number webhooks with correct URLs' do + with_modified_env FRONTEND_URL: frontend_url do + service = described_class.new(channel: channel) + sid = service.perform + + expect(sid).to eq('AP123') + + expected_voice_url = channel.voice_call_webhook_url + expected_status_url = channel.voice_status_webhook_url + + # Assert TwiML App creation body includes voice URL and POST method + expect( + a_request(:post, applications_url) + .with(body: hash_including('VoiceUrl' => expected_voice_url, 'VoiceMethod' => 'POST')) + ).to have_been_made + + # Assert number webhook update body includes both URLs and POST methods + expect( + a_request(:post, phone_number_url) + .with( + body: hash_including( + 'VoiceUrl' => expected_voice_url, + 'VoiceMethod' => 'POST', + 'StatusCallback' => expected_status_url, + 'StatusCallbackMethod' => 'POST' + ) + ) + ).to have_been_made + end + end +end diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb index 33be75f2e..81ad8caaa 100644 --- a/spec/factories/channel/channel_voice.rb +++ b/spec/factories/channel/channel_voice.rb @@ -8,7 +8,8 @@ FactoryBot.define do account_sid: "AC#{SecureRandom.hex(16)}", auth_token: SecureRandom.hex(16), api_key_sid: SecureRandom.hex(8), - api_key_secret: SecureRandom.hex(16) + api_key_secret: SecureRandom.hex(16), + twiml_app_sid: "AP#{SecureRandom.hex(16)}" } end account