From 85438cf58a84ada2f5d4d25ba11467407f3df8e0 Mon Sep 17 00:00:00 2001 From: Muhsin Date: Thu, 26 Mar 2026 16:40:33 +0400 Subject: [PATCH] chore: add Twilio voice support to TwilioSms channel --- .../Contacts/VoiceCallButton.vue | 6 +- .../components/ActionButtons.vue | 11 +- .../components/ComposeNewConversationForm.vue | 5 +- .../components-next/icon/provider.js | 7 +- .../dashboard/composables/useInbox.js | 15 ++- app/javascript/dashboard/constants/editor.js | 5 - app/javascript/dashboard/helper/inbox.js | 27 ++-- .../dashboard/settings/inbox/Settings.vue | 6 +- .../settings/inbox/components/ChannelName.vue | 8 +- .../inbox/settingsPage/ConfigurationPage.vue | 39 +++--- app/javascript/shared/mixins/inboxMixin.js | 6 +- app/models/campaign.rb | 2 +- app/models/channel/twilio_sms.rb | 5 + app/models/contact.rb | 2 +- app/models/data_import.rb | 19 +-- app/models/inbox.rb | 1 + app/models/super_admin.rb | 2 +- app/models/user.rb | 2 +- app/views/api/v1/models/_inbox.json.jbuilder | 5 +- ...6120000_add_voice_to_channel_twilio_sms.rb | 7 + .../20260326120001_drop_channel_voice.rb | 20 +++ db/schema.rb | 17 +-- .../enterprise/contact_inbox_builder.rb | 10 +- .../enterprise/messages/message_builder.rb | 7 +- .../v1/accounts/contacts/calls_controller.rb | 13 +- .../api/v1/accounts/inboxes_controller.rb | 42 ++++-- .../controllers/twilio/voice_controller.rb | 4 +- enterprise/app/models/captain/document.rb | 5 + enterprise/app/models/channel/voice.rb | 122 ------------------ enterprise/app/models/company.rb | 2 +- .../models/enterprise/channel/twilio_sms.rb | 68 ++++++++++ .../app/models/enterprise/concerns/account.rb | 1 - .../contacts/contactable_inboxes_service.rb | 4 +- .../twilio/voice_webhook_setup_service.rb | 13 +- .../services/voice/provider/twilio/adapter.rb | 6 +- .../provider/twilio/conference_service.rb | 13 +- .../voice/provider/twilio/token_service.rb | 22 ++-- spec/factories/channel/channel_voice.rb | 21 --- spec/factories/channel/twilio_sms.rb | 8 ++ 39 files changed, 290 insertions(+), 288 deletions(-) create mode 100644 db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb create mode 100644 db/migrate/20260326120001_drop_channel_voice.rb delete mode 100644 enterprise/app/models/channel/voice.rb create mode 100644 enterprise/app/models/enterprise/channel/twilio_sms.rb delete mode 100644 spec/factories/channel/channel_voice.rb 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 686c7af14..f867fc3fe 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'; @@ -29,6 +27,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([ @@ -81,11 +80,13 @@ const isRegularMessageMode = computed(() => { return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; }); -const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE); +const voiceCallEnabled = computed(() => props.voiceEnabled); const shouldShowSignatureButton = computed(() => { return ( - props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value + props.hasSelectedInbox && + isRegularMessageMode.value && + !voiceCallEnabled.value ); }); @@ -110,7 +111,7 @@ watch( () => props.hasSelectedInbox, newValue => { nextTick(() => { - if (newValue && !isVoiceInbox.value) setSignature(); + if (newValue && !voiceCallEnabled.value) 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 455abf996..4483bd97d 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/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 d33af1a11..08fcc45d9 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/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 401739706..4bb2d5a00 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,9 +11,25 @@ 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; + + if (inbox.channel_type === INBOX_TYPES.TWILIO && inbox.voice_enabled) { + 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 +46,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 +60,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 +72,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 +110,6 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; - case INBOX_TYPES.VOICE: - return 'voice'; - default: return 'chat'; } @@ -142,9 +152,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/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 247bb30d8..c36852c8c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -142,7 +142,7 @@ export default { }, ]; - if (!this.isAVoiceChannel) { + if (!this.voiceCallEnabled) { visibleToAllChannelTabs = [ ...visibleToAllChannelTabs, { @@ -170,7 +170,7 @@ export default { this.isATwilioChannel || this.isALineChannel || this.isAPIInbox || - this.isAVoiceChannel || + this.voiceCallEnabled || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || this.isAWebWidgetInbox @@ -742,7 +742,7 @@ 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..5963d7c35 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: '', }, + voiceCallEnabled: { + 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.voiceCallEnabled) { + 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..a174c947d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -189,7 +189,26 @@ export default {