Compare commits

...
28 changed files with 1377 additions and 92 deletions
@@ -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([]);
});
});
+61 -7
View File
@@ -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({
+14
View File
@@ -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.
+1
View File
@@ -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