feat: Add voice calling as a capability on Twilio SMS channel(Enterprise) (#13963)
Voice calling is now a capability on the existing TwilioSms rather than a separate Voice model. A single Twilio phone number handles both SMS and voice calls through one inbox. Fixes https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms **What changed** - Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms - Added voice_enabled, twiml_app_sid, api_key_secret columns to channel_twilio_sms table - Dropped channel_voice table (no production data) - All voice logic lives in Enterprise layer via prepend_mod_with('Channel::TwilioSms') - Added Voice settings tab on Twilio SMS inbox settings to enable/disable voice - Validates Twilio number voice capability before provisioning - Teardown service cleans up TwiML app and credentials when voice is disabled - Frontend voice detection uses isVoiceCallEnabled() / getVoiceCallProvider() helpers — extensible to future providers - Gated by channel_voice feature flag **How to test** 1. Enable feature flag: Account.find(<id>).enable_features('channel_voice') 2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials → verify incoming/outgoing calls and SMS work 3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox → Voice tab → toggle on → provide API key credentials → verify calls work 4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted, credentials cleared, SMS still works 5. Re-enable voice: Toggle on again → must provide api_key_secret again → new TwiML app provisioned --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
+3
-6
@@ -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 }
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user