feat: outbound voice call essentials (#12782)
- Enables outbound voice calls in voice channel . We are only caring about wiring the logic to trigger outgoing calls to the call button introduced in previous PRs. We will connect it to call component in subsequent PRs ref: #11602 ## Screens <img width="2304" height="1202" alt="image" src="https://github.com/user-attachments/assets/b91543a8-8d4e-4229-bd80-9727b42c7b0f" /> <img width="2304" height="1200" alt="image" src="https://github.com/user-attachments/assets/1a1dad2a-8cb2-4aa2-9702-c062416556a7" /> --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
This commit is contained in:
@@ -10,6 +10,9 @@
|
|||||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||||
- **Run Project**: `overmind start -f Procfile.dev`
|
- **Run Project**: `overmind start -f Procfile.dev`
|
||||||
|
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||||
|
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||||
|
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
@@ -37,6 +40,8 @@
|
|||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- MVP focus: Least code change, happy-path only
|
||||||
- No unnecessary defensive programming
|
- No unnecessary defensive programming
|
||||||
|
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||||
|
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
|
|||||||
@@ -103,3 +103,5 @@ class ContactInboxBuilder
|
|||||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ class Messages::MessageBuilder
|
|||||||
private: @private,
|
private: @private,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content_type: @params[:content_type],
|
content_type: @params[:content_type],
|
||||||
|
content_attributes: content_attributes.presence,
|
||||||
items: @items,
|
items: @items,
|
||||||
in_reply_to: @in_reply_to,
|
in_reply_to: @in_reply_to,
|
||||||
echo_id: @params[:echo_id],
|
echo_id: @params[:echo_id],
|
||||||
@@ -222,3 +223,5 @@ class Messages::MessageBuilder
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${contactId}/labels`);
|
return axios.get(`${this.url}/${contactId}/labels`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initiateCall(contactId, inboxId) {
|
||||||
|
return axios.post(`${this.url}/${contactId}/call`, {
|
||||||
|
inbox_id: inboxId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
updateContactLabels(contactId, labels) {
|
updateContactLabels(contactId, labels) {
|
||||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="selectedContact?.phoneNumber"
|
:phone="selectedContact?.phoneNumber"
|
||||||
|
:contact-id="contactId"
|
||||||
:label="$t('CONTACT_PANEL.CALL')"
|
:label="$t('CONTACT_PANEL.CALL')"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, useAttrs } from 'vue';
|
import { computed, ref, useAttrs } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
phone: { type: String, default: '' },
|
phone: { type: String, default: '' },
|
||||||
|
contactId: { type: [String, Number], required: true },
|
||||||
label: { type: String, default: '' },
|
label: { type: String, default: '' },
|
||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
size: { type: String, default: 'sm' },
|
size: { type: String, default: 'sm' },
|
||||||
@@ -18,10 +21,17 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
const attrs = useAttrs();
|
const attrs = useAttrs();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
|
|
||||||
const voiceInboxes = computed(() =>
|
const voiceInboxes = computed(() =>
|
||||||
(inboxesList.value || []).filter(
|
(inboxesList.value || []).filter(
|
||||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||||
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
|||||||
// Unified behavior: hide when no phone
|
// Unified behavior: hide when no phone
|
||||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||||
|
|
||||||
const dialogRef = ref(null);
|
const isInitiatingCall = computed(() => {
|
||||||
|
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||||
|
});
|
||||||
|
|
||||||
const onClick = () => {
|
const navigateToConversation = conversationId => {
|
||||||
|
const accountId = route.params.accountId;
|
||||||
|
if (conversationId && accountId) {
|
||||||
|
const path = frontendURL(
|
||||||
|
conversationUrl({
|
||||||
|
accountId,
|
||||||
|
id: conversationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.push({ path });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCall = async inboxId => {
|
||||||
|
if (isInitiatingCall.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await store.dispatch('contacts/initiateCall', {
|
||||||
|
contactId: props.contactId,
|
||||||
|
inboxId,
|
||||||
|
});
|
||||||
|
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||||
|
navigateToConversation(response?.conversation_id);
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error?.message;
|
||||||
|
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
if (voiceInboxes.value.length > 1) {
|
if (voiceInboxes.value.length > 1) {
|
||||||
dialogRef.value?.open();
|
dialogRef.value?.open();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
const [inbox] = voiceInboxes.value;
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickInbox = () => {
|
const onPickInbox = async inbox => {
|
||||||
// Placeholder until actual call wiring happens
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
|
await startCall(inbox.id);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,6 +96,8 @@ const onPickInbox = () => {
|
|||||||
v-if="shouldRender"
|
v-if="shouldRender"
|
||||||
v-tooltip.top-end="tooltipLabel || null"
|
v-tooltip.top-end="tooltipLabel || null"
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
|
:disabled="isInitiatingCall"
|
||||||
|
:is-loading="isInitiatingCall"
|
||||||
:label="label"
|
:label="label"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:size="size"
|
:size="size"
|
||||||
|
|||||||
@@ -1,41 +1,102 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||||
|
|
||||||
const { contentAttributes } = useMessageContext();
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
|
|
||||||
|
const LABEL_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBTEXT_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BG_COLOR_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { contentAttributes, messageType } = useMessageContext();
|
||||||
|
|
||||||
const data = computed(() => contentAttributes.value?.data);
|
const data = computed(() => contentAttributes.value?.data);
|
||||||
|
const status = computed(() => data.value?.status?.toString());
|
||||||
|
|
||||||
const status = computed(() => data.value?.status);
|
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||||
const direction = computed(() => data.value?.call_direction);
|
const isFailed = computed(() =>
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||||
|
);
|
||||||
|
|
||||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
const labelKey = computed(() => {
|
||||||
useVoiceCallStatus(status, direction);
|
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||||
|
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||||
const containerRingClass = computed(() => {
|
return isOutbound.value
|
||||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const subtextKey = computed(() => {
|
||||||
|
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||||
|
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||||
|
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||||
|
});
|
||||||
|
|
||||||
|
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-0 border-none" hide-meta>
|
<BaseBubble class="p-0 border-none" hide-meta>
|
||||||
<div
|
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
|
||||||
:class="containerRingClass"
|
|
||||||
>
|
|
||||||
<div class="flex gap-3 items-center p-3 w-full">
|
<div class="flex gap-3 items-center p-3 w-full">
|
||||||
<div
|
<div
|
||||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||||
:class="bubbleIconBg"
|
:class="bgColor"
|
||||||
>
|
>
|
||||||
<span class="text-xl" :class="bubbleIconName" />
|
<Icon
|
||||||
|
class="size-5"
|
||||||
|
:icon="iconName"
|
||||||
|
:class="{
|
||||||
|
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||||
|
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex overflow-hidden flex-col flex-grow">
|
<div class="flex overflow-hidden flex-col flex-grow">
|
||||||
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||||
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
{{ $t(labelKey) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-n-slate-11">
|
||||||
|
{{ $t(subtextKey) }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,3 +73,16 @@ export const MEDIA_TYPES = [
|
|||||||
ATTACHMENT_TYPES.AUDIO,
|
ATTACHMENT_TYPES.AUDIO,
|
||||||
ATTACHMENT_TYPES.IG_REEL,
|
ATTACHMENT_TYPES.IG_REEL,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const VOICE_CALL_STATUS = {
|
||||||
|
IN_PROGRESS: 'in-progress',
|
||||||
|
RINGING: 'ringing',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
NO_ANSWER: 'no-answer',
|
||||||
|
FAILED: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VOICE_CALL_DIRECTION = {
|
||||||
|
INBOUND: 'inbound',
|
||||||
|
OUTBOUND: 'outbound',
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import Avatar from 'next/avatar/Avatar.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
|
|||||||
import PriorityMark from './PriorityMark.vue';
|
import PriorityMark from './PriorityMark.vue';
|
||||||
import SLACardLabel from './components/SLACardLabel.vue';
|
import SLACardLabel from './components/SLACardLabel.vue';
|
||||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||||
|
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
activeLabel: { type: String, default: '' },
|
activeLabel: { type: String, default: '' },
|
||||||
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
|||||||
|
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
|
||||||
const callStatus = computed(
|
const voiceCallData = computed(() => ({
|
||||||
() => props.chat.additional_attributes?.call_status
|
status: props.chat.additional_attributes?.call_status,
|
||||||
);
|
direction: props.chat.additional_attributes?.call_direction,
|
||||||
const callDirection = computed(
|
}));
|
||||||
() => props.chat.additional_attributes?.call_direction
|
|
||||||
);
|
|
||||||
|
|
||||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
|
||||||
useVoiceCallStatus(callStatus, callDirection);
|
|
||||||
|
|
||||||
const inboxId = computed(() => props.chat.inbox_id);
|
const inboxId = computed(() => props.chat.inbox_id);
|
||||||
|
|
||||||
@@ -317,20 +312,13 @@ const deleteConversation = () => {
|
|||||||
>
|
>
|
||||||
{{ currentContact.name }}
|
{{ currentContact.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<VoiceCallStatus
|
||||||
v-if="callStatus"
|
v-if="voiceCallData.status"
|
||||||
key="voice-status-row"
|
key="voice-status-row"
|
||||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
:status="voiceCallData.status"
|
||||||
:class="messagePreviewClass"
|
:direction="voiceCallData.direction"
|
||||||
>
|
:message-preview-class="messagePreviewClass"
|
||||||
<span
|
/>
|
||||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
|
||||||
:class="[voiceIconColor]"
|
|
||||||
/>
|
|
||||||
<span class="mx-1">
|
|
||||||
{{ $t(voiceLabelKey) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<MessagePreview
|
<MessagePreview
|
||||||
v-else-if="lastMessageInChat"
|
v-else-if="lastMessageInChat"
|
||||||
key="message-preview"
|
key="message-preview"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
VOICE_CALL_STATUS,
|
||||||
|
VOICE_CALL_DIRECTION,
|
||||||
|
} from 'dashboard/components-next/message/constants';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
status: { type: String, default: '' },
|
||||||
|
direction: { type: String, default: '' },
|
||||||
|
messagePreviewClass: { type: [String, Array, Object], default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const LABEL_KEYS = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_MAP = {
|
||||||
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||||
|
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOutbound = computed(
|
||||||
|
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
);
|
||||||
|
const isFailed = computed(() =>
|
||||||
|
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelKey = computed(() => {
|
||||||
|
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||||
|
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||||
|
return isOutbound.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
}
|
||||||
|
return isFailed.value
|
||||||
|
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||||
|
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = computed(() => {
|
||||||
|
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||||
|
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusColor = computed(
|
||||||
|
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
:class="messagePreviewClass"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
class="inline-block -mt-0.5 align-middle size-4"
|
||||||
|
:icon="iconName"
|
||||||
|
:class="statusColor"
|
||||||
|
/>
|
||||||
|
<span class="mx-1" :class="statusColor">
|
||||||
|
{{ $t(labelKey) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { computed, unref } from 'vue';
|
|
||||||
|
|
||||||
const CALL_STATUSES = {
|
|
||||||
IN_PROGRESS: 'in-progress',
|
|
||||||
RINGING: 'ringing',
|
|
||||||
NO_ANSWER: 'no-answer',
|
|
||||||
BUSY: 'busy',
|
|
||||||
FAILED: 'failed',
|
|
||||||
COMPLETED: 'completed',
|
|
||||||
CANCELED: 'canceled',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CALL_DIRECTIONS = {
|
|
||||||
INBOUND: 'inbound',
|
|
||||||
OUTBOUND: 'outbound',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for handling voice call status display logic
|
|
||||||
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
|
|
||||||
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
|
|
||||||
* @returns {Object} UI properties for displaying call status
|
|
||||||
*/
|
|
||||||
export function useVoiceCallStatus(statusRef, directionRef) {
|
|
||||||
const status = computed(() => unref(statusRef)?.toString());
|
|
||||||
const direction = computed(() => unref(directionRef)?.toString());
|
|
||||||
|
|
||||||
// Status group helpers
|
|
||||||
const isFailedStatus = computed(() =>
|
|
||||||
[
|
|
||||||
CALL_STATUSES.NO_ANSWER,
|
|
||||||
CALL_STATUSES.BUSY,
|
|
||||||
CALL_STATUSES.FAILED,
|
|
||||||
].includes(status.value)
|
|
||||||
);
|
|
||||||
const isEndedStatus = computed(() =>
|
|
||||||
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
|
|
||||||
);
|
|
||||||
const isOutbound = computed(
|
|
||||||
() => direction.value === CALL_DIRECTIONS.OUTBOUND
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.RINGING) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.NO_ANSWER) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
|
||||||
});
|
|
||||||
|
|
||||||
const subtextKey = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.RINGING) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
|
||||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
|
||||||
});
|
|
||||||
|
|
||||||
const bubbleIconName = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return isOutbound.value
|
|
||||||
? 'i-ph-phone-outgoing-fill'
|
|
||||||
: 'i-ph-phone-incoming-fill';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'i-ph-phone-x-fill';
|
|
||||||
}
|
|
||||||
|
|
||||||
// For ringing/completed/canceled show direction when possible
|
|
||||||
return isOutbound.value
|
|
||||||
? 'i-ph-phone-outgoing-fill'
|
|
||||||
: 'i-ph-phone-incoming-fill';
|
|
||||||
});
|
|
||||||
|
|
||||||
const bubbleIconBg = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
|
||||||
return 'bg-n-teal-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'bg-n-ruby-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'bg-n-slate-11';
|
|
||||||
}
|
|
||||||
|
|
||||||
// default (e.g., ringing)
|
|
||||||
return 'bg-n-teal-9 animate-pulse';
|
|
||||||
});
|
|
||||||
|
|
||||||
const listIconColor = computed(() => {
|
|
||||||
const s = status.value;
|
|
||||||
|
|
||||||
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
|
|
||||||
return 'text-n-teal-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFailedStatus.value) {
|
|
||||||
return 'text-n-ruby-9';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEndedStatus.value) {
|
|
||||||
return 'text-n-slate-11';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'text-n-teal-9';
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
labelKey,
|
|
||||||
subtextKey,
|
|
||||||
bubbleIconName,
|
|
||||||
bubbleIconBg,
|
|
||||||
listIconColor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"CREATED_AT_LABEL": "Created",
|
"CREATED_AT_LABEL": "Created",
|
||||||
"NEW_MESSAGE": "New message",
|
"NEW_MESSAGE": "New message",
|
||||||
"CALL": "Call",
|
"CALL": "Call",
|
||||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
"CALL_INITIATED": "Calling the contact…",
|
||||||
|
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||||
"VOICE_INBOX_PICKER": {
|
"VOICE_INBOX_PICKER": {
|
||||||
"TITLE": "Choose a voice inbox"
|
"TITLE": "Choose a voice inbox"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export default {
|
|||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
<VoiceCallButton
|
<VoiceCallButton
|
||||||
:phone="contact.phone_number"
|
:phone="contact.phone_number"
|
||||||
|
:contact-id="contact.id"
|
||||||
icon="i-ri-phone-fill"
|
icon="i-ri-phone-fill"
|
||||||
size="sm"
|
size="sm"
|
||||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||||
|
|||||||
@@ -302,4 +302,22 @@ export const actions = {
|
|||||||
clearContactFilters({ commit }) {
|
clearContactFilters({ commit }) {
|
||||||
commit(types.CLEAR_CONTACT_FILTERS);
|
commit(types.CLEAR_CONTACT_FILTERS);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
initiateCall: async ({ commit }, { contactId, inboxId }) => {
|
||||||
|
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
|
||||||
|
try {
|
||||||
|
const response = await ContactAPI.initiateCall(contactId, inboxId);
|
||||||
|
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
throw new ExceptionWithMessage(error.response.data.message);
|
||||||
|
} else if (error.response?.data?.error) {
|
||||||
|
throw new ExceptionWithMessage(error.response.data.error);
|
||||||
|
} else {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const state = {
|
|||||||
isDeleting: false,
|
isDeleting: false,
|
||||||
isExporting: false,
|
isExporting: false,
|
||||||
isImporting: false,
|
isImporting: false,
|
||||||
|
isInitiatingCall: false,
|
||||||
},
|
},
|
||||||
sortOrder: [],
|
sortOrder: [],
|
||||||
appliedFilters: [],
|
appliedFilters: [],
|
||||||
|
|||||||
@@ -359,4 +359,83 @@ describe('#actions', () => {
|
|||||||
).rejects.toThrow(Error);
|
).rejects.toThrow(Error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#initiateCall', () => {
|
||||||
|
const contactId = 123;
|
||||||
|
const inboxId = 456;
|
||||||
|
|
||||||
|
it('sends correct mutations if API is success', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
conversation_id: 789,
|
||||||
|
status: 'initiated',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
axios.post.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await actions.initiateCall(
|
||||||
|
{ commit },
|
||||||
|
{ contactId, inboxId }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||||
|
]);
|
||||||
|
expect(result).toEqual(mockResponse.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if API returns error with message', async () => {
|
||||||
|
const errorMessage = 'Failed to initiate call';
|
||||||
|
axios.post.mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
actions.initiateCall({ commit }, { contactId, inboxId })
|
||||||
|
).rejects.toThrow(ExceptionWithMessage);
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if API returns error with error field', async () => {
|
||||||
|
const errorMessage = 'Call initiation error';
|
||||||
|
axios.post.mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
error: errorMessage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
actions.initiateCall({ commit }, { contactId, inboxId })
|
||||||
|
).rejects.toThrow(ExceptionWithMessage);
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if API returns generic error', async () => {
|
||||||
|
axios.post.mockRejectedValue({ message: 'Network error' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
actions.initiateCall({ commit }, { contactId, inboxId })
|
||||||
|
).rejects.toThrow(Error);
|
||||||
|
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||||
|
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,3 +71,5 @@ class Contacts::ContactableInboxesService
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Contacts::ContactableInboxesService.prepend_mod_with('Contacts::ContactableInboxesService')
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ Rails.application.routes.draw do
|
|||||||
resources :contact_inboxes, only: [:create]
|
resources :contact_inboxes, only: [:create]
|
||||||
resources :labels, only: [:create, :index]
|
resources :labels, only: [:create, :index]
|
||||||
resources :notes
|
resources :notes
|
||||||
|
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :csat_survey_responses, only: [:index] do
|
resources :csat_survey_responses, only: [:index] do
|
||||||
@@ -550,6 +551,7 @@ Rails.application.routes.draw do
|
|||||||
collection do
|
collection do
|
||||||
post 'call/:phone', action: :call_twiml
|
post 'call/:phone', action: :call_twiml
|
||||||
post 'status/:phone', action: :status
|
post 'status/:phone', action: :status
|
||||||
|
post 'conference_status/:phone', action: :conference_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
21
enterprise/app/builders/enterprise/contact_inbox_builder.rb
Normal file
21
enterprise/app/builders/enterprise/contact_inbox_builder.rb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module Enterprise::ContactInboxBuilder
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_source_id
|
||||||
|
return super unless @inbox.channel_type == 'Channel::Voice'
|
||||||
|
|
||||||
|
phone_source_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def phone_source_id
|
||||||
|
return super unless @inbox.channel_type == 'Channel::Voice'
|
||||||
|
|
||||||
|
return SecureRandom.uuid if @contact.phone_number.blank?
|
||||||
|
|
||||||
|
@contact.phone_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_channels?
|
||||||
|
super || @inbox.channel_type == 'Channel::Voice'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module Enterprise::Messages::MessageBuilder
|
||||||
|
private
|
||||||
|
|
||||||
|
def message_type
|
||||||
|
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :contact
|
||||||
|
before_action :voice_inbox
|
||||||
|
|
||||||
|
def create
|
||||||
|
authorize contact, :show?
|
||||||
|
authorize voice_inbox, :show?
|
||||||
|
|
||||||
|
result = Voice::OutboundCallBuilder.perform!(
|
||||||
|
account: Current.account,
|
||||||
|
inbox: voice_inbox,
|
||||||
|
user: Current.user,
|
||||||
|
contact: contact
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation = result[:conversation]
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
conversation_id: conversation.display_id,
|
||||||
|
inbox_id: voice_inbox.id,
|
||||||
|
call_sid: result[:call_sid],
|
||||||
|
conference_sid: conversation.additional_attributes['conference_sid']
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def contact
|
||||||
|
@contact ||= Current.account.contacts.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def voice_inbox
|
||||||
|
@voice_inbox ||= Current.user.assigned_inboxes.where(
|
||||||
|
account_id: Current.account.id,
|
||||||
|
channel_type: 'Channel::Voice'
|
||||||
|
).find(params.require(:inbox_id))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,38 +1,189 @@
|
|||||||
class Twilio::VoiceController < ApplicationController
|
class Twilio::VoiceController < ApplicationController
|
||||||
|
CONFERENCE_EVENT_PATTERNS = {
|
||||||
|
/conference-start/i => 'start',
|
||||||
|
/participant-join/i => 'join',
|
||||||
|
/participant-leave/i => 'leave',
|
||||||
|
/conference-end/i => 'end'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
before_action :set_inbox!
|
before_action :set_inbox!
|
||||||
|
|
||||||
def status
|
def status
|
||||||
Voice::StatusUpdateService.new(
|
Voice::StatusUpdateService.new(
|
||||||
account: @inbox.account,
|
account: current_account,
|
||||||
call_sid: params[:CallSid],
|
call_sid: twilio_call_sid,
|
||||||
call_status: params[:CallStatus]
|
call_status: params[:CallStatus],
|
||||||
|
payload: params.to_unsafe_h
|
||||||
).perform
|
).perform
|
||||||
|
|
||||||
head :no_content
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
def call_twiml
|
def call_twiml
|
||||||
account = @inbox.account
|
account = current_account
|
||||||
call_sid = params[:CallSid]
|
Rails.logger.info(
|
||||||
from_number = params[:From].to_s
|
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||||
to_number = params[:To].to_s
|
)
|
||||||
|
|
||||||
builder = Voice::InboundCallBuilder.new(
|
conversation = resolve_conversation
|
||||||
account: account,
|
conference_sid = ensure_conference_sid!(conversation)
|
||||||
inbox: @inbox,
|
|
||||||
from_number: from_number,
|
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||||
to_number: to_number,
|
end
|
||||||
call_sid: call_sid
|
|
||||||
).perform
|
def conference_status
|
||||||
render xml: builder.twiml_response
|
event = mapped_conference_event
|
||||||
|
return head :no_content unless event
|
||||||
|
|
||||||
|
conversation = find_conversation_for_conference!(
|
||||||
|
friendly_name: params[:FriendlyName],
|
||||||
|
call_sid: twilio_call_sid
|
||||||
|
)
|
||||||
|
|
||||||
|
Voice::Conference::Manager.new(
|
||||||
|
conversation: conversation,
|
||||||
|
event: event,
|
||||||
|
call_sid: twilio_call_sid,
|
||||||
|
participant_label: participant_label
|
||||||
|
).process
|
||||||
|
|
||||||
|
head :no_content
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def twilio_call_sid
|
||||||
|
params[:CallSid]
|
||||||
|
end
|
||||||
|
|
||||||
|
def twilio_from
|
||||||
|
params[:From].to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def twilio_to
|
||||||
|
params[:To]
|
||||||
|
end
|
||||||
|
|
||||||
|
def twilio_direction
|
||||||
|
@twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def mapped_conference_event
|
||||||
|
event = params[:StatusCallbackEvent].to_s
|
||||||
|
CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
|
||||||
|
return mapped if event.match?(pattern)
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_leg?(from_number)
|
||||||
|
from_number.start_with?('client:')
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_conversation
|
||||||
|
return find_conversation_for_agent if agent_leg?(twilio_from)
|
||||||
|
|
||||||
|
case twilio_direction
|
||||||
|
when 'inbound'
|
||||||
|
Voice::InboundCallBuilder.perform!(
|
||||||
|
account: current_account,
|
||||||
|
inbox: inbox,
|
||||||
|
from_number: twilio_from,
|
||||||
|
call_sid: twilio_call_sid
|
||||||
|
)
|
||||||
|
when 'outbound-api', 'outbound-dial'
|
||||||
|
sync_outbound_leg(
|
||||||
|
call_sid: twilio_call_sid,
|
||||||
|
from_number: twilio_from,
|
||||||
|
direction: twilio_direction
|
||||||
|
)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_conversation_for_agent
|
||||||
|
if params[:conversation_id].present?
|
||||||
|
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||||
|
else
|
||||||
|
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||||
|
parent_sid = params['ParentCallSid'].presence
|
||||||
|
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||||
|
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
|
||||||
|
|
||||||
|
Voice::CallSessionSyncService.new(
|
||||||
|
conversation: conversation,
|
||||||
|
call_sid: call_sid,
|
||||||
|
message_call_sid: conversation.identifier,
|
||||||
|
leg: {
|
||||||
|
from_number: from_number,
|
||||||
|
to_number: twilio_to,
|
||||||
|
direction: 'outbound'
|
||||||
|
}
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_conference_sid!(conversation)
|
||||||
|
attrs = conversation.additional_attributes || {}
|
||||||
|
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||||
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
attrs['conference_sid']
|
||||||
|
end
|
||||||
|
|
||||||
|
def conference_twiml(conference_sid, agent_leg)
|
||||||
|
Twilio::TwiML::VoiceResponse.new.tap do |response|
|
||||||
|
response.dial do |dial|
|
||||||
|
dial.conference(
|
||||||
|
conference_sid,
|
||||||
|
start_conference_on_enter: agent_leg,
|
||||||
|
end_conference_on_exit: false,
|
||||||
|
status_callback: conference_status_callback_url,
|
||||||
|
status_callback_event: 'start end join leave',
|
||||||
|
status_callback_method: 'POST',
|
||||||
|
participant_label: agent_leg ? 'agent' : 'contact'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def conference_status_callback_url
|
||||||
|
host = ENV.fetch('FRONTEND_URL', '')
|
||||||
|
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||||
|
"#{host}/twilio/voice/conference_status/#{phone_digits}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||||
|
name = friendly_name.to_s
|
||||||
|
scope = current_account.conversations
|
||||||
|
|
||||||
|
if name.present?
|
||||||
|
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
||||||
|
return conversation if conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
scope.find_by!(identifier: call_sid)
|
||||||
|
end
|
||||||
|
|
||||||
def set_inbox!
|
def set_inbox!
|
||||||
# Resolve from the digits in the route param and look up exact E.164 match
|
|
||||||
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::Voice.find_by!(phone_number: e164)
|
||||||
@inbox = channel.inbox
|
@inbox = channel.inbox
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def current_account
|
||||||
|
@current_account ||= inbox_account
|
||||||
|
end
|
||||||
|
|
||||||
|
def participant_label
|
||||||
|
params[:ParticipantLabel].to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :inbox
|
||||||
|
|
||||||
|
delegate :account, :channel, to: :inbox, prefix: true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ class Channel::Voice < ApplicationRecord
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def initiate_call(to:)
|
||||||
|
case provider
|
||||||
|
when 'twilio'
|
||||||
|
Voice::Provider::TwilioAdapter.new(self).initiate_call(
|
||||||
|
to: to
|
||||||
|
)
|
||||||
|
else
|
||||||
|
raise "Unsupported voice provider: #{provider}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Public URLs used to configure Twilio webhooks
|
# Public URLs used to configure Twilio webhooks
|
||||||
def voice_call_webhook_url
|
def voice_call_webhook_url
|
||||||
digits = phone_number.delete_prefix('+')
|
digits = phone_number.delete_prefix('+')
|
||||||
@@ -76,6 +87,15 @@ class Channel::Voice < ApplicationRecord
|
|||||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
|
||||||
|
|
||||||
|
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
|
def provision_twilio_on_create
|
||||||
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||||
@@ -96,4 +116,6 @@ class Channel::Voice < ApplicationRecord
|
|||||||
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
|
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
|
||||||
errors.add(:base, "Twilio setup failed: #{e.message}")
|
errors.add(:base, "Twilio setup failed: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
public :provider_config_hash
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
module Enterprise::Contacts::ContactableInboxesService
|
||||||
|
private
|
||||||
|
|
||||||
|
# Extend base selection to include Voice inboxes
|
||||||
|
def get_contactable_inbox(inbox)
|
||||||
|
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
|
||||||
|
def voice_contactable_inbox(inbox)
|
||||||
|
return if @contact.phone_number.blank?
|
||||||
|
|
||||||
|
{ source_id: @contact.phone_number, inbox: inbox }
|
||||||
|
end
|
||||||
|
end
|
||||||
90
enterprise/app/services/voice/call_message_builder.rb
Normal file
90
enterprise/app/services/voice/call_message_builder.rb
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
class Voice::CallMessageBuilder
|
||||||
|
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
|
||||||
|
new(
|
||||||
|
conversation: conversation,
|
||||||
|
direction: direction,
|
||||||
|
payload: payload,
|
||||||
|
user: user,
|
||||||
|
timestamps: timestamps
|
||||||
|
).perform!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(conversation:, direction:, payload:, user:, timestamps:)
|
||||||
|
@conversation = conversation
|
||||||
|
@direction = direction
|
||||||
|
@payload = payload
|
||||||
|
@user = user
|
||||||
|
@timestamps = timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform!
|
||||||
|
validate_sender!
|
||||||
|
message = latest_message
|
||||||
|
message ? update_message!(message) : create_message!
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||||
|
|
||||||
|
def latest_message
|
||||||
|
conversation.messages.voice_calls.order(created_at: :desc).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_message!(message)
|
||||||
|
message.update!(
|
||||||
|
message_type: message_type,
|
||||||
|
content_attributes: { 'data' => base_payload },
|
||||||
|
sender: sender
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_message!
|
||||||
|
params = {
|
||||||
|
content: 'Voice Call',
|
||||||
|
message_type: message_type,
|
||||||
|
content_type: 'voice_call',
|
||||||
|
content_attributes: { 'data' => base_payload }
|
||||||
|
}
|
||||||
|
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_payload
|
||||||
|
@base_payload ||= begin
|
||||||
|
data = payload.slice(
|
||||||
|
:call_sid,
|
||||||
|
:status,
|
||||||
|
:call_direction,
|
||||||
|
:conference_sid,
|
||||||
|
:from_number,
|
||||||
|
:to_number
|
||||||
|
).stringify_keys
|
||||||
|
data['call_direction'] = direction
|
||||||
|
data['meta'] = {
|
||||||
|
'created_at' => timestamps[:created_at] || current_timestamp,
|
||||||
|
'ringing_at' => timestamps[:ringing_at] || current_timestamp
|
||||||
|
}.compact
|
||||||
|
data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_type
|
||||||
|
direction == 'outbound' ? 'outgoing' : 'incoming'
|
||||||
|
end
|
||||||
|
|
||||||
|
def sender
|
||||||
|
return user if direction == 'outbound'
|
||||||
|
|
||||||
|
conversation.contact
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_sender!
|
||||||
|
return unless direction == 'outbound'
|
||||||
|
|
||||||
|
raise ArgumentError, 'Agent sender required for outbound calls' unless user
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_timestamp
|
||||||
|
@current_timestamp ||= Time.zone.now.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
94
enterprise/app/services/voice/call_session_sync_service.rb
Normal file
94
enterprise/app/services/voice/call_session_sync_service.rb
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
class Voice::CallSessionSyncService
|
||||||
|
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
|
||||||
|
|
||||||
|
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
|
||||||
|
@conversation = conversation
|
||||||
|
@call_sid = call_sid
|
||||||
|
@message_call_sid = message_call_sid || call_sid
|
||||||
|
@from_number = leg[:from_number]
|
||||||
|
@to_number = leg[:to_number]
|
||||||
|
@direction = leg[:direction]
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
attrs = refreshed_attributes
|
||||||
|
conversation.update!(
|
||||||
|
additional_attributes: attrs,
|
||||||
|
last_activity_at: current_time
|
||||||
|
)
|
||||||
|
sync_voice_call_message!(attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
conversation
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def refreshed_attributes
|
||||||
|
attrs = (conversation.additional_attributes || {}).dup
|
||||||
|
attrs['call_direction'] = direction
|
||||||
|
attrs['call_status'] ||= 'ringing'
|
||||||
|
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||||
|
attrs['meta'] ||= {}
|
||||||
|
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_voice_call_message!(attrs)
|
||||||
|
Voice::CallMessageBuilder.perform!(
|
||||||
|
conversation: conversation,
|
||||||
|
direction: direction,
|
||||||
|
payload: {
|
||||||
|
call_sid: message_call_sid,
|
||||||
|
status: attrs['call_status'],
|
||||||
|
conference_sid: attrs['conference_sid'],
|
||||||
|
from_number: origin_number_for(direction),
|
||||||
|
to_number: target_number_for(direction)
|
||||||
|
},
|
||||||
|
user: agent_for(attrs),
|
||||||
|
timestamps: {
|
||||||
|
created_at: attrs.dig('meta', 'initiated_at'),
|
||||||
|
ringing_at: attrs.dig('meta', 'ringing_at')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def origin_number_for(current_direction)
|
||||||
|
return outbound_origin if current_direction == 'outbound'
|
||||||
|
|
||||||
|
from_number.presence || inbox_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def target_number_for(current_direction)
|
||||||
|
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
|
||||||
|
|
||||||
|
to_number || conversation.contact&.phone_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_for(attrs)
|
||||||
|
agent_id = attrs['agent_id']
|
||||||
|
return nil unless agent_id
|
||||||
|
|
||||||
|
agent = conversation.account.users.find_by(id: agent_id)
|
||||||
|
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
|
||||||
|
|
||||||
|
agent
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_timestamp
|
||||||
|
@current_timestamp ||= current_time.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_time
|
||||||
|
@current_time ||= Time.zone.now
|
||||||
|
end
|
||||||
|
|
||||||
|
def outbound_origin
|
||||||
|
inbox_number || from_number
|
||||||
|
end
|
||||||
|
|
||||||
|
def inbox_number
|
||||||
|
conversation.inbox&.channel&.phone_number
|
||||||
|
end
|
||||||
|
end
|
||||||
66
enterprise/app/services/voice/call_status/manager.rb
Normal file
66
enterprise/app/services/voice/call_status/manager.rb
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
class Voice::CallStatus::Manager
|
||||||
|
pattr_initialize [:conversation!, :call_sid]
|
||||||
|
|
||||||
|
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||||
|
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
||||||
|
|
||||||
|
def process_status_update(status, duration: nil, timestamp: nil)
|
||||||
|
return unless ALLOWED_STATUSES.include?(status)
|
||||||
|
|
||||||
|
current_status = conversation.additional_attributes&.dig('call_status')
|
||||||
|
return if current_status == status
|
||||||
|
|
||||||
|
apply_status(status, duration: duration, timestamp: timestamp)
|
||||||
|
update_message(status)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def apply_status(status, duration:, timestamp:)
|
||||||
|
attrs = (conversation.additional_attributes || {}).dup
|
||||||
|
attrs['call_status'] = status
|
||||||
|
|
||||||
|
if status == 'in-progress'
|
||||||
|
attrs['call_started_at'] ||= timestamp || now_seconds
|
||||||
|
elsif TERMINAL_STATUSES.include?(status)
|
||||||
|
attrs['call_ended_at'] = timestamp || now_seconds
|
||||||
|
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
conversation.update!(
|
||||||
|
additional_attributes: attrs,
|
||||||
|
last_activity_at: current_time
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolved_duration(attrs, provided_duration, timestamp)
|
||||||
|
return provided_duration if provided_duration
|
||||||
|
|
||||||
|
started_at = attrs['call_started_at']
|
||||||
|
return unless started_at && timestamp
|
||||||
|
|
||||||
|
[timestamp - started_at.to_i, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_message(status)
|
||||||
|
message = conversation.messages
|
||||||
|
.where(content_type: 'voice_call')
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.first
|
||||||
|
return unless message
|
||||||
|
|
||||||
|
data = (message.content_attributes || {}).dup
|
||||||
|
data['data'] ||= {}
|
||||||
|
data['data']['status'] = status
|
||||||
|
|
||||||
|
message.update!(content_attributes: data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def now_seconds
|
||||||
|
current_time.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_time
|
||||||
|
@current_time ||= Time.zone.now
|
||||||
|
end
|
||||||
|
end
|
||||||
71
enterprise/app/services/voice/conference/manager.rb
Normal file
71
enterprise/app/services/voice/conference/manager.rb
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
class Voice::Conference::Manager
|
||||||
|
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
||||||
|
|
||||||
|
def process
|
||||||
|
case event
|
||||||
|
when 'start'
|
||||||
|
ensure_conference_sid!
|
||||||
|
mark_ringing!
|
||||||
|
when 'join'
|
||||||
|
mark_in_progress! if agent_participant?
|
||||||
|
when 'leave'
|
||||||
|
handle_leave!
|
||||||
|
when 'end'
|
||||||
|
finalize_conference!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def status_manager
|
||||||
|
@status_manager ||= Voice::CallStatus::Manager.new(
|
||||||
|
conversation: conversation,
|
||||||
|
call_sid: call_sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_conference_sid!
|
||||||
|
attrs = conversation.additional_attributes || {}
|
||||||
|
return if attrs['conference_sid'].present?
|
||||||
|
|
||||||
|
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
||||||
|
conversation.update!(additional_attributes: attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_ringing!
|
||||||
|
return if current_status
|
||||||
|
|
||||||
|
status_manager.process_status_update('ringing')
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_in_progress!
|
||||||
|
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_leave!
|
||||||
|
case current_status
|
||||||
|
when 'ringing'
|
||||||
|
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
||||||
|
when 'in-progress'
|
||||||
|
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_conference!
|
||||||
|
return if %w[completed no-answer failed].include?(current_status)
|
||||||
|
|
||||||
|
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_status
|
||||||
|
conversation.additional_attributes&.dig('call_status')
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_participant?
|
||||||
|
participant_label.to_s.start_with?('agent')
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_timestamp
|
||||||
|
Time.zone.now.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
5
enterprise/app/services/voice/conference/name.rb
Normal file
5
enterprise/app/services/voice/conference/name.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module Voice::Conference::Name
|
||||||
|
def self.for(conversation)
|
||||||
|
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,82 +1,99 @@
|
|||||||
class Voice::InboundCallBuilder
|
class Voice::InboundCallBuilder
|
||||||
pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!]
|
attr_reader :account, :inbox, :from_number, :call_sid
|
||||||
|
|
||||||
attr_reader :conversation
|
def self.perform!(account:, inbox:, from_number:, call_sid:)
|
||||||
|
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
|
||||||
def perform
|
|
||||||
contact = find_or_create_contact!
|
|
||||||
contact_inbox = find_or_create_contact_inbox!(contact)
|
|
||||||
@conversation = find_or_create_conversation!(contact, contact_inbox)
|
|
||||||
create_call_message_if_needed!
|
|
||||||
self
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def twiml_response
|
def initialize(account:, inbox:, from_number:, call_sid:)
|
||||||
response = Twilio::TwiML::VoiceResponse.new
|
@account = account
|
||||||
response.say(message: 'Please wait while we connect you to an agent')
|
@inbox = inbox
|
||||||
response.to_s
|
@from_number = from_number
|
||||||
|
@call_sid = call_sid
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform!
|
||||||
|
timestamp = current_timestamp
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
contact = ensure_contact!
|
||||||
|
contact_inbox = ensure_contact_inbox!(contact)
|
||||||
|
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
||||||
|
conversation.reload
|
||||||
|
update_conversation!(conversation, timestamp)
|
||||||
|
build_voice_message!(conversation, timestamp)
|
||||||
|
conversation
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def find_or_create_conversation!(contact, contact_inbox)
|
def ensure_contact!
|
||||||
account.conversations.find_or_create_by!(
|
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||||
account_id: account.id,
|
record.name = from_number if record.name.blank?
|
||||||
inbox_id: inbox.id,
|
|
||||||
identifier: call_sid
|
|
||||||
) do |conv|
|
|
||||||
conv.contact_id = contact.id
|
|
||||||
conv.contact_inbox_id = contact_inbox.id
|
|
||||||
conv.additional_attributes = {
|
|
||||||
'call_direction' => 'inbound',
|
|
||||||
'call_status' => 'ringing'
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_call_message!
|
def ensure_contact_inbox!(contact)
|
||||||
content_attrs = call_message_content_attributes
|
ContactInbox.find_or_create_by!(
|
||||||
|
contact_id: contact.id,
|
||||||
|
inbox_id: inbox.id
|
||||||
|
) do |record|
|
||||||
|
record.source_id = from_number
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@conversation.messages.create!(
|
def find_conversation
|
||||||
account_id: account.id,
|
return if call_sid.blank?
|
||||||
|
|
||||||
|
account.conversations.includes(:contact).find_by(identifier: call_sid)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_conversation!(contact, contact_inbox)
|
||||||
|
account.conversations.create!(
|
||||||
|
contact_inbox_id: contact_inbox.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
message_type: :incoming,
|
contact_id: contact.id,
|
||||||
sender: @conversation.contact,
|
status: :open,
|
||||||
content: 'Voice Call',
|
identifier: call_sid
|
||||||
content_type: 'voice_call',
|
|
||||||
content_attributes: content_attrs
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_call_message_if_needed!
|
def update_conversation!(conversation, timestamp)
|
||||||
return if @conversation.messages.voice_calls.exists?
|
attrs = {
|
||||||
|
'call_direction' => 'inbound',
|
||||||
|
'call_status' => 'ringing',
|
||||||
|
'conference_sid' => Voice::Conference::Name.for(conversation),
|
||||||
|
'meta' => { 'initiated_at' => timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
create_call_message!
|
conversation.update!(
|
||||||
|
identifier: call_sid,
|
||||||
|
additional_attributes: attrs,
|
||||||
|
last_activity_at: current_time
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def call_message_content_attributes
|
def build_voice_message!(conversation, timestamp)
|
||||||
{
|
Voice::CallMessageBuilder.perform!(
|
||||||
data: {
|
conversation: conversation,
|
||||||
|
direction: 'inbound',
|
||||||
|
payload: {
|
||||||
call_sid: call_sid,
|
call_sid: call_sid,
|
||||||
status: 'ringing',
|
status: 'ringing',
|
||||||
conversation_id: @conversation.display_id,
|
conference_sid: conversation.additional_attributes['conference_sid'],
|
||||||
call_direction: 'inbound',
|
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
to_number: to_number,
|
to_number: inbox.channel&.phone_number
|
||||||
meta: {
|
},
|
||||||
created_at: Time.current.to_i,
|
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||||
ringing_at: Time.current.to_i
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_contact!
|
def current_timestamp
|
||||||
account.contacts.find_by(phone_number: from_number) ||
|
@current_timestamp ||= current_time.to_i
|
||||||
account.contacts.create!(phone_number: from_number, name: 'Unknown Caller')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_or_create_contact_inbox!(contact)
|
def current_time
|
||||||
ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create!
|
@current_time ||= Time.zone.now
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
98
enterprise/app/services/voice/outbound_call_builder.rb
Normal file
98
enterprise/app/services/voice/outbound_call_builder.rb
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
class Voice::OutboundCallBuilder
|
||||||
|
attr_reader :account, :inbox, :user, :contact
|
||||||
|
|
||||||
|
def self.perform!(account:, inbox:, user:, contact:)
|
||||||
|
new(account: account, inbox: inbox, user: user, contact: contact).perform!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(account:, inbox:, user:, contact:)
|
||||||
|
@account = account
|
||||||
|
@inbox = inbox
|
||||||
|
@user = user
|
||||||
|
@contact = contact
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform!
|
||||||
|
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
||||||
|
raise ArgumentError, 'Agent required' if user.blank?
|
||||||
|
|
||||||
|
timestamp = current_timestamp
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
contact_inbox = ensure_contact_inbox!
|
||||||
|
conversation = create_conversation!(contact_inbox)
|
||||||
|
conversation.reload
|
||||||
|
conference_sid = Voice::Conference::Name.for(conversation)
|
||||||
|
call_sid = initiate_call!
|
||||||
|
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||||
|
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||||
|
{ conversation: conversation, call_sid: call_sid }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_contact_inbox!
|
||||||
|
ContactInbox.find_or_create_by!(
|
||||||
|
contact_id: contact.id,
|
||||||
|
inbox_id: inbox.id
|
||||||
|
) do |record|
|
||||||
|
record.source_id = contact.phone_number
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_conversation!(contact_inbox)
|
||||||
|
account.conversations.create!(
|
||||||
|
contact_inbox_id: contact_inbox.id,
|
||||||
|
inbox_id: inbox.id,
|
||||||
|
contact_id: contact.id,
|
||||||
|
status: :open
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initiate_call!
|
||||||
|
inbox.channel.initiate_call(
|
||||||
|
to: contact.phone_number
|
||||||
|
)[:call_sid]
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||||
|
attrs = {
|
||||||
|
'call_direction' => 'outbound',
|
||||||
|
'call_status' => 'ringing',
|
||||||
|
'agent_id' => user.id,
|
||||||
|
'conference_sid' => conference_sid,
|
||||||
|
'meta' => { 'initiated_at' => timestamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation.update!(
|
||||||
|
identifier: call_sid,
|
||||||
|
additional_attributes: attrs,
|
||||||
|
last_activity_at: current_time
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||||
|
Voice::CallMessageBuilder.perform!(
|
||||||
|
conversation: conversation,
|
||||||
|
direction: 'outbound',
|
||||||
|
payload: {
|
||||||
|
call_sid: call_sid,
|
||||||
|
status: 'ringing',
|
||||||
|
conference_sid: conference_sid,
|
||||||
|
from_number: inbox.channel&.phone_number,
|
||||||
|
to_number: contact.phone_number
|
||||||
|
},
|
||||||
|
user: user,
|
||||||
|
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_timestamp
|
||||||
|
@current_timestamp ||= current_time.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_time
|
||||||
|
@current_time ||= Time.zone.now
|
||||||
|
end
|
||||||
|
end
|
||||||
32
enterprise/app/services/voice/provider/twilio_adapter.rb
Normal file
32
enterprise/app/services/voice/provider/twilio_adapter.rb
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
class Voice::Provider::TwilioAdapter
|
||||||
|
def initialize(channel)
|
||||||
|
@channel = channel
|
||||||
|
end
|
||||||
|
|
||||||
|
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
|
||||||
|
cfg = @channel.provider_config_hash
|
||||||
|
|
||||||
|
host = ENV.fetch('FRONTEND_URL')
|
||||||
|
phone_digits = @channel.phone_number.delete_prefix('+')
|
||||||
|
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
|
||||||
|
|
||||||
|
params = {
|
||||||
|
from: @channel.phone_number,
|
||||||
|
to: to,
|
||||||
|
url: callback_url,
|
||||||
|
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
|
||||||
|
status_callback_event: %w[initiated ringing answered completed],
|
||||||
|
status_callback_method: 'POST'
|
||||||
|
}
|
||||||
|
|
||||||
|
call = twilio_client(cfg).calls.create(**params)
|
||||||
|
|
||||||
|
{ call_sid: call.sid }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def twilio_client(config)
|
||||||
|
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
class Voice::StatusUpdateService
|
class Voice::StatusUpdateService
|
||||||
pattr_initialize [:account!, :call_sid!, :call_status]
|
pattr_initialize [:account!, :call_sid!, :call_status, { payload: {} }]
|
||||||
|
|
||||||
|
TWILIO_STATUS_MAP = {
|
||||||
|
'queued' => 'ringing',
|
||||||
|
'initiated' => 'ringing',
|
||||||
|
'ringing' => 'ringing',
|
||||||
|
'in-progress' => 'in-progress',
|
||||||
|
'inprogress' => 'in-progress',
|
||||||
|
'answered' => 'in-progress',
|
||||||
|
'completed' => 'completed',
|
||||||
|
'busy' => 'no-answer',
|
||||||
|
'no-answer' => 'no-answer',
|
||||||
|
'failed' => 'failed',
|
||||||
|
'canceled' => 'failed'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
normalized_status = normalize_status(call_status)
|
||||||
|
return if normalized_status.blank?
|
||||||
|
|
||||||
conversation = account.conversations.find_by(identifier: call_sid)
|
conversation = account.conversations.find_by(identifier: call_sid)
|
||||||
return unless conversation
|
return unless conversation
|
||||||
return if call_status.to_s.strip.empty?
|
|
||||||
|
|
||||||
update_conversation!(conversation)
|
Voice::CallStatus::Manager.new(
|
||||||
update_last_call_message!(conversation)
|
conversation: conversation,
|
||||||
|
call_sid: call_sid
|
||||||
|
).process_status_update(
|
||||||
|
normalized_status,
|
||||||
|
duration: payload_duration,
|
||||||
|
timestamp: payload_timestamp
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def update_conversation!(conversation)
|
def normalize_status(status)
|
||||||
attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status)
|
return if status.to_s.strip.empty?
|
||||||
conversation.update!(additional_attributes: attrs)
|
|
||||||
|
TWILIO_STATUS_MAP[status.to_s.downcase]
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_last_call_message!(conversation)
|
def payload_duration
|
||||||
msg = conversation.messages.voice_calls.order(created_at: :desc).first
|
return unless payload.is_a?(Hash)
|
||||||
return unless msg
|
|
||||||
|
|
||||||
data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {}
|
duration = payload['CallDuration'] || payload['call_duration']
|
||||||
data['data'] ||= {}
|
duration&.to_i
|
||||||
data['data']['status'] = call_status
|
end
|
||||||
msg.update!(content_attributes: data)
|
|
||||||
|
def payload_timestamp
|
||||||
|
return unless payload.is_a?(Hash)
|
||||||
|
|
||||||
|
ts = payload['Timestamp'] || payload['timestamp']
|
||||||
|
return unless ts
|
||||||
|
|
||||||
|
Time.zone.parse(ts).to_i
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,36 +18,94 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
|||||||
let(:from_number) { '+15550003333' }
|
let(:from_number) { '+15550003333' }
|
||||||
let(:to_number) { channel.phone_number }
|
let(:to_number) { channel.phone_number }
|
||||||
|
|
||||||
it 'invokes Voice::InboundCallBuilder with expected params and renders its TwiML' do
|
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||||
builder_double = instance_double(Voice::InboundCallBuilder)
|
instance_double(Voice::InboundCallBuilder)
|
||||||
expect(Voice::InboundCallBuilder).to receive(:new).with(
|
conversation = create(:conversation, account: account, inbox: inbox)
|
||||||
hash_including(
|
|
||||||
account: account,
|
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||||
inbox: inbox,
|
account: account,
|
||||||
from_number: from_number,
|
inbox: inbox,
|
||||||
to_number: to_number,
|
from_number: from_number,
|
||||||
call_sid: call_sid
|
call_sid: call_sid
|
||||||
)
|
).and_return(conversation)
|
||||||
).and_return(builder_double)
|
|
||||||
expect(builder_double).to receive(:perform).and_return(builder_double)
|
|
||||||
expect(builder_double).to receive(:twiml_response).and_return('<Response/>')
|
|
||||||
|
|
||||||
post "/twilio/voice/call/#{digits}", params: {
|
post "/twilio/voice/call/#{digits}", params: {
|
||||||
'CallSid' => call_sid,
|
'CallSid' => call_sid,
|
||||||
'From' => from_number,
|
'From' => from_number,
|
||||||
'To' => to_number
|
'To' => to_number,
|
||||||
|
'Direction' => 'inbound'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include('<Response>')
|
||||||
|
expect(response.body).to include('<Dial>')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
||||||
|
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||||
|
|
||||||
|
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||||
|
hash_including(
|
||||||
|
conversation: conversation,
|
||||||
|
call_sid: call_sid,
|
||||||
|
message_call_sid: conversation.identifier,
|
||||||
|
leg: {
|
||||||
|
from_number: from_number,
|
||||||
|
to_number: to_number,
|
||||||
|
direction: 'outbound'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).and_return(sync_double)
|
||||||
|
|
||||||
|
post "/twilio/voice/call/#{digits}", params: {
|
||||||
|
'CallSid' => call_sid,
|
||||||
|
'From' => from_number,
|
||||||
|
'To' => to_number,
|
||||||
|
'Direction' => 'outbound-api'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include('<Response>')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the parent call SID when syncing outbound-dial legs' do
|
||||||
|
parent_sid = 'CA_parent'
|
||||||
|
child_sid = 'CA_child'
|
||||||
|
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
||||||
|
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||||
|
|
||||||
|
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||||
|
hash_including(
|
||||||
|
conversation: conversation,
|
||||||
|
call_sid: child_sid,
|
||||||
|
message_call_sid: parent_sid,
|
||||||
|
leg: {
|
||||||
|
from_number: from_number,
|
||||||
|
to_number: to_number,
|
||||||
|
direction: 'outbound'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).and_return(sync_double)
|
||||||
|
|
||||||
|
post "/twilio/voice/call/#{digits}", params: {
|
||||||
|
'CallSid' => child_sid,
|
||||||
|
'ParentCallSid' => parent_sid,
|
||||||
|
'From' => from_number,
|
||||||
|
'To' => to_number,
|
||||||
|
'Direction' => 'outbound-dial'
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to eq('<Response/>')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises not found when inbox is not present' do
|
it 'raises not found when inbox is not present' do
|
||||||
expect(Voice::InboundCallBuilder).not_to receive(:new)
|
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
|
||||||
post '/twilio/voice/call/19998887777', params: {
|
post '/twilio/voice/call/19998887777', params: {
|
||||||
'CallSid' => call_sid,
|
'CallSid' => call_sid,
|
||||||
'From' => from_number,
|
'From' => from_number,
|
||||||
'To' => to_number
|
'To' => to_number,
|
||||||
|
'Direction' => 'inbound'
|
||||||
}
|
}
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
@@ -62,7 +120,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
|||||||
hash_including(
|
hash_including(
|
||||||
account: account,
|
account: account,
|
||||||
call_sid: call_sid,
|
call_sid: call_sid,
|
||||||
call_status: 'completed'
|
call_status: 'completed',
|
||||||
|
payload: hash_including('CallSid' => call_sid, 'CallStatus' => 'completed')
|
||||||
)
|
)
|
||||||
).and_return(service_double)
|
).and_return(service_double)
|
||||||
expect(service_double).to receive(:perform)
|
expect(service_double).to receive(:perform)
|
||||||
|
|||||||
@@ -4,54 +4,118 @@ 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: '+15551230001') }
|
let(:channel) { create(:channel_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 }
|
||||||
let(:call_sid) { 'CA1234567890abcdef' }
|
let(:call_sid) { 'CA1234567890abcdef' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_and_perform
|
def perform_builder
|
||||||
described_class.new(
|
described_class.perform!(
|
||||||
account: account,
|
account: account,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
from_number: from_number,
|
from_number: from_number,
|
||||||
to_number: to_number,
|
|
||||||
call_sid: call_sid
|
call_sid: call_sid
|
||||||
).perform
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new conversation with inbound ringing attributes' do
|
context 'when no existing conversation matches call_sid' do
|
||||||
builder = build_and_perform
|
it 'creates a new inbound conversation with ringing status' do
|
||||||
conversation = builder.conversation
|
conversation = nil
|
||||||
expect(conversation).to be_present
|
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
|
||||||
expect(conversation.account_id).to eq(account.id)
|
|
||||||
expect(conversation.inbox_id).to eq(inbox.id)
|
attrs = conversation.additional_attributes
|
||||||
expect(conversation.identifier).to eq(call_sid)
|
expect(conversation.identifier).to eq(call_sid)
|
||||||
expect(conversation.additional_attributes['call_direction']).to eq('inbound')
|
expect(attrs['call_direction']).to eq('inbound')
|
||||||
expect(conversation.additional_attributes['call_status']).to eq('ringing')
|
expect(attrs['call_status']).to eq('ringing')
|
||||||
|
expect(attrs['conference_sid']).to be_present
|
||||||
|
expect(attrs.dig('meta', 'initiated_at')).to be_present
|
||||||
|
expect(conversation.contact.phone_number).to eq(from_number)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a single voice_call message marked as incoming' do
|
||||||
|
conversation = perform_builder
|
||||||
|
voice_message = conversation.messages.voice_calls.last
|
||||||
|
|
||||||
|
expect(voice_message).to be_present
|
||||||
|
expect(voice_message.message_type).to eq('incoming')
|
||||||
|
data = voice_message.content_attributes['data']
|
||||||
|
expect(data).to include(
|
||||||
|
'call_sid' => call_sid,
|
||||||
|
'status' => 'ringing',
|
||||||
|
'call_direction' => 'inbound',
|
||||||
|
'conference_sid' => conversation.additional_attributes['conference_sid'],
|
||||||
|
'from_number' => from_number,
|
||||||
|
'to_number' => inbox.channel.phone_number
|
||||||
|
)
|
||||||
|
expect(data['meta']['created_at']).to be_present
|
||||||
|
expect(data['meta']['ringing_at']).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets the contact name to the phone number for new callers' do
|
||||||
|
conversation = perform_builder
|
||||||
|
|
||||||
|
expect(conversation.contact.name).to eq(from_number)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||||
|
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||||
|
expect(conversation.display_id).to be_present
|
||||||
|
original.call(conversation)
|
||||||
|
end
|
||||||
|
|
||||||
|
perform_builder
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a voice_call message with ringing status' do
|
context 'when a conversation already exists for the call_sid' do
|
||||||
builder = build_and_perform
|
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||||
conversation = builder.conversation
|
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||||
msg = conversation.messages.voice_calls.last
|
let!(:existing_conversation) do
|
||||||
expect(msg).to be_present
|
create(
|
||||||
expect(msg.message_type).to eq('incoming')
|
:conversation,
|
||||||
expect(msg.content_type).to eq('voice_call')
|
account: account,
|
||||||
expect(msg.content_attributes.dig('data', 'call_sid')).to eq(call_sid)
|
inbox: inbox,
|
||||||
expect(msg.content_attributes.dig('data', 'status')).to eq('ringing')
|
contact: contact,
|
||||||
end
|
contact_inbox: contact_inbox,
|
||||||
|
identifier: call_sid,
|
||||||
|
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:existing_message) do
|
||||||
|
create(
|
||||||
|
:message,
|
||||||
|
account: account,
|
||||||
|
inbox: inbox,
|
||||||
|
conversation: existing_conversation,
|
||||||
|
message_type: :incoming,
|
||||||
|
content_type: :voice_call,
|
||||||
|
sender: contact,
|
||||||
|
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns TwiML that informs the caller we are connecting' do
|
it 'reuses the conversation without creating a duplicate' do
|
||||||
builder = build_and_perform
|
existing_message
|
||||||
xml = builder.twiml_response
|
expect { perform_builder }.not_to change(account.conversations, :count)
|
||||||
expect(xml).to include('Please wait while we connect you to an agent')
|
existing_conversation.reload
|
||||||
expect(xml).to include('<Say')
|
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||||
|
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the existing voice call message instead of creating a new one' do
|
||||||
|
existing_message
|
||||||
|
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
|
||||||
|
updated_message = existing_conversation.reload.messages.voice_calls.last
|
||||||
|
|
||||||
|
data = updated_message.content_attributes['data']
|
||||||
|
expect(data['status']).to eq('ringing')
|
||||||
|
expect(data['call_direction']).to eq('inbound')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
97
spec/enterprise/services/voice/outbound_call_builder_spec.rb
Normal file
97
spec/enterprise/services/voice/outbound_call_builder_spec.rb
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Voice::OutboundCallBuilder do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
|
||||||
|
let(:inbox) { channel.inbox }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
|
||||||
|
let(:call_sid) { 'CA1234567890abcdef' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||||
|
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||||
|
allow(inbox).to receive(:channel).and_return(channel)
|
||||||
|
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
||||||
|
allow(Voice::Conference::Name).to receive(:for).and_call_original
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.perform!' do
|
||||||
|
it 'creates a conversation and voice call message' do
|
||||||
|
conversation_count = account.conversations.count
|
||||||
|
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
|
||||||
|
|
||||||
|
result = described_class.perform!(
|
||||||
|
account: account,
|
||||||
|
inbox: inbox,
|
||||||
|
user: user,
|
||||||
|
contact: contact
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(account.conversations.count).to eq(conversation_count + 1)
|
||||||
|
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
|
||||||
|
|
||||||
|
conversation = result[:conversation].reload
|
||||||
|
attrs = conversation.additional_attributes
|
||||||
|
|
||||||
|
aggregate_failures do
|
||||||
|
expect(result[:call_sid]).to eq(call_sid)
|
||||||
|
expect(conversation.identifier).to eq(call_sid)
|
||||||
|
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
|
||||||
|
expect(attrs['agent_id']).to eq(user.id)
|
||||||
|
expect(attrs['conference_sid']).to be_present
|
||||||
|
|
||||||
|
voice_message = conversation.messages.voice_calls.last
|
||||||
|
expect(voice_message.message_type).to eq('outgoing')
|
||||||
|
|
||||||
|
message_data = voice_message.content_attributes['data']
|
||||||
|
expect(message_data).to include(
|
||||||
|
'call_sid' => call_sid,
|
||||||
|
'conference_sid' => attrs['conference_sid'],
|
||||||
|
'from_number' => channel.phone_number,
|
||||||
|
'to_number' => contact.phone_number
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error when contact is missing a phone number' do
|
||||||
|
contact.update!(phone_number: nil)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.perform!(
|
||||||
|
account: account,
|
||||||
|
inbox: inbox,
|
||||||
|
user: user,
|
||||||
|
contact: contact
|
||||||
|
)
|
||||||
|
end.to raise_error(ArgumentError, 'Contact phone number required')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error when user is nil' do
|
||||||
|
expect do
|
||||||
|
described_class.perform!(
|
||||||
|
account: account,
|
||||||
|
inbox: inbox,
|
||||||
|
user: nil,
|
||||||
|
contact: contact
|
||||||
|
)
|
||||||
|
end.to raise_error(ArgumentError, 'Agent required')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||||
|
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||||
|
expect(conversation.display_id).to be_present
|
||||||
|
original.call(conversation)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class.perform!(
|
||||||
|
account: account,
|
||||||
|
inbox: inbox,
|
||||||
|
user: user,
|
||||||
|
contact: contact
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -55,6 +55,23 @@ RSpec.describe Voice::StatusUpdateService do
|
|||||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'normalizes busy to no-answer' do
|
||||||
|
conversation
|
||||||
|
message
|
||||||
|
|
||||||
|
described_class.new(
|
||||||
|
account: account,
|
||||||
|
call_sid: call_sid,
|
||||||
|
call_status: 'busy'
|
||||||
|
).perform
|
||||||
|
|
||||||
|
conversation.reload
|
||||||
|
message.reload
|
||||||
|
|
||||||
|
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
|
||||||
|
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
||||||
|
end
|
||||||
|
|
||||||
it 'no-ops when conversation not found' do
|
it 'no-ops when conversation not found' do
|
||||||
expect do
|
expect do
|
||||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ RSpec.describe MutexApplicationJob do
|
|||||||
described_class.new.send(:with_lock, lock_key) do
|
described_class.new.send(:with_lock, lock_key) do
|
||||||
# Do nothing
|
# Do nothing
|
||||||
end
|
end
|
||||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises StandardError if it execution raises it' do
|
it 'raises StandardError if it execution raises it' do
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const tailwindConfig = {
|
|||||||
'./app/javascript/survey/**/*.vue',
|
'./app/javascript/survey/**/*.vue',
|
||||||
'./app/javascript/dashboard/components-next/**/*.vue',
|
'./app/javascript/dashboard/components-next/**/*.vue',
|
||||||
'./app/javascript/dashboard/helper/**/*.js',
|
'./app/javascript/dashboard/helper/**/*.js',
|
||||||
|
'./app/javascript/dashboard/composables/**/*.js',
|
||||||
'./app/javascript/dashboard/components-next/**/*.js',
|
'./app/javascript/dashboard/components-next/**/*.js',
|
||||||
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
|
'./app/javascript/dashboard/routes/dashboard/**/**/*.js',
|
||||||
'./app/views/**/*.html.erb',
|
'./app/views/**/*.html.erb',
|
||||||
|
|||||||
Reference in New Issue
Block a user