Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma ce782409c7 chore(voice): trim redundant comments on backend voice files
Pass through pla-150 to drop comments that just describe what the next
line already does:
- whatsapp_calls_controller: removed the Twilio-comparison note (the
  ensure_calling_enabled body says it), the SDP-forwarding tagline, and
  tightened the 138006/transport-error/wamid blocks.
- call.rb: dropped trivial annotations on STATUSES / TERMINAL_STATUSES;
  the names already say what they are.
- send_on_channel_service: collapsed the two-line voice_call rationale
  into one — the surrounding context covers the rest.

No behavior change. 33/33 enterprise call specs still pass; rubocop clean.
2026-05-04 15:24:34 +07:00
Tanmay Deep Sharma 092d59333c chore(voice): rubocop modifier-if on inbox jbuilder voice_enabled line 2026-05-04 14:20:07 +07:00
Tanmay Deep Sharma 1bcc25f95c fix(voice): guard audio attachment after_create_commit hooks on file presence
Both enqueue_audio_transcription and broadcast_message_update_for_audio
fired immediately after Attachment#save without checking whether a file
was actually attached. The broadcast path then walked through
Message#push_event_data → Attachment#audio_metadata → file.metadata,
which is nil when no file is attached, raising NoMethodError on
file.metadata[:width].

This was visible to:
- Captain::OpenAiMessageBuilderService specs that build audio attachments
  without a file (intentional — those specs test attachment_parts logic,
  not file storage). 4 specs failing, all crashing inside the broadcast
  callback rather than in the code under test.
- Any future callsite that creates an audio attachment shell before
  attaching the blob (we don't have one in production today, but the
  callbacks should be defensive).

Add `return unless file.attached?` to both. The transcription job has
nothing to transcribe without a file; the broadcast carries no useful
audio info before the file lands. The callbacks fire again — correctly —
once the blob is attached and committed in a subsequent transaction.
2026-05-04 14:15:38 +07:00
Tanmay Deep Sharma 442631102c fix(voice): stub Channel::Voice for legacy inbox rows after DropChannelVoice migration 2026-05-02 16:06:36 +07:00
9 changed files with 19 additions and 383 deletions
+1 -2
View File
@@ -46,8 +46,7 @@ class Base::SendOnChannelService
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
# voice_call bubbles are call status indicators, not deliverable messages — skip the send pipeline
# otherwise it falls through to "Template not found" / 24-hour-window errors
# voice_call bubbles are call status indicators, not deliverable messages
message.private? || outgoing_message_originated_from_channel? || message.content_type == 'voice_call'
end
+1 -3
View File
@@ -144,6 +144,4 @@ if resource.twilio? && resource.channel.respond_to?(:voice_enabled?)
end
## Voice attribute for WhatsApp Cloud (only embedded-signup channels surface true)
if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
json.voice_enabled resource.channel.voice_enabled?
end
json.voice_enabled resource.channel.voice_enabled? if resource.channel_type == 'Channel::Whatsapp' && resource.channel.respond_to?(:voice_enabled?)
@@ -59,7 +59,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
authorize @conversation, :show?
end
# Twilio voice also exposes voice_enabled? but uses a different initiation path.
def ensure_calling_enabled
channel = @conversation.inbox.channel
return if channel.is_a?(Channel::Whatsapp) && channel.voice_enabled?
@@ -98,7 +97,6 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
'uploaded'
end
# Browser-built SDP offer is forwarded to Meta; the connect webhook later delivers Meta's answer.
def create_outbound_call
contact_phone = @conversation.contact.phone_number.delete('+')
result = provider_service.initiate_call(contact_phone, params[:sdp_offer])
@@ -112,9 +110,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
)
end
# 138006 = no call permission yet; send opt-in template (throttled) and surface state to FE.
# Lock the conversation so concurrent initiate requests can't both pass the throttle gate
# and double-send the opt-in template.
# Meta error 138006 means the contact hasn't opted in yet; send the opt-in
# template (throttled, behind a conversation lock to prevent double-send).
def render_permission_request
status = nil
@conversation.with_lock do
@@ -142,8 +139,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
last_requested.present? && Time.zone.parse(last_requested) > PERMISSION_REQUEST_THROTTLE.ago
end
# Treat transport errors the same as a falsy provider return so the action renders 422
# rather than letting Faraday/HTTParty exceptions bubble up as 500s.
# Treat transport errors as a falsy return so we render 422 rather than 500.
def send_permission_request_safely
provider_service.send_call_permission_request(@conversation.contact.phone_number.delete('+'))
rescue StandardError => e
@@ -151,7 +147,7 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
nil
end
# Record the wamid so the reply webhook can match context.id back to this conversation.
# Stash the outbound wamid so the reply webhook can match context.id back here.
def record_permission_request_wamid(sent)
attrs = (@conversation.additional_attributes || {}).merge(
'call_permission_requested_at' => Time.current.iso8601,
-2
View File
@@ -29,9 +29,7 @@
# index_calls_on_provider_and_provider_call_id (provider,provider_call_id) UNIQUE
#
class Call < ApplicationRecord
# All valid call statuses
STATUSES = %w[ringing in_progress completed no_answer failed].freeze
# 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
+8
View File
@@ -0,0 +1,8 @@
# Stub for legacy inboxes whose channel_type is still 'Channel::Voice' after
# the upstream DropChannelVoice migration moved voice fields onto
# Channel::TwilioSms. Pointing at the new table lets Rails resolve
# `belongs_to :channel, polymorphic: true` lookups to nil (no matching row),
# so jbuilder `.try` chains short-circuit instead of raising.
class Channel::Voice < ApplicationRecord
self.table_name = 'channel_twilio_sms'
end
@@ -14,6 +14,7 @@ module Enterprise::Concerns::Attachment
def enqueue_audio_transcription
return unless file_type.to_sym == :audio
return unless file.attached?
Messages::AudioTranscriptionJob.perform_later(id)
end
@@ -21,6 +22,10 @@ module Enterprise::Concerns::Attachment
def broadcast_message_update_for_audio
return unless file_type.to_sym == :audio
return unless message
# Without an attached file, the message serializer's audio_metadata path
# dereferences `file.metadata[:width]` on nil and raises. The pre-attach
# broadcast wouldn't carry useful audio info anyway — skip until upload completes.
return unless file.attached?
message.reload.send_update_event
end
@@ -1,207 +0,0 @@
require 'rails_helper'
RSpec.describe 'WhatsApp Calls API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
end
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
create(:inbox_member, user: agent, inbox: inbox)
allow_any_instance_of(Channel::Whatsapp).to receive(:provider_service).and_return(provider_service) # rubocop:disable RSpec/AnyInstance
end
describe 'GET /api/v1/accounts/:account_id/whatsapp_calls/:id' do
it 'returns the call payload' do
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['id']).to eq(call.id)
expect(body['call_id']).to eq('wacid_abc')
expect(body['provider']).to eq('whatsapp')
end
it 'returns 401 when unauthenticated' do
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}"
expect(response).to have_http_status(:unauthorized)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/accept' do
it 'forwards SDP and returns the updated call payload' do
allow(provider_service).to receive(:pre_accept_call).and_return(true)
allow(provider_service).to receive(:accept_call).and_return(true)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
params: { sdp_answer: 'sdp_answer' }, headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('in_progress')
end
it 'returns 422 when sdp_answer is missing' do
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/reject' do
it 'rejects the call via Meta and returns its new status' do
allow(provider_service).to receive(:reject_call).and_return(true)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/reject",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('failed')
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/terminate' do
it 'terminates the call via Meta and returns its new status' do
call.update!(status: 'in_progress')
allow(provider_service).to receive(:terminate_call).and_return(true)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/terminate",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(call.reload.status).to eq('completed')
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/initiate' do
let(:contact) { create(:contact, account: account, phone_number: '+15551234567') }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15551234567') }
let(:initiate_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
end
it 'creates an outbound Call and returns calling status' do
allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] })
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to include('status' => 'calling', 'call_id' => 'wacid_outbound')
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
end
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['status']).to eq('permission_requested')
attrs = initiate_conversation.reload.additional_attributes
expect(attrs['call_permission_requested_at']).to be_present
expect(attrs['call_permission_request_message_id']).to eq('wamid.req_xyz')
end
it 'returns permission_request_failed when send_call_permission_request raises a transport error' do
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
allow(provider_service).to receive(:send_call_permission_request).and_raise(Faraday::TimeoutError)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.permission_request_failed'))
end
it 'returns 422 when sdp_offer is missing' do
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns 422 when Meta raises CallFailed for non-permission errors' do
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::CallFailed, 'Meta error')
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Meta error')
end
it 'returns 422 when the conversation contact has no phone number' do
contact.update!(phone_number: nil)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.contact_phone_required'))
end
it 'returns 422 when the conversation belongs to a non-WhatsApp inbox' do
twilio_channel = create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239998')
create(:inbox_member, user: agent, inbox: twilio_channel.inbox)
twilio_conversation = create(:conversation, account: account, inbox: twilio_channel.inbox)
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
params: { conversation_id: twilio_conversation.display_id, sdp_offer: 'sdp_offer' },
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
end
end
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
before do
message = create(:message, conversation: conversation, account: account, inbox: inbox,
content_type: 'voice_call', message_type: 'incoming')
call.update!(message_id: message.id)
end
it 'attaches the recording to the call message' do
file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg')
expect do
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
params: { recording: file }, headers: agent.create_new_auth_token
end.to change { call.message.attachments.count }.by(1)
expect(response).to have_http_status(:ok)
expect(response.parsed_body['status']).to eq('uploaded')
end
it 'is idempotent: returns already_uploaded if an audio attachment exists' do
call.message.attachments.create!(account_id: account.id, file_type: :audio,
file: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg'))
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
params: { recording: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg') },
headers: agent.create_new_auth_token
expect(response.parsed_body['status']).to eq('already_uploaded')
end
end
end
@@ -1,133 +0,0 @@
require 'rails_helper'
describe Whatsapp::CallService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
end
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
allow(channel).to receive(:provider_service).and_return(provider_service)
allow(inbox).to receive(:channel).and_return(channel)
allow(call).to receive(:inbox).and_return(inbox)
allow(ActionCable.server).to receive(:broadcast)
end
describe '#accept' do
let(:sdp_answer) { "v=0\r\n...sdp..." }
before do
allow(provider_service).to receive(:pre_accept_call).and_return(true)
allow(provider_service).to receive(:accept_call).and_return(true)
end
it 'forwards the SDP answer to Meta and transitions the call to in_progress' do
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
expect(provider_service).to have_received(:pre_accept_call).with('wacid_abc', sdp_answer)
expect(provider_service).to have_received(:accept_call).with('wacid_abc', sdp_answer)
expect(call.reload).to have_attributes(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: be_present)
expect(call.meta['sdp_answer']).to eq(sdp_answer)
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.accepted')
)
end
it 'claims the conversation when no assignee is set' do
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
expect(conversation.reload.assignee_id).to eq(agent.id)
end
it 'raises AlreadyAccepted when another agent has already accepted the call' do
call.update!(status: 'in_progress')
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(Voice::CallErrors::AlreadyAccepted)
end
it 'raises NotRinging when the call has reached a terminal state' do
call.update!(status: 'completed')
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(Voice::CallErrors::NotRinging)
end
it 'raises CallFailed when sdp_answer is missing' do
expect { described_class.new(call: call, agent: agent, sdp_answer: nil).accept }
.to raise_error(Voice::CallErrors::CallFailed, 'sdp_answer is required')
end
it 'wraps Meta transport exceptions as CallFailed and leaves the call ringing' do
allow(provider_service).to receive(:pre_accept_call).and_raise(Faraday::TimeoutError)
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(Voice::CallErrors::CallFailed)
expect(call.reload.status).to eq('ringing')
end
end
describe '#reject' do
before { allow(provider_service).to receive(:reject_call).and_return(true) }
it 'tells Meta to reject and finalizes the call as failed' do
described_class.new(call: call, agent: agent).reject
expect(provider_service).to have_received(:reject_call).with('wacid_abc')
expect(call.reload.status).to eq('failed')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed'))
)
end
it 'is a no-op for already-terminal calls' do
call.update!(status: 'completed')
described_class.new(call: call, agent: agent).reject
expect(provider_service).not_to have_received(:reject_call)
end
it 'raises CallFailed and leaves the call ringing when Meta rejects the request' do
allow(provider_service).to receive(:reject_call).and_return(false)
expect { described_class.new(call: call, agent: agent).reject }
.to raise_error(Voice::CallErrors::CallFailed)
expect(call.reload.status).to eq('ringing')
end
end
describe '#terminate' do
before { allow(provider_service).to receive(:terminate_call).and_return(true) }
it 'finalizes an in-progress call as completed' do
call.update!(status: 'in_progress')
described_class.new(call: call, agent: agent).terminate
expect(provider_service).to have_received(:terminate_call).with('wacid_abc')
expect(call.reload.status).to eq('completed')
expect(call.meta['ended_at']).to be_present
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended')
)
end
it 'finalizes a still-ringing call as no_answer when the agent hangs up before the contact picks up' do
described_class.new(call: call, agent: agent).terminate
expect(call.reload.status).to eq('no_answer')
end
end
end
-28
View File
@@ -209,32 +209,4 @@ RSpec.describe Channel::Whatsapp do
end
end
end
describe '#voice_enabled?' do
let(:account) { create(:account) }
it 'returns true for embedded-signup 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('calling_enabled' => true))
expect(channel.voice_enabled?).to be true
end
it 'returns false for whatsapp_cloud channels without embedded_signup source' 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
end
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
channel = create(:channel_whatsapp, account: account, provider: 'default',
validate_provider_config: false, sync_templates: false)
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true))
expect(channel.voice_enabled?).to be false
end
end
end