feat(voice): Wire Twilio voice flow through unified call model (#14091)
Twilio voice now uses first-class `Call` records as the source of truth for call state, instead of storing it on `conversation.additional_attributes` and `conversation.identifier`. Each call gets its own record, its own `voice_call` bubble matched by `call_sid`, and its own conference name keyed off `Call.id`. Multiple calls on the same conversation (for `lock_to_single_conversation` inboxes) now work correctly, and the conversation card stays in sync with the real latest message. Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
@@ -48,3 +48,5 @@ class MessageFinder
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
end
|
||||
|
||||
MessageFinder.prepend_mod_with('MessageFinder')
|
||||
|
||||
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async joinClientCall({ to, conversationId }) {
|
||||
async joinClientCall({ to, conversationId, callSid }) {
|
||||
if (!this.device || !this.initialized || !to) return null;
|
||||
if (this.activeConnection) return this.activeConnection;
|
||||
|
||||
@@ -76,6 +76,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
To: to,
|
||||
is_agent: 'true',
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
};
|
||||
|
||||
const connection = await this.device.connect({ params });
|
||||
|
||||
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
leaveConference(inboxId, conversationId) {
|
||||
leaveConference({ inboxId, conversationId, callSid }) {
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
params: { conversation_id: conversationId },
|
||||
params: { conversation_id: conversationId, call_sid: callSid },
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
+10
-4
@@ -35,10 +35,16 @@ const emit = defineEmits([
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ const props = defineProps({
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
attachments: { type: Array, default: () => [] },
|
||||
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
content: { type: String, default: null },
|
||||
contentAttributes: { type: Object, default: () => ({}) },
|
||||
contentType: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -30,12 +30,10 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
const { call } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ const handleEndCall = async () => {
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -38,10 +38,16 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
|
||||
@@ -42,8 +42,8 @@ export function useCallSession() {
|
||||
);
|
||||
});
|
||||
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
@@ -66,6 +66,7 @@ export function useCallSession() {
|
||||
await TwilioVoiceClient.joinClientCall({
|
||||
to: joinResponse?.conference_sid,
|
||||
conversationId,
|
||||
callSid,
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
|
||||
@@ -23,11 +23,11 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
callSid: contentData.call_sid,
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
callSid: call.provider_call_id,
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
@@ -60,9 +60,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
||||
conversationId,
|
||||
callStatus: status,
|
||||
callSid,
|
||||
});
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
|
||||
@@ -307,34 +307,21 @@ export const mutations = {
|
||||
}
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CALL_STATUS](
|
||||
[types.UPDATE_MESSAGE_CALL_STATUS](
|
||||
_state,
|
||||
{ conversationId, callStatus }
|
||||
{ conversationId, callStatus, callSid }
|
||||
) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
chat.additional_attributes = {
|
||||
...chat.additional_attributes,
|
||||
call_status: callStatus,
|
||||
};
|
||||
},
|
||||
|
||||
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (!chat) return;
|
||||
|
||||
const lastCall = (chat.messages || []).findLast(
|
||||
m => m.content_type === CONTENT_TYPES.VOICE_CALL
|
||||
const message = (chat.messages || []).find(
|
||||
m =>
|
||||
m.content_type === CONTENT_TYPES.VOICE_CALL &&
|
||||
m.call?.provider_call_id === callSid
|
||||
);
|
||||
if (!message?.call) return;
|
||||
|
||||
if (!lastCall) return;
|
||||
|
||||
lastCall.content_attributes ??= {};
|
||||
lastCall.content_attributes.data = {
|
||||
...lastCall.content_attributes.data,
|
||||
status: callStatus,
|
||||
};
|
||||
message.call = { ...message.call, status: callStatus };
|
||||
},
|
||||
|
||||
[types.SET_ACTIVE_INBOX](_state, inboxId) {
|
||||
|
||||
@@ -2,55 +2,18 @@ import { mutations } from '../index';
|
||||
import types from '../../../mutation-types';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates call_status preserving existing additional_attributes', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, additional_attributes: { other_attr: 'value' } },
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
other_attr: 'value',
|
||||
call_status: 'in-progress',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates additional_attributes if it does not exist', () => {
|
||||
const state = { allConversations: [{ id: 1 }] };
|
||||
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(state.allConversations[0].additional_attributes).toEqual({
|
||||
call_status: 'completed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
|
||||
it('does nothing if conversation is not found', () => {
|
||||
const state = { allConversations: [] };
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(state.allConversations).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing if no voice call message exists', () => {
|
||||
it('does nothing if no matching voice call message exists', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
|
||||
@@ -59,6 +22,7 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(state.allConversations[0].messages[0]).toEqual({
|
||||
id: 1,
|
||||
@@ -66,7 +30,7 @@ describe('#mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the last voice call message status', () => {
|
||||
it('updates only the voice call message matching the given callSid', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
@@ -75,12 +39,12 @@ describe('#mutations', () => {
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { status: 'ringing' } },
|
||||
call: { provider_call_id: 'CA111', status: 'ringing' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { status: 'ringing' } },
|
||||
call: { provider_call_id: 'CA222', status: 'ringing' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -89,34 +53,15 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
callSid: 'CA111',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data.status
|
||||
).toBe('ringing');
|
||||
expect(
|
||||
state.allConversations[0].messages[1].content_attributes.data.status
|
||||
).toBe('in-progress');
|
||||
expect(state.allConversations[0].messages[0].call.status).toBe(
|
||||
'in-progress'
|
||||
);
|
||||
expect(state.allConversations[0].messages[1].call.status).toBe('ringing');
|
||||
});
|
||||
|
||||
it('creates content_attributes.data if it does not exist', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [{ id: 1, content_type: 'voice_call' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data.status
|
||||
).toBe('completed');
|
||||
});
|
||||
|
||||
it('preserves existing data in content_attributes.data', () => {
|
||||
it('preserves existing call fields when updating status', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
@@ -125,8 +70,11 @@ describe('#mutations', () => {
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: {
|
||||
data: { call_sid: 'CA123', status: 'ringing' },
|
||||
call: {
|
||||
provider_call_id: 'CA123',
|
||||
status: 'ringing',
|
||||
direction: 'incoming',
|
||||
duration_seconds: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -136,12 +84,13 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'in-progress',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].messages[0].content_attributes.data
|
||||
).toEqual({
|
||||
call_sid: 'CA123',
|
||||
expect(state.allConversations[0].messages[0].call).toEqual({
|
||||
provider_call_id: 'CA123',
|
||||
status: 'in-progress',
|
||||
direction: 'incoming',
|
||||
duration_seconds: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,8 +101,34 @@ describe('#mutations', () => {
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'ringing',
|
||||
callSid: 'CA123',
|
||||
});
|
||||
expect(state.allConversations[0].messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('does nothing if matching message has no call object yet', () => {
|
||||
const state = {
|
||||
allConversations: [
|
||||
{
|
||||
id: 1,
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
content_type: 'voice_call',
|
||||
call: { provider_call_id: 'CA-OTHER' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
|
||||
conversationId: 1,
|
||||
callStatus: 'completed',
|
||||
callSid: 'CA-MISSING',
|
||||
});
|
||||
expect(state.allConversations[0].messages[0].call).toEqual({
|
||||
provider_call_id: 'CA-OTHER',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,6 @@ export default {
|
||||
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
|
||||
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
|
||||
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
|
||||
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
|
||||
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
|
||||
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
|
||||
|
||||
|
||||
@@ -12,3 +12,5 @@ json.private message.private
|
||||
json.source_id message.source_id
|
||||
json.sender message.sender.push_event_data if message.sender
|
||||
json.attachments message.attachments.map(&:push_event_data) if message.attachments.present?
|
||||
|
||||
json.set! :call, message.call.push_event_data if message.content_type == 'voice_call' && message.respond_to?(:call) && message.call.present?
|
||||
|
||||
@@ -10,36 +10,35 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
end
|
||||
|
||||
def create
|
||||
conversation = fetch_conversation_by_display_id
|
||||
ensure_call_sid!(conversation)
|
||||
call = resolve_call!
|
||||
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(call: call)
|
||||
conference_sid = conference_service.ensure_conference_sid
|
||||
conference_service.mark_agent_joined(user: current_user)
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
id: conversation.display_id,
|
||||
id: call.conversation.display_id,
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true
|
||||
}
|
||||
end
|
||||
|
||||
def destroy
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
|
||||
render json: { status: 'success', id: conversation.display_id }
|
||||
call = resolve_call!
|
||||
Voice::Provider::Twilio::ConferenceService.new(call: call).end_conference
|
||||
render json: { status: 'success', id: call.conversation.display_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_call_sid!(conversation)
|
||||
return conversation.identifier if conversation.identifier.present?
|
||||
def resolve_call!
|
||||
sid = params[:call_sid].presence
|
||||
raise ActionController::ParameterMissing, :call_sid if sid.blank?
|
||||
|
||||
incoming_sid = params.require(:call_sid)
|
||||
|
||||
conversation.update!(identifier: incoming_sid)
|
||||
incoming_sid
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Call.where(inbox_id: @voice_inbox.id, provider: :twilio, conversation_id: conversation.id)
|
||||
.find_by!(provider_call_id: sid)
|
||||
end
|
||||
|
||||
def set_voice_inbox_for_conference
|
||||
|
||||
@@ -6,20 +6,18 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont
|
||||
authorize contact, :show?
|
||||
authorize voice_inbox, :show?
|
||||
|
||||
result = Voice::OutboundCallBuilder.perform!(
|
||||
call = Voice::OutboundCallBuilder.perform!(
|
||||
account: Current.account,
|
||||
inbox: voice_inbox,
|
||||
user: Current.user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
conversation = result[:conversation]
|
||||
|
||||
render json: {
|
||||
conversation_id: conversation.display_id,
|
||||
conversation_id: call.conversation.display_id,
|
||||
inbox_id: voice_inbox.id,
|
||||
call_sid: result[:call_sid],
|
||||
conference_sid: conversation.additional_attributes['conference_sid']
|
||||
call_sid: call.provider_call_id,
|
||||
conference_sid: call.conference_sid
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -20,30 +20,28 @@ class Twilio::VoiceController < ApplicationController
|
||||
end
|
||||
|
||||
def call_twiml
|
||||
account = current_account
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
)
|
||||
|
||||
conversation = resolve_conversation
|
||||
conference_sid = ensure_conference_sid!(conversation)
|
||||
|
||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||
call = resolve_call
|
||||
render xml: conference_twiml(call)
|
||||
end
|
||||
|
||||
def conference_status
|
||||
event = mapped_conference_event
|
||||
return head :no_content unless event
|
||||
if event.nil?
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_CONFERENCE_UNMAPPED_EVENT account=#{current_account.id} event=#{params[:StatusCallbackEvent]} call_sid=#{twilio_call_sid}"
|
||||
)
|
||||
return head :no_content
|
||||
end
|
||||
|
||||
conversation = find_conversation_for_conference!(
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
conversation: conversation,
|
||||
call: call,
|
||||
event: event,
|
||||
call_sid: twilio_call_sid,
|
||||
participant_label: participant_label
|
||||
).process
|
||||
|
||||
@@ -80,8 +78,8 @@ class Twilio::VoiceController < ApplicationController
|
||||
from_number.start_with?('client:')
|
||||
end
|
||||
|
||||
def resolve_conversation
|
||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
||||
def resolve_call
|
||||
return find_call_for_agent if agent_leg?(twilio_from)
|
||||
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
@@ -92,79 +90,72 @@ class Twilio::VoiceController < ApplicationController
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
when 'outbound-api', 'outbound-dial'
|
||||
sync_outbound_leg(
|
||||
call_sid: twilio_call_sid,
|
||||
from_number: twilio_from,
|
||||
direction: twilio_direction
|
||||
)
|
||||
sync_outbound_leg(call_sid: twilio_call_sid, direction: twilio_direction)
|
||||
else
|
||||
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_for_agent
|
||||
if params[:conversation_id].present?
|
||||
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
else
|
||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
||||
end
|
||||
def find_call_for_agent
|
||||
sid = params[:call_sid].presence
|
||||
raise ArgumentError, 'call_sid is required for agent leg' if sid.blank?
|
||||
|
||||
inbox_calls.find_by!(provider_call_id: sid)
|
||||
end
|
||||
|
||||
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||
def sync_outbound_leg(call_sid:, direction:)
|
||||
parent_sid = params['ParentCallSid'].presence
|
||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
|
||||
call = inbox_calls.find_by!(provider_call_id: lookup_sid)
|
||||
|
||||
Voice::CallSessionSyncService.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: twilio_to,
|
||||
direction: 'outbound'
|
||||
}
|
||||
).perform
|
||||
call.update!(parent_call_sid: parent_sid) if parent_sid.present? && call.parent_call_sid != parent_sid
|
||||
call
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(conversation)
|
||||
attrs = conversation.additional_attributes || {}
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
attrs['conference_sid']
|
||||
def inbox_calls
|
||||
Call.where(inbox_id: inbox.id, provider: :twilio)
|
||||
end
|
||||
|
||||
def conference_twiml(conference_sid, agent_leg)
|
||||
def conference_twiml(call)
|
||||
conference_sid = ensure_conference_sid!(call)
|
||||
|
||||
Twilio::TwiML::VoiceResponse.new.tap do |response|
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_sid,
|
||||
start_conference_on_enter: agent_leg,
|
||||
start_conference_on_enter: agent_leg?(twilio_from),
|
||||
end_conference_on_exit: false,
|
||||
status_callback: conference_status_callback_url,
|
||||
status_callback_event: 'start end join leave',
|
||||
status_callback_method: 'POST',
|
||||
participant_label: agent_leg ? 'agent' : 'contact'
|
||||
participant_label: participant_label_for(twilio_from)
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(call)
|
||||
return call.conference_sid if call.conference_sid.present?
|
||||
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
def participant_label_for(from_number)
|
||||
return from_number.delete_prefix('client:') if from_number.start_with?('client:')
|
||||
|
||||
'contact'
|
||||
end
|
||||
|
||||
def conference_status_callback_url
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
def find_call_for_conference!(friendly_name, call_sid)
|
||||
name = friendly_name.to_s
|
||||
scope = current_account.conversations
|
||||
|
||||
if name.present?
|
||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
scope.find_by!(identifier: call_sid)
|
||||
call = inbox_calls.by_conference_sid(name).first if name.present?
|
||||
call || inbox_calls.find_by!(provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module Enterprise::MessageFinder
|
||||
def conversation_messages
|
||||
super.includes(call: [:contact, { inbox: :channel }])
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,8 @@ class Call < ApplicationRecord
|
||||
# Statuses where the call is finished and won't change again
|
||||
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
|
||||
|
||||
store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
|
||||
|
||||
enum :provider, { twilio: 0, whatsapp: 1 }
|
||||
enum :direction, { incoming: 0, outgoing: 1 }
|
||||
|
||||
@@ -41,7 +43,7 @@ class Call < ApplicationRecord
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation
|
||||
belongs_to :contact
|
||||
belongs_to :message, optional: true
|
||||
belongs_to :message, optional: true, inverse_of: :call
|
||||
belongs_to :accepted_by_agent, class_name: 'User', optional: true
|
||||
|
||||
has_one_attached :recording
|
||||
@@ -52,4 +54,50 @@ class Call < ApplicationRecord
|
||||
validates :status, presence: true, inclusion: { in: STATUSES }
|
||||
|
||||
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
|
||||
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
|
||||
|
||||
def self.find_by_provider_call_id(provider, sid)
|
||||
find_by(provider: provider, provider_call_id: sid)
|
||||
end
|
||||
|
||||
def default_conference_sid
|
||||
"conf_account_#{account_id}_call_#{id}"
|
||||
end
|
||||
|
||||
def display_status
|
||||
status.to_s.tr('_', '-')
|
||||
end
|
||||
|
||||
def from_number
|
||||
incoming? ? contact.phone_number : inbox.channel&.phone_number
|
||||
end
|
||||
|
||||
def to_number
|
||||
incoming? ? inbox.channel&.phone_number : contact.phone_number
|
||||
end
|
||||
|
||||
def recording_url
|
||||
return nil unless recording.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_blob_url(recording)
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
provider_call_id: provider_call_id,
|
||||
provider: provider,
|
||||
direction: direction,
|
||||
status: display_status,
|
||||
duration_seconds: duration_seconds,
|
||||
conference_sid: conference_sid,
|
||||
accepted_by_agent_id: accepted_by_agent_id,
|
||||
started_at: started_at&.to_i,
|
||||
ended_at: ended_at,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
recording_url: recording_url,
|
||||
transcript: transcript
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,17 +25,6 @@ module Enterprise::Conversation
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
|
||||
attrs_change = previous_changes['additional_attributes']
|
||||
return false unless attrs_change.is_a?(Array) && attrs_change[1].is_a?(Hash)
|
||||
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
module Enterprise::Message
|
||||
def self.prepended(base)
|
||||
base.class_eval do
|
||||
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
|
||||
|
||||
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
|
||||
end
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
data = super
|
||||
data[:call] = call.push_event_data if content_type == 'voice_call' && call.present?
|
||||
data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_pending_conversation_as_open_for_human_response
|
||||
|
||||
@@ -1,90 +1,26 @@
|
||||
class Voice::CallMessageBuilder
|
||||
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
|
||||
new(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: payload,
|
||||
user: user,
|
||||
timestamps: timestamps
|
||||
).perform!
|
||||
end
|
||||
|
||||
def initialize(conversation:, direction:, payload:, user:, timestamps:)
|
||||
@conversation = conversation
|
||||
@direction = direction
|
||||
@payload = payload
|
||||
@user = user
|
||||
@timestamps = timestamps
|
||||
def initialize(call)
|
||||
@call = call
|
||||
end
|
||||
|
||||
def perform!
|
||||
validate_sender!
|
||||
message = latest_message
|
||||
message ? update_message!(message) : create_message!
|
||||
call.message || create_message!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||
|
||||
def latest_message
|
||||
conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
end
|
||||
|
||||
def update_message!(message)
|
||||
message.update!(
|
||||
message_type: message_type,
|
||||
content_attributes: { 'data' => base_payload },
|
||||
sender: sender
|
||||
)
|
||||
end
|
||||
attr_reader :call
|
||||
|
||||
def create_message!
|
||||
params = {
|
||||
content: 'Voice Call',
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => base_payload }
|
||||
message_type: call.outgoing? ? 'outgoing' : 'incoming',
|
||||
content_type: 'voice_call'
|
||||
}
|
||||
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||
end
|
||||
|
||||
def base_payload
|
||||
@base_payload ||= begin
|
||||
data = payload.slice(
|
||||
:call_sid,
|
||||
:status,
|
||||
:call_direction,
|
||||
:conference_sid,
|
||||
:from_number,
|
||||
:to_number
|
||||
).stringify_keys
|
||||
data['call_direction'] = direction
|
||||
data['meta'] = {
|
||||
'created_at' => timestamps[:created_at] || current_timestamp,
|
||||
'ringing_at' => timestamps[:ringing_at] || current_timestamp
|
||||
}.compact
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
def message_type
|
||||
direction == 'outbound' ? 'outgoing' : 'incoming'
|
||||
Messages::MessageBuilder.new(sender, call.conversation, params).perform
|
||||
end
|
||||
|
||||
def sender
|
||||
return user if direction == 'outbound'
|
||||
|
||||
conversation.contact
|
||||
end
|
||||
|
||||
def validate_sender!
|
||||
return unless direction == 'outbound'
|
||||
|
||||
raise ArgumentError, 'Agent sender required for outbound calls' unless user
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= Time.zone.now.to_i
|
||||
call.outgoing? ? call.accepted_by_agent : call.contact
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
class Voice::CallSessionSyncService
|
||||
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
|
||||
|
||||
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
|
||||
@conversation = conversation
|
||||
@call_sid = call_sid
|
||||
@message_call_sid = message_call_sid || call_sid
|
||||
@from_number = leg[:from_number]
|
||||
@to_number = leg[:to_number]
|
||||
@direction = leg[:direction]
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
attrs = refreshed_attributes
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
sync_voice_call_message!(attrs)
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refreshed_attributes
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_direction'] = direction
|
||||
attrs['call_status'] ||= 'ringing'
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
attrs['meta'] ||= {}
|
||||
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||
attrs
|
||||
end
|
||||
|
||||
def sync_voice_call_message!(attrs)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: {
|
||||
call_sid: message_call_sid,
|
||||
status: attrs['call_status'],
|
||||
conference_sid: attrs['conference_sid'],
|
||||
from_number: origin_number_for(direction),
|
||||
to_number: target_number_for(direction)
|
||||
},
|
||||
user: agent_for(attrs),
|
||||
timestamps: {
|
||||
created_at: attrs.dig('meta', 'initiated_at'),
|
||||
ringing_at: attrs.dig('meta', 'ringing_at')
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def origin_number_for(current_direction)
|
||||
return outbound_origin if current_direction == 'outbound'
|
||||
|
||||
from_number.presence || inbox_number
|
||||
end
|
||||
|
||||
def target_number_for(current_direction)
|
||||
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
|
||||
|
||||
to_number || conversation.contact&.phone_number
|
||||
end
|
||||
|
||||
def agent_for(attrs)
|
||||
agent_id = attrs['agent_id']
|
||||
return nil unless agent_id
|
||||
|
||||
agent = conversation.account.users.find_by(id: agent_id)
|
||||
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
|
||||
|
||||
agent
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
|
||||
def outbound_origin
|
||||
inbox_number || from_number
|
||||
end
|
||||
|
||||
def inbox_number
|
||||
conversation.inbox&.channel&.phone_number
|
||||
end
|
||||
end
|
||||
@@ -1,66 +1,44 @@
|
||||
class Voice::CallStatus::Manager
|
||||
pattr_initialize [:conversation!, :call_sid]
|
||||
|
||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
||||
pattr_initialize [:call!]
|
||||
|
||||
def process_status_update(status, duration: nil, timestamp: nil)
|
||||
return unless ALLOWED_STATUSES.include?(status)
|
||||
return unless Call::STATUSES.include?(status)
|
||||
return if call.status == status
|
||||
|
||||
current_status = conversation.additional_attributes&.dig('call_status')
|
||||
return if current_status == status
|
||||
|
||||
apply_status(status, duration: duration, timestamp: timestamp)
|
||||
update_message(status)
|
||||
apply_call_updates!(status, duration: duration, timestamp: timestamp)
|
||||
call.conversation.update!(last_activity_at: Time.zone.now)
|
||||
# Bump updated_at so the message.updated dispatcher rebroadcasts with the fresh Call embedded.
|
||||
call.message&.touch # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_status(status, duration:, timestamp:)
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_status'] = status
|
||||
def apply_call_updates!(status, duration:, timestamp:)
|
||||
attrs = { status: status }
|
||||
ts = timestamp || now_seconds
|
||||
|
||||
if status == 'in-progress'
|
||||
attrs['call_started_at'] ||= timestamp || now_seconds
|
||||
elsif TERMINAL_STATUSES.include?(status)
|
||||
attrs['call_ended_at'] = timestamp || now_seconds
|
||||
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
|
||||
if status == 'in_progress'
|
||||
# Twilio can emit multiple in-progress updates (answered + in-progress, retries).
|
||||
# Keep the earliest timestamp so duration_seconds doesn't shift forward.
|
||||
started_at = Time.zone.at(ts)
|
||||
attrs[:started_at] = started_at if call.started_at.nil? || started_at < call.started_at
|
||||
elsif Call::TERMINAL_STATUSES.include?(status)
|
||||
call.ended_at = ts
|
||||
attrs[:meta] = call.meta
|
||||
attrs[:duration_seconds] = resolved_duration(duration, ts)
|
||||
end
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
call.update!(attrs)
|
||||
end
|
||||
|
||||
def resolved_duration(attrs, provided_duration, timestamp)
|
||||
def resolved_duration(provided_duration, timestamp)
|
||||
return provided_duration if provided_duration
|
||||
return unless call.started_at
|
||||
|
||||
started_at = attrs['call_started_at']
|
||||
return unless started_at && timestamp
|
||||
|
||||
[timestamp - started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def update_message(status)
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = status
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
[timestamp - call.started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def now_seconds
|
||||
current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
class Voice::Conference::Manager
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
||||
pattr_initialize [:call!, :event!, :participant_label]
|
||||
|
||||
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
|
||||
|
||||
def process
|
||||
case event
|
||||
when 'start'
|
||||
ensure_conference_sid!
|
||||
mark_ringing!
|
||||
when 'join'
|
||||
mark_in_progress! if agent_participant?
|
||||
join_agent! if agent_participant?
|
||||
when 'leave'
|
||||
handle_leave!
|
||||
when 'end'
|
||||
finalize_conference!
|
||||
finalize!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_manager
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_conference_sid!
|
||||
attrs = conversation.additional_attributes || {}
|
||||
return if attrs['conference_sid'].present?
|
||||
|
||||
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
|
||||
end
|
||||
|
||||
def mark_ringing!
|
||||
return if current_status
|
||||
# Guard against delayed conference-start retries rolling a progressed call back to ringing.
|
||||
return unless call.status == 'ringing'
|
||||
|
||||
status_manager.process_status_update('ringing')
|
||||
end
|
||||
|
||||
def mark_in_progress!
|
||||
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
|
||||
def join_agent!
|
||||
user_id = extract_user_id
|
||||
call.update!(accepted_by_agent_id: user_id) if user_id
|
||||
status_manager.process_status_update('in_progress', timestamp: now)
|
||||
end
|
||||
|
||||
# Parses agent user_id from participant_label. Only returns an id when the
|
||||
# label's embedded account id matches the call's account — protects against
|
||||
# a spoofed/cross-account label attaching a foreign user to the call.
|
||||
def extract_user_id
|
||||
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
|
||||
return unless match
|
||||
return unless match[2].to_i == call.account_id
|
||||
|
||||
match[1].to_i
|
||||
end
|
||||
|
||||
def handle_leave!
|
||||
case current_status
|
||||
case call.status
|
||||
when 'ringing'
|
||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
||||
when 'in-progress'
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
status_manager.process_status_update('no_answer', timestamp: now)
|
||||
when 'in_progress'
|
||||
status_manager.process_status_update('completed', timestamp: now)
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_conference!
|
||||
return if %w[completed no-answer failed].include?(current_status)
|
||||
def finalize!
|
||||
return if Call::TERMINAL_STATUSES.include?(call.status)
|
||||
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
end
|
||||
|
||||
def current_status
|
||||
conversation.additional_attributes&.dig('call_status')
|
||||
status_manager.process_status_update('completed', timestamp: now)
|
||||
end
|
||||
|
||||
def agent_participant?
|
||||
participant_label.to_s.start_with?('agent')
|
||||
participant_label.to_s.start_with?('agent-')
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
def now
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module Voice::Conference::Name
|
||||
def self.for(conversation)
|
||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
end
|
||||
@@ -13,21 +13,30 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
|
||||
def perform!
|
||||
timestamp = current_timestamp
|
||||
existing = find_existing_call
|
||||
return existing if existing
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = ensure_contact!
|
||||
contact_inbox = ensure_contact_inbox!(contact)
|
||||
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
||||
conversation.reload
|
||||
update_conversation!(conversation, timestamp)
|
||||
build_voice_message!(conversation, timestamp)
|
||||
conversation
|
||||
conversation = resolve_conversation!(contact, contact_inbox)
|
||||
call = create_call!(contact, conversation)
|
||||
message = Voice::CallMessageBuilder.new(call).perform!
|
||||
call.update!(message_id: message.id)
|
||||
call
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# A concurrent Twilio retry won the create race; return what now exists.
|
||||
find_existing_call || raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_existing_call
|
||||
Call.where(account_id: account.id, inbox_id: inbox.id)
|
||||
.find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
end
|
||||
|
||||
def ensure_contact!
|
||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||
record.name = from_number if record.name.blank?
|
||||
@@ -43,57 +52,37 @@ class Voice::InboundCallBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation
|
||||
return if call_sid.blank?
|
||||
def resolve_conversation!(contact, contact_inbox)
|
||||
if inbox.lock_to_single_conversation
|
||||
reusable = account.conversations
|
||||
.where(contact_id: contact.id, inbox_id: inbox.id)
|
||||
.where.not(status: :resolved)
|
||||
.order(last_activity_at: :desc)
|
||||
.first
|
||||
return reusable if reusable
|
||||
end
|
||||
|
||||
account.conversations.includes(:contact).find_by(identifier: call_sid)
|
||||
end
|
||||
|
||||
def create_conversation!(contact, contact_inbox)
|
||||
account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: :open,
|
||||
identifier: call_sid
|
||||
status: :open
|
||||
)
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing',
|
||||
'conference_sid' => Voice::Conference::Name.for(conversation),
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
def create_call!(contact, conversation)
|
||||
call = Call.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
direction: 'inbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
||||
from_number: from_number,
|
||||
to_number: inbox.channel&.phone_number
|
||||
},
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
contact: contact,
|
||||
provider: :twilio,
|
||||
direction: :incoming,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,17 +16,14 @@ class Voice::OutboundCallBuilder
|
||||
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
||||
raise ArgumentError, 'Agent required' if user.blank?
|
||||
|
||||
timestamp = current_timestamp
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
conversation = create_conversation!(contact_inbox)
|
||||
conversation.reload
|
||||
conference_sid = Voice::Conference::Name.for(conversation)
|
||||
call_sid = initiate_call!
|
||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
{ conversation: conversation, call_sid: call_sid }
|
||||
call = create_call!(conversation, call_sid)
|
||||
message = Voice::CallMessageBuilder.new(call).perform!
|
||||
call.update!(message_id: message.id)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,48 +48,23 @@ class Voice::OutboundCallBuilder
|
||||
end
|
||||
|
||||
def initiate_call!
|
||||
inbox.channel.initiate_call(
|
||||
to: contact.phone_number
|
||||
)[:call_sid]
|
||||
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'outbound',
|
||||
'call_status' => 'ringing',
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => conference_sid,
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
def create_call!(conversation, call_sid)
|
||||
call = Call.create!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
direction: 'outbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conference_sid,
|
||||
from_number: inbox.channel&.phone_number,
|
||||
to_number: contact.phone_number
|
||||
},
|
||||
user: user,
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
contact: contact,
|
||||
accepted_by_agent: user,
|
||||
provider: :twilio,
|
||||
direction: :outgoing,
|
||||
status: 'ringing',
|
||||
provider_call_id: call_sid,
|
||||
meta: { 'initiated_at' => Time.zone.now.to_i }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
class Voice::Provider::Twilio::ConferenceService
|
||||
pattr_initialize [:conversation!]
|
||||
pattr_initialize [:call!]
|
||||
|
||||
def ensure_conference_sid
|
||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
||||
return existing if existing.present?
|
||||
return call.conference_sid if call.conference_sid.present?
|
||||
|
||||
sid = Voice::Conference::Name.for(conversation)
|
||||
merge_attributes('conference_sid' => sid)
|
||||
sid
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
call.conference_sid
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
merge_attributes(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => { id: user.id, name: user.name }
|
||||
)
|
||||
call.update!(accepted_by_agent: user)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
client = conversation.inbox.channel.client
|
||||
return if call.conference_sid.blank?
|
||||
|
||||
client = call.inbox.channel.client
|
||||
client
|
||||
.conferences
|
||||
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
|
||||
.list(friendly_name: call.conference_sid, status: 'in-progress')
|
||||
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_attributes(attrs)
|
||||
current = conversation.additional_attributes || {}
|
||||
conversation.update!(additional_attributes: current.merge(attrs))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,12 +5,12 @@ class Voice::StatusUpdateService
|
||||
'queued' => 'ringing',
|
||||
'initiated' => 'ringing',
|
||||
'ringing' => 'ringing',
|
||||
'in-progress' => 'in-progress',
|
||||
'inprogress' => 'in-progress',
|
||||
'answered' => 'in-progress',
|
||||
'in-progress' => 'in_progress',
|
||||
'inprogress' => 'in_progress',
|
||||
'answered' => 'in_progress',
|
||||
'completed' => 'completed',
|
||||
'busy' => 'no-answer',
|
||||
'no-answer' => 'no-answer',
|
||||
'busy' => 'no_answer',
|
||||
'no-answer' => 'no_answer',
|
||||
'failed' => 'failed',
|
||||
'canceled' => 'failed'
|
||||
}.freeze
|
||||
@@ -19,13 +19,10 @@ class Voice::StatusUpdateService
|
||||
normalized_status = normalize_status(call_status)
|
||||
return if normalized_status.blank?
|
||||
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
call = Call.where(account_id: account.id).find_by(provider: :twilio, provider_call_id: call_sid)
|
||||
return unless call
|
||||
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
).process_status_update(
|
||||
Voice::CallStatus::Manager.new(call: call).process_status_update(
|
||||
normalized_status,
|
||||
duration: payload_duration,
|
||||
timestamp: payload_timestamp
|
||||
|
||||
@@ -4,7 +4,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:voice_inbox) { voice_channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
|
||||
let(:admin) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
before do
|
||||
create(:inbox_member, inbox: voice_inbox, user: agent)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: voice_inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: 'CALL123'
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates conference and sets identifier' do
|
||||
it 'resolves the Call by call_sid and invokes the conference service' do
|
||||
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)
|
||||
body = response.parsed_body
|
||||
expect(body['conference_sid']).to be_present
|
||||
conversation.reload
|
||||
expect(conversation.identifier).to eq('CALL123')
|
||||
expect(body['conference_sid']).to eq('CF123')
|
||||
expect(body['id']).to eq(conversation.display_id)
|
||||
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||
expect(conference_service).to have_received(:mark_agent_joined)
|
||||
end
|
||||
|
||||
it 'does not allow accessing conversations from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
other_conversation.reload
|
||||
expect(other_conversation.identifier).to be_nil
|
||||
end
|
||||
|
||||
it 'returns conflict when call_sid missing' do
|
||||
it 'rejects the request when call_sid is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(conference_service).not_to have_received(:ensure_conference_sid)
|
||||
end
|
||||
|
||||
it 'does not allow accessing calls from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: other_inbox,
|
||||
conversation: other_conversation,
|
||||
contact: other_conversation.contact,
|
||||
provider_call_id: 'OTHER123'
|
||||
)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
before do
|
||||
create(:inbox_member, inbox: voice_inbox, user: agent)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: voice_inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: 'CALL123'
|
||||
)
|
||||
end
|
||||
|
||||
it 'ends conference and returns success' do
|
||||
it 'ends the conference for the resolved call' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id }
|
||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
expect(conference_service).to have_received(:end_conference)
|
||||
end
|
||||
|
||||
it 'does not allow ending conferences for conversations from inboxes without access' do
|
||||
it 'does not allow ending conferences for calls from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: other_inbox,
|
||||
conversation: other_conversation,
|
||||
contact: other_conversation.contact,
|
||||
provider_call_id: 'OTHER123'
|
||||
)
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id }
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
@@ -19,15 +19,24 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
let(:to_number) { channel.phone_number }
|
||||
|
||||
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||
instance_double(Voice::InboundCallBuilder)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
contact = conversation.contact
|
||||
call = create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
).and_return(conversation)
|
||||
).and_return(call)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
@@ -39,64 +48,59 @@ 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(call.conference_sid)
|
||||
end
|
||||
|
||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
call = create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
direction: :outgoing,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'From' => to_number,
|
||||
'To' => from_number,
|
||||
'Direction' => 'outbound-api'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include(call.conference_sid)
|
||||
expect(call.reload.parent_call_sid).to be_nil
|
||||
end
|
||||
|
||||
it 'uses the parent call SID when syncing outbound-dial legs' do
|
||||
it 'records the parent call SID when syncing outbound-dial legs' do
|
||||
parent_sid = 'CA_parent'
|
||||
child_sid = 'CA_child'
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: child_sid,
|
||||
message_call_sid: parent_sid,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
call = create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
direction: :outgoing,
|
||||
provider_call_id: parent_sid
|
||||
)
|
||||
call.update!(conference_sid: call.default_conference_sid)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => child_sid,
|
||||
'ParentCallSid' => parent_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'From' => to_number,
|
||||
'To' => from_number,
|
||||
'Direction' => 'outbound-dial'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.parent_call_sid).to eq(parent_sid)
|
||||
end
|
||||
|
||||
it 'raises not found when inbox is not present' do
|
||||
|
||||
@@ -7,7 +7,6 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550001111' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
let(:call_sid) { 'CA1234567890abcdef' }
|
||||
|
||||
before do
|
||||
@@ -24,98 +23,88 @@ RSpec.describe Voice::InboundCallBuilder do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when no existing conversation matches call_sid' do
|
||||
it 'creates a new inbound conversation with ringing status' do
|
||||
conversation = nil
|
||||
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
|
||||
context 'when no existing call matches call_sid' do
|
||||
it 'creates a new conversation and Call with ringing status' do
|
||||
call = nil
|
||||
expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
|
||||
|
||||
attrs = conversation.additional_attributes
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs['call_direction']).to eq('inbound')
|
||||
expect(attrs['call_status']).to eq('ringing')
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
expect(attrs.dig('meta', 'initiated_at')).to be_present
|
||||
expect(conversation.contact.phone_number).to eq(from_number)
|
||||
aggregate_failures do
|
||||
expect(call).to be_a(Call)
|
||||
expect(call.provider_call_id).to eq(call_sid)
|
||||
expect(call.provider).to eq('twilio')
|
||||
expect(call.direction).to eq('incoming')
|
||||
expect(call.status).to eq('ringing')
|
||||
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
expect(call.meta['initiated_at']).to be_present
|
||||
expect(call.contact.phone_number).to eq(from_number)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a single voice_call message marked as incoming' do
|
||||
conversation = perform_builder
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
it 'creates a voice_call message matched to the call and linked via message_id' do
|
||||
call = perform_builder
|
||||
voice_message = call.conversation.messages.voice_calls.last
|
||||
|
||||
expect(voice_message).to be_present
|
||||
expect(voice_message.message_type).to eq('incoming')
|
||||
data = voice_message.content_attributes['data']
|
||||
expect(data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'status' => 'ringing',
|
||||
'call_direction' => 'inbound',
|
||||
'conference_sid' => conversation.additional_attributes['conference_sid'],
|
||||
'from_number' => from_number,
|
||||
'to_number' => inbox.channel.phone_number
|
||||
)
|
||||
expect(data['meta']['created_at']).to be_present
|
||||
expect(data['meta']['ringing_at']).to be_present
|
||||
aggregate_failures do
|
||||
expect(voice_message).to be_present
|
||||
expect(voice_message.message_type).to eq('incoming')
|
||||
expect(call.message_id).to eq(voice_message.id)
|
||||
expect(voice_message.call).to eq(call)
|
||||
end
|
||||
end
|
||||
|
||||
it 'sets the contact name to the phone number for new callers' do
|
||||
conversation = perform_builder
|
||||
call = perform_builder
|
||||
|
||||
expect(conversation.contact.name).to eq(from_number)
|
||||
expect(call.contact.name).to eq(from_number)
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
it 'does not set conversation.identifier or write call state to additional_attributes' do
|
||||
call = perform_builder
|
||||
conversation = call.conversation
|
||||
|
||||
perform_builder
|
||||
expect(conversation.identifier).to be_nil
|
||||
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a conversation already exists for the call_sid' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
context 'when a Call already exists for the call_sid' do
|
||||
let(:existing_call) do
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the existing call without creating a duplicate' do
|
||||
existing_call
|
||||
expect { perform_builder }.not_to change(Call, :count)
|
||||
expect(perform_builder).to eq(existing_call)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox has lock_to_single_conversation enabled' do
|
||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let!(:existing_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
|
||||
)
|
||||
end
|
||||
let(:existing_message) do
|
||||
create(
|
||||
:message,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: existing_conversation,
|
||||
message_type: :incoming,
|
||||
content_type: :voice_call,
|
||||
sender: contact,
|
||||
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
|
||||
)
|
||||
let!(:existing_open_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
|
||||
end
|
||||
|
||||
it 'reuses the conversation without creating a duplicate' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to change(account.conversations, :count)
|
||||
existing_conversation.reload
|
||||
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
|
||||
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
|
||||
before { inbox.update!(lock_to_single_conversation: true) }
|
||||
|
||||
it 'reuses the most recent non-resolved conversation' do
|
||||
call = nil
|
||||
expect { call = perform_builder }.not_to change(account.conversations, :count)
|
||||
expect(call.conversation).to eq(existing_open_conversation)
|
||||
end
|
||||
|
||||
it 'updates the existing voice call message instead of creating a new one' do
|
||||
existing_message
|
||||
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
|
||||
updated_message = existing_conversation.reload.messages.voice_calls.last
|
||||
|
||||
data = updated_message.content_attributes['data']
|
||||
expect(data['status']).to eq('ringing')
|
||||
expect(data['call_direction']).to eq('inbound')
|
||||
it 'still creates a new Call and voice_call message on the reused conversation' do
|
||||
expect { perform_builder }.to change(Call, :count).by(1)
|
||||
.and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,45 +15,45 @@ RSpec.describe Voice::OutboundCallBuilder do
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
|
||||
allow(inbox).to receive(:channel).and_return(channel)
|
||||
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
|
||||
allow(Voice::Conference::Name).to receive(:for).and_call_original
|
||||
end
|
||||
|
||||
describe '.perform!' do
|
||||
it 'creates a conversation and voice call message' do
|
||||
conversation_count = account.conversations.count
|
||||
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
|
||||
it 'creates a conversation, Call, and voice_call message' do
|
||||
call = nil
|
||||
expect do
|
||||
call = described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
|
||||
|
||||
result = described_class.perform!(
|
||||
aggregate_failures do
|
||||
expect(call).to be_a(Call)
|
||||
expect(call.provider_call_id).to eq(call_sid)
|
||||
expect(call.direction).to eq('outgoing')
|
||||
expect(call.status).to eq('ringing')
|
||||
expect(call.accepted_by_agent_id).to eq(user.id)
|
||||
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
|
||||
voice_message = call.conversation.messages.voice_calls.last
|
||||
expect(call.message_id).to eq(voice_message.id)
|
||||
expect(voice_message.message_type).to eq('outgoing')
|
||||
expect(voice_message.call).to eq(call)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not set conversation.identifier or write call state to additional_attributes' do
|
||||
call = described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
expect(account.conversations.count).to eq(conversation_count + 1)
|
||||
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
|
||||
|
||||
conversation = result[:conversation].reload
|
||||
attrs = conversation.additional_attributes
|
||||
|
||||
aggregate_failures do
|
||||
expect(result[:call_sid]).to eq(call_sid)
|
||||
expect(conversation.identifier).to eq(call_sid)
|
||||
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
|
||||
expect(attrs['agent_id']).to eq(user.id)
|
||||
expect(attrs['conference_sid']).to be_present
|
||||
|
||||
voice_message = conversation.messages.voice_calls.last
|
||||
expect(voice_message.message_type).to eq('outgoing')
|
||||
|
||||
message_data = voice_message.content_attributes['data']
|
||||
expect(message_data).to include(
|
||||
'call_sid' => call_sid,
|
||||
'conference_sid' => attrs['conference_sid'],
|
||||
'from_number' => channel.phone_number,
|
||||
'to_number' => contact.phone_number
|
||||
)
|
||||
end
|
||||
expect(call.conversation.identifier).to be_nil
|
||||
expect(call.conversation.additional_attributes).not_to include('call_status', 'call_direction', 'agent_id', 'conference_sid')
|
||||
end
|
||||
|
||||
it 'raises an error when contact is missing a phone number' do
|
||||
@@ -79,19 +79,5 @@ RSpec.describe Voice::OutboundCallBuilder do
|
||||
)
|
||||
end.to raise_error(ArgumentError, 'Agent required')
|
||||
end
|
||||
|
||||
it 'ensures the conversation has a display_id before building the conference SID' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
|
||||
expect(conversation.display_id).to be_present
|
||||
original.call(conversation)
|
||||
end
|
||||
|
||||
described_class.perform!(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
user: user,
|
||||
contact: contact
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,17 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
|
||||
let(:call) do
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: channel.inbox,
|
||||
conversation: conversation,
|
||||
contact: conversation.contact
|
||||
)
|
||||
end
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
let(:service) { described_class.new(call: call) }
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
|
||||
before do
|
||||
@@ -14,42 +23,37 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
end
|
||||
|
||||
describe '#ensure_conference_sid' do
|
||||
it 'returns existing sid if present' do
|
||||
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
|
||||
it 'returns existing sid if present on the Call' do
|
||||
call.update!(conference_sid: 'CF_EXISTING')
|
||||
|
||||
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
|
||||
end
|
||||
|
||||
it 'sets and returns generated sid when missing' do
|
||||
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
|
||||
|
||||
sid = service.ensure_conference_sid
|
||||
|
||||
expect(sid).to eq('CF_GEN')
|
||||
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
|
||||
expect(service.ensure_conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
expect(call.reload.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
|
||||
end
|
||||
end
|
||||
|
||||
describe '#mark_agent_joined' do
|
||||
it 'stores agent join metadata' do
|
||||
it 'sets accepted_by_agent on the Call' do
|
||||
agent = create(:user, account: account)
|
||||
|
||||
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(call.reload.accepted_by_agent_id).to eq(agent.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#end_conference' do
|
||||
it 'completes in-progress conferences' do
|
||||
it 'completes in-progress conferences matching the call conference_sid' do
|
||||
call.update!(conference_sid: 'CF123_FRIENDLY')
|
||||
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
|
||||
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
|
||||
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
|
||||
|
||||
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
|
||||
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
|
||||
allow(conferences_proxy).to receive(:list).with(friendly_name: 'CF123_FRIENDLY', status: 'in-progress').and_return([conf_instance])
|
||||
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
|
||||
allow(conf_context).to receive(:update).with(status: 'completed')
|
||||
|
||||
@@ -57,5 +61,11 @@ describe Voice::Provider::Twilio::ConferenceService do
|
||||
|
||||
expect(conf_context).to have_received(:update).with(status: 'completed')
|
||||
end
|
||||
|
||||
it 'no-ops when call has no conference_sid' do
|
||||
allow(twilio_client).to receive(:conferences)
|
||||
service.end_conference
|
||||
expect(twilio_client).not_to have_received(:conferences)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,43 +4,47 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Voice::StatusUpdateService do
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550002222' }
|
||||
let(:call_sid) { 'CATESTSTATUS123' }
|
||||
let(:contact) { create(:contact, account: account, phone_number: from_number) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
|
||||
let(:conversation) do
|
||||
Conversation.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
identifier: call_sid,
|
||||
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
||||
end
|
||||
let!(:call) do
|
||||
create(
|
||||
:call,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
conversation: conversation,
|
||||
contact: contact,
|
||||
provider_call_id: call_sid
|
||||
)
|
||||
end
|
||||
let(:message) do
|
||||
conversation.messages.create!(
|
||||
let!(:message) do
|
||||
msg = conversation.messages.create!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: contact,
|
||||
content: 'Voice Call',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
|
||||
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'ringing' } }
|
||||
)
|
||||
call.update!(message_id: msg.id)
|
||||
msg
|
||||
end
|
||||
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:from_number) { '+15550002222' }
|
||||
let(:call_sid) { 'CATESTSTATUS123' }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
end
|
||||
|
||||
it 'updates conversation and last voice message with call status' do
|
||||
# Ensure records are created after stub setup
|
||||
conversation
|
||||
message
|
||||
it 'updates the Call and touches the linked message on status transition' do
|
||||
previous_updated_at = message.updated_at
|
||||
travel 1.second
|
||||
|
||||
described_class.new(
|
||||
account: account,
|
||||
@@ -48,31 +52,24 @@ RSpec.describe Voice::StatusUpdateService do
|
||||
call_status: 'completed'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
call.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('completed')
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
|
||||
expect(call.status).to eq('completed')
|
||||
expect(message.updated_at).to be > previous_updated_at
|
||||
end
|
||||
|
||||
it 'normalizes busy to no-answer' do
|
||||
conversation
|
||||
message
|
||||
|
||||
it 'normalizes busy to no_answer on the Call' do
|
||||
described_class.new(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'busy'
|
||||
).perform
|
||||
|
||||
conversation.reload
|
||||
message.reload
|
||||
|
||||
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
|
||||
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
|
||||
expect(call.reload.status).to eq('no_answer')
|
||||
end
|
||||
|
||||
it 'no-ops when conversation not found' do
|
||||
it 'no-ops when no Call matches the provided call_sid' do
|
||||
expect do
|
||||
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
|
||||
end.not_to raise_error
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FactoryBot.define do
|
||||
factory :call do
|
||||
association :conversation
|
||||
account { conversation.account }
|
||||
inbox { conversation.inbox }
|
||||
contact { conversation.contact }
|
||||
provider { :twilio }
|
||||
direction { :incoming }
|
||||
status { 'ringing' }
|
||||
sequence(:provider_call_id) { |n| "CA#{SecureRandom.hex(15)}#{n}" }
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user