AgentBot-owned conversations are now treated as assigned across conversation lists, counts, pagination, permissions, unread membership, human auto-assignment, and advanced assignee filters. ## Closes - https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination ## Follow-ups - https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot tracks ownership refresh during AgentBot deletion. - https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes tracks the existing saved-filter header count refresh gap. ## Why The backend treated every conversation without a human assignee as unassigned, even when an AgentBot owned it. The frontend already hid AgentBot-owned conversations from the Unassigned list, so counts, pagination, filters, unread membership, direct-access permissions, and auto-assignment could disagree with the visible queue. ## What changed - Treat conversations with either a human assignee or AgentBot owner as assigned. - Keep conversation counts, pagination, unread memberships, advanced filters, and automation assignee conditions aligned with the shared ownership semantics. - Keep human assignee equality and not-equality filters human-only, even when a human and AgentBot have the same numeric ID. - Exclude AgentBot-owned conversations from both legacy and V2 human auto-assignment, and from unassigned-only Enterprise access. - Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse or accept an assigned conversation. - Emit ownership-change updates when only the AgentBot owner changes, so connected clients refresh queue state. ## Validation - AgentBot assignment changed ownership to the bot, moved the conversation to pending, and removed it from the open queue. - Assignee "is present" returned human- and AgentBot-owned conversations; "is not present" returned only genuinely unassigned conversations. - Automation assignee presence conditions treated AgentBot ownership as present and did not execute the absent-owner path. - Live human-assignee equality and not-equality filters excluded AgentBot-owned conversations, including numeric ID collisions. - A 32-conversation pending queue loaded across pagination with matching totals and no missing or duplicate rows. - AgentBot ownership changes and human takeover updated filtered rows immediately without a reload. - Human takeover opened the conversation and restored the public reply composer; subsequent unassignment kept the conversation open. - An unassigned-only custom-role agent saw only genuinely unassigned conversations and could not see AgentBot-owned conversations. - AgentBot-owned conversations showed the handled-by-bot banner, Take over action, and disabled public reply composer. - New conversations in the connected inbox were assigned to the AgentBot and excluded from human auto-assignment. - Opening an AgentBot-owned conversation did not let the legacy assignment callback or its locked recheck replace the bot. - Moving an AgentBot-owned conversation to an auto-assigning team preserved the bot and did not create a second human owner. - Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound reuse, and inbound WhatsApp acceptance preserved existing AgentBot owners. - Focused ownership, filters, pagination, permissions, unread-count, auto-assignment, frontend, and lint checks passed locally. - GitHub Actions, Docker builds, CircleCI, security checks, and the final Codex review are green on the final head.
147 lines
6.1 KiB
Ruby
147 lines
6.1 KiB
Ruby
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 'keeps the AgentBot owner when accepting the call' do
|
|
agent_bot = create(:agent_bot, account: account)
|
|
conversation.update!(assignee_agent_bot: agent_bot)
|
|
|
|
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
|
|
|
|
expect(conversation.reload.assigned_entity).to eq(agent_bot)
|
|
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(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::AlreadyAccepted') }
|
|
end
|
|
|
|
it 'raises CallAlreadyEnded 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(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallAlreadyEnded') }
|
|
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(StandardError) do |error|
|
|
expect(error.class.name).to eq('Voice::CallErrors::CallFailed')
|
|
expect(error.message).to eq('sdp_answer is required')
|
|
end
|
|
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(StandardError) { |error| expect(error.class.name).to eq('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 rejected' 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('rejected')
|
|
expect(call.end_reason).to eq('agent_rejected')
|
|
expect(ActionCable.server).to have_received(:broadcast).with(
|
|
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'rejected'))
|
|
)
|
|
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(StandardError) { |error| expect(error.class.name).to eq('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
|