feat(voice): Wire Twilio voice flow through unified call model (#14091)

Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-04-30 11:25:39 +04:00
committed by GitHub
co-authored by Muhsin
parent cd9c8e3303
commit 1124c1b4c2
37 changed files with 630 additions and 811 deletions
@@ -4,7 +4,7 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
let(:account) { create(:account) }
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
let(:voice_inbox) { voice_channel.inbox }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
let(:admin) { create(:user, :administrator, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
@@ -66,41 +66,57 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'creates conference and sets identifier' do
it 'resolves the Call by call_sid and invokes the conference service' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['conference_sid']).to be_present
conversation.reload
expect(conversation.identifier).to eq('CALL123')
expect(body['conference_sid']).to eq('CF123')
expect(body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:ensure_conference_sid)
expect(conference_service).to have_received(:mark_agent_joined)
end
it 'does not allow accessing conversations from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:not_found)
other_conversation.reload
expect(other_conversation.identifier).to be_nil
end
it 'returns conflict when call_sid missing' do
it 'rejects the request when call_sid is missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
expect(response).to have_http_status(:unprocessable_content)
expect(conference_service).not_to have_received(:ensure_conference_sid)
end
it 'does not allow accessing calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end
end
end
@@ -115,25 +131,43 @@ RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
end
context 'when authenticated agent with inbox access' do
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
before do
create(:inbox_member, inbox: voice_inbox, user: agent)
create(
:call,
account: account,
inbox: voice_inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: 'CALL123'
)
end
it 'ends conference and returns success' do
it 'ends the conference for the resolved call' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: conversation.display_id }
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(conversation.display_id)
expect(conference_service).to have_received(:end_conference)
end
it 'does not allow ending conferences for conversations from inboxes without access' do
it 'does not allow ending conferences for calls from inboxes without access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(
:call,
account: account,
inbox: other_inbox,
conversation: other_conversation,
contact: other_conversation.contact,
provider_call_id: 'OTHER123'
)
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
headers: agent.create_new_auth_token,
params: { conversation_id: other_conversation.display_id }
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
expect(response).to have_http_status(:not_found)
end