Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bae23defd | ||
|
|
e585c84778 | ||
|
|
e096804803 | ||
|
|
456a4e0206 | ||
|
|
38f11b010c | ||
|
|
4041277a35 | ||
|
|
1b0974b407 | ||
|
|
e4f9355352 | ||
|
|
fac4675dc5 |
@@ -0,0 +1,216 @@
|
||||
import { ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VoiceCallBubble from './VoiceCall.vue';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
let messageContext;
|
||||
let joinCallMock;
|
||||
let endCallMock;
|
||||
let conversationsById;
|
||||
let activeCall;
|
||||
let hasActiveCall;
|
||||
let isJoining;
|
||||
|
||||
vi.mock('../provider.js', () => ({
|
||||
useMessageContext: () => messageContext,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useCallSession', () => ({
|
||||
useCallSession: () => ({
|
||||
activeCall,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
joinCall: joinCallMock,
|
||||
endCall: endCallMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vuex', () => ({
|
||||
useStore: () => ({
|
||||
getters: {
|
||||
getConversationById: id => conversationsById[id] || null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
const translations = {
|
||||
'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS': 'Call in progress',
|
||||
'CONVERSATION.VOICE_CALL.AGENT_ANSWERED': `${params.agentName} answered`,
|
||||
'CONVERSATION.VOICE_CALL.YOU_ANSWERED': 'You answered',
|
||||
'CONVERSATION.VOICE_CALL.THEY_ANSWERED': 'They answered',
|
||||
'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET': 'Not answered yet',
|
||||
'CONVERSATION.VOICE_CALL.CALL_ENDED': 'Call ended',
|
||||
'CONVERSATION.VOICE_CALL.NO_ANSWER': 'No answer',
|
||||
'CONVERSATION.VOICE_CALL.JOIN_CALL': 'Join call',
|
||||
'CONVERSATION.VOICE_CALL.REJOIN_CALL': 'Rejoin call',
|
||||
};
|
||||
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(VoiceCallBubble, {
|
||||
global: {
|
||||
stubs: {
|
||||
BaseBubble: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
Icon: true,
|
||||
AudioChip: {
|
||||
props: ['attachment'],
|
||||
template:
|
||||
'<div data-test-id="audio-chip">{{ attachment.dataUrl }}</div>',
|
||||
},
|
||||
},
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('VoiceCall.vue', () => {
|
||||
beforeEach(() => {
|
||||
joinCallMock = vi.fn().mockResolvedValue({ conferenceSid: 'CF123' });
|
||||
endCallMock = vi.fn().mockResolvedValue();
|
||||
conversationsById = {
|
||||
12: {
|
||||
id: 12,
|
||||
inbox_id: 3,
|
||||
meta: {
|
||||
assignee: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
activeCall = ref(null);
|
||||
hasActiveCall = ref(false);
|
||||
isJoining = ref(false);
|
||||
messageContext = {
|
||||
contentAttributes: ref({
|
||||
data: {
|
||||
status: VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
call_sid: 'CALL123',
|
||||
meta: {},
|
||||
},
|
||||
}),
|
||||
attachments: ref([]),
|
||||
conversationId: ref(12),
|
||||
currentUserId: ref(1),
|
||||
inboxId: ref(3),
|
||||
messageType: ref(MESSAGE_TYPES.INCOMING),
|
||||
};
|
||||
});
|
||||
|
||||
it('shows the answering agent name when another agent answers the call', () => {
|
||||
messageContext.contentAttributes.value.data.meta.joinedBy = {
|
||||
id: 2,
|
||||
name: 'Ben',
|
||||
};
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain('Ben answered');
|
||||
});
|
||||
|
||||
it('shows "You answered" when the current user answered the call', () => {
|
||||
messageContext.contentAttributes.value.data.meta.joinedBy = {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
};
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain('You answered');
|
||||
});
|
||||
|
||||
it('shows the formatted duration when a call has ended', () => {
|
||||
messageContext.contentAttributes.value.data.status =
|
||||
VOICE_CALL_STATUS.COMPLETED;
|
||||
messageContext.contentAttributes.value.data.meta.duration = 133;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain('Call ended');
|
||||
expect(wrapper.text()).toContain('02:13');
|
||||
});
|
||||
|
||||
it('renders the recording attachment on the voice call message', () => {
|
||||
messageContext.attachments.value = [
|
||||
{
|
||||
id: 1,
|
||||
fileType: 'audio',
|
||||
dataUrl: 'https://example.com/recording.mp3',
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.find('[data-test-id="audio-chip"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('https://example.com/recording.mp3');
|
||||
});
|
||||
|
||||
it('shows a rejoin action for an in-progress call when not already connected', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.find('[data-test-id="voice-call-join"]').trigger('click');
|
||||
|
||||
expect(joinCallMock).toHaveBeenCalledWith({
|
||||
conversationId: 12,
|
||||
inboxId: 3,
|
||||
callSid: 'CALL123',
|
||||
});
|
||||
expect(wrapper.text()).toContain('Rejoin call');
|
||||
});
|
||||
|
||||
it('joins the call after refresh when the payload is camel-cased', async () => {
|
||||
messageContext.contentAttributes.value.data = {
|
||||
status: VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
callSid: 'CALL456',
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.find('[data-test-id="voice-call-join"]').trigger('click');
|
||||
|
||||
expect(joinCallMock).toHaveBeenCalledWith({
|
||||
conversationId: 12,
|
||||
inboxId: 3,
|
||||
callSid: 'CALL456',
|
||||
});
|
||||
expect(wrapper.text()).toContain('Rejoin call');
|
||||
});
|
||||
|
||||
it('hides the rejoin action when the agent is already on the same call', async () => {
|
||||
activeCall.value = {
|
||||
callSid: 'CALL123',
|
||||
conversationId: 12,
|
||||
};
|
||||
hasActiveCall.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
const joinButton = wrapper.find('[data-test-id="voice-call-join"]');
|
||||
|
||||
await joinButton.trigger('click');
|
||||
|
||||
expect(joinButton.attributes('disabled')).toBeDefined();
|
||||
expect(joinCallMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.text()).not.toContain('Join call');
|
||||
expect(wrapper.text()).not.toContain('Rejoin call');
|
||||
});
|
||||
|
||||
it('does not expose a join action when another agent is assigned', () => {
|
||||
conversationsById[12].meta.assignee = { id: 99, name: 'Ben' };
|
||||
|
||||
const wrapper = mountComponent();
|
||||
const joinButton = wrapper.find('[data-test-id="voice-call-join"]');
|
||||
|
||||
expect(joinButton.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.text()).not.toContain('Join call');
|
||||
expect(wrapper.text()).not.toContain('Rejoin call');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,19 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MESSAGE_TYPES,
|
||||
VOICE_CALL_STATUS,
|
||||
} from '../constants';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
|
||||
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',
|
||||
};
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
@@ -30,38 +29,114 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { activeCall, hasActiveCall, isJoining, joinCall, endCall } =
|
||||
useCallSession({
|
||||
manageSessionState: false,
|
||||
});
|
||||
const {
|
||||
attachments,
|
||||
contentAttributes,
|
||||
conversationId,
|
||||
currentUserId,
|
||||
inboxId,
|
||||
messageType,
|
||||
} = useMessageContext();
|
||||
|
||||
const JOINABLE_STATUSES = [
|
||||
VOICE_CALL_STATUS.RINGING,
|
||||
VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
];
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
const callSid = computed(() => {
|
||||
return (data.value?.callSid || data.value?.call_sid || '').toString();
|
||||
});
|
||||
const joinedBy = computed(() => {
|
||||
return data.value?.meta?.joinedBy || data.value?.meta?.joined_by;
|
||||
});
|
||||
const callDuration = computed(() => {
|
||||
return data.value?.meta?.duration;
|
||||
});
|
||||
const recordingAttachment = computed(() => {
|
||||
return attachments.value?.find(
|
||||
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
|
||||
);
|
||||
});
|
||||
const conversation = computed(() => {
|
||||
return store.getters.getConversationById?.(conversationId.value) || null;
|
||||
});
|
||||
const activeConversation = computed(() => {
|
||||
const activeConversationId = activeCall.value?.conversationId;
|
||||
if (!activeConversationId) return null;
|
||||
|
||||
return store.getters.getConversationById?.(activeConversationId) || null;
|
||||
});
|
||||
const assigneeId = computed(() => {
|
||||
return (
|
||||
conversation.value?.meta?.assignee?.id || conversation.value?.assignee_id
|
||||
);
|
||||
});
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === 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 didCurrentUserAnswer = computed(() => {
|
||||
return joinedBy.value?.id === currentUserId.value;
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
const label = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.OUTGOING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
? t('CONVERSATION.VOICE_CALL.MISSED_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return (
|
||||
formatDuration(callDuration.value) ||
|
||||
t('CONVERSATION.VOICE_CALL.CALL_ENDED')
|
||||
);
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
|
||||
if (joinedBy.value?.name) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: joinedBy.value.name,
|
||||
});
|
||||
}
|
||||
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
}
|
||||
|
||||
return isFailed.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@@ -70,12 +145,66 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
const resolvedInboxId = computed(() => {
|
||||
return inboxId.value || conversation.value?.inbox_id || null;
|
||||
});
|
||||
const isCurrentActiveCall = computed(() => {
|
||||
return activeCall.value?.callSid === callSid.value;
|
||||
});
|
||||
const canJoinCall = computed(() => {
|
||||
return (
|
||||
JOINABLE_STATUSES.includes(status.value) &&
|
||||
!!conversationId.value &&
|
||||
!!resolvedInboxId.value &&
|
||||
!!callSid.value &&
|
||||
(!assigneeId.value || assigneeId.value === currentUserId.value) &&
|
||||
!isCurrentActiveCall.value
|
||||
);
|
||||
});
|
||||
const joinCallText = computed(() => {
|
||||
return status.value === VOICE_CALL_STATUS.IN_PROGRESS
|
||||
? t('CONVERSATION.VOICE_CALL.REJOIN_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.JOIN_CALL');
|
||||
});
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (!canJoinCall.value || isJoining.value) return;
|
||||
|
||||
if (hasActiveCall.value) {
|
||||
if (activeCall.value?.callSid === callSid.value) return;
|
||||
|
||||
const activeInboxId = activeConversation.value?.inbox_id;
|
||||
if (activeCall.value?.conversationId && activeInboxId) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeInboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: resolvedInboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
||||
<button
|
||||
class="flex gap-3 items-center p-3 w-full text-left border-none bg-transparent"
|
||||
:class="{
|
||||
'cursor-pointer transition-colors hover:bg-n-alpha-1 active:bg-n-alpha-2':
|
||||
canJoinCall,
|
||||
'cursor-default': !canJoinCall,
|
||||
}"
|
||||
:disabled="!canJoinCall || isJoining"
|
||||
data-test-id="voice-call-join"
|
||||
type="button"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bgColor"
|
||||
@@ -92,12 +221,21 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<span
|
||||
v-if="canJoinCall"
|
||||
class="mt-1 text-xs font-medium text-n-teal-10"
|
||||
>
|
||||
{{ joinCallText }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
||||
<AudioChip class="text-n-slate-12" :attachment="recordingAttachment" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallSession } from '../useCallSession';
|
||||
|
||||
vi.mock('vue', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
onMounted: vi.fn(),
|
||||
onUnmounted: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
useAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/channel/voice/voiceAPIClient', () => ({
|
||||
default: {
|
||||
joinConference: vi.fn(),
|
||||
leaveConference: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/channel/voice/twilioVoiceClient', () => ({
|
||||
default: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
initializeDevice: vi.fn(),
|
||||
joinClientCall: vi.fn(),
|
||||
endClientCall: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn(key => key),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useCallSession', () => {
|
||||
let consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows the backend conflict message when another agent already claimed the call', async () => {
|
||||
TwilioVoiceClient.initializeDevice.mockResolvedValue({ device: true });
|
||||
VoiceAPI.joinConference.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Jane Agent is already handling the call.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { joinCall } = useCallSession();
|
||||
const result = await joinCall({
|
||||
conversationId: 42,
|
||||
inboxId: 1,
|
||||
callSid: 'CALL123',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(useAlert).toHaveBeenCalledWith(
|
||||
'Jane Agent is already handling the call.'
|
||||
);
|
||||
expect(TwilioVoiceClient.joinClientCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,51 +1,62 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
|
||||
export function useCallSession() {
|
||||
export function useCallSession({ manageSessionState = true } = {}) {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
});
|
||||
const handleCallDisconnected = () => callsStore.clearActiveCall();
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
if (manageSessionState) {
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleCallDisconnected
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleCallDisconnected
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
if (manageSessionState) {
|
||||
durationTimer.stop();
|
||||
}
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
@@ -69,10 +80,13 @@ export function useCallSession() {
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
if (manageSessionState) {
|
||||
durationTimer.start();
|
||||
}
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
@@ -91,9 +105,7 @@ export function useCallSession() {
|
||||
};
|
||||
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
return formatDuration(callDuration.value);
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from '../voice';
|
||||
|
||||
vi.mock('dashboard/api/channel/voice/twilioVoiceClient', () => ({
|
||||
default: {
|
||||
endClientCall: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const buildVoiceMessage = overrides => ({
|
||||
content_type: CONTENT_TYPES.VOICE_CALL,
|
||||
content_attributes: {
|
||||
data: {
|
||||
call_sid: 'CALL123',
|
||||
status: 'ringing',
|
||||
call_direction: 'inbound',
|
||||
},
|
||||
},
|
||||
conversation_id: 42,
|
||||
conversation: {
|
||||
assignee_id: null,
|
||||
},
|
||||
sender: {
|
||||
id: 7,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('voice helper', () => {
|
||||
const commit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows inbound calls to everyone when the conversation is unassigned', () => {
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
handleVoiceCallCreated(buildVoiceMessage(), 1);
|
||||
|
||||
expect(callsStore.calls).toEqual([
|
||||
expect.objectContaining({
|
||||
callSid: 'CALL123',
|
||||
conversationId: 42,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides inbound calls when they are assigned to another agent', () => {
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
handleVoiceCallCreated(
|
||||
buildVoiceMessage({
|
||||
conversation: {
|
||||
assignee_id: 9,
|
||||
},
|
||||
}),
|
||||
1
|
||||
);
|
||||
|
||||
expect(callsStore.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes a visible call when an update shows it is assigned to another agent', () => {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: 'CALL123',
|
||||
conversationId: 42,
|
||||
callDirection: 'inbound',
|
||||
senderId: 7,
|
||||
});
|
||||
|
||||
handleVoiceCallUpdated(
|
||||
commit,
|
||||
buildVoiceMessage({
|
||||
conversation: {
|
||||
assignee_id: 9,
|
||||
},
|
||||
}),
|
||||
1
|
||||
);
|
||||
|
||||
expect(callsStore.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the call widget when the conversation assignment changes to another agent', () => {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: 'CALL123',
|
||||
conversationId: 42,
|
||||
callDirection: 'inbound',
|
||||
senderId: 7,
|
||||
});
|
||||
|
||||
syncConversationCallVisibility(
|
||||
{
|
||||
id: 42,
|
||||
meta: {
|
||||
assignee: {
|
||||
id: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
expect(callsStore.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,26 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
return (
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId) &&
|
||||
!isAssignedToAnotherAgent(assigneeId, currentUserId)
|
||||
);
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
return {
|
||||
@@ -29,6 +49,7 @@ function extractCallData(message) {
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@@ -36,10 +57,19 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
@@ -53,8 +83,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
@@ -64,9 +100,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCallsForConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewCall = status === 'ringing';
|
||||
|
||||
if (isNewCall) {
|
||||
callsStore.addCall({
|
||||
@@ -77,3 +123,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
@@ -81,8 +81,11 @@
|
||||
"MISSED_CALL": "Missed call",
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"JOIN_CALL": "Join call",
|
||||
"REJOIN_CALL": "Rejoin call"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from 'dashboard/helper/voice';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
@@ -391,18 +392,19 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateConversation({ commit, dispatch }, conversation) {
|
||||
const {
|
||||
meta: { sender },
|
||||
} = conversation;
|
||||
updateConversation({ commit, dispatch, rootGetters }, conversation) {
|
||||
const sender = conversation.meta?.sender;
|
||||
commit(types.UPDATE_CONVERSATION, conversation);
|
||||
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
|
||||
|
||||
dispatch('conversationLabels/setConversationLabel', {
|
||||
id: conversation.id,
|
||||
data: conversation.labels,
|
||||
});
|
||||
|
||||
dispatch('contacts/setContact', sender);
|
||||
if (sender) {
|
||||
dispatch('contacts/setContact', sender);
|
||||
}
|
||||
},
|
||||
|
||||
updateConversationLastActivity(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import actions, {
|
||||
hasMessageFailedWithExternalError,
|
||||
} from '../../conversations/actions';
|
||||
@@ -56,6 +57,11 @@ describe('#hasMessageFailedWithExternalError', () => {
|
||||
});
|
||||
|
||||
describe('#actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#getConversation', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.get.mockResolvedValue({
|
||||
|
||||
@@ -40,6 +40,20 @@ export const useCallsStore = defineStore('calls', {
|
||||
this.calls = this.calls.filter(c => c.callSid !== callSid);
|
||||
},
|
||||
|
||||
removeCallsForConversation(conversationId) {
|
||||
const callsToRemove = this.calls.filter(
|
||||
call => call.conversationId === conversationId
|
||||
);
|
||||
|
||||
if (callsToRemove.some(call => call.isActive)) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
|
||||
this.calls = this.calls.filter(
|
||||
call => call.conversationId !== conversationId
|
||||
);
|
||||
},
|
||||
|
||||
setCallActive(callSid) {
|
||||
this.calls = this.calls.map(call => ({
|
||||
...call,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
dynamicTime,
|
||||
dateFormat,
|
||||
shortTimestamp,
|
||||
formatDuration,
|
||||
getDayDifferenceFromNow,
|
||||
hasOneDayPassed,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
@@ -93,6 +94,24 @@ describe('#shortTimestamp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#formatDuration', () => {
|
||||
it('returns empty string for invalid values', () => {
|
||||
expect(formatDuration(null)).toEqual('');
|
||||
expect(formatDuration(undefined)).toEqual('');
|
||||
expect(formatDuration(-1)).toEqual('');
|
||||
expect(formatDuration('abc')).toEqual('');
|
||||
});
|
||||
|
||||
it('formats short durations as mm:ss', () => {
|
||||
expect(formatDuration(0)).toEqual('00:00');
|
||||
expect(formatDuration(133)).toEqual('02:13');
|
||||
});
|
||||
|
||||
it('formats long durations as hh:mm:ss', () => {
|
||||
expect(formatDuration(3733)).toEqual('01:02:13');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getDayDifferenceFromNow', () => {
|
||||
it('returns 0 for timestamps from today', () => {
|
||||
// Mock current date: May 5, 2023
|
||||
|
||||
@@ -93,6 +93,32 @@ export const shortTimestamp = (time, withAgo = false) => {
|
||||
return convertToShortTime;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a duration in seconds into mm:ss or hh:mm:ss.
|
||||
* @param {number|string} durationInSeconds - Duration in seconds.
|
||||
* @returns {string} Formatted duration string.
|
||||
*/
|
||||
export const formatDuration = durationInSeconds => {
|
||||
if (durationInSeconds === null || durationInSeconds === undefined) return '';
|
||||
|
||||
const totalSeconds = Number(durationInSeconds);
|
||||
if (Number.isNaN(totalSeconds) || totalSeconds < 0) return '';
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes
|
||||
.toString()
|
||||
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the difference in days between now and a given timestamp.
|
||||
* @param {Date} now - Current date/time.
|
||||
|
||||
@@ -591,6 +591,7 @@ Rails.application.routes.draw do
|
||||
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
|
||||
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
|
||||
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
|
||||
post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
|
||||
def create
|
||||
conversation = fetch_conversation_by_display_id
|
||||
ensure_call_sid!(conversation)
|
||||
return if claim_conversation!(conversation) == false
|
||||
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
|
||||
conference_sid = conference_service.ensure_conference_sid
|
||||
@@ -33,6 +33,22 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
|
||||
private
|
||||
|
||||
def claim_conversation!(conversation)
|
||||
conversation.with_lock do
|
||||
if conversation.assignee_id == current_user.id
|
||||
ensure_call_sid!(conversation)
|
||||
true
|
||||
elsif conversation.assignee.present?
|
||||
render_conflict_for_assigned_agent!(conversation.assignee.name)
|
||||
false
|
||||
else
|
||||
ensure_call_sid!(conversation)
|
||||
conversation.update!(assignee: current_user)
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_call_sid!(conversation)
|
||||
return conversation.identifier if conversation.identifier.present?
|
||||
|
||||
@@ -55,4 +71,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
authorize conversation, :show?
|
||||
conversation
|
||||
end
|
||||
|
||||
def render_conflict_for_assigned_agent!(agent_name)
|
||||
render json: { error: "#{agent_name} is already handling the call." }, status: :conflict
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,7 @@ class Twilio::VoiceController < ApplicationController
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
persist_twilio_conference_sid!(conversation, params[:ConferenceSid])
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
conversation: conversation,
|
||||
@@ -50,6 +51,14 @@ class Twilio::VoiceController < ApplicationController
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def recording_status
|
||||
Voice::RecordingStatusService.new(
|
||||
account: current_account,
|
||||
payload: params.to_unsafe_h
|
||||
).perform
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_call_sid
|
||||
@@ -139,6 +148,10 @@ class Twilio::VoiceController < ApplicationController
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_sid,
|
||||
record: 'record-from-start',
|
||||
recording_status_callback: recording_status_callback_url,
|
||||
recording_status_callback_event: 'completed',
|
||||
recording_status_callback_method: 'POST',
|
||||
start_conference_on_enter: agent_leg,
|
||||
end_conference_on_exit: false,
|
||||
status_callback: conference_status_callback_url,
|
||||
@@ -155,6 +168,11 @@ class Twilio::VoiceController < ApplicationController
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def recording_status_callback_url
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_recording_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
name = friendly_name.to_s
|
||||
scope = current_account.conversations
|
||||
@@ -167,6 +185,14 @@ class Twilio::VoiceController < ApplicationController
|
||||
scope.find_by!(identifier: call_sid)
|
||||
end
|
||||
|
||||
def persist_twilio_conference_sid!(conversation, conference_sid)
|
||||
return if conference_sid.blank?
|
||||
return if conversation.additional_attributes&.dig('twilio_conference_sid') == conference_sid
|
||||
|
||||
attrs = (conversation.additional_attributes || {}).merge('twilio_conference_sid' => conference_sid)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
e164 = "+#{digits}"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class Voice::Provider::Twilio::RecordingAttachmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Down::Error, wait: 5.seconds, attempts: 3
|
||||
|
||||
def perform(conversation_id, recording_sid, recording_url, recording_duration = nil)
|
||||
conversation = Conversation.find_by(id: conversation_id)
|
||||
return if conversation.blank?
|
||||
|
||||
Voice::Provider::Twilio::RecordingAttachmentService.new(
|
||||
conversation: conversation,
|
||||
recording_sid: recording_sid,
|
||||
recording_url: recording_url,
|
||||
recording_duration: recording_duration
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ class Voice::CallStatus::Manager
|
||||
|
||||
current_status = conversation.additional_attributes&.dig('call_status')
|
||||
return if current_status == status
|
||||
return if preserve_existing_terminal_status?(current_status, status)
|
||||
|
||||
apply_status(status, duration: duration, timestamp: timestamp)
|
||||
update_message(status)
|
||||
@@ -43,17 +44,10 @@ class Voice::CallStatus::Manager
|
||||
end
|
||||
|
||||
def update_message(status)
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
message = latest_voice_call_message
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = status
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
message.update!(content_attributes: updated_message_attributes(message, status))
|
||||
end
|
||||
|
||||
def now_seconds
|
||||
@@ -63,4 +57,35 @@ class Voice::CallStatus::Manager
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
|
||||
def latest_voice_call_message
|
||||
conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def updated_message_attributes(message, status)
|
||||
data = (message.content_attributes || {}).deep_dup
|
||||
call_data = data['data'] ||= {}
|
||||
call_data['status'] = status
|
||||
call_data['meta'] ||= {}
|
||||
update_duration_meta(data, status)
|
||||
data
|
||||
end
|
||||
|
||||
def update_duration_meta(data, status)
|
||||
duration = conversation.additional_attributes['call_duration']
|
||||
|
||||
if status == 'completed' && duration.present?
|
||||
data['data']['meta']['duration'] = duration
|
||||
else
|
||||
data['data']['meta'].delete('duration')
|
||||
end
|
||||
end
|
||||
|
||||
def preserve_existing_terminal_status?(current_status, next_status)
|
||||
TERMINAL_STATUSES.include?(current_status) &&
|
||||
TERMINAL_STATUSES.include?(next_status)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,9 +45,9 @@ class Voice::Conference::Manager
|
||||
def handle_leave!
|
||||
case current_status
|
||||
when 'ringing'
|
||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp) if contact_participant?
|
||||
when 'in-progress'
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp) if contact_participant?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,10 @@ class Voice::Conference::Manager
|
||||
participant_label.to_s.start_with?('agent')
|
||||
end
|
||||
|
||||
def contact_participant?
|
||||
participant_label.to_s.start_with?('contact')
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
|
||||
@@ -11,11 +11,15 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
joined_by = { id: user.id, name: user.name }
|
||||
|
||||
merge_attributes(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => { id: user.id, name: user.name }
|
||||
'joined_by' => joined_by
|
||||
)
|
||||
|
||||
sync_message_join_metadata!(joined_by)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
@@ -32,6 +36,18 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
conversation.update!(additional_attributes: current.merge(attrs))
|
||||
end
|
||||
|
||||
def sync_message_join_metadata!(joined_by)
|
||||
message = conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
return unless message
|
||||
|
||||
content_attributes = (message.content_attributes || {}).deep_dup
|
||||
content_attributes['data'] ||= {}
|
||||
content_attributes['data']['meta'] ||= {}
|
||||
content_attributes['data']['meta']['joined_by'] = joined_by
|
||||
|
||||
message.update!(content_attributes: content_attributes)
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
class Voice::Provider::Twilio::RecordingAttachmentService
|
||||
DEFAULT_FILENAME_EXTENSION = 'wav'.freeze
|
||||
|
||||
pattr_initialize [:conversation!, :recording_sid!, :recording_url!, { recording_duration: nil }]
|
||||
|
||||
def perform
|
||||
return if recording_sid.blank? || recording_url.blank?
|
||||
|
||||
message = voice_call_message
|
||||
return if message.blank? || recording_already_attached?(message)
|
||||
|
||||
recording_file = download_recording
|
||||
|
||||
message.reload.with_lock do
|
||||
next if recording_already_attached?(message)
|
||||
|
||||
attach_recording!(message, recording_file)
|
||||
update_recording_metadata!(message)
|
||||
end
|
||||
ensure
|
||||
recording_file.close! if recording_file.respond_to?(:close!)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def voice_call_message
|
||||
@voice_call_message ||= conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
end
|
||||
|
||||
def recording_already_attached?(message)
|
||||
message.content_attributes
|
||||
&.dig('data', 'meta', 'recording', 'sid')
|
||||
.to_s == recording_sid.to_s
|
||||
end
|
||||
|
||||
def download_recording
|
||||
Down.download(recording_url, http_basic_authentication: [account_sid, auth_token])
|
||||
end
|
||||
|
||||
def attach_recording!(message, recording_file)
|
||||
message.attachments.create!(
|
||||
account_id: conversation.account_id,
|
||||
file_type: :audio,
|
||||
file: {
|
||||
io: recording_file,
|
||||
filename: recording_filename(recording_file),
|
||||
content_type: recording_content_type(recording_file)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def update_recording_metadata!(message)
|
||||
content_attributes = (message.content_attributes || {}).deep_dup
|
||||
content_attributes['data'] ||= {}
|
||||
content_attributes['data']['meta'] ||= {}
|
||||
content_attributes['data']['meta']['recording'] = {
|
||||
'sid' => recording_sid,
|
||||
'duration' => normalized_recording_duration
|
||||
}.compact
|
||||
|
||||
message.update!(content_attributes: content_attributes)
|
||||
end
|
||||
|
||||
def normalized_recording_duration
|
||||
return if recording_duration.blank?
|
||||
|
||||
recording_duration.to_i
|
||||
end
|
||||
|
||||
def recording_filename(recording_file)
|
||||
filename = recording_file.original_filename if recording_file.respond_to?(:original_filename)
|
||||
return filename if filename.present?
|
||||
|
||||
"call-recording-#{recording_sid}.#{recording_extension(recording_file)}"
|
||||
end
|
||||
|
||||
def recording_extension(recording_file)
|
||||
content_type = recording_content_type(recording_file)
|
||||
Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION
|
||||
end
|
||||
|
||||
def recording_content_type(recording_file)
|
||||
recording_file.content_type.presence || 'audio/wav'
|
||||
end
|
||||
|
||||
def account_sid
|
||||
@account_sid ||= channel_config.fetch('account_sid')
|
||||
end
|
||||
|
||||
def auth_token
|
||||
@auth_token ||= channel_config.fetch('auth_token')
|
||||
end
|
||||
|
||||
def channel_config
|
||||
@channel_config ||= conversation.inbox.channel.provider_config_hash
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
class Voice::RecordingStatusService
|
||||
pattr_initialize [:account!, { payload: {} }]
|
||||
|
||||
def perform
|
||||
return unless completed_recording?
|
||||
return if conference_sid.blank? || recording_sid.blank? || recording_url.blank?
|
||||
|
||||
conversation = account.conversations.find_by(
|
||||
"additional_attributes->>'twilio_conference_sid' = ?",
|
||||
conference_sid
|
||||
)
|
||||
return if conversation.blank?
|
||||
|
||||
Voice::Provider::Twilio::RecordingAttachmentJob.perform_later(
|
||||
conversation.id,
|
||||
recording_sid,
|
||||
recording_url,
|
||||
recording_duration
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def completed_recording?
|
||||
payload['RecordingStatus'].to_s.casecmp('completed').zero?
|
||||
end
|
||||
|
||||
def conference_sid
|
||||
payload['ConferenceSid'].to_s
|
||||
end
|
||||
|
||||
def recording_sid
|
||||
payload['RecordingSid'].to_s
|
||||
end
|
||||
|
||||
def recording_url
|
||||
payload['RecordingUrl'].to_s
|
||||
end
|
||||
|
||||
def recording_duration
|
||||
payload['RecordingDuration']
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,7 @@ class Voice::StatusUpdateService
|
||||
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
return if ignore_premature_in_progress?(conversation, normalized_status)
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
@@ -57,4 +58,10 @@ class Voice::StatusUpdateService
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def ignore_premature_in_progress?(conversation, normalized_status)
|
||||
normalized_status == 'in-progress' &&
|
||||
conversation.additional_attributes&.dig('call_direction') == 'inbound' &&
|
||||
!conversation.additional_attributes&.dig('agent_joined')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,8 +78,9 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
expect(body['conference_sid']).to be_present
|
||||
conversation.reload
|
||||
expect(conversation.identifier).to eq('CALL123')
|
||||
expect(conversation.assignee).to eq(agent)
|
||||
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||
expect(conference_service).to have_received(:mark_agent_joined)
|
||||
expect(conference_service).to have_received(:mark_agent_joined).with(user: agent)
|
||||
end
|
||||
|
||||
it 'does not allow accessing conversations from inboxes without access' do
|
||||
@@ -102,6 +103,39 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
|
||||
it 'returns conflict when another agent is already assigned' do
|
||||
other_agent = create(:user, account: account, role: :agent, name: 'Jane Agent')
|
||||
create(:inbox_member, inbox: voice_inbox, user: other_agent)
|
||||
conversation.update!(assignee: other_agent)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
expect(response.parsed_body['error']).to eq('Jane Agent is already handling the call.')
|
||||
conversation.reload
|
||||
expect(conversation.assignee).to eq(other_agent)
|
||||
expect(conversation.identifier).to be_nil
|
||||
expect(conference_service).not_to have_received(:ensure_conference_sid)
|
||||
expect(conference_service).not_to have_received(:mark_agent_joined)
|
||||
end
|
||||
|
||||
it 'allows the assigned agent to rejoin' do
|
||||
conversation.update!(assignee: agent)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
conversation.reload
|
||||
expect(conversation.assignee).to eq(agent)
|
||||
expect(conversation.identifier).to eq('CALL123')
|
||||
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||
expect(conference_service).to have_received(:mark_agent_joined).with(user: agent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:digits) { channel.phone_number.delete_prefix('+') }
|
||||
|
||||
before do
|
||||
clear_enqueued_jobs
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
end
|
||||
@@ -39,6 +42,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include('<Dial>')
|
||||
expect(response.body).to include('record="record-from-start"')
|
||||
expect(response.body).to include("/twilio/voice/recording_status/#{digits}")
|
||||
end
|
||||
|
||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||
@@ -143,4 +148,74 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /twilio/voice/conference_status/:phone' do
|
||||
let(:call_sid) { 'CA_conference_status_sid_456' }
|
||||
let!(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'conference_sid' => 'friendly-conference-name' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'persists the Twilio conference SID from the callback' do
|
||||
manager_double = instance_double(Voice::Conference::Manager, process: nil)
|
||||
allow(Voice::Conference::Manager).to receive(:new).and_return(manager_double)
|
||||
|
||||
post "/twilio/voice/conference_status/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'FriendlyName' => 'friendly-conference-name',
|
||||
'ConferenceSid' => 'CF123456789',
|
||||
'StatusCallbackEvent' => 'conference-start',
|
||||
'ParticipantLabel' => 'contact'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(conversation.reload.additional_attributes['twilio_conference_sid']).to eq('CF123456789')
|
||||
expect(manager_double).to have_received(:process)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /twilio/voice/recording_status/:phone' do
|
||||
let!(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
identifier: 'CA_recording_sid_456',
|
||||
additional_attributes: { 'twilio_conference_sid' => 'CF999' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'enqueues recording attachment when recording completes' do
|
||||
expect do
|
||||
post "/twilio/voice/recording_status/#{digits}", params: {
|
||||
'ConferenceSid' => 'CF999',
|
||||
'RecordingSid' => 'RE123',
|
||||
'RecordingUrl' => 'https://api.twilio.com/recordings/RE123',
|
||||
'RecordingDuration' => '42',
|
||||
'RecordingStatus' => 'completed'
|
||||
}
|
||||
end.to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
|
||||
.with(conversation.id, 'RE123', 'https://api.twilio.com/recordings/RE123', '42')
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'ignores non-completed recording events' do
|
||||
expect do
|
||||
post "/twilio/voice/recording_status/#{digits}", params: {
|
||||
'ConferenceSid' => 'CF999',
|
||||
'RecordingSid' => 'RE123',
|
||||
'RecordingUrl' => 'https://api.twilio.com/recordings/RE123',
|
||||
'RecordingStatus' => 'in-progress'
|
||||
}
|
||||
end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::Conference::Manager do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230011') }
|
||||
let(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: channel.inbox,
|
||||
additional_attributes: { 'call_status' => call_status }
|
||||
)
|
||||
end
|
||||
let(:status_manager) { instance_double(Voice::CallStatus::Manager, process_status_update: true) }
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(Voice::CallStatus::Manager).to receive(:new).and_return(status_manager)
|
||||
end
|
||||
|
||||
describe '#process' do
|
||||
context 'when an agent leaves an in-progress conference' do
|
||||
let(:call_status) { 'in-progress' }
|
||||
|
||||
it 'does not mark the call as completed' do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
event: 'leave',
|
||||
call_sid: 'CA123',
|
||||
participant_label: 'agent'
|
||||
).process
|
||||
|
||||
expect(status_manager).not_to have_received(:process_status_update)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact leaves an in-progress conference' do
|
||||
let(:call_status) { 'in-progress' }
|
||||
|
||||
it 'marks the call as completed' do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
event: 'leave',
|
||||
call_sid: 'CA123',
|
||||
participant_label: 'contact'
|
||||
).process
|
||||
|
||||
expect(status_manager).to have_received(:process_status_update).with(
|
||||
'completed',
|
||||
timestamp: kind_of(Integer)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact leaves before an agent joins' do
|
||||
let(:call_status) { 'ringing' }
|
||||
|
||||
it 'marks the call as no-answer' do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
event: 'leave',
|
||||
call_sid: 'CA123',
|
||||
participant_label: 'contact'
|
||||
).process
|
||||
|
||||
expect(status_manager).to have_received(:process_status_update).with(
|
||||
'no-answer',
|
||||
timestamp: kind_of(Integer)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -32,12 +32,29 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
describe '#mark_agent_joined' do
|
||||
it 'stores agent join metadata' do
|
||||
agent = create(:user, account: account)
|
||||
message = create(
|
||||
:message,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: channel.inbox,
|
||||
sender: conversation.contact,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
'data' => {
|
||||
'status' => 'ringing',
|
||||
'meta' => {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
service.mark_agent_joined(user: agent)
|
||||
|
||||
attrs = conversation.reload.additional_attributes
|
||||
expect(attrs['agent_joined']).to be true
|
||||
expect(attrs['joined_by']['id']).to eq(agent.id)
|
||||
expect(message.reload.content_attributes.dig('data', 'meta', 'joined_by')).to eq(
|
||||
{ 'id' => agent.id, 'name' => agent.name }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::Provider::Twilio::RecordingAttachmentService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230009') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15550009999') }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: contact.phone_number) }
|
||||
let(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox
|
||||
)
|
||||
end
|
||||
let(:voice_call_message) do
|
||||
create(
|
||||
:message,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
sender: contact,
|
||||
message_type: :incoming,
|
||||
content: 'Voice Call',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
'data' => {
|
||||
'status' => 'completed',
|
||||
'meta' => {
|
||||
'duration' => 42
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
let(:recording_sid) { 'RE-recording-123' }
|
||||
let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC123/Recordings/RE-recording-123' }
|
||||
let(:recording_file) do
|
||||
Tempfile.new(['call-recording', '.wav']).tap do |file|
|
||||
file.write('fake wav content')
|
||||
file.rewind
|
||||
file.define_singleton_method(:content_type) { 'audio/wav' }
|
||||
file.define_singleton_method(:original_filename) { 'call-recording.wav' }
|
||||
file.define_singleton_method(:close!) do
|
||||
close
|
||||
unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
channel
|
||||
voice_call_message
|
||||
allow(Down).to receive(:download).with(
|
||||
recording_url,
|
||||
http_basic_authentication: [
|
||||
channel.provider_config_hash['account_sid'],
|
||||
channel.provider_config_hash['auth_token']
|
||||
]
|
||||
).and_return(recording_file)
|
||||
allow(Messages::AudioTranscriptionJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'attaches the recording to the existing voice_call message' do
|
||||
described_class.new(
|
||||
conversation: conversation,
|
||||
recording_sid: recording_sid,
|
||||
recording_url: recording_url,
|
||||
recording_duration: '42'
|
||||
).perform
|
||||
|
||||
voice_call_message.reload
|
||||
|
||||
expect(voice_call_message.attachments.size).to eq(1)
|
||||
expect(voice_call_message.attachments.first.file_type).to eq('audio')
|
||||
expect(voice_call_message.content_attributes.dig('data', 'meta', 'recording')).to eq(
|
||||
{ 'sid' => recording_sid, 'duration' => 42 }
|
||||
)
|
||||
end
|
||||
|
||||
it 'is idempotent for the same recording sid' do
|
||||
service = described_class.new(
|
||||
conversation: conversation,
|
||||
recording_sid: recording_sid,
|
||||
recording_url: recording_url
|
||||
)
|
||||
|
||||
service.perform
|
||||
service.perform
|
||||
|
||||
voice_call_message.reload
|
||||
|
||||
expect(voice_call_message.attachments.count).to eq(1)
|
||||
expect(voice_call_message.content_attributes.dig('data', 'meta', 'recording', 'sid')).to eq(recording_sid)
|
||||
end
|
||||
end
|
||||
@@ -37,7 +37,7 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
end
|
||||
|
||||
it 'updates conversation and last voice message with call status' do
|
||||
it 'updates conversation and last voice message with call status and duration' do
|
||||
# Ensure records are created after stub setup
|
||||
conversation
|
||||
message
|
||||
@@ -45,14 +45,17 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'completed'
|
||||
call_status: 'completed',
|
||||
payload: { 'CallDuration' => '133' }
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('completed')
|
||||
expect(conversation.additional_attributes['call_duration']).to eq(133)
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
||||
expect(message.content_attributes.dig('data', 'meta', 'duration')).to eq(133)
|
||||
end
|
||||
|
||||
it 'normalizes busy to no-answer' do
|
||||
@@ -72,6 +75,43 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
||||
end
|
||||
|
||||
it 'does not overwrite no-answer with a later completed callback' do
|
||||
conversation.update!(additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'no-answer' })
|
||||
message.update!(content_attributes: { data: { call_sid: call_sid, status: 'no-answer' } })
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'completed',
|
||||
payload: { 'CallDuration' => '4' }
|
||||
).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')
|
||||
expect(message.content_attributes.dig('data', 'meta', 'duration')).to be_nil
|
||||
end
|
||||
|
||||
it 'ignores inbound in-progress callbacks before any agent joins' do
|
||||
conversation
|
||||
message
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'in-progress'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
expect(conversation.additional_attributes['call_started_at']).to be_nil
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('ringing')
|
||||
end
|
||||
|
||||
it 'no-ops when conversation not found' do
|
||||
expect do
|
||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||
|
||||
Reference in New Issue
Block a user