From 1124c1b4c288a62b0c4e6bb04569b720b1fc8d34 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 30 Apr 2026 11:25:39 +0400 Subject: [PATCH] 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> --- app/finders/message_finder.rb | 2 + .../api/channel/voice/twilioVoiceClient.js | 3 +- .../api/channel/voice/voiceAPIClient.js | 4 +- .../ConversationCardExpanded.vue | 14 +- .../components-next/message/Message.vue | 1 + .../message/bubbles/VoiceCall.vue | 10 +- .../components/widgets/FloatingCallWidget.vue | 1 + .../widgets/conversation/ConversationCard.vue | 14 +- .../dashboard/composables/useCallSession.js | 5 +- app/javascript/dashboard/helper/voice.js | 16 +- .../store/modules/conversations/index.js | 29 +--- .../conversations/specs/mutations.spec.js | 121 ++++++---------- .../dashboard/store/mutation-types.js | 1 - .../api/v1/models/_message.json.jbuilder | 2 + .../api/v1/accounts/conference_controller.rb | 25 ++-- .../v1/accounts/contacts/calls_controller.rb | 10 +- .../controllers/twilio/voice_controller.rb | 101 ++++++------- .../app/finders/enterprise/message_finder.rb | 5 + enterprise/app/models/call.rb | 50 ++++++- .../app/models/enterprise/conversation.rb | 11 -- enterprise/app/models/enterprise/message.rb | 14 ++ .../services/voice/call_message_builder.rb | 80 +--------- .../voice/call_session_sync_service.rb | 94 ------------ .../app/services/voice/call_status/manager.rb | 70 +++------ .../app/services/voice/conference/manager.rb | 64 ++++---- .../app/services/voice/conference/name.rb | 5 - .../services/voice/inbound_call_builder.rb | 85 +++++------ .../services/voice/outbound_call_builder.rb | 64 +++----- .../provider/twilio/conference_service.rb | 29 ++-- .../services/voice/status_update_service.rb | 19 +-- .../v1/accounts/conference_controller_spec.rb | 86 +++++++---- .../twilio/voice_controller_spec.rb | 82 ++++++----- .../voice/inbound_call_builder_spec.rb | 137 ++++++++---------- .../voice/outbound_call_builder_spec.rb | 72 ++++----- .../twilio/conference_service_spec.rb | 40 +++-- .../voice/status_update_service_spec.rb | 63 ++++---- spec/factories/calls.rb | 12 ++ 37 files changed, 630 insertions(+), 811 deletions(-) create mode 100644 enterprise/app/finders/enterprise/message_finder.rb delete mode 100644 enterprise/app/services/voice/call_session_sync_service.rb delete mode 100644 enterprise/app/services/voice/conference/name.rb create mode 100644 spec/factories/calls.rb diff --git a/app/finders/message_finder.rb b/app/finders/message_finder.rb index 8854e239a..bf82d61d5 100644 --- a/app/finders/message_finder.rb +++ b/app/finders/message_finder.rb @@ -48,3 +48,5 @@ class MessageFinder messages.reorder('created_at desc').limit(20).reverse end end + +MessageFinder.prepend_mod_with('MessageFinder') diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js index 67f74a171..13f61a16c 100644 --- a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js +++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js @@ -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 }); diff --git a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js index 6e1e548c8..41ff7007c 100644 --- a/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js +++ b/app/javascript/dashboard/api/channel/voice/voiceAPIClient.js @@ -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); } diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue index 5a6324902..d0f8f0211 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue @@ -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); diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 3de9d2d04..d738bfcc6 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -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: { diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 5a7d39a4e..a270882ac 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -1,7 +1,7 @@