Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,121 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
let(:call_sid) { 'CA1234567890abcdef' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
end
def perform_builder
described_class.perform!(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
)
end
context 'when no existing conversation matches call_sid' do
it 'creates a new inbound conversation with ringing status' do
conversation = nil
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
attrs = conversation.additional_attributes
expect(conversation.identifier).to eq(call_sid)
expect(attrs['call_direction']).to eq('inbound')
expect(attrs['call_status']).to eq('ringing')
expect(attrs['conference_sid']).to be_present
expect(attrs.dig('meta', 'initiated_at')).to be_present
expect(conversation.contact.phone_number).to eq(from_number)
end
it 'creates a single voice_call message marked as incoming' do
conversation = perform_builder
voice_message = conversation.messages.voice_calls.last
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
data = voice_message.content_attributes['data']
expect(data).to include(
'call_sid' => call_sid,
'status' => 'ringing',
'call_direction' => 'inbound',
'conference_sid' => conversation.additional_attributes['conference_sid'],
'from_number' => from_number,
'to_number' => inbox.channel.phone_number
)
expect(data['meta']['created_at']).to be_present
expect(data['meta']['ringing_at']).to be_present
end
it 'sets the contact name to the phone number for new callers' do
conversation = perform_builder
expect(conversation.contact.name).to eq(from_number)
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
perform_builder
end
end
context 'when a conversation already exists for the call_sid' do
let(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let!(:existing_conversation) do
create(
:conversation,
account: account,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
)
end
let(:existing_message) do
create(
:message,
account: account,
inbox: inbox,
conversation: existing_conversation,
message_type: :incoming,
content_type: :voice_call,
sender: contact,
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
)
end
it 'reuses the conversation without creating a duplicate' do
existing_message
expect { perform_builder }.not_to change(account.conversations, :count)
existing_conversation.reload
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
end
it 'updates the existing voice call message instead of creating a new one' do
existing_message
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
updated_message = existing_conversation.reload.messages.voice_calls.last
data = updated_message.content_attributes['data']
expect(data['status']).to eq('ringing')
expect(data['call_direction']).to eq('inbound')
end
end
end

View File

@@ -0,0 +1,97 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::OutboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
let(:inbox) { channel.inbox }
let(:user) { create(:user, account: account) }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
let(:call_sid) { 'CA1234567890abcdef' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
allow(inbox).to receive(:channel).and_return(channel)
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
allow(Voice::Conference::Name).to receive(:for).and_call_original
end
describe '.perform!' do
it 'creates a conversation and voice call message' do
conversation_count = account.conversations.count
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
result = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
expect(account.conversations.count).to eq(conversation_count + 1)
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
conversation = result[:conversation].reload
attrs = conversation.additional_attributes
aggregate_failures do
expect(result[:call_sid]).to eq(call_sid)
expect(conversation.identifier).to eq(call_sid)
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
expect(attrs['agent_id']).to eq(user.id)
expect(attrs['conference_sid']).to be_present
voice_message = conversation.messages.voice_calls.last
expect(voice_message.message_type).to eq('outgoing')
message_data = voice_message.content_attributes['data']
expect(message_data).to include(
'call_sid' => call_sid,
'conference_sid' => attrs['conference_sid'],
'from_number' => channel.phone_number,
'to_number' => contact.phone_number
)
end
end
it 'raises an error when contact is missing a phone number' do
contact.update!(phone_number: nil)
expect do
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to raise_error(ArgumentError, 'Contact phone number required')
end
it 'raises an error when user is nil' do
expect do
described_class.perform!(
account: account,
inbox: inbox,
user: nil,
contact: contact
)
end.to raise_error(ArgumentError, 'Agent required')
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end
end
end

View File

@@ -0,0 +1,43 @@
require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
let(:call_instance) do
instance_double(Twilio::REST::Api::V2010::AccountContext::CallInstance, sid: 'CA123', status: 'queued')
end
let(:client_double) { instance_double(Twilio::REST::Client, calls: calls_double) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
it 'initiates an outbound call with expected params' do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
phone_digits = channel.phone_number.delete_prefix('+')
expected_url = Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
expected_status_callback = Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
expect(calls_double).to have_received(:create).with(hash_including(
from: channel.phone_number,
to: '+15550001111',
url: expected_url,
status_callback: expected_status_callback,
status_callback_event: array_including('completed', 'failed', 'busy', 'no-answer',
'canceled')
))
expect(result[:call_sid]).to eq('CA123')
expect(result[:conference_sid]).to eq('CF999')
expect(result[:agent_id]).to eq(42)
expect(result[:call_direction]).to eq('outbound')
end
end

View File

@@ -0,0 +1,60 @@
require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
describe '#ensure_conference_sid' do
it 'returns existing sid if present' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end
it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
end
end
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
agent = create(:user, account: account)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
end
end
describe '#end_conference' do
it 'completes in-progress conferences' do
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed')
service.end_conference
expect(conf_context).to have_received(:update).with(status: 'completed')
end
end
end

View File

@@ -0,0 +1,33 @@
require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
allow(voice_grant).to receive(:outgoing_application_sid=)
allow(voice_grant).to receive(:outgoing_application_params=)
allow(voice_grant).to receive(:incoming_allow=)
end
it 'returns a token payload with expected keys' do
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
payload = described_class.new(inbox: inbox, user: user, account: account).generate
expect(payload[:token]).to eq('jwt-token')
expect(payload[:identity]).to include("agent-#{user.id}")
expect(payload[:inbox_id]).to eq(inbox.id)
expect(payload[:account_id]).to eq(account.id)
expect(payload[:voice_enabled]).to be true
expect(payload[:twiml_endpoint]).to include(voice_channel.phone_number.delete_prefix('+'))
end
end

View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::StatusUpdateService do
let(:account) { create(:account) }
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
let(:conversation) do
Conversation.create!(
account_id: account.id,
inbox_id: inbox.id,
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
)
end
let(:message) do
conversation.messages.create!(
account_id: account.id,
inbox_id: inbox.id,
message_type: :incoming,
sender: contact,
content: 'Voice Call',
content_type: 'voice_call',
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
)
end
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
it 'updates conversation and last voice message with call status' do
# Ensure records are created after stub setup
conversation
message
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'completed'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('completed')
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
end
it 'normalizes busy to no-answer' do
conversation
message
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'busy'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
end
it 'no-ops when conversation not found' do
expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
end.not_to raise_error
end
end