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