diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 28776a8d9..586a4b4cb 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -36,6 +36,7 @@ import DyteBubble from './bubbles/Dyte.vue'; import LocationBubble from './bubbles/Location.vue'; import CSATBubble from './bubbles/CSAT.vue'; import FormBubble from './bubbles/Form.vue'; +import VoiceCallBubble from './bubbles/VoiceCall.vue'; import MessageError from './MessageError.vue'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue'; @@ -280,6 +281,10 @@ const componentToRender = computed(() => { return FormBubble; } + if (props.contentType === CONTENT_TYPES.VOICE_CALL) { + return VoiceCallBubble; + } + if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) { return EmailBubble; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index aaf0cb1ba..f66f272de 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -10,6 +10,10 @@ import { useI18n } from 'vue-i18n'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +const props = defineProps({ + hideMeta: { type: Boolean, default: false }, +}); + const { variant, orientation, inReplyTo, shouldGroupWithNext } = useMessageContext(); const { t } = useI18n(); @@ -64,6 +68,13 @@ const scrollToMessage = () => { }); }; +const shouldShowMeta = computed( + () => + !props.hideMeta && + !shouldGroupWithNext.value && + variant.value !== MESSAGE_VARIANTS.ACTIVITY +); + const replyToPreview = computed(() => { if (!inReplyTo) return ''; @@ -93,16 +104,16 @@ const replyToPreview = computed(() => { >
- + {{ replyToPreview }}
+
+
+
+ +
+ +
+ {{ $t(labelKey) }} + {{ $t(subtextKey) }} +
+
+
+ + diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js index 71257f66a..77ea25533 100644 --- a/app/javascript/dashboard/components-next/message/constants.js +++ b/app/javascript/dashboard/components-next/message/constants.js @@ -64,6 +64,7 @@ export const CONTENT_TYPES = { INPUT_CSAT: 'input_csat', INTEGRATIONS: 'integrations', STICKER: 'sticker', + VOICE_CALL: 'voice_call', }; export const MEDIA_TYPES = [ diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 57958ec0f..5cc59f8c8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -3,6 +3,7 @@ import { computed, ref } from 'vue'; import { useRouter } from 'vue-router'; import { useStore, useMapGetter } from 'dashboard/composables/store'; import { getLastMessage } from 'dashboard/helper/conversationHelper'; +import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import Avatar from 'next/avatar/Avatar.vue'; import MessagePreview from './MessagePreview.vue'; @@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value); const lastMessageInChat = computed(() => getLastMessage(props.chat)); +const callStatus = computed( + () => props.chat.additional_attributes?.call_status +); +const callDirection = computed( + () => props.chat.additional_attributes?.call_direction +); + +const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } = + useVoiceCallStatus(callStatus, callDirection); + const inboxId = computed(() => props.chat.inbox_id); const inbox = computed(() => { @@ -306,14 +317,30 @@ const deleteConversation = () => { > {{ currentContact.name }} +
+ + + {{ $t(voiceLabelKey) }} + +

diff --git a/app/javascript/dashboard/composables/useVoiceCallStatus.js b/app/javascript/dashboard/composables/useVoiceCallStatus.js new file mode 100644 index 000000000..111dab2ca --- /dev/null +++ b/app/javascript/dashboard/composables/useVoiceCallStatus.js @@ -0,0 +1,161 @@ +import { computed, unref } from 'vue'; + +const CALL_STATUSES = { + IN_PROGRESS: 'in-progress', + RINGING: 'ringing', + NO_ANSWER: 'no-answer', + BUSY: 'busy', + FAILED: 'failed', + COMPLETED: 'completed', + CANCELED: 'canceled', +}; + +const CALL_DIRECTIONS = { + INBOUND: 'inbound', + OUTBOUND: 'outbound', +}; + +/** + * Composable for handling voice call status display logic + * @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.) + * @param {Ref|string} directionRef - Call direction (inbound, outbound) + * @returns {Object} UI properties for displaying call status + */ +export function useVoiceCallStatus(statusRef, directionRef) { + const status = computed(() => unref(statusRef)?.toString()); + const direction = computed(() => unref(directionRef)?.toString()); + + // Status group helpers + const isFailedStatus = computed(() => + [ + CALL_STATUSES.NO_ANSWER, + CALL_STATUSES.BUSY, + CALL_STATUSES.FAILED, + ].includes(status.value) + ); + const isEndedStatus = computed(() => + [CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value) + ); + const isOutbound = computed( + () => direction.value === CALL_DIRECTIONS.OUTBOUND + ); + + const labelKey = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' + : 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS'; + } + + if (s === CALL_STATUSES.RINGING) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' + : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + } + + if (s === CALL_STATUSES.NO_ANSWER) { + return 'CONVERSATION.VOICE_CALL.MISSED_CALL'; + } + + if (isFailedStatus.value) { + return 'CONVERSATION.VOICE_CALL.NO_ANSWER'; + } + + if (isEndedStatus.value) { + return 'CONVERSATION.VOICE_CALL.CALL_ENDED'; + } + + return 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + }); + + const subtextKey = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.RINGING) { + return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'; + } + + if (s === CALL_STATUSES.IN_PROGRESS) { + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED' + : 'CONVERSATION.VOICE_CALL.YOU_ANSWERED'; + } + + if (isFailedStatus.value) { + return 'CONVERSATION.VOICE_CALL.NO_ANSWER'; + } + + if (isEndedStatus.value) { + return 'CONVERSATION.VOICE_CALL.CALL_ENDED'; + } + + return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'; + }); + + const bubbleIconName = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS) { + return isOutbound.value + ? 'i-ph-phone-outgoing-fill' + : 'i-ph-phone-incoming-fill'; + } + + if (isFailedStatus.value) { + return 'i-ph-phone-x-fill'; + } + + // For ringing/completed/canceled show direction when possible + return isOutbound.value + ? 'i-ph-phone-outgoing-fill' + : 'i-ph-phone-incoming-fill'; + }); + + const bubbleIconBg = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS) { + return 'bg-n-teal-9'; + } + + if (isFailedStatus.value) { + return 'bg-n-ruby-9'; + } + + if (isEndedStatus.value) { + return 'bg-n-slate-11'; + } + + // default (e.g., ringing) + return 'bg-n-teal-9 animate-pulse'; + }); + + const listIconColor = computed(() => { + const s = status.value; + + if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) { + return 'text-n-teal-9'; + } + + if (isFailedStatus.value) { + return 'text-n-ruby-9'; + } + + if (isEndedStatus.value) { + return 'text-n-slate-11'; + } + + return 'text-n-teal-9'; + }); + + return { + status, + labelKey, + subtextKey, + bubbleIconName, + bubbleIconBg, + listIconColor, + }; +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 93f375e7f..9fd39b70f 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -71,6 +71,17 @@ "SHOW_LABELS": "Show labels", "HIDE_LABELS": "Hide labels" }, + "VOICE_CALL": { + "INCOMING_CALL": "Incoming call", + "OUTGOING_CALL": "Outgoing call", + "CALL_IN_PROGRESS": "Call in progress", + "NO_ANSWER": "No answer", + "MISSED_CALL": "Missed call", + "CALL_ENDED": "Call ended", + "NOT_ANSWERED_YET": "Not answered yet", + "THEY_ANSWERED": "They answered", + "YOU_ANSWERED": "You answered" + }, "HEADER": { "RESOLVE_ACTION": "Resolve", "REOPEN_ACTION": "Reopen", diff --git a/app/models/message.rb b/app/models/message.rb index 12b12b205..06be665d8 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -95,7 +95,8 @@ class Message < ApplicationRecord incoming_email: 8, input_csat: 9, integrations: 10, - sticker: 11 + sticker: 11, + voice_call: 12 } enum status: { sent: 0, delivered: 1, read: 2, failed: 3 } # [:submitted_email, :items, :submitted_values] : Used for bot message types @@ -104,9 +105,10 @@ class Message < ApplicationRecord # [:deleted] : Used to denote whether the message was deleted by the agent # [:external_created_at] : Can specify if the message was created at a different timestamp externally # [:external_error : Can specify if the message creation failed due to an error at external API + # [:data] : Used for structured content types such as voice_call store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted, :external_created_at, :story_sender, :story_id, :external_error, - :translations, :in_reply_to_external_id, :is_unsupported], coder: JSON + :translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id @@ -114,6 +116,7 @@ class Message < ApplicationRecord scope :chat, -> { where.not(message_type: :activity).where(private: false) } scope :non_activity_messages, -> { where.not(message_type: :activity).reorder('id desc') } scope :today, -> { where("date_trunc('day', created_at) = ?", Date.current) } + scope :voice_calls, -> { where(content_type: :voice_call) } # TODO: Get rid of default scope # https://stackoverflow.com/a/1834250/939299 diff --git a/config/routes.rb b/config/routes.rb index 139476706..5fc602e0b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -524,6 +524,15 @@ Rails.application.routes.draw do namespace :twilio do resources :callback, only: [:create] resources :delivery_status, only: [:create] + + if ChatwootApp.enterprise? + resource :voice, only: [], controller: 'voice' do + collection do + post 'call/:phone', action: :call_twiml + post 'status/:phone', action: :status + end + end + end end get 'microsoft/callback', to: 'microsoft/callbacks#show' diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb new file mode 100644 index 000000000..436083f14 --- /dev/null +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -0,0 +1,38 @@ +class Twilio::VoiceController < ApplicationController + before_action :set_inbox! + + def status + Voice::StatusUpdateService.new( + account: @inbox.account, + call_sid: params[:CallSid], + call_status: params[:CallStatus] + ).perform + head :no_content + end + + def call_twiml + account = @inbox.account + call_sid = params[:CallSid] + from_number = params[:From].to_s + to_number = params[:To].to_s + + builder = Voice::InboundCallBuilder.new( + account: account, + inbox: @inbox, + from_number: from_number, + to_number: to_number, + call_sid: call_sid + ).perform + render xml: builder.twiml_response + end + + private + + def set_inbox! + # Resolve from the digits in the route param and look up exact E.164 match + digits = params[:phone].to_s.gsub(/\D/, '') + e164 = "+#{digits}" + channel = Channel::Voice.find_by!(phone_number: e164) + @inbox = channel.inbox + end +end diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb index 40f9070be..2662b7284 100644 --- a/enterprise/app/models/channel/voice.rb +++ b/enterprise/app/models/channel/voice.rb @@ -44,13 +44,13 @@ class Channel::Voice < ApplicationRecord # Public URLs used to configure Twilio webhooks def voice_call_webhook_url - base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '') - "#{base}/twilio/voice/call/#{phone_number}" + digits = phone_number.delete_prefix('+') + "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}" end def voice_status_webhook_url - base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '') - "#{base}/twilio/voice/status/#{phone_number}" + digits = phone_number.delete_prefix('+') + "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}" end private diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb index e137c0929..e653ad0ab 100644 --- a/enterprise/app/models/enterprise/conversation.rb +++ b/enterprise/app/models/enterprise/conversation.rb @@ -2,4 +2,15 @@ module Enterprise::Conversation def list_of_keys super + %w[sla_policy_id] 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 end diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb new file mode 100644 index 000000000..f5ae18801 --- /dev/null +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -0,0 +1,82 @@ +class Voice::InboundCallBuilder + pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!] + + attr_reader :conversation + + def perform + contact = find_or_create_contact! + contact_inbox = find_or_create_contact_inbox!(contact) + @conversation = find_or_create_conversation!(contact, contact_inbox) + create_call_message_if_needed! + self + end + + def twiml_response + response = Twilio::TwiML::VoiceResponse.new + response.say(message: 'Please wait while we connect you to an agent') + response.to_s + end + + private + + def find_or_create_conversation!(contact, contact_inbox) + account.conversations.find_or_create_by!( + account_id: account.id, + inbox_id: inbox.id, + identifier: call_sid + ) do |conv| + conv.contact_id = contact.id + conv.contact_inbox_id = contact_inbox.id + conv.additional_attributes = { + 'call_direction' => 'inbound', + 'call_status' => 'ringing' + } + end + end + + def create_call_message! + content_attrs = call_message_content_attributes + + @conversation.messages.create!( + account_id: account.id, + inbox_id: inbox.id, + message_type: :incoming, + sender: @conversation.contact, + content: 'Voice Call', + content_type: 'voice_call', + content_attributes: content_attrs + ) + end + + def create_call_message_if_needed! + return if @conversation.messages.voice_calls.exists? + + create_call_message! + end + + def call_message_content_attributes + { + data: { + call_sid: call_sid, + status: 'ringing', + conversation_id: @conversation.display_id, + call_direction: 'inbound', + from_number: from_number, + to_number: to_number, + meta: { + created_at: Time.current.to_i, + ringing_at: Time.current.to_i + } + } + } + end + + def find_or_create_contact! + account.contacts.find_by(phone_number: from_number) || + account.contacts.create!(phone_number: from_number, name: 'Unknown Caller') + end + + def find_or_create_contact_inbox!(contact) + ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create! + end +end diff --git a/enterprise/app/services/voice/status_update_service.rb b/enterprise/app/services/voice/status_update_service.rb new file mode 100644 index 000000000..18152c549 --- /dev/null +++ b/enterprise/app/services/voice/status_update_service.rb @@ -0,0 +1,29 @@ +class Voice::StatusUpdateService + pattr_initialize [:account!, :call_sid!, :call_status] + + def perform + conversation = account.conversations.find_by(identifier: call_sid) + return unless conversation + return if call_status.to_s.strip.empty? + + update_conversation!(conversation) + update_last_call_message!(conversation) + end + + private + + def update_conversation!(conversation) + attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status) + conversation.update!(additional_attributes: attrs) + end + + def update_last_call_message!(conversation) + msg = conversation.messages.voice_calls.order(created_at: :desc).first + return unless msg + + data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {} + data['data'] ||= {} + data['data']['status'] = call_status + msg.update!(content_attributes: data) + end +end diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb new file mode 100644 index 000000000..0f5e4d00a --- /dev/null +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Twilio::VoiceController', type: :request do + 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 + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + end + + describe 'POST /twilio/voice/call/:phone' do + let(:call_sid) { 'CA_test_call_sid_123' } + let(:from_number) { '+15550003333' } + let(:to_number) { channel.phone_number } + + it 'invokes Voice::InboundCallBuilder with expected params and renders its TwiML' do + builder_double = instance_double(Voice::InboundCallBuilder) + expect(Voice::InboundCallBuilder).to receive(:new).with( + hash_including( + account: account, + inbox: inbox, + from_number: from_number, + to_number: to_number, + call_sid: call_sid + ) + ).and_return(builder_double) + expect(builder_double).to receive(:perform).and_return(builder_double) + expect(builder_double).to receive(:twiml_response).and_return('') + + post "/twilio/voice/call/#{digits}", params: { + 'CallSid' => call_sid, + 'From' => from_number, + 'To' => to_number + } + + expect(response).to have_http_status(:ok) + expect(response.body).to eq('') + end + + it 'raises not found when inbox is not present' do + expect(Voice::InboundCallBuilder).not_to receive(:new) + post '/twilio/voice/call/19998887777', params: { + 'CallSid' => call_sid, + 'From' => from_number, + 'To' => to_number + } + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST /twilio/voice/status/:phone' do + let(:call_sid) { 'CA_status_sid_456' } + + it 'invokes Voice::StatusUpdateService with expected params' do + service_double = instance_double(Voice::StatusUpdateService, perform: nil) + expect(Voice::StatusUpdateService).to receive(:new).with( + hash_including( + account: account, + call_sid: call_sid, + call_status: 'completed' + ) + ).and_return(service_double) + expect(service_double).to receive(:perform) + + post "/twilio/voice/status/#{digits}", params: { + 'CallSid' => call_sid, + 'CallStatus' => 'completed' + } + + expect(response).to have_http_status(:no_content) + end + + it 'raises not found when inbox is not present' do + expect(Voice::StatusUpdateService).not_to receive(:new) + post '/twilio/voice/status/18005550101', params: { + 'CallSid' => call_sid, + 'CallStatus' => 'busy' + } + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb new file mode 100644 index 000000000..12e2d7235 --- /dev/null +++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Voice::InboundCallBuilder do + let(:account) { create(:account) } + let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230001') } + let(:inbox) { channel.inbox } + + let(:from_number) { '+15550001111' } + let(:to_number) { channel.phone_number } + let(:call_sid) { 'CA1234567890abcdef' } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new) + .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) + end + + def build_and_perform + described_class.new( + account: account, + inbox: inbox, + from_number: from_number, + to_number: to_number, + call_sid: call_sid + ).perform + end + + it 'creates a new conversation with inbound ringing attributes' do + builder = build_and_perform + conversation = builder.conversation + expect(conversation).to be_present + expect(conversation.account_id).to eq(account.id) + expect(conversation.inbox_id).to eq(inbox.id) + expect(conversation.identifier).to eq(call_sid) + expect(conversation.additional_attributes['call_direction']).to eq('inbound') + expect(conversation.additional_attributes['call_status']).to eq('ringing') + end + + it 'creates a voice_call message with ringing status' do + builder = build_and_perform + conversation = builder.conversation + msg = conversation.messages.voice_calls.last + expect(msg).to be_present + expect(msg.message_type).to eq('incoming') + expect(msg.content_type).to eq('voice_call') + expect(msg.content_attributes.dig('data', 'call_sid')).to eq(call_sid) + expect(msg.content_attributes.dig('data', 'status')).to eq('ringing') + end + + it 'returns TwiML that informs the caller we are connecting' do + builder = build_and_perform + xml = builder.twiml_response + expect(xml).to include('Please wait while we connect you to an agent') + expect(xml).to include(' 'inbound', 'call_status' => 'ringing' } + ) + end + let(:message) do + 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' } } + ) + end + let(:channel) { create(:channel_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 + + described_class.new( + account: account, + call_sid: call_sid, + call_status: 'completed' + ).perform + + conversation.reload + message.reload + + expect(conversation.additional_attributes['call_status']).to eq('completed') + expect(message.content_attributes.dig('data', 'status')).to eq('completed') + end + + it 'no-ops when conversation not found' do + expect do + described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform + end.not_to raise_error + end +end