Files
353089473e feat(voice): Assignment aware visibility and join conflict for inbound calls (#14333)
### Description
Inbound voice calls now route ownership cleanly: the call widget is
hidden from agents who aren't the conversation assignee, the first agent
to pick up becomes the assignee, and any later join attempt by another
agent is rejected with a clear "<agent> is already handling the call."
alert.

Closes
https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on

### How to test

1. As Agent A and Agent B, open the dashboard for the same voice inbox
in two browsers.
2. Place an inbound call to the inbox with the conversation
**unassigned** — both agents should see the call widget.
3. Have Agent A click **Join**. Agent A's widget transitions to the
active call; Agent B's widget disappears (conversation is now assigned
to Agent A).
4. While the call is in progress, attempt to join from a third agent
(e.g., via the bubble in the conversation timeline) — the join is
rejected with the toast `Agent A is already handling the call.`
5. Resolve the conversation, then place a second call to a conversation
that is already manually assigned to Agent A — only Agent A sees the
widget; nobody else does.
6. Race test: trigger two near-simultaneous join attempts (two agents
click Join within a few hundred ms of each other) — exactly one wins;
the other gets the conflict alert.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-04-30 18:38:10 +04:00

94 lines
2.5 KiB
Ruby

class Voice::Conference::Manager
pattr_initialize [:call!, :event!, :participant_label]
AGENT_LABEL_PATTERN = /\Aagent-(\d+)-account-(\d+)\z/
def process
case event
when 'start'
mark_ringing!
when 'join'
join_agent! if agent_participant?
when 'leave'
handle_leave!
when 'end'
finalize!
end
end
private
def status_manager
@status_manager ||= Voice::CallStatus::Manager.new(call: call)
end
def mark_ringing!
# Guard against delayed conference-start retries rolling a progressed call back to ringing.
return unless call.status == 'ringing'
status_manager.process_status_update('ringing')
end
def join_agent!
user_id = extract_user_id
claim_for_user!(user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
# First-join wins; later joins by other agents are silently ignored so the
# webhook doesn't stomp the original assignee. User-facing rejection happens
# at the API layer.
def claim_for_user!(user_id)
claimed = false
call.with_lock do
next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
claimed = true
end
auto_assign_conversation!(user_id) if claimed
end
def auto_assign_conversation!(user_id)
conversation = call.conversation
return if conversation.assignee_id.present?
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
end
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
def extract_user_id
match = participant_label.to_s.match(AGENT_LABEL_PATTERN)
return unless match
return unless match[2].to_i == call.account_id
match[1].to_i
end
def handle_leave!
case call.status
when 'ringing'
status_manager.process_status_update('no_answer', timestamp: now)
when 'in_progress'
status_manager.process_status_update('completed', timestamp: now)
end
end
def finalize!
return if Call::TERMINAL_STATUSES.include?(call.status)
status_manager.process_status_update('completed', timestamp: now)
end
def agent_participant?
participant_label.to_s.start_with?('agent-')
end
def now
Time.zone.now.to_i
end
end