## Description Some inbound WhatsApp calls stayed stuck in "ringing" forever. When a caller hung up within ~1s of dialing, Meta delivered the terminate webhook before the connect webhook. The terminate arrived with no call record yet and was dropped, then connect created the call in ringing with nothing left to close it. These calls now correctly land as missed (no_answer), and an agent who taps Accept on a call that already ended gets a clean "call ended" instead of a generic error. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - local UI testing ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
322 lines
12 KiB
JavaScript
322 lines
12 KiB
JavaScript
import { computed, readonly, ref, watch, onUnmounted, onMounted } from 'vue';
|
|
import { useStore } from 'vuex';
|
|
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 {
|
|
useWhatsappCallSession,
|
|
sendWhatsappTerminateBeacon,
|
|
cleanupWhatsappSession,
|
|
} from 'dashboard/composables/useWhatsappCallSession';
|
|
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
|
|
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
|
import {
|
|
CONTENT_TYPES,
|
|
VOICE_CALL_DIRECTION,
|
|
VOICE_CALL_STATUS,
|
|
} from 'dashboard/components-next/message/constants';
|
|
import Timer from 'dashboard/helper/Timer';
|
|
|
|
const isWhatsappCall = call => call?.provider === VOICE_CALL_PROVIDERS.WHATSAPP;
|
|
|
|
// Dismissed call sids must not be re-seeded by the conversation-load watcher.
|
|
// Lives at module scope so all consumers share the same set.
|
|
const dismissedCallSids = new Set();
|
|
const markDismissed = callSid => {
|
|
if (callSid) dismissedCallSids.add(callSid);
|
|
};
|
|
|
|
// Globals attached once across all useCallSession() consumers — bubbles in a
|
|
// long thread call this composable many times, and a per-instance Timer +
|
|
// window listener stack would multiply work.
|
|
let globalsAttachedCount = 0;
|
|
let globalDurationTimer = null;
|
|
const globalCallDuration = ref(0);
|
|
let storedCallsStoreRef = null;
|
|
// Shared join lock so two surfaces (bubble + widget) clicking concurrently
|
|
// see one in-flight join, not two unrelated isJoining refs.
|
|
const globalIsJoining = ref(false);
|
|
const globalIsJoiningReadonly = readonly(globalIsJoining);
|
|
|
|
const handleBeforeUnloadGlobal = event => {
|
|
const store = storedCallsStoreRef;
|
|
if (!store) return;
|
|
if (!store.hasActiveCall && !store.hasIncomingCall) return;
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
};
|
|
const handlePageHideGlobal = () => sendWhatsappTerminateBeacon();
|
|
const handleTwilioDisconnectedGlobal = () =>
|
|
storedCallsStoreRef?.clearActiveCall();
|
|
|
|
const attachGlobalsOnFirstMount = callsStore => {
|
|
globalsAttachedCount += 1;
|
|
if (globalsAttachedCount > 1) return;
|
|
storedCallsStoreRef = callsStore;
|
|
globalDurationTimer = new Timer(elapsed => {
|
|
globalCallDuration.value = elapsed;
|
|
});
|
|
TwilioVoiceClient.addEventListener(
|
|
'call:disconnected',
|
|
handleTwilioDisconnectedGlobal
|
|
);
|
|
window.addEventListener('beforeunload', handleBeforeUnloadGlobal);
|
|
window.addEventListener('pagehide', handlePageHideGlobal);
|
|
};
|
|
|
|
const detachGlobalsOnLastUnmount = () => {
|
|
globalsAttachedCount -= 1;
|
|
if (globalsAttachedCount > 0) return;
|
|
globalDurationTimer?.stop();
|
|
globalDurationTimer = null;
|
|
globalCallDuration.value = 0;
|
|
storedCallsStoreRef = null;
|
|
TwilioVoiceClient.removeEventListener(
|
|
'call:disconnected',
|
|
handleTwilioDisconnectedGlobal
|
|
);
|
|
window.removeEventListener('beforeunload', handleBeforeUnloadGlobal);
|
|
window.removeEventListener('pagehide', handlePageHideGlobal);
|
|
};
|
|
|
|
// Build the action surface used by both the root session composable and the
|
|
// lighter useCallActions consumer. All state is module-scoped — the actions
|
|
// don't depend on per-instance refs, so they're cheap to call from anywhere.
|
|
const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
|
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
|
|
|
|
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
|
const call = findCall(callSid);
|
|
if (isWhatsappCall(call)) {
|
|
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
|
|
// tore down the WebRTC session) doesn't stop us hitting /terminate.
|
|
await whatsappSession.endActiveCall(call?.callId);
|
|
globalDurationTimer?.stop();
|
|
callsStore.clearActiveCall();
|
|
return;
|
|
}
|
|
|
|
// try/finally so a failed leaveConference (e.g. backend 5xx) still
|
|
// tears down the local Device and UI state — otherwise the call stays
|
|
// visually active with the mic open.
|
|
try {
|
|
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
|
} finally {
|
|
TwilioVoiceClient.endClientCall();
|
|
globalDurationTimer?.stop();
|
|
callsStore.clearActiveCall();
|
|
}
|
|
};
|
|
|
|
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
|
if (globalIsJoining.value) return null;
|
|
|
|
const call = findCall(callSid);
|
|
// Outbound *WhatsApp* calls have no separate join step — the offer was
|
|
// sent at initiate time and the answer is applied by the cable handler.
|
|
// Routing through acceptIncomingCall here would call prepareInboundAnswer →
|
|
// cleanup() and destroy the live outbound session. Outbound *Twilio*
|
|
// calls still need joinConference + joinClientCall (FloatingCallWidget
|
|
// auto-joins them), so don't short-circuit those.
|
|
if (
|
|
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND &&
|
|
isWhatsappCall(call)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
globalIsJoining.value = true;
|
|
try {
|
|
if (isWhatsappCall(call)) {
|
|
await whatsappSession.acceptIncomingCall({
|
|
callId: call.callId,
|
|
sdpOffer: call.sdpOffer,
|
|
iceServers: call.iceServers,
|
|
});
|
|
callsStore.setCallActive(callSid);
|
|
globalDurationTimer?.start();
|
|
return { callId: call.callId };
|
|
}
|
|
|
|
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
|
if (!device) return null;
|
|
|
|
const joinResponse = await VoiceAPI.joinConference({
|
|
conversationId,
|
|
inboxId,
|
|
callSid,
|
|
});
|
|
|
|
await TwilioVoiceClient.joinClientCall({
|
|
to: joinResponse?.conference_sid,
|
|
conversationId,
|
|
callSid,
|
|
});
|
|
|
|
callsStore.setCallActive(callSid);
|
|
globalDurationTimer?.start();
|
|
|
|
return { conferenceSid: joinResponse?.conference_sid };
|
|
} catch (error) {
|
|
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
|
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
|
|
if (error?.response?.status === 409) {
|
|
TwilioVoiceClient.endClientCall();
|
|
markDismissed(callSid);
|
|
callsStore.dismissCall(callSid);
|
|
} else if (!isWhatsappCall(call)) {
|
|
// Tear down the Twilio Device on any other join error so a retry
|
|
// starts from a clean state — joinClientCall can leave the device
|
|
// half-initialized after a network blip.
|
|
TwilioVoiceClient.endClientCall();
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.error('Failed to join call:', error);
|
|
// Drop any half-built WebRTC state so the next click starts fresh.
|
|
cleanupWhatsappSession();
|
|
return null;
|
|
} finally {
|
|
globalIsJoining.value = false;
|
|
}
|
|
};
|
|
|
|
// Await provider-side reject before dismissing the local entry; if the API
|
|
// call fails the call should stay surfaced so the agent can retry instead of
|
|
// disappearing while the backend still rings.
|
|
const rejectIncomingCall = async callSid => {
|
|
const call = findCall(callSid);
|
|
try {
|
|
if (isWhatsappCall(call) && call?.callId) {
|
|
if (call.callDirection === VOICE_CALL_DIRECTION.OUTBOUND) {
|
|
// Outbound calls that are still ringing must be terminated, not
|
|
// rejected (reject is the inbound-side verb on Meta's API).
|
|
await whatsappSession.endActiveCall(call.callId);
|
|
} else {
|
|
await whatsappSession.rejectIncomingCall(call.callId);
|
|
}
|
|
} else if (call?.inboxId && call?.conversationId) {
|
|
// Twilio incoming reject: agent hasn't joined the Device yet, so
|
|
// endClientCall is a no-op. End the conference server-side instead
|
|
// so Twilio hangs up the inbound leg.
|
|
await VoiceAPI.leaveConference({
|
|
inboxId: call.inboxId,
|
|
conversationId: call.conversationId,
|
|
callSid,
|
|
});
|
|
} else {
|
|
TwilioVoiceClient.endClientCall();
|
|
}
|
|
} finally {
|
|
markDismissed(callSid);
|
|
callsStore.dismissCall(callSid);
|
|
}
|
|
};
|
|
|
|
const dismissCall = callSid => {
|
|
markDismissed(callSid);
|
|
callsStore.dismissCall(callSid);
|
|
};
|
|
|
|
return { endCall, joinCall, rejectIncomingCall, dismissCall };
|
|
};
|
|
|
|
const buildReactiveSurface = callsStore => {
|
|
const activeCall = computed(() => callsStore.activeCall);
|
|
const incomingCalls = computed(() => callsStore.incomingCalls);
|
|
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
|
const formattedCallDuration = computed(() => {
|
|
const total = globalCallDuration.value;
|
|
const minutes = Math.floor(total / 60);
|
|
const seconds = total % 60;
|
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
});
|
|
return {
|
|
activeCall,
|
|
incomingCalls,
|
|
hasActiveCall,
|
|
isJoining: globalIsJoiningReadonly,
|
|
formattedCallDuration,
|
|
};
|
|
};
|
|
|
|
// Root-mount composable. Call once at the dashboard root (FloatingCallWidget
|
|
// is the natural anchor — always mounted, lifetime spans the whole session).
|
|
// This is the only path that registers global window/Twilio listeners and
|
|
// owns the duration Timer.
|
|
export function useCallSession() {
|
|
const store = useStore();
|
|
const callsStore = useCallsStore();
|
|
const whatsappSession = useWhatsappCallSession();
|
|
const { t } = useI18n();
|
|
|
|
const reactive = buildReactiveSurface(callsStore);
|
|
|
|
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
|
|
// on a hard refresh they leave the calls store empty. Seed it from any
|
|
// ringing voice_call message in the conversation cache. Skip calls the
|
|
// agent has already dismissed locally so they don't re-pop on the next
|
|
// conversation update.
|
|
const seedCallsFromHydratedMessages = () => {
|
|
const conversations = store.getters.getAllConversations || [];
|
|
const currentUserId = store.getters.getCurrentUserID;
|
|
const currentUserAvailability = store.getters.getCurrentUserAvailability;
|
|
conversations.forEach(conv => {
|
|
(conv.messages || []).forEach(msg => {
|
|
if (msg.content_type !== CONTENT_TYPES.VOICE_CALL) return;
|
|
if (msg.call?.status !== VOICE_CALL_STATUS.RINGING) return;
|
|
const callSid = msg.call?.provider_call_id;
|
|
if (callSid && dismissedCallSids.has(callSid)) return;
|
|
handleVoiceCallCreated(msg, currentUserId, currentUserAvailability);
|
|
});
|
|
});
|
|
};
|
|
|
|
watch(
|
|
reactive.hasActiveCall,
|
|
active => {
|
|
if (active) {
|
|
globalDurationTimer?.start();
|
|
} else {
|
|
globalDurationTimer?.stop();
|
|
globalCallDuration.value = 0;
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
attachGlobalsOnFirstMount(callsStore);
|
|
seedCallsFromHydratedMessages();
|
|
});
|
|
|
|
// Re-seed when conversations stream in after mount; addCall merges by callSid
|
|
// and dismissed sids are filtered, so this is idempotent.
|
|
watch(
|
|
() => store.getters.getAllConversations?.length,
|
|
() => seedCallsFromHydratedMessages()
|
|
);
|
|
|
|
onUnmounted(() => detachGlobalsOnLastUnmount());
|
|
|
|
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
|
|
|
return { ...reactive, ...actions };
|
|
}
|
|
|
|
// Lightweight consumer for components that need to read state and trigger
|
|
// actions but should NOT mount global listeners (e.g., per-message bubbles
|
|
// rendered in a thread). Reads from the same module-level state that
|
|
// useCallSession owns, so the duration timer and dismissed set stay coherent.
|
|
export function useCallActions() {
|
|
const callsStore = useCallsStore();
|
|
const whatsappSession = useWhatsappCallSession();
|
|
const { t } = useI18n();
|
|
|
|
const reactive = buildReactiveSurface(callsStore);
|
|
const actions = buildCallActions({ callsStore, whatsappSession, t });
|
|
|
|
return { ...reactive, ...actions };
|
|
}
|