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:
Muhsin Keloth
2026-04-29 11:32:19 +04:00
committed by GitHub
co-authored by Muhsin
parent f8f0caf443
commit 0e122188e9
50 changed files with 667 additions and 418 deletions
@@ -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);
@@ -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 }
@@ -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');
});
@@ -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 => {
@@ -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,
};
};
@@ -109,11 +109,6 @@ export const FORMATTING = {
'redo',
],
},
'Channel::Voice': {
marks: [],
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
+1
View File
@@ -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',
+21 -10
View File
@@ -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';
}
@@ -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",
@@ -145,6 +145,7 @@ const openDelete = inbox => {
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
:voice-enabled="inbox.voice_enabled"
class="text-body-main text-n-slate-11"
/>
</div>
@@ -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 {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAVoiceChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1240,6 +1256,12 @@ export default {
>
<ConfigurationPage :inbox="inbox" />
</div>
<div
v-if="selectedTabKey === 'voice-configuration'"
class="mx-6 max-w-4xl"
>
<VoiceConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
@@ -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]}`);
@@ -208,24 +208,6 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel">
<SettingsFieldSection
@@ -0,0 +1,156 @@
<script>
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;
}
},
},
};
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsToggleSection
v-model="voiceEnabled"
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
:description="
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
"
/>
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
</p>
<NextInput
v-if="needsApiKeySid"
v-model="apiKeySid"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
/>
<NextInput
v-model="apiKeySecret"
type="password"
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
"
/>
</div>
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsFieldSection>
<SettingsFieldSection
:label="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsFieldSection>
</div>
<div>
<NextButton
:disabled="isSubmitDisabled"
:is-loading="isUpdating"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="updateVoiceSettings"
/>
</div>
</div>
</template>
+3 -3
View File
@@ -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;
+7 -2
View File
@@ -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')
+10 -4
View File
@@ -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
@@ -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
@@ -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
+3 -12
View File
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
-122
View File
@@ -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
@@ -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
@@ -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'
@@ -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
@@ -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
@@ -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
}
@@ -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
@@ -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
@@ -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
@@ -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) }
@@ -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)
@@ -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('+') }
@@ -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
@@ -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
@@ -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}" }
@@ -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 }
@@ -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') }
@@ -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)
@@ -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
@@ -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) }
@@ -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' }
-21
View File
@@ -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
+8
View File
@@ -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