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 { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
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 { useAlert } from 'dashboard/composables';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
|||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
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 { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||||
@@ -29,6 +27,7 @@ const props = defineProps({
|
|||||||
isDropdownActive: { type: Boolean, default: false },
|
isDropdownActive: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
inboxId: { type: Number, default: null },
|
inboxId: { type: Number, default: null },
|
||||||
|
voiceEnabled: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@@ -81,11 +80,13 @@ const isRegularMessageMode = computed(() => {
|
|||||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
const voiceCallEnabled = computed(() => props.voiceEnabled);
|
||||||
|
|
||||||
const shouldShowSignatureButton = computed(() => {
|
const shouldShowSignatureButton = computed(() => {
|
||||||
return (
|
return (
|
||||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
props.hasSelectedInbox &&
|
||||||
|
isRegularMessageMode.value &&
|
||||||
|
!voiceCallEnabled.value
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ watch(
|
|||||||
() => props.hasSelectedInbox,
|
() => props.hasSelectedInbox,
|
||||||
newValue => {
|
newValue => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (newValue && !isVoiceInbox.value) setSignature();
|
if (newValue && !voiceCallEnabled.value) setSignature();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, requiredIf } from '@vuelidate/validators';
|
import { required, requiredIf } from '@vuelidate/validators';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
@@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
|||||||
|
|
||||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||||
|
|
||||||
|
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
|
||||||
|
|
||||||
const effectiveChannelType = computed(() =>
|
const effectiveChannelType = computed(() =>
|
||||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||||
);
|
);
|
||||||
@@ -442,6 +444,7 @@ useKeyboardEvents({
|
|||||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||||
:message-templates="whatsappMessageTemplates"
|
:message-templates="whatsappMessageTemplates"
|
||||||
:channel-type="inboxChannelType"
|
:channel-type="inboxChannelType"
|
||||||
|
:voice-enabled="voiceCallEnabled"
|
||||||
:is-loading="isCreating"
|
:is-loading="isCreating"
|
||||||
:disable-send-button="isCreating"
|
:disable-send-button="isCreating"
|
||||||
:has-selected-inbox="!!targetInbox"
|
:has-selected-inbox="!!targetInbox"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
export function useChannelIcon(inbox) {
|
export function useChannelIcon(inbox) {
|
||||||
const channelTypeIconMap = {
|
const channelTypeIconMap = {
|
||||||
@@ -14,7 +15,6 @@ export function useChannelIcon(inbox) {
|
|||||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||||
'Channel::Instagram': 'i-woot-instagram',
|
'Channel::Instagram': 'i-woot-instagram',
|
||||||
'Channel::Tiktok': 'i-woot-tiktok',
|
'Channel::Tiktok': 'i-woot-tiktok',
|
||||||
'Channel::Voice': 'i-woot-voice',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerIconMap = {
|
const providerIconMap = {
|
||||||
@@ -38,6 +38,11 @@ export function useChannelIcon(inbox) {
|
|||||||
icon = 'i-woot-whatsapp';
|
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';
|
return icon ?? 'i-ri-global-fill';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-woot-whatsapp');
|
expect(icon).toBe('i-woot-whatsapp');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns correct icon for Voice channel', () => {
|
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Voice' };
|
const inbox = {
|
||||||
|
channel_type: 'Channel::TwilioSms',
|
||||||
|
voice_enabled: true,
|
||||||
|
};
|
||||||
const { value: icon } = useChannelIcon(inbox);
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
expect(icon).toBe('i-woot-voice');
|
expect(icon).toBe('i-woot-voice');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ const mockStore = createStore({
|
|||||||
11: { id: 11, channel_type: INBOX_TYPES.API },
|
11: { id: 11, channel_type: INBOX_TYPES.API },
|
||||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
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 },
|
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||||
};
|
};
|
||||||
return inboxes[id] || null;
|
return inboxes[id] || null;
|
||||||
@@ -211,11 +215,11 @@ describe('useInbox', () => {
|
|||||||
});
|
});
|
||||||
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
||||||
|
|
||||||
// Test Voice
|
// Test Voice (Twilio with voice_enabled)
|
||||||
wrapper = mount(createTestComponent(14), {
|
wrapper = mount(createTestComponent(14), {
|
||||||
global: { plugins: [mockStore] },
|
global: { plugins: [mockStore] },
|
||||||
});
|
});
|
||||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
expect(wrapper.vm.voiceCallEnabled).toBe(true);
|
||||||
|
|
||||||
// Test Tiktok
|
// Test Tiktok
|
||||||
wrapper = mount(createTestComponent(15), {
|
wrapper = mount(createTestComponent(15), {
|
||||||
@@ -274,7 +278,8 @@ describe('useInbox', () => {
|
|||||||
'isAnEmailChannel',
|
'isAnEmailChannel',
|
||||||
'isAnInstagramChannel',
|
'isAnInstagramChannel',
|
||||||
'isATiktokChannel',
|
'isATiktokChannel',
|
||||||
'isAVoiceChannel',
|
'voiceCallEnabled',
|
||||||
|
'voiceCallProvider',
|
||||||
];
|
];
|
||||||
|
|
||||||
expectedProperties.forEach(prop => {
|
expectedProperties.forEach(prop => {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
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 = {
|
export const INBOX_FEATURES = {
|
||||||
REPLY_TO: 'replyTo',
|
REPLY_TO: 'replyTo',
|
||||||
@@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => {
|
|||||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAVoiceChannel = computed(() => {
|
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||||
return channelType.value === INBOX_TYPES.VOICE;
|
|
||||||
});
|
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inbox,
|
inbox,
|
||||||
@@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => {
|
|||||||
isAnEmailChannel,
|
isAnEmailChannel,
|
||||||
isAnInstagramChannel,
|
isAnInstagramChannel,
|
||||||
isATiktokChannel,
|
isATiktokChannel,
|
||||||
isAVoiceChannel,
|
voiceCallEnabled,
|
||||||
|
voiceCallProvider,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -109,11 +109,6 @@ export const FORMATTING = {
|
|||||||
'redo',
|
'redo',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'Channel::Voice': {
|
|
||||||
marks: [],
|
|
||||||
nodes: [],
|
|
||||||
menu: [],
|
|
||||||
},
|
|
||||||
'Channel::Tiktok': {
|
'Channel::Tiktok': {
|
||||||
marks: [],
|
marks: [],
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
|
|||||||
CHATWOOT_V4: 'chatwoot_v4',
|
CHATWOOT_V4: 'chatwoot_v4',
|
||||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||||
|
CHANNEL_VOICE: 'channel_voice',
|
||||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||||
CAPTAIN_V2: 'captain_integration_v2',
|
CAPTAIN_V2: 'captain_integration_v2',
|
||||||
|
|||||||
@@ -11,9 +11,28 @@ export const INBOX_TYPES = {
|
|||||||
SMS: 'Channel::Sms',
|
SMS: 'Channel::Sms',
|
||||||
INSTAGRAM: 'Channel::Instagram',
|
INSTAGRAM: 'Channel::Instagram',
|
||||||
TIKTOK: 'Channel::Tiktok',
|
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 = {
|
export const TWILIO_CHANNEL_MEDIUM = {
|
||||||
WHATSAPP: 'whatsapp',
|
WHATSAPP: 'whatsapp',
|
||||||
SMS: 'sms',
|
SMS: 'sms',
|
||||||
@@ -30,7 +49,6 @@ const INBOX_ICON_MAP_FILL = {
|
|||||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-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.TELEGRAM]: 'i-woot-telegram',
|
||||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||||
[INBOX_TYPES.VOICE]: 'i-woot-voice',
|
|
||||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +75,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
|||||||
|
|
||||||
case INBOX_TYPES.TWILIO:
|
case INBOX_TYPES.TWILIO:
|
||||||
case INBOX_TYPES.WHATSAPP:
|
case INBOX_TYPES.WHATSAPP:
|
||||||
case INBOX_TYPES.VOICE:
|
|
||||||
return phoneNumber || '';
|
return phoneNumber || '';
|
||||||
|
|
||||||
case INBOX_TYPES.EMAIL:
|
case INBOX_TYPES.EMAIL:
|
||||||
@@ -97,9 +113,6 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.LINE:
|
case INBOX_TYPES.LINE:
|
||||||
return 'line';
|
return 'line';
|
||||||
|
|
||||||
case INBOX_TYPES.VOICE:
|
|
||||||
return 'voice';
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'chat';
|
return 'chat';
|
||||||
}
|
}
|
||||||
@@ -142,9 +155,6 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
|||||||
case INBOX_TYPES.TIKTOK:
|
case INBOX_TYPES.TIKTOK:
|
||||||
return 'brand-tiktok';
|
return 'brand-tiktok';
|
||||||
|
|
||||||
case INBOX_TYPES.VOICE:
|
|
||||||
return 'phone';
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 'chat';
|
return 'chat';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -636,7 +636,17 @@
|
|||||||
"WIDGET_BUILDER": "Widget Builder",
|
"WIDGET_BUILDER": "Widget Builder",
|
||||||
"BOT_CONFIGURATION": "Bot Configuration",
|
"BOT_CONFIGURATION": "Bot Configuration",
|
||||||
"ACCOUNT_HEALTH": "Account Health",
|
"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",
|
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||||
"WIDGET_FEATURES": "Widget features",
|
"WIDGET_FEATURES": "Widget features",
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const openDelete = inbox => {
|
|||||||
<ChannelName
|
<ChannelName
|
||||||
:channel-type="inbox.channel_type"
|
:channel-type="inbox.channel_type"
|
||||||
:medium="inbox.medium"
|
:medium="inbox.medium"
|
||||||
|
:voice-enabled="inbox.voice_enabled"
|
||||||
class="text-body-main text-n-slate-11"
|
class="text-body-main text-n-slate-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
|
|||||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||||
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
|
||||||
|
import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue';
|
||||||
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
|
||||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||||
import BotConfiguration from './components/BotConfiguration.vue';
|
import BotConfiguration from './components/BotConfiguration.vue';
|
||||||
@@ -46,6 +47,7 @@ export default {
|
|||||||
BotConfiguration,
|
BotConfiguration,
|
||||||
CollaboratorsPage,
|
CollaboratorsPage,
|
||||||
ConfigurationPage,
|
ConfigurationPage,
|
||||||
|
VoiceConfigurationPage,
|
||||||
CustomerSatisfactionPage,
|
CustomerSatisfactionPage,
|
||||||
FacebookReauthorize,
|
FacebookReauthorize,
|
||||||
GreetingsEditor,
|
GreetingsEditor,
|
||||||
@@ -145,19 +147,17 @@ export default {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!this.isAVoiceChannel) {
|
visibleToAllChannelTabs = [
|
||||||
visibleToAllChannelTabs = [
|
...visibleToAllChannelTabs,
|
||||||
...visibleToAllChannelTabs,
|
{
|
||||||
{
|
key: 'business-hours',
|
||||||
key: 'business-hours',
|
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'csat',
|
||||||
key: 'csat',
|
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
},
|
||||||
},
|
];
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isAWebWidgetInbox) {
|
if (this.isAWebWidgetInbox) {
|
||||||
visibleToAllChannelTabs = [
|
visibleToAllChannelTabs = [
|
||||||
@@ -173,7 +173,6 @@ export default {
|
|||||||
this.isATwilioChannel ||
|
this.isATwilioChannel ||
|
||||||
this.isALineChannel ||
|
this.isALineChannel ||
|
||||||
this.isAPIInbox ||
|
this.isAPIInbox ||
|
||||||
this.isAVoiceChannel ||
|
|
||||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||||
this.shouldShowWhatsAppConfiguration ||
|
this.shouldShowWhatsAppConfiguration ||
|
||||||
this.isAWebWidgetInbox
|
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;
|
return visibleToAllChannelTabs;
|
||||||
},
|
},
|
||||||
currentInboxId() {
|
currentInboxId() {
|
||||||
@@ -787,7 +804,6 @@ export default {
|
|||||||
</SettingsFieldSection>
|
</SettingsFieldSection>
|
||||||
|
|
||||||
<SettingsFieldSection
|
<SettingsFieldSection
|
||||||
v-if="!isAVoiceChannel"
|
|
||||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||||
>
|
>
|
||||||
@@ -1215,6 +1231,12 @@ export default {
|
|||||||
>
|
>
|
||||||
<ConfigurationPage :inbox="inbox" />
|
<ConfigurationPage :inbox="inbox" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedTabKey === 'voice-configuration'"
|
||||||
|
class="mx-6 max-w-4xl"
|
||||||
|
>
|
||||||
|
<VoiceConfigurationPage :inbox="inbox" />
|
||||||
|
</div>
|
||||||
<div v-if="selectedTabKey === 'csat'">
|
<div v-if="selectedTabKey === 'csat'">
|
||||||
<CustomerSatisfactionPage :inbox="inbox" />
|
<CustomerSatisfactionPage :inbox="inbox" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
voiceEnabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -30,7 +34,6 @@ const i18nMap = {
|
|||||||
'Channel::Api': 'API',
|
'Channel::Api': 'API',
|
||||||
'Channel::Instagram': 'INSTAGRAM',
|
'Channel::Instagram': 'INSTAGRAM',
|
||||||
'Channel::Tiktok': 'TIKTOK',
|
'Channel::Tiktok': 'TIKTOK',
|
||||||
'Channel::Voice': 'VOICE',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const twilioChannelName = () => {
|
const twilioChannelName = () => {
|
||||||
@@ -45,6 +48,9 @@ const readableChannelName = computed(() => {
|
|||||||
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
|
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
|
||||||
}
|
}
|
||||||
if (props.channelType === 'Channel::TwilioSms') {
|
if (props.channelType === 'Channel::TwilioSms') {
|
||||||
|
if (props.voiceEnabled) {
|
||||||
|
return t('INBOX_MGMT.CHANNELS.VOICE');
|
||||||
|
}
|
||||||
return twilioChannelName();
|
return twilioChannelName();
|
||||||
}
|
}
|
||||||
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
|
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
|
||||||
|
|||||||
-18
@@ -208,24 +208,6 @@ export default {
|
|||||||
</NextButton>
|
</NextButton>
|
||||||
</SettingsFieldSection>
|
</SettingsFieldSection>
|
||||||
</div>
|
</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">
|
<div v-else-if="isALineChannel">
|
||||||
<SettingsFieldSection
|
<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 = {
|
export const INBOX_FEATURES = {
|
||||||
REPLY_TO: 'replyTo',
|
REPLY_TO: 'replyTo',
|
||||||
@@ -59,8 +59,8 @@ export default {
|
|||||||
isALineChannel() {
|
isALineChannel() {
|
||||||
return this.channelType === INBOX_TYPES.LINE;
|
return this.channelType === INBOX_TYPES.LINE;
|
||||||
},
|
},
|
||||||
isAVoiceChannel() {
|
voiceCallEnabled() {
|
||||||
return this.channelType === INBOX_TYPES.VOICE;
|
return isVoiceCallEnabled(this.inbox);
|
||||||
},
|
},
|
||||||
isAnEmailChannel() {
|
isAnEmailChannel() {
|
||||||
return this.channelType === INBOX_TYPES.EMAIL;
|
return this.channelType === INBOX_TYPES.EMAIL;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# account_sid :string not null
|
# account_sid :string not null
|
||||||
|
# api_key_secret :string
|
||||||
# api_key_sid :string
|
# api_key_sid :string
|
||||||
# auth_token :string not null
|
# auth_token :string not null
|
||||||
# content_templates :jsonb
|
# content_templates :jsonb
|
||||||
@@ -11,6 +12,8 @@
|
|||||||
# medium :integer default("sms")
|
# medium :integer default("sms")
|
||||||
# messaging_service_sid :string
|
# messaging_service_sid :string
|
||||||
# phone_number :string
|
# phone_number :string
|
||||||
|
# twiml_app_sid :string
|
||||||
|
# voice_enabled :boolean default(FALSE), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# account_id :integer not null
|
# account_id :integer not null
|
||||||
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ if resource.twilio?
|
|||||||
if Current.account_user&.administrator?
|
if Current.account_user&.administrator?
|
||||||
json.auth_token resource.channel.try(:auth_token)
|
json.auth_token resource.channel.try(:auth_token)
|
||||||
json.account_sid resource.channel.try(:account_sid)
|
json.account_sid resource.channel.try(:account_sid)
|
||||||
|
json.api_key_sid resource.channel.try(:api_key_sid)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -131,8 +132,13 @@ if resource.whatsapp?
|
|||||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||||
end
|
end
|
||||||
|
|
||||||
## Voice Channel Attributes
|
## Voice attributes for TwilioSms
|
||||||
if resource.channel_type == 'Channel::Voice'
|
if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
|
||||||
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
json.voice_enabled resource.channel.voice_enabled?
|
||||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
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
|
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.string "api_key_sid"
|
||||||
t.jsonb "content_templates", default: {}
|
t.jsonb "content_templates", default: {}
|
||||||
t.datetime "content_templates_last_updated"
|
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 ["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 ["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
|
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
|
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
|
||||||
end
|
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|
|
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
|
||||||
t.string "website_url"
|
t.string "website_url"
|
||||||
t.integer "account_id"
|
t.integer "account_id"
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def generate_source_id
|
def generate_source_id
|
||||||
return super unless @inbox.channel_type == 'Channel::Voice'
|
return super unless twilio_voice_inbox?
|
||||||
|
|
||||||
phone_source_id
|
phone_source_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def phone_source_id
|
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?
|
return SecureRandom.uuid if @contact.phone_number.blank?
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def allowed_channels?
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder
|
|||||||
private
|
private
|
||||||
|
|
||||||
def message_type
|
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
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def twilio_voice_inbox?
|
||||||
|
inbox = @conversation.inbox
|
||||||
|
inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
conversation = fetch_conversation_by_display_id
|
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_sid = conference_service.ensure_conference_sid
|
||||||
conference_service.mark_agent_joined(user: current_user)
|
conference_service.mark_agent_joined(user: current_user)
|
||||||
|
|
||||||
@@ -27,19 +27,34 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
conversation = fetch_conversation_by_display_id
|
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 }
|
render json: { status: 'success', id: conversation.display_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_call_sid!(conversation)
|
def find_or_initialize_call!(conversation)
|
||||||
return conversation.identifier if conversation.identifier.present?
|
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)
|
Current.account.calls.create!(
|
||||||
incoming_sid
|
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
|
end
|
||||||
|
|
||||||
def set_voice_inbox_for_conference
|
def set_voice_inbox_for_conference
|
||||||
|
|||||||
@@ -30,9 +30,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
|||||||
end
|
end
|
||||||
|
|
||||||
def voice_inbox
|
def voice_inbox
|
||||||
@voice_inbox ||= Current.user.assigned_inboxes.where(
|
@voice_inbox ||= begin
|
||||||
account_id: Current.account.id,
|
inbox = Current.user.assigned_inboxes.where(
|
||||||
channel_type: 'Channel::Voice'
|
account_id: Current.account.id,
|
||||||
).find(params.require(:inbox_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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def channel_type_from_params
|
def channel_type_from_params
|
||||||
case permitted_params[:channel][:type]
|
return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice'
|
||||||
when 'voice'
|
|
||||||
Channel::Voice
|
super
|
||||||
else
|
|
||||||
super
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_channels_method
|
def account_channels_method
|
||||||
case permitted_params[:channel][:type]
|
return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice'
|
||||||
when 'voice'
|
|
||||||
Current.account.voice_channels
|
super
|
||||||
else
|
end
|
||||||
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
|
||||||
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}"
|
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||||
)
|
)
|
||||||
|
|
||||||
conversation = resolve_conversation
|
call = resolve_call
|
||||||
conference_sid = ensure_conference_sid!(conversation)
|
conference_sid = ensure_conference_sid!(call)
|
||||||
|
|
||||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||||
end
|
end
|
||||||
@@ -35,15 +35,14 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
event = mapped_conference_event
|
event = mapped_conference_event
|
||||||
return head :no_content unless event
|
return head :no_content unless event
|
||||||
|
|
||||||
conversation = find_conversation_for_conference!(
|
call = find_call_for_conference!(
|
||||||
friendly_name: params[:FriendlyName],
|
friendly_name: params[:FriendlyName],
|
||||||
call_sid: twilio_call_sid
|
call_sid: twilio_call_sid
|
||||||
)
|
)
|
||||||
|
|
||||||
Voice::Conference::Manager.new(
|
Voice::Conference::Manager.new(
|
||||||
conversation: conversation,
|
call: call,
|
||||||
event: event,
|
event: event,
|
||||||
call_sid: twilio_call_sid,
|
|
||||||
participant_label: participant_label
|
participant_label: participant_label
|
||||||
).process
|
).process
|
||||||
|
|
||||||
@@ -80,8 +79,8 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
from_number.start_with?('client:')
|
from_number.start_with?('client:')
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_conversation
|
def resolve_call
|
||||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
return find_call_for_agent if agent_leg?(twilio_from)
|
||||||
|
|
||||||
case twilio_direction
|
case twilio_direction
|
||||||
when 'inbound'
|
when 'inbound'
|
||||||
@@ -91,6 +90,7 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
from_number: twilio_from,
|
from_number: twilio_from,
|
||||||
call_sid: twilio_call_sid
|
call_sid: twilio_call_sid
|
||||||
)
|
)
|
||||||
|
find_call!(twilio_call_sid)
|
||||||
when 'outbound-api', 'outbound-dial'
|
when 'outbound-api', 'outbound-dial'
|
||||||
sync_outbound_leg(
|
sync_outbound_leg(
|
||||||
call_sid: twilio_call_sid,
|
call_sid: twilio_call_sid,
|
||||||
@@ -102,36 +102,47 @@ class Twilio::VoiceController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_conversation_for_agent
|
def find_call_for_agent
|
||||||
if params[:conversation_id].present?
|
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
|
else
|
||||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
find_call!(twilio_call_sid)
|
||||||
end
|
end
|
||||||
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:)
|
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||||
parent_sid = params['ParentCallSid'].presence
|
parent_sid = params['ParentCallSid'].presence
|
||||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
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(
|
Voice::CallSessionSyncService.new(
|
||||||
conversation: conversation,
|
call: call,
|
||||||
call_sid: call_sid,
|
leg_call_sid: call_sid,
|
||||||
message_call_sid: conversation.identifier,
|
|
||||||
leg: {
|
leg: {
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
to_number: twilio_to,
|
to_number: twilio_to,
|
||||||
direction: 'outbound'
|
direction: 'outbound'
|
||||||
}
|
}
|
||||||
).perform
|
).perform
|
||||||
|
call
|
||||||
end
|
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 = conversation.additional_attributes || {}
|
||||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
if attrs['conference_sid'] != name
|
||||||
conversation.update!(additional_attributes: attrs)
|
attrs['conference_sid'] = name
|
||||||
attrs['conference_sid']
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
end
|
||||||
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
def conference_twiml(conference_sid, agent_leg)
|
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)
|
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
def find_call_for_conference!(friendly_name:, call_sid:)
|
||||||
name = friendly_name.to_s
|
name = friendly_name.to_s
|
||||||
scope = current_account.conversations
|
scope = current_account.calls
|
||||||
|
|
||||||
if name.present?
|
if name.present?
|
||||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
call = scope.where("meta ->> 'conference_sid' = ?", name).first
|
||||||
return conversation if conversation
|
return call if call
|
||||||
end
|
end
|
||||||
|
|
||||||
scope.find_by!(identifier: call_sid)
|
scope.find_by!(provider: :twilio, provider_call_id: call_sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_inbox!
|
def set_inbox!
|
||||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||||
e164 = "+#{digits}"
|
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
|
@inbox = channel.inbox
|
||||||
end
|
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 :copilot_threads, dependent: :destroy_async
|
||||||
has_many :companies, 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_many :calls, dependent: :destroy_async
|
||||||
|
|
||||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
module Enterprise::Contacts::ContactableInboxesService
|
module Enterprise::Contacts::ContactableInboxesService
|
||||||
private
|
private
|
||||||
|
|
||||||
# Extend base selection to include Voice inboxes
|
# Extend base selection to include voice-enabled TwilioSms inboxes
|
||||||
def get_contactable_inbox(inbox)
|
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
|
super
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def validate_token_credentials!
|
def validate_token_credentials!
|
||||||
# Only validate Account SID + Auth Token
|
validation_client = channel.api_key_sid.present? ? api_key_client : token_client
|
||||||
token_client.incoming_phone_numbers.list(limit: 1)
|
validation_client.incoming_phone_numbers.list(limit: 1)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
|
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
|
||||||
raise
|
raise
|
||||||
@@ -58,17 +58,11 @@ class Twilio::VoiceWebhookSetupService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def api_key_client
|
def api_key_client
|
||||||
@api_key_client ||= begin
|
@api_key_client ||= ::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||||
cfg = channel.provider_config.with_indifferent_access
|
|
||||||
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def token_client
|
def token_client
|
||||||
@token_client ||= begin
|
@token_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||||
cfg = channel.provider_config.with_indifferent_access
|
|
||||||
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def log_twilio_error(context, error)
|
def log_twilio_error(context, error)
|
||||||
@@ -80,11 +74,10 @@ class Twilio::VoiceWebhookSetupService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_error_details(context, error)
|
def build_error_details(context, error)
|
||||||
cfg = channel.provider_config.with_indifferent_access
|
|
||||||
{
|
{
|
||||||
context: context,
|
context: context,
|
||||||
phone_number: channel.phone_number,
|
phone_number: channel.phone_number,
|
||||||
account_sid: cfg[:account_sid],
|
account_sid: channel.account_sid,
|
||||||
error_class: error.class.to_s,
|
error_class: error.class.to_s,
|
||||||
message: error.message
|
message: error.message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,21 @@ class Voice::CallMessageBuilder
|
|||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
validate_sender!
|
validate_sender!
|
||||||
message = latest_message
|
existing = existing_message
|
||||||
message ? update_message!(message) : create_message!
|
existing ? update_message!(existing) : create_message!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||||
|
|
||||||
def latest_message
|
def existing_message
|
||||||
conversation.messages.voice_calls.order(created_at: :desc).first
|
sid = payload[:call_sid] || payload['call_sid']
|
||||||
|
return if sid.blank?
|
||||||
|
|
||||||
|
conversation.messages.voice_calls
|
||||||
|
.where("content_attributes -> 'data' ->> 'call_sid' = ?", sid)
|
||||||
|
.first
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_message!(message)
|
def update_message!(message)
|
||||||
@@ -37,6 +42,7 @@ class Voice::CallMessageBuilder
|
|||||||
content_attributes: { 'data' => base_payload },
|
content_attributes: { 'data' => base_payload },
|
||||||
sender: sender
|
sender: sender
|
||||||
)
|
)
|
||||||
|
message
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_message!
|
def create_message!
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
class Voice::CallSessionSyncService
|
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)
|
def initialize(call:, leg:, leg_call_sid: nil)
|
||||||
@conversation = conversation
|
@call = call
|
||||||
@call_sid = call_sid
|
@leg_call_sid = leg_call_sid || call.provider_call_id
|
||||||
@message_call_sid = message_call_sid || call_sid
|
|
||||||
@from_number = leg[:from_number]
|
@from_number = leg[:from_number]
|
||||||
@to_number = leg[:to_number]
|
@to_number = leg[:to_number]
|
||||||
@direction = leg[:direction]
|
@direction = leg[:direction]
|
||||||
@@ -20,16 +19,18 @@ class Voice::CallSessionSyncService
|
|||||||
sync_voice_call_message!(attrs)
|
sync_voice_call_message!(attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
conversation
|
call
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
delegate :conversation, to: :call
|
||||||
|
|
||||||
def refreshed_attributes
|
def refreshed_attributes
|
||||||
attrs = (conversation.additional_attributes || {}).dup
|
attrs = (conversation.additional_attributes || {}).dup
|
||||||
attrs['call_direction'] = direction
|
attrs['call_direction'] = direction
|
||||||
attrs['call_status'] ||= 'ringing'
|
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'] ||= {}
|
||||||
attrs['meta']['initiated_at'] ||= current_timestamp
|
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||||
attrs
|
attrs
|
||||||
@@ -40,7 +41,7 @@ class Voice::CallSessionSyncService
|
|||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
payload: {
|
payload: {
|
||||||
call_sid: message_call_sid,
|
call_sid: call.provider_call_id,
|
||||||
status: attrs['call_status'],
|
status: attrs['call_status'],
|
||||||
conference_sid: attrs['conference_sid'],
|
conference_sid: attrs['conference_sid'],
|
||||||
from_number: origin_number_for(direction),
|
from_number: origin_number_for(direction),
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
class Voice::CallStatus::Manager
|
class Voice::CallStatus::Manager
|
||||||
pattr_initialize [:conversation!, :call_sid]
|
pattr_initialize [:call!]
|
||||||
|
|
||||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||||
TERMINAL_STATUSES = %w[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)
|
def process_status_update(status, duration: nil, timestamp: nil)
|
||||||
return unless ALLOWED_STATUSES.include?(status)
|
return unless ALLOWED_STATUSES.include?(status)
|
||||||
|
|
||||||
current_status = conversation.additional_attributes&.dig('call_status')
|
|
||||||
return if current_status == status
|
return if current_status == status
|
||||||
|
|
||||||
apply_status(status, duration: duration, timestamp: timestamp)
|
apply_status(status, duration: duration, timestamp: timestamp)
|
||||||
@@ -16,6 +23,12 @@ class Voice::CallStatus::Manager
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
delegate :conversation, to: :call
|
||||||
|
|
||||||
|
def current_status
|
||||||
|
conversation.additional_attributes&.dig('call_status')
|
||||||
|
end
|
||||||
|
|
||||||
def apply_status(status, duration:, timestamp:)
|
def apply_status(status, duration:, timestamp:)
|
||||||
attrs = (conversation.additional_attributes || {}).dup
|
attrs = (conversation.additional_attributes || {}).dup
|
||||||
attrs['call_status'] = status
|
attrs['call_status'] = status
|
||||||
@@ -31,6 +44,16 @@ class Voice::CallStatus::Manager
|
|||||||
additional_attributes: attrs,
|
additional_attributes: attrs,
|
||||||
last_activity_at: current_time
|
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
|
end
|
||||||
|
|
||||||
def resolved_duration(attrs, provided_duration, timestamp)
|
def resolved_duration(attrs, provided_duration, timestamp)
|
||||||
@@ -43,10 +66,7 @@ class Voice::CallStatus::Manager
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_message(status)
|
def update_message(status)
|
||||||
message = conversation.messages
|
message = call.message || fallback_message
|
||||||
.where(content_type: 'voice_call')
|
|
||||||
.order(created_at: :desc)
|
|
||||||
.first
|
|
||||||
return unless message
|
return unless message
|
||||||
|
|
||||||
data = (message.content_attributes || {}).dup
|
data = (message.content_attributes || {}).dup
|
||||||
@@ -56,6 +76,13 @@ class Voice::CallStatus::Manager
|
|||||||
message.update!(content_attributes: data)
|
message.update!(content_attributes: data)
|
||||||
end
|
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
|
def now_seconds
|
||||||
current_time.to_i
|
current_time.to_i
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
class Voice::Conference::Manager
|
class Voice::Conference::Manager
|
||||||
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
pattr_initialize [:call!, :event!, :participant_label]
|
||||||
|
|
||||||
def process
|
def process
|
||||||
case event
|
case event
|
||||||
@@ -17,19 +17,21 @@ class Voice::Conference::Manager
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
delegate :conversation, to: :call
|
||||||
|
|
||||||
def status_manager
|
def status_manager
|
||||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
|
||||||
conversation: conversation,
|
|
||||||
call_sid: call_sid
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_conference_sid!
|
def ensure_conference_sid!
|
||||||
attrs = conversation.additional_attributes || {}
|
name = Voice::Conference::Name.for(call)
|
||||||
return if attrs['conference_sid'].present?
|
call.update!(meta: call.meta.merge('conference_sid' => name)) if call.meta['conference_sid'].blank?
|
||||||
|
|
||||||
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
conv_attrs = (conversation.additional_attributes || {}).dup
|
||||||
conversation.update!(additional_attributes: attrs)
|
return if conv_attrs['conference_sid'].present?
|
||||||
|
|
||||||
|
conv_attrs['conference_sid'] = name
|
||||||
|
conversation.update!(additional_attributes: conv_attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_ringing!
|
def mark_ringing!
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
module Voice::Conference::Name
|
module Voice::Conference::Name
|
||||||
def self.for(conversation)
|
def self.for(call)
|
||||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
"conf_account_#{call.account_id}_call_#{call.id}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,21 +13,32 @@ class Voice::InboundCallBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
|
existing = find_existing_call
|
||||||
|
return existing.conversation if existing
|
||||||
|
|
||||||
timestamp = current_timestamp
|
timestamp = current_timestamp
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
contact = ensure_contact!
|
contact = ensure_contact!
|
||||||
contact_inbox = ensure_contact_inbox!(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
|
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
|
conversation
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def find_existing_call
|
||||||
|
account.calls.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_contact!
|
def ensure_contact!
|
||||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||||
record.name = from_number if record.name.blank?
|
record.name = from_number if record.name.blank?
|
||||||
@@ -43,10 +54,10 @@ class Voice::InboundCallBuilder
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_conversation
|
def find_open_conversation(contact_inbox)
|
||||||
return if call_sid.blank?
|
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
|
end
|
||||||
|
|
||||||
def create_conversation!(contact, contact_inbox)
|
def create_conversation!(contact, contact_inbox)
|
||||||
@@ -54,34 +65,33 @@ class Voice::InboundCallBuilder
|
|||||||
contact_inbox_id: contact_inbox.id,
|
contact_inbox_id: contact_inbox.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
status: :open,
|
status: :open
|
||||||
identifier: call_sid
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_conversation!(conversation, timestamp)
|
def create_call!(conversation, contact, timestamp)
|
||||||
attrs = {
|
call = account.calls.create!(
|
||||||
'call_direction' => 'inbound',
|
inbox: inbox,
|
||||||
'call_status' => 'ringing',
|
conversation: conversation,
|
||||||
'conference_sid' => Voice::Conference::Name.for(conversation),
|
contact: contact,
|
||||||
'meta' => { 'initiated_at' => timestamp }
|
provider: :twilio,
|
||||||
}
|
direction: :incoming,
|
||||||
|
status: 'ringing',
|
||||||
conversation.update!(
|
provider_call_id: call_sid,
|
||||||
identifier: call_sid,
|
meta: { 'initiated_at' => timestamp }
|
||||||
additional_attributes: attrs,
|
|
||||||
last_activity_at: current_time
|
|
||||||
)
|
)
|
||||||
|
call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call)))
|
||||||
|
call
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_voice_message!(conversation, timestamp)
|
def build_voice_message!(conversation, call, timestamp)
|
||||||
Voice::CallMessageBuilder.perform!(
|
Voice::CallMessageBuilder.perform!(
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
direction: 'inbound',
|
direction: 'inbound',
|
||||||
payload: {
|
payload: {
|
||||||
call_sid: call_sid,
|
call_sid: call.provider_call_id,
|
||||||
status: 'ringing',
|
status: 'ringing',
|
||||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
conference_sid: call.meta['conference_sid'],
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
to_number: inbox.channel&.phone_number
|
to_number: inbox.channel&.phone_number
|
||||||
},
|
},
|
||||||
@@ -89,6 +99,20 @@ class Voice::InboundCallBuilder
|
|||||||
)
|
)
|
||||||
end
|
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
|
def current_timestamp
|
||||||
@current_timestamp ||= current_time.to_i
|
@current_timestamp ||= current_time.to_i
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ class Voice::OutboundCallBuilder
|
|||||||
contact_inbox = ensure_contact_inbox!
|
contact_inbox = ensure_contact_inbox!
|
||||||
conversation = create_conversation!(contact_inbox)
|
conversation = create_conversation!(contact_inbox)
|
||||||
conversation.reload
|
conversation.reload
|
||||||
conference_sid = Voice::Conference::Name.for(conversation)
|
|
||||||
call_sid = initiate_call!
|
call_sid = initiate_call!
|
||||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
call = create_call!(conversation, call_sid, timestamp)
|
||||||
build_voice_message!(conversation, call_sid, conference_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 }
|
{ conversation: conversation, call_sid: call_sid }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -56,30 +59,30 @@ class Voice::OutboundCallBuilder
|
|||||||
)[:call_sid]
|
)[:call_sid]
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
def create_call!(conversation, call_sid, timestamp)
|
||||||
attrs = {
|
call = account.calls.create!(
|
||||||
'call_direction' => 'outbound',
|
inbox: inbox,
|
||||||
'call_status' => 'ringing',
|
conversation: conversation,
|
||||||
'agent_id' => user.id,
|
contact: contact,
|
||||||
'conference_sid' => conference_sid,
|
provider: :twilio,
|
||||||
'meta' => { 'initiated_at' => timestamp }
|
direction: :outgoing,
|
||||||
}
|
status: 'ringing',
|
||||||
|
provider_call_id: call_sid,
|
||||||
conversation.update!(
|
accepted_by_agent_id: user.id,
|
||||||
identifier: call_sid,
|
meta: { 'initiated_at' => timestamp }
|
||||||
additional_attributes: attrs,
|
|
||||||
last_activity_at: current_time
|
|
||||||
)
|
)
|
||||||
|
call.update!(meta: call.meta.merge('conference_sid' => Voice::Conference::Name.for(call)))
|
||||||
|
call
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
def build_voice_message!(conversation, call, timestamp)
|
||||||
Voice::CallMessageBuilder.perform!(
|
Voice::CallMessageBuilder.perform!(
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
direction: 'outbound',
|
direction: 'outbound',
|
||||||
payload: {
|
payload: {
|
||||||
call_sid: call_sid,
|
call_sid: call.provider_call_id,
|
||||||
status: 'ringing',
|
status: 'ringing',
|
||||||
conference_sid: conference_sid,
|
conference_sid: call.meta['conference_sid'],
|
||||||
from_number: inbox.channel&.phone_number,
|
from_number: inbox.channel&.phone_number,
|
||||||
to_number: contact.phone_number
|
to_number: contact.phone_number
|
||||||
},
|
},
|
||||||
@@ -88,6 +91,21 @@ class Voice::OutboundCallBuilder
|
|||||||
)
|
)
|
||||||
end
|
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
|
def current_timestamp
|
||||||
@current_timestamp ||= current_time.to_i
|
@current_timestamp ||= current_time.to_i
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter
|
|||||||
end
|
end
|
||||||
|
|
||||||
def twilio_client
|
def twilio_client
|
||||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token)
|
||||||
end
|
|
||||||
|
|
||||||
def config
|
|
||||||
@config ||= @channel.provider_config_hash
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
class Voice::Provider::Twilio::ConferenceService
|
class Voice::Provider::Twilio::ConferenceService
|
||||||
pattr_initialize [:conversation!, { twilio_client: nil }]
|
pattr_initialize [:call!, { twilio_client: nil }]
|
||||||
|
|
||||||
def ensure_conference_sid
|
def ensure_conference_sid
|
||||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
existing = call.meta['conference_sid']
|
||||||
return existing if existing.present?
|
return existing if existing.present?
|
||||||
|
|
||||||
sid = Voice::Conference::Name.for(conversation)
|
sid = Voice::Conference::Name.for(call)
|
||||||
merge_attributes('conference_sid' => sid)
|
call.update!(meta: call.meta.merge('conference_sid' => sid))
|
||||||
|
merge_conversation_attributes('conference_sid' => sid)
|
||||||
sid
|
sid
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_agent_joined(user:)
|
def mark_agent_joined(user:)
|
||||||
merge_attributes(
|
call.update!(accepted_by_agent_id: user.id)
|
||||||
|
merge_conversation_attributes(
|
||||||
'agent_joined' => true,
|
'agent_joined' => true,
|
||||||
'joined_at' => Time.current.to_i,
|
'joined_at' => Time.current.to_i,
|
||||||
'joined_by' => { id: user.id, name: user.name }
|
'joined_by' => { id: user.id, name: user.name }
|
||||||
@@ -21,26 +23,27 @@ class Voice::Provider::Twilio::ConferenceService
|
|||||||
def end_conference
|
def end_conference
|
||||||
twilio_client
|
twilio_client
|
||||||
.conferences
|
.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') }
|
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def merge_attributes(attrs)
|
delegate :conversation, to: :call
|
||||||
|
|
||||||
|
def merge_conversation_attributes(attrs)
|
||||||
current = conversation.additional_attributes || {}
|
current = conversation.additional_attributes || {}
|
||||||
conversation.update!(additional_attributes: current.merge(attrs))
|
conversation.update!(additional_attributes: current.merge(attrs))
|
||||||
end
|
end
|
||||||
|
|
||||||
def twilio_client
|
def twilio_client
|
||||||
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
|
@twilio_client ||= begin
|
||||||
end
|
channel = conversation.inbox.channel
|
||||||
|
if channel.api_key_sid.present? && channel.try(:api_key_secret).present?
|
||||||
def account_sid
|
::Twilio::REST::Client.new(channel.api_key_sid, channel.api_key_secret, channel.account_sid)
|
||||||
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
|
else
|
||||||
end
|
::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||||
|
end
|
||||||
def auth_token
|
end
|
||||||
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService
|
|||||||
token: access_token.to_jwt,
|
token: access_token.to_jwt,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
voice_enabled: true,
|
voice_enabled: true,
|
||||||
account_sid: config['account_sid'],
|
account_sid: channel.account_sid,
|
||||||
agent_id: user.id,
|
agent_id: user.id,
|
||||||
account_id: account.id,
|
account_id: account.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
phone_number: inbox.channel.phone_number,
|
phone_number: channel.phone_number,
|
||||||
twiml_endpoint: twiml_url,
|
twiml_endpoint: twiml_url,
|
||||||
has_twiml_app: config['twiml_app_sid'].present?
|
has_twiml_app: channel.twiml_app_sid.present?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def config
|
def channel
|
||||||
@config ||= inbox.channel.provider_config_hash || {}
|
@channel ||= inbox.channel
|
||||||
end
|
end
|
||||||
|
|
||||||
def identity
|
def identity
|
||||||
@@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService
|
|||||||
|
|
||||||
def access_token
|
def access_token
|
||||||
Twilio::JWT::AccessToken.new(
|
Twilio::JWT::AccessToken.new(
|
||||||
config['account_sid'],
|
channel.account_sid,
|
||||||
config['api_key_sid'],
|
channel.api_key_sid,
|
||||||
config['api_key_secret'],
|
channel.api_key_secret,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
ttl: 1.hour.to_i
|
ttl: 1.hour.to_i
|
||||||
).tap { |token| token.add_grant(voice_grant) }
|
).tap { |token| token.add_grant(voice_grant) }
|
||||||
@@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService
|
|||||||
def voice_grant
|
def voice_grant
|
||||||
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
||||||
grant.incoming_allow = true
|
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
|
grant.outgoing_application_params = outgoing_params
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService
|
|||||||
agent_id: user.id,
|
agent_id: user.id,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
client_name: identity,
|
client_name: identity,
|
||||||
accountSid: config['account_sid'],
|
accountSid: channel.account_sid,
|
||||||
is_agent: 'true'
|
is_agent: 'true'
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def twiml_url
|
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)
|
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
|
|||||||
normalized_status = normalize_status(call_status)
|
normalized_status = normalize_status(call_status)
|
||||||
return if normalized_status.blank?
|
return if normalized_status.blank?
|
||||||
|
|
||||||
conversation = account.conversations.find_by(identifier: call_sid)
|
call = account.calls.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||||
return unless conversation
|
return unless call
|
||||||
|
|
||||||
Voice::CallStatus::Manager.new(
|
Voice::CallStatus::Manager.new(call: call).process_status_update(
|
||||||
conversation: conversation,
|
|
||||||
call_sid: call_sid
|
|
||||||
).process_status_update(
|
|
||||||
normalized_status,
|
normalized_status,
|
||||||
duration: payload_duration,
|
duration: payload_duration,
|
||||||
timestamp: payload_timestamp
|
timestamp: payload_timestamp
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||||
let(:account) { create(:account) }
|
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(:voice_inbox) { voice_channel.inbox }
|
||||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
||||||
let(:admin) { create(:user, :administrator, account: account) }
|
let(:admin) { create(:user, :administrator, account: account) }
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a voice inbox when administrator' do
|
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,
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||||
perform: "AP#{SecureRandom.hex(16)}"))
|
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)}",
|
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||||
auth_token: SecureRandom.hex(16),
|
auth_token: SecureRandom.hex(16),
|
||||||
api_key_sid: SecureRandom.hex(8),
|
api_key_sid: SecureRandom.hex(8),
|
||||||
api_key_secret: SecureRandom.hex(16),
|
api_key_secret: SecureRandom.hex(16) } } },
|
||||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
|
|
||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe 'Twilio::VoiceController', type: :request do
|
RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||||
let(:account) { create(:account) }
|
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(:inbox) { channel.inbox }
|
||||||
let(:digits) { channel.phone_number.delete_prefix('+') }
|
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(:api_key_secret) { 'api_key_secret_123' }
|
||||||
let(:phone_number) { '+15551230001' }
|
let(:phone_number) { '+15551230001' }
|
||||||
let(:frontend_url) { 'https://app.chatwoot.test' }
|
let(:frontend_url) { 'https://app.chatwoot.test' }
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
|
||||||
let(:channel) do
|
let(:channel) do
|
||||||
build(:channel_voice, phone_number: phone_number, provider_config: {
|
build(:channel_twilio_sms, :with_voice,
|
||||||
account_sid: account_sid,
|
account: account,
|
||||||
auth_token: auth_token,
|
phone_number: phone_number,
|
||||||
api_key_sid: api_key_sid,
|
account_sid: account_sid,
|
||||||
api_key_secret: api_key_secret
|
auth_token: auth_token,
|
||||||
})
|
api_key_sid: api_key_sid,
|
||||||
|
api_key_secret: api_key_secret)
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
|
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
|
RSpec.describe Voice::InboundCallBuilder do
|
||||||
let(:account) { create(:account) }
|
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(:inbox) { channel.inbox }
|
||||||
let(:from_number) { '+15550001111' }
|
let(:from_number) { '+15550001111' }
|
||||||
let(:to_number) { channel.phone_number }
|
let(:to_number) { channel.phone_number }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe Voice::OutboundCallBuilder do
|
RSpec.describe Voice::OutboundCallBuilder do
|
||||||
let(:account) { create(:account) }
|
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(:inbox) { channel.inbox }
|
||||||
let(:user) { create(:user, account: account) }
|
let(:user) { create(:user, account: account) }
|
||||||
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
|||||||
|
|
||||||
describe Voice::Provider::Twilio::Adapter do
|
describe Voice::Provider::Twilio::Adapter do
|
||||||
let(:account) { create(:account) }
|
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(:adapter) { described_class.new(channel) }
|
||||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||||
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
|
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(calls_double).to receive(:create).and_return(call_instance)
|
||||||
|
|
||||||
allow(Twilio::REST::Client).to receive(:new)
|
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)
|
.and_return(client_double)
|
||||||
|
|
||||||
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
|
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
|
describe Voice::Provider::Twilio::ConferenceService do
|
||||||
let(:account) { create(:account) }
|
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(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
|
||||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
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, twilio_client: twilio_client) }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
|||||||
describe Voice::Provider::Twilio::TokenService do
|
describe Voice::Provider::Twilio::TokenService do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
let(:user) { create(:user, :administrator, account: 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(:inbox) { voice_channel.inbox }
|
||||||
|
|
||||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
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' } }
|
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
|
||||||
)
|
)
|
||||||
end
|
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(:inbox) { channel.inbox }
|
||||||
let(:from_number) { '+15550002222' }
|
let(:from_number) { '+15550002222' }
|
||||||
let(:call_sid) { 'CATESTSTATUS123' }
|
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
|
trait :whatsapp do
|
||||||
medium { :whatsapp }
|
medium { :whatsapp }
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user