diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index efbe7b8b2a2..d57060145da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,7 @@ on: push: branches: # White-list of deployable tags and branches. Note that all white-listed branches cannot include any `/` characters - next + - multi-llms env: rid: ${{ github.run_id }}-${{ github.run_number }} @@ -258,7 +259,7 @@ jobs: git config user.name "${GIT_AUTHOR_NAME}" - name: Get existing changelog from documentation Branch - run: | + run: | git fetch origin documentation git checkout origin/documentation -- docs/changelog/logs diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js index 7181a43864f..b0145f7e1c4 100644 --- a/docs/samples/browser-plugin-meetings/app.js +++ b/docs/samples/browser-plugin-meetings/app.js @@ -780,6 +780,7 @@ function leaveMeeting(meetingId) { publishShareBtn.disabled = true; unpublishShareBtn.disabled = true; enableMeetingDependentButtons(false); + clearSipCalloutFields(); }); } @@ -1296,6 +1297,115 @@ function sendDtmfTones() { } } +// SIP Call-Out Functions -------------------------------------------------- + +function validateSipCalloutFields() { + const sipAddress = document.getElementById('gc-sip-address').value.trim(); + const displayName = document.getElementById('gc-sip-display-name').value.trim(); + const button = document.getElementById('gc-sip-callout'); + const statusElm = document.getElementById('gc-sip-callout-status'); + + const shouldEnable = sipAddress && displayName; + button.disabled = !shouldEnable; + + // Update status message + if (statusElm) { + if (!sipAddress || !displayName) { + statusElm.innerText = 'Please fill in all required fields.'; + statusElm.style.color = 'orange'; + } else { + statusElm.innerText = 'Ready to call out.'; + statusElm.style.color = 'green'; + } + } +} + + +function validateCancelSipFields() { + const participantId = document.getElementById('gc-sip-participant-id').value.trim(); + const button = document.getElementById('gc-sip-cancel-callout'); + const shouldEnable = !!participantId; + button.disabled = !shouldEnable; +} + +function clearSipCalloutFields() { + document.getElementById('gc-sip-address').value = ''; + document.getElementById('gc-sip-display-name').value = ''; + document.getElementById('gc-sip-participant-id').value = ''; + // Reset button states + validateSipCalloutFields(); + validateCancelSipFields(); + // Clear status messages + document.getElementById('gc-sip-callout-status').innerText = ''; + document.getElementById('gc-sip-cancel-callout-status').innerText = ''; +} + +function callOutSipParticipant() { + const meeting = getCurrentMeeting(); + + if (!meeting) { + const statusElm = document.getElementById('gc-sip-callout-status'); + statusElm.innerText = 'Error: No active meeting. Please join a meeting first.'; + statusElm.style.color = 'red'; + return; + } + // Get user input fields + const sipAddress = document.getElementById('gc-sip-address').value.trim(); + const displayName = document.getElementById('gc-sip-display-name').value.trim(); + const statusElm = document.getElementById('gc-sip-callout-status'); + const button = document.getElementById('gc-sip-callout'); + button.disabled = true; + statusElm.innerText = 'Calling out...'; + statusElm.style.color = 'blue'; + meeting.sipCallOut(sipAddress, displayName) + .then((data) => { + statusElm.innerText = 'SIP call-out successful!'; + statusElm.style.color = 'green'; + console.log('MeetingControls#callOutSipParticipant() :: success!', data); + document.getElementById('gc-sip-address').value = ''; + document.getElementById('gc-sip-display-name').value = ''; + button.disabled = false; + validateSipCalloutFields(); + }) + .catch((error) => { + statusElm.innerText = `Error: ${error.message || 'See console'}`; + statusElm.style.color = 'red'; + console.error('MeetingControls#callOutSipParticipant() :: error', error); + button.disabled = false; + }); +} + +function cancelSipCallOut() { + const participantId = document.getElementById('gc-sip-participant-id').value.trim(); + const statusElm = document.getElementById('gc-sip-cancel-status'); + const button = document.getElementById('gc-sip-cancel-callout'); + + if (!participantId) { + statusElm.innerText = 'Please enter a participant ID.'; + statusElm.style.color = 'red'; + return; + } + button.disabled = true; + statusElm.innerText = 'Cancelling...'; + statusElm.style.color = 'blue'; + meeting.cancelSipCallOut( + participantId, + ) + .then((data) => { + statusElm.innerText = 'SIP call-out cancelled!'; + statusElm.style.color = 'green'; + console.log('MeetingControls#cancelSipCallOut() :: success!', data); + document.getElementById('gc-sip-participant-id').value = ''; + button.disabled = false; + validateCancelSipFields(); + }) + .catch((error) => { + statusElm.innerText = `Error: ${error.message || 'See console'}`; + statusElm.style.color = 'red'; + console.error('MeetingControls#cancelSipCallOut() :: error', error); + button.disabled = false; + }); + } const localVideoQuality = { '360p': '360p', '480p': '480p', @@ -4084,6 +4194,10 @@ function enableMeetingDependentButtons(enable) { meetingDependentButtons.forEach((button) => { button.disabled = !enable; }); + + // Update SIP call-out button states when meeting state changes + validateSipCalloutFields(); + validateCancelSipFields(); } enableMeetingDependentButtons(false); diff --git a/docs/samples/browser-plugin-meetings/index.html b/docs/samples/browser-plugin-meetings/index.html index e621e1fff21..fb3de843615 100644 --- a/docs/samples/browser-plugin-meetings/index.html +++ b/docs/samples/browser-plugin-meetings/index.html @@ -710,6 +710,34 @@

+ + +
+

SIP Call-Out

+

NOTE: Meeting details will be automatically retrieved from the active meeting. You only need to enter the SIP address and display name.

+
+ + + + + + + + +
+ +
+

Cancel SIP Call-Out

+

NOTE: Cancel an active SIP call-out using the participant ID.

+ + + + + +
+
diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index b612b9a0b82..af640440959 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -1,5 +1,5 @@ // Globals -let webex; +let webex = undefined; let sdk; let agentDeviceType; let deviceId; @@ -11,10 +11,6 @@ let taskControl; let currentTask; let taskId; let wrapupCodes = []; // Add this to store wrapup codes -let isConsultOptionsShown = false; -let isTransferOptionsShown = false; // Add this variable to track the state of transfer options -let isConferenceActive = false; // Track conference state -let hasConferenceEnded = false; // Track if conference was ended in this consultation session let consultationData = null; // Track who we consulted with for conference let entryPointId = ''; let stateTimer; @@ -46,6 +42,8 @@ const updateDialNumberElm = document.querySelector('#updateDialNumber'); const updateTeamDropdownElm = document.querySelector('#updateTeamDropdown'); const incomingCallListener = document.querySelector('#incomingsection'); const incomingDetailsElm = document.querySelector('#incoming-task'); +const participantListElm = document.querySelector('#participant-list'); + const answerElm = document.querySelector('#answer'); const declineElm = document.querySelector('#decline'); const callControlListener = document.querySelector('#callcontrolsection'); @@ -87,6 +85,19 @@ const timerValueElm = autoWrapupTimerElm.querySelector('.timer-value'); const outdialAniSelectElm = document.querySelector('#outdialAniSelect'); deregisterBtn.style.backgroundColor = 'red'; +function isIncomingTask(task, agentId) { + const taskData = task?.data; + const taskState = taskData?.interaction?.state; + const participants = taskData?.interaction?.participants; + const hasJoined = agentId && participants?.[agentId]?.hasJoined; + + return ( + !taskData?.wrapUpRequired && + !hasJoined && + (taskState === 'new' || taskState === 'consult' || taskState === 'connected' || taskState === 'conference') + ); +}; + // Store and Grab `access-token` from sessionStorage if (sessionStorage.getItem('date') > new Date().getTime()) { tokenElm.value = sessionStorage.getItem('access-token'); @@ -238,36 +249,6 @@ function closeConsultDialog() { initiateConsultDialog.close(); } -function showConsultButton() { - consultTabBtn.style.display = 'inline-block'; - updateConferenceButtonState(); -} - -function hideConsultButton() { - consultTabBtn.style.display = 'none'; - updateConferenceButtonState(); -} - -function showEndConsultButton() { - endConsultBtn.style.display = 'inline-block'; - updateConferenceButtonState(); -} - -function hideEndConsultButton() { - endConsultBtn.style.display = 'none'; - // Reset conference state and clear consultation data when ending consult - isConferenceActive = false; - consultationData = null; - updateConferenceButtonState(); -} - -function toggleTransferOptions() { - // toggle display of transfer options - isTransferOptionsShown = !isTransferOptionsShown; - const transferOptionsElm = document.querySelector('#transfer-options'); - transferOptionsElm.style.display = isTransferOptionsShown ? 'block' : 'none'; -} - async function getQueueListForTelephonyChannel() { try { // Need to access via data as that is the list of queues @@ -600,6 +581,8 @@ async function onTransferTypeSelectionChanged() { // Function to initiate consult async function initiateConsult() { + const currentAgentId = webex?.cc?.taskManager?.getAgentId() || agentId; + const destinationType = destinationTypeDropdown.value; const consultDestinationEl = consultDestinationHolderElm.querySelector('input, select'); const consultDestination = consultDestinationEl && consultDestinationEl.value ? consultDestinationEl.value.trim() : ''; @@ -610,37 +593,37 @@ async function initiateConsult() { } closeConsultDialog(); - + const consultPayload = { to: consultDestination, destinationType: destinationType, }; if (destinationType === 'queue') { - // Store consultation data for queue consult + // Store consultation data for queue consult (reuse currentAgentId) consultationData = { to: consultDestination, destinationType: destinationType, - agentId: agentId // Include current agent ID + consultingAgentId: currentAgentId, // Current agent ID (the one initiating the consult) from SDK + consultedAgentId: consultDestination, // The queue being consulted + isConsultedAgent: false // This agent is the consulting one, not the consulted one }; - hasConferenceEnded = false; // Reset for new consultation handleQueueConsult(consultPayload); return; } - // Store consultation data for agent consult + // Store consultation data for the agent who initiated the consult (reuse currentAgentId) consultationData = { to: consultDestination, destinationType: destinationType, - agentId: agentId // Include current agent ID + consultingAgentId: currentAgentId, // Current agent ID (the one initiating the consult) from SDK + consultedAgentId: consultDestination, // The agent being consulted + isConsultedAgent: false // This agent is the consulting one, not the consulted one }; - hasConferenceEnded = false; // Reset for new consultation try { await currentTask.consult(consultPayload); console.log('Consult initiated successfully'); - // Disable the blind transfer button after initiating consult, only enable it once consult is confirmed - updateConsultUI(); } catch (error) { console.error('Failed to initiate consult', error); alert('Failed to initiate consult'); @@ -651,7 +634,6 @@ async function handleQueueConsult(consultPayload) { // Update UI immediately currentConsultQueueId = consultPayload.to; endConsultBtn.innerText = 'Cancel Consult'; - updateConsultUI(); try { await currentTask.consult(consultPayload); @@ -662,18 +644,10 @@ async function handleQueueConsult(consultPayload) { console.error('Failed to initiate queue consult', error); alert('Failed to initiate queue consult'); // Restore UI state - refreshUIPostConsult(); currentConsultQueueId = null; } } -// Updates UI state for queue consult initiation -function updateConsultUI() { - disableCallControlPostConsult(); - disableTransferControls(); - hideConsultButton(); - showEndConsultButton(); -} // Function to initiate transfer async function initiateTransfer() { @@ -693,8 +667,6 @@ async function initiateTransfer() { try { await currentTask.transfer(transferPayload); console.log('Transfer initiated successfully'); - disableTransferControls(); - toggleTransferOptions(); // Hide the transfer options } catch (error) { console.error('Failed to initiate transfer', error); alert('Failed to initiate transfer'); @@ -717,11 +689,12 @@ async function initiateConsultTransfer() { }; try { - await currentTask.consultTransfer(consultTransferPayload); - console.log('Consult transfer initiated successfully'); - consultTransferBtn.disabled = true; // Disable the consult transfer button after initiating consult transfer - consultTransferBtn.style.display = 'none'; // Hide the consult transfer button after initiating consult transfer - endConsultBtn.style.display = 'none'; + if (currentTask.data.isConferenceInProgress) { + await currentTask.transferConference(); + } else { + await currentTask.consultTransfer(consultTransferPayload); + console.log('Consult transfer initiated successfully'); + } } catch (error) { console.error('Failed to initiate consult transfer', error); } @@ -744,14 +717,141 @@ async function endConsult() { try { await currentTask.endConsult(consultEndPayload); console.log('Consult ended successfully'); - hideEndConsultButton(); - showConsultButton(); } catch (error) { console.error('Failed to end consult', error); alert('Failed to end consult'); } } +/** + * Gets the count of active agent participants in the conference + * @param {Object} task - The task object containing interaction details + * @returns {number} Number of active agent participants + */ +function getActiveAgentCount(task) { + if (!task?.data?.interaction) return 0; + + const mediaMainCall = task.data.interaction.media?.[task.data.interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants || []); + const participants = task.data.interaction.participants || {}; + + let agentCount = 0; + participantsInMainCall.forEach((participantId) => { + const participant = participants[participantId]; + if ( + participant && + participant.pType !== 'Customer' && + participant.pType !== 'Supervisor' && + participant.pType !== 'VVA' && + !participant.hasLeft + ) { + agentCount++; + } + }); + + return agentCount; +} + +// MPC: Update participant list display +function updateParticipantList(task) { + if (!task || !task.data || !task.data.interaction) { + participantListElm.style.display = 'none'; + return; + } + + const { participants } = task.data.interaction; + const mediaMainCall = task.data.interaction.media?.[task.data.interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants || []); + + + if (task.data.isConferenceInProgress) { + let participantHtml = '📋 Active Participants:
'; + + // Only show participants who are actually in the main call + participantsInMainCall.forEach((participantId) => { + const participant = participants[participantId]; + if (!participant) return; + + const role = participant.pType || 'Unknown'; + const name = participant.name || participantId.substring(0, 8); + + // Don't show participants who have left + if (participant.hasLeft) return; + + const status = participant.hasJoined !== false ? '✅' : '⏳'; + + + participantHtml += `${status} ${role}: ${name}
`; + }); + + participantListElm.innerHTML = participantHtml; + participantListElm.style.display = 'block'; + } else { + participantListElm.style.display = 'none'; + } +} + +// Function to handle conference actions +async function toggleConference() { + if (!currentTask) { + alert('No active task'); + return; + } + + try { + console.log('Conference action:', { + hasConsultationData: consultationData !== null, + participants: Object.keys(currentTask.data?.interaction?.participants || {}), + buttonText: conferenceToggleBtn.textContent + }); + + if (conferenceToggleBtn.textContent === 'Merge') { + // Handle Ctrl+Click or Shift+Click for Exit Conference when in conference + consulting + if (event && (event.ctrlKey || event.shiftKey)) { + if (confirm('Exit the conference? (Ctrl/Shift+Click detected)')) { + console.log('Exiting conference via Ctrl/Shift+Click...'); + await currentTask.exitConference(); + console.log('Conference exited successfully'); + return; + } + } + await currentTask.consultConference(); + console.log('Conference merge operation completed successfully'); + + } else if (conferenceToggleBtn.textContent === 'Exit Conference') { + // Exit conference when no active consultation + console.log('Exiting conference (no active consultation)...'); + await currentTask.exitConference(); + console.log('Conference exited successfully'); + } + + // The event listeners will handle UI updates with fresh task data + } catch (error) { + console.error(`Failed to perform conference action:`, error); + alert(`Failed to perform conference action. ${error.message || 'Please try again.'}`); + } +} + +// Update conference button visibility and text +function updateConferenceButtonState(task, isConsultationInProgress) { + // Use passed task parameter instead of global currentTask for consistency + const taskToUse = task || currentTask; + if (!conferenceToggleBtn || !taskToUse) return; + // MPC Logic: Simplified conference button management + if (!task.data.isConferenceInProgress || isConsultationInProgress) { + // Show "Start Conference" button for ACTIVE consultation + //conferenceToggleBtn.style.display = 'inline-block'; + conferenceToggleBtn.textContent = 'Merge'; + conferenceToggleBtn.className = 'btn--green'; + conferenceToggleBtn.title = 'Merge consultation into conference with all participants'; + } else { + // MPC: In conference - show EXIT CONFERENCE (not "End Conference") + conferenceToggleBtn.textContent = 'Exit Conference'; + conferenceToggleBtn.className = 'btn--red'; + conferenceToggleBtn.title = 'Exit from conference (other agents continue, you enter wrap-up)'; + } +} + // Function to load outdial ANI entries async function loadOutdialAniEntries(outdialANIId) { @@ -843,17 +943,6 @@ function pressKey(value) { document.getElementById('outBoundDialNumber').value += value; } -// Enable consult button after task is accepted -function enableConsultControls() { - consultTabBtn.disabled = false; - consultTabBtn.style.display = 'inline-block'; - endConsultBtn.style.display = 'none'; -} - -// Disable consult button after task is accepted -function disableConsultControls() { - consultTabBtn.disabled = true; -} // Enable transfer button after task is accepted function enableTransferControls() { @@ -879,12 +968,16 @@ function enableCallControlPostConsult() { endElm.disabled = false; } -function refreshUIPostConsult() { - enableCallControlPostConsult(); - enableTransferControls(); - showConsultButton(); - hideEndConsultButton(); -} +function isInteractionOnHold(task) { + if (!task || !task.data || !task.data.interaction) { + return false; + } + const interaction = task.data.interaction; + if (!interaction.media) { + return false; + } + return Object.values(interaction.media).some((media) => media.isHold); +} // Register task listeners function registerTaskListeners(task) { @@ -896,145 +989,41 @@ function registerTaskListeners(task) { task.on('task:media', (track) => { document.getElementById('remote-audio').srcObject = new MediaStream([track]); }); - task.on('task:end', (task) => { - incomingDetailsElm.innerText = ''; - if (currentTask.data.interactionId === task.data.interactionId) { - if (!task.data.wrapUpRequired) { - answerElm.disabled = true; - declineElm.disabled = true; - console.log('Task ended without call being answered'); - } - else { - console.info('Call ended successfully'); - updateButtonsPostEndCall(); - } - updateTaskList(); // Update the task list UI to have latest tasks - handleTaskSelect(task); - } - }); + task.on('task:end', updateTaskList); // Update the task list UI to have latest tasks - task.on('task:hold', (task) => { - if (currentTask.data.interactionId === task.data.interactionId) { - console.info('Call has been put on hold'); - holdResumeElm.innerText = 'Resume'; - } - }); + task.on('task:hold', updateTaskList); - // Consult flows - task.on('task:consultCreated', (task) => { - console.info('Consult created'); - }); + task.on('task:resume', updateTaskList); - task.on('task:offerConsult', (task) => { - console.info('Received consult offer from another agent'); - }); + // Consult flows + task.on('task:consultCreated', updateTaskList); - task.on('task:consultAccepted', (task) => { - if (currentTask.data.interactionId === task.data.interactionId) { - // When we accept an incoming consult - hideConsultButton(); - showEndConsultButton(); - consultTransferBtn.disabled = true; // Disable the consult transfer button since we are not yet owner of the call - } - }); + task.on('task:offerConsult', updateTaskList); - task.on('task:consulting', (task) => { - if (currentTask.data.interactionId === task.data.interactionId) { - // When we are consulting with the other agent - consultTransferBtn.style.display = 'inline-block'; // Show the consult transfer button - consultTransferBtn.disabled = false; // Enable the consult transfer button - } - }); + task.on('task:consultAccepted', updateTaskList); - task.on('task:consultQueueFailed', (task) => { - // When trying to consult queue fails - if (currentTask.data.interactionId === task.data.interactionId) { - console.error(`Received task:consultQueueFailed for task: ${task.data.interactionId}`); - hideEndConsultButton(); - showConsultButton(); - } - }); + task.on('task:consulting', updateTaskList); - task.on('task:consultQueueCancelled', (task) => { - if (currentTask.data.interactionId === task.data.interactionId) { - // When we manually cancel consult to queue before it is accepted by other agent - console.log(`Received task:consultQueueCancelled for task: ${currentTask.data.interactionId}`); - currentConsultQueueId = null; - hideEndConsultButton(); - showConsultButton(); - enableTransferControls(); - enableCallControlPostConsult(); - } - }); + task.on('task:consultQueueCancelled', updateTaskList); - task.on('task:consultEnd', (task) => { - if (currentTask.data.interactionId === task.data.interactionId) { - hideEndConsultButton(); - showConsultButton(); - enableTransferControls(); - enableCallControlPostConsult(); - consultTransferBtn.style.display = 'none'; - consultTransferBtn.disabled = true; - answerElm.disabled = true; - declineElm.disabled = true; - currentConsultQueueId = null; - // Clear consultation data and reset conference state when consult ends - consultationData = null; - isConferenceActive = false; - hasConferenceEnded = false; // Reset for next consultation - updateConferenceButtonState(); - if(task.data.isConsulted) { - updateButtonsPostEndCall(); - incomingDetailsElm.innerText = ''; - task = undefined; - } - } - }); - + task.on('task:consultEnd', updateTaskList); task.on('task:rejected', (reason) => { + updateTaskList(); console.info('Task is rejected with reason:', reason); showAgentStatePopup(reason); }); - task.on('task:wrappedup', task => { - currentTask = undefined; - updateTaskList(); // Update the task list UI to have latest tasks - }); + task.on('task:wrappedup', updateTaskList); // Update the task list UI to have latest tasks - // Conference event listeners - task.on('task:conferenceStarted', (task) => { - updateTaskList(); - showConsultButton(); - console.info('Conference started event received:', { - currentTaskId: currentTask?.data?.interactionId, - eventTaskId: task.data?.interactionId, - hasConsultationData: consultationData !== null - }); - - // Check if we have an active consultation (more reliable than interactionId matching) - if (consultationData !== null) { - console.info('Conference started successfully - updating UI'); - isConferenceActive = true; - updateConferenceButtonState(); - } + // Conference event listeners - Simplified approach + task.on('task:participantJoined', (task) => { + console.info('🚀 Conference started event - updating task list'); + updateTaskList(); // This will refresh currentTask and call updateCallControlUI with latest data }); - task.on('task:conferenceEnded', (task) => { - updateTaskList(); - showConsultButton(); - console.info('Conference ended event received:', { - currentTaskId: currentTask?.data?.interactionId, - eventTaskId: task.data?.interactionId, - hasConsultationData: consultationData !== null - }); - - // Check if we have an active consultation (more reliable than interactionId matching) - if (consultationData !== null && isConferenceActive) { - console.info('Conference ended successfully - updating UI'); - isConferenceActive = false; - hasConferenceEnded = true; // Mark that conference has been ended - updateConferenceButtonState(); - } + task.on('task:participantLeft', (task) => { + console.info('🔚 Conference ended event - updating task list'); + updateTaskList(); // This will refresh currentTask and call updateCallControlUI with latest data }); } @@ -1043,40 +1032,173 @@ function disableAllCallControls() { muteElm.disabled = true; pauseResumeRecordingElm.disabled = true; consultTabBtn.disabled = true; - declineElm.disabled = true; transferElm.disabled = true; endElm.disabled = true; pauseResumeRecordingElm.disabled = true; + conferenceToggleBtn.style.display = 'none'; + endConsultBtn.style.display = 'none'; + consultTransferBtn.style.display = 'none'; +} + +function makeDisabledAndHide(element, hide, disable) +{ + element.style.display = hide ? 'none' : 'inline-block'; + element.disabled = disable; +} + +/** + * Checks if the current agent is a secondary agent in a consultation scenario. + * Secondary agents are those who were consulted (not the original call owner). + * @param {Object} task - The task object containing interaction details + * @returns {boolean} True if this is a secondary agent (consulted party) + */ +function isSecondaryAgent(task) { + const interaction = task.data.interaction; + + return ( + interaction.callProcessingDetails.relationshipType === 'consult' && + interaction.callProcessingDetails.parentInteractionId && + interaction.callProcessingDetails.parentInteractionId !== interaction.interactionId + ); +} + +/** + * Checks if the current agent is a secondary EP-DN (Entry Point Dial Number) agent. + * This is specifically for telephony consultations to external numbers/entry points. + * @param {Object} task - The task object containing interaction details + * @returns {boolean} True if this is a secondary EP-DN agent in telephony consultation + */ +function isSecondaryEpDnAgent(task) { + return task.data.interaction.mediaType === 'telephony' && isSecondaryAgent(task); +} + +function getConsultMPCState(task, agentId) { + const interaction = task.data.interaction; + if ( + !!task.data.consultMediaResourceId && + !!interaction.participants[agentId]?.consultState && + task.data.interaction.state !== 'wrapUp' && + task.data.interaction.state !== 'post_call' // If interaction.state is post_call, we want to return post_call. + ) { + // interaction state for all agents when consult is going on + switch (interaction.participants[agentId]?.consultState) { + case 'consultInitiated': + return 'consult'; + case 'consultCompleted': + return interaction.state === 'connected' ? 'connected' : 'consultCompleted'; + case 'conferencing': + return 'conference'; + default: + return 'consulting'; + } + } + + return interaction?.state; +} + +function getTaskStatus(task, agentId) { + const interaction = task.data.interaction; + if (isSecondaryEpDnAgent(task)) { + if (interaction.state === 'conference') { + return 'conference'; + } + return 'consulting'; // handle state of child agent case as we cant rely on interaction state. + } + if ( + (task.data.interaction.state === 'wrapUp' || + task.data.interaction.state === 'post_call') && + interaction.participants[agentId]?.consultState === 'consultCompleted' + ) { + return 'consultCompleted'; + } + + return getConsultMPCState(task, agentId); +} + +function getConsultStatus(task) { + if (!task || !task.data) { + return 'No consultation in progress'; + } + + const state = getTaskStatus(task, agentId); + + const { interaction } = task.data; + const taskState = interaction?.state; + const participants = interaction?.participants || {}; + const participant = Object.values(participants).find(p => p.pType === 'Agent' && p.id === agentId); + + if (state === 'consult') { + if ((participant && participant.isConsulted )|| isSecondaryEpDnAgent(task)) { + return 'beingConsulted'; + } + return 'consultInitiated'; + } else if (state === 'consulting') { + if ((participant && participant.isConsulted) || isSecondaryEpDnAgent(task)) { + return 'beingConsultedAccepted'; + } + return 'consultAccepted'; + } else if (state === 'connected') { + return 'connected'; + } else if (state === 'conference') { + return 'conference'; + } else if (state === 'consultCompleted') { + return taskState; + } } function updateCallControlUI(task) { const { data } = task; const { interaction, mediaResourceId } = data; - const { - isTerminated, - media, - participants, - callProcessingDetails - } = interaction; - + const { isTerminated, media, participants, callProcessingDetails } = interaction; + autoWrapupTimerElm.style.display = 'none'; - if (task.data.wrapUpRequired) { + participantListElm.style.display = 'none'; updateButtonsPostEndCall(); if (task.autoWrapup && task.autoWrapup.isRunning()) { startAutoWrapupTimer(task); } return; } + wrapupElm.disabled = true; wrapupCodesDropdownElm.disabled = true; const hasParticipants = Object.keys(participants).length > 1; - const isNew = task.data.interaction.state === 'new'; + const isNew = isIncomingTask(task, agentId); const digitalChannels = ['chat', 'email', 'social']; + const isBrowser = agentDeviceType === 'BROWSER'; + + // Element lookup map to avoid eval usage + const elementMap = { + 'holdResumeElm': holdResumeElm, + 'muteElm': muteElm, + 'pauseResumeRecordingElm': pauseResumeRecordingElm, + 'consultTabBtn': consultTabBtn, + 'declineElm': declineElm, + 'transferElm': transferElm, + 'endElm': endElm, + 'endConsultBtn': endConsultBtn, + 'consultTransferBtn': consultTransferBtn, + 'conferenceToggleBtn': conferenceToggleBtn + }; + + // Helper to set multiple controls at once + function setControls(configs) { + for (const [elmName, config] of Object.entries(configs)) { + const element = elementMap[elmName]; + if (element) { + makeDisabledAndHide(element, config.hide, config.disable); + } + } + } if (isNew) { disableAllCallControls(); - } else if (digitalChannels.includes(task.data.interaction.mediaType)) { + enableAnswerDeclineButtons(currentTask); + return; + } + + if (digitalChannels.includes(task.data.interaction.mediaType)) { holdResumeElm.disabled = true; muteElm.disabled = true; pauseResumeRecordingElm.disabled = true; @@ -1085,48 +1207,116 @@ function updateCallControlUI(task) { transferElm.disabled = false; endElm.disabled = !hasParticipants; pauseResumeRecordingElm.disabled = true; - } else if (task?.data?.interaction?.mediaType === 'telephony') { + return; + } + + if (task?.data?.interaction?.mediaType === 'telephony') { // hold/resume call - const isHold = media && media[mediaResourceId] && media[mediaResourceId].isHold; + const isHold = isInteractionOnHold(task); holdResumeElm.disabled = isTerminated; holdResumeElm.innerText = isHold ? 'Resume' : 'Hold'; - transferElm.disabled = false; + + // MPC: Hide transfer button in conference mode (Exit Conference replaces transfer) + if (task.data.isConferenceInProgress) { + transferElm.disabled = true; + transferElm.style.display = 'none'; + } else { + transferElm.disabled = false; + transferElm.style.display = 'inline-block'; + } + muteElm.disabled = false; endElm.disabled = !hasParticipants; - consultTabBtn.disabled = false; + pauseResumeRecordingElm.disabled = false; pauseResumeRecordingElm.innerText = 'Pause Recording'; if (callProcessingDetails) { - const { pauseResumeEnabled, isPaused } = callProcessingDetails; - - // pause/resume recording - // pauseResumeRecordingElm.disabled = !pauseResumeEnabled; // TODO: recheck after rajesh PR(https://github.com/webex/widgets/pull/427/files) and why it is undefined + const { isPaused } = callProcessingDetails; pauseResumeRecordingElm.innerText = isPaused === 'true' ? 'Resume Recording' : 'Pause Recording'; } + + const consultStatus = getConsultStatus(task, agentId); + console.log(`event {task.data.type} ${consultStatus}`); - // end consult, consult transfer buttons - const { consultMediaResourceId, destAgentId, destinationType } = data; - if (consultMediaResourceId && destAgentId && destinationType) { - const destination = participants[destAgentId]; - destinationTypeDropdown.value = destinationType; - consultDestinationInput.value = destination.dn; - - consultTabBtn.style.display = 'none'; - endConsultBtn.style.display = 'inline-block'; - consultTransferBtn.style.display = 'inline-block'; - - // Set consultationData for Agent 2 (consulted agent) so they can see conference button - if (!consultationData) { - consultationData = { - to: destAgentId, - destinationType: destinationType, - agentId: agentId // Current agent ID (Agent 2) - }; - hasConferenceEnded = false; // Reset for new consultation - console.log('Set consultationData for consulted agent:', consultationData); - updateConferenceButtonState(); // Update conference button visibility - } + // Check if we've reached the 7 participant limit + const activeAgentCount = getActiveAgentCount(task); + const hasReachedParticipantLimit = activeAgentCount >= 7; + + // Update consult button tooltip if disabled due to participant limit + if (hasReachedParticipantLimit) { + consultTabBtn.title = 'Maximum 7 participants allowed in conference'; + } else { + consultTabBtn.title = 'Initiate consultation with another agent'; + } + + updateConferenceButtonState(task, consultStatus === 'beingConsultedAccepted' || consultStatus === 'consultAccepted'); + + // Map consultStatus to control configs + const controlMap = { + beingConsulted: () => {}, // No changes + beingConsultedAccepted: () => setControls({ + 'holdResumeElm': { hide: true, disable: false }, + 'muteElm': { hide: false || !isBrowser, disable: false }, + 'pauseResumeRecordingElm': { hide: false, disable: true }, + 'consultTabBtn': { hide: true, disable: true }, + 'transferElm': { hide: true, disable: true }, + 'endElm': { hide: true, disable: true }, + 'endConsultBtn': { hide: false, disable: false }, + 'consultTransferBtn': { hide: true, disable: true }, + 'conferenceToggleBtn': { hide: true, disable: true }, + }), + consultInitiated: () => setControls({ + 'holdResumeElm': { hide: true, disable: false }, + 'muteElm': { hide: true, disable: false }, + 'pauseResumeRecordingElm': { hide: true, disable: false }, + 'consultTabBtn': { hide: true, disable: hasReachedParticipantLimit }, + 'transferElm': { hide: true, disable: false }, + 'endElm': { hide: false, disable: true }, // Disable end call during consultation + 'endConsultBtn': { hide: false, disable: false }, + 'consultTransferBtn': { hide: true, disable: true }, + 'conferenceToggleBtn': { hide: true, disable: true }, + }), + consultAccepted: () => setControls({ + 'holdResumeElm': { hide: true, disable: false }, + 'muteElm': { hide: false || !isBrowser, disable: false }, + 'pauseResumeRecordingElm': { hide: false, disable: true }, + 'consultTabBtn': { hide: true, disable: hasReachedParticipantLimit }, + 'transferElm': { hide: true, disable: false }, + 'endElm': { hide: true, disable: true }, // Disable end call during consultation + 'endConsultBtn': { hide: false, disable: false }, + 'consultTransferBtn': { hide: false, disable: false }, + 'conferenceToggleBtn': { hide: false, disable: false }, + }), + conference: () => setControls({ + 'consultTabBtn': { hide: false, disable: hasReachedParticipantLimit }, + 'transferElm': { hide: true, disable: false }, + 'endConsultBtn': { hide: true, disable: true }, + 'muteElm': { hide: false || !isBrowser, disable: false }, + 'pauseResumeRecordingElm': { hide: false, disable: false }, + 'holdResumeElm': { hide: false, disable: !isHold }, + 'endElm': { hide: false, disable: isHold || false }, // Allow end call in conference + 'consultTransferBtn': { hide: true, disable: true }, + 'conferenceToggleBtn': { hide: false, disable: false }, + }), + connected: () => setControls({ + 'consultTabBtn': { hide: false, disable: hasReachedParticipantLimit }, + 'transferElm': { hide: false, disable: false }, + 'endConsultBtn': { hide: true, disable: true }, + 'muteElm': { hide: false || !isBrowser, disable: false }, + 'pauseResumeRecordingElm': { hide: false, disable: false }, + 'holdResumeElm': { hide: false, disable: false }, + 'endElm': { hide: false, disable: isHold || false }, + 'consultTransferBtn': { hide: true, disable: true }, + 'conferenceToggleBtn': { hide: true, disable: true }, + }) + }; + + if (consultStatus && controlMap[consultStatus]) { + controlMap[consultStatus](); } + + // MPC: Update participant list display + updateParticipantList(task); } } @@ -1281,6 +1471,7 @@ function register() { agentId = agentProfile.agentId; agentName = agentProfile.agentName; wrapupCodes = agentProfile.wrapupCodes; + agentDeviceType = agentProfile.deviceType; populateWrapupCodesDropdown(); outdialANIId = agentProfile.outdialANIId; loadOutdialAniEntries(agentProfile.outdialANIId).catch(error => { @@ -1338,15 +1529,12 @@ function register() { } }); entryPointId = agentProfile.outDialEp; - updateTaskList(); - }).catch((error) => { - console.error('Event subscription failed', error); - }) - - webex.cc.on('task:incoming', (task) => { - taskEvents.detail.task = task; - incomingCallListener.dispatchEvent(taskEvents); - }); + webex.cc.on('task:incoming', (task) => { + console.log('Incoming task received: ', task); + updateTaskList(); + taskId = task.data.interactionId; + registerTaskListeners(currentTask); + }); webex.cc.on('task:hydrate', (currentTask) => { handleTaskHydrate(currentTask); @@ -1408,6 +1596,10 @@ function register() { idleCodesDropdown.selectedIndex = idx >= 0 ? idx : 0; startStateTimer(data.lastStateChangeTimestamp, data.lastIdleCodeChangeTimestamp); }); + updateTaskList(); + }).catch((error) => { + console.error('Event subscription failed', error); + }) } // New function to handle unregistration @@ -1685,7 +1877,6 @@ incomingCallListener.addEventListener('task:incoming', (event) => { declineElm.disabled = true; await currentTask.accept(); updateTaskList(); - handleTaskSelect(currentTask); incomingDetailsElm.innerText = 'Task Accepted'; } @@ -1727,6 +1918,13 @@ if (window.location.hash) { if (accessToken) { sessionStorage.setItem('access-token', accessToken); sessionStorage.setItem('date', new Date().getTime() + parseInt(expiresIn, 10)); + tokenElm.disabled = true; + saveElm.disabled = true; + authStatusElm.innerText = 'Saved access token!'; + registerStatus.innerHTML = 'Not Subscribed'; + registerBtn.disabled = false; + // Dynamically add the IMI Engage controller bundle script + initializeEngageWidget(); tokenElm.value = accessToken; } } @@ -1759,21 +1957,15 @@ function holdResumeCall() { holdResumeElm.disabled = true; currentTask.hold().then(() => { console.info('Call held successfully'); - holdResumeElm.innerText = 'Resume'; - holdResumeElm.disabled = false; }).catch((error) => { console.error('Failed to hold the call', error); - holdResumeElm.disabled = false; }); } else { holdResumeElm.disabled = true; currentTask.resume().then(() => { console.info('Call resumed successfully'); - holdResumeElm.innerText = 'Hold'; - holdResumeElm.disabled = false; }).catch((error) => { console.error('Failed to resume the call', error); - holdResumeElm.disabled = false; }); } } @@ -1821,7 +2013,6 @@ function endCall() { endElm.disabled = true; currentTask.end().then(() => { console.log('task ended successfully by agent'); - updateButtonsPostEndCall(); updateTaskList(); updateUnregisterButtonState(); }).catch((error) => { @@ -1898,6 +2089,7 @@ function renderTaskList(taskList) { taskListContainer.innerHTML = '

No tasks available

'; engageElm.innerHTML = ``; currentTask = undefined; + participantListElm.style.display = 'none'; return; } @@ -1932,9 +2124,9 @@ function renderTaskList(taskList) { const callerDisplay = task.data.interaction.callAssociatedDetails?.ani; // Determine task properties - const isNew = task.data.interaction.state === 'new'; + const isNew = isIncomingTask(task, agentId); const isTelephony = task.data.interaction.mediaType === 'telephony'; - const isBrowserPhone = webex.cc.taskManager.webCallingService.loginOption === 'BROWSER'; + const isBrowserPhone = agentDeviceType === 'BROWSER'; // Determine which buttons to show const showAcceptButton = isNew && (isBrowserPhone || !isTelephony); @@ -2015,10 +2207,10 @@ function renderTaskList(taskList) { function enableAnswerDeclineButtons(task) { const callerDisplay = task.data.interaction?.callAssociatedDetails?.ani; - const isNew = task.data.interaction.state === 'new' - const chatAndSocial = ['chat', 'social']; + const isNew = isIncomingTask(task, agentId); + const chatAndSocial = ['chat', 'social']; if (task.data.interaction.mediaType === 'telephony') { - if (webex.cc.taskManager.webCallingService.loginOption === 'BROWSER') { + if (agentDeviceType === 'BROWSER') { answerElm.disabled = !isNew; declineElm.disabled = !isNew; @@ -2114,3 +2306,4 @@ updateLoginOptionElm.addEventListener('change', updateApplyButtonState); updateDialNumberElm.addEventListener('input', updateApplyButtonState); updateApplyButtonState(); + diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index 6e2b06a0f47..e975b8bf0a5 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -184,6 +184,7 @@

+
Remote Audio diff --git a/packages/@webex/contact-center/src/cc.ts b/packages/@webex/contact-center/src/cc.ts index 39ddac36450..8cbe43fb0aa 100644 --- a/packages/@webex/contact-center/src/cc.ts +++ b/packages/@webex/contact-center/src/cc.ts @@ -396,6 +396,16 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter this.trigger(TASK_EVENTS.TASK_HYDRATE, task); }; + /** + * Handles task merged events when tasks are combined eg: EPDN merge/transfer + * @private + * @param {ITask} task The task object that has been merged + */ + private handleTaskMerged = (task: ITask) => { + // @ts-ignore + this.trigger(TASK_EVENTS.TASK_MERGED, task); + }; + /** * Sets up event listeners for incoming tasks and task hydration * Subscribes to task events from the task manager @@ -404,6 +414,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter private incomingTaskListener() { this.taskManager.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); this.taskManager.on(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + this.taskManager.on(TASK_EVENTS.TASK_MERGED, this.handleTaskMerged); } /** @@ -784,7 +795,7 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter throw error; } - const loginResponse = this.services.agent.stationLogin({ + const loginResponse = await this.services.agent.stationLogin({ data: { dialNumber: data.loginOption === LoginOption.BROWSER ? this.agentConfig.agentId : data.dialNumber, diff --git a/packages/@webex/contact-center/src/index.ts b/packages/@webex/contact-center/src/index.ts index ba502c6ae1c..77aae74a4b7 100644 --- a/packages/@webex/contact-center/src/index.ts +++ b/packages/@webex/contact-center/src/index.ts @@ -126,6 +126,7 @@ export type { AgentContact, /** Task interface */ ITask, + Interaction, TaskData, /** Task response */ TaskResponse, diff --git a/packages/@webex/contact-center/src/services/config/types.ts b/packages/@webex/contact-center/src/services/config/types.ts index b6b07c66a1c..8ab9e4c961e 100644 --- a/packages/@webex/contact-center/src/services/config/types.ts +++ b/packages/@webex/contact-center/src/services/config/types.ts @@ -63,8 +63,6 @@ export const CC_TASK_EVENTS = { AGENT_CONFERENCE_TRANSFERRED: 'AgentConferenceTransferred', /** Event emitted when conference transfer fails */ AGENT_CONFERENCE_TRANSFER_FAILED: 'AgentConferenceTransferFailed', - /** Event emitted when consulted participant is moving/being transferred */ - CONSULTED_PARTICIPANT_MOVING: 'ConsultedParticipantMoving', /** Event emitted for post-call activity by participant */ PARTICIPANT_POST_CALL_ACTIVITY: 'ParticipantPostCallActivity', /** Event emitted when contact is blind transferred */ @@ -91,6 +89,8 @@ export const CC_TASK_EVENTS = { CONTACT_RECORDING_RESUME_FAILED: 'ContactRecordingResumeFailed', /** Event emitted when contact ends */ CONTACT_ENDED: 'ContactEnded', + /** Event emitted when contact is merged */ + CONTACT_MERGED: 'ContactMerged', /** Event emitted when ending contact fails */ AGENT_CONTACT_END_FAILED: 'AgentContactEndFailed', /** Event emitted when agent enters wrap-up state */ diff --git a/packages/@webex/contact-center/src/services/core/Utils.ts b/packages/@webex/contact-center/src/services/core/Utils.ts index 3e182007425..8e439607933 100644 --- a/packages/@webex/contact-center/src/services/core/Utils.ts +++ b/packages/@webex/contact-center/src/services/core/Utils.ts @@ -6,11 +6,10 @@ import WebexRequest from './WebexRequest'; import { TaskData, ConsultTransferPayLoad, - ConsultConferenceData, - consultConferencePayloadData, CONSULT_TRANSFER_DESTINATION_TYPE, Interaction, } from '../task/types'; +import {PARTICIPANT_TYPES, STATE_CONSULT} from './constants'; /** * Extracts common error details from a Webex request payload. @@ -218,59 +217,118 @@ export const createErrDetailsObject = (errObj: WebexRequestPayload) => { }; /** - * Derives the consult transfer destination type based on the provided task data. + * Gets the consulted agent ID from the media object by finding the agent + * in the consult media participants (excluding the current agent). * - * Logic parity with desktop behavior: - * - If agent action is dialing a number (DN/EPDN/ENTRYPOINT): - * - ENTRYPOINT/EPDN map to ENTRYPOINT - * - DN maps to DIALNUMBER - * - Otherwise defaults to AGENT + * @param media - The media object from the interaction + * @param agentId - The current agent's ID to exclude from the search + * @returns The consulted agent ID, or empty string if none found + */ +export const getConsultedAgentId = (media: Interaction['media'], agentId: string): string => { + let consultParticipants: string[] = []; + let consultedParticipantId = ''; + + Object.keys(media).forEach((key) => { + if (media[key].mType === STATE_CONSULT) { + consultParticipants = media[key].participants; + } + }); + + if (consultParticipants.includes(agentId)) { + const id = consultParticipants.find((participant) => participant !== agentId); + consultedParticipantId = id || consultedParticipantId; + } + + return consultedParticipantId; +}; + +/** + * Gets the destination agent ID for CBT (Capacity Based Team) scenarios. + * CBT refers to teams created in Control Hub with capacity-based routing + * (as opposed to agent-based routing). This handles cases where the consulted + * participant is not directly in participants but can be found by matching + * the dial number (dn). * - * @param taskData - The task data used to infer the agent action and destination type - * @returns The normalized destination type to be used for consult transfer + * @param interaction - The interaction object + * @param consultingAgent - The consulting agent identifier + * @returns The destination agent ID for CBT scenarios, or empty string if none found */ +export const getDestAgentIdForCBT = (interaction: Interaction, consultingAgent: string): string => { + const participants = interaction.participants; + let destAgentIdForCBT = ''; + + // Check if this is a CBT scenario (consultingAgent exists but not directly in participants) + if (consultingAgent && !participants[consultingAgent]) { + const foundEntry = Object.entries(participants).find( + ([, participant]: [string, Interaction['participants'][string]]) => { + return ( + participant.pType.toLowerCase() === PARTICIPANT_TYPES.DN && + participant.type === PARTICIPANT_TYPES.AGENT && + participant.dn === consultingAgent + ); + } + ); + + if (foundEntry) { + destAgentIdForCBT = foundEntry[0]; + } + } + + return destAgentIdForCBT; +}; + /** - * Checks if a participant type represents a non-customer participant. - * Non-customer participants include agents, dial numbers, entry point dial numbers, - * and entry points. + * Calculates the destination agent ID for consult operations. + * + * @param interaction - The interaction object + * @param agentId - The current agent's ID + * @returns The destination agent ID */ -const isNonCustomerParticipant = (participantType: string): boolean => { - return ( - participantType === 'Agent' || - participantType === 'DN' || - participantType === 'EpDn' || - participantType === 'entryPoint' - ); +export const calculateDestAgentId = (interaction: Interaction, agentId: string): string => { + const consultingAgent = getConsultedAgentId(interaction.media, agentId); + + // Check if this is a CBT (Capacity Based Team) scenario + // If not CBT, the function will return empty string and we'll use the normal flow + const destAgentIdCBT = getDestAgentIdForCBT(interaction, consultingAgent); + if (destAgentIdCBT) { + return destAgentIdCBT; + } + + return interaction.participants[consultingAgent]?.type === PARTICIPANT_TYPES.EP_DN + ? interaction.participants[consultingAgent]?.epId + : interaction.participants[consultingAgent]?.id; }; /** - * Gets the destination agent ID from participants data by finding the first - * non-customer participant that is not the current agent and is not in wrap-up state. + * Calculates the destination agent ID for fetching destination type. * - * @param participants - The participants data from the interaction - * @param agentId - The current agent's ID to exclude from the search - * @returns The destination agent ID, or empty string if none found + * @param interaction - The interaction object + * @param agentId - The current agent's ID + * @returns The destination agent ID for determining destination type */ -export const getDestinationAgentId = ( - participants: Interaction['participants'], - agentId: string -): string => { - let id = ''; - - if (participants) { - Object.keys(participants).forEach((participant) => { - const participantData = participants[participant]; - if ( - isNonCustomerParticipant(participantData.type) && - participantData.id !== agentId && - !participantData.isWrapUp - ) { - id = participantData.id; - } - }); +export const calculateDestType = (interaction: Interaction, agentId: string): string => { + const consultingAgent = getConsultedAgentId(interaction.media, agentId); + + // Check if this is a CBT (Capacity Based Team) scenario, otherwise use consultingAgent + const destAgentIdCBT = getDestAgentIdForCBT(interaction, consultingAgent); + const destinationaegntId = destAgentIdCBT || consultingAgent; + const destAgentType = destinationaegntId + ? interaction.participants[destinationaegntId]?.pType + : undefined; + if (destAgentType) { + if (destAgentType === 'DN') { + return CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER; + } + if (destAgentType === 'EP-DN') { + return CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT; + } + // Keep the existing destinationType if it's something else (like "agent" or "Agent") + // Convert "Agent" to lowercase for consistency + + return destAgentType.toLowerCase(); } - return id; + return CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; }; export const deriveConsultTransferDestinationType = ( @@ -286,45 +344,3 @@ export const deriveConsultTransferDestinationType = ( return CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; }; - -/** - * Builds consult conference parameter data using EXACT Agent Desktop logic. - * This matches the Agent Desktop's consultConference implementation exactly. - * - * @param dataPassed - Original consultation data from Agent Desktop format - * @param interactionIdPassed - The interaction ID for the task - * @returns Object with interactionId and ConsultConferenceData matching Agent Desktop format - * @public - */ -export const buildConsultConferenceParamData = ( - dataPassed: consultConferencePayloadData, - interactionIdPassed: string -): {interactionId: string; data: ConsultConferenceData} => { - const data: ConsultConferenceData = { - // Include agentId if present in input data - ...('agentId' in dataPassed && {agentId: dataPassed.agentId}), - // Handle destAgentId from consultation data - to: dataPassed.destAgentId, - destinationType: '', - }; - - // Agent Desktop destination type logic - if ('destinationType' in dataPassed) { - if (dataPassed.destinationType === 'DN') { - data.destinationType = CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER; - } else if (dataPassed.destinationType === 'EP_DN') { - data.destinationType = CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT; - } else { - // Keep the existing destinationType if it's something else (like "agent" or "Agent") - // Convert "Agent" to lowercase for consistency - data.destinationType = dataPassed.destinationType.toLowerCase(); - } - } else { - data.destinationType = CONSULT_TRANSFER_DESTINATION_TYPE.AGENT; - } - - return { - interactionId: interactionIdPassed, - data, - }; -}; diff --git a/packages/@webex/contact-center/src/services/core/constants.ts b/packages/@webex/contact-center/src/services/core/constants.ts index 61505380812..409ba1e76aa 100644 --- a/packages/@webex/contact-center/src/services/core/constants.ts +++ b/packages/@webex/contact-center/src/services/core/constants.ts @@ -64,6 +64,22 @@ export const CONNECTIVITY_CHECK_INTERVAL = 5000; */ export const CLOSE_SOCKET_TIMEOUT = 16000; +/** + * Constants for participant types, destination types, and interaction states + * @ignore + */ +export const PARTICIPANT_TYPES = { + /** Participant type for Entry Point Dial Number */ + EP_DN: 'EpDn', + /** Participant type for dial number */ + DN: 'dn', + /** Participant type for Agent */ + AGENT: 'Agent', +}; + +/** Interaction state for consultation */ +export const STATE_CONSULT = 'consult'; + // Method names for core services export const METHODS = { // WebexRequest methods diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index 18fef740702..35db7ce8e56 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -12,6 +12,13 @@ import LoggerProxy from '../../logger-proxy'; import Task from '.'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; +import { + checkParticipantNotInInteraction, + getIsConferenceInProgress, + isParticipantInMainInteraction, + isPrimary, + isSecondaryEpDnAgent, +} from './TaskUtils'; /** @internal */ export default class TaskManager extends EventEmitter { @@ -122,14 +129,15 @@ export default class TaskManager extends EventEmitter { method: METHODS.REGISTER_TASK_LISTENERS, interactionId: payload.data.interactionId, }); + task = new Task( this.contact, this.webCallingService, { ...payload.data, wrapUpRequired: - payload.data.interaction?.participants?.[payload.data.agentId]?.isWrapUp || - false, + payload.data.interaction?.participants?.[this.agentId]?.isWrapUp || false, + isConferenceInProgress: getIsConferenceInProgress(payload.data), }, this.wrapupData, this.agentId @@ -160,6 +168,7 @@ export default class TaskManager extends EventEmitter { } } break; + case CC_EVENTS.AGENT_CONTACT_RESERVED: task = new Task( this.contact, @@ -239,13 +248,22 @@ export default class TaskManager extends EventEmitter { break; } case CC_EVENTS.CONTACT_ENDED: + // Update task data task = this.updateTaskData(task, { ...payload.data, - wrapUpRequired: payload.data.interaction.state !== 'new', + wrapUpRequired: + payload.data.interaction.state !== 'new' && + !isSecondaryEpDnAgent(payload.data.interaction), }); + + // Handle cleanup based on whether task should be deleted this.handleTaskCleanup(task); - task.emit(TASK_EVENTS.TASK_END, task); + task?.emit(TASK_EVENTS.TASK_END, task); + + break; + case CC_EVENTS.CONTACT_MERGED: + task = this.handleContactMerged(task, payload.data); break; case CC_EVENTS.AGENT_CONTACT_HELD: // As soon as the main interaction is held, we need to emit TASK_HOLD @@ -358,18 +376,45 @@ export default class TaskManager extends EventEmitter { case CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED: // Conference ended - update task state and emit event task = this.updateTaskData(task, payload.data); + if ( + !task || + isPrimary(task, this.agentId) || + isParticipantInMainInteraction(task, this.agentId) + ) { + LoggerProxy.log('Primary or main interaction participant leaving conference'); + } else { + this.removeTaskFromCollection(task); + } task.emit(TASK_EVENTS.TASK_CONFERENCE_ENDED, task); break; - case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE: - // Participant joined conference - update task state with participant information and emit event - task = this.updateTaskData(task, payload.data); + case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE: { + task = this.updateTaskData(task, { + ...payload.data, + isConferenceInProgress: getIsConferenceInProgress(payload.data), + }); task.emit(TASK_EVENTS.TASK_PARTICIPANT_JOINED, task); break; - case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE: + } + case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE: { // Conference ended - update task state and emit event - task = this.updateTaskData(task, payload.data); + + task = this.updateTaskData(task, { + ...payload.data, + isConferenceInProgress: getIsConferenceInProgress(payload.data), + }); + if (checkParticipantNotInInteraction(task, this.agentId)) { + if ( + isParticipantInMainInteraction(task, this.agentId) || + isPrimary(task, this.agentId) + ) { + LoggerProxy.log('Primary or main interaction participant leaving conference'); + } else { + this.removeTaskFromCollection(task); + } + } task.emit(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); break; + } case CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED: // Conference exit failed - update task state and emit failure event task = this.updateTaskData(task, payload.data); @@ -391,13 +436,10 @@ export default class TaskManager extends EventEmitter { task = this.updateTaskData(task, payload.data); task.emit(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, task); break; - case CC_EVENTS.CONSULTED_PARTICIPANT_MOVING: - // Participant is being moved/transferred - update task state with movement info - task = this.updateTaskData(task, payload.data); - break; case CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY: // Post-call activity for participant - update task state with activity details task = this.updateTaskData(task, payload.data); + task.emit(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, task); break; default: break; @@ -437,6 +479,54 @@ export default class TaskManager extends EventEmitter { } } + /** + * Handles CONTACT_MERGED event logic + * @param task - The task to process + * @param taskData - The task data from the event payload + * @returns Updated or newly created task + * @private + */ + private handleContactMerged(task: ITask, taskData: TaskData): ITask { + if (taskData.childInteractionId) { + // remove the child task from collection + this.removeTaskFromCollection(this.taskCollection[taskData.childInteractionId]); + } + + if (this.taskCollection[taskData.interactionId]) { + LoggerProxy.log(`Got CONTACT_MERGED: Task already exists in collection`, { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: taskData.interactionId, + }); + // update the task data + task = this.updateTaskData(task, taskData); + } else { + // Case2 : Task is not present in taskCollection + LoggerProxy.log(`Got CONTACT_MERGED : Creating new task in taskManager`, { + module: TASK_MANAGER_FILE, + method: METHODS.REGISTER_TASK_LISTENERS, + interactionId: taskData.interactionId, + }); + + task = new Task( + this.contact, + this.webCallingService, + { + ...taskData, + wrapUpRequired: taskData.interaction?.participants?.[this.agentId]?.isWrapUp || false, + isConferenceInProgress: getIsConferenceInProgress(taskData), + }, + this.wrapupData, + this.agentId + ); + this.taskCollection[taskData.interactionId] = task; + } + + this.emit(TASK_EVENTS.TASK_MERGED, task); + + return task; + } + private removeTaskFromCollection(task: ITask) { if (task?.data?.interactionId) { delete this.taskCollection[task.data.interactionId]; @@ -448,7 +538,13 @@ export default class TaskManager extends EventEmitter { } } + /** + * Handles cleanup of task resources including Desktop/WebRTC call cleanup and task removal + * @param task - The task to clean up + * @private + */ private handleTaskCleanup(task: ITask) { + // Clean up Desktop/WebRTC calling resources for browser-based telephony tasks if ( this.webCallingService.loginOption === LoginOption.BROWSER && task.data.interaction.mediaType === 'telephony' @@ -456,8 +552,9 @@ export default class TaskManager extends EventEmitter { task.unregisterWebCallListeners(); this.webCallingService.cleanUpCall(); } - if (task.data.interaction.state === 'new') { - // Only remove tasks in 'new' state immediately. For other states, + + if (task.data.interaction.state === 'new' || isSecondaryEpDnAgent(task.data.interaction)) { + // Only remove tasks in 'new' state or isSecondaryEpDnAgent immediately. For other states, // retain tasks until they complete wrap-up, unless the task disconnected before being answered. this.removeTaskFromCollection(task); } diff --git a/packages/@webex/contact-center/src/services/task/TaskUtils.ts b/packages/@webex/contact-center/src/services/task/TaskUtils.ts new file mode 100644 index 00000000000..e59fd5b488e --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/TaskUtils.ts @@ -0,0 +1,113 @@ +/* eslint-disable import/prefer-default-export */ +import {Interaction, ITask, TaskData} from './types'; + +/** + * Determines if the given agent is the primary agent (owner) of the task + * @param task - The task to check + * @param agentId - The agent ID to check for primary status + * @returns true if the agent is the primary agent, false otherwise + */ +export const isPrimary = (task: ITask, agentId: string): boolean => { + if (!task.data?.interaction?.owner) { + // Fall back to checking data.agentId when owner is not set + return task.data.agentId === agentId; + } + + return task.data.interaction.owner === agentId; +}; + +/** + * Checks if the given agent is a participant in the main interaction (mainCall) + * @param task - The task to check + * @param agentId - The agent ID to check for participation + * @returns true if the agent is a participant in the main interaction, false otherwise + */ +export const isParticipantInMainInteraction = (task: ITask, agentId: string): boolean => { + if (!task?.data?.interaction?.media) { + return false; + } + + return Object.values(task.data.interaction.media).some( + (mediaObj) => + mediaObj && mediaObj.mType === 'mainCall' && mediaObj.participants?.includes(agentId) + ); +}; + +/** + * Checks if the given agent is not in the interaction or has left the interaction + * @param task - The task to check + * @param agentId - The agent ID to check + * @returns true if the agent is not in the interaction or has left, false otherwise + */ +export const checkParticipantNotInInteraction = (task: ITask, agentId: string): boolean => { + if (!task?.data?.interaction?.participants) { + return true; + } + const {data} = task; + + return ( + !(agentId in data.interaction.participants) || + (agentId in data.interaction.participants && data.interaction.participants[agentId].hasLeft) + ); +}; + +/** + * Determines if a conference is currently in progress based on the number of active agent participants + * @param TaskData - The payLoad data to check for conference status + * @returns true if there are 2 or more active agent participants in the main call, false otherwise + */ +export const getIsConferenceInProgress = (data: TaskData): boolean => { + const mediaMainCall = data.interaction.media?.[data?.interactionId]; + const participantsInMainCall = new Set(mediaMainCall?.participants); + const participants = data.interaction.participants; + + const agentParticipants = new Set(); + if (participantsInMainCall.size > 0) { + participantsInMainCall.forEach((participantId: string) => { + const participant = participants?.[participantId]; + if ( + participant && + participant.pType !== 'Customer' && + participant.pType !== 'Supervisor' && + !participant.hasLeft && + participant.pType !== 'VVA' + ) { + agentParticipants.add(participantId); + } + }); + } + + return agentParticipants.size >= 2; +}; + +/** + * Checks if the current agent is a secondary agent in a consultation scenario. + * Secondary agents are those who were consulted (not the original call owner). + * @param task - The task object containing interaction details + * @returns true if this is a secondary agent (consulted party), false otherwise + */ +export const isSecondaryAgent = (interaction: Interaction): boolean => { + if (!interaction.callProcessingDetails) { + return false; + } + + return ( + interaction.callProcessingDetails.relationshipType === 'consult' && + !!interaction.callProcessingDetails.parentInteractionId && + interaction.callProcessingDetails.parentInteractionId !== interaction.interactionId + ); +}; + +/** + * Checks if the current agent is a secondary EP-DN (Entry Point Dial Number) agent. + * This is specifically for telephony consultations to external numbers/entry points. + * @param task - The task object containing interaction details + * @returns true if this is a secondary EP-DN agent in telephony consultation, false otherwise + */ +export const isSecondaryEpDnAgent = (interaction: Interaction): boolean => { + if (!interaction) { + return false; + } + + return interaction.mediaType === 'telephony' && isSecondaryAgent(interaction); +}; diff --git a/packages/@webex/contact-center/src/services/task/constants.ts b/packages/@webex/contact-center/src/services/task/constants.ts index 4bc085a1282..53a79177bba 100644 --- a/packages/@webex/contact-center/src/services/task/constants.ts +++ b/packages/@webex/contact-center/src/services/task/constants.ts @@ -23,6 +23,25 @@ export const CONFERENCE_TRANSFER = '/conference/transfer'; export const TASK_MANAGER_FILE = 'taskManager'; export const TASK_FILE = 'task'; +/** + * Task data field names that should be preserved during reconciliation + * These fields are retained even if not present in new data during updates + */ +export const PRESERVED_TASK_DATA_FIELDS = { + /** Indicates if the task is in consultation state */ + IS_CONSULTED: 'isConsulted', + /** Indicates if wrap-up is required for this task */ + WRAP_UP_REQUIRED: 'wrapUpRequired', + /** Indicates if a conference is currently in progress (2+ active agents) */ + IS_CONFERENCE_IN_PROGRESS: 'isConferenceInProgress', +}; + +/** + * Array of task data field names that should not be deleted during reconciliation + * Used by reconcileData method to preserve important task state fields + */ +export const KEYS_TO_NOT_DELETE: string[] = Object.values(PRESERVED_TASK_DATA_FIELDS); + // METHOD NAMES export const METHODS = { // Task class methods diff --git a/packages/@webex/contact-center/src/services/task/index.ts b/packages/@webex/contact-center/src/services/task/index.ts index d79db77d5d6..2ea33055e8c 100644 --- a/packages/@webex/contact-center/src/services/task/index.ts +++ b/packages/@webex/contact-center/src/services/task/index.ts @@ -1,16 +1,11 @@ import EventEmitter from 'events'; import {CALL_EVENT_KEYS, LocalMicrophoneStream} from '@webex/calling'; import {CallId} from '@webex/calling/dist/types/common/types'; -import { - generateTaskErrorObject, - deriveConsultTransferDestinationType, - getDestinationAgentId, - buildConsultConferenceParamData, -} from '../core/Utils'; +import {generateTaskErrorObject, calculateDestAgentId, calculateDestType} from '../core/Utils'; import {Failure} from '../core/GlobalTypes'; import {LoginOption} from '../../types'; import {TASK_FILE} from '../../constants'; -import {METHODS} from './constants'; +import {METHODS, KEYS_TO_NOT_DELETE} from './constants'; import routingContact from './contact'; import LoggerProxy from '../../logger-proxy'; import { @@ -281,9 +276,24 @@ export default class Task extends EventEmitter implements ITask { * @private */ private reconcileData(oldData: TaskData, newData: TaskData): TaskData { + // Remove keys from oldData that are not in newData + Object.keys(oldData).forEach((key) => { + if (!(key in newData) && !KEYS_TO_NOT_DELETE.includes(key as string)) { + delete oldData[key]; + } + }); + + // Merge or update keys from newData Object.keys(newData).forEach((key) => { - if (newData[key] && typeof newData[key] === 'object' && !Array.isArray(newData[key])) { - oldData[key] = this.reconcileData({...oldData[key]}, newData[key]); + if ( + newData[key] && + typeof newData[key] === 'object' && + !Array.isArray(newData[key]) && + oldData[key] && + typeof oldData[key] === 'object' && + !Array.isArray(oldData[key]) + ) { + this.reconcileData(oldData[key], newData[key]); } else { oldData[key] = newData[key]; } @@ -511,6 +521,7 @@ export default class Task extends EventEmitter implements ITask { * Puts the current task/interaction on hold. * Emits task:hold event when successful. For voice tasks, this mutes the audio. * + * @param mediaResourceId - Optional media resource ID to use for the hold operation. If not provided, uses the task's current mediaResourceId * @returns Promise * @throws Error if hold operation fails * @example @@ -531,9 +542,17 @@ export default class Task extends EventEmitter implements ITask { * console.error('Failed to place task on hold:', error); * // Handle error (e.g., show error message, reset UI state) * } + * + * // Place task on hold with custom mediaResourceId + * try { + * await task.hold('custom-media-resource-id'); + * console.log('Successfully placed task on hold with custom mediaResourceId'); + * } catch (error) { + * console.error('Failed to place task on hold:', error); + * } * ``` */ - public async hold(): Promise { + public async hold(mediaResourceId?: string): Promise { try { LoggerProxy.info(`Holding task`, { module: TASK_FILE, @@ -546,9 +565,11 @@ export default class Task extends EventEmitter implements ITask { METRIC_EVENT_NAMES.TASK_HOLD_FAILED, ]); + const effectiveMediaResourceId = mediaResourceId ?? this.data.mediaResourceId; + const response = await this.contact.hold({ interactionId: this.data.interactionId, - data: {mediaResourceId: this.data.mediaResourceId}, + data: {mediaResourceId: effectiveMediaResourceId}, }); this.metricsManager.trackEvent( @@ -556,7 +577,7 @@ export default class Task extends EventEmitter implements ITask { { ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), taskId: this.data.interactionId, - mediaResourceId: this.data.mediaResourceId, + mediaResourceId: effectiveMediaResourceId, }, ['operational', 'behavioral'] ); @@ -578,11 +599,13 @@ export default class Task extends EventEmitter implements ITask { errorData: err.data?.errorData, reasonCode: err.data?.reasonCode, }; + const effectiveMediaResourceId = mediaResourceId ?? this.data.mediaResourceId; + this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_HOLD_FAILED, { taskId: this.data.interactionId, - mediaResourceId: this.data.mediaResourceId, + mediaResourceId: effectiveMediaResourceId, error: error.toString(), ...taskErrorProps, ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), @@ -597,6 +620,7 @@ export default class Task extends EventEmitter implements ITask { * Resumes the task/interaction that was previously put on hold. * Emits task:resume event when successful. For voice tasks, this restores the audio. * + * @param mediaResourceId - Optional media resource ID to use for the resume operation. If not provided, uses the task's current mediaResourceId from interaction media * @returns Promise * @throws Error if resume operation fails * @example @@ -617,9 +641,17 @@ export default class Task extends EventEmitter implements ITask { * console.error('Failed to resume task:', error); * // Handle error (e.g., show error message) * } + * + * // Resume task from hold with custom mediaResourceId + * try { + * await task.resume('custom-media-resource-id'); + * console.log('Successfully resumed task from hold with custom mediaResourceId'); + * } catch (error) { + * console.error('Failed to resume task:', error); + * } * ``` */ - public async resume(): Promise { + public async resume(mediaResourceId?: string): Promise { try { LoggerProxy.info(`Resuming task`, { module: TASK_FILE, @@ -627,7 +659,9 @@ export default class Task extends EventEmitter implements ITask { interactionId: this.data.interactionId, }); const {mainInteractionId} = this.data.interaction; - const {mediaResourceId} = this.data.interaction.media[mainInteractionId]; + const defaultMediaResourceId = + this.data.interaction.media[mainInteractionId]?.mediaResourceId; + const effectiveMediaResourceId = mediaResourceId ?? defaultMediaResourceId; this.metricsManager.timeEvent([ METRIC_EVENT_NAMES.TASK_RESUME_SUCCESS, @@ -636,7 +670,7 @@ export default class Task extends EventEmitter implements ITask { const response = await this.contact.unHold({ interactionId: this.data.interactionId, - data: {mediaResourceId}, + data: {mediaResourceId: effectiveMediaResourceId}, }); this.metricsManager.trackEvent( @@ -644,7 +678,7 @@ export default class Task extends EventEmitter implements ITask { { taskId: this.data.interactionId, mainInteractionId, - mediaResourceId, + mediaResourceId: effectiveMediaResourceId, ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), }, ['operational', 'behavioral'] @@ -661,6 +695,11 @@ export default class Task extends EventEmitter implements ITask { } catch (error) { const err = generateTaskErrorObject(error, METHODS.RESUME, TASK_FILE); const mainInteractionId = this.data.interaction?.mainInteractionId; + const defaultMediaResourceId = mainInteractionId + ? this.data.interaction.media[mainInteractionId]?.mediaResourceId + : ''; + const effectiveMediaResourceId = mediaResourceId ?? defaultMediaResourceId; + const taskErrorProps = { trackingId: err.data?.trackingId, errorMessage: err.data?.message, @@ -673,9 +712,7 @@ export default class Task extends EventEmitter implements ITask { { taskId: this.data.interactionId, mainInteractionId, - mediaResourceId: mainInteractionId - ? this.data.interaction.media[mainInteractionId].mediaResourceId - : '', + mediaResourceId: effectiveMediaResourceId, ...taskErrorProps, ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), }, @@ -1405,35 +1442,31 @@ export default class Task extends EventEmitter implements ITask { public async consultTransfer( consultTransferPayload?: ConsultTransferPayLoad ): Promise { - try { - // Get the destination agent ID using custom logic from participants data - const destAgentId = getDestinationAgentId( - this.data.interaction?.participants, - this.data.agentId - ); - - // Resolve the target id (queue consult transfers go to the accepted agent) - if (!destAgentId) { - throw new Error('No agent has accepted this queue consult yet'); - } + // Get the destination agent ID using custom logic from participants data + const destAgentId = calculateDestAgentId(this.data.interaction, this.agentId); - LoggerProxy.info( - `Initiating consult transfer to ${consultTransferPayload?.to || destAgentId}`, - { - module: TASK_FILE, - method: METHODS.CONSULT_TRANSFER, - interactionId: this.data.interactionId, - } - ); - // Obtain payload based on desktop logic using TaskData - const finalDestinationType = deriveConsultTransferDestinationType(this.data); - - // By default we always use the computed destAgentId as the target id - const consultTransferRequest: ConsultTransferPayLoad = { - to: destAgentId, - destinationType: finalDestinationType, - }; + // Resolve the target id (queue consult transfers go to the accepted agent) + if (!destAgentId) { + throw new Error('No agent has accepted this queue consult yet'); + } + LoggerProxy.info( + `Initiating consult transfer to ${consultTransferPayload?.to || destAgentId}`, + { + module: TASK_FILE, + method: METHODS.CONSULT_TRANSFER, + interactionId: this.data.interactionId, + } + ); + + // Derive destination type from the participant's type property + const destType = calculateDestType(this.data.interaction, this.agentId); + // By default we always use the computed destAgentId as the target id + const consultTransferRequest: ConsultTransferPayLoad = { + to: destAgentId, + destinationType: destType, + }; + try { const result = await this.contact.consultTransfer({ interactionId: this.data.interactionId, data: consultTransferRequest, @@ -1471,17 +1504,12 @@ export default class Task extends EventEmitter implements ITask { errorData: err.data?.errorData, reasonCode: err.data?.reasonCode, }; - const failedDestinationType = deriveConsultTransferDestinationType(this.data); - const failedDestAgentId = getDestinationAgentId( - this.data.interaction?.participants, - this.data.agentId - ); this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, { taskId: this.data.interactionId, - destination: failedDestAgentId || '', - destinationType: failedDestinationType, + destination: destAgentId || '', + destinationType: destType, isConsultTransfer: true, error: error.toString(), ...taskErrorProps, @@ -1514,28 +1542,36 @@ export default class Task extends EventEmitter implements ITask { * ``` */ public async consultConference(): Promise { + // Get the destination agent ID dynamically from participants + // This handles multi-party conference scenarios, CBT (Capacity Based Team), and EP-DN cases + const destAgentId = calculateDestAgentId(this.data.interaction, this.agentId); + + // Validate that we have a destination agent (for queue consult scenarios) + if (!destAgentId) { + throw new Error('No agent has accepted this queue consult yet'); + } + + // Get the destination agent ID for fetching destination type + // This helps determine the correct participant type for CBT (Capacity Based Team) and EP-DN scenarios + const destAgentType = calculateDestType(this.data.interaction, this.agentId); + // Extract consultation conference data from task data (used in both try and catch) const consultationData = { agentId: this.agentId, - destAgentId: this.data.destAgentId, - destinationType: this.data.destinationType || 'agent', + to: destAgentId, + destinationType: destAgentType || this.data.destinationType || 'agent', }; try { - LoggerProxy.info(`Initiating consult conference to ${consultationData.destAgentId}`, { + LoggerProxy.info(`Initiating consult conference to ${destAgentId}`, { module: TASK_FILE, method: METHODS.CONSULT_CONFERENCE, interactionId: this.data.interactionId, }); - const paramsDataForConferenceV2 = buildConsultConferenceParamData( - consultationData, - this.data.interactionId - ); - const response = await this.contact.consultConference({ - interactionId: paramsDataForConferenceV2.interactionId, - data: paramsDataForConferenceV2.data, + interactionId: this.data.interactionId, + data: consultationData, }); // Track success metrics (following consultTransfer pattern) @@ -1543,9 +1579,9 @@ export default class Task extends EventEmitter implements ITask { METRIC_EVENT_NAMES.TASK_CONFERENCE_START_SUCCESS, { taskId: this.data.interactionId, - destination: paramsDataForConferenceV2.data.to, - destinationType: paramsDataForConferenceV2.data.destinationType, - agentId: paramsDataForConferenceV2.data.agentId, + destination: consultationData.to, + destinationType: consultationData.destinationType, + agentId: consultationData.agentId, ...MetricsManager.getCommonTrackingFieldForAQMResponse(response), }, ['operational', 'behavioral', 'business'] @@ -1568,20 +1604,13 @@ export default class Task extends EventEmitter implements ITask { reasonCode: err.data?.reasonCode, }; - // Track failure metrics (following consultTransfer pattern) - // Build conference data for error tracking using extracted data - const failedParamsData = buildConsultConferenceParamData( - consultationData, - this.data.interactionId - ); - this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_CONFERENCE_START_FAILED, { taskId: this.data.interactionId, - destination: failedParamsData.data.to, - destinationType: failedParamsData.data.destinationType, - agentId: failedParamsData.data.agentId, + destination: consultationData.to, + destinationType: consultationData.destinationType, + agentId: consultationData.agentId, error: error.toString(), ...taskErrorProps, ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}), @@ -1684,9 +1713,6 @@ export default class Task extends EventEmitter implements ITask { } } - // TODO: Uncomment this method in future PR for Multi-Party Conference support (>3 participants) - // Conference transfer will be supported when implementing enhanced multi-party conference functionality - /* /** * Transfers the current conference to another agent * @@ -1707,7 +1733,7 @@ export default class Task extends EventEmitter implements ITask { * } * ``` */ - /* public async transferConference(): Promise { + public async transferConference(): Promise { try { LoggerProxy.info(`Transferring conference`, { module: TASK_FILE, @@ -1771,5 +1797,5 @@ export default class Task extends EventEmitter implements ITask { throw err; } - } */ + } } diff --git a/packages/@webex/contact-center/src/services/task/types.ts b/packages/@webex/contact-center/src/services/task/types.ts index d2d06ec6787..2f461ab20f9 100644 --- a/packages/@webex/contact-center/src/services/task/types.ts +++ b/packages/@webex/contact-center/src/services/task/types.ts @@ -490,6 +490,30 @@ export enum TASK_EVENTS { * ``` */ TASK_PARTICIPANT_LEFT_FAILED = 'task:participantLeftFailed', + + /** + * Triggered when a contact is merged + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_MERGED, (task: ITask) => { + * console.log('Contact merged:', task.data.interactionId); + * // Handle contact merge + * }); + * ``` + */ + TASK_MERGED = 'task:merged', + + /** + * Triggered when a participant enters post-call activity state + * @example + * ```typescript + * task.on(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, (task: ITask) => { + * console.log('Participant in post-call activity:', task.data.interactionId); + * // Handle post-call activity + * }); + * ``` + */ + TASK_POST_CALL_ACTIVITY = 'task:postCallActivity', } /** @@ -733,6 +757,8 @@ export type TaskData = { isConsulted?: boolean; /** Indicates if the task is in conference state */ isConferencing: boolean; + /** Indicates if a conference is currently in progress (2+ active agents) */ + isConferenceInProgress?: boolean; /** Identifier of agent who last updated the task */ updatedBy?: string; /** Type of destination for transfer/consult */ @@ -755,6 +781,8 @@ export type TaskData = { isWebCallMute?: boolean; /** Identifier for reservation interaction */ reservationInteractionId?: string; + /** Identifier for the reserved agent channel (used for campaign tasks) */ + reservedAgentChannelId?: string; /** Indicates if wrap-up is required for this task */ wrapUpRequired?: boolean; }; @@ -1003,19 +1031,6 @@ export type ConsultConferenceData = { destinationType: string; }; -/** - * Legacy consultation conference data type matching Agent Desktop - * @public - */ -export type consultConferencePayloadData = { - /** Identifier of the agent initiating consult/conference */ - agentId: string; - /** Type of destination (e.g., 'agent', 'queue') */ - destinationType: string; - /** Identifier of the destination agent */ - destAgentId: string; -}; - /** * Parameters required for cancelling a consult to queue operation * @public @@ -1135,16 +1150,16 @@ export interface ITask extends EventEmitter { autoWrapup?: AutoWrapup; /** - * cancels the auto-wrapup timer for the task - * This method stops the auto-wrapup process if it is currently active + * Cancels the auto-wrapup timer for the task. + * This method stops the auto-wrapup process if it is currently active. * Note: This is supported only in single session mode. Not supported in multi-session mode. * @returns void */ cancelAutoWrapupTimer(): void; /** - * Deregisters all web call event listeners - * Used when cleaning up task resources + * Deregisters all web call event listeners. + * Used when cleaning up task resources. * @ignore */ unregisterWebCallListeners(): void; @@ -1164,7 +1179,7 @@ export interface ITask extends EventEmitter { * @returns Promise * @example * ```typescript - * task.accept(); + * await task.accept(); * ``` */ accept(): Promise; @@ -1174,48 +1189,58 @@ export interface ITask extends EventEmitter { * @returns Promise * @example * ```typescript - * task.decline(); + * await task.decline(); * ``` */ decline(): Promise; /** - * Places the current task on hold + * Places the current task on hold. + * @param mediaResourceId - Optional media resource ID to use for the hold operation. If not provided, uses the task's current mediaResourceId * @returns Promise * @example * ```typescript - * task.hold(); + * // Hold with default mediaResourceId + * await task.hold(); + * + * // Hold with custom mediaResourceId + * await task.hold('custom-media-resource-id'); * ``` */ - hold(): Promise; + hold(mediaResourceId?: string): Promise; /** - * Resumes a task that was previously on hold + * Resumes a task that was previously on hold. + * @param mediaResourceId - Optional media resource ID to use for the resume operation. If not provided, uses the task's current mediaResourceId from interaction media * @returns Promise * @example * ```typescript - * task.resume(); + * // Resume with default mediaResourceId + * await task.resume(); + * + * // Resume with custom mediaResourceId + * await task.resume('custom-media-resource-id'); * ``` */ - resume(): Promise; + resume(mediaResourceId?: string): Promise; /** - * Ends/terminates the current task + * Ends/terminates the current task. * @returns Promise * @example * ```typescript - * task.end(); + * await task.end(); * ``` */ end(): Promise; /** - * Initiates wrap-up process for the task with specified details + * Initiates wrap-up process for the task with specified details. * @param wrapupPayload - Wrap-up details including reason and auxiliary code * @returns Promise * @example * ```typescript - * task.wrapup({ + * await task.wrapup({ * wrapUpReason: "Customer issue resolved", * auxCodeId: "RESOLVED" * }); @@ -1224,25 +1249,109 @@ export interface ITask extends EventEmitter { wrapup(wrapupPayload: WrapupPayLoad): Promise; /** - * Pauses the recording for current task + * Pauses the recording for current task. * @returns Promise * @example * ```typescript - * task.pauseRecording(); + * await task.pauseRecording(); * ``` */ pauseRecording(): Promise; /** - * Resumes a previously paused recording + * Resumes a previously paused recording. * @param resumeRecordingPayload - Parameters for resuming the recording * @returns Promise * @example * ```typescript - * task.resumeRecording({ + * await task.resumeRecording({ * autoResumed: false * }); * ``` */ resumeRecording(resumeRecordingPayload: ResumeRecordingPayload): Promise; + + /** + * Initiates a consultation with another agent or queue. + * @param consultPayload - Consultation details including destination and type + * @returns Promise + * @example + * ```typescript + * await task.consult({ to: "agentId", destinationType: "agent" }); + * ``` + */ + consult(consultPayload: ConsultPayload): Promise; + + /** + * Ends an ongoing consultation. + * @param consultEndPayload - Details for ending the consultation + * @returns Promise + * @example + * ```typescript + * await task.endConsult({ isConsult: true, taskId: "taskId" }); + * ``` + */ + endConsult(consultEndPayload: ConsultEndPayload): Promise; + + /** + * Transfers the task to another agent or queue. + * @param transferPayload - Transfer details including destination and type + * @returns Promise + * @example + * ```typescript + * await task.transfer({ to: "queueId", destinationType: "queue" }); + * ``` + */ + transfer(transferPayload: TransferPayLoad): Promise; + + /** + * Transfers the task after consultation. + * @param consultTransferPayload - Details for consult transfer (optional) + * @returns Promise + * @example + * ```typescript + * await task.consultTransfer({ to: "agentId", destinationType: "agent" }); + * ``` + */ + consultTransfer(consultTransferPayload?: ConsultTransferPayLoad): Promise; + + /** + * Initiates a consult conference (merge consult call with main call). + * @returns Promise + * @example + * ```typescript + * await task.consultConference(); + * ``` + */ + consultConference(): Promise; + + /** + * Exits from an ongoing conference. + * @returns Promise + * @example + * ```typescript + * await task.exitConference(); + * ``` + */ + exitConference(): Promise; + + /** + * Transfers the conference to another participant. + * @returns Promise + * @example + * ```typescript + * await task.transferConference(); + * ``` + */ + transferConference(): Promise; + + /** + * Toggles mute/unmute for the local audio stream during a WebRTC task. + * @returns Promise + * @example + * ```typescript + * await task.toggleMute(); + * ``` + */ + toggleMute(): Promise; } diff --git a/packages/@webex/contact-center/test/unit/spec/services/core/Utils.ts b/packages/@webex/contact-center/test/unit/spec/services/core/Utils.ts index 2931c69179f..35c0e895574 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/core/Utils.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/core/Utils.ts @@ -229,7 +229,7 @@ describe('Utils', () => { }); }); - it('should return DUPLICATE_LOCATION message and fieldName for DN number', () => { + it('should return DUPLICATE_LOCATION message and fieldName for dial number', () => { const failure = {data: {reason: 'DUPLICATE_LOCATION'}} as Failure; const result = Utils.getStationLoginErrorData(failure, LoginOption.AGENT_DN); expect(result).toEqual({ @@ -277,53 +277,284 @@ describe('Utils', () => { }); }); - describe('getDestinationAgentId', () => { - const currentAgentId = 'agent-current-123'; + describe('getConsultedAgentId', () => { + const currentAgentId = 'agent-123'; - it('returns another Agent id when present and not in wrap-up', () => { - const participants: any = { - [currentAgentId]: {type: 'Agent', id: currentAgentId, isWrapUp: false}, - agent1: {type: 'Agent', id: 'agent-1', isWrapUp: false}, - customer1: {type: 'Customer', id: 'cust-1', isWrapUp: false}, + it('should return consulted agent ID from consult media', () => { + const media: any = { + mainCall: { + mType: 'mainCall', + participants: [currentAgentId, 'customer-1'], + }, + consultCall: { + mType: 'consult', + participants: [currentAgentId, 'agent-456'], + }, + }; + + const result = Utils.getConsultedAgentId(media, currentAgentId); + expect(result).toBe('agent-456'); + }); + + it('should return empty string when no consult media exists', () => { + const media: any = { + mainCall: { + mType: 'mainCall', + participants: [currentAgentId, 'customer-1'], + }, }; - const result = Utils.getDestinationAgentId(participants, currentAgentId); - expect(result).toBe('agent-1'); + const result = Utils.getConsultedAgentId(media, currentAgentId); + expect(result).toBe(''); }); - it('ignores self and wrap-up participants', () => { - const participants: any = { - [currentAgentId]: {type: 'Agent', id: currentAgentId, isWrapUp: false}, - agentWrap: {type: 'Agent', id: 'agent-wrap', isWrapUp: true}, + it('should return empty string when current agent is not in consult participants', () => { + const media: any = { + consultCall: { + mType: 'consult', + participants: ['other-agent-1', 'other-agent-2'], + }, }; - const result = Utils.getDestinationAgentId(participants, currentAgentId); + const result = Utils.getConsultedAgentId(media, currentAgentId); + expect(result).toBe(''); + }); + + it('should handle empty media object', () => { + const result = Utils.getConsultedAgentId({}, currentAgentId); expect(result).toBe(''); }); - it('supports DN, EpDn and entryPoint types', () => { - const participantsDN: any = { - [currentAgentId]: {type: 'Agent', id: currentAgentId, isWrapUp: false}, - dn1: {type: 'DN', id: 'dn-1', isWrapUp: false}, + it('should handle multiple media entries and find consult', () => { + const media: any = { + media1: {mType: 'mainCall', participants: [currentAgentId]}, + media2: {mType: 'hold', participants: []}, + media3: {mType: 'consult', participants: [currentAgentId, 'consulted-agent']}, + }; + + const result = Utils.getConsultedAgentId(media, currentAgentId); + expect(result).toBe('consulted-agent'); + }); + }); + + describe('getDestAgentIdForCBT', () => { + it('should return destination agent ID for CBT scenario', () => { + const interaction: any = { + participants: { + 'agent-uuid-123': { + type: 'Agent', + pType: 'dn', + dn: '5551234567', + id: 'agent-uuid-123', + }, + 'customer-1': { + type: 'Customer', + pType: 'Customer', + id: 'customer-1', + }, + }, + }; + const consultingAgent = '5551234567'; // Phone number, not in participants as key + + const result = Utils.getDestAgentIdForCBT(interaction, consultingAgent); + expect(result).toBe('agent-uuid-123'); + }); + + it('should return empty string when consultingAgent is in participants (non-CBT)', () => { + const interaction: any = { + participants: { + 'agent-123': { + type: 'Agent', + pType: 'Agent', + id: 'agent-123', + }, + }, }; - expect(Utils.getDestinationAgentId(participantsDN, currentAgentId)).toBe('dn-1'); + const consultingAgent = 'agent-123'; // Exists as key in participants + + const result = Utils.getDestAgentIdForCBT(interaction, consultingAgent); + expect(result).toBe(''); + }); - const participantsEpDn: any = { - [currentAgentId]: {type: 'Agent', id: currentAgentId, isWrapUp: false}, - epdn1: {type: 'EpDn', id: 'epdn-1', isWrapUp: false}, + it('should return empty string when no matching dial number found', () => { + const interaction: any = { + participants: { + 'agent-uuid-123': { + type: 'Agent', + pType: 'dn', + dn: '5559999999', + id: 'agent-uuid-123', + }, + }, }; - expect(Utils.getDestinationAgentId(participantsEpDn, currentAgentId)).toBe('epdn-1'); + const consultingAgent = '5551234567'; // Different number + + const result = Utils.getDestAgentIdForCBT(interaction, consultingAgent); + expect(result).toBe(''); + }); - const participantsEntry: any = { - [currentAgentId]: {type: 'Agent', id: currentAgentId, isWrapUp: false}, - entry1: {type: 'entryPoint', id: 'entry-1', isWrapUp: false}, + it('should return empty string when consultingAgent is empty', () => { + const interaction: any = { + participants: { + 'agent-uuid-123': { + type: 'Agent', + pType: 'dn', + dn: '5551234567', + }, + }, }; - expect(Utils.getDestinationAgentId(participantsEntry, currentAgentId)).toBe('entry-1'); + + const result = Utils.getDestAgentIdForCBT(interaction, ''); + expect(result).toBe(''); }); - it('returns empty string when participants is missing or empty', () => { - expect(Utils.getDestinationAgentId(undefined as any, currentAgentId)).toBe(''); - expect(Utils.getDestinationAgentId({} as any, currentAgentId)).toBe(''); + it('should match only when participant type is dial number and type is Agent', () => { + const interaction: any = { + participants: { + 'participant-1': { + type: 'Customer', + pType: 'dn', + dn: '5551234567', + }, + 'participant-2': { + type: 'Agent', + pType: 'Agent', + dn: '5551234567', + }, + 'participant-3': { + type: 'Agent', + pType: 'dn', + dn: '5551234567', + id: 'correct-agent', + }, + }, + }; + + const result = Utils.getDestAgentIdForCBT(interaction, '5551234567'); + expect(result).toBe('participant-3'); + }); + + it('should handle case-insensitive participant type comparison', () => { + const interaction: any = { + participants: { + 'agent-uuid': { + type: 'Agent', + pType: 'DN', // Uppercase (dial number) + dn: '5551234567', + }, + }, + }; + + const result = Utils.getDestAgentIdForCBT(interaction, '5551234567'); + expect(result).toBe('agent-uuid'); }); }); + + describe('calculateDestAgentId', () => { + const currentAgentId = 'agent-123'; + + it('should return destAgentIdCBT when found', () => { + const interaction: any = { + media: { + consult: { + mType: 'consult', + participants: [currentAgentId, '5551234567'], + }, + }, + participants: { + 'agent-uuid-456': { + type: 'Agent', + pType: 'dn', + dn: '5551234567', + id: 'agent-uuid-456', + }, + }, + }; + + const result = Utils.calculateDestAgentId(interaction, currentAgentId); + expect(result).toBe('agent-uuid-456'); + }); + + it('should return participant id for regular agent when not CBT', () => { + const consultedAgentId = 'agent-456'; + const interaction: any = { + media: { + consult: { + mType: 'consult', + participants: [currentAgentId, consultedAgentId], + }, + }, + participants: { + [consultedAgentId]: { + type: 'Agent', + id: consultedAgentId, + }, + }, + }; + + const result = Utils.calculateDestAgentId(interaction, currentAgentId); + expect(result).toBe(consultedAgentId); + }); + + it('should return epId for EpDn type participants', () => { + const consultedAgentId = 'epdn-456'; + const interaction: any = { + media: { + consult: { + mType: 'consult', + participants: [currentAgentId, consultedAgentId], + }, + }, + participants: { + [consultedAgentId]: { + type: 'EpDn', + id: consultedAgentId, + epId: 'entry-point-id-789', + }, + }, + }; + + const result = Utils.calculateDestAgentId(interaction, currentAgentId); + expect(result).toBe('entry-point-id-789'); + }); + + it('should return undefined when no consulting agent found', () => { + const interaction: any = { + media: { + mainCall: { + mType: 'mainCall', + participants: [currentAgentId], + }, + }, + participants: {}, + }; + + const result = Utils.calculateDestAgentId(interaction, currentAgentId); + expect(result).toBeUndefined(); + }); + + it('should handle CBT scenario when phone number is not a direct participant key', () => { + const interaction: any = { + media: { + consult: { + mType: 'consult', + participants: [currentAgentId, '5551234567'], // Phone number in media + }, + }, + participants: { + // Note: '5551234567' is NOT a key - this is CBT + 'agent-uuid-cbt': { + type: 'Agent', + pType: 'dn', + dn: '5551234567', // Found by matching DN + id: 'agent-uuid-cbt', + }, + }, + }; + + const result = Utils.calculateDestAgentId(interaction, currentAgentId); + expect(result).toBe('agent-uuid-cbt'); // Returns the CBT agent + }); + }); + }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts index 711ea3ad320..08cd5c39f47 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/TaskManager.ts @@ -479,6 +479,73 @@ describe('TaskManager', () => { ); }); + it('should set isConferenceInProgress correctly when creating task via AGENT_CONTACT with conference in progress', () => { + const testAgentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f'; + taskManager.setAgentId(testAgentId); + taskManager.taskCollection = []; + + const payload = { + data: { + ...initalPayload.data, + type: CC_EVENTS.AGENT_CONTACT, + interaction: { + mediaType: 'telephony', + state: 'conference', + participants: { + [testAgentId]: { pType: 'Agent', hasLeft: false }, + 'agent-2': { pType: 'Agent', hasLeft: false }, + 'customer-1': { pType: 'Customer', hasLeft: false }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [testAgentId, 'agent-2', 'customer-1'], + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + const createdTask = taskManager.getTask(taskId); + expect(createdTask).toBeDefined(); + expect(createdTask.data.isConferenceInProgress).toBe(true); + }); + + it('should set isConferenceInProgress to false when creating task via AGENT_CONTACT with only one agent', () => { + const testAgentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f'; + taskManager.setAgentId(testAgentId); + taskManager.taskCollection = []; + + const payload = { + data: { + ...initalPayload.data, + type: CC_EVENTS.AGENT_CONTACT, + interaction: { + mediaType: 'telephony', + state: 'connected', + participants: { + [testAgentId]: { pType: 'Agent', hasLeft: false }, + 'customer-1': { pType: 'Customer', hasLeft: false }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [testAgentId, 'customer-1'], + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + const createdTask = taskManager.getTask(taskId); + expect(createdTask).toBeDefined(); + expect(createdTask.data.isConferenceInProgress).toBe(false); + }); + it('should emit TASK_END event on AGENT_WRAPUP event', () => { webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); @@ -1357,8 +1424,12 @@ describe('TaskManager', () => { describe('Conference event handling', () => { let task; + const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f'; beforeEach(() => { + // Set the agentId on taskManager before tests run + taskManager.setAgentId(agentId); + task = { data: { interactionId: taskId }, emit: jest.fn(), @@ -1424,6 +1495,18 @@ describe('TaskManager', () => { interactionId: taskId, participantId: 'new-participant-123', participantType: 'agent', + interaction: { + participants: { + [agentId]: { pType: 'Agent', hasLeft: false }, + 'new-participant-123': { pType: 'Agent', hasLeft: false }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [agentId, 'new-participant-123'], + }, + }, + }, }, }; @@ -1434,19 +1517,339 @@ describe('TaskManager', () => { // No specific task event emission for participant joined - just data update }); - it('should handle PARTICIPANT_LEFT_CONFERENCE event', () => { + it('should call updateTaskData only once for PARTICIPANT_JOINED_CONFERENCE with pre-calculated isConferenceInProgress', () => { const payload = { data: { - type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE, interactionId: taskId, - isConferencing: false, + participantId: 'new-agent-789', + interaction: { + participants: { + [agentId]: { pType: 'Agent', hasLeft: false }, + 'agent-2': { pType: 'Agent', hasLeft: false }, + 'new-agent-789': { pType: 'Agent', hasLeft: false }, + 'customer-1': { pType: 'Customer', hasLeft: false }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [agentId, 'agent-2', 'new-agent-789', 'customer-1'], + }, + }, + }, }, }; + const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData'); + webSocketManagerMock.emit('message', JSON.stringify(payload)); - expect(task.data.isConferencing).toBe(false); - expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + // Verify updateTaskData was called exactly once + expect(updateTaskDataSpy).toHaveBeenCalledTimes(1); + + // Verify it was called with isConferenceInProgress already calculated + expect(updateTaskDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ + participantId: 'new-agent-789', + isConferenceInProgress: true, // 3 active agents + }) + ); + + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_JOINED, task); + }); + + describe('PARTICIPANT_LEFT_CONFERENCE event handling', () => { + it('should call updateTaskData only once for PARTICIPANT_LEFT_CONFERENCE with pre-calculated isConferenceInProgress', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { pType: 'Agent', hasLeft: false }, + 'agent-2': { pType: 'Agent', hasLeft: true }, // This agent left + 'customer-1': { pType: 'Customer', hasLeft: false }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [agentId, 'customer-1'], // agent-2 removed from participants + }, + }, + }, + }, + }; + + const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // Verify updateTaskData was called exactly once + expect(updateTaskDataSpy).toHaveBeenCalledTimes(1); + + // Verify it was called with isConferenceInProgress already calculated + expect(updateTaskDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ + isConferenceInProgress: false, // Only 1 active agent remains + }) + ); + + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should emit TASK_PARTICIPANT_LEFT event when participant leaves conference', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: false, + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should NOT remove task when agent is still in interaction', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: false, + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // Task should still exist in collection + expect(taskManager.getTask(taskId)).toBeDefined(); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should NOT remove task when agent left but is in main interaction', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: true, + }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [agentId], + }, + }, + }, + }, + }; + + const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // Task should still exist - not removed + expect(removeTaskSpy).not.toHaveBeenCalled(); + expect(taskManager.getTask(taskId)).toBeDefined(); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should NOT remove task when agent left but is primary (owner)', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: true, + }, + }, + owner: agentId, + media: { + [taskId]: { + mType: 'consultCall', + participants: ['other-agent'], + }, + }, + }, + }, + }; + + const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // Task should still exist - not removed because agent is primary + expect(removeTaskSpy).not.toHaveBeenCalled(); + expect(taskManager.getTask(taskId)).toBeDefined(); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should remove task when agent left and is NOT in main interaction and is NOT primary', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: true, + }, + }, + owner: 'another-agent-id', + media: { + [taskId]: { + mType: 'mainCall', + participants: ['another-agent-id'], + }, + }, + }, + }, + }; + + const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // Task should be removed + expect(removeTaskSpy).toHaveBeenCalled(); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should remove task when agent is not in participants list', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + 'other-agent-id': { + hasLeft: false, + }, + }, + owner: 'another-agent-id', + }, + }, + }; + + const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // Task should be removed because agent is not in participants + expect(removeTaskSpy).toHaveBeenCalled(); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should update isConferenceInProgress based on remaining active agents', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: false, + pType: 'Agent', + }, + 'agent-2': { + hasLeft: false, + pType: 'Agent', + }, + 'customer-1': { + hasLeft: false, + pType: 'Customer', + }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [agentId, 'agent-2', 'customer-1'], + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // isConferenceInProgress should be true (2 active agents) + expect(task.data.isConferenceInProgress).toBe(true); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should set isConferenceInProgress to false when only one agent remains', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: { + participants: { + [agentId]: { + hasLeft: false, + pType: 'Agent', + }, + 'agent-2': { + hasLeft: true, + pType: 'Agent', + }, + 'customer-1': { + hasLeft: false, + pType: 'Customer', + }, + }, + media: { + [taskId]: { + mType: 'mainCall', + participants: [agentId, 'customer-1'], + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // isConferenceInProgress should be false (only 1 active agent) + expect(task.data.isConferenceInProgress).toBe(false); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); + + it('should handle participant left when no participants data exists', () => { + const payload = { + data: { + type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, + interactionId: taskId, + interaction: {}, + }, + }; + + const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection'); + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + // When no participants data exists, checkParticipantNotInInteraction returns true + // Since agent won't be in main interaction either, task should be removed + expect(removeTaskSpy).toHaveBeenCalled(); + expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task); + }); }); it('should handle PARTICIPANT_LEFT_CONFERENCE_FAILED event', () => { @@ -1490,6 +1893,217 @@ describe('TaskManager', () => { expect(otherTask.data.isConferencing).toBeUndefined(); expect(otherTask.emit).not.toHaveBeenCalled(); }); - }); + }); + + describe('CONTACT_MERGED event handling', () => { + let task; + let taskEmitSpy; + let managerEmitSpy; + + beforeEach(() => { + // Create initial task + webSocketManagerMock.emit('message', JSON.stringify(initalPayload)); + task = taskManager.getTask(taskId); + taskEmitSpy = jest.spyOn(task, 'emit'); + managerEmitSpy = jest.spyOn(taskManager, 'emit'); + }); + + it('should update existing task data and emit TASK_MERGED event when CONTACT_MERGED is received', () => { + const mergedPayload = { + data: { + type: CC_EVENTS.CONTACT_MERGED, + interactionId: taskId, + agentId: taskDataMock.agentId, + interaction: { + ...taskDataMock.interaction, + state: 'merged', + customField: 'updated-value', + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(mergedPayload)); + + const updatedTask = taskManager.getTask(taskId); + expect(updatedTask).toBeDefined(); + expect(updatedTask.data.interaction.customField).toBe('updated-value'); + expect(updatedTask.data.interaction.state).toBe('merged'); + expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, updatedTask); + }); + + it('should create new task when CONTACT_MERGED is received for non-existing task', () => { + const newMergedTaskId = 'new-merged-task-id'; + const mergedPayload = { + data: { + type: CC_EVENTS.CONTACT_MERGED, + interactionId: newMergedTaskId, + agentId: taskDataMock.agentId, + interaction: { + mediaType: 'telephony', + state: 'merged', + participants: { + [taskDataMock.agentId]: { + isWrapUp: false, + hasJoined: true, + }, + }, + }, + }, + }; + + // Verify task doesn't exist before + expect(taskManager.getTask(newMergedTaskId)).toBeUndefined(); + + webSocketManagerMock.emit('message', JSON.stringify(mergedPayload)); + + // Verify task was created + const newTask = taskManager.getTask(newMergedTaskId); + expect(newTask).toBeDefined(); + expect(newTask.data.interactionId).toBe(newMergedTaskId); + expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, newTask); + }); + + it('should remove child task when childInteractionId is present in CONTACT_MERGED', () => { + const childTaskId = 'child-task-id'; + const parentTaskId = 'parent-task-id'; + + // Create child task + const childPayload = { + data: { + type: CC_EVENTS.AGENT_CONTACT_RESERVED, + interactionId: childTaskId, + agentId: taskDataMock.agentId, + interaction: {mediaType: 'telephony'}, + }, + }; + webSocketManagerMock.emit('message', JSON.stringify(childPayload)); + + // Verify child task exists + expect(taskManager.getTask(childTaskId)).toBeDefined(); + + // Create parent task + const parentPayload = { + data: { + type: CC_EVENTS.AGENT_CONTACT_RESERVED, + interactionId: parentTaskId, + agentId: taskDataMock.agentId, + interaction: {mediaType: 'telephony'}, + }, + }; + webSocketManagerMock.emit('message', JSON.stringify(parentPayload)); + + // Send CONTACT_MERGED with childInteractionId + const mergedPayload = { + data: { + type: CC_EVENTS.CONTACT_MERGED, + interactionId: parentTaskId, + childInteractionId: childTaskId, + agentId: taskDataMock.agentId, + interaction: { + mediaType: 'telephony', + state: 'merged', + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(mergedPayload)); + + // Verify child task was removed + expect(taskManager.getTask(childTaskId)).toBeUndefined(); + + // Verify parent task still exists + expect(taskManager.getTask(parentTaskId)).toBeDefined(); + + // Verify TASK_MERGED event was emitted + expect(managerEmitSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_MERGED, + expect.objectContaining({ + data: expect.objectContaining({ + interactionId: parentTaskId, + }), + }) + ); + }); + + it('should handle CONTACT_MERGED with EP-DN participant correctly', () => { + const epdnTaskId = 'epdn-merged-task'; + const mergedPayload = { + data: { + type: CC_EVENTS.CONTACT_MERGED, + interactionId: epdnTaskId, + agentId: taskDataMock.agentId, + interaction: { + mediaType: 'telephony', + state: 'merged', + participants: { + [taskDataMock.agentId]: { + type: 'Agent', + isWrapUp: false, + hasJoined: true, + }, + 'epdn-participant': { + type: 'EpDn', + epId: 'entry-point-123', + isWrapUp: false, + }, + }, + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(mergedPayload)); + + const mergedTask = taskManager.getTask(epdnTaskId); + expect(mergedTask).toBeDefined(); + expect(mergedTask.data.interaction.participants['epdn-participant']).toBeDefined(); + expect(mergedTask.data.interaction.participants['epdn-participant'].type).toBe('EpDn'); + expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, mergedTask); + }); + + it('should not affect other tasks when CONTACT_MERGED is received', () => { + const otherTaskId = 'other-unrelated-task'; + const otherPayload = { + data: { + type: CC_EVENTS.AGENT_CONTACT_RESERVED, + interactionId: otherTaskId, + agentId: taskDataMock.agentId, + interaction: {mediaType: 'chat'}, + }, + }; + webSocketManagerMock.emit('message', JSON.stringify(otherPayload)); + + const otherTask = taskManager.getTask(otherTaskId); + const otherTaskEmitSpy = jest.spyOn(otherTask, 'emit'); + + // Send CONTACT_MERGED for the original task + const mergedPayload = { + data: { + type: CC_EVENTS.CONTACT_MERGED, + interactionId: taskId, + agentId: taskDataMock.agentId, + interaction: { + mediaType: 'telephony', + state: 'merged', + }, + }, + }; + + webSocketManagerMock.emit('message', JSON.stringify(mergedPayload)); + + // Verify other task was not affected + expect(otherTaskEmitSpy).not.toHaveBeenCalled(); + expect(otherTask.data.interaction.mediaType).toBe('chat'); + + // Verify original task was updated + expect(managerEmitSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_MERGED, + expect.objectContaining({ + data: expect.objectContaining({ + interactionId: taskId, + }), + }) + ); + }); + }); }); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts new file mode 100644 index 00000000000..aebfc02a988 --- /dev/null +++ b/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts @@ -0,0 +1,131 @@ +import { checkParticipantNotInInteraction, + getIsConferenceInProgress, + isParticipantInMainInteraction, + isPrimary,} from '../../../../../src/services/task/TaskUtils'; +import {ITask} from '../../../../../src/services/task/types'; + +describe('TaskUtils', () => { + let mockTask: ITask; + const mockAgentId = 'agent-123'; + const mockOtherAgentId = 'agent-456'; + + beforeEach(() => { + mockTask = { + data: { + interactionId: 'interaction-123', + agentId: mockAgentId, + interaction: { + owner: mockAgentId, + participants: { + [mockAgentId]: { hasLeft: false }, + [mockOtherAgentId]: { hasLeft: false }, + }, + media: { + 'media-1': { + mType: 'mainCall', + participants: [mockAgentId, mockOtherAgentId], + }, + }, + }, + }, + emit: jest.fn(), + updateTaskData: jest.fn(), + } as any; + }); + + describe('isPrimary', () => { + it('should return true when agent is the owner', () => { + expect(isPrimary(mockTask, mockAgentId)).toBe(true); + }); + + it('should return false when agent is not the owner', () => { + expect(isPrimary(mockTask, mockOtherAgentId)).toBe(false); + }); + + it('should fallback to data.agentId when owner is not set', () => { + mockTask.data.interaction.owner = undefined; + expect(isPrimary(mockTask, mockAgentId)).toBe(true); + expect(isPrimary(mockTask, mockOtherAgentId)).toBe(false); + }); + }); + + describe('isParticipantInMainInteraction', () => { + it('should return true when agent is in mainCall media', () => { + expect(isParticipantInMainInteraction(mockTask, mockAgentId)).toBe(true); + }); + + it('should return false when agent is not in mainCall media', () => { + mockTask.data.interaction.media['media-1'].participants = [mockOtherAgentId]; + expect(isParticipantInMainInteraction(mockTask, mockAgentId)).toBe(false); + }); + + it('should return false when no mainCall media exists', () => { + mockTask.data.interaction.media['media-1'].mType = 'consult'; + expect(isParticipantInMainInteraction(mockTask, mockAgentId)).toBe(false); + }); + }); + + describe('checkParticipantNotInInteraction', () => { + it('should return false when agent is active participant', () => { + expect(checkParticipantNotInInteraction(mockTask, mockAgentId)).toBe(false); + }); + + it('should return true when agent is not in participants', () => { + delete mockTask.data.interaction.participants[mockAgentId]; + expect(checkParticipantNotInInteraction(mockTask, mockAgentId)).toBe(true); + }); + + it('should return true when agent has left', () => { + mockTask.data.interaction.participants[mockAgentId].hasLeft = true; + expect(checkParticipantNotInInteraction(mockTask, mockAgentId)).toBe(true); + }); + }); + + describe('getIsConferenceInProgress', () => { + beforeEach(() => { + // Set up mock task with proper media structure for conference detection + mockTask.data.interaction.media = { + [mockTask.data.interactionId]: { + mType: 'mainCall', + participants: [mockAgentId, mockOtherAgentId, 'customer-123'], + }, + }; + mockTask.data.interaction.participants = { + [mockAgentId]: { pType: 'Agent', hasLeft: false }, + [mockOtherAgentId]: { pType: 'Agent', hasLeft: false }, + 'customer-123': { pType: 'Customer', hasLeft: false }, + }; + }); + + it('should return true when there are 2 or more active agents', () => { + expect(getIsConferenceInProgress(mockTask.data)).toBe(true); + }); + + it('should return false when there is only 1 active agent', () => { + mockTask.data.interaction.participants[mockOtherAgentId].hasLeft = true; + expect(getIsConferenceInProgress(mockTask.data)).toBe(false); + }); + + it('should exclude customers from agent count', () => { + // Remove one agent, should still be false with only 1 agent + customer + delete mockTask.data.interaction.participants[mockOtherAgentId]; + mockTask.data.interaction.media[mockTask.data.interactionId].participants = [mockAgentId, 'customer-123']; + expect(getIsConferenceInProgress(mockTask.data)).toBe(false); + }); + + it('should exclude supervisors from agent count', () => { + mockTask.data.interaction.participants[mockOtherAgentId].pType = 'Supervisor'; + expect(getIsConferenceInProgress(mockTask.data)).toBe(false); + }); + + it('should exclude VVA from agent count', () => { + mockTask.data.interaction.participants[mockOtherAgentId].pType = 'VVA'; + expect(getIsConferenceInProgress(mockTask.data)).toBe(false); + }); + + it('should return false when no main call media exists', () => { + mockTask.data.interaction.media = {}; + expect(getIsConferenceInProgress(mockTask.data)).toBe(false); + }); + }); +}); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/index.ts b/packages/@webex/contact-center/test/unit/spec/services/task/index.ts index 24660638fad..ce09fe98525 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/index.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/index.ts @@ -39,7 +39,8 @@ describe('Task', () => { let loggerInfoSpy; let loggerLogSpy; let loggerErrorSpy; - let getDestinationAgentIdSpy; + let calculateDestAgentIdSpy; + let calculateDestTypeSpy; const taskId = '0ae913a4-c857-4705-8d49-76dd3dde75e4'; const mockTrack = {} as MediaStreamTrack; @@ -119,6 +120,32 @@ describe('Task', () => { interaction: { mediaType: 'telephony', mainInteractionId: taskId, + participants: { + '723a8ffb-a26e-496d-b14a-ff44fb83b64f': { + pType: 'Agent', + type: 'AGENT', + id: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + hasLeft: false, + hasJoined: true, + isWrapUp: false, + }, + 'f520d6b5-28ad-4f2f-b83e-781bb64af617': { + pType: 'Agent', + type: 'AGENT', + id: 'f520d6b5-28ad-4f2f-b83e-781bb64af617', + hasLeft: false, + hasJoined: true, + isWrapUp: false, + }, + 'ebeb893b-ba67-4f36-8418-95c7492b28c2': { + pType: 'Agent', + type: 'AGENT', + id: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', + hasLeft: false, + hasJoined: true, + isWrapUp: false, + }, + }, media: { '58a45567-4e61-4f4b-a580-5bc86357bef0': { holdTimestamp: null, @@ -145,13 +172,18 @@ describe('Task', () => { }, }; - // Mock destination agent id resolution from participants - getDestinationAgentIdSpy = jest - .spyOn(Utils, 'getDestinationAgentId') - .mockReturnValue(taskDataMock.destAgentId); + // Mock calculateDestAgentId to return the expected destination agent + calculateDestAgentIdSpy = jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValue(taskDataMock.destAgentId); + + // Mock calculateDestType to return 'agent' by default + calculateDestTypeSpy = jest.spyOn(Utils, 'calculateDestType').mockReturnValue('agent'); - // Create an instance of Task - task = new Task(contactMock, webCallingService, taskDataMock); + // Create an instance of Task with wrapupData and agentId + task = new Task(contactMock, webCallingService, taskDataMock, { + wrapUpProps: { wrapUpReasonList: [] }, + autoWrapEnabled: false, + autoWrapAfterSeconds: 0 + }, taskDataMock.agentId); // Mock navigator.mediaDevices global.navigator.mediaDevices = { @@ -217,7 +249,7 @@ describe('Task', () => { }); describe('updateTaskData cases', () => { - it('test updating the task data by overwrite', async () => { + it('updates the task data by overwrite', async () => { const newData = { type: CC_EVENTS.AGENT_CONTACT_ASSIGNED, agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', @@ -266,12 +298,12 @@ describe('Task', () => { expect(task.data).toEqual(newData); }); - it('test updating the task data by merging', async () => { + it('updates the task data by merging with key removal', async () => { const newData = { - // ...taskDataMock, // Purposefully omit this to test scenario when other keys isn't present + // Purposefully omit other keys to test remove and merge behavior isConsulting: true, // Add a new custom key to test persistence interaction: { - // ...taskDataMock.interaction, // Purposefully omit this to test scenario when a nested key isn't present + // Purposefully omit other interaction keys to test removal media: { '58a45567-4e61-4f4b-a580-5bc86357bef0': { holdTimestamp: null, @@ -298,11 +330,12 @@ describe('Task', () => { }, }; + // The reconcileData method removes keys from oldData that are not in newData + // This means only keys present in newData will remain in the final result const expectedData: TaskData = { - ...taskDataMock, - isConsulting: true, + isConsulting: true, // New key is added interaction: { - ...taskDataMock.interaction, + // Only the media key from newData.interaction remains media: { '58a45567-4e61-4f4b-a580-5bc86357bef0': { holdTimestamp: null, @@ -335,6 +368,60 @@ describe('Task', () => { expect(task.data).toEqual(expectedData); }); + + it('updates the task data by merging and preserving existing keys', async () => { + const newData = { + ...taskDataMock, // Include all existing keys to test merge without removal + isConsulting: true, // Add a new custom key + interaction: { + ...taskDataMock.interaction, // Include existing interaction data + media: { + ...taskDataMock.interaction.media, // Include existing media + '58a45567-4e61-4f4b-a580-5bc86357bef0': { + holdTimestamp: null, + isHold: true, + mType: 'consult', + mediaMgr: 'callmm', + mediaResourceId: '58a45567-4e61-4f4b-a580-5bc86357bef0', + mediaType: 'telephony', + participants: [ + 'f520d6b5-28ad-4f2f-b83e-781bb64af617', + '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + ], + }, + }, + }, + }; + + const expectedData: TaskData = { + ...taskDataMock, + isConsulting: true, + interaction: { + ...taskDataMock.interaction, + media: { + ...taskDataMock.interaction.media, + '58a45567-4e61-4f4b-a580-5bc86357bef0': { + holdTimestamp: null, + isHold: true, + mType: 'consult', + mediaMgr: 'callmm', + mediaResourceId: '58a45567-4e61-4f4b-a580-5bc86357bef0', + mediaType: 'telephony', + participants: [ + 'f520d6b5-28ad-4f2f-b83e-781bb64af617', + '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + ], + }, + }, + }, + }; + + expect(task.data).toEqual(taskDataMock); + const shouldOverwrite = false; + task.updateTaskData(newData, shouldOverwrite); + + expect(task.data).toEqual(expectedData); + }); }); it('should accept a task and answer call when using BROWSER login option', async () => { @@ -570,6 +657,40 @@ describe('Task', () => { ); }); + it('should hold the task with custom mediaResourceId and return the expected response', async () => { + const customMediaResourceId = 'custom-media-resource-id-123'; + const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + contactMock.hold.mockResolvedValue(expectedResponse); + + const response = await task.hold(customMediaResourceId); + + expect(contactMock.hold).toHaveBeenCalledWith({ + interactionId: taskId, + data: {mediaResourceId: customMediaResourceId}, + }); + expect(response).toEqual(expectedResponse); + expect(loggerInfoSpy).toHaveBeenCalledWith(`Holding task`, { + module: TASK_FILE, + method: 'hold', + interactionId: task.data.interactionId, + }); + expect(loggerLogSpy).toHaveBeenCalledWith(`Task placed on hold successfully`, { + module: TASK_FILE, + method: 'hold', + interactionId: task.data.interactionId, + }); + expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( + 1, + METRIC_EVENT_NAMES.TASK_HOLD_SUCCESS, + { + ...MetricsManager.getCommonTrackingFieldForAQMResponse(expectedResponse), + taskId: taskDataMock.interactionId, + mediaResourceId: customMediaResourceId, + }, + ['operational', 'behavioral'] + ); + }); + it('should handle errors in hold method', async () => { const error = {details: (global as any).makeFailure('Hold Failed')}; contactMock.hold.mockImplementation(() => { @@ -599,6 +720,36 @@ describe('Task', () => { ); }); + it('should handle errors in hold method with custom mediaResourceId', async () => { + const customMediaResourceId = 'custom-media-resource-id-456'; + const error = {details: (global as any).makeFailure('Hold Failed with custom mediaResourceId')}; + contactMock.hold.mockImplementation(() => { + throw error; + }); + + await expect(task.hold(customMediaResourceId)).rejects.toThrow(error.details.data.reason); + expect(generateTaskErrorObjectSpy).toHaveBeenCalledWith(error, 'hold', TASK_FILE); + const expectedTaskErrorFieldsHold = { + trackingId: error.details.trackingId, + errorMessage: error.details.data.reason, + errorType: '', + errorData: '', + reasonCode: 0, + }; + expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( + 1, + METRIC_EVENT_NAMES.TASK_HOLD_FAILED, + { + taskId: taskDataMock.interactionId, + mediaResourceId: customMediaResourceId, + error: error.toString(), + ...expectedTaskErrorFieldsHold, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details), + }, + ['operational', 'behavioral'] + ); + }); + it('should resume the task and return the expected response', async () => { const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; contactMock.unHold.mockResolvedValue(expectedResponse); @@ -623,6 +774,29 @@ describe('Task', () => { ); }); + it('should resume the task with custom mediaResourceId and return the expected response', async () => { + const customMediaResourceId = 'custom-media-resource-id-789'; + const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + contactMock.unHold.mockResolvedValue(expectedResponse); + const response = await task.resume(customMediaResourceId); + expect(contactMock.unHold).toHaveBeenCalledWith({ + interactionId: taskId, + data: {mediaResourceId: customMediaResourceId}, + }); + expect(response).toEqual(expectedResponse); + expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( + 1, + METRIC_EVENT_NAMES.TASK_RESUME_SUCCESS, + { + taskId: taskDataMock.interactionId, + mainInteractionId: taskDataMock.interaction.mainInteractionId, + mediaResourceId: customMediaResourceId, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(expectedResponse), + }, + ['operational', 'behavioral'] + ); + }); + it('should handle errors in resume method', async () => { const error = {details: (global as any).makeFailure('Resume Failed')}; contactMock.unHold.mockImplementation(() => { @@ -654,6 +828,36 @@ describe('Task', () => { ); }); + it('should handle errors in resume method with custom mediaResourceId', async () => { + const customMediaResourceId = 'custom-media-resource-id-999'; + const error = {details: (global as any).makeFailure('Resume Failed with custom mediaResourceId')}; + contactMock.unHold.mockImplementation(() => { + throw error; + }); + + await expect(task.resume(customMediaResourceId)).rejects.toThrow(error.details.data.reason); + expect(generateTaskErrorObjectSpy).toHaveBeenCalledWith(error, 'resume', TASK_FILE); + const expectedTaskErrorFieldsResume = { + trackingId: error.details.trackingId, + errorMessage: error.details.data.reason, + errorType: '', + errorData: '', + reasonCode: 0, + }; + expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( + 1, + METRIC_EVENT_NAMES.TASK_RESUME_FAILED, + { + taskId: taskDataMock.interactionId, + mainInteractionId: taskDataMock.interaction.mainInteractionId, + mediaResourceId: customMediaResourceId, + ...expectedTaskErrorFieldsResume, + ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details), + }, + ['operational', 'behavioral'] + ); + }); + it('should initiate a consult call and return the expected response', async () => { const consultPayload = { to: '1234', @@ -821,15 +1025,16 @@ describe('Task', () => { ); }); - it('should send DIALNUMBER when task destinationType is DN during consultTransfer', async () => { + it('should send DIALNUMBER when calculateDestType returns dialNumber during consultTransfer', async () => { const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; contactMock.consultTransfer.mockResolvedValue(expectedResponse); - // Ensure task data indicates DN scenario - task.data.destinationType = 'DN' as unknown as string; + // Mock calculateDestType to return dialNumber + calculateDestTypeSpy.mockReturnValue(CONSULT_TRANSFER_DESTINATION_TYPE.DIALNUMBER); await task.consultTransfer(); + expect(calculateDestTypeSpy).toHaveBeenCalledWith(taskDataMock.interaction, taskDataMock.agentId); expect(contactMock.consultTransfer).toHaveBeenCalledWith({ interactionId: taskId, data: { @@ -839,15 +1044,16 @@ describe('Task', () => { }); }); - it('should send ENTRYPOINT when task destinationType is EPDN during consultTransfer', async () => { + it('should send ENTRYPOINT when calculateDestType returns entryPoint during consultTransfer', async () => { const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; contactMock.consultTransfer.mockResolvedValue(expectedResponse); - // Ensure task data indicates EP/EPDN scenario - task.data.destinationType = 'EPDN' as unknown as string; + // Mock calculateDestType to return entryPoint + calculateDestTypeSpy.mockReturnValue(CONSULT_TRANSFER_DESTINATION_TYPE.ENTRYPOINT); await task.consultTransfer(); + expect(calculateDestTypeSpy).toHaveBeenCalledWith(taskDataMock.interaction, taskDataMock.agentId); expect(contactMock.consultTransfer).toHaveBeenCalledWith({ interactionId: taskId, data: { @@ -857,15 +1063,16 @@ describe('Task', () => { }); }); - it('should keep AGENT when task destinationType is neither DN nor EPDN/ENTRYPOINT', async () => { + it('should use AGENT when calculateDestType returns agent during consultTransfer', async () => { const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; contactMock.consultTransfer.mockResolvedValue(expectedResponse); - // Ensure task data indicates non-DN and non-EP/EPDN scenario - task.data.destinationType = 'SOMETHING_ELSE' as unknown as string; + // Mock calculateDestType to return agent (default behavior) + calculateDestTypeSpy.mockReturnValue(CONSULT_TRANSFER_DESTINATION_TYPE.AGENT); await task.consultTransfer(); + expect(calculateDestTypeSpy).toHaveBeenCalledWith(taskDataMock.interaction, taskDataMock.agentId); expect(contactMock.consultTransfer).toHaveBeenCalledWith({ interactionId: taskId, data: { @@ -902,7 +1109,11 @@ describe('Task', () => { const taskWithoutDestAgentId = new Task(contactMock, webCallingService, { ...taskDataMock, destAgentId: undefined, - }); + }, { + wrapUpProps: { wrapUpReasonList: [] }, + autoWrapEnabled: false, + autoWrapAfterSeconds: 0 + }, taskDataMock.agentId); const queueConsultTransferPayload: ConsultTransferPayLoad = { to: 'some-queue-id', @@ -910,61 +1121,123 @@ describe('Task', () => { }; // For this negative case, ensure computed destination is empty - getDestinationAgentIdSpy.mockReturnValueOnce(''); + calculateDestAgentIdSpy.mockReturnValueOnce(''); await expect( taskWithoutDestAgentId.consultTransfer(queueConsultTransferPayload) - ).rejects.toThrow('Error while performing consultTransfer'); + ).rejects.toThrow('No agent has accepted this queue consult yet'); }); - it('should handle errors in consult transfer', async () => { - const consultPayload = { - destination: '1234', - destinationType: DESTINATION_TYPE.AGENT, - }; - const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; - contactMock.consult.mockResolvedValue(expectedResponse); + describe('consultTransfer', () => { + it('should successfully perform consult transfer with agent destination', async () => { + const expectedResponse: TaskResponse = { + data: {interactionId: taskId}, + trackingId: 'test-tracking-id' + } as AgentContact; + contactMock.consultTransfer.mockResolvedValue(expectedResponse); + + calculateDestTypeSpy.mockReturnValue(CONSULT_TRANSFER_DESTINATION_TYPE.AGENT); - const response = await task.consult(consultPayload); + const result = await task.consultTransfer(); - expect(contactMock.consult).toHaveBeenCalledWith({interactionId: taskId, data: consultPayload}); - expect(response).toEqual(expectedResponse); + expect(calculateDestAgentIdSpy).toHaveBeenCalledWith(taskDataMock.interaction, taskDataMock.agentId); + expect(calculateDestTypeSpy).toHaveBeenCalledWith(taskDataMock.interaction, taskDataMock.agentId); + expect(contactMock.consultTransfer).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + to: taskDataMock.destAgentId, + destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, + }, + }); + expect(result).toEqual(expectedResponse); + expect(loggerInfoSpy).toHaveBeenCalledWith( + `Initiating consult transfer to ${taskDataMock.destAgentId}`, + { + module: TASK_FILE, + method: 'consultTransfer', + interactionId: taskId, + } + ); + expect(loggerLogSpy).toHaveBeenCalledWith( + `Consult transfer completed successfully to ${taskDataMock.destAgentId}`, + { + module: TASK_FILE, + method: 'consultTransfer', + trackingId: expectedResponse.trackingId, + interactionId: taskId, + } + ); + }); + + it('should track metrics on successful consult transfer', async () => { + const expectedResponse: TaskResponse = { + data: {interactionId: taskId}, + trackingId: 'test-tracking-id' + } as AgentContact; + contactMock.consultTransfer.mockResolvedValue(expectedResponse); + + await task.consultTransfer(); + + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.TASK_TRANSFER_SUCCESS, + { + taskId: taskDataMock.interactionId, + destination: taskDataMock.destAgentId, + destinationType: 'agent', + isConsultTransfer: true, + ...MetricsManager.getCommonTrackingFieldForAQMResponse(expectedResponse), + }, + ['operational', 'behavioral', 'business'] + ); + }); - const error = {details: (global as any).makeFailure('Consult Transfer Failed')}; - contactMock.consultTransfer.mockImplementation(() => { - throw error; + it('should throw error when no destination agent is found', async () => { + calculateDestAgentIdSpy.mockReturnValue(''); + + await expect(task.consultTransfer()).rejects.toThrow('No agent has accepted this queue consult yet'); + + expect(contactMock.consultTransfer).not.toHaveBeenCalled(); }); - const consultTransferPayload: ConsultTransferPayLoad = { - to: '1234', - destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, - }; + it('should handle and rethrow contact method errors', async () => { + const mockError = new Error('Consult Transfer Failed'); + contactMock.consultTransfer.mockRejectedValue(mockError); + generateTaskErrorObjectSpy.mockReturnValue(mockError); - await expect(task.consultTransfer(consultTransferPayload)).rejects.toThrow( - error.details.data.reason - ); - expect(generateTaskErrorObjectSpy).toHaveBeenCalledWith(error, 'consultTransfer', TASK_FILE); - const expectedTaskErrorFieldsConsultTransfer = { - trackingId: error.details.trackingId, - errorMessage: error.details.data.reason, - errorType: '', - errorData: '', - reasonCode: 0, - }; - expect(mockMetricsManager.trackEvent).toHaveBeenNthCalledWith( - 2, - METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, - { - taskId: taskDataMock.interactionId, - destination: taskDataMock.destAgentId, - destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT, - isConsultTransfer: true, - error: error.toString(), - ...expectedTaskErrorFieldsConsultTransfer, - ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details), - }, - ['operational', 'behavioral', 'business'] - ); + await expect(task.consultTransfer()).rejects.toThrow('Consult Transfer Failed'); + + expect(generateTaskErrorObjectSpy).toHaveBeenCalledWith(mockError, 'consultTransfer', TASK_FILE); + expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith( + METRIC_EVENT_NAMES.TASK_TRANSFER_FAILED, + expect.objectContaining({ + taskId: taskDataMock.interactionId, + destination: taskDataMock.destAgentId, + destinationType: 'agent', + isConsultTransfer: true, + error: mockError.toString(), + }), + ['operational', 'behavioral', 'business'] + ); + }); + + it('should dynamically calculate destAgentId when not available', async () => { + const consultedAgentId = 'dynamic-agent-123'; + calculateDestAgentIdSpy.mockReturnValue(consultedAgentId); + + const expectedResponse: TaskResponse = {data: {interactionId: taskId}} as AgentContact; + contactMock.consultTransfer.mockResolvedValue(expectedResponse); + + await task.consultTransfer(); + + expect(calculateDestAgentIdSpy).toHaveBeenCalledWith(taskDataMock.interaction, taskDataMock.agentId); + expect(contactMock.consultTransfer).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + to: consultedAgentId, + destinationType: 'agent', + }, + }); + }); }); it('should do vteamTransfer if destinationType is queue and return the expected response', async () => { @@ -1587,12 +1860,6 @@ describe('Task', () => { conferenceTransfer: jest.fn(), }; - // Re-setup the getDestinationAgentId spy for conference methods - getDestinationAgentIdSpy = jest - .spyOn(Utils, 'getDestinationAgentId') - .mockReturnValue(taskDataMock.destAgentId); - - task = new Task(contactMock, webCallingService, taskDataMock, { wrapUpProps: { wrapUpReasonList: [] }, autoWrapEnabled: false, @@ -1616,7 +1883,7 @@ describe('Task', () => { interactionId: taskId, data: { agentId: taskDataMock.agentId, // From task data agent ID - to: taskDataMock.destAgentId, // From getDestinationAgentId() using task participants + to: taskDataMock.destAgentId, // From calculateDestAgentId() using task participants destinationType: 'agent', // From consultation data }, }); @@ -1658,6 +1925,166 @@ describe('Task', () => { interactionId: taskId, }); }); + + it('should dynamically calculate destAgentId from participants when this.data.destAgentId is null', async () => { + // Simulate scenario where destAgentId is not preserved (e.g., after hold/unhold) + task.data.destAgentId = null; + + const consultedAgentId = 'consulted-agent-123'; + calculateDestAgentIdSpy.mockReturnValue(consultedAgentId); + + const mockResponse = { + trackingId: 'test-tracking-dynamic', + interactionId: taskId, + }; + contactMock.consultConference.mockResolvedValue(mockResponse); + + const result = await task.consultConference(); + + // Verify calculateDestAgentId was called to dynamically calculate the destination + expect(calculateDestAgentIdSpy).toHaveBeenCalledWith( + taskDataMock.interaction, + taskDataMock.agentId + ); + + // Verify the conference was called with the dynamically calculated destAgentId + expect(contactMock.consultConference).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + agentId: taskDataMock.agentId, + to: consultedAgentId, // Dynamically calculated value + destinationType: 'agent', + }, + }); + expect(result).toEqual(mockResponse); + }); + + it('should throw error when no destination agent is found (queue consult not accepted)', async () => { + // Simulate queue consult scenario where no agent has accepted yet + calculateDestAgentIdSpy.mockReturnValue(''); // No agent found + + await expect(task.consultConference()).rejects.toThrow('No agent has accepted this queue consult yet'); + + // Verify the conference was NOT called + expect(contactMock.consultConference).not.toHaveBeenCalled(); + }); + + it('should calculate destination type from participant type for regular agents', async () => { + const destAgentId = 'consulted-agent-456'; + + calculateDestAgentIdSpy = jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValue(destAgentId); + calculateDestTypeSpy = jest.spyOn(Utils, 'calculateDestType').mockReturnValue('agent'); + + const mockResponse = {trackingId: 'test-tracking-id', interactionId: taskId}; + contactMock.consultConference.mockResolvedValue(mockResponse); + + await task.consultConference(); + + expect(calculateDestTypeSpy).toHaveBeenCalledWith( + task.data.interaction, + taskDataMock.agentId + ); + + expect(contactMock.consultConference).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + agentId: taskDataMock.agentId, + to: destAgentId, + destinationType: 'agent', + }, + }); + }); + + it('should use DN destination type for dial number participants', async () => { + const destAgentId = 'dn-uuid-123'; + + calculateDestAgentIdSpy = jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValue(destAgentId); + calculateDestTypeSpy = jest.spyOn(Utils, 'calculateDestType').mockReturnValue('dialNumber'); + + const mockResponse = {trackingId: 'test-tracking-id-dn', interactionId: taskId}; + contactMock.consultConference.mockResolvedValue(mockResponse); + + await task.consultConference(); + + expect(contactMock.consultConference).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + agentId: taskDataMock.agentId, + to: destAgentId, + destinationType: 'dialNumber', + }, + }); + }); + + it('should use EpDn destination type for entry point dial number participants', async () => { + const destAgentId = 'epdn-uuid-456'; + + calculateDestAgentIdSpy = jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValue(destAgentId); + calculateDestTypeSpy = jest.spyOn(Utils, 'calculateDestType').mockReturnValue('entryPoint'); + + const mockResponse = {trackingId: 'test-tracking-id-epdn', interactionId: taskId}; + contactMock.consultConference.mockResolvedValue(mockResponse); + + await task.consultConference(); + + expect(contactMock.consultConference).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + agentId: taskDataMock.agentId, + to: destAgentId, + destinationType: 'entryPoint', + }, + }); + }); + + it('should fall back to task.data.destinationType when calculateDestType returns empty', async () => { + const destAgentId = 'consulted-agent-789'; + + calculateDestAgentIdSpy = jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValue(destAgentId); + calculateDestTypeSpy = jest.spyOn(Utils, 'calculateDestType').mockReturnValue(''); // No type found + + task.data.destinationType = 'EPDN'; + + const mockResponse = {trackingId: 'test-tracking-id-fallback', interactionId: taskId}; + contactMock.consultConference.mockResolvedValue(mockResponse); + + await task.consultConference(); + + expect(contactMock.consultConference).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + agentId: taskDataMock.agentId, + to: destAgentId, + destinationType: 'EPDN', // Falls back to task.data.destinationType + }, + }); + }); + + it('should handle CBT scenarios with correct destination type', async () => { + const destAgentId = 'agent-cbt-uuid'; + + calculateDestAgentIdSpy = jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValue(destAgentId); + calculateDestTypeSpy = jest.spyOn(Utils, 'calculateDestType').mockReturnValue('dialNumber'); + + const mockResponse = {trackingId: 'test-tracking-id-cbt', interactionId: taskId}; + contactMock.consultConference.mockResolvedValue(mockResponse); + + await task.consultConference(); + + expect(calculateDestTypeSpy).toHaveBeenCalledWith( + task.data.interaction, + taskDataMock.agentId + ); + + expect(contactMock.consultConference).toHaveBeenCalledWith({ + interactionId: taskId, + data: { + agentId: taskDataMock.agentId, + to: destAgentId, + destinationType: 'dialNumber', // dialNumber for CBT scenarios + }, + }); + }); }); describe('exitConference', () => { @@ -1707,9 +2134,6 @@ describe('Task', () => { }); }); - // TODO: Uncomment this test section in future PR for Multi-Party Conference support (>3 participants) - // Conference transfer tests will be uncommented when implementing enhanced multi-party conference functionality - /* describe('transferConference', () => { it('should successfully transfer conference', async () => { const mockResponse = { @@ -1756,6 +2180,5 @@ describe('Task', () => { }); }); }); - */ }); }); diff --git a/packages/@webex/internal-plugin-llm/src/constants.ts b/packages/@webex/internal-plugin-llm/src/constants.ts index b985f98c75e..5f78f380d89 100644 --- a/packages/@webex/internal-plugin-llm/src/constants.ts +++ b/packages/@webex/internal-plugin-llm/src/constants.ts @@ -1,2 +1,4 @@ // eslint-disable-next-line import/prefer-default-export export const LLM = 'llm'; + +export const LLM_DEFAULT_SESSION = 'llm-default-session'; diff --git a/packages/@webex/internal-plugin-llm/src/llm.ts b/packages/@webex/internal-plugin-llm/src/llm.ts index a9a1679a50b..049802b03a1 100644 --- a/packages/@webex/internal-plugin-llm/src/llm.ts +++ b/packages/@webex/internal-plugin-llm/src/llm.ts @@ -2,7 +2,7 @@ import Mercury from '@webex/internal-plugin-mercury'; -import {LLM} from './constants'; +import {LLM, LLM_DEFAULT_SESSION} from './constants'; // eslint-disable-next-line no-unused-vars import {ILLMChannel} from './llm.types'; @@ -42,39 +42,46 @@ export const config = { */ export default class LLMChannel extends (Mercury as any) implements ILLMChannel { namespace = LLM; - + defaultSessionId = LLM_DEFAULT_SESSION; /** - * If the LLM plugin has been registered and listening - * @instance - * @type {Boolean} - * @public + * Map to store connection-specific data for multiple LLM connections + * @private + * @type {Map} */ - - private webSocketUrl?: string; - - private binding?: string; - - private locusUrl?: string; - - private datachannelUrl?: string; + private connections: Map< + string, + { + webSocketUrl?: string; + binding?: string; + locusUrl?: string; + datachannelUrl?: string; + } + > = new Map(); /** * Register to the websocket * @param {string} llmSocketUrl + * @param {string} sessionId - Connection identifier * @returns {Promise} */ - private register = (llmSocketUrl: string): Promise => + private register = ( + llmSocketUrl: string, + sessionId: string = LLM_DEFAULT_SESSION + ): Promise => this.request({ method: 'POST', url: llmSocketUrl, body: {deviceUrl: this.webex.internal.device.url}, }) .then((res: {body: {webSocketUrl: string; binding: string}}) => { - this.webSocketUrl = res.body.webSocketUrl; - this.binding = res.body.binding; + // Get or create connection data + const sessionData = this.connections.get(sessionId) || {}; + sessionData.webSocketUrl = res.body.webSocketUrl; + sessionData.binding = res.body.binding; + this.connections.set(sessionId, sessionData); }) .catch((error: any) => { - this.logger.error(`Error connecting to websocket: ${error}`); + this.logger.error(`Error connecting to websocket for ${sessionId}: ${error}`); throw error; }); @@ -82,50 +89,107 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel * Register and connect to the websocket * @param {string} locusUrl * @param {string} datachannelUrl + * @param {string} sessionId - Connection identifier * @returns {Promise} */ - public registerAndConnect = (locusUrl: string, datachannelUrl: string): Promise => - this.register(datachannelUrl).then(() => { + public registerAndConnect = ( + locusUrl: string, + datachannelUrl: string, + sessionId: string = LLM_DEFAULT_SESSION + ): Promise => + this.register(datachannelUrl, sessionId).then(() => { if (!locusUrl || !datachannelUrl) return undefined; - this.locusUrl = locusUrl; - this.datachannelUrl = datachannelUrl; - this.connect(this.webSocketUrl); + + // Get or create connection data + const sessionData = this.connections.get(sessionId) || {}; + sessionData.locusUrl = locusUrl; + sessionData.datachannelUrl = datachannelUrl; + this.connections.set(sessionId, sessionData); + + return this.connect(sessionData.webSocketUrl, sessionId); }); /** * Tells if LLM socket is connected + * @param {string} sessionId - Connection identifier * @returns {boolean} connected */ - public isConnected = (): boolean => this.connected; + public isConnected = (sessionId = LLM_DEFAULT_SESSION): boolean => { + const socket = this.getSocket(sessionId); + + return socket ? socket.connected : false; + }; /** * Tells if LLM socket is binding + * @param {string} sessionId - Connection identifier * @returns {string} binding */ - public getBinding = (): string => this.binding; + public getBinding = (sessionId = LLM_DEFAULT_SESSION): string => { + const sessionData = this.connections.get(sessionId); + + return sessionData?.binding; + }; /** * Get Locus URL for the connection + * @param {string} sessionId - Connection identifier * @returns {string} locus Url */ - public getLocusUrl = (): string => this.locusUrl; + public getLocusUrl = (sessionId = LLM_DEFAULT_SESSION): string => { + const sessionData = this.connections.get(sessionId); + + return sessionData?.locusUrl; + }; /** * Get data channel URL for the connection + * @param {string} sessionId - Connection identifier * @returns {string} data channel Url */ - public getDatachannelUrl = (): string => this.datachannelUrl; + public getDatachannelUrl = (sessionId = LLM_DEFAULT_SESSION): string => { + const sessionData = this.connections.get(sessionId); + + return sessionData?.datachannelUrl; + }; /** * Disconnects websocket connection * @param {{code: number, reason: string}} options - The disconnect option object with code and reason + * @param {string} sessionId - Connection identifier + * @returns {Promise} + */ + public disconnectLLM = ( + options: {code: number; reason: string}, + sessionId: string = LLM_DEFAULT_SESSION + ): Promise => + this.disconnect(options, sessionId).then(() => { + // Clean up sessions data + this.connections.delete(sessionId); + }); + + /** + * Disconnects all LLM websocket connections + * @param {{code: number, reason: string}} options - The disconnect option object with code and reason * @returns {Promise} */ - public disconnectLLM = (options: object): Promise => - this.disconnect(options).then(() => { - this.locusUrl = undefined; - this.datachannelUrl = undefined; - this.binding = undefined; - this.webSocketUrl = undefined; + public disconnectAllLLM = (options?: {code: number; reason: string}): Promise => + this.disconnectAll(options).then(() => { + // Clean up all connection data + this.connections.clear(); }); + + /** + * Get all active LLM connections + * @returns {Map} Map of sessionId to session data + */ + public getAllConnections = (): Map< + string, + { + webSocketUrl?: string; + binding?: string; + locusUrl?: string; + datachannelUrl?: string; + } + > => new Map(this.connections); } diff --git a/packages/@webex/internal-plugin-llm/src/llm.types.ts b/packages/@webex/internal-plugin-llm/src/llm.types.ts index a9a462adbcf..16f0ff03673 100644 --- a/packages/@webex/internal-plugin-llm/src/llm.types.ts +++ b/packages/@webex/internal-plugin-llm/src/llm.types.ts @@ -1,9 +1,24 @@ interface ILLMChannel { - registerAndConnect: (locusUrl: string, datachannelUrl: string) => Promise; - isConnected: () => boolean; - getBinding: () => string; - getLocusUrl: () => string; - disconnectLLM: (options: {code: number; reason: string}) => Promise; + registerAndConnect: ( + locusUrl: string, + datachannelUrl: string, + sessionId?: string + ) => Promise; + isConnected: (sessionId?: string) => boolean; + getBinding: (sessionId?: string) => string; + getLocusUrl: (sessionId?: string) => string; + getDatachannelUrl: (sessionId?: string) => string; + disconnectLLM: (options: {code: number; reason: string}, sessionId?: string) => Promise; + disconnectAllLLM: (options?: {code: number; reason: string}) => Promise; + getAllConnections: () => Map< + string, + { + webSocketUrl?: string; + binding?: string; + locusUrl?: string; + datachannelUrl?: string; + } + >; } // eslint-disable-next-line import/prefer-default-export export type {ILLMChannel}; diff --git a/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js b/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js index 746bc35a8a5..fb2129b3b71 100644 --- a/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js +++ b/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js @@ -21,7 +21,8 @@ describe('plugin-llm', () => { llmService = webex.internal.llm; llmService.connect = sinon.stub().callsFake(() => { - llmService.connected = true; + // Simulate a successful connection by stubbing getSocket to return connected: true + llmService.getSocket = sinon.stub().returns({connected: true}); }); llmService.disconnect = sinon.stub().resolves(true); llmService.request = sinon.stub().resolves({ diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index 707e86053ea..12fed89c151 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -6,7 +6,7 @@ import url from 'url'; import {WebexPlugin} from '@webex/webex-core'; -import {deprecated, oneFlight} from '@webex/common'; +import {deprecated} from '@webex/common'; import {camelCase, get, set} from 'lodash'; import backoff from 'backoff'; @@ -25,6 +25,7 @@ const normalReconnectReasons = ['idle', 'done (forced)', 'pong not received', 'p const Mercury = WebexPlugin.extend({ namespace: 'Mercury', lastError: undefined, + defaultSessionId: 'mercury-default-session', session: { connected: { @@ -39,7 +40,18 @@ const Mercury = WebexPlugin.extend({ default: false, type: 'boolean', }, - socket: 'object', + sockets: { + default: () => new Map(), + type: 'object', + }, + backoffCalls: { + default: () => new Map(), + type: 'object', + }, + _shutdownSwitchoverBackoffCalls: { + default: () => new Map(), + type: 'object', + }, localClusterServiceUrls: 'object', mercuryTimeOffset: { default: undefined, @@ -96,6 +108,95 @@ const Mercury = WebexPlugin.extend({ }); }, + /** + * Attach event listeners to a socket. + * @param {Socket} socket - The socket to attach listeners to + * @param {sessionId} sessionId - The socket related session ID + * @returns {void} + */ + _attachSocketEventListeners(socket, sessionId) { + socket.on('close', (event) => this._onclose(sessionId, event, socket)); + socket.on('message', (...args) => this._onmessage(sessionId, ...args)); + socket.on('pong', (...args) => this._setTimeOffset(sessionId, ...args)); + socket.on('sequence-mismatch', (...args) => + this._emit(sessionId, 'sequence-mismatch', ...args) + ); + socket.on('ping-pong-latency', (...args) => + this._emit(sessionId, 'ping-pong-latency', ...args) + ); + }, + + /** + * Handle imminent shutdown by establishing a new connection while keeping + * the current one alive (make-before-break). + * Idempotent: will no-op if already in progress. + * @param {string} sessionId - The session ID for which the shutdown is imminent + * @returns {void} + */ + _handleImminentShutdown(sessionId) { + const oldSocket = this.sockets.get(sessionId); + + try { + if (this._shutdownSwitchoverBackoffCalls.get(sessionId)) { + this.logger.info( + `${this.namespace}: [shutdown] switchover already in progress for ${sessionId}` + ); + + return; + } + this._shutdownSwitchoverId = `${Date.now()}`; + this.logger.info( + `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId} for ${sessionId}` + ); + + this._connectWithBackoff(undefined, sessionId, { + isShutdownSwitchover: true, + attemptOptions: { + isShutdownSwitchover: true, + onSuccess: (newSocket, webSocketUrl) => { + this.logger.info( + `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl} for ${sessionId}` + ); + + // Atomically switch active socket reference + this.socket = this.sockets.get(this.defaultSessionId) || newSocket; + this.connected = this.hasConnectedSockets(); // remain connected throughout + + this._emit(sessionId, 'event:mercury_shutdown_switchover_complete', { + url: webSocketUrl, + }); + + if (oldSocket) { + this.logger.info( + `${this.namespace}: [shutdown] old socket retained; server will close with 4001` + ); + } + }, + }, + }) + .then(() => { + this.logger.info( + `${this.namespace}: [shutdown] switchover completed successfully for ${sessionId}` + ); + }) + .catch((err) => { + this.logger.info( + `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection for ${sessionId}: `, + err + ); + this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: err}); + // Old socket will eventually close with 4001, triggering normal reconnection + }); + } catch (e) { + this.logger.error( + `${this.namespace}: [shutdown] error during switchover for ${sessionId}`, + e + ); + this._shutdownSwitchoverBackoffCalls.delete(sessionId); + this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: e}); + } + }, + /** * Get the last error. * @returns {any} The last error. @@ -104,29 +205,96 @@ const Mercury = WebexPlugin.extend({ return this.lastError; }, - @oneFlight - connect(webSocketUrl) { - if (this.connected) { - this.logger.info(`${this.namespace}: already connected, will not connect again`); + /** + * Get all active socket connections + * @returns {Map} Map of sessionId to socket instances + */ + getSockets() { + return this.sockets; + }, + + /** + * Get a specific socket by connection ID + * @param {string} sessionId - The connection identifier + * @returns {Socket|undefined} The socket instance or undefined if not found + */ + getSocket(sessionId = this.defaultSessionId) { + return this.sockets.get(sessionId); + }, + + /** + * Check if any sockets are connected + * @returns {boolean} True if at least one socket is connected + */ + hasConnectedSockets() { + for (const socket of this.sockets.values()) { + if (socket && socket.connected) { + return true; + } + } + + return false; + }, + + /** + * Check if any sockets are connecting + * @returns {boolean} True if at least one socket is connected + */ + hasConnectingSockets() { + for (const socket of this.sockets.values()) { + if (socket && socket.connecting) { + return true; + } + } + + return false; + }, + + // @oneFlight + connect(webSocketUrl, sessionId = this.defaultSessionId) { + if (!this._connectPromises) this._connectPromises = new Map(); + + // First check if there's already a connection promise for this session + if (this._connectPromises.has(sessionId)) { + this.logger.info( + `${this.namespace}: connection ${sessionId} already in progress, returning existing promise` + ); + + return this._connectPromises.get(sessionId); + } + + const sessionSocket = this.sockets.get(sessionId); + if (sessionSocket?.connected || sessionSocket?.connecting) { + this.logger.info( + `${this.namespace}: connection ${sessionId} already connected, will not connect again` + ); return Promise.resolve(); } this.connecting = true; - this.logger.info(`${this.namespace}: starting connection attempt`); + this.logger.info(`${this.namespace}: starting connection attempt for ${sessionId}`); this.logger.info( `${this.namespace}: debug_mercury_logging stack: `, new Error('debug_mercury_logging').stack ); - return Promise.resolve( + const connectPromise = Promise.resolve( this.webex.internal.device.registered || this.webex.internal.device.register() - ).then(() => { - this.logger.info(`${this.namespace}: connecting`); + ) + .then(() => { + this.logger.info(`${this.namespace}: connecting ${sessionId}`); - return this._connectWithBackoff(webSocketUrl); - }); + return this._connectWithBackoff(webSocketUrl, sessionId); + }) + .finally(() => { + this._connectPromises.delete(sessionId); + }); + + this._connectPromises.set(sessionId, connectPromise); + + return connectPromise; }, logout() { @@ -136,7 +304,7 @@ const Mercury = WebexPlugin.extend({ new Error('debug_mercury_logging').stack ); - return this.disconnect( + return this.disconnectAll( this.config.beforeLogoutOptionsCloseReason && !normalReconnectReasons.includes(this.config.beforeLogoutOptionsCloseReason) ? {code: 3050, reason: this.config.beforeLogoutOptionsCloseReason} @@ -144,21 +312,63 @@ const Mercury = WebexPlugin.extend({ ); }, - @oneFlight - disconnect(options) { + // @oneFlight + disconnect(options, sessionId = this.defaultSessionId) { return new Promise((resolve) => { - if (this.backoffCall) { - this.logger.info(`${this.namespace}: aborting connection`); - this.backoffCall.abort(); + const backoffCall = this.backoffCalls.get(sessionId); + if (backoffCall) { + this.logger.info(`${this.namespace}: aborting connection ${sessionId}`); + backoffCall.abort(); + this.backoffCalls.delete(sessionId); } - - if (this.socket) { - this.socket.removeAllListeners('message'); - this.once('offline', resolve); - resolve(this.socket.close(options || undefined)); + const shutdownSwitchoverBackoffCalls = this._shutdownSwitchoverBackoffCalls.get(sessionId); + if (shutdownSwitchoverBackoffCalls) { + this.logger.info(`${this.namespace}: aborting shutdown switchover connection ${sessionId}`); + shutdownSwitchoverBackoffCalls.abort(); + this._shutdownSwitchoverBackoffCalls.delete(sessionId); } + // Clean up any pending connection promises + if (this._connectPromises) { + this._connectPromises.delete(sessionId); + } + + const sessionSocket = this.sockets.get(sessionId); + const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`; + if (sessionSocket) { + sessionSocket.removeAllListeners('message'); + sessionSocket.connecting = false; + sessionSocket.connected = false; + this.once(sessionId === this.defaultSessionId ? 'offline' : `offline${suffix}`, resolve); + resolve(sessionSocket.close(options || undefined)); + } resolve(); + + // Update overall connected status + this.connected = this.hasConnectedSockets(); + }); + }, + + /** + * Disconnect all socket connections + * @param {object} options - Close options + * @returns {Promise} Promise that resolves when all connections are closed + */ + disconnectAll(options) { + const disconnectPromises = []; + + for (const sessionId of this.sockets.keys()) { + disconnectPromises.push(this.disconnect(options, sessionId)); + } + + return Promise.all(disconnectPromises).then(() => { + this.connected = false; + this.sockets.clear(); + this.backoffCalls.clear(); + // Clear connection promises to prevent stale promises + if (this._connectPromises) { + this._connectPromises.clear(); + } }); }, @@ -233,55 +443,69 @@ const Mercury = WebexPlugin.extend({ }); }, - _attemptConnection(socketUrl, callback) { + _attemptConnection(socketUrl, sessionId, callback, options = {}) { + const {isShutdownSwitchover = false, onSuccess = null} = options; + const socket = new Socket(); - let attemptWSUrl; + socket.connecting = true; + let newWSUrl; - socket.on('close', (...args) => this._onclose(...args)); - socket.on('message', (...args) => this._onmessage(...args)); - socket.on('pong', (...args) => this._setTimeOffset(...args)); - socket.on('sequence-mismatch', (...args) => this._emit('sequence-mismatch', ...args)); - socket.on('ping-pong-latency', (...args) => this._emit('ping-pong-latency', ...args)); + this._attachSocketEventListeners(socket, sessionId); - Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]) - .then(([webSocketUrl, token]) => { - if (!this.backoffCall) { - const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`; + const backoffCall = isShutdownSwitchover + ? this._shutdownSwitchoverBackoffCalls.get(sessionId) + : this.backoffCalls.get(sessionId); - this.logger.info(msg); + // Check appropriate backoff call based on connection type + if (isShutdownSwitchover && !backoffCall) { + const msg = `${this.namespace}: prevent socket open when switchover backoff call no longer defined for ${sessionId}`; + const err = new Error(msg); - return Promise.reject(new Error(msg)); - } + this.logger.info(msg); - attemptWSUrl = webSocketUrl; + // Call the callback with the error before rejecting + callback(err); - let options = { - forceCloseDelay: this.config.forceCloseDelay, - pingInterval: this.config.pingInterval, - pongTimeout: this.config.pongTimeout, - token: token.toString(), - trackingId: `${this.webex.sessionId}_${Date.now()}`, - logger: this.logger, - }; + return Promise.reject(err); + } - // if the consumer has supplied request options use them - if (this.webex.config.defaultMercuryOptions) { - this.logger.info(`${this.namespace}: setting custom options`); - options = {...options, ...this.webex.config.defaultMercuryOptions}; - } + if (!isShutdownSwitchover && !backoffCall) { + const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined for ${sessionId}`; + const err = new Error(msg); - // Set the socket before opening it. This allows a disconnect() to close - // the socket if it is in the process of being opened. - this.socket = socket; + this.logger.info(msg); - this.logger.info(`${this.namespace} connection url: ${webSocketUrl}`); + // Call the callback with the error before rejecting + callback(err); + + return Promise.reject(err); + } + + // For shutdown switchover, don't set socket yet (make-before-break) + // For normal connection, set socket before opening to allow disconnect() to close it + if (!isShutdownSwitchover) { + this.sockets.set(sessionId, socket); + } + + return this._prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover) + .then((webSocketUrl) => { + newWSUrl = webSocketUrl; - return socket.open(webSocketUrl, options); - }) - .then(() => { this.logger.info( - `${this.namespace}: connected to mercury, success, action: connected, url: ${attemptWSUrl}` + `${this.namespace}: ${ + isShutdownSwitchover ? '[shutdown] switchover' : '' + } connected to mercury, success, action: connected for ${sessionId}, url: ${newWSUrl}` ); + + // Custom success handler for shutdown switchover + if (onSuccess) { + onSuccess(socket, webSocketUrl); + callback(); + + return Promise.resolve(); + } + + // Default behavior for normal connection callback(); return this.webex.internal.feature @@ -295,32 +519,49 @@ const Mercury = WebexPlugin.extend({ }); }) .catch((reason) => { + // For shutdown, simpler error handling - just callback for retry + if (isShutdownSwitchover) { + this.logger.info( + `${this.namespace}: [shutdown] switchover attempt failed for ${sessionId}`, + reason + ); + + return callback(reason); + } + + // Normal connection error handling (existing complex logic) this.lastError = reason; // remember the last error + const backoffCall = this.backoffCalls.get(sessionId); // Suppress connection errors that appear to be network related. This // may end up suppressing metrics during outages, but we might not care // (especially since many of our outages happen in a way that client // metrics can't be trusted). - if (reason.code !== 1006 && this.backoffCall && this.backoffCall?.getNumRetries() > 0) { - this._emit('connection_failed', reason, {retries: this.backoffCall?.getNumRetries()}); + if (reason.code !== 1006 && backoffCall && backoffCall?.getNumRetries() > 0) { + this._emit(sessionId, 'connection_failed', reason, { + sessionId, + retries: backoffCall?.getNumRetries(), + }); } this.logger.info( - `${this.namespace}: connection attempt failed`, + `${this.namespace}: connection attempt failed for ${sessionId}`, reason, - this.backoffCall?.getNumRetries() === 0 ? reason.stack : '' + backoffCall?.getNumRetries() === 0 ? reason.stack : '' ); // UnknownResponse is produced by IE for any 4XXX; treated it like a bad // web socket url and let WDM handle the token checking if (reason instanceof UnknownResponse) { this.logger.info( - `${this.namespace}: received unknown response code, refreshing device registration` + `${this.namespace}: received unknown response code for ${sessionId}, refreshing device registration` ); return this.webex.internal.device.refresh().then(() => callback(reason)); } // NotAuthorized implies expired token if (reason instanceof NotAuthorized) { - this.logger.info(`${this.namespace}: received authorization error, reauthorizing`); + this.logger.info( + `${this.namespace}: received authorization error for ${sessionId}, reauthorizing` + ); return this.webex.credentials.refresh({force: true}).then(() => callback(reason)); } @@ -333,8 +574,10 @@ const Mercury = WebexPlugin.extend({ // BadRequest implies current credentials are for a Service Account // Forbidden implies current user is not entitle for Webex if (reason instanceof BadRequest || reason instanceof Forbidden) { - this.logger.warn(`${this.namespace}: received unrecoverable response from mercury`); - this.backoffCall.abort(); + this.logger.warn( + `${this.namespace}: received unrecoverable response from mercury for ${sessionId}` + ); + backoffCall?.abort(); return callback(reason); } @@ -344,10 +587,10 @@ const Mercury = WebexPlugin.extend({ .then((haMessagingEnabled) => { if (haMessagingEnabled) { this.logger.info( - `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${attemptWSUrl} error: ${reason.message}` + `${this.namespace}: received a generic connection error for ${sessionId}, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}` ); - return this.webex.internal.services.markFailedUrl(attemptWSUrl); + return this.webex.internal.services.markFailedUrl(newWSUrl); } return null; @@ -358,42 +601,103 @@ const Mercury = WebexPlugin.extend({ return callback(reason); }) .catch((reason) => { - this.logger.error(`${this.namespace}: failed to handle connection failure`, reason); + this.logger.error( + `${this.namespace}: failed to handle connection failure for ${sessionId}`, + reason + ); callback(reason); }); }, - _connectWithBackoff(webSocketUrl) { + _prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover = false) { + const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection'; + + return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then( + ([webSocketUrl, token]) => { + let options = { + forceCloseDelay: this.config.forceCloseDelay, + pingInterval: this.config.pingInterval, + pongTimeout: this.config.pongTimeout, + token: token.toString(), + trackingId: `${this.webex.sessionId}_${Date.now()}`, + logger: this.logger, + }; + + if (this.webex.config.defaultMercuryOptions) { + const customOptionsMsg = isShutdownSwitchover + ? 'setting custom options for switchover' + : 'setting custom options'; + + this.logger.info(`${this.namespace}: ${customOptionsMsg}`); + options = {...options, ...this.webex.config.defaultMercuryOptions}; + } + + // Set the socket before opening it. This allows a disconnect() to close + // the socket if it is in the process of being opened. + this.sockets.set(sessionId, socket); + this.socket = this.sockets.get(this.defaultSessionId) || socket; + + this.logger.info(`${this.namespace} ${logPrefix} url for ${sessionId}: ${webSocketUrl}`); + + return socket.open(webSocketUrl, options).then(() => webSocketUrl); + } + ); + }, + + _connectWithBackoff(webSocketUrl, sessionId, context = {}) { + const {isShutdownSwitchover = false, attemptOptions = {}} = context; + return new Promise((resolve, reject) => { - // eslint gets confused about whether or not call is actually used + // eslint gets confused about whether call is actually used // eslint-disable-next-line prefer-const let call; - const onComplete = (err) => { - this.connecting = false; - - this.backoffCall = undefined; + const onComplete = (err, sid = sessionId) => { + if (isShutdownSwitchover) { + this._shutdownSwitchoverBackoffCalls.delete(sid); + } else { + this.backoffCalls.delete(sid); + } if (err) { + const msg = isShutdownSwitchover + ? `[shutdown] switchover failed after ${call.getNumRetries()} retries` + : `failed to connect after ${call.getNumRetries()} retries`; + this.logger.info( - `${ - this.namespace - }: failed to connect after ${call.getNumRetries()} retries; log statement about next retry was inaccurate; ${err}` + `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}` ); return reject(err); } - this.connected = true; - this.hasEverConnected = true; - this._emit('online'); - this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true); + // Update overall connected status + const sessionSocket = this.sockets.get(sid); + if (sessionSocket) { + sessionSocket.connecting = false; + sessionSocket.connected = true; + } + // Default success handling for normal connections + if (!isShutdownSwitchover) { + this.connecting = this.hasConnectingSockets(); + this.connected = this.hasConnectedSockets(); + this.hasEverConnected = true; + this._emit(sid, 'online', {sessionId: sid}); + this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true); + } return resolve(); }; - // eslint-disable-next-line prefer-reflect - call = backoff.call((callback) => { - this.logger.info(`${this.namespace}: executing connection attempt ${call.getNumRetries()}`); - this._attemptConnection(webSocketUrl, callback); - }, onComplete); + call = backoff.call( + (callback) => { + const attemptNum = call.getNumRetries(); + const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection'; + + this.logger.info( + `${this.namespace}: executing ${logPrefix} attempt ${attemptNum} for ${sessionId}` + ); + this._attemptConnection(webSocketUrl, sessionId, callback, attemptOptions); + }, + (err) => onComplete(err, sessionId) + ); call.setStrategy( new backoff.ExponentialStrategy({ @@ -402,15 +706,29 @@ const Mercury = WebexPlugin.extend({ }) ); - if (this.config.initialConnectionMaxRetries && !this.hasEverConnected) { + if ( + this.config.initialConnectionMaxRetries && + !this.hasEverConnected && + !isShutdownSwitchover + ) { call.failAfter(this.config.initialConnectionMaxRetries); } else if (this.config.maxRetries) { call.failAfter(this.config.maxRetries); } + // Store the call BEFORE setting up event handlers to prevent race conditions + // Store backoff call reference BEFORE starting (so it's available in _attemptConnection) + if (isShutdownSwitchover) { + this._shutdownSwitchoverBackoffCalls.set(sessionId, call); + } else { + this.backoffCalls.set(sessionId, call); + } + call.on('abort', () => { - this.logger.info(`${this.namespace}: connection aborted`); - reject(new Error('Mercury Connection Aborted')); + const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection'; + + this.logger.info(`${this.namespace}: ${msg} aborted for ${sessionId}`); + reject(new Error(`Mercury ${msg} Aborted for ${sessionId}`)); }); call.on('callback', (err) => { @@ -418,8 +736,12 @@ const Mercury = WebexPlugin.extend({ const number = call.getNumRetries(); const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax); + const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : ''; + this.logger.info( - `${this.namespace}: failed to connect; attempting retry ${number + 1} in ${delay} ms` + `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${ + number + 1 + } in ${delay} ms for ${sessionId}` ); /* istanbul ignore if */ if (process.env.NODE_ENV === 'development') { @@ -428,25 +750,52 @@ const Mercury = WebexPlugin.extend({ return; } - this.logger.info(`${this.namespace}: connected`); + this.logger.info(`${this.namespace}: connected ${sessionId}`); }); call.start(); - - this.backoffCall = call; }); }, _emit(...args) { try { - this.trigger(...args); + if (!args || args.length === 0) { + return; + } + + // New signature: _emit(sessionId, eventName, ...rest) + // Backwards compatibility: if the first arg isn't a known sessionId (or defaultSessionId), + // treat the call as the old signature and forward directly to trigger(...) + const [first, second, ...rest] = args; + + if ( + typeof first === 'string' && + (this.sockets.has(first) || first === this.defaultSessionId) && + typeof second === 'string' + ) { + const sessionId = first; + const eventName = second; + const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`; + + this.trigger(`${eventName}${suffix}`, ...rest); + } else { + // Old usage: _emit(eventName, ...args) + this.trigger(...args); + } } catch (error) { - this.logger.error( - `${this.namespace}: error occurred in event handler:`, - error, - ' with args: ', - args - ); + // Safely handle errors without causing additional issues during cleanup + try { + this.logger.error( + `${this.namespace}: error occurred in event handler:`, + error, + ' with args: ', + args + ); + } catch (logError) { + // If even logging fails, just ignore to prevent cascading errors during cleanup + // eslint-disable-next-line no-console + console.error('Mercury _emit error handling failed:', logError); + } } }, @@ -470,78 +819,152 @@ const Mercury = WebexPlugin.extend({ return handlers; }, - _onclose(event) { + _onclose(sessionId, event, sourceSocket) { // I don't see any way to avoid the complexity or statement count in here. /* eslint complexity: [0] */ try { const reason = event.reason && event.reason.toLowerCase(); - const socketUrl = this.socket.url; + let sessionSocket = this.sockets.get(sessionId); + let socketUrl; + event.sessionId = sessionId; - this.socket.removeAllListeners(); - this.unset('socket'); - this.connected = false; - this._emit('offline', event); - this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false); + const isActiveSocket = sourceSocket === sessionSocket; + if (sourceSocket) { + socketUrl = sourceSocket.url; + } + this.sockets.delete(sessionId); + + if (isActiveSocket) { + // Only tear down state if the currently active socket closed + if (sessionSocket) { + sessionSocket.removeAllListeners(); + sessionSocket = null; + this._emit(sessionId, 'offline', event); + } + // Update overall connected status + this.connecting = this.hasConnectingSockets(); + this.connected = this.hasConnectedSockets(); + + if (!this.connected) { + this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false); + } + } else { + // Old socket closed; do not flip connection state + this.logger.info( + `${this.namespace}: [shutdown] non-active socket closed, code=${event.code} for ${sessionId}` + ); + // Clean up listeners from old socket now that it's closed + if (sourceSocket) { + sourceSocket.removeAllListeners(); + } + } switch (event.code) { case 1003: // metric: disconnect this.logger.info( - `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}` + `${this.namespace}: Mercury service rejected last message for ${sessionId}; will not reconnect: ${event.reason}` ); - this._emit('offline.permanent', event); + if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event); break; case 4000: // metric: disconnect - this.logger.info(`${this.namespace}: socket replaced; will not reconnect`); - this._emit('offline.replaced', event); + this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`); + if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event); + // If not active, nothing to do + break; + case 4001: + // replaced during shutdown + if (isActiveSocket) { + // Server closed active socket with 4001, meaning it expected this connection + // to be replaced, but the switchover in _handleImminentShutdown failed. + // This is a permanent failure - do not reconnect. + this.logger.warn( + `${this.namespace}: active socket closed with 4001; shutdown switchover failed for ${sessionId}` + ); + this._emit(sessionId, 'offline.permanent', event); + } else { + // Expected: old socket closed after successful switchover + this.logger.info( + `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed for ${sessionId}` + ); + this._emit(sessionId, 'offline.replaced', event); + } break; case 1001: case 1005: case 1006: case 1011: - this.logger.info(`${this.namespace}: socket disconnected; reconnecting`); - this._emit('offline.transient', event); - this._reconnect(socketUrl); + this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`); + if (isActiveSocket) { + this._emit(sessionId, 'offline.transient', event); + this.logger.info( + `${this.namespace}: [shutdown] reconnecting active socket to recover for ${sessionId}` + ); + this._reconnect(socketUrl, sessionId); + } // metric: disconnect // if (code == 1011 && reason !== ping error) metric: unexpected disconnect break; case 1000: case 3050: // 3050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block if (normalReconnectReasons.includes(reason)) { - this.logger.info(`${this.namespace}: socket disconnected; reconnecting`); - this._emit('offline.transient', event); - this._reconnect(socketUrl); + this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`); + if (isActiveSocket) { + this._emit(sessionId, 'offline.transient', event); + this.logger.info( + `${this.namespace}: [shutdown] reconnecting due to normal close for ${sessionId}` + ); + this._reconnect(socketUrl, sessionId); + } // metric: disconnect // if (reason === done forced) metric: force closure } else { this.logger.info( - `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}` + `${this.namespace}: socket ${sessionId} disconnected; will not reconnect: ${event.reason}` ); - this._emit('offline.permanent', event); + if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event); } break; default: this.logger.info( - `${this.namespace}: socket disconnected unexpectedly; will not reconnect` + `${this.namespace}: socket ${sessionId} disconnected unexpectedly; will not reconnect` ); // unexpected disconnect - this._emit('offline.permanent', event); + if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event); } } catch (error) { - this.logger.error(`${this.namespace}: error occurred in close handler`, error); + this.logger.error( + `${this.namespace}: error occurred in close handler for ${sessionId}`, + error + ); } }, - _onmessage(event) { - this._setTimeOffset(event); + _onmessage(sessionId, event) { + this._setTimeOffset(sessionId, event); const envelope = event.data; if (process.env.ENABLE_MERCURY_LOGGING) { - this.logger.debug(`${this.namespace}: message envelope: `, envelope); + this.logger.debug(`${this.namespace}: message envelope from ${sessionId}: `, envelope); + } + + envelope.sessionId = sessionId; + + // Handle shutdown message shape: { type: 'shutdown' } + if (envelope && envelope.type === 'shutdown') { + this.logger.info( + `${this.namespace}: [shutdown] imminent shutdown message received for ${sessionId}` + ); + this._emit(sessionId, 'event:mercury_shutdown_imminent', envelope); + + this._handleImminentShutdown(sessionId); + + return Promise.resolve(); } + envelope.sessionId = sessionId; const {data} = envelope; this._applyOverrides(data); @@ -556,7 +979,7 @@ const Mercury = WebexPlugin.extend({ resolve((this.webex[namespace] || this.webex.internal[namespace])[name](data)) ).catch((reason) => this.logger.error( - `${this.namespace}: error occurred in autowired event handler for ${data.eventType}`, + `${this.namespace}: error occurred in autowired event handler for ${data.eventType} from ${sessionId}`, reason ) ); @@ -564,32 +987,35 @@ const Mercury = WebexPlugin.extend({ Promise.resolve() ) .then(() => { - this._emit('event', event.data); + this._emit(sessionId, 'event', envelope); const [namespace] = data.eventType.split('.'); if (namespace === data.eventType) { - this._emit(`event:${namespace}`, envelope); + this._emit(sessionId, `event:${namespace}`, envelope); } else { - this._emit(`event:${namespace}`, envelope); - this._emit(`event:${data.eventType}`, envelope); + this._emit(sessionId, `event:${namespace}`, envelope); + this._emit(sessionId, `event:${data.eventType}`, envelope); } }) .catch((reason) => { - this.logger.error(`${this.namespace}: error occurred processing socket message`, reason); + this.logger.error( + `${this.namespace}: error occurred processing socket message from ${sessionId}`, + reason + ); }); }, - _setTimeOffset(event) { + _setTimeOffset(sessionId, event) { const {wsWriteTimestamp} = event.data; if (typeof wsWriteTimestamp === 'number' && wsWriteTimestamp > 0) { this.mercuryTimeOffset = Date.now() - wsWriteTimestamp; } }, - _reconnect(webSocketUrl) { - this.logger.info(`${this.namespace}: reconnecting`); + _reconnect(webSocketUrl, sessionId = this.defaultSessionId) { + this.logger.info(`${this.namespace}: reconnecting ${sessionId}`); - return this.connect(webSocketUrl); + return this.connect(webSocketUrl, sessionId); }, }); diff --git a/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js b/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js index acc75c8af06..123518068b1 100644 --- a/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js +++ b/packages/@webex/internal-plugin-mercury/src/socket/socket-base.js @@ -33,6 +33,8 @@ export default class Socket extends EventEmitter { this._domain = 'unknown-domain'; this.onmessage = this.onmessage.bind(this); this.onclose = this.onclose.bind(this); + // Increase max listeners to avoid memory leak warning in tests + this.setMaxListeners(5); } /** @@ -358,9 +360,20 @@ export default class Socket extends EventEmitter { return Promise.reject(new Error('`event.data.id` is required')); } + // Don't try to acknowledge if socket is not in open state + if (this.readyState !== 1) { + return Promise.resolve(); // Silently ignore acknowledgment for closed sockets + } + return this.send({ messageId: event.data.id, type: 'ack', + }).catch((error) => { + // Gracefully handle send errors (like INVALID_STATE_ERROR) to prevent test issues + if (error.message === 'INVALID_STATE_ERROR') { + return Promise.resolve(); // Socket was closed, ignore the acknowledgment + } + throw error; // Re-throw other errors }); } diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js index 27f1598450b..a2dd39d77aa 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js @@ -38,14 +38,31 @@ describe('plugin-mercury', () => { }, timestamp: Date.now(), trackingId: `suffix_${uuid.v4()}_${Date.now()}`, + sessionId: 'mercury-default-session', }; beforeEach(() => { clock = FakeTimers.install({now: Date.now()}); }); - afterEach(() => { + afterEach(async () => { clock.uninstall(); + // Clean up mercury socket and mockWebSocket + if (mercury && mercury.socket) { + try { + await mercury.socket.close(); + } catch (e) {} + } + if (mockWebSocket && typeof mockWebSocket.close === 'function') { + mockWebSocket.close(); + } + // Restore stubs + if (Socket.getWebSocketConstructor.restore) { + Socket.getWebSocketConstructor.restore(); + } + if (socketOpenStub && socketOpenStub.restore) { + socketOpenStub.restore(); + } }); beforeEach(() => { @@ -76,6 +93,7 @@ describe('plugin-mercury', () => { }); mercury = webex.internal.mercury; + mercury.defaultSessionId = 'mercury-default-session'; }); afterEach(() => { @@ -301,7 +319,7 @@ describe('plugin-mercury', () => { }) .then(() => { assert.called(offlineSpy); - assert.calledWith(offlineSpy, {code, reason}); + assert.calledWith(offlineSpy, {code, reason, sessionId: 'mercury-default-session'}); switch (action) { case 'close': assert.called(permanentSpy); diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js index 8d8f89e1cf0..620e70c3833 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js @@ -99,9 +99,32 @@ describe('plugin-mercury', () => { }); mercury = webex.internal.mercury; + mercury.defaultSessionId = 'mercury-default-session'; }); - afterEach(() => { + afterEach(async () => { + // Clean up Mercury connections and internal state + if (mercury) { + try { + await mercury.disconnectAll(); + } catch (e) { + // Ignore cleanup errors + } + // Clear any remaining connection promises + if (mercury._connectPromises) { + mercury._connectPromises.clear(); + } + } + + // Ensure mock socket is properly closed + if (mockWebSocket && typeof mockWebSocket.close === 'function') { + try { + mockWebSocket.close(); + } catch (e) { + // Ignore cleanup errors + } + } + if (socketOpenStub) { socketOpenStub.restore(); } @@ -109,6 +132,9 @@ describe('plugin-mercury', () => { if (Socket.getWebSocketConstructor.restore) { Socket.getWebSocketConstructor.restore(); } + + // Small delay to ensure all async operations complete + await new Promise(resolve => setTimeout(resolve, 10)); }); describe('#listen()', () => { @@ -227,7 +253,7 @@ describe('plugin-mercury', () => { const promise = mercury.connect(); const u2cInvalidateEventEnvelope = { data: { - timestamp: "1759289614", + timestamp: '1759289614', }, }; @@ -498,9 +524,13 @@ describe('plugin-mercury', () => { // skipping due to apparent bug with lolex in all browsers but Chrome. skipInBrowser(it)('does not continue attempting to connect', () => { - mercury.connect(); + const promise = mercury.connect(); + + // Wait for the connection to be established before proceeding + mockWebSocket.open(); - return promiseTick(2) + return promise.then(() => + promiseTick(2) .then(() => { clock.tick(6 * webex.internal.mercury.config.backoffTimeReset); @@ -508,7 +538,8 @@ describe('plugin-mercury', () => { }) .then(() => { assert.calledOnce(Socket.prototype.open); - }); + }) + ); }); }); @@ -583,11 +614,11 @@ describe('plugin-mercury', () => { }); describe('#logout()', () => { - it('calls disconnect and logs', () => { + it('calls disconnectAll and logs', () => { sinon.stub(mercury.logger, 'info'); - sinon.stub(mercury, 'disconnect'); + sinon.stub(mercury, 'disconnectAll'); mercury.logout(); - assert.called(mercury.disconnect); + assert.called(mercury.disconnectAll); assert.calledTwice(mercury.logger.info); assert.calledWith(mercury.logger.info.getCall(0), 'Mercury: logout() called'); @@ -599,24 +630,24 @@ describe('plugin-mercury', () => { }); it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout', () => { - sinon.stub(mercury, 'disconnect'); + sinon.stub(mercury, 'disconnectAll'); mercury.config.beforeLogoutOptionsCloseReason = 'done (permanent)'; mercury.logout(); - assert.calledWith(mercury.disconnect, {code: 3050, reason: 'done (permanent)'}); + assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'done (permanent)'}); }); it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send code 3050 for logout if the reason is different than standard', () => { - sinon.stub(mercury, 'disconnect'); + sinon.stub(mercury, 'disconnectAll'); mercury.config.beforeLogoutOptionsCloseReason = 'test'; mercury.logout(); - assert.calledWith(mercury.disconnect, {code: 3050, reason: 'test'}); + assert.calledWith(mercury.disconnectAll, {code: 3050, reason: 'test'}); }); it('uses the config.beforeLogoutOptionsCloseReason to disconnect and will send undefined for logout if the reason is same as standard', () => { - sinon.stub(mercury, 'disconnect'); + sinon.stub(mercury, 'disconnectAll'); mercury.config.beforeLogoutOptionsCloseReason = 'done (forced)'; mercury.logout(); - assert.calledWith(mercury.disconnect, undefined); + assert.calledWith(mercury.disconnectAll, undefined); }); }); @@ -722,12 +753,12 @@ describe('plugin-mercury', () => { return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => { // By this time backoffCall and mercury socket should be defined by the // 'connect' call - assert.isDefined(mercury.backoffCall, 'Mercury backoffCall is not defined'); + assert.isDefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is not defined'); assert.isDefined(mercury.socket, 'Mercury socket is not defined'); // Calling disconnect will abort the backoffCall, close the socket, and // reject the connect mercury.disconnect(); - assert.isUndefined(mercury.backoffCall, 'Mercury backoffCall is still defined'); + assert.isUndefined(mercury.backoffCalls.get('mercury-default-session'), 'Mercury backoffCall is still defined'); // The socket will never be unset (which seems bad) assert.isDefined(mercury.socket, 'Mercury socket is not defined'); @@ -745,15 +776,15 @@ describe('plugin-mercury', () => { let reason; - mercury.backoffCall = undefined; - mercury._attemptConnection('ws://example.com', (_reason) => { + mercury.backoffCalls.clear(); + mercury._attemptConnection('ws://example.com', 'mercury-default-session',(_reason) => { reason = _reason; }); return promiseTick(webex.internal.mercury.config.backoffTimeReset).then(() => { assert.equal( reason.message, - 'Mercury: prevent socket open when backoffCall no longer defined' + `Mercury: prevent socket open when backoffCall no longer defined for ${mercury.defaultSessionId}` ); }); }); @@ -775,7 +806,7 @@ describe('plugin-mercury', () => { return assert.isRejected(promise).then((error) => { const lastError = mercury.getLastError(); - assert.equal(error.message, 'Mercury Connection Aborted'); + assert.equal(error.message, `Mercury Connection Aborted for ${mercury.defaultSessionId}`); assert.isDefined(lastError); assert.equal(lastError, realError); }); @@ -869,7 +900,7 @@ describe('plugin-mercury', () => { }, }; assert.isUndefined(mercury.mercuryTimeOffset); - mercury._setTimeOffset(event); + mercury._setTimeOffset('mercury-default-session', event); assert.isDefined(mercury.mercuryTimeOffset); assert.isTrue(mercury.mercuryTimeOffset > 0); }); @@ -879,7 +910,7 @@ describe('plugin-mercury', () => { wsWriteTimestamp: Date.now() + 60000, }, }; - mercury._setTimeOffset(event); + mercury._setTimeOffset('mercury-default-session', event); assert.isTrue(mercury.mercuryTimeOffset < 0); }); it('handles invalid wsWriteTimestamp', () => { @@ -890,7 +921,7 @@ describe('plugin-mercury', () => { wsWriteTimestamp: invalidTimestamp, }, }; - mercury._setTimeOffset(event); + mercury._setTimeOffset('mercury-default-session', event); assert.isUndefined(mercury.mercuryTimeOffset); }); }); @@ -984,5 +1015,626 @@ describe('plugin-mercury', () => { }); }); }); + + describe('shutdown protocol', () => { + describe('#_handleImminentShutdown()', () => { + let connectWithBackoffStub; + + beforeEach(() => { + mercury.connected = true; + mercury.socket = { + url: 'ws://old-socket.com', + removeAllListeners: sinon.stub(), + }; + connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff'); + connectWithBackoffStub.returns(Promise.resolve()); + sinon.stub(mercury, '_emit'); + }); + + afterEach(() => { + connectWithBackoffStub.restore(); + mercury._emit.restore(); + }); + + it('should be idempotent - no-op if already in progress', () => { + mercury._shutdownSwitchoverInProgress = true; + + mercury._handleImminentShutdown(); + + assert.notCalled(connectWithBackoffStub); + }); + + it('should set switchover flags when called', () => { + mercury._handleImminentShutdown(); + + assert.isTrue(mercury._shutdownSwitchoverInProgress); + assert.isDefined(mercury._shutdownSwitchoverId); + }); + + it('should call _connectWithBackoff with correct parameters', (done) => { + mercury._handleImminentShutdown(); + + process.nextTick(() => { + assert.calledOnce(connectWithBackoffStub); + const callArgs = connectWithBackoffStub.firstCall.args; + assert.isUndefined(callArgs[0]); // webSocketUrl + assert.isObject(callArgs[1]); // context + assert.isTrue(callArgs[1].isShutdownSwitchover); + done(); + }); + }); + + it('should handle exceptions during switchover', () => { + connectWithBackoffStub.restore(); + sinon.stub(mercury, '_connectWithBackoff').throws(new Error('Connection failed')); + + mercury._handleImminentShutdown(); + + assert.isFalse(mercury._shutdownSwitchoverInProgress); + }); + }); + + describe('#_onmessage() with shutdown message', () => { + beforeEach(() => { + sinon.stub(mercury, '_handleImminentShutdown'); + sinon.stub(mercury, '_emit'); + sinon.stub(mercury, '_setTimeOffset'); + }); + + afterEach(() => { + mercury._handleImminentShutdown.restore(); + mercury._emit.restore(); + mercury._setTimeOffset.restore(); + }); + + it('should trigger _handleImminentShutdown on shutdown message', () => { + const shutdownEvent = { + data: { + type: 'shutdown', + }, + }; + + const result = mercury._onmessage(shutdownEvent); + + assert.calledOnce(mercury._handleImminentShutdown); + assert.calledWith(mercury._emit, 'event:mercury_shutdown_imminent', shutdownEvent.data); + assert.instanceOf(result, Promise); + }); + + it('should handle shutdown message without additional data gracefully', () => { + const shutdownEvent = { + data: { + type: 'shutdown', + }, + }; + + mercury._onmessage(shutdownEvent); + + assert.calledOnce(mercury._handleImminentShutdown); + }); + + it('should not trigger shutdown handling for non-shutdown messages', () => { + const regularEvent = { + data: { + type: 'regular', + data: { + eventType: 'conversation.activity', + }, + }, + }; + + mercury._onmessage(regularEvent); + + assert.notCalled(mercury._handleImminentShutdown); + }); + }); + + describe('#_onclose() with code 4001 (shutdown replacement)', () => { + let mockSocket, anotherSocket; + + beforeEach(() => { + mockSocket = { + url: 'ws://active-socket.com', + removeAllListeners: sinon.stub(), + }; + anotherSocket = { + url: 'ws://old-socket.com', + removeAllListeners: sinon.stub(), + }; + mercury.socket = mockSocket; + mercury.connected = true; + sinon.stub(mercury, '_emit'); + sinon.stub(mercury, '_reconnect'); + sinon.stub(mercury, 'unset'); + }); + + afterEach(() => { + mercury._emit.restore(); + mercury._reconnect.restore(); + mercury.unset.restore(); + }); + + it('should handle active socket close with 4001 - permanent failure', () => { + const closeEvent = { + code: 4001, + reason: 'replaced during shutdown', + }; + + mercury._onclose(closeEvent, mockSocket); + + assert.calledWith(mercury._emit, 'offline.permanent', closeEvent); + assert.notCalled(mercury._reconnect); // No reconnect for 4001 on active socket + assert.isFalse(mercury.connected); + }); + + it('should handle non-active socket close with 4001 - no reconnect needed', () => { + const closeEvent = { + code: 4001, + reason: 'replaced during shutdown', + }; + + mercury._onclose(closeEvent, anotherSocket); + + assert.calledWith(mercury._emit, 'offline.replaced', closeEvent); + assert.notCalled(mercury._reconnect); + assert.isTrue(mercury.connected); // Should remain connected + assert.notCalled(mercury.unset); + }); + + it('should distinguish between active and non-active socket closes', () => { + const closeEvent = { + code: 4001, + reason: 'replaced during shutdown', + }; + + // Test non-active socket + mercury._onclose(closeEvent, anotherSocket); + assert.calledWith(mercury._emit, 'offline.replaced', closeEvent); + + // Reset the spy call history + mercury._emit.resetHistory(); + + // Test active socket + mercury._onclose(closeEvent, mockSocket); + assert.calledWith(mercury._emit, 'offline.permanent', closeEvent); + }); + + it('should handle missing sourceSocket parameter (treats as non-active)', () => { + const closeEvent = { + code: 4001, + reason: 'replaced during shutdown', + }; + + mercury._onclose(closeEvent); // No sourceSocket parameter + + // With simplified logic, undefined !== this.socket, so isActiveSocket = false + assert.calledWith(mercury._emit, 'offline.replaced', closeEvent); + assert.notCalled(mercury._reconnect); + }); + + it('should clean up event listeners from non-active socket when it closes', () => { + const closeEvent = { + code: 4001, + reason: 'replaced during shutdown', + }; + + // Close non-active socket (not the active one) + mercury._onclose(closeEvent, anotherSocket); + + // Verify listeners were removed from the old socket + // The _onclose method checks if sourceSocket !== this.socket (non-active) + // and then calls removeAllListeners in the else branch + assert.calledOnce(anotherSocket.removeAllListeners); + }); + + it('should not clean up listeners from active socket listeners until close handler runs', () => { + const closeEvent = { + code: 4001, + reason: 'replaced during shutdown', + }; + + // Close active socket + mercury._onclose(closeEvent, mockSocket); + + // Verify listeners were removed from active socket + assert.calledOnce(mockSocket.removeAllListeners); + }); + }); + + describe('shutdown switchover with retry logic', () => { + let connectWithBackoffStub; + + beforeEach(() => { + mercury.connected = true; + mercury.socket = { + url: 'ws://old-socket.com', + removeAllListeners: sinon.stub(), + }; + connectWithBackoffStub = sinon.stub(mercury, '_connectWithBackoff'); + sinon.stub(mercury, '_emit'); + }); + + afterEach(() => { + connectWithBackoffStub.restore(); + mercury._emit.restore(); + }); + + it('should call _connectWithBackoff with shutdown switchover context', (done) => { + connectWithBackoffStub.returns(Promise.resolve()); + + mercury._handleImminentShutdown(); + + // Give it a tick for the async call to happen + process.nextTick(() => { + assert.calledOnce(connectWithBackoffStub); + const callArgs = connectWithBackoffStub.firstCall.args; + + assert.isUndefined(callArgs[0]); // webSocketUrl is undefined + assert.isObject(callArgs[1]); // context object + assert.isTrue(callArgs[1].isShutdownSwitchover); + assert.isObject(callArgs[1].attemptOptions); + assert.isTrue(callArgs[1].attemptOptions.isShutdownSwitchover); + done(); + }); + }); + + it('should set _shutdownSwitchoverInProgress flag during switchover', () => { + connectWithBackoffStub.returns(new Promise(() => {})); // Never resolves + + mercury._handleImminentShutdown(); + + assert.isTrue(mercury._shutdownSwitchoverInProgress); + }); + + it('should emit success event when switchover completes', async () => { + // We need to actually call the onSuccess callback to trigger the event + connectWithBackoffStub.callsFake((url, context) => { + // Simulate successful connection by calling onSuccess + if (context && context.attemptOptions && context.attemptOptions.onSuccess) { + const mockSocket = {url: 'ws://new-socket.com'}; + context.attemptOptions.onSuccess(mockSocket, 'ws://new-socket.com'); + } + return Promise.resolve(); + }); + + mercury._handleImminentShutdown(); + + // Wait for async operations + await promiseTick(50); + + const emitCalls = mercury._emit.getCalls(); + const hasCompleteEvent = emitCalls.some( + (call) => call.args[0] === 'event:mercury_shutdown_switchover_complete' + ); + + assert.isTrue(hasCompleteEvent, 'Should emit switchover complete event'); + }); + + it('should emit failure event when switchover exhausts retries', async () => { + const testError = new Error('Connection failed'); + + connectWithBackoffStub.returns(Promise.reject(testError)); + + mercury._handleImminentShutdown(); + await promiseTick(50); + + // Check if failure event was emitted + const emitCalls = mercury._emit.getCalls(); + const hasFailureEvent = emitCalls.some( + (call) => + call.args[0] === 'event:mercury_shutdown_switchover_failed' && + call.args[1] && + call.args[1].reason === testError + ); + + assert.isTrue(hasFailureEvent, 'Should emit switchover failed event'); + }); + + it('should allow old socket to be closed by server after switchover failure', async () => { + connectWithBackoffStub.returns(Promise.reject(new Error('Failed'))); + + mercury._handleImminentShutdown(); + await promiseTick(50); + + // Old socket should not be closed immediately - server will close it + assert.equal(mercury.socket.removeAllListeners.callCount, 0); + }); + }); + + describe('#_prepareAndOpenSocket()', () => { + let mockSocket, prepareUrlStub, getUserTokenStub; + + beforeEach(() => { + mockSocket = { + open: sinon.stub().returns(Promise.resolve()), + }; + prepareUrlStub = sinon + .stub(mercury, '_prepareUrl') + .returns(Promise.resolve('ws://example.com')); + getUserTokenStub = webex.credentials.getUserToken; + getUserTokenStub.returns( + Promise.resolve({ + toString: () => 'mock-token', + }) + ); + }); + + afterEach(() => { + prepareUrlStub.restore(); + }); + + it('should prepare URL and get user token', async () => { + await mercury._prepareAndOpenSocket(mockSocket, 'ws://test.com', false); + + assert.calledOnce(prepareUrlStub); + assert.calledWith(prepareUrlStub, 'ws://test.com'); + assert.calledOnce(getUserTokenStub); + }); + + it('should open socket with correct options for normal connection', async () => { + await mercury._prepareAndOpenSocket(mockSocket, undefined, false); + + assert.calledOnce(mockSocket.open); + const callArgs = mockSocket.open.firstCall.args; + + assert.equal(callArgs[0], 'ws://example.com'); + assert.isObject(callArgs[1]); + assert.equal(callArgs[1].token, 'mock-token'); + assert.isDefined(callArgs[1].forceCloseDelay); + assert.isDefined(callArgs[1].pingInterval); + assert.isDefined(callArgs[1].pongTimeout); + }); + + it('should log with correct prefix for normal connection', async () => { + await mercury._prepareAndOpenSocket(mockSocket, undefined, false); + + // The method should complete successfully - we're testing it runs without error + // Actual log message verification is complex due to existing stubs in parent scope + assert.calledOnce(mockSocket.open); + }); + + it('should log with shutdown prefix for shutdown connection', async () => { + await mercury._prepareAndOpenSocket(mockSocket, undefined, true); + + // The method should complete successfully with shutdown flag + assert.calledOnce(mockSocket.open); + }); + + it('should merge custom mercury options when provided', async () => { + webex.config.defaultMercuryOptions = { + customOption: 'test-value', + pingInterval: 99999, + }; + + await mercury._prepareAndOpenSocket(mockSocket, undefined, false); + + const callArgs = mockSocket.open.firstCall.args; + + assert.equal(callArgs[1].customOption, 'test-value'); + assert.equal(callArgs[1].pingInterval, 99999); // Custom value overrides default + }); + + it('should return the webSocketUrl after opening', async () => { + const result = await mercury._prepareAndOpenSocket(mockSocket, undefined, false); + + assert.equal(result, 'ws://example.com'); + }); + + it('should handle errors during socket open', async () => { + mockSocket.open.returns(Promise.reject(new Error('Open failed'))); + + try { + await mercury._prepareAndOpenSocket(mockSocket, undefined, false); + assert.fail('Should have thrown an error'); + } catch (err) { + assert.equal(err.message, 'Open failed'); + } + }); + }); + + describe('#_attemptConnection() with shutdown switchover', () => { + let mockSocket, prepareAndOpenSocketStub, callback; + + beforeEach(() => { + mockSocket = { + url: 'ws://test.com', + }; + prepareAndOpenSocketStub = sinon + .stub(mercury, '_prepareAndOpenSocket') + .returns(Promise.resolve('ws://new-socket.com')); + callback = sinon.stub(); + mercury._shutdownSwitchoverBackoffCall = {}; // Mock backoff call + mercury.socket = mockSocket; + mercury.connected = true; + sinon.stub(mercury, '_emit'); + sinon.stub(mercury, '_attachSocketEventListeners'); + }); + + afterEach(() => { + prepareAndOpenSocketStub.restore(); + mercury._emit.restore(); + mercury._attachSocketEventListeners.restore(); + }); + + it('should not set socket reference before opening for shutdown switchover', async () => { + const originalSocket = mercury.socket; + + await mercury._attemptConnection('ws://test.com', callback, { + isShutdownSwitchover: true, + onSuccess: (newSocket, url) => { + // During onSuccess, verify original socket is still set + // (socket swap happens inside onSuccess callback in _handleImminentShutdown) + assert.equal(mercury.socket, originalSocket); + }, + }); + + // After onSuccess, socket should still be original since we only swap in _handleImminentShutdown + assert.equal(mercury.socket, originalSocket); + }); + + it('should call onSuccess callback with new socket and URL for shutdown', async () => { + const onSuccessStub = sinon.stub(); + + await mercury._attemptConnection('ws://test.com', callback, { + isShutdownSwitchover: true, + onSuccess: onSuccessStub, + }); + + assert.calledOnce(onSuccessStub); + assert.equal(onSuccessStub.firstCall.args[1], 'ws://new-socket.com'); + }); + + it('should emit shutdown switchover complete event', async () => { + const oldSocket = mercury.socket; + + await mercury._attemptConnection('ws://test.com', callback, { + isShutdownSwitchover: true, + onSuccess: (newSocket, url) => { + // Simulate the onSuccess callback behavior + mercury.socket = newSocket; + mercury.connected = true; + mercury._emit('event:mercury_shutdown_switchover_complete', {url}); + }, + }); + + assert.calledWith( + mercury._emit, + 'event:mercury_shutdown_switchover_complete', + sinon.match.has('url', 'ws://new-socket.com') + ); + }); + + it('should use simpler error handling for shutdown switchover failures', async () => { + prepareAndOpenSocketStub.returns(Promise.reject(new Error('Connection failed'))); + + try { + await mercury._attemptConnection('ws://test.com', callback, { + isShutdownSwitchover: true, + }); + } catch (err) { + // Error should be caught and passed to callback + } + + // Should call callback with error for retry + assert.calledOnce(callback); + assert.instanceOf(callback.firstCall.args[0], Error); + }); + + it('should check _shutdownSwitchoverBackoffCall for shutdown connections', () => { + mercury._shutdownSwitchoverBackoffCall = undefined; + + const result = mercury._attemptConnection('ws://test.com', callback, { + isShutdownSwitchover: true, + }); + + return result.catch((err) => { + assert.instanceOf(err, Error); + assert.match(err.message, /switchover backoff call/); + }); + }); + }); + + describe('#_connectWithBackoff() with shutdown switchover', () => { + // Note: These tests verify the parameterization logic without running real backoff timers + // to avoid test hangs. The backoff mechanism itself is tested in other test suites. + + it('should use shutdown-specific parameters when called', () => { + // Stub _connectWithBackoff to prevent real execution + const connectWithBackoffStub = sinon + .stub(mercury, '_connectWithBackoff') + .returns(Promise.resolve()); + + mercury._handleImminentShutdown(); + + // Verify it was called with shutdown context + assert.calledOnce(connectWithBackoffStub); + const callArgs = connectWithBackoffStub.firstCall.args; + assert.isObject(callArgs[1]); // context + assert.isTrue(callArgs[1].isShutdownSwitchover); + + connectWithBackoffStub.restore(); + }); + + it('should pass shutdown switchover options to _attemptConnection', () => { + // Stub _attemptConnection to verify it receives correct options + const attemptStub = sinon.stub(mercury, '_attemptConnection'); + attemptStub.callsFake((url, callback) => { + // Immediately succeed + callback(); + }); + + // Call _connectWithBackoff with shutdown context + const context = { + isShutdownSwitchover: true, + attemptOptions: { + isShutdownSwitchover: true, + onSuccess: () => {}, + }, + }; + + // Start the backoff + const promise = mercury._connectWithBackoff(undefined, context); + + // Check that _attemptConnection was called with shutdown options + return promise.then(() => { + assert.calledOnce(attemptStub); + const callArgs = attemptStub.firstCall.args; + assert.isObject(callArgs[2]); // options parameter + assert.isTrue(callArgs[2].isShutdownSwitchover); + + attemptStub.restore(); + }); + }); + + it('should set and clear state flags appropriately', () => { + // Stub to prevent actual connection + sinon.stub(mercury, '_attemptConnection').callsFake((url, callback) => callback()); + + mercury._shutdownSwitchoverInProgress = true; + + const promise = mercury._connectWithBackoff(undefined, { + isShutdownSwitchover: true, + attemptOptions: {isShutdownSwitchover: true, onSuccess: () => {}}, + }); + + return promise.then(() => { + // Should be cleared after completion + assert.isFalse(mercury._shutdownSwitchoverInProgress); + mercury._attemptConnection.restore(); + }); + }); + }); + + describe('#disconnect() with shutdown switchover in progress', () => { + let abortStub; + + beforeEach(() => { + mercury.socket = { + close: sinon.stub().returns(Promise.resolve()), + removeAllListeners: sinon.stub(), + }; + abortStub = sinon.stub(); + mercury._shutdownSwitchoverBackoffCall = { + abort: abortStub, + }; + }); + + it('should abort shutdown switchover backoff call on disconnect', async () => { + await mercury.disconnect(); + + assert.calledOnce(abortStub); + }); + + it('should handle disconnect when no switchover is in progress', async () => { + mercury._shutdownSwitchoverBackoffCall = undefined; + + // Should not throw + await mercury.disconnect(); + + // Should still close the socket + assert.calledOnce(mercury.socket.close); + }); + }); + }); }); }); diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js index da1226443dc..f7c51c9fac8 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/socket.js @@ -466,12 +466,12 @@ describe('plugin-mercury', () => { .then(() => assert.calledWith(mockWebSocket.close, 3001, 'Custom Normal'))); it('accepts the logout reason', () => - socket - .close({ - code: 3050, - reason: 'done (permanent)', - }) - .then(() => assert.calledWith(mockWebSocket.close, 3050, 'done (permanent)'))); + socket + .close({ + code: 3050, + reason: 'done (permanent)', + }) + .then(() => assert.calledWith(mockWebSocket.close, 3050, 'done (permanent)'))); it('can safely be called called multiple times', () => { const p1 = socket.close(); diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts index e99d41e2b96..3033bf8110a 100644 --- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts @@ -1415,8 +1415,11 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin { */ public setDeviceInfo(device: any): void { // This was created to fix the circular dependency between internal-plugin-device and internal-plugin-metrics - this.logger.log('CallDiagnosticMetrics: @setDeviceInfo called', device); - + this.logger.log('CallDiagnosticMetrics: @setDeviceInfo called', { + userId: device?.userId, + deviceId: device?.url, + orgId: device?.orgId, + }); this.device = device; } } diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts index ffd20e77c2c..8b198d26e78 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts @@ -3906,7 +3906,7 @@ describe('internal-plugin-metrics', () => { assert.deepEqual(webexLoggerLogCalls[0].args, [ 'CallDiagnosticMetrics: @setDeviceInfo called', - device, + {userId: 'userId', deviceId: 'deviceUrl', orgId: 'orgId'}, ]); assert.deepEqual(cd.device, device); diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index cde5e365a85..4edd95f179a 100644 --- a/packages/@webex/media-helpers/package.json +++ b/packages/@webex/media-helpers/package.json @@ -22,9 +22,9 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/internal-media-core": "2.19.0", + "@webex/internal-media-core": "2.20.1", "@webex/ts-events": "^1.1.0", - "@webex/web-media-effects": "2.27.1" + "@webex/web-media-effects": "2.32.1" }, "browserify": { "transform": [ diff --git a/packages/@webex/media-helpers/src/webrtc-core.ts b/packages/@webex/media-helpers/src/webrtc-core.ts index 1c12109d7cf..f3dff92c05a 100644 --- a/packages/@webex/media-helpers/src/webrtc-core.ts +++ b/packages/@webex/media-helpers/src/webrtc-core.ts @@ -29,6 +29,8 @@ export { RemoteStreamEventNames, type VideoContentHint, type StreamState, + type InboundAudioIssueEvent, + InboundAudioIssueSubTypes, } from '@webex/internal-media-core'; export type ServerMuteReason = diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 44a03cfbe5a..794c7a4e484 100644 --- a/packages/@webex/plugin-meetings/package.json +++ b/packages/@webex/plugin-meetings/package.json @@ -62,7 +62,7 @@ "dependencies": { "@webex/common": "workspace:*", "@webex/event-dictionary-ts": "^1.0.1930", - "@webex/internal-media-core": "2.19.0", + "@webex/internal-media-core": "2.20.1", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", @@ -75,7 +75,7 @@ "@webex/plugin-people": "workspace:*", "@webex/plugin-rooms": "workspace:*", "@webex/ts-sdp": "^1.8.1", - "@webex/web-capabilities": "^1.6.0", + "@webex/web-capabilities": "^1.7.1", "@webex/webex-core": "workspace:*", "ampersand-collection": "^2.0.2", "bowser": "^2.11.0", diff --git a/packages/@webex/plugin-meetings/src/common/errors/webex-errors.ts b/packages/@webex/plugin-meetings/src/common/errors/webex-errors.ts index 0047639b743..a5f5943a41c 100644 --- a/packages/@webex/plugin-meetings/src/common/errors/webex-errors.ts +++ b/packages/@webex/plugin-meetings/src/common/errors/webex-errors.ts @@ -169,3 +169,22 @@ class AddMediaFailed extends WebexMeetingsError { } export {AddMediaFailed}; WebExMeetingsErrors[AddMediaFailed.CODE] = AddMediaFailed; + +/** + * @class SdpResponseTimeoutError + * @classdesc Raised whenever we timeout waiting for remote SDP answer + * @extends WebexMeetingsError + * @property {number} code - 30204 + * @property {string} message - 'Timed out waiting for REMOTE SDP ANSWER' + */ +class SdpResponseTimeoutError extends WebexMeetingsError { + static CODE = 30204; + + // eslint-disable-next-line require-jsdoc + constructor() { + super(SdpResponseTimeoutError.CODE, 'Timed out waiting for REMOTE SDP ANSWER'); + } +} + +export {SdpResponseTimeoutError}; +WebExMeetingsErrors[SdpResponseTimeoutError.CODE] = SdpResponseTimeoutError; diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index b59cb4de847..65de78ee17b 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -347,7 +347,7 @@ export const EVENT_TRIGGERS = { MEETING_SELF_LEFT: 'meeting:self:left', NETWORK_QUALITY: 'network:quality', MEDIA_NEGOTIATED: 'media:negotiated', - MEDIA_INBOUND_AUDIO_ISSUE_DETECTED: 'media:inboundAudio:issueDetected', + MEDIA_INBOUND_AUDIO_ISSUE_DETECTED: 'media:inboundAudio:issueDetected', // event.data: InboundAudioIssueEvent // the following events apply only to multistream media connections ACTIVE_SPEAKER_CHANGED: 'media:activeSpeakerChanged', REMOTE_VIDEO_SOURCE_COUNT_CHANGED: 'media:remoteVideoSourceCountChanged', @@ -964,6 +964,8 @@ export const DISPLAY_HINTS = { LOWER_SOMEONE_ELSES_HAND: 'LOWER_SOMEONE_ELSES_HAND', LEAVE_TRANSFER_HOST_END_MEETING: 'LEAVE_TRANSFER_HOST_END_MEETING', LEAVE_END_MEETING: 'LEAVE_END_MEETING', + STREAMING_STATUS_STARTED: 'STREAMING_STATUS_STARTED', + STREAMING_STATUS_STOPPED: 'STREAMING_STATUS_STOPPED', CAPTION_START: 'CAPTION_START', CAPTION_STATUS_ACTIVE: 'CAPTION_STATUS_ACTIVE', MANUAL_CAPTION_START: 'MANUAL_CAPTION_START', diff --git a/packages/@webex/plugin-meetings/src/index.ts b/packages/@webex/plugin-meetings/src/index.ts index 9985fd9ed55..9435a688d99 100644 --- a/packages/@webex/plugin-meetings/src/index.ts +++ b/packages/@webex/plugin-meetings/src/index.ts @@ -18,6 +18,7 @@ import { import Meeting from './meeting'; import MeetingInfoUtil from './meeting-info/utilv2'; import JoinMeetingError from './common/errors/join-meeting'; +import {SdpResponseTimeoutError} from './common/errors/webex-errors'; registerPlugin('meetings', Meetings, { config, @@ -73,6 +74,7 @@ export { Meeting, MeetingInfoUtil, JoinWebinarError, + SdpResponseTimeoutError, }; export {RemoteMedia} from './multistream/remoteMedia'; diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts index 0991b5fbac2..9a6cd60d7af 100644 --- a/packages/@webex/plugin-meetings/src/media/index.ts +++ b/packages/@webex/plugin-meetings/src/media/index.ts @@ -194,6 +194,12 @@ Media.createMediaConnection = ( config.stopIceGatheringAfterFirstRelayCandidate = stopIceGatheringAfterFirstRelayCandidate; } + if (BrowserInfo.isEdge() || BrowserInfo.isChrome()) { + // we need this for getting inbound audio metadata + // but the audioLevel that we use is only available on Chromium based browsers + config.enableInboundAudioLevelMonitoring = true; + } + return new MultistreamRoapMediaConnection( config, meetingId, diff --git a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts index 918db20ce56..58a5a789cba 100644 --- a/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/src/meeting/in-meeting-actions.ts @@ -41,6 +41,8 @@ interface IInMeetingActions { isLocalRecordingStarted?: boolean; isLocalRecordingStopped?: boolean; isLocalRecordingPaused?: boolean; + isLocalStreamingStarted?: boolean; + isLocalStreamingStopped?: boolean; isManualCaptionActive?: boolean; isSaveTranscriptsEnabled?: boolean; @@ -187,6 +189,10 @@ export default class InMeetingActions implements IInMeetingActions { isManualCaptionActive = null; + isLocalStreamingStarted = null; + + isLocalStreamingStopped = null; + isSaveTranscriptsEnabled = null; isSpokenLanguageAutoDetectionEnabled = null; @@ -366,6 +372,8 @@ export default class InMeetingActions implements IInMeetingActions { isLocalRecordingStarted: this.isLocalRecordingStarted, isLocalRecordingStopped: this.isLocalRecordingStopped, isLocalRecordingPaused: this.isLocalRecordingPaused, + isLocalStreamingStarted: this.isLocalStreamingStarted, + isLocalStreamingStopped: this.isLocalStreamingStopped, canStopManualCaption: this.canStopManualCaption, isManualCaptionActive: this.isManualCaptionActive, isSaveTranscriptsEnabled: this.isSaveTranscriptsEnabled, diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 392f4f1f5b9..4a85439fdae 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -30,6 +30,7 @@ import { NetworkQualityMonitor, StatsMonitor, StatsMonitorEventNames, + InboundAudioIssueSubTypes, } from '@webex/internal-media-core'; import { @@ -57,6 +58,7 @@ import { NoMediaEstablishedYetError, UserNotJoinedError, AddMediaFailed, + SdpResponseTimeoutError, } from '../common/errors/webex-errors'; import LoggerProxy from '../common/logs/logger-proxy'; @@ -4251,6 +4253,8 @@ export default class Meeting extends StatelessWebexPlugin { isLocalRecordingStarted: MeetingUtil.isLocalRecordingStarted(this.userDisplayHints), isLocalRecordingStopped: MeetingUtil.isLocalRecordingStopped(this.userDisplayHints), isLocalRecordingPaused: MeetingUtil.isLocalRecordingPaused(this.userDisplayHints), + isLocalStreamingStarted: MeetingUtil.isLocalStreamingStarted(this.userDisplayHints), + isLocalStreamingStopped: MeetingUtil.isLocalStreamingStopped(this.userDisplayHints), isManualCaptionActive: MeetingUtil.isManualCaptionActive(this.userDisplayHints), isSaveTranscriptsEnabled: MeetingUtil.isSaveTranscriptsEnabled(this.userDisplayHints), isSpokenLanguageAutoDetectionEnabled: MeetingUtil.isSpokenLanguageAutoDetectionEnabled( @@ -7449,7 +7453,7 @@ export default class Meeting extends StatelessWebexPlugin { } seconds` ); - const error = new Error('Timed out waiting for REMOTE SDP ANSWER'); + const error = new SdpResponseTimeoutError(); // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ @@ -10041,4 +10045,31 @@ export default class Meeting extends StatelessWebexPlugin { displayName ); } + + /** + * Call out a SIP participant to a meeting + * @param {string} address - The SIP address or phone number + * @param {string} displayName - The display name for the participant + * @param {string} [correlationId] - Optional correlation ID + * @returns {Promise} Promise that resolves when the call-out is initiated + */ + sipCallOut(address: string, displayName: string) { + return this.meetingRequest.sipCallOut( + this.meetingInfo.meetingId, + this.meetingInfo.meetingId, + address, + displayName + ); + } + + /** + * Cancel an ongoing SIP call-out + * @param {string} participantId - The participant ID to cancel + * @returns {Promise} Promise that resolves when the call-out is cancelled + * @public + * @memberof Meetings + */ + cancelSipCallOut(participantId: string) { + return this.meetingRequest.cancelSipCallOut(participantId); + } } diff --git a/packages/@webex/plugin-meetings/src/meeting/request.ts b/packages/@webex/plugin-meetings/src/meeting/request.ts index d21f06b479a..611174d094c 100644 --- a/packages/@webex/plugin-meetings/src/meeting/request.ts +++ b/packages/@webex/plugin-meetings/src/meeting/request.ts @@ -1046,4 +1046,84 @@ export default class MeetingRequest extends StatelessWebexPlugin { }, }); } + + /** + * Call out to a SIP participant + * + * @param {any} meetingId - The meeting ID. + * @param {any} meetingNumber - The meeting number. + * @param {string} address - The SIP address to call out. + * @param {string} displayName - The display name for the participant. + * @returns {Promise} The API response + */ + public async sipCallOut(meetingId, meetingNumber, address, displayName) { + const body: any = { + meetingId, + meetingNumber, + address, + displayName, + }; + try { + // @ts-ignore + const response = await this.request({ + method: HTTP_VERBS.POST, + service: 'hydra', + resource: 'meetingParticipants/callout', + body, + headers: { + Accept: 'application/json', + }, + }); + + LoggerProxy.logger.info('Meetings:request#sipCallOut --> SIP call-out successful', response); + + return response.body; + } catch (err) { + LoggerProxy.logger.error( + `Meetings:request#sipCallOut --> Error calling out SIP participant, error ${JSON.stringify( + err + )}` + ); + throw err; + } + } + + /** + * Cancel an ongoing SIP call-out + * + * @param {string} participantId - The ID of the participant whose SIP call-out should be cancelled. + * @returns {Promise} The API response + */ + public async cancelSipCallOut(participantId) { + const body = { + participantId, + }; + + try { + // @ts-ignore + const response = await this.request({ + method: HTTP_VERBS.POST, + service: 'hydra', + resource: 'meetingParticipants/cancelCallout', + body, + headers: { + Accept: 'application/json', + }, + }); + + LoggerProxy.logger.info( + 'Meetings:request#cancelSipCallOut --> SIP call-out cancelled successfully', + response + ); + + return response.body; + } catch (err) { + LoggerProxy.logger.error( + `Meetings:request#cancelSipCallOut --> Error cancelling SIP participant call-out, error ${JSON.stringify( + err + )}` + ); + throw err; + } + } } diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index b1b96efd3c6..4b80a58d661 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -633,6 +633,12 @@ const MeetingUtil = { isLocalRecordingPaused: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOCAL_RECORDING_STATUS_PAUSED), + isLocalStreamingStarted: (displayHints) => + displayHints.includes(DISPLAY_HINTS.STREAMING_STATUS_STARTED), + + isLocalStreamingStopped: (displayHints) => + displayHints.includes(DISPLAY_HINTS.STREAMING_STATUS_STOPPED), + canStopManualCaption: (displayHints) => displayHints.includes(DISPLAY_HINTS.MANUAL_CAPTION_STOP), isManualCaptionActive: (displayHints) => diff --git a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts index 9e02c6b964d..dc78a3ce9e7 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts @@ -141,12 +141,12 @@ describe('createMediaConnection', () => { const roapMediaConnectionConstructorStub = sinon .stub(InternalMediaCoreModule, 'RoapMediaConnection') .returns(fakeRoapMediaConnection); - + StaticConfig.set({bandwidth: {audio: 123, video: 456, startBitrate: 999}}); - + const ENABLE_EXTMAP = false; const ENABLE_RTX = true; - + Media.createMediaConnection(false, 'sendonly-debug-id', 'meetingId', { mediaProperties: { mediaDirection: { @@ -168,7 +168,7 @@ describe('createMediaConnection', () => { turnServerInfo: undefined, iceCandidatesTimeout: undefined, }); - + assert.calledWith( roapMediaConnectionConstructorStub, sinon.match.any, @@ -194,12 +194,12 @@ describe('createMediaConnection', () => { const roapMediaConnectionConstructorStub = sinon .stub(InternalMediaCoreModule, 'RoapMediaConnection') .returns(fakeRoapMediaConnection); - + StaticConfig.set({bandwidth: {audio: 123, video: 456, startBitrate: 999}}); - + const ENABLE_EXTMAP = true; const ENABLE_RTX = false; - + Media.createMediaConnection(false, 'recvonly-debug-id', 'meetingId', { mediaProperties: { mediaDirection: { @@ -221,7 +221,7 @@ describe('createMediaConnection', () => { turnServerInfo: undefined, iceCandidatesTimeout: undefined, }); - + assert.calledWith( roapMediaConnectionConstructorStub, sinon.match.any, @@ -242,7 +242,6 @@ describe('createMediaConnection', () => { 'recvonly-debug-id' ); }); - it('creates a MultistreamRoapMediaConnection when multistream is enabled', () => { const multistreamRoapMediaConnectionConstructorStub = sinon @@ -511,6 +510,138 @@ describe('createMediaConnection', () => { ); }); + const testEnableInboundAudioLevelMonitoring = ( + testName: string, + browserStubs: {isChrome?: boolean; isEdge?: boolean; isFirefox?: boolean}, + isMultistream: boolean, + expectedConfig: object, + additionalOptions = {} + ) => { + it(testName, () => { + const connectionConstructorStub = isMultistream + ? sinon.stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection') + : sinon.stub(InternalMediaCoreModule, 'RoapMediaConnection'); + + connectionConstructorStub.returns(fakeRoapMediaConnection); + + // Set up browser stubs + sinon.stub(BrowserInfo, 'isChrome').returns(browserStubs.isChrome || false); + sinon.stub(BrowserInfo, 'isEdge').returns(browserStubs.isEdge || false); + sinon.stub(BrowserInfo, 'isFirefox').returns(browserStubs.isFirefox || false); + + const baseOptions = { + mediaProperties: { + mediaDirection: { + sendAudio: true, + sendVideo: true, + sendShare: false, + receiveAudio: true, + receiveVideo: true, + receiveShare: true, + }, + ...(isMultistream + ? {} + : { + audioStream: fakeAudioStream, + videoStream: fakeVideoStream, + shareVideoTrack: null, + shareAudioTrack: null, + }), + }, + ...(isMultistream + ? {} + : { + remoteQualityLevel: 'HIGH', + enableRtx: true, + enableExtmap: true, + }), + ...additionalOptions, + }; + + if (!isMultistream) { + StaticConfig.set({bandwidth: {audio: 123, video: 456, startBitrate: 999}}); + } + + Media.createMediaConnection(isMultistream, 'debug string', 'meeting id', baseOptions); + + if (isMultistream) { + assert.calledOnceWithExactly( + connectionConstructorStub, + expectedConfig, + 'meeting id', + sinon.match.func, + sinon.match.func, + sinon.match.func + ); + } else { + assert.calledOnceWithExactly( + connectionConstructorStub, + expectedConfig, + sinon.match.object, + 'debug string' + ); + } + }); + }; + + testEnableInboundAudioLevelMonitoring( + 'enables enableInboundAudioLevelMonitoring for multistream when browser is Chrome', + {isChrome: true}, + true, + { + iceServers: [], + disableAudioTwcc: true, + enableInboundAudioLevelMonitoring: true, + } + ); + + testEnableInboundAudioLevelMonitoring( + 'enables enableInboundAudioLevelMonitoring for multistream when browser is Edge', + {isEdge: true}, + true, + { + iceServers: [], + disableAudioTwcc: true, + enableInboundAudioLevelMonitoring: true, + } + ); + + testEnableInboundAudioLevelMonitoring( + 'does not enable enableInboundAudioLevelMonitoring for multistream when browser is Firefox', + {isFirefox: true}, + true, + { + iceServers: [], + disableAudioTwcc: true, + doFullIce: true, + stopIceGatheringAfterFirstRelayCandidate: undefined, + } + ); + + testEnableInboundAudioLevelMonitoring( + 'does not enable enableInboundAudioLevelMonitoring for non-multistream connections even when browser is Chrome', + {isChrome: true}, + false, + { + iceServers: [], + iceCandidatesTimeout: undefined, + skipInactiveTransceivers: false, + requireH264: true, + sdpMunging: { + convertPort9to0: false, + addContentSlides: true, + bandwidthLimits: { + audio: 123, + video: 456, + }, + startBitrate: 999, + periodicKeyframes: 20, + disableExtmap: false, + disableRtx: false, + }, + } + ); + [ {testCase: 'turnServerInfo is undefined', turnServerInfo: undefined}, { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts index 65eceb06791..66b393bd2cf 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/in-meeting-actions.ts @@ -35,6 +35,8 @@ describe('plugin-meetings', () => { isLocalRecordingStarted: null, isLocalRecordingStopped: null, isLocalRecordingPaused: null, + isLocalStreamingStarted:null, + isLocalStreamingStopped:null, isManualCaptionActive: null, isPremiseRecordingEnabled: null, isSaveTranscriptsEnabled: null, @@ -137,6 +139,8 @@ describe('plugin-meetings', () => { 'isLocalRecordingStarted', 'isLocalRecordingStopped', 'isLocalRecordingPaused', + 'isLocalStreamingStarted', + 'isLocalStreamingStopped', 'canSetMuteOnEntry', 'canUnsetMuteOnEntry', 'canSetDisallowUnmute', diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 9ea9c4396b2..3106be93d65 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -97,6 +97,7 @@ import PermissionError from '../../../../src/common/errors/permission'; import JoinWebinarError from '../../../../src/common/errors/join-webinar-error'; import IntentToJoinError from '../../../../src/common/errors/intent-to-join'; import MultistreamNotSupportedError from '../../../../src/common/errors/multistream-not-supported-error'; +import {SdpResponseTimeoutError} from '@webex/plugin-meetings/src/common/errors/webex-errors'; import testUtils from '../../../utils/testUtils'; import { MeetingInfoV2CaptchaError, @@ -1999,18 +2000,15 @@ describe('plugin-meetings', () => { // Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting assert.calledOnce(webex.internal.newMetrics.submitClientEvent); - assert.calledWithMatch( - webex.internal.newMetrics.submitClientEvent, - { - name: 'client.call.initiated', - payload: { - trigger: 'user-interaction', - isRoapCallEnabled: true, - pstnAudioType: undefined - }, - options: {meetingId: meeting.id}, - } - ); + assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, { + name: 'client.call.initiated', + payload: { + trigger: 'user-interaction', + isRoapCallEnabled: true, + pstnAudioType: undefined, + }, + options: {meetingId: meeting.id}, + }); }); }); it('should fail if password is required', async () => { @@ -2679,7 +2677,11 @@ describe('plugin-meetings', () => { // simulate timeout waiting for the SDP answer that never comes await clock.tickAsync(ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT); - await assert.isRejected(result); + await assert.isRejected( + result, + SdpResponseTimeoutError, + 'Timed out waiting for REMOTE SDP ANSWER' + ); assert.calledOnceWithExactly(getErrorPayloadForClientErrorCodeStub, { clientErrorCode: 2007, @@ -4087,7 +4089,7 @@ describe('plugin-meetings', () => { member2: {isInMeeting: false, isInLobby: true}, member3: {isInMeeting: false, isInLobby: false}, member4: {isInMeeting: true, isInLobby: false}, - } + }, }; sinon.stub(meeting, 'getMembers').returns({membersCollection: fakeMembersCollection}); const fakeData = {intervalMetadata: {}}; @@ -6739,7 +6741,7 @@ describe('plugin-meetings', () => { // Verify pstnCorrelationId was set assert.exists(meeting.pstnCorrelationId); assert.notEqual(meeting.pstnCorrelationId, meeting.correlationId); - const firstPstnCorrelationId = meeting.pstnCorrelationId + const firstPstnCorrelationId = meeting.pstnCorrelationId; meeting.meetingRequest.dialIn.resetHistory(); @@ -6814,15 +6816,19 @@ describe('plugin-meetings', () => { assert.equal(e, error); // Verify behavioral metric was sent with dial_in_correlation_id - assert.calledWith(Metrics.sendBehavioralMetric, BEHAVIORAL_METRICS.ADD_DIAL_IN_FAILURE, { - correlation_id: meeting.correlationId, - dial_in_url: meeting.dialInUrl, - dial_in_correlation_id: sinon.match.string, - locus_id: meeting.locusUrl.split('/').pop(), - client_url: meeting.deviceUrl, - reason: error.error.message, - stack: error.stack, - }); + assert.calledWith( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.ADD_DIAL_IN_FAILURE, + { + correlation_id: meeting.correlationId, + dial_in_url: meeting.dialInUrl, + dial_in_correlation_id: sinon.match.string, + locus_id: meeting.locusUrl.split('/').pop(), + client_url: meeting.deviceUrl, + reason: error.error.message, + stack: error.stack, + } + ); // Verify pstnCorrelationId was cleared after error assert.equal(meeting.pstnCorrelationId, undefined); @@ -6841,15 +6847,19 @@ describe('plugin-meetings', () => { assert.equal(e, error); // Verify behavioral metric was sent with dial_out_correlation_id - assert.calledWith(Metrics.sendBehavioralMetric, BEHAVIORAL_METRICS.ADD_DIAL_OUT_FAILURE, { - correlation_id: meeting.correlationId, - dial_out_url: meeting.dialOutUrl, - dial_out_correlation_id: sinon.match.string, - locus_id: meeting.locusUrl.split('/').pop(), - client_url: meeting.deviceUrl, - reason: error.error.message, - stack: error.stack, - }); + assert.calledWith( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.ADD_DIAL_OUT_FAILURE, + { + correlation_id: meeting.correlationId, + dial_out_url: meeting.dialOutUrl, + dial_out_correlation_id: sinon.match.string, + locus_id: meeting.locusUrl.split('/').pop(), + client_url: meeting.deviceUrl, + reason: error.error.message, + stack: error.stack, + } + ); // Verify pstnCorrelationId was cleared after error assert.equal(meeting.pstnCorrelationId, undefined); @@ -6894,7 +6904,7 @@ describe('plugin-meetings', () => { // Verify that pstnCorrelationId is still cleared even when no phone connection is active assert.equal(meeting.pstnCorrelationId, undefined); - // And verify no disconnect was attempted + // And verify no disconnect was attempted assert.notCalled(MeetingUtil.disconnectPhoneAudio); }); }); @@ -10568,7 +10578,7 @@ describe('plugin-meetings', () => { describe('#setUpLocusUrlListener', () => { it('listens to the locus url update event', (done) => { const newLocusUrl = 'newLocusUrl/12345'; - const payload = {url: newLocusUrl} + const payload = {url: newLocusUrl}; meeting.members = {locusUrlUpdate: sinon.stub().returns(Promise.resolve(test1))}; meeting.recordingController = {setLocusUrl: sinon.stub().returns(undefined)}; @@ -10611,7 +10621,7 @@ describe('plugin-meetings', () => { }); it('update mainLocusUrl for controlsOptionManager if payload.isMainLocus as true', (done) => { const newLocusUrl = 'newLocusUrl/12345'; - const payload = {url: newLocusUrl, isMainLocus: true} + const payload = {url: newLocusUrl, isMainLocus: true}; meeting.controlsOptionsManager = {setLocusUrl: sinon.stub().returns(undefined)}; @@ -10843,7 +10853,9 @@ describe('plugin-meetings', () => { meeting.meetingRequest.changeMeetingFloor = sinon.stub().returns(Promise.resolve()); (meeting.deviceUrl = 'deviceUrl.com'), (meeting.localShareInstanceId = '1234-5678'); webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); - webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon.stub().returns(1000); + webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon + .stub() + .returns(1000); }); it('should call changeMeetingFloor()', async () => { meeting.screenShareFloorState = 'GRANTED'; @@ -11494,8 +11506,10 @@ describe('plugin-meetings', () => { canShareWhiteBoardSpy = sinon.spy(MeetingUtil, 'canShareWhiteBoard'); canMoveToLobbySpy = sinon.spy(MeetingUtil, 'canMoveToLobby'); showAutoEndMeetingWarningSpy = sinon.spy(MeetingUtil, 'showAutoEndMeetingWarning'); - isSpokenLanguageAutoDetectionEnabledSpy = sinon.spy(MeetingUtil, 'isSpokenLanguageAutoDetectionEnabled'); - + isSpokenLanguageAutoDetectionEnabledSpy = sinon.spy( + MeetingUtil, + 'isSpokenLanguageAutoDetectionEnabled' + ); }); afterEach(() => { @@ -12502,7 +12516,9 @@ describe('plugin-meetings', () => { meeting.meetingRequest.changeMeetingFloor = sinon.stub().returns(Promise.resolve()); meeting.deviceUrl = 'deviceUrl.com'; webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); - webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon.stub().returns(1000); + webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon + .stub() + .returns(1000); webex.internal.newMetrics.submitClientEvent = sinon.stub(); }); it('should stop the whiteboard share', async () => { @@ -12606,7 +12622,9 @@ describe('plugin-meetings', () => { meeting.deviceUrl = 'my-web-url'; meeting.locusInfo.info = {isWebinar: false}; webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); - webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon.stub().returns(1500); + webex.internal.newMetrics.callDiagnosticLatencies.getShareDuration = sinon + .stub() + .returns(1500); webex.internal.newMetrics.submitClientEvent = sinon.stub(); }); @@ -12855,8 +12873,8 @@ describe('plugin-meetings', () => { shareStatus = meeting.webinar.selfIsAttendee || meeting.guest - ? SHARE_STATUS.REMOTE_SHARE_ACTIVE - : SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE; + ? SHARE_STATUS.REMOTE_SHARE_ACTIVE + : SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE; } if (eventTrigger.member) { @@ -13802,32 +13820,32 @@ describe('plugin-meetings', () => { }); }); - describe('handleShareVideoStreamMuteStateChange', () => { - it('should emit MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE event with correct fields', () => { - meeting.isMultistream = true; - meeting.statsAnalyzer = {shareVideoEncoderImplementation: 'OpenH264'}; - meeting.mediaProperties.shareVideoStream = { - getSettings: sinon.stub().returns({displaySurface: 'monitor', frameRate: 30}), - }; + describe('handleShareVideoStreamMuteStateChange', () => { + it('should emit MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE event with correct fields', () => { + meeting.isMultistream = true; + meeting.statsAnalyzer = {shareVideoEncoderImplementation: 'OpenH264'}; + meeting.mediaProperties.shareVideoStream = { + getSettings: sinon.stub().returns({displaySurface: 'monitor', frameRate: 30}), + }; - meeting.handleShareVideoStreamMuteStateChange(true); + meeting.handleShareVideoStreamMuteStateChange(true); - assert.calledOnceWithExactly( - Metrics.sendBehavioralMetric, - BEHAVIORAL_METRICS.MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE, - { - correlationId: meeting.correlationId, - muted: true, - encoderImplementation: 'OpenH264', - displaySurface: 'monitor', - isMultistream: true, - frameRate: 30, - } - ); + assert.calledOnceWithExactly( + Metrics.sendBehavioralMetric, + BEHAVIORAL_METRICS.MEETING_SHARE_VIDEO_MUTE_STATE_CHANGE, + { + correlationId: meeting.correlationId, + muted: true, + encoderImplementation: 'OpenH264', + displaySurface: 'monitor', + isMultistream: true, + frameRate: 30, + } + ); + }); }); }); }); - }); describe('#startKeepAlive', () => { let clock; @@ -15026,11 +15044,9 @@ describe('plugin-meetings', () => { assert.exists(unsetStagePromise.then); await unsetStagePromise; - assert.calledOnceWithExactly( - meeting.meetingRequest.synchronizeStage, - locusUrl, - {overrideDefault: false} - ); + assert.calledOnceWithExactly(meeting.meetingRequest.synchronizeStage, locusUrl, { + overrideDefault: false, + }); }); }); @@ -15055,8 +15071,54 @@ describe('plugin-meetings', () => { meeting.meetingInfo.siteFullUrl, meeting.locusId, meetingUuid, - displayName, + displayName + ); + }); + }); + + describe('#sipCallOut', () => { + beforeEach(() => { + meeting.meetingRequest.sipCallOut = sinon.stub().returns(Promise.resolve({body: {}})); + }); + + it('sends the expected request', async () => { + const address = 'sip:user@example.com'; + const displayName = 'John Doe'; + const meetingId = 'a643beaa47f04eedac08f1310ca12366'; + + meeting.meetingInfo = { + meetingId, + }; + + const sipCallOutPromise = meeting.sipCallOut(address, displayName); + + assert.exists(sipCallOutPromise.then); + await sipCallOutPromise; + + assert.calledOnceWithExactly( + meeting.meetingRequest.sipCallOut, + meetingId, + meetingId, + address, + displayName ); }); }); + + describe('#cancelSipCallOut', () => { + beforeEach(() => { + meeting.meetingRequest.cancelSipCallOut = sinon.stub().returns(Promise.resolve({body: {}})); + }); + + it('sends the expected request', async () => { + const participantId = '12345-abcde'; + + const cancelSipCallOutPromise = meeting.cancelSipCallOut(participantId); + + assert.exists(cancelSipCallOutPromise.then); + await cancelSipCallOutPromise; + + assert.calledOnceWithExactly(meeting.meetingRequest.cancelSipCallOut, participantId); + }); + }); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js index e58b5435a5a..cb814de558e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -975,6 +975,8 @@ describe('plugin-meetings', () => { {functionName: 'isLocalRecordingStarted',displayHint:'LOCAL_RECORDING_STATUS_STARTED'}, {functionName: 'isLocalRecordingStopped', displayHint: 'LOCAL_RECORDING_STATUS_STOPPED'}, {functionName: 'isLocalRecordingPaused', displayHint: 'LOCAL_RECORDING_STATUS_PAUSED'}, + {functionName: 'isLocalStreamingStarted',displayHint:'STREAMING_STATUS_STARTED'}, + {functionName: 'isLocalStreamingStopped', displayHint: 'STREAMING_STATUS_STOPPED'}, {functionName: 'isManualCaptionActive', displayHint: 'MANUAL_CAPTION_STATUS_ACTIVE'}, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js index e1b8b1469d3..1844575b611 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meetings/index.js @@ -690,11 +690,9 @@ describe('plugin-meetings', () => { assert.deepEqual(result.options, { mode: 'BLUR', blurStrength: 'STRONG', - generator: 'worker', quality: 'LOW', authToken: 'fake_token', mirror: false, - canvasResolutionScaling: 1, }); assert.exists(result.enable); assert.exists(result.disable); diff --git a/packages/@webex/webex-core/src/lib/services-v2/service-catalog.ts b/packages/@webex/webex-core/src/lib/services-v2/service-catalog.ts index aa492ef9743..0243ce21f76 100644 --- a/packages/@webex/webex-core/src/lib/services-v2/service-catalog.ts +++ b/packages/@webex/webex-core/src/lib/services-v2/service-catalog.ts @@ -319,7 +319,13 @@ const ServiceCatalog = AmpState.extend({ serviceDetails.forEach((serviceObj) => { const serviceDetail = this._getServiceDetail(serviceObj.id, serviceGroup); + serviceObj?.serviceUrls?.sort((a, b) => { + if (a.priority < 0 && b.priority < 0) return 0; + if (a.priority < 0) return 1; + if (b.priority < 0) return -1; + return a.priority - b.priority; + }); if (serviceDetail) { serviceDetail.serviceUrls = serviceObj.serviceUrls || []; } else { diff --git a/packages/@webex/webex-core/src/webex-core.js b/packages/@webex/webex-core/src/webex-core.js index 7a3a0183cfd..84c1d8c8860 100644 --- a/packages/@webex/webex-core/src/webex-core.js +++ b/packages/@webex/webex-core/src/webex-core.js @@ -330,6 +330,7 @@ const WebexCore = AmpState.extend({ * @returns {WebexCore} */ initialize(attrs = {}) { + console.log('multiple LLM special build'); this.config = merge({}, config, attrs.config); // There's some unfortunateness with the way {@link AmpersandState#children} diff --git a/packages/@webex/webex-core/test/integration/spec/services-v2/service-catalog.js b/packages/@webex/webex-core/test/integration/spec/services-v2/service-catalog.js index 92cfebd477f..424796c8d3e 100644 --- a/packages/@webex/webex-core/test/integration/spec/services-v2/service-catalog.js +++ b/packages/@webex/webex-core/test/integration/spec/services-v2/service-catalog.js @@ -19,6 +19,7 @@ import { formattedServiceHostmapEntryConv, serviceHostmapV2, } from '../../../fixtures/host-catalog-v2'; +import {cloneDeep} from 'lodash'; describe('webex-core', () => { describe('ServiceCatalogV2', () => { @@ -659,6 +660,41 @@ describe('webex-core', () => { catalog.updateServiceGroups('preauth', formattedHM.services); }); + it('make sure the serviceUrls is in Priority order', (done) => { + const notInOrderServiceHM = { + activeServices: { + conversation: 'urn:TEAM:us-east-2_a:conversation', + idbroker: 'urn:TEAM:us-east-2_a:idbroker', + locus: 'urn:TEAM:us-east-2_a:locus', + mercury: 'urn:TEAM:us-east-2_a:mercury', + }, + services: [ + { + id: 'urn:TEAM:us-east-2_a:conversation', + serviceName: 'conversation', + serviceUrls: [ + { + baseUrl: 'https://example-1.svc.webex.com/conversation/api/v1', + priority: 2, + }, + { + baseUrl: 'https://conv-a.wbx2.com/conversation/api/v1', + priority: 1, + }, + ], + }, + ], + orgId: '3e0e410f-f83f-4ee4-ac32-12692e99355c', + timestamp: '1745533341', + format: 'U2Cv2', + }; + const notInOrderFormattedHM = services._formatReceivedHostmap(notInOrderServiceHM); + const checkFormattedHM = cloneDeep(notInOrderFormattedHM); + catalog.updateServiceGroups('preauth', notInOrderFormattedHM.services); + assert.deepEqual(catalog._getServiceDetail('urn:TEAM:us-east-2_a:conversation')?.serviceUrls[0], checkFormattedHM.services[0].serviceUrls[1]) + assert.equal( catalog.get('urn:TEAM:us-east-2_a:conversation'), 'https://conv-a.wbx2.com/conversation/api/v1') + done(); + }); }); }); }); diff --git a/packages/calling/package.json b/packages/calling/package.json index 7accc2b08d3..2f65af9f929 100644 --- a/packages/calling/package.json +++ b/packages/calling/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@types/platform": "1.3.4", - "@webex/internal-media-core": "2.19.0", + "@webex/internal-media-core": "2.20.1", "@webex/internal-plugin-metrics": "workspace:*", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", diff --git a/packages/calling/src/CallHistory/CallHistory.test.ts b/packages/calling/src/CallHistory/CallHistory.test.ts index 345b009dafe..6d3414cf176 100644 --- a/packages/calling/src/CallHistory/CallHistory.test.ts +++ b/packages/calling/src/CallHistory/CallHistory.test.ts @@ -96,7 +96,13 @@ describe('Call history tests', () => { 'invoking with days=7, limit=2000, sort=ASC, sortBy=startTime', {file: CALL_HISTORY_FILE, method: METHODS.GET_CALL_HISTORY_DATA} ); - expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + `Failed to get call history: ${JSON.stringify(failurePayload)}`, + { + file: CALL_HISTORY_FILE, + method: METHODS.GET_CALL_HISTORY_DATA, + } + ); expect(uploadLogsSpy).toHaveBeenCalledTimes(1); }); @@ -117,7 +123,13 @@ describe('Call history tests', () => { 'invoking with days=0, limit=0, sort=ASC, sortBy=startTime', {file: CALL_HISTORY_FILE, method: METHODS.GET_CALL_HISTORY_DATA} ); - expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + `Failed to get call history: ${JSON.stringify(failurePayload)}`, + { + file: CALL_HISTORY_FILE, + method: METHODS.GET_CALL_HISTORY_DATA, + } + ); expect(uploadLogsSpy).toHaveBeenCalledTimes(1); }); @@ -299,7 +311,10 @@ describe('Call history tests', () => { }, methodDetails ); - expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toBeCalledWith( + expect.stringContaining('Failed to update missed calls'), + methodDetails + ); expect(uploadLogsSpy).toHaveBeenCalledTimes(1); }); @@ -593,10 +608,13 @@ describe('Call history tests', () => { }, methodDetails ); - expect(errorSpy).toHaveBeenCalledWith(expect.any(Error), { - file: CALL_HISTORY_FILE, - method: METHODS.DELETE_CALL_HISTORY_RECORDS, - }); + expect(errorSpy).toBeCalledWith( + expect.stringContaining('Failed to delete call history records'), + { + file: CALL_HISTORY_FILE, + method: METHODS.DELETE_CALL_HISTORY_RECORDS, + } + ); expect(uploadLogsSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/calling/src/CallHistory/CallHistory.ts b/packages/calling/src/CallHistory/CallHistory.ts index 94b409b19a3..5f20133eb18 100644 --- a/packages/calling/src/CallHistory/CallHistory.ts +++ b/packages/calling/src/CallHistory/CallHistory.ts @@ -1,6 +1,5 @@ /* eslint-disable dot-notation */ /* eslint-disable no-underscore-dangle */ -import ExtendedError from '../Errors/catalog/ExtendedError'; import SDKConnector from '../SDKConnector'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; import { @@ -218,8 +217,10 @@ export class CallHistory extends Eventing implements ICal return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to get call history: ${err}`) as ExtendedError; - log.error(extendedError, {file: CALL_HISTORY_FILE, method: METHODS.GET_CALL_HISTORY_DATA}); + log.error(`Failed to get call history: ${JSON.stringify(err)}`, { + file: CALL_HISTORY_FILE, + method: METHODS.GET_CALL_HISTORY_DATA, + }); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -285,8 +286,10 @@ export class CallHistory extends Eventing implements ICal return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to update missed calls: ${err}`) as ExtendedError; - log.error(extendedError, {file: CALL_HISTORY_FILE, method: METHODS.UPDATE_MISSED_CALLS}); + log.error(`Failed to update missed calls: ${JSON.stringify(err)}`, { + file: CALL_HISTORY_FILE, + method: METHODS.UPDATE_MISSED_CALLS, + }); await uploadLogs(); // Catch the 401 error from try block, return the error object to user @@ -334,8 +337,10 @@ export class CallHistory extends Eventing implements ICal return ucmLineDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to fetch UCM lines data: ${err}`) as ExtendedError; - log.error(extendedError, {file: CALL_HISTORY_FILE, method: METHODS.FETCH_UCM_LINES_DATA}); + log.error(`Failed to fetch UCM lines data: ${JSON.stringify(err)}`, { + file: CALL_HISTORY_FILE, + method: METHODS.FETCH_UCM_LINES_DATA, + }); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -427,10 +432,7 @@ export class CallHistory extends Eventing implements ICal return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to delete call history records: ${err}` - ) as ExtendedError; - log.error(extendedError, { + log.error(`Failed to delete call history records: ${JSON.stringify(err)}`, { file: CALL_HISTORY_FILE, method: METHODS.DELETE_CALL_HISTORY_RECORDS, }); diff --git a/packages/calling/src/CallSettings/UcmBackendConnector.test.ts b/packages/calling/src/CallSettings/UcmBackendConnector.test.ts index b90762d905e..a32c6847966 100644 --- a/packages/calling/src/CallSettings/UcmBackendConnector.test.ts +++ b/packages/calling/src/CallSettings/UcmBackendConnector.test.ts @@ -212,7 +212,7 @@ describe('Call Settings Client Tests for UcmBackendConnector', () => { method: 'getCallForwardAlwaysSetting', }); expect(log.error).toHaveBeenCalledWith( - new Error('Failed to get call forward always setting: [object Object]'), + `Failed to get call forward always setting: ${JSON.stringify(responsePayload)}`, { file: UCM_CONNECTOR_FILE, method: 'getCallForwardAlwaysSetting', diff --git a/packages/calling/src/CallSettings/UcmBackendConnector.ts b/packages/calling/src/CallSettings/UcmBackendConnector.ts index 7274fcb7a8d..3b120bd2da6 100644 --- a/packages/calling/src/CallSettings/UcmBackendConnector.ts +++ b/packages/calling/src/CallSettings/UcmBackendConnector.ts @@ -1,4 +1,3 @@ -import ExtendedError from 'Errors/catalog/ExtendedError'; import log from '../Logger'; import SDKConnector from '../SDKConnector'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; @@ -248,10 +247,7 @@ export class UcmBackendConnector implements IUcmBackendConnector { return response; } catch (err: unknown) { const errorInfo = err as WebexRequestPayload; - const extendedError = new Error( - `Failed to get call forward always setting: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get call forward always setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); diff --git a/packages/calling/src/CallSettings/WxCallBackendConnector.test.ts b/packages/calling/src/CallSettings/WxCallBackendConnector.test.ts index fc134b3e5e3..c18eb030c8d 100644 --- a/packages/calling/src/CallSettings/WxCallBackendConnector.test.ts +++ b/packages/calling/src/CallSettings/WxCallBackendConnector.test.ts @@ -185,7 +185,10 @@ describe('Call Settings Client Tests for WxCallBackendConnector', () => { file: WEBEX_CALLING_CONNECTOR_FILE, method: 'getCallWaitingSetting', }); - expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(`Failed to get call waiting setting: {}`, { + file: 'WxCallBackendConnector', + method: 'getCallWaitingSetting', + }); expect(logSpy).not.toHaveBeenCalled(); }); diff --git a/packages/calling/src/CallSettings/WxCallBackendConnector.ts b/packages/calling/src/CallSettings/WxCallBackendConnector.ts index 4905b5ab751..1fccac8b8d2 100644 --- a/packages/calling/src/CallSettings/WxCallBackendConnector.ts +++ b/packages/calling/src/CallSettings/WxCallBackendConnector.ts @@ -1,4 +1,3 @@ -import ExtendedError from 'Errors/catalog/ExtendedError'; import SDKConnector from '../SDKConnector'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; import { @@ -134,10 +133,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to get call waiting setting: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get call waiting setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = { @@ -184,10 +180,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to get DoNotDisturb setting: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get DoNotDisturb setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -237,10 +230,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to set DoNotDisturb setting: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to set DoNotDisturb setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -282,10 +272,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to get Call Forward setting: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get Call Forward setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -329,10 +316,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to set Call Forward setting: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to set Call Forward setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -374,8 +358,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to get Voicemail setting: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get Voicemail setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -419,9 +402,7 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to set Voicemail setting: ${err}`) as ExtendedError; - - log.error(extendedError, loggerContext); + log.error(`Failed to set Voicemail setting: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; diff --git a/packages/calling/src/CallingClient/CallingClient.ts b/packages/calling/src/CallingClient/CallingClient.ts index b2ef01be6ac..d4159874bc9 100644 --- a/packages/calling/src/CallingClient/CallingClient.ts +++ b/packages/calling/src/CallingClient/CallingClient.ts @@ -3,7 +3,6 @@ /* eslint-disable @typescript-eslint/no-shadow */ import * as Media from '@webex/internal-media-core'; import {Mutex} from 'async-mutex'; -import ExtendedError from 'Errors/catalog/ExtendedError'; import {METHOD_START_MESSAGE} from '../common/constants'; import { filterMobiusUris, @@ -472,10 +471,7 @@ export class CallingClient extends Eventing implements break; } catch (err: unknown) { - const extendedError = new Error( - `Failed to get client region info: ${err}` - ) as ExtendedError; - log.error(extendedError, { + log.error(`Failed to get client region info: ${JSON.stringify(err)}`, { method: METHODS.GET_CLIENT_REGION_INFO, file: CALLING_CLIENT_FILE, }); @@ -609,8 +605,7 @@ export class CallingClient extends Eventing implements } ); } catch (err: unknown) { - const extendedError = new Error(`Failed to get Mobius servers: ${err}`) as ExtendedError; - log.error(extendedError, { + log.error(`Failed to get Mobius servers: ${JSON.stringify(err)}`, { method: METHODS.GET_MOBIUS_SERVERS, file: CALLING_CLIENT_FILE, }); diff --git a/packages/calling/src/CallingClient/calling/call.test.ts b/packages/calling/src/CallingClient/calling/call.test.ts index 17c05621811..83254a07b54 100644 --- a/packages/calling/src/CallingClient/calling/call.test.ts +++ b/packages/calling/src/CallingClient/calling/call.test.ts @@ -1400,15 +1400,15 @@ describe('State Machine handler tests', () => { await flushPromises(2); expect(call.isConnected()).toBe(false); - expect(call['mediaStateMachine'].state.value).toBe('S_ROAP_ERROR'); - expect(call['callStateMachine'].state.value).toBe('S_UNKNOWN'); + expect(call['mediaStateMachine'].state.value).toBe('S_ROAP_TEARDOWN'); + expect(call['callStateMachine'].state.value).toBe('S_CALL_CLEARED'); expect(warnSpy).toHaveBeenCalledWith('Failed to process MediaOk request', { file: 'call', method: 'handleRoapEstablished', }); - expect(uploadLogsSpy).toHaveBeenCalledWith({ - correlationId: call.getCorrelationId(), - callId: call.getCallId(), + expect(warnSpy).toHaveBeenCalledWith('Call failed due to media issue', { + file: 'call', + method: 'handleRoapError', }); }); @@ -1703,6 +1703,68 @@ describe('State Machine handler tests', () => { expect(stateMachineSpy).toBeCalledOnceWith({data: {media: true}, type: 'E_UNKNOWN'}); }); + it('incoming call: failing ROAP_ANSWER posts error path and tears down', async () => { + const statusPayload = ({ + statusCode: 403, + body: mockStatusBody, + }); + + const warnSpy = jest.spyOn(log, 'warn'); + const postMediaSpy = jest.spyOn(call as any, 'postMedia').mockRejectedValueOnce(statusPayload); + + // Simulate inbound call flow + call['direction'] = CallDirection.INBOUND; + + const setupEvent = { + type: 'E_RECV_CALL_SETUP', + data: { + seq: 1, + messageType: 'OFFER', + }, + }; + + call.sendCallStateMachineEvt(setupEvent as CallEvent); + expect(call['callStateMachine'].state.value).toBe('S_SEND_CALL_PROGRESS'); + + const connectEvent = {type: 'E_SEND_CALL_CONNECT'}; + call.sendCallStateMachineEvt(connectEvent as CallEvent); + expect(call['callStateMachine'].state.value).toBe('S_SEND_CALL_CONNECT'); + + const offerEvent = { + type: 'E_RECV_ROAP_OFFER', + data: { + seq: 1, + messageType: 'OFFER', + }, + }; + call.sendMediaStateMachineEvt(offerEvent as RoapEvent); + + const answerEvent = { + type: 'E_SEND_ROAP_ANSWER', + data: { + seq: 1, + messageType: 'ANSWER', + }, + }; + + await call.sendMediaStateMachineEvt(answerEvent as RoapEvent); + await flushPromises(2); + + expect(postMediaSpy).toBeCalledOnceWith(answerEvent.data as RoapMessage); + expect(warnSpy).toHaveBeenCalledWith('Failed to send MediaAnswer request', { + file: 'call', + method: 'handleOutgoingRoapAnswer', + }); + expect(warnSpy).toHaveBeenCalledWith('Call failed due to media issue', { + file: 'call', + method: 'handleRoapError', + }); + + // Final state should be torn down and cleared for unconnected call + expect(call['mediaStateMachine'].state.value).toBe('S_ROAP_TEARDOWN'); + expect(call['callStateMachine'].state.value).toBe('S_CALL_CLEARED'); + }); + it('state changes during successful incoming call with out of order events', async () => { const statusPayload = ({ statusCode: 200, diff --git a/packages/calling/src/CallingClient/calling/call.ts b/packages/calling/src/CallingClient/calling/call.ts index c96e9b489fd..c604c0c20a3 100644 --- a/packages/calling/src/CallingClient/calling/call.ts +++ b/packages/calling/src/CallingClient/calling/call.ts @@ -8,7 +8,6 @@ import {createMachine, interpret} from 'xstate'; import {v4 as uuid} from 'uuid'; import {EffectEvent, TrackEffect} from '@webex/web-media-effects'; import {RtcMetrics} from '@webex/internal-plugin-metrics'; -import ExtendedError from '../../Errors/catalog/ExtendedError'; import {ERROR_LAYER, ERROR_TYPE, ErrorContext} from '../../Errors/types'; import { handleCallErrors, @@ -986,8 +985,7 @@ export class Call extends Eventing implements ICall { method: this.handleOutgoingCallSetup.name, }); } catch (e) { - const extendedError = new Error(`Failed to setup the call: ${e}`) as ExtendedError; - log.error(extendedError, { + log.error(`Failed to setup the call: ${JSON.stringify(e)}`, { file: CALL_FILE, method: METHODS.HANDLE_OUTGOING_CALL_SETUP, }); @@ -1062,8 +1060,7 @@ export class Call extends Eventing implements ICall { }, SUPPLEMENTARY_SERVICES_TIMEOUT); } } catch (e) { - const extendedError = new Error(`Failed to put the call on hold: ${e}`) as ExtendedError; - log.error(extendedError, { + log.error(`Failed to put the call on hold: ${JSON.stringify(e)}`, { file: CALL_FILE, method: METHODS.HANDLE_CALL_HOLD, }); @@ -1138,8 +1135,7 @@ export class Call extends Eventing implements ICall { }, SUPPLEMENTARY_SERVICES_TIMEOUT); } } catch (e) { - const extendedError = new Error(`Failed to resume the call: ${e}`) as ExtendedError; - log.error(extendedError, { + log.error(`Failed to resume the call: ${JSON.stringify(e)}`, { file: CALL_FILE, method: METHODS.HANDLE_CALL_RESUME, }); @@ -1262,13 +1258,12 @@ export class Call extends Eventing implements ICall { file: CALL_FILE, method: METHODS.HANDLE_OUTGOING_CALL_ALERTING, }); - } catch (err) { - const extendedError = new Error(`Failed to signal call progression: ${err}`) as ExtendedError; - log.error(extendedError, { + } catch (e) { + log.error(`Failed to signal call progression: ${JSON.stringify(e)}`, { file: CALL_FILE, method: METHODS.HANDLE_OUTGOING_CALL_ALERTING, }); - const errData = err as MobiusCallResponse; + const errData = e as MobiusCallResponse; handleCallErrors( (error: CallError) => { @@ -1348,13 +1343,12 @@ export class Call extends Eventing implements ICall { file: CALL_FILE, method: METHODS.HANDLE_OUTGOING_CALL_CONNECT, }); - } catch (err) { - const extendedError = new Error(`Failed to connect the call: ${err}`) as ExtendedError; - log.error(extendedError, { + } catch (e) { + log.error(`Failed to connect the call: ${JSON.stringify(e)}`, { file: CALL_FILE, method: METHODS.HANDLE_OUTGOING_CALL_CONNECT, }); - const errData = err as MobiusCallResponse; + const errData = e as MobiusCallResponse; handleCallErrors( (error: CallError) => { @@ -1401,7 +1395,7 @@ export class Call extends Eventing implements ICall { method: METHODS.HANDLE_OUTGOING_CALL_DISCONNECT, }); } catch (e) { - log.warn('Failed to delete the call', { + log.warn(`Failed to delete the call: ${JSON.stringify(e)}`, { file: CALL_FILE, method: METHODS.HANDLE_OUTGOING_CALL_DISCONNECT, }); @@ -1814,7 +1808,7 @@ export class Call extends Eventing implements ICall { const message = event.data as RoapMessage; /* istanbul ignore else */ - if (message) { + if (message && message.messageType === 'ERROR') { try { const res = await this.postMedia(message); @@ -2066,12 +2060,12 @@ export class Call extends Eventing implements ICall { log.info(`callFrom: ${callFrom}`, loggerContext); } catch (error) { const errorInfo = error as WebexRequestPayload; - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); - const errorLog = new Error( - `Failed to upload webrtc telemetry statistics. ${errorStatus}` - ) as ExtendedError; + const errorStatus = await serviceErrorCodeHandler(errorInfo, loggerContext); - log.error(errorLog, loggerContext); + log.error( + `Failed to upload webrtc telemetry statistics. ${JSON.stringify(errorStatus)}`, + loggerContext + ); await uploadLogs({ correlationId: this.correlationId, diff --git a/packages/calling/src/CallingClient/calling/callManager.test.ts b/packages/calling/src/CallingClient/calling/callManager.test.ts index b9044481eb4..3446b967359 100644 --- a/packages/calling/src/CallingClient/calling/callManager.test.ts +++ b/packages/calling/src/CallingClient/calling/callManager.test.ts @@ -282,7 +282,7 @@ describe('Call Manager Tests with respect to calls', () => { expect(patchMock).toHaveBeenCalledWith(MobiusCallState.ALERTING); expect(errorSpy).toHaveBeenCalledWith( - Error(`Failed to signal call progression: ${dummyResponse}`), + `Failed to signal call progression: ${JSON.stringify(dummyResponse)}`, { file: 'call', method: 'handleOutgoingCallAlerting', diff --git a/packages/calling/src/CallingClient/registration/register.ts b/packages/calling/src/CallingClient/registration/register.ts index 2f05ee27ee7..885cad3dfc1 100644 --- a/packages/calling/src/CallingClient/registration/register.ts +++ b/packages/calling/src/CallingClient/registration/register.ts @@ -164,7 +164,7 @@ export class Registration implements IRegistration { }, }); } catch (error) { - log.warn(`Delete failed with Mobius ${error}`, { + log.warn(`Delete failed with Mobius: ${JSON.stringify(error)}`, { file: REGISTRATION_FILE, method: METHODS.DELETE_REGISTRATION, }); @@ -407,10 +407,13 @@ export class Registration implements IRegistration { break; } } catch (error) { - log.warn(`Ping failed for primary Mobius: ${mobiusUrl} with error: ${error}`, { - file: REGISTRATION_FILE, - method: FAILBACK_UTIL, - }); + log.warn( + `Ping failed for primary Mobius: ${mobiusUrl} with error: ${JSON.stringify(error)}`, + { + file: REGISTRATION_FILE, + method: FAILBACK_UTIL, + } + ); status = 'down'; } } @@ -918,7 +921,7 @@ export class Registration implements IRegistration { method: METHODS.DEREGISTER, }); } catch (err) { - log.warn(`Delete failed with Mobius: ${err}`, { + log.warn(`Delete failed with Mobius: ${JSON.stringify(err)}`, { file: REGISTRATION_FILE, method: METHODS.DEREGISTER, }); diff --git a/packages/calling/src/CallingClient/registration/webWorker.ts b/packages/calling/src/CallingClient/registration/webWorker.ts index ea2534e8c0e..2dabc9193f3 100644 --- a/packages/calling/src/CallingClient/registration/webWorker.ts +++ b/packages/calling/src/CallingClient/registration/webWorker.ts @@ -48,11 +48,11 @@ const messageHandler = (event: MessageEvent) => { keepAliveRetryCount = 0; } catch (err: any) { const headers = {} as Record; - if (err.headers.has('Retry-After')) { + if (err.headers?.has('Retry-After')) { headers['retry-after'] = err.headers.get('Retry-After'); } - if (err.headers.has('Trackingid')) { + if (err.headers?.has('Trackingid')) { // eslint-disable-next-line dot-notation headers['trackingid'] = err.headers.get('Trackingid'); } diff --git a/packages/calling/src/CallingClient/registration/webWorkerStr.ts b/packages/calling/src/CallingClient/registration/webWorkerStr.ts index dffaeddd707..76856347cff 100644 --- a/packages/calling/src/CallingClient/registration/webWorkerStr.ts +++ b/packages/calling/src/CallingClient/registration/webWorkerStr.ts @@ -75,11 +75,11 @@ const messageHandler = (event) => { keepAliveRetryCount = 0; } catch (err) { let headers = {}; - if(err.headers.has('Retry-After')) { + if(err.headers?.has('Retry-After')) { headers['retry-after'] = err.headers.get('Retry-After'); } - if(err.headers.has('Trackingid')) { + if(err.headers?.has('Trackingid')) { headers['trackingid'] = err.headers.get('Trackingid'); } diff --git a/packages/calling/src/Contacts/ContactsClient.test.ts b/packages/calling/src/Contacts/ContactsClient.test.ts index 450aa3dcd89..8d414745a48 100644 --- a/packages/calling/src/Contacts/ContactsClient.test.ts +++ b/packages/calling/src/Contacts/ContactsClient.test.ts @@ -488,7 +488,7 @@ describe('ContactClient Tests', () => { expect(errorSpy).toBeCalledTimes(1); expect(errorSpy).toHaveBeenNthCalledWith( 1, - Error(`Unable to create contact group: ${failureResponsePayload}`), + `Unable to create contact group: ${JSON.stringify(failureResponsePayload)}`, loggerContext ); @@ -528,9 +528,9 @@ describe('ContactClient Tests', () => { expect(uploadLogsSpy).toBeCalledTimes(1); expect(errorSpy).toHaveBeenNthCalledWith( 1, - Error( - `Unable to delete contact group ${mockGroupResponse.groupId}: ${failureResponsePayload}` - ), + `Unable to delete contact group ${mockGroupResponse.groupId}: ${JSON.stringify( + failureResponsePayload + )}`, loggerContext ); expect(warnSpy).toHaveBeenNthCalledWith( @@ -842,10 +842,13 @@ describe('ContactClient Tests', () => { file: CONTACTS_CLIENT, method: METHODS.CREATE_CONTACT, }); - expect(log.error).toBeCalledWith(Error(`Failed to create contact: ${failureResponsePayload}`), { - file: CONTACTS_CLIENT, - method: METHODS.CREATE_CONTACT, - }); + expect(log.error).toBeCalledWith( + `Failed to create contact: ${JSON.stringify(failureResponsePayload)}`, + { + file: CONTACTS_CLIENT, + method: METHODS.CREATE_CONTACT, + } + ); }); it('successful deletion of contacts', async () => { diff --git a/packages/calling/src/Contacts/ContactsClient.ts b/packages/calling/src/Contacts/ContactsClient.ts index 8c25e085a2d..76701f6aeb5 100644 --- a/packages/calling/src/Contacts/ContactsClient.ts +++ b/packages/calling/src/Contacts/ContactsClient.ts @@ -1,5 +1,4 @@ /* eslint-disable no-await-in-loop */ -import ExtendedError from 'Errors/catalog/ExtendedError'; import { FAILURE_MESSAGE, METHOD_START_MESSAGE, @@ -432,10 +431,8 @@ export class ContactsClient implements IContacts { return contactResponse; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Error fetching contacts: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + log.error(`Error fetching contacts: ${JSON.stringify(err)}`, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); await uploadLogs(); return errorStatus; @@ -652,10 +649,8 @@ export class ContactsClient implements IContacts { return contactResponse; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Unable to create contact group: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + log.error(`Unable to create contact group: ${JSON.stringify(err)}`, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); await uploadLogs(); return errorStatus; @@ -704,12 +699,8 @@ export class ContactsClient implements IContacts { return contactResponse; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error( - `Unable to delete contact group ${groupId}: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + log.error(`Unable to delete contact group ${groupId}: ${JSON.stringify(err)}`, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); await uploadLogs(); return errorStatus; @@ -816,10 +807,8 @@ export class ContactsClient implements IContacts { return contactResponse; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Failed to create contact: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + log.error(`Failed to create contact: ${JSON.stringify(err)}`, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); await uploadLogs(); return errorStatus; @@ -864,12 +853,8 @@ export class ContactsClient implements IContacts { return contactResponse; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error( - `Unable to delete contact ${contactId}: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + log.error(`Unable to delete contact ${contactId}: ${JSON.stringify(err)}`, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); await uploadLogs(); return errorStatus; diff --git a/packages/calling/src/Events/impl/index.ts b/packages/calling/src/Events/impl/index.ts index 995c1f46d5f..74bfbc5e9a1 100644 --- a/packages/calling/src/Events/impl/index.ts +++ b/packages/calling/src/Events/impl/index.ts @@ -23,7 +23,7 @@ export class Eventing extends (EventEmitter as { Logger.info( `${timestamp} ${ LOG_PREFIX.EVENT - }: ${event.toString()} - event emitted with parameters -> ${args} = `, + }: ${event.toString()} - event emitted with parameters -> ${args}`, { file: 'Events/impl/index.ts', method: 'emit', diff --git a/packages/calling/src/Logger/index.test.ts b/packages/calling/src/Logger/index.test.ts index 612a11adb47..c64d9f8aed3 100644 --- a/packages/calling/src/Logger/index.test.ts +++ b/packages/calling/src/Logger/index.test.ts @@ -1,4 +1,3 @@ -import ExtendedError from '../Errors/catalog/ExtendedError'; import {LOGGER} from './types'; import log from '.'; @@ -50,7 +49,7 @@ describe('Coverage tests for logger', () => { log.trace(fakePrint, dummyContext); expect(traceSpy).not.toHaveBeenCalledTimes(1); - log.error(new Error(fakePrint) as ExtendedError, dummyContext); + log.error(fakePrint, dummyContext); expect(errorSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/calling/src/Logger/index.ts b/packages/calling/src/Logger/index.ts index 654ba55fdd5..b7d9ccdc2af 100644 --- a/packages/calling/src/Logger/index.ts +++ b/packages/calling/src/Logger/index.ts @@ -2,7 +2,6 @@ import {Logger} from '../SDKConnector/types'; import {REPO_NAME} from '../CallingClient/constants'; import {IMetaContext} from '../common/types'; -import ExtendedError from '../Errors/catalog/ExtendedError'; import {LOGGING_LEVEL, LogContext, LOGGER, LOG_PREFIX} from './types'; /* @@ -188,13 +187,13 @@ const logTrace = (message: string, context: LogContext) => { /** * Can be used to print only errors. * - * @param error - Error string . + * @param errorMsg - Error string . * @param context - File and method which called. */ -const logError = (error: ExtendedError, context: LogContext) => { +const logError = (errorMsg: string, context: LogContext) => { if (currentLogLevel >= LOGGING_LEVEL.error) { writeToLogger( - `${format(context, '[ERROR]')} - !${LOG_PREFIX.ERROR}!${LOG_PREFIX.MESSAGE}:${error.message}`, + `${format(context, '[ERROR]')} - !${LOG_PREFIX.ERROR}!${LOG_PREFIX.MESSAGE}:${errorMsg}`, LOGGER.ERROR ); } diff --git a/packages/calling/src/Voicemail/BroadworksBackendConnector.test.ts b/packages/calling/src/Voicemail/BroadworksBackendConnector.test.ts index 55d3a6e39d6..d2373b0659e 100644 --- a/packages/calling/src/Voicemail/BroadworksBackendConnector.test.ts +++ b/packages/calling/src/Voicemail/BroadworksBackendConnector.test.ts @@ -218,6 +218,10 @@ describe('Voicemail Broadworks Backend Connector Test case', () => { method: 'getUserId', } ); + expect(errorSpy).toHaveBeenCalledWith('Failed to get userId: {}', { + file: 'BroadworksBackendConnector', + method: 'getUserId', + }); }); it('verify failed case when token is invalid', async () => { @@ -242,6 +246,10 @@ describe('Voicemail Broadworks Backend Connector Test case', () => { method: 'getUserId', } ); + expect(errorSpy).toHaveBeenCalledWith('Failed to get userId: {}', { + file: 'BroadworksBackendConnector', + method: 'getUserId', + }); }); it('verify no response case when token have invalid userid', async () => { diff --git a/packages/calling/src/Voicemail/BroadworksBackendConnector.ts b/packages/calling/src/Voicemail/BroadworksBackendConnector.ts index 997b850b469..b0d132896da 100644 --- a/packages/calling/src/Voicemail/BroadworksBackendConnector.ts +++ b/packages/calling/src/Voicemail/BroadworksBackendConnector.ts @@ -1,6 +1,5 @@ /* eslint-disable valid-jsdoc */ /* eslint-disable no-underscore-dangle */ -import ExtendedError from '../Errors/catalog/ExtendedError'; import {ERROR_CODE} from '../Errors/types'; import SDKConnector from '../SDKConnector'; import { @@ -151,8 +150,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect statusCode: err instanceof Error ? Number(err.message) : '', } as WebexRequestPayload; - const extendedError = new Error(`Failed to get userId: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get userId: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); return serviceErrorCodeHandler(errorInfo, loggerContext); @@ -180,11 +178,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect this.bwtoken = response[TOKEN][BEARER]; log.log('Successfully fetched Broadworks token', loggerContext); } catch (err: unknown) { - const extendedError = new Error(`Broadworks token exception: ${err}`) as ExtendedError; - log.error(extendedError, { - file: BROADWORKS_VOICEMAIL_FILE, - method: METHODS.GET_BW_TOKEN, - }); + log.error(`Broadworks token exception: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); } } @@ -292,8 +286,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect statusCode: err instanceof Error ? Number(err.message) : '', } as WebexRequestPayload; - const extendedError = new Error(`Failed to get voicemail list: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail list: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); @@ -382,8 +375,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect statusCode: err instanceof Error ? Number(err.message) : '', } as WebexRequestPayload; - const extendedError = new Error(`Failed to get voicemail content: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail content: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); @@ -445,8 +437,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect statusCode: err instanceof Error ? Number(err.message) : '', } as WebexRequestPayload; - const extendedError = new Error(`Failed to mark voicemail as read: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to mark voicemail as read: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); @@ -499,10 +490,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect statusCode: err instanceof Error ? Number(err.message) : '', } as WebexRequestPayload; - const extendedError = new Error( - `Failed to mark voicemail as unread: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to mark voicemail as unread: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); @@ -555,8 +543,7 @@ export class BroadworksBackendConnector implements IBroadworksCallBackendConnect statusCode: err instanceof Error ? Number(err.message) : '', } as WebexRequestPayload; - const extendedError = new Error(`Failed to delete voicemail: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to delete voicemail: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); diff --git a/packages/calling/src/Voicemail/UcmBackendConnector.ts b/packages/calling/src/Voicemail/UcmBackendConnector.ts index 5b6ec04188b..bfaaa4fc5e7 100644 --- a/packages/calling/src/Voicemail/UcmBackendConnector.ts +++ b/packages/calling/src/Voicemail/UcmBackendConnector.ts @@ -1,7 +1,6 @@ /* eslint-disable no-underscore-dangle */ /* eslint-disable valid-jsdoc */ /* eslint-disable @typescript-eslint/no-shadow */ -import ExtendedError from '../Errors/catalog/ExtendedError'; import SDKConnector from '../SDKConnector'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; import { @@ -186,9 +185,7 @@ export class UcmBackendConnector implements IUcmBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to get voicemail list: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); - + log.error(`Failed to get voicemail list: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); const errorInfo = err as WebexRequestPayload; @@ -219,8 +216,7 @@ export class UcmBackendConnector implements IUcmBackendConnector { return response as VoicemailResponseEvent; } catch (err: unknown) { - const extendedError = new Error(`Failed to get voicemail content: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail content: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); @@ -371,8 +367,7 @@ export class UcmBackendConnector implements IUcmBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to mark voicemail as read: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to mark voicemail as read: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); @@ -419,10 +414,7 @@ export class UcmBackendConnector implements IUcmBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error( - `Failed to mark voicemail as unread: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to mark voicemail as unread: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); @@ -466,8 +458,7 @@ export class UcmBackendConnector implements IUcmBackendConnector { return responseDetails; } catch (err: unknown) { - const extendedError = new Error(`Failed to delete voicemail: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to delete voicemail: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); diff --git a/packages/calling/src/Voicemail/Voicemail.ts b/packages/calling/src/Voicemail/Voicemail.ts index 4f6c94e6a74..9e846d2fd79 100644 --- a/packages/calling/src/Voicemail/Voicemail.ts +++ b/packages/calling/src/Voicemail/Voicemail.ts @@ -1,7 +1,6 @@ /* eslint-disable dot-notation */ /* eslint-disable no-underscore-dangle */ /* eslint-disable valid-jsdoc */ -import ExtendedError from 'Errors/catalog/ExtendedError'; import {METHOD_START_MESSAGE} from '../common/constants'; import SDKConnector from '../SDKConnector'; import {ISDKConnector, WebexSDK} from '../SDKConnector/types'; @@ -60,26 +59,21 @@ export class Voicemail extends Eventing implements IVoicema * */ public async init() { + const loggerContext = { + file: VOICEMAIL_FILE, + method: METHODS.INIT, + }; + try { - log.info(METHOD_START_MESSAGE, { - file: VOICEMAIL_FILE, - method: METHODS.INIT, - }); + log.info(METHOD_START_MESSAGE, loggerContext); const response = this.backendConnector.init(); - log.log('Voicemail connector initialized successfully', { - file: VOICEMAIL_FILE, - method: METHODS.INIT, - }); + log.log('Voicemail connector initialized successfully', loggerContext); return response; } catch (err: unknown) { - const extendedError = new Error(`Failed to initialize voicemail: ${err}`) as ExtendedError; - log.error(extendedError, { - file: VOICEMAIL_FILE, - method: METHODS.INIT, - }); + log.error(`Failed to initialize voicemail: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); @@ -162,13 +156,15 @@ export class Voicemail extends Eventing implements IVoicema sort: SORT, refresh?: boolean ): Promise { + const loggerContext = { + file: VOICEMAIL_FILE, + method: METHODS.GET_VOICEMAIL_LIST, + }; + try { log.info( `${METHOD_START_MESSAGE} with: offset=${offset}, limit=${offsetLimit}, sort=${sort}, refresh=${refresh}`, - { - file: VOICEMAIL_FILE, - method: METHODS.GET_VOICEMAIL_LIST, - } + loggerContext ); const response = await this.backendConnector.getVoicemailList( @@ -180,18 +176,14 @@ export class Voicemail extends Eventing implements IVoicema this.submitMetric(response, VOICEMAIL_ACTION.GET_VOICEMAILS); - log.log(`Successfully retrieved voicemail list: statusCode=${response.statusCode}`, { - file: VOICEMAIL_FILE, - method: METHODS.GET_VOICEMAIL_LIST, - }); + log.log( + `Successfully retrieved voicemail list: statusCode=${response.statusCode}`, + loggerContext + ); return response; } catch (err: unknown) { - const extendedError = new Error(`Failed to get voicemail list: ${err}`) as ExtendedError; - log.error(extendedError, { - file: VOICEMAIL_FILE, - method: METHODS.GET_VOICEMAIL_LIST, - }); + log.error(`Failed to get voicemail list: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); @@ -205,10 +197,12 @@ export class Voicemail extends Eventing implements IVoicema * @param messageId - The identifier of the voicemail message. */ public async getVoicemailContent(messageId: string): Promise { - log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.GET_VOICEMAIL_CONTENT, - }); + }; + + log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, loggerContext); const response = await this.backendConnector.getVoicemailContent(messageId); @@ -216,10 +210,7 @@ export class Voicemail extends Eventing implements IVoicema log.log( `Successfully retrieved voicemail content for messageId=${messageId}, statusCode=${response.statusCode}`, - { - file: VOICEMAIL_FILE, - method: METHODS.GET_VOICEMAIL_CONTENT, - } + loggerContext ); return response; @@ -230,10 +221,12 @@ export class Voicemail extends Eventing implements IVoicema * */ public async getVoicemailSummary(): Promise { - log.info(METHOD_START_MESSAGE, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.GET_VOICEMAIL_SUMMARY, - }); + }; + + log.info(METHOD_START_MESSAGE, loggerContext); const response = await this.backendConnector.getVoicemailSummary(); @@ -241,10 +234,10 @@ export class Voicemail extends Eventing implements IVoicema if (response !== null) { this.submitMetric(response, VOICEMAIL_ACTION.GET_VOICEMAIL_SUMMARY); - log.log(`Successfully retrieved voicemail summary: statusCode=${response.statusCode}`, { - file: VOICEMAIL_FILE, - method: METHODS.GET_VOICEMAIL_SUMMARY, - }); + log.log( + `Successfully retrieved voicemail summary: statusCode=${response.statusCode}`, + loggerContext + ); } return response; @@ -256,10 +249,12 @@ export class Voicemail extends Eventing implements IVoicema * @param messageId -string result from the voicemail list. */ public async voicemailMarkAsRead(messageId: string): Promise { - log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.VOICEMAIL_MARK_AS_READ, - }); + }; + + log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, loggerContext); const response = await this.backendConnector.voicemailMarkAsRead(messageId); @@ -267,10 +262,7 @@ export class Voicemail extends Eventing implements IVoicema log.log( `Successfully marked voicemail as read: messageId=${messageId}, statusCode=${response.statusCode}`, - { - file: VOICEMAIL_FILE, - method: METHODS.VOICEMAIL_MARK_AS_READ, - } + loggerContext ); return response; @@ -282,10 +274,12 @@ export class Voicemail extends Eventing implements IVoicema * @param messageId -string result from the voicemail list. */ public async voicemailMarkAsUnread(messageId: string): Promise { - log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.VOICEMAIL_MARK_AS_UNREAD, - }); + }; + + log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, loggerContext); const response = await this.backendConnector.voicemailMarkAsUnread(messageId); @@ -293,10 +287,7 @@ export class Voicemail extends Eventing implements IVoicema log.log( `Successfully marked voicemail as unread: messageId=${messageId}, statusCode=${response.statusCode}`, - { - file: VOICEMAIL_FILE, - method: METHODS.VOICEMAIL_MARK_AS_UNREAD, - } + loggerContext ); return response; @@ -308,10 +299,12 @@ export class Voicemail extends Eventing implements IVoicema * @param messageId -string result from the voicemail list. */ public async deleteVoicemail(messageId: string): Promise { - log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.DELETE_VOICEMAIL, - }); + }; + + log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, loggerContext); const response = await this.backendConnector.deleteVoicemail(messageId); @@ -319,10 +312,7 @@ export class Voicemail extends Eventing implements IVoicema log.log( `Successfully deleted voicemail: messageId=${messageId}, statusCode=${response.statusCode}`, - { - file: VOICEMAIL_FILE, - method: METHODS.DELETE_VOICEMAIL, - } + loggerContext ); return response; @@ -334,10 +324,12 @@ export class Voicemail extends Eventing implements IVoicema * @param messageId - MessageId for which we need the transcript. */ public async getVMTranscript(messageId: string): Promise { - log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.GET_VM_TRANSCRIPT, - }); + }; + + log.info(`${METHOD_START_MESSAGE} with: messageId=${messageId}`, loggerContext); const response = await this.backendConnector.getVMTranscript(messageId); @@ -346,10 +338,7 @@ export class Voicemail extends Eventing implements IVoicema log.log( `Successfully retrieved voicemail transcript: messageId=${messageId}, statusCode=${response.statusCode}`, - { - file: VOICEMAIL_FILE, - method: METHODS.GET_VM_TRANSCRIPT, - } + loggerContext ); } @@ -362,17 +351,16 @@ export class Voicemail extends Eventing implements IVoicema * @param callingPartyInfo - Calling Party Info. */ public resolveContact(callingPartyInfo: CallingPartyInfo): Promise { - log.info(METHOD_START_MESSAGE, { + const loggerContext = { file: VOICEMAIL_FILE, method: METHODS.RESOLVE_CONTACT, - }); + }; + + log.info(METHOD_START_MESSAGE, loggerContext); const response = this.backendConnector.resolveContact(callingPartyInfo); - log.log('Contact resolution completed successfully', { - file: VOICEMAIL_FILE, - method: METHODS.RESOLVE_CONTACT, - }); + log.log('Contact resolution completed successfully', loggerContext); return response; } diff --git a/packages/calling/src/Voicemail/WxCallBackendConnector.test.ts b/packages/calling/src/Voicemail/WxCallBackendConnector.test.ts index c2cd3f990f9..74190f3ee53 100644 --- a/packages/calling/src/Voicemail/WxCallBackendConnector.test.ts +++ b/packages/calling/src/Voicemail/WxCallBackendConnector.test.ts @@ -108,11 +108,11 @@ describe('Voicemail webex call Backend Connector Test case', () => { }) ); expect(errorSpy).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ + `Failed to get voicemail list: ${JSON.stringify(failurePayload)}`, + { file: 'WxCallBackendConnector', method: 'getVoicemailList', - }) + } ); }); @@ -150,9 +150,7 @@ describe('Voicemail webex call Backend Connector Test case', () => { ); expect(errorSpy).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - message: expect.stringContaining('Failed to mark voicemail as read'), - }), + `Failed to mark voicemail as read: ${JSON.stringify(failurePayload)}`, { file: 'WxCallBackendConnector', method: 'voicemailMarkAsRead', @@ -194,9 +192,7 @@ describe('Voicemail webex call Backend Connector Test case', () => { ); expect(errorSpy).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - message: expect.stringContaining('Failed to mark voicemail as unread'), - }), + `Failed to mark voicemail as unread: ${JSON.stringify(failurePayload)}`, { file: 'WxCallBackendConnector', method: 'voicemailMarkAsUnread', @@ -238,9 +234,7 @@ describe('Voicemail webex call Backend Connector Test case', () => { } ); expect(errorSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Failed to delete voicemail'), - }), + `Failed to delete voicemail: ${JSON.stringify(failurePayload)}`, { file: 'WxCallBackendConnector', method: 'deleteVoicemail', @@ -447,12 +441,13 @@ describe('Voicemail webex call Backend Connector Test case', () => { file: 'WxCallBackendConnector', method: 'getVoicemailSummary', }); + expect(errorSpy).toHaveBeenCalledWith( - expect.any(Error), - expect.objectContaining({ + `Failed to get voicemail summary: ${JSON.stringify(failurePayload)}`, + { file: 'WxCallBackendConnector', method: 'getVoicemailSummary', - }) + } ); }); @@ -570,6 +565,14 @@ describe('Voicemail webex call Backend Connector Test case', () => { headers: {}, }); expect(response).toStrictEqual(responseDetails); + expect(infoSpy).toHaveBeenCalledWith(METHOD_START_MESSAGE, { + file: 'WxCallBackendConnector', + method: 'getVoicemailSummary', + }); + expect(logSpy).toHaveBeenCalledWith('Successfully fetched voicemail summary', { + file: 'WxCallBackendConnector', + method: 'getVoicemailSummary', + }); }); it('verify that PENDING transcription status is passed while transcribing is in progress in the backend', async () => { @@ -879,6 +882,17 @@ describe('Voicemail webex call Backend Connector Test case', () => { const response = await wxCallBackendConnector.voicemailMarkAsRead(messageId.$); expect(response).toStrictEqual(EMPTY_SUCCESS_RESPONSE); + expect(infoSpy).toHaveBeenCalledWith( + `${METHOD_START_MESSAGE} with messageId: ${messageId.$}`, + { + file: 'WxCallBackendConnector', + method: 'voicemailMarkAsRead', + } + ); + expect(logSpy).toHaveBeenCalledWith('Successfully marked voicemail as read', { + file: 'WxCallBackendConnector', + method: 'voicemailMarkAsRead', + }); }); it('verify successful voicemailMarkAsUnread', async () => { @@ -889,6 +903,17 @@ describe('Voicemail webex call Backend Connector Test case', () => { const response = await wxCallBackendConnector.voicemailMarkAsUnread(messageId.$); expect(response).toStrictEqual(EMPTY_SUCCESS_RESPONSE); + expect(infoSpy).toHaveBeenCalledWith( + `${METHOD_START_MESSAGE} with messageId: ${messageId.$}`, + { + file: 'WxCallBackendConnector', + method: 'voicemailMarkAsUnread', + } + ); + expect(logSpy).toHaveBeenCalledWith('Successfully marked voicemail as unread', { + file: 'WxCallBackendConnector', + method: 'voicemailMarkAsUnread', + }); }); it('verify successful deleteVoicemail', async () => { @@ -898,6 +923,17 @@ describe('Voicemail webex call Backend Connector Test case', () => { const response = await wxCallBackendConnector.deleteVoicemail(messageId.$); expect(response).toStrictEqual(EMPTY_SUCCESS_RESPONSE); + expect(infoSpy).toHaveBeenCalledWith( + `${METHOD_START_MESSAGE} with messageId: ${messageId.$}`, + { + file: 'WxCallBackendConnector', + method: 'deleteVoicemail', + } + ); + expect(logSpy).toHaveBeenCalledWith('Successfully deleted voicemail', { + file: 'WxCallBackendConnector', + method: 'deleteVoicemail', + }); }); it('verify resolveContact', async () => { diff --git a/packages/calling/src/Voicemail/WxCallBackendConnector.ts b/packages/calling/src/Voicemail/WxCallBackendConnector.ts index 060fbcd5823..3812640be5d 100644 --- a/packages/calling/src/Voicemail/WxCallBackendConnector.ts +++ b/packages/calling/src/Voicemail/WxCallBackendConnector.ts @@ -1,6 +1,5 @@ /* eslint-disable dot-notation */ /* eslint-disable no-underscore-dangle */ -import ExtendedError from '../Errors/catalog/ExtendedError'; import SDKConnector from '../SDKConnector'; import { RAW_REQUEST, @@ -211,11 +210,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { storeVoicemailList(this.context, messageinfo); } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Failed to get voicemail list: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail list: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } @@ -288,11 +285,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Failed to get voicemail content: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail content: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } @@ -347,11 +342,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Failed to get voicemail summary: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail summary: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } @@ -390,11 +383,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Failed to mark voicemail as read: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to mark voicemail as read: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } @@ -433,13 +424,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error( - `Failed to mark voicemail as unread: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to mark voicemail as unread: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } @@ -479,11 +466,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error(`Failed to delete voicemail: ${err}`) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to delete voicemail: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } @@ -530,13 +515,9 @@ export class WxCallBackendConnector implements IWxCallBackendConnector { return responseDetails; } catch (err: unknown) { - const errorInfo = err as WebexRequestPayload; - const extendedError = new Error( - `Failed to get voicemail transcript: ${err}` - ) as ExtendedError; - log.error(extendedError, loggerContext); + log.error(`Failed to get voicemail transcript: ${JSON.stringify(err)}`, loggerContext); await uploadLogs(); - const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext); + const errorStatus = serviceErrorCodeHandler(err as WebexRequestPayload, loggerContext); return errorStatus; } diff --git a/packages/calling/src/common/Utils.test.ts b/packages/calling/src/common/Utils.test.ts index f3a277e6c5e..2ee8bc7f117 100644 --- a/packages/calling/src/common/Utils.test.ts +++ b/packages/calling/src/common/Utils.test.ts @@ -1807,15 +1807,10 @@ describe('uploadLogs', () => { expect(true).toBe(false); // This will fail the test if no exception is thrown } catch (error) { expect(error).toBe(mockError); - expect(logSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Failed to upload Logs'), - }), - { - file: UTILS_FILE, - method: 'uploadLogs', - } - ); + expect(logSpy).toHaveBeenCalledWith(`Failed to upload Logs ${JSON.stringify(error)}`, { + file: UTILS_FILE, + method: 'uploadLogs', + }); expect(mockSubmitRegistrationMetric).toHaveBeenCalledWith( 'web-calling-sdk-upload-logs-failed', { @@ -1828,7 +1823,7 @@ describe('uploadLogs', () => { tracking_id: undefined, feedback_id: 'mocked-uuid-12345', call_id: undefined, - error: 'Failed to upload Logs Error: Upload failed', + error: `Failed to upload Logs ${JSON.stringify(error)}`, }, tags: {action: 'upload_logs', device_id: undefined, service_indicator: 'calling'}, type: 'behavioral', @@ -1852,15 +1847,10 @@ describe('uploadLogs', () => { const result = await uploadLogs(mockMetaData, false); expect(result).toBeUndefined(); - expect(logSpy).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('Failed to upload Logs'), - }), - { - file: UTILS_FILE, - method: 'uploadLogs', - } - ); + expect(logSpy).toHaveBeenCalledWith(`Failed to upload Logs ${JSON.stringify(mockError)}`, { + file: UTILS_FILE, + method: 'uploadLogs', + }); expect(mockSubmitRegistrationMetric).toHaveBeenCalledWith( 'web-calling-sdk-upload-logs-failed', { @@ -1873,7 +1863,7 @@ describe('uploadLogs', () => { tracking_id: undefined, feedback_id: 'mocked-uuid-12345', call_id: undefined, - error: 'Failed to upload Logs Error: Upload failed', + error: `Failed to upload Logs ${JSON.stringify(mockError)}`, }, tags: {action: 'upload_logs', device_id: undefined, service_indicator: 'calling'}, type: 'behavioral', diff --git a/packages/calling/src/common/Utils.ts b/packages/calling/src/common/Utils.ts index 4b9edebb942..c9e158c044e 100644 --- a/packages/calling/src/common/Utils.ts +++ b/packages/calling/src/common/Utils.ts @@ -4,7 +4,6 @@ import * as platform from 'platform'; import {v4 as uuid} from 'uuid'; import {METRIC_EVENT, METRIC_TYPE, UPLOAD_LOGS_ACTION} from '../Metrics/types'; -import ExtendedError from '../Errors/catalog/ExtendedError'; import {getMetricManager} from '../Metrics'; import {restoreRegistrationCallBack, retry429CallBack} from '../CallingClient/registration/types'; import {CallingClientErrorEmitterCallback} from '../CallingClient/types'; @@ -1681,8 +1680,8 @@ export async function uploadLogs( feedbackId, }; } catch (error) { - const errorLog = new Error(`Failed to upload Logs ${error}`) as ExtendedError; - log.error(errorLog, { + const errorLog = new Error(`Failed to upload Logs ${JSON.stringify(error)}`); + log.error(errorLog.message, { file: UTILS_FILE, method: 'uploadLogs', }); diff --git a/packages/legacy/tools/src/index.ts b/packages/legacy/tools/src/index.ts index 30039dedc45..fd522ef3de2 100644 --- a/packages/legacy/tools/src/index.ts +++ b/packages/legacy/tools/src/index.ts @@ -1,6 +1,5 @@ /** * Library that provides tooling for legacy packages. - * * @packageDocumentation */ diff --git a/yarn.lock b/yarn.lock index acc32470bf5..cf4a7e65e87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3109,15 +3109,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.25.6": - version: 7.25.6 - resolution: "@babel/runtime@npm:7.25.6" - dependencies: - regenerator-runtime: ^0.14.0 - checksum: ee1a69d3ac7802803f5ee6a96e652b78b8addc28c6a38c725a4ad7d61a059d9e6cb9f6550ed2f63cce67a1bd82e0b1ef66a1079d895be6bfb536a5cfbd9ccc32 - languageName: node - linkType: hard - "@babel/runtime@npm:^7.8.4": version: 7.20.7 resolution: "@babel/runtime@npm:7.20.7" @@ -8894,7 +8885,7 @@ __metadata: "@typescript-eslint/eslint-plugin": 5.38.1 "@typescript-eslint/parser": 5.38.1 "@web/dev-server": 0.4.5 - "@webex/internal-media-core": 2.19.0 + "@webex/internal-media-core": 2.20.1 "@webex/internal-plugin-metrics": "workspace:*" "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 @@ -9223,23 +9214,23 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.19.0": - version: 2.19.0 - resolution: "@webex/internal-media-core@npm:2.19.0" +"@webex/internal-media-core@npm:2.20.1": + version: 2.20.1 + resolution: "@webex/internal-media-core@npm:2.20.1" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 "@webex/rtcstats": ^1.5.5 "@webex/ts-sdp": 1.8.2 - "@webex/web-capabilities": ^1.6.1 - "@webex/web-client-media-engine": 3.34.0 + "@webex/web-capabilities": ^1.7.1 + "@webex/web-client-media-engine": 3.35.0 events: ^3.3.0 ip-anonymize: ^0.1.0 typed-emitter: ^2.1.0 uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: dc9b6ec53c2a79d75e3d884afe172c077676a3d5e2ec7c368431a2d207dfcd8a6b75767d91dc42b91b50027faa34ee9ca2ce4faf35e7b8d221d61be6d588865c + checksum: 780f238cefe248f807988d827220ea0c351bf4567ebd7ec9f5023b175bfbbea2f697c68799b9c8def81f4d1c204d788bcb5c8a10c61e3fe366be2c86f6148aa8 languageName: node linkType: hard @@ -9936,19 +9927,7 @@ __metadata: languageName: node linkType: hard -"@webex/ladon-ts@npm:^5.5.1": - version: 5.5.1 - resolution: "@webex/ladon-ts@npm:5.5.1" - dependencies: - "@rollup/rollup-linux-x64-gnu": 4.36.0 - dependenciesMeta: - "@rollup/rollup-linux-x64-gnu": - optional: true - checksum: 2dbe15cce061f66dc6fa79f8d64156f379f3f6b6503d2690ea4d046db8933475abb21319b55c3f6ee2c3299ece4a292355cf8ecbdc0bfc37d2a832a458508a93 - languageName: node - linkType: hard - -"@webex/ladon-ts@npm:^5.7.1": +"@webex/ladon-ts@npm:^5.10.0": version: 5.10.0 resolution: "@webex/ladon-ts@npm:5.10.0" dependencies: @@ -10038,13 +10017,13 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.19.0 + "@webex/internal-media-core": 2.20.1 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" "@webex/test-helper-mock-webex": "workspace:*" "@webex/ts-events": ^1.1.0 - "@webex/web-media-effects": 2.27.1 + "@webex/web-media-effects": 2.32.1 eslint: ^8.24.0 jsdom-global: 3.0.2 prettier: ^2.7.1 @@ -10306,7 +10285,7 @@ __metadata: "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" "@webex/event-dictionary-ts": ^1.0.1930 - "@webex/internal-media-core": 2.19.0 + "@webex/internal-media-core": 2.20.1 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" @@ -10326,7 +10305,7 @@ __metadata: "@webex/test-helper-retry": "workspace:*" "@webex/test-helper-test-users": "workspace:*" "@webex/ts-sdp": ^1.8.1 - "@webex/web-capabilities": ^1.6.0 + "@webex/web-capabilities": ^1.7.1 "@webex/webex-core": "workspace:*" ampersand-collection: ^2.0.2 bowser: ^2.11.0 @@ -11032,70 +11011,55 @@ __metadata: languageName: unknown linkType: soft -"@webex/web-capabilities@npm:^1.6.0": - version: 1.6.0 - resolution: "@webex/web-capabilities@npm:1.6.0" +"@webex/web-capabilities@npm:^1.6.1": + version: 1.6.1 + resolution: "@webex/web-capabilities@npm:1.6.1" dependencies: bowser: ^2.11.0 - checksum: 18473b9f63cd12b13534f69aadee26d737401025a6e8de1485eee778c6f6a87fd7d1fab60d66f8ae3c15bcd7b3db0ffe614994bdaef5dc199261175448980330 + checksum: 0c8d6b818247ed5991411b65f4074087bec46f02a197b76f7f87a143398481e5dc7ccddba257e437a7f321798f5fbb8acaf48bf5fc313a5a46b4dc9350802bc1 languageName: node linkType: hard -"@webex/web-capabilities@npm:^1.6.1": - version: 1.6.1 - resolution: "@webex/web-capabilities@npm:1.6.1" +"@webex/web-capabilities@npm:^1.7.1": + version: 1.7.1 + resolution: "@webex/web-capabilities@npm:1.7.1" dependencies: bowser: ^2.11.0 - checksum: 0c8d6b818247ed5991411b65f4074087bec46f02a197b76f7f87a143398481e5dc7ccddba257e437a7f321798f5fbb8acaf48bf5fc313a5a46b4dc9350802bc1 + checksum: d61efb296bc30478b7fb0242ce0b17963f2f6b53d7d6dad6e7f48f8074097e0306d817a82f2f13e4c15242300b58b667e3063863834030e98a1e67d182df4c64 languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.34.0": - version: 3.34.0 - resolution: "@webex/web-client-media-engine@npm:3.34.0" +"@webex/web-client-media-engine@npm:3.35.0": + version: 3.35.0 + resolution: "@webex/web-client-media-engine@npm:3.35.0" dependencies: "@webex/json-multistream": ^2.3.1 "@webex/rtcstats": ^1.5.5 "@webex/ts-events": ^1.2.1 "@webex/ts-sdp": 1.8.2 - "@webex/web-capabilities": ^1.6.1 - "@webex/web-media-effects": 2.28.2 - "@webex/webrtc-core": 2.13.3 + "@webex/web-capabilities": ^1.7.1 + "@webex/web-media-effects": 2.32.1 + "@webex/webrtc-core": 2.13.4 async: ^3.2.4 js-logger: ^1.6.1 typed-emitter: ^2.1.0 uuid: ^8.3.2 - checksum: aae84de432aa875d242712fabb169664636cd403c8a83fc69ee854acc6282e7afbc88b0cbca0dfb49eb4af1c0c9ab0a4b7b2a3bfc24d340c5bb4ac4cc62b4027 + checksum: b81394b8bae2d1ea6212ca8ee5ced790558ff1504ba98c17752b2b1b09a415ae9266498f8d6d063bf08e05366903f9f6ca3c5de4a388ab05d544fa83fc7c6a72 languageName: node linkType: hard -"@webex/web-media-effects@npm:2.27.1": - version: 2.27.1 - resolution: "@webex/web-media-effects@npm:2.27.1" +"@webex/web-media-effects@npm:2.32.1": + version: 2.32.1 + resolution: "@webex/web-media-effects@npm:2.32.1" dependencies: - "@webex/ladon-ts": ^5.5.1 - events: ^3.3.0 - js-logger: ^1.6.1 - typed-emitter: ^1.4.0 - uuid: ^9.0.1 - worker-timers: ^8.0.2 - yarn: ^1.22.22 - checksum: e8bfb73860bfc25fa730d13b713a5cf3851d861cf6616343f4ce84d68cb085f2d2251e6788735f4671499324bee6e35b9d708fe1fdb0770695c116e2b55227e6 - languageName: node - linkType: hard - -"@webex/web-media-effects@npm:2.28.2": - version: 2.28.2 - resolution: "@webex/web-media-effects@npm:2.28.2" - dependencies: - "@webex/ladon-ts": ^5.7.1 + "@webex/ladon-ts": ^5.10.0 events: ^3.3.0 js-logger: ^1.6.1 typed-emitter: ^1.4.0 uuid: ^9.0.1 worker-timers: ^8.0.21 yarn: ^1.22.22 - checksum: bc16791b2c3eb0937d8d4f25c0e22673f45bb2e6580a829f43d04ad34095e71b7983e3fb5a5c5e4454f04256cc46e3fcd4816aab6bfb493e5a39ee303ebd89f5 + checksum: d7ccbea8080c7a085a2808b8826b9573daaf060fcbecdd83ba2f0e347a395169f4f5cdcd21bb87571d460a60a7ee3655cf977126cda44d8c461658e7019289e0 languageName: node linkType: hard @@ -11189,18 +11153,18 @@ __metadata: languageName: unknown linkType: soft -"@webex/webrtc-core@npm:2.13.3": - version: 2.13.3 - resolution: "@webex/webrtc-core@npm:2.13.3" +"@webex/webrtc-core@npm:2.13.4": + version: 2.13.4 + resolution: "@webex/webrtc-core@npm:2.13.4" dependencies: "@webex/ts-events": ^1.2.1 "@webex/web-capabilities": ^1.6.1 - "@webex/web-media-effects": 2.28.2 + "@webex/web-media-effects": 2.32.1 events: ^3.3.0 js-logger: ^1.6.1 typed-emitter: ^2.1.0 webrtc-adapter: ^8.1.2 - checksum: 9b78656d0f14e961343b98f2338fae12dc2da39c8bbb973c648deeb012f7c0d9c49a58fbf24183d8ba71ea9ef36e2a858ecb9d217aed140dd70cdc03c07bf902 + checksum: ee5e7b1896b1c976756975e834913d6b169c8ac634cb42982de560fd215e1a111dc06ce08bed3f19cdc4a4c315d2476266da322095469c3fd91d49957ad6a50d languageName: node linkType: hard @@ -19113,16 +19077,6 @@ __metadata: languageName: node linkType: hard -"fast-unique-numbers@npm:^9.0.9": - version: 9.0.9 - resolution: "fast-unique-numbers@npm:9.0.9" - dependencies: - "@babel/runtime": ^7.25.6 - tslib: ^2.7.0 - checksum: 58481531260a91d57859a631378609368a5c3828a36da2e833f1b82f205eec7fe698df76151af84a4bbb1bf911ed9659b79e646d418462d7981e0e24e48f6394 - languageName: node - linkType: hard - "fast-url-parser@npm:1.1.3": version: 1.1.3 resolution: "fast-url-parser@npm:1.1.3" @@ -33796,13 +33750,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.7.0": - version: 2.7.0 - resolution: "tslib@npm:2.7.0" - checksum: 1606d5c89f88d466889def78653f3aab0f88692e80bb2066d090ca6112ae250ec1cfa9dbfaab0d17b60da15a4186e8ec4d893801c67896b277c17374e36e1d28 - languageName: node - linkType: hard - "tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" @@ -36099,18 +36046,6 @@ __metadata: languageName: node linkType: hard -"worker-timers-broker@npm:^7.1.2": - version: 7.1.2 - resolution: "worker-timers-broker@npm:7.1.2" - dependencies: - "@babel/runtime": ^7.25.6 - fast-unique-numbers: ^9.0.9 - tslib: ^2.7.0 - worker-timers-worker: ^8.0.4 - checksum: ec2deb097662ef2331cdf4681023fe970504cd30b58cbf13ceab0741d05fd21a49e518c73f03b20a01e2684d9b8d6d59787aed82d05e615be99c8dc0229623c4 - languageName: node - linkType: hard - "worker-timers-broker@npm:^8.0.11": version: 8.0.11 resolution: "worker-timers-broker@npm:8.0.11" @@ -36124,16 +36059,6 @@ __metadata: languageName: node linkType: hard -"worker-timers-worker@npm:^8.0.4": - version: 8.0.4 - resolution: "worker-timers-worker@npm:8.0.4" - dependencies: - "@babel/runtime": ^7.25.6 - tslib: ^2.7.0 - checksum: fd59d4c947895efd036e46cd8d4c288b228256f7bac24ff5b83c682ef44e53584ce8bc4f0525eee469be00dbf1f89e9266682d0297df7478704996087a7553b2 - languageName: node - linkType: hard - "worker-timers-worker@npm:^9.0.11": version: 9.0.11 resolution: "worker-timers-worker@npm:9.0.11" @@ -36145,18 +36070,6 @@ __metadata: languageName: node linkType: hard -"worker-timers@npm:^8.0.2": - version: 8.0.5 - resolution: "worker-timers@npm:8.0.5" - dependencies: - "@babel/runtime": ^7.25.6 - tslib: ^2.7.0 - worker-timers-broker: ^7.1.2 - worker-timers-worker: ^8.0.4 - checksum: e8c00e33e5af252a37472c0fc1bc3c3b26cdd174e179ab1b301364b880db7e560f6f3eb82a4438970fe113c1e9a4855569df0ef61edc6d5de613a51789587ef4 - languageName: node - linkType: hard - "worker-timers@npm:^8.0.21": version: 8.0.25 resolution: "worker-timers@npm:8.0.25"