diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue index fcd7ad232..207129282 100644 --- a/app/javascript/dashboard/components-next/call/CallCard.vue +++ b/app/javascript/dashboard/components-next/call/CallCard.vue @@ -35,7 +35,14 @@ const props = defineProps({ }, }); -defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']); +defineEmits([ + 'accept', + 'reject', + 'end', + 'toggleMute', + 'goToConversation', + 'dismiss', +]); const { t } = useI18n(); @@ -115,6 +122,19 @@ const channelIcon = computed(() => { {{ statusLabel }} + + diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue index 39e75ddbf..86d9e979c 100644 --- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue @@ -25,6 +25,7 @@ const { joinCall, endCall: endCallSession, rejectIncomingCall, + dismissCall, formattedCallDuration, } = useCallSession(); @@ -51,6 +52,15 @@ const mainCardState = computed(() => { : VOICE_CALL_DIRECTION.INCOMING; }); +// Stacked cards are always non-active (ringing) calls, so reflect each call's +// real direction. An outbound call must render as OUTGOING — otherwise it shows +// the incoming-only dismiss (✕) control and the agent could drop it locally +// without terminating, leaving the customer ringing with no widget to end it. +const stackedCardState = call => + call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND + ? VOICE_CALL_DIRECTION.OUTGOING + : VOICE_CALL_DIRECTION.INCOMING; + const toggleMute = () => { isMuted.value = !isMuted.value; setWhatsappCallMuted(isMuted.value); @@ -230,10 +240,11 @@ onBeforeUnmount(stopRingtone); v-for="call in stackedIncomingCalls" :key="call.callSid" :call="call" - :state="VOICE_CALL_DIRECTION.INCOMING" + :state="stackedCardState(call)" :call-info="getCallInfo(call)" @accept="handleJoinCall(call)" @reject="rejectIncomingCall(call.callSid)" + @dismiss="dismissCall(call.callSid)" @go-to-conversation="goToConversation(call)" /> @@ -248,6 +259,7 @@ onBeforeUnmount(stopRingtone); :show-mute="hasActiveCall && isWhatsappActive" @accept="handleJoinCall(primaryIncomingCall)" @reject="rejectIncomingCall(primaryIncomingCall?.callSid)" + @dismiss="dismissCall(primaryIncomingCall?.callSid)" @end="handleEndCall" @toggle-mute="toggleMute" @go-to-conversation="goToConversation(activeCall || primaryIncomingCall)" diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index ae9c4ec75..37a80e8fc 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -91,10 +91,6 @@ const wasDeclinedByAgent = computed( endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED ); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); -const didCurrentUserAnswer = computed( - () => - !!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value -); const conversationAssignee = computed(() => { const conversation = store.getters.getConversationById?.( conversationId?.value @@ -124,43 +120,48 @@ const durationSeconds = computed(() => { const formattedDuration = computed(() => formatDuration(durationSeconds.value)); +// Agent who handled the call (initiator on outbound, answerer on inbound), taken +// strictly from the persisted accept fields — never the conversation's current +// assignee, which would mis-attribute a historical call after a reassignment. +const handlerName = computed(() => { + if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName; + if (!acceptedByAgentId.value) return null; + const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value); + return agent?.available_name || agent?.name || null; +}); + +const handledBy = computed(() => + handlerName.value + ? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value }) + : null +); + const labelKey = computed(() => { if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; - if (status.value === VOICE_CALL_STATUS.RINGING) { - return isOutbound.value - ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' - : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; - } if (isFailed.value) { return isOutbound.value ? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL' : 'CONVERSATION.VOICE_CALL.MISSED_CALL'; } - return 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; + // RINGING or an as-yet-unknown/initial status: orient purely by direction so an + // outbound call never falls through to the "Incoming call" label. + return isOutbound.value + ? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL' + : 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; }); const subtext = computed(() => { - if (status.value === VOICE_CALL_STATUS.RINGING) { - return isOutbound.value - ? t('CONVERSATION.VOICE_CALL.CALLING') - : t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); - } + // Completed: "Handled by {agent} · 0:42" (drops either part when absent). if (status.value === VOICE_CALL_STATUS.COMPLETED) { - return formattedDuration.value; + return [handledBy.value, formattedDuration.value] + .filter(Boolean) + .join(' · '); } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { - if (isOutbound.value) return null; - if (didCurrentUserAnswer.value) { - return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'); - } - if (displayAgentName.value) { - return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', { - agentName: displayAgentName.value, - }); - } - return null; + return handledBy.value; } if (isFailed.value) { + // Missed/failed calls have no handler, so keep the reason rather than "Handled by". if (isOutbound.value) { return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); } @@ -171,6 +172,10 @@ const subtext = computed(() => { } return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); } + // RINGING or an as-yet-unknown/initial status. + if (isOutbound.value) { + return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING'); + } return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); }); diff --git a/app/javascript/dashboard/components-next/message/chips/Audio.vue b/app/javascript/dashboard/components-next/message/chips/Audio.vue index ec50d4a62..8b3dbb850 100644 --- a/app/javascript/dashboard/components-next/message/chips/Audio.vue +++ b/app/javascript/dashboard/components-next/message/chips/Audio.vue @@ -31,6 +31,17 @@ const timeStampURL = computed(() => { return timeStampAppendedURL(attachment.dataUrl); }); +const TRANSCRIPT_PREVIEW_LENGTH = 200; +const isTranscriptExpanded = ref(false); +const isTranscriptLong = computed( + () => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH +); +const displayedTranscript = computed(() => { + const text = attachment.transcribedText || ''; + if (!isTranscriptLong.value || isTranscriptExpanded.value) return text; + return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}…`; +}); + const audioPlayer = useTemplateRef('audioPlayer'); const isPlaying = ref(false); @@ -215,7 +226,18 @@ const downloadAudio = async () => { v-if="attachment.transcribedText && showTranscribedText" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" > - {{ attachment.transcribedText }} + {{ displayedTranscript }} + diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js index 849738f6f..eaa523d75 100644 --- a/app/javascript/dashboard/helper/voice.js +++ b/app/javascript/dashboard/helper/voice.js @@ -1,4 +1,5 @@ import { CONTENT_TYPES } from 'dashboard/components-next/message/constants'; +import { MESSAGE_TYPE } from 'shared/constants/messages'; import { useCallsStore } from 'dashboard/stores/calls'; import types from 'dashboard/store/mutation-types'; @@ -58,6 +59,10 @@ function extractCallerSnapshot(message) { // Snapshot caller info from the message at add-time so the widget can keep // rendering it after the user navigates away from a conversation list that // had the conversation hydrated (and Vuex evicts it from the store). + // Only incoming messages carry the contact as the sender; on outbound calls + // the sender is the initiating agent, so skip the snapshot and let the widget + // fall back to the conversation's contact (conversation.meta.sender). + if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null; const sender = message?.sender; if (!sender) return null; return { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index f0ff6811a..daf49ab59 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -86,13 +86,16 @@ "MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up", "MISSED_CALL_DECLINED_BY": "Declined by {agentName}", "CALL_ENDED": "Call ended", + "HANDLED_BY": "Handled by {agentName}", "NOT_ANSWERED_YET": "Not answered yet", "CALLING": "Calling…", "THEY_ANSWERED": "They answered", "YOU_ANSWERED": "You answered", "AGENT_ANSWERED": "{agentName} answered", "JOIN_CALL": "Join call", - "CALL_BACK": "Call back" + "CALL_BACK": "Call back", + "TRANSCRIPT_SHOW_MORE": "Show more", + "TRANSCRIPT_SHOW_LESS": "Show less" }, "HEADER": { "RESOLVE_ACTION": "Resolve", @@ -312,6 +315,7 @@ "NOT_ANSWERED_YET": "Not answered yet", "HANDLED_IN_ANOTHER_TAB": "Being handled in another tab", "REJECT_CALL": "Reject", + "DISMISS_CALL": "Dismiss", "JOIN_CALL": "Join call", "END_CALL": "End call", "MUTE": "Mute mic", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 62cd2b7bc..9e5206bb3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -253,7 +253,6 @@ export default { if ( this.isAWhatsAppCloudChannel && - this.isEmbeddedSignupWhatsApp && this.isFeatureEnabledonAccount( this.accountId, FEATURE_FLAGS.CHANNEL_VOICE diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js index 2a634a5d9..9b54e230c 100644 --- a/app/javascript/dashboard/stores/calls.js +++ b/app/javascript/dashboard/stores/calls.js @@ -54,7 +54,9 @@ export const useCallsStore = defineStore('calls', { return; } - this.calls.push({ + // Prepend so the newest call surfaces as the primary card (incomingCalls[0]) + // and older calls drop into the stack below it. + this.calls.unshift({ ...callData, isActive: false, }); diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 2c2205b19..00d3277c9 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -41,19 +41,19 @@ class Channel::Whatsapp < ApplicationRecord end # Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers. - # Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow — - # 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs. + # Meta's Calling API is available to any whatsapp_cloud inbox (embedded-signup or manual keys); + # only 360dialog (default provider) can't reach the call APIs. def voice_enabled? voice_calling_supported? && provider_config['calling_enabled'].present? && account.feature_enabled?('channel_voice') end - # Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only - # reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud - # and 360dialog inboxes can't be toggled on even though calling_enabled would persist. + # Whether this inbox can do WhatsApp calling at all. Meta's Calling API is + # reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled + # on even though calling_enabled would persist. def voice_calling_supported? - provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup' + provider == 'whatsapp_cloud' end def provider_service @@ -69,7 +69,7 @@ class Channel::Whatsapp < ApplicationRecord # Saved with validate: false to skip validate_provider_config's remote credential # re-check, which could spuriously fail and desync the flag from Meta. def enable_voice_calling! - raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported? + raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported? raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice') provider_service.update_calling_status('ENABLED') @@ -82,7 +82,7 @@ class Channel::Whatsapp < ApplicationRecord # `calls` from the webhook subscription (best-effort, so a Meta outage can't # trap admins). Leaves Meta's WABA calling.status untouched. def disable_voice_calling! - raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported? + raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported? self.provider_config = provider_config.merge('calling_enabled' => false) save!(validate: false) diff --git a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb index b54940586..0301d428b 100644 --- a/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb @@ -35,8 +35,14 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro def initiate @call = create_outbound_call - @message = Voice::CallMessageBuilder.new(@call).perform! - @call.update!(message_id: @message.id) + # Link the call to its message in one transaction so the message.created + # broadcast (an after_create_commit hook) fires only once call.message_id is + # set. Otherwise the live ringing bubble receives a message with no `call` + # payload (no direction/agent) and renders "Calling…" instead of "Handled by …". + ActiveRecord::Base.transaction do + @message = Voice::CallMessageBuilder.new(@call).perform! + @call.update!(message_id: @message.id) + end end private diff --git a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb index b1e2bc053..db411f068 100644 --- a/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb +++ b/enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb @@ -31,10 +31,11 @@ module Enterprise::Webhooks::WhatsappEventsJob # and timer/recorder kick off before the contact actually answers. def handle_call_events(channel, params) value = params.dig(:entry, 0, :changes, 0, :value) || {} + contacts = value[:contacts] Array(value[:calls]).each do |call_payload| with_call_lock(channel, call_payload[:id]) do - Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform + Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload], contacts: contacts }).perform end end diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb index d543adad0..b9c3f6856 100644 --- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -16,6 +16,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService advanced_search_indexing advanced_search linear_integration + channel_voice ].freeze BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index c502b717a..748bf1efa 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -1,11 +1,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService include Integrations::LlmInstrumentation - WHISPER_MODEL = 'whisper-1'.freeze - # Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes - # = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API - # as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription. - WHISPER_BYTE_LIMIT = 25_000_000 + TRANSCRIPTION_MODEL = 'gpt-4o-mini-transcribe'.freeze + # OpenAI's transcription endpoint hard limit is 25 MB *decimal* (25_000_000), not + # binary (25.megabytes = 26_214_400) — using the binary form leaks the 25.0–26.2 MB + # range to the API as 413s. Long audio (~70+ min Opus) keeps the attachment but skips + # transcription. + TRANSCRIPTION_BYTE_LIMIT = 25_000_000 attr_reader :attachment, :message, :account @@ -42,7 +43,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService blob = attachment.file&.blob return false unless blob - blob.byte_size > WHISPER_BYTE_LIMIT + blob.byte_size > TRANSCRIPTION_BYTE_LIMIT end def fetch_audio_file @@ -75,12 +76,12 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcribed_text = nil File.open(temp_file_path, 'rb') do |file| - # temperature: 0.0 minimises Whisper's hallucinations on silence / - # near-silent audio; non-zero values trigger spiraling repeats like - # "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour. + # temperature: 0.0 minimises hallucinations on silence / near-silent + # audio; non-zero values trigger spiraling repeats — well-documented + # behaviour across OpenAI transcription models. response = @client.audio.transcribe( parameters: { - model: WHISPER_MODEL, + model: TRANSCRIPTION_MODEL, file: file, temperature: 0.0 } @@ -97,7 +98,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService def instrumentation_params(file_path) { span_name: 'llm.messages.audio_transcription', - model: WHISPER_MODEL, + model: TRANSCRIPTION_MODEL, account_id: account&.id, feature_name: 'audio_transcription', file_path: file_path diff --git a/enterprise/app/services/voice/inbound_call_builder.rb b/enterprise/app/services/voice/inbound_call_builder.rb index b6fb089b7..eef70e76b 100644 --- a/enterprise/app/services/voice/inbound_call_builder.rb +++ b/enterprise/app/services/voice/inbound_call_builder.rb @@ -59,9 +59,17 @@ class Voice::InboundCallBuilder end def ensure_contact! - account.contacts.find_or_create_by!(phone_number: from_number) do |record| - record.name = from_number if record.name.blank? + contact = account.contacts.find_or_create_by!(phone_number: from_number) do |record| + record.name = contact_name.presence || from_number end + contact.update!(name: contact_name) if contact_name.present? && contact.name == from_number + contact + end + + # WhatsApp inbound calls carry the caller's profile name in extra_meta; Twilio + # calls don't, so contact naming falls back to the phone number. + def contact_name + extra_meta['contact_name'].presence end # WhatsApp ContactInbox.source_id must be digits-only (the wa_id); Twilio accepts the +. diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb index 99f6350ff..afca508a8 100644 --- a/enterprise/app/services/whatsapp/incoming_call_service.rb +++ b/enterprise/app/services/whatsapp/incoming_call_service.rb @@ -73,15 +73,27 @@ class Whatsapp::IncomingCallService def create_inbound_call(payload) sdp_offer = payload.dig(:session, :sdp) + extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers } + name = caller_profile_name(payload) + extra_meta['contact_name'] = name if name.present? + call = Voice::InboundCallBuilder.perform!( inbox: inbox, from_number: "+#{payload[:from]}", call_sid: payload[:id], - provider: :whatsapp, - extra_meta: { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers } + provider: :whatsapp, extra_meta: extra_meta ) update_conversation(call) broadcast_incoming(call, sdp_offer) end + # Match strictly on wa_id (== calls[].from): in a batched payload missing this + # call's contact entry, borrowing another caller's name would corrupt this + # contact, so fall back to the phone number (nil here) instead of contacts.first. + def caller_profile_name(payload) + contacts = Array(params[:contacts]).map(&:with_indifferent_access) + match = contacts.find { |c| c[:wa_id].to_s == payload[:from].to_s } + match&.dig(:profile, :name).presence + end + # `connect` is the WebRTC tunnel-ready signal, not the pickup signal. Apply # Meta's SDP answer so the handshake completes during ringing; the call # stays in `ringing` until status=ACCEPTED arrives. Don't gate on @@ -159,11 +171,13 @@ class Whatsapp::IncomingCallService ) end - # Ring the assignee if any, else online inbox agents, else the whole account. + # Ring the assignee if any, else online inbox agents, else fall back to the + # inbox's own agents and account admins. Never the whole-account stream, which + # would ring online agents from unrelated inboxes. def broadcast_incoming(call, sdp_offer) contact = call.contact token = call.conversation.assignee&.pubsub_token - streams = token ? [token] : (online_agent_streams.presence || account_streams) + streams = token ? [token] : (online_agent_streams.presence || fallback_agent_streams) broadcast(call, 'voice_call.incoming', streams: streams, direction: call.direction_label, inbox_id: call.inbox_id, @@ -175,6 +189,11 @@ class Whatsapp::IncomingCallService inbox.available_agents.pluck('users.pubsub_token').compact end + def fallback_agent_streams + user_ids = inbox.member_ids | inbox.account.administrators.ids + User.where(id: user_ids).pluck(:pubsub_token).compact + end + def broadcast(call, event, streams: account_streams, **extra) payload = { event: event, data: base_payload(call).merge(extra) } streams.each { |s| ActionCable.server.broadcast(s, payload) } diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb index 212c0bf01..32752c2b2 100644 --- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb +++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb @@ -72,7 +72,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do content_type: 'audio/mpeg' ) allow(service).to receive(:can_transcribe?).and_return(true) - allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1) + allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::TRANSCRIPTION_BYTE_LIMIT + 1) end it 'returns an error without calling Whisper' do diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb index 53c7974f4..53b8ec52f 100644 --- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb +++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb @@ -32,6 +32,9 @@ describe Whatsapp::IncomingCallService do describe 'inbound connect' do let(:sdp_offer) { "v=0\r\n...sdp..." } + let!(:agent) { create(:user, account: account) } + + before { create(:inbox_member, inbox: inbox, user: agent) } it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do allow(ActionCable.server).to receive(:broadcast) @@ -44,10 +47,16 @@ describe Whatsapp::IncomingCallService do expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing', provider_call_id: provider_call_id) expect(call.meta['sdp_offer']).to eq(sdp_offer) + # No agent is online, so the call falls back to the inbox's agents (and + # account admins) — never the whole-account stream. expect(ActionCable.server).to have_received(:broadcast).with( - "account_#{account.id}", + agent.pubsub_token, hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer)) ) + expect(ActionCable.server).not_to have_received(:broadcast).with( + "account_#{account.id}", + hash_including(event: 'voice_call.incoming') + ) end end diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb index 9afc21f7c..95bc83932 100644 --- a/spec/models/channel/whatsapp_spec.rb +++ b/spec/models/channel/whatsapp_spec.rb @@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do expect(channel.voice_enabled?).to be true end - it 'returns false for whatsapp_cloud channels without embedded_signup source' do + it 'returns true for manual whatsapp_cloud channels with calling_enabled' do channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true)) - expect(channel.voice_enabled?).to be false + expect(channel.voice_enabled?).to be true end it 'returns false for default-provider channels (360dialog) even with calling_enabled' do