mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
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>
This commit is contained in:
parent
1124c1b4c2
commit
353089473e
@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
@ -11,11 +13,6 @@ const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
@ -30,13 +27,40 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { call } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId } = useMessageContext();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.accepted_by_agent_id);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
);
|
||||
return conversation?.meta?.assignee || null;
|
||||
});
|
||||
const displayAgentName = computed(() => {
|
||||
if (call.value?.accepted_by_agent_name)
|
||||
return call.value.accepted_by_agent_name;
|
||||
if (acceptedByAgentId.value) {
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
if (agent?.available_name) return agent.available_name;
|
||||
if (agent?.name) return agent.name;
|
||||
}
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
@ -50,16 +74,28 @@ const labelKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@ -93,7 +129,7 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
{{ subtext }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
@ -74,6 +77,11 @@ export function useCallSession() {
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
|
||||
@ -22,6 +22,29 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
if (currentUserId == null) return false;
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
|
||||
// Outbound calls are scoped to the initiator via shouldSkipCall; the
|
||||
// conversation may be auto-assigned to a different agent on creation, so
|
||||
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
|
||||
if (callDirection === 'outbound') return true;
|
||||
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const call = message?.call || {};
|
||||
return {
|
||||
@ -29,6 +52,7 @@ function extractCallData(message) {
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@ -36,10 +60,19 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
@ -53,8 +86,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
@ -66,11 +105,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
callSid,
|
||||
});
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCall(callSid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNewCall) {
|
||||
if (status === 'ringing') {
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
@ -79,3 +126,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
@ -83,7 +83,8 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
|
||||
@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from 'dashboard/helper/voice';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
@ -393,19 +394,18 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
updateConversation({ commit, dispatch }, conversation) {
|
||||
const {
|
||||
meta: { sender },
|
||||
} = conversation;
|
||||
updateConversation({ commit, dispatch, rootGetters }, conversation) {
|
||||
const sender = conversation.meta?.sender;
|
||||
|
||||
commit(types.UPDATE_CONVERSATION, conversation);
|
||||
syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
|
||||
|
||||
dispatch('conversationLabels/setConversationLabel', {
|
||||
id: conversation.id,
|
||||
data: conversation.labels,
|
||||
});
|
||||
|
||||
dispatch('contacts/setContact', sender);
|
||||
if (sender) dispatch('contacts/setContact', sender);
|
||||
},
|
||||
|
||||
updateConversationLastActivity(
|
||||
|
||||
@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
|
||||
dismissCall(callSid) {
|
||||
this.calls = this.calls.filter(call => call.callSid !== callSid);
|
||||
},
|
||||
|
||||
removeCallsForConversation(conversationId) {
|
||||
const callsToRemove = this.calls.filter(
|
||||
call => call.conversationId === conversationId
|
||||
);
|
||||
|
||||
if (callsToRemove.some(call => call.isActive)) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
}
|
||||
|
||||
this.calls = this.calls.filter(
|
||||
call => call.conversationId !== conversationId
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -64,6 +64,8 @@ en:
|
||||
email_already_exists: 'You have already signed up for an account with %{email}'
|
||||
invalid_params: 'Invalid, please check the signup paramters and try again'
|
||||
failed: Signup failed
|
||||
voice:
|
||||
call_already_accepted: '%{agent_name} is already handling the call.'
|
||||
assignment_policy:
|
||||
not_found: Assignment policy not found
|
||||
attachments:
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox_for_conference
|
||||
rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
|
||||
|
||||
def token
|
||||
render json: Voice::Provider::Twilio::TokenService.new(
|
||||
@ -54,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
|
||||
authorize conversation, :show?
|
||||
conversation
|
||||
end
|
||||
|
||||
def render_call_already_accepted(error)
|
||||
render json: { error: error.message }, status: :conflict
|
||||
end
|
||||
end
|
||||
|
||||
@ -92,6 +92,7 @@ class Call < ApplicationRecord
|
||||
duration_seconds: duration_seconds,
|
||||
conference_sid: conference_sid,
|
||||
accepted_by_agent_id: accepted_by_agent_id,
|
||||
accepted_by_agent_name: accepted_by_agent&.available_name,
|
||||
started_at: started_at&.to_i,
|
||||
ended_at: ended_at,
|
||||
from_number: from_number,
|
||||
|
||||
@ -31,10 +31,32 @@ class Voice::Conference::Manager
|
||||
|
||||
def join_agent!
|
||||
user_id = extract_user_id
|
||||
call.update!(accepted_by_agent_id: user_id) if 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.
|
||||
|
||||
@ -9,7 +9,8 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
call.update!(accepted_by_agent: user)
|
||||
claim_call!(user)
|
||||
assign_conversation!(user)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
@ -21,4 +22,30 @@ class Voice::Provider::Twilio::ConferenceService
|
||||
.list(friendly_name: call.conference_sid, status: 'in-progress')
|
||||
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claim_call!(user)
|
||||
call.with_lock do
|
||||
raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
|
||||
call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
|
||||
end
|
||||
end
|
||||
|
||||
def claimed_by_other_agent?(user)
|
||||
call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
|
||||
end
|
||||
|
||||
def raise_already_accepted!(agent)
|
||||
raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
|
||||
end
|
||||
|
||||
# Existing assignments win — manual reassignment and pre-call assignment
|
||||
# (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
|
||||
def assign_conversation!(user)
|
||||
conversation = call.conversation
|
||||
return if conversation.assignee_id.present?
|
||||
|
||||
Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
|
||||
end
|
||||
end
|
||||
|
||||
11
lib/custom_exceptions/call_already_accepted.rb
Normal file
11
lib/custom_exceptions/call_already_accepted.rb
Normal file
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
|
||||
def message
|
||||
I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
|
||||
end
|
||||
|
||||
def http_status
|
||||
409
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user