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;
+ }
+ },
+ },
+};
+
+
+
+
+
+
+
+
+ {{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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