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.
82 lines
2.5 KiB
Ruby
82 lines
2.5 KiB
Ruby
class Voice::OutboundCallBuilder
|
|
attr_reader :account, :inbox, :user, :contact
|
|
|
|
def self.perform!(account:, inbox:, user:, contact:, conversation: nil)
|
|
new(account: account, inbox: inbox, user: user, contact: contact, conversation: conversation).perform!
|
|
end
|
|
|
|
def initialize(account:, inbox:, user:, contact:, conversation: nil)
|
|
@account = account
|
|
@inbox = inbox
|
|
@user = user
|
|
@contact = contact
|
|
@existing_conversation = conversation
|
|
end
|
|
|
|
def perform!
|
|
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
|
raise ArgumentError, 'Agent required' if user.blank?
|
|
|
|
# Claim for the caller if a reused conversation is unassigned at trigger time; wins over auto-assignment.
|
|
# New conversations set the assignee at creation instead (see create_conversation!).
|
|
claim_for_caller = @existing_conversation && @existing_conversation.assigned_entity.nil?
|
|
|
|
ActiveRecord::Base.transaction do
|
|
contact_inbox = ensure_contact_inbox!
|
|
conversation = @existing_conversation || create_conversation!(contact_inbox)
|
|
# Dial before locking so the Twilio round-trip doesn't hold the conversation row lock.
|
|
call_sid = initiate_call!
|
|
if claim_for_caller
|
|
@existing_conversation.lock!
|
|
@existing_conversation.update!(assignee: user)
|
|
end
|
|
call = create_call!(conversation, call_sid)
|
|
message = Voice::CallMessageBuilder.new(call).perform!
|
|
call.update!(message_id: message.id)
|
|
call
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def ensure_contact_inbox!
|
|
ContactInbox.find_or_create_by!(
|
|
contact_id: contact.id,
|
|
inbox_id: inbox.id
|
|
) do |record|
|
|
record.source_id = contact.phone_number
|
|
end
|
|
end
|
|
|
|
def create_conversation!(contact_inbox)
|
|
account.conversations.create!(
|
|
contact_inbox_id: contact_inbox.id,
|
|
inbox_id: inbox.id,
|
|
contact_id: contact.id,
|
|
assignee_id: user.id,
|
|
status: :open
|
|
)
|
|
end
|
|
|
|
def initiate_call!
|
|
inbox.channel.initiate_call(to: contact.phone_number)[:call_sid]
|
|
end
|
|
|
|
def create_call!(conversation, call_sid)
|
|
call = Call.create!(
|
|
account: account,
|
|
inbox: inbox,
|
|
conversation: conversation,
|
|
contact: contact,
|
|
accepted_by_agent: user,
|
|
provider: :twilio,
|
|
direction: :outgoing,
|
|
status: 'ringing',
|
|
provider_call_id: call_sid,
|
|
meta: { 'initiated_at' => Time.zone.now.to_i }
|
|
)
|
|
call.update!(conference_sid: call.default_conference_sid)
|
|
call
|
|
end
|
|
end
|