diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index e9183ce9b..b258dc763 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; @@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const voiceInboxes = computed(() => - (inboxesList.value || []).filter( - inbox => inbox.channel_type === INBOX_TYPES.VOICE - ) + (inboxesList.value || []).filter(isVoiceCallEnabled) ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index e8f484997..22c142322 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import FileUpload from 'vue-upload-component'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; - import Button from 'dashboard/components-next/button/Button.vue'; import WhatsAppOptions from './WhatsAppOptions.vue'; import ContentTemplateSelector from './ContentTemplateSelector.vue'; @@ -30,6 +28,7 @@ const props = defineProps({ isDropdownActive: { type: Boolean, default: false }, messageSignature: { type: String, default: '' }, inboxId: { type: Number, default: null }, + voiceEnabled: { type: Boolean, default: false }, }); const emit = defineEmits([ @@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => { return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; }); -const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE); - const shouldShowSignatureButton = computed(() => { return ( - props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value + props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled ); }); @@ -111,7 +108,7 @@ watch( () => props.hasSelectedInbox, newValue => { nextTick(() => { - if (newValue && !isVoiceInbox.value) setSignature(); + if (newValue && !props.voiceEnabled) setSignature(); }); }, { immediate: true } diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index c4111b481..f7813332f 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -2,7 +2,7 @@ import { ref, computed } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { required, requiredIf } from '@vuelidate/validators'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { appendSignature, removeSignature, @@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); const inboxMedium = computed(() => props.targetInbox?.medium || ''); +const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox)); + const effectiveChannelType = computed(() => getEffectiveChannelType(inboxChannelType.value, inboxMedium.value) ); @@ -442,6 +444,7 @@ useKeyboardEvents({ :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" :message-templates="whatsappMessageTemplates" :channel-type="inboxChannelType" + :voice-enabled="voiceCallEnabled" :is-loading="isCreating" :disable-send-button="isCreating" :has-selected-inbox="!!targetInbox" diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index a5b99f84f..d7a9c93ad 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -1,4 +1,5 @@ import { computed } from 'vue'; +import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; export function useChannelIcon(inbox) { const channelTypeIconMap = { @@ -14,7 +15,6 @@ export function useChannelIcon(inbox) { 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', 'Channel::Tiktok': 'i-woot-tiktok', - 'Channel::Voice': 'i-woot-voice', }; const providerIconMap = { @@ -38,6 +38,11 @@ export function useChannelIcon(inbox) { icon = 'i-woot-whatsapp'; } + // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.) + if (isVoiceCallEnabled(inboxDetails)) { + icon = 'i-woot-voice'; + } + return icon ?? 'i-ri-global-fill'; }); diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index 8bccd84cc..38c001fae 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -19,8 +19,11 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-woot-whatsapp'); }); - it('returns correct icon for Voice channel', () => { - const inbox = { channel_type: 'Channel::Voice' }; + it('returns correct icon for voice-enabled Twilio channel', () => { + const inbox = { + channel_type: 'Channel::TwilioSms', + voice_enabled: true, + }; const { value: icon } = useChannelIcon(inbox); expect(icon).toBe('i-woot-voice'); }); diff --git a/app/javascript/dashboard/composables/spec/useInbox.spec.js b/app/javascript/dashboard/composables/spec/useInbox.spec.js index 71472a35b..1c01032d1 100644 --- a/app/javascript/dashboard/composables/spec/useInbox.spec.js +++ b/app/javascript/dashboard/composables/spec/useInbox.spec.js @@ -47,7 +47,11 @@ const mockStore = createStore({ 11: { id: 11, channel_type: INBOX_TYPES.API }, 12: { id: 12, channel_type: INBOX_TYPES.SMS }, 13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM }, - 14: { id: 14, channel_type: INBOX_TYPES.VOICE }, + 14: { + id: 14, + channel_type: INBOX_TYPES.TWILIO, + voice_enabled: true, + }, 15: { id: 15, channel_type: INBOX_TYPES.TIKTOK }, }; return inboxes[id] || null; @@ -211,11 +215,11 @@ describe('useInbox', () => { }); expect(wrapper.vm.isAnInstagramChannel).toBe(true); - // Test Voice + // Test Voice (Twilio with voice_enabled) wrapper = mount(createTestComponent(14), { global: { plugins: [mockStore] }, }); - expect(wrapper.vm.isAVoiceChannel).toBe(true); + expect(wrapper.vm.voiceCallEnabled).toBe(true); // Test Tiktok wrapper = mount(createTestComponent(15), { @@ -274,7 +278,8 @@ describe('useInbox', () => { 'isAnEmailChannel', 'isAnInstagramChannel', 'isATiktokChannel', - 'isAVoiceChannel', + 'voiceCallEnabled', + 'voiceCallProvider', ]; expectedProperties.forEach(prop => { diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 632d3556b..6e8d0a52a 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -1,7 +1,11 @@ import { computed } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; import { useCamelCase } from 'dashboard/composables/useTransformKeys'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { + INBOX_TYPES, + isVoiceCallEnabled, + getVoiceCallProvider, +} from 'dashboard/helper/inbox'; export const INBOX_FEATURES = { REPLY_TO: 'replyTo', @@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => { return channelType.value === INBOX_TYPES.TIKTOK; }); - const isAVoiceChannel = computed(() => { - return channelType.value === INBOX_TYPES.VOICE; - }); + const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); + + const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); return { inbox, @@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, - isAVoiceChannel, + voiceCallEnabled, + voiceCallProvider, }; }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index fd02461a8..378d303b4 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -109,11 +109,6 @@ export const FORMATTING = { 'redo', ], }, - 'Channel::Voice': { - marks: [], - nodes: [], - menu: [], - }, 'Channel::Tiktok': { marks: [], nodes: [], diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 0cc67db67..1f9632425 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -36,6 +36,7 @@ export const FEATURE_FLAGS = { CHATWOOT_V4: 'chatwoot_v4', CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_TIKTOK: 'channel_tiktok', + CHANNEL_VOICE: 'channel_voice', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CAPTAIN_CUSTOM_TOOLS: 'custom_tools', CAPTAIN_V2: 'captain_integration_v2', diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 401739706..4039a07d1 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,9 +11,29 @@ export const INBOX_TYPES = { SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', TIKTOK: 'Channel::Tiktok', - VOICE: 'Channel::Voice', }; +// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) +export const VOICE_CALL_PROVIDERS = { + TWILIO: 'twilio', +}; + +export const getVoiceCallProvider = inbox => { + if (!inbox) return null; + + // Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes. + const channelType = inbox.channel_type || inbox.channelType; + const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled; + + if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) { + return VOICE_CALL_PROVIDERS.TWILIO; + } + + return null; +}; + +export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null; + export const TWILIO_CHANNEL_MEDIUM = { WHATSAPP: 'whatsapp', SMS: 'sms', @@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', - [INBOX_TYPES.VOICE]: 'i-ri-phone-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.TELEGRAM]: 'i-woot-telegram', [INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', - [INBOX_TYPES.VOICE]: 'i-woot-voice', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', }; @@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => { case INBOX_TYPES.TWILIO: case INBOX_TYPES.WHATSAPP: - case INBOX_TYPES.VOICE: return phoneNumber || ''; case INBOX_TYPES.EMAIL: @@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; - case INBOX_TYPES.VOICE: - return 'voice'; - default: return 'chat'; } @@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => { case INBOX_TYPES.TIKTOK: return 'brand-tiktok'; - case INBOX_TYPES.VOICE: - return 'phone'; - default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 6b1ff20b1..24207cef2 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -636,7 +636,17 @@ "WIDGET_BUILDER": "Widget Builder", "BOT_CONFIGURATION": "Bot Configuration", "ACCOUNT_HEALTH": "Account Health", - "CSAT": "CSAT" + "CSAT": "CSAT", + "VOICE": "Voice" + }, + "VOICE_CONFIGURATION": { + "ENABLE_VOICE": { + "LABEL": "Enable Voice Calling", + "DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls." + }, + "CREDENTIALS": { + "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." + } }, "CHANNEL_PREFERENCES": "Channel Preferences", "WIDGET_FEATURES": "Widget features", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index 838d3ac0b..884c198c4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -145,6 +145,7 @@ const openDelete = inbox => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 11eb1f6aa..a26eb0e18 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import ConfigurationPage from './settingsPage/ConfigurationPage.vue'; +import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue'; import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue'; import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import BotConfiguration from './components/BotConfiguration.vue'; @@ -46,6 +47,7 @@ export default { BotConfiguration, CollaboratorsPage, ConfigurationPage, + VoiceConfigurationPage, CustomerSatisfactionPage, FacebookReauthorize, GreetingsEditor, @@ -169,19 +171,17 @@ export default { }, ]; - if (!this.isAVoiceChannel) { - visibleToAllChannelTabs = [ - ...visibleToAllChannelTabs, - { - key: 'business-hours', - name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), - }, - { - key: 'csat', - name: this.$t('INBOX_MGMT.TABS.CSAT'), - }, - ]; - } + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'business-hours', + name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), + }, + { + key: 'csat', + name: this.$t('INBOX_MGMT.TABS.CSAT'), + }, + ]; if (this.isAWebWidgetInbox) { visibleToAllChannelTabs = [ @@ -197,7 +197,6 @@ export default { this.isATwilioChannel || this.isALineChannel || this.isAPIInbox || - this.isAVoiceChannel || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || this.isAWebWidgetInbox @@ -232,6 +231,24 @@ export default { ]; } + if ( + this.isATwilioChannel && + this.inbox.phone_number && + this.inbox.medium === 'sms' && + this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.CHANNEL_VOICE + ) + ) { + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'voice-configuration', + name: this.$t('INBOX_MGMT.TABS.VOICE'), + }, + ]; + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -812,7 +829,6 @@ export default { @@ -1240,6 +1256,12 @@ export default { > +
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 1804e1224..711217f32 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -12,6 +12,10 @@ const props = defineProps({ type: String, default: '', }, + voiceEnabled: { + type: Boolean, + default: false, + }, }); const getters = useStoreGetters(); const { t } = useI18n(); @@ -30,7 +34,6 @@ const i18nMap = { 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', - 'Channel::Voice': 'VOICE', }; const twilioChannelName = () => { @@ -45,6 +48,9 @@ const readableChannelName = computed(() => { return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API'); } if (props.channelType === 'Channel::TwilioSms') { + if (props.voiceEnabled) { + return t('INBOX_MGMT.CHANNELS.VOICE'); + } return twilioChannelName(); } return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 0bfabb7b6..9cd1665bf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -208,24 +208,6 @@ export default {
-
- - - - - - -
+import { useAlert } from 'dashboard/composables'; +import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue'; +import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue'; +import NextInput from 'dashboard/components-next/input/Input.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; + +export default { + components: { + SettingsFieldSection, + SettingsToggleSection, + NextInput, + NextButton, + }, + props: { + inbox: { + type: Object, + default: () => ({}), + }, + }, + data() { + return { + voiceEnabled: this.inbox.voice_enabled || false, + apiKeySid: this.inbox.api_key_sid || '', + apiKeySecret: '', + isUpdating: false, + }; + }, + computed: { + isVoiceConfigured() { + return !!this.inbox.voice_configured; + }, + hasApiKeySid() { + return !!this.inbox.api_key_sid; + }, + hasExistingCredentials() { + return this.hasApiKeySid && !!this.inbox.has_api_key_secret; + }, + needsCredentials() { + return ( + this.voiceEnabled && + !this.isVoiceConfigured && + !this.hasExistingCredentials + ); + }, + needsApiKeySid() { + return this.needsCredentials && !this.hasApiKeySid; + }, + isSubmitDisabled() { + if (!this.voiceEnabled) return false; + if (this.needsCredentials) { + if (this.needsApiKeySid && !this.apiKeySid) return true; + return !this.apiKeySecret; + } + return false; + }, + }, + watch: { + 'inbox.voice_enabled'(val) { + this.voiceEnabled = val || false; + }, + 'inbox.api_key_sid'(val) { + this.apiKeySid = val || ''; + }, + }, + methods: { + async updateVoiceSettings() { + this.isUpdating = true; + try { + const channelPayload = { voice_enabled: this.voiceEnabled }; + + if (this.needsCredentials) { + if (this.needsApiKeySid) { + channelPayload.api_key_sid = this.apiKeySid; + } + channelPayload.api_key_secret = this.apiKeySecret; + } + + await this.$store.dispatch('inboxes/updateInbox', { + id: this.inbox.id, + formData: false, + channel: channelPayload, + }); + this.apiKeySecret = ''; + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } finally { + this.isUpdating = false; + } + }, + }, +}; + + + diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 3fe04dc11..40579125b 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -1,4 +1,4 @@ -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; export const INBOX_FEATURES = { REPLY_TO: 'replyTo', @@ -59,8 +59,8 @@ export default { isALineChannel() { return this.channelType === INBOX_TYPES.LINE; }, - isAVoiceChannel() { - return this.channelType === INBOX_TYPES.VOICE; + voiceCallEnabled() { + return isVoiceCallEnabled(this.inbox); }, isAnEmailChannel() { return this.channelType === INBOX_TYPES.EMAIL; diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 2f9130cbb..e288361ea 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -4,6 +4,7 @@ # # id :bigint not null, primary key # account_sid :string not null +# api_key_secret :string # api_key_sid :string # auth_token :string not null # content_templates :jsonb @@ -11,6 +12,8 @@ # medium :integer default("sms") # messaging_service_sid :string # phone_number :string +# twiml_app_sid :string +# voice_enabled :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null @@ -58,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord client.messages.create(**params) end - private - def client if api_key_sid.present? Twilio::REST::Client.new(api_key_sid, auth_token, account_sid) @@ -68,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord end end + private + def send_message_from if messaging_service_sid? { messaging_service_sid: messaging_service_sid } @@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord end end end + +Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms') diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 619e6a28c..e4c19fea0 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -72,6 +72,7 @@ if resource.twilio? if Current.account_user&.administrator? json.auth_token resource.channel.try(:auth_token) json.account_sid resource.channel.try(:account_sid) + json.api_key_sid resource.channel.try(:api_key_sid) end end @@ -131,8 +132,13 @@ if resource.whatsapp? json.reauthorization_required resource.channel.try(:reauthorization_required?) 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) +## Voice attributes for TwilioSms +if resource.twilio? && resource.channel.respond_to?(:voice_enabled?) + json.voice_enabled resource.channel.voice_enabled? + json.voice_configured resource.channel.try(:twiml_app_sid).present? + json.has_api_key_secret resource.channel.try(:api_key_secret).present? + if resource.channel.try(:twiml_app_sid).present? + 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 end diff --git a/db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb b/db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb new file mode 100644 index 000000000..0051f7614 --- /dev/null +++ b/db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb @@ -0,0 +1,7 @@ +class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0] + def change + add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false + add_column :channel_twilio_sms, :twiml_app_sid, :string + add_column :channel_twilio_sms, :api_key_secret, :string + end +end diff --git a/db/migrate/20260326120001_drop_channel_voice.rb b/db/migrate/20260326120001_drop_channel_voice.rb new file mode 100644 index 000000000..6da514226 --- /dev/null +++ b/db/migrate/20260326120001_drop_channel_voice.rb @@ -0,0 +1,20 @@ +class DropChannelVoice < ActiveRecord::Migration[7.0] + def up + drop_table :channel_voice, if_exists: true + end + + def down + create_table :channel_voice do |t| + t.string :phone_number, null: false + t.string :provider, null: false, default: 'twilio' + t.jsonb :provider_config, null: false + t.integer :account_id, null: false + t.jsonb :additional_attributes, default: {} + + t.timestamps + end + + add_index :channel_voice, :phone_number, unique: true + add_index :channel_voice, :account_id + end +end diff --git a/db/schema.rb b/db/schema.rb index bce190760..16be9e111 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -552,6 +552,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do t.string "api_key_sid" t.jsonb "content_templates", default: {} t.datetime "content_templates_last_updated" + t.boolean "voice_enabled", default: false, null: false + t.string "twiml_app_sid" + t.string "api_key_secret" t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true @@ -568,18 +571,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true end - create_table "channel_voice", force: :cascade do |t| - t.string "phone_number", null: false - t.string "provider", default: "twilio", null: false - t.jsonb "provider_config", null: false - t.integer "account_id", null: false - t.jsonb "additional_attributes", default: {} - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_channel_voice_on_account_id" - t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true - end - create_table "channel_web_widgets", id: :serial, force: :cascade do |t| t.string "website_url" t.integer "account_id" diff --git a/enterprise/app/builders/enterprise/contact_inbox_builder.rb b/enterprise/app/builders/enterprise/contact_inbox_builder.rb index 043505f7f..e7ab48fd5 100644 --- a/enterprise/app/builders/enterprise/contact_inbox_builder.rb +++ b/enterprise/app/builders/enterprise/contact_inbox_builder.rb @@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder private def generate_source_id - return super unless @inbox.channel_type == 'Channel::Voice' + return super unless twilio_voice_inbox? phone_source_id end def phone_source_id - return super unless @inbox.channel_type == 'Channel::Voice' + return super unless twilio_voice_inbox? return SecureRandom.uuid if @contact.phone_number.blank? @@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder end def allowed_channels? - super || @inbox.channel_type == 'Channel::Voice' + super || twilio_voice_inbox? + end + + def twilio_voice_inbox? + @inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled? end end diff --git a/enterprise/app/builders/enterprise/messages/message_builder.rb b/enterprise/app/builders/enterprise/messages/message_builder.rb index 727248956..06bd0357c 100644 --- a/enterprise/app/builders/enterprise/messages/message_builder.rb +++ b/enterprise/app/builders/enterprise/messages/message_builder.rb @@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder private def message_type - return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice' + return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call' super end + + def twilio_voice_inbox? + inbox = @conversation.inbox + inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled? + end end diff --git a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb index bcdd178fe..11a352325 100644 --- a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb @@ -30,9 +30,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont end def voice_inbox - @voice_inbox ||= Current.user.assigned_inboxes.where( - account_id: Current.account.id, - channel_type: 'Channel::Voice' - ).find(params.require(:inbox_id)) + @voice_inbox ||= begin + inbox = Current.user.assigned_inboxes.where( + account_id: Current.account.id, + channel_type: 'Channel::TwilioSms' + ).find(params.require(:inbox_id)) + raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled? + + inbox + end end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb index 396c3a91d..f9d828806 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb @@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController end def channel_type_from_params - case permitted_params[:channel][:type] - when 'voice' - Channel::Voice - else - super - end + return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice' + + super end def account_channels_method - case permitted_params[:channel][:type] - when 'voice' - Current.account.voice_channels - else - super - end + return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice' + + super + end + + def create_channel + return create_voice_channel if permitted_params[:channel][:type] == 'voice' + + super + end + + def get_channel_attributes(channel_type) + attrs = super + attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms' + attrs + end + + def create_voice_channel + raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice') + + voice_params = params.require(:channel).permit( + :phone_number, :provider, + provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret] + ) + config = voice_params[:provider_config] || {} + + Current.account.twilio_sms.create!( + phone_number: voice_params[:phone_number], + account_sid: config[:account_sid], + auth_token: config[:auth_token], + api_key_sid: config[:api_key_sid], + api_key_secret: config[:api_key_secret], + medium: :sms, + voice_enabled: true + ) end end diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index aa2696b31..a8555b373 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -169,8 +169,10 @@ class Twilio::VoiceController < ApplicationController def set_inbox! digits = params[:phone].to_s.gsub(/\D/, '') - e164 = "+#{digits}" - channel = Channel::Voice.find_by!(phone_number: e164) + 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 diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb deleted file mode 100644 index dbb9931df..000000000 --- a/enterprise/app/models/channel/voice.rb +++ /dev/null @@ -1,122 +0,0 @@ -# == Schema Information -# -# Table name: channel_voice -# -# id :bigint not null, primary key -# additional_attributes :jsonb -# phone_number :string not null -# provider :string default("twilio"), not null -# provider_config :jsonb not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null -# -# Indexes -# -# index_channel_voice_on_account_id (account_id) -# index_channel_voice_on_phone_number (phone_number) UNIQUE -# -class Channel::Voice < ApplicationRecord - include Channelable - - self.table_name = 'channel_voice' - - validates :phone_number, presence: true, uniqueness: true - validates :provider, presence: true - validates :provider_config, presence: true - - # Validate phone number format (E.164 format) - validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ } - - # 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 - - def name - "Voice (#{phone_number})" - end - - def messaging_window_enabled? - false - end - - def initiate_call(to:, conference_sid: nil, agent_id: nil) - case provider - when 'twilio' - Voice::Provider::Twilio::Adapter.new(self).initiate_call( - to: to, - conference_sid: conference_sid, - agent_id: agent_id - ) - else - raise "Unsupported voice provider: #{provider}" - end - end - - # Public URLs used to configure Twilio webhooks - def voice_call_webhook_url - digits = phone_number.delete_prefix('+') - Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) - end - - def voice_status_webhook_url - digits = phone_number.delete_prefix('+') - Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits) - end - - private - - def twilio? - provider == 'twilio' - end - - def validate_provider_config - return if provider_config.blank? - - case provider - when 'twilio' - validate_twilio_config - end - end - - def validate_twilio_config - config = provider_config.with_indifferent_access - # 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 provider_config_hash - if provider_config.is_a?(Hash) - provider_config - else - JSON.parse(provider_config.to_s) - 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 - - public :provider_config_hash -end diff --git a/enterprise/app/models/enterprise/channel/twilio_sms.rb b/enterprise/app/models/enterprise/channel/twilio_sms.rb new file mode 100644 index 000000000..15c201795 --- /dev/null +++ b/enterprise/app/models/enterprise/channel/twilio_sms.rb @@ -0,0 +1,81 @@ +module Enterprise::Channel::TwilioSms + extend ActiveSupport::Concern + + def self.prepended(base) + base.class_eval do + encrypts :api_key_secret if Chatwoot.encryption_configured? + + validate :voice_requires_phone_number, if: :voice_enabled? + before_validation :provision_twiml_app, on: :create, if: :voice_enabled? + before_validation :provision_twiml_app_on_update, on: :update, if: :voice_enabled_changed_to_true? + after_commit :teardown_voice, on: :update, if: :voice_disabled? + end + end + + def initiate_call(to:, conference_sid: nil, agent_id: nil) + Voice::Provider::Twilio::Adapter.new(self).initiate_call( + to: to, + conference_sid: conference_sid, + agent_id: agent_id + ) + end + + def voice_call_webhook_url + digits = phone_number.delete_prefix('+') + Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) + end + + def voice_status_webhook_url + digits = phone_number.delete_prefix('+') + Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits) + end + + # Voice channels store the secret in api_key_secret; SMS channels keep using auth_token via super. + def client + if api_key_sid.present? && api_key_secret.present? + Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid) + else + super + end + end + + private + + def voice_requires_phone_number + return if phone_number.present? + + errors.add(:base, 'Voice calling requires a phone number and cannot be used with messaging service SID') + end + + def voice_enabled_changed_to_true? + voice_enabled? && voice_enabled_changed? + end + + def voice_disabled? + !voice_enabled? && voice_enabled_previously_changed? + end + + def teardown_voice + Twilio::VoiceTeardownService.new(channel: self).perform + end + + def provision_twiml_app + return if twiml_app_sid.present? + return if phone_number.blank? + + validate_voice_capability! + service = ::Twilio::VoiceWebhookSetupService.new(channel: self) + self.twiml_app_sid = service.perform + rescue StandardError => e + Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{e.class} #{e.message} phone=#{phone_number} account=#{account_id}") + errors.add(:base, "Twilio voice setup failed: #{e.message}") + end + + def validate_voice_capability! + number = client.incoming_phone_numbers.list(phone_number: phone_number).first + raise 'Phone number not found in Twilio account' unless number + raise 'This phone number does not support voice calls' unless number.capabilities['voice'] + end + + alias provision_twiml_app_on_update provision_twiml_app +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 427b5245b..1ef112fb5 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -16,7 +16,6 @@ module Enterprise::Concerns::Account has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async - has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' has_many :calls, dependent: :destroy_async has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings' diff --git a/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb b/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb index 546d5f4bc..ce6e2a64b 100644 --- a/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb +++ b/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb @@ -1,9 +1,9 @@ module Enterprise::Contacts::ContactableInboxesService private - # Extend base selection to include Voice inboxes + # Extend base selection to include voice-enabled TwilioSms inboxes def get_contactable_inbox(inbox) - return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice' + return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled? super end diff --git a/enterprise/app/services/twilio/voice_teardown_service.rb b/enterprise/app/services/twilio/voice_teardown_service.rb new file mode 100644 index 000000000..dec365373 --- /dev/null +++ b/enterprise/app/services/twilio/voice_teardown_service.rb @@ -0,0 +1,33 @@ +class Twilio::VoiceTeardownService + pattr_initialize [:channel!] + + def perform + delete_twiml_app if channel.twiml_app_sid.present? + clear_number_webhooks + ensure + clear_voice_credentials + end + + private + + def delete_twiml_app + channel.client.applications(channel.twiml_app_sid).delete + rescue StandardError => e + Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}") + end + + def clear_number_webhooks + numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number) + return if numbers.empty? + + channel.client + .incoming_phone_numbers(numbers.first.sid) + .update(voice_url: '', status_callback: '') + rescue StandardError => e + Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}") + end + + def clear_voice_credentials + channel.update(twiml_app_sid: nil) + end +end diff --git a/enterprise/app/services/twilio/voice_webhook_setup_service.rb b/enterprise/app/services/twilio/voice_webhook_setup_service.rb index c1da2f24a..e9a679b36 100644 --- a/enterprise/app/services/twilio/voice_webhook_setup_service.rb +++ b/enterprise/app/services/twilio/voice_webhook_setup_service.rb @@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService private def validate_token_credentials! - # Only validate Account SID + Auth Token - token_client.incoming_phone_numbers.list(limit: 1) + channel.client.incoming_phone_numbers.list(limit: 1) rescue StandardError => e log_twilio_error('AUTH_VALIDATION_TOKEN', e) raise @@ -26,7 +25,7 @@ class Twilio::VoiceWebhookSetupService def create_twiml_app! friendly_name = "Chatwoot Voice #{channel.phone_number}" - app = api_key_client.applications.create( + app = channel.client.applications.create( friendly_name: friendly_name, voice_url: channel.voice_call_webhook_url, voice_method: HTTP_METHOD @@ -38,39 +37,25 @@ class Twilio::VoiceWebhookSetupService end def configure_number_webhooks! - numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number) + numbers = channel.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 - ) + channel.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) @@ -80,11 +65,10 @@ class Twilio::VoiceWebhookSetupService 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], + account_sid: channel.account_sid, error_class: error.class.to_s, message: error.message } diff --git a/enterprise/app/services/voice/provider/twilio/adapter.rb b/enterprise/app/services/voice/provider/twilio/adapter.rb index 061143f03..b72ed3369 100644 --- a/enterprise/app/services/voice/provider/twilio/adapter.rb +++ b/enterprise/app/services/voice/provider/twilio/adapter.rb @@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter end def twilio_client - Twilio::REST::Client.new(config['account_sid'], config['auth_token']) - end - - def config - @config ||= @channel.provider_config_hash + Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token) end end diff --git a/enterprise/app/services/voice/provider/twilio/conference_service.rb b/enterprise/app/services/voice/provider/twilio/conference_service.rb index 5daea8733..94e75d89a 100644 --- a/enterprise/app/services/voice/provider/twilio/conference_service.rb +++ b/enterprise/app/services/voice/provider/twilio/conference_service.rb @@ -1,5 +1,5 @@ class Voice::Provider::Twilio::ConferenceService - pattr_initialize [:conversation!, { twilio_client: nil }] + pattr_initialize [:conversation!] def ensure_conference_sid existing = conversation.additional_attributes&.dig('conference_sid') @@ -19,10 +19,11 @@ class Voice::Provider::Twilio::ConferenceService end def end_conference - twilio_client + client = conversation.inbox.channel.client + client .conferences .list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress') - .each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') } + .each { |conf| client.conferences(conf.sid).update(status: 'completed') } end private @@ -31,16 +32,4 @@ class Voice::Provider::Twilio::ConferenceService current = conversation.additional_attributes || {} conversation.update!(additional_attributes: current.merge(attrs)) end - - def twilio_client - @twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token) - end - - def account_sid - @account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid'] - end - - def auth_token - @auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token'] - end end diff --git a/enterprise/app/services/voice/provider/twilio/token_service.rb b/enterprise/app/services/voice/provider/twilio/token_service.rb index cee4c1887..a2769b587 100644 --- a/enterprise/app/services/voice/provider/twilio/token_service.rb +++ b/enterprise/app/services/voice/provider/twilio/token_service.rb @@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService token: access_token.to_jwt, identity: identity, voice_enabled: true, - account_sid: config['account_sid'], + account_sid: channel.account_sid, agent_id: user.id, account_id: account.id, inbox_id: inbox.id, - phone_number: inbox.channel.phone_number, + phone_number: channel.phone_number, twiml_endpoint: twiml_url, - has_twiml_app: config['twiml_app_sid'].present? + has_twiml_app: channel.twiml_app_sid.present? } end private - def config - @config ||= inbox.channel.provider_config_hash || {} + def channel + @channel ||= inbox.channel end def identity @@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService def access_token Twilio::JWT::AccessToken.new( - config['account_sid'], - config['api_key_sid'], - config['api_key_secret'], + channel.account_sid, + channel.api_key_sid, + channel.api_key_secret, identity: identity, ttl: 1.hour.to_i ).tap { |token| token.add_grant(voice_grant) } @@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService def voice_grant Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant| grant.incoming_allow = true - grant.outgoing_application_sid = config['twiml_app_sid'] + grant.outgoing_application_sid = channel.twiml_app_sid grant.outgoing_application_params = outgoing_params end end @@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService agent_id: user.id, identity: identity, client_name: identity, - accountSid: config['account_sid'], + accountSid: channel.account_sid, is_agent: 'true' } end def twiml_url - digits = inbox.channel.phone_number.delete_prefix('+') + digits = channel.phone_number.delete_prefix('+') Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) end end diff --git a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb index f4949b9b8..f3019910a 100644 --- a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do let(:account) { create(:account) } - let(:voice_channel) { create(:channel_voice, account: account) } + let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:voice_inbox) { voice_channel.inbox } let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) } let(:admin) { create(:user, :administrator, account: account) } 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 4291d5091..709a8da22 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,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do end it 'creates a voice inbox when administrator' do + account.enable_features('channel_voice') + account.save! + stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json}) + .to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) @@ -34,8 +39,7 @@ 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), - twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } }, + api_key_secret: SecureRandom.hex(16) } } }, as: :json expect(response).to have_http_status(:success) diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb index 43141414e..c04f5ae41 100644 --- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe 'Twilio::VoiceController', type: :request do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') } let(:inbox) { channel.inbox } let(:digits) { channel.phone_number.delete_prefix('+') } diff --git a/spec/enterprise/models/channel/twilio_sms_voice_spec.rb b/spec/enterprise/models/channel/twilio_sms_voice_spec.rb new file mode 100644 index 000000000..b2d5dba48 --- /dev/null +++ b/spec/enterprise/models/channel/twilio_sms_voice_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Channel::TwilioSms do + let(:account) { create(:account) } + let(:twiml_app_sid) { 'AP1234567890abcdef' } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid)) + end + + describe 'factory' do + it 'has a valid :with_voice factory' do + channel = create(:channel_twilio_sms, :with_voice, account: account) + expect(channel).to be_valid + expect(channel.voice_enabled?).to be true + end + end + + describe 'validations' do + it 'requires a phone number when voice is enabled' do + channel = build(:channel_twilio_sms, :with_voice, account: account, phone_number: nil) + channel.valid? + expect(channel.errors[:base]).to include('Voice calling requires a phone number and cannot be used with messaging service SID') + end + end + + describe '#voice_enabled?' do + it 'returns true when voice_enabled is set' do + channel = create(:channel_twilio_sms, :with_voice, account: account) + expect(channel.voice_enabled?).to be true + end + + it 'returns false by default' do + channel = create(:channel_twilio_sms, account: account) + expect(channel.voice_enabled?).to be false + end + end + + describe '#voice_call_webhook_url' do + it 'returns the webhook URL based on phone number' do + channel = create(:channel_twilio_sms, :with_voice) + digits = channel.phone_number.delete_prefix('+') + expect(channel.voice_call_webhook_url).to include(digits) + end + end + + describe '#voice_status_webhook_url' do + it 'returns the status webhook URL based on phone number' do + channel = create(:channel_twilio_sms, :with_voice) + digits = channel.phone_number.delete_prefix('+') + expect(channel.voice_status_webhook_url).to include(digits) + end + end + + describe 'provisioning on create' do + it 'stores twiml_app_sid from the webhook setup service' do + stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json}) + .to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + channel = create(:channel_twilio_sms, :with_voice, twiml_app_sid: nil) + expect(channel.twiml_app_sid).to eq(twiml_app_sid) + end + end + + describe 'teardown on disable' do + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } + let(:app_context) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) } + let(:twilio_client) { instance_double(Twilio::REST::Client) } + let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) } + + before do + allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client) + allow(twilio_client).to receive(:applications).with(channel.twiml_app_sid).and_return(app_context) + allow(app_context).to receive(:delete) + allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list) + allow(numbers_list).to receive(:list).with(phone_number: channel.phone_number).and_return([]) + end + + it 'deletes the TwiML app and clears twiml_app_sid' do + original_twiml_sid = channel.twiml_app_sid + channel.update!(voice_enabled: false) + + expect(twilio_client).to have_received(:applications).with(original_twiml_sid) + expect(app_context).to have_received(:delete) + expect(channel.reload.twiml_app_sid).to be_nil + end + + it 'preserves api_key_sid and api_key_secret' do + channel.update!(voice_enabled: false) + expect(channel.reload.api_key_sid).to be_present + expect(channel.reload.api_key_secret).to be_present + end + + it 'does not fail if Twilio API errors' do + allow(app_context).to receive(:delete).and_raise(StandardError.new('Not found')) + + expect { channel.update!(voice_enabled: false) }.not_to raise_error + expect(channel.reload.twiml_app_sid).to be_nil + end + end +end diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb deleted file mode 100644 index 01f4b4c5b..000000000 --- a/spec/enterprise/models/channel/voice_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -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 - - describe 'validations' do - it 'validates presence of provider_config' do - channel.provider_config = nil - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include("can't be blank") - end - - it 'validates presence of account_sid in provider_config' do - channel.provider_config = { auth_token: 'token' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider') - end - - it 'validates presence of auth_token in provider_config' do - channel.provider_config = { account_sid: 'sid' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider') - end - - it 'validates presence of api_key_sid in provider_config' do - channel.provider_config = { account_sid: 'sid', auth_token: 'token' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider') - end - - it 'validates presence of api_key_secret in provider_config' do - channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' } - expect(channel).not_to be_valid - 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', - twiml_app_sid: 'test_app_sid' - } - expect(channel).to be_valid - end - end - - describe '#name' do - it 'returns Voice with phone number' do - expect(channel.name).to include('Voice') - 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 index e31dfeb20..6c47fa325 100644 --- a/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb +++ b/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb @@ -9,14 +9,16 @@ RSpec.describe Twilio::VoiceWebhookSetupService do let(:api_key_secret) { 'api_key_secret_123' } let(:phone_number) { '+15551230001' } let(:frontend_url) { 'https://app.chatwoot.test' } + let(:account) { create(:account) } 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 - }) + build(:channel_twilio_sms, :with_voice, + account: account, + phone_number: phone_number, + 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}" } diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb index e8953da3c..a021c3a9c 100644 --- a/spec/enterprise/services/voice/inbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Voice::InboundCallBuilder do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') } let(:inbox) { channel.inbox } let(:from_number) { '+15550001111' } let(:to_number) { channel.phone_number } diff --git a/spec/enterprise/services/voice/outbound_call_builder_spec.rb b/spec/enterprise/services/voice/outbound_call_builder_spec.rb index fb75b404b..13d667925 100644 --- a/spec/enterprise/services/voice/outbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/outbound_call_builder_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Voice::OutboundCallBuilder do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') } let(:inbox) { channel.inbox } let(:user) { create(:user, account: account) } let(:contact) { create(:contact, account: account, phone_number: '+15550001111') } diff --git a/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb b/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb index 68157ac50..62513c2a3 100644 --- a/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb +++ b/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' describe Voice::Provider::Twilio::Adapter do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account) } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:adapter) { described_class.new(channel) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) } @@ -19,7 +19,7 @@ describe Voice::Provider::Twilio::Adapter do allow(calls_double).to receive(:create).and_return(call_instance) allow(Twilio::REST::Client).to receive(:new) - .with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token']) + .with(channel.account_sid, channel.auth_token) .and_return(client_double) result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42) diff --git a/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb index 9997280cb..54b22c542 100644 --- a/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb +++ b/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb @@ -2,14 +2,15 @@ require 'rails_helper' describe Voice::Provider::Twilio::ConferenceService do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account) } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) } let(:twilio_client) { instance_double(Twilio::REST::Client) } - let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) } + let(:service) { described_class.new(conversation: conversation) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } before do allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service) + allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client) end describe '#ensure_conference_sid' do diff --git a/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb index fe6aebe01..0c96dbe76 100644 --- a/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb +++ b/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Voice::Provider::Twilio::TokenService do let(:account) { create(:account) } let(:user) { create(:user, :administrator, account: account) } - let(:voice_channel) { create(:channel_voice, account: account) } + let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:inbox) { voice_channel.inbox } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } diff --git a/spec/enterprise/services/voice/status_update_service_spec.rb b/spec/enterprise/services/voice/status_update_service_spec.rb index fab626fb6..6f88f51c6 100644 --- a/spec/enterprise/services/voice/status_update_service_spec.rb +++ b/spec/enterprise/services/voice/status_update_service_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Voice::StatusUpdateService do content_attributes: { data: { call_sid: call_sid, status: 'ringing' } } ) end - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') } let(:inbox) { channel.inbox } let(:from_number) { '+15550002222' } let(:call_sid) { 'CATESTSTATUS123' } diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb deleted file mode 100644 index 81ad8caaa..000000000 --- a/spec/factories/channel/channel_voice.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :channel_voice, class: 'Channel::Voice' do - sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" } - provider_config do - { - account_sid: "AC#{SecureRandom.hex(16)}", - auth_token: SecureRandom.hex(16), - api_key_sid: SecureRandom.hex(8), - api_key_secret: SecureRandom.hex(16), - twiml_app_sid: "AP#{SecureRandom.hex(16)}" - } - end - account - - after(:create) do |channel_voice| - create(:inbox, channel: channel_voice, account: channel_voice.account) - end - end -end diff --git a/spec/factories/channel/twilio_sms.rb b/spec/factories/channel/twilio_sms.rb index 1963a4f28..68ac5a5d1 100644 --- a/spec/factories/channel/twilio_sms.rb +++ b/spec/factories/channel/twilio_sms.rb @@ -17,5 +17,13 @@ FactoryBot.define do trait :whatsapp do medium { :whatsapp } end + + trait :with_voice do + with_phone_number + voice_enabled { true } + api_key_sid { "SK#{SecureRandom.hex(16)}" } + api_key_secret { SecureRandom.hex(16) } + twiml_app_sid { "AP#{SecureRandom.hex(16)}" } + end end end