Files
0e376f4fe2 feat(whatsapp-call): support BSUID callers for inbound voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling

## Description

Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

-  Locally via UI

## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-07-21 16:19:53 +05:30

167 lines
5.5 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Twilio::VoiceController', type: :request do
let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_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 for inbound calls and renders conference TwiML' do
conversation = create(:conversation, account: account, inbox: inbox)
contact = conversation.contact
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: contact,
provider_call_id: call_sid
)
call.update!(conference_sid: call.default_conference_sid)
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
inbox: inbox,
call_sid: call_sid,
caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } }
).and_return(call)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Response>')
expect(response.body).to include('<Dial>')
expect(response.body).to include(call.conference_sid)
end
it 'looks up the Call when Twilio sends the outbound-api PSTN leg' do
conversation = create(:conversation, account: account, inbox: inbox)
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
direction: :outgoing,
provider_call_id: call_sid
)
call.update!(conference_sid: call.default_conference_sid)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => to_number,
'To' => from_number,
'Direction' => 'outbound-api'
}
expect(response).to have_http_status(:ok)
expect(response.body).to include(call.conference_sid)
expect(call.reload.parent_call_sid).to be_nil
end
it 'records the parent call SID when syncing outbound-dial legs' do
parent_sid = 'CA_parent'
child_sid = 'CA_child'
conversation = create(:conversation, account: account, inbox: inbox)
call = create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
direction: :outgoing,
provider_call_id: parent_sid
)
call.update!(conference_sid: call.default_conference_sid)
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => child_sid,
'ParentCallSid' => parent_sid,
'From' => to_number,
'To' => from_number,
'Direction' => 'outbound-dial'
}
expect(response).to have_http_status(:ok)
expect(call.reload.parent_call_sid).to eq(parent_sid)
end
it 'raises not found when inbox is not present' do
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
post '/twilio/voice/call/19998887777', params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
expect(response).to have_http_status(:not_found)
end
it 'rejects the inbound contact leg without building a call when inbound calls are disabled' do
channel.update!(provider_config: { 'inbound_calls_enabled' => false })
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
expect do
post "/twilio/voice/call/#{digits}", params: {
'CallSid' => call_sid,
'From' => from_number,
'To' => to_number,
'Direction' => 'inbound'
}
end.not_to change(Call, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include('<Reject')
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',
payload: hash_including('CallSid' => call_sid, 'CallStatus' => '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