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:
parent
d35b0a9465
commit
d48503bdcf
@ -330,13 +330,14 @@
|
|||||||
"LABEL": "API Key Secret",
|
"LABEL": "API Key Secret",
|
||||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||||
"REQUIRED": "API Key Secret is required"
|
"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",
|
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||||
"API": {
|
"API": {
|
||||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||||
|
|||||||
@ -116,16 +116,22 @@ export default {
|
|||||||
key: 'collaborators',
|
key: 'collaborators',
|
||||||
name: this.$t('INBOX_MGMT.TABS.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) {
|
if (this.isAWebWidgetInbox) {
|
||||||
visibleToAllChannelTabs = [
|
visibleToAllChannelTabs = [
|
||||||
...visibleToAllChannelTabs,
|
...visibleToAllChannelTabs,
|
||||||
@ -144,6 +150,7 @@ export default {
|
|||||||
this.isATwilioChannel ||
|
this.isATwilioChannel ||
|
||||||
this.isALineChannel ||
|
this.isALineChannel ||
|
||||||
this.isAPIInbox ||
|
this.isAPIInbox ||
|
||||||
|
this.isAVoiceChannel ||
|
||||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||||
this.shouldShowWhatsAppConfiguration ||
|
this.shouldShowWhatsAppConfiguration ||
|
||||||
this.isAWebWidgetInbox
|
this.isAWebWidgetInbox
|
||||||
@ -676,7 +683,7 @@ export default {
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
<div class="pb-4">
|
<div v-if="!isAVoiceChannel" class="pb-4">
|
||||||
<label>
|
<label>
|
||||||
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
|
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -22,7 +22,6 @@ const state = reactive({
|
|||||||
authToken: '',
|
authToken: '',
|
||||||
apiKeySid: '',
|
apiKeySid: '',
|
||||||
apiKeySecret: '',
|
apiKeySecret: '',
|
||||||
twimlAppSid: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||||
@ -33,7 +32,6 @@ const validationRules = {
|
|||||||
authToken: { required },
|
authToken: { required },
|
||||||
apiKeySid: { required },
|
apiKeySid: { required },
|
||||||
apiKeySecret: { required },
|
apiKeySecret: { required },
|
||||||
twimlAppSid: { required },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const v$ = useVuelidate(validationRules, state);
|
const v$ = useVuelidate(validationRules, state);
|
||||||
@ -55,9 +53,6 @@ const formErrors = computed(() => ({
|
|||||||
apiKeySecret: v$.value.apiKeySecret?.$error
|
apiKeySecret: v$.value.apiKeySecret?.$error
|
||||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
|
? 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() {
|
function getProviderConfig() {
|
||||||
@ -67,7 +62,6 @@ function getProviderConfig() {
|
|||||||
api_key_sid: state.apiKeySid,
|
api_key_sid: state.apiKeySid,
|
||||||
api_key_secret: state.apiKeySecret,
|
api_key_secret: state.apiKeySecret,
|
||||||
};
|
};
|
||||||
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,17 +154,6 @@ async function createChannel() {
|
|||||||
@blur="v$.apiKeySecret?.$touch"
|
@blur="v$.apiKeySecret?.$touch"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
|
||||||
v-model="state.twimlAppSid"
|
|
||||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
|
|
||||||
:placeholder="
|
|
||||||
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
:message="formErrors.twimlAppSid"
|
|
||||||
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
|
|
||||||
@blur="v$.twimlAppSid?.$touch"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<NextButton
|
<NextButton
|
||||||
:is-loading="uiFlags.isCreating"
|
:is-loading="uiFlags.isCreating"
|
||||||
|
|||||||
@ -126,6 +126,25 @@ export default {
|
|||||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||||
|
<SettingsSection
|
||||||
|
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||||
|
:sub-title="
|
||||||
|
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection
|
||||||
|
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||||
|
:sub-title="
|
||||||
|
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="isALineChannel" class="mx-8">
|
<div v-else-if="isALineChannel" class="mx-8">
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||||
|
|||||||
@ -57,15 +57,15 @@ export default {
|
|||||||
isALineChannel() {
|
isALineChannel() {
|
||||||
return this.channelType === INBOX_TYPES.LINE;
|
return this.channelType === INBOX_TYPES.LINE;
|
||||||
},
|
},
|
||||||
|
isAVoiceChannel() {
|
||||||
|
return this.channelType === INBOX_TYPES.VOICE;
|
||||||
|
},
|
||||||
isAnEmailChannel() {
|
isAnEmailChannel() {
|
||||||
return this.channelType === INBOX_TYPES.EMAIL;
|
return this.channelType === INBOX_TYPES.EMAIL;
|
||||||
},
|
},
|
||||||
isATelegramChannel() {
|
isATelegramChannel() {
|
||||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||||
},
|
},
|
||||||
isAVoiceChannel() {
|
|
||||||
return this.channelType === INBOX_TYPES.VOICE;
|
|
||||||
},
|
|
||||||
isATwilioSMSChannel() {
|
isATwilioSMSChannel() {
|
||||||
const { medium: medium = '' } = this.inbox;
|
const { medium: medium = '' } = this.inbox;
|
||||||
return this.isATwilioChannel && medium === 'sms';
|
return this.isATwilioChannel && medium === 'sms';
|
||||||
|
|||||||
@ -118,3 +118,9 @@ if resource.whatsapp?
|
|||||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
## Voice Channel Attributes
|
||||||
|
if resource.channel_type == 'Channel::Voice'
|
||||||
|
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
||||||
|
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||||
|
end
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class Channel::Voice < ApplicationRecord
|
|||||||
|
|
||||||
# Provider-specific configs stored in JSON
|
# Provider-specific configs stored in JSON
|
||||||
validate :validate_provider_config
|
validate :validate_provider_config
|
||||||
|
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
|
||||||
|
|
||||||
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||||
|
|
||||||
@ -41,8 +42,23 @@ class Channel::Voice < ApplicationRecord
|
|||||||
false
|
false
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
|
def twilio?
|
||||||
|
provider == 'twilio'
|
||||||
|
end
|
||||||
|
|
||||||
def validate_provider_config
|
def validate_provider_config
|
||||||
return if provider_config.blank?
|
return if provider_config.blank?
|
||||||
|
|
||||||
@ -54,10 +70,30 @@ class Channel::Voice < ApplicationRecord
|
|||||||
|
|
||||||
def validate_twilio_config
|
def validate_twilio_config
|
||||||
config = provider_config.with_indifferent_access
|
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|
|
required_keys.each do |key|
|
||||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -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
|
||||||
@ -24,6 +24,9 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a voice inbox when administrator' do
|
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",
|
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
params: { name: 'Voice Inbox',
|
params: { name: 'Voice Inbox',
|
||||||
@ -31,7 +34,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
auth_token: SecureRandom.hex(16),
|
auth_token: SecureRandom.hex(16),
|
||||||
api_key_sid: SecureRandom.hex(8),
|
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
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
|
|||||||
@ -3,8 +3,13 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Channel::Voice do
|
RSpec.describe Channel::Voice do
|
||||||
|
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||||
let(:channel) { create(:channel_voice) }
|
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
|
it 'has a valid factory' do
|
||||||
expect(channel).to be_valid
|
expect(channel).to be_valid
|
||||||
end
|
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')
|
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||||
end
|
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
|
it 'is valid with all required provider_config fields' do
|
||||||
channel.provider_config = {
|
channel.provider_config = {
|
||||||
account_sid: 'test_sid',
|
account_sid: 'test_sid',
|
||||||
auth_token: 'test_token',
|
auth_token: 'test_token',
|
||||||
api_key_sid: 'test_key',
|
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
|
expect(channel).to be_valid
|
||||||
end
|
end
|
||||||
@ -57,4 +69,11 @@ RSpec.describe Channel::Voice do
|
|||||||
expect(channel.name).to include(channel.phone_number)
|
expect(channel.name).to include(channel.phone_number)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -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
|
||||||
@ -8,7 +8,8 @@ FactoryBot.define do
|
|||||||
account_sid: "AC#{SecureRandom.hex(16)}",
|
account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
auth_token: SecureRandom.hex(16),
|
auth_token: SecureRandom.hex(16),
|
||||||
api_key_sid: SecureRandom.hex(8),
|
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
|
end
|
||||||
account
|
account
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user