Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
859911b9a1 | ||
|
|
94766e1200 | ||
|
|
5a16a5a0ee | ||
|
|
da01ed142c | ||
|
|
b8df28c8a3 | ||
|
|
a77a1c8ab4 | ||
|
|
57821ab6f9 | ||
|
|
b4e27ed4cb | ||
|
|
726193e3ac | ||
|
|
55fd3c62cc | ||
|
|
1a63c4089a | ||
|
|
b98ce4b18c | ||
|
|
ddbd81b85e | ||
|
|
986f3ebc3a | ||
|
|
a6a612568a | ||
|
|
7ebe9db77c | ||
|
|
fdf22a034f | ||
|
|
14100580fb | ||
|
|
453fcecfb4 | ||
|
|
401ec4cd81 | ||
|
|
38867ba75f | ||
|
|
d3b7d7e79a | ||
|
|
cb275ce1d9 | ||
|
|
e96fa6f561 | ||
|
|
78296b9ce3 | ||
|
|
9c85a92e86 | ||
|
|
f7d4d2d58c | ||
|
|
fdce5910e0 | ||
|
|
aa15ddfef8 | ||
|
|
d80a7454ce | ||
|
|
b684f75631 | ||
|
|
6e0b10cb42 | ||
|
|
020bf6af93 | ||
|
|
a51af8ca68 | ||
|
|
dfdfc669ea | ||
|
|
987d4d4a01 | ||
|
|
3471296394 | ||
|
|
465b70ce27 | ||
|
|
c90fdd6594 | ||
|
|
6890293e60 | ||
|
|
5c704691d7 | ||
|
|
1946c373d7 | ||
|
|
797d7790db | ||
|
|
1d555cebfc | ||
|
|
2b5d3300f3 | ||
|
|
ddfa6cea32 | ||
|
|
85438cf58a |
@@ -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);
|
||||
|
||||
|
||||
+6
-5
@@ -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 }
|
||||
|
||||
+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');
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,9 +11,28 @@ 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;
|
||||
|
||||
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 +49,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 +63,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 +75,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 +113,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -142,9 +155,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 WebRTC 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,
|
||||
@@ -145,19 +147,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 = [
|
||||
@@ -173,7 +173,6 @@ export default {
|
||||
this.isATwilioChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isAVoiceChannel ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
@@ -208,6 +207,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() {
|
||||
@@ -787,7 +804,6 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAVoiceChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1215,6 +1231,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]}`);
|
||||
|
||||
-18
@@ -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
|
||||
|
||||
+156
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
|
||||
|
||||
@@ -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
@@ -547,6 +547,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) 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
|
||||
@@ -563,18 +566,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_09_091202) 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
|
||||
|
||||
@@ -11,9 +11,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
|
||||
def create
|
||||
conversation = fetch_conversation_by_display_id
|
||||
ensure_call_sid!(conversation)
|
||||
call = find_or_initialize_call!(conversation)
|
||||
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
|
||||
conference_sid = conference_service.ensure_conference_sid
|
||||
conference_service.mark_agent_joined(user: current_user)
|
||||
|
||||
@@ -27,19 +27,34 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
|
||||
def destroy
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
|
||||
call = Current.account.calls.where(conversation_id: conversation.id).order(created_at: :desc).first
|
||||
return render(json: { status: 'success', id: conversation.display_id }) unless call
|
||||
|
||||
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
|
||||
render json: { status: 'success', id: conversation.display_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_call_sid!(conversation)
|
||||
return conversation.identifier if conversation.identifier.present?
|
||||
def find_or_initialize_call!(conversation)
|
||||
sid = params[:call_sid].presence
|
||||
existing = Current.account.calls.where(conversation_id: conversation.id, provider: :twilio)
|
||||
existing = existing.where(provider_call_id: sid) if sid
|
||||
call = existing.order(created_at: :desc).first
|
||||
return call if call
|
||||
|
||||
incoming_sid = params.require(:call_sid)
|
||||
raise ActionController::ParameterMissing, :call_sid unless sid
|
||||
|
||||
conversation.update!(identifier: incoming_sid)
|
||||
incoming_sid
|
||||
Current.account.calls.create!(
|
||||
inbox_id: conversation.inbox_id,
|
||||
conversation: conversation,
|
||||
contact_id: conversation.contact_id,
|
||||
provider: :twilio,
|
||||
direction: :outgoing,
|
||||
status: 'ringing',
|
||||
provider_call_id: sid,
|
||||
accepted_by_agent_id: current_user.id
|
||||
)
|
||||
end
|
||||
|
||||
def set_voice_inbox_for_conference
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,8 +25,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
)
|
||||
|
||||
conversation = resolve_conversation
|
||||
conference_sid = ensure_conference_sid!(conversation)
|
||||
call = resolve_call
|
||||
conference_sid = ensure_conference_sid!(call)
|
||||
|
||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||
end
|
||||
@@ -35,15 +35,14 @@ class Twilio::VoiceController < ApplicationController
|
||||
event = mapped_conference_event
|
||||
return head :no_content unless event
|
||||
|
||||
conversation = find_conversation_for_conference!(
|
||||
call = find_call_for_conference!(
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
conversation: conversation,
|
||||
call: call,
|
||||
event: event,
|
||||
call_sid: twilio_call_sid,
|
||||
participant_label: participant_label
|
||||
).process
|
||||
|
||||
@@ -80,8 +79,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
from_number.start_with?('client:')
|
||||
end
|
||||
|
||||
def resolve_conversation
|
||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
||||
def resolve_call
|
||||
return find_call_for_agent if agent_leg?(twilio_from)
|
||||
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
@@ -91,6 +90,7 @@ class Twilio::VoiceController < ApplicationController
|
||||
from_number: twilio_from,
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
find_call!(twilio_call_sid)
|
||||
when 'outbound-api', 'outbound-dial'
|
||||
sync_outbound_leg(
|
||||
call_sid: twilio_call_sid,
|
||||
@@ -102,36 +102,47 @@ class Twilio::VoiceController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_for_agent
|
||||
def find_call_for_agent
|
||||
if params[:conversation_id].present?
|
||||
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
conversation = current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
current_account.calls.active.where(conversation_id: conversation.id).order(created_at: :desc).first!
|
||||
else
|
||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
||||
find_call!(twilio_call_sid)
|
||||
end
|
||||
end
|
||||
|
||||
def find_call!(sid)
|
||||
current_account.calls.find_by!(provider: :twilio, provider_call_id: sid)
|
||||
end
|
||||
|
||||
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||
parent_sid = params['ParentCallSid'].presence
|
||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
|
||||
call = find_call!(lookup_sid)
|
||||
|
||||
Voice::CallSessionSyncService.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
call: call,
|
||||
leg_call_sid: call_sid,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: twilio_to,
|
||||
direction: 'outbound'
|
||||
}
|
||||
).perform
|
||||
call
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(conversation)
|
||||
def ensure_conference_sid!(call)
|
||||
name = call.meta['conference_sid'].presence || Voice::Conference::Name.for(call)
|
||||
call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank?
|
||||
|
||||
conversation = call.conversation
|
||||
attrs = conversation.additional_attributes || {}
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
attrs['conference_sid']
|
||||
if attrs['conference_sid'] != name
|
||||
attrs['conference_sid'] = name
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
name
|
||||
end
|
||||
|
||||
def conference_twiml(conference_sid, agent_leg)
|
||||
@@ -155,22 +166,24 @@ class Twilio::VoiceController < ApplicationController
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
def find_call_for_conference!(friendly_name:, call_sid:)
|
||||
name = friendly_name.to_s
|
||||
scope = current_account.conversations
|
||||
scope = current_account.calls
|
||||
|
||||
if name.present?
|
||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
||||
return conversation if conversation
|
||||
call = scope.where("meta ->> 'conference_sid' = ?", name).first
|
||||
return call if call
|
||||
end
|
||||
|
||||
scope.find_by!(identifier: call_sid)
|
||||
scope.find_by!(provider: :twilio, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
e164 = "+#{digits}"
|
||||
channel = Channel::Voice.find_by!(phone_number: e164)
|
||||
channel = Channel::TwilioSms.find_by!(phone_number: e164)
|
||||
raise ActiveRecord::RecordNotFound, "Voice not enabled for #{e164}" unless channel.voice_enabled?
|
||||
|
||||
@inbox = channel.inbox
|
||||
end
|
||||
|
||||
|
||||
@@ -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,87 @@
|
||||
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 voice_enabled?
|
||||
voice_enabled
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
# Override: when api_key_secret is stored separately (voice channels),
|
||||
# use it instead of auth_token for API key authentication.
|
||||
# Existing SMS channels store the secret in auth_token — that path is unchanged 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
|
||||
|
||||
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,41 @@
|
||||
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
|
||||
twilio_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 = twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
return if numbers.empty?
|
||||
|
||||
twilio_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
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
|
||||
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||
else
|
||||
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -17,8 +17,8 @@ class Twilio::VoiceWebhookSetupService
|
||||
private
|
||||
|
||||
def validate_token_credentials!
|
||||
# Only validate Account SID + Auth Token
|
||||
token_client.incoming_phone_numbers.list(limit: 1)
|
||||
validation_client = channel.api_key_sid.present? ? api_key_client : token_client
|
||||
validation_client.incoming_phone_numbers.list(limit: 1)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
|
||||
raise
|
||||
@@ -58,17 +58,11 @@ class Twilio::VoiceWebhookSetupService
|
||||
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
|
||||
@api_key_client ||= ::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||
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
|
||||
@token_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
|
||||
def log_twilio_error(context, error)
|
||||
@@ -80,11 +74,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
|
||||
}
|
||||
|
||||
@@ -19,16 +19,21 @@ class Voice::CallMessageBuilder
|
||||
|
||||
def perform!
|
||||
validate_sender!
|
||||
message = latest_message
|
||||
message ? update_message!(message) : create_message!
|
||||
existing = existing_message
|
||||
existing ? update_message!(existing) : create_message!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||
|
||||
def latest_message
|
||||
conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
def existing_message
|
||||
sid = payload[:call_sid] || payload['call_sid']
|
||||
return if sid.blank?
|
||||
|
||||
conversation.messages.voice_calls
|
||||
.where("content_attributes -> 'data' ->> 'call_sid' = ?", sid)
|
||||
.first
|
||||
end
|
||||
|
||||
def update_message!(message)
|
||||
@@ -37,6 +42,7 @@ class Voice::CallMessageBuilder
|
||||
content_attributes: { 'data' => base_payload },
|
||||
sender: sender
|
||||
)
|
||||
message
|
||||
end
|
||||
|
||||
def create_message!
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
class Voice::CallSessionSyncService
|
||||
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
|
||||
attr_reader :call, :leg_call_sid, :from_number, :to_number, :direction
|
||||
|
||||
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
|
||||
@conversation = conversation
|
||||
@call_sid = call_sid
|
||||
@message_call_sid = message_call_sid || call_sid
|
||||
def initialize(call:, leg:, leg_call_sid: nil)
|
||||
@call = call
|
||||
@leg_call_sid = leg_call_sid || call.provider_call_id
|
||||
@from_number = leg[:from_number]
|
||||
@to_number = leg[:to_number]
|
||||
@direction = leg[:direction]
|
||||
@@ -20,16 +19,18 @@ class Voice::CallSessionSyncService
|
||||
sync_voice_call_message!(attrs)
|
||||
end
|
||||
|
||||
conversation
|
||||
call
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :conversation, to: :call
|
||||
|
||||
def refreshed_attributes
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_direction'] = direction
|
||||
attrs['call_status'] ||= 'ringing'
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
attrs['conference_sid'] ||= call.meta['conference_sid'] || Voice::Conference::Name.for(call)
|
||||
attrs['meta'] ||= {}
|
||||
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||
attrs
|
||||
@@ -40,7 +41,7 @@ class Voice::CallSessionSyncService
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: {
|
||||
call_sid: message_call_sid,
|
||||
call_sid: call.provider_call_id,
|
||||
status: attrs['call_status'],
|
||||
conference_sid: attrs['conference_sid'],
|
||||
from_number: origin_number_for(direction),
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
class Voice::CallStatus::Manager
|
||||
pattr_initialize [:conversation!, :call_sid]
|
||||
pattr_initialize [:call!]
|
||||
|
||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
||||
|
||||
# Map dashed statuses (Twilio-native / frontend) to underscored Call model statuses.
|
||||
CALL_MODEL_STATUS = {
|
||||
'ringing' => 'ringing',
|
||||
'in-progress' => 'in_progress',
|
||||
'completed' => 'completed',
|
||||
'no-answer' => 'no_answer',
|
||||
'failed' => 'failed'
|
||||
}.freeze
|
||||
|
||||
def process_status_update(status, duration: nil, timestamp: nil)
|
||||
return unless ALLOWED_STATUSES.include?(status)
|
||||
|
||||
current_status = conversation.additional_attributes&.dig('call_status')
|
||||
return if current_status == status
|
||||
|
||||
apply_status(status, duration: duration, timestamp: timestamp)
|
||||
@@ -16,6 +23,12 @@ class Voice::CallStatus::Manager
|
||||
|
||||
private
|
||||
|
||||
delegate :conversation, to: :call
|
||||
|
||||
def current_status
|
||||
conversation.additional_attributes&.dig('call_status')
|
||||
end
|
||||
|
||||
def apply_status(status, duration:, timestamp:)
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_status'] = status
|
||||
@@ -31,6 +44,16 @@ class Voice::CallStatus::Manager
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
|
||||
persist_on_call!(status, attrs)
|
||||
end
|
||||
|
||||
def persist_on_call!(status, attrs)
|
||||
updates = { status: CALL_MODEL_STATUS[status] }
|
||||
updates[:started_at] = Time.zone.at(attrs['call_started_at']) if status == 'in-progress' && attrs['call_started_at']
|
||||
updates[:duration_seconds] = attrs['call_duration'] if TERMINAL_STATUSES.include?(status) && attrs['call_duration']
|
||||
|
||||
call.update!(updates)
|
||||
end
|
||||
|
||||
def resolved_duration(attrs, provided_duration, timestamp)
|
||||
@@ -43,10 +66,7 @@ class Voice::CallStatus::Manager
|
||||
end
|
||||
|
||||
def update_message(status)
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
message = call.message || fallback_message
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
@@ -56,6 +76,13 @@ class Voice::CallStatus::Manager
|
||||
message.update!(content_attributes: data)
|
||||
end
|
||||
|
||||
def fallback_message
|
||||
conversation.messages.voice_calls
|
||||
.where("content_attributes -> 'data' ->> 'call_sid' = ?", call.provider_call_id)
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def now_seconds
|
||||
current_time.to_i
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Voice::Conference::Manager
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
||||
pattr_initialize [:call!, :event!, :participant_label]
|
||||
|
||||
def process
|
||||
case event
|
||||
@@ -17,19 +17,21 @@ class Voice::Conference::Manager
|
||||
|
||||
private
|
||||
|
||||
delegate :conversation, to: :call
|
||||
|
||||
def status_manager
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
)
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
|
||||
end
|
||||
|
||||
def ensure_conference_sid!
|
||||
attrs = conversation.additional_attributes || {}
|
||||
return if attrs['conference_sid'].present?
|
||||
name = Voice::Conference::Name.for(call)
|
||||
call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank?
|
||||
|
||||
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
conv_attrs = (conversation.additional_attributes || {}).dup
|
||||
return if conv_attrs['conference_sid'].present?
|
||||
|
||||
conv_attrs['conference_sid'] = name
|
||||
conversation.update!(additional_attributes: conv_attrs)
|
||||
end
|
||||
|
||||
def mark_ringing!
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module Voice::Conference::Name
|
||||
def self.for(conversation)
|
||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
def self.for(call)
|
||||
"conf_account_#{call.account_id}_call_#{call.id}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,21 +13,32 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
|
||||
def perform!
|
||||
existing = find_existing_call
|
||||
return existing.conversation if existing
|
||||
|
||||
timestamp = current_timestamp
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = ensure_contact!
|
||||
contact_inbox = ensure_contact_inbox!(contact)
|
||||
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
||||
conversation = find_open_conversation(contact_inbox) || create_conversation!(contact, contact_inbox)
|
||||
conversation.reload
|
||||
update_conversation!(conversation, timestamp)
|
||||
build_voice_message!(conversation, timestamp)
|
||||
|
||||
call = create_call!(conversation, contact, timestamp)
|
||||
message = build_voice_message!(conversation, call, timestamp)
|
||||
call.update!(message_id: message.id)
|
||||
|
||||
denormalize_to_conversation!(conversation, call, timestamp)
|
||||
conversation
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_existing_call
|
||||
account.calls.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||
record.name = from_number if record.name.blank?
|
||||
@@ -43,10 +54,10 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation
|
||||
return if call_sid.blank?
|
||||
def find_open_conversation(contact_inbox)
|
||||
return unless inbox.lock_to_single_conversation
|
||||
|
||||
account.conversations.includes(:contact).find_by(identifier: call_sid)
|
||||
contact_inbox.conversations.where.not(status: :resolved).order(created_at: :desc).first
|
||||
end
|
||||
|
||||
def create_conversation!(contact, contact_inbox)
|
||||
@@ -54,34 +65,33 @@ class Voice::InboundCallBuilder
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: :open,
|
||||
identifier: call_sid
|
||||
status: :open
|
||||
)
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing',
|
||||
'conference_sid' => Voice::Conference::Name.for(conversation),
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
def create_call!(conversation, contact, timestamp)
|
||||
call = account.calls.create!(
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => timestamp }
|
||||
)
|
||||
call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call)))
|
||||
call
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, timestamp)
|
||||
def build_voice_message!(conversation, call, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: 'inbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
call_sid: call.provider_call_id,
|
||||
status: 'ringing',
|
||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
||||
conference_sid: call.meta['conference_sid'],
|
||||
from_number: from_number,
|
||||
to_number: inbox.channel&.phone_number
|
||||
},
|
||||
@@ -89,6 +99,20 @@ class Voice::InboundCallBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def denormalize_to_conversation!(conversation, call, timestamp)
|
||||
attrs = (conversation.additional_attributes || {}).merge(
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing',
|
||||
'conference_sid' => call.meta['conference_sid'],
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
)
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
@@ -22,10 +22,13 @@ class Voice::OutboundCallBuilder
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
conversation = create_conversation!(contact_inbox)
|
||||
conversation.reload
|
||||
conference_sid = Voice::Conference::Name.for(conversation)
|
||||
|
||||
call_sid = initiate_call!
|
||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
call = create_call!(conversation, call_sid, timestamp)
|
||||
message = build_voice_message!(conversation, call, timestamp)
|
||||
call.update!(message_id: message.id)
|
||||
|
||||
denormalize_to_conversation!(conversation, call, timestamp)
|
||||
{ conversation: conversation, call_sid: call_sid }
|
||||
end
|
||||
end
|
||||
@@ -56,30 +59,30 @@ class Voice::OutboundCallBuilder
|
||||
)[:call_sid]
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'outbound',
|
||||
'call_status' => 'ringing',
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => conference_sid,
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
def create_call!(conversation, call_sid, timestamp)
|
||||
call = account.calls.create!(
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
direction: :outgoing,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
accepted_by_agent_id: user.id,
|
||||
meta: { 'initiated_at' => timestamp }
|
||||
)
|
||||
call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call)))
|
||||
call
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
def build_voice_message!(conversation, call, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: 'outbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
call_sid: call.provider_call_id,
|
||||
status: 'ringing',
|
||||
conference_sid: conference_sid,
|
||||
conference_sid: call.meta['conference_sid'],
|
||||
from_number: inbox.channel&.phone_number,
|
||||
to_number: contact.phone_number
|
||||
},
|
||||
@@ -88,6 +91,21 @@ class Voice::OutboundCallBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def denormalize_to_conversation!(conversation, call, timestamp)
|
||||
attrs = (conversation.additional_attributes || {}).merge(
|
||||
'call_direction' => 'outbound',
|
||||
'call_status' => 'ringing',
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => call.meta['conference_sid'],
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
)
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
@@ -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,17 +1,19 @@
|
||||
class Voice::Provider::Twilio::ConferenceService
|
||||
pattr_initialize [:conversation!, { twilio_client: nil }]
|
||||
pattr_initialize [:call!, { twilio_client: nil }]
|
||||
|
||||
def ensure_conference_sid
|
||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
||||
existing = call.meta['conference_sid']
|
||||
return existing if existing.present?
|
||||
|
||||
sid = Voice::Conference::Name.for(conversation)
|
||||
merge_attributes('conference_sid' => sid)
|
||||
sid = Voice::Conference::Name.for(call)
|
||||
call.update!(meta: call.meta.merge('conference_sid' => sid))
|
||||
merge_conversation_attributes('conference_sid' => sid)
|
||||
sid
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
merge_attributes(
|
||||
call.update!(accepted_by_agent_id: user.id)
|
||||
merge_conversation_attributes(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => { id: user.id, name: user.name }
|
||||
@@ -21,26 +23,27 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
def end_conference
|
||||
twilio_client
|
||||
.conferences
|
||||
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
|
||||
.list(friendly_name: Voice::Conference::Name.for(call), status: 'in-progress')
|
||||
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_attributes(attrs)
|
||||
delegate :conversation, to: :call
|
||||
|
||||
def merge_conversation_attributes(attrs)
|
||||
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']
|
||||
@twilio_client ||= begin
|
||||
channel = conversation.inbox.channel
|
||||
if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
|
||||
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||
else
|
||||
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
end
|
||||
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
|
||||
|
||||
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
|
||||
normalized_status = normalize_status(call_status)
|
||||
return if normalized_status.blank?
|
||||
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
call = account.calls.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
return unless call
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
).process_status_update(
|
||||
Voice::CallStatus::Manager.new(call: call).process_status_update(
|
||||
normalized_status,
|
||||
duration: payload_duration,
|
||||
timestamp: payload_timestamp
|
||||
|
||||
@@ -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,104 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::TwilioSms do
|
||||
let(:account) { create(:account) }
|
||||
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(described_class).to receive(:validate_voice_capability!) # rubocop:disable RSpec/AnyInstance
|
||||
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,7 +2,7 @@ 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) }
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user